一条命令背后的七个环节
在浏览器里打开 https://www.example.com/,再到页面上出现文字,中间经过的环节比大多数人想象的多:DNS 解析、TCP 握手、TLS 协商、HTTP 请求、服务端处理、响应传输、浏览器解析渲染。用户感受到的「慢」,可能是其中任何一个环节,也可能是几层叠加。
做性能优化最忌讳的就是「凭感觉」。有人说「加个 CDN 就快了」,有人说「上 Redis」,有人说「图片压一压」。这些手段各自对应不同的瓶颈,套错地方不但不解决问题,还会引入新的复杂度。正确的方法是先测量,找到那个占了总时间大头的环节,再针对性动手。这篇文章讲的就是怎么在一台只有命令行的服务器上,用系统自带和几个小工具,把这条链路拆开量清楚。
第一层:先分清「网络慢」还是「服务端慢」
最容易被误判的是这一层。站长发现在家打开很慢,第一反应是服务器出问题了,结果远程登录一看 CPU 负载 0.1、内存空着一半。这时候大概率是网络路径的问题:本地宽带的国际出口拥塞、运营商的 DNS 解析到了远处的节点、或者中间某跳丢包。
区分方法很简单,在服务器上对本地回环发一次请求,再对自己公网 IP 发一次:
# 只测服务端处理能力,完全不经过网络
curl -s -o /dev/null -w "loopback: dns=%{time_namelookup} conn=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
-H "Host: www.example.com" http://127.0.0.1:8080/
# 再测一次走公网的(从服务器自己打自己的公网 IP)
curl -s -o /dev/null -w "public: dns=%{time_namelookup} conn=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
https://www.example.com/
如果 loopback 的 time_starttransfer 是 0.08 秒,public 的是 0.12 秒,说明服务端本身没问题,那 0.04 秒的差就是网络加 TLS 的开销,完全正常。反过来,如果 loopback 就要 1.2 秒,那问题一定在 PHP、数据库或者磁盘上,跟网络无关,这时候去折腾 CDN 纯属浪费时间。
curl -w 输出的几个时间是分段累加的,理解它们的含义能直接定位瓶颈:
time_namelookup:DNS 解析耗时。超过 0.1 秒说明解析链路有问题,检查/etc/resolv.conf里的 DNS 服务器响应速度,可以用dig @8.8.8.8 www.example.com对比。time_connect:TCP 三次握手完成的时间。这个减去 namelookup 就是纯网络往返延迟。国内到海外机房通常在 0.15 到 0.3 秒之间,超过 0.5 秒连接质量就有问题了。time_appconnect:TLS 握手完成时间。对 HTTPS 请求,这个减去 connect 就是 TLS 开销。首次连接包含证书链验证和密钥交换,1-RTT 大概是 2 到 3 个网络往返。time_starttransfer:收到第一个响应字节,也就是 TTFB。这个是服务端处理时间的真实体现,包含 PHP 执行、数据库查询、模板渲染的全部耗时。time_total:整个请求完成。total 减去 starttransfer 就是内容传输时间,如果这个差值很大而内容体积不大,说明带宽被占满或者存在丢包重传。
第二层:TTFB 高的时候,看 PHP 还是数据库
假设测量结果是 TTFB 一点二秒,其他环节都正常,那么问题就在服务端。这时候要回答的下一个问题是:时间花在 PHP 执行上,还是花在 MySQL 查询上?
最快的办法是打开 PHP-FPM 的慢日志,把阈值调低到 1 秒,观察一天:
; /etc/php/*/fpm/pool.d/www.conf
request_slowlog_timeout = 1s
slowlog = /var/log/php-fpm-slow.log
request_terminate_timeout = 45s
慢日志里会有完整的 PHP 调用栈,栈顶的函数就是卡住的地方。如果栈顶是 mysqli_query 或者 PDO 的执行方法,那就是数据库慢;如果栈顶是 file_get_contents,那可能是某段代码在同步请求外部接口(很多统计插件、gravatar 头像、外链检测都会这么干);如果是 preg_match,那是正则回溯爆炸。
顺带说一下 request_terminate_timeout:这个参数设成 45 秒,意思是单个请求超过 45 秒就被 FPM 直接杀掉。它的作用和 max_execution_time 完全不同——后者只计算 PHP 脚本自身的执行时间,遇到 sleep()、数据库阻塞、网络请求这些「等待」是不计时的。所以一个卡在数据库上的请求,max_execution_time=30 也可能跑上十分钟,只有 request_terminate_timeout 能真正把它掐断。
确认是数据库之后,看 MySQL 的慢查询日志。long_query_time 设成 0.5 秒比较合适,同时打开 log_queries_not_using_indexes——后者会记下所有没走索引的查询,虽然会产生一些噪音,但对找出「忘记加索引」这种典型问题非常有效。
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = ON;
-- 现在的 MySQL 8.0 默认写进 mysql.slow_log 表,也可以改成写文件
SHOW VARIABLES LIKE 'slow_query_log_file';
第三层:用 EXPLAIN ANALYZE 验证真实的执行代价
找到慢 SQL 之后,很多人只看一眼 EXPLAIN 输出里的 rows 列就下结论。这一步很容易误判,因为 rows 是优化器基于统计信息算出来的估计值,统计信息过期或者数据分布倾斜的时候,估计和实际能差好几个数量级。
MySQL 8.0 提供了 EXPLAIN ANALYZE,它会真正执行这条查询,然后输出每个节点的实际行数、实际耗时和循环次数:
EXPLAIN ANALYZE
SELECT id, title, created
FROM posts
WHERE status = 'publish' AND created >= '2026-01-01'
ORDER BY created DESC
LIMIT 20;
输出里形如 (cost=1234 rows=567) (actual time=0.05..12.3 rows=20 loops=1)。这里的 actual ... rows=20 是最后一个节点实际返回的行数,中间的节点才是关键——如果某个扫描节点的 rows 估计是 100 而实际是 48000,说明优化器严重低估了这个条件的过滤能力,它可能因此选错了索引或者选错了连接顺序。
loops 这个字段尤其值得注意。一个 loops=1000 的子查询,即使单次耗时只有 0.05 毫秒,乘起来也是一秒。很多人看执行计划时只盯着每个节点的实际耗时和预期对比,忽略了时间要乘以循环次数,结果就是「每个环节都很快,加起来却慢」的诡异现象。
还有一种情况是用 LIMIT 掩盖了问题。查询最后只返回 20 行,看着很快,但它是先扫描了二十万行、排序、然后取前 20 行。这时候要看的是最内层扫描节点的 actual rows,如果那个数字是二十万,那么随着数据增长,这个查询迟早会慢下来。这类「现在能用但会随时间恶化」的查询,比当下就慢的查询更危险,因为没人会去优化它。
第四层:请求数太多时,前端才是主战场
有时候 TTFB 只有 0.15 秒,看起来很不错,但用户反馈「打开很慢」。这种情况多半是前端资源的问题:一个页面引用了几十个 JS/CSS 文件、字体文件阻塞渲染、图片没有压缩。服务端再快也救不了。
在命令行里没有浏览器的瀑布图,但有个替代方案:curl 抓下 HTML 之后统计资源数量和体积。
# 抓首页 HTML,统计各类外部资源
curl -s https://www.example.com/ -o /tmp/home.html
grep -oE '(src|href)="[^"]+\.(js|css|woff2?|png|jpg|jpeg|webp|svg)' /tmp/home.html \
| sed -E 's/^(src|href)="//' | sort -u > /tmp/assets.txt
wc -l /tmp/assets.txt # 资源总数
# 逐个测体积和耗时(用 HEAD 只拿头)
while read u; do
curl -sI --max-time 5 "$u" | awk -v u="$u" '
/^[Cc]ontent-[Ll]ength:/ {len=$2}
END {printf "%8d %s\n", len, u}'
done < /tmp/assets.txt | sort -rn | head -15
这段脚本会按体积从大到小列出前十五个资源。经验上,一个内容型页面的总传输量应该控制在 1MB 以内(不含图片),JS/CSS 文件数控制在十个以内。如果发现有十几个 200KB 以上的 JS,那基本可以确定前端是瓶颈,该做的是合并、按需加载、去掉不用的插件脚本,而不是继续优化服务端。
字体文件是另一个高频问题。一个中文字体包动辄 3 到 8MB,如果它在 <head> 里用 @font-face 加载又没有 font-display: swap,浏览器会等字体下载完才渲染文字,用户看到的是几秒钟的白屏。解决办法是用 font-display: swap 先显示系统字体,或者用 unicode-range 做分片,只加载页面实际用到的那部分字符。
第五层:用压测区分「单请求慢」和「并发下慢」
前面的测量都是单请求视角。但真实的线上是并发的,一个「单个请求 80 毫秒」的页面,在 50 并发下可能变成 3 秒。这两者是不同的问题:前者是算法和 IO 效率,后者是容量和排队。
区分方法是在服务端跑一次小规模压测,同时观察后端的处理状态。wrk 是个轻量的选择,不需要装一堆依赖:
# 10 个连接、持续 30 秒、打文章列表页
wrk -t4 -c10 -d30s --latency https://www.example.com/archives/
输出里的关键指标是 Latency 的分布和 Requests/sec。如果 p50 是 90 毫秒而 p99 是 2 秒,说明存在少数请求特别慢(通常是缓存未命中、或者撞上了锁),这比「所有请求都慢一点」更值得关注,因为用户的体感由慢请求决定。
压测期间同时看 PHP-FPM 的状态页,能看到是不是进程池被打满了:
; 打开 pool 状态页
pm.status_path = /fpm-status
; nginx 侧只允许本机访问
location = /fpm-status {
allow 127.0.0.1; deny all;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
}
curl http://127.0.0.1/fpm-status?full 会输出 active processes、listen queue、max children reached 等字段。listen queue 只要不是 0 且持续增长,就说明进程池不够用,请求在排队;max children reached 大于 0 说明历史上曾经打满过。这种情况下,优化代码减少单请求耗时比加进程数更根本——加进程只是把排队往后推,内存和数据库连接会先扛不住。
把测量做成常态,而不是出事才做
上面五层是「排查」的顺序,但真正有效的性能管理是「日常观测」。道理很简单:出问题时你已经很着急了,这时候才开始建立基线、找工具、学命令,判断质量一定差。提前把数据采好,出问题时只要和昨天的曲线对比,异常点一眼就能看出来。
最小可行的方案是每小时跑一次轻量测量,把结果写进一个 append-only 的日志文件。用 monitor.sh 加 cron 就够了,不需要上 Prometheus 那种重型方案(除非你本来就有一套)。
#!/bin/bash
URL="https://www.example.com/"
OUT=/var/log/perf/day-$(date +%F).log
curl -s -o /dev/null --max-time 10 \
-w "%{time_iso8601} dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code} size=%{size_download}\n" \
"$URL" >> "$OUT"
这个日志攒两周之后价值就出来了。你可以统计每天的 TTFB 中位数,看它是不是在缓慢上升——缓慢上升通常意味着数据量在增长而索引或者缓存策略没跟上,这类问题用告警发现不了(因为没超过阈值),只有看趋势才能发现。也可以对比 total 和 size_download,如果某天开始传输时间明显变长而体积不变,那可能是上游带宽或者 CDN 回源出了问题。
还有一个容易被忽略的点:从多个位置测。同一台服务器,从本地测 80 毫秒,从另一个机房测 400 毫秒,这个差异本身就是信息。如果差异稳定,那是物理距离,没什么可优化的;如果某天突然拉大,那就是路由或者中间链路的临时问题,等几小时可能会自己恢复,不需要动服务器。区分「该动手」和「该等待」,是运维经验里很值钱的一部分。
几个常见的误判
误判一:CPU 使用率高就是性能问题。 一台跑着 PHP 的机器,CPU 在 40% 到 60% 之间波动是健康的,说明资源在被有效利用。真正值得警惕的是 CPU 不高但请求很慢——那意味着进程在等待 IO 或者锁,属于另一类问题,去看 iostat 和 vmstat 的 wa 列。
误判二:加了缓存就一定变快。 缓存的收益取决于命中率。站点只有几百个页面而每天访客几百人,每个页面被访问的频率很低,缓存命中率可能只有 20%,这时候缓存的维护成本和「缓存不一致」的调试成本,远超它省下的那点时间。先算命中率,再决定要不要加。
误判三:TTFB 低就是快。 TTFB 只衡量到第一个字节,用户真正关心的是「内容可见」。一个 TTFB 100 毫秒的页面,如果要在浏览器里再跑三秒的 JS 才能渲染出内容,用户依然觉得慢。所以服务端的指标和前端指标必须一起看,缺一个都会得出错误结论。
说到底,性能优化不是「用上更多技术」,而是「知道时间花在哪里」。测量做扎实了,需要做的事情往往比想象中少——很多时候只是加一个索引、删掉一个阻塞的外部请求、或者让一张图从 2MB 变成 200KB,就够了。