土法炼钢兴趣小组的算法知识备份

【网络工程】HTTP 缓存工程:Cache-Control 全景与条件请求

文章导航

分类入口
network
标签入口
#http-caching#cache-control#etag#cdn#conditional-requests

目录

HTTP 缓存是 Web 性能优化的基石。一个正确的缓存策略可以将页面加载时间从秒级降到毫秒级,将服务端负载降低 80%+。但缓存也是 Web 工程中最容易出错的领域之一——缓存失效、缓存击穿、过期数据、CDN 与浏览器缓存不一致——每个问题都可能导致用户看到错误内容或服务过载。

本文从工程师视角系统剖析 HTTP 缓存体系,覆盖 Cache-Control 指令全表、条件请求机制、CDN 缓存行为和实际故障排查。

一、HTTP 缓存的分层架构

1.1 缓存存储位置

HTTP 缓存分布在从客户端到服务端的多个层级:

用户浏览器
│
├── 内存缓存(Memory Cache)
│   └── 当前标签页中已加载的资源(关闭标签页后清除)
│
├── 磁盘缓存(Disk Cache)
│   └── 持久化到磁盘的缓存(跨会话保留)
│
├── Service Worker 缓存
│   └── 由 Service Worker 脚本控制的缓存(完全可编程)
│
└── HTTP 缓存(Shared/Private Cache)
    │
    ├── 代理缓存(Shared Cache)
    │   ├── CDN 边缘节点
    │   ├── 企业代理服务器
    │   └── 反向代理(Nginx/Varnish)
    │
    └── 网关缓存(Origin Shield)
        └── CDN 的中间层缓存

每一层缓存的行为由不同的 HTTP 头字段控制。理解这些控制机制是缓存工程的核心。

1.2 缓存命中的判定流程

flowchart TD
    A[收到请求] --> B{缓存中有匹配的响应?}
    B -- 否 --> C[转发到上游/源站]
    B -- 是 --> D{响应是否新鲜?}
    D -- 是 --> E[直接返回缓存响应<br/>Status: 200 from cache]
    D -- 否 --> F{响应有验证器?<br/>ETag / Last-Modified}
    F -- 否 --> C
    F -- 是 --> G[发送条件请求<br/>If-None-Match / If-Modified-Since]
    G --> H{源站返回 304?}
    H -- 是 --> I[更新缓存元数据<br/>返回缓存响应]
    H -- 否 --> J[用新响应更新缓存<br/>返回新响应]

1.3 缓存键(Cache Key)

缓存使用”缓存键”来匹配请求。默认的缓存键通常是:

Cache Key = HTTP Method + Effective Request URI

# 示例:
GET https://api.example.com/users?page=1&limit=10
Cache Key = "GET https://api.example.com/users?page=1&limit=10"

# 注意:Query String 的顺序会影响缓存命中
GET /users?page=1&limit=10    # 缓存键 A
GET /users?limit=10&page=1    # 缓存键 B(与 A 不同!)

CDN 通常允许自定义缓存键规则:

# Cloudflare 缓存键自定义示例
# 忽略 Query String 顺序
# 排除特定参数(如跟踪参数)
# 加入特定头字段(如 Accept-Language)

二、Cache-Control 指令全景

Cache-Control 是 HTTP 缓存的核心控制头字段。它定义了丰富的指令集,分为请求指令和响应指令。

2.1 响应指令(Response Directives)

# ── 缓存能力 ──

# public: 响应可以被任何缓存存储(包括 CDN 等共享缓存)
Cache-Control: public, max-age=3600
# 通常用于静态资源。注意:即使没有 public,只要响应有明确的
# max-age 或 Expires,共享缓存也会存储

# private: 响应只能被浏览器缓存,不能被 CDN/代理缓存
Cache-Control: private, max-age=600
# 用于包含用户个人数据的响应

# no-cache: 缓存可以存储,但每次使用前必须向源站验证
Cache-Control: no-cache
# 不是"不缓存"!是"必须重新验证"

# no-store: 禁止缓存存储响应(真正的"不缓存")
Cache-Control: no-store
# 用于极度敏感的数据(银行交易、一次性密码等)

# ── 过期控制 ──

# max-age: 响应的最大新鲜时间(秒)
Cache-Control: max-age=86400
# 从响应生成时算起,86400 秒(1 天)内视为新鲜

