网站速度排查实战:TTFB 与渲染分层、FPM 排队判读、三个真实提速案例的完整归因流程

说句实话,"网站加载 1.9 秒"这种数字我从来不信。它既没有说明测的是哪台服务器、哪个地区、什么网络,也没有说明是冷缓存还是热缓存,更没有说明那一秒里究竟花在了网络往返、服务器处理,还是浏览器渲染上。我自己的站在同一天里,用不同工具测出来的 FCP 能从 0.8 秒一直到 2.6 秒,差别不在站,在于测量本身。

这篇记录的是我用了大半年的一套排查顺序:不追求评分,只追求把"慢"拆成可以归因的数字。它需要一台能 SSH 的服务器、浏览器 DevTools,以及一个原则——先分层,再动手。

为什么"评分"一定会误导你

Lighthouse 或 PageSpeed Insights 给出的总分是一个加权后的合成指标。它的输入包括实验室环境下的模拟网络(一般是 Slow 4G + 4 倍 CPU 降速),所以你看到的"移动端 38 分"并不是真实用户的体验,而是"一个被刻意限制的手机在访问你的站"。这个数字有价值——它稳定、可复现、能横向比较改动前后——但它不能告诉你"慢在哪里"。

更麻烦的是它会把几层完全不同的问题混在一起:服务器首字节慢 800ms、字体加载阻塞了文字绘制、一张首屏大图 1.2MB、第三方统计脚本在主线程上跑了 300ms——这四件事在总分里都表现为"性能差",但修法完全不一样。如果你按评分去改,最典型的结局是花一周换 CDN、升级服务器,回来一看分数只涨了 4 分,因为真正的大头是那张图。

所以我现在的做法是:评分只用来做回归检查(改完前端后别把 LCP 从 1.6 秒搞到 2.8 秒),归因则完全靠分层测量。

第一层:分清 TTFB 与渲染,先切一刀

任何一次"网站慢"的排查,第一步都是把总时间切成两段:从发起请求到收到第一个字节(TTFB),以及从第一个字节到页面可用(渲染)。这两段时间的负责人完全不同——前者归运维,后者归前端。

在 DevTools 的 Network 面板里,点开 HTML 主文档,看 Timing 分解:

Queued at 0.0 ms
Started at 1.2 ms
Connection Start      → DNS 8ms / TCP 24ms / TLS 41ms
Request/Response      → Request sent 0.3ms / Waiting (TTFB) 386ms / Content Download 12ms

判断规则很简单:TTFB 超过 400ms 就要查服务端,超过 800ms 基本可以确定问题在后端而不是网络。如果 Waiting 只有 120ms,但整个页面 2.4 秒才可见,那瓶颈就在渲染链路上,去优化数据库毫无意义。

注意这里有个常见误判:Connection Start 里的 DNS/TCP/TLS 加起来经常有 60~100ms,很多人以为是"网络差",其实这是新建连接的一次性成本。要判断你的站有没有把长连接用起来,看 Network 面板里多个资源的 Connection ID 是不是同一个——如果每个资源都是不同 ID,说明连接没被复用,每多一个资源就多付一次三次握手。

第二层:服务端的 TTFB 到底花在哪

确认 TTFB 是主因之后,别急着改配置。先在服务器本地用 curl 打一次自己的站,把网络因素排除掉:

for i in 1 2 3 4 5; do
  curl -o /dev/null -s -w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
    -H 'Host: www.example.com' http://127.0.0.1/index.php
done

如果本机回环的 ttfb 也在 300ms 以上,那和 CDN、和线路、和用户所在地都没关系,是应用在处理。这时候再往下分:PHP 站看 PHP-FPM 的 slowlog 与 status 页,静态站看磁盘 I/O 与 open_file_cache。

我这里踩过最典型的一个坑是"TTFB 随并发升高而暴涨,但 CPU 只有 20%"。

# 观察 FPM 池的排队情况
curl -s http://127.0.0.1/fpm-status?full | egrep 'active|listen queue|max listen|idle'
# active processes: 5
# listen queue: 23
# max listen queue: 41
# idle processes: 0

idle 为 0、listen queue 一直在涨,说明请求在等 worker,而不是 worker 在忙。pm.max_children 设得太小,或者某个慢 SQL 把 worker 长期占住。这时候正确动作是先看 slowlog 抓到的调用栈,而不是直接把 max_children 翻倍——翻倍只是把内存压力往后推,慢查询还在。

反过来,如果 idle 有一堆、active 很低,TTFB 依然高,那就是单请求本身慢,去看 external 调用、DNS 解析、文件锁。我遇到过一次是主题里一个 file_get_contents 去请求外部 API,超时设了 5 秒,每页都等——这种问题在监控里表现为"平均慢",在 slowlog 里表现为"调用栈停在 curl_exec"。

第三层:首字节之后的渲染链路

服务端干净了,剩下的时间几乎都花在"浏览器要等什么才能画出首屏"。这一层要盯的是阻塞链:

  • head 里的同步脚本。任何一个没有 defer/async 的 <script> 都会让 HTML 解析停下来。统计代码、广告脚本、第三方客服,几乎都是这么放进去的。
  • 没有 preload 的关键字体。字体文件通常在 CSS 里被引用,浏览器要先下载 CSS、解析出 font-face、再发字体请求,字体到了文字才可读。这一串是严格串行的。
  • 首屏大图没有尺寸和对齐。图片没有 width/height 会造成 CLS;用了 loading="lazy" 又放在首屏,会造成 LCP 被推迟。
  • CSS 里引用的背景图。浏览器只有解析到那条规则才知道要下载,往往比 img 还晚。

