ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库与缓存双写一致性:原理、方案与工程实践

数据库与缓存双写一致性:原理、方案与工程实践 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缓存RedisDB多级缓存在高并发下确实很香但一致性复杂度呈指数上升。我的建议是分级管理一级缓存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 删除补偿 对账任务四件套基本够用。关于“数据库和缓存双写一致性”这件事我想说的最后一句是不要妄图消灭不一致而是把不一致发生的时间、概率、影响范围都控制在业务可接受的范围内。只要你能保证最终一致能兜底中间的一点风浪指短暂脏读通常不会动摇业务根基。但同时那些看起来概率极低的场景恰恰是你在凌晨三四点被叫起来处理事故的原因。把补偿机制和监控做扎实比任何花里胡哨的“强一致方案”都要实在。
返回列表