DNS 和 CDN:为什么只有一个省的用户集体打不开后台

静态资源切上 CDN 之后,只有一个省的用户集体打不开后台,这种故障会逼着人把平时一带而过的 DNS 细节全补回来。页面为什么会白屏,答案不一定在 JS,也不一定在服务器本身,可能只是某一段解析链路里的缓存还没更新。

那次排查就很典型:我们自己访问正常,其他地区的商家也正常,问题却集中在同一个省。顺着这个分布往下查,最后卡点落在一个之前几乎没认真理解过的词上:TTL。域名解析不是"改完立刻全球生效",它天生就带缓存,而 CDN、CNAME、本地 DNS、运营商 DNS 叠在一起之后,任何一层都可能把旧结果留住。

后面会把 DNS 解析链路、TTL、CNAME、CDN 接入和多地访问差异放到这次排查里一并讲清楚。

我们为什么要接 CDN

先交代动机。上半年商家后台的首屏一直被运营和老板投诉慢,我第一反应是「JS 写胖了」,一头扎进 webpack 配置里抠 splitChunks、上 tree-shaking、把第三方库换成体积更小的,忙活了两周,Lighthouse 分数确实涨了几分,但用户那边的体感几乎没变。

后来我老老实实打开 Chrome 的 Network 面板,把瀑布流一条条看过去,才发现真正的大头根本不在脚本执行上——一个广东的商家打开页面,光是连到我们部署在北京机房的静态资源服务器,DNS + TCP 握手 + 首字节就花掉了将近 1 秒,资源本身才几十 KB。

浏览器拿到一个 URL 后,时间真正花在这几段上,Network 面板点开任意一条请求的 Timing 都能看到:

1Queueing          排队
2Stalled           被代理 / 连接数限制卡住
3DNS Lookup        域名解析 ←——— 这里重点看它
4Initial connection TCP 三次握手
5SSL               TLS 握手
6TTFB              发出请求到收到第一个字节
7Content Download  真正下载内容

我们的问题就卡在 DNS Lookup 和 Initial connection 上,Content Download 反而短得可怜。压 JS 压的是最后一段,前面几段一动不动,自然没用。**力气使错了地方,再使劲也没用。**结论摆在那儿:资源离用户太远,得上 CDN。评审、选厂商、配回源,八月初正式切换。然后就是开头那一幕。

先搞清 DNS 到底在干什么

出事当晚,我让报障的商家发了两样东西:ping static.xxx.com 的截图和 nslookup 的结果。对着截图我第一次真切地理解了这套流程。

用户访问 www.example.com,但网络层认的是 IP,不认域名。把域名翻译成 IP 这件事,就是 DNS(Domain Name System)干的。这个查询不是一步到位的,浏览器会按一条链路逐级找,能命中缓存就不往下走:

1浏览器自身 DNS 缓存
2  → 操作系统缓存(hosts 文件也在这一层生效)
3    → 本地 DNS(一般是运营商或你配的 8.8.8.8 / 114.114.114.114)
4      → 根域名服务器 → .com 顶级域服务器 → example.com 权威服务器

最坏情况要走完整条递归链路,几十到几百毫秒都可能。但实际上绝大多数查询会在前面几层就命中缓存,所以同一个域名第二次访问时 DNS Lookup 经常显示为 0。

想亲眼看一次完整解析,命令行比什么都直观。那晚我现学了 dig

1# 看完整解析结果和耗时(Query time 那一行)
2dig static.example.com
3
4# 跟踪从根服务器开始的逐级递归,理解上面那条链路
5dig +trace static.example.com
6
7# 只看这个域名实际指向哪儿(CNAME 链 + A 记录)
8dig static.example.com +short

Windows 上没有 dig,用 nslookup static.example.com 也行。本机 DNS 缓存查不准时,macOS 上先 sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder 清一遍,Windows 是 ipconfig /flushdns。还有一层很多人不知道的缓存在浏览器自己手里:Chrome 地址栏输入 chrome://net-internals/#dns 能看到(并清掉)浏览器进程内的 DNS 缓存——排查「命令行解析对了、浏览器还是连旧地址」这种灵异现象时用得上。

在我们自己的机器上,dig +short 返回的是一长串 CNAME,最后落到 CDN 厂商一个带地域标识的节点 IP——一切正常。但商家发来的 nslookup 截图上,那个域名解析出来的还是北京源站的旧 IP。而源站在切换时已经改了配置、不再直接对外服务静态资源,于是白屏。

为什么只有一个省

同一个域名,我们解析到新地址,他们解析到旧地址——差别在链路的第三层:本地 DNS。用户用的是各自省份运营商的 LocalDNS,我们办公室走的是公司出口的另一家。

