Skip to content

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 的全部差异 ​

维度CommonJSES Module
加载时机运行时动态加载编译期静态解析
加载方式同步阻塞异步(三阶段:构造 → 实例化 → 求值)
导出语义值拷贝(基础类型快照)引用绑定(live binding)
能否动态路径可以 require(variable)静态 import 不行,需 import()
书写位置任意位置,可在 if 里必须顶层(import() 除外)
Tree-Shaking不支持支持
this 指向module.exportsundefined
顶层变量有 __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 / preparelibuv 内部使用,业务无感面试只需知道存在
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.nextTickNode 自有队列最高每个阶段结束先清空它,且可以无限插队
Promise.thenV8 微任务队列次高nextTick 清空后才执行
setTimeout(fn, 0)timers 阶段宏任务实际最小延迟约 1ms
setImmediatecheck 阶段宏任务当前阶段跑完就执行,不等 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
     读文件              压缩               写文件
流类型代表必须实现的方法
Readablefs.createReadStream、http.IncomingMessage_read(size)
Writablefs.createWriteStream、http.ServerResponse、process.stdout_write(chunk, enc, cb)
Duplexnet.Socket(读写互不相干)_read + _write
Transformzlib.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 密集型的三个特征:

  1. CPU 占用率一直很高,持续消耗时间片;
  2. 核心动作是算数、循环、加密、解析、渲染、复杂算法;
  3. 磁盘、网络等待时间占比极低。

常见场景:大数运算、斐波那契递归、MD5 / SHA256 加密、图片视频编解码、矩阵运算、AI 推理、超大 JSON 解析、复杂排序。

js
// CPU 密集型:纯计算,CPU 一直在执行指令
let sum = 0
for (let i = 0; i < 1e8; i++) {
  sum += i
}
// 现象:CPU 飙到接近 100%,磁盘和网络毫无动静
// 在 Node 里:这段代码会把主线程彻底堵死,事件循环停转,所有请求排队

I/O 密集型的三个特征:

  1. 大部分时间在等外部资源返回;
  2. CPU 经常处于空闲 / 阻塞等待状态;
  3. 代码逻辑往往很简单,只是外部资源慢。

常见场景:文件与日志读写、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);
对比项pipepipeline(推荐)
背压处理✅ 自动✅ 自动
错误传递❌ 不传递,需每个流单独监听✅ 任一环出错统一回调
失败时销毁流❌ 不销毁,文件句柄泄漏✅ 自动 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✅✅ 只能跑 JSIPC 通道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_threadschild_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 = b
js
// ---- 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只能执行同步代码
uncaughtExceptionNode 15+ 未捕获异常默认导致进程退出,不要用它"续命"
unhandledRejectionNode 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 对比两个时间点的增量
未移除的事件监听器MaxListenersExceededWarningemitter.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.nextTicknextTickQueueNode 自己维护触发 uncaughtException
queueMicrotaskV8 微任务队列V8(标准 API)触发 uncaughtException
Promise.thenV8 微任务队列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 then

queueMicrotask 和 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,所有定时器都被推迟 500ms
js
// 验证误差
const start = Date.now();
setTimeout(() => console.log("期望 100ms,实际", Date.now() - start), 100);
const end = Date.now() + 200;
while (Date.now() < end) {} // 同步阻塞 200ms
// 输出:期望 100ms,实际 200+ms

ref / 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"
  }
}

三条铁律:

  1. 顺序敏感:types 必须放最前,否则 TS 解析不到类型。
  2. exports 一旦存在就是封锁式的 —— 没列出来的路径,外部 require('my-lib/internal/foo') 会直接报 ERR_PACKAGE_PATH_NOT_EXPORTED。很多「升级依赖后深层引入全挂」就是这个原因。
  3. 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 之后对齐了浏览器,改成每执行完一个宏任务就清一次微任务队列,所以现在两边输出顺序基本一致了。

