Node是并发性能的绊脚石吗?测试Express服务器的基准并发能力 - ddadaal.me
最近在一个项目中,我遇到了一个使用Node.js编写的请求转发服务的性能瓶颈问题。这个服务的主要工作看似非常简单:获取用户的请求,将请求体(body)转发到后端的服务器,然后将服务器的响应原样返回给客户端。
然而,在进行压力测试时,我们发现当并发请求达到约2000时,系统表现出了明显的性能问题:
- 大约6%的请求出现错误
- 服务器资源利用率极不平衡 - 只有一个CPU核心达到了100%利用率,而其他核心几乎处于闲置状态
大家都说Node.js的IO性能并不算差。这个现象引发了我的思考: 是什么限制了Node.js在这种场景下的性能表现? 一个看似简单的请求转发工作,为何无法充分利用多核资源?
带着这些疑问,我决定对Node.js代理服务的基准性能进行一次深入研究。我想了解在没有任何特殊优化的情况下,一个标准的Express服务究竟能够处理多少并发请求。这将帮助我确定问题是否出在Node.js本身的并发处理能力上,并在之后遇到类似性能问题或技术选项的时候,对Node本身所能达到的极限能力有个心理预期。
为了进行这项研究,我采取了以下步骤:
-
编写两个简单的项目:
- 一个模拟AI后端服务的Express服务器
- 一个标准的Express代理服务,负责转发请求到模拟的AI后端
-
使用wrk作为性能测试工具,这是一个常用的HTTP基准测试工具,能够产生大量并发连接来测试服务器性能
-
在相同硬件条件下,测试不同并发级别下的性能表现,包括:
- 请求成功率、超时率
- 响应时间
- 各个核心的CPU利用率
这个项目是一个简单的Express服务器,它接收POST请求,并返回一个模拟的AI响应。
为了模拟真实响应,这个服务器返回结果前可能会延迟一段时间。我同样会测试延迟不同的时间会对代理服务的性能表现的影响。
代理服务同样使用Express实现,它的核心功能是:
- 接收来自客户端的请求
- 提取请求中的payload
- 将payload转发到后端AI服务
- 等待后端响应
- 将后端响应传回给客户端
这个服务保持了最小化的实现,没有添加额外的错误处理、负载均衡或缓存等优化措施,以便我能够测试Node.js的基准性能。
测试运行于WSL2,CPU为5900X 12C24T @ 4.5 Ghz,Node版本22.14.0。
使用6个线程和不同的连接数,超时时间设置为5s,使用wrk对两个服务分别进行压力测试。其中,运行在5001端口的是模拟后端服务,5000是代理服务。
数据比较多,值得关注的结论如下:
对于后端服务:
- 200-500连接数开始已经出现了5s内无法完成的超时请求
- 2000-5000连接数开始出现并非超时的错误,说明此时node本身已经无法接受更多请求
- 连接数打到5000后,错误率开始指数上升
对于代理服务:
- 几乎所有指标都大幅差于后端服务,在50连接时请求数就已经只有后端服务的1/3
- 2000连接数开始错误率即开始指数上升
- 把平均延迟和后台服务的平均延迟作差,可以发现代理本身逻辑执行在1.5-2s附近
在所有实验中,我还记录了CPU各个核心的使用率,下面是测试后端、2000个连接数时其中一秒的CPU使用率,可以看到,只有一个核心(2)很忙,其他核心没有被充分利用。其他所有数据都具有类似的情况。
10:51:38 AM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
10:51:39 AM all 5.93 0.00 1.52 0.00 0.00 4.97 0.00 0.00 0.00 87.58
10:51:39 AM 0 1.75 0.00 0.88 0.00 0.00 11.40 0.00 0.00 0.00 85.96
10:51:39 AM 1 0.00 0.00 1.00 0.00 0.00 1.00 0.00 0.00 0.00 98.00
10:51:39 AM 2 84.00 0.00 6.00 0.00 0.00 0.00 0.00 0.00 0.00 10.00
10:51:39 AM 3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 4 11.00 0.00 3.00 0.00 0.00 0.00 0.00 0.00 0.00 86.00
10:51:39 AM 5 18.18 0.00 3.03 0.00 0.00 0.00 0.00 0.00 0.00 78.79
10:51:39 AM 6 1.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 7 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 8 4.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 95.00
10:51:39 AM 9 2.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 10 2.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 98.00
10:51:39 AM 11 0.00 0.00 1.98 0.00 0.00 0.00 0.00 0.00 0.00 98.02
10:51:39 AM 12 3.96 0.00 4.95 0.00 0.00 0.00 0.00 0.00 0.00 91.09
10:51:39 AM 13 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 14 4.50 0.00 3.60 0.00 0.00 10.81 0.00 0.00 0.00 81.08
10:51:39 AM 15 0.00 0.00 0.00 0.00 0.00 2.04 0.00 0.00 0.00 97.96
10:51:39 AM 16 2.65 0.00 0.88 0.00 0.00 15.93 0.00 0.00 0.00 80.53
10:51:39 AM 17 0.92 0.00 1.83 0.00 0.00 12.84 0.00 0.00 0.00 84.40
10:51:39 AM 18 2.44 0.00 4.07 0.00 0.00 19.51 0.00 0.00 0.00 73.98
10:51:39 AM 19 0.00 0.00 2.75 0.00 0.00 13.76 0.00 0.00 0.00 83.49
10:51:39 AM 20 4.63 0.00 0.00 0.00 0.00 11.11 0.00 0.00 0.00 84.26
10:51:39 AM 21 2.73 0.00 0.00 0.00 0.00 11.82 0.00 0.00 0.00 85.45
10:51:39 AM 22 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
10:51:39 AM 23 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
在上述实验中,后端服务收到请求就直接返回。但是实际上后端服务可能需要一定时间处理。打印日志也是Node服务常见的实践。这两个场景到底对资源消耗有多大呢?
为了更直观地对比不同场景下的性能表现,多设计两个场景
- 后端等到500ms后才返回结果
- 后端、代理端每收到请求和响应就将请求和响应的URL打印出来(
console.log)
我整理了以下对比表格,重点关注平均延迟(Avg Latency)、超时率(Timeout %)和错误率(Error %)这三个关键指标:
从这个对比表格中,我们可以得出几个重要观察:
-
延迟500ms的影响:当后端服务增加500ms延迟后,整体延迟有增加,连接数越多,平均延迟增加越少,但是增加的延迟仍然会使得错误率增加
-
超时情况分析:在大多数连接数下,超时率都相对较低;但在代理服务的中等连接数(2000-5000)场景下,超时率明显上升,特别是在延迟返回的测试中,显示出代理服务在处理较长延迟请求时的瓶颈
-
日志输出的影响:与直接返回相比,增加日志输出确实会增加延迟,但影响不是特别显著。在低并发情况下(50-500连接),日志对后端服务的影响较小;但在高并发时(10000+连接),日志输出会明显增加系统负担
-
错误率增长点:无论哪种场景,代理服务的错误率普遍高于后端服务,且在5000连接数左右开始出现明显的错误率上升
-
极端高并发下的异常:在20000连接的极端情况下,所有配置都表现出较高的错误率,但代理服务的延迟反而下降,这可能是因为大量请求被直接拒绝,导致成功请求的平均延迟降低
为了对比,我又准备了用Go使用标准库net/http编写的相同功能的两个程序,并在后端500ms延迟、不打log的情况下,测出go的成绩如下(未列出的连接数并未发生错误):
二者的差距还是在预期的,go从来没有发生过超时,整体延迟、错误率数据也比node好很多。如在5000个连接下,代理程序出现1.51%的错误,node版本出现9.08%的错误,差距在6倍左右。但是需要注意的是,go可以利用全部CPU核心,而node的js线程只能利用一个核心,如果启动多个node并进行负载均衡,最终结果不一定有很大的差别。
通过这些简单数据,我们发现
- Node本身的单线程模型无法利用所有CPU的能力,即使在纯网络IO任务中也可能成为性能瓶颈
- 在这次实验中,express在2000连接数是个门槛,在此之上,超时率、错误率会迅速增加
- 并且,我的测试机器是消费级CPU,而一般服务器CPU并不能达到如此的单核性能,所以在生产环境中的性能表现会更差
要想解决这个问题,在代码中做出一定的优化,例如减少日志打印、简化Node中的逻辑等,也会有一定的效果。如果优化代码的效果不佳,唯一的办法是运行多个node进程,可以考虑的方法主要是启动多个node服务并增加负载均衡,或者使用node cluster让node可以启动多个worker process。
本实验中的代码和结果均在 https://github.com/ddadaal/node-express-concurrency-baseline-test 中可用。
整个测试项目、测试脚本甚至本篇文章基本都是直接使用Copilot + Claude 3.7 Sonnet模型生成的,不得不说,让AI生成大框架、自己再来完善细节的做法确实能提高不少效率。这种实验的大多数工作实际上是框架代码,代码本身逻辑简单,但是需要使用大量API、编写繁琐的测试逻辑、数据分析以及做表,手写非常耗费精力,让AI来做这些繁琐的工作实在是再合适不过了。写文章也可以让AI帮忙做,我之前写篇文章至少需要花一整天,这一次居然一个上午就搞定了。
哦,上面这一句话不是AI写的😀