后端面试有一道流传最广的题:缓存和数据库的一致性怎么保证?标准答案只有一句口诀——”先更新数据库,再删除缓存”。几乎人人会背,但能完整推演出”为什么偏偏是这个顺序”的人,凤毛麟角。
口诀不是教条,而是并发时序推演的结果。这篇就把它从头推导一遍:先证明口诀为什么成立,再看它在哪些场景下会失效,最后给出生产环境的完整防线。看完你会发现,缓存一致性从来不是一道”是/否”题,而是一道概率题——所有方案的目标,都是把脏数据窗口压到业务可以接受的尺寸。
一、从并发时序推起
最简场景:一行数据同时存在于数据库和 Redis,一个写请求和一个读请求正在并发。我们来枚举可能的交错,看哪种顺序会留下脏数据。
假设读请求的固定动作是:查缓存 → 未命中则查库 → 把结果回填缓存。写请求的候选方案有两种:方案甲,先删缓存、再更新库;方案乙,先更新库、再删缓存。
方案甲的致命时序是这样的:
| 步骤 | 写请求 | 读请求 |
|---|---|---|
| 1 | 删除缓存 | — |
| 2 | — | 查缓存未命中 |
| 3 | — | 查数据库,读到旧值 |
| 4 | 更新数据库为新值 | — |
| 5 | — | 把旧值回填缓存 |
| 6 | —(结束,但缓存里躺着旧值) | — |
写请求全部干完了,缓存里却被读请求回填了旧值——而且没有任何后续动作会修正它,脏数据将一直存活到过期时间为止。问题出在”删缓存”和”更新库”之间有个空档,恰好被读请求钻了进去,还把读到的旧值主动保存了下来。
方案乙呢?把顺序反过来:先更新库、再删缓存。理论上也有一个脏窗口——读请求在缓存未命中后查到旧值,写请求此时更新库并删缓存,随后读请求才回填。注意这个时序成立的条件:读请求”查库”发生在”写库提交”之前,而”回填缓存”发生在”删除缓存”之后,也就是一次读库操作跨越了整个写事务的耗时。写库通常是毫秒级,读库通常远快于它——这件事可能发生,但概率极低,且脏数据会在下一次删除或过期时自愈。
两个方案对比,结论自然浮出水面:先更新数据库,再删除缓存。它不是把脏窗口消灭为零,而是把一个大而致命的窗口,压缩成一个极小且自愈的窗口。
还有一个子问题:为什么是”删”缓存,而不是”更新”缓存?想象两个写请求并发:A 要把库存改成 1,B 要改成 2。数据库层面有锁保证提交顺序是 A 后 B,最终库里的值是 2;但两个请求”更新缓存”的动作没有数据库锁管着,完全可能 B 先写了缓存、A 后写——缓存里留下 1,库是 2,又是一对冤家。删除则没有这个问题:它幂等、无序无害,删完把”缓存里是什么”交给下一次读请求的 lazy load,天然和数据库的最终值对齐。
二、口诀救不了你的三个场景
口诀是必要条件,不是充分条件。以下三种生产环境的高发场景,光靠它不够。
场景一:主从延迟下的”删了又脏”。 你的库是主从架构,读写分离。写请求更新主库、删除缓存;另一头某个读请求因为缓存已删而回源,但负载均衡把它打到了从库——从库还没同步到这次更新,读到的又是旧值,照样回填。脏数据的源头从”写时序”变成了”复制时序”,口诀管不到它。
场景二:删除失败。 更新库成功,删缓存那一步网络抖动失败了。缓存里的旧值从此逍遥法外,直到 TTL 到期。单次删除是一个不可靠操作,而不可靠操作不能作为一致性方案的唯一支柱。
场景三:业务假装自己需要强一致。 有人执着于”缓存必须永远和数据库一模一样”,于是上分布式锁、把读写串行化,缓存架构复杂三倍,性能优势归零。这不是方案问题,是需求误判——下文会讲,真正需要强一致的数据,根本不该走这套缓存。
三、生产环境的完整防线
成熟的方案是分层布防,每一层各自负责压掉一类脏窗口。
第一层:口诀顺序。 先更新库、再删缓存,缩小并发时序窗口——这是地基,上文已推导。
第二层:延迟双删与 binlog 兜底。 针对主从延迟,最直接的武器是延迟双删:更新库并删缓存之后,等一段时间再删一次,把从库追平期间可能产生的错误回填清掉。
write(key, value):
1. 更新主库
2. 删除缓存 —— 常规操作
3. 延迟 D 毫秒后,再次删除缓存 —— D ≥ 主从延迟 + 一次读回填的耗时
延迟双删的麻烦在于”延迟多久”是经验值,而且业务代码里挂着一个定时器,不够优雅。更工程化的做法是把第二次删除从业务代码里剥离出去:用 Canal 这类工具订阅数据库的 binlog,一旦捕获到变更事件,就异步执行一次缓存删除,失败进重试队列。业务代码只管更新库,一致性维护变成数据库变更的”影子流程”,可靠且可观测。
binlog 订阅者(独立进程):
on_row_change(table, key):
del cache(key)
on_failure:
send_to_retry_queue(key) # 重试队列消费前按退避策略重删
第三层:TTL 兜底。 给缓存一律设过期时间,哪怕它看起来永远不该过期。TTL 的意义不是性能,而是一致性方案的”最终防线”:前面所有机制失效的情况下,脏数据的存活时间有一个确定的上界。工程上可以据此定 SLA——”任何脏数据存活不超过 N 秒”,这就是你可以向业务方承诺的最终一致性等级。
最后回应场景三:一致性是要花钱买的,买多少,取决于业务语义。商品详情晚几秒无感,允许最终一致;库存扣减、账户余额差一秒都是事故,这类数据的正确姿势是绕开缓存直查库,或用锁串行化,让”热点只读数据”才享受缓存。按数据分级选择一致性等级,比寻找万能方案现实得多。
四、概率题,不是布尔题
把整篇推演收拢成一句话:缓存与数据库的一致性,追求的从来不是”相等”,而是”脏窗口足够小且必然自愈”。
口诀缩小了写路径的窗口,延迟双删和 binlog 订阅覆盖了复制路径的窗口,TTL 给一切意外封了顶。三层防线各管一段,组合起来才是生产可用的答案——而那个”先更新数据库,再删缓存”的口诀,只是其中成本最低的第一层。
这个视角同样能迁移到分布式系统的其他一致性问题上:消息不丢、主从同步、多副本收敛,本质上都是同一门手艺——识别脏窗口的来源,用分层机制把它的上限压到业务能接受的尺寸。一致性工程师和普通工程师的差距,不在会不会背口诀,而在能不能算清每一层防线各堵住了哪种时序、漏掉的又由谁来兜底。











暂无评论内容