HTTP 缓存几乎是性能优化里"投入最少回报最大"的一块,但很多人对它的理解还停在"加个 Cache-Control 就行"。结果上线后要么缓存太强,改了文件用户看不到新版本;要么缓存太弱,白白浪费带宽。这篇文章把 HTTP 缓存的所有头部一次讲透,告诉你"什么场景配什么"。
强缓存 vs 协商缓存:先建立框架
HTTP 缓存分两层,理解这两层的关系是后面所有讨论的前提:
- 强缓存:浏览器看本地缓存还没过期,不发请求,直接用本地副本。控制头:
Cache-Control、Expires。 - 协商缓存:本地缓存过期了,浏览器发请求,但带上"我手里这份的标识"。服务器看了如果发现没变,返回 304 Not Modified,浏览器继续用本地副本。控制头:
ETag/If-None-Match、Last-Modified/If-Modified-Since。
所以一次请求的真实路径是:本地缓存没过期 → 直接用(强缓存,0 请求);过期了 → 发请求带条件头 → 服务器 304(协商缓存,1 个请求但无 body) → 服务器 200(完整下载)。
Cache-Control:现代缓存控制的主角
所有 HTTP 缓存配置都应该用 Cache-Control,Expires 只是兼容老浏览器的回退,且因为是绝对时间,客户端时钟错乱时不可靠。
Cache-Control: max-age=3600 # 缓存 1 小时
Cache-Control: public, max-age=31536000 # 公共缓存(CDN 可缓),1 年
Cache-Control: private, max-age=600 # 仅浏览器缓存,不让 CDN 缓
Cache-Control: no-cache # 每次都协商,不能直接用本地副本
Cache-Control: no-store # 完全不缓存
Cache-Control: max-age=600, must-revalidate # 过期后必须协商,不允许过期还用
Cache-Control: max-age=600, stale-while-revalidate=60 # 过期 60 秒内仍可用
几个最容易混淆的:
no-cache不是"不缓存",而是"每次都协商" —— 还是会存,只是用之前要问服务器"还能用吗"。no-store才是真正的"不缓存",任何中间环节都不能存。敏感页面(银行账户、私密对话)用这个。private表示"只让浏览器缓存,不让 CDN 缓存",适合用户私有内容(已登录页面、个人数据)。public显式允许任何缓存(包括 CDN)。
ETag 与 Last-Modified:协商缓存的两种凭证
Last-Modified / If-Modified-Since
# 首次响应
HTTP/1.1 200 OK
Last-Modified: Tue, 15 May 2026 10:00:00 GMT
Cache-Control: max-age=3600
# 缓存过期后,浏览器请求
GET /styles.css
If-Modified-Since: Tue, 15 May 2026 10:00:00 GMT
# 服务器对比:文件没变
HTTP/1.1 304 Not Modified # 无 body,省带宽
问题:Last-Modified 精度只到秒。1 秒内修改两次,客户端拿不到第二次的修改。还有一个尴尬场景:文件内容没变只 touch 了一下,时间戳变了 —— 客户端会被迫重新下载完全一样的内容。
ETag / If-None-Match
# 首次响应
HTTP/1.1 200 OK
ETag: "5d8c7-1a2b3c4d" # 文件内容指纹
Cache-Control: max-age=3600
# 缓存过期后请求
GET /styles.css
If-None-Match: "5d8c7-1a2b3c4d"
# 服务器对比指纹,没变
HTTP/1.1 304 Not Modified
ETag 通常用文件内容的哈希(或 inode+size+mtime)生成,内容变了它才变,精度比时间戳高得多。代价是服务器要算哈希(可以缓存或用 weak ETag W/"..." 减负)。
实际中两者经常同时存在,服务器响应里都返回,客户端两个条件头都带 —— 但ETag 优先级更高,服务器先看 ETag 匹配,再考虑 Last-Modified。
Express 实战:三种类型资源的不同策略
const express = require('express');
const app = express();
// 1. 永远不变的静态资源(带哈希文件名):一年强缓存,immutable
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true,
setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
},
}));
// 2. HTML 入口文件:每次协商,不能强缓存,否则用户永远看不到更新
app.get('*.html', (req, res, next) => {
res.setHeader('Cache-Control', 'no-cache');
next();
});
// 3. API 接口:看情况
app.get('/api/users/me', (req, res) => {
res.setHeader('Cache-Control', 'private, max-age=60'); // 私有,1 分钟
res.json({ name: 'mores' });
});
app.get('/api/products', (req, res) => {
// 公开数据,可短暂强缓存 + 长时间协商
res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json([/* ... */]);
});
immutable 是个被低估的指令:它告诉浏览器"这个文件在 max-age 内绝对不会变,你别浪费请求来协商"。配合 app.js?v=hash 或 app.{hash}.js 的文件名,实现"一年都不需要协商,改了就换文件名"的最优解。这是几乎所有现代构建工具(webpack / vite / next.js)的默认策略。
Vary:同 URL 不同响应时的关键头
同一个 URL 可能根据请求头返回不同内容:同一个 /index.html,中文用户和英文用户看到不同语言;同一个 /avatar.png,支持 WebP 的浏览器返回 WebP 否则返回 PNG。这时缓存必须知道"按哪个请求头区分"。
# 服务器响应
Content-Type: text/html
Vary: Accept-Language # 告诉缓存:同 URL 但 Accept-Language 不同,分别缓存
Content-Language: zh-CN
# 另一种典型用法:按 Accept-Encoding 区分压缩格式
Vary: Accept-Encoding
不写 Vary 会导致 CDN 把第一个请求的响应误用给所有人 —— 中文用户看到了英文页,WebP 浏览器收到了 PNG。这类 bug 排查起来非常诡异,因为本地复现不出来,只在经过缓存的环境出现。
实战:CDN 后面的"双层缓存"策略
真实生产环境往往是"用户 → CDN → 源站"的两级缓存。这时你需要两套 Cache-Control:
Cache-Control: public, max-age=60, s-maxage=3600
# max-age=60:浏览器缓存 1 分钟
# s-maxage=3600:CDN 缓存 1 小时(s 是 shared 缓存的前缀)
这样设计的逻辑:用户拿到响应后 1 分钟就和 CDN 重新确认,CDN 每小时才回源站确认一次。源站压力低,用户感知更新快。改了内容后只需要 CDN 刷新缓存(或者用版本号文件名),用户最长等 1 分钟就能看到。
主动失效:purge 和版本化文件名
缓存设长了,内容紧急更新怎么办?两个办法:
- CDN purge API:几乎所有 CDN 都提供"按 URL 强制清缓存"的接口。CI 部署后调用。
- 版本化文件名:HTML 里引用的 JS / CSS 用
app.abc123.js这种带哈希的文件名。HTML 本身短缓存或不缓存,改了就引用新哈希,浏览器自然请求新文件,旧的留在那里给老用户继续用 —— 这是更优雅、零失败风险的做法。
Service Worker:浏览器侧的"自定义缓存层"
HTTP 缓存是浏览器内置的,你只能"告诉它要不要缓存"。Service Worker 让你接管这个决策。下面是一个最常见的"网络优先,失败回退缓存"策略:
// service-worker.js
const CACHE = 'v1';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll([
'/',
'/styles.css',
'/app.js',
'/offline.html',
])));
});
self.addEventListener('fetch', (e) => {
e.respondWith((async () => {
try {
const fresh = await fetch(e.request);
const cache = await caches.open(CACHE);
cache.put(e.request, fresh.clone()); // 异步更新缓存
return fresh;
} catch {
// 网络失败,回退缓存,缓存里也没就显示离线页
const cached = await caches.match(e.request);
return cached || caches.match('/offline.html');
}
})());
});
Service Worker 的杀手锏是离线可用。PWA、文档站、电商详情页都从中受益 —— 哪怕网络断了,用户仍能浏览已缓存的内容。
常见的几个坑
坑 1:HTML 设了长强缓存。 这是最危险的错误。HTML 是用户访问的入口,缓存几天意味着用户几天看不到新版本。HTML 永远应该 no-cache 或非常短的 max-age。
坑 2:登录态页面被 CDN 缓存。 没写 private,CDN 把用户 A 的"我的订单"页面缓存了,用户 B 进来看到了 A 的订单。任何包含用户信息的响应,必须 Cache-Control: private,或者干脆 no-store。
坑 3:启用了 gzip 但忘了 Vary: Accept-Encoding。 不支持 gzip 的客户端拿到了 gzip 内容,显示一堆乱码。
坑 4:ETag 用了内容哈希,但负载均衡后端多台机生成的哈希不同。 同一个文件不同后端返回不同 ETag,导致 304 永远不命中,白白消耗带宽。解决:用稳定的算法,或者直接关掉 ETag 只用 Last-Modified。
坑 5:错把"刷新页面"等同于"缓存失效"。 普通刷新仍走强缓存;Ctrl+F5 强制刷新会跳过强缓存,但仍带条件头;DevTools 的"Disable cache"才是完全跳过任何缓存。排查缓存问题时别在普通刷新里转圈。
怎么验证你的缓存策略
# 1. curl 看真实响应头
curl -I https://your-site.com/app.js
# 2. 模拟带条件头的请求
curl -I -H 'If-None-Match: "abc123"' https://your-site.com/app.js
# 期望:HTTP/1.1 304 Not Modified
# 3. DevTools Network 面板看 "Size" 列:
# "(memory cache)" / "(disk cache)" = 强缓存命中
# "304" + 较小 size = 协商缓存命中
# "200" + 完整 size = 完全下载
# 4. 看 X-Cache 头(CDN 通常会加)
# X-Cache: HIT 命中 CDN 缓存
# X-Cache: MISS CDN 回源了
缓存粒度:从静态资源到 API 响应
不同类型的资源,缓存策略差别很大。给一张速查表:
资源类型 推荐 Cache-Control
带哈希文件名的 JS/CSS public, max-age=31536000, immutable
不带哈希的图片 public, max-age=86400(1 天)+ ETag
HTML 入口 no-cache (允许协商,绝不强缓存)
登录页面 / 用户数据 private, no-store
POST/PUT 响应 no-store
公开列表 API public, max-age=60, s-maxage=600
个人化 API private, max-age=0, must-revalidate
实战:用 nginx 配合上面的策略
# nginx.conf 片段
location ~* \.(js|css)$ {
# 假设 webpack 构建带哈希:app.abc123.js
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.(png|jpg|jpeg|gif|webp|svg)$ {
expires 1d;
add_header Cache-Control "public, max-age=86400";
# 启用 ETag(nginx 默认开)
}
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache";
}
# API 路径让上游服务自己控制,nginx 不覆盖
location /api/ {
proxy_pass http://backend;
proxy_cache_bypass $http_pragma;
}
# 启用 gzip 时记得加 Vary
gzip on;
gzip_types text/css application/javascript application/json;
gzip_vary on;
缓存与 CORS 一起部署的坑
跨域请求的预检(OPTIONS)响应也会被缓存。如果你的 Access-Control-Allow-Origin 不固定(回显请求来源),缓存层可能把第一个请求的 CORS 头发给所有人,导致跨域失败。修复:在响应里加 Vary: Origin,让 CDN 按来源分别缓存。
# 后端伪代码
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Vary', 'Origin'); // 关键
res.setHeader('Access-Control-Max-Age', '86400'); // 预检结果缓存 1 天
怎么主动让用户拿到最新 HTML
SPA 部署里最痛的事:用户标签页一直开着,你后端发了新版本,但用户的浏览器还在用旧 HTML,旧 HTML 引用的 JS hash 还存在(因为 immutable),用户感受不到更新。常用的兜底策略:
// 入口 JS 启动后,定时拉一个版本文件,与本地比对
async function checkVersion() {
const r = await fetch('/version.json?_=' + Date.now(), { cache: 'no-store' });
const { build } = await r.json();
if (build !== window.__BUILD__) {
// 静默提示用户刷新
showUpdateBanner();
}
}
setInterval(checkVersion, 5 * 60 * 1000); // 每 5 分钟检查一次
这比"用户刷新就能看到新版"靠谱得多 —— 因为很多用户一周都不刷新一次,特别是 SaaS 后台。版本检测主动提示,把"何时更新"的决策权还给用户。
HTTP/2 与 HTTP/3 时代的缓存细节
HTTP/2 引入了 Server Push 试图把"主动推送资源"做进协议,但因为缓存协调困难,Chrome 已经在 2022 年废弃了它。现在的最佳实践改为 <link rel="preload"> 和 103 Early Hints —— 前者让浏览器尽早开始下载,后者让代理或服务端在 200 响应到达前就告诉浏览器哪些子资源该开始拉了。
# 103 Early Hints 示例(支持的服务器/CDN 已经很多了)
HTTP/1.1 103 Early Hints
Link: </styles.css>; rel=preload; as=style
Link: </app.js>; rel=preload; as=script
HTTP/1.1 200 OK
Content-Type: text/html
...
写在最后
HTTP 缓存的所有复杂度,都来自一个简单问题的不同答案:这份内容能复用多久? 永远不变的资源用长强缓存 + immutable + 哈希文件名;偶尔变的资源用短强缓存 + 协商缓存;高度个性化的资源用 private + 短缓存;敏感数据用 no-store。把这套对照表记住,大部分场景你都能直接套用。
给你的服务做一次缓存审计:打开 DevTools 的 Network 面板,清缓存重新加载,看每个静态资源的"Cache-Control"和"Size"。如果发现 HTML 强缓存了,或者 JS / CSS 没带 immutable,或者每次刷新所有资源都 200 完整下载 —— 那都是用户体验和服务器成本两方面都能立刻改进的地方。
—— 别看了 · 2026