尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TCP重传机制详解:ARQ、快速重传与SACK全解析

TCP重传机制详解:ARQ、快速重传与SACK全解析 先讲一个我自己的排障经历可能不少搞网络的人都有同感。前两年帮客户排查一个内网大文件传输慢的问题应用层写的是TCP两端都是千兆网卡按理说应该能跑到八九百兆但实际只有两三百兆。抓包一看吓一跳几十秒的流量里有一大片Dup ACK和TCP Retransmission。再仔细数一数发送窗口里的数据段丢了三四个接收端反复说“我缺某一段”发送端却只能一段一段地重传等完这个等那个整个传输就像堵车一样走不动。那一刻我意识到理解TCP的ARQ自动重传机制真的不是背几个概念那么简单。这也就是我今天想和你聊透的话题。ARQ全称Automatic Repeat reQuest自动重传请求是TCP保证可靠传输的核心手段。TCP在IP这个“尽力而为”的网络上跑丢包、乱序、重复都是家常便饭所以必须靠确认和重传来兜底。而这套兜底机制里实际可以拆成三种层层递进的实现方式超时重传、快速重传以及选择性确认加选择性重传也就是SACK。本文会把这三种方式的工作原理、参数计算、优缺点和抓包表现全部掰开揉碎最后附上我踩过的坑和排查命令。不管你是刚学TCP传输控制协议的新人还是经常需要在现场分析Modbus TCP、S7通信、通用socket程序慢或卡问题的工程师这篇文章应该都能给你一些直接可用的参考。1. 重传机制为什么是TCP的“保命符”1.1 可靠传输的最后一公里是“重传”TCP要解决的核心问题是IP层只提供“尽力而为”的传输也就是说数据包从A到B的路上可能被路由器丢弃、可能因为网络拥塞被主动甩掉、也可能在队列里被超时清空。正常网络环境下丢包率可能不到千分之一但在弱网、Wi-Fi、跨运营商线路、或者现场电磁干扰严重的工业环境里丢包率飙到百分之一甚至更高都很常见。既然底层会丢TCP就必须自己做两件事第一让接收方告诉发送方“我收到了哪些数据”这就是确认ACK第二让发送方知道“哪些数据对方没收到”然后把这些数据再发一遍这就是重传。确认加重传合在一起就是ARQ。具体到TCP的实现确认机制用的是“累计确认”意思是接收方回复的ACK序号不是某一段的编号而是“我下一个期望收到的字节序号”。举个例子接收方收到了字节1000到1999它就会回复ACK 2000表示“2000之前的字节我都收到了你接着发2000”。如果接下来又收到了2000到2999就会回复ACK 3000。这个累计机制有个副作用如果中间丢了一段后面的数据先到了接收方也只能上报“我最前面的缺口在哪”而不能完整表达“哪些我先收到了”于是就有了后续的各种扩展机制我们后面慢慢讲。1.2 教科书的三种ARQ模型TCP实际上走了第三条路大学计算机网络课里讲的ARQ一般有三种理想模型停等ARQ、回退N帧ARQGo-Back-N、选择性重传ARQSelective Repeat。停等协议是发一个等一个确认效率太低回退N帧是发现丢包后把后面已经发出去的数据全部重发选择性重传则只重发真正丢失的那几段效率最高。但TCP并没有像教科书里那样直接实现某一种。它的实际路线是以超时重传为最终兜底用快速重传把丢包检测提前再用SACK实现“近似选择性重传”。你可以这么理解超时重传是“不知道对方收没收到等得太久就全量补发”快速重传是“听到对方连续喊了好几遍没收到就赶紧补发最早缺的那一段”SACK则是“对方直接画了一张地图告诉你哪几块收到了、哪几块没收到你按图索骥精确补发”。所以下面三章我们就按TCP实际的演进路径把超时重传、快速重传、SACK这三种实现方式逐个拆开。2. 超时重传最朴素但最耗时的兜底方案2.1 工作原理与定时器到期没确认就重发超时重传是TCP最古老、也最基础的重传方式从1981年的RFC 793就有。它的逻辑非常简单发送端发出一个数据段后启动一个重传定时器如果在定时器到期之前没有收到对这个段的有效确认就认为这个段丢了或者确认丢了于是重新发送一遍。如果重传后还是没有收到确认就再重传而且每次重传的等待时间都会翻倍这就是所谓的指数退避。定时器的时间间隔就叫RTORetransmission Timeout重传超时时间。RTO不能是一个固定值因为网络状况是动态变化的所以TCP要不断测量RTTRound-Trip Time往返时间再根据RTT的波动情况动态调整RTO。在内网通畅时RTT可能只有0.2毫秒在跨省公网上RTT可能是30毫秒在卫星链路上RTT甚至可能到500毫秒以上。如果RTO设得太小数据还在路上就超时了结果造成大量虚假重传如果设得太大真的丢包了又迟迟发现不了吞吐量会严重下降。2.2 RTT估计和RTO计算一个公式看懂它RTO的计算在1988年的Jacobson算法里定型后来经过RFC 2988和RFC 6298的微调核心思想是使用指数加权移动平均EWMA来平滑RTT波动。公式是这样的SRTT (1 - α) × SRTT α × RTT // 平滑后的RTT估计值 RTTVAR (1 - β) × RTTVAR β × |RTT - SRTT| // RTT的方差估计 RTO SRTT max(G, 4 × RTTVAR) // 最终的超时时间其中α取1/8β取1/4G是定时器的时钟粒度。可以看到RTO并不是简单等于“平均RTT”而是在平均RTT基础上加上4倍的抖动偏差。这样做是为了自适配网络越稳定RTT方差越小RTO越接近SRTT丢包后能快速发现网络越抖动RTO越要放大否则容易误判超时。另外还有一个特别重要的规则叫Karn算法如果某个段发生了重传那么收到这个段的ACK时不能用这次RTT样本去更新SRTT和RTTVAR。原因是这个ACK可能是针对第一次发送的段回的也可能是针对重传的段回的你无法区分贸然用来更新RTT估计会让RTO算得不准。标准文档里RTO的最小值是1秒但Linux内核在实际实现中允许调低比如在低时延数据中心网络里可以把tcp_rto_min设置到200毫秒甚至更低。要注意超时重传时的等待代价是非常高的尤其在RTO已经因为连续超时翻倍到好几秒的情况下整个连接可能陷入“假死”状态明明链路恢复了TCP也得等好一阵子才能反应过来。2.3 超时重传的代价带宽空转与指数退避超时重传最大的问题就是它“发现丢包”非常迟钝。设想一个场景RTT是10毫秒RTO算出来是200毫秒现在第5个数据段丢了。发送端会一直发送后续数据直到窗口用完然后停下来干等。接收端因为没有第5个段后续就算收到了第6、7、8段也只能反复回复“我期望第5段”但发送端此时并不能立刻确定第5段丢了一定要等到RTO超时那一刻才反应过来。这段时间里发送窗口是停摆的网络带宽在空转吞吐量直接掉一个数量级。更要命的是指数退避第一次超时等200毫秒重传后再超时等400毫秒再超时等800毫秒如果链路持续不稳定几次之后RTO就膨胀到好几秒用户体验就是“传输卡死了过几秒又动一下”。我在实际抓包里见过不少类似案例一个明明只有几百MB的文件因为网络丢包率偏高RTO膨胀到4秒以上传输时间从几秒拉长到十几分钟。这种场景如果只靠超时重传是完全不能接受的。所以TCP必须有一种更快速发现丢包的机制这就是快速重传。3. 快速重传用重复ACK把“丢包通知”提前3.1 三个重复ACK乱序和丢包的博弈平衡快速重传的出发点是与其等RTO超时不如利用接收端回复的重复ACK来判断丢包。TCP里有个规律接收端每收到一个“不是自己当前期望序号”的数据段就会回复一个包含“当前期望序号”的ACK。也就是说如果发送端一直收到重复的ACK就说明接收端已经收到了某个缺口之后的数据而这个缺口很可能就是丢了。但事情没有这么简单。在网络中数据段乱序到达是一种很常见的现象前面的段还没到后面的段先到了接收端同样会回复重复ACK。如果发送端一看到重复ACK就立刻重传就可能把一个只是“稍微迟到”的数据段误判为丢失造成大量无谓重传反而加剧网络拥塞。所以TCP采用了一个阈值当发送端连续收到3个重复ACK也就是除了第一个原始ACK之外再收到3个相同的ACK一共连续4个相同ACK时才认为某个段确实丢了触发快速重传。这个“3”是经过实践验证的平衡点正常情况下网络乱序深度很少超过3个段超过3个重复ACK仍然等不到那个缺失的段那么它大概率是丢了。3.2 运作细节它只在“最老”的缺口上补刀快速重传触发时发送端会立即重传当前窗口内序号最小的那个未确认段也就是“最老的缺口”。同时这套机制和拥塞控制是联动的进入快速恢复Fast Recovery流程把拥塞窗口减半而不是降到1这样丢包后吞吐量不会断崖式下跌。快速重传最大的价值在于它把丢包发现时间从“一个RTO”缩短到“约等于一个RTT”。假设RTT是20毫秒RTO是200毫秒不用快速重传时发现丢包要200毫秒用了之后只要大概20到40毫秒就能重传。这个时间差在高速长肥网络里非常可观直接决定吞吐量是几百兆还是几十兆。举一个具体数字。发送端连续发出序号1000、2000、3000、4000、5000五个段其中2000这段丢了。接收端会先确认段1000回复ACK 2000。接着段3000到了接收端还是回复ACK 2000。段4000到了仍然回复ACK 2000。段5000到了依然是ACK 2000。发送端在收到第3个重复ACK 2000时就能断定段2000丢了立刻补发。这个流程在抓包里看得非常清楚你会看到一大堆“dup ack 2000”然后跟着一个“Retransmission for segment 2000”。3.3 两个致命短板每次精确处理一个丢包但快速重传并不是万能的。它有两个很明显的短板。第一个短板是快速重传一次只能精确处理一个丢失段。当一个发送窗口内同时丢了多个段时比如上面的例子里段2000和段4000都丢了快速重传只会补发段2000。段4000呢它要等到重传的段2000到达接收端后接收端才有可能把ACK推进到3000然后发送端才能从后续的重复ACK里发现“段4000也丢了”再触发一次快重传。也就是说多个丢包场景下快速重传需要多轮RTT才能把所有缺口补完严重时吞吐量依然惨不忍睹。第二个短板是快速重传本身并不能让发送端得知“缺口之外还有哪些数据已经安全到达”。接收端能表达的信息只有“我最期待的序号是哪个”至于后面那些乱序到达的数据块它收了没、收了哪些发送端一概不知。于是发送端就只能靠猜猜测错误就会导致不必要的重传浪费带宽。这也引出了咱们的重头戏SACK。SACK的出现就是为了补上“接收端到底收到了哪些数据”这块信息拼图。4. SACK让对端把“我已收到的”画成一张地图4.1 快重传的局限就是SACK的出发点SACK的全称是Selective Acknowledgment选择性确认。它的思路很直接接收端在ACK里额外携带一个选项告诉发送端“我已经收到的所有乱序数据块的边界”发送端收到后就能精确知道自己哪些段还需要重传哪些段已经在接收方手里不用再靠猜。在SACK出现之前TCP能表达的信息确实太少。接收端面对一个乱序到达的包群只能反复重复“我最期望的是X号段”至于X之后的那些段它其实已经收到了但因为累计确认机制的限制表达不出来。发送端不知道这个信息就会做出两种错误行为要么把已经到达的数据又发一遍浪费带宽要么漏掉某个丢失的段一直等它超时。SACK就是来解决这两类问题的。4.2 握手协商SACK Permitted在三次握手里敲定SACK不是一个默认开启就能用的东西它需要在TCP三次握手阶段进行能力协商。协商的原理是发起方在SYN报文里带上SACK Permitted选项如果接收方也支持SACK就在SYNACK报文里也带上SACK Permitted选项第三次握手的ACK报文里是否携带则没有严格要求。注意SACK的支持是“双向独立”的。也就是说A到B这个方向能不能用SACK和B到A这个方向能不能用取决于握手时双方各自声明的情况。如果有一方不支持那么这条TCP连接里SACK就不会启用。我在现场排障时经常遇到一种情况客户端和服务器都是现代操作系统看起来都支持SACK但抓包发现双方成交后完全没有SACK块。后来仔细一查发现是中间链路上的某种设备或者某些安全策略把TCP选项给剥掉了。这里提醒大家如果确认通信双方都开启了SACK但抓包看不到SACK那就要往中间网络设备上去排查了。4.3 SACK块用左右边界画出一张已收地图SACK选项在TCP头里的格式Kind值为5长度字段可变。每个SACK块由两个32位的序号组成左边界和右边界。左边界表示这个已收数据块的第一个字节序号右边界表示这个数据块最后一个字节序号加1使用的是左闭右开区间。一个SACK选项最多可以携带4个SACK块这是因为TCP头选项区域总共只有40字节除去2字节的选项头和可能存在的其他选项比如时间戳4个块已经是一个比较优化的上限。如果多个块同时存在它们的排列顺序也有讲究第一个块是接收端最近收到的新数据块后面的块则按左边界升序排列。这个排序规则对发送端判断“是否有新数据到达”很有用。用抓包示例来解释假设接收端已经收到序号1000到1999然后收到了3000到3999、4000到4999、5000到5999三段由于2000到2999这一段缺失它回复的报文大概是这样的ACK 2000 nop,nop,sack 1 {3000:6000}这个SACK块的意思是“我虽然下一个期望的字节是2000但3000到5999这些字节我已经收好了”。等一下三个块为什么只显示一个SACK块因为3000到3999、4000到4999、5000到5999在序号上是连续的接收方在整理时会把它们合并成一块报告为3000:6000。只有真正不连续的多个洞才会被报告成多个SACK块。如果又有6000到6999到达那么接收方就可以报告两组块比如ACK 2000 nop,nop,sack 1 {3000:6000} {7000:8000}当然这是比较理想的情况。实际抓包里你会看到SACK块常常伴随着时间戳选项一起出现tcpdump会显示成“nop,nop”这种东西那些是为了字节对齐填充的无操作选项不影响语义。4.4 Scoreboard从通知机制到选择性重传的落地接收端提供了SACK信息发送端怎么用起来这里的关键概念是Scoreboard我习惯叫它“重传记账本”。发送端在内部维护每个数据段的发送状态已确认、已SACK、未确认未SACK、已重传。收到一个SACK块之后发送端就把对应序号范围的数据段标记为“已SACK”意思是“对端说收到这块了不用再发”。当需要重传时发送端遍历记账本只重传那些既没有收到ACK、也没有被SACK标记的段。这样一来多丢几个包也没关系只要SACK信息足够发送端就可以在一个RTT内同时补发所有缺失的段真正实现了教科书意义上的“选择性重传”。还要提一个经常被忽视的扩展机制DSACK定义在RFC 2883。它的作用是让接收端告诉发送端“你重传的这段数据我其实之前已经收到过了。”DSACK能帮助发送端发现虚假重传、网络重复包、以及某些异常路径问题对链路的“自我认知”很有价值。4.5 手把手拆一个抓包案例下面用一个实际场景把SACK的价值串起来。假设发送端窗口里有5个数据段序号分别是1000、2000、3000、4000、5000每段1000字节。现在第2段2000和第4段4000都丢了。先看超时重传。发送端发完5个段后接收端会收到1000、3000、5000于是连续回复ACK 2000并附上SACK信息如果启用。但发送端在不启用SACK的情况下只能按快重传逻辑收到3个重复ACK后重传2000。重传第2段到达后接收端继续回复ACK 3000附上SACK块表明5000甚至后面更多段已经收到。这时候发送端才能知道第4段也丢了再等一轮重复ACK触发第二次重传。整个流程至少需要2到3个RTT而且一旦某个重传段再丢失成本还要翻倍。再看启用了SACK的情况。接收端收到1000、3000、5000后会回复ACK 2000 sack 1 {3000:4000} {5000:6000}发送端一看这两块立刻就知道2000和4000都丢了于是同时重传这两段。整个恢复过程只需要1个RTT左右效率天差地别。我实测过一个场景内网环境用iperf3打流人为注入1%的随机丢包。关闭SACK时TCP吞吐量从接近线速掉到原来的十分之一以下抓包基本全是Retransmission和Dup ACK开启SACK后同样1%丢包吞吐量虽然也下降但能稳定保持在不丢包时的一半以上。别小看这个差距在长肥网络或者弱网环境里SACK开启与否常常就是“能不能用”和“完全不可用”的区别。5. 三种实现方式的横向对比与选型建议5.1 一张表看透三种实现三种重传方式不是替代关系而是同时存在、互相配合的关系。为了让你一眼看清差异我做了一个对比表对比维度超时重传快速重传SACK重传触发信号RTO定时器到期收到3个重复ACK握手协商后通过ACK中的SACK块反馈发现丢包速度慢通常数百毫秒到数秒较快约等于1个RTT较快约等于1个RTT反馈信息粒度只有“没收到ACK”知道“最老的缺口在哪”知道“所有缺口和已收块”一次能重传几个缺口理论上一个段重传完再等通常一个段多处丢包时要多轮多个缺口一个RTT内批量补发额外开销无选项开销无选项开销占用TCP选项空间最多4个块误判风险不会误判但反应太慢乱序深度大时可能误判能修正大部分误判配合DSACK更好适用场景任何TCP连接的兜底单点丢包为主的网络多丢包、高带宽时延积网络表格看下来就很清楚超时重传是永远不能关掉的最后一道保险快速重传是把丢包发现时间从RTO压缩到RTT的提速器SACK则是精细化重传的放大器。三者层层递进协同工作。5.2 内核参数怎么调以及一个容易混淆的“busy”场景在Linux服务器上这几个机制基本都是默认开启的对应的内核参数如下参数作用默认值net.ipv4.tcp_sack是否启用SACK1开启net.ipv4.tcp_dsack是否启用DSACK1开启net.ipv4.tcp_fack是否启用FACK重传1开启net.ipv4.tcp_retries1决定放弃前尝试重传的次数3net.ipv4.tcp_retries2决定超时前尝试重传的次数15我的建议很简单不到万不得已不要关闭SACK。我在不少生产环境里看到有人因为某个“优化指南”把tcp_sack改成0结果多丢包场景下吞吐直接崩溃。SACK是经过几十年验证的成熟机制现代TCP协议栈对它的管理已经非常精细。最后再纠正一个容易踩坑的认知。有时候你在现场遇到类似“博图S7-1500通过TSEND_C发送TCP数据太慢一直返回busy”这种问题本能反应会怀疑是不是重传机制有问题抓了一堆包发现网络很干净没有丢包也没有重传。这种情况大概率不是ARQ的问题而是发送缓冲区或者对端接收窗口满了TSEND_C在等待缓冲区释放所以一直busy。排查顺序一定不要乱先看对端窗口是否为零窗口再看发送缓冲区大小最后才轮到检查重传统计。丢包导致的重传和流控导致的busy是两回事混在一起查会绕很多弯路。6. 排查实录抓包里的Dup ACK和SACK怎么判6.1 重复ACK不一定是丢包先判断乱序这是很多新手最容易翻车的地方。抓包看到连续几个Dup ACK就断定“有丢包了”其实不一定。乱序到达同样会触发重复ACK。判断方法有两个参考维度。第一看乱序的幅度。如果缺失的那个段的序号很快就到了比如只落后两三个段那多半是网络乱序不是真的丢包。第二看SACK块的演变。如果后续SACK块里那个“缺口”很快被填充并且ACK序号很快往前推进说明只是乱序如果ACK序号在某个位置停住了SACK块里的边界不断增加但那个缺口始终没有被填上那才是真的丢了。简单说重传与否不能只看Dup ACK的数量还要结合时间窗口和后续ACK推进情况综合判断。6.2 快重传没触发、SACK没生效的常见原因快重传没有触发最常见的原因是重复ACK数量不够。默认阈值是3如果你抓包只看到1到2个重复ACK然后发送端就超时了那说明重复ACK数量不足以触发快重传。这经常发生在窗口比较小、在途数据本来就少的场景下。发送端等不到足够的Dup ACK就只能靠超时。SACK没生效的原因就要多排查几个环节了一是TCP握手时没有协商SACK Permitted双方或中间设备把它屏蔽了二是传输过程中某些网络设备修改或剥离了TCP选项三是应用本身用了原始socket且没有启用SACK扩展比如某些嵌入式协议栈默认关闭四是本地或对端内核参数被改过把tcp_sack设成了0。排查时可以先用两端抓包确认握手包再逐段核对中间设备不要一上来就怀疑内核。6.3 排查命令与统计解读实际排查时我用的最多的命令是tcpdump和netstat。抓包时建议加几个关键过滤条件# 抓指定端口的TCP流量只看重传和重复ACK sudo tcpdump -i eth0 -nn tcp port 8080 -w /tmp/tcp.pcap # 打开pcap后过滤显示重传 tcpdump -nn -r /tmp/tcp.pcap tcp[tcpflags] (tcp-tcpflags) tcp-syn or tcp[tcpflags] (tcp-tcpflags) tcp-ack更推荐的做法是抓完包后用Wireshark打开直接看Expert Info里的“Retransmission”“Duplicate ACK”“Out-Of-Order”这些提示。不要只看颜色标记要展开TCP头里的SACK选项看SACK块的左右边界和ACK序号之间的关系。查看当前连接的重传统计可以用netstatnetstat -s | grep -E retrans|sack这个命令会显示系统级别的重传次数、SACK恢复次数等。如果sack recovery count长期为0而retrans长尾又特别多就说明SACK实际没有起到作用值得深入分析。6.4 用tc netem做一次可控的丢包实验如果你想在本地环境验证三种重传方式的差异强烈推荐用tc的netem模块做可控丢包实验。这个工具在Linux上可以直接用不需要专门搭一台丢包器。比如我想给eth0网卡注入随机1%的丢包率sudo tc qdisc add dev eth0 root netem loss 1%实验做完后记得删掉否则会影响正常网络sudo tc qdisc del dev eth0 root接下来用iperf3或scp在两个主机间传输大文件分别执行“关闭SACK”和“开启SACK”两组对比抓包对比重传次数和吞吐量。关闭SACK的命令是sudo sysctl -w net.ipv4.tcp_sack0跑完再开启sudo sysctl -w net.ipv4.tcp_sack1我做过一组数据不丢包时吞吐接近1Gbps注入1%丢包且关闭SACK后吞吐掉到80Mbps左右重传包占整个流量的比例高得吓人开启SACK后同样1%丢包吞吐能回到450Mbps以上重传包比例也大幅下降。自己做一遍这个实验对三种重传机制的理解会扎实很多。细心的朋友可能会问netem只模拟了随机丢包没模拟RTT和带宽确实是这样。但作为机制验证它已经足以说明SACK的价值。如果要更贴近真实弱网可以再加上延迟和抖动参数比如sudo tc qdisc add dev eth0 root netem loss 1% delay 50ms 10ms distribution normal这样就把“丢包延迟”两个因素同时注入测出来的效果更接近公网传输的实际情况。说了这么多最后分享一个我自己的体会。每次看到抓包里有大面积的Dup ACK我都会先压住“这个网络很烂”的判断把SACK块一个个展开看。SACK块就是接收端写给发送端的情报它清清楚楚地告诉你哪些字节已经安全落袋哪些字节还有一个洞没补上。多盯几次你对TCP重传机制的理解就会从“知道概念”变成“能预判行为”。如果手头有条件强烈建议自己开两个虚拟机用tc netem注入丢包亲手把三种重传方式的抓包对比做一遍。理解ARQ最好的老师不是博客是你自己抓的那份pcap。
返回列表