CDN 加速

限流之后,新的问题来了

限流上线后,系统不再崩溃了。但新的投诉随之而来——“你们的短链接打不开了”、“太慢了,等了好几秒才跳转”。

我查看监控数据,发现被限流的那 20% 请求,用户体验确实很差。而且随着用户量增长,海外用户的投诉越来越多:

“我在纽约,每次点击短链接都要等 300 多毫秒才能跳转,这也太慢了吧?”

我意识到,限流只是治标不治本。它保护了系统,但牺牲了用户体验。我需要找到一个方案,既能减轻服务器压力,又能让全球用户快速访问。

发现 CDN 加速的可能性

那天晚上,我研究了国内外短链接服务的架构,发现一个共同点:他们都用了 CDN。

“CDN 不是用来加速静态资源的吗?短链接跳转是动态请求,也能用 CDN?” 我有些疑惑。

深入研究后,我恍然大悟:短链接的跳转操作,本质上是一个只读的静态映射。同一个短码,永远返回同一个长 URL。这个特性让 CDN 缓存成为可能。

核心思想

传统模式下,所有用户都请求我的服务器:

全球用户 → 我的服务器(北京)

      查询数据库

      返回长 URL

这导致:

  • 纽约用户要跨半个地球访问北京,延迟 200ms+
  • 我的服务器要承受所有流量,QPS 压力巨大

CDN 模式下,把跳转结果缓存到边缘节点:

全球用户 → 最近的 CDN 节点

      直接返回长 URL(缓存命中)
      
CDN 未命中 → 我的服务器 → 缓存结果

我画了一张架构图:

                    ┌─────────────┐
                    │   源站(北京) │
                    │  我的服务器    │
                    └──────┬──────┘
                           │ 内容分发
              ┌────────────┼────────────┐
              ↓            ↓            ↓
        ┌──────────┐ ┌──────────┐ ┌──────────┐
        │ CDN 节点  │ │ CDN 节点  │ │ CDN 节点  │
        │  纽约     │ │  伦敦     │ │  东京     │
        └─────┬────┘ └─────┬────┘ └─────┬────┘
              ↑            ↑            ↑
           美国用户      欧洲用户      亚洲用户

理论上,CDN 能承担 80-90% 的流量,用户延迟从 200ms+ 降到 20ms 左右。

选择 CDN 服务商

我对比了几家主流 CDN 服务商:

CDN 提供商全球节点数国内覆盖价格特点
Cloudflare300+一般免费/按量生态丰富
AWS CloudFront400+一般按量付费AWS 集成
阿里云 CDN2800+优秀按量付费国内首选
腾讯云 CDN2800+优秀按量付费国内优秀
七牛云 CDN2000+良好按量付费性价比高

考虑到我的用户主要在国内,但也有一些海外用户,我选择了阿里云 CDN。它的国内节点覆盖最好,海外也有不少节点。

CDN 配置实战

第一步:添加加速域名

我在阿里云 CDN 控制台添加了加速域名 s.url,回源地址设置为我的服务器 IP。

配置很简单,但有一个关键点:CDN 默认缓存所有响应,包括错误响应。这会导致问题,后面我会详细说。

第二步:配置 Nginx 返回 CDN 友好的响应

CDN 是否缓存,取决于响应头中的 Cache-Control。我需要让 Nginx 返回正确的缓存头。

我修改了 Nginx 配置,使用 Lua 脚本查询 Redis 并返回带缓存头的 302 响应:

server {
    listen 80;
    server_name s.url;

    location ~ ^/([a-zA-Z0-9]{6})$ {
        # 获取短码
        set $short_code $1;

        # 使用 Nginx + Lua 查询 Redis
        content_by_lua_block {
            local redis = require "resty.redis"
            local red = redis:new()
            local ok, err = red:connect("redis", 6379)

            local short_code = ngx.var[1]
            local long_url, err = red:get("url:" .. short_code)

            if long_url and long_url ~= ngx.null then
                -- 🔑 关键:设置缓存头,允许 CDN 缓存
                ngx.header["Cache-Control"] = "public, s-maxage=300, max-age=60"
                ngx.header["X-Cache-Origin"] = "redis"
                ngx.redirect(long_url, 302)
            else
                -- 404 不缓存
                ngx.header["Cache-Control"] = "no-store"
                ngx.exit(404)
            end
        }
    }
}

这里的 Cache-Control 头是关键:

  • public:允许 CDN 缓存
  • s-maxage=300:CDN 缓存 300 秒(5 分钟)
  • max-age=60:浏览器缓存 60 秒

第三步:针对不同类型短链接的缓存策略

我意识到,不同的短链接应该有不同的缓存策略:

