CDN 缓存键治理实战:查询字符串、追踪参数与 Vary 如何把缓存切碎,让回源率降一半

为什么缓存命中率上去了,源站压力却没降

很多站长给站点接上 CDN 之后,会盯着控制台里的"缓存命中率 95%"沾沾自喜,然后发现源站带宽账单还是那么高、Nginx 的请求数也没少多少。这种"看着命中率很高、实际没省下多少"的落差,绝大多数情况下都指向同一个被忽视的环节:CDN 缓存键(Cache Key)的构成。

这篇文章不讲怎么选 CDN,也不讲怎么开缓存,而是专门讲一件事——缓存键里到底该不该包含查询字符串、包含哪些、怎么治理,以及为什么你那个"看起来开了缓存"的页面其实一条都没被缓存住。

缓存键是什么:CDN 眼里"同一个资源"的判定标准

CDN 节点上通常不会存一份叫"原始 URL"的东西,它存的是缓存键,也就是决定"这两个请求是否命中同一份缓存"的指纹。默认情况下,缓存键往往就是完整的 URL,包括协议、主机、路径和查询字符串。也就是说:

/article/1001.html
/article/1001.html?from=wechat
/article/1001.html?utm_source=weibo&utm_campaign=x
/article/1001.html?_=1792128623

在默认缓存键下,上面这四条会被当成四个完全不同的资源分别回源、分别缓存。你的文章只有一份,但 CDN 上存了四份,源站被打了四次。这就是"命中的是那部分裸 URL 请求,占总请求量的比例看着很高,实际绝对回源次数一点没少"的根本原因。

第一类污染源:营销参数与追踪参数

外部平台分享链接时,几乎都会自动附加追踪参数:微信会带 from=singlemessage 或 from=groupmessage,微博和知乎会带 utm_source、utm_medium,搜索引擎会带 gclid、fbclid、yclid、msclkid。这些参数对页面内容没有任何影响,理应被排除在缓存键之外。

正确做法是在 CDN 配置里指定"缓存键忽略以下参数",常见的白名单/黑名单写法是:

# 概念示例:忽略所有以 utm_ 开头的参数
忽略参数: utm_*, gclid, fbclid, yclid, msclkid, from, \
          spm, scm, share_token, wxopenid

# Nginx 作为源站时,可以用 map 判断"这个请求是否该被缓存"
map $args $cacheable {
    default                      1;
    "~*utm_"                     0;
    "~*fbclid"                   0;
    "~*gclid"                    0;
    "~*_="                       0;   # 时间戳破缓存参数,绝不缓存
}
proxy_cache_bypass  $cacheable_exception;
proxy_no_cache      $cacheable_exception;
add_header X-Cacheable $cacheable always;

注意那个 _= 参数:很多站长为了调试或强制刷新会手动加时间戳,但如果你在页面上(比如评论提交后跳转、广告点击回跳)也用这种方式顺便"破缓存",就会导致大量本该命中缓存的请求被绕过。加时间戳应该是例外,不是常规操作。

第二类污染源:分页、排序与筛选参数

列表页、搜索页、标签页是缓存键碎片化的重灾区。以我自己的站为例,一个"文章列表"页支持了这些参数:

  • ?page=2(分页,内容不同,应该参与缓存键)
  • ?sort=hot / ?sort=new(排序,内容不同,应该参与)
  • ?tag=nginx(筛选,内容不同,应该参与)
  • ?utm_source=xxx(追踪,内容相同,不该参与)
  • ?sessionid=abc(会话,内容相同但会污染,不该参与)

关键判断原则只有一句话:这个参数会不会改变返回给用户的 HTML 正文? 会改变就必须进缓存键,不会改变就必须排除。很多 CDN 默认把参数全进缓存键,看起来很"安全",其实代价是缓存被切得粉碎,命中率虚高、回源率不降。

对分页这类确实应该参与缓存键的参数,还要注意参数顺序归一化的问题。?a=1&b=2 和 ?b=2&a=1 语义完全相同,但如果 CDN 不做排序归一化,就会算成两个缓存键,同样造成碎片。部分支持"参数排序"的 CDN 可以开启这个选项;不支持的,就需要在源站层用 Nginx 做 301 归一化。

第三类污染源:Cookie 与 Vary 头

如果 CDN 因为看到 Cookie 头就决定"这个请求是私人的、不缓存",那你站上所有带 Cookie 的访问都会跳过缓存。典型的踩坑场景是:

  1. 站里装了一个统计脚本或评论插件,它在每个页面响应里种了一个 Cookie。
  2. 用户第一次访问,CDN 缓存了页面(但页面里嵌了种 Cookie 的逻辑)。
  3. 用户第二次访问带着 Cookie 来,CDN 一看有 Cookie,直接回源。
  4. 于是老用户全部回源,等于给老用户完全没上缓存。

