缓存穿透,缓存击穿,缓存雪崩及缓存双写一致性解决方案

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案:

  1. 接口层增加校验,过滤掉参数不合法的请求

  2. 可以将查询结果为不存在的key同样保存到缓存中,value为null,并且设置自动过期时间,这种方式可以避免短时间集中的暴力攻击

  3. 可以使用布隆过滤器,可以过滤掉绝大部分不存在的key。布隆过滤器及实现方式总结

缓存击穿

缓存击穿是指大量的请求绕过Redis打到了DB上,和缓存穿透不同的是,缓存击穿一般查询的都是DB中存在的数据。一般是由于在超高并发场景下,大量请求正在访问某一个热点key,但此时该热点key忽然过期。导致这些大量的并发同时打到了DB如MySQL上。

解决方案:

  1. 可以使用分布式锁,这种方式可以使最终只有一个请求打到MySQL上,该请求从DB中查到数据后更新缓存,后续的请求便可以直接从缓存中获取数据了。
  2. 如果可以提前锁定哪些是热点key,那么可以给热点key设置永不过期。

缓存雪崩

缓存雪崩是指Redis忽然失去了原有的承载高并发的功能。比如redis宕机,或redis中大量的key在某个时间段集中失效。导致了大量的请求都会直接打到DB上。

缓存雪崩和缓存击穿的区别是,缓存雪崩的影响面通常是大范围的,不限于某个热点key。而缓存击穿主要是集中在某些热点key上。

解决方案:

  1. 为了防止redis中大部分的key同时失效,可以为不同的key根据不同的策略来分配不同的过期时间。
  2. 为了防止redis宕机,可以为Redis搭建高可用集群架构。比如哨兵机制,Redis Cluster集群。最大程度的保证Redis的可用状态。

缓存穿透,缓存击穿,缓存雪崩的区别

Redis中是否有数据DB中是否有数据影响面
缓存穿透一般
缓存击穿部分热点key
缓存雪崩广

缓存双写一致性

缓存双写一致性指的是每次更新数据,要保证缓存中的数据要与数据库中的数据一致。

但是在多线程并发情况下,由于更新数据库和更新缓存这两个操作不是原子操作,所以可能会导致缓存中的数据数据库中的数据不一致的情况。

我们先来分析以下我们更新数据库和缓存的常见方式:

先更新数据库,再更新缓存(不推荐)

这种方式现在一般不推荐使用。原因是在多线程场景下有可能会出下以下情况:

  1. 线程A先更新了数据库,此时可能由于网络波动的原因,还没有来得及更新缓存
  2. 线程B也要修改该数据,也更新了数据库
  3. 线程B修改完数据库后,更新了缓存,此时缓存中为线程B修改后的最新数据
  4. 线程A此时才开始更新缓存,当线程A更新完缓存后,缓存中的数据就是线程A修改的旧数据
    在这里插入图片描述

通过上图可以看出,先更新数据库,再更新缓存,在多线程场景下,可能会导致缓存中的数据不是数据库中的最新数据。

所以这种方式一般不推荐使用。

先删除缓存,再更新数据库

这种方式在并发场景下仍然有可能出现问题。比如:

  1. 线程A先删除了缓存,但是此时还没有来得及更新数据库
  2. 线程B查询缓存,发现没有数据,则到数据库中查询
  3. 线程B到数据库查询到了还没有更新的旧数据,并且将这个旧数据重新写入了缓存中
  4. 此时线程A才将新数据更新到数据库中

在这里插入图片描述
如果出现了上图中的场景,那么可能会导致两个问题:

  • 会导致缓存中数据为脏数据。
  • 在时间点3的时候,如果对该条数据的并发查询请求过多,可能会出现缓存击穿现象。

针对先删除缓存,再更新数据库的方式可能出现缓存脏数据的情况,可以采用延时双删的策略。

延时双删

是在删除缓存并更新数据库后,隔一定的时间,再次删除缓存。这样可以避免并发场景下另一个线程将旧数据写入缓存中的情况。

