昨天半夜被电话叫醒,某核心交易链路的缓存和数据库数据对不上了。用户余额显示异常、订单状态错乱,页面上一片告警。排查到凌晨才发现,是并发高峰时段一个极端罕见的线程交错,让旧数据在缓存里多存活了几秒。说白了,这就是缓存与数据库双写一致性问题——平时业务量小的时候概率低得可以忽略,但一旦流量冲到亿级,再低的概率也会变成必现的故障。这篇文章就把我这些年处理这类问题积累的方案、参数设计心得和踩过的坑,完整拆开讲一遍,希望对你正在设计的系统有所启发。
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,理由是"感觉差不多"。这里面的问题在于:如果你的读请求回填耗时要 2s,500ms 的延迟等于没删;反过来,如果延迟时间设置到 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 C),binlog 事件按时间顺序进入队列,但如果消费端是多线程并行消费,可能出现 A 事件后处理、C 事件先处理的情况——最终缓存被旧值覆盖。解决方案也比较成熟:按主键哈希把同一行的变更事件路由到同一个分区,在消费端保证同一个分区的消息是顺序消费的。这是一个看起来基础,但极其关键的配置——我在某项目中排查缓存值"跳变"问题时,最终就是发现消息队列的 key 设错了,导致同一个用户的多条变更落到了不同分区。
难题二:消费幂等性。异步链路中,消息极有可能被重复投递(消费者宕机后重平衡、处理成功后提交 offset 失败等)。所以消费端处理逻辑本身必须幂等。对于"删除缓存"这种操作天然幂等;但如果你选择的是"写入缓存",就要注意不能让旧变更事件把新缓存值覆盖掉——这种场景同样是版本号校验最擅长解决的。
3.4 从行级更新到字段级更新的性能优化
消费端处理 binlog 事件时,最朴素的做法是拿到整行数据直接写缓存。但高并发下这是有浪费的:如果一条记录有 40 个字段,而你的缓存只需要其中 10 个高频字段,每次变更都重新组装整行数据并回填完整对象,浪费 CPU 和带宽。
我实际项目里的做法是:在消费端维护一份"字段映射配置",把 binlog 的行变更映射为缓存对象的局部更新。比如用户表改了手机号,消费端只需要更新缓存对象里的 phone 字段,而不是整个对象重新 SET。这个优化在写频繁、缓存对象较大的场景下效果非常明显,缓存写入的 QPS 能降一半以上。
4. 兜底对账与补偿机制:无论前面多完美,最后一定要有一张安全网
4.1 为什么必须有兜底
前面讲了延迟双删和日志订阅,但我要泼点冷水:上面每一个环节都可能出故障。延迟双删的线程池可能宕机;日志订阅的消费者可能 OOM;Redis 可能在删除前一刻发生了主从切换;消息队列本身可能丢消息。这是分布式系统的常态——任何单一组件都不能假定为不可失败。
因此,一个生产级的高并发缓存系统,必须有一个独立的兜底链路。它在正常工况下不承载主流量,只在数据不一致积累到一定程度时介入纠正。
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 删除缓存就足够了;配置类数据则可以走日志订阅 + 本地缓存的多级架构。
把这些年处理过的双写一致性问题梳理下来,我发现真正棘手的往往不是某个方案的技术细节,而是面对一堆选项时,你能不能说清楚自己这个业务最在意什么——是在意实现成本,是在意一致性窗口的长度,还是在意不让业务代码被同步逻辑污染。想清楚这一点,再回来选方案,你大概就不会再纠结了。