为什么你的 Go TCP server P99 延迟这么高

Go 服务 P99 飙高但 pprof 看不出问题?大概率是网络层的事。两组实测数据告诉你:TCP_NODELAY 不是万金油,缓冲区也不能乱调。附完整排查判断表。

封面

Go 服务上线,P50 一切正常,P99 偶尔飙到几百毫秒。你打开 pprof,CPU 和内存都很健康,goroutine 数量也没炸。

然后你就卡住了。

代码层面看不出问题,到底慢在哪?大概率,你该往下看一层了——网络层。具体来说,是那些你从来没碰过的 socket 选项。

这篇文章不是 socket 选项大全——我聚焦两个最常见的网络层根因:Nagle 算法和缓冲区。所有结论都有实测数据,有些可能和你想的不一样。

1. 应用层排查:pprof 为什么看不出问题

遇到 P99 飙高,大多数人的第一反应是开 pprof。

CPU profile 看热点函数,heap profile 看内存分配,goroutine profile 看有没有泄漏。这些工具很好用,但它们有一个盲区:pprof 只能看到你的代码在干什么,看不到内核在干什么。

网络 I/O 的延迟发生在内核的 TCP 栈里——Nagle 在攒包、Delayed ACK 等着捎带回复,缓冲区那边也在排队。这些事情对 pprof 来说是一次普通的 syscall.Readsyscall.Write,它不会告诉你"这次 Write 比上次慢了 40ms,因为 Nagle 在等 ACK"。

所以如果你的 pprof 干干净净,CPU 不高、内存不涨、goroutine 不多,但 P99 就是上不去——该换工具了。

pprof 的盲区

2. 往下看一层:Go 标准库帮你设了什么

在你写 net.Listen("tcp", ":8080") 的时候,Go 标准库在背后做了不少事。我翻了一下 Go 1.26 的 net 包源码,列一下它默认设置的 socket 选项:

选项 默认值 设置时机 说明
TCP_NODELAY 开启 newTCPConn() — 每个连接创建时 禁用 Nagle 算法,小包立即发送
SO_REUSEADDR 开启 listenStream() — 仅 Listen 时 端口复用,服务重启不用等 TIME_WAIT
SO_KEEPALIVE 开启 newTCPConn() — 探测空闲 15s 后开始 长连接心跳,检测僵尸连接(Go 1.24+ 行为有变)
IPV6_V6ONLY 视情况 socket() — IPv6 地址族时 “tcp” 双栈设 0,“tcp6” 设 1(不同平台行为不同,通常无需关心)

注:Go 标准库不主动设置 SO_LINGER(用系统默认)。如果你遇到大量 TIME_WAIT,这是另一个值得关注的选项。

这四个选项里,最容易影响 P99 的是 TCP_NODELAY——默认开着通常是对的,但有个场景例外。

Go 标准库默认 socket 选项

3. TCP_NODELAY 不是万金油

大多数文章告诉你:TCP_NODELAY 降低延迟,开就对了。但我跑了两组实测,结果让我意外。

场景一:单向批量发送小包

我写了一个 benchmark:客户端连上服务端后,连续发 10000 个 64 字节的小包,服务端只收不回。每次直接调 conn.Write(64B),没有用 bufio.Writer 做应用层缓冲。

实验环境说明:以下所有数据在 macOS (darwin/arm64) 本地回环上测得。本地回环消除了真实 RTT 和拥塞控制等变量,测的主要是系统调用和 Nagle 算法层面的差异。线上 Linux + 真实网络延迟的表现方向一致,但幅度会有差异。

场景                      NoDelay=true    NoDelay=false   差异
小包(64B) x 10000         126.5ms         41.3ms          NoDelay 慢了 206%
中包(512B) x 10000        121.9ms         39.7ms          NoDelay 慢了 207%
大包(4KB) x 5000          42.7ms          28.1ms          NoDelay 慢了 52%

[实测 Go 1.26.2 darwin/arm64]

NoDelay 对比

NoDelay=true 反而慢了两倍多。

原因不复杂:禁用 Nagle 后,每个 64 字节的小包单独发一个 TCP 段。10000 个包 = 10000 次系统调用。而 Nagle 开启时,内核会把相邻的小包合并成更大的 TCP 段发出去,系统调用次数大幅减少。

在单向批量发送的场景下,减少系统调用次数比减少单包延迟更重要。

注意:如果你的代码用了 bufio.Writer,应用层已经做了合并,Nagle 的影响会小得多。上面的数据是裸 Write 的极端场景。

场景二:请求-响应往返

再换一个更贴近真实 RPC 的场景:客户端发一个小请求(32-64 字节),等服务端回复后再发下一个。

场景               NoDelay=true    NoDelay=false   差异
小请求(32B)         42µs            39µs           差异 <10%
小请求(64B)         40µs            40µs           基本无差

[实测 Go 1.26.2 darwin/arm64,5000 次往返均值]

在本地回环上,差异微乎其微。因为本地 RTT 接近 0,Nagle 等 ACK 几乎不需要等。

但在真实网络环境下(RTT = 1-10ms),请求-响应模式 + Nagle 开启会触发 Nagle-Delayed ACK 互锁(下一节详述),每个请求可能额外等 40-75ms(Linux 约 40ms,macOS 可达 75ms)。 这就是为什么 Go 默认开 TCP_NODELAY——它假设你的场景是请求-响应而非批量发送。

