Skip to content

上一节《JMeter 快速入门》讲了工具怎么用,这篇是实战记录:对一台部署在云服务器上的管理后台(前端 + 后端 API)做了一次完整压测——从定目标、设计场景、跑梯度,到从数据里把瓶颈挖出来。所有数据都是真实跑出来的,瓶颈发现的部分比“怎么压”更值得看。

一、背景与压测目标

被测系统:一台 2 核云服务器,nginx 托管前端静态资源,反向代理后面的 Java 后端(Spring Boot + MySQL)。日常访问没问题,但“没问题”不等于“知道上限”——压测就是要回答三个问题:

目标对应指标怎么算达标
吞吐量上限QPS(每秒请求数,autocannon 的输出里叫 RPS,两者等价)找到吞吐不再增长的拐点
响应快不快平均响应时间、P99P99 < 1s(管理后台可接受)
稳不稳错误率、超时数错误率 < 0.1%

其中 P99(99% 的请求快于这个时间)比平均值更关键——平均值 38ms 很好看,P99 若是 1.8s,那 1% 的用户(高峰期可能就是几百人)体验是“卡死了”。

二、测试方案

场景设计:贴近真实用户行为

不拿接口列表乱压,而是沿着用户真实路径选三个场景,覆盖三层:

场景请求覆盖层模拟的用户行为
A 静态页GET /dashboard/analyticsnginx 静态资源打开页面
B 租户列表 APIGET /prod-api/auth/tenant/listnginx → 应用 → MySQL登录页加载租户下拉
C 验证码生成 APIGET /prod-api/auth/codenginx → 应用 → 图片生成 + Redis 写入登录页出验证码

三个场景全是 GET 只读,不产生脏数据;且都是免认证接口,不需要处理登录态(需要登录态的业务接口怎么压,见文末 FAQ)。

工具选型

工具特点适合
JMeterGUI 配置、生态全、Java 系复杂场景编排、企业既有资产
k6单二进制、JS 写脚本、云原生CI 集成、团队协作
autocannonNode 一装就用、输出 JSON 好分析快速单接口压测、结果可编程处理

这次选 autocannon:目标就是几个 GET 接口的梯度压测,没必要起 JMeter 的 GUI;JSON 结果直接喂给脚本算带宽,分析链路最短。JMeter 更合适“几十个接口 + 登录态 + 参数化”的复杂编排(用法见快速入门)。

关键配置

js
import autocannon from 'autocannon';

const result = await autocannon({
  url: 'https://your-server/dashboard/analytics',
  method: 'GET',
  connections: 30,   // 并发数 = Keep-Alive 连接数 ≈ 模拟的并发用户
  duration: 20,      // 持续 20 秒(太短数据抖动大,太长对线上压力大)
  timeout: 10,       // 单请求 10s 超时
});
// result.latency.p99 / result.requests.average / result.errors ...

三个容易踩的点:

  1. connections 就是并发数——autocannon 默认 10,忘了改,测出来的“压测”其实是温吞水
  2. duration 至少 15s——前几秒有 TLS 握手、连接建立的热身期,太短 P99 失真
  3. autocannon 默认不发 Accept-Encoding: gzip——浏览器会协商压缩,压测工具默认不会。这次正好用它做了一组 gzip 对照实验(见第六节)

压测纪律:对线上系统要克制

  • 梯度从低到高(10 → 30 → 50 并发),每档 20s,档间休息 10s 给系统喘息
  • 出现错误率飙升立即停,不加码
  • 这一轮只压 GET 只读,写接口不碰(线上写压测怎么做才安全,见第七节续章)
  • 避开业务高峰(这次是周日上午跑的)

三、执行过程

每个场景跑三档并发,记录 QPS、平均延迟、P99、错误率:

bash
node run-load.mjs static 10 20   # 场景A,10并发,20秒
node run-load.mjs static 30 20
node run-load.mjs static 50 20
# 场景B、C 同理

场景 A:静态页(未压缩)

