ARTICLE DETAIL

资讯详情

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

数据库与缓存一致性实战:从Cache Aside到消息补偿

数据库与缓存一致性实战:从Cache Aside到消息补偿 数据库和缓存的一致性问题我印象最深的不是哪篇技术文档而是一次真实的线上事故。某个订单服务在促销期间先更新数据库里的库存又去更新缓存里的库存结果缓存写入超时页面显示的库存还停留在更新前的数字。那一刻我意识到数据库与缓存一致性不是一个可以靠背方案解决的理论名词而是每天在线上发生、稍不留神就可能引发资损的硬仗。这篇文章想把我在多次缓存改造和应急排查中积累的方案取舍讲清楚包括什么时候用 Cache Aside、什么时候上延迟双删和消息补偿以及金融类场景里那些不能妥协的底线。适合正在做微服务、缓存架构或者被数据一致性问题困扰的开发者参考。1. 当数据库和缓存开始打架问题其实出在更新路径上1.1 从先更新数据库再更新缓存这个直觉方案说起我在不少团队里看到的第一版缓存代码写起来非常符合直觉读请求先查缓存没命中再查数据库查到后回填缓存写请求先把新值写入数据库再把新值写入缓存。这个流程简单、容易理解上线初期也跑得挺顺但问题都藏在并发和异常里。最典型的一个坑是缓存覆盖。假设有两个请求同时在改同一个 key请求 A 把数据库里的库存从 10 改成 5请求 B 把它从 5 改成 3。数据库层面因为事务和行锁最终结果一定是 3这个没问题。但缓存更新是独立的两个操作可能 A 先写库、B 后写库而 B 的缓存更新却先于 A 执行最终缓存里存的是 5数据库里是 3。读请求一进来看到的就是错误数据。更常见的还有缓存写入失败。数据库更新成功但 Redis 写入超时、连接池耗尽、序列化异常缓存就停留在旧值。此时没有任何机制感知这个问题除非有人主动删掉这个 key否则脏数据会一直持续到 TTL 过期。TTL 如果是几小时用户就会在几小时里持续看到旧数据。所以我一直跟团队强调数据库更新和缓存更新是两个独立操作它们没有事务边界天然无法保证原子性。任何方案只要把写缓存当成业务主链路中的必须步骤就会埋雷。这也是为什么后来大家普遍从更新缓存转向删除缓存——删除一个 key 不存在也不会有副作用而更新缓存则需要考虑值对不对、会不会覆盖。1.2 换成先删缓存再更新数据库之后又出了什么问题既然更新缓存的并发问题太明显很多人会自然想到把顺序反过来先删除缓存再更新数据库。这个方案的逻辑是先把旧缓存清掉让读请求读不到旧值等数据库更新完成后读请求回填的自然就是新值。听起来合理但实际运行时会有一个隐蔽的时间窗口。在缓存删除之后、数据库更新完成之前有读请求进来发现缓存未命中于是去数据库读数据。此时数据库还是旧值读请求就把旧值回填到了缓存。等数据库更新完成旧值已经躺在缓存里而且只要没有下一次写请求它可能一直不会失效。这个场景在并发稍高的系统里几乎必然发生。我见过一个案例配置中心更新公告内容先删缓存再改数据库结果某个读请求正好卡在窗口里把旧公告回填了导致新公告上线半个多小时没生效。排查到最后才发现不是发布系统的问题而是缓存回填旧值。所以到这里可以下一个结论单纯的顺序调换并不能解决一致性问题只是把不一致的窗口从数据库更新后挪到了数据库更新前。如果业务对旧数据完全不敏感那还好但大多数业务不是这样。1.3 先定义清晰我们到底要的是强一致还是最终一致在继续讨论方案之前我想先把一致性这个词掰开。通常讨论数据一致性至少有三个不同层次强一致、最终一致、会话一致。强一致的意思是任何时刻读到的数据都与最新写入保持一致读操作永远不会返回旧值。想要在数据库和缓存之间做到这一点只靠应用层协调几乎不可能必须依赖分布式事务或者让缓存直接参与主链路并支持事务代价非常大。最终一致的意思是允许在一段时间内读到旧值但系统会通过某种机制自动收敛到新值。大部分缓存方案追求的都是这个目标。关键不在于是不是最终一致而在于最终到底多久以及旧值窗口期内会不会造成不可接受的影响。比如商品库存显示多一件少一件和账户余额显示多一百少一百完全不是一个量级的问题。所以后面所有方案本质上都在回答两个问题不一致窗口有多长窗口期之外系统能不能自动修复带着这两个问题去看技术选型很多纠结就能解开。2. 主流缓存更新策略的真实代价别只记住结论要记住为什么2.1 Cache Aside默认选择但边界条件要心里有数业界最常用的方案是 Cache Aside也叫旁路缓存。它的读路径是先查缓存命中就直接返回未命中则查数据库回填缓存后返回。写路径是先更新数据库然后删除缓存。为什么写路径要删除缓存而不是更新缓存我在前面已经提到了并发覆盖的问题。删除操作天然幂等就算同一个 key 被删两次也没有副作用而且删除后下一次读请求会回填最新值这相当于把缓存值对不对的校验推迟到了读取时由数据库来兜底。但 Cache Aside 不是完美的。它的不一致窗口主要来源于两个地方一是数据库更新成功到缓存删除成功之间的短暂时长这期间旧缓存仍可被读到二是缓存删除失败之后如果没有任何重试或补偿旧值会持续存在。业内普遍认为这个方案在大多数业务场景下已经够用前提是必须有监控和补偿手段不能只靠运气。2.2 Read Through、Write Through、Write Behind 各自解决什么问题Cache Aside 是应用层自己控制缓存和数据库的交互。还有一些方案把控制权交给缓存组件本身比如 Read Through、Write Through、Write Behind。Read Through 下应用只和缓存打交道缓存未命中时由缓存组件自己回查数据库。Write Through 下写请求先写缓存缓存组件同步写数据库等两边都成功后才返回给应用。Write Behind 更进一步写请求只写缓存异步批量写数据库性能很高但缓存一旦宕机或进程崩溃数据就可能丢失。这三个方案在真实业务系统里用得比 Cache Aside 少原因很简单大部分团队的缓存定位是加速层不是存储层。数据库才是唯一的权威数据源。当你把写路径交给缓存组件去管理就等于引入了一个中间层来承担一致性责任这要求组件本身足够可靠比如 Apache Ignite、Hazelcast 这类支持持久化的内存数据网格而不是普通的 Redis。Redis 默认不保证持久化和强一致用它做 Write Through 风险很大。2.3 一张表看清几种策略的取舍到这里我用一张表把几种策略放在一起对比方便做决策时快速参考策略读路径写路径不一致窗口主要风险适用场景Cache Aside先查缓存未命中回填更新数据库删除缓存短但删除失败会变长缓存删除失败并发回填旧值大多数互联网业务Read Through缓存组件负责回填通过缓存写数据库取决于组件实现组件复杂度高运维成本大使用成熟缓存库的场景Write Through缓存读取缓存同步写库较低写放大、延迟上升对一致性有要求的非核心系统Write Behind缓存读取缓存异步写库较长缓存宕机可能丢数据日志、计数、统计类数据2.4 没有完美方案只有代价可接受的方案我一直强调一个观点数据库与缓存之间的一致性不是一个可以被某个方案一键消灭的问题而是一个需要在一致性、性能、可用性、复杂度之间反复权衡的问题。想要更强的一致最简单的思路是让缓存直接失效并强制读数据库但这样缓存命中率下降数据库压力上升。想要更高的性能可以让缓存长期有效但数据不一致的时间会变长。如果想要两者兼顾就需要引入消息队列、版本号、Binlog 订阅等额外组件复杂度随之上升。所以选型的底层逻辑不是哪个方案最好而是你的业务能容忍多大的不一致窗口能接受多大的复杂度。对大多数展示类数据Cache Aside 加 TTL 足够对需要快速收敛的数据再逐步升级到延迟双删、消息补偿或 Binlog 订阅。3. 延迟双删、消息补偿、Binlog订阅线上方案背后的统一逻辑3.1 延迟双删用第二次删除去抵消并发回填的旧值Cache Aside 最大的隐患在于缓存删除后到数据库更新完成前读请求可能回填旧值。延迟双删就是针对这个窗口做的补丁第一次删除缓存更新数据库sleep 一小段时间后再删除一次缓存。伪代码大概是这样的def update(key, db_value): redis.delete(key) # 第一次删除让旧缓存立即失效 update_db(db_value) # 更新数据库 time.sleep(delay) # 等待旧值回填的窗口过去 redis.delete(key) # 第二次删除清掉可能被回填的旧值这里的 delay 是关键参数它需要大于读请求从缓存未命中到数据库读完并回填缓存的耗时。这个耗时在不同网络条件和数据库压力下波动很大所以延迟双删并不精确只能靠经验值或压测结果来设置。如果设得太短第二次删除发生时旧值回填还没完成等于白删设得太长写请求的响应时间会明显变差因为 sleep 的是同步线程。更常见的问题是第二次删除本身可能失败。延迟双删并不能保证删除成功只是把两个失败点变成了三个。每次提到这个方案我都会额外强调必须搭配定时任务或消息队列做补偿否则线上很容易出现偶尔不一致。3.2 消息补偿把缓存删除变成可重试的异步任务不管是 Cache Aside 还是延迟双删缓存删除失败都很难被发现。消息补偿解决的就是这个问题。具体做法不复杂在更新数据库的业务事务里同时向本地消息表插入一条待删除缓存 key的记录业务事务提交后后台任务扫描这张表把删除事件投递到消息队列消费端收到消息后执行 Redis 删除删除失败就重试重试到一定次数后告警由人工介入。这里有一个容易被忽略的点为什么要用本地消息表而不是直接发消息因为如果先发消息再提交数据库事务消息发出去了但事务回滚消费端就会删掉一个本不该被删的缓存 key虽然大多数情况下多删一次无害但会带来无谓的缓存穿透。如果用数据库事务保证业务更新和消息表写入同时成功消息投递环节即使失败也能从消息表恢复这就把缓存删除从一次不可靠的即时操作变成了一条有状态、可追踪的任务链路。3.3 版本号方案从靠时间升级到靠逻辑来判断新旧延迟双删的另一个替代方向是给缓存数据加版本号或时间戳让回填动作具有判断能力。比如数据库表里维护一个 version 字段每次更新 1写缓存时把 version 一起写入。回填缓存前先读取当前缓存中的 version只有待写入的数据版本高于现有版本时才覆盖。在 Redis 里可以用 Lua 脚本做这个原子判断避免并发下读和写之间插入其他操作local cur tonumber(redis.call(GET, KEYS[1])) local new tonumber(ARGV[1]) if cur and cur new then return 0 end redis.call(SET, KEYS[1], ARGV[2]) return 1版本号方案的好处是不再依赖睡眠时间来规避回填窗口而是用逻辑判断直接拒绝旧值写入。但它的前提是版本号本身生成正确而且读请求查数据库时必须拿到当前版本。如果数据库查询走了从库而从库落后于主库那么回填时拿到的版本也可能是旧的需要在具体实现中特别注意。对于金融和交易类数据版本号往往还要和数据库的乐观锁配合使用。3.4 Binlog订阅让缓存变更脱离业务代码行文到这里我猜很多读者已经感觉到所有方案都试图在一个不稳定的链路上做可靠操作。那有没有办法绕开业务代码直接从数据库层面感知变化有就是 Binlog 订阅。常见的实现是部署 Canal 这样的组件伪装成 MySQL 从库拉取 Binlog解析出增删改事件投递到消息队列最后由消费者删除或更新对应缓存。这样做的好处非常明显业务代码不需要改动任何写路径也不存在忘了删缓存或缓存更新失败导致业务报错的问题。同时因为 Binlog 是数据库的物理日志只要主库提交成功事件就一定会产生。但它也有代价。首先引入 Canal 意味着多了一套需要运维的中间件小项目会显得很重。其次Binlog 事件是异步流从数据库提交到缓存删除生效之间有延迟。更麻烦的是Binlog 到达消费者时可能是乱序的同一个 key 的更新事件必须先于删除事件被处理否则可能把新值误删。所以实际项目中通常要结合版本号或者时间戳来做乱序控制复杂度并不低。3.5 这些方案到底在解决什么缩短窗口、保证可靠、纠正错误把延迟双删、消息补偿、版本号、Binlog 订阅放在一起看会发现它们的本质目标不外乎三个缩短不一致窗口、把删除缓存变成可靠动作、在旧值误回填后提供再次纠正的机制。这给我的启发是不要神话任何一种方案。它们只是从不同角度去逼近同一个目标选择哪一个取决于你的团队能接受多少额外组件、业务能容忍多少不一致时间。对我而言最简单的组合是Cache Aside 打底消息队列做删除补偿再给核心 key 加短 TTL 作为最终兜底。这套组合已经能覆盖绝大多数场景。4. 金融级场景一致性不是技术指标而是资金安全的底线4.1 金融场景为什么不能照搬通用方案标题里特意提到金融类特殊场景我是很认同的。因为在金融系统里数据库与缓存一致性问题不只是技术指标而是直接和资金安全、监管要求、客户信任挂钩。假设一个用户查询账户余额缓存中显示的是 1000而数据库实际已经是 500。这个差异在电商库存场景里可能只是一次体验问题但在金融场景里会带来真实投诉甚至让用户误以为资金被多扣。如果后续下单、支付、转账的校验逻辑也依赖缓存里的余额那就可能让一笔不该成功的交易被执行风险等级完全不同。所以金融系统对一致性的基本要求不是尽量一致而是关键数据必须强一致非关键数据也要可追查、可对账。很多互联网通用的缓存方案比如用 Redis 缓存热点账户余额在金融系统里是不允许直接使用的除非有非常完整的补偿和审计机制。4.2 一个可复用的金融场景落地架构我在金融类项目里倾向于这样分层第一层是账务核心数据。余额、冻结金额、可用额度、交易流水这些数据只以数据库为准不做 Redis 缓存。为了提升读性能优先考虑数据库读写分离、分库分表、汇总表构建而不是引入缓存副本。如果读压力实在大可以在只读从库上做查询因为从库和主库之间是数据库原生同步一致性窗口远小于应用层缓存。第二层是展示类数据。用户昵称、头像、理财产品名称、风险等级提示语、活动 banner这些数据可以使用 Cache Aside 加短 TTL更新时通过消息队列删除缓存。即使删除失败TTL 过期后也会自动恢复不会造成资金损失。第三层是关键链路上的辅助判断。比如判断用户是否具备购买资格、活动是否可参与这类数据可以用缓存做前置检查但真正的扣减和资格判定必须回到数据库事务里用乐观锁或行锁做最终裁决。缓存最多起到快速过滤的作用不能作为最终依据。4.3 金融场景里的最终一致怎么验收和保障在金融项目里光有技术方案不够还要有一整套验收机制。我自己踩过不少坑后总结出三件必做的事。第一件是对账。对账不能只看缓存和数据库当前值还要结合数据变更时间。每日或者准实时跑批抽查一定比例的缓存 key发现不一致后自动删除对应缓存并记录差异原因。对账结果要留痕方便审计。第二件是降级演练。金融系统最担心的不是缓存失效而是缓存故障后所有请求直接打到数据库把数据库拖垮。所以每个用了缓存的查询都必须有降级开关。我会定期在测试环境模拟 Redis 集群不可用验证请求是否自动切换到数据库以及数据库是否能承受峰值流量。这个演练一定要做不能只写在文档里。第三件是监控告警。缓存删除失败率、消息队列积压量、TTL 命中率、版本不一致数量这些指标比缓存命中率更能反映一致性问题。我习惯把缓存删除成功但耗时超过阈值也单独告警因为延迟过高往往意味着网络抖动或 Redis 负载异常有可能会演变成删除失败。4.4 一个反直觉但很重要的观点很多金融数据根本不配进缓存这句话听起来有点绝对但我确实想提醒所有做金融系统的朋友在引入缓存之前先认真算一笔账。很多场景的性能瓶颈可以通过数据库索引优化、分库分表、读写分离、汇总数据来解决。缓存只是把读压力转移到了另一层换来的是短时高性能代价却是数据一致性、缓存命中率维护、缓存穿透、雪崩等一系列问题。特别是账户余额、额度这类数据如果因为性能问题而缓存等于把最核心的资金正确性暴露在一个非权威副本上。一旦缓存与数据库出现偏差你后面花费在补偿、对账、客服投诉上的成本会远远超过那点性能收益。所以我现在的原则是金融核心链路里凡是需要强一致的数据一律不进缓存只有那些丢掉后不会产生资金争议的数据才允许进入 Redis并且必须配上降级和 TTL 兜底。5. 权衡的本质给业务一个可量化的不一致容忍度5.1 选型之前先回答三个问题每次有人问我数据库和缓存一致性到底该选哪个方案我不会直接给答案而是先反问三个问题。第一个问题数据不一致的窗口最长能接受多少秒商品详情页可能可以接受 10 秒库存展示可能只能接受 1 秒账户余额可能一秒都不能错。这个数字决定了你能不能只靠 TTL还是必须上消息补偿。第二个问题不一致发生后的业务影响是什么是影响一次点击体验还是造成用户投诉还是直接引发资金损失影响越严重方案越要偏重强一致。第三个问题不一致发生后系统能不能自动恢复有些场景可以靠过期时间自愈有些场景必须靠对账任务发现有些场景则需要人工介入。想清楚这三件事方案其实已经呼之欲出。5.2 一个可参考的决策矩阵结合我自己的经验我整理了一张粗略的决策矩阵方便大家对照选择数据类型典型例子推荐方案一致性容忍度页面展示型商品详情、公告、活动配置Cache Aside 短 TTL 删除缓存秒级可容忍交易校验型库存、优惠券状态、资格数据库主判定 MQ 删除缓存 乐观锁毫秒级尽量避免覆盖账户资金型余额、冻结金额、额度不缓存或仅只读副本 对账审计强一致不能靠缓存做判定审计追溯型交易流水、订单状态数据库本地事务 消息表 事件重放强一致 可追溯这张表只是一个起点真正的判断标准还是回到业务场景本身。比如同样是库存如果页面显示的是虚拟库存而真实扣减在数据库那么展示缓存可以相对宽松如果是可下单剩余量那缓存就必须和数据库扣减结果强绑定否则容易超卖。5.3 一个容易踩的误区不要一上来就上分布式事务中间件在一致性讨论里我经常看到有人把解决方案引向分布式事务中间件比如使用 Seata、TCC 这类框架去协调数据库和缓存的一致性。我的态度很明确这通常是把问题复杂化了。缓存不是一个支持 ACID 事务的资源。你需要的不是数据库和缓存在同一个事务里提交而是数据库更新成功之后缓存能被可靠地删除或更新。这是一个异步任务可靠性问题不是分布式事务问题。用分布式事务中间件去约束 Redis 和 MySQL会带来额外的锁开销、性能损耗和运维复杂度收益却很有限。正确做法是让数据库事务只负责业务数据的正确性缓存通过可靠的消息链路异步收敛。如果实在需要强一致那不如干脆把缓存去掉只在数据库层面做优化。这比强行让缓存变事务性要务实得多。5.4 日常治理里的几个隐藏坑方案选对之后日常维护里还有很多细节会影响一致性表现我顺手列出几个踩过的坑。第一个坑是缓存 TTL 设置过于集中。所有 key 都设成 10 分钟过期会导致缓存雪崩大量请求同时穿透到数据库。正确的做法是把 TTL 分散比如 5 到 15 分钟随机。第二个坑是热 key 的缓存回填。某个 key 被大量并发请求同时发现未命中所有人都回填数据库数据库压力瞬间飙升。处理方式是在回填时加互斥锁或者使用本地缓存做一层挡板。第三个坑是删除缓存时忽略了序列化规则。有些团队的缓存 key 带有统一前缀有些实体使用 JSON 序列化如果删除时 key 拼写规则不一致可能误删别的服务的数据。建议所有涉及缓存 key 的变更都在同一个工具类里维护避免各处手动拼接。第四个坑是对账任务设计得不严谨。对账如果只对比最终值会漏掉曾经不一致但已经恢复的问题。我在金融项目里会让对账任务额外记录不一致发生的时间段和持续长度这样不仅能看到现状还能评估线上是否出现过短暂的脏数据对审计非常有帮助。最后分享一点经验文章写到这里我其实没有给出一套放之四海皆准的方案因为数据库与缓存一致性本来就没有标准答案。它在不同业务里是不同的问题在商品详情页是 TTL 和 Cache Aside 就能解决的体验问题在交易链路里是版本号和消息补偿要解决的正确性问题在金融场景里则是不允许缓存介入的资金安全问题。我在实际项目中养成了一个习惯每次上线缓存相关功能都会在测试环境模拟两类故障——一是数据库更新成功但缓存删除失败二是缓存删除成功但旧值被并发回填。只有系统在两种故障下都能自动恢复我才会放心地把代码发布到线上。第二个习惯是把所有缓存 key 按业务线梳理成清单标注允许的不一致时间、是否有补偿机制、是否需要在故障时降级。这份清单会在每次架构评审时更新比任何技术方案文档都有用。如果你正被这个问题困扰我的建议很简单先别急着选框架和中间件先把你手上的数据分成强一致、最终一致、可容忍秒级延迟三类然后逐类去设计。缓存是给数据库减负的加速层不是第二个数据库。这句话我写给自己也送给每一个在数据库与缓存一致性里挣扎过的人。
返回列表