1. 课程导入

在开始写第一行优化代码之前,我们需要先建立一张清晰的“认知地图”。Node.js 之所以能在高并发场景中占据一席之地,核心并不在于 JavaScript 语言本身有多快,而在于它把异步 IO 与事件循环作为运行时的一等公民。理解这一点,才能理解后续所有性能优化手段的来龙去脉。

一、这门课要解决什么问题

很多开发者对 Node.js 的印象停留在“单线程、非阻塞、适合 IO 密集型”。这句话本身没错,但它太笼统,无法指导实际工作。当线上接口 P99 延迟从 80ms 涨到 900ms,当 CPU 使用率长期卡在 100%,当内存随运行时间缓慢爬升,我们需要的是可定位、可度量、可验证的方法,而不是一句口号。

因此本课程的学习目标可以归纳为三层:

  • 原理层:讲清事件循环的六个阶段、libuv 线程池、微任务与宏任务的执行顺序,以及异步 IO 在操作系统层面的真实形态(epoll/kqueue/IOCP)。
  • 度量层:掌握用 perf_hooks、--prof、clinic、火焰图等手段,把“感觉慢”变成“哪一行慢、慢多少”。
  • 优化层:针对 CPU 密集、IO 阻塞、内存泄漏、句柄泄漏、并发控制等典型问题,给出可落地的改造方案。

三层之间是递进关系:不懂原理就选不对度量指标,不会度量就无法判断优化是否真的生效。这也是本课程把原理放在最前面的原因。

二、核心概念:先分清几个容易混淆的词

异步不等于并行。 异步描述的是“调用发起后不等待结果就返回”,并行描述的是“多个任务在同一时刻真正同时执行”。Node.js 的主线程是单线程的,它通过事件循环把等待时间让给其他任务,从而获得高吞吐,但单个 CPU 密集任务依然会阻塞整个循环。

非阻塞 IO 不等于没有线程。 文件读写、DNS 解析、加密计算等操作,Node.js 会交给 libuv 的线程池(默认 4 个线程)处理。这意味着当并发文件操作超过线程池容量时,请求会排队,表现为延迟上升而非报错。

微任务优先于宏任务。 process.nextTick 与 Promise 回调属于微任务,会在当前阶段结束后立即清空;setTimeout、setImmediate、IO 回调属于宏任务,按事件循环阶段调度。递归使用 process.nextTick 会饿死 IO,这是常见的隐蔽故障。

下面这张图把事件循环的阶段、微任务清空点以及 libuv 线程池的关系放在一起,便于建立整体印象:

Node.js 事件循环与异步 IO 执行模型 主线程 · 事件循环(Event Loop) ① timers:setTimeout / setInterval 到期回调 ② pending callbacks:延迟的 IO 回调 ③ poll:等待并执行 IO 回调(核心阶段) ④ check:setImmediate 回调 ⑤ close callbacks:socket 关闭等清理 每个阶段结束后清空微任务队列 process.nextTick → Promise.then / queueMicrotask 循环往复 libuv 线程池(默认 4 线程) fs 文件读写 dns.lookup 解析 crypto.pbkdf2 等计算 超出容量 → 排队等待 完成回调 提交任务 关键判断 • CPU 密集 → 阻塞 poll 阶段 • 线程池满 → IO 延迟上升 • 微任务递归 → 饿死 IO • 句柄未释放 → 内存泄漏 • 同步 API → 直接卡主线程

三、整体知识框架

本课程后续内容围绕四条主线展开,它们不是孤立的,而是互相印证:

  1. 运行机制线:事件循环阶段、微任务/宏任务、libuv 线程池、异步 API 的底层实现差异。
  2. 度量工具线:perf_hooks 打点、--prof 生成日志、clinic doctor/flame 可视化、process.memoryUsage() 观察堆变化。
  3. 优化模式线:流式处理替代全量缓冲、Worker Threads 卸载 CPU 任务、连接池与并发限流、缓存与批量合并。
  4. 稳定性线:内存泄漏排查、句柄泄漏、未捕获异常与进程守护、压测与容量评估。

建议的学习方式是:每学一个原理,立刻用一段最小可运行代码验证它。例如学完微任务顺序,就写一段同时包含 nextTick、Promise、setTimeout、setImmediate 的脚本,先预测输出顺序,再实际运行对比。这种“预测—验证—修正”的循环,比单纯阅读记忆效率高得多。

四、实战环境搭建

本课程示例统一使用 Node.js 20 LTS 及以上版本,原因是该版本对 perf_hooks、Worker Threads、诊断通道(diagnostics_channel)的支持更完整。先确认环境:

node -v
# 期望输出:v20.x.x 或更高

npm -v
# 期望输出:10.x.x 或更高

接着创建一个专用练习目录,并初始化项目。我们约定所有示例都放在 labs/ 下,按章节编号命名,便于对照:

mkdir -p node-perf-labs/labs
cd node-perf-labs
npm init -y
npm pkg set type=module

安装本课程会反复用到的诊断与压测工具。注意这些工具仅用于本地分析,不要装到生产依赖里:

npm i -D clinic autocannon
# clinic:生成火焰图、事件循环延迟报告
# autocannon:HTTP 压测,输出 QPS 与延迟分位

为了验证环境可用,写一个最小 HTTP 服务 labs/ch01/server.mjs:

import http from 'node:http';
import { performance } from 'node:perf_hooks';

const server = http.createServer((req, res) => {
  const start = performance.now();
  // 模拟一次异步 IO:读取当前时间并延迟返回
  setTimeout(() => {
    const cost = (performance.now() - start).toFixed(2);
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ path: req.url, costMs: cost }));
  }, 20);
});

server.listen(3000, () => {
  console.log('listening on http://127.0.0.1:3000');
});

启动服务后,用 autocannon 发起 10 个并发连接、持续 5 秒的压测:

node labs/ch01/server.mjs
# 另开一个终端
npx autocannon -c 10 -d 5 http://127.0.0.1:3000/

观察输出时重点关注三个指标:Req/Sec(每秒请求数,反映吞吐)、Latency p99(99% 请求的延迟上限,反映尾部体验)、Non-2xx(错误响应数,应为 0)。由于示例中人为加入了 20ms 延迟,若并发为 10,理论上每秒约能处理 500 个请求左右;实际数值会因机器性能浮动,这里只作为量级参考,不作为实测结论。

判断环境是否就绪的标准很简单:服务能启动、压测能跑完、返回体中的 costMs 接近 20ms 且波动不大。如果 costMs 明显偏大或错误数不为 0,先检查端口占用与 Node 版本,再继续后续章节。

五、学习前的三个提醒

第一,不要过早优化。先让功能正确,再用数据找到瓶颈,最后才动手改。没有度量支撑的优化,往往只是把代码改复杂了。

第二,区分“快”和“稳”。QPS 高但 P99 抖动大,用户体验依然差;内存缓慢增长最终会导致进程重启。稳定性指标和吞吐指标要一起看。

第三,保留可复现的最小案例。每次遇到性能问题,先把它压缩成一个能稳定复现的脚本,再逐步删减无关代码。这个习惯会让后续所有排查事半功倍。

环境就绪、地图清晰之后,我们就可以进入事件循环的细节,去看清每一次异步调用背后究竟发生了什么。