news 2026/10/16 4:36:33

缓存与数据库双写一致性实战:从Cache Aside到binlog订阅

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓存与数据库双写一致性实战:从Cache Aside到binlog订阅

你是不是也遇到过这种场景:线上代码没改,数据库也看不出问题,但用户反馈"我明明改了资料,刷新又变回去了"。查了半天,最后定位到 Redis 缓存里躺着一个旧值,数据库早就更新了,缓存却纹丝不动。这就是典型的缓存与数据库数据不一致问题,也是后端开发里绕不开的"双写一致性"。

这个问题的本质在于:业务数据同时存在 Redis 和数据库两份,更新时必须保证两者最终能对齐。但缓存和数据库是两套独立系统,不存在事务,任何一步失败或并发交错,都会留下脏数据。这篇文章我不打算讲教科书上的定义,而是从实际项目出发,把我在线上踩过的坑、用过的方案、以及每个方案为什么有效、为什么失效,完整梳理一遍。适合正在做后端开发、准备面试、或者被线上脏数据困扰过的同学参考。

1. 先搞清楚:缓存和数据库为什么会不一致

很多人一上来就背方案,但没理解问题的根源,导致换了个场景就不会用了。所以第一步,我们先把这个问题的产生机制彻底拆开。

1.1 一个典型线上的"脏数据"事故现场

先说我踩过的坑。某个活动页配置,数据存在 MySQL,Redis 做热点缓存,key 的有效期是 30 分钟。业务方通过管理后台修改活动配置,代码逻辑是:先更新 MySQL,再删除 Redis 缓存。看起来没毛病,对吧?但线上还是出现了"改了配置,用户端 20 分钟后才生效"的情况。

排查后发现,问题出在并发读写上。当时的情况是这样的:

  • 请求A(读):缓存未命中,回源 MySQL 读到了旧配置,正准备写回缓存
  • 请求B(写):更新 MySQL 为新配置,执行删除缓存
  • 请求A继续执行:把之前读到的旧配置写回了缓存

于是缓存里重新出现了旧值,直到这个 key 过期前,所有用户读到的都是过期配置。这个过程里,MySQL 和 Redis 的操作都是成功的,没有异常,但数据就是不一致了。这就是并发时序导致的一致性破坏,不是简单的"删除失败"能解释的。

这个案例告诉我们一件事:在缓存 + 数据库这个架构下,一致性不只是"操作要成功"的问题,更是"并发时序要正确"的问题。缓存未命中时的回填动作,如果和写操作交叉执行,就可能产生脏数据。很多团队引以为傲的"先更新数据库,再删缓存"方案,也不是绝对安全的,后面我会细讲。

1.2 一致性强度划分:强一致、最终一致与业务容忍度

在讨论方案之前,必须先明确业务对一致性的容忍度。没搞清楚这个,讨论方案没有意义。

  • 强一致:任何时刻读到的都是最新数据,读操作必须同步等待写入完成。在缓存场景下,要做到强一致,基本只能让所有读写串行化,本质上是放弃缓存带来的并发收益,或者引入分布式锁把并发度降下来。
  • 最终一致:允许短暂的不一致窗口,但经过一段时间或补偿机制后,数据最终对齐。绝大多数缓存架构采用的都是这个模型。
  • 弱一致:容忍较长时间的不一致,比如排行榜、商品浏览量这类数据,错几分钟无所谓。

我在实际项目里判断业务需求,主要看三个问题:这个数据错了会有什么后果?用户是否能感知?不一致窗口能持续多久?比如用户资产、订单状态这种强相关数据,我会给 key 设置比较短的过期时间,配合消息补偿;而活动配置、文章内容这种弱相关数据,容忍度就高很多,简单处理也能接受。

这里有一个关键认知:缓存一致性问题的本质是"读写并发交错",而不只是"更新失败"。所以任何方案,只要没处理好"读缓存未命中 → 回源 → 回填"这个链路的并发,都不可能做到理论上的一致。

2. 主流双写方案逐个拆解

这一章是核心中的核心。我用项目实践的顺序来拆解:从最经典的 Cache Aside 模式开始,分析它为什么是主流,再逐步叠加工程手段来解决它的并发漏洞。

