后续:
虽然理论理解的很好,但是实际部署后遇到了缓存不更新的问题——即使我调用了 revalidateTag + cachePurge,页面上看到的还是旧数据。
尝试清空了标签缓存和 r2 里面的所有数据,但网站上看到的仍然是旧数据。AI 分析问题大概率出在边缘节点上,也就是 revalidateTag + cachePurge :
- 理论上是会清除边缘节点里的数据的,但实际上没有
- 边缘节点自己应该会不停的从 R2 异步更新数据才对,但实际上没有
- 读取了边缘缓存之后,OpenNEXT 应该还会检查标签缓存确认缓存有没有过期的,但实际上没有
以上三种情况,只要有一个按照预期工作,都不会出现缓存不更新的问题。
感觉 OpenNEXT 的适配有问题,但是又找不到证据 :joy: 最后去掉了边缘缓存,就没有数据不更新的问题了。
在一个可能相关的 issue 下提交了一个评论。
在把 getcheapai.com 部署到 Cloudflare Workers 之后,我开始给它做性能提升。考虑到我的数据仅在每次爬取新数据之后才会发生变化,我决定给所有页面设置一个长达一年的缓存,然后每次爬取数据之后手动清除缓存。
代码上看大概是这样:
import { unstable_cache } from 'next/cache'
export const revalidate = 31536000 // ISR 设置一年过期时间
// 首页
export default async function HomePage() {
// 使用 cache
const getDataWithCache = unstable_cache(
() => getDataFromDB(),
['home-page-data'],
{
revalidate: 31536000, // 数据库数据也设置一年才过期
tags: ['data'] // 其余地方也用同一个缓存标签
}
)
const data = await getDataWithCache()
return <div>{data}</div>
}
再添加一个 API 接口用于刷新缓存:
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST() {
revalidateTag('data');
return NextResponse.json({
message: '所有数据缓存已清除'
})
}
这样,每个页面都只在首次被访问时才会由 Next.js 动态渲染,之后的访问全都是直接返回上次渲染的内容,直到调用 API 接口清除缓存。
但由于我部署在 Cloudflare Workers 上,所以还需要进行适配。
遇到问题
@opennextjs/cloudflare 专门提供了一篇文档解释要如何在 Cloudflare Workers 里开启缓存:Caching - OpenNEXT Cloudflare。
我对里面的技术栈一知半解,然后照本宣科的按照它提供的「Small site using revalidation」章节进行了配置并部署到了线上,就开始看效果。
不断的刷新首页,先是看到 HTTP 响应头里有 X-NEXT-JS-Cache: MISS,然后变成 HIT,说明成功缓存了;但是多刷新几次之后,我发现它变成了 STALE,又变成了 HIT,如此反复。
但是,我设置了一年缓存,理论上应该一直是 HIT 才对。问了 Claude Code,它提供了解决方案,但是由于我对 Cloudflare Workers 的整个架构不太清楚,所以我不知道它的解决方案是不是对的、会造成什么后果。所以,我决定借助 AI 先弄清楚整个架构。
这篇文章就是我对 Cloudflare Workers 整个架构的理解。
存储缓存
OpenNext 使用 Cloudflare R2 存储缓存,配置如下:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});
这个配置产生了如下流程:
1. 用户访问网站,请求到达 Cloudflare Worker 里的 Next.js
2. Next.js 通过 OpenNEXT 读取了 R2 看看是否命中了缓存,如果命中了缓存,那么再看看缓存是否已过期
- 如果没有命中缓存,或者缓存过期了,那么就会重新生成页面,然后将缓存存放进 R2,然后返回给用户
- 如果命中了缓存且未过期,那么将缓存内容返回给用户
R2 内存储的 HTML 缓存是这样的格式:
{ "type": "app", "html": "<HTML 内容>", "rsc": "<RSC 片段内容>", "meta": { "headers": { "x-nextjs-stale-time": "300", "x-next-cache-tags": "_N_T_/layout,_N_T_/page,_N_T_/,data" } }, "revalidate": 31536000 }
这里面有一个问题,就是如果有缓存,但是缓存过期了,那么会造成阻塞:Next.js 会先生成页面,然后存进 R2,然后返回给用户。
业界流行的做法是 Stale-While-Revalidate:先返回给用户旧的缓存内容,然后异步生成新的缓存,那么下一个用户访问时就会是新的内容了。
但是,由于 Cloudflare Workers 是 Serverless 的,所以当内容返回给用户的时候,进程就会被关闭,也就做不到「异步生成新的缓存」这一点了。
于是,OpenNEXT 引入了一个新的组件:队列。
队列
让我们在刚才的配置基础上加上队列:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
queue: doQueue,
});
这个配置产生了如下流程:
1. 用户访问网站,请求到达 Cloudflare Worker 里的 Next.js
2. Next.js 想要通过 OpenNEXT 读取缓存,于是 OpenNEXT 读取了 R2,然后:
- 如果缓存不存在,那么重新生成页面并存放进 R2,然后返回给用户,结束 Worker 运行
- 如果命中了缓存且未过期,那么将缓存内容返回给用户,结束 Worker 运行
- 如果命中了缓存,但缓存过期了,那么 Worker 会将「重新生成缓存」这个任务推送给队列,然后返回给用户已有的但是是已经过期的缓存内容,结束 Worker 运行。但在这之后,队列会负责异步唤起 Worker 执行重新生成缓存的任务
这样就不会阻塞响应了。同时,从这个流程就可以看到,队列仅被用于基于时间的缓存策略,也就是你需要用到「xxx 秒钟后重新生成缓存」(例如给首页配置 export const revalidate = 10)。如果你只用「按需缓存策略」(后面会提到),那么是用不上的,也无需配置队列。
对队列任务去重
可以给队列再套一层 queueCache:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
import queueCache from "@opennextjs/cloudflare/overrides/queue/queue-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
queue: queueCache(doQueue),
});
queueCache 的作用是给任务去重。当网站访问量很大时,队列可能在短时间内收到大量相同的清除缓存的任务。这时候,它会对任务进行去重。比如第一次收到时,它会记录下「一会儿重新生成首页的缓存」,但真正执行可能得等一段时间(比如 1 秒之后),在这 1 秒内,它会忽略所有要求重新生成首页的任务。如果不用队列,那么首页可能会在短时间内被重新生成多次,造成浪费。
进一步优化:加入边缘缓存(regional cache)
OpenNEXT 提到可以给 R2 加一层边缘缓存:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
import queueCache from "@opennextjs/cloudflare/overrides/queue/queue-cache";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, {
mode: "long-lived", // 或者 "short-lived"
shouldLazilyUpdateOnCacheHit: true, // long-lived 时默认是 true,short-lived 时默认是 false
bypassTagCacheOnCacheHit: false, // 默认 false
}),
queue: queueCache(doQueue),
});
给 R2 套了边缘缓存之后,流程变成了这样:
1. 用户访问网站,请求到达 Cloudflare Worker 里的 Next.js
2. Next.js 想要通过 OpenNEXT 读取缓存,而 OpenNEXT 会先读取边缘节点:
- 如果边缘节点里命中了缓存,且缓存未过期,那么返回给用户,结束 Worker 运行
- 如果缓存不存在,或者过期了,那么 OpenNEXT 会继续读取 R2
- 如果 R2 内缓存不存在,那么重新生成页面并存放进 R2 和边缘节点,然后返回给用户,结束 Worker 运行
- 如果 R2 内命中了缓存且未过期,那么将缓存内容存放进边缘节点,然后返回给用户,结束 Worker 运行
- 如果 R2 内命中了缓存,但缓存过期了,那么 Worker 会将「重新生成缓存」这个任务推送给队列,然后返回给用户已有的但是是已经过期的缓存内容,结束 Worker 运行。但在这之后,队列会负责异步唤起 Worker 执行重新生成缓存的任务
有了边缘节点,就能最大可能减少读取 R2,因为读取边缘节点会比读取 R2 更快。
有了前面的流程,那么再看其中的 3 个参数就很好理解了:
mode决定了边缘节点在缓存 R2 的内容时的时间short-lived代表在边缘节点最多只保存 R2 的内容 1 分钟(即MIN('1 分钟', 'Next.js 里设置的过期时间'))long-lived将缓存分为两个部分:数据缓存(使用fetch的next参数、使用unstable_cache保留的缓存)和 HTML 缓存(通过配置 ISR 或者静态生成 SSG 产生的 HTML 内容)。其中,数据缓存会一直保留在边缘节点当中直到依照 Next.js 里配置的时间过期,而 HTML 缓存最多保留 30 分钟(即MIN('30 分钟', 'Next.js 里设置的过期时间'))
shouldLazilyUpdateOnCacheHit从名字就能看出来了,当用户请求命中了边缘节点的缓存时,是否要异步发起一次对边缘节点上的缓存的更新- 这么做是为了避免边缘节点返回旧的缓存内容,相当于是不停的异步从 R2 里读取内容放到边缘节点里
bypassTagCacheOnCacheHit里提到了一个新的概念:Tag Cache,标签缓存。要理解它,就要提到我前面说过的「按需缓存策略」了
按需缓存策略和标签缓存
文章一开始,我想要的需求其实就是「按需缓存策略」:给页面设置长达一年的缓存(本质上可以设更久,因为我并不需要页面根据时间来过期),然后我自己主动调用 Next.js 提供的方法来让首页过期。
要让某个页面的缓存过期,可以使用 Next.js 提供的 revalidatePath 方法。但是,Next.js 还提供一个更实用的方法:revalidateTag。
页面的 HTML 内容大概率是会基于别的内容(例如,从 API 接口或者数据库拿到的数据),而 Next.js 支持通过多种方式(fetch 函数的 next 参数、use cache 指令、unstable_cache 方法)来缓存这部分内容。
当缓存这部分内容时,Next.js 支持给它们打上「标签」。举个例子,如果你生成页面的时候依赖某个接口的数据,你给这个接口用了上面的方法做了缓存,并且打上了名为 data 的标签,那么当你调用 revalidateTag('data') 时,你可以将接口的缓存以及用到了这个接口来生成 HTML 内容的页面的缓存都失效掉。
这个功能很强大,但是为了知道哪些页面使用了哪些标签来生成内容,就需要一个地方来存储这层依赖关系,这就是前面提到的 Tag Cache(标签缓存)的作用。
在 OpenNEXT 内配置标签缓存
OpenNEXT 支持使用 Cloudflare D1 数据库或者 Cloudflare Durable Object 来存储缓存的依赖关系。小型网站可以使用 D1 数据库,中大型推荐用 Durable Object。
现在,让我们给前面的 OpenNEXT 配置加上标签缓存:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, {
mode: "long-lived",
shouldLazilyUpdateOnCacheHit: true,
bypassTagCacheOnCacheHit: false,
}),
queue: doQueue,
tagCache: d1NextTagCache, // 使用 D1 数据库存储缓存依赖关系
});
有了标签缓存之后,之前的流程会在「确认缓存是否过期」时,增加一步读取标签缓存的步骤:
1. 用户访问网站,请求到达 Cloudflare Worker 里的 Next.js
2. Next.js 想要通过 OpenNEXT 读取缓存,而 OpenNEXT 会先读取边缘节点:
- 如果边缘节点里命中了缓存,且缓存未过期,【还需要读取标签缓存,确认页面依赖的标签缓存没有过期】,如果也没有过期,那么返回给用户,结束 Worker 运行
- 如果缓存不存在,或者过期了,那么 OpenNEXT 会继续读取 R2
- 如果 R2 内缓存不存在,那么重新生成页面并存放进 R2 和边缘节点,然后返回给用户,结束 Worker 运行
- 如果 R2 内命中了缓存且未过期,【还需要读取标签缓存,确认页面依赖的标签缓存没有过期】,如果也没有过期,那么将缓存内容存放进边缘节点,然后返回给用户,结束 Worker 运行
- 如果 R2 内命中了缓存,但缓存过期了,那么 Worker 会将「重新生成缓存」这个任务推送给队列,然后返回给用户已有的但是是已经过期的缓存内容,结束 Worker 运行。但在这之后,队列会负责异步唤起 Worker 执行重新生成缓存的任务
回到边缘缓存的 bypassTagCacheOnCacheHit 选项
理解了标签缓存,那么这个参数从名字上看就能理解了:由于我们引入了标签缓存,那么不仅仅是在 R2,在边缘节点里也多了一步查询标签缓存的步骤。而如果将这个参数设为 true,就是告诉边缘节点,如果你命中了缓存,那么无需查询标签缓存,可以直接将这个缓存返回给用户。
这样,边缘节点就节省了查询标签缓存的耗时,用户请求得到了更快的响应。
但是,这同样会导致边缘节点返回过期的缓存内容。你可能会说:我不是可以用 revalidateTag 将缓存标记为已过期吗?但是请注意,revalidateTag 虽然会清除 R2 里的缓存并在 tagCache 中标记 tag 为已失效,但边缘节点属于在 R2 外面套了一层,是由 Cloudflare 提供的缓存层,它并不知道我们在 Next.js 内部调用了 revalidateTag。
这个时候,就轮到自动清除缓存(Automatic Cache Purge)上场了。
自动清除缓存(Automatic Cache Purge)
对于性能优化,我们当然希望既要又要:既要边缘缓存能在无需读取标签缓存的情况下直接响应用户请求,又要缓存总是新的。最好的办法就是,当我们在 Next.js 内部调用类似 revalidateTag 这类让缓存过期的方法时,还要通知 Cloudflare 的边缘节点:「我们将 xxx 缓存过期了,你也要清除 xxx 的缓存」。
这个通知机制就是 OpenNEXT 里的 cachePurge 配置:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import doQueue from "@opennextjs/cloudflare/overrides/queue/do-queue";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
import d1NextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";
import { purgeCache } from "@opennextjs/cloudflare/overrides/cache-purge/index";
export default defineCloudflareConfig({
incrementalCache: withRegionalCache(r2IncrementalCache, {
mode: "long-lived",
shouldLazilyUpdateOnCacheHit: true,
bypassTagCacheOnCacheHit: true, // <- 由于我们有了通知机制,所以可以放心的跳过对标签缓存的查询
}),
queue: doQueue,
tagCache: d1NextTagCache,
cachePurge: purgeCache({ type: "direct" }), // <- 通知机制
});
但需要注意:cachePurge 仅在调用 revalidateTag/revalidatePath 等方法时才会通知边缘节点清除缓存。如果缓存是由于时间自然过期的,不会通知边缘节点主动删除缓存。
不过这并不会造成问题,因为虽然过期的缓存仍然保留在边缘节点中,但 OpenNext 在读取缓存时会检查缓存的过期时间,如果发现缓存已过期,会忽略边缘缓存并从 R2 重新读取。
回到开头:为什么首页会反复 X-NEXT-JS-Cache: HIT / STALE / HIT / STALE?
即使我理解了前面那一堆流程,但还是没有解决我开头遇到的问题。
但我无意间发现,后台出现清除缓存任务的时机正好是在我刷新网页之后——难道这是 OpenNext 或者 Next.js 有意为之的吗?
我问了 AI,果然,它告诉我这确实是 OpenNEXT 有意为之,依据是它在 OpenNEXT 的源码里读到了代码注释。换句话说,它早就知道这是 OpenNEXT 有意为之的行为,但却仍在不停的帮我解决问题,即使这不是个问题。
所以我觉得现在的 AI 还是不能太过相信,它太“愚忠”了,不会在回答问题前,先考虑一下问题本身对不对,而是将用户的问题列为了最高优先级来考虑。就好比它之前杜撰文档里的一句话时,我给它说了文档里的另一段话,它回答我说的对,但事后才知道,由于摘要的存在,它实际上并未在文档里看到我发给它的话,但由于是我发的,它就认为真的是文档里的话。
这样也好,程序员还是有存在的必要的哈哈。
问题还是要回答。AI 说,这是因为我刷新网页时,浏览器会自动带上 Cache-Control: no-cache 请求头,然后 Cloudflare 就会强制让服务动态生成最新的内容。如果用 curl 访问,就会发现不会触发 STALE 了。
顺便分享几个 OpenNEXT 的坑
- OpenNEXT 最高支持到 Next.js v16.0.10,再高会报错,见 opennextjs-cloudflare#1049
- 如果要使用
revalidateTag,那么只能用 Next.js v15 版本,见 opennextjs-cloudflare#1058- 但 v15 版本的 Next.js 有个 bug,会导致 next-intl 的默认语种的 ISR 失效,见 next-intl#2037
一些新发现
OpenNEXT 每次部署后都会刷新全部缓存
在修改了源码并部署到线上之后,我发现在我没有主动清除缓存的情况下,页面的 HTML 却是我更新后的。问了 AI 之后,AI 查到 OpenNEXT 在存储缓存时,会使用 NEXT_BUILD_ID 这个环境变量作为存储缓存的文件夹名称,见源码。
NEXT_BUILD_ID 这个环境变量是 Next.js 在运行 next build 时产生的,每次运行时都不一样。举个例子:
- 第一次部署时,build id 是
abc,OpenNext 会从 R2 的/incremental-cache/abc/目录下写入 / 读取缓存 - 第二次部署时,build id 是
def,那么 OpenNext 会改为从 R2 的/incremental-cache/def/目录下写入 / 读取缓存 - 这样一来,相当于第二次部署前产生的缓存就全都无效了。
查了下源码,发现 OpenNEXT 在很多地方(标签缓存、队列等)都用到了 NEXT_BUILD_ID,也就是说我们就算用了长缓存,也不必担心 HTML 内容不会更新。
顺带一提,OpenNEXT 把 build id 输出到了 https://www.getcheapai.com/BUILD_ID 可以用来确认线上的版本。