# s-maxage: 共享缓存(CDN)的最大新鲜时间(覆盖 max-age)
Cache-Control: max-age=60, s-maxage=3600
# 浏览器缓存 60 秒,CDN 缓存 3600 秒
# 常用于频繁变化但可以被 CDN 缓存更久的内容

# ── 验证控制 ──

# must-revalidate: 过期后必须重新验证,不能使用过期缓存
Cache-Control: max-age=3600, must-revalidate
# 与 no-cache 的区别:max-age 期间内可以直接使用缓存
# 过期后必须验证,如果源站不可达则返回 504 而非过期缓存

# proxy-revalidate: 同 must-revalidate,但只作用于共享缓存
Cache-Control: max-age=3600, proxy-revalidate

# ── 高级指令 ──

# immutable: 告诉客户端,在 max-age 期间内资源不会变化
Cache-Control: max-age=31536000, immutable
# 浏览器不会在页面刷新时发送条件请求
# 用于带 hash 的静态资源:/app.abc123.js

# stale-while-revalidate: 过期后仍可使用旧缓存,同时后台重新验证
Cache-Control: max-age=60, stale-while-revalidate=30
# 60 秒内:直接使用缓存
# 60-90 秒:使用旧缓存 + 后台向源站请求新响应
# 90 秒后:必须等待源站响应

# stale-if-error: 如果源站出错,可以使用过期缓存
Cache-Control: max-age=60, stale-if-error=86400
# 60 秒内:使用缓存
# 60 秒后:尝试验证,如果源站返回 5xx 或不可达,继续使用旧缓存(最多 1 天)
# 对可用性非常重要——源站故障时 CDN 不会把错误传给用户

# no-transform: 禁止中间代理转换响应(如压缩图片、转码视频)
Cache-Control: no-transform
# 某些运营商代理会自动压缩图片以节省带宽,这会导致图片质量下降

2.2 请求指令(Request Directives)

# 客户端可以在请求中使用 Cache-Control 指令来约束缓存行为

# no-cache: 要求缓存重新验证(即使缓存是新鲜的)
# 浏览器 Ctrl+Shift+R(强制刷新)发送
Cache-Control: no-cache

# no-store: 要求不使用缓存
Cache-Control: no-store

# max-age: 客户端可接受的最大缓存年龄
Cache-Control: max-age=0
# max-age=0 等价于 no-cache(要求重新验证)

# max-stale: 客户端愿意接受过期多久的缓存
Cache-Control: max-stale=300
# 可以使用过期不超过 300 秒的缓存

# min-fresh: 客户端要求响应至少还有多少秒的新鲜时间
Cache-Control: min-fresh=120
# 只接受至少还有 120 秒新鲜时间的缓存

# only-if-cached: 只接受缓存响应,不去源站
Cache-Control: only-if-cached
# 如果没有缓存命中,返回 504 Gateway Timeout

2.3 no-cache vs no-store:最常见的误解

行为 no-cache no-store
缓存存储响应 ✅ 允许 ❌ 禁止
使用前验证 ✅ 每次都验证 N/A(不存储)
离线可用 ✅ 如果验证失败可用旧缓存 ❌ 无缓存可用
适用场景 经常变化但可缓存的数据 敏感数据、一次性数据
浏览器行为 发条件请求(304 可能很快) 每次都获取完整响应
# 验证 no-cache 和 no-store 的区别
# no-cache: 浏览器发送条件请求,可能收到 304(无响应体)
curl -v -H "If-None-Match: \"abc123\"" https://example.com/api/data
# 如果数据未变:HTTP/2 304(几十字节)
# 如果数据已变:HTTP/2 200 + 完整响应体

# no-store: 每次都获取完整响应
curl -v https://example.com/api/sensitive-data
# 始终 HTTP/2 200 + 完整响应体

三、条件请求:ETag 与 Last-Modified

3.1 ETag(实体标签)

ETag 是响应内容的唯一标识符。当内容变化时,ETag 也会变化。

# 服务端响应携带 ETag
HTTP/2 200 OK
ETag: "a1b2c3d4e5f6"
Cache-Control: no-cache
Content-Type: application/json

{"users": [...]}

# 客户端后续请求携带 If-None-Match
GET /api/users HTTP/2
If-None-Match: "a1b2c3d4e5f6"

