先写库还是先删缓存?一次推演说清

先写DB再删缓存不是规范约定,而是两种并发顺序的窗口博弈结果:先删后写留下TTL级长窗口,先写后删只留毫秒级短窗口。本文顺着时序推一遍,并给极端竞态兜底方案。

封面

你大概率背过这条八股:缓存和数据库一起更新时,先更新数据库,再删缓存。面试这么答,博客这么写,团队规范也这么定。答案不在规范里,在两种顺序的并发窗口里。

但很少有人追问一句:为什么不是反过来?先删缓存、再写数据库,难道不行吗?

这不是抬杠。高并发系统里,“改完数据刷新页面却看到旧值"是一类常见、而且排查成本很高的故障,查到最后往往就是更新顺序没想清楚。两种顺序最终都能让数据一致,区别不在"对不对”,而在它们各自的并发时序里留下了多长的"脏数据窗口"。

本文不甩结论,把两条路径摆开,顺着并发时序推一遍,你会看清哪个更稳,以及什么时候该兜底。

缓存里躺的多是读多写少、又能容忍短暂不一致的数据:用户资料、商品详情、配置项。正因为它读得多,并发读写重叠就是常态,更新顺序才会在生产环境真实地制造故障,而不是只在面试题里出现。


一、先删缓存,再写 DB:旧值被回填的长窗口

先把模式说清楚。Cache-Aside(旁路缓存),读的时候如果缓存没命中,才回源数据库把数据取出来填进缓存;写的时候只更新数据库,再删掉缓存里的那个 key,让下一次读重新从数据库加载,而不是去改写缓存里的值。为什么是"删"而不是"更新",原因后面会专门点破,先记住这个读写形态。

为了让你有体感,我们看一个最常见的场景:用户改昵称。

假设用户把昵称从"老张"改成"老张Plus"。同一时刻,另一个请求(比如首页信息流、或者同一个用户的另一个接口)正好要读这个昵称。我们把写请求叫 A,读请求叫 B,A 和 B 在时间上重叠。

如果采用"先删缓存,再写 DB",时序是这样的:

先删缓存再写DB的并发时序:删缓存后、写DB前的空窗期内并发读把旧值回填

关键就在第 1 步到第 5 步之间。A 把缓存删空之后、还没把数据库改成新值之前,这段空窗期内任何一个读请求都会 miss,都会回源数据库读到旧值,再把旧值填回缓存。

等 A 终于把数据库写完了,缓存里躺着的已经是旧值,而且没有任何机制会再去纠正它,除非等 TTL 过期,或者等下一次更新再次删缓存。

也就是说,一次并发读,把脏数据的存活时间从"写数据库的耗时"一路撑到了"整个 TTL"。缓存 TTL 设个几十秒到几分钟是常事,那这几十秒到几分钟里,所有读这个昵称的地方看到的都是旧名字。用户改完刷新,看到的还是旧名字,要等快一分钟才"自己变好"。更糟的是,这次回填把 TTL 又刷新了一轮,相当于脏数据"续了命"。

这是一个长窗口:脏数据驻留到 TTL 才消失,期间所有读都受害。

空窗期示意:并发读在删缓存后、写DB前的空窗内回填旧值的时间跨度

别以为这只是小概率。空窗期里会不会被并发读命中,取决于窗口长度乘以接口的 QPS。写数据库耗个几毫秒到上百毫秒,期间如果接口每秒几千次读,在高 QPS 下极容易有读请求落进这个空窗、把旧值填回去。QPS 越高,长窗口越像是"一定触发"而非"可能触发"。这正是先删后写在生产环境最危险的地方:它不是偶发,是高频必现。

还有一个容易被忽略的点:先删后写的空窗,本身就比先写后删的空窗更长。因为"写数据库"通常比"删缓存"慢得多,删缓存是一次(通常跨网络的)内存/Redis 操作,写数据库要落盘、走事务、还可能涉及索引和维护开销。先删后写的回填窗口要等那个更慢的写完成才关闭,可脏数据已经写进缓存、会一直驻留到 TTL 才消失,写完成不等于一致;先写后删只需等那个更快的删完成,删完即一致。两头等待的速度本来就不同,窗口起点就不一样,这又多了一条支持"先写后删"的理由。