2.1 Cache Aside:最经典也最常被写错的模式

Cache Aside 是大多数后端团队默认的选择。它的规则非常简单:

  • 读请求:先读缓存,未命中就查数据库,然后把结果写回缓存
  • 写请求:先更新数据库,再删除缓存

注意,是"删除"缓存,不是"更新"缓存。这个细节很多人不理解,我解释一下:更新操作的成本远高于删除。假设缓存里存的 value 是一个很长的 JSON,更新数据库后如果要同步更新缓存,你需要先把新数据序列化成 JSON,再写进缓存;而删除只需要一条 DEL 命令。更重要的是,这个 key 可能被频繁读,但写频率通常低于读频率。删除缓存后,下一次读请求会触发回填,写一次就行了,没必要在每次写操作时都主动把新值塞进缓存。

还有一个很重要的点:先更新数据库,再删缓存,这个顺序不能反。如果先删缓存再更新数据库,中间恰好有一个读请求回源,读到的是数据库更新前的旧值,然后回填缓存,缓存就脏了。又或者删缓存成功了,数据库更新失败,那么下次读请求回源会拿到旧值,但此时缓存已经被删除了,数据是一致的旧值,影响不大;可如果先删缓存后更新,更新期间所有读请求都会穿透到 MySQL,大量请求打到数据库,虽然一致但性能受损。

所以标准的 Cache Aside 写路径是:

