一个 8 年性能老兵的线上复盘。不吹概念,只讲我真实排查过的一类"看不见"的延迟。
一、现象与背景:一个 40ms 的幽灵
2024 年 3 月,某电商订单链路(下单 → 库存 → 价格 → 风控 → 支付路由,共 12 跳同步 RPC,每跳 P50 约 10ms)在业务无变更的情况下,P99 从 180ms 突然跳到 1150ms,持续约 3 天。
几个让我警觉的细节:
- 网关入口 QPS 只涨了 8%(从 2100 到 2270),远不足以解释 6 倍劣化;
- 每一跳的 P50 都正常(8~14ms),只有 P99 被拉高到 40~50ms;
- 全链路 trace 里,12 跳中有 1 跳固定落在 38~42ms 区间,其它跳都在正常水位;
- 出问题的那一跳,上游服务是 Java 8 + Netty 4.1.x,下游是 Java 11 + gRPC 1.52;
- 下游 CPU、GC、慢 SQL 全部干净,磁盘 IO 也正常。
当时业务侧的体感是:下单成功率没掉,但支付超时重试率从 0.3% 涨到 2.1%——这种"不报错但变慢"的问题,最容易被当成"网络抖动"放过。
二、根因分析:两个"好心"的算法联手坑了你
TCP 有两个默认开启的机制,单独看都是合理的,叠在一起就出事:
Nagle 算法(发送端):如果上一个包的 ACK 还没回来,且新数据不足一个 MSS(通常 1460 字节),就先攒着不发。设计初衷是减少网络上小包的泛滥。
Delayed ACK(接收端,Linux 默认 net.ipv4.tcp_delack_min=1,实际延迟约 40ms):收到小包后不立刻回 ACK,等 40ms 或攒够 2 个包再回。设计初衷是省 ACK 包。
两者联动的经典死锁:
- 发送端发一个小包(比如 RPC 请求头,200 字节);
- 接收端收到后不立刻 ACK,等 40ms;
- 这 40ms 内,发送端又来一个响应包(比如 gRPC 的 trailing metadata,又是小数据);
- 发送端发现上一个 ACK 还没到 + 新数据不足 MSS → Nagle 触发,把响应包扣住;
- 40ms 后接收端才回 ACK,发送端拿到 ACK 才放行响应包。
净效果:一个 RPC 往返凭空多了 40ms。在单跳里这 40ms 藏在 P99 里,在 12 跳链路里只要 1 跳命中,就是 180ms → 220ms 起步;如果链路里多跳都是小响应 + 高并发,P99 翻 6 倍完全正常。
为什么 P50 不受影响?——因为不是每个请求都触发 Nagle。只有"上一个 ACK 还没到 + 新数据是小包"这个窗口命中时才卡 40ms,命中概率在 10%~30% 之间,正好落在 P99 分位。
三、具体做法:3 步定位 + 1 行代码修复
第 1 步:用 tcpdump 抓 40ms 空窗
# 在出问题的下游机器上抓
tcpdump -i eth0 -nn -ttt 'tcp and host <上游IP> and port <下游端口>' -w rpc.pcap
# 用 Wireshark 打开,找请求包和响应包之间的时间差
# 如果稳定看到 ~40ms 的 gap,且 gap 前没有 ACK → 基本坐实 Nagle + Delayed ACK
第 2 步:确认是 Nagle(不是 Delayed ACK 单独问题)
# 看系统是否禁用了 Delayed ACK(有些云厂商默认禁)
sysctl net.ipv4.tcp_delack_min
# 如果 tcp_delack_min=1 且没被改过 → Delayed ACK 是开的
# 看发送端 Nagle 状态(Java 应用层)
# Netty 默认:channel.config().isTcpNoDelay() == false(即 Nagle 开)
# gRPC 默认:GRPC 内部已经 setTcpNoDelay(true),所以 gRPC 侧通常没问题
# 问题往往出在 **客户端用 Netty/OkHttp 自研 RPC** 的那一跳
第 3 步:一行代码修复(客户端侧)
// Netty 客户端(出问题的服务)
Bootstrap b = new Bootstrap()
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true) // ← 加这一行
.option(ChannelOption.SO_KEEPALIVE, true);
// 或者已有 Channel 上:
channel.config().setTcpNoDelay(true);
# 如果不想改代码,也可以在系统层"半禁"(不推荐,影响所有 TCP)
sysctl -w net.ipv4.tcp_no_metrics_cache=1 # 这个不解决 Nagle,只是缓解
# 真正禁 Nagle 只能应用层 setSocketOption(SO_NODELAY)
修复后实测:出问题的 1 跳 P99 从 42ms 降到 11ms,全链路 P99 从 1150ms 回到 192ms,支付超时重试率回到 0.3%。
四、踩过的坑与反直觉之处
-
Nagle 是默认开的。Linux 默认
net.ipv4.tcp_nagle不存在(没有全局开关),JavaSocket默认tcpNoDelay=false(即 Nagle 开)。很多人以为"我用了 TCP,延迟就透明",其实应用层没显式关掉 Nagle,它就在后台攒包。 -
只有小响应才中招。如果你的 RPC 响应都是几十 KB 以上,Nagle 不会攒(超过 MSS 直接发)。问题集中在"响应是 header/metadata/短 JSON"这类场景——gRPC 的 trailing metadata、HTTP/2 的 RST_STREAM、心跳包,都是重灾区。
-
P50 正常 ≠ 没有问题。这类 40ms 的"毛刺"只出现在 P90~P99 区间,用平均 RT 监控会完全漏掉。建议 RPC 框架的 metrics 至少暴露 P50/P95/P99 三个分位。
-
不要全局禁 Delayed ACK。有些团队一发现问题就
sysctl -w net.ipv4.tcp_delack_ack=0,这把接收端 ACK 也改掉了,高并发下 ACK 风暴反而更糟。正确做法是客户端关 Nagle,让发送端不等 ACK 就发小包,接收端的 Delayed ACK 照常工作。 -
gRPC 用户注意:gRPC Java 客户端默认已经
setTcpNoDelay(true),所以如果你的全链路都是 gRPC,大概率不会踩这个坑。踩坑的多是自研 Netty RPC + gRPC 混用的架构——那一跳自研的客户端没关 Nagle,就成了整条链路的瓶颈。
五、2025 年 6 月复盘补充:两个新坑 + 一个免抓包验证法
新坑 1:HTTP/2 多路复用下,40ms 会"嫁祸"到另一个 stream
2025 年 6 月,另一个团队(用 Envoy + HTTP/2 做内部服务间通信)遇到类似 P99 毛刺。trace 里看到的诡异现象是:stream A 的请求发出后,stream B 的响应延迟了 40ms——40ms 不是出现在"谁发的请求"上,而是出现在"谁在等下一个小包"上。
原因:HTTP/2 多路复用共享一条 TCP 连接。当 stream A 的客户端发完请求后,Nagle 扣住了 stream A 的一个后续小包(比如 DATA frame 的 tail),此时 stream B 的 server 端正在等 ACK 确认 window update → stream B 的响应被间接延迟。在 trace 里,这 40ms 记在了 stream B 的 span 上,但根因是 stream A 的 Nagle。
排查方法:看 ss -ti 里同一 TCP 连接的 unacked 和 nottsent 计数,如果 unacked > 0 且应用层有多 stream 并发,Nagle 跨 stream 污染概率极高。
新坑 2:Nginx / LB 的 upstream keepalive 让 40ms "隐身"到 TTFB 里
Nginx 配了 proxy_http_version 1.1 + proxy_set_header Connection "" 启用 upstream keepalive 后,LB 和后端之间是长连接复用。此时:
- 第一次请求走的是新连接,Nagle 无历史 ACK 可等,不触发;
- 第 2~N 次请求复用连接,如果前一次响应的 ACK 还没回来 + 本次请求是小包(比如 GET /health,200 字节)→ Nagle 触发,40ms 出现在 TTFB(Time To First Byte)里。
更隐蔽的是:Nginx 的 proxy_read_timeout 默认 60s,40ms 远小于超时,不会触发任何重试或告警。你只能从 upstream_response_time - upstream_header_time 的差值里看到它,而大多数团队的 Nginx 日志只记 upstream_response_time,不记 upstream_header_time。
修复:LB 侧(Nginx/Envoy)加 proxy_buffering off(减少 LB 攒包窗口)+ 后端客户端关 Nagle(治本)。
免 tcpdump 快速验证(30 秒出结论)
如果环境不方便抓包(生产安全限制、容器化环境),用 ss -ti 看 TCP 状态:
# 看目标连接的 unacked 包数和 Nagle 相关状态
ss -ti dst :<下游端口> | grep -E "unacked|notsent|rto"
# 关键指标:
# unacked > 0 → 有包发了但 ACK 没回来(Nagle 可能正在等)
# notsent > 0 → 有包在发送缓冲区排队(Nagle 正在攒)
# rto > 200ms → 网络 RTT 偏大(排除网络慢的干扰)
# 如果 unacked=0 且 notsent=0 但 RTT 正常 → Nagle 不是根因,查应用层
配合应用层:
# Java 进程内直接看 Socket 的 Nagle 状态(JVM 9+)
jcmd <pid> VM.native_memory summary # 看 socket 数
# 或者用 Arthas:
ognl '#conn=@java.net.Socket@getLocalPort(), @com.zaxxer...#...' # 太复杂,直接看代码
# 最简单:Arthas watch
watch io.netty.channel.socket.nio.NioSocketChannel config '{returnObj.tcpNoDelayEnabled}' -x 2 -n 5
# 如果输出 false → Nagle 开着,确认嫌疑
六、结论与可复用清单
结论:RPC 链路 P99 劣化但 P50 正常、CPU/GC/IO 都干净时,优先怀疑 TCP 层的 Nagle + Delayed ACK 联动,而不是急着查慢 SQL 或网络带宽。
可复用清单(下次遇到类似"幽灵延迟"直接照着做):
- [ ]
ss -ti看 unacked/notsent,30 秒排除或坐实 Nagle - [ ] 抓包看请求/响应间是否有 ~40ms 稳定空窗(有条件时)
- [ ] 确认客户端是否显式设置了
TCP_NODELAY=true(Netty:isTcpNoDelay();Java Socket:getTcpNoDelay()) - [ ] 检查响应体大小:如果 P99 慢的那跳响应 < 1KB,Nagle 嫌疑 +1
- [ ] 对比 P50 vs P99:P50 正常 + P99 毛刺 = 概率性触发,典型 Nagle 特征
- [ ] HTTP/2 多路复用场景:检查是否跨 stream 污染(
ss -ti看 unacked + 多 stream 并发) - [ ] LB keepalive 场景:加
upstream_header_time日志,看 TTFB 是否含 40ms 毛刺 - [ ] 修复:客户端
setTcpNoDelay(true),不要动系统层 Delayed ACK - [ ] 长期:RPC 框架的 metrics 必须暴露 P99,且告警阈值要基于 P99 而不是平均 RT
一句话总结:Nagle 和 Delayed ACK 各自都是 TCP 的"好员工",但它们联手时,你的 P99 就是它们的 KPI。