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 线程池的关系放在一起,便于建立整体印象:
三、整体知识框架
本课程后续内容围绕四条主线展开,它们不是孤立的,而是互相印证:
- 运行机制线:事件循环阶段、微任务/宏任务、libuv 线程池、异步 API 的底层实现差异。
- 度量工具线:
perf_hooks打点、--prof生成日志、clinic doctor/flame可视化、process.memoryUsage()观察堆变化。 - 优化模式线:流式处理替代全量缓冲、Worker Threads 卸载 CPU 任务、连接池与并发限流、缓存与批量合并。
- 稳定性线:内存泄漏排查、句柄泄漏、未捕获异常与进程守护、压测与容量评估。
建议的学习方式是:每学一个原理,立刻用一段最小可运行代码验证它。例如学完微任务顺序,就写一段同时包含 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 抖动大,用户体验依然差;内存缓慢增长最终会导致进程重启。稳定性指标和吞吐指标要一起看。
第三,保留可复现的最小案例。每次遇到性能问题,先把它压缩成一个能稳定复现的脚本,再逐步删减无关代码。这个习惯会让后续所有排查事半功倍。
环境就绪、地图清晰之后,我们就可以进入事件循环的细节,去看清每一次异步调用背后究竟发生了什么。