跳转至

How Hard Can It Be? Designing and Implementing a Deployable Multipath TCP

NSDI 2012

先给出一条主线,后面所有细节都挂在这条线上:

MPTCP 想做的事很简单:让一条 TCP 连接同时走多条路。真正难的地方在于,网络里到处是"自作聪明"的中间设备(middlebox),它们只认识普通 TCP。所以 MPTCP 的几乎每个设计,都是为了"在多路径上传输,同时在每条路上都伪装成普通 TCP"。

Danger

MPTCP 巅峰神作! M Handley 出品, 质量无需多言!

一气呵成, 大道至简! 笔者现在理解为什么都说近几年 SIGCOMM / NSDI 质量严重下降了

跟当年的大作相比, 现在这些确实没眼看...

一、背景:设备已支持多路径,TCP 却还是单路径

现状:

  • 手机同时有 WiFi 和 4G/5G;
  • 数据中心两台服务器之间有很多条等价路径;
  • 大型服务器往往同时接入多个运营商。

但 TCP 连接由四元组 (srcIP, srcPort, dstIP、dstPort) 唯一确定,一条连接只能走一条路。这带来两个问题:

  • 浪费:手机下载时只用 WiFi,4G 闲着
  • 脆弱:你走出家门,WiFi 断了,TCP 连接直接断,视频卡住,下载失败

打个比方:你要从北京往上海寄一整套书,手上有顺丰和邮政两条线路,规定却是"一单只能交给一家快递"。顺丰一出问题,整单就作废

作者认为有必要对"传输层"进行重构, 使得其原生支持 multi-path

MPTCP 的目标:一条连接同时用多家快递,一家出问题另一家接着送。同时有一个硬约束:应用程序不用改一行代码,它以为自己用的就是普通 TCP

二、稻草人方案为什么行不通

最直接的做法是:书还是按原来的页码编号 1、2、3、4...

其中,单数页交给顺丰,双数页交给邮政,收件人按页码排好即可

在理想网络里完全可行。问题在于真实网络中,每条线路上都有很多"查件员",也就是 MiddleBox:NAT、防火墙、透明代理、网卡的分段卸载(TSO)等。

它们只懂普通 TCP,并且会对 TCP 包做各种检查和修改。

作者做了大规模实测(24 个国家、142 个接入网), 发现了这些现象:

  • 顺丰的查件员只看到 1、3、5……页,中间缺页,他会觉得不对劲:
    • 有些路径会直接把包扣下
    • 有 26% 的路径会把"它没见过的数据"的 ACK 丢掉或"纠正"
  • 有 10% 的路径会改写 TCP 序号
  • 有 6% 的路径会删掉不认识的 TCP 选项
  • 有的设备会把包切开(TSO)或合并
  • 有的甚至会改包里的内容
    • 比如 NAT 改写 FTP 报文里的 IP 地址

所以论文的核心观点是: 设计 TCP 扩展时,不能再只考虑通信的两端,还必须考虑中间那些会删、改、切、拦的设备

三、预备知识: TCP Packet 长什么样?"TCP Option" 是什么?

后文反复提到"把信息放进 TCP 选项",这里先把 TCP 包的结构讲清楚

Tip

笔者习惯用 TCP Option 而不是 TCP 选项

3.1 一个 TCP Packet 的整体结构

一个包的结构就是层层套信封:

Text Only
1
2
3
4
5
6
┌──────────┬──────────────────────────────┬────────────────┐
│  IP 头   │           TCP 头              │   数据(payload)│
│ (20字节) │  固定部分 20 字节 + 选项 0~40   │  应用的数据     │
└──────────┴──────────────────────────────┴────────────────┘
  谁发给谁      怎么可靠地送                   送的东西
  (IP 地址)    (端口、序号、确认……)

3.2 TCP Head 的固定部分

Text Only
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 0                   15 16                  31   (bit)
┌──────────────────────┬──────────────────────┐
│      源端口           │      目的端口         │
├──────────────────────┴──────────────────────┤
│              序号 Sequence Number            │  ← 这批数据从第几个字节开始
├─────────────────────────────────────────────┤
│              确认号 ACK Number               │  ← 我已收到对方第几个字节之前的所有数据
├────────┬──────┬──────────┬──────────────────┤
│首部长度 │ 保留  │ 标志位    │   接收窗口 Window │  ← 标志位: SYN/ACK/FIN/RST…
│ (4bit) │      │          │                  │     窗口: 我还能收多少(流控)
├────────┴──────┴──────────┼──────────────────┤
│        校验和             │    紧急指针       │
├──────────────────────────┴──────────────────┤
│        TCP 选项(可选,0~40 字节)             │  ← 就是这里
└─────────────────────────────────────────────┘

