在 Node.js 生态中,迁移到新运行时从来不是一件小事。当 Trigger.dev 决定将他们的 Firestarter 服务从 Node.js 切换到 Bun 时,他们的目标很明确:在不影响稳定性的前提下,获得显著的性能提升。最终结果令人印象深刻 ——5 倍吞吐量、28 倍更低的尾延迟、容器镜像体积缩减 62%。但过程远比预期曲折,Bun 的 HTTP 模型与 Node.js 存在根本性差异,如果不加注意,一个隐藏的内存泄漏足以吃掉所有性能收益。

背景:Firestarter 服务的性能瓶颈

Firestarter 是 Trigger.dev 平台中的关键组件,它扮演着「预热启动连接经纪人」的角色。这个服务维护着数千个来自空闲运行控制器的长轮询 HTTP 连接,每个连接都在等待任务分发。当任务到达时,Firestarter 将其与等待中的控制器匹配,通过已建立的连接发送 payload,从而避免冷启动和容器拉起。这个服务位于_trigger.dev 每次任务执行的关键路径上。

原始实现基于 Node.js 运行,存在三个明显的性能热点:31% 的 CPU 时间消耗在 SQLite 查询上、每次请求都用 Zod 进行完整模式验证、每次 GET 请求都使用 Object.fromEntries() 转换请求头。这种架构能够工作,但在高并发场景下表现得不尽如人意。

第一阶段:用内存 Map 替换 SQLite

团队首先识别到的问题是一个被过度设计的 SQLite 查询引擎。原始设计将连接元数据展平为键值对并存储在内存 SQLite 数据库中,意图提供灵活的查询能力。然而实际访问模式始终只涉及 4 个字段:deployment ID、version、CPU 和 memory。每次匹配请求都会执行一个包含 JOIN、GROUP BY 和 HAVING COUNT (DISTINCT) 的相关子查询,而本质上这只是一次哈希表查找。

使用 node --prof 进行性能分析后确认,getConnection 方法占据了 31% 的总 CPU 时间。团队将其替换为复合键的 Map<string, Set<string>> 数据结构,键由 deployment + version + cpu + memory 组成,中间用空字符分隔。匹配操作从 SQL 查询的 O (n) 复杂度降级为 Map 查找的 O (1)。

这一轮优化的基准测试结果如下:吞吐量从 2,099 req/s 提升至 4,534 req/s,p50 延迟从 22.5ms 降至 10.1ms,p95 延迟从 29.1ms 降至 14.9ms,最大延迟从 619ms 降至 403ms。吞吐量提升了 2.2 倍,同时可以移除 Node.js 的 --experimental-sqlite 标志。

第二阶段:迁移到 Bun.serve ()

移除 SQLite 后,下一个性能瓶颈暴露出来:50% 以上的 CPU 时间消耗在 node:http 内部实现上,包括 writev、socket 管理和流处理。当服务需要维护数千个并发长轮询连接时,Node.js 的 HTTP 栈开销变得显著。

团队添加了 Bun 入口点,使用 Bun.serve() 及其原生路由 API。由于在 SQLite 移除阶段已经将连接管理器设计为传输无关,这一迁移主要是接线工作。基准测试在 500 个控制器和 50 个并发 supervisor 请求的条件下进行,结果显示吞吐量从 4,434 req/s 提升至 9,434 req/s,p50 延迟从 10.1ms 降至 4.5ms,p95 延迟从 14.9ms 降至 7.4ms,最大延迟从 403ms 大幅降至 22ms。再次实现了翻倍的性能提升。

第三阶段:分析并优化热路径

Bun 开箱即用就比 Node.js 快,但团队并未停止优化。利用 Bun 的 --cpu-prof-md 标志输出 Markdown 格式的 CPU 分析报告,可以直接用文本编辑器查看而无需额外工具。分析结果揭示了三个明确的热点。

第一个热点是 Zod 验证,每次 POST 请求都执行完整的 DequeuedMessage.safeParse(),占总 CPU 的 22%。由于这是内部服务间流量,控制器已经验证过完整模式,团队将其替换为最小化的字段存在性检查。第二个热点是请求头转换,每次 GET 请求都调用 Object.fromEntries(req.headers.entries()) 然后读取单个字段,占 10.5% 的 CPU,替换为直接的 req.headers.get() 调用。第三个热点是调试日志,即使在过滤级别下也在序列化日志参数之前进行检查,占 8.6% 的 CPU。这三项修复在相同负载下将 CPU 使用量降低了约 40%。

第四阶段:编译为独立二进制

最后一步是利用 Bun 的 bun build --compile 功能生成单个自包含可执行文件。编译后的二进制文件无需运行时环境,无需 node_modules,容器镜像中只需包含二进制文件和一个 CA 证书包。

