缓存和数据库的一致性,别停在 “延时双删”

商品详情页是后端最典型的读写并发场景。一天几千万次请求,九成以上打在读上,价格、库存状态、促销标签全在 Redis 里躺着。写操作每天只占千分之几,却足以让缓存和数据库打架。

先说说延时双删(也有人叫延迟双删)是怎么回事。它是这样一套动作:

删除缓存 → 更新数据库 → 等待几百毫秒 → 再次删除缓存

第一删是为了让后续读请求不至于读到老值;第二删是清理等待窗口内被重新回填的旧数据。

这套方案本身没有大问题,最大误区是把它当成最终方案。真正上线后,故障远不止 “第二次删除失败” 这一种。


一、那些被忽略的空档

先看最朴素的执行顺序:先更新数据库、再删缓存。

读请求 A:

  1. T1:缓存未命中
  2. T2:查询数据库拿到旧值 100
  3. T3:把旧值写回缓存

写请求 B 在 A 读库、写缓存的间隙里,完成了数据库更新并删除缓存。此时缓存本就是空的,这次删除毫无作用。等 A 把旧值写入缓存,缓存与数据库数据永久不一致,且没有自动修复机制。

于是业界改成:先删缓存,再更新数据库。
只是把风险窗口从 “读回填” 挪到 “数据库事务未提交”。窗口变窄,但并未消失:如果读请求在第一次删缓存之后、数据库提交之前进入,依然会读到旧值并回填缓存。

真正闭环:CDC 订阅 binlog

用 Canal 订阅 MySQL 的 binlog,等数据库事务真正提交成功后,再触发缓存删除。

  • 触发逻辑从 “业务代码手动调用” 变成 “数据真实变更才执行”,天然保证顺序;
  • 解决正常业务路径下的数据不一致问题;
  • 短板:无法处理消费失败、消息积压、消费者重启丢失位点。

二、删除失败与缓存回血

Canal 消费失败、Redis 删除失败,只靠 “同步重试 3 次” 远远不够。标准落地是两层兜底方案:

1)MQ 异步重试(第一层保障)

  • 缓存删除请求投递到消息队列;
  • 消费失败采用指数退避重试;
  • 超过重试阈值,消息进入死信队列并触发告警;
  • 前提:删除操作必须幂等,删除不存在的 key 不报错,多次执行结果一致,重试不会产生副作用。

重点:重试绝对不能放在业务线程同步执行。同步重试会拉长接口 RT,一旦 Redis 抖动,直接阻塞下单核心链路。删除操作必须异步入队,业务线程只负责发消息。

2)定时增量对账(第二层兜底)

只抓取一段时间内发生变更的商品 ID,批量对比缓存与数据库数据,发现不一致就主动修正。
不用全表扫描,只核对变更数据集,对数据库压力可控。

旧值回血问题(并发读回填脏数据)

缓存被旧值覆盖,根源在读请求回填逻辑,单纯靠重试删缓存解决不了。两种治理方案:

  1. 版本号控制(治本)
    缓存 value 带上版本号(更新时间戳也可),读请求回填前先比对版本;如果当前数据版本低于缓存已有版本,直接丢弃写入,慢半拍的旧数据永远覆盖不了新数据。
  2. 短 TTL + 对账兜底(保底)
    给缓存设置几十秒过期时间,脏数据最多存活几十秒,等待定时任务自动修正。

通常两个方案一起使用。

衍生问题:主从同步延迟

写主库、读从库,binlog 同步存在几十~几百毫秒延迟。这段时间从库读到的依然是旧数据,回填缓存就会产生脏数据。

两种解法:

  • 强一致性场景(秒杀价格、库存):更新后的一小段时间,读请求强制路由主库;
  • 低成本方案:把缓存删除延迟时间拉长,覆盖主从同步耗时,预留足够时间余量。

三、删太多和删太频:高并发下的性能问题

一致性方案必然带来性能损耗,在高并发场景下会被成倍放大。

场景 1:批量更新(大量 key 集中删除)

运营一次性批量修改 5 万个 SKU 价格,循环删除 5 万个 Redis key,瞬间击穿缓存,大量请求直接穿透到数据库,缓存整体失效。

优化手段:

  1. 分批执行:ID 拆分,每批几百个,批次之间增加时间间隔;
  2. 避免直接删除:只把版本号 + 1,依靠 TTL 让旧缓存自然失效;
  3. 大键删除使用 UNLINK,不要用 DEL,防止阻塞 Redis 主线程。

场景 2:热点 Key 击穿

爆款商品缓存一旦被删除,上万并发请求同时击穿到数据库,直接把 DB 打满。

优化方案:

  1. 热点 Key 不设置自动过期,由后台定时任务主动刷新;删除时加分布式锁,只放行一个请求重建缓存,其余请求等待;
  2. 逻辑过期(最优方案):缓存里写入过期时间,读到过期数据时,先把旧值返回给用户,同时异步刷新缓存,用户完全无感知。

额外优化:多级缓存

读多写少场景增加本地缓存:Caffeine 一级本地缓存 + Redis 二级远程缓存,大幅减少跨网络调用。
代价:多实例部署下本地缓存很难做到强一致,依靠广播失效 + 1~2 秒短 TTL 兜底即可。


四、先明确业务需要哪种一致性

不是所有业务都要上一整套强一致方案,按需选型才是最优解。

  1. 强一致(核心交易数据)
    价格、库存、优惠券状态,一旦出错直接造成资金损失。
    方案:读请求强制走主库;缓存只做加速,不作为权威数据源;缓存删除失败直接阻塞或降级,宁可慢,不能错。
  2. 最终一致(普通商品信息)
    商品标题、详情图、店铺公告,数据延迟几秒完全不影响用户体验。
    方案:Canal 异步删缓存 + 定时对账兜底,成本与收益匹配。
  3. 允许误差(计数类数据)
    浏览量、点赞数、曝光计数,写入量大,允许少量误差。
    方案:先写缓存,异步刷入数据库,定期校准;没必要为这类数据搭建复杂的强一致架构。

选型标准:评估数据不一致的时长、误差范围,业务能否接受。把这个边界确认清楚,方案自然就定下来了。


五、落地代码的几条经验

  1. 所有缓存删除操作必须打印日志并埋点监控:删除失败次数、重试次数、死信队列堆积量,这三项指标比架构图更能反映链路健康度。
  2. 缓存 Key 命名要可以反向追溯数据表,方便定位脏数据,也便于对账任务按业务分片执行。
  3. 对账任务严禁全表扫描。采用增量时间窗比对,配合每日凌晨低峰期少量全量抽样校验。
  4. 数据库事务未提交前,绝对不能发送缓存删除消息。使用事务消息 / 本地消息表保证:事务提交成功才发消息。否则消息先到、数据库还没提交,删完缓存立刻被旧值回填。

最后总结

缓存一致性的难点,不在于选择延时双删还是 CDC,而在于把失效边界梳理清楚:

  • 什么场景会产生脏数据?
  • 脏数据最多存活多久?
  • 谁负责自动修复?
  • 修复失败会造成多大业务影响?

把这四个问题答完整,才算真正吃透这套方案。

延时双删只是业务流程里的临时补丁,不是架构级解决方案。真正的方案,藏在数据变更的全链路里:变更从哪里发出、经过哪些环节、失败如何兜底、多久能够自愈。链路理顺之后,不管是双删还是 CDC,都只是整个链路中的一环。

posted @ 2026-11-04 09:47  codigger  阅读(44)  评论(1)    收藏  举报