网站压测实战:wrk阶梯加压找拐点

一、压测前先问:你要测的是哪一种问题

很多站长第一次做压测,是因为「感觉网站有点慢」或者「听说要压测」。打开 wrk,对着首页轰 30 秒,看到 QPS 三千,截图发个朋友圈,然后就没有然后了。这种做法唯一的作用是把生产服务器打挂一次,什么都说明不了。

压测的本质不是「把机器压满」,而是用一个可重复的方法,找出系统的第一个瓶颈在哪里,以及它失效时的表现。所以在敲命令之前,先回答三个问题。

第一个问题:你要压的是静态页还是带数据库的动态页。两者的压法完全不同。静态页压的是 Nginx 的 IO 与内核网络栈,动态页压的是 PHP-FPM 进程数、MySQL 连接池、以及它们背后的 CPU。拿静态页的 QPS 去估动态页的容量,误差能到十倍。

第二个问题:你想知道的是「容量」还是「稳定性」。容量型压测是阶梯加压、找拐点;稳定性型是固定并发跑两小时看内存有没有缓慢上涨、连接有没有泄漏。这两种测试时长差一个数量级,混在一起做会得出「压了十分钟就够」的错误结论。

第三个问题:压测期间谁能容忍影响线上。个人站长通常只有一台生产机,压测必须放在目标机之外,或者至少在凌晨流量谷底,并且准备好一键停掉的方法。把压测机放在机房外、走公网,压出来的是「公网 + 服务器」的整体能力,这更接近真实用户视角,但也意味着你要先确认压测机的出口带宽不是瓶颈——很多人压测压出来的 QPS 上限,其实是自己家宽的上行带宽上限。

先想清楚这三个问题,后面所有参数都有意义;想不清楚,参数填得再好看也只是数字游戏。

二、选工具:wrk、ab、vegeta 各自的适用范围

常见的命令行压测工具就那么几个,各有各的脾气,用错场景会得出完全错误的结论。

ab(ApacheBench)是最老的一个,几乎所有系统都能装。它的特点是非常简单,但有两个致命限制:默认以固定速率发请求之外,它不能精确控制并发模型,而且对 keep-alive 的支持很别扭。ab 压出来的数字普遍偏低,用来做「这台机大概什么量级」的粗判可以,用来做容量规划不行。

wrk 是目前最常用来压 HTTP 的单机工具。多线程、事件驱动、支持 keep-alive,能在一台普通笔记本上打出几万 QPS。缺点是它只发同一个请求,不支持场景编排,也不易做「先登录再请求」这种链路压测。

vegeta 的强项是场景化:它可以从一个请求列表文件读入,按指定速率持续攻击,并把结果输出成可画图的格式。做稳定性测试和「按真实访问日志回放」时用它最合适。

装上不难,Debian/Ubuntu 上一行就够:

apt install -y apache2-utils wrk
# vegeta 需要单独下载二进制,从 github releases 取 linux_amd64.tar.gz
tar xf vegeta_*_linux_amd64.tar.gz -C /usr/local/bin/ vegeta

我的建议是:找拐点用 wrk,做持续压测和场景回放用 vegeta,ab 只在临时机器上什么都没有时用来做冒烟测试。压测工具本身的开销必须远小于被测服务,否则你测的是压测机。

还有一个容易被忽略的点:压测机和服务端之间的每条 TCP 连接都要占用客户端的临时端口(默认范围约 28000 个)和 TIME_WAIT 状态。用 keep-alive 时连接复用好,问题不大;但如果被测服务因为某些原因主动关连接,压测机很快就会端口耗尽,报「Cannot assign requested address」。这不是服务端的错,详见下一节。

三、先定基线:不打无准备之仗

压测之前必须先记录一份「空载基线」,否则后面所有数字都没有参照物。这份基线至少要包含以下几项,用一条命令就能存下来。

