Cache-Control 缓存头怎么配:按资源类型给出浏览器与 CDN 配置表
带 hash 的静态文件用 Cache-Control: public, max-age=31536000, immutable;HTML 用 no-cache,让浏览器每次回源确认;用户私有数据用 private 或 no-store;想让 CDN 缓存而浏览器不缓存,用 s-maxage 把两层分开。cache-control 难配,难在一个响应头要同时管浏览器和 CDN 两层缓存。下面先把指令讲清,再按资源类型给出配置表,最后用 curl -I 验证配置有没有生效。
Cache-Control 管的是哪两层缓存?
一个请求从浏览器出发,可能经过两层缓存:
- 私有缓存:浏览器自己的磁盘和内存缓存,只服务当前用户。
- 共享缓存:CDN 边缘节点、反向代理(Nginx
proxy_cache、Varnish 等),同一份副本会发给许多用户。
大多数配置错误出在把两层混为一谈。例如给登录后的页面加了 public, max-age=600,浏览器缓存没问题,CDN 却可能把 A 用户的页面发给 B 用户。反过来,给 HTML 加了 no-store,CDN 一次都不能缓存,所有流量都打回源站。
所以配缓存头之前,先回答两个问题:这份响应能不能给别人看?内容变了以后,最多能接受多久的旧数据?
no-cache、no-store、max-age 有什么区别?
这几个指令最常被混用,含义如下:
| 指令 | 含义 | 常见误解 |
|---|---|---|
max-age=N | 响应在 N 秒内是新鲜的,可以不问源站直接用 | 以为改了文件用户就能马上看到 |
no-cache | 可以存,但每次使用前必须回源确认(协商缓存) | 以为是“不缓存” |
no-store | 任何缓存都不许存 | 以为和 no-cache 一样 |
private | 只允许浏览器缓存,共享缓存不能存 | 以为能保护敏感数据不落盘(不能,浏览器仍会存) |
public | 明确允许共享缓存存,包括带 Authorization 的请求 | 以为不写就不能被 CDN 缓存 |
s-maxage=N | 只对共享缓存生效,覆盖 max-age | 以为浏览器也会读 |
must-revalidate | 过期后必须回源确认,不能直接用旧副本 | 以为过期前也要确认 |
immutable | 新鲜期内内容不会变,刷新页面也不必回源确认 | 以为可以单独使用 |
stale-while-revalidate=N | 过期后 N 秒内可以先返回旧副本,同时在后台更新 | 以为所有浏览器和 CDN 都支持 |
stale-if-error=N | 源站出错时,过期 N 秒内的旧副本仍可返回 | 同上,支持情况要查具体 CDN 文档 |
简单记:no-cache 是“用之前先问”,no-store 才是“不许存”。对 HTML 用 no-cache 配合 ETag,大多数请求只回一个 304,几乎不耗带宽,同时保证用户拿到最新入口。
ETag 和 Last-Modified 在什么时候起作用?
no-cache 或 max-age 到期后,缓存会带上条件请求回源:
- 上次响应有
ETag: "abc123",就发If-None-Match: "abc123"; - 上次响应有
Last-Modified,就发If-Modified-Since。
源站判断内容没变,返回 304 Not Modified,不带响应体。ETag 比 Last-Modified 精确,后者只到秒级,而且多台源站文件时间不一致时会误判。负载均衡后面有多台机器时,要确认它们生成的 ETag 一致(Nginx 默认按修改时间和文件大小生成,部署时保留文件时间即可),否则协商缓存会一直失败。
可以这样手动验证:
# 第一次取 ETag
curl -sI https://example.com/ | grep -i etag
# 带上 ETag 再请求,期望返回 304
curl -sI -H 'If-None-Match: "上一步拿到的值"' https://example.com/ | head -1
不同资源该怎么配?一张表直接照抄
下面是常见的起点配置,按自己的发布频率调整数字:
| 资源类型 | 推荐 Cache-Control | 说明 |
|---|---|---|
带 hash 的 JS/CSS/字体(app.3f9a1c.js) | public, max-age=31536000, immutable | 文件名随内容变化,旧文件永远不会被改写 |
| HTML 入口页 | no-cache | 配合 ETag,每次确认;发布后用户马上拿到新的 hash 引用 |
| HTML,想让 CDN 也扛流量 | public, max-age=0, s-maxage=60, stale-while-revalidate=30 | 浏览器每次回源,CDN 缓存 60 秒;发布时要清 CDN 缓存 |
不带 hash 的图片(/logo.png) | public, max-age=86400 | 改图时换文件名比等过期可靠 |
| 用户上传的图片(URL 唯一) | public, max-age=2592000 | URL 不复用就可以放长 |
| 公共 API(排行榜、状态数据) | public, max-age=0, s-maxage=30, stale-while-revalidate=60 | CDN 吸收突发流量,浏览器总拿较新数据 |
| 登录后的 API、个人页面 | private, no-cache | 浏览器可缓存但要确认,CDN 不许存 |
| 支付、令牌、敏感个人信息 | no-store | 哪一层都不留副本 |
robots.txt、sitemap.xml | public, max-age=3600 | 改动不频繁,一小时足够 |
有两条容易漏:
- 不带 hash 的文件不要用
immutable。浏览器会在一年内都不再确认,改了文件也看不到。 - 响应里有
Set-Cookie时要小心。很多 CDN 默认不缓存带Set-Cookie的响应,但不是全部;如果给这类响应配了public, s-maxage,可能把会话 Cookie 缓存下来发给其他人。公共页面应在源站去掉不必要的Set-Cookie。
s-maxage 和 CDN-Cache-Control 怎么分开管两层?
只用一个 Cache-Control 时,靠 s-maxage 区分:浏览器读 max-age,共享缓存优先读 s-maxage。
Cache-Control: public, max-age=60, s-maxage=3600
意思是浏览器缓存 1 分钟,CDN 缓存 1 小时。这样发布新版本后,清一次 CDN 缓存,用户最多等 1 分钟就能看到。
问题是 s-maxage 对源站和 CDN 之间所有共享缓存都生效,包括你没法控制的中间代理。RFC 9213 定义了 CDN-Cache-Control 头,只给 CDN 读,浏览器会忽略它:
Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=3600
Cloudflare 还支持只给自己读、不会转发给下游 CDN 的 Cloudflare-CDN-Cache-Control。不同 CDN 对这些头的支持程度不一样,上线前查一下自己所用 CDN 的官方文档,再用下面的方法实测。
怎么用 curl 判断 CDN 有没有命中?
最直接的方法是连续请求两次,看响应头:
curl -sI https://example.com/static/app.3f9a1c.js | grep -iE 'cache-control|age|cf-cache-status|x-cache|etag|last-modified|vary'
关注这几个头:
| 响应头 | 怎么看 |
|---|---|
Age | 副本在共享缓存里已经存了多少秒。第二次请求时出现且数值在增长,说明命中了缓存 |
cf-cache-status(Cloudflare) | HIT 命中;MISS 未命中已回源;EXPIRED 过期后回源;REVALIDATED 协商后复用;UPDATING/STALE 返回了旧副本;BYPASS 被规则或头部绕过;DYNAMIC 该资源不在缓存范围内 |
X-Cache 或类似头 | 其他 CDN 常用的命中标记,具体取值看该 CDN 文档 |
Vary | 缓存键会包含这些请求头。Vary: * 或 Vary: Cookie 会让命中率大幅下降 |
一个排查顺序:
- 先看源站本身返回的头,绕过 CDN 直接请求源站(
curl -sI --resolve example.com:443:源站IP https://example.com/路径),确认Cache-Control写对了。 - 再经 CDN 请求两到三次,看
cf-cache-status是否从MISS变成HIT,Age是否出现。 - 一直是
MISS:检查是否有Set-Cookie、private、no-store,或者查询参数每次都不一样(比如带时间戳)。 - 一直是
DYNAMIC:Cloudflare 默认只缓存按扩展名识别的静态文件,HTML 和 API 需要单独写缓存规则。 - 用户反馈看不到更新:看
Age和max-age,判断是浏览器缓存还是 CDN 缓存没过期。
注意,同一个 URL 在不同地区由不同边缘节点服务,各自有独立的缓存。你在一个地方测到 HIT,另一个地区的用户第一次访问仍然可能是 MISS。测量 CDN 速度时也要考虑这一点:cdnping 的探测怎样避开缓存对结果的影响,写在数据方法页面;公共前端 CDN 的速度对比可以看 CDN 速度榜。
Nginx 怎么配?
一个按资源类型分开的示例:
# 带 hash 的静态资源
location ~* "\.[0-9a-f]{8,}\.(js|css|woff2)$" {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 普通图片
location ~* \.(png|jpg|jpeg|gif|webp|avif|svg)$ {
add_header Cache-Control "public, max-age=86400" always;
}
# HTML 入口
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache" always;
}
# 登录后的 API
location /api/me {
proxy_pass http://backend;
proxy_hide_header Cache-Control;
add_header Cache-Control "private, no-cache" always;
}
几个 Nginx 的坑:
- 正则里含
{}时要用引号把整个正则括起来,否则 Nginx 会把花括号当成块的开始,nginx -t直接报错。 add_header默认只对 200、204、206、301、302、304 等状态码生效,加always才会对 4xx/5xx 也生效。错误页一旦被 CDN 按长缓存存下,修好源站后用户仍会看到错误。- 子
location里只要写了一条add_header,就不会继承上级的任何add_header。安全头写在server块里时,经常因此在某个location里丢失。 expires 30d;会同时生成Expires和Cache-Control: max-age,和手写的add_header Cache-Control叠加时可能出现两条头,二选一即可。- 后端已经返回
Cache-Control时,再add_header会出现两条,用proxy_hide_header先去掉后端的那条。
Cloudflare 上要注意什么?
Cloudflare 默认遵守源站的 Cache-Control(包括 s-maxage、private、no-store),但默认只缓存按扩展名识别的静态资源。要缓存 HTML 或 API,需要在 Cache Rules 里给匹配的路径设置“符合缓存条件”,然后在规则里选择:
- 边缘 TTL:“使用源站缓存控制头”时按你的
s-maxage/max-age走;也可以在规则里覆盖。 - 浏览器 TTL:可以单独覆盖浏览器端的
max-age,但不要把它设得比源站更长,否则发布后用户会卡在旧版本。 - 缓存键:去掉无意义的查询参数(如
utm_*),可以明显提高命中率。
带 Authorization 头或会话 Cookie 的路径,不要写进“符合缓存条件”的规则里,除非你确定响应对所有人相同。AI 应用的场景(流式响应、API 限速、回源保护)在用 Cloudflare 加速 AI 应用里有更细的配置说明。
上线前的检查表
- 所有构建产物都带内容 hash,且只有它们用了
immutable - HTML 入口是
no-cache,或者 CDN 层缓存时间很短且发布流程里有清缓存这一步 - 登录后接口全部是
private或no-store,用未登录的 curl 请求一次,确认拿不到别人的数据 - 公共页面不返回
Set-Cookie - 错误响应(404、500)的缓存时间有明确设置,没有被缓存成长期
-
Vary只包含必要的头,通常是Accept-Encoding - 用
curl -I连续请求两次,看到Age或HIT - 绕过 CDN 直连源站,响应头和预期一致
常见问题
改了 CSS,用户还是看到旧样式?
多半是文件名没带 hash,又配了较长的 max-age。根治办法是让构建工具输出带 hash 的文件名,HTML 改为 no-cache。临时办法是清 CDN 缓存,但浏览器里已有的副本只能等过期。
Cache-Control: no-cache 会不会让网站变慢?
对 HTML 来说,多出来的是一次条件请求,内容没变时返回 304,只有响应头没有响应体。真正影响速度的通常是连接建立本身,DNS、TCP、TLS 各占多少可以参考AI API 延迟拆解里的拆法,同样适用于网页请求。
npm 镜像、软件包下载也受这些头影响吗?
受影响。镜像站和 registry 对包元数据和 tarball 一般采用不同的缓存策略:元数据会变,缓存时间短;tarball 版本固定,可以长期缓存。装包慢时可以对照npm 镜像源怎么设置里的排查思路。
这里说的缓存和 Claude 的提示缓存是一回事吗?
不是。HTTP 缓存存的是完整响应,由响应头控制;Claude API 的提示缓存(prompt caching)是服务端复用提示前缀的计算结果,用请求参数控制,细节见Claude API 提示缓存怎么用。
相关阅读
如果网站面向中国大陆用户,CDN 选型和节点覆盖同样影响体验,可以先读国内怎么选公共 CDN;整体网络排查顺序见开发者网络自检清单。需要基于 Cloudflare 全球网络做边缘加速时,可以看 cdnping Edge 的说明。