图 1:本文封面,一个 master 发号施令,一群 worker 埋头干活。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面是「解释 Nginx 服务器上的 Master 和 Worker 进程分别是什么」。官方文档的原话可以作为骨架:Nginx 有一个 master 进程和若干 worker 进程,master 的职责是读取并评估配置、维护 worker 进程,worker 做实际的请求处理。答题的关键是把「为什么这样分」讲出来。
master:控制面,不碰连接。
master 干的是管理活,按官方文档可列成四类:读取并校验配置;按配置拉起、监控、回收 worker;接收管理员信号并翻译成对 worker 的动作;在 worker 异常退出后补拉新的。它自己不处理任何用户连接——你在压测时打满的永远是 worker。
信号机制是 master 的工作界面。管理员发给 master 的常用信号有:TERM/INT 快速停止;QUIT 优雅停止;HUP 重新加载配置;USR1 重开日志文件(日志切割就靠它);USR2 平滑升级可执行文件;WINCH 优雅关闭 worker。以 HUP 为例,master 收到后先做新配置的语法校验,校验失败就回滚、老配置继续跑、worker 一个不动;校验通过才按新配置拉起一批新 worker,同时通知旧 worker 优雅退出——旧 worker 关掉监听 socket、不再接新连接,把手上存量请求服务完再退出。整个 reload 过程对外零断连,这就是「改配置不掉线」的机制来源。
worker:数据面,各自为战。
每个 worker 是独立进程,跑自己的事件循环(epoll 等系统机制),争抢新连接、处理请求、转发上游。worker 之间不共享内存,一个 worker 崩溃不会污染其他 worker,master 发现缺口后补拉一个即可。worker 数量由配置指定,常见做法是设为 CPU 核数(也可以配成 auto 自动按核数调整),让每个 worker 钉在一个核上,避免进程间切换的浪费。
为什么这么分?三个动机。
第一是权限与全局视角:读配置、绑定特权端口、写 pid 文件这些操作需要特权和全局状态,集中在单个 master 里做,worker 就可以用非特权用户跑。第二是水平扩展与故障隔离:数据面按进程复制,天然利用多核;进程隔离让单个 worker 的崩溃只是局部事件。第三是管理操作不打断服务:reload、日志切割、平滑升级都发生在控制面,通过信号翻译给数据面逐步执行,用户无感。如果管理面与数据面混在一个进程里,改一次配置就得断一轮连接。
用一段模拟把 master 的三类动作跑出来。
下面这段代码模拟 master/worker 的信号语义:worker 崩溃补拉、HUP 换配置的「校验-拉新-优雅退旧」、配置失败回滚。用 Node.js 22.15.0 直接运行(node master-worker.js):
'use strict';
// Nginx master/worker 信号语义的最小模拟:master 管配置与工人,worker 管连接
let nextPid = 100;
class Worker {
constructor(config) { this.pid = nextPid++; this.config = config; this.connections = 3; this.draining = false; }
tick(log) {
if (this.draining && --this.connections <= 0) return false; // 优雅退出:存量请求服务完才走
return true;
}
}
class Master {
constructor(config) { this.config = config; this.workers = [new Worker(config), new Worker(config)]; }
signal(sig, log, payload) {
if (sig === 'HUP') {
if (!payload.valid) { log.push('HUP:新配置语法校验失败,回滚,worker 不动'); return; }
log.push('HUP:校验通过,按新配置拉起新 worker');
const fresh = [new Worker(payload.config), new Worker(payload.config)];
for (const w of this.workers) w.draining = true; // 通知旧 worker 优雅退出
this.workers.push(...fresh);
this.config = payload.config;
}
if (sig === 'QUIT') { for (const w of this.workers) w.draining = true; log.push('QUIT:全体优雅退出中'); }
}
tick(log) {
const alive = this.workers.filter((w) => w.tick(log));
if (alive.length !== this.workers.length) log.push('旧 worker 存量连接服务完毕,退出');
this.workers = alive;
}
crashOne(log) {
const victim = this.workers.shift();
log.push(`worker ${victim.pid} 崩溃`);
this.workers.push(new Worker(this.config));
log.push('master 检测到缺口,立即拉起新 worker');
}
}
const log = [];
const master = new Master('v1');
master.crashOne(log);
master.signal('HUP', log, { valid: true, config: 'v2' });
for (let i = 0; i < 3; i++) master.tick(log);
master.signal('HUP', log, { valid: false, config: 'broken' });
log.push(`当前 worker 数 ${master.workers.length},配置版本 ${master.config}`);
for (const line of log) console.log(line);
实测输出:
worker 100 崩溃
master 检测到缺口,立即拉起新 worker
HUP:校验通过,按新配置拉起新 worker
旧 worker 存量连接服务完毕,退出
HUP:新配置语法校验失败,回滚,worker 不动
当前 worker 数 2,配置版本 v2
五行输出对应 master 的三类动作:崩溃补拉、HUP 的「先验后换」、失败回滚。注意中间几次 tick 里旧 worker 是排干存量连接后才退出的——优雅退出的成本由旧 worker 自己承担,新请求已经在走新 worker 了。
图 2:控制面与数据面的职责划分及故障语义。
可能的追问。
「master 挂了会怎样?」worker 还能继续服务存量连接,但失去了管理面:没有进程补拉、没有 reload、日志切割停摆,应尽快恢复 master。「为什么用多进程而不是多线程?」进程隔离换来的是崩溃不扩散与地址空间独立,配合事件驱动,单 worker 内部根本不需要多线程的复杂度。「这套范式别处有吗?」遍地都是:PostgreSQL 的 postmaster 加后端进程、各类推理服务的 supervisor 加 worker,都是同一个「管理面与数据面分离」的同构设计。
回到面试现场,建议的答题结构:先用官方定义一句话分清两者职责,再展开 master 的四类管理动作与信号列表(HUP 的「校验-拉新-退旧」流程是加分项),然后讲 worker 的事件循环与进程隔离,最后收在三个分工动机上。你在线上 reload 或升级过 Nginx 吗?有没有遇到过旧 worker 迟迟不退的情况,原因是什么?欢迎聊聊。