从 Next.js 迁移到 Astro:Cloudflare Workers CPU Time 性能对比

2026年7月27日7336 18分钟
Cayden
作者Cayden

独立开发者,做一些有趣的东西,在这里分享我的产品、技术经验和一些思考

欢迎关注微信公众号 CayDock

最近做了一个电商导航站,并把它从 Next.js 迁移到了 Astro。原因很简单——部署在 Cloudflare Workers 上时,多访问几个页面就会出现 CPU Time 超限提示。迁移到 Astro 后,同样的页面、同样的流量,CPU Time 几乎可以忽略不计。

这篇文章把整个过程记录下来,包括问题复现、ISR 缓存尝试、迁移原因、以及迁移前后的真实 CPU Time 对比数据。


先说结论

数据全部来自 Cloudflare GraphQL Analytics API,采样窗口为 2026-07-26 ~ 2026-07-27,迁移时间点约为 Jul 26 18:00 GMT+8(跳过该小时避免数据混合)。迁移前窗口 506 次请求,迁移后窗口 1139 次请求。

指标Next.js + OpenNextAstro(迁移后)降幅
CPU Time P50222.5ms5.6ms-97.5%
CPU Time P75377.7ms25.3ms-93.3%
CPU Time P90524.6ms50.3ms-90.4%
CPU Time P99871.5ms92.0ms-89.4%
CPU Time P9991190.7ms118.4ms-90.1%
Request Duration P50222.5ms15.0ms-93.3%
Request Duration P75377.7ms74.8ms-80.2%
Request Duration P90524.6ms114.6ms-78.2%
Request Duration P99871.5ms147.1ms-83.1%
Request Duration P9991190.7ms174.0ms-85.4%
每请求平均 CPU237ms16.7ms-93.0%
Max Memory53.9MB15.2MB-71.8%
是否触发 CPU Time 警告✅ 经常❌ 从未

最直观的指标:CPU Time P999 从 1190ms 降到 118ms,刚好压到 100ms 量级。 这意味着哪怕是最慢的 0.1% 请求,也再不会撞到 Cloudflare 的软限制。


项目背景:一个电商导航站

最近做了一个电商导航站,主要收录各类电商平台、品牌与购物相关网站。技术上的需求很简单:

  • 内容存放在 Markdown / MDX 中,构建时生成静态页面
  • 提供分类、标签筛选功能,需要少量动态逻辑
  • 全站托管在 Cloudflare 上,免费额度内可持续运行
  • 偶尔需要 SEO 友好的动态元数据

按理说,这是一个典型的静态站 + 少量动态的场景。但在 Next.js 的世界里,它会变成一个全栈 React 应用


最初的技术选型:为什么是 Next.js

最近几个月做这个导航站的时候,我几乎没怎么犹豫就选了 Next.js:

  1. 熟悉度高:过去几年一直在用 Next.js,团队(也就是我自己)积累了大量经验。
  2. 生态完善:MDX、next-intl、D1 适配、OpenNext 适配 CF Workers……看起来一切都现成。
  3. 一份代码,多种渲染模式:SSG、ISR、SSR、DPR 都支持,未来加新功能不用换框架。

部署方案用的是 OpenNext + Cloudflare Workers(不是 Pages,因为 Pages 不支持 Node.js runtime,部分依赖装不上)。整个项目跑起来也很顺利,build 一次能过,部署上去能跑。

直到……开始看监控数据。


问题的出现:CPU Time 超限提示

Cloudflare Workers 的免费套餐对单次请求有 CPU Time 软限制(默认约 10ms 量级),超过后会写警告日志:

Worker exceeded CPU time limit warning

一开始我以为是偶发情况,毕竟电商导航站大部分页面都是静态的。但实际跑了一周后看监控面板,发现情况比想象的严重:

  • 首页 P50 CPU Time 长期在 90ms 以上,远超软限制
  • P90 一度冲到 800ms,P999 多次突破 1s
  • 连续访问 3~5 个页面,必定有一两条 CPU Time Exceeded 的日志

更糟的是,从 GraphQL API 拉到的数据显示——迁移前的 P999 CPU Time 高达 1190ms(接近 1.2 秒)。当 CPU Time 真正突破硬限制时,请求会直接失败,返回 521 或者超时。

这意味着什么?

意味着一个应该 100% 静态的站,在 Cloudflare 上跑得并不轻松——明明只是返回一段 HTML,CPU Time 却被运行时本身吃掉了。


第一轮优化:ISR 缓存

我最初的判断是:肯定是缓存没配好

于是我打开了 Next.js 15 的 ISR(Incremental Static Regeneration):

// app/page.tsx
export const revalidate = 3600 // 1 小时重新生成

// app/category/[slug]/page.tsx
export async function generateStaticParams() {
  return categories.map((c) => ({ slug: c.slug }))
}

export const revalidate = 3600

并且在 next.config.ts 里加上了:

export default {
  experimental: {
    // 启用更激进的静态化
    staticGenerationTimeout: 120,
  },
}

