Node.js Cluster 详解

cluster 解决什么问题

单进程 Node 只用一个核。cluster 让 N 个进程跑同一个服务,共享同一个端口,把多核吃满。

官方文档开头就划了边界:不需要进程隔离的话,用 worker_threads(同进程、开销小一个量级)。要隔离性(崩了不连坐)、独立内存上限,才轮到 cluster。

尽管 2026 年几乎没人手写 cluster。PM2 cluster mode 是它的封装,K8s 又用多副本吃掉了 PM2 的一大块。但 cluster 背后的模式(分发、优雅关闭、滚动替换)在 PM2 和 K8s 里原样复现,这块仍然值得去了解。

1. 工作模型:一个文件,两种角色

js 复制代码
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');

if (cluster.isPrimary) {
  for (let i = 0; i < availableParallelism(); i++) cluster.fork();
  cluster.on('exit', (worker) => console.log(`worker ${worker.process.pid} died`));
} else {
  http.createServer((req, res) => res.end('ok')).listen(8000);  // 所有 worker 监听同一端口
}

cluster.fork() 只有 primary 能调(底层就是 child_process.fork(),自带 IPC 通道)。isMasterisPrimary 的旧名,已废弃。

worker 里那句 listen(8000) 并没有真的在 worker 里 bind。它被 cluster 拦截,发 IPC 消息问 primary"这个端口谁来管"。接下来怎么分连接,就是两种分发模式的分歧点。

2. 核心机制:两种分发模式

2.1 先补一块拼图:socket 有两种

  • 监听 socket:门口的取号机,只负责收新连接
  • 已连接 socket :每条具体通话的线路。accept() 从取号机取一个号,生成一条新的已连接 socket

两种模式的全部区别,就长在"worker 拿到的是哪种 socket"上。

2.2 SCHED_RR:primary 当接线员(默认,Windows 除外)

sql 复制代码
client ──connect──▶ primary: accept() ──句柄经 IPC 传送──▶ worker 3
                         │                                   │
                         └─────── 之后的字节流直达,不再经过 primary ──┘
  1. 真正 bind+listen 的是 primary
  2. 连接到达,primary accept 出已连接 socket,挑一个 worker,把 fd 通过 IPC 送过去。这就是前作实验 9 worker.send(msg, handle) 的工业化版本
  3. 交接完成后数据直达,primary 不是反向代理。它只在建连瞬间插一手:每连接一次 accept + 一次 fd 传递,零数据拷贝
  4. 内建防过载:worker 消化一条在途分发时不在队列里,消化完才回去领下一条。事件循环卡死的 worker 自然领不到新连接

一个常见疑惑:listen 代码明明写在 worker 分支里,分配权为什么在 primary? 因为拦截让"代码写在哪"和"bind 在哪执行"解耦了。worker 的 listen() 被截住后变成一张申请表(qu eryServer 消息),真正向内核注册端口的是 primary 进程。之后的逻辑链很短:socket 在谁手里,accept 就归谁;accept 归谁,分配权就归谁。分配不是 primary 的额外能力,是"socket 在 我手里"的直接推论。NONE 模式则是 primary 注册完把钥匙复印给所有 worker,自己退场。

2.3 SCHED_NONE:workers 抢同一个麦(Windows 默认)

  1. primary 建好监听 socket,用 IPC 把 socket 的 fd 分发给各 worker(listen 阶段一次性发完)
  2. 之后 primary 彻底退出数据路径。每个 worker 在同一个监听 socket 上自己 accept
  3. 新连接到达,内核决定唤醒谁,谁抢到归谁

理论上 NONE 性能最高(每连接零 IPC 开销),实际严重不均。官方文档原话:8 个 worker 时 70% 的连接落在 2 个进程上。

设置方式:

js 复制代码
cluster.schedulingPolicy = cluster.SCHED_NONE; // 或 SCHED_RR
// 第一个 worker fork 后冻结;也可用环境变量 NODE_CLUSTER_SCHED_POLICY=rr|none

2.4 实验 1:lsof 验尸,两种模式谁在监听

cluster-dist.js 起 8 个 worker,分别用两种模式跑,跑的同时看 8000 端口的监听者。

RR 模式:

