图 1:本文封面,曲线的拐点比峰值值钱。
先说明属性:这是一篇历史题目复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面:有一个 RPC 服务,对外提供一个查询接口,给定请求返回匹配结果——怎么测试这个接口的性能?这是一道开放设计题,新手容易答成「开很多线程打过去看 QPS」,但那只是动作不是方案。完整的回答分两半:压测怎么设计,以及数字怎么读。
设计四要素:梯度、预热、时长、数据。
并发要阶梯爬升,不是一把拉满。从 1 个并发起步,翻倍递增(1、2、4、8……),每档跑稳再升档——只有梯度才能让你看到曲线在哪里拐弯,一把拉满只能看到一个烂数字。每档先做预热:JVM 的 JIT 编译、连接池建连、缓存填充都需要热身,前几分钟的数据直接丢弃,否则你测到的是冷启动而不是稳态。每档要跑够时间,短测试遇不上 Full GC 与慢调用,几十秒的成绩单没有参考价值。请求数据要贴近真实分布:参数集合太小会全部命中缓存,报文大小失真会掩盖序列化开销,用生产流量的采样重放最靠谱。工具层面,JMeter 官方文档明确要求负载测试用 CLI 非 GUI 模式运行,GUI 只用来调脚本——这类细节答出来就是加分项。
指标怎么读:平台、长尾与拐点。
跑完梯度得到三张曲线。QPS 曲线先随并发线性上涨,然后进入平台期——再加压吞吐不涨,多出来的请求全在排队,平台的高度就是单机吞吐上限。延迟看分布不看平均:平均值会被海量快请求拉得很好看,P99 才是尾部用户的真实体验;平均值与 P99 的裂口拉大,说明长尾在恶化。最重要的是错误率拐点:超时与拒绝开始成片出现的位置,才是真正的容量天花板,它通常比 QPS 平台更早到来——吞吐还没涨满,服务已经开始失败了。
用模型把三条曲线跑出来。
下面这份闭环压测模型模拟「服务端最多 8 个请求并行处理、客户端有超时预算」的场景,按并发梯度爬升,用 Node.js 22.15.0 直接运行(node loadtest.js):
'use strict';
// 闭环压测模型:N 个并发槽位各自「发请求->等响应->再发」;
// 服务端最多 CAPACITY 个请求并行处理,多出来的排队,排队+处理超过超时预算算失败。
const SERVICE_MS = 10; // 服务端单次处理耗时
const CAPACITY = 8; // 服务端最大并行处理数(如线程池大小)
const TIMEOUT_MS = 120; // 客户端超时预算
const WARMUP_REQS = 400; // 每档预热请求数(采样前丢弃)
const SAMPLE_REQS = 1200; // 每档采样请求数
function runLevel(concurrency, totalReqs) {
const completions = []; // 服务端正在处理请求的完成时刻(最多 CAPACITY 个)
const nextArrival = new Array(concurrency).fill(0); // 每个槽位下一次发起请求的时刻
const results = [];
for (let i = 0; i < totalReqs; i++) {
let s = 0; // 取最早空闲的槽位(闭环:响应到了才发下一个)
for (let j = 1; j < concurrency; j++) if (nextArrival[j] < nextArrival[s]) s = j;
const t = nextArrival[s];
completions.sort((a, b) => a - b);
let startService;
if (completions.length < CAPACITY) {
startService = t; // 有空位,立即处理
} else {
startService = Math.max(t, completions.shift()); // 排队等最早空出的位置
}
const end = startService + SERVICE_MS;
completions.push(end);
nextArrival[s] = end;
results.push({ start: t, end, ok: end - t <= TIMEOUT_MS });
}
return results;
}
const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
console.log('并发\tQPS\t平均ms\tP99ms\t错误率');
for (const c of [1, 2, 4, 8, 16, 32, 64, 128]) {
runLevel(c, WARMUP_REQS); // 预热:JIT、连接池热身,预热数据直接丢弃
const rs = runLevel(c, SAMPLE_REQS);
const lats = rs.map((r) => r.end - r.start).sort((a, b) => a - b);
const span = rs[rs.length - 1].end - rs[0].start;
const qps = (rs.length / span) * 1000;
const err = rs.filter((r) => !r.ok).length / rs.length;
const avg = lats.reduce((a, b) => a + b, 0) / lats.length;
console.log(`${c}\t${qps.toFixed(0)}\t${avg.toFixed(1)}\t${percentile(lats, 0.99)}\t${(err * 100).toFixed(1)}%`);
}
实测输出:
并发 QPS 平均ms P99ms 错误率
1 100 10.0 10 0.0%
2 200 10.0 10 0.0%
4 400 10.0 10 0.0%
8 800 10.0 10 0.0%
16 800 19.9 20 0.0%
32 800 39.6 40 0.0%
64 800 78.1 80 0.0%
128 800 152.0 160 92.0%
这张表把三个读法全演示了一遍:并发从 1 涨到 8,QPS 同步涨到 800,之后进入平台期——16、32、64 并发下 QPS 纹丝不动,涨的全是延迟(平均 19.9、39.6、78.1 毫秒,多出来的都在排队)。到 128 并发,排队把延迟顶过 120 毫秒的超时预算,错误率跳到 92%——错误率拐点出现在这里,比「QPS 不涨」晚了好几档,但也意味着这个水位下服务名义上还在吐吞吐、实际上九成请求已失败。安全容量不能取拐点,要取拐点之前再打折:比如把并发 32 以内、错误率与 P99 都可接受的水位定为单机红线,再按机器数换算集群容量,并为流量尖峰留出余量。
图 2:压测找的不是最大数字,是「哪里开始变坏」。
可能的追问。
「QPS 上不去先查什么?」先确认瓶颈在哪一侧——压测机自身的 CPU、端口与连接数常常是隐形天花板,JMeter 官方文档也强调注入端要做机器 sizing 与 OS 调优。「为什么强调闭环?」真实客户端收到响应才发下一个请求,闭环模型才模拟得出「服务变慢->客户端堆积」的反馈;开环固定速率猛灌适合探极限,两者回答的问题不同。「P99 达标就够吗?」看你的 SLA 承诺到哪一位,广告、推荐这类重体验的接口常常要盯到 P999。
模型推理接口上线前的容量评估就是同一道题:并发梯度、预热、P99 与错误率拐点,每一步都对应真实的上线决策。你做压测时有没有遇到过「QPS 很漂亮、上线就翻车」的情况?回头看是预热没做、数据太假,还是拐点读错了?欢迎聊聊。