一次JMeter压测踩坑:我差点以为200并发系统没崩

49 阅读10分钟

200并发时P99响应时间从26ms暴涨到677ms,涨了26倍,怎么定位的?

摘要:首页三个接口,50 并发时 P99 都在 17ms 以内,100 并发也稳。但到 200 并发,商品列表 P99 从 26ms 涨到 677ms,绝对值进入不可接受区间;同时整体吞吐量的增长开始放缓。同一个系统、同一档并发,为什么单单它先扛不住? 这篇文章记录用 JMeter 梯度压测定位这个性能拐点的完整过程。

前言:性能测试不是“跑个数字”。单点压测看不出趋势,梯度压测才能发现拐点。很多人跑一遍 50 并发、看到 0% 错误率就收工,其实真正的故事在“再往上一档”。


一、为什么要做梯度压测

很多人做性能测试是这样的:

  1. JMeter 打开
  2. 加线程组,50 并发
  3. 跑一遍,看结果
  4. “TPS 1000,不错”,收工

这样做的致命问题:你看不到“趋势”。

性能测试真正的价值在“发现拐点”——

  • 并发从 50 → 100,吞吐量线性增长 → 系统正常
  • 并发从 100 → 200,吞吐量增长放缓 + P99 暴涨 → 有瓶颈

判断拐点的标准不是“响应时间变慢”,而是“吞吐量不再线性增长”。 这一点想清楚,压测才有意义。


二、测试对象:首页 3 个接口

选最典型也最常用的接口做对比:

接口路径SQL 复杂度
商品列表/prod/prodListByTagId?tagId=1&size=10多表 JOIN
商品标签/prod/tag/prodTagList单表查询
轮播图/indexImgs单表查询

第 1 个是复杂查询,后两个是简单查询——形成对照组。


三、JMeter 脚本设计

线程组配置:

参数值
Number of Threads50 / 100 / 200(三档)
Ramp-Up Period10-20 秒
Loop Count10

三档梯度:

  • 50 并发 × 10 轮 = 1500 请求
  • 100 并发 × 10 轮 = 3000 请求
  • 200 并发 × 10 轮 = 6000 请求

测试说明:三档都是同时对首页三个接口压测(一个线程组里放 3 个 HTTP Request),所以表格里的吞吐量是三个接口的合计值。每档独立跑,独立记录数据;三档连续跑,中间不重启后端,避免 JVM 冷启动干扰。

脚本位置:scripts/jmeter/mall4j_homepage.jmx


四、三档结果对比

测试严谨性前置说明:每一档压测前我都会先清空 JMeter 的历史数据,预热 30 秒再正式启动线程,避免前一档的请求残留影响下一档的统计结果,保证三档数据的对比基准完全一致。

第一档:50 并发

image.png 图 1:50 并发下 1500 请求,三个接口 P99 都在 20ms 以内。

接口平均P95P99错误率吞吐量
商品列表8ms11ms56ms0%50.8/s
商品标签2ms3ms12ms0%51.6/s
轮播图2ms3ms6ms0%51.7/s
合计4ms9ms17ms0%152/s

结论:非常稳,三个接口 P99 都在 20ms 以内。

第二档:100 并发

image.png

图 2:100 并发下吞吐量翻倍到 298/s,商品列表 P99 反而降到 26ms(JVM 预热完成)。

接口平均P95P99错误率吞吐量
商品列表10ms15ms26ms0%99.6/s
商品标签2ms4ms6ms0%100/s
轮播图2ms4ms6ms0%100/s
合计5ms12ms16ms0%298/s

结论:依然很稳,吞吐量几乎翻倍(152 → 298)。

第三档:200 并发(关键)

image.png

图 3:200 并发下,商品列表 P99 高达 677ms,是另外两个接口的 6 倍多。

接口平均P95P99最大错误率
商品列表193ms497ms677ms783ms0%
商品标签19ms48ms104ms268ms0%
轮播图20ms49ms109ms245ms0%
合计77ms278ms608ms783ms0%

结论:系统整体承压,商品列表绝对值失控,吞吐量增长放缓(497/s)。

关于“50 并发 P99 比 100 并发还高”的解释

眼尖的读者会发现:商品列表 50 并发 P99 = 56ms,100 并发反而降到 26ms。 这不符合“并发越高响应越慢”的直觉。

原因是 JVM 预热和连接池初始化。

  • 第一档(50 并发)运行时,JVM 还在解释执行字节码,JIT 没完全编译
  • HikariCP 连接池也在建立连接,还没打满
  • MySQL 的 Buffer Pool 还没加载数据页(冷启动时都在磁盘上)

所以第一档有冷启动抖动,第二档反而更稳。

这也是梯度压测“要连续跑,不要每档重启服务”的原因——重启一次,前面的预热都白做了。


五、拐点信号:吞吐量增长放缓 + P99 暴涨

把三档放在一起看:

并发档合计吞吐量相比上一档商品列表 P99
50152/s—56ms
100298/s+96%(≈翻倍)26ms
200497/s+67%(翻倍不到)677ms

两个信号叠在一起:

  1. 吞吐量增长放缓 —— 50→100 是翻倍,100→200 只涨了 67%,说明系统开始“吃力”了
  2. 商品列表 P99 从 26ms 涨到 677ms —— 绝对值进入不可接受区间

如果只看响应时间,会误判“只是慢了点”。但结合吞吐量的变化,就能看出“系统在 200 并发附近开始承压”。

为什么单说“商品列表”有问题

严格来说,三个接口都劣化了,说明系统整体在承压。

但商品列表是唯一一个“绝对值失控”的——677ms 对比另外两个的 104ms / 109ms,差了 6 倍多。

接口100 并发 P99200 并发 P99绝对值
商品列表26ms677ms❌ 进入不可接受区间
商品标签6ms104ms✅ 仍可接受
轮播图6ms109ms✅ 仍可接受

单表查询还能靠缓存和简单执行计划撑住,多表 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 无索引导致 filesortEXPLAIN 看 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

十、下一步优化方向

已经定位到拐点,接下来是优化:

  1. 分析 prodListByTagId 的 EXPLAIN 执行计划 —— 看扫了多少行、有没有走索引
  2. 检查 HikariCP 连接池配置 —— 我本地调到了 20,200 并发下可能仍不够
  3. 给相关表补索引 —— tz_sku(prod_id)、tz_prod(shop_id, status)
  4. 重新压测对比 —— 看 P99 和吞吐量能不能恢复线性
  5. 这也是从“性能测试”到“性能优化”的一步

十一、系列文章

这是 Mall4j 自动化测试系列的第 4 篇:

源码已开源:GitHub 仓库


免责声明:本文结论基于本地部署的某个 Mall4j 版本,不同版本可能已修复。文中 JMeter 数据基于本地环境,TPS 和响应时间不代表生产环境,仅用于展示“梯度压测找拐点”的方法。开源项目维护不易,理性反馈。

本项目为测试工程,不包含 Mall4j 源码。Mall4j 遵循 AGPL-3.0 协议,如需商用请联系原作者获取授权。


相关链接


你做性能测试时,发现过什么样的“拐点”?是哪个接口先扛不住的?评论区聊聊。