上一节《JMeter 快速入门》讲了工具怎么用,这篇是实战记录:对一台部署在云服务器上的管理后台(前端 + 后端 API)做了一次完整压测——从定目标、设计场景、跑梯度,到从数据里把瓶颈挖出来。所有数据都是真实跑出来的,瓶颈发现的部分比“怎么压”更值得看。
一、背景与压测目标
被测系统:一台 2 核云服务器,nginx 托管前端静态资源,反向代理后面的 Java 后端(Spring Boot + MySQL)。日常访问没问题,但“没问题”不等于“知道上限”——压测就是要回答三个问题:
| 目标 | 对应指标 | 怎么算达标 |
|---|---|---|
| 吞吐量上限 | QPS(每秒请求数,autocannon 的输出里叫 RPS,两者等价) | 找到吞吐不再增长的拐点 |
| 响应快不快 | 平均响应时间、P99 | P99 < 1s(管理后台可接受) |
| 稳不稳 | 错误率、超时数 | 错误率 < 0.1% |
其中 P99(99% 的请求快于这个时间)比平均值更关键——平均值 38ms 很好看,P99 若是 1.8s,那 1% 的用户(高峰期可能就是几百人)体验是“卡死了”。
二、测试方案
场景设计:贴近真实用户行为
不拿接口列表乱压,而是沿着用户真实路径选三个场景,覆盖三层:
| 场景 | 请求 | 覆盖层 | 模拟的用户行为 |
|---|---|---|---|
| A 静态页 | GET /dashboard/analytics | nginx 静态资源 | 打开页面 |
| B 租户列表 API | GET /prod-api/auth/tenant/list | nginx → 应用 → MySQL | 登录页加载租户下拉 |
| C 验证码生成 API | GET /prod-api/auth/code | nginx → 应用 → 图片生成 + Redis 写入 | 登录页出验证码 |
三个场景全是 GET 只读,不产生脏数据;且都是免认证接口,不需要处理登录态(需要登录态的业务接口怎么压,见文末 FAQ)。
工具选型
| 工具 | 特点 | 适合 |
|---|---|---|
| JMeter | GUI 配置、生态全、Java 系 | 复杂场景编排、企业既有资产 |
| k6 | 单二进制、JS 写脚本、云原生 | CI 集成、团队协作 |
| autocannon | Node 一装就用、输出 JSON 好分析 | 快速单接口压测、结果可编程处理 |
这次选 autocannon:目标就是几个 GET 接口的梯度压测,没必要起 JMeter 的 GUI;JSON 结果直接喂给脚本算带宽,分析链路最短。JMeter 更合适“几十个接口 + 登录态 + 参数化”的复杂编排(用法见快速入门)。
关键配置
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 ...三个容易踩的点:
- connections 就是并发数——autocannon 默认 10,忘了改,测出来的“压测”其实是温吞水
- duration 至少 15s——前几秒有 TLS 握手、连接建立的热身期,太短 P99 失真
- autocannon 默认不发
Accept-Encoding: gzip——浏览器会协商压缩,压测工具默认不会。这次正好用它做了一组 gzip 对照实验(见第六节)
压测纪律:对线上系统要克制
- 梯度从低到高(10 → 30 → 50 并发),每档 20s,档间休息 10s 给系统喘息
- 出现错误率飙升立即停,不加码
- 这一轮只压 GET 只读,写接口不碰(线上写压测怎么做才安全,见第七节续章)
- 避开业务高峰(这次是周日上午跑的)
三、执行过程
每个场景跑三档并发,记录 QPS、平均延迟、P99、错误率:
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 | 错误 | 吞吐 |
|---|---|---|---|---|---|
| 5 | 158 | 31ms | 47ms | 0 | 7.0 Mbps |
| 10 | 261 | 38ms | 172ms | 0 | 11.6 Mbps |
| 30 | 260 | 114ms | 861ms | 0 | 11.5 Mbps |
| 50 | 259 | 184ms | 1831ms | 1 | 11.5 Mbps |
场景 B:租户列表 API
| 并发 | QPS | 平均延迟 | P99 | 错误 | 吞吐 |
|---|---|---|---|---|---|
| 10 | 283 | 35ms | 51ms | 0 | 0.74 Mbps |
| 30 | 897 | 33ms | 47ms | 0 | 2.3 Mbps |
| 50 | 1330 | 37ms | 63ms | 0 | 3.5 Mbps |
场景 C:验证码生成 API
| 并发 | QPS | 平均延迟 | P99 | 错误 |
|---|---|---|---|---|
| 10 | 287 | 34ms | 50ms | 0 |
| 30 | 866 | 34ms | 49ms | 0 |
| 50 | 1261 | 39ms | 120ms | 0 |
四、数据分析:两条完全不同的曲线
把三组数据放在一起,形态差异一眼可见:
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,开始有轻微征兆,但离饱和还远。
五、瓶颈发现:三个判定手法
从这次数据里提炼三个“一眼看穿瓶颈”的手法:
- 吞吐钉死 + 延迟线性涨 → 瓶颈是某个固定容量的资源。看哪个指标钉死了:带宽钉了是网络,CPU 钉了是算力,连接数钉了是中间件配置
- 吞吐随并发线性涨 → 还没到瓶颈,可以继续加压找拐点(这次出于克制没往上加)
- P99 先于平均值恶化 → 排队效应。平均 38ms 时 P99 已经 172ms,说明尾部请求已经在等——看 P99 比看平均值早半步发现瓶颈
还有一个反直觉的点:静态页是“最轻”的请求,却最先碰壁——因为它响应体最大。瓶颈从来不是由“接口复杂度”决定的,是由“每字节消耗的稀缺资源”决定的。
六、优化建议(含实验验证)
① 静态资源上 CDN(收益最大):静态流量卸给 CDN 后,源站带宽瓶颈直接消失,这正是各家云厂商 CDN 的核心卖点。这个系统所有 CSS/JS/图片也走同一个带宽,CDN 化收益是全局的。
② 开 gzip 压缩(本次做了对照实验):同端点同 30 并发,带上 Accept-Encoding: gzip 再压一次:
| 未压缩 | gzip | 变化 | |
|---|---|---|---|
| QPS | 260 | 436 | +67% |
| 平均延迟 | 114ms | 68ms | -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 复制 Authorization 和 clientid 两个请求头,直接塞进 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 | 错误 |
|---|---|---|---|---|---|
| 资产分页列表 | 10 | 53 | 190ms | 332ms | 0 |
| 资产分页列表 | 30 | 65 | 459ms | 741ms | 0 |
| 资产分页列表 | 50 | 75 | 658ms | 1107ms | 0 |
| 工作台聚合 | 10 | 19 | 523ms | 871ms | 0 |
| 工作台聚合 | 30 | 20 | 1464ms | 1678ms | 0 |
| 分析页概览 | 10 | 52 | 192ms | 318ms | 0 |
| 分析页概览 | 30 | 52 | 573ms | 901ms | 0 |
| 写·新增公告 | 5 | 37 | 136ms | 293ms | 0 |
| 写·新增公告 | 10 | 37.9 | 266ms | 454ms | 0 |
说明:写接口的数字是业务成功口径——压测工具只能统计 HTTP 状态码,而这类接口被拦截时同样返回 HTTP 200(业务码 500),必须逐条核对响应体里的业务码才算数。第一版脚本测出的“270 QPS”其实是防重拦截的速度,不是 INSERT 的速度。
发现一:瓶颈在业务 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 陷阱》。
三条教训:
- 压测必须核对落库量:请求发出数 ≠ 业务成功数,压完用业务口径(库里多了几条)对账,对不上就是假数据
- 带业务码的系统要逐条核响应体:HTTP 200 只代表“网关收到了”,不代表“业务办成了”
- 压测工具的 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 详解》看测试环境怎么起真实中间件
