RFC 2018 —— SACK (选择性确认) 深度解析

4 阅读9分钟

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)。

网络故障设定

  • 丢失:第 258 号包。
  • 到达:第 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 (记分板)。

  1. 记录方式

    • 每当收到一个带 SACK 的 ACK,内核会解析其中的 SACK Blocks。
    • 将这些 [Start, End) 区间插入到 Scoreboard 中。
    • 合并逻辑:如果新收到的块与已有的块相邻或重叠(例如已有 [1021-1041),新收到 [1041-1051)),内核会自动将它们合并成一个大块 [1021-1051),减少管理开销。
  2. 重传决策算法 (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);
        }
    }
    
  3. 处理 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,是网络调优中最简单、回报率最高的一步。