Node 与 Go:API 对决

2025-05-25

Node 与 Go:API 对决

免责声明和介绍

本博客主要是为了娱乐和教育探索。本文的结果不应成为您技术决策的唯一依据。这并不意味着一种语言比另一种语言更好,请不要太认真地阅读。

事实上,比较如此不同的语言并没有多大意义。

很酷,话虽如此,让我们来点有趣的,比较一些指标,并更好地了解这两种语言在面临巨大压力时如何处理一些关键方面(RAM、CPU、打开文件描述符数和操作系统线程数)。

这些指标是如何收集的?

如果您想了解此基准测试是如何分析的,请展开下面的部分,否则,您可以直接跳到结果🤓。

幕后

技术栈

它是使用以下技术创建的:

  • 1x EC2 t2.micro(API 的命令中心)
  • 1x EC2 t2.xlarge(请求发射枪)
  • Postgres RDS 用于数据持久化
  • Vegeta释放 HTTP 负载
  • Golang 1.21.4 和 Node.js 21.4.0 的 API 对决
  • 打开 Tofu自动启动我们的服务器

重要提示: API 服务器仅有 1 核处理器和 1GB 内存。本文展示了两种方法在非常有限的环境下的效率。您可以点击此处查看完整代码。

流程图

沟通是如何发生的?
流程图

两台服务器都位于 AWS 的同一 VPC 内,从而确保了最小的延迟。然而,RDS 虽然位于同一 AWS 区域 (sa-east-1),但却运行在另一个 VPC 中,因此实际延迟会更高。

这很好,因为在现实世界中,会有延迟

手动 RDS 设置

不幸的是,我无法使用 OpenTofu 设置 Postgres RDS(咳咳:技能问题),所以我不得不在 AWS 上手动制作它并执行以下脚本:

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
);
TRUNCATE TABLE users;
Enter fullscreen mode Exit fullscreen mode

好了,一切就绪,表演时间到了!

环境初始化

使用以下命令启动环境:

tofu apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

完整的 main.tf 文件在这里

它起什么作用?

  • 启动 1 个 VPC + 2 个子网
  • 启动 2 个 Ubuntu 服务器,执行特定脚本
  • 在 API 服务器上安装 Node + Golang
  • 在 Gun 服务器上设置 Vegeta
  • 部署 API 和负载测试器代码
  • 生成 2 个用于连接的 SSH 脚本(ssh_connect_api.sh 和 ssh_connect_gun.sh)

监控流程

通过此设置,我可以访问 API 服务器并启动 Node 或 Go API。

同时,启动monitor_process.sh来获取RAM、CPU、线程数和文件描述符数等指标并保存到.csv文件中。

所有操作都是根据正在运行的 API 的进程 ID 完成的。

监控流量

在这里查看脚本!

脚本参数

  • 进程 ID
  • 每秒的请求数(正确命名 CSV 文件)

一旦 API 运行,我就会使用console.log(process.pid)Node 或fmt.Printf("ID: %d", os.Getpid())Golang 获取进程 ID。

然后,我可以简单地运行:

./monitor_process.sh 2587 2000
Enter fullscreen mode Exit fullscreen mode

此命令监视我们的进程,每秒使用新数据更新名为process_stats_2000.csv的.csv文件。

好的,现在让我们分析结果,比较两个 API,看看我们可以从中获得什么经验,让我们开始吧!


每秒 2,000 个请求

好的,对于第一步,我运行了这个 Vegeta 脚本,它在 30 秒内每秒向服务器 API 发出 2,000 个请求。

这是在Gun Server中通过运行

./metrics.sh 2000
Enter fullscreen mode Exit fullscreen mode

其产生以下输出:

Starting Vegeta attack for 30s at 2000 requests per second...
Enter fullscreen mode Exit fullscreen mode

然后,我将结果组合成一些漂亮的图表,让我们看一下:

延迟 x 秒

通过查看延迟图表,我们可以看到 Golang 最初遇到了很多困难,需要大约 5 秒才能稳定下来。

2,000 个请求/秒的延迟时间超过几秒

这可能是一次性异常,但由于我不会重新进行测试并且指标都是正确的,因此我认为这是 Node 的一次幸运之举

Node 在大部分测试过程中保持一致的延迟,在 12 秒和 20 秒时出现峰值。

而 Golang 则在一开始时在稳定延迟方面遇到了一些麻烦,失去了领先位置。不过,之后情况有所好转,将延迟保持在 230 毫秒左右。

