潜伏的缓存刺客: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 大模型的工程落地,欢迎持续关注我。我会分享最一线的开发经历,帮大家避免我踩过的坑。

相关文章

觉得文章有帮助?

如果我的分享对你有所启发,欢迎通过赞助来支持我持续创作。

❤️ 赞助我

评论