一个热点 key 在凌晨过期。随后 800 个并发请求同时"未命中",一起去查库——库的 CPU 瞬间 100%,查询排队,于是"所有"请求都变慢,而不只是那 800 个。
这是"缓存重建风暴"(也叫"击穿"的并发形态)。它的触发条件很朴素:"同一时刻、大量请求、同一个 key、且回源很慢"——四个条件凑齐,缓存就从"加速器"变成"放大器的开关"。
1. 四种"放大"的形态
| 形态 | 触发 |
|---|---|
| 单 key 击穿 | 热点 key 过期 |
| 批量同时过期 | TTL 无抖动 |
| 全量失效(flush/重启) | 缓存清空 |
| 回源很慢 | 一次回源几百毫秒 |
第二项与第三项"最像事故":**它们不是"一个 key",而是"一批"**这个"一批"的量级很惊人:10 万个缓存项在"同一分钟过期",就是"10 万次回源"——而库平时可能只承接"每秒几十次"。
——于是"回源量"直接等于"缓存容量".
2. "单 key 击穿"的三种解法
| 解法 | 机制 |
|---|---|
| 互斥重建(单飞) | 只让一个请求回源,其余等 |
| 逻辑过期(不设 TTL) | 返回旧值 + 后台刷新 |
| 兜底值(默认值) | 回源失败时给"能用的" |
第一项的"边界"是"等待者的超时预算":回源 200ms、等待者最多等 2 次(400ms)——超过就该"降级",而不是"继续等"。
第一项是"最通用"的:"谁拿到锁谁回源,其余等待/重试"——代价是"一次延迟尖峰"(等待者)。
第二项是"体验最好"的:值里带"逻辑过期时间",过期后"仍返回旧值",同时异步刷新第二项的"代价"是"可能返回过期数据":于是它只适合"能容忍 T+1 分钟旧值"的数据(商品描述、配置),不适合"库存/余额".
——于是"没有请求等库".
// 单飞重建(伪代码,三步)
var cached = redis.get(key);
if (cached != null) return cached;
if (!redis.setIfAbsent("lock:"+key, "1", 5, SECONDS)) { // 抢不到就等/重试
return waitOrFallback(key);
}
try { var v = db.load(key); redis.set(key, v, ttl); return v; }
finally { redis.del("lock:"+key); }
3. "批量过期"的两种应对
| 应对 | 说明 |
|---|---|
| TTL 加抖动 | 过期点被打散 |
| 分批预热 | 在低峰"主动刷新" |
第一项的"实现"一行就够:ttl = base + random(0, base/10)——于是"同一批写入"的 key 在 10% 的窗口内被打散。
第一项"最便宜":TTL 加上 ±10% 的随机——于是"同一批写入"的 key 不会"同一秒过期".
"单飞"的正确实现要"带超时与失败放行",否则锁会变成"新的单点":
// 三个细节(缺一个就会"锁住整个 key")
1 锁带 TTL(5s)——防止"持锁进程崩了不释放"
2 等待者有超时(<= 2 次 × 回源耗时)——超了就降级
3 回源失败"删锁 + 返回兜底"——否则后续请求全在等
// 判据:模拟"回源抛异常",看"是否自动恢复"
4. "回源保护"的三个手段
| 手段 | 作用 |
|---|---|
| 限流(并发数) | 不让库"被一次打满" |
| 超时 + 降级 | 回源失败给"旧值/默认值" |
| 预热 | 高峰前把热 key 装回去 |
第二项是"最重要"的:"回源失败"必须有"可返回的东西"第二项的"层级"要设计:"新鲜值 → 旧值(逻辑过期)→ 默认值" 三级——每一级都"能返回",于是"库挂了"也不至于"页面白屏".
——否则"缓存失效"等于"服务不可用".
"预热"要"按"即将过期的 key"做",而不是"全量刷一遍":
// 三种"预热"策略
1 定时任务:低峰期"扫"即将过期(TTL < 5min)的 key,逐个刷新
2 启动预热:只预热"Top N 热 key"(按访问频次)
3 灰度预热:新版本实例先"只读预热",再切流量
// 判据:预热后"命中率"应回到 95%+,且"库 QPS"不出现尖峰
5. "怎么发现风暴"
| 信号 | 说明 |
|---|---|
| 缓存命中率"断崖" | 从 95% 掉到 40% |
| 库连接数"瞬间打满" | 与命中率同步 |
| 同一 SQL"并发上千" | 慢日志里同一个查询 |
第三项最直接:"同一个 SQL 在同一秒出现上千次" 就是"重建风暴"的指纹.
"库被打满"时要"先看是不是同一个 SQL",因为"修法完全不同":
-- 一条"指纹聚合"(找"并发最高的那个查询")
SELECT LEFT(argument, 120) AS sql, COUNT(*) c
FROM mysql.slow_log WHERE start_time > NOW()-INTERVAL 5 MINUTE
GROUP BY LEFT(argument,120) ORDER BY c DESC LIMIT 5;
-- 判据:若 Top1 的 c 远超其它 → "单 key 击穿";若"多条都高" → "批量过期"
6. 排错:五种"重建风暴"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 凌晨定时变慢 | 批量过期 | TTL 抖动 |
| 某商品页超时 | 热点 key 击穿 | 单飞/逻辑过期 |
| 重启后全站慢 | 缓存空 | 预热 |
| 库 CPU 尖刺 | 回源无限制 | 限流 + 降级 |
| 只有"新数据"慢 | 冷数据无缓存 | 预热/兜底 |
"不可缓存"这条要单独说,因为它常被"缓存"误伤:
// 三种"不该缓存"的数据
1 强一致(余额/库存)——缓存会"读到旧值"
2 极短生命周期(一次性 token)——缓存命中率近 0
3 高频变化(实时报价)——缓存"刚存就过期"
// 判据:缓存它"省下的 IO" 是否大于"一致性风险"
7. 边界:什么情况"不必防风暴"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 回源很快(< 5ms) | 打不满库 | 不必 |
| 流量很小(< 10 QPS) | 凑不齐"大量并发" | 不必 |
| 数据"不可缓存"(实时) | 本来就要回源 | 不必 |
| 已有"库侧限流/读写分离" | 有兜底 | 弱化 |
判断口径一句话:"同一个 key 会不会在同一秒被几百个请求同时未命中"。最后一条与"容量"有关的边界:"防风暴"的配置要让"库的容量"与"最坏回源量"对齐:若"最坏回源 1000 QPS"而库只能 200——**那就必须"限流 + 降级",否则"防不住".
会(热点商品、配置中心、首页聚合),必须"单飞 + 抖动 + 降级";不会(长尾 key、低流量),"缓存本身"就够了。
再补一个"最容易被忽略"的场景:"发布"本身也是一次"全量失效"——新实例"缓存是空的",而负载均衡"立刻把流量切过去"——于是"滚动发布"期间,每起一个新实例就"制造一次小风暴"。解法有两个:发布前"预热"(实例就绪前先把 Top N 拉进缓存),或"错峰切流"(先小流量,命中率稳定后再放大)。而"发布时变慢"这件事,往往被归因到"新版本代码有问题",实际是"缓存冷启动"。
而"发布即风暴"这一条,解释了为什么"缓存预热脚本"常常是"发布流程的第一步"。
最后一个"度量"建议:把"缓存命中率"与"库 QPS"画在同一张图上——两条线"一条下坠、一条上冲"就是风暴的形状;而只看其中一条,你会以为"缓存坏了"或"库变慢了",而实际是"两者互为因果"。
而这两条线,应当放进"同一块看板"——因为它们的因果只有在"一起看"时才成立。
这也是"缓存治理"里最实用的一条可视化:一条下坠、一条上冲,就是风暴;两条同涨同落,才是容量不足。
而"同涨同落"的那种,才该"加机器"。
这条判据能省掉一次错误的扩容决策。
8. 自测
- "重建风暴"的四个触发条件是什么?为什么缺一个就"不发作"?
- "逻辑过期"为什么"没有请求等库"?它的代价是什么?
- "TTL 加抖动"解决的是哪一类风暴?为什么"批量写入"的 key 会同时过期?
- 为什么"回源失败必须有可返回的东西"?没有会发生什么?
- "同一个 SQL 每秒上千次"说明了什么?
- 判断"是否需要防风暴"的那个问题是什么?