ARTICLE DETAIL

资讯详情

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

缓存与数据库一致性实战:从Cache Aside到延迟双删

缓存与数据库一致性实战:从Cache Aside到延迟双删 你翻过线上日志没我翻过。凌晨两点用户明明支付成功了状态却一直显示“未支付”后台查订单数据库里状态明明是“已支付”但用户页面读到的还是旧值。最后定位出来不是支付接口的问题是缓存和数据库没对齐。缓存、数据库、一致性这三个词一旦凑到一起就意味着一定会有某天凌晨电话叫你起来看数据。这几年来我在不同项目里反复处理这个问题从单体Tomcat到分布式集群都遇到过。这篇博文就把我踩过的坑、用过的方案、反复验证过的手段一次讲清楚适合正在做后端开发、系统设计或者被线上数据不一致问题折磨的朋友参考。1. 一致性问题的根因两条写路径没有原子绑定1.1 缓存是什么从边缘到核心的“速度差填补”先说个基础概念。缓存不是一个高深技术它就是在“速度差异”的两端之间插一层中间存储。你电脑上装系统字体文件、图标文件会被系统缓存你浏览器访问网站图片和脚本会被浏览器缓存你往移动硬盘拷大文件操作系统会先往内存里写缓存再异步落盘——这就是“移动硬盘要不要开启写入缓存”那个选项的本质。浏览器缓存位置能设置到D盘、系统缓存可以清理重置这些日常操作都在说明一件事缓存用来加速但缓存的本质是“一份可能滞后于源数据的副本”。数据库和缓存的关系也是这样。数据库磁盘IO再快也比不上内存操作关系型数据库再能扛也扛不住秒级百万次读请求。于是大家都是在数据库前面挡一层Redis或者本地缓存把高频读请求截下来让数据库喘口气。但问题恰恰出在“副本”两个字上。副本在创建之后源数据只要发生变化副本就面临过期风险。我们平常说缓存数据库一致性第一层意思其实是如何让这份副本尽量跟随源数据变化不要给用户读到过期甚至错误的数据。1.2 数据库的“可靠”与缓存的“快”并不天然兼容数据库设计目标是可靠和持久。一次写操作要落日志、刷脏页、保证ACID缓存设计目标是快读写内存支持过期淘汰。两者在设计哲学上就不一样。你写一个订单状态数据库层面有事务保护要么全成功要么全回滚。但如果你同时要更新数据库和缓存你去哪里找这个事务Redis没有和MySQL共享事务的能力消息队列也没有和数据库共享事务的接口。两个系统之间的写入天然是两步操作。这里有一个最容易被忽视的失败路径数据库写成功了缓存删除或更新失败了。比如你更新了订单状态然后去删Redis键Redis刚好主从切换、网络抖动删除操作超时了。此时数据库里是新数据缓存里是旧数据一致性就破了。又或者反过来缓存更新成功了但数据库事务回滚了缓存里就是一条不该存在的脏数据。我的观点是缓存和数据库的一致性问题本质上是两条写路径的原子性缺失。你说要保证强一致就必须让两步操作变成一个原子操作这在分布式系统里几乎不可能零成本做到你说接受最终一致就要设计好“谁先谁后、失败怎么补、怎么对账”的机制。1.3 一致性问题的三种形态先认识敌人再谈解决方案。根据我的经验线上遇到的一致性bug基本就三种形态第一种读到旧值。数据库已经更新缓存还是老数据。典型场景是删除缓存失败、或者压根没删。第二种读到新值又闪回旧值。用户第一次请求读到新数据刷新一次又变回旧数据。典型场景是并发请求下旧数据被某个线程重新写回缓存覆盖了新值。第三种数据永久错乱。缓存里写入了数据库永远没有的数据比如事务回滚但缓存更新成功或者update操作序列顺序颠倒。这三种形态严重程度不同处理代价也不同。有些业务可以容忍几秒的旧值但绝对不能容忍闪回有些业务连几秒旧值都容忍不了。所以动手之前先搞清楚你面对的是哪种形态、业务能不能接受这是后面所有方案选择的前提。2. 一致性到底要什么级别先定义需求再谈方案2.1 强一致、最终一致与应用边界很多人一上来就喊“我要强一致”但实际业务根本不需要。强一致的意思是任何时刻任何节点读到同一份数据都是最新的。在单机数据库内部靠事务和锁可以达到在分布式系统里要实现强一致基本就是让所有读写都走同一个协调节点牺牲可用性或者吞吐量。你做个登录状态的缓存用户改个头像如果严格要求全世界立马看到新头像这个成本你根本撑不住。最终一致的意思是允许短暂的不一致但保证在某个时间窗口内所有副本最终都会收敛到最新值。绝大多数缓存场景用的都是这个级别。关键是这个时间窗口够不够短短到用户感知不到或者感知到了也不认为出错。你可以拿两个业务对比感受一下。文章阅读量显示在页面上你刷新两次看到两次不同的数字用户根本不会在意甚至没人知道哪个是老值但你在电商页面看到“库存还剩5件”点击购买却提示“库存不足”用户就会觉得系统有bug。同样是缓存不一致前者是最终一致就够后者必须尽最大努力做到强一致。2.2 读己之写与单调读两个常用的“低成本强一致”不是所有强一致需求都要上分布式事务。很多场景只是要求“我自己改完的数据我自己马上能看到”。这叫“读己之写”。做法很简单用户在同一个会话里强制读数据库或者请求带一个版本号命中正在进行写操作的数据时直接穿透缓存。还有一种是单调读要求用户第一次读到某个版本后后续读到的版本不能比之前更旧。这个要求比“读己之写”稍微放宽一点。实现方式是缓存里存版本号每次读请求都带上次版本号如果缓存里的版本号比客户端上次读到的还小就回源数据库。这两个手段我实际用过能解决相当一部分“感觉像强一致”的需求而且几乎零成本不需要引入额外组件。你可以把它理解成“业务级别的版本校验”数据库更新时发一个自增版本号缓存放数据也放版本号读的时候发现版本倒退了就穿透到底层重新拉一次。2.3 选型判断什么时候才值得引入分布式事务一致性分布式事务一致性是另一个重灾区。有人一看缓存库不一致就建议上两阶段提交、分布式事务框架结果性能和复杂度双双爆炸。我这些年做技术选型有一个判断清单你可以直接拿过去用第一这个数据更新频率高不高如果一天改不了几次比如用户昵称、商品标题你根本不用分布式事务给缓存设一个较短的过期时间过期后自然回源最终一致就够了。第二不一致的窗口影响面多大影响的是单个用户自己看到旧头像还是全局用户看到错误价格影响面小放宽一致性要求影响面大考虑强一致手段甚至直接不下缓存。第三能容忍放弃可用性吗两阶段提交在协调者故障时会阻塞你要是核心交易链路可能一个节点抖动整个下单流程就卡住了。对高可用要求极高的系统宁可短暂不一致也不能让主链路不可用。如果这三个问题想清楚了你会发现大部分缓存系统其实是“本地缓存分布式缓存数据库”三层而真正需要分布式强一致的业务少之又少。3. 主流落地Cache Aside 与延迟双删3.1 Cache Aside为什么它是默认选择业界用了几十年的Cache Aside模式至今仍是默认方案。它的核心规则就两条读先读缓存缓存没有就查数据库再写缓存并返回写先更新数据库然后删除缓存或者更新缓存。这套模式代码看起来简单但为什么大家都选它而不是别的模式因为它把“数据库”当成唯一事实来源缓存永远可以随时丢弃重建。数据库是持久化的锚点缓存是易失副本这个分工非常清晰。业务开发时你不用担心缓存数据被写错因为最坏情况就是缓存没数据回源拉一次。对比下其它模式你就懂了。Write Through模式要求写操作同时更新数据库和缓存每次写都必须两边都成功失败处理复杂Write Behind模式先把写操作记在缓冲里异步批量刷库吞吐高但一旦宕机丢数据。对于绝大多数业务系统Cache Aside的语义最简单、最容易维护、也最容易在出问题时人工纠正。3.2 为什么“先删缓存再更新库”也有坑Cache Aside在写路径上有个经典顺序问题更新数据库之后删缓存并发期间可能会出现“旧值写回”的脏数据。举例。缓存里存商品价格为100元。用户A发起改价把数据库改成80元在A还没来得及删缓存时用户B发起读请求缓存还没删B直接读到缓存里的100元——这是旧值。等A删完缓存B的请求又因为缓存缺失重新查了数据库查到80元并写回缓存——这时缓存是新值没毛病。但换一种时序就全反了。A把数据库改成80元之后删除缓存在A删除之后、数据库更新之前B读到缓存缺失查数据库查到旧值100元写回缓存。然后A才把数据库改成80元。最终数据库是80元缓存里却是100元而且缓存没有过期时间的话这个错误数据会一直存在。这就是“并发下旧值写回”的经典案例。所以现在主流做法其实是“先更新数据库再删除缓存”同时配合较短的缓存过期时间作为兜底。这个方案的保证级别是删除失败或延迟删除时只是短暂旧值但不会出现永久错乱只要你保证“删除缓存”的动作最终会执行数据就会收敛。3.3 延迟双删实操延迟时间、失败重试、幂等保障“先更新库再删缓存”也不能完全规避上面那个时序问题于是诞生了延迟双删更新数据库之后立刻删除一次缓存稍等一段时间再次删除缓存。第二次删除是为了解决“在第一次删除后、第二次删除前某个读线程把旧值写回缓存”的情况。你可能会问延迟多久合适我的经验是延迟时间要大于“读请求从缓存缺失到查库再到写回缓存”的最长耗时。一般业务读耗时在10到100毫秒之间保守一点设置500毫秒到1秒能覆盖绝大多数场景。延迟双删也不是银弹。它最大的短板是第二次删除如果也失败了怎么办我的做法是加一个删除重试机制——删除缓存前先把“本次缓存键和删除动作”写入一个本地重试表或者消息队列删除失败后由后台任务重试。另一个兜底是给所有缓存键设置合理过期时间比如30到60分钟就算重试全部失败最坏情况也就是短时间读到旧值过期后自动回源收敛。这里我放一段我在生产环境里压过线的伪代码可以直接参考// 更新数据库 updateOrderStatus(orderId, PAID); // 第一次删除缓存 delCache(order: orderId); // 延迟第二次删除 asyncDelay(500ms); delCache(order: orderId);再用一张表带你看懂各种模式的区别和选型场景方案核心动作失败风险适用场景先删缓存再更新库删缓存 → 写库并发旧值写回概率高基本不推荐单独使用先更新库再删缓存写库 → 删缓存删除失败时短暂旧值大多数业务推荐延迟双删写库 → 删缓存 → 延时再删第二次删除可能失败并发读量大、容忍度低的场景版本号/时间戳校验写库带版本 → 写回缓存校检需业务配合改造对数据新旧敏感的场景4. Redis缓存治理与多级缓存的实际战斗4.1 Redis缓存治理过期、淘汰与失效代价Redis本身只是一把武器真正决定系统能不能扛住的是缓存治理策略。线上缓存最常见的毛病不是“没有缓存”而是“缓存键设计混乱、过期时间随意、热点数据没治理”。关于过期时间我的建议是能短则短不要因为性能压力把TTL设置成永远。TTL是最终一致的兜底机制没了这个兜底一旦任何删除动作失败你就只能靠人工清缓存才能恢复。我之前接手过一套系统把商品详情缓存TTL设为24小时结果运营改价格后用户看了大半天旧价格就是因为删除动作失败又没TTL兜底。后来把TTL压到10分钟再配合主动删除出问题最多也就治10分钟。淘汰策略也要想清楚。Redis默认的volatile-lru只在设置了过期时间的键里做淘汰如果你有些键没有设置过期时间内存满了之后反而会被LRU无差别淘汰导致大量缓存缺失回源数据库。要梳理清楚哪些键该设TTL、哪些键允许常驻按业务重要程度分级。4.2 缓存穿透、击穿、雪崩三个容易混淆的“失效问题”缓存失效这件事线上最常见的是三个词穿透、击穿、雪崩。很多人混着说但它们其实是三个完全不同的故障场景。缓存穿透是请求的数据连数据库都不存在。攻击者拿一堆不存在的ID来刷接口缓存永远没有请求全部打到数据库DB直接被打挂。解法是布隆过滤器或者把空结果也缓存起来缓存空值的TTL设短一点比如30秒。缓存击穿是某个热点Key过期瞬间大量并发请求同时回源数据库。典型场景是秒杀商品详情页缓存正好失效成千上万的请求一起打进来。解法是互斥锁只在缓存重建时允许一个线程回源其他线程自旋等待或者用逻辑过期策略——缓存里存一个过期标志检测到快过期时异步重建不回源阻塞。缓存雪崩是大面积Key在同一时间失效或者Redis本身宕机导致大量请求直接压到数据库。解法是过期时间加随机偏移避免同一时刻集体失效同时做好降级方案比如在Redis故障时让服务降级为本地短缓存加数据库限流。这三个里面击穿和一致性关系最密切。因为一旦发生击穿大量线程同时回源就可能出现“多个线程查到不同版本”的竞争最终写回缓存的是旧数据。4.3 MyBatis缓存、Spring三级缓存与全局缓存的分工多级缓存是另一个常见的混乱来源。MyBatis缓存、Spring三级缓存、Redis缓存它们解决的问题维度完全不一样很多人混在一起理解——这并不对。Spring三级缓存本质是解决单机Spring框架内部Bean创建时的循环依赖问题它只在IoC容器初始化阶段起作用跟线上用户请求的一致性没有关系更不会影响数据库和Redis的一致性。你可以把它当成一个“解决对象创建顺序”的机制别被这个名字里的“三级缓存”带偏。MyBatis的一级缓存作用在同一个SqlSession里同一个会话内多次查询同一SQL且数据没变化时直接命中缓存看起来没问题。但一级缓存的生命周期和数据库事务并不完全一致一旦你手动控制SqlSession不当就会出现“数据库里改完了但同一个SqlSession里读到的还是旧值”的现象。我的建议很直接在分布式和高并发场景下默认把MyBatis一级缓存关闭二级缓存也要谨慎使用。让ORM层只负责把SQL翻译成结果不做跨节点的数据缓存一致性责任集中交给Redis和数据库之间去管理反而更清晰。如果你在架构里同时用了本地缓存和Redis一定要想清楚它们各自的一致性要求本地缓存更适合放变化频率极低、允许分钟级延迟的数据高频变动的数据最好还是直接走Redis或者加消息通知主动失效本地缓存。5. 分布式场景与同步方案从双写到变更订阅5.1 为什么双写不可靠重复、乱序与失败补偿有些人会把一致性方案做成业务代码里“同步双写”也就是数据库写完再同步写Redis。这个方案看着简单实际最坑。第一数据库写成功了Redis写超时怎么办你可能需要回滚数据库但回滚动作没法保证数据库事务已提交你只能在逻辑上再发一条“补偿更新”可补偿更新也可能失败。第二并发写同一Key时网络延迟可能导致后发的Redis写请求先到最后Redis里保存的反而是一份旧数据。第三业务代码里到处散落着双写逻辑维护成本极高哪次漏写了一个地方就是新的一致性隐患。我不建议业务代码做双写除非你的团队有严格的规范和充分的对账机制。更可靠的路径是把“数据库变更”当成唯一事件源数据库Binlog或者变更日志里记录了每一次真实数据变更你订阅这些变更事件然后异步更新缓存。你不再手动管两个系统而是让数据库的变更通知驱动缓存更新。5.2 基于数据库变更的缓存同步Binlog订阅的工程细节以MySQL为例你可以说用Canal接Binlog拿到变更数据然后写入Redis。这个方案不是只有大厂能用一个小团队也能做核心在于理解事件流的一致性保证。一个关键点Binlog是顺序的。你的缓存更新必须保证按这个顺序执行否则你先处理了后写的变更、再处理前写的变更缓存里就会留下旧值。所以消费端要做分区有序同一个数据键的变更必须固定在一个消费者线程里串行处理。你还要做幂等每条变更事件带一个唯一ID或时间戳处理前先比对Redis里的版本如果事件版本小于等于当前版本直接跳过。这套方案的另一大优势是“补偿天然存在”。数据库里每一条更新都会有Binlog不需要你手动发消息通知缓存删除就算缓存更新失败你也可以通过消费者重试机制不断重放事件直到缓存对上了。我个人的体会是它比手动双写可靠得多因为它完全不用改动业务代码和业务解耦。不过要注意缓存过期时间还是要保留。Binlog订阅也可能因为消费者异常堆积、重启等原因造成较长的窗口期。TTL兜底能让系统在极端情况下自动恢复而不是永远卡在错误数据上。5.3 数据库同步软件与配套工具的选型细节除了缓存和数据库的一致性问题很多团队还会用数据库同步软件进行从库搭建、数据迁移或数据归档。比如在MySQL主从同步、Oracle数据同步、SQL Server定时同步这些场景都会涉及同步工具的选型和环境匹配问题。数据库同步软件和缓存一致性的关系是这样的它们本身不是一致性方案而是“数据搬运”工具。你在源库和目标库之间做同步目标库数据是源库的副本同样会存在“同步延迟”和“同步失败”的问题。选工具时重点看三点支持哪些数据源类型、断点续传能力、变更捕获方式。以MySQL为例有基于Binlog的增量同步以Oracle和SQL Server为例则可能依赖触发器、日志挖掘或定时任务延迟往往是秒级甚至分钟级。配套工具上数据库客户端和驱动匹配也是一个经常踩坑的地方。我记得有同事在使用数据库管理工具时遇到过“请先安装Access数据库64位系统驱动程序”的报错就是因为本机装了64位Office但工具用的还是32位驱动两边不匹配。这类问题不影响线上但会影响你排查问题的效率。数据库连接池参数同样值得关注。连接池里如果有太多空闲连接不释放数据库端可能会清理它们导致应用拿到的连接失效而连接池容量太小高并发下一旦缓存全部失效回源请求直接把连接池打满数据库也跟着雪崩。连接池的连接数和超时时间一定要按缓存失效场景下的峰值回源流量来压测验证而不是按平时的吞吐量来配。导数据这类批量操作也会影响一致性。用工具把Excel导入数据库时如果你的导入逻辑是一行一行写的每秒几百行数据库负载还受得住如果是一把锁表导入导入期间所有读请求可能被阻塞而缓存还在提供旧数据服务这就会造成“导入前后差异巨大”的体验。批量导入后最佳实践是先清理相关缓存再放开流量。6. 线上问题排查五个步骤与一张速查表6.1 从“数据对不上”到“定位根因”的五步排查法线上报告缓存和数据库不一致的时候别急着清缓存。我有一套固定排查流程效率很高。第一步确认差异状态。拉出Redis里的值和数据库里的值对比时间和内容。如果Redis里没有这个Key说明问题不在缓存陈旧而在读请求绕过了缓存或者缓存被误删。第二步看删除动作有没有执行成功。凡是走Cache Aside方案的写库之后必须删缓存。去Redis日志或者代码日志里查这次写操作的删除指令耗时和返回结果确定是不是删除失败或超时。第三步检查TTL是否合理。如果这个Key的过期时间是24小时或者更长而你发现缓存里的值和数据库不一致那这个TTL本身就是帮凶。当你没有信心每次删除都成功时TTL就是你的第二次机会设短一点永远不会吃亏。第四步复现并发时序。可以写一个简单的多线程测试模拟“写库线程加并发读线程”同时跑看缓存最终是否被旧值覆盖。如果你能在测试环境复现再对比生产代码的读写顺序通常马上能看出问题出在“删完缓存又被写回”还是“压根没删”。第五步检查监控和重试。看看自己有没有后台任务在重试删除有没有对账任务定时比对缓存和数据库的值。没有的话说明你的系统连“自愈”能力都没有这回只能人工处理下回应该把重试和对账加上。清缓存本身也是一门学问。上线平台一般都有缓存清理入口如果是普通测试环境直接用Redis客户端删掉对应Key也行。自动化测试里用Python加Selenium清浏览器缓存、清Redis缓存也都可以脚本化但生产环境执行清缓存操作一定走审批和操作记录避免误清导致缓存击穿。6.2 一致性高频问题速查症状根因处置手段改完数据页面一直显示旧值删除缓存失败或TTL太长手工清除对应Key缩短TTL加删除重试刷新一次看到新值再刷新变旧值并发旧值写回缓存延迟双删版本号校验缓存里存在数据库没有的数据事务回滚但缓存更新成功更新缓存前先确数据库提交成功错误数据立即清缓存缓存失效瞬间数据库被并发打挂缓存击穿互斥锁重建逻辑过期异步刷新大量Key同时过期数据库被打满缓存雪崩TTL加随机偏移本地降级缓存缓存数据明明没问题接口却总是查库本地缓存/ORM缓存层级干扰关闭MyBatis一级缓存梳理缓存键分布6.3 我个人的几点体会做了这么多年我的最大感受是缓存和数据库一致性不是一个“解决掉就一劳永逸”的问题而是一个“持续设计、持续治理”的问题。与其追求理论上的绝对一致不如把目标设定为绝大多数请求走缓存足够快少数不一致场景能快速发现、快速修复、快速解释。版本号是一个成本低、效果好的技巧。我比较喜欢在每个缓存Value里带上数据库更新时的版本号或时间戳这样即使并发写回缓存也能在校验时识别出“这个数据比当前的旧”然后主动丢弃而不是覆盖。重试和监控是两件不能省略的事。任何一条删除缓存动作都应该有失败日志和重试机制哪怕只是个简单的定时任务扫重试表。否则你根本不知道系统已经在旧值状态下跑了多久。我还想提醒做架构设计的朋友缓存层级不是越多越好。你每加一层缓存就是给一致性增加一分难度同时也给排查增加一层迷雾。能让数据直连的地方就不要硬塞缓存能5分钟失效的就不用设置永不过期能用Redis一处解决的就不用本地缓存层层叠加。最后再分享一个小技巧每次上线涉及缓存写逻辑时加一条临时的“新旧值并存”监控日志对比数据库变更时间与缓存更新时间。这个日志不需要长期保留但上线初期靠它能快速定位到底是链路没打通还是顺序错乱能帮你省掉大量排查时间。
返回列表