昨天半夜被电话叫醒某核心交易链路的缓存和数据库数据对不上了。用户余额显示异常、订单状态错乱页面上一片告警。排查到凌晨才发现是并发高峰时段一个极端罕见的线程交错让旧数据在缓存里多存活了几秒。说白了这就是缓存与数据库双写一致性问题——平时业务量小的时候概率低得可以忽略但一旦流量冲到亿级再低的概率也会变成必现的故障。这篇文章就把我这些年处理这类问题积累的方案、参数设计心得和踩过的坑完整拆开讲一遍希望对你正在设计的系统有所启发。1. 双写不一致的根因先搞懂数据是怎么在缓存和数据库之间打架的1.1 缓存追求快、数据库追求准这就是矛盾的起点很多刚接触高并发系统的同学把缓存看得很简单——数据库前面挡一层 Redis 不就行了实际完全不是这么回事。缓存的价值在于把热数据的读取从磁盘 IO 变成内存访问把数据库的 QPS 压力降下来但缓存本身只是一份临时副本它是可以被淘汰、被覆盖、甚至丢失的。数据库才是唯一的事实来源。这两者天然有矛盾缓存更新得快但可能错数据库更新得慢相对而言但一定准。双写一致性要解决的就是在这两者之间找到一个工程上可接受的一致程度而不是追求理论上的完全一致。这里要先明确一个概念在分布式系统里跨组件、跨网络的强一致线性一致性代价极其昂贵基本只有单节点数据库事务才能做到。缓存和数据库分属两个独立存储系统无法用本地事务把它们的写入捆绑在一起所以绝大多数高并发场景求的是最终一致性——即在某个时间窗口内数据可能不一致但经过一段时间后会自动收敛一致。注意这里说的最终一致不是放任不一致而是通过设计让不一致窗口尽可能短短到用户无感知、业务可接受。1.2 经典 Cache Aside 模式下的竞态窗口在哪里业界最常用的缓存读写模式是Cache Aside旁路缓存。读请求先查缓存命中就返回不命中则查数据库再回填缓存。写请求则是先更新数据库再删除缓存有些团队是更新缓存这个细节后面会专门说。所有人都知道这个流程但很多人没仔细想过为什么写操作要删缓存而不是直接更新缓存为什么删除操作放在更新数据库之后先回答第一个问题。直接更新缓存的问题在于你把写数据库和写缓存变成了两个步骤如果第二个步骤失败就会留下一个过期值。而且更新缓存需要额外把数据组装好成本更高。删除缓存则简单很多——下次读请求 miss 了自然会把最新数据拉回来。第二个问题更重要。如果先删除缓存再更新数据库这时候一旦有读请求进来发现缓存 miss就会去查数据库但数据库还是旧值于是旧数据被回填到了缓存。等数据库更新完缓存里已经停留了一份过期数据必须等待下一次淘汰或写入才可能纠正。所以标准的 Cache Aside 采用先更新 DB再删缓存的顺序——即使删除缓存失败缓存里只是多留了一会儿旧数据数据库一定是最新的后续还有补偿机制可以兜底。但即便用这个正确顺序高并发下还是有一个经典的竞态窗口这也是整个双写问题的核心源头请求 A 执行写操作更新了数据库此时 DB 中是新值。在这个瞬间请求 B 来读缓存发现 miss于是去数据库拿数据。请求 B 拿到的是更新后的新值还没回填缓存。请求 A 执行了删除缓存操作缓存被清空。请求 B 把刚才读到的数据回填到缓存。问题来了如果第 3 步在 A 更新之前执行请求 B 晚于写操作到达数据库节点但早于删除动作完成第 5 步回填的就是旧值。也就是说——读请求回填的旧数据恰好覆盖在写请求删除缓存之后这个时序窗口让缓存里残留了过期数据。1.3 亿级流量如何把概率问题放大成故障很多技术文章分析到这里就结束了然后丢给你一个结论所以要用延迟双删。但我想强调另一个维度——为什么同样的问题在小系统里不值一提在大流量下就是事故。假设上面这个竞态窗口出现的概率是千分之一实际往往更小。日请求量 100 万时每天可能出现约 1000 次不一致其中大部分会被后续的写操作自然覆盖用户感知不到。但如果你的系统日请求量过亿核心缓存 key 每秒 QPS 上万千分之一的概率就是每秒十多次的潜在不一致这时候它不再是概率问题而是确定性问题——正如深夜那次把我叫醒的告警一样。流量的放大效应还体现在另一面高并发下同一个热点 key 的读写往往集中在极短的时间窗内多个线程的读写交错会让竞态窗口重叠。比如一个热门活动页面的库存 key同一毫秒内有几十个读请求 miss数据库查询和缓存回填的耗时差异很大写请求的删除操作很难准确切中所有回填动作。理解了根源下面几种主流方案就有了判断基础——它们本质都是在想尽办法缩短这个竞态窗口或者让旧数据无法被回填。2. 延迟双删最朴素也最常用但参数设定决定成败2.1 双删的完整流程与设计动机延迟双删是目前很多团队采用的第一版方案核心思路非常简单既然删一次可能刚好被并发读请求的回填覆盖那我就过一会儿再删一次。public void writeWithDelayDelete(String key, Object value) { // 第一步更新数据库 db.update(key, value); // 第二步立即删除缓存 cache.del(key); // 第三步延迟指定时间后再次删除缓存 delayedDelete(key, DELETE_DELAY_MS); }第二次删除的意义在于如果第一次删除之后有读请求回填了旧数据那么等这些读请求的“回填动作”全部完成之后再删一次就能把这份残留的旧数据清掉。它的前提假设是在延迟时间窗口内所有可能回填旧数据的读请求都已经完成。只要我们把这个窗口估算得足够宽第二次删除时缓存中残留的旧值就能被清掉。2.2 延迟时间的计算拍脑袋定 500ms 是要出事的延迟时间怎么定我见过一些项目直接写固定值 500ms 或 1s理由是感觉差不多。这里面的问题在于如果你的读请求回填耗时要 2s500ms 的延迟等于没删反过来如果延迟时间设置到 5s缓存一致性的恢复时间就会被拉长很多可能会出现都写入 3 秒了读到还是旧数据的体验。一个相对靠谱的估算方法是延迟时间 读请求从数据库读取数据到回填缓存所需的最长耗时。但最长耗时理论上没有上界GC 停顿、网络抖动、数据库慢查询都可能拉到秒级所以实际操作中一般用TP99 或 TP999 作为基准再乘上 2~3 倍的裕量。举个实际例子。假设你监控数据显示缓存回填链路查 DB 序列化 Redis SET的 TP999 是 350ms那你的延迟时间可以设置在 800ms~1s 左右。如果业务对实时性要求很高比如库存扣减就取 800ms结合第二层的版本号校验后面会讲兜底如果业务容忍度还行可以取 1.5s更稳妥。注意延迟删除本身需要引入一个定时机制来实现。可以用 [RocketMQ] 的延迟消息、Redis 的 zset 延迟队列或者简单的数据库定时任务扫表。不建议自己用线程池 sleep 来做——进程重启、线程积压都会导致延迟任务丢失。2.3 延迟双删的三大副作用延迟双删虽然简单但在真正的亿级流量下有三个问题必须面对副作用一延迟期间缓存命中率下降。第一次删除后到第二次删除前所有打在这个 key 上的读请求都会击穿到数据库。如果这个 key 恰好是热点数据库可能瞬间收到几倍的流量。所以延迟双删一般搭配缓存的单飞singleflight机制——同 key 只允许一个请求穿透到数据库其余请求等待它回填后复用结果。这个组合能有效避免缓存击穿。副作用二极端并发下双删依然存在漏网之鱼。如果读请求的回填耗时异动超过预设延迟时间比如数据库主从延迟、突发慢查询第二次删除执行完之后才完成回填旧数据照样会被写进去。这个问题靠双删本身无法根治最多把概率进一步压低。要根治需要引入版本号或类似机制。副作用三业务代码侵入。双删逻辑通常写在 Service 层每个写操作都要手动调用删除工具类漏写一个写方法就留了一个未删除的洞。这也是为什么后来我倾向于把同步逻辑从业务代码中剥离出去——这就是下一章要讲的日志订阅方案。2.4 用版本校验把双删的漏网之鱼堵上延迟双删的一个增强版本是在回填缓存时携带一个版本标记。大致思路是缓存 value 的 key 不变但 value 里带上业务版本号或最后更新时间戳。写请求把新版本号随数据库一起更新读请求回填缓存时如果发现缓存里已有的版本号比当前数据库版本旧则不允许覆盖。public void fillCache(String key, long currentDbVersion) { CacheItem item cache.get(key); // 如果缓存已存在且版本号 当前版本不覆盖 if (item ! null item.version currentDbVersion) { return; } cache.set(key, new CacheItem(dbData, currentDbVersion)); }这等于给缓存回填加了一层落笔前先看版本的校验从源头阻止旧数据覆盖新数据。缺点是需要对数据增加版本字段并且读 DB 时拿到的是不是最新版本依赖于数据库读一致性如果读请求读到了旧版本数据即使版本校验逻辑正确也还是会把旧版本写进缓存。因此它更适合当作双删的增强手段而不是独立方案。3. 基于日志订阅的异步同步把一致性逻辑从业务代码里剥离出来3.1 架构组成与核心思路看到这里你已经发现延迟双删再怎么优化始终有两个绕不开的痛点侵入业务代码和一致性窗口不可控。于是高并发项目做到一定体量后普遍会迁移到基于数据库变更日志binlog订阅的异步同步方案。架构的核心思路是业务代码只负责写数据库完全不感知缓存的存在缓存更新由另一条独立的异步链路负责。业务写入数据库后数据库的变更记录进 binlog。一个伪装成从库的采集组件Canal 就是干这个的实时读取 binlog 变更事件。变更事件被投递到消息队列Kafka / RocketMQ。专门的消费服务拿到变更事件后执行缓存写入或删除。这套方案最直观的好处业务 Service 层变得非常干净不再要写更新完 DB 后删缓存这行代码也不存在写方法忘删这种低级事故。同步逻辑收敛在消费端组件里出了问题只需要在独立的消费服务上排查影响面可控。3.2 全链路的延迟现状能推到多快很多业务方问我的第一个问题是这套链路延迟有多少用户刚改了数据多久能在缓存里看到我直接给一个比较真实的量级参考MySQL 的 binlog 从产生到被采集组件拉取正常情况下在毫秒到几十毫秒级别发到消息队列再被消费如果消费端没有积压单条消息的处理延迟在几十毫秒左右再加上一次 Redis 写入操作整体链路一般能控制在 100~300ms 内。这个数据在绝大多数业务场景下已经非常能接受了。用户改了个人资料200ms 后在缓存读到新值感知完全无差别。要注意的是延迟会随着消费积压而恶化——一旦下游消费跟不上写入 QPS消息堆积缓存一致性窗口就会从几百毫秒拉长到秒级甚至更久所以要给消息队列的消费 lag 设好告警这是这条链路的命脉。3.3 顺序性和幂等性异步链路的两座山把同步操作从 API 调用变成消息驱动之后两个难题浮出水面难题一消息顺序性。假设同一行数据在同一秒内被修改了三次update A → update B → update Cbinlog 事件按时间顺序进入队列但如果消费端是多线程并行消费可能出现 A 事件后处理、C 事件先处理的情况——最终缓存被旧值覆盖。解决方案也比较成熟按主键哈希把同一行的变更事件路由到同一个分区在消费端保证同一个分区的消息是顺序消费的。这是一个看起来基础但极其关键的配置——我在某项目中排查缓存值跳变问题时最终就是发现消息队列的 key 设错了导致同一个用户的多条变更落到了不同分区。难题二消费幂等性。异步链路中消息极有可能被重复投递消费者宕机后重平衡、处理成功后提交 offset 失败等。所以消费端处理逻辑本身必须幂等。对于删除缓存这种操作天然幂等但如果你选择的是写入缓存就要注意不能让旧变更事件把新缓存值覆盖掉——这种场景同样是版本号校验最擅长解决的。3.4 从行级更新到字段级更新的性能优化消费端处理 binlog 事件时最朴素的做法是拿到整行数据直接写缓存。但高并发下这是有浪费的如果一条记录有 40 个字段而你的缓存只需要其中 10 个高频字段每次变更都重新组装整行数据并回填完整对象浪费 CPU 和带宽。我实际项目里的做法是在消费端维护一份字段映射配置把 binlog 的行变更映射为缓存对象的局部更新。比如用户表改了手机号消费端只需要更新缓存对象里的 phone 字段而不是整个对象重新 SET。这个优化在写频繁、缓存对象较大的场景下效果非常明显缓存写入的 QPS 能降一半以上。4. 兜底对账与补偿机制无论前面多完美最后一定要有一张安全网4.1 为什么必须有兜底前面讲了延迟双删和日志订阅但我要泼点冷水上面每一个环节都可能出故障。延迟双删的线程池可能宕机日志订阅的消费者可能 OOMRedis 可能在删除前一刻发生了主从切换消息队列本身可能丢消息。这是分布式系统的常态——任何单一组件都不能假定为不可失败。因此一个生产级的高并发缓存系统必须有一个独立的兜底链路。它在正常工况下不承载主流量只在数据不一致积累到一定程度时介入纠正。4.2 对账任务核心逻辑怎么低成本发现不一致对账的基本思路是定期比对缓存和数据库中的数据找出不一致并修复。具体实现上有两种档位档位一全量扫描对账。适合数据量不大百万级以下的场景。定时任务遍历数据库全表对于每个主键读取缓存值并比对关键字段。发现不一致就强制用数据库值覆盖缓存。实现简单但数据量大时耗时非常长不适合大表。档位二增量变更比对。适合海量数据场景。核心思路是以写日志为准追踪最后写入时间。具体做法给数据库表增加一个更新时间字段或直接使用 binlog 的 position对账任务只处理最近一段时间内有变更但缓存未被正确更新的记录。这类记录通常很少因为正常链路的代码基本能把 99.99% 的变更处理掉所以对账任务的频率可以调得比较高成本也可控。4.3 补偿修复要有节奏千万不要狂跑很多团队对账发现问题后第一反应是赶紧把所有不一致的记录全部刷新一遍。这是非常危险的做法——如果被刷新的缓存 key 数量巨大瞬间产生的热 key 写流量可能打满 Redis甚至引起集群抖动。正确的补偿姿势应该是限速修复。我常用的策略是把待修复 key 放进一个带速率限制的队列每秒最多刷新几百到几千个 key同时通过监控观察 Redis 的写负载指标动态调整速率。修复完成后用抽样比对验证一致率是否恢复到正常水平。另外一点心得对账发现的不一致一定要按业务等级分流。资金、库存、价格这类直接影响交易的字段必须优先修复并同时发出高危告警个人资料、偏好设置这类低危数据可以进入低优先级队列慢慢处理。不分青红皂白一把抓生产事故往往就出在把低危数据的修复流量挤占了高危数据的修复通道这种细节上。4.4 监控告警让不一致可观测有了兜底还不够还要有监控让你能看到不一致的存在。常见指标有三个缓存删除失败率每次删除缓存操作失败都要记录并告警失败率突然上升往往意味着 Redis 集群性能问题或 API 调用异常。对账不一致数量每次对账任务执行后记录不一致 key 的数量。这个数字应该长期接近于 0。一旦出现明显上涨说明主链路双删或日志订阅出现了问题。消费链路 Lag日志订阅方案的消费积压数量。Lag 持续上涨意味着缓存一致性窗口正在被拉长。5. 高并发场景下的方案选型对比之后你会发现没有银弹5.1 各方案综合对比把前面几个主流方案放到一张表里横向对比结论会更直观。适用的数据量级从百万级到亿级都有但要求的关键能力各不相同。方案一致性窗口业务侵入复杂度典型成本推荐场景Cache Aside仅删除秒级依赖竞态概率极低极低几乎为零读多写少、并发不极端的场景延迟双删百毫秒~秒级中低延迟队列资源写并发增强但可容忍短时不一致版本号校验 双删毫秒~百毫秒中高中数据模型需加字段对一致性要求较高的核心链路日志订阅异步同步百毫秒级极低业务无感高采集组件 MQ 消费服务亿级流量、写频繁、业务迭代快的系统对账兜底长分钟级低中定时任务资源任何生产系统都应配置的安全网5.2 按业务类型怎么选我的务实建议如果你是中小业务一天流量百万级团队只有两三个后端我建议你老老实实先把 Cache Aside 删除缓存做到极致不要上来就上日志订阅那一套。这个阶段的主要矛盾是功能迭代速度一致性问题出现概率不高即便出现对用户影响也有限用最简单的方式覆盖即可。如果你的业务已经进入千万级 DAU、亿级请求量核心链路实时性又很高订单、支付、库存那我认为延迟双删作为短期过渡、日志订阅作为长期方案是更优路径。计划上第一个月先用延迟双删 版本号兜底解决最紧急的线上一致性问题第二三个月搭建日志订阅链路把核心表的缓存同步全部切换过去同步把对账任务和监控告警补齐。这里要特别提醒——无论选择哪套方案删除缓存失败的场景永远要有重试补偿。我在不止一个项目里遇到过相同的坑代码里调用了缓存删除 API但异常被静默吞掉导致缓存里积压了大量过期数据。这个问题的解法很简单删除失败时把 key 写入一个本地重试队列由定时任务批量重删或者直接接入上面说的对账机制让兜底链路发现并修复。5.3 一个容易被忽略的技术债务不一刀切最后分享一个容易被忽略的经验一套系统内可以同时存在多种一致性方案不要试图用一个方案解决所有问题。你的系统里可能有几十种业务数据——用户资料、商品详情、库存、订单状态、配置开关……它们的并发特征、实时性要求、容忍程度完全不同。用一个统一组件处理所有场景大概率要么过度设计要么力不从心。例如库存变动非常频繁实时性要求极高我一般用数据库强一致 缓存 TTL 极短甚至不走缓存 版本校验异步回填的组合而用户昵称修改这种低频操作直接用 Cache Aside 删除缓存就足够了配置类数据则可以走日志订阅 本地缓存的多级架构。把这些年处理过的双写一致性问题梳理下来我发现真正棘手的往往不是某个方案的技术细节而是面对一堆选项时你能不能说清楚自己这个业务最在意什么——是在意实现成本是在意一致性窗口的长度还是在意不让业务代码被同步逻辑污染。想清楚这一点再回来选方案你大概就不会再纠结了。