缓存穿透、缓存击穿、缓存雪崩概念及解决方案
一、缓存击穿
概念
一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。
案例
@Service
public class UserInfoServiceImpl implements UserInfoService {
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate redisTemplate;
@Override
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
//如果缓存中不存在,查询数据库
//1
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if(userInfo == null){
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//2
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
private boolean isEmpty(String string) {
return !StringUtils.hasText(string);
}
}
流程如下:

在//1到//2之间耗时1.5秒,那就代表着在这1.5秒时间内所有的查询都会走查询数据库。这也就是我们所说的缓存中的缓存击穿。
其实,你们项目如果并发量不是很高,也不用怕,并且我见过很多项目也就差不多是这么写的,也没那么多事,毕竟只是第一次的时候可能会发生缓存击穿。
但,我们也不要抱着一个侥幸的心态去写代码,既然是多线程导致的,估计很多人会想到锁,下面我们使用锁来解决。
锁
既然使用到锁,那么我们第一时间应该关心的是锁的粒度。
如果我们放在方法findById上,那就是所有查询都会有锁的竞争,这里我相信大家都知道我们为什么不放在方法上。
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
//只有不存的情况存在锁
synchronized (UserInfoServiceImpl.class){
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if(userInfo == null){
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
看似解决问题了,其实问题还是没得到解决,还是会缓存击穿,因为排队获取到锁后,还是会执行同步块代码,也就是还会查询数据库,完全没有解决缓存击穿。
双重检查锁
由此,我们引入双重检查锁,我们在上的版本中进行稍微改变,在同步模块中再次校验缓存中是否存在。
public UserInfo findById(Long id) {
//查询缓存
String userInfoStr = redisTemplate.opsForValue().get(id);
//第一次校验缓存是否存在
if (isEmpty(userInfoStr)) {
//上锁
synchronized (UserInfoServiceImpl.class){
//再次查询缓存,目的是判断是否前面的线程已经set过了
userInfoStr = redisTemplate.opsForValue().get(id);
//第二次校验缓存是否存在
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
//数据库中不存在
if(userInfo == null){
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
//放入缓存
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
互斥锁
代码实现和synchronized锁实现大致相同
//获取Redisson对象实例 省略...
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
//判断缓存是否存在,是否为空对象
if (isEmpty(userInfoStr)) {
RLock lock = Redisson.getLock(id);
try{
//尝试获取锁
//waitTimeout尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
//leaseTime锁的持有时间,超过这个时间锁会自动失效
//leaseTime值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完
if(lock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS)){
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
if(userInfo == null){
//构建一个空对象
userInfo = new UserInfo();
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
} finally {
lock.unlock();
}
}
UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
//空对象处理
if(userInfo.getId() == null){
return null;
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
这样,看起来我们就解决了缓存击穿问题,大家觉得解决了吗?
小结
在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。
二、缓存穿透
概念
访问一个不存在的key,缓存不起作用,请求会穿透到DB,流量大时DB会挂掉。
案例
回顾上面的案例,在正常的情况下是没问题,但是一旦有人恶意攻击呢?比如说:入参id=-1,在数据库里并没有这个id,怎么办呢?
第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,直接返回了,并没有操作缓存
第四步、再次执行第一步.....死循环了吧
方案1:设置空对象
就是当缓存中和数据库中都不存在的情况下,以id为key,空对象为value。
set(id,空对象);
回到上面的四步,就变成了。
比如说:入参id=-1,在数据库里并没有这个id,怎么办呢?
第一步、缓存中不存在
第二步、查询数据库
第三步、由于数据库中不存在,以id为key,空对象为value放入缓存中
第四步、执行第一步,此时,缓存就存在了,只是这时候只是一个空对象。
代码实现部分:
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
//判断缓存是否存在,是否为空对象
if (isEmpty(userInfoStr)) {
synchronized (UserInfoServiceImpl.class){
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
UserInfo userInfo = userMapper.findById(id);
if(userInfo == null){
//构建一个空对象
userInfo= new UserInfo();
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
UserInfo userInfo = JSON.parseObject(userInfoStr, UserInfo.class);
//空对象处理
if(userInfo.getId() == null){
return null;
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
方案2:布隆过滤器
具体请参考
private static Long size = 1000000000L;
private static BloomFilter bloomFilter = BloomFilter.create(Funnels.longFunnel(), size);
@Override
public UserInfo findById(Long id) {
String userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr)) {
//校验是否在布隆过滤器中
if(bloomFilter.mightContain(id)){
return null;
}
synchronized (UserInfoServiceImpl.class){
userInfoStr = redisTemplate.opsForValue().get(id);
if (isEmpty(userInfoStr) ) {
if(bloomFilter.mightContain(id)){
return null;
}
UserInfo userInfo = userMapper.findById(id);
if(userInfo == null){
//放入布隆过滤器中
bloomFilter.put(id);
return null;
}
userInfoStr = JSON.toJSONString(userInfo);
redisTemplate.opsForValue().set(id, userInfoStr);
}
}
}
return JSON.parseObject(userInfoStr, UserInfo.class);
}
小结
- 接口层增加校验,如用户鉴权校验,
id做基础校验,id<=0的直接拦截 - 采用布隆过滤器,使用一个足够大的
bitmap,用于存储可能访问的key,不存在的key直接被过滤; - 访问
key未在DB查询到值,也将空值写进缓存,但可以设置较短过期时间。
三、缓存雪崩
概念
大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。
一般多发生在项目初期缓存未加载或缓存过期时间相同
解决方案
1.缓存数据设置过期时间时加一个随机值时间,防止同一时间大量数据过期现象发生。
2.一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
3.如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
4.设置热点数据永远不过期。
5. 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。
总结
缓存击穿:使用双重检查锁的方式来解决,看到双重检查锁,大家肯定第一印象就会想到单例模式,这里也算是给大家复习一把双重检查锁的使用。
缓存穿透:
将空对象放入缓存中、布隆过滤器、使用锁的时候注意锁的力度,建议换成分布式锁(Redis或者Zookeeper实现),多个节点时可用Redisson提供的分布式锁!
缓存雪崩:
数据预热、分散缓存失效时间、数据永不过期