页面加载慢在哪:五段拆解与定位阈值

「页面打开太慢了」这句话,十个站长里有九个说过,但很少有人能说清楚慢在哪一层。是 DNS 慢、是 TLS 握手慢、是后端 PHP 慢、是数据库慢、还是浏览器渲染慢?如果分不清这一层,优化就只能靠猜:一会儿上 CDN,一会儿开缓存,一会儿换主题,改完之后「感觉快了点」,但下次再慢还是不知道从哪下手。这篇文章给的是一套可执行的定位方法:用浏览器和命令行的几组数字,把一次页面加载拆成五个阶段,每个阶段给出明确的阈值,超了就知道该往哪里查。

一、先把「加载」拆成五段,别混着谈

一次页面请求,从用户敲下回车到看见内容,至少经过五段,每段的负责方完全不同:

第一段,DNS 解析。把域名换成 IP。这一段通常在 20 到 80 毫秒,但第一次访问、或者用户换了个网络环境(比如从 WiFi 切到 4G)时会重新走一遍。它慢起来的典型表现是「第一次打开卡一下,刷新就快」,而刷新快的原因不是服务变快了,是 DNS 被缓存了。第二段,TCP 与 TLS 握手。到服务器的往返,加上 TLS 的额外往返。没有 TLS 1.3、没有会话复用时,这一段可以吃掉 200 毫秒以上。第三段,TTFB(首字节时间)。服务器处理请求并吐出第一个字节的时间,这一段是后端的主战场:PHP 执行、数据库查询、缓存命中与否全在这里。第四段,下载与解析。HTML 下来的同时开始解析,遇到 CSS 和同步 JS 会阻塞渲染。第五段,渲染与首屏。浏览器布局、绘制,加上图片和字体的到达时间。

这五段里,前三段用户的「感知」最强(白屏阶段),后两段影响的是「看起来完成了没有」。所以定位的第一步永远是:先确定白屏时间是哪一段贡献的,而不是先去看主题的 CSS 有多大。

二、第一组数字:TTFB,判断是「网络」还是「后端」

最省事的工具是 curl,它能把连接和传输的时间拆开。用 time_namelookup、time_connect、time_appconnect、time_starttransfer 四个数,做减法就能得到各段耗时:

curl -s -o /dev/null -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}' "https://www.example.com/"

读法是这样的:tcp - dns 是 TCP 建连耗时,正常情况下同城几十毫秒;tls - tcp 是 TLS 握手,TLS 1.3 + 会话复用下应该在 50 毫秒以内,超过 200 毫秒就说明没有复用或者证书链有问题(缺中间证书会让客户端再发一次请求去下载);ttfb - tls 才是后端真实处理时间。四个数里最有诊断价值的是最后这个差值——它跟你的 PHP 代码和数据库直接相关,和网络无关。

一个常见的误判:把 TTFB 400 毫秒当成「网络慢」,然后去上 CDN。但如果 tcp 到 tls 都是 30 毫秒、只有 ttfb 是 400,那问题百分百在源站,上 CDN 只能帮到「第一次请求」(回源还是 400 毫秒),帮不到缓存未命中以外的任何情况。判断 CDN 有没有用,要看命中率,不看「有没有 CDN」。

还有一条必须做的对照:从服务器本机 curl 一次,再从外部 curl 一次。如果本机 TTFB 只有 40 毫秒、外部是 400 毫秒,中间那 360 毫秒就是网络或者 CDN 回源的损耗;如果两边都是 400 毫秒,那就别怀疑网络,直接进 PHP 和数据库。

三、第二组数字:浏览器里的五个时间点

curl 只能测到「第一个字节」,测不到渲染。渲染要看浏览器的 Performance 面板,或者一段几行的 JS:

performance.getEntriesByType('navigation').forEach(function (n) {
  console.log('dns        ', Math.round(n.domainLookupEnd - n.domainLookupStart));
  console.log('connect    ', Math.round(n.connectEnd - n.connectStart));
  console.log('ttfb       ', Math.round(n.responseStart - n.requestStart));
  console.log('domReady   ', Math.round(n.domContentLoadedEventEnd));
  console.log('load       ', Math.round(n.loadEventEnd));
});

