《Java 并发编程·性能诊断与事故》15.3 libraryd 并发验收与压测报告

以 libraryd 借阅链路为被测系统,给出 64/256/1024 三档并发加单热点场景的完整验收报告:先写死 p99、错误率、吞吐、CPU 与 GC 判据,再用 111 行探针在 10 核 M1 Pro、负载 63 的本机实测,附原始输出、判定代码、遗留风险和可直接套用的模板。

承接的问题

15.1 和 15.2 管的是合入前:规范能不能过扫描,评审能不能放行。但代码合了、镜像构建了,还要回答一个更硬的问题——这套并发配置在目标并发下到底能不能上线。答案不能是「压了一下,表现良好」,而要是一份别人拿着能复现、能判定的报告。

验收报告最常见的四种废法:没有阈值,通篇「延迟较低」;只看平均延迟,p50 好看但 5% 的请求卡了一秒;只统计成功请求,73% 被拒绝也能得出「p99 仅 46ms」;不写机器、负载和提交方式,换台机器数字就对不上。本节按「判据 → 场景 → 实测 → 判定 → 风险」的顺序给一份完整报告,数据全部来自本机这一次真实运行。

判据必须在跑之前写死

先定阈值再看数,否则人会不自觉地把阈值调到数据刚好能过。下表是本次验收的判据,任何一条不满足即该档不通过,没有「综合考量」。

指标64 并发256 并发1024 并发依据
p99 延迟≤ 50ms≤ 100ms≤ 200ms产品 SLO 200ms;低档位收紧以提前发现退化
错误率≤ 0.1%≤ 1%≤ 5%低档位应接近零;1024 允许计划内削峰
吞吐≥ 4000/s≥ 4000/s≥ 4000/s实测容量约 5900/s 的三分之二,防严重回退
CPU≤ 85%≤ 85%≤ 85%给 GC 和系统线程留余量
GC 暂停≤ 200ms/10s≤ 200ms/10s≤ 200ms/10s超过则尾延迟不可控
堆峰值≤ 512MB≤ 512MB≤ 512MB与 -Xmx512m 一致

三条口径必须一起写进报告。第一,p99 只统计被接受的请求,被拒绝的请求没有延迟样本,必须和错误率同看。第二,低档位阈值更严是团队策略,不是从公式推出来的;它的作用是容量还有富余时就能发现回归。第三,吞吐下限是防回退线,不是目标值,不能拿它反推容量。

平均值不进入判据。一个 p50 为 7ms、p99 为 15ms 的档位,和 p50 为 7ms、p99 为 800ms 的档位,平均值可能只差几毫秒,用户体验却是两类产品。

被测系统与四档场景

被测对象是 libraryd 的借阅链路:按 ISBN 哈希到 64 个分片,每个分片一把 ReentrantLock,锁内扣减库存并模拟 2ms 服务(0.5ms 自旋加 1.5ms park)。服务端固定 16 个 worker、256 个队列位,与 12.1 的池配置一致。

项值
机器Apple M1 Pro,10 核,32GB,macOS 26.3
JDKTemurin 25.0.5+8,-Xms256m -Xmx512m
服务端16 worker,ArrayBlockingQueue(256),AbortPolicy 自行计数
分片64 个 ISBN 分片,每片一把锁
单请求2.0ms(0.5ms 自旋 + 1.5ms park)
预热/测量2s / 10s
背景负载load average 63.49(同机还有其他任务)

本轮采集直接在宿主 macOS 上运行 JVM,未加容器 CPU/内存配额。本机虽可通过 colima 使用 Docker(4 vCPU / 7.7GiB),但 --cpus=2 --memory=1g 的受控配额对照未实测,因此这些数字不能直接外推到 CFS 配额受限的 Pod。

四档场景:64、256、1024 三档按 i % 64 轮转 ISBN,另加一档 64 并发全部打同一个热点 ISBN。1024 档不是开 1024 个平台线程,而是单个提交者按波次提交 1024 个任务、持有 1024 个 future。这样测的是服务端在突发下的接收能力,不是客户端每连接一线程的行为;虚拟线程客户端变体未实测,两者不能互相替代。

探针:一个文件跑完四档

探针只依赖 JDK 自带的管理 Bean,不引入任何压测框架。核心是 wave():提交一整波、等全部 future 完成,再发下一波;拒绝在提交处计数,超时和异常在 get() 处计数。