编译后的性能对比显示:吞吐量相比解释执行提升 14%,p95 延迟降低 24%,镜像大小从约 120MB(bun + node_modules)缩减至约 68MB。团队还测试了 --bytecode 选项(JSC 字节码预编译),发现它实际上损害了稳态性能。字节码有助于冷启动场景(CLI 工具、无服务器函数),但对于 JSC JIT 已经预热的长运行服务器,更大的二进制文件和额外的内存映射开销反而导致性能下降。

隐藏的陷阱:Bun 内存泄漏

部署到生产环境后,CPU 确实下降了,但 RSS(常驻内存)却快速攀升。监控图表显示 Node.js 稳定在 192 MiB,而 Bun 攀升至 250 MiB。团队发现了一个在 Bun HTTP 模型中特有的内存泄漏。

每个 GET /warm-start 请求都返回一个 Bun 持有的 Promise<Response>,有三条解决路径:被匹配(发送 payload 后解决)、超时(65 秒后发送 408)、客户端断开(pod 死亡或网络抖动触发 abort 事件)。前两条路径正常,但第三条导致了泄漏。

当客户端断开时,团队的 abort 处理器调用 removeConnection() 清理连接管理器,但从未解决挂起的 Promise<Response>。在 Node.js 的 Fastify 或 Express 中这不是问题 —— 服务器将响应状态绑定到 socket,当 socket 死亡时无论是否调用 res.end() 都会被清理。但在 Bun 中,fetch 处理器的契约不同,每个 Promise<Response> 必须被 settled,Bun 会保留内部请求状态直到 promise 被解决或拒绝。如果永不解决,状态永远留在内存中。每个泄漏的连接约 500-2000 字节,每小时数百次断开累积下来相当可观。

修复方法是一行代码:在 abort 事件中向已断开的客户端发送 499 状态码。499 是 Nginx 的「客户端关闭请求」状态码,客户端永远不会看到它(他们已经断开),但这允许 Bun 释放请求上下文。修复后 RSS 从 250 MiB 降至 85 MiB 并保持稳定。

值得注意的是,团队此前已经将 idleTimeout 从 Bun 默认的 10 秒调整为 120 秒,因为 10 秒的默认值会过早终止长轮询连接。这减少了连接抖动,从而减缓了泄漏速度,但并未根除问题。修复泄漏后,CPU 上升了约 5%—— 这是正确清理连接的代价,此前这些连接在后台泄漏。

完整性能对比

指标 Node.js + SQLite Node.js + Map Bun(解释执行) Bun(编译)
吞吐量 2,099 req/s 4,534 req/s 9,434 req/s ~10,700 req/s
p50 延迟 22.5ms 10.1ms 4.5ms ~3.9ms
p95 延迟 29.1ms 14.9ms 7.4ms ~5.6ms
最大延迟 619ms 403ms 22ms ~17ms
镜像大小 ~180MB ~180MB ~120MB ~68MB

最终实现了 5 倍吞吐量提升、28 倍更好的最大延迟、容器镜像从 180MB 缩减至 68MB。生产环境的实际表现甚至优于本地基准测试 ——Node.js 下的 CPU 波动剧烈,在负载下几乎翻倍,而 Bun 保持稳定;Node.js 的事件循环延迟飙升至 40-80ms,而 Bun 几乎为零。

迁移要点总结

对于考虑从 Node.js 迁移到 Bun 的团队,Trigger.dev 的经验提供了几个关键教训。首先,始终在优化前进行性能分析,SQLite 替换在事后看来一目了然,但只有通过性能分析才能发现它。其次,Bun 的 HTTP 模型与 Node.js 存在根本差异 —— 响应生命周期绑定到 promise 而非 socket,如果要迁移长轮询或流式端点,必须考虑每条代码路径是否正确解决了 Promise<Response>。第三,bun build --compile 可以在零代码改动的情况下带来 14% 的吞吐量和 24% 的 p95 延迟改善。第四,修复内存泄漏后 CPU 上升约 5%,这是正确清理连接的必要代价。最后,每个阶段都要进行基准测试,使用相同的负载测试配置,否则无法判断哪些改动真正有效。

Trigger.dev 将完整的调试经验封装为一个可复用的 Agent Skill,涵盖 Bun 内存泄漏排查、堆分析、JSC 指标、AbortSignal 生命周期和 prom-client 兼容性等问题。对于在生产环境中使用 Bun 的开发者,这些细节往往比基准数字更有价值。


资料来源:本文核心数据与案例来自 Trigger.dev 官方博客《Why we replaced Node.js with Bun for 5x throughput》(2026 年 3 月 27 日)。