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

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 就是上不去------该换工具了。

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 + 真实网络延迟的表现方向一致,但幅度会有差异。

bash 复制代码
场景                      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 字节),等服务端回复后再发下一个。

bash 复制代码
场景               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 一次能传多少数据。

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

bash 复制代码
传输 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),再判断是不是真的不够用。

bash 复制代码
// 查看当前连接的缓冲区大小(示例省略错误处理,生产代码请处理 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

相关推荐
运维开发笔记15 小时前
5.2 Go 数组进阶学习笔记(多维、排序、搜索)
go
newerp1 天前
Redis 操作与缓存策略
后端·程序员·go
newerp1 天前
GORM ORM 基础
后端·程序员·go
newerp1 天前
CRUD 操作与预处理语句
后端·程序员·go
程序员爱钓鱼1 天前
Go 编程实战:闭包 Closure——函数如何记住外部变量
后端·google·go
橘bird2 天前
Go 语言学习教程 万字精讲(新手完整版)
go
用户330144867632 天前
08. mcentral:中心缓存的 span 管理
go
用户330144867632 天前
07. mcache.refill:缓存补充与 span 归还
go
程序员爱钓鱼2 天前
Go 编程实战:匿名函数 Anonymous Function——没有名字的函数与灵活回调
后端·面试·go
用户330144867633 天前
03. Size Class 分级机制
go