香港CN2服务器上「小包秒回、大包卡死」的幽灵:被运营商吃掉的 PMTU 与 tcp_mtu_probing 的三个反直觉设定

先把症状摆出来,看看你是不是同一款

一台香港 CN2 的机器,跑 nginx + 自研服务,国内用户访问的表现是分裂的:

  • 首页 200 OK,time_total 0.02s,看起来一切正常
  • 点下载/上传,进度条卡在 0 字节,一直转圈到超时
  • SSH 能连上,motd 也打印了,敲 ls 回车之后就没下文了
  • MySQL 握手成功,SELECT 1 秒回,SELECT 一张带 TEXT 的表直接 hang 死
  • 从 HK 往大陆某 IDC 做 rsync,日志停在 "starting"

这些症状的共同点是:握手阶段的小包永远正常,一旦发送数据超过某个尺寸就静默死亡。这个尺寸通常不是应用层决定的,是 MTU。

为什么偏偏是 CN2 线路上容易踩

CN2 GIA/GT 是运营商在自家 AS 内做 MPLS 转发,部分路径还叠了 L2VPN / GRE 隧道,路径上某一跳的实际 MTU 不是 1500,而是 1400、1440 这种值。

正常情况这没问题——路由器会回一个 ICMP type 3 code 4(Fragmentation Needed),内核据此把 PMTU 降下来。但跨境段的 ICMP 策略普遍是"能不回就不回":要么直接丢,要么被 icmp_ratelimit 限速到几十秒才回一次。你的内核永远等不到那个通知,只能按原来的 MSS 重传、RTO 翻倍、再重传,直到应用层超时。

这就是 PMTU 黑洞。它不是 CN2 独有的,但跨境 + 运营商自建骨干的组合让它出现的概率显著高于普通公网路径。

五分钟定位:三步走

第一步:看 TCP 连接本身的状态

ss -tinp 'sport = :443 or dport = :443'

重点看这几个字段:mss(当前发送 MSS)、rcvmss(对端通告值)、retranslostrtortt。一个典型的中招连接长这样:

ESTAB 0  82304  10.0.0.5:443  116.xx.xx.xx:51234
     cubic wscale:7,7 rto:204 rtt:28.7/2.3 ato:40 mss:1460 rcvmss:1460
     cwnd:2 ssthresh:2 bytes_sent:1024 segs_out:12 segs_in:3
     lost:9 retrans:9/9 unacked:9 rcv_space:65483

注意 bytes_sent 很小,但 lost:9 retrans:9/9 unacked:9——发出去的包全进了黑洞,一个 ACK 都没回来。而 rtt:28.7/2.3 说明握手阶段的往返是健康的。这不是拥塞,拥塞不会让 cwnd 掉到 2 还收不到任何 ACK。

顺手看一眼内核的 PMTU 缓存里到底有没有东西:

ip route get 116.xx.xx.xx
# 输出里带 "mtu 1400" 说明内核已经知道真实 PMTU
# 只有 "dev eth0 src 10.0.0.5" 说明内核还在拿接口 MTU 硬算

第二步:让内核自己去探一遍

tracepath 会发 DF 置位的包并读取 ICMP(不像 ping 那样需要你手动二分):

tracepath -n 116.xx.xx.xx

输出里有一列 pmtu。如果看到前几跳 1500,中间某跳突然变成 1452 或 1400,问题就锁定了。

第三步:抓 SYN/SYN-ACK 里的 MSS option

tcpdump -ni eth0 -s 0 -vv \
  'tcp[tcpflags] & (tcp-syn) != 0 and tcp[tcpflags] & (tcp-ack) != 0'

你会看到 <mss 1460,nop,nop,sackOK,nop,wscale 7>。通告的是 1460,可实际线路只能吃 1400——这就是黑洞的起点。

tcp_mtu_probing 的三个反直觉设定

搜到这个问题的人,答案通常只有一句 sysctl -w net.ipv4.tcp_mtu_probing=1,然后发现没什么用。原因在这三个地方。

一、=1 不是"主动探测",是"检测到黑洞才降级"

内核文档原话是这样写的(Documentation/networking/ip-sysctl.rst):

tcp_mtu_probing - INTEGER
    Controls TCP Packetization-Layer Path MTU Discovery.
    0 - Disabled
    1 - Disabled by default, enabled when an ICMP black hole detected
    2 - Always enabled, use initial MSS of tcp_base_mss.

"when an ICMP black hole detected" 的触发条件是:同一个段连续重传若干次、且 RTO 已经翻倍到阈值。也就是说,在你的探测生效之前,连接必须先实打实地惨一次。

如果你的应用层设了 TCP_USER_TIMEOUT(gRPC、Thrift、相当一部分 SDK 都会设),连接会在 probe 起来之前就被应用层掐断重连,重连之后还是 1460,还是卡。这就是"参数改了但还是不行"最常见的原因——不是参数没生效,是还没轮到它生效。

二、tcp_base_mss 的默认 1024 是个历史包袱

