Redis 把数据库读压力从红线拉回安全区,两周后却把一张旧价格牢牢守在用户面前。缓存不是一份免费副本;从写入那刻起,你就欠下了让两份数据重新相遇的账。
创业第 9 个月,「秒抢」累计用户约 5 万。公司 25 人,后端 5 人。数据库读压力连续一周逼近红线,团队刚把演出详情接入 Redis;上线两周,命中率漂亮得让每个人都松了口气。周四 16:40,运营却抱着电脑冲进开发区:「票价改了二十分钟,用户还在按旧价下单。」
阿凯把详情接口的四张卡片投到屏幕上。没有 5xx,没有慢查询,缓存命中率甚至比昨天更好——正因为它稳定命中,旧值才被稳定地交付给每个用户。
读路径是标准 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,不会把正常缓存一起变冷。随后再修写路径,避免下一次更新继续漏删。
缓存把读请求从数据库搬到内存,但写入后会出现两个事实源的短暂错位:DB 已经是新值,缓存仍是旧值;或者缓存先变,DB 还没完成。这个窗口再短,只要热点 key 的 QPS 足够高,错误就会被批量交付。
把一个看似很小的窗口代进去:数据库更新完成后,应用过了 200ms 才删除缓存;该 key 正以 5,000 QPS 被读取。
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 连短暂旧值都可能换来超卖,今天这些通用方案届时要重新算账。
这张卡最难记的地方,是它没有尖峰:▁▁▁▁▁。因此要主动制造“症状”——记录缓存值版本,抽样读取 DB 对比,对关键字段做业务对账。没有对账的双写不一致,可能在 TTL 到期后悄悄消失,也可能在用户账单上留下永久后果。
写 DB 成功后,你要决定如何处理缓存。更新能让下一次读直接拿到新值,删除能让下一次读从权威源重建;版本化 key 则用新名字切走旧值。三个方案都成立,但失败窗口、读写比例和实现成本不同。
A · 更新缓存:写后下一次读无需回源,新鲜值立即可用,热点不会突然 miss。 账单:应用要准确重建完整缓存对象;并发写可能把旧计算结果覆盖新值,DB 成功而缓存更新失败仍需补偿。适合对象简单、写入口收敛、更新后不能承受回源尖峰的场景。
B · 删除缓存:不在写路径重复拼装数据,下一次读从 DB 获取权威快照,逻辑较简单。 账单:删除后的首个读要付回源延迟;热点并发 miss 需要互斥回源,删缓存失败也要重试。适合读多写少、缓存对象由多表组合、能做好回源保护的场景。
C · 版本化 key:新旧版本可以并存,切换与回滚清晰,读者不会撞上半成品。 账单:需要版本指针、旧版本回收和跨 key 原子切换设计,内存占用与运维复杂度更高。适合大对象发布、配置批次切换、需要快速回滚的场景。
「秒抢」的选择:普通场次详情采用 B——DB 更新成功后删除缓存,删除失败进入幂等重试队列;热点 key 加互斥回源。理由是详情由多张表拼装,写路径直接更新容易漏字段,而读多写少让一次受控回源更划算。库存 key 不套用这个结论,第 10 章会单独处理它的强约束。
回放 16:40 的正确剧本——先恢复价格事实,再修复写路径,最后补上监控看不见的那只眼睛:
确认权威值。核对 DB 事务、后台审计记录与票价审批单,确认 ¥680 才是当前价格;暂停该场次新下单 30 秒,避免继续按错误展示成交。
精准失效。只删除 show:8842,用一个受控请求回填并比对版本。用户页在 2 秒内显示 ¥680,价格不一致新增工单停止。
补写路径。后台改为 DB 提交成功后发出幂等失效任务;删除失败自动重试并告警。连续修改三次测试价,缓存版本均在 120ms 内追上。
补业务观测。对价格、开售状态等关键字段做缓存/DB 抽样比对,告警不再只看命中率。回溯审计确认受影响订单,交由客服按差价规则处理。
接口成功率、延迟和缓存命中率都平稳,曲线呈 ▁▁▁▁▁;用户却持续报告页面价格与后台不同。最可能是?
A。 No.03 的记忆钩子正是「无症状,只有工单」。B 很诱人,因为它也与缓存有关,但雪崩会让命中率断崖下跌、DB 流量阶跃上升;题中性能指标全稳,说明问题在值的正确性而非可用性。
缓存与 DB 不一致 200ms,该 key 读取 5,000 QPS。窗口内大约会产生多少次错误读?
C。
200ms 是 0.2 秒,0.2 × 5,000 = 1,000。D 是最诱人的错误项,它忘了窗口只有一秒的五分之一;B 则把毫秒数直接当成请求数,没有乘读 QPS。
应用只向缓存写入,缓存确认后稍后批量落 DB。这个模式是什么,主要风险是什么?
C。 先在缓存确认、后异步持久化就是 write-behind。B 很诱人,因为名字里也有 write,但 write-through 要等同步写 DB 完成才确认;它牺牲写延迟,换取更直接的持久化路径。
cache-aside 更新时采用「先删缓存,再写 DB」。并发读恰好夹在两步之间,可能发生什么?
B。 删除后、DB 新值提交前,读 miss 会从 DB 拿旧值并回填;随后写 DB 完成,却没有第二次失效动作。A 是诱人的“数据库会替我排好序”直觉,但缓存回填不受该写事务保护。