压力之下:在单核 EC2 上对 Node.js 进行基准测试
你好!
在这篇文章中,我将对Node.js 21.2.0纯API(不使用任何框架!)进行压力测试,以了解事件循环在受限环境中的效率。
我使用 AWS 来托管服务器 (EC2) 和数据库 (RDS,使用 Postgres)。
主要目标是了解一个简单的 Node API 在单个核心上每秒可以处理多少个请求,然后找出瓶颈并尽可能地进行优化。
让我们开始吧!
基础设施
- AWS RDS 运行 Postgres
- EC2 t2.small API
- EC2 t3.micro 用于负载测试仪
数据库设置
数据库将包含一个users表,该表使用以下 SQL 查询创建:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);
TRUNCATE TABLE users;
API设计
该 API 将只有一个 POST 端点,用于将用户保存到 Postgres 数据库。我知道有很多 JavaScript 框架可以简化开发,但也可以使用 Node 来处理请求/响应。
为了连接数据库,我选择了图书馆,pg因为它是最常用的,我们就从它开始吧。
连接池
连接数据库时,使用连接池至关重要。如果没有连接池,API 每次请求都需要打开/关闭数据库连接,效率极其低下。
连接池允许 API 重用连接,由于我们计划向 API 发送大量并发请求,因此拥有连接池至关重要。
要检查 Postgres 数据库的连接数限制,请运行:
SHOW max_connections;
我使用的是运行在 t3.micro 数据库上的 RDS,其配置如下:
太好了,我们的数据库最大连接数为 81,我们知道了不应该超过的上限是多少。
由于 API 将在单核处理器上运行,因此连接池中不宜有大量的连接,因为这会给处理器带来很多麻烦(上下文切换)。
我们从40开始。
创建 API
npm init我们将从创建文件开始我们的项目index.mjs。MJS 让我可以使用 EcmaScript 语法,而无需进行太多的解析/加载操作。
我首先要做的是添加pg库npm add pg。我使用的是 .npm但你也可以使用 pnpm、yarn 或任何其他你喜欢的 node 包管理器。
那么,我们先来创建连接池:
import pg from "pg"; // Required because pg lib uses CommonJS 🤢
const { Pool } = pg;
const pool = new Pool({
host: process.env.POSTGRES_HOST,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
port: 5432,
database: process.env.POSTGRES_DATABASE,
max: 40, // Limit is 81, let's start with 40
idleTimeoutMillis: 0, // How much time before kicking out an idle client.
connectionTimeoutMillis: 0, // How much time to disconnect a new client, we don't want to disconnect them for now.
ssl: false
/* If you're running on AWS, you'll need to use:
ssl: {
rejectUnauthorized: false
}
*/
});
我们使用 process.env 文件来访问环境变量,因此请.env在根目录下创建一个文件,并填写您的 PostgreSQL 信息:
POSTGRES_HOST=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
接下来,我们创建一个函数,将用户持久化到数据库中。
const createUser = async (email, password) => {
const queryText =
"INSERT INTO users(email, password) VALUES($1, $2) RETURNING id";
const { rows } = await pool.query(queryText, [email, password]);
return rows[0].id;
};
最后,让我们通过导入node:http软件包并编写代码来创建一个 Node HTTP 服务器,以处理新请求、将字符串解析为 JSON、查询数据库,并在出现任何错误时返回 201、400 或 500,最终文件如下所示。
// index.mjs
import http from "node:http";
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
host: process.env.POSTGRES_HOST,
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
port: 5432,
database: process.env.POSTGRES_DATABASE,
max: 40,
idleTimeoutMillis: 0,
connectionTimeoutMillis: 2000,
ssl: false
/* If you're running on AWS, you'll need to use:
ssl: {
rejectUnauthorized: false
}
*/
});
const createUser = async (email, password) => {
const queryText =
"INSERT INTO users(email, password) VALUES($1, $2) RETURNING id";
const { rows } = await pool.query(queryText, [email, password]);
return rows[0].id;
};
const getRequestBody = (req) =>
new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => (body += chunk.toString()));
req.on("end", () => resolve(body));
req.on("error", (err) => reject(err));
});
const sendResponse = (res, statusCode, headers, body) => {
headers["Content-Length"] = Buffer.byteLength(body).toString();
res.writeHead(statusCode, headers);
res.end(body);
};
const server = http.createServer(async (req, res) => {
const headers = {
"Content-Type": "application/json",
Connection: "keep-alive", // Default to keep-alive for persistent connections
"Cache-Control": "no-store", // No caching for user creation
};
if (req.method === "POST" && req.url === "/user") {
try {
const body = await getRequestBody(req);
const { email, password } = JSON.parse(body);
const userId = await createUser(email, password);
headers["Location"] = `/user/${userId}`;
const responseBody = JSON.stringify({ message: "User created" });
sendResponse(res, 201, headers, responseBody);
} catch (error) {
headers["Connection"] = "close";
const responseBody = JSON.stringify({ error: error.message });
console.error(error);
const statusCode = error instanceof SyntaxError ? 400 : 500;
sendResponse(res, statusCode, headers, responseBody);
}
} else {
headers["Content-Type"] = "text/plain";
sendResponse(res, 404, headers, "Not Found!");
}
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
现在运行完毕npm install,你可以运行
node --env-file=.env index.mjs
启动应用程序后,您应该会在终端上看到以下内容:
恭喜,我们已经构建了一个简单的 NodeAPI,它只有一个端点,通过连接池连接到 Postgres,并将新用户插入到 users 表中。
将 API 部署到 EC2
首先,创建一个 AWS 账户,然后转到 EC2 > 实例 > 启动实例。
然后,创建一个 Ubuntu 64 位 (x86) t2.micro 实例,允许 SSH 流量和允许来自 Internet 的 HTTP 流量。
您的摘要应如下所示:
你需要创建一个 key-value-pair.pem 文件才能通过 SSH 连接到它,本文不会介绍这部分内容,网上已经有很多教程教你如何启动和连接到 EC2 实例,所以去找找看吧!
允许端口 3000 上的 TCP 连接
创建完成后,我们需要允许端口 3000 的 TCP 流量,这需要在安全组配置中完成(EC2 > 安全组 > 您的安全组)。
在此页面上,点击“编辑入站规则”,然后点击“添加规则”,并按照图片所示填写表单,这将允许我们访问实例的 3000 端口。
最终的入站规则表应该类似于这样。
正在连接到 EC2
将.pem文件下载到文件夹中,然后访问 EC2 实例并复制公网 IPv4 IP 地址,然后在同一文件夹中运行以下命令:
ssh -i <path-to-pen> ubuntu@<public-ipv4-address>
如果你看到这个 EC2 欢迎页面,那就恭喜你成功加入啦🎉
安装 Node
让我们按照Debian/Ubuntu 系统 Linux 发行版的 Node 文档进行操作。
跑步:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
然后:
NODE_MAJOR=21
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
重要提示:请务必确认 NODE_MAJOR 为 21,因为我们需要使用最新版本的 Node <3
sudo apt-get update
sudo apt-get install nodejs -y
node -v
这就是你应该看到的(随着帖子时间推移,版本可能会有所不同)。
很好,现在我们有了一台安装了 Node.js 的全新 Ubuntu 服务器,我们需要将我们的 API 代码迁移到该服务器上并启动它。
将 API 部署到 EC2
我们将使用一个名为scp的工具,它使用 ssh 连接将文件从本地复制到目标位置,在本例中,目标位置是我们刚刚创建的 EC2 实例。
步骤:
- 从项目中删除 node_modules 文件夹。
- 转到应用程序根文件夹的父文件夹。
就我而言,文件夹的名称是node-api(我知道,非常有创意!)
现在,跑!
scp -i <path-to-pem> -r ./node-api ubuntu@<public-ipv4-address>:/home/ubuntu
将文件夹传输node-api到我们 EC2 实例上的 /home/ubuntu/node-api 文件夹。
在 EC2 上运行 API
使用 SSH 返回 EC2 服务器并运行
cd node-api
npm install
NODE_ENV=production node --env-file=.env index.mjs
就这样,API 已经在 AWS 上运行了。
让我们通过向我们的 API 的 IP 地址的 3000 端口发送 POST 请求,并将电子邮件和密码传递给它,来再次确认它是否正常工作。
您可以使用 curl(在另一个终端上)来执行此操作:
curl -X POST -H "Content-Type: application/json" -d {email: user@example.com, password: password} http://<public-ipv4-address>:3000/user
我使用Table Plus连接到 RDS Postgres 数据库,您可以使用任何 Postgres 客户端。
为了确保 API 将数据持久化到数据库,我们运行以下查询:
SELECT COUNT(id) FROM users;
它应该返回 1。
太好了,成功了!
压力测试
现在我们的 API 已经可以正常工作了,我们需要测试它在单个核心上可以处理多少并发请求。
有很多工具可以做到这一点,我将使用贝吉塔。
您可以从本地计算机运行以下步骤,但请记住,您的网络可能会成为瓶颈,因为压力测试需要同时发送大量数据包。
我将使用另一个运行 Ubuntu 的 EC2 实例(更强大的实例,t2x.large)。
配置贝吉塔
请按照文档说明在您的操作系统上安装 Vegeta。
然后,在应用程序的根文件夹下创建一个用于负载测试的新文件夹,如下所示:
node_benchmark/
node-api/
load-tester/
vegeta/
进入 vegeta 文件夹,创建一个名为 start.sh 的脚本,内容如下:
#!/bin/bash
if [[ $# -ne 1 ]]; then
echo 'Wrong arguments, expecting only one (reqs/s)'
exit 1
fi
TARGET_FILE="targets.txt"
DURATION="30s" # Duration of the test, e.g., 60s for 60 seconds
RATE=$1 # Number of requests per second
RESULTS_FILE="results_$RATE.bin"
REPORT_FILE="report_$RATE.txt"
ENDPOINT="http://<ipv4-public-address>:3000/user"
# Check if Vegeta is installed
if ! command -v vegeta &> /dev/null
then
echo "Vegeta could not be found, please install it."
exit 1
fi
# Create target file with unique email and password for each request
echo "Generating target file for Vegeta..."
> "$TARGET_FILE" # Clear the file if it already exists
# Assuming body.json exists and contains the correct JSON structure for the POST request
for i in $(seq 1 $RATE); do
echo "POST $ENDPOINT" >> "$TARGET_FILE"
echo "Content-Type: application/json" >> "$TARGET_FILE"
echo "@body.json" >> "$TARGET_FILE"
echo "" >> "$TARGET_FILE"
done
echo "Starting Vegeta attack for $DURATION at $RATE requests per second..."
# Run the attack and save the results to a binary file
vegeta attack -rate=$RATE -duration=$DURATION -targets="$TARGET_FILE" > "$RESULTS_FILE"
echo "Load test finished, generating reports..."
# Generate a textual report from the binary results file
vegeta report -type=text "$RESULTS_FILE" > "$REPORT_FILE"
echo "Textual report generated: $REPORT_FILE"
# Generate a JSON report for further analysis
JSON_REPORT="report.json"
vegeta report -type=json "$RESULTS_FILE" > "$JSON_REPORT"
echo "JSON report generated: $JSON_REPORT"
cat $REPORT_FILE
重要提示:请将此处替换<ipv4-public-address>为您的 EC2 节点 API 服务器的 IP 地址。
现在,创建一个body.json文件:
{
"email": "A1391FDC-2B51-4D96-ADA4-5EEE649A4A75@example.com",
"password": "password"
}
现在您可以开始对我们的 API 进行负载测试了。
此脚本将执行以下操作:
- 跑30秒
- 使用脚本第一个参数定义的并发请求数/秒来调用 API。
- 生成包含测试信息的文本文件和 .json 文件。
最后,我们需要使start.sh文件可执行,可以通过运行以下命令来实现:
chmod +x start.sh
在运行每个测试之前,我将使用以下查询清除 Postgres 中的 users 表。
TRUNCATE TABLE users;
这将帮助我们了解创建了多少用户!
1.000 请求/秒
好了,让我们进入有趣的部分,看看我们这台单核 1GB 的服务器能否每秒处理 1000 个请求。
跑步
./start.sh 1000
等待程序运行完成,此处将生成以下输出:
让我来给你详细解释一下:
Node API 以每秒 1.000 个请求的速度成功处理了所有请求,并返回了预期的成功状态 201。
平均而言,每个请求的返回时间为 4.254 毫秒,其中 99% 的请求返回时间不到 25.959 毫秒。
| 指标 | 价值 |
|---|---|
| 每秒请求数 | 1000.04 |
| 成功率 | 100% |
| p99 响应时间 | 25.959毫秒 |
| 平均响应时间 | 4.254毫秒 |
| 最慢响应时间 | 131.889 毫秒 |
| 最快响应时间 | 2.126毫秒 |
| 状态码 201 | 30000 |
太棒了,成功了!
让我们再接再厉,将每秒请求数提高一倍。
每秒 2000 次请求
跑步
./start.sh 2000
我们来检查一下输出结果。
太棒了,它每秒可以处理 2000 个请求,并且还能保持 100% 的成功率。
| 指标 | 价值 |
|---|---|
| 每秒请求数 | 2000.07 |
| 成功率 | 100.00% |
| p99 响应时间 | 2.062秒 |
| 平均响应时间 | 136.347 毫秒 |
| 最慢响应时间 | 4.067秒 |
| 最快响应时间 | 2.164毫秒 |
| 状态码 201 | 60000 |
有几点需要注意,虽然成功率仍然是 100%,但 p99 从 25.959 毫秒跃升至 2.067 秒(比之前的测试慢了 79 倍)。
平均响应时间也从 4.254 毫秒跃升至 136.347 毫秒(慢了 32.1 倍)。
是的,每秒请求数翻倍给我们的服务器带来了很大的压力。
我们再努力一点,看看结果如何。
每秒 3000 次请求
./start.sh 3000
当请求量达到每秒 3000 次时,我们的 Node.js API 开始出现问题,只能处理 52.20% 的请求,让我们看看发生了什么。
| 指标 | 价值 |
|---|---|
| 每秒请求数 | 2267.72 |
| 成功率 | 52.20% |
| p99 响应时间 | 30.001秒 |
| 平均响应时间 | 6.146秒 |
| 最慢响应时间 | 30.156秒 |
| 最快响应时间 | 3.018毫秒 |
| 状态码 201 | 36089 |
| 状态码 500 | 21588 |
| 状态码 0 | 11465 |
我们的 API 收到了 21588 个请求,均返回状态码 500,让我们查看 API 日志:
我们可以看到我们的 Postgres 连接超时了,当前的 connectionTimeoutMillis 配置为 2000(2 秒),让我们尝试将其增加到 30000,看看这是否会改善我们的负载测试。
我们可以通过将 index.mjs 文件第 13 行的值从 2000 改为 30000 来实现这一点:
connectionTimeoutMillis: 30000
我们再运行一遍:
./start.sh 3000
结果如何?
| 指标 | 价值 |
|---|---|
| 每秒请求数 | 2959.90 |
| 成功率 | 97.39% |
| p99 响应时间 | 13.375秒 |
| 平均响应时间 | 6.901秒 |
| 最慢响应时间 | 30.001秒 |
| 最快响应时间 | 3.476毫秒 |
| 状态码 201 | 86486 |
| 状态码 0 | 2318 |
太好了,我们只需增加数据库的连接超时时间,成功率就提高了 45.19%,而且所有 500 错误也完全消失了!
状态码 0 通常表示服务器因无法处理更多连接而重置了连接。
我们来检查一下是CPU、内存还是网络的问题。
测试高峰时,CPU 使用率仅为 13%,所以问题不在于 CPU。
我再次运行程序后htop发现内存使用率只有70%左右,所以这也不是问题所在:
我们来尝试一些不同的方法。
文件描述符
在 Unix 系统中,每个新连接(套接字)都会被分配一个文件描述符。默认情况下,在 Ubuntu 系统中,打开的文件描述符的最大数量为 1024。
你可以通过运行来检查ulimit -n。
我们尝试将数值增加到 2000,然后重新进行测试,看看能否消除这 2% 的超时错误。
为此,我将按照本教程操作,并将数值改为 6000。
sudo vi /etc/security/limits.conf
nofile = 文件数量。soft
= 软限制。hard
= 硬限制。
然后使用以下命令重启 EC2 实例sudo reboot now。
登录后,我们可以看到限额发生了变化:
让我们重新进行测试:
使用以下命令启动 API
NODE_ENV=production node --env-file=.env index.mjs
然后运行负载测试:
./start.sh 3000
我们来看看结果:
出乎意料的是,结果更糟!
在最多打开 2000 个文件的情况下,节点 API 仅成功响应了 78.43% 的请求。
这是因为只有一个核心,增加更多开放的插槽会导致处理器比以前的版本更频繁地在文件之间切换。
我们试着把它降到 700,看看情况会不会好转。
(操作方法相同,我就不赘述了)。
让我们看看最大打开文件数设置为 700 时的新输出结果。
在最大打开文件数为 700 的情况下,我们达到了 83.20% 的成功率。让我们把最大打开文件数改回 1024,并将连接池从 40 减少到 20。
如果这行不通,我们假设 3.000 req/s 略高于限制,我们将尝试找到单个核心节点 API 可以 100% 成功处理的最大请求数/秒。
连接池有 20 个连接,API 能够处理 93.06% 的数据,这证明我们可能不需要 40 个连接。
我们试试 2.600 reqs/s:
2.600 请求/秒
./start.sh 2600
| 指标 | 价值 |
|---|---|
| 每秒请求数 | 2600.04 |
| 成功率 | 100% |
| p99 响应时间 | 8.171秒 |
| 平均响应时间 | 4.573秒 |
| 最慢响应时间 | 9.234秒 |
| 最快响应时间 | 5.244毫秒 |
| 状态码 201 | 77999 |
拍摄结束!
结论
该实验展示了纯Node.js API在单核服务器上的功能。
使用纯 Node.js 21.2.0 API,在单核处理器上配备 1GB 内存,连接池最大支持 20 个连接,我们实现了 2.600 个请求/秒,没有出现任何故障。
通过微调连接池大小和文件描述符限制等参数,我们可以显著影响性能。
你的Node.js服务器处理过的最高负载是多少?分享一下你的经验吧!
文章来源:https://dev.to/ocodista/under-Pressure-benchmarking-nodejs-on-a-single-core-ec2-5ghe




























