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 的意思是,这个请求一开始就不在缓存的考虑范围里。头加了等于没加。
为什么不生效
我后来一共找到三个原因,它们叠在一起,所以才这么难发现:
- Pages Functions 返回的响应,Cloudflare 默认当作动态内容,根本不去查缓存。
s-maxage只管缓存多久,不管能不能缓存。要让 CDN 参与,得在控制台加一条 Cache Rule,把这些路径标成可缓存。 - 响应里带了 Set-Cookie。i18n 模块给每个页面响应都种了一个语言 cookie(
i18n_redirected),而 Cloudflare 不缓存带 Set-Cookie 的响应。 - 我最开始试的 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 缓存,要同时满足两个条件:
- 响应里不能有 Set-Cookie。我把 i18n 的语言 cookie 关掉了(
detectBrowserLanguage.useCookie: false),原因和代价写在了 i18n 那篇里。 - 控制台里要有一条 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 才算数。