alt text

这些固定字段在 1981 年就定死了,格式不能改。一旦改了,全世界的设备都不认识。

3.3 TCP Option: TCP 留给未来的"备注栏"

想给 TCP 加新功能,又不能改固定字段,怎么办?

TCP 头末尾预留了选项区,格式是统一的"类型-长度-值"(TLV)

Text Only
1
2
3
4
┌────────┬────────┬─────────────────┐
│ Type   │ Length │   Value ...     │
│ 1字节   │ 1字节  │   具体内容       │
└────────┴────────┴─────────────────┘

接收方遇到不认识的 Type,可以根据 Length 跳过它,不会出错。这正是向后兼容的关键:老设备忽略新选项,照常工作

你其实早就在用 TCP Options 了, 只是你自己不知道
选项 作用
MSS 握手时告诉对方"一个包最多给我发多大"
Window Scale 把 16 位的窗口放大,否则窗口最大只有 64 KB
SACK 选择性确认,比如"第 1–100 和 201–300 字节收到了,中间缺一段"
Timestamps 测 RTT

MPTCP 的所有新信息都放在一个新 TCP Option 里(标准中 Kind = 30), 包括:

MP_CAPABLE、MP_JOIN、ADD_ADDR,以及论文里说的 DSM(数据序号映射)和 Data ACK

四、MPTCP 的整体设计

一句话概括: 一条 MPTCP 连接由多个"子流"(subflow)组成,每个子流在网络上看起来就是一条完全正常的 TCP 连接

Text Only
1
2
3
4
5
6
7
8
9
        应用(以为自己在用普通 TCP)
                  │
        ┌─────────────────────┐
        │   MPTCP 连接层       │  ← 共享的发送/接收缓存、全局编号
        └─────────────────────┘
           │               │
      子流1 (WiFi)      子流2 (4G)     ← 各自都是"标准 TCP"
           │               │
      查件员们看到的都是一条正常、连续的 TCP

回到寄书的比方:每家快递都有自己独立的运单号,每一单内部连续,查件员挑不出毛病。同时在包裹的"备注栏"(TCP 选项)里写上"这是全书第几页",收件人按备注重新排版

下面按一条连接的生命周期,看每一步碰到什么问题、怎么解决(笔者习惯采用"问题引导"的方式撰文😄)

五、碰到的核心问题与解决方式

问题 1: 怎么知道对方支持 MPTCP?

  • 做法:

    • 三次握手时,在 SYN 里加一个 MP_CAPABLE 选项,相当于问"你会 MPTCP 吗?"
    • 对方支持就在 SYN/ACK 里回一个
  • 坑:

    • MiddleBox 可能只删掉回程的那个选项
    • 结果: 客户端以为"对方不支持",服务器却以为"已经启用了",双方对暗号的理解不一致
  • 解决:

    • 握手完成后,客户端发的 数据包 也要继续带上 MPTCP 选项,直到确认对方收到为止
    • 服务器如果收到的第一个数据包没有带选项,就知道路上被删了,立刻退回普通 TCP
Note

这是贯穿全文的第一个原则:发现情况不对,就安静地退回普通 TCP。最差也和 TCP 一样,不会更差

问题 2: 怎么给连接加新路?

手机连上 4G 后,想给已有连接加一个子流。这里有三个坑

(1) 不能直接在 4G 上发数据, 未经过握手的包会被拦

关键在于:NAT 和防火墙都是"有状态"的设备,它们心里有一张"连接登记表"

NAT连接登记表

登记表是怎么建立的? 以手机走 4G 访问服务器为例。手机的 4G 地址通常是运营商分配的内网地址(比如 10.20.30.40),出去时要经过运营商的 NAT,被换成一个公网地址:

Text Only
1
手机 10.20.30.40:5000  →  [运营商 NAT]  →  公网 1.2.3.4:61000  →  服务器

NAT 看到一个 SYN 包出去时,会在表里登记一行:

Text Only
1
内部 10.20.30.40:5000  ↔  外部 1.2.3.4:61000  ↔  服务器 S:443

之后这条连接的所有包(包括服务器发回来的)都按这一行来转换和放行。