二、先写 DB,再删缓存:一次性脏读的短窗口

换过来,采用"先写 DB,再删缓存",同样的两个请求重叠,时序完全不同:

先写DB再删缓存的并发时序:写DB后、删缓存前仅有一次命中旧值的短窗口

区别在哪?A 先把数据库写成新值,这之后到 A 删缓存之前,缓存里仍是旧值。这段时间内读请求 B 会命中旧值,但也只是这一次读到旧值。A 一删缓存,缓存变空,后续任何读都 miss,回源数据库已经是新值,填回去的就是新的。

不一致窗口的长度,等于"删一次缓存"的耗时,毫秒级。这是一个极短窗口:脏数据最多影响删缓存之前那一次读,删完立刻一致。

量级感很重要:一边是 TTL 的几十秒到几分钟,一边是删缓存的毫秒级。差出两到三个数量级。这就是为什么两种顺序不能混为一谈,它们留下的"错误可见时间"根本不在一个尺度上。面试爱考这题,不是考你背结论,是考你能不能想到这层量级差。很多人答得出"先写后删",答不出"为什么",而"为什么"恰恰是工程里真正用得上的部分。

极端时序下慢读仍可能回填旧值:B 在 A 写库前发起读且读库比写加删更慢

不过我得诚实补一句边界,免得把这个结论神化。Cache-Aside 不是强一致方案。“先写后删"在极端时序下仍可能回填旧值:如果 B 在 A 写数据库之前就发起了读,而且 B 读数据库比 A 的"写+删"还慢,那 B 仍可能把旧值填回去。

这个竞态触发条件很苛刻:B 必须在 A 写库前发起读,且读库比一次写加删还慢。现实里普通点查读库通常比"写+删"快,所以概率极低;但在慢查询、大表全表扫描、或者缓存节点抖动导致回源变慢时,这个前提会被放大。它理论上存在,一旦发生,脏数据同样驻留到 TTL。所以准确的说法是:先写后删不保证零脏数据,它只是把"高概率长窗口"压成了"极低概率短窗口”。要彻底消除这个极端竞态,需要第四章的兜底。

也别把"读比写慢"当成纯理论。生产环境里它真实存在:一条没走索引的慢查询、一个锁等待中的大事务、或者读的是刚同步延迟的从库,任意一种都能让一次读比"写+删"更慢。并发量一上来,这些慢读和写请求重叠的概率并不低,极端竞态也就从纸面走进了监控。


三、结论:窗口长短决定一切

把两条路径放在一起看,差异就一句话:

维度 先删缓存再写 DB 先写 DB 再删缓存
脏数据触发条件 删缓存后、写 DB 前的任意读都会回填旧值 写 DB 后、删缓存前的读命中旧值(一次性)
脏数据驻留时长 到 TTL 过期(长,秒级到分钟级) 到删缓存完成(短,毫秒级)
触发概率 高(空窗期内任何读都触发) 低(仅单次命中)
自愈机制 等 TTL 或下次更新 删缓存即自愈
不一致窗口

所以"先更新 DB 再删缓存"被推荐,不是因为某本规范更权威,而是两种顺序并发窗口博弈的结果:先删后写,让删缓存后、写 DB 前的任意读把旧值回填,脏数据驻留到 TTL;先写后删,只让写 DB 后、删缓存前的那一次读命中旧值,删完立刻一致。

两种顺序的脏数据窗口长度对比:TTL 级长窗口 vs 毫秒级短窗口的量级差

这里顺带回应一下模式本身。Cache-Aside 作为经典旁路缓存模式,各大缓存模式文档(如 AWS / Azure 的 Cache-Aside 定义)都有论述(外部引用,仅供佐证)。但那些资料大多只告诉你"先操作 DB 再淘汰缓存",没解释为什么。本文补的正是这个"为什么",它不是约定俗成,是并发时序推出来的最优解。