public void updateData(String key, Object newValue) { // 1. 先更新数据库 database.update(newValue); // 2. 再删除缓存 redis.del(key); }

但是,就像我在开头案例里说的,这个流程在并发情况下仍然存在漏洞。具体场景是这样的:

  1. 读请求A:缓存未命中,回源 MySQL 查到旧值
  2. 写请求B:更新 MySQL,数据变成新值
  3. 写请求B:删除 Redis 缓存
  4. 读请求A:把旧值写回 Redis

这时候,Redis 里是旧值,MySQL 里是新值,缓存与数据库数据不一致。Cache Aside 模式在"请求A的步骤1和步骤4之间,一个写请求插入并完成"时,会出现脏数据。这个窗口非常小,但确实存在。对于一致性要求较高的业务,就要在这个模式上做加固。

2.2 为什么"先删缓存"听起来合理却会翻车

我见过不少团队,为了解决缓存和数据库一致性问题,选择"先删除缓存,再更新数据库"。他们的理由是:缓存里没有数据了,读请求就必然会回源数据库,这样就能拿到新值,缓存自然不会被旧数据污染。

听起来很合理,但实际跑起来你会发现一个更严重的问题:在高并发下,删除缓存后、数据库更新前这一小段时间里,所有对该 key 的读请求全部穿透到数据库。如果这个 key 是热点数据,瞬间就可能把 MySQL 打崩。这是性能问题。

还有一致性风险:假设先删缓存成功,数据库还没更新,这时一个读请求来了,缓存未命中,回源数据库读到了旧值并回填缓存。然后写请求才更新数据库。结果是:数据库是新值,缓存是旧值,而且因为缓存已经被回填过了,写请求不会再删一次,这个旧值会在缓存里一直存活到过期。不一致窗口被拉得很长。我用一个执行序列来对比:

步骤先删缓存方案Cache Aside(先更新后删除)
1删除缓存更新数据库
2读请求回源读旧值,回填删除缓存
3更新数据库无(缓存干净)
4缓存旧值,库新值,不一致下个读请求触发回填

从表格可以看出,先删缓存在步骤2就已经种下了脏数据的种子。而 Cache Aside 的好处是,更新数据库和删除缓存这两个动作之间,缓存里依然有旧值可读,不会造成大量请求直接打到数据库;删除后,缓存是空的,下一次读会拿到新值并回填。它的不一致窗口更小。

所以我的结论是:不要采用"先删缓存再更新数据库"方案。这段路我走过,线上服务报警和用户投诉都经历过。如果怕 Cache Aside 的并发漏洞,正确的方向是叠加其他机制,而不是改变顺序。

2.3 Write Through / Write Behind 的适用边界

除了 Cache Aside,还有两类写法在部分场景下会用到:Write Through 和 Write Behind。

Write Through(直写):写请求先更新缓存,缓存组件同步更新数据库。优点是缓存和数据库几乎同步,一致性较好;缺点也很明显,写请求的延迟等于缓存加数据库两次写入的耗时,写性能明显下降,而且如果缓存写入成功但数据库失败,还要做补偿回滚。

Write Behind(异步写):写请求只更新缓存,数据库的更新异步完成。优点是写性能极好,吞吐量高;缺点是缓存和数据库天然存在窗口期不一致,而且缓存一旦宕机未落库的数据直接丢失。

从工程实践看,这两类方案都不适合"核心业务数据"的双写场景。Write Through 适合对性能要求不高但对一致性有要求的场景,比如配置类数据;Write Behind 适合可以容忍丢失、高频写入的场景,比如点赞数、浏览量的计数。

在大多数 To C 业务里,我们最终选择的还是 Cache Aside + 补偿方案。因为数据库是事实源,缓存只是加速层,任何以缓存为准的写方案都要承担缓存状态丢失的风险。明确这一点,后面很多问题都想得通了。

3. 工程兜底:延迟双删与binlog订阅

Cache Aside 解决了"删缓存失败"和大部分"顺序错误"的问题,但并发交错的隐患仍然存在。如果做过严格的一致性压测,就会发现刚才那个"读请求旧值回填"的漏洞真实存在。两个方向可以兜底:一个是延迟双删,把错误覆盖的机会用时间差抹掉;另一个是 binlog 订阅,让数据库主动通知缓存更新。本节展开讲。

3.1 延迟双删的原理与参数推导

延迟双删的核心思路是:在 Cache Aside 的"更新数据库 → 删除缓存"之后,再延时执行一次删除,把上一次"读请求回填旧值"造成的污染清除掉。流程如下:

  1. 更新数据库
  2. 删除缓存
  3. 等待一段时间(比如 500ms)
  4. 再次删除缓存

为什么第二次删除要延时?因为第一次删除后,读请求可能刚好回源读到旧值并回填。如果没有延时,第二次删除和回填仍然可能交错,删了个寂寞。延时的作用是等到所有"正在回源读旧值"的请求全部完成,此时缓存里如果出现了旧值,第二次删除就能精确命中。

延时时长怎么确定?我提供一个经验公式:延迟时间 > 一次完整读操作的最长耗时,包括缓存未命中、MySQL查询、结果序列化、写回缓存。如果你有监控数据,可以用读链路 P99 甚至 P999 耗时。我的做法是先看线上监控里 MySQL 查询耗时分布,取 P99 耗时 + 网络抖动余量,一般 500ms 到 1s 之间足够。设置太大,会影响写接口的响应时间;设置太小,第二次删除可能赶不上回填,等于白删。

需要说明的是,延迟双删再快也有窗口,极端情况下仍然存在理论上的并发漏洞。比如读请求的旧值回填发生在第二次删除之后、下一个写请求更新数据库之前,这个场景依旧会有短暂的脏读。但在真实业务中,这个概率已经低到可以接受。延迟双删是"工程兜底",不是"数学证明"。我通常用异步线程或延时队列来执行第二次删除,不阻塞主写接口,避免写操作多等一个延时周期。

3.2 binlog订阅方案:从数据库出发的最终一致

如果说延迟双删是一种"事后补救",那 binlog 订阅方案就是从源头解决问题。思路是:MySQL 的 binlog 会记录所有数据变更,我们提供一个订阅组件解析 binlog,把变更事件拿到后,异步删除对应的 Redis key。这样业务代码不需要在更新数据库后手动删缓存,只要数据库真变了,订阅机制就能感知到。

这个方案最大的优势是解决了"业务代码忘记删缓存"或"删缓存失败"的问题——因为删除动作从业务链路中解耦出来,由独立组件负责,可以重试,可以补偿。

但引入这个组件后,你也得接受两个现实:

  • 首先,binlog 是异步解析的,从数据库提交事务到订阅组件触发删除缓存,之间有几十毫秒到几百毫秒的延迟,取决于消费速度和消息堆积。这个窗口内,缓存可能还是旧值。
  • 其次,binlog 的格式必须设置为 ROW 模式,否则只记录 SQL 语句的 statement 格式,解析不到字段级别的变更内容,无法精确知道是哪个 key 变了。这是很多团队踩过的坑。

公司的某个中台项目,原来用延迟双删,但有一次因为写请求量过大,线程池阻塞导致第二次删除延误了几秒,线上出现了明显的缓存不一致。后来改成 binlog 订阅,配置好监听后,业务侧删缓存的代码逐步删除,一致性窗口大大缩短,而且因为有了消息重试机制,基本没有出现过缓存存活旧值超过一分钟的情况。

binlog 消费端的设计上,我建议把消费逻辑做成幂等的,同一个 key 的删除指令重复执行不会产生副作用。消费端还可以加一个去重层,同一秒内对同一个 key 的多次删除合并成一次,降低 Redis 的写入压力。此外,消费端要有监控和报警,一旦积压超过阈值,立即告警,因为这个积压意味着缓存的不一致窗口被拉长。

3.3 版本号回填与key粒度串行化

除了删除策略,还有一种更接近理论解的做法:给缓存中的每个 value 加一个版本号,读回填时,只有当版本号大于等于当前版本时才写入。这样即使有并发的旧值回填,也会因为版本号过期而被丢弃。

举个例子:缓存里存的数据是{"version": 3, "data": {...}}。写请求更新数据库后,把版本号递增到 4,并删除缓存。读请求A在缓存未命中时回源读到了旧版本 3 的数据,然后把这个旧版本数据写回缓存。此时由于缓存 key 已被删除,Redis 里没有 key,写回操作会重新创建 key。如果版本号机制只存在于业务数据内部,那么读请求A回填的数据版本是 3,而下一次读请求会发现版本号低于数据库中已更新的版本,会再次回源并回填新值。这个方案落地时,一般配合 TTL 使用,业务数据内部维护 version,读回填带上 ExpectedVersion 条件。

这个方案有一个问题:它依赖读请求发现版本号过期后主动回源,而不是在写入时直接拒绝。Redis 的 Lua 脚本可以做原子性校验:只有当回填数据的版本号大于缓存中当前版本号时才允许覆盖。

-- 执行前检查版本,防止旧数据覆盖新数据 local current = redis.call('GET', KEYS[1]) if current and cjson.decode(current).version > tonumber(ARGV[1]) then return 0 end redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) return 1

