我一直觉得,拥塞控制是计算机网络里最“讲道理”的机制——它不需要任何中心节点发号施令,散布在各地的发送端各自猜测“网络现在到底堵不堵”,居然就能把整个网络的资源用得相对公平。但真拿起教材啃的时候,几乎人人都会被那套 cwnd、ssthresh、慢启动、快速恢复的状态机绕晕。我当年也一样,直到有次家里宽带莫名卡顿,我抓包下来看着 TCP 窗口的锯齿形曲线,才把这一堆概念真正串起来。
这篇文章准备写得贴地一点,把拥塞控制从“它到底在解决什么问题”到“TCP 靠哪些信号判断拥塞”再到“状态机每一步为什么要这么走”整条链路都过一遍,最后附上几个我自己实测过的观察方法和调参实验。适合正在啃计算机网络教材的、被面试问过拥塞控制的、或者单纯想知道“为什么我的网速忽快忽慢”的人。
1. 先捅破窗户纸:流量控制和拥塞控制根本不是一回事
很多人把这两个概念混在一起,因为教材里它们经常挨着出现,都跟窗口有关。我建议先分清这件事:流量控制管的是“接收方吃不吃得下”,拥塞控制管的是“中间网络送不送得动”。
1.1 一个类比:停车场和高速收费站
流量控制就像目的地停车场的管理员告诉你“我这会儿只剩两个车位,你慢慢来”。这个信息是接收方直接发给发送方的,对应 TCP 里接收窗口(rwnd)。
拥塞控制则是你开车上路后发现“高速上堵成一锅粥”,你得自己判断还要不要继续踩油门。没有哪个设施会专门给你发通知说“前方拥堵”,你只能通过“速度上不去、走两步停一步”来感知,然后主动减速,避免把整条路彻底堵死。对应 TCP 里发送方自己维护的拥塞窗口(cwnd)。
真实的发送窗口取两者较小值:
- 实际可用窗口 = min(cwnd, rwnd)
也就是说,就算接收方喊“我内存够大你随便发”,只要网络链路堵了,发送方也得老实降速。反之,就算网络畅通,接收方消化不了,你也得停。
1.2 为什么拥塞控制必须“端到端”解决
这是当年设计 TCP 时很有争议的一个点。你可能会想:能不能让路由器主动参与,告诉发送端“我这里忙,你慢点”?理论上可以,事实上路由器也确实会丢包、会标记 ECN,但拥塞控制的最终决策必须落在端主机上。
原因很简单:网络中间节点数量太多、厂商太多、链路类型太杂。一个路由器只能看到自己的局部排队情况,它不知道上游哪儿堵、下游哪儿空。更重要的是,如果拥塞控制依赖每个路由器都“懂规矩”,只要有某个老旧设备不支持,整套机制就失效了。端到端原则在这里的体现就是:把最复杂的自适应逻辑放在最容易升级的终端上,让中间网络尽量保持简单。
明白了这个边界,后面看状态机就不会晕——你始终在替发送端问同一个问题:我现在发出去的包,是顺畅到达了,还是把网络挤得更堵了?
2. 网络给 TCP 的“体检报告”:丢包、延迟、ECN 三种信号怎么解读
发送端是瞎子,它看不见网络的真实状态,只能通过某些间接指标来推断。传统教材主要讲“丢包=拥塞”,但现代拥塞控制算法早就不只盯着丢包了。
2.1 丢包:最可靠的拥塞信号,但也是最滞后的信号
路由器内部有缓存队列。当瞬间流量超过链路带宽,队列堆积满以后,新来的包无处可放,只能被扔掉。发送端长时间收不到确认(ACK),最终触发超时重传,或者收到连续重复 ACK,就知道“网上出事了”。
丢包作为信号有个大问题:它告诉你拥塞已经发生且已经造成了损害。就像你看到血压计爆表时才意识到身体出问题,这时候血管可能已经伤了。所以经典 TCP 对丢包的反应非常激烈——直接腰斩窗口,甚至从头再来。
2.2 延迟:拥塞来临前的“天气预报”
一个链路从空闲到拥塞,其实有个渐变过程。网络设备里的队列开始排队时,丢包还没发生,但每个包在队列里的等待时间已经在变长。这个变化反映到端到端,就是 RTT(往返时间)上升。
用大白话说,延迟上升相当于“路上车变多了,但还在缓慢挪动”;丢包则相当于“彻底堵死,后面的车开始掉头折返”。所以延迟信号比丢包更早、更平滑。现代算法里 BBR 就把最小 RTT 当作网络状态的重要指标。
2.3 ECN:让路由器开口说话
端到端原则不等于中间节点完全哑巴。ECN(显式拥塞通知)就是路由器主动发声的一套机制。路由器检测到队列快满时,不需要丢包,而是在 IP 头里打上拥塞标记(CE)。接收方看到这个标记,会在 ACK 里反馈给发送方,发送方据此降速。
这套机制的好处是避免了丢包重传的额外开销,属于“温柔的提醒”。但实际部署中,很多家用路由器、老设备并不会正确标记,所以 ECN 更多是锦上添花。我实测下来,在可控的实验室环境里 ECN 能明显降低重传率,但跨公网传输时效果就不稳定了。
这三类信号也不是互相排斥的。经典 TCP 主要用丢包,CUBIC 也是丢包驱动,BBR 则综合使用带宽采样和延迟测量。后面讲现代算法时你会看到,选哪个信号基本决定了算法的性格。
3. cwnd 和 ssthresh 一把梭:从慢启动到快速恢复的完整推演
状态机是拥塞控制最劝退的部分。我当初学的时候死记“慢启动、拥塞避免、快速重传、快速恢复”四个名字,一到考试画图就乱。后来发现,只要抓住 cwnd 和 ssthresh 这两个变量,再想清楚“收到什么事件、怎么改”,整张图就活了。
3.1 两个核心变量:cwnd 和 ssthresh
- cwnd(拥塞窗口):发送端允许在未收到 ACK 前连续发出的数据量,单位是 MSS(最大段大小)。
- ssthresh(慢启动阈值):决定当前处于“快速增长期”还是“谨慎增长期”的分界线。
cwnd 小于 ssthresh 时,走慢启动,窗口指数增长;cwnd 大于等于 ssthresh 时,走拥塞避免,窗口近似线性增长。ssthresh 不是写死的,每次检测到丢包都会动态调整,通常被砍到丢包前 cwnd 的一半。
我建议你把 ssthresh 理解成“安全水位线”:船在这条线以下,你放心加速;过了这条线,你就要收着踩油门,因为已经接近你认为的安全上限了。
3.2 慢启动为什么用“指数”而不用“直线”
慢启动这个名字有点误导,它其实增长得飞快。初始 cwnd 通常是 1 个 MSS(有些实现已经是 10 个 MSS),每收到一个 ACK,cwnd 增加 1 个 MSS。因为 ACK 是成批回来的,实际效果是每过一个 RTT,cwnd 翻倍:1、2、4、8、16……
为什么要这么激进?因为发送端不知道网络的可用带宽是多少,只能快速试探。就像一个从没去过新餐厅的人,不知道菜量大小,先点一份试试,发现端上来很快,就一次翻倍加菜,直到发现服务员有点忙不过来了再收敛。如果一开始就慢慢加,高带宽网络的利用率会非常低。
3.3 拥塞避免的“加法”和丢包后的“乘法”
当 cwnd 触到 ssthresh,进入拥塞避免阶段,增长节奏变成“每个 RTT 只加 1 个 MSS”。在实现层面就是每收到 ACK,cwnd 增加 1/cwnd。这样窗口曲线从陡峭的指数换成平缓的线性,慢慢试探。
一旦发生丢包,TCP 的“乘法减小”立刻生效:
- ssthresh = cwnd / 2
- 早期版本(Tahoe)会把 cwnd 直接重置为 1 MSS,重新慢启动
- 改进版本(Reno)进入快速恢复,cwnd 降到 ssthresh + 3 MSS
“乘性减”保证了多流竞争时窗口能快速回缩,腾出空间给其他人。加上拥塞避免的“加性增”,两者合起来就是著名的 AIMD(加性增、乘性减)策略,这也是 TCP 公平性的根基。
3.4 为什么快速重传要等“3 个重复 ACK”
发送端怎么判断一个包丢了?最简单的是等超时,但超时时间通常不小,等它太浪费吞吐。更好的办法是接收端每收到乱序包就重发对缺失包的 ACK。发送端连续收到 3 个重复 ACK,就认为那个包大概率丢了。
为什么是 3 而不是 2?因为网络存在乱序。如果一个包只是绕了点路晚到,接收端可能只触发一次重复 ACK;连续 3 次重复 ACK 意味着后面已经有 3 个包先到了,丢失概率极高。用 3 这个数字是为了在“尽早重传”和“容忍乱序”之间找平衡。
注意:快速重传只解决“单个包丢了”的情况。如果发生大量丢包,超时仍会发生,此时 TCP 会把 cwnd 重置回起点,这也解释了为什么丢包率稍高的网络,TCP 性能会断崖式下跌。
3.5 一个完整推演例子
假设初始 cwnd = 1 MSS,ssthresh = 16 MSS。整个过程如下:
- 慢启动阶段:cwnd 按 1、2、4、8、16 增长。到 16 时触到 ssthresh,不再翻倍。
- 进入拥塞避免:每个 RTT 只加 1,cwnd 变成 17、18、19、20……此时每发一批数据,链路开始接近瓶颈。
- 某个时刻,第 21 个包丢了,发送端收到 3 个重复 ACK。
- 快速重传 + 快速恢复:ssthresh 更新为 10(20 的一半),cwnd 设为 13(10 + 3)。
- 快速恢复期间每收到一个重复 ACK,cwnd 临时加 1(相当于补偿网络中已被接收的数据占用的窗口);收到新 ACK 后退出恢复,cwnd 降到 ssthresh 即 10。
- 之后按拥塞避免继续线性增长,等待下一次丢包。
把这个过程画成横轴时间、纵轴 cwnd 的图,就是教材里那个经典的锯齿波。再看一遍流程你就会发现,TCP 的哲学就是“大胆试探,快速退缩”,所有机制都围绕这个核心。
4. 从 AIMD 到吞吐量极限:经典拥塞控制的高原期
理解了单一 TCP 连接的行为,下一步就该问:如果几百条连接挤在同一个瓶颈链路上,会发生什么?这涉及公平性和效率两个问题。
4.1 公平性为什么能收敛:AIMD 的几何直觉
假设两条 TCP 连接共用一条链路,横轴是连接 A 的窗口,纵轴是连接 B 的窗口。所有“恰好占满链路”的窗口组合是一条斜线,斜线以下链路空闲,斜线以上必然拥塞丢包。
- 加性增:每条连接窗口同时“向右上 45 度平移”,大家同比例加 1,不会拉开差距。
- 乘性减:发生丢包时,每条连接窗口各自减半,也就是向原点方向收缩一半。
两条线交替进行,最终会在公平线附近震荡,而不是某一方独占。这就是 AIMD 被写进教科书的原因——它不需要中央调度,只靠每个端点的自私行为,就能达到某种程度的集体公平。
4.2 高带宽长延迟网络的“瓶径”:窗口必须足够大才跑得快
经典拥塞控制有个先天矛盾:它要靠在网络上制造丢包来判断“现在到极限了没有”。链路越是高带宽、低丢包,它就越难发现极限在哪,只能慢吞吞地线性上涨。而一旦发生一次随机丢包(注意,不是拥塞导致的丢包),窗口腰斩,吞吐直接掉一半。
衡量这种场景有个经典的近似公式:
- 吞吐 ≈ 1.22 × MSS / (RTT × √p)
p 是丢包率,RTT 是往返时间。表达式里 1/√p 是最要命的项。假设 MSS 是 1460 字节,RTT 是 100ms,丢包率只有 1%(0.01),那吞吐只有约 1.22 × 1460 / (0.1 × 0.1) ≈ 178 KB/s,这个数值放在千兆链路上简直就是浪费。
这说明经典 TCP 的脸面丢在了“长肥管道”(高带宽、大延迟)上。只要 RTT 大一点、丢包率稍微涨一点,性能就惨不忍睹。于是就有了后来一系列针对这个公式的解药。
4.3 为什么 TCP 三次握手后第一波数据那么慢
另一个常被忽略的衍生问题:新连接的 cwnd 从 1 个 MSS 起步,即使靠慢启动翻倍,跨大西洋高延迟链路时前几个 RTT 都在“热身”,根本吃不满带宽。现代协议栈已经默认初始窗口设为 10 个 MSS,但相比动辄几百 MB 的瞬间拥塞窗口还是小巫见大巫。
所以很多 Web 优化手段,比如 HTTP/2 多路复用、连接池复用、TCP Fast Open,本质上都在绕开“慢启动重新开始”的代价。明白了这一点,你就明白为什么前端性能优化专家总说“别老开新连接”。
5. CUBIC 和 BBR 的思路差在哪:当丢包不再是唯一信号
如果你现在去服务器上敲 sysctl net.ipv4.tcp_congestion_control,大概率看到的是 cubic。这是 Linux 也默认启用的算法。但最近几年 BBR 风头很盛,很多网络调优文章都推荐切过去。两者解决的是不同时代的问题。
5.1 CUBIC:对高带宽环境特调的三次曲线
CUBIC 不再像 Reno 那样只做线性增长,而是引入了一个三次函数:丢包发生后,窗口先快速恢复到一个接近丢包前的水平,然后进入缓慢的“平台期”,再继续试探性增长。因为三次函数有一段“凹到凸”的转换,CUBIC 在长距离高带宽链路下能更快抢占带宽,同时保持较好的 RTT 公平性。
CUBIC 并没有脱离“丢包即拥塞”的框架,它只是把这个反应曲线调整得更适合现代网络。它的优点是和现有网络兼容性好,不需要中间设备配合,部署成本极低。缺点也明显:当瓶颈链路缓存很深时,它会把队列填满才停止增长,造成延迟飙升,也就是大家常说的 bufferbloat(缓冲膨胀)。
5.2 BBR:直接测量带宽和延迟,不再等丢包
BBR 的思路是整个范式层面的转变。它不再盯着丢包率,而是持续测量两个参数:
- 瓶颈带宽 BtlBw:链路上实际能达到的最大传输速率
- 最小往返时间 RTprop:链路空载时的传输延迟
BBR 的发送速率约等于“瓶颈带宽 × 一个增益系数”。它会周期性抬高增益系数去探测更多可用带宽,发现没有改善就退回来。因为不会刻意把路由器队列灌满,端到端延迟能维持在很低水平。
我自己的测试中,在某条模拟高延迟高丢包的链路上,CUBIC 的下载吞吐大约 12 Mbps 且延迟抖得厉害,切到 BBR 后同样条件下吞吐到 60 Mbps 以上,延迟曲线平直很多。代价是 BBR 在无线网络等带宽频繁变化的环境里,可能出现短暂的不收敛。
5.3 实际切换命令与适用场景
Linux 下切换算法很简单:
# 查看当前算法 sysctl net.ipv4.tcp_congestion_control # 临时切换为 bbr sudo sysctl -w net.ipv4.tcp_congestion_control=bbr # 永久生效需要写入 /etc/sysctl.conf 或 /etc/sysctl.d/ 下的配置适合用 BBR 的场景:长距离跨机房传输、视频流推送、重负载下载服务。适合用 CUBIC 的场景:公网混流、运营商网络质量参差不齐的常规 Web 服务,以及你不想折腾任何内核参数的生产环境。
6. 动手观战:用模拟延迟和抓包看懂 cwnd 的一举一动
光看教材始终隔了一层,我强烈建议你把 Linux 当作一个“活体实验台”,亲手把拥塞控制行为逼出来。下面是我验证过的一套低成本方案。
6.1 用 netem 制造可控的“残废网络”
Linux 自带的 tc 加上 netem 模块,可以在本机模拟延迟、丢包、乱序。比如我经常拿回环接口 lo 做实验,避免影响真实上网:
# 给 lo 增加 50ms 延迟和 1% 丢包 sudo tc qdisc add dev lo root netem delay 50ms 1% loss # 实验结束恢复 sudo tc qdisc del dev lo root延迟影响 RTT,丢包影响 cwnd 的锯齿形状。把这俩叠加后,再用 scp 或 wget 下载一个大文件,网络层的感觉会和平时差别很大。
6.2 用 ss 实时查看窗口变化
系统自带的 ss 命令能直接输出每条 TCP 连接的 cwnd 和 ssthresh:
ss -tin输出里你会看到类似 cubic 1 1 的高频字段,后面的数字分别是拥塞窗口、慢启动阈值等。实验时开两个终端,一边持续下载,一边盯着 cwnd 的变化,你就会直观看到“涨到某个值后突然掉一半”的锯齿。这是把书里的波形图变成现实里能摸到的数据的最快路径,我自己当年看了一次就再也忘不掉了。
6.3 对比不同算法的实战结果
在相同丢包率下分别跑 cubic 和 bbr,然后用 iperf3 测吞吐:
iperf3 -c 127.0.0.1 -t 30注意 lo 接口是本地回环,实测数值会虚高,但它足以让你对比算法之间的相对差异。更高可信度的做法是用两台真实设备或虚拟机隔一段模拟链路。没有条件时,单机对比也够验证“不同算法在损耗下的敏感度不同”这个核心结论。
6.4 网络慢的时候,怎么判断是不是拥塞控制在“捣乱”
日常排障时,如果你怀疑是拥塞控制导致的降速,可以按这个顺序快速判断:
- 先看 RTT 和丢包率:ping 目标主机,延迟高或丢包明显,大概率是链路本身有问题。
- 再看 cwnd:ss -tin 里如果 cwnd 频繁掉到一半,说明正在经历丢包回退。
- 最后换算法试:临时切到 bbr 观察吞吐变化。如果换算法后明显改善,说明原来的算法在当前链路上不匹配,可以长期切换。
这套排查逻辑我实际用过好几次,比上来就重启路由器靠谱得多。
最后再分享一个学习上的小技巧:看状态机图别死记箭头,拿一页纸自己画坐标轴,假装你是 TCP 发送端,手里只有一个 cwnd 计数器,每收到一个 ACK 就更新一下,画完慢启动再画拥塞避免,再画一次丢包恢复。画完三张图后,那些曾经拗口的名字就都长在你手里了。