
我一直觉得拥塞控制是计算机网络里最“讲道理”的机制——它不需要任何中心节点发号施令散布在各地的发送端各自猜测“网络现在到底堵不堵”居然就能把整个网络的资源用得相对公平。但真拿起教材啃的时候几乎人人都会被那套 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 和 ssthreshcwnd拥塞窗口发送端允许在未收到 ACK 前连续发出的数据量单位是 MSS最大段大小。ssthresh慢启动阈值决定当前处于“快速增长期”还是“谨慎增长期”的分界线。cwnd 小于 ssthresh 时走慢启动窗口指数增长cwnd 大于等于 ssthresh 时走拥塞避免窗口近似线性增长。ssthresh 不是写死的每次检测到丢包都会动态调整通常被砍到丢包前 cwnd 的一半。我建议你把 ssthresh 理解成“安全水位线”船在这条线以下你放心加速过了这条线你就要收着踩油门因为已经接近你认为的安全上限了。3.2 慢启动为什么用“指数”而不用“直线”慢启动这个名字有点误导它其实增长得飞快。初始 cwnd 通常是 1 个 MSS有些实现已经是 10 个 MSS每收到一个 ACKcwnd 增加 1 个 MSS。因为 ACK 是成批回来的实际效果是每过一个 RTTcwnd 翻倍1、2、4、8、16……为什么要这么激进因为发送端不知道网络的可用带宽是多少只能快速试探。就像一个从没去过新餐厅的人不知道菜量大小先点一份试试发现端上来很快就一次翻倍加菜直到发现服务员有点忙不过来了再收敛。如果一开始就慢慢加高带宽网络的利用率会非常低。3.3 拥塞避免的“加法”和丢包后的“乘法”当 cwnd 触到 ssthresh进入拥塞避免阶段增长节奏变成“每个 RTT 只加 1 个 MSS”。在实现层面就是每收到 ACKcwnd 增加 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 MSSssthresh 16 MSS。整个过程如下慢启动阶段cwnd 按 1、2、4、8、16 增长。到 16 时触到 ssthresh不再翻倍。进入拥塞避免每个 RTT 只加 1cwnd 变成 17、18、19、20……此时每发一批数据链路开始接近瓶颈。某个时刻第 21 个包丢了发送端收到 3 个重复 ACK。快速重传 快速恢复ssthresh 更新为 1020 的一半cwnd 设为 1310 3。快速恢复期间每收到一个重复 ACKcwnd 临时加 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_controlbbr # 永久生效需要写入 /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 和 ssthreshss -tin输出里你会看到类似 cubic 1 1 的高频字段后面的数字分别是拥塞窗口、慢启动阈值等。实验时开两个终端一边持续下载一边盯着 cwnd 的变化你就会直观看到“涨到某个值后突然掉一半”的锯齿。这是把书里的波形图变成现实里能摸到的数据的最快路径我自己当年看了一次就再也忘不掉了。6.3 对比不同算法的实战结果在相同丢包率下分别跑 cubic 和 bbr然后用 iperf3 测吞吐iperf3 -c 127.0.0.1 -t 30注意 lo 接口是本地回环实测数值会虚高但它足以让你对比算法之间的相对差异。更高可信度的做法是用两台真实设备或虚拟机隔一段模拟链路。没有条件时单机对比也够验证“不同算法在损耗下的敏感度不同”这个核心结论。6.4 网络慢的时候怎么判断是不是拥塞控制在“捣乱”日常排障时如果你怀疑是拥塞控制导致的降速可以按这个顺序快速判断先看 RTT 和丢包率ping 目标主机延迟高或丢包明显大概率是链路本身有问题。再看 cwndss -tin 里如果 cwnd 频繁掉到一半说明正在经历丢包回退。最后换算法试临时切到 bbr 观察吞吐变化。如果换算法后明显改善说明原来的算法在当前链路上不匹配可以长期切换。这套排查逻辑我实际用过好几次比上来就重启路由器靠谱得多。最后再分享一个学习上的小技巧看状态机图别死记箭头拿一页纸自己画坐标轴假装你是 TCP 发送端手里只有一个 cwnd 计数器每收到一个 ACK 就更新一下画完慢启动再画拥塞避免再画一次丢包恢复。画完三张图后那些曾经拗口的名字就都长在你手里了。