CDN 缓存命中率上不去:缓存键、s-maxage 与刷新语义的五层判断

这是我最常被问到的一类运维问题:「我改了页面,CDN 上还是旧的」或者「明明配置了缓存规则,为什么命中率只有百分之十几」。这两个问题看起来相反,根子却是同一个:大部分人从没把 CDN 的缓存决策链完整地想过一遍。

CDN 的缓存不是「开或关」一个开关,而是五层判断依次生效:是否有缓存指令、是否有 Vary、缓存键包含什么、TTL 取哪个来源、以及刷新时到底刷了什么。任何一层理解错了,表现都是「看起来在缓存,实际没省事」。这篇文章按这五层拆开讲,每层给可验证的检查方法。

一、第一层:先分清哪些该缓存,哪些一缓存就出事

静态资源和 HTML 的待遇必须完全不同。图片、CSS、JS、字体这些带内容指纹的文件可以缓存一年,因为它们改名换内容;HTML 是站点的控制面,它决定了某个用户看到什么,缓存时间稍长就会出事故。

我的默认分工是这样:

location ~* \.(?:css|js|jpg|jpeg|png|gif|webp|avif|svg|woff2?|ttf|ico)$ {
    expires 365d;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

location ~* \.(?:html?)$ {
    expires 0;
    add_header Cache-Control "public, max-age=0, must-revalidate" always;
}

immutable 这个指令值得单独说:它告诉浏览器「这个 URL 的内容永远不会变,别在刷新时回来验证」。没有它,用户按 F5 时浏览器仍会发条件请求,几毫秒的延迟白白浪费。有了它,前提就必须是所有静态资源的文件名都带内容哈希,否则改图不生效,只能靠刷缓存。

HTML 用 max-age=0, must-revalidate 是标准做法,但要注意它只影响浏览器,CDN 边缘节点的行为由平台自己的规则决定。很多 CDN 默认对 HTML 不缓存,也有默认缓存 30 分钟到 2 小时的。这一项必须先查清楚,否则你会在「源站改了、边缘还是旧的」里反复卡住。

二、第二层:缓存键决定命中率

缓存键就是「拿什么去查缓存」。默认通常是 host + path + query string。这三个要素里任何一个含高基数的变量,命中率就会崩。

最常见的杀手是统计参数。用户从公众号点过来、从搜索引擎点过来、从广告点过来,URL 后面带 ?utm_source=x&utm_campaign=y,每个组合都是一个新的缓存键,于是同一份页面在边缘存了几十份,每一份都只被访问一次。命中率看起来低得离谱,其实是缓存被自己灌满了。

解法是在 CDN 层配置「忽略指定查询参数」,把 utm_*、gclid、fbclid、spm 这些营销参数从缓存键里剔掉。如果在源站做,可以在 Nginx 里重写:

set $cached_uri $uri;
if ($args ~ ^(.*)(^|&)(utm_[^&]*|gclid|fbclid|spm)(=.*)?$) {
    set $cached_uri $uri;
}

不过说实话,Nginx 侧的写法很别扭,而且改不动上游 CDN 的键。正确的位置是 CDN 控制台的「缓存键 / 查询字符串处理」配置项,几乎每家都有,选项通常是「保留全部 / 忽略全部 / 忽略指定 / 白名单」。选「忽略指定」最稳。

第二个杀手是 Cookie。Vary: Cookie 会让缓存键包含整个 Cookie 串,等于缓存全废。检查方式是看响应里有没有这个头,以及 CDN 是否配置了「按 Cookie 区分缓存」。如果站点需要按登录态输出不同内容,正确做法是登录用户直接绕过缓存(源站返回 Cache-Control: private),而不是把 Cookie 塞进缓存键。

三、第三层:TTL 取哪个来源,优先级必须清楚

一个响应能带三四个 TTL 信号:Cache-Control: max-age、Expires、CDN 控制台里配的「缓存过期时间」、以及平台默认值。它们冲突时谁赢?答案是「看平台」,而这是最容易出错的地方。

多数 CDN 的规则是:配置了「忽略源站缓存头」时以控制台为准,没配置时以 Cache-Control 为准,没有 Cache-Control 时才回落到 Expires 或默认值。所以如果你在 CDN 控制台里给 HTML 设了 2 小时,但源站返回 max-age=0,实际生效的通常是 2 小时——源站那句话被忽略了。

验证方法很简单,看响应头里的年龄字段:

curl -sI "https://www.example.com/article.html?_=$(date +%s)" | grep -iE 'age|x-cache|cache-control|expires'

连续请求两次,看 Age 是否增长、X-Cache 是否从 MISS 变 HIT。如果两次都是 MISS 且 Age 为 0,说明根本没缓存;如果 Age 每次都是一个大数字且不增长,说明命中了但从没回源,TTL 可能长得离谱。

s-maxage 是专门给共享缓存(CDN)用的,优先级高于 max-age。这是 HTTP 规范里最实用的一个指令之一,却用得很少。想要「浏览器不缓存、CDN 缓存一小时」,正确写法是:

Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600

浏览器每次都会回源验证,CDN 则一小时才回源一次。这一行配置的收益比大多数优化手段都直接。

四、第四层:刷新到底刷了什么

这一层是事故高发区。「刷新缓存」在大多数 CDN 上有两个选项:刷新 URL 和刷新目录。刷新 URL 是精确删除某个键,刷新目录是批量删除前缀。前者精确但慢,后者快但可能一刷就是几千个键,触发平台的配额限制。

几个实践原则。刷新时一定要带上协议和主机名,只写路径通常不生效;带查询参数的 URL 和不带的 URL 是两个键,只刷其中一个另一个还是旧的;如果配置过「忽略查询参数」,那刷新时也应该只刷不带参数的形态。

更重要的是:刷新是应急手段,不是发布流程的一部分。如果你的发布流程里每天都要刷几十次缓存,说明缓存策略设计错了——要么 HTML 的 TTL 太长,要么静态资源没做指纹化。正确的发布流程是:改了内容的资源换文件名,没换名的资源不改内容。这样永远不需要刷新。

顺带说一个常见误区:刷新 CDN 不等于让用户看到新内容。CDN 刷掉之后,用户浏览器里那份还是旧的,直到它的本地缓存过期。所以「改了页面但老用户还是看到旧版」的真正解法是 HTML 用短 TTL 或者用版本号做入口跳转,而不是刷 CDN。

五、第五层:回源时的那些细节

命中率再高也有回源的时候,而回源这一跳经常被忽略。三个点值得检查。

回源 Host 与 SNI。很多站点在 CDN 后面的源站用 IP 直连,Host 头必须显式设置成域名,否则 Nginx 会落到默认 server 块,返回一个完全不相干的站点或者 404。启用 HTTPS 回源时还要保证 SNI 正确,curl --resolve 是排查这个的最快工具。

回源的连接复用。看源站 access log 里来自 CDN 节点的连接数,如果每秒几百个新连接、每个只服务一次,说明 CDN 没复用连接。表现是源站的 ss -s 里 TIME_WAIT 飙高,CPU 大量耗在建连上。多数 CDN 有「长连接回源」开关,打开后源站压力能降一个量级。

回源的压缩。如果是 CDN 拉取时请求 gzip,而源站只对特定 UA 压缩,就会出现「边缘拿到的是未压缩的大文件」。检查源站日志里 CDN 节点 UA 对应的响应大小,和浏览器 UA 对应的比一比,差异明显就说明压缩没生效。这一条我踩过一次,一张页面回源 480KB 对 76KB,六倍的带宽。

六、命中率的正常区间与诊断次序

命中率多少算正常,取决于站点结构。纯静态站做到 95% 以上不稀奇;带登录态的站做到 70% 就已经不错;电商类站点 40% 到 60% 属于常态。没有绝对值,只看趋势。

当命中率下降时,按这个次序查:先看有没有新上的高基数查询参数;再看缓存键里是否混进了 Cookie 或 UA;然后看 TTL 是否被误改短;最后看是否有大面积的目录刷新。四步基本能定位,不需要登机器。

另外注意「字节命中率」和「请求命中率」是两个指标。图片和视频撑起来的字节命中率可能很高,但请求命中率很低,因为 HTML 这类小文件请求多、缓存短。看哪个指标取决于你在优化带宽还是优化源站负载——前者看字节,后者看请求。

七、一份可以照抄的检查清单

最后给一份清单,新站接 CDN 或者接手一个老站时逐条过一遍:静态资源文件名是否带内容哈希;HTML 的 Cache-Control 是否明确写了;CDN 控制台有没有「忽略源站缓存头」;营销参数是否已从缓存键剔除;Vary 头是否只包含 Accept-Encoding;回源 Host 与 SNI 是否正确;长连接回源是否开启;刷新操作的配额还剩多少。

八条里通常有三四条是错的,这就是为什么很多站「上了 CDN 还是慢」。CDN 只是把距离缩短了,真正决定效果的还是这几行缓存头。

Last modification:November 13th, 2026 at 12:34 pm

Leave a Comment