这是一次让我至今印象深刻的线上排障。监控告警 P99 飙到 5s,但 QPS 不高、CPU 不满、DB 没有慢查询、APM 看不出任何异常。所有应用层指标都告诉你"没事",但用户就是说慢。
最后定位到的元凶藏在 TCP 协议层——一个绝大多数后端开发者从来不去看的地方。这篇把整个排查过程完整复盘,从 APM → tcpdump → 内核 socket 状态一路追下去,附完整命令和经验沉淀。
一、背景与现象
业务背景:一个典型的微服务架构,核心订单查询服务,部署在阿里云 ECS 上,跨多个可用区,前面挂 SLB,下游依赖 RDS、Redis、外部支付服务。日常 QPS 大约 2000,P99 稳定在 80ms 以内。
某个工作日下午,监控告警群开始炸:
[ALERT] order-query-service P99 latency: 4823ms (threshold: 500ms)
[ALERT] order-query-service P99 latency: 5210ms
[ALERT] order-query-service error rate: 0.8% (threshold: 0.1%)
P99 不是一直高,是偶发飙升——每隔几分钟突然出现一批慢请求,然后又恢复正常。错误率虽然只有 0.8%,但绝对数量已经够客服爆炸了。
第一反应是去看监控大盘:
- CPU:35%,正常
- 内存:60%,正常
- GC:YoungGC 频率正常,没有 FullGC
- 线程池:核心线程在工作,队列没积压
- DB 慢查询:0 条
- Redis 延迟:1ms 以内
- 下游接口:上游 SLA 都在 99.95% 以上
应用层所有指标都告诉你"一切正常"。但慢请求是真实存在的。
这种"所有监控正常但用户说慢"的场景,是最难排查的一类问题。
二、第一轮:APM 链路追踪
我们的链路追踪用的是 SkyWalking。打开 P99 那个时间段的慢请求 trace,找到一条具体的 5200ms 请求:
[Trace ID: a3f5e8c9b2d14761]
└─ /api/order/query (5217ms)
├─ checkAuth (3ms)
├─ getUserInfo via RPC (2ms)
├─ queryOrderFromDB (8ms)
├─ enrichOrderDetails (5ms)
├─ callPaymentService (5189ms) ← 元凶?
└─ buildResponse (2ms)
看起来定位到了:callPaymentService 这个外部调用花了 5.1 秒。下意识就要去拍支付服务的同事。
但作为一个被坑过的人,我没有立刻甩锅。先去支付服务那边查同一条 traceId:
[Trace ID: a3f5e8c9b2d14761]
└─ /payment/check (12ms)
├─ validateOrder (3ms)
├─ queryPaymentStatus (8ms)
└─ return (1ms)
支付服务说他们只处理了 12ms。
这就有意思了。订单服务说"我等了支付服务 5.1 秒",支付服务说"我 12ms 就处理完了"。5 秒钟去哪了?
中间这段时间,数据要么卡在网络上,要么卡在某一端的内核 socket 缓冲区里。这是典型的"网络层吃掉了时间"的信号。
三、第二轮:怀疑 GC、连接池、线程
在动手抓包之前,先把另外几个可能的"应用层但 APM 看不出来"的因素排除掉。
3.1 GC?
虽然 GC 监控显示没 FullGC,但保险起见看一下 GC 日志:
# 看慢请求时间点附近的 GC 情况
grep "2024-XX-XX 14:" gc.log | tail -100
# 看 STW 是否有异常
jstat -gc <pid> 1000 10
GC 干净。最长一次 YoungGC 也就 30ms,跟 5 秒完全不在一个量级。排除。
3.2 HTTP 连接池?
订单服务调支付服务用的是 OkHttp,连接池配置:
maxIdleConnections: 100
keepAliveDuration: 5 minutes
connectTimeout: 1s
readTimeout: 3s
诡异的事来了——配的 readTimeout 是 3s,但慢请求耗时 5s。说明请求并没有触发超时,是真的等到了响应。
但连接池里的连接复用是不是有问题?比如复用了一个"半死不活"的 TCP 连接?
抓一下连接池状态:
# 通过 JMX 或 Actuator 看 OkHttp 连接池状态
curl http://localhost:8080/actuator/metrics/okhttp.pool.connection.count
连接池数量正常,没有打满。但连接复用导致的 stale connection 是个高度可疑点,记下来后面验证。
3.3 线程?
# 在告警时间点附近 dump 线程栈
jstack <pid> > stack.txt
看了一下没发现明显的线程阻塞。线程池也都在工作,没有死锁。
应用层、JVM 层、框架层基本排除完,问题大概率在 TCP 协议层或者网络链路层。
四、第三轮:tcpdump 抓包
到这一步,没有什么比直接抓包看真相更直接的了。
4.1 在订单服务这一侧开抓
# 抓订单服务到支付服务的所有 TCP 包,过滤端口
sudo tcpdump -i eth0 -w order-to-payment.pcap \
host <payment-svc-ip> and tcp port <payment-port> \
-s 0 -G 60 -W 30
# -G 60: 每 60s 切一个文件
# -W 30: 保留最多 30 个文件
这里有个小技巧:生产环境抓包不要一直抓,用 -G + -W 做循环写盘,避免磁盘打满。
抓了大约 10 分钟,等到下一次告警,立刻停掉抓包。
4.2 用 tshark 提取慢连接的特征
直接用 wireshark 打开太花眼,先用 tshark 命令行做粗筛——找出耗时超过 1s 的 HTTP 请求:
# 提取所有 HTTP 请求和响应的时间差
tshark -r order-to-payment.pcap -Y "http" \
-T fields -e tcp.stream -e frame.time_relative -e http.request.uri -e http.response.code
很快找到一条异常的 stream,HTTP 请求发出后,过了 5.1 秒才收到响应。
4.3 看那条 stream 的完整握手过程
# 用 stream id 过滤,看这条 TCP 流的所有包
tshark -r order-to-payment.pcap -Y "tcp.stream eq 47" \
-T fields -e frame.time_relative -e tcp.flags -e tcp.seq -e tcp.ack -e tcp.len -e tcp.analysis.retransmission
输出的关键片段(简化):
0.000 [SYN] seq=0
0.002 [SYN, ACK] seq=0 ack=1
0.002 [ACK] seq=1 ack=1
0.003 [PSH, ACK] seq=1 len=487 ← HTTP 请求发出
0.005 [ACK] seq=1 ack=488
3.012 [PSH, ACK] seq=1 len=487 ← 重传!
3.014 [ACK] seq=488 ack=488 ← 重复 ACK
3.014 [PSH, ACK] seq=488 len=215 ← 服务器开始发响应
5.123 [ACK] seq=488 ack=703
看到关键证据了——TCP retransmission。在 3 秒处出现了重传。
这意味着:
- 服务器其实早就收到了请求(看到了 ACK)
- 但客户端因为某种原因没有收到响应包
- 触发了 TCP 重传定时器(RTO)
- 重传后才正常拿到响应
5 秒延迟里,3 秒是在等 RTO 超时,2 秒是后续重传 + 恢复期。
五、第四轮:定位丢包根因
知道是 TCP 重传以后,下一个问题是:包丢在哪里?
可能的位置:
- 客户端发出的请求包丢失
- 服务器发出的响应包丢失
- 中间某个网络节点(LB、网关、交换机)丢包
- 客户端网卡接收时丢弃(队列溢出、CPU 软中断打满)
- 服务端发送时丢弃
5.1 看本机的 TCP 重传统计
# 查看 TCP 重传次数
cat /proc/net/snmp | grep Tcp:
# 关注 RetransSegs 字段
# 用 nstat 看实时变化
nstat -a | grep -i retrans
# 看更细的 TCP 统计
ss -s
# 看具体 socket 的拥塞窗口、RTT、重传
ss -tin
ss -tin 的输出特别值得细看,能看到每个 TCP 连接的:
rtt: 平滑的 RTTrttvar: RTT 抖动cwnd: 拥塞窗口retrans: 重传次数
抓到的几条慢连接,retrans 字段都不是 0,确实是 TCP 层在重传。
5.2 检查网卡丢包
# 看网卡硬件层丢包
ethtool -S eth0 | grep -i drop
ethtool -S eth0 | grep -i discard
# 看接收队列溢出
netstat -s | grep -i "listen overflows\|dropped\|prune"
# 看软中断在哪个 CPU 上跑(是否打满某个 CPU)
cat /proc/softirqs | grep NET_RX
# 用 sar 看历史网卡丢包
sar -n EDEV 1
订单服务这边网卡没看到异常丢包,软中断分布均匀。
5.3 关键证据:多地 Ping 看链路质量
到这一步基本可以判断丢包不在本机,而在链路上。但需要更直接的证据。
我们的订单服务和支付服务部署在阿里云不同 VPC,通过私网 SLB 互通。让 SRE 同事从外部多个节点持续 Ping 两边的服务 IP,重点看抖动和丢包率。
可以用聚合多地节点的工具同时对目标地址发起持续 Ping,比单点判断更可靠。我们用的是 BiuPing 多地 Ping ,5 分钟跑下来发现:
- 大部分地区 Ping 都很稳定
- 但访问支付服务那个 IP 的某条路径,从某个时间点开始出现 2-3% 的丢包
- 配合 路由追踪 工具,定位到中间某一跳的物理设备在抖动
这跟我们看到的 TCP 重传时间点完全吻合。
5.4 提工单 + 临时方案
把抓包数据 + 多地 Ping 截图 + 路由追踪结果打包发工单。同时立刻做几件临时止血的事:
- 缩短 readTimeout:从 3s 改成 800ms,避免单个慢请求把线程阻塞太久
- 加重试机制:失败请求快速重试一次(要确认接口幂等)
- 降低连接 keepAlive 时间:让"半死不活"的连接尽快被回收
重新发布后,P99 立刻降到 1s 以内(虽然偶发还是有丢包,但不会再卡 5s)。
云厂商工单两小时后回复,确认是某个底层网络设备的链路抖动,他们已经做了路由切换。
后续 24 小时 P99 完全恢复到 80ms 的正常水平。
六、深入:TCP 重传为什么这么慢?
排查完很多人会问:为什么一次 TCP 重传就要等 3 秒?
这跟 Linux 内核的 RTO(Retransmission Timeout)机制有关。
6.1 初始 RTO 是多少?
Linux 默认 tcp_rto_min 是 200ms,但第一次重传不是按这个值,而是按 RFC 6298 的初始 RTO 计算的,通常是 1 秒。
如果重传后还没收到 ACK,RTO 会指数退避:
1st retransmit: 1s
2nd retransmit: 2s
3rd retransmit: 4s
4th retransmit: 8s
...
所以一次重传卡 1-3 秒是非常常见的,5 秒+ 也不少见。
6.2 怎么"优化"TCP 重传?
重要:你不能阻止重传发生(除非链路本身好),但可以减少重传带来的伤害。
6.2.1 调整内核参数(谨慎)
# 看当前值
sysctl net.ipv4.tcp_retries2
sysctl net.ipv4.tcp_syn_retries
sysctl net.ipv4.tcp_synack_retries
# 减少最大重试次数(默认 15 次,约 924 秒才放弃;可以调小到 8 次约 100 秒)
# 编辑 /etc/sysctl.conf
net.ipv4.tcp_retries2 = 8
# 启用 RACK(Recent ACKnowledgment)替代传统 RTO 机制,更快感知丢包
net.ipv4.tcp_recovery = 1
# 应用配置
sysctl -p
注意:调整这些参数会影响整机所有 TCP 连接,生产环境务必先在压测环境验证。
6.2.2 应用层做超时和重试
更安全的做法是不动内核,在应用层兜底:
- 业务超时设得比 RTO 短:比如 800ms 超时,宁可应用层报错重试,也不要傻等 TCP 重传
- 重试要幂等:用 traceId 或业务唯一键做去重
- 连接池配置 validateOnBorrow:每次从池子拿连接时验证一下是否还活着
6.2.3 启用 BBR 拥塞控制(重要)
Linux 4.9+ 支持的 Google BBR 算法,对丢包链路友好得多:
# 看当前拥塞控制算法
sysctl net.ipv4.tcp_congestion_control
# 启用 BBR
echo "net.core.default_qdisc = fq" >> /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf
sysctl -p
# 验证
lsmod | grep bbr
sysctl net.ipv4.tcp_congestion_control
跟传统的 Cubic 相比,BBR 在有丢包的链路上吞吐和延迟表现都好得多——传统拥塞控制把丢包当成"拥塞"立刻降速,但很多时候丢包是链路抖动而不是拥塞。
七、5 个最容易被忽略的网络层慢请求元凶
复盘完这次排障,整理出 5 个生产环境中常见但容易漏掉的网络层因素,沉淀为 Checklist:
元凶 1:TCP 重传(本次)
- 现象:偶发的秒级延迟,APM 看不出来
- 排查:
ss -tin看retrans字段;tcpdump 抓包看 retransmission - 解决:缩短应用超时、加重试、启用 BBR、提工单
元凶 2:连接池中的 stale connection
- 现象:偶发的"第一次请求"特别慢,连接被复用了一个已经被中间设备静默关闭的旧 TCP 连接
- 排查:观察是否在低峰期之后第一波请求变慢;连接池配置
validateOnBorrow - 解决:缩短 keepAlive、加连接验证、用 HTTP/2 多路复用
元凶 3:DNS 解析偶发慢
- 现象:偶发的请求加上 100-500ms 不等的延迟
- 排查:APM 是否记录了 DNS 解析时间;本地 DNS 缓存配置;权威 DNS 性能
- 解决:本地接入 nscd 或 dnsmasq、应用层缓存 DNS 结果、使用 HTTPDNS
元凶 4:跨可用区调用
- 现象:同地域不同 AZ 之间,调用延迟比同 AZ 高 1-5ms(看起来不多,但累积起来很可怕)
- 排查:检查依赖服务的部署 AZ,看是否跟自己同 AZ
- 解决:开启就近路由(阿里云、腾讯云的 SLB 都支持),或做服务亲和性部署
元凶 5:TIME_WAIT / CLOSE_WAIT 堆积
- 现象:高并发场景下,建连失败、端口耗尽
- 排查:
ss -tan | awk '{print $1}' | sort | uniq -c看各种状态的连接数 - 解决:开启
tcp_tw_reuse(不要开tcp_tw_recycle,已被废弃且有问题)、增加端口范围、用长连接代替短连接
八、写在最后:超越应用层的视角
这次排障让我深刻意识到一件事:作为后端,如果你只懂应用层和 SQL,你的排障能力是有天花板的。
应用层指标可以告诉你"系统正常",但用户依然在受罪。要真正定位到根因,你必须能往下钻——钻到 TCP 协议层、内核网络栈、甚至物理网络。这些"看不见的层",恰恰是生产环境最容易出问题、又最难排查的地方。
总结这次排障用到的工具栈:
| 层级 | 工具 |
|---|---|
| 应用层 | APM(SkyWalking/Jaeger/阿里 ARMS)、应用日志、jstack、jstat |
| 协议层 | tcpdump、tshark、wireshark、ss -tin、/proc/net/snmp |
| 内核层 | nstat、ethtool、/proc/softirqs、sar |
| 链路层 | 多地 Ping、路由追踪、MTR |
把这套工具栈用熟,线上 90% 的"诡异慢"问题都能定位到根因。
如果你也在做高可用服务,建议把上面提到的 5 个元凶整理成自己团队的网络层排障 Runbook,下次告警的时候照着走一遍,能省非常多时间。
文中用到的多地 Ping 和路由追踪工具来自 BiuPing,支持全国电信/联通/移动 + 海外节点同时测,免费且不用注册,对快速验证"是不是网络抖了"特别方便。
希望这篇复盘对你有帮助。也欢迎在评论区分享你踩过的网络层坑——这种事,听一个真实 case 胜过看十本书。