ruby 复制代码
$ lsof -nP -iTCP:8000 -sTCP:LISTEN
node    92314 admin   20u  IPv6 0x89ccb0c1d2a1d60f  TCP *:8000 (LISTEN)

只有 1 个进程(primary)在监听。8 个 worker 连监听 socket 的影子都没有。

NONE 模式:

markdown 复制代码
$ lsof -nP -iTCP:8000 -sTCP:LISTEN
node    92464 admin   20u  IPv6 0x54d33b4fcd3dd803  TCP *:8000 (LISTEN)
node    92466 admin   13u  IPv6 0x54d33b4fcd3dd803  TCP *:8000 (LISTEN)
...(共 9 行)

9 个进程全在监听,DEVICE 列是同一个值:它们拿的是同一个内核对象的 fd 复印件。socket 是内核对象,fd 只是门票,而门票可以跨进程复制(SCM_RIGHTS)。

一句话对照:RR 里 primary 把已连接 socket 按条传给 worker;NONE 里 primary 把监听 socket 本身一次性发给所有 worker。

两种模式说的"传 socket",到底层是同一种搬运:fd 传递(SCM_RIGHTS,前作实验 9)。socket 是内核对象,本身不可能跨进程移动,能穿越边界的只有 fd 的复印件,这是唯一的搬运工。RR 和 NONE 的区别在传什么、传几次,不在怎么传。后文谈 primary 生死时会回到这一层数门票:RR 唯一一张门票在 primary 手里,NONE 有 9 张。

2.5 实验 2:分布实测

js 复制代码
// cluster-dist.js(直接运行:node cluster-dist.js rr   或   node cluster-dist.js none)
const cluster = require('node:cluster');
const http = require('node:http');

const N = 8; // worker 数,和官方文档里"8 个里 2 个拿 70%"的场景对齐
cluster.schedulingPolicy = process.argv[2] === 'none' ? cluster.SCHED_NONE : cluster.SCHED_RR;

if (cluster.isPrimary) {
  const counts = new Map();
  for (let i = 0; i < N; i++) {
    const w = cluster.fork();
    counts.set(w.id, 0);
    w.on('message', (m) => { if (m === 'hit') counts.set(w.id, counts.get(w.id) + 1); });
  }
  setTimeout(() => {
    const total = [...counts.values()].reduce((a, b) => a + b, 0);
    console.log(`\n策略 = ${process.argv[2] || 'rr'},连接总数 = ${total}`);
    for (const [id, n] of counts) {
      const pct = total ? (n / total * 100).toFixed(1) : '0.0';
      console.log(`worker ${String(id).padStart(2)}: ${String(n).padStart(3)} 条  ${pct.padStart(5)}%`);
    }
    for (const id in cluster.workers) cluster.workers[id].kill();
    process.exit(0);
  }, 5000);
} else {
  http.createServer((req, res) => {
    process.send('hit');          // 每处理一条连接,向 primary 报一次数
    res.end('ok');
  }).listen(8000);
}
js 复制代码
// cluster-blast.js(直接运行:node cluster-blast.js)
// 并发打出 400 条连接(keepAlive: false,一条连接一个请求)
const http = require('node:http');
const K = 400;
const agent = new http.Agent({ keepAlive: false, maxSockets: Infinity });
let done = 0, failed = 0;

for (let i = 0; i < K; i++) {
  const req = http.get('http://127.0.0.1:8000', { agent }, (res) => {
    res.resume();
    res.on('end', () => { if (++done + failed === K) {
      console.log(`打靶完成:成功 ${done}/${K}`); process.exit(0);
    }});
  });
  req.on('error', () => { failed++; });
}

跑法:node cluster-dist.js rr & sleep 1; node cluster-blast.js; wait

RR 实测(教科书级均匀):

yaml 复制代码
策略 = rr,连接总数 = 400
worker  1:  56 条   14.0%
worker  4:  48 条   12.0%
worker  8:  47 条   11.8%   (其余都在 47~52 之间)

NONE 实测第一跑(差 8.5 倍):

yaml 复制代码
策略 = none,连接总数 = 400
worker  2: 119 条   29.8%
worker  7:  14 条    3.5%

