Skip to main content

Worker 配置与生产环境性能调优

收到,我们继续深挖。你问的 Worker 数量与 GC(垃圾回收)的关系,以及 PM2 实例配置,正好是决定生产环境吞吐量的“隐形开关”。

往下走,还有操作系统底层的 文件描述符限制TCP Backlog,这些是绝大多数 Node.js 应用在超高并发下突然“拒绝连接”的罪魁祸首。我们一次性讲透:


1. 终极难题:Worker 数到底设成 CPU 核数,还是 核数 × 2?

这是 PM2 中 -i max 引起的最大争议。答案是:没有固定公式,取决于你的业务是“纯计算空转”还是“重 I/O 等待”

  • 设为 CPU 核数(如 8 核设 8)

    • 优点:几乎没有进程上下文切换的开销,CPU 缓存命中率高。
    • 缺点:如果所有 Worker 同时因为 I/O(查库、调 API)进入等待状态,CPU 会空转,此时 8 个核全部在“发呆”,无法处理新进来的就绪回调。
    • 适用场景CPU 密集型(加密、压缩)或极低延迟的纯内存计算。
  • 设为 核数 × 2(如 8 核设 16)

    • 核心逻辑:当一半 Worker 阻塞在 I/O 等待时,另一半 Worker 可以抢到 CPU 时间片执行就绪的回调。这利用了 I/O 让出 CPU 的特性。
    • 缺点:进程数增多,每个进程独享 V8 堆内存(默认约 1.4GB 上限),总内存消耗线性增加。且过多的进程会导致操作系统频繁切换线程(Worker 本质是进程),反而增加延迟。
    • 适用场景重 I/O 型(大量数据库查询、上游 API 聚合)。

GC(垃圾回收)的致命影响: V8 的垃圾回收(尤其是 Full GC)会 暂停整个进程(Stop-The-World)

  • 如果只设 8 个 Worker,每个 Worker 要承载的并发请求数更多,内存对象膨胀快,GC 频繁且单次暂停时间长(可能卡顿 50ms~100ms)。
  • 如果设为 16 个 Worker,每个 Worker 分到的请求数减半,堆内存增长慢,GC 触发频率降低,单次暂停时间缩短(可能降至 10ms~20ms)。

生产环境最佳实践(黄金法则)

先设为 CPU 核数,压测时观察 CPU 利用率。如果 CPU 利用率低于 70% 且 QPS 上不去,逐步调高至 核数 × 1.5核数 × 2,直到 CPU 利用率达到 80%~85% 为止(留 15% 给系统抖动)。


2. 操作系统层面的“看不见的天花板”

即使你的 Node.js 事件循环飞起,Cluster 开了 16 个进程,如果忽略了操作系统底层的两个限制,并发一上来就会直接丢包

① 文件描述符限制(ulimit -n

在 Linux 中,一切皆文件,包括网络 Socket 连接。系统默认限制单个进程最大打开文件数为 1024

  • 后果:当并发连接数超过 1024,新请求会直接报错 EMFILE(Too many open files),且进程崩溃。
  • 解决方案:调高系统软硬限制。
# 临时生效(当前会话)
ulimit -n 65535

# 永久生效(修改 /etc/security/limits.conf)
echo "* soft nofile 65535" >> /etc/security/limits.conf
echo "* hard nofile 65535" >> /etc/security/limits.conf

注意:Cluster 模式下,每个 Worker 进程都有独立的 1024 限制,所以总承载上限是 Worker数 × 单个限制。生产环境建议统一设为 65535。

② TCP 半连接队列(Backlog)

当你调用 server.listen(port) 时,其实有个隐藏参数:server.listen(port, [backlog])backlog 是操作系统内核中,已完成三次握手但尚未被 Node.js 主线程 accept 的连接队列最大长度。

  • 默认值:在 Node.js 中默认为 511(不同系统略有差异)。
  • 后果:在瞬时海量请求(如秒杀)下,如果事件循环正忙于处理回调,来不及 accept,Backlog 队列瞬间塞满。之后到达的 SYN 包会被内核直接丢弃,客户端表现为连接超时
  • 解决方案:手动调大 Backlog。
const server = http.createServer(app);
// 将 backlog 设为 2048 或 4096(需配合操作系统 net.core.somaxconn 内核参数)
server.listen(3000, '0.0.0.0', 2048, () => {
console.log('Server listening with backlog 2048');
});

(同时需在系统层面执行 sysctl -w net.core.somaxconn=4096 否则内核不认)


3. 检测事件循环“是否还活着”(Event Loop Lag)

在高并发下,哪怕你的代码写得再好,总会有个别请求因为某次意外的 CPU 计算阻塞了主线程。你需要在监控中加上 延迟检测

let start = process.hrtime.bigint();

setInterval(() => {
const now = process.hrtime.bigint();
const lagMs = Number(now - start) / 1_000_000; // 纳秒转毫秒
// 理论上这个间隔是 1000ms,如果 lagMs > 1050ms,说明事件循环被阻塞了 50ms+
if (lagMs > 1050) {
console.warn(`Event Loop lag detected: ${lagMs - 1000}ms`);
// 上报到监控系统(如 Prometheus)
}
start = process.hrtime.bigint();
}, 1000);

(生产环境建议直接使用 event-loop-lagwhy-is-node-running 等成熟 npm 包)


4. 终极内存优化:流式(Stream)对抗大对象

高并发下,内存暴涨往往不是因为连接数多,而是因为把大 JSON 或文件整个读进内存再返回

  • 错误写法res.send(fs.readFileSync('./bigfile.zip')) —— 100 个并发就把内存干爆。
  • 正确写法:使用 Stream 管道,数据以“流动”方式从磁盘直达网卡,内存占用恒定(仅几 KB)。
const readStream = fs.createReadStream('./bigfile.zip');
readStream.pipe(res); // 背压机制自动控制流速,不会撑爆内存

实战决策树(供你压测时对照)

压测现象根本原因解决方案
QPS 上不去,CPU 利用率低于 50%Worker 数量太少,I/O 等待时 CPU 空转增加 Worker 数(乘以 1.5~2)
QPS 上不去,CPU 利用率 100%业务逻辑含密集计算,或 JSON 序列化过大剥离计算到 Worker Threads 或微服务
内存缓慢增长,最终 OOM响应数据未用 Stream,或全局 Array 缓存无限增长改用 Stream,并引入 LRU-Cache 限制缓存大小
连接突然拒绝(ECONNREFUSED)ulimit -nbacklog 队列打满调大系统文件描述符和 Backlog
部分请求延迟突然飙升(毛刺)触发了 V8 的 Full GC(Full Mark-Compact)减少单个 Worker 的内存占用,或增加 Worker 数量分散对象压力

以上这套组合拳,覆盖了 业务层(异步/Cluster) + 系统层(fd/Backlog) + 运行时层(GC/Lag)。如果你手头正好有一台测试服务器,我建议你先用 autocannonwrk 工具压测一下,观察 node --trace-gc app.js 的 GC 日志输出——看 GC 频次是否超过每秒 2~3 次,这是衡量 Node.js 高并发健康度的最核心指标。

如果压测过程中出现了某个特定瓶颈(比如 CPU 飙升但 QPS 不动,或内存泄漏无法定位),你可以把压测报告贴出来,我帮你精准排雷。