
1. Redis缓存和数据库之间那点说不清道不明的帐做后端这几年几乎每个项目都会遇到分布式缓存一致性这个坎。缓存能扛读流量这谁都知道但只要数据改了缓存和数据库就必然有一段时间不对账。最典型的场景就是电商的购物车、订单状态、库存余量还有配置中心的热更新这些全是缓存一致性问题的重灾区。你问十个人九个人会告诉你用Cache Aside也就是旁路缓存策略读的时候先读缓存没命中就读库再回填写的时候先更新数据库再把缓存删掉。但真在线上跑起来你会发现这个策略看起来无懈可击实际操作里到处是漏洞。我自己最早踩坑是在一个交易后台系统用户下单之后订单状态从待支付变成已支付数据库更新成功按理说缓存删掉就完事了。但偏偏那一次Redis删除操作超时返回失败代码里catch住异常继续往下走。结果就是缓存里还存着待支付的旧状态用户刷新页面看到订单还是没支付客服电话直接被打爆。那次之后我才认真去研究缓存一致性的底层逻辑——它本质上不是Redis或者MySQL哪一方的问题而是两份数据各自独立更新带来的时间差问题。这篇文章不打算讲教科书理论我会把我在项目里实际用过的方案、踩过的坑、以及最后沉淀下来的取舍逻辑都拆开说。内容主要面向后端开发、架构师以及正在设计高并发读多写少系统的朋友。你会看到为什么延迟双删在某些场景下是伪命题为什么Binlog订阅方案更省心以及强一致场景下到底该不该引入分布式事务。分布式缓存一致性这个话题网上文章不少但大多数只讲双删这一个点。我想换个角度从问题根因出发把常用方案串起来讲清楚顺便把我那个订单状态错误的完整排查链路也分享出来希望能帮同行少走弯路。2. 一致性问题到底出在哪两个存储之间没有事务边界先看一个具体的运行时序。假设有一个商品的库存数量初始值是100数据库和缓存里都是100。现在一个请求要把库存改成99另一个请求恰好正在读库存。按照Cache Aside策略正常的执行顺序应该是读请求先拿到缓存的100返回给用户写请求更新数据库为99然后删除缓存下一个读请求读不到缓存回源数据库拿到99回填缓存。这套流程只要执行得足够快用户几乎感知不到差异。但问题在于写请求里更新数据库和删除缓存这两个动作并不是原子的。如果更新数据库成功了删除缓存之前的一瞬间读请求来了它会直接命中缓存里的旧值100。更麻烦的是如果程序在校验缓存删除结果时不做处理这个旧值可能被服务很长时间。我列一下缓存不一致出现的三个典型窗口更新数据库成功删除Redis失败或超时旧缓存继续存活直到下一个写操作或TTL到期。并发读写交错读请求在数据库更新完成和缓存删除之间回源查询并回填了旧数据导致刚删除的缓存又被旧值覆盖。多副本场景下主库更新了但从库延迟读请求打到从库拿到了旧数据并回填缓存。很多人觉得第三个窗口是数据库主从的问题跟缓存没太大关系。但在实际系统里主从架构缓存架构是叠加的读请求一旦走了从库整个链路的延时点就多了一环。这也是为什么我在后面讲方案的时候还会专门提到版本号这个思路就是为了对付这类回填旧值的刁钻场景。把这三个窗口看明白你就能理解一个结论缓存一致性问题的本质是两个存储系统之间没有共享事务上下文。数据库有事务Redis也有自己的原子操作但跨系统之后没有任何一个事务能同时保证数据库更新和缓存失效要么都成功、要么都失败。所以所有方案本质上都在做一件事用某种机制把缓存失效这个动作补成一个最终都会执行成功的操作或者用业务手段把脏数据的影响窗口压到最小。这个认知相当重要。我在前几个项目里反复跟团队强调不要试图保证缓存和数据库永远一致这是伪命题。物理上不可能。能追求的只是对用户的视角来说绝大多数时候一致或者不一致的时间窗口小到业务不可感知。想清楚这一点后面的方案取舍才不会拧巴。3. 延迟双删的真实效果它能补漏但补不了所有漏3.1 延迟双删是怎么运作的既然直接删除缓存有窗口期那很多人自然想到一个变体先删除缓存再更新数据库等一小段时间之后再次删除缓存。这就是所谓的延迟双删也常被称为Double Delete。它的逻辑是第一次删除先把缓存清掉让并发读请求回源数据库即使读到旧数据因为数据库还没更新完成那么更新完成后第二次删除再把这个可能被回填的旧值清掉。听起来很合理很多文章甚至把它说成终极方案。我在线上用过的延迟双删版本是这样的// 步骤一删除缓存 redisTemplate.delete(key); // 步骤二更新数据库 orderMapper.updateStatus(orderId, PAID); // 步骤三等待一段延迟时间 Thread.sleep(500); // 步骤四再次删除缓存 redisTemplate.delete(key);看起来没什么问题但实际部署的时候踩坑点一个接一个。第一Thread.sleep(500)这段代码会让当前线程挂起在高并发请求里睡掉500毫秒跟性能杀手没什么区别。你要么把它放到异步线程里要么用消息队列触发第二次删除否则接口RT直接飙到500ms以上压测根本过不了。第二这个500ms到底怎么定的拍脑袋。它能覆盖多少并发交错场景取决于数据库更新完成到最晚那个读请求回填旧值之间的时间差。如果数据库主从延迟超过500ms读请求从从库拿到旧数据并回填缓存的时间可能发生在第二次删除之后那这一轮双删就白做了。第三更隐蔽的问题是如果你有多个线程同时更新同一个key第二次删除可能会误删新值对应的缓存。比如线程A先把库存改成98删缓存然后线程B改成97删缓存线程A的第二次删除落在线程B更新完成之后会把缓存里本来应该是97的那份数据也删掉。这倒不会造成数据错误最坏情况是缓存被清了流量打到数据库但也说明第二次删除不是无副作用的。3.2 一次线上故障双删之后缓存里还是旧订单状态那次订单状态不对的故障排查链路大概是这样的。我先把所有订单状态相关逻辑梳理了一遍确认数据库里的值是对的Redis里的值不对。然后看日志发现第一次删除缓存成功数据库更新也成功但第二次删除压根没有执行——因为代码里有个try-catch把Thread.sleep的异常吞掉了后续的第二次删除逻辑直接跳过。对你没看错就是那么低级。但这类低级故障在真实系统里特别常见因为双删逻辑散落在业务代码里每个接口写一份你能保证这版不出问题不保证半年后接手的人不在中间加一个提前return。修复那个事故我当时的做法是给第二次删除加了一个不可跳过的异步重试机制把待删除的缓存key写到本地延迟队列200ms之后取出并再次执行删除删除失败就进重试队列连续重试三次。这样业务流程本身不需要休眠第二次删除的可靠性也提高了。但从那次之后我基本不在新项目里用延迟双删了原因很简单它把一致性保障建立在数值恰好够大的睡眠时间上本质上是赌概率而不是保证。真正的工程方案要么让缓存删除变成一个有兜底的重试任务要么干脆从数据管道层面解决。4. 一致性分级先想清楚你要的是最终一致还是强一致4.1 绝大多数业务场景只要最终一致就够了很多团队一上来就琢磨要不要上分布式事务我会反问一句你的业务真的需要强一致吗别急着回答需要先看数据形态。电商的购物车、用户资料、文章详情页、商品详情快照这类读多写少、改了之后用户能接受下一次刷新才看到新结果的数据最终一致完全够用。缓存有个TTL兜底最坏情况下过了TTL自然过期用户看到的新旧数据差异在可接受范围内。最终一致的实现方式也很轻量核心是四条业务代码里数据库事务提交成功后发一个异步消息MQ或者本地消息表消息里带上key和需要清理的缓存字段。单独的消费服务收到消息后对Redis执行删除操作。如果消费者删除失败消息不要直接确认进入重试队列配合指数退避。所有缓存设置合理的TTL作为最后一道自愈保险。这套方案和双删的区别是它把删缓存这个动作彻底从业务主链路中剥离了。业务代码只需要保证数据库更新成功消息发送成功二者放在同一个本地事务里通过消息表完成再往后的事情全部交给异步任务。不阻塞主流程也不用赌时间。4.2 强一致场景的取舍用锁还是放弃缓存不过也总有一些地方绕不开强一致。典型的是扣减库存、下单支付金额校验、账号余额更新这类读到的值必须是刚提交的值的场景。在这些场景里用Cache Aside加异步删除基本上是行不通或者风险极高的。我见过有团队在扣库存场景里把库存放到Redis里直接做原子扣减数据库只做最终账本这套路不是不行但前提是你必须接受Redis为主、数据库为备的思想也就是把一致性问题的原点从关系型数据库搬到Redis事务上。如果你一定要数据库和缓存强一致常见的选择有两个。第一个是分布式锁DB事务。在更新操作上锁保证同一时刻只有一个请求在走更新数据库删缓存链路读请求在更新期间要么走缓存旧值业务短时间容忍要么强制读主库。这个方案能把窗口缩得很小但并发度上限直接受锁粒度制约而且锁本身垮了或者超时了一样有隐患。第二个是分布式事务中间件比如Seata的AT模式、TCC模式把更新数据库和删除缓存纳入同一个全局事务。这个方案的一致性等级最高但代价也最高全局锁的粒度、事务协调器的稳定性、网络开销都会显著上升。我个人的建议是除非业务真的因为数据不一致发生了资损或者强对账失败否则别把分布式事务塞进高并发缓存链路里性价比太低了。还有一条更直白的路子跟一致性正则化机制的理念很像——把一致性策略固化成统一的框架能力让业务代码不必每次自己写先删缓存还是先更新库的判断。比如Spring Cache的CacheEvict就是一种粗粒度工具更粗暴的是基于AOP统一拦截Mapper更新方法自动失效对应缓存。这种机制化处理最大的好处是避免每个开发自己实现一致性逻辑减少出错面。5. 更省心的做法Binlog订阅加版本号把一致性下沉到数据管道5.1 Canal订阅Binlog业务代码零侵入如果你对延迟双删感到心累又不想在业务代码里埋一堆删除缓存的逻辑那可以考虑把缓存失效这个动作彻底从业务应用里挪走放到数据管道层。具体做法是使用Canal这类组件伪装成MySQL从库订阅Binlog解析出数据变更事件然后投递到Kafka或RocketMQ由专门的消费者去更新或者删除Redis缓存。这个方案的链路是开启MySQL Binlog并确认binlog_row_imagefull保证解析出的数据包含完整行记录。部署Canal服务配置好监听的表和数据库实例它就像MySQL的从库一样实时接收Binlog。Canal把变更事件发送到KafkaTopic可以按表拆分。消费端接收到事件解析出主键和变更后的值然后根据业务规则决定是删除对应的Redis key还是直接把新值写入缓存。这套方案的优势非常明显业务代码完全不用感知缓存的存在甚至缓存失效规则改了也不需要重新发版。原来散落在各个Service里的redisTemplate.delete全部收敛到一个地方一致性的控制变得更集中。我没有夸大它的好处。我在项目中落地过类似方案消费者拿到Binlog事件后只执行一个操作按主键规则拼出Redis key删除缓存。整个流程不碰业务逻辑所以即使哪天缓存要不要删的问题出了争议也只需要改消费者代码风险边界清晰。当然它也有缺陷。Binlog订阅是异步链路从数据库提交到消费者执行删除通常会有几十到几百毫秒的延迟极端情况下更久。如果业务对时间窗口极其敏感它同样满足不了强一致。另外多引入Canal和MQ运维复杂度也跟着上来小团队需要评估一下成本。5.2 缓存里存版本号防旧值回填最有效的杀手锏不管用哪种方式清理缓存都绕不开一个场景多个线程并发写同一个key或者一个线程更新数据库的同时另一个线程读到了旧值并回填缓存。延迟双删解决不了这个问题Binlog订阅也解决不了因为回填动作可能发生在删除之后。真正能对付这个场景的是缓存版本号机制。具体思路是数据库的表里加一个version字段每次更新都version version 1缓存里不仅存业务数据还存一个对应的version。读请求回源数据库后回填缓存时把当前的version一起写进去。当缓存被回填旧值后如果数据库已经更新到更高的版本我们可以通过对比版本号发现不一致再做一次矫正。更常见的落地方式是在缓存值里嵌套版本号字段写入时比较版本# 使用Redis Lua脚本原子性对比版本号 # KEYS[1]为业务缓存keyARGV[1]为新值ARGV[2]为数据库当前版本号 if redis.call(get, KEYS[1] .. _version) ARGV[2] then redis.call(set, KEYS[1], ARGV[1]) redis.call(set, KEYS[1] .. _version, ARGV[2]) return 1 else return 0 end这套逻辑的精髓在于缓存写入不是无条件的它要求你手上的版本号必须和当前缓存里的版本号一致才允许覆盖。这样即便有并发线程用旧数据回填版本号对不上写入也会失败数据库最新数据不会被旧缓存数据二次污染。不过也要说清楚版本号方案会增加缓存的存储开销和代码复杂度而且它解决的是缓存被旧值覆盖的问题并没有解决缓存删除被跳过的问题。两个问题最好组合着用删除做兜底版本做并发保护。6. 一致性怎么验证不要等出问题才想起对账方案讲再多如果线上没有监控和校准机制出问题的时候还是只能靠客服反馈。我现在的习惯是任何一个缓存链路上线之前必须先回答三个问题缓存和数据库如果出现不一致多久能发现能不能定位到是哪条链路产生的能不能自动矫正6.1 一致性巡检定时抽样比对数据库和缓存最简单的巡检方式是做一个定时任务扫描数据库里热度较高的数据按主键读取对应的缓存对比关键字段。差异超过阈值就告警。这个方法能发现多数由删除失败、回填乱序引起的脏数据缺点是扫描频率不好控制扫太快增加数据库压力扫太慢发现问题的时效性差。实用的做法是不扫全表只扫最近15分钟内发生过变更的数据这些数据才有比对的必要。变更记录可以从Binlog拿也可以业务在更新时顺手写一张变更日志表。我建议用Binlog因为业务表变更记录不需要额外维护数据更全。6.2 全链路追踪和删除失败告警巡检只能事后发现其实更关键的是把删缓存失败这个信号做成实时告警。代码里所有执行Redis删除操作的地方把返回值和异常都记下来带上traceId以及当前进程的机器IP、方法名。失败率达到阈值就触发告警并且自动把失败的key投递到重试队列。当年那个订单状态错误事故如果能早一点做这一步根本不会等到客服电话打过来。所以我现在写缓存清理代码最少要包含三样东西操作key、操作结果、失败时是否有重试标记。这三样信息拼接成一条结构化日志哪怕工具再差grep也能查出来。6.3 自愈机制重试队列和TTL是最后的保险最后一道保险是给所有缓存设置一个合理的TTL。加了TTL的缓存即使删除失败、更新失败最长也就脏一个TTL周期。很多系统不敢给缓存设TTL是怕提前过期导致流量打到数据库这就是另外一个优化问题了可以用热点预热、本地缓存做二级缓存来对冲。但不管怎么说没有TTL的缓存相当于慢性毒药任何一个环节问题都会被无限放大。重试队列的自愈价值也很大。消费端删除Redis失败时不要直接丢弃消息把它转入延迟队列延迟几秒后再试。连续重试多次还是失败就发告警让人介入。配合Binlog订阅场景消费者本身就是薄弱环节这一套尤其必要。7. 说实话真正关键的还是业务数据分级做多了以后我对分布式缓存一致性这个问题的理解反而趋向于简单不要试图用一个方案解决所有缓存问题先给数据分级再选方案。数据特征一致性要求推荐方案商品详情、配置、文章内容最终一致Cache Aside TTL 异步删除订单状态、物流信息最终一致秒级可接受Binlog订阅 版本号防覆盖库存、余额、支付金额强一致必要时锁/分布式事务或直接读主库并减少缓存层登录态、验证码本身就是缓存态Redis TTL自动过期无需跨系统事务这张表不是标准答案但它是我做业务时比较实用的一套分类逻辑。低一致性要求的数据用最轻量的方案别为了追求理论完美把链路做得又长又复杂高一致性要求的数据宁愿接受缓存没命中回源数据库的性能成本也不要冒着资损风险去赌删除时机。另外我还想提一点就是一致性正则化机制这个思路。很多团队每次写缓存清理逻辑都是从零开始同一个更新后删缓存的规则这个接口写一遍那个接口写一遍代码质量参差不齐。如果能把这类规则沉淀成统一的注解、拦截器或者消息消费模板从机制上保证每条更新路径都会触发缓存失效一致性问题的发生概率会大幅下降。这比任何高深的算法都管用因为它解决的是人总会犯错的问题。最后分享一个我自己的操作习惯。新接手一个系统我会先做一次全量的缓存key盘点把每个key对应数据库哪个表、更新链路是哪个接口、是否设置TTL、删除失败之后有没有重试全部列成一张清单。这份清单看起来很笨但排查问题的时候比什么监控都有用。分布式缓存一致性这个领域其实不缺理论方案缺的是把方案落实到每一个具体key上的耐心。