---
title: "Cache-Control 缓存头怎么配：按资源类型给出浏览器与 CDN 配置表"
description: "讲清 cache-control 中 no-cache、no-store、max-age、s-maxage、immutable 的区别，按资源类型给出浏览器与 CDN 配置表，并用 curl 验证缓存是否命中。"
url: https://cdnping.org/blog/cache-control-headers-guide-cdn
language: zh-CN
published: 2026-10-21
category: "边缘与 CDN"
tags: ["Cache-Control","HTTP 缓存","CDN","Nginx","Cloudflare"]
source: cdnping.org
license: Free to quote and summarize with a link to the url above.
---
# 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 默认按修改时间和文件大小生成，部署时保留文件时间即可），否则协商缓存会一直失败。

可以这样手动验证：

```bash
# 第一次取 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` | 改动不频繁，一小时足够 |

有两条容易漏：

1. 不带 hash 的文件不要用 `immutable`。浏览器会在一年内都不再确认，改了文件也看不到。
2. 响应里有 `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 有没有命中？

最直接的方法是连续请求两次，看响应头：

```bash
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` 会让命中率大幅下降 |

一个排查顺序：

1. 先看源站本身返回的头，绕过 CDN 直接请求源站（`curl -sI --resolve example.com:443:源站IP https://example.com/路径`），确认 `Cache-Control` 写对了。
2. 再经 CDN 请求两到三次，看 `cf-cache-status` 是否从 `MISS` 变成 `HIT`，`Age` 是否出现。
3. 一直是 `MISS`：检查是否有 `Set-Cookie`、`private`、`no-store`，或者查询参数每次都不一样（比如带时间戳）。
4. 一直是 `DYNAMIC`：Cloudflare 默认只缓存按扩展名识别的静态文件，HTML 和 API 需要单独写缓存规则。
5. 用户反馈看不到更新：看 `Age` 和 `max-age`，判断是浏览器缓存还是 CDN 缓存没过期。

注意，同一个 URL 在不同地区由不同边缘节点服务，各自有独立的缓存。你在一个地方测到 `HIT`，另一个地区的用户第一次访问仍然可能是 `MISS`。测量 CDN 速度时也要考虑这一点：cdnping 的探测怎样避开缓存对结果的影响，写在[数据方法](/methodology)页面；公共前端 CDN 的速度对比可以看 [CDN 速度榜](/benchmark/cdn)。

## Nginx 怎么配？

一个按资源类型分开的示例：

```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 应用](/blog/cloudflare-edge-for-ai-apps)里有更细的配置说明。

## 上线前的检查表

- [ ] 所有构建产物都带内容 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 延迟拆解](/blog/api-latency-connect-rtt-ttft)里的拆法，同样适用于网页请求。

### npm 镜像、软件包下载也受这些头影响吗？

受影响。镜像站和 registry 对包元数据和 tarball 一般采用不同的缓存策略：元数据会变，缓存时间短；tarball 版本固定，可以长期缓存。装包慢时可以对照[npm 镜像源怎么设置](/blog/npm-registry-mirror-china-setup)里的排查思路。

### 这里说的缓存和 Claude 的提示缓存是一回事吗？

不是。HTTP 缓存存的是完整响应，由响应头控制；Claude API 的提示缓存（prompt caching）是服务端复用提示前缀的计算结果，用请求参数控制，细节见[Claude API 提示缓存怎么用](/blog/claude-prompt-caching-save-cost)。

## 相关阅读

如果网站面向中国大陆用户，CDN 选型和节点覆盖同样影响体验，可以先读[国内怎么选公共 CDN](/learn/choose-public-cdn-china)；整体网络排查顺序见[开发者网络自检清单](/blog/developer-network-self-check-2026)。需要基于 Cloudflare 全球网络做边缘加速时，可以看 [cdnping Edge](/edge) 的说明。
