100 并发一压上去,创建评测的 P99 直接飙到 2 秒、比日常观测高出一倍——这是上线前两天第一次完整压测的结果,差点没敢发布。从那以后,性能测试从”上线前的仪式”变成了”每次发布前的硬门槛”。评测平台和普通 Web 应用不太一样,它的负载是”用户提交一个批次,后台同时调多个模型 API 跑几百条用例”,既有常规接口压力,又有流式响应和批量任务这种特殊链路。平台最终用”JMeter 压常规接口 + 自研脚本压流式 + 全链路监控 + 性能基线对比”三层方案,把”100 并发跑 1000 用例”这个目标场景测透了。下面讲清楚每一层怎么做、踩过什么坑。
一、性能指标定义
压测之前先定指标,不然测完不知道算不算过。平台把核心接口的 P99、错误率、并发目标写死成一张表,这就是验收的依据。
| 接口 | P99 目标 | 错误率 | 并发 |
|---|---|---|---|
| 登录 | < 500ms | < 0.1% | 50 |
| 创建评测 | < 1s | < 0.5% | 100 |
| SxS 流式 | 首字 < 500ms | < 1% | 50 |
| 批量提交 | < 2s | < 0.5% | 20 |
| WebSocket | 延迟 < 1s | 断连率 < 0.1% | 100 |
指标为什么这么定?我的原则是”指标跟着用户体验走,不跟着平均值走”。P99 代表 99% 的用户体验,平均值再好看,尾部的 1% 用户照样会卡到想骂人。错误率阈值也分接口设,流式接口允许 1% 以内,登录这种基础链路压到 0.1%,因为登录挂了对所有用户都是灾难。这些数字不是拍脑袋,是先按业务预期粗估,再根据几轮压测结果往回校准的。
二、测试环境
压测环境最怕”测试和生产的配置不一样”,测出来的数字没人敢信。平台的做法是压测环境完全复刻生产规格。
- 4 应用实例(与生产同);
- MySQL 主从(与生产同);
- Redis Cluster 6 节点;
- RabbitMQ 3 节点;
- 压测机独立(不影响被测)。
数据规模按生产 30% 准备(避免环境差异过大)。数据量这块容易偷懒,但真不能省:数据量 0 的时候所有查询都走索引,压测结果虚高,等生产上了几百万行,慢查询全冒出来。我们按生产 30% 造数,索引选择和真实场景基本一致,测出来的数字才有参考价值。
环境规格定成和生产一致,也经历过一段纠结。有段时间我们想过直接在生产环境压测,省一套机器,但风险摆在那里:压测流量会挤占真实用户的资源,万一触发告警,值班的人还分不清是业务流量还是测试流量。独立压测环境多花的机器钱,换来的是随便压、随便重启、随时造脏数据的自由。压测机单独放一台也是同样的道理,JMeter 本身吃 CPU,和被测服务挤在一起,两边都测不准。
三、JMeter 压测方案
选压测工具时我们对比过几款:ab 只能打单接口,登录态和业务链路都模拟不了;wrk 性能好但脚本能力弱,复杂场景写起来费劲;k6 用 JS 写脚本很现代,可团队当时没人熟,上手成本不低。JMeter 的 GUI 配脚本直观,分布式压测成熟,社区资料多,遇到问题一搜就有答案,综合下来定了它。
常规接口压测我们用 JMeter,脚本、配置、执行方式都有固定的套路。
1. 测试脚本
<!-- jmx/eval-create.jmx -->
<ThreadGroup>
<stringProp name="ThreadGroup.num_threads">100</stringProp>
<stringProp name="ThreadGroup.ramp_time">30</stringProp>
<stringProp name="ThreadGroup.duration">300</stringProp>
<HTTPSamplerProxy>
<stringProp name="HTTPSampler.domain">api.example.com</stringProp>
<stringProp name="HTTPSampler.path">/api/eval/session</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
</HTTPSamplerProxy>
<JSONPostProcessor>
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
</JSONPostProcessor>
<ResultCollector>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<success>true</success>
</value>
</objProp>
<stringProp name="filename">results.jtl</stringProp>
</ResultCollector>
</ThreadGroup>
这段脚本定义了一个 100 线程、30 秒爬坡、持续 5 分钟的压测任务,目标接口是创建评测。results.jtl 会记录每次请求的时间、延迟、成功与否,是后续分析 P99 和错误率的原始数据。脚本里每个参数都有讲究,下面拆开说。
2. 关键配置
- 100 线程 + 30s 渐进 = 模拟真实用户递增;
- 持续 5 分钟 = 跑过瞬时峰值看稳态;
- 禁用思考时间 = 压极限(真实用户有 1-3s 思考);
- 启用 keep-alive = 模拟真实 HTTP 连接。
渐进爬坡很关键:一上来 100 并发全压上去,系统会瞬间崩溃,但真实用户是陆续进来的。30 秒爬坡能观察系统在负载上升过程中的表现,还能看清哪一刻开始出现性能拐点。禁用思考时间是为了压极限,测的是系统扛得住的最大压力;如果连极限都能过,真实场景下留有余量。
3. 分布式压测
100 并发单机顶不住,用 JMeter 分布式:
压测机 A (50 线程)
压测机 B (50 线程)
↓
JMeter 汇总
↓
Grafana 看板
单台压测机开到 100 线程,JMeter 本身会先成为瓶颈,压测机 CPU 跑满,测出的延迟掺了压测机的噪声。拆成两台各 50 线程后,压测机资源充裕,测的是应用的真实表现。执行分布式压测按三步走:
- 每台压测机先启动
jmeter-server服务,等待接收任务; - Master 用
-R参数指定压测机列表,把测试计划分发下去并汇总结果; - 汇总数据导出后,导入 Grafana 和上次基线对比。
四、关键业务场景
评测平台有四个核心场景要分别压测,不能拿一个 JMeter 脚本走天下。
1. 创建评测
# 100 并发,每秒 10 个
jmeter -n -t eval-create.jmx -l results.jtl
观察:
- TPS(每秒事务数)目标 > 200;
- P99 响应时间 < 1s;
- 错误率 < 0.5%;
- MySQL QPS 不超过 5000;
- CPU 不超过 70%。
创建评测是链路比较长的接口:写批次、写任务、发 MQ、返回给前端,任何一个环节慢了都会拉高 P99。压这个接口时我习惯同时盯 MySQL 的 QPS 和 MQ 堆积量,光看接口延迟容易漏掉”数据库快被打爆”这种隐藏问题。
2. SxS 流式响应
流式响应压测要专门工具:
# sxs-stream-bench.py
import asyncio
import aiohttp
import time
async def call_once(session, i):
async with session.post(URL, json=REQ) as resp:
first_byte = None
chunks = 0
async for chunk in resp.content.iter_any():
if first_byte is None:
first_byte = time.time()
chunks += 1
last_byte = time.time()
return first_byte, last_byte, chunks
async def main():
async with aiohttp.ClientSession() as session:
tasks = [call_once(session, i) for i in range(50)]
results = await asyncio.gather(*tasks)
first_bytes = [r[0] for r in results]
durations = [r[1] - r[0] for r in results]
print(f"首字延迟 P99: {sorted(first_bytes)[49] - START:.3f}s")
print(f"总耗时 P99: {sorted(durations)[49]:.3f}s")
JMeter 测流式接口不顺手,因为它默认等完整响应才记录时间,首字延迟这种指标抓不到。这个脚本用 aiohttp 逐块读响应,记录”第一个字节到达时间”和”完整读完时间”,就能分别评估首字延迟和总耗时。压流式接口时我踩过的坑是代理缓冲:Nginx 默认缓冲 SSE,首字延迟会被拉到几十秒,压测前必须先关掉 proxy_buffering。
3. 批量任务
1000 个用例 × 5 模型 = 5000 子任务跑完的时间:
目标:30 分钟内完成 95% 子任务
监控:
- MQ 堆积量 < 1000;
- LLM 调用 QPS 稳定;
- 子任务平均处理时间 < 5s。
批量任务是评测平台最典型的场景。它的瓶颈往往不在平台本身,而在上游模型 API 的限流:5 个模型并发调,一旦触发上游 429,子任务全卡在重试上。所以我们压批量任务时,把 LLM 调用 QPS 也列为监控项,一旦接近上游配额就主动降并发,而不是让重试风暴把 MQ 堆爆。
4. WebSocket 并发
100 个 WebSocket 连接同时在线,模拟”实时进度推送”:
# ws-bench.py
import asyncio
import websockets
import time
async def listen(id):
async with websockets.connect(f"wss://api.example.com/ws/batch/{id}") as ws:
msgs = 0
start = time.time()
async for msg in ws:
msgs += 1
return time.time() - start, msgs
async def main():
tasks = [listen(100 + i) for i in range(100)]
results = await asyncio.gather(*tasks)
# 统计
WebSocket 压测关注的是连接稳定性和推送延迟,100 个连接挂 5 分钟不断线、消息不积压才算过。这个脚本简单直接,但要留意网关层的 proxy_read_timeout,连接空闲久了会被代理掐断,那不一定是你应用的锅。
五、监控配合
第一次压测我犯过只开压测工具不看监控的错:整体 P99 难看,却说不出卡在网关、数据库还是 GC,只能一边重压一边临时加日志,效率低到想摔键盘。后来监控成了压测的标准配套,先摆看板再按开始键,压测从”等一个数字”变成”盯一组曲线”。
压测时同步打开监控:
- 应用指标:TPS、P99、错误率(Prometheus + Grafana);
- JVM 指标:GC 次数、堆内存、线程数(Micrometer);
- MySQL:QPS、慢查询、连接数(Performance Schema);
- Redis:命中率、慢命令、内存(INFO 命令);
- LLM 调用:成功率、各模型响应时间分布。
不配监控的压测等于白测——你知道整体慢了,但不知道慢在哪一层。我们的习惯是压测前把所有看板摆在副屏上,压测中盯着曲线看:P99 往上翘的瞬间,MySQL QPS 是不是同时飙升?GC 是不是开始频繁?一次压测下来,瓶颈定位往往靠的就是这几个曲线的交叉点。
压测前后对比数据:
压测前 P99: 1.2s
压测后 P99: 1.5s <- 退化 25%,要排查
每次发布版本压测后,和上一次的基线对比,退化超过阈值就停下来查原因,不带病上线。
六、性能瓶颈分析
压测暴露问题只是第一步,定位瓶颈才是大头。我们把瓶颈按层拆开逐个排查。
1. 数据库瓶颈
SHOW PROCESSLIST; -- 看是否有慢查询锁表
SHOW ENGINE INNODB STATUS; -- 看死锁
优化:
- 加索引(EXPLAIN 看是否走索引);
- SQL 重写(避免 SELECT *);
- 读写分离(主写从读)。
我们遇到过的典型情况是:压测脚本里一个查询没带过滤条件,全表扫,直接把 InnoDB 的锁排队拉满,别的请求全等着。EXPLAIN 看执行计划已经是肌肉记忆,rows 字段异常大就说明没走索引。
2. JVM 瓶颈
jstat -gcutil <pid> 1000 # GC 状态
jstack <pid> # 线程栈
优化:
- 调大堆内存;
- 切换 G1GC;
- 减少大对象。
JVM 的问题多半表现为”延迟周期性飙升”,规律性的 GC 停顿最典型。jstat -gcutil 盯着 Full GC 次数,配合 jstack 抓线程栈,能定位到具体阻塞点。有一次我们发现 JSON 序列化把几 MB 的大字符串反复分配,触发频繁 GC,改成流式序列化后明显缓解。
3. Redis 瓶颈
redis-cli --latency # 延迟
redis-cli INFO stats # 命中率
优化:
- 大 key 拆分;
- 加本地缓存;
- 调整 Redis 集群。
Redis 命中率低到一定程度,说明大量请求穿透到数据库。评测平台的限流计数、进度缓存都在 Redis 上,压测时如果命中率掉了,要么是大 key 被频繁读写,要么是缓存过期策略太激进。
4. LLM 瓶颈
- 限流触发率高 → 降并发;
- 错误率高 → 切备选模型;
- 响应时间长 → 选更快的模型。
LLM 调用是评测平台区别于普通应用的特有瓶颈。它的特点是”不可控”:同一个模型不同时间响应差异很大,上游还会限流。我们的策略是把它当外部依赖看待——降级、切换、熔断,而不是死磕优化。压测时如果上游不稳定,就用 mock LLM 替代,先把平台自身的性能测出来。
mock LLM 的策略我们一直用到现在。压测分两轮:第一轮接 mock,验证平台自身各环节的承载上限;第二轮把真实模型接回来跑一遍,看真实响应分布对 P99 的影响。两轮结果一对照,哪些慢是平台的、哪些慢是上游的,一目了然,省得出了波动就互相甩锅。
七、性能基线
每次发布前,平台会跑一遍同样的压测,把结果和基线比对。
基线不是凭空定的。第一版基线是在一次完整的、调优后的压测结果上落定的:各项指标都达标、系统稳定跑满 30 分钟,把当时的 P50、P95、P99 固化成 baseline.json。之后每次发布前重跑同样的场景,数值一对比,退化立现。基线文件本身进版本库,谁改过、为什么改,都有记录。
# baseline.json
{
"evalCreate": {
"p50": 350,
"p95": 700,
"p99": 1200,
"tps": 250,
"errorRate": 0.001
},
"sxsStream": {
"firstByteP99": 500,
"durationP99": 30000
}
}
基线文件存的是”这个版本必须达到的最低下限”,而不是”理论最优值”。它更大的价值是拦住无感的退化:有时候一次小改动把 P99 从 1.2s 拉到 1.4s,单看不会觉得有问题,但和基线一比,退化 17%,就该查查是不是索引丢了或者缓存没吃到。
发布版本性能退化 > 10% 阻断发布。这条规则一开始被执行得磕磕绊绊,因为”阻断发布”总有人想绕过,后来我们把阻断做成了 CI 里的一步:基线对比不过,流水线直接红。
八、压测报告
每次压测产出标准报告,报告模板固定,方便历史对比:
# 评测平台 v2.5 性能压测报告
## 测试环境
- 4 实例 × 4C8G
- MySQL 1 主 1 从
- Redis 6 节点
## 测试场景
- 100 并发用户持续 5 分钟
- 创建评测 / 查询 / 流式 / 批量
## 测试结果
| 接口 | P50 | P95 | P99 | TPS | 错误率 |
|---|---|---|---|---|---|
| 登录 | 80ms | 200ms | 450ms | 500 | 0.0% |
| 创建评测 | 350ms | 800ms | 1.2s | 250 | 0.1% |
| SxS 流式 | 首字 300ms | - | 28s 完成 | 50 | 0.5% |
| 批量提交 | 1.2s | 1.8s | 2.5s | 80 | 0.2% |
## 结论
- 全部指标达标
- MySQL QPS 峰值 3500,连接池 100 够用
- Redis 命中率 99%
- LLM 调用 P99 25s(与上游一致)
## 后续优化
- 批量提交 P99 偏高(2.5s),可优化 MQ 批量发送
报告里最值得写的是”后续优化”这一节。压测的价值不止是”证明能上线”,更是给下一次优化指方向。批量提交 P99 偏高这条,我们后续真的通过 MQ 批量发送把它压下去了,这就是报告带来的收益。
九、踩过的坑
压测踩坑的密度比开发高,因为环境、数据、工具任何一个环节不对,结果就失真。列几个典型的:
- 压测机和被测同机房:网络延迟忽略。生产部署跨机房,真实延迟更高。
- 数据太少:清库后压测 0 数据量,索引不命中,测不到真实性能。准备 30% 生产数据。
- 没考虑预热:JVM 没跑热就测,第一次 GC 后数据失真。预热 5 分钟。
- 只看 TPS:TPS 高但 P99 高是用户体验差。要看响应时间分布。
- 错误率没算:5xx 错误也算成功(HTTP 200)就糟糕了。要严格判定业务成功。
- LLM 限流拖垮:压测时把模型打 429 了,导致性能差。要降并发或用 mock LLM。
“错误率没算”这条印象尤其深:有一次 HTTP 全返回 200,但业务码全是超时失败,压测报告一片绿,实际上 99% 的请求都超时了。从那以后,断言里必须校验业务码,不能只看 HTTP 状态。
十、平台性能调优经验
把几个版本的调优过程和效果串起来看,能看出性能工作是怎么逐步推进的:
| 阶段 | 优化 | 效果 |
|---|---|---|
| v1.0 | 加 Redis 缓存 | P99 1200ms → 800ms |
| v1.5 | 异步化日志 | 错误率 0.5% → 0.2% |
| v2.0 | 批量任务用 MQ | 提交 P99 3s → 1.2s |
| v2.5 | 数据库分区 + 索引 | 批量查询 5s → 200ms |
| v3.0 | LLM 网关 | 整体延迟降 30% |
这条时间线说明一件事:性能优化是持续的过程,不是上线前突击一次。每次压测发现的问题,排进下个版本的需求里,一步步把 P99 往下压。到这里,评测平台”目标定标 → 压测验证 → 监控定位 → 基线守护”的性能闭环就完整了。
常见问题(FAQ)
Q1:JMeter 够用吗?
够用,主流工具。压极限上 k6 更现代,平台两者都支持。
Q2:需要做全链路压测吗?
需要,但要小心 LLM 限流。生产压测用沙箱 provider。
Q3:性能基线多久更新一次?
每季度一次架构变更加一次,平时随版本。