bigywater.tech
网络协议 · 60

TCP Fast Open:把数据塞进 SYN

TCP Fast Open 想把“握手一个往返 + 请求应答一个往返”压成一个往返:第一次连接拿一个 cookie,以后的连接把数据直接塞进 SYN。这篇先在 macOS 上实测它在线上长什么样、能省多少时间、公网上谁支持,再用 root 权限补上密钥轮换和“客户端为什么悄悄不用了”,最后在 Linux 容器里对照 Linux 的行为、重放和被中间设备丢弃的情形。

202 → 101 ms
50 ms 单向延迟的链路上,普通 connect + send 与 SYN 带数据的 connectx 的耗时中位数
3 次重传后停用
macOS 在 SYN 带数据重传 3 次后对该目的地不再发 TFO 选项,至少 40 分钟没有恢复
5 次
被重放的 SYN 让 Linux 服务端应用读到同一份数据的次数(含原始 1 次)

开关与调用方式

实测 · 本机执行输出
  net.inet.tcp.fastopen: 3
  net.inet.tcp.fastopen_backlog: 10
  listen() 之前对监听套接字 setsockopt(TCP_FASTOPEN, 1):EINVAL
  listen() 之后 setsockopt(TCP_FASTOPEN, 1):成功,读回 1
  Python 的 socket 模块里有 TCP_FASTOPEN: True;有 MSG_FASTOPEN: False(Linux 用 sendto + MSG_FASTOPEN,macOS 用 connectx,这里通过 ctypes 调用 libc 的 connectx)

net.inet.tcp.fastopen=3;服务端要在 listen() 之后设 TCP_FASTOPEN;客户端用 connectx 带数据(Python 需要 ctypes)。

服务端不支持时

实测 · 本机执行输出
  服务端收到的数据 [b'hello0', b'hello1', b'hello2', b'hello3', b'hello4', b'hello5', b'hello6', b'hello7']
  第 1 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 2 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 3 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 4 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 5 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 6 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 7 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']
  第 8 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无;SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']

用一个客户端从没用过的目的地做对照:8 次连接的 SYN 都是 0 字节、带长度 2 的 TFO 选项请求 cookie,服务端不回 TFO 选项,数据在握手后才发。

cookie 与 SYN 带数据

实测 · 本机执行输出
  服务端收到的数据 12 条,全部正确:True
  12 个连接的 SYN 载荷(字节):[0, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7]
  12 个 SYN 的 TFO 选项长度:[[2], [10], [10], [10], [10], [10], [10], [10], [10], [10], [10], [10]](2 = 只请求 cookie;10 = 带 8 字节 cookie)
  第 2 个连接(SYN 里带数据的第一个)的全部包:
    +   0.00 ms  C→S  SYN       载荷   7 字节  选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO']  ← TFO 选项长度 [10]
    +   0.26 ms  S→C  SYN+ACK   载荷   0 字节  选项 ['MSS', 'WS', 'TS', 'SACKok']
    +   0.32 ms  C→S  ACK       载荷   0 字节  选项 ['TS']
    +   0.34 ms  S→C  ACK       载荷   0 字节  选项 ['TS']
    +   0.47 ms  S→C  PSH+ACK   载荷  10 字节  选项 ['TS']
    +   0.56 ms  C→S  FIN+ACK   载荷   0 字节  选项 ['TS']
    +   0.58 ms  S→C  ACK       载荷   0 字节  选项 ['TS']
    +  55.64 ms  S→C  FIN+ACK   载荷   0 字节  选项 ['TS']
    +  55.77 ms  C→S  ACK       载荷   0 字节  选项 ['TS']
  它的 SYN+ACK 的 ack = SYN 的 seq + 8(1 个 SYN + 7 字节数据)

第一个连接请求 cookie(TFO 选项长 2);之后的连接 SYN 带 8 字节 cookie(选项长 10)和数据,SYN+ACK 的 ack 确认了数据。

省下一个往返

实测 · 本机执行输出
  普通 connect + send     :逐次(ms):[202, 200, 201, 202]
  connectx(TFO)         :逐次(ms):[205, 101, 100, 103]
  connectx 这 4 次的 SYN 载荷(字节):[0, 5, 5, 5]
  普通 connect + send 中位 202 ms;connectx 里 SYN 带数据的 3 次中位 101 ms,SYN 没带数据的 1 次中位 205 ms;省下 101 ms ≈ 1.0 个 RTT(RTT = 2 × 50 ms)