连环追问链:

  1. 追问:setTimeout(fn, 0) 和 setImmediate 谁先执行?
    • 答:在主模块里不确定。因为 setTimeout(fn, 0) 会被改成 1ms,事件循环启动的准备耗时是否超过 1ms 决定了进入 timers 时定时器有没有到期。但在 I/O 回调内部是确定的:一定是 setImmediate 先 —— 因为 I/O 回调在 poll 阶段执行,紧接着就是 check 阶段,而 timers 要等下一轮。
  2. 追问:process.nextTick 为什么优先级最高?滥用会有什么后果?
    • 答:它不属于事件循环的任何阶段,而是在每个阶段切换时被完全清空,且清空过程中新入队的也会一并执行。所以递归调用 nextTick 会饿死事件循环,I/O 永远得不到执行。库里需要"延迟到当前操作之后"的场景应该优先用 setImmediate。
  3. 追问:poll 阶段什么时候会阻塞?
    • 答:poll 队列为空时,如果有 setImmediate 待执行就立刻进 check;否则看最近的 timer 还有多久到期,阻塞到那个时刻;如果连 timer 都没有,就一直阻塞等新的 I/O 事件。这就是空闲 Node 进程 CPU 占用接近 0 的原因。
  4. 追问:Node 是单线程的,为什么能处理高并发?
    • 答:单线程指的是执行 JS 的线程只有一条。底层 libuv 对网络 I/O 用 epoll/kqueue 这种内核事件通知机制,一条线程可以管理上万连接;文件 I/O、DNS、zlib 则交给默认 4 条的线程池。所以 Node 擅长 I/O 密集,但一段 CPU 密集的同步代码会直接堵死整个服务。
  5. 追问:如果有一段 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 所有流。

连环追问链:

  1. 追问:fs.readFile 和 fs.createReadStream 有什么区别,什么时候必须用流?
    • 答:readFile 把整个文件读进内存再回调,内存占用等于文件大小,且单个 Buffer 有上限(buffer.constants.MAX_LENGTH,64 位机约 4GB,老版本 2GB),超了直接抛错。流按 highWaterMark(文件流默认 64KB)分片,内存恒定,还能边读边处理降低首字节延迟。只要文件大小不可控(用户上传、日志、导出),就必须用流。
  2. 追问:highWaterMark 调大调小有什么影响?
    • 答:调大减少系统调用次数、吞吐更高,但单次内存占用和延迟变大;调小内存更省、响应更快,但系统调用频繁、CPU 开销上升。默认值是经验平衡值:文件流 64KB,object mode 下是 16 个对象。
  3. 追问:为什么流式读取中文会乱码?
    • 答:一个 UTF-8 中文占 3 字节,chunk 边界可能把它切开,单独 toString() 就得到「�」。要用 string_decoder 的 StringDecoder,它会把不完整的尾部字节缓存到下一次 write 再拼接。
  4. 追问:Transform 流怎么实现?_transform 里的 callback 必须调用吗?
    • 答:继承 Transform 实现 _transform(chunk, enc, cb),用 this.push() 输出数据,处理完必须调 cb(),否则流会卡住不再读取下一个 chunk。另外还有 _flush(cb),用于在流结束前把缓存的残余数据 push 出去(比如按行分割时的最后一行)。
  5. 追问:object mode 是什么?
    • 答:默认流只能传 Buffer/string,开 objectMode: true 后可以传任意 JS 对象,此时 highWaterMark 的单位从字节变成个数。常用于把数据库游标、CSV 行记录做成流水线处理。

✅ 项目关联话术(按真实经历替换):

我们有个百万级数据导出 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 错误。

