发布于 2026-01-06 0 阅读
0

压力之下:在单核 EC2 上对 Node.js 进行基准测试

压力之下:在单核 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;
Enter fullscreen mode Exit fullscreen mode

API设计

该 API 将只有一个 POST 端点,用于将用户保存到 Postgres 数据库。我知道有很多 JavaScript 框架可以简化开发,但也可以使用 Node 来处理请求/响应。

为了连接数据库,我选择了图书馆,pg因为它是最常用的,我们就从它开始吧。

连接池

连接数据库时,使用连接池至关重要。如果没有连接池,API 每次请求都需要打开/关闭数据库连接,效率极其低下。

连接池允许 API 重用连接,由于我们计划向 API 发送大量并发请求,因此拥有连接池至关重要。

要检查 Postgres 数据库的连接数限制,请运行:

SHOW max_connections;
Enter fullscreen mode Exit fullscreen mode

我使用的是运行在 t3.micro 数据库上的 RDS,其配置如下:

AWS RDS 配置

这是查询结果:
最大连接数

太好了,我们的数据库最大连接数为 81,我们知道了不应该超过的上限是多少。

由于 API 将在单核处理器上运行,因此连接池中不宜有大量的连接,因为这会给处理器带来很多麻烦(上下文切换)。

我们从40开始。

创建 API

npm init我们将从创建文件开始我们的项目index.mjs。MJS 让我可以使用 EcmaScript 语法,而无需进行太多的解析/加载操作。

我首先要做的是添加pgnpm 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
  }
  */
});
Enter fullscreen mode Exit fullscreen mode

我们使用 process.env 文件来访问环境变量,因此请.env在根目录下创建一个文件,并填写您的 PostgreSQL 信息:

POSTGRES_HOST=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
Enter fullscreen mode Exit fullscreen mode

接下来,我们创建一个函数,将用户持久化到数据库中。

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;
};
Enter fullscreen mode Exit fullscreen mode

