后端微服务诊断方法论
核心思想
不要猜,要逐层排除。每一层用一个最小的动作产生一块证据,用证据链把问题范围持续缩小。
错误的做法:上来就改代码、调参数、怀疑某个组件。 正确的做法:先确认"请求到底走到了哪里,在哪里断掉的",从入口到出口,一层一层收窄。
第一阶段:构建网络拓扑全景图(必须先做)
在动手查任何代码或日志之前,先搞清楚完整的请求流转路径。
必须回答的问题
| 问题 | 目的 | |------|------| | 请求从浏览器/客户端出发,经过哪些节点? | 画出完整链路,不漏任何一跳 | | 每两个节点之间的通信协议是什么?(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 ← 查代码注入点
结论:共享线程池被慢任务拖死 ✅
第六阶段:根因定性标准
根因必须满足的条件(缺一不可)
- 能解释所有现象:不仅解释超时,也要解释为什么有时正常
- 有直接证据:不是推测,有线程栈/抓包/日志实锤
- 有代码落点:能精确到具体类、方法、行号
- 能闭环验证:修复后问题不再出现
说"不能 100% 确定"的场景
- 只证明了"池子里有慢任务",但还没在目标请求超时那一刻直接观察到"目标请求提交的任务确实在排队等待"
- 只证明了"某个环节正常",但还没证明"所有环节都正常"
- 证据有时间差(事后看日志,不是超时瞬间的)
在这些场景下,结论要严谨:说"大概率"、"最像",而不是"肯定"。
第七阶段:临时止血与长期修复的区分
向用户输出建议时,必须分清两件事
| 类型 | 目标 | 举例 | |------|------|------| | 临时止血 | 先让服务恢复 | 重启、调大线程池、暂停非核心功能 | | 长期修复 | 根治问题 | 线程池隔离、加超时控制、架构改造 |
不要混在一起给。先给止血方案,再说明长期修复方向。
反模式(必须避免)
| 反模式 | 为什么错 |
|--------|----------|
| 没搞清楚拓扑就开始排查 | 会在错误的位置抓包,浪费大量时间 |
| 一次给一堆命令 | 用户执行不完,且结果混杂难以解读 |
| 用工具的默认行为下错误结论 | 如 thread vs thread --all |
| 在某个服务侧证据足够后还继续死磕同一个服务 | 应该根据证据转移焦点到更上游或更下游 |
| 跳过"绑定请求到线程"步骤 | 无法区分正常流量和问题流量 |
| 把临时方案说成最终方案 | 会误导用户以为修好了 |
| 在不理解网络模式时硬猜 | Swarm VIP / overlay 下的抓包位置选择很容易错 |
Scan to contact