18.02-HTTP缓存

要点

  • HTTP 缓存是最便宜的 API 加速手段——只加响应头,不改业务代码,请求根本到不了 Worker
  • Cache-Control 决定「缓存多久」;Vary 决定「按什么分片」;ETag/Last-Modified 决定「能不能复用旧版本」
  • 响应头写错一个字母就可能泄露用户数据,或者把该刷新的缓存永远冻住
  • 条件请求(304 Not Modified)是省流量的利器,但 AI 接口很少用上——因为 payload 本来就大
  • Cloudflare CDN 的缓存行为跟浏览器不完全一样,s-maxagestale-while-revalidate 是 CDN 专属指令
  • 调试缓存问题需要看 cf-cache-status 响应头——它能告诉你缓存命中了没有、为什么没命中

1. 为什么 HTTP 缓存值得单独讲

上一篇讲了缓存的四层架构。这一篇专门拆开第一层——HTTP 响应头。

单独讲的原因有三个:

  1. 指令多且容易混Cache-Control 一个头就有十几个指令,no-cacheno-store 字面意思完全相反,很多人用了几年都没搞对
  2. CDN 行为和浏览器不一样。同一个响应头在浏览器和 Cloudflare 边缘的表现不同,生产事故的根源常常在这
  3. AI 项目有独特需求。流式响应、SSE、大 payload、语义相似但字面不同的 URL——这些都需要特殊处理

先把指令表过一遍,再看几个真实场景。

2. Cache-Control 完整指令表

Cache-Control 是 HTTP/1.1 的核心缓存头,一个响应可以组合多个指令:

2.1 响应指令

指令含义适用场景
public可被任何缓存存储(浏览器、CDN、代理)公开 API、静态资源
private只能被用户独享缓存(浏览器)存储用户数据、认证后的接口
no-cache可以缓存,但每次使用前必须向源站验证经常变化但可复用的数据
no-store完全不缓存,每次都要重新请求敏感数据、实时数据
max-age=N新鲜期 N 秒(浏览器用)通用 TTL
s-maxage=N新鲜期 N 秒(CDN 用,覆盖 max-age)CDN 专属 TTL
must-revalidate过期后必须验证,不能用 stale 响应金融、库存等强一致场景
proxy-revalidate共享缓存必须验证(对私有缓存无效)配合 public 使用
stale-while-revalidate=N过期后 N 秒内可返回旧响应,后台异步刷新AI 查询、搜索结果
stale-if-error=N源站出错时 N 秒内可用旧响应兜底容灾降级
immutable响应永远不会变,不需要验证带 hash 的静态资源

2.2 请求指令

客户端也能发 Cache-Control,告诉缓存链不要做什么:

指令含义
no-cache不要返回缓存,必须向源站验证
no-store不要存储这次请求/响应的任何部分
max-age=N要求缓存的响应不超过 N 秒旧
max-stale=N接受过期不超过 N 秒的响应
min-fresh=N要求响应在 N 秒内仍然新鲜
only-if-cached只返回缓存,没有就直接失败(504)

前端开发者常用 fetch(url, { cache: 'no-store' }) 绕过缓存——这就是在请求头里加 Cache-Control: no-store

2.3 常见组合示例

// 公开静态资源:CDN 缓存 1 年,浏览器也缓存 1 年
'public, max-age=31536000, immutable'
 
// 用户数据:浏览器缓存 1 分钟,CDN 不缓存
'private, max-age=60'
 
// AI 查询:CDN 缓存 1 分钟,过期后 5 分钟内返回旧数据同时刷新
'public, max-age=60, stale-while-revalidate=300'
 
// 敏感数据:完全不缓存
'no-store'
 
// 经常变化但可复用:每次都要验证
'no-cache'
 
// 源站挂了也能降级:缓存 1 分钟,出错时 1 小时内可用旧数据
'public, max-age=60, stale-if-error=3600'

