微型博客是内容密集型产品:HTML、JS/CSS 静态资源、图片附件、时间线 API、实时推送,每类流量都有截然不同的分发特征。CDN(内容分发网络)的价值不只是「加速」,更核心的是把流量挡在源站之外、把内容推到离用户最近的地方。本文按流量类型逐层拆解 CDN 建设,并重点讨论缓存失效与成本这两件最容易翻车的工程。
一、CDN 基础与静态资源加速
1.1 CDN 工作原理
CDN 的核心是「就近缓存」:把内容缓存在遍布全球的边缘节点,用户请求总是被调度到地理距离最近的节点,大幅降低网络往返延迟。
用户 (上海) ──→ 上海边缘节点 (命中缓存, ~5ms)
│
└── 未命中时回源 → 源站 (上海, ~40ms)
用户 (洛杉矶) ──→ 洛杉矶边缘节点 (命中缓存, ~8ms)
│
└── 未命中时回源 → 源站 (上海, ~200ms)
对微型博客这类内容消费型产品,静态资源与图片占整体流量的 80% 以上,是 CDN 的第一优先覆盖对象。
1.2 静态资源版本化
JS/CSS 等应用静态资源应采用内容哈希命名 + 永久缓存,这是静态资源加速的最优实践:
// Vite 构建产物示意
dist/assets/
├── index-DX3f2ka9.js // 内容哈希
├── vendor-9f1c4b7d.js
└── main-8a2e1f0c.css
# CDN 或源站 Nginx 配置:哈希资源长期缓存
location ~* \.(js|css|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.html$ {
expires -1; # HTML 永不缓存,确保引用最新资源
add_header Cache-Control "no-cache";
}
immutable 指令告诉浏览器和 CDN 该资源内容永不变,可放心长期缓存。当代码更新时,文件名哈希变化自然产生新 URL,不存在缓存污染。
1.3 图片资源加速
图片是微型博客流量的大头,其 CDN 策略与静态资源不同,需要尺寸/格式分层与源站协调。图片的完整管线(处理、存储、格式升级)参见 https://plumephp.com/miniblog-object-storage-images/,这里聚焦分发环节:
| 图片类型 | 缓存策略 | 说明 |
|---|---|---|
| 头像 | max-age=86400 (1天) | 更新频率低,变更时用新 key 覆盖 |
| 时间线配图 | max-age=31536000 + 内容哈希 | 内容不可变,永久缓存 |
| 动态封面/背景 | max-age=3600 | 有更新需求,短 TTL |
| 用户上传原图 | 不缓存或极短 TTL | 可能被审核下架,需尽快失效 |
二、边缘缓存策略
2.1 Cache-Control 分层语义
正确的缓存策略依赖对 HTTP 缓存头语义的精确理解:
浏览器缓存 ←── Cache-Control (max-age / no-cache / no-store)
↓
CDN 边缘缓存 ←── Cache-Control + s-maxage (共享缓存 TTL)
↓
源站 (PostgreSQL / 对象存储)
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=0, must-revalidate
对于时间线 API 这类按用户个性化的内容,必须用 private 禁止 CDN 共享缓存;对于全站一致的内容(如公共话题页、热门榜单),可用 public 配合 s-maxage 让 CDN 缓存。
2.2 边缘缓存示例配置
以 Cloudflare / 自建 CDN 为例,按路径区分缓存策略:
// Cloudflare Worker:基于 URL 的缓存策略路由
addEventListener('fetch', (event) => {
event.respondWith(handle(event.request));
});
async function handle(request) {
const url = new URL(request.url);
let cacheTtl = 0;
// 静态资源:永久缓存
if (/\.(js|css|svg|webp|avif)$/.test(url.pathname)) {
cacheTtl = 31536000;
}
// 公共时间线:短 TTL 缓存
else if (url.pathname.startsWith('/api/public/timeline')) {
cacheTtl = 30;
}
// 个性化 API:不缓存
else if (url.pathname.startsWith('/api/')) {
cacheTtl = 0;
}
const response = await fetch(request); // 回源
if (cacheTtl > 0 && response.status === 200) {
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, s-maxage=${cacheTtl}`);
return new Response(response.body, { status: 200, headers });
}
return response;
}
2.3 分层缓存体系
微型博客的缓存应当是全链路分层,而不只是 CDN 一层:
请求链路上的缓存层级
浏览器缓存 (HTTP Cache)
↓ miss
CDN 边缘缓存 (边缘节点)
↓ miss
应用缓存层 (Redis: 时间线/热点数据)
↓ miss
数据库 / 对象存储 (最终数据源)
- L1 浏览器:命中则零网络请求
- L2 边缘节点:命中则无跨地域网络开销
- L3 应用 Redis:命中则省掉数据库查询,缓存策略详见 https://plumephp.com/posts/redis/ 专题
- L4 数据库:最终一致性与持久性保证
每一层命中率都应被监控,层与层之间的 miss 比例能直观反映缓存配置是否合理。
2.4 缓存键设计
CDN 以「缓存键 (Cache Key)」区分不同的缓存条目。默认缓存键通常是完整 URL,但这对动态内容并不友好。合理的缓存键设计需要回答「哪些参数参与区分」:
| 参数类型 | 示例 | 是否纳入缓存键 |
|---|---|---|
| 内容处理参数 | ?w=600&format=webp | 是(不同处理结果不同内容) |
| 语言参数 | ?lang=zh | 是(本地化内容不同) |
| 追踪参数 | ?utm_source=xxx | 否(同一内容,只影响统计) |
| 随机防缓存 | ?t=12345 | 否(会击穿缓存,应去掉) |
# Nginx 缓存键:只保留影响内容的参数
proxy_cache_key "$scheme$host$uri?image_view=$arg_image_view&w=$arg_w";
proxy_cache_key "$scheme$host$uri" # 忽略 utm / t 等无关参数
缓存键设计失误的典型后果是「缓存碎片化」:同一张图片因 ?v=1、?v=2 等无意义参数产生大量重复缓存条目,既降低命中率又浪费边缘存储。正确做法是归一化 URL 后再作为缓存键,只保留对内容有影响的参数,其余参数一律剥离。
三、动态内容加速
3.1 动态请求为什么不能简单缓存
时间线、点赞、评论等 API 请求带用户态(Cookie/Token),且内容实时变化,传统 CDN 无法缓存。动态加速的核心手段是把「计算推到边缘」,减少源站往返:
- Edge Functions / Workers:在 CDN 边缘执行鉴权、改写、聚合
- TLS 会话复用:源站与边缘节点之间复用连接,降低握手开销
- 回源优化:合并请求、开启 Brotli 压缩、减少 payload
3.2 边缘聚合与个性化
// Cloudflare Worker 动态聚合:合并多个接口请求
async function handleTimeline(request) {
const token = request.headers.get('Authorization');
if (!token) return new Response('Unauthorized', { status: 401 });
// 在边缘并行请求多个内部服务,合并响应
const [posts, profile, notifications] = await Promise.all([
fetch('https://api.internal/timeline', { headers: { Authorization: token } }),
fetch('https://api.internal/me', { headers: { Authorization: token } }),
fetch('https://api.internal/notifications/count', { headers: { Authorization: token } }),
]);
const [postData, profileData, notifData] = await Promise.all([
posts.json(), profile.json(), notifData.json(),
]);
return Response.json({
posts: postData,
profile: profileData,
unreadNotifications: notifData.count,
});
}
把 N 次串行请求合并为边缘的一次并行聚合,首屏请求从 3 个 RTT 降到 1 个,移动端体感提升显著。结合 https://plumephp.com/miniblog-mobile-adaptation/ 的性能预算,这类优化直接作用于 LCP 指标。
3.3 动态内容的短期缓存
并非所有动态内容都不能缓存。以下场景可以安全地做短 TTL 缓存:
| 场景 | TTL | 失效条件 |
|---|---|---|
| 公共热门话题页 | 10-30s | 定时刷新 |
| 排行榜 / 趋势榜 | 60s | 定时任务重算 |
| 用户公开资料 | 60s | 资料变更时主动清除 |
| 搜索热词 | 5-10s | 聚合计算更新 |
// Go 侧配合:源站主动清除 CDN 缓存(Purge API)
func (s *CacheService) PurgeURL(ctx context.Context, url string) error {
// 调用 CDN 提供商的 purge 接口
req, _ := http.NewRequestWithContext(ctx, "POST",
s.cdnBase+"/v1/purge", strings.NewReader(`{"urls":["`+url+`"]}`))
req.Header.Set("Authorization", "Bearer "+s.cdnToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
return resp.Body.Close()
}
3.4 现代传输协议:HTTP/3 与 Brotli
传输层的升级同样属于动态加速范畴,而且是「协议级」的收益:
| 技术 | 解决的问题 | 收益 |
|---|---|---|
| HTTP/2 | 队头阻塞、连接复用 | 多路复用、首屏资源并行 |
| HTTP/3 (QUIC) | 弱网下 TCP 握手慢、丢包重传 | 0-RTT 恢复、弱网抗丢包强 |
| Brotli | gzip 压缩率已到瓶颈 | 文本体积再降 15-25% |
# 启用 Brotli 压缩(优先于 gzip)
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css application/json application/javascript image/svg+xml;
# 仅在客户端声明支持时启用
add_header "Accept-Encoding" "gzip, deflate, br, zstd";
移动网络环境下(参考 https://plumephp.com/miniblog-mobile-adaptation/ 的性能优化),HTTP/3 能显著改善弱网场景的首屏速度:QUIC 基于 UDP 实现了 0-RTT 连接恢复,用户在电梯、地铁等弱网场景中重连成本大幅下降。Brotli 则直接降低传输字节数,与 WebP 图片压缩(https://plumephp.com/miniblog-object-storage-images/)叠加后,整页传输体积可以压缩到传统方案的一半以下。
四、CDN 缓存失效与成本
4.1 缓存失效的三种手段
CDN 最容易踩的坑是「内容更新了,用户看到的还是旧的」。失效手段按粒度分三层:
| 手段 | 粒度 | 生效速度 | 适用场景 |
|---|---|---|---|
| 版本化新 URL | 单资源 | 即时 | 静态资源/图片 |
| Purge 定向清除 | 单 URL/目录 | 秒级 | 内容更新、审核下架 |
| Cache-Tag 批量失效 | 按标签 | 秒级 | 同一用户/话题的内容批量更新 |
// 通过 Cache-Tag 实现「一个用户的内容整体失效」
// 源站响应时打上标签
const headers = new Headers();
headers.set('Cache-Tag', `user:${userId}`);
// 更新用户资料后,按标签批量失效
await cdn.purgeByTag(`user:${userId}`);
4.2 缓存命中率与成本控制
CDN 成本两大项:流量费与请求费。命中率直接决定成本:
缓存命中率 = 边缘命中请求数 / 总请求数
| 命中率区间 | 健康状况 | 排查方向 |
|---|---|---|
| > 95% | 优秀 | 静态资源+图片占大头 |
| 85%-95% | 良好 | 动态 API 占比偏高 |
| < 85% | 需要优化 | 缓存头配置 / TTL 过短 / 带查询参数缓存未开 |
带查询参数的缓存是命中率杀手。/img/pic.webp?v=1 与 ?v=2 在默认配置下是两个不同缓存项。应区分对待:处理参数(如裁图尺寸)需纳入缓存键,但无关参数应忽略:
# Nginx CDN:忽略无意义的查询参数
proxy_cache_key "$uri?image_view";
4.3 成本治理清单
- 图片压缩先行:WebP/AVIF 让单图体积下降 30-50%,流量费直接减半(见 https://plumephp.com/miniblog-object-storage-images/)
- 合理 TTL:长期不更新的内容用长 TTL,避免反复回源计费
- 带宽封顶:为热门资源设置单 URL 带宽上限,防热点放大
- 预缓存 (Preload):新内容发布后主动预热 CDN,把回源压力集中在低峰
- 跨区域成本差异:国内 CDN 流量成本远低于海外,出海业务善用边缘按区域定价
五、总结
微型博客的 CDN 建设可以总结为「三流三分」:静态资源流走版本化永久缓存,图片流走哈希命名 + 尺寸格式分层,动态 API 流走边缘聚合 + 短期缓存 + 定向失效。缓存失效与成本治理不是事后修补,而应在设计缓存头的那一刻就内置进去——版本化优先、Tag 失效兜底、命中率纳入日常监控。
当 CDN 配置正确时,源站看到的数据请求可能只有全部流量的 5% 以下——这意味着 95% 的流量成本被 CDN 吸收,源站得以专注在真正无法缓存的计算与数据上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。