ARTICLE DETAIL

资讯详情

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

分布式缓存一致性实战:从双写到最终一致的工程方案

分布式缓存一致性实战:从双写到最终一致的工程方案 做后端这几年只要一提起“分布式缓存一致性”我心里立刻就会浮现出当年被线上脏数据支配的场景。所谓分布式缓存一致性简单来说就是缓存里的数据和数据库里的数据不能各说各话否则用户看到的库存、订单状态、评论数量就会在前后两次刷新之间出现自相矛盾的结果。这篇内容不是我第一次踩坑之后的即兴感叹而是把一套从“缓存与数据库双写”到“最终一致”的完整处理思路摊开来讲适合正在做交易、电商、消息流这类强业务场景的后端同学也适合正在梳理分布式中间件工程化应用的人参考。需要先说明的是标题里“一致性”这三个字在不同人嘴里可能完全是两码事有人要的是强一致有人接受最终一致。我接下来的叙述默认以互联网场景下的最终一致性为主前面会先讲清楚不一致是怎么发生的再聊方案选型、落地代码和线上排查最后落脚到一套偏实用的工程实践上。1. 问题从哪来不一致到底是怎么发生的1.1 一次线上事故复盘我印象最深的一次事故发生在交易系统的订单查询模块。当时业务方反馈部分用户支付成功后刷新订单列表状态一会儿显示“待支付”一会儿显示“已支付”持续了将近二十分钟才稳定。表面看是缓存没刷新但查到最后发现是两拨线程在同一时刻分别写了缓存和删了缓存旧数据又被回填进 Redis导致数据库已经是新值缓存却长期保留旧值。这件事直接让我意识到一个问题缓存不一致不是偶发 bug而是读写模型设计缺陷的必然结果。当应用层对同一个 Key 同时执行写数据库、删缓存、回填缓存三类操作时如果没有约束顺序和原子性那出现中间状态只是时间问题。线上出事故恰恰发生在流量高峰期因为并发越高操作交错的可能性就越大。事故复盘之后我们把缓存层的更新方式从“应用层自由发挥”改成了“统一写路径 删缓存 异步补偿”后续这类跨库不一致的问题才基本绝迹。这套思路也成了我今天聊分布式缓存一致性的主线。1.2 缓存与存储不一致的三大诱因想解决问题得先承认一个前提缓存和数据库是两个独立的存储系统它们之间不可能存在物理上的事务边界。只要一个业务操作分两步落库和写 Redis就天然存在失败、超时、乱序三种风险。第一个诱因是写库与写缓存非原子。更新数据库成功后网络抖动导致缓存更新失败或者反过来删缓存成功但数据库事务回滚。两个系统各自成功了一部分整体结果就悬空了。第二个诱因是并发交错覆盖。线程 A 读到旧值准备回填缓存线程 B 已经把数据库改成新值并删了缓存线程 A 接着说“我看库里没有就写旧值吧”直接把旧值写回去。这个场景最常见也最隐蔽。第三个诱因是删除缓存与读操作竞争。删缓存的动作发生在请求处理中恰好另一个线程刚把旧值读出来准备回填删除动作等于白做旧值又活过来了。这三个诱因单独看都不复杂但组合在一起就会让缓存出现几十秒甚至几分钟的脏数据窗口。Cache Aside 模式里那句“先更新数据库再删除缓存”只是原则不是银弹因为原则背后还藏着一堆执行细节。1.3 先分清你要哪种一致性强一致 vs 最终一致讨论方案之前必须先回答一个问题这个场景真的需要强一致吗如果业务允许短暂时间内的旧数据那没必要把简单系统做成分布式事务的复杂结构如果业务绝对不可接受比如余额扣减、库存超卖那“缓存 数据库 最终一致”的组合根本不该出现在主流程里应该直接把强一致逻辑放到数据库事务中处理。从 CAP 的角度看网络分区发生时可用性和一致性只能二选一。绝大多数互联网产品在缓存层选择的是“分区容忍 最终一致”也就是允许短暂的中间状态但保证在可预期的时间窗口内收敛到一致。你可以把这个窗口理解成脏数据的“保质期”方案设计的目标就是缩短保质期并在保质期结束之前主动修正。想清楚这一点再去看方案就不会盲目追求“缓存永远和数据库一模一样”而是会思考另外两个问题不一致窗口能持续多久窗口结束之后能不能被对账机制发现后面几节的所有方案其实都是围绕这两个问题展开的。2. 方案选型五套常见套路逐个拆解2.1 Cache Aside 延迟双删简单但别用错先更新数据库再删除缓存这是 Cache Aside 的标准动作。问题在于读请求在删除动作发生前后都有可能把旧值回填到缓存。延迟双删就是在第一次删除之后隔一段时间再删一次专门处理“第一次删除后、下一次回填前”被旧值填充的窗口。延迟双删的执行顺序建议放在数据库事务提交之后。第一次删除用于清除之前的旧值延迟第二次删除用于处理并发读线程回填的旧值。这个方案实现成本很低不用引入 MQ不用改表结构只需要一个异步任务或者延迟队列就行。但我要泼一盆冷水延迟双删并不是一致性兜底方案它只是把不一致窗口缩小的手段。延迟时间如果设置不合理第二次删除来得太早旧值在第二次删除之后回填照样前功尽弃设置太长又会让缓存空窗期变长把请求压到数据库。所以这个方案不能单独用必须配一个对账机制否则就是“赌概率”。2.2 分布式锁与串行化双写场景下的保底如果业务允许双写既写数据库又写缓存而不是先写库再删缓存那必须考虑并发覆盖问题。最简单有效的约束是给同一个业务 Key 加一把分布式锁让“读库回填缓存”和“更新数据库后写缓存”这两个操作串行执行。锁的粒度一定得是业务主键不能是整个缓存服务器否则所有 Key 共用一把锁系统吞吐等于报废。我见过有人用 Redisson 的分布式锁包住整个读请求结果缓存放量之后 CPU 打满锁也成了单点瓶颈。正确的姿势是只锁“校验 写入”这一段锁内先查一次缓存命中的话直接放行只有真正需要回填时再去抢锁减少锁竞争。分布式锁不是终极解药但它是双写场景下成本最低的保底方案。它能保证同一时间只有一个线程在刷新某个 Key 的缓存从根上解决“旧值回填”问题。代价是引入额外组件和锁等待耗时所以一般只在写多读少、缓存命中率要求高的场景使用。2.3 对账补偿把不一致从暗处捞出来再完善的实时流程也会有超时、宕机、事务回滚漏掉的情况。对账补偿是一种“事后兜底”的思路系统定期扫描缓存和数据库找出不一致的数据重新生成正确缓存或者回滚错误操作。具体做法一般是在业务表里加版本号或者更新时间字段。对账任务扫描某个时间窗口内的订单或商品数据逐一比较数据库版本和缓存版本不一致的按数据库版本为准回填缓存。扫描频率可以根据数据量和一致性要求来设定常见的是每分钟跑一次增量每半小时跑一次全量校验。对账补偿的优点是能兜住所有实时方案漏掉的不一致缺点是不能解决“对账周期内读到了脏数据”的问题。所以它不是替代方案而是所有一致性系统的最后一道防线。我建议任何用了延迟双删或者分布式锁的团队都顺手把对账做了聊胜于无的基础上还能给线上问题排查提供依据。2.4 本地消息表与事务消息让缓存更新成为变更事件从事件驱动的角度看缓存不一致的根源在于业务操作和缓存更新的耦合。把“更新数据库”和“更新缓存”拆成两件独立的事用消息在中间做桥接是更优雅的解法。本地消息表的思路是在同一个本地事务里把业务数据和待发送消息一起写库事务提交后再异步投递消息到 MQ消费端接到消息后更新缓存。这样做的好处是消息的投递状态也进了数据库事务不会出现“业务成功了但通知丢了”的尴尬。事务消息的方案则更彻底由消息中间件来保证本地事务和消息发送的原子性。这个方案的优点是稳健一致性由消息系统接力应用层不需要自己处理复杂的重试逻辑。缺点是链路变长需要额外维护 MQ 组件而且消费端必须做到幂等否则消息重复投递会导致缓存被反复刷新。实际项目里我会优先考虑事务消息因为它天然支持可靠投递和重试比本地消息表少写很多代码。2.5 从一致性正则化机制看全局治理排在前面几节里的“一致性正则化机制”我认为本质上不是在说某个具体算法而是一种治理思路把容易引发不一致的操作乱序、重复、丢失问题通过幂等键、版本控制、有序执行等标准化手段压平。换句话说不是靠某一个绝招解决所有不一致而是建立一套让不一致无处遁形的规则。实践里可以落地为几条硬约束所有写缓存的请求必须携带业务版本号缓存存储时带上数据时间戳读取时如果发现版本落后就拒绝回填并触发异步加载所有缓存更新接口必须幂等重复消息直接去重所有涉及多源写入的流程必须有一个统一入口禁止业务代码直接操作 Redis。这几条规则合在一起就是一致性正则化的具体表现。分布式事务一致性在这个框架里属于另一种话题它解决的是跨多个数据源的事务边界问题比如一个操作同时写订单库和库存库要么都成功要么都失败。缓存不属于强事务中的参与方所以我会把它放在更外围的位置用消息、对账这些手段来处理不强行拉进 2PC 或者 TCC 里否则成本高得像用大象来搬蚂蚁。2.6 方案对比适用场景、成本和短板方案一致性窗口实现成本风险点推荐场景Cache Aside 延迟双删短但不保证收敛低延迟时间难调读多写少、缓存命中率高的场景分布式锁 串行化极小锁内无覆盖中锁竞争、单点风险双写并发高的场景对账补偿周期性收敛中对账周期内有脏读所有带缓存的项目本地消息表/事务消息取决于消费延迟中高需要消息组件、幂等写操作频繁、链路复杂版本号 幂等规则极小高需统一改造核心业务要求高可靠我个人的选择顺序是先看业务是否允许最终一致允许的话“数据库更新 删缓存 异步延迟删 对账兜底”就是最稳的起步组合如果出现明显并发覆盖问题再引入分布式锁如果写路径特别复杂则升级到事务消息。方案不是越复杂越好而是越贴合场景越好。3. 落地实操以订单缓存一致性为例一步步做3.1 总体架构与模块划分下面用订单服务举例讲一套我实际用过的完整实现。订单模块的缓存 Key 一般是order:info:{orderId}查询接口先查缓存未命中则查数据库并回填。更新接口流程比较复杂涉及支付回调、退款、取消等多个入口但这些入口的共性是都需要修改订单状态。整体架构分四块第一块是业务服务负责更新数据库和发出缓存变更信号第二块是缓存服务只负责对 Redis 做读写删不掺业务逻辑第三块是对账任务周期性扫描版本号不一致的数据第四块是消息队列负责承载异步清理和重试消息。模块划分的关键原则是“缓存操作不要散落在各处”。所有对order:info:{orderId}的写操作都经过缓存服务的同一个方法这个方法内部统一处理版本号校验和延迟删除。否则一旦以后要调整优化策略就得东改一处西改一处还容易漏掉角落里的直连 Redis 代码。3.2 关键代码实现与参数选择订单表增加version字段每次更新订单状态时把 version 加一。这笔更新和业务操作在同一个数据库事务里完成事务提交后被TransactionalEventListener捕获触发缓存清理动作。// 伪代码订单状态更新 Transactional public OrderDTO updateOrderStatus(Long orderId, Integer newStatus) { Order order orderMapper.selectById(orderId); Order newOrder new Order(); newOrder.setId(orderId); newOrder.setStatus(newStatus); newOrder.setVersion(order.getVersion() 1); orderMapper.updateById(newOrder); returnOrderEventPublisher.publish(new OrderUpdatedEvent(orderId, newOrder.getVersion())); return toDTO(newOrder); } // 事务提交后统一走缓存清理 TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onOrderUpdated(OrderUpdatedEvent event) { cacheService.clearOrderCache(event.getOrderId(), event.getVersion()); }缓存服务内部走“延迟双删 版本号校验”的组合逻辑先直接删除 Key再发一条延迟消息延迟时间到达后执行二次删除二次删除时带上版本号只有缓存版本号小于等于数据库版本号时才允许删除否则说明已被更新跳过删除。public void clearOrderCache(Long orderId, Long version) { String cacheKey order:info: orderId; stringRedisTemplate.delete(cacheKey); delayedCacheCleaner.add(cacheKey, version, delayMillis); } public void doDelayedClean(String cacheKey, Long version) { OrderCacheItem item cacheService.getCache(cacheKey); if (item null || item.getVersion() version) { stringRedisTemplate.delete(cacheKey); } }为什么删除前还要比较版本因为延迟期间可能有新请求带着更高版本号把缓存回填了如果无条件二次删除会把更新的缓存也删掉导致下一次查询被迫穿透到数据库。带版本号的延迟删除比普通延迟双删更精细避免“误杀新缓存”。3.3 延迟双删的延迟值怎么算延迟双删的延迟值是最容易拍脑袋的地方但它是整个方案的灵魂。我一般按两个维度评估主从同步延迟和应用层回填缓存耗时。如果订单数据库是一主一从写主库后读从库回填缓存从库同步延迟通常小于 100ms但写入高峰时同步延迟可能触顶到几百毫秒。应用层一次查询加回填缓存的耗时需要压测得出一般 P99 在 50ms 到 200ms 之间。延迟值必须大于这两个数字之和才能保证第二次删除落在旧值回填动作之后。我当时用这种粗算方式设置了一个基准值默认延迟 1 秒。为什么取 1 秒而不是 500ms因为延迟值取大一点不影响缓存正确性只影响缓存空窗期的长短取小了却会导致第二种删除仍然早于旧值回填方案失效。上线后再通过监控调整如果发现数据库压力因为缓存空窗期升高再把延迟值降到 800ms、600ms但绝不低于从库同步延迟的 3 倍。这里有一个细节延迟删除和普通删除一样应该在事务提交之后再执行。如果事务没提交就先删缓存事务回滚后缓存已经被删了下一次查询发现库里还是旧值又把旧值回填回去等于白白增加一次数据库压力整体调度也很混乱。所以我统一用AFTER_COMMIT事务事件来触发清理。3.4 对账任务的设计细节对账任务通常按“增量为主、全量为辅”来设计。增量对账读取最近十分钟内有updated_time变化的订单数据按主键捞出来之后与缓存里的版本号逐一比较不一致就按数据库版本回填。这种扫描数据量小跑得很轻适合每 1 到 2 分钟执行一次。全量对账则放在低峰期比如凌晨两点扫描全表订单找出所有版本号不一致的数据。它用到的 SQL 一般是分批查主键列表不回表查缓存只对可能不一致的 Key 做比对。我这里推荐一种低成本写法在数据库里查版本号同时用管道一次性从 Redis 拉一批 Key 的版本号内存中做比对比对出差异再逐条回填。对账任务本身不需要做到严格实时但要有告警。如果某条订单数据连续多次对账都不一致说明背后有持续的并发写或者消息丢失任务会把这条数据单独标记出来推送到告警平台让人工介入排查。我之前就是靠这个功能发现了某个退款入口没有接入统一的缓存清理逻辑。4. 常见问题与排查技巧实录4.1 线上排查顺序遇到“数据看起来不对”的反馈我的排查顺序一般是很固定的。第一步确认数据库的值是什么这是最可靠的事实基准第二步确认缓存的值是什么最好连版本号一起捞出来看第三步回顾最近对应 Key 有没有被写缓存、删缓存的日志有没有超时和重试记录。很多问题不用看代码就能定位。比如数据库值正常缓存值一直没变那就说明更新入口没有走统一缓存清理逻辑需要去查链路。如果缓存值和数据库值都不一致问题可能出在上游数据源本身和缓存无关。如果缓存值出现过旧、又自动恢复正常基本就是并发覆盖引发的不一致窗口。把这三步走完九十案例都能定位到具体原因。如果还没定位到再上链路追踪看这一段请求实际经过了哪些系统重点看缓存操作和数据库操作的先后顺序以及事务提交的时间点。4.2 常见问题速查表现象可能原因排查方向处理建议缓存一直显示旧值某个写入口漏删缓存检查所有更新订单状态的入口统一收口到缓存服务缓存短暂消失后又出现旧值并发读回填旧值看请求时间线加延迟双删缓存版本号领先数据库回填逻辑异常检查回填代码按数据库版本为准修正缓存被误删请求压到数据库延迟删除未校验版本看删除日志增加版本号比较对账任务一直告警写入口未接对账检查更新入口补全消息链路上面这张表是我总结出来的几个高频场景大多数都可以靠“版本号比对 操作日志”解决。4.3 避坑心得有几条避坑心得想单独聊一聊。第一永远不要相信“先删缓存再更新数据库反正失败了缓存会重新加载”这句话。这句话成立的前提是读请求一定会回填正确数据但并发场景下旧值回填的概率远比你想象的高这招只在低并发内部系统能用。第二不要过度设计一致性方案。如果业务能接受短期脏读那就老老实实用 Cache Aside 加个简单异步删除不要为了“显得专业”把所有 Key 都引入分布式锁和事务消息否则系统复杂度会把人压垮。复杂度每增加一分出问题的面就大一分很多人不是被业务难死的是被过度设计拖死的。第三版本号字段的生成要保证单调递增。订单场景下直接对 version 加一就够了但如果数据可能被多台机器并发更新一定要确保每次 update 都携带大于当前值的 version必要时可以在数据库层做乐观锁校验比如update ... where version ?这样能从根上挡住并发覆盖。第四缓存空窗期对数据库的压力不能忽视。删缓存和二次删缓存之间有空白期如果期间有大量读请求全部打到数据库会出现缓存击穿。我当时给订单详情接口加了“只允许一个线程回填其他线程等待”的本地互斥锁把空窗期的数据库压力控制住了。这也是为什么我一直强调延迟双删不能只看一致性要看整体性能。最后再分享一点个人体会从我踩过的坑来看分布式缓存一致性不是一道能一劳永逸的数学题更像一个需要持续维护的工程约定。没有哪种方案能让你彻底删除“不一致”三个字你能做的是设计好概率控制好窗口再接上兜底和告警。我现在的习惯是每个核心业务缓存都必须有版本号写路径必须有统一入口常规操作必须有对账任务线上监控必须保留缓存操作日志。这套组合下来一致性事故基本绝迹真正遇到类似问题也能在几分钟内定位到具体环节而不是全网排查。如果你正好在搭建新服务的缓存层我建议先从这个最小组合起步别急着上复杂组件。等系统跑通再根据真实的并发数据决定是否引入消息队列和分布式锁那时候每一步选择都有了数据支撑心里会踏实很多。
返回列表