并发QPS平均延迟P99错误吞吐
515831ms47ms07.0 Mbps
1026138ms172ms011.6 Mbps
30260114ms861ms011.5 Mbps
50259184ms1831ms111.5 Mbps

场景 B:租户列表 API

并发QPS平均延迟P99错误吞吐
1028335ms51ms00.74 Mbps
3089733ms47ms02.3 Mbps
50133037ms63ms03.5 Mbps

场景 C:验证码生成 API

并发QPS平均延迟P99错误
1028734ms50ms0
3086634ms49ms0
50126139ms120ms0

静态页压测:吞吐钉死、延迟暴涨

四、数据分析:两条完全不同的曲线

把三组数据放在一起,形态差异一眼可见:

API 场景(B/C):QPS 随并发线性增长(283 → 897 → 1330),延迟几乎不动(33-39ms),P99 稳定在 50-120ms。这是系统还有余量的健康形态——加并发,多干活,不磨蹭。

静态页场景(A):10 并发时 QPS 261,30 并发还是 260,50 并发 259——并发翻了五倍,吞吐一动不动;而平均延迟从 38ms 涨到 184ms,P99 从 172ms 暴涨到 1831ms。这是瓶颈已饱和的典型形态:系统每秒只能处理这么多,多出来的请求全在排队,排得越久延迟越高。

为什么饱和的是静态页?

线索在“吞吐”列:三档并发的带宽全部钉死在 ≈11.5 Mbps。这不是巧合,算一笔账:

261 QPS × 5.3KB(index.html) ≈ 1.4 MB/s ≈ 11.3 Mbps

和实测值严丝合缝。结论:静态页的瓶颈是服务器公网带宽,不是 nginx,更不是应用。API 场景响应体只有几百字节,50 并发才用 3.5 Mbps,带宽远没满,所以一路线性涨。

再验证一件事就能闭环:API 场景的 QPS 随并发线性涨到 50 并发还没停,说明应用 + 数据库都还有余量;验证码接口(C)在 50 并发时 P99 从 49 抬到 120ms——图片生成吃 CPU,开始有轻微征兆,但离饱和还远。

五、瓶颈发现:三个判定手法

从这次数据里提炼三个“一眼看穿瓶颈”的手法:

  1. 吞吐钉死 + 延迟线性涨 → 瓶颈是某个固定容量的资源。看哪个指标钉死了:带宽钉了是网络,CPU 钉了是算力,连接数钉了是中间件配置
  2. 吞吐随并发线性涨 → 还没到瓶颈,可以继续加压找拐点(这次出于克制没往上加)
  3. P99 先于平均值恶化 → 排队效应。平均 38ms 时 P99 已经 172ms,说明尾部请求已经在等——看 P99 比看平均值早半步发现瓶颈

还有一个反直觉的点:静态页是“最轻”的请求,却最先碰壁——因为它响应体最大。瓶颈从来不是由“接口复杂度”决定的,是由“每字节消耗的稀缺资源”决定的。

六、优化建议(含实验验证)

① 静态资源上 CDN(收益最大):静态流量卸给 CDN 后,源站带宽瓶颈直接消失,这正是各家云厂商 CDN 的核心卖点。这个系统所有 CSS/JS/图片也走同一个带宽,CDN 化收益是全局的。

② 开 gzip 压缩(本次做了对照实验):同端点同 30 并发,带上 Accept-Encoding: gzip 再压一次:

未压缩gzip变化
QPS260436+67%
平均延迟114ms68ms-40%
带宽占用11.5 Mbps(打满)7.1 Mbps(未满)-38%

5.3KB 压到 1.7KB,同样的带宽能多走 67% 的请求。这个系统的 nginx 实际已开启 gzip(实测响应头 Content-Encoding: gzip)——但压测工具默认不发压缩协商头,所以表里“未压缩”列是压测客户端视角;真实浏览器拿到的是压缩后的响应。这也提醒我们:压测配置要尽量还原真实客户端行为,否则测出来的瓶颈可能是假的(这次假瓶颈反而引导出了对照实验,算是意外收获)。

