AI 资讯 知识库 后端技术缓存一致性实战:先更新谁,才不会读到脏数据

缓存一致性实战:先更新谁,才不会读到脏数据

几乎每个后端工程师都背过这句口诀:先更新数据库,再删除缓存。但面试往下追问一层就露怯了——为什么是删除而不是更新缓存?为什么这个顺序就比反过来好?它明明也存在不一致窗口,为什么大家还敢用?这篇文章不打算给你「终极方案」,因为那东西不存在;它只做一件事:把缓存与数据库双写的几种时序逐一推演到翻车现场,让你知道每个方案漏风的口子在哪里、用什么材料去堵,最后给出经过生产检验的组合拳。

缓存一致性实战手绘信息图:Cache-Aside 基本盘、两个翻车现场、延迟双删与 binlog 兜底、生产组合拳

一、先立规矩:Cache-Aside 与「删除而非更新」

Cache-Aside(旁路缓存)是应用侧最常用的缓存模式:读的时候先查缓存,命中直接返回;未命中就查数据库,把结果回填进缓存再返回。写的时候先更新数据库,然后把对应的缓存删掉,等下次读取时重新加载。微软 Azure 架构文档对这个模式的定义里写死了一条顺序:更新数据存储,再删除缓存条目——如果反过来先删缓存,客户端可能在数据库更新完成前取数,一次缓存未命中会把数据库里的旧值读出来、回填进缓存,导致脏数据驻留。

写路径里更值得深挖的是:为什么是删除缓存,而不是把新值更新进缓存?三个理由。第一,删除是原子且幂等的操作,错了再删一次就行;而「把旧值更新进缓存」是一个覆盖写,后果严重得多——Facebook 在 NSDI'13 的 Memcache 论文里把这一点说得很直白:删错了 key 不是大事,把陈旧数据写进缓存才是灾难。第二,缓存是懒加载的语义,删掉之后冷数据根本不必再写回去,省掉写入那些没人读的数据。第三,也是最容易被忽略的:如果写路径是「更新数据库 + 更新缓存」两个动作,两个并发写请求的数据库写入顺序和缓存更新顺序可能相反——库里是 B 的新值,缓存里却留着 A 的旧值,而且会一直留下去。删除不存在这个问题:谁后删谁生效。

二、四种时序的翻车现场

把「写数据库」和「动缓存」的先后组合摆开,逐一推演并发时序。

方案一:先删缓存,再更新数据库。这是最危险的顺序。删掉缓存之后、数据库更新完成之前,任何读请求都会缓存未命中,从数据库读到旧值并回填——这个不一致窗口不是概率问题,而是写操作的整个耗时期间必然敞开。写库越慢,窗口越大。除非加锁把读写串行化,否则这个方案在并发下没有生存空间。

方案二:先更新数据库,再删除缓存。主流默认。但它也有一个著名的残余窗口:某个读请求在缓存失效后来查,读到数据库旧值;就在它还没来得及回填时,一个写请求完成了「更新数据库 + 删除缓存」;随后那个慢读把旧值回填进缓存——脏数据从此驻留到过期。推演到这里很多人会慌,但要看清窗口成立的三个苛刻条件:读请求出现在写之前、且读到了旧值、且它的回填动作晚于写请求的删除。同时满足的概率很低,而且还有一个兜底——只要缓存设了过期时间(TTL),脏数据的存在时长就被封顶了。这就是 Cache-Aside 的真实定位:不保证强一致,但把不一致收敛到一个以 TTL 为上界的小窗口里。微软文档也明说了:这个模式不保证数据存储与缓存之间的一致性。

方案三:更新缓存 + 更新数据库的各种变体。上面说过,双写顺序颠倒的风险让它天然不如删除,直接排除。

方案四:分布式锁串行化。按 key 加锁、读写排队,理论上可以做到强一致,代价是缓存的性能优势被锁消耗大半。只适合极低并发又对一致性苛刻的场景——先问自己是否真的需要。

三、把窗口钉死:延迟双删与 binlog 兜底

对方案二的残余窗口,工程上有两件成熟的补丁。

第一件:延迟双删。时序是「更新数据库 → 删除缓存 → 等待一小段时间 → 再删一次」。原理很朴素:那个造成污染的慢读,它的回填一定发生在读到旧值之后的一小段时间内,第二次删除等它回填完再清一次,脏数据就活不过两次删除之间。睡眠时间没有精确公式,业界经验是按业务的平均读耗时估算(常见数百毫秒到一秒量级);代价是写路径变慢,而且第二次删除同样可能失败——所以它堵住了大部分窗口,但不是零窗口。