实操上我习惯用一次 Performance 录制来判断,而不是靠猜。录完之后看 Main 线程的长任务:如果有一个 400ms 的黄色块出现在 FCP 之前,点开看它的调用栈,十次里有七八次是第三方脚本。

// 在 Console 里快速列出所有阻塞解析的脚本
[...document.querySelectorAll('script[src]')]
  .filter(s => !s.defer && !s.async)
  .forEach(s => console.log(s.src));

一张对照表:症状 → 归属层 → 第一动作

症状归属层第一动作
TTFB 600ms,本机回环也慢应用 / 数据库slowlog + FPM status,别先改 Nginx
TTFB 快,但 LCP 2.5s前端资源看首屏图体积、字体 preload、阻塞脚本
首页快,内页慢模板 / 查询对比两页的 SQL 条数与耗时
本地快,外地用户慢网络 / CDN多地 ping 与 CDN 命中率
第一次慢,刷新就快冷缓存路径检查缓存是否只在有 Cookie 时失效

一个真实例子:1.8 秒到 0.7 秒,改了三处

我的一个技术博客站,线上 TTFB 常年 700ms 上下,LCP 1.9 秒。按上面顺序查下来:

第一处,FPM status 显示 idle=0、listen queue 峰值 30,max_children 只给了 5。我的机器是 2 核 2G,每个 PHP 进程常驻约 40MB,理论上可以开到 20,但当时怕内存不够设得很保守。调到 12 之后,排队消失,TTFB 从 700ms 掉到 260ms。

第二处,首屏那张封面图是原图直出,1.4MB、2400px 宽,在没有 CDN 的直连下要 900ms。裁成 1200px 的 WebP 之后变成 86KB,同时补上 width/height,CLS 从 0.18 归零。

第三处,head 里有一段统计脚本没加 defer,阻塞解析约 180ms。加 defer 之后 FCP 提前了 150ms 左右。

三处加起来,LCP 从 1.9 秒到 0.7 秒。这里面没有任何一个动作是"升级服务器"或"上 CDN"。这也是我一直强调先分层的原因:在错误的层上花钱,收益是零。

把这套流程变成例行检查

排查一次不算本事,能每周自动跑一次才算。我现在的做法很土但有效:一个 systemd timer,每周日凌晨用 curl 打 5 次首页与 5 次内页,把 ttfb 与 total 写进一个 CSV,再加一条 awk 判断中位数有没有超过基线 30%。超了就发一封邮件给自己。

#!/bin/bash
# /usr/local/bin/web-timing.sh
OUT=/var/log/web-timing.csv
[ -f "$OUT" ] || echo "ts,url,ttfb,total" > "$OUT"
for path in / /about.html /archives.html; do
  for i in 1 2 3 4 5; do
    curl -o /dev/null -s -w "%{time_starttransfer},%{time_total}\n" \
      "https://www.example.com${path}?_=$(date +%s%N)" \
      | awk -v ts="$(date -Iseconds)" -v u="$path" -F, \
        '{printf "%s,%s,%.3f,%.3f\n", ts, u, $1, $2}' >> "$OUT"
  done
done

用 ?_=$(date +%s%N) 是为了绕过 CDN 与浏览器缓存,测的是真实回源路径。如果反过来你想测用户实际体验,就别加这个参数——两种测法都要有,混用会得出很荒谬的结论。

几个容易被当成"性能问题"的假问题

DevTools 开着测。断点、Network 面板的记录、大量 console 输出都会拖慢页面,尤其是长列表站点能差出 30%。测试时开一个无痕窗口,关掉其他扩展。

只看平均值。一次 20 个样本的平均 TTFB 是 300ms,可能是 19 次 200ms 加一次 2.2 秒。那次 2.2 秒才是用户投诉的来源。所以要把 P95 一起看,样本量至少 20。

在错误的机器上测。在自己电脑上测机房里的站,中间隔着运营商骨干网,测出来的抖动和你的服务器无关。线上问题的复现要在服务器本机、在 CDN 节点、在真实用户侧分别测一遍。

把压缩当万能药。gzip 对 HTML/CSS/JS 有大收益,对已经是 JPEG/WebP 的图片基本零收益,某些情况下甚至变大。压错类型只会浪费 CPU。

小结

性能排查最怕的是"感觉"。感觉服务器该升级了、感觉 CDN 有用、感觉图片已经压过了——这些感觉每一条都可能花掉你一个周末。真正省时间的做法是先花十分钟把时间轴切开:TTFB 还是渲染,本机还是网络,单请求还是排队。切完之后,问题通常只剩一个方向,剩下的就是执行。

至于评分,我把它当回归测试用:改完之后别变差就行。分数的绝对值不重要,重要的是你知道自己的站哪一层是瓶颈、下次出事该先看哪个数字。

Last modification:October 17th, 2026 at 01:27 pm

Leave a Comment