这个映射关系是存储在 NAT 中的. 比如对应的路由器/网关...

如果不握手直接发数据会怎样?

假设手机想"偷懒",不做握手,直接从 4G 发一个数据包,序号接着 WiFi 那条连接往下编。

NAT 一查表,发现没有这条连接的登记。它不知道这是哪来的包,也就不知道该换成哪个外部端口,通常会直接丢掉,有的还会回一个 RST。

有状态防火墙的逻辑也一样:没见过开头 SYN 的连接,中间的包一律视为可疑

(2) 新子流的四元组变了,服务器怎么知道它属于哪条连接?

准确地说,不是 IP 和端口一定都会变,而是四元组至少有一项和原子流不同。这会引发"身份标记"的问题!

手机这个典型场景下它们往往都会变:

[1] IP 为什么变?

因为: 新子流往往就是走另一张网卡

  • WiFi 网卡的地址由家里路由器分配,比如 192.168.1.5
  • 4G 网卡的地址由运营商分配,比如 10.20.30.40

想走 4G 这条路,源 IP 就必须是 4G 网卡的地址,否则包根本不会从 4G 出去。所以换路径往往就意味着换源 IP

[2] 端口为什么变?

  • 新子流在操作系统里就是一个新的 TCP 连接,源端口一般由系统重新随机挑一个
  • 即使客户端用了相同的端口,NAT 也会按自己的规则改写端口,服务器最终看到什么端口,客户端控制不了

[3] 也有 IP 不变、只有端口变的情况

论文里的数据中心场景就是如此:两台服务器之间的 IP 对不变,只换源端口

因为交换机按四元组做 ECMP 来分配路径,端口一变,新子流就可能被哈希到另一条物理路径上

[4] 真正的问题是什么?

服务器原本靠四元组区分连接。新子流的四元组和原连接对不上,而且经过 NAT 之后,连客户端自己都不知道服务器看到的会是什么地址和端口,所以客户端也没法事先告诉服务器"我待会用地址 X 来连"

(3) 攻击者 Hijack 加一条子流怎么办?

如果谁都能声称"我属于这条连接",攻击者就能劫持连接

(4) 解决方式

  • 新子流也走一次正常的三次握手:
    • 让沿途所有 NAT 和防火墙都"登记"这条新连接,查件员就不会拦
    • 解决 "问题 2-1"
  • 第一次握手时双方交换随机密钥。新子流的 SYN 里带 MP_JOIN,其中包括:
    • 由密钥算出的 token,相当于"会员卡号",用来说明"我属于哪条连接" (本质: sessionID)
    • 不管你从哪个门、以什么身份进来,出示卡号,服务器就知道你属于哪条连接
    • NAT 只改 IP 头和 TCP 头里的地址和端口,不会改这个token
    • 解决 "问题 2-2"
  • 基于密钥的 MAC 签名,用来证明"我是本人"
    • 解决 "问题 2-3"

另外还有一个细节:服务器通常看不到 NAT 后面的客户端,主动发 SYN 很可能失败。所以如果服务器有新地址,它用 ADD_ADDR 告诉客户端,由客户端发起连接

问题 3 (最核心): 数据怎么编号?

这是整个设计的心脏,采用两层编号

子流序号 数据序号 (DSN, Data Sequence Number)
作用 让每个子流看起来是一条连续的普通 TCP 记录"这是整个数据流的第几个字节"
放在哪 TCP 头里原本的序号字段 TCP Option
用途 子流内部检测丢包、重传 接收端在连接层重新排序
类比 快递运单号 书的页码

这样,查件员在每条路上看到的序号都是连续的,不会起疑;接收端则按数据序号把各路送来的数据拼回原来的顺序

问题 4: 确认和流控, 为什么还需要一个"全局 ACK"?

流控的作用是,告诉对方"我的接收缓存还有多少空间,别发多了"

如果每个子流各有一块缓存,就可能死锁:

  1. 应用要的下一段数据在 WiFi 子流 上丢了
  2. 4G 子流 继续收数据,把自己那块缓存塞满
  3. WiFi 断了,丢的那段只能改走 4G 重发
  4. 但 4G 的缓存已经满了,没有空间,数据进不来。应用等着这段数据,缓存也就永远腾不出来

解决: 所有子流共享一块接收缓存,接收窗口按数据序号来算

这又带来一个问题:发送端需要知道"整体上已经确认到哪了"

