承接的问题
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 |
| JDK | Temurin 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,同机还有别的任务;数字会随调度变化,只用于本次判定,不作为性能基线。
| 场景 | 吞吐 | p50 | p95 | p99 | p999 | 错误率 | CPU | 堆峰值 | GC | 队列峰值 |
|---|---|---|---|---|---|---|---|---|---|---|
| 64 | 4844/s | 7ms | 13ms | 15ms | 19ms | 0% | 24% | 20MB | 0 次 | 64 |
| 256 | 5941/s | 22ms | 40ms | 42ms | 46ms | 0% | 30% | 39MB | 0 次 | 256 |
| 1024 | 5861/s | 23ms | 42ms | 46ms | 56ms | 73.47% | 29% | 116MB | 1 次/2ms | 256 |
| 热点 64 | 343/s | 92ms | 177ms | 195ms | 212ms | 0% | 2% | 75MB | 0 次 | 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 | 下次发布前 |
| 热点 ISBN | 343/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 清单 | 下一节 目录与学习路径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。