强缓存和协商缓存怎么配:Cache-Control、ETag 与 304 不生效的排查顺序

强缓存命中不发请求,协商缓存命中返回 304。拆开每个 Cache-Control 指令的真实语义,给出 304 不生效时的排查顺序与四个高频误判。

约 8 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

先分清两层,再动配置

浏览器拿到响应后先判断一件事:这份内容能不能不发请求直接用。能,就是强缓存,由 Cache-Control 的 max-age 与 s-maxage 控制,命中时 DevTools 的 Size 列显示 disk cache,状态码仍然是 200,瀑布图里没有新请求。不能,浏览器才带上 If-None-Match 或 If-Modified-Since 去问服务器,服务器回 304 表示本地副本还能用,这一段叫协商缓存。两者是先后顺序而不是二选一:强缓存没过期,协商缓存根本没有机会执行。后面所有排查都是在这条时间线上找断点。还有一条优先级:Cache-Control 与 Expires 同时存在时以 Cache-Control 为准,Expires 直接被忽略,所以只配 Expires 的老系统在新浏览器里行为并不可控。还有第三层:Service Worker 的 fetch 事件在 HTTP 缓存之前拦截,Cache API 里存的那份优先级更高,排查时别忘了看 Application 面板。

七个指令的语义与误用

指令语义典型值常见误用
max-age浏览器可直接复用的秒数31536000(一年)配在 index.html 上
s-maxage只作用于 CDN 等共享缓存,覆盖 max-age600以为它能控制浏览器
no-cache可以存,但每次使用前必须校验HTML 入口当成完全不缓存
no-store完全不写入缓存带凭证的响应拿来替代 no-cache,每次全量下载
immutable有效期内连刷新都不校验哈希静态资源配在会变的 URL 上
stale-while-revalidate过期后一段时间先给旧副本,同时后台更新60用在价格、库存接口
must-revalidate过期后禁止使用陈旧副本订单、余额与 stale-while-revalidate 同时写

CDN 与浏览器各算各的

s-maxage 只约束 CDN 这类共享缓存,max-age 约束浏览器。只改 s-maxage,CDN 确实回源了,老用户手里那份仍然有效;只改 max-age,边缘节点还按旧策略吐旧文件。发布流程要固定成:先刷 CDN,再发布 HTML,最后才指望浏览器去取新的哈希资源。响应里的 Age 头能告诉你这份副本在边缘躺了多久,它是判断谁在缓存的最快证据。边缘节点之间彼此独立,同一个文件在十个节点上就是十份副本,刷掉一个节点不会让其余的失效。Vary 头决定边缘节点用什么维度切分缓存键,漏掉 Vary: Accept-Encoding,同一个节点就可能把压缩版发给不支持压缩的客户端。

no-cache 不是不缓存

no-cache 允许写入缓存,只要求每次复用前校验一次;命中 304 省掉的是响应体,不是那一趟往返。no-store 才是一个字节都不留,适合带用户凭证或一次性令牌的响应,用它替换 no-cache 只会让每次访问全量下载。stale-while-revalidate 处理过期瞬间的等待:在配置的窗口内先返回旧副本,同时后台发起校验,用户不会卡在空白页。代价是短暂的不一致,价格和库存接口不要用。Safari 至今忽略这个指令,会退回同步校验。还有一个容易漏掉的点:带 Set-Cookie 的响应如果没有标 private,共享缓存可能把它存下来再发给别人,这类事故比缓存不命中严重得多。ETag 还分强弱,带 W/ 前缀的是弱校验,只保证语义等价,字节可以不同,反向代理重写内容时常用它。

304 不再出现时的排查顺序

  1. 先看请求头有没有 If-None-Match 或 If-Modified-Since。没有条件头,服务器只会回 200。
  2. 再看响应里有没有 ETag 或 Last-Modified。两者都没有,就没有协商的凭据;有些框架默认只发 Date 和 Content-Length,需要显式开启。
  3. 用 curl -I 直接打源站,确认源站自己返回 304。源站不给 304,CDN 也变不出来。
  4. 对比两次请求的 ETag 是否一致。同一文件 ETag 会变,说明它由 inode、mtime 或压缩状态参与生成。
  5. 最后看 CDN:有 Age 或 X-Cache: HIT 说明请求没到源站,此时生效的是边缘节点的缓存策略。