3. Vary:缓存按什么分片

默认情况下,缓存只用 URL 做 key。https://api.example/search?q=cathttps://api.example/search?q=dog 是两个不同的缓存条目——这没问题。

但如果同一个 URL 在不同 Accept-Language 下返回不同内容呢?缓存只看 URL 就会把中文响应返回给英文用户。

Vary 头告诉缓存:「除了 URL,还要按这些 header 值分片」。

app.get('/api/articles/:id', async (c) => {
  const lang = c.req.header('Accept-Language') || 'en'
  const article = await loadArticle(c.env, c.req.param('id'), lang)
 
  c.header('Cache-Control', 'public, max-age=3600')
  c.header('Vary', 'Accept-Language')  // 按语言分片
 
  return c.json(article)
})

这样缓存会把 (URL, Accept-Language) 组合作为 key,中文和英文各自一份缓存。

3.1 Vary 的代价

每多一个 Vary 字段,缓存的分片数就乘以一个因子。Vary: Accept-Language, Accept-Encoding 意味着每种语言×每种编码都是一个独立缓存条目。

实际建议:

  • Vary 真正影响响应内容的 header
  • Accept-Encoding 通常不用写——CDN 和浏览器会自动处理压缩
  • 不要在 Authorization 上 Vary——这会让每个用户都独占缓存,失去共享意义

3.2 Cloudflare 的 Vary 处理

Cloudflare CDN 默认遵守 Vary 头。但有些情况下它会忽略:

  • Vary: *——告诉缓存「永远不要复用」,Cloudflare 会直接穿透
  • 过长的 Vary 列表会降低缓存命中率,Cloudflare 可能选择忽略

一般 Vary 不超过 3 个 header 比较安全。

4. 条件请求:ETag 和 Last-Modified

Cache-Controlmax-age 是「盲缓存」——在 TTL 内无条件相信缓存。条件请求是「协商缓存」——客户端带着旧版本的标识去问源站:「你那边有更新吗?」

4.1 ETag

ETag 是响应内容的指纹(通常是 hash)。源站返回 ETag: "abc123",客户端下次请求时带上 If-None-Match: "abc123"。源站一比对:没变就返回 304 Not Modified(无 body,省流量),变了就返回新内容。

app.get('/api/documents/:id', async (c) => {
  const doc = await c.env.DB.prepare(
    'SELECT * FROM documents WHERE id = ?'
  ).bind(c.req.param('id')).first()
 
  if (!doc) return c.notFound()
 
  // 用内容 hash 做 ETag
  const etag = `"${await sha256(JSON.stringify(doc))}"`
 
  // 客户端带的 If-None-Match 匹配就没变
  if (c.req.header('If-None-Match') === etag) {
    return c.body(null, 304)
  }
 
  c.header('ETag', etag)
  c.header('Cache-Control', 'public, max-age=60')
  return c.json(doc)
})

4.2 Last-Modified

更老的机制——用时间戳做标识。源站返回 Last-Modified: Wed, 21 Jun 2026 10:00:00 GMT,客户端下次带 If-Modified-Since。源站比对更新时间,没变就 304。

app.get('/api/posts/:id', async (c) => {
  const post = await loadPost(c.env, c.req.param('id'))
 
  const lastModified = new Date(post.updatedAt).toUTCString()
 
  const ifModifiedSince = c.req.header('If-Modified-Since')
  if (ifModifiedSince && new Date(ifModifiedSince) >= new Date(post.updatedAt)) {
    return c.body(null, 304)
  }
 
  c.header('Last-Modified', lastModified)
  c.header('Cache-Control', 'public, max-age=60')
  return c.json(post)
})

4.3 ETag vs Last-Modified

维度ETagLast-Modified
精度内容 hash,内容变就变秒级时间戳,秒内多次修改会漏
性能每次要算 hash直接读时间字段
适用精确校验粗略校验、文件系统
优先级更高(HTTP/1.1 推荐)更老(HTTP/1.0 兼容)

