用红温警告?先别急,这个问题十有八九能救回来。做后端的人,几乎都会被“缓存和数据库更新顺序不一致”这个问题折磨过。明明下单流程没问题,用户却反馈“我支付成功了,订单还是待付款”;明明后台改了个配置,前端页面死活不生效。这类问题在技术交流群里出现频率极高,而且每次排查起来都让人心力交瘁。
先简单交代一下,这个问题本质上是在讨论:当我们同时面对一层高速缓存(比如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的写操作把缓存设成3,A后执行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都是低成本且幂等的。尤其在高并发场景下,减少一次网络往返就是减少一次出错的机会。这个看似微小的设计,在压测时能明显拉开吞吐量差距。
做缓存一致性这块,我一直信奉一个原则:不要在业务线程里赌运气,异常兜底必须存在,监控必须完善。不然等线上刺刀见红的那天,慌的一定是自己。