JSON API开发中的Node.js与Go对比:吞吐量相同,内存占用减少2.6倍
对相同的 Node.js 和 Go HTTP 服务进行并行负载测试,可以显示重写在哪些方面能带来好处(驻留内存),而在哪些方面则没有效果(吞吐量和延迟)。
“用 Go 重写它”是几乎所有 Node.js 团队迟早会听到的建议,这类提议通常会强调速度更快、事件循环负担更轻以及云服务费用更低,但却缺乏具体数据支撑。要解决这个问题,可以用两种语言构建相同的小型服务,以完全一致的方式加载它们,然后观察在多次运行后仍存在的差异。本文将详细介绍这样的实验过程、实际得到的结果、哪些数据是人为造成的,以及如何将这些结果转化为针对自身服务的合理迁移决策。
提出这个问题的时机并非偶然。TypeScript团队转向基于Go的本地编译器后,“将其移植到Go语言”似乎成了解决性能问题的默认方案(参见TypeScript 7的Go语言重写在实际应用中的意义)。然而,编译器属于受CPU性能限制的批处理程序;而Web API的特性则截然不同,正因如此它需要独立的性能测试标准。
一个刻意设计得相当乏味的服务
测试对象是一个基于内存用户存储的极简JSON API,该存储中预置了10,000条记录。它提供了两个接口:
GET /users/:id用于查询用户并返回结果,若未找到则返回404错误信息。POST /users用于解析JSON格式的请求体,对其进行验证后插入新用户记录。
没有数据库,也没有框架。这两种选择都是经过深思熟虑的:使用数据库会导致大量时间开销,并掩盖任何运行时的差异;而框架则会增加与语言本身无关的额外负担。两种实现方式中的路由、验证规则以及初始数据都是完全相同的。
环境及其固有的不对称性
所有测试都在一台配备16核Apple Silicon处理器的笔记本电脑上运行,该系统安装了macOS 26.6、Node v22.15.0和Go 1.24.4。Node以单个进程运行,未使用cluster模块或worker_threads,因此请求处理仅依赖单个核心。Go的net/http则使用默认的GOMAXPROCS设置,这使得调度器能够将协程分配到所有核心上。
在阅读本文的其余部分时,请始终牢记这种不对称性。这是整个对比中最重要的一点,因为负载生成器需要与两台服务器争夺相同的16个核心资源。
两种实现方式
Node版本仅使用内置的http模块。需要注意的是,路由是通过手动检查req.method以及URL的startsWith来实现的,数据存储则采用普通的Map结构,每个响应都会通过JSON.stringify进行序列化处理,并且会显式地添加响应结束标记。POST请求的处理逻辑在注释中进行了概括;它与后面Go代码中的检查逻辑是相同的。
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url.startsWith("/users/")) {
const id = req.url.split("/")[2];
const user = users.get(id);
if (!user) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(user));
return;
}
// POST /users: parse body, validate name + email, insert. Same checks as Go below.
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
Go版本在ServeMux上注册处理函数,并通过截取路径前缀来获取ID。数据存储采用由互斥锁保护的映射结构,这是由于Go会通过多个协程并发处理请求,而Node的单线程事件循环永远不会同时从两个地方访问Map。响应是通过json.NewEncoder生成的,数据会直接写入ResponseWriter中。
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/users/")
user, ok := store.get(id)
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
})
POST路由的验证逻辑在两种语言中是相同的:name必须是非空字符串,而email必须包含@符号。这是Go版本的函数实现;Node版本则使用typeof和String.prototype.includes来执行相同的两次验证,因此两种实现并没有更优的代码路径。
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
负载是如何施加的
负载来自autocannon,以15秒为周期在两种并发级别下进行测试:50个连接代表中等负载,300个连接则用于模拟高负荷的端点。
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
POST接口也以相同方式进行了测试,但使用JSON格式的请求体,因为对输入数据的解码与验证更接近实际请求的处理流程,而非简单的映射查找。
内存使用情况是单独测量的。每台服务器的驻留内存大小(通过ps -o rss命令获取)在空闲状态时以及300个连接测试结束后立即被记录,且每次测量都从新启动的进程开始,以避免之前测试留下的内存残留影响数据准确性。关键的是,在延迟测试期间并未启用内存采样工具;正如后续章节所示,这一细节确实改变了测试结果。
在查看结果之前,请务必认清实际情况:这只是一台笔记本电脑,而非独立的基准测试设备,而且负载生成器会与服务器共享CPU资源。这些数据仅适用于当前这类工作负载,并不能作为Node与Go之间的总体对比依据。进行此类比较时,应将服务器代码、基准测试脚本以及原始输出结果(此处为benchmark.json文件)保存在结论旁边,并注明哪些数据被采用,哪些被舍弃。
吞吐量:难分高下
在所有测试中,Go都未能在请求处理速率上取得显著优势。在GET接口的50个连接场景下,Node的表现更佳,每秒处理请求量大约多出5,000次。而在POST接口的300个连接场景下,两者的每秒请求量仅相差几百次。所谓Node“在高负载下性能下降”的说法并未得到验证。
这一设置解释了部分原因。由于负载生成器与两台服务器通过回环接口共享16个核心,没有进程会面临CPU资源不足的问题,因此消除了在负载过高的生产环境中可能出现的运行时间差异。而在小型专用实例上,这种差距很可能会变大。这确实是笔记本性能测试的固有局限,应当明确说明而非隐瞒。
延迟:排除干扰后也处于相同水平
在300个连接的情况下,中位数、p99值乃至最大延迟之间的差距都仅在几毫秒之内。
之前的测试中Node的最大延迟为230毫秒,这一数值足以作为得出结论的依据。但在一台没有内存采样器占用CPU资源的安静机器上重新测试后,这一峰值消失了。问题出在测量工具本身,而非Node的尾部延迟问题。
这一经验适用于在共享机器上进行的任何基准测试。单个最坏情况数值是最不可靠的指标,因为它可能由调度器故障或后台进程导致。在相信那些惊人的最大值之前,应在空闲机器上重新运行测试,同时查看p99数值,并确认该最坏情况是否会出现。
内存:唯一持续存在的差异
常驻内存是唯一既数值较大又能够重复出现的差异:
- 空闲状态:Go进程为13 MB,Node进程为49 MB。
- 持续承受300个连接负载后:Go进程为32 MB,Node进程为84 MB。
该测试共重复了三次,每次都从新的进程开始,结果完全一致。在功能相同的任务下,Node进程的内存使用量大约是Go进程的2.6倍。
其解释主要基于架构层面。Node 进程包含 V8 引擎、JIT 编译器以及为动态语言设计的垃圾回收堆,而 Go 二进制文件则是预先编译好的,运行时更为精简。如果需要为容器内存限制付费,这种差异会在每个副本中都会体现出来:部署在多个 Pod 中的端点需要在每一个 Pod 中都支付 Node 的基础成本。
该实验未展示的内容
人们很容易将此解读为“Go 优于 Node”,但数据并不支持这一观点。吞吐量与延迟相互关联,而在低并发 GET 请求测试中 Node 更具优势。如果你的限制条件是每秒请求数或拥有多余核心的服务器上的尾延迟,那么这项基准测试表明重新编写代码几乎不会带来任何好处。
它也并未提及与数据库相关的操作。大多数生产环境中的服务,其延迟主要来自查询等待时间,而非 JSON 序列化过程,而这两种操作在任意语言中的耗时都是相同的。如果你的服务在 Postgres 上是 I/O 密集型的,那么这些数据对你来说基本没有参考价值。
最终,核心的不对称性依然存在:Go默认会使用每个核心,而单进程Node则只使用一个核心。那些看似Go的优势,其实不过是“Go能自动实现并行处理”而已。合理的初步解决方案是使用Node自带的cluster模块,为每个核心分配一个工作进程,这样Node就能像Go一样充分利用硬件资源。这一点在此处并未进行测试,而且相比引入另一种语言,它的干预程度要小得多。需要记住的是,每个集群工作进程都是独立的进程,拥有自己的堆内存,因此虽然集群化可以提高CPU利用率,但总内存占用反而会增大而非减小。
将数据转化为决策依据
根据这些结果,全面重写并无法满足要求。而采取更有针对性的措施则可行:仅将那些在多个副本上运行且占用大量内存的端点迁移过去,这样内存使用量减半就会成为显而易见的指标,其余部分则仍留在 Node 中。
这种克制很重要,因为代码迁移从来都不是免费的。更换编程语言意味着需要另一套工具链、另一套部署流程、为值班工程师准备的额外操作手册,还会导致团队技能分布不均。当内存成为关键限制因素时,这些成本是值得承担的;而在其他情况下则不必。
当下一份关于从 Node 迁移到 Go 的提案出现时,你可以用同样的方法来评估它:
- 构建相关服务或端点的两个版本。
- 在实际流量所达到的并发强度下加载它们,包括写入操作路径。
主要结论
- 对于简单的内存型 JSON API,在相同硬件上 Node 与 Go 的吞吐量和延迟表现基本一致。
- Go 最明显且可重复的优势在于负载下的驻留内存占用低约 2.6 倍,这对需要大规模部署的服务尤为重要。
- Go 的默认多核调度机制与 Node 的单进程模式存在干扰因素,因此在重新编写代码之前应先尝试集群部署。
- 在共享机器上进行基准测试时,异常高的延迟峰值很常见,务必先复现问题再下结论。
- 在确实能带来实际收益且其价值高于学习第二种语言的成本时,再选择性地进行迁移。
相关阅读
- Node.js Streams详解:解决内存不足导致的文件处理崩溃问题 — 了解为何将整个文件加载到内存中会导致Node.js服务器崩溃,以及如何通过可读流、可写流、双向流和转换流结合背压机制来解决这一问题。
- 一个天气CLI,五种技术栈:Rust、Go、Zig、Bun和Node.js — 同一个基于HTTP加JSON的CLI在Rust、Go、Zig、Bun和Node.js中的实现方式,以及二进制大小、构建时间和配置难度对选择技术栈的影响。
- 等待与计算:Node.js与Go中的并发与并行 —— 提供可运行的Node.js和Go示例,区分并发与并行,说明何时使用Promise足够,何时需要工作线程,以及goroutine之间的差异。