两年前,Encore 团队面临一个关键抉择:如何为 TypeScript 应用提供高性能的后端运行时支持。已有的 Go 运行时运行良好,但如果简单地为 Node.js 创建一个 Sidecar 进程,延迟开销会达到每请求 2-4 毫秒。最终他们选择了完全不同的路径 —— 用 Rust 从零编写一个与 Node.js 共存的运行时。这个决定带来了 9 倍于 Express.js 的吞吐量提升,但也带来了无数工程挑战。本文将深入复盘这些挑战的解决思路与迭代经验。

从 Sidecar 到同进程:为什么不能走捷径

在确定最终方案之前,Encore 团队首先尝试了一种看似合理的渐进策略:将已有的 Go 运行时作为 Sidecar 进程部署在 Node.js 旁边,两者通过 IPC 通信。然而原型验证暴露了两个严重问题。

首先是延迟累积。一次典型的 API 请求会涉及数据库查询和消息发布,这意味着请求需要跨越 IPC 边界六到七次。基准测试显示,仅序列化与上下文切换就为每个请求增加了 2-4 毫秒的开销,这在微服务架构中是难以接受的。其次是运维复杂度。两个独立进程意味着两套监控、两套日志、两种崩溃可能,在生产环境中涉及数十个服务时,故障模式会呈指数级增长。

最终的技术选型是 Rust 配合 napi-rs。这样做有两个关键优势:第一,Rust 提供了与 Go 相同的内存安全和线程安全保证,同时能够接入 Tokio 异步生态处理数千并发连接,而不会阻塞 Node.js 的事件循环;第二,将所有非业务逻辑 ——HTTP 生命周期、数据库连接池、消息队列、链路追踪 —— 移入 Rust,意味着这些操作可以真正多线程运行,这是纯 Node.js 环境无法实现的优势。

跨越语言边界的核心技术挑战

让 Rust 与 JavaScript 真正协同工作远不止调用几次 NAPI 这么简单。团队遇到了三个关键挑战,每个都涉及对运行时行为的深度理解。

回调返回值捕获问题。NAPI 的设计初衷是从 JavaScript 调用原生代码:注册一个函数,JavaScript 调用它,原生代码执行并返回值。但反方向则困难得多 —— 当消息队列收到一条消息需要分发给 TypeScript 处理器,或者 HTTP 请求需要调用 TypeScript 端点函数时,原生代码需要获取 JavaScript 的返回值。标准抽象只支持向 JavaScript 传递参数,不支持捕获返回值。团队不得不 Fork 了 napi-rs 的 ThreadSafeFunction 实现,增加手动调用 JavaScript 函数并捕获返回值的能力。

Promise 链桥接问题。TypeScript 端点处理器是返回 Promise 的异步函数。当从 Rust 调用 JavaScript 并获得返回值时,需要检测这是否是一个 Promise—— 如果是,还需要链上一个 .then() 回调,通过 Tokio 通道将结果解析回 Rust。这本质上是将 JavaScript 的异步模型桥接到 Rust 的异步模型,两者的生命周期管理完全不同。

Future 取消时的处理边缘情况。Rust 的 Future 可以在任何时候被丢弃 —— 比如当 Cloud Run 的请求超时关闭连接时。但此时 JavaScript 处理器仍在 Node.js 事件循环上运行,无法被取消。Rust 端永远达不到 request_span_end,导致链路追踪缺少根 Span。团队实现了一个 CancellationGuard,当检测到 Future 被丢弃时,会 spawn 一个后台任务来等待 JavaScript 端完成,确保 Span 正确关闭。

性能优化的关键决策

Rust 运行时的性能优势来自几个刻意为之的架构决策,而非单纯的语言特性。

验证逻辑前置到 Rust 层。在基准测试中,Encore.ts 处理 121,005 请求每秒,而 Express.js 加上 Zod 验证只有 15,707。当启用验证时差距更大:Encore.ts 仍有 107,018 QPS,Express + Zod 跌至 11,878。核心原因是 Encore 在编译时由 TypeScript 解析器提取类型信息,运行时在 Rust 层直接进行请求验证,而其他框架必须在 JavaScript 中执行验证逻辑。

自定义二进制追踪协议。每个 Encore 应用的每个操作都会被追踪 ——API 调用、数据库查询、消息发布、外部 HTTP 调用、缓存操作。追踪数据包含时间、嵌套关系、请求响应体和错误详情。团队没有使用 Protobuf 构造消息,而是实现了自定义二进制协议。EventBuffer 使用可变长度整数编码和原始字节传输追踪 ID(16 字节)和 Span ID(8 字节),这与 OpenTelemetry 的二进制协议思路相同。在处理数百万追踪时,这种紧凑格式节省的成本非常可观。

