17. Cluster + Express 实战

本章解决的问题

让多个 Worker 运行 Express 很容易,真正困难的是资源所有权:每个 Worker 都会创建数据库连接池、Redis 连接、内存 Session、限流计数和日志输出。本章先搭建清晰结构,再逐项处理这些日常问题。

推荐结构

Primary
├─ 创建与监控 Worker
├─ 协调整体关闭
└─ 不创建 Express App

Worker
├─ 创建 Express App
├─ 创建本 Worker 的数据库/Redis 连接
├─ 监听 HTTP 端口
└─ 收到停机通知后关闭自己的资源

最小 Express 示例

import * as cluster from "node:cluster";
import os from "node:os";
import express, { type Request, type Response } from "express";

const port = 8080;
const workerCount = Math.min(2, os.availableParallelism());

if (cluster.isPrimary) {
  for (let index = 0; index < workerCount; index += 1) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.error("Worker 退出", {
      workerId: worker.id,
      pid: worker.process.pid,
      code,
      signal,
    });
  });
} else {
  const app = express();

  app.get("/api/health", (_request: Request, response: Response) => {
    response.json({
      ok: true,
      pid: process.pid,
      workerId: cluster.worker?.id,
    });
  });

  const server = app.listen(port, () => {
    console.log(`Worker ${cluster.worker?.id} listening on ${port}`);
  });

  server.on("error", (error) => {
    console.error("Express 监听失败:", error.message);
    process.exitCode = 1;
  });
}

访问 /api/health 时 PID 可能变化。由于 HTTP Keep-Alive 会复用连接,单个客户端连续请求也可能一直落到同一个 Worker,这不代表 Cluster 没有工作。

Worker 数量不是越多越好

Node.js 22+ 可使用:

const logicalCpuCount = os.availableParallelism();

但 Worker 数量还受以下条件影响:

先用较小实例数压测,再根据吞吐、延迟、RSS、Event Loop Delay 和下游容量调整。

数据库连接池会相乘

假设 4 个 Worker,每个连接池上限为 20:

理论最大数据库连接数 = 4 × 20 = 80

滚动发布时新旧实例会短暂共存,实际峰值可能更高。因此要按总预算反推单 Worker 连接上限,并在 Worker 关闭时释放连接池。

async function closeResources(): Promise<void> {
  await databasePool.end();
  await redisClient.quit();
}

数据库连接对象不能从 Primary 通过 IPC 发送给 Worker。

Session、缓存和限流不共享

以下实现只在单个 Worker 内有效:

const sessions = new Map<string, Session>();
const requestCounts = new Map<string, number>();
const cache = new Map<string, Result>();

请求被分配到另一个 Worker 后,数据就“消失”了;实际上它只存在于原 Worker。生产环境通常使用:

粘性会话能让同一客户端尽量回到同一 Worker,但会带来分布不均、Worker 重启丢状态等问题,不能替代持久化。

WebSocket 与长连接

WebSocket 升级后连接会长期绑定某个 Worker。需要注意:

Cluster 解决端口共享,不自动解决跨 Worker WebSocket 房间和广播。

优雅关闭

Primary 负责协调,Worker 负责关闭自己的 Server 和资源。

// Worker 分支
let shuttingDown = false;

async function shutdown(): Promise<void> {
  if (shuttingDown) return;
  shuttingDown = true;

  server.close(async (error) => {
    if (error) {
      console.error("HTTP Server 关闭失败:", error.message);
      process.exitCode = 1;
    }

    try {
      await closeResources();
    } catch (closeError: unknown) {
      console.error("资源关闭失败:", closeError);
      process.exitCode = 1;
    }
  });
}

process.once("SIGTERM", () => void shutdown());
process.once("SIGINT", () => void shutdown());

真实项目还应增加强制退出上限,并追踪活跃请求。不要在 process.on("exit") 中期待异步关闭数据库。

Primary 收到 SIGTERM
  → 停止创建 Worker
  → 通知/断开 Worker
  → Worker 停止接收新请求
  → 等待已有请求与连接
  → 关闭 DB/Redis
  → Worker exit
  → Primary exit

日志和文件

多 Worker 会同时写日志。结构化日志至少加入:

timestamp、pid、workerId、requestId、route、durationMs

多个进程直接向同一文件追加时,需要明确日志库的跨进程策略;更常见的是输出 stdout/stderr,再由 PM2、容器运行时或日志代理采集。

上传和临时文件名必须唯一,不能让不同 Worker 都使用 temp/output.zip。可以从 node:crypto 导入 randomUUID() 创建任务目录,并配合原子重命名和定时清理。

跨平台注意事项

常见错误

练习

  1. 启动两个 Worker,让 /api/health 返回 PID 和 Worker ID。
  2. 在 Worker 内建立计数器,观察请求跨 Worker 后计数不连续,并解释原因。
  3. 给定数据库最大连接数 100、滚动发布可能同时存在 8 个 Worker,计算安全的单池上限。
  4. 设计一个使用 Redis 的全局限流方案,只写清键、时间窗口和原子操作即可。

验收清单