第一章临床资料与现象描述小D老K出大事了昨晚我们进行了一次大促压测刚开始一切正常但压力加到一定程度后商品详情页接口突然大面积超时监控面板上数据库的连接数瞬间打满CPU 100%。更诡异的是有几个测试账号反馈明明已经下单买了东西刷新页面后商品的库存竟然变回了下单前的数量感觉像是“白嫖”成功了老K别急先喝口水把详细情况说一下。你们用了什么架构压测的具体参数是多少小D我们用的是标准的三层架构接入层Nginx Lua做简单限流应用层Spring Boot 微服务集群核心是商品服务数据层MySQL主库库存最终一致性从库读Redis Cluster核心缓存存储商品基本信息、库存等热点数据。业务逻辑也很清晰就是经典的“Cache Aside Pattern”读请求先读 Redis命中则直接返回未命中则查 DB将结果写回 Redis再返回。写请求下单减库存先更新 DB 中的库存开启事务。更新成功后删除对应的 Redis 缓存 Key。事务提交。压测数据5000 并发用户持续 10 分钟。刚开始 5 分钟非常平稳平均响应时间 50ms。从第 6 分钟开始接口超时5s比例从 0% 骤升到 35%然后系统就崩溃了。第二章紧急会诊与现场止血老K典型的“缓存雪崩”前兆。但你说的“库存反弹”问题比单纯的雪崩更复杂这涉及到数据一致性了。我们先止血再排查病因。小D我们已经紧急重启了部分服务并切断了压测流量系统恢复了。现在想复盘到底哪里出了问题。老K很好。我们来看数据。你把压测那段时间的监控指标调出来Redis 监控QPS、命中率、网络延迟、慢日志。MySQL 监控QPS、Threads_running、锁等待情况。应用服务 GC 日志和CPU 使用率。小D调出监控Redis 方面在故障发生前 30 秒有一个明显的网络抖动TCP 重传率飙升持续了约 5 秒。之后Redis 的 QPS 瞬间跌落到几乎为 0但几秒后又暴涨到平时的 10 倍。命中率从 95% 直线下降到 10%。MySQL 方面在 Redis 抖动后约 1 秒Threads_running 从 50 飙升到 2000大量是“更新库存”和“查询商品详情”的 SQL。有很多长时间的锁等待。GC 日志显示故障期间应用服务频繁发生 Full GC每次停顿好几秒。老K指着屏幕破案的关键就在这里。这是一场由“网络抖动”引发的连锁反应最终导致了“缓存雪崩”与“数据不一致”的双重灾难。小D我们来一步步还原事故现场。第三章病理剖析——从微小抖动到系统崩溃第一阶段蝴蝶效应——网络抖动第 5 分 30 秒老K最初的那 5 秒网络抖动是点燃一切的导火索。在分布式系统中网络是不可靠的。小D不就是几秒的延迟吗为什么会导致后面那么大的问题老K关键在于Redis Client 和 Server 之间的连接以及 Tomcat 线程池的处理逻辑都受到了影响。对于正在执行的“读请求”假设有 100 个 Tomcat 线程同时发起了GET key命令到 Redis。因为网络抖动这些命令在 TCP 层面超时了。你们的 Redis 客户端假设是 Jedis 或 Lettuce是怎么配置的超时策略小D默认配置好像是 2 秒超时。老K问题就在这里。在网络抖动期间这 100 个线程不会立即返回。它们会阻塞等待 Redis 的响应直到 2 秒后才会抛出异常。这 100 个线程就被“挂起”了 2 秒。对于正在执行的“写请求”同样一个“下单”操作在执行DEL cache命令时也可能因为网络抖动而阻塞。关键推论 1短暂的网络抖动导致大量应用线程因等待 I/O 而阻塞Tomcat 线程池正在被快速耗尽。第二阶段连锁反应——缓存击穿与雪崩的形成第 5 分 35 秒 - 第 6 分钟老K2 秒的超时时间到了那 100 个线程获取到了什么是数据吗小D不是是超时异常。所以它们没有拿到缓存数据。老K没错。这时“缓存击穿”发生了。这些线程发现缓存未命中实际是访问失败按照你的代码逻辑它们会去查询数据库。java// 简化后的业务代码 public Product getProductById(Long productId) { // 1. 查询缓存 String cacheKey product: productId; String cacheValue redisClient.get(cacheKey); // 2. 如果缓存未命中或访问失败查DB if (cacheValue null) { // 问题就在这里访问Redis失败抛出异常导致cacheValue也为null // 于是所有线程都认为缓存是空的全部涌入DB Product product productMapper.selectById(productId); if (product ! null) { // 3. 回写缓存 redisClient.set(cacheKey, product); } return product; } // ... 反序列化并返回 }小D我明白了那 100 个阻塞的线程刚释放就同时涌向了数据库。但这只有 100 个线程怎么会把数据库打爆我们压测可是 5000 并发啊。老K问得好。Tomcat 默认线程池配置是多少比如server.tomcat.max-threads200。当最初的 100 个线程因等待 Redis 而阻塞时新的请求进来Tomcat 会创建新的线程来处理直到达到最大线程数 200。当网络抖动结束那 100 个旧线程因为超时而被释放它们立刻去查 DB。而此时后面因为线程池打满而在等待队列中积压的 1000 个请求假设队列容量 1000它们执行的也是getProductById方法。这 200 个活跃线程100 旧100 新查 DB 的时候因为 DB 查询相对较慢十几毫秒它们也会短暂阻塞在 DB 的 I/O 上。但它们返回后会执行redisClient.set(cacheKey, product)。问题来了此时 Redis 网络已经恢复但面对的是 200 个线程同时对一个 Key 进行SET操作这没问题。但是当一个线程查完 DB把数据写回缓存后其他线程按理说应该能命中缓存了为什么雪崩还在继续小D呃...是因为那个商品 Key 特别热老K这是一个因素。但更关键的是“时间差”。假设第一个线程查 DB 用了 50ms它把数据写回 Redis。但在这 50ms 内可能已经有几百个请求也穿透到了 DB。它们不会因为你第一个线程写回了缓存就停止因为它们已经越过了if (cacheValue null)这道坎正在执行 DB 查询。这就是经典的“惊群效应”。关键推论 2网络抖动导致缓存访问失败被业务代码错误地等同于“缓存未命中”引发了大规模的并发 DB 查询。即使缓存恢复也无法阻止已经穿透的流量。第三阶段死亡螺旋——数据库被打满与 GC 风暴第 6 分钟开始老K现在200 个线程都在查 DB。MySQL 的连接池瞬间被占满。新的请求进来获取不到 DB 连接会怎么样小D会等待或者超时。线程又会阻塞在等待 DB 连接上。老K对。线程池中的线程全部阻塞要么等 Redis要么等 DB。Tomcat 的 Accept 队列满了开始拒绝连接。同时因为请求处理不完积压的对象越来越多年轻代迅速填满触发 Minor GC。Minor GC 后大量存活的对象被晋升到老年代很快老年代也满了触发 Full GC。Full GC 会“Stop The World”暂停所有应用线程。这又进一步加剧了请求的超时和堆积。系统彻底进入“死亡螺旋”。小D太可怕了一个小小的网络抖动威力这么大。第四阶段幽灵重现——“库存反弹”的秘密老K我们再来剖析最诡异的“库存反弹”问题。这比系统崩溃更隐蔽因为它破坏了业务数据的正确性。小D是啊我明明看到下单接口返回成功了数据库里库存也减少了为什么刷新页面后库存又变回原来的了老K我们结合“下单减库存”的代码和刚才的雪崩场景来分析。你的下单逻辑是“先更新 DB后删缓存”。这在正常情况下能保证最终一致性。但在并发和异常环境下它存在一个著名的“Bug 窗口”。假设库存只有 1 件。用户 A 和用户 B 同时下单。时刻 1 (T1):用户 A 的请求进入开启事务更新 DB 中的库存从 1 减到 0。事务尚未提交。时刻 2 (T2):用户 B 的请求进入开启事务。它去更新库存时发现库存为 0因为 A 的事务虽然未提交但UPDATE语句会加行锁B 会被阻塞等待 A 释放锁。这里 B 是阻塞的没问题。时刻 3 (T3):用户 A 的事务准备提交。在提交前它执行了redisClient.del(cacheKey)把缓存删掉了。时刻 4 (T4):用户 A 的事务提交释放锁。此时按道理缓存是空的。下一个读请求会查 DB 得到库存 0然后回写缓存一切正常。但“库存反弹”是怎么发生的需要引入一个并发“读”请求 C。老K看这个时序图边说边画时间线程 A (写-下单)线程 B (写-下单并发)线程 C (读-查询)Redis 状态 (Key:库存)DB 库存T1UPDATE stock0 WHERE id1(行锁)11T2UPDATE stock0 WHERE id1(阻塞)11T3DEL cache(阻塞)null(已删除)1T4事务提交(释放锁)null0T5获取到锁执行UPDATEnull0T6此时库存已经是 0更新无效或返回null0T7DEL cache(提交前)null0T8事务提交null0T9读请求 C 开始T10GET cache- nullnull0T11查 DB- 读取到库存0T12SET cache, 0(准备回写)到这里一切正常应该返回库存 0老K上面的流程是正常的。要实现“库存反弹”必须让读请求 C读到旧数据并用旧数据覆盖了缓存。什么时候能读到旧数据就在 T3 和 T4 之间也就是“删缓存” 和 “事务提交” 之间的间隙。修正后的时序图库存反弹版时间线程 A (写-下单)线程 C (读-查询)Redis 状态 (Key:库存)DB 库存T1UPDATE stock0 WHERE id1(行锁)11T2DEL cachenull1T3事务尚未提交 (此时 DB 库存还是 1)读请求 C 开始null1T4GET cache- nullnull1T5查 DBT6因为 A 的事务未提交C 读取的是快照读 (或未提交数据取决于隔离级别)库存是1(旧数据)null1T7SET cache, 1—— 罪魁祸首用旧数据覆盖了缓存1 (旧数据)1T8事务提交1 (旧数据)0T9请求结束返回给用户库存 11 (旧数据)0小D我懂了在 T3 到 T7 这个极短的时间窗口内一个读请求进来了。它发现缓存是空的因为刚被 A 删掉于是去查 DB。而此时写事务 A 还没提交所以它读到了 DB 中的旧数据库存1。然后它把这个旧数据重新写回了 Redis。等写事务 A 提交后DB 已经变成了 0但 Redis 里却还是 1。后续所有的读请求都会优先从 Redis 里拿到这个错误的库存 1直到它过期或被再次删除。这就是“库存反弹”的真相老K没错。在缓存雪崩的背景下这个时间窗口被无限放大。因为系统负载高事务执行慢线程调度不确定读请求更容易在这个窗口内插入。同时大量穿透的读请求也在不断地重复“查旧 DB写脏缓存”的过程固化了错误数据。关键推论 3“先更新 DB后删缓存”的模式在读写并发且无法保证强一致性的场景下存在天然的“脏数据”时间窗口。故障期间这个窗口被放大导致了严重的数据不一致。第四章治疗方案——从治标到治本小D原来如此。那我们现在该怎么修复是不是只要把网络抖动的问题解决了就行了老K网络抖动是诱因但不是根本。我们不能假设网络永远稳定。要从架构和代码层面对系统进行“免疫”改造。我们分三步走紧急治标、缓兵之计、根治之策。方案一紧急治标针对当前故障的快速修复优化缓存访问异常处理问题代码将Redis 访问异常等同于缓存未命中引发了击穿。修复捕获异常区分对待。如果是网络超时等异常不应该去查 DB而是应该直接返回错误或返回旧数据如果本地有缓存或者进行重试。原则不能让一个外部基础设施的故障拖垮整个应用和数据库。javapublic Product getProductById(Long productId) { String cacheKey product: productId; try { String cacheValue redisClient.get(cacheKey); if (cacheValue ! null) { return deserialize(cacheValue); } } catch (RedisConnectionException | TimeoutException e) { // 1. 记录告警 // 2. 降级处理直接查DB但不尝试回写缓存或者返回一个默认的“热销中”商品 // 这里最简单的做法是直接抛一个限流/降级异常让前端稍后重试。 // 绝对不能默默地认为 cacheValue null 而去查DB。 throw new ServiceUnavailableException(系统繁忙请稍后重试); } // 只有明确返回nullKey不存在才查DB Product product productMapper.selectById(productId); if (product ! null) { // 回写缓存时要设置一个合理的过期时间防止缓存永远不变 redisClient.setex(cacheKey, 3600, serialize(product)); } return product; }设置合理的 Jedis/Lettuce 连接池和超时时间超时时间不能太长否则会耗尽 Tomcat 线程。一般建议 200ms-500ms。连接池的最大等待时间也要设置避免无限阻塞。增加数据库连接池大小监控一旦发现活跃连接数超过阈值立即报警。方案二缓兵之计缓解雪崩与一致性问题针对缓存雪崩本地缓存多级缓存在应用内存中如 Caffeine再加一层缓存。读流程读本地缓存 - 读 Redis - 读 DB。写流程更新 DB 后除了删除 Redis 缓存还要广播通知所有应用实例删除本地缓存可使用 Redis Pub/Sub 或 MQ。好处即使 Redis 抖动本地缓存依然可用能抵挡绝大部分读请求保护 DB。这相当于给系统加了“安全气囊”。请求合并Hystrix/ Sentinel对于同一个 Key在极端并发情况下只允许一个请求去查 DB其他请求等待那个请求的结果。这可以有效解决“惊群效应”。针对数据不一致延迟双删策略针对“先删缓存后更新DB”导致的不一致可以在更新 DB 后再休眠一小段时间如 500ms再次删除缓存。流程删缓存 - 更新 DB -休眠 - 再次删缓存。原理第二次删除就是为了干掉在读请求在“窗口期”内写进去的脏数据。缺点休眠会阻塞写操作线程降低写性能休眠时间难以确定只能说是“大概率”删除。将“删除缓存”改为“更新缓存”更新 DB 后不删除而是直接更新缓存。问题并发写的情况下如果先更新的 DB 后更新缓存会导致缓存和 DB 数据不一致乱序。通常需要配合分布式锁复杂度高不推荐。小D这些方案感觉都有一些取舍要么影响性能要么不能完全保证一致性。方案三根治之策架构级解决方案老K没错。前面都是“修修补补”。要彻底解决“先操作 DB后操作缓存”这种分布式事务带来的数据一致性问题我们需要引入更强大的武器Change Data Capture (CDC) 和最终一致性思想的重构。核心思路让应用只关注 DB 操作缓存通过异步监听 DB 的变更日志来更新。这样业务代码和缓存操作彻底解耦不再有“双写”一致性的烦恼。新架构流程写请求下单应用只做一件事开启事务更新 DB 中的库存。事务提交。完全不碰 Redis 缓存这样就彻底关闭了那个导致脏数据的时间窗口。Binlog 监听组件Canal有一个独立的服务或线程伪装成 MySQL 从库实时接收 MySQL 的 Binlog (Binary Log) 变更日志。它解析出“库存表”的数据变更比如ID为1的商品库存从1变为0。消息队列MQCanal 将解析出的变更事件是一个数据变更消息发送到 RocketMQ 或 Kafka 的一个特定 Topic 中。缓存更新消费服务一个独立的消费服务从 MQ 中拉取“库存变更”消息。根据消息内容执行redisClient.setex(cacheKey, newValue)直接更新缓存而不是删除。关键点消费服务可以配合重试机制和消息幂等性设计确保缓存最终会被更新成最新值。小D这样就能保证一致性了吗如果消费服务更新缓存失败怎么办老K这就是“最终一致性”的保障。如果更新失败消息会进入 MQ 的重试队列不断重试直到成功。如果重试多次还是失败比如 Redis 挂了可以落盘存储并触发报警人工介入。好处分析彻底解耦业务代码不再关心缓存逻辑更简单性能更好少了一次网络开销。强数据一致性保证最终只要 Binlog 不丢MQ 消息不丢消费程序可靠缓存最终一定和 DB 一致。彻底解决了“读写并发”导致的时间窗口问题。抗压能力更强缓存更新是异步的不会影响主业务流程。业务透明任何需要基于数据变更做其他事情比如同步到搜索引擎、大数据平台都可以复用这个 Binlog 流。小D这个架构听起来完美多了那对于“读请求”呢老K读请求就变得非常简单直接读 Redis。如果命中直接返回。如果未命中比如缓存过期、新商品第一次被访问则回源读取 DB然后直接更新缓存。这种情况下的并发穿透可以通过本地缓存或请求合并来解决。最终的“读”流程演进原始应用 - 读缓存(无) - 读 DB - 写缓存。CDC 后读多应用 - 读缓存(有) - 返回。读少 (缓存穿透)应用 - 读缓存(无) - 读 DB - 写缓存。与此同时Canal 组件也会捕获到这次 DB 读操作吗不会。所以这里仍然有极小的概率发生“旧数据覆盖”但概率极低。如果业务要求极高可以在应用回写缓存时使用SET NX命令只有当 Key 不存在时才写入后续更新完全交给 Canal 组件。这样可以进一步保证缓存数据永远来自 Binlog 这个“权威渠道”。第五章康复与预防总结与最佳实践小D谢谢老K今天真是上了一课。从一个简单的网络抖动竟然能挖出这么多深层次的问题。老K这就是系统架构的魅力也是它的脆弱之处。我们来总结一下这次“诊疗”的收获这也是我们未来设计和排查问题的“检查清单”故障是常态而不是异常。在设计系统时要假设网络会抖、Redis 会慢、DB 会死锁。所有的外部依赖都可能是不可靠的要有完善的熔断、降级、超时控制机制。警惕“默认”代码。最经典的if (cache null) { loadFromDB(); }模式在遇到异常时就是致命的。一定要区分“未命中”和“访问失败”。分布式系统中的一致性没有银弹。强一致性很难通常需要 2PC (两阶段提交) 或分布式锁性能差。最终一致性是更现实的选择。通过 CDC (Binlog) MQ 可靠消费可以实现高性能、高可靠的最终一致性。“先更新 DB后操作缓存”本质上是在用一个分布式事务对抗物理规律而 CDC 是顺从规律用异步的方式达成一致。监控的重要性。如果没有精细的监控Redis 网络抖动、TCP 重传、线程池状态我们可能还在代码里一行行地找 Bug却不知道真正的元凶在网络层面。拥抱异步化和最终一致性。对于核心业务可以考虑将实时性要求不那么高的部分如缓存更新、日志同步、数据统计异步化削峰填谷提高系统稳定性。小D明白了。那我们接下来是不是要按 CDC 的方案来重构老KCDC 是好但也要考虑成本和复杂度。对于你们现在的阶段可以先实施“紧急治标”和“缓兵之计”中的本地缓存 请求合并先把系统的“骨架”练好避免再次因为小抖动而骨折。同时针对“库存反弹”这个具体问题如果不想大改可以暂时采用延迟双删作为过渡。至于 CDC 方案可以作为你们技术演进的下一个重要目标列入技术债清单在业务相对平稳的时期进行改造。小D好的我这就去整理复盘报告和优化方案。老K去吧。记住每一次故障都是一次提升系统韧性的机会。我们要感谢这些 Bug它们是我们最好的老师。终章诊疗总结本次“代码诊疗室”通过一个看似简单的网络抖动引发的缓存雪崩和数据不一致案例深度剖析了分布式系统中常见的几个核心问题故障传导链网络抖动 - 线程阻塞 - 线程池耗尽 - 缓存穿透 - 数据库被打满 - GC 风暴 - 系统崩溃。数据不一致根源“先更新 DB后删缓存”模式在读写并发下的经典时间窗口被高负载放大。解决方案演进治标优化异常处理设置合理超时。治本引入 CDC 架构实现业务与缓存解耦通过 Binlog 异步更新缓存达到高性能的最终一致性。中策本地缓存 延迟双删作为过渡方案。核心思想在不可靠的分布式环境下通过合理的架构设计如异步化、最终一致性、多级缓存、优雅降级来构建高可用、高一致性的系统是每一位程序员的终极追求。