乐极生悲的 Next.js+OpenNext 生产踩坑排错实录:被10+M R2读操作和10M+ Worker 请求轮番肘击的一天

选择 Cloudflare Worker 的时候,我最喜欢说的一句话就是:“随便用,我跟你讲,随便用!$5/月用到老。”轮到框架的时候,我也喜欢说:“现在是 AI 时代,选用户量最大的。那一定是 AI 最熟悉的,最不容易出问题的。所以我推荐 Next.js + OpenNext。”

我觉得我话也不算错。可是,我前天就被这两句话联合肘击了……

前两天运气不错,一篇关于 AMD 免费 AI API 的文章 爆了,网页几千点击,X 10w 浏览,小红书 1w 浏览。这本来是件好事,但是当我下午突然收到 Cloudflare 的账单邮件,说我的 Worker 账单已经超过阈值时,我的笑容凝固在脸上。

补充一些技术背景:

按理说这套技术方案并不激进,不抽卡,相当稳扎稳打,细水长流。可是,现实就是这么骨感,我还是踩到两个坑,净亏 20刀。

  1. 第一波:R2 B操作雪崩。全天只有 2300 个真实访客,R2 Dashboard 却刷出了 787 万次 B 类读操作

  2. 第二波:Worker 请求风暴。刚把 R2 治得服服帖帖,两天后抬头一看,Cloudflare Worker 调用量直接冲破 1000 万次,浏览器 Network 里的 _rsc 预取请求像着了魔一样无限循环。

下面我就详细拆解这两个问题,帮助大家了解问题的根源,希望大家不要重蹈我的覆辙。

2300 个用户,怎么烧掉 787 万次 R2 B(读)操作?

我早上随手刷 Cloudflare 账单时,看到一根奇怪的柱子一柱擎天。

R2 B类操作

当天全站 UV 也就 2300 左右,可 R2 的指标赫然显示:

  • A 类操作(写:PUT / LIST 等):1.9 万次

  • B 类操作(读:GET / HEAD 等)787 万次

两个数字的比值超过了 400:1

抓虫抓线索:A/B 比值暴露了什么?

排查对象存储账单,A/B 操作比值是最重要的定性工具:

  • 如果是缓存穿透(MISS):每次 MISS 回源重新渲染,势必把结果写回 R2,此时每一次 MISS 都会对应至少 1 次 PUT。如果 700 多万次全因 MISS 产生,A 类操作早该被写到几百万次了。但 A 类只有区区 1.9 万次,说明全天整站重新渲染生成的缓存不过几千次。

  • 结论很明确:烧掉的 700 多万次,全发生在缓存命中(HIT)的时

我顺手把主域名 Zone 的 CDN 边缘总请求数调出来比对:同一时间段内 Zone 请求是 7.07M,R2 B 类是 7.14M,曲线几乎是像素级贴合。

Worker request 记录
R2 B读操作记录

(这里的 7M 请求跟 3000 UV 也对不上,请大家记住,下半部分我们会修这个。)

这意味着:用户发起的几乎每一个请求,打到源站 Worker 时,都在背后附带了一次 R2 读操作。

顺藤摸瓜:是谁在 Cache HIT 时读 R2?

排查代码,网站代码根本没有直接操作 R2 的业务代码,唯一连着 R2 的就是 OpenNext 的增量缓存(Incremental Cache)。

翻看出事时的配置:

// open-next.config.ts(出事版本)
incrementalCache: withRegionalCache(r2IncrementalCache, {
  mode: "long-lived",
}),

这里的机制是:以 R2 为远端持久化缓存,外层套一层基于 Cloudflare Cache API 的 RegionalCache(区域级内存)挡在前面。

我翻出 regional-cache.js 翻看其 get() 实现,赫然发现:在 Next.js 16 下,shouldLazilyUpdateOnCacheHit 的默认值是 true

它的运行逻辑是:

  1. 客户端请求打到当前区域的 Worker,RegionalCache(Cache API)命中。

  2. Worker 马上把内容返回给前台用户(首屏很快,前台感知不到延迟)。

  3. 但是,Worker 内部立刻通过 context.waitUntil() 在后台再次发起 R2 读请求,试图刷新本地 Regional 条目。

也就是说:前台命中 = 0 次 R2 前台阻塞 + 1 次 R2 后台偷跑。

如果只是这样倒还不至于毁灭,致命的是当时叠加了另外两层放大因子:

  1. CDN TTL 设得太短:当时全站 CDN s-maxage 首页只设了 600 秒,内页设了 1800 秒,边缘根本存不住内容。

  2. 边缘节点各自为政:Cloudflare 在全球有 300 多个数据中心。每个边缘节点各自过期、各自回源。

过短的 CDN 寿命 × 全球数百个独立 PoP × 每次命中必触发 1 次后台读,直接把 2300 个用户的正常访问,杠杆式放大了成 700 多万次 R2 计费操作。

