1. 先把问题说透:双写一致性到底难在哪
1.1 从一次“缓存穿透”事故说起
我在一家电商平台做后端的时候,遇到过这么一件事。用户下单前要查库存,系统逻辑很简单:先查Redis缓存,缓存没有则查MySQL,然后回填缓存。运营那边某个商品做秒杀活动,流量一冲,先是有用户反映“明明有库存却提示已售罄”,接着又有用户反馈“已经售罄了还能加购”。排查下来发现,问题就出在我们最熟悉的那个流程上:下单接口更新数据库扣减了库存,但当时为了性能把删除缓存这步给漏了,或者删的时候Redis刚好抖动超时了。库存字段在缓存里还是旧值,后面的请求全打在旧缓存上,直接读到了错误的数值。
这其实就是典型的“数据库和缓存双写一致性”问题。很多团队在项目早期根本不拿它当回事,觉得“先更新库,再删缓存”就够了,直到出事故才明白:数据库是数据的唯一权威源(source of truth),缓存只是加速层,两者之间只要不是同一步操作,天然就会存在时间差,这个时间差里任何一个读请求都可能拿到不一致的数据。
双写一致性,简单说就是当数据库里的数据发生变化时,缓存里的数据也必须以某种方式跟着变化,并且最终让用户读到的数据是正确、合理的。它本质上不是“写两次”的先后问题,而是两个存储系统之间如何“对账”的问题。
1.2 一致性问题的本质:两个存储之间的时间差
数据库和缓存是两套独立的存储系统,各自管理各自的数据。你没办法像在单机MySQL里用事务一样,让Redis和MySQL参与同一个ACID事务(也有中间件方案,但成本极高)。所以当一条数据被更新时,旧的缓存值和新写入数据库的值,必然有一段时间是共存的。
这个时间差,就是所有一致性问题的根源。它的长短取决于你的更新策略:
- 如果先更新数据库,再更新缓存,那在“数据库更新完成”到“缓存更新完成”之间,读请求可能读到旧缓存。
- 如果先更新缓存,再更新数据库,那在“缓存更新完成”到“数据库更新完成”之间,缓存里的数据可能比数据库新,一旦数据库更新失败,缓存就成了“脏数据”的源头。
- 如果先删缓存,再更新数据库,那在“缓存删除完成”到“数据库更新完成”之间,读请求会直接打到数据库,虽然不会读到旧值,但可能造成缓存穿透。
- 如果先更新数据库,再删缓存,这是最经典的Cache Aside模式,但在“数据库更新完成”到“缓存删除完成”之间,读请求依然可能读到旧缓存,并且此时并发写多的时候还有更隐蔽的坑。
理解了时间差,你就能明白:任何声称“绝对一致”的方案,在分布式环境下都是耍流氓。我们要做的是把不一致的概率降到可接受的程度,并保证最终一致。
1.3 一致性的强弱:从强一致到最终一致
在讨论方案之前,得先明确你的业务到底需要哪种一致性级别。这决定了你愿意付出多少性能代价。
- 强一致性:任何时刻读到的数据都是最新写入的结果,线性一致。要实现这个,最直接的办法是读写都串行化,比如用分布式锁把所有读写请求锁到一个节点上,或者直接用数据库主从同步的强一致方案。代价就是性能急剧下降,缓存基本没有意义。
- 弱一致性:写入后,读操作可能读不到最新值,但过一段时间能读到。
- 最终一致性:系统保证在没有新更新的情况下,经过一段时间,所有读操作最终都能读到最新值。这是缓存场景下最现实的追求。延迟双删、消息队列异步同步,本质都是在努力缩短“最终”的时间窗口,并让这个窗口内的异常数据尽量少影响用户。
实际业务里,99%的场景追求最终一致性就够了。比如商品详情、用户资料、订单状态,哪怕延迟几百毫秒同步,用户能感知的差异很有限。真正需要强一致的是库存扣减、余额变动这种金钱相关的操作,但这种场景通常建议直接走数据库,别让缓存掺和。
2. 主流的双写方案梳理:没有银弹,只有取舍
2.1 先更新数据库,再删除缓存(Cache Aside)
这是最经典、也是最推荐大多数团队使用的方案。流程很简单:写请求先更新数据库,然后删除对应缓存;读请求先读缓存,没命中就查数据库,然后把数据写回缓存。
为什么是“删除缓存”而不是“更新缓存”?因为缓存的数据结构可能和数据库不一样,比如数据库里存的是用户信息,缓存里可能存的是用户信息的JSON、某个聚合DTO,甚至是一份经过计算的结果。删除缓存让下一次读请求去重建它,能避免“被动更新”带来的复杂计算逻辑和数据不一致。
但这个方案有一个著名的并发坑。假设两个并发请求:A是写请求,B是读请求。执行顺序可能是这样的:
- B读缓存,未命中。
- B查询数据库,得到旧值。
- A更新数据库,把值改成新值。
- A删除缓存。
- B把刚才查到的旧值写回缓存。
于是缓存里永远留下了旧值,而数据库是新值。这个问题有解吗?严格来说,无解。因为B的写回操作和A的删除操作没有一个天然的互斥。有一些优化技巧,比如把缓存的值带上版本号,或者B写回之前校验时间戳,但都只是降低概率,不是消除。
所以Cache Aside方案的适用场景是:对并发读写冲突概率较低、能容忍极小概率不一致的业务。它的优点是简单、可控、性能损耗低;缺点是极端并发下可能出脏数据,且删除缓存失败时需要补偿。
2.2 先删除缓存,再更新数据库
这个方案当时被很多人推崇,理由是“读请求在缓存被删除后,如果去查数据库,一定是查到新值,然后再写回缓存,这样就不会出现旧值回填的问题”。听起来很美,但实际坑更多。
核心问题是:如果在删除缓存之后、更新数据库之前,有一个读请求过来,它会发现缓存没命中,然后去数据库查。此时数据库还是旧值,读请求就把旧值写回缓存。等写请求更新完数据库,缓存里已经是旧值了,而且因为缓存中有值,后续读请求都不会再去查数据库,一致性问题持续存在,直到缓存过期。
有人会说,那我们可以更新完数据库后再删一次缓存,这就变成了延迟双删的雏形。所以单纯“先删缓存再更新数据库”是不可取的,它只是延迟双删的一个中间步骤。
从性能角度讲,先删缓存还会增加一段时间的缓存穿透,如果流量大,数据库压力会瞬间升高。我见过有的团队用了这个方案,一次促销活动直接把主库打挂了。
2.3 延迟双删:一个简单粗暴的补救
延迟双删的策略是:
- 先删除缓存。
- 更新数据库。
- 休眠一小段时间(比如500毫秒)。
- 再次删除缓存。
这个“二次删除”的目的,就是为了干掉在第2步和第4步之间可能被旧值写回缓存的那些数据。休眠时间的选择,通常要大于一次业务读请求查询数据库并回填缓存的最长时间。比如你的数据库查询平均耗时50毫秒,最慢200毫秒,那休眠400毫秒可能就够;如果查询更慢,就得调整。
需要强调的是,延迟双删依然不是百分百一致。因为第二次删除也可能失败,也可能在第二次删除后、又有读请求把旧值写回(不过这种概率已经极低了)。而且休眠等待会阻塞写请求的响应时间,对高并发写场景不大友好。可以把它优化成异步二次删除,用一个延迟队列来执行,而不是同步Sleep阻塞线程。
还有一个常见变体:第二次删除放在消息队列里,消费者延迟执行。这样写请求可以立即返回,但消息队列的投递延迟和消费失败也引入了新的变量。
2.4 消息队列与异步同步:让写入有“日志”
既然直接操作缓存容易出各种并发问题,那我们就换一个思路:把数据库变更这件事当成一个事件,通过消息队列异步地通知缓存更新服务。
具体做法通常是:
- 写业务先更新数据库。
- 发送一条“数据已变更”的消息到MQ。
- 专门的消费者收到消息后,删除对应的缓存(或者重建缓存)。
- 如果消费者失败,重试;重试多次还失败,进入死信队列,人工介入。
这个方案最大的优点是解耦,写请求只用保证数据库更新成功,后面的事情交给异步任务。而且消费者可以批量处理,比如对同一商品短时间内多次变更,只需要删一次缓存,避免了重复删除。
缺点也很明显:多了一套MQ依赖,消息可能乱序,消息可能重复,消息可能延迟。乱序问题尤其要小心,比如对同一个key先后更新为A、B,如果消息投递乱序,先到B的删除,后到A的删除,那缓存的最终状态取决于最后一次删除后哪个读请求回填,有可能回填A,导致和数据库(B)不一致。解决乱序的办法是给每条消息一个版本号或时间戳,消费者只处理最新版本。
实际上,消息队列方案已经接近生产级了,很多大厂就是用这种方式做缓存最终一致的。
3. 实操中的关键细节:把方案落到实处
3.1 缓存过期时间:最后一道防线
不管用哪种方案,请务必给所有缓存设置合理的过期时间(TTL)。为什么?因为所有一致性方案都有失败的概率,而TTL是兜底策略,即使缓存里真的残留了脏数据,最多存活一个TTL周期,之后会自动消失,重新从数据库加载。
TTL怎么设?不同缓存场景差别很大。如果是商品基础信息,可能允许15分钟;如果是库存数量,可能只需要10秒甚至更短;如果是用户会话,可能是30分钟。原则是:数据更新频率越高,业务对新鲜度要求越高,TTL越短。
但TTL也不能太短,否则缓存命中率太低,失去了缓存的意义。一个折中方法是“强制过期+主动刷新”结合:热点数据主动在后台刷新TTL,非热点数据让它自然过期。
我自己的习惯是给缓存的分层加一个“业务过期时间”和“物理过期时间”。业务过期时间是逻辑上的,比如5分钟;物理过期时间是CLIENT端实际写入Redis的TTL,可能设成10分钟。如果业务逻辑读到时发现业务时间过期了,就触发异步刷新;物理时间再长一点,防止异步刷新期间大量请求穿透。
3.2 删除失败怎么办:重试与补偿
缓存删除不是每次都能成功。Redis连接超时、网络抖动、Redis实例重启,都会导致DEL命令没发出去。如果你用的是“先更新数据库,再删缓存”方案,删除失败就意味着后续所有读请求都会命中旧缓存,直到TTL过期。
最笨的办法是捕获异常后立即重试一次、两次。但瞬时抖动往往重试也没用。更好的做法是引入重试机制:
- 在业务代码里,删除失败时发送一条延迟消息(比如放在延迟队列里,5秒后再删)。
- 或者启动一个补偿任务,扫描一段时间内更新过的数据库记录,比对缓存时间戳,发现不一致就删除。
注意,这里说的“补偿任务”不是简单的“扫描所有记录”,那样成本太高。可以结合业务日志或数据库的binlog来做。比如你更新数据库时,在业务表里加一个last_update_time字段,补偿任务只扫最近5分钟更新过的记录,去Redis查对应缓存,如果缓存里的时间戳比last_update_time早,就删掉缓存。
还有一种做法是给缓存加“逻辑标识”,比如在缓存value里存一个version,每次更新数据库时version自增,补偿任务发现缓存中的version小于数据库中的当前version,就删除缓存。这种方案更优雅,但对业务逻辑的侵入性比较强。
3.3 缓存删除了却读到旧值:并发窗口的防护
前面提到Cache Aside的并发问题,这里再说说现实中的防护手段。
第一种是引入“数据版本号”。更新数据库时,把数据行的版本号也一起更新(比如UPDATE table SET value = ?, version = version + 1 WHERE id = ?)。写回缓存时,不只是写入value,还要写入这个version。读请求拿到缓存中的version和数据库当前版本比较(这个比较可能占用一次查询,但是可以做成轻量的),如果缓存版本落后,则拒绝使用并触发一次刷新。缺点是每次读都要额外查一次数据库版本,性能损耗明显。
第二种是“延迟双删”的变种:在读回填缓存时,先粒度过期判断。比如在读请求查询数据库后,回填缓存前,再次读一次数据库,如果还是刚才那个值就回填,否则丢弃。这种“确认后写”的方式能降低概率,但增加了一次查询。
第三种是“分布式锁”或者“游标锁”,在读写同一个key时加锁,让并发操作串行化。这个能解决一致性问题,但锁的粒度和时间要小心控制,否则高并发下就是性能灾难。
实际上,很多团队最终的方案是组合拳:Cache Aside + 短TTL + 删除失败补偿 + 定期对账。个别极端场景允许短暂不一致,只要TTL一到就会自愈。
3.4 本地缓存与分布式缓存的一致性注意点
很多项目会用多级缓存:JVM堆内缓存(Caffeine/Guava Cache)作为一级,Redis作为二级,数据库作为三级。本地缓存的访问速度极快,但问题也最令人头疼——本地缓存在每台机器上各存一份,更新数据库后,你删Redis容易,删所有机器的本地缓存怎么办?
业界常见做法是使用类似Redis的Pub/Sub广播机制,或者消息队列通知各节点清除本地缓存。如果用的是Spring的@Cacheable配合Caffeine,也可以通过事件监听器在所有节点上执行cache.invalidate()。
要注意的是,本地缓存的失效事件是异步的,收到消息到实际清除之间仍有时间窗口。所以本地缓存适合存那些变化不频繁、对时效性不敏感的只读配置,比如应用配置、菜单树、字典数据。如果业务数据本身就经常变,别往JVM缓存里放。
另外,本地缓存一定要跟Redis一样设置TTL,理想情况是本地缓存TTL远短于Redis的TTL,比如Redis 10分钟,本地1分钟,这样即使广播消息丢失,最多1分钟内恢复正常。分布式缓存治理里有一句话:越靠近用户的缓存,安全性越差,越要保守。
4. 搭建一致性监控与验证机制
4.1 如何验证数据一致:对账任务
你有很多方案,但你不知道线上是不是真的不出问题。所以必须搭建对账机制。最常见的做法是异步比对:从数据库抽出变更记录,从缓存读取对应值,两者比对,如果不一致则记录日志,触发告警或自我修复。
具体实现可以分层次:
- 实时对账:在写请求更新数据库后,把变更的key发到MQ,消费者收到消息后延迟5秒读缓存、读数据库,比对值。不一致就删除缓存,并记录一条warning日志。这种方式的优点是能快速发现并发问题,缺点是额外消费资源。
- 定时全量对账:每天凌晨跑任务,扫描所有缓存Key(可以通过Redis的SCAN命令),对每个Key反查数据库,比对值。这个成本很高,一般只针对热点key或者重要业务key做全量,非热点采样对账。
- 抽样对账:按业务维度随机抽取一些key,检查和数据库的一致性,估算整体脏数据率。
对账的核心是“发现不一致”和“修复不一致”两个动作要闭环,光告警不处理,等于白做。我通常习惯在告警里直接带上自动修复逻辑:确认缓存与数据库不一致,就删除缓存并重试一次。
4.2 关键指标:缓存命中率、延迟、脏数据率
没有指标,就没有治理。至少需要监控这几个数据:
- 缓存命中率:过低说明缓存基本没起作用,过高可能要怀疑缓存更新逻辑有问题(比如没有及时失效)。
- 缓存删除失败率:删除操作失败后有没有补偿,补偿成功了多少。
- 缓存与数据库的延迟差值:衡量从数据库更新到缓存最终一致花了多久。可以通过写入时间戳来算。
- 脏数据率:通过对账任务算出的不一致比例,目标最好是无限接近0。
Redis本身的监控指标也要看:内存使用率、命中次数、平均耗时、慢日志。特别是慢日志,如果某些大key的删除操作特别慢,可能影响整个缓存集群的性能。DEL大key是阻塞Redis的,尽量用UNLINK替代。
4.3 Redis缓存治理中的常见问题排查
说几个我实际排查过的问题。
现象:缓存一直没更新,但隔几分钟自己就好了。排查发现TTL设置了30分钟,而数据库更新频率是每分钟一次,也就是数据最多有30分钟延迟。这是TTL设置不合理。
现象:删除缓存成功,但读请求依然拿到旧值。排查发现除了Redis缓存,还有一层本地缓存(Caffeine),或者HTTP客户端缓存的响应头没关闭。有时候你以为清的是Redis,实际数据被CDN或浏览器缓存住了。
现象:删除了缓存,数据库瞬间打崩。原因是热点key在删除后,大量并发请求同时查数据库回填缓存,形成了“缓存击穿”。解决办法是加互斥锁,只让一个线程去数据库查,其他线程等待,这也是一种缓存治理方案。
现象:数据库更新成功,但MQ消费者一直消费失败,缓存一直没删。检查消费者的日志,发现是序列化问题,数据库返回的值无法反序列化。这个问题往往在数据字段变更后发生。
排查这些问题的通用方法论是:从读路径和写路径分别入手,确认数据到底在哪一环被“卡住”了。读路径顺序是浏览器缓存→CDN→本地缓存→Redis→DB,每一环都看一眼;写路径则是DB→MQ/补偿任务→Redis删除,逐个环节测响应时间与日志。
5. 更新数据库与缓存的双写在特殊场景中的演进
5.1 强一致场景下的选择
如果你所在的业务真的需要强一致(比如金融交易、库存临界管理),那最好的答案是:别用缓存,或者只在只读场景用缓存。举个例子,库存扣减这种操作,内部可以加分布式锁,先把锁拿到,再去更新数据库,这才保证并发扣减不出超卖。此时如果还想去更新缓存,就必须在同一把锁的保护下操作,顺序变成:加锁 → 更新数据库 → 删除缓存 → 解锁。这个流程能保证在这把锁保护的范围内,读请求要么读到旧值(锁外),要么在锁内读不到中间状态。
如果连这种锁粒度都不能接受,那就走数据库主库读写,缓存干脆不参与写路径。比如余额查询,可以用缓存存一份最近10秒的余额快照,但下单扣款时直接读主库的最新余额,绝不允许缓存参与判断。
还要提醒一点:事务边界问题。如果在事务里先更新数据库,事务还没提交,你就删了缓存,结果事务回滚了,那缓存被白白删掉,下次读请求还要重新查询数据库。如果事务提交后忘了删缓存,那缓存就是旧值。所以一定要在事务提交后再触发删除,Spring里可以注册TransactionSynchronization的afterCommit回调。
5.2 多级缓存场景(JVM缓存+Redis+DB)
多级缓存在高并发下确实很香,但一致性复杂度呈指数上升。我的建议是分级管理:
- 一级缓存(JVM):只放极稳定的配置,允许最长5分钟的延迟,靠短TTL兜底。
- 二级缓存(Redis):放热点业务数据,通过Cache Aside + 延迟双删 + 对账来保证最终一致。
- 三级缓存(DB持久层):永远以这里为准。
同时要设计好多级缓存之间的“击穿透传”关系。一级缓存失效后,去查二级缓存;二级缓存失效后,去查数据库并回填两级缓存。回填时可以对null值做一个短暂的缓存(比如1分钟),防止缓存穿透。
这个场景最怕的是更新数据时,只删了Redis,没广播删除JVM缓存。解决办法是引入一个本地缓存注册中心,每个节点启动时把自己注册,更新时通过Redis PubSub或MQ广播cache_evict事件。注意,为了保证最终一致,就算广播丢失,JVM缓存也会因短TTL而过期,所以TTL策略在这里尤其重要。
5.3 数据库同步软件与binlog订阅
越来越多团队用Canal(“数据库同步软件”在业界的一个代表)来订阅MySQL的binlog,把数据变更事件解析后同步到Redis,或者推送到MQ供缓存更新。
这种架构最大的好处是“物理解耦”:业务代码完全不用管缓存更新,只要更新数据库就行,binlog会记录所有真正的数据变更,Canal把变更解析成SQL或事件,再触发缓存的更新/删除。这相当于在数据库层面建立了一个“变更日志”,缓存方订阅日志,做起事来极其高效。
需要注意的几个点:
- binlog是数据库主库产生的,从库不一定开;要确保Canal连接的是有完整binlog权限的账号。
- binlog同步有延迟,从数据库提交到Canal消费,通常有几十到几百毫秒延迟。如果业务不能容忍这个延迟,还是要考虑同步写路径的方案。
- 用binlog事件更新缓存时,要处理“删除”事件和“更新”事件。删除事件应该直接删除缓存,更新事件可以更新缓存或删除缓存,建议删除缓存而不是直接更新,因为缓存中的数据结构可能和binlog中的字段不同。
- 幂等性:binlog可能被并发消费多次,更新逻辑需要支持幂等,比如先用时间戳比较,只处理最新事件。
我见过一些团队用这个方案把缓存同步做得很稳,但在上线初期被“重复消费”坑过,消费逻辑没有幂等控制,导致缓存数据反复被覆盖成旧值。解决方案是在事件体里带上变更时间戳,消费时与缓存里的时间戳比较,只接受更新的数据。
6. 踩坑记录与个人经验
6.1 印象最深的几次线上事故
第一次是“更新数据库成功,删除缓存失败,而且没做补偿”。那次早晨Redis集群发生连接数暴涨,部分请求的DEL命令超时。我们没有兜底,缓存中几十个热点商品库存全部是旧值,用户疯狂下单,实际数据库库存已经扣减完了,但缓存显示还有货。最后前端加提示、数据库做二次校验,才把事故平息。后来我强制要求:所有缓存删除必须捕获异常,失败必须发一条延迟MQ,并且单独监控删除失败率。
第二次是“延迟双删的休眠时间设短了”。业务高峰期数据库主库慢查询,单条查询耗时从200ms涨到1秒,但延迟双删的休眠时间还是固定的300ms,结果二次删除执行时,很多读请求还没查完数据库,之后把旧值回填到缓存。那次之后我认识到,休眠时间不是拍脑袋定的,要定时压测统计最大查询耗时,并且要留足冗余。更稳妥的做法是改为异步延迟删除,并记录每条删除命令的“上次更新版本号”,在删除时校验。
第三次是“本地缓存广播丢失”。当时做了一个基于Spring Cache + Redis的分级缓存,更新数据库后通过Redis PubSub发送本地缓存失效事件。结果某个夜晚Redis pubsub连接断裂,事件丢了,导致每台服务器的JVM缓存里还是旧数据,持续了整整10分钟(TTL设的10分钟)。后来把TTL改成1分钟,虽然后台压力大了一点,但至少业务不会被错误数据影响。
6.2 最后的几点建议
根据这些经验,我给正在做缓存设计的朋友几条很务实的建议:
第一,不要在代码里到处直接操作Redis删除逻辑,封装一个统一的CacheService,里面包含删除、重试、补偿、告警逻辑。让业务方只调用一个方法,比如cacheService.delWithGuaranteed(key)。把最好的坏情况处理都藏在内部。
第二,任何更新数据库的地方,都尝试在事务提交后触发缓存失效。不要用AOP扫天下的方式去无脑清缓存,那个容易误杀,热点key尤其要小心。宁可精确删除指定的key,也不要flushall。
第三,缓存一致性方案不是选一个就完事的,要结合业务变化持续治理。比如TTL要不要调、缓存key的粒度要不要细化、要不要加多级缓存,这些都是动态调整的。最好专门维护一份“缓存治理清单”,每个业务接口对应什么缓存策略,写清楚过期时间、一致性方案、补偿方式、对账口径。
第四,不要把一致性问题的损失全推到运维身上。研发阶段就要模拟故障演练,比如故意模拟Redis超时、MQ延迟、删除失败,看系统能不能在几百毫秒内自愈。我见过很多团队做全链路压测时只测性能,从来不测故障场景,结果线上Redis一抖动,全线崩溃。
第五,如果预算允许,优先考虑成熟的缓存治理平台或中间件。比如有的团队已经做了基于binlog的缓存同步组件,你只要配置一个映射规则,就能自动监听表变更并同步缓存。比自己造轮子稳得多。对于中小团队,定义好业务缓存操作规范,用Cache Aside + 短TTL + 删除补偿 + 对账任务,四件套基本够用。
关于“数据库和缓存双写一致性”这件事,我想说的最后一句是:不要妄图消灭不一致,而是把不一致发生的时间、概率、影响范围都控制在业务可接受的范围内。只要你能保证最终一致能兜底,中间的一点风浪(指短暂脏读)通常不会动摇业务根基。但同时,那些看起来概率极低的场景,恰恰是你在凌晨三四点被叫起来处理事故的原因。把补偿机制和监控做扎实,比任何花里胡哨的“强一致方案”都要实在。