《Java 虚拟机原理与性能调优》10.3 建立回归基线与容量规划

把启动、吞吐、p50/p95/p99、GC 次数与停顿、堆占用写成一份基线 JSON,用 scripts/jvm/baseline.py 采集并对比。本节真跑两次基线得到 PASS,再用 -Xint 制造回归得到 FAIL,并用 Little 定律和 p99 曲线给出 libraryd 的容量表与扩容触发条件。

本节目标:把一次性的调优结论变成可重复的门禁:基线 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」。这个结论如果要长期有效,需要两件事:

  1. 每次改代码、升级 JDK、改参数后,能快速判断有没有回退。
  2. 流量增长时,能算出要加几个副本,而不是拍脑袋。

手工跑一次 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/sp50 msp95 msp99 msL = λW
103964.72.06.59.510.0
203994.43.512.521.020.1
504146.38.530.057.050.1
1003447.124.062.587.5100.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
100012≤ 21 ms
300023≤ 21 ms
500023≤ 21 ms
1000045≤ 21 ms

N+1 的含义是「任意一个实例挂掉,剩余实例仍能承载目标流量」。5000 QPS 需要 2 个实例承载(5600),再加 1 个冗余,共 3 个。10000 QPS 需要 4 个(11200),加 1 个共 5 个。

这个模型的边界很清楚:

  • 它假设流量均匀分到每个实例。实际负载均衡、长连接、会话粘性都会造成倾斜。
  • 它假设每个实例的数据库连接数、连接池大小、下游配额都能线性扩展。数据库通常是先到瓶颈的一层。
  • 它假设 p99 曲线在副本之间可加。共享数据库、缓存、消息队列会让多个实例互相影响。
  • 它来自 localhost 的最小服务,没有 TLS、鉴权、序列化框架和网络延迟。

所以 2800 req/s/实例是「本机实验的上限参考」,不是生产承诺。生产容量要在预发环境用真实依赖重跑同一套基线,并按数据库连接数和下游配额取最小值。

在 libraryd 里落地

CI 里的回归门禁固定成三步:

  1. 在专用 runner 上启动 libraryd,用 scripts/jvm/baseline.py 采集当前分支的基线。
  2. 与主分支基线对比,阈值:吞吐不低于 90%,p99 不超过 max(130%, +5ms),最大 GC 停顿不超过上一版 +5 ms,启动不超过 120%。
  3. 任一项 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 虚拟机原理与性能调优》目录与学习路径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发