约 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-age | 600 | 以为它能控制浏览器 |
| no-cache | 可以存,但每次使用前必须校验 | HTML 入口 | 当成完全不缓存 |
| no-store | 完全不写入缓存 | 带凭证的响应 | 拿来替代 no-cache,每次全量下载 |
| immutable | 有效期内连刷新都不校验 | 哈希静态资源 | 配在会变的 URL 上 |
| stale-while-revalidate | 过期后一段时间先给旧副本,同时后台更新 | 60 | 用在价格、库存接口 |
| must-revalidate | 过期后禁止使用陈旧副本 | 订单、余额 | 与 stale-while-revalidate 同时写 |
s-maxage 只约束 CDN 这类共享缓存,max-age 约束浏览器。只改 s-maxage,CDN 确实回源了,老用户手里那份仍然有效;只改 max-age,边缘节点还按旧策略吐旧文件。发布流程要固定成:先刷 CDN,再发布 HTML,最后才指望浏览器去取新的哈希资源。响应里的 Age 头能告诉你这份副本在边缘躺了多久,它是判断谁在缓存的最快证据。边缘节点之间彼此独立,同一个文件在十个节点上就是十份副本,刷掉一个节点不会让其余的失效。Vary 头决定边缘节点用什么维度切分缓存键,漏掉 Vary: Accept-Encoding,同一个节点就可能把压缩版发给不支持压缩的客户端。
no-cache 允许写入缓存,只要求每次复用前校验一次;命中 304 省掉的是响应体,不是那一趟往返。no-store 才是一个字节都不留,适合带用户凭证或一次性令牌的响应,用它替换 no-cache 只会让每次访问全量下载。stale-while-revalidate 处理过期瞬间的等待:在配置的窗口内先返回旧副本,同时后台发起校验,用户不会卡在空白页。代价是短暂的不一致,价格和库存接口不要用。Safari 至今忽略这个指令,会退回同步校验。还有一个容易漏掉的点:带 Set-Cookie 的响应如果没有标 private,共享缓存可能把它存下来再发给别人,这类事故比缓存不命中严重得多。ETag 还分强弱,带 W/ 前缀的是弱校验,只保证语义等价,字节可以不同,反向代理重写内容时常用它。
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 绑定的资源。构建产物写进文件名(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 一刀切。
排查时很多人勾上 Network 面板的 Disable cache,然后发现每个请求都是 200,于是断定服务端没配协商缓存。这个开关会让浏览器跳过强缓存,并给请求加上 Cache-Control: no-cache,你看到的是被强制校验之后的结果,不代表真实用户的路径。正确做法是保持缓存开启,先硬刷新一次,再普通刷新一次,对比两次的状态码和 Size 列:硬刷新看到 200 并重建缓存,普通刷新看到 304 才说明协商缓存真的在工作。要验证强缓存,就在地址栏回车而不是按 F5。
304 表示内容没变,只有响应头没有响应体,省的是带宽;强缓存命中时连请求都不发,状态码仍是 200。
URL 没变,旧的强缓存还在有效期内。改内容必须同时改文件名或加内容哈希。
不影响抓取,但搜索引擎每次都要重新下载,HTML 用 no-cache 更合适。
两层独立判定。CDN 命中时返回自己手里的副本,浏览器是否发请求只由本地 max-age 决定。