3. 经典拥塞控制算法:慢启动、拥塞避免、快速重传、快速恢复
TCP 的拥塞控制,说白了就是一套“试探-退让-再试探”的规则。我刚开始调流媒体的时候,总觉得这玩意儿是网络层的事,跟应用层关系不大。后来被线上卡顿狠狠教育过一次,才明白——不懂拥塞控制,你连码率为什么跳都解释不清。
这一章我们拆开来看四个核心阶段。它们不是孤立的,而是一个完整的状态机。你想想看,TCP 发送方就像个瞎子,只能靠 ACK 和丢包来猜网络状况。那它怎么猜?靠的就是这几套算法。
3.1 慢启动:从 1 开始,指数增长
连接刚建立的时候,发送方不知道网络能扛多少。所以它很怂,从 cwnd = 1 个 MSS 开始发。每收到一个 ACK,cwnd 就加 1。这看起来是线性增长,但别忘了——每个 RTT 内 ACK 的数量也在翻倍。
举个例子:
- 初始 cwnd = 1,发 1 个包,等 ACK
- 收到 1 个 ACK → cwnd = 2,发 2 个包
- 收到 2 个 ACK → cwnd = 4,发 4 个包
- 收到 4 个 ACK → cwnd = 8,发 8 个包
每个 RTT 翻一倍。这就是指数增长。为什么叫“慢启动”?其实它一点也不慢,只是起点低。我个人习惯把这段时间叫做“网络摸底期”——摸清楚了再加速。
关键参数:ssthresh(慢启动阈值)
当 cwnd 达到 ssthresh 时,慢启动结束,进入拥塞避免。ssthresh 的初始值通常很高(比如 65535 字节),但每次丢包后会被重置为当前 cwnd 的一半。
我踩过的坑: 曾经在弱网环境下,慢启动还没跑完就超时了。后来发现是初始 RTO 设置太保守。建议在流媒体场景下,把初始 cwnd 适当调大(比如 10 个 MSS),减少首屏等待时间。
3.2 拥塞避免:线性增长,小心试探
过了 ssthresh,TCP 就换了个策略。它不再指数增长,而是每个 RTT 只增加 1 个 MSS。具体实现是:每收到一个 ACK,cwnd 增加 MSS / cwnd。这样算下来,一个 RTT 内刚好加 1。
为什么这么保守?因为网络已经接近瓶颈了。你再猛冲,路由器队列就要爆。拥塞避免阶段,说白了就是“贴着天花板走”。
我记得有一次调一个直播推流,码率一直在 2Mbps 和 500Kbps 之间剧烈抖动。抓包一看,cwnd 在拥塞避免阶段反复触顶丢包。后来把发送缓冲区调大了一倍,抖动就平滑了。原因很简单——缓冲区给了拥塞控制更多的反应时间。
| 阶段 | 增长方式 | 触发条件 | 典型场景 |
|---|---|---|---|
| 慢启动 | 指数增长(每 RTT 翻倍) | 连接建立 / 超时重传后 | 连接初始、空闲恢复 |
| 拥塞避免 | 线性增长(每 RTT +1 MSS) | cwnd ≥ ssthresh | 稳定传输、接近瓶颈 |
3.3 快速重传:不等超时,提前行动
传统 TCP 靠超时重传(RTO)来恢复丢包。但 RTO 通常几百毫秒,对于实时流媒体来说太慢了。快速重传的思路是:收到 3 个重复的 ACK,就认为包丢了,不等超时直接重传。
为什么会收到重复 ACK?因为接收方收到乱序包时,会回复它期望的那个序号。比如期望 seq=100,结果来了 seq=101,那它还是回 ACK=100。连续来 3 个 seq=101、102、103,就会触发 3 个重复的 ACK=100。
注意: 快速重传只适用于单个丢包。如果连续丢多个包,或者发生超时,还是要走 RTO。我曾经在弱网环境下遇到过“伪重传”——因为网络延迟抖动导致重复 ACK 误判。后来加了延迟 ACK 阈值才解决。
3.4 快速恢复:丢包后不归零
快速重传之后,TCP 不会回到慢启动,而是进入快速恢复。这是 TCP Reno 的核心改进。它做了三件事:
- 把 ssthresh 设为当前 cwnd 的一半
- 把 cwnd 设为 ssthresh + 3(因为已经收到的 3 个重复 ACK 说明有 3 个包已经离开网络)
- 每收到一个重复 ACK,cwnd 临时加 1(相当于“偷”一点带宽)
等收到新的 ACK(非重复的),说明丢的包已经补上了,这时候把 cwnd 设为 ssthresh,进入拥塞避免。
你想想看,这个设计很聪明——丢包不一定是网络满了,可能只是短暂的抖动。快速恢复让 TCP 不用从头开始,保留了大部分吞吐量。
快速恢复 vs 慢启动:
- 慢启动:丢包后 cwnd 归 1,从头开始
- 快速恢复:丢包后 cwnd 减半,保留一半吞吐量
对于流媒体来说,快速恢复能减少码率骤降的幅度。我建议在 ABR 算法中,把快速恢复的 cwnd 变化映射到码率选择上,避免码率跳变太剧烈。
3.5 状态机总览
这四个阶段不是顺序执行的,而是根据事件切换。我画了一张状态机图,帮你理清关系:
3.6 实际调优建议
光知道理论不够,得能落地。我总结了几条实战经验:
- 初始 cwnd 调大: 默认 10 个 MSS 对于流媒体来说偏小。我建议根据 RTT 动态调整,RTT 小于 50ms 时可以设到 20-30 个 MSS。
- ssthresh 不要设太高: 如果链路带宽有限,ssthresh 设太高会导致慢启动阶段频繁丢包。建议根据历史吞吐量动态计算。
- 快速重传阈值可调: 默认 3 个重复 ACK。对于高丢包率的无线网络,可以降到 2 个,减少等待时间。
- 注意 RTO 最小值: Linux 默认 RTOmin=200ms。对于低延迟场景(比如 WebRTC),建议降到 100ms 甚至更低。
我曾经踩过的坑: 有一次在 4G 网络下做直播,发现快速恢复阶段 cwnd 恢复太慢,导致码率一直上不去。后来排查发现是接收窗口(rwnd)限制了 cwnd 增长。记住——cwnd 不能超过 rwnd,两个窗口取最小值才是实际发送窗口。