认识 Redis 缓存:为什么会失效?
在高性能的应用中,我们经常使用 Redis 作为缓存来减轻数据库的压力。当我们查询数据时,优先从 Redis 中获取,如果 Redis 中没有,再去查询数据库,然后将数据放入 Redis,这个过程被称为 缓存命中。

但是,缓存并非万无一失。当缓存中的数据失效时,就可能引发一系列问题。我们通常将这些问题归纳为三种常见的“失效”场景:缓存击穿、缓存穿透和缓存雪崩。
别担心,这些名字听起来有点吓人,但理解起来其实很简单。我们可以用一个生动的比喻来解释它们。
缓存击穿:高并发的“致命一击”
概念:
想象一下,你家楼下有一家超火的奶茶店。他们把最受欢迎的“招牌奶茶”提前做好了几杯,放在柜台里,顾客一来就能直接拿走(这就像缓存)。
但是,每杯奶茶都有保质期(缓存过期时间)。当最后一杯招牌奶茶刚好卖完,而此刻大量顾客又同时涌进来,都想买这杯奶茶。店员不得不临时去后厨(数据库)现做,但做一杯奶茶需要时间,后面排队的人越来越多,所有人都只能干等。
这就是缓存击穿。它指的是某个热点数据(招牌奶茶)在缓存过期的一瞬间,同时有大量高并发请求涌入,这些请求会绕过缓存,直接访问数据库,导致数据库瞬间压力过大,甚至宕机。
解决方案:
- 设置永不过期:
- 将热点数据设置一个较长的过期时间。
- 在后台定时刷新缓存,或者在数据更新时主动删除缓存。
- 这就像,奶茶店把招牌奶茶保质期延长到很长,并且店员会定期检查,快过期了就主动换新的。
- 加互斥锁(推荐):
- 当第一个请求去查询数据库时,先给这个数据加锁。
- 其他所有后续请求来了,发现这个数据正在被处理,就会在原地等待。
- 等第一个请求处理完毕,将数据放入缓存后,其他请求就能直接从缓存中获取,避免了大量请求同时访问数据库。
代码示例:
下面是一个使用 Java 锁来防止缓存击穿的简单代码示例:
public String getData(String key) {
// 1. 先从缓存获取
String value = redisClient.get(key);
if (value != null) {
return value;
}
// 2. 缓存中没有,加锁
synchronized (this) {
// 3. 再次从缓存获取(双重检查)
value = redisClient.get(key);
if (value != null) {
return value;
}
// 4. 缓存中还是没有,去数据库查询
value = databaseClient.get(key);
// 5. 查到数据,放入缓存
if (value != null) {
redisClient.set(key, value, 60); // 设置过期时间
}
return value;
}
}
缓存穿透:查不到的“白忙活”
概念:
继续奶茶店的比喻。如果一个顾客,每次来都点一杯根本不存在的“月球奶茶”。店员每次都得去后厨(数据库)翻找,结果自然是“查无此茶”。如果有很多顾客都被恶意引导,都来点“月球奶茶”,店员们就会一直白忙活,不断地去后厨查询,数据库的压力会越来越大。
这就是缓存穿透。它指的是查询一个根本不存在的数据。由于缓存中本身就没有这个数据,所以每次请求都会穿过缓存,直接打到数据库上。如果恶意攻击者利用这个漏洞,不断发起查询不存在数据的请求,就会导致数据库负载过高。
解决方案:
- 缓存空值:
- 当数据库查询结果为空时,将这个空结果也缓存起来,并设置一个较短的过期时间。
- 这样,下次再有对这个“不存在数据”的请求,就能直接从缓存中获取到空值,而不用再去访问数据库。
- 就像,店员告诉顾客“没有月球奶茶”,并把这个信息记下来,下次再有人问,就直接回答,不用再进后厨了。
- 布隆过滤器(Bloom Filter):
- 布隆过滤器是一个非常高效的数据结构,它能快速判断一个数据是否存在。
- 在数据写入数据库时,同时将数据的摘要信息放入布隆过滤器。
- 当有请求过来时,先用布隆过滤器判断这个数据是否存在。如果过滤器说“不存在”,那这个数据就肯定不存在,直接返回,连缓存都不用查。如果过滤器说“可能存在”,再去查询缓存和数据库。
- 这就像,奶茶店门口有一本厚厚的“菜单”,上面记录了所有存在的奶茶。顾客点单时,先查这本菜单,如果查不到,就直接告诉他没有,省去了去后厨的时间。
代码示例:
public String getData(String key) {
String value = redisClient.get(key);
// 1. 如果缓存中存在,直接返回
if (value != null) {
return value;
}
// 2. 缓存中没有,去数据库查询
value = databaseClient.get(key);
// 3. 数据库查询结果不为空,放入缓存
if (value != null) {
redisClient.set(key, value, 60);
} else {
// 4. 数据库查询结果为空,也放入缓存,并设置较短的过期时间
redisClient.set(key, "null", 5);
}
return value;
}
缓存空值的代码实现非常简单:
缓存雪崩:一起“集体阵亡”
概念:
回到奶茶店。如果店里所有的奶茶,不管招牌的还是普通的,保质期都一样,比如都是上午10点过期。到了10点,所有的奶茶都不能卖了。而此时,刚好是午餐高峰期,大量顾客涌入,所有的请求都因为找不到缓存(奶茶),而涌向了后厨(数据库),导致后厨工作量瞬间暴增,瘫痪了。
这就是缓存雪崩。它指的是在某一个时间点,大量的缓存同时失效。由于这些请求无法命中缓存,导致所有请求都涌向数据库,在瞬间对数据库造成极大的冲击。
解决方案:
- 设置随机过期时间(推荐):
- 给不同的缓存数据设置不同的过期时间。
- 比如,给缓存A设置5分钟过期,给缓存B设置5分10秒过期,给缓存C设置4分50秒过期。
- 这样,即使到了某个时间点,也只有少量缓存会失效,而不是全部失效,从而将数据库压力分散开来。
- 多级缓存:
- 使用多级缓存,比如使用 Ehcache 或 Caffeine 等本地缓存。
- 当 Redis 缓存失效时,请求先尝试从本地缓存中获取,如果本地缓存中还有,就可以直接返回。
- 只有本地缓存也失效时,才去访问数据库。
代码示例:
设置随机过期时间非常简单,只需要在设置缓存时稍作修改:
// 正常设置过期时间
redisClient.set(key, value, 60);
// 设置随机过期时间,在60-120秒之间
// 假设 rand是一个随机数生成器,rand.nextInt(60) 生成0-59的随机数
int randomExpiration = 60 + new Random().nextInt(60);
redisClient.set(key, value, randomExpiration);
总结
| 问题名称 | 发生原因 | 解决方案 |
|---|---|---|
| 缓存击穿 | 热点数据失效,高并发请求同时涌入 | 互斥锁、永不过期 |
| 缓存穿透 | 查询不存在的数据,请求穿透缓存 | 缓存空值、布隆过滤器 |
| 缓存雪崩 | 大量缓存在同一时间失效 | 随机过期时间、多级缓存 |
Comments 27 条评论
奶茶比喻太上头了,脑子一下就亮了😂
@龙马精神 我都想给作者寄十杯真奶茶了,图解太香!
布隆过滤器原来这样用,我还以为是盗梦空间那玩意儿同名呢,长见识了👍
看完立刻把秒杀活动的缓存过期时间改成随机20~40秒,效果等明天来汇报,别崩!
雪崩那段我已经跪着截图了,下次面试要是挂在这三兄弟上我就买你家奶茶
互斥锁那段能不能再细说,synchronized在高并发下会不会变成新瓶颈?
@阳光微笑 synchronized 真扛不住高并发,我们切 redisson 分布式锁,qps 从 2000 飙到 8000 没崩
布隆过滤器我有个坑:万一误报怎么办?布只能认倒霉吗?😂
老板,布隆过滤器+缓存空值双保险试过没?线上怎么削峰填谷求科普
看完立刻复习:击穿是女神头像瞬间过期,5000个舔狗一起刷新😂
兄弟别只顾着讲击穿,雪崩才是财报季最怕的,全爆仓!
代码能不能给个SpringCache版本,手写锁我怕整出新bug
@Ragtime Rose SpringCache 版本我给你扔个 Gist,少走弯路👍
我们生产环境加了本地Caffeine做二级缓存,上游DB总算睡了个安稳觉
提前给服务器献祭十杯乌龙茶祈愿明天不炸……
看完脑子嗡嗡的,原来奶茶还能这么教分布式的😂
布隆过滤器看完就去查了 guava,30 行代码搞定,爽
昨天刚把热点 key 上了永不过期+后台定时任务,今天分布式锁都省了,稳!
雪崩那张图我可以当壁纸吗?被老板催得头皮发麻的时候瞄一眼
月球奶茶绝了,我怀疑我写的恶意脚本就是点这玩意儿
有没有哥哥试过 redisson 锁?听说对锁续期更友好?
大学老师要是早用奶茶举例,我估计不至于挂科
看着简单,生产上一集群延时全飙红,哭都没地儿哭
有个问题,布隆过滤器误判率怎么算来着?脑袋一时短路
问了运维,昨晚就是雪崩,3 台数据库一起挂,差点睡机房
作者醒醒,下一篇讲 lua 脚本原子操作如何?给跪