系统是被打出来的 · 系统设计实战手册
PART 2 · 缓存与流量 · 第 5 章

缓存三兄弟
穿透、击穿、雪崩

缓存救过你无数次,所以你从没想过它会杀了你。这一章从一次真实形态的值班现场开始,认识三种最经典的缓存死法——然后在模拟器里,亲手把系统玩坏。

05 · 1 值班现场02:47,手机在床头柜上震动

创业第 1 年 4 个月,「秒抢」累计用户约 20 万。公司 45 人,后端 9 人。你是其中之一,本周 on-call。明早十点有一场顶流演唱会开票,你睡前特意检查过一遍大盘,一切正常。然后,02:47。

PagerDuty · P0 · 触发于 02:47:12
[CRITICAL] order-service P99 latency > 5000ms(阈值 800ms)
[CRITICAL] mysql-primary CPU 100%,活跃连接 1498/1500
[WARNING]  api-gateway 5xx 比例 37%,仍在上升
──────────────────────────────────────
15 分钟前,运维群最后一条消息:
「Redis 集群滚动升级完成 ✅ 各节点版本已确认,收工」

你打开监控大盘,四个数字跳进眼睛:

Redis 命中率
2.1%
▼ 平时 98%
DB 查询 QPS
31,400
▲ 平时约 700
订单接口 P99
>10s
▲ 请求大面积超时
应用线程池
排队 2.3 万
● 队列持续增长

线索已经足够拼出剧情:滚动升级重启了缓存节点,内存里的数据没了。凌晨流量虽然只有白天的零头,但命中率从 98% 跌到 2%,意味着原本由缓存挡住的查询几乎全部砸向了数据库——DB 正在被它平时流量 40 多倍的洪水冲刷。

A是对的,理由只有一条:先止血。DB 此刻唯一需要的是喘息。把入口流量限到它容量的七八成,被拒绝的请求快速失败——用户看到「排队中,请稍后重试」,远好过整站无响应。只有 DB 活着,缓存才有机会回填(一会儿你会在模拟器里亲眼看到为什么)。

B治标都算不上。重启只是把队列清空一次,请求马上会重新灌满它;如果应用层还有本地缓存,重启连这最后一层缓冲也一起清掉了。

C的问题是时间。扩容从库要做主从同步,新实例灌满数据是分钟级到小时级的事,而你的系统正在以秒为单位恶化。故障中扩容是常见的本能反应,也是常见的无效动作。

D值得做,只是排在后面。回滚的是程序版本,不是内存数据——缓存里的数据已经没了,回滚不会让它回来。该联系运维,但不是做这件事。

05 · 2 原理缓存不是优化,是契约

很多人把缓存理解为「让系统更快的东西」。这个理解在你上缓存的第一天就过期了。真相是:从缓存上线那一刻起,数据库的容量就再也没打算独自接住全部流量。缓存从性能优化变成了架构依赖——这就是契约。

契约的条款只有一行数学:

DB 承受的流量 = 总流量 × ( 1 − 命中率 )

它看起来无害,直到你代入数字。假设「秒抢」白天总查询量是 20,000 QPS,数据库实际容量约 1,000 QPS:

命中率 99%
200
QPS 到 DB
命中率 98%
400
QPS 到 DB
命中率 95%
1,000
QPS 到 DB ⚠ 满载
命中率 90%
2,000
QPS 到 DB 💥
命中率 0%
20,000
QPS 到 DB 💀

注意从 98% 到 96% 的那一步:听起来只是「掉了两个百分点」,但 miss 率从 2% 变成 4%——DB 流量翻倍。命中率上的每一个百分点,都是压在数据库身上的杠杆。这也是为什么监控里最值得盯的不是命中率本身,而是 总流量 × (1 − 命中率) 与 DB 容量之间的余量。

缓存越好,契约越重。命中率 99.5% 的系统,意味着 DB 只见过 0.5% 的世面——一旦缓存出事,它面对的是自己从未经历过的 200 倍流量。这就是三兄弟共同的杀伤原理:它们杀的都不是缓存,是缓存身后那个被保护得太好的数据库。

05 · 3 病历卡三种死法,三张病历