四刀下去,B 类彻底归零

定位清楚后,对症下药其实非常迅速:

  1. 切断后台偷读,止住出血口
    open-next.config.ts 中显式关闭:

incrementalCache: withRegionalCache(r2IncrementalCache, {
  mode: "long-lived",
  shouldLazilyUpdateOnCacheHit: false, // 别再背地里读 R2 了
}),

关闭后的代价是:区域缓存按 TTL 自然过期,依靠 Next.js 的 ISR revalidate 以及内容发布时的 revalidateTag 兜底,对于内容型网站完全绰绰有余。

  1. 缓存与媒体分桶
    NEXT_INC_CACHE_R2_BUCKET 从混用的媒体桶独立出来,单独建一个 freeaiapi-cache 桶。同时加上 R2 Lifecycle 规则:incremental-cache/ 前缀对象 7 天后自动过期。分离后,哪里有 bug 就比较容易看出来。
    这里我也提醒大家,如果你也使用这套方案缓存预渲染结果,记得配置 R2 Lifecycle 规则,否则历史构建结果会越来越多,占用大量空间。

  2. 精简 Next.js 图片变体
    从默认十几组尺寸砍到最核心的 5 组(deviceSizes: [640, 1080, 1920]imageSizes: [64, 256]),避免不同屏幕尺寸的图片变换产生过多衍生缓存。

  3. 拉长缓存生命周期至一天
    把整站 CDN s-maxage 和路由的 revalidate 统一拉长到 86400(24 小时)。日常内容变更走 CMS 的 Tag 定向失效,紧急部署时只要换了 buildId,全站缓存天然刷新。

改完重新部署后,效果立竿见影:每次部署只在初始化写缓存时产生几千次固定的 A 类“开机税”,B 类每小时调用量直接从 1,000,000 级崩塌式暴跌,随着区域节点变暖,最终稳定在几乎归零的水平。

修复后,不再重复更新缓存

R2 刚熄火,Worker 请求风暴又来了

R2 的账单危机只是前菜。下午,我突然收到 Cloudflare 的账单警报:Worker 账单已经超过我设定的限额。

我赶紧再次刷新账单,发现短短两天,我的站点消耗了接近 1000 万次 Worker 调用,同时 Observability 也爆掉了。

整个账单都爆掉了

这比 R2 还要棘手——R2 好歹有清楚的 A/B 分类,而 Worker 监控里只能看到请求数在呈阶梯状疯狂上窜。

初步排查

我第一时间猜测是用户量暴涨。但是查看 GA,也就几千个 UV,虽然是有一个尖峰,但是我全部网站加起来,每个月的 UV 远不止这个数,所以只是短时间内用户量暴涨不应该导致这种在总量上从未出现的情况出现。

另一个奇怪的现象是:访问量最大的页面是 /contact,这就更奇怪了,按理说不会有人关注我这个页面。

难道是被人攻击?可是我一个前几天只有几十,这两天只有几千的小破站谁要攻击我呢?

先确定下有没有顺利走缓存吧还是。

现场还原:Network 面板里的无尽瀑布

应该说我的运气不错,因为我很快就找到真正的症结,虽然是误打误撞。

我打开页面,打开浏览器 DevTools 的 Network 面板,本意是想确认一下各个请求的缓存情况,没想到眼前的一幕让我目瞪口呆:请求流完全没有停下来的意思,控制台不断滚过一条条请求:

GET /?_rsc=1a2b3c
GET /contact?_rsc=1a2b3c
GET /models?_rsc=1a2b3c
GET /providers?_rsc=1a2b3c
GET /about?_rsc=1a2b3c
...

这是 Next.js App Router 特有的 React Server Components(RSC)预取请求。

正常的 Next.js 页面中,带有 <Link> 的元素进入视口后,浏览器确实会发起一次 _rsc 请求把目标页面的 RSC payload 提前拉下来。预取完了,网络传输就该彻底安静。

但现场的情况是:浏览器一直在重复预取同一批路由,永不休止!

这就解释了为什么后端的几个特定路由(/contact/models 等)请求量高得匪夷所思。根本不是黑客刷接口,也不是用户疯了似地点链接,而是每一个打开页面的访客浏览器,都在后台化身成了一台不停向后端发起攻击的肉鸡。

排除法:从 optimisticRouting 到 Cache Interception

项目前一阵刚跟进升级到 Next.js 16.3。App Router 在 16.3 引入了不少新的路由预取与推测机制。

第一反应是先关掉新特性的嫌疑犯:

// next.config.ts
experimental: {
  optimisticRouting: false,
}

打包、部署、清缓存、刷新页面——毫无变化,_rsc 依旧狂飙。

