香港CN2服务器上「小包秒回、大包卡死」的幽灵:被运营商吃掉的 PMTU 与 tcp_mtu_probing 的三个反直觉设定
先把症状摆出来,看看你是不是同一款
一台香港 CN2 的机器,跑 nginx + 自研服务,国内用户访问的表现是分裂的:
- 首页 200 OK,
time_total0.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(对端通告值)、retrans、lost、rto、rtt。一个典型的中招连接长这样:
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 数字一眼就能看出来是黑洞而不是拥塞。要是线路本身就在绕路、延迟抖到看不出基线,那这些内核参数调起来就是盲人摸象——先解决线路,再谈内核。