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 | |
3.2 TCP Head 的固定部分¶
| Text Only | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |

这些固定字段在 1981 年就定死了,格式不能改。一旦改了,全世界的设备都不认识。
3.3 TCP Option: TCP 留给未来的"备注栏"¶
想给 TCP 加新功能,又不能改固定字段,怎么办?
TCP 头末尾预留了选项区,格式是统一的"类型-长度-值"(TLV)
| Text Only | |
|---|---|
1 2 3 4 | |
接收方遇到不认识的 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 选项)里写上"这是全书第几页",收件人按备注重新排版
下面按一条连接的生命周期,看每一步碰到什么问题、怎么解决(笔者习惯采用"问题引导"的方式撰文😄)
五、碰到的核心问题与解决方式¶
问题 1: 怎么知道对方支持 MPTCP?¶
-
做法:
- 三次握手时,在 SYN 里加一个
MP_CAPABLE选项,相当于问"你会 MPTCP 吗?" - 对方支持就在
SYN/ACK里回一个
- 三次握手时,在 SYN 里加一个
-
坑:
- MiddleBox 可能只删掉回程的那个选项
- 结果: 客户端以为"对方不支持",服务器却以为"已经启用了",双方对暗号的理解不一致
-
解决:
- 握手完成后,客户端发的 数据包 也要继续带上 MPTCP 选项,直到确认对方收到为止
- 服务器如果收到的第一个数据包没有带选项,就知道路上被删了,立刻退回普通 TCP
Note
这是贯穿全文的第一个原则:发现情况不对,就安静地退回普通 TCP。最差也和 TCP 一样,不会更差
问题 2: 怎么给连接加新路?¶
手机连上 4G 后,想给已有连接加一个子流。这里有三个坑
(1) 不能直接在 4G 上发数据, 未经过握手的包会被拦¶
关键在于:NAT 和防火墙都是"有状态"的设备,它们心里有一张"连接登记表"
NAT连接登记表
登记表是怎么建立的? 以手机走 4G 访问服务器为例。手机的 4G 地址通常是运营商分配的内网地址(比如 10.20.30.40),出去时要经过运营商的 NAT,被换成一个公网地址:
| Text Only | |
|---|---|
1 | |
NAT 看到一个 SYN 包出去时,会在表里登记一行:
| Text Only | |
|---|---|
1 | |
之后这条连接的所有包(包括服务器发回来的)都按这一行来转换和放行。
这个映射关系是存储在 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"
- 由密钥算出的 token,相当于"会员卡号",用来说明"我属于哪条连接" (本质:
- 基于密钥的 MAC 签名,用来证明"我是本人"
- 解决 "问题 2-3"
另外还有一个细节:服务器通常看不到 NAT 后面的客户端,主动发 SYN 很可能失败。所以如果服务器有新地址,它用 ADD_ADDR 告诉客户端,由客户端发起连接
问题 3 (最核心): 数据怎么编号?¶
这是整个设计的心脏,采用两层编号
| 子流序号 | 数据序号 (DSN, Data Sequence Number) | |
|---|---|---|
| 作用 | 让每个子流看起来是一条连续的普通 TCP | 记录"这是整个数据流的第几个字节" |
| 放在哪 | TCP 头里原本的序号字段 | TCP Option |
| 用途 | 子流内部检测丢包、重传 | 接收端在连接层重新排序 |
| 类比 | 快递运单号 | 书的页码 |
这样,查件员在每条路上看到的序号都是连续的,不会起疑;接收端则按数据序号把各路送来的数据拼回原来的顺序
问题 4: 确认和流控, 为什么还需要一个"全局 ACK"?¶
流控的作用是,告诉对方"我的接收缓存还有多少空间,别发多了"
如果每个子流各有一块缓存,就可能死锁:
- 应用要的下一段数据在 WiFi 子流 上丢了
- 4G 子流 继续收数据,把自己那块缓存塞满
- WiFi 断了,丢的那段只能改走 4G 重发
- 但 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 | |
- 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
- 对外伪装: 每个子流都像普通 TCP, 让中间盒挑不出毛病
- 对内重组: 用数据序号 (DSN) 和 Data ACK 管理整条连接
- 灵活回退: 任何情况不对, 就退回普通 TCP