TCP/IP 要点:连接与报文
网络基础回答了"包怎么找到目的地",但排障到深处,问题往往收敛到 TCP 自身的行为细节:服务端重启后客户端第一次连接被拒、抓包里出现成串的重复 ACK、SSH 能登录却 scp 一卡死就断——这三件事分别对应 RST 的来源、快速重传的触发条件与 PMTU 黑洞,答案都不在配置文件里,而在协议的运转方式里。本页沿一条 TCP 连接的完整生命周期展开:先讲握手挥手的状态机,再用 ss 把状态数出来、用 tcpdump 把报文看出来,最后处理 MTU 这类"链路属性反噬传输层"的问题。IP 寻址、子网与分层模型基础篇已讲透,此处不再重复;按层排查的方法论见网络故障排除,本页聚焦传输层本身。
内容参考自 RFC 9293、man 手册与鸟哥的私房菜(概念框架),见文末参考资料。
学习目标
- 读懂三次握手与四次挥手的每一个报文,说清 TIME_WAIT、CLOSE_WAIT 各自属于哪一端
- 用
ss按状态过滤、计数并归属到进程,养成"先计数、后抓包"的排障顺序 - 用
tcpdump抓一次完整会话,辨认 S/F/R/P/. 标志,掌握 RST 的三种常见来源 - 理解 MTU、IP 分片、MSS 协商与 PMTU 黑洞,会用
ping -M do探测路径 MTU - 知道 UDP 的适用边界与 QUIC 的趋势,能用
ss -u观察 UDP 套接字
1. 从"通"到"懂"
刚接触网络排障时,工具箱里的命令都在回答"通不通":ping 测可达,dig 测解析,curl 测服务。等到排查"服务明明在监听、客户端却报连接被拒",或者"连接建立了、传大文件必断"这类问题,"通不通"就不够用了——需要知道 TCP 在通与不通之间还维护着一套状态机,每个状态是谁的责任、每个报文是谁的表态。这层知识与基础篇的关系,类似先学会看路口再学看信号灯时序:地图没变,但你能解释"为什么绿灯了车还不动"。
本页的方法论只有一条:先看状态,再看报文。ss 是静态快照,便宜、无侵入,一条命令就能把几千条连接按状态分好类;tcpdump 是动态录像,精确但昂贵——生产机上无过滤地抓包,输出速度可能超过你能阅读的速度。这与日志排障先按 severity 过滤再读全文是同一种纪律:便宜的先来,昂贵的留给已经缩小的问题面。顺序反了,不是抓不到信息,而是信息淹没你。
工欲善其事,先把工具备齐:ss 属于 iproute2,三大发行版都预装;tcpdump 多数最小安装不带,需要手动补;ping 属于 iputils。三系安装对照如下,一次装齐,本页所有实验都能跑:
| 工具 | Debian/Ubuntu | Arch | RHEL/CentOS/Rocky |
|---|---|---|---|
| ss | iproute2(预装) | iproute2(预装) | iproute2(预装) |
| tcpdump | apt install tcpdump | pacman -S tcpdump | dnf install tcpdump |
ping(含 -M do) | iputils-ping(预装) | iputils(预装) | iputils(预装) |
2. TCP 连接的生命周期
2.1 三次握手:确认"双方收发都通"
客户端 服务端(LISTEN)
│ ── SYN, seq=x ─────────────▶ │ "我想连接,我的序号从 x 起"
│ ◀───── SYN+ACK, seq=y, ────── │ "收到;我也想连接,我的序号从 y 起"
│ ack=x+1 │
│ ── ACK, ack=y+1 ──────────▶ │ "收到你的序号" → 双方 ESTABLISHED三次握手常被误记成"例行公事",其实每一次交换都在排除一种故障:第一次 SYN 证明客户端能发;第二次 SYN+ACK 证明服务端能收也能发;第三次 ACK 证明客户端能收。少一次,就有一方的"收"没被验证过——两次握手的老设计中,服务端无法区分"客户端真的在"与"一个迟到的历史 SYN 报文",会把连接资源白白分配出去。序号 x、y(ISN,初始序号)不从 0 或固定值起,正是为了防止旧连接的迟到报文混进新连接被当成有效数据;内核对 ISN 做随机化,是协议设计里"防御历史"的一个细节。
握手报文还顺路完成了一件大事:MSS 选项的交换。双方在 SYN 里各自声明"我这条路一次最多能收多大的 TCP 段",由此避免 IP 层分片——这件事第 5 节展开。先记住时序:SYN(1 个 RTT 内完成前两次交换)→ ACK,此后才是数据包;抓包时握手与数据的分界,就是 length 从 0 变为非 0 的那一刻。
握手的代价因此是固定的一次往返:对短连接密集的业务(每次请求都新建连接的 HTTP 旧用法),1 RTT 的建立成本在高延迟链路上相当可观——跨洲 200ms 的 RTT 意味着每个连接还没发一个字节就先花掉 200ms。长连接复用与连接池把这笔税一次付清后反复使用,是应用层最有效的"TCP 调优";内核侧的 TCP Fast Open(TFO,cookie 让首个 SYN 就能捎带数据)则是协议层的补救,因中间设备兼容问题未能普及,知道即可。
2.2 四次挥手与半关闭
主动关闭方 被动关闭方
│ ── FIN, seq=m ─────────────▶ │ "我发完了"
│ ◀──────────────── ACK ─────── │ "知道了"(但它可能还有数据要发)
│ │ ……继续发送剩余数据……
│ ◀── FIN, seq=n ────────────── │ "我也发完了"
│ ── ACK ────────────────────▶ │ → 被动方 CLOSED,主动方 TIME_WAIT挥手比握手多一次,因为关闭是不对称的:握手时双方都从零开始、可以合并表态;挥手时被动方收到 FIN 的瞬间可能还有没发完的数据,只能先 ACK 应答,等数据发完再补自己的 FIN——ACK 与 FIN 分开发送,就有了四次。TCP 由此支持半关闭:一端调用 shutdown(fd, SHUT_WR) 只关写方向,读方向保持打开,对端的剩余数据仍能收进来。多数应用不使用半关闭,直接 close() 双向一起关,但理解它才能解释抓包里"FIN 之后还有数据包"的时序。
关闭还有第二条路径:RST。异常中止(进程崩溃、向已关闭的连接写数据)会直接发 RST,跳过挥手仪式,排队未发的数据就地丢弃,也不进入 TIME_WAIT。"优雅关闭走 FIN、出事走 RST"——这两种姿态在 tcpdump 里一眼可辨,是判断"对端是正常下线还是异常死掉"的直接证据。
2.3 状态机核心子集
┌──────────┐ 被动打开
│ LISTEN │◀──────────── 服务端
└────┬─────┘
收到 SYN,回 SYN+ACK │ 客户端发 SYN
┌────▼─────┐ ┌──────────┐
│ SYN_RECV │ │ SYN_SENT │
└────┬─────┘ └────┬─────┘
收到 ACK │ 三次握手完成 │ 收到 SYN+ACK,回 ACK
┌────▼───────────────────▼────┐
│ ESTABLISHED │
└────┬───────────────────┬────┘
主动关闭,发 FIN │ │ 收到 FIN,回 ACK
┌────▼─────┐ ┌────▼─────┐
│FIN_WAIT_1│ │CLOSE_WAIT│(等应用调 close)
└────┬─────┘ └────┬─────┘
收到 ACK │ 应用 close,发 FIN
┌────▼─────┐ ┌────▼─────┐
│FIN_WAIT_2│ │ LAST_ACK │
└────┬─────┘ └────┬─────┘
收到 FIN,回 ACK │ │ 收到最后的 ACK
┌────▼─────┐ ┌────▼─────┐
│TIME_WAIT │── 2×MSL ──▶ │ CLOSED │
└──────────┘ └──────────┘两个方向的路径必须分开记:主动关闭方走 FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED,被动关闭方走 CLOSE_WAIT → LAST_ACK → CLOSED。同一对连接的四次挥手,两端看到的状态序列完全不同——这解释了排障时的经典困惑:为什么服务端上全是 CLOSE_WAIT 而客户端上全是 TIME_WAIT?因为这次是客户端先发起关闭;反过来服务端主动关,状态就互换。状态不是连接的属性,是"每一端各自的"属性。
TIME_WAIT 停留约 60 秒(Linux 将 2×MSL 定死为 60 秒),存在理由有二:其一,让本连接的旧报文在网络上自然消亡,防止污染之后复用同一四元组的新连接;其二,被动方的 LAST_ACK 依赖主动方还在场——如果最后的 ACK 丢了,被动方重传 FIN,主动方必须还能应答。跳过 TIME_WAIT 的"优化"多数是在拆这两道保险,第 7 节会回到这个话题。另外 FIN_WAIT_2 若对端始终不关,连接会滞留;内核对孤儿 FIN_WAIT_2 设有超时(tcp_fin_timeout,默认 60 秒),但应用持有的不会自动回收——抓包看到一方 FIN 后另一方长时间沉默,问题多半在对端应用的关闭逻辑。
把状态与责任对应成表,排障时按行取用:
| 状态 | 属于哪一端 | 出现时机 | 排障含义 |
|---|---|---|---|
| SYN_SENT | 发起方 | SYN 已发、未收到应答 | 长期停留 = 对端不可达或 SYN 半路被丢(防火墙 DROP) |
| SYN_RECV | 接收方 | 收到 SYN、回了 SYN+ACK | 堆积 = 握手无人完成,警惕 SYN flood |
| ESTABLISHED | 双方 | 数据传输中 | 数量突降比突升更值得报警 |
| CLOSE_WAIT | 被动关闭方 | 收到 FIN、回过 ACK | 堆积 = 本端应用没调 close(),见 3.2 节 |
| TIME_WAIT | 主动关闭方 | 挥手完成后的善后期 | 正常姿态,60 秒自动消亡,不占 fd |
2.4 keepalive 与应用层心跳
TCP 自带保活机制:连接空闲超过 net.ipv4.tcp_keepalive_time(默认 7200 秒,两小时)后开始发探测包,连续 tcp_keepalive_probes 次(默认 9 次、间隔 75 秒)无响应则宣告连接死亡。默认参数对生产服务过于迟钝——对端异常消失后,最长要两小时十分钟才能感知,期间应用还在往一条死连接上写数据。把内核参数调小固然可行,但更通行的做法是应用层心跳:周期短、可控、携带业务语义(消息队列的心跳常顺带汇报消费位点),且能穿透那些对空闲 TCP 连接毫不留情的中间设备(运营商 NAT 对空闲 UDP 映射几十秒就回收,TCP 长连接同样可能被静默掐断)。
一个命名陷阱要提前拆掉:HTTP 的 Keep-Alive 与 TCP 的 keepalive 是两回事。前者是应用层"用完连接不关、下个请求接着用"的复用约定,解决的是握手开销;后者是传输层的死链探测。排查"连接为什么断",先分清讨论的是哪一层——HTTP 复用超时由服务端 keepalive_timeout 类配置控制,与内核那组 tcp_keepalive_* 参数互不相干。
3. 看见状态机:ss 实操
3.1 按状态过滤与计数
ss 的 state 过滤器直接使用 2.3 节的状态名,是统计连接分布的第一入口:
# 总览:一行看全局,注意 TCP 行括号里的分项
$ ss -s
Total: 236
TCP: 18 (estab 9, closed 6, orphaned 0, timewait 3)
# 按状态计数(减 1 去掉表头)
$ ss -t state time-wait | wc -l
157
$ ss -t state established '( dport = :443 )' | wc -l
92
# CLOSE_WAIT 必须带 -p 归属到进程——这是它唯一的价值:指认责任人
$ ss -t state close-wait -p
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
0 0 192.168.1.20:45632 203.0.113.10:https users:((("app",pid=2841,fd=213)))ss -s 的 TCP 括号里,estab 与 timewait 是快速心跳指标:监控里对这两个数画趋势线,比绝对值报警有意义得多——TIME_WAIT 从 50 涨到 5000 说明短连接模式发生变化,CLOSE_WAIT 从 0 涨到三位数则几乎一定是应用出了 bug。-p 输出里的 fd=213 也是线索:进程的文件描述符号一路涨到几百,配合 lsof -p 2841 | wc -l 确认 fd 泄漏,CLOSE_WAIT 的病因就锁定了一半。
监听状态的两列 Recv-Q/Send-Q 容易被误读,值得单独说:
$ ss -tlnp | head -3
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=918,fd=6))
LISTEN 0 128 127.0.0.53%lo:domain 0.0.0.0:* users:(("systemd-resolve",pid=301,fd=18))对 LISTEN 套接字,Send-Q 是 accept 队列上限(应用调 listen(fd, backlog) 的 backlog,nginx 默认 511),Recv-Q 是当前排队等待 accept() 的连接数——长期非零说明应用处理连接的速度跟不上到达速度,backlog 涡满后新连接会被内核直接丢弃,客户端表现为"高峰期偶发连接超时"。这与第 6.2 节 UDP 的 Recv-Q(待读字节数)同列名不同义,读数前先看 State 列。
3.2 CLOSE_WAIT 与 TIME_WAIT 的诊断分工
这两个状态是最常被混淆的一对,判别口诀只有一句:CLOSE_WAIT 是"对方关了、你没关",TIME_WAIT 是"你先关了、善后中"。对端发来 FIN、本端内核回完 ACK,连接就停在 CLOSE_WAIT,等应用调用 close() 才继续走 LAST_ACK——只要应用不调,它就永远停着,fd、内存、连接表项全部占用。TIME_WAIT 则完全相反,它是主动关闭方履行 2.3 节两道保险的正常姿态,60 秒后自动消亡,不占用 fd(内核里是轻量的 mini socket),大量存在通常是短连接高频建立的结果而非故障本身。
诊断动作由此分叉:看到 CLOSE_WAIT 堆积,去找那个没调 close() 的进程,审它的代码路径——典型是对端先关闭后,本端把数据读完就忘了关,或异常分支跳过了清理逻辑,重启应用只是止痛,fd 会重新攒起来。看到 TIME_WAIT 海量,先问业务为什么不用长连接(连接池、Keep-Alive 复用才是治本),实在要缓解再用内核手段——出方向的连接可以开 net.ipv4.tcp_tw_reuse=1(依赖时间戳,默认已开),允许新连接复用 TIME_WAIT 端口;把 net.ipv4.ip_local_port_range 从默认的 32768 60999 扩大也能续命。至于网上流传的 tcp_tw_recycle,内核 4.12 起已经删除——它在 NAT 环境下会误杀同源连接,老教程里的这条"调优"不要再抄。
3.3 三条经验法则
第一,先计数、后抓包。ss 一条命令毫秒级返回,先把问题面收敛到"哪个状态、哪个进程、哪个对端",再决定要不要动 tcpdump。第二,看状态分布判断谁先关的。TIME_WAIT 集中在 A 端、CLOSE_WAIT 集中在 B 端,说明关闭由 A 发起;分布与预期角色不符(比如本该服务端回收的连接由客户端海量发起关闭),常能直接指出设计问题。第三,归属到进程之后,先审应用再调内核。内核参数是最后一格挡位,顺序颠倒的代价是:调了一堆 sysctl,真正的漏 close() 代码一行没改。
4. 抓包看握手与挥手:tcpdump 实操
4.1 抓一次完整会话
终端一开抓包,终端二发起请求,一次 HTTP 会话的完整生命周期就落在屏幕上:
# 终端一:不解析名字、限制 20 条、只看 80 端口
$ sudo tcpdump -i any -n -c 20 port 80
# 终端二:curl http://203.0.113.10/14:31:05.301209 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [S], seq 1287305421, win 64240, [mss 1460,sackOK,TS val 3089034202 ecr 0,nop,wscale 7], length 0
14:31:05.302005 IP 203.0.113.10.80 > 192.168.1.20.51344: Flags [S.], seq 3982085013, ack 1287305422, win 65535, [mss 1460], length 0
14:31:05.302093 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [.], ack 3982085014, win 502, length 0
14:31:05.302870 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [P.], seq 1287305422:1287305490, ack 3982085014, win 502, length 68
14:31:05.303530 IP 203.0.113.10.80 > 192.168.1.20.51344: Flags [.], ack 1287305490, win 83, length 0
14:31:05.304102 IP 203.0.113.10.80 > 192.168.1.20.51344: Flags [P.], seq 3982085014:3982086078, ack 1287305490, win 83, length 1064
14:31:05.304180 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [.], ack 3982086078, win 501, length 0
14:31:05.306220 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [F.], seq 1287305490, ack 3982086078, win 501, length 0
14:31:05.306872 IP 203.0.113.10.80 > 192.168.1.20.51344: Flags [.], ack 1287305491, win 83, length 0
14:31:05.307609 IP 203.0.113.10.80 > 192.168.1.20.51344: Flags [F.], seq 3982086078, ack 1287305491, win 83, length 0
14:31:05.307664 IP 192.168.1.20.51344 > 203.0.113.10.80: Flags [.], ack 3982086079, win 501, length 0逐行对照 2.1 与 2.2 节:前三行是握手(length 0,纯控制报文),第 4 行起 length 68 是 HTTP 请求(GET 一行大约就这么长),随后是响应与确认;末四行是四次挥手——客户端先 FIN、服务端 ACK,服务端数据发完后 FIN、客户端 ACK。注意 seq 1287305422:1287305490 这种区间写法:tcpdump 对携带数据的报文显示起止序号,差值即 length;纯控制报文只显示单值。第一行 SYN 里的 mss 1460,sackOK,wscale 7 选项,正是 2.1 节说的"握手顺路交换参数",第 5 节会用到 mss 这一项。
生产机上更稳妥的姿势是先落盘、后分析:把包写进文件,机器上只承担捕获的开销,分析放到事后慢慢做:
# 捕获写文件(-w),条数与过滤条件照旧收紧
$ sudo tcpdump -i any -n -w /tmp/session.pcap -c 2000 port 80
# 离线回放(-r),-A 可看载荷明文;同一个文件也能直接交给 Wireshark 图形化分析
$ tcpdump -r /tmp/session.pcap | head
reading from file /tmp/session.pcap, link-type EN10MB (Ethernet)pcap 是行业标准格式,tcpdump 抓的文件 Wireshark 能直接打开——命令行里确认"有没有、在哪一端",图形界面里看"为什么",两者处理的是同一份证据。
4.2 flags 速查
| tcpdump 显示 | 含义 | 出现场景 |
|---|---|---|
[S] | SYN | 握手第一步 |
[S.] | SYN+ACK | 握手第二步(. 表示 ACK 位同时置位) |
[.] | 仅 ACK | 数据确认、心跳性质的空确认 |
[P.] | PSH+ACK | 发送方要求接收方尽快交给应用,数据包常见 |
[F.] | FIN+ACK | 挥手 |
[R] / [R.] | RST | 异常中止,见 4.3 |
. 恒代表 ACK 位;组合标志按字母叠加。读抓包文件的节奏就是先扫 Flags 列:一个健康会话的形状必然是"三行握手、若干 P./. 交错、末尾两对 F./. "——形状不对,问题就已经定位了一半。
4.3 RST 的三种来源
RST 出现,先问它替谁说话。其一,端口没人在听:内核收到发往未监听端口的 SYN,立即回 RST,客户端表现为 Connection refused——这是最快、最"诚实"的失败,一毫秒内告诉你"门没开"。其二,防火墙 REJECT:显式拒绝规则(reject-with tcp-reset)会伪造一个 RST 冒充服务端,客户端看到的同样是 refused,但真相在防火墙上——抓包时 RST 的 TTL 与真实服务端报文不一致是穿帮的痕迹(各平台初始 TTL 不同)。其三,进程异常:服务进程崩溃或被 kill -9,内核替它给所有连接发 RST;应用向已被对端关闭的连接写数据,也会招回一个 RST(写端随后收到 EPIPE/SIGPIPE)。三种来源对应三条处置路径,混为一谈就会在错误的方向上浪费时间——详见防火墙篇对 DROP 与 REJECT 的策略讨论。
与 RST 相对的另一个极端是什么都没有:SYN 发出去石沉大海,客户端最终报 timeout。无响应的病因列表完全不同——防火墙 DROP(静默丢弃)、路由不可达、或第 5 节的 PMTU 黑洞。所以排障第一问永远是:refused 还是 timeout?前者说明"有人回话,只是拒绝",后者说明"包根本没到或回不来",两个世界,两套工具链。"端口未监听"这种 RST 可以随手复现,眼见为实:
$ curl --connect-timeout 2 http://203.0.113.10:8080/ &
$ sudo tcpdump -i any -n port 8080
14:39:11.102301 IP 192.168.1.20.51500 > 203.0.113.10.8080: Flags [S], seq 3512994823, win 64240, [mss 1460], length 0
14:39:11.102870 IP 203.0.113.10.8080 > 192.168.1.20.51500: Flags [R.], seq 0, ack 3512994824, length 0
# 客户端随即收到:curl: (7) Failed to connect to 203.0.113.10 port 8080: Connection refused一问一答共两行、总耗时 0.7 毫秒——这就是"诚实拒绝"的全部样子;对比 timeout 场景里 SYN 反复重传、间隔指数退避(1s、2s、4s…)的抓包形状,两种失败在屏幕上一眼可分。
5. MTU、分片与 PMTU
5.1 MTU 是链路属性
MTU(最大传输单元)是一条链路一次能承载的 IP 包尺寸上限,典型以太网是 1500 字节——注意它约束的是 IP 层载荷,不含 14 字节以太网帧头。这个数字不是全局常量而是逐跳属性:本机网卡 1500、中间某段 VPN 隧道 1420、对端 PPPoE 1492,一条路径的 MTU 取其中最小值。
IP 层有分片机制应付超限:大包切成几片独立路由、到目的地重组。但分片代价高昂——任一片丢失,整个原始包作废重传,对 UDP 应用(DNS 大响应、视频流)是一片即全损;对 TCP 更糟的是拥塞控制会把分片误判为路径拥塞。协议设计者因此给 TCP 另辟了路:不分片,从源头就把段长控制在最小值以内。
5.2 MSS 协商与 PMTUD
MSS(最大段长)是 TCP 层的对应物:IPv4 下 1500 MTU 对应 MSS 1460(减去 20 字节 IP 头与 20 字节 TCP 头),IPv6 头更大则为 1440。4.1 节抓包里 SYN 携带的 mss 1460 就是本端声明"这条方向你发给我的段别超过 1460"。双方各自声明、各自生效,握手一次就把两端的出口段长都钉死在双方链路能承受的范围内。
但 MSS 只覆盖了两端,管不到中间链路。若路径中段 MTU 更小(典型:隧道),靠 PMTUD 兜底:IP 头置 DF(Don't Fragment)位,包在窄处被丢弃时,那个路由器回一条 ICMP "Fragmentation Needed"(类型 3 代码 4,携带下一跳 MTU),发送端据此下调段长重发。这套机制 1990 年就标准化了(RFC 1191),问题是它依赖 ICMP 能活着回来——而 ICMP 常被防火墙一刀切拦掉。
5.3 PMTU 黑洞:小包通、大包死
ICMP 被拦 + 窄链路 = PMTU 黑洞,症状极其经典:小包业务一切正常,大包业务必死。SSH 交互正常(每个包几十字节)但 scp 传文件卡死(满 MSS 的数据包);HTTPS 握手挂在证书阶段(证书链塞满大包);curl 小页面能开大页面转圈。凡"命令行能用、传数据不行"的网络问题,都值得先怀疑 MTU。
# DF 位置位、载荷 1472 字节:1472+8(ICMP头)+20(IP头) = 1500
$ ping -c 3 -M do -s 1472 203.0.113.10
PING 203.0.113.10 (203.0.113.10) 1500(1528) bytes of data.
From 192.168.1.1 icmp_seq=1 Frag needed and DF set (mtu = 1492)
From 192.168.1.1 icmp_seq=2 Frag needed and DF set (mtu = 1492)
# 逐级降载荷二分:1472 失败、1464 成功 → 路径 MTU = 1464+28 = 1492(PPPoE)
$ ping -c 3 -M do -s 1464 203.0.113.10
1492 bytes from 203.0.113.10: icmp_seq=1 ttl=64 time=1.87 ms-M do 强制 DF(do = Don't Fragment),-s 指定 ICMP 载荷字节。能收到 "Frag needed" 回执是幸运的——路径 MTU 直接写在括号里;更阴险的情况是 ICMP 也被拦,表现为大载荷静默超时而小载荷正常,此时只能二分试出来。定位到窄点后的修复按层选:能改就放行 ICMP 类型 3 代码 4 让 PMTUD 正常工作;改不了对端就在出口做 MSS clamping(5.4 节)。
排查也从本机查起——先确认自己网卡的 MTU 没有先成为瓶颈,尤其是隧道与虚拟接口:
$ ip link show | grep -E "^[0-9]+:"
2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT
3: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN mode DEFAULT
# 临时改小验证(重启失效;持久化配置见网络配置基础篇)
$ sudo ip link set dev wg0 mtu 1380隧道接口的 MTU 由创建工具设定(wg-quick 默认 1420),手工再包一层加密或嵌套隧道时要自己往下扣;持久化方法(Netplan/systemd-networkd/NetworkManager 三系各自的写法)在网络配置基础统一讲,此处不重复。
5.4 封装叠加与 MSS clamping
VPN 与隧道是现代 MTU 问题的主要产地:WireGuard 默认 MTU 1420(外层 IP+UDP+加密头吃掉约 80 字节),PPPoE 是 1492(6 字节 PPP 头 + 2 字节 PPP 协议域)。叠加封装的极端例子是"VPN 里再套 VPN",MTU 逐层缩水,最内层的满尺寸包注定过不去。
既然黑洞的根源是"握手里声明的 MSS 大于路径实际能过的大小",就有了一个釜底抽薪的手段:MSS clamping——网关在转发的 SYN/SYN+ACK 里把 MSS 选项改写成出口 MTU 能承受的值,两端全程蒙在鼓里,但从此不会造出过大的段。PPPoE 拨号路由器、云上 overlay 网关默认都做这件事;自建 NAT/网关时记得补上(nftables 的 tcp mss clamp 或 iptables 的 TCPMSS --clamp-mss-to-pmtu 目标),策略与转发配置见安全篇防火墙。内网万兆环境追求吞吐会用到 9000 字节的巨帧(jumbo frame),但那是全链路共识——交换机、网卡、对端全部支持才行,边界上仍要回到 1500。
6. UDP 与选型
6.1 无连接的代价与收益
UDP 把"尽力而为"四个字执行到底:无握手、无重传、无顺序保证、无拥塞控制,换来的是零建立延迟、无状态与近乎零的协议开销。这份极简恰好匹配一类需求:单次往返的小交易(DNS 一问一答,丢了应用层重发一次即可)、迟到不如不到的流(音视频、游戏同步,重传一帧过期画面毫无意义)。判断标准不是"UDP 快",而是"这个业务能不能自己承受丢包"——能,就把可靠性从协议栈里省掉;不能,就老实 TCP,或者在应用层重建一套自己可控的可靠机制。
最后一条路线正是近年的趋势:QUIC(HTTP/3 的底座)跑在 UDP 上,却自己实现了可靠传输、多路复用与集成 TLS 的握手——把 TCP 积累三十年的机制搬到用户态重做一遍,动力之一正是绕开中间设备对 TCP 的僵化干预(内核升级难、防火墙"优化"多)。看懂 TCP 才能看懂 QUIC 为什么这样设计,这是本页知识在当下的落点。选型时把两边的取舍摆在一起:
| 维度 | TCP | UDP |
|---|---|---|
| 建立成本 | 三次握手,1 个 RTT | 无握手,即发即走 |
| 可靠性 | 重传、排序、流控、拥塞控制内建 | 一概没有,应用自理 |
| 顺序 | 保证字节流顺序 | 数据报独立,可能乱序到达 |
| 广播/组播 | 不支持 | 天然支持 |
| 典型负载 | HTTP、SSH、数据库、邮件 | DNS、DHCP、NTP、音视频流、游戏 |
| 现代变体 | MPTCP(多路径) | QUIC(自带可靠性的 UDP) |
6.2 观察 UDP
$ ss -u -a -p | head -5
State Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0 0 0.0.0.0:bootps 0.0.0.0:* users:(("dnsmasq",pid=812,fd=5))
UNCONN 0 0 127.0.0.53%lo:domain 0.0.0.0:* users:(("systemd-resolve",pid=301,fd=12))
UNCONN 0 0 192.168.1.20:47398 203.0.113.53:domain users:(("chronyd",pid=690,fd=5))UDP 套接字没有 ESTAB 之类的状态(无连接),恒为 UNCONN;第三行那种带对端地址的是已连接 UDP——应用主动 connect() 固定了对端,只为收内核帮忙过滤的包,并非握手。Recv-Q 对 UDP 的含义是"已到达、等应用来读"的字节数:DNS 服务器上这个数字持续非零,说明查询到达的速度超过了应用消费的速度,是容量问题的早期信号——这与 TCP 监听队列的 Recv-Q(等待 accept() 的连接数)是同名不同义,读数时先分清套接字类型。
7. 常见坑
TIME_WAIT 数千条引发恐慌。 多数是主动关闭方的正常姿态:高并发短连接业务里,60 秒窗口内攒出几千条毫不奇怪,它不占 fd、不拖慢新建连接。只有当它伴随出方向端口耗尽(connect() 报 Cannot assign requested address,默认临时端口 32768-60999 约 2.8 万个用尽)才需要处理:优先改应用用长连接/连接池,其次 net.ipv4.tcp_tw_reuse=1(仅出方向、需时间戳),再扩 ip_local_port_range。老教程的 tcp_tw_recycle 已随内核 4.12 删除,NAT 下它本来就会误杀连接,别抄。
CLOSE_WAIT 堆积不降。 这是应用 bug 的心电图:对端已 FIN、本端内核已 ACK,连接卡在等应用 close()。用 ss -t state close-wait -p 找到进程,lsof 看 fd 是否同步增长确认泄漏,然后审代码——典型病灶是"读到了 EOF 但异常分支没走清理"。重启进程能让曲线归零,但 fd 会重新攒起来,治本只有改代码。
Connection refused 与超时不分。 refused 意味着一个 RST 立刻回来:端口未监听或防火墙 REJECT(伪造 RST);timeout 意味着包有去无回:防火墙 DROP、路由不通或 MTU 黑洞。两者处置路径完全不同,报错文案就是第一道分诊台——先读错误是"拒绝"还是"超时",再选工具。
MTU 黑洞当成网络抖动。 小包通、大包死,SSH 能登 scp 卡死、小网页能开大文件打转——这是路径 MTU 问题而非"网络不稳"。ping -M do -s 1472 起步二分探测,能收到 "Frag needed" 直接读出窄点 MTU,静默超时则继续降载荷。修复靠放行 ICMP 3/4 或 MSS clamping,不要试图靠"重试"绕过。
NAT/PPPoE 环境忘了 MSS clamping。 自建网关做 NAT 转发一切正常,直到内网用户报"某些网站打不开、上传附件失败"——出口 MTU 小于内网声明的 MSS,且 ICMP 被上游拦成黑洞。在网关上对转发的 SYN 做 tcp mss clamp(或 TCPMSS --clamp-mss-to-pmtu)即刻见效;这是 PPPoE 拨号路由器出厂默认自带的配置,自建网关要自己记得补。
SYN 洪水与 syncookies 混为一谈。 ss -t state syn-recv 计数暴涨、来源地址分散伪造,是 SYN flood 的形状;Linux 的 net.ipv4.tcp_syncookies=1 默认开启,让服务端在半连接队列被灌满时用密码学挑战代替记状态,正常客户端不受影响、攻击者拿不到完整握手。真正的大流量洪水要靠上游清洗,主机参数只是最后一道薄盾——判断攻击是否成立,先看 syn-recv 与 estab 的比例是否畸形。
参考资料
- RFC 9293 — Transmission Control Protocol (TCP)(现行标准,取代 RFC 793)— rfc-editor.org/rfc/rfc9293.html
- RFC 1191 — Path MTU Discovery — rfc-editor.org/rfc/rfc1191.html
- Linux 内核文档 ip-sysctl(tcp_tw_reuse / keepalive / syncookies 等参数)— docs.kernel.org/networking/ip-sysctl.html
- 鸟哥的私房菜 — TCP/IP 与网络基础 — linux.vbird.org
- man 8 ss、man 8 tcpdump、man 8 ping — 各工具手册页