4. 快速重传与选择性确认(SACK):重复ACK触发机制、SACK选项格式与作用
说到TCP的重传,很多人第一反应就是超时重传。但说实话,等超时太慢了。尤其是在丢包率不高的局域网里,等那几百毫秒的RTO,用户体验早就崩了。所以TCP设计了一个更聪明的机制——快速重传。它不依赖定时器,而是靠接收端发回来的ACK来“催”发送端。
我个人习惯把快速重传比作“同事催你交代码”。你发了一个PR,同事没收到,他就一直发“你还没发吗?”的消息。连续收到3条同样的催促,你就知道——哦,上次那个包丢了,赶紧重发。
4.1 重复ACK的触发机制
先搞清楚一个问题:接收端什么时候会发重复ACK?
正常情况下,接收端收到一个数据包,会回复一个ACK,确认号是下一个期望收到的字节序号。比如收到seq=1的包,回复ACK=100(假设下一个期望字节是100)。
但如果接收端收到了乱序包呢?举个例子:
- 接收端已经收到了seq=1~99的数据,期望seq=100
- 结果下一个到达的是seq=200的包(跳过了100~199)
- 接收端不会确认seq=200,而是立即回复ACK=100——这就是一个重复ACK
为什么?因为接收端只确认连续收到的数据。seq=200虽然到了,但100~199还没到,所以它只能告诉发送端:“我还在等100,你赶紧发。”
我记得有一次在排查一个数据中心的问题,发现客户端疯狂重传,但服务器端明明已经收到了数据。后来抓包一看,原来是中间某个交换机把几个包的顺序打乱了。接收端每收到一个乱序包,就发一个重复ACK。发送端收到3个重复ACK后,触发了快速重传——但其实包根本没丢,只是晚到了。嗯,这就是快速重传的一个小陷阱。
快速重传触发条件:
- 发送端收到3个或以上的重复ACK(即连续4个相同的ACK,包括原始的那个)
- 触发后,发送端立即重传被确认丢失的那个包(即ACK号对应的那个包)
- 同时将拥塞窗口减半,进入拥塞避免阶段
为什么是3个重复ACK? 不是2个,也不是4个。RFC 5681里明确写了这个阈值。原因很简单:2个重复ACK可能是网络轻微乱序导致的,3个基本可以确认是丢包了。我在调优时偶尔会把这个值改成2,适用于对延迟极度敏感的场景,但代价是可能误触发。
4.2 快速重传的局限性
快速重传虽然比超时快,但它有一个明显的短板——它不知道到底丢了哪些包。
你想想看:发送端一次发了1、2、3、4、5五个包。接收端只收到了1、3、4、5,丢了2。接收端每收到一个乱序包,就回复ACK=2(期望收到2)。发送端收到3个重复ACK后,知道包2丢了,于是重传包2。
但问题来了——包2重传之后,接收端收到了,然后回复ACK=6(因为3、4、5早就到了)。一切看起来正常。但如果丢的不是一个包,而是多个呢?比如丢了包2和包4:
- 收到包1,回复ACK=2
- 收到包3,回复ACK=2(重复)
- 收到包5,回复ACK=2(重复)
- 触发快速重传,重发包2
- 收到包2,回复ACK=4(因为包3到了,但包4还没到)
- 收到包4的重传,回复ACK=6
看到了吗?发送端只能一个一个地等。它不知道包4也丢了,只能等下一个重复ACK或者超时。这在丢包率高的链路上,效率非常低。
我曾经在一个跨国专线的项目上遇到过这个问题。链路延迟本来就高,加上偶尔的丢包,快速重传根本不够用。每次丢多个包,就要来回好几轮才能补全。后来我打开了SACK,效果立竿见影。
4.3 SACK选项格式与作用
SACK(Selective Acknowledgment,选择性确认)就是来解决这个问题的。它让接收端能明确告诉发送端:“我收到了哪些包,没收到哪些包。” 发送端一看就明白了,可以一次性重传所有丢失的包。
SACK是一个TCP选项,在三次握手时协商启用。格式如下:
TCP SACK选项格式(RFC 2018):
+--------+--------+
| Kind=5 | Length |
+--------+--------+--------+--------+
| Left Edge #1 (4字节) |
+--------+--------+--------+--------+
| Right Edge #1 (4字节) |
+--------+--------+--------+--------+
| ... 更多块 ... |
+--------+--------+--------+--------+
| Left Edge #n (4字节) |
+--------+--------+--------+--------+
| Right Edge #n (4字节) |
+--------+--------+--------+--------+
- Kind: 5(标识SACK选项)
- Length: 可变,取决于包含的SACK块数量
- 每个SACK块包含一对Left Edge和Right Edge
- Left Edge: 已收到数据块的起始序号
- Right Edge: 已收到数据块的结束序号(不包含)
每个SACK块描述了一段已经收到但不连续的数据。比如接收端收到了seq=1000~1999和seq=3000~3999,但中间2000~2999丢了,那么SACK块就是:
- 块1: Left=1000, Right=2000(表示1000~1999已收到)
- 块2: Left=3000, Right=4000(表示3000~3999已收到)
发送端看到这个,就知道2000~2999丢了,直接重传这一整段。
注意:SACK选项的长度是有限制的。TCP选项总长度最多40字节,SACK选项头占2字节,每个SACK块占8字节。所以最多只能携带4个SACK块((40-2)/8 = 4.75,取整为4)。如果乱序片段太多,只能选最近4个。
4.4 SACK与快速重传的配合
实际工作中,SACK不是替代快速重传,而是增强它。流程是这样的:
- 发送端收到3个重复ACK,触发快速重传
- 发送端查看ACK中携带的SACK选项,了解哪些数据已经被接收端收到
- 发送端重传所有被SACK块“排除”在外的数据(即丢失的数据)
- 发送端可以一次性重传多个丢失的包,而不是一个一个等
我画了一张图,帮你理解这个流程:
4.5 SACK的实际效果
打开SACK之后,效果有多明显?我直接给你看数据:
| 场景 | 无SACK | 有SACK |
|---|---|---|
| 单包丢失 | 1次RTT恢复 | 1次RTT恢复 |
| 连续多包丢失(2个) | 2~3次RTT恢复 | 1次RTT恢复 |
| 连续多包丢失(4个) | 4~5次RTT恢复 | 1次RTT恢复 |
| 高延迟链路(200ms RTT) | 恢复时间极长 | 恢复时间缩短50%~80% |
我曾经在一个跨国传输的项目中做过对比测试。链路RTT约180ms,丢包率1%左右。没开SACK时,一个大文件传输经常要重传好几轮,总耗时比理论值多了30%以上。开了SACK之后,重传次数直接减半,传输时间基本接近理论值。
如何确认SACK是否开启?
- Linux:
sysctl net.ipv4.tcp_sack,返回1表示开启 - 抓包: 查看SYN包中是否包含SACK Permitted选项(Kind=4)
- Windows: 默认开启,可通过注册表
Tcp1323Opts控制
4.6 避坑指南
最后分享几个我踩过的坑:
- 不要关闭SACK:有些老旧的网络设备或者安全策略会禁用SACK,觉得它增加了协议复杂度。但现代网络环境下,关闭SACK带来的性能损失远大于那点CPU开销。除非你确定链路上从来不会丢包——但说实话,这种网络不存在。
- SACK与D-SACK的区别:D-SACK(Duplicate SACK)是SACK的扩展,用于检测重复包和伪重传。我在一个案例中遇到过,发送端误判了丢包,重传了已经到达的数据,D-SACK帮我们快速定位了这个问题。
- 中间设备可能篡改SACK:有些防火墙或负载均衡器会修改TCP选项。我曾经遇到一个客户,开了SACK但性能没提升,抓包发现中间设备把SACK选项给干掉了。排查了三天才找到原因。
快速重传和SACK,说白了就是TCP在丢包场景下的“快速反应部队”。快速重传负责拉警报,SACK负责提供精确的“敌情”信息。两者配合,才能让TCP在复杂的网络环境中保持高效。