做后端这些年,“缓存和数据库不一致”几乎是我排查过最多的疑难杂症之一。面试必问,线上必踩,很多系统跑着跑着就会出现“数据怎么变了又变回去”“刷新几次结果不一样”的诡异现象。旁路缓存模式(Cache-Aside Pattern)作为最基础的缓存策略,理论上讲应该是“先更新数据库,再删除缓存”这么简单的一件事,但真正把它跑稳、跑对,远比背出这一句话复杂得多。
这篇文章我想从一次线上事故讲起,把旁路缓存模式下的一致性竞态完整推演一遍,说清楚为什么“先更新数据库再删缓存”比“先删缓存再更新数据库”安全,删除缓存失败之后有哪些兜底手段,以及最终一致窗口到底应该怎么估算。内容适合正在做缓存设计、或者被缓存一致性问题折磨过的后端开发,也适合准备系统设计面试的同学。不会只讲概念,会把能落地的东西都摆出来。
1. 不一致的场景推演:读写并发时缓存里到底发生了什么
1.1 先从旁路缓存模式的读写路径说起
旁路缓存模式的规则其实非常简单,四条路走完:
- 读请求:先查缓存,命中就直接返回;没命中就去数据库查,查到后回填缓存再返回。
- 写请求:先更新数据库,然后删除缓存。
这个模式的关键在于“删除缓存”而不是“更新缓存”,这一点我在后面会展开说。先记住一个事实:在旁路缓存模式下,缓存里的数据不是由写请求主动设置的,而是由下一次读请求回填的。
这也是它被称为“旁路”(Cache-Aside)的原因——应用层主动管理缓存的数据加载和失效,缓存完全在业务主链路旁边工作。
1.2 一个典型的脏数据事故时间线
先看一个我实际遇到过的场景。某个商品服务,商品信息存在 MySQL,Redis 里缓存了商品详情,TTL 设置的是 30 分钟。
正常情况没问题,但某天运营后台批量修改了一批商品的名称,修改完成后,线上商品详情页出现了“名称已经改了,但还是显示旧名称”的投诉。
排查完代码后发现一个非常经典的竞态:
时间线: T1: 写请求开始,删除缓存 T2: 写请求执行 UPDATE 商品表 SET name='新名称' WHERE id=100 T3: 读请求到达,此时缓存已被删除,于是去查数据库 T4: 读请求在 T2 提交事务之前查到了旧名称 T5: 读请求把旧名称回填到缓存 T6: 写请求事务提交,数据库变成新名称 T7: 后续所有读请求命中缓存,展示的是旧名称如果没有人再去改这条数据,这个旧名称会在缓存里一直存活到 TTL 过期,也就是最长 30 分钟。而实际上运营发现投诉的时候,已经过了十几分钟,用户看到的就是一个“修改不生效”的假象。
这个事故的根源就是“先删缓存、再更新数据库”这种写顺序。删除缓存之后到数据库更新完成之间存在一个窗口,读请求一旦在这个窗口内进来,就会把旧值放回缓存。而且这个回填行为是无害的、合法的——读请求根本不知道有个写请求正在执行。
1.3 为什么说“删除缓存”之后再回填的风险比想象中大
在这个竞态里,真正可怕的地方不是读请求读到了旧值,而是读请求回填旧值这个动作。
读请求的正常逻辑是“缓存没有,那我就从数据库读取并回填”,这是旁路缓存模式赖以工作的基础,但它的前提是“数据库里的数据是当前最新值”。一旦这个前提因为并发写而被打破,读请求就从一个“加速者”变成了“脏数据固化器”。
更要命的是,这个脏数据一旦被固化,在没有任何后续写操作的情况下,很难被自动纠正。只能等 TTL 到期,或者等下一次针对同一条数据的写入把它删掉。如果这是一条低频更新的数据,TTL 又设置得很长,用户会在很长一段时间内持续看到旧值。
这也是为什么网上几乎所有讨论这个模式的文章都会强调:不要用“先删缓存再更新数据库”这个顺序。它不是百分百出问题,而是风险窗口不可控,问题出现后的恢复时间也不可控。
2. 写序之争:先更库还是先删缓存,哪个更安全
2.1 两种写顺序的完整竞态对比
很多开发兄弟都知道“先更新数据库,再删除缓存”是对的,但如果追问一句“为什么对”,能答清楚的人不多。这里用最笨的办法,把两种顺序的竞态窗口都摆出来,答案就自动浮出来了。
先看“先更库再删缓存”的完整时间线:
时间线: T1: 写请求开始,UPDATE 数据库 SET name='新名称' WHERE id=100 T2: 写请求事务提交,数据库已是新名称 T3: 写请求执行 DEL 缓存 T4: 读请求在 T3 之前到达,缓存中命中的还是旧名称 T5: 读请求直接返回旧名称 T6: T3 执行完成,后续读请求命中缓存失败,从数据库回填新名称这个顺序下确实也有不一致窗口,从 T2 到 T3 之间,读请求可能读到旧值。但这个窗口有几个特点:
- 窗口极短,就是“数据库更新完成”到“缓存删除完成”之间的毫秒级时间。
- 读请求不会回填旧值,因为缓存里还有旧值,读请求直接返回了,没有触发“去数据库查询”的逻辑。
- 窗口是自然收敛的,删除操作完成后,旧值就消失了,不需要任何额外干预。
再看“先删缓存再更库”:
时间线: T1: 写请求开始,DEL 缓存 T2: 写请求执行 UPDATE 数据库 T3: 读请求到达,缓存未命中,查询数据库 T4: 读请求查到旧名称(数据库还没提交事务) T5: 读请求回填旧名称到缓存 T6: 写请求事务提交,数据库变成新名称 T7: 后续读请求命中缓存,拿到旧名称,直到 TTL 过期两组时间线放在一起对比,差别一目了然:前者的风险是一个极短暂且会自动消失的“读到旧值”现象,后者的风险是一个不确定时长、需要人工或 TTL 兜底的“脏数据固化”现象。
2.2 为什么不同时更新两条链路,却一定要“删除”而不是“更新”
还有一个高频问题:既然都要操作缓存,为什么不直接SET新值到缓存,而是删除缓存?
答案在于“更新缓存”在并发场景下会引入一个严重的乱序问题。假设有两个写请求 W1 和 W2 并发执行,数据库层面的最终结果由事务提交顺序决定,假设是先 W1 后 W2,数据库最终值是 V2 对应的数据。但在缓存更新这条链路上,没有数据库事务那样的顺序保证,可能 W2 的SET先执行了,W1 的SET后执行,结果缓存最终存的是 V1。
这就造成了数据库是 V2、缓存是 V1 的长期不一致。问题在于,缓存层没有一个“提交顺序”的概念,更新缓存这个动作天然无法保持和数据库一致的先后顺序。
删除缓存就没有这个问题。删除之后,下一次读请求会把当前数据库里的最新值加载回来,不存在“把旧值覆盖新值”的可能。
用个生活化的例子:如果一个便签板上写着旧信息,你想换成新信息,有两种方式,一是直接把新信息写上去,但这需要你保证“最后写上去的是最新的”,在多人并发的场景下很难做到;二是把便签板清空,让下一个人根据当前真实状态重新写。删除缓存就是在做“清空便签板”这件事。
这个逻辑就是旁路缓存模式最核心的设计:缓存失效永远好过缓存更新,因为失效天然规避了写入顺序问题。
2.3 删缓存比更新缓存更适合做补偿
删除缓存的另一个优势是“幂等”。删除一个不存在的 key 就是删除一个不存在的 key,没有副作用。但如果更新缓存,就需要知道要更新成什么值,而这个值可能已经被另一个写请求刷新过了,更新操作反而会覆盖别人写入的新值。
在分布式系统里,凡是涉及“补偿重试”的操作,都希望它本身是幂等的。删除缓存天然满足这个条件,重试多少次都不会出现覆盖问题。
所以在实际开发中,我几乎从不建议在写链路里直接SET缓存新值,除非这个场景对不精确性完全不敏感,且能接受极低的并发正确性要求。否则一律走“删除”路线。
3. 删除缓存失败的兜底链路:重试、binlog与延迟双删
3.1 删除失败是旁路缓存模式里最隐蔽的坑
如果只考虑正常情况,“先更新数据库再删除缓存”已经足够安全,真正让生产系统翻车的是“删除失败”这个异常路径。
删除失败的原因五花八门:Redis 服务端抖动、网络超时、key 序列化异常、Redis 内存淘汰策略导致删除操作被拒。一旦删除失败,数据库已经写入新值,缓存却还是旧值,这就是一次标准的持久化脏数据。
很多团队在线下测试时从没遇到过这个问题,因为本地的 Redis 永远稳定,网络延迟也极低。但在生产环境中,哪怕 Redis 的可用性达到 99.9%,一年下来也会有接近 9 小时的不可用时间,这个窗口里任何一个写请求触发的删除操作都可能在抖动边缘异常退出。
所以,删除缓存必须有一套明确的兜底机制,而不是写完数据库之后调一次del就完事。
3.2 方案一:删除重试队列,最实用的救火队友
先说最常用、同样也是我推荐优先落地的方案:把“删除缓存”当成一个带重试的任务,而不是一次性的 Redis 调用。
基本流程是这样的:
- 写请求先更新数据库,事务提交成功后,执行一次删除缓存。
- 如果删除成功,流程结束。
- 如果删除失败,把
(业务key, 操作时间戳)写入一个重试队列。 - 一个后台任务定时扫描队列,对失败的 key 重新发起删除。
- 设置最大重试次数,超过次数后进入人工处理/告警通道。
这个方案的重点在于重试计数器一定要带。否则当 Redis 出现大规模故障时,积压的重试任务会在恢复后瞬间全部打过去形成雪崩。
还有一点:重试任务需要带上数据的版本信息或者操作时间戳,避免出现误删。比如一个 key 在删除失败后,又经历了新的写请求,缓存已经被后续操作正确维护,旧的重试任务如果盲目删除,可能会把新写入的缓存也删掉。当然,在旁路缓存模式下,删掉缓存只是损失一点性能,下次读请求会重新回填,所以误删的后果相对可控,但能避免还是要尽量避免。
提示:重试队列的可靠性和延迟性决定了整个补偿链路的效果。如果业务对一致性要求较高,建议把待删除的 key 先写本地消息表,通过 MQ 投递,而不是放在内存队列里。否则应用重启,队列里的任务就全丢了。
3.3 方案二:订阅 binlog 实现缓存被动失效
如果说重试队列是主动补救,那 binlog 订阅就是被动兜底,两者不冲突,可以组合使用。
binlog 方案的核心思路是:既然写请求更新完数据库了,MySQL 的 binlog 里一定有这条变更记录,那么通过 Canal 这类中间件订阅 binlog,解析出变更的数据主键 ID,再异步删除对应的缓存 key。
这个方案的好处很明显:
- 业务代码里不需要显式调用“删除缓存”的逻辑,所以不会出现“忘了删”这种低级错误。
- 即使业务代码的删除逻辑被绕过了(比如有人直接执行 SQL 改了数据),只要 binlog 订阅正常,缓存依然会被失效。
- 天然支持多数据源同步场景,比如数据从主库同步到搜索索引或统计库,binlog 是整个数据变更的权威来源。
缺点同样是结构性的:
- 引入 Canal、MQ 等组件,运维复杂度和部署成本上了一个台阶。
- 存在延迟问题,binlog 从产生到被消费删除缓存,中间隔了几百毫秒甚至更久,这个窗口内读到旧值属于正常。
- 如果消费端堆积,脏数据窗口会被拉长,所以需要实时监控消费滞后。
说实话,对于大多数业务系统,我不建议一上来就上 binlog 方案,除非确实存在大量 SQL 直接改库的运维场景,或者团队有足够的精力维护这套链路。小团队做 Cache-Aside 优先把重试队列做扎实,收益会更高。
3.4 方案三:延迟双删,一个降概率的妥协方案
再来说延迟双删。很多文章把它列为解决一致性问题的标准答案,但我的看法是:它是一个工程妥协,能降低问题发生概率,不能彻底根治问题。
延迟双删的流程是:
- 删除缓存。
- 更新数据库。
- 休眠一小段时间(比如 500ms 到 1s)。
- 再次删除缓存。
它解决的是“先删缓存、再更新数据库”这个路线下的旧值回填问题。在第二步更新数据库、第四步再次删除缓存之间,即使读请求把旧值回填了,也会被第四步的删除清掉。
但这个方案有一个无法回避的问题:休眠时间的长度没有任何精确依据。读请求从查询数据库到回填缓存,耗时取决于 SQL 执行速度、网络延迟、数据库负载,一次慢查询可能让回填发生在几秒之后。延迟双删设定的休眠时间如果小于这个回填耗时,第二次删除就会在回填之前执行,等于白删。
所以,延迟双删本质上只是在“大概率能把回填的旧值清掉”的前提下,降低事故概率而已。它不是一致性的保证。
还有一个衍生变种叫“双删加延迟写”,思路是在第三次读请求回填时带上时间戳校验,过期数据不允许覆盖新数据。这种方案在特定框架里能见到,但它已经脱离了旁路缓存模式的范畴,属于自定义的强一致缓存方案了,复杂度也会明显上升。
我的个人建议是:如果系统还在设计阶段,优先选择“先更库再删缓存 + 重试队列”,不要走“先删缓存再更库 + 延迟双删”这条组合。延迟双删看似简单,实际上参数调优全靠拍脑袋,线上出问题后也很难定位。
3.5 三种方案的组合实践
实际生产环境中,我见过比较稳的一套组合策略是:
- 主策略:先更新数据库,再删除缓存。
- 一级兜底:删除失败写入重试队列,带次数限制和指数退避。
- 二级兜底:缓存 TTL 设置一个业务可接受的上限,过期后自动拉取最新值。
- 三级兜底(可选):核心数据引入 binlog 订阅,做最终一致性补偿。
这套策略覆盖了正常路径、异常路径、长期兜底三个层面,每一层都不复杂,但合在一起之后,能让任何单一故障都不至于演变成长时间脏数据事故。
4. “最终一致”的窗口到底怎么算:业务容忍度才是决策依据
4.1 旁路缓存模式做不到强一致,先接受这个事实
很多人在设计缓存方案时,第一反应是问“怎么保证缓存和数据库强一致”。这个问题本身就有点问题——在分布式架构下,跨存储的强一致通常需要引入分布式事务,成本极高,实际业务中绝大多数场景也不要求强一致。
旁路缓存模式天然就是一个最终一致方案。缓存里的数据不一定是最新的,但会在一个可预期的时间内收敛到与数据库一致。这个收敛时间就是一致性窗口。
所以,在设计缓存时,最重要的问题不是“怎么保证一致”,而是“业务能容忍多久的不一致”。这个窗口决定了你的策略选择。
4.2 用业务场景来分类,而不是一刀切
把常见的数据场景按一致性的敏感度分个类:
| 数据场景 | 可容忍的不一致窗口 | 推荐策略 |
|---|---|---|
| 商品标题、用户昵称、文章内容 | 秒级到分钟级 | Cache-Aside + TTL + 删除重试 |
| 库存、余额、订单状态 | 0 或毫秒级 | 不走旁路缓存,直接走 DB 或引入分布式锁 |
| 推荐列表、热门榜单、聚合统计 | 分钟级 | Cache-Aside + 定时刷新 + 对账 |
判断依据其实很朴素:一秒钟不一致会造成用户投诉吗?如果会,那缓存就不该承担这个数据的读路径;如果不会,那用旁路缓存完全合理。
4.3 TTL 不是懒惰的借口,是最后一道保险
TTL 在旁路缓存模式的讨论里常常被忽略,但它才是整个一致性兜底体系里最重要的保底机制。
前面说过,删除失败、并发竞态、代码 Bug 都可能导致缓存长时间保留旧值。如果缓存没有 TTL,这些异常情况造成的脏数据就是永久性的。一旦有了 TTL,缓存最多存活 TTL 时长,过期之后下一次读请求会自动从数据库拉取最新值,相当于一个无条件的最终收敛器。
所以,我给团队定过一条死规矩:所有缓存 key 必须设置 TTL,不允许出现永不过期的 key。
哪怕某个业务场景的数据几乎不变,也建议设置一个很长但非无限的 TTL,比如 7 天或 30 天。这不是为了数据一致性(一个 30 天不变的 key 即使永不过期在一致性上也差不多),而是为了防止代码失控。我在排查事故时见过太多“这个 key 不可能变”的结果缓存,偏偏因为代码逻辑一变,这个 key 的数据就变得极其关键,而没有 TTL 让它变成了一颗定时炸弹。
TTL 值的设定也有一点经验之谈:太短会导致缓存命中率下降,失去加速的意义;太长会拉大一致性窗口。一般读多写少的场景,设置为 5 到 30 分钟比较合理,具体根据业务容忍度而定。如果数据有明确的上下线时间点(比如秒杀商品的开始时间),可以结合业务属性动态设置 TTL。
4.4 对账任务:脏数据的收尾工作
无论做了多少层防护,总有一些漏网之鱼会绕过所有机制。最典型的就是:应用代码在更新数据库后直接del缓存,但del因为网络分区根本没执行成功,然后 Redis 和数据库之间的网络恢复了,而业务应用并没有感知之前那次删除失败。
这种场景下,重试队列收不到失败任务,binlog 订阅可能没接到变更事件,TTL 又在很长时间之后,唯一能兜住的就是对账。
对账机制很简单:写一个定时任务,扫描最近一段时间有变更的数据主键,去 Redis 里比对缓存值和数据库值,不一致就删除缓存。这个任务的频率不需要太快,每隔几十秒扫描最近几分钟的变更数据即可。对账的粒度也不用精确到秒级,因为它的作用本来就是兜底,不需要承担主路径的一致性职责。
我个人建议核心数据都要加对账,它的代码量不大,但能挡住绝大多数“理论上不该发生”的黑天鹅事件。
5. 生产实践:一套可落地的Cache-Aside组件长什么样
5.1 组件边界:别让业务代码裸写缓存操作
很多团队把缓存操作散落在各个业务代码里,每个地方都写一套if (cache.get(key) == null) { queryDb(); cache.set(key, value); }的逻辑。这种方式在最初开发时很灵活,但一旦需要统一治理一致性、重试、监控时就会发现根本没有一个抓手。
我建议把旁路缓存模式封装成一个独立的基础组件,对上层只提供三个能力:
- 读接口:传入 key 和加载函数,组件负责查缓存、回填、处理穿透。
- 失效接口:传入 key,组件负责删除缓存,并在失败时进入重试链路。
- 批量接口:针对 mget 这类场景,逐 key 失效,避免批量删除时因一个失败导致全批失败。
组件内部无论怎么写,对外部业务来说,只需要关心“我要读这个 key,如果没了帮我加载数据”和“这个 key 的有效缓存要删除了”这两件事。具体的一致性保障逻辑全部收敛在组件内部。
5.2 读路径的关键细节:穿透、击穿、回填
读路径如果设计不细致,会在高并发时放大缓存系统的问题。这里单独说几个已经踩过坑的点。
第一,缓存穿透。一个不存在的 key,每次查询都会打到数据库。解决办法是:当数据库查询结果为空时,在缓存里放一个空值(比如"NULL"),并设置一个较短的 TTL(比如 30 到 60 秒),这样下一次甚至后续的 N 次请求都能被缓存拦截。如果怕空值被恶意排队塞满,可以对 key 做布隆过滤器前置校验。
第二,缓存击穿。一个热点 key 失效的瞬间,大量请求同时打到数据库。解决办法是加互斥锁,让同一个 key 的加载过程在并发下只执行一次。组件内部需要维护一个本地锁或者用 Redis 分布式锁来控制回填的串行化。
第三,回填超时。从数据库加载数据可能本身很慢,如果不设置回填超时,一个慢查询可能拖垮整个请求。建议组件内部设置回填耗时阈值(比如 50ms 和 200ms 两档),超过阈值时记录日志并降级返回数据库结果但不写缓存。这样至少不会因为“缓存功能”反噬主业务。
5.3 写路径的核心逻辑:先更库再删缓存,失败进队列
写路径的一致性逻辑,用伪代码示意一下核心部分:
public class CacheAsideService { private final DatabaseService dbService; private final CacheService cacheService; private final RetryQueue retryQueue; public void update(String key, DatabaseUpdate dbUpdate) { // 第一步:更新数据库 dbUpdate.execute(); // 第二步:删除缓存 boolean deleted = cacheService.delete(key); // 第三步:删除失败进入重试队列 if (!deleted) { retryQueue.offer(key); } } public Object get(String key, Supplier<Object> dbLoader) { Object cached = cacheService.get(key); if (cached != null) { return cached; } // 这里需要加锁防止击穿 synchronized (key) { Object value = dbLoader.get(); cacheService.set(key, value, ttl); return value; } } }当然这只是最简化的示意,真实代码里还要处理空缓存标记、异步请求上下文、可观测性埋点等。但核心顺序不能变:数据库更新在前,缓存删除在后,删除失败不静默,而是进入重试链路。
5.4 监控和告警:一致性问题的“眼睛”
最后是很多人会忽略的监控。缓存一致性问题的排查难点在于,它不像 500 错误一样有明确的错误码,问题发生时请求可能全部成功,只是用户看到的数据不对。如果没有监控,这类问题会像暗雷一样潜伏很久。
建议至少监控以下几项指标:
- 删除缓存失败次数,以及失败率趋势。
- 重试队列积压数量。
- 缓存命中率变化,突然下降说明大量缓存被删除或 TTL 过短。
- 回填耗时 P99,回填时间暴涨说明数据库压力大或缓存层抖动。
- 缓存过期驱逐次数,排除内存淘汰导致的异常丢失。
告警规则可以设成:删除失败率超过 1% 持续 1 分钟,或者重试队列积压超过 1000 条,就触发告警。这些数值不一定适用于所有团队,但值得当作初始阈值参考。
还有一点个人经验:所有删除缓存的操作一定要打印日志,带上 key 和操作结果。线上排查“某个 key 为什么被删了”时,没有日志就只能靠猜,有了日志几秒钟就能定位。同样的,重试队列每次重试也要有日志,方便判断重试是否反复打同一个 key,找出真正的问题源头。
一线的踩坑越久就越明白,缓存一致性从来不是“选对一个模式”就能轻松解决的事。旁路缓存模式把一致性规则简化成了“先更库再删缓存,删失败要补偿”这句话,但真正让它运转稳定的是后面的工程细节:TTL 不能省、重试要幂等、监控要完善、压测要覆盖并发窗口。我个人现在设计任何带缓存的系统,都会先问团队一个问题:如果这条缓存在下一秒彻底失效,你的数据库扛得住吗?如果扛得住,再去谈一致性策略。如果扛不住,优先级就不是怎么保证一致,而是先扩容或者降级。把这个顺序想清楚,好多关于缓存一致性的纠结都能省下一大半。