HTTP 缓存是 Web 性能优化的第一杠杆。一次命中缓存的请求,可以完全跳过网络传输、减少源站压力,把响应时间从几百毫秒降到几毫秒。理解 HTTP 缓存机制,是前端工程师与后端工程师都必须掌握的底层能力。
一、HTTP 缓存体系总览
1.1 两类缓存判定模型
HTTP 缓存分为两大类:强缓存(Heuristic/Fresh)与协商缓存(Conditional Request)。
浏览器发起请求
│
▼
┌─────────────────┐
│ 强缓存判定 │ ← Cache-Control / Expires 未过期
│ 直接命中本地 │ 直接使用本地副本,不发请求
└────────┬────────┘
│ 过期
▼
┌─────────────────┐
│ 协商缓存判定 │ ← 携带 If-None-Match / If-Modified-Since
│ 发请求给服务器 │ 服务器比对 ETag / Last-Modified
└────────┬────────┘
│
┌─────┴──────┐
▼ ▼
304 200
(Not Modified) (新响应体)
│ │
▼ ▼
使用本地副本 更新本地副本并返回
一句话:强缓存是「本地直接判定,零网络开销」,协商缓存是「带条件问服务器,节省响应体传输」。
1.2 两种模型的对比
| 维度 | 强缓存 | 协商缓存 |
|---|---|---|
| 是否发起请求 | 否,直接使用本地副本 | 是,携带条件头 |
| 性能开销 | 零网络请求 | 一次往返(但通常无响应体) |
| 判定依据 | Cache-Control/Expires 新鲜度 | ETag/Last-Modified 资源标识 |
| 典型状态码 | 200 (from memory cache) | 304 Not Modified |
| 失效控制 | 时间维度 | 资源内容维度 |
二、Cache-Control 指令全解
2.1 响应指令
Cache-Control 是 HTTP/1.1 引入的通用缓存控制头,字段之间用逗号分隔。以下是全部核心指令:
| 指令 | 含义 | 典型值 |
|---|---|---|
max-age=<s> | 资源在缓存中的最大新鲜时间(秒) | max-age=31536000 |
s-maxage=<s> | 共享缓存(CDN/网关)的最大新鲜时间 | s-maxage=60 |
public | 允许任何缓存(浏览器+CDN)存储 | public |
private | 仅允许私有缓存(浏览器)存储 | private |
no-store | 完全禁止缓存(不写入任何存储) | no-store |
no-cache | 每次使用前必须向源站校验(协商) | no-cache |
must-revalidate | 过期后必须向源站验证,禁止用过期副本 | must-revalidate |
proxy-revalidate | 共享缓存过期后必须验证 | proxy-revalidate |
immutable | 过期时间内绝不重新验证(配合 long max-age) | immutable |
stale-while-revalidate | 过期后先返回旧副本,同时后台异步刷新 | stale-while-revalidate=86400 |
stale-if-error | 源站出错时允许使用过期副本 | stale-if-error=86400 |
2.2 请求指令
请求方向也可携带 Cache-Control,用于控制缓存行为:
# 强制绕过缓存,回源获取最新资源
Cache-Control: no-cache
# 无论本地是否有缓存都回源
Cache-Control: no-store
# 仅接受新鲜度不超过指定秒数的缓存
Cache-Control: max-age=0
Cache-Control: max-stale=3600
Cache-Control: min-fresh=60
# 仅使用本地缓存,不访问网络(离线优先)
Cache-Control: only-if-cached
# curl 中强制回源
curl -H "Cache-Control: no-cache" https://example.com/app.js
# 查看响应中的缓存头
curl -I https://example.com/app.js
三、强缓存:Expires 与 max-age
3.1 Expires(HTTP/1.0)与 max-age(HTTP/1.1)
Expires 是 HTTP/1.0 时代的绝对时间,max-age 是相对时间。
# HTTP/1.0 方式:绝对时间
Expires: Wed, 30 Sep 2026 12:00:00 GMT
# HTTP/1.1 方式:相对时间
Cache-Control: max-age=31536000
一句话:
max-age基于「响应到达缓存的时间」计算,不受客户端时钟漂移影响,优先级高于Expires。
3.2 强缓存生效的条件
强缓存命中要求同时满足:
- 响应携带
Cache-Control且未超过max-age(或Expires未到); - 没有
no-cache/no-store指令; - 缓存副本仍在存储中未被淘汰。
# 模拟强缓存判定逻辑(Python)
import time
def is_fresh(entry, now_ts):
"""entry 为缓存条目,包含 stored_time 与 max_age"""
max_age = entry.get("max_age", 0)
if max_age == 0:
return False
age = now_ts - entry["stored_time"]
return age < max_age
四、协商缓存:Last-Modified 与 ETag
4.1 Last-Modified / If-Modified-Since
基于资源的最后修改时间做条件请求:
# 首次响应
HTTP/1.1 200 OK
Last-Modified: Thu, 25 Sep 2026 08:30:00 GMT
# 后续请求
If-Modified-Since: Thu, 25 Sep 2026 08:30:00 GMT
# 未修改 → 304
HTTP/1.1 304 Not Modified
4.2 ETag / If-None-Match
基于资源的实体标签(通常是内容哈希)做条件请求:
# 首次响应
HTTP/1.1 200 OK
ETag: "e6d8a1b2-5f3c-9a1b-c4d5e6f7a8b9"
Content-Type: application/javascript
Content-Length: 13318
# 后续请求
If-None-Match: "e6d8a1b2-5f3c-9a1b-c4d5e6f7a8b9"
# 未修改 → 304,无响应体
HTTP/1.1 304 Not Modified
4.3 两者的对比与配合
| 维度 | Last-Modified | ETag |
|---|---|---|
| 精度 | 秒级,1 秒内多次修改无法区分 | 字节级,内容变则变 |
| 性能 | 只需读文件 mtime,快 | 需计算哈希,成本略高 |
| 强校验 | 弱,时间戳可能被中间层改写 | 强,内容标识几乎不可能冲突 |
| 优先级 | 低(仅当无 ETag 时使用) | 高 |
生产上建议以 ETag 为主、Last-Modified 为辅:If-None-Match 存在时服务器忽略 If-Modified-Since。
// Go 服务端生成强 ETag(基于内容哈希)
func makeETag(content []byte) string {
h := sha256.New()
h.Write(content)
sum := h.Sum(nil)
return fmt.Sprintf("\"%x\"", sum[:16])
}
// 条件请求处理
func handler(w http.ResponseWriter, r *http.Request) {
data, _ := os.ReadFile(r.URL.Path)
etag := makeETag(data)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Write(data)
}
五、私有缓存与共享缓存
5.1 两者的边界
HTTP 缓存按可共享范围分为两类:
- 私有缓存(Private Cache):单用户独享,典型是浏览器内存/磁盘缓存。
- 共享缓存(Shared Cache):多用户复用,典型是 CDN 边缘节点、反向代理网关。
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 浏览器缓存 │ │ CDN 边缘节点 │ │ 源站网关 │
│ (私有, 单用户) │ → │ (共享, 多用户) │ → │ (共享缓存层) │
│ Memory+Disk │ │ 覆盖全国/全球 │ │ Nginx/网关 │
└──────────────┘ └──────────────┘ └──────────────┘
一句话:含用户隐私信息的响应必须用
private,否则共享缓存可能把 A 用户的页面错发给 B 用户。
5.2 权限指令的语义
# 只能被浏览器缓存,CDN 不得存储
Cache-Control: private, max-age=3600
# 可被 CDN 与浏览器同时缓存
Cache-Control: public, max-age=86400
# 共享缓存使用更短的 s-maxage,浏览器用 max-age
Cache-Control: public, max-age=3600, s-maxage=60
s-maxage 只对共享缓存生效:CDN 每 60 秒回源校验一次,而浏览器最长可复用 1 小时。这是「CDN 快速感知更新、浏览器减少回源」的经典组合。
# Nginx 作为共享缓存网关的典型配置
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m
max_size=1g inactive=24h;
server {
listen 80;
location /api/ {
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
# 与源站 Cache-Control 联动,忽略 no-cache 等敏感指令需谨慎
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
# 回源时带上客户端缓存信息,源站可据此决策
proxy_pass http://backend;
}
}
六、缓存分层架构:浏览器 → CDN → 网关 → 源站
6.1 多层命中的路径
一次完整的多层缓存请求:
客户端
│ ① 浏览器缓存命中? ── 命中 → 直接渲染(0 RTT)
│ 未命中
▼
CDN 边缘节点(就近 PoP)
│ ② CDN 缓存命中? ── 命中 → 返回(靠近用户的 1 RTT)
│ 未命中
▼
反向代理/API 网关
│ ③ 网关缓存命中? ── 命中 → 返回
│ 未命中
▼
源站(应用服务器 + 数据库)
│ ④ 源站计算并响应
每一层都承担一部分流量,逐层递减。层与层之间的缓存头必须语义一致,否则会出现「CDN 认为新鲜但浏览器已过期」的错位。
6.2 分层缓存的关键参数
| 层级 | 典型 TTL | 失效手段 | 故障语义 |
|---|---|---|---|
| 浏览器 | 长(如 1 年,资源带指纹) | 变更 URL 指纹 | 离线可用 |
| CDN 边缘 | 中(如 10 分钟) | 主动 Purge 接口 | stale-if-error 兜底 |
| 网关 | 短(如 1 分钟) | 被动回源验证 | 降级直连源站 |
| 源站 | 无(动态数据) | 主动失效广播 | 全链路兜底 |
# 常见 CDN 主动失效操作
# 阿里云 / 腾讯云 / Cloudflare 均提供 Purge API 与命令行
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-d '{"files":["https://example.com/app.js","https://example.com/style.css"]}'
# 正则批量失效
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-d '{"tags":["release-2026-09"]}'
6.3 Vary 与内容协商缓存
同一 URL 可能因 Accept-Encoding、Accept-Language 等请求头产生不同版本。Vary 告诉缓存这些头参与缓存键的划分:
HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding
Cache-Control: public, max-age=86400
注意:Vary: Accept-Encoding 会导致同一资源被拆分存储两份(gzip/br 各一)。滥用 Vary 会大幅降低缓存命中率,这也是很多站点强制去掉 Vary: User-Agent 的原因。
七、缓存失效与版本控制
7.1 两种失效策略对比
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 覆盖式更新 | 同一 URL 覆盖新内容 | 实现简单 | 需主动失效,存在「新老内容交错」窗口 |
| 版本号查询串 | app.js?v=20260926 | 易于回滚 | 查询串不参与部分 CDN 缓存键,易漏缓存 |
| 内容指纹 | app.8a3f2b.js(哈希) | 永久缓存、天然无失效问题 | 需构建期计算哈希 |
7.2 内容指纹 + immutable
现代前端工程普遍采用「文件内容哈希命名 + 一年 max-age + immutable」:
# 带指纹的资源,可放心使用极长缓存
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
ETag: "8a3f2b9c-..."
// webpack/vite 输出带内容哈希的文件名
// webpack.config.js
module.exports = {
output: {
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].chunk.js',
},
optimization: {
runtimeChunk: 'single', // 运行时单独成块,避免业务变更导致 runtime 缓存失效
},
};
<!-- index.html 引用带指纹的资源;index.html 本身 short-cache 或 no-cache -->
<script src="/assets/app.8a3f2b9c.js" defer></script>
<link rel="stylesheet" href="/assets/main.c1d4e5f6.css">
一句话:内容变了 URL 就变,旧 URL 的缓存自然永久有效,从根上消除了「缓存失效」问题。
7.3 HTML 本身的缓存策略
带指纹的资源文件用长缓存,但入口 HTML 必须是弱缓存或动态的:
location / {
root /usr/share/nginx/html;
index index.html;
# HTML 每次都要校验,拿到最新指纹引用
location ~ \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
etag on;
}
# 带指纹的静态资源一年不失效
location ~* \.(js|css|png|jpg|woff2|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
八、前端工程中的缓存策略
8.1 按资源类型分类管理
| 资源类型 | 缓存策略 | 原因 |
|---|---|---|
index.html | no-cache + ETag | 必须第一时间拿到新指纹引用 |
| JS/CSS 指纹文件 | 1 年 + immutable | 内容寻址,永不失效 |
| 图片/字体 | 长缓存(可带 stale-while-revalidate) | 体积大、更新频率低 |
| API 响应 | 按业务分级:private 或 s-maxage | 涉及用户数据时禁止共享缓存 |
| 日志/埋点 | no-store | 实时性要求高 |
8.2 动态 API 的缓存实践
// Go 中按接口语义设置缓存头
func userProfileHandler(w http.ResponseWriter, r *http.Request) {
uid := r.URL.Query().Get("uid")
// 私有数据:仅浏览器缓存 5 分钟,CDN 不缓存
w.Header().Set("Cache-Control", "private, max-age=300")
// 公共数据:CDN 缓存 60 秒,浏览器 5 分钟
// w.Header().Set("Cache-Control", "public, max-age=300, s-maxage=60")
// w.Header().Set("Vary", "Accept-Encoding")
// 结构化数据配合 ETag,做条件刷新
data := fetchUser(uid)
etag := makeETag(data)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
json.NewEncoder(w).Encode(data)
}
// 前端 SW(Service Worker)层缓存兜底
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
// 先网络、后缓存:保证数据新鲜,离线时回退缓存
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open('api-v1').then((c) => c.put(req, copy));
return res;
})
.catch(() => caches.match(req))
);
});
8.3 缓存与刷新编排
一次发版涉及「浏览器缓存 → CDN → 网关 → 源站」四层的联动刷新。推荐发布流水线:
- 先刷新 CDN:Purge 掉所有
index.html与不再引用的旧指纹资源; - 再发布源站:新版本静态资源与 HTML 上线;
- 最后等待收敛:浏览器侧用户刷新页面即拿到新 HTML,从而引用新指纹资源。
# 发布脚本片段
echo "1. Purge CDN..."
curl -X POST ".../purge_cache" -d '{"files":["https://example.com/index.html"]}'
echo "2. Deploy to origin (rsync)..."
rsync -avz --delete ./dist/ user@origin:/usr/share/nginx/html/
echo "3. Verify..."
curl -s -I https://example.com/app.js | grep -i cache-control
九、总结
| 缓存层级 | 判定机制 | 关键头 | 典型场景 |
|---|---|---|---|
| 强缓存 | 本地新鲜度 | Cache-Control: max-age / Expires | 静态资源、图片 |
| 协商缓存 | 条件请求 | ETag / Last-Modified | HTML、动态 API |
| 私有缓存 | 仅本用户 | private | 个人资料、购物车 |
| 共享缓存 | 多用户复用 | public + s-maxage | CDN、网关层 |
| 内容寻址 | URL 指纹 | immutable + 长 max-age | JS/CSS 构建产物 |
HTTP 缓存不是单一开关,而是一套分层、分级、按资源语义决策的体系。正确的做法是:对带指纹的不可变资源用「极长强缓存」,对入口 HTML 用「协商缓存」,对动态数据用「短 TTL + 条件请求」,再用 CDN 的 Purge 能力做主动失效。把这套策略落实到位,往往是最低成本、最高回报的性能优化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。