连环追问链:

  1. 追问:为什么 exports = {...} 不生效,module.exports = {...} 就可以?
    • 答:因为模块包装函数的参数里,exports 只是 module.exports 的一个引用别名。给 exports 整体重新赋值只是改了局部变量的指向,真正被返回的 module.exports 没变。所以挂属性可以用 exports.a = 1,整体替换必须用 module.exports = ...。
  2. 追问:Node 里怎么区分一个文件是 CJS 还是 ESM?
    • 答:看三个依据:扩展名 .mjs 强制 ESM、.cjs 强制 CJS;.js 看最近的 package.json 里 "type" 字段,module 走 ESM,缺省或 commonjs 走 CJS。库的 exports 字段还能用条件导出给两种环境提供不同入口。
  3. 追问:两者能互相引用吗?
    • 答:ESM 里可以 import CJS,整个 module.exports 会作为 default 导出,具名导入靠 Node 的静态分析"尽力而为",复杂场景可能失败。反过来 CJS 不能同步 require ESM(ESM 是异步加载的),只能用 await import() 动态导入。
  4. 追问:ESM 里怎么拿 __dirname?
    • 答:import { fileURLToPath } from 'url' 配合 path.dirname(fileURLToPath(import.meta.url))。Node 20.11+ 已经提供了 import.meta.dirname 和 import.meta.filename。
  5. 追问:require 的缓存机制是什么?怎么清缓存?
    • 答:每个模块第一次加载后,解析出的绝对路径作为 key 存进 require.cache,之后直接返回同一个 module.exports,这也是单例模式的实现基础。delete require.cache[require.resolve('./m')] 可以清掉,常用于单测和开发热更新。ESM 的模块缓存没有官方清除手段,只能用加 query 参数这种 hack。

✅ 项目关联话术(按真实经历替换):

我们在把老项目迁到 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。

连环追问链:

  1. 追问:cluster 为什么多个进程 listen 同一个端口不报 EADDRINUSE?
    • 答:因为子进程的 listen 被 cluster 模块劫持了,它并没有真的 bind 端口,而是向主进程发消息,由主进程持有真正的 listening socket,accept 到连接后通过 IPC 把文件描述符传给子进程。Windows 下走的是另一套(句柄共享由系统调度),所以负载可能不均衡。
  2. 追问:cluster 下有什么东西是不能用的?
    • 答:所有依赖进程内状态的东西:内存缓存、express-session 的 MemoryStore、限流计数、全局自增 ID、定时任务。缓存和 session 必须外置到 Redis,定时任务要加分布式锁或只在主进程里跑,WebSocket 场景还需要 sticky session 或 Redis 适配器做跨进程广播。
  3. 追问:worker_threads 之间怎么传数据,有什么性能陷阱?
    • 答:默认用 postMessage,走结构化克隆,大对象的序列化开销很大。优化手段有两个:用 transferList 把 ArrayBuffer 的所有权转移(零拷贝,但原线程里就不能再用了);或者用 SharedArrayBuffer 真正共享内存,配合 Atomics 做同步避免竞态。
  4. 追问:worker 数量开多少合适?
    • 答:CPU 密集任务按 os.cpus().length 来,开更多只会增加上下文切换。而且不要每个任务都 new Worker,创建成本在 10ms 量级,应该封装固定大小的线程池复用(可以直接用 piscina)。注意容器环境里 os.cpus() 返回的是宿主机核数,要读 cgroup 的 limit 或用 os.availableParallelism()。
  5. 追问: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)、未清理的定时器和未关闭的流/连接、以及闭包意外捕获大对象。修完一定要回归压测对比内存曲线。