import com.sun.management.OperatingSystemMXBean;
import java.lang.management.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;

public final class AcceptanceProbe {
    static final int WORKERS=16, QUEUE=256, SHARDS=64;
    static final long SERVICE=2_000_000L, SPIN=500_000L, WARM=2_000_000_000L, MEASURE=10_000_000_000L;
    static final ReentrantLock[] LOCKS=new ReentrantLock[SHARDS];
    static final int[] STOCK=new int[SHARDS];
    static volatile long SINK=1;
    static final MemoryMXBean MEM=ManagementFactory.getMemoryMXBean();
    static final ThreadMXBean TH=ManagementFactory.getThreadMXBean();
    static final OperatingSystemMXBean OS=(OperatingSystemMXBean)ManagementFactory.getOperatingSystemMXBean();
    static { for(int i=0;i<SHARDS;i++){LOCKS[i]=new ReentrantLock();STOCK[i]=1_000_000;} }

    public static void main(String[] a) throws Exception {
        System.out.printf("cores=%d workers=%d queue=%d shards=%d service=2.0ms%n",
            Runtime.getRuntime().availableProcessors(),WORKERS,QUEUE,SHARDS);
        run("sharded",64,false); run("sharded",256,false);
        run("sharded",1024,false); run("hot-isbn",64,true);
    }

    static void run(String name,int conc,boolean hot) throws Exception {
        AtomicInteger seq=new AtomicInteger();
        ThreadPoolExecutor pool=new ThreadPoolExecutor(WORKERS,WORKERS,0,TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(QUEUE),
            r->Thread.ofPlatform().name("loan-worker-"+seq.incrementAndGet()).unstarted(r),
            (r,e)->{throw new RejectedExecutionException("queue full");});
        LongAdder ok=new LongAdder(), rej=new LongAdder();
        LongAdder tout=new LongAdder(), err=new LongAdder();
        ConcurrentLinkedQueue<Long> lat=new ConcurrentLinkedQueue<>();
        long[] peak=new long[3];

        long warmEnd=System.nanoTime()+WARM;
        while(System.nanoTime()<warmEnd) wave(pool,conc,hot,false,ok,rej,tout,err,lat,peak);
        ok.reset(); rej.reset(); tout.reset(); err.reset(); lat.clear();

        long cpu0=OS.getProcessCpuTime(); long[] gc0=gc();
        long t0=System.nanoTime(), end=t0+MEASURE;
        while(System.nanoTime()<end) wave(pool,conc,hot,true,ok,rej,tout,err,lat,peak);
        long t1=System.nanoTime(), cpu1=OS.getProcessCpuTime();
        pool.shutdown(); pool.awaitTermination(5,TimeUnit.SECONDS);

        long[] v=lat.stream().mapToLong(Long::longValue).sorted().toArray();
        long offered=ok.sum()+rej.sum()+tout.sum()+err.sum();
        double sec=(t1-t0)/1e9, thr=ok.sum()/sec;
        double er=offered==0?0:(rej.sum()+tout.sum()+err.sum())/(double)offered;
        double cores=(cpu1-cpu0)/(double)(t1-t0);
        double cpuPct=100*cores/Runtime.getRuntime().availableProcessors();
        long[] gc1=gc();

        System.out.printf("%s/%d offered=%d ok=%d reject=%d timeout=%d err=%d%n",
            name,conc,offered,ok.sum(),rej.sum(),tout.sum(),err.sum());
        System.out.printf("  thr=%.0f/s p50=%d p95=%d p99=%d p999=%d max=%dms%n",
            thr,pct(v,.5),pct(v,.95),pct(v,.99),pct(v,.999),v.length==0?-1:v[v.length-1]);
        System.out.printf("  cpu=%.2f cores (%.0f%%) heap=%dMB gc=%d/%dms pool=%d queue=%d threads=%d%n",
            cores,cpuPct,peak[1],gc1[0]-gc0[0],gc1[1]-gc0[1],WORKERS,peak[0],peak[2]);
        System.out.printf("  errorRate=%.2f%%%n",er*100);
    }