# 如果内容未变:
HTTP/2 304 Not Modified
ETag: "a1b2c3d4e5f6"
# 无响应体,节省带宽

# 如果内容已变:
HTTP/2 200 OK
ETag: "x7y8z9w0a1b2"
Content-Type: application/json

{"users": [...]}  # 新数据

强 ETag vs 弱 ETag

# 强 ETag:字节级精确匹配(默认)
ETag: "a1b2c3d4e5f6"
# 响应体的每一个字节都相同

# 弱 ETag:语义等价(前缀 W/)
ETag: W/"v2.6"
# 内容在语义上等价,但可能有细微差异
# 例如:HTML 中的空格/换行不同,但渲染结果相同
# 弱 ETag 不能用于范围请求(Range Request)

3.2 Last-Modified

Last-Modified 基于时间戳验证:

# 服务端响应携带 Last-Modified
HTTP/2 200 OK
Last-Modified: Thu, 24 Jul 2025 10:30:00 GMT
Cache-Control: max-age=3600

# 缓存过期后,客户端发送条件请求
GET /style.css HTTP/2
If-Modified-Since: Thu, 24 Jul 2025 10:30:00 GMT

# 如果未修改:
HTTP/2 304 Not Modified

# 如果已修改:
HTTP/2 200 OK
Last-Modified: Thu, 24 Jul 2025 15:45:00 GMT
Content-Type: text/css
...

3.3 ETag vs Last-Modified 对比

特性 ETag Last-Modified
精度 内容级别(精确) 秒级别(最小粒度 1 秒)
1 秒内多次修改 ✅ 能区分 ❌ 不能区分
负载均衡场景 ⚠️ 多台服务器需要生成一致的 ETag ⚠️ 需要时钟同步
性能开销 需要计算哈希或维护版本号 文件系统 mtime 直接可用
CDN 支持 完整支持 完整支持
优先级 ✅ 高(同时存在时优先使用)
# Nginx: 默认同时生成 ETag 和 Last-Modified
# ETag 基于文件大小和 mtime 计算
etag on;            # 默认开启

# 注意:Nginx 默认的 ETag 是弱比较
# ETag: "66a1b2c3-1f40"
# 格式:mtime(十六进制)- size(十六进制)

3.4 ETag 的工程陷阱

多服务器 ETag 不一致

# 如果 ETag 基于文件 inode 生成,不同服务器的 ETag 会不同
Server A: ETag: "abc-123-456"    # inode-size-mtime
Server B: ETag: "def-123-456"    # 不同 inode

# 用户先访问 Server A,缓存 ETag "abc-123-456"
# 下次请求到 Server B,If-None-Match: "abc-123-456" 匹配失败
# 本该 304 的请求变成了 200 → 缓存失效

# 解决方案:
# 1. Nginx 的 ETag 基于 mtime + size,不含 inode(默认安全)
# 2. Apache 需要配置:FileETag MTime Size(去掉 INode)
# 3. 使用内容哈希作为 ETag(最可靠)

gzip 压缩与 ETag 冲突

# Nginx 对动态压缩的响应会自动移除强 ETag
# 因为压缩后的字节不同,强 ETag 不再有效

# 解决方案 1:使用弱 ETag
# Nginx 会自动将 "abc" 转为 W/"abc" 当启用 gzip 时

# 解决方案 2:预压缩静态文件
gzip_static on;
# 预先生成 .gz 文件,ETag 基于 .gz 文件计算,保持一致

四、Vary 头:缓存的第三维度

4.1 Vary 头的工作原理

Vary 头将额外的请求头字段加入缓存键,使同一个 URL 可以有多个缓存副本:

# 服务端响应:内容根据 Accept-Encoding 变化
HTTP/2 200 OK
Vary: Accept-Encoding
Content-Encoding: gzip
Cache-Control: public, max-age=3600

# 缓存存储:
# Cache Key 1: GET /style.css + Accept-Encoding: gzip → gzip 版本
# Cache Key 2: GET /style.css + Accept-Encoding: br   → brotli 版本
# Cache Key 3: GET /style.css + Accept-Encoding: identity → 原始版本

# 每个 Accept-Encoding 变体独立缓存

4.2 Vary 头的工程陷阱

陷阱 1:Vary: User-Agent 导致缓存碎片化

# 错误:按 User-Agent 变化响应
Vary: User-Agent

