文章目录
缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:
-
接口层增加校验,过滤掉参数不合法的请求
-
可以将查询结果为不存在的key同样保存到缓存中,value为null,并且设置自动过期时间,这种方式可以避免短时间集中的暴力攻击
-
可以使用布隆过滤器,可以过滤掉绝大部分不存在的key。布隆过滤器及实现方式总结
缓存击穿
缓存击穿是指大量的请求绕过Redis打到了DB上,和缓存穿透不同的是,缓存击穿一般查询的都是DB中存在的数据。一般是由于在超高并发场景下,大量请求正在访问某一个热点key,但此时该热点key忽然过期。导致这些大量的并发同时打到了DB如MySQL上。
解决方案:
- 可以使用分布式锁,这种方式可以使最终只有一个请求打到MySQL上,该请求从DB中查到数据后更新缓存,后续的请求便可以直接从缓存中获取数据了。
- 如果可以提前锁定哪些是热点key,那么可以给热点key设置永不过期。
缓存雪崩
缓存雪崩是指Redis忽然失去了原有的承载高并发的功能。比如redis宕机,或redis中大量的key在某个时间段集中失效。导致了大量的请求都会直接打到DB上。
缓存雪崩和缓存击穿的区别是,缓存雪崩的影响面通常是大范围的,不限于某个热点key。而缓存击穿主要是集中在某些热点key上。
解决方案:
- 为了防止redis中大部分的key同时失效,可以为不同的key根据不同的策略来分配不同的过期时间。
- 为了防止redis宕机,可以为Redis搭建高可用集群架构。比如哨兵机制,Redis Cluster集群。最大程度的保证Redis的可用状态。
缓存穿透,缓存击穿,缓存雪崩的区别
Redis中是否有数据 | DB中是否有数据 | 影响面 | |
---|---|---|---|
缓存穿透 | 无 | 无 | 一般 |
缓存击穿 | 无 | 有 | 部分热点key |
缓存雪崩 | 无 | 有 | 广 |
缓存双写一致性
缓存双写一致性指的是每次更新数据,要保证缓存中的数据要与数据库中的数据一致。
但是在多线程并发情况下,由于更新数据库和更新缓存这两个操作不是原子操作,所以可能会导致缓存中的数据数据库中的数据不一致的情况。
我们先来分析以下我们更新数据库和缓存的常见方式:
先更新数据库,再更新缓存(不推荐)
这种方式现在一般不推荐使用。原因是在多线程场景下有可能会出下以下情况:
- 线程A先更新了数据库,此时可能由于网络波动的原因,还没有来得及更新缓存
- 线程B也要修改该数据,也更新了数据库
- 线程B修改完数据库后,更新了缓存,此时缓存中为线程B修改后的最新数据
- 线程A此时才开始更新缓存,当线程A更新完缓存后,缓存中的数据就是线程A修改的旧数据
通过上图可以看出,先更新数据库,再更新缓存,在多线程场景下,可能会导致缓存中的数据不是数据库中的最新数据。
所以这种方式一般不推荐使用。
先删除缓存,再更新数据库
这种方式在并发场景下仍然有可能出现问题。比如:
- 线程A先删除了缓存,但是此时还没有来得及更新数据库
- 线程B查询缓存,发现没有数据,则到数据库中查询
- 线程B到数据库查询到了还没有更新的旧数据,并且将这个旧数据重新写入了缓存中
- 此时线程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("响应结果");
}
先更新数据库,再删除缓存
先更新数据库,再删除缓存,仍然存在着一些问题。比如:
- 线程A更新了数据库,但是还没有进行删除缓存
- 此时线程B查询该数据,先到缓存中查询,发现有数据。直接获取缓存中的数据返回。
- 那么线程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。 这个可以自行了解一下。