内存布局优化。追踪系统需要关联单调时间(用于精确的持续时间测量)和壁挂时间(用于显示)。TimeAnchor 在同一时刻捕获 tokio::time::Instantchrono::DateTime,后续事件只记录单调偏移量。这避免了分布式追踪系统中常见时钟偏移问题 —— 子 Span 的时间看起来比父 Span 还早。

抽象与可维护性的平衡

运行需要支持三种不同的云提供商 ——NSQ(本地开发)、GCP Pub/Sub 和 AWS SNS+SQS。直接的 Rust 做法是大量使用泛型,但这会将提供商选择泄漏到代码库的每个类型签名中。团队选择了 trait 对象模式。

三个 trait——ClusterTopicSubscription—— 各对应三个实现。Manager 在启动时根据运行时配置选择正确的实现,用 Arc<dyn Trait> 包装,代码库的其他部分完全不需要知道底层是哪个云提供商。每个提供商都有其独特的实现细节:NSQ 使用类似 Actor 的模式,带有 tokio-spawned 的生产者和消息通道;GCP 使用 tokio::sync::OnceCell 进行延迟客户端初始化,因为 Cluster::topic() 方法需要同步返回(调用者不应该 await 才能获得主题引用),但创建 GCP 客户端是涉及网络调用的异步操作;AWS SQS/SNS 需要发布者 ID 来实现 FIFO 消息排序,其他提供商则不需要。

这种设计在对象存储模块中重复使用,实现了 S3 兼容存储和 Google Cloud Storage 的抽象。

迭代中的经验教训

团队在事后总结中承认了几个可以改进的地方。

错误上下文投资不足。Rust 的错误处理在语言层面很出色,但早期他们过度依赖 anyhow::Context 的通用模式,而不是定义具体的错误类型。当问题在消息队列堆栈深处失败时,“发布消息失败” 远不如包含主题名、消息大小、提供商和具体失败模式的结构化错误有用。团队正在逐步重构更好的错误类型。

OpenTelemetry 适配器应该更早交付。自定义追踪格式捕获的信息比在 OpenTelemetry 中直接表示更丰富(请求 / 响应载荷、自动重述),这很有价值。但客户从第一天起就希望将追踪导出到他们现有的可观测性栈,“没有使用开放标准” 的质疑是合理的。团队现在正在构建 OTel 适配器,但如果能更早优先级会好得多。

快照测试的价值被低估。团队使用快照测试,特别是在 TypeScript 类型系统解析周围。这是代码库中最有效的测试策略之一。每次添加对新 TypeScript 构造的支持时,快照测试都会捕获整个表面的回归。应该从一开始就更投资于这个方向。

Go 运行时的可能性。两种语言使用单一运行时会显著降低维护负担。但 Go 和 Rust 之间的 FFI 比 Node.js 情况更难,因为 Go 的垃圾回收器在运行。内存所有权在 Go-Rust 边界很棘手,Go 可以在 GC 期间移动对象,而 cgo 本身也有性能开销。团队已经开始探索,但实现起来并不简单。

关键参数与可落地建议

基于 Encore 的实践经验,以下是构建类似运行时可以考虑的关键参数。

对于进程间通信延迟,Sidecar 方案的 IPC 开销在 2-4 毫秒量级,当请求涉及多次跨边界操作时会累积成显著瓶颈。对于 FFI 场景下的 Promise 桥接,需要在 Rust 端实现 .then() 回调链,通过 tokio oneshot 通道将 JavaScript 的异步结果同步回 Rust 上下文。对于追踪采样,采样决策应在请求开始时确定并传播到所有子 Span,避免部分追踪(只能看到 API 调用看不到数据库查询的追踪比没有追踪更糟糕)。对于泛型膨胀问题,当存在多个实现但类型签名不需要区分时,trait 对象 (Arc<dyn Trait>) 比泛型更合适。

小结

Encore 用 67,000 行 Rust 代码构建了一个与 Node.js 共存的 TypeScript 运行时,实现了 9 倍于 Express.js 的吞吐量提升。这个成功并非来自语言选择本身,而是来自一系列深思熟虑的工程决策:将验证逻辑前置到 Rust 层、自定义二进制追踪协议、用 trait 对象管理多云抽象、以及对 Promise 桥接和 Future 取消等边界情况的仔细处理。

真正的经验教训在于:技术选型只是起点,持续的迭代 —— 无论是错误类型的重构、OpenTelemetry 适配器的开发,还是快照测试体系的完善 —— 才是让大型系统保持健康的动力。

资料来源:本文核心事实与数据来自 Encore 官方博客《What We Learned Building a Rust Runtime for TypeScript》(2026 年 4 月 8 日)。