网络-09 TCP 流量控制与拥塞控制
前置阅读:网络-08 TCP 可靠传输
1. 滑动窗口
客户端每次发送后,服务端对其响应,没有收到响应时会等待并重发。这样效率是低下的,由此引入“窗口”来提高效率。 简单来说,在窗口范围内不用先等上次的响应,继续发下面的内容。
那丢包了怎么发现?接收方每收到一个失序的报文段,就立刻重复发送它当前期望的那个 ack 号(即"重复确认")。发送方一旦连续收到三个重复 ACK,就判定该段已丢失并立即重传,不必等重传定时器超时——这就是快重传。注意方向:判断和重传的动作都发生在发送方,接收方只是通过重复 ACK 把"我这里缺了东西"这个信号传递出去,并没有"通知对方重发"的专门报文。

发送窗口=min{接收窗口rwnd,拥塞窗口cwnd) 接收窗口:接收方根据接受缓存设置的值,并告知发送方,反映接收方容量。 拥塞窗口:发送方根据自己估算的网络拥塞程度而设置的窗口值,反映网络当前容量。 发送端有发送缓存窗口,接收端有接收缓存窗口。
2. 流量控制
如果发送方把数据发送得过快,接收方可能会来不及接收,这就会造成数据的丢失。所谓流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收。
在通信过程中,接收方根据自己的接收缓存大小、动态地调整发送窗口大小,即接收窗口rwnd(receive window接收方设置确认报文段的窗口字段来将rwnd通知给发送方),发送方的发送窗口取决于接收窗口rwnd和拥塞窗口cwnd的最小值)。
设A向B发送数据。在连接建立时,B告诉了A:“我的接收窗口是 rwnd = 400 ”(这里的 rwnd 表示 receiver window) 。因此,发送方的发送窗口不能超过接收方给出的接收窗口的数值。请注意,TCP的窗口单位是字节,不是报文段。
TCP为每一个连接设有一个持续计时器(persistence timer)。只要TCP连接的一方收到对方的零窗口通知,就启动持续计时器。若持续计时器设置的时间到期,就发送一个零窗口控测报文段(携1字节的数据),那么收到这个报文段的一方就重新设置持续计时器
3. 拥塞控制
3.1 慢开始和拥塞避免
上面讲到,窗口可以提高传输效率,但是刚开始窗口就比较大,就很可能造成堵塞。所以提出一个慢启动的概念。经典教材从 1 MSS 开始举例:1、2、4、8,按 RTT 指数增长;现代 TCP 的初始拥塞窗口通常是 10 MSS,但增长思想相同。到达一个点后,开始慢速增长,就是为了拥塞避免,这时候换成近似线性增长。我们把这个转换的转折点叫做门限ssthresh。发送方维持一个叫做拥塞窗口 cwnd (congestion window)的状态变量。拥塞窗口的大小取决于网络的拥塞程度,并且动态地在变化。

我们图中可以看到,门限变为拥塞窗口的一半,再从1开始慢慢增加。
问题来了,如果网络一直不拥塞就可以一直增大吗?不会,发送的窗口大小还要受接收窗口rwnd大小的限制,所以 客户端发送窗口的上限 = min(rwnd,cwnd)
3.2 快重传
又一个问题来了,怎么及时知道网络拥塞了? 快重传算法首先要求接收方每收到一个失序的报文段后就立即发出重复确认。这样做可以让发送方及早知道有报文段没有到达接收方。每当比期望序号大的失序报文到达时,就发送ack号为期望号。 发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段。 不难看出,快重传并非取消重传计时器,而是在某些情况下可更早地重传丢失的报文段。
3.3 快恢复
当发送端收到连续三个重复的确认时,就执行“乘法减小”算法:把当前拥塞窗口 cwnd 的一半赋给慢开始门限 ssthresh,然后令 cwnd = ssthresh(标准实现还会再加 3 个 MSS,用于补偿已经离开网络的那三个报文段),接下去直接进入拥塞避免阶段做线性增长。
注意这里容易说错:减半的对象是 cwnd,减半的结果赋给 ssthresh,而不是"把 ssthresh 减半"。
换句话说,快恢复下拥塞后并不是从 1 开始慢慢增加,而是从拥塞窗口的一半处开始线性增加——这也是它和超时重传的区别:超时重传说明网络状况很差,要退回 cwnd = 1 重新慢开始;而三个重复 ACK 说明后续报文还能到达接收方,网络只是轻微拥塞,没必要退那么狠。

