压测很漂亮上线就卡:单机压测与线上表现背离的四层成因排查

为什么单机压测很好看,上线就卡

几乎每个个人站长都经历过这样一个阶段:在自己电脑上用 ab 或者 wrk 压一下本地或测试机,QPS 数字漂亮得让人安心,于是信心满满地上线,结果真实流量一进来,页面响应时间从 200ms 掉到 3 秒,甚至开始零星 502。这时候大家的第一反应通常是「配置不够」或者「代码有问题」,于是一遍遍翻 Nginx 配置、查 PHP 慢日志,最后发现两样都没问题。真正的原因,往往藏在压测方法本身——你压的是单点,而线上的瓶颈其实在别处。

这篇文章不打算重复「加大内存」「上缓存」这类泛泛的建议,而是聚焦一个非常具体、非常常见的场景:单机压测结果与线上真实表现严重背离。我会把这类背离拆成几个可验证的成因,并给出可以直接照做的定位方法,最后附上一份压测前必须核对的自查清单。整篇思路适用于 Nginx + PHP-FPM + MySQL 的经典个人站长架构,换成其他组合原理相通。

第一层:压测工具压的是后端,不是完整链路

用 ab 或 wrk 直接打 http://127.0.0.1/index.php 的时候,你绕过了 DNS 解析、TLS 握手、CDN 回源、真实公网 RTT,甚至连 Nginx 的静态资源处理路径都跳过了。这意味着一件事:你测出来的数字只反映 PHP 执行加数据库查询的耗时,而线上用户感受到的是「DNS + TCP + TLS + 排队 + 应用 + 回传」的全链路耗时。

一个典型的量化差距是这样的:本地压测显示 P95 是 180ms,看起来很不错;但线上真实用户的 P95 可能是 1.4 秒。多出来的 1.2 秒里,TLS 握手可能占 200ms(首次连接)、CDN 回源等待可能占 300ms、网络抖动与重传可能占 400ms,剩下的才是排队。也就是说,你压测时看到的「后端很快」是真的,但用户体感慢也是真的,两者并不矛盾——它们量的是不同的东西。

正确的做法是压完整链路,而不是压回环地址。具体来说,把压测目标换成真实域名:

wrk -t4 -c200 -d60s --latency https://www.example.com/

注意两个细节:一是必须用真实域名和真实 TLS,二是连接数要贴近你预期的并发峰值而不是随便设。如果你担心压测流量污染线上数据或日志,可以在 Nginx 里对这个压测来源 IP 单独做日志降噪,或者干脆临时用测试子域名回源到同一台机器,这样既走了真实链路,又不影响正式访问统计。

第二层:并发模型不对,压测数字失去意义

ab 是单线程、单连接的同步工具,它的并发是靠多进程模拟的,在高并发下自身就会成为瓶颈,CPU 先被压测工具吃掉,你测到的其实是 ab 的极限而不是服务器的极限。wrk 用 epoll,能轻松发出数万并发,但它默认不模拟真实用户行为——不放真人化的思考时间,不加载静态资源,不发后续的 XHR 请求。

这就导致两种典型误判。第一种是「压低了」:用 ab 压高并发,测出的 QPS 远低于服务器真实能力,于是你误以为配置不行,白白加了一堆优化。第二种是「压高了」:用 wrk 只压一个纯内存返回的接口,测出几万 QPS,然后就以为整站能扛住,实际上首页要查十几次数据库、渲染模板、拼接多个片段,真实 QPS 可能是压测值的十分之一。

更贴近真实的做法是用能模拟场景的工具。如果不想引入太重的东西,一个务实的折中是用 wrk 配合 Lua 脚本,加入随机延迟和多种 URL 混合:

