本节目标:把压测从「跑个工具看数字」变成可信的工程方法——明确目标、设计贴近真实的场景与数据、选对工具、分离压测机与被测机、正确采集指标,避开假瓶颈,最后用实测 QPS 与安全系数倒推出有依据的实例数。
适用版本:Spring Boot 4.1.x(Java 21)
17.2 压测与容量评估
17.1 教了怎么定位瓶颈,但瓶颈定位有个前提:你得先有一个能复现问题的负载。压测就是制造这个负载的手段。问题在于,压测也是最容易被做歪的一环——一台笔记本上跑个 ab,得出「单机 5000 QPS」,然后据此规划生产容量,结果上线后连 500 都扛不住。
本节的核心观点:压测做错比不做更危险,因为它会给你一个错误的、被信任的容量数字。要得到可信的数字,必须在目标、场景、工具、环境、口径五个环节都做对。
17.2.1 先明确压测目标
没有目标的压测就是浪费时间。目标不同,场景设计和工具选型完全不同。常见三类目标:
| 目标 | 想回答的问题 | 输出物 |
|---|---|---|
| 找拐点 | 系统在多少并发下开始劣化? | 并发 → QPS/P99 曲线、最大可持续 QPS |
| 验证容量 | 目标负载下能否满足 SLO? | 目标 QPS 下的 P99、错误率是否达标 |
| 回归对比 | 这次改动有没有让性能退化? | 改动前后的同场景指标对比 |
「找拐点」要的是逐渐加压直到系统劣化;「验证容量」是直接上目标负载并观察是否稳定;「回归对比」要的是固定负载、固定环境、可重复,重点在对比而非绝对值。
明确目标还会顺带定下成功判据。比如「在 3000 QPS 下 P99 < 200ms 且错误率 < 0.1%」——有了这条线,压测结果才有「通过 / 不通过」的结论,而不是一堆看不懂的数字。
17.2.2 场景设计:读多写少、混合比例、真实数据
第一个决策是读写比例。 图书借阅服务的真实流量里,查书、查借阅记录这类读操作占绝对多数,借书、还书这类写操作少得多。如果压测只压「查一本书」,得到的 QPS 会严重高估容量;如果只压「借书」,又会低估。要按真实比例混合。
第二个决策是接口组合。 单一接口压测(如只压 GET /books/{id})只能测出那个接口的极限,不代表整体。真实流量是多个接口按比例混合,缓存命中率、连接池占用、锁竞争都会因为组合而不同。
第三个决策是数据量与数据分布。 这是最容易被忽视、也最容易导致「压测很快、生产很慢」的地方:
- 数据量:在 1 万行的表上压测,和在生产 1000 万行的表上,SQL 的执行计划可能完全不同(小表可能全表扫也很快)。压测库的数据量要接近生产。
- 数据分布:如果所有请求都命中同一本书(热点数据),缓存永远命中,得到的 QPS 虚高。要按真实分布打散,让一部分请求命中冷数据、一部分走到数据库。
一句话:压测场景的可信度,取决于它离真实流量有多近。 场景失真,数字再精确也没用。
用 k6 把上面的三点落成一个可进版本库的脚本(读多写少、接口混合、阶梯加压找拐点):
import http from 'k6/http';
import { check } from 'k6';
// 阶梯加压:每 30 秒升一档并发,观察 QPS 与 P99 的拐点
export const options = {
scenarios: {
ramp: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: 100 }, // 预热
{ duration: '2m', target: 400 }, // 逐步加压
{ duration: '2m', target: 800 },
{ duration: '2m', target: 800 }, // 稳定期
],
},
},
thresholds: {
http_req_duration: ['p(99)<200'], // SLO:P99 < 200ms
http_req_failed: ['rate<0.001'], // 错误率 < 0.1%
},
};
const BASE = __ENV.BASE_URL || 'http://10.0.0.20:8080';
export default function () {
// 读多:查询图书列表(按真实分布打散 category,避免缓存被刷热)
const categories = ['NOVEL', 'TECH', 'HISTORY', 'ART', 'SCIENCE'];
const c = categories[Math.floor(Math.random() * categories.length)];
const r = http.get(`${BASE}/api/books?category=${c}&page=0&size=20`);
check(r, { 'list ok': (res) => res.status === 200 });
// 写少:约 1/10 的迭代触发一次借阅
if (Math.random() < 0.1) {
const body = JSON.stringify({ bookId: 1000 + Math.floor(Math.random() * 500) });
http.post(`${BASE}/api/loans`, body, { headers: { 'Content-Type': 'application/json' } });
}
}
thresholds 把 SLO 写进了脚本,k6 结束时直接给「通过 / 不通过」的结论——这就是 17.2.1 说的「成功判据」。ramping-vus 的阶梯让拐点自然浮现:当并发从 400 升到 800 而 QPS 不再增长、P99 陡增时,拐点就在那里。
17.2.3 工具选型:定位不同,别只看 QPS 数字
| 工具 | 定位 | 适合 | 不适合 |
|---|---|---|---|
ab | Apache 自带的最简压测 | 单 URL 冒烟、快速看量级 | 多接口、复杂场景、动态参数 |
wrk | 多线程 + Lua 脚本 | 单接口高并发、可脚本化 | 复杂业务流程、图形化报告 |
| JMeter | 功能最全的 GUI 压测平台 | 多接口混合、参数化、断言、分布式 | 追求极致单机 QPS(本身较重) |
| Gatling | 基于 Scala DSL,代码化场景 | 复杂场景、CI 集成、报告美观 | 快速上手(要写 DSL) |
| k6 | 基于 JavaScript,面向开发 | 代码化、CI 集成、云压测 | 传统企业 GUI 习惯 |
选型建议:验证容量与回归对比用 k6 或 Gatling(脚本可进版本库,天然可重复);快速定位单接口极限用 wrk;复杂业务流程、需要参数化和断言的用 JMeter。 工具本身不决定压测质量,场景设计才决定。
一个 wrk 的例子(压借阅查询接口):
wrk -t4 -c128 -d60s --latency \
"http://10.0.0.20:8080/api/books?category=NOVEL&page=0&size=20"
-t4 是 4 个压测线程,-c128 是 128 个并发连接,-d60s 压 60 秒,--latency 打印延迟分布。示例输出:
Running 1m test @ http://10.0.0.20:8080/api/books?category=NOVEL&page=0&size=20
4 threads and 128 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.34ms 8.91ms 220.10ms 88.20%
Req/Sec 890.12 120.44 1.02k 70.10%
Latency Distribution
50% 10.20ms
75% 15.30ms
90% 22.80ms
99% 88.40ms
213420 requests in 60.02s, 512.33MB read
Requests/sec: 3556.12
判读:Requests/sec 是吞吐,Latency Distribution 里的 99% 是 P99。只报 Requests/sec 不报 P99 的压测报告没有意义——高 QPS 可能靠错误或超时堆出来的。
17.2.4 压测机与被测机必须分离
这是最硬的一条纪律:压测客户端和服务端不要在同一台机器上。
原因很直接:压测客户端本身要消耗 CPU、内存、网络带宽和文件描述符。当它和服务端抢同一份资源时,你测的到底是服务端的能力,还是「客户端和服务端加起来的能力」,完全说不清。更糟的是,客户端可能在服务端还没饱和时就先饱和了,于是你得出「服务端 QPS 只有这么点」的错误结论——其实瓶颈是压测机。
正确做法:
- 压测机与被测机物理分离,最好跨网段但同机房(避免公网抖动)。
- 压测机要预留足够资源,其 CPU 使用率不应接近饱和(压测期间盯一下压测机的
top)。 - 需要更大压力时用多台压测机分布式发起,而不是把客户端线程数调到离谱。
同理,被测的 Spring Boot 应用也要独占机器:别在压测机上同时跑数据库、消息队列或监控 agent 的重活。
17.2.5 预热与稳定期
JVM 应用有一个「冷启动」阶段:类加载、JIT 编译、连接池填充、缓存预热。冷启动阶段的数字不代表稳态性能。
- 预热(warmup):正式采集前,用中等负载跑一段时间(几十秒到几分钟),让 JIT 把热点方法编译到 C2、让连接池和缓存填充起来。没预热就采样,会把 JIT 的编译开销算进延迟里。
- 稳定期:预热之后进入正式采集,此时才记录指标。稳定期要足够长(通常至少几分钟),以覆盖 GC 周期、定时任务、缓存过期等周期性行为。
- 看趋势而非单点:稳定期里 QPS 和 P99 应该是平稳的;如果持续下降或抖动剧烈,说明系统还没进入稳态,或者已经出现资源泄漏、队列堆积。
wrk 没有内置预热,需要自己「先压一段不计入、再压一段计入」;k6/Gatling/JMeter 都有 warmup 阶段或可脚本化实现。
17.2.6 指标采集:不要在压测客户端测服务端延迟
最容易犯的测量错误:用压测客户端记录的延迟,当作服务端的处理延迟。
客户端测到的是「端到端往返时间」,里面包含:网络往返、客户端排队、服务端处理、响应序列化。当并发很高时,客户端自身的排队会显著放大这个数字。用它在服务端侧做决策,会把「客户端排队」误判成「服务端慢」。
正确的采集口径:
| 指标 | 应在哪里采集 | 为什么 |
|---|---|---|
| 服务端处理延迟 | 服务端(Micrometer / Actuator) | 排除网络与客户端排队 |
| 端到端延迟 | 客户端 | 反映用户真实感知,作为补充 |
| 吞吐 QPS | 客户端 | 客户端统计最完整 |
| 错误率 | 服务端 + 客户端 | 服务端看 5xx,客户端看超时/连接失败 |
| 资源使用 | 服务端(CPU/GC/连接池) | 定位瓶颈资源 |
也就是说:吞吐和错误率看客户端,处理延迟和资源看服务端。 两者对不上时(客户端 P99 远高于服务端 P99),差额就是网络与客户端排队的开销,这本身就是有用的信息。
服务端侧用 Micrometer + Actuator 采集(16.1 已搭好),可以按 URI 维度看 P99 与请求计数;压测期间同时抓 JFR(17.1),把「服务端慢」和「GC/锁/IO」关联起来。
17.2.7 常见陷阱:假瓶颈与假容量
| 陷阱 | 现象 | 后果 | 对策 |
|---|---|---|---|
| 连接池与压测线程数不匹配 | 连接池成瓶颈,服务端线程在等连接 | 误判为「应用慢」 | 让压测并发与连接池、线程池一起放大验证 |
| 缓存被压测「刷热」 | 重复请求同一数据,缓存永远命中 | QPS 虚高,生产冷数据打穿 | 用接近真实分布的数据打散 |
| 单机压测下容量结论 | 只压一台实例 | 容量结论不可迁移 | 多实例压测,或明确按单实例折算 |
| 压测机先饱和 | 客户端 CPU 打满 | 误判服务端上限 | 分离压测机并监控其资源 |
| 短压测抓瞬时峰值 | 只压 10 秒 | 错过 GC 周期与长尾 | 稳定期拉长到覆盖完整周期 |
| 只看平均值 | 平均延迟很低 | 掩盖 P99 长尾 | 永远报 P99/P99.9 |
重点说第一条「连接池与压测线程数不匹配」:如果 HikariCP 连接池只有 20 个连接(11.1 讲过怎么定),而压测用 200 并发,那么 180 个请求会排在连接池外等待。此时压测报告的「服务端慢」其实是连接池不够,不是应用逻辑慢。对策是:压测时同步观察连接池活跃数、等待时间(hikaricp.connections.pending 等指标),如果连接池长期打满,说明瓶颈在池大小而不是代码——要么调池,要么调并发,两者要一起验证。
17.2.8 容量评估:用实测 QPS 乘安全系数倒推
容量评估不是「拍脑袋定 4 个实例」,而是从一个可信的实测 QPS 出发做算术。步骤:
- 测出单实例的最大可持续 QPS。 「可持续」指在满足 SLO(如 P99 < 200ms、错误率 < 0.1%)前提下的 QPS,而不是压到崩溃前的瞬时峰值。这个数从「找拐点」的曲线里读。
- 按峰值流量算实例数。 假设业务峰值是
PQPS,单实例可持续CQPS,则理论实例数N = ceil(P / C)。 - 乘安全系数。 生产环境要留冗余应对突发、实例故障、发布滚动重启。安全系数通常取 1.3~2.0(可用性要求越高、突发越猛,系数越大)。最终实例数
N = ceil(P / C × factor)。 - 按最坏情况验证。 用「N-1 个实例」验证容量(模拟一个实例故障),确认剩余实例仍能满足 SLO。
举例(数字仅为演算,非实测):若单实例可持续 800 QPS,业务峰值 2400 QPS,安全系数取 1.5,则 N = ceil(2400 / 800 × 1.5) = ceil(4.5) = 5 个实例;再用 4 个实例跑一次「N-1」验证,确认还能扛住 2400 QPS。
把这个算术固化成脚本,避免每次手算:
import math
def instances(peak_qps, per_instance_qps, factor):
return math.ceil(peak_qps / per_instance_qps * factor)
print(instances(peak_qps=2400, per_instance_qps=800, factor=1.5)) # 5
为什么不用「总 QPS ÷ 单机 QPS」直接算? 因为没有安全系数就没有冗余,任何一次实例重启或突发流量都会击穿。容量评估的目标不是「刚好够用」,而是「在合理波动下仍然满足 SLO」。
容量结论必须连同前提一起写下来:单实例 QPS 是在什么 JVM 参数、什么数据量、什么场景下测的。换了大版本、改了 GC(17.3)、调了连接池,结论都要重新验证。
17.2.9 压测报告:没有前提的数字等于零
压测的价值在于「可复现、可对比」。一份合格的报告必须让人照着能重跑出同样的结论。至少包含:
| 字段 | 内容 | 为什么重要 |
|---|---|---|
| 目标 | 找拐点 / 验证容量 / 回归对比 | 决定怎么解读数字 |
| 场景 | 接口、读写比例、数据量与分布 | 场景不同,数字不可比 |
| 环境 | 实例数、规格、JVM 参数、JDK 版本 | 环境变了结论作废 |
| 工具与负载 | 工具、并发、时长、压测机规格 | 排除压测机成为瓶颈 |
| 结果 | QPS、P50/P95/P99、错误率、资源曲线 | 完整指标,不只看平均值 |
| 结论 | 单实例可持续 QPS、安全系数、实例数 | 可执行的决策依据 |
| 对比 | 与上次基线的差异 | 回归的判据 |
把报告和脚本一起提交到版本库,每次改配置或发版都重跑一遍。性能回归往往比功能回归更隐蔽——功能测试会红,性能退化不会报警,只会慢慢把 P99 推高。
小结
- 压测做错比不做更危险,因为它给出一个被信任的错误容量数字;目标、场景、工具、环境、口径五个环节都要做对。
- 先明确目标(找拐点 / 验证容量 / 回归对比)并定下成功判据,再设计场景。
- 场景要贴近真实:读写比例、接口组合、数据量与数据分布都要对齐生产,否则数字虚高。
- 工具按定位选:wrk/ab 快速看量级,k6/Gatling 做可重复的容量与回归,JMeter 做复杂业务流程;工具不决定质量,场景才决定。
- 压测机与被测机必须分离,压测机不能先饱和;被测应用要独占资源。
- 预热让 JIT、连接池、缓存进入稳态,稳定期要足够长并看趋势;吞吐与错误率看客户端,处理延迟与资源看服务端。
- 避开假瓶颈:连接池与并发不匹配、缓存被刷热、单机压测下结论、短压测抓峰值、只看平均值。
- 容量评估用「单实例可持续 QPS × 安全系数」倒推实例数,并用 N-1 验证;结论要连同前提一起记录,改了参数就重新验证。
阅读导航:上一节:17.1 性能剖析方法 · 下一节:17.3 JVM 参数与运行时调优 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。