1. 从一个让人抓狂的传输现象说起
做网络协议调优的人,几乎都遇到过这样一种场景:明明链路带宽没跑满,发送端却像被踩了刹车一样,速度死活上不去;抓包一看,满屏都是重复ACK,偶尔还夹杂着几个RACK相关的标记。第一次碰到这种情况的时候,我盯着Wireshark的序列号看了整整一个下午,才慢慢摸清楚这背后其实是两套丢包判定机制在互相配合,又在某些情况下互相打架。
RACK,全称Recent Acknowledgment,中文一般叫“最近确认”,是近几年在传输层拥塞控制里越来越重要的一套丢包检测思路。它和传统的重复ACK机制不是替代关系,而是互补关系。重复ACK负责快速发现“中间丢了一个包”,RACK负责兜底那些重复ACK覆盖不到的边角场景,比如尾部丢包、乱序严重、ACK被延迟合并等等。理解这两者的分工和配合,是排查传输性能问题的基本功。
这篇文章适合谁看?如果你在做网络性能优化、写过自定义的可靠传输协议、调过拥塞控制算法,或者只是单纯被“为什么带宽跑不满”这个问题折磨过,那接下来的内容应该能帮你省下不少抓包和试错的时间。我会从设计思路讲到具体参数,再讲到实际排查时怎么区分到底是重复ACK在起作用还是RACK在起作用,尽量把踩过的坑都摊开说清楚。
2. 重复ACK与RACK的整体设计思路拆解
2.1 重复ACK到底在解决什么问题
重复ACK的核心逻辑其实很朴素:接收端每收到一个乱序的报文,就立刻回一个ACK,告诉发送端“我还在等你前面那个没到的包”。发送端连续收到三个针对同一个序列号的ACK,就判定这个包丢了,触发快速重传,不用等超时定时器。
这套机制的精妙之处在于它把丢包检测的延迟从“超时时间”压缩到了“三个报文往返”的量级。在没有重复ACK之前,发送端只能靠重传超时来发现丢包,那个时间通常是几百毫秒甚至秒级。三个重复ACK把发现时间降到了毫秒级,这是传输性能的一次巨大飞跃。
但重复ACK有个天生的软肋:它依赖“后续报文还能继续到达接收端”。如果丢的是发送窗口里的最后一个包,后面没有新报文了,接收端就凑不出三个重复ACK,发送端只能干等超时。这就是所谓的“尾部丢包”问题。另一个软肋是乱序:如果链路本身乱序严重,接收端会因为乱序产生大量重复ACK,发送端可能误判丢包,触发不必要的重传。
2.2 RACK为什么被引入
RACK换了一个完全不同的判定角度。它不看“收到了几个重复ACK”,而是看“时间”。核心思想是:如果一个报文发出后,经过足够长的时间,比它更晚发出的报文都已经被确认了,那这个报文大概率是丢了。
这个“足够长的时间”不是固定的,而是根据当前连接的往返时延动态计算的。RACK维护一个“最近被确认的报文”的时间戳,当收到新的ACK时,如果某个未被确认的报文发送时间比这个最近确认时间早了超过一个RTT加上一点余量,就判定它丢了。
RACK最大的优势是不依赖后续报文的到达。哪怕丢的是最后一个包,只要之前有报文被确认过,RACK就能根据时间差把它揪出来。这就补上了重复ACK在尾部丢包场景下的短板。同时,RACK对乱序的容忍度也更好,因为它用的是时间窗口而不是计数窗口,偶发的乱序不会轻易触发误判。
2.3 两者为什么必须配合而不是二选一
有人可能会想,既然RACK这么强,为什么不干脆只用RACK?原因在于RACK的判定需要时间积累,它的反应速度天然比重复ACK慢。重复ACK在理想情况下三个报文往返就能触发重传,而RACK至少要等一个RTT加余量。对于中间丢包这种重复ACK能快速处理的场景,用RACK反而会拖慢恢复速度。
所以实际系统里的做法是:重复ACK负责快速路径,第一时间发现并重传中间丢包;RACK负责慢速路径和兜底,处理重复ACK覆盖不到的尾部丢包、乱序误判、ACK合并等场景。两者共享同一个重传队列和拥塞窗口,谁先判定丢包谁就触发重传,另一个看到包已经被重传了就自动跳过。
这种配合关系决定了排查问题时不能只看一个指标。你看到重复ACK多,不代表RACK没在工作;你看到RACK触发了重传,也不代表重复ACK机制失效了。得把两者的触发条件和时间线放在一起看,才能还原出真实的传输行为。
3. 核心细节解析与实操要点
3.1 重复ACK的触发条件与边界情况
重复ACK的判定逻辑看起来简单,但边界情况特别多。标准定义是“收到三个重复ACK触发快速重传”,但实际实现里有很多变体。比如有些实现会在收到第二个重复ACK时就进入“快速恢复”的预备状态,收到第三个才真正重传。还有些实现会根据拥塞窗口的大小动态调整这个阈值。
一个容易被忽略的细节是:重复ACK的计数是针对同一个序列号的。如果接收端因为乱序回了ACK 100、ACK 100、ACK 120、ACK 100,那针对100的重复ACK只有三个,但中间夹了一个120,有些实现会把计数器重置。这个行为在不同协议栈里不一致,排查时如果发现重传没触发,先确认一下重复ACK的计数逻辑。
另一个关键点是SACK(选择性确认)的配合。开了SACK之后,接收端可以在ACK里附带“我已经收到了哪些块”的信息,发送端就能更精确地判断丢的是哪个包,而不是靠重复ACK的数量猜。SACK和重复ACK是互补的,SACK让重传更精准,重复ACK让触发更及时。
注意:如果你的抓包里看到大量重复ACK但重传很少,先检查SACK是否开启。SACK开启后,很多重复ACK会被SACK块“解释”掉,发送端知道那些包只是乱序不是丢了,就不会触发重传。
3.2 RACK的时间窗口计算与参数选择
RACK的核心参数是那个“时间余量”,通常记作reo_wnd或者类似的名称。它的计算方式是:先取当前连接的平滑RTT,然后乘以一个系数,再加上一个固定的小偏移。这个系数和偏移的选择直接决定了RACK的敏感度和误判率。
系数太小,RACK会把正常乱序误判成丢包,触发不必要的重传,浪费带宽还加重拥塞。系数太大,RACK反应迟钝,尾部丢包要等很久才能被发现。实际调优时,我一般会先看链路的乱序程度:如果乱序主要来自多路径或者无线链路,系数要适当放大;如果链路很干净,系数可以收紧。
还有一个容易被忽略的参数是RACK的“时间基准”怎么选。有的实现用最近被ACK的报文的发送时间,有的用最近被ACK的报文的确认时间。这两个选择在ACK延迟合并的场景下差异很大。用发送时间做基准,RACK会更激进;用确认时间做基准,RACK会更保守。排查时如果发现RACK触发过于频繁,可以先确认一下基准的选择。
3.3 两者共享状态时的冲突处理
重复ACK和RACK共享重传队列,这就带来一个状态同步问题。假设重复ACK先判定包A丢了,触发了重传,重传的包还在路上,RACK又根据时间差判定包A丢了,这时候如果不做去重,就会重传两次。
实际实现里通常用一个“已重传”标记来避免重复。但这里有个微妙的时序问题:如果重复ACK触发的重传包本身也丢了,RACK再次判定时,那个“已重传”标记还在,RACK就会跳过,导致这个包永远不被重传,只能等超时。这个场景在丢包率较高的链路上偶尔会出现,表现就是某个包的重传延迟异常大。
解决办法通常是给“已重传”标记加一个有效期,超过一定时间后允许再次重传。这个有效期的设置需要权衡:太短会导致重复重传,太长会导致上面说的死锁。我一般会把它设成一个RTT左右,实测下来在大多数链路上都能兼顾。
3.4 实操中怎么区分两者的贡献
排查传输问题时,最实际的需求是搞清楚“这次重传到底是重复ACK触发的还是RACK触发的”。抓包工具通常不会直接告诉你,但可以通过时间线推断。
如果重传发生在收到第三个重复ACK之后紧接着的几个毫秒内,大概率是重复ACK触发的。如果重传发生在某个ACK到达之后,但之前并没有凑够三个重复ACK,那大概率是RACK触发的。更精确的做法是看重传包的序列号:重复ACK触发的重传通常针对的是重复ACK指向的那个序列号,RACK触发的重传可能针对的是更早的、已经被“跳过”的序列号。
还有一个技巧是临时关闭其中一个机制做对比测试。很多协议栈支持通过参数关闭RACK或者调整重复ACK的阈值。关掉RACK后如果尾部丢包的恢复时间明显变长,说明RACK在起作用;关掉重复ACK的快速重传后如果中间丢包的恢复变慢,说明重复ACK在起作用。这种对比测试虽然粗暴,但能快速定位问题。
4. 实操过程与核心环节实现
4.1 搭建可复现的测试环境
要复现重复ACK和RACK的交互,最方便的是用网络模拟工具构造一个可控的丢包和乱序环境。我一般会用一个发送端、一个接收端,中间加一个可以注入丢包和延迟的模拟链路。发送端跑一个支持RACK的传输协议栈,接收端正常收包。
关键配置是模拟链路的参数:基础RTT设成50毫秒左右,丢包率从0.1%开始逐步加到2%,乱序率单独控制。这样可以在不同条件下观察两者的行为差异。测试数据用大文件传输,保证有足够多的报文来触发各种边界情况。
发送端需要打开详细的日志,记录每个报文的发送时间、每个ACK的到达时间、每次重传的触发原因。接收端也要记录每个报文的到达时间和回ACK的时间。两边的时间戳要对齐,最好用同一个时钟源或者做偏移校正。
4.2 构造尾部丢包场景观察RACK
尾部丢包是RACK的主场。构造方法是:让发送端发送一批报文,然后在最后一个报文上注入丢包,同时确保之前的所有报文都正常到达并被确认。这时候接收端凑不出三个重复ACK,因为后面没有新报文了。
在没有RACK的情况下,发送端只能等重传超时,恢复时间通常是几百毫秒。打开RACK后,发送端会在最后一个被确认的报文之后大约一个RTT加余量的时间点判定尾部丢包,触发重传。恢复时间能压缩到几十毫秒。
实测时要注意:尾部丢包的判定依赖“之前有报文被确认”。如果整个发送窗口的报文全丢了,RACK也没有时间基准,还是得靠超时。所以测试时要确保至少有一个报文成功到达并被确认。
4.3 构造中间丢包场景观察重复ACK
中间丢包是重复ACK的主场。构造方法是:发送端发送一批报文,在中间某个报文上注入丢包,确保后续报文继续到达。接收端收到后续报文后会回重复ACK,发送端凑够三个就触发快速重传。
这个场景下RACK也会介入,但通常重复ACK会先触发。因为重复ACK只需要三个报文往返,而RACK要等一个RTT加余量。实测时如果发现RACK先触发了,说明重复ACK的计数逻辑可能有问题,或者SACK把重复ACK“吃掉”了。
一个值得观察的细节是重传后的恢复速度。重复ACK触发重传后,发送端会进入快速恢复,拥塞窗口减半但不回到慢启动。RACK触发重传后,拥塞控制的行为取决于具体实现,有的会进入快速恢复,有的会直接进慢启动。这个差异会显著影响后续的吞吐量。
4.4 参数调优的实操记录
我在一次实际调优中遇到过这样的场景:链路RTT 80毫秒,丢包率0.5%,乱序率1%。默认参数下,吞吐量只有带宽的60%左右。抓包发现重复ACK很多,但重传也不少,说明有误判。
第一步是收紧RACK的时间余量。默认系数是2倍RTT,我把它降到1.5倍,误判减少了一些,但吞吐量提升不明显。第二步是检查重复ACK的阈值,发现实现里用的是固定3个,但在乱序率1%的链路上,这个阈值偏低。把阈值动态调整为“3加上乱序率乘以一个系数”后,误判明显减少。
第三步是调整SACK的行为。默认SACK块的数量有限,乱序严重时SACK块不够用,发送端还是得靠重复ACK猜。增加SACK块的数量后,发送端能更精确地判断哪些包只是乱序,重传更精准。三步下来,吞吐量恢复到了带宽的85%以上。
提示:调优时不要一次改多个参数,否则无法判断哪个参数起了作用。每次只改一个,观察至少几分钟的稳定状态再改下一个。
5. 常见问题与排查技巧实录
5.1 重复ACK很多但吞吐量正常
这种情况通常不是问题,而是链路乱序的正常表现。乱序导致接收端回重复ACK,但SACK让发送端知道那些包只是乱序不是丢了,所以不触发重传。吞吐量正常说明拥塞控制没有误判,不需要干预。
如果确认乱序是链路特性而不是故障,可以考虑适当放宽重复ACK的阈值,减少不必要的ACK流量。但要注意,放宽阈值会降低对真实丢包的敏感度,需要权衡。
5.2 RACK频繁触发但重传后问题依旧
RACK频繁触发说明它认为有很多包超时未确认。但如果重传后问题依旧,可能是重传的包也丢了,或者RACK的时间基准有问题。先检查链路是不是有持续的高丢包,如果是,RACK频繁触发是正常反应,问题在链路本身。
如果链路丢包不高但RACK还是频繁触发,检查时间基准的选择。用发送时间做基准在ACK延迟合并的场景下会偏激进,可以改成用确认时间做基准试试。另外检查RTT的测量是否准确,RTT测量偏差会直接导致RACK的时间窗口计算错误。
5.3 尾部丢包恢复时间异常长
尾部丢包恢复时间长,说明RACK没有及时触发。可能的原因有几个:一是RACK被关闭了或者参数设得太保守;二是尾部丢包之前没有报文被确认,RACK没有时间基准;三是“已重传”标记的死锁问题,导致RACK跳过了本该重传的包。
排查时先确认RACK是否开启,然后看尾部丢包之前是否有报文被确认。如果都没有问题,检查“已重传”标记的有效期设置,适当缩短有效期看是否能改善。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 重复ACK多但重传少 | 链路乱序,SACK在起作用 | 检查SACK是否开启 | 正常现象,无需干预 |
| RACK频繁触发 | 链路丢包高或时间基准偏激进 | 检查链路丢包率和RTT测量 | 调整时间基准或放宽余量 |
| 尾部丢包恢复慢 | RACK未开启或参数保守 | 确认RACK状态和时间基准 | 开启RACK或收紧余量 |
| 重传后问题依旧 | 重传包也丢了或死锁 | 检查重传包是否到达 | 缩短已重传标记有效期 |
| 吞吐量上不去 | 两者误判导致拥塞窗口频繁减半 | 对比关闭其中一个机制 | 分别调优阈值和余量 |
5.5 几个容易踩的坑
第一个坑是只看重复ACK数量不看SACK。SACK开启后,很多重复ACK是“良性”的,不代表丢包。只看重复ACK数量会误判链路质量。
第二个坑是RACK的时间余量设得太小。在无线或者多路径链路上,乱序是常态,余量太小会导致大量误判重传,反而降低吞吐量。我一般会在干净链路上用1.5倍RTT,在乱序链路上用2到2.5倍RTT。
第三个坑是忽略ACK延迟合并的影响。有些接收端会把多个ACK合并成一个发送,这会导致RACK的时间基准偏移,判定失准。排查时要确认接收端的ACK行为,必要时调整RACK的基准选择。
第四个坑是调优时只看吞吐量不看延迟。有时候吞吐量上去了,但延迟抖动变大了,这对交互式应用是灾难。调优时要同时看吞吐量和延迟两个指标,找到平衡点。
6. 从实际项目里攒下来的几点体会
我在几个不同的传输优化项目里反复调过重复ACK和RACK的配合,最大的体会是:这两个机制没有“最优参数”,只有“最适合当前链路的参数”。同一条链路,白天和晚上的乱序率可能就不一样,固定参数很难一直保持最优。
一个比较实用的做法是让参数自适应。RACK的时间余量可以根据实时测量的乱序率动态调整,重复ACK的阈值也可以根据SACK反馈的乱序程度动态变化。这样不用人工反复调,系统自己就能适应链路变化。
另一个体会是日志的重要性。重复ACK和RACK的交互发生在毫秒级,靠抓包很难看清全貌。在协议栈里埋好日志,记录每次重传的触发原因、时间戳、当时的RTT和乱序率,排查问题时能省下大量时间。我现在的习惯是,任何传输相关的改动,第一件事就是把日志埋点加上,不然出了问题只能靠猜。
最后分享一个小技巧:如果你不确定某个重传是重复ACK还是RACK触发的,可以在重传触发点加一个临时的计数器,分别统计两者的触发次数。跑一段时间后看比例,就能大致判断当前链路的主要丢包类型。中间丢包多就优化重复ACK,尾部丢包多就优化RACK,对症下药比盲目调参有效得多。