HTTP 缓存机制详解:Cache-Control、ETag 与协商缓存的正确配置

HTTP 缓存几乎是性能优化里"投入最少回报最大"的一块,但很多人对它的理解还停在"加个 Cache-Control 就行"。结果上线后要么缓存太强,改了文件用户看不到新版本;要么缓存太弱,白白浪费带宽。这篇文章把 HTTP 缓存的所有头部一次讲透,告诉你"什么场景配什么"。

强缓存 vs 协商缓存:先建立框架

HTTP 缓存分两层,理解这两层的关系是后面所有讨论的前提:

  1. 强缓存:浏览器看本地缓存还没过期,不发请求,直接用本地副本。控制头:Cache-ControlExpires
  2. 协商缓存:本地缓存过期了,浏览器发请求,但带上"我手里这份的标识"。服务器看了如果发现没变,返回 304 Not Modified,浏览器继续用本地副本。控制头:ETag / If-None-MatchLast-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=hashapp.{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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Docker 多阶段构建实战:把镜像体积缩小 50 倍的工程姿势

2026-5-15 11:04:01

技术教程

正则表达式实战指南:从基础语法到零宽断言与命名捕获组

2026-5-15 11:04:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索