凌晨三点,手机用一种足以击穿耳膜的尖啸,将我从深度睡眠中强行唤醒。这种为核心报警定制的铃声,是每个SRE(网站可靠性工程师)的梦魇。
报警短信言简意赅,甚至有些“无辜”:“核心业务API响应时间超过2000ms”。
然而,我那在无数次线上事故中锤炼出的第六感却在狂吼:这绝不是一次普通的网络抖动。
当我用微微颤抖的手指接入VPN,点开监控大盘时,后背瞬间被冷汗浸湿。那条代表数据库CPU占用率的曲线,像是在嘲笑我所有过往的经验,放弃了任何挣扎,直接从20%悍然拉升到99%,在屏幕上画出了一座垂直的、令人绝望的墓碑。
我脑子里只剩下两个字:雪崩。
整点雪崩——当你的缓存大军约定好同一秒“集体阵亡”
直觉告诉我,问题出在缓存。能让数据库在瞬息之间从“度假模式”切换到“过劳死模式”的,只有一种可能:海量请求绕过缓存,像潮水一样直接拍在了数据库的沙滩上。
果然,日志里充斥着大量的数据库查询,而这些查询本该由Redis挡住。我迅速定位到了负责缓存预热的代码:
// 灾难的源头:所有Key的过期时间完全一致
@PostConstruct
public void wrongInit() {
// 初始化1000个城市数据到Redis,所有缓存数据有效期30秒
IntStream.rangeClosed(1, 1000).forEach(i ->
stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30, TimeUnit.SECONDS));
log.info("Cache init finished");
}
看到30, TimeUnit.SECONDS
这个整齐划一的参数,我差点把键盘砸了。
这意味着什么?意味着我们亲手设置了一颗定时炸弹。所有缓存Key在同一时刻被创建,也将在30秒后的同一时刻集体失效。那一瞬间,成千上万的请求,就像约定好了一样,手拉手冲向数据库,场面堪比双十一零点的抢购。
这就是最经典的缓存雪崩。不是缓存服务挂了,而是你的设计,让它在某个瞬间“社会性死亡”。
给每个缓存Key发一张不同时间的“死亡通知书”
修复方案简单到令人发指,甚至有些“反常识”的优雅。我们不需要复杂的锁,也不需要高深的算法,只需要给死亡时间,加上一点小小的“不确定性”。
@PostConstruct
public void rightInit1() {
// 破局的关键:在固定时间上,增加一个随机的“扰动值”
IntStream.rangeClosed(1, 1000).forEach(i ->
stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i),
30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
log.info("Cache init finished");
}
通过增加一个0~10
秒的随机延迟,我们将集中的过期时间点,打散成了一条平滑的曲线。原本在1秒内压垮数据库的洪峰,被削平成了未来10秒内可以轻松应对的涓涓细流。
热点狙击——那个被万人追捧的Key,成了压垮DB的最后一根稻草
解决了雪崩问题,警报暂时解除。但我的神经没有放松。在复盘日志时,我发现了一个更诡异的现象:在雪崩的瞬间,某个特定的Key——hotsopt
,回源数据库的次数,竟然高达几十次。
这不正常。雪崩是“面”的打击,而这个,是精准的“点”的狙击。
这就是缓存击穿。当一个被高并发访问的“超级热点”Key,在它过期的一瞬间,成百上千的线程会同时发现缓存不存在,然后像一群饿狼扑向同一只羊羔一样,同时去查询数据库、重建缓存。
// “单纯”的代码,在高并发下处处是陷阱
@GetMapping("wrong")
public String wrong() {
String data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
data = getExpensiveData(); // 极其耗时的数据库操作
stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
}
return data;
}
在高并发下,if (StringUtils.isEmpty(data))
这个判断,就像一个开了闸的大坝。第一个线程冲进去执行getExpensiveData()
时,后面99个线程已经通过了if判断,正在门外排队等着冲进去。
破局:双重检查锁——“单线程”过独木桥,请把DB的门焊死
对付这种“热点狙击”,唯一的办法就是“加锁”。只允许一个线程去重建缓存,其他线程要么等待,要么直接返回旧数据(如果业务允许)。在这里,一把基于Redis的分布式锁是最好的“武器”。
@GetMapping("right")
public String right() {
String data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
RLock locker = redissonClient.getLock("locker");
// 只允许一个线程获得锁
if (locker.tryLock()) {
try {
// 双重检查:防止A线程重建缓存后,B线程又查了一遍
data = stringRedisTemplate.opsForValue().get("hotsopt");
if (StringUtils.isEmpty(data)) {
data = getExpensiveData();
stringRedisTemplate.opsForValue().set("hotsopt", data, 5, TimeUnit.SECONDS);
}
} finally {
// 必须在finally中释放锁,这是纪律!
locker.unlock();
}
}
}
return data;
}
双重检查锁(DCL)在这里是精髓。第一个if
是性能考量,避免所有请求都去抢锁。第二个if
是逻辑严谨性的保证,防止在等待锁的过程中,缓存已经被其他线程重建。
幽灵攻击——当黑客用“不存在”的数据,发起致命的“穿透”
正当我以为已经堵上了所有漏洞时,DBA又发来了告警:数据库出现了大量针对user
表的无效ID查询。ID从-1、-2到-999,全是些压根不可能存在的数据。
我瞬间明白,我们遭遇了最阴险的攻击:缓存穿透。
攻击者在短时间内,用大量“数据库里肯定不存在”的Key来请求你的接口。由于这些Key在缓存中永远是miss
,请求会次次都“穿透”缓存,直达数据库。如果我们的数据库对id < 0
这种查询没有索引优化,每一次查询都是一次慢扫描。这相当于有人在用一把小锤子,不知疲倦地敲打你数据库最薄弱的那块玻璃。
// 无法区分“缓存失效”和“数据本就为空”的致命缺陷
@GetMapping("wrong")
public String wrong(@RequestParam("id") int id) {
String key = "user" + id;
String data = stringRedisTemplate.opsForValue().get(key);
// 致命问题:当data为null时,你不知道是缓存过期了,还是用户压根不存在
if (StringUtils.isEmpty(data)) {
data = getCityFromDb(id); // 每次都去查库
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
return data;
}
getCityFromDb
对一个不存在的ID返回的是空字符串,而isEmpty
无法区分这个空字符串是来自数据库的“查无此人”,还是Redis的“我这里没有”。
破局(一):给“空值”一个名分——承认它的“不存在”,也是一种保护
最直接的办法,就是缓存这个“不存在”的状态。当数据库告诉你“查无此人”时,不要直接返回,而是在缓存里存一个特殊的占位符,比如"NODATA"
。
// ...
data = getCityFromDb(id);
if (!StringUtils.isEmpty(data)) {
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
} else {
// 核心:为“空结果”建立缓存,并设置一个较短的过期时间
stringRedisTemplate.opsForValue().set(key, "NODATA", 5, TimeUnit.MINUTES);
}
// ...
这样,下一次同样的非法ID请求过来时,会直接命中"NODATA"
这个缓存,根本不会给它骚扰数据库的机会。
破局(二):布隆过滤器——门口的那个保安,他甚至不存你的名字,却认识你
如果非法Key的模式千奇百怪,缓存空值可能会污染缓存空间。这时,就需要祭出大杀器——布隆过滤器。
你可以把它想象成一个极其节省内存的“白名单”。在系统启动时,把所有合法的用户ID都放进布隆过滤器里。
它的神奇之处在于:
- 它说**“不存在”,那就一定不存在**。
- 它说**“可能存在”**,那才需要去查缓存和数据库(有极低的误判率)。
// 引入Guava的BloomFilter
private BloomFilter<Integer> bloomFilter;
@PostConstruct
public void init() {
// 初始化一个能容纳1万个元素,期望误判率为1%的布隆过滤器
bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
// 把所有合法的ID都“告诉”布隆过滤器
IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}
@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
// 第一道防线:问问“保安”见没见过这个人
if (bloomFilter.mightContain(id)) {
// “保安”说见过,再走正常流程
String key = "user" + id;
// ...后续逻辑同上
}
// “保安”说没见过,直接打发走,连Redis的门都不让碰
return "";
}
布隆过滤器像一个尽职尽责的保安,挡住了99%的恶意流量,让它们连缓存服务器的面都见不到,更别说去骚扰金贵的数据库了。
深渊回响:数据一致性的“妄念”——你先更新了缓存,但数据库背叛了你
风暴平息,但真正的反思才刚刚开始。上述所有问题,都围绕着“缓存查不到怎么办”。但还有一个终极问题:当数据变化时,我该怎么更新缓存?
- 先更新缓存,再更新数据库? 绝对不行! 你成功更新了缓存,但数据库事务回滚了,数据就彻底不一致了,这是最严重的“缓存投毒”。
- 先更新数据库,再更新缓存? 听起来不错,但在高并发下,请求A更新了数据库,请求B也更新了数据库。但由于网络延迟,B先更新了缓存,A后更新了缓存。最终,缓存里存的是A的旧数据。脏数据诞生。
- 先删除缓存,再更新数据库? 同样有并发问题。A删了缓存,还没来得及更新数据库,B的读请求来了,发现缓存没有,就去读了数据库的旧值,然后重建了缓存。A再完成数据库更新。最终,缓存里还是旧数据。
先操作数据库,再“通知”缓存滚蛋
经历无数次血泪教训后,业界沉淀出了最经典、最稳妥的模式:Cache-Aside Pattern(旁路缓存模式)。
- 读操作:先读缓存,缓存命中则直接返回。缓存未命中,则读数据库,成功后将数据放入缓存,然后返回。
- 写操作:先更新数据库,再删除缓存。
为什么是删除缓存,而不是更新缓存?
- 懒加载:只有当数据真正被访问时,才去重建缓存。你的写操作可能很频繁,但读操作很少,更新缓存的开销完全是浪费。
- 简单性与幂等性:删除操作是幂等的,删1次和删10次效果一样。如果因为网络问题需要重试,删除操作的风险远小于更新操作。
当然,“更新数据库成功,但删除缓存失败”的极端情况依然存在。对此,可以通过消息队列+延迟重试等机制来保证最终一致性。但对于绝大多数场景,Cache-Aside模式已经是健壮性、性能和简单性之间最好的平衡。
缓存避坑金句指北
- 雪崩之戒:为你的缓存过期时间,加上一个随机的“扰动值”。永远不要让你的缓存大军在同一秒“集体阵亡”。
- 击穿之锁:面对热点Key,请毫不犹豫地使用分布式锁或进程内锁,并牢记“双重检查”原则,只让一个请求成为重建缓存的“天选之子”。
- 穿透之盾:用“缓存空值”大法承认“不存在”也是一种状态,或用“布隆过滤器”这个门卫,将99%的恶意流量挡在门外。
- 一致性之道:抛弃所有“先写缓存”的妄念。牢记**“先更新数据库,再删除缓存”**的黄金法则(Cache-Aside Pattern),让数据流动得简单、可控。
你的每一次缓存写入,都可能是在为未来的事故埋下一颗定时炸弹。 在另一篇文章中,我复盘了那些由Redis引发的真实生产事故:从KEYS锁死服务,到“巨无霸键”撑爆内存,揭示了看似常规的操作背后隐藏的致命陷阱,点击查看。