CDN跨地域加速实战:边缘缓存、回源与命中率优化
1. 先把“慢”拆开:跨地域访问到底卡在哪
跨地域访问慢这件事,最怕的就是一句“上CDN吧”就完事。我在模拟项目A上踩过这个坑:站点A的源站放在一个机房,用户分布在相隔约两千公里的两地,上线第二天就有人反馈“页面转圈三秒”。当时第一反应是带宽不够,扩容之后毫无改善,因为问题根本不在带宽,而在往返时延(RTT)和请求次数。CDN加速的本质,是把“用户到源站的一次长途往返”替换成“用户到附近边缘节点的一次短途往返”,再加一层边缘缓存,让大部分请求压根不回源。所以想彻底解决跨地域访问慢,得先把慢拆成可量化的几块:握手耗时、传输轮次、资源并发、回源链路、缓存命中率。这几块哪一块没处理好,CDN都可能只是“看起来上了,实际没快多少”。
1.1 一次请求要跑几趟“长途”
把一次 HTTPS 请求拆开看,用户浏览器要先做 DNS 解析,然后 TCP 三次握手,再 TLS 握手,最后才发 HTTP 请求。以单程 20ms、RTT 40ms 的跨地域链路为例:TCP 三次握手是 1.5 个 RTT,约 60ms;TLS 1.3 是 1 个 RTT,约 40ms;HTTP 请求到首字节再来 1 个 RTT,约 40ms。加起来 140ms,还没算服务端处理时间。如果源站处理要 50ms,那 TTFB 就在 190ms 上下。同城访问 RTT 只有 2ms 左右,同样的流程 20ms 就能出首字节。这 170ms 的差距,乘以首屏资源数量和串行层级,就是用户体感上的“慢半拍”到“慢三秒”。
更麻烦的是丢包。跨地域链路经过的中间设备多,1% 的丢包率不算夸张,但 TCP 一旦触发重传,最小重传超时通常按 200ms 起步,一次重传就把刚才省下的时间全吃回去了。带宽再大也救不了重传,因为瓶颈是“等”。这也是为什么很多团队扩容带宽后毫无体感改善——他们优化的是吞吐,而用户抱怨的是时延。
1.2 首屏资源数量决定总耗时
单个请求 190ms 不算灾难,灾难的是首屏有几十个请求。站点A 的首屏是 1 个 HTML、3 个 CSS、8 个 JS、20 多张图片,浏览器对同一域名默认并发 6 个连接,等于要排七八轮。每轮按 190ms 算,光排队就 1.3 秒以上。如果 CSS 里还引了字体、JS 里又动态加载了组件,串行层级再叠两层,三秒就这么来的。这里有个容易被忽略的点:HTTP/1.1 下每个域名 6 连接的限制,会让人本能地去拆域名(静态资源放两三个子域),但 HTTP/2 的多路复用已经把这个限制消掉了,继续拆域反而多出 DNS 和连接建立成本,得不偿失。
1.3 哪些能靠 CDN 解决,哪些不能
得说清楚边界。静态资源——图片、样式、脚本、字体、视频切片、下载包,这些内容不随用户变化,天然适合边缘缓存,命中之后用户直接从附近节点拿,RTT 从 40ms 降到个位数,效果立竿见影。动态接口——登录、下单、查询个人数据,这些响应因人而异,缓存收益有限,CDN 能做的是链路优化:就近接入、长连接复用、优质回源路径,把“长途”变成“短途接长途”。所以指望开了 CDN 接口就变快,多半会失望;但如果把动静分开,静态走缓存、动态走链路,整体体感会有质的变化。
2. 方案设计:缓存分层与回源链路的整体思路
动手配之前,得先有一张图在脑子里:用户 → 边缘节点 → 中间层(可选)→ 源站。每一层解决不同问题。边缘节点解决“用户到最近节点”的时延,中间层解决“边缘未命中时不要全部压到源站”,源站只承担真正无法缓存的内容。这个分层不是必须的,小站点两层就够,但流量上来之后,中间层的价值会非常明显——它把回源请求做了二次聚合,源站看到的 QPS 会低一个量级。
2.1 动静分离是前提,不是可选项
我见过不少站点把接口和静态资源放在同一个域名下,然后抱怨缓存命中率上不去。原因很简单:同一个域名下,HTML 和接口响应带着Set-Cookie,CDN 为了不串号,只能对这些响应谨慎处理,甚至连带把同域下的静态资源也拖累。正确做法是至少拆两个域名:static.example.com放静态资源,api.example.com放接口,主站域名只负责 HTML。拆开之后,静态域可以放心大胆地设长 TTL、关掉 Cookie 透传、开强制缓存,命中率很容易做到 95% 以上。
2.2 缓存层级怎么划
常见划法是三层:边缘节点只缓存高频资源,TTL 相对短,追求“快”;中间层缓存全量可缓存资源,TTL 更长,追求“稳”;源站只做兜底。也有一层半的做法:边缘节点 + 源站,靠预热和刷新来保证一致性。选哪种取决于更新频率和流量规模。站点A 是内容型站点,图片和脚本更新不频繁,用了两层(边缘 + 源站),配合文件名带哈希,效果已经够用。如果是商品详情页这种“内容会变但变化不频繁”的场景,中间层就值得加,因为它能把源站的回源压力削掉一大截。
2.3 商用服务与自建的取舍
小团队基本不用纠结,直接用服务商。自建边缘节点这事,机位成本、带宽成本、调度系统、证书管理,没有足够规模根本摊不平。但选服务商时有几个点必须问清楚:缓存键能不能自定义、能不能忽略指定 query 参数、回源 Host 能不能单独指定、私有回源鉴权支不支持自定义头、刷新接口的配额是多少、日志能不能落到自己的存储里。这几项决定了后面能不能精细调优,比价格重要得多。
| 对比维度 | 商用CDN服务 | 自建边缘集群 |
|---|---|---|
| 初期投入 | 按量付费,几乎为零 | 机位、带宽、调度系统,投入大 |
| 节点覆盖 | 现成的多区域节点 | 需要自己布点 |
| 调优自由度 | 受产品能力限制 | 完全可控 |
| 运维成本 | 低 | 高,需要专门团队 |
| 适合规模 | 中小到大型均适用 | 超大规模且流量稳定 |
2.4 缓存键设计决定命中率上限
缓存键是“什么样的请求算同一个资源”的判定依据。默认通常是scheme + host + path + query。问题在于 query 里常带一堆无意义参数,比如?from=wx&t=1699999999,时间戳一变,缓存键就变,命中率直接归零。解决办法是在控制台配置“忽略指定参数”,只保留真正影响内容的参数(比如id、page、size),其余全部忽略。这一步做完,很多站点的命中率能从 60% 跳到 90% 以上,比任何其他优化都见效快。
3. 边缘缓存配置实操
配缓存的核心就三件事:什么能缓存、缓存多久、什么情况下失效。这三件事分别对应缓存规则、TTL、刷新机制。很多人只配了第一条,后面两条靠默认值,结果就是“缓存是开了,但用户还是看到旧内容”或者“刚发的版本十分钟才生效”。
3.1 按资源类型定 TTL
TTL 不是越长越好,也不是越短越安全,要按“内容变化频率”和“出错代价”来定。带内容哈希的文件名(如app.3f9a2c.js)可以设一年,因为内容一变文件名就变;不带哈希的图片设 7 到 30 天;HTML 设不缓存或极短(比如 60 秒),保证发版能及时生效;接口默认不缓存,除非是明确可缓存的公开数据。
| 资源类型 | 建议 TTL | 理由 |
|---|---|---|
| 带哈希的 JS/CSS | 365 天 | 文件名即版本,永不过期也安全 |
| 图片/字体 | 30 天 | 变化少,出错可接受 |
| HTML 入口 | 0 或 60 秒 | 发版需要即时生效 |
| 公开接口数据 | 30 到 300 秒 | 允许短暂陈旧,换取回源量下降 |
| 用户私有接口 | 不缓存 | 缓存会串号 |
3.2 响应头怎么写才不打架
CDN 的缓存行为最终看响应头。Cache-Control优先级最高,Expires是它的老前辈,两者同时存在时以Cache-Control为准。no-store表示完全不缓存,no-cache表示可以存但每次要回源校验,private表示只允许浏览器缓存、CDN 不缓存。这几条经常被混用,导致“明明配了缓存却不生效”。源站侧这样写比较稳妥:
server { listen 443 ssl; http2 on; server_name static.example.com; root /data/www/static; # 带哈希的静态资源,长缓存 location ~* \.(js|css|woff2|webp|png|jpg)$ { add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; } # HTML 入口,允许缓存但每次校验 location = /index.html { add_header Cache-Control "no-cache"; } # 接口,禁止 CDN 缓存 location /api/ { add_header Cache-Control "private, no-store"; } }immutable这个指令值得单独说,它告诉浏览器“这个 URL 的内容永远不会变,别在用户刷新时发校验请求了”,对带哈希的资源非常合适,能省掉一次条件请求。但切记只在文件名带哈希时使用,否则用户会长期看不到更新。
3.3 缓存键与参数忽略
前面提过,query 参数是命中率杀手。除了忽略无关参数,还要注意参数顺序——?a=1&b=2和?b=2&a=1在部分实现里会被当成不同缓存键。规范做法是让前端统一参数顺序,或者在 CDN 侧开启参数排序。另外,路径大小写、结尾斜杠也会影响缓存键,/img/a.png和/img/a.png/在严格模式下是两个资源,配置时留意一下。
3.4 压缩与协议优化
文本资源不开压缩,等于白白多传几倍数据。gzip兼容性最好,br(Brotli)对文本压缩率更高,通常能再省 15% 到 20%。配置上建议两个都开,按Accept-Encoding协商。注意一点:压缩会影响缓存键(因为Vary: Accept-Encoding),所以要么让 CDN 统一压缩、源站不压缩,要么确保缓存键里正确包含这个维度,否则会出现“支持 br 的浏览器拿到 gzip 内容”或者更糟的“拿到未压缩内容”。协议层面,HTTP/2 的多路复用能显著减少连接数,HTTP/3 基于 UDP,在弱网和跨地域场景下改善更明显,能开就开。
3.5 预热与刷新怎么配合
新版本上线,最怕“一部分用户拿到新 HTML,引用的却是旧缓存里的 JS”。解决办法是发布流程里加一步:先传静态资源到源站,再调预热接口把新文件推到边缘,最后才切 HTML 入口。这样用户拿到新 HTML 时,引用的资源已经在边缘就位。回滚时反过来:先切 HTML 回旧版本,再清理新资源。刷新接口通常有配额限制,别拿它当常规手段用,日常靠文件名哈希解决,刷新只用于“发错版本”这种救火场景。
3.6 命中率怎么算、怎么提
命中率 = 边缘命中请求数 / 总请求数。低于 80% 就该查原因了。常见拉低命中率的因素有:query 参数未忽略、响应带Set-Cookie、TTL 过短、缓存键包含用户标识、资源 URL 每次带随机数。逐条排查,通常两三轮就能把命中率推到 90% 以上。要注意区分“请求命中率”和“流量命中率”——前者按次数算,后者按字节算,大文件场景下两者可能差很多,看监控时别只看一个数。
4. 回源配置实操
边缘缓存解决的是“命中之后快”,回源配置解决的是“没命中时别出事”。回源这块配错,轻则回源失败率升高,重则源站被打挂。我在模拟项目B上就遇到过回源 Host 配错导致源站返回 404 的情况——边缘节点拿到的全是错误页,还把错误页缓存了下来,用户看到的是一片空白,排查了半天才发现是回源头的问题。
4.1 回源 Host、协议与端口
回源 Host 决定源站收到请求时认为你在访问哪个站点。如果源站用 Nginx 按server_name分站点,回源 Host 配错就会落到默认站点,返回的内容完全不对。正确做法是显式指定回源 Host 为源站的真实域名,而不是 CDN 加速域名。回源协议建议用 HTTPS,避免中间链路明文;端口按源站实际监听来,非标端口要在控制台明确填写。源站侧可以这样限制只接受来自 CDN 的回源:
map $http_x_origin_token $deny_origin { default 1; "替换为你的长随机串" 0; } server { listen 443 ssl; server_name origin.example.com; location / { if ($deny_origin) { return 403; } proxy_pass http://127.0.0.1:8080; } }这个自定义头的方式比 IP 白名单更可靠,因为部分服务商的回源 IP 段会变动,白名单维护起来很痛苦。随机串要足够长,且只配在 CDN 的回源头里,不要出现在任何前端代码中。
4.2 回源超时与重试的参数取舍
超时设太短,源站偶尔抖一下就被判失败;设太长,边缘节点连接被占住,故障时雪崩。经验值:连接超时 5 秒,回源总超时 10 到 15 秒,重试 1 次,且只对幂等请求(GET/HEAD)重试。POST 请求重试可能导致重复下单,务必关掉。重试次数不是越多越好,重试会把压力放大,源站已经扛不住的时候,重试等于补刀。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 连接超时 | 5s | 源站不可达时快速失败 |
| 回源总超时 | 10–15s | 兼顾慢接口与连接占用 |
| 重试次数 | 1 次 | 仅 GET/HEAD |
| 重试间隔 | 100–300ms | 避免瞬时冲击 |
| 回源并发上限 | 按源站能力设 | 防止打挂源站 |
4.3 大文件与 Range 回源
视频、安装包这类大文件,用户经常拖拽进度条,会发Range请求。如果边缘节点不支持 Range 缓存,每次拖拽都会回源,源站带宽直接被拉满。配置上要确认两点:CDN 支持 Range 请求透传,且对大文件做分片缓存。测试方法很简单,用curl带Range头请求一个几十兆的文件,看返回是不是 206 状态码,再看第二次请求是否命中缓存。
curl -sI -H 'Range: bytes=0-1048575' https://static.example.com/demo-package.zip返回206 Partial Content且带Content-Range说明透传正常;看响应头里的缓存标识确认是否命中。
4.4 回源链路与多源主备
回源链路的质量直接影响未命中时的体验。部分服务商提供“就近回源”和“优质链路回源”,对跨地域场景提升明显,值得开。多源主备配置也要做:主源不可用时自动切备源,备源可以是对象存储的静态副本。切换策略建议按状态码(5xx 连续出现 N 次)触发,而不是按响应时间,否则源站慢一点就被误切,反而引入不一致。
5. 动态请求与API加速
静态资源优化完,剩下的瓶颈基本都在接口上。动态加速不是“把接口缓存起来”,而是优化“用户到源站”这一段路。开启之后,用户请求先到就近节点,节点之间走优化过的骨干路径回源,避开公网绕行和拥塞。实测下来,跨地域场景下 TTFB 能降 30% 到 50%,抖动也小很多。
5.1 什么情况下值得开
三类场景收益明显:一是用户分布广、源站集中在单一区域;二是接口响应本身不慢(比如 50ms 以内),但网络传输占了大头;三是链路质量不稳定,时快时慢。反过来说,如果接口本身要跑 2 秒数据库查询,开动态加速只能省掉网络那几十毫秒,属于治标。先把慢查询和 N+1 问题解决,再谈链路优化,顺序不能反。
5.2 连接复用与协议优化
动态加速的一个隐性收益是连接复用。边缘节点到源站维持长连接,用户侧每次新建连接的成本被摊薄了。配合 HTTP/2 的多路复用,并发请求不用排队等连接。这里有个细节:如果源站前面还有一层反向代理,要确保 keepalive 配置合理,keepalive_timeout别设太短,keepalive_requests别设太小,否则连接刚建立就被关掉,复用等于没做。
5.3 边缘的轻量逻辑
有些逻辑放到边缘做,能省掉一次回源。比如登录态校验、简单的 A/B 分流、地域化的 302 跳转、静态化的活动页拼装。这类逻辑的特点是“不需要查数据库、规则相对固定”。把它们放到边缘,响应时间从几十毫秒降到个位数。但要注意别把业务逻辑大量往边缘搬,调试和版本管理会变得很麻烦,边缘代码只放“薄薄一层”最稳妥。
6. 上线验证:怎么证明真的变快了
配完不等于生效,生效不等于变快。必须有可量化的验证手段,否则就是自嗨。我在模拟项目C上见过“CDN 上线一周,老板问快了没有,没人答得上来”的尴尬。验证分三块:指标定义、拨测对比、日志分析。
6.1 指标怎么定义
别只看平均值,平均值会被大量快请求稀释。核心看四个:TTFB 的 P95、首屏完成时间的 P75、边缘命中率、回源 5xx 比例。P95 能反映“最难受的那批用户”的体验,比均值有说服力。埋点要在真实用户侧做,用浏览器的 Performance API 采集,而不是只在办公室测。
// 采集 TTFB 与资源加载情况,上报到自己的监控 const nav = performance.getEntriesByType('navigation')[0]; const ttfb = nav.responseStart - nav.requestStart; const resources = performance.getEntriesByType('resource'); const slow = resources.filter(r => r.duration > 1000).map(r => r.name); report({ ttfb, slowCount: slow.length, ts: Date.now() });6.2 多节点拨测对比
办公室网络往往离节点很近,测出来全是好数据。要在不同区域找几个测试点,用同一套命令跑对比。curl -w能把各阶段耗时打出来,非常直观:
curl -o /dev/null -s \ -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' \ https://static.example.com/app.3f9a2c.js切换前后各跑一轮,重点看time_starttransfer和time_total的差值。如果接入后connect时间没降,说明调度没把你导到就近节点,要查 DNS 解析结果和调度配置。
6.3 日志能说明什么
边缘访问日志里能看到缓存状态(通常是一个响应头,值类似HIT、MISS、EXPIRED)、回源耗时、状态码。抽样分析 MISS 的 URL,能找出“本该缓存却没缓存”的资源。常见的漏网之鱼是带随机参数的图片、带用户 ID 的接口路径、以及被Set-Cookie污染的响应。把这些清掉,命中率还能再上一个台阶。
7. 常见问题与排查实录
下面这些是我在实际操作中反复遇到的,按现象、原因、动作整理成表,遇到问题先对照查一遍,能省不少时间。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 命中率长期低于 60% | query 参数未忽略、响应带 Set-Cookie、TTL 过短 | 看响应头,抽样 MISS 日志 |
| 回源 502 | 回源 Host 错、源站端口不通、私有鉴权头没配 | 源站直接 curl、核对回源头 |
| 回源 504 | 源站处理慢、回源超时过短 | 看源站耗时分布,调整超时 |
| 用户看到旧版本 | HTML 被长缓存、刷新未提交 | 检查 HTML 的 Cache-Control |
| 部分区域访问仍慢 | 调度未就近、节点异常 | 多节点拨测,看解析结果 |
| HTTPS 握手失败 | 证书链不全、SNI 不匹配 | 用 openssl 检查证书链 |
| 流量费用突增 | 被刷、大文件未限速 | 看 TOP URL 与 TOP IP |
| 视频拖拽卡顿 | Range 未透传、分片未开 | 用 curl 测 206 响应 |
7.1 缓存“看起来开了但不生效”
这是最高频的问题。排查顺序:先看响应头有没有Cache-Control: no-store/private,再看有没有Set-Cookie(有的话 CDN 通常不敢缓存),然后看缓存键里是否混入了随机参数,最后看是不是请求方法不是 GET(POST 默认不缓存)。四步走完,九成问题能定位。有个隐蔽情况:源站返回 200 但带了Vary: *,这等于告诉 CDN“这个响应因一切而变”,自然无法缓存。
7.2 源站被回源打挂
回源配置不当,边缘未命中时会集中回源,源站瞬间被压垮,然后 5xx 增多,缓存又更不容易建立,形成恶性循环。缓解手段:给回源加并发上限、开中间层聚合、把热门资源提前预热、对源站做限流保护。另外要检查重试配置,重试次数过多会在故障时放大流量,我一般只留 1 次重试,宁可让少量请求失败,也不让源站雪崩。
7.3 缓存污染导致串号
如果缓存键里没包含用户维度,而响应又是个性化内容,就会出现“A 用户看到 B 用户数据”的严重问题。防法很简单:所有带用户态的接口一律private, no-store,并且不要在同一个路径下混放个性化和公共内容。上线前做一次检查,用一个未登录态和一个登录态分别请求同一个 URL,看返回是否不同,不同就说明这个路径不能缓存。
8. 成本控制与稳定性经验
CDN 用起来容易,账单失控也容易。流量计费是按字节算的,一个没压缩的大图、一次被刷的下载、一段忘记限速的视频,都可能让费用翻几倍。稳定性方面,CDN 是外部依赖,要有降级预案,不能把全部身家押在一条链路上。
8.1 流量费用怎么控
三个抓手:压缩、限速、防刷。压缩前面说过,文本资源开 br 能省两成。限速针对大文件下载,给单连接限个合理带宽,避免少数用户占满。防刷靠频次限制和特征识别,对异常高频的 IP 或 UA 做拦截,对下载类接口加签名校验和有效期。另外建议开带宽封顶告警,日流量超过阈值就通知,别等月底看账单才发现。
8.2 降级预案要有
CDN 服务异常时,DNS 要能快速切回源站直连。做法是保留一个备用域名指向源站,主域名解析异常时切换。源站要能扛住短时全量流量,至少保证核心页面可访问。这个预案平时用不上,但真出事的时候能救命。切换流程要写成文档并且演练过一次,别等到故障时现查。
8.3 灰度切流别一把梭
新配置上线,先切 5% 流量观察,看命中率、5xx、回源耗时是否正常,再逐步放大。缓存规则、回源配置这类改动影响面大,一把全量切换出问题时回滚窗口很短。灰度期间盯紧错误率和 TTFB 曲线,有明显劣化立即回退,比事后分析划算得多。
8.4 几个容易忽略的细节
一是证书。加速域名和回源域名可能用不同证书,到期时间要分别监控,见过只监控了主站证书、回源证书过期导致全站 502 的情况。二是日志留存。边缘日志默认只存几天,要长期分析得提前配置投递到自己的存储。三是刷新配额。大促前集中刷新容易超额,提前规划版本节奏,尽量用文件名哈希而不是刷新来解决更新。四是测试环境。测试环境也接一套 CDN 配置,否则上线才发现规则写错,回滚成本很高。
我在几个项目里反复验证下来,最有效的顺序是:先做动静分离和文件名哈希,再配缓存规则和参数忽略,然后调回源超时和鉴权,最后开动态加速和协议优化。这个顺序的好处是每一步都有可量化的收益,出问题也容易定位是哪一步引入的。反过来,一上来就全开,出了故障根本不知道从哪查起。另外有个小技巧:给关键资源的响应头加一个自定义标记(比如x-build: 20240612),拨测时一眼就能看出命中的是哪个版本,排查缓存不一致问题时特别省事。