伪代码:

/**
*
* 更新数据
*/
public void update() {
	// 先删除缓存中的数据
	redis.delete();
	// 更新数据库中的数据
	dao.update();
	// 线程休眠一段时间
	Thread.sleep(2000);
	// 再次删除缓存中的数据
	redis.delete();
}

在这里插入图片描述
当再次删除缓存后,后续的查询请求,会再次到数据库中获取最新数据并写到缓存中去,这样就保证了缓存中的数据是最新数据,从而避免了脏数据。

异步延时双删

当然,延时双删策略因为有线程的休眠,所以会导致方法的执行时间较长,那么可以在这个基础上,将第二次删除缓存的操作改为异步执行,从而提升用户体验。

异步延时双删 伪代码:

/**
*
* 更新数据
*/
public void update() {
	// 先删除缓存中的数据
	redis.delete();
	// 更新数据库中的数据
	dao.update();
	// 开启异步线程
	new Thread( ()-> {
		//异步线程休眠一段时间
		Thread.sleep(2000);
		// 再次删除缓存中的数据
		redis.delete();
	}).start();
	System.out.println("响应结果");
}
先更新数据库,再删除缓存

先更新数据库,再删除缓存,仍然存在着一些问题。比如:

  1. 线程A更新了数据库,但是还没有进行删除缓存
  2. 此时线程B查询该数据,先到缓存中查询,发现有数据。直接获取缓存中的数据返回。
  3. 那么线程B获取的数据就是脏数据。

在这里插入图片描述
其实对比上面两种方式,这种方式可能导致的问题就会小很多。但是也还不是完美的。因为目前还没有一种完美的缓存一致性解决方案。

这种方式只会导致短暂的查询到旧数据的情况,但是不会导致一直查询到的都是脏数据。

使用canal订阅binlog日志,删除缓存

当然,如果有极端情况,比如删除缓存失败了。也会导致后续的查询,获得的都是缓存中的旧数据。

要解决这个问题,可以使用阿里的canal框架。监听MySQL的binlog文件,当文件发生变动,代表数据出现了更新,那么获取变动的数据,另起一段程序,进行缓存删除的操作。

这个抽空会专门用一篇文章来说。

分布式读写锁解决缓存双写一致性问题(终极)

其实上述这些缓存更新的方式,出现问题的原因,归根结底就一个:有多线程的问题。

其实这里有一个终极解决方案,你大概已经猜出来了。

没错,就是分布式锁。

使用分布式锁可以避免上述一切问题。当然不可避免会损耗部分的性能。这里我们还可以对分布式锁继续做一下优化。可以使用分布式读写锁。

使用分布式读写锁后,在并发查询的情况下,相当于没有加锁。只有在并发写-写和并发读-写才会加锁。

伪代码:

/**
*
* 更新数据
*/
public void update() {
	try{
		// 获取写锁
		if(redis.getWriteLock(key)){
			// 更新数据库中的数据
            int count = dao.update();
            // 如果数据库更新成功,更新缓存中的数据
            if (count > 0) {
            	redis.set();
            }
		}	
	}finally{
		// 解锁
		redis.unlock(key);
	}
}

/**
*
* 查询数据
*/
public void get() {
	
	try{
		// 获取读锁
		if(redis.getReadLock(key)) {
			// 先从缓存中获取数据
			Object result = redis.get();
			// 缓存中没有,去数据库查
			if(result == null) {
				result = dao.get();
			}
			// 数据库查到,回写进缓存,使用setnx:如果不存在,则添加
			if(result != null) {
				redis.setnx();
			}
			return result;
		}	
	}finally{
		// 解锁
		redis.unlock(key);
	}	
}

使用分布式锁可以避免上述一切问题。当然会损耗部分的性能,但,鱼与熊掌不可兼得,不是吗?

分布式读写锁有多种实现方式,比如Redisson。 这个可以自行了解一下。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值