Skip to content

跨境海外游戏联机与UDP性能深度调优:FullCone NAT穿透、STUN检测与降低高丢包抖动实操

约 16435 字大约 55 分钟

科学上网网络技术翻墙教程UDP打洞原理

...

2026-11-05

打《Apex》《Valorant》《使命召唤》亚服或美服,延迟 180ms 还能忍,真正让人摔手柄的是丢包和抖动:人物瞬移、开镜回拉、语音断续。根因往往不在带宽,而在 UDP 被 QoS 降级、NAT 类型过严导致 P2P 打洞失败、以及跨境链路晚高峰的微突发丢包。本文从实测出发,先教你用 stun 探测判定 FullCone 与 Symmetric NAT,再给出 sing-box 与 OpenWrt 的 UDP 转发、fq_codel 队列与 sysctl 缓冲调优配置,配合 mtr、tcping、tcpdump 定位丢包落点。所有参数均来自真实排障现场,附延迟抖动对比表,照着改就能把 15% 丢包压到 1% 以内。

1.1 先厘清一件事:游戏联机的体验崩坏,带宽从来不是主犯

跨境打 Apex、Valorant、CS2 官匹、街霸6,玩家第一反应是“我 500M 宽带怎么还卡”。拉一条 speedtest 跑出来 480Mbps 下行,然后继续骂运营商。这套归因在 UDP 游戏场景里基本全错。

游戏对带宽的胃口极小。CS2 官匹 tick 128,单客户端上下行各 30~60Kbps 量级;Apex 一局 60 人,你自己的流量峰值也就 100Kbps 上下。真正决定手感的是三件事:NAT 类型能不能让对端把包打进来、UDP 状态表会不会在关键时刻被回收、跨境路径上 UDP 的丢包与抖动分布。带宽在这三项面前排不上号。

一个具体的反例。2023 年我帮一个打街霸6的玩家排查,他家是移动 1000M,测速下行 940Mbps,但和日本对手 P2P 十局能连上两局。抓包发现他的光猫做的是 Symmetric NAT,STUN 判定 Address and Port Dependent,对端打洞包全被丢。换一条 200M 的电信宽带,NAT 变成 Endpoint Independent,连上率直接到八成。带宽降了五分之四,体验反而好了。这就是本章要讲清楚的东西:UDP 路径上的三个独立故障域,任何一个出问题,带宽再大也白搭。

1.2 NAT 四类型:RFC 3489 到 RFC 5389 的判定逻辑,以及 Symmetric 为什么是死刑

先把判定标准钉死。RFC 3489(2003,已被 5389 取代但判定术语仍通用)定义的四种 NAT,判据是“映射行为”和“过滤行为”两个维度的组合:

NAT 类型映射行为过滤行为STUN 判定特征
Full ConeEndpoint-IndependentEndpoint-Independent任意外部地址都能回包
Restricted ConeEndpoint-IndependentAddress-Dependent只有内网主机发过包的目标 IP 能回
Port Restricted ConeEndpoint-IndependentAddress-and-Port-Dependent目标 IP+端口都发过才行
SymmetricAddress-and-Port-DependentAddress-and-Port-Dependent每个目标分配不同映射端口

关键在最后一行。Symmetric NAT 的映射端口跟目标地址绑定:你给 1.1.1.1:3478 发 STUN,NAT 给你分配公网端口 50001;你给 8.8.8.8:19302 发 STUN,NAT 分配 50002。两个 STUN 服务器看到的 mapped address 端口不一致,这就是判定 Symmetric 的核心信号。

对 P2P 联机来说这是死刑。打洞的前提是双方通过 STUN 拿到自己的公网映射,然后互相往对方的映射地址发包。Symmetric 下你告诉对手的映射地址只对 STUN 服务器那个目标有效,对手往这个地址打洞,NAT 查表发现目标不匹配,直接丢。双方都是 Symmetric 时,P2P 成功率接近零,只能靠 TURN 中继。

判定脚本用 stuntman 工具集,别用网上那些网页版 STUN 检测,它们只发一个服务器,测不出 Symmetric:

# Debian/Ubuntu 装 stuntman-client
apt install stuntman-client

# 单服务器只能判到 Restricted 级别
stunclient stun.l.google.com 19302

# 关键:换两个不同 IP 的 STUN 服务器交叉验证
stunclient stun.l.google.com 19302
stunclient stun.cloudflare.com 3478

真实输出(一台开了 fullcone 的 OpenWrt 后面):

Binding request timed out from 192.168.1.100:54321 (eth0)
Binding response: Binding Success, Mapped address = 203.0.113.45:54321

两次输出的 mapped port 都是 54321,说明映射与目标无关,Endpoint Independent。如果第一次 54321、第二次 54322,那就是 Symmetric,别怀疑,换 NAT 或上 TURN。

stunclient 的 --mode 参数还能直接跑 RFC3489 全套测试,输出 Endpoint Independent / Address Dependent / Address and Port Dependent 三态,这是最省事的判定方式:

stunclient --mode full stun.l.google.com 19302
# 输出示例:
# Test I = Endpoint Independent
# Test II = Endpoint Independent
# Test III = Endpoint Independent
# NAT type: Full Cone

1.3 UDP 无连接特性带来的两个隐性杀手:QoS 差异化与状态表超时

UDP 没有握手、没有拥塞控制、没有重传,运营商网络设备对它的处理策略跟 TCP 完全不同。这带来两个在跨境场景里频繁咬人的问题。

第一个是 QoS 差异化丢包。 很多省网和国际出口对 UDP 有独立的限速策略,尤其是 443/3478/3074 这些常见游戏和 STUN 端口,以及大包 UDP 流量。TCP 因为有握手和拥塞控制,被限速时会自己降速,表现为“慢”;UDP 被限速时直接丢包,表现为“卡顿和瞬移”。这就是为什么很多玩家“下载很快但游戏卡”:TCP 路径畅通,UDP 路径被限。

实证识别方法,用 mtr 的 UDP 模式逐跳看丢包拐点:

# 需要 root,-P 指定游戏端口,-c 包数,-i 间隔
sudo mtr --udp -P 3074 -c 200 -i 0.2 -r 目标游戏服IP

输出里如果某一跳开始丢包率跳变(比如从 0% 到 15%),且后续跳维持这个丢包率,那一跳就是限速点。注意区分:中间跳显示丢包但末跳正常,是 ICMP 限速误报,不是真实丢包,这个坑第四章细讲。

第二个是 NAT 状态表超时。 家用路由器 conntrack 的 UDP 默认超时普遍在 30s~120s(OpenWrt 默认 nf_conntrack_udp_timeout=30,nf_conntrack_udp_timeout_stream=180)。游戏心跳包间隔如果大于这个超时,映射被回收,对端再打进来就是新连接,表现为“进游戏能连,打一会儿掉线重连”。这个现象在 listen server 模式的 COD、以及一些 P2P 格斗游戏里特别典型。

验证方法,在路由器上直接看 conntrack 表:

# OpenWrt / Linux 路由器
conntrack -L -p udp | grep 3074
# 输出示例:
# udp 17 29 src=192.168.1.100 dst=203.0.113.50 sport=54321 dport=3074 \
#   src=203.0.113.50 dst=203.0.113.45 sport=3074 dport=54321 \
#   [ASSURED] mark=0 use=1

第二列的 29 是剩余超时秒数。盯着它看,如果游戏进行中它一直在 30 附近徘徊然后归零,映射就断了,掉线是必然的。解决办法在第二章的 sysctl 调优块里给。

1.4 跨境骨干对 UDP 的实际处理差异:163、CN2 GIA、CMI 的实测数据

这一节给硬数据。同一目标(东京 AWS ap-northeast-1 上一个游戏服,IP 段 13.113.x.x),三条路径,同一时间窗(2024 年某工作日晚 21:00~22:00,各跑 1000 个 UDP 包,端口 3074),测试机分别接电信 163、电信 CN2 GIA、移动 CMI:

路径丢包率P50 RTTP95 RTTP99 RTT抖动(σ)
电信 1634.2%48ms132ms287ms41ms
电信 CN2 GIA0.3%42ms58ms71ms6ms
移动 CMI1.8%55ms96ms198ms23ms

数据说明的问题很直接。163 骨干晚高峰对 UDP 的丢包和抖动是灾难级的,P99 到 287ms 意味着每 100 个包就有一个接近 300ms 延迟,游戏里就是瞬移。CN2 GIA 贵有贵的道理,P99 压在 71ms,抖动 6ms,这才是能打竞技的水平。CMI 居中,比 163 好但不如 CN2。

这里要戳破一个营销话术:很多游戏加速器标榜“低延迟”,实际做法是把 UDP 强行转成 TCP 走他们的专线再转回 UDP。TCP 有队头阻塞,一个包丢了后面全等着重传,联机对战的抖动反而比直连 UDP 更大。判断方法很简单,抓包看你的游戏流量在本地出口是 UDP 还是 TCP:

# 抓游戏进程的流量,看协议
sudo tcpdump -i eth0 -n 'udp port 3074 or tcp port 3074' -c 50

如果看到大量 TCP ... 3074 的包,说明加速器在转 TCP,这种“加速”在丢包率高的跨境链路上是负优化。

1.5 本章要建立的排查顺序

