
1. 缓存一致性问题的根源不是选型问题是时序问题1.1 为什么缓存会出现不一致先从一个我真实踩过的坑说起。做秒杀活动压测的时候运营在后台把商品价格从99改到79前台商品页却还固执地展示着99。用户下单结算时按79走了最后对账发现少了几块钱的营收排查半天才定位到是缓存没更新。这种问题在分布式系统里非常典型但凡你的服务里引入了Redis这类缓存组件就一定会面对它缓存里放的数据和数据库里真实的状态总有可能在某一个瞬间对不上。之所以一定会出现是因为缓存本质上是一个“冗余副本”。数据库是权威数据源缓存是为了扛住高并发读而临时存放的一份数据拷贝。既然是拷贝那就天然存在同步问题你写数据库的操作和写缓存的操作不可能做到原子性。比如更新订单状态时先更新了数据库还没来得及删缓存进程重启了缓存里就永远是旧值再比如先删了缓存还没来得及写数据库另一个线程发现缓存miss回源把旧值又读回去了缓存里又变成了旧数据。很多人觉得只要把“更新缓存”和“更新数据库”的顺序理清楚就完事了其实没那么简单。问题的本质是时序窗口而不是操作顺序。缓存与数据库之间永远存在一个时间差你做任何操作这个时间差都不会归零只能想办法把窗口压缩到业务可接受的范围或者在窗口期内发生错误时能自动补偿。1.2 即使按照“正确顺序”操作仍然可能不一致最常见的方案是更新数据库后删除缓存也就是业内常说的Cache Aside模式。这个方向本身是对的它利用了缓存的“懒加载”特性读到miss时回源数据库回填缓存这样缓存里存的永远是最近一次被访问的数据不会有主动更新的额外开销。但“先写库、后删缓存”这八个字背后有三个隐藏问题第一删除缓存这个动作本身可能失败。Redis瞬间连接超时或者网络抖动或者Redis主从切换期间写入失败缓存删除操作就被吞掉了。一次删除失败这条数据就永远停留在旧值状态直到缓存被LRU淘汰或者手动清理。第二并发窗口期内的脏读问题。写线程更新了数据库但还没删缓存此时大量读请求打到缓存上读到的都是旧值。这个窗口可以短到微秒级也可以长到秒级取决于你删除缓存的耗时和数据库事务的提交时间。第三删除后又被旧值回填。线程A删除缓存线程B此刻正在执行读操作发现缓存miss于是回源数据库读取。如果线程B读到的还是旧数据比如读到了从库而主从同步还没完成那它会把旧值重新写回缓存。这下缓存又脏了而且这个脏数据可能存活很长时间。所以缓存一致性不是一个“做了就行”的事情而是一个需要持续治理、持续补偿的系统工程。理解了这三类问题后面的方案选择、参数设计、监控告警才有依据。2. 方案选型从删缓存的副作用到binlog订阅怎么选才踏实2.1 主流方案对比没有银弹只有取舍先把目前主流的技术路线罗列出来你在面试或者做技术方案时大概率也绕不开这几条方案核心理念一致性强度延迟实现成本适用场景Cache Aside读miss回源写库后删缓存最终一致存在窗口低低绝大多数互联网场景延迟双删写库后延迟一段时间再删一次最终一致窗口缩短低低缓存更新频繁、并发读高的数据Read/Write Through缓存作为主存储异步同步DB最终一致中中缓存框架内置如LocalCacheWrite Behind先写缓存异步批量写DB弱一致可能丢更新高中写多读少、允许丢失部分数据binlog订阅监听DB变更日志同步删除/更新缓存最终一致延迟可控低高强治理要求、数据一致性敏感的核心链路我个人在实际项目中用得最多的是Cache Aside 延迟双删 binlog订阅兜底的组合。前两者负责解决大部分一致性窗口问题后者负责在极端情况比如删缓存失败、并发回填旧值发生时做最终补偿。2.2 延迟双删到底有没有用延迟双删的思路非常朴素既然删除缓存后可能被旧值回填那我就在写库之后延迟一段时间再删一次缓存把回填的旧值再次清掉这样最终缓存里存的是最新数据。这里最关键的参数是延迟时间。它怎么取我的经验是延迟时间 主从同步耗时P99 业务容忍窗口。如果你们的MySQL主从延迟P99是200ms业务能接受的最长脏数据窗口是500ms那么延迟时间建议取500ms到1s之间。取太短可能旧值还没回填完就把第二次删除执行完起不到兜底效果取太长延迟窗口内缓存一直被旧数据占用影响业务时效。我见过不少团队把这个延迟时间写死成500ms这其实不够严谨。更合理的做法是用一个可配置的参数并且用主从延迟监控指标来动态调整。生产环境里主从延迟不是一个固定值它会随着大事务、DDL、慢SQL、网络抖动而波动所以延迟双删只能“缩短不一致窗口”不能“消灭不一致窗口”。2.3 数据库binlog订阅方案的优势与代价延迟双删解决的是“缓存被旧值污染”的常规问题但它解决不了“删除操作失败、没有任何线程发现”的隐蔽问题。这时候binlog订阅方案的价值就体现出来了。原理不难理解MySQL的binlog记录着所有数据变更事件我们通过Canal这类组件把自己伪装成一个MySQL从库实时接收binlog解析出具体是哪张表、哪个主键、执行了什么操作然后根据事件内容去删除对应的Redis缓存键。这样就算业务代码里删除缓存失败binlog通道依然会触发一次补偿删除。这个方案提供的是一种旁路治理能力它不侵入业务代码对性能几乎无影响。代价是引入了一套新的中间件和链路Canal需要独立部署消费端需要做幂等、重试和积压监控。另外它只能保证“MySQL数据变更后缓存一定会被删”但如果你在删除缓存的瞬间又发生了并发读回填那仍然可能出现缓存里是旧值的情况。所以binlog订阅方案通常会和短TTL兜底一起用。我给缓存设置一个随机TTL比如5分钟到10分钟之间的随机值即使所有补偿机制都失效了最坏情况下缓存也会自然过期然后重新回源数据库。这是最后一道防线。2.4 分布式锁、分布式事务与缓存一致性的关系热词里经常把分布式锁、分布式事务和缓存一致性放在一起聊因为它们确实是三类相互关联但层次不同的工具。设计一个支付扣款场景用户余额在数据库里Redis里有余额的快照用于展示。扣款时如果只更新数据库不更新Redis那用户看到的余额就一直是旧的。如果先更新Redis再更新数据库Redis写成功了数据库写失败了数据就乱了。这种场景下很多方案会选择在更新操作上加一把分布式锁确保同一时间只有一个线程在执行“更新数据库更新缓存”的组合操作。锁本身不解决问题它负责把并发变得串行让“写库→删除缓存”这个过程不被其他写操作打断。但分布式锁只能解决并发写之间的互斥解决不了“写一半失败”的问题。要保证写库和删缓存两个动作“要么都成功要么都失败”那就需要分布式事务了。实践中很少为了删个缓存去做强一致事务成本太高也不符合缓存的定位。更务实的思路是用事务消息或者本地消息表数据库事务提交成功后往消息队列里发一条“删除缓存”的消息由消费者来执行删除。消息的投递和重试机制保证了删除操作最终会被执行。所以我的理解是分布式锁治并发分布式事务治原子性缓存一致性则是前两者落地后在数据层面呈现的结果。它们的最终目标其实是同一个——让多个数据副本在用户可感知的时间范围内保持一致。3. 实操落地延迟双删、版本号与binlog订阅的完整实现3.1 前置条件与组件准备先交代一下我这边使用的基础环境大家对照准备MySQL 8.0开启binlog日志格式为ROWRedis 6.x单实例或主从架构均可Canal 1.1.5用于binlog解析Java 11Spring Boot 2.7集成Spring Data Redis和MyBatis这里说明一下为什么要选ROW格式的binlog。STATEMENT格式记录的是SQL语句本身对于一条UPDATE语句它可能影响多行数据Canal解析后无法准确知道具体涉及哪些主键。而ROW格式记录的是每行数据变更前后的值Canal能精准拿到变更行的主键、字段这样删缓存时能精确到具体key不会误删。生产环境我强烈建议直接用ROW格式。3.2 延迟双删的代码实现细节先给一个最朴素的实现版本工具类方法public void updateProductWithCache(Product product) { // 第一步更新数据库 productMapper.updateById(product); // 第二步立即删除缓存 String cacheKey product:detail: product.getId(); redisTemplate.delete(cacheKey); // 第三步延迟二次删除 long delayMillis 800L; ScheduledExecutorService executor Executors.newScheduledThreadPool(1); executor.schedule(() - { redisTemplate.delete(cacheKey); }, delayMillis, TimeUnit.MILLISECONDS); }这段代码写着顺手但上线前一定要考虑三点第一延迟时间不能拍脑袋定。前面已经说过它要大于主从同步耗时加上业务容忍窗口。我在实际项目里会先跑一段时间主从延迟监控取P99值后再加上300~500ms的安全余量。第二异步线程池要复用不能“用一次建一次”。上面用Executors新建线程池只是演示生产环境建议用Spring自带的ThreadPoolTaskScheduler统一管理并且注意线程池的拒绝策略避免高并发时大量任务堆积拖垮JVM。第三删除操作失败要有一个补偿出口。延迟删除时如果Redis又连接超时这次删除就丢了。我的做法是在删除缓存的方法里捕获异常把key写入一个本地内存队列或Redis的Stream里由定时任务扫描重试。这个做法就是最简单的本地消息表雏形。3.3 版本号校验机制让缓存和数据库对暗号延迟双删能缩短不一致窗口但它的核心短板是删除缓存是一个“盲删”操作删除时并不关心缓存里的value是不是当前这条数据的最新版本。如果中间发生了并发回填第二次删掉的可能是别人刚刚写入的新值。一个更精细的做法是在缓存value里带上一个版本号。比如订单数据在数据库里有一个update_time或者version字段写入缓存时把version一起存进去。读缓存时不仅检查缓存是否存在还要检查缓存里的version是否与数据库一致。但这个方案在纯缓存体系里不好落地因为每次读都要查一次数据库就失去了缓存的意义。我的实践是结合binlog实现版本比对Canal解析到数据变更时不仅删除缓存还顺带把当前数据的版本号同步到Redis的一个独立key里。业务读缓存时只做一次快速校验把缓存里的version和独立版本key做对比不一致就视为过期重新回源。这样既避免了实时查库又能在 binlog同步延迟极短的时间内发现脏数据。这套机制我用在订单详情这类“强读一致性”场景上效果明显但代价是缓存结构变复杂了运维监控的粒度也得更细。所以版本号机制不要滥用只在业务真正敏感的数据上启用。3.4 binlog订阅消费端的实现与幂等处理Canal的部署和配置不是这篇的重点我重点说消费端的实现。Canal把binlog事件投递给消费端后本质上是一条MQ消息。消费端接收到消息后先解析出tableName和主键ID然后删除对应的缓存key。伪代码如下public void onMessage(CanalEntry.Entry entry) { // 1. 解析事件类型只有INSERT/UPDATE/DELETE需要处理 CanalEntry.EventType eventType entry.getEventType(); if (eventType CanalEntry.EventType.QUERY) { return; } // 2. 提取表名和变更行的主键 String tableName entry.getHeader().getTableName(); ListCanalEntry.RowData rowDatasList CanalEntry.RowChange.parseFrom(entry.getStoreValue()).getRowDatasList(); // 3. 按表名组装缓存key并删除 for (CanalEntry.RowData rowData : rowDatasList) { String primaryKey extractPrimaryKey(rowData, tableName); String cacheKey buildCacheKey(tableName, primaryKey); redisTemplate.delete(cacheKey); } }这代码看起来简单但生产上最容易翻车的点不在解析而在消费语义上。binlog事件投递到MQ之后消费端可能重复消费比如网络抖动导致ACK丢失MQ会把同一条消息再投递一次。删除缓存这个操作天然是幂等的重复删除同一key不会出错但如果你在消费端做的是“读取binlog里的最新值并回填缓存”那就会遇到消息乱序导致旧值覆盖新值的问题。所以我的建议是binlog消费端只做删除操作不做回填操作把回填交给读请求触发。这样天然幂等也不用关心顺序问题。3.5 核心参数配置与选择逻辑把几个我经常调整的关键参数列出来都是经验值供你参考参数推荐值选择逻辑延迟双删时间500ms ~ 1s主从延迟P99 业务容忍窗口Redis缓存TTL5分钟 ~ 30分钟加随机扰动兜底防永久脏数据避免缓存雪崩Canal消费线程数核心线程数2最大4按binlog事件量调整避免消费积压删除重试次数3次间隔1s/5s/30s超过3次进入死信队列告警人工处理Redis连接超时200ms过短容易误判失败过长影响删除时效这里有一个我踩过的坑Redis的删除操作并发太高时单条删除反而会成为瓶颈。所以涉及到批量修改比如批量上下架商品不要一条条del而是用Pipeline或者Lua脚本批量提交把几十次RTT压缩成一次网络往返性能差距是数量级的。另外关于缓存TTL设置随机扰动这一条我在做活动页时曾经把所有商品缓存都设置成相同的10分钟过期结果到了过期时间点大量请求同时穿透到数据库数据库瞬间被打挂。这就是缓存雪崩的典型场景。后来我把TTL改成5到10分钟的随机值同一时刻过期的key数量大幅减少压力自然就摊平了。4. 常见问题与排查技巧实录4.1 偶发不一致压测时才暴露这类问题最恶心线上偶尔出现一两条数据对不上平时流量不大察觉不到一压测就频繁冒出来。我遇到过一起典型的案例用户修改头像后有些请求还能看到旧头像。排查链路显示写接口先更新数据库再删除缓存看起来逻辑没问题。但压测时QPS一上去主从延迟从50ms飙升到400ms而我们的延迟双删写死了300ms于是经常出现旧值回填成功后第二次删除还没执行的情况脏数据被反复读。解决思路有三个方向一是把延迟双删时间改为主从延迟P99动态调整二是在这个场景里对读请求做强制主库读比如通过一个标记位在数据刚刚更新后的1秒内强制读主库避开从库同步延迟三是上线前压测时就要盯住主从延迟指标很多缓存不一致的苗头其实在压测阶段就能发现。4.2 缓存命中率暴跌数据却一致了有次我们优化订单列表把原来的“更新缓存”改成“删除缓存读时回填”结果订单详情接口的响应时间直接翻倍。原因是读多写少场景下删除缓存会让所有读请求都miss返回源头去查数据库代价比更新缓存要高得多。方案本身没错但用错了场景。读多写少的数据删除缓存会让缓存形同虚设应该用更新缓存而不是删除缓存。但更新缓存要解决并发更新乱序问题我的做法是给每条数据加版本号更新缓存前先判断新值的版本号是否大于缓存里的版本号避免旧值覆盖新值。如果你们的Redis支持事务或Lua也可以用Lua脚本在服务端原子地完成“读取旧版本号→比较→写入新值”这一整个流程这样不会出现并发写覆盖的问题。4.3 并发写覆盖导致Redis里的值来回跳模拟一个场景两个管理员同时编辑同一个商品的价格A改成100B改成120。如果两个人的操作都走了“写库→删缓存”那么最终数据库里是120缓存可能是100。为什么因为A的写库操作和B的写库操作在数据库层面是串行的但删缓存的操作在两个线程里可能交错执行A删完缓存后B的读请求回填了100B删完缓存后A的读请求又回填了120。解决这类覆盖问题核心是让“同一数据的变更操作”在缓存层面串行化。我在项目中用过两种方法一种是在更新数据时先获取一把分布式锁Redis的SETNX拿到锁的线程才能执行“写库删缓存”没拿到的线程等待或者直接返回重试另一种是引入一个按业务主键分区的写队列同一个商品ID的写操作进入同一个队列消费从源头保证顺序。4.4 排查工具与定位方法遇到缓存不一致问题不要一上来就翻代码我先写一套标准排查流程第一步对比数据查数据库当前值再查Redis缓存值确定不一致的方向和字段。第二步看缓存命中与回源时间在Redis里给key设置过期时间后通过Redis的OBJECT IDLETIME命令看这个key最后一次被访问是什么时候判断脏数据存续了多久。第三步查看binlog消费进度如果使用了Canal看消费端有没有积压。Canal积压意味着缓存删除了但没及时执行自然会出现不一致。第四步查业务日志中的“删除缓存异常”记录很多问题并不神秘就是删除Redis的异常被吞掉了。我会在代码里把删除缓存的异常打印出来并且统计异常率一旦突增马上能感知。第五步用对账脚本兜底对于核心数据比如库存、余额我会写一个定时对账任务每隔几分钟扫描一部分热点数据对比数据库和缓存发现不一致就告警并自动回填修复。最后放一个速查表现象可能原因排查方向缓存值永不变删除缓存失败且无兜底检查异常日志、重试队列、Canal消费时好时坏主从延迟导致旧值回填强制主库读、延迟双删动态调参热点key一过期就崩缓存击穿加分布式锁回填、多级缓存大量key同时过期TTL固定无扰动随机TTL、错峰过期操作后缓存跟DB不一并发写覆盖分布式锁、版本号比对5. 缓存治理与工程落地一致性不只是技术方案5.1 订单与库存场景的缓存一致实践把场景落到具体业务上订单和库存的缓存一致性是很多电商系统的核心痛点。扣减库存时如果是“先扣Redis再异步扣减数据库”那Redis扣减成功了数据库扣减失败就会出现超卖。所以我的做法是Redis扣减库存用Lua脚本原子操作扣减成功之后发送一条事务消息到消息队列由消息消费者在数据库里执行真正的库存扣减。如果数据库扣减失败消息重试多次仍然失败则通过消息回查机制反向恢复Redis库存并告警。这种设计本质上就是用最终一致性来保底。它在Redis和数据库之间插入了一个异步消息通道这个通道天然削峰、天然重试避免了两边状态同时变更的强依赖。用户看到的库存是Redis里的实时值可能略微乐观但真正的库存准确性由DB和消息链路保证最终会收敛到一致状态。5.2 缓存穿透、击穿、雪崩与一致性的关系这仨在热词里出现频率很高但它们和缓存一致性不是一回事是另一层面的问题。穿透是指查询一个不存在的key每次都会打到数据库击穿是指某个热点key过期的瞬间大量请求集中访问雪崩是指大量key同时过期导致数据库压力暴涨。但它们和一致性会交织当你用“缓存回填”机制处理击穿问题时回填的时机、回填的数据源、回填后是否校验版本都会影响一致性。我一般建议热点key回填时加一把分布式锁同一时间只有一个线程去查库回填其他线程阻塞等待或者返回旧缓存避免多个线程把不同版本的旧值回填进去。对于穿透问题把查询结果为null的情况也缓存一份但TTL要设短比如30秒到60秒不然数据库里新增了数据缓存里却还是空值反而制造新的不一致。5.3 可观测性建设一致性不能靠运气最后是治理层面的事。很多团队把缓存一致性当成“上线前配置好就完事”的工作这远远不够。我的经验是至少要建立三类指标第一类是缓存与数据库的偏差率。可以通过定时对账任务抽样对比核心数据的两份值统计不一致的比例。这个指标应该是缓慢下降并趋近于零的一旦升高就该排查是否引入了新的写入路径。第二类是缓存删除成功率与耗时。删除缓存这个动作虽然是基础操作但它直接影响一致性所以要监控它的失败率和P99耗时。失败率突然升高往往是Redis连接出问题或者网络抖动。第三类是缓存命中率和回源率。命中率下降不是bug但它意味着大量请求打到了数据库如果数据库扛不住回源率上升本身就是故障的预警信号。结合回源率来调整缓存策略、TTL、预热任务这是缓存治理的日常操作。可观测的关键不是堆指标而是让每一类变更都有对应的反馈信号你改了延迟双删的参数第二天能看到偏差率下降你上线了binlog订阅能看到删除失败率收敛到零。凭感觉调参、不建立反馈闭环的话缓存一致性永远是薛定谔的状态。我在实际项目里落地了一年多最终的组合是业务核心数据走binlog订阅 短TTL兜底非核心数据走延迟双删 自动补偿读多写少的数据用版本号更新所有热点key统一加随机TTL防雪崩。这个组合不是最优雅的但实测下来最稳——它把每一种不一致的可能都安排了至少一条兜底路径。缓存一致性这件事真正的门槛不在方案选型而在你愿不愿意为每一个可能出错的环节留好后手。