我前些年带新人看《计算机网络》教材,很多人看到“拥塞控制”这一章就卡住。教材上画的锯齿图挺漂亮,但真到排查线上故障时,很少有人能从那张图联想到自己抓包抓到的一堆重复ACK和超时重传。这篇文章我会用实际经验把拥塞控制拆开讲,包括协议栈里的经典算法、抓包时的真实表现,以及Linux上能直接用的调参手段,适合刚学完理论但想落实到自己项目里的同学,也适合被“网速忽快忽慢”折磨的运维朋友。
1. 拥塞控制到底在解决什么问题
1.1 从一次网络变慢说起
有次我在某机房做性能测试,客户端从服务器下载一个大文件,速度一会儿稳定在80MB/s,一会儿突然掉到2MB/s,然后又恢复。当时第一反应是服务器磁盘瓶颈,或者中间链路丢包。后来抓包发现一个规律:速度掉下来之前,总会出现连续三个重复ACK,然后TCP窗口突然收窄,接着一堆重传包跟出去。
这就是拥塞控制机制在“自作主张”地介入。它感觉到网络里似乎发生了拥塞,于是主动踩了刹车。问题是,这种刹车是否准确、是否值得信任,恰恰是整个主题里最有意思的部分。
很多刚接触网络的人把“拥塞控制”和“流量控制”混为一谈。流量控制是防止发送方把接收方缓冲区打爆,它的边界是接收方的接收窗口(rwnd);而拥塞控制防止的是数据把中间链路和交换设备打爆,它的边界是网络容量。接收窗口只属于端到端的两个节点,拥塞窗口则需要全网协作。TCP的发送窗口最终取两者中的较小值,这也是为什么即使接收窗口很大,一旦网络拥塞,发送速率照样上不去。
1.2 拥塞控制的本质与定位
拥塞这个词听起来很抽象,其实说透了就是:某条链路上待发送的总数据量超过了链路能承载的最大吞吐量。在计算机网络里,数据不是直接从主机A飞到主机B的,中间经过路由器,路由器有缓冲区。当短暂的突发流量涌入时,路由器先把数据放到缓冲区里缓冲;如果持续涌入,缓冲区满了,后续来的数据包就会被丢弃。
所以拥塞的表现有两个:排队时延变大,以及丢包。这两个信号在早期的TCP算法里是核心依据。经典的TCP Reno算法几乎完全依靠“丢包”来判断拥塞,而后续出现的算法开始尝试用“时延变化”来预判拥塞。这里面的逻辑差异会直接影响整个传输的锯齿形态。
拥塞控制的位置很特殊。它不在链路的硬件层,也不在应用程序层,而是放在传输层,由TCP协议栈的算法完成。原因也很实际:链路中间节点不需要知道你是文件传输还是网页请求,它只负责转发;但作为发送端,你必须主动适应网络状态,否则所有人都拼命发,最终大家都传不出去。
拥塞控制里的核心变量有三个:拥塞窗口(cwnd)、慢启动阈值(ssthresh)和往返时间(RTT)。所有算法本质上都是根据某种反馈信号,动态调整这三个变量,尤其是cwnd的大小。cwnd代表你“最多还能发出多少数据”,单位是MSS(最大段大小)。你想象一下,一个水龙头,cwnd就是允许你每秒流出多少升水的阀门,网络拥塞了,阀门就变小,网络顺畅,阀门就慢慢变大。后面所有内容都围绕这个阀门展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典TCP拥塞控制算法拆解
2.1 慢启动:确认一个可靠的起点
我一开始学慢启动最大的困惑是:为什么叫“慢”启动,但实际增长速度却是指数级的?后来才明白,它慢的是起点,而不是速度。
每次TCP连接建立后,cwnd的初始值通常很小。历史上是1个MSS,后来在很多场景下Linux初始值已放宽到10个MSS。发送方发了第一批数据,收到ACK之后,cwnd翻倍。这1个RTT内,window变为2,下一个RTT变成4,再下个变成8。这个增长速度非常吓人,所以叫“慢启动”其实是用词倒挂。
为什么要这样设计?因为刚开始你对网络的容量一无所知。如果你一开始就用一个很大的窗口,可能瞬间灌爆路由器缓冲区。从1或10个MSS起步,通过指数增长,能快速逼近网络可用带宽,但又有条不紊。这个阶段唯一关心的就是“我发出去了多少,ACK回来多少”。
慢启动的终止条件有两个:一是达到了慢启动阈值ssthresh,转入拥塞避免阶段;二是发生丢包(或检测到明显拥塞信号),此时按算法类型进入重传或者快速恢复。
这里有个细节值得注意:慢启动并不慢。它设计初衷是快速到达可用带宽附近,只是过程偏向探索。如果在长肥网络中(高带宽高延迟),纯粹的慢启动也需要很多个RTT才能“爬”到目标窗口,所以后来出现了各种优化手段,比如TCP Fast Open、启用更大的初始窗口等,但核心思想没变。
2.2 拥塞避免:在悬崖边试探
当cwnd达到ssthresh时,慢启动结束,进入拥塞避免。这时候发送方不敢再翻倍增长,改为每个RTT只增加1个MSS。这个“线性增加”的过程配合后面丢包时的“倍减”兜底,合起来叫AIMD(加法增加,乘法减小)。
为什么切换为线性增长?因为在接近网络容量时,指数增长会迅速推高队列,大概率导致缓冲区溢出。你应该像开车接近弯道一样逐渐试探,而不是轰油门冲过去。教科书里常说“加性增、乘性减”,给我们一种精确的感觉;但实际网络环境噪声很大,线性增加也免不了偶尔撞上拥塞。
拥塞避免阶段与慢启动的切换点由ssthresh决定。举个例子,假设初始ssthresh为64,MSS为1500字节,cwnd初始为10,那么前几个RTT内cwnd按20->40->80增长,当cwnd从40增长到80时,发现超过64,就停止慢启动,改从接近64的位置进入线性增长。很多实现里,超过阈值后的增长按每个RTT一个MSS计算,而不是严格的“等于阈值后再加”。
在实际服务器上,你常会看到ssthresh因之前的拥塞而不断被调小。假设当前cwnd是64,发生一次丢包,Reno算法会把ssthresh设为cwnd的一半,即32,同时cwnd降到32左右。这样接下去再次从慢启动出发,到32后转为拥塞避免。这就是“五个指头弹钢琴”的典型节奏:遇到拥塞,音量先降下来。
2.3 快速重传与快速恢复:把损失降到最小
最初TCP只有超时重传。一旦超时,发送方不仅等待时间很长,还得回到慢启动阶段慢慢爬回来,这对于丢包率较高的网络代价太大。快速重传机制的引入,使得发送方可以根据接收方的重复ACK,快速感知丢包,不等RTO超时就立即重传。
接收方为什么会发重复ACK?因为TCP是累积确认。当接收方收到乱序的包,比如序号1000到了,2000没到,然后3000到了,它没法确认2000,只能再次就自己最后连续收到的序号发送ACK,也就是重复ACK。当发送方收到三个重复ACK时,几乎可以肯定某个包丢了。
收到三个重复ACK后,发送方进入快速恢复。传统Reno的处理是:ssthresh降为当前cwnd的一半;cwnd也降为一半并暂定;重传丢失的包;然后每收到一个额外的重复ACK,cwnd临时加一个MSS,好让已经“离开”网络的包继续驱动发送,直到收到新ACK,退出恢复过程。
快速恢复的妙处在于它不回到慢启动,而是从减半后的窗口继续传输。因为三个重复ACK至少意味着网络还没有完全断了,只是丢了一个包。这种机制大幅提升了丢包后的恢复速度。NewReno相比Reno又改进了一点,在恢复过程中继续确认多个丢包而不退出,这里不多展开,你只需要知道Reno对同一个窗口内多个丢包处理得不够好,NewReno改进了这一点。
我把经典算法几条规则整理成口诀:慢启动翻倍,拥塞避免加一;超时莫慌,回起点重来;三次重复ACK,快速重传减半再恢复。这里的核心不是背诵参数,而是理解每次调整背后的“试探-反馈-调整”闭环。
3. 从Reno到BBR:算法演进路线
3.1 为什么需要不断演进
很多教材讲到Reno就结束了,好像拥塞控制就长这样。但现实远非如此。Reno有个根本假设:丢包就意味着拥塞。在早期的低速链路上,这个假设基本成立,因为路由器缓冲区不大,丢包基本等于队列满了。可后来网络变复杂,很多无线链路、卫星链路的丢包并不代表拥塞,而是信号干扰或误码。Reno看到这些“伪拥塞”会错误地降窗,白白浪费带宽。
另一个重要变化是高带宽长时延网络的出现。比如跨洲际的光纤链路,带宽几十Gbps不变,RTT却可能高达两百毫秒。Reno的拥塞避免是“每RTT增加1个MSS”,一个MSS只有1.5KB,包往返一次需要200ms,算下来每秒只能增加约7.5KB/s。想要从1Gbps的链路里追回一半带宽,理论上需要极长的时间,根本跑不满带宽。这被称为“Reno在长肥管道下的瓶颈”。
于是一代代算法被提出来:Vegas试图用RTT的变化预测拥塞,但部署阻力大;Cubic是Linux在2.6.19后默认使用的算法,引入了立方曲线,适合高带宽长距离;BBR是后来出现的拥塞控制新思路,完全跳出“丢包即拥塞”的旧框架,直接建模带宽与RTT的边界。
3.2 Cubic与BBR的工作原理
Cubic算法的名字来自三次函数。它的关键是让cwnd的增长不依赖RTT,而依赖距上次拥塞事件的时间。假设上次拥塞时窗口降到Wmax,之后窗口先快速回升,接近Wmax时放慢增长,在Wmax附近保持一个平台,如果确认没拥塞,再向上加速。这个形状很像三次曲线在阈值附近变平坦,所以叫Cubic。
这种设计的好处是:在高带宽网络中,即使RTT很大,窗口恢复速度也不再受“每个RTT一个MSS”的线性限制,可以快速追到瓶颈带宽附近,在接近时又保持谨慎。Cubic对网络场景的适应性明显优于Reno,所以在很长一段时间里是Linux默认。
而BBR则从根本上改变了反馈量。它不把丢包作为拥塞信号,而是持续测量两个量:估计带宽(BtlBw)和最小RTT。算法认为,网络最大吞吐量由瓶颈链路带宽决定,只要发送速率不超过BtlBw,同时链路中排队的数据包足够少(由最小RTT保证),就能在接近理论极限的速率下工作,而不必忍受周期性丢包和窗口减半。
BBR的工作分阶段:启动(类似慢启动但以带宽估计为依据)、排空(降低发送速率排掉多余队列)、稳定运行(保持带宽高利用,周期性探测是否带宽增加)。实际使用中,BBR在链路有少量丢包时,仍能维持高吞吐,而Reno/Cubic早就降窗了。这就是为什么很多流媒体服务和海外传输场景会主动启用BBR。
从部署角度讲,算法需要通信双方协同吗?其实拥塞控制主要由发送端执行,接收端只需要标准TCP行为。Linux服务器上想切换算法,一条命令就能完成,但这会对链路上的共存流量产生公平性影响,后面我专门讲。
表格对比一下主流算法的特点:
| 算法 | 拥塞信号 | 窗口增长模型 | 典型适用场景 |
|---|---|---|---|
| Reno | 丢包(重复ACK/超时) | 线性加、乘性减 | 低丢包、低带宽小缓冲网络 |
| NewReno | 丢包,多丢包处理更稳 | 同Reno优化 | 普通数据中心内部 |
| CUBIC | 丢包 | 三次函数增长 | 高带宽长距离、通用Internet |
| BBR | 带宽估计+最小RTT | 按BDP估测 | 高丢包、高带宽、动态链路 |
4. 手把手模拟一次拥塞控制实验
4.1 实验环境准备
你在书本上看到一堆曲线,不如亲手复现一次。我建议用Linux上的网络命名空间加tc命令来模拟一个有延迟和丢包的链路,再配合抓包工具看拥塞行为。这样不用买两台机器,一台主机就能完成。
最基本的拓扑是:两个命名空间,一个充当客户端,一个充当服务端,中间通过虚拟以太网对连接。在服务端跑一个简单的下载服务,比如用Python的HTTP服务。客户端用curl下载文件,同时在中间链路上用tc命令加上延迟和丢包。
具体准备内容如下:
- Linux系统(我常用Ubuntu 22.04,其他发行版大同小异)
- 安装了iproute2(自带tc)、python3
- 抓包工具:tcpdump或Wireshark
- 两台主机之间传输一个大小适中的文件,比如20MB,便于观察完整拥塞过程
如果只想做纯理论验证,也可以不用命名空间,直接在本地回环接口上模拟,但回环接口不经过真实协议栈的某些排队逻辑,效果不明显。命名空间隔离更干净,操作如下:
bash复制# 创建两个命名空间
ip netns add ns1
ip netns add ns2
# 创建veth对
ip link add veth1 type veth peer name veth2
# 将 veth1 放入 ns1,veth2 放入 ns2
ip link set veth1 netns ns1
ip link set veth2 netns ns2
# 为两个网卡配置IP并启用
ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1
ip netns exec ns1 ip link set veth1 up
ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2
ip netns exec ns2 ip link set veth2 up
# 在ns1中检查连通性
ip netns exec ns1 ping 10.0.0.2
这样两个命名空间就通了。接着在ns1上设置路由出口的延迟和丢包:
bash复制# 添加10ms延迟,5%丢包
ip netns exec ns1 tc qdisc add dev veth1 root netem delay 10ms loss 5%
你需要把HTTP服务跑在ns2里,然后在ns1发起下载。由于tc的netem只影响从ns1发出的包,所以丢包和延迟发生在客户端到服务器的方向,这正好模拟一条“从你的机器到远端服务器”的上行链路质量不佳。
4.2 抓包分析拥塞窗口与重传
抓包是整个实验里最直观的部分。在ns2(服务端)一边,用tcpdump抓取到网卡veth2上的所有流量,写入pcap文件:
bash复制ip netns exec ns2 tcpdump -i veth2 -w /tmp/congestion.pcap
同时在ns1启动下载:
bash复制ip netns exec ns1 curl http://10.0.0.2/yourfile.bin -o /dev/null
下载完成后,你就能在pcap文件里看到典型的拥塞控制痕迹。重点关注三类报文:数据包、ACK包、重复ACK。
具体分析步骤:
- 打开pcap文件,用Wireshark的专家信息(Expert Info)看有多少重传和乱序包。
- 通过“Statistics -> TCP Stream Graph -> Time-Sequence Graph (Stevens)”看时间-序号图。你会看到典型的锯齿形——持续上升的序号曲线,中间某个点突然回落(重传)或者停滞,然后再上升。
- 通过“Statistics -> TCP Stream Graph -> Round Trip Time Graph”看RTT的波动,通常丢包前RTT会先变大,对应队列积压。
如果你看不到明显的锯齿,可能原因:下载文件太小,还没进入完整拥塞周期就结束了;或者5%丢包率过低,触发拥塞的条件不明显。建议把文件改为50MB,丢包率提到10%,延迟再增加到50ms。这样几乎必定触发快速重传。
4.3 内核参数调节实战
除了观察,你还能直接修改Linux内核的拥塞控制算法。要查看当前可用的算法:
bash复制sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_congestion_control表示当前默认算法。在较新内核上,默认一般是cubic:
bash复制sysctl net.ipv4.tcp_congestion_control
如果想临时切换到BBR:
bash复制sysctl -w net.ipv4.tcp_congestion_control=bbr
注意并非所有内核都编译了BBR模块。如果提示参数不存在或无效,先检查你的内核是否支持BBR。Ubuntu 20.04以后的内核基本都带了bbr模块,但需要先加载:
bash复制modprobe tcp_bbr
echo net.ipv4.tcp_congestion_control=bbr >> /etc/sysctl.conf
实验里,同一拓扑下,把算法分别设为cubic和bbr,再做一次下载。对比两者的吞吐量曲线,你大概率会看到BBR在丢包率5%时保持平稳,而Cubic会出现周期性下跌。这就是算法设计差异的直接体验。
还有一些参数和拥塞控制强相关:
net.ipv4.tcp_window_scaling:是否启用窗口缩放,必须开启才能支持大cwnd。net.ipv4.tcp_sack:选择性确认,开启后允许接收方告知发送方哪些段已收到,对多丢包场景恢复帮助很大。net.ipv4.tcp_fastopen:降低握手阶段慢启动的启动延迟。
调参前建议先确认你的场景是否需要。比如一个内网低延迟、无丢包的环境,强行开BBR可能反而因为频繁探测带宽而出现抖动。调参不是越大越好,适配场景才是关键。
5. 常见问题与排查技巧实录
5.1 丢包和拥塞不能画等号
有次线上同事反馈,用户上传大文件很慢。我们查监控发现丢包率只有1%,并不高,但TCP速度始终上不去。后来在中间链路上抓包,发现一个奇怪现象:TCP发出去的包经常重传,但都是“伪装重传”——第一次传输的包其实早到了,只是ACK丢失。这种情况并不是拥塞,而是有设备对ACK进行限速或过滤。
这提醒我们:看到丢包就启动遣返回太简单了。真实网络里丢包的来源很多:噪声导致的误码、错误的链路切换、路由器CPU过载丢弃控制面报文、甚至防火墙规则误伤。如果你用的是BBR这种不依赖丢包的算法,可能没事;但如果用Reno/Cubic,误判就会导致持续降低发送速率,吞吐量大幅缩水。
排查思路应该是先抓包确认丢包的是数据包还是ACK包,丢包分布是随机还是突发。如果丢包只集中在特定方向,且RTT没有明显增长,那多半不是队列拥塞,而是链路质量问题。
5.2 缓冲区膨胀与延迟信号
另一个常见坑是缓冲区膨胀(Bufferbloat)。现代路由器的内存很大,能缓存大量数据包。算法判断拥塞靠的是丢包,但缓冲区够大时,丢包迟迟不出现,数据却已经在路由器里长篇排队。用户感知到的就是延迟越来越大,但下载速度可能还是接近带宽,因为发送端没有收到拥塞信号。
这种情况下,即使你的cwnd在增加,实际有效吞吐并没有提升,因为新数据在队列里等待前面的老数据离开。RTT会随着cwnd增长而持续拉长,这在抓包图上表现为时间-序号曲线斜率变缓,同时RTT不断上升。Cubic在这种场景下表现凑合,BBR则专门针对这个问题——它盯着最小RTT,只要队列中有排队,就通过周期性的rate和pacing策略主动减少发送速率,排空队列。
对于自建网络,解决Bufferbloat还可以在路由器上启用主动队列管理,比如最简单的RED或现在更完善的CoDel、CAKE等。它们的作用是在缓冲区快满之前,提前标记或丢弃少量数据包,给TCP算法一个明确的退避信号,避免让包无意义地卡在队列里。
如果你只想在自己的Linux端优化,可以把net.ipv4.tcp_congestion_control换成bbr,同时检查net.core.default_qdisc是否已改为fq。开启fq(公平队列)配合BBR,能有效减少延迟对拥塞信号的影响。
5.3 公平性陷阱与多流竞争
拥塞控制并不只关乎单条连接,多条连接共享同一个瓶颈链路时必须保持大致公平。经典的AIMD具有Reno在公平性上的合理性:所有连接在拥塞时同比例降低,空闲时同速率增加,总体会收敛到接近公平的分割点。
但Cubic和BBR在这个问题上都有争议。Cubic在高带宽长距离下为了提升效率,会占用更多窗口,导致与Reno竞争时可能分走更多带宽。BBR则靠探测出的BtlBw,基本不让流,一个BBR流加上一个Reno流共存时,BBR可能会占走大部分带宽。这在很多数据中心里会造成高优先级的小流被大流挤压。
实际工作中,如果同一个出口有多条业务流量,不要盲目全上BBR,尤其是对延迟敏感的业务。可以先做小规模对比测试,观察业务平均时延、重传率和吞吐量是否都能接受。
我也踩过一次:一部视频上传业务切到BBR后单流吞吐提升明显,但同一网段的其他小请求(比如监控查询)时延从5ms涨到150ms。最后只能在网关处针对视频流合理配置流量整形,或者换回Cubic加RED,才把公平性拉回来。
如果你需要评估多种算法在共享链路上的公平性,可以用两个不同命名空间同时发起下载,各自用不同算法,再统计两端带宽分配。这里不再展开,但有兴趣可以自己搭。
最后再说一个实际工作里很有用的技巧:别把拥塞控制当“唯一的救命稻草”。应用层的重试策略、发送缓冲大小、甚至TCP_NODELAY开关,都会影响链路的真实利用效率。比如小包很多时,接口默认禁用Nagle算法可能反而带来更多小包拥塞,需要综合权衡。
对我来说,拥塞控制真正有意思的点不在记忆几个算法名称,而在于你掌握了一套“如何从反馈信号中做决策”的思路。今天这篇聊到这里,如果你手边有台Linux机器,不妨照着第4章的实验自己动手跑一遍。看到那张时间-序号图里的锯齿变得有规律时,你对《计算机网络》里最抽象的章节,也就有了具体的感觉。