mkdir -p /tmp/bench && cd /tmp/bench
date > baseline.txt
uptime >> baseline.txt
nproc >> baseline.txt
free -m >> baseline.txt
nginx -v >> baseline.txt 2>&1
php -v | head -1 >> baseline.txt
ss -s >> baseline.txt

然后单独测一次「单请求延迟」,这个数字是最重要的基准。即在零并发的情况下,一个请求从发出到收到完整响应需要多久。

curl -s -o /dev/null -w 'dns=%{time_namelookup} conn=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download} ' "https://你的域名/目标页面"

这一行输出把一次请求拆成了四段。DNS 时间是域名解析;conn 是 TCP 握手(含 TLS 的话会体现在 ttfb 里);ttfb 是第一字节时间,直接反映后端的处理速度;total 是完整下载时间。如果 ttfb 就有 800ms,那说明后端本身就慢,后面无论怎么压,QPS 都不可能高——先优化,别急着压。

基线里还有一个数字要看:ss -s 里的 timewait 数量。稳定状态下它应该在几百到几千之间。如果压测前后这个数字暴涨到几万,说明连接没有被复用。

把这些数字抄进一个表格,压测后的每个阶段都对比一次。这样做的好处是:当 QPS 不再上涨时,你能立刻看出是 CPU 满了、内存满了,还是 TIME_WAIT 满了。没有基线,就只能猜。

四、wrk 的正确用法与参数含义

wrk 的参数不多,但每一个都影响结论。最基础的调用是这样的:

wrk -t4 -c200 -d60s --latency "https://你的域名/目标页面"

逐个解释:-t4 是压测线程数,一般设成压测机 CPU 核数,不要超过;-c200 是保持 200 个并发连接;-d60s 是持续 60 秒;--latency 让它在最后多打印一份延迟分布。

这里有个最常见的误区:把 -c 当成「模拟多少个用户」。实际上 -c 是并发连接数,而 wrk 的连接是「一次请求完成后立刻发下一个」,所以单个连接可以在一秒内发出几十个请求。真实用户的行为是有思考时间的,200 个并发连接对应的可能是几千个用户。反过来,如果你想要「模拟 200 个每秒发一个请求的用户」,应该用 vegeta 的 -rate 参数。

另一个误区是线程数设太大。wrk 是事件驱动的,单个线程就能处理大量连接,线程数超过压测机核数只会让上下文切换变多、结果变差。看到「加大 -t 之后 QPS 反而下降」,就是线程开多了。

带 keep-alive 的测试更接近真实浏览器。加一个自定义 header 就能长期复用连接:

wrk -t4 -c200 -d60s --latency -H "Connection: keep-alive" -H "Accept-Encoding: gzip" "https://你的域名/目标页面"

加上 Accept-Encoding: gzip 很重要,因为很多站对不带这个头的请求返回未压缩内容,压出来的带宽和 CPU 数据完全不是真实用户的情况。

如果要测 POST 或者带 Cookie 的登录后页面,wrk 自带的能力不够,要用 Lua 脚本扩展。这是 wrk 唯一的学习成本,但很值得:

wrk.method = "GET"
wrk.headers["Cookie"] = "PHPSESSID=你的会话ID"
wrk.headers["User-Agent"] = "Mozilla/5.0 (compatible; bench)"

把上面几行存成 /tmp/bench/login.lua,然后用 -s /tmp/bench/login.lua 加载。注意压测登录态页面会消耗真实的数据库查询,对后端压力比静态页大得多,压测前先确认数据库能承受。

五、阶梯加压找拐点,而不是一次压到底

一次压到底只能得到「撑得住」或「挂了」两个结论,最有用的是拐点在哪里。做法是阶梯式增加并发,每一级稳定跑 30 秒,记录 QPS、P95 延迟和错误率。

for c in 10 50 100 200 400 800; do
  echo "=== concurrency=$c"
  wrk -t4 -c$c -d30s --latency "https://你的域名/目标页面" \
    | grep -E 'Requests/sec|Latency|Non-2xx|Socket errors'
  sleep 10
done

