HTTP 缓存策略速览

从 Cache-Control 到条件请求,一张图理清浏览器缓存的关键开关。

浏览器缓存是 Web 性能里「投入产出比」最高的一环:不加缓存,一次页面刷新就要把脚本、样式、图片全部重传;配好缓存,大部分资源直接从本地读取。本文不用面试题的口气罗列知识点,只讲清楚几个开关各管什么、怎么配合。

一切从 Cache-Control 开始

响应头 Cache-Control 是缓存的总开关,几乎所有策略都从这里起步。最常见的取值是 max-age,它告诉浏览器:这份资源在接下来多少秒内可以直接复用,不用再问服务器。

举个例子,给静态图片设置一天的缓存:

HTTP/1.1 200 OK
Content-Type: image/jpeg
Cache-Control: public, max-age=86400

max-age=86400 表示 24 小时内图片都不会过期。这里要注意两点:

  • 取值单位是,不是毫秒,86400 恰好是一天;
  • public 允许任何中间代理(CDN、网关)缓存,private 则只允许浏览器缓存。

几个容易混淆的指令

下面这几个值经常一起出现,但含义不同,值得单独拎出来:

禁止缓存与禁止复用

no-store 最彻底——告诉所有节点不要存任何副本,常用于登录态、支付信息这类敏感数据。而 no-cache 名字有迷惑性,它其实允许缓存,只是每次用之前都先回服务器验证一下(见下一节的「条件请求」)。

用一句话记:no-store 是「根本别存」,no-cache 是「存了但每次都要验」

缓存过期了怎么办:条件请求

max-age 只解决「多久过期」,过期之后浏览器不会直接丢弃,而是发起一次条件请求,让服务器判断资源是否真的变了。这个过程有两条载体,一个是 ETag,一个是 Last-Modified。先从整体看一眼这张流程图:

HTTP 缓存生命周期
图中这条路可以拆成三段,下面依次展开:先看指纹,再看对账,最后讲兜底。

三段式拆解

  • 到期前:本地直接命中,零请求
    • 判断依据:max-age 还没走完
    • 备注:must-revalidate 可禁用过期后的「陈旧复用」
  • 到期后:发条件请求去对账
    • 载体一:If-None-Match + ETag
    • 载体二:If-Modified-Since + Last-Modified
  • 对账失败:服务器返回完整 200 正文

ETag:内容的指纹

服务器返回资源时,可以附带一个 ETag 头,本质是内容的哈希,比如 "abc123"。浏览器把它存下来,过期后重新请求时带上 If-None-Match: "abc123"

GET /app.js HTTP/1.1
Host: example.com
If-None-Match: "abc123"

服务器比对后,如果没变,就返回一个没有正文304 响应,浏览器得知货没换,直接用本地副本就行。这个交互可以用一张简单的时序表概括:

请求头 / 响应头归属方作用
Cache-Control: max-age=86400响应规定缓存存活多久
ETag: "abc123"响应对内容取指纹
If-None-Match: "abc123"请求拿旧指纹去对账
304 Not Modified响应没变,省下传输正文

Last-Modified:退而求其次

Last-Modified 记录文件最后修改时间,配合请求头 If-Modified-Since 使用,原理类似。它的精度只有秒,同一秒内改两次就会漏判,所以口碑不如 ETag。实践中两者也可以同时给出,服务器一般优先校验 ETag,再退而看时间戳。两者的取舍对比,可以看这篇 RFC 7232 摘要

实战里怎么选

不同资源适合不同的策略,别一刀切:

  • 带版本号的静态资源,如 app.a1b2c3.js,放心设很长的 max-age——文件名变了,URL 就变,不会吃到旧缓存;
  • HTML 页面本身要设短缓存或 no-cache,否则改完上线浏览器还在用旧壳;
  • 用户数据接口,谨慎用 no-store,避免隐私泄到公共缓存。
// 服务端设置响应头(示意)
res.set('Cache-Control', 'public, max-age=31536000'); // 一年 = 365 * 24 * 60 * 60
res.set('ETag', `"${hashOf(res.body)}"`);

再配合一张检查清单,上线前逐条过一遍会更稳妥:

  • 静态资源带版本号,并设了长 max-age
  • HTML 页面设置了 no-cache
  • 接口确认是否加 ETag 做条件请求
  • 敏感数据确认是 no-store

其他容易漏的细节

缓存相关要补的知识点还不少,随手列几条备忘:

  1. Vary 头:比如 Vary: Accept-Encoding,告诉缓存器「按压缩方式区分缓存」,否则 gzip 和原始文件会串台;
  2. 浏览器地址栏刷新和「强制刷新」行为不同——后者接近绕过缓存;
  3. 私有缓存(浏览器)和共享缓存(代理)对 private / public 的响应不同;
  4. 有些框架会自动带 ETag,但只对 / 这类少数路由生效,别默认全覆盖。

其中第 3 条值得展开,用嵌套引用示意思考层级:

缓存不只是浏览器本地的事。

你还需要考虑 CDN 和反向代理,它们都是共享缓存层。

给它们看的指令,可能和给浏览器看的不一样。

调试时最常用的快捷键是 F12 打开开发者工具,切到 Network 面板。把某个响应标成灰色,多半就是从本地缓存读的了。

结尾

缓存调优没有银弹,本质是在「新鲜度」和「传输成本」之间找平衡。把 Cache-Control总纲、把条件请求当兜底,多数站点的问题就能解决大半。更完整的定义可以去翻 MDN,见文末脚注。

顺带说明,有的资料里会把 max-age 写作 \*max-age\* 以强调它是必填字段,其实这只是排版写法,并不是真的星号——HTTP 头里没有「必填」这个概念,全靠约定。


如无特别说明,示例均以 HTTP/1.1 为背景;HTTP/2 之后的字段含义不变。

再提醒一句被问烂的话:缓存不是「无脑设 max-age 就完事」,它必须和你的更新流程配套才能不踩坑。上面清单里勾掉的两条,就是最关键的起步动作。

转载或引用请注明出处,本文基于 MDN 按需整理,非逐字翻译。1


Footnotes

  1. ETag 与条件请求的完整机制,见:https://developer.mozilla.org/docs/Web/HTTP/Conditional_requests