AI 项目里 ETag 用得更多——响应内容精确匹配更重要。但每次都要算 hash 也有开销,一个折中办法是把 hash 存到数据库,查出来直接用。

4.4 条件请求对 AI 接口的价值

条件请求对 AI 推理接口基本没用——每次响应都不一样,没有复用价值。但对 AI 系统的元数据接口有用:

  • 模型列表 /api/models——几个月不变,ETag 很合适
  • 用户配额 /api/quotas——几分钟内可能不变,304 能省不少流量
  • RAG 索引状态 /api/index/status——分钟级变化,Last-Modified 够用

5. Cloudflare CDN 缓存的特殊行为

Cloudflare 作为 CDN,它的缓存行为跟浏览器有两点关键不同。

5.1 s-maxagestale-while-revalidate

浏览器不认识 s-maxagestale-while-revalidate——这两个是 CDN 专属指令。所以常见的组合是:

Cache-Control: public, max-age=60, s-maxage=86400, stale-while-revalidate=300

含义:浏览器缓存 1 分钟,CDN 缓存 1 天,过期后 5 分钟内返回旧数据并后台刷新。

5.2 Cloudflare 缓存层级

Cloudflare 有两层边缘缓存:

  • 节点级缓存:单个数据中心内的缓存(对应 Cache API)
  • Tiered Cache:多个数据中心组成的分层缓存(对应 CDN 边缘)

