返回 Skill 列表
extension
分类: 开发与工程无需 API Key

diagnostic-methodology

线上问题诊断

person作者: zhangxunpanhubModelScope

后端微服务诊断方法论

核心思想

不要猜,要逐层排除。每一层用一个最小的动作产生一块证据,用证据链把问题范围持续缩小。

错误的做法:上来就改代码、调参数、怀疑某个组件。 正确的做法:先确认"请求到底走到了哪里,在哪里断掉的",从入口到出口,一层一层收窄。


第一阶段:构建网络拓扑全景图(必须先做)

在动手查任何代码或日志之前,先搞清楚完整的请求流转路径

必须回答的问题

| 问题 | 目的 | |------|------| | 请求从浏览器/客户端出发,经过哪些节点? | 画出完整链路,不漏任何一跳 | | 每两个节点之间的通信协议是什么?(HTTP/HTTPS/TCP) | 确定抓包位置和排查手段 | | 各节点部署在哪里?(物理机/虚拟机/容器/K8s/Swarm) | 确定你能接触到哪些层 | | 容器之间如何通信?(overlay/VIP/dnsrr/host 网络) | 避免在错误的位置抓包 | | 是否存在负载均衡/反向代理?具体规则是什么? | 排查"幽灵节点"和路由异常 |

典型拓扑模板

客户端 → CDN/WAF → 甲方网关 → Nginx(自管)→ Gateway(Spring Cloud Gateway)
                                                      ↓
                                                 目标业务服务
                                                     ├── 服务A
                                                     ├── 服务B
                                                     └── ...

对于 Docker Swarm:

Gateway 容器 → swarm VIP → IPVS → overlay 网络 → 目标服务容器

重点理解:Nginx 先代理到 Gateway,Gateway 再根据路由规则将请求转发到对应的业务服务。Gateway 后面通常挂多个业务服务,出问题的只是其中一个。排查时要搞清楚 Gateway 的路由规则(哪个路径对应哪个服务),才能准确定位目标。

关键原则:不要假设"同宿主机 = 直连"。 Swarm VIP / overlay / vxlan 路径下,即使只有一个副本,流量也可能经过多层虚拟网络。


第二阶段:逐层排除法(核心方法)

诊断顺序:从外向内、从入口到出口

第 1 层:确认请求是否到达了最外层网关(Nginx/CDN/甲方网关)
    ↓
第 2 层:确认网关是否收到了请求并正确路由
    ↓
第 3 层:确认网关是否向下游发起了转发
    ↓
第 4 层:确认网络链路是否将请求送到了目标容器
    ↓
第 5 层:确认目标容器的 Web 容器(Tomcat/Nginx)是否收到请求
    ↓
第 6 层:确认请求是否进入了业务框架(Spring MVC/DispatcherServlet)
    ↓
第 7 层:确认是否进入了具体 Controller 方法
    ↓
第 8 层:在方法体内逐段定位卡点

每层的标准动作

每一层只做一个最小的验证动作,拿到结论后再进入下一层。不要一次给一堆命令。

| 排查层 | 工具 | 典型命令/动作 | 要确认什么 | |--------|------|---------------|-----------| | Nginx 入口 | nginx access.log | 搜索对应 URL 的请求 | 状态码是 504(网关超时)还是 499(客户端断开) | | Gateway 路由入口 | Arthas watch | watch NettyRoutingFilter filter "..." -b -x 2 | 请求是否进入 Gateway 路由阶段 | | Gateway 转发 | Arthas watch + tcpdump | 在 Gateway 侧抓发往下游的包 | 是否真正发出了下游 HTTP 请求 | | 网络链路 | tcpdump(正确的位置) | 先确认走 VIP 还是直连 IP,再选抓包位置 | 是否存在重传、SYN 卡住、无 ACK | | 目标容器网络 | nsenter + tcpdump | 进入容器网络命名空间抓包 | 请求包是否进了容器 | | Tomcat 入口 | Arthas watch | watch StandardWrapperValve invoke "..." -b | 请求是否进入 Servlet 处理 | | Spring MVC | Arthas watch | watch RequestMappingHandlerAdapter invokeHandlerMethod "..." -b | 路由匹配是否成功 | | Controller 方法 | Arthas watch | 监听具体方法 | 是否进入方法体 | | 方法内部 | thread 栈 / trace | thread <id> 看调用栈 | 卡在哪个子调用 |