既然 Next.js 核心配置没用,问题必然出在 Next.js 与 OpenNext 的接合部。我顺着 @opennextjs/cloudflare 的改动与 issue 往下排查,目光锁定在 open-next.config.ts 里的一个开关:enableCacheInterception

翻查社区 issue,果然在 opennextjs-cloudflare#1348(以及关联的 opennextjs-aws#1212)里找到了真凶:

Next.js 16 + Cache Interception 的段预取(Segment Prefetch)Bug

开启 enableCacheInterception: true 后,OpenNext 会试图在 Cloudflare 的路由层直接拦截并响应 ISR/SSG 的缓存内容,省去启动 Next.js server 实例的开销。

但在 Next.js 16 下,当客户端发起一个只请求部分段(segment)的 RSC 预取时,缓存拦截器因为路由匹配逻辑缺陷,错误地返回了整页的完整 RSC Payload 及全量 Header

Next.js 的前端路由状态机收到这一串货不对板的数据后懵了:它找不到它要找的特定 segment 数据,导致这个预取的 Promise 永远无法完成(Mark as settled)。

只要这个 <Link> 还在用户屏幕视口内,Next.js 的调度器就判定“这个链接还没成功预取完成”,于是下一刻立刻重新发起。周而复始,死循环诞生。

止血方案:关停缓存拦截

在 OpenNext 官方彻底合并上游修复之前,最稳妥的止血方案就是在 open-next.config.ts 里果断关掉这个拦截开关:

// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";

export default defineCloudflareConfig({
  enableCacheInterception: false, // 关闭有 bug 的缓存拦截
});

重新构建部署,再次打开浏览器,Network 面板里的 _rsc 终于恢复了久违的正常:首屏渲染完毕,静悄悄加载完视口内的预取请求后,网络活动彻底归零。

Weixin Image_20260905001749_225_14.png

防御加固:别让 Next.js 的激进预取拖垮你

关掉 enableCacheInterception 解决了死循环,但这场风波也给我敲了一记警钟:Next.js App Router 默认的 Prefetch 机制,在边缘无服务器(Edge / Serverless)场景下实在是太激进了。

默认情况下,只要你用 <Link href="/somewhere">,当它通过 IntersectionObserver 滚入视口,Next.js 就会主动发起请求。

设想一下:

  • 页面底部庞大的 Footer(常见于隐私政策、关于我们、条款、帮助中心等十几个链接);

  • 分页条(一排 1, 2, 3, 4, 5, 6… 每一个都是 Link);

  • 长列表或卡片网格里的次级标签;

  • 面包屑导航与后台侧边栏。

用户可能只是快速滑到底部看一眼版权信息,浏览器就已经不声不响替他向你的 Cloudflare Worker 发起了四五十个 RSC 预取请求。对于按次计费的 Worker 来说,这是极大的浪费。

于是趁着这次事故复盘,我把手里几个项目彻底过了一遍,确立了预取分级治理原则:

1. 划分高意图与低意图链接

  • 保留默认预取(高意图)

    • 顶栏主导航栏(Header Nav)

    • 品牌 Logo(回首页链接)

    • 漏斗转化的核心 CTA 按钮

  • 强制关闭预取 prefetch={false}(低意图)

    • Footer 的次要条款与外链

    • 分页组件(Pagination)里的数字页码

    • 列表项卡片(PostCard / ProductCard / SkillCard)

    • 面包屑(Breadcrumb)

    • 搜索结果列表

    • 管理后台侧边栏菜单

// 针对次要链路,一律显式声明 prefetch={false}
<Link href="/privacy" prefetch={false}>
  隐私政策
</Link>

<Link href={`/page/${pageNum}`} prefetch={false}>
  {pageNum}
</Link>

这次一口气在全站 35 个组件中把次要链接全部加上了 prefetch={false}。在用户真正将鼠标悬停或点击时,Next.js 依然能迅速响应,但首屏的静默预取请求数直接削减了 70% 以上

最后的复盘沉淀

总结一下,关于 Cloudflare Worker + Next.js + OpenNext 在网站性能方面的最新最佳实践:

  1. 设置合适的缓存时间,这是一切的基础

  2. 手动设置 <Link>prefretch 范围,不要无脑全读

  3. 合理设置图片优化,不要切太多分辨率,既浪费 Image 额度,也浪费缓存和带宽

  4. 设置 R2 Lifecycle 规则,定时清理 ISR

  5. 关注 OpenNext 的 Issue 和 Discussion,既要及时更新,也不要盲目跟进更新……

总结

回顾这一轮救火,实践告诉我们,使用 Serverless 架构、使用 Cloudflare Worker、使用 AI 最熟悉的 Next.js 并不能让我们的开发万事大吉。

随时监控,日常排查数据变化,及时跟进各种问题,都必不可少。

希望这次分享对大家有所帮助,我踩过的坑,你们就不要踩了。

相关文章

觉得文章有帮助?

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

❤️ 赞助我

评论