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 提供商 | 全球节点数 | 国内覆盖 | 价格 | 特点 |
|---|---|---|---|---|
| Cloudflare | 300+ | 一般 | 免费/按量 | 生态丰富 |
| AWS CloudFront | 400+ | 一般 | 按量付费 | AWS 集成 |
| 阿里云 CDN | 2800+ | 优秀 | 按量付费 | 国内首选 |
| 腾讯云 CDN | 2800+ | 优秀 | 按量付费 | 国内优秀 |
| 七牛云 CDN | 2000+ | 良好 | 按量付费 | 性价比高 |
考虑到我的用户主要在国内,但也有一些海外用户,我选择了阿里云 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=300 | CDN 缓存 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 后,我上线了。效果立竿见影:
性能对比
| 指标 | 上线前 | 上线后 | 改善 |
|---|---|---|---|
| 纽约用户延迟 | 350ms | 35ms | 降低 90% |
| 伦敦用户延迟 | 280ms | 30ms | 降低 89% |
| 东京用户延迟 | 120ms | 20ms | 降低 83% |
| 北京用户延迟 | 50ms | 15ms | 降低 70% |
| 源站 QPS | 50000 | 10000 | 降低 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 ✅