NONE 实测第二跑(赢家换人,差 13 倍):

yaml 复制代码
worker  8: 124 条   31.0%
worker  1:   9 条    2.3%

解读:

  • RR 的均匀是确定性的。分发权在 primary 手里严格轮流,和内核心情无关
  • NONE 的偏斜方向每次跑都不同,证明不是固定偏好,是抢占式竞争的涌现结果。原因是内核唤醒的缓存亲和性:刚处理完连接的 worker 栈和数据还在 CPU cache 里,调度器倾向再叫它,越忙越热、越闲越冷
  • 所以 NONE 的不均不是没调好的 bug,是机制本性。生产上 p99 延迟会被那个过载 worker 拖死,这是大家宁可付 RR 一次 fd 传递成本的原因

类比:RR 是总机转接,所有来电先响在前台,前台接起来再转坐席;NONE 是群里抢单,门铃装在每个人手机上,谁先按谁接。

3. 分发不是均衡

3.1 cluster 做的是连接分发,不是负载均衡

  • 均衡意味着感知忙闲(CPU、事件循环延迟、在途请求数),按反馈调度
  • RR 只是轮流派活,不看任何人状态。唯一的负载感知是防卡死 smarts:在途分发没消化完的 worker 跳过。这只覆盖"卡死","很忙但没死"照样一视同仁地喂

3.2 实验 3:keep-alive 把请求钉死在单个 worker 上

RR 的分配单位是一条 TCP 连接,不是请求。连接派给谁,这条连接上的所有请求就永远归谁。

js 复制代码
// cluster-blast-keepalive.js(直接运行:node cluster-blast-keepalive.js)
// 一条 keep-alive 长连接上打 400 个请求
const http = require('node:http');
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
let i = 0;

function next() {
  http.get('http://127.0.0.1:8000', { agent }, (res) => {
    res.resume();
    res.on('end', () => {
      if (++i < 400) next();
      else { console.log('400 个请求全部走完(同一条连接)'); process.exit(0); }
    });
  });
}
next();

跑法:node cluster-dist.js rr & sleep 1; node cluster-blast-keepalive.js; wait

实测:

yaml 复制代码
策略 = rr,连接总数 = 400
worker  4: 400 条  100.0%   (其余 7 个全是 0)

现代客户端全是 keep-alive 长连接,所以真实场景是:连接数看着均匀,请求量可以严重倾斜。一个话多的客户端顶一百个话少的。

3.3 无路由,所以必须无状态

Node 不做 sticky session。同一用户断线重连就是一条新连接,重新掷骰子分给任意 worker。session 放内存等于让用户每次刷新有 7/8 的概率掉登录。

解法是把状态搬出进程:session 放 Redis、登录态签进 JWT。worker 变成无状态计算器。这不是洁癖,是可替换性的入场券:无状态的 worker 才能随便杀,优雅重载和 K8s 销毁 Pod 的前提都是"这个进程死了不带走任何别人需要的东西"。

3.4 真均衡在上面那层

干什么 代表
集群外负载均衡 跨实例分发,健康检查、最少连接、权重、熔断 nginx upstream、云 LB、K8s Service
cluster 单机吃满多核 primary 的 RR 分发

cluster 的定位从来不是负载均衡器。推论三条:请求耗时方差大的服务别指望 cluster 救倾斜;监控时请求量不均先看连接数分布再报警;session 外部化是硬前提。

4. Worker 生命周期

一个 worker 从生到死路过五个事件,primary 全程旁观:

arduino 复制代码
cluster.fork()
   │
   ▼  'fork'      worker 进程已 spawn(还没跑起来)
   ▼  'online'    worker 的 JS 开始执行
   ▼  'listening' server.listen() 完成,能接活了(优雅重载等的就是它)
   ▼  'disconnect'  IPC 通道断开(优雅退役的标志)
   ▼  'exit'      进程死亡,带 (code, signal)

最重要的设计是区分两种死法:

js 复制代码
cluster.on('exit', (worker, code, signal) => {
  if (!worker.exitedAfterDisconnect) {
    // 意外死亡:崩溃、OOM、被 kill -9,补一个新的
    cluster.fork();
  }
  // exitedAfterDisconnect=true 说明是我们主动 disconnect 让它退役的,不用补
});

