我把 Node.js 服务迁到 Bun 运行时后,QPS 涨了 3.7 倍:迁移踩坑与生产灰度方案 我把 Node.js 服务迁到 Bun 运行时后QPS 涨了 3.7 倍迁移踩坑与生产灰度方案Bun 1.x 出来快两年了坦白讲我之前一直没敢在生产用——毕竟 Node.js 那套生态太重谁都不想当第一个吃螃蟹的。直到上个月一个内部 API 网关在压测时打到了 Node 18 的极限单实例 4.2k QPSCPU 78%P99 已经到了 280ms。扩容到 16 个实例才勉强扛住。抱着死马当活马医的心态我把核心路由层用 Bun 重写了一下。结果是单实例 15.6k QPS3.7 倍CPU 41%-37ppP99 压到 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 最大的卖点恰恰是用 JavaScriptCoreJSC替代 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 虽然也快但Buffer、fs、child_process这些 API 行为细节跟 Node 有差异老的 npm 包很多跑不起来。Bun 实现了 Node 95% 的核心 API——fs、http、net、tls、stream、events、Buffer、processnpm 包几乎零改造直接装。我们服务依赖 87 个 npm 包只有 1 个包一个冷门的 ORM 工具有兼容性问题。下面是迁移后的运行时架构客户端 → Nginx → API 网关Bun 1.1.x, Elysia→ 30 微服务 ↑ 灰度切流10% → 50% → 100%我们用 ElysiaBun 原生 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-parseBun 上跑通业务代码改 5 行就完事。还有一个坑path 模块在 Windows 路径上的行为有差异。我们服务跑在 Linux 上没遇到但同事的一个工具库跑 macOS 时路径解析有 bug。Bun 团队已经在 1.1.20 修复了一部分但跨平台还是建议做一遍集成测试。第二步用 Elysia 写一个最小可运行的网关Elysia 是 Bun 生态里最成熟的框架API 设计跟 Fastify 很像import{Elysia}fromelysia;import{jwt}fromelysiajs/jwt;import{rateLimit}fromelysiajs/ratelimit;// 启动服务constappnewElysia().use(jwt({secret:process.env.JWT_SECRET!})).use(rateLimit({max:100,duration:1m}))// 中间件统一鉴权.onBeforeHandle(async({request,set,jwt}){constauthrequest.headers.get(authorization);if(!auth){set.status401;return{error:missing token};}consttokenauth.replace(Bearer ,);constpayloadawaitjwt.verify(token);if(!payload){set.status401;return{error:invalid token};}// ts-ignorerequest.userpayload;})// 业务路由.get(/api/orders/:id,async({params,request}){// ts-ignoreconstuserIdrequest.user.userId;constorderawaitfetchOrder(params.id,userId);return{code:0,data:order};}).post(/api/orders,async({body,request}){// ts-ignoreconstuserIdrequest.user.userId;constorderawaitcreateOrder(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.8sNode 22 Fastify1.9sBun 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 个差异。阶段 210% 真实流量按用户 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_userIdURL 参数做哈希 key 是不够稳的应该用登录后的用户 ID 放在 header 里X-User-Id不然未登录用户全部走 Node灰度比例不准。阶段 350% 真实流量看监控10% 跑了 3 天监控指标没异常后切到 50%。重点观察三个指标P99 延迟——Bun 应该比 Node 低 50%错误率——必须持平不能因为兼容性踩坑引入新错误内存使用——JSC 内存模型和 V8 不一样峰值要单独看我们 50% 阶段发现一个内存问题Bun 跑 4 小时后 RSS 涨了 800MB。原因是 JSC 的 GC 触发频率比 V8 低长生命周期对象堆积。我们加了bun --smol-mode标志让 GC 更激进内存稳定在 200MB 以内。阶段 4100% 切流 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,640271%平均延迟47ms12ms-74%P99 延迟280ms65ms-77%CPU 占用峰值 QPS 时78%41%-37pp内存占用RSS680MB220MB-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{constvprocess.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.000ZBun 输出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,6403.7x总承载 QPS67k78k16%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 严格类型不熟悉这次迁移的教训先影子流量后真实流量——影子流量是免费的安全网必须先跑Bun 的内存不是免费午餐——JSC GC 触发频率低长跑服务要观察 RSSnpm 兼容性不是 100%——迁移前先在 CI 跑一遍所有依赖TypeScript 严格模式要配好——Bun 项目用 strict 是必须的保留热备实例——不要全切留 1-2 个 Node 实例作 fallback如果你也在做 Node 性能优化强烈建议先评估一下 Bun。它不一定适合所有场景但对 IO 密集型服务性价比高得离谱。—— 用 3.7 倍 QPS 换来两个月带薪假的网关工程师

本月热点