50 ms 单向延迟下,普通连接 202 ms,带数据的 TFO 连接 101 ms。

公网服务器

实测 · 本机执行输出
  connectx 第 1 次:SYN 载荷 56 字节,SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO'](TFO 选项长度 [10]);SYN+ACK 选项 ['MSS', 'SACKok', 'TS', 'WS'](TFO 选项长度 []),ack = SYN 的 seq + 57
  connectx 第 2 次:SYN 载荷 56 字节,SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO'](TFO 选项长度 [10]);SYN+ACK 选项 ['MSS', 'SACKok', 'TS', 'WS'](TFO 选项长度 []),ack = SYN 的 seq + 57
  connectx 第 3 次:SYN 载荷 56 字节,SYN 选项 ['MSS', 'WS', 'TS', 'SACKok', 'TFO'](TFO 选项长度 [10]);SYN+ACK 选项 ['MSS', 'SACKok', 'TS', 'WS'](TFO 选项长度 []),ack = SYN 的 seq + 57
  connectx 到收到响应第一批字节:[267, 243, 248] ms;响应开头 [b'HTTP/1.1 200'];普通 connect + send:[386, 378, 476] ms

example.com 接受 SYN 里的 56 字节请求并确认,首字节比普通连接快约一个 RTT。

服务端密钥轮换

实测 · 本机执行输出
  设置 K1:net.inet.tcp.fastopen_key:  ->
  换成 K2:net.inet.tcp.fastopen_key:  ->
  服务端收到的数据 [b'hello0', b'hello1', b'hello2', b'hello3', b'hello4', b'hello5', b'hello6'],全部正确:True
  第 1 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 e317342e
  第 2 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 e317342e;SYN+ACK 的 TFO 选项 无,数据被接受
  第 3 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 e317342e;SYN+ACK 的 TFO 选项 无,数据被接受
  第 4 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 e317342e;SYN+ACK 的 TFO 选项 533701b8,数据没被接受(ack = seq + 1)   ← 换密钥之后
  第 5 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 533701b8;SYN+ACK 的 TFO 选项 无,数据被接受   ← 换密钥之后
  第 6 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 533701b8;SYN+ACK 的 TFO 选项 无,数据被接受   ← 换密钥之后
  第 7 个:SYN 载荷 6 字节,TFO 选项长度 [10],cookie 前 4 字节 533701b8;SYN+ACK 的 TFO 选项 无,数据被接受   ← 换密钥之后

换密钥之后,旧 cookie 被拒,SYN+ACK 的 ack 只有 seq + 1 并带来新 cookie,数据在握手后重发,不丢;下一个连接起用新 cookie。

客户端悄悄停用

实测 · 本机执行输出
  连接总数 40(前 3 个无丢包;其后 30 个在 30% 丢包下;之后每 10 秒一次);丢包阶段 connectx/recv 失败 0 次
  无丢包第 1 个:0B/TFO2
  无丢包第 2 个:7B/TFO10
  无丢包第 3 个:7B/TFO10
  丢包第  1 个:6B/TFO10
  丢包第  2 个:6B/TFO10 → 0B/无TFO
  丢包第  3 个:6B/TFO10
  丢包第  4 个:6B/TFO10 → 0B/无TFO
  丢包第  5 个:6B/TFO10 → 0B/无TFO
  丢包第  6 个:0B/无TFO
  丢包第  7 个:0B/无TFO
  丢包第  8 个:0B/无TFO
  丢包第  9 个:0B/无TFO
  丢包第 10 个:0B/无TFO
  丢包第 11 个:0B/无TFO
  丢包第 12 个:0B/无TFO
  丢包第 13 个:0B/无TFO
  丢包第 14 个:0B/无TFO
  丢包第 15 个:0B/无TFO
  丢包第 16 个:0B/无TFO
  丢包第 17 个:0B/无TFO
  丢包第 18 个:0B/无TFO
  丢包第 19 个:0B/无TFO
  丢包第 20 个:0B/无TFO
  丢包第 21 个:0B/无TFO
  丢包第 22 个:0B/无TFO
  丢包第 23 个:0B/无TFO
  丢包第 24 个:0B/无TFO
  丢包第 25 个:0B/无TFO
  丢包第 26 个:0B/无TFO
  丢包第 27 个:0B/无TFO
  丢包第 28 个:0B/无TFO
  丢包第 29 个:0B/无TFO
  丢包第 30 个:0B/无TFO
  关掉丢包之后:
    +   0 s:0B/无TFO
    +  10 s:0B/无TFO
    +  20 s:0B/无TFO
    +  30 s:0B/无TFO
    +  40 s:0B/无TFO
    +  50 s:0B/无TFO
    +  60 s:0B/无TFO
    +  70 s:0B/无TFO
    +  80 s:0B/无TFO
    +  90 s:0B/无TFO
    + 100 s:0B/无TFO
    + 110 s:0B/无TFO
    + 120 s:0B/无TFO
    + 130 s:0B/无TFO
    + 140 s:0B/无TFO
    + 150 s:0B/无TFO
    + 160 s:0B/无TFO
    + 170 s:0B/无TFO
    + 180 s:0B/无TFO
    + 190 s:0B/无TFO
    + 200 s:0B/无TFO
    + 210 s:0B/无TFO
    + 220 s:0B/无TFO
    + 230 s:0B/无TFO
    + 240 s:0B/无TFO
    + 250 s:0B/无TFO
    + 260 s:0B/无TFO
    + 270 s:0B/无TFO
    + 280 s:0B/无TFO
    + 290 s:0B/无TFO
    + 300 s:0B/无TFO
    + 310 s:0B/无TFO
    + 320 s:0B/无TFO
    + 330 s:0B/无TFO
    + 340 s:0B/无TFO
    + 350 s:0B/无TFO