# 问题:User-Agent 有数千种不同的值
# 每种 User-Agent 生成一个独立的缓存条目
# 缓存命中率趋近于零

# 正确做法:在服务端将 User-Agent 归类
# 然后使用自定义头字段:
Vary: X-Device-Type  # 值只有: mobile, tablet, desktop

陷阱 2:Vary: Cookie 使缓存失效

# 问题:Cookie 值因用户而异
Vary: Cookie

# 效果:每个用户都有独立的缓存副本
# 共享缓存(CDN)的命中率为零
# 退化为 private 缓存

# 解决方案:如果响应确实依赖 Cookie,应该用 private
Cache-Control: private, max-age=600
# 而不是 Vary: Cookie + public

陷阱 3:遗漏 Vary 头导致内容错误

# 场景:API 根据 Accept-Language 返回不同语言
# 但没有设置 Vary: Accept-Language

GET /api/greeting HTTP/2
Accept-Language: zh-CN
→ 200: {"msg": "你好"}     # CDN 缓存了中文版本

GET /api/greeting HTTP/2
Accept-Language: en-US
→ 200: {"msg": "你好"}     # CDN 返回了中文版本!

# 修复:
Vary: Accept-Language

4.3 Vary 与 CDN

不同 CDN 对 Vary 头的支持程度不同:

CDN Vary: Accept-Encoding Vary: 其他头字段 最大变体数
Cloudflare ✅ 完整支持 ⚠️ 需要 Enterprise 计划 有限
Fastly ✅ 完整支持 ✅ 完整支持 可配置
AWS CloudFront ✅ 完整支持 ✅ 需要配置白名单 有限
Akamai ✅ 完整支持 ✅ 完整支持 可配置
# 检查响应的 Vary 头
curl -I https://example.com/style.css 2>/dev/null | grep -i vary
# Vary: Accept-Encoding

五、Expires 与 Pragma(历史遗留)

5.1 Expires 头

Expires 是 HTTP/1.0 的缓存控制头字段,指定响应过期的绝对时间:

# Expires 使用 HTTP-date 格式
Expires: Thu, 01 Jan 2026 00:00:00 GMT

# 问题:
# 1. 依赖客户端时钟准确性(客户端时钟偏差会导致缓存行为异常)
# 2. 无法表达相对时间(如"缓存 1 小时")
# 3. 日期格式解析复杂

# Cache-Control: max-age 覆盖 Expires
# 当两者同时存在时,max-age 优先
Cache-Control: max-age=3600
Expires: Thu, 01 Jan 2026 00:00:00 GMT
# → max-age=3600 生效,Expires 被忽略

5.2 Pragma: no-cache

# HTTP/1.0 的缓存控制(不建议在新代码中使用)
Pragma: no-cache

# 等价于 Cache-Control: no-cache
# 仅用于向后兼容 HTTP/1.0 代理
# 现代系统应该只使用 Cache-Control

六、缓存策略设计

6.1 常见资源的缓存策略

# Nginx 缓存配置示例

# HTML 文档:不缓存或短缓存
location ~* \.html$ {
    add_header Cache-Control "no-cache";
    # 每次访问都验证,确保用户看到最新内容
    # 但如果内容未变,返回 304(几十字节)
}

# 带 hash 的静态资源:长期缓存
location ~* \.(js|css)$ {
    # 文件名包含内容哈希(如 app.abc123.js)
    add_header Cache-Control "public, max-age=31536000, immutable";
    # 1 年缓存 + immutable(浏览器刷新也不重新验证)
    # 文件内容变化时,hash 变化 → URL 变化 → 自动获取新版本
}

# 图片:中等缓存
location ~* \.(png|jpg|gif|webp|svg)$ {
    add_header Cache-Control "public, max-age=86400";
    # 1 天缓存
}

# API 响应:按业务需求
location /api/ {
    # 用户个人数据:私有缓存
    add_header Cache-Control "private, no-cache";
    
    # 公共只读数据(如配置):短期共享缓存
    # add_header Cache-Control "public, max-age=60, s-maxage=300";
}

# 字体文件:长期缓存 + CORS
location ~* \.(woff2?|ttf|eot)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
}

6.2 不可变资源策略(Content-Addressed)

现代前端构建工具(Webpack、Vite、esbuild)生成带内容哈希的文件名,这是最可靠的缓存策略:

