Bun 运行时生产环境迁移实战:从 Node.js 到 Bun,我踩过的坑和最终收益
先给结论
我把一个日活 5 万的 Node.js API 服务迁移到了 Bun 1.1,启动时间从 4.2 秒降到 0.3 秒,内存占用降低约 40%。但中间经历了 3 次线上回滚和 2 个通宵排查。这篇记录完整的迁移决策、操作步骤和关键坑点,希望能帮你省下我踩坑的时间。
为什么想到换运行时
事情要从一次线上故障说起。我们的 Node.js 服务(Express + TypeScript)在高峰期频繁 CPU 飙高,排查发现是大量文件读写和 JSON 解析导致的。当时团队有人提议上 Rust 重写——被我直接否决了,成本太高。
回头看,Bun 的几个特性正好切中痛点:
- 内置
Bun.file()API,文件读取比 Node.jsfs快 10 倍 - 原生 SQLite 支持,不需要
better-sqlite3这个 native 模块 - 启动速度快,适合容器化频繁扩缩容的场景
你不是非得换运行时,但必须有一个换的理由。我的理由:减少基础设施开销,同时提高单机吞吐。
迁移前的准备工作
兼容性评估
第一步不是写代码,是搞清楚现有项目能不能跑。
# 在 CI 中加一个兼容性检查步骤
bun run src/index.ts
我用这个命令跑了项目,炸了一堆错误。整理下来主要三类:
- Node.js 内置模块 —
crypto、fs、path等大部分兼容,但child_process的部分用法有问题 - CommonJS 模块 — 项目中混合使用了
require和import,Bun 支持但行为略有差异 - N-API 原生模块 —
sharp、bcrypt这些需要重新编译
我的建议:先用 bun run 跑你的测试套件,看测试通过率。通过率低于 80% 的话,建议等几个版本再迁移。
关键依赖检查清单
我建了个兼容性表格:
| 依赖 | Node.js | Bun 1.1 | 备注 |
|---|---|---|---|
| Express | ✅ | ✅ | 完全兼容 |
| Prisma | ✅ | ⚠️ | 需用 prisma generate 重新生成 |
| BullMQ | ✅ | ❌ | Redis 兼容但 ioredis 有 bug |
| Zod | ✅ | ✅ | 更快(Bun 的原生 TS 支持) |
| node-cron | ✅ | ⚠️ | 改用 Bun.schedule() |
最痛的教训: 别信文档说的"完全兼容"。ioredis 文档说兼容,但生产环境下连接池会泄漏。我用 bun:sqlite 替换了 Redis 的部分缓存逻辑才解决。
分步迁移实操
第一步:搭建迁移环境
# 安装 Bun
curl -fsSL https://bun.sh/install | bash
# 创建独立的迁移分支
git checkout -b migrate-to-bun
# 用 Bun 安装依赖(这会重写 lockfile)
rm -rf node_modules package-lock.json
bun install
注意:
bun install不会生成node_modules下所有的包——它有自己的缓存策略。如果某些包在运行时报 "module not found",试试bun install --yarn模式。
第二步:替换文件操作(最值的优化)
这是迁移中收益最明显的部分。我们的服务每天处理大量模板渲染和文件读取。
优化前(Node.js):
import fs from 'fs/promises';
import path from 'path';
async function loadTemplate(name: string) {
const filePath = path.join(__dirname, 'templates', `${name}.html`);
return await fs.readFile(filePath, 'utf-8');
}
优化后(Bun):
const templates = new Map<string, string>();
async function loadTemplate(name: string) {
if (templates.has(name)) return templates.get(name)!;
const content = await Bun.file(`./templates/${name}.html`).text();
templates.set(name, content);
return content;
}
Bun.file() 的读取速度大约是 fs.readFile 的 3-5 倍,配合内存缓存后,这部分接口的响应时间从 120ms 降到了 15ms。
第三步:SQLite 替代 Redis 缓存
我们原来用 Redis 存一些简单的 KV 缓存,迁移时发现 ioredis 在 Bun 下有连接泄漏问题。干脆把简单的缓存场景换成了 Bun 内置的 SQLite:
import { Database } from 'bun:sqlite';
const db = new Database(':memory:');
db.run(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
expires_at INTEGER
)
`);
const cache = {
get(key: string) {
const row = db.query(
`SELECT value FROM cache WHERE key = ? AND (expires_at > ? OR expires_at IS NULL)`
).get(key, Date.now());
return row ? JSON.parse(row.value) : null;
},
set(key: string, value: unknown, ttlMs = 300000) {
db.query(
`INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)`
).run(key, JSON.stringify(value), Date.now() + ttlMs);
}
};
结果: 每天省下了一个 Redis 实例的开销(约 ¥200/月),缓存读取延迟从 2ms 降到了 0.1ms。
第四步:迁移构建和部署配置
Dockerfile 改造:
# 多阶段构建
FROM oven/bun:1.1 AS build
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1.1 AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]
注意基础镜像大小:oven/bun:1.1 约 180MB,node:20-slim 约 120MB。Bun 镜像更大,但因为不需要安装额外构建工具,实际部署镜像反而更小。
CI 配置也改了:
# GitHub Actions
- name: Install Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.1.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests
run: bun test
bun test 比 jest 快太多了。原来跑一遍测试需要 45 秒,现在 8 秒。
生产踩坑实录
坑 1:热重载的陷阱
Bun 的 --watch 模式在开发环境很好用,但我犯了个低级错误——部署时忘了去掉 --watch。
# ❌ 错误:生产环境用了 watch 模式
CMD ["bun", "--watch", "run", "dist/index.js"]
# ✅ 正确:生产环境不加 --watch
CMD ["bun", "run", "dist/index.js"]
后果:服务在无任何流量时自动重启了 3 次,导致连接断开。排查了半小时才发现。
坑 2:Process 全局对象的差异
Bun 实现了大部分 Node.js 的全局 API,但 process.nextTick 的行为有细微差别:
// Node.js 中:微任务队列的优先级不同
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
// Node.js 输出: nextTick > promise
// Bun 输出: promise > nextTick
这个差异导致我们一个依赖执行顺序的定时任务出错。解决方法:用 queueMicrotask 替代 process.nextTick。
坑 3:流式响应的背压问题
我们的文件上传接口用了 stream.pipe(),在 Bun 下遇到了背压(backpressure)处理不一致的问题:
// ❌ Node.js 下正常,Bun 下内存泄漏
readStream.pipe(writeStream);
// ✅ 改用 Bun 的 WriteStream
const writer = Bun.write(Bun.file(destPath), readStream);
await writer;
Bun 团队的推荐是尽量用 Bun.write() 而不是手动 pipe,后者在 Bun 中尚未完全优化。
坑 4:原生模块编译失败
sharp 这个图片处理库需要重新编译原生绑定:
# 在 Bun 环境下重新编译
bun x sharp --install
如果遇到 LLVM 版本不兼容,可以试试:
# 强制使用系统 GCC
CC=gcc CXX=g++ bun install
注意: 如果你的 Docker 镜像基于 Alpine,需要额外安装
build-base和python3。
性能实测数据
以下是迁移前后在生产环境(同一台 4C8G 服务器)的对比:
| 指标 | Node.js 20 | Bun 1.1 | 提升 |
|---|---|---|---|
| 启动时间 | 4.2s | 0.3s | 92% |
| 稳态内存 | 420MB | 256MB | 39% |
| RPS(吞吐) | 2800 | 4100 | 46% |
| P99 延迟 | 210ms | 145ms | 31% |
| 镜像大小 | 420MB | 380MB | 9.5% |
说明一下:我们的服务主要是 I/O 密集型,如果是 CPU 密集场景,Bun 的优势会小很多。
回滚预案
迁移第一天我就准备好了回滚方案:
# docker-compose.override.yaml
services:
api:
image: ${IMAGE_TAG:-myapp:bun}
entrypoint: >
sh -c "if [ \"$RUNTIME\" = \"node\" ]; then node dist/index.js; else bun run dist/index.js; fi"
这个设计让我们能在 30 秒内切回 Node.js。实际上线第一周我用了 3 次回滚,每次都是发现一个之前没测到的兼容性问题。
什么时候不建议迁移
诚实地说,以下场景建议再等等:
- 重度使用 gRPC — Bun 的 gRPC 支持不稳定
- 依赖大量 N-API 模块 — 每个都需要单独测试兼容性
- Serverless 边缘场景 — Bun 的冷启动优势在 V8 isolate 面前不明显
- 团队 Node.js 经验不足 — 新运行时意味着新的调试工具和排查方式
最后的建议
迁移完成后回头看,三个决策最关键:
- 渐进式迁移 — 从非核心服务开始,积累经验再动核心业务
- 充分的兼容性测试 — 不要只看单元测试,要跑集成测试和压力测试
- 保留回滚能力 — 随时能切回 Node.js,心理压力会小很多
Bun 还不是完美的 Node.js 替代品,但它已经足够好到值得在生产环境尝试了。关键在于:知道它哪里好,更重要的是知道它哪里不好。

评论已关闭!