排错套路

3/19/2021 工作工作经验

排错套路

# 写在前面:先谈心法,再谈套路

工作这些年,我踩过不少生产事故,也旁观过很多同事排错。慢慢总结出一个感受:排错水平的差距,一开始并不是工具用得熟不熟,而是"心法"对不对

新手上来第一反应是登服务器、看代码、猜原因;老兵上来先问三件事——影响面多大?能不能回滚?现场留了没有?

后来我把它总结成自己排错的三条铁律:

  • 止血优先:生产事故第一要务是恢复业务,不是搞清楚"为什么"。能回滚就回滚,能切流量就切流量,能扩容就扩容——根因分析是复盘阶段的事
  • 留证据:止血之前,务必留一份现场——堆快照、线程栈、日志切片、监控截图。哪怕留一个未重启的节点当"标本",也胜过事后无米之炊。
  • 变更可回滚:自己写的代码、改的配置、执行的 SQL,默认要能回滚。不能回滚的变更,本身就是故障放大器。

心法定了,下面的所有"套路"才有真正的用武之地。

# 在不同环境排查问题,有不同的打法

要说排查问题的思路,我们首先得明白是在什么环境排错。不同环境的约束不同,打法也不同。

环境 特点 排查手段 关键约束
开发环境 权限最全、可重启、可调试 单步调试、任意工具 能否稳定重现
测试环境 有远程访问权限、可造数据 jvisualvmArthas 远程附加;压测造场景 数据与生产的差异
生产环境 权限严、流量真、恢复优先 主要靠监控、日志、快照 能多快恢复

生产环境有三个绕不开的约束:

  1. 权限管控严格——一般不允许调试工具从远程附加进程。
  2. 恢复优先于分析——难以留出充足的时间慢慢排查。
  3. 环境复杂、流量真实——最容易出问题,也是出问题最多的环境。

所以生产环境的排错重心不是"临场发挥",而是事前把该埋的桩埋好——监控、日志、快照、链路是否到位,直接决定你能不能在几分钟内做出正确判断。

# 生产问题排查的四大支柱

其实,排查问题就像在破案。生产出问题时,因为要尽快恢复应用,不可能保留完整现场用于排查和测试。因此,是否有充足的信息可以了解过去、还原现场,就成了破案的关键。

现在我做需求的时候,会默认把这四样东西一并铺好:日志、指标、快照、链路

# 一、日志(Logs)

日志就不用多说了,写代码时主要注意两点:

  1. 确保错误、异常信息可以被完整地记录到文件日志中;
  2. 用合理的日志优先级,生产日志级别开在 INFO 以上:
    • DEBUG 用于开发调试;
    • INFO 用于重要流程信息;
    • WARN 用于需要关注的问题;
    • ERROR 用于阻断流程的错误。

一个小建议:日志一开始就用结构化格式(JSON)写,并且把 TraceID 打进去。等出了事再想统一日志格式,改造成本比一开始就做要大得多。

# 二、指标(Metrics)

在生产环境排查问题时,我希望我的应用有多个层次的监控:

  • 主机层面:CPU、内存、磁盘、网络。部署在 K8s 上还要看 Pod 层。有一层 OS 就要做一层监控
  • 网络层面:专线带宽、交换机基本情况、网络延迟。
  • 中间件和存储:不仅监控进程的 CPU、内存、IO 等基础指标,更要监控组件内部的关键指标(MySQL 慢查询、Redis 连接数、Kafka Lag)。Prometheus 的 exporter 生态覆盖得挺全。
  • 应用层面:JVM 的类加载、内存、GC、线程等(我用 Micrometer 做应用指标)。
  • 业务层面——我踩过的坑:技术指标全绿、下单量却跌了一半。加几个业务指标(下单量、支付成功率、登录量),比 CPU 曲线值钱得多。

# 三、快照(Snapshots)

