2026/10/9 20:39:52

从17分钟雪崩事故,看高并发系统的超时、限流与熔断防线

从17分钟雪崩事故,看高并发系统的超时、限流与熔断防线 我记得很清楚那是一个凌晨两点零九分手机振动把我和运维同时吵醒。告警内容是“订单服务下单接口P99延迟突破800ms”而基线是80ms。十分钟后第二波告警接踵而至商品服务线程池活跃线程数打满然后支付回调超时网关开始拒绝连接首页接口也跟着起不来。从第一个超时告警到全站基本不可用前后只有十七分钟。这就是高并发架构里最常见的死亡螺旋接口超时没有在前端被掐断最终演变成雪崩。这篇文章我把这次事故从时间线、根因、防御方案到验证方式完整拆开讲清楚超时是怎么被放大的、雪崩是怎么传导的以及超时控制、限流、熔断、降级、隔离这些手段到底应该怎么配置才真正有用。适合正在做微服务改造、电商促销支撑或者任何“接口偶尔变慢”的核心系统维护者参考。1. 凌晨两点的告警一次雪崩事故的完整复盘1.1 事故时间线17分钟从单点超时蔓延到全站不可用先把当时的时间线摆出来你会发现雪崩不是瞬间发生的它有清晰的递进过程00:42 01:余订单服务下单接口P99延迟从80ms涨到800ms告警触发00:47 商品服务线程池活跃线程接近100%新请求开始排队部分请求等待超过3秒00:53 支付回调接口开始超时用户端出现“支付成功但订单状态未更新”的反馈引发集中重试00:59 网关层accept队列打满大量连接被拒绝首页、搜索等无关接口也开始不可用01:02 大家决定重启核心服务但服务滚动重启过程中流量又瞬间打满新节点直到流量降下来才稳住事后看所有故障都有先兆第一个超时发生在订单服务说明最开始的“病根”只在一个服务内部。但因为我们没有做任何防御它顺着调用链一路传染下去最后把整个机房都拖下水。1.2 雪崩的三层递进慢调用、资源耗尽、故障传导如果要把雪崩拆成可理解的机制我会分成三个层次第一层是慢调用。某个服务内部出现异常比如慢SQL、GC停顿、外部依赖抖动导致单次响应时间拉长。这时候服务本身还没挂只是“走得慢”。第二层是资源耗尽。我们用的是Tomcat默认200线程池当平均响应时间从80ms变成800ms同样QPS下需要的并发线程数直接放大10倍。用Littles Law算一下在途请求数 QPS × 平均响应时间。假设QPS是1000RT是80ms时只需要80个并发RT变成800ms就需要800个并发而线程池只有200个剩下的请求全部排队。第三层是故障传导。排队请求等不到结果上游调用方就开始堆积自己的线程等到调用方自己的线程池也耗尽它又会把故障传给更上游。这种链式反应一旦形成就像一栋楼的承重墙挨个倒塌叫雪崩一点不过分。1.3 事后共识不是流量问题是防御机制集体缺席复盘时第一件事是看流量监控。那晚的QPS和半小时前几乎没有区别没有促销、没有热点新闻、没有爬虫集中攻击。也就是说不是流量压垮了系统而是系统本身失去了对异常的“吸收能力”。正常情况下一个慢调用最多影响它自己但在没有任何超时控制、没有限流、没有熔断、没有隔离、没有降级的系统里慢调用会像滚雪球一样吃掉整个集群的并发资源。这次事故换来的共识是高并发架构不只是“扛得住大流量”更重要的是“在局部出问题时故障边界能被控制住”。2. 超时元凶排查流量没涨慢调用才是高压锅上的阀门2.1 日志大对象引发的GC停顿一个隐藏的定时炸弹第一条线索来自GC监控。我们拉出当时的gc.log发现00:40开始Young GC频率从每5秒一次变成每秒一次单次GC停顿从30ms涨到150-300ms。然后再看堆内存明显是“分配速率过高”典型的对象大量创建特征。用jstack抓线程栈看到下单核心链路上好几条线程都在执行日志打印里面有一行新加的debug日志打印了一整个购物车聚合对象。这个对象toString()包含上百个嵌套字段序列化出来大概十几KB。平时量小无所谓但下单接口QPS上千每个请求都new出一个大字符串瞬间把年轻代打满。那行日志是当天下午某位同事为排查问题临时加的忘了删。这里的教训是日志打印本身也会成为高并发杀手尤其是debug级别、大对象、循环体内打印、以及在toStString实现很重的实体上打日志。日志框架本身有异步但对象构建和字符串拼接发生在业务线程里避不开。2.2 慢SQL卡死连接池木桶效应在数据库侧发作GC停顿解释了一部分超时但并没有解释全部。jstack里还有一批线程阻塞在MySQL连接获取上状态是“waiting for connection”。查DBA那边的show processlist发现一条SQL跑了1.2秒扫描了500多万行。那条SQL是一条对订单子表的统计查询某次索引优化时新字段没有纳入索引覆盖优化器还选了全表扫描。它单个慢没问题问题是应用侧数据库连接池只有20个连接。这种SQL一多20个连接全部被占住所有正常SQL都得排队。数据库侧“木桶效应”就是这么来的一条慢SQL占据连接不释放整个数据库连接池对其他业务来说等于瘫痪。注意这里有个奇怪的配合SQL执行1.2秒不算“超时”但我们连接池的socketTimeout设了30秒所以连接一直不释放。慢SQL加上没合理的超时时间就把局部故障放大成了数据库整体故障。2.3 第三方依赖的抖动外部系统的不可靠承诺还有一个隐蔽的元凶是支付回调它调用了外部支付网关的HTTP接口。第三方网关在高峰期偶尔P99抖动到几秒而我们当时调用它时没有设置任何读超时用的是HTTPClient默认的无限超时。这就导致一个很被动的局面支付网关慢我们这边的线程就“傻等”用户等不了发起重试重试又叠加新的等待连接把应用服务器的线程池彻底耗尽。外部依赖是不可控的你唯一能做的是控制自己等待它多久。没有超时的同步调用相当于把系统生死交给别人决定。3. 接口层设防超时控制与重试策略的重构思路3.1 超时的参数化设计连接超时、读超时、总超时破解雪崩的第一步就是给所有同步调用设置合理的超时。超时不是一个大而化之的概念要分类型配置连接超时建立连接的最大时间防止对端IP不可达时一直等TCP握手读超时连接建立后等待响应的最大时间这是最关键的参数总超时从发起调到拿到结果的总体耗时上限防止多次内部重试把时间拉长以我们重构后的HTTP客户端为例用的连接池配置是这样的// JVM全局共享的HTTP连接池配置 PoolingHttpClientConnectionManager cm new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); // 总连接数 cm.setDefaultMaxPerRoute(50); // 单个路由最大连接数 cm.setValidateAfterInactivity(2000); // 空闲2秒后需要重新校验 RequestConfig config RequestConfig.custom() .setConnectTimeout(500) .setSocketTimeout(800) .setConnectionRequestTimeout(100) .build();三个超时加起来不超过1.5秒但大概只有后端正常P99的3-4倍。这样配置的效果是下游再快你也不着急但下游一旦慢1秒内就会被切断。MySQL侧的socketTimeout也要显式配置# JDBC URL上的参数防止数据库端卡死无响应 jdbc:mysql://host:3306/db?connectTimeout500socketTimeout1000useSSLfalse事务超时、Redis命令超时、MQ消费的超时——每一个跨网络调用的点都要有明确的超时值。我建议梳理一张“全链路超时清单”每个服务每个依赖都要填不填的调用一律不允许上线。3.2 超时时间定多少按P99而不是P50超时值定多少实际上是个数学问题。很多人按平均响应时间设置这是错的。你接口P50是50ms但P99是300ms如果超时设100ms那你等于把最健康的3%请求全部误杀。我的经验是两个规则第一条超时值按调用链上各环节P99之和再留30%-50%余量。比如A调用BB的P99是100msA调B的超时就别低于300ms但整个A接口的P99是300ms那A就不能容忍B的坏了因为B一旦变慢到300msA自己的下游就受不了。第二条超时是“底线保护”不是“性能目标”。也就是说正常时候你不希望任何请求走到超时边界超时存在的意义是拦住那千分之一的坏运气。如果你发现线上经常有请求卡在超时边界才返回说明你的超时设得太宽了或者代码里有bug造成了线程没有及时释放。3.3 重试的放大效应重试N次链路深度M请求爆炸超时控制之后第二个动作是规范重试。很多程序员对重试的直觉是“失败一次没关系再试一次就好”在高并发链路里这非常危险。假设调用链深度是5层用户 → 网关 → 订单服务 → 库存服务 → 商品服务 → 数据库每层都做“失败重试2次”的配置那么一个用户请求在最坏情况下会被放大成多少请求每层最多发送1次原始请求2次重试3次5层深度就是3^5243次。高并发场景下一次接口抖动就可能触发数十倍的流量涌向最底层系统秒挂。重试的正确姿势只在最外层的入口服务允许重试最多1次。内层服务一律不做同步重试重试必须配合指数退避比如第一次等100ms、第二次等200ms不能立即重试重试前检查错误类型只有连接中断、超时这种“不确定失败”才值得重试业务性报错参数错误、余额不足一律不重试每一条可能重试的写操作必须有幂等保证3.4 重试的伴侣幂等性设计重试和幂等是一对没有幂等的重试就是雪上加霜。最简单的幂等方案是业务侧维护“请求唯一ID”。我们做了一个约束所有写接口下单、支付回调、库存扣减都要求调用方传requestId服务端在Redis里用SETNX记录“这个requestId已处理”处理完成后把结果缓存在键上。这样即使同一个请求被重发多次第二次来的时候直接返回第一次的结果。// 幂等判断利用Redis SETNX Boolean first redis.opsForValue().setIfAbsent(buildIdempotentKey(orderId), processing, 10, TimeUnit.SECONDS); if (first null || !first) { // 重复请求直接返回已有结果不再执行下单逻辑 return redis.get(buildIdempotentResultKey(orderId)); } try { // 核心业务逻辑... redis.set(buildIdempotentResultKey(orderId), JSON.toJSONString(result), 60, TimeUnit.SECONDS); } finally { redis.delete(buildIdempotentKey(orderId)); }这技巧不复杂但它让“重试”从破坏性操作变成安全操作。没有幂等之前一次超时重试可能导致用户付了两次钱、扣了两次库存。有幂等之后重试再频繁都只是多几次Redis查询不影响业务正确性。4. 入口保护限流与熔断的正确打开方式4.1 限流算法选型固定窗口、滑动窗口和令牌桶的差别超时解决的是“等待多久”的问题限流解决的是“放多少流量进来”的问题。限流算法常见三种很多团队直接用默认但默认不一定适合你。固定窗口每秒一个计数器到点清零。实现简单但存在“窗口临界突发”问题比如每秒限100个请求第1秒最后50ms和第2秒前50ms合计能放进200个请求滑动窗口把窗口切细成多个小格精确统计最近一个完整时间周期。能解决临界突发代价是多一份内存存储令牌桶匀速往桶里放令牌请求必须拿到令牌才能执行桶可以留一点余量应对突发。Guava的RateLimiter和很多网关默认都是令牌桶我们网关层的做法是单机用令牌桶集群用滑动窗口。业务上更需要“平滑”的场景选令牌桶需要“严格卡住某个阈值”的限流选滑动窗口。4.2 分布式限流的落地Redis Lua 的取舍单机限流在单体时代够用但服务有多个副本时每个节点的计数器是独立的总QPS可能变成单节点阈值的N倍。分布式限流要把计数放在中心存储上。最简单的可行方案是Redis Lua脚本保证“取计数、判断、自增”是原子的local key KEYS[1] local limit tonumber(ARGV[1]) local now tonumber(ARGV[2]) local window tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, now - window * 1000) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, now .. - .. math.random(100000)) redis.call(EXPIRE, key, window) return 1 end return 0这是我们限流脚本的简化版用ZSET存储每个请求的时间戳每次请求到来就清理窗口之外的数据再判断窗口内数量是否超过阈值。用Lua保证脚本原子执行避免并发下多放行请求。要注意的是Redis本身也依赖网络限流器不能因为Redis抖动而拖垮业务。所以我们的设计是本地也保留一套令牌桶兜底降级。Redis不可用时自动退化为单机限流让每个节点大约承担总阈值除以副本数的流量。虽然不精确但至少不会让系统完全敞开。4.3 熔断器的三态流转从关闭到半开的边界条件熔断器是对“故障传播”的最后一道拦截它很像家里的保险丝电流异常变大时跳闸保护线路。熔断器维护三个状态关闭Closed请求正常通过统计失败率打开Open失败率达到阈值立刻切断所有请求快速返回兜底结果半开Half-Open熔断一段时间后放一小部分试探请求进来看下游是否恢复试探成功则关闭失败则重新打开我们用Resilience4j做熔断配置核心参数供参考resilience4j: circuitbreaker: configs: default: # 10秒内请求数最少达到20个才计算失败率避免样本太少误伤 sliding-window-size: 20 # 失败率超过50%触发熔断 failure-rate-threshold: 50 # 熔断打开后等待10秒再进入半开 wait-duration-in-open-state: 10s # 半开状态放5个试探请求 permitted-number-of-calls-in-half-open-state: 5这里有个很容易被忽略的点熔断的统计维度是“某个下游依赖的调用”不是整个服务。比如订单服务调库存、调支付、调商品要对每个依赖单独配置熔断器。不然库存服务出故障支付服务会被“连坐”熔断影响面反而更大。4.4 限流与熔断的分工保护自己还是保护下游我在不少团队看到一种误解觉得限流和熔断是二选一。其实分工完全不同。限流是保护自己不管下游能扛多少我这边先控制自己的入口流量熔断是保护下游也是变相保护自己检测到下游已经病了主动拒绝调用它避免把流量继续灌进病体高并发接口挂掉的常见组合是入口流量猛增 → 某个下游先扛不住 → 所有请求堵在那等着 → 最终所有服务线程池耗尽。限流和熔断要同时上限流能控制总流量熔断能在下游故障时快速断开调用两者缺一不可。5. 隔离与降级故障发生时怎么控制爆炸半径5.1 线程池隔离用“舱壁模式”避免服务间互相拖垮超时、限流、熔断都做好之后还有一类故障无法靠“拦截”解决某个依赖既不超时也不报错就是“正常地慢”把速度拖到你的线程池全部占满。这是最阴险的故障形态唯一的正面防御是隔离。线程池隔离也叫舱壁模式的思路是同一个服务对不同的下游依赖用不同的线程池。这样支付网关再慢占住的只是“支付线程池”的30个线程数据库正常业务的线程池完全不受影响。// 按依赖拆分线程池以“支付回调”线程池为例 ExecutorService payCallbackExecutor new ThreadPoolExecutor( 20, // 核心线程数 30, // 最大线程数 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(50), new NamedThreadFactory(pay-callback-), new ThreadPoolExecutor.CallerRunsPolicy() );线程池大小怎么定有一个粗略公式线程数 目标QPS × 目标RT(秒) × 备用系数(1.5~2)。比如支付回调目标支撑500 QPS、单次耗时200ms那一组20-30个线程就够用了。宁可让这个线程池排队也不让它抢占别的依赖的资源。5.2 信号量隔离适合小高并发的轻量选择如果调用量非常大每个请求都丢进线程池再调度线程切换成本也是一种开销。对于某些超短调用比如Redis GET、本地缓存读取用信号量更合适。信号量的玩法是进入调用前acquire调用结束release但执行仍然在调用方线程里。它不像线程池那样独立资源只控制并发度。适合那些“本身很快但你又不想让它无限制占用主线程”的场景。我们通常对缓存类的短调用用信号量对跨网络的外部服务调用用线程池。5.3 降级开关配置中心里的“保命牌”降级和隔离配合使用能进一步缩小故障影响。降级的核心是准备“备用方案”当主路径挂了立刻切换到备胎方案。以首页商品列表为例正常逻辑是调用商品服务实时查询再渲染。因为商品服务抖动我们准备了三级降级方案一级降级从Redis缓存取商品基础信息放弃实时价格二级降级从本地JVM缓存取静态快照每5分钟更新一次三级降级返回预设的默认展示结构保证首页框架能打开降级开关不要写在代码常量里必须放在配置中心。我们用的是应用配置中心的动态配置一旦检测到依赖故障Ops可以直接改配置下发达降级指令不需要发布服务。有一个实操细节降级开关本身要写在本地也能判断的逻辑里。直接用配置中心客户端读取配置配置中心挂了怎么办我们的做法是本地维护一份开关快照应用启动时加载配置中心每次推送更新本地业务判断时读本地快照。这样即使配置中心不可用开关值仍然保留在内存里不会因为配置中心故障而失守。6. 压测与故障注入把“应该没问题”变成“实测没问题”6.1 压测目标设计按峰值预期而不是日常流量前面说的所有方案如果不经过验证都只是纸面规划。高并发架构里最忌讳的一句话是“应该能扛住吧”。我坚持所有核心接口在发布前都要做压测而且压测流量必须按“峰值预期的2-3倍”来设计。怎么做压测流量模型一个简单的办法是统计最近半年全站最高QPS通常来自促销或运营活动再乘以1.5的安全系数。比如日常峰值是5000 QPS那么大促目标就是7500 QPS压测就按7500 QPS起步逐步加到10000以上观察服务在哪个点开始进入饱和。压测工具我们长期用的是开源方案JMeter做接口级压测Gatling用于部分复杂场景配合nGrinder做分布式施压。压测过程中采集三个核心指标P99延迟、线程池活跃线程数、GC停顿时间。当P99开始显著上涨、线程池排队数持续不为零那个流量值就是当前架构的“软极限”。6.2 混沌工程人为制造故障来验证预案压测只能验证正常流量下的并发能力它验证不了“故障发生时系统能不能自保”。这就是混沌工程的价值——你去主动制造故障看防御机制是不是真的生效。我们在压测环境里做过几次经典的故障注入对订单服务的MySQL用SELECT SLEEP(5)模拟慢SQL验证熔断器能不能在10秒内打开随机kill掉一个商品服务节点验证网关重试不会把流量全打到存活节点给支付网关的接口注入30%延迟异常验证线程池隔离是否把影响控制在支付线程池内部把Redis节点设为只读验证降级开关能不能自动切到本地缓存这组实验非常值得做。第一次做的时候我们就发现熔断器虽然配置了但因为“最少请求数20”这个参数在低流量时几乎达不到故障发生时熔断根本不会触发。后来又调整了滑动窗口大小把最小请求数降到10才在故障注入测试里看到理想效果。这就是为什么纸面配置必须靠故障注入来验证。6.3 复盘检查清单把教训固化为规则经过那场事故和后续改建我把每次高并发故障复盘要检查的问题整理成了一张清单现在每个服务上线前都会带着这张清单过一遍超时清单所有跨网络调用是否都配置了连接超时和读超时超时值是否按P99链路计算重试清单谁允许重试重试几次重试是否带了退避写操作是否都有幂等限流清单入口是否有全局限流单机限流阈值是否经过计算限流器是否有本地兜底熔断清单每个下游依赖是否有独立熔断器熔断阈值是否经过故障注入验证隔离清单慢依赖是否都走了独立线程池线程池大小是否按QPS×RT预估降级清单每个核心接口是否都有降级预案开关是否能在配置中心一键下发告警清单P99、线程活跃数、GC停顿、连接池使用率是否都有独立监控告警这七条不是摆设每一条在那场17分钟的故障里都有对应的“如果当时做了就不会发生”的版本。高并发架构并不是把QPS堆到多少万而是当系统里某个环节意外变慢时整个架构仍然能像一个缓冲垫一样把异常吸收住而不是让故障像多米诺骨牌一样倒下去。最后再分享一个实际操作里的小技巧每次大促前我都会在凌晨低峰期对最核心的两三个接口做一次“预期内故障演练”故意让某个依赖超时然后观察监控曲线。你会发现这种演练既能验证系统韧性也能让值班同事养成看到告警后“先看熔断器状态、再看线程池水位、最后查慢SQL”的肌肉记忆。故障不会因为你不想它发生就不发生但你的系统是否做好了准备是可以在平静的日子里提前验证好的。