我掏出了多地拨测工具(17ce、站长之家这类,选一个域名,全国几十个监测点同时帮你解析和请求),结果一目了然:绝大多数省份的监测点都已经解析到 CDN 节点,唯独出事那个省的某运营商监测点,返回的还是旧 A 记录。

在等拨测结果的间隙,我还想到一手排除法:先确认 CDN 节点本身是好的,把「解析的问题」和「节点的问题」切开。curl--resolve 参数可以强行指定「这个域名就当它解析到这个 IP」,绕过 DNS 直连目标:

1# 假装 static.example.com 解析到 1.2.3.4(某个 CDN 节点 IP),直接对它发请求
2curl -I --resolve static.example.com:443:1.2.3.4 https://static.example.com/app.4f2a9c.js
3# 返回 200,说明节点服务正常,问题锁死在解析这一侧

比让用户改 hosts 轻量得多,验证完删掉参数就是,本机什么都不留。这一验证下来节点全部正常,问题只剩 DNS。

原因就是 TTL。DNS 的每条记录都带一个 TTL(Time To Live),告诉各级缓存「这条结果你可以放心用多久」。我们那条 A 记录的 TTL 是运维早年图省事设的 86400 秒,整整一天。切换 CNAME 的那一刻,改动只发生在权威服务器上;各地运营商的 LocalDNS 手里的旧记录还在有效期内,它们有权继续用到过期为止。大部分运营商的缓存陆续到期换新了,出事那个省的 LocalDNS 缓存得最顽固——听运维的同事说,有些运营商还会无视 TTL 超期缓存,业内的老毛病了。

所以这次事故严格说没有单一原因,只有一个没做的动作:**切换 DNS 记录之前,应该提前一两天把 TTL 降到 300 秒甚至 60 秒,等切换稳定后再调回去。**这是运维的常识,但前端不知道这回事,就只能在群里干着急。当晚的处置也只能是等——同时给个别急着对账的商家发了临时方案:改 hosts 指到新节点。第二天中午,拨测全绿,客诉归零。

事后我想过:那干脆把 TTL 永远设成 60 秒不就没这事了?这其实是个取舍:TTL 太长,改记录时全网生效慢,就是我们这晚的下场;TTL 太短,缓存频繁失效,各级 DNS 的查询量暴涨,用户侧的 DNS Lookup 命中率也跟着掉,等于把解析延迟均摊给了每一次访问。所以常规做法是平时给一个小时到一天的适中值,只在计划变更的窗口前临时调低——跟发版前灰度是一个思路。

顺带记一条听来的补充:移动端 App 为了绕开运营商 LocalDNS 的各种不靠谱(缓存顽固、解析被劫持插广告),这两年流行 HTTPDNS——App 直接通过 HTTP 接口向服务商要解析结果,跳过整条传统链路。浏览器里我们用不上,但静态资源全站 HTTPS 至少能防住劫持者往页面里注广告,这也是我们坚持 CDN 全程 HTTPS 的理由之一。

意外的收获:hosts 坑

给商家发「改 hosts」的临时方案时,我想起手头一桩老问题,顺手也解决了。前阵子测试环境某个接口域名在我一台机器上间歇性解析失败,查了半天没头绪——这回学了链路才反应过来去看 /etc/hosts,里面果然残留着一条早期联调时写死的内网 IP。hosts 的优先级高于 DNS 查询,谁都想不到去那儿看。以后定位这类问题,我都会先 dig 拿权威结果,再 cat /etc/hosts 对一遍,两边不一致基本就是它了。

CDN 到底靠什么工作

事故处理完,周末我把书里 CDN 那章也啃完了,和这次实操对上了号。

CDN(内容分发网络)解决的是「物理距离」问题。资源只存在一台北京的源站时,广州用户每次都得跨大半个中国去取;接入 CDN 后,资源会被缓存到遍布各地的边缘节点,广州用户就近从广州节点拿,北京用户从北京节点拿。

而它的调度靠的正是 DNS。把 static.example.com CNAME 到 CDN 厂商的域名后,CDN 的智能 DNS 会根据查询来源的位置,返回离它最近的那个节点 IP。所以 DNS 和 CDN 是一套组合拳——dig 时看到的那长串 CNAME 最后落到带地域标识的节点上,就是这套调度在工作。也正因为调度长在 DNS 上,这次事故才会表现成「按地区塌方」:坏的不是节点,是那个省的解析。

适合放 CDN 的,是「对所有用户都一样、且不常变」的静态资源:

  • JS / CSS 打包产物
  • 图片、字体、图标
  • 视频、音频、可下载的安装包