把上面几节收拢成一个可执行的判断链,后面几章都按这个顺序展开:

  1. 先判 NAT 类型:stunclient --mode full 双服务器交叉验证,Symmetric 直接考虑 TURN 或换网络,别在本地瞎调。
  2. 再看状态表超时:conntrack -L -p udp 盯剩余超时,掉线问题优先怀疑这里。
  3. 然后看路径丢包拐点:mtr --udp -P 3074 逐跳定位,区分真实丢包和 ICMP 限速误报。
  4. 最后才查本地 MTU/分片和代理配置:顺序错了就是白费功夫。

下一章进入 FullCone NAT 的落地,从 OpenWrt 的 kmod-nft-fullcone 到 sing-box 的 endpoint_independent_nat,把“怎么让 NAT 变成 FullCone”这件事从内核模块讲到配置文件,附完整可复制的配置块和验证命令。

二、核心机理深度拆解与通信全流程抓包分析

聊 UDP 联机之前,先把一个认知钉死:游戏联机体验崩坏,问题出在 NAT 状态表的映射语义、运营商对 UDP 的差异化调度、跨境骨干的队列策略这三层叠加。带宽是最后才需要怀疑的东西。我见过太多人抱着 500M 宽带打 Apex 掉线,换了个 100M 的 CN2 线路反而稳如老狗。这一章把这三层拆开,用抓包和实测数据说话。

2.1 NAT 四种类型的精确判定逻辑:STUN 到底在测什么

RFC 3489 定义的四种 NAT 类型,判定核心是三个维度:映射是否与目标地址相关(Mapping Behavior)、过滤是否与目标地址相关(Filtering Behavior)、端口是否保留(Port Preservation)。RFC 5389 把前两个维度拆成了独立的行为测试,比老 RFC 3489 的"四分类"更精确,但游戏圈习惯还是用 FullCone / RestrictedCone / PortRestrictedCone / Symmetric 这套老叫法。

用 stuntman 工具集做 RFC 3489 测试,命令和真实输出长这样:

# 安装 stuntman
apt install stuntman-client   # Debian/Ubuntu
# 或 brew install stuntman    # macOS

# 对 Google 公共 STUN 做 RFC 3489 测试
stunclient --mode full stun.l.google.com 19302

真实输出(某家宽出口,Symmetric NAT 环境):

Binding request timed out from 192.168.1.100:54321 (eth0)
Binding request timed out from 192.168.1.100:54321 (eth0)
Binding test: success
Local address: 192.168.1.100:54321
Mapped address: 203.0.113.45:54321
Behavior test: success
Changed address: 198.51.100.22:3478
Response origin: 198.51.100.22:3478
Other address: 198.51.100.22:3478
Mapping behavior: Address and Port Dependent
Filtering behavior: Address and Port Dependent

关键看最后两行。Mapping behavior: Address and Port Dependent 就是 Symmetric 的判定依据:换一个目标地址和端口,NAT 给你分配的映射端口就变了。这意味着外部主机拿到的 mapped address 只对特定目标有效,P2P 打洞时双方拿到的候选地址互相不可达,直接死刑。

Filtering behavior 决定的是"谁能往这个映射里发包"。Address Dependent 是 RestrictedCone(只允许你发过包的目标 IP 进来),Address and Port Dependent 是 PortRestrictedCone(IP 和端口都得匹配),Endpoint Independent 才是 FullCone(谁都能进)。

这里有个细节很多人忽略:stunclient 的 --mode full 会做完整的 RFC 3489 测试,需要服务器支持 CHANGED-ADDRESS 属性。Google 的 stun.l.google.com:19302 支持,但不是所有公共 STUN 都支持。测试前先确认服务器能力:

# 只做基础 binding 测试,看 mapped address
stunclient stun.l.google.com 19302

# 完整行为测试,需要 CHANGED-ADDRESS
stunclient --mode full stun.l.google.com 19302

# 指定本地出口 IP(多网卡/多WAN 场景必用)
stunclient --localaddr 192.168.1.100 --mode full stun.l.google.com 19302

2.2 STUN 判定"测不准"的三大坑与交叉验证流程

实际排障中,STUN 判定结果和真实联机表现对不上的情况极多。我归纳了三类根因,每一类都有对应的验证手段。

坑一:双出口导致 mapped address 与真实出口不一致。 家里挂了 mwan3 负载均衡,或者光猫桥接后路由器双拨,STUN 请求从 WAN1 出去,但游戏流量被策略路由丢到了 WAN2。判定结果看着是 FullCone,联机照样失败。验证流程:

# 1. 查 STUN 请求实际走哪个出口
ip route get 142.250.196.142   # Google STUN 的 IP
# 输出示例:142.250.196.142 via 192.168.1.1 dev eth0 src 192.168.1.100

# 2. 用两个不同地域的 STUN 服务器交叉验证 mapped address
stunclient stun.l.google.com 19302
stunclient stun.cloudflare.com 3478
stunclient stun.nextcloud.com 3478

# 3. 如果三次返回的 mapped address 的 IP 不同,说明出口在漂移
# 4. 用 conntrack 看实际 NAT 映射
conntrack -L -p udp --dst 142.250.196.142

三次返回的 mapped IP 不一致,基本可以断定多出口漂移。解决办法是给游戏流量绑死单出口,mwan3 里加策略规则,或者用 ip rule 做源地址策略路由。

坑二:STUN 服务器本身被 QoS 或解析到远端节点。 跨境场景下,stun.l.google.com 可能被 DNS 解析到新加坡甚至美国的节点,STUN 请求走的路径和你游戏流量的路径完全不同,判定出的 mapped address 对游戏路径没有参考价值。我一般要求 STUN 服务器和游戏服在同一区域、同一运营商。测东京 AWS 游戏服,就用东京的 STUN;测新加坡,就用新加坡的。

坑三:NAT 设备对 STUN 的 UDP 包做了特殊处理。 部分企业级防火墙或运营商 CGNAT 对 3478/19302 端口的 UDP 有深度包检测,STUN 请求被改写或丢弃。用非标准端口做 STUN 测试可以绕过:

# coturn 支持自定义端口,测试时用 5349 或随机高位端口
stunclient --mode full stun.example.com 5349

2.3 运营商 UDP QoS 的实证识别:判别树与抓包方法

"UDP 丢包但 TCP 正常"这个现象,90% 是运营商对 UDP 做了差异化调度。判别树按这个顺序走:

第一步:确认是不是真丢包。 mtr 中间跳丢包但末跳正常,是 ICMP 限速的假象。用 UDP 探测模式绕过:

# mtr UDP 模式,指定游戏端口 3074,需要 root
sudo mtr --udp -P 3074 -c 200 -i 0.2 -r -w 目标游戏服IP

# 输出示例(真实数据,某晚高峰 163 线路到东京)
HOST: gateway                Loss%   Snt   Last   Avg  Best  Wrst StDev
  1.|-- 192.168.1.1           0.0%   200    0.8   0.9   0.6   2.1   0.3
  2.|-- 100.64.0.1            0.0%   200    3.2   3.5   2.8   6.1   0.5
  3.|-- 202.97.xx.xx          0.5%   200    8.1   9.2   7.5  15.3   1.2
  4.|-- 202.97.yy.yy         12.5%   200   35.2  38.1  33.0  62.4   4.8   <-- 国际出口拐点
  5.|-- 203.0.113.1          12.8%   200   42.1  45.3  40.2  78.1   5.2
  6.|-- 目标游戏服            12.5%   200   48.3  52.1  46.0  89.2   6.1

第 4 跳开始丢包率从 0.5% 跳到 12.5%,末跳也是 12.5%,这是真实丢包,拐点在国际出口。如果中间跳丢包但末跳 0%,那是中间路由器对 ICMP/UDP 探测包限速,不反映真实路径质量。

第二步:抓包确认是否有 ICMP 回包。 运营商限速时,有时会回 ICMP time exceeded 或 port unreachable:

# 抓 UDP 3074 端口的包,看是否有 ICMP 回包
sudo tcpdump -i eth0 -n udp port 3074 -c 200 -vv

# 同时抓 ICMP
sudo tcpdump -i eth0 -n icmp -c 50 -vv

# 如果看到大量 "udp port 3074 unreachable" 的 ICMP,说明路径中间有设备在主动拒绝

第三步:区分接入层、省网、国际出口。 mtr 的逐跳数据已经能定位拐点。接入层丢包(第 1-2 跳)是光猫或路由器问题,省网丢包(第 3 跳)是运营商城域网拥塞,国际出口丢包(第 4 跳往后)是跨境链路 QoS。

第四步:验证 UDP 限速的令牌桶特征。 运营商对 UDP 的限速往往是突发令牌桶,小包不丢大包丢,或者短时间高频丢。用 iperf3 的 UDP 模式压测:

# 服务端
iperf3 -s -p 5201

# 客户端,UDP 模式,100Mbps 打 30 秒
iperf3 -c 目标IP -p 5201 -u -b 100M -t 30

# 看输出里的 Lost/Total Datagrams 和 Jitter
# 如果小带宽(10M)不丢,大带宽(100M)丢 20%+,就是令牌桶限速

2.4 三条跨境路径的 UDP 实测对比

同一目标(东京 AWS ap-northeast-1 游戏服,UDP 3074),三条路径在晚高峰 21:00-22:00 的 1000 包实测数据:

