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)或让应用层合并写。