TCP-over-TCP 与 Split-TCP:深入探究 TCP 隧道
坑边闲话:前阵子排查一条跨境隧道时,我在
ss -ti里看到外层 TCP 的拥塞窗口被压到了 5,顺着这个数字一路往下挖,发现事情没那么简单。本文尝试把 TCP-over-TCP、Split-TCP、WireGuard 封装这几件事讲清楚,告诉你为什么同样是「跑在 TCP 上的隧道」,有的几乎零损耗,有的一碰丢包就崩。
1. 从一个问题开始·
1.1 实验环境·
我手里有四个 OpenWrt 站点和一台境外 VPS:
- lab:位于北京,接入 CSTNET(中国科技网),有固定公网 IPv4,是国内 WireGuard 网络的中心节点。
- home:家用联通宽带,PPPoE 拿到的是运营商级 NAT(CGNAT)之后的私网地址,没有公网 IPv4,但有公网 IPv6。
- dorm / office:与 lab 处在同一个校园网上游。
- fra:法兰克福的一台 VPS,有公网 IPv4,没有 IPv6。
国内四个站点之间用 WireGuard 组成中心辐射结构(hub-and-spoke),WireGuard 原生跑在 UDP 上,国内段一切正常。麻烦出在跨境段:WireGuard 的握手特征非常明显,跨境使用很快会被识别并阻断,所以出境的那一段只能交给带伪装的代理协议(VLESS + REALITY 之类)。整体拓扑大致如下:
flowchart LR
subgraph domestic["domestic: WireGuard over UDP (hub-and-spoke)"]
dorm["dorm"]
lab["lab (Beijing, CSTNET)<br/>public IPv4, WG hub"]
office["office"]
home["home<br/>carrier-grade NAT<br/>no public IPv4"]
end
subgraph abroad["cross-border legs must be carried by disguised proxy protocols"]
fra["fra (Frankfurt VPS)<br/>public IPv4, no IPv6"]
client["overseas client"]
end
dorm <-->|WG| lab
lab <-->|WG| office
home <-->|WG| lab
lab <-->|"VLESS+REALITY over TCP<br/>(both directions, see ch.5)"| fra
home ==>|"WG inside VLESS/TCP"| fra
fra <-->|WG| client
1.2 需求与约束·
需求其实很朴素:
- 从海外访问 lab 内网的服务,比如一台跑
llama.cpp的机器(HTTP API)。这要求 fra 能主动连进 lab。 - lab 内网也要能访问 fra 那一侧的网络。这要求 lab 能主动连到 fra。
- home 和海外双向互通。但 home 没有公网 IPv4,fra 又没有 IPv6,任何连接都只能由 home 发起。
约束有两条:跨境段必须伪装;VLESS 是 client-server 协议,只有客户端能发起连接。
最后落地成了两种做法:
- lab 与 fra 之间:两条方向相反的 VLESS + REALITY 隧道,各自按连接代理。
- home 与 fra 之间:home 主动建立一条 VLESS 连接,把 WireGuard 的 UDP 包装进去,靠 WireGuard 实现双向互通。
1.3 带着三个问题读·
这两种做法表面上都是「TCP 隧道」,实测表现却天差地别。读下去之前,不妨先想想这三个问题:
- VLESS、SOCKS5 都跑在 TCP 上,为什么 lab 那条不算 TCP-over-TCP,而 home 那条算?
- 把 WireGuard 塞进 VLESS 之后,到底是哪一步让事情变坏的?
- 一条带 TLS 伪装的隧道,到底会让性能损失多少?
最后一章会逐一回答。
2. TCP-over-TCP:两层可靠性叠在一起会发生什么·
TCP-over-TCP 指的是:把一条 TCP 连接的完整报文(包括 TCP 头、确认、重传)当作数据,塞进另一条 TCP 连接里传输。基于 TCP 的 VPN
- OpenVPN 的 TCP 模式,用 SSH 转发 IP 包,
- 把 WireGuard 装进 TCP 代理
都属于这一类。早在 2001 年,Olaf Titz 就写过一篇 Why TCP-over-TCP Is A Bad Idea, 核心观点至今成立。要理解它,得先回到 TCP 自己是怎么保证可靠的。
2.1 TCP 靠什么保证可靠:重传计时器与拥塞窗口·
TCP 有两个相互配合的控制回路:
- 重传。RFC 6298 规定了重传超时(RTO)的计算:用平滑往返时间
SRTT和往返时间偏差RTTVAR估算,RTO = SRTT + 4 * RTTVAR,RFC 建议下限 1 秒,Linux 实现取 200 ms。超时后 RTO 按指数退避翻倍。另外,RFC 5681 定义了快速重传:收到三个重复确认就立即重传,不必等超时。 - 拥塞控制。Reno、CUBIC 这类算法把丢包当作拥塞信号:快速重传时窗口乘以一个系数(Reno 是 0.5,CUBIC 是 0.7,见 RFC 9438);一旦超时,拥塞窗口
cwnd直接降到 1 个 MSS,重新慢启动。
这两个回路的输入都只有两样东西:丢包和往返时间。TCP-over-TCP 的所有问题,都源于外层把这两个信号扭曲了。
2.2 内层 TCP 的失明·
外层 TCP 提供的是可靠、有序的字节流。对内层来说,它的报文永远不会丢,只会迟到。
这让内层基于丢包的拥塞控制彻底失明:它收不到任何丢包信号,于是认为路径畅通,持续扩大窗口。多出来的数据并没有凭空消失,而是堆积在外层的发送缓冲区里(代理进程的用户态缓冲区加上内核 socket 缓冲区),表现为延迟越来越高,也就是常说的 bufferbloat。内层唯一能感知到的信号是延迟,而延迟恰恰是 Reno、CUBIC 不怎么理会的东西,直到它大到触发超时。
2.3 计时器打架与重传放大·
真正的灾难发生在外层丢包时。设想跨境链路上丢了一个外层报文:
- 外层必须先把它重传成功(快速重传至少要一个往返,超时则要 200 ms 以上并且退避),在此之前,后面所有数据都只能在接收端缓冲区里干等。
- 内层看到的往返时间从 140 ms 突然跳到几百毫秒甚至几秒。它的 RTO 是按平滑往返时间算的,很可能被这次跳变超过。
- 内层判定超时:拥塞窗口降到 1,RTO 翻倍,然后把「丢失」的报文重发一遍。但这些报文其实没丢,还排在外层的队列里。
- 外层是可靠的,它会把这些重复数据也老老实实地送到对端,占用本就紧张的带宽,队列更长,延迟更高。
时间线大致如下:
sequenceDiagram
participant S as inner TCP sender
participant T as outer TCP (tunnel)
participant R as inner TCP receiver
S->>T: sends A, B, C
T->>R: outer segments 1-3
T-xR: outer segment 4 (lost)
T->>R: outer segments 5-7
Note over R: 5-7 arrived but are held in the receive buffer
Note over S: no ACK, RTT looks huge
Note over S: RTO fires:<br/>cwnd = 1, RTO x2
S->>T: resend A', B', C'
T->>R: retransmit outer segment 4
Note over R: segments 4-7 released at once
T->>R: must now also deliver A', B', C'<br/>(copies of data it was already carrying)
两个控制回路对同一个事件、在不同的时间尺度上做出反应,结果互相放大,吞吐量可能跌到比任何一层单独工作时都低。这就是所谓的「TCP meltdown」。
需要说句公道话:现代 Linux 有 F-RTO(RFC 5682,检测虚假超时)、DSACK(RFC 2883,事后发现重传是多余的并撤销降窗)、RACK 等机制,所以并不是每次丢包都会熔毁。但在 RTT 长、丢包率不低的跨境链路上,这些问题会被明显放大。
2.4 队头阻塞:一个包卡住所有连接·
上面只讨论了一条内层连接。如果多条内层连接共用同一条外层 TCP,还会多出一个问题:外层的有序交付意味着,一个外层报文丢失,会卡住它后面所有内层连接的数据,哪怕那些数据早就到了,和丢的那个包毫无关系。
1 | one outer TCP connection (strictly in-order byte stream) |
这和 HTTP/2 跑在 TCP 上遇到的是同一个问题。QUIC(RFC 9000)把流的有序性下放到每个 stream 内部、底层改用 UDP,正是为了绕开它。
2.5 实测:一条被丢包压垮的外层连接·
home 那条 WireGuard-over-VLESS 的链路,外层只有一条 TCP 连接。我在 home 上看到的状态是这样的:
1 | ss -tin dst <fra-ip> |
1 | ESTAB 0 0 <home-wan>:49110 <fra-ip>:<port> users:(("xray",...)) |
几个数字值得细看:
| 指标 | 数值 | 含义 |
|---|---|---|
rtt / minrtt |
174 ms / 144 ms | 平均往返时间比最低值高了 30 ms,说明有排队 |
| 重传 | 40228 个数据报文里重传 110 个,约 0.27% | 丢包率不高,但对长 RTT 的连接来说已经足够伤 |
dsack_dups |
27 | 110 次重传里有 27 次事后被证明是多余的 |
cwnd / ssthresh |
5 / 3 | 拥塞窗口被丢包反复压低 |
| 估算速率 | 5 × 1388 B × 8 ÷ 0.174 s,约 320 kbit/s | 这就是外层此刻能提供的全部带宽,所有内层流量共享 |
有意思的是反方向。fra 发往 home 的数据由 fra 的内核负责拥塞控制,而 fra 用的是 BBR: 同一条连接上,那个方向的窗口很大,重传率约 0.18%,完全没有被压垮。
原因在于算法的设计思路不同。
- CUBIC 属于基于丢包的算法,跨境链路上那些与拥塞无关的随机丢包,都会被它当作拥塞信号而降速。
- BBR(Cardwell 等人 2016 年发表)则是基于模型的:它持续估计瓶颈带宽和最小往返时间,并按这两个量来控制发送,不会因为零星丢包就把窗口砍掉。
home 那台路由器的内核只有 reno 和 CUBIC, 这就是同一条连接两个方向表现迥异的原因。装上 BBR 能明显缓解这个问题,但它解决不了嵌套本身:外层依然是可靠有序的,队头阻塞依然存在。
3. Split-TCP:在代理处把 TCP 切成两段·
3.1 机制:终结、缓冲、重新发起·
lab 那条隧道之所以表现好,是因为 VLESS / SOCKS5 在不开多路复用时,做的是按连接代理:
- 本地应用的 TCP 连接在本地代理处就结束了。握手、确认、重传,都由代理代替真正的对端完成。
- 代理把收到的应用数据读进用户态缓冲区,作为纯字节流写进跨境的外层 TCP。内层 TCP 的头部、确认、重传都不会跨境。
- 对端代理收到字节流后,再和真正的目标服务器建立一条全新的 TCP 连接。
于是一条逻辑连接变成了三段首尾相接、互不嵌套的 TCP:
flowchart LR
app["app"]
lp["local proxy (sing-box)<br/>handshake, ACK and<br/>retransmission end here"]
rp["remote proxy (sing-box)"]
srv["server"]
app <-->|"TCP #1: LAN, RTT under 1 ms<br/>(app talks to the proxy)"| lp
lp <-->|"TCP #2: cross-border, RTT ~135 ms<br/>carries payload bytes only,<br/>no inner TCP headers"| rp
rp <-->|"TCP #3: remote LAN / Internet"| srv
本地这一段 RTT 不到 1 ms,应用的拥塞窗口几乎瞬间就能涨起来。又长又容易丢包的跨境段,只由一条 TCP 负责可靠性和拥塞控制。三段各管各的,不存在第 2 章那种两层回路互相干扰的问题。注意,这一点和外层用几条连接无关,哪怕只有一条应用连接,也不存在嵌套。
3.2 RFC 3135 与性能增强代理·
这种做法早有名字。RFC 3135(2001 年)把它归入性能增强代理(Performance Enhancing Proxy,PEP)中的「split connection」一类:PEP 在问题链路的两端终结 TCP,中间那一段单独用一条为该链路优化过的连接传输。
当年推动 RFC 3135 的场景是卫星链路(RTT 极长,带宽时延积很大)和无线链路(大量与拥塞无关的丢包)。跨境链路恰好同时具备这两个特征。所以从 RFC 3135 的视角看,一个按连接工作的 VLESS 代理,其实就是一个「无心插柳」的 split-connection PEP。
RFC 3135 也花了不少篇幅讨论 PEP 的代价,其中两条和本文直接相关:一是它破坏了端到端语义(3.5 节);二是 IP 层的端到端加密(IPsec)会让 PEP 完全失效,因为它看不到也改不了 TCP 头。第 4 章会看到,WireGuard 带来的正是同一个问题。
3.3 流量控制从端到端变成逐跳·
切开之后,流量控制并没有消失,只是从端到端变成了逐跳传递:
- 跨境段变慢时,代理的缓冲区逐渐被填满;
- 缓冲区满了,代理就停止从本地连接读数据;
- 本地 TCP 的接收窗口随之缩小到 0,应用的
send()被阻塞。
反压就这样一跳一跳地传回应用。代价是每条连接都要占用代理的一份缓冲内存,而缓冲区的大小,决定了有多少数据处于「本地已确认、但还没真正送达」的状态。
3.4 多路复用的取舍·
不开多路复用时,每条应用连接各用一条外层 TCP:拥塞控制彼此独立,一条连接丢包也不会拖累别的连接,代价是每次新建连接都要付出握手的往返时间。开了多路复用(sing-box 的 multiplex、Xray 的 Mux),许多应用连接挤在少数几条外层 TCP 里:省掉了握手,但它们共享同一个拥塞窗口,也会互相队头阻塞,和 HTTP/2 一模一样。
把几种方式放在一起看:
| 方式 | 外层 TCP | 内层 TCP 是否被封装 | 拥塞控制的关系 | 跨连接的队头阻塞 |
|---|---|---|---|---|
| 按连接代理(不开多路复用) | 每个连接一条 | 否,两端各自终结 | 多套并列 | 无 |
| 开多路复用 | 一条或少数几条 | 否 | 一套共用 | 有 |
| WireGuard 装进 VLESS | 一条 | 是,整个 IP 包被装进去 | 嵌套 | 有 |
我在 lab 上看到的正是第一行:去往 fra 的外层连接有二十条左右,每条的拥塞窗口都还是初始值 10,几乎没有重传。
3.5 端到端语义·
代理在数据真正到达目标之前,就先向应用确认了。这听起来有点吓人,但要注意:即使没有代理,TCP 本身也从来不是完全端到端的。send() 返回成功,只说明数据被拷进了本机内核的缓冲区;就算收到了 TCP 确认,也只代表对端内核收到了,不代表对端应用已经处理。这就是 Saltzer、Reed 和 Clark 在 1984 年提出的「端到端论点」(end-to-end argument):可靠性最终只能由两端的应用自己保证。
Split-TCP 没有引入新的问题,它只是把「确认点」提前到了本地代理,让「以为送达、其实没到」的窗口变得更大。所以:
- 一问一答的协议基本不受影响:HTTP、SSH、数据库协议、git 等,收到响应本身就证明请求已经送达并被处理。
- 「只发不回」的协议需要留意:比如没有应用层确认的 syslog over TCP、Graphite 的纯文本协议、
cat file | nc host port这种直接灌数据的做法、打印机的原始 9100 端口。它们把「连接正常关闭」当作「对方已收到」,经过 split-TCP 之后,这个假设更容易落空。
4. WireGuard-over-VLESS 是怎么变成 TCP-over-TCP 的·
4.1 为什么需要 WireGuard·
home 在 CGNAT 后面,只能当客户端;fra 又没有 IPv6,没法直接连 home 的公网 IPv6。如果只用 VLESS,就只有 home 能主动连到 fra,反方向无路可走。
解决办法是在 VLESS 里再跑一层 WireGuard。home 上的 Xray 开一个任意门(dokodemo-door)入口,把本机 WireGuard 发出的 UDP 包送进 VLESS 连接,由 fra 上的 Xray 转交给 fra 本机的 WireGuard。home 的配置大致是这样(/etc/xray/config.json 中的入口,端口已替换为占位符):
1 | { |
home 的 WireGuard 把对端地址写成 127.0.0.1:15000。关键在 fra 那一侧:fra 的 WireGuard 看到的 home 对端地址,是本机 Xray 用来收发 UDP 的那个端口(形如 127.0.0.1:57736)。WireGuard 有「漫游」机制,会把对端地址更新为最近一个通过认证的报文的来源。只要 home 每 25 秒发一次保活包,这条通路就一直有效,fra 主动发给 home 的 WireGuard 包,会顺着 home 建好的那条 TCP 连接送回去。我实测过:fra 可以直接连上 home 内网的 SSH,home 也能 ping 通 fra 的隧道地址,往返都在 145 ms 左右。
VLESS 只决定了「谁来发起连接」,双向互通靠的是外面套的那层 WireGuard。
4.2 代理只看到一个 UDP 流·
问题恰恰出在这层 WireGuard 上。看一下封装层次:
1 | inner flow (app) [ IP | TCP seq/ack | payload ] |
在代理眼里,home 和 fra 之间只有一个 UDP 流:WireGuard 发往 fra 的那个。它看不见里面有多少条应用连接,只能把这串 UDP 报文原样、按序、可靠地塞进一条 VLESS 连接。于是:
- 内层 TCP 的报文头、确认、重传,全都被装进了外层 TCP,第 2.2 和 2.3 节的嵌套问题完整出现;
- 所有内层连接共用这一条外层 TCP,2.4 节的队头阻塞也一并出现;
- 连 DNS、QUIC 这类本来跑在 UDP 上的流量,也被迫享受了「可靠有序」的待遇,一个外层丢包同样会卡住它们。
2.5 节那条拥塞窗口只剩 5 的连接,就是这样来的。
4.3 加密让切分变得不可能·
为什么代理不能像 lab 那样,把 WireGuard 里面的 TCP 也切开?因为 split-TCP 的前提是代理能读懂并终结内层 TCP,而 WireGuard 恰恰把整个内层 IP 包都加密了。代理拿到的只是一串密文,既看不到 TCP 头,也无从冒充对端去确认。
这正是 RFC 3135 讨论 IPsec 时指出的问题:IP 层端到端加密会让 PEP 失效。WireGuard 就是一个现代版的 IPsec, 所以只要选择在三层用 WireGuard 打通,就天然放弃了 split-TCP. 这不是哪里配错了,而是结构决定的。
4.4 伪装、双向、无嵌套的困局·
把这些方案放在一起,会发现它们最多只能占到下面三个目标中的两个:
- 伪装:在国内的网络环境下,最可靠的是 TCP 上的 TLS,比如 REALITY.
- 双向的三层互通:像 WireGuard 那样,两端都能主动发起任意连接。
- 不出现 TCP 套 TCP
| 方案 | 伪装 | 双向三层 | 无 TCP 套 TCP |
|---|---|---|---|
| WireGuard 或 AmneziaWG 直接跑 UDP | 弱,跨境 UDP 常被限速,原生 WireGuard 会被识别 | 是 | 是 |
| WireGuard 装进 VLESS + REALITY | 是 | 是 | 否 |
| VLESS + REALITY 按连接代理 | 是 | 否,只能由客户端发起 | 是 |
| 伪 TCP 封装(udp2raw、phantun) | 弱,载荷是纯随机字节 | 是 | 是 |
最后一行值得多说一句。伪 TCP 封装只是给 UDP 套上一个 TCP 头,并不真的做重传,所以没有嵌套;但它的载荷看起来是完全随机的字节,而 Wu 等人在 USENIX Security 2023 的论文中报告过,防火墙正是会针对这类「全加密流量」做检测和封锁。AmneziaWG 是给 WireGuard 加了混淆的分支,但我没有找到它在国内的独立测试。
国内的工具生态最终几乎都收敛到「TLS 伪装 + 按连接代理」,原因就在这张表里:在这个环境中,伪装和避免嵌套,比双向的三层互通更重要。
5. 两端都有公网 IP 才是最好的情况·
5.1 两条单向隧道·
lab 有公网 IPv4,fra 也有,所以两边都能当服务端。最终的做法是两条方向相反、彼此独立的 VLESS + REALITY 隧道,每一条都是按连接代理的 split-TCP:
flowchart LR
subgraph tun["VLESS + REALITY, one outer TCP per flow"]
direction LR
lab["lab"]
fra["fra"]
lab -->|"tunnel A: lab dials fra<br/>(lab = client, fra = server)"| fra
fra -->|"tunnel B: fra dials lab<br/>(fra = client, lab = server)"| lab
end
这里有一段插曲。fra 连 lab 这个方向最初用的是明文 SOCKS5:用户名、密码以及 llama.cpp 的 HTTP 内容,都以明文跨境传输。后来换成了 REALITY,除了安全上的收益,建立连接也快了不少。原因可以直接数往返次数:
- 带用户名密码的 SOCKS5:TCP 握手、协商认证方式、认证、发起 CONNECT,一共大约 4 个往返。
- VLESS + REALITY:TCP 握手、TLS 1.3 握手,VLESS 请求头可以和第一批应用数据一起发出,一共大约 2 个往返。
实测建连到收到第一个字节,SOCKS5 约 672 到 707 ms,REALITY 约 420 到 450 ms,正好差了两个 RTT。
REALITY 的伪装目标我选了 lab 所在运营商自己的官网,原因有三:lab 去连它是国内直连,延迟只有 40 ms 左右,而 REALITY 处理每次握手都要去连一下伪装目标;它支持 TLS 1.3 和 X25519;一个指向该运营商 IP 的 TLS 连接、SNI 也是该运营商的域名,看起来非常自然。用不带凭据的 TLS 客户端去探测,拿到的是那个网站真实的证书。防火墙上,lab 的入站端口只对 fra 的地址开放。
5.2 VLESS 反向代理为什么不太可用·
对 home 这种只能当客户端的一侧,理论上还有一条路:反向代理。由 home 主动连到 fra,fra 再把需要发往 home 的连接沿着这条链路送回去。Xray 支持这个功能,但实际用起来有几道坎:
- 只有 Xray 支持。sing-box 没有对应的功能,我在 lab 和 fra 上用的都是 sing-box。
- 新旧两套写法。旧的 bridge/portal 写法已被官方标为废弃;新的简化版 VLESS 反向代理,据社区帖子说从 v25.10.15 才开始有,引入它的 PR 自己也说明这个子协议还不稳定,不保证不同版本之间兼容。
- 已知问题。有人报告从 v26.4.25 升级之后,主动连出那一侧的反向路由失效了(Xray-core issue #6242),我没有去确认现在是否已修复。
- 性能上的代价。反向连接会被多路复用到少数几条链路上,回到 3.4 节说的共享拥塞窗口和队头阻塞。
所以结论很朴素:能让两端都有公网可达性(IPv4 或 IPv6 都行),就尽量这样做,然后用两条单向的按连接代理隧道。只有一端可达时,WireGuard 装进 VLESS 是可行的折中,但要接受嵌套的代价,最好在两端发送方都启用 BBR。对 home 来说还好:那条链路九天里只走了约 2.6 MiB 的流量,基本都是保活包,TCP 套 TCP 的问题暂时只是理论上的。
6. Benchmark:隧道到底损失了多少·
6.1 测量方法与陷阱·
为了回答第三个问题,我在两端临时跑了一对极简的 Python 测速程序,对比三条路径:
flowchart LR
subgraph paths["test servers listen only on addresses that are reachable through the intended path"]
direction TB
subgraph dpath["direct"]
direction LR
d1["lab"] -->|"plain TCP over the Internet"| d2["fra"]
end
subgraph apath["tunnel A"]
direction LR
a1["lab"] --> a2["sing-box (lab)"]
a2 ==>|"VLESS+REALITY, lab dials"| a3["sing-box (fra)"]
a3 --> a4["fra"]
end
subgraph bpath["tunnel B"]
direction LR
b1["fra"] --> b2["sing-box (fra)"]
b2 ==>|"VLESS+REALITY, fra dials"| b3["sing-box (lab)"]
b3 --> b4["lab"]
end
end
几个保证结论可信的细节:
- 让路由决定路径。测速服务端只监听「只能经由某条隧道到达」的内部地址:lab 那侧监听内网地址,fra 那侧监听 TUN 网卡的地址。直连基线的服务端监听公网地址,但只接受 lab 公网 IP 发起的连接。
- 由接收方计时。吞吐量从收到第一个字节算到收到最后一个字节,不把建连时间算进去。
- 交替测试并核对路径。三条路径轮流测、跑多轮。事后在 sing-box 日志里数连接数,和测试次数一一对上,确认流量确实走了预期的路径。
- 同时记录 CPU。fra 只有 1 个 vCPU,需要排除 CPU 成为瓶颈的可能。
还有一个踩过的坑必须提:经过用户态 TUN 协议栈的 ping 和 TCP 握手都不可信。sing-box 这类程序会在本地直接接下 TCP 握手,甚至直接应答 ICMP echo。我在 lab 上 ping 法兰克福那边的地址,回包只用了不到 1 ms,而真实的 RTT 有 140 ms;对一个远端其实拒绝连接的端口,TCP connect 照样「成功」。只有应用层真实的回应(比如 SSH 服务端发来的欢迎信息)和接收方的计时才算数。
6.2 延迟·
测试时间是北京时间下午,不是晚高峰。
| 直连 | 隧道 A | 隧道 B | |
|---|---|---|---|
| 新建连接到收到首字节(中位数) | 268 ms | 448 到 453 ms | 418 到 421 ms |
| 已建立连接上的单次往返 | 135 到 141 ms | 127 到 144 ms | 133 到 138 ms |
直连建连是 2 个往返(TCP 握手加一次请求),隧道多出来的约 1 个往返主要是 TLS 1.3 握手。连接建好之后,三条路径每次往返的时间完全一样,几毫秒的差别只是线路本身的波动。
6.3 吞吐·
单流,每次 64 MB,单位 Mbit/s:
| 数据方向 | 直连 | 隧道 A | 隧道 B |
|---|---|---|---|
| lab 到 fra | 127、140 | 115、135 | 151、84 |
| fra 到 lab | 172、160、129 | 129、129、128 | 132、122、141 |
4 路并发,每路 32 MB,单位 Mbit/s:
| 数据方向 | 直连 | 隧道 A | 隧道 B |
|---|---|---|---|
| lab 到 fra | 241 | 281 | 203 |
| fra 到 lab | 365 | 324 | 395 |
fra 到 lab 方向的单流,前两轮直连看起来比隧道快 20% 左右,但第三轮三条路径同时补测,结果是 129、128、141,差别消失了,说明那只是线路波动。隧道 B 的 lab 到 fra 方向有一次只有 84,并发也是三者中最低的,样本太少,还分不清是波动还是这个方向确实偏弱。测试期间 fra 的平均 CPU 占用最高 32%,lab 最高 18%,CPU 不是瓶颈。
6.4 怎么理解这些数字·
隧道的代价只在建连那一刻。 每次新建连接多花约 150 到 180 ms,连接建好之后,延迟和吞吐都和直连在同一水平。对 llama.cpp 这种频繁新开连接的用法,建连那一个往返才是能感觉到的差别。
单流吞吐的上限由线路决定,不由隧道决定。 带宽时延积(BDP)告诉我们:要在 135 ms 的 RTT 上跑满 150 Mbit/s,链路上需要同时有约 2.5 MB 的数据在途,发送窗口必须足够大。更关键的是丢包。Mathis 等人在 1997 年给出过一个经典的近似式:对基于丢包的 TCP,单流吞吐大致不超过
1 | throughput <= (MSS / RTT) * (C / sqrt(p)) C ~ 1.22, p = loss rate |
吞吐与 RTT 成反比,与丢包率的平方根成反比。跨境链路两者都不占优,单条连接停在一百多 Mbit/s 也就不奇怪了,多开几条就能叠加到两三百甚至四百。这也是为什么按连接代理(每个连接一条独立的外层 TCP)在这种链路上天然占便宜。
7. 回到开头的三个问题·
一、同样跑在 TCP 上,为什么 lab 那条不算 TCP 套 TCP?
因为 lab 那条是按连接代理。应用的 TCP 在代理处被终结,跨境的外层 TCP 里只有应用数据,没有内层的 TCP 头、确认和重传。内外两层是首尾相接、各管一段,不是一层包着一层。这就是 RFC 3135 所说的 split-connection。
二、把 WireGuard 塞进 VLESS 之后,是哪一步让事情变坏的?
不是 VLESS,也不是 TCP,而是 WireGuard 把多条内层连接加密成了一个不透明的 UDP 流。代理看不见、也切不开,只能把整串 IP 包原样塞进一条外层 TCP,于是嵌套重传和队头阻塞同时出现。这和 RFC 3135 指出的「IPsec 会让 PEP 失效」是同一个道理。
三、一条带 TLS 伪装的隧道,到底损失多少?
每次新建连接多约 1 个往返(150 到 180 ms),连接建立之后,往返时间和吞吐与直连在同一水平。真正限制单流吞吐的,是 135 ms 的 RTT 和跨境丢包,而不是隧道本身。选隧道之前,先想清楚自己要的是「连接」还是「网络」。只需要访问对面的服务,就用按连接代理,享受 split-TCP 的好处;确实需要一张双向的三层网络,就得接受 TCP 套 TCP 的代价,或者想办法让两端都有公网可达性。









