首页 / 资讯中心 / 文章详情

TCP滑动窗口、流量控制与拥塞控制:原理、配合与调优实战

TCP滑动窗口、流量控制与拥塞控制:原理、配合与调优实战 ★ FEATURED ARTICLE
1. 为什么TCP要同时做三件事从一次文件传输说起如果你和我一样日常工作是写网络服务、调接口性能或者排查线上抖动那你一定绕不开TCP这个老伙计。今天我们只聊三件事滑动窗口、流量控制、拥塞控制。它们经常被放在一起讲却很少有人把三者真正拆开逐一看清楚谁管谁、怎么配合、出了错怎么查。先从一个最常见的场景切入。你在公司内网往另一台服务器传一个2GB的安装包带宽是千兆理论上大概20秒就能传完。但实际用scp传的时候你发现速度只有不到10MB/s甚至更慢。此时抓包去看会发现TCP窗口数据非常有趣发送方明明有空闲带宽却总是小口小口地发接收方明明有内存却还在通告一个很小的窗口偶尔还伴随丢包重传和拥塞窗口的剧烈下降。这种现象就是滑动窗口、流量控制、拥塞控制三者互相制约的典型结果。可以这样理解滑动窗口解决的是一次能发多少流量控制解决的是对方能收多少拥塞控制解决的是网络能承载多少。三者不是互相独立的而是三个不同维度上的限速器叠加在一起最终取它们之间的最小值才是你实际看到的传输速率。这篇文章不是教科书复读。我会按真实抓包、真实调优的顺序把每个机制的原理、它们在Linux内核参数里的位置、以及最常见的坑都串起来讲。适合正在做网络编程的开发者、刚入门TCP/IP想弄清细节的运维以及所有对为什么带宽是1000M传输只有100M感到困惑的人。2. 滑动窗口是怎么用留有余地换吞吐的2.1 从发一个等一个到连续流水线发送要理解滑动窗口先得理解它的反面。假设没有窗口机制TCP最原始的做法是发送方发出一个数据段必须等接收方返回ACK确认报文后才能发下一个数据段。这种停等协议在局域网里测试没什么问题但一旦涉及跨地域传输问题立刻暴露链路越长等待一个ACK的时间就越久而链路实际上一直是空闲的带宽完全被浪费。滑动窗口的改进思路很朴素——允许发送方在收到ACK之前连续发送多个数据段。这些未确认但允许发送的数据段数量就是发送窗口的大小。窗口像一条在数据流水线上往前滑动的传送带左边的边界是已确认的序号右边的边界是我最多能发到哪确认到达后整个窗口就向前移动。这里有一个关键点滑动窗口不是内存分配的方式而是一种允许发送的序号范围。它同时约束了发送方的行为不能连续把数据全扔进链路必须遵守窗口边界。这个边界有一个明显的上限就是接收方通告的窗口大小这一点我们会在流量控制部分展开。2.2 窗口的三种状态已发送未确认、可发送、暂不可发送在实际实现中发送方维护的发送窗口可以用一组状态来描述。假设窗口大小为10个数据段序号从1开始序号1到3已发送但还没收到ACK此时它们占据窗口左端。序号4到8尚未发送但在窗口允许范围内可以随时发送。序号9到10已超出窗口右边界不能发送必须等窗口滑动后才有资格进入可发送状态。一旦收到ACK比如确认序号1成功窗口右边界就从10扩大到11假设接收方通告的窗口没变那么序号9和10就从不可发送滑入可发送范围。这就是滑动二字的由来——它永远不是固定的一条线而是随着确认不断前移的活动边界。很多初学者容易混淆的一个点是TCP Window窗口大小和TCP Sequence Number序号的关系。窗口是数量概念描述允许发送多少个字节序号是位置概念描述数据流中某个字节的编址。每一次ACK确认的是某个序号及其之前的所有字节而窗口大小决定的是你能从当前未确认位置往后推多少字节。两者相乘才构成完整的传输视图。2.3 窗口过小的反直觉现象Tinygrams风暴滑动窗口本身不难但实际操作中有一个高频问题接收方的窗口如果设得极小发送方每次只能发一点点数据就会产生大量的小包Tinygrams。我在某次排查内部消息系统延迟时遇到过类似情况某个消费端应用程序读数据很慢接收缓冲区几乎总是满的通告窗口经常只剩几百字节于是发送端每次都只发几百字节。线上抓包看到一堆长度不足200字节的小包吞吐量惨不忍睹。这种问题的根因不在TCP而在应用层读数据的速度。TCP窗口机制只是如实反映接收方的处理能力。修复方法不是调内核参数而是让应用层尽快读走socket缓冲区中的数据或者干脆改成批量读取。这一点在后面流量控制部分还会细说。3. 流量控制接收方说慢点发送方就得真慢点3.1 TCP头里的窗口字段为什么只有16位流量控制的本质是使用TCP报文头部中的窗口字段由接收方持续通告自己能接收的字节数。发送方看到这个值后会把自己的发送窗口上限控制在该值以内确保不会把接收方的缓冲区撑爆。TCP头里的窗口字段只有16位直接用二进制表示的话最大也就是65535字节。在现在的网络环境下这个值偏小所以TCP引入了窗口缩放因子Window Scaling选项。在三次握手中双方会协商一个缩放因子通常是2的整数次幂实际窗口大小 窗口字段的值 × 缩放因子。例如窗口字段显示4096缩放因子是7实际通告窗口就是4096 × 128 524288字节。这个机制带来一个非常隐蔽的坑抓包工具默认情况下看到的是缩放后的窗口大小还是原始窗口字段取决于抓包软件是否解析了TCP选项。我就见过同事用Wireshark看一个大窗口连接怎么算都觉得不对最后发现是没留意Window scaling factor那一列。实际排查时优先确认双方的缩放因子协商情况再谈窗口大小。3.2 接收缓冲区到底设多大合适流量控制直接受操作系统接收缓冲区大小影响。在Linux上接收缓冲区相关参数包括参数默认值作用net.ipv4.tcp_rmem4096 87380 6291456接收缓冲区的最小值、默认值、最大值单位字节net.ipv4.tcp_window_scaling1是否开启窗口缩放net.core.rmem_max212992单个socket接收缓冲区的上限如果应用读取socket数据的频率不够快或者程序直接不读比如某个worker线程卡死接收缓冲区就会堆积数据TCP通告窗口随之变小。调用方不会收到任何报错但发送方的传输速率会一降再降。这类问题非常容易误判为网络故障实际上完全是应用层的背压。我在一个大数据传输任务中遇到过这种情况数据生产端在A机房消费端在B机房链路明明很好但传输始终只有几十MB/s。排查到最后发现消费端程序在处理某一类数据时做了同步磁盘写入I/O速度成了瓶颈。socket缓冲区被占满窗口通告从几MB逐步掉到几十KB最终表现就是传输速度被流量控制按住了。所以流量控制其实是TCP内建的一种保护机制它保证了接收方在忙碌时不会被数据淹没。3.3 零窗口把对方冻结的协议细节如果接收缓冲区完全满了接收方会通告窗口为0。此时发送方必须停止发送直到收到一个窗口非零的ACK这个过程叫零窗口处理。但这里存在一个悖论如果接收方没有数据要发TCP会不会一直不发那个窗口更新报文为了解决这个问题TCP规定发送方在收到零窗口通告后会启动一个持续计时器Persist Timer周期性发送一个1字节的探测报文目的是促使接收方回复当前的窗口大小。如果接收方回复的窗口还是0发送方就继续等一旦窗口恢复了发送方就能立刻开始传输。这个机制平时很少被人注意但它的存在意味着即使通信双方看似卡死TCP也不会彻底死锁。如果两个进程真正卡住了抓包时能看到规律性的窗口探测报文——看到这种包你就可以放心地断定瓶颈在应用层不在TCP协议栈。4. 拥塞控制别让网络堵死在半路4.1 拥塞窗口与慢启动的探路逻辑流量控制管的是接收方能不能收拥塞控制管的是网络能不能扛住。换个更直白的说法发送方不知道路径上的路由器缓存满了没有更不知道沿途有多少流量在争抢带宽。为了让网络不至于整体瘫痪TCP必须自己摸着石头过河——这就是拥塞窗口Congestion Windowcwnd存在的意义。发送方的实际发送量 min(接收窗口rwnd拥塞窗口cwnd)。在连接刚建立时发送方并不知道网络底线在哪所以Linux默认的初始拥塞窗口通常只有10个MSS最大段大小常见为1448字节。如果按MSS1448字节算初始cwnd大约只有14KB左右。每收到一个ACKcwnd就增加一个MSS这种增长是指数的包数量从1变成2、4、8、16……直到超过慢启动阈值ssthresh或发生丢包。为什么要用这种试探性的指数增长因为网络状况变化太快TCP无法预先测量。指数增长能在几十个RTT内快速逼近可用带宽同时又不至于一开始就把大量数据灌进网络。如果网络出现丢包TCP就认为我已经把网络塞满了从而进入拥塞避免阶段把发送速度降下来。4.2 四大算法家族Reno、NewReno、BIC、CUBIC以及新秀BBR说到拥塞控制就绕不开具体的算法。不同算法决定丢包后窗口怎么降、恢复期怎么升。现代Linux内核默认的算法通常是CUBIC但对性能和延迟敏感的传输场景未必每个场景都合适。算法核心策略适用场景Reno拥塞避免阶段线性增长丢包时窗口减半快速重传恢复老协议栈延迟较高NewReno改进了快速恢复阶段对多个丢包的处理中规中矩兼容性好BIC/ CUBIC窗口增长采用三次函数曲线丢包后能更快恢复到原窗口高带宽长链路Linux默认CUBICBBR基于带宽和RTT建模不靠丢包做信号追求低延迟高吞吐长肥网络、高丢包环境很多人在局域网内测速时会发现RTT只有零点几毫秒TCP无论用哪种算法都能跑满带宽。一旦到了跨地域、跨运营商的链路上RTT变成几十毫秒甚至上百毫秒算法差异就会非常明显。CUBIC在长链路下的窗口恢复能力比NewReno强很多但它在高抖动网络里依然会把丢包作为拥塞信号窗口会不必要地骤降。BBR则改成了用带宽估计×最小RTT来建模不轻易因为丢包降速但它在处理公平性和与现有算法共存时也有自己的问题。要真正理解这些建议在实验室里用netem模拟不同延迟和丢包率对比观察拥塞窗口曲线。4.3 SACK与快速重传丢包后的止损机制丢包不仅意味着拥塞还意味着接收方可能收到了乱序数据或者某些数据缺失。为了不因为一个丢包就把后面所有数据都重复传一遍TCP引入了选择性确认SACK。通过SACK选项接收方可以明确告诉发送方我收到了哪些不连续的数据块发送方只需要精确重传缺失的部分效率提升非常明显。打开SACK后抓包时会看到TCP包头部带有SACK选项里面列出了一段或多段非连续接收的字节范围。我在一次跨洲数据传输中测试过同样在5%丢包的模拟环境下关闭SACK后吞吐量下降了超过60%开启SACK后又恢复了接近75%的原始吞吐。SACK的代价是报文头变大最多能加几十字节的选项但对于长链路而言收益远大于开销。5. 三个机制在真实网络中的配合、冲突与调参实践5.1 窗口是乘法叠加不是加法加出来的前面分别讲了三个机制现在把它们放在同一个收发过程中看。假设接收方窗口rwnd1MB拥塞窗口cwnd初始只有14KB那么发送方的实际有效窗口是14KB。随着ACK持续返回cwnd指数增长很快超过1MB这时瓶颈就切换到rwnd实际窗口被固定在1MB。如果中间网络出现丢包cwnd又会大幅降到几十KB于是实际窗口又从接收窗口的限制切换回拥塞窗口的限制。这个切换过程非常频繁尤其在高延迟环境里。你抓包时比较表面现象会看到窗口值一会儿大一会儿小但如果把rwnd和cwnd分开画两条曲线逻辑就清楚了。任何一个值成为当前瓶颈发送速率就由它说了算。调优的思路永远是先确认当前瓶颈到底在哪个维度。现象最可能的瓶颈优先排查方向接收窗口通告很大但实际吞吐低拥塞窗口受限丢包率、RTT、拥塞控制算法拥塞窗口很大但实际吞吐低接收窗口受限socket接收缓冲区、应用读取速度发送方一直小包发送应用写数据频繁但数据量小Nagle算法、写入聚合、延迟ACK吞吐时好时坏抖动明显网络拥塞链路质量、队列缓存、BBR/ CUBIC切换5.2 Nagle算法与延迟ACK的相爱相杀很多人搞不懂Nagle算法为什么存在。它的目的是减少网络中的小包数量如果发送方有尚未确认的小数据新到的小数据就不能立刻发只能合并到缓冲区等ACK回来后再一次性发送。这个思路在拨号网络时代非常有效今天却常常和接收方的延迟ACK机制产生恶性协作发送方等ACK接收方等下一段数据凑满两个MSS结果双方互相等待出现延迟飙升。我遇到过的最典型场景是客户端每隔几十毫秒发送一个几十字节的心跳包服务端回复也很频繁。两边都开着Nagle和延迟ACK结果单次请求的RTT从正常的1ms飙到了接近50ms。解决方案要么在应用层禁止Nagle设置TCP_NODELAY要么在服务端调整ACK策略。注意TCP_NODELAY解决的是发送端问题如果延迟ACK仍在单向数据传输的尾包依然会多等一个ACK计时周期。5.3 调优Linux内核参数的安全顺序如果你确认问题确实是窗口机制导致的调参顺序建议从温和到激进先确认双方是否支持窗口缩放ethtool -k、sysctl net.ipv4.tcp_window_scaling。加大socket缓冲区调整net.ipv4.tcp_rmem和net.ipv4.tcp_wmem的最大值同时让程序用setsockopt设置SO_RCVBUF/SO_SNDBUF。对大流量长连接考虑开启BBR将net.ipv4.tcp_congestion_control设为bbr前提是内核和网络链路都兼容。对时延极度敏感的小包请求关闭Nagle或聚合写入。这里面最大的坑是很多人一上来就把tcp_rmem调成几百MB但完全没有检查应用层读取速度。结果缓冲区确实变大了数据全堆在内核里窗口通告倒是大了些却把问题从吞吐低变成内存浪费延迟更不可控。调参永远是对症下药不是无脑堆资源。6. 从抓包到排障实测一次窗口下降的全过程6.1 抓包前的准备工作别被默认参数误导我用一个实际排障过程来说明三者的配合关系。某次线上服务反馈跨地域传输文件特别慢延迟约80ms带宽100Mbps理论最大速率约12.5MB/s实际却不足3MB/s。第一步做的事情就是抓包条件非常明确在发送方服务器抓包过滤条件为tcp host 目标IP。保存完整的包序列方便后续查看TCP选项和窗口演变。同时记录ss -ti因为ss输出的tcpi信息里直接包含cwnd和rwnd这个非常关键光靠抓包看不到cwnd。抓包后看到的现象是窗口缩放因子协商正常rwnd始终有4MB左右说明接收方完全没有施加流量控制压力。这立即把流量控制排除掉。再看ACK包的时间间隔和序号间距发现序号一直在前进但前进速度明显低于带宽。紧接着用ss -ti看本机的cwnd发现cwnd只有几千字节几乎可以确定是拥塞窗口被压住了。6.2 顺着cwnd找丢包点cwnd为什么小要么是慢启动阈值太低要么是发生了丢包后还没恢复。于是下一步看丢包统计netstat -s | grep -E segments|retransmits。结果retransmitted segments数量多得惊人但同时没有看到大量超时重传更多是快速重传——说明链路确实有丢包大概是路由器缓存或运营商链路上的随机丢包。到这里思路已经清晰了CUBIC算法把丢包看成拥塞信号每次丢包就把cwnd减半接着再缓慢恢复。80ms的RTT让恢复节奏变得更慢所以吞吐一直起不来。这个场景下错误的选择是继续调大socket缓冲区因为接收窗口并不是瓶颈正确的选择是先测出真实丢包率再决定是否切换拥塞控制算法。6.3 用BBR替代CUBIC以及它带来的新问题在测试环境里我把拥塞控制算法切成了BBR重新跑了同一份传输任务。初步结果非常惊艳吞吐立刻从3MB/s跳到10MB/s左右几乎逼近链路上限。原因是BBR不再因为丢包就大幅收缩窗口而是通过持续探测带宽估计值来设定发送速率在随机丢包链路上非常占便宜。但BBR也给我上了一课。它在这条链路上的RTT波动明显变大原本稳定的实时交互接口出现了更多的排队延迟。因为BBR会刻意把链路队列填满来探测剩余带宽而这条链路的转发设备队列深度不大填满后对其他低优先级流量产生了排挤。最终方案不是全线切换BBR而是对大文件传输连接单独启用BBR实时交互链路保持CUBIC。这个分连接调算法的做法才是真实网络环境下最稳妥的选择。6.4 一个容易被忽略的坑MSS与窗口的换算最后补充一个非常容易出错的细节窗口大小和吞吐量的换算。很多人直接拿窗口大小除以RTT来估算吞吐上限这是对的但有一个前提——窗口的单位必须理解对。如果接收窗口是65535没开缩放RTT是100ms那么理论最大吞吐就是65535 × 8 / 0.1 ≈ 5.2Mbps。这个数值远低于普通百兆网卡的能力因此大文件传输不开窗口缩放就永远跑不满带宽。可一旦开了窗口缩放能跑多快就取决于实际协商后的窗口。比如窗口是8MBRTT是100ms理论吞吐就是8 × 8 / 0.1 640Mbps。只要缓冲区水足够深TCP能撑起很大的吞吐。理解这个换算关系能帮你快速判断当前性能是否已经逼近协议栈极限而不是继续无脑调参。7. 我平时是怎么看待这三个机制的以及给你的一点建议做了这么多年网络相关的工作我一直觉得滑动窗口、流量控制、拥塞控制是TCP协议栈里最像现实生活的一组机制。滑动窗口像工厂里的流水线决定工位上能放多少半成品流量控制像下游仓库的库容仓库满了上游再能干也得停下来拥塞控制像通往仓库的道路路上一堵车流水线再快也没用。三个机制互相拉扯最后呈现出来的传输速率永远是那个最紧的瓶颈。这套理解方式在我排查高性能服务器、跨机房专线、大文件分发等问题时提供了非常清晰的定位路径。拿到任何传输慢的问题我先不问带宽够不够而是先问三个问题接收方窗口开到位没有发送方拥塞窗口卡在哪链路上有没有丢包和异常重传这三个问题一旦排序清楚80%的性能问题都能找到明确方向。最后再分享一个小习惯每次调整完TCP参数不要只看应用层的速度一定要用ss和抓包工具对比收窗口值。如果窗口和速度都对得上说明瓶颈确实在这条链路如果速度没变窗口却变得很大那大概率是链路丢包在限制你这时候换拥塞控制算法比调缓冲区有用得多。希望这篇文章能帮你少走一些弯路。
阅读完成 · 觉得有帮助?
咨询建站