-- mix.lua
request = function()
  local paths = {"/", "/post/123.html", "/category/tech/", "/static/app.css"}
  local p = paths[math.random(#paths)]
  return wrk.format("GET", p)
end

然后这样跑:

wrk -t4 -c200 -d120s --latency -s mix.lua https://www.example.com/

混合静态与动态请求之后,你得到的数字会明显更接近线上,因为静态资源会占用连接、动态请求会占用后端 worker,两者对资源的竞争在压测里才能体现出来。

第三层:被忽略的连接建立成本

单机压测往往复用长连接,测完一轮下来只建了几百个 TCP 连接。但真实场景里,用户来自四面八方,每一个新访客都要经历 DNS 查询、TCP 三次握手、TLS 握手(1-RTT 或 2-RTT)。一个访客如果只访问一个页面就走了,那么连接建立的成本可能比页面本身的渲染时间还长。

这就是为什么「开启 HTTP/2 之后反而感觉没快多少」的情况经常出现——HTTP/2 优化的是单连接上的多路复用,对已经建立连接的会话收益很大,但对「只来一次就走」的流量帮助有限。想真正降低这部分成本,需要从三个地方入手:一是启用 TLS 会话复用(ssl_session_cache shared:SSL:10m;ssl_session_timeout 1d;),让回访用户跳过完整握手;二是确保 CDN 开启了连接复用和 TLS 会话票据;三是把首屏关键的静态资源合并或内联,减少建连次数。

压测时要专门为「新建连接」做一轮测试。可以用 wrk 配合 --latency 并定期断开连接,或者用 curl -w 观察各阶段耗时:

curl -o /dev/null -s -w "dns:%{time_namelookup} conn:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" https://www.example.com/

把这几段拆开看,你会很清楚地知道时间花在哪一段:如果 time_connect 异常大,问题在网络或连接队列;如果 time_appconnect 显著,问题在 TLS 配置;只有 time_starttransfer 大,才轮到后端应用负责。

第四层:压测时的「假干净」环境

测试机上常见的另一个陷阱是环境过于干净。测试机往往没有开 opcache(或者刚重启还没热)、没有真实的日志写入压力、没有定时任务在跑、磁盘是 SSD 且几乎空闲。而线上机器上,日志在持续写、cron 在跑、备份在占用 IO、磁盘可能已经用了七成导致写入变慢。

光这一条就足以造成 2 到 3 倍的性能差异。要缩小这个差距,压测前必须让测试环境尽量贴近线上:把 opcache 打开并预热,把日志级别和线上设成一致(很多人测试时开着 debug,线上是 warn,日志 IO 压力完全不同),并确认磁盘使用率大致相当。一个简单有效的验证是:压测前后各跑一次 iostat -x 1 5,看看 %utilawait 有没有被压满。如果 %util 上到 90% 以上,那么你的瓶颈根本不是 CPU 或 PHP,而是磁盘 IO,继续调 PHP-FPM 参数是南辕北辙。

压测前必须核对的自查清单

把上面四层收敛成一张可以在动手前逐条打勾的清单,能省掉大量瞎调时间。清单如下:

第一,压测目标是否是真实域名与真实 TLS,而不是 127.0.0.1 回环地址。第二,压测工具的并发模型是否与目标并发量匹配,是否混合了静态与动态请求。第三,脚本里是否加入了随机延迟和多种 URL,而不是死循环打同一个接口。第四,是否单独测过「新建连接」场景,而不只是复用长连接的场景。第五,测试环境的 opcache、日志级别、磁盘使用率是否与线上一致。第六,是否在压测过程中同时观察了 iostatss -stop,确认瓶颈落在哪一层。

还有一条容易被忽略的:压测和被压的机器不要是同一台。很多人图省事在服务器本机跑压测脚本,这时候压测进程和 Web 服务抢同一个 CPU 和同一块磁盘,结果完全失真。至少把压测端放到另一台机器或另一台云主机上,并且确保压测端与被压端之间的网络带宽不是瓶颈(如果压测端出口只有 10Mbps,那测出来的上限就是 10Mbps 的带宽,跟服务器能力无关)。

从压测结论到线上调优的正确顺序

拿到一份可信的压测数据之后,调优顺序也应该有讲究。正确的顺序是:先解决排队,再解决单次耗时,最后才考虑加机器。所谓排队,指的是 Nginx 的 worker_connections、PHP-FPM 的 max_children、MySQL 的 max_connections 这几个池子的容量,一旦访问量超过池子容量,请求就会被拒绝或排长队,这时候加 CPU 没用,加池子才有用。所谓单次耗时,指的是数据库慢查询、模板渲染、外部 API 调用这些具体环节,用慢日志和 profile 定位。最后才是横向扩容。

很多个人站长一遇到卡顿就想着升级服务器套餐,其实绝大多数中小站点的瓶颈都在「池子太小」和「慢查询太多」这两件事上。把这两件事解决掉,一台普通的 2 核 4G 机器撑住日均几万 PV 是完全可能的。压测的价值不在于得到一个好看的数字,而在于让你清楚地知道:当访问量翻十倍的时候,第一个倒下的环节会是哪一个。知道这个,你才能提前把资源加在正确的地方。

Last modification:September 24th, 2026 at 08:25 pm

Leave a Comment