TCP的拥塞控制步骤,一次讲透
做网络四年,面试过不少候选人,也排查过无数“网速突然垮掉”的诡异现场。TCP拥塞控制是网络传输的节流阀,决定了你的吞吐上限和稳定性。今天不聊理论课本,讲讲拥塞控制到底怎么一步步起作用,以及你在Linux上调参、抓包时怎么判断它真的在工作。
不知道你有没有遇到过这种场景:内网拷个几百MB的文件,前几秒速度蹭蹭往上冲,然后突然断崖式下跌,接着又慢慢回血。又或者公司专线明明带宽有100Mbps,实际传输速率死活卡在10Mbps上下,怎么调都上不去。这种“间歇性变慢”的诡异问题,十有八九和TCP拥塞控制脱不开干系。
这篇文章适合三类人看:一是被网络性能问题折磨的运维和开发,二是准备面试的工程师,三是纯好奇“TCP为什么能自适应网速”的技术爱好者。我会把拥塞控制的完整执行链路、状态切换细节、以及Linux下的观测手段一次性讲透。看完你至少能做到:遇到传输慢的问题,能判断是拥塞控制机制在起作用,还是真的网络链路有问题。
1. 先弄明白拥塞窗口和接收窗口的区别
很多人一上手就把滑动窗口和拥塞控制搞混,这俩确实是TCP里最难缠的两个机制,但它们解决的问题完全不同。
1.1 滑动窗口:管的是接收方的胃口
滑动窗口,术语叫接收窗口(rwnd),它在TCP头部的Window字段中携带,由接收方告诉发送方:“你最多一次给我发这么多数据,再多我缓冲区装不下。”
打个比方,接收方像一个饭店后厨,一次只能处理10个订单。客人一直往里递菜谱(数据包),后厨忙不过来就会把菜谱堆在门口(缓冲区)。如果门外都堆满了,就只能让新来的菜谱先在外面等着,甚至直接拒收。滑动窗口就是这个“最多能塞多少菜谱”的上限。
1.2 拥塞窗口:管的是网络的吞吐能力
拥塞窗口(cwnd)则是发送方自己估算的一个值,用来判断“当前网络能同时承载多少数据”而不造成拥塞。它不在TCP头部里传输,只存在于发送方的控制系统内部。
这个cwnd是动态变化的,而且遵循一个核心原则:只要网络没出问题,就大胆多发;一旦发现网络出了问题(比如丢包),就立即收敛。
实际可发送的数据量,取的是min(rwnd, cwnd)。也就是说,哪怕接收方说“我胃口很大,你一次可以发1MB”,只要你的拥塞窗口只有10KB,你也只能发10KB。反过来也一样。
这就解释了为什么很多场景里,服务器配置没问题、带宽也够,但传输速度就是上不去——瓶颈可能压根不在接收方,而在发送方的拥塞窗口算法上。
1.3 为什么TCP“天生”需要拥塞控制
TCP比UDP复杂,核心原因就是它要为“公平性和稳定性”买单。设想一下:如果没有拥塞控制,所有主机都用最大速率猛发数据,网络中间路由器一旦排队溢出,就会一次性丢几千个包。这些包同时触发各主机重传,又造成更大的拥塞,最终整个网络崩溃。这在1990年代真实发生过,被称为“拥塞崩溃”(congestion collapse)。
所以TCP必须建立一套逐步探测、遇拥塞退让、再重新试探的规则。这就是拥塞控制存在的意义,也是整个互联网能跑得动的前提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拥塞控制四件套:慢启动、拥塞避免、快重传、快恢复
现在进入正题。TCP拥塞控制完整状态下由四个步骤协同工作。先记住四个状态关键词:cwnd(拥塞窗口)、ssthresh(慢启动阈值)、RTT(往返时延)、重传类型。
2.1 慢启动:从1开始指数向上探路
概念上经常说“慢启动”很慢,但实际它的探测速度是指数级增长,一点不慢。流程是这样的:
- 连接建立后,cwnd初始值通常是10个MSS(MSS是最大报文段长度,不同内核版本默认值有差异,Linux下一般是10)。
- 每收到一个ACK,cwnd就增加1个MSS。
- 也就是说,一顿往返(一个RTT)内,如果所有报文段都被确认,cwnd就翻一倍。
- 这个过程持续到cwnd达到ssthresh为止。初期的ssthresh通常是初始窗口的两倍,也可能由系统配置指定。
举个具体数字:假设MSS是1460字节,cwnd从10开始。
- 第1个RTT后:cwnd=20
- 第2个RTT后:cwnd=40
- 第3个RTT后:cwnd=80
只用6个RTT左右,cwnd就从10膨胀到1024左右。按一个RTT=20ms算,从建连到塞满10Mbps链路,只需要120ms左右。
这其实说明了一件事:慢启动的“慢”不是指速度慢,而是指“用线性积累换取指数探测”。它的目的是在不预先知道网络带宽的情况下,快速找到可用带宽的“天花板”。
2.2 拥塞避免:到达阈值后转成线性爬坡
当cwnd到达ssthresh后,慢启动的使命结束,进入拥塞避免阶段。这个阶段cwnd不再指数增长,而是改为每个RTT增加1个MSS。
还是上面那个例子,ssthresh是640,cwnd达到640后,接下来每个RTT只增加1个MSS(1460字节)。这会带来一个直观感受:快到极限附近时,网络吞吐增速肉眼可见地放缓。
设计成这样逻辑很简单:指数增长是“探险”,线性增长是“精耕”。在接近链路容量的区域,每一步探测都必须慢一点、稳一点,否则很容易突然灌满路由器缓冲区。
2.3 快重传:用3个重复ACK判定“丢包了”
TCP怎么知道网络拥塞了?最主要的信号就是丢包。但TCP是可靠传输协议,有确认机制,接收方收到乱序数据时,不会发NACK,而是重复发送同一个ACK(表示“我还在期望这个序号”)。
发送方如果连续收到3个相同的ACK,就判定“中间果然丢了包”,这时候不必等超时重传计时器(RTO)到期,直接重传丢失的数据段。
这里有个关键问题:为什么是3个重复ACK,而不是1个或2个?
因为数据包在传输过程中,偶尔的乱序也可能产生重复ACK。比如包1到达,包2、包3、包4因为网络抖动走错了路(但没丢),接收方会针对包1发4次ACK。这是正常的,不代表丢包。用3这个阈值,是为了在“误判”和“延迟容忍度”之间取平衡——经验表明,连续3个重复ACK基本可以排除随机乱序的干扰。
2.4 快恢复:丢包后把窗口砍半但不下到零
进入快重传后,TCP进入快恢复(Fast Recovery)状态。具体动作是:
- ssthresh被更新为当前cwnd的一半(cwnd / 2)。
- cwnd随之减半(注意,这里不是直接降到1)。
- 在有选择确认(SACK)支持时,可以只重传丢失的包,而不是全部重传。
- 之后进入拥塞避免阶段,继续线性增长。
这比从1重新慢启动要高效得多。为什么?
因为收到3个重复ACK本身,说明网络还能正常把数据送回来(有数据在流动)。如果网络完全瘫痪,你会等到超时。既然有数据在流动,就没必要把cwnd打回原形,砍半已经能显著减轻网络压力,同时保留一定的吞吐能力。
2.5 超时重传:最凶险的“掉线”信号
另一种丢包是超时重传:发送方在RTO计时器到期后仍没收到任何ACK,此时不能断定“只是轻微拥塞”,只能认为是“网络严重拥塞或链路断开”。
这种情况下,TCP采取最保守策略:
- ssthresh被置为当前拥塞窗口的一半。
- cwnd直接被重置为初始值(1个MSS或10个MSS,取决于实现)。
- 重新进入慢启动,从头开始探测。
你可能会问:为什么超时了要把cwnd降到这么低?因为RTO的超时意味着数据发出去了,但ACK迟迟不回来,中间路由器可能已经严重拥塞,所有队列都满了。此时如果继续发大量数据,只会加剧拥塞。宁可重头探索,也不能赌网络能撑住。
这里有一个实操心得:当你用tcpdump抓包看到大段重传、而且RTO间隔越来越长(1s、2s、4s翻倍),基本可以断定链路真的出问题了——不是靠拥塞控制算法能解决的,要考虑物理链路、MTU、防火墙策略。
3. 完整状态切换:一次丢包全流程推演
只看定义容易懵,我实际推演一遍,把数字和状态写出来,你会理解得更扎实。
3.1 场景假设
假设发送方到接收方之间的瓶颈带宽是2Mbps,RTT是100ms,MSS是1460字节,初始ssthresh=20(为了演示取小值)。传输一个大文件。
3.2 慢启动阶段
- cwnd=10,第一轮发出10个包,全部ACK回来。
- cwnd=20,达到ssthresh,触发进入拥塞避免。
- 以上过程中,cwnd从10到20,只花了1个RTT,指数增长立竿见影。
3.3 拥塞避免阶段
- cwnd=20,线性增长。
- 每过1个RTT,cwnd加1。20, 21, 22, 23……渐渐逼近带宽天花板。
- 带宽延迟积(BDP)算一下:2Mbps×100ms=25000字节,除以MSS 1460,约17个MSS。但cwnd已经20多个MSS,发送速率已经超出链路承载,此时中间路由器开始排队。
3.4 丢包检测与快重传快恢复
- 排队到极限后,某个包被路由器丢弃。
- 接收方收到乱序数据,连续发3个重复ACK。
- 发送方收到3个重复ACK,立即重传丢掉的包。
- ssthresh=cwnd/2=14左右。
- cwnd从28降到14。
- 进入拥塞避免,从14开始线性增长。
3.5 如果发生超时重传
- 如果出现更严重的拥塞,某个包的ACK在RTO内都没回来。
- ssthresh=cwnd/2,cwnd=1(或10)。
- 重启慢启动,重新探测。
整个过程用逻辑图表达就是:
正常发送 → 累积ACK / 重复ACK → 判断网络状态 → 丢包则进入快恢复/重传 → 更新ssthresh和cwnd → 回到拥塞避免或慢启动 → 继续探测。
3.6 两个实测中的“异常”提醒
第一,很多人以为“收到3个重复ACK后一定会进快恢复”,其实不一定。如果TCP连接不支持SACK,或者重复ACK数量没达到3个(比如只有2个),发送方就必须继续等,直到重传计时器到期。在丢包严重的链路上,这会表现为传输速度持续低迷。
第二,如果网络中存在大量乱序包(比如多路径、无线链路漫游),重复ACK会频繁出现,但这不是真正的丢包。部分TCP实现会把“乱序”也误判为“拥塞”,导致cwnd被频繁减半,吞吐始终上不来。这是无线网络下TCP性能差的一个重要原因。
4. 主流拥塞控制算法选型:Reno、CUBIC与BBR
拥塞控制的底层逻辑讲完了,但不同算法在步骤细节上有很大差异。你在 /proc/sys/net/ipv4/tcp_congestion_control 里看到的算法,就是决定这些步骤怎么执行的实际策略。
4.1 各算法对比
| 算法 | 核心策略 | 适用场景 | 不足 |
|---|---|---|---|
| Reno | 快重传后cwnd减半。经典但陈旧 | 低带宽低延时稳定链路,现代网络效率太低 | 高带宽链路下窗口恢复慢 |
| NewReno | 改进Reno的快速恢复,可连续恢复多个丢包 | 部分兼容性强的Linux发行版仍有使用 | 丢包率高时性能依旧一般 |
| CUBIC | 窗口增长呈三次函数,适应性极强,Linux默认算法 | 高带宽、大RTT场景,公网传输主流 | 遭遇随机丢包时会误判拥塞 |
| BBR | 通过测量带宽延迟积(BDP)直接建模链路,不靠丢包信号 | 高丢包、大RTT、高带宽链路 | CPU占用略高,兼容性需验证 |
4.2 CUBIC为什么成为Linux默认
CUBIC是Linux 2.6.19之后默认的拥塞控制算法,它的核心改进在于:把窗口增长从线性/对数模式改成三次函数模式,使得窗口在拥塞恢复初期快速增长,在接近瓶颈前平稳过渡。
实际体验是:在公网高带宽链路上,CUBIC比Reno的吞吐高很多。Reno的线性增长在100Mbps以上链路里,恢复窗口慢如蜗牛。而CUBIC能在几秒内把窗口恢复到位。
4.3 BBR的真实表现
BBR是Google提出的算法,理念和前两者完全不同:不再靠丢包判断拥塞,而是通过实时测量RTT和带宽来建模当前链路,然后维持稳定的发送速率。
我实测过在AWS EC2间用BBR传输大文件,高峰期丢包率约2%~3%的链路上,CUBIC的吞吐会掉到带宽的40%,BBR却能保持带宽的90%以上。这背后的原因就是CUBIC把“丢包”一律当成拥塞信号,而BBR知道“丢包可能只是链路噪声,不代表链路满了”。
注意,BBR不是万能的。在低丢包、本低RTT的内网环境,BBR相对于CUBIC没有显著优势;在无线网络里,由于RTT波动剧烈,BBR有时会高估带宽,造成队列堆积。选型一定要结合场景。
4.4 如何在Linux上切换算法
code复制# 查看当前算法
sysctl net.ipv4.tcp_congestion_control
# 临时切换
sysctl -w net.ipv4.tcp_congestion_control=bbr
# 永久生效(写入/etc/sysctl.conf)
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf
sysctl -p
需要提醒的是,切换算法后,已建立的TCP连接不受影响,只对新连接生效。测试时记得开新连接。
另外内核需要支持所需算法,BBR需要Linux 4.9以上内核,CUBIC基本在任意现代内核里都有。
5. 实际项目排查:如何用命令看拥塞控制是否在起作用
原理和算法都过了一遍,剩下的问题很现实:怎么知道我的TCP连接正处于慢启动、拥塞避免还是快恢复? 难道只能黑盒猜?
5.1 用ss命令观察cwnd变化
Linux的ss命令可以展示当前TCP连接的部分连接状态:
code复制ss -s
ss -tin
-tin参数会展示RTT、cwnd、发送队列等关键参数。你可以在传输大文件时持续观察:
- 如果cwnd在一路翻倍增长,说明处于慢启动。
- 如果cwnd每个RTT只+1,说明进入拥塞避免。
- 如果cwnd突然减半,说明刚发生过快重传/恢复。
一个真实案例:我在调试一个文件传输工具时,发现传输速度始终上不去。用ss -tin盯了几十秒,发现cwnd一直在4~6之间徘徊,且伴随频繁的重复ACK。进一步抓包发现是无线网卡的驱动丢包严重,TCP一发现丢包就砍cwnd,最终吞吐被死死压住。
5.2 用tcpdump抓包观察重复ACK与重传
code复制tcpdump -i eth0 -nn 'tcp port 5001 and (tcp[tcpflags] & (tcp-syn|tcp-ack|tcp-rst) != 0)'
看重复ACK时,重点观察相同序号的ACK是否连续出现三次以上。看重传时,重点观察同一序列号的TCP包是否在短时间内再次出现。
5.3 判断“问题在拥塞控制还是真链路故障”
说个最典型的场景:热词里有人问“为什么内网拷文件速度忽快忽慢”,还有问“Modbus TCP能ping通但Modscan连不上”。这两类问题有个共性——第一步不是查应用层,而是要先判断链路层是否有丢包。
实际操作步骤:
- 用
ping -f做连续快速探测,看丢包率。 - 用
ss -tin观察cwnd波动频率。 - 用
iperf3做带宽灌包测试,直接测出链路实际可用带宽。 - 如果链路本身丢包率极低(<0.1%),但TCP传输吞吐总是上不去,那就要考虑是拥塞控制算法在误判(乱序、触发重复ACK),或者MTU设置不合理导致分片丢失。
如果链路丢包率本身就很高,那TCP任何拥塞控制算法都救不了你。这种问题优先排查网卡、交换机、光纤、MTU、防火墙流控策略。
5.4 关于热词“SSH超时连接一分钟后断开”“端口不可用”的连带说明
这些场景表面看和拥塞控制没关系,但排查思路是一致的:先确认链路有没有真正丢包,再确认连接是否因为超时重传太多被上层应用判定为“死链”。
比如“西门子PLC只有每次重启的时候才能连上一分钟”这个问题,如果你抓包看,会发现TCP传输阶段频繁出现重传,实际连接早就处于半死状态了。这种情况要排查的是PLC与上位机之间的网络设备是否做了连接超时回收策略,或者是否有防火墙把空闲连接踢掉。拥塞控制只在传输过程中起作用,但连接被中间设备砍掉,TCP状态机直接跳到断开,自然就没后续了。
6. 调优实践:我踩过的三个坑和最终建议
最后分享几个项目里踩出的经验,都是拿着文档不好找到答案的细节。
6.1 坑一:乱调初始窗口导致突发拥塞
有段时间为了“提高小文件传输速度”,我尝试把初始窗口从10调高到64。结果在办公室网络里测速正常,一拉高并发请求就出现大量超时重传——因为网关设备的缓冲区根本容不下这么多并发突发。
初始窗口设置是个权衡:适合大量小请求的场景,但遇到低缓冲路由器,反而会造成更严重的拥塞。没有统一最优值,建议结合生产环境的流量模型做A/B测试,而不是拍脑袋调高。
6.2 坑二:忽略MTU导致的“小包重传异常”
排查一个跨地域传输性能问题时,链路本身丢包率不到0.01%,但大文件传输速度极慢。后来抓包发现,超过1500字节的包全部被中间加密设备丢弃,TCP只能靠分片和超时重传恢复,拥塞控制算法反复判定“网络拥塞”,cwnd永远涨不上去。最终把MTU调成1400后,传输速度直接翻了4倍。
这类问题用拥塞控制的视角看,是“伪装成拥塞的链路问题”。如果一开始就认定“丢包=拥塞”,很容易绕进调参的死胡同。
6.3 坑三:把拥塞控制算法当万能解药
BBR在公网测试效果很好,但应用到公司专线后,某些TCP连接反而出现更频繁的RTT波动。原因在于专线中间有QoS策略,会主动对超过带宽限制的流量降速,而BBR为了让带宽饱和会不断提速,正好反复触发QoS限速,形成震荡。
所以我的实际经验是:选算法前,先搞清楚中间网络设备是否做流量整形。如果做了,BBR的激进探测策略和QoS策略是相克的。这种情况下,老老实实用CUBIC配合合适的丢包阈值反而更稳。
最后总结一句我在无数性能排查里验证过的土办法:拥塞控制解决的问题,本质是“在不知道链路容量上限的前提下,安全地逼近链路容量”。遇到网络性能问题,先别急着改算法、调窗口,先确认丢包率、RTT、窗口曲线这三个变量,再决定是链路问题还是算法问题。顺序搞反了,你会浪费大把时间在错误方向上打转。