这五个数对应上面五段。经验阈值(对内容站,不含广告和第三方脚本):dns 小于 100 毫秒、connect 小于 150 毫秒、ttfb 小于 300 毫秒、domReady 小于 1.5 秒、load 小于 3 秒。超过这些值不是「一定有问题」,而是「值得查一下」。真正的价值在于对比:同一台设备上,把主题换回默认主题再测一次,数字差多少,就知道主题本身吃掉多少。

一个非常容易踩的坑:在后台登录状态下测性能。登录态的页面通常带一整排管理工具条、带调试信息、带不可缓存的动态区块,测出来的数字毫无意义。测真实用户视角必须用无痕窗口、未登录、禁用扩展——广告拦截插件会拦掉 AdSense,让页面看起来快得离谱;而某些「网页加速」插件会预加载,让数字好看到失真。这两类插件在真实用户里都有安装,但它们不该参与你的基线测量。

四、第三组数字:服务器侧到底在等谁

前三组数字告诉你「慢在哪里」,第四组告诉你「谁在等」。PHP 站点最常见的情形是 TTFB 高但 CPU 不高——那不是计算慢,是在等 IO。这时候要看的是「时间花在等什么上」。

最快的判断方法是在 PHP 里临时打点,或者直接看慢请求日志。Nginx 的 $request_time 和 $upstream_response_time 是两个关键变量:前者是整个请求从进来到出去的时间,后者是后端返回的时间。如果 $request_time 明显大于 $upstream_response_time,说明时间花在 Nginx 侧(比如传输大文件、被客户端慢速读拖住);如果两者接近且都很大,就是后端的问题。

log_format timing '$remote_addr $request $status req=$request_time up=$upstream_response_time';
access_log /var/log/nginx/timing.log timing;

拿到这些数据之后按耗时排序,看排在最前面的十个请求是什么。经验是:排在最前的往往是同一个页面的同一个变体(比如某个带筛选参数的文章列表),而不是「所有页面都慢」。这种情况下优化那一个页面,比全局调参数有效一百倍。

如果后端确实慢,接下来要分清是「CPU 密集」还是「等待 IO」。看两个地方:top 里的 wa(IO 等待)占比,和 MySQL 的慢查询。如果 PHP 进程的 CPU 很低但时间很长,去看数据库;如果 CPU 打满,去看代码里有没有全表扫描、有没有在大循环里查库(N+1 查询)。这两类问题的优化方向完全相反,判断错了会白忙一场。

五、第四组数字:渲染阻塞的 CSS 和 JS

TTFB 只有 200 毫秒,但首屏白了两秒,问题就在解析阶段。浏览器的规则很简单:同步的 <script> 会阻塞 HTML 解析,放在 <head> 里的外部 CSS 会阻塞渲染。一个 200KB 的同步 JS 放在头部,等于把首屏推后了它下载执行的全部时间。

排查方法是在 Performance 面板看主线程的长任务,或者更省事地用一段 JS 列出所有阻塞渲染的资源:

performance.getEntriesByType('resource')
  .filter(function (r) { return r.initiatorType === 'script' || r.initiatorType === 'link'; })
  .sort(function (a, b) { return b.duration - a.duration; })
  .slice(0, 10)
  .forEach(function (r) { console.log(Math.round(r.duration), r.name); });

输出的前几名如果是第三方的统计脚本、广告脚本、客服插件,那就是它们。处置方式按优先级:第一,能删的删(很多站装了三四个统计,实际只看一个);第二,能异步的加 defer 或 async,注意 async 是下载完立即执行、顺序不保证,只适合完全独立的脚本(统计类),有依赖关系的必须用 defer;第三,能移到页面底部的移到页面底部;第四,实在要保留的,至少给它加一个加载失败兜底,不要让它卡住后面的解析。

CSS 侧的原则是「首屏必需的内联、其余异步」。把一个 300KB 的 CSS 整体内联进 HTML 是错误做法(HTML 变大、无法缓存、每次都要重下),正确做法是把首屏需要的那部分关键样式内联,其余用 media="print" onload="this.media='all'" 的方式异步加载。这一步对首屏的提升通常比任何服务器优化都明显。