30% 丢包下,SYN 带数据的重传 3 次之后,客户端对该目的地不再发 TFO 选项;丢包关掉后至少 40 分钟没有恢复。

停用的范围与恢复

实测 · 本机执行输出
  同一目的地、新端口:0B/无TFO;0B/无TFO;0B/无TFO
  新目的地 127.77.0.22:0B/TFO2;6B/TFO10;6B/TFO10
    +    1 s:0B/无TFO
    +   61 s:0B/无TFO
    +  121 s:0B/无TFO
    +  181 s:0B/无TFO
    + 2281 s:0B/无TFO
    + 2341 s:0B/无TFO
    + 2401 s:0B/无TFO
  共轮询 41 次,覆盖 2401 秒;最后一次 SYN 载荷 0 字节;没有恢复

停用是按目的地址记的:同一目的地换端口仍不尝试,别的目的地不受影响;之后每 60 秒试一次,共 41 次、覆盖 2401 秒,没有恢复。

Linux:省一个往返

实测 · 本机执行输出
  清掉缓存前 ip tcp_metrics 里的 TFO 记录:1 条带 fo_cookie
  清掉后:0 条带 fo_cookie
  耗时(ms):普通 connect + send ['313.5', '215.0', '213.0', '212.5'];TFO(第 1 次取 cookie)['214.1', '103.7', '110.7', '108.7', '108.8'];清缓存后 TFO ['214.8', '102.3']
  普通 4 次中位 214 ms;TFO 里 SYN 带数据的 4 次中位 109 ms
  每个连接的 SYN(按时间顺序:4 个普通、5 个 TFO、2 个清缓存后):
    第 1 个:SYN 载荷 0 字节,TFO 选项长度 无,cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无
    第 2 个:SYN 载荷 0 字节,TFO 选项长度 无,cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无
    第 3 个:SYN 载荷 0 字节,TFO 选项长度 无,cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无
    第 4 个:SYN 载荷 0 字节,TFO 选项长度 无,cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 无
    第 5 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 a407c2fe
    第 6 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据被接受
    第 7 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据被接受
    第 8 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据被接受
    第 9 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据被接受
    第 10 个:SYN 载荷 0 字节,TFO 选项长度 [2],cookie 前 4 字节 -;SYN+ACK 的 TFO 选项 a407c2fe
    第 11 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据被接受
  服务端收到的数据:n0 n1 n2 n3 t0 t1 t2 t3 t4 f0 f1

同样 100 ms 往返,Linux 的 TFO 连接中位 109 ms,普通连接 214 ms;ip tcp_metrics flush 清掉缓存后又要重新取 cookie。

对不支持的服务端

实测 · 本机执行输出
  3 次耗时(ms):['217.3', '216.1', '213.6']
    第 1 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据没被接受(ack = seq + 1)
    第 2 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据没被接受(ack = seq + 1)
    第 3 个:SYN 载荷 2 字节,TFO 选项长度 [10],cookie 前 4 字节 a407c2fe;SYN+ACK 的 TFO 选项 无,数据没被接受(ack = seq + 1)
  服务端(7001)收到的数据:x0 x1 x2

