Appearance
第21章 乱序、重复数据和 SACK
接收端先收到了 Seq 3001,过了一会儿才收到 Seq 2001。这可能是发生了丢包,也可能只是前一个报文段走得慢了些。TCP 需要先将这些“乱序事实”保存下来,然后等待足够的证据再决定是否进行恢复。本章我们将从接收队列出发,深入理解累计 Ack (Cumulative ACK) 与 SACK 如何共同勾勒出“连续前缀、序列号缺口和已到达区间”。
乱序从何而来
同一个 TCP 连接中的报文段,可能会因为多种原因导致到达顺序发生改变:
- 链路层重传,导致前一个数据帧多等了一轮;
- 路由变化、链路聚合 (Link Aggregation) 或多路径调度,让相邻的报文经历了不同的延迟;
- 接收端主机的并行收包、硬件卸载 (Offload) 以及队列调度,导致抓包点看到的处理顺序发生错乱;
- 某个报文段确实丢失了,而后续的报文段继续到达。在接收端看来,这在初期的表现与单纯的乱序完全一样。
此外,如果抓包时发生漏抓 (Packet Drop),也会制造出“看起来缺了一段”的假象。因此,乱序本质上只是一种“到达现象”。至于是否真的发生了丢包,还需要进一步观察缺口能否自行补齐、发送方有没有触发重传,以及接收方有没有推进累计确认。
重复数据 (Duplicate) 的来源同样五花八门。比如,原报文只是在路上耽搁了,发送端等不及就先重传了一份,结果两份副本最终都到了;网络设备有时也会因为某些机制复制报文;另外,抓包点的位置也可能导致同一份数据在不同阶段被重复记录。面对这些情况,TCP 会根据序列号 (Sequence Number) 来精准识别已接收的范围,最终只把连续且去重后的字节流按序交给应用层。
接收端如何保存乱序数据
假设当前的累计 Ack = 2001,意味着序列号
应用层的读取操作严格受限于“连续前缀”。虽然
在这个过程中,接收端回复的累计 Ack 依然会停在 2001。如果只看这个字段,发送端只能模糊地知道“最左侧还有缺口”。为了解决这个问题,SACK (Selective Acknowledgment) 选项闪亮登场。它能额外报告缺口右侧那些“已经顺利到达”的数据区间,帮助发送端在内部建立一个极其精确的“记分板 (Scoreboard)”。
SACK 的协商与区间语义
SACK 机制依赖两个不同的 TCP 选项。其中,SACK Permitted 选项只出现在握手阶段的 SYN 报文中。只要一端发送了它,就表示该端有能力接收和处理后续的 SACK 选项。当双方都在 SYN 报文中携带了该选项时,这条连接的双向数据传输就都支持 SACK 了。连接建立后,数据接收方就可以在返回的 ACK 报文中携带具体的 SACK Blocks。
每个 SACK Block 由两个 32 位的序列号组成:
左边界是这个连续数据块的第一个序列号,右边界则是最后一个字节之后的序列号。注意,右边界是一个开区间,这与 TCP 中 Seq 和 Ack 的常规计算逻辑完全一致。SACK 选项的长度是
需要强调的是,SACK 报告只是补充信息,TCP 首部中的 Ack 字段依然雷打不动地承担着“累计确认”的重任。发送端通常必须把数据一直保留在发送缓存中,直到累计 Ack 彻底越过了该数据的范围才能释放。此外,RFC 2018 甚至允许接收端在内存告急等资源压力下,“吃书”撤销之前已经通过 SACK 报告过的数据。正因如此,发送端的重传恢复逻辑绝不能完全依赖 SACK,而是必须保留基于超时和累计确认的“兜底”机制。
用数字画出序列号缺口
我们依然以每个报文段 1000 字节为例:
| 到达事件 | 接收端连续前缀 | 累计 Ack | SACK Blocks |
|---|---|---|---|
| 段 A: | 2001 | 空 | |
| 段 C: | 2001 | ||
| 段 D: | 2001 | ||
| 段 B: | 5001 | 空 |
注意,段 C 和段 D 是相邻的,因此接收端会聪明地将它们合并成一个更大的 SACK Block [3001,5001)。当姗姗来迟的段 B 终于到达时,左侧的缺口被填平,这让累计 Ack 得以直接“跳跃”推进到 5001,此时 SACK 块也随之清空。
如果存在两个缺口呢?假设段 B [2001,3001) 和段 D [4001,5001) 都在网络中丢失了,而 A、C、E 顺利到达,此时的视图如下:
text
已累计确认 缺口 B 已收到 C 缺口 D 已收到 E
[1001,2001) [2001,3001) [3001,4001) [4001,5001) [5001,6001)
Ack = 2001 SACK 3001-4001 SACK 5001-6001此时,发送端收到的确认报文中包含 Ack = 2001 以及两个 SACK Block。凭借这些信息,发送端就能精准制导,优先重传 B 和 D,而明确报告已到达的 C 和 E 则可以直接跳过。RFC 6675 专门描述了如何基于这种 SACK 记分板来估算网络中的在途数据 (Flight Size) 并执行丢包恢复。在现代操作系统内核中,SACK 通常还会与 RACK 等更先进的机制组合使用,以获得更佳的恢复性能。
重复数据与 D-SACK
针对重复到达的数据,RFC 2883 引入了 D-SACK (Duplicate SACK) 扩展机制。当接收端收到重复数据时,它会把第一个 SACK Block 当作 D-SACK 块,专门用来报告这部分重复到达的序列号范围。通过这种方式,发送端就能获得极具价值的反馈:“刚刚触发的那次重传,很可能是多余的 (Spurious Retransmission)”。基于此,发送端可以动态调整乱序容忍度 (Reordering Tolerance) 或优化拥塞控制的统计。当然,D-SACK 的生效依赖于两端 TCP 栈的支持,但无论底层如何周旋,应用层最终接收到的,永远是唯一且有序的字节流。
如何解读 Wireshark 的乱序标签
Wireshark 会根据当前 TCP 会话中已经抓到的 Seq 和 Ack 历史,智能推导并打上相应的分析标签:
TCP Out-Of-Order:当前报文段的序列号,跟分析器预测的“下一个应该到达的序列号”对不上,说明发生了乱序。TCP Dup ACK:Ack 序号没有向前推进,并且符合分析器预设的“重复确认”判定条件。TCP Previous Segment Not Captured:分析器发现序列号出现了断层(缺口)。这个标签的名字很严谨,它暗示了“前面的报文可能丢了,但也可能是单纯没抓到”。TCP Spurious Retransmission:虚假重传。当前报文段携带的数据,在分析器的“记忆”中已经被确认过了,说明发送方做了一次无用功。SACK:这不是推导出来的,而是 TCP 报文选项里真实携带的区间信息。不过,至于这个区间左侧到底为什么出现缺口,还需要进一步推理。
在排查复杂的乱序或丢包问题时,建议遵循以下“四步走”心法:
- 先确认抓包文件里有没有完整的三次握手(缺乏初始上下文会导致后续推导全部错乱);
- 仔细检查出问题的 Seq 序列号范围;
- 结合累计 Ack 和 SACK 的数据,在脑海中(或纸上)画出接收端的缓存区间图;
- 对比缺口补齐的时间点、发送端触发重传的时间点,有条件的话最好结合双端的抓包文件进行对比。 此外,在发送端使用
ss -ti命令查看实时 TCP 统计信息、重传计数和 RACK 状态,能够为你提供极佳的底层算法佐证。
从 SACK 记分板推导重传范围
我们可以把发送端尚未被累计确认的序列号空间想象成一排方格。每当收到一个 SACK Block,发送端就会把对应的方格涂成“接收端报告已到达”;累计 Ack 左侧的方格则涂成“已完成”。这两者之间留下的空白方格,就构成了“候选缺口”。当然,知道缺口在哪还不算完,发送端的恢复算法还需要综合考虑收到的重复 ACK 数量、报文的发送时间以及乱序容忍度 (Reordering Degree)。因此,从“发现候选缺口”到“真正触发立即重传”,中间还需要经历一套严格的判定逻辑。
假设发送端已经发出了 Ack = 2000,并且 ACK 的选项中携带了 [3000, 5000) 和 [6000, 7000) 两个 SACK 块。此时记分板的视图如下:
| 序列范围 | 记分板状态 | 行为含义 |
|---|---|---|
| 已累计确认 | 发送队列的左边界可以向前推进,释放内存 | |
| 空白缺口 | 候选恢复范围,极大概率需要重传 | |
| SACK 已报告 | 接收端已妥善缓存该段数据 | |
| 空白缺口 | 候选恢复范围,大概率需要重传 | |
| SACK 已报告 | 接收端已妥善缓存该段数据 |
一旦发送端重传了 [6000, 7000) 则会继续通过 SACK 选项报告。只有当
由于 TCP 选项空间有限(最多携带 3-4 个 SACK 块),单个 ACK 报文可能无法把接收队列里所有的乱序块都报告完。为了还原全貌,我们需要观察连续多份 ACK 中 SACK 块的“并集”,以此来回顾究竟有哪些区间曾经成功到达过。需要注意的是,历史并集仅仅是辅助视图,发送端当前决策的依据永远是“最新那份 ACK 里的有效 SACK 块”,并且发送端必须随时做好应对“接收端撤回 SACK”的准备。
在实际的双向数据传输中,通信双方需要各自维护一块独立的记分板。客户端发送数据时,参考的是服务端返回的 Ack / SACK;服务端发送数据时,参考的则是客户端返回的 Ack / SACK。分析复杂抓包时,建议严格按照数据流向进行着色区分,并结合四元组和时间戳进行关联,千万不要把反向流量的序列号混入当前方向的缺口推导中。
受控实验:手动制造有限乱序
我们将复用第20章建立的 tcp-rx 网络命名空间(Namespace)。如果你在上一章结尾已经删除了它,请先回头重新执行拓扑创建命令。确认相关网卡接口存在:
bash
ip link show veth-tx
sudo ip netns exec tcp-rx ip link show veth-rx在终端 A 启动 iperf3 服务端,然后在终端 B 对接收端接口进行抓包:
bash
sudo ip netns exec tcp-rx iperf3 -s -B 10.200.20.2
sudo ip netns exec tcp-rx tcpdump -i veth-rx -s 0 -w chapter21.pcap 'tcp port 5201'接下来,我们在终端 C 使用 tc 命令配置带有抖动 (Jitter) 的延迟,并注入有限比例的乱序,然后开始发送 32 MiB 的数据:
bash
sudo tc qdisc replace dev veth-tx root netem delay 35ms 15ms reorder 25% 50%
sudo ip netns exec tcp-rx tc qdisc replace dev veth-rx root netem delay 35ms
iperf3 -c 10.200.20.2 -n 32M -l 1200 -i 0.5实验完成后,用 Wireshark 打开抓包文件。先追踪并选定一条 TCP 流(假设索引为 N),然后在过滤栏输入以下表达式:
text
tcp.stream == N &&
(tcp.analysis.out_of_order || tcp.analysis.duplicate_ack ||
tcp.analysis.spurious_retransmission || tcp.options.sack.count > 0)预期现象
- 你会观察到某些拥有高 Sequence Number 的报文段率先到达,导致接收端的累计 Ack 暂停推进,紧接着返回的 ACK 报文中会出现一个或多个 SACK Block。
- 当发生延迟的乱序报文最终自行抵达后,接收端的累计 Ack 会“大步跨越”那些已经缓存的序列区间。当然,有时发送端等不及,可能会抢先触发快速重传。
- 尽管网络层面极其混乱,但 TCP 交付给应用层的数据依然是完美按序的,应用层只会读取到唯一的一份干净字节流。
- 由于网卡硬件卸载 (Offload) 和聚合机制的影响,在发送端本机抓包时,你可能会看到远超 MTU 的“超大号” TCP 报文。你可以尝试在
veth-tx和veth-rx两侧分别抓包,对比结果,以深刻体会抓包点位置带来的观测差异。
实验结束后,在服务端和抓包终端按 Ctrl+C 终止进程,并及时清理 tc 规则。如果你还要继续做后续的实验,请保留网络命名空间:
bash
sudo tc qdisc del dev veth-tx root
sudo ip netns exec tcp-rx tc qdisc del dev veth-rx root如果在你的测试环境中无法使用 netem 模块,你也可以尝试在 Wi-Fi、VPN 或者跨地域公网传输的文件上抓包,然后直接用过滤器 tcp.options.sack.count > 0 去寻找自然发生的丢包或乱序缺口。如果你的网络实在太好,抓不到任何乱序,可以直接拿前面“用数字画出序列号缺口”那一节的表格做脑内演练:遮住最后一列,仅根据到达顺序手推 Ack 与 SACK 的值,再与答案进行比对。
理解检查
- 当收到
Ack = 7000, SACK = [9000,11000)时,表示哪一段连续前缀已经被确认?当前最左侧的缺口是从哪个序列号开始的? - 如果报文段
与 同时躺在接收队列中,它们在 ACK 报文中应该合并成几个 SACK Block? - “缺口因迟到报文自行补齐”与“缺口因发送方重传而补齐”,在单侧抓包分析时,可以通过哪些时间戳或序列号特征来进行区分?
- 既然 SACK 已经明确报告某段数据“已到达”,为什么发送端的发送缓存仍必须死等累计 Ack推进后,才能真正释放这段数据对应的内存?
小结
接收端通过乱序队列来缓存接收窗口内的非连续数据;利用累计 Ack 来宣告连续前缀的范围;同时借助 SACK Blocks 向发送端报告缺口右侧已经到达的数据区间。对于重复到达的数据,TCP 会根据序列号进行去重,并通过 D-SACK 将这种冗余传输的情况及时反馈给发送端。虽然 Wireshark 的分析标签为排查问题提供了极佳的检索入口,但亲自对 Seq、Ack、SACK 进行区间推演,才是网络排障中最具说服力、可复核的分析手段。
规范依据
- RFC 2018:TCP Selective Acknowledgment Options
- RFC 6675:基于 SACK 的保守丢包恢复算法
- RFC 2883:Duplicate SACK (D-SACK) 扩展
- RFC 5681:TCP 拥塞控制(涵盖重复 ACK 与快速恢复)