200并发时P99响应时间从26ms暴涨到677ms,涨了26倍,怎么定位的?
摘要:首页三个接口,50 并发时 P99 都在 17ms 以内,100 并发也稳。但到 200 并发,商品列表 P99 从 26ms 涨到 677ms,绝对值进入不可接受区间;同时整体吞吐量的增长开始放缓。同一个系统、同一档并发,为什么单单它先扛不住? 这篇文章记录用 JMeter 梯度压测定位这个性能拐点的完整过程。
前言:性能测试不是“跑个数字”。单点压测看不出趋势,梯度压测才能发现拐点。很多人跑一遍 50 并发、看到 0% 错误率就收工,其实真正的故事在“再往上一档”。
一、为什么要做梯度压测
很多人做性能测试是这样的:
- JMeter 打开
- 加线程组,50 并发
- 跑一遍,看结果
- “TPS 1000,不错”,收工
这样做的致命问题:你看不到“趋势”。
性能测试真正的价值在“发现拐点”——
- 并发从 50 → 100,吞吐量线性增长 → 系统正常
- 并发从 100 → 200,吞吐量增长放缓 + P99 暴涨 → 有瓶颈
判断拐点的标准不是“响应时间变慢”,而是“吞吐量不再线性增长”。 这一点想清楚,压测才有意义。
二、测试对象:首页 3 个接口
选最典型也最常用的接口做对比:
| 接口 | 路径 | SQL 复杂度 |
|---|---|---|
| 商品列表 | /prod/prodListByTagId?tagId=1&size=10 | 多表 JOIN |
| 商品标签 | /prod/tag/prodTagList | 单表查询 |
| 轮播图 | /indexImgs | 单表查询 |
第 1 个是复杂查询,后两个是简单查询——形成对照组。
三、JMeter 脚本设计
线程组配置:
| 参数 | 值 |
|---|---|
| Number of Threads | 50 / 100 / 200(三档) |
| Ramp-Up Period | 10-20 秒 |
| Loop Count | 10 |
三档梯度:
- 50 并发 × 10 轮 = 1500 请求
- 100 并发 × 10 轮 = 3000 请求
- 200 并发 × 10 轮 = 6000 请求
测试说明:三档都是同时对首页三个接口压测(一个线程组里放 3 个 HTTP Request),所以表格里的吞吐量是三个接口的合计值。每档独立跑,独立记录数据;三档连续跑,中间不重启后端,避免 JVM 冷启动干扰。
脚本位置:scripts/jmeter/mall4j_homepage.jmx
四、三档结果对比
测试严谨性前置说明:每一档压测前我都会先清空 JMeter 的历史数据,预热 30 秒再正式启动线程,避免前一档的请求残留影响下一档的统计结果,保证三档数据的对比基准完全一致。
第一档:50 并发
图 1:50 并发下 1500 请求,三个接口 P99 都在 20ms 以内。
| 接口 | 平均 | P95 | P99 | 错误率 | 吞吐量 |
|---|---|---|---|---|---|
| 商品列表 | 8ms | 11ms | 56ms | 0% | 50.8/s |
| 商品标签 | 2ms | 3ms | 12ms | 0% | 51.6/s |
| 轮播图 | 2ms | 3ms | 6ms | 0% | 51.7/s |
| 合计 | 4ms | 9ms | 17ms | 0% | 152/s |
结论:非常稳,三个接口 P99 都在 20ms 以内。
第二档:100 并发
图 2:100 并发下吞吐量翻倍到 298/s,商品列表 P99 反而降到 26ms(JVM 预热完成)。
| 接口 | 平均 | P95 | P99 | 错误率 | 吞吐量 |
|---|---|---|---|---|---|
| 商品列表 | 10ms | 15ms | 26ms | 0% | 99.6/s |
| 商品标签 | 2ms | 4ms | 6ms | 0% | 100/s |
| 轮播图 | 2ms | 4ms | 6ms | 0% | 100/s |
| 合计 | 5ms | 12ms | 16ms | 0% | 298/s |
结论:依然很稳,吞吐量几乎翻倍(152 → 298)。
第三档:200 并发(关键)
图 3:200 并发下,商品列表 P99 高达 677ms,是另外两个接口的 6 倍多。
| 接口 | 平均 | P95 | P99 | 最大 | 错误率 |
|---|---|---|---|---|---|
| 商品列表 | 193ms | 497ms | 677ms | 783ms | 0% |
| 商品标签 | 19ms | 48ms | 104ms | 268ms | 0% |
| 轮播图 | 20ms | 49ms | 109ms | 245ms | 0% |
| 合计 | 77ms | 278ms | 608ms | 783ms | 0% |
结论:系统整体承压,商品列表绝对值失控,吞吐量增长放缓(497/s)。
关于“50 并发 P99 比 100 并发还高”的解释
眼尖的读者会发现:商品列表 50 并发 P99 = 56ms,100 并发反而降到 26ms。 这不符合“并发越高响应越慢”的直觉。
原因是 JVM 预热和连接池初始化。
- 第一档(50 并发)运行时,JVM 还在解释执行字节码,JIT 没完全编译
- HikariCP 连接池也在建立连接,还没打满
- MySQL 的 Buffer Pool 还没加载数据页(冷启动时都在磁盘上)
所以第一档有冷启动抖动,第二档反而更稳。
这也是梯度压测“要连续跑,不要每档重启服务”的原因——重启一次,前面的预热都白做了。
五、拐点信号:吞吐量增长放缓 + P99 暴涨
把三档放在一起看:
| 并发档 | 合计吞吐量 | 相比上一档 | 商品列表 P99 |
|---|---|---|---|
| 50 | 152/s | — | 56ms |
| 100 | 298/s | +96%(≈翻倍) | 26ms |
| 200 | 497/s | +67%(翻倍不到) | 677ms |
两个信号叠在一起:
- 吞吐量增长放缓 —— 50→100 是翻倍,100→200 只涨了 67%,说明系统开始“吃力”了
- 商品列表 P99 从 26ms 涨到 677ms —— 绝对值进入不可接受区间
如果只看响应时间,会误判“只是慢了点”。但结合吞吐量的变化,就能看出“系统在 200 并发附近开始承压”。
为什么单说“商品列表”有问题
严格来说,三个接口都劣化了,说明系统整体在承压。
但商品列表是唯一一个“绝对值失控”的——677ms 对比另外两个的 104ms / 109ms,差了 6 倍多。
| 接口 | 100 并发 P99 | 200 并发 P99 | 绝对值 |
|---|---|---|---|
| 商品列表 | 26ms | 677ms | ❌ 进入不可接受区间 |
| 商品标签 | 6ms | 104ms | ✅ 仍可接受 |
| 轮播图 | 6ms | 109ms | ✅ 仍可接受 |
单表查询还能靠缓存和简单执行计划撑住,多表 JOIN 在系统整体承压时最先扛不住。
“倍数”会误导人(6ms→104ms 涨 17 倍,听起来吓人,但绝对值可接受);“绝对值”才是真正的判断标准。
六、推测根因(未验证)
以下都是基于 SQL 结构的推测,还没验证。我习惯先列假设,再用 EXPLAIN 和慢查询日志逐个排除,不靠猜下结论。
商品列表的 SQL 大概是:
SELECT ...
FROM tz_prod p
JOIN tz_sku s ON p.prod_id = s.prod_id
JOIN tz_shop_detail sd ON p.shop_id = sd.shop_id
WHERE ...
ORDER BY ...
三表 JOIN,tz_sku 是一对多(一个商品有多个 SKU)。
可能的根因:
| 假设 | 验证方式 |
|---|---|
缺少联合索引 (prod_id, status) | EXPLAIN 看 type 和 key |
| N+1 查询(循环查 SKU) | 后端日志里 SQL 调用次数 |
| 连接池不足 | 后端日志里 HikariPool 告警 |
ORDER BY 无索引导致 filesort | EXPLAIN 看 Extra 列 |
我本地环境的真实小背景:我本地部署的 Mall4j 默认没有给多表关联字段加联合索引,这也是这次商品列表接口最先扛不住的核心前置条件,很多刚部署完的开源电商系统都会遇到完全一样的问题。
下一步就是用 EXPLAIN 逐个排除。
七、为什么 0% 错误率更值得警惕
跑完 200 并发,所有请求都是 200 OK,错误率 0%。
看起来“没问题”。但数据告诉我们:
- 合计 P99 = 608ms(其中商品列表 P99 = 677ms),1% 的用户等了超过 600ms
- 吞吐量增长放缓 —— 系统没到极限,但已经在“吃力”
- 如果加到 300 并发,很可能就开始报错
性能测试要看的不是“崩没崩”,是“离崩还有多远”。
“合计 P99 = 608ms” 这个数字在生产环境是不可接受的。
八、从这次压测学到的
1. 拐点的判断标准是“吞吐量”,不是“响应时间”
只看响应时间会误判“慢了点”。吞吐量不再线性增长,才是真正的信号。
2. 对比不同接口,找差异化瓶颈
同系统内不同接口的对比,比“单接口绝对数字”更能说明问题。单表查询能撑住,多表 JOIN 撑不住——问题就在那个 JOIN 上。
3. 倍数会误导人,绝对值才是标准
6ms → 104ms 涨 17 倍,听起来吓人,但绝对值可接受。判断严重性,看绝对数。
4. 0% 错误率不等于没问题
看性能趋势,不是看“是否崩溃”。
5. 第一档数据要小心解释
冷启动抖动会让第一档“看起来比第二档还慢”。梯度压测要连续跑,不重启服务。
九、JMeter 脚本核心配置与避坑
JMX 文件已开源,核心配置:
<ThreadGroup>
<intProp name="ThreadGroup.num_threads">200</intProp>
<intProp name="ThreadGroup.ramp_time">10</intProp>
<elementProp name="ThreadGroup.main_controller">
<stringProp name="LoopController.loops">10</stringProp>
</elementProp>
</ThreadGroup>
⚠️ 新手必踩坑:监听器会拖慢 JMeter 自己
我第一次跑 200 并发时,GUI 模式开了全量监听器,JMeter 直接 OOM 卡死,我还以为是后端服务崩了,排查了半小时才发现是压测工具自己先成了瓶颈。
原因:View Results in Table 和 Graph Results 这两个监听器会把每一条请求的详细数据缓存在 JMeter 内存里,200 并发 6000 请求直接爆内存,测出来的响应时间严重失真。
正确做法:
- 压测时只开
Aggregate Report(只记录汇总数据,不占内存) - 调试期才开
View Results in Table(看请求明细) - 最稳妥:直接用命令行非 GUI 模式生成
.jtl结果文件,跑完再导入 Aggregate Report 看汇总,压测结果立刻就准确了
命令示例:
jmeter -n -t mall4j_homepage.jmx -l result.jtl
十、下一步优化方向
已经定位到拐点,接下来是优化:
- 分析
prodListByTagId的EXPLAIN执行计划 —— 看扫了多少行、有没有走索引 - 检查 HikariCP 连接池配置 —— 我本地调到了 20,200 并发下可能仍不够
- 给相关表补索引 ——
tz_sku(prod_id)、tz_prod(shop_id, status) - 重新压测对比 —— 看 P99 和吞吐量能不能恢复线性
- 这也是从“性能测试”到“性能优化”的一步
十一、系列文章
这是 Mall4j 自动化测试系列的第 4 篇:
- 第 1 篇:一个 Emoji 引发的排查:MySQL utf8mb3 导致接口返回异常MySQL
- 第 2 篇:一个缺失的状态校验,让 Mall4j 的订单系统开了 4 个后门
- 第 3 篇:双端电商系统的测试差异分析——别让“只测一端”漏掉80%的联动缺陷)
- 第 4 篇:一次JMeter压测踩坑:我差点以为200并发系统没崩(本文)
- 第 5 篇:从 Mall4j 的 3 个角色,聊聊真实项目需要几种角色
源码已开源:GitHub 仓库
免责声明:本文结论基于本地部署的某个 Mall4j 版本,不同版本可能已修复。文中 JMeter 数据基于本地环境,TPS 和响应时间不代表生产环境,仅用于展示“梯度压测找拐点”的方法。开源项目维护不易,理性反馈。
本项目为测试工程,不包含 Mall4j 源码。Mall4j 遵循 AGPL-3.0 协议,如需商用请联系原作者获取授权。
相关链接
你做性能测试时,发现过什么样的“拐点”?是哪个接口先扛不住的?评论区聊聊。