我把 Node.js 服务迁到 Bun 运行时后,QPS 涨了 3.7 倍:迁移踩坑与生产灰度方案
2026/7/23 11:15:47 网站建设 项目流程

我把 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(同等压测)P99Node API 兼容生产就绪度
Node 18 (Express)4.2k280ms100%
Node 22 (Fastify)6.8k220ms100%
Deno 2.x9.2k140ms70%(要适配)
Bun 1.1.x15.6k65ms95%(几乎零改造)

选 Bun 不只是看 QPS 涨 3.7 倍——最关键是它的 Node API 兼容性。Deno 虽然也快,但Bufferfschild_process这些 API 行为细节跟 Node 有差异,老的 npm 包很多跑不起来

Bun 实现了 Node 95% 的核心 API——fshttpnettlsstreameventsBufferprocessnpm 包几乎零改造直接装。我们服务依赖 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 + Express2.8s
Node 22 + Fastify1.9s
Bun 1.1 + Elysia180ms

冷启动快了 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%。重点观察三个指标

  1. P99 延迟——Bun 应该比 Node 低 50%+
  2. 错误率——必须持平,不能因为兼容性踩坑引入新错误
  3. 内存使用——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 + ExpressBun 1.1 + Elysia提升
峰值 QPS4,21015,640+271%
平均延迟47ms12ms-74%
P99 延迟280ms65ms-77%
CPU 占用(峰值 QPS 时)78%41%-37pp
内存占用(RSS)680MB220MB-68%
冷启动时间2.8s180ms-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 新架构收益
生产实例数165-11 个
峰值 QPS(单实例)4,21015,640+3.7x
总承载 QPS67k78k+16%
P99 延迟280ms65ms-77%
内存总占用10.9GB1.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 严格类型不熟悉

这次迁移的教训:

  1. 先影子流量后真实流量——影子流量是免费的安全网,必须先跑
  2. Bun 的内存不是"免费午餐"——JSC GC 触发频率低,长跑服务要观察 RSS
  3. npm 兼容性不是 100%——迁移前先在 CI 跑一遍所有依赖
  4. TypeScript 严格模式要配好——Bun 项目用 strict 是必须的
  5. 保留热备实例——不要全切,留 1-2 个 Node 实例作 fallback

如果你也在做 Node 性能优化,强烈建议先评估一下 Bun。它不一定适合所有场景,但对 IO 密集型服务,性价比高得离谱

—— 用 3.7 倍 QPS 换来两个月带薪假的网关工程师

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询