第三阶段:工具使用范式

Arthas 使用核心原则

Arthas 是外科手术刀,不是体检仪。每次 watch 都要带精确的过滤条件。

watch 命令的渐进使用模式

第一步:-b(AtEnter)确认"进没进"
watch <类名> <方法名> "{关键参数}" "<过滤条件>" -b -x 2

第二步:-f(AtExit)确认"出没出来"
watch <类名> <方法名> "{#cost, throwExp}" "<过滤条件>" -f -x 2

第三步:-e(AtException)专门抓异常
watch <类名> <方法名> "{throwExp}" "<过滤条件>" -e -x 2

关键教训

| 错误做法 | 正确做法 | 原因 | |----------|----------|------| | thread \| grep http-nio | thread --all \| grep http-nio | thread 默认只输出热点线程,不全 | | thread -b 无输出就认为没阻塞 | 理解 -b 只看锁阻塞,不看 IO 等待 | WAITING 状态也可能是卡住了(如在等 Future.get()) | | watch 不带过滤条件 | 带路径或方法名过滤 | 生产环境噪音大,无法定位具体请求 | | 用 -e 判断"请求没到" | 先用 -b 确认进入,再用 -f 确认退出 | -e 只在异常时输出,正常流程不输出 | | 一次给多条命令 | 一次只给一条,拿到结果再给下一条 | 偶发问题需要聚焦,避免分散 |

绑定请求到具体线程(关键技巧)

当 Arthas 监听只能证明"进来了"但不能证明"卡住了"时:

watch <拦截器/入口类> <方法> "{@java.lang.Thread@currentThread().getId(), ...}" "<过滤>" -b -x 2

拿到线程 ID 后,直接 thread <id> 看调用栈,这才是最硬的证据。

thread 状态解读

| 线程状态 | 可能含义 | 不能直接等同于 | |----------|----------|----------------| | WAITING | 空闲等待、等 Future.get()、等 IO | 不一定"没事做" | | TIMED_WAITING | 有超时的等待 | 不一定"马上恢复" | | RUNNABLE | 正在执行或就绪 | 不一定是"CPU 密集型" | | BLOCKED | 锁竞争 | 这是真正的阻塞 |

关键教训:WAITING on FutureTask 本身就是卡住的表现!不要因为不是 BLOCKED 就认为没问题。

tcpdump 使用核心原则

抓包之前,必须先确认抓包位置和抓包目标。

位置选择

| 场景 | 正确位置 | 错误位置 | |------|----------|----------| | Gateway 通过 VIP 访问下游服务 | Gateway 容器的网络命名空间,抓 VIP | 宿主机直接抓 task IP | | Swarm overlay 网络 | nsenter -t <PID> -n tcpdump 进容器 | 在宿主机默认网络命名空间抓 |

精确关联请求(关键技巧)

生产环境噪音大,不要只看"所有流量",要用请求的唯一参数过滤

# 正向:抓带具体 URL 的 HTTP 内容
nsenter -t ... -n tcpdump -i any -A -s 0 host <VIP> and port <PORT> \
  | grep -a '<HTTP方法> /<目标路径>?...___time=XXXXX'
# 反向:从浏览器的请求参数中拿到唯一标识(如时间戳参数 ___time),
# 用这个唯一标识在抓包结果里精确匹配,把目标请求从生产其他流量中筛出来

如何解读抓包结果

| 现象 | 含义 | |------|------| | 有 SYN + SYN-ACK + 数据收发 + FIN | 正常完成 | | 有 PSH 但无 ACK | 数据发出,对端没收到或没响应 | | 大量 Retransmission | 丢包或对端不响应 | | 完全没有相关包 | 请求未从当前观察点发出 | | cksum incorrect | 通常是网卡硬件校验卸载的假象,不一定是问题 |


第四阶段:偶发问题的特殊策略

偶发问题("时好时坏"、"一会能复现一会不能复现")的排查需要转变策略。