这种方案的实现成本不低,而且每次读操作都要解析 JSON 判断版本,有一定性能损耗。我更推荐把它用在"高价值且写冲突频繁"的数据上,而不是全局启用。

再说串行化。针对同一个 key 的读写,可以让它们进入同一个队列,按顺序执行,从源头上消灭并发交错的可能。做法是按 key 做哈希,分发到不同的队列或线程池。同一时刻,同一个 key 的读写操作只有一个在执行,彻底消除了"读回写"和"删缓存/更新库"的交错。代价是吞吐量下降,对同一个 key 的写请求需要串行等待。

网上很多人一听到串行化就觉得"性能肯定不行",但我实测下来,只要热点 key 不是特别极端,串行化对整体吞吐的影响是可控的。一个经验值是,把锁粒度控制在 key 级别而不是 table 级别,同一张表的不同 key 之间仍然可以并行。如果热点极度集中在一个 key 上,串行化确实会卡住,这时就要考虑从业务侧拆分 key,比如按用户 ID 分片。

4. 实战中的坑与排查技巧

方案讲再多,最后还是要落地到线上。这一章分享我在实战中踩过的坑、排查问题的方法,以及一些面试中常被问到的变体,希望帮你少走弯路。

4.1 脏数据复现与定位思路

定位缓存不一致,最直接的方法是对比 Redis 缓存里的值和 MySQL 里的值。但线上的 key 通常有几十万个,不可能一个个比对。我的定位思路是"从现象反推链路":

  • 第一步,确认不一致的表现形式。是"修改后旧值持续存在"还是"时好时坏"?持续存在一般是删除失败或未删除;时好时坏大概率是并发交错。
  • 第二步,查看写操作是否真的删除了缓存。在 Redis 开启慢日志,或者在代码里对删缓存操作做日志记录,确认写请求执行时 DEL 命令是否发出、是否成功。
  • 第三步,检查读回填的代码路径。如果回填动作没有加锁或版本控制,那并发场景下很容易出问题。
  • 第四步,检查缓存过期时间设置。如果 TTL 设成了一个小时,即使删除成功,也可能因为有旧值回填导致脏数据存活时间很长。

