18.02-HTTP缓存
要点
- HTTP 缓存是最便宜的 API 加速手段——只加响应头,不改业务代码,请求根本到不了 Worker
Cache-Control决定「缓存多久」;Vary决定「按什么分片」;ETag/Last-Modified决定「能不能复用旧版本」- 响应头写错一个字母就可能泄露用户数据,或者把该刷新的缓存永远冻住
- 条件请求(304 Not Modified)是省流量的利器,但 AI 接口很少用上——因为 payload 本来就大
- Cloudflare CDN 的缓存行为跟浏览器不完全一样,
s-maxage、stale-while-revalidate是 CDN 专属指令 - 调试缓存问题需要看
cf-cache-status响应头——它能告诉你缓存命中了没有、为什么没命中
1. 为什么 HTTP 缓存值得单独讲
上一篇讲了缓存的四层架构。这一篇专门拆开第一层——HTTP 响应头。
单独讲的原因有三个:
- 指令多且容易混。
Cache-Control一个头就有十几个指令,no-cache和no-store字面意思完全相反,很多人用了几年都没搞对 - CDN 行为和浏览器不一样。同一个响应头在浏览器和 Cloudflare 边缘的表现不同,生产事故的根源常常在这
- 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=cat 和 https://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-Control 的 max-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
| 维度 | ETag | Last-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-maxage 和 stale-while-revalidate
浏览器不认识 s-maxage 和 stale-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/DYNAMICAge是缓存已经存了多久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/models8.3 Cloudflare 仪表盘
Cloudflare Dashboard → Analytics 能看到缓存命中率。如果命中率低于 80%,说明 TTL 太短或 URL 变化太多。
9. 小结
HTTP 缓存的核心是三个头:
Cache-Control:缓存多久、谁能缓存、过期后怎么办Vary:按什么分片ETag/Last-Modified:协商缓存的标识
CDN(Cloudflare)的缓存行为跟浏览器不一样——s-maxage 和 stale-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 里最便宜但也最容易踩坑的一层。