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

上缓存
以及你签下的一致性契约

Redis 把数据库读压力从红线拉回安全区,两周后却把一张旧价格牢牢守在用户面前。缓存不是一份免费副本;从写入那刻起,你就欠下了让两份数据重新相遇的账。

04 · 1 值班现场16:40,运营改了价格,页面纹丝不动

创业第 9 个月,「秒抢」累计用户约 5 万。公司 25 人,后端 5 人。数据库读压力连续一周逼近红线,团队刚把演出详情接入 Redis;上线两周,命中率漂亮得让每个人都松了口气。周四 16:40,运营却抱着电脑冲进开发区:「票价改了二十分钟,用户还在按旧价下单。」

客服聚合告警 · P1 · 触发于 16:40:22
[CRITICAL] show-price mismatch 工单 327,10 分钟内持续增加
[WARNING]  show:8842 页面价格 ¥580,DB 当前值 ¥680
[INFO]     admin price update 返回 200,DB affected_rows=1
──────────────────────────────────────
运营群 16:39:
「后台已经显示 680,为什么用户端还是 580?」

阿凯把详情接口的四张卡片投到屏幕上。没有 5xx,没有慢查询,缓存命中率甚至比昨天更好——正因为它稳定命中,旧值才被稳定地交付给每个用户。

缓存命中率
99.4%
● 服务稳定
该 key 读 QPS
5,000
▲ 首页推荐位
DB 价格
¥680
● 16:20 已提交
缓存价格 / TTL
¥580 / 19h
● 写路径未失效

读路径是标准 cache-aside:先查缓存,miss 才查 DB 并回填。运营后台的写路径却只更新了数据库,没有删除或更新 show:8842。于是 99.4% 的高命中率不再是好消息,它说明 5,000 次/秒的请求几乎都绕过了已经正确的 DB。

你还看到这个 key 剩余 TTL 19 小时。等自然过期不是修复,是让错误价格继续售卖。此刻必须先把错误值从读路径摘掉,同时避免一次全量清缓存把下一章的事故提前召来。

A的问题是爆炸半径。 清空整集群确实会带走旧价格,也会带走每个健康 key。下一秒大量 miss 会一起砸向 DB;第 5 章会给这种“为修一个 key 而清空全部缓存”的后果正式命名。

B方向反了——它放大的正是你想压住的东西。 写路径仍只改 DB,再提交一次只是把正确值重复写回同一处;缓存继续命中 ¥580,运营还可能误以为后台操作成功等于用户已经看到。重复动作没有触碰故障点。

C是对的下一步,不是对的第一步。 缩短 TTL 可以限制未来脏值的寿命,却不会主动删除当前剩余 19 小时的 key;统一短 TTL 还会提高回源频率。先精准止血,再按数据新鲜度设计 TTL。

D该先做的就是它:精准摘掉错误副本。 DB 已确认是 ¥680,删除单个 key 后让一个受控请求回源,能在秒级恢复正确价格;范围只覆盖事故 key,不会把正常缓存一起变冷。随后再修写路径,避免下一次更新继续漏删。

04 · 2 原理两份数据之间,每个窗口都要付费

缓存把读请求从数据库搬到内存,但写入后会出现两个事实源的短暂错位:DB 已经是新值,缓存仍是旧值;或者缓存先变,DB 还没完成。这个窗口再短,只要热点 key 的 QPS 足够高,错误就会被批量交付。

不一致窗口内的错误读次数 ≈ 窗口时长 × 该 key 读 QPS

把一个看似很小的窗口代进去:数据库更新完成后,应用过了 200ms 才删除缓存;该 key 正以 5,000 QPS 被读取。

不一致窗口
200ms
即 0.2 秒
热点读 QPS
5,000
次 / 秒
错误读
约 1,000
0.2 × 5,000
若窗口 2s
约 10,000
窗口放大 10 倍

200ms × 5,000 QPS = 约 1,000 次脏读。这就是缓存一致性最反直觉的账:毫秒级窗口在人类感知里很短,在高 QPS 下却足够复制一千次错误。讨论方案时不能只说“最终会一致”,还要写清窗口多长、窗口内谁会读错、读错代价是什么。

常见的四种读写模式,区别不在名字,而在谁负责填缓存、谁负责把写入持久化:

