HTTP 缓存与性能优化:强缓存、协商缓存与 CDN 联动

深入讲解 HTTP 缓存体系,包括强缓存与协商缓存机制、Cache-Control 指令全解、私有与共享缓存区分、浏览器到源站的多层缓存架构,以及前端工程中的缓存失效与版本控制策略。

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 强缓存生效的条件

强缓存命中要求同时满足:

  1. 响应携带 Cache-Control 且未超过 max-age(或 Expires 未到);
  2. 没有 no-cache / no-store 指令;
  3. 缓存副本仍在存储中未被淘汰。
# 模拟强缓存判定逻辑(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-ModifiedETag
精度秒级,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.htmlno-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 → 网关 → 源站」四层的联动刷新。推荐发布流水线:

  1. 先刷新 CDN:Purge 掉所有 index.html 与不再引用的旧指纹资源;
  2. 再发布源站:新版本静态资源与 HTML 上线;
  3. 最后等待收敛:浏览器侧用户刷新页面即拿到新 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-ModifiedHTML、动态 API
私有缓存仅本用户private个人资料、购物车
共享缓存多用户复用public + s-maxageCDN、网关层
内容寻址URL 指纹immutable + 长 max-ageJS/CSS 构建产物

HTTP 缓存不是单一开关,而是一套分层、分级、按资源语义决策的体系。正确的做法是:对带指纹的不可变资源用「极长强缓存」,对入口 HTML 用「协商缓存」,对动态数据用「短 TTL + 条件请求」,再用 CDN 的 Purge 能力做主动失效。把这套策略落实到位,往往是最低成本、最高回报的性能优化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「network」更多文章

  1. 云原生网络:CNI 容器网络、Overlay/Underlay 与 Service Mesh 数据面
  2. SSE 与实时通信方案:Server-Sent Events、WebSocket 对比与选型实战
  3. 网络故障排查实战:tcpdump/Wireshark/ss/iperf 工具链与分层诊断方法论