没有 exitedAfterDisconnect 这个标志位,自愈逻辑会出乌龙:优雅重载时你主动退役一个 worker,自愈代码以为崩溃又补一个,worker 越滚越多。

自愈和优雅重载不是一回事 。自愈是被动补位:worker 意外死亡,原样补一个,代码没变;优雅重载是主动换血(第 6 节):代码更新了,全员滚动替换。对应物:K8s 里自愈是 Deployment 维持副本数,优雅重载是 rolling update;PM2 里自愈是崩溃自动重启,优雅重载是 pm2 reloadexitedAfterDisconnect 就是两者的分界线:重载中主动退役的 worker 不该触发自愈。

文档警告:Node 不会自动补 worker。池子越来越小直到全部死光、服务拒连,补不补全是应用的责任。这是 PM2 存在的核心理由。

5. 优雅关闭:drain 机制

drain:拔塞子,等池子里的水自己流干。停止流入,等存量自然流出。前作背压实验里的 drain 事件排的是内存字节,优雅关闭排的是连接,K8s 的 kubectl drain 排的是 Pod,同一个隐喻。

5.1 三句话拆开"停接新活,存量做完自然退出"

  1. 停接新活 = server.close() 关闭监听 socket,新 SYN 收到 RST。cluster RR 场景更妙:worker 压根没有监听 socket,它的停接是发 IPC 让 primary 把它从分发队列摘除,端口从头到尾开着
  2. 存量做完 = server.close() 绝不碰已有连接,只是记挂着它们,回调在所有连接自然结束时才触发
  3. 自然退出 = 事件循环被饿死。listen 句柄关了、连接一个个结束、兜底 timer 是 unref 的,引用计数归零,进程自己走,退出码 0

5.2 实验 4:优雅关闭时间线

js 复制代码
// drain-demo.js(直接运行:node drain-demo.js,配合 kill -TERM <pid> 观察)
const http = require('node:http');

const server = http.createServer((req, res) => {
  if (req.url === '/slow') {
    console.log('慢请求开始处理(5 秒)');
    setTimeout(() => res.end('slow done\n'), 5000);
    // ↑ 模拟一个 5 秒才处理完的请求。它的作用:充当"存量",
    //   让我们能观察 SIGTERM 到来时在途请求的待遇
  } else {
    res.end('fast\n');
    // ↑ 即时响应。它的作用:SIGTERM 之后发新请求,测"新连接还能不能进"
  }
});

server.listen(8001, () => console.log(`listening, pid=${process.pid}`));
// ↑ ① 建立监听 socket(取号机开张)。打印 pid 是为了让 kill 命令有目标

process.on('SIGTERM', () => {
  // ↑ ② 接管信号。这行不写,SIGTERM 默认动作 = 当场终止(退出码 143),
  //   下面的一切都不存在。优雅关闭的全部前提就是这行注册

  console.log('收到 SIGTERM,调用 server.close()...');

  server.close(() => {
    // ↑ ③ close() 同步做一件事、异步挂一件事:
    //     a. 立刻关闭监听 socket → "停接新活"(下一行的日志打完就生效)
    //     b. 不碰已有连接,只登记回调:等所有连接自然结束才触发 → "存量做完"

    console.log('所有连接已结束,进程自然退出');
    process.exit(0);
    // ↑ ④ 存量清空后的收尾。其实不写这行,事件循环清空后进程也会自己走;
    //     写上更明确,也能控制退出码
  });

  console.log('监听 socket 已关,新连接将被拒绝;存量连接继续服务');
  // ↑ 注意执行顺序:这行在 close() 调用之后同步执行,
  //   而 close 的回调要等存量清空(可能几秒后)才触发
});

操作:起服务 → curl .../slow &kill -TERM <pid> → 立刻 curl .../fast

实测时间线:

markdown 复制代码
1. 慢请求开始处理(5 秒)
2. 收到 SIGTERM (手动 kill -TEAM pid) → server.close()
3. 新请求 → curl exit=7(connection refused)   ← 停接新活生效
4. slow done                                    ← 在途请求照常跑完
5. 所有连接已结束,进程自然退出                    ← 没人杀它,自己走的

