Node 与 Go:API 对决
- 免责声明和介绍
- 这些指标是如何收集的?
- 每秒 2,000 个请求
- 每秒 3,000 个请求
- Dev.to 无法链接到同名部分😔
- 每秒 5,000 个请求
- 但它们确实存在,而且非常棒!
- 最后的考虑
免责声明和介绍
本博客主要是为了娱乐和教育探索。本文的结果不应成为您技术决策的唯一依据。这并不意味着一种语言比另一种语言更好,请不要太认真地阅读。
事实上,比较如此不同的语言并没有多大意义。
很酷,话虽如此,让我们来点有趣的,比较一些指标,并更好地了解这两种语言在面临巨大压力时如何处理一些关键方面(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;
好了,一切就绪,表演时间到了!
环境初始化
使用以下命令启动环境:
tofu apply -auto-approve
它起什么作用?
- 启动 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
此命令监视我们的进程,每秒使用新数据更新名为process_stats_2000.csv的.csv文件。
好的,现在让我们分析结果,比较两个 API,看看我们可以从中获得什么经验,让我们开始吧!
每秒 2,000 个请求
好的,对于第一步,我运行了这个 Vegeta 脚本,它在 30 秒内每秒向服务器 API 发出 2,000 个请求。
这是在Gun Server中通过运行
./metrics.sh 2000
其产生以下输出:
Starting Vegeta attack for 30s at 2000 requests per second...
然后,我将结果组合成一些漂亮的图表,让我们看一下:
延迟 x 秒
通过查看延迟图表,我们可以看到 Golang 最初遇到了很多困难,需要大约 5 秒才能稳定下来。
这可能是一次性异常,但由于我不会重新进行测试并且指标都是正确的,因此我认为这是 Node 的一次幸运之举。
Node 在大部分测试过程中保持一致的延迟,在 12 秒和 20 秒时出现峰值。
而 Golang 则在一开始时在稳定延迟方面遇到了一些麻烦,失去了领先位置。不过,之后情况有所好转,将延迟保持在 230 毫秒左右。
文件描述符计数
在 Linux 中,每个服务器连接都会创建一个新的套接字和相应的文件描述符 (FD) 。这些 FD 存储连接信息。
在 Ubuntu 上,打开文件描述符的默认软限制为 1024。
然而,Go 和 Node 都忽略了软限制,始终使用硬限制。这一点可以通过/proc/$PID/limits
在 node/go 进程启动后访问 FD 来验证。
您可以使用该命令ulimit -n
查看当前 shell 会话的打开文件描述符的操作系统软限制。
好的,这意味着操作系统不会干扰打开的 FD 的数量;编程语言会对其进行管理。
在这个测试中,Node 保持较低但不规则的打开 FD 数量,而 Golang 则飙升至 8,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 运行时调度程序讲座。
解释
在整个测试过程中,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 的事件循环更复杂并且需要更多的步骤/计算。
全面的
我必须诚实地说:我对这个结果感到惊讶。
Node赢得了这一场🏆。
它展示了:
- 卓越的 p99 延迟,99% 的请求响应时间低于 1.2 秒,而 Go 的响应时间则为 4.31 秒
- 平均延迟更快,为 147ms,而 Go 为 459ms,速度快 3.1 倍!
- 最大延迟明显更小,峰值仅为 1.5 秒,而 Go 则为 6.4 秒,慢了 4.2 秒。(来吧,Gopher,你看起来很糟糕!)
每秒 3,000 个请求
现在让我们重新进行测试,在 30 秒内为每个 API 发送 3,000 个请求/秒,并查看结果。
延迟 x 秒
虽然 Go 能够保持真正稳定的延迟(只有两个小峰值),但 Node 却陷入了严重的困境,并在整个测试过程中表现出非常不一致的延迟。
文件描述符计数
还记得我告诉过你,Node 和 Go 都不遵守打开文件描述符的软限制,并且两种语言都自行管理它吗?
通过在测试的每个阶段设置开放 FD 的“硬”限制(基于某些我不确定是哪一个指标),Golang 能够在更短的时间内使用更少的资源处理、处理和传递更多请求。
这太酷了!
看看 Go 是如何管理其 FD 的:
- 8个FD:在前0-3秒内
- 1,590 FD:4-17 秒之间
- 2,225 FD:18-31 秒之间
另一方面,Node 不会像 Go 那样干扰打开的文件描述符。你可以在图表中看到这一点。
从经验上看,Go 似乎以某种速率预先分配(或预先打开)文件描述符并重复使用它们,而不是在每个连接到达时生成一个。
不过,我不确定他们到底是怎么做到的,如果你有什么提示,请随时发表评论😄
我发现了一些关于此内容的好文章:
- net/http 有连接池吗? - 讨论 Go 的
net/http
包以及它如何管理连接。 - Go Netpoller - 解释 Go Netpoller 的文章。
线程数
节点:随着请求开始到达,操作系统线程数从 11 个飙升至 15 个。我认为这是由于 DNS 查找操作造成的,正如本期简述的那样。
Go:将操作系统线程从 4 个提升到 5 个。Go 的智能之处在于运行时调度程序,它可以将多个 Goroutine 打包到每个操作系统线程中。即使线程变得笨拙,它也能平滑地启动一个新的操作系统线程。
这种方法不仅高效,更是资源优化的典范,能够最大限度地发挥硬件的性能。真是太棒了!🚀
内存
这时,您可能注意到 Node.js 行比 Go 行持续时间更长,这是因为 API 花费了更多时间来回答它收到的所有请求。
这也影响了内存的使用。还记得第一次测试时,Node 的内存使用量远低于 Go 吗?
中央处理器
这一次,Node 需要的 CPU 使用率远高于此,并且能够将使用率保持在 35% 以下,而 Node 的峰值为 64%。
全面的
🎉我们吵架了!🎉
争论尚未结束,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 在整个测试过程中也能保持稳定。
文件描述符计数
我们再次看到 Golang 的打开文件描述符是多么稳定,而 Node.js 的打开文件描述符是多么不受管理、线性增长
我相信这与 Go Network Poller 直接相关,它重用(并且可能预先创建)文件描述符,而不是在每个请求到达时创建一个。
我想知道 Node 是否可以从这种方法中受益,一定会检查一下😅
线程数
在此图表中,我们可以看到 Node 以 11 个 OS 线程开始,每当连接开始到达时就会跳到 15 个,而 Golang 在测试的大部分时间内保持 4 个 OS 线程,最后增加到 5 个。
内存
Node.js 的 RAM 使用量呈线性增长,而 Go 的增长则是阶梯式的,类似于爬梯子。
Go 中的这种模式是由于其运行时主动管理资源并为goroutine、OS 线程和打开的文件描述符设置限制。
中央处理器
两种语言的 CPU 使用模式非常相似,这表明这可能超出了语言控制范围,而是委托给了操作系统。
全面的
Go 再次表现出色,具有更高的成功率和更低的 p99、平均、最小和最大延迟。
鉴于Go 是一种编译型语言,而 Node.js(JavaScript)是解释型语言,因此出现这种结果是意料之中的。
编译型语言在执行机器码之前通常步骤较少。
尽管存在固有的挑战,Node.js 仍成功处理了 89.38% 的请求。
最后的考虑
感谢您花时间阅读这篇博文🙏
Go 语言,一门专注于并发和并行设计的编译型语言,最终胜出并不令人意外。不过,看看最终结果如何,仍然很有趣。
看到 Go 和 Node.js 如何以不同的方式处理任务以及这对计算机资源有何影响,真是太酷了。
我总结了以下要点。
打开文件描述符管理
- Go:得益于其智能网络轮询器和资源管理,Go 演示了一种文件描述符预分配和重用策略。这种方法有助于在高网络负载下实现高效处理和可扩展性。
- Node.js:在文件描述符使用中显示动态的、可能不受管理的模式,反映了其处理服务器连接和逐个打开 FD 的方法。
线程管理和 Node.js
- Go:保持稳定、较低的OS 线程数,突出了其运行时调度程序在优化线程使用方面的效率,尤其是在高压力下🤯。
- Node.js:与普遍看法相反,Node.js 使用多个线程来执行 DNS 查找、垃圾收集器(Hi V8)和阻止异步 I/O 操作等任务,它不仅仅是一个线程。