用红温警告先别急这个问题十有八九能救回来。做后端的人几乎都会被“缓存和数据库更新顺序不一致”这个问题折磨过。明明下单流程没问题用户却反馈“我支付成功了订单还是待付款”明明后台改了个配置前端页面死活不生效。这类问题在技术交流群里出现频率极高而且每次排查起来都让人心力交瘁。先简单交代一下这个问题本质上是在讨论当我们同时面对一层高速缓存比如Redis和一份持久化存储比如MySQL时用什么样的更新顺序和策略才能让两边数据尽量保持一致并且不引入严重的性能或可用性负担。它解决的痛点是“数据读多写少时的响应速度”与“数据最终一致性”之间的冲突。这篇文章不是纯理论搬运我会结合自己踩过的一些坑把“先更新缓存还是先更新数据库”这个选择题掰开揉碎再给出几种线上常用的修复方案包括但不仅限于延迟双删、消息队列补偿、以及基于Binlog的订阅同步。无论你是刚接触缓存的新手还是已经在生产环境里维护过分布式系统的工程师都能从中找到可以直接落地的思路。1. 为什么“更新顺序”会成为一个问题1.1 两种存储的本质差异要理解顺序为什么重要得先明确Redis和MySQL在定位上的根本不同。MySQL是事实来源数据具备完整的ACID事务保障崩溃恢复能力强但吞吐量有上限尤其在热点行更新上容易成为瓶颈。Redis是加速层读性能极强但它的持久化策略默认不是强一致的哪怕开启了AOF也做不到像MySQL那样的事务级可靠性。线上通常采用的组合方式是读请求先查Redis命中直接返回未命中则回源查MySQL再回填Redis。写请求则要同时更新MySQL和Redis。问题就出在这个“同时更新”上——因为两个操作不是一个原子事务必然存在先后而不同顺序在不同时间点会暴露不同状态给并发读请求于是产生了不一致。1.2 一致性的三种级别业内聊缓存一致性通常会落入三个层次强一致性任何时刻任何节点读到的数据都完全一样这在跨存储引擎的架构中几乎做不到除非引入分布式事务或全局锁但代价极高。最终一致性允许中间有一段时间读到旧值但经过一段时间后所有副本最终会收敛到最新值。这是生产环境的主流选择。会话一致性同一个用户或会话内读到一致数据其他维度可以暂时不一致。常用于登录态、购物车等场景。我们后续聊的所有方案目标基本都是“最终一致性”顶多把不一致的时间窗口压缩到毫秒级。如果有人跟你承诺“绝对一致”那他要么是在忽悠你要么是场景极其特殊。2. 四种经典的更新顺序组合把“更新数据库”和“更新缓存”想象成两个操作步骤不讨论任何附加策略时每个步骤都有成功和失败的可能理论上就存在四种组合。我直接按实际操作顺序来说网上经常讲的“先删缓存再更新库”“先更新库再删缓存”本质上是这四种组合的不同侧重。2.1 先更新缓存再更新数据库这个顺序是反面教材但很多初学者会下意识这么写因为脑子里的逻辑是“代码从上到下执行先写了缓存就生效快”。实际线上几乎没人敢用原因很直白如果缓存先写成功了数据库更新失败那缓存里就是一份脏数据而且它会一直存在直到缓存过期。举个例子商品价格原本是100元运营改成80元你先把Redis里的价格改成80结果MySQL更新语句因为字段超长报错回滚Redis里的80却留下来了。用户看到的价格和数据库对不上订单生成后对账又是一堆麻烦。更糟糕的是这种脏数据没有自动修复机制因为后续读请求都会命中缓存里的80根本不会触发回源数据库的逻辑。2.2 先更新数据库再更新缓存这个顺序比第一种好数据库作为事实来源先落定缓存只是同步更新。但它仍然有两个隐患。隐患一是并发覆盖问题。假设有两个线程同时对同一行数据做更新。线程A把数据库的值从1改成2线程B把数据库的值从2改成3。由于线程调度和网络延迟可能出现A先更新了数据库B再更新数据库但B先执行了Redis的写操作把缓存设成3A后执行Redis写操作把缓存覆盖回2。最终数据库是3缓存是2不一致了。隐患二是写缓存失败问题。数据库更新成功但Redis写入时网络抖动超时缓存里仍然是旧值。虽然这个旧值会在过期后被修正但在过期之前的这段时间内读请求都会读到脏数据。2.3 先删缓存再更新数据库这也是一个流传很广的方案核心思路是不求缓存跟数据库同步更新而是主动把缓存干掉让读请求回源数据库拉最新值。但它有一个经典的并发漏洞。线程A先删除了缓存还没更新数据库线程B这时发起读请求发现缓存未命中回源数据库读到了旧值然后把旧值回填到缓存接着线程A才更新数据库。结果缓存里存的是一个比数据库还旧的旧值而且会一直留在那里。这个场景在实际并发量高的业务中很容易触发特别是在秒杀、价格调整这类热点数据上。2.4 先更新数据库再删缓存这就是大家常说的Cache Aside模式也是目前工程上用得最多的一种。它同样存在一个时间窗口但概率和危害相对可控。它的主要问题是更新数据库成功后删除缓存失败怎么办如果删除动作没执行成功缓存里还是旧值和先更新数据库再更新缓存的隐患二类似。此外在极端并发下也有覆盖问题只是窗口极窄。核心原因在于先更新数据库后删缓存能让“读缓存未命中回源数据库”这个动作大多数情况下查到新值。为什么说它是最优解的顺序因为MySQL的更新操作有行锁删除缓存这个动作本身代价极低失败时可以配合重试重试还不行就等缓存过期。相较之下其他顺序要么容易产生长时间脏数据要么并发窗口太大。3. 解决不一致的主流方案实战顺序定了接下来就是“怎么保证删缓存这件事可靠地发生”。我下面列出的几个方案都是围绕“先更新数据库再删除缓存并且删除失败有兜底”的原则来展开的。3.1 方案一延迟双删延迟双删是在“先更新数据库再删除缓存”的基础上额外增加一次延迟删除。整套流程是更新MySQL数据库。删除Redis缓存。休眠一小段时间通常是几百毫秒。再次删除Redis缓存。为什么要多删一次就是为了解决2.3里提到的并发窗口。线程A更新库之后删了缓存但线程B可能在A更新库之前已经读了旧值并回填缓存导致A删了个寂寞。如果A在多等几百毫秒后再删一次就能把B回填的旧值清掉。延迟时间的选取有个经验值要大于一次“读数据库回填缓存”的总耗时。具体数值需要压测慢SQL场景下建议500ms起步正常OLTP场景200ms到300ms也够用。注意延迟双删不是万能的。如果第二次删除也失败了问题依旧存在需要配合下面的方案二做兜底。public void updateWithDelayDelete(String key, Object dbValue) { // 1. 更新数据库 updateDatabase(dbValue); // 2. 删除缓存 redisTemplate.delete(key); // 3. 延迟再次删除 executor.schedule(() - redisTemplate.delete(key), 300, TimeUnit.MILLISECONDS); }3.2 方案二删除重试机制与消息队列补偿延迟双删解决的是并发覆盖删除失败则必须靠重试来兜底。最简单粗暴的做法是在业务代码里捕获删除异常循环重试三次。但这里有个不优雅的点重试是同步的会阻塞主线程而且如果Redis实例短暂不可用三次重试大概率全部失败。更稳的做法是把删除缓存的任务投递到MQ由独立的消费者去执行删除删除失败就再次投递或者记录到失败表里做定时扫描。这样做的好处是解耦业务线程不用关心Redis是否可用只要保证MQ消息持久化成功就一定能走到最终的删除动作。我之前在一个订单服务里这么写过更新完订单状态后发送一条包含key的延迟消息消息里带上重试次数。消费者收到后执行删除失败就判断重试次数少于三次则重新投递超过三次则写入告警表并触发人工介入。代码层面的关键点是消息的幂等性。因为消息可能被重复消费删缓存本身是天然幂等的这一点倒不用担心。3.3 方案三Binlog订阅同步Canal方案如果说MQ补偿是应用层面的兜底那Binlog订阅就是架构层面的终极方案。思路是应用层只更新MySQL完全不碰缓存然后通过Canal这类中间件伪装成MySQL的从节点读取Binlog解析出数据变更事件再异步把变更同步到Redis。这个方案的好处非常明显应用层逻辑极简不用关心删除缓存是否成功因为同步动作完全由中间件负责。缓存和数据库的一致性由Binlog的可靠性来背书只要MySQL的Binlog开启并设置成ROW模式删除、更新、插入事件都能完整拿到。天然解耦适合微服务架构和跨团队协作的场景。不过它的代价是需要额外部署Canal或类似的组件对运维能力有一定要求而且Binlog解析后需要写一个自定义的同步逻辑比如把事件转成具体的Redis删除或写入指令。这里有个细节要注意如果同步逻辑是“根据Binlog事件里的最新值直接写缓存”那可能遇到之前提到的并发覆盖问题。所以很多团队在实践时仍然会在消费端执行“删除”操作而不是“写入”操作让读请求自然去回源数据库。这是务实的选择因为删除的幂等性和容错性都更好。3.4 方案四给缓存设置合理过期时间听起来像是不起眼的兜底方案但其实是最重要的一层保护网。不管你用双删、MQ还是Canal只要缓存设置了过期时间就注定最终会收敛到一致状态。这个时间窗口以内可能读到旧值但超时后缓存自动失效读请求强制回源数据库。过期时间不是越大越好也不是越小越好。太大会延长脏数据暴露时间太小会导致缓存命中率下降把压力重新打给数据库。一个参考值是对活跃度高的热点数据设置30分钟到1小时对容易变更的配置类数据设置5到10分钟对强时效数据则直接不缓存或设置极短过期时间。我个人在实践时甚至会把过期时间加上一个随机偏移量防止大量key同时过期造成缓存雪崩。这个属于另一个话题但值得提一句。3.5 方案五读写串行化少数场景专用如果业务场景对一致性要求极高比如账户余额、库存扣减而且并发量并没有大到必须牺牲一致性来换取性能那可以考虑读写串行化的手段。具体做法有很多种比如通过分布式锁把对同一个key的读和写操作串行执行或者利用MySQL的行锁配合缓存更新在同个事务中完成缓存操作放在事务提交后。这类方案的缺点很明显吞吐量会下降复杂度会上升。所以在做技术选型时我一般只建议在“不一致会造成资金损失或严重客诉”的场景使用。普通的商品展示、用户资料更新用不上这个级别的手段。4. 不同业务场景的选型建议方法列了一堆关键还是得落地。我根据自己的实践和观察把常见业务场景与推荐方案做了一个对应这份建议适合大多数中小型团队。业务场景并发特征推荐方案原因商品详情、资讯内容读多写少对短暂不一致容忍度高先更新库再删缓存 过期兜底成本最低实现简单短暂读旧值可接受库存、秒杀高并发写同一行对一致性要求极高读写串行化或用Redis原子操作纯粹的双删和MQ补偿在高并发下仍有窗口用户资料、配置中心写频率低但更新后需要快速生效先更新库再删缓存 Canal异步删除引入Canal后可以做到秒级收敛且不占业务线程订单状态流转状态变迁频繁用户感知强烈先更新库再删缓存 MQ重试补偿既保证吞吐量又通过MQ兜底删除失败的场景报表类非实时数据对时效性要求较低定时刷新或直接不缓存没必要引入复杂逻辑这里有一个通用的教训不要为了“技术上的优雅”而强行上复杂方案。如果业务容忍秒级不一致只要做好“先更新数据库再删除缓存删除失败重试”这三板斧就已经能覆盖绝大多数问题。5. 常见问题与排查技巧实录5.1 问题一缓存删除失败但日志没有告警很多团队在删除缓存时只打了debug日志甚至不处理异常。等到线上出问题时日志早被冲掉了根本定位不了原因。我的习惯是所有缓存删除操作都要打info日志记录key和结果如果删除失败必须打error日志并额外发送一条监控告警。宁可日志多到刷屏也不要在排查问题时无据可查。5.2 问题二延迟双删的延迟时间怎么定这个没有标准答案只能靠压测。常规做法是先测出“读数据库回填缓存”的P99耗时然后用这个值乘以2到3倍作为延迟时间。比如P99是100ms那延迟双删的时间就设置成200ms或300ms。如果发现线上偶尔有不一致可以把延迟时间调大一些但要注意别太大否则第二次删除太晚并发窗口依然存在。5.3 问题三Canal同步延迟很高Canal本身性能很好但如果消费端处理逻辑太慢或者消费端出现积压一致性窗口就会被拉长。排查时先看Canal消费的延迟指标再看消费端的线程池配置。一个常见坑是消费端把多条不同表的Binlog事件串行处理导致整体吞吐下降。我的做法是按表名拆分为多个消费者实例并行消费。5.4 问题四脏缓存被回填且缓存不过期如果某个key的过期时间设置成了永久遇到并发回填脏数据问题就会被无限放大。线上排查时我见过很多次“缓存永不过期”的写法问原因大多是“为了性能”。建议是任何缓存key都设置过期时间哪怕设置成24小时也比永久好。如果实在需要永久缓存也要在业务层面设计主动刷新机制而不是放任不管。5.5 问题五本地缓存和Redis缓存共存时的不一致有些服务用了Caffeine或Guava做本地缓存外面又套了一层Redis。更新数据库后只删了Redis本地缓存没删此时同一台机器上的请求仍可能读到旧值。这个问题的解法是引入一个本地缓存版本号的概念或者用广播机制通知各节点失效本地缓存。如果你用的是Spring Cache可以配置双缓存模式并在更新时同时清除两级缓存。6. 我自己项目的实操记录与体会我在自己的博客项目中用了个对比方案来加深理解。一个接口采用“先更新数据库再删除缓存”的写法另一个接口故意写成“先删缓存再更新数据库”。然后我用JMeter模拟并发读写压了一会儿第二种写法很快就复现出了脏数据第一种则相对稳定。这个对比实验特别适合在团队内部做分享不比干讲理论印象更深刻。我最终在线上采用的结构可以概括为三层第一层是主流程先更新数据库再删除缓存。这一层应对的是90%的场景。第二层是补偿流程删除缓存失败时发送MQ延迟消息由独立消费者负责重试删除。这一层应对的是Redis抖动等异常情况。第三层是兜底流程所有缓存key都有过期时间配置中心类的key设置为5分钟业务类的key设置为30分钟到1小时。这一层保证了哪怕前两层都失效最终也会走向一致。这套方案不是最炫技的但在稳定性和可维护性之间找到了平衡点。最后分享一个细节。删除Redis缓存时我用的指令是DEL而不是先GET再判断然后删除的Lua脚本。因为无论缓存里有没有值DEL都是低成本且幂等的。尤其在高并发场景下减少一次网络往返就是减少一次出错的机会。这个看似微小的设计在压测时能明显拉开吞吐量差距。做缓存一致性这块我一直信奉一个原则不要在业务线程里赌运气异常兜底必须存在监控必须完善。不然等线上刺刀见红的那天慌的一定是自己。
