压测最容易做错的地方不是工具,而是没有事先想清楚要回答什么。上来就 JMeter 开 500 线程跑十分钟,拿到一份"平均响应时间 82ms"的报告,然后没人知道这组数字意味着什么:是容量上限、是瓶颈、还是压测机自己先撑不住了?
压测的价值在于它能给出曲线,而不是单点数字。
1. 压测要回答的三个问题
| 问题 | 需要的输出 | 不回答它的后果 |
|---|---|---|
| 容量上限是多少 | TPS–延迟曲线、拐点 TPS | 扩容全靠拍脑袋 |
| 瓶颈在哪 | 瓶颈资源(CPU/连接池/锁/DB)证据 | 加机器不加性能 |
| 从哪开始劣化 | 拐点前后的指标对比 | 线上先崩才知道 |
一次合格的压测,报告里必须有这三个答案,否则就是"跑了一次数"。值得注意的是第三问:拐点比峰值更有用,因为生产不会精确停在峰值上,只要越过拐点,延迟就会非线性上涨。
2. JMeter 与 wrk 的定位差异
| 维度 | JMeter | wrk |
|---|---|---|
| 定位 | 场景编排(多接口、有依赖、有断言) | 单接口高并发打点 |
| 加压能力 | 线程数受 JVM 与内存限制 | 事件驱动,单机可到数万连接 |
| 脚本 | GUI 编排 + Groovy/JSR223 | Lua 脚本(wrk -s) |
| 结果 | 聚合报告、jtl 明细 | 命令行摘要,延迟分布 |
| 适合 | 业务流程级压测、参数化、关联 | 找单接口吞吐上限、快速对比 |
# wrk:4 线程 400 连接,持续 60 秒,脚本里带鉴权头
wrk -t4 -c400 -d60s -s scripts/order.lua --latency http://target:8080/api/orders/1001
# JMeter:必须用非 GUI 模式,GUI 只用来编辑脚本
jmeter -n -t order.jmx -l result.jtl -e -o report/ \
-Jthreads=200 -Jrampup=60 -Jduration=300
JMeter 的 GUI 模式不能用来跑压测:GUI 自身的渲染会吃掉大量 CPU,压测机先被 GUI 拖垮,你测到的是压测机的上限。
3. 线程模型与加压方式
最常被误解的一条:JMeter 的线程数 = 并发用户数,不是 TPS。TPS 由"并发数 ÷ 单请求耗时"决定,200 线程对 50ms 的接口是 4000 TPS,对 500ms 的接口只有 400 TPS。
| 参数 | 含义 | 常见误用 |
|---|---|---|
Threads |
并发用户数 | 当成 TPS 填 |
Ramp-up |
多久把线程全部拉起 | 设 0 → 瞬间冲击,测的是冷启动 |
Loop Count / Duration |
每线程跑几次 / 跑多久 | 用循环数导致总时长不可控 |
Constant Throughput Timer |
目标 TPS 节流 | 与线程数冲突,互相卡住 |
Synchronizing Timer |
集合点,制造瞬时并发 | 用在长事务上导致线程堆积 |
<!-- order.jmx 片段:线程组关键参数,用属性占位便于命令行覆盖 -->
<ThreadGroup>
<stringProp name="ThreadGroup.num_threads">${__P(threads,200)}</stringProp>
<stringProp name="ThreadGroup.ramp_time">${__P(rampup,60)}</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<stringProp name="ThreadGroup.duration">${__P(duration,300)}</stringProp>
</ThreadGroup>
-Jthreads=200 覆盖的就是 ${__P(threads,200)}。把参数外置后,同一份 jmx 可以被不同压测场景复用,不用每次改 GUI。
4. 目标 TPS 的确定与阶梯加压
目标 TPS 有三个来源,按可信度排序:生产峰值 × 安全系数 > 容量规划目标 > 拍脑袋。
目标 TPS = 生产峰值 TPS × 增长系数(如 1.5~3)× 峰值集中系数(如 1.2)
确定之后不要一次性打满,用阶梯加压找拐点:
| 阶段 | 时长 | 目的 |
|---|---|---|
| 预热 | 3–5 min | 触发 JIT、连接池、缓存填满 |
| 阶梯 | 每档 5 min,每档 +20% | 找到延迟开始非线性上涨的 TPS |
| 稳态 | 10–15 min | 确认该 TPS 下指标平稳 |
| 超载 | 5 min | 观察劣化形态与恢复能力 |
# 用 shell 循环做阶梯,每档覆盖线程数属性,档间留恢复时间
for c in 50 100 150 200 250 300; do
jmeter -n -t order.jmx -l "result-$c.jtl" -Jthreads=$c -Jduration=300 || break
sleep 60
done
每档之间留恢复时间,是为了区分"这一档压不上去"和"上一档的残留把这一档拖垮了"。不留间隔时,连接池还没回收就开始下一档,曲线会呈现虚假的劣化。
5. 结果指标解读
| 指标 | 含义 | 为什么不够 |
|---|---|---|
| Average | 全部请求耗时均值 | 被长尾与短请求互相抵消,掩盖问题 |
| P90 / P95 | 90%/95% 请求快于此值 | 反映多数人体验 |
| P99 | 尾部体验 | 通常是"最不满的那批用户" |
| Max | 最慢一次 | 受 GC、超时重试影响,波动极大 |
| Throughput | 每秒完成请求数 | 必须与并发数一起看 |
| Error % | 失败比例 | 必须区分"业务失败"与"压测失败" |
平均值的欺骗性可以用一个例子说明:99 个请求 10ms、1 个请求 10s,平均值约 110ms,看起来还行;而 P99 是 10s,说明每 100 个用户里有 1 个等了 10 秒。看平均永远发现不了长尾。
# wrk 的典型输出形态(数值随目标与机器而异,此处为结构示意)
Running 1m test @ http://target:8080/api/orders/1001
4 threads and 400 connections
Latency 45.2ms 38.1ms 120.5ms 85.30%
Req/Sec 2.21k 310.4 3.10k 62.11%
Socket errors: connect 0, read 12, write 0, timeout 3
Latency 行的四个数依次是:平均、标准差、最大值、正负一个标准差内的比例。标准差大说明延迟分布发散,此时平均值几乎没有参考价值。
# 从 jtl 里直接算分位,避免只看聚合报告
awk -F, 'NR>1{print $2}' result-200.jtl | sort -n | \
awk '{a[NR]=$1} END{print "P50="a[int(NR*0.5)], "P95="a[int(NR*0.95)], "P99="a[int(NR*0.99)]}'
6. 错误率与超时的判定
| 现象 | 可能原因 | 判据 |
|---|---|---|
Connection refused |
目标端口耗尽 / 服务未起 | 看压测机 TIME_WAIT 数量 |
Socket timeout |
目标处理慢 / 队列满 | 对应服务端线程池队列指标 |
| HTTP 503 | 上游限流或过载保护 | 查网关与限流配置 |
| 业务错误码 | 参数或数据被压测重复使用 | 用参数化唯一 ID |
| 错误率随并发线性上升 | 达到容量上限 | 该点即拐点候选 |
判定原则:错误率 > 0 的压测结果不能用来谈容量。只要开始出错,延迟数字就已经失真(失败请求往往很快返回)。压测报告里的容量必须是"错误率 0 或接近 0 的最高档 TPS"。
7. 压测环境与生产的一致性
| 维度 | 不一致的后果 | 可接受的做法 |
|---|---|---|
| 数据量 | 索引/查询计划不同,结果失真 | 至少与生产同量级(如 1/10 也要够到索引拐点) |
| 机器规格 | 扩容比例算错 | 记录规格,按比例折算 |
| 网络 | 同机压测绕过网络开销 | 压测机与被测服务分开 |
| 中间件配置 | 连接池、JVM 参数不同 | 用生产的 -Xmx、连接池上限 |
| 依赖 | 下游被打桩 | 记录哪些是桩,报告里注明 |
最容易被忽略的是 JVM 参数:测试环境常给 -Xmx512m,生产给 -Xmx4g,GC 行为完全不同,压出来的延迟曲线没法迁移。
8. 压测报告该写什么
**结论**
- 目标 TPS XXX,实测在 YYY TPS 时 P99 越过 500ms(拐点)
- 瓶颈:数据库连接池(HikariCP pending 持续 > 0),非 CPU
**环境**
- 应用:JDK 17 / Spring Boot 3.3.x / -Xmx4g / 8C16G × 2
- 中间件:MySQL 8(连接池上限 50)、Redis 7
- 压测机:wrk 4 线程 400 连接,与被测服务不同机
- 数据量:订单表 XXX 万行(按生产 1/10 缩减,索引一致)
**过程**
- 预热 5min;阶梯 50→300 线程,每档 5min,档间 60s
**数据**(按本次运行核对,勿跨环境引用)
| 并发 | TPS | P95 | P99 | 错误率 | CPU |
|---|---|---|---|---|---|
| ... | ... | ... | ... | ... | ... |
**瓶颈证据**
- 线程 dump 显示 N 个线程阻塞在连接获取
- HikariCP 指标:pending 峰值 N
**改进项**
1. 连接池上限 50 → 80(需同步评估 DB 侧)
2. 慢查询 order.findByUser 加索引
报告里必须写"不可迁移的部分":哪些是桩、数据量差多少、机器规格差多少。否则下一个人会把这些数字当生产容量用。
9. 排错:七种"压不上去"的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| TPS 不随线程数增长 | 压测机自身成瓶颈 | 看压测机 CPU、sar -n DEV |
| 一开始就很慢 | 没热身,连接池/缓存空 | 加预热阶段 |
| 延迟低但 TPS 上不去 | 客户端串行等待(同步阻塞) | 检查脚本是否有串行逻辑 |
| 每隔几秒出现尖刺 | Full GC | 开 -Xlog:gc* 对齐时间轴 |
| 错误率随并发突增 | 端口耗尽或连接池满 | 调 net.ipv4.ip_local_port_range |
| 结果不可复现 | 数据被压测修改、缓存状态不同 | 每次压测前重置数据 |
| JMeter 报 OOM | 保存了全部响应数据 | 关掉 save response,只存统计 |
第一条最常见:单机 wrk 打满时 CPU 100%,此时被测服务的 TPS 曲线是平的——那不是服务上限,是压测机上限。压测机至少要留 30% 余量。
10. 边界:什么时候压测给不出答案
| 场景 | 更该用 | 理由 |
|---|---|---|
| 单次慢请求归因 | 分布式追踪 | 压测只有聚合,没有单次链路 |
| 内存泄漏 | 长时间浸泡测试 + 堆分析 | 短压测看不出来 |
| 容量规划 | 压测 + 生产指标外推 | 压测环境终归是近似 |
| 第三方依赖性能 | 对方 SLA + 降级演练 | 你压不动别人的服务 |
| 突发流量形态 | 尖峰/脉冲式压测 | 稳态压测不覆盖 |
| 前端性能 | 浏览器性能面板 | 与服务端压测是两码事 |
一句话收口:压测回答"能扛多少",不回答"为什么慢"——后者要交给追踪和剖析。
11. 自测
- 为什么 JMeter 的线程数不等于 TPS?
- 阶梯加压时,每档之间为什么要留恢复时间?
- 99 个 10ms 加 1 个 10s 的样本,平均值和 P99 各说明什么?
- 压测结果出现错误率后,为什么不能拿它的延迟数字谈容量?
- 压测机 CPU 打满时,TPS 曲线意味着什么?
- 压测报告里为什么必须写明"不可迁移的部分"?