接手过不少"查不出原因"的故障,最后绝大多数都指向同一个结论:不是问题本身多诡异,而是排查顺序乱了。这次我想用一次后台订单服务故障的真实排查经历,完整拆解"故障排查及修复"应该怎么干——从接到告警、固定现场、定位根因、修复验证,到最后的复盘加固,每一环都讲讲我当时是怎么判断、为什么这么选、中间踩过哪些坑。内容偏后端服务方向,但排查思路和工具方法论是通用的,做运维、做开发、甚至自己维护小服务的同学都能直接拿来用。
1. 故障刚发生的那一步:先固定现场,而不是急着改配置
1.1 接到告警后我在前十分钟做了什么
晚上八点出头,告警群里开始刷消息:订单服务的错误率指标突然从平时的 0.2% 蹿到了 6%,P99 延迟从 300ms 涨到 4 秒,紧接着网关那边也报 502 比例升高。群里有同事第一反应是"重启一下服务试试",我拦住了。
这里有个非常关键的原则:故障现场是线上环境里最珍贵的证据,重启会把现场彻底毁掉。尤其是偶发性故障,一旦重启,线程栈、连接状态、内存里的异常对象全部消失,后面就只能靠猜。我在前十分钟只做了三件事:
- 在告警群里固定时间线:故障从什么时候开始、持续多久、由哪个监控指标触发。
- 确认影响范围:是全部接口都挂了,还是只有下单接口慢?是所有用户都失败,还是某些特定请求失败?
- 拉取最近变更记录:过去两小时有没有发布、配置修改、数据库 DDL、流量切换。
这三件事看起来简单,但很多人遇到故障就直接扎进日志里翻,反而漏掉了最重要的背景信息。我当时查了发布平台,确认下午六点刚发过一个版本,改动内容里有一条 SQL 优化。这个信息在后面起了决定性作用——它给了我们一个重点怀疑方向,而不是漫无目的地全链路排查。
1.2 把"偶发性故障"从玄学变成可量化指标
故障里最让人头疼的就是"偶发"两个字。用户反馈时好时坏,你盯着屏幕的时候它偏偏正常。处理这类问题的办法只有一个:给"偶发"建立坐标系。
我的做法是同时拉三组数据做时间对齐:
- 网关层 Nginx 访问日志:统计每分钟请求量、5xx 比例、响应时间分布。
- 应用层日志:重点找异常堆栈、慢请求日志、数据库连接获取超时记录。
- 基础设施监控:CPU、内存、GC 停顿、数据库活跃连接数、连接池指标。
把三组数据按分钟对齐后,规律一下子就出来了:错误率高峰出现在每个整点的前五分钟(比如 20:00-20:05、21:00-21:05),其他时间段虽然也有波动,但远没有达到告警阈值。这个"周期性"特征非常关键,它直接排除了网络抖动、机房故障这类随机性原因,把怀疑范围收敛到了"某个定时任务"或"某个周期性的业务高峰"上。
我这几年排查故障最深的一个体会是:大部分所谓疑难杂症,都是因为证据不够。证据够了,根因自己会跳出来。 所以遇到问题先别急,把时间线、指标、日志摆齐了再动手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排查链路拆解:从日志里找证据,而不是凭感觉怀疑
2.1 应用日志中的三个关键信息抽取点
固定完现场,我开始系统性地翻日志。翻日志不是全文搜"Exception"就完事,要有目的地找三类信息:
- 异常类型与频次:是
ConnectionTimeoutException还是Connection is not available, request timed out?后者说明连接池已经耗尽,前者可能是网络问题。同一时间段内同类异常的出现次数,直接反映问题的严重程度。 - 异常出现的位置:堆栈里是发生在 MyBatis 执行 SQL 的时候,还是在调用远程服务的时候?这决定了问题出在数据库端还是下游服务端。
- 请求关联 ID:通过 Trace ID 把同一个请求在网关、应用、数据库三条链路上的时间串起来,看时间到底消耗在哪一段。
我当时在日志里翻到了一个反复出现的报错:HikariPool-1 - Connection is not available, request timed out after 30000ms。这个报错的意思很明确——应用向连接池申请数据库连接时,等了 30 秒都没等到空闲连接,直接超时。换句话说,连接池里的连接被占满了,新请求拿不到连接。
这里还要多说一句:日志级别别乱调。很多人排查问题的时候把 DEBUG 全开,结果日志量爆炸,反而把关键信息冲掉了。我习惯是把错误日志和慢请求日志单独归档,保持 WARN 以上级别长期开启,这样故障时能快速定位,平时又不会打扰。
2.2 监控指标里的规律:连接池曲线比日志更诚实
日志给出了"连接池耗尽"这个定性结论,但为什么耗尽、什么时候开始耗尽的,日志里看不全。我又调出了数据库连接池的监控曲线,这一看就发现了更精确的规律:
- 连接池活跃连接数在整点前的五分钟内持续走高,从平时的 20 个左右一路涨到池子上限 50 个。
- 等到整点一过,活跃连接数开始回落,但回落速度很慢,要持续七八分钟才能降回正常水平。
- 在这段"高位横盘"期间,新请求的等待时间明显拉长,部分请求直接超时。
把这两条曲线和日志里的异常时间对在一起,完整的因果链已经浮现:某个周期性任务在整点前触发大量 SQL 查询,这些查询执行得非常慢,每个连接被占用的时间远超正常水平,于是连接池被慢查询堵死,后续正常请求也拿不到连接,形成雪崩。
排查到这一步,我已经比较有把握了。剩下的问题是:慢查询到底是哪一条?为什么平时没事、偏偏周期性发生?这时候就要进入数据库那边去看了。
2.3 排除法的正确姿势:每排除一个环节都要有日志或指标支撑
在锁定数据库之前,我还快速过了一遍其他可能环节,这里分享下排除法的正确用法。排查问题最忌讳的是"每样都试一下",正确做法是对每个假设都找到对应的反证或佐证:
- 网络问题:检查了网关到应用的 TCP 重传率、丢包率,正常。排除。
- 下游依赖:订单服务依赖的库存服务、用户服务在故障时段指标平稳,响应时间没有波动。排除。
- 应用自身资源:CPU 使用率在故障时段最高只有 55%,内存没有明显增长,GC 停顿最长 80ms,均不构成瓶颈。排除。
- 数据库:活跃连接数打满、慢查询日志里出现了耗时 12 秒的 SQL。锁定。
这一套排除下来,每一步都有监控数据支撑,而不是"我觉得不是网络问题"。排查类工作最怕的就是自我感觉良好,你必须有证据才能往下走。
3. 根因浮出水面:一条慢查询、一个连接泄漏点、一个配置误区
3.1 慢查询为什么慢:索引选择失败的典型剧本
慢查询日志里最扎眼的一条 SQL 长这样:
sql复制SELECT * FROM order_detail
WHERE merchant_id = ? AND order_status IN (1,2,3)
AND create_time > ?
ORDER BY create_time DESC LIMIT 50;
单看这条 SQL 并不复杂,表里数据量也就 200 多万行。问题出在索引上:查询条件里有 merchant_id、order_status、create_time 三个字段,但表上只有 merchant_id 的单列索引和 create_time 的单列索引。MySQL 优化器在执行的时候,往往会选择 create_time 索引来满足排序需求,然后对 merchant_id 做回表过滤。
这个选择在数据量小的时候没有感觉,但订单表的特点是 merchant_id 分布极度不均衡——大商户的订单量能占全表的 30%。一旦某个大商户的订单数据落到 create_time 的某个时间分区里特别密集,这条查询扫描的行数就会从几百行暴涨到几十万行,单次查询耗时直接从 50ms 涨到 12 秒。
为什么会是周期性触发?因为那条 SQL 就是整点前的"订单汇总对账任务"在跑,平时它扫描的数据量不大,刚好赶上那个时段大商户的订单激增,执行计划就崩了。
这里我要强调一句:SQL 慢不一定是因为没建索引,更常见的是建了索引但优化器选错了。 排查时不要只看有没有索引,要用 EXPLAIN 看实际执行计划,重点关注 type、key、rows 三列。type 如果是 ALL 或 index,说明在扫全表或扫全索引,这基本就是慢查询的元凶。
3.2 连接池耗尽的连锁反应:Little's Law 视角下的计算
很多人不理解为什么一条慢查询就能拖垮整个服务。这里用一个小公式解释,特别直观:
连接池需要的大小 ≈ QPS(每秒请求数)× 平均响应时间(秒)
假设订单服务正常时 QPS 是 200,平均响应时间是 100ms(0.1 秒),那么理想情况下只需要 200 × 0.1 = 20 个连接。这也是为什么连接池默认配置 50 个看起来绰绰有余——因为它本来就是按照正常延迟来估算的。
但当慢查询出现,一部分请求的响应时间变成 12 秒,这些请求占用的连接在 12 秒内不会被释放。假设每分钟有 50 个慢请求,等于同时有 50 个连接被"冻结"。算下来就是:慢请求把池子占满 → 正常请求拿不到连接 → 正常请求在池子外面排队等待 → 排队的请求也算进了"连接池不够用"的循环里,最终服务整体不可用。
这就是为什么连接池耗尽类故障往往不是渐进式恶化的,而是突然一下就雪崩。很多同行在复盘时只盯着"连接池太小"这个表面原因,其实真正的元凶是慢查询和连接泄漏,池子配置只是背锅侠。
3.3 隐藏的第二个问题:异常路径上的连接泄漏
修慢查询之前,我又顺手把那个定时任务的代码翻了一遍,结果发现了另一个更隐蔽的问题——连接泄漏。
那段代码长这样(简化版):
java复制public void syncOrderData() {
Connection conn = null;
try {
// 省略前面的业务逻辑
conn = dataSource.getConnection();
// 执行慢查询
List<Order> orders = queryOrders(conn);
// 处理数据
process(orders);
} catch (Exception e) {
log.error("同步订单数据失败", e);
// 注意:这里直接抛出,没有关闭连接
throw e;
} finally {
// 正常路径会走到这里,但异常路径在 catch 中已经抛出
// 而且这里也没有关闭 conn
// System.out.println("finally");
}
}
我见过太多类似的代码了:正常路径上连接释放没问题,但一旦业务逻辑抛异常,连接就永远"丢"了。连接池里的连接被借出去之后如果没人归还,它就一直处于活跃状态,直到连接池老化回收机制把它清掉。在慢查询出现的那个时段,恰好异常也频繁,等于一边慢查询占用连接,一边异常泄漏连接,两个问题叠加,池子很快就空了。
修复方式其实很基础:用 try-with-resources 或者确保 finally 里释放连接。但这类问题在代码评审阶段往往被忽略,因为只要不触发异常路径,它就永远不会暴露。
4. 修复方案落地的三个阶段:止血、根治、验证
4.1 紧急止血:先恢复可用性,再谈优化
定位到根因后,我没有立刻去改代码。原因是:改代码需要走发布流程,至少半小时,而线上每一分钟都在产生用户投诉。止血阶段我的优先级是"尽快恢复服务"。
当时做的两个止血动作:
- 临时把连接池上限从 50 调到 120,同时把连接等待超时从 30 秒降到 5 秒。调大上限是为了给慢查询和正常请求更多的并发空间;调短超时是为了让拿不到连接的请求快速失败,而不是全部堆积在池子入口。
- 直接把那个定时任务临时停掉。反正它只是一个对账汇总任务,晚跑一小时不影响核心交易链路,但留在这里会持续消耗数据库连接。
这两个动作做完,十分钟内错误率就从 6% 降到了 1% 以内,P99 也恢复了正常。这里有个经验:故障处理时要敢于做"业务上可接受的牺牲",不要死守所有功能同时在线。 一个辅助任务停掉带来的损失,远小于核心链路雪崩带来的损失。
还有一点很重要:止血操作要记录清楚,谁操作的、改了什么、原始值是多少、什么时候改的,全部记在故障群里。不然事后恢复的时候容易漏改回去。
4.2 根治修复:索引、连接管理、连接池参数三管齐下
服务恢复可用后,才开始做根治。我按优先级排了三件事:
第一件:优化索引。
不再依赖 MySQL 优化器的自觉,直接建了联合索引:
sql复制ALTER TABLE order_detail
ADD INDEX idx_merchant_status_time (merchant_id, order_status, create_time);
这个联合索引同时覆盖了查询条件里的三个字段,由于索引的最左前缀原则,merchant_id、order_status 的等值过滤可以精确定位数据范围,create_time 用于排序和范围过滤。建完索引后,我用 EXPLAIN 验证执行计划,key 已经变成新索引,扫描行数从 48 万行降到了 320 行,慢查询耗时从 12 秒降到了 45ms。
第二件:修复连接泄漏。
把那段定时任务代码改成 try-with-resources 写法:
java复制public void syncOrderData() {
List<Order> orders;
try (Connection conn = dataSource.getConnection()) {
orders = queryOrders(conn);
process(orders);
} catch (Exception e) {
log.error("同步订单数据失败", e);
throw new BizException("订单数据同步失败", e);
}
}
try-with-resources 会在代码块退出时自动调用 close(),不管正常返回还是抛异常都会释放连接。这个改法看起来就是少了几行代码,但它消除了一个隐患。
第三件:连接池参数调优。
我把连接池配置从一刀切改成按业务分流:
- 核心交易链路连接池:最大 80,最小空闲 10。
- 定时任务等非核心任务:单独用独立的数据源,最大 15,并且设置
connectionTimeout=3000、validationTimeout=1000。
核心交易链路和定时任务共用一个连接池本身就是一个设计隐患——非核心任务的慢查询可以直接拖垮核心链路。我之前只考虑了"够不够用",没有考虑"互相影响",这次故障让我重新认识到了资源隔离的重要性。
连接池参数不是越大越好。池子越大,数据库需要维护的连接数就越多,切换和断连的成本越高。正确的思路是按业务的实际 QPS 和响应时间算一个合理值,再留 1.5 到 2 倍余量。
4.3 验证方案:没有压测通过,就不要说修复完成
修完代码不是结束,必须验证。我的验证分三步走:
第一步:SQL 级验证。 直接在测试环境执行优化前后的 SQL,对比执行计划和耗时。目标是把 12 秒降到 200ms 以内,实际到了 45ms,达标。
第二步:接口级压测。 用线上同样的流量模型(每个整点前五分钟 QPS 翻倍)对接口做压测。压测工具用的是自建的压测平台,压了 30 分钟,期间持续观察连接池活跃连接数曲线,确认最高只到 40 个,没有触顶,P99 稳定在 180ms 左右。
第三步:灰度发布。 先让新版本在两个节点上运行,观察半小时,确认没有异常后再全量发布。灰度期间我盯的是三个指标:错误率、P99 延迟、连接池活跃连接数。三个指标都平稳,才允许发布剩余节点。
关于修复验证,我的观点是:验证的严格程度要和故障的危害程度成正比。 如果这是一次差点导致线上事故的重大故障,我甚至会强制要求做一周的观察期,期间每天出一个稳定性日报,确认无复发后再关单。
5. 故障复盘与长效防护:修好一次不算本事,不长教训才是损失
5.1 监控告警的补位:让下一次故障在萌芽阶段就被发现
复盘会上我第一个提的就是监控盲区。这次故障从慢查询出现到连接池告警触发,中间隔了将近 25 分钟。为什么这么久?因为原来只监控了"错误率"和"接口延迟"这两个业务层指标,连接池的活跃连接数、慢查询数量和耗时都没有监控。
所以监控补位做了四件事:
- 为连接池活跃连接数、等待获取连接数、池子利用率增加监控,超过 70% 就告警,超过 90% 立刻盯人。
- 为慢查询单独拉一条监控,任何 SQL 执行超过 1 秒就记录并告警,不再依赖事后翻慢查询日志。
- 把数据库的"活跃会话数"纳入监控大盘,和连接池指标做联动。
- 设置定时任务的执行时长监控,超过基线 3 倍直接告警。
告警不是越多越好,而是要分级:P0 级(服务不可用)立刻响铃拉群,P1 级(指标恶化但服务可用)五分钟内响应,P2 级(潜在隐患)当天处理。如果你把所有阈值都设置得特别敏感,告警疲劳反而会让真正重要的告警被忽略。
5.2 把经验沉淀成可复用的排查清单
复盘结束后,我把这次排查过程整理成了一份连接池故障排查清单,发给了团队。现在分享出来,大家写自己的 runbook 时可以做个参考:
- 收到连接池相关告警,第一步看连接池基线监控:活跃连接数是多少、池子上限是多少、最近一小时曲线是什么趋势。
- 第二步看慢查询:最近五分钟有没有新增慢 SQL?耗时多少?扫描行数多少?执行计划是否合理?
- 第三步看代码:异常的请求链路里,有没有在 catch 或异常分支里忘记释放连接?
- 第四步看同步任务:故障发生时段有没有周期性的批处理任务在跑?这些任务是否和核心链路共用资源?
- 第五步看变更:过去两小时有没有发布、索引变更、配置调整?
这份清单的核心逻辑是"从资源池到调用方再到变更"的自底向上排查。很多人遇到这类问题喜欢先怀疑别人——网络、数据库、中间件——但实际上大部分问题都出在自己应用层。
5.3 复盘会上的几句实在话
最后聊几句复盘会的体会。技术团队做复盘时特别容易陷入两个极端:要么变成互相甩锅,要么变成你好我好大家好。我比较认可的复盘模式是:把"人的失误"转化为"流程和机制的问题"。
比如这次,如果只追责"写代码的人没有在 finally 里关连接",那下次换个场景换个代码还会犯同样的错。但如果从机制层面去改——在 Code Review 检查单里强制加入"资源释放检查"、在发布流程里强制加入"慢查询审查"、在测试流程里强制加入"异常路径测试",那问题才算是真正被解决。
我的习惯是每次故障复盘后都输出三样东西:一份根因分析报告、一份可落地的整改清单、一份更新后的排查手册。这三样东西的价值远远大于当场的那次修复。
现在回头看这次故障,它其实一点都不复杂——一条慢查询、一个连接泄漏点、一个资源隔离缺失,三个普通问题叠加在一起,就造成了服务雪崩。这也是我想通过这次排查经历表达的:故障排查和修复,从来不是靠灵感,而是靠流程、证据和工具。 你只要把现场固定住,把证据链铺开,把假设一个个验证掉,绝大多数问题都会水落石出。先把这套方法练熟,再遇到十级火情的时候,你就不会慌到只能按重启键了。
