一句话总结
Node.js 是「基于 V8 + libuv 的异步事件驱动运行时」:JS 主线程单线程,但 I/O 由底层线程池与操作系统异步完成,靠事件循环把完成的结果调度回主线程。它擅长 I/O 密集型、不擅长 CPU 密集型——面试高频考点集中在事件循环阶段、宏任务/微任务顺序、Cluster 多进程、内存与 CPU 线上排查。
基础:Node 是什么
很多候选人把 Node 当成一门语言,这是第一道送命题。Node 是运行时(Runtime),由三块组成:
- V8:Google 的 JS 引擎,负责编译执行 JS。
- libuv:跨平台异步 I/O 库,提供事件循环、线程池、定时器、信号等能力。
- 事件循环(Event Loop):libuv 的核心调度器,决定回调何时回到主线程执行。
单线程到底指什么
「单线程」指执行用户 JS 代码的主线程只有一个。所有同步代码、Promise 回调、定时器回调都在这一个线程上串行执行。但网络、文件、DNS 等 I/O 操作并不阻塞这个线程——它们被交给 libuv 的线程池(默认 4 个线程)或操作系统内核异步处理,完成后把结果包装成事件压入队列,等主线程空闲时取出执行。
// 一段代码看清「不阻塞」
console.log('A');
setTimeout(() => console.log('B'), 0);
fs.readFile('big.txt', () => console.log('C')); // 异步,交给线程池
console.log('D');
// 输出顺序:A → D → B → C(D 在定时器前,C 在 I/O 完成后)核心:事件循环机制
事件循环每一轮(tick)会按固定阶段(phase)顺序执行,每个阶段都有一个 FIFO 回调队列:
- timers:执行
setTimeout/setInterval到期的回调。 - pending callbacks:执行上一轮延迟到这一轮的 I/O 错误类回调(如 TCP 连接错误)。
- idle / prepare:内部使用,一般面试不考。
- poll:检索新的 I/O 事件并执行相关回调(文件、网络等);这是最繁忙的阶段。
- check:执行
setImmediate的回调。 - close callbacks:执行
close事件回调(如socket.on('close'))。
宏任务与微任务
每个阶段之间,主线程会先清空微任务队列,再进入下一阶段。微任务包括:Promise.then / catch / finally、queueMicrotask。而 process.nextTick 不属于事件循环阶段,它有独立的最高优先级队列——在当前操作结束后、事件循环继续之前立即执行,先于微任务。
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
process.nextTick(() => console.log('4'));
setImmediate(() => console.log('5'));
console.log('6');
// 输出:1 → 6 → 4 → 3 → 2 → 5
// 4(nextTick) > 3(Promise) > 2(timers) > 5(check)setTimeout(fn,0) 与 setImmediate 的顺序不确定:如果主模块里两者都注册,取决于进入事件循环时定时器是否已到期;但在 I/O 回调内部,setImmediate 一定先于 setTimeout。进阶:异步编程与并发模型
异步演进:回调 → Promise → async/await
早期用回调函数,嵌套过深形成「回调地狱」;Promise 用链式 then 把嵌套拍平;async/await 用同步写法表达异步流程,本质是 Promise 的语法糖——async 函数永远返回 Promise,await 会暂停当前 async 函数但不阻塞主线程。
// 错误:await 写在循环里串行,慢
async function slow(urls) {
const out = [];
for (const u of urls) out.push(await fetch(u)); // 一个接一个
return out;
}
// 正确:并发请求 + 等待全部
async function fast(urls) {
return Promise.all(urls.map(u => fetch(u))); // 同时发出
}Cluster:多进程吃满多核
单线程无法利用多核。Cluster 模块让 master 进程 fork 出多个 worker,每个 worker 独立运行事件循环并共享同一端口;master 默认用 round-robin 把新连接分发给 worker,实现负载均衡。
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
os.cpus().forEach(() => cluster.fork()); // 每核一个 worker
cluster.on('exit', (w) => cluster.fork()); // 崩溃自愈
} else {
require('./app'); // worker 启动服务,共享端口
}Worker Threads:真正多线程
当必须做 CPU 密集计算(加密、压缩、图像处理)又不想阻塞事件循环,用 Worker Threads 在真正的内核线程里执行,可通过 postMessage 或 SharedArrayBuffer 共享内存交换数据,比 fork 子进程更轻量。
Stream 背压(backpressure)
用 readable.pipe(writable) 时,管道会自动处理背压:当可写端消费慢,可读端会暂停,避免内存暴涨。手动写时必须检查 writable.write() 的返回值——返回 false 表示缓冲已满,应监听 drain 后再继续写。
实战:稳定性与线上排查
内存泄漏排查
常见根因:闭包长期持有大对象、意外挂到全局变量、未清理的定时器/事件监听器、缓存无限增长。排查链路:
- 启动加
--inspect,用 Chrome DevTools 或heapdump在两份时间点各抓一次堆快照。 - 对比快照,按「保留大小」排序,定位持续增长的对象类型与引用链。
- 修复后复测,确认堆使用曲线回到平稳。
const heapdump = require('heapdump');
// 触发一次快照(也可在收到 SIGUSR2 时抓取)
heapdump.writeSnapshot(`/tmp/node-${Date.now()}.heapsnapshot`);CPU 100% 定位
常见诱因:同步死循环、正则灾难性回溯(如 /(a+)+$/ 匹配长字符串)、超大 JSON 解析。用 CPU Profiler(0x 或 --prof)抓火焰图,定位热点函数。
错误兜底与优雅退出
未捕获异常默认会让进程退出。process.on('uncaughtException') 与 process.on('unhandledRejection') 可以兜底,但不能简单吞掉——应记录日志、停止接收新流量、排空已有连接后主动退出,交给守护进程或 Cluster 重启,避免进程处于「半死」状态。
process.on('unhandledRejection', (reason) => {
logger.error('unhandledRejection', reason);
// 视严重程度决定是否退出
});
process.on('SIGTERM', async () => {
server.close(); // 停止接收新请求
await drainConnections(); // 排空在途请求
process.exit(0);
});面试模拟
Q1:Node 是单线程吗?为什么能高并发?
主线程是单线程,但 I/O 由 libuv 线程池/内核异步处理,事件循环把完成结果回调到主线程,靠「非阻塞 + 事件驱动」支撑高并发。
Q2:setTimeout 和 setImmediate 谁先执行?
主模块中顺序不确定;在 I/O 回调内部,setImmediate 一定先于 setTimeout 执行。
Q3:process.nextTick 和 Promise.then 谁先?
nextTick 队列优先级更高,在当前操作结束后、事件循环继续前立即清空,早于微任务。
Q4:Cluster 和 Worker Threads 区别?
Cluster 是多进程(隔离好、内存不共享、故障互不影响,适合 Web 服务);Worker Threads 是多线程(共享内存、更轻量,适合 CPU 密集计算)。
Q5:如何排查内存泄漏?
--inspect 抓两份堆快照对比,按保留大小定位持续增长对象与引用链;常见原因是未清理的监听器/定时器、全局引用、无限缓存。
Q6:Stream 背压是什么?不处理会怎样?
消费端慢于生产端时,手动 write 返回 false 应暂停;不处理会导致内存无限增长直至 OOM。用 pipe 可自动处理。