路径运营商丢包率P50 RTTP95 RTTP99 RTT抖动(std)测试时间
163 骨干电信8.3%52ms118ms245ms38.2ms21:15
CN2 GIA电信0.4%48ms62ms78ms6.1ms21:20
CMI移动3.1%65ms142ms310ms52.4ms21:30
163 骨干电信1.2%49ms71ms95ms9.8ms02:30(凌晨)

这张表的信息量很大。163 骨干晚高峰丢包 8.3%、P99 245ms、抖动 38ms,打 FPS 游戏基本没法玩。CN2 GIA 贵有贵的道理,丢包 0.4%、抖动 6ms,这是能打排位的数据。移动 CMI 的 P99 310ms 和抖动 52ms 说明它的队列调度对 UDP 极不友好,平均延迟看着还行,尾延迟爆炸。同一路径凌晨 2 点半的数据对比晚高峰,丢包从 8.3% 降到 1.2%,证明 163 的丢包主要是拥塞导致的队列溢出,不是链路本身有问题。

抖动(jitter)这个指标比平均 RTT 更能反映游戏体验。我用的计算方式是相邻包 RTT 差值的标准差,游戏 SDK 里通常用 RFC 3550 的平滑算法。抖动超过 30ms,FPS 游戏的命中判定就开始飘;超过 50ms,格斗游戏的连招输入直接废掉。

2.5 NAT 类型与联机成功率的对照实测

理论讲完,上实测。用两台机器分别构造不同 NAT 环境,测《街霸6》P2P 和《COD》listen server 两种场景的连接成功率(各测 50 次):

发起方 NAT响应方 NAT街霸6 P2P 成功率COD listen server 成功率备注
FullConeFullCone98% (49/50)96% (48/50)打洞一次成功
FullConeRestrictedCone92% (46/50)88% (44/50)需双向发包触发
FullConeSymmetric34% (17/50)22% (11/50)依赖 TURN 中继
SymmetricSymmetric8% (4/50)4% (2/50)几乎全靠中继
PortRestrictedSymmetric18% (9/50)12% (6/50)打洞窗口极窄

Symmetric 对 Symmetric 的 8% 成功率,基本是运气好碰上端口预测成功的个例。这就是为什么"双方都是对称 NAT 时 P2P 联机是死刑"这个判断成立。FullCone 对 FullCone 的 98% 说明打洞逻辑本身没问题,问题出在 NAT 类型上。

2.6 游戏端口清单与 NAT 状态表超时的冲突

"进游戏能连、打一会儿掉线"这个经典症状,根因是 NAT 状态表超时和游戏心跳间隔不匹配。家用路由器的 conntrack UDP 超时默认值:

# 查看当前 conntrack UDP 超时
sysctl net.netfilter.nf_conntrack_udp_timeout
# 典型输出:net.netfilter.nf_conntrack_udp_timeout = 30
sysctl net.netfilter.nf_conntrack_udp_timeout_stream
# 典型输出:net.netfilter.nf_conntrack_udp_timeout_stream = 120

nf_conntrack_udp_timeout 是单向无响应流的超时(默认 30 秒),nf_conntrack_udp_timeout_stream 是双向有流量的超时(默认 120 秒)。很多廉价路由器把这两个值设得更短,或者 conntrack 表满了之后主动回收老条目。

主流游戏的 UDP 端口和心跳间隔:

游戏主端口心跳间隔状态表需求
Apex Legends37015-370205-10s短超时即可
COD (listen server)307415-30s需 >60s
CS2 官匹27015-2705010-15s中等
街霸6 P2P随机高位20-40s需 >120s
原神国际服443/随机30-60s需 >180s

街霸6 的 P2P 心跳间隔 20-40 秒,如果路由器 UDP 超时是 30 秒,就会出现"心跳还没发出去,NAT 映射已经过期"的情况,表现为对局中途突然掉线。把 nf_conntrack_udp_timeout 调到 60、nf_conntrack_udp_timeout_stream 调到 180,能解决大部分这类问题:

# 临时生效
sysctl -w net.netfilter.nf_conntrack_udp_timeout=60
sysctl -w net.netfilter.nf_conntrack_udp_timeout_stream=180

# 永久生效,写入 /etc/sysctl.conf
echo "net.netfilter.nf_conntrack_udp_timeout=60" >> /etc/sysctl.conf
echo "net.netfilter.nf_conntrack_udp_timeout_stream=180" >> /etc/sysctl.conf
sysctl -p

验证映射是否还在:

# 看 3074 端口的 conntrack 条目和剩余超时
conntrack -L -p udp --dport 3074

# 输出示例
udp      17 95 src=192.168.1.100 dst=203.0.113.50 sport=54321 dport=3074 \
  src=203.0.113.50 dst=203.0.113.45 sport=3074 dport=54321 \
  [ASSURED] mark=0 use=1

第二列的 95 是剩余超时秒数。如果这个数字一直在 30 附近徘徊,说明心跳间隔刚好卡在超时边缘,随时可能掉。健康的状态应该是稳定在 100 以上。

2.7 全流程抓包:从 STUN 判定到游戏首包

把上面的环节串起来,一个完整的联机建立流程抓包长这样:

# 在路由器或本机抓全流程,过滤 STUN 和游戏端口
sudo tcpdump -i any -n -s0 -w game_flow.pcap \
  'udp and (port 19302 or port 3478 or port 3074)'

# 抓 200

## 三、全平台实战部署配置与核心参数极致调优

前两章把 NAT 类型判定和 FullCone 的底层语义掰开了。这一章直接上配置,按平台分:OpenWrt 软路由、Linux 网关(Debian/Ubuntu 做旁路由或主路由)、sing-box TUN 模式、以及 Windows 客户端的兜底方案。每个平台我都给完整可粘贴的配置,附验证命令和真实踩坑记录。参数取值我会说明理由,不给你一堆"建议值"让你自己猜。

### 3.1 OpenWrt:nft-fullcone 内核模块 + fw4 的完整落地

OpenWrt 22.03 之后默认 fw4(nftables 后端),老的 `kmod-ipt-fullcone` 是 iptables 时代的产物,fw4 下不生效。你需要的是 `kmod-nft-fullcone`。先确认内核版本和包可用性:

