Redis——缓存击穿
文章目录
- 1. 问题介绍
- 1.1 定义
- 1.2 举例
- 2. 解决方案
- 2.1 方案一:互斥锁
- 2.1.1 做法
- 2.1.2 流程
- 2.1.3 示例代码
- 2.1.4 优点
- 2.1.5 缺点
- 2.1.6 适用场景
- 2.2 方案二:逻辑过期
- 2.2.1 做法
- 2.2.2 流程
- 2.2.3 示例代码
- 2.2.4 优点
- 2.2.5 缺点
- 2.2.6 适用场景
- 2.3 方案三:限流
- 3. 总结
1. 问题介绍
1.1 定义
缓存击穿:在一个热点缓存过期后的短时间内 (数据还未缓存到 Redis 中之前),如果有大量 并发请求 查询这个缓存,则这些请求会直接访问 MySQL 数据库,导致 MySQL 压力过大,从而宕机。
也可以把缓存击穿理解为 大量并发请求查询过期热点缓存,直接冲击 MySQL,导致其宕机。
1.2 举例
对于请求 goods/10001
,如果它的缓存过期了,并且从查询数据库到将其缓存到 Redis 共需 30ms,则在这 30ms 中,每个 goods/10001
请求都会查询数据库,假如有大量的请求,则会导致 MySQL 宕机。
2. 解决方案
从定义和举例中可以看出,缓存击穿的核心问题在于 热点缓存过期导致超多线程同时查询 MySQL,所以有两种解决的思路:
- 防止多线程查询同一个数据。
- 不让热点缓存过期。
2.1 方案一:互斥锁
2.1.1 做法
在查询 MySQL 时,针对这个数据加互斥锁,所有查询此数据的线程都需要等待互斥锁释放,然后再次查询缓存,此时缓存中如果有需要的数据,则直接返回,如果没有,则继续查询 MySQL 数据库。
2.1.2 流程
实际上这种思想和单例模式的 双重检查 很像,都是先查看值是否为 null
,如果为 null
,则加锁,获取锁之后再查看值是否为 null
,如果为 null
,才进行初始化;否则返回不为 null
的值即可。
双重检查的流程图如下所示:
本做法的流程图如下所示:
2.1.3 示例代码
注:在代码中,设置分布式互斥锁的过期时间为 5s,这个时间对于大多数查询请求都是足够的,但对于某些比较复杂的多表查询,可能会导致锁过期,这属于分布式锁的续期问题,之后会详细讲解,现在暂时使用它。
public Goods get(long goodsId) {
// 获取缓存对应的键
String key = "goods:" + goodsId;
// 如果缓存中有对应的数据,则直接返回
Goods goods = (Goods) redisTemplate.opsForValue().get(key);
if (goods != null) {
return goods;
}
// 获取互斥锁,如果获取互斥锁失败,则休眠一段时间后重试
String lockKey = "goods:lock:" + goodsId;
if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS))) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return get(goodsId); // 递归调用
}
try {
// 获取到锁后,再次检查缓存,防止其他线程在获取锁期间将数据写入缓存
goods = (Goods) redisTemplate.opsForValue().get(key);
if (goods != null) {
return goods;
}
// 从数据库中查询,如果数据库中有数据,则缓存查询到的数据
goods = goodsMapper.getById(goodsId);
if (goods != null) {
redisTemplate.opsForValue().set(key, goods, 3, TimeUnit.MINUTES);
}
} finally {
// 释放互斥锁
redisTemplate.delete(lockKey);
}
// 返回查询到的对象
return goods;
}
2.1.4 优点
- 实现简单:实际上这种 加锁防止多次操作 的思想在单例模式中就能学到,实现起来也相对简单,之后要讲的分布式锁续期也可以通过框架来实现。
- 一致性强:在查询 MySQL 时,由于加了分布式互斥锁,所以全局只有一个线程去查询 MySQL,其它线程都在等待这个线程查询到的结果,不会返回之前过期的数据(这是相对 方案二 而言的)。
2.1.5 缺点
- 性能开销大:锁的获取和释放有一定的性能损耗,在高并发环境下,大量线程竞争锁会导致线程的上下文切换频繁,增加系统的开销。
- 响应时间长:当有大量请求同时发现缓存过期并尝试获取分布式互斥锁时,这些请求会被阻塞,直到锁被释放,从而导致响应时间增加。
在示例代码中,锁竞争失败后先休眠了 100ms,减少了锁之间的竞争,从而减少性能开销,但是会导致响应时间变长。
2.1.6 适用场景
加互斥锁的方案适合 要求数据一致性强、可容忍响应时间长 的场景,比如银行的转账业务,需要返回数据库中最新的数据。
2.2 方案二:逻辑过期
2.2.1 做法
给缓存加上一个 expire
字段,代表它的逻辑过期时间,无需设置缓存的实际过期时间。在处理查询请求时,获取到缓存先不着急返回,而是查看这个缓存是否逻辑过期,如果逻辑过期,则获取互斥锁,如果获取成功,则让别的线程去查询 MySQL、缓存新数据 和 释放互斥锁,这个线程先返回旧数据;如果获取失败,则返回旧数据;如果没有逻辑过期,直接返回缓存数据即可。
2.2.2 流程
2.2.3 示例代码
注:本示例代码中使用 new Thread()
开启了一个新线程,在生产中不要这样做,而是使用线程池,避免频繁创建线程浪费时间。除此之外,由于使用到了分布式互斥锁,所以也会存在续期问题。
public class Goods { // 商品类
private long goodsId;
private long expire;
public long getGoodsId() { return goodsId; }
public long getExpire() { return expire; }
public void setGoodsId(long goodsId) { this.goodsId = goodsId; }
public void setExpire(long expire) { this.expire = expire; }
}
public Goods get(long goodsId) {
// 获取缓存对应的键
String key = "goods:" + goodsId;
// 如果缓存中没有对应的数据,则查询 MySQL
Goods goods = (Goods) redisTemplate.opsForValue().get(key);
if (goods == null) {
// 如果数据库中有数据,则缓存查询到的数据
goods = goodsMapper.getById(goodsId);
if (goods != null) {
goods.setExpire(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3)); // 设置 3min 的逻辑过期时间
redisTemplate.opsForValue().set(key, goods);
}
// 返回新数据
return goods;
}
// 如果缓存中有数据,则判断数据是否过期,如果没有过期,则直接返回
long expire = goods.getExpire();
if (expire > System.currentTimeMillis()) {
return goods;
}
// 获取互斥锁,如果获取互斥锁失败,说明已经有线程去更新数据了,本线程直接返回旧数据即可
String lockKey = "goods:lock:" + goodsId;
if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS))) {
return goods;
}
// 开启一个新线程去更新数据
new Thread(() -> {
try {
// 查询 MySQL
Goods newGoods = goodsMapper.getById(goodsId);
if (newGoods != null) {
// 更新缓存数据
newGoods.setExpire(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(3)); // 设置 3min 的逻辑过期时间
redisTemplate.opsForValue().set(key, newGoods);
}
} finally {
// 释放互斥锁
redisTemplate.delete(lockKey);
}
}).start();
// 本线程先返回旧数据
return goods;
}
2.2.4 优点
- 性能开销小:当查询到旧数据时,全局只有一个线程查询 MySQL,不会有线程之间频繁切换的问题。但如果旧数据很多,则 针对每个旧数据都使用新线程查询 会占用较多的 CPU 资源。
- 响应时间短:由于查询到旧数据直接返回,所以响应时间会很短。
2.2.5 缺点
- 实现复杂:相对于方案一,本方案需要使用线程池,并且还要给对象添加一个
expire
字段表示逻辑过期,增加了实现的复杂度。 - 一致性弱:在另一个线程更新缓存的时间段内,只能获取到旧数据。但更新完毕后,就可以获取到新数据了。从而保证了数据的 最终一致性。
- 缓存数据太多:由于没有给缓存设置物理过期时间,所以缓存永不过期,如果缓存太多数据,则根据 Redis 配置的缓存淘汰策略不同,会造成不同程度的影响(之后会讲)。
2.2.6 适用场景
逻辑过期的方案适合 要求响应时间短、数据只需要最终一致 的场景,比如视频的详情查询,暂时查询不到最新的点赞量、浏览量也无所谓。
2.3 方案三:限流
限流对于缓存击穿问题仍是一种不错的解决方案,加载控制器层。如果前两种方案都无法实现,则可以采取这种方案。
3. 总结
缓存击穿指的是热点数据过期后,短时间内大量查询请求穿透 Redis,直接冲击 MySQL,导致其宕机。
解决方案主要有两种:
- 互斥锁:在缓存过期后,全局只允许一个线程查询 MySQL。适用于数据强一致性的场景。
- 逻辑过期:无需给缓存设置物理过期时间,而给缓存设置逻辑过期时间,数据过期后先返回旧数据,使用另一个线程(这个线程也是全局唯一的,使用分布式互斥锁保证)查询并缓存新数据。适用于响应时间短的场景。
- 此外,限流也可以作为一种保底方案处理缓存击穿问题。