缓存救过你无数次,所以你从没想过它会杀了你。这一章从一次真实形态的值班现场开始,认识三种最经典的缓存死法——然后在模拟器里,亲手把系统玩坏。
创业第 1 年 4 个月,「秒抢」累计用户约 20 万。公司 45 人,后端 9 人。你是其中之一,本周 on-call。明早十点有一场顶流演唱会开票,你睡前特意检查过一遍大盘,一切正常。然后,02:47。
你打开监控大盘,四个数字跳进眼睛:
线索已经足够拼出剧情:滚动升级重启了缓存节点,内存里的数据没了。凌晨流量虽然只有白天的零头,但命中率从 98% 跌到 2%,意味着原本由缓存挡住的查询几乎全部砸向了数据库——DB 正在被它平时流量 40 多倍的洪水冲刷。
A是对的,理由只有一条:先止血。DB 此刻唯一需要的是喘息。把入口流量限到它容量的七八成,被拒绝的请求快速失败——用户看到「排队中,请稍后重试」,远好过整站无响应。只有 DB 活着,缓存才有机会回填(一会儿你会在模拟器里亲眼看到为什么)。
B治标都算不上。重启只是把队列清空一次,请求马上会重新灌满它;如果应用层还有本地缓存,重启连这最后一层缓冲也一起清掉了。
C的问题是时间。扩容从库要做主从同步,新实例灌满数据是分钟级到小时级的事,而你的系统正在以秒为单位恶化。故障中扩容是常见的本能反应,也是常见的无效动作。
D值得做,只是排在后面。回滚的是程序版本,不是内存数据——缓存里的数据已经没了,回滚不会让它回来。该联系运维,但不是做这件事。
很多人把缓存理解为「让系统更快的东西」。这个理解在你上缓存的第一天就过期了。真相是:从缓存上线那一刻起,数据库的容量就再也没打算独自接住全部流量。缓存从性能优化变成了架构依赖——这就是契约。
契约的条款只有一行数学:
它看起来无害,直到你代入数字。假设「秒抢」白天总查询量是 20,000 QPS,数据库实际容量约 1,000 QPS:
注意从 98% 到 96% 的那一步:听起来只是「掉了两个百分点」,但 miss 率从 2% 变成 4%——DB 流量翻倍。命中率上的每一个百分点,都是压在数据库身上的杠杆。这也是为什么监控里最值得盯的不是命中率本身,而是 总流量 × (1 − 命中率) 与 DB 容量之间的余量。
三兄弟长得像(最后都表现为「DB 被打死」),但发病机理完全不同,处方也完全不同。诊断的关键在监控症状的形状:是缓慢渗血、单点尖刺,还是断崖塌方。
把三张病历叠在一起看,规律浮出来了:穿透杀慢刀(缓存永远不接手)、击穿扎一针(一个 key 的瞬间)、雪崩塌一片(一群 key 的同时)。下次盯着大盘时,先看命中率曲线的形状——形状就是诊断。
下面是一个活的「秒抢」微缩模型:请求从左往右流过缓存、砸向数据库。它遵守你刚学的那条契约,也会像真实系统一样死掉。生产环境不给你的练习机会,这里管够。
ttl += random(±10%) 的全部价值。w 表示全局缓存热度 → 真实系统里不同 key 有不同热度分布(Zipf)、不同 TTL、不同回源成本 → 因此不要得出「雪崩会让所有 key 同时死亡」的结论,真实场景通常是少数热点 key 集中失守,长尾 key 影响小得多。
这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。
实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证,
但保证的是这个模型里的现象。
回到白天。复盘会上,团队决定给「演唱会详情页」这类热点 key 上击穿保护。三个候选方案摆在桌上——每一个都是对的,每一个都有账单。点开看你愿意付哪一份。
A · 互斥回源:锁能拿到、回源能成功时,DB 不会被同一 key 的并发击穿,数据也是新鲜的。账单:过期瞬间有一批请求要原地等待回源完成,P99 出现毛刺;跨实例互斥需要分布式锁,你为了保护一个依赖又引入了一个新依赖。适合回源成本高、能容忍延迟毛刺的数据。
B · 逻辑过期:用户读路径不需要等待,只要刷新触发也做了并发保护,DB 通常只会收到一个异步刷新请求。账单:刷新窗口内所有人读到的是旧数据(最终一致);实现复杂度最高,还需要兜底任务防止「异步刷新失败后永远陈旧」。适合读极多、对新鲜度不敏感的数据。
C · 永不过期 + 写时更新:读路径最简单,没有过期这回事。账单:缓存一致性的全部责任转移到写路径——任何一处更新遗漏,就是永久脏数据;key 集合必须有限可控,否则内存只进不出。适合 key 少、写入口收敛的配置类数据。
「秒抢」的选择:详情页走 B——开票前八小时,票价从 1280 改成 1281 晚十秒被看到,没人在乎;但接口卡 200ms,热搜上见。而库存这个 key 三个方案全都不敢用:它对旧值零容忍,这个难题要等第 10 章「超卖!」正面解决。
回放 02:47 的正确剧本——留意每个动作的顺序,顺序本身就是知识:
止血。网关限流至 DB 容量的 80%,超出部分返回「排队中」友好页。DB 连接数从 1498 回落到 600,恢复响应。
预热。跑预热脚本,把明早开票场次、热门场馆等头部 key 主动灌回缓存——让最值钱的 2% 的 key 先复活,命中率从 2% 拉回 71%。
放量。限流阈值 50% → 80% → 100% 逐级放开,每一级观察五分钟。命中率回到 97%,P99 回到 180ms。
解除。全量恢复。你在故障群发了最后一条消息,定了 10:00 前的两个 action item,睡了三个小时。
监控显示:DB QPS 每到整点出现一根规律性尖峰,随后回落;命中率在同一时刻短暂跳水后快速恢复。最可能是?
C。「整点 + 规律性 + 一批」指向定时任务写入的同批 TTL 集体过期——雪崩的温和形态(你在模拟器实验①里见过它)。如果尖峰只对应某一个 key、时间点与该 key 的 TTL 对齐,才是击穿(B);穿透(A)的形状是持续渗血而非规律尖峰。
总流量 20,000 QPS,命中率从 99% 跌到 90%。到达 DB 的流量变成原来的几倍?
D。看 miss 率而不是命中率:1% → 10%,即 200 QPS → 2,000 QPS,十倍。「命中率只掉了 9 个点」是故障群里最危险的一句安慰话。
安全团队发现有脚本在逐个枚举不存在的订单号刷接口。最对症的处方组合是?
A。这是穿透(查不存在的数据),布隆过滤器在入口直接拒绝不合法 key,空值缓存兜住漏网之鱼。B 是击穿的处方,C 是雪崩的处方,D 是给对手升级武器。开错药方的前提是诊断错误——所以 Q1 那道题最重要。
缓存集群宕机,DB 已被打死。你重启了 DB,它几秒内再次被打死。下一步最应该做什么?
B。这正是模拟器实验②③的剧情。C 是最诱人的错误答案:缓存回填依赖 DB 活着——冷缓存挡不住流量,DB 就活不了;DB 活不了,缓存就永远是冷的。打破这个循环最快能拧动的钥匙在流量入口,不在存储层;预热、扩容或旁路缓存也能打破它,只是更慢。
总流量 × (1 − 命中率) 相对 DB 容量的余量。命中率 90% 在小流量系统里岁月静好,在大流量系统里已经着火。