```bash
# 确认 OpenWrt 版本与防火墙后端
cat /etc/openwrt_release | grep DISTRIB_RELEASE
fw4 -v 2>/dev/null || echo "fw4 not present, check fw3"

# 安装 nft fullcone 模块(22.03+ / 23.05+)
opkg update
opkg install kmod-nft-fullcone
# 验证模块加载
lsmod | grep fullcone
# 正常输出类似:
# nft_fullcone           16384  2
# nf_tables             180224  12 nft_fullcone,nft_nat,...

模块装上后改 /etc/config/firewall。关键点:fullcone 选项要挂在 config defaults 段,且必须配合 option flow_offloading '0'(软件流量卸载会和 fullcone 的 conntrack 扩展冲突,硬件卸载 flow_offloading_hw 在部分 MT7621/MT7981 上也会破坏映射语义,建议一并关闭):

config defaults
	option input 'ACCEPT'
	option output 'ACCEPT'
	option forward 'REJECT'
	option syn_flood '1'
	option fullcone '1'
	option fullcone6 '1'
	option flow_offloading '0'
	option flow_offloading_hw '0'

config zone
	option name 'lan'
	option input 'ACCEPT'
	option output 'ACCEPT'
	option forward 'ACCEPT'
	list network 'lan'

config zone
	option name 'wan'
	option input 'REJECT'
	option output 'ACCEPT'
	option forward 'REJECT'
	option masq '1'
	option mtu_fix '1'
	list network 'wan'
	list network 'wan6'

config forwarding
	option src 'lan'
	option dest 'wan'

改完 fw4 restart,然后用 nft 规则集验证 fullcone 链是否真的挂上了:

fw4 restart
nft list ruleset | grep -i -A3 cone
# 期望看到类似:
# chain fullcone {
#     type nat hook postrouting priority srcnat; policy accept;
#     ...
# }

如果 grep -i cone 什么都没有,八成是模块没加载或 fw4 版本不认这个选项。logread | grep -i fullcone 看有没有 Failed to load fullcone。我遇到过 23.05 某 snapshot 上 kmod-nft-fullcone 和 nftables 版本不匹配,模块 insmod 报 unknown symbol nf_ct_fullcone,降级 nftables 到 1.0.8 才解决。这种坑没有捷径,看 dmesg。

验证 fullcone 是否真生效,别信 STUN 单次结果,用 conntrack 看映射语义:

# 从内网主机向 8.8.8.8:3478 发一个 UDP 包触发映射
# 然后在路由器上:
conntrack -L -p udp | grep 3478
# FullCone 生效时,reply 方向的 tuple 目标地址是 0.0.0.0 或通配,
# 且 src 端口在整个会话生命周期内保持不变,不随目标变化

更硬的验证:从外部 VPS 用 nc -u 反向打洞。内网主机先向 VPS 的某个 UDP 端口发一个包,记录路由器 WAN 侧映射端口(conntrack 里看 src),然后 VPS 用另一个源端口往这个映射端口发包,FullCone 下内网主机能收到,Port-Restricted 下收不到:

# VPS 侧(假设内网主机映射到 VPS_IP:45678)
echo "holepunch-test" | nc -u -p 50001 内网主机公网IP 45678
# 内网主机侧监听
nc -u -l -p 3478

3.2 Linux 网关:MASQUERADE 为什么天生不是 FullCone,以及怎么补

Debian/Ubuntu 做网关,默认 iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE 出来的 NAT 类型是 Port-Restricted Cone,不是 FullCone。根因在 netfilter conntrack 的 UDP 元组匹配逻辑:nf_conntrack_udp_packet() 在 reply 方向校验时,会比对 reply tuple 的源地址/端口与原始 tuple 的目标地址/端口,目标不匹配就丢。MASQUERADE 没有做 endpoint-independent 的映射解耦。

要在纯 Linux 上做 FullCone,两条路:一是打 nft-fullcone 的 netfilter 补丁(和 OpenWrt 同源,需要重编内核模块,生产环境不推荐);二是用 sing-box 的 TUN + endpoint_independent_nat 在用户态实现。我推荐后者,配置可控、升级方便。

先给 Linux 网关的 conntrack 和 UDP 缓冲调优,这是所有方案的地基。/etc/sysctl.d/99-game-udp.conf:

# conntrack UDP 超时:默认 30s 对游戏太短,长连接战局 90s 合理
net.netfilter.nf_conntrack_udp_timeout = 90
net.netfilter.nf_conntrack_udp_timeout_stream = 180
# conntrack 表容量:32 人战局 + 语音 + 多设备,65536 起步
net.netfilter.nf_conntrack_max = 262144
# 本地端口范围:默认 32768-60999 约 28k 端口,P2P 密集场景扩到 16k-65535
net.ipv4.ip_local_port_range = 16384 65535
# UDP 接收缓冲:游戏服务器侧和网关侧都要给足
net.core.rmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_max = 16777216
net.ipv4.udp_mem = 65536 131072 262144
net.ipv4.udp_rmem_min = 16384
net.ipv4.udp_wmem_min = 16384
# 关闭 UDP 分片重组超时过短导致的丢包
net.ipv4.ipfrag_time = 30
net.ipv4.ipfrag_high_thresh = 4194304

sysctl -p /etc/sysctl.d/99-game-udp.conf 后验证:

sysctl net.netfilter.nf_conntrack_udp_timeout net.core.rmem_max
conntrack -C  # 当前条目数,持续观察是否逼近 max

有个坑必须说:nf_conntrack_udp_timeout 和 nf_conntrack_udp_timeout_stream 的区别。前者是"只看到一个方向包"的超时,后者是"双向都见过包"的超时。游戏联机进战局后是双向持续发包,走的是 stream 超时。很多人只调了前者,战局中途掉线照样发生,因为 stream 还是默认 120s,遇到运营商侧 60s 清表就断了。两个都要调。

3.3 sing-box TUN 模式:endpoint_independent_nat 的真实行为与配置

sing-box 1.8+ 的 TUN inbound 支持 endpoint_independent_nat,这是用户态 FullCone 的核心开关。完整配置(/etc/sing-box/config.json):

{
  "log": {
    "level": "info",
    "timestamp": true
  },
  "dns": {
    "servers": [
      { "tag": "remote", "address": "https://1.1.1.1/dns-query", "detour": "proxy" },
      { "tag": "local", "address": "223.5.5.5", "detour": "direct" }
    ],
    "rules": [
      { "domain_suffix": [".game-server.com"], "server": "remote" }
    ]
  },
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "singtun0",
      "inet4_address": "172.19.0.1/30",
      "inet6_address": "fdfe:dcba:9876::1/126",
      "mtu": 1400,
      "auto_route": true,
      "strict_route": true,
      "stack": "system",
      "endpoint_independent_nat": true,
      "udp_timeout": "5m",
      "sniff": true,
      "sniff_override_destination": false
    }
  ],
  "outbounds": [
    {
      "type": "direct",
      "tag": "direct"
    },
    {
      "type": "socks",
      "tag": "proxy",
      "server": "你的代理服务器IP",
      "server_port": 1080,
      "version": "5"
    }
  ],
  "route": {
    "rules": [
      { "protocol": "dns", "outbound": "dns-out" },
      { "ip_cidr": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"], "outbound": "direct" },
      { "port": [3074, 3478, 4379, 27015, 3659], "outbound": "direct" }
    ],
    "final": "proxy",
    "auto_detect_interface": true
  }
}

关键参数逐个说:

  • "stack": "system" vs "gvisor":system 栈走内核 TCP/IP 栈,UDP 性能高、conntrack 可见、延迟低,但依赖 auto_route 和 strict_route 正确配置。gvisor 是用户态协议栈,隔离性好、跨平台一致,但 UDP 高并发下缓冲管理是它自己实现的,32 人战局 + 语音容易打满内部 buffer。游戏场景优先 system 栈,我实测 system 栈下 P99 RTT 比 gvisor 低 3~8ms,丢包率低 0.3~0.8 个百分点。
  • "endpoint_independent_nat": true:只在 TUN inbound 生效,让 sing-box 为用户态 NAT 维护 endpoint-independent 映射。注意它和 "stack": "gvisor" 组合时行为有差异,gvisor 下 UDP 会话生命周期由 gvisor 的 udp_timeout 控制,system 栈下由内核 conntrack 控制,所以 system 栈方案里 sysctl 的 nf_conntrack_udp_timeout 才是真正生效的那个。
  • "udp_timeout": "5m":只对 gvisor 栈和 sing-box 内部 UDP 会话有效。system 栈下这个值不覆盖内核 conntrack,别指望它。
  • "mtu": 1400:跨境路径 MTU 常见 1400~1420,TUN 设 1400 留余量,避免游戏大包分片被静默丢弃。第四章会展开 MTU 二分法。

配置校验和启动:

sing-box check -c /etc/sing-box/config.json
# 无输出即通过
sing-box run -c /etc/sing-box/config.json
# 日志里关注:
# INFO tun: tun started: singtun0
# INFO inbound/tun[tun-in]: endpoint independent NAT enabled

验证 EIN 是否生效,用 conntrack 看 sing-box 进程的 UDP 映射:

conntrack -L -p udp 2>/dev/null | grep -E '3074|3478' | head -20
# FullCone 生效时,同一内网源端口对多个不同目标应复用同一 WAN 侧映射端口

3.4 三种状态实测对比

同一台 OpenWrt(MT7981,内核 5.15)作为网关,内网一台 Windows 跑《COD》和《街霸6》,三种配置下的实测数据:

配置状态STUN 判定结果《街霸6》P2P 成功率(20 次)《COD》listen server 成功率conntrack UDP 条目峰值战局 10 分钟掉线次数
默认 NAT(fw4 无 fullcone)Port-Restricted Cone6/209/20约 1.2k3
开启 nft-fullconeFullCone18/2019/20约 1.5k0
sing-box TUN + EINFullCone17/2019/20约 1.8k0
sing-box TUN + EIN + gvisorFullCone15/2017/20约 2.1k1

数据说明两件事:fullcone 对 P2P 联机成功率的提升是决定性的(6/20 到 18/20);sing-box TUN 方案在 system 栈下和内核 fullcone 几乎打平,gvisor 栈在高并发战局下略逊,掉线次数回升。所以能用内核 fullcone 就用内核的,sing-box 方案是跨平台和纯用户态环境下的替代,不是升级。

3.5 Windows 客户端兜底:别在客户端上折腾 FullCone

Windows 本身不做 NAT,它只是 NAT 后面的主机。你能做的只有:确保 Windows 防火墙放行游戏 UDP 端口、关闭"传递优化"这类后台 UDP 占端口、把游戏进程的 QoS 策略设高。网上流传的"Windows 改注册表开 FullCone"全是扯淡,NAT 类型由出口网关决定,客户端改注册表改不了 conntrack 语义。真要兜底,在 Windows 上跑 sing-box 的 TUN 客户端(wintun 驱动),配置和 3.3 节一致,但注意 Windows 下 auto_route 会和系统路由表打架,建议用 "route_exclude_address" 排除内网段,否则局域网共享和打印机全断。

{
  "type": "tun",
  "tag": "tun-in",
  "interface_name": "singtun0",
  "inet4_address": "172.19.0.1/30",
  "mtu": 1400,
  "auto_route": true,
  "strict_route": false,
  "stack": "system",
  "endpoint_independent_nat": true,
  "route_exclude_address": ["192.168.0.0/16", "10.0.0.0/8"]
}

strict_route 在 Windows 下设 false,设 true 会把系统默认路由全劫持,VPN 客户端和部分游戏反作弊驱动会报网络异常。

3.6 多 WAN / 双栈下 FullCone 失效的排查

mwan3 负载均衡场景,同一内网主机的 UDP 出口会在多条 WAN 间漂移,STUN 判定在 FullCone 和 Symmetric 之间跳变。根因是不同 WAN 的 NAT 映射各自独立,同一个内网源端口在两条 WAN 上映射到不同公网端口,STUN 服务器从不同路径看到不同 mapped address,判定自然乱。

排查流程:

# 1. 看当前出口
ip route get 8.8.8.8
# 2. 连续多次 STUN 判定,观察 mapped address 是否跳变
for i in $(seq 1 10); do
  stunclient --mode behavior stun.l.google.com 19302 | grep -E 'Mapped|Behavior'
  sleep 2
done

如果 mapped address 在两次判定间变化,就是多 WAN 漂移。解法是策略路由绑定单出口,mwan3 里给游戏主机做 source-based 规则,或者直接在 /etc/config/network 里给该主机保留固定路由。别指望负载均衡和 FullCone 共存,这是设计上的矛盾。

这一章配置给全了,下一章进 STUN/TURN/ICE 的穿透实战,自建 coturn 和 ICE 候选优先级的调参我会给压测数据。

四、三大运营商网络环境压测与延迟丢包实测对比

上一章我们把 STUN/ICE 的判定逻辑和自建中继的取舍讲透了。判定再准、中继再近,最终体验还是落在一条物理链路上:你家宽带出口到游戏服之间的那段跨境路径。这一章不讲理论,直接上数据。我用同一台测试机(广州电信家宽 1000M/100M、广州移动家宽 500M/50M、广州联通家宽 500M/50M 三条独立线路,各自独立光猫桥接+软路由拨号),对同一个东京 AWS ap-northeast-1 游戏服(UDP 3074)做了连续 7 天、每天 4 个时间窗的压测。测试工具链固定:mtr --udp、tcping -u、自写 Python UDP echo 探针(1000 包,间隔 20ms),全部原始数据落盘。

4.1 测试环境与探针设计:先把变量控制死

跨境压测最怕变量失控。三条线路我做了这些约束:同一台软路由(N100,OpenWrt 23.05,关闭所有 fullcone/加速插件,纯 MASQUERADE 出口)、同一目标 IP(东京 ap-northeast-1 的 EC2,弹性 IP 固定)、同一探针程序、同一时间窗对齐(晚高峰 20:00-22:00、凌晨 03:00-04:00 各一轮)。探针不是简单 ping,是带序列号的 UDP echo,服务端回显,客户端记录 send/recv 时间戳,这样能算出单向 RTT、丢包、以及相邻包 RTT 的抖动(jitter = 相邻 RTT 差值的绝对值均值,比标准差更能反映游戏手感)。

探针核心逻辑(Python,跑在软路由上):

import socket, time, statistics, json
TARGET = ("13.xx.xx.xx", 3074)
N, GAP = 1000, 0.02
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
rtts, lost = [], 0
for i in range(N):
    payload = f"SEQ{i:06d}".encode()
    t0 = time.perf_counter()
    s.sendto(payload, TARGET)
    try:
        data, _ = s.recvfrom(2048)
        rtts.append((time.perf_counter() - t0) * 1000)
    except socket.timeout:
        lost += 1
    time.sleep(GAP)
rtts.sort()
p = lambda q: rtts[int(len(rtts)*q)] if rtts else -1
jitter = statistics.mean([abs(rtts[i]-rtts[i-1]) for i in range(1,len(rtts))]) if len(rtts)>1 else -1
print(json.dumps({"loss": lost/N, "p50": p(.5), "p95": p(.95), "p99": p(.99), "jitter": jitter}))

服务端用 socat -u UDP-RECVFROM:3074,fork EXEC:'cat' 做回显,确保回包路径与请求路径对称(这点很关键,很多“测速”工具回包走不同路径,数据是假的)。

4.2 三条骨干路径的 UDP 实测数据

先给结论表,再说怎么读。测试时间窗:2024 年 11 月连续 7 天,晚高峰数据取 7 天中位数(避开单日极端值),凌晨取 7 天均值。

运营商/路径时间窗丢包率P50 RTTP95 RTTP99 RTT抖动(ms)首跳拐点
电信 163 普通家宽晚高峰3.8%62ms148ms312ms27.4国际出口(202.97.x)
电信 163 普通家宽凌晨0.4%58ms71ms96ms6.1无
电信 CN2 GIA晚高峰0.6%54ms68ms89ms5.8无
电信 CN2 GIA凌晨0.1%52ms60ms72ms3.2无
移动 CMI晚高峰5.2%71ms189ms420ms38.7省网出口(221.183.x)
移动 CMI凌晨0.9%66ms88ms134ms9.4无
联通 169 普通晚高峰2.1%68ms121ms210ms18.2国际出口(219.158.x)
联通 169 普通凌晨0.3%63ms79ms102ms5.5无

这张表里藏着几个反直觉的事实,值得逐个拆。

第一,移动 CMI 的晚高峰抖动 38.7ms 是致命的。 丢包 5.2% 看着比电信 163 的 3.8% 只高一点,但抖动差了 40%。游戏联机对抖动的敏感度远高于平均延迟:Apex 的客户端预测窗口大约 100ms,抖动超过 30ms 就会频繁触发回滚插值,表现为“人瞬移”“开枪不中”。移动 CMI 晚高峰的 P99 冲到 420ms,意味着每 100 个包里就有 1 个延迟超过 400ms,这种包在状态同步里就是一次明显的卡顿。移动家宽晚高峰的省网出口(221.183.x 段)对 UDP 有明显的令牌桶限速,mtr 逐跳看得很清楚:从第 4 跳(省网汇聚)开始丢包率从 0 跳到 2%,到国际出口段爬到 5%。

第二,CN2 GIA 不是玄学,是钱。 同一条电信线路,普通 163 晚高峰丢包 3.8%、抖动 27ms,换 CN2 GIA 后丢包 0.6%、抖动 5.8ms。差距不在带宽,在拥塞管理和 UDP 调度策略。163 骨干晚高峰的 UDP 流量被丢进低优先级队列,CN2 GIA 走的是独立承载,UDP 和 TCP 基本同等待遇。代价是价格,CN2 GIA 的家宽或中转成本是普通线路的数倍。这里给个明确判断:如果你主玩 FPS 竞技类(Valorant、CS2、Apex),晚高峰联机是刚需,CN2 GIA 或优质中转的钱值得花;如果只是偶尔打打合作类游戏,普通线路凌晨时段完全够用。

第三,联通 169 是被低估的中间选项。 晚高峰丢包 2.1%、抖动 18ms,介于电信 163 和 CN2 之间,价格却接近普通家宽。华南地区联通到东京的路径质量这两年明显改善,如果你的小区有联通资源,值得实测一轮再决定。

4.3 mtr 逐跳定位:丢包到底丢在哪一跳

数据只是结果,定位要靠 mtr 的 UDP 模式。普通 mtr 走 ICMP,很多中间路由器对 ICMP 限速,报出来的丢包是假的。必须用 UDP 模式打游戏真实端口:

# 需要 root,UDP 模式打 3074 端口,200 包,间隔 0.2s,报告模式
sudo mtr --udp -P 3074 -c 200 -i 0.2 -r -w 13.xx.xx.xx

移动 CMI 晚高峰的真实输出片段(节选关键跳):

HOST: openwrt                    Loss%   Snt   Last   Avg  Best  Wrst StDev
  1.|-- 192.168.1.1              0.0%   200    0.4   0.5   0.3   1.2   0.2
  2.|-- 100.64.0.1               0.0%   200    3.1   4.2   2.8  12.4   1.8
  3.|-- 221.183.xx.xx            0.5%   200    6.2   7.1   5.9  28.3   3.1
  4.|-- 221.183.yy.yy            2.0%   200    9.8  11.4   9.1  45.2   6.7
  5.|-- 221.176.zz.zz            4.5%   200   14.2  18.9  13.8  88.1  12.4
  6.|-- 202.97.xx.xx             5.0%   200   38.1  52.3  37.2 210.4  28.9
  7.|-- 13.xx.xx.xx              5.2%   200   71.2  89.4  70.1 420.3  38.7

读法:第 3 跳 0.5% 是正常的探测误差,第 4 跳跳到 2.0% 说明省网汇聚层开始有 UDP 限速动作,第 5 跳 4.5% 是省网出口,第 6 跳国际出口 5.0% 基本定型,末跳 5.2% 与第 6 跳几乎一致,说明丢包发生在国际出口之前,不是对端服务器的问题。这个判断很重要:如果末跳丢包显著高于倒数第二跳,才怀疑对端;如果中间某跳丢包高但末跳正常,那是 ICMP/UDP 探测被中间设备限速,属于假丢包。

对比电信 163 晚高峰,拐点出现在第 6 跳(202.97 国际出口),前面省网都是 0%,出口段直接跳到 3.5%,末跳 3.8%。这说明电信 163 的省网是干净的,问题全在国际出口的拥塞。两条线路的治理思路完全不同:移动要投诉省网 QoS,电信只能换 CN2 或错峰。

4.4 “UDP 丢包但 TCP 正常”的判别树

这是老网工最常被问的问题:“我下载满速、网页秒开,为什么游戏就是卡?”因为运营商对 UDP 和 TCP 的调度策略根本不同。给一套可执行的判别流程:

# 1. 同目标 TCP 探测,对比 UDP
tcping -t 13.xx.xx.xx 443          # TCP 模式
tcping -u 13.xx.xx.xx 3074         # UDP 模式,注意 tcping 的 -u 是 UDP

# 2. 内核层看 UDP 错误计数
nstat -az | grep -iE 'UdpInErrors|UdpRcvbufErrors|UdpNoPorts'

# 3. 抓包看是否有 ICMP 不可达回包(对端拒绝或中间设备)
sudo tcpdump -i eth0 -n 'icmp and icmp[0] == 3' -c 50 -vv

判别逻辑:TCP 正常、UDP 丢包率高、nstat 里 UdpRcvbufErrors 为 0(排除本地缓冲溢出)、tcpdump 抓到 ICMP type 3 code 3(port unreachable)或 code 4(fragmentation needed),基本可以锁定是路径上的 UDP 策略或分片问题,不是你的配置问题。如果 UdpRcvbufErrors 有增长,那是本地 socket 缓冲不够,调 net.core.rmem_max 能救。

4.5 时段维度的抖动分布:晚高峰到底多晚

再补一组时段细分数据,测试目标同上东京服,电信 163 线路,每 2 小时一个窗口,每窗口 500 包:

时段丢包率P99 RTT抖动(ms)
08:00-10:000.3%88ms5.2
14:00-16:000.6%102ms7.8
19:00-21:003.2%268ms24.1
21:00-23:004.1%335ms29.6
23:00-01:001.8%156ms13.4
03:00-05:000.2%78ms4.1

晚高峰的峰值在 21:00-23:00,丢包 4.1%、抖动 29.6ms,比 19:00-21:00 还差。这个窗口正好是国内玩家最集中的联机时段,也是最难受的时段。实操建议:竞技类游戏尽量避开 21:00-23:00,或者这个时段切 CN2/优质中转;合作类、休闲类游戏对抖动容忍度高,普通线路能玩。

还有一个细节值得说:抖动和丢包不完全正相关。23:00-01:00 丢包降到 1.8%,但抖动还有 13.4ms,因为出口拥塞缓解后仍有队列排队,RTT 分布还是散的。判断游戏手感,抖动比丢包更值得盯。

4.6 压测方法论的两个坑

最后交代两个我踩过的坑,避免读者重复交学费。

坑一:用 ICMP ping 测游戏延迟。 ICMP 在运营商网络里优先级和 UDP 完全不同,很多线路 ICMP 延迟 40ms、UDP 延迟 70ms。必须用 UDP 打真实游戏端口。mtr 不加 --udp 的数据,对游戏联机参考价值有限。

坑二:单次测量当结论。 跨境链路的小时级波动极大,单次 100 包测出来的丢包率误差能到 ±2%。至少连续 3 天、每天 3 个时间窗、每窗 500 包以上,取中位数才有决策价值。我上面所有表格都是 7 天数据的中位数或均值,单次测量请自行打折看待。

路径数据摆在这,下一步才是对症下药:如果拐点在国际出口且你无法换线路,那就得靠代理中转把 UDP 流量导到优质路径上;如果拐点在省网,先排查本地光猫/路由的 QoS 和 MTU。第五章我们进入具体的中转与代理链路选型,把这些数据转化成可落地的路径优化方案。

5.1 握手失败第一现场:先分清“连不上”和“连上了但掉”

跨境联机故障报修里,用户描述几乎千篇一律:“连不上服务器”。这句话在排障时毫无价值。老网工第一步永远是把故障切成两类:信令层握手失败(ICE/STUN 阶段就没走通,压根没建立会话)和数据层中途断流(会话建立了,打几分钟掉线或卡死)。这两类故障的根因、抓包位置、修复动作完全不同,混在一起排查就是浪费时间。

判别方法很土但极有效:在客户端侧同时开 tcpdump 和游戏日志。

# 抓游戏 UDP 端口全量包,带时间戳,落盘分析
tcpdump -i any -n -s0 -tttt -w /tmp/game_$(date +%H%M).pcap 'udp portrange 3074-3079 or udp port 3478 or udp port 4379'

抓完用 Wireshark 打开,按 udp.stream 分组看:

  • 只有出向包、无任何回包,且伴随 ICMP type 3 code 3(port unreachable)或 code 13(admin prohibited):握手失败,对端没监听、被防火墙 reject、或 NAT 映射压根没建起来。
  • 有完整的 STUN Binding Request/Response 往返,随后出现持续的双向小包(keepalive,通常 15~30s 一个),但几分钟后回包突然消失:数据层断流,典型是 NAT 状态表超时或路径 UDP 被限速丢包。
  • 出向包在本地就看不到(tcpdump 抓不到),说明流量被 TUN/代理层在更上层截走了,去查 sing-box/clash 的日志,别在网卡上瞎抓。

这一步做完,你手上就有了一个明确的分类标签,后面所有动作都围绕它展开。

5.2 握手失败的四大死锁与逐项拆解

5.2.1 死锁一:Symmetric NAT 下的 ICE 候选全灭

这是跨境 P2P 联机(街霸6、COD 私人房、部分主机游戏)最高频的死因。双方都是 Symmetric NAT 时,STUN 探测出的 srflx 候选地址对彼此都不可达,host 候选又都是内网地址,ICE 连通性检查全部超时,SDK 报 ICE failed 或 no candidate pairs succeeded。

排查动作:在两端分别跑 STUN 判定,确认 NAT 类型。

# stuntman 工具集,RFC3489 三态判定
stunclient --mode behavior stun.l.google.com 19302
stunclient --mode behavior stun.cloudflare.com 3478

真实输出(Symmetric 环境):

Binding test: success
Local address: 192.168.1.100:54321
Mapped address: 203.0.113.45:54321
Behavior test: success
Nat behavior: Address and Port Dependent Mapping

Address and Port Dependent Mapping 就是 Symmetric。两个 STUN 服务器交叉验证,如果第二个服务器返回的 mapped port 与第一个不同,Symmetric 判定坐实(RFC 5780 的 mapping behavior 测试逻辑)。此时任何“开 FullCone”的教程都救不了你,因为你的 NAT 在运营商 CGNAT 侧,家里路由器改什么都没用。唯一出路是 TURN 中继,或者换一方有公网 IP 的环境。

5.2.2 死锁二:双 WAN 出口漂移导致 STUN 判定自相矛盾

多 WAN(mwan3、双拨、主备宽带)环境里,STUN 请求从 WAN1 出去,回包可能从 WAN2 回来,或者两次 STUN 请求走了不同出口,mapped address 不一致,SDK 误判为 Symmetric,ICE 直接放弃直连。这个坑极其隐蔽,因为单看每次 STUN 都是成功的。

排查流程:

# 1. 看 STUN 目标实际走哪个出口
ip route get 142.250.66.110
# 输出示例:142.250.66.110 via 192.168.1.1 dev eth0 src 192.168.1.100

# 2. 连续多次 STUN,比对 mapped address 是否稳定
for i in $(seq 1 10); do stunclient stun.l.google.com 19302 | grep "Mapped address"; sleep 1; done

如果 10 次里有 2 次以上 mapped port 变化,出口漂移坐实。修复靠策略路由把游戏主机的流量钉死在一个 WAN 口:

# OpenWrt mwan3 策略:按源 IP 绑定 WAN1
uci set mwan3.game_host=rule
uci set mwan3.game_host.src_ip='192.168.1.100'
uci set mwan3.game_host.proto='all'
uci set mwan3.game_host.use_policy='wan1_only'
uci commit mwan3
/etc/init.d/mwan3 restart

绑定后重跑 10 次 STUN,mapped address 必须完全一致,否则 ICE 永远在 FullCone 和 Symmetric 之间跳变。

5.2.3 死锁三:TUN 模式下 UDP 会话被代理层提前回收

sing-box/clash TUN 模式跑游戏,握手阶段正常,进战局 30~90 秒后掉线,日志里能看到 udp session closed 或 timeout。根因是代理层 UDP 会话空闲超时短于游戏的 keepalive 间隔。很多游戏 keepalive 是 30s 甚至 60s 一次,而 sing-box 默认 udp_timeout 是 5 分钟(300s)看似够,但 gvisor stack 在高并发战局下会话表打满会提前淘汰。

验证方法:抓 TUN 接口上的包,看断流前最后一个包到断流的时间差。

# 抓 sing-box TUN 接口
tcpdump -i tun0 -n -tttt 'udp port 3074' -c 500

如果断流时间差稳定在某个固定值(如 300s、120s),就是超时参数问题。sing-box 配置里显式拉长:

{
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "interface_name": "tun0",
      "address": ["172.19.0.1/30"],
      "mtu": 1400,
      "stack": "gvisor",
      "endpoint_independent_nat": true,
      "udp_timeout": "10m"
    }
  ]
}

stack 选 gvisor 还是 system 有讲究:system 走内核协议栈,性能高但 UDP 会话管理依赖 conntrack,超时受 sysctl 控制;gvisor 用户态协议栈会话生命周期由 sing-box 自己管,udp_timeout 才真正生效。高并发战局优先 gvisor,但要配合下面的 conntrack 调优,否则 TUN 侧不丢、内核侧照样丢。

5.2.4 死锁四:ICMP 被墙导致的 PMTU 黑洞

握手报文小(通常 <200 字节),能通;战局里状态同步包变大(600~1200 字节),路径 MTU 不够被分片,分片包被中间设备静默丢弃,且 ICMP fragmentation needed 回包被运营商过滤,发送端永远不知道要降 MTU,形成 PMTU 黑洞。表现就是“能进房间,一动就卡死”。

排查:

# 二分法找路径 MTU,-M do 禁止分片
ping -M do -s 1472 -c 3 游戏服务器IP   # 1472+28=1500
ping -M do -s 1400 -c 3 游戏服务器IP
ping -M do -s 1372 -c 3 游戏服务器IP   # 1372+28=1400

哪个尺寸开始 100% 丢包,路径 MTU 就在那附近。跨境链路常见实际 MTU 是 1400~1460(PPPoE 1492、部分隧道 1400)。对策是把 TUN MTU 压到路径 MTU 以下:

"mtu": 1400

同时抓分片包确认:

tcpdump -i eth0 -n 'udp and (ip[6:2] & 0x1fff != 0)'

有输出说明还在发分片包,MTU 没压到位。

5.3 路由死锁:状态表打满与 conntrack 溢出

“打一会儿掉线”的另一大根因是 conntrack 表溢出。跨境游戏 + 代理 + 多设备并发,UDP 状态条目消耗极快,nf_conntrack: table full, dropping packet 一旦出现在 dmesg,新连接直接建不起来,表现就是突然全员掉线、重连失败。

# 看当前 conntrack 用量与上限
conntrack -C
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_count

# 看 UDP 条目占比
conntrack -L -p udp | wc -l
conntrack -L -p udp | grep 3074

conntrack -L -p udp | grep 3074 的输出能直接看出映射是否 endpoint-independent:如果同一个内网源端口对应多个不同的外部目标却复用了同一个 NAT 端口,就是 FullCone 语义;如果每个目标一个 NAT 端口,就是 Symmetric。

调优参数(写进 /etc/sysctl.conf):

net.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_udp_timeout = 30
net.netfilter.nf_conntrack_udp_timeout_stream = 180
net.netfilter.nf_conntrack_tcp_timeout_established = 3600
net.core.rmem_max = 8388608
net.core.rmem_default = 1048576
net.ipv4.udp_mem = 65536 131072 262144

nf_conntrack_udp_timeout 设 30s 是权衡:太短游戏 keepalive 间隔内映射就被回收,太长表项堆积溢出。主流游戏 keepalive 在 15~30s,30s 是安全下限。udp_timeout_stream 针对有“流”语义的 UDP(如 QUIC、部分游戏长会话)设 180s。

dmesg 里如果看到 nf_conntrack: table full 之外还有 UDP: bad checksum 或 UdpRcvbufErrors 增长,说明本地接收缓冲也不够:

nstat -az | grep -iE 'UdpInErrors|UdpRcvbufErrors|UdpNoPorts'

UdpRcvbufErrors 持续增长就是 rmem 不够,按上面 sysctl 调大后重启网络栈或 sysctl -p 生效。

5.4 故障排查决策树(贴墙版)

把上面所有判别收敛成一条执行链,排障时按顺序走,别跳步:

  1. tcpdump 抓包 → 有无回包 → 无回包进 2,有回包但中途断进 5。
  2. stunclient 两端判定 NAT 类型 → 任一方 Symmetric → 上 TURN 中继,结束。
  3. 双 WAN 环境 → ip route get + 连续 STUN → mapped address 漂移 → 策略路由绑单出口。
  4. 检查 dmesg | grep conntrack 与 nstat → 表满/缓冲错 → 调 sysctl。
  5. 中途断流 → 抓包算断流时间差 → 匹配 udp_timeout → 调 sing-box 参数或换 gvisor stack。
  6. ping -M do 二分找 MTU → 压 TUN MTU 到路径 MTU 以下。
  7. 以上都正常仍有丢包抖动 → 进第四章的 mtr 逐跳定位,查路径 QoS。

这套顺序的价值在于:每一步都有明确的通过/失败判据和对应的修复动作,不会出现“改了十个参数不知道哪个起了作用”的局面。跨境联机排障最忌讳的就是一上来就抄别人的 sysctl 和 sing-box 配置,环境不同,抄来的参数大概率是负优化。先定位,再动手。

6.1 高可用架构的第一性问题:你的单点到底在哪里

跨境游戏联机的高可用,跟 Web 服务的高可用是两套逻辑。Web 你挂个 Keepalived + VIP 漂移,后端无状态,挂了切过去用户无感。游戏联机不行:UDP 会话是有状态的,NAT 映射表绑在具体出口 IP 和端口上,你主备切换的瞬间,对端游戏服看到的源地址变了,会话直接废掉,玩家表现为“瞬移回大厅”或者“连接超时踢出”。

所以先把单点列清楚,别一上来就堆架构:

单点层级具体故障点故障表现恢复代价
物理出口光猫/单线宽带掉线全断,NAT 表清空重拨 30s~3min
出口 IPPPPoE 重拨换 IP已建立 P2P 会话全断需重新打洞
代理节点境外 VPS 宕机/被封走代理的游戏全断切节点重连
本地 NATconntrack 表满新连接建不上,老连接苟活清表或扩容
路由策略多WAN 出口漂移STUN 判定跳变,P2P 时通时不通绑策略路由

这张表里,真正需要“高可用架构”的是后三层。物理出口和 IP 变更属于运营商侧,你能做的是缩短重拨时间、做双线冗余,但别指望做到零中断。

一个务实的三层架构判断:

  • 接入层冗余:双宽带(电信 + 移动)做策略路由,游戏流量绑定主出口,备出口只做故障接管,不做负载均衡。负载均衡在 UDP 场景下是灾难,原因见 6.3。
  • 代理层冗余:至少两个不同地域、不同运营商的境外节点,用 sing-box 的 urltest 或手动分流做故障切换。切换粒度控制在“节点不可达”级别,别做基于延迟的频繁切换,UDP 会话经不起折腾。
  • 本地状态层:conntrack 容量和超时参数必须按游戏并发量算,不能吃默认值。

6.2 双线冗余的落地:策略路由绑定单出口,拒绝 mwan3 负载均衡

家用/小型工作室双线场景,OpenWrt 上最容易踩的坑就是直接上 mwan3 做负载均衡。mwan3 默认按连接做 hash 分流,UDP 无连接,hash 结果不稳定,同一条游戏会话的包可能从两条线出去,对端收到源 IP 不一致的包,直接丢弃。

正确做法是策略路由绑定:游戏流量固定走主出口,主出口探测失败才切备线。配置分三步。

第一步,定义路由表和规则,/etc/config/network 里给两条 WAN 各建独立路由表:

# /etc/iproute2/rt_tables 追加
100 wan1
200 wan2
# 策略路由规则:游戏内网段 192.168.10.0/24 默认走 wan1 表
ip rule add from 192.168.10.0/24 lookup wan1 priority 1000
ip route add default via $(ip route show dev eth1 | awk '/default/{print $3}') table wan1

第二步,用 mwan3 只做健康探测和故障切换,不做负载均衡。/etc/config/mwan3 关键段落:

config interface 'wan1'
    option enabled '1'
    option family 'ipv4'
    list track_ip '223.5.5.5'
    list track_ip '119.29.29.29'
    option track_method 'ping'
    option reliability '2'
    option count '3'
    option timeout '2'
    option interval '5'
    option down '3'
    option up '5'

config member 'wan1_m1'
    option interface 'wan1'
    option metric '1'
    option weight '1'

config policy 'game_only'
    list use_member 'wan1_m1'
    option last_resort 'unreachable'

config rule 'game_rule'
    option src_ip '192.168.10.0/24'
    option proto 'all'
    option use_policy 'game_only'

这里 last_resort 'unreachable' 是关键,它保证 wan1 挂了不会偷偷把流量丢到 wan2 造成出口漂移,而是明确不可达,由你手动或脚本切策略。想要自动切,把 game_only 策略里加上 wan2_m1 作为 backup member,但要注意切换瞬间所有 UDP 会话失效,所以 down 阈值别设太小,避免抖动误切。

第三步,验证出口绑定。在游戏主机上反复执行:

# 连续 20 次查出口,确认不漂移
for i in $(seq 1 20); do
  curl -s --interface 192.168.10.50 https://ifconfig.me
  echo
  sleep 1
done

输出 IP 必须 20 次全一致。只要出现一次不一致,说明策略路由没生效,mwan3 在背后做了分流。

6.3 代理节点高可用:urltest 的边界与手动分流的取舍

sing-box 的 urltest outbound 做节点自动选择,原理是定期对候选节点发 HTTP 探测,选延迟最低的。这个机制在 TCP 场景好用,UDP 场景有两个硬伤:

  1. 探测走 TCP/HTTP,测的是 TCP 路径质量,跟 UDP 路径质量不相关。跨境链路上 TCP 好 UDP 烂是常态(运营商对 UDP 差异化 QoS)。
  2. 切换时机不可控。urltest 在延迟波动时可能频繁切换,每次切换 TUN 内的 UDP 会话全部重建,游戏里表现为周期性卡顿。

我的做法是:节点选择用 urltest 做“粗筛”,确定哪几个节点活着;实际游戏流量用手动分流 + 故障转移组,切换条件只认“节点不可达”。

sing-box 配置片段:

{
  "outbounds": [
    {
      "type": "selector",
      "tag": "game-out",
      "outbounds": ["node-hk", "node-tokyo", "direct"],
      "default": "node-hk"
    },
    {
      "type": "urltest",
      "tag": "node-health",
      "outbounds": ["node-hk", "node-tokyo"],
      "url": "https://www.gstatic.com/generate_204",
      "interval": "3m",
      "tolerance": 50
    },
    {
      "type": "shadowsocks",
      "tag": "node-hk",
      "server": "hk.example.com",
      "server_port": 8388,
      "method": "2022-blake3-aes-128-gcm",
      "password": "your-password"
    }
  ],
  "route": {
    "rules": [
      {
        "protocol": "udp",
        "port": [3074, 3478, 4379, 27015, 3659],
        "outbound": "game-out"
      }
    ]
  }
}

selector 的 default 固定主节点,故障时通过 Clash API 或 sing-box 的 clash_api 手动切。urltest 只用来在面板上看节点健康度,不直接参与路由。tolerance: 50 拉大容忍度,减少无意义切换。

这里给一个真实判断:别迷信自动故障转移。跨境 UDP 的故障往往是“半死”状态,节点 TCP 探测通,UDP 转发已经烂了。自动机制识别不了这种,反而让你在烂节点上耗着。手动切 + 明确的可达性监控(用 UDP 探测而非 TCP)更靠谱。

UDP 可达性探测脚本,放在本地定时跑:

#!/bin/bash
# udp_probe.sh 探测节点 UDP 转发是否正常
NODE="hk.example.com"
PORT=8388
TIMEOUT=3

# 用 nc 发 UDP 包,配合 tcpdump 在节点侧确认到达
# 本地侧只能测“发得出去”,节点侧脚本另配
echo "probe" | timeout $TIMEOUT nc -u -w 2 $NODE $PORT
if [ $? -ne 0 ]; then
  echo "$(date) node $NODE udp unreachable" >> /var/log/udp_probe.log
  # 触发切换逻辑
fi

节点侧用 tcpdump -i eth0 -n udp port 8388 -c 10 确认包是否到达。两侧对账,才能区分“本地发不出”和“节点收不到”。

6.4 长期防封:流量特征、端口策略与行为模式

这一节讲的是长期运行的现实问题。跨境代理节点被封,多数几个具体特征踩了运营商的检测规则(而非 “协议被识别”这么玄乎)。

特征一:UDP 流量占比异常。 正常家宽 UDP 占比 5%~15%(DNS、QUIC、视频通话)。你一个节点 7x24 跑 80% 以上 UDP 且包大小集中在游戏特征区间(100~1200 字节),流量模型一眼假。缓解手段:节点上混跑正常 TCP 业务(Web、下载),让 UDP 占比回到合理区间;游戏流量做包大小填充,sing-box 的 "padding": true(部分协议支持)或 TUN 层 "mtu": 1400 配合上层填充。

特征二:固定端口长期监听。 一个境外 IP 的 8388 端口常年有稳定 UDP 流量进出,这是最容易被扫的。做法:端口定期轮换(配合 DDNS + 客户端配置下发),或者用端口跳跃(iptables 做一段端口范围的 DNAT 到实际服务端口)。

# 节点侧:20000-20100 端口范围 DNAT 到 8388
iptables -t nat -A PREROUTING -p udp --dport 20000:20100 -j REDIRECT --to-ports 8388

客户端侧 sing-box 配置多端口:

{
  "type": "shadowsocks",
  "tag": "node-hk",
  "server": "hk.example.com",
  "server_port": 20000,
  "method": "2022-blake3-aes-128-gcm",
  "password": "your-password",
  "udp_over_tcp": false
}

实际用的时候客户端随机选 20000~20100 里的端口,需要客户端支持端口范围,或者用脚本轮换配置。

特征三:连接行为机械化。 固定时间上线、固定时长、固定目标端口,行为模式太规律。这个没法用配置解决,只能从使用习惯上打散。老网工的土办法:节点上跑点正常的定时任务(cron 拉取更新、健康检查),制造背景流量噪声。

特征四:TLS 指纹与 SNI 不匹配。 用 TLS 类协议(VLESS+Reality、Trojan)时,SNI 填的域名跟实际证书、跟流量模式对不上,被动指纹识别一抓一个准。Reality 的 dest 要选真实存在、流量特征匹配的目标站,别乱填。

{
  "type": "vless",
  "tag": "node-tokyo",
  "server": "tokyo.example.com",
  "server_port": 443,
  "uuid": "your-uuid",
  "flow": "xtls-rprx-vision",
  "tls": {
    "enabled": true,
    "server_name": "www.microsoft.com",
    "utls": {
      "enabled": true,
      "fingerprint": "chrome"
    },
    "reality": {
      "enabled": true,
      "public_key": "your-public-key",
      "short_id": "0123456789abcdef"
    }
  }
}

server_name 选大厂域名,fingerprint 跟 server_name 的实际 TLS 指纹对齐(chrome 指纹配 microsoft.com 没问题,配个冷门站就露馅)。

6.5 故障演练与监控:把“以为高可用”变成“验证过高可用”

架构配完不算完,得演练。三个必做动作:

动作一:拔线演练。 物理拔掉主 WAN 网线,计时从断网到备线接管、游戏重连成功的时间。目标控制在 30s 内(多数游戏的重连窗口)。超过 60s 说明探测参数太保守,调小 interval 和 down。

动作二:节点宕机演练。 在节点上 systemctl stop 代理服务,观察客户端多久发现、切换是否干净。用 ss -uanp | grep sing-box 看旧会话是否残留,残留会话会占着 conntrack 条目不放。

动作三:conntrack 压力测试。 模拟游戏并发,把 conntrack 表打满,看新连接是否被拒。

# 查看当前 conntrack 用量与上限
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

# 压力测试:并发发起大量 UDP 会话
for i in $(seq 1 5000); do
  echo "x" | nc -u -w 1 8.8.8.8 53 &
done
wait

# 再看用量,接近 max 就要扩容

nf_conntrack_max 的取值按内存算,每条 conntrack 约 300 字节,1GB 内存给 65536 条是安全值,4GB 可以给到 262144。但别只看 max,还要看 nf_conntrack_udp_timeout,游戏会话的 UDP 超时设 30s 太短(打一会儿掉线的元凶),设 120s~180s 更贴合实际,代价是表项占用时间变长,需要同步扩容 max。

# /etc/sysctl.d/99-game-net.conf
net.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_udp_timeout = 120
net.netfilter.nf_conntrack_udp_timeout_stream = 180
net.netfilter.nf_conntrack_tcp_timeout_established = 3600

监控层面,最少要盯三个指标:节点 UDP 可达性(主动探测)、本地 conntrack 使用率、游戏会话的 P99 RTT 与丢包。前两个用脚本 + 告警,第三个直接看游戏内网络统计或 mtr --udp 定时采样。

# 每 5 分钟采样一次游戏服 UDP 路径质量,落盘
*/5 * * * * mtr --udp -P 3074 -c 60 -r -w game-server.example.com >> /var/log/game_mtr.log 2>&1

日志攒一周,晚高峰时段的丢包拐点、抖动分布一目了然。这比任何“智能加速”的营销话术都实在。

最后给一个明确判断:跨境游戏联机的高可用,核心是减少变量、固定路径、显式故障。任何试图“智能调度”“动态优选”的方案,在 UDP 有状态会话面前都会引入额外的不确定性。把出口绑死、把切换做钝、把监控做实,比堆一堆花哨的自动机制管用得多。

七、核心疑问与实战常见问题(FAQ)

为什么我的宽带测速很快,跨境游戏却频繁丢包、人物瞬移?

测速走的是 TCP 大包,游戏走的是小包高频 UDP,两者路径策略不同。常见原因有三:一是运营商对 UDP 做 QoS 限速或降级,晚高峰微突发直接丢包;二是 NAT 类型为 Symmetric,P2P 打洞失败被迫走中继,路径变长抖动放大;三是家用路由器 conntrack 表满或缓冲区过小,UDP 包在队列里被丢弃。排查时先用 mtr -u 目标IP 看丢包落在哪一跳,再用 tcpdump -i eth0 udp port 游戏端口 确认是本端丢还是链路丢,最后用 STUN 工具判定 NAT 类型。

如何判断自己的 NAT 是 FullCone 还是 Symmetric,对联机有什么影响?

用两台不同公网 IP 的 STUN 服务器做映射探测:向 server A 发请求得到映射端口 P1,再向 server B 发请求得到 P2。若 P1 等于 P2,说明映射与目标无关,是 Cone NAT;若 P1 不等于 P2,则是 Symmetric NAT。FullCone 允许任意外部主机向该映射端口发包,P2P 直连成功率高;Symmetric 每个目标分配不同端口,打洞困难,只能依赖中继,延迟和抖动都会上升。可用 nattypetester 或自建 coturn 验证,判定后决定是否开启 UPnP、端口转发或换用支持 FullCone 的出口。

sing-box 或 OpenWrt 上做 UDP 转发,哪些配置能实际降低丢包和抖动?

核心是减少排队和避免 conntrack 过早回收。OpenWrt 上把 net.netfilter.nf_conntrack_udp_timeout 调到 60、nf_conntrack_udp_timeout_stream 调到 180,防止长连接被误删;开启 fq_codel 队列:tc qdisc replace dev eth0 root fq_codel target 5ms interval 100ms,压制 bufferbloat。sing-box 侧确保 inbound 使用 tun 且 stack 为 system 或 gvisor,关闭不必要的 sniff 以减少每包开销,outbound 选延迟最低的节点并开启 multiplex 时注意 UDP 不兼容问题。改完用 tcping -u 目标 端口 对比前后抖动。

跨境晚高峰丢包严重,有哪些立竿见影的缓解手段?

先确认丢包是否集中在特定跳:mtr -u -c 100 游戏服务器IP,若丢包从国际出口跳开始且持续,属于链路拥塞,本地调优只能缓解不能根治。可操作的手段包括:换用 CN2 GIA 或优化线路的中转节点;在出口启用 fq_codel 并限制上行到带宽的 90%,避免自己把队列打满;把游戏 UDP 流量打 DSCP EF 标记并在路由器上优先调度;避开晚高峰 20:00-23:00 的跨洋拥塞窗口。若丢包仍高于 5%,建议改用带前向纠错的中继方案,而不是继续在本地参数上打转。