
先问一个现实问题如果Redis里缓存的数据和MySQL里的真实数据不一致业务会怎样轻则页面展示陈旧信息被用户吐槽重则超卖、错账、下单状态混乱。缓存和数据库双写一致性是后端绕不开的坎也是面试官最爱追问的深水区。这篇文章不重复教科书里的空概念而是把从“伪解法”到“生产级架构”的进化路径完整拆开每种方案为什么看着能用、实际却埋雷以及最后我是怎么通过异步化版本控制兜底对账把它做成线上稳跑的。我是做电商中台出身经历过几次因为缓存不一致导致的线上故障。说实话网上讲双写一致性的文章很多但大部分停留在“先更新数据库再删缓存”这种表面答案上。等你真把并发量打上去就会发现情况远比想象中复杂。这篇文章会照顾不同基础的读者刚接触缓存的新手可以重点看第一、二章理解问题本质已经有一定经验的同学可以直接跳到第三、四章参考生产级落地方案。1. 一致性问题的根因为什么缓存总比数据库“慢半拍”1.1 先搞清楚我们到底在用哪种缓存模式在谈双写一致性之前必须先把缓存模式说清楚。目前主流的缓存读写模式有四种Cache Aside、Read Through、Write Through、Write Behind。我见过太多人连Cache Aside和Read Through都分不清就开始讨论一致性方案结果讨论半天发现两个人说的不是同一套东西。Cache Aside旁路缓存是最常用的一种逻辑很简单读请求先读缓存命中就返回未命中就查数据库再把结果回填缓存。写请求先更新数据库再删除缓存。这套模式的特点是业务代码直接操作缓存和数据库灵活性高容易控制所以大部分互联网项目默认选它。Read Through和Write Through的区别在于缓存层会代理数据库操作业务方只跟缓存打交道。Write Behind更进一步缓存先写异步刷回数据库性能最好但数据丢失风险也最大。这三种模式里本文讨论的“双写一致性问题”主要围绕Cache Aside展开因为它是业务代码里最容易出幺蛾子的场景。如果你用Write Behind那追求的根本不是强一致而是高吞吐下的最终收敛不在今天讨论范围内。我个人的经验是能用Cache Aside就先别玩花的。它简单、可控、出了问题好排查是性价比最高的默认选项。1.2 “双写”的本质矛盾两个存储系统之间没有原子事务为什么双写一致性这么难很多人以为难在“删除缓存”这个动作本身其实不对。真正的难点在于数据库和缓存是两个独立的存储系统它们之间没有共享事务。你没有办法用一条SQL同时更新MySQL和Redis更没有一个跨存储的Commit或者Rollback机制。拿生活打个比方你既要在笔记本上记账又要在手机App上记账。两边的记录必须保持一致但两个App之间没有联动。你只能在笔记本改完再跑去手机改中间任何一步断了两边就对不上了。数据库和缓存的关系就是这样而且并发场景下不是一个人在记账是几百个人同时跑来跑去改数据。再补充一个容易忽略的点读请求也会“写”缓存。缓存未命中时查询数据库的线程会把结果回填到缓存。这个“回填”动作本质上是读请求发起的写操作它跟业务写请求之间没有全局顺序保证。回填的可能是旧数据也可能是新数据完全取决于网络时序。这就让不一致问题从“双写”变成了“读写多写”复杂度直接上了一层。这也是为什么很多人想不通明明按照“先更新数据库再删除缓存”执行了缓存里的数据还是不对。因为在删除缓存成功之后、下一个读请求回填缓存之前可能已经有一个读请求拿着旧值完成了回填旧缓存值就这么“复活”了。1.3 业务对一致性的真实需求是有分级的在动工之前一定要先想清楚你的业务到底需要强一致还是最终一致强一致的意思是任何时刻读到的都是最新数据最终一致的意思是允许短暂不同步但最终会收敛。这两种需求对应的架构完全不同。强一致的典型场景是金融交易、库存扣减、支付状态变更。这类数据错了就是钱的问题延迟几百毫秒可以忍但不能出现“库存显示还有实际已经超卖”的情况。最终一致的典型场景是用户昵称、商品详情、文章内容这类读多写少的数据短暂读到旧值影响不大几秒内恢复正常即可。我在实际工作中见过不少团队为“用户昵称”这种数据上分布式事务浪费了大量资源。判断标准其实很简单不一致发生的那一刻用户能感知吗用户能感知并造成损失就必须强一致用户感知不到或者感知了也没有损失最终一致就够了。先把这个判断做完再谈方案选型不然后面的架构一定会过度设计。2. 那些年被当作解法的“伪解法”2.1 先删缓存再写库缓存击穿与旧值回填的温床网上流传的第一种做法是写操作先删除缓存再更新数据库。思路是“我直接把缓存干掉读请求查不到就去数据库拿最新的不就行了吗”这个想法听着很合理但并发环境下立刻露馅。假设有两个线程A和B。A要更新数据先删掉了缓存B此时发起读请求缓存未命中就去数据库查询。问题在于A还没更新数据库数据库里还是旧值B查到旧值后把它回填到了缓存。然后A才更新数据库数据库变成新值。结果缓存里永远躺着旧值直到下一次缓存过期或被再次删除。这个时序一旦在高并发下放大后果就是缓存里长时间存着脏数据。更麻烦的是删除缓存后的瞬间大量读请求同时打到数据库本来缓存该扛住的流量全数穿透到MySQL很容易把数据库冲垮。这就是典型的缓存击穿现象。我见过有团队因为这个方案把数据库CPU打到100%最后不得不紧急回滚发布。所以“先删缓存再写库”这个方案不但没解决一致性问题还顺手制造了缓存击穿的风险属于典型的负优化。2.2 先写库再删缓存看似正确但删除不是银弹后来有人提出改进先更新数据库再删除缓存。比起先删后写这个方案已经在进步因为只要删除缓存成功下一次读请求一定会从数据库拉到最新数据。看起来完美但它默认了一个前提删除缓存一定成功。现实世界哪有这么顺利。第一个问题是删除动作本身可能失败。网络抖动、Redis超时、代码异常任何一个环节出问题缓存里的旧值就永远留在那里。第二个问题是时序窗口。即使删除最终成功也会存在一个短暂窗口数据库已经更新缓存还没删除此时读请求命中旧缓存用户看到的就是旧数据。更隐蔽的是第三个问题还是并发A更新完数据库还没删除缓存B发起读请求缓存未命中B查到数据库中的旧值并回填缓存A随后删除缓存。如果A的删除发生在B回填之前问题不大但如果B回填发生在A删除之后旧值又回填进去了。这种时序在极端高并发下是真实存在的。不要因为“删除缓存失败是小概率”就忽略它。小概率事件在每日千万级请求下就是必然事件。这个方案只能作为起点不能作为终点。2.3 直接更新缓存并发写和乱序更新重灾区还有一种更草率的做法写操作不删缓存而是直接把新值更新到缓存里。表面上看省了一次Redis操作实际上把一致性风险放大到了极致。两个线程A和B同时更新同一条数据A先更新数据库为200B后更新数据库为100。如果A先把200写进缓存B再把100写进缓存那缓存和数据库都是一样的没问题。但网络时序是不可控的A先更新数据库为200然后网络延迟还没写到缓存B后更新数据库为100立刻把100写到缓存A这时候才把200写到缓存。数据库最终是100缓存却是200用户看到的永远是一个错误的值。乱序在分布式系统里几乎是无法彻底避免的。你可以在业务代码里加分布式锁、加版本号来缓解但那就已经偏离“直接更新”这个简单方案了。况且直接更新缓存还会带来一个额外问题写入缓存的数据如果不小心比数据库事务先提交一旦数据库回滚缓存里的值就变成了“幻觉数据”。这个方案我只建议在一种场景下用数据本身允许丢失和覆盖比如访问计数、实时在线的展示数值对准确性没有硬性要求。除此之外一律别碰。2.4 伪解法的共性缺陷缺少兜底闭环把上面三种方案放在一起看你会发现它们有一个共同点都假设操作能按预期顺序顺利执行。一旦出现删除失败、网络延迟、线程乱序整个方案就失效了。伪解法之所以“伪”不是因为第一步错了而是因为没给自己留后路。真正的生产级方案必须包含三个要素第一重试机制任何一步失败都能通过异步任务重试补偿第二版本控制让新旧数据有明确的先后顺序而不是靠执行时序猜第三对账巡检即使前面全崩了后台任务还能发现并修复不一致。这三样缺一样都只能算伪解法。3. 延迟双删第一个能扛住线上的过渡方案3.1 核心流程与代码骨架延迟双删是在“先写库再删缓存”基础上改进的核心思路是删除缓存、更新数据库、休眠一小段时间、再次删除缓存。第二次删除解决的正是之前提到的那个死角——第一次删除之后、数据库更新之前可能有读请求把旧值回填进缓存。我直接给一个可运行的伪代码骨架方便你理解流程public void updateData(Long id, String newValue) { // 第一次删除缓存 String cacheKey buildKey(id); redisTemplate.delete(cacheKey); // 更新数据库 userMapper.update(id, newValue); // 休眠让并发读请求完成旧值回填 try { Thread.sleep(DUAL_DELETE_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 第二次删除缓存 redisTemplate.delete(cacheKey); }注意实际生产环境我不会建议你用Thread.sleep它会阻塞线程池高并发下浪费严重。更好的做法是把第二次删除提交到延迟队列或者异步任务里让写入线程立刻返回由后台任务负责二次删除。核心思想不变删两次中间留一个时间窗口。3.2 sleep间隔到底该设多少一个可量化的计算方法延迟双删最关键的参数是休眠时间。设短了旧值还没回填完就二次删除等于没删设长了写入延迟变大接口RT飙升。这里有个计算方法你可以直接套用。sleep时间 业务读请求在服务层消耗的时间 主从同步延迟时间 冗余缓冲时间。举个例子假设读请求从缓存miss到数据库查询再到回填缓存平均耗时80ms你的MySQL主从延迟平均200ms再留200ms的缓冲。那么sleep时间就是80200200480ms稳妥起见取500ms。这个值在绝大多数中小型业务下够用了。如果你开了读写分离读请求可能走向从库那就必须把主从同步延迟算进去。从库同步延迟大的时候即使第二次删完缓存后续读请求从从库拉到的还是旧数据顺手又回填了一次。这种情况下延迟双删的sleep时间甚至需要拉到秒级实用性就大打折扣了。所以说延迟双删是过渡方案不是终点方案。3.3 第二次删除失败的重试补偿延迟双删最大的隐患在第二次删除。如果第二次删除失败缓存里的旧值还是会被读请求回填一致性依旧被破坏。所以必须在第二次删除失败后提供补偿机制。我的做法是把删除失败的任务投递到MQ由独立消费者反复重试。具体流程是第二次删除Redis返回异常时将cacheKey封装成消息发给延迟队列消费者收到消息后再次执行删除如果还是失败按指数退避策略重试最多重试N次超过重试上限后告警给值班人员人工介入处理。这里有个小技巧删除缓存的动作要设计成幂等。Redis的DEL本身天然幂等删不存在的key也不会报错所以重试不会产生副作用。千万不要在删除缓存前先查询缓存里是什么值那个查询本身又会产生读请求反而引入新的不一致。3.4 不要神话延迟双删它解决不了的三件事延迟双删虽然能扛住一部分线上场景但局限非常明显。第一它解决不了“读操作从从库拉到旧值”的窗口期问题。只要读请求走从库且从库同步延迟高于sleep时间旧值回填就无法避免。第二它解决不了多级缓存问题。如果你在Redis前面还加了一层本地缓存比如Caffeine延迟双删只能操作Redis本地缓存里的旧值依然存在。第三它解决不了删除期间不断有新写入的情况。如果写操作持续发生每次写删除一次缓存中间总有窗口期让旧值回填最终一致性收敛时间会被拉长。所以我的结论是如果你的业务QPS不高、对一致性容忍度适中延迟双删是一个性价比不错的过渡方案但如果追求更高的一致性保障就必须继续往后看。4. 生产级架构异步化、版本控制与对账闭环4.1 用binlog订阅把缓存淘汰从业务代码里抽离延迟双删的痛点之一是业务代码要主动维护缓存状态侵入性强。真正生产级的做法是引入binlog订阅把缓存淘汰动作从业务代码中抽离做成一个独立的异步链路。MySQL的binlog记录了所有数据变更事件。业界常用的方案是使用Canal伪装成MySQL从库消费binlog把变更事件投递到消息队列Kafka或RocketMQ再由消费者解析事件、删除对应的缓存。这样一来业务代码只负责更新数据库完全不感知缓存的存在。缓存淘汰由binlog消费者统一负责逻辑清晰维护成本也低。核心消费者逻辑大概长这样public void onMessage(BinlogChangeEvent event) { String tableName event.getTableName(); String primaryKey event.getPrimaryKey(); // 根据业务表名拼缓存key删除缓存 String cacheKey buildCacheKey(tableName, primaryKey); redisTemplate.delete(cacheKey); }这个方案的第一个好处是解耦。缓存策略调整时业务代码一行都不用改。第二个好处是天然具备重试基础。消费者从MQ拉取消息处理失败可以重新入队配合MQ的重试机制基本能保证删除操作最终成功。第三个好处是覆盖全面。无论数据是通过Service层修改、管理后台修改还是定时任务批量修改只要binlog有记录消费者就能感知不存在漏删。我在项目中做过一次对比同样一批写操作使用延迟双删方案缓存不一致的窗口期平均在500ms到1s切到binlog订阅后窗口期压缩到100ms以内且不再受业务代码时序影响。这个改善在大促场景下非常明显。4.2 版本号让旧数据无处回填binlog订阅能保证“删除”动作及时可靠但还堵不住一个漏洞读请求可能拿着旧值回填缓存。原因在于缓存被删除之后、读请求回填之前数据库可能还没有完成最新写入。为了解决这个问题我给缓存引入版本号机制。具体做法是数据库表中增加一个version字段每次更新数据时version加一写入缓存时把value和version一起存入。读请求回填缓存时先从数据库拿最新version再决定是否写入。如果请求A查询到的数据版本是2但数据库当前版本已经是3那么A回填的2就是旧值直接丢弃不回填缓存。这样就把一致性的保证从“执行顺序”升级为“逻辑新旧”。即使读请求乱序回填只要版本号被比较旧数据就会被拒绝。实现层面有几种方案。简单粗暴的做法是Redis的value里存一个结构体包含业务数据和版本号。还有一种做法是单独用一个Redis key存储版本号每次写入时同时更新。两种都可以关键是读回填必须带着版本校验这是个硬性约束。4.3 对账巡检一致性问题的最后防线无论架构设计得多完美总有覆盖不到的角落——比如网络分区、缓存节点故障、消费者代码上线时的短暂遗漏。所以成熟系统必须加一道最终防线对账巡检。不要依赖“理论上不会出错”要假设“一定会出错”然后再设计发现机制。我搭建过的对账方案大致是这样每个业务数据表都有一个updated_at字段对账任务每隔几分钟扫描一次数据库筛选出最近N分钟内更新过的记录拿到记录的主键列表后批量查询Redis中的缓存如果缓存存在且缓存里的更新时间或版本号落后于数据库说明缓存是脏数据触发更新或删除操作。这套方案看起来简单实际落地有几个细节值得注意第一扫描数据库不要全表扫一定要走updated_at索引否则大表会拖垮主库第二一次扫描的量要控制住可以分片分批执行避免对业务造成压力第三对账任务发现不一致时先记录日志再修复保留现场方便排查根因第四对账频率根据业务容忍度设置一般5分钟一轮即可不用太激进。对账巡检不会降低不一致发生的概率它的价值在于把“用户发现错误”变成“系统主动发现错误”把故障发现时间从小时级压缩到分钟级这对线上稳定性至关重要。4.4 什么情况下才需要上分布式事务或分布式锁这个章节容易让人走极端一部分人认为“最终一致就够了”另一部分人觉得“必须强一致”于是直接上Seata或者各种分布式事务框架。我的判断是大多数业务场景根本不需要分布式事务上了反而给自己挖坑。分布式事务的代价是性能损耗和复杂度剧增。Seata AT模式要解析SQL、生成undo_log、全局锁协调一次写操作的开销翻了不止一倍。在非支付、非库存类业务上使用完全是杀鸡用牛刀。真正需要强一致的往往是那些数据错了会产生资金风险或法律风险的场景。这时候我会优先考虑分布式锁把同一个数据的读写操作串行化降低并发交错概率。我之前负责过一个积分变更接口逻辑是先更新数据库积分再刷新Redis里的积分展示。因为并发导致用户积分被覆盖出现过客诉。最后解决方案不是上Seata而是给用户ID加分布式锁把同一个用户的积分更新和缓存刷新放进同一个锁边界内然后配一个延迟双删兜底。效果立竿见影性能损耗可以接受。分布式事务不是银弹分布式锁也不是。它们的共同点是增加复杂度所以一定要在“一致性需求”和“系统复杂度”之间做权衡。我的原则是能用最终一致解决的绝不用强一致方案必须强一致时优先用锁其次才考虑事务框架。5. 常见问题与排查技巧实录5.1 一张表解决一半疑问我整理了日常排查缓存一致性问题时最高频的几种现象和思路直接用表格呈现方便你存下来对照现象可能原因排查思路解决方案缓存里一直是旧数据长时间不更新删除缓存失败且没有重试机制查Redis是否存在key看删除日志增加异步重试删除失败入MQ补偿数据偶尔回到旧值刷新后又正常读请求旧值回填发生在写操作之后看缓存中的更新时间戳引入版本号旧版本不写入缓存删了以后数据库被打垮删除缓存瞬间大量读请求穿透查DB监控和QPS加分布式锁或限流避免缓存击穿同一条数据多次更新后缓存值错误并发写请求乱序更新缓存按时间戳比对缓存与DB加版本号或分布式锁更新主库后从库读到旧数据并回填主从同步延迟读请求走了从库排查从库延迟时间延迟双删或binlog订阅扩大等待窗口管理后台改了数据缓存没变管理后台走的不是业务Service未触发删缓存查操作日志看是否走binlog统一走binlog订阅方案缓存过期后新数据写库失败先删缓存后写库写库失败导致缓存被删查数据库事务提交状态调整为先写库再删缓存失败重试这张表不是万能药但它能帮你快速缩小问题范围。大多数缓存不一致的故障根源都跑不出这几类。5.2 我踩过的三个坑第一个坑延迟双删的sleep时间设太短。当年我把sleep设为200ms测试环境一切正常线上却频频出现缓存旧值。后来排查发现线上读请求的耗时比测试环境高一倍还多旧值回填发生在400ms之后我的第二次删除早就执行完了。从此我把sleep时间调整为一个动态值取最近5分钟读请求RT的P99再加上主从延迟监控值最终得到一个自适应参数。这是一个很实用的经验。第二个坑删除缓存失败被静默忽略。早期代码里删缓存用了try-catch包裹catch里只打了一条日志没有告警。某次Redis集群抖动大量删除失败日志被其他错误信息淹没缓存和数据库不一致持续了近半小时直到用户投诉才被发现。上线告警后我加了失败率和失败数量的监控指标一旦超过阈值立刻通知值班群。第三个坑binlog消费者没有幂等保护。理论上DEL是幂等的但消费者如果同时处理多条同一条记录的变更事件再加上消息重复投递会大量删除同一个key。Redis的压力倒是其次关键是影响到了正常读到缓存数据的请求导致缓存命中率短期下跌。后来我在消费者里加了去重逻辑同一个key在1秒内的重复事件直接合并处理问题消失。这三个坑都不是高深的技术难题但它们告诉我一个道理缓存一致性问题里出问题的往往不是方案本身而是方案的工程化细节有没有做到位。5.3 最后说点我个人对缓存治理的态度做了这么多年缓存治理我的核心感受是一致性方案没有银弹只有不断地在性能、一致性、复杂度之间做取舍。每次接一个新项目我都会先问三个问题这条数据允许不一致的时长是多少不一致造成的损失有多大当前的基础设施能支撑哪种方案回答完这三个问题方案基本就清晰了。技术方案的终极进化不在于用多高级的框架而在于把每一个环节的失败路径都考虑清楚。重试、版本、对账这三板斧砍下去缓存和数据库双写一致性就不会再成为让你半夜惊坐起来的那个噩梦。