☰
徒手挖掘:线上故障排查的核心能力,从jstack到EXPLAIN的实战指南
2026/10/27 22:22:02 网站建设 项目流程

你有没有遇到过这种情况:线上一个接口突然变慢,日志只有一句timeout,监控图上一片飘红,大家靠“重启大法”恢复了三次,每次都管用三四天,然后它在另一个时段再次出现。第四次的时候,所有人都沉默了,因为你已经找不到可以继续“重启”的借口了。

很多团队把这类问题叫“偶发故障”。但真正的偶发故障其实很少,大部分只是你还没有往下挖到那层必然原因而已。

这两年的技术圈特别推崇“脚手架”、“低代码”、“智能运维”,这当然提升了产出效率。但有一项能力被悄悄低估了——在工具失效、日志不够、文档不对的时候,你愿不愿意关掉舒服的仪表盘,蹲下来亲自扒开泥土看一眼最底层的东西。**真正的工程师,是可以徒手挖掘的人。**他们不会停在“现象已经恢复”,而是会一路挖到“根因已经确认、修复已经验证、同类问题不会再犯”。

本文不打算讨论什么宏大叙事,而是想通过两个非常典型的线上问题,拆解“徒手挖掘”到底是怎么回事。你会发现,它既不是玄学,也不完全依赖经验,它是一套可以训练的思路:先看现象,再分层,再定位,再验证。我会把具体命令、代码片段、判断路径都写出来,哪怕你现在就在值班,也能照着做一遍。

1. 什么是“徒手挖掘”,为什么工程师必须具备这项能力

先统一一下概念。我这里说的“徒手挖掘”,不是指不借助任何工具,而是指在既定工具链断掉之后,你还能依靠操作系统、运行时、接口现场和源码去定位问题。它本质上是从现象层跨越到机制层的一种排障方式。

现在很多应用是分层的:前端有网关,后端有框架,框架下面有容器,容器里面是操作系统,操作系统再往下是 CPU、内存和磁盘。日常开发中,绝大多数工程师只需要和最上面两层打交道。业务变化太快,框架帮你封装了事务、连接池、线程调度与配置管理,你根本不需要关心底层细节。

但问题恰恰出在“不需要关心”四个字上。当框架替你处理了 99% 的逻辑,剩下的 1% 一旦出错,你既没有处理经验,也缺少必要的排查直觉。比如:

  • 为什么连接池明明设置了maxActive=20,请求量没到 20 就开始报获取连接超时?
  • 为什么 JVM 堆内存看起来只用了 30%,接口却卡在高延迟附近?
  • 为什么 MySQL 有索引,条件也写了,查询还是要扫描全表?
  • 为什么 Redis 慢查询监控显示平均延迟不到 1ms,业务链路却出现偶发超时?

这些问题有一个共同点:**应用层给出的信息是“结果”,不是“原因”。**你盯着报错日志看一百遍,也只能看到“我等不到连接”或“我超时了”,至于谁把连接拿走了、为什么迟迟不归还、此刻线程在哪个位置等待,日志基本不会告诉你。

只有当你愿意往下挖一层,去看线程栈、看堆内对象分布、看执行计划、看系统调用,你才能从“现象”里走出来。这种往下挖的动作,就是徒手挖掘。它不是某个岗位的专属技能,而是所有后端开发、运维、测试甚至前端工程师在关键时候救场的能力。

2. 那些年我们挖过的最典型的“三层土”

实战里,我总结了一个排障经验:当你面对一个线上故障,不要急着改代码,先把自己的视角从业务逻辑切到运行环境。大部分问题都埋在三层土里,你必须一层一层往下刨。

**第一层:进程层。**应用进程是否活着、线程数是否异常、GC 频率如何、文件描述符是否泄漏、日志是否在刷同一个错误。

**第二层:资源层。**CPU 使用率、内存占用、磁盘 IO、网络连接数、TCP 重传率、连接池/线程池水位。

**第三层:依赖层。**数据库、缓存、消息队列、外部接口等下游服务的状态,以及它们与当前链路之间的协议交互。