接口能不能走 CDN?可以,但要分清楚。纯查询、对实时性不敏感的 GET 接口(比如首页配置、商品分类)可以让 CDN 缓存几秒到几分钟,扛住流量峰值;涉及登录态、个性化、写操作的接口绝对不能缓存,否则会出现「A 用户看到 B 用户数据」这种严重事故。我们目前只让接口走边缘加速(回源加速、不缓存正文),缓存交给后端自己控制。

缓存策略:HTML 和静态资源必须分开对待

接 CDN 除了解析,最容易翻车的就是缓存。上线前对配置时我们把这条原则钉死了:带 hash 的静态资源用长缓存,HTML 入口用短缓存或不缓存。

打包工具会给产物名带上内容 hash:

1app.4f2a9c.js
2chunk-vendor.8b1d33.js
3main.0e5f12.css

内容一变,hash 就变,文件名就变,等于换了个新 URL,浏览器和 CDN 都不会拿到旧的。所以这些文件可以放心设超长缓存:

1# 静态资源(Nginx 示例)
2location ~* \.(js|css|png|jpg|woff2)$ {
3    expires 1y;
4    add_header Cache-Control "public, max-age=31536000, immutable";
5}
6
7# HTML 入口:每次都回源校验,不能长缓存
8location = /index.html {
9    add_header Cache-Control "no-cache";
10}

immutable 是个很实用的指令——告诉浏览器这文件永远不会变,连「回来问一句改没改」都省了,对带 hash 的资源再合适不过。

为什么 HTML 不能长缓存?因为 HTML 里写着引用哪个 hash 版本的 JS/CSS。发版后 HTML 一旦被缓存住,用户拿到的还是旧 HTML,里面指向的还是旧 hash,新代码永远到不了用户手上。这就是经典的「我明明发布了,用户怎么还是旧版本」——DNS 事故平息后一周,我们第一次在 CDN 环境下发版,就顺手把这套排查流程演练了一遍,顺序是固定的:

  1. 文件名 hash 变了没——构建产物如果 hash 没变,说明根本没生效,回去查构建配置。
  2. CDN 刷新了没——HTML 这种短缓存文件,发版后要主动调 CDN 的刷新接口(purge)把边缘节点的旧副本清掉。各家厂商都有刷新 URL / 刷新目录的 API,CI 里挂一步自动刷新最稳,我们已经挂上了。
  3. 浏览器本地缓存——让用户硬刷新(Cmd/Ctrl+Shift+R)或开无痕窗口验证,能复现就基本确认是缓存层问题。
  4. Service Worker——如果上了 PWA,SW 会拦截请求走自己的缓存。我被这个坑过一次,找了一下午,最后在 Application 面板里看到 SW 缓存了旧 HTML。SW 的更新逻辑要单独设计,发版时让它 skipWaiting 并清旧 cache。

域名不是越多越好

整理资源域名时还顺了一遍旧账。一个稍微复杂点的页面,引用的域名可能有一长串:

1cdn-a.example.com    # 打包后的 JS/CSS
2img.example.com      # 图片
3static.example.com   # 字体
4api.example.com      # 接口
5hm.baidu.com         # 统计
6某某广告/AB 测试 SDK 的域名

每个第一次出现的新域名,浏览器都要做一次 DNS 解析。HTTP/1.1 时代有人喜欢「域名分片」(domain sharding)——把资源拆到 cdn-acdn-bcdn-c 多个域名上,绕开浏览器对单域名并发连接数(通常 6 个)的限制,换取更高的并行下载。

这招现在要慎用了。一是每个新域名都摊上一份 DNS + TCP + TLS 的固定开销,分片太多反而得不偿失;二是我们这次接 CDN 顺带开了 HTTP/2,多路复用本身就能在一个连接上并行很多请求,再做分片纯属帮倒忙,还会破坏 HTTP/2 的连接复用。我的结论是:HTTP/1.1 下分到 2 个域名差不多到顶,上了 HTTP/2 就别分了。

一句话:域名不是越多越好,每多一个第三方域名,首屏就多扛一份固定延迟——而且如这次所见,每个域名还是一个独立的故障面。

命中还是回源,响应头会说话

CDN 用了两周,我又攒出一个新习惯:看响应头判断这次请求是边缘节点直接给的,还是回源站取的。各家厂商的头名字略有差异,常见的是 X-Cache

1X-Cache: HIT from CDN-node-guangzhou    # 边缘命中,用户就近拿到
2X-Cache: MISS                           # 未命中,节点回源站取了一趟