每两级之间休息 10 秒,是为了让连接自然回收、TIME_WAIT 有时间消退,否则下一级测试会在一个已经被污染的状态下开始。

读结果的方法是:QPS 随并发线性上涨的那一段,说明系统还有余量;当并发翻倍而 QPS 只涨 10%~20%、延迟却翻倍时,就踩到拐点了。拐点对应的 QPS 大约是这个系统的实际容量上限,而拐点之后的每一分并发都在制造排队。

拐点出现后立刻同步看服务端,这才是有价值的部分。开一个终端跑:

vmstat 1
# 另开一个看 PHP-FPM 队列
watch -n1 'ss -ltn | grep -E ":80|:443"'

vmstat 里如果 us 和 sy 加起来接近 100,是 CPU 瓶颈;如果 wa 高,是磁盘 IO 瓶颈;如果 si 和 so 非零,说明开始用 swap 了,内存不足。这三者对应完全不同的优化方向,很多人压测完只知道「QPS 只有 500」,却不知道 500 是卡在 CPU 还是卡在磁盘。

还有一个常被忽略的观察点:ss -ltn 输出里 Recv-Q 列如果持续非零,说明内核的 accept 队列堆积,Nginx 的 worker 来不及接新连接。这时候要么调大 backlog,要么加 worker 进程。

六、压测机自己的坑:端口耗尽与文件描述符

压测到一定并发,压测机会先崩,而你以为是服务端崩了。两种典型报错:

一是 Cannot assign requested address。这是本地临时端口耗尽。每条出去的 TCP 连接要占一个本地端口,连接关闭后进入 TIME_WAIT 状态并占用 60 秒。如果被测服务不保持 keep-alive,200 并发一秒能产生几千条连接,几秒就把 28000 个端口耗光。解决办法有两个:让压测走 keep-alive(首选),或者临时放大端口范围和缩短 TIME_WAIT:

sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_fin_timeout=15

注意 tcp_tw_reuse 只在客户端侧有意义,而且对 NAT 后的连接要谨慎,生产服务器上不建议随便开。在压测机上临时开着,跑完改回来即可。

二是 Too many open files。默认的文件描述符上限是 1024,200 并发连接就接近上限了。压测前一定要调:

ulimit -n 65535
# 永久生效写到 /etc/security/limits.conf
# * soft nofile 65535
# * hard nofile 65535

同样的道理服务端也要检查。Nginx 的 worker_rlimit_nofile、PHP-FPM 的 rlimit_files,如果不设,默认继承 1024,一万并发根本不可能。这类问题在压测报告里表现为「并发上去之后错误率突然变成 50%」,而在服务端日志里能看到大量 worker_connections are not enough 或者 Too many open files。

判断「到底是谁的瓶颈」有一个简单办法:同时观察压测机的 CPU 和服务端的 CPU。如果压测机 CPU 已经 100%、服务端只有 30%,那这次压测无效,加机器或者降并发重来。

七、读结果:QPS、P95、P99 与错误率

wrk 的输出里有一堆数字,真正要看的是四个:Requests/sec(QPS)、Latency 的 P50/P95/P99、Transfer/sec(带宽)、以及 Non-2xx 与 Socket errors。

QPS 单独看没有意义。要把 QPS 和延迟放在一起看:同样 2000 QPS,P95 是 50ms 和 P95 是 800ms 是完全不同的两个系统。前者是健康的,后者是「能扛但用户已经很难受了」。

P95 和 P99 是最接近用户体感的数字。平均值会被大量快请求拉低,看起来很美;而每 100 个用户里有 5 个遇到慢请求,就足以形成「这站好卡」的评价。经验判据:内容站的 P95 应该控制在 500ms 以内,P99 控制在 1.5 秒以内。超过这个量级,优化收益通常比加配置更大。

Non-2xx 必须为零。压测中如果出现 502/504,说明后端在超时或者进程被打满,此时 QPS 数字再高也没有意义——那是在统计失败请求。502 多数是 PHP-FPM 进程池打满,504 是上游超时。看到这两个数字,先去看 Nginx 的 error_log。

