压测最常见的失败不是"压不上去",而是压出了一堆数字,没人能据此做决定。平均响应时间 80ms、TPS 3000,然后呢?这个服务该扩几台、瓶颈在哪、能不能扛住下一个大促,全答不上来。
原因多半在设计阶段就定了:没有 SLA、没有负载模型、只压了一个接口、数据只有十个 ID。这篇按"指标 → 负载模型 → 脚本 → 读结果 → 找瓶颈"拆开讲。
1. 四种性能测试,各自回答一个问题
| 类型 | 负载形态 | 回答的问题 | 典型时长 |
|---|---|---|---|
| 基准 baseline | 单机、低并发 | 当前版本的下限量级 | 分钟级 |
| 负载(load) | 目标并发/到达率 | 预期流量下是否达标 | 十几分钟 |
| 压力(stress) | 阶梯递增直到崩 | 容量上限在哪、怎么崩 | 半小时级 |
| 稳定性(soak) | 目标负载长时间恒定 | 有无泄漏、衰减 | 数小时到一天 |
四者不能混:用压力测试的数据去回答"能不能扛住日常流量",通常会高估;用基准数据去回答容量上限,通常会低估。
2. 先定义指标,再压
| 指标 | 采集点 | 为什么不能用平均值 |
|---|---|---|
| 延迟分位数 p50/p90/p95/p99 | 服务端 + 客户端各一份 | 平均值会把长尾藏起来 |
| 吞吐(req/s 或 rpm) | 服务端 | 客户端统计会被自身瓶颈污染 |
| 错误率(按状态码与异常类型分) | 服务端 + 网关 | 只看 5xx 会漏掉业务错误 |
| 资源占用(CPU、内存、连接池、线程池队列) | JVM/容器指标 | 没有资源数据就找不到瓶颈 |
| 下游影响(DB QPS、慢查询、缓存命中率) | 下游监控 | 压测常把下游打崩而自己"看起来很好" |
# Spring Boot 3.3.x:让服务端输出可聚合的分位数直方图
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
slo:
http.server.requests: 100ms,300ms,1s
没有 SLA 的数字无法判断好坏。SLA 要可判定:"500 req/s 下 p95 < 300ms 且错误率 < 0.1%",而不是"要快"。
3. 负载模型的两种,差别在排队
| 模型 | 参数 | 客户端行为 | 风险 |
|---|---|---|---|
| 闭合(closed) | 并发用户数 / VU 数 | 上一个请求回来才发下一个 | 会低估排队:响应变慢时自动降速 |
| 开放(open) | 到达率(req/s) | 按固定速率发,不等响应 | 更接近真实入口流量;需要预留 VU 或丢弃 |
真实用户是闭合的(点一下等结果),入口流量是开放的(按到达率进来)。只测闭合模型,系统变慢会让压测机跟着变慢,于是压不到真正的饱和点——这就是"线上比压测差"最常见的解释。
// k6:开放模型,按到达率驱动
export const options = {
scenarios: {
open_load: {
executor: 'constant-arrival-rate',
rate: 500, timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 200, // 预留不足会报 dropped_iterations
maxVUs: 800,
},
},
};
dropped_iterations 非零说明压测机没跟上目标速率,此时测的是压测机上限,不是被测服务。
4. 场景三要素:比例、思考时间、数据分布
| 要素 | 设错的表现 | 正确做法 |
|---|---|---|
| 业务比例 | 只压最热的查询接口,写路径没压 | 按生产访问日志的接口占比配权重 |
| 思考时间 | 零间隔连续请求,缓存全命中 | sleep(1~3s) 或按真实会话间隔 |
| 数据分布 | 同一批 ID 反复打,命中率虚高 | 从 ID 池随机取,池子远大于缓存 |
import { sleep } from 'k6';
import exec from 'k6/execution';
export default function () {
const r = Math.random();
if (r < 0.70) {
http.get(`${BASE}/api/items/${pickId()}`); // 读
} else if (r < 0.95) {
http.post(`${BASE}/api/orders`, JSON.stringify(pickOrder()),
{ headers: { 'Content-Type': 'application/json' } });
} else {
http.get(`${BASE}/api/search?q=${pickKeyword()}`); // 搜索,最贵
}
sleep(1 + Math.random() * 2);
}
比例别拍脑袋:从网关访问日志按路径聚类统计,比"我们觉得读多写少"可靠。
5. 脚本要点:参数化、关联、断言、超时
| 要点 | 做法 | 不做会怎样 |
|---|---|---|
| 参数化 | 数据池 + 随机/轮询取值,池子大于缓存 | 缓存全命中,测出假的高吞吐 |
| 关联 | 从响应提取 token/ID 传给下一步 | 第二步全部 401/404,测的是错误路径 |
| 断言 | 校验状态码与关键字段,而非只算耗时 | 500 也算"成功",延迟好看但全错 |
| 超时 | 显式设置请求超时 | 挂起连接占满 VU,压测机自己先崩 |
| 分组 | 用 group 标注业务流程 |
结果里只有一条混合曲线,无法归因 |
import { check } from 'k6';
import { Trend } from 'k6/metrics';
const orderLatency = new Trend('order_latency', true);
export const options = {
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<800'],
http_req_failed: ['rate<0.001'],
order_latency: ['p(95)<500'],
},
};
export default function () {
const res = http.post(`${BASE}/api/orders`, body, params);
orderLatency.add(res.timings.duration);
check(res, {
'status is 201': (r) => r.status === 201,
'has orderId': (r) => !!r.json('orderId'),
});
}
阈值让压测有结论:跑完直接"通过/不通过",而不是留一堆图让人自由解读。
6. 预热与冷启动
首轮数据几乎总不可用:JIT 未编译、连接池为空、缓存为空、Bean 刚初始化。处理方式:
- 正式压测前跑 1–3 分钟预热,这段单独标记、不进统计;
- "冷启动"与"稳态"都要有结论,但不能混在一条曲线里;
- 容器化环境注意 CPU 配额与 CPU 节流(
cpu.throttled),冷启动阶段的抖动常被误读为代码问题。
7. 结果解读的四个陷阱
| 陷阱 | 表现 | 正确读法 |
|---|---|---|
| 用平均值掩盖长尾 | 平均 80ms,但 p99 是 3s | 先看分位数曲线,平均值只作参考 |
| 忽略错误率只看延迟 | 延迟很漂亮,因为大量请求超时后快速失败 | 延迟与错误率必须同图看 |
| 压测机自身成为瓶颈 | CPU 打满、dropped_iterations 上升 |
压测机与被测服务分开,压测机资源留 50% 余量 |
| 跨环境数据不可比 | 开发机 4 核 vs 生产 32 核 | 只比同环境、同数据量、同版本的数 |
再补一条:客户端与服务端两套延迟都要看,差值就是网络与排队。服务端 50ms、客户端 400ms,瓶颈在链路或客户端连接池。
8. 找拐点:阶梯加压
export const options = {
scenarios: {
ramp: {
executor: 'ramping-arrival-rate',
startRate: 50, timeUnit: '1s',
preAllocatedVUs: 200, maxVUs: 2000,
stages: [
{ duration: '2m', target: 200 }, // 预热段
{ duration: '5m', target: 800 }, // 线性加压
{ duration: '5m', target: 1600 },
{ duration: '3m', target: 1600 }, // 保持,观察是否衰减
],
},
},
};
读法:把吞吐与 p95 画在一起。吞吐还涨而 p95 抬升,是进入排队;吞吐不涨而 p95 陡升,就是拐点;吞吐回落说明已开始拒绝或超时。拐点负载是容量上限候选值,再留余量。
9. 瓶颈定位:按饱和度,不按猜测
| 顺序 | 看什么 | 饱和信号 |
|---|---|---|
| 1 应用 | 线程池队列、GC 停顿、http.server.requests 分位数 |
队列积压、Full GC 频繁 |
| 2 数据库 | 活跃连接数、慢查询、锁等待、Buffer Pool 命中 | 连接池耗尽、Threads_running 顶到上限 |
| 3 中间件 | Redis 命中率与延迟、MQ 积压、ES 段合并 | 命中率骤降、p99 抬升 |
| 4 网络/基础设施 | 带宽、重传、容器 CPU 节流 | 重传率上升、throttled 时间增长 |
原则:先找"哪个资源的饱和度先到 100%",而不是先怀疑某段代码。资源没饱和而延迟高,才回头看锁竞争、GC、外部调用。
10. 稳定性测试:短跑测不出来的东西
| 观察项 | 短跑(10 分钟) | 长跑(数小时) |
|---|---|---|
| 内存 | 看不到泄漏 | 老年代只涨不落即为泄漏候选 |
| 连接池 | 尺寸稳定 | 泄漏表现为活跃连接数单调上升 |
| 缓存 | 命中率虚高 | 膨胀后触发淘汰,延迟出现周期性尖刺 |
| 日志/磁盘 | 无感 | 日志爆量写满磁盘,服务假死 |
| 定时任务叠加 | 未触发 | 整点批处理与压测叠加,延迟突增 |
长跑必须固定负载,否则分不清"加压变慢"与"泄漏变慢"。下游监控要同步看:稳定性问题常先在下游暴露。
11. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
目标速率上不去,dropped_iterations 上升 |
压测机能力不足或 maxVUs 太小 |
提高 VU 上限、拆分压测机、减少脚本内计算 |
| 延迟很低但错误率高 | 快速失败被算作"快" | 断言状态码与业务字段,阈值加 http_req_failed |
| 压测机 CPU 打满 | 压测机与被测服务同机 / 脚本过重 | 分离机器;把参数化数据改用 SharedArray |
| 吞吐上不去,服务端 CPU 也不高 | 客户端连接池或 DNS 解析成瓶颈 | 复用连接、预热 DNS、减少 TLS 握手 |
| 压测时下游监控告警 | 只关注被测服务 | 把下游指标纳入同一张看板 |
| 首轮数据比稳态差很多 | 冷启动、JIT、空缓存 | 加预热段并在结果里剔除 |
| 长跑后期延迟周期性尖刺 | 缓存淘汰、GC、定时任务 | 看 GC 日志与任务时间表,用固定负载复现 |
12. 边界:压测给不了什么
| 问题 | 压测能否回答 | 该用什么 |
|---|---|---|
| 容量上限量级 | 能(同环境、同数据分布) | 阶梯加压 |
| 线上真实流量下的表现 | 不能 | 全链路压测、线上影子流量 |
| 某段代码为什么慢 | 不能 | profiler、火焰图、GC 日志 |
| 故障注入下的行为 | 不能 | 混沌实验 |
| 成本与容量的最优配比 | 不能 | 容量规划 + 成本模型 |
一条经验:压测回答"给定条件下能扛多少",不回答"为什么"。 前者靠场景设计,后者靠观测工具;混在一次压测里,两边都做不好。
13. 自测
- 闭合模型为什么会低估排队?开放模型用什么参数驱动?
- 没有 SLA 的压测数字,为什么无法判断好坏?
- 数据只有同一批 ID,会得到什么样的假结论?
- 延迟很好看但错误率很高,可能的原因是什么?
- 吞吐不再上涨而 p95 陡升,说明到了什么点?
- 定位瓶颈时,为什么先看资源饱和度而不是先看代码?