HTTP 缓存与条件请求

系统讲解 HTTP 缓存机制:浏览器/代理/CDN 三层缓存、Cache-Control 各指令(max-age/s-maxage/no-store/no-cache/private/immutable)、强缓存与协商缓存的区别、ETag 与 Last-Modified 的生成与比较、条件请求与 304、Vary 与内容协商陷阱、stale-while-revalidate 与资源指纹化策略。

引言

HTTP 缓存是性能优化里「性价比最高、也最容易被搞错」的一环。一个 Cache-Control 头写错,轻则用户看到旧页面,重则 CDN 缓存了用户私有数据造成越权。它同时横跨协议语义(哪些响应可缓存、如何验证新鲜度)与工程实践(指纹化、分层缓存、失效策略)。

本文从缓存的三个层级讲起,逐条拆解 Cache-Control 指令,讲透强缓存与协商缓存的配合、ETag 与条件请求的往返、Vary 的陷阱,最后落到资源指纹化与 CDN 实战。目标是让你能针对每一类资源,写出精确的缓存头。

相关:HTTP 状态码与请求语义 、curl 与 HTTP 调试实战 。CDN 与代理层落地见 nginx 缓存与压缩 与 network 专题 。

1. 缓存的三个层级

1.1 谁在缓存

层级位置典型代表缓存键
浏览器缓存客户端Chrome、SafariURL + Vary
共享代理中间企业代理、SquidURL + Vary
CDN 边缘网络边缘Cloudflare、AkamaiURL + Vary

私有缓存(private) 只服务单个用户(浏览器),可存用户专属响应;共享缓存(shared) 服务多个用户(CDN、代理),绝不能存带用户信息的响应。这个区分是所有缓存安全的基石。

1.2 缓存决策流程

收到响应
  → 该响应可缓存吗?(方法、状态码、Cache-Control)
    → 是:存入缓存,标记新鲜度
请求到达
  → 缓存中有新鲜副本吗?
    → 有:直接用(不发请求)
    → 没有(陈旧):发条件请求验证
      → 304:用副本
      → 200:更新副本

2. 强缓存:Cache-Control

2.1 新鲜度指令

Cache-Control 是缓存控制的唯一权威,优先级高于 Expires。

Cache-Control: max-age=31536000, immutable
指令含义适用
max-age=NN 秒内视为新鲜(私有/共享通用)通用
s-maxage=N仅对共享缓存生效,覆盖 max-ageCDN
no-cache可存但每次必须验证需实时校验
no-store完全不缓存敏感数据
private仅私有缓存可存用户数据
public共享缓存也可存公开资源
must-revalidate陈旧后禁止使用,必须验证严格一致
immutable新鲜期内绝不验证指纹化静态资源
stale-while-revalidate=N陈旧后 N 秒内先用旧值,后台更新性能优先
stale-if-error=N源站错误时可用陈旧副本 N 秒可用性

2.2 no-cache vs no-store:最常混淆的一对

  • no-cache:可以缓存,但每次使用前必须向服务器验证(发条件请求)。名字有误导性,它其实是「必须验证」。
  • no-store:完全禁止缓存,任何副本都不留。用于密码、token、支付信息。
# 错误:以为 no-cache 是不缓存,用它保护敏感数据
Cache-Control: no-cache

# 正确:敏感数据必须 no-store
Cache-Control: no-store, private

2.3 Expires 与 Cache-Control 的关系

Expires 是 HTTP/1.0 的绝对时间,Cache-Control: max-age 是相对时间。两者同时存在时 max-age 优先。只用 Cache-Control 即可,Expires 主要用于兼容极老客户端。

# 兼容写法
Cache-Control: max-age=3600
Expires: Wed, 21 Oct 2026 07:28:00 GMT

Expires 依赖客户端时钟,时钟不准会失效;max-age 是相对的,更可靠。

3. 协商缓存:ETag 与 Last-Modified

强缓存过期后,不一定要重新下载——如果内容没变,服务器只需回一个 304 Not Modified。

3.1 Last-Modified / If-Modified-Since

基于文件的最后修改时间:

# 首次响应
Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT

# 后续请求
If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT

# 未变则响应
HTTP/1.1 304 Not Modified

局限:

  • 精度只到秒:一秒内多次修改无法区分。
  • 时间不可靠:文件被重新生成但内容不变,时间会变。
  • 分布式不一致:多台服务器的文件时间可能不同。

3.2 ETag / If-None-Match

ETag 是内容的指纹(通常是哈希):

# 首次响应
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 后续请求
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 未变
HTTP/1.1 304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
类型形式特点
强 ETag"abc123"字节级完全一致才匹配
弱 ETagW/"abc123"语义等价即可(忽略细微差异)

优先级:If-None-Match 优先于 If-Modified-Since。两者都发时,服务器应以 ETag 为准。

3.3 如何生成 ETag

import hashlib
from flask import Flask, request, Response

app = Flask(__name__)

@app.route("/data")
def data():
    body = render_content()               # 生成响应体
    etag = '"%s"' % hashlib.md5(body.encode()).hexdigest()
    if request.headers.get("If-None-Match") == etag:
        return Response(status=304, headers={"ETag": etag})
    return Response(body, headers={
        "ETag": etag,
        "Cache-Control": "no-cache",      # 可存,但每次验证
    })

注意:ETag 常被加引号包裹,比较时必须包含引号。W/ 前缀表示弱验证。

3.4 nginx 的自动 ETag

nginx 默认对静态文件生成基于 mtime + size 的弱 ETag:

# 关闭自动 ETag(若用自己计算的强 ETag)
etag off;

# 或保留,让 nginx 处理
# etag on;  # 默认

nginx 的 ETag 由文件元数据生成,多机部署时若文件时间不一致会导致 ETag 不一致——多机场景建议改用内容哈希生成 ETag。更细的多层缓存配置见 nginx 多级缓存 。

4. 条件请求的往返

4.1 完整往返示例

# 第一次请求
curl -i https://example.com/app.js

# HTTP/1.1 200 OK
# Cache-Control: max-age=0, must-revalidate
# ETag: "abc123"
# Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT
# (响应体 ...)

# 缓存过期后再次请求
curl -i https://example.com/app.js \
  -H 'If-None-Match: "abc123"' \
  -H 'If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT'

# HTTP/1.1 304 Not Modified
# ETag: "abc123"
# (无响应体,省下传输)

304 的收益:省下响应体传输(大文件收益巨大),但仍有一次往返(RTT)。所以对静态资源,用长 max-age + 指纹化文件名比频繁 304 更优——直接零往返。

4.2 条件请求的其他用途

头用途
If-None-MatchGET 条件获取(缓存验证)
If-MatchPUT/DELETE 乐观并发控制(防覆盖)
If-Modified-Since同上,时间粒度
If-Unmodified-Since仅在未修改时执行
If-Range断点续传的验证

If-Match 在 API 里用于乐观锁:

PUT /articles/42
If-Match: "v5-etag"

若当前 ETag 不是 v5-etag,服务器返回 412 Precondition Failed,防止并发覆盖。这与 API 缓存与性能 中的并发控制密切相关。

5. Vary 与内容协商

5.1 Vary 的作用

同一 URL 可能因请求头不同返回不同内容。Vary 告诉缓存「按哪些请求头区分副本」:

Vary: Accept-Encoding, Accept-Language

若不设 Vary,缓存可能把 gzip 版本返回给不支持 gzip 的客户端,或把中文页面返回给英文用户。

5.2 Vary: * 与缓存失效

Vary: *

Vary: * 意味着「任何请求头都可能影响响应」,等于告诉缓存不要缓存(无法命中)。除非确实需要,否则别用。

5.3 Vary 的常见陷阱

陷阱后果对策
漏设 Accept-Encoding压缩/非压缩串味显式声明
设了 User-Agent缓存碎片化(UA 无穷多)归一化或改用其他维度
设了 Cookie几乎无法命中用 private 或拆分 URL

Vary: User-Agent 是经典反模式:UA 字符串千变万化,缓存命中率趋近于零。

6. 缓存策略与实战

6.1 资源指纹化(fingerprinting)

