Node是并发性能的绊脚石吗?测试Express服务器的基准并发能力
✨AI全文摘要
作者在Node.js请求转发服务中发现高并发时出现性能瓶颈,6%请求出错且CPU单核满载。通过压力测试发现Express代理服务在2000并发时错误率上升,主因Node.js单线程模型无法利用多核资源。对比测试显示Go语言利用多核优势,错误率显著低于Node.js。解决方案建议优化代码逻辑、减少日志输出或采用多进程负载均衡。实验表明Node.js纯网络IO场景下单核性能受限,生产环境需考虑横向扩展或改用多线程语言。
由Azure AI部署的deepseek-r1模型推理
问题发现
最近在一个项目中,我遇到了一个使用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
是代理服务。
Server | Connections | Requests/sec | Avg Latency | Max Latency | Total Requests | Timeouts | Timeout % | Total Errors | Error % |
---|---|---|---|---|---|---|---|---|---|
5001 | 50 | 9776.19 | 6.03ms | 291.00ms | 97839 | 0 | 0.00% | 0 | 0.00% |
5001 | 100 | 9294.12 | 12.84ms | 537.33ms | 93006 | 0 | 0.00% | 0 | 0.00% |
5001 | 150 | 9322.45 | 31.57ms | 1.31s | 93276 | 0 | 0.00% | 0 | 0.00% |
5001 | 200 | 8688.89 | 63.29ms | 2.14s | 86951 | 0 | 0.00% | 0 | 0.00% |
5001 | 500 | 8769.33 | 163.54ms | 4.99s | 87741 | 121 | 0.14% | 121 | 0.14% |
5001 | 1000 | 8200.58 | 98.60ms | 4.96s | 82284 | 67 | 0.08% | 67 | 0.08% |
5001 | 2000 | 8808.86 | 102.43ms | 4.95s | 88480 | 54 | 0.06% | 54 | 0.06% |
5001 | 5000 | 7769.21 | 248.05ms | 314.09ms | 78333 | 25 | 0.03% | 1134 | 1.45% |
5001 | 10000 | 7531.77 | 453.61ms | 592.39ms | 76076 | 13 | 0.02% | 6176 | 8.12% |
5001 | 20000 | 6601.98 | 414.19ms | 582.31ms | 66423 | 15 | 0.02% | 15917 | 23.96% |
5000 | 50 | 2673.49 | 74.76ms | 2.34s | 26763 | 0 | 0.00% | 0 | 0.00% |
5000 | 100 | 2884.08 | 40.05ms | 909.96ms | 28860 | 0 | 0.00% | 0 | 0.00% |
5000 | 150 | 2729.84 | 94.20ms | 2.16s | 27322 | 0 | 0.00% | 0 | 0.00% |
5000 | 200 | 2586.36 | 176.31ms | 3.61s | 25887 | 0 | 0.00% | 0 | 0.00% |
5000 | 500 | 2375.25 | 192.97ms | 4.96s | 23767 | 70 | 0.29% | 70 | 0.29% |
5000 | 1000 | 2454.10 | 139.51ms | 5.00s | 24629 | 90 | 0.37% | 90 | 0.37% |
5000 | 2000 | 2449.30 | 326.00ms | 4.85s | 24597 | 33 | 0.13% | 33 | 0.13% |
5000 | 5000 | 1786.15 | 326.11ms | 5.00s | 18038 | 476 | 2.64% | 1768 | 9.80% |
5000 | 10000 | 2016.64 | 78.55ms | 3.78s | 20313 | 10 | 0.05% | 6306 | 31.04% |
5000 | 20000 | 1398.34 | 3.40ms | 639.14ms | 14072 | 0 | 0.00% | 16275 | 115.66% |
数据比较多,值得关注的结论如下:
对于后端服务:
- 200-500连接数开始已经出现了5s内无法完成的超时请求
- 2000-5000连接数开始出现并非超时的错误,说明此时node本身已经无法接受更多请求
- 连接数打到5000后,错误率开始指数上升
对于代理服务:
- 几乎所有指标都大幅差于后端服务,在50连接时请求数就已经只有后端服务的1/3
- 2000连接数开始错误率即开始指数上升
- 把平均延迟和后台服务的平均延迟作差,可以发现代理本身逻辑执行在1.5-2s附近
CPU使用
在所有实验中,我还记录了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 %)这三个关键指标:
连接数 | 服务器 | 直接返回-延迟 | 延迟-延迟 | 输出日志-延迟 | 直接返回-超时率 | 延迟-超时率 | 输出日志-超时率 | 直接返回-错误率 | 延迟-错误率 | 输出日志-错误率 |
---|---|---|---|---|---|---|---|---|---|---|
50 | 后端 | 6.03ms | 503.92ms | 9.17ms | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% |
500 | 后端 | 163.54ms | 506.11ms | 132.43ms | 0.14% | 0.00% | 0.13% | 0.14% | 0.00% | 0.13% |
2000 | 后端 | 102.43ms | 533.43ms | 105.53ms | 0.06% | 0.00% | 0.08% | 0.06% | 0.00% | 0.15% |
5000 | 后端 | 248.05ms | 529.92ms | 358.23ms | 0.03% | 0.00% | 0.05% | 1.45% | 7.73% | 2.23% |
10000 | 后端 | 453.61ms | 607.15ms | 641.06ms | 0.02% | 0.00% | 0.00% | 8.12% | 9.89% | 12.64% |
20000 | 后端 | 414.19ms | 610.10ms | 375.80ms | 0.02% | 0.00% | 0.00% | 23.96% | 30.43% | 35.20% |
50 | 代理 | 74.76ms | 519.05ms | 54.27ms | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% | 0.00% |
500 | 代理 | 192.97ms | 717.28ms | 208.93ms | 0.29% | 0.00% | 0.29% | 0.29% | 0.00% | 0.29% |
2000 | 代理 | 326.00ms | 726.51ms | 417.52ms | 0.13% | 2.03% | 0.21% | 0.13% | 2.03% | 0.21% |
5000 | 代理 | 326.11ms | 892.77ms | 434.87ms | 2.64% | 1.35% | 2.87% | 9.80% | 13.92% | 10.33% |
10000 | 代理 | 78.55ms | 788.52ms | 89.68ms | 0.05% | 2.90% | 0.01% | 31.04% | 36.76% | 36.39% |
20000 | 代理 | 3.40ms | 777.70ms | 94.90ms | 0.00% | 0.00% | 0.00% | 115.66% | 232.21% | 107.78% |
从这个对比表格中,我们可以得出几个重要观察:
-
延迟500ms的影响:当后端服务增加500ms延迟后,整体延迟有增加,连接数越多,平均延迟增加越少,但是增加的延迟仍然会使得错误率增加
-
超时情况分析:在大多数连接数下,超时率都相对较低;但在代理服务的中等连接数(2000-5000)场景下,超时率明显上升,特别是在延迟返回的测试中,显示出代理服务在处理较长延迟请求时的瓶颈
-
日志输出的影响:与直接返回相比,增加日志输出确实会增加延迟,但影响不是特别显著。在低并发情况下(50-500连接),日志对后端服务的影响较小;但在高并发时(10000+连接),日志输出会明显增加系统负担
-
错误率增长点:无论哪种场景,代理服务的错误率普遍高于后端服务,且在5000连接数左右开始出现明显的错误率上升
-
极端高并发下的异常:在20000连接的极端情况下,所有配置都表现出较高的错误率,但代理服务的延迟反而下降,这可能是因为大量请求被直接拒绝,导致成功请求的平均延迟降低
Go
为了对比,我又准备了用Go使用标准库net/http
编写的相同功能的两个程序,并在后端500ms延迟、不打log的情况下,测出go的成绩如下(未列出的连接数并未发生错误):
Server | Connections | Requests/sec | Avg Latency | Max Latency | Total Requests | Timeouts | Timeout % | Total Errors | Error % |
---|---|---|---|---|---|---|---|---|---|
5001 | 5000 | 7310.92 | 501.39ms | 516.91ms | 73728 | 0 | 0.00% | 902 | 1.22% |
5001 | 10000 | 7060.75 | 501.72ms | 517.20ms | 71154 | 0 | 0.00% | 5900 | 8.29% |
5001 | 20000 | 6493.01 | 501.33ms | 512.36ms | 65536 | 0 | 0.00% | 15902 | 24.26% |
5000 | 5000 | 5910.59 | 636.54ms | 3.46s | 59611 | 0 | 0.00% | 903 | 1.51% |
5000 | 10000 | 5441.54 | 657.59ms | 1.25s | 54953 | 0 | 0.00% | 5900 | 10.74% |
5000 | 20000 | 4857.47 | 663.12ms | 2.11s | 48912 | 0 | 0.00% | 15902 | 32.51% |
二者的差距还是在预期的,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 中可用。
Acknowledgements
整个测试项目、测试脚本甚至本篇文章基本都是直接使用Copilot + Claude 3.7 Sonnet模型生成的,不得不说,让AI生成大框架、自己再来完善细节的做法确实能提高不少效率。这种实验的大多数工作实际上是框架代码,代码本身逻辑简单,但是需要使用大量API、编写繁琐的测试逻辑、数据分析以及做表,手写非常耗费精力,让AI来做这些繁琐的工作实在是再合适不过了。写文章也可以让AI帮忙做,我之前写篇文章至少需要花一整天,这一次居然一个上午就搞定了。
哦,上面这一句话不是AI写的😀