HTTP 缓存解决的问题很具体:把响应留在离用户更近的位置,下一次直接复用,省掉一次回源。它由响应头控制,浏览器缓存和 CDN 共享缓存各看一部分规则。配得粗糙时,常见的结果是改版后用户还看到旧页面,或者页面每次都回源。下面按概念、原理、步骤、误区四段讲清。
HTTP 缓存的两个状态:新鲜与过期
按 RFC 9111 的定义,缓存分私有缓存和共享缓存两类。私有缓存跟着单个客户端,典型是浏览器缓存;共享缓存位于客户端与源站之间,包括代理缓存、CDN,以及 Service Worker 配合 Cache API 的托管缓存。共享缓存要服务多个用户,个性化内容不能随便放进去。
存下来的响应只有两种状态:新鲜(fresh)和过期(stale)。判定依据是 age,也就是响应从源站生成之后经过的时间,而不是本地收到的时间。新鲜期内可以直接复用,过期后要么向源站验证,要么重新下载。
共享缓存转发时会给响应补上 Age 头。例如源站返回 Cache-Control: max-age=604800(一周),共享缓存存了一天再转发,会带上 Age: 86400,浏览器据此算出的剩余新鲜期是 518400 秒。
强缓存与 max-age、s-maxage 的分工
强缓存指在新鲜期内不问源站,直接用本地副本。控制它的主要是 Cache-Control 里的数字型指令,几条指令的适用范围差别很大。
| 指令 | 生效范围 | 作用 |
|---|---|---|
| max-age=N | 私有与共享缓存 | 响应生成后 N 秒内保持新鲜 |
| s-maxage=N | 只有共享缓存 | 覆盖 max-age 与 Expires,浏览器忽略它 |
| Expires | 私有与共享缓存 | HTTP/1.0 的写法,给一个具体时刻;与 max-age 同时出现时以 max-age 为准 |
| immutable | 私有与共享缓存 | 新鲜期内连刷新都不再验证,多见于带 hash 的静态资源 |
一年(31536000 秒)这个取值不是硬性规定。HTTP/3 的头部压缩标准 RFC 9204 里有几条预置的 Cache-Control 值,其中就包括 max-age=31536000,使用时只占 1 个字节。缓存本身会按容量淘汰旧条目,所以设一年和设一个月,实际留存的概率差别没有想象中大。
stale-while-revalidate 和 stale-if-error 属于扩展指令,定义在 RFC 5861 中,2010 年发布。前者允许过期后再给一段时间旧副本,同时在后台做验证;后者在上游返回 500、502、503、504,或出现网络与 DNS 故障时用旧副本兜住。
RFC 5861 自带的例子可以直接对照:max-age=600, stale-if-error=1200 表示新鲜 600 秒,过期后 1200 秒内遇到错误仍可返回旧响应;当 Age 超过 1800 秒,错误就必须原样透传。
协商缓存与 304 的往返流程