曾经有一次,我排查了很久的"数据不一致",最后发现是读接口里一个隐藏分支没有走统一的回填逻辑,而是自己拼了一个不同 key 的缓存。这种问题代码 review 是查不出来的,因为两个 key 长得很像,必须靠运行时日志或者 Redis 的 key 统计才能发现。后来我把所有缓存读写的入口统一封装成一个工具类,禁止业务代码直接操作 Redis,这类问题才减少。

4.2 删除缓存失败:重试、补偿与降级

删除缓存失败,是双写一致性里最常见的问题,没有之一。Redis 是独立服务,读写接口可能超时、连接可能断开、命令可能丢失。删除失败了怎么办?我见过的最不靠谱的做法是"数据库更新成功后,在同一个事务里删缓存",这个思路的问题在于,事务回滚的是数据库,Redis 的操作无法回滚。而且如果数据库已经提交,缓存删除却失败,你没有任何补偿手段。

正确思路是把"删除缓存"当成一个异步任务,失败了可以重试。具体做法是:

  1. 更新数据库成功后,把需要删除的缓存 key 封装成一条消息,投递到 MQ。
  2. 独立的消费者接收消息,执行 Redis DEL。
  3. 如果 DEL 失败,消息会进入重试队列,直到成功。重试要有最大次数,超过次数进入死信队列,人工介入。

这个方案需要注意消息的消费幂等性。同一个 key 的删除指令重复执行不会产生副作用,所以删除操作天然适合做成幂等任务。

我提供一个更轻量的降级手段:缓存 key 不设置永不过期,而是统一设置一个相对短的 TTL,比如 30 分钟或 1 小时。这意味着,即使删除缓存失败,或者真的出现了并发脏读,数据也会在 TTL 到期后自动失效,下一次读请求拿到的一定是数据库的最新值。这是一个底线保障,我强烈建议所有缓存 key 都设置 TTL,除了极少数全量低频数据。虽然 TTL 无法避免窗口期内的不一致,但它能把不一致的持续时间限制在可控范围内。

4.3 面试官爱问的几个变体问题

双写一致性是面试高频考点,除了标准方案,面试官还会问一些变体:

第一个变体:为什么是"删除缓存"而不是"更新缓存"?这个我前面已经说过了,更新成本更高,且如果多个写请求并发,后写的值可能覆盖先写的值,导致缓存里不是最新值;而删除缓存产生的回填天然是"最后读的人"写的,更接近最新状态。再加上缓存不一定只依赖数据库一份数据,可能还有计算逻辑,直接更新容易出现信息缺失。

第二个变体:为什么先更新数据库,而不是先删缓存?这个问题考察的不仅是性能,还有失败补偿的难易度。先删缓存再更新,如果更新失败,缓存已经没了,后续读请求全部穿透到数据库,而且如果数据库更新失败前还有读请求回填,缓存里可能被塞入旧值。反过来,先更新数据库,删除缓存失败,还有 TTL 兜底,而且删除动作的补偿比数据库事务补偿简单得多。

第三个变体:缓存和数据库一致性要求做到什么程度?这个问题考察你对业务的理解。我通常的回答是"视业务容忍度而定",一般数据用最终一致即可,强一致数据建议不引入缓存,或使用分布式锁串行化读改写。能说清楚这个,面试官一般会认可。