最有效的策略是「内容变了,URL 就变」:

/app.3f2a1b.js      ← 内容哈希进文件名
/app.js             ← 不带哈希

对指纹化资源,设超长缓存:

Cache-Control: public, max-age=31536000, immutable

因为内容变时 URL 也变,不存在「过期」问题。HTML 入口文件则相反:

Cache-Control: no-cache

让它每次验证,从而及时引用到新的资源 URL。

6.2 按资源类型定策略

资源Cache-Control理由
HTML 入口no-cache必须及时更新,但可用 ETag 省流量
指纹化 JS/CSSpublic, max-age=31536000, immutableURL 变化即失效
图片/字体public, max-age=604800变动少
API 数据private, no-cache 或按需依业务
用户私有private, no-store安全
用户上传(带哈希)public, max-age=31536000内容寻址

6.3 stale-while-revalidate

Cache-Control: max-age=60, stale-while-revalidate=600

含义:60 秒内新鲜;60~660 秒内立即返回陈旧副本,同时在后台异步更新。用户零等待,下次拿到新数据。这是现代 CDN 性能优化的利器。

Cache-Control: max-age=60, stale-if-error=86400

源站故障时,可用陈旧副本最长 86400 秒,提升可用性。

6.4 CDN 分层

CDN 通常有边缘节点与中间层(shield)两级。用 s-maxage 控制共享缓存:

Cache-Control: public, max-age=0, s-maxage=600

含义:浏览器不缓存(max-age=0),但 CDN 缓存 600 秒。这样用户每次拿到 CDN 的最新副本,而回源被 CDN 挡住。边缘与中心的分层策略见 nginx CDN 边缘缓存 。

7. 常见错误与调试

7.1 典型错误

错误后果
用 no-cache 保护敏感数据数据仍被缓存,越权
Cache-Control: max-age=0 当「不缓存」仍会条件请求,非完全禁用
漏设 Vary: Accept-Encoding压缩内容串味
强缓存私有页面CDN 返回他人数据
ETag 基于文件时间多机不一致
Expires 用了本地时间时区错误

7.2 调试缓存

# 看响应头
curl -I https://example.com/app.js

# 模拟条件请求,验证 304
curl -i https://example.com/app.js -H 'If-None-Match: "abc123"'

# 看是否命中 CDN 缓存
curl -I https://example.com/app.js | grep -i -E 'age|x-cache|cf-cache'

# 强制绕过本地缓存
curl -i -H 'Cache-Control: no-cache' https://example.com/app.js

响应头 Age 表示副本已在缓存中存活的秒数。Age: 0 或缺失说明是回源响应;Age: 300 说明命中了已缓存 300 秒的副本。

浏览器 DevTools 的 Network 面板会标注 (from disk cache)、(from memory cache)、304,是排查缓存问题最直接的工具。更完整的 HTTP 调试方法见 curl 与 HTTP 调试实战 。

7.3 缓存清除(purge)

内容更新后若 URL 未变,需要主动清除 CDN 缓存:

# Cloudflare 示例
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://example.com/app.js"]}'

指纹化之所以优于 purge,就是因为它不需要这一步——新内容对应新 URL,旧缓存自然淘汰。

8. 小结

HTTP 缓存的核心是两条链路:强缓存(Cache-Control: max-age,命中则零请求)与协商缓存(ETag/Last-Modified + 条件请求,命中则省响应体)。设计缓存策略时按资源分类:入口 HTML 用 no-cache,指纹化静态资源用长 max-age + immutable,私有数据用 private(必要时 no-store),共享缓存用 s-maxage 控制。三个最常见的错误是:把 no-cache 当 no-store 用、漏设 Vary、对私有内容开了共享缓存。把这三条守住,再配合 stale-while-revalidate 与指纹化,缓存就能从「事故源」变成「性能引擎」。相关语义细节回到 HTTP 状态码与请求语义 ,CDN 侧配置见 nginx 多级缓存 与 nginx CDN 边缘缓存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「others」更多文章

  1. CSV/TSV 解析陷阱
  2. 模板引擎原理与选型
  3. URL 解析与百分号编码