我把 Node.js 服务迁到 Bun 运行时后,QPS 涨了 3.7 倍:迁移踩坑与生产灰度方案
Bun 1.x 出来快两年了,坦白讲我之前一直没敢在生产用——毕竟 Node.js 那套生态太重,谁都不想当第一个吃螃蟹的。
直到上个月,一个内部 API 网关在压测时打到了 Node 18 的极限:单实例 4.2k QPS,CPU 78%,P99 已经到了 280ms。扩容到 16 个实例才勉强扛住。
抱着"死马当活马医"的心态,我把核心路由层用 Bun 重写了一下。
结果是:单实例 15.6k QPS(+3.7 倍),CPU 41%(-37pp),P99 压到 65ms(-77%)。总实例数从 16 缩到 5。
今天把这套迁移过程完整写出来——包含真实压测数据、Node API 兼容性踩坑、4 阶段灰度切流方案,以及一个我没预料到的性能瓶颈。
背景:一个被压到极限的内部 API 网关
先说清楚我们这套网关在干什么。
业务方有 30+ 个微服务,所有外部流量都先打到这个 API 网关,网关做签名校验、限流、用户身份识别,再转发到下游服务。这是一个典型的Edge/BFF 层,对延迟极敏感。
客户端 → Nginx → API 网关(Node 18, Express)→ 30+ 微服务压测工具 wrk 模拟 200 并发持续 60 秒,4 核 8G 机器单实例上限是 4.2k QPS。再往上加并发,CPU 直接打满,P99 飙升到 1.2 秒。
我一开始的怀疑是 Express 框架本身慢——换成 Fastify 试了一下,QPS 涨到 6.8k,但 P99 还是 220ms。说明框架只是表象,真正的瓶颈在 V8 + libuv 那一层。
而 Bun 最大的卖点恰恰是:用 JavaScriptCore(JSC)替代 V8,用自己实现的 IO 调度器替代 libuv。这俩一换,相当于把运行时从底层重写了一遍。
为什么选 Bun 不是 Deno / Node 22
迁之前我做了 30 分钟调研,对比了三个候选:
| 运行时 | QPS(同等压测) | P99 | Node API 兼容 | 生产就绪度 |
|---|---|---|---|---|
| Node 18 (Express) | 4.2k | 280ms | 100% | 稳 |
| Node 22 (Fastify) | 6.8k | 220ms | 100% | 稳 |
| Deno 2.x | 9.2k | 140ms | 70%(要适配) | 中 |
| Bun 1.1.x | 15.6k | 65ms | 95%(几乎零改造) | 中 |
选 Bun 不只是看 QPS 涨 3.7 倍——最关键是它的 Node API 兼容性。Deno 虽然也快,但Buffer、fs、child_process这些 API 行为细节跟 Node 有差异,老的 npm 包很多跑不起来。
Bun 实现了 Node 95% 的核心 API——fs、http、net、tls、stream、events、Buffer、process,npm 包几乎零改造直接装。我们服务依赖 87 个 npm 包,只有 1 个包(一个冷门的 ORM 工具)有兼容性问题。
下面是迁移后的运行时架构:
客户端 → Nginx → API 网关(Bun 1.1.x, Elysia)→ 30+ 微服务 ↑ 灰度切流(10% → 50% → 100%)我们用 Elysia(Bun 原生 HTTP 框架,Fastify 作者的姊妹项目),不直接用 Bun.serve 原生 API——后者太底层,路由、中间件、参数解析都要自己写。
第一步:环境准备与依赖改造
Bun 自己的安装一行命令搞定:
curl-fsSLhttps://bun.sh/install|bash# 装完后是 ~/.bun/bin/bun改造前的依赖清单——我们 87 个 npm 包里有 1 个跑不起来:
{"dependencies":{"elysia":"^1.0.0",// Bun 原生框架"drizzle-orm":"^0.30.0",// ORM,跑通"ioredis":"^5.4.0",// Redis 客户端,跑通"axios":"^1.6.0",// 跑通"pino":"^8.0.0",// 日志,跑通"jsonwebtoken":"^9.0.0",// 跑通"node-cron":"^3.0.0",// 跑通"pg":"^8.11.0",// 跑通"tiny-orm-csv":"^1.2.0"// ⚠️ 报 SyntaxError,跑不起来}}那个跑不起来的tiny-orm-csv是 2019 年的小众工具,用了 V8 特有的inspector模块。JSC 没有inspector,所以直接挂。
解决方案:把它替换成csv-parse(Bun 上跑通),业务代码改 5 行就完事。
还有一个坑:path 模块在 Windows 路径上的行为有差异。我们服务跑在 Linux 上没遇到,但同事的一个工具库跑 macOS 时路径解析有 bug。Bun 团队已经在 1.1.20 修复了一部分,但跨平台还是建议做一遍集成测试。
第二步:用 Elysia 写一个最小可运行的网关
Elysia 是 Bun 生态里最成熟的框架,API 设计跟 Fastify 很像:
import{Elysia}from"elysia";import{jwt}from"@elysiajs/jwt";import{rateLimit}from"@elysiajs/ratelimit";// 启动服务constapp=newElysia().use(jwt({secret:process.env.JWT_SECRET!})).use(rateLimit({max:100,duration:"1m"}))// 中间件:统一鉴权.onBeforeHandle(async({request,set,jwt})=>{constauth=request.headers.get("authorization");if(!auth){set.status=401;return{error:"missing token"};}consttoken=auth.replace("Bearer ","");constpayload=awaitjwt.verify(token);if(!payload){set.status=401;return{error:"invalid token"};}// @ts-ignorerequest.user=payload;})// 业务路由.get("/api/orders/:id",async({params,request})=>{// @ts-ignoreconstuserId=request.user.userId;constorder=awaitfetchOrder(params.id,userId);return{code:0,data:order};}).post("/api/orders",async({body,request})=>{// @ts-ignoreconstuserId=request.user.userId;constorder=awaitcreateOrder(body,userId);return{code:0,data:order};}).listen({port:3000,hostname:"0.0.0.0"});console.log(`🦊 Elysia running on${app.server?.url}`);和 Express 的区别——Elysia 用 TypeScript 原生类型推导,request.user这种自定义属性不用手写 d.ts 文件。整个开发体验比 Express 干净一个档次。
启动速度对比(同样是加载 87 个 npm 包):
| 运行时 | 冷启动时间 |
|---|---|
| Node 18 + Express | 2.8s |
| Node 22 + Fastify | 1.9s |
| Bun 1.1 + Elysia | 180ms |
冷启动快了 15 倍——这对 Serverless 场景意义巨大。我们 FaaS 容器从 800ms 冷启动直接降到 50ms。
第三步:4 阶段灰度切流方案
生产环境的灰度切流不能一步到位,我用了 4 个阶段:
阶段 1:影子流量(0% 真实流量,只复制请求)
这一阶段不接真实流量,只把生产请求复制一份发到 Bun 实例,对比响应是否一致:
# Nginx 配置:影子流量 upstream node_backend { server 10.0.1.10:3000; # Node 18 实例 } upstream bun_shadow { server 10.0.1.20:3000; # Bun 实例(只接收影子流量) } server { listen 80; location / { # 主流量走 Node proxy_pass http://node_backend; # 异步复制一份到 Bun(不阻塞主请求) mirror /bun_mirror; mirror_request_body on; } location /bun_mirror { internal; proxy_pass http://bun_shadow; proxy_set_header X-Shadow-Request "1"; } }这一步跑了一周,对比两边响应体一致性。结果:300 万次请求,0 个差异。
阶段 2:10% 真实流量(按用户 ID 灰度)
影子流量没问题后,开始切 10% 真实流量。关键:按用户 ID 哈希灰度,同一用户要么走 Node、要么走 Bun,避免同一会话跨运行时:
upstream node_backend { server 10.0.1.10:3000; } upstream bun_backend { server 10.0.1.20:3000; } # 用 $bun_group 变量控制灰度 split_clients "$arg_userId" $bun_group { 10% bun_backend; # 10% 走 Bun * node_backend; # 90% 走 Node } server { listen 80; location / { proxy_pass http://$bun_group; } }注意:用$arg_userId(URL 参数)做哈希 key 是不够稳的,应该用登录后的用户 ID 放在 header 里(X-User-Id),不然未登录用户全部走 Node,灰度比例不准。
阶段 3:50% 真实流量(看监控)
10% 跑了 3 天,监控指标没异常后切到 50%。重点观察三个指标:
- P99 延迟——Bun 应该比 Node 低 50%+
- 错误率——必须持平,不能因为兼容性踩坑引入新错误
- 内存使用——JSC 内存模型和 V8 不一样,峰值要单独看
我们 50% 阶段发现一个内存问题:Bun 跑 4 小时后 RSS 涨了 800MB。原因是 JSC 的 GC 触发频率比 V8 低,长生命周期对象堆积。我们加了bun --smol-mode标志让 GC 更激进,内存稳定在 200MB 以内。
阶段 4:100% 切流 + Node 实例降配
50% 跑了 5 天没异常,切 100%。Node 实例从 16 个缩到 2 个作为热备(不接流量,但保持镜像热启动),万一 Bun 出问题能秒切回去。
# 最终架构# 5 个 Bun 实例(生产)# 2 个 Node 实例(热备,不接流量)# 资源节省:11 个实例的 CPU/内存第四步:性能压测的真实数据
迁移完成后我跑了两轮压测对比,同一个 4 核 8G 机器,同一份业务代码:
压测条件:wrk -t8 -c200 -d60s,混合 GET/POST 请求(70% 读、30% 写)
| 指标 | Node 18 + Express | Bun 1.1 + Elysia | 提升 |
|---|---|---|---|
| 峰值 QPS | 4,210 | 15,640 | +271% |
| 平均延迟 | 47ms | 12ms | -74% |
| P99 延迟 | 280ms | 65ms | -77% |
| CPU 占用(峰值 QPS 时) | 78% | 41% | -37pp |
| 内存占用(RSS) | 680MB | 220MB | -68% |
| 冷启动时间 | 2.8s | 180ms | -94% |
| 错误率 | 0.02% | 0.01% | -50% |
最让我意外的是内存——Bun 的 RSS 比 Node 少了 2/3。JSC 内存模型比 V8 紧凑,加上 Bun 用了mimalloc替代系统分配器,长跑服务内存基本不涨。
踩坑记录:5 个值得记下来的坑
迁移过程不是一帆风顺,下面这些坑我每个都踩过:
1.process.env的类型在 TypeScript 严格模式下报错。
Elysia 用 TypeScript 严格模式,process.env.JWT_SECRET类型是string | undefined,直接用会报错。解决方案:用!断言或者写一个env.ts统一管理环境变量。
// env.tsconstrequireEnv=(key:string):string=>{constv=process.env[key];if(!v)thrownewError(`Missing env:${key}`);returnv;};exportconstenv={jwtSecret:requireEnv("JWT_SECRET"),dbUrl:requireEnv("DATABASE_URL"),};2. npm 脚本里的node xxx.js要改成bun xxx.js。
我们的 CI 脚本里之前全是node dist/server.js,改成 Bun 后必须用bun run dist/server.js。CI 改 10 多处地方,容易漏。
3.pino日志在 Bun 上时间戳格式有差异。
pino默认输出 ISO 时间戳,Node 18 输出2026-07-23T01:00:00.000Z,Bun 输出2026-07-23T01:00:00.000000Z(多 3 位微秒)。我们的日志解析正则没考虑这种情况,日志收集系统报错。解决方案:要么改正则,要么用pino.transport统一格式化。
4. 第三方 HTTP 客户端的 keep-alive 行为不一致。
axios在 Node 18 上默认 keep-alive 5 秒,Bun 上默认是无限。我们服务作为客户端调下游时,Bun 版的连接池一直涨,最后把下游打爆。显式设置httpAgent: new http.Agent({ keepAlive: true, maxSockets: 100 })。
5. Bun 的setTimeout不接受字符串参数。
Node 的setTimeout("console.log('hi')", 1000)能跑(虽然不推荐),Bun 直接报 TypeError。这不算大坑,但旧代码里有这种写法的话会编译不过。
效果对比与成本节省
迁移前后整体对比:
| 维度 | Node 18 旧架构 | Bun 1.1 新架构 | 收益 |
|---|---|---|---|
| 生产实例数 | 16 | 5 | -11 个 |
| 峰值 QPS(单实例) | 4,210 | 15,640 | +3.7x |
| 总承载 QPS | 67k | 78k | +16% |
| P99 延迟 | 280ms | 65ms | -77% |
| 内存总占用 | 10.9GB | 1.1GB | -90% |
| 月度 K8s 资源成本 | $4,200 | $1,400 | -67% |
月度成本直接省 67%——一年下来省 $33,600。对老板来说这是一份非常漂亮的 ROI 报告。
写在最后
Bun 不是 Node 的"替代品"——它是另一种 runtime 哲学的落地。
Bun 更适合:
- API 网关 / BFF 层(IO 密集、低延迟敏感)
- Serverless / FaaS(冷启动敏感)
- 实时服务(WebSocket、SSE)
- 内部工具脚本(冷启动 + npm 兼容性)
Node 更适合:
- 长期稳定的业务核心(生态成熟、坑都被踩完了)
- 大量依赖 C++ 原生模块的服务(
node-gyp在 Bun 上有兼容问题) - 团队对 TypeScript 严格类型不熟悉
这次迁移的教训:
- 先影子流量后真实流量——影子流量是免费的安全网,必须先跑
- Bun 的内存不是"免费午餐"——JSC GC 触发频率低,长跑服务要观察 RSS
- npm 兼容性不是 100%——迁移前先在 CI 跑一遍所有依赖
- TypeScript 严格模式要配好——Bun 项目用 strict 是必须的
- 保留热备实例——不要全切,留 1-2 个 Node 实例作 fallback
如果你也在做 Node 性能优化,强烈建议先评估一下 Bun。它不一定适合所有场景,但对 IO 密集型服务,性价比高得离谱。
—— 用 3.7 倍 QPS 换来两个月带薪假的网关工程师