至于开头埋的引子"为什么是删缓存而不是更新缓存":如果写的时候去更新缓存里的值,那在"写 DB"和"写缓存"之间又是一个并发窗口,而且更新缓存本身常常是一次无效写(写进去的值可能立刻又被下一次写覆盖)。删,比更新更简单也更安全,把"加载正确值"推迟到下一次读,让一次读去消化这个不一致窗口。这也是同一套"缩短窗口"思路的延伸,能用一次读承担的窗口,就不要用一个独立的写操作去制造新的窗口。

当然,删也有代价:删掉一个热点 key 后,并发读会集体 miss、一起回源打 DB(缓存击穿 / 惊群)。但相比"长窗口脏数据",击穿是一次性、可防护的(互斥重建或逻辑过期),而长窗口是持续可见的错误,两者不是一个量级的代价。所以"删优于更新"成立,前提是热点 key 的击穿要单独兜底,这和第四章的极端竞态兜底是两件事。

工程上还有一层理由让"先 DB 后删"更稳:删缓存失败,比更新缓存失败好处理得多。删失败了,重试一次就行,删是幂等操作;更新失败了,你还得想清楚要不要回滚数据库、要不要补偿,语义复杂。把缓存失效做成"删"而不是"写",失败面更小,这正是 Cache-Aside 用删不用更新的深层原因。

顺着这个失败面,还能看清"先 DB 后删"的容错形态:如果删缓存这一步抛了异常,最坏情况是缓存留着旧值,但数据库已是新值,下次重试删除或等 TTL 都能纠正,不会让"更新"完全不生效。反过来,若用"先删缓存再写 DB"且删缓存失败,那数据库根本没被碰,缓存也没了,整次更新等于白做。两种顺序在失败面上的差别,同样是"窗口长短"思路的延伸,把不可逆的写操作放在前面,把可重试的删放在后面,系统的失败态更可控。


四、兜底:延迟双删与 binlog 可靠删除

第二章讲了极端竞态仍可能回填旧值。要不要兜底,取决于你对一致性窗口的容忍度。两个常见手段。

延迟双删,是给"先写后删"补的一道保险:写完数据库、删一次缓存之后,等一个短延时,再删一次,把极端时序里被回填的旧值清掉。

延迟双删时序:第一次删除后等待 Δt,第二次删除清掉极端时序回填的旧值

这里的延迟时长 Δt 是个经验值,通常取几百毫秒到秒级,原则是大于"一次正常读库加回填"的耗时。不能写死成常数,也不能取太长,取小了兜不住慢读,取大了反而把第二次删除前的窗口又拉长一截。具体取多少有个可操作的参考:略大于该接口"读库加回填"的 P99 耗时,所以它更像随接口性能调的旋钮,不是拍脑袋的常数。

实现上也有轻重之分。最简单是在业务线程里 sleep 一下再删,但会阻塞写请求;更干净的做法是发一条延时消息到消息队列,或者丢进定时任务扫变更表,由异步消费者执行第二次删除,不占用写路径。

第二次删除同样可能失败,所以异步消费者要带重试和告警。别以为双删就万无一失,它只是把极端竞态的概率再压低一截,兜底始终是"尽力而为",不是强一致,失败面还要靠监控兜住。

binlog / CDC 可靠删除,把"删缓存"从写路径里彻底解耦。监听 MySQL 的 binlog(用 Canal、Debezium 这类工具),数据变更一提交,由独立的消费者异步把各级缓存失效掉:

binlog/CDC 可靠删除:变更提交后由 CDC 消费者统一失效 Redis L2 与本地 L1 缓存

这招妙在它绕开了"先写还是先删"的顺序博弈:无论业务怎么写,只要数据库提交了,binlog 就出来,消费者就删缓存。它还顺手解决了仓库里另一篇文章 redis-vs-caffeine 留下的"删除时序争议",多实例(本地 L1 + Redis L2)下,到底先删 L2 还是先删 L1 本身就有争议、容易出错。CDC 让所有缓存层订阅同一个变更事件,由消费者统一保证失效,那个顺序争议就失去了意义。另外,binlog 可能重复投递,而删除是幂等操作,天然不怕重复,这一点又呼应了"删比更新好",如果用更新,重复投递就可能写出错乱的值。