模式 读路径 写路径 主要账单
cache-aside 应用先读缓存;miss 后读 DB 并回填 应用写 DB,再更新或删除缓存 应用掌握业务语义,但双写顺序与失败补偿由应用承担
read-through 应用只读缓存;缓存组件在 miss 时加载 DB 通常仍需配套写策略 读逻辑集中,但缓存层要理解加载、错误与回源保护
write-through 读缓存即可获得已同步值 写缓存,缓存同步写 DB 后再确认 一致性路径集中,写延迟增加,缓存成为写入关键依赖
write-behind 读到缓存中的新值 缓存先确认,随后异步批量落 DB 吞吐高,但缓存故障、乱序与落库失败可能造成数据损失或回退

「秒抢」当前用 cache-aside,因为应用最清楚票价、场次状态和展示信息的读错代价。但自由也意味着责任:写 DB 成功、删缓存失败怎么办?并发读恰好在删缓存后、DB 提交前回填旧值怎么办?每一种顺序都要写失败分支,不能只画两根顺利的箭头。

缓存一致性不是追求某个动作“原子般优雅”,而是把错误窗口、失败补偿和业务代价写成契约。没有这份契约,高命中率只会更高效地交付旧数据。

下一章,也就是第 5 章,会处理缓存不命中时的三种流量死法;本章提醒你,缓存一直命中也可能出事。库存则更苛刻:第 10 章会说明为什么库存这个 key 连短暂旧值都可能换来超卖,今天这些通用方案届时要重新算账。

04 · 3 病历卡大盘全绿,工单却知道真相

缓存与 DB 双写不一致

病历卡 No.03
Cache / DB Dual-write Inconsistency · 两份值在失败窗口里分叉
机理
一次业务更新要触碰 DB 与缓存两个独立系统,中间没有共同事务。某一步失败、超时或被并发读穿插后,一边保存新值,另一边继续提供旧值;高命中率会让错误绕开权威 DB。
症状
▁▁▁▁▁ 无症状型——监控上看不见,只有工单。接口成功率、缓存命中率和 DB 延迟都可能正常;对账、抽样比对或用户投诉才暴露同一 key 的值分叉。
易发场景
cache-aside 写 DB 后漏删缓存;更新缓存成功但 DB 回滚;删除缓存与提交 DB 被并发读穿插;消息失效通知丢失;超长 TTL 让脏值长期存活。
处方
① 先定义权威源:明确以 DB 还是缓存为准;② 缩短错误窗口:写 DB 后删除缓存并对失败重试;③ 可观测对账:抽样比较版本号和值;④ 幂等失效事件:让漏掉的一次删除能够补发,而不是静默失联。
案例
「秒抢」运营将场次价格从 ¥580 改为 ¥680,后台写 DB 成功却漏删缓存;热点 key 以 5,000 QPS 持续命中旧值,监控全绿,直到 327 张客服工单把双写裂缝照出来。

这张卡最难记的地方,是它没有尖峰:▁▁▁▁▁。因此要主动制造“症状”——记录缓存值版本,抽样读取 DB 对比,对关键字段做业务对账。没有对账的双写不一致,可能在 TTL 到期后悄悄消失,也可能在用户账单上留下永久后果。

04 · 5 权衡更新缓存,还是删除缓存

写 DB 成功后,你要决定如何处理缓存。更新能让下一次读直接拿到新值,删除能让下一次读从权威源重建;版本化 key 则用新名字切走旧值。三个方案都成立,但失败窗口、读写比例和实现成本不同。

决策点 D2

场次详情更新后,缓存应该怎么处理?

A · 更新缓存:写后下一次读无需回源,新鲜值立即可用,热点不会突然 miss。 账单:应用要准确重建完整缓存对象;并发写可能把旧计算结果覆盖新值,DB 成功而缓存更新失败仍需补偿。适合对象简单、写入口收敛、更新后不能承受回源尖峰的场景。

B · 删除缓存:不在写路径重复拼装数据,下一次读从 DB 获取权威快照,逻辑较简单。 账单:删除后的首个读要付回源延迟;热点并发 miss 需要互斥回源,删缓存失败也要重试。适合读多写少、缓存对象由多表组合、能做好回源保护的场景。

C · 版本化 key:新旧版本可以并存,切换与回滚清晰,读者不会撞上半成品。 账单:需要版本指针、旧版本回收和跨 key 原子切换设计,内存占用与运维复杂度更高。适合大对象发布、配置批次切换、需要快速回滚的场景。

「秒抢」的选择:普通场次详情采用 B——DB 更新成功后删除缓存,删除失败进入幂等重试队列;热点 key 加互斥回源。理由是详情由多张表拼装,写路径直接更新容易漏字段,而读多写少让一次受控回源更划算。库存 key 不套用这个结论,第 10 章会单独处理它的强约束。

04 · 6 复盘那张 ¥580 后来怎么消失了

回放 16:40 的正确剧本——先恢复价格事实,再修复写路径,最后补上监控看不见的那只眼睛:

16:42

确认权威值。核对 DB 事务、后台审计记录与票价审批单,确认 ¥680 才是当前价格;暂停该场次新下单 30 秒,避免继续按错误展示成交。

16:43

精准失效。只删除 show:8842,用一个受控请求回填并比对版本。用户页在 2 秒内显示 ¥680,价格不一致新增工单停止。

16:49

补写路径。后台改为 DB 提交成功后发出幂等失效任务;删除失败自动重试并告警。连续修改三次测试价,缓存版本均在 120ms 内追上。

17:08

补业务观测。对价格、开售状态等关键字段做缓存/DB 抽样比对,告警不再只看命中率。回溯审计确认受影响订单,交由客服按差价规则处理。

壹
先定义哪份数据说了算。没有权威源,补偿程序只是在两份值之间猜;对「秒抢」详情,DB 是事实,缓存是可重建副本。
貳
高命中率不是正确率。缓存可以稳定、快速、低延迟地返回错误值。关键业务要监控版本与内容,而不只监控性能。
叁
为第二步失败准备补偿。双写之间没有共同事务时,删除失败、消息丢失和进程崩溃都要有幂等重试与对账兜底。

04 · 7 自测监控全绿时,你还会怀疑缓存吗

Q1 · 看形状诊断

接口成功率、延迟和缓存命中率都平稳,曲线呈 ▁▁▁▁▁;用户却持续报告页面价格与后台不同。最可能是?

A。 No.03 的记忆钩子正是「无症状,只有工单」。B 很诱人,因为它也与缓存有关,但雪崩会让命中率断崖下跌、DB 流量阶跃上升;题中性能指标全稳,说明问题在值的正确性而非可用性。

Q2 · 窗口算术

缓存与 DB 不一致 200ms,该 key 读取 5,000 QPS。窗口内大约会产生多少次错误读?

C。 200ms 是 0.2 秒,0.2 × 5,000 = 1,000。D 是最诱人的错误项,它忘了窗口只有一秒的五分之一;B 则把毫秒数直接当成请求数,没有乘读 QPS。

Q3 · 模式辨认

应用只向缓存写入,缓存确认后稍后批量落 DB。这个模式是什么,主要风险是什么?

C。 先在缓存确认、后异步持久化就是 write-behind。B 很诱人,因为名字里也有 write,但 write-through 要等同步写 DB 完成才确认;它牺牲写延迟,换取更直接的持久化路径。

Q4 · 顺序陷阱

cache-aside 更新时采用「先删缓存,再写 DB」。并发读恰好夹在两步之间,可能发生什么?

B。 删除后、DB 新值提交前,读 miss 会从 DB 拿旧值并回填;随后写 DB 完成,却没有第二次失效动作。A 是诱人的“数据库会替我排好序”直觉,但缓存回填不受该写事务保护。

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

「缓存一致性方案怎么设计?」 答题骨架:先定权威源与可接受窗口 → 选 cache-aside 等模式 → 写顺序与失败补偿 → 热点回源保护 → 版本比对与业务对账。
「为什么先删缓存再写库也有坑?」 答题骨架:画并发时序 → 删除后读 miss → 读到旧 DB 值并回填 → DB 新值提交 → 缓存遗留旧值 → 给出二次失效或改顺序的账单。
「延迟双删值不值?」 答题骨架:它缩小特定并发窗口 → 延迟长度依赖读耗时与复制延迟 → 第二次删除仍可能失败 → 不能替代重试和对账。反直觉加分点:先量错误窗口,再决定是否加复杂度。