一次把优惠券详情接口从 60 QPS 拉到 400 QPS 的压测复盘
本文基于一次真实压测复盘整理,接口名、域名、业务编号及用户信息均已脱敏。文中的结论适用于"接口业务逻辑不重,但响应体较大,吞吐却长期停在几十 QPS"的场景。
先说结论
压测目标是让一个优惠券详情接口稳定承载 400 QPS。最初即使把 JMeter 目标从 200 QPS 调到 400 QPS、并发升到 400,实际吞吐仍只有约 66 QPS,平均响应时间接近 5.7 秒。
问题不在某一行慢 SQL,也不能仅凭线程数判断是应用线程池耗尽。排查后发现:接口返回的 JSON 较大,压测流量没有走有效的响应压缩路径,网关到压测机的响应传输和排队成为主要限制。严格的 gzip A/B 对照已将吞吐从约 61 QPS 提升到约 199 QPS;另一次 400 线程、5 万请求的稳定性压测达到 398.22 QPS 、平均响应时间 73 ms、全程无错误。
需要先说明数据边界:400 QPS 场景使用的详情对象与 gzip A/B 场景不同,因此它证明的是该接口在相应测试条件下可以接近 400 QPS,不能严谨地归因成"只加 gzip 就从 60 QPS 变成 400 QPS"。这也是本文保留完整压测口径的原因。
这次复盘还有一个容易被忽略的点:JMeter 的"目标 QPS"不是"实际 QPS"。在线程组闭环模型中,请求必须等上一次响应结束才能发下一次,实际吞吐会被并发数和响应时间共同限制。
1. 问题现象:目标 400 QPS,实际只有 65 QPS
接口是一个典型的详情查询:入口服务接收请求后,同步查询券详情,再补充样式等展示信息,最后向客户端返回 JSON。服务自身没有重写入操作,但有串行下游调用和较大的响应体。
最初的两轮结果如下。
| 目标 QPS | 并发线程 | 请求数 | 实际吞吐 | 平均 RT | 错误率 |
|---|---|---|---|---|---|
| 200 | 400 | 10,000 | 66.12 QPS | 5,699 ms | 0.07% |
| 400 | 400 | 10,000 | 65.81 QPS | 5,675 ms | 0% |
第二轮把目标 QPS 翻倍,实际吞吐没有提升,反而仍停在 60 多 QPS。与此同时,400 QPS 场景的 P90、P95、P99 分别为 7.85 秒、8.45 秒、10.72 秒。
这说明继续堆目标 QPS 没有意义:系统已经进入排队状态。
2. 第一原则:不要把目标 QPS 当成实际 QPS
压测脚本采用的是常见的线程组模型:一个线程发起请求,等待响应完成,再进入下一轮。即使增加了限速器,它也只能限制"最快多久发一个请求",无法突破线程在等待响应时产生的阻塞。
可以用一个近似公式理解:
text
实际吞吐上限 ≈ 有效并发数 / 平均响应时间(秒)
例如,400 个线程在平均响应时间约 5.7 秒时,理论上限约为:
text
400 / 5.7 ≈ 70 QPS
这与压测中观察到的 65~66 QPS 基本一致。因此,问题的第一落点不是"限速器没配对",而是"为什么单次请求需要数秒"。
3. 先把压测脚本的干扰项排除
在开始归因前,先检查脚本本身。否则压出来的可能只是一个错误的流量模型。
本次脚本存在三个需要明确的点:
- 请求参数中的用户标识曾被固定,而 token、设备号等字段来自 CSV。这样会把多用户压测意外变成单用户热点测试。
- 详情 ID 也被固定。固定业务对象并非一定错误,但要在报告中说明:结论只代表这个热点对象,而不是所有详情数据。
- CSV 配置关闭循环并在读完后停止线程。若数据行不足,并发线程会提前退出,目标并发和实际并发就不再一致。
这些问题不足以解释 gzip 前后数量级的性能差异,但会影响对"服务真实容量"的解释。压测报告必须同时写清楚:流量是否是多用户、是否单热点、业务数据是否可复用。
4. 沿调用链定位:应用层不重,不代表整条链路不慢
代码调用链可以抽象为:
text
客户端
→ API 网关
→ 详情聚合服务
→ 券详情下游服务
→ 样式/展示下游服务
→ API 网关
→ 客户端
入口服务没有直接进行复杂数据库计算,但一次请求至少包含两个同步下游调用。这里需要注意:
- "入口服务代码很薄"只能说明它不一定是 CPU 瓶颈;
- 同步调用会拉长整体响应时间,并占住入口线程;
- 即使下游处理很快,较大的响应体也可能在返回客户端的最后一段形成排队。
因此,不能只盯 Controller 或 SQL,也不能看到应用线程数较高就直接下结论为"Tomcat 线程池满了"。线程 busy 可能来自等待下游、等待网络写出,或客户端读取慢。
5. 从"QPS 卡住"到"响应体过大":完整定位过程
"响应体过大"不是一开始就能得出的结论。我们按下面的证据链逐步收敛,避免把相关性误当因果关系。
5.1 先确认这是稳定的平台,而不是一次偶发抖动
目标从 200 QPS 提升到 400 QPS、并发保持 400 后,实际吞吐仍稳定在 65~66 QPS;继续增加线程的历史结果也没有带来线性提升。与此同时,错误率接近 0,但平均 RT 长期在 5 秒以上。
这两个现象组合起来说明:请求不是大量失败,而是在某个共享环节排队。此时继续增加线程,只会让闭环模型中等待响应的线程更多,不能解释瓶颈在哪里。
5.2 回看代码链路,排除"入口服务本身很重"的直觉
接着沿着第 4 节的调用链检查。入口服务不直接执行重型查询,而是同步聚合下游详情和样式数据。这个结论没有证明下游一定快,但至少排除了"入口 Controller 自己做了复杂计算或大批量查库"的第一直觉。
因此,排查范围从"某个方法的业务耗时"扩大为三段:下游调用、网关写出、客户端接收。尤其是最后一段,在只看应用日志或 SQL 时很容易遗漏。
5.3 发现异常信号:返回内容比请求大得多,且脚本没有显式协商压缩
对同一个详情对象抓取原始响应后,未压缩 JSON 约 32 KB。对于一个查询接口,这个体积本身不一定有问题;但它与"响应时间数秒、吞吐稳定停在几十 QPS"的现象同时出现,值得验证。
随后检查 JMeter 请求定义和调试输出,发现脚本没有显式发送 Accept-Encoding: gzip,压缩状态检查也显示请求侧未启用 gzip。这里的结论仍然只是一个待验证假设:返回大 JSON 未被有效压缩,可能使网关到压测机的写出或接收环节排队。
用 61 QPS × 32 KB 粗算,已完成请求对应的有效数据量约为 2 MiB/s。这个数不能用于判断网卡上限,但足以说明"响应传输"是一个应优先验证的方向,而不是继续盲目加压。
5.4 最小 A/B 实验:只改变 Accept-Encoding
为了验证该假设,最有效的办法不是继续调大并发,而是做一个变量唯一的 A/B 实验:
bash
# 未压缩对照
curl -H 'Accept-Encoding: identity' -o identity.json -D identity.headers 'https://example.com/api/detail'
# gzip 对照
curl --compressed -H 'Accept-Encoding: gzip' -o gzip.json -D gzip.headers 'https://example.com/api/detail'
检查点有四个:
- gzip 响应头中是否有
Content-Encoding: gzip; - 压缩前后响应内容解压后是否一致;
- 压测脚本是否真的发送了
Accept-Encoding: gzip。 - 在相同详情对象、相同并发和相同请求数下,延迟与实际吞吐是否发生预期变化。
实测中,未压缩 JSON 约 32 KB ,gzip 在线传输体约 6 KB。解压后的内容与未压缩响应一致,说明优化只改变传输编码,没有改变业务数据。
这里有一个坑:JMeter 常会自动解压响应,因此 JTL 中的 bytes 不一定代表网络线上实际传输的字节数。判断 gzip 是否生效,应以响应头、原始响应文件或网关指标为准,不能只看 JTL 的 bytes。在本次验证中,响应头确认存在 Content-Encoding: gzip,且解压后的响应内容与未压缩对照一致;随后压测的吞吐和响应时间也按预期改善,假设才得到验证。
6. 结果:先用 A/B 证明主因,再验证 400 QPS 容量
先对固定业务对象做未压缩对照,再开启 gzip 压缩路径进行正式验证。
| 场景 | 目标 QPS / 线程 | 请求数 | 实际吞吐 | 平均 RT | P95 | 错误率 |
|---|---|---|---|---|---|---|
| 未压缩对照 | 200 / 200 | 2,000 | 61.35 QPS | 2,601 ms | 4,078 ms | 0% |
| gzip 验证 | 200 / 200 | 10,000 | 198.58 QPS | 79 ms | 123 ms | 0% |
| 400 QPS 稳定性压测(独立详情对象) | 400 / 400 | 50,000 | 398.22 QPS | 73 ms | 97 ms | 0% |
严格对照的结论是:在相同业务对象和请求路径下,传输压缩让平均响应时间从秒级下降到百毫秒以内,并将吞吐从约 60 QPS 提升到约 200 QPS。400 QPS 结果说明接口在另一组详情数据与相应配置下已经具备接近 400 QPS 的能力;若要把它作为优化收益,需要补一组"相同详情对象、相同用户模型、只切换压缩开关"的 400 QPS 对照。
这能有力说明大响应体的返回链路是当时的主要限制,但还不能直接推出"网卡带宽只有多少"。例如用 61 QPS × 32 KB 反推得到的只是有效完成吞吐,不等于物理网卡上限。要继续定位排队发生在网关、网络还是客户端,还需要补充网关和 TCP 层证据。
7. 这类问题最容易踩的坑
坑一:不断加线程、加目标 QPS
在闭环线程模型中,响应变慢后线程都在等待,目标 QPS 只是一个愿望值。应先看实际吞吐、平均 RT 和分位数,再决定是扩压还是定位慢点。
坑二:看到 0% 错误率就认为容量够了
本次最初两轮压测几乎没有错误,但吞吐只有目标的六分之一,且 P99 已超过 10 秒。容量验收要同时看吞吐、延迟分位数和错误率。
坑三:用 JTL 的 bytes 判断 gzip
JMeter 的自动解压会让统计字节数与线上传输量不一致。必须检查 Content-Encoding,必要时保存原始响应体做比对。
坑四:把有效吞吐当作带宽上限
"QPS × 响应大小"只能说明完成请求的速率,不能证明物理链路的带宽上限。网关事件循环、客户端接收速度、TCP 窗口和应用排队都可能造成类似现象。
坑五:忽略压测脚本是否模拟了正确用户模型
固定用户、固定详情对象、过小 CSV 数据集,都可能把真实多用户流量变成单热点测试。它们不一定无效,但必须明确写入测试范围。
8. 可复用的排查清单
当一个"大响应查询接口"长期卡在几十 QPS 时,可以按下面顺序处理:
- 确认实际吞吐:目标 QPS、实际 QPS、线程数、平均 RT、P95/P99 分开记录。
- 校验压测模型:用户、鉴权、业务对象、CSV 循环和线程退出策略是否符合预期。
- 补齐调用链:区分入口业务耗时、同步下游耗时和返回客户端耗时。
- 做 gzip/identity A/B :只变更
Accept-Encoding,校验响应头和内容一致性。 - 检查网关与 TCP 指标:避免把网络/写出排队误判为应用 CPU 或线程池问题。
- 再做长稳压测:短压验证后,用足够请求量验证吞吐、分位数和错误率是否稳定。
写在最后
这次优化最值得复用的不是"给接口加 gzip"这一招,而是定位顺序:先承认实际吞吐没有达到目标,再用排队模型解释现象;随后用单变量 A/B 实验验证传输层假设,最后才做大规模稳定性验证。
对于读多写少、响应体较大的聚合查询接口,性能瓶颈不一定在业务代码或数据库。把"响应从服务返回到客户端"的最后一段纳入压测视野,往往能比盲目扩线程更快找到答案。