注意:CDC 解决的是缓存失效顺序,管不了读路径打到延迟从库的情况。若读走从库,即使先写后删 + CDC 已删缓存,读请求仍可能从滞后的从库回填 V_old。上线后出现"改完还看到旧值",很大概率是这个根因。要么保证读主库或已追平的从库,要么读写都走同一一致性视图,否则 CDC 也兜不住。这恰是强一致诉求的边界(见本节)。

两个手段怎么选:延迟双删轻量,业务内加个延时任务就能做,适合对一致性窗口敏感但不想引入新组件的团队。

CDC 更可靠、失效更统一,但要引入 Canal/Debezium 加消息队列的消费管道,架构复杂度上升,还要处理消费积压和断点续传。两者都是最终一致,不是强一致。


五、边界:强一致不该硬上缓存

最后划一条边界,免得这个最优解被用错地方。

有人会说:“那双写(更新 DB 的同时更新缓存)不是更直接?“仓库里 go-cache-system 一文就讲过双写。双写在并发下的问题不比先删后写小:写 DB 和写缓存之间各有一个窗口,任一失败即留脏窗口,比先 DB 后删那种单一、可自愈的短窗口更脆弱。双写成功时缓存最终会自愈,但失败面是两个写操作的叠加,容错形态更差。先 DB 后删,远比双写干净。

更要紧的是:缓存的定位是加速读,不是保证一致。如果你的场景要求强一致,读必须永远拿到最新写,那正确的解法不是把缓存更新顺序调得更精妙,而是这个数据本就不该硬上缓存,或者走写穿透(Write-Through)由缓存层自己保证一致。但 Write-Through 的代价是写路径变重:每次写都要先过缓存层,缓存挂了写也受影响,或者要同步双写并自己处理失败,复杂度陡增。这恰好呼应了 redis-engineering 一文给出的 Cache-Aside 退出条件:强一致诉求下,旁路缓存不是合适的工具,强行调顺序只是在错误的抽象上打补丁。所以动手调顺序之前,先问一句:这数据真的适合放缓存吗?

这也解释了为什么"先写后删"是默认答案:它把脏数据窗口压到最短,剩下的极端竞态才需要第四章去兜底。而 redis-engineering 的 Cache-Aside 模式、redis-vs-caffeine 的多实例删除时序争议、go-cache-system 的双写坑,本质都是在同一个问题上从不同角度踩坑,本文补上的,是那个被反复引用、却少有人论证的"为什么”。

回看开头:缓存适合的是读多写少、能容忍短暂不一致的数据。正因为读多,并发重叠才成为常态,更新顺序才从八股变成真问题。如果你的数据不满足这个条件,写远多于读,或者读必须永远最新,那这篇文章给的不是"调顺序"的配方,而是"别用缓存"的提醒。顺序能优化的是最终一致系统的窗口,优化不了强一致的硬约束。

仓库文章闭环:redis-engineering 模式 → redis-vs-caffeine 时序争议 → go-cache-system 双写坑 → 本文顺序论证与兜底


写在最后

给你一张决策表,覆盖了本文所有的判断点:

你的场景 推荐顺序 理由 是否需要兜底
最终一致,能接受毫秒级脏读 先写 DB,再删缓存 窗口最短,删完即一致 多数情况不需要
对极端竞态零容忍,团队轻量 先写 DB,再删缓存 + 延迟双删 兜底极端回填 延迟双删
多实例、变更频繁、要统一失效 先写 DB,再删缓存 + binlog/CDC 绕开顺序博弈,统一失效 CDC
强一致,读必须最新 不该硬上缓存 旁路缓存非强一致方案 改用其他方案

如果你现在的代码就是先删后写,也别慌着大改。把两行顺序对调成本极低,但要同步确认两件事:删缓存失败时有重试,别让"删失败"静默放过;监控里有缓存命中率和脏数据投诉的观测。顺序对了,失败面还要有人看着。

一句话收束:先写库,再删缓存,是因为脏数据的窗口更短。其余都是细节。

决策板:按场景选择更新顺序与兜底手段

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →