1.传统 TCP 的“盲人摸象”困境
要理解 SACK 的伟大,必须先深刻理解没有它时的痛苦。
1.1 传统机制:累积确认 (Cumulative ACK) 的死穴
在传统 TCP (RFC 793) 中,接收方回复的 ACK 只有一个含义:
“我连续收到了直到序列号 N−1 的所有数据,我期待收到的下一个字节是 N 。”
致命缺陷:
如果接收方收到了 N+100 到 N+200 的数据,但没收到 N 到 N+99 ,它无法告诉发送方“我有后面那段数据”。它只能反复喊:“我要 N !我要 N !我要N !”
发送方听到后,只能盲目猜测:“好吧,既然你一直要 N ,那我就重传 NN 开始的一段数据。”
- 后果:发送方可能会重传那些接收方已经收到的数据(因为接收方没法告诉它别传)。这浪费了宝贵的带宽,更可怕的是,它极大地拖慢了恢复速度。
2. SACK 如何打破僵局
RFC 2018 引入了一种新的 TCP Option(选项),允许接收方在 ACK 包中附带一个“已收到的非连续数据块列表”。
2.1 协议细节:TCP Option 格式
SACK 信息携带在 TCP 头部的 Options 字段中。TCP 头部最大 60 字节,固定部分 20 字节,所以 Options 最多 40 字节。
2.1.1 握手协商:SACK-Permitted (Kind=4)
SACK 不是默认开启的,必须在三次握手时协商。
-
位置:SYN 包 和 SYN-ACK 包。
-
结构 (2 字节):
+--------+--------+ | Kind=4 | Len=2 | +--------+--------+ -
规则:只有双方都在 SYN 中发送了此选项,后续数据传输才能使用 SACK。如果一方没发,连接将回退到传统累积确认模式。
2.2.2 数据传输:SACK Block (Kind=5)
当发生丢包或乱序时,接收方在 ACK 包中携带此选项。
-
结构:
+--------+--------+----------------+----------------+ ... | Kind=5 | Length | Left Edge 1 | Right Edge 1 | ... +--------+--------+----------------+----------------+ ...- Kind (1 字节): 固定为 5。
- Length (1 字节): 总长度。
- Left Edge (4 字节): 数据块的起始序列号 ( inclusive )。
- Right Edge (4 字节): 数据块的结束序列号 ( exclusive )。即
[Left, Right)区间内的数据已收到。
-
容量限制 (关键点):
- 每个 SACK 块占用 8 字节 (4+4)。
- 头部开销:Kind(1) + Length(1) = 2 字节。
- 剩余空间:40 - 2 = 38 字节。
- 最大块数:38/8=4.7538/8=4.75 →→ 最多 4 个块。
- 特殊情况:如果同时开启了 TCP Timestamps (通常都会开,占 10 字节),剩余空间变为 40−2−10=2840−2−10=28 字节。
- 结论:在开启时间戳的现代系统中,一个 ACK 包最多只能携带 3 个 SACK 块。
思考题:如果接收方有 5 段不连续的数据怎么办?
答案:SACK 机制规定,接收方必须优先报告最新的乱序数据块。旧的数据块会被丢弃(虽然还在缓存里),等待下一次 ACK 机会再报,或者等发送方重传填补空洞后自然合并。
3. 案例实录
为了让你彻底看懂,我们模拟一个真实的传输场景。假设 MSS (最大报文段大小),发送方一次性发送 10 个包 (Seq 1 到 10)。
网络故障设定:
- 丢失:第 2、5、8 号包。
- 到达:第 1, 3, 4, 6, 7, 9, 10 号包。
- 初始序列号 (ISN):假设为 1000 (为了方便计算,我们用相对偏移量 1-10 代表 1001-1010)。
3.1 第一幕:灾难发生 (无 SACK vs 有 SACK)
A 没有 SACK (传统 TCP Reno)
| 时间 | 事件 | 接收方动作 (内心独白) | 发送给发送方的 ACK | 发送方状态 |
|---|---|---|---|---|
| T1 | 收到包 1 | "收到 1001-1010。期待 1011。" | ACK 1011 | 正常 |
| T2 | 包 2 丢失 | (无) | (无) | 等待 |
| T3 | 收到包 3 | "收到 1021-1030。但我缺 1011!没法说我有 3。" | ACK 1011 (Dup 1) | 收到 1 个重复 ACK |
| T4 | 收到包 4 | "收到 1031-1040。还是缺 1011!" | ACK 1011 (Dup 2) | 收到 2 个重复 ACK |
| T5 | 收到包 6 | "收到 1051-1060。缺 1011!" | ACK 1011 (Dup 3) | 触发快速重传! |
| T6 | 重传动作 | (无) | (无) | 重传 包 2 (1011-1020)。 (注意:它不知道 3,4,6,7 都到了) |
| T7 | 收到重传包 2 | "太好了!1,2,3,4 连续了。现在缺 5 (1041)。" | ACK 1041 | 收到新 ACK,以为 4 之前都好了 |
| T8 | 收到包 7 | "收到 1061-1070。缺 1041!" | ACK 1041 (Dup 1) | ... |
| T9 | ... | ... | ... | 再次触发重传 包 5 |
| T10 | ... | ... | ... | 再次触发重传 包 8 |
结果分析:
- 发送方像个无头苍蝇,每经过一个 RTT 只能修复一个洞。
- 修复 3 个丢包需要 3 个 RTT。
- 期间发送方可能还会因为超时而重传一些其实已经到达的数据(如果超时时间设置得短)。
B 开启 SACK (RFC 2018)
同样的场景,看看 SACK 如何上演“上帝视角”。
表格
| 时间 | 事件 | 接收方动作 (构建 SACK 块) | 发送给发送方的 ACK 包内容 | 发送方智能处理 |
|---|---|---|---|---|
| T1 | 收到包 1 | 连续。 | ACK 1011 | 正常 |
| T2 | 包 2 丢失 | - | - | - |
| T3 | 收到包 3 | 乱序。缓存 [3]。生成 SACK 块 [1021, 1031)。 | ACK 1011 + SACK: [1021-1031] | 关键一步: 发送方收到后,标记 包 3 已安全到达。无需重传包 3。 |
| T4 | 收到包 4 | 连续接在 3 后面。缓存 [3, 4]。更新 SACK 块 [1021, 1041)。 | ACK 1011 + SACK: [1021-1041] | 发送方更新状态:包 3, 4 安全。 |
| T5 | 收到包 6 | 乱序。缓存 [3, 4, 6]。生成第二个 SACK 块 [1051, 1061)。 | ACK 1011 + SACK: [1051-1061], [1021-1041] | 发送方更新状态: 已知丢失:包 2, 包 5。 已知安全:1, 3, 4, 6。 |
| T6 | 收到包 7 | 连续接在 6 后面。更新第二块 [1051, 1071)。 | ACK 1011 + SACK: [1051-1071], [1021-1041] | 发送方确认:包 7 也安全。 |
| T7 | 收到包 9 | 乱序。生成第三块 [1081, 1091)。 | ACK 1011 + SACK: [1081-1091], [1051-1071], [1021-1041] | 全局视野达成! 发送方明确知道: ❌ 丢失:2, 5, 8 ✅ 已有:1, 3, 4, 6, 7, 9 |
| T8 | 收到包 10 | 连续接在 9 后面。更新第三块 [1081, 1101)。 | ACK 1011 + SACK: [1081-1101], [1051-1071], [1021-1041] | 此时已收到 3 个重复 ACK (T5, T7, T8)。 触发快速重传。 |
| T9 | 重传动作 | - | - | 智能决策: 检查重传队列。 包 3? -> SACKed, 跳过。 包 4? -> SACKed, 跳过。 包 6,7? -> SACKed, 跳过。 包 9,10? -> SACKed, 跳过。 只重传:包 2, 包 5, 包 8! |
| T10 | 接收方收齐 | 收到 2, 5, 8。所有数据连续 (1-10)。 | ACK 1101 | 传输完成。 |
结果对比:
- 耗时:仅用 1 个 RTT (从 T3 到 T9) 就定位并修复了所有 3 个丢包。
- 带宽:没有浪费任何字节去重传已经到达的数据。
- 吞吐量:在高丢包率下,吞吐量几乎是传统 TCP 的 3 倍 (因为修复速度快 3 倍)。
4. 发送方如何维护状态?
很多教程只讲了接收方怎么发 SACK,却没讲发送方怎么存 SACK。这是实现的难点。
4.1 发送方的数据结构:SACK Scoreboard
Linux 内核 (以及大多数现代 TCP 栈) 在发送方的 struct tcp_sock 中维护了一个 Scoreboard (记分板)。
-
记录方式:
- 每当收到一个带 SACK 的 ACK,内核会解析其中的 SACK Blocks。
- 将这些
[Start, End)区间插入到 Scoreboard 中。 - 合并逻辑:如果新收到的块与已有的块相邻或重叠(例如已有
[1021-1041),新收到[1041-1051)),内核会自动将它们合并成一个大块[1021-1051),减少管理开销。
-
重传决策算法 (F-RTO / SACK-based Retransmission):
当触发重传时,发送方遍历待重传队列(Outstanding Queue):for (each segment in retransmit_queue) { if (segment.seq is covered by any SACK block in scoreboard) { // 这个包接收方已经确认收到了! mark_as_SACKed(segment); continue; // 跳过,绝不重传 } if (segment.seq < highest_sack_end && !is_SACKed) { // 这个包在 SACK 范围的“空洞”里,肯定是丢了 mark_for_retransmit(segment); } } -
处理 SACK 块数量限制:
- 由于 ACK 包只能带 3-4 个块,如果乱序非常严重(比如有 10 个洞),接收方会采取 贪心策略:优先报告最新的、最大的乱序块。
- 发送方会根据这些片段拼凑出最可能的丢失列表。即使信息不全,也比完全盲猜要强得多。
5. 总结:SACK 的核心价值
| 维度 | 传统 TCP (Cumulative ACK) | SACK TCP (RFC 2018) |
|---|---|---|
| 信息量 | 1 bit (只告诉对方“断在哪”) | N bits (告诉对方“哪些连上了,哪些断了”) |
| 重传效率 | 串行修复 (1 RTT 修 1 个) | 并行修复 (1 RTT 修 N 个) |
| 带宽浪费 | 高 (重复发送已到达数据) | 极低 (精准打击) |
| 抗丢包能力 | 弱 (丢包率 > 5% 时性能崩塌) | 强 (丢包率 20% 仍能保持较高吞吐) |
| 适用性 | 完美的局域网 | 复杂的互联网、跨国链路、移动网络 |
RFC 2018 (SACK) 是 TCP 协议史上的一次“智能化升级”。它没有改变 TCP 的可靠性承诺,但它改变了实现可靠性的效率。
在现代网络环境中,没有 SACK 的 TCP 就像是一个只会死记硬背的学生,而有了 SACK 的 TCP 则是一个懂得举一反三的天才。
确保你的服务器和客户端都开启了 SACK,是网络调优中最简单、回报率最高的一步。