三兄弟长得像(最后都表现为「DB 被打死」),但发病机理完全不同,处方也完全不同。诊断的关键在监控症状的形状:是缓慢渗血、单点尖刺,还是断崖塌方。

缓存穿透

病历卡 No.04
Cache Penetration · 查询「根本不存在」的数据
机理
请求查询一个 DB 里根本不存在的 key。缓存里自然没有,于是每次都放行到 DB;DB 查不到,也就没有东西可以回填缓存。缓存对这类请求永久失效——它被打穿了。
症状
▁▂▂▃▃▄▄ 缓慢渗血型。命中率持续偏低或缓慢阴跌;DB 慢查询日志里出现大量返回空结果的同构查询,key 往往是无规律的 ID。
易发场景
恶意脚本或爬虫枚举 ID(订单号、用户 ID 逐个试);上游 bug 传入非法参数;被删除数据的历史链接仍被外部引用。
处方
① 缓存空值:查无此数据也缓存一个空标记,配短 TTL;② 布隆过滤器:把全量合法 key 装入内存过滤器,不在集合里的请求直接拒绝(可能误放行、绝不误拦截,恰好适合当门卫);③ 入口层参数校验与风控限速——穿透常常首先是个安全问题。
案例
「秒抢」上线三个月时遭遇过爬虫逐号枚举订单接口:命中率一周内从 98% 阴跌到 91%,没人在意——直到一次促销把 DB 推过了临界点。穿透的可怕在于它平时只是慢性病。

缓存击穿

病历卡 No.05
Hot Key Breakdown · 一个热点 key 的过期瞬间
机理
某个热点 key(比如顶流演唱会的详情页,每秒被读几万次)到达 TTL 过期。过期后的那几百毫秒里,成千上万个并发请求同时 miss,同时去查 DB,同时试图回源——DB 在一瞬间收到本该由一次回源解决的全部压力。
症状
▁▁▁█▁▁▁ 单点尖刺型。DB QPS 出现毫秒级垂直尖刺后迅速回落;慢查询集中在同一条 SQL、同一个 key;时间点与该 key 的 TTL 对齐。
易发场景
开票页、热搜条目、首页配置——任何「读极多、key 极少」的数据。流量越大,过期瞬间的并发 miss 越密集,尖刺越致命。
处方
① 互斥回源(singleflight):同一 key 只放一个请求去查 DB,其余请求等它回填;② 逻辑过期:物理上永不过期,值里带一个逻辑过期时间,过期后先返回旧值、异步刷新;③ 热点永不过期 + 由写路径主动更新。三者的取舍是本章末尾决策点 D2 的主题。
案例
微博曾多次在明星突发事件时服务异常(公开报道),是「热点数据 + 瞬时读放大」这一类故障最出名的反复上演——热点问题在第 8 章(热点分片)还会以另一副面孔回来。

缓存雪崩

病历卡 No.06
Cache Avalanche · 大面积同时失效
机理
击穿是一个 key 的事故,雪崩是一大片 key 同时失效。两种触发方式:其一,同批 TTL——凌晨批量任务把十万个 key 写进缓存,全部设了同样的 3600 秒,于是一小时后它们像约好了一样同时过期;其二,缓存集群本身宕机或重启——就是你 02:47 遇到的那种。命中率断崖式归零,DB 直面全量流量。
症状
███▁▁▁▁ 断崖塌方型。命中率垂直下坠;DB QPS 阶跃式打满并持续;线程池排队、上游超时,故障沿调用链向上蔓延成级联故障(病历卡 No.09)。
易发场景
定时任务统一写缓存;缓存集群升级、重启、故障切换;容器整批调度重建——任何让「很多 key 的生死绑在同一时刻」的操作。
处方
① TTL 加随机抖动:3600 秒变成 3600±600 秒,把同一时刻的过期洪峰摊平成细流;② 集群高可用:主从 + 持久化 + 分片,避免「重启 = 数据清零」;③ 熔断限流兜底:假设雪崩终会发生,确保 DB 在最坏情况下只收到它能承受的流量;④ 预热与慢放量:缓存恢复后不等于战斗结束——冷缓存需要先灌数据再逐步放开入口。
案例
Facebook 在 2010 年的一次著名故障中,因配置校验逻辑的错误,所有客户端将缓存值判为无效并同时涌向数据库集群,最终不得不整站下线才恢复,全程约两个半小时(官方复盘公开)。这类「错误反馈把系统锁死在坏状态」的机理,附录图鉴里另有一张卡(No.16 亚稳态故障)专门收录。