    static void wave(ThreadPoolExecutor pool,int conc,boolean hot,boolean record,
                     LongAdder ok,LongAdder rej,LongAdder tout,LongAdder err,
                     ConcurrentLinkedQueue<Long> lat,long[] peak) throws InterruptedException {
        List<Future<?>> fs=new ArrayList<>(conc);
        for(int i=0;i<conc;i++){
            String isbn=hot?"978-hot":"978-"+(i%SHARDS);
            long t=System.nanoTime();
            try{
                fs.add(pool.submit(()->{reserve(isbn); if(record) lat.add((System.nanoTime()-t)/1_000_000L);}));
            }catch(RejectedExecutionException e){ if(record) rej.increment(); }
            peak[0]=Math.max(peak[0],pool.getQueue().size());
        }
        for(Future<?> f:fs){
            try{ f.get(2,TimeUnit.SECONDS); if(record) ok.increment(); }
            catch(TimeoutException e){ if(record) tout.increment(); f.cancel(true); }
            catch(Exception e){ if(record) err.increment(); }
        }
        peak[1]=Math.max(peak[1],MEM.getHeapMemoryUsage().getUsed()>>20);
        peak[2]=Math.max(peak[2],TH.getThreadCount());
    }

    static void reserve(String isbn){
        int idx=Math.floorMod(isbn.hashCode(),SHARDS);
        ReentrantLock lock=LOCKS[idx];
        lock.lock();
        try{
            if(STOCK[idx]<=0) return;
            STOCK[idx]--;
            long spinEnd=System.nanoTime()+SPIN;
            while(System.nanoTime()<spinEnd) SINK=SINK*6364136223846793005L+1;
            LockSupport.parkNanos(SERVICE-SPIN);
        }finally{ lock.unlock(); }
    }

    static long pct(long[] v,double p){
        if(v.length==0) return -1;
        int i=(int)Math.ceil(p*v.length)-1;
        return v[Math.max(0,Math.min(i,v.length-1))];
    }

    static long[] gc(){
        long n=0,t=0;
        for(GarbageCollectorMXBean g:ManagementFactory.getGarbageCollectorMXBeans()){
            n+=Math.max(0,g.getCollectionCount()); t+=Math.max(0,g.getCollectionTime());
        }
        return new long[]{n,t};
    }
}

编译与运行:

javac --release 25 -Xlint:all AcceptanceProbe.java
java -Xms256m -Xmx512m -cp . AcceptanceProbe | tee report.txt

实测结果

这是本机单次运行,load average 63.49,同机还有别的任务;数字会随调度变化,只用于本次判定,不作为性能基线。

场景吞吐p50p95p99p999错误率CPU堆峰值GC队列峰值
644844/s7ms13ms15ms19ms0%24%20MB0 次64
2565941/s22ms40ms42ms46ms0%30%39MB0 次256
10245861/s23ms42ms46ms56ms73.47%29%116MB1 次/2ms256
热点 64343/s92ms177ms195ms212ms0%2%75MB0 次64

原始输出(四档合计约 50 秒):

cores=10 workers=16 queue=256 shards=64 service=2.0ms
sharded/64 offered=48448 ok=48448 reject=0 timeout=0 err=0
  thr=4844/s p50=7 p95=13 p99=15 p999=19 max=23ms
  cpu=2.40 cores (24%) heap=20MB gc=0/0ms pool=16 queue=64 threads=22
  errorRate=0.00%
sharded/256 offered=59648 ok=59648 reject=0 timeout=0 err=0
  thr=5941/s p50=22 p95=40 p99=42 p999=46 max=52ms
  cpu=3.00 cores (30%) heap=39MB gc=0/0ms pool=16 queue=256 threads=22
  errorRate=0.00%
sharded/1024 offered=221184 ok=58691 reject=162493 timeout=0 err=0
  thr=5861/s p50=23 p95=42 p99=46 p999=56 max=65ms
  cpu=2.94 cores (29%) heap=116MB gc=1/2ms pool=16 queue=256 threads=22
  errorRate=73.47%
hot-isbn/64 offered=3456 ok=3456 reject=0 timeout=0 err=0
  thr=343/s p50=92 p95=177 p99=195 p999=212 max=221ms
  cpu=0.17 cores (2%) heap=75MB gc=0/0ms pool=16 queue=64 threads=22
  errorRate=0.00%

六个读数值得逐条解释。

第一,16 个 worker、单请求 2ms,理论上限是 8000/s;实测 5941/s,约为上限的 74%,差额来自波次提交与汇总开销。64 档只有 4844/s,比 256 档低,是因为每波任务少、提交和 get() 的固定开销摊得更薄,不是容量差异,不能把三档吞吐连成扩容曲线(该用法未采信)。