最后,让我们通过导入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}`);
});
Enter fullscreen mode Exit fullscreen mode

现在运行完毕npm install,你可以运行

node --env-file=.env index.mjs
Enter fullscreen mode Exit fullscreen mode

启动应用程序后,您应该会在终端上看到以下内容:

服务器正在运行

恭喜,我们已经构建了一个简单的 NodeAPI,它只有一个端点,通过连接池连接到 Postgres,并将新用户插入到 users 表中。

将 API 部署到 EC2

首先,创建一个 AWS 账户,然后转到 EC2 > 实例 > 启动实例。

然后,创建一个 Ubuntu 64 位 (x86) t2.micro 实例,允许 SSH 流量和允许来自 Internet 的 HTTP 流量。

您的摘要应如下所示:

AWS EC2 t2.micro 摘要

你需要创建一个 key-value-pair.pem 文件才能通过 SSH 连接到它,本文不会介绍这部分内容,网上已经有很多教程教你如何启动和连接到 EC2 实例,所以去找找看吧!

允许端口 3000 上的 TCP 连接

创建完成后,我们需要允许端口 3000 的 TCP 流量,这需要在安全组配置中完成(EC2 > 安全组 > 您的安全组)。

安全组页面

在此页面上,点击“编辑入站规则”,然后点击“添加规则”,并按照图片所示填写表单,这将允许我们访问实例的 3000 端口。

入站规则

最终的入站规则表应该类似于这样。

入站规则

正在连接到 EC2

将.pem文件下载到文件夹中,然后访问 EC2 实例并复制公网 IPv4 IP 地址,然后在同一文件夹中运行以下命令:

公共 IPv4 地址

ssh -i <path-to-pen> ubuntu@<public-ipv4-address>
Enter fullscreen mode Exit fullscreen mode

如果你看到这个 EC2 欢迎页面,那就恭喜你成功加入啦🎉

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
Enter fullscreen mode Exit fullscreen mode

然后:

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
Enter fullscreen mode Exit fullscreen mode

重要提示:请务必确认 NODE_MAJOR 为 21,因为我们需要使用最新版本的 Node <3

sudo apt-get update
sudo apt-get install nodejs -y
node -v
Enter fullscreen mode Exit fullscreen mode

这就是你应该看到的(随着帖子时间推移,版本可能会有所不同)。

已安装的节点

很好,现在我们有了一台安装了 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
Enter fullscreen mode Exit fullscreen mode

将文件夹传输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
Enter fullscreen mode Exit fullscreen mode

就这样,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
Enter fullscreen mode Exit fullscreen mode

结果应该如下所示:
用户创建

我使用Table Plus连接到 RDS Postgres 数据库,您可以使用任何 Postgres 客户端。

为了确保 API 将数据持久化到数据库,我们运行以下查询:

 SELECT COUNT(id) FROM users;
Enter fullscreen mode Exit fullscreen mode

返回

它应该返回 1。

太好了,成功了!

压力测试

现在我们的 API 已经可以正常工作了,我们需要测试它在单个核心上可以处理多少并发请求。

有很多工具可以做到这一点,我将使用贝吉塔。

您可以从本地计算机运行以下步骤,但请记住,您的网络可能会成为瓶颈,因为压力测试需要同时发送大量数据包。

我将使用另一个运行 Ubuntu 的 EC2 实例(更强大的实例,t2x.large)。

配置贝吉塔

请按照文档说明在您的操作系统上安装 Vegeta。

然后,在应用程序的根文件夹下创建一个用于负载测试的新文件夹,如下所示:

node_benchmark/
  node-api/
  load-tester/
    vegeta/
Enter fullscreen mode Exit fullscreen mode

进入 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
Enter fullscreen mode Exit fullscreen mode

重要提示:请将此处替换<ipv4-public-address>为您的 EC2 节点 API 服务器的 IP 地址。

现在,创建一个body.json文件:

{
  "email": "A1391FDC-2B51-4D96-ADA4-5EEE649A4A75@example.com",
  "password": "password"
}
Enter fullscreen mode Exit fullscreen mode

现在您可以开始对我们的 API 进行负载测试了。

此脚本将执行以下操作:

  • 跑30秒
  • 使用脚本第一个参数定义的并发请求数/秒来调用 API。
  • 生成包含测试信息的文本文件和 .json 文件。

最后,我们需要使start.sh文件可执行,可以通过运行以下命令来实现:

chmod +x start.sh
Enter fullscreen mode Exit fullscreen mode

在运行每个测试之前,我将使用以下查询清除 Postgres 中的 users 表。

TRUNCATE TABLE users;
Enter fullscreen mode Exit fullscreen mode

这将帮助我们了解创建了多少用户!

1.000 请求/秒

好了,让我们进入有趣的部分,看看我们这台单核 1GB 的服务器能否每秒处理 1000 个请求。

跑步

./start.sh 1000
Enter fullscreen mode Exit fullscreen mode

等待程序运行完成,此处将生成以下输出:

1.000 请求/秒

让我来给你详细解释一下:

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
Enter fullscreen mode Exit fullscreen mode

我们来检查一下输出结果。

2.000 请求/秒

太棒了,它每秒可以处理 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
Enter fullscreen mode Exit fullscreen mode

3.000 请求/秒 输出

当请求量达到每秒 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 日志:

API日志

我们可以看到我们的 Postgres 连接超时了,当前的 connectionTimeoutMillis 配置为 2000(2 秒),让我们尝试将其增加到 30000,看看这是否会改善我们的负载测试。

我们可以通过将 index.mjs 文件第 13 行的值从 2000 改为 30000 来实现这一点:

connectionTimeoutMillis: 30000
Enter fullscreen mode Exit fullscreen mode

我们再运行一​​遍:

./start.sh 3000
Enter fullscreen mode Exit fullscreen mode

结果如何?

成功率97.39%

指标 价值
每秒请求数 2959.90
成功率 97.39%
p99 响应时间 13.375秒
平均响应时间 6.901秒
最慢响应时间 30.001秒
最快响应时间 3.476毫秒
状态码 201 86486
状态码 0 2318

太好了,我们只需增加数据库的连接超时时间,成功率就提高了 45.19%,而且所有 500 错误也完全消失了!

让我们来看看剩下的错误(状态码 0)。
绑定地址已被使用

状态码 0 通常表示服务器因无法处理更多连接而重置了连接。

我们来检查一下是CPU、内存还是网络的问题。

测试高峰时,CPU 使用率仅为 13%,所以问题不在于 CPU。

中央处理器

我再次运行程序后htop发现内存使用率只有70%左右,所以这也不是问题所在:

记忆

我们来尝试一些不同的方法。

文件描述符

在 Unix 系统中,每个新连接(套接字)都会被分配一个文件描述符。默认情况下,在 Ubuntu 系统中,打开的文件描述符的最大数量为 1024。

你可以通过运行来检查ulimit -n

限制 API

我们尝试将数值增加到 2000,然后重新进行测试,看看能否消除这 2% 的超时错误。

为此,我将按照本教程操作,并将数值改为 6000。

sudo vi /etc/security/limits.conf
Enter fullscreen mode Exit fullscreen mode

nofile 的新限制

nofile = 文件数量。soft
= 软限制。hard
= 硬限制。

然后使用以下命令重启 EC2 实例sudo reboot now

登录后,我们可以看到限额发生了变化:

新的 ulimit 为 2000

让我们重新进行测试:

使用以下命令启动 API

NODE_ENV=production node --env-file=.env index.mjs
Enter fullscreen mode Exit fullscreen mode

然后运行负载测试:

./start.sh 3000
Enter fullscreen mode Exit fullscreen mode

我们来看看结果:

打开 2000 个文件后的结果

出乎意料的是,结果更糟!

在最多打开 2000 个文件的情况下,节点 API 仅成功响应了 78.43% 的请求。

这是因为只有一个核心,增加更多开放的插槽会导致处理器比以前的版本更频繁地在文件之间切换。

我们试着把它降到 700,看看情况会不会好转。

(操作方法相同,我就不赘述了)。

让我们看看最大打开文件数设置为 700 时的新输出结果。

700

在最大打开文件数为 700 的情况下,我们达到了 83.20% 的成功率。让我们把最大打开文件数改回 1024,并将连接池从 40 减少到 20。

如果这行不通,我们假设 3.000 req/s 略高于限制,我们将尝试找到单个核心节点 API 可以 100% 成功处理的最大请求数/秒。

93%

连接池有 20 个连接,API 能够处理 93.06% 的数据,这证明我们可能不需要 40 个连接。

我们试试 2.600 reqs/s:

2.600 请求/秒

./start.sh 2600
Enter fullscreen mode Exit fullscreen mode

这就是结果:
2600 100% 成功

指标 价值
每秒请求数 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