4. 流量控制和拥塞控制有什么区别
流量控制是发送端控制的,根据接收端的情况选择发送速度,原理是通过滑动窗口的大小改变来实现。而拥塞控制是根据的传输线路的拥挤程度来选择发送速度。好比是两个车站间传送货物,一个是根据接收车站的接收效率决定,一个是根据路上的车辆拥挤决定。
5. 现代服务端还需要知道什么
5.1 窗口不只决定“能发多少”,还决定吞吐上限
链路上保持多少未确认数据,至少要覆盖**带宽时延积(BDP)**才能跑满:
BDP = 带宽 × RTT
例如 1 Gbps、RTT 100ms 的跨国链路,BDP 约为 12.5 MB。若接收窗口或发送缓冲区只有几 MB,即使链路完全不丢包,也不可能跑满带宽。
TCP 首部原始窗口字段只有 16 位,最大 65535 字节。现代 TCP 在握手时通过 Window Scale 选项协商窗口扩大因子。抓包看到窗口字段很小,不要忘记 Wireshark 展示的计算窗口还要考虑这个缩放值。
5.2 Reno、CUBIC 与 BBR
前文描述的是 Reno 系的经典框架,面试必须掌握,因为“慢开始、拥塞避免、快重传、快恢复”都由它建立。但生产内核通常使用更现代的算法:
| 算法 | 主要依据 | 特点 |
|---|---|---|
| Reno/NewReno | 丢包 | 逻辑直观,高带宽高 RTT 链路增长较慢 |
| CUBIC | 丢包 + 三次函数增长 | Linux 常见默认算法,高 BDP 网络比 Reno 更积极 |
| BBR | 估算瓶颈带宽和最小 RTT | 不把每次丢包都等同于拥塞,主动控制发送速率和在途数据 |
BBR 并不是所有场景都一定更快。算法之间还涉及公平性、队列长度、内核版本和网络模型。正确做法是通过压测和线上指标选择,而不是看到“BBR”就全局打开。
查看当前算法:
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
ss -ti
5.3 ECN 与 pacing
传统算法等到丢包才认为发生拥塞,信号来得较晚。**ECN(Explicit Congestion Notification)**允许支持它的路由器在包上做拥塞标记,接收方再通过 TCP 标志反馈给发送方,从而在真正丢包前降速。
**Pacing(节奏发送)**则把一整个窗口的数据均匀铺到一个 RTT 内发送,避免瞬间突发把交换机队列打满。现代拥塞控制不仅决定“窗口多大”,也越来越关注“这些包以什么节奏发出去”。
对 Go 应用来说,这些主要由内核完成。应用层最重要的是不要制造无界并发:即使单条 TCP 连接能自我拥塞控制,成千上万条连接同时启动仍会给下游和 NAT/LB 带来突发压力,应使用并发限制和连接池。
6. 本章面试题
流量控制和拥塞控制的区别?
控制的对象不同:
- 流量控制是端到端的——发送方根据接收方的处理能力调整速度,靠接收窗口
rwnd实现。解决的是"接收方来不及收"。 - 拥塞控制是全局的——发送方根据网络的拥挤程度调整速度,靠拥塞窗口
cwnd实现。解决的是"网络扛不住"。
实际发送窗口 = min(rwnd, cwnd),两者同时起作用,取更严格的那个。
类比:两个车站间送货,流量控制是看收货车站的卸货能力,拥塞控制是看路上的拥堵情况。
慢开始、拥塞避免、快重传、快恢复分别做什么?
- 慢开始:
cwnd从 1 开始指数增长(1→2→4→8…),快速探测可用带宽。名字里的"慢"指起点低,增长其实很快。 - 拥塞避免:
cwnd达到门限ssthresh后改为线性增长(每轮 +1),谨慎试探上限。 - 快重传:收到三个重复 ACK 立即重传丢失的段,不等超时(见 网络-08)。
- 快恢复:配合快重传——把
ssthresh设为当前cwnd的一半,然后cwnd = ssthresh直接进入拥塞避免,不退回 1。
关键区别:超时重传说明网络很差,要退回 cwnd=1 重新慢开始;而三个重复 ACK 说明后续报文仍能到达,只是轻微拥塞,没必要退那么狠。
快恢复时 ssthresh 和 cwnd 怎么变?(容易说错)
正确说法:把当前 cwnd 的一半赋给 ssthresh,然后令 cwnd = ssthresh(标准实现还会加 3 个 MSS,补偿已离开网络的那三个报文段),随后进入拥塞避免做线性增长。
常见错误说法是"把 ssthresh 减半" —— 减半的对象是 cwnd,减半的结果赋给 ssthresh。
零窗口(Win=0)是什么?发送方怎么知道可以继续发了?
接收方缓冲区满时,会通告 rwnd = 0,告诉发送方"别发了"。
发送方不能干等——万一后续的窗口更新通知丢了就会永久死锁。所以 TCP 为每个连接设有持续计时器(persistence timer):收到零窗口通知就启动,到期后发送一个零窗口探测报文(携带 1 字节数据),迫使接收方回应当前窗口状态。
抓包时看到 Win=0 是一个强信号:说明接收方应用读取数据太慢,瓶颈在对端处理能力,不在网络链路。识别方法见 网络-21。
为什么慢开始要从 1 开始,而不是直接用大窗口?
因为发送方完全不知道网络当前的容量。一上来就发大量数据,很可能瞬间造成拥塞和大面积丢包,反而更慢(拥塞崩溃)。
指数增长保证了探测速度并不慢——10 轮 RTT 就能到 1024。这是"先谨慎试探、快速逼近、接近上限时转为保守"的策略。
补充:现代实现的初始窗口通常是 10 个 MSS(RFC 6928)而非 1,因为如今的网络带宽远高于协议设计年代。
什么是带宽时延积 BDP?为什么链路不丢包也可能跑不满?
BDP = 带宽 × RTT,表示跑满链路时需要同时保持在途的未确认数据量。1 Gbps、100ms 的链路需要约 12.5 MB 窗口。
如果 rwnd、cwnd 或 socket 缓冲区小于 BDP,发送方必须频繁等待 ACK,即使网络完全不丢包也跑不满。
Reno、CUBIC 和 BBR 的核心区别?
- Reno/NewReno 以丢包为主要拥塞信号,建立了慢开始、拥塞避免等经典框架;
- CUBIC 同样以丢包为主,但用三次函数增长窗口,更适合高 BDP 网络,是 Linux 常见默认;
- BBR 主动估算瓶颈带宽和最小 RTT,控制发送速率和在途数据,不把每次丢包都直接当作拥塞。
BBR 不是所有场景都必然更快,选择算法应结合内核版本、公平性和实际压测。
以上是经典的拥塞控制算法(Reno 系)框架。现代 Linux 默认使用 CUBIC,而在跨国、弱网等高丢包场景下 BBR 表现更好——它不再把丢包当作拥塞信号,而是主动测量带宽与 RTT。
拥塞控制算法的选择、内核参数调优以及 TIME_WAIT / CLOSE_WAIT 等线上问题的排查,见 网络-10 TCP 实战调优。
xingliuhua