Cache-Control 指令会同时影响这两层。但 Cloudflare 的 Tiered Cache 有一些特殊规则:

  • 默认情况下 HTML 响应不缓存(即使你加了 public
  • 只有特定扩展名的静态资源才会被 CDN 默认缓存
  • API 响应需要显式加 Cache-Control 才会被缓存

可以在 Cloudflare Dashboard 的「Cache → Cache Rules」里配置更细粒度的规则。

5.3 cf-cache-status 响应头

Cloudflare 会在响应里加一个 cf-cache-status 头,告诉你缓存状态:

含义
HIT缓存命中,直接从边缘返回
MISS缓存未命中,回源站取
EXPIRED缓存过期了,但源站返回 304,用旧缓存
STALE缓存过期了,但 stale-while-revalidate 还没过,先用旧的
DYNAMIC这个响应没被缓存(没有 Cache-Control 或被规则排除)
BYPASS显式跳过缓存(no-store 或 Cache Rule 配置)

调试缓存问题时先看这个头。MISS 太多说明 TTL 太短或 URL 变化太多;DYNAMIC 太多说明根本没配上缓存。

6. 缓存毒化攻击与防御

缓存毒化(Cache Poisoning)是攻击者让 CDN 缓存了恶意响应。常见手法:

6.1 未考虑 Vary 的情况

// 危险示例
app.get('/api/profile', async (c) => {
  const user = await loadUser(c.req.header('X-User-Id'))
  c.header('Cache-Control', 'public, max-age=3600')
  // 忘了 Vary: X-User-Id
  return c.json(user)
})

攻击者请求 /api/profile 时带上 X-User-Id: attacker,CDN 缓存了这个响应。下一个正常用户访问时拿到的是攻击者的 profile。

防御:返回用户相关数据的接口必须 private,或者 Vary 所有影响响应的 header。

6.2 URL 参数污染

CDN 默认把整个 URL(含 query)作为缓存 key。攻击者可以构造:

/api/search?q=test<script>alert(1)</script>

如果这个响应被缓存,所有访问这个 URL 的用户都会拿到带 XSS 的版本。

防御:

  • 在 Cloudflare Cache Rules 里配置「只缓存白名单参数」
  • 输入校验和转义(但这是应用层的事)

6.3 对 AI 项目的影响

AI 接口常常接受 prompt 作为参数。如果 URL 里有 ?prompt=...,攻击者可以注入恶意 prompt 让 CDN 缓存。

防御:

  • 不要把 prompt 放在 URL 里(POST + body 更安全)
  • 如果必须 GET,对 prompt 做严格校验
  • 用户相关的 AI 响应必须 private

7. AI 场景的 HTTP 缓存模式

7.1 模型列表和配置接口

这类接口几乎不变,适合长 TTL:

app.get('/api/models', (c) => {
  c.header('Cache-Control', 'public, max-age=3600, s-maxage=86400, immutable')
  return c.json({ models: [...] })
})

immutable 告诉浏览器:「在 max-age 内连验证都不用做」。

7.2 AI 查询接口

查询接口响应可能变化,但几分钟内可以复用。用 stale-while-revalidate 让用户体验到 0 延迟:

app.post('/api/ai/query', async (c) => {
  const body = await c.req.json()
 
  // POST 请求不能被 HTTP 缓存,但可以在响应里加 Cache-Control
  // 让 CDN 知道这个响应的「新鲜度」——虽然 CDN 不会缓存 POST 响应本身
  // 但应用层可以用这个信息决定 KV 缓存的 TTL
 
  const result = await callLLM(body.prompt)
 
  c.header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
  return c.json({ answer: result })
})

等等——POST 响应不能被 HTTP 缓存啊,加 Cache-Control 干嘛?

这里有个技巧:POST 响应虽然不能被浏览器和 CDN 缓存,但 max-age 可以作为应用层缓存(KV)的 TTL 参考。你可以在中间件里读这个值,自动设置 KV 的 expirationTtl

7.3 流式响应的缓存

SSE 和 streaming 响应不适合用 HTTP 缓存——它们是长连接,缓存层没法等它结束再存。

流式响应的「缓存」应该放到应用层:把完整响应存进 KV,下次命中时假装再流一遍(分 chunk 返回 SSE)。这个在第 18.06 篇讲。

8. 调试 HTTP 缓存

缓存问题最难的是「看不到」。几个调试手段:

8.1 看响应头

浏览器 DevTools → Network → 选请求 → 看 Response Headers。

重点看:

  • Cache-Control 是不是你期望的值
  • cf-cache-status 是 HIT/MISS/DYNAMIC
  • Age 是缓存已经存了多久
  • X-Cache(有些 CDN 用这个)

8.2 curl 测试

# 第一次请求,应该是 MISS
curl -I https://api.example/models
# 第二次请求,应该是 HIT
curl -I https://api.example/models
 
# 绕过缓存
curl -I -H 'Cache-Control: no-cache' https://api.example/models
 
# 强制重新验证(带 ETag)
curl -I -H 'If-None-Match: "abc123"' https://api.example/models

8.3 Cloudflare 仪表盘

Cloudflare Dashboard → Analytics 能看到缓存命中率。如果命中率低于 80%,说明 TTL 太短或 URL 变化太多。

9. 小结

HTTP 缓存的核心是三个头:

  • Cache-Control:缓存多久、谁能缓存、过期后怎么办
  • Vary:按什么分片
  • ETag/Last-Modified:协商缓存的标识

CDN(Cloudflare)的缓存行为跟浏览器不一样——s-maxagestale-while-revalidate 是 CDN 专属;cf-cache-status 是调试的利器。

AI 项目里 HTTP 缓存的适用场景:

  • 模型列表、配置等几乎不变的元数据 → public, max-age=长, immutable
  • 查询接口(允许延迟) → public, max-age=60, stale-while-revalidate=300
  • 用户数据 → private, max-age=60
  • 敏感数据 → no-store

POST 请求(AI 推理)不能被 HTTP 缓存,但 max-age 可以作为应用层缓存的 TTL 参考。流式响应需要应用层缓存方案(下几篇讲)。

缓存不是万能的——写错响应头会泄露数据、会冻住过时内容、会被毒化攻击。下一篇讲进程内的内存缓存,那是 Worker 里最便宜但也最容易踩坑的一层。