潜伏的缓存刺客:Next.js + OpenNext + Cloudflare KV 存储暴涨实录
意外的账单告警:免费的“羊毛”怎么反咬了一口?
这两天,Cloudflare serverless 和 AI 联手背刺开发者的讨论甚嚣尘上,作为老“被背刺家”,我现在每天都会抽空梳理基础设施用量。
昨天,我发现 KV 存储又爆了……但是很奇怪,KV 存储在我的应用里使用的并不多,所以简单排查之后,我把目标锁定在 freeaiapi.org 的缓存。
作为一个面向全球开发者的免费 AI API 导航站,我们在架构设计上一向崇尚极致的能效比:全站基于 Next.js 16,借助 OpenNext 部署在 Cloudflare Workers 上,数据库跑在 D1,媒体资源落在 R2,页面缓存则由 KV 承担增量持久化(ISR)。
多说一句,之前我主要用 R2 做 ISR,但是后来发现 R2 的使用范围很广,容易用超。所以前段时间改成 KV。
在我的认知里,这套 Serverless 架构几乎是性价比的天花板。尤其 Workers KV,每个月拥有 1GB 的免费存储额度,和 10M/1M 的读写,对于一个仅有数百个页面、更新频率并不极端的目录站而言,理论上应该不用花钱才对。
然而,当我昨天点开 Cloudflare 的账单与用量监控面板时,一串刺眼的红色数字让我心如刀绞——是的,我就是这么抠:
计费周期存储量 3.59 GB-month,远超免费用量,正在持续产生额外扣费。
哎,虽然我每个月都被 Cloudflare 账单背刺,但每次背刺的位置都不一样……多半是 FreeAIAPI.org 最近切换到的 ISR 缓存导致的吧。接着,我顺着数据写入的代码链路展开排查,果然挖出一个深埋在 OpenNext 框架底层的缺陷。
源码下的真相:未完成的 TODO 与永久存留的写入
在 Next.js 的静态生成体系中,增量静态再生成(ISR)负责在后台重新渲染页面并持久化快照。OpenNext 为 Cloudflare 适配提供了两种主要增量存储方案:R2 与 KV。
早在 9 月中旬,我们曾短暂使用过 R2 方案,但因为 R2 的用途更广泛,很多地方都要用,于是我们根据官方推荐切到了按读写次数计费、更便宜的 KV 持久化方案,即使用 @opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache。
当时我们理所当然地认为:既然页面代码上写着 export const revalidate = 604800(7 天),那么数据落进 KV 时,自然也会遵循 7 天的生存周期,到期自动被刷新或清理。
但事实不是。当我们直接点开依赖包里 OpenNext 的源码时,硕大的 TODO 赫然在目:
async set(key, value, cacheType) {
const kv = getCloudflareContext().env[BINDING_NAME];
if (!kv)
throw new IgnorableError("No KV Namespace");
debugCache("KVIncrementalCache", `set ${key}`);
try {
await kv.put(this.getKVKey(key, cacheType), JSON.stringify({
value,
lastModified: Date.now(),
})
// TODO: Figure out how to best leverage KV's TTL.
// NOTE: Ideally, the cache should operate in an SWR-like manner.
);
}
catch (e) {
error("Failed to set to cache", e);
}
}
原来官方在实现 KV 增量缓存时,根本就没有给 kv.put() 传递第三个参数 { expirationTtl }!
而在 Cloudflare KV 的规范中:只要写入时不指定过期时间,该 Key 就是永久有效的,永远不会被底层存储引擎自动回收。
官方开发者之所以留下了这行未完成的 TODO,是因为遇到了经典的 SWR 语义冲突:
-
在 Next.js 中,ISR 依赖的是 软过期机制。
-
所以,哪怕过期,旧页面也应该立刻返回给当前访客以保证秒开,同时在后台异步触发重渲染
-
如果生硬地给 Cloudflare KV 设上等于 revalidate 的硬 TTL,一旦到期,Key 直接被物理删除,用户访问时就会被切到 SSR 同步渲染中
-
如果设置多一个变量,又要改变上层框架,也就是 Next.js 的行为
面对这个矛盾,官方开发者没有想好怎么做,于是就把问题搁置了。然后,KV ISR 就变成永不清理的对象存储。
地质层沉淀:为什么普通 Demo 没事,真实项目会炸?
为什么市面上绝大多数基于 OpenNext 的教程和 Demo 从来没有人提过这个问题?
因为普通玩具项目往往只有几个静态页,几个月也不部署,就算永久不删,KV 用量也只有几十兆,很难触及月增 1GB 这条计费红线。
但在一个真实运作的生产级全栈项目中,这一机制会被三重放大催化为一场存储灾难:
1. 随机 Build ID 与版本隔离
为了防止新旧版本发布交替时用户加载到错乱的静态 Chunk,Next.js 和 OpenNext 在生成缓存 Key 时引入了随机生成的 Build ID。查看 OpenNext 的 Key 计算逻辑:
export function computeCacheKey(key, options) {
const { cacheType = "cache", prefix = DEFAULT_PREFIX, buildId = FALLBACK_BUILD_ID } = options;
const hash = createHash("sha256").update(key).digest("hex");
return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/");
}
每执行一次部署,Next.js 就会生成一个新的 Build ID,产生一套新的缓存。
而我在积极开发这个项目,每周都有好几次更新部署。
2. 构建期全量 Bulk Put
每次执行 opennextjs-cloudflare deploy,都会触发 populateCache 命令。它会扫描构建产物中的预渲染页面,通过 wrangler kv bulk put 把全站几百个页面的完整 HTML 与 RSC Payload 快照打包,全量塞进以当前 Build ID 为前缀的缓存。
Free AI API 站点支持 6 种母语级国际化语言,拥有上百个模型详情、数百个 API 端点和数十篇深度文章,单次全站预渲染产物就达数十兆。
3. 老版本瞬时沦为“孤岛僵尸数据”
新版本部署完成后,线上运行的进程读写的全是新 Build ID 的路径。
上一版本的全部预渲染页面与运行时长尾 ISR 页面,瞬间失去了任何代码访问入口。它们既不会被读取,也不会被重写,但因为没有 TTL,全部安详且永久地躺在 KV 磁盘上。
从 9 月下旬开启 KV 持久化至今,随着持续的功能迭代、单测完善、内容更新,我们前后执行了数十次发布。每一次发布,就相当于在地质层上原样覆盖了一层数十兆的全新岩层。几万个包含完整大段富文本、JSON 载荷的历史页面碎片日积月累,最终堆积成 24GB 的庞然大物。
修复问题:切换到新存储+硬性清理缓存
面对已经形成规模的 24GB 历史包袱,成本最低的方式是直接放弃旧桶;而面对未来的防膨胀治理,则必须重构增量缓存的生命周期模型。
我们最终实施了三步走的根治方案:
1. 壮士断腕:启用全新 KV 命名空间
不要浪费宝贵的 API 配额去对旧桶做数万次的分页遍历删除。
新建一个全新的 KV Namespace。拿到 Namespace ID 后,直接替换掉 wrangler.jsonc 里的旧值。
随后,删掉那个塞满 24GB 僵尸数据的旧 Namespace。
最后,祈祷 Cloudflare 计费的时候不要把它算进去……
2. 重构增量缓存:Soft SWR 与 Hard Ceiling 结合
为了兼顾 Next.js 极速 SWR 的良好体验,同时绝不给任何条目永久留存的机会,我们在 apps/web/src/lib/kv-incremental-cache.ts 中自己实现了一个带 TTL 守护的增量缓存适配器:
import { createHash } from "node:crypto";
import { getCloudflareContext } from "@opennextjs/cloudflare";
export const NAME = "cf-kv-incremental-cache";
export const BINDING_NAME = "NEXT_INC_CACHE_KV";
export const DEFAULT_PREFIX = "isr-cache";
// 物理硬淘汰兜底:7 天(与全站内容更新周期对齐)
export const DEFAULT_KV_EXPIRATION_TTL_SECONDS = 7 * 24 * 3600; // 604,800 秒
export const MIN_KV_EXPIRATION_TTL_SECONDS = 60;
export function calculateExpirationTtl(value: unknown): number {
if (value && typeof value === "object") {
const revalidate = (value as { revalidate?: unknown }).revalidate;
if (typeof revalidate === "number" && revalidate > 0) {
// 给予两倍于 revalidate 的生存窗口支持 SWR 兜底,上限封顶 7 天
const candidate = Math.floor(revalidate * 2);
return Math.min(
DEFAULT_KV_EXPIRATION_TTL_SECONDS,
Math.max(MIN_KV_EXPIRATION_TTL_SECONDS, candidate),
);
}
}
return DEFAULT_KV_EXPIRATION_TTL_SECONDS;
}
export class KVIncrementalCacheWithTtl {
readonly name = NAME;
async setWithEnv(key: string, value: unknown, cacheType?: string, env?: Record<string, unknown>) {
const kv = env?.[BINDING_NAME];
if (!kv) return;
const kvKey = this.getKVKey(key, cacheType, env);
const expirationTtl = calculateExpirationTtl(value);
// 核心改造:强制向 Cloudflare KV 注入 expirationTtl
await kv.put(
kvKey,
JSON.stringify({
value,
lastModified: Date.now(),
}),
{ expirationTtl },
);
}
}
在这套架构下:
-
业务层(Soft SWR):缓存内容依旧包含
lastModified和value.revalidate,Next.js 与自定义的withStoredRevalidate包装器继续完美执行软过期判定与后台异步更新; -
物理层(Hard Ceiling):每一个写入 KV 的条目都强制带上了最长 7 天的
expirationTtl。哪怕新版本发布后旧条目彻底失联,7 天一到,Cloudflare 底层存储引擎就会自动将其物理抹除,不需要任何外部脚本干预。
在 apps/web/open-next.config.ts 中,用这个自定义实现无缝替换掉官方的默认导出即可。
3. 补齐构建期预渲染的 Bulk Put 漏洞
不仅运行时生成的 ISR 缓存需要加 TTL,构建期被 wrangler kv bulk put 批量写入的预渲染页面同样不能漏掉。
我们在根目录的 scripts/patch-opennext.mjs 中追加了一段构建补丁逻辑:
// 为构建期 KV bulk put 注入 expiration_ttl(7 天),防止历史预渲染数据堆积
for (const file of populateFiles) {
let content = fs.readFileSync(file, "utf-8");
content = content.replace(
/value:\s*fs\.readFileSync\(fullPath,\s*["']utf8["']\),/g,
`value: fs.readFileSync(fullPath, "utf8"),\n expiration_ttl: 604800,`,
);
fs.writeFileSync(file, content, "utf-8");
}
通过这套组合拳,无论是构建阶段由 OpenNext 预先灌入的预渲染静态资产,还是运行阶段由全球访客触发生成的长尾页面,全链路 100% 的 KV 条目都拥有明确的 7 天生命周期终点。
总结
正所谓月月都上当,当当不一样。自打项目越做越多,越做越深入,我踩中的账单陷阱层出不穷:
-
七月:图片转换坑
-
八月:容器使用坑(忘记关 Container……)
-
九月:
prefetch DDOS 自己 -
10月:KV 缓存爆炸
AI 时代,写代码变得无比轻松;但是做产品,难度并没有低很多。各种坑层出不穷,我们必须打起十二分的精神,不可迷信框架,也不可迷信 AI。需要好好利用 AI 做好监控,随时发现问题。
Serverless 赋予了独立开发者以前所未有的低成本撬动全球基础设施的能力;但是原生的自动扩容能力,也让我们的账单可能无上限扩张,随时被 Cloudflare“没收”财产。
唯有扒开黑盒,直面源码,才能在一次次避坑排障中,把架构打磨得真正健壮可靠。
📬 关注与交流
如果你也正在探索 Next.js、OpenNext、Cloudflare Workers 全栈架构与开源 AI 大模型的工程落地,欢迎持续关注我。我会分享最一线的开发经历,帮大家避免我踩过的坑。
相关文章
乐极生悲的 Next.js+OpenNext 生产踩坑排错实录:被10+M R2读操作和10M+ Worker 请求轮番肘击的一天
前几天运气不错,FreeAIAPI 出了篇爆款文章,结果,立刻就把基础不牢的网站打爆了。前脚修好 R2 的 10+M 读操作泄漏,后面 Cloudflare Worker 又爆出 10+M 请求。复盘
Next.js + Cloudflare Workers 上的 OG Image 完全指南:从零到生产
Next.js 16 + OpenNext + Cloudflare Workers 上从零搭建 OG image 体系的完整教程与避坑笔记。
Next.js 在 Cloudflare Workers 上生成 OG 图:Satori、缓存与 2026 预热实践
在 Cloudflare Workers 上为 Next.js 生成 Open Graph 图片:Satori/resvg 限制、冷启动与 CPU 时间、R2/CDN 缓存与发布时预热,附可复制的 r