请求-响应对比

Nagle 和 Delayed ACK 的互锁

当 Nagle 和 Delayed ACK 同时存在,且发送端有未被 ACK 的在途数据时,会出现经典的互锁:客户端想发下一个小包,但 Nagle 说"上一个包的 ACK 还没收到,等着";服务端收到了数据,Delayed ACK 说"等等看有没有响应数据可以捎带回去"。双方互等,直到 Delayed ACK 超时(Linux 约 40ms,macOS 约 75ms)才打破僵局。

Go 默认开了 TCP_NODELAY(禁用 Nagle),从客户端侧避免了这个问题。但如果你的对端(比如一个 C++ 服务)没有开启 TCP_NODELAY,对端的 Nagle 仍在攒包,互锁仍可能发生。

Nagle 和 Delayed ACK 互锁

小结:什么时候该碰 TCP_NODELAY

你的场景 建议
RPC/HTTP 请求-响应(有网络延迟) 保持 NoDelay=true(Go 默认)
单向批量发送小包 考虑 SetNoDelay(false),让 Nagle 合并
日志转发、消息队列 看延迟容忍度——能忍 40ms 就关,要求实时就开

4. 缓冲区也有讲究

排除了 Nagle 的问题之后,如果吞吐量还是上不去——下一个要看的是缓冲区。

SO_RCVBUF 和 SO_SNDBUF(接收缓冲区和发送缓冲区)直接影响 TCP 一次能传多少数据。

我测了不同缓冲区大小对吞吐量的影响:

传输 100MB 数据(每次写 4096B)

缓冲区大小     吞吐量(MB/s)    耗时
系统默认        578.3          175ms
8KB            238.1          420ms    ← 吞吐量降 59%
64KB           321.7          310ms
256KB          546.2          183ms
1MB            557.7          179ms

[实测 Go 1.26.2 darwin/arm64,系统默认 SO_RCVBUF≈400KB,SO_SNDBUF≈147KB(macOS 值,Linux 默认值不同)]

两个发现:

第一,系统默认值已经挺合理的。除非你有特殊需求,不用改。

第二,手动设小缓冲区会严重影响性能。8KB 缓冲区的吞吐量只有默认值的 41%。我见过有人为了"节省内存"把缓冲区设成 4KB——省了不到 1MB 内存,丢了一半吞吐量。

在有网络延迟的环境下,缓冲区过小还会限制 TCP 接收窗口(rwnd),进一步降低吞吐量——这个影响比本地测试更显著。

Go 标准库不主动设置 SO_RCVBUF/SO_SNDBUF,用的是操作系统的默认值。这通常是对的。你要碰缓冲区,得先确认默认值是多少(GetsockoptInt),再判断是不是真的不够用。

// 查看当前连接的缓冲区大小(示例省略错误处理,生产代码请处理 err)
rawConn, _ := tcpConn.SyscallConn()
rawConn.Control(func(fd uintptr) {
    rcvBuf, _ := syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_RCVBUF)
    sndBuf, _ := syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF)
    fmt.Printf("SO_RCVBUF=%d, SO_SNDBUF=%d\n", rcvBuf, sndBuf)
})

缓冲区调优

5. 调完之后,效果怎么样

回到开头的场景。

如果你的 Go TCP server 在批量发送小包时变慢,排查发现是 TCP_NODELAY 导致的(每个小包单独发一个 TCP 段,系统调用次数暴增),关掉 NoDelay 让 Nagle 合并——批量发送总耗时从 126ms 降到 41ms,降幅 67%

如果是缓冲区设小了——把 8KB 调回系统默认,吞吐量从 238 MB/s 回到 578 MB/s,提升 143%

这些数字是本地回环的实测,度量的是批量操作的总耗时和吞吐量,不是单请求 P99。但在有真实网络延迟的线上环境中,Nagle + Delayed ACK 互锁对单请求 P99 的影响会更直接——每个请求可能多出 40-75ms 的台阶。

什么时候该调,什么时候别动

最后给一张判断表,下次遇到网络层性能问题可以照着走:

你的症状 可能的网络层原因 排查方式 建议操作
P99 偶尔多出 ~40ms 台阶 Nagle + Delayed ACK 互锁 ss -ti 查 Nagle 状态 / tcpdump 看 ACK 时序间隔 确认双端都开了 TCP_NODELAY
批量发送小包时整体变慢 TCP_NODELAY 增加系统调用 对比 SetNoDelay(true/false) 的 benchmark 结果 批量场景关闭 NoDelay
大数据传输吞吐量低 缓冲区设太小 GetsockoptInt 查 SO_RCVBUF/SO_SNDBUF 恢复系统默认或适当调大
以上都没问题 可能不是网络层的事 考虑应用层原因:连接池策略、序列化开销、GC 暂停 别乱调 socket 选项

判断决策表

大多数情况下,Go 标准库的默认设置就够了。如果需要更深入的内核态排查,ss -ti 查连接状态、tcpdump 抓包看时序是下一步。

本文的实验代码都在 zhiyulab-evidence/go-tcp-diagnostics 仓库,可以 clone 下来自己跑。


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →