先更新数据库,再删缓存:一句口诀背后的完整推演

后端面试有一道流传最广的题:缓存和数据库的一致性怎么保证?标准答案只有一句口诀——”先更新数据库,再删除缓存”。几乎人人会背,但能完整推演出”为什么偏偏是这个顺序”的人,凤毛麟角。

口诀不是教条,而是并发时序推演的结果。这篇就把它从头推导一遍:先证明口诀为什么成立,再看它在哪些场景下会失效,最后给出生产环境的完整防线。看完你会发现,缓存一致性从来不是一道”是/否”题,而是一道概率题——所有方案的目标,都是把脏数据窗口压到业务可以接受的尺寸。

一、从并发时序推起

最简场景:一行数据同时存在于数据库和 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 给一切意外封了顶。三层防线各管一段,组合起来才是生产可用的答案——而那个”先更新数据库,再删缓存”的口诀,只是其中成本最低的第一层。

这个视角同样能迁移到分布式系统的其他一致性问题上:消息不丢、主从同步、多副本收敛,本质上都是同一门手艺——识别脏窗口的来源,用分层机制把它的上限压到业务能接受的尺寸。一致性工程师和普通工程师的差距,不在会不会背口诀,而在能不能算清每一层防线各堵住了哪种时序、漏掉的又由谁来兜底。

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容