def redirect(short_code):
    """带 CDN 缓存头的重定向"""

    long_url = get_long_url(short_code)
    if not long_url:
        response = jsonify({"error": "Not Found"})
        response.status_code = 404
        response.headers['Cache-Control'] = 'no-store'  # 404 不缓存
        return response

    response = redirect_response(long_url, code=302)

    # 根据短链接类型设置不同的缓存时间
    if is_permanent_link(short_code):
        # 永久链接:目标 URL 很少变化,缓存时间长
        response.headers['Cache-Control'] = 'public, s-maxage=300, max-age=60'
    else:
        # 临时链接:目标 URL 可能变化,缓存时间短
        response.headers['Cache-Control'] = 'public, s-maxage=30, max-age=10'

    return response
参数含义建议值
public允许 CDN 缓存始终使用
private只允许浏览器缓存不推荐
max-age=60浏览器缓存 60 秒10-60 秒
s-maxage=300CDN 缓存 300 秒60-300 秒
no-cache每次都要验证不推荐
no-store完全不缓存用于 404 等错误响应

为什么用 302 而不是 301?

有人问我:“为什么用 302 临时重定向,而不是 301 永久重定向?301 不是性能更好吗?”

我解释道:“301 会被浏览器永久缓存。如果短链接的目标 URL 变了,用户看到的还是旧的 URL,体验很差。302 每次都会请求服务器(或 CDN),能及时响应变化。”

这对 CDN 的影响是:301 响应 CDN 几乎永久缓存,302 响应按照 s-maxage 缓存。对于可能变化的短链接,302 更合适。

缓存失效机制

配置好 CDN 后,我遇到了一个新问题:缓存更新延迟。

某天,一个客户投诉:“我更新了短链接的目标 URL,但用户还是跳转到旧地址!”

我查看代码,发现 s-maxage=300,意味着 CDN 会缓存 5 分钟。对于营销活动,5 分钟的延迟可能无法接受。

方案一:等待过期(最简单)

最简单的方式是让缓存自然过期。

Cache-Control: public, s-maxage=60

→ CDN 最多缓存 60 秒
→ 60 秒后 CDN 会回源获取最新 URL

优点:简单,无需额外逻辑 缺点:有缓存不一致时间窗口

方案二:主动刷新缓存(我选择了这个)

我实现了 CDN 缓存刷新 API,当短链接目标 URL 变化时,主动刷新 CDN 缓存:

import requests

class CDNRefresher:
    """CDN 缓存刷新器"""

    def __init__(self, cdn_api_url, api_key):
        self.cdn_api_url = cdn_api_url
        self.api_key = api_key

    def purge_url(self, short_code):
        """刷新单个 URL 的缓存"""
        url = f"https://s.url/{short_code}"

        response = requests.post(
            f"{self.cdn_api_url}/purge",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"urls": [url]}
        )

        if response.status_code == 200:
            print(f"✅ 已刷新:{url}")
        else:
            print(f"❌ 刷新失败:{url}")

    def purge_batch(self, short_codes):
        """批量刷新缓存"""
        urls = [f"https://s.url/{code}" for code in short_codes]

        response = requests.post(
            f"{self.cdn_api_url}/purge",
            headers={"Authorization": f"Bearer {self.api_key}"},
            json={"urls": urls}
        )

        return response.status_code == 200


# 当短链接目标 URL 变化时
def update_short_link(short_code, new_long_url):
    """更新短链接并刷新 CDN 缓存"""
    # 1. 更新数据库
    db.execute("UPDATE url_mapping SET long_url = ? WHERE short_code = ?",
               (new_long_url, short_code))

    # 2. 更新 Redis
    redis.setex(f"url:{short_code}", 3600, new_long_url)

    # 3. 刷新 CDN 缓存
    cdn_refresher.purge_url(short_code)

    return {"status": "updated"}

这样,目标 URL 更新后,缓存会在几秒内失效。

方案三:URL 版本化

另一种方式是在 URL 中加入版本号:

https://s.url/abc123?v=1  ← 旧版本
https://s.url/abc123?v=2  ← 新版本,CDN 视为新 URL

但这种方式会改变短链接,不适合我的场景。

上线效果

配置好 CDN 后,我上线了。效果立竿见影:

性能对比

指标上线前上线后改善
纽约用户延迟350ms35ms降低 90%
伦敦用户延迟280ms30ms降低 89%
东京用户延迟120ms20ms降低 83%
北京用户延迟50ms15ms降低 70%
源站 QPS5000010000降低 80%
CDN 缓存命中率0%80%-

全链路性能优化

CDN 只是其中一环,看看加上 CDN 后的全链路性能:

链路                 优化前      优化后
─────────────────────────────────────────
DNS 解析            50ms       5ms    (CDN DNS 加速)
TCP 连接            100ms      10ms   (就近节点)
TLS 握手            100ms      20ms   (CDN TLS 卸载)
服务端处理          50ms       0ms    (CDN 直接响应)
─────────────────────────────────────────
总计                300ms      35ms   ⚡ 快了 8.5 倍