第一次被访问的资源必然 MISS(节点还没缓存副本),之后同一节点的请求才会 HIT。所以刚发完版、或者某个冷门省份的第一个用户,拿到的速度和源站直连差不多,这是正常现象,不是 CDN 没生效。但如果一个高频资源反复 MISS,就要查了:多半是响应头里带了 Cache-Control: no-cache 之类的指令把节点缓存禁掉了,或者 URL 上挂了随机查询参数,每次都被当成新资源。**命中率是 CDN 的命根子,而命中率是被你自己的缓存头和 URL 规范喂出来的。**回源还有个配置细节叫回源 Host——节点回源时带给源站的 Host 头,配错了源站的 Nginx 匹配不到 server 块,返回的就是默认站点或 404,这也是接入初期的经典坑,我们测试环境撞过一次。

图片上了 CDN,防盗链和证书别漏配

商品图切到 CDN 之后运营提了个担心:图片地址是公开的,别家小站直接热链我们的图怎么办——流量费可是按我们的账单算。CDN 厂商的控制台里这功能是现成的:Referer 防盗链,白名单里只放自己的域名,其余来源一律 403。有两个前端必须知道的细节:

一是空 Referer 要不要放行。用户直接在地址栏打开图片、或者从收藏夹进来,请求是不带 Referer 的;一刀切拒绝空 Referer,这部分正常访问也会被误伤。我们最后选择放行空 Referer,牺牲一点防护换体验。二是自家 App 的 webview、小程序里的请求 Referer 各有各的怪脾气(小程序的 Referer 是固定格式的 servicewechat.com 域名),白名单没加全,上线就是一片图挂。这事我们在测试环境用真机过了一轮才敢发。

证书也一样是接入清单上的一项:全站 HTTPS 意味着 CDN 边缘节点要替源站完成 TLS 握手,证书得托管(或签发)到 CDN 上。漏配的表现很有迷惑性——HTTP 访问一切正常,HTTPS 报证书域名不匹配,用户看到的是浏览器的红色警告页,客服转述过来就成了「网站提示不安全打不开」。排查时让用户点开警告详情看证书的颁发对象,一眼就能确认是不是这个问题。

DNS 预解析和预连接

既然第一次访问新域名要付 DNS 的钱,那能不能提前付?可以。浏览器提供了几个资源提示,写在 <head> 里:

1<!-- 只提前做 DNS 解析,开销最小,兼容性最好 -->
2<link rel="dns-prefetch" href="//static.example.com">
3
4<!-- 更进一步:DNS + TCP + TLS 全都提前建立好,连接直接就绪 -->
5<link rel="preconnect" href="https://static.example.com" crossorigin>

dns-prefetch 只省 DNS 那一段,preconnect 把握手也提前做了,对那种「页面加载后过一会儿才用到」的关键第三方域名(比如字体服务、关键 API)收益更明显。我一般两个搭配着用:preconnect 给最关键的一两个域名,dns-prefetch 兜底给次要的。

但别上头。我见过有人把页面里出现的所有域名全 prefetch 一遍,二三十个。每个预解析/预连接都是真实占用资源的,预连接尤其会建立用不上的 TCP 连接,浪费用户带宽和服务器连接数。每一条提示都应该对应一个你确信会用到的关键域名,多了反而有害。

前端实际能落地的事

把这场事故和后面的排查落到日常工作,前端能直接参与、收益又稳的就这么几条:

  • 静态资源全部走 CDN,构建产物文件名带内容 hash
  • HTML 走短缓存 / no-cache,静态资源走 immutable 长缓存,两套策略严格分开
  • CI 发版流程里加一步「刷新 CDN 上的 HTML」,别等用户报障才手动刷
  • 涉及 DNS 记录的切换,提前降 TTL,切换后用多地拨测确认全国生效,别只信自己电脑上的结果
  • 发版后看关键资源的 X-Cache 是不是 HIT,命中率掉了先查缓存头和 URL 规范
  • 图片域名配好 Referer 防盗链和 HTTPS 证书,webview、小程序的 Referer 用真机验证
  • 控制第三方域名数量,能合并的合并,没用的统计/广告 SDK 该砍就砍
  • 对关键域名做 preconnect,次要域名 dns-prefetch,但绝不滥用
  • 遇到慢,先打开 Network 面板看 Timing 瀑布流,定位到底卡在哪一段

DNS 把域名翻译成 IP,CDN 借助 DNS 把资源调度到离用户最近的节点。这两件事都发生在「你的业务代码跑起来之前」,却实实在在地决定了用户多久能看见画面——甚至决定了某个省的商家今晚能不能对账。

那本网络书的 DNS 一章,我最终是在事故文档写完之后读完的。下次再被说「页面慢」或者「页面挂了」,先别急着压 JS——打开 Network,跑一把 dig,看清楚时间和请求到底断在哪一段,再动手。