五个高频误判

  • 改了文件但 URL 没变:命中的是旧的强缓存,浏览器根本不会去问服务器。改内容必须同时改文件名或加哈希。
  • ETag 在 gzip 与非 gzip 下不一致:Apache 的 mod_deflate 默认给压缩响应追加 -gzip 后缀(DeflateAlterETag AddSuffix),同一 URL 两种编码就有两个 ETag,客户端交替请求永远 200。Apache 2.2 的默认 FileETag 还包含 INode,多机部署时同一文件 ETag 各不相同。
  • 缺少 Vary: Accept-Encoding:缓存可能把 gzip 版本发给不支持压缩的客户端,或者反过来。
  • 304 少了 Cache-Control:按 RFC 9111,304 里出现的头部会更新已存条目,缺失的沿用旧值。CDN 改写后头部对不上时,DevTools 的 Headers 面板会显示成缓存策略没生效,排查时以实际返回的头部为准,别只看状态码。
  • 只给 HTML 配了 no-cache,忘了接口:没有显式新鲜度的响应会走启发式缓存,浏览器把 Last-Modified 之后经过时间的 10% 当作 max-age,行为完全不可预测。

用两个命令确认谁在缓存

curl 检查边缘缓存与条件请求
curl -sI https://cdn.example.com/static/app.3f9c1a.js | grep -iE 'cache-control|etag|age|x-cache'

curl -sI -H 'If-None-Match: "3f9c1a"' https://cdn.example.com/static/app.3f9c1a.js | head -n 1
# 期望输出: HTTP/2 304

把长缓存留给带哈希的 URL

能长期缓存的只有内容与 URL 绑定的资源。构建产物写进文件名(app.3f9c1a.js),配 public, max-age=31536000, immutable,一年内连强制刷新都不回源;HTML 用 no-cache,保证发布后立刻拿到新引用;接口按业务定,多数场景 no-cache 或几十秒的 max-age 就够。这套分层有个前提:HTML 绝不能被长缓存。HTML 一旦被缓存一年,新的哈希文件名根本没有机会出现在页面上,用户会被永久钉在旧版本。发布时顺手换掉 HTML 的 ETag,能避免部分 CDN 把新旧 HTML 混着发。构建工具通常已经按这套规则生成文件名,手写 Nginx 配置时最容易退回 max-age=0 一刀切。

DevTools 里的 Disable cache 会骗你

排查时很多人勾上 Network 面板的 Disable cache,然后发现每个请求都是 200,于是断定服务端没配协商缓存。这个开关会让浏览器跳过强缓存,并给请求加上 Cache-Control: no-cache,你看到的是被强制校验之后的结果,不代表真实用户的路径。正确做法是保持缓存开启,先硬刷新一次,再普通刷新一次,对比两次的状态码和 Size 列:硬刷新看到 200 并重建缓存,普通刷新看到 304 才说明协商缓存真的在工作。要验证强缓存,就在地址栏回车而不是按 F5。

常见问题

304 和 200 的区别是什么

304 表示内容没变,只有响应头没有响应体,省的是带宽;强缓存命中时连请求都不发,状态码仍是 200。

为什么改了文件用户还看到旧版本

URL 没变,旧的强缓存还在有效期内。改内容必须同时改文件名或加内容哈希。

Cache-Control 设 no-store 会影响 SEO 吗

不影响抓取,但搜索引擎每次都要重新下载,HTML 用 no-cache 更合适。

CDN 缓存和浏览器缓存哪个优先

两层独立判定。CDN 命中时返回自己手里的副本,浏览器是否发请求只由本地 max-age 决定。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件