从"主动抓"变成"挂机捕获"

# 不带过滤条件挂后台,等自然复现
watch <类> <方法> "{...}" '#cost > 10000' > /tmp/slow.log &

偶发问题的常见模式

| 模式 | 现象 | 根因方向 | |------|------|----------| | 幽灵节点 | 有时正常有时超时 | 注册中心有死节点,负载均衡偶发命中 | | 特定内容触发 | 有些请求能成功,有些不行 | 特定内容(大报文、特殊字段值、外部链接)触发不同处理路径 | | 资源竞争 | 并发高时频繁,低时正常 | 线程池、连接池、锁竞争 | | 网络抖动 | 无明显业务规律 | 防火墙、conntrack、MTU、vxlan | | 外部依赖慢 | 有时正常有时卡 | 下游服务或外部 URL 响应不稳定 |

A/B 对照实验(快速定性)

当你怀疑某个环节时,设计一个对照实验:

| 怀疑点 | 对照实验 | |--------|----------| | Swarm VIP 有问题 | 直连 task IP vs 走 VIP,对比成功率 | | 特定内容触发 | 用最简单内容 vs 复杂内容,对比 | | 共享资源争抢 | 暂停其他业务操作,只测目标接口 |


第五阶段:共享资源诊断

当问题缩小到应用内部后,必须警惕共享资源引起的间接故障。

检查清单

| 共享资源 | 表现 | 排查命令 | |----------|------|----------| | 线程池 | 主线程在 Future.get() 等待 | thread --all \| grep <pool-prefix> 看工作线程在干什么 | | 连接池 | 获取连接超时 | 检查连接池配置和使用情况 | | 公共 Cache | 缓存雪崩 | 检查缓存命中率和 key 分布 | | 共享锁 | 线程 BLOCKED | thread -b |

线程池饥饿的典型证据链

1. 目标请求的主线程卡在 Future.get() ← thread <id>
2. <pool-prefix>-* 工作线程全部被其他任务占住 ← thread --all | grep <pool-prefix>
3. 那些占池的任务卡在外部 HTTP 调用 ← thread <工作线程ID>
4. 两边用的是同一个线程池 Bean ← 查代码注入点
结论:共享线程池被慢任务拖死 ✅

第六阶段:根因定性标准

根因必须满足的条件(缺一不可)

  1. 能解释所有现象:不仅解释超时,也要解释为什么有时正常
  2. 有直接证据:不是推测,有线程栈/抓包/日志实锤
  3. 有代码落点:能精确到具体类、方法、行号
  4. 能闭环验证:修复后问题不再出现

说"不能 100% 确定"的场景

  • 只证明了"池子里有慢任务",但还没在目标请求超时那一刻直接观察到"目标请求提交的任务确实在排队等待"
  • 只证明了"某个环节正常",但还没证明"所有环节都正常"
  • 证据有时间差(事后看日志,不是超时瞬间的)

在这些场景下,结论要严谨:说"大概率"、"最像",而不是"肯定"。


第七阶段:临时止血与长期修复的区分

向用户输出建议时,必须分清两件事

| 类型 | 目标 | 举例 | |------|------|------| | 临时止血 | 先让服务恢复 | 重启、调大线程池、暂停非核心功能 | | 长期修复 | 根治问题 | 线程池隔离、加超时控制、架构改造 |

不要混在一起给。先给止血方案,再说明长期修复方向。


反模式(必须避免)

| 反模式 | 为什么错 | |--------|----------| | 没搞清楚拓扑就开始排查 | 会在错误的位置抓包,浪费大量时间 | | 一次给一堆命令 | 用户执行不完,且结果混杂难以解读 | | 用工具的默认行为下错误结论 | 如 thread vs thread --all | | 在某个服务侧证据足够后还继续死磕同一个服务 | 应该根据证据转移焦点到更上游或更下游 | | 跳过"绑定请求到线程"步骤 | 无法区分正常流量和问题流量 | | 把临时方案说成最终方案 | 会误导用户以为修好了 | | 在不理解网络模式时硬猜 | Swarm VIP / overlay 下的抓包位置选择很容易错 |