HTTP 缓存策略
大约 11 分钟约 3322 字
HTTP 缓存策略
什么是 HTTP 缓存?
HTTP 缓存是浏览器和服务器之间的一种协作机制,通过缓存已获取的资源来减少网络请求、降低服务器负载、加快页面加载速度。合理配置缓存策略是前端性能优化的核心手段之一。
缓存的作用
- 减少网络请求 — 强缓存直接使用本地资源,零网络开销
- 节省带宽 — 协商缓存返回 304 响应,不传输响应体
- 加快加载 — 缓存命中时资源在毫秒级加载完成
- 降低服务器压力 — 减少重复请求的处理和带宽消耗
- 离线支持 — 配合 Service Worker 实现离线访问
缓存的两个阶段
1. 强缓存(Freshness)
浏览器检查资源是否在有效期内
→ 有效:直接使用缓存(200 from cache)
→ 过期:进入协商缓存
2. 协商缓存(Validation)
浏览器向服务器验证资源是否更新
→ 未更新:返回 304 Not Modified(使用缓存)
→ 已更新:返回 200 OK + 新资源Cache-Control 详解
Cache-Control 是 HTTP/1.1 引入的缓存控制头,功能比 Expires 更强大。
常用指令
# 强缓存时间(秒)
Cache-Control: max-age=31536000 # 缓存 1 年
Cache-Control: max-age=0 # 立即过期(每次都验证)
Cache-Control: max-age=60 # 缓存 60 秒
# 缓存范围
Cache-Control: public # 任何中间代理都可以缓存
Cache-Control: private # 只有浏览器可以缓存
# 禁止缓存
Cache-Control: no-cache # 使用前必须验证(不是不缓存!)
Cache-Control: no-store # 完全不缓存(连内存中都不存)
# 不可变资源(配合内容哈希文件名)
Cache-Control: immutable # 告知浏览器资源不会变化
# 重新验证
Cache-Control: must-revalidate # 过期后必须验证,不能用过期资源
Cache-Control: proxy-revalidate # 代理服务器过期后必须验证
# 过期时间
Cache-Control: s-maxage=86400 # 共享缓存(CDN)的过期时间容易混淆的指令
no-cache ≠ 不缓存
no-cache 的含义是"使用前必须向源服务器验证"
浏览器仍然会存储资源,只是每次使用前会发请求确认
no-store = 真正不缓存
浏览器完全不会存储这个资源
max-age=0 ≠ no-cache
max-age=0 表示"立即过期",但行为类似 no-cache协商缓存
ETag / If-None-Match
ETag 是服务器为资源生成的唯一标识(通常是内容的哈希值)。
# 首次请求
GET /api/user/123 HTTP/1.1
Host: api.example.com
# 响应
HTTP/1.1 200 OK
ETag: "abc123"
Content-Type: application/json
{"id": 123, "name": "张三"}
# 第二次请求(带上 ETag)
GET /api/user/123 HTTP/1.1
Host: api.example.com
If-None-Match: "abc123"
# 响应(未修改)
HTTP/1.1 304 Not Modified
ETag: "abc123"
# 无响应体,节省带宽Last-Modified / If-Modified-Since
基于资源的最后修改时间进行验证。
# 首次请求
GET /api/user/123 HTTP/1.1
# 响应
HTTP/1.1 200 OK
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMT
Content-Type: application/json
{"id": 123, "name": "张三"}
# 第二次请求
GET /api/user/123 HTTP/1.1
If-Modified-Since: Wed, 15 Jan 2024 10:30:00 GMT
# 响应
HTTP/1.1 304 Not ModifiedETag vs Last-Modified
| 维度 | ETag | Last-Modified |
|---|---|---|
| 精确度 | 精确(基于内容哈希) | 粗略(基于时间,秒级) |
| 性能 | 需要计算哈希 | 读取文件修改时间 |
| 适用场景 | 动态内容、API 响应 | 静态文件 |
| 注意事项 | 强 ETag vs 弱 ETag | 时钟同步问题 |
服务端配置
Nginx 配置
server {
listen 80;
server_name www.example.com;
root /usr/share/nginx/html;
# 静态资源(带哈希文件名)— 强缓存 1 年
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
# 开启 gzip
gzip_static on;
}
# HTML 入口 — 不缓存
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# API 响应 — 不缓存
location /api/ {
add_header Cache-Control "no-store";
proxy_pass http://backend:5000;
}
# 图片资源 — 协商缓存
location /images/ {
expires 7d;
add_header Cache-Control "public, must-revalidate";
etag on;
}
# 字体文件 — 长期缓存
location /fonts/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
}Node.js Express 配置
import express from 'express';
import crypto from 'crypto';
import fs from 'fs';
const app = express();
// API 路由 — 不缓存
app.get('/api/users', (req, res) => {
res.set('Cache-Control', 'no-store');
res.json({ users: [] });
});
// 带协商缓存的 API
app.get('/api/user/:id', (req, res) => {
const user = getUser(req.params.id);
const etag = '"' + crypto.createHash('md5')
.update(JSON.stringify(user))
.digest('hex') + '"';
// 检查 ETag
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'public, max-age=60, must-revalidate');
res.json(user);
});
// 静态文件服务
app.use('/assets', express.static('dist/assets', {
maxAge: '1y',
immutable: true,
setHeaders: (res) => {
res.set('Cache-Control', 'public, immutable');
},
}));
app.use(express.static('dist', {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.set('Cache-Control', 'no-cache');
}
},
}));缓存破坏(Cache Busting)
当资源更新时,需要确保用户获取最新版本。最常用的方式是文件名哈希。
内容哈希文件名
<!-- 构建工具自动生成带哈希的文件名 -->
<link rel="stylesheet" href="/assets/app.a1b2c3d4.css">
<script src="/assets/app.e5f6g7h8.js"></script>
<img src="/assets/logo.9i0j1k2l.png">
<!-- 文件内容变化 → 哈希变化 → 新 URL → 缓存失效 -->// Vite 默认配置
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
},
});查询参数方式
<!-- 传统方式:在 URL 后追加版本号或时间戳 -->
<link rel="stylesheet" href="/styles/app.css?v=2.0.0">
<script src="/scripts/app.js?v=20240115"></script>
<!-- 缺点:每次版本更新所有资源都失效 -->
<!-- 推荐:使用内容哈希文件名 -->Service Worker 缓存
Service Worker 提供了更精细的缓存控制,可以实现离线访问和自定义缓存策略。
缓存策略模式
// sw.js — Service Worker
// 策略 1:Cache First(缓存优先)
// 适合不经常变化的静态资源
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('static-v1').then((cache) => {
cache.put(event.request, clone);
});
return response;
});
})
);
});
// 策略 2:Network First(网络优先)
// 适合频繁更新的 API 数据
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open('api-v1').then((cache) => {
cache.put(event.request, clone);
});
return response;
})
.catch(() => caches.match(event.request)) // 离线时使用缓存
);
}
});
// 策略 3:Stale While Revalidate(先缓存后更新)
// 适合允许短暂过期但需要更新的资源
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('dynamic-v1').then((cache) => {
return cache.match(event.request).then((cached) => {
const fetched = fetch(event.request).then((response) => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetched;
});
})
);
});版本管理和缓存更新
const CACHE_VERSION = 'v2.0.0';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
// 安装时预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/assets/app.js',
'/assets/app.css',
]);
})
);
self.skipWaiting(); // 立即激活
});
// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
.map((key) => caches.delete(key))
);
})
);
self.clients.claim(); // 立即控制所有页面
});Vary 头
Vary 头告诉缓存系统根据请求头的不同值来区分缓存。
# 根据 Accept-Encoding 区分 gzip 和 br 的缓存
Vary: Accept-Encoding
# 根据语言区分缓存
Vary: Accept-Language
# 多个 Vary 头
Vary: Accept-Encoding, Accept-Language# Nginx 配置
location /api/ {
gzip on;
add_header Vary "Accept-Encoding";
}常见误区
误区 1:no-cache 误以为是"不缓存"
# no-cache 的真实含义
Cache-Control: no-cache
# "你可以缓存这个资源,但使用前必须向源服务器验证"
# 浏览器会存储资源,但每次使用前会发送 If-None-Match 或 If-Modified-Since
# 真正不缓存
Cache-Control: no-store
# "不要存储这个资源的任何信息"误区 2:更新了代码但用户看到旧版本
# 原因分析
# 1. HTML 设置了长期缓存 → 用户获取的是旧的 HTML
# 2. CDN 缓存未刷新 → CDN 返回旧版本
# 3. Service Worker 缓存未更新 → SW 返回旧版本
# 解决方案
# 1. HTML 设置 no-cache
# 2. 发布后刷新 CDN 缓存
aws cloudfront create-invalidation --distribution-id XXXXX --paths "/index.html"
# 3. 更新 Service Worker 版本
const CACHE_VERSION = 'v2.1.0'; // 修改版本号触发更新误区 3:CDN 和源站缓存不一致
# CDN 有自己的缓存策略,可能与源站不同步
# 解决:在 CDN 配置中设置合理的缓存策略
# 或在源站响应中设置 s-maxage 控制 CDN 缓存时间
Cache-Control: public, max-age=3600, s-maxage=600
# 浏览器缓存 1 小时,CDN 缓存 10 分钟CDN 缓存策略
CDN 缓存层级
请求流程:
浏览器 → CDN 边缘节点 → CDN 中间层 → 源站
缓存层级说明:
1. 浏览器缓存 — 用户本地(用户独享)
2. CDN 边缘缓存 — 离用户最近的节点(区域共享)
3. CDN 中间层缓存 — 区域中心节点(更大范围共享)
4. 源站缓存 — 服务器自身的缓存(全局共享)
缓存穿透场景:
CDN 未命中 → 回源获取 → 同时缓存到 CDN
关键:通过 s-maxage 单独控制 CDN 缓存时间CDN 缓存配置实战
# Nginx 作为源站的 CDN 响应头配置
server {
# 静态资源 — 浏览器缓存 1 年,CDN 缓存 7 天
location /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=604800, immutable";
add_header Vary "Accept-Encoding";
}
# API 接口 — 浏览器不缓存,CDN 短暂缓存
location /api/public/ {
add_header Cache-Control "public, max-age=0, s-maxage=10, must-revalidate";
add_header Vary "Accept-Encoding, Accept-Language";
}
# 私有 API — 所有层都不缓存
location /api/private/ {
add_header Cache-Control "private, no-store";
}
# 图片 — 使用协商缓存
location /uploads/ {
add_header Cache-Control "public, max-age=86400, s-maxage=86400";
etag on;
add_header Vary "Accept-Encoding";
}
}
# CDN 缓存刷新(以 CloudFlare 为例)
# curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
# -H "Authorization: Bearer {api_token}" \
# -d '{"files":["https://example.com/index.html"]}'缓存键(Cache Key)设计
CDN 缓存键决定哪些请求共享同一个缓存副本
默认缓存键 = URL(不含查询参数)
最佳实践:
1. 静态资源 — 用内容哈希文件名,查询参数无关紧要
2. API 响应 — 需要考虑 Vary 头中的所有变量
3. 多语言站点 — 将 Accept-Language 纳入缓存键
4. 压缩格式 — 将 Accept-Encoding 纳入缓存键
错误案例:
- 私有数据被 CDN 缓存(遗漏 private 指令)
- 不同语言版本共享缓存(未配置 Vary: Accept-Language)
- 移动端和桌面端返回不同内容但共享缓存(未区分 User-Agent)ASP.NET Core 服务端缓存配置
响应缓存中间件
// ASP.NET Core 服务端缓存配置
var builder = WebApplication.CreateBuilder(args);
// 添加响应缓存服务
builder.Services.AddResponseCaching();
// 添加输出缓存(.NET 7+)
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("NoCache", builder => builder.NoCache());
options.AddPolicy("Images", builder => builder
.Expire(TimeSpan.FromHours(1))
.Tag("images"));
});
var app = builder.Build();
app.UseResponseCaching();
app.UseOutputCache();
// 端点级缓存
app.MapGet("/api/products", (IProductService service) =>
{
return service.GetAll();
}).CacheOutput("Images");
// 禁用缓存
app.MapGet("/api/user/profile", (IUserService service) =>
{
return service.GetProfile();
}).CacheOutput("NoCache");ETag 自动生成
// 自定义 ETag 中间件
public class ETagMiddleware
{
private readonly RequestDelegate _next;
public ETagMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context)
{
var response = context.Response;
var originalBodyStream = response.Body;
using var memoryStream = new MemoryStream();
response.Body = memoryStream;
await _next(context);
// 仅对 GET 请求和成功响应生成 ETag
if (context.Request.Method == "GET" && response.StatusCode == 200)
{
memoryStream.Seek(0, SeekOrigin.Begin);
var content = memoryStream.ToArray();
var etag = "\"" + ComputeHash(content) + "\"";
if (context.Request.Headers.IfNoneMatch == etag)
{
response.StatusCode = 304;
return;
}
response.Headers.ETag = etag;
memoryStream.Seek(0, SeekOrigin.Begin);
}
await memoryStream.CopyToAsync(originalBodyStream);
}
private static string ComputeHash(byte[] data)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(data);
return Convert.ToHexString(hash)[..16]; // 取前 16 位
}
}HTTP/2 与 HTTP/3 缓存差异
HTTP/2 Server Push 与缓存
HTTP/2 Server Push 注意事项:
- Server Push 的资源同样遵循 HTTP 缓存规则
- 如果浏览器已有缓存,Push 会浪费带宽
- Chrome 已弃用 Server Push(2023 年)
- 推荐替代方案:rel="preload" 和 rel="prefetch"
rel="preload" — 提前加载关键资源
<link rel="preload" href="/assets/app.css" as="style">
<link rel="preload" href="/assets/app.js" as="script">
<link rel="preload" href="/fonts/roboto.woff2" as="font" crossorigin>
rel="prefetch" — 预取未来可能需要的资源
<link rel="prefetch" href="/assets/next-page.js">
<link rel="dns-prefetch" href="//api.example.com">
rel="modulepreload" — 预加载 ES 模块
<link rel="modulepreload" href="/assets/dep.js">Cache Storage API(前端代码控制缓存)
// 使用 Cache Storage API 精细控制缓存
const CACHE_NAME = 'app-v1';
// 预缓存关键资源
async function precacheResources() {
const cache = await caches.open(CACHE_NAME);
await cache.addAll([
'/',
'/index.html',
'/assets/app.css',
'/assets/app.js',
]);
}
// 自定义缓存策略 — 超时网络优先
async function networkFirstWithTimeout(request, timeout = 3000) {
const cache = await caches.open(CACHE_NAME);
try {
// 设置超时的网络请求
const networkPromise = fetch(request);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(reject, timeout)
);
const response = await Promise.race([networkPromise, timeoutPromise]);
cache.put(request, response.clone());
return response;
} catch {
// 超时或网络失败,使用缓存
const cached = await cache.match(request);
if (cached) return cached;
throw new Error('网络和缓存都不可用');
}
}
// 缓存容量管理
async function trimCache(maxEntries = 50) {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
if (keys.length > maxEntries) {
await cache.delete(keys[0]); // 删除最旧的
await trimCache(maxEntries); // 递归清理
}
}缓存安全注意事项
敏感数据缓存风险
安全规则:
1. 用户私有数据必须设置 private 或 no-store
2. 认证令牌、个人信息绝不缓存
3. 不同用户共享 CDN 时需特别小心
典型漏洞场景:
- API 返回用户数据但未设置 private
- CDN 缓存了带用户 Session 的页面
- 登录态响应被中间代理缓存
安全配置模板:
# 认证相关
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Expires: 0
# 公开只读数据
Cache-Control: public, max-age=300, s-maxage=60
# 用户私有数据
Cache-Control: private, max-age=0, must-revalidate排错清单
- 用 Chrome DevTools Network 面板检查每个请求的缓存头
- 确认 Size 列显示
(disk cache)或(memory cache)表示命中缓存 - 确认状态码 304 表示协商缓存命中
- 确认强缓存资源是否带哈希文件名
- 检查 CDN 缓存是否与源站一致
- 勾选 DevTools Network 面板的 "Disable cache" 测试无缓存行为
最佳实践总结
- 静态资源长期缓存 — 文件名哈希 +
max-age=1y, immutable - HTML 不缓存 —
no-cache, no-store, must-revalidate - API 按需缓存 — GET 请求可设短期缓存,POST/PUT 不缓存
- CDN 配置 s-maxage — 分别控制浏览器和 CDN 的缓存时间
- 发布后刷新 CDN — 确保 HTML 入口及时更新
- Service Worker 版本管理 — 更新版本号触发缓存清理
