前言

今天花了大半天时间处理缓存相关的问题,从首页 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。这是最简单的缓存方式——不需要改代码。

配置步骤

  1. 登录 Cloudflare Dashboard → 选择 cxyuan.asia 域名
  2. 左侧导航:Rules → Cache Rules
  3. 点击 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 EdgeCache Rules(Dashboard)5 minPurge Everything(Dashboard)
Worker caches.defaultsrc/index.ts middleware5 min (s-maxage=300)改 CACHE_VERSION 重部署 / invalidateApiCache()
Nitro SWRnuxt.config.ts routeRules60 sec已移除,不再使用
浏览器Cache-Control 响应头Browser TTL 5 min硬刷新 / 清除浏览器缓存

常见问题

Q: 修改文章后用户多久能看到?

立即看到。因为 invalidateApiCache() 会自增 cacheBuster,后续请求缓存键变化 → 自动 MISS → 返回最新数据。但如果你用浏览器在 5 分钟内第二次访问同一篇文章详情页,浏览器的 Cache-Control 可能返回旧内容,需要硬刷新。

Q: 部署新代码后旧缓存怎么办?

手动:改 CACHE_VERSIONv1v2)再部署。自动:每次 CI/CD 部署时自动递增版本号。

Q: 为什么 /captcha/ 和 /auth/ 不缓存?

验证码必须每次不同(防机器人),登录/注册涉及 token 签发,缓存会导致认证逻辑异常。在 cacheExclude 中明确排除这两类路径。

Q: 管理员能看到最新数据吗?

能。中间件检查 c.req.header('Authorization'),管理员请求携带 token → shouldCache = false → 不走缓存,直接查 D1。

总结

缓存是性能的利器,也是 Bug 的温床。这套缓存体系经历了踩坑、验证、修复的完整循环,最终形成了三层防御 + 自动失效 + 人工兜底的结构:

  • Edge Cache → 扛住绝大多数静态页面请求
  • caches.default → 让 API 响应在全球节点就近缓存
  • CacheBuster → 写操作自动清除缓存
  • SSR 重试 → 最后一道防线