TCP 拥塞控制
TCP 拥塞控制
一个问题:网络满了会怎样
你在一段公路上开车。前方堵了 10 公里,所有车都慢下来,挤在一起。最后一公里完全不动。这就是网络拥塞——路由器队列满了,新到的包被丢,丢包又触发重传,重传又让队列更满,雪崩。
1986 年互联网经历了一次"拥塞崩溃"——LBL 到 UC Berkeley 的链路 400 米距离,理论 32Kbps 带宽,因为 TCP 不断重传拥塞丢失的包,实际吞吐量降到 40bps,1/800。Van Jacobson 1988 年发表那篇经典论文《Congestion Avoidance and Control》,发明了 TCP 拥塞控制四大算法,从此互联网没再大规模崩溃过。
拥塞控制的核心概念
拥塞窗口(cwnd, Congestion Window):发送方自己维护的变量,表示"我觉得网络能承载多少数据"。和接收窗口 rwnd 不同,cwnd 是发送方对网络状态的估计。
发送窗口 = min(rwnd, cwnd)。两者都开窗口才能发。
慢启动阈值(ssthresh):慢启动和拥塞避免的分界点。cwnd 超过 ssthresh 后从"指数增长"切换到"线性增长"。
RTT(Round-Trip Time):往返时间。cwnd 在每个 RTT 增长一次。
四大算法
慢启动(Slow Start)
为什么叫慢启动? 其实一点都不慢——它每次 RTT 把 cwnd 翻倍(指数增长)。只是和后来"线性增长"比显得"慢"。
第 1 个 RTT:cwnd = 1 MSS
第 2 个 RTT:cwnd = 2 MSS
第 3 个 RTT:cwnd = 4 MSS
第 4 个 RTT:cwnd = 8 MSS
第 5 个 RTT:cwnd = 16 MSS
...
第 10 个 RTT:cwnd = 1024 MSS ≈ 1.5MB10 个 RTT 就从 1KB 长到 1.5MB。新连接开始时网络带宽未知,慢启动用来快速探测网络容量。
慢启动阈值 ssthresh 决定什么时候停止指数增长。默认 65535 字节(远古默认值)。/proc/sys/net/ipv4/tcp_slow_start_in_idle 控制是否在空闲连接上重新慢启动。
拥塞避免(Congestion Avoidance)
cwnd 达到 ssthresh 后,进入拥塞避免阶段,每个 RTT 增长 1 MSS(线性增长)。这是为了"谨慎"探测剩余容量。
为什么切换到线性? 因为指数增长到一定程度后,网络容量已经接近,线性增长给小步快跑试错的机会。如果还指数增长,下个 RTT 就会超载丢包。
快速重传(Fast Retransmit)
发送方每发一个包就启动一个重传计时器。计时器超时还没收到 ACK,发送方认为包丢了,重传。
但计时器超时通常要几百毫秒(最少 200ms),慢。快速重传让发送方在 3 次重复 ACK 后立即重传——不等计时器。
设想发送方发了包 1、2、3、4、5。包 2 丢了,但包 3、4、5 到了接收方。接收方看到包 3 是"我期望包 2",回 ACK(2)。收到包 4、5 也是 ACK(2)。发送方连续收到 3 个 ACK(2)(重复 ACK),立刻重传包 2——不等计时器。
Wireshark 过滤 tcp.analysis.duplicate_ack 看重复 ACK 数量。
快速恢复(Fast Recovery)
传统 TCP(TCP Tahoe):检测到丢包后 cwnd 直接降到 1 MSS,重新慢启动。这太激进了。
新 TCP(TCP Reno):快速重传后,ssthresh 设为当前 cwnd/2,cwnd 设为 ssthresh+3(每个重复 ACK 加 1 MSS 算"已经离开网络"),进入拥塞避免阶段。不再慢启动。
现代拥塞控制算法
经典算法(TCP Reno、Cubic)已经在数据中心和高带宽长延迟网络下捉襟见肘。Google 2016 年推出 BBR(Bottleneck Bandwidth and Round-trip propagation time),原理完全不同:
| 算法 | 思路 | 信号 |
|---|---|---|
| Reno / Cubic | 基于丢包 | 丢包就是拥塞 |
| BBR | 基于带宽和延迟 | 主动测最大带宽和最小 RTT |
Reno 在丢包时降速。BBR 测一段时间网络的最大带宽(瓶颈带宽)和最小 RTT(传播延迟),按 BDP(带宽延迟积)发数据,不依赖丢包信号。
BDP = 带宽 × RTT。例如 100Mbps 带宽 × 100ms RTT = 1.25MB。BBR 把 cwnd 设为 BDP,刚好把链路填满又不会撑爆路由器队列。
BBR 的优势:
- 弱网环境(高丢包率)下吞吐率比 Reno 高 2-10 倍
- 短 RTT 网络下启动快
- 不容易引发 bufferbloat(路由器缓存被填满导致延迟飙升)
BBR 的劣势:
- 占用带宽激进,可能饿死传统 Reno 流
- Google 服务用 BBR,对端运营商 CUBIC 流被挤占带宽,引发运营商不满
Linux 4.9+ 内核支持 BBR。sysctl net.ipv4.tcp_congestion_control 看当前算法。
真实场景:拥塞控制的"踩踏"
场景 1:看 Netflix 卡顿
- 你看 Netflix 4K 视频,码率 25Mbps
- 路由器出口带宽只有 50Mbps
- 父亲在用 BT 下载 100Mbps
- TCP Reno 探测到丢包,cwnd 减半,吞吐量下降
- 视频开始缓冲
场景 2:数据中心 incast
- 一个 Coordinator 同时向 100 台 Worker 请求数据
- 100 个回复同时到达交换机端口
- 交换机缓存溢出,丢包
- 100 个连接都重传,TCP 拥塞控制重新慢启动
- 整体吞吐量比理论低 80%——incast 问题
数据中心用 DCTCP(Data Center TCP)解决:交换机显式标记 ECN(Explicit Congestion Notification),发送方收到 ECN 就降速一点点(cwnd 减 0.1),不重传。CUBIC 减半太激进,DCTCP 适合短距离低延迟。
场景 3:跨太平洋光缆
- 中美海底光缆 RTT 200ms
- 带宽 100Gbps
- BDP = 100Gbps × 0.2s = 2.5GB
- TCP 拥塞窗口需要达到 2.5GB 才能填满链路
- 经典 TCP 慢启动要 30+ RTT 才能达到 2.5GB(6 秒)
- 这 6 秒内链路浪费
BBR 把 cwnd 一次性设到 BDP,启动时间从 6 秒降到 0。
拥塞控制 vs 流量控制:什么时候用哪个
拥塞控制:防"网络"满。解决"多个发送方共享网络时的公平与效率"。
流量控制:防"接收方"满。解决"单个接收方处理速度跟不上"。
你发 100Mbps 给朋友(接收方)
↓
接收方网卡只能收 50Mbps → 流量控制 rwnd 限制到 50Mbps
↓
中间路由器队列满,丢包 → 拥塞控制 cwnd 减半到 25Mbps
↓
最终实际速率 = min(50Mbps, 25Mbps) = 25Mbps两者协同保证"网络能转,接收方能接"。
思考题
- 经典 TCP 慢启动从 1 MSS 起步。如果 cwnd 直接设到 50 MSS 会怎样?现代操作系统会这样吗?
- 你下载一个大文件,cwnd 从 1 长到瓶颈带宽花了 10 个 RTT。如果 RTT 是 200ms,10 个 RTT 就是 2 秒。这 2 秒带宽是浪费的。BBR 怎么解决?
- 三个 TCP 流共享一条 100Mbps 链路,每个流的 RTT 不同(RTT1=10ms、RTT2=50ms、RTT3=200ms)。Reno 怎么保证公平?BBR 呢?
- 数据中心交换机开启 ECN 后,所有 TCP 流的 cwnd 都会响应 ECN。这让 DCTCP 能感知拥塞程度(多少包被标记),而不只是"丢包 vs 不丢包"。DCTCP 的 cwnd 调整公式
cwnd = cwnd × (1 - α/2)中 α 是什么?
延伸阅读
- 《深入浅出计算机网络》(高军)第 5 章
- Van Jacobson 1988: "Congestion Avoidance and Control"
- RFC 5681: TCP Congestion Control
- RFC 8257: TCP RTO 重新计算
- Google BBR 论文:https://research.google/pubs/transport-handling-packet-duplicates-and-losses-in-bbr/
- 动手实验:
sysctl net.ipv4.tcp_congestion_control看当前 Linux 拥塞控制算法 - 动手实验:访问 https://fast.com 看当前带宽,再访问 https://www.waveform.com/tools/bufferbloat 测试 bufferbloat