如何解决缓存击穿?
# 面试话术
缓存击穿指在高并发的系统中,一个热点数据缓存过期或者在缓存中不存在,导致大量并发请求直接访问数据库,从而给数据库造成巨大压力,甚至可能引起宕机。
具体来说,当某个热点数据在缓存中过期时,如果此时有大量并发请求同时访问这个数据,由于缓存中不存在,所有请求都会直接访问数据库,导致数据库负载急剧增加。

一般来说,解决缓存击穿的主要方法分为三种:加分布式锁、热点数据预加载以及热点数据永不过期。
# 1. 分布式锁
分布式锁的解决方案就是保证只有一个请求可以访问数据库,其它请求等待结果。这样可以避免大量的请求同时访问数据库。

但是这种的话有一个弊端,那就是获取分布式锁的请求,都会执行一遍查询数据库,并更新到缓存。理论上只有第一个加载数据库记录请求是有效的。所以可以通过双重判定锁的形式,在获取到分布式锁之后,再次查询一次缓存是否存在,如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。这样就可以避免大量请求访问数据库。双重判定锁有效提升了解锁性能以及数据库访问。

# 2. 热点数据预加载
热点数据预加载,指的是在活动或者大促开始前,针对已知的热点数据从数据库加载到缓存中,这样可以避免海量请求第一次访问热点数据需要从数据库读取的流程。可以极大减少请求响应时间,有效避免缓存击穿。
# 3. 热点数据永不过期
热点数据永不过期,指的就是可以预知的热点数据,在活动开始前,设置过期时间为-1,这样的话,就不会有缓存击穿的风险。这个可以搭配热点数据预加载一起完成。等对应热点缓存的活动结束后,这些数据访问量就比较低了,可以通过后台任务的方案对指定缓存设置过期时间,这样可以有效降低 Redis 存储压力。
# 问题详解
我们可以从分布式锁的角度上,分析如何通过互斥锁完成缓存击穿场景解决方案?
# 1. 查询缓存不存在请求数据库
第一版,查询数据缓存是否存在,不存在的话请求数据库,数据库存在则把当前数据回写到缓存。
问题比较明显,如果缓存过期或被删除,大量请求就会全部请求到数据库,导致数据库压力暴涨。
伪代码如下:
public String selectTrain(String id) {
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
cahce.set(id, dbData);
cacheData = dbData;
}
}
return cacheData;
}
# 2. 分布式互斥锁优化数据库压力
在获取数据时,使用分布式锁(如 Redis 的分布式锁)来控制同时只有一个请求可以去后端获取数据,其他请求需要等待锁释放。这样可以防止多个请求同时穿透到后端存储。

在原有基础上继续改进,伪代码如下:
public String selectTrain(String id) {
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
Lock lock = getLock(id);
lock.lock();
try {
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
cahce.set(id, dbData);
cacheData = dbData;
}
} finally {
lock.unlock();
}
}
return cacheData;
}
这种方案有效地避免了缓存穿透问题,因为只有一个线程能够在同一时间内查询数据库,其他线程需要等待,不会同时穿透到后端存储系统。
完美解决问题,但是很多老板比较疑惑,为什么我在之前很多文档里都说了双重判定锁这个东西?这玩意是个啥,跟着马哥一起往下看,技术揭秘。
# 3. 双重判定锁
上边还有一个问题就是,假如 100w 的请求读取一个缓存,100w 的请求全部卡在 lock.lock 获取分布式锁处,只有一个线程会执行逻辑请求数据库并放入缓存。问题来了,因为接下来的所有请求,99.99...w 还是会继续请求数据库,大家读一下上面的伪代码就明白了。
这会造成两个实际的问题:
- 全部用户获取锁后查询数据库,会对数据库造成无用的性能浪费,因为这 100w 的请求,只有第一次是有效的。
- 查询数据库会造成用户响应时间变长,接口吞吐量下降。
双重判断:获取锁后,在查询数据库之前,再次检查一下缓存中是否存在数据。这是一个双重判断,如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。

伪代码如下:
public String selectTrain(String id) {
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
Lock lock = getLock(id);
lock.lock();
try {
cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
cahce.set(id, dbData);
cacheData = dbData;
}
}
} finally {
lock.unlock();
}
}
return cacheData;
}
下面是这种解决方案的一般步骤:
- 获取锁:在查询数据库前,首先尝试获取一个分布式锁。只有一个线程能够成功获取锁,其他线程需要等待。
- 查询数据库:如果双重判断确认数据确实不存在于缓存中,那么就执行查询数据库的操作,获取数据。
- 写入缓存:获取到数据后,将数据写入缓存,并设置一个合适的过期时间,以防止缓存永远不会被更新。
- 释放锁:最后,释放获取的锁,以便其他线程可以继续使用这个锁。