本节目标:把一次性的调优结论变成可重复的门禁:基线 JSON 怎么采、阈值怎么定、什么时候判 PASS/FAIL,以及如何从单机曲线推到副本数。
适用版本:JDK 25(Temurin 25.0.5+8),ab为 macOS 自带/usr/sbin/ab。本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125),默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。
承接的问题
10.2 的结论是「保留默认 G1,把单实例在途请求限制在 20」。这个结论如果要长期有效,需要两件事:
- 每次改代码、升级 JDK、改参数后,能快速判断有没有回退。
- 流量增长时,能算出要加几个副本,而不是拍脑袋。
手工跑一次 ab 不够。基线要包含启动、吞吐、分位、GC 和堆,才能区分「吞吐掉了」是 GC 变多、启动变慢,还是单纯机器噪声。基线还要能被脚本对比,输出 PASS/FAIL,而不是靠人眼看两份报告。
这一节用 10.1 的最小服务做完整演示:先采集两次相同配置的基线,验证门禁不会误报;再用 -Xint 制造一次真实回归,验证门禁会报 FAIL;最后用 Little 定律和 p99 曲线做容量规划。
一、基线 JSON 的字段
一份可对比的基线至少包含:
{
"startup_ms": 123.4,
"throughput": 6190.6,
"p50_ms": 7.0,
"p95_ms": 15.0,
"p99_ms": 22.0,
"gc_young": 39,
"gc_max_pause_ms": 2.73,
"heap_used_mb": 107.9
}
每个字段的来源:
startup_ms:从进程启动到打印监听的时间,由脚本用perf_counter包住Popen和readline。throughput、p50/p95/p99:ab -k跑 3 轮、每轮 2000 个请求,取中位数。gc_young、gc_max_pause_ms:启动时带-Xlog:gc:file=...,从日志里数Pause Young和最大ms。heap_used_mb:压测结束后用jcmd <pid> GC.heap_info读used。
这是 /api/reports/summary?n=20000 在默认 G1 下的本机基线。换到借阅端点 /api/loans/42,同一脚本得到的是:
{
"startup_ms": 120.1,
"throughput": 10551.2,
"p50_ms": 2.0,
"p95_ms": 3.0,
"p99_ms": 5.0,
"gc_young": 1,
"gc_max_pause_ms": 4.14,
"heap_used_mb": 152.4
}
两个端点的数字不能混在一份基线里。借阅端点主要成本在网络和 JSON 拼接,报告端点主要成本在 CPU 和分配;它们的吞吐、p99、GC 次数都不同。基线文件按端点命名,例如 baseline-loan.json 和 baseline-report.json。
二、采集脚本:启动、压测、GC、堆
scripts/jvm/baseline.py 的核心是四段:启动服务、warmup、跑 ab、读 jcmd。下面是可运行的版本:
#!/usr/bin/env python3
import argparse, json, os, re, statistics, subprocess, sys, time, urllib.request
JAVA = os.path.expanduser("~/.jdks/jdk-25.0.5+8/Contents/Home/bin/java")
JCMD = os.path.expanduser("~/.jdks/jdk-25.0.5+8/Contents/Home/bin/jcmd")
AB = "/usr/sbin/ab"
def wait_ready(proc):
t0 = time.perf_counter()
line = proc.stdout.readline()
if "listening" not in line:
raise RuntimeError("service did not start: " + line)
return (time.perf_counter() - t0) * 1000
def ab_run(url, n, c):
r = subprocess.run([AB, "-k", "-n", str(n), "-c", str(c), url],
capture_output=True, text=True, check=True)
out = r.stdout
pick = lambda p: float(re.search(p, out).group(1))
return {
"rps": pick(r"Requests per second:\s+([\d.]+)"),
"p50": pick(r"\n\s+50%\s+(\d+)"),
"p95": pick(r"\n\s+95%\s+(\d+)"),
"p99": pick(r"\n\s+99%\s+(\d+)"),
}
def jcmd(pid, command):
return subprocess.run([JCMD, str(pid), command],
capture_output=True, text=True).stdout
主流程:
ap = argparse.ArgumentParser()
ap.add_argument("--jar", required=True)
ap.add_argument("--port", type=int, default=18171)
ap.add_argument("--path", default="/api/loans/42")
ap.add_argument("--out", required=True)
ap.add_argument("--compare")
ap.add_argument("--jvm-args", default="")
args = ap.parse_args()
log = args.out.replace(".json", ".gc.log")
proc = subprocess.Popen(
[JAVA, *args.jvm_args.split(),
f"-Xlog:gc:file={log}:time,uptime,level,tags",
"-jar", args.jar, str(args.port)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
startup = wait_ready(proc)
base = f"http://127.0.0.1:{args.port}"
ab_run(base + args.path, 300, 20)
runs = [ab_run(base + args.path, 2000, 50) for _ in range(3)]
heap = jcmd(proc.pid, "GC.heap_info")
used = re.search(r"used\s+(\d+)K", heap)
time.sleep(0.3)
urllib.request.urlopen(base + "/shutdown").read()
proc.wait(timeout=20)
text = open(log, errors="ignore").read()
pauses = [float(x) for x in re.findall(r"Pause Young[^\n]*?([\d.]+)ms", text)]
sample = {
"startup_ms": round(startup, 1),
"throughput": round(statistics.median(r["rps"] for r in runs), 1),
"p50_ms": statistics.median(r["p50"] for r in runs),
"p95_ms": statistics.median(r["p95"] for r in runs),
"p99_ms": statistics.median(r["p99"] for r in runs),
"gc_young": text.count("Pause Young"),
"gc_max_pause_ms": round(max(pauses) if pauses else 0.0, 2),
"heap_used_mb": round(int(used.group(1)) / 1024, 1) if used else None,
}
json.dump(sample, open(args.out, "w"), indent=2)
print(json.dumps(sample, indent=2))
这段脚本有三处容易写错:
wait_ready必须读服务的启动行。如果服务启动失败,readline返回空串,脚本要立刻报错,不能继续压测一个不存在的端口。ab必须带-k。10.1 已经证明,不带-k的 p99 会把 TCP 建连算进去,同一端点从 17 ms 变成 78 ms。-Xlog:gc在 Python 的列表参数里不需要加 shell 引号,但手工在 zsh 里执行时必须加,否则*会被展开。
三、两次相同基线:PASS 与阈值的绝对下限
先跑第一次:
python3 scripts/jvm/baseline.py \
--jar /tmp/vol4-e/minihttpd.jar --port 18171 \
--out logs/baseline-1.json
本机输出:
{
"startup_ms": 120.1,
"throughput": 10551.2,
"p50_ms": 2.0,
"p95_ms": 3.0,
"p99_ms": 5.0,
"gc_young": 1,
"gc_max_pause_ms": 4.14,
"heap_used_mb": 152.4
}
第二次加 --compare:
python3 scripts/jvm/baseline.py \
--jar /tmp/vol4-e/minihttpd.jar --port 18171 \
--out logs/baseline-2.json --compare logs/baseline-1.json
阈值不能只写相对值。以 baseline-1 的 p99 5 ms 为例,如果下一次测到 7 ms,p99 <= 130% 会判 FAIL;但 2 ms 在 ab 的 1 ms 粒度下只是两档噪声,这种误报会让门禁失去信任。脚本把判据改成「相对 130% 或绝对 +5 ms,取较宽者」。第二次运行的本机输出:
{
"startup_ms": 108.0,
"throughput": 10721.5,
"p50_ms": 2.0,
"p95_ms": 3.0,
"p99_ms": 4.0,
"gc_young": 1,
"gc_max_pause_ms": 1.66,
"heap_used_mb": 155.6
}
PASS throughput >= 90%
PASS p99 <= max(130%, +5ms)
PASS gc_max_pause <= prev+5ms
PASS startup <= 120%
RESULT: PASS
这不是「放宽阈值让结果好看」。低毫秒区间的相对阈值会被量化噪声放大;5 ms 的绝对下限是测量分辨率的补偿。对 p99 50 ms 以上的服务,相对阈值仍然有效;对 p99 5 ms 的服务,绝对下限更合理。
四、制造一次真实回归:-Xint 必须 FAIL
门禁只有在能抓住回归时才有意义。用 -Xint 关掉 JIT,跑同一个报告端点:
python3 scripts/jvm/baseline.py \
--jar /tmp/vol4-e/minihttpd.jar --port 18171 \
--path "/api/reports/summary?n=20000" \
--out logs/baseline-report-xint.json \
--jvm-args=-Xint \
--compare logs/baseline-report.json
本机输出:
{
"startup_ms": 113.5,
"throughput": 69.5,
"p50_ms": 663.0,
"p95_ms": 864.0,
"p99_ms": 1950.0,
"gc_young": 102,
"gc_max_pause_ms": 5.9,
"heap_used_mb": 213.8
}
FAIL throughput >= 90%
FAIL p99 <= max(130%, +5ms)
PASS gc_max_pause <= prev+5ms
PASS startup <= 120%
RESULT: FAIL
报告端点的吞吐从 6190.6 掉到 69.5 req/s,p99 从 22 ms 涨到 1950 ms。门禁抓住了这次回归,而且 startup 和 gc_max_pause 仍然是 PASS——这正说明基线要分项看:启动没变慢、GC 没恶化,问题在 JIT 缺席导致的 CPU 执行效率。
有趣的是,同一个 -Xint 在借阅端点 /api/loans/42 上仍然能跑 10273 req/s,门禁会 PASS。借阅端点太轻,解释执行的 CPU 成本不是瓶颈;报告端点每次计算 20000 个 loan,JIT 才是关键。基线必须选能代表真实成本的端点,否则门禁形同虚设。
五、容量规划:用 p99 曲线,不用单点吞吐
10.2 的并发曲线(报告端点,两轮中位数):
| 并发 | 吞吐 req/s | p50 ms | p95 ms | p99 ms | L = λW |
|---|---|---|---|---|---|
| 10 | 3964.7 | 2.0 | 6.5 | 9.5 | 10.0 |
| 20 | 3994.4 | 3.5 | 12.5 | 21.0 | 20.1 |
| 50 | 4146.3 | 8.5 | 30.0 | 57.0 | 50.1 |
| 100 | 3447.1 | 24.0 | 62.5 | 87.5 | 100.0 |
如果 SLA 是 p99 ≤ 50 ms,c=50 的 57 ms 已经越线,c=20 的 21 ms 有充足余量。容量不能取「最大吞吐 4146」,而要取「满足 p99 的最大吞吐」。本机选 c=20、3994 req/s 作为单实例工作点,再留 30% 余量:
单实例规划容量 = 3994 × 0.7 ≈ 2800 req/s
按 2800 req/s/实例、N+1 副本:
| 目标 QPS | 需要实例 | N+1 后 | 预计 p99 |
|---|---|---|---|
| 1000 | 1 | 2 | ≤ 21 ms |
| 3000 | 2 | 3 | ≤ 21 ms |
| 5000 | 2 | 3 | ≤ 21 ms |
| 10000 | 4 | 5 | ≤ 21 ms |
N+1 的含义是「任意一个实例挂掉,剩余实例仍能承载目标流量」。5000 QPS 需要 2 个实例承载(5600),再加 1 个冗余,共 3 个。10000 QPS 需要 4 个(11200),加 1 个共 5 个。
这个模型的边界很清楚:
- 它假设流量均匀分到每个实例。实际负载均衡、长连接、会话粘性都会造成倾斜。
- 它假设每个实例的数据库连接数、连接池大小、下游配额都能线性扩展。数据库通常是先到瓶颈的一层。
- 它假设 p99 曲线在副本之间可加。共享数据库、缓存、消息队列会让多个实例互相影响。
- 它来自 localhost 的最小服务,没有 TLS、鉴权、序列化框架和网络延迟。
所以 2800 req/s/实例是「本机实验的上限参考」,不是生产承诺。生产容量要在预发环境用真实依赖重跑同一套基线,并按数据库连接数和下游配额取最小值。
在 libraryd 里落地
CI 里的回归门禁固定成三步:
- 在专用 runner 上启动
libraryd,用scripts/jvm/baseline.py采集当前分支的基线。 - 与主分支基线对比,阈值:吞吐不低于 90%,p99 不超过
max(130%, +5ms),最大 GC 停顿不超过上一版 +5 ms,启动不超过 120%。 - 任一项 FAIL 就阻断合并,并附上两次 JSON 和 GC 日志。
python3 scripts/jvm/baseline.py \
--jar libraryd-app/target/libraryd-app.jar \
--path "/api/reports/summary?n=20000" \
--out baseline-pr.json \
--compare baseline-main.json
CI runner 必须是固定机型、固定 JDK、固定容器镜像;共享 runner 的邻居噪声会让 p99 门禁频繁误报。如果只能用共享 runner,就把 p99 阈值放宽到 max(150%, +10ms),并把正式基线放在专用机器上跑。
容量表和扩容触发条件写进运维手册:
| 指标 | 扩容触发 | 缩容触发 |
|---|---|---|
| p99 | > 40 ms 持续 5 分钟 | < 20 ms 持续 30 分钟 |
| 单实例在途请求 | > 16 | < 8 |
| CPU | > 70% 持续 10 分钟 | < 40% 持续 30 分钟 |
| GC 最大停顿 | > 50 ms | — |
| 数据库连接池使用率 | > 80% | < 40% |
单实例在途请求上限设为 16,比压测拐点 20 再留一点余量;到 16 就开始扩容,而不是等到 p99 越线。GC 最大停顿的 50 ms 阈值在本机实验里从未触发(最大 2.73 ms),保留它是为了覆盖生产的大堆和 Full GC 场景。
常见错误与边界
错误一:用均值做容量。 均值 5 ms 可能对应 p99 57 ms;按均值算出来的并发会把 p99 推得更高。容量按 p99 目标和对应的并发曲线取,不按平均延迟。
错误二:只跑一次基线。 本机同一配置两次 p99 是 5 ms 和 4 ms,启动是 120.1 ms 和 108.0 ms。单次数字会被噪声主导;至少 3 轮压测、取中位数,并保存原始值。
错误三:阈值没有绝对下限。 p99 从 5 ms 抖到 7 ms 就会被 130% 阈值判 FAIL,这是量化噪声,不是回归。低毫秒区间要加 +5ms 之类的绝对下限。
错误四:基线端点选错。 -Xint 在借阅端点上仍然 10273 req/s,门禁 PASS;在报告端点上只有 69.5 req/s,门禁 FAIL。基线要选能暴露真实 CPU/分配成本的端点。
错误五:从单机线性外推集群。 单机 2800 req/s × 5 台不等于 14000 req/s。数据库连接、锁、缓存、负载均衡都会让曲线变平,甚至下降。
边界一:CI 共享 runner 不适合正式基线。 邻居噪声、CPU 配额、频率波动会让 p99 不可复现。正式基线在固定机器上跑,CI 只跑冒烟或放宽阈值的门禁。
边界二:基线有保质期。 换 JDK、换 GC、换机器、换数据库版本、换数据规模,都要重建基线。拿半年前的 JSON 对比今天的服务,得到的 FAIL 可能只是环境变了。
小结
- 基线至少包含启动、吞吐、p50/p95/p99、GC 次数与最大停顿、堆占用;按端点分开存。
scripts/jvm/baseline.py用Popen测启动、ab -k跑 3 轮、jcmd GC.heap_info读堆、GC 日志数停顿。- 相同配置两次基线可以 PASS;p99 阈值要加绝对下限,否则 5 ms 到 7 ms 的噪声会误报。
-Xint在报告端点上把吞吐从 6190.6 打到 69.5 req/s、p99 从 22 ms 打到 1950 ms,门禁正确 FAIL;同一模式在借阅端点上却 PASS,说明端点选择决定门禁有效性。- 容量按 p99 曲线取,本机 c=20 是 3994 req/s、p99 21 ms;留 30% 余量后按 2800 req/s/实例规划,5000 QPS 用 3 个实例(含 N+1)。
- 单机数字不能线性外推到集群;数据库连接、负载均衡和共享依赖通常先成为瓶颈。
到这里,第四卷的观测、启动、压测、调优和容量闭环就走完了。回到目录,可以按「启动慢」「p99 高」「内存涨」「线程卡」四条症状重新选择入口。
阅读导航:上一节:10.2 一次完整的调优:从现象到参数 · 下一节:《Java 虚拟机原理与性能调优》目录与学习路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。