接口上线前的容量评估,我见过太多团队是靠拍脑袋蒙过去的。开发说"我这接口很快,单机扛个几千 QPS 没问题",测试说"我拿 Postman 点了两下,响应挺快的",然后上线当天流量一冲,服务直接雪崩。问题的根子在于,很多人从头到尾就没弄清楚自己负责的接口到底能扛多少 QPS、吞吐量天花板在哪个位置、从第几个并发开始性能开始塌。自测接口的 QPS 和最大吞吐量,本质上是一次小型容量实验:你得知道测什么、怎么测、测出来的数字怎么读、以及为什么这个数字明天可能就不作数了。这篇文章适合后端开发、测试、运维以及任何需要给接口"报个数"的人,从概念到脚本到数据解读,我会把我自己踩过的坑和常用的手法都摊开讲。
1. 概念没理清,压测全白做
在动手之前,先把 QPS、吞吐量这几个词的真实边界划清楚。很多争议其实不是技术问题,而是双方说的根本不是同一个东西。
1.1 QPS、吞吐量、并发、响应时间各自指什么
QPS 是 Queries Per Second 的缩写,直译就是每秒查询数,落到接口这个场景,通常指这个接口每秒能处理完多少个请求,注意是"处理完",不是"收到"。有些监控把进入服务端的请求数除以时间就叫 QPS,那叫到达速率,和真正处理完成的速率中间可能隔着一个队列。
吞吐量是个更宽的概念,它可以按请求数算(那就和 QPS 数值上重合),也可以按字节算,比如每秒吞吐多少 MB。当你的接口返回的是大 JSON、图片或者文件流时,光看 QPS 会失真,因为同样 1000 QPS,返回 1KB 和 1MB,网络和内存的压力完全不是一个量级。
并发数是指同一时刻正在处理中的请求数量,响应时间是单个请求从发出到收到完整响应所花的时间。这四个指标不是孤立的,它们之间有一个非常实用的换算关系。
1.2 把四个指标串起来的利特尔法则
利特尔法则(Little's Law)原本是排队论里的结论,搬到接口性能上同样成立:
并发数 = QPS × 平均响应时间(单位统一为秒)
举个具体的例子。假设你希望接口跑到 1000 QPS,实测平均响应时间是 50ms,也就是 0.05 秒,那么需要的并发大约是 1000 × 0.05 = 50。反过来说,你用 50 个并发去压,如果响应时间没变,理论上能压出 1000 QPS;如果响应时间涨到 200ms,QPS 就会掉到 250 左右,因为同样 50 个并发,每个请求占用连接的时间变长了四倍。
这个公式最大的价值在于帮你做交叉验证。压测报告里如果出现"200 并发,QPS 5000,平均响应时间 400ms",代进去算一下:200 ÷ 0.4 = 500,和 5000 差了一个数量级,那这个数据里必然有错,要么并发统计方式不对,要么响应时间统计口径有问题。
1.3 定指标之前必须先问清楚的三件事
第一件事是业务峰值模型。电商大促是短时间脉冲式,后台批处理是长时间平稳,两者对"最大吞吐量"的定义完全不同。脉冲式要关注瞬时峰值和恢复能力,平稳型要关注长时间运行的稳定性,比如有没有内存泄漏。
第二件事是请求成本是否一致。同一个接口,带缓存命中和回源查库的耗时可能差十倍。如果你压测用的是固定参数,很可能全部命中缓存,测出来的数字漂亮但没有意义。真实流量里参数是散的,得让压测数据也散起来。
第三件事是可接受的错误率和延迟红线。没有任何系统能在无限压力下保持零错误,"最大吞吐量"通常定义为错误率开始稳定超过某个阈值(比如 0.1%),或者 P99 延迟越过红线(比如 500ms)之前的那个点。这个红线得提前和业务方对齐,不然压出来的数字没人认。
2. 压测工具怎么选,别一上来就抬重炮
工具选错,要么压不上去,要么压出来的数不可信。工具本身没有绝对的好坏,只有场景匹配度。
2.1 主流工具的能力对照
我把自己常用和见过的工具整理成一张表,方便快速判断。
| 工具 | 脚本语言 | 协议支持 | 单机压测能力 | 上手成本 | 典型场景 |
|---|---|---|---|---|---|
| curl | 无 | HTTP 等 | 约等于 1 | 极低 | 单次探针、验证接口通不通 |
| ab | 无 | HTTP | 中(单线程模型,受限于 CPU 单核) | 低 | 快速冒烟、粗略看吞吐 |
| hey | 无 | HTTP | 中 | 低 | ab 的现代替代品,支持并发和 QPS 限速 |
| wrk | Lua | HTTP/HTTPS | 高(多线程 + 事件驱动) | 中 | 追求极限 QPS、需要脚本定制 |
| k6 | JavaScript | HTTP/1.1、HTTP/2、gRPC、WebSocket | 高 | 中 | 云原生、CI 集成、场景化脚本 |
| Locust | Python | HTTP 为主 | 中高(可分布式) | 中 | 复杂业务链路、需要分布式扩展 |
| JMeter | GUI + 少量代码 | 几乎全协议 | 中(GUI 模式笨重,建议命令行) | 高 | 多协议混合、企业存量资产 |
选型上我的经验是:单接口极限 QPS 用 wrk 或 k6,多接口业务链路用 Locust 或 JMeter。ab 和 hey 适合五分钟出个粗略数字,但别拿它们的结果去写正式的容量报告,因为它们的客户端模型太简单,容易自己先成为瓶颈。
2.2 选型时容易被忽略的三个维度
第一个维度是连接复用能力。你的接口如果支持 keep-alive,压测工具就必须开启长连接,否则压测脚本会把大量时间花在 TCP 三次握手和四次挥手上。wrk 默认就是长连接,ab 需要手动加-k,JMeter 要在 HTTP 请求默认值里勾选 keep-alive。
第二个维度是结果输出的可解析性。做容量基线是要留档的,最好选能输出结构化结果(JSON、CSV)的工具,方便后续做曲线。k6 的--out json、Locust 的 CSV 都很好用,JMeter 的 jtl 也可以,但 wrk 的默认输出只有文本,需要自己解析。
第三个维度是是否支持压测过程中的动态行为。真实流量不会一直匀速,会有突发。k6 的 stages 和 Locust 的 LoadTestShape 都能模拟阶梯、尖峰、波浪等各种压力曲线,wrk 靠 Lua 脚本也能做简单的限速,但表达力弱一些。
2.3 压测环境准备的检查清单
环境没准备好,压出来的数字全是噪声。下面这份清单我每次都会过一遍:
- 压测客户端和服务端不要在同一台机器上,除非你就是想测同机极限(那结论也只能用于同机场景)。
- 压测机自己要先调优:
ulimit -n调到 65535 以上,net.ipv4.ip_local_port_range改成1024 65535,net.ipv4.tcp_tw_reuse打开,否则单机并发过千就会因为端口耗尽报错。 - 被测服务要提前预热。Java 服务的 JIT 编译、连接池初始化、缓存加载都需要时间,冷启动阶段测出的前几十秒数据不能算数,我一般会先跑个 10% 压力预热 2 到 3 分钟再正式开始。
- 关掉不相关的后台任务,比如日志采集、定时任务、监控 Agent 的高频上报,这些会偷走 CPU。
- 统一时间基准,客户端和服务端做一次 NTP 对时,不然跨机器统计的时间戳没法和延迟对不上。
注意:千万不要在生产环境做破坏性压测。如果确实要在生产验证,只做限量的、带熔断保护的探测,并且提前知会相关方。
3. 手把手做一次可信的 QPS 压测
理论说够了,下面是完整的实操过程。我会用 wrk 做主演示,因为这个工具能压出比较真实的极限数字,同时给出 k6 的等价写法。
3.1 压测脚本怎么写才不会被客户端拖累
先用最简单的命令行模式压一下:
wrk -t8 -c200 -d60s --latency \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ http://target-host:8080/api/v1/query?id=1001参数含义:-t8是 8 个线程,-c200是 200 个并发连接,-d60s持续 60 秒,--latency输出延迟分布。线程数一般设成压测机 CPU 核数,连接数按目标并发来。
但这只是固定参数的压法,容易被缓存欺骗。更接近真实的是用 Lua 脚本做参数随机化:
-- query.lua local counter = 1 local threads = {} function setup(thread) thread:set("id", counter) table.insert(threads, thread) counter = counter + 1 end request = function() local uid = math.random(1, 1000000) local path = "/api/v1/query?id=" .. uid return wrk.format("GET", path) end done = function(summary, latency, requests) io.write("---- done ----\n") end运行方式变成wrk -t8 -c200 -d60s -s query.lua http://target-host:8080。这里的math.random让每次请求的 id 都不同,能有效避开简单的本地缓存和 CDN 缓存,测出的才是后端真实计算成本。
如果用 k6,等价脚本长这样:
import http from 'k6/http'; import { check } from 'k6'; export const options = { stages: [ { duration: '2m', target: 50 }, { duration: '3m', target: 200 }, { duration: '3m', target: 500 }, { duration: '2m', target: 0 }, ], thresholds: { http_req_failed: ['rate<0.001'], http_req_duration: ['p(99)<500'], }, }; export default function () { const uid = Math.floor(Math.random() * 1000000); const res = http.get(`http://target-host:8080/api/v1/query?id=${uid}`); check(res, { 'status is 200': (r) => r.status === 200 }); }k6 的 stages 天然支持压力梯度,而且 thresholds 能在跑的过程中就给出"是否达标"的判定,非常适合放进流水线做回归。
3.2 压力梯度怎么设计才靠谱
不要一上来就 200 并发。我的做法是阶梯式上探,具体档位根据接口预期来定,一般从预估峰值的 20% 起步。假设你预估这个接口峰值 1000 QPS,可以这样排:
| 阶段 | 并发 | 持续时长 | 观察目的 |
|---|---|---|---|
| 预热 | 20 | 2 分钟 | JIT 完成、连接池预热、缓存填充 |
| 冒烟 | 50 | 3 分钟 | 确认基本可用、拿到基线延迟 |
| 爬坡 | 100 / 200 | 各 3 分钟 | 观察延迟是否线性增长 |
| 逼近 | 400 / 600 | 各 3 分钟 | 寻找拐点前兆 |
| 探顶 | 800 / 1000 | 各 3 分钟 | 定位拐点、观察错误率 |
| 拉满 | 1500 起 | 2 分钟 | 看失败模式(快速失败还是拖死) |
每一档之间留 30 秒的间隔,让系统有个短暂的"喘气"时间,观察恢复情况。如果某一档之后延迟没有回落、线程数没有下降、CPU 没有回落,那可能出现了队列积压,需要单独记录。
为什么每个档位要至少 3 分钟?因为很多性能问题在短时间内是看不出来的,比如连接池耗尽、GC 长暂停、磁盘 IO 打满,这些现象往往在持续压力下经过一两分钟才显现。3 分钟是个经验值,能覆盖大多数抖动周期。
3.3 压测过程中必须同步采集的两侧数据
只盯着 QPS 一个数,等于闭着眼睛开车。压测时至少同步采集以下指标。
客户端侧:
- 实际并发数(不是配置值,是实际建立的连接数)
- QPS、响应时间 P50 / P95 / P99
- 错误分类:连接超时、读超时、5xx、业务错误码
- 压测机自身 CPU、内存、网络带宽
服务端侧:
- 应用 CPU、内存、GC 次数和停顿时长
- 线程池活跃数、队列积压数
- 数据库连接池使用率、慢查询数
- 上下游依赖的响应时间(比如缓存、MQ、第三方服务)
- 网络收发字节数
我一般会用dstat或sar记录压测机的资源,服务端靠 Prometheus 或 APM 抓取。两边的时间戳要对齐,方便后面把曲线叠加起来看。这一步做与不做,直接决定你能不能在出问题时快速定位。
3.4 一次完整的压测现场记录
下面是我最近一次给一个查询接口做容量测试的现场记录,脱敏后分享出来。
被测接口:GET /api/v1/item/detail?id=xxx,返回一个约 3KB 的 JSON,后端是一次 Redis 查询加一次 MySQL 点查。
压测机:8 核 16G,与被测服务不同宿主机,同机房内网。
服务端:4 核 8G 容器,JVM 堆 4G,连接池最大 100。
结果汇总如下:
| 并发 | QPS | 平均延迟 | P95 | P99 | 错误率 | 服务端 CPU |
|---|---|---|---|---|---|---|
| 50 | 1180 | 42ms | 78ms | 120ms | 0 | 25% |
| 100 | 2260 | 44ms | 85ms | 140ms | 0 | 48% |
| 200 | 3050 | 65ms | 160ms | 380ms | 0 | 72% |
| 400 | 3400 | 117ms | 340ms | 890ms | 0.05% | 91% |
| 600 | 3180 | 188ms | 620ms | 1800ms | 1.2% | 96% |
| 800 | 2600 | 305ms | 1100ms | 2600ms | 6.8% | 97% |
读这张表,拐点出现在 200 到 400 并发之间,QPS 峰值大约在 3400 附近。400 并发时 P99 已经接近 900ms,超过了我们定的 500ms 红线,所以安全水位应该在 200 到 300 并发、QPS 2200 到 3000 之间。再往上虽然 QPS 短暂冲高到 3400,但延迟和错误率已经不可接受了。
这个数字最后定在 2500 QPS 作为单实例的容量基线,配合 0.7 的冗余系数,上线时按"单实例 2500 QPS、水平扩展"来做容量规划。
4. 数据怎么读,把曲线翻译成结论
压测跑完拿到一堆数字,真正的功夫在于解读。会看的人能从一个 QPS 曲线的形状里读出系统瓶颈在哪。
4.1 识别系统拐点的四种信号
拐点不是一个精确的点,而是一个区间。当下面这些信号同时出现两个以上,基本可以判定进入拐点区。
第一,延迟开始非线性上升。并发翻倍,延迟涨得比一倍还多,说明请求开始排队。可以用一个简单的比值判断:并发从 A 到 2A,如果 P95 延迟涨幅超过 2 倍,系统已经开始争抢资源。
第二,QPS 不再随并发增长。这是最直观的信号,继续加并发,QPS 反而持平或下降,说明系统的处理能力已经饱和,多出来的并发只是在排队。
第三,错误率从零跳到非零。哪怕只有 0.01%,也要警觉,因为错误率通常不是线性增长,而是到达某个点后突然放大。
第四,某种资源打满。CPU 100%、连接池满、线程池队列堆积、磁盘 IO 饱和,任何一个资源到顶都会成为瓶颈。定位瓶颈最快的办法就是看哪个资源最先到顶。
4.2 平均延迟会骗人,分位数才有话说
假设 1000 个请求里 990 个耗时 20ms,10 个耗时 3000ms,平均延迟是 (990×20 + 10×3000) ÷ 1000 = 49.8ms。看起来还行,但那 10 个用户实际等待了 3 秒,体验是灾难性的。平均值把长尾抹平了,分位数不会。
P95 表示 95% 的请求快于这个值,P99 表示 99% 的请求快于这个值。容量评估时我一般遵循这样的原则:平均延迟用于看整体趋势,P95 用于看大部分用户体验,P99 用于看极端情况和容量红线。业务对延迟敏感的话,有时候还要看 P99.9。
有一个经验公式可以帮你快速估算:如果 P99 是 P50 的 10 倍以上,说明系统里存在明显的排队或者间歇性停顿,去查 GC、慢查询、锁竞争。
4.3 写出一份团队看得懂的容量结论
压测报告不要只丢一堆数字,要让读的人能直接拿去做决策。我通常按这个结构写:
接口:GET /api/v1/item/detail 测试时间:2025-xx-xx 测试环境:4C8G 容器,JVM 4G,MySQL 主从 结论: 1. 单实例峰值吞吐 3400 QPS(400 并发时) 2. 推荐安全水位 2500 QPS(P99 < 500ms,错误率 0) 3. 拐点区间 200~400 并发,瓶颈为服务端 CPU(400 并发时达 91%) 4. 水平扩展建议:目标集群 QPS / 2500 = 实例数,向上取整后再加 1 个冗余实例注意"安全水位"这个词很重要,它不是峰值。很多事故就是上线时按峰值容量来配,一旦流量有波动或者依赖抖动,立刻越界。
5. 常见问题与排查实录
压测过程中遇到的怪事比顺利的时候多。下面这些是我踩过或者帮别人排查过的典型问题。
5.1 压不上去,到底是谁的锅
现象:无论怎么加并发,QPS 卡在一个数字上不去,延迟也没明显上升。
排查顺序我一般这样走。
先看压测机。top看 CPU 是否打满,ss -s看连接数是否接近端口上限,netstat -an | grep TIME_WAIT | wc -l看有多少连接卡在 TIME_WAIT。如果是压测机的问题,先按 2.3 节调优,再不行就上多台压测机分布式压。
再看网络。同机房内网带宽一般不是问题,跨机房或者走公网就要看带宽是否被吃满。用iftop或nload看一眼收发速率。一个 3KB 的响应在 3000 QPS 下就是 9MB/s,大约 72Mbps,如果压测机是百兆网卡,直接就到顶了。
最后才看服务端。服务端如果有前置的 Nginx 或网关,检查它们的连接数限制、worker_connections、限流规则。我遇到过一次 QPS 死活上不去,最后发现是 Nginx 的limit_req默认配置在生效,每秒只放行 500 个请求。
5.2 结果忽高忽低,波动大得离谱
现象:同样参数跑两次,QPS 差 30% 以上。
可能原因我整理成一张速查表。
| 现象 | 可能原因 | 验证方式 | 处理建议 |
|---|---|---|---|
| 两次结果差异大 | 缓存命中率不同 | 看 Redis 命中率、DB 查询量 | 压测数据随机化,先预热 |
| 周期性抖动 | GC 长暂停 | 开 GC 日志,看停顿分布 | 调堆大小、换垃圾回收器 |
| 前快后慢 | 连接池/线程池耗尽 | 看池的活跃数和等待队列 | 扩大池或降低并发 |
| 忽快忽慢 | 压测机被其他任务干扰 | top看负载 | 独占压测机 |
| 全都很慢 | 上下游依赖变慢 | 单独压依赖服务 | 先解决依赖瓶颈 |
| 短时间跑很快,长时间跑崩 | 内存泄漏或连接泄漏 | 长跑 30 分钟看内存曲线 | 修代码,别用压测掩盖问题 |
5.3 关于"最大吞吐量"的几个误区
第一个误区:把 QPS 峰值当成可承载值。峰值只代表瞬时能力,系统在这个点上没有任何余量,任何抖动都会导致雪崩。
第二个误区:忽略依赖。被测接口自己很快,但它依赖的 MySQL、Redis、第三方接口可能早就到极限了。压测时要拉通看,别只盯着被测服务。
第三个误区:压测环境与生产环境差异太大。核数、内存、JVM 参数、数据库规格、网络拓扑,任一不同都会让数字失真。我的做法是至少保证核数、内存和数据库规格一致,配置差异要显式记录在报告里。
第四个误区:用单接口的 QPS 简单乘以接口数来估算全局容量。多个接口共享 CPU、内存、连接池,叠加时会有资源争抢,实际总吞吐往往低于简单相加。
6. 几个我踩过坑才明白的细节
聊到这,再分享几个特别实用的经验。
第一,压测一定要设上限。脚本里要写清楚最大并发和最长时长,避免跑飞。我见过一次压测脚本没设时长,第二天早上才发现还在跑,白跑了一晚上不说,还把测试库的磁盘写满了。给脚本加--timeout或者在代码里加停止条件。
第二,观察"恢复能力"比观察"峰值"更重要。压到拐点之后,把压力降回来,看系统能否在 1 分钟内恢复到基线延迟。如果降回来之后依然慢,说明有资源没释放,比如连接没归还、线程没回收,这是个隐藏 bug。
第三,记录下每次压测的环境快照。同一份脚本,换台机器、换个 JVM 版本、改个连接池参数,结果都可能变。我会把uname -a、java -version、关键配置文件都存档,半年后回头看还能复现。
第四,接口幂等性和压测的关系。如果接口不是幂等的(比如下单),压测参数一定要用可识别的测试标记,方便事后清理脏数据。如果是写接口,还要考虑数据库写入本身会不会拖慢,读写混合的压测和纯读压测完全是两个结果。
第五,别迷信一次性结论。代码在变、依赖在变、流量模型在变。我给自己定的规矩是核心接口每个大版本上线前重压一次,日常做轻量级的回归压测,确保容量基线不过期。
第六,学会用免费工具做长期监控。压测是一次性的,容量评估需要长期数据支撑。可以把压测的核心指标接入监控看板,日常流量的 P99、错误率、资源水位都能辅助判断容量趋势。至于压测本身,wrk、k6、Locust 这些开源工具已经足够覆盖绝大多数场景,没必要为了压测专门去采购重型商业方案,把预算花在监控和自动化上更划算。
接口的 QPS 和吞吐量自测,说到底是一场主动性很强的实验。你得先想清楚要回答什么问题,再设计实验,再读数据,最后把结论翻译成团队能用的容量规划。整个过程里,工具只是手段,对系统行为的理解才是真正的门槛。