先说结论:多出来的那条 DNS 解析,正在替别的站打工
上个月查一台跑了两年的小站,首页到首屏加载完成一共花了 3.4 秒,网络条件正常,服务器负载也不高。用 curl 看 HTML 只有 18KB,压缩后 6KB,按道理不该慢成这样。把页面上的外部资源逐个列出来才发现,转了两圈找不到的瓶颈在这里:一台第三方统计脚本所依赖的域名,加上两个字体域、一个头像域、一个图标域、一个广告域,一共七个外部域名分散在五个 DNS 服务商下。
这七个域名的解析,每一个都要先做一次递归查询。新手容易把 DNS 理解成"查一次缓存一次,之后就快了",但实际情况是:用户第一次访问你的站,浏览器本地没有任何缓存,每一次对一个陌生域名的解析都要从根开始走一趟递归链路,平均 100 到 250 毫秒。七个域名就像七道关口,而且它们之间还是串行的——因为浏览器要等脚本里的域名解析完才知道下一个要连哪。
这篇文章不讲泛泛的"上 CDN 就快了",而是把这个问题的排查顺序、量化方法和能真正落地的收口动作写清楚。顺序是:先数清有多少个外部域名,再判断哪些能合并、哪些能预解析、哪些根本不该存在,最后用数据验证改完之后到底快了多少。
第一步:把页面上的所有外部域名逐个数出来,不要靠肉眼扫源码
看源码找外链是最容易漏的。CSS 里的 url()、JS 里拼接的域名、字体文件里的内嵌引用,肉眼扫一遍根本数不全。正确做法是让浏览器自己告诉你它连了谁。
# 方法一:用 curl 抓页面,把所有 host 抽出来(不含 JS 动态加载)
curl -s https://www.example.com/ \
| grep -oE 'https?://[a-zA-Z0-9._-]+' \
| sed -E 's#https?://##' \
| sort | uniq -c | sort -rn
# 方法二:抓取 CSS 里的 url() 引用
curl -s https://www.example.com/style.css \
| grep -oE 'url\(([^)]+)\)' | sort -u
# 方法三(最准):打开浏览器 DevTools → Network,
# 勾选 Disable cache,刷新,按 Domain 列排序,一眼看到所有域名三个方法各有盲区。curl 抓不到 JS 运行时插入的域名,DevTools 抓不到只在特定页面才加载的资源。我的建议是:先 curl 出静态的那一批,再用 DevTools 在首页、文章页、列表页各刷一次做补充,把三次的结果取并集。这个并集才是真实的数字。
根据 HTTP/1.1 时代的经验,一台服务器同时维持 6 个并发连接就够忙了,所以把资源分散到多个域名曾经是"正道"——这叫域名分片(domain sharding)。但这个经验早在上 HTTPS + HTTP/2 之后就失效了:HTTP/2 在单个连接上做多路复用,一条连接就够了,再分片反而要多付几次 TLS 握手的成本。所以看到手上还在用 static1.example.com、static2.example.com 这种分片做法的站点,基本可以判断是历史遗留。
第二步:判断每个域名是"必须的"还是"可以合并的"
数出来之后,按这张表给每个域名归类。归类比"直觉上想不想留"重要得多,因为人总是倾向于对熟悉的资源手下留情。
| 类型 | 典型代表 | 能不能砍 | 判断依据 |
|---|---|---|---|
| 统计与埋点 | 第三方统计、热力图、A/B 测试 | 能,而且应该砍 | 对首屏视觉没有任何贡献,纯数据采集 |
| 字体 | Google Fonts 或自建字体域 | 能合并 | 自托管到主域,或者子集化后本地引入 |
| 图标 | Font Awesome CDN、图标库 CDN | 能砍 | 抽出用到的几个图标做成 SVG 雪碧图或内联 |
| 头像 | Gravatar 之类的头像服务 | 能砍 | 延迟加载,或改成首字母色块占位 |
| 广告 | 广告联盟的域名 | 通常不能砍 | 但可以改成异步加载 + 延迟到首屏之后 |
| 框架库 | 公共 CDN 上的 jQuery、Vue | 能合并 | 自托管或直接打进主包,少一个域名一次握手 |
这里有个反直觉的点:很多站长一看"统计脚本只有 20KB,留着吧",但统计脚本的价值在数据,不在加载速度,而它带来的代价不是一个 20KB 的文件,而是一次额外的 DNS 解析 + 一次额外的 TLS 握手 + 一个额外的主机连接。在移动网络下,这三件事加起来往往是 300 毫秒以上。20KB 的脚本用 4G 下载不到 100 毫秒,但解析连接能吃掉你三倍的时间。所以判断标准不是文件大小,而是"这个域名值不值得为它单开一条链路"。
第三步:预解析——便宜但容易被写错的一招
对于砍不掉的外部域名(广告、必须的第三方 SDK),最低成本的处理是加 dns-prefetch 和 preconnect。这两个标签的语义完全不同,很多人混着写,效果差很多:
<!-- 只做 DNS 解析,最便宜,兼容性最好 -->
<link rel="dns-prefetch" href="//ads.example-cdn.com">
<link rel="dns-prefetch" href="//fonts.example.com">
<!-- 解析 + TCP 连接 + TLS 握手,贵但省时间,用于确定要用的域名 -->
<link rel="preconnect" href="https://ads.example-cdn.com" crossorigin>
<!-- 只用于同源的静态资源 -->
<link rel="preload" as="style" href="/assets/app.css">三条容易踩的坑:
坑一:preconnect 不能滥用。每个 preconnect 都会真的建立一条连接并完成 TLS 握手。浏览器对同时进行的 preconnect 数量是有限制的(Chrome 大约同时 6 个,且有超时丢弃机制),写十几条 preconnect 的结果是前面的抢了资源,后面的全被丢弃,等于白写。规则是:preconnect 只给那些确定会用、且在首屏就会用的域名,最多三条;其余的一律降级成 dns-prefetch。
坑二:跨域的 preconnect 需要写 crossorigin。如果不写,浏览器建立的是无 CORS 凭证的连接,而字体、跨域脚本的请求是带 CORS 的,两者不能复用同一条连接。等你真正请求字体时,浏览器发现连接不匹配,会重新握手一次——预连接白做了。字体尤其要注意,因为字体请求天然是 CORS 模式。
坑三:漏了协议会多一次重定向。写成 href="//ads.example-cdn.com" 这种协议相对 URL,在纯 HTTPS 站点上没问题,但如果那个 CDN 不支持 HTTPS,就会先被重定向一次。所以干脆写全 https://,宁可失败也要让它早点失败。
还有一个更隐蔽的问题:dns-prefetch 的解析结果只在浏览器的 DNS 缓存里有效,而 Chrome 的 DNS 缓存 TTL 很短(通常 60 秒左右,还受系统缓存和运营商递归缓存影响)。如果你的站点是单页应用,用户在里面停留超过一分钟再点开一个新页面,预解析的结果可能已经过期了。所以它适合的是一次性加载的静态页面,对长留存的应用型站点帮助有限——这时候该考虑的是 Service Worker 缓存,而不是 DNS 预解析。
第四步:真正的大头——把能合并的域名合并回主域
前面两步都是优化,这一步才是减负。合并的逻辑是:能自托管的一律自托管,能让浏览器只跟一个域名打交道的就只跟一个域名打交道。
字体:如果还在引用外部字体服务,直接下载 woff2 放到自己的 /assets/fonts/ 下。注意版权——开源字体(SIL OFL、Apache 2.0)可以自托管,商业字体不行。中文字体还有一个更大的坑:一个完整的思源黑体 woff2 有 4MB 以上,直接自托管反而更慢。这种情况要用子集化,只保留页面实际用到的字。中文字体子集化的完整做法另有一套流程,核心是先统计页面上出现的字符集,再用工具裁剪,最后用 unicode-range 分段加载,让浏览器只下载它需要的那一段。
# 快速看一下页面用到的字符集规模
curl -s https://www.example.com/ \
| sed 's/<[^>]*>//g' \
| grep -o . | sort -u | wc -l
# 输出通常 400~900,而字体里有 7000+ 字,裁掉 90%图标:Font Awesome 完整版有 400KB+,而一个页面往往只用六七个图标。现在主流做法是内联 SVG:把要用的图标 SVG 代码直接写进 HTML,或者做成一个 <symbol> 雪碧图。代价是 HTML 稍微大一点,收益是少一个域名、少一次请求、少一个阻塞渲染的 CSS。
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="i-search" viewBox="0 0 24 24">
<path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm10 16l-4.35-4.35"/>
</symbol>
</svg>
<!-- 使用处 -->
<svg class="icon"><use href="#i-search"/></svg>头像:Gravatar 这类服务的问题不只是多一个域名,还有它对每个邮箱哈希都要单独发一次请求,评论多的页面能炸出几十个请求。做法是延迟加载 + 占位:默认显示首字母色块,用 loading="lazy" 加 IntersectionObserver,真正进入视口才加载。首屏一个请求都不发,DNS 也就不必提前解析。
统计与埋点:这里我建议直接做减法。绝大多数小站真正需要的只有两三件事:PV/UV、来源、落地页。这些东西用服务器端的 access log 就能算出来,配一个日志分析工具即可,一个外部域名都不用加。
第五步:验证——别靠感觉,靠数字
改完之后必须有数据支撑,否则你不知道改动到底有没有用,也不知道有没有改坏。最直接的方式是测 DNS 解析耗时本身:
# 看一次解析的耗时(含递归)
dig +stats www.example.com | grep -E 'Query time|SERVER'
# 冷启动模拟:指定一个干净的递归服务器,避开本地缓存
dig @8.8.8.8 www.example.com | grep 'Query time'
dig @1.1.1.1 www.example.com | grep 'Query time'
# 多次采样看稳定性,别只看单次
for i in $(seq 1 5); do
dig @8.8.8.8 www.example.com | grep 'Query time'
sleep 1
done更接近真实用户体验的是 DevTools 的 Performance 面板。录制一次首页加载,看 Network 时间线里的"DNS Lookup"分段。改之前和改之后各录一次,对比三条数:域名总数、首屏完成时间、主线程空闲时间。我一般会关注这几个阈值:外部域名超过 4 个就要审一遍;首屏加载超过 2.5 秒要找原因;DNS 总耗时超过 500 毫秒说明链路有问题。
还有一条容易忽略的检查:改完合并之后,确认没有引入新的同源连接数瓶颈。HTTP/1.1 下浏览器对单个域名只开 6 个并发连接,把所有资源都收回到主域之后,如果页面有大量小图片,可能会出现排队。这种情况的正确解法不是再分域名,而是上 HTTP/2——HTTP/2 没有这个限制。所以合并域名和上 HTTP/2 应该一起做,只做一半会适得其反。
第六步:如果合并做完了还是慢,问题可能不在 DNS
排查到这个位置,经常会出现一种情况:域名已经从七个降到两个,但首屏时间几乎没变。这时候要换个方向了,因为 DNS 只占整个加载链路的一小部分,剩下的时间通常花在下面几个地方:
- TLS 握手。看 DevTools 的 TLS 分段。如果超过 300 毫秒,检查是否开了 OCSP Stapling、会话复用(session ticket)是否生效、证书链是否完整(多余的中间证书会增加一次往返)。
- 服务端响应时间(TTFB)。如果 TTFB 超过 600 毫秒,DNS 再怎么优化也没用,问题在 PHP/数据库/缓存。这时候该查的是慢查询、PHP-FPM 队列、有没有开 OPcache。
- 渲染阻塞资源。如果 HTML 很快到了但页面白着,瓶颈在 CSS 和 JS。首屏关键 CSS 有没有内联、有没有加载一堆用不上的样式、脚本是不是都带了
defer。 - 图片尺寸。一张 2000px 宽的图被 CSS 缩到 400px 显示,浪费的带宽比 DNS 解析大几十倍。
这个判断分层的意义在于避免"优化错地方"。DNS、连接、传输、渲染是四段完全不同的链路,每一段的优化手段和方法都不一样。先用 DevTools 的瀑布图把总耗时拆成这四段,哪一段最长就修哪一段,这是唯一可靠的顺序。凭直觉去猜,往往会花三天时间做一件只值 50 毫秒的事。
一个可以直接照抄的收口清单
最后把整套动作整理成按优先级排的清单,按这个顺序做,投入产出比最高:
- 砍掉纯采集类的第三方域名(统计、热力图、A/B 工具)。这一步通常能砍掉 1~3 个域名,收益最直接,成本几乎为零。
- 把字体、图标、框架库收回主域自托管,同时做字体子集化。砍掉 2~4 个域名。
- 头像和评论区的资源改成懒加载,首屏不做任何请求。
- 剩下确实砍不掉的域名统一加
dns-prefetch,其中确定首屏要用的最多三条加preconnect并带上crossorigin。 - 同时确认 HTTP/2 已开启,避免合并后触发 HTTP/1.1 的并发限制。
- 用 DevTools 录制前后对比,记录域名数、首屏时间、TTFB 三个数,写进变更记录。
做完这套,我见过的典型站点从七个外部域名降到一两个,首屏时间缩短 400 到 900 毫秒不等。收益不如换台好服务器那么立竿见影,但它的成本是零——你不需要为它多花一分钱,只需要少引几个脚本。对个人站长来说,这种"减法型"的优化往往比"加法型"的更值得先做:不用花钱、不用升级配置、风险可控,随时能回滚。
还有一句话值得记住:页面上的每一个外部域名,都是你把一部分性能和可用性交到了别人手上。对方机房抖动、证书过期、DNS 被污染,你的站就跟着白屏。少引一个,就少一份这种不可控。