构建输出:
dist/
├── index.html           ← no-cache(入口)
├── assets/
│   ├── app.a1b2c3.js    ← immutable, 1年(hash 变则 URL 变)
│   ├── vendor.d4e5f6.js ← immutable, 1年
│   ├── style.g7h8i9.css ← immutable, 1年
│   └── logo.j0k1l2.png  ← immutable, 1年
└── favicon.ico          ← max-age=86400

缓存更新流程:
1. 用户访问 index.html(no-cache → 每次验证)
2. index.html 引用 app.a1b2c3.js(缓存命中 → 直接使用)
3. 代码更新后,构建生成 app.x7y8z9.js(新 hash)
4. 新的 index.html 引用 app.x7y8z9.js
5. 用户刷新 → 获取新 index.html → 请求新的 app.x7y8z9.js
6. 旧的 app.a1b2c3.js 自然过期(或被 CDN purge)

6.3 stale-while-revalidate 策略

# 适用于可以容忍短暂过期的场景(新闻列表、商品信息等)
Cache-Control: public, max-age=60, stale-while-revalidate=300

# 时间线:
# 0-60秒:   使用缓存(新鲜)
# 60-360秒: 使用旧缓存 + 后台异步更新
#            用户不会感知到任何延迟
# 360秒后:  必须等待源站响应

# 结合 stale-if-error 提升可用性
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
# 即使源站故障,也能用旧缓存顶住最多 1 天

七、CDN 缓存工程

7.1 CDN 缓存层级

用户请求 → CDN 边缘 PoP → CDN Origin Shield → 源站
                │                    │
           L1 Cache            L2 Cache
         (每个 PoP 独立)    (全局共享)

L1 Cache(边缘):
  - 最接近用户
  - 每个 PoP 独立维护
  - 缓存命中率取决于 PoP 流量
  - 容量较小

L2 Cache(Origin Shield):
  - 源站前的集中缓存
  - 减少回源请求
  - 更高的缓存命中率
  - 所有边缘节点的缓存未命中汇聚到这里

7.2 CDN 缓存行为与头字段

# CDN 响应头解读

# X-Cache: 缓存命中状态
X-Cache: Hit from cloudfront       # 缓存命中
X-Cache: Miss from cloudfront      # 缓存未命中(回源)
X-Cache: RefreshHit from cloudfront # 重新验证后命中

# Age: 响应在缓存中存在的时间(秒)
Age: 1234
# 含义:这个响应在缓存中已存在 1234 秒
# 如果 max-age=3600,则剩余新鲜时间 = 3600 - 1234 = 2366 秒

# Via: 经过的代理
Via: 1.1 abc123.cloudfront.net (CloudFront)

# X-Cache-Hits: 缓存被命中的次数(Varnish)
X-Cache-Hits: 42

# CF-Cache-Status: Cloudflare 缓存状态
CF-Cache-Status: HIT      # 缓存命中
CF-Cache-Status: MISS     # 未命中
CF-Cache-Status: EXPIRED  # 已过期
CF-Cache-Status: BYPASS   # 跳过缓存(如有 Cookie)
CF-Cache-Status: DYNAMIC  # 动态内容,不缓存
# 检查 CDN 缓存行为
curl -sI https://example.com/style.css | grep -iE "cache-control|age|x-cache|cf-cache"
# Cache-Control: public, max-age=86400
# Age: 3421
# X-Cache: Hit from cloudfront

# 对比不同 PoP 的缓存状态
curl -sI --resolve example.com:443:1.2.3.4 https://example.com/style.css | grep x-cache
curl -sI --resolve example.com:443:5.6.7.8 https://example.com/style.css | grep x-cache

7.3 CDN 缓存清除(Purge)

# Cloudflare: 清除特定 URL 的缓存
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
     -H "Authorization: Bearer {api_token}" \
     -H "Content-Type: application/json" \
     --data '{"files":["https://example.com/style.css"]}'

# Cloudflare: 清除所有缓存
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
     -H "Authorization: Bearer {api_token}" \
     -H "Content-Type: application/json" \
     --data '{"purge_everything":true}'

# Fastly: 基于 Surrogate-Key 清除(更精确)
# 响应头: Surrogate-Key: product-123 category-shoes
curl -X POST "https://api.fastly.com/service/{service_id}/purge/product-123" \
     -H "Fastly-Key: {api_key}"
