CDN 原理
“光影”的 OSS 源站在北京。有一天我闲着没事,用 curl 测试了一下从不同地区访问同一张图片的延迟:
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
- 请求验证关注返回状态、响应头、跳转目标和响应时间。
为什么差距这么大?因为光速。
物理距离 + 网络跳数 + TCP 协议开销 = 远距离用户不可避免的延迟。
CDN 的核心思路很简单:既然不能缩短距离,就把数据搬到离用户更近的地方。
一次完整的 CDN 访问流程:
用户在广州,输入 cdn.guangying.com
│
▼
① DNS 解析
用户 → Local DNS → 权威 DNS
权威 DNS 返回:CNAME 到 CDN 的 DNS 域名
CDN DNS 根据"广州"返回广州边缘节点的 IP
│
▼
② 建立连接
用户 → 广州边缘节点(物理距离 ~10km,RTT ~2ms)
│
▼
③ 缓存查找
广州边缘节点检查:这个 URL 有缓存吗?
│
├─ 有缓存 ──→ 直接返回 ✅(~5ms)
│
└─ 无缓存 ──→ 回源到北京源站
│
④ 回源请求
广州节点 → 北京源站(RTT ~40ms)
获取图片 → 缓存到广州节点 → 返回用户
总耗时:~100ms(第一次)
│
▼
⑤ 后续请求
广州用户再次访问同一张图 → 广州边缘节点直接返回(~5ms)
深圳用户访问同一张图 → 可能命中广州节点的缓存(~10ms)
DNS 解析的魔法
CDN 的关键在于 DNS 解析——它怎么知道用户在广州?
智能 DNS(GSLB)的判断依据:
判断"用户在哪里"的数据来源:
1. 用户 Local DNS 的 IP 地址
→ 精确到运营商 + 城市
→ 例如:119.29.xx.xx = 广东电信
2. EDNS Client Subnet(ECS)
→ 新版 DNS 协议,把用户真实 IP 传给权威 DNS
→ 比依赖 Local DNS IP 更准确
3. Anycast 路由
→ 同一个 IP 在全球多节点宣告
→ BGP 路由自动选择最近的节点
我在阿里云上开了一个 CDN 服务,把 cdn.guangying.com 指向 OSS 源站:
CDN 配好后,我重新测试了延迟:
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
- 请求验证关注返回状态、响应头、跳转目标和响应时间。
效果对比:
图片加载延迟对比(50KB WebP 缩略图)
地区 OSS 直连 CDN(首次) CDN(命中) 提升
────────────────────────────────────────────────────────
北京 62ms 55ms 5ms 12倍
上海 180ms 90ms 8ms 22倍
广州 3200ms 120ms 10ms 320倍
成都 2800ms 100ms 8ms 350倍
纽约 8500ms 500ms 30ms 283倍
缓存命中后,所有地区都在 10~30ms 内完成。
缓存不会永远存在。当缓存过期或被淘汰时,CDN 节点需要回到源站获取图片。
回源优化:回源跟随 301
配置 CDN 回源时有一个开关经常被忽略:回源跟随 301。
先说清楚 301 是什么。301 是永久重定向,告诉客户端”这个地址以后都去新地址拿”。在图片系统里,源站返回 301 的常见来源有四种:
| 场景 | 源站为什么返回 301 |
|---|---|
| 域名迁移 | 对象存储或业务域名换了,对外统一到一个域名 |
| 强制 HTTPS | 站点只允许 HTTPS,HTTP 请求被重定向 |
| 防盗链 / 鉴权跳转 | Referer 不在白名单时,重定向到一个提示页 |
| 对象存储内部重定向 | 存储内部把请求转到实际的数据节点 |
不开启”回源跟随 301”时,链路是这样的:
用户 → 边缘节点 → 回源 → 源站返回 301
│
边缘节点把 301 原样返回 ──────┘
│
浏览器重新发请求 ──────────────┘ (多一次 DNS + 连接 + 往返)
开启之后,边缘节点自己跟着 301 再回源一次,把最终的内容缓存下来,用户只看到 200:
用户 → 边缘节点 → 回源 → 源站返回 301
│
边缘节点跟随重定向,再回源一次 ──→ 拿到图片
│
缓存最终内容,返回 200 给用户 ─┘ (用户只经历一次请求)
价值主要在三处:省掉用户侧的一次重定向往返;避免把源站地址暴露给终端用户;把重定向目标的内容真正缓存下来,后续请求直接命中。
对应的回源配置大致是这样:
回源配置
回源类型 : OSS 域名
回源协议 : HTTPS
回源 Host : img-origin.example.com
回源跟随 301 : 开启
回源超时 : 30s
回源重试 : 2 次
备用源站 : img-origin-backup.example.com
四个必须注意的前提:
- 重定向目标必须稳定且可缓存。跟随后缓存的是重定向目标的内容,如果目标地址本身还会再跳一次,就会形成跳转链,每多一层都会增加首字节延迟。
- 回源 Host 要和源站期望的一致。源站的防盗链和签名校验通常按 Host 匹配,跟随重定向时不更新 Host,源站会返回 403 或再次重定向。
- 跟随 301 不解决”内容已经变了”的问题。301 是永久重定向,被缓存后不会自动更新;源站地址变更时,仍然要主动刷新相关缓存。
- 能直接配回源 Host 就不要依赖 301。CDN 的回源 Host 是一次性配置,301 是运行时行为,前者更可控。只有在源站行为不可控(比如对象存储内部重定向)时,才用回源跟随来兜底。
判断要不要开,最简单的办法是:拿一个会返回 301 的 URL 直接回源,看响应体和状态码,确认 301 的目标是不是稳定的资源地址。
缓存命中率是 CDN 的核心指标:
命中率 = 命中缓存的请求数 / 总请求数
回源率 = 1 - 命中率
CDN 之所以能把”广州用户等 3.2 秒”变成”10 毫秒”,靠的就是这一步:请求没有去北京源站拿,而是在广州边缘节点直接返回了已缓存的副本。命中率越高,回源越少,源站的带宽和延迟压力越小。
命中率当然不是越高越好,但它有一条非常实际的门槛:低于 90%,意味着每 10 个请求里就有 1 个要跨半个中国回源。
按”光影”优化后的实际数据,命中率每提升一个百分点,回源请求数就少一百分之一:
| 命中率 | 每 1000 个请求里的回源次数 | 回源流量占比 | 源站的感受 |
|---|---|---|---|
| 80% | 200 | 20% | 源站持续高压,扩容压力大 |
| 90% | 100 | 10% | 回源仍是主要成本来源 |
| 95% | 50 | 5% | 源站只承接突发流量 |
| 96% | 40 | 4% | 当前方案的水平 |
| 99% | 10 | 1% | 回源几乎可以忽略,但要付出更多缓存空间和更频繁的刷新 |
衡量命中率时有一个必须分清楚的口径:请求数命中率和字节命中率。一张 5 KB 的缩略图命中了、5 MB 的原图没命中,请求数命中率很高,字节命中率却很低——而回源流量费是按字节算的。
把 CDN 访问日志导成 CSV 之后,这两个口径都能自己算出来:
import csv
from collections import defaultdict
# 日志导出为 CSV 后至少需要这几列:
# uri(请求路径)、status(响应状态码)、bytes_sent(响应字节数)、hit(缓存命中状态,HIT / MISS)
LOG = "cdn_access_202610.csv"
def summarize(path: str) -> dict:
total = 0
hit = 0
bytes_total = 0
bytes_hit = 0
per_uri: dict[str, dict] = defaultdict(lambda: {"total": 0, "hit": 0})
with open(path, newline="", encoding="utf-8") as fp:
for row in csv.DictReader(fp):
total += 1
is_hit = row["hit"].strip().upper() == "HIT"
hit += int(is_hit)
size = int(row["bytes_sent"] or 0)
bytes_total += size
bytes_hit += size if is_hit else 0
stat = per_uri[row["uri"]]
stat["total"] += 1
stat["hit"] += int(is_hit)
return {
"request_hit_rate": hit / total if total else 0.0,
"byte_hit_rate": bytes_hit / bytes_total if bytes_total else 0.0,
"per_uri": per_uri,
}
三个最容易把命中率做低的坑:
- 缓存键里带了不该带的参数。时间戳、签名、追踪参数一旦进了缓存键,同一张图会被拆成无数份缓存,命中率断崖式下跌。
- 用
Vary头做内容协商。Vary: Accept会按浏览器的Accept字符串分裂缓存,一个 URL 常常变成三四份,命中率随之下降。 - 频繁刷新。每次内容更新都全量刷新,会把刚建立起来的缓存重新打散。刷新只用在”必须立刻生效”的场景。
怎么验证一次请求到底命中了没有?Age 头是最可靠的依据——它是 HTTP 缓存的标准头,命中时带上对象在边缘已经存放的秒数:
curl -sI "https://cdn.guangying.com/i/a3f8c2e1.jpg" | grep -iE "^(age|cache-control|via)"
第一次请求通常没有 Age(或者为 0),紧接着再请求一次,Age 就会变成一个正数,说明第二次走的是边缘缓存。
日常监控上,我更愿意看拆开之后的命中率,而不是一个总数:
| 维度 | 为什么要拆 |
|---|---|
| 按 URL 类型 | 原图和缩略图的命中率天然不同,混在一起看不出问题 |
| 按格式 | 某种格式可能因为参数组合问题一直不命中 |
| 按地域 | 某些区域的边缘节点覆盖或回源链路可能有问题 |
| 按状态码 | 404 被缓存会让命中率虚高,同时掩盖真实问题 |
最后一条纪律:命中率是一个要被拆开看的指标,不是一个要被汇报的数字。整体 96% 可能掩盖着长尾 URL 只有六成,具体是哪一部分在拖后腿,要靠上面那张按维度拆开的表才能回答。
等等——CDN 流量费比 OSS 直连还贵?是的,CDN 本身不省钱,它省的是用户体验。
但 CDN 间接省钱的地方在于:
- 回源流量减少 95%——OSS 外网流量费大幅降低
- 源站压力减少——不需要高配置服务器
- 用户留存提升——加载速度影响转化率
发现内容有问题?
告诉我们哪里需要改进。
