Cloudflare Pages 缓存踩坑:s-maxage 为什么不生效

我的首页 TTFB 有 1.4 秒。页面本身很小,JS 和 CSS 加起来才 16KB,慢在服务端:首页渲染时要去 Supabase 查最新的文章,那个接口单次要 3 秒左右。这篇记录我给这个 Nuxt 站点加缓存的过程,从「加个响应头」开始,最后接口和页面各用了一套办法。

第一反应:加个缓存头

最直接的想法是给接口加缓存头,让 Cloudflare 的 CDN 替我挡住:

Cache-Control: public, s-maxage=600, stale-while-revalidate=3600

部署之后看响应,头确实在,再看 cf-cache-status:

cf-cache-status: DYNAMIC

DYNAMIC 的意思是,这个请求一开始就不在缓存的考虑范围里。头加了等于没加。

为什么不生效

我后来一共找到三个原因,它们叠在一起,所以才这么难发现:

  1. Pages Functions 返回的响应,Cloudflare 默认当作动态内容,根本不去查缓存。s-maxage 只管缓存多久,不管能不能缓存。要让 CDN 参与,得在控制台加一条 Cache Rule,把这些路径标成可缓存。
  2. 响应里带了 Set-Cookie。i18n 模块给每个页面响应都种了一个语言 cookie(i18n_redirected),而 Cloudflare 不缓存带 Set-Cookie 的响应。
  3. 我最开始试的 Nitro routeRules 里的 swr,在 Cloudflare 上底层是内存缓存。Worker 的实例生命周期很短,内存里的东西跨请求留不住。我翻了源码,cloudflare 的 preset 并没有替换掉默认的缓存存储。

接口:在 Worker 里用 Cache API 自己存

接口这边我没走 CDN,直接在接口代码里用 Worker 的 Cache API(caches.default):先查缓存,命中就返回;没命中就查数据库,把结果存进去。几个接口都要这么做,我把它抽成了一个函数:

// server/utils/cache.ts(简化版)
export async function serveWithEdgeCache(event, fetcher) {
  const cache = getDefaultCache()          // 只有 workerd 里才有,本地 node 返回 undefined
  if (!cache) return fetcher(event)

  const key = new Request(getRequestURL(event).href)   // 完整 URL 做键
  const cached = await cache.match(key)
  if (cached) return cached

  const response = await fetcher(event)
  if (response.status === 200) {
    await cache.put(key, response.clone())  // 存多久由响应自带的 s-maxage 决定
  }
  return response
}

// 接口里一行接上
export default defineEventHandler(event => serveWithEdgeCache(event, fetchPosts))

写的时候有几处要注意:

  • 缓存键用完整 URL。分页、筛选、搜索参数不同,结果就不同,各存一份,互不干扰。
  • 只缓存状态码 200 的响应,出错的结果不能被存下来反复返回。
  • cache.put 一定要 await。我第一版用的是 event.waitUntil,想着不阻塞响应,结果每次都是未命中:Nitro 的 Cloudflare 适配器对 waitUntil 的接线不可靠,请求一结束,没等完的 promise 就被取消了。改成 await 之后马上就好了,多花的几十毫秒只在第一次请求时出现。
  • Cache API 的缓存只在当前数据中心有效,不会同步到其他节点。每个节点各自冷启动一次,每 10 分钟最多一次慢查询,对我的场景完全可以接受。
  • 加一个诊断头 X-Cache-Api: HIT / MISS / UNAVAILABLE。以后排查缓存问题,直接看响应头,不用猜。

效果

从我这里测线上的文章列表接口,换一个没请求过的参数,连发三次:

第 1 次  X-Cache-Api: MISS   TTFB 1.64s
第 2 次  X-Cache-Api: HIT    TTFB 0.59s
第 3 次  X-Cache-Api: HIT    TTFB 0.42s

作为对照,直接取站上的一个静态文件,TTFB 也在 0.6 秒左右。也就是说,命中缓存之后,这个接口已经和静态文件一样快了,剩下的基本都是我到 Cloudflare 节点的网络往返。

验证缓存是否生效,最省事的办法是用 curl 看响应头:

curl -sI 'https://你的站点/api/xxx' | grep -iE 'cf-cache-status|x-cache-api'

第一次应该是 MISS,紧接着再发一次应该变成 HIT。注意缓存是按数据中心分开的,用手机流量和家里宽带分别去测,会各自冷启动一次,看到 MISS 不代表缓存没生效。

页面:关掉 cookie,再加一条 Cache Rule

接口解决之后,页面本身还是每次都要在 Worker 里渲染。页面能不能走 CDN 缓存,要同时满足两个条件:

  1. 响应里不能有 Set-Cookie。我把 i18n 的语言 cookie 关掉了(detectBrowserLanguage.useCookie: false),原因和代价写在了 i18n 那篇里。
  2. 控制台里要有一条 Cache Rule,把首页、文章列表、文章详情这些路径设为可缓存,缓存时长跟随源站的 Cache-Control。

有一个坑必须先堵上:CDN 的缓存键不区分 cookie。我登录后台之后,能打开还没发布的草稿,如果这一次的页面被 CDN 缓存了,所有访客都能看到这篇草稿。所以草稿页和后台页面,响应头都要改成不缓存:

// 文章详情页:未发布的文章不许进 CDN
if (import.meta.server && post.value?.is_published === false) {
  useResponseHeader('cache-control').value = 'private, no-store'
}

这里我还踩了一个小坑:一开始用的是 h3 的 setResponseHeader,类型检查没报错,运行时直接 500。它在页面组件里没有自动导入,要用 Nuxt 自己的 useResponseHeader。

配置之前,线上所有页面都是 DYNAMIC;加了 Cache Rule、代码还没部署时,变成了 BYPASS,说明规则已经命中,只是被旧代码的 cookie 挡住了。部署之后再测,每个页面第一次请求要在 Worker 里渲染,之后就都是 CDN 直接返回:

                   第 1 次          第 2 次        第 3 次
/zh                EXPIRED 1.82s    HIT 0.61s      HIT 0.61s
/zh/blog           MISS    1.22s    HIT 0.60s      HIT 0.61s
/zh/blog/133       MISS    4.00s    HIT 0.96s      HIT 0.62s

EXPIRED 是缓存过了 10 分钟有效期,回源重新取了一次。命中之后,页面和静态文件一样快了。

什么时候别用 Cache API

Cache API 的存储只在单个数据中心、按最近最少使用淘汰,也不保证一定留得住。它适合用来加速,不适合用来存东西。下面几种情况我不会用它:

  • 数据要求全局一致,写进去之后所有节点必须马上读到新值。这时候该用 KV 或者数据库。
  • 跟用户有关的数据,比如登录后才能看的内容。缓存键里没有用户这一维,会把一个人的数据返回给另一个人。
  • 需要精细控制缓存,比如按标签批量失效。Cache Rule 或者 KV 更合适。

我的场景是公开的文章列表,10 分钟内的延迟完全能接受,Cache API 正好够用。


回头看,最费时间的不是写缓存代码,而是第一步:相信了「加个 s-maxage 就行」。在 Cloudflare Pages 上,缓存头只是必要条件之一,每改一步都要用 curl 看 cf-cache-status,看到 HIT 才算数。

更多