8.2 拥塞控制、Nagle 与延迟
一句话
一条 TCP 连接的吞吐由
min(cwnd, rwnd) / RTT决定,延迟由 “握手 + 慢启动 + Nagle/延迟确认 + 排队” 四段叠加;带宽很少是瓶颈,RTT 和窗口才是。
先把五个量摆清楚
| 量 | 谁维护 | 含义 | 典型值 |
|---|---|---|---|
| cwnd 拥塞窗口 | 发送方内核 | 我允许多少未确认数据在途(怕把网络打爆) | 10 MSS 起步,动态涨落 |
| rwnd 接收窗口 | 接收方内核 | 我还能收多少(怕把自己打爆) | 由 socket 缓冲区决定 |
| MSS | 双方协商 | 单段最大载荷 | 1460(以太网) |
| RTT | 测量 | 一个往返 | 同机房 0.5ms,跨洋 180ms |
| BDP | 算出来 | 管道容量 = 带宽 × RTT | 1Gbps × 100ms = 12.5MB |
真正决定 “一次能发多少” 的是:
在途数据上限 = min(cwnd, rwnd) 吞吐上限 = min(cwnd, rwnd) / RTT
🔑 **拥塞控制管 “网络别堵”,流量控制管 “接收方别爆”。**两者取小,谁小谁就是瓶颈——排查吞吐问题时,先分清是哪一个。
拥塞控制:一条连接的一生
| 阶段 | 触发条件 | cwnd 怎么变 | 曲线 |
|---|---|---|---|
| 慢启动 | 连接开始,或 cwnd < ssthresh | 每收到一个 ACK +1 MSS ≈ 每 RTT 翻倍 | 指数 |
| 拥塞避免 | cwnd ≥ ssthresh | 每 RTT +1 MSS(加性增) | 线性 |
| 快重传 | 收到 3 个重复 ACK | 立刻重传丢失段,不等 RTO | — |
| 快恢复 | 快重传之后 | ssthresh = cwnd/2,cwnd 降到 ssthresh | 减半,不归零 |
| 超时重传 | RTO 到期(没收到任何 ACK) | ssthresh = cwnd/2,cwnd = 1 MSS | 归零重来 |
- 初始 cwnd = 10 MSS (RFC 6928) ≈ 14.6KB。这是 “新连接一开始就很慢” 的根源。
- RTO 初始 1 秒,之后指数退避 (1s、2s、4s…),Linux 下限 200ms。所以 “超时重传” 的代价远大于 “快重传”——一个丢包靠 3 个重复 ACK 能救回来,靠超时就是几百毫秒起步。
- 丢包不等于拥塞:随机误码、无线丢包、链路抖动都会被当成拥塞信号,cwnd 直接减半——这是 CUBIC 在长肥管道上跑不满的根本原因。
吞吐 = 窗口 / RTT:一张表看懂 BDP
先算管道能装多少数据 (BDP = 带宽 × RTT):
| 带宽 | RTT 1ms | RTT 10ms | RTT 50ms | RTT 100ms |
|---|---|---|---|---|
| 100 Mbps | 12.5 KB | 125 KB | 625 KB | 1.25 MB |
| 1 Gbps | 125 KB | 1.25 MB | 6.25 MB | 12.5 MB |
| 10 Gbps | 1.25 MB | 12.5 MB | 62.5 MB | 125 MB |
再看 “窗口不够时能跑多快” (吞吐 = 窗口 / RTT,RTT 固定 100ms):
| 发送窗口 | 吞吐上限 | 占 1Gbps 的比例 |
|---|---|---|
| 64 KB | 640 KB/s ≈ 5.1 Mbps | 0.5% |
| 256 KB | 2.5 MB/s ≈ 20 Mbps | 2% |
| 1 MB | 10 MB/s ≈ 80 Mbps | 8% |
| 12.5 MB | 125 MB/s ≈ 1 Gbps | 100% |
🔑 **“千兆带宽只跑到 5Mbps” 几乎永远是窗口问题,不是带宽问题。**64KB × 未开窗口缩放 (window scaling)= 经典事故:老设备不协商
WS选项,窗口被锁在 64KB,RTT 一过 100ms 吞吐就只剩 5Mbps。
窗口字段只有 16 bit,原生上限 65535 字节。窗口缩放选项 (RFC 1323) 把窗口乘上 2^shift (shift ≤ 14),最大约 1GB。这条选项只在 SYN 里协商,连接建立后无法补救——中间设备改写或丢弃 SYN 选项,会把两端一起打回 64KB。
慢启动有多慢:首屏 700ms 的由来
从 10 MSS 起步,每个 RTT 翻倍 (RTT 100ms):
| 经过 RTT | cwnd | 对应数据量 |
|---|---|---|
| 0 | 10 MSS | 14.6 KB |
| 3 | 80 MSS | 117 KB |
| 6 | 640 MSS | 936 KB |
| 7 | 1280 MSS | 1.87 MB |
- 到 1MB 需要 7 个 RTT:100ms 链路 = 700ms,30ms 链路 = 210ms。
- 也就是说,一条只传 50KB 的连接,一辈子都在慢启动里。这就是 “短连接 + 大带宽” 形不成吞吐的原因:连接还没热身就关了。
- 反过来,长连接 / 连接池让 cwnd 一直保持在高位,第 N 次请求直接满速——这是连接复用最容易被忽略的收益 (详见 8.3 连接复用与 TIME_WAIT)。
算法演进:Reno → CUBIC → BBR
| 算法 | 判据 | 怎么退让 | 强项 | 弱项 |
|---|---|---|---|---|
| Reno | 丢包 | 丢一个包就减半(AIMD) | 简单、公平 | 高 BDP 下恢复慢,抖动大 |
| CUBIC | 丢包 | cwnd 按时间三次函数增长,与 RTT 无关 | Linux 默认,RTT 公平性好 | 长肥管道 + 有丢包时跑不满 |
| BBR | 带宽 + 最小 RTT | 建模 BtlBw 与 RTprop,按带宽 pacing,不靠丢包退让 | 高丢包、跨洋链路吞吐高 | 与丢包驱动算法共存时公平性差 |
| DCTCP | ECN 标记 | 按标记比例小幅降窗 | 数据中心低排队 | 需要交换机支持 ECN |
丢包对吞吐的伤害 (Mathis 近似,Reno 类算法):
吞吐 ≈ (MSS / RTT) × 1.22 / √p p = 丢包率
RTT 100ms、MSS 1460 时:
| 丢包率 | 吞吐上限 | 说明 |
|---|---|---|
| 0.01% | ≈ 14 Mbps | 正常链路 |
| 0.1% | ≈ 4.5 Mbps | 跨境公网常见 |
| 1% | ≈ 1.4 Mbps | 卫星 / 弱网,CUBIC 基本废掉 |
🔑 **丢包驱动的算法把 “丢包率” 当唯一信号,于是 1% 的随机丢包就能把吞吐压到带宽的千分之一量级。**BBR 换成 “测带宽 + 测最小 RTT”,同样的链路能跑出数量级更高的吞吐——这是跨境专线/弱网场景换算法的理由。
Nagle 与延迟确认:两个 “善意优化” 的 40ms 连招
Nagle 算法 (RFC 896):只要还有 “已发出、未确认” 的数据,就把新的小段缓存起来,等 ACK 或攒够一个 MSS 再发。
| 情况 | Nagle 的行为 |
|---|---|
| 没有未确认数据 | 小包立即发 |
| 有未确认数据,新数据 < MSS | 缓存,等 ACK |
| 缓存攒够一个 MSS | 立即发 |
延迟确认 (Delayed ACK):收到数据不马上回 ACK,而是等一会儿,指望 “有数据要回时顺便捎带” 或 “两个包合并确认”。
| 情况 | 接收方行为 |
|---|---|
| 有数据要回(双向流量) | ACK 捎带在数据里,立即 |
| 只有单向数据 | 等 40ms(Linux TCP_DELACK_MIN)再 ACK |
| 连续收到两个段 | 第二个段到达时立即 ACK |
两个都 “省” 的优化撞在一起:
t=0 client: write("a") → a 立即发出
t=0 client: write("b") → 有未确认数据,被 Nagle 压住
t=40ms server: 延迟确认到期 → ACK(a)
t=40ms+RTT client: 收到 ACK → 发出 b
t=40ms+RTT server: 收到 b → 服务端看到两字节间隔 ≈ 40ms + RTT
一次 “写两段、读一次” 的交互就多付 40ms。1000 次这种交互 = 40 秒。
反例与正解 (同一个函数,只差 “写几次”):
// ❌ 两次 write + 一次 read:Nagle 与延迟确认叠加,每次多 40ms
func bad(conn net.Conn, buf []byte) error {
// 第一段:立即发
if _, err := conn.Write([]byte("GET / HTTP/1.1\r\n")); err != nil {
return err
}
// 第二段:有未确认数据,被 Nagle 压住,等第一个包的 ACK
if _, err := conn.Write([]byte("Host: x\r\n\r\n")); err != nil {
return err
}
_, err := io.ReadFull(conn, buf) // 服务端此时才回数据
return err
}
// ✅ 合并成一次 write:一个段发完,Nagle 没有可压的东西
func good(conn net.Conn, buf []byte) error {
req := []byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")
if _, err := conn.Write(req); err != nil {
return err
}
_, err := io.ReadFull(conn, buf)
return err
}
修法三选一:
| 修法 | 怎么做 | 适用 |
|---|---|---|
| 合并写(首选) | bufio.Writer / writev / 一次 Write | 应用层可控的拼包 |
| 关 Nagle | conn.SetNoDelay(true)(TCP_NODELAY) | 请求-响应型协议、RPC、Redis、MySQL、游戏 |
| 攒包 | Linux TCP_CORK,攒满 MSS 或 200ms 再发 | 明确知道要「头 + 体」一起发的场景 |
什么时候该关 Nagle:
| 场景 | 关不关 | 原因 |
|---|---|---|
| HTTP / RPC / gRPC | 关 | 小请求频繁,且每次都在等响应 |
| Redis / MySQL / 各类 DB 客户端 | 关 | 请求小、响应依赖请求快速到达 |
| 游戏、终端、实时交互 | 关 | 延迟敏感,包越小越好发 |
| 单向大流量(文件、日志上传) | 不用管 | 基本不触发 Nagle,触发了也只是合并,不影响总时长 |
| 高频小包广播 / 推送 | 视情况 | 关掉可能让包数翻倍,先测再定 |
💡 Go 的
net包默认给 TCP 连接开了TCP_NODELAY——所以 Go 服务端不会踩这个坑。踩坑的通常是 C/C++/Java 客户端,或有人为了 “省包” 手动SetNoDelay(false)。反向的坑也存在:关了 Nagle 却仍然写两次,段数没变少,只是把 “等 40ms” 换成 “多一个包的开销”——真正的解法是合并写。
一次 RPC 的延迟预算 (RTT 30ms,短连接)
| 环节 | 耗时 | 能否省掉 |
|---|---|---|
| DNS 解析 | 0–100 ms | 缓存 / 预解析 / HTTPDNS |
| TCP 三次握手 | 30 ms(1 RTT) | 连接池 |
| TLS 1.3 握手 | 30 ms(1 RTT) | 会话复用(0-RTT) |
| 慢启动 | 前 7 个 RTT 吞吐不足 | 连接预热、长连接 |
| 请求-响应 | 30–60 ms(1–2 RTT) | 就近接入、减少串联 |
| Nagle + 延迟确认 | 0–40 ms | TCP_NODELAY / 合并写 |
| 代理 / LB 串联 | 每跳 0.5–2 ms + 排队 | 减少跳数、限制排队 |
🔑 同一台机器、同一段业务代码,短连接 90ms 起,长连接 30ms 起。差的 60ms 全是协议开销,业务逻辑一行没变。
实测:怎么在自己的机器上看到 (或看不到) 40ms
package main
import (
"fmt"
"net"
"time"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
defer ln.Close()
gapCh := make(chan time.Duration, 1)
// 服务端只收不回:没有数据可捎带,ACK 只能走延迟确认定时器
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1)
if _, err := c.Read(buf); err != nil {
return
}
t1 := time.Now()
if _, err := c.Read(buf); err != nil { // 第二个字节到达的时刻
return
}
gapCh <- time.Since(t1)
}(c)
}
}()
for _, noDelay := range []bool{false, true} {
c, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
panic(err)
}
c.(*net.TCPConn).SetNoDelay(noDelay) // Go 默认 true,这里显式覆盖
c.Write([]byte("a")) // 第一个小包:立即发出
start := time.Now()
c.Write([]byte("b")) // 有未确认数据时,Nagle 会压住它
gap := <-gapCh
fmt.Printf("NoDelay=%-5v 第二字节晚到 %v(Write(\"b\") 返回耗时 %v)\n",
noDelay, gap.Round(time.Millisecond), time.Since(start).Round(time.Millisecond))
c.Close()
}
}
本机实测 (macOS 回环,Go 1.27):
NoDelay=false 第二字节晚到 0s(Write("b") 返回耗时 0s)
NoDelay=true 第二字节晚到 0s(Write("b") 返回耗时 0s)
测不出来不代表没有,而是这个环境不满足触发条件:
| 条件 | 回环上 | 真实链路 / Linux 上 |
|---|---|---|
| 接收方没有数据可捎带 | 满足 | 满足 |
| ACK 真的被延迟 | 不满足:回环立即 ACK | 满足,等 40ms |
| 观察到两段之间约 40ms 的间隔 | 否 | 是 |
在 Linux 服务器上跑同一段代码,看到的是 ≈40ms;在真实网卡上用抓包确认最直接:
# 看两个小包的发送间隔(-ttt 打印相对时间戳)
sudo tcpdump -i eth0 -nn -ttt 'tcp port 8080' -c 20
⚠️ 别用 “回环测不出来” 去否定这个问题。回环没有物理链路,ACK 几乎零成本;生产上 40ms 的毛刺正是发生在 “服务端不返回数据” 的请求-响应模式里。
反模式
| 反模式 | 后果 |
|---|---|
| 只加带宽、不降 RTT | 单连接吞吐 = 窗口 / RTT,RTT 不动,加带宽无效 |
| 每请求新建连接,却指望慢启动跑满 | 连接活不过 7 个 RTT,永远在慢启动 |
| 接收缓冲区固定 64KB 不调 | 100ms RTT 下吞吐天花板 5Mbps(窗口缩放没开时更死) |
用 sleep 或应用层循环攒包 | 人为引入不确定延迟,不如 bufio / TCP_CORK |
| 无脑把 socket 缓冲区调到几 MB | bufferbloat:排队时延从 10ms 涨到数百 ms,丢包更晚才发生 |
| 在 1% 丢包的跨境链路上用 CUBIC | 吞吐 ∝ 1/√p,1% 丢包能把千兆压到 1Mbps 量级 |
| 把 cwnd 当作全局可调参数 | cwnd 是每条连接的,调不了也管不了别的连接 |
自检
Q1 1Gbps 专线、RTT 100ms,单条 TCP 连接压测只有 5Mbps;已经确认带宽空闲、CPU 不高。按顺序该查什么?
▸ 答案:先算账:5Mbps ≈ 640KB/s,640KB/s × 0.1s ≈ 64KB 的窗口。所以第一嫌疑是接收窗口被锁在 64KB (SYN 里的 window scaling 没协商上,或被中间设备改写),其次是应用读取太慢导致 rwnd 一直很小,再次是 cwnd 上不去 (丢包)。查法:抓包看 SYN 的 wscale 选项、看 ss -ti 里的 rwnd/cwnd,再看应用是否读完数据。加带宽不解决任何一种。
Q2 监控显示某个接口的 P99 稳定比 P50 多 40ms,且集中在 “写两次、读一次” 的调用上。根因和修法?
▸ 答案:Nagle 与延迟确认叠加:第二次小写被 Nagle 压住,服务端只收不回、延迟 40ms 才 ACK,于是第二个包被推迟 40ms。修法:把两次写合并成一次 (bufio.Writer 或 writev),或对该连接 SetNoDelay(true)。注意 Go 默认已开 TCP_NODELAY,遇到这个现象要查的是用了什么客户端、有没有人手动关掉。
Q3 同样的业务,为什么跨境链路上换成 BBR 后吞吐能提升一个数量级,而 CUBIC 不行?
▸ 答案:CUBIC 把丢包当拥塞信号,跨境链路有 0.1%–1% 的随机丢包,公式 吞吐 ∝ 1/√p 直接把窗口压死;BBR 不看丢包,而是周期性地测 “最大交付带宽” 和 “最小 RTT”,按 带宽 × RTT 建模发送速率并 pacing。随机丢包不是拥塞,不该退让——这是两种算法对 “丢包含义” 的假设不同。
Q4 BBR 与 CUBIC 的本质区别是什么?
▸ 答案:CUBIC 是丢包驱动:把丢包当作拥塞信号,丢包就降速;BBR 是模型驱动:测量带宽与最小 RTT,按 “带宽时延积” 控制发送速率,不把丢包等同于拥塞。所以在有随机丢包的链路上,BBR 往往吞吐更高、延迟更稳;但在深队列上,BBR 可能 “抢占” 其他流。
Q5 Nagle 与 delayed ACK 为什么会叠加出约 40ms 的延迟?
▸ 答案:因为二者互相等待:Nagle 要等 “上一个包被 ACK” 才发小包,而接收方的 delayed ACK 又要等 “有数据或超时(常 40ms)” 才回 ACK。于是小包被卡住,等到 ACK 超时才发出去。解法是 TCP_NODELAY(关 Nagle)或让应用层合并写。