Linux 客户端把 cookie 和数据发给没开 TFO 的端口:数据没被确认,握手后重发,不丢不重,耗时与普通连接一样。

SYN 里的数据可被重放

实测 · 本机执行输出
  (容器网卡开着发送校验和卸载,抓到的出站包里 TCP 校验和是占位值,直接重发会被服务端丢弃——第一次尝试时服务端计数 TcpInCsumErrors = 2,重发的包没有生效;下面是重新计算校验和之后的结果)
  抓到的 SYN:载荷 7 字节,TFO 选项长度 [10]
  服务端内核计数 TcpExtTCPFastOpenPassive(收到有效 cookie 的 SYN 并建立子连接的次数)增加了 5(1 次原始 + 重放)
  服务端应用收到的记录(9 条):['order-0', 'order-0', "ERR ConnectionResetError(104, 'Connection reset by peer')", 'order-0', "ERR ConnectionResetError(104, 'Connection reset by peer')", 'order-0', "ERR ConnectionResetError(104, 'Connection reset by peer')", 'order-0', "ERR ConnectionResetError(104, 'Connection reset by peer')"]
  其中应用读到 order-0 的有 5 次,另有 4 条是随后收到客户端 RST 的报错(客户端内核没有对应的套接字,对重放引发的 SYN+ACK 回 RST)

原样重发抓到的 SYN,服务端应用又读到同一份数据:5 次读取里有 4 次来自重放。所以只适合幂等请求。

被中间设备丢弃

实测 · 本机执行输出
  tcp_fastopen_blackhole_timeout_sec = 0
  丢弃期间 5 次耗时(ms):['1269.1', '1227.7', '1228.9', '1227.0', '1231.1'];撤掉规则后再 3 次:['106.0', '106.2', '103.0']
    第 1 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B, +1.1s/0B
    第 2 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B, +1.0s/0B
    第 3 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B, +1.0s/0B
    第 4 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B, +1.0s/0B
    第 5 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B, +1.0s/0B
    第 6 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B
    第 7 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B
    第 8 个连接的 SYN(相对时间 s / 载荷):+0.0s/2B
  客户端内核计数(从本组容器启动起累计,其中第十节的 3 次 + 本节的 5 次 = 8 次 SYN 里的数据没被对端确认):TcpExtTCPFastOpenActiveFail 8;TcpExtTCPFastOpenBlackhole 0;
  服务端收到的数据:d0 d1 d2 d3 d4 e0 e1 e2

带数据的 SYN 被丢掉时,Linux 约 1 秒后重传无数据的 SYN,每个连接多等约 1.0 秒;这个内核里的黑洞检测没启用,连续 5 次都是这样。

分步演示

TCP Fast Open
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一、三、四、五节来自较早的一次运行(lo0 和 example.com:80 各 3 次,第四节加了 netlab 每方向 50 ms);第二、六、七、八节在 lo0 的别名 127.77.0.N 上,别名的增删和 net.inet.tcp.fastopen_key 的设置通过一个只允许这几件事的免密 root 脚本完成,第七节用 netlab 开了 30% 丢包、单向 20 ms;第九到十二节在 Docker Desktop 的 Linux 虚拟机里的两个容器之间(内核 7.0.14-linuxkit,eth0 上 netem 每方向 50 ms),第十二节用 iptables 丢弃长度 ≥ 70 字节的 SYN 模拟中间设备
    • 没有验证:macOS 客户端停用 TFO 的确切阈值和计数规则(只在 30% 丢包的一次运行里看到,SYN 带数据重传 3 次后停用);net.inet.tcp.clear_tfocache 能否清除这种停用(没有给它免密权限,没有试);cookie 的有效期和自动轮换周期(只试了手动换密钥);macOS 作为服务端时对 SYN 重放的反应(重放实验只在 Linux 容器里做,5 次重放里 1 次没有被计数,原因没有查明);Linux 内核的黑洞检测(这个虚拟机里该开关是 0,没有启用);公网上经过真实中间设备(NAT、防火墙)时 SYN 带数据的处理,这里只用 iptables 模拟;HTTP / TLS 之上的实际收益(TCP 握手之后 TLS 还要多个往返,TFO 只省 TCP 握手那一个)
    京ICP备2026067502号-1京公网安备11010502063487号