gzip 之后带宽 7.1 Mbps 未打满、QPS 却停在 436——说明下一个瓶颈(TLS 握手开销 / 客户端机器)接棒了。优化是逐层剥洋葱的:拔掉一个瓶颈,下一个会浮出来,每层都要重新测。

③ API 侧暂不需要动作:50 并发、1330 QPS 时 P99 才 63ms,对一台 2C 机器的管理后台来说绰绰有余。等真实用户量上来了,优先加机器带宽而不是升配置——这次的数据证明了带宽先于算力成为瓶颈。

七、续章:带登录态压业务接口

第一轮压测全是免认证 GET,等于只摸了系统的"门面":鉴权链路、真实业务 SQL、写操作一个都没经过。这样的结论够不够客观?带着这个疑问做了第二轮——把 token 塞进请求头,压真实的业务接口。

方案:登录态 + 接口分级

登录态按 FAQ 里的方案:浏览器人工登录一次,F12 复制 Authorizationclientid 两个请求头,直接塞进 autocannon 的 headers,Sa-Token 有效期内即插即用。

接口按风险分两级:

级别接口说明
读·资产分页列表GET /asset/info/list?pageNum=1&pageSize=10最典型的列表页请求,带条件查询 + 分页
读·工作台聚合GET /asset/dashboard/workbench/data一次聚合待办、统计、趋势等多组数据
读·分析页概览GET /asset/dashboard/analytics/overview分类分布、占比统计,GROUP BY 重查询
写·新增公告POST /system/notice挑最简单的单表写入,并发 ≤10、每档 10s

写接口的安全控制:标题带固定前缀(如【压测清理】)+ 时间戳,压完可按前缀精确检索删除;并发压到最低档、时长减半,把造出的脏数据控制在可清理的量级。读压完不需要清理,写必须留清理路径——这是线上写压测的底线。

写接口还额外踩了一个坑:第一版脚本所有请求共用同一个标题,结果只落库了 5 条——被系统的防重复提交校验拦下了(同一参数短时间窗内只放行一次)。修正方式是每个请求用唯一参数(标题拼时间戳 + 序号)。这个坑反而成了本次压测最有价值的发现,见下文"发现三"。

数据:一个数量级的差距

场景并发QPS平均延迟P99错误
资产分页列表1053190ms332ms0
资产分页列表3065459ms741ms0
资产分页列表5075658ms1107ms0
工作台聚合1019523ms871ms0
工作台聚合30201464ms1678ms0
分析页概览1052192ms318ms0
分析页概览3052573ms901ms0
写·新增公告537136ms293ms0
写·新增公告1037.9266ms454ms0

说明:写接口的数字是业务成功口径——压测工具只能统计 HTTP 状态码,而这类接口被拦截时同样返回 HTTP 200(业务码 500),必须逐条核对响应体里的业务码才算数。第一版脚本测出的“270 QPS”其实是防重拦截的速度,不是 INSERT 的速度。

各接口 QPS 天花板对比

发现一:瓶颈在业务 SQL,不在架构

第一轮的结论是"应用 + 数据库都还有余量"——这次要修正:那个结论只在"轻查询"范围内成立。同一个系统、同一套鉴权和连接池,租户列表能扛 1330 QPS,资产分页列表只有 75,差了 18 倍。中间差的不是架构能力,是业务 SQL 的执行成本(多表关联 + 条件过滤 + COUNT 统计)。

所以更准确的说法是:基础设施有余量,业务查询才是真实的天花板。压测如果只压免认证的轻接口,会得到一个漂亮但虚假的安全感。

发现二:重聚合接口最先撞墙

工作台聚合接口,10 并发 19 QPS,30 并发还是 20——QPS 纹丝不动,平均延迟却从 523ms 翻到 1464ms。这就是第五节说的"吞吐钉死 + 延迟线性涨"形态,只不过这次钉死它的不是带宽,是慢查询:每个请求都要跑多组统计 SQL,数据库每秒只能消化 20 个。

这类"一进页面就调"的聚合接口,往往就是全站最先撑不住的地方——用户体感上"首页变卡了",根因大概率在这。