六、把阈值写成一个能跑的检查脚本

上面所有数字,最终要变成一条命令,能每天跑一次、超阈值就提醒。不需要复杂的监控系统,一个 shell 脚本加上 curl 就够了:

#!/bin/sh
# 每天跑一次,把关键页面各测三遍,取中位数附近的样本
for u in "https://www.example.com/" "https://www.example.com/post/1.html"; do
  for i in 1 2 3; do
    curl -s -o /dev/null -w '%{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}' "$u"
    echo " $u"
  done
done

把输出重定向到一个按天命名的文件,一个月后回头看,你会得到一条真实的首屏时间曲线。这条曲线的价值远大于任何单次测试:它能告诉你「上周三那次改版到底带来了多少毫秒」,也能告诉你「某个区域用户的访问是不是在变慢」。单次测试只能回答「现在快不快」,曲线才能回答「为什么变快或变慢」。

脚本里要留一个判断:如果当天采集到的样本 time_starttransfer 全部是 0,说明请求根本没成功(比如站点挂了、证书过期了、被 WAF 拦了),这时候不要把它当成「性能变好了」。这是自动化监控最常见的误报来源——把失败当成 0 毫秒。

七、优化顺序:先做哪个,后做哪个

定完位之后,优化顺序是有讲究的,做反了会浪费时间。我的建议顺序是:

第一步,先修 TTFB 里超过 500 毫秒的部分。因为后端慢是唯一「每次都要付」的成本,CDN 只能帮你省掉一部分,省不掉每一次回源。把慢查询、把首页那几个不必要的统计查询、把没必要的远程 API 调用清掉,通常能拿回 200 到 400 毫秒,这是收益最大的一步。第二步,处理渲染阻塞。把同步第三方脚本改成异步,把非首屏 CSS 异步化,收益通常在 300 毫秒以上,而且没有任何副作用(不涉及数据正确性)。第三步,静态资源加长缓存。带 hash 的文件名给一年缓存,配合发布时改名,几乎零风险。第四步,才考虑 CDN 和图片压缩。CDN 的价值取决于命中率,图片压缩的价值取决于图片占比,这两件事都需要先看清现状再动手,不是默认选项。

反过来,最不该先做的是无差别地调参数:把 worker_processes 调到 8、把 keepalive_timeout 调到 300、把 MySQL 的 buffer 调大一倍。这些动作在没有数据支撑的情况下,效果几乎都是零,风险却是实实在在的(内存被吃完、连接被占满)。

八、几个反复出现的误判

误判一:本地测得好就当用户也快。本地网络到服务器可能是同城直连,用户可能在另一个省走三段运营商链路。判断跨地域性能,要么用第三方的多点测速,要么自己开一台异地的小机器跑同一个脚本对比。

误判二:TTFB 低就以为页面快。TTFB 只覆盖到第一个字节,一个 TTFB 50 毫秒但首屏 4 秒的页面,用户体验依然是「很卡」。这两件事必须分开测。

误判三:上了 CDN 就一定会快。如果站点的主要访问都是未登录的动态页面、或者 CDN 的缓存 key 配得不一致导致命中率很低,加了 CDN 反而因为多一跳而变慢。判断标准只有一个:看命中率和回源量,不看「有没有开」。

误判四:把「首屏」和「加载完成」混为一谈。用户能操作页面(首屏可见)和页面所有资源下载完成(load 事件)经常差好几秒。优化首屏和优化 load 是两件不同的事,前者影响「感觉」,后者影响「统计」。

小结

页面加载慢这件事,难的从来不是优化手段,而是定位。TTFB 拆出网络与后端,浏览器五个时间点拆出解析与渲染,服务器侧日志拆出「谁在等谁」,这三组数字交叉验证之后,慢的原因基本上藏不住。真正该记住的一句话是:先测,再改,改完再测,并且把数字留档。没有对照的优化都是感觉,有对照的优化才是工程。对个人站长来说,一套每天跑一次的 curl 脚本,比任何「性能优化插件」都更有价值。

Last modification:November 9th, 2026 at 07:26 pm

Leave a Comment