解法是让不应影响缓存的 Cookie 不参与缓存判定。在 CDN 里配置"忽略 Cookie XXX",或者在源站主动把统计类 Cookie 用 Path=/ 限定到不需要缓存的路径上。同时检查 Vary 响应头——如果源站返回了 Vary: Cookie 或 Vary: User-Agent,CDN 会按这些维度切分缓存,缓存键维度瞬间爆炸。

# 源站示例:只有明确需要区分 UA 的页面才返回 Vary
# 大多数 HTML 页面不要加 Vary: User-Agent
add_header Vary "Accept-Encoding" always;

# 排查:看响应头里到底有没有意外引入的 Vary
curl -sI "https://www.example.com/article/1001.html" | grep -i vary

第四类污染源:协议、主机名与 www/非 www

http://example.com、https://example.com、https://www.example.com 在默认缓存键下是三份缓存。这在早期"HTTP 到 HTTPS 迁移没做 301"的站上极常见。标准做法是:

# Nginx:把所有变体永久跳转到唯一的规范形式
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://www.example.com$request_uri;
}
server {
    listen 443 ssl;
    server_name example.com;
    return 301 https://www.example.com$request_uri;
}

跳转做干净之后,缓存键里的主机维度收敛到一个值,缓存利用率自然上去。注意顺序:先用 301 把流量收敛,再考虑缓存,否则你是在缓存三份一模一样的副本。

怎么验证缓存键治理有没有生效

不要凭感觉,要用可观测证据。核心手段是读 CDN 返回的命中状态头,各个厂商的头名不同,常见的有 X-Cache、X-Cache-Status、CF-Cache-Status、X-Cache-Lookup:

# 连续请求同一个资源两次,第二次应为 HIT
for i in 1 2; do
  curl -sI "https://www.example.com/article/1001.html" \
    | grep -iE 'x-cache|age|cache-control'
  sleep 1
done

# 验证参数是否被忽略:这两次应该命中同一份缓存
curl -sI "https://www.example.com/article/1001.html"              | grep -i x-cache
curl -sI "https://www.example.com/article/1001.html?utm_source=x" | grep -i x-cache

如果第二条带了追踪参数却依然返回 HIT,说明忽略参数生效了。反之如果返回 MISS 或 BYPASS,就是缓存键没治理好。Age 头也很有用:它表示这份缓存已经在节点上存活了多少秒,连续两次请求 Age 在增长,说明你确实命中了同一份缓存而不是每次都被重新生成。

一个真实的对比:治理前后的差距

我在主站上做过一次对比。治理前,CDN 统计的请求构成大致是:裸 URL 占 55%,带 utm_* 的占 25%,带 from= 的占 12%,其余为分页与杂项。命中率看着有 88%,但回源请求数是按照 100% 的原始请求量的 12% 算的,仍然可观。

排除追踪参数、归一化主机与协议、给分页做 301 规范化之后,缓存键的基数下降非常明显,同样的访问量下回源请求数下降了一半以上。这里我要强调:不要只看"命中率"这一个数字,它是个比值,可以被"回源的那部分请求本来就少"伪造出来。真正该盯的指标是回源请求数和回源带宽的绝对值。可以这样看源站侧的证据:

# 统计源站日志里来自 CDN 回源 IP 段的请求数
grep 'CDN-回源IP前缀' /var/log/nginx/access.log | wc -l

# 按小时看回源趋势(治理前后各取一小时对比)
awk '{print substr($4,2,14)}' /var/log/nginx/access.log | sort | uniq -c

治理清单:照着做就不会错

  1. 列出全站所有会用到的查询参数,逐个回答"它改变页面内容吗",分成"进缓存键"和"不进缓存键"两组。
  2. 追踪参数一律排除:utm_*、gclid、fbclid、yclid、msclkid、from、spm、share_token。
  3. 时间戳参数(_=)绝不进缓存键,并且不要在常规链接里使用破缓存参数。
  4. 协议与主机名用 301 收敛到唯一规范形式。
  5. 检查 Vary 头,去掉不必要的 Vary: Cookie、Vary: User-Agent。
  6. 检查 Cookie 策略,确保统计类 Cookie 不会导致全站回源。
  7. 参数排序归一化,避免顺序不同产生不同缓存键。
  8. 用 X-Cache/Age 头验证,并盯回源请求数绝对值,而不是命中率。
  9. 设置合理的 TTL:HTML 页面可以设短一点(如 5–30 分钟)配合主动刷新,静态资源设长一点(如 30 天)配合文件名 hash。
  10. 治理后做一次对比,用前后各一小时的回源日志数量证明效果。

写在最后

缓存键治理是那种"不做不报错、做了立刻省一半钱"的优化。它不需要你换 CDN,也不需要改架构,只需要把参数一条条理清楚。如果你现在的 CDN 缓存命中率很高但回源账单没降,别再盯着命中率了,去数一数你的源站日志里到底还有多少回源请求,然后回来对着这份清单逐条排查——通常第一、二条就能帮你砍掉一大截。

Last modification:October 16th, 2026 at 01:24 pm

Leave a Comment