发现三:写压测的“假绿”陷阱——HTTP 200 ≠ 业务成功

写接口第一版结果非常漂亮:270 QPS、P99 53ms,“单表 INSERT 快过所有读接口”。直到压完去库里核对落库量——4034 个请求只落库了 5 条

真相:系统有防重复提交校验,同一参数短时间窗内只放行一次,后续请求被拦截时返回的是 HTTP 200 + 业务码 500。压测工具只看 HTTP 状态码,errors=0,于是一个“拒绝路径”的 QPS 被当成了“写入路径”的性能——数据全绿,结论全错。

修正后(每个请求唯一参数,绕开防重)的真实数据:38 QPS 就饱和,10 并发和 5 并发吞吐持平、延迟从 136ms 翻到 266ms。这个水位和工作台聚合(20 QPS)是同一档的——写路径上有事务、ID 生成、字段校验,单表写入并不便宜。这次"数据打假"的完整排查(源码定位防重逻辑、控制变量实验验证拦截规律、三代压测脚本对比)单独写成了《压测假绿排查:防重复提交拦截下的 QPS 陷阱》。

三条教训:

  1. 压测必须核对落库量:请求发出数 ≠ 业务成功数,压完用业务口径(库里多了几条)对账,对不上就是假数据
  2. 带业务码的系统要逐条核响应体:HTTP 200 只代表“网关收到了”,不代表“业务办成了”
  3. 压测工具的 errors=0 不等于零失败——工具的视野停在 HTTP 层,业务层的“绿”要自己验。这和写单测拒绝“假绿”是同一个道理

这轮的优化指向

  • 聚合接口上缓存:工作台/分析页这类统计数据,实时算不如定时预热(定时任务算好存 Redis,接口只读缓存),20 QPS 能变成数百 QPS
  • 盯紧列表 SQL:50 并发 P99 破 1s 的资产列表,优先检查关联查询的索引和 COUNT 语句
  • 压测要分层下钻:门面接口(免认证轻接口)→ 业务读 → 业务写,一层层压过去,瓶颈才会现形

八、FAQ

Q:线上系统有验证码,登录接口怎么压? 登录接口本身不做高强度压测(验证码校验逻辑 + 自动试密码有锁定风险)。需要登录态的业务接口:人工在浏览器登录一次,F12 复制 Authorization 头,塞进压测脚本(Sa-Token/JWT 有效期内够用),不要让脚本去破解验证码,也不要为压测临时关闭线上验证码开关。另外像这次的验证码生成接口 /auth/code 本身就是免认证的,天然适合压。实测效果见第七节续章。

Q:connections 设多大算“模拟 N 个用户”? autocannon 的 connections 是 Keep-Alive 长连接数,近似等于“同时挂着等待响应的用户数”。真实用户还有思考时间(点击间隔),所以 50 连接的压测强度其实高于 50 个真实用户——生产容量估算时要留这个余量。

Q:压测会不会把线上压挂? 会有影响,所以要有纪律:梯度加压、只压只读、避开高峰、见错即停。这次压测全程在线上业务可感知范围内(压测期间页面访问正常,压完延迟秒级恢复)。

小结

  • 压测先定三件事:目标指标(QPS/P99/错误率)、贴近用户行为的场景、克制的梯度纪律
  • 吞吐钉死 + 延迟线性涨 = 瓶颈饱和;吞吐和带宽对上账,就能定位瓶颈是网络而不是应用
  • gzip 是带宽受限场景最便宜的杠杆:一个响应头,QPS +67%
  • 免认证轻接口压出的"有余量"是假安全感:带登录态压业务接口,同一个系统 QPS 差出 18 倍,真实天花板在业务 SQL 里
  • 写压测必须对账落库量:HTTP 200 ≠ 业务成功,防重复提交能把"压测"悄悄变成"拦截测压",errors=0 也可能是假绿
  • 优化是剥洋葱:拔掉一个瓶颈,下一个浮出来,每层重新测
  • 下一篇可以看《JMeter 快速入门》补工具基础,或《Testcontainers 详解》看测试环境怎么起真实中间件