5.3 版本坑和 WebSocket 坑

  • Node < 19:server.close() 不动空闲 keep-alive 连接,浏览器握着空连接不放,回调永远不触发。18.2 起可以补 server.closeIdleConnections()
  • Node ≥ 19(含 22):close() 自己会关闭空闲连接(官方变更记录:The method closes idle connections before returning)。实测 v22 下 SIGTERM 后复用旧连接也是 ECONNREFUSED
  • WebSocket 坑:closeAllConnections() 不销毁已升级的 socket(WebSocket、HTTP/2)。WS 服务调全套 close 组合拳连接照样挂着,只能自己维护 socket 清单逐个 destroy,或者靠超时强杀兜底

5.4 SIGTERM 和 SIGKILL 都不是"一定会有"的

SIGTERM 三重不保证:不保证有人发(它是外部想让你死时的请求);不注册 handler 就没有事件(实测无 handler 收到后一行 JS 不跑,shell 看到退出状态 143 = 128+15);注册了也可能迟到(handler 跑在事件循环上,循环堵死就排队)。

SIGKILL 设计上就是不可捕获:process.on('SIGKILL') 直接抛异常;被 kill -9 的进程连 exit 事件都没机会跑(实测退出状态 137 = 128+9)。它存在的意义是留一个保证有效的终止开关。

信号 可捕获 默认动作 典型发送者
SIGTERM 终止 kubelet / PM2 / kill
SIGINT 终止 Ctrl+C
SIGHUP 终止 终端关闭
SIGKILL 终止 kill -9 / OOM killer / 宽限期兜底
SIGSTOP 暂停 kill -STOP

生产姿势:认真处理 SIGTERM,同时假设自己随时可能被 SIGKILL,重要状态持续落盘。

5.5 K8s 里的正确死法

js 复制代码
process.on('SIGTERM', () => {
  server.close(() => process.exit(0));                 // 停接新连接,存量做完就退
  setTimeout(() => process.exit(1), 10_000).unref();   // 兜底强退,给编排层留余量
});

注意顺序:Pod 先从 Service endpoints 摘除(新流量不再来),再收到 SIGTERM。两者有传播延迟,生产上常加 preStop: sleep 5 兜底。宽限期到了 kubelet 补 SIGKILL(terminationGracePeriodSeconds 默认 30 秒)。

6. 优雅重载:给服务换血,一个请求都不许掉

6.1 机制

cluster 能做到零停机的物理基础:监听 socket 握在 primary 手里,worker 只是领活干的。换掉任何 worker,端口不撒手。

用词约定:优雅是目标(不掉请求),滚动是手段(逐个换)。蓝绿部署也优雅但不滚动,cluster 走的是滚动这条路。

scss 复制代码
[W1][W2][W3][W4]            全部跑旧代码
[W1][W2][W3][W4] + W5       fork 新人(加载新代码),等它 'listening'
[W2][W3][W4][W5]            W1 disconnect():停接新连接,存量做完,退出
   ... 逐个替换 ...
[W5][W6][W7][W8]            全部新代码。全程端口没关过,请求没丢过

核心代码:

js 复制代码
const fresh = cluster.fork();        // 1. 新人先上岗
fresh.on('listening', () => {
  worker.disconnect();               // 2. 老人停接新活,存量做完自然退出
  setTimeout(() => worker.kill(), 5000);  // 3. 兜底:长连接堵住 drain 就强杀
});

顺序不能反:先 fork 新的,再退旧的,反过来就有空窗。第 3 步兜底对应 WebSocket 坑:长连接"永远处理不完",disconnect() 会一直等。

6.2 映射到编排层

优雅重载的步骤 K8s rolling update PM2 reload
fork 新 worker 启动新 Pod fork 新进程
listening readiness 探针通过 等 online
disconnect() 旧 worker 发 SIGTERM 发退出信号
超时 kill() 兜底 宽限期到点 SIGKILL kill_timeout

7. primary 单点:两种模式,两种死法

7.1 实验 5:kill -9 primary

RR 模式实测:

