
先说个我真实遇到的事。凌晨两点线上告警突然弹出来Redis集群命中率从99%掉到31%主库CPU被打满最核心的那块缓存前后数据差了三倍。排查到最后根源不是Redis挂了而是一条本应在毫秒级完成的“删除缓存”操作因为网络抖动超时之后没被重试旧数据在缓存里活过了整整一个有效期。从那天起我对“分布式缓存一致性”的态度从一个面试概念题变成了实打实的工程必修课。在分布式架构已经成为标配、Redis几乎人手一套的今天“分布式缓存一致性”讨论的其实是一个很朴素的问题主数据在数据库里副本数据在缓存里当数据库的值发生变化时缓存多久能跟上跟上之前用户看到的到底是新值还是旧值这两个问题直接决定了你的缓存方案是可用的还是埋在线上的一颗雷。这篇文章不是教科书式的原理复读。我会结合电商订单、库存预扣、会话体系这类常见业务把分布式缓存一致性从理论落到线上包括并发时序怎么分析、更新策略怎么选、穿透击穿雪崩怎么应对、分布式锁到底该放在哪一环、以及最终一致性的落地姿势和监控治理方法。适合正在做后端开发、准备分布式相关面试或者已经被线上缓存问题折磨过一轮的同学。1. 缓存的“裂痕”从哪来一致性问题产生的三个根源1.1 副本的本质缓存只是数据库的“复印件”想理解缓存一致性先要接受一个前提缓存里存的数据永远是数据库里某个时刻的“复印件”。这个说法有点扎心但它是所有讨论的起点。数据库里的价格是100元缓存里的价格也是100元你能看到它们一致是因为没有人在这个瞬间修改价格。一旦DBA跑了一个UPDATE把价格改成80元数据库立刻变成80元而缓存大概率还是100元。从这一刻开始两者就不一致了直到缓存被主动更新、被删除、或者等到过期时间自然失效。这个现象在单机应用里也存在但在分布式系统里被无限放大。单机场景下你可能只有一个应用实例、一个数据库、一个简单的内存缓存更新路径短、失败概率低、出了问题也好排查。但在分布式架构下应用是多实例部署的缓存是独立集群数据库可能还有主从复制延迟缓存和数据库之间的每一次同步都要经过网络。而网络恰恰是最不可靠的那一环延迟、超时、重试、乱序随便一个意外都能把“复印件”的同步时间从毫秒级拖到秒级甚至分钟级。所以任何缓存方案都消灭不了这个窗口只能压缩它、监控它、或者在业务层面让它变得可以接受。谁要是说自己的缓存和数据库“绝对一致”那要么是没上过生产环境要么是数据压根没有并发写。1.2 并发交错先改库还是先改缓存决定脏数据活多久缓存一致性问题最常见的来源不是缓存方案选错了而是并发场景下多个线程操作数据库和缓存的顺序发生了交错。我举一个非常典型的时序你大概率在代码评审里遇到过。假设商品库存初始是10线程A执行“扣减库存”把数据库改成9线程B因为某些原因比如查询链路慢读到了旧数据正在执行“读取库存并回填缓存”回填的是10。那么A更库、B回填缓存这两个操作一旦交错就会出现一个结果数据库里是9缓存里却躺着10。更麻烦的是如果这种交错发生在更新缓存而不是删除缓存的方案里问题会变得更隐蔽。线程A把缓存更新为9线程B把缓存更新为10两个操作都成功但数据库里最终的值是9缓存里却是10。这种错误不是短暂的过期就能自愈的只要缓存不失效它会一直错下去。这也是为什么有经验的团队更倾向于“删除缓存”而不是“更新缓存”。并发交错导致的一致性问题是概率性的压力越大、链路越长出现的概率越高。单靠代码review很难发现只有线上出现数据对不上账的时候才会暴露。1.3 失败与重试一致性事故里概率最高的一环如果说并发交错是“理论上会发生”那失败重试就是“现实中一定会发生”。典型的失败场景是这样的业务代码先更新数据库成功了正准备删除缓存的时候Redis连接超时。代码里如果只是简单catch一下然后打印日志那这次删除就静默失败了。数据库是新值缓存还是旧值两者会一直不一致直到缓存过期。有人会问删一次失败那重试一次不就行了事情没那么简单。盲目重试可能带来另一个问题如果Redis发生了主从切换或者缓存已经被其他链路删掉又重新回填了新值你的一次重试删除可能把别人刚写进去的“新值”也删掉了造成更大的混乱。所以重试不是不行而是需要考虑幂等和时序。我见过很多线上缓存事故最终的根因都不是什么高深的分布式理论问题而是更新数据库成功了、删除缓存失败了、没有补偿机制。就这么简单但后果是用户持续看到旧数据业务方来投诉你打开Redis一查key还在TTL还有几个小时。这种事故的教训是只要你的缓存失效链路里存在“可能失败但没有兜底”的环节你就在赌运气。2. 主流更新策略对比Cache Aside 是最流行的方案但细节决定成败2.1 Cache Aside 的标准姿势与“为什么是删缓存而不是更新缓存”先给没有接触过这个概念的读者一个快速扫盲。Cache Aside旁路缓存是目前工程界最主流的缓存读写模式大多数用Redis做缓存的团队实际采用的都是这个模式或其变体。读路径很简单先读缓存命中就直接返回没命中就查数据库查到了回填缓存再返回。写路径是先更新数据库然后删除缓存或者先删除缓存再更新数据库。很多刚入门的朋友会在这里有一个疑问既然是缓存为什么不直接更新缓存里的值非要删掉呢删掉之后下一次读取不还得重新查库回填这不是多此一举吗这里有几个非常实际的理由。第一更新缓存不幂等。如果有多个线程同时执行更新缓存的操作但数据库更新的顺序和缓存更新的顺序不一致缓存里留下的可能是旧值。而删除缓存是幂等操作删了就是删了不存在“删错了值”的问题最多就是多删一次。第二更新缓存需要你拿着最新的数据去覆盖。这意味着业务代码在写库之后还得管理一份“最新的值”逻辑上多了一个状态要维护。而删除缓存只需要知道key成本低得多。第三缓存里的数据格式可能变化。今天缓存里存的是JSON字符串明天你可能想改成压缩格式如果采用更新缓存的方案每次写入都得处理格式问题。而删除缓存下一次回填自动用新格式省心。所以在绝大多数业务场景下Cache Aside的标准姿势就是数据库写成功之后删除对应的缓存key。2.2 先更库后删缓存 vs 先删缓存后更库两个窗口谁更短这里有一个长期存在争议的问题写路径上“先更新数据库再删除缓存”和“先删除缓存再更新数据库”到底应该选哪个先说结论多数情况下建议选择“先更新数据库再删除缓存”。原因是这两个顺序都会产生一个不一致窗口但窗口的性质不同。如果先删除缓存、再更新数据库那么在更新数据库的这个时间段内所有读请求都会因为缓存缺失而打到数据库。如果更新数据库失败了缓存已经被删了下一个读请求会把数据库里的旧值重新回填到缓存里系统自动恢复问题不大。但如果在删除缓存之后、更新数据库之前恰好有一个读请求查到了旧值并回填了缓存那你后面即使更新数据库成功缓存里残留的还是旧值这个脏数据会一直存活这是个大坑。如果先更新数据库、再删除缓存那么数据库更新成功到缓存删除完成之间的窗口里读请求还能读到旧缓存。这个窗口通常非常短几毫秒到几十毫秒而且只要缓存删除成功下一次读取就一定是新值了。相比之下这个方案的好处是缓存里即使残留旧值也只是一个短暂窗口不会长期存在。所以从错误恢复的角度看“先更库、后删缓存”更安全。它把不一致的时间压缩在极小的窗口内而且不会留下永久性的脏数据。写路径不一致窗口失败后果是否容易留下长期脏数据先删缓存再更新数据库删除后到更库前的窗口读请求可能回填旧值更库失败会自动恢复是容易残留旧值且难以发现先更数据库再删除缓存更库后到删除前的窗口读请求可能读到旧缓存删除失败需要补偿否窗口短过期可自愈2.3 延迟双删与重试队列把残留窗口压到可接受范围即使选了“先更库、后删缓存”也仍然存在一个经典问题删除缓存这个动作本身可能失败或者删除完成后又被并发线程回填了旧值。解决这个问题的常见手法是延迟双删。思路是更新数据库后立刻删除一次缓存然后过一小段时间比如500毫秒再删除一次。第二次删除的目的就是清掉在第一次删除和数据库更新间隙被某些读线程误回填进来的旧值。这里有一个关键参数需要说明延迟时间怎么定。我的经验是延迟时间至少要大于“一次数据库查询加上一次缓存回填”的平均耗时。如果业务查询DB平均耗时是50毫秒那你延迟200毫秒到500毫秒通常够了。没有标准答案需要根据自己系统的实际耗时来定压测环境可以稍微调大一点。延迟太短第二次删除可能发生在旧值回填之前白删延迟太长又会影响最终一致的时效。public void updateData(String key, Object newValue) { // 1. 先更新数据库 updateDatabase(newValue); // 2. 删除缓存 cache.delete(key); // 3. 延迟一段时间后再次删除清掉可能回填的旧值 Thread.sleep(500); cache.delete(key); }说到底延迟双删是一个“尽可能降低概率”的手段它并不能100%保证缓存和数据库永远一致。真正能兜底的是后面要讲的消息补偿机制和监控对账。2.4 澄清Spring三级缓存与分布式缓存一致性不是一回事搜索“缓存一致性”的人里有不少是被Spring三级缓存原理带过来的。这里必须做一个澄清Spring三级缓存解决的是Spring IoC容器内部创建Bean时的循环依赖问题缓存的是正在创建过程中的对象实例作用范围在单个JVM进程内。而分布式缓存一致性解决的是跨节点的“数据库主数据与外部缓存副本”之间的数据同步问题。两者完全不是一个层次。如果你用Spring三级缓存的思路去理解分布式缓存一致性会走进死胡同。面试的时候被问到分布式缓存一致性你要讲的是本章和后续章节的内容而不是三级缓存。我把这两个概念分开讲的目的是想提醒很多技术名词里都有“缓存”两个字但背后的语义差之千里学习的时候先划分清楚讨论边界才不会稀里糊涂。3. 穿透、击穿、雪崩缓存“不挡流量”时一致性问题会被放大多少3.1 缓存穿透压根没有缓存键DB成了唯一真相源缓存穿透指的是查询一个根本不存在的数据。比如按用户ID查询一个已经被删除的订单缓存里没有数据库里也没有。这种情况下每次请求都会绕过缓存直接打到数据库。穿透和一致性的关系很隐蔽但也很直接因为数据不存在缓存里不会有对应的key所以每一条查询都是“实时查库”数据库返回什么用户看到什么。如果数据库本身存在主从延迟或者事务隔离级别导致的可重复读问题用户在不同时间点查到的结果可能不一致。更现实的是穿透会带来巨大的DB压力DB一旦被打到慢查询甚至故障所有依赖缓存的数据回填都会停滞进而引发更广泛的一致性问题。处理穿透的常见办法有两个。一是布隆过滤器在缓存前面加一层“可能存在”的判定不存在的数据直接被拦截压根不去查库。二是空值缓存对于查询结果为空的数据也往缓存里写一个空值并设置一个较短的过期时间比如60秒防止同一个不存在的key反复穿透。有一点要特别提醒空值缓存本身也会涉及一致性。比如一个数据刚被插入数据库但之前查询不存在时往缓存里写了空值而且TTL还没过期那用户在这段时间内还是查不到这个数据。所以空值缓存的TTL要短或者插入数据的业务代码里显式删除一次对应的空缓存。这属于容易遗漏的细节。3.2 缓存击穿热点key过期瞬间的并发回填风暴缓存击穿和穿透一字之差但场景完全不同。击穿指的是某个非常热点的key在过期的那一瞬间大量请求同时发现缓存未命中于是一起涌向数据库去查询并回填。为什么击穿和一致性有关因为在大规模并发回填的情况下多个实例可能同时查到数据库的不同版本主从延迟背景下然后各自把结果写回缓存。假设数据库被更新过有的实例查到的是新值有的实例查到的是旧值谁后写谁覆盖最终缓存里留下的值就成了“薛定谔的值”取决于最后写入的线程。我见过一个库存系统的真实案例一个爆款商品的库存key过期瞬间几十个应用实例同时回填缓存其中一个实例因为主从延迟读到了旧的库存量回填后缓存里显示的库存比真实可售库存多了几千件直接导致了超卖风险预警。处理击穿的核心思路是互斥回填在缓存未命中时先尝试获取一把分布式锁只有拿到锁的线程才允许去数据库查询并回填缓存其他线程短暂等待后再从缓存读取。这样能保证同一时刻只有一个线程在回填避免多版本并发覆盖。另一个思路是热点key不过期由后台任务定期刷新缓存。这个方案能彻底避开“过期瞬间”这个脆弱窗口但要求你的系统有能力维护一份“哪些key是热点key”的清单并且刷新要及时。如果后台任务挂了一会缓存里可能会出现一段时间的旧值所以巡检逻辑一定要有。3.3 缓存雪崩批量失效与集群故障下的一致性悬崖缓存雪崩通常有两种触发方式。第一种是大批量key在同一时刻过期。比如一次性加载了一批促销活动的商品数据设置了相同的过期时间结果零点一到几万个key集体消失所有请求一瞬间压到数据库。第二种是Redis集群本身出现问题比如某个分片不可用导致大量key的访问直接降级到数据库。雪崩对一致性的冲击是“持续性”的而不仅仅是“瞬间”的。大量请求绕过缓存直达数据库后数据库的读QPS会飙升到正常值的几十倍很容易出现慢查询、连接打满、甚至实例重启。数据库一旦不稳定即使缓存系统恢复回填操作也会失败用户的每一次请求都可能在“查库超时”和“缓存未命中”之间反复横跳此时整个系统的一致性已经无从谈起因为连数据都读不出来了。解决批量失效的常规手段是给过期时间加随机值。比如每个key的TTL在基础值之上加上一个0到300秒的随机数把集体失效的时间点打散。解决集群故障的思路则是多级缓存兜底Redis之上再加一层本地缓存比如CaffeineRedis不可用时至少还能挡住一部分请求同时做好熔断和降级宁可让用户看到降级提示也不要让数据库被冲垮。雪崩这类故障提醒我们缓存一致性不只是“缓存里的值对不对”的问题还包括“缓存系统本身可不可用”。一个经常宕机的缓存系统连讨论一致性都是一种奢侈。4. 分布式锁的正确位置挡住并发回填而不是妄想锁住一致性4.1 为什么缓存回填需要分布式锁在分布式架构下同一份数据会被多个应用实例访问每个实例都有自己的线程池。当某个热点key过期时理论上所有实例的所有线程都可能同时发现“缓存未命中”然后同时去数据库查数据、同时回填缓存。如果没有一把全局的锁来控制这种行为会带来两个后果。第一数据库在热点失效瞬间会承受成百上千倍的读压力很容易被打挂。第二多个线程并发回填时由于数据库主从延迟、接口耗时差异等原因各线程读到的值可能不同后写覆盖先写缓存里最终留下的值可能是旧的这就是我们前面说的并发覆盖导致的不一致。分布式锁在这里的作用是把“回填缓存”这个动作变成互斥的同一时刻只允许一个线程去查库回填其他线程等待锁释放后直接从缓存里拿结果。这样既保护了数据库也消除了并发回填带来的覆盖问题。4.2 一种可用的Redis分布式锁实现基于Redis实现分布式锁是当前最普遍的做法。一个工程上可用的版本要同时满足三个要求加锁时设置过期时间防止死锁、用唯一的token防止误删别人的锁、释放时用Lua脚本保证原子操作。先看加锁代码String token UUID.randomUUID().toString(); Boolean locked redis.setIfAbsent(lock:goods:1001, token, Duration.ofSeconds(10)); if (locked) { try { // 查询数据库 Object value queryFromDB(goods:1001); // 回填缓存 cache.set(goods:1001, value, Duration.ofSeconds(60)); } finally { // 释放锁必须校验token防止误删其他线程的锁 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redis.executeLua(luaScript, Arrays.asList(lock:goods:1001), token); } } else { // 没拿到锁短暂等待后读缓存 Thread.sleep(50); return cache.get(goods:1001); }有几个细节值得展开。第一setIfAbsent底层就是SET key value NX EX这个组合命令是原子性的不能拆成两步执行否则判断锁是否存在和设置锁之间会有间隙。第二为什么不直接用del(key)释放锁因为如果持锁线程执行的业务逻辑超过了锁的过期时间锁已经自动释放了另一个线程拿到锁写入了新值此时如果第一个线程回来执行del(key)就会把别人的锁删掉。加上token判断后只能删除自己持有期间的那个锁。第三释放锁为什么用Lua脚本因为“取值、比对、删除”这三个操作必须作为一个原子单元执行普通代码分步执行会出现“比对成功但删除前被其他线程抢锁”的极端情况。4.3 分布式锁的边界它解决不了缓存和DB的数据同步问题这是很多人最容易搞混的地方我要专门拎出来说清楚。分布式锁解决的是“多个线程同时回填缓存时的并发控制”问题它不解决“数据库已经更新但缓存尚未更新”的数据同步问题。换句话说如果业务代码成功更新了数据库但删除缓存的操作失败了你就算把分布式锁用出花来缓存里依然是旧值用户依然会看到不一致的数据。我见过一个团队花了大精力把缓存回填的分布式锁写得非常完美锁的过期时间、续期机制、故障转移都做了但缓存失效的链路里缺少补偿机制。结果线上出了事故排查了一圈最后发现数据库早就更新成功了Redis里面旧值活得好好的因为当时根本没有代码去删除它。分布式锁再强也只是一个“防止并发踩踏”的护栏而不是“保证主从副本一致”的同步引擎。所以想清楚锁的边界很重要它有它该站的位置但它不是包治百病的药。4.4 面试中关于Redis分布式锁的高频追问因为redis分布式锁是面试高频题这里顺手把几个常见的追问点写出来帮准备面试的同学理清思路。锁的过期时间怎么设如果业务执行时间可能超过锁的过期时间怎么办常见方案是启动一个守护线程周期性“续期”业务还没结束就自动延长锁的过期时间业务结束后再释放锁。Redis主从切换时锁会丢吗会。因为Redis主从复制是异步的主节点加锁成功但还没来得及同步到从节点主节点挂了从节点顶上后并没有这把锁另一个线程就可能重复加锁成功。这是基于Redis的分布式锁在极端情况下的固有限制选型时要知道这个短板。为什么选择Redis而不是ZooKeeperRedis吞吐高、部署简单、适合高并发场景但在某些强一致的分布式协调场景下ZooKeeper顺序节点和临时节点的特性更可靠。工程上要看业务对一致性的要求来取舍。这些追问的底层逻辑其实是在考察你知不知道工具的局限性。能准确说出来比背出一堆配置命令更让面试官信服。5. 最终一致才是大多数业务的现实答案异步补偿的正确打开方式5.1 从“手动删缓存”到“消息驱动删缓存”看到这里你应该已经有一个共识删除缓存这个动作偶尔会失败而失败就需要补偿。那补偿怎么做早期团队常见的做法是“发现缓存不一致登录服务器手动执行del”。这个方法在数据量小的时候管用但到了线上环境靠人肉去删缓存既不实时也不可靠更不可能每分钟都盯着。工程上正确的做法是把“删除缓存”从一个内联的同步操作改造成一个可以异步重试的任务。具体来说业务代码更新数据库成功后不直接调用Redis的delete而是发一条消息到消息队列消息内容是“请删除这个key”。一个独立的消费者收到消息后去执行删除缓存的操作。如果删除失败消息进入重试队列不断重试直到成功为止。这个改造带来的好处是显而易见的。第一业务主链路和缓存删除解耦Redis抖动不会拖垮主流程的写操作。第二删除操作可以被持久化不会因为进程崩溃而丢失。第三重试机制让“删除失败”不再是一个无法自愈的事件。要注意的是这种异步化会引入一个新的不确定性数据库更新成功的时刻和缓存删除生效的时刻之间会出现一个时间差。这个时间差通常非常短毫秒到秒级在绝大多数业务里可以接受。如果你的业务连这个窗口都无法容忍那只能考虑读写全部串行化的强一致方案但那种方案的性能和复杂度代价大多数业务都承受不起。5.2 监听MySQL binlog让数据库成为唯一可信事件源异步补偿还有一种更彻底的玩法不在业务代码里主动发消息而是监听MySQL的binlog把数据库的每一次变更都变成一个事件再通过事件驱动缓存失效。这个方案的思路是反正缓存里的数据最终来源于数据库那数据库的变化本身就是最可靠的事件源。任何修改数据库的操作无论是业务代码、后台脚本、还是数据订正工具最终都会落进binlog。监听binlog就等价于“不漏掉任何一次数据变更”。以开源中间件Canal为例链路大概是这样的Canal伪装成MySQL的从节点订阅binlog拿到变更事件后解析出表名、主键、变更前镜像和变更后镜像然后把“变更事件”发到消息队列消费者的任务是根据表名和主键拼出对应的缓存key执行删除。// 伪代码示例Canal 事件消费 public void onRowChange(CanalEntry.RowChange change) { String tableName change.getTableName(); for (CanalEntry.RowData rowData : change.getRowDatasList()) { Long id extractId(rowData); // 约定缓存key的生成规则 String cacheKey buildCacheKey(tableName, id); // 删除缓存失败自动重试 cache.deleteWithRetry(cacheKey); } }用binlog监听的好处在于业务代码里不再需要写“删除缓存”的代码。数据变更和缓存失效的关系被提升到了数据层业务开发只需要把精力放在数据库操作上。而且对于历史数据修复、批量订正这类操作即使业务代码忘了处理缓存失效binlog监听也能自动兜底。当然这个方案也有成本要额外部署和维护Canal集群要处理binlog格式变更要保证消息消费的幂等性。但是对于数据一致性要求高、改动频繁的核心链路这个投入非常值。5.3 幂等设计和“缓存多删一次没坏处”异步删除方案里有一个关键设计原则幂等。删除缓存这个操作本身是天然幂等的同一个key删两次和删一次效果完全一样不存在“删多了”的问题。这个特性让删除方案在工程上非常受欢迎。哪怕消息队列出现了重复投递消费者多执行了几次删除也不会出任何问题。但如果你在补偿链路里做的是“回填缓存”而不是“删除缓存”那就必须小心了。回填操作不幂等因为回填会往缓存里写入一个具体的值。如果两个消费者线程拿到的是不同版本的数据后写的覆盖先写的缓存里就可能是旧值。很多团队在异步补偿时直接做“回填”这是一个容易踩坑的选择。如果确实需要异步回填我建议在数据模型上增加版本号。每次数据库更新时生成一个递增版本号或者更新时间戳缓存回填时携带这个版本号写入前对比当前缓存里的版本号只允许新版本覆盖旧版本旧版本直接丢弃。这种“乐观锁”思路能让回填操作在并发场景下也保持正确。5.4 实践里不要混为一谈的两个词分布式事务与缓存一致性有人会把分布式事务和缓存一致性放在一起讨论包括搜索热词里高频出现的“订单与库存分布式事务”“分布式事务一致性”。这两个概念确实有联系但解决的问题完全不同混在一起很容易把系统设计搞乱。分布式事务解决的是“多个服务或多个数据库之间写操作的原子性”。比如下单要扣库存、扣优惠券、记账这几个动作分布在不同的服务里任何一步失败都不能让整体数据处于半完成状态。它关注的是写写一致是跨节点的原子提交问题。缓存一致性解决的是“主数据库与缓存副本之间的读视图问题”。数据库更新成功了缓存什么时候能同步到让用户读到新值。它关注的是读写一致是副本传播问题。在实际系统里一条业务请求可能既要处理分布式事务也要处理缓存一致性。比如订单创建成功后订单服务的数据库更新了需要让订单缓存失效同时库存服务的数据库也更新了也需要让库存缓存失效。两条链路需要分别设计用各自的手段去解决而不是指望某一个分布式事务框架把所有问题都包揽下来。5.4.1 强一致什么时候才是必要的既然最终一致是多数业务的现实答案那什么时候需要追求更强的语义我的经验是主要看两个维度数据读到的旧值会不会引发资金风险或安全风险以及业务方能不能接受秒级甚至毫秒级的差异。如果缓存里放的是用户头像、商品介绍、文章内容旧值多存活几十秒毫无压力。如果缓存里放的是账户余额、订单状态、库存数量那就要谨慎很多。即便如此我见过的大部分“强一致需求”最终通过“不让用户读到错误结果”的方式满足的而不是让缓存绝对同步。比如库存扣减后虽然缓存里还显示有货但真正下单时会去数据库校验可售库存以数据库为准拒绝超卖订单支付成功后虽然缓存里的状态还是“待支付”但详情页会同时显示一个“支付结果确认中”的状态。这些方案的本质是在关键路径上绕过缓存直接以权威数据源为准。比强行追求缓存和数据库的毫秒级一致成本低得多也更可靠。6. 治理经验从指标监控到压测再到上线前绕不开的自查6.1 监控指标不只是命中率这几项必须一起看很多团队对缓存的监控停留在“命中率”一个指标上这不叫缓存治理这叫看个热闹。命中率只告诉你缓存有没有起到流量削减的作用完全无法反映一致性问题。我建议至少把下面这些指标纳入日常监控。缓存到数据库的读写比、缓存删除失败次数、缓存回填耗时、缓存回填并发数、热点key过期瞬间的DB读QPS、缓存与数据库对账差异数量。其中删除失败次数和对账差异数直接对应一致性问题。删除失败率一旦从0.01%涨到0.5%就说明缓存失效链路出现了系统性故障需要立刻排查。对账差异数则是最后一道防线定期抽样比对数据库核心表和缓存里的值发现差异超过阈值就告警。监控指标要能反推问题而不是只告诉你系统还活着。我见过很多系统监控大屏特别华丽但问起“缓存删除失败率是多少”没人能回答。这种监控在事故面前等于没有。6.2 用压测脚本模拟热点失效瞬间缓存一致性问题在低并发下很难复现必须主动制造并发条件去压出来。我自己常用的一个手段是“热点key失效压测”预先写入一个热点key设置一个很短的过期时间然后在它即将过期的瞬间用脚本同时发起大量读取请求观察数据库的QPS曲线和缓存回填后的值是否符合预期。压测脚本的思路大致是这样import threading import time import redis r redis.Redis(hostlocalhost, port6379) DB 10 # 模拟数据库里的最新值 def worker(): # 模拟大量并发读同一个热点key for _ in range(100): r.get(hot:key) # 检查回填后的值是否一致 value r.get(hot:key) assert value str(DB), fvalue mismatch: {value} threads [threading.Thread(targetworker) for _ in range(50)] for t in threads: t.start() for t in threads: t.join()压测时要重点观察两个数据一是热点key过期时数据库QPS的峰值能不能被限流和互斥控制在可接受范围内二是所有线程回填结束后缓存里的值和数据库的期望值是不是一致的。如果峰值爆表说明回填的互斥控制没做好如果值对不上说明并发覆盖问题真的存在。6.3 上线前的一致性自查清单每个缓存Key都要回答这五个问题我团队内部有一个习惯任何涉及缓存变更的上线都必须让开发同学逐条回答一张自查清单。这张清单帮我拦下了不少潜在事故。检查项说明这个缓存key有明确的过期时间吗没有过期时间的缓存一旦出现不一致只能靠手动修复等于埋雷。key失效时会不会形成热点击穿如果是热点key有没有互斥回填或后台续期方案数据更新后缓存如何失效是同步删除、延迟双删、还是消息异步删除链路清晰吗删除缓存失败有补偿吗有没有重试队列或者binlog监听兜底如果没有上线后出事只能靠人肉。缓存读不到时回填是否有限流和互斥如果没有热点失效瞬间的并发冲击谁来挡如果某个key对五个问题中的任何一个回答不了那就说明这个缓存的设计还没到位。不要急着上线先补上缺失的环节。6.4 线上缓存治理的日常节奏最后聊一聊治理节奏的问题。缓存一致性不是上线那天处理完就结束了它会随着业务演进不断冒出新问题。新增一个缓存key的时候大家通常会认真讨论过期时间和失效方式但三个月后某个后台同学加了一个批量更新任务直接把数据库改了缓存链条上的所有人都会措手不及。我的习惯是每个月拉一次缓存治理专项把线上所有的核心缓存key列出来逐一确认它们的TTL设置、失效方式、补偿链路是否还在生效。重点排查那些“很久没动过”的缓存配置它们往往是最容易被遗忘、也最容易出事的。同时每次发布涉及数据库变更时顺手过一遍binlog消费链路的状态确认消息延迟正常、消费积压没有飙升。这个专项看起来琐碎但在故障预防上的性价比极高。那些半夜两点让你爬起来删缓存的故障绝大多数不是因为方案不对而是因为链条里某个环节静默失效了。日常多看一眼线上就少熬一次夜。做了几年缓存治理我最大的体会是分布式缓存一致性不是一个“彻底解决”的问题而是一个“把不确定窗口控制到可接受范围”的问题。选好更新姿势做好过期兜底配好补偿链路盯住关键指标剩下的就交给时间和监控去消化。最后分享一个我一直在用的习惯每次上线涉及缓存的变更我都会在发布单里附一段缓存影响说明写清楚哪些key会失效、失效窗口多大、补偿链路是什么、监控面板看哪里。这个习惯救过我很多次也希望你下次遇到类似问题时不用再凌晨爬起来用redis-cli手动删那些本应自动失效的key。