内容分发与 CDN:静态加速、边缘缓存与缓存失效

系统讲解微型博客内容分发网络的建设:静态资源与图片加速、边缘缓存策略(Cache-Control/分层缓存)、动态内容加速(Edge Functions/回源优化)、CDN 缓存失效与成本控制。

微型博客是内容密集型产品: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 无法缓存。动态加速的核心手段是把「计算推到边缘」,减少源站往返:

  1. Edge Functions / Workers:在 CDN 边缘执行鉴权、改写、聚合
  2. TLS 会话复用:源站与边缘节点之间复用连接,降低握手开销
  3. 回源优化:合并请求、开启 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 恢复、弱网抗丢包强
Brotligzip 压缩率已到瓶颈文本体积再降 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 吸收,源站得以专注在真正无法缓存的计算与数据上。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniblog」更多文章

  1. 速率限制与防滥用:令牌桶、滑动窗口与分布式限流
  2. 通知系统:通知类型、聚合去重、多端同步与推送架构
  3. 评论与互动系统:评论树、@提及、点赞转发与互动计数一致性