shell 复制代码
$ kill -9 <primary_pid>
$ curl http://127.0.0.1:8000
curl: (7) Failed to connect        ← 端口随 primary 一起死
$ lsof -nP -iTCP:8000 -sTCP:LISTEN
(空)                              ← 8 个活着的 worker 救不了场

RR 里监听 socket 只有 primary 持有,它一死内核回收 socket,worker 手里只有已连接 socket,新连接无处可 accept。物理死亡。

NONE 模式,直觉上 worker 各持监听 socket 的 fd(实验 1 见过),引用计数不归零,端口本该活着。实测:

ini 复制代码
$ kill -9 <primary_pid>
[worker 13468] 退出,code=0
[worker 13467] 退出,code=0
[worker 13469] 退出,code=0
[worker 13470] 退出,code=0
$ lsof -nP -iTCP:8000 -sTCP:LISTEN
(空)

worker 集体自杀了,退出码 0。

7.2 殉葬是框架的主动选择

Node v22 源码 lib/internal/cluster/child.js 第 44~50 行,每个 worker 启动时埋好的钩子:

js 复制代码
process.once('disconnect', () => {
  worker.emit('disconnect');
  if (!worker.exitedAfterDisconnect) {
    // Unexpected disconnect, primary exited, or some such nastiness, so
    // worker exits immediately.
    process.exit(kNoFailure);
  }
});

链条:primary 死亡 → IPC 管道端关闭 → worker 读到 EOF 触发 disconnect → 发现不是优雅退役 → 立即 exit(0)

为什么这样设计:primary 死了,worker 们确实还能接活,但那是一群没人管的 worker。没人 fork 替补、没人做优雅重载、IPC 全断、分发还天然偏斜。失管的 worker 群对生产系统是负资产,不如整建制退出,交给外层编排者整体干净重启。和 K8s 里 Pod 作为一个整体生死是同一个哲学。

7.3 结论

primary 死后
RR 物理死亡:监听 socket 随 primary 消失
NONE 物理上能活,框架主动殉葬:worker 集体 exit(0)

cluster 的一切可用性都押在 primary 这条命上。两条实践建议:primary 里只放最少的代码(越简单越不会崩);生产上让 PM2 或 K8s 在最外层兜底整体重启。

8. cluster 缺陷清单

缺陷 后果
primary 单点 见第 7 节
有分发无均衡 keep-alive 倾斜(实验 3),NONE 偏斜(实验 2)
生命周期管理全甩给应用 不自愈、不重载,编排逻辑一行不给
内存 N 倍 同一份代码加载 N 遍,V8 堆各一份,底座几百 MB
优雅关闭暗坑 keep-alive(<19)、WebSocket 不随 close 关闭
worker 间不能直接通信 消息必须 primary 中转,两跳延迟
只管单机 业务稍大就要 LB/K8s,生态位尴尬

一句话:cluster 是精确的半成品。最难的内核部分(跨进程传句柄、共享端口、两种分发)做掉了,最烦的工程部分(自愈、均衡、编排、观测)原样留给你。

9. PM2:把甩给应用的部分全做完

PM2 没有替代 cluster,它是 cluster 的封装加一个不死的外层守护加一整套运维闭环:

cluster 的坑 PM2 的对策
worker 死了没人补 God daemon 自动重启,指数退避防 crashloop,max_memory_restart 兜底
primary 单点 PM2 自身是 detached 守护进程;pm2 startup + pm2 save 生成 systemd/launchd 开机自启
优雅重载要写状态机 pm2 reload 内置滚动替换;wait_ready + kill_timeout 旋钮
日志交错 God 收拢所有实例输出,pm2 logs 统一 tail,pm2-logrotate 切割
监控自己收 pm2 ls / pm2 monit
实例数写死 ecosystem.config.js 声明式 + pm2 scale app 4 动态调整

底层没变:PM2 cluster mode 跑的就是 node:cluster,本文的机制全部原样适用。

再往上,K8s 把同样的闭环又上移一层:自愈(Deployment)、滚动更新、日志、监控全部平台自带。当前分布:单机/VPS 用 PM2,容器化用 K8s(Pod 里一个 Node 进程),折中是 pm2-runtime 放容器里吃满多核。

10. 思考题:如果重新设计 cluster