把三张病历叠在一起看,规律浮出来了:穿透杀慢刀(缓存永远不接手)、击穿扎一针(一个 key 的瞬间)、雪崩塌一片(一群 key 的同时)。下次盯着大盘时,先看命中率曲线的形状——形状就是诊断。

05 · 4 模拟器在浏览器里把系统玩坏

下面是一个活的「秒抢」微缩模型:请求从左往右流过缓存、砸向数据库。它遵守你刚学的那条契约,也会像真实系统一样死掉。生产环境不给你的练习机会,这里管够。

CACHE AVALANCHE SIMULATOR · 秒抢监控大盘 02:47:00
到达 DB 的 QPS DB 容量线 缓存命中率 % 请求错误率 %
缓存命中率
—
到达 DB 的 QPS
—
DB 状态
—
P99 延迟
—
请求错误率
—

🧪 三个实验(按顺序做)

  1. 看见「同批过期」。什么都别动,观察约一分钟:每隔一段时间,命中率会规律性跳水一次——那是一批同 TTL 的 key 集体过期,DB 上随之出现一根根尖刺。现在勾选 TTL 随机抖动,看洪峰如何被摊平成细流。这就是一行 ttl += random(±10%) 的全部价值。
  2. 复现 02:47,并发现「重启救不活」。点 💥 缓存集群宕机:命中率归零,DB 数秒内过载崩溃。现在点「恢复缓存集群」——注意,系统并没有恢复。缓存是冷的,回填数据要靠 DB 查询,可 DB 一活过来就被全量流量再次打死。冷缓存、死 DB、全量流量,三者互相锁死——这叫死亡螺旋,也是「为什么回滚/重启解决不了雪崩」的直观答案。
  3. 用熔断打破螺旋。保持宕机后的惨状,勾选 熔断限流:网关只放行 DB 容量的 85%,其余请求快速失败。看 DB 重启存活 → 命中率沿着一条上升曲线爬回去 → 错误率随之归零。代价是恢复期内一部分用户请求失败——「部分失败」换「整体存活」,这就是止血的全部含义,也是决策点 D1 里 C 选项的数学证明。

⚖️ 这个模型简化了什么

这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。 实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证, 但保证的是这个模型里的现象。

05 · 5 权衡没有正确答案,只有代价

回到白天。复盘会上,团队决定给「演唱会详情页」这类热点 key 上击穿保护。三个候选方案摆在桌上——每一个都是对的,每一个都有账单。点开看你愿意付哪一份。

决策点 D2

顶流演唱会详情页,单 key 峰值 5 万次读/秒。选哪种过期保护?

A · 互斥回源:锁能拿到、回源能成功时,DB 不会被同一 key 的并发击穿,数据也是新鲜的。账单:过期瞬间有一批请求要原地等待回源完成,P99 出现毛刺;跨实例互斥需要分布式锁,你为了保护一个依赖又引入了一个新依赖。适合回源成本高、能容忍延迟毛刺的数据。

B · 逻辑过期:用户读路径不需要等待,只要刷新触发也做了并发保护,DB 通常只会收到一个异步刷新请求。账单:刷新窗口内所有人读到的是旧数据(最终一致);实现复杂度最高,还需要兜底任务防止「异步刷新失败后永远陈旧」。适合读极多、对新鲜度不敏感的数据。

C · 永不过期 + 写时更新:读路径最简单,没有过期这回事。账单:缓存一致性的全部责任转移到写路径——任何一处更新遗漏,就是永久脏数据;key 集合必须有限可控,否则内存只进不出。适合 key 少、写入口收敛的配置类数据。

「秒抢」的选择:详情页走 B——开票前八小时,票价从 1280 改成 1281 晚十秒被看到,没人在乎;但接口卡 200ms,热搜上见。而库存这个 key 三个方案全都不敢用:它对旧值零容忍,这个难题要等第 10 章「超卖!」正面解决。

