负载测试不是一个可以贴进 README 的数字。它只回答三个问题:这个服务能扛多少量、它在什么地方开始跟不上、以及你刚上线的改动到底是帮忙还是添乱。除此之外——那个大字标题「Requests/sec」、那句「我们服务了 N 百万次请求」的自夸——全是噪音。所以,我在容器里跑一个用完就扔的服务,用 hey 压测在一系列并发下打它,然后诚实地读延迟分位数。在中间的某个位置你会看到:吞吐量停止攀升,而 p99 开始起飞。这个点有个名字:拐点(the knee)。
你到底想学到什么
- 容量。 「单核上,p99 低于 100 ms 时能跑 1,500 req/s」是一个你能据以扩容、据以告警的数字。
- 在哪里崩。 不是「崩了」,而是哪个资源先饱和:CPU、连接池、一把锁,还是压测工具本身。每个服务都有一个最先撞到的瓶颈。
- 改动有没有用。 只改一件事,在同一台机器上重跑同一个测试,在固定并发下比较 p99。这是唯一诚实的判定方式。
- 虚数。 总共服务了多少请求、不带延迟的「峰值 rps」、来自另一台机器另一天的图表。只有吞吐量、没有延迟,那是情绪,不是结果。
选一个工具:hey、wrk、k6、ab
它们都发 HTTP;区别在于能告诉你什么、以及给客户端带来多少开销。
ab哪儿都装好了——这是它最大的优点。单线程,而且不加-k时每个请求新开一条连接,所以你量到的是 TCP 建连而不是你的应用。它只报均值,不报分位数。hey是我要快速答案时的默认选择:一个自包含的二进制,开箱就给你 p50/p95/p99 和状态码分布,-z指定时长、-c指定并发、-m/-d指定方法和请求体,客户端开销很低。wrk用来施加真正的压力——C 内核加 LuaJIT 脚本,几个线程就能撑起上千条连接:wrk -t4 -c200 -d30s --latency。k6用来做那些必须可复现、可卡关的测试:场景、梯度加压、不达标就失败的门槛、可归档的输出。需要进 CI、做浸泡或尖峰测试时,我用它。
一句话:一次性问题用 hey,要最大原始压力用 wrk,结果必须可复现且要过/不过用 k6,ab 只做十秒体检。
安全地做:只打自己的机器、只打本地容器
负载测试和拒绝服务攻击在流量上没有任何区别。绝不把压测工具对准你不拥有的主机——不是竞争对手的站、不是公开 API、更不是「就十个并发」去招呼别人的小博客。哪怕一次温和的突发也能把小服务打挂,而在很多地方那是犯罪。回环地址和你自己的容器,是唯一安全的目标。
整个实验就是一个极小的 Python 镜像,带两条刻意不同的路由:/pool(8 个槽位的 worker 池,加 5 ms 模拟下游等待,像一次数据库调用)和 /work(纯 CPU),两者都被限制在一个 CPU 上。
# 用完即弃的网络 + 应用,限制在一个核,只在 Docker 内可达
docker network create loadtest-net
docker run -d --name ltapp --network loadtest-net --cpus=1 loadtest-app
# 压测工具加入同一个私有网络。不映射端口,没有公网 IP。
docker run --rm --network loadtest-net rcmorano/docker-hey \
-z 15s -c 1 http://ltapp:8000/pool
两个容器都不碰宿主网络,做完就删。
设计一个像真实流量的测试
负载测试是一个模型,旋钮设错了就什么都量不出来。在敲下 -c 1000 之前:
- 打对路径、按对比例。 单打一个接口只能说明那个接口;真实流量是大量便宜的读加少量昂贵的写,如果百分之十的请求会打到慢路由,就把这个比例建进去。
- 发真实的请求体。 对
/发一个没有 body 的 GET,量的是你的 HTTP 解析器;如果接口收 JSON POST,就发 JSON POST。 - 保持连接复用。
hey和wrk默认复用连接,ab要加-k。每个请求都重新建连,量到的是 TCP 和 TLS,不是你的处理逻辑。 - 预热。 缓存要填充、JIT 要编译、连接池要打开;把最开始的几秒丢掉。
- 刻意区分闭环和开环。
hey和wrk是闭环的:每个 worker 等自己的响应回来才发下一个,所以响应变慢会自动降低请求速率。固定到达率(k6的constant-arrival-rate)在你变慢时继续硬推——而这恰恰是暴露崩溃的方式。
扫一遍并发:找拐点,而不是找峰值
单并发的一次运行只是一个点;扫一遍并发得到一条曲线,而曲线才是结果。路由和时长都不变,只有 -c 在动:
for c in 1 2 4 8 16 32 64 128 256; do
docker run --rm --network loadtest-net rcmorano/docker-hey \
-z 15s -c "$c" http://ltapp:8000/pool
done
conc reqs/sec p50 p95 p99 max non2xx appcpu
1 181.6 0.0055 0.0056 0.0056 0.0070s 0 5%
2 362.1 0.0055 0.0056 0.0057 0.0085s 0 9%
4 724.7 0.0055 0.0056 0.0058 0.0084s 0 16%
8 1452.7 0.0055 0.0057 0.0058 0.0126s 0 35%
16 1544.4 0.0103 0.0106 0.0155 0.2226s 0 43%
32 1544.8 0.0206 0.0213 0.0363 0.2320s 0 44%
64 1543.4 0.0413 0.0420 0.0775 0.2484s 0 40%
128 1542.1 0.0828 0.0841 0.1605 0.2891s 0 42%
256 1537.3 0.1657 0.1708 0.3275 0.4961s 0 43%
只看两列。吞吐量线性爬到 c=8 时的 1,452 req/s,然后几乎不动:c=16 是 1,544,c=256 是 1,537。负载翻了九倍,吞吐量一模一样。同时 p50 延迟从 5.5 ms 涨到 165 ms,三十倍。这就是「过了拐点」的定义:你付出了延迟,什么也没换到。
拐点在 c=8——这就是要围绕它做设计的数字。生产环境我待在它下方,大约 50–70%,好让流量尖峰有地方去。
延迟分位数:为什么均值会撒谎,p99 才是你真正感受到的
均值是报得最多、也最没用的延迟数字:少量慢请求会被它抹平。如果 99 个请求耗时 5 ms、1 个耗时 5,000 ms,均值约 55 ms——一个没有用户真正体验过的数字。延迟分位数描述的是形状:
- p50 是典型请求:一半更快,一半更慢。
- p95/p99 是尾部。p99 = 100 ms 意味着每 100 个请求里有 1 个要等更久。
- max 是单个样本,是整份报告里噪声最大的东西。当线索看,别当指标用。
比较我两次已经饱和的 /pool 运行:
$ hey -z 15s -c 64 http://ltapp:8000/pool
Requests/sec: 1542.16
50% in 0.0413 secs # p50
95% in 0.0425 secs
99% in 0.0777 secs # p99
Slowest: 0.3244 secs
[200] 23198 responses
$ hey -z 15s -c 256 http://ltapp:8000/pool
Requests/sec: 1537.46
50% in 0.1656 secs
99% in 0.3275 secs # p99 已经是 p50 的两倍
Slowest: 0.6476 secs
[200] 23317 responses
吞吐量一样(1,542 对 1,537——四舍五入的差别)。差别全在尾部:p99 从 78 ms 翻倍到 328 ms。均值会把这件事藏起来,p99 拒绝。所以我的仪表盘上放的是 p99,而压测报告里那句「我们平均 40 ms」,我不再相信了。
还有一个坑:一个会扇出到十个下游的页面,会继承其中最差的尾延迟,于是你服务的 p99 悄悄变成了你页面的接近 p50。
和服务端指标对上:客户端只是一半
客户端告诉你发生了什么,服务端告诉你为什么。给你压的东西加一条 /stats 路由——计数器、在途请求数、进程 CPU、服务端自己的处理延迟——然后在一次运行之后立刻读它:
$ curl -s http://ltapp:8000/stats | jq .
{"uptime_s": 12.5, "requests": {"total": 15528, "/pool": 15528},
"in_flight": 1, "max_in_flight": 64, "process_cpu_s": 3.93,
"latency_ms": {"/pool": {"n": 15528, "p50": 41.0, "p95": 41.5,
"p99": 77.1, "max": 113.8}}}
两个事实跳出来。第一,服务端自己的 p50(41.0 ms)和客户端的 p50(41.3 ms)几乎完全一致——延迟在应用内部,不在网络里。第二,整轮跑下来应用只用了大约 38% 的一个核(docker stats 采样在 31–38%,压测一停就掉到 4%):它不是 CPU 瓶颈。瓶颈是那个 8 槽位的池子,c=64 时大约有 56 个请求在它后面排队。修法是更大的池子、更快的下游,或者前面加一层缓存——而不是换个更大的 CPU。
再看 CPU 密集的那条路由,是完全相反的故事:
$ hey -z 15s -c 50 http://ltapp:8000/work
Requests/sec: 141.88
50% in 0.3096 secs
99% in 0.6995 secs
Slowest: 4.5789 secs
[200] 2156 responses
/work 在烧 CPU,整轮都被钉在 ~100%。但请坐稳了看这个反直觉的地方:同一条路由,c=1 时服务了 227 req/s,c=50 时只有 142。并发翻了五十倍,吞吐量反而下降了。过了拐点,多出来的并发不会乖乖排队——它会带来上下文切换、内存抖动和争用,尾部随之爆炸(max 4.6 秒)。更多负载不等于更高吞吐。
于是核对清单是:CPU 打满(扩容或者少做点活)、池子占满(调大或者加速依赖)、错误计数在涨(你不是饱和了,你是坏了)、压测工具自己到顶了(看它的 CPU)。
负载、浸泡、尖峰是三种不同的测试
上面那次扫描是负载测试:稳定、逐步加压,找容量和拐点。另外两种形状回答不同的问题。
- 浸泡(soak)。 同样的中等负载,但跑几个小时。指标是漂移,不是峰值:内存涨不涨、文件描述符漏不漏、连接池会不会慢慢饿死、p99 会不会在第三个小时悄悄抬升?能过五分钟测试的服务,经常在五小时后倒下。我让浸泡测试跑一整晚,但绝不在改一行代码后的循环里跑。
- 尖峰(spike)。 一次远超容量的突发,然后回到正常。问题不是「扛不扛得住」——它扛不住——而是它能不能恢复:队列会不会排空、连接会不会关掉、熔断器会不会复位,还是一分钟的坏运气让服务烂了一个小时?用
k6的ramping-vus或constant-arrival-rateexecutor,因为闭环工具恰恰在你最想继续硬推的时候退缩了。
笔记本上的压测只是参考,不是圣经
上面每个数字都来自同一台机器——应用和压测工具跑在一起。这一点要诚实面对。
- 压测工具和应用在抢资源——同一个 CPU、同一个内核、同一份散热预算。并发越高,我量到的就越有一部分是压测工具的开销,而不是服务的。
- 回环不是网络。 没有真实 RTT、没有丢包、没有 MTU 意外、没有 TLS。
--cpus=1是共享内核上靠自觉生效的 cgroup 限制,而且这台机器当时还跑着 Prometheus。绝对速率不是生产数字。- 能迁移过去的是:曲线的形状、拐点相对你自己的配置落在哪里、同一台机器上某次改动的相对效果,以及你发现的失效模式。绝对那句「1,542 req/s」是我笔记本的属性,不是你的服务。
如果你需要一个绝对的容量数字,把压测工具放到另一台机器上,把它和应用的核错开,再重跑这次扫描。
我现在怎么做
- 先说清楚目标——容量、崩溃点,还是改动有没有用——然后搭能回答它的最小测试。
- 默认用 hey 压测扫并发(
1 2 4 8 16 32 64 128 256),每个 15 秒,读曲线,绝不用一个点下结论。 - 报延迟分位数(p50/p95/p99),不报均值;把 p99 当作用户真正感受到的东西。
- 找出拐点,然后让生产跑在它的 50–70%,给尖峰留出余量。
- 每一次都把客户端数字和服务端 CPU、在途请求数、错误率对上。
- 负载、浸泡、尖峰分开做,因为它们的失败方式不同。
- 永远只压自己的机器或用完即弃的容器,绝不压不属于你的主机。
负载测试的好坏,取决于你读它时带着多少诚实。一旦你能看见拐点,它自然就和保护它的那件事配上了——限流说到底,就是把真实流量留在这条安全线之内。而当你想让部署本身也能扛住这场测试时,零停机部署就是让你能重跑它而不中断的那个东西。