这里的"快照"指应用进程在某一时刻的状态。我给所有 Java 服务默认加这两个 JVM 参数,OOM 时自动 dump:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=...
1
2

之后用 MAT 分析堆快照就行。同类的工具还有:jstack 抓线程栈、async-profiler 采 CPU 热点、jcmd 抓一切。

踩过的坑HeapDumpPath 一定要指向一个磁盘容量足够、不会被容器销毁的路径。K8s Pod 崩了之后本地文件系统跟着没了,dump 也就没了——记得挂 PVC 或者主机路径。

# 四、链路(Traces)

微服务和分布式系统时代,一次请求跨十几个服务是常事。没有链路追踪,你连"慢在哪一跳"都定位不了——只能靠猜。

  • 常见方案:SkyWalking、Jaeger、Zipkin、OpenTelemetry
  • 关键要求:TraceID 全链路透传——包括异步线程、消息队列、定时任务。TraceID 断在哪,你的排错就断在哪。

日志、指标、快照、链路都到位后,再来看定位问题的套路。

# 分析定位问题的套路

定位问题,首先要定位问题出在哪个层次上——是自己应用的问题,还是外部因素?我一般先看程序有没有异常,异常信息通常比较具体,可以马上定位到大概方向;如果是资源消耗型的问题可能没异常,就靠指标监控配合显性问题点来定位。

一般情况下,程序的问题来自以下三个方面。

# 第一,程序发布后的 Bug

回滚后可以立即解决。这类问题占生产故障的很大比例——所以我在心法里强调"可回滚"是第一原则。先回滚止血,具体的版本差异分析放到复盘再做。

自己开发时也养成一个习惯:任何生产变更,脑子里过一遍"可监控、可灰度、可回滚"——三个都能答上来再上线。

# 第二,外部因素

比如主机、中间件或数据库的问题。这类问题的排查方式,按照主机层面的问题、中间件或存储(统称组件)的问题分为两类。

主机层面的问题,可以使用工具排查:

问题类型 常用排查工具
CPU 相关问题 topvmstatpidstatps
内存相关问题 freetoppsvmstatcachestatsar
IO 相关问题 lsofiostatpidstatsariotopdfdu
网络相关问题 ifconfigipnslookupdigpingtcpdumpiptables

组件的问题,可以从以下几个方面排查:

  1. 排查组件所在主机是否有问题;
  2. 排查组件进程基本情况,观察各种监控指标;
  3. 查看组件的日志输出,特别是错误日志;
  4. 进入组件控制台,使用一些命令查看其运作情况。

# 第三,系统资源不够造成系统假死

通常需要先通过重启和扩容解决问题,之后再进行分析——但记住心法:至少留一个节点作为现场。系统资源不够,一般体现在 CPU 使用高、内存泄漏或 OOM、IO 问题、网络相关问题这四个方面。

# CPU 使用高

如果现场还在,具体的分析流程是:

  1. 首先,在 Linux 服务器上运行 top -Hp pid 命令,来查看进程中哪个线程 CPU 使用高;
  2. 然后,输入大写的 P 将线程按照 CPU 使用率排序,并把明显占用 CPU 的线程 ID 转换为 16 进制;
  3. 最后,在 jstack 命令输出的线程栈中搜索这个线程 ID,定位出问题的线程当时的调用栈。

如果没有条件直接在服务器上运行 top 命令,可以用采样法:间隔固定秒数(比如 10 秒)运行一次 jstack 命令,采样几次后,对比得出哪些线程始终处于运行状态。