连环追问链:

  1. 追问:process.memoryUsage() 几个字段分别是什么意思?
    • 答:rss 是进程实际占用的物理内存总量;heapTotal/heapUsed 是 V8 堆的分配量和使用量;external 是 V8 管理的 C++ 对象内存;arrayBuffers 是 ArrayBuffer 和 Buffer 占用。Buffer 分配在堆外,所以 heapUsed 正常但 rss 飙高时要重点查 Buffer。
  2. 追问:MaxListenersExceededWarning 是什么原因?
    • 答:同一个 EventEmitter 上同一事件注册超过 10 个监听器时的告警。它是泄漏的强信号 —— 通常是在每次请求/每次循环里重复 on 却没 off。正确做法是用 once、在结束时 removeListener,确实需要多个才用 setMaxListeners 调高。
  3. 追问:Node 的内存上限是多少,怎么调?
    • 答:老生代默认约 1.5~2GB(新版本会根据系统内存自适应),用 --max-old-space-size=4096 调整。但调大只是缓解,如果是泄漏迟早还会满。容器里要注意这个值要小于容器的 memory limit,否则会先被 OOMKilled 而不是抛 JS 堆溢出错误。
  4. 追问:怎么发现是哪段代码阻塞了事件循环?
    • 答:监控事件循环延迟(perf_hooks 的 monitorEventLoopDelay,或用 setInterval 对比预期间隔),延迟高说明有同步阻塞。然后用 --prof + --prof-process 或 clinic flame 做 CPU 火焰图,找出占用最高的同步函数。
  5. 追问: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 连在一起,不会交叉。

连环追问链:

  1. 追问:为什么 11 await 之后 排在 9 then 后面?
    • 答:await null 会把后续代码包装成一个微任务入队,入队时机是执行到 await 那一刻。而这行代码在 queueMicrotask 和 Promise.then 之后才执行,所以入队更晚。如果把 IIFE 挪到最前面,11 就会跑在 8、9 前面。
  2. 追问:如果把 2 timeout 和 5 immediate 的代码挪进一个 fs.readFile 回调里,顺序还不确定吗?
    • 答:确定,一定是 setImmediate 先。因为 I/O 回调在 poll 阶段执行,紧接着就进 check 阶段跑 setImmediate,而 timers 要等下一轮循环。
  3. 追问:Node 10 和 Node 11 跑这段代码结果会不一样吗?
    • 答:如果有多个同类宏任务会不一样。Node 10 及之前是一个阶段的所有宏任务跑完才清一次微任务,所以两个 setTimeout 里的 then 会被攒到最后一起输出;Node 11 对齐浏览器,改成每个宏任务后都清一次。面试里能提到这个版本分界点是加分项。
  4. 追问:queueMicrotask 和 Promise.resolve().then 有区别吗?
    • 答:队列是同一个,顺序按入队。区别在异常处理:queueMicrotask 里抛错走 uncaughtException,then 里抛错变成 unhandledRejection;另外 queueMicrotask 不创建 Promise 对象,开销更小,且是浏览器/Node 通用的标准 API。
  5. 追问:怎么证明 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)]) 把「用户取消」和「整体超时」合并成一个信号往下传。

连环追问链:

  1. 追问:Promise.race 加 setTimeout 的写法具体有什么问题?
    • 答:三个泄漏。① 底层请求继续占着连接池名额,高并发下连接池被耗尽;② 那个 setTimeout 不会被清掉,在超时时间内一直吊着事件循环(CLI 场景会导致进程不退出);③ 后到的响应仍然会触发后续的 .then,可能重复写库。
  2. 追问:AbortError 要不要上报告警?
    • 答:要区分。用户主动取消、客户端断开属于正常流量,应该只打 info 日志不计入错误率;而 AbortSignal.timeout 触发的超时是真问题,要计入指标。判断方式是看 err.name === 'AbortError' 再结合是哪个 signal 触发的(可以给 controller 的 abort(reason) 传入原因,从 signal.reason 读出来)。
  3. 追问:abort 之后已经写了一半的文件怎么办?
    • 答:pipeline 带 signal 时会把链路上所有流 destroy(),但已经落盘的部分不会回滚。所以写文件一定要「先写临时文件,成功后再 rename」—— rename 在同分区是原子操作,这样不会留下半截的脏文件。
  4. 追问:signal 用在 EventEmitter 上有什么坑?
    • 答:AbortSignal 本身是个事件目标,每次把它传给 events.once、addEventListener 都会挂一个 abort 监听。一个长生命周期的 signal(比如全局超时 signal)被成千上万个请求复用,监听器会不断累积,先告警 MaxListenersExceededWarning 然后内存泄漏。正确做法是每个请求一个 controller,请求结束就丢弃。
  5. 追问:下游是 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));