前面盘完了缺陷(第 8 节)和 PM2 的补法(第 9 节)。这节换个问法:今天重新设计这个标准库模块会怎么做。按四步走:保留什么、在现有架构上修什么、动哪一刀架构、为什么现实没有长成这样。

10.1 保留什么(现状做对的)

  • worker 代码无感知(listen() 拦截委托,业务一行不改)
  • fd 传递原语
  • worker 殉葬作为默认(fail as a unit),但应可配置

10.2 修三件事(不动架构)

  1. 自愈内置cluster.fork({ restart: 'on-failure', maxRestarts: 10, backoff: 'exponential' }),语义直接抄 systemd
  2. 加 SCHED_LC(最少连接):primary 本来就在跟踪每个 worker 的在途分发数(防过载机制),把计数暴露出来、分发时挑最少的,零成本把假均衡变真均衡
  3. cluster.reload() 一行优雅重载:滚动替换核心就 20 行纯套路,标准库该收。要带上生产语义:先换一个观察稳定再动其余的(金丝雀),新 worker 起不来就中止,旧舰队原样保留

再加三个候选:readiness 握手(cluster.ready(),把 listening 和 ready 分开);cluster.scale(n) 声明式副本数;负载自报告钩子(cluster.reportLoad(),诚实版的负载均衡,因为 primary 看不到别的进程的 CPU,只能问)。

10.3 动架构:SO_REUSEPORT

现在所有痛苦的根:控制面(管理 worker)和数据面(accept 连接)绑死在 primary 一个进程上,它一死两面同时塌。

先补 SO_REUSEPORT 是什么。回到默认保护的源头:两个 socket bind 同一端口报 EADDRINUSE,是因为连接到达时内核不知道该交给谁,歧义没法解决就一刀切禁止。SO_REUSEPORT 就是一行 setsockopt,语义是"我知道这端口还有别人要用,连接来了内核你直接指派"。所有参与者都声明,歧义消失,禁止解除。

用取号机对照三种方案:

css 复制代码
默认:一个门牌只准放一台取号机,放第二台报 EADDRINUSE

cluster NONE:还是【一台】取号机,钥匙复印 8 份,客人来铃响大家抢
             (lsof 证据:9 行 DEVICE 全相同)

SO_REUSEPORT:【八台】取号机摆同一个门牌下,内核当门卫直接指派
             (lsof 证据:每个进程 DEVICE 各不相同)

关键就一句:NONE 是一个对象多张门票,SO_REUSEPORT 是多个对象各立门户。

实验 6:SO_REUSEPORT 实测

Node 的 net 模块没暴露这个选项,借 Python 的手(和前作实验 8 借 Python 演示 SCM_RIGHTS 同一个原因):

python 复制代码
# reuseport.py(用法:python3 reuseport.py,起多个进程各自独立 bind 8002)
import socket, os

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)  # 显式声明:我要共享
s.bind(('127.0.0.1', 8002))
s.listen(128)
print(f'pid {os.getpid()}: 独立 socket 绑定 8002 成功', flush=True)

while True:
    conn, _ = s.accept()
    body = f'from pid {os.getpid()}\n'.encode()
    conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: ' + str(len(body)).encode()
                 + b'\r\nConnection: close\r\n\r\n' + body)
    conn.close()

对照组(不设选项):第二个进程报 Errno 48 Address already in use

实验组(两个 SO_REUSEPORT 进程):

shell 复制代码
pid 19993: 独立 socket 绑定 8002 成功
pid 19992: 独立 socket 绑定 8002 成功

$ lsof -nP -iTCP:8002 -sTCP:LISTEN
Python  19992 admin  TCP 127.0.0.1:8002 (LISTEN)  DEVICE 0xb3c9...  ← 各不相同
Python  19993 admin  TCP 127.0.0.1:8002 (LISTEN)  DEVICE 0xedc1...

$ 连打 8 次 curl(macOS 实测)
from pid 19992 × 8        ← 全部落在一个进程上

$ kill -9 19992; curl ...
from pid 19993            ← 无缝接管,服务零中断