如果现场没有了,我们可以通过排除法来分析。CPU 使用高,一般由下面的因素引起:

  • 突发压力:通过负载均衡的流量或日志量来确认。Nginx 等反向代理都会记录 URL,可以依靠 Access Log 进行细化定位,也可以通过监控观察 JVM 线程数的情况。压力问题导致 CPU 使用高的情况下,如果程序的各资源使用没有明显不正常,之后可以通过压测 + Profiler(jvisualvmasync-profiler)进一步定位热点方法;如果资源使用不正常(比如产生了几千个线程),就需要考虑调参。
  • GC:通过 JVM 监控 GC 相关指标、GC Log 进行确认。如果确认是 GC 的压力,那么内存使用也很可能会不正常,需要按照内存问题分析流程做进一步分析。
  • 程序中死循环或不正常的处理流程:结合应用日志分析。一般情况下,应用执行过程中都会产生一些日志,可以重点关注日志量异常部分。

# 内存泄露或 OOM

最简单的分析方式,就是堆转储后使用 MAT 分析。堆转储包含了堆现场全貌和线程栈信息,一般观察支配树图、直方图就可以马上看到占用大量内存的对象。

需要注意的是,Java 进程对内存的使用不仅仅是堆区,还包括:

  • 线程使用的内存(线程个数 × 每一个线程的线程栈)
  • 元数据区(Metaspace)
  • 堆外内存(Direct Memory / Native Memory)——最容易被忽略,Netty、gRPC 用得多

每一个内存区都可能产生 OOM,可以结合监控观察线程数、已加载类数量、堆外内存等指标分析。另外,务必检查 JVM 参数设置是否合理。

# IO 相关问题

除非是代码问题引起的资源不释放等问题,否则通常都不是由 Java 进程内部因素引发的。

# 网络相关问题

一般也是由外部因素引起的。

  • 对于连通性问题,结合异常信息通常比较容易定位;
  • 对于性能或瞬断问题,可以先尝试使用 ping 等工具简单判断,如果不行再使用 tcpdumpWireshark 来分析。

# 分析和定位问题的十二条心得

有些时候,我们分析和定位问题时,会陷入误区或是找不到方向。遇到这种情况,可以借鉴下我踩坑总结出来的十二条心得。前九条偏技术方法,后三条偏排查习惯——在真实事故中,后者往往决定成败

# 一、考虑"鸡"和"蛋"的问题

发现业务逻辑执行很慢且线程数增多的情况时,我们需要考虑两种可能性:

  1. 程序逻辑有问题或外部依赖慢,使得业务逻辑执行慢,在访问量不变的情况下需要更多的线程数来应对。比如,10 TPS 的并发原先一次请求 1s 可以执行完成,10 个线程可以支撑;现在执行完成需要 10s,那就需要 100 个线程。
  2. 有可能是请求量增大了,使得线程数增多,应用本身的 CPU 资源不足,再加上上下文切换问题导致处理变慢了。

出现问题的时候,我们需要结合内部表现和入口流量一起看,确认这里的"慢"到底是根因还是结果。

# 二、通过分类寻找规律

在定位问题没有头绪的时候,可以尝试总结规律:

  • 我们有 10 台应用服务器做负载均衡,出问题时可以通过日志分析是否是均匀分布的,还是问题都出现在 1 台机器;
  • 应用日志一般会记录线程名称,出问题时我们可以分析日志是否集中在某一类线程上;
  • 如果发现应用开启了大量 TCP 连接,通过 netstat 我们可以分析出主要集中连接到哪个服务。

如果能总结出规律,很可能就找到了突破点。

# 三、根据调用拓扑分析,不能想当然

比如,我们看到 Nginx 返回 502 错误,一般会认为是下游服务的问题。但下游不一定就是你的 Java 程序,链路可能是 Nginx -> Traefik -> 应用,一味排查 Java 程序始终找不到根因。

又比如 Spring Cloud Feign 出现连接超时,也不一定是服务端的问题——如果客户端连的是 Nginx 代理而不是走 Eureka 服务发现,那超时其实是 Nginx 宕机所致。

我的做法:自己维护一张服务依赖图放在飞书文档里,每次架构变动就顺手更新。事故时对着图排查,比脑子里回忆快 10 倍。