能不能从各个子流的 ACK 推算出来?不行。两条路速度不同,ACK 回来的顺序是乱的,推算结果会时高时低,发送端会误判窗口,要么多发导致被丢,要么少发浪费机会

所以必须有一个显式的全局确认:Data ACK,相当于收件人直接说"全书前 500 页我都收到了"

问题 5: 这些"额外信息"放哪?

数据序号 (DSN) 和 Data ACK 有两个地方可以放:

  • TCP Option: 备注栏
  • 数据负载: 塞进书页里

IETF 曾为此争论很久。本文给出结论:应该放进 TCP Option 中!!!

放进负载为什么不行:一旦放进负载,Data ACK 就成了"数据",而数据是要受流控约束的。于是可能出现:

  • 对方缓存满了,我想发 Data ACK 告诉它"我收到了",但流控不允许我发
  • 对方收不到 Data ACK,就无法释放它的发送缓存,于是卡住,也就不去读接收缓存
  • 对方不读,缓存就一直是满的,我的 Data ACK 也就一直发不出去

这就形成了死锁!!!

所以只能放在 TCP 选项里: Data ACK 可以放进不带数据的纯 ACK 包的选项里发出去,纯 ACK 不占用对方的接收缓存,不受流控限制,所以永远能发出去

把这些套进第三节的包结构,一个 MPTCP 数据包大概长这样:

Text Only
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
IP 头:  源 10.20.30.40  →  目的 服务器        (4G 子流的地址)
TCP 头固定部分:
    序号 = 5001          ← 子流序号(运单号),在这条子流上连续
    确认号 = …           ← 子流级确认
    窗口 = …
TCP 选项 (MPTCP):
    数据序号映射: 子流偏移 X 起的 1400 字节 = 全局数据第 Y 字节起
    Data ACK   = 全局数据我已收到第 Z 字节之前的所有内容
    校验和
数据: 1400 字节
  • MiddleBox 只看固定部分: 看到的是一条序号连续、平平无奇的普通 TCP 连接
  • MPTCP 的两端: 从选项里读出"全书页码"并重新组装

问题 6: Option 也会被查件员弄乱

数据序号写在选项里还不够,查件员还会这样干扰:

  • 切包(网卡 TSO):一个大包被切成几个小包,选项被原样复制到每个小包上。如果选项只写"本包从第 500 页开始",几个小包都会说自己从第 500 页开始,映射就乱了
    • 解决:选项要写清楚"子流的第 X 到 X+L 字节,对应全书的第 Y 到 Y+L 字节",并且用相对偏移来写,这样即使序号被改写也不受影响
  • 合包:两个包被合并成一个时,40 字节的选项区放不下两份映射,只能保留一份,另一部分数据就"没有页码"了
    • 解决:这部分数据只会得到子流级确认、得不到 Data ACK,发送端会重传,连接仍能继续前进
  • 改内容(比如 NAT 改写 FTP 报文里的 IP 地址,长度会变):映射整体错位
    • 解决:在映射信息里加校验和
    • 校验不通过,如果还有别的子流,就关掉这一条;如果只剩这一条,就退回普通 TCP

问题 7: 路通了,速度反而变慢

实现层面的坑

这是很多人想不到的一点。WiFi 很快(RTT 20 ms),3G 很慢(RTT 150 ms,在大缓冲的影响下实际能到秒级)

接收端要按顺序把数据交给应用。如果一段数据走了 3G 还没到,WiFi 送来的后续数据只能在缓存里排队等它。缓存一旦被占满,快的 WiFi 也只能停下来,被慢路拖住

实验中,在缓存较小的情况下,MPTCP 甚至比只用 WiFi 的普通 TCP 还慢,这样就没人愿意用了

解决 (sender-side):

  • M1 机会重传:
    • 发现缓存被慢路上的某段数据卡住时,用快路把这段数据再发一遍,不必干等
  • M2 惩罚慢子流:
    • 把拖后腿的子流的拥塞窗口减半,让它少承担一些数据,避免反复卡住

两者结合后: Buffer多就同时用两条路; Buffer少也得确保至少不比最好那条路差

结论是: 在极端场景下,吞吐提升了 10 倍

MPTCP TL;DR
  1. 对外伪装: 每个子流都像普通 TCP, 让中间盒挑不出毛病
  2. 对内重组: 用数据序号 (DSN) 和 Data ACK 管理整条连接
  3. 灵活回退: 任何情况不对, 就退回普通 TCP