很多人遇到问题先怀疑数据库,这是倒着挖。顺序应该是先看自己的进程,再看本机资源,最后才追依赖。如果一上来就登录数据库看,往往会绕一大圈,最后发现是自己的连接池配置写错了。

举个例子。有一个服务每天凌晨会报一批Connection is closed,白天一切正常。开发团队查了很长时间数据库,甚至考虑过是不是 DBA 改了最大连接数。然后有人去看了应用日志的时间分布,发现报错全部集中在定时任务启动后的 3 秒内。再往后挖,发现定时任务里有个工具类在重复创建数据库连接,任务并发执行时把连接数打到了上限,后续连接全部被判拒绝。问题不在数据库,而在于连接管理方式。但如果只看数据库层面,这个结论可能永远出不来。

这就是分层排查的意义。**每一层有每一层该看的信息,不要跳过当前层直接去怀疑下一层。**跳过,意味着你失去了本可以定位到根因的证据。

3. 徒手挖掘的标准动作:从进程和系统调用入手

下面列几个我几乎每次深挖问题都会用到的“标准动作”。它们不是最深的原理,但却是最容易让你看到真相的第一步。

3.1 先看进程在哪

如果你负责的是一个 Java 应用,第一步不是打开 IDE,而是找到进程并确认它的状态:

jps -l # 输出示例 # 12345 /opt/app/order-service.jar

如果jps找不到进程,说明应用可能根本没起来;如果找到了,就可以继续看线程情况。

3.2 看线程在等什么

线程卡住、响应变慢、CPU 飙高,这些症状最终都要落在线程栈上。但jstack不能乱用,生产环境它会把进程暂停一下,不过对绝大多数应用来说,短暂停顿可以接受。建议先 dump 两到三次,间隔 5 秒,对比线程状态变化。

jstack 12345 > /tmp/jstack_1.txt sleep 5 jstack 12345 > /tmp/jstack_2.txt

然后重点看这几类线程:

  • RUNNABLE:正在执行的线程,不代表没毛病,要看它执行到哪个栈帧。
  • WAITING/TIMED_WAITING:在等待锁或条件变量,如果大量业务线程都在等待,说明池子已经耗尽。
  • BLOCKED:被某个锁挡住,需要进一步找持锁线程。

3.3 看 JVM 堆内存分布

如果你怀疑内存或 GC 问题,可以先看堆内对象的分布,快速判断是不是某个业务对象被大量创建:

jmap -histo:live 12345 | head -30

这条命令会触发一次 Full GC,生产环境要小心。如果实在不能接受,就换用jmap -histo(不加:live),别硬来。

3.4 用系统调用看底层行为

很多在应用里看不到的细节,到了系统调用层面就会暴露出来。strace是 Linux 下排查系统调用问题的利器,适合看文件读写、网络连接、信号处理等。不过在高并发 Java 应用上使用时要谨慎,因为它会严重拖慢进程,通常先在测试环境复现,再决定是否在生产上短时间采样。

strace -f -p 12345 -e trace=network -o /tmp/strace_network.log

网络类问题还可以抓包:

tcpdump -i eth0 host 10.0.0.1 and port 3306 -w /tmp/mysql.cap

抓包产生的文件可能很大,实际使用时要加上-c限制包数量,或者按时间窗口切片。

4. 实战案例一:一个“三天一挂”的 Java 服务

我们来看一个典型的徒手挖掘过程。

这个服务的现象非常规律:运行三天后,接口响应越来越慢,随后服务直接不可用,重启之后立刻恢复,再过三天又复现。

团队先按惯性看了日志,发现大量RedisConnectionFailureException和Unable to connect to Redis。于是怀疑 Redis 有问题,联系中间件团队反复排查,结论是 Redis 本身没有任何异常。随后又怀疑网络抖动,但网络监控也没有发现问题。

真正开始往下挖,是从一次服务“假死”但还没有完全崩溃时开始的。当时先执行了jstack,看到大量业务线程处于如下状态:

