ARTICLE DETAIL

资讯详情

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

TCP滑动窗口、流量控制与拥塞控制:从原理到Java调优实践

TCP滑动窗口、流量控制与拥塞控制:从原理到Java调优实践 学Java网络编程绕不过TCP面试问网络也绕不过TCP。JavaSE阶段最劝退的除了那堆Socket API就是TCP里几个听起来很专业、实际面试还总被问到的机制滑动窗口、流量控制、拥塞控制。网上讲这几个概念的文章很多但大多是各讲各的很少有人把它们的来龙去脉串在一起导致很多人背完概念还是不知道怎么用更别提面试时能讲清楚它们的关联了。这篇我把三件事从头到尾讲透它们不是孤立的考点而是TCP从“数据能到”到“数据到得既可靠又快”的完整演进路径。读完你不仅能应对面试题还能把这些机制映射到真实的Java Socket调优和线上网络问题排查里这是很多系统学习资料不会告诉你的部分。1. 先拆地基确认应答与超时重传是可靠性的根1.1 为什么TCP要自己解决可靠传输IP层是尽力而为的转发它不保证数据不丢、不重复、不乱序。数据包在网络上随时可能因为路由器缓存满、链路抖动、传输超时等原因被丢弃也有可能在某个节点迂回后先发后到还有可能被交换机或网卡复制了一份。换句话讲IP层把数据扔进一个不可靠的管道真实到达对端时顺序、完整性、唯一性全靠上层协议自己保证。TCP作为传输层协议必须在IP之上构建一个“无差错、按顺序、不重复”的可靠字节流。你可以把IP层想象成普通快递包裹寄出去可能延误、可能破损甚至直接丢了也只能认栽。TCP则像一家更负责任的快递公司必须确认每一件包裹签收签收不到就重发发到之后还按单号重新排序最后整整齐齐送到你手里。这就是TCP可靠性设计的出发点。在Java里我们体会不到这些因为调用Socket的write写出去看起来数据立刻就到了。实际上底层TCP在背地里做了大量工作确认应答、超时重传、缓存排序全都在内核协议栈里自动完成应用层感受到的只是“一个不会丢数据的管道”。但这份可靠不是白来的它靠的是一整套精确的编号和反馈机制。1.2 序列号与确认应答可靠性的最小单元TCP把要发送的字节流从0开始编号每个字节都有一个唯一的序列号。发送方在发送数据段时头部里会带上序号seq表示“这个数据段的第一个字节是整个字节流中的第几个”。接收方收到数据后会回复一个确认号ack表示“你发到这里的字节我都收到了”。对应关系大概是这样的发送方发出一个从seq1开始、长度为100字节的数据段接收方成功接收后回一个ack101。这个ack表示“序号1到100的字节全部收到下一个期望收到的字节从101开始”。这种一次确认一整段的机制叫累积确认它的好处在于不需要给每个字节单独回复ack一个确认号就能说明之前所有数据都没问题协议开销非常小。但是要注意接收方只说了“收到101之前的数据”并不保证101之后还有没有数据没到。如果接收方收到了seq201开始的段而101到200这一段丢了它会怎么回它依然会回ack101因为那才是它接下来最想要的字节。发送方如果连续收到多个ack101就意识到中间出了问题这个现象就是后面快重传的基础。序列号的另一个作用是去重接收方完全可以靠序列号识别重复到达的数据段把重复的直接丢掉不交给上层应用。序列号机制是整个TCP可靠性的基石没有它就谈不上重传和排序。1.3 超时重传丢了不能干等着光有确认还不够万一ack一直没回来呢发送方不能无限等下去于是引入了超时重传机制。发送方每发出一个数据段就启动一个重传定时器时间到了还没收到对应的ack就重新发送这段数据。这个等待时间叫RTO。RTO怎么设是个学问。设短了网络稍微抖动一下一个ack晚到几百毫秒发送方就急着重传白白浪费带宽甚至造成大量重复数据设长了数据真丢了要等半天才发现吞吐量一落千丈。所以TCP会动态估算RTT也就是一个数据段从发出到收到ack所需的时间然后根据这个时间动态调整RTO。常用的估算公式是加权移动平均新的RTT估计值取历史平均值的八分之七加上最新一次RTT的八分之一再留出足够的余量作为RTO。内核里也提供了RTO上下限具体参数在Linux的/proc/sys/net/ipv4/目录下可以看到。为什么要费这么大劲调RTO因为重传机制是TCP可靠性的“事故应急通道”但因为要等待超时反应很慢。而真正让TCP在正常传输时保持高吞吐的是下面要讲的滑动窗口机制。理解了这个顺序你才能明白TCP的可靠性不是靠“重传”撑起来的重传只是最后一道保险平时的效率全靠窗口化传输。2. 滑动窗口把“发一条等一条”升级成“批发式发送”2.1 停等协议的效率瓶颈如果把可靠性简单粗暴地实现为“发一个数据段等一个ack再发下一个”这种协议叫停等协议。功能上它完全可靠但效率低到没法用。我经常给同学算一笔账。假设网络往返时间RTT是100毫秒这个数值在普通跨省网络上很常见。每次只能发送1KB数据发完干等100毫秒的ack那么理论吞吐量就只有1KB除以0.1秒等于10KB/s。哪怕物理带宽是100Mbps甚至1000Mbps停等协议也只能跑出这个速度带宽利用率低得可怜。问题出在“等”这个动作上。发送方有大量时间在空转网络链路大部分时间都在闲着。所以工程师想到了一个思路既然RTT期间网络是空闲的为什么不在这段时间里多塞几个数据段也就是说允许发送方在没有收到ack的情况下连续发送多个数据段然后再批量收ack。这一步改良就是滑动窗口出现的动机。2.2 滑动窗口的组成与发送视图滑动窗口允许发送方一次性向网络中注入多个数据段从而把“等待时间”填满。发送方需要维护一个发送窗口这个窗口里能容纳多少字节就表示“不用等ack也能发多少字节”。从发送方的角度看字节流被窗口划分成四块区域。区域状态说明已发送且已确认窗口左边界左侧的数据已经收到ack可以彻底从发送缓存中清掉了已发送未确认已经发出去了但是在等ack这部分数据仍占用发送缓存不能删可发送未发送位于窗口内还没发出去随时可以发这是窗口空余能力的体现禁止发送窗口右边界右侧的数据不能发因为接收方还没确认有空间能接收窗口滑动的实质是当新的ack到达左边界向右移动窗口整体向右滑动释放出新的可发送区域。比如发送窗口大小是4个段先连续发出1、2、3、4此时窗口就停在1到4的位置。收到ack3后说明1、2已经被确认下一段能发的是5窗口滑到3、4、5、6的位置其中3、4还在等确认5、6是新释放出来可发送的。窗口大小直接决定“同一时刻能在网络上飞的数据量”。当RTT不变时窗口越大吞吐量越高。用公式表示就是吞吐量约等于窗口大小除以RTT。这也是为什么后来会用“带宽延迟积”来估算最优窗口——你要填满一条链路窗口至少要等于带宽乘以RTT也就是一整个往返周期内链路能容纳的字节数。2.3 接收窗口和乱序数据的处理接收方同样维护一个窗口叫接收窗口。它表示“我当前允许你发送的数据范围”。接收窗口的左侧是已经确认并交付给应用层的数据中间是接收缓冲区中为尚未到达的数据预留的空间右侧是暂时不能接受的区域。接收方收到数据后会把数据放进接收缓冲区滑动窗口并通过ack把新的窗口大小反馈给发送方。乱序到达时怎么办比如发送方发了3、4、5、6结果5先到了3、4还没到。接收方不会把5扔掉而是把它暂存在接收缓冲区里同时仍然回复ack3表示“3之前的都收到了请你重传/给我3”。TCP处理乱序是有容量的前提是乱序的数据没有超出接收窗口的范围。如果接收缓冲区被占满了后续数据就只能丢弃从而触发发送方重传这是我们要尽量避免的。从这个细节也能看出滑动窗口并不只是“提高速度”这么简单它其实是通过动态界定“哪段区间内的数据允许在网络上流动”同时处理了可靠性、流速、乱序等多个问题。不过滑动窗口的边界靠什么确定答案是接收方通告的窗口大小这就自然引出了流量控制。3. 流量控制凡事量力而行别撑爆接收方3.1 接收方窗口rwnd的反馈机制滑动窗口允许发送方连续发送但发送快了接收方不一定吃得消。接收方的应用层读数据的速度通常是不可控的如果发送方一股脑把几十MB数据灌进来接收缓冲区很快就会被填满之后到达的数据无处存放只能丢弃丢弃又触发重传网络陷入恶性循环。TCP的解决方案是在每个数据段的头部携带一个窗口字段这个字段叫rwnd也就是接收窗口大小。rwnd的值由接收方填写表示“我当前接收缓冲区还有多少空闲你最多还能发多少字节”。发送方必须遵守一个纪律网络中尚未收到ack的数据总量不能超过接收方通告的rwnd。举个例子接收方通告rwnd3000字节发送方已经发出去有500字节还没收到ack那么它最多还能再往网络里发2500字节。当接收方应用层不断读取数据接收缓冲区腾出空间后rwnd会变大而当缓冲区快满时rwnd会缩小甚至变成0。这种动态反馈就是流量控制的本质一切以接收方的处理能力为准发送方不能超发。3.2 零窗口与坚持定时器当rwnd变成0时发送方必须停下发送不然发出去全是白费。这里有个隐藏的坑发送方停了接收方什么时候再让它继续接收方会在下一次发送ack时带上新的rwnd。但万一接收方发出的“窗口已更新”这个ack丢失了呢发送方还在傻等接收方以为发送方已经收到了新窗口结果双方都停在那里谁也不发数据这个状态就是死锁。TCP解决这个问题靠的是坚持定时器。发送方在窗口为0时并不会完全沉默而是周期性地发送一个很小的探测报文通常只有1字节询问接收方“窗口更新了没有”接收方收到探测后会立刻回复一个包含最新rwnd的ack。即使这个ack丢了也没关系探测会再次发送。这个机制很像两个人隔着一堵墙聊天听不清就再喊一声确保信息迟早能传到。3.3 糊涂窗口综合征与Nagle算法流量控制引入后还有一个效率陷阱叫糊涂窗口综合征。场景是这样的接收方的应用读取数据很慢缓冲区快满了但还剩下几十字节的空地于是通告一个几十字节的小窗口。发送方收到这个小窗口就发一个几十字节的小报文。这个报文本身可能比TCP头和IP头还小数据载荷没多少却占用了完整的报文开销。发送方这边则有Nagle算法来兜底如果一个TCP连接上还有未收到ack的数据在飞那么新来的小数据会先在发送缓冲区里攒着等ack到了或者攒到了MSS大小再一并发送。这样能有效避免大量小报文打爆网络。代价是增加了延迟对于实时互动类场景不友好。所以Java里Socket提供了setTcpNoDelay(true)方法用来关闭Nagle算法让每个小消息立即发出。如果你的业务是高频、小消息、对延迟敏感的聊天或游戏同步关闭Nagle是常见做法如果是文件传输这类大量数据的场景保留Nagle反而能让网络利用率更高。到这里流量控制保证了发送方不会压垮接收方。但网络通道本身是不是能承受这么多连接同时并发传输这是流量控制管不到的必须再由拥塞控制来解决。4. 拥塞控制大家一起踩刹车才能让网络不崩4.1 流量控制和拥塞控制的本质区别流量控制针对的是“点对点”的问题发送方和接收方之间的速度匹配相当于餐厅后厨对出菜速度的自我管理避免堆太多盘子。拥塞控制则是“端到端的网络”问题它管的是整条链路甚至整个网络里所有流量会不会把路由器、交换机、骨干链路堵死。打个比方更容易理解高速公路上大家都在正常行驶突然某个出口附近车太多整个路段都堵了。这时就算你开车技术好、你的车能开到200公里每小时前面堵死了你照样走不动。每辆车的“车速”就是发送窗口而整条高速网的通过能力就是路由器和链路容量。如果所有车都开足马力网络设备会先扛不住产生大量丢包所有连接一起遭殃。所以TCP必须有一套算法让每个连接在增加自己的发送量之前先试探性地确认网络能够承受。这套机制的核心是维护另一个窗口拥塞窗口cwnd。发送方的实际发送上限是“接收方能力”和“网络容量”这两者中的最小值用公式表示就是发送窗口的上限 min(rwnd, cwnd)4.2 慢开始与拥塞避免拥塞窗口的初始值非常小通常只有1个MSS。每收到一个ackcwnd就增加一个MSS。因为一个RTT内能收到大约一个窗口那么多个ack所以每个RTT结束后cwnd大约翻倍。从1到2、4、8、16呈指数增长。这个过程叫慢开始名字叫“慢”其实增长一点也不慢是为了快速试探网络当前的可用带宽。增长不能无限继续TCP会设置一个慢开始阈值ssthresh。当cwnd小于ssthresh时执行慢开始当cwnd达到或者超过ssthresh时切换成拥塞避免阶段。在拥塞避免阶段每个RTT结束后cwnd只增加1个MSS从指数增长变成线性增长。这样做的逻辑是慢开始用来快速逼近网络能承受的流量上限逼近之后就不能再猛冲了要谨慎地试探避免一瞬间突破网络容量导致拥塞。4.3 收到重复ACK后的快重传与快恢复如果网络真的拥塞数据包就会丢失。TCP一旦检测到丢包就必须降速。这里根据丢包发生的方式不同有两种处理路径。第一种是超时重传说明网络状况非常糟糕很可能连ack都传不回来发送方不得不将cwnd直接降回1个MSS重新走一遍慢开始这是最保守的恢复方式。第二种是收到重复ack。回忆之前讲过的累积确认机制如果接收方丢了数据段3那么即使后续的4、5、6都到了它也会一直回ack3。发送方连续收到3个完全相同的ack时基本可以断定数据段3丢了。但和超时不同既然有那么多ack还能回来说明网络没有完全堵死。所以TCP采用了一个更激进的恢复方案立即重传丢失的数据段不必等超时这就是快重传。随后进入快恢复把慢开始阈值ssthresh设置为当前cwnd的一半然后把cwnd也降为减半后的值从拥塞避免阶段继续线性增长而不是从头慢开始。快重传和快恢复这套组合本质上是TCP在“确认网络还能通”的前提下尽量止血减少因拥塞造成的吞吐量损失。经典实现TCP Reno就是这样的策略Linux默认算法已经从Reno进化到CUBIC但面试和教材里常讲的还是Reno模型理解它就能理解后续所有变体的思路。4.4 四种拥塞控制策略的行为对照为了把慢开始、拥塞避免、快重传、快恢复这四种状态机之间的关系讲清楚我整理了一个表格标注了触发条件和cwnd的变化行为。机制触发条件cwnd变化慢开始cwnd ssthresh每个RTT约翻倍指数增长拥塞避免cwnd ssthresh每个RTT增加1个MSS线性增长超时重传RTO超时ssthresh减半cwnd重置为1个MSS重新慢开始快重传收到3个重复ACK立即重传丢失段ssthresh减半快恢复快重传执行后cwnd调整为减半后值直接进入拥塞避免注意这里的核心原则叫AIMD加性增、乘性减。在没发生拥塞时窗口线性增长一点一点压榨可用带宽一旦发现拥塞迹象就把窗口减半甚至清零避免雪上加霜。这个原则保证了TCP连接即便在带宽波动很大的网络里也能自动找到相对稳定的传输速率。4.5 真实环境下的拥塞控制感受很多人觉得拥塞控制是网络协议栈内部的事Java程序员不用关心。我的经验是完全相反你在线上遇到大量“连接超时”或者“传输忽快忽慢”的时候背后大概率就有拥塞控制的影子。比如一个服务器同时处理上千个连接某段时间突发请求量暴涨网络设备丢包率上升所有连接都会进入拥塞避免阶段你观察到的就是客户端频繁重传、响应突然变慢。另外cubic和BBR这类现代算法也会直观体现在传输速度上。BBR通过持续探测链路带宽和最小延迟能跑出比传统算法更高的吞吐。如果你在云服务器上做大数据量传输想验证某个连接实际用了多大的拥塞窗口用ss -tin命令就能直接看到。5. 真机观测与Java编程中的常见坑5.1 用ss命令看到真实的窗口值理论讲得再多不如动手看一次。Linux下有个非常好用的命令ss可以直观看到TCP连接的窗口信息。ss -tin输出里会包含类似cwnd:20 rwnd:65536这样的字段分别代表当前拥塞窗口大小和接收窗口大小。如果在传输大文件时观察你会发现cwnd会波动这就是慢开始、拥塞避免或者拥塞恢复触发的迹象。如果rwnd长期处于很小的值说明接收方应用读取数据不及时瓶颈在应用层而不是网络带宽。Windows上虽然没有直接等价的ss命令但用Wireshark抓包时同样可以在TCP段头看到Window字段以及三次握手时协商的窗口缩放因子。窗口字段只有16位最大只能表示65535字节远远不够高速网络的带宽延迟积所以TCP引入了窗口缩放选项把通告窗口左移若干位。抓包时看到Window可能显示数字乘以缩放因子才是实际窗口大小。5.2 Java Socket缓冲区参数与窗口的关系Java层面的Socket.setReceiveBufferSize()和Socket.setSendBufferSize()方法分别对应系统底层的SO_RCVBUF和SO_SNDBUF。SO_RCVBUF的大小会直接影响接收通告窗口rwnd的上限因为rwnd不能超过接收缓冲区的容量。想提高接收吞吐把receiveBufferSize调大通常是有效的。但有几个细节必须注意。第一SO_RCVBUF设置的是一个期望值内核通常会双倍分配比如你设为64KB内核可能实际分配128KB取中间值还是取双倍取决于操作系统版本。第二发送缓冲区设置过大并不意味着就能跑满带宽因为发送侧还受cwnd和网络延迟约束真正的上限是两者中的最小值。第三在做性能调优时光调Java参数作用有限最好同时调整系统级的/proc/sys/net/ipv4/tcp_rmem和tcp_wmem参数让内核默认值符合业务预期。5.3 TCP高频报错与排查思路平时在JavaE阶段练习和实际项目中大家总会遇到一些非常眼熟的TCP报错下面这几类几乎是必踩的坑。connect timed out这一类通常是发送方的SYN请求一直没收到响应。可能原因包括目标IP不可达、防火墙丢掉SYN包、目标端口处于关闭状态或者SYN队列满了。排查顺序建议是先ping测通不通再用telnet ip port或者nc -vz ip port试端口最后在目标机上用ss -lnt确认端口是否在监听。bind: only one usage of each socket address这种错误出现在服务端启动时通常是因为端口已经被占用或者上一次程序退出后连接还停留在TIME_WAIT状态。TIME_WAIT本身是TCP为保证旧连接的数据不会串到新连接而设计的状态会持续一段时间。Java里可以通过ServerSocket.setReuseAddress(true)设置地址重用缓解端口被占用的问题但真正解决思路是找出占用端口的进程然后优化连接关闭方式。还有一个容易被忽略的问题高并发下客户端抛出“Connection reset”往往是因为服务端接收缓冲区满了直接关闭了连接或者对端应用没有及时读取数据。这本质上就是流量控制机制在极端情况下的表现理解了rwnd和接收缓冲区的关系排查起来心里就有谱。我整理了一张问题速查表方便大家平时对照。现象可能原因排查方向connect超时网络不通、SYN被防火墙丢弃、目标不存在ping、telnet/nc测试端口、抓包看SYN有没有响应bind地址被占用端口已被占用或处于TIME_WAITss -lntp、netstat -ano、改端口或开启SO_REUSEADDR吞吐量上不去接收缓冲区太小、拥塞窗口受限、链路延迟太大调SO_RCVBUF/SO_SNDBUF、用ss看cwnd/rwnd、算带宽延迟积大量timeout和重传网络拥塞、丢包严重、应用层处理慢抓包看重复ACK、检查丢包率、排查应用读取逻辑Connection reset对方缓冲区满被强制关闭、中间设备拦截看接收缓冲区和rwnd、确认防火墙、检查对端连接关闭位置5.4 调优TCP的几个实用经验最后分享几个我自己实际用过的调优方向。如果你要传输大文件先算一下带宽延迟积也就是带宽乘以RTT这个积决定了理论最优窗口大小。假设带宽是100MbpsRTT是50毫秒那么带宽延迟积差不多是0.625MB窗口至少要这么大才能跑满带宽。如果你在Java里写的是长连接、双向通信的服务优先考虑关闭Nagle算法也就是调用setTcpNoDelay(true)否则小消息会被攒住造成明显的延迟。但要注意Nagle算法只是发送端的行为接收端的延迟确认也可能造成额外等待二者需要配合调整。Java自带的Socket参数能做到的其实有限真正细粒度的优化往往需要调整系统内核参数比如增大tcp_rmem、tcp_wmem或者修改TCP拥塞控制算法Linux上可以通过sysctl net.ipv4.tcp_congestion_control来查看和设置。这些调优不是一蹴而就的每次只改一个变量压测对比效果才是靠谱的做法。我个人在实际调试里的体会是TCP这三个机制从来不是单独工作的。滑动窗口决定了你“最多能发多少”流量控制决定了“按接收方的胃口应该发多少”拥塞控制又决定了“按网络的脾气现在适合发多少”。你把这一条链路想清楚了再回头敲Java的Socket代码很多以前靠背的结论比如“不要频繁创建连接”“调大缓冲区不一定提速”“连接关闭要规范”就都有了理论依据。这也是我强烈建议每个学JavaSE的人在写网络代码之前认真过一遍这几个机制的原因它不是纯理论而是让你少踩无数坑的底层知识。
返回列表