probe 一旦触发,发送 MSS 会被压到 net.ipv4.tcp_base_mss(默认 1024),然后每个 RTT 往上爬一点。对 CN2 这种 RTT 20-40ms 的线路无所谓,但如果你还跑着香港到大陆的内网专线(RTT 5ms 以内、跑大吞吐),从 1024 爬回 1360 能拖掉几十秒,业务侧体感就是"每隔一阵子抽一次"。

把 base_mss 设到接近真实 PMTU 的值,能省掉大部分爬坡时间:

net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_base_mss = 1360

注意 base_mss 既是起点也是上限,设到 1400 以上基本等于关掉了探测,别乱填。

三、它只管你自己的发送方向

tcp_mtu_probing 是本机 TCP 栈的行为,生效前提是本机作为发送方,并且本机 TCP 栈能观察到重传

如果你的架构里前面还有一层 L4 代理(HAProxy / LVS / nginx stream),或者中间有 conntrack 做 NAT,那么该重传、该 probe 的是代理那一端。你在后端业务机上调半天 sysctl,等于给一台不出问题的机器吃药。

比 sysctl 更靠谱的解法:在握手阶段就把 MSS 谈小

最干净的做法是根本不指望探测——连接一建立就用对的 MSS。

这里有个绝大多数教程都写错的点:路由上的 advmss 只对"本机主动 connect() 出去"的连接生效

# 只影响本机主动发起的连接
ip route change default via 203.0.113.1 dev eth0 advmss 1360

服务端被动接受进来的连接,SYN-ACK 里的 MSS 是内核用 dst_mtu() - 40 算出来的,跟 advmss 一点关系都没有。这就是为什么很多人改了路由 advmss,客户端抓包一看 SYN-ACK 里还是 1460。

被动连接要改,只有两条路:

# 方案 A:mangle 表拦截 SYN-ACK 改写 MSS(SYN-ACK 的 SYN 位是置位的,会被匹配到)
iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --set-mss 1360

# 方案 B:直接降接口 MTU,内核自然算出更小的 MSS
ip link set dev eth0 mtu 1400    # 1400 - 40 = 1360

方案 A 更精准,可以按目的网段区分;方案 B 简单粗暴,但会连本机到不需要降 MTU 的对端也一起影响。如果只有大陆方向有问题,用 ipset 分组:

ipset create cn dst hash:net
# ... 灌入你的大陆网段列表

iptables -t mangle -N MSS_CLAMP
iptables -t mangle -A MSS_CLAMP -p tcp --tcp-flags SYN,RST SYN \
  -j TCPMSS --set-mss 1360
iptables -t mangle -A OUTPUT -m set --match-set cn dst -j MSS_CLAMP

1360 不是拍脑袋来的。CN2 跨境段常见的 PMTU 是 1400(GRE 封装)或 1440(MPLS 标签栈),减掉 40 字节 TCP/IP 头就是 1360 和 1400。先用 tracepath 实测出真实值再定,别抄博客上的数字。

两个顺手要一起看的点

ICMP 限速导致的"随机成功"

即使上游没有完全丢弃 ICMP,net.ipv4.icmp_ratelimit 默认是 1000ms,意味着同一秒内最多回一次。当你有一堆连接同时在做 PMTU 发现时,绝大多数 ICMP 会被限速丢掉,表现就是"有些连接降下来了,有些没有",看起来非常随机:

net.ipv4.icmp_ratelimit = 100

前提是你确认上游确实有回 ICMP。纯黑洞的话这条改了也没用。

跑 QUIC / HTTP3 的,TCP 那套参数完全救不了

现在很多香港机器顺手就开了 HTTP/3。QUIC 跑在 UDP 上,有自己的 DPLPMTUD,内核的 tcp_mtu_probing 一个字都管不到它。而且 QUIC 的初始 PMTU 通常保守到 1200,遇到黑洞时的表现是 1-RTT 之后整条连接静默超时,比 TCP 还难查。

要改的是应用层:

# nginx.conf
quic_mtu 1350;

或者 quiche 里的 set_initial_max_udp_payload_size()。不设这个参数,遇到同样的路径,TCP 还能靠 clamp 硬撑,QUIC 是直接躺平。

小结

  • 香港 CN2 的 PMTU 黑洞,症状永远是"小包通、大包死",先别往应用层找。
  • tcp_mtu_probing=1 是被动降级不是主动探测;有应用层超时(TCP_USER_TIMEOUT)的场景基本指望不上它。
  • 服务端要 clamp 的是 SYN-ACK,路由 advmss 对被动连接无效,用 iptables -j TCPMSS 或降接口 MTU。
  • 跑 QUIC 的记得单独改应用层的 MTU 配置,内核参数救不了 UDP。

最后说一句前提:这些参数之所以值得调,是因为线路本身是干净的。我手上那批轻云互联的香港 CN2 机器,三网 CN2 GIA 直连、RTT 稳定在 20-40ms 不抖,PMTU 一旦出问题,ss 里的 retrans 数字一眼就能看出来是黑洞而不是拥塞。要是线路本身就在绕路、延迟抖到看不出基线,那这些内核参数调起来就是盲人摸象——先解决线路,再谈内核。