使用前提与本机口径
这一页只收 macOS 上能直接执行的命令。macOS 没有 ss,也没有 Linux 那套 socket 统计工具;连接诊断靠 lsof、netstat、tcpdump,JVM 诊断靠 jcmd、jstack、jfr。Linux 部署侧的差异单独标注。
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
gRPC-Java 1.76.0(grpc-netty-shaded)
先把 JDK 固定住,避免 jcmd 用 JDK 8 去连 JDK 25 的进程:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
端口约定:libraryd 8080,comm-server gRPC 9093,gateway HTTP 9094,指标与管理 9095。下文默认排查 9095 和 9093;查网关时替换为 9094。
标注「本机」的输出必须来自真实执行;本附录的命令是模板,除平台限制外不附带伪造输出。JDK 25 上运行 gRPC shaded Netty 可能打印 sun.misc.Unsafe::allocateMemory 的终结弃用警告,这是依赖内部行为,不是命令或示例的错误。
JDK 与构建
java -version
mvn -v
mvn -q -DskipTests package
mvn -q test
mvn -q dependency:tree -Dincludes=io.grpc:grpc-netty
用途:
java -version确认运行时是 25.0.5,不是默认的 JDK 8。mvn -v确认 Maven 3.9.12 和它使用的JAVA_HOME。package产出四个模块的 jar;test跑单测与契约测试。dependency:tree确认只有grpc-netty-shaded,没有非 shaded 的grpc-netty混入。
风险:mvn test 会启动真实端口(测试用 0 号随机端口),如果测试里写了固定端口,可能与本地服务冲突。看 surefire 报告确认端口来源,不要靠猜。
证书与 TLS
keytool -list -v -keystore /etc/linkd/server.p12 -storetype PKCS12
keytool -genkeypair -alias linkd -keyalg RSA -keysize 2048 \
-validity 365 -keystore /tmp/linkd-test.p12 -storetype PKCS12 \
-dname 'CN=localhost, OU=Linkd, O=Plume, L=Shanghai, ST=Shanghai, C=CN' \
-ext 'SAN=dns:localhost,ip:127.0.0.1'
openssl x509 -in /etc/linkd/server.crt -noout -subject -issuer -dates
openssl verify -CAfile /etc/linkd/ca.crt /etc/linkd/server.crt
openssl s_client -connect 127.0.0.1:9093 -alpn h2 -servername localhost </dev/null
用途:
keytool -list看 keystore 里的别名、算法、有效期和证书链。-genkeypair生成自签测试证书,SAN必须包含客户端实际使用的主机名或 IP,否则主机名校验失败。openssl x509 -dates看notBefore/notAfter,这是证书过期最快的判断方式。openssl verify验证证书链;失败通常是缺中间 CA。s_client做真实握手,-alpn h2确认协商到 HTTP/2。
风险:s_client 会建立真实 TLS 连接,可能触发服务端握手日志;不要在生产高峰大量执行。私钥和 keystore 口令不要写进命令历史,用 -storepass 从环境变量或交互输入读取。
端口与连接
lsof -nP -iTCP:9093
lsof -nP -iTCP:9093 -sTCP:LISTEN
lsof -nP -iTCP:9093 -sTCP:ESTABLISHED
lsof -nP -p "$(pgrep -f 'comm-server.*jar' | head -1)"
netstat -anv -p tcp | awk 'NR==1 || $4 ~ /\.9093$/ || $5 ~ /\.9093$/'
用途:
-n不做 DNS 反解,-P不把端口换成服务名,避免命令卡住。-sTCP:LISTEN只看向监听;没有这一行就是没启动或绑错地址。-sTCP:ESTABLISHED数连接;服务端与客户端各一条,数连接数时只数服务端 PID。netstat -anv -p tcp看内核视角的Recv-Q/Send-Q,判断应用是否读得慢。
读法:
Recv-Q持续大于 0,说明应用没来得及读;可能是线程阻塞,也可能是AUTO_READ类机制被关掉。Send-Q持续增大,说明对端读得慢或网络拥塞。rhiwat/shiwat是内核 socket 缓冲高水位,不是应用层写水位。
风险:lsof 遍历文件描述符表,连接数很大时耗时上升,不要放进每秒执行的心跳脚本。看自己的进程不需要 sudo。
macOS 没有 ss。Linux 上对应命令是 ss -tanp、ss -s,不要把本节的 netstat 参数直接搬到 Linux。
抓包
macOS 的 loopback 是 lo0,不是 Linux 的 lo。tcpdump 需要 sudo:
sudo tcpdump -i lo0 -nn -s 0 -c 50 port 9093
sudo tcpdump -i lo0 -nn -A -s 0 -c 20 port 9094
sudo tcpdump -i lo0 -nn -w /tmp/linkd.pcap -c 500 port 9093
用途:
-nn不解析主机名与端口名。-s 0抓完整包,不截断 payload。-c限包数,避免忘了停。-A以 ASCII 显示 payload,看明文 HTTP;gRPC 是二进制,-A只能看到帧头的一部分。-w落盘,后续用 Wireshark 或tcpdump -r分析。
读法:
- 三次握手是
[S]、[S.]、[.];只有[S]没有[S.],常见于 backlog 满或 accept 卡住。 [P.]是带数据的包;length是负载字节数。win 0是零窗口,表示接收缓冲满;对照netstat的Recv-Q区分「应用没读」和「内核没空间」。- 大量重复
seq是重传;大量[R]是连接被重置。
风险:抓包需要 sudo,payload 可能含令牌、Cookie 与业务数据,pcap 按敏感文件管理。生产不要长期开,用 -c 限包数或 -w 落盘后立即停止。流量大时抓包本身会丢包,看到「少包」不等于链路真丢了。
压测:ab 可用,wrk/hey 未安装
本机 ab 可用;wrk 和 hey 未安装,不要写进脚本当作可用工具。
ab -V
ab -k -n 5000 -c 50 http://127.0.0.1:9094/healthz
ab -k -n 20000 -c 64 \
-p /tmp/getbook.json \
-T 'application/json' \
http://127.0.0.1:9094/books/978-7-111-00001-1
用途与限制:
-k复用连接;不加是每个请求一次握手。-n总请求数,样本太少时 p99 没有意义。-c并发数;ab 单进程,-c太大时客户端自身可能先饱和。-p/-T用于 POST;GET 不需要。
ab 是闭环工具,存在协调缺失:服务端卡顿时客户端不发请求,卡顿时段不进入延迟统计。它也不支持 gRPC、流式响应和 HTTP/2。需要这些时用 14.2 的自写 gRPC 客户端,不要临时安装未经验证的工具。
风险:压测会真实占用下游资源。先在测试租户、独立限流配额下执行,并设置停止条件(p99、错误率、持续时间)。不要对生产入口直接跑 -n 1000000。
JVM 诊断:jcmd 与 JFR
jcmd -l
jcmd <pid> Thread.print > /tmp/threads-1.txt
sleep 5
jcmd <pid> Thread.print > /tmp/threads-2.txt
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram | head -20
jcmd <pid> VM.system_properties | grep -E 'linkd|grpc'
jcmd <pid> VM.command_line
jcmd <pid> VM.native_memory summary
用途:
jcmd -l列出可附加的 JVM 与主类,先确认 pid。Thread.print抓线程栈;间隔 5 秒抓两次,同一个方法两次都在栈顶才是「卡住」。GC.heap_info看堆的 reserved / committed / used,不触发 full GC。GC.class_histogram看对象类型分布,默认会先做一次 full GC,停顿明显。VM.system_properties确认linkd.fault.enabled等参数是否生效。VM.command_line看启动参数。VM.native_memory summary需要启动时加-XX:NativeMemoryTracking=summary,否则报未启用。
JFR 的 start/stop 用 jcmd,读文件用 jfr:
jcmd <pid> JFR.start name=linkd settings=profile \
filename=/tmp/linkd.jfr duration=60s
jcmd <pid> JFR.check name=linkd
jcmd <pid> JFR.stop name=linkd
jfr summary /tmp/linkd.jfr
jfr print --events jdk.GarbageCollection,jdk.SocketRead,jdk.SocketWrite /tmp/linkd.jfr
读法:jfr summary 先看实际 Duration,再看 GC 暂停、SocketRead/SocketWrite 耗时分布,最后看 CPU 采样。duration 到了之后录制会自动结束,再按名字 stop 会失败;先 JFR.check 看状态。
也可以用 jstack <pid> 代替 Thread.print,输出格式基本一致。jcmd 与目标进程最好用同一个 JDK,版本差太远可能附加失败。
风险:线程栈需要走到安全点,进程有明显停顿时输出会延迟;对延迟敏感的服务不要连续高频抓。GC.class_histogram 触发 full GC,避开高峰。JFR 的 profile 配置开销低于 diagnostic,但不是零,必须配 duration 或 maxsize。
用 Java 封装命令与指标读取
诊断脚本经常需要「执行命令、限时、拿输出」。用 ProcessBuilder 包一层,避免脚本里无限等待:
package com.plume.linkd.v3.diag;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public final class CommandRunner {
public record Output(int exitCode, String stdout, String stderr, boolean timedOut) {
}
public static Output run(Duration timeout, String... command) throws Exception {
Process process = new ProcessBuilder(command).start();
boolean finished = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
process.destroyForcibly();
return new Output(-1, "", "timeout after " + timeout, true);
}
String stdout = new String(process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
String stderr = new String(process.getErrorStream().readAllBytes(),
StandardCharsets.UTF_8);
return new Output(process.exitValue(), stdout, stderr, false);
}
public static void main(String[] args) throws Exception {
Output output = run(Duration.ofSeconds(5), "lsof", "-nP", "-iTCP:9095");
System.out.println("exit=" + output.exitCode() + " timedOut=" + output.timedOut());
System.out.println(output.stdout());
}
}
这个实现只适合输出较小的诊断命令。如果命令输出很大,waitFor 之后才读流可能因为管道缓冲满而卡住;生产脚本要么把输出重定向到临时文件,要么用两个线程分别读 stdout/stderr。
从 9095 读一个指标值,避免每次手写 grep:
package com.plume.linkd.v3.diag;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class MetricsProbe {
private final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(300))
.build();
public double value(String baseUrl, String metricName) {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/metrics"))
.timeout(Duration.ofSeconds(2))
.GET()
.build();
try {
String body = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
for (String line : body.split("\n")) {
if (line.startsWith("#") || !line.startsWith(metricName)) {
continue;
}
String[] parts = line.trim().split("\\s+");
if (parts.length >= 2) {
return Double.parseDouble(parts[parts.length - 1]);
}
}
return Double.NaN;
} catch (Exception e) {
return Double.NaN;
}
}
}
这个解析器只适合「一个指标名对应一行样本」的场景。带 label 的指标会匹配到多行,应该按 label 精确匹配,或者直接用 Prometheus 查询接口。NaN 表示没找到或请求失败,调用方要单独处理,不要当成 0。
组合排查路径
单条命令只回答一个问题。按现象定顺序,前一步没有结论再走下一步:
| 现象 | 第一条命令 | 接着看 | 判据 |
|---|---|---|---|
| 9094 连不上 | lsof -nP -iTCP:9094 -sTCP:LISTEN | netstat -anv -p tcp、tcpdump -i lo0 | 没有 LISTEN 是没启动;有 LISTEN 但 SYN 无响应看 backlog |
| gRPC 返回 UNAVAILABLE | lsof -nP -iTCP:8080 -sTCP:LISTEN | pgrep、curl 8080/healthz | libraryd 没听、进程退出或被防火墙拦 |
| p99 升高、CPU 不高 | 两次 jcmd Thread.print(间隔 5 秒) | netstat 的 Recv-Q、JFR | 线程卡在锁/下游等待;Recv-Q 持续大于 0 |
| 429 增多 | 查 ratelimit_rejected_total 维度 | 入口 QPS、下游 QPS | 入口没涨而下游涨,是重试风暴 |
| 连接数只涨不降 | lsof -nP -iTCP:9093 -sTCP:ESTABLISHED | wc -l | TIME_WAIT、连接池借出数 | 请求停止后不回落,是泄漏 |
| 堆外内存上涨 | jcmd GC.heap_info | RSS、VM.native_memory summary | 堆内稳定而 RSS 上涨,查直接缓冲与连接 |
| 证书握手失败 | openssl x509 -enddate | s_client、truststore | notAfter 已过或 SAN 不匹配 |
固定顺序是 lsof → netstat → 两次 Thread.print → GC.heap_info → GC.class_histogram → JFR。原则是「先便宜、后昂贵;先现象、后根因」。lsof 与 netstat 几乎不影响服务,线程栈有短暂 safepoint,class_histogram 触发 full GC,JFR 持续写文件。不要在第一步就上最重的工具。
常见错误与边界
把 lsof 的 IPv6 通配当成只监听 IPv6。 *:9093 通常是双栈,IPv4 客户端也能连;判断协议族看 tcp4 / tcp46。
把 netstat 的 rhiwat 当成应用层写水位。 前者是内核 socket 缓冲高水位,后者是 Netty 的 WriteBufferWaterMark,观察位置和调整方式都不同。
不带 sudo 跑 tcpdump。 macOS 的 BPF 设备需要权限,报错是 You don't have permission to capture on that device,不是接口名写错。
在生产长期开 JFR profile。 开销低于 diagnostic,但不是零;必须配 duration 或 maxsize,录完即停。
用 JDK 8 的 jcmd 连 JDK 25 进程。 先对齐 java -version 与 jcmd -version;附加失败先怀疑版本,不要怀疑端口。
把 GC.class_histogram 当无侵入命令。 它默认统计存活对象,会先做 full GC;延迟敏感实例上这一下停顿足以触发超时。
假设 wrk、hey、grpcurl 可用。 本机未安装,脚本里要么用 ab,要么自写客户端;不要写「执行 wrk 得到以下结果」。
把 JDK 25 的 Unsafe 警告当成启动失败。 gRPC shaded Netty 会触发终结弃用警告,进程仍可正常服务;判断健康看 readyz 和实际调用,不看警告文本。
边界:本附录是 macOS 口径。 Linux 上的 ss、/proc、cgroup 指标不在其中;跨平台脚本要先判断 uname,不要混用参数。
边界:命令输出会随版本变化。 lsof 的列、netstat 的字段、jcmd 的子命令在不同版本可能不同;以本机 man 和 --help 为准。
小结
- 先固定
JAVA_HOME,确认java -version是 25.0.5,再谈诊断。 - 证书用
keytool -list、openssl x509 -dates、s_client -alpn h2三条命令覆盖有效期、链和握手。 - macOS 没有
ss;连接用lsof、netstat,抓包用sudo tcpdump -i lo0。 ab可用但只适合 HTTP/1.x 闭环压测;wrk、hey、grpcurl未安装,不要假设可用。- 线程栈至少抓两次、间隔 5 秒;JFR 用
jcmd控制、jfr读文件。 - 排查顺序先便宜后昂贵:端口与连接 → 线程栈 → 堆与对象 → JFR;先现象后根因。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。