连环追问链:

  1. 追问:server.close() 之后为什么有时进程还是不退出?
    • 答:close() 只停止接受新连接,已建立的 keep-alive 空闲连接不会被关掉,它的回调要等所有连接断开才触发。解决办法是 Node 18.2+ 直接用 server.closeIdleConnections() 关掉空闲连接,再配 server.closeAllConnections() 做超时兜底;老版本要自己维护一个连接 Set 并设置 res.setHeader('Connection', 'close') 让客户端主动断。
  2. 追问:为什么不用 process.exit() 直接退?
    • 答:process.exit 是立刻终止,不等任何异步操作。在途的日志写入、埋点上报、事务提交全部丢失,日志里会出现「请求进来了但没有结果」的断点。正确做法是设置 process.exitCode 让事件循环自然耗尽,或者像上面那样把清理做完再退。
  3. 追问:K8s 环境下这套逻辑还需要吗?
    • 答:需要,但职责要分清。K8s 负责发信号和摘 Endpoints(preStop 钩子 + readiness 探针 + terminationGracePeriodSeconds),应用只需保证「收到 SIGTERM 后在 grace period 内干净退出」。注意应用的强退兜底时间必须小于 terminationGracePeriodSeconds(默认 30 秒),否则会先被 SIGKILL,清理逻辑白写。
  4. 追问:uncaughtException 之后应该继续跑还是退出?
    • 答:必须退出。未捕获异常意味着进程状态已经不可信(可能有半完成的事务、泄漏的句柄),继续服务会产生更难排查的脏数据。标准做法是:在 handler 里同步记录好日志和告警,然后走优雅退出流程,由 PM2/K8s 拉起新进程。千万不要在 handler 里 return 当作无事发生。
  5. 追问:定时任务(cron)在多实例下怎么保证只跑一次?
    • 答:分布式锁是通用解法(Redis SETNX + 过期时间 + 续租)。轻量场景可以只在 cluster 主进程或 NODE_APP_INSTANCE === '0' 的实例上启动,但这在多 Pod 场景下无效。另外优雅退出时要注意:正在执行的定时任务要等它跑完或主动释放锁,否则锁要等过期才释放,下个周期会被跳过。

✅ 项目关联话术(按真实经历替换):

我们之前每次发布监控上都有一波 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));
}

三个必须记住的点:

  1. write() 返回 false 就必须 pause(),等 drain 再 resume() —— 这是背压的全部。
  2. pipe 不传递错误也不销毁流,生产环境一律用 pipeline。
  3. 两端的 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();
    },
  })
);

三个必须记住的点:

  1. _read 里 push(null) 结束流;_transform / _write 里必须调 cb(),不调就永久卡住。
  2. 跨 chunk 的不完整数据(半行、半个中文字符)必须自己缓存到下一次,并在 _flush / _final 里收尾。
  3. 异步错误用 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__"));

三个必须记住的点:

  1. 先缓存再执行(_cache[filename] = module 在执行代码之前),这是循环依赖不死循环、但会拿到不完整 exports 的根本原因。
  2. 函数包装器注入 exports, require, module, __filename, __dirname 五个参数,且 fn.call(module.exports, ...) 让顶层 this === module.exports。
  3. 返回的是 module.exports 而非 exports 形参 —— 所以 exports = {} 整体赋值无效。

✅ 边界 Case 自测:

  • require('./a') 省略扩展名 → 按 .js → .json 顺序补全
  • require('./dir') → 读 package.json 的 main,缺失则退回 index.js
  • require('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())))
);

三个必须记住的点:

  1. once 要用包装函数并把原函数挂在 wrapper.listener 上,否则 off(type, 原函数) 匹配不到、无法移除。
  2. emit 遍历前必须拷贝监听器数组,防止回调中增删监听器导致遍历错乱。
  3. 调度器的 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 道面试题(输出顺序 / 取消与超时 / 零停机发布)口述一遍