前言
今天花了大半天时间处理缓存相关的问题,从首页 SSR 空数据到 API 响应缓存,从 Cache Rules 到 caches.default,从时区到缓存自动失效。索性把这套缓存体系完整记录下来,以后配置缓存时翻出这篇文章就能搞定。为什么需要关注缓存
cxyuan.asia 的目标用户在中国大陆,而服务器在 Cloudflare 全球网络。没有缓存时,每次请求都要回源处理,高峰期可能拖慢好几秒。缓存的目的是:
- 降低延迟:用户从最近节点获取数据,不必回源
- 减少计算:D1 查询、SSR 渲染等重复工作不再重复
- 提升可用性:即使源站短暂异常,缓存仍可提供陈旧但可用的内容
架构中的三层缓存
整个系统存在三层缓存,理解它们的层级关系是正确配置的前提:
┌─────────────────────────────────────────────────┐
│ 用户浏览器 / 客户端 │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 第1层: Cloudflare Edge Cache (CDN) │
│ cxyuan.asia Cache Rule: Edge TTL 5min │
│ ◆ 命中后直接返回,不触发 Pages Function │
│ ◆ 通过 Cache Rules 配置/Hostname匹配 │
└─────────────────┬───────────────────────────────┘
│ (Edge Cache MISS)
▼
┌─────────────────────────────────────────────────┐
│ 第2层: Nuxt Nitro SWR (仅服务端) │
│ routeRules: { swr: 60 } │
│ ◆ 首次 SSR 后缓存 HTML │
│ ◆ 60秒内直接返回缓存,后台异步重新生成 │
└─────────────────┬───────────────────────────────┘
│ (SWR MISS)
▼
┌─────────────────────────────────────────────────┐
│ 第3层: 后端 API + caches.default │
│ api.cxyuan.asia Worker │
│ ◆ GET 请求缓存 5分钟 │
│ ◆ 管理后台写入时自动失效 │
│ ◆ 空响应/异常不缓存 │
└─────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ D1 数据库 (源数据) │
└─────────────────────────────────────────────────┘当一个请求到达时,依次经过这三层缓存。任何一层命中就立即返回,只有全部 MISS 才真正查询数据库。
场景一:首页缓存(Cloudflare Cache Rules)
对于 Pages 站点 cxyuan.asia,可以直接在 Cloudflare Dashboard 中配置 Cache Rules。这是最简单的缓存方式——不需要改代码。
配置步骤
- 登录 Cloudflare Dashboard → 选择 cxyuan.asia 域名
- 左侧导航:Rules → Cache Rules
- 点击 Create Cache Rule
配置示例:
Rule name: 全站页面缓存
When: Hostname equals "cxyuan.asia"
Then: Edge TTL = 5 minutes
Browser TTL = 5 minutes命中效果(浏览器 Network 面板就能看到):
cf-cache-status: HIT
cache-control: public, s-maxage=300
TTFB: ~0.6s(未命中时约 1.5-2s)场景二:API 请求缓存(Worker 内 caches.default)
为什么 API 不能用 Cache Rules
我们的 API 部署在 Cloudflare Workers 上,绑定了自定义域名 api.cxyuan.asia。但是 Workers 自定义域名不走标准 CDN 缓存,Cache Rules 对这个域名不生效。
因此需要在 Worker 代码内自行管理缓存,使用 caches.default API。
核心缓存中间件
在 src/index.ts 中,我们注册了一个 /api/v1/* 的中间件来处理所有 API 请求的缓存逻辑:
const cacheExclude = ['/captcha/', '/auth/']
app.use('/api/v1/*', async (c, next) => {
const url = new URL(c.req.url)
const shouldCache = c.req.method === 'GET'
&& !c.req.header('Authorization')
&& !cacheExclude.some(p => url.pathname.includes(p))
const cacheKeyUrl = new URL(url.href)
cacheKeyUrl.searchParams.set('_cv', getCacheVersion())
const cacheKey = new Request(cacheKeyUrl.href, { method: 'GET' })
// 1. 读取缓存
if (shouldCache) {
const cached = await caches.default.match(cacheKey)
if (cached) return cached
}
await next()
// 2. 写入缓存(只有正常响应才缓存)
if (shouldCache && c.res.status === 200) {
const clone = c.res.clone()
const bodyText = await clone.text()
try {
const json = JSON.parse(bodyText)
if (json.code === 200) {
const cacheRes = new Response(bodyText, {
status: clone.status,
headers: clone.headers,
})
cacheRes.headers.set('Cache-Control',
'public, s-maxage=300, stale-while-revalidate=60')
c.executionCtx.waitUntil(
caches.default.put(cacheKey, cacheRes))
}
} catch {
// 非 JSON 响应也缓存
}
}
})阅读/写入流程
请求 → GET /api/v1/articles/public
↓
① 判断 shouldCache?
├─ GET ✓
├─ 无 Authorization ✓(管理员跳过缓存)
└─ 不在排除列表 ✓(/captcha/ 和 /auth/ 不缓存)
↓ Yes
② caches.default.match(key)
├─ HIT → 直接返回缓存(不执行路由)
└─ MISS → await next() 执行路由
↓
③ 路由处理 → 返回 { code: 200, data: {...} }
↓
④ 判断是否写入缓存?
├─ status === 200 ✓
└─ json.code === 200 ✓
↓
⑤ caches.default.put(key, response) → 缓存 5 分钟场景三:缓存键版本化(自动失效)
每次部署新代码后,旧的缓存响应可能已经过时。最简单的方案:在缓存键中加入版本号。
// src/config/cache.ts
const CACHE_VERSION = 'v1'
let cacheBuster = 0
export function getCacheVersion() {
return `${CACHE_VERSION}-${cacheBuster}`
}
export function invalidateApiCache() {
cacheBuster++
}缓存键最终会变成这样:
原始请求:/api/v1/articles/public?page=1&size=10
缓存键: /api/v1/articles/public?page=1&size=10&_cv=v1-0部署新版本时只要把 CACHE_VERSION 改成 v2,所有旧缓存自动失效,全部走 MISS → 重新生成。
场景四:写入时自动清缓存
管理员在后台编辑/发布文章后,普通用户应该立即看到最新内容,而不是等待 5 分钟缓存过期。
实现原理
在文章增删改查成功时调用 invalidateApiCache(),cacheBuster 自增 → 缓存键变化 → 后续请求自然 MISS:
// articleController.ts
export const create = async (c: Context) => {
// ...创建文章...
invalidateApiCache() // ← 缓存键从 v1-0 变成 v1-1
return ApiResponse.created(c, { id: articleId })
}
export const update = async (c: Context) => {
// ...更新文章...
invalidateApiCache() // ← 缓存键自增
return ApiResponse.success(c, null, '更新成功')
}流程图:
管理员编辑文章 → D1 更新成功
↓
invalidateApiCache() → cacheBuster: 0 → 1
↓
用户请求首页 → 缓存键 _cv=v1-1(不再是 v1-0)
↓
caches.default.match() → 未找到 → MISS
↓
Worker 查询 D1 → 返回最新数据 → 写入新缓存 (v1-1)场景五:不缓存错误响应
这是一个从实际故障中总结的经验。之前的 ReadableStream 兼容性问题导致 API 有时返回空响应,这个空响应被 caches.default 缓存了 5 分钟,导致所有用户在这 5 分钟内都看到空数据。
修复方案:只有 json.code === 200 的正常响应才写入缓存:
const json = JSON.parse(bodyText)
if (json.code === 200) { // ← 只缓存正常响应
caches.default.put(cacheKey, cacheRes)
}
// code !== 200 或异常 → 不缓存场景六:空 HTML 不被缓存(移除 SWR)
Nuxt 的 routeRules: { swr: 60 } 会在首次 SSR 后将 HTML 缓存 60 秒。如果此时 API 返回空数据(比如 Worker 冷启动),这页空 HTML 就会被缓存,后续用户看到的也是空页面:
首页首次 SSR → API 返回空 (冷启动)
↓
Nitro SWR 缓存空 HTML (60s)
↓
Cloudflare Edge 缓存空 HTML (5min)
↓
所有用户看到空首页 ❌解决方案:移除 routeRules,让 SSR 每次都重新生成。
// nuxt.config.ts — 删除这段
// routeRules: {
// "/**": { swr: 60 },
// },场景七:首页 SSR 失败重试
作为最后一层防御,首页的 useAsyncData 在第一次拿不到数据时等待 500ms 后重试一次:
// pages/index.vue
const { data } = await useAsyncData("home-articles", async () => {
for (let i = 0; i < 2; i++) { // 最多重试 1 次
const { code, data: res } = await $axios.get("/articles/public?...")
if (code === 200 && res?.list) {
return { list: res.list, total: res.pagination.total }
}
if (i === 0) await sleep(500) // 首次失败等 500ms 再试
}
return { list: [], total: 0 }
})配置速查表
| 层级 | 位置 | TTL | 失效方式 |
|---|---|---|---|
| Cloudflare Edge | Cache Rules(Dashboard) | 5 min | Purge Everything(Dashboard) |
| Worker caches.default | src/index.ts middleware | 5 min (s-maxage=300) | 改 CACHE_VERSION 重部署 / invalidateApiCache() |
| Nitro SWR | nuxt.config.ts routeRules | 60 sec | 已移除,不再使用 |
| 浏览器 | Cache-Control 响应头 | Browser TTL 5 min | 硬刷新 / 清除浏览器缓存 |
常见问题
Q: 修改文章后用户多久能看到?
立即看到。因为 invalidateApiCache() 会自增 cacheBuster,后续请求缓存键变化 → 自动 MISS → 返回最新数据。但如果你用浏览器在 5 分钟内第二次访问同一篇文章详情页,浏览器的 Cache-Control 可能返回旧内容,需要硬刷新。
Q: 部署新代码后旧缓存怎么办?
手动:改 CACHE_VERSION(v1 → v2)再部署。自动:每次 CI/CD 部署时自动递增版本号。
Q: 为什么 /captcha/ 和 /auth/ 不缓存?
验证码必须每次不同(防机器人),登录/注册涉及 token 签发,缓存会导致认证逻辑异常。在 cacheExclude 中明确排除这两类路径。
Q: 管理员能看到最新数据吗?
能。中间件检查 c.req.header('Authorization'),管理员请求携带 token → shouldCache = false → 不走缓存,直接查 D1。
总结
缓存是性能的利器,也是 Bug 的温床。这套缓存体系经历了踩坑、验证、修复的完整循环,最终形成了三层防御 + 自动失效 + 人工兜底的结构:
- Edge Cache → 扛住绝大多数静态页面请求
- caches.default → 让 API 响应在全球节点就近缓存
- CacheBuster → 写操作自动清除缓存
- SSR 重试 → 最后一道防线