# 只清除带有 Surrogate-Key: product-123 的缓存条目

7.4 s-maxage 与 CDN 控制

# 通过 s-maxage 对浏览器和 CDN 设置不同的缓存时间
Cache-Control: public, max-age=0, s-maxage=86400

# 效果:
# 浏览器:max-age=0 → 每次都验证(发条件请求)
# CDN:s-maxage=86400 → 缓存 1 天
# 好处:CDN 吸收大部分流量,用户总能验证到最新内容

# CDN 专用指令(非标准,但广泛支持)
# Cloudflare:
CDN-Cache-Control: max-age=86400
# Surrogate-Control(Varnish/Fastly):
Surrogate-Control: max-age=86400

八、缓存实战:常见问题与排查

8.1 缓存未命中排查清单

# 1. 检查响应头
curl -sI https://example.com/resource | grep -iE "cache-control|vary|set-cookie|pragma"

# 常见未命中原因:
# ❌ Cache-Control: no-store(禁止缓存)
# ❌ Cache-Control: private(CDN 不缓存)
# ❌ Set-Cookie 存在(大多数 CDN 不缓存带 Set-Cookie 的响应)
# ❌ Vary: Cookie(每个用户独立缓存,命中率极低)
# ❌ Authorization 头(共享缓存不缓存需要认证的响应)

# 2. 检查 Query String
# /page?v=1 和 /page?v=2 是不同的缓存键
# 随机 Query String(如跟踪参数)会导致缓存碎片化

# 3. 检查 CDN 配置
# 是否缓存了正确的文件类型?
# Query String 排序是否规范化?
# Cookie 是否从缓存键中排除?

8.2 缓存击穿(Cache Stampede)

缓存击穿发生在热点缓存过期的瞬间,大量请求同时穿透到源站:

T=0:  缓存有效,100 QPS 全部命中
T=60: 缓存过期
T=60.001: 100 个请求同时发现缓存过期 → 100 个回源请求
→ 源站瞬时负载 100x

防御策略:

1. stale-while-revalidate(最简单)
   Cache-Control: max-age=60, stale-while-revalidate=300
   过期后第一个请求触发后台更新,其他请求继续用旧缓存

2. 加锁回源(CDN 层面)
   # Nginx proxy_cache_lock
   proxy_cache_lock on;
   proxy_cache_lock_timeout 5s;
   # 同一个缓存键只有第一个请求回源,其他等待

3. 提前续期(应用层面)
   # 在缓存即将过期时主动更新
   TTL remaining < 10%: 后台 goroutine 异步更新缓存

8.3 缓存穿透

缓存穿透是指查询不存在的数据,缓存永远无法命中,每次都打到数据库:

请求: GET /api/users/9999999999(不存在的用户 ID)
→ 缓存未命中 → 数据库查询 → 空结果 → 不缓存 → 下次继续穿透

防御策略:

1. 缓存空结果
   Cache-Control: public, max-age=60
   # 即使是 404,也缓存 60 秒

2. Bloom Filter 前置过滤
   # 用 Bloom Filter 判断 ID 是否可能存在
   # 如果 Bloom Filter 说"不存在",直接返回 404

3. 请求校验
   # 在缓存层之前验证 ID 格式
   # 无效格式直接拒绝

8.4 调试工具

# 完整的缓存调试流程

# 1. 查看所有缓存相关头字段
curl -sI https://example.com/style.css | \
    grep -iE "^(cache-control|expires|etag|last-modified|age|vary|x-cache|cf-cache|pragma|set-cookie):"