# 四、考虑资源限制类问题

观察各种曲线指标,如果发现曲线慢慢上升然后稳定在一个水平线上,那么一般就是资源达到了限制或瓶颈

  • 网络带宽曲线上升到 120MB 左右不动了 → 可能打满了 1GB 网卡;
  • 数据库活跃连接数上升到 10 个就不动了 → 可能是连接池打满。

平顶曲线 = 触碰瓶颈,这是一条非常灵的经验,屡试不爽。

# 五、考虑资源相互影响

CPU、内存、IO 和网络这四类资源相辅相成,一个资源的瓶颈很可能引起其他资源的连锁反应:

  • 内存泄露 → Full GC → CPU 飙升;
  • 网络/磁盘慢 → 内存队列堆积 → 内存暴涨。

因此不能只看单一指标下结论,要看全景

# 六、排查网络问题要考虑三方面

到底是客户端问题服务端问题,还是传输问题

以数据库访问慢为例:

  • 客户端:连接池不够、GC 停顿、CPU 占满;
  • 传输:光纤、防火墙、路由表;
  • 服务端:慢 SQL、锁等待、磁盘 IO。

服务端慢一般看 MySQL 慢日志,传输慢用 ping,都排除了且仅部分客户端慢,就是客户端本身的问题。别一上来就假设"肯定是服务端的锅"。

# 七、快照类与趋势类工具结合使用

工具类型 代表工具 用途
趋势类 jstattop、监控曲线 观察指标变化,定位大概问题点
快照类 jstack、MAT、Heap Dump 详细分析某一时刻的细节

先用趋势类总结规律,再用快照类深入分析。反过来容易误判——单个快照只是一个瞬间,至少要多个快照对比才能得出结论。

# 八、不要轻易怀疑监控

我曾看过一个空难事故的分析:飞行员发现仪表显示所有油箱都缺油,第一反应是"油表坏了",结果没多久引擎就断油熄火了。

同样地,应用出问题时,我们宁愿相信自己的经验,也不相信监控图表——这可能让我们完全朝错误方向排查。

如果真的怀疑监控有问题,可以看这套监控对不出问题的应用显示是否正常,如果正常那就应该相信监控而不是自己的经验。

# 九、无法定位到根因时的补救措施

如果因监控缺失等原因无法定位根因,相同问题就有再出现的风险,需要做好三项工作:

  1. 做好日志、监控和快照的补漏工作,下次遇到问题时可以定位根因;
  2. 针对问题的症状做好实时报警,确保出现问题后可以第一时间发现;
  3. 考虑做一套热备的方案,出现问题后可以第一时间切换到热备系统快速解决问题,同时保留老系统的现场。

# 十、先假设,再验证;不做"只验证"的排错

我自己以前排错常见的失败模式:不断"我看看"、"我试一下"——没有假设,纯粹碰运气,然后一个下午过去,还是那句"再看看"。

后来强迫自己用假设驱动的方式:

  1. 根据现象提出一个可证伪的假设("我怀疑是 X 引起的,因为 Y");
  2. 设计一个能证实或证伪的验证动作;
  3. 执行、观察、更新假设。

每一步都要能回答"我在验证什么"。答不上来,就是在瞎忙——这时候该停下来喝口水,重新捋一遍现象。

# 十一、别一个人硬扛

新人时期我总觉得"我自己能搞定",半小时没进展也不吭声,怕别人觉得我菜。后来才意识到:在生产事故里死磕是团队的浪费

我现在给自己定的规矩:

  • 10 分钟原则:10 分钟内没有明确方向、没能止血,就主动求助;
  • 信息透明:在群里同步排查进展和当前假设,让别人有机会补位;
  • 别怕暴露不懂:DBA 一眼看出来的问题,我盯着可能盯半小时。

# 十二、区分"根因"和"诱因"