Socket errors 里的 timeout 和 connect 要分开看。connect 错误多半是连不上(服务挂了或者 accept 队列满),timeout 是连上了但没在时间内响应用,两者对应的问题完全不同。

还有一个小技巧:wrk 的 --latency 会打印直方图,看直方图的分布形态比看单个百分位更有信息量。健康的分布是集中在左侧、右侧有一条短尾;如果分布是双峰的,说明系统里有两类请求走了不同的路径(比如一半命中缓存一半没有),这时候要看缓存命中率而不是加机器。

八、频控与限流:压测不能替代防护

压测还有一个常被忽略的用途:验证你的限流策略是否生效。自己的压测在正常情况下相当于一次小规模攻击,正好可以用来检验 Nginx 的 limit_req 与 limit_conn 配置是否符合预期。

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;

server {
    limit_req zone=perip burst=20 nodelay;
    limit_conn conn_perip 50;
}

压测时如果用单机单 IP 打,很可能会被自己的限流挡住,看到大量 503。这不是 bug,是配置在起作用。要测服务真实容量,应该从多个 IP 打;要测限流是否有效,就单 IP 打,看 503 的比例是否与预期一致。

注意限流挡住的请求也会消耗 CPU(在 Nginx 层被拒绝比在后端被拒绝便宜得多),所以「被打到 503」这件事本身也是有限成本的。压测时如果发现 503 比例超过 20%,同时 Nginx 的 CPU 也满了,说明限流配置的位置还可以再往前挪,比如交给 CDN 边缘去挡。

九、把压测写进日常:一次性的数字没有价值

绝大多数人压测一次,把报告存进文件夹,然后再也不打开。等三个月后网站变慢了,那份报告里的数字已经毫无参考价值,因为代码、数据量、访问量全变了。

真正有用的做法是把压测变成一个有固定脚本、固定参数、固定对比口径的日常动作。我自己的做法是每次上线重大改动(换 PHP 版本、加缓存层、改数据库结构)前后各跑一次,参数固定为 -t4 -c100 -d60s,只记录 QPS、P95、错误率三个数,追加到一个 CSV 里。

echo "$(date +%F),$(nproc),100,$(wrk -t4 -c100 -d60s --latency \
  "https://你的域名/" | awk '/Requests\/sec/{q=$2} /99%/{p=$2} END{print q","p}')" \
  >> /root/bench-history.csv

这样三个月后你能回答一个重要问题:「是流量涨了,还是每请求的成本涨了?」 如果是后者,通常意味着某个新功能引入了低效的查询或者丢了缓存命中率,这类衰退是渐进式的,不做基线对比永远发现不了。

另外一栏建议记录「当时的数据量」,比如文章数、评论数、会话表行数。QPS 下降 30% 但数据量涨了 10 倍,是正常的老化;数据量没变而 QPS 掉了,才是真的退化。

十、小结

压测这件事,工具只占一成,方法占九成。归纳成五句话:压测前定清楚要测的问题;先记录空载基线再动手;用阶梯加压找拐点而不是一次压到底;看 P95 和错误率而不是只看 QPS;把结果写进历史表而不是存进文件夹。

还有一条最容易忘:压测机和服务端都要先调文件描述符和端口范围,否则你测到的是操作系统默认配置的极限,而不是你的程序或硬件的极限。很多人压测得出「我的站只能撑 300 QPS」的结论,实际上是被 1024 的 fd 上限卡住的,调完以后直接变成三千。

最后提醒一句:压测是诊断手段,不是优化手段。压出拐点之后,真正的活儿在拐点背后的那个瓶颈上——是慢查询、是进程数、还是磁盘 IO,得靠日志和监控去定位。这些内容散在本站其它几篇文章里,感兴趣可以顺着看下去。

Last modification:November 8th, 2026 at 08:26 pm

Leave a Comment