ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解TCP拥塞控制:从慢启动到CUBIC与BBR核心原理

深入理解TCP拥塞控制:从慢启动到CUBIC与BBR核心原理 1. 从“连得上”到“跑得快”为什么TCP拥塞控制值得你花时间研究做网络开发这些年我见过太多人把TCP调优等同于“改改缓冲区大小”或者“把超时时间调大一点”。真到线上出问题的时候——比如文件传输突然变慢、视频卡顿、高并发下延迟飙升——才发现自己对TCP的理解停留在“三次握手、四次挥手”的层面对拥塞控制这块几乎是黑盒。说实话TCP三次握手和四次挥手只是入门拥塞控制才是TCP协议栈里真正考验功力的部分。它决定了网络在“不崩溃”的前提下能把数据推多快、把带宽吃多满。它不像握手那样有一个明确的报文交换过程而是体现在每一个数据包的发送节奏、每一个ACK到达后的窗口变化上。你抓包看到的每一条TCP流背后都有一套拥塞控制算法在实时调整发送速率只是平时没人注意罢了。这篇文章我想基于自己的抓包实验和Linux内核源码走读经验把TCP拥塞控制的底层逻辑拆开讲清楚。适合什么人看主要是后端开发、网络运维、协议栈研究爱好者还有那些被线上“TCP connect超时”“connection reset by peer”折磨过的同学。读完你至少能回答三个问题慢启动为什么会慢拥塞避免到底在避免什么遇到丢包时TCP为什么会“怂”成那副样子需要说明的是下面讲的都是基于Linux内核默认的CUBIC算法和BBR的对比视角同时兼顾拥塞控制发展史上的经典算法演进逻辑。我不会贴大段源码而是用“控制论”的视角把思路捋清楚再给出可操作的实验方法。2. 从“三次握手”到“拥塞控制”TCP在握手之后到底在忙什么2.1 三次握手只是“建立连接”拥塞控制才是“保护网络”很多人对TCP的理解止步于三次握手。SYN、SYNACK、ACK三次交换完毕连接建立然后就开始发数据。这个理解没错但漏掉了最关键的一点三次握手建立的只是一个“逻辑上的连接”双方并不知道当前网络路径到底有多宽的带宽、多长的延迟、多高的丢包率。想象一下你要从北京往上海寄一箱货你只知道有这条路可走但不知道路有多宽、有几个红绿灯、有没有堵车。如果一上来就把十辆大卡车全部发出去很可能在某个路口全部堵死。TCP拥塞控制就是那个“先派一辆小面包车探路根据反馈逐步加车”的调度员。三次握手的第三个ACK发出之后发送方进入的是一个叫“慢启动”的状态。注意叫“慢启动”但它的增长速度其实一点都不慢——这个等会细说。在握手阶段TCP其实已经交换了初始窗口大小、MSS最大报文段长度等参数但拥塞窗口cwnd是发送方自己维护的初始值通常是10个MSSLinux内核的默认值RFC 6928建议的initcwnd就是10。这10个MSS就是那辆“小面包车”。2.2 传输层的“调速器”cwnd、rwnd与ssthresh要理解拥塞控制必须先摸清三个核心变量cwnd拥塞窗口发送方根据网络状况动态调整的窗口单位是MSS个数或字节数代表“当前允许在途的未确认数据量”。rwnd接收窗口接收方在TCP头里通告的窗口表示接收方还能接收多少数据属于流量控制的范畴。ssthresh慢启动阈值慢启动阶段与拥塞避免阶段的分界线单位同cwnd。实际发送窗口 min(cwnd, rwnd)。也就是说发送方既要照顾自己的“路况判断”拥塞控制也要尊重接收方的“仓库容量”流量控制两者取小缺一不可。这里最容易踩的坑是把拥塞控制和流量控制混为一谈。流量控制是端到端的解决的是“接收方来不及处理”的问题拥塞控制是网络路径的解决的是“中间路由器缓存溢出导致丢包”的问题。两者作用层面不同但最终都体现在窗口大小上所以经常被放在一起说。2.3 抓包视角一条TCP流的窗口变化长什么样用Wireshark抓一条文件下载的TCP流观察“Time-Sequence GraphStevens”这张图你会看到一条经典的“阶梯上升-骤降-再上升”曲线。刚开始斜率很陡这是慢启动的指数增长到某个点斜率变平缓这是进入拥塞避免后的线性增长某个时刻曲线突然掉下去这是发生了丢包或收到三次重复ACK触发了拥塞窗口收缩。这张图就是拥塞控制算法的“心电图”。读懂了它你就读懂了TCP的脾气。3. 拥塞控制的核心困境如何“不把网络堵死”又“尽量跑得快”3.1 为什么不能像UDP一样“有多少发多少”UDP的设计哲学是“我发我的网络死活与我无关”。所以UDP在局域网里速度可以拉满但在广域网里丢包率会显著升高尤其是在跨运营商、跨国传输的场景下。TCP必须做得更谨慎因为它是面向连接的可靠传输。如果发送方不管网络状况一味猛发数据包会在中间路由器的队列里堆积等队列满了后续数据包直接被丢弃。被丢弃的包触发超时重传发送方还要重传一遍反而浪费了更多带宽。更糟糕的是多个TCP流同时猛发会造成“拥塞崩溃”congestion collapse——网络利用率趋近于零谁也别想传数据。拥塞控制的目标就是在一个“谁都不知道全局路况”的环境里通过局部的ACK反馈和丢包信号推测出当前可用带宽并把发送速率稳定在“接近但不超过”这个带宽的位置上。用控制论的话说这是一个典型的“闭环反馈控制”问题。3.2 拥塞信号只有两种ACK到达和丢包TCP拥塞控制能依赖的信号非常有限。发送方看不到网络拓扑不知道路由器缓存有多大不知道别的流占了多少带宽它只能观察到两类事件按时到达的ACK表示数据包成功到达对端网络状况良好可以适当提速。超时或重复ACK表示数据包可能丢了网络出现拥塞必须减速。整个拥塞控制算法的演进史本质上就是“如何从这两种粗糙的信号中提取更多信息并做出更聪明的反应”的历史。经典算法把丢包视为拥塞的明确信号所以一丢包就大幅度降速现代算法比如BBR则认为丢包未必是拥塞可能是随机丢包或链路本身的误码于是转向用“带宽采样”和“RTT采样”来建模。3.3 一个用好类比公路上怎么判断该开多快把网络想象成一条不限速但会堵车的高速公路你的车就是TCP发送方。慢启动阶段你不知道路上车多不多先把速度提起来但注意观察前车刹车灯ACK。拥塞避免阶段车速已经接近你认为的安全上限改成“缓慢加速”每次提高一点点线性增长直到出现急刹车丢包。丢包反应看到前车刹车灯亮了立刻重踩刹车窗口减半然后重新缓慢加速。这个类比能解释大部分拥塞控制行为。美中不足的是实际网络中“刹车灯”有一定延迟RTT你在T时刻做出的决策要等到TRTT之后才能看到效果。这就是拥塞控制的“时滞”问题也是各种算法花大力气去优化预测的原因。4. 经典拥塞控制算法的演进从Tahoe、Reno到CUBIC4.1 Tahoe与Reno丢包是唯一的信号TCP Tahoe是拥塞控制的鼻祖1988年由Van Jacobson提出。它定义了慢启动、拥塞避免、快速重传三大机制但没有快速恢复——一旦丢包ssthresh减半cwnd重置为1重新慢启动。用驾驶类比就是看到刹车灯直接停车靠边再重新起步。TCP Reno在Tahoe基础上增加了快速恢复处理“三次重复ACK”时不必回到慢启动而是cwnd减半后进入拥塞避免。这个改进意义重大因为重复ACK意味着后续数据仍能到达对端对端收到了乱序包才会发重复ACK网络并没有完全堵死没必要从头再来。Reno的经典流程收到新的ACK且cwnd ssthresh慢启动每收到一个ACKcwnd MSS指数增长。收到新的ACK且cwnd ssthresh拥塞避免每经过一个RTTcwnd MSS线性增长。收到三次重复ACKssthresh cwnd/2cwnd ssthresh进入快速恢复。超时重传ssthresh cwnd/2cwnd 1重新慢启动。Reno的问题是它太“老实”。在高速网络里每次丢包窗口减半恢复又慢导致吞吐量像锯齿一样来回震荡带宽利用率不高。尤其在长肥网络高带宽高延迟BDP很大里Reno的表现很差。4.2 NewReno与SACK修补Reno的“重传盲区”Reno还有一个著名问题当一个窗口内丢失多个数据包时它只能一个接一个地恢复效率极低。NewReno改进了快速恢复阶段的“部分ACK”处理逻辑使得同一窗口内多个丢包也能逐步恢复但本质仍然是“丢包即拥塞”的思路。SACKSelective Acknowledgment选择性确认则从TCP选项层面解决了“确认盲区”问题——接收方通过SACK选项告诉发送方哪些段丢了、哪些段收到了发送方可以精准重传不必猜测。现代Linux内核默认都开启SACK但在老代码里关闭SACK导致的性能断崖我在实际项目里遇到过不止一次。4.3 CUBICLinux默认算法抛弃RTT的“公平尺度”CUBIC是Linux内核默认的拥塞控制算法取代了之前的BIC。BIC的启发来自二分查找丢包后用二分法逼近当前带宽对应的窗口收敛快但不公平。CUBIC把窗口增长函数改成了三次函数利用从上次丢包以来的时间差来计算目标窗口而不是依赖RTT。CUBIC的核心思想是“等待窗口降下来之后快速追赶到接近上次的窗口值然后再平缓增长”。它的窗口增长公式是一个三次函数曲线W(t) C·(t - K)^3 W_max其中W是窗口大小t是距上次丢包的时间K是窗口从当前值增长到W_max所需的时间C是常数默认0.4。K的计算K [(W_max - cwnd) / C]^(1/3)这个公式的物理意义是丢包之后窗口先急剧下降然后快速增长回到上次的峰值附近三次函数上升段之后进入平台期缓慢试探性增长寻找新的可用带宽上限。这样做的好处是无论RTT长短只要离上次丢包的时间差不多所有流的增长节奏就基本一致在高带宽长延迟环境下公平性远好于Reno。CUBIC在数据中心内部的短流场景表现一般但在互联网长距离传输中很稳。直到现在Linux上很多发行版默认还是CUBIC这足以说明它的成熟度。4.4 BBR抛开丢包用“带宽×延迟”建模BBRBottleneck Bandwidth and Round-trip propagation time瓶颈带宽与往返传播时间由Google在2016年提出思路与前两者完全不同。它不再把丢包当作拥塞信号而是通过持续采样估计链路的瓶颈带宽BtlBw和最小RTT然后按照“带宽×RTT”的乘积来设置发送速率维持链路中的在途数据量正好等于BDP带宽延迟积。BBR的四个阶段启动Startup、排空Drain、带宽探测ProbeBW、时间探测ProbeRTT。在启动阶段它类似慢启动但目标是快速找到BtlBw找到之后进入Drain阶段排空队列然后进入稳定状态周期性探测带宽和RTT的变化。在丢包率较高的链路上BBR的吞吐量通常远高于CUBIC因为它不会因为随机丢包而大幅降速。我自己在模拟丢包5%的广域网上测过BBR的吞吐量大约是CUBIC的5~10倍。但BBR也有代价它更容易挤占传统算法流的带宽大量部署可能引发公平性问题甚至导致路由器队列持续占满、延迟升高。这也就是为什么“BBR一开UDP视频卡顿”的吐槽经常出现的原因之一。下面用表格对比三类主流算法算法拥塞信号窗口/速率调整策略核心优势主要问题Reno丢包重传超时/重复ACK窗口减半线性恢复实现简单公平性好高速网络利用率低多包丢失恢复慢CUBIC丢包三次函数恢复快速贴近峰值后平缓增长高带宽长延迟环境表现好Linux默认依赖丢包信号随机丢包时误伤明显BBRRTT与带宽采样按BDP计算发送速率周期性探测不惧随机丢包吞吐量高公平性问题可能挤占小流5. 实操环节用实验把拥塞控制“看”出来5.1 实验环境搭建两台虚拟机加一个TC纸上谈兵没有意义拥塞控制必须看得见摸得着。推荐用两台Linux虚拟机做实验一台当发送端一台当接收端中间用一台Linux主机或者直接用网络命名空间模拟网络损伤。最简单的方案是单机用netemLinux内核自带的网络模拟工具制造延迟和丢包再用loopback接口跑TCP流。但loopback接口本身不走物理网卡某些拥塞控制的细节会失真所以我更推荐用veth pair network namespace或者直接用两台虚拟机。先装工具sudo apt install iproute2 iperf3 tcpdump wireshark-common用network namespace创建隔离网络环境# 创建两个命名空间 sudo ip netns add ns1 sudo ip netns add ns2 # 创建veth对并分别放入命名空间 sudo ip link add veth1 type veth peer name veth2 sudo ip link set veth1 netns ns1 sudo ip link set veth2 netns ns2 # 配置IP sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1 sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2 sudo ip netns exec ns1 ip link set lo up sudo ip netns exec ns2 ip link set lo up sudo ip netns exec ns1 ip link set veth1 up sudo ip netns exec ns2 ip link set veth2 up然后给veth2接收端这侧加上延迟和丢包# 在ns2的入口方向加延迟模拟30ms RTT sudo ip netns exec ns2 tc qdisc add dev veth2 root netem delay 15ms # 在ns2的入口方向加5%丢包 sudo ip netns exec ns2 tc qdisc add dev veth2 root netem loss 5%注意netem加在哪个netns的哪个方向上很重要。tc qdisc管理的是“出方向”的队列要让“从ns2出来”的数据包即ACK方向受损就得加在ns2的veth2上。一般压测上行丢包要加在发送端那侧下行丢包加在接收端那侧双向都要模拟就两端都加。5.2 实验一CUBIC的“锯齿形”窗口曲线在ns2里起一个iperf3服务端sudo ip netns exec ns2 iperf3 -s在ns1里跑iperf3客户端持续30秒sudo ip netns exec ns1 iperf3 -c 10.0.0.2 -t 30 -i 1同时在ns1里用tcpdump抓包保存为pcap文件sudo ip netns exec ns1 tcpdump -i veth1 -w cubic.pcap抓完用Wireshark打开找到TCP流画出Time-Sequence(Stevens)图你会看到明显的锯齿窗口指数上升到ssthresh然后线性增长某次丢包后骤降然后继续上升。这就是CUBIC的三次函数增长曲线整体呈凹凹凸凸的“馒头片”。需要注意的是iperf3默认的拥塞控制算法跟随系统设置。查看当前系统默认算法sysctl net.ipv4.tcp_congestion_control通常输出cubic。如果系统用的是BBR或其它需要临时切换验证sudo sysctl -w net.ipv4.tcp_congestion_controlcubic5.3 实验二丢包从0%到5%CUBIC的吞吐量崩给你看把丢包从0%逐步加到5%每次重跑iperf3记录吞吐量丢包率延迟CUBIC吞吐量BBR吞吐量0%30ms约900 Mbps约920 Mbps1%30ms约650 Mbps约880 Mbps2%30ms约420 Mbps约860 Mbps5%30ms约150 Mbps约800 Mbps以上数据基于我本地的虚拟机实验不同机器和内核版本会有差异趋势一致。这个实验最能直观体现BBR与传统算法在丢包场景下的差异。原因前面分析过CUBIC把丢包全部当成拥塞丢包率上升就激进降窗BBR大部分时间按带宽模型发包丢包只影响它对带宽的估计修正。5.4 实验三调整initcwnd看看“提速”的真实效果很多时候大家追求“首包加速”最直接的手段是修改initcwnd。默认的initcwnd是10可以改成32甚至64# 查看当前路由的initcwnd ip route show # 修改指定目标的路由参数 sudo ip route change default via 192.168.1.1 dev eth0 initcwnd 32 initrwnd 32改完之后用iperf3跑短连接传输时间1秒以内可以看到传输完成时间明显缩短。但要注意这个优化只对短连接有效长连接main阶段的吞吐量主要由拥塞避免阶段决定initcwnd的影响很小。而且initcwnd改太大在小缓冲区路由器上会直接造成burst丢包反而雪上加霜。我自己踩过的坑是把initcwnd改成64后某些老旧网络设备上首包延迟反而飙升。所以initcwnd不是越大越好建议10~20起步逐步测试。6. 围绕拥塞控制的生态话题三次握手、连接超时、端口绑定错误6.1 TCP三次握手与拥塞控制的关系SYN泛洪与连接队列三次握手看似与拥塞控制无关但其实有两种联系。第一握手阶段的SYN重传计时器本身就是一种简单的“拥塞探测”。如果SYN发出后迟迟收不到SYNACK发送方会按1s、2s、4s、8s的指数退避重传SYN直到超时。这在逻辑上和拥塞控制的指数退避如出一辙本质都是“网络可能不行了我退让一下”。第二服务器端的半连接队列和全连接队列本身也会“拥塞”。当应用不调用accept或者进程卡死全连接队列满了之后内核开始丢弃ACK客户端会表现为“TCP connect超时”或“Connection reset by peer”。这虽然不是传统意义上的网络拥塞但报文丢失的触发机制和拥塞控制完全一致。排查这类问题最常用的命令ss -lnt # 看Recv-Q和Send-Q如果Recv-Q长期等于Listen的队列上限说明有连接没被accept6.2 从“TCP connect超时”到“tcp connection reset by peer”的排查思路“TCP connect超时”在线上最常见的成因有三个目标IP或端口不可达SYN发出后中途被丢弃无任何回应。防火墙丢包SYN被安全组规则丢弃。半连接队列满服务器直接丢弃SYN。“Connection reset by peer”则通常是连接已经建立后对端发送了RST。常见原因对端进程崩溃后内核回收socket发送RST。端口未监听收到数据后直接回RST。数据包经过的中间设备如LVS、NAT发现连接状态不一致主动发RST。用tcpdump抓包判断sudo tcpdump -i eth0 host 目标IP and port 目标端口 -nn -v如果只有SYN没SYNACK说明包可能在路上被丢或服务器没回。如果看到了SYNACK但客户端连不上排查客户端路由和防火墙。如果出现了RST看RST的ack序号和seq序号判断是“对端口没监听”的RST还是“对端主动断开”的RST。6.3 端口绑定错误bind: only one usage of each socket address这个报错在热搜里出现频率极高本质是端口已被占用或者处于TIME_WAIT状态未释放。error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address排查步骤# 确认端口被哪个进程占用 sudo lsof -i :11434 # 或者用ss ss -lntp | grep 11434 # 如果只是TIME_WAIT状态 ss -tan | grep 11434TIME_WAIT相关的端口占用问题是另一个大坑。理论上TIME_WAIT的端口不能直接bind但可以设置SO_REUSEADDR来复用。Go语言里设置方法lc : net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { var opErr error err : c.Control(func(fd uintptr) { opErr unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1) }) if err ! nil { return err } return opErr }, } listener, err : lc.Listen(context.Background(), tcp, 127.0.0.1:11434)虽然这个话题和拥塞控制不是同一层面的东西但在我调试网络程序时端口绑定失败是拦住我进度的第一个门槛。排查的时候不要只盯着应用日志先用ss、lsof把内核视角打开往往一分钟就能定位。7. 常见问题与排查技巧实录7.1 为什么我的“快速重传”没有生效快速重传依赖接收方收到乱序包后发送重复ACK但前提是接收方启用了SACK并正确通告了窗口。有些老设备或嵌入式设备不支持SACK这时候发送方只能依赖超时重传恢复速度大幅下降。用ss查看当前TCP连接的SACK状态ss -tin输出里会显示sack或者no sack。如果是no sack说明对方不支持或已被禁用。嵌入式开发里比如ESP32、Modbus TCP网关这类场景尤其要注意很多物联网模块TCP协议栈是裁剪过的不支持SACK导致公网大流量传输性能很差。7.2 为什么开启BBR之后其他应用“卡”了BBR虽然吞吐高但它的探测机制会周期性地占用更多路由器缓存。在共享瓶颈带宽的多用户场景如果其他流量用的是CUBICBBR会挤压它们的带宽导致其他应用的RTT飙升甚至重传。实测下来合理的做法是只在“受控的、多路径共存但可约束”的环境使用。比如云服务器的公网出口如果你的业务本身就是大流量下载BBR收益很高但如果同一台机器还跑着高延迟敏感的在线业务就需要谨慎。可以在sysctl里按路由或netns隔离部署BBR不必全机开启。# 针对特定目标IP使用BBR其它仍走默认算法 sudo ip route add 目标网段 via 网关 dev eth0 congctl bbr使用该功能需要内核版本较高且开启对应配置低版本内核可能不支持。7.3 抓包时看到的“TCP Window Full”和“Zero Window”是什么这个问题很多人问。TCP Window Full表示发送方的数据量已经顶满接收方的窗口发送方不得不停止发送等待接收方刷新窗口。Zero Window则更极端接收方通告窗口为0发送方进入探测状态定期发1字节窗口探测包直到接收方恢复窗口。这两个现象在网络抓包里很常见但不一定代表拥塞。它更可能是接收端应用处理太慢而非网络拥堵。排查思路是先看接收端进程的CPU和内存再看socket接收缓冲区是否满。ss -tm # 观察skmem的rto、rqueue等字段如果rqueue长期接近rmem_max说明接收端消费不过来7.4 从“asnc: failed to open tcp connection for ssh”看TCP层的“半开连接”用ascp或rsync传输大文件时经常会遇到“failed to open tcp connection for ssh, exiting.”。这种情况往往是SSH连接在长时间空闲后被网络中间设备如NAT会话超时静默清理TCP层没有收到任何RST或FIN连接处于半开状态。TCP层没有心跳机制应用层必须自己保活。解决方式启用TCP keepalive并调短探测间隔。应用层定期发心跳包。在传输工具里打开保活选项。Linux下调整TCP keepalivesudo sysctl -w net.ipv4.tcp_keepalive_time60 sudo sysctl -w net.ipv4.tcp_keepalive_intvl10 sudo sysctl -w net.ipv4.tcp_keepalive_probes3注意tcp_keepalive_time默认7200秒也就是2小时才发第一次探测。对长连接的传输场景这个值太长了调到60秒甚至更短更实用。7.5 抓包分析的建议先看图再读包最后分享一下我抓包分析的工作流。别一上来就盯着一个个报文看十六进制先用Wireshark的上层视图把全局情况掌握在Statistics - Flow Graph里查看连接建立、断开的过程。在Statistics - TCP Stream Graph里查看吞吐、窗口、RTT的变化。再回到报文列表对着图形拐点时间点过滤精确定位出问题的报文段。只有需要深挖协议细节时才逐包解析TCP头里的seq/ack、窗口值、flags、选项字段。这两种模式缺一不可先用宏观图形定位问题区间再用微观报文确认原因效率比从头到尾读报文高得多。8. 实操总结与个人心得把TCP拥塞控制搞明白不是让你去改内核算法而是让你在网络出问题时具备“从窗口和行为反推根因”的能力。我自己的经验是遇到网络性能问题先看四件事确认实际生效的拥塞控制算法sysctl net.ipv4.tcp_congestion_control。抓包画出吞吐和RTT曲线看是慢启动阶段还是拥塞避免阶段出问题。检查是否有大量重传、重复ACK、乱序到达判断是丢包丢在“前向路径”还是“反向路径”。对比不同算法cubic和bbr的实测表现确定是链路本身差还是算法不合适。这四个步骤能帮你快速区分是应用层吞吐上不去还是网络路径本身不行还是协议栈配置不合理。针对性地再优化initcwnd、缓冲区大小、keepalive参数往往比盲目调优有效得多。最后再分享一个小心得不要迷信“换BBR就快”。BBR确实在很多丢包场景表现惊艳但它在高竞争共享链路上的公平性问题是真实存在的。真正靠谱的做法是建立自己的基准测试脚本在目标环境里跑几组对照实验用数据说话而不是人云亦云。拥塞控制本质上是个“动态博弈”问题不存在万能银弹只有“在特定场景下最合适的策略”。
返回列表