引言:当性能测试暴露短板时
2025 年 10 月 4 日,独立开发者 Theo Browne 发布了一组基准测试,对比 Cloudflare Workers 和 Vercel 的服务端 JavaScript 执行速度。测试结果令人意外:在各种 CPU 密集型任务中,Cloudflare Workers 的表现比 Vercel 上的 Node.js 慢了高达 3.5 倍。这对于以性能著称的 Cloudflare 来说,无疑是一次不大不小的公关危机。
然而,Cloudflare 官方博客随后发布的一篇回应文章,却堪称是技术公关的典范。他们没有回避问题,而是选择迅速、透明地直面问题,深入剖析了性能瓶颈的根源,并分享了解决方案。更难能可贵的是,他们不仅解决了自身产品的问题,还将其中的一些发现和优化回馈给了上游社区,比如向 V8 和 Node.js 项目提交了 Pull Request。
这篇文章不仅是一次出色的危机公关,更是一堂生动的、关于系统性能优化的公开课。它告诉我们,当你的产品在性能上落后于竞争对手时,最佳的回应是深入技术细节,用代码和数据说话。
现在,就让我们跟随 Cloudflare 的脚步,一同深入这场精彩的性能优化之旅。
背景:为什么性能差异如此之大?
在我们深入之前,先简单回顾一下背景知识:
- Cloudflare Workers: 基于 V8 Isolate 技术。Isolate 是一种轻量级的隔离执行环境,它共享同一个进程的内存,但拥有独立的 JavaScript 堆和执行线程。
- Vercel (Node.js): 基于完整的 Node.js 运行时环境。
两者虽然都使用 V8 作为 JavaScript 引擎,但运行模式的差异导致了它们在资源利用和启动速度上的不同。按理说,既然核心都是 V8,执行纯粹的 JavaScript 代码,性能不应该有如此大的鸿沟。那么,问题究竟出在哪里?经过深入分析,Cloudflare 发现了以下几个关键问题:
- 调度算法问题:不是 CPU 速度慢,而是请求排队等待
- 垃圾回收器调优问题:2017 年的配置已经过时
- 框架层面的优化缺失:OpenNext 存在大量不必要的内存分配
- 测试方法本身的问题:基准测试存在配置错误
核心问题一:智能路由与热隔离调度
问题诊断
Cloudflare 在过去一年中推出了智能路由系统,通过一致性哈希环(Consistent Hash Ring)将每个 Worker 固定到特定的"分片服务器"上。这个设计的初衷是减少冷启动,提高性能。
让我用一个具体的例子来说明这个系统的工作原理:
传统模型(随机分配):
数据中心内有 100 台服务器
Worker A 的请求随机分配:
├── 请求 1 → 服务器 23
├── 请求 2 → 服务器 67
├── 请求 3 → 服务器 12
└── 请求 4 → 服务器 89
问题:100 台服务器都要加载 Worker A
└── 内存浪费 + 冷启动风险
新模型(分片):
Worker A 固定分配给服务器 23:
├── 请求 1 → 服务器 10 收到 → 转发到 23 ✅
├── 请求 2 → 服务器 45 收到 → 转发到 23 ✅
├── 请求 3 → 服务器 78 收到 → 转发到 23 ✅
└── 请求 4 → 服务器 23 收到 → 本地执行 ✅
优势:
├── 只有服务器 23 需要加载 Worker A
├── Worker A 永远保持"热"状态
└── 内存使用减少 99%
技术实现细节
系统使用了一致性哈希环来实现分片:
// 一致性哈希环的核心逻辑
async function handleRequest(workerID, request) {
// 1. 计算该 Worker 的"家"在哪台服务器
const shardServer = consistentHash(workerID);
if (shardServer === currentServer) {
// 2a. 如果本地就是"家",直接执行
return await executeWorker(workerID, request);
} else {
// 2b. 如果"家"在别的服务器,转发过去
// 🔥 乐观路由:同时做两件事
const [shardResponse, localWorker] = Promise.race([
// 立即发送请求到分片服务器
forwardToShard(shardServer, workerID, request),
// 同时在本地加载 Worker(备用方案)
lazyLoadWorker(workerID)
]);
// 3. 如果分片服务器过载,返回一个"自己执行"的指令
if (shardResponse.type === 'OVERLOADED') {
// 使用本地已加载的 Worker
return await localWorker.execute(request);
}
return shardResponse;
}
}
问题根源
然而,这个优化的调度算法在面对 CPU 密集型工作负载时出现了问题。当一个 Worker 正在处理 CPU 密集型任务时,后续请求会被阻塞,导致延迟急剧增加。原有的启发式算法无法快速检测到这种情况,特别是当突发的高负载流量来自单一客户端时。
提示:这个问题导致的延迟增加并不会影响计费,因为 Cloudflare 只对实际执行 JavaScript 代码的 CPU 时间计费,而不是等待时间。
解决方案
Cloudflare 更新了算法,能够更早地检测持续的 CPU 密集型工作,并智能地分配流量:
- I/O 密集型工作负载继续聚合到已经"热"的 isolate
- CPU 密集型工作负载被分散,避免相互阻塞
- 新的 isolate 能够更快地启动以应对负载
这个改进已经全球部署,所有用户都能自动受益。
核心问题二:V8 垃圾回收器的时代错配
分代垃圾回收的基本原理
V8 引擎采用分代垃圾回收策略,这基于一个经典的观察:大多数对象的生命周期很短("朝生夕死"),而少数对象会存活很长时间。
V8 的内存布局如下:
┌─────────────────────────────────────────┐
│ V8 Heap Memory │
├─────────────────────────────────────────┤
│ Young Generation (新生代) │
│ ┌───────────────────────────────────┐ │
│ │ Nursery (育儿区) │ │
│ │ - 所有新对象首先分配在这里 │ │
│ │ - 使用 Scavenger/Cheney 算法 │ │
│ │ - 典型大小: 1-16 MB │ │
│ └───────────────────────────────────┘ │
├─────────────────────────────────────────┤
│ Old Generation (老年代) │
│ ┌───────────────────────────────────┐ │
│ │ Old Space (老对象空间) │ │
│ │ - 存放多次存活的对象 │ │
│ │ - 使用 Mark-Sweep/Mark-Compact │ │
│ │ - 典型大小: 几百 MB 到几 GB │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
问题诊断
2017 年 6 月,当 Cloudflare Workers 项目刚启动两个月时,团队根据当时 V8 的建议,为内存小于 512MB 的环境配置了年轻代大小。由于 Workers 默认限制为 128MB,这个配置在当时看起来很合理。
然而,V8 的垃圾回收器在过去 7 年中发生了巨大变化。在 CPU 密集型任务(如 React SSR)中,过小的年轻代导致了严重的性能问题:
// CPU 密集型任务示例
function renderReactApp() {
const vdom = createVirtualDOM(); // 创建大量临时对象
const html = renderToString(vdom); // 转换过程创建更多对象
return html;
}
// 原始配置问题
// youngGenerationSize = 2MB // 过小!
// 发生的情况:
// 1. React 渲染创建大量临时对象(虚拟 DOM 节点、组件实例等)
// 2. 2MB 年轻代在几毫秒内填满
// 3. 触发 Minor GC
// 4. GC 完成前,又创建更多对象
// 5. GC 陷入恶性循环
性能影响的量化分析:
年轻代 = 2MB → GC 每 10ms 触发一次 → 30% CPU 用于 GC
年轻代 = 8MB → GC 每 50ms 触发一次 → 10% CPU 用于 GC
年轻代 = 16MB → GC 每 100ms 触发一次 → 5% CPU 用于 GC
解决方案
Cloudflare 放弃了手动调优,转而让 V8 根据其内部启发式算法自由选择年轻代大小。这个改变带来了约 25% 的性能提升,内存使用只有小幅增加。更重要的是,这个优化惠及所有 Workers,不仅仅是基准测试。
框架层优化:OpenNext 的性能瓶颈
背景说明
Next.js 是一个与 Vercel 平台紧密集成的 React 框架。为了让 Next.js 能在其他平台上运行,社区推出了 OpenNext 项目。在 Cloudflare 上运行的实际上是 OpenNext,而不是原生的 Next.js,这引入了额外的性能变量。
内存分配和拷贝问题
通过性能分析,Cloudflare 发现 10-25% 的请求处理时间都花在了垃圾回收上。深入调查发现了多个问题:
- 不必要的缓冲区分配:
// 问题代码:创建 50 个 2048 字节的 Buffer 实例
// 无论是否使用
pipeThrough() // 创建大量不必要的缓冲区
- 冗余的数据拷贝:
// 问题代码
const length = Buffer.concat(chunks).length;
// Buffer.concat 会创建新的缓冲区,仅仅为了获取长度
// 优化后
const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
- 低效的流适配器:
// 问题代码
const stream = Readable.toWeb(Readable.from(res.getBody()))
// 优化后
const stream = ReadableStream.from(chunks);
流处理优化
OpenNext 在处理流时存在效率问题:
// 问题:值导向的流
const readable = new ReadableStream({
pull(controller) {
controller.enqueue(chunks.shift());
if (chunks.length === 0) {
controller.close();
}
}
}); // 默认 highWaterMark 是 1!
// 优化:字节导向的流
const readable = new ReadableStream({
type: 'bytes',
pull(controller) {
controller.enqueue(chunks.shift());
if (chunks.length === 0) {
controller.close();
}
}
}, { highWaterMark: 4096 });
Cloudflare 已经向 OpenNext 提交了多个 Pull Request 来修复这些问题,这些改进不仅惠及 Cloudflare 平台,也让其他 OpenNext 平台受益。
V8 引擎优化:JSON.parse 的性能提升
问题发现
在性能分析中,JSON.parse() 与 reviver 函数的组合使用成为了一个明显的瓶颈。在 Theo 的 Next.js 基准测试中,单个请求中 reviver 函数被调用超过 100,000 次!
技术细节
最近的 ECMAScript 标准为 reviver 回调函数添加了第三个参数,用于提供 JSON 源上下文。这个改变无意中降低了性能。
Cloudflare 的 V8 贡献者提交了一个优化补丁,通过检测 reviver 函数是否只使用两个参数来进行优化:
// 优化前
var deserialized = JSON.parse(str, reviver);
// 优化后:使用箭头函数包装
var deserialized = JSON.parse(str, (key, val) => reviver(key, val));
为什么箭头函数能带来性能提升?
- 禁用 arguments 对象:箭头函数没有自己的 arguments 对象,V8 可以安全地跳过传递第三个参数
- 没有 this 绑定:限制了 reviver 通过 this 操纵反序列化对象图的可能性
这个优化使 JSON.parse 的运行时间减少了约 36%,将在 V8 14.3 版本(Chrome 143)中发布,整个生态系统都将受益。
意外收获:修复 Node.js 的三角函数性能问题
问题背景
有趣的是,在另一个基准测试中,Cloudflare Workers 在三角函数计算上比 Vercel 上的 Node.js 快 3 倍。这个结果同样不正常。
根本原因
调查发现,这是由于编译时标志(compile-time flag)的差异:
// V8 源码中的伪代码
#ifdef USE_FAST_TRIG_FUNCTIONS
// 快速路径:使用现代 CPU 指令(如 AVX2)
double Math_sin(double x) {
return __builtin_sin_fast(x); // 编译器内建函数
}
#else
// 慢速路径:通用实现
double Math_sin(double x) {
return taylor_series_sin(x); // 泰勒级数展开
}
#endif
Node.js 为了支持广泛的平台(从嵌入式系统到服务器),选择了"最小公分母"策略:
| 特性 | Node.js | Cloudflare Workers |
|---|---|---|
| 部署环境 | 用户的任意机器 | Cloudflare 自有数据中心 |
| 硬件控制 | 无法控制 | 完全可控(统一硬件) |
| CPU 代数 | 可能是 10 年前的 | 现代服务器 CPU |
| 编译优化 | 保守配置 | 激进优化配置 |
解决方案
Cloudflare 向 Node.js 提交了 Pull Request,在支持的平台上启用快速三角函数路径。这个改进虽然不会让 Cloudflare 的客户受益(Workers 已经使用快速路径),但会让整个 Node.js 生态系统变得更快。
基准测试的挑战与改进
测试方法的局限性
- 网络延迟的影响:从本地笔记本测试远程服务器会引入网络延迟变量
- 硬件差异:不同代的服务器 CPU 性能有差异
- 多租户影响:云环境中的"吵闹邻居"会影响性能
配置错误的修复
-
Next.js 配置问题:
- Cloudflare 版本未启用
force-dynamic - 导致响应流式传输被抑制
- 影响了 TTFB(Time To First Byte)测量
- Cloudflare 版本未启用
-
React SSR 配置问题:
- 未设置
NODE_ENV环境变量 - React 默认运行在开发模式,性能大幅降低
- 未设置
性能优化的成果与展望
已实现的改进
经过一周的密集优化,Cloudflare Workers 在所有基准测试中都达到了与 Vercel 相当的性能水平(除了 Next.js 测试仍有差距)。具体改进包括:
-
平台层面:
- 更智能的请求调度算法
- 优化的垃圾回收器配置
- 所有 Workers 自动受益,无需修改代码
-
框架层面:
- 减少不必要的内存分配
- 优化流处理
- 改进缓存实现
-
引擎层面:
- JSON.parse 性能提升 36%
- 为 Node.js 启用快速三角函数
未来计划
Cloudflare 的优化之旅还在继续:
- 继续改进 OpenNext 性能,缩小与原生 Next.js 的差距
- 进一步优化调度算法,确保请求几乎永不相互阻塞
- 持续贡献到 V8 和 Node.js 项目
- 建立更全面的基准测试套件
技术启示与最佳实践
这次性能优化之旅给我们带来了几个重要启示:
1. 性能问题往往是多层次的
从这次事件可以看出,性能问题可能存在于:
- 基础设施层(调度算法)
- 运行时层(垃圾回收配置)
- 框架层(内存管理)
- 应用层(配置错误)
2. 开源协作的力量
Cloudflare 不仅修复了自己平台的问题,还:
- 向 V8 贡献了 JSON.parse 优化
- 向 Node.js 贡献了三角函数优化
- 向 OpenNext 贡献了多项性能改进
这种开放的态度让整个生态系统受益。
3. 基准测试的复杂性
创建公平、准确的基准测试极其困难:
- 需要考虑网络延迟
- 硬件差异会影响结果
- 配置错误可能导致误导性结果
- 测试方法本身可能有偏差
4. 持续优化的重要性
即使是 2017 年的"最佳实践"配置,到 2024 年也可能成为性能瓶颈。技术栈需要持续评估和优化。
结语
Cloudflare 对这次性能测试的回应堪称典范。他们没有回避问题,而是深入挖掘,不仅解决了自身平台的问题,还为整个 JavaScript 生态系统做出了贡献。这种开放、协作的精神正是推动技术进步的核心动力。
对于开发者而言,这次事件提醒我们:
- 性能优化需要全栈思维
- 基准测试要谨慎设计和解读
- 开源协作能创造共赢
- 持续学习和改进是技术人员的基本素养
正如 Cloudflare 在文章结尾所说:"如果你有显示 Workers 更慢的基准测试,请发送给我们。我们会进行分析,修复上游问题,并分享我们的发现!"这种态度值得每一个技术团队学习。
性能优化永无止境,但正是这种不断追求卓越的精神,推动着云计算和边缘计算技术不断向前发展。