ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

排障不是猜谜:亲手挖到根因的五层方法

排障不是猜谜:亲手挖到根因的五层方法 晚上十点半某台应用服务器开始告警接口平均延迟从 80ms 涨到 3s调用方已经开始超时重试。这种时刻不同工程师的反应差别很大。有人先把报错截图发到群里有人搜了第一个关键字就开始改配置也有人会先确认日志、看进程、看端口、看资源再决定下一步动哪里。最后一种人的工作方式就像文章标题说的那样Real Engineers Dig with Their Bare Hands。真正的工程师会用自己的双手一直挖到问题发生的底层证据。这不只是态度问题而是一套可以训练的排障方法论。这篇文章要做的就是把这句口号拆开变成可执行的观察顺序、命令、验证实验和检查清单。1. 为什么排障不能停在表面答案1.1 同样的报错可能来自完全不同的根因排障最危险的一步是把“错误信息”当成“根因”。以最常见的Address already in use为例它至少可能来自三种完全不同的情况端口确实被另一个进程占用。进程绑定了 IPv4 地址但客户端使用 IPv6 方式访问操作系统返回Address already in use。服务异常退出后端口进入TIME_WAIT新进程没有开启SO_REUSEADDR短期内无法重新监听。如果只看到这一行报错就去找“端口占用解决方法”很可能把SO_REUSEADDR加上但实际问题是另一个进程还活着。网络层、进程层、代码层的原因靠同一个错误文案是完全区分不开的。搜索引擎和 AI 工具的价值在于给排障提供候选方向而不是直接代替排查过程。搜到的答案往往基于某个版本、某个操作系统、某个特定配置组合。如果不知道自己的问题产生链路照搬答案很容易修好一个表面现象再踩进下一个坑。真正的排障起点应当是“先确认现象再复现再验证”。报错信息只是现象的一种表达方式不是技术结论。1.2 “亲手挖掘”意味着五个动作把“Real Engineers Dig with Their Bare Hands”翻译成技术行为可以拆成五个动作观察看现象、看日志、看监控曲线保留第一手信息。复现缩小输入范围把随机问题变成稳定问题。测量用命令、工具或临时埋点量化当前进程、端口、资源、代码路径的状态。验证每次只改动一个变量观察结果变化确认因果关系。记录把命令、时间点、结果、修改项保存下来形成可回溯的排查轨迹。这五个动作的关键点是一致的不轻信别人给出的结论也不轻信自己第一次看到的假设。每得到一个判断都要有证据支持。实际工作中很多人缺的不是技术知识而是“先验证再下结论”的习惯。比如服务内存上涨第一反应是“堆太小”但看完free -h发现物理内存本身已经耗尽再细查可能是/tmp被日志写满而不是 JVM 参数问题。如果只看top中的内存列很容易把系统内存问题误判成应用堆问题。这种误判只有一路往下挖到指标和日志真相对得上时才能避免。2. 挖掘之前先准备好可以观察的实验场2.1 用容器搭建一个最小服务要训练“亲手挖掘”的能力首先需要一个可以随便折腾、不会影响线上业务的环境。容器是最合适的学习场地因为它允许在几分钟内重建整个环境也可以随时查看容器内的进程、日志和文件系统。下面用一个 Python Flask 服务作为示例。先创建app.pyfrom flask import Flask import logging logging.basicConfig(levellogging.DEBUG) app Flask(__name__) app.route(/health) def health(): logging.info(health check) return ok app.route(/task) def task(): logging.debug(task start) return done if __name__ __main__: app.run(host0.0.0.0, port8080)这个服务很简单却足够支撑后续的日志查看、端口检查和进程观察。再创建DockerfileFROM python:3.11-slim WORKDIR /app COPY app.py . RUN pip install flask EXPOSE 8080 CMD [python, app.py]构建并启动docker build -t bare-hand-lab . docker run -d --name bare-hand-lab -p 8080:8080 bare-hand-lab curl http://127.0.0.1:8080/health返回ok说明服务正常。用容器的好处在于排障时不会污染宿主机器。比如后面要模拟磁盘占用、内存增长、端口冲突都可以在这套环境里做结束后直接销毁容器重新创建。查看容器内进程时可以用docker top bare-hand-lab docker exec -it bare-hand-lab bashdocker top会输出容器内进程的 PID、PPID、状态和启动命令。docker exec可以进入容器内部查看文件系统、环境变量、网络连接。这个能力本质上就是在“下探到系统内部”和线上排查的方向一致。2.2 日志、进程和资源观测先打开没有观测能力就没有办法挖掘。在开始排障训练之前至少要把三类观测打开应用日志docker logs -f bare-hand-lab可以实时看到容器内 Python 进程的输出。系统日志在 Linux 主机上可以看/var/log/syslog或/var/log/messages使用 systemd 的系统可以用journalctl。系统资源top、free -h、df -h、vmstat 2分别对应 CPU、内存、磁盘和系统整体状态。组合式观察是一种常用做法。比如每隔两秒刷新一次进程状态watch -n 2 ps -eo pid,ppid,stat,cmd | grep python-e表示显示所有进程-o指定输出列后面跟 PID、PPID、状态和命令行。这样可以看到容器内的 Python 进程是否存活、状态是否为S睡眠或R运行。这里要理解日志和指标不是排障的终点而是定位问题的路标。它们回答的是“系统正在做什么”和“系统状态如何”真正判断“为什么”还需要继续往下挖。3. 五层挖掘法从请求到代码逐层下探3.1 第一层输入、配置和环境变量很多线上问题原因不是代码坏了而是“输入变了”。发布时改了配置、环境变量没对齐、请求参数和测试环境不同这些都会直接改变程序行为。第一层排查要回答的问题现象发生前后有哪些输入可能发生了变化实际操作包括三件事查看最近的发布或配置变更git diff、配置中心版本记录、环境变量比对。确认程序实际加载的配置启动参数、.env文件、配置中心推送结果。用完整请求复现带着 Header、Cookie、请求体和查询参数重新发起一次请求。例如curl -v -X GET http://127.0.0.1:8080/task?userId1001modefast \ -H X-Token: test-token-v会输出请求和响应头便于确认请求有没有到达服务、返回的状态码和耗时是多少。为什么第一层要看输入因为输入是距离现象最近的变量验证成本最低。先确认“现状和预期输入是否一致”再往下追代码可以减少大量无效排查。3.2 第二层进程与线程输入没有问题现象依然出现就进入进程层。要回答的问题进程是否存在线程状态如何CPU 被谁消耗。常用命令ps -ef | grep java top -H -p pid jstack pid | grep -A 20 java.lang.Thread.Statetop -H -p pid以线程维度显示某个进程的 CPU 使用率。Java 服务出现 CPU 飙高时这一条命令可以快速定位到具体线程再结合jstack找到对应的业务代码。jstack输出的线程状态中需要关注四类RUNNABLE线程正在执行或等待 CPU。BLOCKED线程正在等待锁。WAITING线程无限期等待另一个线程的通知。TIMED_WAITING线程在指定时间内等待。这里的坑是看到RUNNABLE并不代表没有性能问题可能只是线程在密集自旋或频繁写入日志。线程状态只能说明“线程在做什么”不能直接说明“程序逻辑是否正确”。3.3 第三层端口与连接当现象是连接失败、拒绝访问、端口被占用时进入网络层。要回答的问题端口是否在监听连接到哪个进程连接状态分布如何。常用命令ss -lntp lsof -i:8080 ss -ant | awk {print $1} | sort | uniq -css -lntp查看监听端口和进程-l表示 listening-n不解析服务名-t只显示 TCP-p显示进程信息。lsof -i:8080可以查看占用 8080 端口的进程。连接状态也很关键。ESTABLISHED表示正常连接TIME_WAIT表示主动关闭连接的一方正在等待确认大量TIME_WAIT通常说明短连接频繁创建。此时要检查的往往是连接池配置而不是端口本身。需要注意lsof在某些精简容器镜像里可能没有安装。遇到这种情况优先使用ss它属于iproute2包在大多数 Linux 发行版和基础镜像里都更常见。3.4 第四层系统资源进程和网络都正常就要看系统资源。要回答的问题磁盘满了吗内存够吗CPU 是否被打满inode 是否耗尽。常用命令df -h df -i free -h iostat -x 1 5df -h看磁盘剩余空间df -i看 inode 使用率。inode 是文件系统用来保存文件元数据的索引节点每个文件至少占一个。即使磁盘还有空间只要 inode 耗尽就无法创建新文件。一个非常典型的案例应用日志报No space left on device但执行df -h后发现磁盘还有空间。此时通常要检查两件事df -i是否已经 100%。lsof | grep deleted是否有已删除但仍被进程占用的文件。第二个情况尤其隐蔽某个进程打开了日志文件日志被logrotate轮转或手工删除但文件描述符没有释放磁盘空间不会真正回收。只有找到持有该文件描述符的进程并重启它空间才会释放。3.5 第五层代码与依赖前四层都没有找到问题或者找到了触发条件但解释不了根因就要进入代码层。要回答的问题调用链在哪异常堆栈指向哪个方法依赖版本之间是否冲突。具体做法从异常堆栈中提取类名、方法名、行号打开源码对照阅读。查看调用链确认是否走入了预期分支。检查依赖版本。Java 项目用mvn dependency:treePython 项目用pip list或pip show。mvn dependency:tree -Dincludesorg.apache.commons:commons-lang3这条命令只显示指定依赖的依赖树用来快速确认某个类来自哪个 jar 包、是否有多个版本被同时引入。NoSuchMethodError、NoClassDefFoundError这类问题很多时候不是代码写错而是依赖冲突。代码层排障要遵循一个原则先用日志和指标缩小范围再打开源码。不要第一眼看到报错就去读源码那样会花费大量时间却不一定找到入口。3.6 五层速查表层级检查对象常用命令典型现象第 1 层输入、配置、环境变量curl、env、git diff参数错误、配置不生效、环境不一致第 2 层进程、线程、CPUps、top -H、jstack进程消失、CPU 飙高、线程阻塞第 3 层端口、连接、防火墙ss、lsof、netstat连接失败、端口被占用、大量 TIME_WAIT第 4 层磁盘、内存、inodedf -h、df -i、free -h磁盘满、inode 耗尽、内存不足第 5 层代码、依赖、堆栈jstack、source code、dependency tree异常堆栈、依赖冲突、逻辑错误排障时不一定严格从第一层开始但每次跳层都要能说明原因。比如日志已经给出明确堆栈就可以直接进入代码层如果现象是重启后消失则应该先在环境或配置层找差异。4. 两个最小复现实验验证“挖到根因”的过程4.1 案例一写入文件失败根因在文件系统而不在程序在测试环境模拟一个常见故障服务写入日志时失败程序抛出OSError: [Errno 28] No space left on device。先模拟磁盘空间写满。在容器内执行dd if/dev/zero of/tmp/demo.fill bs1M count1024把 1GB 数据写入/tmp下的demo.fill用于占满剩余空间。这只是测试环境操作生产环境不能这样模拟。此时查看磁盘状态df -h df -idf -h会显示/或/tmp所在分区的使用率接近 100%。这就是“空间耗尽”的直接证据。但更隐蔽的情况是程序写入一直失败df -h却显示磁盘还有几百 MB 剩余。这种时候检查是否有已删除文件仍被进程持有lsof | grep deleted输出会显示某个进程打开了一个已被删除的日志文件占用着大量空间。原因是进程已经打开了文件句柄文件被外部程序删除后句柄仍然有效磁盘空间要等进程关闭句柄后才释放。解决方案是找到对应进程确认是否可以安全重启或者使用: /proc/pid/fd/fd方式清空文件内容。后一种方式比较激进只建议在有充分把握时使用。这个案例的关键点在于程序里报错的下一层是操作系统返回的磁盘不足再往下挖可能是 inode 耗尽也可能是被删除但未释放的文件句柄。看到No space left on device就简单执行rm -rf清理日志并不能解决所有情况。4.2 案例二内存持续增长先用 top 定位症状范围再模拟一个内存问题。创建一个 Python 脚本memory_demo.pyimport time data [] while True: data.append(x * 1024 * 1024) time.sleep(0.1)这个脚本每秒预留约 10MB 内存并不断累积运行一段时间后内存会明显上涨。运行它python3 memory_demo.py 然后用top -p pid观察。重点看RES列它表示进程实际占用的物理内存。RES持续上涨说明进程内存在增长。但top只能确认“进程在涨”不能说明“哪里在涨”。如果是 Java 项目还需要进一步区分堆内内存和堆外内存。使用jstatjstat -gcutil pid 1000 10jstat -gcutil每秒输出一次 GC 统计持续 10 次。输出中的E表示 Eden 区使用率O表示老年代使用率M表示元空间使用率FGC表示 Full GC 次数FGCT表示 Full GC 累计时间。如果老年代持续上涨、Full GC 次数频繁但内存仍然回收不了说明存在对象泄漏或大对象长期存活。此时才需要进一步取堆转储分析比如使用jmap -dump。但jmap在生产环境执行可能触发 Full GC 并导致 JVM 暂停必须经过审批、在低峰期操作并评估对业务的影响。内存排障最常见的问题是只看进程整体内存不看堆内/堆外、线程、缓存之间的分配关系。最开始的判断往往是“堆太小”实际上很多服务的内存大头在堆外比如直接内存、线程栈、本地缓存。必须从top到jstat、再到业务代码一层层确认。4.3 每次实验都要留下可回溯的记录动手排障时大脑会同时记住很多中间结果但几个小时后就会混淆。建议使用一个简单的排障记录表时间现象复现步骤已执行命令结果是否验证假设21:30接口超时连续调用 /task 5 次top -H -p 1234某线程 CPU 99%否21:35CPU 高复现 5 次jstack 1234堆栈卡在 JSON 解析是记录的重点不是“我做了什么”而是“我看到了什么”和“我基于什么修改了下一步”。这些记录在复盘时比记忆可靠得多。5. 排障中最常见的错误姿势5.1 只贴报错首行不贴调用栈和上下文报错首行只能说明异常类型比如OutOfMemoryError。但真正有价值的信息在堆栈里是哪个线程、哪个类、哪一行代码触发了问题以及异常之前日志中发生了什么。正确做法保留从异常堆栈第一行到Caused by的完整内容并记录异常出现的频率、时间点、最近变更。这里有一个常见误区把整个日志文件全部贴出来。完整日志并不等于有效上下文应该先筛选出该请求或该进程相关的片段再结合整体趋势判断。5.2 改配置之前没确认配置是否真的被加载很多配置不生效的问题不是因为配置写错而是程序根本没加载那份配置。比如修改了application.yml但服务启动时还从环境变量或配置中心读值导致本地文件里的修改被覆盖。检查方式查看启动命令中指定的配置文件路径。在服务里打印实际生效的关键配置值。如果使用 Spring Boot可以用/actuator/env查看配置来源但要先做好访问控制避免敏感超管端点暴露。正确顺序是先确认“当前程序实际用的配置值是什么”再改对应位置的配置。否则会出现改完重启也没变化的假象。5.3 不断重启服务但不保留现场服务异常后直接systemctl restart或docker restart是最常见的处理方式。它能让服务暂时恢复却也把最关键的证据覆盖了。问题在于进程退出前的标准输出、异常堆栈、core dump、线程现场都是定位根因的重要材料。一旦重启这些现场可能被新日志冲掉或直接消失。正确做法是在重启之前先复制当前日志文件、记录退出码和最后一段输出。如果进程还存活先用top、jstack、ss等命令采集必要信息再决定是否重启。生产环境配合健康检查和进程守护服务恢复不应该依赖人工手动重启。5.4 在真实环境擅自执行高权限命令strace、jmap、gdb这类工具会与目标进程产生交互。strace使用 ptrace 跟踪系统调用会让目标进程显著变慢jmap -dump可能暂停 JVM。在真实环境直接执行轻则影响性能重则触发服务中断和安全审计问题。正确做法优先使用只读命令如top、ps、ss、df、du。需要介入进程的命令先在测试环境验证影响范围再走变更审批。如果只能在生产环境操作选择低峰期明确执行窗口和回滚方案。可以在团队内部建立一个“低风险命令清单”和“高风险命令清单”让排障人员形成统一预期。风险等级工具示例说明低风险只读ps、top、ss、df、free不影响进程运行适合优先执行中风险诊断jstack、jstat可能对目标进程有短暂影响需关注频率高风险介入strace、jmap、gdb会与进程交互需要审批和窗口评估6. 生产环境里怎么“安全向下挖”6.1 最小权限与执行前确认生产环境强调的是“可审计”和“可回滚”。任何进入服务器的操作都应该对应到具体人员、具体时间和具体目的。在开始挖掘之前先问自己三个问题这个问题是否只能在生产环境确认我是否具备执行这些命令的权限如果命令影响到了服务我准备好立即回滚吗通常只读命令不需要太多权限但要遵守公司的跳板机和堡垒机规范。涉及进程级操作的命令应该先申请、再执行。不要为图方便直接使用 root 账号做日常排障。6.2 收集证据时注意数据脱敏日志、配置、请求参数中经常包含手机号、Token、身份证号、密钥等信息。在截图、贴日志到工单或发送给同事之前必须脱敏。可以用sed做快速脱敏。例如把日志中的 token 替换为***grep -E ERROR|Exception /var/log/app/app.log \ | sed -E s/(token[: ])[A-Za-z0-9_-]/\1***/g \ evidence.log这条命令先筛出错误日志再替换 token 字段最后写入到单独文件。脱敏的原则是只保留排障必需的最小信息其他全部抹掉。不要因为时间紧张就直接把原始日志全文外发一旦泄露用户数据很难补救。6.3 变更必须带好回滚方案排障一旦进入修改阶段就不再只是分析问题而是一次变更。改配置、升级依赖、重启服务、清理文件都应该有回滚方案。具体落地方案修改配置前先备份原文件cp application.yml application.yml.bak-20250101。升级依赖前记录旧版本号确认可用镜像或安装包能回滚。重启服务前确认启动脚本和健康检查逻辑如果启动失败能自动告警。高影响操作尽量先在一台机器上灰度验证成功后再批量执行。强烈建议在排障过程中区分“验证性修改”和“修复性修改”。验证性修改只用来确认假设比如临时调整日志级别修复性修改才是最终解决方案。两者混在一起很难评估哪种改动真正解决了问题。6.4 学习环境与生产环境对比维度学习环境生产环境权限可以使用 root最小权限需审批数据可随意构造不能泄露需脱敏命令strace、jmap 等随意使用高风险命令需评估影响范围只影响本机影响真实用户回滚难度重建容器即可需要备份、灰度、监控生产环境排障的原则可以概括为一句话先观察再复现能测试环境验证就不要在生产环境操作。必须做操作时走流程、留记录、准备回滚。7. 可复用的排障清单和训练建议7.1 排障前检查清单把以下清单打印出来或贴在终端旁边排障时逐项确认现象是否可稳定复现。如果不能复现记录触发频率和第一次出现时间。最近一次发布或变更是什么。优先检查配置、依赖、流量、数据变更。日志完整上下文是否已保存。确保重启不会覆盖关键证据。关键指标是否已记录。包括 CPU、内存、磁盘、网络、GC 情况。是否已做只读检查。优先执行top、ps、ss、df、free等无副作用命令。是否准备好回滚方案。修改任何配置或依赖前备份原文件并确认恢复路径。是否明确了操作时间窗。影响面较大的命令要避开业务高峰。是否知道何时需要上报。单人排查超过一定时间仍无进展应该拉上第二个人同步信息避免在错误方向里越走越深。7.2 日常训练建议可以在自己的测试环境中刻意制造故障再按完整流程恢复。比如用dd写满/tmp练习磁盘空间排查流程。杀掉服务进程练习从进程层开始定位恢复。改错配置后启动练习从配置加载路径排查。模拟端口冲突练习使用ss和lsof定位占用进程。每次训练后复盘的不只是“结论”而是“路径”我当时为什么先看这里看到什么之后决定走下一步哪一步判断错了。这种方法比刷十篇排障文章都管用。另一个训练方向是阅读源码。遇到框架报错时不要只查文档而是顺着堆栈打开源码理解框架在哪个环节抛出异常、需要什么前置条件。这个能力初期很慢但积累下来之后排障速度会明显提升。最后建议每完成一次真实排障都整理一张“现象-命令-根因-解法”的排障卡片。卡片格式不需要复杂现象什么服务、什么时间、什么日志。关键命令哪条命令产生了最有价值的输出。根因问题出在输入、进程、端口、资源还是代码。解法改动内容、验证方式、防止复发措施。收集几十张卡片之后很多问题都能快速归类也就拥有了真正的“挖掘直觉”。回到 Real Engineers Dig with Their Bare Hands 这句话。它不是反对搜索引擎也不是反对 AI 辅助排障。它反对的是不观察、不复现、不验证只等着别人喂答案。真正可靠的方式永远是从现象出发用双手翻过日志、进程、端口、资源和代码这些层次找到根因再回来验证修复效果。下一次收到告警时可以试着先不急着重启先挖一层从日志开始。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进