Appearance
Day9 Node.js 核心:模块机制、事件循环、Stream、Buffer、进程线程
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. CommonJS 模块机制
Node 加载一个 .js 文件时,并不是直接执行文件内容,而是先把代码包进一个函数再调用,这就是模块作用域的来源:
js
// 你写的代码
const fs = require("fs");
module.exports = { a: 1 };
// Node 实际执行的(模块包装器 Module.wrap)
(function (exports, require, module, __filename, __dirname) {
const fs = require("fs");
module.exports = { a: 1 };
});text
require('./a.js') 的完整过程
┌──────────────────────────────────────────────────┐
│ 1. Module._resolveFilename 路径解析 → 绝对路径 │
│ 2. 查 Module._cache[绝对路径] │
│ 命中 → 直接返回 cached.exports(不再执行) │
│ 3. new Module() 创建模块对象 │
│ { id, exports: {}, loaded: false, children } │
│ 4. 先写入缓存(关键!这一步在执行之前) │
│ 5. module.load() → 按扩展名选 extension 处理器 │
│ .js → 读文件 + 包装 + compile │
│ .json → JSON.parse │
│ .node → process.dlopen 加载 C++ 插件 │
│ 6. loaded = true,返回 module.exports │
└──────────────────────────────────────────────────┘第 4 步「先缓存再执行」是循环依赖能跑通的根本原因:a 引 b、b 又引 a 时,b 拿到的是 a 当前已经赋值的那部分 exports(一个不完整的对象),而不是死循环。
模块查找优先级:
text
require('x')
├─ 核心模块(fs / path / http…,或带 node: 前缀)→ 直接返回,优先级最高
├─ './' '../' '/' 开头 → 当作文件路径
│ x → x.js → x.json → x.node → x/package.json(main) → x/index.js
└─ 裸模块名 → 逐层向上找 node_modules
/app/src/node_modules/x
/app/node_modules/x
/node_modules/x ← 找不到抛 MODULE_NOT_FOUND| 五个模块变量 | 本质 |
|---|---|
exports | 形参,初始指向 module.exports,重新赋值会失效 |
module | 当前模块对象,module.exports 才是真正的导出 |
require | 函数,挂着 require.cache / require.resolve |
__filename | 当前文件绝对路径 |
__dirname | 当前文件所在目录绝对路径 |
js
// 经典坑:直接给 exports 赋值会断开与 module.exports 的引用
exports.a = 1; // ✅ 有效
exports = { a: 1 }; // ❌ 无效,只是改了形参指向
module.exports = { a: 1 }; // ✅ 有效为什么 exports = {...} 不生效,module.exports = {...} 就可以?
一句话答案:require 返回的永远是 module.exports,不是 exports。exports 只是包装函数的一个形参(局部变量),初始时和 module.exports 指向同一个对象;你直接 exports = {} 只改了局部变量的指向,动不了 module 对象身上的 exports 属性。
js
// Node 内部 Module 简化版(还原 require 的返回值从哪来)
class Module {
constructor(filePath) {
this.exports = {}; // ✅ 真正要导出的东西挂在 module 对象上
}
}
function require(file) {
const m = new Module(file);
// 把文件源码套进包装函数并执行
(function (exports, require, module, __filename, __dirname) {
// ← 你写的模块代码放在这里
})(m.exports, require, m, file, dir); // 注意第一个实参就是 m.exports
return m.exports; // ✅ 最终 return 的是 module.exports,和局部 exports 无关
}三种写法的内存指向变化:
text
初始状态(包装函数刚进来)
module ──┐
├─ exports 属性 ──┐
exports(形参) ─────────────┴──► 堆:{} ← 两者同一个地址
① exports.a = 1 ✅ 生效
module.exports ─┐
exports ────────┴──► { a: 1 } 引用没断,只是加属性
require 返回 { a: 1 }
② exports = { a: 1 } ❌ 不生效
module.exports ─────► {} ← require 返回这个空对象
exports ────────────► { a: 1 }(新对象) ← 局部变量重定向,彻底分家
③ module.exports = { a: 1 } ✅ 生效
module.exports ─────► { a: 1 } ← require 返回这个
exports ────────────► {}(旧的空对象,没人要) ← 此后用 exports.x 已无意义跑一遍就能复现:
js
// test.cjs —— ❌ 错误写法
exports = { msg: "直接给 exports 赋值" };
// main.cjs
const test = require("./test.cjs");
console.log(test); // {} 空对象,拿不到 msg
// test.cjs —— ✅ 正确写法
module.exports = { msg: "给 module.exports 赋值" };
// main.cjs 打印:{ msg: '给 module.exports 赋值' }
// 既想整体覆盖、又想继续用 exports 简写,可以一行重新绑定
module.exports = exports = { msg: "两者重新指向同一个新对象" };
// 原理:赋值从右往左,先把新对象赋给 exports,再赋给 module.exports| 写法 | 是否生效 | 原因 |
|---|---|---|
exports.a = 1 | ✅ | 给两者共同指向的对象追加属性,引用没断 |
exports = { a: 1 } | ❌ | 局部形参重定向,与 module.exports 断开 |
module.exports.a = 1 | ✅ | 直接操作模块的导出对象 |
module.exports = { a: 1 } | ✅ | 替换导出对象,require 返回的就是它 |
module.exports = exports = { a: 1 } | ✅ | 同时赋值,恢复两者引用一致 |
module.exports = {a:1} 后 exports.b = 2 | ❌(b 丢失) | 第一行已让两者分家,再给 exports 加属性无效 |
配套追问:为什么要设计成这样,不让 exports 直接当返回值? 为了同时支持两种导出风格:追加多个成员时写 exports.xxx 更省事;只导出一个函数/类时则直接 module.exports = Class 整体覆盖。如果把局部 exports 当返回值,后者就没法实现了。
加粗要点: CommonJS 的出口只有一个 ——
module.exports。exports是「给module.exports起的一个局部别名」,只能用来加属性,不能整体赋值;一旦你覆盖了module.exports,之前那个exports别名就作废了,后续exports.x = ...一律丢失。
2. CommonJS 与 ES Module 的全部差异
| 维度 | CommonJS | ES Module |
|---|---|---|
| 加载时机 | 运行时动态加载 | 编译期静态解析 |
| 加载方式 | 同步阻塞 | 异步(三阶段:构造 → 实例化 → 求值) |
| 导出语义 | 值拷贝(基础类型快照) | 引用绑定(live binding) |
| 能否动态路径 | 可以 require(variable) | 静态 import 不行,需 import() |
| 书写位置 | 任意位置,可在 if 里 | 必须顶层(import() 除外) |
| Tree-Shaking | 不支持 | 支持 |
this 指向 | module.exports | undefined |
| 顶层变量 | 有 __dirname / __filename | 用 import.meta.url 自行推导 |
| 循环依赖 | 拿到不完整的 exports | 靠提升与 TDZ,可能拿到未初始化报错 |
| 文件识别 | .cjs 或 type: commonjs | .mjs 或 type: module |
js
// 值拷贝 vs 引用绑定
// counter.cjs
let count = 0;
module.exports = { count, add: () => count++ };
// 外部拿到的 count 永远是 0 —— 因为导出的是那一刻的值拷贝
// counter.mjs
export let count = 0;
export const add = () => count++;
// 外部的 count 会跟着变 —— ESM 导出的是绑定互操作要点: ESM 里可以
import cjs from './a.cjs'(整体当 default),但不能对 CJS 做具名解构导入(除少数被静态分析识别的情况);CJS 里不能require()一个 ESM,只能用await import()。Node 22+ 在开启标志后支持require(esm),但生产上还是建议明确二选一。
3. Node 事件循环的 6 个阶段
text
┌──────────────────────────────┐
┌─>│ timers │ setTimeout / setInterval 到期回调
│ └─────────────┬────────────────┘
│ ┌─────────────┴────────────────┐
│ │ pending callbacks │ 上一轮延迟下来的系统级 I/O 回调(如 TCP ECONNREFUSED)
│ └─────────────┬────────────────┘
│ ┌─────────────┴────────────────┐
│ │ idle / prepare │ libuv 内部使用
│ └─────────────┬────────────────┘ ┌───────────────┐
│ ┌─────────────┴────────────────┐ │ incoming: │
│ │ poll │<─────│ connections, │
│ └─────────────┬────────────────┘ │ data, etc. │
│ ┌─────────────┴────────────────┐ └───────────────┘
│ │ check │ setImmediate 专属阶段
│ └─────────────┬────────────────┘
│ ┌─────────────┴────────────────┐
└──┤ close callbacks │ socket.on('close') 等
└──────────────────────────────┘
每个阶段之间都会清空:nextTick 队列 → 微任务队列(Promise)| 阶段 | 干什么 | 面试要点 |
|---|---|---|
| timers | 执行到期的 setTimeout / setInterval | 定时器不保证精确,只保证不早于 |
| pending callbacks | 上一轮循环延迟下来的系统级 I/O 回调 | 比如 TCP 连接错误的 ECONNREFUSED |
| idle / prepare | libuv 内部使用,业务无感 | 面试只需知道存在 |
| poll | 取 I/O 事件并执行回调,可能阻塞等待 | 事件循环的核心,决定阻塞多久 |
| check | 执行 setImmediate 回调 | 名字反直觉:它比 timers 更"立即" |
| close callbacks | 执行 close 事件回调 | socket.destroy() 后的清理 |
poll 阶段的阻塞策略: 如果 poll 队列非空就依次执行回调;队列空时 —— 有
setImmediate待执行就立刻进 check;否则看有没有即将到期的 timer,有就阻塞到那个时间点,都没有就一直阻塞等 I/O。这就是为什么纯 I/O 的 Node 进程 CPU 占用接近 0。
4. 微任务:process.nextTick 与 Promise.then
js
console.log("start");
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick1"));
Promise.resolve().then(() => console.log("promise1"));
process.nextTick(() => console.log("nextTick2"));
console.log("end");
// start → end → nextTick1 → nextTick2 → promise1 → timer → immediate| 队列 | 归属 | 优先级 | 说明 |
|---|---|---|---|
process.nextTick | Node 自有队列 | 最高 | 每个阶段结束先清空它,且可以无限插队 |
Promise.then | V8 微任务队列 | 次高 | nextTick 清空后才执行 |
setTimeout(fn, 0) | timers 阶段 | 宏任务 | 实际最小延迟约 1ms |
setImmediate | check 阶段 | 宏任务 | 当前阶段跑完就执行,不等 timer |
nextTick可以饿死事件循环: 因为它在每个阶段结束时被完全清空,并且清空过程中新加入的 nextTick 也要一并执行。递归调用process.nextTick会让 I/O 永远得不到执行机会;换成setImmediate则会把控制权交还给事件循环。所以库作者才建议默认用setImmediate。
5. Buffer:Node 的二进制缓冲区
Buffer 是 Node 处理二进制数据的核心,底层继承自 Uint8Array,分配的是 V8 堆外内存(不受 --max-old-space-size 限制,但受系统内存限制)。
js
// 三种创建方式
const b1 = Buffer.alloc(8); // 分配并填 0(安全,推荐)
const b2 = Buffer.allocUnsafe(8); // 不清零,更快,但可能含旧数据(有泄露风险)
const b3 = Buffer.from("中文", "utf8"); // 从字符串/数组/ArrayBuffer 创建
console.log(Buffer.from("中").length); // 3 —— 一个中文 UTF-8 占 3 字节
console.log("中".length); // 1 —— 字符串长度按 UTF-16 码元算
// 常用操作
b3.toString("utf8"); // 转字符串
b3.toString("base64"); // base64 编码
b3.toString("hex"); // 十六进制
Buffer.concat([b1, b3]); // 拼接(比 += 高效)
b3.slice(0, 3); // 返回**共享内存**的视图,不是拷贝!
Buffer.byteLength("中文"); // 6,算真实字节数| 编码 | 用途 |
|---|---|
utf8 | 默认,文本 |
base64 | 图片/文件转字符串传输(体积涨 1/3) |
hex | 调试、哈希值展示 |
latin1 | 单字节映射,二进制安全的"伪字符串" |
ascii | 只取低 7 位,会丢信息 |
最大的坑:切割多字节字符。 流式读取时如果一个中文字符的 3 个字节被分到了两个 chunk 里,直接
chunk.toString()会出现乱码「�」。正确做法是用require('string_decoder').StringDecoder,它会把不完整的字节缓存到下一次。
6. Stream:四大类流
text
Readable ──pipe──> Transform ──pipe──> Writable
(可读) (转换,双工) (可写)
fs.createReadStream → zlib.createGzip → fs.createWriteStream
读文件 压缩 写文件| 流类型 | 代表 | 必须实现的方法 |
|---|---|---|
| Readable | fs.createReadStream、http.IncomingMessage | _read(size) |
| Writable | fs.createWriteStream、http.ServerResponse、process.stdout | _write(chunk, enc, cb) |
| Duplex | net.Socket(读写互不相干) | _read + _write |
| Transform | zlib.createGzip、crypto.createCipheriv | _transform(chunk, enc, cb) |
js
// 可读流的两种模式
// ① 流动模式 flowing:绑定 data 事件或 pipe 后自动推送
rs.on("data", (chunk) => {});
// ② 暂停模式 paused:手动 read()
rs.on("readable", () => {
let chunk;
while ((chunk = rs.read()) !== null) {
/* ... */
}
});核心价值: 1GB 文件用
fs.readFile需要 1GB+ 内存(且超过buffer.constants.MAX_LENGTH直接抛错);用流则按highWaterMark(文件流默认 64KB)分片,内存占用恒定,且第一个字节就能开始处理,首字节延迟大幅降低。
7. 进程与线程
text
Node 进程
├── 主线程(JS 单线程,执行你的代码 + 事件循环)
├── libuv 线程池(默认 4 条,UV_THREADPOOL_SIZE 可调至 1024)
│ └─ 文件 I/O、DNS(dns.lookup)、zlib 压缩、crypto 部分 API
├── V8 的 GC / 编译线程
└── Worker 线程(worker_threads 手动创建,各自独立的 V8 实例与事件循环)| 方案 | 本质 | 适用场景 | 通信方式 |
|---|---|---|---|
child_process.fork | 独立 Node 进程 | 跑独立脚本、隔离风险 | IPC send / on('message') |
cluster | 多进程共享端口 | 利用多核做 HTTP 服务 | IPC + 主进程分发 |
worker_threads | 同进程多线程 | CPU 密集型计算 | postMessage、SharedArrayBuffer |
| libuv 线程池 | C++ 层线程 | 由 Node 自动使用,无需管理 | — |
一句话结论: Node 的"单线程"指的是执行 JS 的线程只有一条,I/O 本身是多线程/内核事件驱动的。所以 Node 擅长 I/O 密集,不擅长 CPU 密集 —— 后者会堵住主线程,必须丢给
worker_threads或独立进程。
8. CPU 密集型 与 I/O 密集型
上一节反复提到"CPU 密集"和"I/O 密集",这两个词几乎是所有 Node 性能话题的分水岭,必须先把定义钉死。
- CPU 密集型(CPU-bound):大部分时间 CPU 都在做计算 / 逻辑运算,I/O 等待很短,瓶颈是CPU 算力。
- I/O 密集型(I/O-bound):大部分时间都在等外部资源返回(磁盘、数据库、网络),CPU 大多闲着,瓶颈是I/O 速度。
I/O = Input / Output(输入 / 输出),指程序和 CPU 外部的设备打交道:读写磁盘文件、查数据库、发网络请求、读写 socket 等。只要数据不在 CPU/内存里,就要"走出去取",而走出去这一趟比 CPU 算一次慢几个数量级。
一次请求耗时 100ms,时间花在哪里决定了它的类型:
text
CPU 密集型(算 95ms,等 5ms)
时间轴 ├████████████████████████████████████████████░┤
↑ CPU 满载跑指令(加密 / 编解码 / 大循环) ↑ 少量 I/O
→ 瓶颈:核数 × 主频,加机器/加进程才有用
I/O 密集型(等 95ms,算 5ms)
时间轴 ├░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░███┤
↑ CPU 空闲,在等磁盘/数据库/HTTP 响应 ↑ 少量计算
→ 瓶颈:外部资源延迟,提高并发才有用
CPU 工作状态对照
CPU 密集:running ──────────────────────────► 很少进入阻塞等待
I/O 密集:running ─┐ ┌─ running ─┐ ┌─
└─ 阻塞/挂起 ─┘ └─ 阻塞/挂起 ─┘CPU 密集型的三个特征:
- CPU 占用率一直很高,持续消耗时间片;
- 核心动作是算数、循环、加密、解析、渲染、复杂算法;
- 磁盘、网络等待时间占比极低。
常见场景:大数运算、斐波那契递归、MD5 / SHA256 加密、图片视频编解码、矩阵运算、AI 推理、超大 JSON 解析、复杂排序。
js
// CPU 密集型:纯计算,CPU 一直在执行指令
let sum = 0
for (let i = 0; i < 1e8; i++) {
sum += i
}
// 现象:CPU 飙到接近 100%,磁盘和网络毫无动静
// 在 Node 里:这段代码会把主线程彻底堵死,事件循环停转,所有请求排队I/O 密集型的三个特征:
- 大部分时间在等外部资源返回;
- CPU 经常处于空闲 / 阻塞等待状态;
- 代码逻辑往往很简单,只是外部资源慢。
常见场景:文件与日志读写、MySQL / Redis 查询、调用第三方 HTTP 接口、文件上传下载、爬虫。
js
const fs = require('fs/promises')
// I/O 密集型:等磁盘
const log = await fs.readFile('./big.log')
// I/O 密集型:等网络
const res = await fetch('https://api.example.com/user')
const data = await res.json()
// 现象:CPU 占用很低,磁盘 / 网卡在工作
// 在 Node 里:await 期间主线程被释放,可以同时处理成百上千个请求| 类型 | 瓶颈 | CPU 状态 | 耗时大头 | 典型场景 | 适合方案 |
|---|---|---|---|---|---|
| CPU 密集型 | CPU 算力(核数/主频) | 高占用,一直在跑 | 计算本身 | 加密、音视频编解码、算法运算 | 多进程 / 多线程,避开单线程限制 |
| I/O 密集型 | 外部 I/O(磁盘/网络) | 大部分空闲在等待 | 等待资源返回 | 文件读写、数据库、HTTP 请求 | 异步非阻塞 + 提高并发 |
怎么判断自己的任务属于哪一类?
- 看 CPU 占用率:压测时 CPU 接近打满 → CPU 密集;CPU 很低但 QPS 上不去 → I/O 密集;
- 看耗时分布:在关键步骤前后打点,看时间是花在"算"还是花在"等";
- 用工具定位:
node --prof+--prof-process、clinic doctor、0x火焰图;火焰图上又宽又平的 JS 栈帧 = CPU 密集,栈里大量await/ libuv 回调且 CPU 空转 = I/O 密集。
Node 里的对症下药:
js
// CPU 密集:丢出主线程
const { Worker } = require('worker_threads') // 同进程多线程,适合纯计算
// const cluster = require('cluster') // 多进程利用多核,适合 HTTP 服务整体扩容
// const { fork } = require('child_process') // 独立进程跑脚本,隔离崩溃风险
// I/O 密集:永远用异步 API + 控制并发,别用 fs.readFileSync
const results = await Promise.all(ids.map((id) => queryUser(id))) // 并发发起
// 并发量大时要加限流(见手撕代码题 4 的并发限制调度器),避免打爆下游加粗要点: 判断标准只有一句话 —— 时间花在"算"上就是 CPU 密集,花在"等"上就是 I/O 密集。Node 的单线程事件循环天生为 I/O 密集设计(等待时主线程可以服务别人),但对 CPU 密集是致命的(一个大循环就能让整个服务失去响应)。所以I/O 密集 → 异步 + 并发;CPU 密集 →
worker_threads/cluster/ 独立进程,千万不要指望async/await能加速计算。
✨ 高级深挖知识点
1. libuv 架构与"异步 I/O"的真相
text
你的 JS 代码
│
┌────────▼────────┐
│ Node Bindings │ (C++ 层,暴露 fs/net/crypto)
└────────┬────────┘
│
┌────────▼─────────────────────────────┐
│ libuv │
│ │
│ 网络 I/O ──> epoll/kqueue/IOCP │ ← 真·内核异步,不占线程
│ 文件 I/O ──> 线程池(默认 4 线程) │ ← 其实是同步调用 + 线程模拟
│ DNS/zlib ──> 线程池 │
└──────────────────────────────────────┘| 操作类型 | 实现方式 | 是否占线程池 |
|---|---|---|
net / http 的 socket 读写 | epoll / kqueue | ❌ 不占 |
fs.readFile 等所有文件操作 | 线程池 | ✅ 占 |
dns.lookup | 线程池 | ✅ 占 |
dns.resolve | 底层网络请求 | ❌ 不占 |
zlib 异步 API、crypto.pbkdf2 | 线程池 | ✅ 占 |
js
// 验证线程池大小的经典实验
process.env.UV_THREADPOOL_SIZE = 4; // 必须在 require('fs') 之前设置
const fs = require("fs");
const start = Date.now();
for (let i = 0; i < 8; i++) {
fs.readFile(__filename, () => console.log(i, Date.now() - start));
}
// 输出会明显分成两批:前 4 个一组,后 4 个晚一截面试加分点: 很多人以为 Node 的异步全是「内核异步」,其实只有网络 I/O 是;文件 I/O 在 Linux 上因为
aio兼容性差,libuv 用的是线程池 + 阻塞调用。这解释了两件事:① 高并发读写文件时需要调大UV_THREADPOOL_SIZE;②crypto.pbkdf2、zlib这类 CPU 任务会和文件 I/O 抢同一个线程池。
2. setTimeout(fn, 0) 与 setImmediate 的顺序为什么不确定
js
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// 主模块里执行:顺序随机!可能 timeout 先,也可能 immediate 先原因:setTimeout(fn, 0) 会被 Node 强制改成 1ms。事件循环启动时如果准备工作耗时 ≥ 1ms,进入 timers 阶段时定时器已到期 → timeout 先;如果准备工作 < 1ms,timers 阶段发现还没到期 → 跳过,走到 check 阶段 → immediate 先。
js
// 但在 I/O 回调内部,顺序是 100% 确定的
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// 永远是:immediate → timeout为什么确定? I/O 回调在 poll 阶段执行,poll 的下一站就是 check 阶段(setImmediate),而 timers 要等到下一轮循环开头。所以"在 I/O 回调里 setImmediate 一定先于 setTimeout"是标准答案。
3. Stream 背压(Backpressure)完整机制
text
读得快 ──────────> 写得慢
│ │
│ 内存里堆积 │
▼ ▼
buffer 超过 highWaterMark → write() 返回 false
│
├─ 调用方应 pause() 停止读取
└─ 等 Writable 清空缓冲 → 触发 'drain' → resume() 继续读js
// ❌ 错误:忽略 write 返回值,内存会被打爆
rs.on("data", (chunk) => ws.write(chunk));
// ✅ 正确:手动处理背压
rs.on("data", (chunk) => {
if (!ws.write(chunk)) rs.pause();
});
ws.on("drain", () => rs.resume());
// ✅ 更正确:pipe 内部已经实现了上面的逻辑
rs.pipe(ws);| 对比项 | pipe | pipeline(推荐) |
|---|---|---|
| 背压处理 | ✅ 自动 | ✅ 自动 |
| 错误传递 | ❌ 不传递,需每个流单独监听 | ✅ 任一环出错统一回调 |
| 失败时销毁流 | ❌ 不销毁,文件句柄泄漏 | ✅ 自动 destroy 所有流 |
| Promise 支持 | ❌ | ✅ require('stream/promises') |
js
const { pipeline } = require("stream/promises");
await pipeline(
fs.createReadStream("in.txt"),
zlib.createGzip(),
fs.createWriteStream("out.gz")
); // 任何一环报错都会 reject,且所有流被正确关闭记死这句: 生产代码永远用
pipeline,不用pipe。pipe的错误不传递是 Node 历史上最经典的内存/句柄泄漏来源。
4. Buffer 的内存管理
text
Buffer.allocUnsafe(size)
│
├─ size < 4KB (Buffer.poolSize >>> 1)
│ └─> 从 8KB 预分配内存池切一块(快,但共享底层 ArrayBuffer)
│
└─ size >= 4KB
└─> 直接 malloc 一块独立内存| API | 是否清零 | 速度 | 安全性 |
|---|---|---|---|
Buffer.alloc(n) | ✅ | 慢 | 安全,默认选它 |
Buffer.allocUnsafe(n) | ❌ | 快 | 可能读到旧内存里的敏感数据 |
Buffer.allocUnsafeSlow(n) | ❌ | 中 | 不走内存池,适合长期持有的大 Buffer |
js
// slice 是视图而非拷贝 —— 高频坑
const big = Buffer.from("hello world");
const part = big.subarray(0, 5); // 等价于 slice,共享内存
part[0] = 0x48;
console.log(big.toString()); // 'Hello world' —— 原 Buffer 被改了!
// 需要真拷贝:Buffer.from(big.subarray(0, 5))内存泄漏陷阱: 从内存池里切出来的小 Buffer 会持有整块 8KB 内存的引用。如果你把成千上万个小
slice长期缓存起来,实际占用会远超预期。长期持有的 Buffer 应该用Buffer.from(...)做一次真实拷贝,或者用allocUnsafeSlow。
Buffer 与字符串互转有成本:
buf.toString()要做编码解析并在 V8 堆内创建新字符串。转发二进制数据(图片、文件代理)时应该全程保持 Buffer/Stream,别中途toString再Buffer.from转回去。
5. 多进程:child_process 与 cluster
| API | 是否新建 V8 | 能否执行 JS 文件 | 通信 | 典型用途 |
|---|---|---|---|---|
spawn | 否(任意命令) | — | 流(stdio) | 执行 shell / ffmpeg,输出量大 |
exec | 否 | — | 回调拿完整输出 | 简单命令,有 maxBuffer 上限(默认 1MB) |
execFile | 否 | — | 回调 | 不经过 shell,更安全防注入 |
fork | ✅ | ✅ 只能跑 JS | IPC 通道 | Node 子进程做任务隔离 |
js
// fork + IPC
const { fork } = require("child_process");
const child = fork("./worker.js");
child.send({ type: "task", payload: 42 });
child.on("message", (msg) => console.log("主进程收到", msg));
child.on("exit", (code) => console.log("退出码", code));
// worker.js
process.on("message", (msg) => {
process.send({ result: msg.payload * 2 });
});text
cluster 的端口共享原理
┌──────────────── Master ────────────────┐
│ 真正 listen(8080) 的只有主进程 │
│ accept 到连接后按 round-robin 分发 │
└───┬────────┬────────┬────────┬─────────┘
│ │ │ │ (通过 IPC 把句柄 fd 传给子进程)
Worker1 Worker2 Worker3 Worker4- Node 默认是 round-robin(主进程 accept 再分发),Windows 上默认走操作系统调度(SO_REUSEPORT 式抢占,容易不均衡)。
- 子进程里的
listen其实被 cluster 劫持了,并没有真的再绑一次端口,所以不会报EADDRINUSE。 - 生产环境通常不手写 cluster,而是用 pm2(
pm2 start app.js -i max)或容器编排横向扩容。
cluster 最大的坑:进程间不共享内存。 本地
Map缓存、express-session的 MemoryStore、限流计数器、定时任务,在多 worker 下都会出问题。缓存必须外置到 Redis,定时任务要加分布式锁或只在cluster.isPrimary里跑。
6. worker_threads 与 CPU 密集型任务
js
// main.js
const { Worker } = require("worker_threads");
function runTask(data) {
return new Promise((resolve, reject) => {
const worker = new Worker("./cpu-task.js", { workerData: data });
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0) reject(new Error(`worker 异常退出 ${code}`));
});
});
}
// cpu-task.js
const { parentPort, workerData } = require("worker_threads");
let sum = 0;
for (let i = 0; i < workerData.n; i++) sum += i;
parentPort.postMessage(sum);| 对比项 | worker_threads | child_process.fork |
|---|---|---|
| 内存开销 | 约几 MB(共享进程) | 约 30MB+(完整 Node 进程) |
| 启动耗时 | ~10ms | ~50ms+ |
| 数据传递 | 结构化克隆,可用 SharedArrayBuffer 零拷贝 | 必须 JSON 序列化 |
| 崩溃影响 | 可能影响整个进程 | 完全隔离 |
| 适用 | CPU 计算(加密、图像、大 JSON 解析) | 任务隔离、跑第三方代码 |
js
// SharedArrayBuffer + Atomics:多线程共享内存且无需拷贝
const shared = new SharedArrayBuffer(4);
const arr = new Int32Array(shared);
Atomics.add(arr, 0, 1); // 原子自增,避免竞态
Atomics.store(arr, 0, 100);
Atomics.wait(arr, 0, 100); // 阻塞等待(只能在 worker 里用,不能在主线程)工程实践: 线程不是越多越好,
new Worker的创建成本不低。正确做法是封装固定大小的线程池(线程数 ≈os.cpus().length),任务排队复用线程 —— 可以直接用成熟库piscina。另外transferList可以把ArrayBuffer所有权转移给 worker,避免大数据拷贝。
7. 循环依赖:CJS 与 ESM 的不同结局
js
// ---- CommonJS ----
// a.js
exports.name = "a";
const b = require("./b");
console.log("a 拿到 b.name =", b.name);
exports.done = true;
// b.js
const a = require("./a"); // 此时 a 还没执行完,拿到的是**半成品 exports**
console.log("b 拿到 a.done =", a.done); // undefined!
exports.name = "b";
// 入口 require('./a') 输出:
// b 拿到 a.done = undefined
// a 拿到 b.name = bjs
// ---- ES Module ----
// 由于是「引用绑定 + 变量提升」,函数声明能正常互相调用
// 但访问尚未初始化的 const/let 会抛 ReferenceError(TDZ)| 模块系统 | 循环依赖表现 | 规避手段 |
|---|---|---|
| CommonJS | 拿到不完整的 exports,静默 undefined | 把 require 延迟到函数内部执行 |
| ES Module | 函数声明 OK;变量访问抛 TDZ 报错 | 提取公共模块打破环 |
根治思路不是技巧而是设计: 出现循环依赖通常意味着模块职责划分有问题。把双方都依赖的那部分抽成第三个模块(如
types.js/constants.js),或者用依赖注入反转方向。
8. 进程退出与异常兜底
js
// ❌ process.exit() 会立刻终止,异步日志/未 flush 的写入会丢
process.on("SIGTERM", () => {
logger.info("收到退出信号"); // 可能永远写不出去
process.exit(0);
});
// ✅ 优雅退出
process.on("SIGTERM", async () => {
server.close(); // 1. 停止接收新连接
await drainInflightRequests(); // 2. 等存量请求处理完
await db.close(); // 3. 释放连接池
process.exitCode = 0; // 4. 设置退出码,让事件循环自然结束
});
// 兜底:捕获未处理异常
process.on("uncaughtException", (err, origin) => {
logger.fatal({ err, origin });
process.exit(1); // 此时进程状态已不可靠,记录后必须退出,交给 pm2/k8s 重启
});
process.on("unhandledRejection", (reason) => {
throw reason; // 转交 uncaughtException 统一处理
});| 事件/API | 要点 |
|---|---|
process.exit(code) | 立即退出,不等待 pending 的异步操作 |
process.exitCode | 只设置退出码,等事件循环空了自然退出(推荐) |
beforeExit | 事件循环空时触发,可以再发起异步,exit() 不触发它 |
exit | 只能执行同步代码 |
uncaughtException | Node 15+ 未捕获异常默认导致进程退出,不要用它"续命" |
unhandledRejection | Node 15+默认视为 uncaughtException |
9. 性能与内存排查
bash
node --inspect=0.0.0.0:9229 app.js # Chrome DevTools 远程调试
node --prof app.js # 生成 V8 profile 日志
node --prof-process isolate-*.log # 分析 CPU 热点
node --max-old-space-size=4096 app.js # 调大老生代(默认约 1.5~2GB)
node --trace-gc app.js # 打印每次 GC| 内存泄漏来源 | 症状 | 排查方式 |
|---|---|---|
| 全局变量 / 模块级 Map 缓存 | 堆持续增长不回落 | heap snapshot 对比两个时间点的增量 |
| 未移除的事件监听器 | MaxListenersExceededWarning | emitter.listenerCount() |
| 闭包持有大对象 | 老生代涨得快 | Retainers 视图看引用链 |
| 未清理的定时器 / 未关闭的流 | 句柄数增长 | process._getActiveHandles() |
js
// 常用自检
const m = process.memoryUsage();
// rss: 进程总占用 | heapTotal/heapUsed: V8 堆 | external: C++ 对象 | arrayBuffers: Buffer
// AsyncLocalStorage:异步链路透传 traceId,替代"参数层层传递"
const { AsyncLocalStorage } = require("async_hooks");
const als = new AsyncLocalStorage();
app.use((req, res, next) => als.run({ traceId: req.id }, next));
// 任意深度的异步函数里:als.getStore().traceId面试加分点: 说排查流程比说工具名更值钱 —— ① 监控发现 RSS 阶梯式上涨且 GC 后不回落;② 压测环境用
--inspect连 DevTools,间隔 5 分钟打两次 heap snapshot;③ 用 Comparison 视图按 size delta 排序找出增长最多的构造函数;④ 顺着 Retainers 找到引用链定位到具体代码;⑤ 修复后回归验证内存曲线趋于平稳。
10. 微任务家族:nextTick / queueMicrotask / Promise.then 的真实区别
很多人只知道「nextTick 优先级最高」,但 Node 里其实有两条独立的微任务队列:
每次宏任务(或每个阶段)执行完毕后:
① 清空 nextTickQueue —— 一直清到空,期间新入队的也执行
② 清空 microtaskQueue(Promise.then / queueMicrotask / async 函数恢复)
—— 同样一直清到空| API | 所属队列 | 由谁实现 | 异常未捕获时 |
|---|---|---|---|
process.nextTick | nextTickQueue | Node 自己维护 | 触发 uncaughtException |
queueMicrotask | V8 微任务队列 | V8(标准 API) | 触发 uncaughtException |
Promise.then | V8 微任务队列 | V8 | 变成 unhandledRejection |
js
process.nextTick(() => console.log("1 nextTick"));
queueMicrotask(() => console.log("2 queueMicrotask"));
Promise.resolve().then(() => console.log("3 then"));
process.nextTick(() => console.log("4 nextTick"));
// 输出:1 nextTick → 4 nextTick → 2 queueMicrotask → 3 thenqueueMicrotask 和 Promise.resolve().then 共用同一个队列,按入队顺序执行,没有谁优先;而 nextTick 整队会抢在它们之前。
js
// ✅ queueMicrotask 比 Promise.then 更适合「只想排个微任务」的场景
queueMicrotask(() => doSomething()); // 不创建 Promise,无额外分配,不吞异常
// ❌ Promise.resolve().then 会把同步抛出的错误转成 rejection 被静默
Promise.resolve().then(() => {
throw new Error("boom"); // 没有 .catch 时变成 unhandledRejection
});面试加分点: 能说出「nextTick 的递归会饿死事件循环,而 microtask 的递归同样会饿死 —— 两者都是清到空为止」,再补一句「
queueMicrotask是跨平台标准 API,写同构库时优先用它而不是process.nextTick」,深度就拉开了。
11. 定时器底层实现:为什么 setTimeout 永远不精确
Node 的定时器不是一个大的最小堆存所有 timer,而是按「超时时长」分桶:
timerListMap(按 duration 分组的 Map)
├─ 100ms 桶 → 链表 [t1 → t2 → t3] 同一桶内按插入顺序天然有序
├─ 1000ms 桶 → 链表 [t4 → t5]
└─ ...
timerListQueue(最小堆)—— 元素是「每个桶的链表头的到期时间」这样设计的好处:同超时时长的定时器入队是 O(1)(直接 append 到链表尾,不用调堆),只有桶级别才进最小堆。HTTP 服务里成千上万个相同超时时间的连接超时定时器,正是这个结构的目标场景。
js
// 不精确的三个来源
setTimeout(fn, 0); // ① 被强制改成 1ms(0 和负数都会被钳到 1)
setTimeout(fn, 2 ** 31); // ② 超过 2147483647(约 24.8 天) 溢出,立即变成 1ms 执行
// ③ 到期后还要排队:事件循环必须先从 poll 阶段回到 timers 阶段,
// 如果某个回调同步跑了 500ms,所有定时器都被推迟 500msjs
// 验证误差
const start = Date.now();
setTimeout(() => console.log("期望 100ms,实际", Date.now() - start), 100);
const end = Date.now() + 200;
while (Date.now() < end) {} // 同步阻塞 200ms
// 输出:期望 100ms,实际 200+msref / unref —— 定时器会不会「吊住」进程:
js
const t = setInterval(() => console.log("tick"), 1000);
t.unref(); // ✅ 不再计入事件循环的活跃句柄,没有其他任务时进程可以正常退出
t.ref(); // 恢复
// 典型用途:心跳、埋点上报的轮询定时器加 unref,
// 避免 CLI 工具跑完活儿却因为一个 setInterval 卡住不退出延伸:
clearTimeout并不是从堆里删除,而是把 timer 从链表摘下并标记。所以频繁 set/clear 的开销很低,不用担心性能。另外 Node 11+ 的timers/promises提供了setTimeout的 Promise 版本,且天然支持signal取消:await setTimeout(1000, undefined, { signal })。
12. Stream 的四种消费姿势与 Web Streams 互转
同一个 Readable,有四种消费方式,错误处理和背压能力完全不同:
| 姿势 | 背压 | 错误处理 | 资源清理 | 适用场景 |
|---|---|---|---|---|
on('data') 手动 | ❌ 要自己 pause/resume | 自己监听 error | 自己 destroy | 需要精细控制 |
rs.pipe(ws) | ✅ 自动 | ❌ 不传递错误 | ❌ 不销毁上游 | 简单脚本(生产慎用) |
pipeline(rs, ..., ws) | ✅ 自动 | ✅ 统一 reject | ✅ 全链路 destroy | 生产首选 |
for await (...of rs) | ✅ 自动 | ✅ try/catch 可捕获 | ✅ break 会 destroy | 需要 await 逐条处理 |
js
// 异步迭代:背压天然由 await 节奏控制,最适合「每条都要落库/调接口」
const rl = require("readline").createInterface({
input: fs.createReadStream("big.log"),
crlfDelay: Infinity, // ✅ 正确处理 \r\n,否则 Windows 日志会多出 \r
});
for await (const line of rl) {
await saveToDb(line); // 读取会自动暂停,直到这句 await 完成
}finished:判断流真正结束(含错误/提前 destroy)
js
const { finished } = require("stream/promises");
const rs = fs.createReadStream("a.txt");
rs.resume(); // 只想消费掉不关心内容
await finished(rs); // ✅ 比监听 'end' 可靠:error、close、aborted 都会被处理end 事件在流被 destroy() 提前关闭时不会触发,所以「读完再删文件」这种逻辑写在 end 里会漏;finished 才是正解。
Web Streams 互转(Node 17+) —— 原生 fetch 返回的 res.body 是 Web ReadableStream,不是 Node Readable:
js
const { Readable, Writable } = require("stream");
// ① Web → Node:把 fetch 响应体落盘
const res = await fetch(url);
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream("out.bin"));
// ② Node → Web:把 Node 流交给需要 Web Streams 的 API(如 Response 构造器)
const webStream = Readable.toWeb(fs.createReadStream("a.txt"));
return new Response(webStream);易混淆点:
res.body只能被消费一次,await res.json()之后再去读res.body会报Body is unusable。需要既读 JSON 又留原始数据时用res.clone()。
13. AbortController:统一的异步取消方案
Node 15+ 把浏览器的 AbortController 搬了过来,现在几乎所有异步 API 都接受 signal,这是取代「超时后让回调空转」的标准做法:
js
const ac = new AbortController();
const { signal } = ac;
// ① fetch
const res = await fetch(url, { signal });
// ② 文件读写
await fs.promises.readFile("a.txt", { signal });
// ③ 流水线:取消时整条链路都会被 destroy
await pipeline(rs, gzip, ws, { signal });
// ④ 等事件:避免「事件永不触发导致 Promise 悬挂」
const [data] = await require("events").once(emitter, "data", { signal });
// ⑤ 定时器
await require("timers/promises").setTimeout(5000, null, { signal });
ac.abort(); // 以上全部立刻 reject AbortError两个语法糖(Node 17.3+ / 18+):
js
// 超时自动 abort
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
// 多个信号任一触发就取消(请求取消 or 全局超时)
const signal = AbortSignal.any([reqSignal, AbortSignal.timeout(3000)]);js
// ✅ 正确区分「用户取消」和「真错误」
try {
await fetch(url, { signal });
} catch (e) {
if (e.name === "AbortError") return; // 取消不是异常,不要上报告警
throw e;
}面试加分点: 补一句「
AbortSignal本质是个 EventEmitter,所以一个长生命周期的 signal 被挂上成千上万个 abort 监听会内存泄漏,Node 对此有MaxListenersExceededWarning。正确做法是每个请求一个 controller,请求结束后解绑」—— 这是真实踩过坑才会讲的细节。
14. 模块互操作深水区:条件导出、双包危险、require(esm)
exports 字段:现代包的唯一入口声明
json
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.mjs",
"./package.json": "./package.json"
}
}三条铁律:
- 顺序敏感:
types必须放最前,否则 TS 解析不到类型。 exports一旦存在就是封锁式的 —— 没列出来的路径,外部require('my-lib/internal/foo')会直接报ERR_PACKAGE_PATH_NOT_EXPORTED。很多「升级依赖后深层引入全挂」就是这个原因。main只是给老 Node / 打包器的 fallback,不能删。
双包危险(Dual Package Hazard)
应用 ESM 入口 → import 'my-lib' → 加载 index.mjs → 实例 A
某个 CJS 依赖 → require('my-lib') → 加载 index.cjs → 实例 B同一个包被加载了两份,模块级单例、instanceof 判断、全局注册表全部失效:
js
//❌ 典型症状
import { Registry } from "my-lib"; // 实例 A 的注册表
// 某个 CJS 中间件往实例 B 的注册表里注册 → A 里查不到
err instanceof MyLibError; // false,因为类来自另一份副本| 规避手段 | 说明 |
|---|---|
| 只发 ESM 单产物 | 最彻底,但断掉 CJS 用户 |
状态外置到 globalThis | 两份代码共享同一个 Symbol key 的全局状态 |
CJS 产物只做 wrapper 转发 | 真实现只有一份(需运行时支持 require(esm)) |
用 error.code 替代 instanceof | 跨副本安全的错误判别 |
Node 22.12+ / 23+:require() 可以直接加载 ESM 了
js
// 条件:目标 ESM 不含顶层 await(含则抛 ERR_REQUIRE_ASYNC_MODULE)
const mod = require("./esm-module.mjs"); // 返回 module namespace 对象
mod.default; // 默认导出不会被自动展开,要自己取 .default这是未来双包问题的终极解法,但目前仍要考虑低版本兼容。
15. 进程生命周期:孤儿、僵尸与零停机重启
stdio 与 detached —— 子进程为什么跟着父进程一起死
js
const { spawn } = require("child_process");
// ❌ 默认:子进程与父进程共享生命周期组,父挂了子也收到信号
spawn("node", ["worker.js"]);
// ✅ 真正后台常驻:脱离进程组 + 重定向 stdio + unref
const child = spawn("node", ["worker.js"], {
detached: true,
stdio: "ignore", // 或 ['ignore', fd, fd] 写入日志文件
});
child.unref(); // 不再吊住父进程的事件循环stdio: 'pipe'(默认)还有个隐藏坑:管道缓冲区满了子进程就会阻塞。如果父进程不读 child.stdout,一个输出量大的子进程会卡死在写 stdout 上,表现为「任务跑一半没了」。
| 概念 | 定义 | 后果与处理 |
|---|---|---|
| 孤儿进程 | 父进程先退出,子进程被 init(PID 1) 收养 | 一般无害,但会变成「杀不掉的残留进程」占端口 |
| 僵尸进程 | 子进程已退出,父进程没回收退出状态 | 占着 PID 表项;Node 会自动 wait,但 detached + 父进程卡死时会残留 |
js
// ✅ 父进程退出前确保清理子进程
const children = new Set();
process.on("exit", () => children.forEach((c) => c.kill("SIGTERM")));cluster 零停机滚动重启(线上发布的核心能力)
① 主进程收到 SIGHUP/自定义信号
② 逐个处理 worker:fork 新 worker → 等它 'listening'
③ 对老 worker 发 disconnect() → 它停止 accept 新连接,处理完存量后自然退出
④ 设置强杀兜底:N 秒后还没退出就 kill('SIGKILL')
⑤ 全部替换完成,整个过程端口一直在监听,外部无感js
// 关键点:必须等新 worker listening 再摘老的,否则有瞬间容量空洞
function restartOne(old) {
return new Promise((resolve) => {
const fresh = cluster.fork();
fresh.once("listening", () => {
old.disconnect();
const timer = setTimeout(() => old.kill("SIGKILL"), 10000);
old.once("exit", () => (clearTimeout(timer), resolve()));
});
});
}面试加分点: 指出「
worker.kill()其实是发信号,而worker.disconnect()才是优雅摘流量」,再补一句「K8s 环境下这套逻辑大多由 Pod 滚动更新 +preStop钩子 + readiness 探针接管,应用层只要实现好 SIGTERM 优雅退出即可」—— 能体现你清楚职责边界在哪。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:说说 Node 的事件循环,它和浏览器的事件循环有什么区别?
精简口述答案:
Node 的事件循环由 libuv 实现,一轮分 6 个阶段:timers 执行到期定时器、pending callbacks 处理上轮遗留的系统 I/O 回调、idle/prepare 内部使用、poll 取 I/O 事件并执行回调(这是核心,队列空时会阻塞等待)、check 执行 setImmediate、close callbacks 处理关闭事件。
和浏览器的区别主要有三点:第一,浏览器只有宏任务和微任务两个概念,Node 多了明确的阶段划分和
setImmediate;第二,Node 多了一个优先级最高的process.nextTick队列,它在每个阶段结束时先于 Promise 微任务被清空;第三,早期 Node(10 以前)是一个阶段跑完所有宏任务才清微任务,Node 11 之后对齐了浏览器,改成每执行完一个宏任务就清一次微任务队列,所以现在两边输出顺序基本一致了。
连环追问链:
- 追问:
setTimeout(fn, 0)和setImmediate谁先执行?- 答:在主模块里不确定。因为
setTimeout(fn, 0)会被改成 1ms,事件循环启动的准备耗时是否超过 1ms 决定了进入 timers 时定时器有没有到期。但在 I/O 回调内部是确定的:一定是setImmediate先 —— 因为 I/O 回调在 poll 阶段执行,紧接着就是 check 阶段,而 timers 要等下一轮。
- 答:在主模块里不确定。因为
- 追问:
process.nextTick为什么优先级最高?滥用会有什么后果?- 答:它不属于事件循环的任何阶段,而是在每个阶段切换时被完全清空,且清空过程中新入队的也会一并执行。所以递归调用
nextTick会饿死事件循环,I/O 永远得不到执行。库里需要"延迟到当前操作之后"的场景应该优先用setImmediate。
- 答:它不属于事件循环的任何阶段,而是在每个阶段切换时被完全清空,且清空过程中新入队的也会一并执行。所以递归调用
- 追问:poll 阶段什么时候会阻塞?
- 答:poll 队列为空时,如果有
setImmediate待执行就立刻进 check;否则看最近的 timer 还有多久到期,阻塞到那个时刻;如果连 timer 都没有,就一直阻塞等新的 I/O 事件。这就是空闲 Node 进程 CPU 占用接近 0 的原因。
- 答:poll 队列为空时,如果有
- 追问:Node 是单线程的,为什么能处理高并发?
- 答:单线程指的是执行 JS 的线程只有一条。底层 libuv 对网络 I/O 用 epoll/kqueue 这种内核事件通知机制,一条线程可以管理上万连接;文件 I/O、DNS、zlib 则交给默认 4 条的线程池。所以 Node 擅长 I/O 密集,但一段 CPU 密集的同步代码会直接堵死整个服务。
- 追问:如果有一段 CPU 密集型计算,怎么办?
- 答:拆成小块用
setImmediate分片让出主线程;或者丢给worker_threads(推荐用 piscina 这种线程池,避免频繁创建);量大的话直接拆成独立服务或用 C++ addon / WASM 加速。
- 答:拆成小块用
✅ 项目关联话术(按真实经历替换):
我在做 XX 服务的导出功能时踩过这个坑。原来是在接口里同步做大 JSON 的序列化和加密,QPS 一上来接口整体 RT 从 50ms 涨到 2s,火焰图看下来主线程被这段同步计算占满了。我们把加密部分迁到了
worker_threads线程池里,并把序列化改成流式输出,RT 回落到 80ms 以内,而且 P99 的波动也明显变小。
题 2:Stream 有哪几种?什么是背压,怎么处理?
精简口述答案:
Stream 有四类:Readable 可读流、Writable 可写流、Duplex 双工流(读写两条独立通道,比如 socket)、Transform 转换流(Duplex 的特例,输出由输入转换而来,比如 gzip、加解密)。
背压指的是生产速度大于消费速度时数据在内存里堆积。Writable 内部有个
highWaterMark水位线,write()返回false就代表缓冲区已满,此时调用方应该pause()暂停读取,等 Writable 触发drain事件再resume()。pipe内部已经实现了这套逻辑,所以手写data事件转发时最容易出问题。但生产上我一定用
pipeline而不是pipe,因为pipe不会传递错误,中间任何一个流出错,其他流不会被销毁,会造成文件句柄和内存泄漏;pipeline会统一回调错误并自动 destroy 所有流。
连环追问链:
- 追问:
fs.readFile和fs.createReadStream有什么区别,什么时候必须用流?- 答:
readFile把整个文件读进内存再回调,内存占用等于文件大小,且单个 Buffer 有上限(buffer.constants.MAX_LENGTH,64 位机约 4GB,老版本 2GB),超了直接抛错。流按highWaterMark(文件流默认 64KB)分片,内存恒定,还能边读边处理降低首字节延迟。只要文件大小不可控(用户上传、日志、导出),就必须用流。
- 答:
- 追问:
highWaterMark调大调小有什么影响?- 答:调大减少系统调用次数、吞吐更高,但单次内存占用和延迟变大;调小内存更省、响应更快,但系统调用频繁、CPU 开销上升。默认值是经验平衡值:文件流 64KB,object mode 下是 16 个对象。
- 追问:为什么流式读取中文会乱码?
- 答:一个 UTF-8 中文占 3 字节,chunk 边界可能把它切开,单独
toString()就得到「�」。要用string_decoder的StringDecoder,它会把不完整的尾部字节缓存到下一次write再拼接。
- 答:一个 UTF-8 中文占 3 字节,chunk 边界可能把它切开,单独
- 追问:Transform 流怎么实现?
_transform里的callback必须调用吗?- 答:继承
Transform实现_transform(chunk, enc, cb),用this.push()输出数据,处理完必须调cb(),否则流会卡住不再读取下一个 chunk。另外还有_flush(cb),用于在流结束前把缓存的残余数据 push 出去(比如按行分割时的最后一行)。
- 答:继承
- 追问:object mode 是什么?
- 答:默认流只能传 Buffer/string,开
objectMode: true后可以传任意 JS 对象,此时highWaterMark的单位从字节变成个数。常用于把数据库游标、CSV 行记录做成流水线处理。
- 答:默认流只能传 Buffer/string,开
✅ 项目关联话术(按真实经历替换):
我们有个百万级数据导出 Excel 的需求,最初的实现是全量查库 → 内存里拼数组 → 生成文件返回,数据量一大就 OOM,容器被 OOMKilled。我重构成了流水线:数据库游标流 → Transform 转 CSV 行 → gzip → 响应流,全程用
pipeline串起来并加了错误兜底。改完以后内存从峰值 1.5G 降到稳定 80M,导出 200 万行也不会挂,而且用户能立刻看到下载进度条动起来。
题 3:CommonJS 和 ES Module 有什么区别?
精简口述答案:
核心区别有四点。第一,加载时机:CJS 是运行时同步加载,
require可以写在 if 里、拼接路径;ESM 是编译期静态分析依赖,import必须在顶层,所以才能做 tree-shaking。第二,导出值的性质:CJS 导出的是值的拷贝(原始类型改了外部感知不到),ESM 导出的是引用绑定(live binding),模块内部变量变化外部能看到,而且导入的变量是只读的。第三,this 和内置变量:CJS 顶层this是module.exports,有__dirname、__filename、require;ESM 顶层this是undefined,只有import.meta.url。第四,循环依赖的表现:CJS 会拿到不完整的exports,表现为 undefined;ESM 由于变量提升,函数能互相调用,但访问未初始化的const/let会抛 TDZ 错误。
连环追问链:
- 追问:为什么
exports = {...}不生效,module.exports = {...}就可以?- 答:因为模块包装函数的参数里,
exports只是module.exports的一个引用别名。给exports整体重新赋值只是改了局部变量的指向,真正被返回的module.exports没变。所以挂属性可以用exports.a = 1,整体替换必须用module.exports = ...。
- 答:因为模块包装函数的参数里,
- 追问:Node 里怎么区分一个文件是 CJS 还是 ESM?
- 答:看三个依据:扩展名
.mjs强制 ESM、.cjs强制 CJS;.js看最近的package.json里"type"字段,module走 ESM,缺省或commonjs走 CJS。库的exports字段还能用条件导出给两种环境提供不同入口。
- 答:看三个依据:扩展名
- 追问:两者能互相引用吗?
- 答:ESM 里可以
importCJS,整个module.exports会作为 default 导出,具名导入靠 Node 的静态分析"尽力而为",复杂场景可能失败。反过来 CJS 不能同步requireESM(ESM 是异步加载的),只能用await import()动态导入。
- 答:ESM 里可以
- 追问:ESM 里怎么拿
__dirname?- 答:
import { fileURLToPath } from 'url'配合path.dirname(fileURLToPath(import.meta.url))。Node 20.11+ 已经提供了import.meta.dirname和import.meta.filename。
- 答:
- 追问:require 的缓存机制是什么?怎么清缓存?
- 答:每个模块第一次加载后,解析出的绝对路径作为 key 存进
require.cache,之后直接返回同一个module.exports,这也是单例模式的实现基础。delete require.cache[require.resolve('./m')]可以清掉,常用于单测和开发热更新。ESM 的模块缓存没有官方清除手段,只能用加 query 参数这种 hack。
- 答:每个模块第一次加载后,解析出的绝对路径作为 key 存进
✅ 项目关联话术(按真实经历替换):
我们在把老项目迁到 ESM 时集中踩了这几个坑:
__dirname全部报未定义、几个只提供 CJS 的老依赖在 ESM 下具名导入失败、jest 的 mock 因为 ESM 静态分析失效。最后方案是先用 tsup 做双产物(同时输出 cjs 和 esm),内部代码统一 ESM 并用import.meta.url替换__dirname,测试框架换成原生支持 ESM 的 vitest,平滑过渡完成。
题 4:进程和线程的区别?Node 里 cluster 和 worker_threads 怎么选?
精简口述答案:
进程是操作系统资源分配的基本单位,有独立的内存空间;线程是 CPU 调度的基本单位,同一进程内的线程共享内存。所以进程间通信要走 IPC/socket,开销大但隔离性好;线程间可以共享内存,快但要处理竞态。
在 Node 里:cluster(多进程)用来利用多核扛 I/O 并发,它让多个 worker 进程共享同一个监听端口,主进程 accept 后按 round-robin 把连接句柄分发下去,适合 HTTP 服务横向扩展,一个 worker 崩了不影响其他。worker_threads(多线程)用来解决 CPU 密集型任务,它在同进程内创建独立的 V8 实例和事件循环,启动快、内存省,还能通过
SharedArrayBuffer零拷贝共享数据。选型原则:扛并发用 cluster 或者直接交给 pm2 / k8s 多副本;解决单个请求里的重计算用 worker_threads。
连环追问链:
- 追问:cluster 为什么多个进程 listen 同一个端口不报 EADDRINUSE?
- 答:因为子进程的
listen被 cluster 模块劫持了,它并没有真的 bind 端口,而是向主进程发消息,由主进程持有真正的 listening socket,accept 到连接后通过 IPC 把文件描述符传给子进程。Windows 下走的是另一套(句柄共享由系统调度),所以负载可能不均衡。
- 答:因为子进程的
- 追问:cluster 下有什么东西是不能用的?
- 答:所有依赖进程内状态的东西:内存缓存、
express-session的 MemoryStore、限流计数、全局自增 ID、定时任务。缓存和 session 必须外置到 Redis,定时任务要加分布式锁或只在主进程里跑,WebSocket 场景还需要 sticky session 或 Redis 适配器做跨进程广播。
- 答:所有依赖进程内状态的东西:内存缓存、
- 追问:worker_threads 之间怎么传数据,有什么性能陷阱?
- 答:默认用
postMessage,走结构化克隆,大对象的序列化开销很大。优化手段有两个:用transferList把ArrayBuffer的所有权转移(零拷贝,但原线程里就不能再用了);或者用SharedArrayBuffer真正共享内存,配合Atomics做同步避免竞态。
- 答:默认用
- 追问:worker 数量开多少合适?
- 答:CPU 密集任务按
os.cpus().length来,开更多只会增加上下文切换。而且不要每个任务都new Worker,创建成本在 10ms 量级,应该封装固定大小的线程池复用(可以直接用 piscina)。注意容器环境里os.cpus()返回的是宿主机核数,要读 cgroup 的 limit 或用os.availableParallelism()。
- 答:CPU 密集任务按
- 追问:
child_process的 spawn / exec / execFile / fork 怎么区分?- 答:
spawn流式返回输出,适合大量输出的长任务;exec起 shell 并一次性回调完整输出,有 1MB 的maxBuffer限制,且拼接用户输入有命令注入风险;execFile不经过 shell,更安全;fork是 spawn 的特例,专门跑 Node 脚本并自动建立 IPC 通道。
- 答:
✅ 项目关联话术(按真实经历替换):
我们的 Node BFF 服务原来是单进程,4 核的容器只用得上 1 核。改造时用 pm2 的 cluster 模式按核数起实例,吞吐直接提升了 3 倍多。但上线后发现两个问题:一是本地内存缓存命中率掉到 1/4,改成了 Redis;二是定时对账任务被执行了 4 次,加了只在
NODE_APP_INSTANCE === '0'时启动的判断。另外有个海报合成的接口是 CPU 密集的,单独迁到了 worker 线程池里,避免它拖慢同进程的其他请求。
题 5:线上 Node 服务内存持续上涨,你怎么排查?
精简口述答案:
我会按四步走。第一步确认现象:看监控里的 RSS 和 heapUsed 曲线,如果是阶梯式上涨且 GC 后不回落,基本可以确定是泄漏而不是正常波动;同时看
external和arrayBuffers,如果是这两项涨,说明是 Buffer 或 C++ 层对象没释放。第二步抓快照:在压测环境或低峰期用
--inspect连 Chrome DevTools,间隔几分钟打两次 heap snapshot,用 Comparison 视图按 size delta 排序,看哪个构造函数的实例数一直在涨。第三步定位引用链:选中可疑对象看 Retainers,顺着引用链找到是哪个模块级变量或闭包在持有它。
第四步对症修复:最常见的四类来源是模块级 Map/数组无限增长(改成 LRU 并设上限和 TTL)、事件监听器没
off(尤其是在请求级别反复on)、未清理的定时器和未关闭的流/连接、以及闭包意外捕获大对象。修完一定要回归压测对比内存曲线。
连环追问链:
- 追问:
process.memoryUsage()几个字段分别是什么意思?- 答:
rss是进程实际占用的物理内存总量;heapTotal/heapUsed是 V8 堆的分配量和使用量;external是 V8 管理的 C++ 对象内存;arrayBuffers是 ArrayBuffer 和 Buffer 占用。Buffer 分配在堆外,所以 heapUsed 正常但 rss 飙高时要重点查 Buffer。
- 答:
- 追问:
MaxListenersExceededWarning是什么原因?- 答:同一个 EventEmitter 上同一事件注册超过 10 个监听器时的告警。它是泄漏的强信号 —— 通常是在每次请求/每次循环里重复
on却没off。正确做法是用once、在结束时removeListener,确实需要多个才用setMaxListeners调高。
- 答:同一个 EventEmitter 上同一事件注册超过 10 个监听器时的告警。它是泄漏的强信号 —— 通常是在每次请求/每次循环里重复
- 追问:Node 的内存上限是多少,怎么调?
- 答:老生代默认约 1.5~2GB(新版本会根据系统内存自适应),用
--max-old-space-size=4096调整。但调大只是缓解,如果是泄漏迟早还会满。容器里要注意这个值要小于容器的 memory limit,否则会先被 OOMKilled 而不是抛 JS 堆溢出错误。
- 答:老生代默认约 1.5~2GB(新版本会根据系统内存自适应),用
- 追问:怎么发现是哪段代码阻塞了事件循环?
- 答:监控事件循环延迟(
perf_hooks的monitorEventLoopDelay,或用setInterval对比预期间隔),延迟高说明有同步阻塞。然后用--prof+--prof-process或 clinic flame 做 CPU 火焰图,找出占用最高的同步函数。
- 答:监控事件循环延迟(
- 追问:
AsyncLocalStorage是干什么的?- 答:它基于
async_hooks,能在整条异步调用链上透传上下文,不需要把 traceId、用户信息一层层当参数传。典型用法是在中间件里als.run({ traceId }, next),之后任意深度的异步函数里als.getStore()都能拿到。日志串联、全链路追踪、多租户隔离都靠它。
- 答:它基于
✅ 项目关联话术(按真实经历替换):
我们有次线上容器每隔两三天就被 OOMKilled 重启一次。排查时先从监控确认 heapUsed 是阶梯上涨的,然后在预发环境压测并打了两次 heap snapshot 对比,发现一个
Map实例的 retained size 一直在涨 —— 是一个用来做接口防重的模块级 Map,只写不删。修复方案是换成带 TTL 和容量上限的 LRU,并把这类缓存统一挪到 Redis。同时我们补了事件循环延迟和内存的告警,这类问题现在能在影响用户之前就发现。
题 6:下面这段代码的输出顺序是什么?
js
console.log("1 同步");
setTimeout(() => {
console.log("2 timeout");
process.nextTick(() => console.log("3 timeout 内 nextTick"));
Promise.resolve().then(() => console.log("4 timeout 内 then"));
}, 0);
setImmediate(() => {
console.log("5 immediate");
process.nextTick(() => console.log("6 immediate 内 nextTick"));
});
process.nextTick(() => console.log("7 nextTick"));
queueMicrotask(() => console.log("8 queueMicrotask"));
Promise.resolve().then(() => console.log("9 then"));
(async () => {
console.log("10 async 函数同步部分");
await null;
console.log("11 await 之后");
})();
console.log("12 同步结束");精简口述答案:
输出顺序是:
1 → 10 → 12 → 7 → 8 → 9 → 11 → 2/5 → ...推导分三步。第一步跑完所有同步代码:
1 同步、10 async 函数同步部分(async 函数在遇到第一个 await 前是同步执行的)、12 同步结束。第二步清微任务,先清 nextTick 队列得到
7;再按入队顺序清 V8 微任务队列:8 queueMicrotask、9 then、11 await 之后(await null等价于排一个微任务,它排在 8、9 之后)。第三步进事件循环:
2 timeout和5 immediate的相对顺序不确定 ——setTimeout(0)被钳为 1ms,取决于事件循环启动准备是否超过 1ms。但无论谁先,它内部的 nextTick 和 then 都会在该宏任务执行完立刻被清空(Node 11+ 行为),所以一定是2 → 3 → 4连在一起、5 → 6连在一起,不会交叉。
连环追问链:
- 追问:为什么
11 await 之后排在9 then后面?- 答:
await null会把后续代码包装成一个微任务入队,入队时机是执行到await那一刻。而这行代码在queueMicrotask和Promise.then之后才执行,所以入队更晚。如果把 IIFE 挪到最前面,11就会跑在8、9前面。
- 答:
- 追问:如果把
2 timeout和5 immediate的代码挪进一个fs.readFile回调里,顺序还不确定吗?- 答:确定,一定是
setImmediate先。因为 I/O 回调在 poll 阶段执行,紧接着就进 check 阶段跑 setImmediate,而 timers 要等下一轮循环。
- 答:确定,一定是
- 追问:Node 10 和 Node 11 跑这段代码结果会不一样吗?
- 答:如果有多个同类宏任务会不一样。Node 10 及之前是一个阶段的所有宏任务跑完才清一次微任务,所以两个
setTimeout里的 then 会被攒到最后一起输出;Node 11 对齐浏览器,改成每个宏任务后都清一次。面试里能提到这个版本分界点是加分项。
- 答:如果有多个同类宏任务会不一样。Node 10 及之前是一个阶段的所有宏任务跑完才清一次微任务,所以两个
- 追问:
queueMicrotask和Promise.resolve().then有区别吗?- 答:队列是同一个,顺序按入队。区别在异常处理:
queueMicrotask里抛错走uncaughtException,then里抛错变成unhandledRejection;另外queueMicrotask不创建 Promise 对象,开销更小,且是浏览器/Node 通用的标准 API。
- 答:队列是同一个,顺序按入队。区别在异常处理:
- 追问:怎么证明 nextTick 会饿死事件循环?
- 答:写
function loop() { process.nextTick(loop) }再配一个setTimeout(() => console.log('never'), 0),会发现定时器永远不触发,CPU 100%。换成setImmediate(loop)则定时器能正常执行 —— 因为 setImmediate 属于 check 阶段,每轮只跑当轮入队的那一批。
- 答:写
✅ 项目关联话术(按真实经历替换):
我们有个接口偶发超时,排查发现是日志中间件里用
process.nextTick做批量 flush,在高 QPS 下递归入队把事件循环压死了,表现是 CPU 跑满但没有任何 I/O 进展。定位手段是用perf_hooks的monitorEventLoopDelay发现循环延迟飙到秒级。修复方式是把 flush 改成setImmediate+ 定长攒批,循环延迟从 1200ms 回落到 2ms 以内。
题 7:一个请求要调三个下游接口,怎么做超时和取消?
精简口述答案:
现在的标准答案是
AbortController,不要再用Promise.race配 setTimeout 那套老写法。
Promise.race的问题是只能让调用方提前拿到结果,底层请求还在跑 —— 连接不释放、响应回来还会继续走后续逻辑、定时器也没清。而AbortController是把取消信号真正传到底层:fetch、fs、pipeline、events.once、timers/promises全部接受signal参数,abort 之后底层会真的断开连接并 reject 一个AbortError。实践上我会做三层:单次调用用
AbortSignal.timeout(3000)做超时;整个请求挂一个 controller,客户端断开(req.on('close'))时 abort 掉所有在途下游调用;用AbortSignal.any([reqSignal, AbortSignal.timeout(5000)])把「用户取消」和「整体超时」合并成一个信号往下传。
连环追问链:
- 追问:
Promise.race加 setTimeout 的写法具体有什么问题?- 答:三个泄漏。① 底层请求继续占着连接池名额,高并发下连接池被耗尽;② 那个
setTimeout不会被清掉,在超时时间内一直吊着事件循环(CLI 场景会导致进程不退出);③ 后到的响应仍然会触发后续的.then,可能重复写库。
- 答:三个泄漏。① 底层请求继续占着连接池名额,高并发下连接池被耗尽;② 那个
- 追问:
AbortError要不要上报告警?- 答:要区分。用户主动取消、客户端断开属于正常流量,应该只打 info 日志不计入错误率;而
AbortSignal.timeout触发的超时是真问题,要计入指标。判断方式是看err.name === 'AbortError'再结合是哪个 signal 触发的(可以给 controller 的abort(reason)传入原因,从signal.reason读出来)。
- 答:要区分。用户主动取消、客户端断开属于正常流量,应该只打 info 日志不计入错误率;而
- 追问:abort 之后已经写了一半的文件怎么办?
- 答:
pipeline带 signal 时会把链路上所有流destroy(),但已经落盘的部分不会回滚。所以写文件一定要「先写临时文件,成功后再 rename」—— rename 在同分区是原子操作,这样不会留下半截的脏文件。
- 答:
- 追问:signal 用在 EventEmitter 上有什么坑?
- 答:
AbortSignal本身是个事件目标,每次把它传给events.once、addEventListener都会挂一个 abort 监听。一个长生命周期的 signal(比如全局超时 signal)被成千上万个请求复用,监听器会不断累积,先告警MaxListenersExceededWarning然后内存泄漏。正确做法是每个请求一个 controller,请求结束就丢弃。
- 答:
- 追问:下游是 gRPC 或数据库查询,不支持 signal 怎么办?
- 答:找库自己的取消机制手动桥接,比如在
signal.addEventListener('abort', () => call.cancel())里调用 gRPC 的 cancel、MySQL 的connection.destroy()。桥接时记得用{ once: true }并在正常完成后removeEventListener,否则又回到上一个问题。
- 答:找库自己的取消机制手动桥接,比如在
✅ 项目关联话术(按真实经历替换):
我们网关层有个聚合接口,下游一个慢服务偶发 30 秒不返回,导致上游 Nginx 超时但我们的 Node 进程里还堆着一堆在途请求,连接池被打满后整个服务雪崩。改造方案是统一封装了一个
request工具,内部用AbortSignal.any合并请求级 signal 和 3 秒超时 signal,同时在路由层监听req.on('close')触发 abort。上线后在途请求数从峰值几千降到几十,连接池再没被打满过。
题 8:你们的 Node 服务怎么做到发布不掉请求?
精简口述答案:
核心是优雅退出,它分三步:收到 SIGTERM 后 ① 先摘流量(让健康检查返回不健康 / 调用
server.close()停止 accept 新连接);② 等存量请求处理完(配合 keep-alive 连接的主动关闭,否则长连接会一直挂着);③ 关闭数据库连接池、MQ 消费者、flush 日志,最后退出。全程要有一个强制退出的兜底定时器,避免某个请求卡住导致进程永不退出被 SIGKILL。这里最容易漏的是第一步和第二步之间要有等待期。因为负载均衡/K8s 的 Endpoints 更新是异步的,你立刻
server.close()的时候上游可能还在往你这儿打流量,结果就是一批连接被拒。正确做法是先把 readiness 探针置为失败,sleep 几秒(或配preStop钩子 sleep),等上游摘干净了再关 server。在 cluster 模式下还要再加一层:主进程逐个替换 worker,先 fork 新的并等它
listening,再对老 worker 调disconnect()优雅摘流量,这样整个滚动过程端口始终在监听。
js
let shuttingDown = false;
app.get("/healthz", (req, res) =>
shuttingDown ? res.status(503).end() : res.send("ok")
);
async function shutdown() {
if (shuttingDown) return; // ✅ 幂等,SIGTERM 可能连发
shuttingDown = true;
const force = setTimeout(() => process.exit(1), 15000); // 兜底强退
force.unref();
await sleep(5000); // ① 等上游摘流量(K8s 里可用 preStop 代替)
await new Promise((r) => server.close(r)); // ② 停止 accept + 等存量完成
await Promise.allSettled([db.end(), redis.quit(), mq.close()]); // ③ 关资源
await logger.flush();
process.exit(0);
}
["SIGTERM", "SIGINT"].forEach((s) => process.on(s, shutdown));连环追问链:
- 追问:
server.close()之后为什么有时进程还是不退出?- 答:
close()只停止接受新连接,已建立的 keep-alive 空闲连接不会被关掉,它的回调要等所有连接断开才触发。解决办法是 Node 18.2+ 直接用server.closeIdleConnections()关掉空闲连接,再配server.closeAllConnections()做超时兜底;老版本要自己维护一个连接 Set 并设置res.setHeader('Connection', 'close')让客户端主动断。
- 答:
- 追问:为什么不用
process.exit()直接退?- 答:
process.exit是立刻终止,不等任何异步操作。在途的日志写入、埋点上报、事务提交全部丢失,日志里会出现「请求进来了但没有结果」的断点。正确做法是设置process.exitCode让事件循环自然耗尽,或者像上面那样把清理做完再退。
- 答:
- 追问:K8s 环境下这套逻辑还需要吗?
- 答:需要,但职责要分清。K8s 负责发信号和摘 Endpoints(
preStop钩子 + readiness 探针 +terminationGracePeriodSeconds),应用只需保证「收到 SIGTERM 后在 grace period 内干净退出」。注意应用的强退兜底时间必须小于terminationGracePeriodSeconds(默认 30 秒),否则会先被 SIGKILL,清理逻辑白写。
- 答:需要,但职责要分清。K8s 负责发信号和摘 Endpoints(
- 追问:
uncaughtException之后应该继续跑还是退出?- 答:必须退出。未捕获异常意味着进程状态已经不可信(可能有半完成的事务、泄漏的句柄),继续服务会产生更难排查的脏数据。标准做法是:在 handler 里同步记录好日志和告警,然后走优雅退出流程,由 PM2/K8s 拉起新进程。千万不要在 handler 里 return 当作无事发生。
- 追问:定时任务(cron)在多实例下怎么保证只跑一次?
- 答:分布式锁是通用解法(Redis SETNX + 过期时间 + 续租)。轻量场景可以只在 cluster 主进程或
NODE_APP_INSTANCE === '0'的实例上启动,但这在多 Pod 场景下无效。另外优雅退出时要注意:正在执行的定时任务要等它跑完或主动释放锁,否则锁要等过期才释放,下个周期会被跳过。
- 答:分布式锁是通用解法(Redis SETNX + 过期时间 + 续租)。轻量场景可以只在 cluster 主进程或
✅ 项目关联话术(按真实经历替换):
我们之前每次发布监控上都有一波 502 毛刺,大概影响几百个请求。排查发现两个问题:一是容器收到 SIGTERM 后直接退出,没有任何优雅处理;二是即使加了
server.close(),上游还在往这个 Pod 打流量。最终方案是加了preStop钩子 sleep 5 秒配合 readiness 探针先摘流量,应用层实现幂等的 shutdown 流程(关 server → 关连接池 → flush 日志),并把强退兜底设成 15 秒小于 grace period 的 30 秒。上线后发布期间的 5xx 彻底归零。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:实现大文件拷贝(手动处理背压 + pipeline 版本)
核心实现:
js
const fs = require("fs");
const { pipeline } = require("stream/promises");
// 方案一:手动处理背压(面试要求手写时考这个)
function copyManual(src, dest) {
return new Promise((resolve, reject) => {
const rs = fs.createReadStream(src, { highWaterMark: 64 * 1024 });
const ws = fs.createWriteStream(dest);
rs.on("data", (chunk) => {
// write 返回 false 表示写缓冲已超过 highWaterMark
if (!ws.write(chunk)) rs.pause();
});
// 缓冲排空后恢复读取
ws.on("drain", () => rs.resume());
rs.on("end", () => ws.end());
ws.on("finish", resolve);
// 任一端出错,都要销毁另一端,避免句柄泄漏
rs.on("error", (err) => {
ws.destroy();
reject(err);
});
ws.on("error", (err) => {
rs.destroy();
reject(err);
});
});
}
// 方案二:生产推荐 —— pipeline 自动处理背压 + 错误 + 销毁
async function copyBest(src, dest) {
await pipeline(fs.createReadStream(src), fs.createWriteStream(dest));
}
// 方案三:带进度 + gzip 压缩的流水线
const zlib = require("zlib");
async function copyWithProgress(src, dest, onProgress) {
const total = (await fs.promises.stat(src)).size;
let done = 0;
const rs = fs.createReadStream(src);
rs.on("data", (c) => onProgress(((done += c.length) / total) * 100));
await pipeline(rs, zlib.createGzip(), fs.createWriteStream(dest));
}三个必须记住的点:
write()返回false就必须pause(),等drain再resume()—— 这是背压的全部。pipe不传递错误也不销毁流,生产环境一律用pipeline。- 两端的
error都要监听,Stream 的error未监听会直接抛成未捕获异常把进程干掉。
✅ 边界 Case 自测:
- 源文件不存在 →
rs触发error(ENOENT),需能被 reject 捕获 - 目标目录不存在 →
ws触发error(ENOENT),要销毁rs - 磁盘写满 →
ws报 ENOSPC,检查是否正确清理了临时文件 - 拷贝 0 字节空文件 → 不应触发
data,但finish必须正常触发 - 拷贝 10GB 大文件 → 内存占用应恒定在 highWaterMark 量级
- 拷贝过程中删除源文件 → Linux 下 fd 仍有效可继续读完,Windows 会报错
- 中途主动
rs.destroy()→ 确认目标文件被清理,没有残留半截文件
🔁 变种拓展题:
js
// 变种 1:限速拷贝(令牌桶 Transform,常用于下载限流)
const { Transform } = require("stream");
class Throttle extends Transform {
constructor(bytesPerSec) {
super();
this.rate = bytesPerSec;
}
_transform(chunk, enc, cb) {
this.push(chunk);
// 按本次数据量换算出需要等待的毫秒数
setTimeout(cb, (chunk.length / this.rate) * 1000);
}
}
// 变种 2:断点续传(从已写入的字节数继续)
async function resumeCopy(src, dest) {
let start = 0;
try {
start = (await fs.promises.stat(dest)).size;
} catch {}
await pipeline(
fs.createReadStream(src, { start }),
fs.createWriteStream(dest, { flags: "a" }) // 追加模式
);
}
// 变种 3:分片并发拷贝(大文件多段并行,适合网络存储)
async function chunkCopy(src, dest, chunks = 4) {
const { size } = await fs.promises.stat(src);
const step = Math.ceil(size / chunks);
await fs.promises.writeFile(dest, ""); // 预创建
await Promise.all(
Array.from({ length: chunks }, (_, i) => {
const start = i * step;
const end = Math.min(start + step - 1, size - 1);
return pipeline(
fs.createReadStream(src, { start, end }),
fs.createWriteStream(dest, { start, flags: "r+" })
);
})
);
}
// 变种 4:计算大文件 MD5(不占内存)
const crypto = require("crypto");
async function md5(file) {
const hash = crypto.createHash("md5");
await pipeline(fs.createReadStream(file), hash);
return hash.digest("hex");
}题 2:自定义 Readable / Writable / Transform(含按行分割)
核心实现:
js
const { Readable, Writable, Transform } = require("stream");
// ① 自定义可读流:把数组变成流
class ArrayReadable extends Readable {
constructor(arr, opts) {
super({ ...opts, objectMode: true });
this.arr = arr;
this.index = 0;
}
// 消费方需要数据时被调用,push(null) 表示结束
_read() {
if (this.index >= this.arr.length) return this.push(null);
this.push(this.arr[this.index++]);
}
}
// ② 自定义可写流:批量入库
class BatchWritable extends Writable {
constructor(size = 100) {
super({ objectMode: true });
this.size = size;
this.buf = [];
}
_write(chunk, enc, cb) {
this.buf.push(chunk);
if (this.buf.length < this.size) return cb(); // 攒够再落库
this.flush().then(() => cb(), cb); // 错误通过 cb(err) 传出
}
// 流结束时调用,把残余数据写完
_final(cb) {
this.flush().then(() => cb(), cb);
}
async flush() {
if (!this.buf.length) return;
const rows = this.buf;
this.buf = [];
await db.batchInsert(rows);
}
}
// ③ 自定义转换流:按行分割(最高频的手撕题)
class LineSplitter extends Transform {
constructor() {
super({ readableObjectMode: true });
this.tail = ""; // 关键:保存上一个 chunk 的残缺尾部
}
_transform(chunk, enc, cb) {
const lines = (this.tail + chunk.toString("utf8")).split("\n");
this.tail = lines.pop(); // 最后一段可能不完整,留到下次
for (const line of lines) this.push(line);
cb(); // 必须调用,否则流卡死
}
// 流结束前把最后一行 push 出去
_flush(cb) {
if (this.tail) this.push(this.tail);
cb();
}
}
// 使用
const { pipeline } = require("stream/promises");
await pipeline(
fs.createReadStream("big.log"),
new LineSplitter(),
new Writable({
objectMode: true,
write(line, enc, cb) {
console.log("一行:", line);
cb();
},
})
);三个必须记住的点:
_read里push(null)结束流;_transform/_write里必须调cb(),不调就永久卡住。- 跨 chunk 的不完整数据(半行、半个中文字符)必须自己缓存到下一次,并在
_flush/_final里收尾。- 异步错误用
cb(err)往外传,不要直接throw,否则无法被pipeline捕获。
✅ 边界 Case 自测:
- 空输入 → 不应触发任何
push,但finish要正常触发 - 文件不以
\n结尾 → 最后一行必须由_flush补发,不能丢 - 连续空行
\n\n\n→ 应产出相应数量的空字符串,不要被静默吞掉 - Windows 换行
\r\n→ 需额外trimEnd('\r'),否则每行尾部多个\r - 单行超大(如 10MB 一行无换行)→
tail会无限增长,需加长度上限保护 - 中文被切断 → 用
StringDecoder替代chunk.toString()才安全 - 下游
cb(err)→ 确认pipeline能 reject 且上游流被销毁
🔁 变种拓展题:
js
// 变种 1:用 StringDecoder 彻底解决多字节字符截断
const { StringDecoder } = require("string_decoder");
class SafeLineSplitter extends Transform {
constructor() {
super({ readableObjectMode: true });
this.decoder = new StringDecoder("utf8");
this.tail = "";
}
_transform(chunk, enc, cb) {
const lines = (this.tail + this.decoder.write(chunk)).split(/\r?\n/);
this.tail = lines.pop();
lines.forEach((l) => this.push(l));
cb();
}
_flush(cb) {
this.tail += this.decoder.end();
if (this.tail) this.push(this.tail);
cb();
}
}
// 变种 2:用生成器创建可读流(最简洁写法)
const rs = Readable.from(
(async function* () {
for (let i = 0; i < 5; i++) yield { id: i };
})()
);
// 变种 3:异步迭代消费流,天然支持背压
for await (const line of fs
.createReadStream("big.log")
.pipe(new LineSplitter())) {
await handle(line); // await 期间流会自动暂停
}
// 变种 4:JSON Lines 解析(容错版,坏行跳过不中断)
class JsonLines extends Transform {
constructor() {
super({ objectMode: true });
}
_transform(line, enc, cb) {
try {
if (String(line).trim()) this.push(JSON.parse(line));
} catch (e) {
this.emit("badLine", line); // 不 cb(err),保证整体不中断
}
cb();
}
}题 3:手写简易 require 模块加载器
核心实现:
js
const fs = require("fs");
const path = require("path");
const vm = require("vm");
function Module(id) {
this.id = id; // 模块绝对路径
this.exports = {}; // 对外导出对象
this.loaded = false;
}
Module._cache = Object.create(null);
Module._extensions = {
".js"(module) {
const content = fs.readFileSync(module.id, "utf8");
// 关键:用函数包装器隔离作用域,并注入 5 个变量
const wrapper = Module.wrap(content);
const fn = vm.runInThisContext(wrapper, { filename: module.id });
const dirname = path.dirname(module.id);
const require = Module.createRequire(module.id);
// this 指向 module.exports —— 这就是 CJS 顶层 this 的来历
fn.call(
module.exports,
module.exports,
require,
module,
module.id,
dirname
);
},
".json"(module) {
module.exports = JSON.parse(fs.readFileSync(module.id, "utf8"));
},
};
Module.wrap = (code) =>
`(function (exports, require, module, __filename, __dirname) {\n${code}\n})`;
// 路径解析:补全扩展名、处理目录与 node_modules
Module._resolveFilename = function (request, parentId) {
let filename = request.startsWith(".")
? path.resolve(path.dirname(parentId), request)
: Module._resolveNodeModules(request, parentId);
if (fs.existsSync(filename) && fs.statSync(filename).isFile())
return filename;
for (const ext of Object.keys(Module._extensions)) {
if (fs.existsSync(filename + ext)) return filename + ext;
}
// 目录:先看 package.json 的 main,再退 index
const pkg = path.join(filename, "package.json");
if (fs.existsSync(pkg)) {
const main = JSON.parse(fs.readFileSync(pkg, "utf8")).main;
if (main)
return Module._resolveFilename("./" + main, path.join(filename, "x"));
}
for (const ext of Object.keys(Module._extensions)) {
const i = path.join(filename, "index" + ext);
if (fs.existsSync(i)) return i;
}
throw new Error(`Cannot find module '${request}'`);
};
Module._resolveNodeModules = function (request, parentId) {
let dir = path.dirname(parentId);
while (true) {
const p = path.join(dir, "node_modules", request);
if (fs.existsSync(p)) return p;
const parent = path.dirname(dir);
if (parent === dir) break; // 到根目录了
dir = parent;
}
throw new Error(`Cannot find module '${request}'`);
};
Module._load = function (request, parentId) {
const filename = Module._resolveFilename(request, parentId);
// ① 命中缓存直接返回(单例 + 循环依赖的基础)
if (Module._cache[filename]) return Module._cache[filename].exports;
const module = new Module(filename);
// ② 关键:先写缓存再执行,循环依赖时拿到的是「半成品 exports」而不是死循环
Module._cache[filename] = module;
try {
Module._extensions[path.extname(filename)](module);
module.loaded = true;
} catch (e) {
delete Module._cache[filename]; // 加载失败要清缓存,否则下次拿到坏模块
throw e;
}
return module.exports;
};
Module.createRequire = function (parentId) {
const require = (request) => Module._load(request, parentId);
require.cache = Module._cache;
require.resolve = (r) => Module._resolveFilename(r, parentId);
return require;
};
module.exports = Module.createRequire(path.join(process.cwd(), "__main__"));三个必须记住的点:
- 先缓存再执行(
_cache[filename] = module在执行代码之前),这是循环依赖不死循环、但会拿到不完整 exports 的根本原因。- 函数包装器注入
exports, require, module, __filename, __dirname五个参数,且fn.call(module.exports, ...)让顶层this === module.exports。- 返回的是
module.exports而非exports形参 —— 所以exports = {}整体赋值无效。
✅ 边界 Case 自测:
require('./a')省略扩展名 → 按.js→.json顺序补全require('./dir')→ 读package.json的main,缺失则退回index.jsrequire('lodash')→ 逐级向上查找node_modules,到根目录仍无则报错- 同一模块 require 两次 → 只执行一次,两次拿到同一引用(可用计数器验证)
- 循环依赖 a↔b → 不死循环,先加载方拿到对方的不完整 exports
- 模块内
throw→ 缓存必须被清除,重新 require 时能再次执行 require('./a.json')→ 走 json 扩展处理,返回解析后的对象
🔁 变种拓展题:
js
// 变种 1:支持 require 返回 Proxy,记录模块依赖图(打包器雏形)
const graph = {};
function loadWithGraph(request, parentId) {
const id = Module._resolveFilename(request, parentId);
(graph[parentId] ||= []).push(id);
return Module._load(request, parentId);
}
// 变种 2:实现 require 缓存清除(热更新 / 单测隔离)
function purge(request) {
const id = require.resolve(request);
delete require.cache[id];
}
// 深度清除:递归删掉该模块及其所有子模块缓存
function purgeDeep(id, seen = new Set()) {
if (seen.has(id)) return;
seen.add(id);
const mod = require.cache[id];
if (!mod) return;
mod.children.forEach((c) => purgeDeep(c.id, seen));
delete require.cache[id];
}
// 变种 3:劫持 require 实现自定义扩展名(类似 ts-node)
require.extensions[".ts"] = function (module, filename) {
const src = fs.readFileSync(filename, "utf8");
const js = transpile(src); // 交给 esbuild/swc 编译
module._compile(js, filename);
};
// 变种 4:沙箱隔离执行(限制可用全局变量)
function runInSandbox(code, sandbox = {}) {
const context = vm.createContext({ console, ...sandbox }); // 没有 fs/process
return vm.runInContext(code, context, { timeout: 1000 });
}题 4:手写 EventEmitter + 并发限制调度器
核心实现:
js
// ① 手写 EventEmitter(Node 事件系统的核心)
class EventEmitter {
constructor() {
this._events = Object.create(null);
this._maxListeners = 10;
}
on(type, fn) {
(this._events[type] ||= []).push(fn);
if (this._events[type].length > this._maxListeners) {
console.warn(
`MaxListenersExceededWarning: ${type} 超过 ${this._maxListeners} 个监听器`
);
}
return this; // 支持链式调用
}
// 关键:用包装函数实现,且挂 listener 以便 off 时能匹配到原函数
once(type, fn) {
const wrapper = (...args) => {
this.off(type, wrapper);
fn.apply(this, args);
};
wrapper.listener = fn;
return this.on(type, wrapper);
}
off(type, fn) {
const arr = this._events[type];
if (!arr) return this;
this._events[type] = arr.filter((f) => f !== fn && f.listener !== fn);
if (!this._events[type].length) delete this._events[type];
return this;
}
emit(type, ...args) {
const arr = this._events[type];
// error 事件没有监听器时,Node 的行为是直接 throw
if (!arr || !arr.length) {
if (type === "error")
throw args[0] instanceof Error ? args[0] : new Error("Unhandled error");
return false;
}
// 必须拷贝一份:回调里可能 off 掉自己,直接遍历原数组会漏执行
[...arr].forEach((fn) => fn.apply(this, args));
return true;
}
listenerCount(type) {
return this._events[type]?.length ?? 0;
}
removeAllListeners(type) {
type ? delete this._events[type] : (this._events = Object.create(null));
return this;
}
setMaxListeners(n) {
this._maxListeners = n;
return this;
}
}
// ② 并发限制调度器(控制同时进行的异步任务数量)
class TaskPool {
constructor(limit = 4) {
this.limit = limit;
this.running = 0;
this.queue = [];
}
add(taskFn) {
return new Promise((resolve, reject) => {
this.queue.push({ taskFn, resolve, reject });
this._next();
});
}
_next() {
while (this.running < this.limit && this.queue.length) {
const { taskFn, resolve, reject } = this.queue.shift();
this.running++;
// 用 Promise.resolve 包裹,兼容同步函数和同步抛错
Promise.resolve()
.then(taskFn)
.then(resolve, reject)
.finally(() => {
this.running--;
this._next(); // 腾出位置,继续取下一个
});
}
}
}
// 使用:同时最多 3 个请求
const pool = new TaskPool(3);
const results = await Promise.all(
urls.map((u) => pool.add(() => fetch(u).then((r) => r.json())))
);三个必须记住的点:
once要用包装函数并把原函数挂在wrapper.listener上,否则off(type, 原函数)匹配不到、无法移除。emit遍历前必须拷贝监听器数组,防止回调中增删监听器导致遍历错乱。- 调度器的
finally里递减计数并递归取下一个任务;任务失败也要释放名额,否则池子会被逐渐占死。
✅ 边界 Case 自测:
emit('error')无监听器 → 必须抛出,与 Node 行为一致- 同一函数
on两次 → 执行两次;off一次只移除一个 - 回调里
off掉自己 → 本轮其余监听器仍要全部执行 once的回调里再次emit同事件 → 不应重复执行(已被移除)- 调度器中任务 reject → 名额必须释放,后续任务能继续执行
limit大于任务总数 → 全部立即并发,不应卡住- 任务函数同步 throw → 应被转成 reject,不能变成未捕获异常
- 空队列调用
_next()→ 不报错、不死循环
🔁 变种拓展题:
js
// 变种 1:异步串行 EventEmitter(等每个监听器的 Promise)
async emitAsync(type, ...args) {
for (const fn of [...(this._events[type] || [])]) await fn(...args);
}
// 变种 2:prependListener —— 插到队首优先执行
prepend(type, fn) {
(this._events[type] ||= []).unshift(fn);
return this;
}
// 变种 3:带超时与重试的调度器
add(taskFn, { timeout = 5000, retry = 2 } = {}) {
const run = (left) =>
Promise.race([
Promise.resolve().then(taskFn),
new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), timeout)),
]).catch((e) => (left > 0 ? run(left - 1) : Promise.reject(e)));
return this._schedule(() => run(retry));
}
// 变种 4:worker_threads 线程池(CPU 密集任务复用线程)
const os = require("os");
const { Worker } = require("worker_threads");
class WorkerPool {
constructor(file, size = os.availableParallelism?.() ?? os.cpus().length) {
this.idle = Array.from({ length: size }, () => new Worker(file));
this.queue = [];
}
run(data) {
return new Promise((resolve, reject) => {
this.queue.push({ data, resolve, reject });
this._dispatch();
});
}
_dispatch() {
if (!this.idle.length || !this.queue.length) return;
const worker = this.idle.pop();
const { data, resolve, reject } = this.queue.shift();
const done = (fn) => (v) => {
worker.removeAllListeners("message");
worker.removeAllListeners("error");
this.idle.push(worker); // 归还线程
fn(v);
this._dispatch();
};
worker.once("message", done(resolve));
worker.once("error", done(reject));
worker.postMessage(data);
}
destroy() {
this.idle.forEach((w) => w.terminate());
}
}题 5:手写 pipeline(错误传播 + 资源清理 + 取消)
这题考的是「你到底懂不懂
pipe为什么不能用在生产」。核心三件事:把上下游串起来、任一环出错就销毁全部流、全部正常结束才 resolve。
js
const { finished } = require("stream");
function myPipeline(...streams) {
// 支持最后一个参数传 options(如 { signal })
let opts = {};
if (
streams.length &&
!streams[streams.length - 1].pipe &&
typeof streams[streams.length - 1] !== "function"
) {
opts = streams.pop();
}
if (streams.length < 2) {
return Promise.reject(new TypeError("至少需要两个流"));
}
return new Promise((resolve, reject) => {
let settled = false;
const cleanups = [];
// ① 统一收口:只结算一次,并销毁所有流
const done = (err) => {
if (settled) return;
settled = true;
cleanups.forEach((fn) => fn());
if (err) {
// 销毁全部流,释放 fd / socket,避免句柄泄漏
streams.forEach((s) => {
if (typeof s.destroy === "function" && !s.destroyed) s.destroy();
});
reject(err);
} else {
resolve();
}
};
// ② 给每个流挂上结束/错误监听
streams.forEach((stream, i) => {
const isLast = i === streams.length - 1;
// finished 同时覆盖 end / finish / close / error / aborted
const cleanup = finished(stream, { readable: !isLast }, (err) => {
if (err) return done(err);
if (isLast) done(); // 只有最后一个流结束才算整体完成
});
cleanups.push(cleanup);
});
// ③ 串联:前一个 pipe 到后一个(pipe 只负责搬数据和背压)
for (let i = 0; i < streams.length - 1; i++) {
streams[i].pipe(streams[i + 1]);
}
// ④ 支持 AbortSignal 取消
if (opts.signal) {
const onAbort = () => {
const e = new Error("The operation was aborted");
e.name = "AbortError";
done(e);
};
if (opts.signal.aborted) return onAbort();
opts.signal.addEventListener("abort", onAbort, { once: true });
cleanups.push(() =>
opts.signal.removeEventListener("abort", onAbort)
);
}
});
}和原生 pipe 的关键差异(面试时直接说这三条):
| 行为 | a.pipe(b).pipe(c) | pipeline(a, b, c) |
|---|---|---|
| 错误传播 | ❌ 每个流要单独监听 error | ✅ 任一环出错统一 reject |
| 出错时上游处理 | ❌ 继续读,fd 不释放 | ✅ 全链路 destroy() |
| 完成判定 | 要自己监听最后一个的 finish | ✅ 返回的 Promise 就是完成信号 |
✅ 边界 Case 自测清单:
- 正常跑通三段流水线(读 → gzip → 写)→ Promise 正常 resolve
- 源文件不存在(ENOENT)→ reject 且中间的 gzip、目标流都被 destroy
- 中间 Transform 里
cb(new Error())→ reject,源流停止读取 - 目标磁盘写满(ENOSPC)→ reject,源流不再继续读
- 传入少于 2 个流 → reject TypeError 而不是静默成功
- 传
{ signal }并在中途abort()→ reject AbortError,临时文件可被清理 - signal 在调用前就已 aborted → 立刻 reject,不应真的开始读写
- 同一个错误触发多个流的 error → 只 reject 一次(
settled哨兵生效)
🔁 变种拓展题:
js
// 变种 1:支持异步生成器作为中间环节(原生 pipeline 也支持)
async function* upperCase(source) {
for await (const chunk of source) yield chunk.toString().toUpperCase();
}
await pipeline(fs.createReadStream("a.txt"), upperCase, process.stdout);
// 变种 2:带进度回调的 pipeline(用一个透明 Transform 插在中间计数)
function progress(total, onTick) {
let loaded = 0;
return new Transform({
transform(chunk, enc, cb) {
loaded += chunk.length;
onTick(((loaded / total) * 100).toFixed(1) + "%");
cb(null, chunk); // 原样透传
},
});
}
// 变种 3:带超时的 pipeline —— 用 AbortSignal.timeout 一行搞定
await myPipeline(rs, gzip, ws, { signal: AbortSignal.timeout(30_000) });
// 变种 4:写文件的原子性保证(配合 pipeline 的清理能力)
const tmp = dest + ".tmp";
try {
await myPipeline(rs, fs.createWriteStream(tmp));
await fs.promises.rename(tmp, dest); // ✅ 同分区 rename 是原子操作
} catch (e) {
await fs.promises.unlink(tmp).catch(() => {}); // 失败则清理半截文件
throw e;
}题 6:手写 cluster 零停机滚动重启 + 优雅退出
这题把「进程管理 + 信号处理 + 优雅退出」三块串成一道工程题,比单纯背 cluster API 更能看出实战经验。
js
// ========== 主进程:守护 + 滚动重启 ==========
const cluster = require("cluster");
const os = require("os");
if (cluster.isPrimary) {
const SIZE = os.availableParallelism?.() ?? os.cpus().length; // ✅ 容器内更准
const KILL_TIMEOUT = 10_000;
let reloading = false;
for (let i = 0; i < SIZE; i++) cluster.fork();
// ① 意外退出自动拉起(主动 disconnect 的不拉)
cluster.on("exit", (worker, code, signal) => {
if (worker.exitedAfterDisconnect) return; // 滚动重启/主动关闭,不补
console.error(`worker ${worker.process.pid} 挂了(${code}/${signal}),重启`);
setTimeout(() => cluster.fork(), 1000); // ✅ 延迟,防止崩溃循环打满 CPU
});
// ② 逐个替换:先起新的,确认 listening 后再摘老的
function restartOne(old) {
return new Promise((resolve) => {
const fresh = cluster.fork();
const onListen = () => {
old.disconnect(); // 优雅:停止 accept,处理完存量后自然退出
const timer = setTimeout(() => old.kill("SIGKILL"), KILL_TIMEOUT);
old.once("exit", () => {
clearTimeout(timer);
resolve();
});
};
fresh.once("listening", onListen);
// 新 worker 起不来的兜底:不能把老的摘了
fresh.once("exit", () => {
fresh.removeListener("listening", onListen);
resolve();
});
});
}
process.on("SIGHUP", async () => {
if (reloading) return; // ✅ 幂等,防止并发 reload 把所有 worker 一起摘掉
reloading = true;
for (const w of Object.values(cluster.workers)) await restartOne(w);
reloading = false;
console.log("滚动重启完成");
});
// ③ 主进程退出时带走所有 worker
process.on("SIGTERM", async () => {
for (const w of Object.values(cluster.workers)) w.disconnect();
setTimeout(() => process.exit(0), KILL_TIMEOUT).unref();
});
} else {
startWorker();
}
// ========== 子进程:优雅退出 ==========
function startWorker() {
const server = require("http").createServer(handler);
server.keepAliveTimeout = 5000;
server.listen(3000);
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
setTimeout(() => process.exit(1), 15_000).unref(); // 兜底强退
server.closeIdleConnections?.(); // Node 18.2+:先断空闲 keep-alive
await new Promise((r) => server.close(r)); // 等存量请求跑完
await Promise.allSettled([db?.end(), redis?.quit()]);
process.exit(0);
};
process.on("disconnect", shutdown); // ✅ cluster 摘流量时触发
process.on("SIGTERM", shutdown);
}✅ 边界 Case 自测清单:
- 滚动重启全程用
autocannon压测 → 0 个失败请求、端口始终可连 - 新 worker 因为代码语法错误起不来 → 老 worker 不能被摘掉,服务仍可用
- 连续两次发 SIGHUP →
reloading哨兵生效,不会把所有 worker 同时摘走 - worker 卡死不退出 →
KILL_TIMEOUT后被 SIGKILL,不会永久挂起 - worker 被 OOM Kill →
exitedAfterDisconnect为 false,自动拉起新的 - worker 启动即崩(配置错误)→ 延迟 1 秒重启,CPU 不会被打满
- keep-alive 长连接存在时退出 →
closeIdleConnections后close回调能正常触发 - 容器内
availableParallelism()返回 cgroup 限制的核数而非宿主机核数
🔁 变种拓展题:
js
// 变种 1:崩溃循环熔断 —— 60 秒内重启超过 5 次就停止拉起并告警
const restarts = [];
cluster.on("exit", (w) => {
if (w.exitedAfterDisconnect) return;
const now = Date.now();
restarts.push(now);
while (restarts.length && now - restarts[0] > 60_000) restarts.shift();
if (restarts.length > 5) return alert("崩溃循环,停止自愈");
setTimeout(() => cluster.fork(), 1000);
});
// 变种 2:worker 心跳检测,卡死(事件循环阻塞)的直接换掉
// 主进程:定时 ping,N 次无 pong 判定失活
setInterval(() => {
for (const w of Object.values(cluster.workers)) {
if ((w.missed = (w.missed ?? 0) + 1) > 3) w.kill("SIGKILL");
else w.send({ type: "ping" });
}
}, 5000).unref();
// worker:process.on('message', m => m.type === 'ping' && process.send({ type: 'pong' }))
// 变种 3:定时任务只在一个实例上跑
if (cluster.worker?.id === 1) startCron();
// 变种 4:主进程按 pid 聚合各 worker 的内存,超阈值的单独优雅替换
setInterval(async () => {
for (const w of Object.values(cluster.workers)) {
const rss = await askWorkerRss(w);
if (rss > 1.5 * 1024 ** 3) await restartOne(w); // 内存泄漏的临时自愈兜底
}
}, 60_000).unref();⚠️ 当日踩坑实录
| 常见坑 | 解决方案 |
|---|---|
❌ 用 fs.readFile 读大文件导致容器 OOM 被 kill | ✅ 文件大小不可控时一律用 createReadStream + pipeline,内存恒定在 highWaterMark 量级 |
❌ Stream 没监听 error,一次 ENOENT 就把进程打挂 | ✅ 每个流都要有 error 处理;更省事的办法是统一用 pipeline 做兜底 |
❌ 用 pipe 串流水线,中途报错后文件句柄泄漏 | ✅ pipe 不传递错误也不销毁流,生产代码改用 stream/promises 的 pipeline |
❌ 手写 rs.on('data', c => ws.write(c)) 内存暴涨 | ✅ 判断 write() 返回值,为 false 时 rs.pause(),等 drain 再 resume() |
| ❌ 流式读取日志出现乱码「�」 | ✅ 中文被 chunk 边界截断,用 string_decoder 的 StringDecoder 缓存残缺字节 |
❌ exports = { foo } 导出后外部拿到空对象 | ✅ exports 只是形参别名,整体替换必须写 module.exports = { foo } |
❌ 循环依赖里拿到的属性是 undefined | ✅ CJS 先缓存再执行,拿到的是半成品 exports;把 require 延后到函数内部,或抽公共模块打破环 |
❌ 迁 ESM 后 __dirname is not defined | ✅ 用 path.dirname(fileURLToPath(import.meta.url)),Node 20.11+ 可直接用 import.meta.dirname |
❌ package.json 加了 "type": "module" 后老文件全报错 | ✅ 把 CJS 文件改名 .cjs,或拆成子目录单独放一个 {"type":"commonjs"} 的 package.json |
❌ CJS 里 require 一个 ESM 包报 ERR_REQUIRE_ESM | ✅ 改用 await import() 动态导入,或找该包的 CJS 版本/双产物 |
❌ Buffer.allocUnsafe 写接口返回体里混进了别人的数据 | ✅ 未清零内存可能残留敏感信息,不确定会被完全覆写时用 Buffer.alloc |
❌ buf.slice() 改了一处,原 Buffer 也跟着变了 | ✅ slice/subarray 是共享内存的视图,要真拷贝得用 Buffer.from(buf.subarray(...)) |
| ❌ 长期缓存大量小 Buffer,内存占用远超预期 | ✅ 池化的小 Buffer 会持有整块 8KB,长期持有改用 Buffer.allocUnsafeSlow 或做一次真拷贝 |
❌ 递归 process.nextTick 后服务不响应请求了 | ✅ nextTick 在每个阶段结束被完全清空,会饿死事件循环;需要让出控制权时用 setImmediate |
❌ 断言 setTimeout(fn,0) 一定比 setImmediate 先 | ✅ 主模块里顺序不确定;只有在 I/O 回调内部才保证 setImmediate 先执行 |
❌ 一段大 JSON 的 JSON.parse 把整个服务卡死 2 秒 | ✅ 同步 CPU 操作会阻塞事件循环,改用流式解析(如 JSONStream)或丢给 worker 线程 |
| ❌ 并发读写上百个文件,耗时呈阶梯状递增 | ✅ 文件 I/O 走 libuv 线程池,默认只有 4 条;在 require 任何模块前设置 UV_THREADPOOL_SIZE |
❌ crypto.pbkdf2 和文件读写互相拖慢 | ✅ 它们抢同一个线程池,高频加解密应独立进程/线程,或调大线程池 |
❌ process.exit(0) 后日志和落库丢了一半 | ✅ exit 不等待异步,改设 process.exitCode 让事件循环自然结束,或在 beforeExit 里收尾 |
❌ 一个 unhandledRejection 让进程静默退出 | ✅ Node 15+ 默认视为未捕获异常,必须注册 unhandledRejection / uncaughtException 兜底并记录后重启 |
| ❌ 收到 SIGTERM 立刻退出,存量请求全部 502 | ✅ 优雅退出:server.close() 停止新连接 → 等存量请求完成 → 关连接池 → 退出 |
| ❌ 上 cluster 后缓存命中率骤降、session 掉登录 | ✅ 进程间不共享内存,缓存与 session 外置到 Redis |
| ❌ cluster 下定时任务被执行了 N 次 | ✅ 只在主进程或 NODE_APP_INSTANCE === '0' 时启动,或用分布式锁 |
❌ 每个请求都 new Worker,QPS 反而更低了 | ✅ 线程创建成本在 10ms 量级,封装固定大小线程池复用(或直接用 piscina) |
| ❌ worker 之间传大对象,序列化开销比计算还大 | ✅ 用 transferList 转移 ArrayBuffer 所有权,或用 SharedArrayBuffer + Atomics 零拷贝 |
❌ 容器里 os.cpus().length 返回宿主机核数,线程开爆 | ✅ 用 os.availableParallelism() 或读取 cgroup 的 CPU limit |
| ❌ 单测之间互相污染,模块状态被带到下一个用例 | ✅ delete require.cache[require.resolve(path)],或用 jest.resetModules() / vitest 的隔离 |
❌ 控制台一直刷 MaxListenersExceededWarning | ✅ 请求级别重复 on 未 off 的典型泄漏,改用 once 或结束时 removeListener |
❌ --max-old-space-size 调得比容器内存还大 | ✅ 结果是先被 OOMKilled 而非抛 JS 堆溢出,该值必须小于容器 limit |
❌ 以为 queueMicrotask 比 Promise.then 优先级高 | ✅ 两者共用 V8 同一个微任务队列,严格按入队顺序执行;只有 nextTick 队列会整队抢先 |
❌ setTimeout(fn, 2 ** 31) 的定时器立刻就执行了 | ✅ 超时值超过 2147483647(约 24.8 天) 会溢出被重置为 1ms,长延时请用累加的分段定时器 |
| ❌ CLI 工具跑完活儿却卡住不退出 | ✅ 心跳/轮询的 setInterval 会吊住事件循环,给定时器加 .unref() |
❌ 「读完就删文件」的逻辑写在 end 事件里偶发不执行 | ✅ 流被 destroy() 提前关闭时不触发 end,改用 stream/promises 的 finished |
❌ Windows 日志按行读出来每行末尾多个 \r | ✅ readline.createInterface 要带 crlfDelay: Infinity |
❌ 把原生 fetch 的 res.body 当 Node 流去 pipe | ✅ 它是 Web ReadableStream,要用 Readable.fromWeb(res.body) 转换 |
❌ await res.json() 之后再读 res.body 报 Body is unusable | ✅ 响应体只能消费一次,需要二次读取先 res.clone() |
❌ 用 Promise.race + setTimeout 做超时,连接池被打满 | ✅ race 只让调用方提前返回,底层请求仍在跑;改用 AbortSignal.timeout 真正取消 |
❌ 复用一个全局 signal,刷 MaxListenersExceededWarning | ✅ AbortSignal 是事件目标,abort 监听会累积;每个请求一个 controller,结束即丢弃 |
❌ 把 AbortError 当错误上报,告警量炸了 | ✅ 用户取消属正常流量,按 err.name === 'AbortError' 降级为 info,只对超时计入错误率 |
| ❌ 流水线中途失败,磁盘上留下半截脏文件 | ✅ 先写 dest.tmp,成功后 rename(同分区原子),失败则 unlink 清理 |
❌ 升级依赖后 require('pkg/lib/util') 报 ERR_PACKAGE_PATH_NOT_EXPORTED | ✅ 包加了 exports 字段即为封锁式,只能走声明过的子路径 |
❌ 发包后用户反馈 instanceof 判断失效、单例是两份 | ✅ 双包危险:ESM/CJS 产物各加载一份,状态挂 globalThis,错误判别改用 error.code |
❌ exports 里 types 放在 import 后面,TS 找不到类型 | ✅ 条件导出顺序敏感,types 必须排第一 |
❌ Node 22 用 require() 加载 ESM 报 ERR_REQUIRE_ASYNC_MODULE | ✅ 目标模块含顶层 await 时无法同步加载,只能改 await import() |
❌ spawn 的子进程跑一半就不动了 | ✅ 默认 stdio: 'pipe' 的管道缓冲满了会阻塞子进程;要么消费 child.stdout,要么设 'ignore' |
| ❌ 想常驻的后台子进程跟着父进程一起死 | ✅ 需同时 detached: true + stdio: 'ignore' + child.unref() |
❌ server.close() 的回调迟迟不触发,进程被 SIGKILL | ✅ keep-alive 空闲连接不会自动断,先调 server.closeIdleConnections()(Node 18.2+) |
| ❌ 滚动重启时 worker 被无限 fork | ✅ cluster.on('exit') 里要先判断 worker.exitedAfterDisconnect,主动摘的不补 |
| ❌ worker 启动即崩,CPU 被重启风暴打满 | ✅ 重启加延迟 + 60 秒内超过 N 次就熔断告警,不要无脑立刻 fork |
| ❌ 滚动重启先摘老 worker,出现瞬间容量空洞 | ✅ 必须等新 worker 的 listening 事件后再 disconnect() 老的 |
| ❌ 应用的强退兜底设成 60 秒,清理逻辑全白写 | ✅ 兜底时间必须小于 terminationGracePeriodSeconds,否则先被 SIGKILL |
| ❌ 摘流量和关 server 之间没有等待期,发布仍掉请求 | ✅ 先让 readiness 探针失败并 sleep 几秒(或配 preStop),等上游摘干净再 close() |
📌 当日复盘任务
- [ ] 画出 Node 事件循环 6 个阶段的完整流程图,标出 nextTick 与微任务的清空时机
- [ ] 手写一道混合
setTimeout/setImmediate/nextTick/Promise的输出顺序题并验证 - [ ] 用代码验证"I/O 回调内
setImmediate一定先于setTimeout" - [ ] 写一段递归
nextTick的代码,观察 I/O 如何被饿死,再换成setImmediate对比 - [ ] 用
UV_THREADPOOL_SIZE实验验证文件 I/O 的线程池大小限制 - [ ] 整理 CommonJS 与 ES Module 的 10 点差异表,不看笔记默写一遍
- [ ] 写一个 CJS 循环依赖的最小例子,解释为什么拿到 undefined
- [ ] 写一个 ESM 循环依赖的例子,观察 TDZ 报错
- [ ] 手写简易
require:支持缓存、扩展名补全、目录 index、node_modules 查找 - [ ] 对比
Buffer.alloc/allocUnsafe/allocUnsafeSlow的差异并实测性能 - [ ] 验证
buf.subarray共享内存的特性,写出安全的拷贝写法 - [ ] 用流实现大文件拷贝,分别写手动背压版和
pipeline版 - [ ] 实现一个按行分割的 Transform,覆盖"文件不以换行结尾"和"中文截断"两个 case
- [ ] 对比
pipe与pipeline在中途报错时的行为差异(句柄是否释放) - [ ] 手写 EventEmitter,实现
on/once/off/emit并通过自测清单 - [ ] 实现并发限制调度器,确认任务失败时名额能正确释放
- [ ] 写一个
worker_threads线程池,跑一段 CPU 密集计算对比耗时 - [ ] 用
cluster起多进程服务,验证端口共享与内存不共享两个现象 - [ ] 为一个服务补上优雅退出逻辑(SIGTERM → 停新连接 → 等存量 → 关资源)
- [ ] 用
--inspect打两次 heap snapshot,完整走一遍内存泄漏定位流程 - [ ] 用
AsyncLocalStorage实现一次请求级 traceId 的全链路日志透传 - [ ] 把本日 5 道面试题的精简答案口述录音一遍,控制在每题 2 分钟内
- [ ] 跑一遍题 6 的输出顺序题,再把 IIFE 挪到最前面观察
await之后的位置变化 - [ ] 用代码验证
queueMicrotask与Promise.then共用队列、按入队顺序执行 - [ ] 对比
queueMicrotask和Promise.then里抛错的归属(uncaughtException vs unhandledRejection) - [ ] 写一段同步阻塞 200ms 的代码,实测
setTimeout(fn, 100)的真实误差 - [ ] 给一个
setInterval加unref(),验证进程能否正常退出 - [ ] 用
monitorEventLoopDelay采集一次事件循环延迟,再人为阻塞观察曲线 - [ ] 把「读完删文件」的逻辑分别写在
end和finished里,中途 destroy 对比差异 - [ ] 用
for await逐行读一个大日志并模拟异步落库,确认内存恒定 - [ ] 用
Readable.fromWeb把一次fetch的响应体 pipeline 落盘 - [ ] 用
AbortSignal.timeout给 fetch / pipeline / fs 各加一次超时并捕获 AbortError - [ ] 实现一个请求级 controller,在客户端断开时取消所有在途下游调用
- [ ] 手写
myPipeline,覆盖 ENOENT、中途 Transform 报错、signal 取消三个 case - [ ] 实现「临时文件 + rename」的原子写入,验证失败时不留脏文件
- [ ] 给自己的包写一份完整
exports条件导出,并验证深层子路径被正确封锁 - [ ] 构造一个双包危险的最小复现,用
globalThis方案修掉 - [ ] 用
spawn跑一个输出量很大的命令,故意不读 stdout 观察子进程阻塞 - [ ] 用
detached+unref起一个真正脱离父进程的常驻子进程 - [ ] 手写 cluster 滚动重启,用压测工具验证全程零失败请求
- [ ] 给 worker 加崩溃熔断(60 秒 5 次)和心跳检测,分别制造场景验证
- [ ] 完整实现一遍优雅退出(readiness 置失败 → sleep → closeIdleConnections → close → 关资源)
- [ ] 把本日新增的 3 道面试题(输出顺序 / 取消与超时 / 零停机发布)口述一遍