用户投诉大幅减少,海外用户反馈”现在访问快多了”。

CDN 的坑

CDN 上线后,效果很好。但我也遇到了一些问题。

问题一:缓存成本超出预期

月底我看账单,发现 CDN 流量费 ¥870/月。而我的服务器租费才 ¥500/月。CDN 比服务器还贵!

我算了笔账:

每天处理 1 亿次重定向
每次重定向约 1KB 数据
CDN 缓存命中率 80%

CDN 流量:
- 总流量:1 亿 × 1KB = 100GB/天
- 回源流量:100GB × 20% = 20GB/天

费用(阿里云 CDN):
- CDN 流量费:100GB × ¥0.24/GB ≈ ¥24/天
- 回源流量费:20GB × ¥0.5/GB ≈ ¥10/天
- 月费用:约 ¥1020

但如果没有 CDN:
- 服务器需要抗 100K QPS
- 至少 10 台高配服务器
- 月费用:¥50,000+

虽然有 CDN 成本,但还是省了 98%!💰

虽然 CDN 有成本,但相比扩容服务器,还是划算的。

问题二:缓存命中率不稳定

运行一段时间后,我发现缓存命中率波动很大。有时 90%,有时只有 60%。

我分析了日志,发现:

  • 很多短链接只被访问一次(比如一次性验证链接)
  • 这些短链接缓存了也没用,反而占用 CDN 空间
  • 热门短链接的缓存命中率很高

我调整了策略:只对热门短链接开启 CDN 缓存,冷门短链接不缓存。

def get_cache_strategy(short_code):
    """根据访问频率决定缓存策略"""
    access_count = get_access_count(short_code)

    if access_count > 100:
        # 热门链接:长时间缓存
        return 'public, s-maxage=3600, max-age=300'
    elif access_count > 10:
        # 中等热度:中等缓存
        return 'public, s-maxage=300, max-age=60'
    else:
        # 冷门链接:不缓存
        return 'no-store'

调整后,缓存命中率稳定在 85% 以上。

问题三:404 也被缓存了

有用户反馈:“我创建了一个短链接,但访问时显示 404。后来我发现是创建失败了,但 404 被 CDN 缓存了,一直显示错误。”

我意识到,CDN 默认会缓存所有响应,包括 404。我修改了代码,确保 404 不被缓存:

# ❌ 错误:404 也不小心被 CDN 缓存了
@app.route('/<short_code>')
def redirect(short_code):
    long_url = get_long_url(short_code)
    if not long_url:
        response = jsonify({"error": "Not Found"})
        response.status_code = 404
        # 404 默认也可能被 CDN 缓存!
        return response

# ✅ 正确:404 不缓存
@app.route('/<short_code>')
def redirect(short_code):
    long_url = get_long_url(short_code)
    if not long_url:
        response = jsonify({"error": "Not Found"})
        response.status_code = 404
        response.headers['Cache-Control'] = 'no-store'  # 不缓存 404
        return response

问题四:缓存雪崩

有一天,我发现源站 QPS 突然飙升。查看日志,发现大量请求同时回源。

原来是很多缓存同时过期,导致”缓存雪崩”。

我给缓存时间加了随机偏移:

import random

def get_cache_maxage():
    """带随机偏移的缓存时间"""
    base = 300  # 基础 5 分钟
    jitter = random.randint(0, 60)  # 0-60 秒随机
    return base + jitter

# 实际过期时间在 300-360 秒之间随机分布
# 避免大量缓存同时过期

问题五:Vary 头导致缓存失效

有一次,我发现某些用户访问正常,某些用户访问异常。排查后发现,CDN 根据 Accept-Language 头分别缓存了响应,导致缓存命中率下降。

我检查了响应头,发现有个地方设置了 Vary: Accept-Language。对于短链接跳转,响应内容与语言无关,我移除了这个头。

# 如果响应内容因 Accept-Language 不同而不同
response.headers['Vary'] = 'Accept-Language'
# CDN 会按语言分别缓存

# 短链接跳转与语言无关,不需要 Vary

完整的请求流程

加上 CDN 后,完整的请求流程变成了这样:

用户点击 https://s.url/abc123

[1] DNS 解析 → CDN 的 Anycast IP

[2] TCP/TLS → 就近的 CDN 节点(30ms 内)

[3] CDN 检查缓存
   ├── 命中 → 302 重定向 ✅(总耗时 ~35ms)
   └── 未命中 ↓
[4] CDN 回源 → 我的服务器

[5] 服务器查 Redis → 查数据库

[6] 返回 302 + Cache-Control

[7] CDN 缓存结果 → 响应用户

[8] 后续请求直接走 CDN ✅