Skip to content
返回

Cloudflare Workers 及相关技术栈的理解

后续:

虽然理论理解的很好,但是实际部署后遇到了缓存不更新的问题——即使我调用了 revalidateTag + cachePurge,页面上看到的还是旧数据。

尝试清空了标签缓存和 r2 里面的所有数据,但网站上看到的仍然是旧数据。AI 分析问题大概率出在边缘节点上,也就是 revalidateTag + cachePurge :

以上三种情况,只要有一个按照预期工作,都不会出现缓存不更新的问题。

感觉 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 个参数就很好理解了:

按需缓存策略和标签缓存

文章一开始,我想要的需求其实就是「按需缓存策略」:给页面设置长达一年的缓存(本质上可以设更久,因为我并不需要页面根据时间来过期),然后我自己主动调用 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 每次部署后都会刷新全部缓存

在修改了源码并部署到线上之后,我发现在我没有主动清除缓存的情况下,页面的 HTML 却是我更新后的。问了 AI 之后,AI 查到 OpenNEXT 在存储缓存时,会使用 NEXT_BUILD_ID 这个环境变量作为存储缓存的文件夹名称,见源码

NEXT_BUILD_ID 这个环境变量是 Next.js 在运行 next build 时产生的,每次运行时都不一样。举个例子:

  1. 第一次部署时,build id 是 abc,OpenNext 会从 R2 的/incremental-cache/abc/ 目录下写入 / 读取缓存
  2. 第二次部署时,build id 是 def,那么 OpenNext 会改为从 R2 的/incremental-cache/def/ 目录下写入 / 读取缓存
  3. 这样一来,相当于第二次部署前产生的缓存就全都无效了。

查了下源码,发现 OpenNEXT 在很多地方(标签缓存、队列等)都用到了 NEXT_BUILD_ID,也就是说我们就算用了长缓存,也不必担心 HTML 内容不会更新。

顺带一提,OpenNEXT 把 build id 输出到了 https://www.getcheapai.com/BUILD_ID 可以用来确认线上的版本。


分享这篇文章:

上一篇
一些低价中转站体验
下一篇
深度 AI 编程一个月之后的体验报告。