没有强缓存信息,或者副本已经过期时,走协商缓存。做法是把本地副本的标识发回源站,让源站判断能不能继续用。
- 源站响应里带 ETag(服务端生成的不透明标识,可以是内容哈希或版本号)或 Last-Modified(最后修改时间)。
- 缓存把标识放进请求头:ETag 对应 If-None-Match,Last-Modified 对应 If-Modified-Since。
- 内容没变时源站返回 304 Not Modified,没有响应体,传输量极小;缓存把旧副本重新算作新鲜,继续复用。
- 内容变了则返回 200 和完整内容,客户端替换本地副本。
304 响应里通常还会带一条 Cache-Control,客户端收到后会用新的新鲜期重置本地副本,所以验证一次既确认了内容,也顺带续了期限。
RFC 9110 建议 200 响应同时给出 ETag 与 Last-Modified;两者都出现在请求里时,If-None-Match 优先级更高。Last-Modified 只有秒级精度,同一秒内的多次修改可能看不出来,用内容哈希做 ETag 更可靠。
浏览器的两个刷新动作对应两条不同请求。普通重新加载会带 Cache-Control: max-age=0 加条件头,命中时仍是 304;强制刷新带 no-cache 与 Pragma: no-cache,通常直接拿回 200。
三步落地:分类、写头、验证
不用把所有指令都记住。把站点资源分成三类,基本能覆盖大多数场景。
- 带 hash 或版本号的静态子资源:文件名随内容变,URL 一变就是新资源。可以给 public, max-age=31536000, immutable,缓存一年;更新时改文件名而不是改内容。
- URL 固定的入口资源:HTML、manifest.json、.well-known 下的文件。用 no-cache 配 ETag,让浏览器每次都验证;页面若按用户渲染,再补 private。
- 登录后的页面与接口:返回个人数据,用 private;含敏感字段的用 no-store。
想同时兼顾浏览器与 CDN,可以写成组合:public, max-age=60, s-maxage=3600,浏览器一分钟内直接用,CDN 一小时回源一次。若担心源站抖动,再追加 stale-while-revalidate 或 stale-if-error 的秒数。
验证分两层。浏览器侧打开开发者工具的 Network 面板,看 Size 列是否显示 memory cache 或 disk cache,Status 列是否出现 304;命令行用 curl -I 只看响应头,确认 Cache-Control、ETag、Vary 是否按预期出现。CDN 侧则看命中状态和 Age 的增长。想手工复现一次请求,用 Bruno 这类接口工具发一次请求,响应头会逐项列出来。
动手前先确认站点本身的传输层没问题,证书与续期配稳了缓存才有意义,这部分可以参考 ACME 证书自动化 的流程。另外要清楚一个边界:中间缓存里已经存下的长缓存,规范没有提供删除手段,Clear-Site-Data: cache 只能清浏览器、动不了 CDN。托管缓存是例外,CDN 面板和 API、Service Worker 的 Cache API 都能主动删条目,这也是它们比纯靠响应头更灵活的地方。所以入口 HTML 不要给长 max-age,否则改版时无处下手。
五个容易踩的误区
- 把 no-cache 当成不缓存。它允许存储,只要求每次复用前验证;真要谁都不存,用 no-store。另外 no-store 也不会删掉同一 URL 已经存下的旧副本。
- 给 HTML 设一年 max-age。入口页 URL 没法像 bundle 那样加 hash,长缓存等于把发布通道堵住,这类资源更适合 no-cache。
- 不写 Cache-Control 就以为不缓存。没有这个头会走启发式缓存,规范建议按存储时间与最后修改时间的关系取约 10%,行为不可控。给每个响应显式写一条头更稳。
- 漏写 Vary。按 Accept-Encoding 或 Accept-Language 变化的响应,必须把对应请求头写进 Vary,否则可能把 gzip 版本发给不支持压缩的客户端。反过来也别把 User-Agent 塞进 Vary,变体数量会把命中率拖垮。
- 个性化内容漏了 private。共享缓存可能把 A 用户的页面发给 B 用户。带 Authorization 的请求,响应默认不进共享缓存,要放开得显式写 public、s-maxage 或 must-revalidate。
另外,HttpOnly 之类的 Cookie 属性解决的是脚本读取问题,不解决缓存串号;判断标准仍然是有没有写 private。
还有一个容易忽略的机制叫请求折叠。多个客户端的相同请求同时到达共享缓存时,缓存会合并成一次回源,再把结果分给所有人。这种情况下即便响应写了 max-age=0 或 no-cache,并发请求仍可能共享同一份结果,所以个性化响应必须靠 private 把共享缓存排除在外。
适用人群与上手建议
这套规则对前端、后端、运维和独立站长都用得上,尤其是自己维护静态站或反向代理的人。上手顺序建议是:先给入口 HTML 加 no-cache 和 ETag,再给静态资源加 hash 并把 max-age 拉长,最后才按需叠加 s-maxage 与 stale-while-revalidate。每改一次就用 curl 和开发者工具复验一次,比事后猜哪里配错省事得多。
读规范时以 RFC 9111 与 MDN 的 HTTP 缓存指南、Cache-Control 页面为准,这几处都把 no-cache 与 no-store 的区别写在显眼位置;浏览器行为细节可以对照 RFC 5861 的原文示例。缓存和数据库写盘有相似之处,都是把读写路径拆开,区别只在存储位置,同类的机制类文章还有 SQLite WAL 模式 一篇。
© 版权声明
文章版权归作者所有,转载需注明出处。未声明原创的皆为网络收集,如有侵权请邮箱lishiwenhuagx@sina.com联系下架处理。