理论上,ISR 会把页面缓存到 KV,访问时直接读缓存,不重新执行组件代码。

实际效果

ISR 确实起了一些作用

  • 第二次访问同一个页面时,CPU Time 显著下降(缓存命中)
  • 缓存命中时不再走 RSC 渲染

但问题没有根本解决

  • 首次访问 / 缓存失效时,CPU Time 仍能冲到 870ms(P99 实际值)
  • ISR 缓存击穿:当多个用户同时访问一个失效页面,依然会触发完整渲染
  • 流量稍大就触发警告:站内几十个分类、几百个详情页,缓存空间被撑满
  • 每次重新部署都触发全量失效

说白了,ISR 解决的是重复访问的成本,但没解决单次请求的基线成本。而 P999 高达 1190ms 的尖刺,正好来自这些"首次访问 / 缓存失效"的请求。


关键洞察:问题不在缓存,在运行时

翻了一堆监控数据后,我发现一个反直觉的事实:

这个导航站 99% 的页面是纯静态的。

也就是说,理论上一次构建、永久静态文件就够了。但 Next.js 的运行时是这样的:

请求 → Worker 启动 → OpenNext 适配层 → Next.js Runtime → 
React Server Components 渲染 → HTML 序列化 → 响应

即使是完全静态的页面,每次请求还是要经过这套链路。CPU Time 居高不下,本质原因是:

1. OpenNext 适配层很重

为了让 Next.js 跑在 Workers 上,OpenNext 加了一层 shim:

  • 模拟 Node.js API
  • 适配 Request/Response
  • 加载 Next.js runtime

光这一层,cold start 就要几十毫秒。

2. React Server Components 本身不便宜

即使是 RSC,每个组件还是要经过 React 的渲染管线:

  • 解析 JSX
  • 执行组件函数
  • 流式序列化
  • 处理 hydration 元数据

对一个纯展示的卡片列表来说,这些步骤纯属浪费。

3. 客户端 hydration JS

Next.js 默认会生成 hydration bundle,让页面"可交互"。但这个导航站的大部分页面根本不需要交互,纯属增加成本。

结论:问题的根源不是"没缓存好",而是 Next.js 的运行时模型对纯静态内容来说太重了。


迁移到 Astro:决策过程

既然问题本质是"运行时太重",那目标就很明确:找一个运行时更轻的框架

候选名单:

框架优势劣势
Astro零 JS 默认、islands 架构、SSR/SSG 灵活生态比 Next 小
TanStack Start类型安全路由、支持 SSR 和静态预渲染、可部署到 Cloudflare仍是全栈 React 框架,对以静态内容为主的项目偏重
VitePress文档站专用、超轻量只适合文档场景
纯静态 HTML最快最省没有动态能力
继续 Next.js熟悉度高CPU 问题无解

TanStack Start 也在考虑范围内。它支持 SSR、静态预渲染和 Cloudflare 部署,类型安全的路由体验也很有吸引力;但它本质上仍是全栈 React 框架。这个导航站以静态内容为主,不需要复杂的客户端状态和数据交互,因此最终选择了默认零 JS、静态优先的 Astro。

Astro 是最合适的:

  1. 零 JS by default:默认不发送任何 JS 到浏览器
  2. Islands 架构:只有需要交互的组件才会被 hydrate
  3. 支持 SSR / SSG / 混合:既能纯静态,也能按需动态
  4. 原生 Cloudflare 适配:官方 @astrojs/cloudflare 适配器,部署简单

迁移实施

1. 项目结构改造

flowcay/
├── src/
│   ├── content/          # Astro Content Collections(替代 Velite)
│   ├── components/       # .astro 组件
│   ├── pages/            # 路由
│   └── layouts/
├── astro.config.mjs
└── wrangler.toml

2. Astro 配置

// astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'

export default defineConfig({
  output: 'static', // 默认全静态
  adapter: cloudflare({
    platformProxy: { enabled: true },
  }),
  build: {
    inlineStylesheets: 'auto',
  },
})

对于需要动态的部分(比如用户自定义导航、收藏功能),可以用 Astro 的 export const prerender = false 单独标记为 SSR。

3. Content Collections

Astro 自带的 Content Collections 替代了之前用的 Velite:

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const sites = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    url: z.string().url(),
    category: z.string(),
    tags: z.array(z.string()).default([]),
  }),
})

export const collections = { sites }

4. 部署

用 Astro 的 Cloudflare 适配器直接构建:

npm run build
wrangler deploy

构建产物就是一个标准的 Worker,加上一堆静态资源。


迁移前后性能对比

以下数据全部来自 Cloudflare GraphQL Analytics APIworkersInvocationsAdaptive 数据集),采样窗口:

  • 迁移前:Jul 26 00:00 ~ 17:00 GMT+8(506 次请求)
  • 迁移后:Jul 26 19:00 ~ Jul 27 23:00 GMT+8(1139 次请求)

跳过 Jul 26 18:00 那个小时,避免迁移过程中的混合数据干扰。

