从零理解 HTTP 缓存:强缓存与协商缓存的实践指南

在前端和后端的日常开发里,HTTP 缓存经常被当成“配置一下响应头”的小事。但当站点开始有真实流量,缓存策略是否正确,直接决定了服务器的带宽成本、接口的响应时间,以及用户打开页面的第一感受。一个配置得当的缓存策略可以让九成以上的静态资源请求根本不落到源站;而一个错误的缓存策略,则可能让用户在上线新版本后仍然看到几天前的页面。

要理解 HTTP 缓存,先要分清两类机制:强缓存和协商缓存。它们解决的问题不同,触发时机也不同。

强缓存:连请求都不发出去

强缓存由 Cache-Control(以及历史遗留的 Expires)控制。当浏览器发现本地缓存的资源还在有效期内,它会直接使用本地副本,根本不会向服务器发送请求。这也是强缓存最大的价值:省掉一次完整的网络往返。

常见配置如下:

Cache-Control: public, max-age=31536000, immutable

这三个指令的含义分别是:资源可以被任何中间层(CDN、代理)缓存;有效期一年;在有效期内资源内容不会改变。immutable 尤其重要,它告诉浏览器这个 URL 对应的内容永远是这一份,用户即使手动刷新也不用回源校验。

与之相对,HTML 入口文件不应该这样配置。因为路由地址是固定的,比如 /index.html,一旦被强缓存一年,你发版之后用户根本拿不到新的 HTML,自然也就看不到新的 JS 文件名。

协商缓存:每次问一下服务器

协商缓存的思路是:浏览器仍然发起请求,但会带上一个校验标识,服务器判断资源没有变化就返回 304,响应体为空。这样虽然省不掉一次往返,但省掉了传输体积,对大文件尤其明显。

两组标识通常成对出现:Last-ModifiedIf-Modified-Since 基于时间,ETagIf-None-Match 基于内容摘要。ETag 更精确,比如文件内容没变但修改时间变了,基于时间的方案会误判成已更新。

# 第一次响应
ETag: "a1b2c3"
Cache-Control: no-cache

# 后续请求
If-None-Match: "a1b2c3"

# 命中则返回
HTTP/1.1 304 Not Modified

注意 no-cache 并不是“不缓存”,而是“缓存但每次必须校验”,这一点经常被误解。真正完全不缓存的是 no-store,它通常只用于包含敏感信息的接口响应。

组合策略:指纹文件名加长缓存

业界最稳的组合是:对带内容指纹的静态资源,比如 app.8f3c1a.js,设置一年强缓存;对 HTML 入口设置 no-cache。这样资源永不重复下载,而新的入口 HTML 总能引用到新的指纹文件,天然完成了版本切换。部署时只要保证旧文件不立刻删除,正在浏览的用户也不会因为资源 404 而白屏。

三个容易踩的坑

第一,把 HTML 也设成一年强缓存,导致发版不生效,这是最常见的事故。判断方法很简单:发版后用一个从未访问过站点的浏览器打开,如果看到旧页面,就基本可以确认是入口文件被强缓存了。

第二,CDN 缓存了带用户信息的接口响应,导致 A 用户看到 B 用户的数据。转发接口一定要显式声明 privateno-store,不能依赖默认行为。

第三,只依赖 Expires。它与服务器时间强相关,客户端时钟偏差会带来难以复现的问题,应当始终以 Cache-Control 为准,把 Expires 仅作为兼容老客户端的兜底。

验证缓存是否按预期工作

改完配置不要凭感觉判断。打开浏览器开发者工具的 Network 面板,看 Size 一列:出现 disk cachememory cache 说明命中了强缓存;状态码 304 说明走了协商缓存;200 则是完整回源。也可以用命令行快速确认响应头:

curl -I https://ai5u.cc/css/style.css

缓存没有“万能配置”,只有与资源特性匹配的策略。判断标准很简单:这个 URL 对应的内容会不会变?会变的话多久变一次?想清楚这两个问题,响应头自然就写对了。