返回博客

降低 Cloudflare Workers 上 OpenNext 的 KV 存储成本

2026-05-115 min read

Learn English Sounds 开始每天在 Cloudflare KV 存储上花费 0.50 美元。对于一个之前托管成本几乎为零的静态内容网站来说,这似乎不是很多钱。我深入研究了一下,发现了一个经典的 Cloudflare OpenNext 陷阱。如果你通过 @opennextjs/cloudflare 适配器在 Workers 上运行 Next.js,这个陷阱很可能也会影响到你。

什么是 Cloudflare KV?

Cloudflare KV(Key-Value 的缩写)是一个全球分布式键值存储,专为读密集型工作负载而设计。你写入一次值,它最终会复制到世界各地的数据中心,从而实现从任何地方快速读取。定价有两个组成部分:操作(读、写、删)和存储(按 GB-月收费)。前 1 GB 存储是免费的,之后每增加 1 GB-月大约收费 0.50 美元。当某些东西悄悄地用陈旧数据填满命名空间时,第二个数字就会让你付出代价。

对于 OpenNext,KV 用于缓存渲染的页面,这样未来的请求就不需要重新渲染。它速度快,每次操作成本低,非常适合缓存 HTML 和 JSON。陷阱在于 KV 中的任何内容都不会自动过期。如果你写入一个键,它会一直存在,直到你删除它。

设置

该网站使用 OpenNext 将 Next.js 部署到 Cloudflare Workers。增量缓存(ISR 和完整路由缓存背后的存储层)配置为使用 KV 和区域缓存包装器。相关的配置如下所示:

import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
import { withRegionalCache } from "@opennextjs/cloudflare/overrides/incremental-cache/regional-cache";
import d1TagCache from "@opennextjs/cloudflare/overrides/tag-cache/d1-next-tag-cache";

export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(kvIncrementalCache, {
    mode: "long-lived",
    bypassTagCacheOnCacheHit: true,
  }),
  tagCache: d1TagCache,
});

这正是 OpenNext 文档推荐的。静态页面在构建时渲染,写入 KV,并通过区域 Cache API 层提供服务。CPU 使用率下降,因为 worker 很少需要渲染任何东西。存储成本本应是一个四舍五入的误差。

什么是区域缓存?

值得停下来思考一下,因为它解释了为什么清理 KV 不会损害性能。Cloudflare 为每个 worker 提供了对 Cache API 的访问权限,这是一个位于请求命中的边缘数据中心的每区域内存缓存。OpenNext 的 withRegionalCache 包装了 KV 增量缓存,并将该 Cache API 用作第一层。

任何缓存页面的查找顺序是:首先是区域 Cache API,然后是 KV,最后是重新渲染。一个区域中的第一个请求会从 KV 中提取页面并将其存储在本地缓存中。该区域中的每个后续请求都将从边缘的内存中提供服务,而无需访问 KV 或运行 worker 的渲染代码。使用 mode: "long-lived",区域副本会一直保留,只要 Cloudflare 保持它们,在热节点上通常是几分钟到几小时。

所以 KV 是冷存储。它只在区域缓存是冷的或被驱逐时才会被读取。删除未引用的 KV 键不会改变 KV 的读取频率;它只会改变你存储的数量。CPU 不会移动,因为热路径的任何东西都没有改变。

我在 KV 中实际发现了什么

我列出了命名空间中的键。共有 276,637 个。每一个都以 incremental-cache/ 开头。按第二个路径段分组揭示了真实情况:

$ wrangler kv key list --namespace-id <id> --remote \
    | jq -r '.[].name' | awk -F/ '{print $2}' \
    | sort | uniq -c | sort -rn | head

17896 Mqxc5oa6LB-nPqyGFrVbV
13068 zMp2Qi0YY-gVHmaXRB9Ce
 9108 zMlI9hb_vFtk705L8GDKL
 8748 -KcEM0U2hAvoQH7n5dyV5
  ...
 5895 W-RYs65XPM1m0Dg899-cz

三十九个不同的前缀。每一个都是过去部署的 Next.js BUILD_ID。每次 next build 都会生成一个新的构建 ID。每次部署都会写入一个以该 ID 范围限定的新缓存条目集合。活动的 worker 只会读取其自身构建的前缀。旧前缀是纯粹的死重,OpenNext 或 Cloudflare 中没有任何东西会为你清理它们。

这就是一个活跃的网站如何悄悄地积累超过 250,000 个孤立键。

为什么你活跃的构建也在增长

列表中还有第二个值得注意的模式。最大的前缀有 17,896 个键,次大的有 13,068 个,其余大部分在 6,500 个左右徘徊。最大的那个是当前活动的构建。它比其他构建有更多的键,因为实际流量会随着时间的推移不断将新的长尾页面填充进去。这是正常的,也是可以的。问题在于它下面的所有内容。

修复方法

活动的 worker 只从 incremental-cache/<current-BUILD_ID>/ 读取。因此,在每次成功部署后,删除所有不以该前缀开头的所有键。你删除的键保证不会被任何活动的 कोड路径引用。这不是一个消耗 CPU 的缓存无效化;这是在清理从未被读取的垃圾。

部署后运行的清理脚本

我在项目中添加了一个 Node 脚本,并将其链接到部署命令。它从 OpenNext 构建输出中读取构建 ID,列出所有 KV 键,并以 10,000 个键的块(wrangler 的批量删除限制)删除活动前缀之外的所有内容。