一次事故往往是多个因素叠加的结果:

  • 诱因:让问题显现的那一下——比如流量突增、一次发布、一条运营 SQL;
  • 根因:埋在系统里的那个雷——连接池配小、代码逻辑缺陷、缺少限流、慢 SQL 没索引。

只处理诱因("限流一下就好了"、"重启一下就好了")而不追根因,同样的雷下次还会踩。每次事故要问自己一句:如果诱因换个样子,这个雷还会不会炸? 会的话,说明根因没除。

# 顺手做几件事,让下一次排错更轻松

排错这件事很有意思:你现在排错顺不顺,取决于半年前的自己有没有埋好桩。所以每次做完一个需求,我会顺手做几件事:

  • 补监控:这个功能挂了会怎么被发现?告警配了没?业务指标缺不缺?
  • 补日志:关键路径的入参、异常分支的上下文有没有打?TraceID 透传了没?
  • 想回滚:这次改动能不能一键回滚?开关有没有留?灰度怎么灰?
  • 想降级:依赖挂了怎么办?兜底数据有没有?熔断阈值合不合理?

这些事情做的时候都是"多花 10 分钟",但真出事的时候,救命的就是它们。

# 复盘:把每一次故障变成自己的经验资产

定位到问题原因后,一定要做好记录和复盘。每一次故障都是宝贵的资源——但要真正把它变成"资源",需要做扎实四件事。

# 1. 完整的时间线

从告警触发到恢复的每个动作、每个决策,精确到分钟。别只写"20:00 出问题,20:30 恢复"——中间那 30 分钟每一步做了什么、判断依据是什么,才是复盘价值最高的部分。

# 2. 根本原因分析

5 Whys 追到底——不是"因为 CPU 高了"就完事,而是继续问:

  • 为什么 CPU 会高?→ 因为有个死循环。
  • 为什么死循环没被发现?→ 因为单测没覆盖这个分支。
  • 为什么单测没覆盖?→ 因为这段代码是配置分支,很少走到。
  • 为什么这段代码这么脆弱?→ 因为它假设配置一定合法。
  • 为什么假设配置一定合法?→ 因为没做参数校验……

追到底才知道该改在哪里。停在第一层,只能得出"下次注意"这种没用的结论。

# 3. 短、中、长期改进方案

改进方案分层写:

  • 短期(当天到一周):修补当前问题、临时监控、代码回滚;
  • 中期(一到两个月):加单测、补告警、增加降级预案;
  • 长期(季度级):架构上的调整,比如拆服务、换存储、加隔离层。

每条必须有 Owner、有 Deadline、可跟踪。写完不落地的复盘等于没做。

# 4. 定期回顾

我会把自己参与过的故障归档,季度性回看一次:

  • 有没有反复踩同一个坑?(说明改进没落地)
  • 哪一类问题占比最大?(说明该重点补哪块能力)
  • 有没有共性——比如都是变更引起的?还是都是外部依赖挂了?

反复踩同一个坑,是最大的浪费

# 重点回顾

写到最后,我把自己的排错观整理成三条:

  • 有依据,不猜:分析问题一定要有依据,靠猜是猜不出来的——所以监控、日志、快照、链路要提前铺好。定位时从大到小思考:先定层次(内 vs 外)、再定类别(CPU vs 内存)、再定细节。遇到瓶颈时退回来看全局,比一头扎进细节更快。
  • 止血优先,别恋战:生产事故的第一目标是恢复业务,不是"我一定要现场找出根因"。留好现场,先回滚、扩容、切流量把业务救回来,根因慢慢查。
  • 每次故障都要有沉淀:修完就散不叫复盘。把时间线、根因、Action Item 沉淀下来,下次再遇到同类问题,你和团队都能少踩一个坑。

排错这件事,最终拼的不是谁工具用得溜,而是谁能在混乱中保持冷静、在证据不足时不瞎猜、在解决问题后愿意回头补桩