CDN 原理

广州用户的 3.2 秒

“光影”的 OSS 源站在北京。有一天我闲着没事,用 curl 测试了一下从不同地区访问同一张图片的延迟:

验证要点

  • 命令只用于验证系统状态,读者不需要记具体参数。
  • 请求验证关注返回状态、响应头、跳转目标和响应时间。

为什么差距这么大?因为光速。

物理距离 + 网络跳数 + TCP 协议开销 = 远距离用户不可避免的延迟。

CDN 的核心思路很简单:既然不能缩短距离,就把数据搬到离用户更近的地方。

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 服务,把 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

四个必须注意的前提:

  1. 重定向目标必须稳定且可缓存。跟随后缓存的是重定向目标的内容,如果目标地址本身还会再跳一次,就会形成跳转链,每多一层都会增加首字节延迟。
  2. 回源 Host 要和源站期望的一致。源站的防盗链和签名校验通常按 Host 匹配,跟随重定向时不更新 Host,源站会返回 403 或再次重定向。
  3. 跟随 301 不解决”内容已经变了”的问题。301 是永久重定向,被缓存后不会自动更新;源站地址变更时,仍然要主动刷新相关缓存。
  4. 能直接配回源 Host 就不要依赖 301。CDN 的回源 Host 是一次性配置,301 是运行时行为,前者更可控。只有在源站行为不可控(比如对象存储内部重定向)时,才用回源跟随来兜底。

判断要不要开,最简单的办法是:拿一个会返回 301 的 URL 直接回源,看响应体和状态码,确认 301 的目标是不是稳定的资源地址。

CDN 缓存命中率

缓存命中率是 CDN 的核心指标:

命中率 = 命中缓存的请求数 / 总请求数
回源率 = 1 - 命中率

CDN 之所以能把”广州用户等 3.2 秒”变成”10 毫秒”,靠的就是这一步:请求没有去北京源站拿,而是在广州边缘节点直接返回了已缓存的副本。命中率越高,回源越少,源站的带宽和延迟压力越小。

命中率当然不是越高越好,但它有一条非常实际的门槛:低于 90%,意味着每 10 个请求里就有 1 个要跨半个中国回源。

按”光影”优化后的实际数据,命中率每提升一个百分点,回源请求数就少一百分之一:

命中率每 1000 个请求里的回源次数回源流量占比源站的感受
80%20020%源站持续高压,扩容压力大
90%10010%回源仍是主要成本来源
95%505%源站只承接突发流量
96%404%当前方案的水平
99%101%回源几乎可以忽略,但要付出更多缓存空间和更频繁的刷新

衡量命中率时有一个必须分清楚的口径:请求数命中率和字节命中率。一张 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,
    }

三个最容易把命中率做低的坑:

  1. 缓存键里带了不该带的参数。时间戳、签名、追踪参数一旦进了缓存键,同一张图会被拆成无数份缓存,命中率断崖式下跌。
  2. 用 Vary 头做内容协商。Vary: Accept 会按浏览器的 Accept 字符串分裂缓存,一个 URL 常常变成三四份,命中率随之下降。
  3. 频繁刷新。每次内容更新都全量刷新,会把刚建立起来的缓存重新打散。刷新只用在”必须立刻生效”的场景。

怎么验证一次请求到底命中了没有?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 的成本

等等——CDN 流量费比 OSS 直连还贵?是的,CDN 本身不省钱,它省的是用户体验。

但 CDN 间接省钱的地方在于:

  1. 回源流量减少 95%——OSS 外网流量费大幅降低
  2. 源站压力减少——不需要高配置服务器
  3. 用户留存提升——加载速度影响转化率

发现内容有问题?

告诉我们哪里需要改进。