本节目标:把「连接卡住」拆成内核状态、进程文件描述符、线上报文三层证据,能读懂 CLOSE_WAIT、TIME_WAIT、SYN_SENT,并知道每条命令的盲区。
适用版本:JDK 25(Temurin 25.0.5+8);macOS 26.3 的netstat、lsof、tcpdump。
承接的问题
linkd 的 v1 已经能处理 HTTP/1.1、HTTP/2 教学帧和 WebSocket,14.3 也把优雅关闭补上了。但「功能都有」和「出了问题能定位」是两件事。验收前最常见的两个现象是:/healthz 一直返回 200,/books/42 却开始超时,线程转储里业务线程是 RUNNABLE,栈顶停在 SocketDispatcher.read0;或者进程的 fd 数量持续上涨,重启后恢复,日志里却没有任何异常。
这两个现象的共同点是:Java 层只看得到「读阻塞」或「没报错」,看不到 TCP 走到了哪一步。netstat 给状态,lsof 把状态落到进程和 fd,tcpdump 给线上报文。三者顺序不能反:先缩小到端口和状态,再决定抓什么包。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。附录 B 留下的 Probe 进程仍占着 127.0.0.1:9090,本节实测把端口换成 9091,其余命令与判读不变;真实部署仍是 9090。
先确认这台机器有什么
不要照抄 Linux 的排障习惯。macOS 没有 ss,也没有 epoll;tcpdump 需要 root 权限,回环抓包要指定 lo0。先跑三条检查:
command -v netstat lsof tcpdump ab
command -v ss wrk hey
本机输出(第二条命令的退出码为 1,表示三个都没找到):
/usr/sbin/netstat
/usr/sbin/lsof
/usr/sbin/tcpdump
/usr/sbin/ab
结论很直接:诊断命令齐全,ab 可用(ab -V 报 Version 2.3 <$Revision: 1913912 $>),wrk、hey、ss 不存在。15.2 的压测只写 ab 的实测;想用 wrk 的读者需要自己安装,本卷不提供它的数字。
tcpdump 的位置有了,权限是另一回事。本机执行 sudo -n true 返回 sudo: a password is required,也就是说当前会话无法非交互提权。后面抓包样例会明确标成「示意输出」,只有权限错误是「本机输出」。
核心机制一:netstat 的三列才是证据
netstat -anv -p tcp 在 macOS 上同时给出状态、队列和字节计数。-a 包含监听与非监听,-n 不解析名字,-v 才带 rxbytes/txbytes/rhiwat 这些扩展列,-p tcp 限定协议。只写 netstat -an 会得到解析过的名字和缺失的计数,排障时不够用。
先用一个保持连接的客户端制造一对 ESTABLISHED。命令是「发一个请求,然后让 stdin 挂住 6 秒」:
{ printf 'GET /healthz HTTP/1.1\r\nHost: x\r\n\r\n'; sleep 6; } | nc 127.0.0.1 9091
另一个终端里过滤 9091 的两端:
netstat -anv -p tcp | awk '$4 ~ /\.9091$/ || $5 ~ /\.9091$/'
本机输出(节选,行宽有截断):
tcp4 0 0 127.0.0.1.9091 127.0.0.1.50196 ESTABLISHED 120 103 408300 146988 java:63296
tcp4 0 0 127.0.0.1.50196 127.0.0.1.9091 ESTABLISHED 258 34 408256 146988 nc:71207
tcp4 0 0 127.0.0.1.9091 *.* LISTEN 0 0 131072 131072 java:63296
LISTEN 是监听套接字,前两行是同一个连接的两端。读法按列来:
| 列 | 含义 | 常见误读 |
|---|---|---|
Recv-Q | 已到内核、还没被应用 read 走的字节 | 当成应用层队列长度 |
Send-Q | 已交给内核、对端还没确认的字节 | 和累计发送字节混为一谈 |
(state) | TCP 状态机当前状态 | 把 TIME_WAIT 一律当成泄漏 |
rxbytes/txbytes | 该 socket 累计收发的字节量级 | 逐字节和业务载荷对账 |
rhiwat/shiwat | socket 缓冲的历史高水位 | 当成当前内存占用 |
Recv-Q 是最有用的一列。它非零且持续不降,说明数据已经到了本机,应用没有读或读得太慢;它为零而业务仍然超时,问题通常在应用逻辑、下游或写方向。rhiwat 从 131072 抬到 408300 后不会回落,它是高水位,不是当前值,用它判断泄漏会得到相反的结论。
CLOSE_WAIT:对端关了,本端没关
CLOSE_WAIT 的成因只有一句话:本端收到了 FIN,但应用没有调用 close()。为了复现,写一个只 accept、从不 read 的服务端:
try (ServerSocket server = new ServerSocket(9095, 50, InetAddress.getByName("127.0.0.1"))) {
List<Socket> held = new ArrayList<>();
while (true) {
held.add(server.accept());
}
}
客户端连上后写 200000 字节再关闭:
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", 9095), 1000);
socket.getOutputStream().write(new byte[200_000]);
}
本机输出(行宽有截断):
tcp4 200000 0 127.0.0.1.9095 127.0.0.1.61047 CLOSE_WAIT 400728 0 620616 146988 java:64855
tcp4 0 0 127.0.0.1.61047 127.0.0.1.9095 FIN_WAIT_2 0 200000 408300 228908 :65071
两行要一起看:
- 9095 这一侧是
CLOSE_WAIT,Recv-Q=200000。FIN 已经到了,但 20 万字节还躺在接收缓冲区里,因为应用从未read。应用不读,缓冲区不释放;应用不close,socket 不离开 CLOSE_WAIT。 - 客户端那一侧是
FIN_WAIT_2。它发了 FIN 并收到 ACK,正等本端的 FIN。只要 9095 不关,客户端也会一直占着一个 fd。 rxbytes=400728不是 200000。这个计数包含协议头、重传和回环上的重复计算,不要拿它反推「应用读了多少」。判断未读字节只看Recv-Q。
对应到 linkd,Handler.onReadable 里 channel.read(buffer) 返回 -1 就表示对端半关闭。正确的收尾是走 14.3 的关闭流程:beginClose()、排空写队列、channel.close()、从 EventLoop 的登记表移除。如果 handler 看到 -1 只 return,socket 就停在上图的 CLOSE_WAIT。数量随时间上涨,fd 迟早耗尽;此时 accept 会以 Too many open files 失败,而日志里可能一条异常都没有。
用一条命令做基线,不要用眼睛数:
netstat -anv -p tcp | awk '$6 == "CLOSE_WAIT" {n++} END {print n+0}'
本机在实验里得到 2(包含另一条历史连接),9095 这一条是稳定复现的。生产上要按端口统计,并连续观察 5 分钟趋势,单点数字说明不了泄漏。
TIME_WAIT:谁先发 FIN,谁留在原地
TIME_WAIT 不是错误,是主动关闭方在等 2MSL,确保最后一个 ACK 能到达、旧连接的重复报文在网络里消散。本机 sysctl net.inet.tcp.msl 是 15000,所以一条 TIME_WAIT 大约停留几十秒;数它之前先等窗口清空,否则会把上一轮的残留算进来。
做一组对照。先等 35 秒确认归零,再跑 100 次非 keep-alive 请求:
sleep 35
netstat -anv -p tcp | awk '$4 ~ /\.9091$/ && $6 == "TIME_WAIT" {n++} END {print n+0}'
/usr/sbin/ab -n 100 -c 10 http://127.0.0.1:9091/healthz
netstat -anv -p tcp | awk '$4 ~ /\.9091$/ && $6 == "TIME_WAIT" {n++} END {print n+0}'
本机输出:
0
100
再等 35 秒,改用 ab -k -n 100 -c 10 跑同一组请求并统计,本机 9091 这一侧是 0。
差别的原因是关闭方不同:ab 默认用 HTTP/1.0,每个请求一条新连接,服务端响应后先关,TIME_WAIT 落在 9091 这一侧,100 个请求留下 100 条;加 -k 后 100 个请求只用了 10 条连接,关闭发生在测试结束、由 ab 发起,TIME_WAIT 落在客户端临时端口上,9091 这一侧为 0。所以「TIME_WAIT 很多」先问两个问题:统计窗口多长、谁先发的 FIN。
SYN_SENT:握手没完成
SYN_SENT 表示本端发了 SYN,没收到 SYN+ACK。用 RFC 5737 的 TEST-NET-1 地址做实验,30 秒超时:
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("192.0.2.1", 9090), 30_000);
}
本机输出(连接建立中查询,行宽有截断):
tcp4 0 0 10.115.24.171.61117 192.0.2.1.9090 SYN_SENT 0 0 131072 131072 java:65985
SYN_SENT 停留在本端,说明包发出去了但没有回应,常见原因是防火墙丢包、路由不可达、对端 backlog 满到内核直接丢弃。它与 Connection refused 不同:后者收到了 RST,说明包到了主机但没有进程监听。排查时先看状态,再看 tcpdump 有没有回包,不要一上来就怀疑 Java 代码。lsof 对同一条连接给出的是:
java 65985 bingrong.yan 5u IPv6 0xdb80ba421c74ee9a 0t0 TCP 10.115.24.171:61117->192.0.2.1:9090 (SYN_SENT)
核心机制二:lsof 把端口落到进程和 fd
netstat -v 已经带 process:pid,但端口被谁监听、这个进程还开了哪些 fd,lsof 更直接。命令是 lsof -nP -iTCP:9091:-n 不解析主机名,-P 不解析端口名,两个都加才能看到 9091 而不是服务名。
本机输出:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
java 63296 bingrong.yan 5u IPv6 0x76bd93a9d167b25e 0t0 TCP 127.0.0.1:9091 (LISTEN)
FD=5u 是文件描述符 5,u 表示读写打开。监听 fd 和每条连接 fd 的编号不同;连接多了以后,lsof -p 63296 | wc -l 的增长就是 fd 泄漏最直接的证据。本机那个 CLOSE_WAIT 实验进程 64855 在持有连接后,lsof -p 64855 有 25 行输出,其中包含监听 fd 和一条连接 fd。
两个容易踩的坑:
TYPE写IPv6不等于监听在::。 本机监听的是127.0.0.1,lsof仍显示IPv6,这是 JDK 在该平台使用双栈 socket 的表现。地址族以NAME列的实际地址为准。NAME末尾的状态可能和netstat不一致。 本机那条 CLOSE_WAIT 连接在lsof里显示(CLOSED)。lsof的状态来自它对 fd 的解读,TCP 状态机以netstat为准;两者冲突时不要用lsof下结论。
lsof 返回 1 且无输出表示没有匹配项,不是命令坏了;查监听加 -sTCP:LISTEN,查已建立加 -sTCP:ESTABLISHED。
核心机制三:tcpdump 的权限与网卡边界
回环抓包的命令是:
sudo tcpdump -i lo0 -nn -s 0 port 9091
四个参数各有作用:-i lo0 抓回环,linkd 和 libraryd 都在本机时流量不走 en0;-nn 不解析主机名和端口名,避免 DNS 拖慢抓包;-s 0 抓完整包,不截断载荷;port 9091 只留目标端口。少写 lo0 是最常见的错误:在 en0 上等半天,一个包都看不到,因为回环流量根本不过物理网卡。
不加 sudo 时,本机输出是:
tcpdump: lo0: You don't have permission to capture on that device
((cannot open BPF device) /dev/bpf0: Permission denied)
这条错误说明权限不足,不是网卡不存在,也不是过滤器写错。不要照着它去改 -i 参数。
本机 sudo 需要密码,无法非交互执行,所以下面这段抓包结果是示意输出:字段与真实报文一致,时间、端口、序号仅用于讲解,不代表任何一次实际抓包。
17:50:01.123456 IP 127.0.0.1.50196 > 127.0.0.1.9091: Flags [S], seq 1000, win 65535, options [mss 16344,nop,wscale 6], length 0
17:50:01.123478 IP 127.0.0.1.9091 > 127.0.0.1.50196: Flags [S.], seq 2000, ack 1001, win 65535, options [mss 16344,nop,wscale 6], length 0
17:50:01.123490 IP 127.0.0.1.50196 > 127.0.0.1.9091: Flags [.], ack 1, win 2048, length 0
17:50:01.123501 IP 127.0.0.1.50196 > 127.0.0.1.9091: Flags [P.], seq 1:19, ack 1, length 18
17:50:01.123560 IP 127.0.0.1.9091 > 127.0.0.1.50196: Flags [P.], seq 1:97, ack 19, length 96
17:50:01.123570 IP 127.0.0.1.9091 > 127.0.0.1.50196: Flags [F.], seq 97, ack 19, length 0
读法:
[S]、[S.]、[.]是三次握手;[P.]是带数据的包;[F.]是 FIN;[R]是 RST。- 只有
[S]没有[S.],说明 SYN 到了对端但没人应答,问题在监听端、backlog 或防火墙。 - 谁先发
[F.],谁进 TIME_WAIT。上例是 9091 先发,和前面非 keep-alive 的统计对得上。 seq:ack的区间长度等于length,可以据此确认半包是不是真的被拆成了多个 TCP 段。
常用收窄方式:
sudo tcpdump -i lo0 -nn -s 0 -c 20 port 9091 # 抓 20 个包就停
sudo tcpdump -i lo0 -nn -w /tmp/linkd.pcap port 9091 # 存文件,用 Wireshark 慢慢看
-c 是防止忘记停、磁盘被写满的最简单办法;-w 存下来的 pcap 可以反复分析,比终端刷屏有用。抓包只能证明「线上发生了什么」,不能证明「应用为什么这么处理」,最终仍要回到线程栈和代码。
在 linkd 里落地:ConnectionDiagnostics
把上面的手工命令收成一个类,供 LinkdMain 的运维入口调用。它不做端口猜测,只接受端口号,输出按状态聚合的计数和前几条非 LISTEN 明细:
package com.plume.linkd.app;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public final class ConnectionDiagnostics {
public record Report(int port, Map<String, Integer> states, int listenFds,
List<String> samples) {
}
public static void main(String[] args) throws IOException, InterruptedException {
Report r = inspect(args.length > 0 ? Integer.parseInt(args[0]) : 9090);
System.out.println("port=" + r.port() + " listenFds=" + r.listenFds());
r.states().forEach((s, n) -> System.out.printf("%-12s %d%n", s, n));
r.samples().forEach(line -> System.out.println(" " + line));
}
public static Report inspect(int port) throws IOException, InterruptedException {
Map<String, Integer> states = new LinkedHashMap<>();
List<String> samples = new ArrayList<>();
String suffix = "." + port;
for (String line : run(List.of("netstat", "-anv", "-p", "tcp"))) {
String[] f = line.trim().split("\\s+");
if (f.length < 6 || !f[0].startsWith("tcp")) {
continue;
}
if (!f[3].endsWith(suffix) && !f[4].endsWith(suffix)) {
continue;
}
states.merge(f[5], 1, Integer::sum);
if (!"LISTEN".equals(f[5]) && samples.size() < 5) {
samples.add(line.trim());
}
}
return new Report(port, states, countListenFds(port), samples);
}
private static int countListenFds(int port) throws IOException, InterruptedException {
int count = 0;
for (String line : run(List.of("lsof", "-nP", "-iTCP:" + port, "-sTCP:LISTEN"))) {
if (!line.startsWith("COMMAND") && !line.isBlank()) {
count++;
}
}
return count;
}
private static List<String> run(List<String> command)
throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
if (!process.waitFor(5, TimeUnit.SECONDS)) process.destroyForcibly();
return lines;
}
}
对空闲的替身服务运行 java -cp out com.plume.linkd.app.ConnectionDiagnostics 9091,本机输出(行宽有截断):
port=9091 listenFds=1
LISTEN 1
TIME_WAIT 2
tcp4 0 0 127.0.0.1.9091 127.0.0.1.60826 TIME_WAIT 310 84 408256 146988 :63380
对 CLOSE_WAIT 实验端口运行,本机输出(行宽有截断):
port=9095 listenFds=1
CLOSE_WAIT 1
FIN_WAIT_2 1
LISTEN 1
tcp4 200000 0 127.0.0.1.9095 127.0.0.1.61047 CLOSE_WAIT 400728 0 620616 146988 java:64855
三个设计约束:
- 匹配
.9091结尾,不用grep 9091。 后者会同时命中19091、90911,在端口密集的机器上会虚增计数。 inspect会阻塞,不能放在EventLoop上。ProcessBuilder.start()加waitFor最长 5 秒;在事件循环里调用会让所有连接停摆。它只能从LinkdMain的独立运维线程或 12.2 的业务线程池触发。- 输出解析绑定 macOS 的
netstat列序。 换 Linux 需要重写字段下标;这不是跨平台工具,只是把本节的手工步骤固化成可重复执行的代码。
接入 LinkdMain 时,用一个名为 linkd-diag 的守护线程调用 inspect(9090),把 states() 交给 System.getLogger。inspect 会抛 IOException,必须捕获并记日志,不能让运维线程静默退出。
常见错误与边界
错误一:用 grep 9090 过滤端口。 19090、90900 都会被匹配,状态计数虚高。用 awk '$4 ~ /\.9090$/ || $5 ~ /\.9090$/' 匹配地址后缀。
错误二:把 TIME_WAIT 数量当连接泄漏。 本机同样 100 个请求,非 keep-alive 留下 100 条,keep-alive 留下 0 条。数量取决于关闭方和统计窗口,不取决于代码好坏。先看谁先发 FIN,再看窗口内是否有异常增长。
错误三:看到 CLOSE_WAIT 就重启。 重启能清掉计数,但根因还在:某条代码路径收到 -1 没有 close()。不修代码,几小时后数量会回到同一个值。
错误四:在 en0 上抓回环。 linkd 与 libraryd 都监听 127.0.0.1,流量走 lo0。抓错网卡的表现是「命令没报错,但一个包都没有」。
边界:netstat -v 的字节计数不是业务载荷。 rxbytes=400728 对应 200000 字节载荷,差值来自协议头、重传和平台计数方式;判断未读数据用 Recv-Q。
边界:抓包不等于定位。 tcpdump 能证明 FIN 是谁发的、RST 有没有回来,但不能告诉你应用为什么没 close()。抓包之后仍要回到线程栈和 Connection 的关闭路径。
小结
- macOS 没有
ss;连接诊断用netstat -anv -p tcp、lsof -nP -iTCP:<port>、sudo tcpdump -i lo0。 Recv-Q是应用未读字节,Send-Q是对端未确认字节;rhiwat是高水位,不是当前占用。- CLOSE_WAIT 表示收到 FIN 但应用没
close(),TIME_WAIT 由主动关闭方持有;本机实验分别是Recv-Q=200000与 100 条对 0 条。 - SYN_SENT 表示握手没有回应,与
Connection refused的 RST 是两类问题;ConnectionDiagnostics会阻塞,不能跑在事件循环线程上。
能看见连接停在哪一步之后,下一个问题是「这个服务到底能扛多少」。单条连接的证据不能回答吞吐和尾延迟,下一节用 ab 和分位数把容量说清楚。
阅读导航:上一节:14.3 优雅关闭与连接排空 · 下一节:15.2 压测工具与指标口径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。