CDN 缓存策略与命中率优化:从 40% 到 95% 的全链路调优实战
一、CDN 命中率为什么是前端性能的命门
CDN 命中率直接决定用户体验和源站压力。命中率 40% 意味着 60% 的请求回源,源站带宽成本翻倍,用户首次访问延迟增加 200-500ms。命中率 95% 意味着只有 5% 的请求回源,源站几乎无压力,用户访问延迟稳定在 20-50ms。
我接手过的一个项目,CDN 命中率只有 42%。排查后发现原因不是 CDN 配置问题,而是缓存策略设计不合理:动态接口加了缓存但没设 Vary 头,导致不同用户拿到相同数据;静态资源的 Cache-Control 设了 no-cache,每次都回源验证;API 响应没有区分可缓存和不可缓存的数据,一刀切全不缓存。
CDN 缓存策略优化的核心不是调 CDN 配置,而是从源站到 CDN 到浏览器,全链路设计缓存策略。每个资源类型有不同的缓存需求,每个请求路径有不同的缓存规则。一刀切的策略必然导致命中率低下。
这篇文章从前端到后端到 CDN,给出全链路缓存策略的设计和优化方案。
二、全链路缓存架构
flowchart TD A[用户请求] --> B[浏览器缓存] B -->|命中| C[直接返回<br/>0ms 延迟] B -->|未命中| D[CDN 边缘节点] D -->|命中| E[CDN 返回<br/>20-50ms 延迟] D -->|未命中| F[CDN 中间层<br/>区域缓存] F -->|命中| G[区域缓存返回<br/>50-100ms] F -->|未命中| H[回源站] H --> I[源站应用缓存<br/>Redis/Memcached] I -->|命中| J[应用缓存返回<br/>100-200ms] I -->|未命中| K[源站计算] K --> L[数据库查询] L --> M[返回响应] M --> N[设置缓存头] N --> O[CDN 缓存响应] O --> P[浏览器缓存响应] subgraph 缓存分层策略 Q[强缓存<br/>Cache-Control: max-age] R[协商缓存<br/>ETag/Last-Modified] S[不缓存<br/>Cache-Control: no-store] end Q -->|静态资源| T[JS/CSS/图片/字体] R -->|半动态资源| U[HTML/API 列表页] S -->|实时数据| V[用户信息/支付接口]缓存分四层:浏览器缓存 → CDN 边缘缓存 → CDN 区域缓存 → 源站应用缓存。每一层有独立的命中策略和失效机制。全链路命中率 = 各层命中率的乘积,所以每一层都要优化。
三、全链路缓存策略实现
3.1 静态资源:强缓存 + 内容哈希
# Nginx: 静态资源缓存配置 server { listen 80; server_name static.example.com; # 带 hash 的静态资源:长期强缓存 location ~* /assets/.*\.[a-f0-9]{8,}\.(js|css|png|jpg|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options "nosniff"; # immutable 告诉浏览器不需要条件请求验证 } # 不带 hash 的静态资源:短缓存 + 协商 location ~* /static/.*\.(js|css|png|jpg|svg|woff2)$ { expires 7d; add_header Cache-Control "public"; etag on; } # HTML 入口文件:不缓存或极短缓存 location / { root /usr/share/nginx/html; try_files $uri /index.html; # HTML 必须每次验证,确保用户拿到最新版本 add_header Cache-Control "no-cache"; etag on; # 关键:HTML 不能强缓存,否则新版本发布后用户看不到更新 } # 媒体文件:大文件分片缓存 location ~* /media/.*\.(mp4|webm|mp3)$ { expires 30d; add_header Cache-Control "public"; add_header Accept-Ranges bytes; # 支持断点续传,CDN 可以缓存分片 } }3.2 API 响应:分层缓存策略
# api_cache.py - API 响应缓存中间件 from fastapi import FastAPI, Request, Response from fastapi.middleware import Middleware from functools import wraps import hashlib import json class CacheStrategy: """API 缓存策略配置""" # 完全不可缓存的接口 NO_CACHE = { "Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache", } # 私有缓存(仅浏览器缓存,CDN 不缓存) PRIVATE = { "Cache-Control": "private, max-age=300", } # 短时公共缓存(CDN + 浏览器) SHORT_PUBLIC = { "Cache-Control": "public, max-age=60, s-maxage=300", # s-maxage > max-age: CDN 缓存时间比浏览器长 } # 长时公共缓存 LONG_PUBLIC = { "Cache-Control": "public, max-age=3600, s-maxage=86400", } # API 缓存策略映射 API_CACHE_MAP = { # 不可缓存 "POST /api/auth/login": CacheStrategy.NO_CACHE, "POST /api/payment": CacheStrategy.NO_CACHE, "GET /api/user/profile": CacheStrategy.PRIVATE, # 短缓存 "GET /api/feed": CacheStrategy.SHORT_PUBLIC, "GET /api/search": CacheStrategy.SHORT_PUBLIC, "GET /api/articles": CacheStrategy.SHORT_PUBLIC, # 长缓存 "GET /api/categories": CacheStrategy.LONG_PUBLIC, "GET /api/config": CacheStrategy.LONG_PUBLIC, "GET /api/tags": CacheStrategy.LONG_PUBLIC, } def get_cache_headers(method: str, path: str) -> dict: """根据 API 路径获取缓存头""" key = f"{method} {path}" # 精确匹配 if key in API_CACHE_MAP: return API_CACHE_MAP[key] # 前缀匹配 for pattern, headers in API_CACHE_MAP.items(): p_method, p_path = pattern.split(" ", 1) if method == p_method and path.startswith(p_path): return headers # 默认不缓存 return CacheStrategy.NO_CACHE # Vary 头:区分不同客户端的缓存 VARY_MAP = { "/api/feed": "Accept-Encoding, X-User-Region", # 按地区区分 "/api/search": "Accept-Encoding, X-Search-Lang", # 按语言区分 "/api/articles": "Accept-Encoding", # 仅按编码区分 } class CacheMiddleware: """API 缓存中间件""" async def __call__(self, request: Request, call_next): response = await call_next(request) # 设置缓存头 cache_headers = get_cache_headers(request.method, request.url.path) for key, value in cache_headers.items(): response.headers[key] = value # 设置 Vary 头 vary = VARY_MAP.get(request.url.path, "Accept-Encoding") response.headers["Vary"] = vary # 为可缓存接口添加 CDN 缓存键标识 if "s-maxage" in response.headers.get("Cache-Control", ""): cache_key = self._generate_cache_key(request) response.headers["X-Cache-Key"] = cache_key return response def _generate_cache_key(self, request: Request) -> str: """生成 CDN 缓存键""" parts = [ request.method, request.url.path, request.headers.get("Accept-Encoding", ""), request.headers.get("X-User-Region", ""), ] raw = "|".join(parts) return hashlib.md5(raw.encode()).hexdigest()[:12]3.3 CDN 缓存刷新与预热
# cdn_manager.py - CDN 缓存管理 import httpx import asyncio from dataclasses import dataclass @dataclass class CDNConfig: provider: str # aliyun/tencent/cloudflare api_url: str api_key: str class CDNManager: def __init__(self, config: CDNConfig): self.config = config async def purge_urls(self, urls: list[str]) -> dict: """精确刷新指定 URL 的缓存""" if self.config.provider == "aliyun": return await self._aliyun_purge(urls) elif self.config.provider == "cloudflare": return await self._cloudflare_purge(urls) async def purge_directory(self, directory: str) -> dict: """刷新目录下所有缓存""" urls = [f"https://cdn.example.com{directory}*"] return await self.purge_urls(urls) async def prefetch(self, urls: list[str]) -> dict: """预热:提前将资源拉取到 CDN 节点""" if self.config.provider == "aliyun": return await self._aliyun_prefetch(urls) async def _aliyun_purge(self, urls: list[str]) -> dict: """阿里云 CDN 缓存刷新""" import hmac import hashlib import base64 import time timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) async with httpx.AsyncClient() as client: resp = await client.post( f"{self.config.api_url}/2018-01-15/PushObjectCache", headers={ "Authorization": f"ACS {self.config.api_key}", "Date": timestamp, }, json={ "ObjectPath": urls, "ObjectType": "File" } ) return resp.json() async def _cloudflare_purge(self, urls: list[str]) -> dict: """Cloudflare CDN 缓存刷新""" async with httpx.AsyncClient() as client: resp = await client.post( f"{self.config.api_url}/zones/{self.config.api_key}/purge_cache", json={"files": urls} ) return resp.json() async def _aliyun_prefetch(self, urls: list[str]) -> dict: """阿里云 CDN 预热""" async with httpx.AsyncClient() as client: resp = await client.post( f"{self.config.api_url}/2018-01-15/PushObjectCache", json={ "ObjectPath": urls, "ObjectType": "preload" } ) return resp.json() # 发布后自动刷新 CDN 缓存 async def post_deploy_cache_refresh(version: str): """发布新版本后刷新 CDN 缓存""" cdn = CDNManager(CDNConfig( provider="aliyun", api_url="https://cdn.aliyuncs.com", api_key=os.getenv("ALIYUN_CDN_KEY") )) # 1. 刷新 HTML 入口(必须刷新,否则用户看不到新版本) await cdn.purge_urls(["https://cdn.example.com/index.html"]) # 2. 带 hash 的静态资源不需要刷新(文件名变了就是新 URL) # 3. 预热关键资源 await cdn.prefetch([ f"https://cdn.example.com/assets/main.{version}.js", f"https://cdn.example.com/assets/vendor.{version}.js", f"https://cdn.example.com/assets/main.{version}.css", ])3.4 命中率监控
# cache_monitor.py - CDN 命中率监控 import httpx from prometheus_client import Gauge, Counter class CacheMonitor: def __init__(self): self.hit_rate = Gauge( "cdn_hit_rate_percent", "CDN cache hit rate", ["domain", "content_type"] ) self.request_count = Counter( "cdn_request_total", "CDN request count", ["domain", "cache_status"] ) async def collect_metrics(self, domain: str): """从 CDN API 收集命中率指标""" # 从 CDN 供应商 API 获取实时命中率 async with httpx.AsyncClient() as client: resp = await client.get( f"https://cdn-api.example.com/metrics", params={"domain": domain, "granularity": "5min"} ) data = resp.json() for content_type, metrics in data.items(): total = metrics["total_requests"] hits = metrics["cache_hits"] rate = (hits / total * 100) if total > 0 else 0 self.hit_rate.labels( domain=domain, content_type=content_type ).set(rate) self.request_count.labels( domain=domain, cache_status="hit" ).inc(hits) self.request_count.labels( domain=domain, cache_status="miss" ).inc(total - hits)四、边界分析与架构权衡
4.1 缓存一致性 vs 命中率
缓存一致性要求越高,命中率越低。如果要求用户永远看到最新数据,那缓存时间必须设得很短,命中率自然低。
平衡策略:区分"必须实时"和"可以延迟"的数据——用户余额必须实时,文章列表可以延迟 1 分钟;使用 stale-while-revalidate 策略——先返回缓存数据,后台异步更新;对关键数据做主动缓存刷新——数据变更时主动刷新 CDN 缓存。
4.2 Vary 头的陷阱
Vary 头告诉 CDN 根据请求头的值区分缓存。Vary 太少会导致不同用户拿到相同缓存(如不同语言的用户看到相同内容),Vary 太多会导致缓存碎片化、命中率下降。
最佳实践:只 Vary 必要的请求头(Accept-Encoding、Accept-Language);自定义头用 CDN 的缓存键配置而非 Vary 头;定期检查 Vary 头导致的缓存碎片率。
4.3 大文件缓存
视频、安装包等大文件的 CDN 缓存策略与普通资源不同。大文件回源成本极高(带宽 + 延迟),但缓存命中率也高(同一文件被多次请求)。
优化方案:大文件使用分片缓存(Range 请求),CDN 可以缓存文件的各个分片;设置更长的缓存时间(30 天+);预热热门大文件,避免首次请求回源。
4.4 动态内容的缓存
API 响应等动态内容的缓存是最复杂的场景。完全不可缓存导致源站压力大,过度缓存导致数据不一致。
分层策略:个性化数据(用户信息)→ 不缓存或仅浏览器私有缓存;半动态数据(文章列表)→ CDN 短缓存 + stale-while-revalidate;准静态数据(配置信息)→ CDN 长缓存 + 主动刷新。
五、总结
CDN 缓存策略优化的核心不是调 CDN 配置,而是全链路设计缓存策略——从源站的 Cache-Control 头,到 CDN 的缓存规则,到浏览器的缓存行为,每一层都要精确控制。
命中率从 40% 提升到 95% 的关键动作:静态资源加内容哈希实现长期强缓存;API 响应按数据特征分层设置缓存策略;正确使用 Vary 头区分不同客户端的缓存;发布后精确刷新 HTML 入口,预热关键静态资源。
从云原生实践的角度,CDN 缓存策略应该纳入 CI/CD 流程——每次发布自动刷新需要更新的缓存,预热新版本的静态资源。手动管理 CDN 缓存是不可靠的,自动化才是正道。