三个性质各自得到验证或证伪:

  • 无单点:成立,跨平台成立。任何 worker 死了,内核不再分发给它
  • 无交接:成立。没有 primary 经手,没有 fd 传递开销
  • 负载分流:Linux 限定。Linux 4.5+ 四元组哈希加独立 accept 队列,均匀且治惊群;macOS 实测 8 个请求全落一个,只是热备开关;Windows 根本没有这个选项

优雅重载在 SO_REUSEPORT 下变得免费:新一代 worker 先 bind 同端口,老 worker 收信号关 socket、drain、退出,全程无人转发无人协调。

10.4 为什么现实不是这样

设计说得再好,现实里 Node 没有 SO_REUSEPORT、PM2 还在用 fd 传递。不是没人想到,是约束不允许:

  • libuv 的职责是抹平平台差异,这个选项在三个平台是三个物种,没法抽象,干脆不暴露
  • PM2 是纯用户态工具,socket 全来自 Node 的链,够不着 setsockopt,这不是它的选择
  • 历史时机:PM2 诞生于 2013,SO_REUSEPORT 的 TCP 好用要等 Linux 4.5(2016),架构早已定型
  • nginx 在用,因为它只服务 Unix-like,没有 Windows 包袱,C 代码直接面对系统调用

守护进程同理。顺着"自愈内置"会想:干脆学 PM2,让 cluster 自带一个 detached 守护进程盯着 primary?也不做,三个理由:

第一,监督层上它是纯冗余。 任何用户态进程都保证不了自己不死,所以这个 daemon 想可靠,唯一的路是把自己注册给 systemd/launchd(pm2 startup 干的就是这个)。但 systemd 一旦能盯 daemon,就能直接盯 primary(一行 Restart=always),链从四环变三环,daemon 在监督层没有提供任何 init 给不了的能力:

sql 复制代码
systemd → daemon → primary → workers    ← daemon 多余
systemd → primary → workers             ← 本来就够了

第二,运维闭环上它必然越权。 PM2 的 daemon 能存在,靠的不是监督,是监督之外的东西:日志聚合、监控、scale、声明式配置。这些是机器级政策,答案因部署形态而异(裸机、Docker、K8s 完全不同)。标准库的纪律是 require 一个库不该改变机器状态。daemon 只做监督是冗余,做全闭环就长成了第二个 PM2,两头不占,所以不做。

第三,daemon 本来也治不了本。 它盯得再紧也是出事后救援:PM2 方案下 primary 崩溃的故障窗口(God 轮询发现、re-fork、模块加载、重新 listen)是几百毫秒到几秒的 100% 拒连。SO_REUSEPORT 方案让 accept 路径上没有会死的角色,窗口约等于零。一个缩短窗口,一个让窗口不存在,这就是"缓解"和"消除"的区别。

10.5 明确不做什么

  • 不做请求级路由(要终结 HTTP 当反向代理,破坏交接后零拷贝,那是 L7 LB 的活)
  • 不做日志聚合、监控面板、开机自启(PM2 的领域)
  • 不做 sticky session(内存 session 是反模式,不给拐杖)

设计主线:把 K8s 验证过的编排语义(replicas、readiness、rolling update、restart policy)下沉为单机原语,数据面交给内核,控制面简单到不会死。

相关推荐
名字还没想好☜1 小时前
React 用 useEffect 做轮询实战:setInterval 拿到旧 state 的闭包陷阱与正确清理
前端·javascript·react.js·react·useeffect
hiahiahia1231 小时前
AI Web 项目的文件到底应该怎么放?
前端·人工智能
愚公搬代码1 小时前
【愚公系列】《Web应用安全》003-测试环境的搭建
前端·安全
_codemonster2 小时前
主流前端技术分层选型
前端
犹豫的果冻布丁4 小时前
从零给 DeepSeek Harness 写一个壁纸皮肤插件(已开源)
前端·后端
漏刻有时4 小时前
数据可视化Three.js 3D 地图实战:单文件原生实现省域区县拉伸建模
前端
会说话的番茄4 小时前
AI 满嘴跑火车怎么办?给它配个"小抄"
前端·aigc
计算机魔术师4 小时前
AI 权力集中辩论实录:从「token 工厂」到「唯一幸存者」
前端
eric-sjq4 小时前
WanlyFrontend 中文声明式前端语言完全指南:让 0.6B 模型写出漂亮网页
前端