深蓝黑背景封面:左侧大标题「压测一个 RPC 接口」,副标题「并发梯度、预热、P99 与容量水位」;右侧原创示意图——一张压测曲线图:横轴并发数阶梯爬升,青色 QPS 线先随并发线性上涨后进入平台期,紫色延迟线平稳一段后在拐点陡然抬头,拐点处竖起一面水位旗标着「容量拐点」;左上标注「面试经典 · 系统设计」,右上标注「Day 09 · 11-06」 图 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 都可接受的水位定为单机红线,再按机器数换算集群容量,并为流量尖峰留出余量。

压测方法论图:上方是压测设计四要素——并发梯度(阶梯爬升而非一把拉满,才能找到拐点)、预热(JIT 与连接池热身后再采样,前几分钟数据丢弃)、持续时间(每档并发跑够时间,覆盖慢调用与 GC 周期)、数据构造(贴近真实分布的参数与报文大小,避免全命中缓存的假数据)。中部三栏讲指标怎么读:QPS 平台期是吞吐上限,再加压不涨吞吐只涨队列;延迟看分布不看平均,P99 与平均值的裂口暴露长尾;错误率拐点(超时、拒绝开始出现的位置)才是真正的容量天花板,通常比 QPS 平台更早到来。下方讲容量结论:取拐点前的安全水位再打折留余量,并按机器数换算集群容量。底部提示:压测的目的不是刷出一个大数字,而是找到「哪里开始变坏」 图 2:压测找的不是最大数字,是「哪里开始变坏」。

可能的追问。

「QPS 上不去先查什么?」先确认瓶颈在哪一侧——压测机自身的 CPU、端口与连接数常常是隐形天花板,JMeter 官方文档也强调注入端要做机器 sizing 与 OS 调优。「为什么强调闭环?」真实客户端收到响应才发下一个请求,闭环模型才模拟得出「服务变慢->客户端堆积」的反馈;开环固定速率猛灌适合探极限,两者回答的问题不同。「P99 达标就够吗?」看你的 SLA 承诺到哪一位,广告、推荐这类重体验的接口常常要盯到 P999。

模型推理接口上线前的容量评估就是同一道题:并发梯度、预热、P99 与错误率拐点,每一步都对应真实的上线决策。你做压测时有没有遇到过「QPS 很漂亮、上线就翻车」的情况?回头看是预热没做、数据太假,还是拐点读错了?欢迎聊聊。