文件描述符计数

这个很有趣。
FD 计数 2,000 个请求/秒

在 Linux 中,每个服务器连接都会创建一个新的套接字和相应的文件描述符 (FD) 。这些 FD 存储连接信息。

在 Ubuntu 上,打开文件描述符的默认软限制为 1024。

然而,Go 和 Node 都忽略了软限制,始终使用硬限制。这一点可以通过/proc/$PID/limits在 node/go 进程启动后访问 FD 来验证。

您可以使用该命令ulimit -n查看当前 shell 会话的打开文件描述符的操作系统软限制。

节点限制

好的,这意味着操作系统不会干扰打开的 FD 的数量;编程语言会对其进行管理。

在这个测试中,Node 保持较低但不规则的打开 FD 数量,而 Golang 则飙升至 8,000,然后稳定下来,并保持一致直到结束。

线程数

Node.js 不是单线程的吗?🤯
线程数 2,000 个请求/秒

嗯,不是

默认情况下,Node 启动几个线程:

  • 1 主线程:执行 JavaScript 代码并处理事件循环。
  • 4 个工作线程(默认libuv 线程池
    • 处理阻塞异步 I/O,例如 DNS 查找查询、加密模块和一些文件 I/O 操作。
  • V8 线程
    • 1 编译器线程:将 JavaScript 编译为本机机器代码。
    • 1 Profiler Thread:收集性能配置文件以进行优化。
    • 2 个或更多垃圾收集器线程:管理内存分配和垃圾收集。
  • 附加内部线程:数量各不相同,适用于各种 Node.js 和 V8 后台任务。

我注意到,在 Node 进程启动时,它创建了 11 个 OS 线程,一旦请求开始到达,线程数就会跳转到 15 个 OS 线程并保持在那里。

另一方面,Go 保留了 4 个稳定的 OS 线程。

内存

TL;DR
  • Node.js:
    • RAM 使用率持续较低,在 75MB 到 120MB 之间。
    • 利用事件循环进行 I/O 操作,避免产生新线程。
    • 有关 Node 事件循环的更多信息:探索 Node.js 中的异步 I/O
  • 去:
    • 初始 RAM 使用率较高,稳定在 300MB。
    • 为每个网络请求生成一个新的 goroutine。
    • Goroutines 比 OS 线程更轻,但在负载下仍然会影响内存。
    • 深入了解 Go 的运行时调度程序:Go 运行时调度程序讲座

RAM 使用率 2,000 请求/秒

解释
在整个测试过程中,Node 保持了较低且更稳定的 RAM 使用率,在 75MB 到 120MB 之间。

同时,Go 的 RAM 使用量在最初几秒钟内不断增加,直到稳定在 300MB(几乎是 Node 峰值的三倍)。

这种差异可以通过两种语言处理异步操作(如 I/O 数据库通信)的方式来解释。

Node 使用事件循环 (Event-Loop) 方法,这意味着它不会创建新的线程。相比之下,Go 通常会为每个请求生成一个新的 goroutine,这会增加内存使用量。goroutine是由 Go 运行时管理的轻量级线程

尽管它比 OS 线程轻,但在高负载下仍然会留下内存占用。

要了解 Node 事件循环的见解,请查看我写的这篇博客文章

为了更好地理解 Go Runtime 调度程序,请观看这​​个精彩的演讲- 这是我看过的最好的演讲之一。


中央处理器

此时,Node 能够使用比 Go 更少的 CPU,这可能是因为 Go 运行时比 libuv 的事件循环更复杂并且需要更多的步骤/计算。

CPU 使用率 2,000 个请求/秒

全面的

我必须诚实地说:我对这个结果感到惊讶。

Node赢得了这一场🏆。

它展示了:

  • 卓越的 p99 延迟,99% 的请求响应时间低于 1.2 秒,而 Go 的响应时间则为 4.31 秒
  • 平均延迟更快,为 147ms,而 Go 为 459ms,速度快 3.1 倍!
  • 最大延迟明显更小,峰值仅为 1.5 秒,而 Go 则为 6.4 秒,慢了 4.2 秒。(来吧,Gopher,你看起来很糟糕!)

Go vs Node 2,000 个请求/秒

每秒 3,000 个请求

现在让我们重新进行测试,在 30 秒内为每个 API 发送 3,000 个请求/秒,并查看结果。

延迟 x 秒

虽然 Go 能够保持真正稳定的延迟(只有两个小峰值),但 Node 却陷入了严重的困境,并在整个测试过程中表现出非常不一致的延迟。
每秒 3,000 个请求的延迟

文件描述符计数

还记得我告诉过你,Node 和 Go 都不遵守打开文件描述符的软限制,并且两种语言都自行管理它吗?

这是一个有趣的事实:
FD 计数 3,000 个请求 Node vs Go

通过在测试的每个阶段设置开放 FD 的“硬”限制(基于某些我不确定是哪一个指标),Golang 能够在更短的时间内使用更少的资源处理、处理和传递更多请求。

这太酷了!

看看 Go 是如何管理其 FD 的:

  • 8个FD:在前0-3秒内
  • 1,590 FD:4-17 秒之间
  • 2,225 FD:18-31 秒之间

另一方面,Node 不会像 Go 那样干扰打开的文件描述符。你可以在图表中看到这一点。

从经验上看,Go 似乎以某种速率预先分配(或预先打开)文件描述符并重复使用它们,而不是在每个连接到达时生成一个。

不过,我不确定他们到底是怎么做到的,如果你有什么提示,请随时发表评论😄

我发现了一些关于此内容的好文章:

线程数

好的,这次测试发生了一些值得注意的事情。
线程数 3,000 个请求 Node vs Go

节点:随着请求开始到达,操作系统线程数从 11 个飙升至 15 个。我认为这是由于 DNS 查找操作造成的,正如本期简述的那样。

Go:将操作系统线程从 4 个提升到 5 个。Go 的智能之处在于运行时调度程序,它可以将多个 Goroutine 打包到每个操作系统线程中。即使线程变得笨拙,它也能平滑地启动一个新的操作系统线程。

这种方法不仅高效,更是资源优化的典范,能够最大限度地发挥硬件的性能。真是太棒了!🚀

内存

这时,您可能注意到 Node.js 行比 Go 行持续时间更长,这是因为 API 花费了更多时间来回答它收到的所有请求。

这也影响了内存的使用。还记得第一次测试时,Node 的内存使用量远低于 Go 吗?

当服务器上有大量连接等待处理时,情况就不是这样了。
RAM 使用情况 3,000 个请求 Node vs Go

中央处理器

这一次,Node 需要的 CPU 使用率远高于此,并且能够将使用率保持在 35% 以下,而 Node 的峰值为 64%。
CPU 使用率 3,000 个请求 Node vs Go

全面的

总体而言,Node 与 Go 相比,每秒请求数为 3,000

🎉我们吵架了!🎉

争论尚未结束,Golang 在这一点上表现得更加出色,让我们来看看数据:

Golang 有:

  • 更低的延迟
    • p99:738.873ms 对 30.001s,比 Node低 40 倍。
    • 平均:60.454ms vs 7.079s -快 118 倍
    • 最大值:Go 的峰值为 1.33 秒,而 Node 的峰值为 30.0004 秒。
  • 完美成功率(100%)
    • 而来自 Node 的请求率为 91.93%,其中一些请求失败。

那是一场大屠杀,就像将一辆新跑车与一辆Fusquinha 跑车进行比较一样。

详细比较

Node.js 性能指标:

  • 总请求数:86,922,速率为每秒 2,897.33。
  • 吞吐量:每秒 1,449.29 个请求。
  • 期间
    • 总计:55.135秒。
    • 攻击阶段:30.001秒。
    • 等待时间:25.134 秒。
  • 延迟
    • 最小值:3.458 毫秒。
    • 平均值:7.079秒。
    • 中位数(第 50 个百分位数):6.068 秒。
    • 第 90 个百分位数:9.563 秒。
    • 第 95 个百分位数:26.814 秒。
    • 第 99 个百分位数:30.001 秒。
    • 最大值:30.004 秒。
  • 数据传输
    • 输入字节数:2,077,556(平均 23.90 字节/请求)。
    • 输出字节数:7,351,352(平均 84.57 字节/请求)。
  • 成功率:91.93%。
  • 状态代码:7016 次失败,79,906 次成功(201 代码)。

Golang性能指标:

  • 总请求数:90,001,速率为每秒 3,000.09。
  • 吞吐量:每秒 2,999.89 个请求。
  • 期间
    • 总计:30.001秒。
    • 攻击阶段:29.999秒。
    • 等待时间:2.035 毫秒。
  • 延迟
    • 最小值:1.371 毫秒。
    • 平均值:60.454 毫秒。
    • 中位数(第 50 个百分位数):4.773 毫秒。
    • 第 90 百分位数:194.115 毫秒。
    • 第 95 百分位数:453.031 毫秒。
    • 第 99 百分位数:736.873 毫秒。
    • 最大值:1.33秒。
  • 数据传输
    • 输入字节数:2,430,027(平均 27.00 字节/请求)。
    • 输出字节数:8,280,092(平均 92.00 字节/请求)。
  • 成功率:100%。
  • 状态代码:全部 90,001 个请求均成功(201 代码)。

  • 吞吐量:与 Node 相比,Go 具有更高的吞吐量。

  • 延迟:Node 表现出明显更高的延迟,尤其是在平均值、第 95 和第 99 个百分位数。

  • 成功率:Go 的成功率达到了 100%,而 Node 的成功率较低,并且有一些请求失败。

每秒 5,000 个请求

最后一轮,让我们看看两种语言如何应对严峻的压力

延迟 x 秒

Go 能够保持非常低且稳定的延迟,直到约 20 秒后,它开始出现一些问题,导致 5 秒的峰值,这非常慢。

Node 在整个测试过程中都出现了问题,响应延迟在 5-10 秒之间。

很高兴地注意到,即使在非常紧张的测试中,Go 在整个测试过程中也能保持稳定。
5,000 个请求/秒的延迟

文件描述符计数

我们再次看到 Golang 的打开文件描述符是多么稳定,而 Node.js 的打开文件描述符是多么不受管理、线性增长

我相信这与 Go Network Poller 直接相关,它重用(并且可能预先创建)文件描述符,而不是在每个请求到达时创建一个。

我想知道 Node 是否可以从这种方法中受益,一定会检查一下😅
5,000 个请求/秒 Go vs Node FD 数量

线程数

在此图表中,我们可以看到 Node 以 11 个 OS 线程开始,每当连接开始到达时就会跳到 15 个,而 Golang 在测试的大部分时间内保持 4 个 OS 线程,最后增加到 5 个。

Go 的策略在高负载下似乎更加稳定。
5,000 个请求/Go vs Node 线程数

内存

Node.js 的 RAM 使用量呈线性增长,而 Go 的增长则是阶梯式的,类似于爬梯子。

Go 中的这种模式是由于其运行时主动管理资源并为goroutineOS 线程打开的文件描述符设置限制。
5,000 个请求/秒 Go 与 Node RAM 使用情况对比

中央处理器

两种语言的 CPU 使用模式非常相似,这表明这可能超出了语言控制范围,而是委托给了操作系统。
5,000 个请求/秒 Go 与 Node CPU 使用率对比

全面的

Go 再次表现出色,具有更高的成功率和更低的 p99、平均、最小和最大延迟。

鉴于Go 是一种编译型语言,而 Node.js(JavaScript)是解释型语言,因此出现这种结果是意料之中的
编译型语言在执行机器码之前通常步骤较少。

尽管存在固有的挑战,Node.js 仍成功处理了 89.38% 的请求。
Go vs Node 总体每秒 5,000 个请求

最后的考虑

感谢您花时间阅读这篇博文🙏

Go 语言,一门专注于并发和并行设计的编译型语言,最终胜出并不令人意外。不过,看看最终结果如何,仍然很有趣。

看到 Go 和 Node.js 如何以不同的方式处理任务以及这对计算机资源有何影响,真是太酷了。

我总结了以下要点。

打开文件描述符管理

  • Go:得益于其智能网络轮询器和资源管理,Go 演示了一种文件描述符预分配和重用策略。这种方法有助于在高网络负载下实现高效处理和可扩展性。
  • Node.js:在文件描述符使用中显示动态的、可能不受管理的模式,反映了其处理服务器连接和逐个打开 FD 的方法。

线程管理和 Node.js

  • Go:保持稳定、较低的OS 线程数,突出了其运行时调度程序在优化线程使用方面的效率,尤其是在高压力下🤯。
  • Node.js:与普遍看法相反,Node.js 使用多个线程来执行 DNS 查找、垃圾收集器(Hi V8)和阻止异步 I/O 操作等任务,它不仅仅是一个线程。
文章来源:https://dev.to/ocodista/node-vs-go-api-showdown-4njl
PREV
如何监控多个应用程序之间的请求...🤔 TL;DR
NEXT
Vue Apollo v4:初体验