Writing

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

对于网站内容而言,动态页面的缓存是性能的分水岭。这篇文章记录我给 Cloudflare Pages 上的 Nuxt 站点加缓存的全过程——从「加一个响应头」的直觉做法开始,一路踩到 DYNAMIC、Set-Cookie、内存驱动三个坑,最后用 Cache API 解决。

问题的起点

我的首页 TTFB 1.4 秒。排查发现根因不在页面本身:首页 SSR 时要实时查 Supabase 拉最新文章,那个接口单次要 3.1 秒。页面资源本身很小(全部 JS/CSS 加起来 16KB),慢的是服务端到数据库的往返。

第一反应:给接口加缓存响应头

最直觉的做法是给接口加缓存头,让 Cloudflare CDN 兜住:

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

部署后检查响应头,头确实在。再看 cf-cache-status

cf-cache-status: DYNAMIC

DYNAMIC 的意思是「这个请求在请求时就不具备缓存资格」。头加了等于没加。

为什么 s-maxage 不生效

三个原因叠在一起,少一个都不会这么隐蔽:

  1. Pages Functions 的响应默认按动态代码处理,不查 CDN 缓存s-maxage 只决定缓存多久,不决定能不能缓存。要让 CDN 参与,需要在 Cloudflare 控制台配 Cache Rule,把路由显式标记为「可缓存」。
  2. 响应带了 Set-Cookie。i18n 模块在页面上种语言 cookie(i18n_redirected),而带 Set-Cookie 的响应即使配了规则也会被拒缓存。
  3. 我最初用的 Nitro routeRules swr 底层是内存缓存驱动,而 Cloudflare 的 worker 实例是短命的——内存缓存随实例消失,跨请求根本不持久。查了源码,cloudflare preset 并没有覆盖默认的 cache storage。

正解:在 worker 里用 Cache API 自己管

绕开 CDN 那套,在接口代码里直接用 worker 的 Cache API(caches.default):命中直接返回,未命中查数据库后写入:

// 伪代码,完整实现见 server/utils/cache.ts 和接口文件
const cache = getDefaultCache()   // workerd 才有,本地 node 返回 undefined
if (cache) {
  const key = new Request(getRequestURL(event).href)  // 完整 URL 作键
  const cached = await cache.match(key)
  if (cached) return cached

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

几个决定成败的细节:

  • 缓存键是完整 URL:分页、筛选、搜索参数各不相同,各存一份,互不污染
  • 只缓存 200:错误响应不污染缓存
  • 写入必须 await,不能用 waitUntil:serverless 下未 await 的 promise 会随请求结束被取消。第一次实现用的 waitUntil,实测每次都是缓存未命中——改 await 之后立刻生效
  • Cache API 是数据中心本地的:不跨 PoP 复制,每个节点各自冷启动一次,10 分钟内最多一次慢查询,这是可接受的
  • 加一个诊断头X-Cache-Api: HIT / MISS / UNAVAILABLE,以后排查缓存问题直接看响应头,不用猜

验证

第一次请求 MISS(查库 + 写入缓存),之后连续请求全部 HIT。同一 PoP 下,命中时 worker 不再查 Supabase,服务器端耗时从 3 秒降到 10 毫秒量级。

第一次: X-Cache-Api: MISS   (查库 + 写入,耗时 3s)
之后:   X-Cache-Api: HIT    (直接返回缓存)

怎么验证缓存真的生效

加完缓存别只看代码,用 curl 看响应头:

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

第一次请求应该是 MISS(或没有 X-Cache-Api 头,取决于是否命中缓存路径),紧接着再发一次应该变成 HIT。注意 Cloudflare 的缓存是按数据中心分片的,从不同网络测(比如手机流量和家里宽带)会各自冷启动一次,看到 MISS 不代表缓存没生效。

什么时候别用 Cache API

Cache API 的存储是数据中心本地的、LRU 驱逐、不保证保留——它是「加速器」不是「存储」。以下情况不要用:

  • 数据需要全局一致性(刚写入立刻要求所有节点读到新值)——用 KV 或数据库
  • 用户相关的数据(带会话的内容)——缓存键没有鉴权维度,会串数据
  • 需要精确的缓存控制(按 tag 批量失效、自定义 key)——Cache Rule 或 KV 更合适

我的场景是「公开文章列表,10 分钟内的一致性完全可接受」,Cache API 正好够用。

总结

  • s-maxage 不决定缓存资格,Pages Functions 默认 DYNAMIC,要配 Cache Rule
  • Set-Cookie 会挡缓存——排查时先看响应有没有 cookie
  • Nitro 的 routeRules swr 在 Pages 上默认不持久(memory 驱动,isolate 短命)
  • Cache API 是程序化的正解:自己 match/put,写入要 await
  • 排查缓存问题,先看 cf-cache-status 和自定义诊断头,别凭感觉

一句话:Cloudflare Pages 上想给动态接口加缓存,别指望响应头,要在 worker 里自己管。

More