TCP滑动窗口、流量控制与拥塞控制:从原理到排障实战
前阵子协助排查一个跨机房的传输问题千兆链路RTT稳定在50ms上下但大文件传输速率怎么都上不了10Mbps。抓包那一刻发送窗口死死停在65535字节接收方通告窗口还有一大半空余——典型的窗口缩放失效。那次之后我复盘时总说一句话TCP的滑动窗口、流量控制、拥塞控制这三个概念考试能背出来不稀奇真正遇到速率上不去时能不被忽悠才是本事。所以下面我直接把三个概念的关系、滑动窗口的细节、日常排查的坑位一次说清楚。无论你负责网络、写后端还是做客户端优化只要和TCP打过交道这篇文章应该能帮你把很多零散知识串起来。1. 先搞清楚这三个概念各管哪一段路TCP可靠传输的根基是“确认重传”。可如果严格按“发一个包等一个ACK再发下一个”的停等模式走效率完全没法看。假设RTT是50msMSS是1460字节停等协议的吞吐上限就是1460 * 8 / 0.05 233600 bps ≈ 233.6 kbps这个速率放到今天连视频通话都费劲。所以TCP引入了滑动窗口允许发送方在没收到ACK之前连续发送多个报文。窗口是一段连续的字节范围窗口大小决定“有多少数据可以同时在路上”。这个设计把TCP从停等协议变成了流水线协议。但窗口开多大不能只由发送方拍脑袋。这里引出两个限制接收方的缓冲区能装多少接收方通过TCP头里的window字段告诉发送方这个值叫rwndreceive window。网络链路能承载多少发送方自己维护一个cwndcongestion window避免一次性把数据灌进网络把中间路由器队列打爆。所以实际发送窗口是min(rwnd, cwnd)。流量控制管“接收方能不能接得住”拥塞控制管“网络能不能扛得住”滑动窗口是承载这套机制的传输模型。这个关系一旦理清后面很多性能问题都能顺藤摸瓜。1.1 为什么教科书总把三个概念放一起讲因为它们在TCP发送逻辑里是联动的。发送方每次发数据、收ACK都会更新窗口状态接收方通过TCP头部窗口字段反馈自己的剩余容量网络通过丢包或延迟隐含地告诉发送方“我快撑不住了”。一个数据连接的一生其实就是这三者不断博弈的过程。只看其中任何一个都会得出片面的结论。1.2 一个特别容易踩的坑很多人以为rwnd和cwnd是同一回事看到“窗口”就以为是接收缓冲区。实际上rwnd由接收方决定反映的是接收方当前能接收多少cwnd由发送方自己维护反映的是发送方对网络容量的估算。抓包时如果只盯着TCP窗口字段很可能误判成“接收方处理太慢”但实际发送窗口可能已经被cwnd限制住了。2. 滑动窗口从“一个一个发”变成“排着队发”滑动窗口并不神秘它就是发送方维护的一组字节序号区间。把已发送数据分成四类已经确认的、已发送未确认的、还没发送但落在窗口里的、窗口外的。随着ACK不断回来窗口右边界向前推进左边界也跟着移动所以看起来像是在“滑”。2.1 发送窗口的内部状态假设窗口大小是1000字节发送方已经发出前400字节那么“可用窗口”就是600字节。收到ACK确认前200字节后未确认区域变成200~400可用窗口变成800数据可以继续发到1200字节的位置。注意TCP用的是累积确认。ACK号表示“期望接收的下一个字节序号”也意味着这个序号之前的字节全部确认。举个例子发送方发了1~1000字节收到ACK201说明1~200已经被接收如果后来又收到ACK501说明201~500也确认了。所以即使中间有部分乱序只要接收方返回一个更高的ACK发送方就不需要为那些已经被覆盖确认的包重传。2.2 接收窗口到底是怎么算出来的接收方收到的数据会先进入内核的接收缓冲区应用进程用read/recv等方式取走数据。缓冲区剩余空间就是rwnd。应用读取越快rwnd越大应用处理不过来rwnd就会缩小到0。接收方在返回的每个TCP报文里都会带上这个值。很多抓包里看到“Window 64000”之类的字段就是接收方当前能再收的字节数。如果在抓包过程中window字段不断变小甚至变成0基本可以判断接收端应用消费速度跟不上。2.3 窗口缩放64KB不够用怎么办TCP头里window字段只有16bit最大值是65535字节。放在几十毫秒RTT的现代链路上这个窗口会严重限制吞吐。于是有了Window Scale选项在三次握手阶段双方通过TCP option协商一个shift count真实窗口大小等于窗口字段值 shift。举个例子如果shift7最大窗口就是65535 7 8388608字节约8MB。如果shift14可以到1GB左右。关键点是这个选项必须在SYN阶段协商并且两端都支持才行。只要有一端不支持或中间设备把option改掉了窗口就退化成64KB。我在一个实际项目里就踩过这个坑两台都是新系统看着内核参数都没问题但传输速率始终上不去。后来抓SYN包才发现中间设备没有原样透传Window Scale选项导致两边实际没协商成功。2.4 零窗口和持续计时器当接收方缓冲区被占满rwnd会变成0发送方必须停下来。这时候最怕的是接收方刚腾出一点空间发出的“窗口更新”报文却在传输中丢了。发送方傻等窗口接收方傻等数据连接原地死锁。TCP为此设计了持续计时器。发送方在窗口为0时会定时发一个1字节的探测包接收方即使缓冲区满也会回应并在回包里带上当前rwnd。如果窗口恢复发送方立刻继续发送。抓包里如果看到连续多个“Zero Window”或者“Window Update”说明接收端要么应用卡住要么缓冲区设置太小。3. 流量控制不要让接收方“吃撑”流量控制解决的是一个很朴素的供需问题发送方可能很快接收方缓冲区却很小。如果不限制数据会在接收方内核里堆积最终溢出丢包。丢包后发送方还要重传反而把链路搞得更乱。与其让数据被丢掉不如让发送方慢点来。3.1 接收缓冲区为什么会被打满设想一个场景发送方以200Mbps的速率发数据接收方应用每秒只能取走20MB数据而接收缓冲区只有2MB。几百毫秒内缓冲区就会满。TCP看到rwnd变成0发送方停止发送才不会继续造成浪费。但这个机制有一个明显特征rwnd是动态变化的。应用每取走一些数据接收方就可能在下一个ACK中把窗口调大。所以流量控制下发送速率是“脉冲式”的缓冲区有空间就发满了就停。3.2 零窗口探测和窗口更新报文的区别接收方恢复空间后会主动发一个不带数据的ACK叫窗口更新。这类报文很关键但也很容易被网络丢。持续计时器就是兜底方案发送方每过一段时间发1字节探测包强制对方回应。这样一个连接不会因为窗口更新丢失而死锁。实战中如果大量出现零窗口、窗口更新交替说明接收侧已经变成瓶颈。这时不要盲目调大发送方的缓冲区要看接收端应用为什么消费慢或者直接调大接收缓冲区并确认应用用异步方式读取数据。3.3 傻窗口综合征和Nagle算法还有一类问题叫傻窗口综合征接收方缓冲区只腾出1个字节就立刻通告“能发1字节”发送方为这1字节要付出40字节的首部开销链路利用率极低。标准的抑制办法是接收方在可用窗口小于MSS或某个阈值时先不主动更新窗口发送方配合Nagle算法把小块数据攒够一个MSS或等到ACK再发。Nagle算法和延迟ACK放在一起会产生一个经典的40ms互锁发送方有一小包数据因为Nagle要等ACK但接收方为了凑更大报文故意延迟ACK结果大家干等。处理办法不是无脑关闭Nagle而是结合业务场景。需要频繁交互小包的服务可以评估是否禁用普通大文件传输场景保持默认通常更合理。4. 拥塞控制不要让网络“瘫掉”流量控制只盯着接收方可是数据包从发送方到接收方要经过很多路由器和交换机。每个中间节点的队列容量有限。如果所有连接都不管不顾地按自己的最大速度发路由器队列会迅速爆掉接着丢包、重传整个网络进入拥塞崩溃。TCP无法直接问路由器“你还有多少空间”只能靠自身试探。这个试探量就是cwnd。拥塞控制的本质就是发送方不断调整cwnd直到找到和当前网络容量匹配的发送速率。4.1 慢启动从小步快跑开始连接刚建立时发送方对网络一无所知所以cwnd从一个很小的值开始。早期是1个MSS现代Linux默认初始cwnd约10个MSS。每收到一个ACKcwnd增加1个MSS每个RTT窗口大约翻倍。初始10个MSS第二个RTT就能发20个MSS第三个RTT发40个MSS。这个阶段叫慢启动不是因为它增长慢而是起点低、步子稳。它通过ACK反馈来确认网络还有余量再逐步加码。当cwnd达到ssthresh慢启动阈值后进入拥塞避免。4.2 拥塞避免加法增超过ssthresh后cwnd不能再指数增长否则很容易冲过头。拥塞避免阶段每个RTT只增加1个MSS大概相当于线性增长。这是为了在接近网络容量时更小心地试探避免把中间队列打满。正常状态下cwnd会沿着线性缓慢爬升直到出现丢包信号。丢包信号无非两类RTO超时和重复ACK。4.3 超时重传和快速重传的区别RTO超时往往意味着网络非常拥塞甚至链路中断。传统Reno算法在这种情况下会把cwnd降回1个MSS重新走慢启动。这很保守但很安全。快速重传则温和一些发送方连续收到3个重复ACK基本可以判断某个包丢了但后续数据还在流动。此时ssthresh设置为当前cwnd的一半cwnd降到ssthresh 3*MSS进入快速恢复。在快速恢复阶段每收到一个重复ACK临时增加1个MSS直到收到新的ACKcwnd降到ssthresh再进入拥塞避免。这套“加法增、乘法减”的锯齿曲线是TCP Reno家族的核心。现代Linux默认用CUBIC对高带宽长距离链路更友好丢包后恢复速度更快。不管算法怎么变目标都一样在不知道网络容量的前提下通过丢包和延迟信号逼近可用带宽。4.4 BDP窗口到底该开多大BDP带宽延迟积 带宽 × RTT表示一条链路中最多能容纳的数据字节数。如果发送窗口小于BDP管道没有装满带宽浪费如果远大于BDP多余数据会在中间队列堆积增加延迟甚至丢包。举个例子1Gbps链路RTT 50msBDP 1e9 * 0.05 / 8 6.25MB。这意味着rwnd和cwnd至少要达到6.25MB才能让链路满载。如果接收窗口还是64KB理论吞吐只有65536 * 8 / 0.05 ≈ 10.5Mbps。这就是为什么很多跨机房传输在调大窗口后速度能有数量级提升。5. 性能排查到底是流量控制限制还是拥塞控制限制很多工程师一遇到“带宽高但传得慢”第一反应是加带宽或者调大socket buffer。但真正的问题可能早就写在窗口字段里了。我一般按下面几步判断。5.1 先看发送方未确认数据量如果发送方已发送未确认的字节数长期贴着窗口上限说明发送窗口已经成了瓶颈。这时候再细分如果rwnd很小接收方缓冲区经常满说明是流量控制在踩刹车。排查重点在接收端应用消费速度、接收缓冲区大小。如果rwnd一直很大但cwnd不断下降甚至出现重传和重复ACK说明是拥塞控制在踩刹车。排查重点在网络丢包率、中间设备队列、链路质量。5.2 一个典型跨机房案例某次场景两个机房之间带宽1GbpsRTT约50ms传大文件始终只有10Mbps左右。抓包发现接收方通告窗口一直是65535但发送方最多发到65535字节就停在窗口边界等ACK。原因很清晰双方没有成功协商Window Scale窗口实际还是64KB。按公式算出来的10.5Mbps和现场几乎一致。后来修正窗口缩放并调大缓冲区传输速率才恢复到预期。5.3 另一个案例高速内网也慢还有一次是千兆内网RTT不到1msBDP只有125KB左右64KB窗口理论上能跑到几百Mbps但实际吞吐上不去。排查发现应用层频繁发送小块数据Nagle和延迟ACK互相等待大量时间浪费在RTT上而不是带宽上。这种问题调窗口没什么用关键是把小包合并成大包或者根据场景调整Nagle策略。5.4 抓包时要看什么看SYN包里的Window Scale选项两端是否都带最终协商出的shift是多少。看TCP头window字段和shift计算后的真实窗口大小。Wireshark一般会显示计算后的窗口值但也要留意原始字段。看重复ACK、快速重传、超时重传数量多不多。这些东西才是拥塞控制的直接证据。6. Linux下的实操记录和几个能直接抄的工具命令基础理论讲完实操才是真正的分水岭。下面几个命令和调整方法都是我在实际环境中反复用过的。6.1 查看一条TCP连接的窗口数据在Linux上ss -tin可以看到连接内部的拥塞窗口、慢启动阈值、RTT、MSS等信息。输出里类似cwnd:10 ssthresh:100的字段就是当前容量的直接体现。如果cwnd长期被压得很低和ssthresh差距很大通常说明刚刚发生过丢包正在重新爬坡。想看更精细的流级数据还是建议用tcpdump抓包或者用Wireshark打开抓包文件看每个包的Window字段、ACK号、重复ACK序列。6.2 确认并开启窗口缩放先确认系统参数sysctl net.ipv4.tcp_window_scaling正常应该是1。如果为0需要打开。同时可以检查接收缓冲区范围sysctl net.ipv4.tcp_rmem这三个值分别表示最小、默认、最大接收缓冲区字节数。需要加大接收窗口时可以把第三个值调大。例如sysctl -w net.ipv4.tcp_rmem4096 87380 6291456注意这只是内核自动分配的参考范围。应用如果通过setsockopt(SO_RCVBUF)明确设置过缓冲区会覆盖或影响最终协商结果。6.3 修改后必须持久化临时用sysctl -w 改参数重启后会恢复。要让配置长期生效需要写进/etc/sysctl.conf或/etc/sysctl.d/下的配置文件并执行sysctl -p重新加载。这个步骤经常被漏掉导致“明明改了重启后又变回去”。6.4 常见误区和经验口诀窗口不是越大越好。窗口远超BDP会把多余数据堆积在网络队列里延迟变大反而加剧拥塞。不要只看rwnd不看cwnd。cwnd小的时候真正的瓶颈可能在发送方或网络链路而不是接收方。不要一遇到速度慢就调内核参数。先抓包确认瓶颈类型再决定是调窗口、关Nagle还是查链路丢包。不要忘了慢启动和ssthresh。即使窗口调得再大如果刚经历过丢包cwnd已经降到很低也需要时间重新爬坡。我个人的习惯是每次排查询速问题时把rwnd、cwnd、重传事件、RTT四个指标贴在一张图上用“窗口是否贴边”“重传是否频繁”来判断瓶颈归属。这个办法帮我在很多项目里避开了毫无意义的盲目调参。TCP这三个概念看着基础但真正理解透之后整个网络性能优化的思路都会清晰很多。