性能与基准
本教程共 34 篇 · 第 33 篇 · 更新于 2026-08-06
本节目标:
- 从引擎、系统层、工具链三个层面理解 Bun「为什么快」,而不是只记住几个倍数。
- 会读官方公开基准:知道每个数字的前提条件,以及它们不能说明什么。
- 掌握一套自测工具链:
performance.now()/Bun.nanoseconds()、mitata、oha、hyperfine。- 会用
--cpu-prof、--heap-prof、heapStats()定位自己代码里的瓶颈。- 清楚 Bun 在哪些场景下不一定更快,避免「换成 Bun 就万事大吉」的误判。
33.1 Bun 为什么快
「快」不是单点优势,而是三层设计叠加的结果。
第一层:JavaScriptCore 引擎
Node.js 与 Deno 用 Google 的 V8,Bun 用 Apple 为 Safari 开发的 JavaScriptCore(JSC)。两者都是成熟的开源引擎,取舍不同而已。
V8 在长时间运行的服务端场景做了大量优化;JSC 则在启动时间与内存占用上更有优势。它采用多层 JIT 架构(解释器 → 基线 JIT → 优化 JIT),冷启动阶段不必立刻做重量级编译。
官方在首页给出的表述是「进程启动比 Node.js 快 3 倍」,另有一些早期材料写作 4 倍。这类倍数会随版本、平台、被测脚本明显波动,理解成「同一量级上的可观改善」,比死记数字有用。
选 JSC 还有个副作用红利:Bun 的 Headers、URL 等 Web API 直接复用 WebKit 的 C++ 实现,不必用 JavaScript 重写一遍。
第二层:系统层用原生代码实现
Node.js 的很多标准库是 JavaScript 与 C++ 混合实现,跨越 JS/native 边界的开销不小。Bun 把大量热点路径直接落到原生实现:
- HTTP 服务器与事件循环基于 uWebSockets / usockets;
- 路由匹配用树形结构 + SIMD 加速的参数解码 + JavaScriptCore 结构缓存;
- 内存分配器用 mimalloc;
bun:sqlite直接绑定 SQLite,不经过 npm 包的 N-API 中转;- 字符串工具如
Bun.stripANSI()用 SIMD 实现,官方给出的数据是比同名 npm 包快 6–57 倍。
这类优化的共同点是:把本该在 JS 里跑的循环,搬到原生代码里跑一次。
v1.3 的发布说明里这类条目很多。比如 postMessage 传字符串时跳过序列化,最高快 500 倍、峰值内存约降到 1/22;AbortSignal.timeout 快 40 倍;node:crypto 的 DiffieHellman 约 400 倍。
Note看到「400 倍」这种数字请务必回到原文看基准条件。它们通常测的是单个 API 的微基准,在真实应用里这个 API 可能只占总耗时的 1%,整体提升会被 Amdahl 定律拉平。
第三层:一体化省掉了工具链切换
这一层容易被忽略,但日常开发里感知最强。
传统链路是 node + ts-node/tsx + esbuild/webpack + jest + npm。每个环节都是独立进程、独立的模块解析、独立的转译缓存。Bun 把四件事放进同一个二进制,共享同一套转译器与模块解析器:
- 运行
.ts不需要先起一个转译进程; bun test用的转译器就是运行时那一个;bun install的全局缓存与运行时的模块解析共用同一套路径逻辑。
省下来的不只是 CPU 时间,还有进程启动、文件重复读取、缓存重复生成的开销。
33.2 怎么读官方公开基准
Bun 所有公开基准的源码都在仓库的 /bench 目录下,可以自己复现。下面把官方站点与博客给出的主要数据整理出来,每一条都带上它的前提。
打包速度
打包 10,000 个 React 组件,Linux x64(Hetzner):
| 工具 | 构建耗时 |
|---|---|
| Bun v1.3.0 | 269.1 ms |
| Rolldown v1.0.0-beta.42 | 494.9 ms |
| esbuild v0.25.10 | 571.9 ms |
| Farm v1.0.5 | 1,608 ms |
| Rspack v1.5.8 | 2,137 ms |
早期《The Bun Bundler》一文用的是改编自 esbuild three.js 基准的场景,结论是 Bun 比 esbuild 快 1.75 倍、比 Parcel 2 快 150 倍、比 Rollup + Terser 快 180 倍、比 Webpack 快 220 倍。
有两点必须说明。其一,Bun 打包器的架构本身就借鉴自 esbuild,官方致谢里写得很清楚,二者属于同一技术路线的不同实现。
其二,表里的 Rolldown 当时还是 beta。它是 Vite 生态的下一代打包器,仍在快速迭代,拿 beta 版数据评判它的最终性能并不公平。
HTTP 与 WebSocket 吞吐
| 场景 | Bun | Deno | Node.js |
|---|---|---|---|
| Express「hello world」(req/s) | 59,026 | 25,335 | 19,039 |
| WebSocket 聊天(msg/s,32 客户端) | 2,536,227 | 1,320,525 | 435,099 |
| 数据库查询(qps,100 行 × 100 并发) | 28,571 | 14,522 | 11,169 |
这三组数据的版本前提各不相同(分别是 Bun v1.2 / v1.2 / v1.2.22 对应各自的 Deno、Node 版本),且都是 Linux x64。
Warninghello-world 级别的压测测的是框架与运行时的开销上限,不是你的应用性能。 一旦请求里出现数据库查询、外部 API 调用、JSON 序列化大对象,这些环节往往占据 90% 以上的耗时,运行时之间的差距会被显著稀释。把这类数字当作「天花板参考」而非「预期收益」。
包管理与运行时启动
bun install:官方文档首页表述为「比 npm 快最多 30 倍」,依托全局缓存实现。- 进程启动:比 Node.js 快约 3 倍。
33.3 自己做基准:工具与方法
先澄清:没有 bun bench 这个命令
Bun v1.3.14 不提供 bun bench 子命令。 这是个流传很广的误解,动手前先把它掐掉。
误解的来源是官方博客里那种写法:bun bench/snippets/buffer-includes.js。那是在 Bun 仓库根目录下用 bun 运行 bench/snippets/ 里的一个脚本——bench/ 是目录路径,不是子命令。
自己动手确认最快:
bun --help # 子命令清单里没有 bench
bun bench # 会被当成文件路径处理,找不到就报错
官方推荐的方案是「运行时计时 API + 专用工具」的组合,下面逐一说明。
计时:performance.now() 与 Bun.nanoseconds()
// 方式一:Web 标准,毫秒(浮点)
const t0 = performance.now();
doSomething();
console.log(`耗时 ${(performance.now() - t0).toFixed(3)} ms`);
// 方式二:Bun 专属,纳秒精度,返回进程启动至今的纳秒数
const t0 = Bun.nanoseconds();
doSomething();
console.log(`耗时 ${Bun.nanoseconds() - t0} ns`);
Bun.nanoseconds() 的基准点是进程启动时刻;需要换算成 Unix 时间戳时配合 performance.timeOrigin 使用。
微基准:mitata
官方推荐用 mitata 做函数级微基准,它会自动处理预热、多轮采样与分位数统计:
bun add -d mitata
// bench.js
import { bench, run } from "mitata";
bench("Array.prototype.includes", () => {
const arr = [1, 2, 3, 4, 5];
arr.includes(3);
});
bench("Set.prototype.has", () => {
const set = new Set([1, 2, 3, 4, 5]);
set.has(3);
});
run();
bun ./bench.js
输出形如:
cpu: Apple M3 Max
runtime: bun 1.3.14 (arm64-darwin)
benchmark time (avg) (min … max) p75 p99 p999
------------------------------------------------- -----------------------------
myRandom 6.26 ns/iter (6.16 ns … 17.68 ns) 6.23 ns 7.67 ns 10.17 ns
Tip微基准最容易被 JIT 优化「作弊」:如果被测函数的返回值没人用,引擎可能整段消除掉。写微基准时记得把结果消费掉(例如累加到一个外部变量),否则你测到的是空循环。
HTTP 压测:oha / bombardier
官方对压测工具有一条硬性要求:压测端必须至少和 Bun.serve() 一样快。否则瓶颈落在压测工具自己身上,测出来的数字毫无意义。
文档点名说了,一些流行的 Node.js 系压测工具(如 autocannon)不够快,推荐换成下面三个:
bombardier(Go)oha(Rust)http_load_test(C)
# 用 oha 压测本地服务:50 并发,持续 30 秒
oha -c 50 -z 30s http://localhost:3000/
# 用 bombardier:125 连接,10 万次请求
bombardier -c 125 -n 100000 http://localhost:3000/
CLI / 脚本耗时:hyperfine
对比「跑一条命令要多久」用 hyperfine,它会自动多轮运行、剔除异常值并给出统计区间:
hyperfine --warmup 3 'bun run build.ts' 'node --experimental-strip-types build.ts'
官方在对比 Bun 版本间的应用级性能时用的就是这套组合:HTTP 用 oha,next build / tsc -b 这类命令用 hyperfine。
33.4 剖析:找到自己代码里的瓶颈
基准告诉你「慢」,剖析告诉你「慢在哪一行」。
CPU 剖析
# 生成 Chrome DevTools 格式的 .cpuprofile
bun --cpu-prof script.js
# 生成 Markdown 格式,便于 grep / 交给 LLM 分析
bun --cpu-prof-md script.js
# 两种格式同时生成
bun --cpu-prof --cpu-prof-md script.js
.cpuprofile 可在 Chrome DevTools 的 Performance 面板(Load profile)或 VS Code 的 CPU 剖析器中打开。常用参数:
| 参数 | 作用 |
|---|---|
--cpu-prof | 生成 .cpuprofile(Chrome DevTools 格式) |
--cpu-prof-md | 生成 Markdown 剖析报告 |
--cpu-prof-name <filename> | 指定输出文件名 |
--cpu-prof-dir <dir> | 指定输出目录 |
无法修改启动命令时(例如命令被脚本包了好几层),可以通过环境变量注入:
BUN_OPTIONS="--cpu-prof-md" bun script.js
堆剖析与内存统计
# 进程退出时生成 V8 .heapsnapshot,可在 Chrome DevTools 的 Memory 面板加载
bun --heap-prof script.js
# Markdown 版本,适合命令行分析
bun --heap-prof-md script.js
Bun 有两个堆:一个给 JavaScript 运行时,一个给其他所有原生分配。分别查看:
// JS 堆统计
import { heapStats } from "bun:jsc";
console.log(heapStats());
// => { heapSize, heapCapacity, extraMemorySize, objectCount, objectTypeCounts: { ... } }
// 原生堆(mimalloc)摘要
Bun.unsafe.mimallocDump();
排查内存问题时经常需要手动触发 GC 后再取快照:
Bun.gc(true); // 同步 GC
Bun.gc(false); // 异步 GC
// 生成堆快照并落盘,可用 Safari / WebKit GTK 开发者工具打开分析
import { generateHeapSnapshot } from "bun";
const snapshot = generateHeapSnapshot();
await Bun.write("heap.json", JSON.stringify(snapshot, null, 2));
NoteJavaScript 是垃圾回收语言而非引用计数,对象不被立刻释放是正常且正确的;只有「永远不被释放」才是泄漏。判断泄漏要看多次 GC 后内存是否持续单调增长,而不是看某一时刻的绝对值。
33.5 客观看待:Bun 不一定更快的场景
红线之一是对比要客观。以下几类情况值得实测再下结论:
- 长时间运行的 CPU 密集型服务。V8 在长周期运行下的优化 JIT 非常成熟,某些数值计算、正则密集型负载上 Node.js 可能持平甚至更好。Bun 的优势更明显地体现在启动、I/O 与工具链环节。
- 瓶颈在外部依赖时。请求耗时主要花在数据库、Redis、第三方 API 上时,换运行时的收益接近于零。
- 依赖大量原生模块(N-API)的项目。Bun 对 N-API 的兼容一直在推进,但仍属于「目标 100% 兼容」而非「已达成」。遇到原生模块行为差异时,需要逐个验证。
- 依赖 Node.js 尚未覆盖的边角 API。Bun 的 Node 兼容度在持续提升,具体状态以官方 Node.js 兼容页为准;迁移前建议先跑通完整测试套件。
- 打包器功能覆盖面。Bun 的打包器速度领先,但 Webpack/Rollup 生态积累的插件数量仍是它们的优势。选型要同时看性能与生态。
Tip最实际的做法是在自己的负载上测:把生产环境真实的接口拿一条出来,用
oha压 Bun 与 Node 两份实现,再用--cpu-prof看两边的火焰图差在哪里。任何第三方基准都替代不了这一步。
33.6 小结
- Bun 的速度来自三层叠加:JavaScriptCore 引擎(启动快、内存省)、系统层原生实现(uWebSockets、mimalloc、SIMD、直连 SQLite)、一体化工具链(省掉进程与转译的重复开销)。
- 官方基准都能在仓库
/bench下复现,但读数时务必带上版本、硬件、负载三个前提;hello-world 压测是天花板参考,不是预期收益。 - v1.3.14 没有
bun bench子命令。自测的正确姿势是:performance.now()/Bun.nanoseconds()做粗粒度计时,mitata做微基准,oha/bombardier做 HTTP 压测,hyperfine做命令级对比。 - 剖析工具链:
--cpu-prof/--cpu-prof-md定位 CPU 热点,--heap-prof与bun:jsc的heapStats()、Bun.unsafe.mimallocDump()定位内存问题。 - 压测工具本身必须足够快,否则测的是工具而不是服务;这是官方专门强调的一条。
下一章我们把镜头拉远,讲讲 Bun 是怎么走到今天的:从 Zig 到 Rust 的重写、加入 Anthropic 之后的变与不变、版本路线图,以及学完这 34 章之后该往哪里走。