CPU Time(最关键的指标)

这是导致我迁移的直接原因。P999 从 1190.7ms 直接降到 118.4ms,整整 10 倍 的差距。

分位Next.js + OpenNextAstro降幅
P50222.5ms5.6ms-97.5%
P75377.7ms25.3ms-93.3%
P90524.6ms50.3ms-90.4%
P99871.5ms92.0ms-89.4%
P9991190.7ms118.4ms-90.1%

平均每请求 CPU:237ms → 16.7ms(-93%)。

迁移前 P99 接近 900ms、P999 突破 1s——这正是 Cloudflare 免费套餐下 CPU Time 警告频发的原因。迁移后所有分位都稳稳压在 100ms 量级(P999 也只有 118ms),警告彻底消失。

Request Duration(用户感知到的响应时间)

分位Next.js + OpenNextAstro降幅
P50222.5ms15.0ms-93.3%
P75377.7ms74.8ms-80.2%
P90524.6ms114.6ms-78.2%
P99871.5ms147.1ms-83.1%
P9991190.7ms174.0ms-85.4%

P50 从 222ms 降到 15ms——这是用户能直接感知的提速,页面"啪"一下就出来了。

更值得注意的是:迁移前 Duration == CPU Time(每个分位都几乎相等),说明 Next.js 版本下每个请求都是 100% CPU-bound,整个请求耗时完全被运行时吃光,没有给网络/edge 留任何余量。

迁移后 CPU Time < Duration(如 P50: 5.6ms vs 15ms),说明 Worker 现在非常快,绝大部分耗时都在 edge / CDN / 网络层——这正是静态化应该有的健康表现。

Memory usage(内存占用)

GraphQL API 不暴露 Memory 的分位数(只有 max 字段),所以这里使用 max 值对比:

指标Next.js + OpenNextAstro降幅
Max Memory53.9MB15.2MB-71.8%

迁移后内存峰值从 53.9MB 降到 15.2MB,差不多是原来的 1/3.5。

内存占用降低对 Workers 来说意义不大(Workers 是按请求计费,不是常驻),但更小的内存占用往往意味着更小的 cold start 开销,这也是迁移后 P50 响应时间下降的原因之一。

实际体验

  • CPU Time 警告:迁移后再未触发过(迁移前几乎每小时都有几次)
  • 页面响应:P50 从 222ms 降到 15ms,体感是"完全无感的加载"
  • Cloudflare 监控面板:之前那条 10ms 的红色警告线再也不亮了

为什么 Astro 能这么省?

回过头看,本质上是因为 Astro 的设计哲学:默认零 JS + 静态优先

1. 默认就是静态

Astro 默认 output: 'static',构建时生成纯 HTML 文件,部署到 Workers 后基本上是零运行时开销——直接返回 KV/Cache 中的文件。

2. 没有 RSC 运行时

Astro 的 .astro 组件在构建时就编译成了 HTML,没有 React runtime,没有 RSC 流式序列化,没有 hydration metadata。

3. Islands 按需加载 JS

如果某个组件真的需要交互(比如搜索框),用 client:load 显式标记:

<SearchBox client:load />

Astro 才会为这个组件打包并 hydrate JS。其他静态部分保持纯 HTML。

4. 适配器轻量

@astrojs/cloudflare 适配器非常薄,只做必要的胶水代码,不像 OpenNext 那样要模拟一整套 Node.js 环境。


反思:什么时候不该用 Next.js

这次迁移让我重新思考了框架选型的问题。

Next.js 是好框架,但它的"好"是有成本的

  • 适合有大量动态交互的应用(Dashboard、SaaS、电商后台)
  • 适合需要 Server Actions / 流式响应的场景
  • 适合客户端状态复杂的项目

Next.js 不适合的场景

  • 纯静态内容站(博客、文档、导航站)
  • SEO 优先、几乎无交互的展示型页面
  • 资源受限的部署环境(Cloudflare Workers 免费套餐)
  • 对 cold start 敏感的场景

这个导航站属于第二类。选 Next.js 是典型的"用锤子找钉子"——因为我熟悉 Next.js,所以所有项目都用它,结果把一个本该零成本的静态站搞成了重运行时应用。


经验总结

最后把这次迁移的核心经验总结一下:

  1. CPU Time 超限往往是运行时问题,不是缓存问题。加缓存只能缓解,不能根治。
  2. 静态内容用静态方案。如果 99% 是静态,就别用 Next.js 这种全栈框架。
  3. Astro 是 Cloudflare 平台上的最优解之一。零 JS、islands、原生 CF 适配,CPU Time 几乎为 0。
  4. 不要让熟悉度绑架选型。每个框架都有最佳场景,超出场景就会付出代价。

迁移完成于 2026 年 7 月。如果你也在 Cloudflare Workers 上遇到 CPU Time 问题,欢迎交流。

分享:

发表评论

最少 3 个字符您的邮箱不会被公开。标有 * 的字段为必填项(邮箱选填)

暂无评论。成为第一个分享想法的人吧!