第二,1024 档每波 1024 个任务,服务端能接住的是 16 个运行中加 256 个排队,共 272 个;其余 752 个当场被拒。58691 / 216 波 ≈ 272,与配置精确对上。73.47% 不是抖动,是队列容量的算术结果。

第三,1024 档的 p99 46ms 只覆盖被接受的 26.53% 请求,被拒的 162493 个没有延迟样本。这个分位未采信为健康证据;把它单独贴出来,就会得到「高并发下延迟反而更好」的荒谬结论。

第四,热点档吞吐 343/s,约为单锁理论上限 500/s 的 69%,p99 195ms 已超过 50ms 判据。64 个分片对同一个 ISBN 没有任何帮助,热点只能靠请求合并、单 key 排队或业务限流解决。

第五,CPU 只有 24%–30%,GC 在 10 秒内 0–1 次、最长 2ms。瓶颈既不是 CPU 也不是 GC,而是 worker 数与队列容量;按 CPU 使用率扩容会得出完全错误的结论。GC 样本太少,未采信为 GC 影响的证据。

第六,1024 档 116MB 堆峰值与 16 万个被拒 future、排队对象同时出现,不能代表稳态内存;报告里必须标注,否则会被误读成「高并发只要 116MB」。

判定:把判据写成代码

判据不能只写在文档里,否则「谁来判」又变成主观题。把阈值和比较逻辑固化成 43 行代码,任何人拿同一行数据跑,结论必须一致。

import java.util.List;

/** 把验收判据写成代码:同一行数据,谁来跑都得到同一个结论。 */
public final class Criteria {

    record Row(String scenario, int conc, long p99, double errorRate,
               double throughput, double cpuPct, long gcPauseMs) {}

    static String check(Row r) {
        long p99Limit = r.conc() <= 64 ? 50 : r.conc() <= 256 ? 100 : 200;
        double errLimit = r.conc() <= 64 ? 0.001 : r.conc() <= 256 ? 0.01 : 0.05;
        StringBuilder fail = new StringBuilder();
        if (r.p99() > p99Limit) {
            fail.append("p99 ").append(r.p99()).append("ms>").append(p99Limit).append("ms; ");
        }
        if (r.errorRate() > errLimit) {
            fail.append(String.format("error %.2f%%>%.2f%%; ", r.errorRate() * 100, errLimit * 100));
        }
        if (r.throughput() < 4_000) {
            fail.append(String.format("thr %.0f/s<4000/s; ", r.throughput()));
        }
        if (r.cpuPct() > 85) {
            fail.append(String.format("cpu %.0f%%>85%%; ", r.cpuPct()));
        }
        if (r.gcPauseMs() > 200) {
            fail.append("gc ").append(r.gcPauseMs()).append("ms>200ms; ");
        }
        return fail.isEmpty() ? "PASS" : "FAIL(" + fail.toString().replaceAll("; $", "") + ")";
    }

    public static void main(String[] args) {
        List<Row> rows = List.of(
                new Row("sharded", 64, 15, 0.0000, 4844, 24, 0),
                new Row("sharded", 256, 42, 0.0000, 5941, 30, 0),
                new Row("sharded", 1024, 46, 0.7347, 5861, 29, 2),
                new Row("hot-isbn", 64, 195, 0.0000, 343, 2, 0));
        for (Row r : rows) {
            System.out.printf("%s/%d p99=%dms err=%.2f%% thr=%.0f/s cpu=%.0f%% => %s%n",
                    r.scenario(), r.conc(), r.p99(), r.errorRate() * 100,
                    r.throughput(), r.cpuPct(), check(r));
        }
    }
}

输出与报告结论逐行一致:

sharded/64 p99=15ms err=0.00% thr=4844/s cpu=24% => PASS
sharded/256 p99=42ms err=0.00% thr=5941/s cpu=30% => PASS
sharded/1024 p99=46ms err=73.47% thr=5861/s cpu=29% => FAIL(error 73.47%>5.00%)
hot-isbn/64 p99=195ms err=0.00% thr=343/s cpu=2% => FAIL(p99 195ms>50ms; thr 343/s<4000/s)

