2232 字
约 7 分钟
17
S12-A25 · 缓存重建风暴:一次失效,为什么把库打满

一个热点 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. 自测

  1. "重建风暴"的四个触发条件是什么?为什么缺一个就"不发作"?
  2. "逻辑过期"为什么"没有请求等库"?它的代价是什么?
  3. "TTL 加抖动"解决的是哪一类风暴?为什么"批量写入"的 key 会同时过期?
  4. 为什么"回源失败必须有可返回的东西"?没有会发生什么?
  5. "同一个 SQL 每秒上千次"说明了什么?
  6. 判断"是否需要防风暴"的那个问题是什么?
S12-A25 · 缓存重建风暴:一次失效,为什么把库打满
https://www.clxhxhhr.top/posts/6680/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。