// scripts/prune-kv-cache.mjs
// ---------------------------------------------------------------
// 在每次成功部署后从 Cloudflare KV 中删除陈旧的 OpenNext 增量缓存条目
// 作为部署后步骤运行。
// ---------------------------------------------------------------

import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";

// Cloudflare KV 命名空间,为 OpenNext 的增量缓存提供支持。
// 在 wrangler.toml 的 [[kv_namespaces]] 下为 NEXT_INC_CACHE_KV 查找此 ID。
const KV_NAMESPACE_ID = "<your-namespace-id>";

// OpenNext 在 `incremental-cache/<BUILD_ID>/...` 下写入缓存键。
// 活动的 worker 只读取其自己的 BUILD_ID,因此任何其他前缀都是垃圾。
// 我们从磁盘读取新构建的 BUILD_ID,以便脚本始终匹配
// 刚刚发生的部署。
const KEEP_BUILD_ID = readFileSync(".open-next/assets/BUILD_ID", "utf8").trim();

// wrangler 的 `kv bulk delete` 每次最多接受 10,000 个键。
const CHUNK_SIZE = 10000;

// 1) 列出命名空间中的每个键。
// maxBuffer 增加到 512 MB,因为大型网站可以返回数十 MB 的 JSON。
const list = spawnSync(
  "npx",
  ["wrangler", "kv", "key", "list", "--namespace-id", KV_NAMESPACE_ID, "--remote"],
  { encoding: "utf8", maxBuffer: 1024 * 1024 * 512 }
);

// 2) 决定保留什么和删除什么。
// 保留:   incremental-cache/<KEEP_BUILD_ID>/...   (活动的 worker 从这里读取)
// 删除: incremental-cache/<任何其他内容>/...  (来自先前部署的孤立键)
// 第二个 `startsWith` 检查会忽略非缓存键。
const keepPrefix = `incremental-cache/${KEEP_BUILD_ID}/`;
const toDelete = JSON.parse(list.stdout)
  .map((k) => k.name)
  .filter((n) => n.startsWith("incremental-cache/") && !n.startsWith(keepPrefix));

// 3) 以 10k 键的块批量删除孤立的键。
// wrangler 从磁盘上的 JSON 文件读取每个块,因此我们使用临时目录
// 并在最后清理它,无论成功还是失败。
const workDir = mkdtempSync(join(tmpdir(), "kv-prune-"));
for (let i = 0; i < toDelete.length; i += CHUNK_SIZE) {
  const chunkPath = join(workDir, `chunk_${i}.json`);
  // wrangler 期望一个 JSON 数组键名,例如 ["key1", "key2", ...]
  writeFileSync(chunkPath, JSON.stringify(toDelete.slice(i, i + CHUNK_SIZE)));
  spawnSync(
    "npx",
    ["wrangler", "kv", "bulk", "delete", "--namespace-id", KV_NAMESPACE_ID, "--remote", chunkPath],
    { stdio: "inherit" } // 将 wrangler 的进度条直接流式传输到终端
  );
}

// 4) 清理临时目录并报告我们所做的。
rmSync(workDir, { recursive: true, force: true });
console.log(`Pruned ${toDelete.length} stale cache keys.`);
然后我将其集成到 package.json 的部署链中,使其每次自动运行:

"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy --cacheChunkSize 1000 && npm run prune-kv-cache",
"prune-kv-cache": "node scripts/prune-kv-cache.mjs"

顺序很重要。先构建,以便磁盘上存在新的 BUILD_ID。然后部署,以便新的 worker 正在运行并从新前缀读取。最后清理,以便在切换完成后立即可以安全地删除旧前缀。

结果

在我清理过的网站上,KV 的键数从 39 个构建中的 276,637 个减少到单个活动构建中的 7,619 个,减少了约 97%。预计的每日存储成本从 0.50 美元降至几美分。没有代码路径更改,没有缓存策略更改,没有 CPU 回退。区域缓存仍然像以前一样包装 KV,因此热读取来自 Cache API,并且永远不会触及存储。

何时不应清理

对于单版本部署,清理是正确的默认设置,但确实存在可能损害你的实际情况。

即时回滚作为安全网。 如果你依赖 wrangler rollback 在几秒钟内切换回先前的 worker 版本,则在清理后,先前版本的缓存将丢失。回滚仍然有效,但回滚后的第一波请求将全部丢失并重新渲染。CPU 会在几分钟内飙升。如果这很重要,请修改脚本以保留最后 N 个构建 ID,而不是仅保留活动的那个。

滚动发布或金丝雀部署。 如果正在进行 Cloudflare 滚动发布或任何类型的渐进式推出,则多个 worker 版本正在同时提供流量。每个版本都需要保留其自己的构建前缀。在推出达到 100% 之前不要清理。

部署期间的进行中请求。 在新 worker 启动时,旧 worker 仍然可能提供少量滞后请求的短暂窗口。它们将错过缓存并重新渲染一次。对于大多数网站来说,这是看不见的。如果你处理突发流量,了解这一点是值得的。

关于 R2 作为替代方案的说明

OpenNext 还提供基于 R2 的增量缓存。R2 存储比 KV 大约便宜 33 倍(撰写本文时,每 GB-月约 0.015 美元 vs 0.50 美元),并且区域缓存包装器以与 KV 相同的方式位于其前面。因此,你的热路径性能不会改变,但冷存储的成本会大大降低。出于卫生原因,清理仍然有意义,但成本压力基本消失了。如果你的网站足够大,即使是活动构建的缓存也相当可观,那么切换到 R2 是值得考虑的。

保持更新

将最新文章和见解发送到您的收件箱。

Unsubscribe anytime. No spam, ever.