"http-nio-8080-exec-7" #27 daemon prio=5 os_prio=0 java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:341) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(...) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(...) ... at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(...) at redis.clients.jedis.JedisPool.getResource(...)

这里描述的不是某个具体项目的源码,而是一类经典结构。你真正在现场看到的堆栈可能类名不同,但模式几乎一致:**大量线程停在一个连接池的borrowObject方法上,说明连接获取已经阻塞。**接着再看 Redis 连接池的配置:

spring.redis.jedis.pool.max-active=8 spring.redis.jedis.pool.max-idle=8 spring.redis.jedis.pool.min-idle=0

max-active=8,也就是最多同时 8 个连接。如果有 8 个线程分别持有一个连接,并且它们都在等待一个永远不会返回的 Redis 命令响应,那么第 9 个线程会一直卡在borrowObject上。一旦卡住,业务线程积压,Tomcat 线程池被占满,服务自然“假死”。

但还有一个疑问:为什么是三天后才挂?这要靠下一层挖掘来解释。继续使用jstack看那 8 个持锁线程,发现它们都阻塞在类似这样的代码上:

List<String> values = redisTemplate.opsForList().range(key, 0, 1000);

这个操作本身不至于卡死,除非它在等待一个永远不会到达的网络响应。而 Redis 命令之所以不返回,是因为客户端和 Redis 之间可能已经在 TCP 层断连,但连接池中的连接对象还认为自己是可用的。于是应用会卡到 socket 读超时,而读超时时间又设置得非常长。当请求流量逐渐堆积,某个时刻恰好 8 个连接全部进入这种“僵尸”状态,整个服务就被击穿了。

这个问题要真正根除,需要修改的是:

# 设置合理的读超时,避免线程长时间阻塞 spring.redis.timeout=2000ms # 如果使用 Lettuce,可以开启连接校验 spring.redis.lettuce.pool.max-active=16 # 取用连接前校验连接可用性 spring.redis.jedis.pool.test-on-borrow=true

这整个链条,靠应用日志根本看不到。你只有看见线程栈,才能知道“阻塞发生在连接池”;只有进一步看见持锁线程,才能知道“僵尸连接来自 Redis”;只有再追到 TCP 层和超时配置,才能解释“为什么是三天”。这就是徒手挖掘的真实路径:每个问题都至少要往下挖三次,才能看到全貌。

5. 实战案例二:一条越来越慢的 SQL

第二个案例同样典型。

业务反馈某个报表查询越来越慢,刚开始是 200ms,半年后变成 3 秒。数据库团队看slow_log,确认有一条 SQL 占到慢查询总数的 80%。表结构非常简单:

CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) DEFAULT NULL, `buyer_uid` bigint NOT NULL, `status` tinyint DEFAULT NULL, `amount` decimal(10,2) DEFAULT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_status_create_time` (`status`, `create_time`) ) ENGINE=InnoDB;

慢 SQL 长这样:

SELECT order_no, amount FROM t_order WHERE buyer_uid = 12345 AND status = 1 ORDER BY create_time DESC LIMIT 10;

第一眼看上去很冤:buyer_uid是查询条件,status有索引,create_time参与排序,为什么不快?很多人这时候会选择加一个联合索引解决。

如果你开始动手加索引,就已经是在盲改了。正确的做法是先让它告诉你原因,先执行:

EXPLAIN SELECT order_no, amount FROM t_order WHERE buyer_uid = 12345 AND status = 1 ORDER BY create_time DESC LIMIT 10;

执行计划往往会显示type=index,key=idx_status_create_time,rows非常大。type=index表示它虽然用了索引,但实际上是在按索引顺序做全索引扫描,相当于把status=1的所有行都读出来,再进行create_time排序。因为status的区分度太低了,这个索引根本没有有效地过滤数据。

走到这一步,很多人会得出“索引不对,改成idx_buyer_uid_status_create_time”的结论。这个方向没错,但还没挖到底。真正需要回答的问题是:为什么这条 SQL 用了半年才开始变慢?

继续挖,发现buyer_uid这一列存在隐式类型转换的风险。继续看表结构时才发现,buyer_uid在业务代码里被传成了字符串:

String buyerUid = userService.getBuyerUid(request);

MySQL 在比较时会对字段做类型转换,导致buyer_uid上的索引失效。但因为查询里还有status和create_time,优化器可能选了一条看似“还有救”的路径,实际却是在一大片数据里做过滤。半年过去,订单数据量翻了数倍,这条“假索引”路径终于撑不住了。

正确的修复有两步:

第一步,修改业务代码,把类型对齐:

long buyerUid = Long.parseLong(userService.getBuyerUid(request));

第二步,根据实际业务查询模式,把联合索引建对:

ALTER TABLE t_order ADD INDEX idx_buyer_uid_status_create_time (buyer_uid, status, create_time);

这个案例想说明的是:**慢 SQL 的根源往往不在 SQL 本身,而在 SQL 前后那几层。**如果只停留在“给这个 SQL 加索引”的层面,你确实能暂时解决问题,但半年后订单量再涨一波,新的慢 SQL 会出现在另一个查询上,因为根子是类型混乱和索引设计没有跟上业务变化。

6. 比工具更重要的是:建立一套底层的因果思维

工具只是“手”,因果思维才是“大脑”。

我在看很多工程师排障时,发现他们往往不是不会用工具,而是没有建立起一套“现象 -> 原因 -> 证据 -> 修复”的闭环。比如看到Connection is closed,直接反手加大连接池;看到 CPU 高,就盲目扩容;看到慢 SQL,就加索引。这些操作在表面上有用,但往往掩盖了更深的因果关系。

徒手挖掘时,建议始终带着下面这个因果链想问题:

  1. **现象是什么?**精确描述,包括开始时间、持续时间、影响范围、触发条件。
  2. **哪一层最先出现异常?**先看进程层、资源层、依赖层的顺序,别跳层。
  3. **这个异常上游发生了什么?**比如连接池耗尽,上游是否真的有过高并发?还是因为某个连接泄漏?
  4. **这个异常会导致什么结果?**比如线程堆积会导致 GC 压力增大,GC 又会导致更多超时,形成雪崩。
  5. **修复后如何验证?**单独修一个点不算完,要观察一个完整业务周期,确保没有复发。

利用jstack,你应该能画出一张业务线程状态的图;利用EXPLAIN,你应该能画出数据扫描路径;利用strace,你应该能画出系统调用序列。能画出这些图,你才真正明白了系统在干什么。

很多问题看起来是随机跳动,其实是固有缺陷在某个边界条件下被激活。你要找的,不是那个让你“这次侥幸恢复”的按钮,而是那个在特定条件下必然触发的开关。

7. 徒手挖掘中常见的误区与反思

“徒手挖掘”不是鼓励你把所有东西都推倒重来,更不是让你不信任任何框架。真正的徒手挖掘,是在工具和框架失效时,还不至于失去判断力。这里有几个常见误区,值得反思。

7.1 把“表面修改”当成“根因修复”

修改连接池参数、增大超时时间、加一台机器,这些操作本身没有错,但如果没找到根因,它们的有效时间通常不会太长。比如前面那个 Redis 连接池“僵尸连接”的问题,如果你只是把max-active从 8 调到 80,那么很快会出现 80 个线程同时卡住的情况。你也只是把故障阈值推迟了一点。

7.2 只验证“现象消失”,不验证“机制正确”

有些时候,现象消失了,但不一定是你的修复生效了,也可能是高峰期过了,或者下游服务恢复了。怎么区分?用机制来判断。你的修复是不是对着因果链的某一环下的手?如果是,即使现象暂时不消失,也值得继续观察;如果不是,哪怕现象消失了,也要警惕下一次爆发。

7.3 在错误的一层浪费太多时间

有的问题,你开了几十次会都查不出来,是因为团队一直在应用层讨论,而问题出在操作系统或网络层。反之亦然,一些明显是业务逻辑 bug 的问题,被一堆人拉到中间件层面验证了半天。在排障之前,花 10 分钟先确定“这个问题最可能存在于哪一层”,往往比直接动手更高效。

7.4 拿着工具乱挖,没有形成记录

还有一种情况是日志已经打出来了,但是没人把它和前面的监控图对照起来。jstack打了,但没保存;EXPLAIN看了,但没复制;tcpdump抓了,但没分析。挖掘出来的证据到不了团队其他人手里,下一次复现时又要从头再挖。每一次排障过程都应该像一次现场考古挖掘,每一步的“土层样本”都必须记录下来。

8. 如何保持“徒手挖掘”的能力

这项能力不是天生的,它会随着你接触业务越深、越依赖框架越弱。要保持住,需要刻意训练。

8.1 维护自己的“排障工具箱”

我建议每个后端工程师都建立一个自己的排障笔记,包含:

  • JVM 常用命令:jps、jstack、jmap、jstat、jcmd;
  • Linux 常用命令:top、vmstat、iostat、ss、strace、lsof;
  • 数据库常用命令:EXPLAIN、SHOW ENGINE INNODB STATUS、SHOW PROCESSLIST;
  • 抓包工具:tcpdump、Wireshark的基本过滤语法。

这些命令不需要每天用,但要用的时候你得能流畅地组合起来。建议在测试环境模拟几个经典故障:线程池耗尽、死锁、慢查询、内存泄漏,然后逼自己只靠命令行把根因找出来。

8.2 每个月读一段核心框架源码

不要只把源码当成晋升面试的谈资,它可以帮你建立“机制感”。比如你读过AbstractQueuedSynchronizer,就能理解线程阻塞在await和park上分别代表什么;你读过连接池的borrowObject源码,就能明白为什么“拿到连接”和“使用连接”之间还有一大段校验逻辑;你读过 MyBatis 的Executor实现,就能理解#{}和${}在 SQL 分析阶段到底改变了什么。这些机制知识会在你看到线程栈上的某个类名时,自动唤醒。

8.3 复盘时,不只讲“怎么修的”,要讲“怎么挖到的”

团队复盘时,建议大家把重点放在“发现路径”而不是“修复方案”上。比如你可以这样记录:

  • 第一次发现是什么时间?通过什么指标发现的?
  • 当时采用了哪些手段,哪些有效,哪些无效?
  • 什么时候开始使用jstack/EXPLAIN/tcpdump?
  • 最终是哪一条日志、哪一行堆栈、哪一个执行计划提供了决定性证据?

这种复盘比“我们加了索引,恢复了,以后注意”有价值得多。它能训练大家的问题定位思路,也能积累团队自己的“土层地图”。

9. 结论:技术与责任感,都要在泥土里练出来

回到文章标题。真正的工程师为什么是“徒手挖掘”的?因为总有一天,工具会变得不够聪明,日志会被打得残缺,框架会在某个边界上露出不可预测的行为。那一瞬间,决定你能不能解决问题的,不是你会几个 API、背过多少八股文,而是你有没有办法在混乱里往下挖到真相。

同时,徒手挖掘也是一种责任感。它意味着你不满足于“服务恢复了”,也不满足于“领导没追问了”,你坚持要看到原因,看到机制,看到证据链条的完整性;你还愿意把自己的挖掘路径记录下来,让下一个遇到类似问题的人少走一段长路。

这篇文章不是让你推翻框架、回归底层重复造轮子,而是想让你在享受云原生和低代码带来的便利时,不要丢掉最基本的工程直觉:所有抽象层都会泄漏,真正的工程师是那个不怕弯下腰、伸手摸到底、再把整块土壤翻出来给大家看清楚的人。

如果你现在正处于一个排障无头绪的时刻,不妨从执行jstack、EXPLAIN、lsof这样的命令开始。一次只挖一层,一层只找一条线索,线索链完整了,问题自然就浮出水面。方法不难,难的是你愿意蹲下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询