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 数量还受以下条件影响:
- 单 Worker 内存占用;
- 数据库最大连接数;
- Redis 和外部 API 限额;
- 同机运行的 Nginx、数据库和分析程序;
- 请求是 CPU 密集还是 I/O 密集;
- 容器的 CPU/内存限制。
先用较小实例数压测,再根据吞吐、延迟、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。生产环境通常使用:
- Session:Redis/数据库,或无状态且可验证的 Token;
- 缓存:Redis 等共享缓存;
- 全局限流:Redis 原子计数或网关层限流;
- 任务状态:数据库或任务队列,而非 Worker 内存。
粘性会话能让同一客户端尽量回到同一 Worker,但会带来分布不均、Worker 重启丢状态等问题,不能替代持久化。
WebSocket 与长连接
WebSocket 升级后连接会长期绑定某个 Worker。需要注意:
- 广播不能只遍历当前 Worker 的连接;
- 多 Worker 广播通常需要 Redis Pub/Sub 等共享通道;
- 负载均衡可能需要粘性策略;
- 优雅停机要停止新连接,并为现有连接设置关闭期限;
- 长连接会使各 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() 创建任务目录,并配合原子重命名和定时清理。
跨平台注意事项
- Windows 和 Linux 的 Signal 行为不同,部署测试必须在目标平台完成。
- 文件锁、删除仍被使用的文件等行为跨平台不同。
- WebSocket 粘性策略常由 Nginx、负载均衡器或部署平台决定。
- 多进程日志聚合方式取决于 PM2、systemd、Docker 等运行环境。
常见错误
- Primary 和每个 Worker 都调用
app.listen()。 - 只按 CPU 数决定 Worker 数,不计算数据库连接总量。
- 使用内存 Session 或限流器,却认为它在所有 Worker 中全局生效。
- WebSocket 广播只发给本 Worker 的客户端。
- 多 Worker 使用同一个固定临时文件名。
- 收到 Signal 后立刻
process.exit(),截断请求和日志。
练习
- 启动两个 Worker,让
/api/health返回 PID 和 Worker ID。 - 在 Worker 内建立计数器,观察请求跨 Worker 后计数不连续,并解释原因。
- 给定数据库最大连接数 100、滚动发布可能同时存在 8 个 Worker,计算安全的单池上限。
- 设计一个使用 Redis 的全局限流方案,只写清键、时间窗口和原子操作即可。
验收清单
- [ ] 只有 Worker 创建 Express App 和监听端口。
- [ ] 能计算所有 Worker 的数据库连接总量。
- [ ] Session、缓存、限流和任务状态不依赖进程内存共享。
- [ ] WebSocket 广播有跨 Worker 通信方案。
- [ ] 临时文件名唯一,日志包含 PID 和 Worker ID。
- [ ] 停机时先停止新请求,再关闭外部资源。