网站加载慢怎么解决?先用 TTFB 定位瓶颈,再决定 CDN、Cache-Control 与 TTL 怎么配,附压缩、图片格式与 HTTP/2 的真实收益和测量方法。
「网站加载慢」是最常见、也最容易被误诊的问题。慢可能出在 DNS 解析、TTFB、图片体积、JavaScript 执行,也可能只是缓存头没配对。这篇按真实诊断顺序展开:先用命令定位瓶颈,再决定要不要上 CDN、缓存设多久、图片怎么压——而不是先买 CDN 再猜哪里慢。
判断顺序:先看 TTFB,再看传输体积,最后看渲染阻塞。CDN 只能解决前两项里的一部分,第三项(JS 执行、重排)必须靠代码和构建侧优化。
先定位瓶颈:加载慢到底慢在哪一段
把一次页面加载拆成四段,问题通常就现形了:连接建立(DNS + TCP + TLS)、等待首字节(TTFB)、传输(下载 HTML / CSS / JS / 图片)、解析与渲染(JS 执行、布局、绘制)。四段的优化手段完全不同,用错工具等于白花钱。
- TTFB > 600ms:瓶颈在后端或区域。优先考虑 CDN + 换更近的源站区域
- TTFB < 200ms 但首屏慢:瓶颈在资源体积与阻塞,先压图片、合并请求
- 第二次访问快、第一次慢:缓存已经生效,重点优化首访(预热、关键 CSS 内联)
定位只需一条命令,四个数字就能分清是谁的问题:
curl -s -o /dev/null -w "dns:%{time_namelookup}s tcp:%{time_connect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n" https://example.com/
time_starttransfer 就是 TTFB。再加 -H 'Cache-Control: no-cache' 重测一次:如果 TTFB 明显升高,说明之前命中的是 CDN 缓存而不是源站的真实响应速度。
CDN 实际做了什么:边缘节点、反向代理与缓存命中
CDN 本质是放在用户旁边的一层反向代理加缓存。它把可缓存内容缓存在各主要城市、各大运营商出口的边缘节点(PoP)上,用户就近取数据,而不是每次都回源站。节点覆盖、计费口径与回源策略怎么比,见 CDN 加速。
- 延迟降低:用户到边缘节点通常 5–30ms,替代掉回源的 100–200ms 跨境往返
- 缓存命中:命中时完全不回源,实务中静态资源的 CDN 命中率普遍在 80%–95%
- 源站减压:带宽与连接数压力转移到 CDN,源站只需保留兜底带宽
- 协议优化:多数 CDN 默认支持 HTTP/2、HTTP/3 与 TLS 1.3,可统一开启
怎么确认是否命中:看响应头。Cloudflare 是 cf-cache-status: HIT,AWS CloudFront 是 x-cache: Hit from cloudfront,Fastly 是 X-Cache: HIT,阿里云 DCDN / 腾讯云 CDN 常见为 X-Cache。字段名各家不同,先 curl -sI https://你的域名/ 看一眼再判断。
首次访问未命中时,CDN 可能比直连源站还慢一点,因为多了一跳边缘节点。所以规则要配对:静态资源长缓存,HTML 短缓存。HTML 被长期缓存才是 CDN 上线后最常见的翻车点。
TTFB 决定首屏上限,也直接决定转化
TTFB(Time To First Byte)是浏览器发出请求到收到第一个字节的时间。它决定了 HTML 什么时候开始下载,也基本决定了 LCP 的下限——实测中 TTFB 常占 LCP 的一半以上。Google 的参考线是 TTFB ≤ 800ms 为良好,Core Web Vitals 要求 LCP ≤ 2.5s,超出后留给你渲染的时间会被直接吃掉。
更现实的问题是营收。Google 与 SOASTA 的大型零售性能研究里,移动端页面速度每下降 1%,销售额下降约 1%;在人为提速的 A/A 实验中,首屏快 20% 对应转化率提升约 9%。这类数字来自特定样本,别当成自己的预期值,但方向和量级足够说明:TTFB 是营收项,不是技术洁癖。
可落地的目标与手段:
- CDN 命中场景,把 TTFB 稳定控制在 200ms 以内
- 源站区域选在用户附近,跨境往返每省 100ms,用户感知就是「快了一档」
- 用页面级缓存或反向代理缓存,绕开重复的数据库查询
- 把首屏不需要的模块(推荐位、评论、第三方脚本)改为异步加载
判断是否真的变快了:CDN 上线后 TTFB 从 150ms 掉到 30ms,但总时长没变,说明瓶颈已经转移到传输体积或渲染——这时继续调缓存是浪费时间,应该转向图片与 JS。
Cache-Control:把缓存策略写进响应头
浏览器和 CDN 都只认响应头。缓存策略不写在 Cache-Control 里,默认行为就是「每次重新验证」,等于没开缓存。下面四组是最常用的写法。
带内容 hash 的 JS / CSS
Cache-Control: public, max-age=31536000, immutable
HTML 页面
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
带版本号的图片
Cache-Control: public, max-age=2592000
登录后的页面 / 个人数据
Cache-Control: private, no-store
max-age:浏览器缓存秒数;s-maxage:共享缓存(CDN)秒数,优先于 max-ageno-cache≠ 不缓存:允许存储,但每次必须回源校验(可用 304,不传 body)no-store:完全不写缓存,个人数据只该用这个immutable:刷新页面也不重新校验,只对文件 URL 带 hash 时安全stale-while-revalidate:过期后先返回旧内容再后台更新,HTML 强烈建议加
压缩要生效,必须带上 Vary: Accept-Encoding,否则 CDN 可能把 brotli 版本发给不支持的客户端,或反过来发 gzip 版本。
TTL 怎么定:静态与动态内容的差别
TTL 的核心取舍只有两个:更新延迟(内容多久能生效)与回源率(源站承受多少请求)。静态资源把 TTL 拉长、更新交给文件名 hash;动态内容用短 TTL 换新鲜度。把静态资源从应用服务器搬到对象存储再挂 CDN,是这一步最省事的做法——存储分层与生命周期规则见 AWS S3 存储分层。
| 内容类型 | 建议 Cache-Control | TTL 参考 | 说明 |
|---|---|---|---|
| 带 hash 的 JS / CSS | public, max-age=31536000, immutable | 1 年 | 改内容就换文件名,可放心长缓存 |
| 带版本号的图片 / 字体 | public, max-age=2592000 | 30 天 | 换 URL 或改文件名触发更新 |
| HTML 页面(文章、首页) | public, s-maxage=60, stale-while-revalidate=300 | 60 秒 – 5 分钟 | 发版后基本立即可见 |
| GET 型公开 API(列表、行情) | public, s-maxage=5-30 | 5–30 秒 | 按业务可接受的陈旧度定 |
| 带鉴权的 API / 购物车 / 订单 | private, no-store | 不缓存 | 任何公共缓存命中都是数据泄露 |
| 已登录页面(HTML) | private, no-cache | 每次校验 | 避免 A 用户看到 B 用户内容 |
| 第三方脚本与字体 | 按供应商建议 | — | 优先本地托管,别把核心渲染压在第三方上 |
动态内容从 5–30 秒起步,而不是直接 0:先观察业务真实更新频率,如果某接口一天只改几次,TTL 放到 60 秒也没问题,回源率能降一个量级。
浏览器缓存、CDN 缓存与源站缓存的分工
一次请求会依次经过三层,能在上一层解决就不要往下传。三层各自独立配置,失效逻辑也不同。
- 浏览器缓存:受
max-age控制,二次访问零网络请求(DevTools 里显示 memory cache / disk cache) - CDN 缓存:受
s-maxage控制,命中后不回源,是节省源站带宽的主力 - 源站缓存(Nginx / 应用层页面缓存):CDN 未命中时的最后一道防线,直接决定 TTFB
当 CDN 不缓存或需要刷新时,浏览器与源站之间靠协商缓存:ETag(或 Last-Modified)配合请求头 If-None-Match,内容没变就回 304 Not Modified,不传 body。协商缓存省的是带宽和时间,不会减少请求次数。
别把 HTML 设成长期缓存。 页面结构变了但用户拿到的是旧 HTML,是比慢更严重的问题——发版后功能异常、链接 404。HTML 的 TTL 应该短到能被业务接受。
压缩与图片:省下来的都是传输时间
同等内容下,传输时间正比于字节数。10Mbps 带宽传 1MB 需要约 0.8 秒,传 300KB 只需要约 0.25 秒——这不是理论值,直接乘进首屏时间。
- 文本压缩:HTML / CSS / JS 开 gzip 通常减少 60%–80%;brotli 在同等质量下再比 gzip 小约 15%–25%
- 图片格式:WebP 相对 JPEG 常见节省 25%–34%,AVIF 相对 JPEG 常见节省约 50%
- 按屏幕发图:用
srcset+sizes给 2 倍屏用户发 2 倍图,避免所有设备都下载大图 - 延迟加载:首屏以下的图片加
loading="lazy",首屏主图不要加 - 预留尺寸:图片写明
width/height,把 CLS 压到 0.1 以内
LCP 图要特殊对待:加 fetchpriority="high" 或 <link rel="preload" as="image"> 提前发起请求,并且排除 lazy load。它是首屏最大的资源,抢不到请求槽位,压缩收益再高也没用。
多数 CDN 支持按 Accept 请求头自动转换 WebP / AVIF,并在边缘完成 brotli 压缩。启用前确认两件事:厂商是否支持,以及转换后是否仍走同一套缓存键(否则一个 URL 会缓存两份格式,命中率白白下降)。
HTTP/2 与 HTTP/3:连接复用到底值多少
一个包含 40 个静态资源的页面,在 HTTP/1.1 下通常要开 6 个连接、经历多次 TCP 与 TLS 握手。HTTP/2 用单连接多路复用加 HPACK 头部压缩,一次握手就能并发请求,实测一般能省 100–300ms 的首屏时间。
- CDN 与源站都要开 H2。只在 CDN 开,边缘到源站那段仍走 HTTP/1.1,收益减半
- 不要再做「合并成一个大文件」「跨域分片域名」——这是 HTTP/1.1 时代的解法,在 H2 下反而拖慢解析
- HTTP/3 基于 UDP / QUIC:支持 0-RTT 恢复与更好的丢包恢复,移动网络切换时体验提升明显
HTTP/3 要灰度再上。 部分企业防火墙、老旧运营商网络与中间设备会阻断 UDP。可用 curl --http3 -s -o /dev/null -w "%{http_version} %{time_starttransfer}s\n" https://example.com/ 验证协商是否成功,失败时应自动回落到 HTTP/2。
内容变了,缓存怎么失效
长缓存带来长 TTL,也就带来「改了内容用户看不到」的问题。按可靠性排序,三种方案优先级不同。
- 版本化 URL(首选):文件名或查询串带内容 hash,如
app.4f3a2b.js。新内容天然是新 URL,不需要任何刷新动作,CDN 命中率不受影响 - 按 URL / 目录刷新:内容必须保持同一 URL 时才用。发布流水线里发一次刷新请求,通常几秒到几十秒内全网生效
- 预热:发布后主动回源拉取新版本(
curl预热接口或控制台预热),避免冷启动时大量用户同时穿透到源站
三种方案的生效时间:版本化 URL 首次访问即生效(无需任何操作);单 URL 刷新通常几秒到几十秒内全网生效,接口级刷新接口限速的差异较大;预热则是主动提前拉取,成本最低但需要你在流水线里多写一步。
注意刷新本身有成本:全量刷新会造成回源风暴,大流量站点应在低峰期执行,或配置刷新限速与自动预热。刷新后立即用 curl -sI 复验一次,确认拿到的是新版本再继续发布后续变更。
怎么测量:Lighthouse、WebPageTest 与 TTFB 基线
优化必须先有基线。没有改前改后的对比,任何性能结论都是猜测。三个工具各管一件事:
- Lighthouse(Chrome DevTools → Lighthouse,或
npx lighthouse https://example.com --only-categories=performance):给 LCP、CLS、TBT 与「减少初始服务器响应时间」审计项 - WebPageTest:多地节点对比 TTFB、First Contentful Paint,并区分 First View 与 Repeat View,直接看出 CDN 是否生效
- DevTools Network 瀑布图:看每条请求的 Size 列、
Status 200 / 304、Priority,以及memory cache/disk cache命中标记
| 指标 | 良好 | 偏差 | 工具 |
|---|---|---|---|
| TTFB | ≤ 0.8s(CDN 命中建议 ≤ 0.2s) | > 1.8s | WebPageTest / curl |
| LCP | ≤ 2.5s | > 4.0s | Lighthouse / CrUX |
| INP | ≤ 200ms | > 500ms | Lighthouse / 真实用户监控 |
| CLS | ≤ 0.1 | > 0.25 | Lighthouse |
实验室数据不等于真实体验。 办公室网络通常又快又稳,测出来一切都好。条件允许时接入 RUM(真实用户监控)或至少用 WebPageTest 多地节点复核,避免优化只对测速工具有效。
一周可执行的优化清单
按这个顺序做,一周内就能看到 TTFB 和首屏的明确变化。每一步都有可验证的数字。
- 第 1 天 · 建基线:用上面的
curl命令记录 DNS / TCP / TTFB / 总时长,再跑一次 Lighthouse,把四项数据存档 - 第 2 天 · 分层诊断:找出 TTFB > 600ms 的接口,和体积最大的 5 个资源,其余先不动
- 第 3 天 · 缓存头:给带 hash 的 JS / CSS 设一年
immutable,HTML 设s-maxage=60+stale-while-revalidate,确认Vary: Accept-Encoding - 第 4 天 · 上 CDN:先只接管静态资源,验证
X-Cache命中头与 TTFB 是否降到 200ms 以内,再考虑接入 HTML - 第 5 天 · 压缩与图片:开启 brotli,WebP / AVIF 自动转换,首屏图加
fetchpriority="high"并去掉 lazy load - 第 6–7 天 · 协议与复核:确认 CDN 与源站都开了 HTTP/2,灰度验证 HTTP/3,重跑 Lighthouse 与 WebPageTest 对比基线
下一步:先只做前三步(建基线 + 改缓存头),这一步不需要采购新资源,却通常能砍掉相当一部分重复请求。如果 TTFB 仍然偏高,再去评估 CDN 与更近的源站区域——把上面 curl 命令测出的三个数字(TTFB / 总时长 / 是否命中缓存)整理成一张表,拿去比方案会高效得多。