从慢SQL到系统雪崩:一次高并发故障的完整复盘与防崩溃实践 1. 凌晨的报警声一次雪崩事故的现场还原1.1 故障前的平静业务背景与架构实况那是某个购物节前夜。线上业务刚完成一轮促销配置运营把优惠券和限时秒杀同时放了出来流量短时间内涨到平峰期的三倍左右。按照以往经验这个量级理论上扛得住——入口有负载均衡应用层做了集群部署数据库也上了主从。可问题恰恰出在“理论上”。当时的系统架构并不复杂App请求先进网关然后分发给用户服务、商品服务、库存服务和订单服务订单落库后通过消息队列通知下游做积分、推送和财务流水。整体链路不长环节也不算多但每个环节之间用的是同步调用也就是A服务等B服务返回B服务又等C服务返回形成一条串联的依赖链。现在回头看这就是雪崩最典型的温床。1.2 雪崩的四个阶段从一根慢SQL到全线瘫痪第一阶段是“慢”。凌晨刚过订单查询接口的P99延迟从平时的120毫秒上升到2秒多。这个接口算的是用户历史订单按说只是普通查询但因为促销活动新增了一张优惠券明细表而查询语句没有给关联字段建索引数据库在促销数据量上来之后开始走全表扫描。第二阶段是“堆积”。订单服务用的是Tomcat默认的200线程线程池被慢查询拖住后新来的请求在队列里越堆越多。队列是有上限的满了之后新的请求直接报错前端表现为“系统繁忙请稍后重试”。这时候用户就会反复点击反复点击又产生出更多请求。第三阶段是“穿透”。数据库连接池总共配置了50个连接其中三四十个被长时间的慢查询长期占用库存、商品、用户这些原本正常的下游服务也拿不到连接了。于是雪崩从订单服务向外蔓延整条交易链路上所有依赖数据库的接口集体超时。第四阶段是“全瘫”。负载均衡发现订单服务的健康检查失败把流量转给其他节点——但其他节点的代码和配置完全一样同样在几秒内被打垮。最后网关响应超时首页商品详情页也开始报错整个促销入口相当于关闭了。这个过程统共不到40分钟。一个没有索引的查询字段最终演变成了一次波及全站的IT系统雪崩。把四个阶段拉成一张表会更直观阶段系统表现底层根因慢查询P99延迟从120ms升至2s新表字段缺索引触发全表扫描线程堆积队列持续膨胀接口大面积超时线程池被慢请求占满连接穿透下游服务集体拿不到数据库连接连接池50个连接被长时间占用全站瘫痪健康检查失败网关超时首页报错同构节点被流量逐级击穿1.3 故障定级与响应第一反应应该做什么被报警电话叫醒之后第一反应不是急着改代码而是先做两件事确认影响范围切流量。我当时按应急预案先把秒杀入口的流量切到静态化页面把非核心的查询类请求快速降级这才止住了雪崩继续蔓延。这里有个经验雪崩处理的第一原则永远是止损不是定位。先把入口的流量限制住再把核心链路之外的功能降级让系统先喘过一口气才有余力去查根因。很多初次遇到故障的团队会犯同一个错误——所有人扑到代码里去查慢SQL忘了先把还在不断冲击系统的流量堵住结果一边排查一边继续被打问题越拖越大。止损动作做完之后才轮到根因追溯。整个定位过程我在第3章会完整拆解但先说一个结论这起事故的根子不在某一台服务器也不在某一个服务而在于依赖链路上的资源没有做隔离任何一个环节被打满都会顺着同步调用传染给整个系统。2. 根因追溯为什么小故障被放大成系统性灾难2.1 线程池耗尽最经典的放大效应很多人一听到雪崩就下意识想到高并发、大流量其实真正致命的往往是线程池耗尽这种“内部消化不了”的问题。线程池的工作原理很多文章都写过我用自己的话翻译一遍线程池好比一家餐厅的厨师团队每个厨师同时只能炒一道菜客人来了就排队排队超过一定长度就要挂出“暂停接客”的牌子。订单服务那200个线程就是200个厨师。当一个慢查询平均要跑3秒时每个线程处理一个请求的时间从几十毫秒拉长到几秒单位时间内能接的请求量骤降队列自然越排越长。更麻烦的是线程池里的线程一旦耗尽后续所有请求都不会进入业务代码直接在线程池层被拒绝这会让调用方收到异常后产生大量重试进一步加重服务负担。线程池耗尽的另一个隐藏后果是GC压力和CPU飙升。大量线程阻塞在慢查询上并不是说CPU就闲下来了——线程切换、上下文保存恢复、对象反复创建都会把CPU打满。我当时看监控订单服务所在机器的CPU已经飙到95%以上而数据库的活跃连接数也早已顶到上限两边一起成了瓶颈。2.2 超时与重试的恶性循环二次伤害制造机雪崩最危险的地方不只在于这一次故障本身还在于它会引发“故障期间的二次流量放大”。用户点下单按钮没反应很多人会反复点前端框架检测到接口超时会自动重试一次业务代码里如果还配了RPC重试那一个请求可能变成三个、五个请求打到下游。这些重试请求在正常情况下不会造成太大压力但下游已经处于过载状态时每多一个请求都是往火堆里添柴。我们的调用链里当时给库存服务的RPC重试配成了3次这个配置在平时几乎没有存在感恰恰在最需要“及时失败”的时候让本已过载的库存服务又多扛了三倍的流量。所以后来团队定了一个规矩所有外部依赖调用必须有超时时间超时之后的默认行为是快速失败只有对幂等且关键的接口才允许配置有上限的重试而且重试必须加退避。这条规矩看起来简单但每一次雪崩复盘几乎都能在“超时和重试设置不当”这一项上找到漏洞。2.3 容量配置的误区按均值下单还是按峰值下单复盘的时候另一个绕不开的问题是容量规划。当时的评估方式是按平时流量的均值再加30%的余量去配置线程池和连接池这个思路在业务模式稳定的阶段勉强够用但遇到促销这种脉冲式流量就暴露了问题。做容量评估不能只看均线要看峰值、看突刺、看上游调用方能够产生多大的瞬时洪峰。运营把一个营销位从首页底部提到顶部流量就可能翻倍一个秒杀活动在开售瞬间能带来平时几十倍的请求量。这些极端场景下的流量特征必须在容量模型里提前算进去。我的实践是用三层模型来做预估第一层是入口网关的QPS峰值第二层是核心链路上每个服务的处理能力第三层是数据库连接池、消息队列堆积量等底层资源水位。三层模型得出结果之后取最大风险项作为扩容依据而不是简单地把每一层都按平均流量加余量。这句话值得贴在监控大屏上系统是按峰值设计还是按均值设计决定了它在关键时刻是帮手还是累赘。3. 复现排查链路监控、日志与链路追踪三步走3.1 第一现场CPU、线程、GC、数据库活跃连接先看什么真正进到故障现场的时候监控面板上一片红色很容易被各种指标带乱节奏。我给自己定过一个排查顺序这套顺序在多次故障里都验证过有效。第一步先看入口流量和报错率确认故障范围是全站还是单服务第二步看服务所在机器的CPU和负载CPU打满通常代表着线程池或GC出了问题第三步看GC日志如果频繁Full GC就要怀疑内存泄漏或者大对象第四步看数据库活跃连接数、慢查询数和锁等待数是最关键的三个值。那次事故里数据库的活跃连接数最先暴露了问题——50个连接全部处于长时间活跃状态而且慢查询列表里出现了那张新增的优惠券明细表。把时间点一对照慢查询开始的时间和系统响应变慢的时间前后吻合方向一下子清晰了。这里有个小提醒排查故障时必须先保存当时的线程dump和GC日志再去做任何操作。很多人一上来就重启服务几十秒内系统恢复了但故障现场没了根因也就无从查起。哪怕只是内存溢出也至少留个堆转储再重启。3.2 链路追踪让每一次慢调用现出原形如果当时系统接入了链路追踪整个定位过程会更快。链路追踪的核心价值在于它能给每一次请求生成一个全局唯一的traceId通过这个ID能看到请求经历了哪些服务、每一步耗时多少、哪一环是瓶颈。我们后来在全链路上了SkyWalking排查依赖链上的问题就轻松多了。比如一次下单请求从网关到订单服务再到库存服务每一步的耗时在链路图里一目了然。不管是因为数据库慢查询还是因为下游服务响应变慢都可以顺着traceId往下钻最多两三步就能定位到具体节点。链路追踪不是故障时才启用的工具它必须提前埋好。你没法在故障发生那一刻才想起要给系统装探针——等打完补丁再去还原历史调用链什么也看不到。我建议在系统建设初期就把它纳入基础组件每个服务启动时自动上报业务代码无侵入成本比自己埋点低得多。3.3 没有埋点的教训故障时才发现数据是断层的那次复盘里最让我难受的一点不是慢SQL本身而是有些关键环节根本没有监控数据。比如订单服务调用消息队列发送通知的那一段当时只记录了一个“发送成功”的log没有记录消息体大小、发送耗时、队列堆积量导致事后无法从数据上还原消息队列是否也是过载的元凶之一。这就是典型的“没有埋点就没有真相”。我见过太多团队平常日志写得随意、指标采集漏项等到故障复盘时拿着不完整的数据来回猜测最后只能靠“感觉”得出结论。埋点的最低标准是三件事核心接口的RT和QPS必须有服务间调用的成功率和耗时必须有数据库、缓存、消息队列这些中间件的连接数和堆积量必须有。这三件事不需要多高级的监控平台最简单的Prometheus加Grafana就能做到关键是形成习惯上线规范而不是靠某个人记性好。4. “没有一片生意雪花是无辜的”雪崩的业务代价4.1 直接损失超时订单、客诉洪峰与客服击穿这一章开始我要把视角从技术切回业务。标题里的那句话说得很直白——没有一片生意雪花是无辜的。系统雪崩的直接受害者当然是用不了的App但真正受到伤害的是每一笔订单背后的生意。那次事故导致超过两万笔下单请求超时其中有相当一部分用户其实已经被扣了款但页面没返回成功结果。第二天客服系统的工单量直接爆掉本来10个人的客服团队处理不了突然涌进来的上千条退款投诉连财务都临时调去支援。超时订单还算好处理用户找过来退款就能解决。真正让业务团队头疼的是压单支付侧显示交易成功订单侧没有生成订单商品没有出库用户的款却已经划走了。这种订单处于一种“悬空”状态处理起来需要跨部门核账每单都要人工确认连续几个工作日都在跟这件事纠缠。4.2 数据不一致订单悬空比宕机更麻烦数据不一致是雪崩最隐蔽的破坏力。表面上看系统恢复了订单接口也不报错了但数据库里到处是半截子状态订单表有记录支付流水表有记录但两者的关联字段对不上有的库存扣减了订单却取消掉了有的是优惠券已被使用但订单没创建成功。这些不一致数据如果不去修会越积越多最终影响财务报表、库存盘点、对账清算。我们当时安排了一个专门的开发小组花了一周写数据修复脚本把两个系统之间能通过唯一订单号对齐的做对齐对不上的逐条人工审核。从那之后我特别强调一个观点系统高可用不只是“服务不挂”还包括“数据不错”。雪崩期间产生的脏数据往往比宕机本身更消耗团队的精力和业务方的耐心。ERP、WMS、MES这些周边系统也会被牵连——订单系统一乱仓储的出货单据、生产的工单状态全都跟着对不上牵一发动全身。4.3 信任损耗一次事故如何改变业务与技术的关系经济损失可以用数字估算信任损耗却没办法量化。那次事故之后业务方对大促的信心明显下降了。运营辛苦搭好的活动页面因为技术故障被迫下线ROI归零不说用户在社交媒体上的抱怨还会直接影响品牌和复购率。更现实的影响发生在团队内部。业务部门开始对技术说“不”每一个新功能上线前都要追问“会不会出问题”管理层要求技术团队每周提交稳定性报告压力传导到每个开发身上新增的需求全被要求加上“应急预案”评审。这种变化不完全是坏事它逼着团队建立了更规范的变更管理和发布流程。但我也想说一句公道话与其让业务在一次事故后才学会敬畏技术不如技术自己先把系统稳定性做好。业务和技术从来不是甲方乙方的关系而是拴在一条绳上的蚂蚱——系统一崩生意必然受牵连生意受损技术团队在公司的生存处境也会变差。这就是“没有一片雪花无辜”的最真实含义。5. 防雪崩改造隔离、限流、降级的落地清单5.1 线程池隔离把故障关进各自的笼子事故之后的改造第一件事就是做线程池隔离。思路很简单不要把鸡蛋放在同一个篮子里。订单服务调用库存、商品、支付、营销等多个下游如果共用一个线程池任何一个下游变慢都会拖死整个订单服务如果每个下游调用用独立的线程池那么营销服务出了问题最多只耗尽营销调用那条线程池库存和支付这条链路还能正常工作。实现上可以用现成的Sentinel或Resilience4j来做线程池隔离和信号量隔离。线程池隔离适合下游调用量差异大、对延迟敏感的收费场景信号量隔离适合快速失败、不关心排队场景的收费场景。两种模式各有取舍落地时按业务场景选不一定要上最重的那套。隔离改造之后我做过一次故障演练人为把一个下游服务的RT调成10秒结果验证了只有依赖它的那条线程池出现排队核心下单链路完全不受影响。那一刻之前熬夜调优的辛苦全值了。5.2 超时、重试与熔断给调用链装保险丝线程池隔离解决的是“故障不扩散”但还不够。一个下游服务持续变慢不能每次都等它超时再报错应该在连续多次失败之后直接给它“断电”让流量不再进入这个故障节点——这就是熔断。现在的开源组件里Sentinel和Resilience4j都内置了熔断器配置几个核心参数就能用滑动窗口大小、失败率阈值、熔断持续时间。我把订单服务对库存服务的调用熔断阈值设成“10秒内失败率达到50%就熔断5秒”意味着即使库存服务正在抽风订单服务也能在5秒内自动绕开它而不是被它拖进同一个泥潭。超时时间也要分级设置。下表是我后来沉淀的一套基础配置模板可以直接抄作业参数建议值说明连接超时500ms建立连接阶段的上限读超时2s等待响应阶段的上限整体调用超时3s全链路总耗时上限重试次数读1次配合指数退避写操作一律不允许自动重试熔断阈值10秒内失败率50%达到后熔断5秒5.3 限流与降级关键时刻保住核心生意限流解决的是“流量超过系统最大承载量时怎么办”。与其让所有请求都进来排队然后集体超时不如在入口就明确告诉一部分用户“当前繁忙”。Sentinel的匀速排队模式特别适合秒杀场景——把请求以固定速率放入系统其他请求立即返回繁忙提示既保护了系统也保护了大部分用户的体验。降级则是提前想好“哪些功能在最危急的时候可以被牺牲”。我们当时梳理了所有功能按业务重要程度分成三级一级是下单、支付和物流查询无论如何都要保二级是优惠券、营销活动可以降低展示频率三级是历史订单查询、个性化推荐系统压力大时可以暂时关闭。大促前把降级开关和限流规则都预置好故障发生时只需要在控制台点一下开关不需要临时改代码发版。好的降级方案一定是提前设计好的临时抱佛脚做出来的降级逻辑往往比故障本身更危险。5.4 数据兜底幂等、对账与演练最后一块是数据层面的兜底。第一件事是给所有写接口做幂等同一个请求无论被重复处理多少次最终结果都保持一致。支付回调、订单创建、库存扣减这几个核心操作统一用“请求唯一ID加唯一索引”的方式做幂等重复消息直接丢弃。第二件事是对账。订单系统和支付系统各有各的数据全链路搞一个对账任务每天定时比对两侧的交易记录把不一致的拎出来告警。好的对账机制能在一两个小时内发现压单问题而不是等到用户投诉才后知后觉。第三件事则是演练。防雪崩的系统设计得再好不通过演练验证也是纸上谈兵。我个人强烈建议每个季度至少在预发环境做一次故障演练从慢SQL、下游超时、机房断网几个常见场景里挑一个把整个团队的应急响应流程跑一遍。演练时暴露出来的问题远比开会讨论出来的问题有价值。那次雪崩结束之后团队在很长一段时间里都保留了一个习惯每次大促前把故障预案从头到尾过一遍确认切换开关、限流阈值、降级名单、值班人电话全都有效。这个习惯后来救了我们好几次——不是每次故障都有惊无险但至少每一次都有人知道第一步该做什么。我在这个行业里待得越久越认同一个朴素的道理系统稳定性不是靠运气也不是靠某个“高手”的临场发挥而是一整套设计、演练和复盘机制长期积累的结果。每一次雪崩都在提醒我们链条上的每个环节、每个服务、每笔生意都是连在一起的一片雪花。别等到风浪来了才发现自己什么都没准备。