本节目标:让压测结果能回答「系统在哪个流量点开始变坏、坏在哪个资源、延迟数字是否可信」。
工具口径:本机ab可用;wrk与hey未安装,不写成可用;gRPC 压测用自写客户端。
承接的问题
14.1 给了容量算式,但算式里的服务时间、并发上限、队列行为都需要测量。压测的任务不是「打出一个很高的 QPS」,而是校准模型:
- 目标流量下,p99 是否在 deadline 内。
- 第一个瓶颈出现在哪一层,和模型预测是否一致。
- 限流、熔断、队列在压力下是否按设计动作。
- 长时间运行是否出现泄漏或性能衰减。
不同问题需要不同剖面。用恒定负载测泄漏,跑 10 分钟看不出趋势;用尖峰测容量,得到的数字受冷启动和队列影响,不能代表稳态。
四种剖面
| 剖面 | 形状 | 建议时长 | 回答的问题 | 失败信号 |
|---|---|---|---|---|
| 恒定 | QPS 固定 | 10–30 分钟 | 稳态容量、延迟分位、错误率 | p99 持续上升、错误率抬头 |
| 阶梯 | 每 2 分钟 +20% | 20–40 分钟 | 拐点在哪、哪个资源先饱和 | 吞吐不再增长而延迟陡增 |
| 尖峰 | 5 秒内到 3–5 倍 | 5–10 分钟 | 队列、限流、自动扩容、冷启动 | 超时、雪崩、扩容跟不上 |
| 浸泡 | 70% 目标负载 | 2–12 小时 | 内存泄漏、连接泄漏、缓存退化 | RSS 单调上涨、fd 增长 |
恒定负载是基线,其他三种都建立在它之上。没有基线就直接做尖峰,看到的所有异常都无法归因。
把剖面写成数据,压测客户端只负责按时间表发请求,四种形状就能共用一套执行逻辑:
package com.plume.linkd.v3.load;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public final class LoadProfile {
public record Segment(Duration offset, Duration duration, double rps) {
}
public static List<Segment> constant(double rps, Duration total) {
return List.of(new Segment(Duration.ZERO, total, rps));
}
public static List<Segment> step(double startRps, double increment,
Duration stepDuration, int steps) {
List<Segment> segments = new ArrayList<>();
Duration offset = Duration.ZERO;
double rps = startRps;
for (int i = 0; i < steps; i++) {
segments.add(new Segment(offset, stepDuration, rps));
offset = offset.plus(stepDuration);
rps += increment;
}
return List.copyOf(segments);
}
public static List<Segment> spike(double baseRps, double peakRps,
Duration spikeDuration) {
Duration baseWindow = Duration.ofMinutes(5);
return List.of(
new Segment(Duration.ZERO, baseWindow, baseRps),
new Segment(baseWindow, spikeDuration, peakRps),
new Segment(baseWindow.plus(spikeDuration), baseWindow, baseRps));
}
public static List<Segment> soak(double targetRps, Duration total) {
return constant(targetRps, total);
}
public static void main(String[] args) {
for (Segment segment : step(50, 50, Duration.ofMinutes(2), 5)) {
System.out.printf("offset=%s rps=%.0f%n", segment.offset(), segment.rps());
}
}
}
Segment.offset 是相对于压测开始的偏移,不是绝对时间;客户端用 startNanos + offset 算出每个请求的预期开始时间,协调缺失修正和剖面切换就能共用同一套计时逻辑。step 的每一级持续 stepDuration,spike 前后各留 5 分钟基线,便于对比尖峰前后的恢复情况。
阶梯负载的关键是「每级稳定后再升」。每级至少持续 2 分钟,让连接池、JIT、缓存进入稳定状态;升得太快,测到的是上一级的余波。记录每一级的吞吐、p99 和关键资源利用率,拐点通常表现为「QPS 不涨、p99 翻倍」。
尖峰负载要区分「流量尖峰」和「重试尖峰」。前者是用户行为,后者是故障放大。压测时如果客户端带重试,3 倍流量可能变成 9 倍下游压力;要么关掉重试单独测,要么把重试预算写进报告。
浸泡负载最容易伤机器,也最容易被忽略。跑之前确认有监控、有自动停止条件、有磁盘空间;跑之后对比开始与结束的 RSS、线程数、fd 数、连接数。只看 QPS 和延迟,泄漏会被稳定的流量掩盖。
指标口径:先定义,再看数
同一份压测报告,不同口径能得出相反结论。至少要固定下面五项:
| 指标 | 定义 | 常见错误 |
|---|---|---|
| 吞吐 | 完成的请求数 / 测量窗口秒数 | 用发出的请求数,把超时算成完成 |
| 错误率 | 失败请求 / 发出的请求 | 把 429 和 503 混进 5xx,或把客户端取消算失败 |
| p50 | 50% 请求的延迟不超过该值 | 只报平均值,掩盖长尾 |
| p95 / p99 | 95%/99% 请求的延迟不超过该值 | 样本太少,p99 只是某一个请求 |
| 超时率 | 达到 deadline 的请求 / 发出请求 | 与错误率重复计算或漏算 |
延迟统计要区分「成功请求」和「全部请求」。只统计成功请求会美化结果:超时的请求被排除,p99 看起来很好。正确做法是把超时按 deadline 值计入,或者单独列一列「超时率」,并在报告里说明。
错误分类要按 gRPC 状态码,而不是 HTTP 状态码。DEADLINE_EXCEEDED、UNAVAILABLE、RESOURCE_EXHAUSTED、CANCELLED 的处置完全不同:前两个可能重试,第三个要退避,第四个通常不该重试。
样本量决定分位的可信度。100 个请求的 p99 只是最大值;要得到稳定的 p99,至少需要几千个请求。报告里写 p99 时,同时写样本数和测量窗口。
协调缺失:闭环比开环更「好看」
协调缺失(coordinated omission)是压测里最常见的方法论错误。
闭环客户端的工作方式是:发一个请求,等响应,再发下一个。如果服务端在 10:00:00 卡了 5 秒,这 5 秒内客户端一个请求都没发;恢复后它继续发。那 5 秒里「本应发出但没发出」的请求,完全没有进入延迟统计。结果是:服务端明明停摆了 5 秒,报告的 p99 却可能只有 200 ms。
开环客户端按预定时间发请求:第 i 个请求计划在 start + i / rps 发出,不管上一个是否完成。延迟从「计划发出时间」算起,而不是从「实际发出时间」算起。这样卡顿期间的排队会被计入延迟,p99 才会反映真实体验。
修正后的延迟:
latency_i = finish_i - intended_start_i
而不是:
latency_i = finish_i - actual_start_i
两者在系统健康时几乎一样,在系统卡顿时差出几个数量级。压测报告的延迟数字,必须注明用的是哪一种。
ab 是闭环工具:-c 个并发,每个连接发完一个再发下一个。它能测吞吐和基本分位,但无法避免协调缺失,也不支持 gRPC。用 ab 得到的 p99 适合做「相对比较」,不适合直接当成用户感受到的 p99。
ab 能测什么、不能测什么
ab 在本机可用,适合快速验证 HTTP 入口的吞吐和错误率:
ab -k -n 20000 -c 64 \
-p /tmp/getbook.json \
-T 'application/json' \
http://127.0.0.1:9094/books/978-7-111-00001-1
参数含义:
-k:keep-alive,复用连接。不加的话每个请求一次 TCP 握手,测到的是握手开销。-n:总请求数。样本要足够,p99 才有意义。-c:并发数。ab 是单进程,-c很大时客户端自身可能先成为瓶颈。-p/-T:POST body 与 Content-Type。GET 请求不需要。
ab 的输出包含 Requests per second、Time per request、Failed requests 和分位表(50/66/75/80/90/95/98/99/100)。读法:
Time per request有两行,一行是「每个请求的平均时间」,一行是「每个并发请求的平均时间」,别混。Failed requests要配合Non-2xx responses看;429 在 ab 里可能被算成失败,也可能是非 2xx,取决于版本。- 分位表是闭环口径,且 ab 默认 HTTP/1.0;对 HTTP/2 或 gRPC 没有参考价值。
ab 不能测:gRPC、流式响应、HTTP/2 多路复用、协调缺失修正、连接池行为。wrk 和 hey 在本机未安装,脚本里不要假设它们存在;需要更复杂的 HTTP 压测时,先用 JDK HttpClient 自写,和 gRPC 客户端共用同一套统计代码。
自写 gRPC 压测客户端
gRPC 压测需要三件事:固定并发、按目标速率发请求、从预期开始时间计算延迟。下面是一个可运行的骨架:
package com.plume.linkd.v3.load;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;
public final class GrpcLoadClient {
public record Result(long samples, long errors, long timeouts,
double throughputRps, double p50Ms, double p95Ms, double p99Ms) {
}
private final ManagedChannel channel;
private final LibraryGrpc.LibraryBlockingStub stub;
private final List<Long> latenciesNanos = Collections.synchronizedList(new ArrayList<>());
private final LongAdder errors = new LongAdder();
private final LongAdder timeouts = new LongAdder();
public GrpcLoadClient(String host, int port) {
this.channel = ManagedChannelBuilder.forAddress(host, port)
.usePlaintext()
.build();
this.stub = LibraryGrpc.newBlockingStub(channel);
}
public Result run(String isbn, int total, double targetRps, int concurrency)
throws InterruptedException {
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
CountDownLatch done = new CountDownLatch(total);
long startNanos = System.nanoTime();
long intervalNanos = (long) (1_000_000_000L / targetRps);
for (int i = 0; i < total; i++) {
long intendedStart = startNanos + i * intervalNanos;
pool.execute(() -> {
sleepUntil(intendedStart);
GetBookRequest request = GetBookRequest.newBuilder().setIsbn(isbn).build();
try {
stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS).getBook(request);
} catch (StatusRuntimeException e) {
switch (e.getStatus().getCode()) {
case DEADLINE_EXCEEDED -> timeouts.increment();
default -> errors.increment();
}
} finally {
// 关键:用预期开始时间,而不是实际开始时间。
latenciesNanos.add(System.nanoTime() - intendedStart);
done.countDown();
}
});
}
done.await();
pool.shutdown();
long elapsedNanos = System.nanoTime() - startNanos;
return summarize(total, elapsedNanos);
}
private Result summarize(int total, long elapsedNanos) {
List<Long> snapshot;
synchronized (latenciesNanos) {
snapshot = new ArrayList<>(latenciesNanos);
}
Collections.sort(snapshot);
double seconds = elapsedNanos / 1_000_000_000.0;
return new Result(
snapshot.size(),
errors.sum(),
timeouts.sum(),
seconds == 0 ? 0 : total / seconds,
percentileMs(snapshot, 0.50),
percentileMs(snapshot, 0.95),
percentileMs(snapshot, 0.99));
}
private static double percentileMs(List<Long> sorted, double p) {
if (sorted.isEmpty()) {
return 0.0;
}
int index = (int) Math.ceil(p * sorted.size()) - 1;
index = Math.max(0, Math.min(sorted.size() - 1, index));
return sorted.get(index) / 1_000_000.0;
}
private static void sleepUntil(long targetNanos) {
long remaining = targetNanos - System.nanoTime();
if (remaining <= 0) {
return;
}
try {
TimeUnit.NANOSECONDS.sleep(remaining);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void close() throws InterruptedException {
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
}
}
这段代码有几个刻意的选择:
intendedStart在提交任务时就算好,延迟从它开始计,直接修正协调缺失。withDeadlineAfter(800, MILLISECONDS)和客户端总预算一致;超时单独计数,不混进普通错误。ExecutorService的线程数是并发上限,目标速率由intervalNanos控制;两者独立,才能做「低并发高 QPS」或「高并发低 QPS」的组合。- 所有延迟样本放在内存里排序。只适合几万到几十万样本;更大规模要用直方图或 HdrHistogram 一类结构,不能无限存
Long。
这个骨架没有做背压:如果服务端变慢,任务会在执行器队列里堆积,内存上涨。真实压测要换成有界队列 + 拒绝计数,或者用信号量限制在途请求数。压测工具本身把内存打爆,得到的数字没有意义。
在 linkd-v3 里落地
压测前先隔离流量,避免限流和真实租户互相干扰:
| 设置 | 值 | 原因 |
|---|---|---|
| 租户 | tenant-loadtest | 与演练租户分开,避免注入器命中 |
| 限流 | 单独一组配额 | 测容量时不应被 200 rps 的默认限流挡住 |
| 数据 | 固定 ISBN 集合 | 避免缓存命中率随数据变化 |
| 时长 | 恒定 15 分钟 | 覆盖 JIT 预热与连接池稳定 |
| 停止条件 | p99 > 2 s 或错误率 > 5% | 防止把下游打挂 |
压测顺序建议:
- 1 rps 跑 60 秒,确认功能与链路正确。
- 恒定 50 rps 跑 5 分钟,记录基线。
- 阶梯每 2 分钟 +50 rps,找到拐点。
- 在拐点的 70% 负载上浸泡 2 小时,看泄漏。
- 回到恒定 200 rps,验证目标流量下的 p99 与错误率。
每一步都记录「吞吐、p50/p95/p99、错误分类、关键资源」。资源至少包括:CPU、堆使用、线程数、fd 数、连接池借出数、队列深度。只记 QPS 和延迟,瓶颈定位只能靠猜。
常见错误与边界
错误一:只报平均延迟。 平均值会把 1% 的超时稀释掉。至少要报 p50、p95、p99 和超时率。
错误二:闭环客户端算 p99。 服务端停摆的时段没有请求进入统计,延迟被系统性低估。用预期开始时间修正,或明确标注这是闭环口径。
错误三:把超时排除在延迟之外。 只统计成功请求,等于把最差的用户体验删掉。超时要按 deadline 计入,或单列。
错误四:压测客户端先成为瓶颈。 ab -c 1000 在单进程上可能先耗尽客户端 CPU;gRPC 客户端如果所有请求共用一个 executor,也会在客户端排队。看服务端指标之前,先确认客户端利用率。
错误五:忽略预热。 JIT、连接池、DNS 缓存都需要时间。把前 30 秒算进结果,会让 p99 偏高;完全跳过预热,又可能错过冷启动问题。折中是单独报告冷启动段。
错误六:压测流量带重试。 重试会把下游压力放大 3 倍,测出来的「容量」其实是重试后的容量。测容量时关重试,测韧性时再打开。
边界:压测环境不等于生产。 数据量、网络拓扑、实例规格、依赖版本都可能不同。压测结论要写明环境,不能直接外推到生产。
边界:本机工具有限。 macOS 没有 ss;wrk、hey、grpcurl 未安装;ab 只适合 HTTP/1.x。需要 gRPC 或 HTTP/2 压测时,自写客户端比临时装工具更可控。
小结
- 四种剖面各回答一个问题:恒定看稳态、阶梯找拐点、尖峰看队列与扩容、浸泡看泄漏。
- 指标口径先定义:吞吐按完成数、错误按状态码分类、延迟报 p50/p95/p99 且包含超时。
- 协调缺失会让闭环压测的 p99 严重偏乐观;用预期开始时间计算延迟。
ab适合 HTTP/1.x 的快速验证,不能测 gRPC、流式与 HTTP/2;本机没有wrk/hey。- 自写 gRPC 客户端要固定并发、按目标速率发请求、分别统计超时与错误,并限制在途数量。
- 压测结论必须带环境、样本量、口径和资源指标,否则无法复现。
阅读导航:上一节 14.1 容量模型与 Little’s law | 下一节 14.3 成本、扩缩容与预算
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。