第二件:binlog 订阅兜底,这是更工程化的解。用一个独立组件监听 MySQL 的 binlog,发现某行数据变更就删除对应缓存,删除失败自动重试,最终一定删成。阿里开源的 Canal 是这个路线的事实标准:它把自己伪装成 MySQL 的从库,向主库发送 dump 协议请求,接收并解析 binlog 字节流,官方列出的典型场景里就有「业务 cache 刷新」,1.1.x 起原生支持把变更投递到 Kafka/RocketMQ,GitHub 上约 29.7k star。这个项目本身也有些年头:它源于阿里杭州与美国双机房的跨机房同步需求,早期靠业务触发器,2010 年起逐步转向数据库日志解析——工程界用 binlog 解决同步问题,比很多人以为的要早得多。这条路的好处是把缓存失效从业务代码里彻底摘出去:不管是应用写、定时任务写还是 DBA 手工改数据,binlog 一律逃不掉;坏处是多一套中间件的运维成本,以及秒级以内的订阅延迟。

值得一提的是,binlog 兜底并非民间偏方,而是大型系统的标配思路:Facebook 那篇论文里的 mcsqueal 守护进程,就是在数据库服务器上监听提交日志、把失效请求广播到区域内所有缓存集群——和 Canal 的思路一模一样,只是早了十年。Facebook 还进一步把「回填」本身管了起来:缓存未命中时下发一个绑定 key 的 64 位 lease 令牌,回写缓存必须携带令牌,期间若发生过失效则令牌作废、写入被忽略——从机制上禁止旧值覆盖新值;同一个 key 十秒内只发一次令牌,顺带把缓存击群也防了。跨区域场景它做得更绝:写入时先在缓存里放一个远程标记(remote marker),在主库数据复制到本区域之前,其他读请求会直接被引到主区域去读——宁可多绕一次路,也不读正在变化的旧值。这套「best-effort 最终一致」的定位,与方案二加 TTL 的哲学一脉相承。

四、生产组合拳与避坑清单

把上面的结论收敛成一套默认配置:先更新数据库再删除缓存,缓存必设 TTL,删除失败进重试队列,要求更高再叠加延迟双删或 binlog 兜底。具体到落地,按条自查:

  1. TTL 必须设置,没有例外。它是所有异步方案最后的防线——哪怕删除全失败,不一致也被 TTL 封顶。量级是个两难:太短则命中率塌掉、回源压力陡增,太长则脏数据驻留越久;微软文档的建议是让过期策略贴合业务的访问模式与新鲜度容忍度,而不是统一拍一个数。
  2. 删除失败不能只打日志。把失败的 key 丢进消息队列重试,或直接上 Canal 订阅 binlog 做兜底,保证删除最终执行。
  3. 不要「更新缓存」,永远删除。双写乱序的风险前面已经推演过。
  4. 热点 key 回填要防击穿。缓存失效瞬间大量请求同时打到数据库,加互斥锁(同 key 同时只放一个请求回源)或用逻辑过期策略——这也是 Facebook lease 机制在解决的同一种病。顺带厘清容易混的三个词:击穿是热点 key 失效的瞬时洪峰,雪崩是大批 key 同时过期,穿透是查根本不存在的数据让缓存永远失效;本文讨论的一致性问题与三者正交,但落地时常用同一套互斥手段。
  5. 读写分离会放大窗口。若读走从库,「先更库再删缓存」之后、从库同步完成之前的读,回填的是从库旧值——主从延迟大的库要评估是否需要针对该 key 读主库。
  6. 监控三件事:删除失败率、重试队列积压、抽样比对缓存与库的数据 diff。一致性是靠监控发现问题的,不是靠信仰。
  7. 强一致需求先做减法。真需要「写后读立即可见」的个别 key,考虑读写穿透模式或按 key 串行化,把强一致的范围缩到最小,而不是全站上锁。

结语

缓存一致性的全部真相是一句话:没有零窗口的方案,只有收敛窗口的组合。Cache-Aside 给了你一个概率极小的残余窗口和 TTL 封顶,延迟双删和 binlog 订阅把窗口进一步钉死,Facebook 的 lease 机制证明工业界最终连「回填」这个动作都要管起来。下次有人问你「先更新谁」,答案依然是先更新数据库再删除缓存——但现在你知道这句话背后每一层为什么,以及它漏风的地方该用什么材料去堵。

参考资料

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?