第四个变体:延迟双删为什么不能彻底解决问题?这个问题绕不开理论极限。延迟双删只是把不一致概率降到极低,但在极端并发下,读请求回填可能发生在第二次删除之后。真正的解只有串行化或版本控制。不过工程上,延迟双删 + TTL + 监控已经能满足绝大多数场景。

4.4 一套可复用的监控与报警配置

聊聊监控。没有监控,你根本不知道系统有没有不一致问题,只能等用户反馈。我会在三个层面做监控:

  • Redis 层面:监控命中率、删除次数、删除失败率。删除失败率突然升高,往往意味着一致性风险。
  • 应用层面:对"更新数据库后删除缓存"做一个计数埋点,记录删除耗时和失败次数。如果删除耗时超过一定阈值,直接告警。
  • 数据层面:针对核心数据,写一个定时任务,随机抽样对比 MySQL 和 Redis 中的值,如果发现不一致,记录并推送告警。这个抽样的成本不高,但是非常有效。

我的经验是,监控不是"有没有",而是"有没有用"。很多团队的监控指标配了一大堆,最后告警都没人看,形同虚设。关键是要把告警收敛到最有意义的指标上,比如"核心业务缓存 key 的删除成功率",以及"抽样比对不一致数量",不要一条告警天天报,没人会理。


最后,我再分享一个实际体会:双写一致性这个问题,没有银弹,每套方案都有自己的适用边界和代价。Cache Aside 简单直接,延迟双删够用,binlog 订阅更稳,串行化最安全但性能受限。选择哪套,取决于你的业务对一致性的容忍度和团队的技术实力。我在项目里一般这样落地:常规业务用 Cache Aside + TTL 兜底,核心数据加延迟双删,强一致要求的数据直接走数据库查询绕过缓存。如果你也在为缓存不一致发愁,先从业务容忍度出发,选一套合适的手段落地,再逐步叠加补偿机制,千万不要一步到位追求理论完美,工程上的简单可靠,永远比理论上的无懈可击更有价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 4:36:00

右键菜单事故复盘:contextmenu事件链、监听器陷阱与防误删设计

1. 事故现场:一条订单记录是怎么被右键吞掉的1.1 从一张便利贴开始的排查我至今记得那张便利贴。加粗、黑色马克笔、末尾跟了四个感叹号——"别按右键!!!!",贴在我们某内部运营后台的公用电脑屏幕…

作者头像 李华
网站建设 2026/10/16 4:35:32

千笔AI与灵感AI深度测评:研究生论文写作加速工具怎么选?

“真的太省时间了!AI论文软件 千笔AI VS 灵感ai,研究生必备神器!”——老实说,我第一次看到这个标题的时候,内心是有点抗拒的。因为读研的前两年,我对这类AI写作工具的态度很统一:噱头&#xff…

作者头像 李华
网站建设 2026/10/16 4:35:29

Pi 1.0原生MCP:协议从手动实现到运行时内建的范式跃迁

1. 标题里的“MCP已死”不是危言耸听,而是技术代际切换的明确信号“MCP已死?”这个问号背后,没有情绪化宣泄,也没有立场站队,而是一次清晰的技术路线收敛。我第一次在某跨平台系统开发中接触MCP(Model Cont…

作者头像 李华
网站建设 2026/10/16 4:34:36

pdf.js实战指南:从PDF预览到性能优化完整方案

PDF预览这件事&#xff0c;说简单也简单&#xff0c;说麻烦也麻烦。尤其当你接到一个“把PDF传到网页上展示”的需求&#xff0c;第一反应可能是<iframe>一塞完事&#xff0c;可一旦遇到需要翻页、缩放、记住阅读进度、甚至提取文字的场景&#xff0c;原生能力就捉襟见肘…

作者头像 李华
网站建设 2026/10/16 4:34:01

位运算四题吃透:二进制补码与算法面试的降维技巧

之前在面试现场&#xff0c;我问过一位候选人 LeetCode 191 这道题&#xff1a;统计一个整数的二进制表示里有多少个 1。他很快写了个循环右移的版本&#xff0c;但当我追问“能不能把 32 次循环降到只循环 1 的个数那么多次”时&#xff0c;空气安静了几秒。位运算就是这样&am…

作者头像 李华