注意 1024 档:如果判据只写 p99,这一档会被判 PASS;是错误率把它拦下来的。这就是「延迟与错误率必须同看」在代码里的体现,而不是一句提醒。

结论、遗留风险与后续动作

本次验收结论:64 并发通过;256 并发通过,但队列峰值正好 256、零余量;1024 并发不通过;热点 64 不通过。可放行的是前两档,且 256 档必须把队列深度和拒绝率接入告警。

风险证据影响后续动作期限
1024 档队列不足拒绝 162493,73.47%过载时四分之三请求失败准入控制或队列调到 1024 后重测,不能只加 worker下次发布前
热点 ISBN343/s,p99 195ms爆款锁竞争拖垮单 key请求合并/单 key 排队/业务限流,改后重测下个迭代
延迟幸存者偏差p99 仅覆盖 26.53%监控误判为健康报表同时展示接受率与拒绝率立即
256 档零余量队列峰值 256,拒绝 0任何突发都直接拒绝留 20% 余量或扩到 320 并重测下次发布前
单次运行、背景负载 63无重复、无置信区间数字不可当基线空载重复 3 次取中位数容量签字前
虚拟线程客户端未实测采用波次异步提交客户端模型不同,结论不可外推如关心每连接行为,补虚拟线程变体按需
未在容器配额下复测宿主直跑,无 --cpus/--memory 限制受限配额下可能 CFS 节流补 --cpus=2 --memory=1g 对照容量签字前

还有三条明确标注:1024 档 p99/p999 未采信为健康证据(幸存者偏差);GC 指标未采信(10 秒内样本不足);跨档吞吐未采信为扩容曲线(波次开销不同)。虚拟线程客户端变体未实测,本报告不外推。

报告模板

把上面的结构压成一页,换掉系统名和数据即可复用。关键是 criteria 与 result 分离:判据先写,结果后填,签字人先看判据再看数。

report: libraryd-concurrency-acceptance
commit: "<git sha>"
env: {cpu: "Apple M1 Pro 10C", mem: 32GB, os: "macOS 26.3", jdk: "Temurin 25.0.5+8"}
config: {workers: 16, queue: 256, shards: 64, service_ms: 2.0, warmup_s: 2, measure_s: 10}
load: {background_loadavg: 63.49, runs: 1, note: "single run, not a baseline"}
scenarios: [64, 256, 1024, hot-64]
criteria:
  p99_ms: {64: 50, 256: 100, 1024: 200, hot-64: 50}
  error_pct: {64: 0.1, 256: 1.0, 1024: 5.0, hot-64: 0.1}
  min_throughput_per_s: 4000
  max_cpu_pct: 85
  max_gc_pause_ms: 200
result:
  - {level: 64, thr: 4844, p99: 15, error_pct: 0.00, verdict: PASS}
  - {level: 256, thr: 5941, p99: 42, error_pct: 0.00, verdict: PASS}
  - {level: 1024, thr: 5861, p99: 46, error_pct: 73.47, verdict: FAIL}
  - {level: hot-64, thr: 343, p99: 195, error_pct: 0.00, verdict: FAIL}
residual_risks: [queue-capacity-1024, hot-key, survivor-bias, zero-headroom-256]
decision: "64/256 conditional go; 1024 and hot-key no-go"
signoff: {owner: "<name>", reviewer: "<name>", date: "2026-10-28"}

常见错误

  • 用平均延迟下结论。平均值会把 5% 的长尾摊平,验收只看 p99/p999。
  • 只统计成功请求。拒绝和超时必须进分母,否则过载档的延迟会「变好」。
  • 压测客户端与被测服务同机。本报告就是同机,所以必须写明背景负载,且不能把绝对值当基线。
  • 把不同档位的吞吐当扩容曲线。波次提交下,档位间的差额很大一部分是测量开销。
  • 判据事后调整。阈值一旦为某次数据改动,这份报告就失去了验收资格。

小结

一份能用的验收报告,核心不是压出多漂亮的数字,而是三件事:判据在跑之前写死,指标口径写清楚(尤其是延迟只算被接受请求),结论敢写不通过。本节 64/256 通过、1024 与热点不通过,都是配置和算术的必然结果,不是机器状态不好。把判据固化成代码、把风险写成带期限的动作,这份报告才能被复核、被追责、被下一次验收复用。

阅读导航:上一节 15.2 Code Review 清单 | 下一节 目录与学习路径

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收