05 · 6 复盘那一夜后来怎么样了

回放 02:47 的正确剧本——留意每个动作的顺序,顺序本身就是知识:

02:49

止血。网关限流至 DB 容量的 80%,超出部分返回「排队中」友好页。DB 连接数从 1498 回落到 600,恢复响应。

02:55

预热。跑预热脚本,把明早开票场次、热门场馆等头部 key 主动灌回缓存——让最值钱的 2% 的 key 先复活,命中率从 2% 拉回 71%。

03:10

放量。限流阈值 50% → 80% → 100% 逐级放开,每一级观察五分钟。命中率回到 97%,P99 回到 180ms。

04:02

解除。全量恢复。你在故障群发了最后一条消息,定了 10:00 前的两个 action item,睡了三个小时。

壹
先止血,再治病。故障处置的第一目标不是「修好」,是「别让它继续恶化」。限流降级永远先于根因修复——根因可以天亮再查,雪崩不等天亮。
贰
缓存重启 ≠ 缓存恢复。进程活了,数据还是冷的。没有预热和慢放量的「恢复」,只是死亡螺旋的下一圈。把预热脚本写在故障发生之前。
叁
给运维动作标上流量风险。「滚动升级缓存集群」在变更评审里看起来人畜无害——如果没人问一句「重启后命中率会怎样」。所有触碰缓存生死的变更,都该在低峰执行并预挂限流预案。

05 · 7 自测你能一眼认出它们吗

Q1 · 看形状诊断

监控显示:DB QPS 每到整点出现一根规律性尖峰,随后回落;命中率在同一时刻短暂跳水后快速恢复。最可能是?

C。「整点 + 规律性 + 一批」指向定时任务写入的同批 TTL 集体过期——雪崩的温和形态(你在模拟器实验①里见过它)。如果尖峰只对应某一个 key、时间点与该 key 的 TTL 对齐,才是击穿(B);穿透(A)的形状是持续渗血而非规律尖峰。

Q2 · 契约算术

总流量 20,000 QPS,命中率从 99% 跌到 90%。到达 DB 的流量变成原来的几倍?

D。看 miss 率而不是命中率:1% → 10%,即 200 QPS → 2,000 QPS,十倍。「命中率只掉了 9 个点」是故障群里最危险的一句安慰话。

Q3 · 对症下药

安全团队发现有脚本在逐个枚举不存在的订单号刷接口。最对症的处方组合是?

A。这是穿透(查不存在的数据),布隆过滤器在入口直接拒绝不合法 key,空值缓存兜住漏网之鱼。B 是击穿的处方,C 是雪崩的处方,D 是给对手升级武器。开错药方的前提是诊断错误——所以 Q1 那道题最重要。

Q4 · 死亡螺旋

缓存集群宕机,DB 已被打死。你重启了 DB,它几秒内再次被打死。下一步最应该做什么?

B。这正是模拟器实验②③的剧情。C 是最诱人的错误答案:缓存回填依赖 DB 活着——冷缓存挡不住流量,DB 就活不了;DB 活不了,缓存就永远是冷的。打破这个循环最快能拧动的钥匙在流量入口,不在存储层;预热、扩容或旁路缓存也能打破它,只是更慢。

🎯 面试映射 · 本章直接覆盖的高频追问

「Redis 突然全挂了,系统会发生什么?怎么办?」答题骨架:契约公式说明 DB 将承受多少倍流量 → 止血(限流降级)→ 恢复(高可用切换/重启)→ 预热 → 慢放量 → 复盘单点问题。能主动讲出「冷缓存死亡螺旋」是强信号。
「穿透、击穿、雪崩的区别与对策?」别背处方,先讲症状形状(渗血 / 尖刺 / 断崖)再对症给药——展示的是诊断能力而非记忆力。
「命中率降到多少需要告警?」反直觉加分点:不看命中率绝对值,看 总流量 × (1 − 命中率) 相对 DB 容量的余量。命中率 90% 在小流量系统里岁月静好,在大流量系统里已经着火。