# 2. 测试条件请求
ETAG=$(curl -sI https://example.com/style.css | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -v -H "If-None-Match: $ETAG" https://example.com/style.css 2>&1 | head -20
# 期望看到 304 Not Modified

# 3. 测试 Vary 行为
curl -sI -H "Accept-Encoding: gzip" https://example.com/style.css | grep -i "vary\|content-encoding"
curl -sI -H "Accept-Encoding: br" https://example.com/style.css | grep -i "vary\|content-encoding"

# 4. Chrome DevTools 缓存分析
# Network 面板 → 右键列头 → 勾选 "Cache-Control", "ETag", "Size"
# Size 列显示 "(memory cache)" 或 "(disk cache)" 表示缓存命中
# Size 列显示实际大小表示网络请求

九、Nginx 代理缓存(proxy_cache)

9.1 基础配置

# 定义缓存区域
proxy_cache_path /var/cache/nginx/api
    levels=1:2                    # 目录层级(避免单目录文件过多)
    keys_zone=api_cache:100m      # 共享内存区域(存缓存键和元数据)
    max_size=10g                  # 最大磁盘占用
    inactive=60m                  # 60 分钟无访问则清除
    use_temp_path=off;            # 直接写入缓存目录(避免跨设备移动)

server {
    location /api/ {
        proxy_pass http://backend;
        
        # 启用缓存
        proxy_cache api_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        
        # 缓存有效期(如果上游没有设置 Cache-Control)
        proxy_cache_valid 200 302 10m;  # 200/302 缓存 10 分钟
        proxy_cache_valid 404     1m;   # 404 缓存 1 分钟
        proxy_cache_valid any     0;    # 其他不缓存
        
        # 添加缓存状态头(调试用)
        add_header X-Cache-Status $upstream_cache_status;
        # 值:HIT / MISS / EXPIRED / STALE / BYPASS / REVALIDATED
        
        # 防止缓存击穿
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
        proxy_cache_lock_age 5s;
        
        # 源站故障时使用过期缓存
        proxy_cache_use_stale error timeout updating
                              http_500 http_502 http_503 http_504;
        
        # 后台更新
        proxy_cache_background_update on;
    }
}

9.2 选择性缓存绕过

# 根据条件跳过缓存
# 登录用户不缓存
map $cookie_session $skip_cache {
    ""      0;          # 无 session cookie → 使用缓存
    default 1;          # 有 session cookie → 跳过缓存
}

# POST/PUT/DELETE 不缓存
map $request_method $skip_method {
    GET     0;
    HEAD    0;
    default 1;
}

server {
    location /api/ {
        proxy_cache api_cache;
        proxy_cache_bypass $skip_cache $skip_method;
        proxy_no_cache $skip_cache $skip_method;
    }
}

十、缓存策略决策树

面对一个具体的资源,如何决定缓存策略?以下决策树覆盖了大部分场景:

资源是否包含敏感/用户个人数据?
├── 是 → 是否需要缓存?
│   ├── 否 → Cache-Control: no-store
│   └── 是 → Cache-Control: private, no-cache
│          (浏览器可缓存,每次验证)
│
└── 否 → 资源是否会变化?
    ├── 从不变化(带 hash 的静态资源)
    │   └── Cache-Control: public, max-age=31536000, immutable
    │
    ├── 很少变化(logo、字体)
    │   └── Cache-Control: public, max-age=86400
    │       + ETag/Last-Modified 用于验证
    │
    ├── 定期变化(API 数据、新闻列表)
    │   └── Cache-Control: public, max-age=60, s-maxage=300,
    │       stale-while-revalidate=120, stale-if-error=86400
    │
    └── 实时性要求高(股票价格、聊天消息)
        └── Cache-Control: no-cache
            + ETag 用于条件验证(减少带宽)

十一、总结

HTTP 缓存工程的核心挑战不在于理解单个指令的含义,而在于为每种资源选择正确的策略组合,并在以下维度之间取得平衡:

  1. 新鲜度 vs 性能:缓存时间越长,性能越好,但用户看到过期数据的风险越大。stale-while-revalidate 在两者之间找到了优雅的折中。

  2. 缓存命中率 vs 正确性Vary 头越多,缓存变体越多,命中率越低。不合理的 Vary 配置(如 Vary: User-Agent)可以将缓存命中率降到接近零。

  3. CDN vs 浏览器:通过 s-maxageprivate/public 区分两层缓存的行为。CDN 层可以更激进(长 TTL + 主动 purge),浏览器层应该更保守。

  4. 可用性 vs 一致性stale-if-error 允许在源站故障时继续服务旧内容,牺牲了一致性,但保住了可用性。在大多数场景下,这是正确的权衡。

最后一个建议:在设计缓存策略之前,先用 curl -sI 检查你的服务现在返回了什么缓存头字段。很多缓存问题的根源是”以为设置了,其实没有”或”设置了,但被中间代理改掉了”。


参考文献


上一篇:HTTP/2 完整解剖:流、帧、HPACK 与 Server Push

下一篇:Cookie 与 Session 工程:安全属性与现代限制

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · network

网络工程索引

汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。


By .