
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.Read 或 syscall.Write,它不会告诉你"这次 Write 比上次慢了 40ms,因为 Nagle 在等 ACK"。
所以如果你的 pprof 干干净净,CPU 不高、内存不涨、goroutine 不多,但 P99 就是上不去——该换工具了。

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——默认开着通常是对的,但有个场景例外。

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=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 仍在攒包,互锁仍可能发生。

小结:什么时候该碰 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 下来自己跑。