理解 Node.js 的事件驱动和 Event Loop是看懂 Express.js 请求处理流程的关键也是排查接口卡顿、进程无响应、并发一高就挂这类问题的第一步。很多人刚学会搭 Express 接口时看到app.get(/user, async (req, res) {})能正常工作就以为 Node.js 是“多线程同时处理 N 个请求”的。实际上它更像一个没有等餐员的大厅所有请求都在主线程里按事件循环的节奏被处理谁遇到等待就自己挂个号等结果回来了再排队回来。这个理解一旦到位你再看setTimeout、setImmediate、process.nextTick、Promise的执行顺序再看为什么同步读文件会把整个 Express 服务拖死就都不是零散知识点而是一套完整机制。这篇文章我会按自己从“只会写接口”到“能定位线上接口变慢”的过程来拆先讲事件驱动是怎么回事再讲 Event Loop 六个阶段和微任务插入点然后用 Express 做两个实验最后给出阻塞陷阱、监控方法和优化边界。不会让你五分钟彻底精通但会把最关键的判断逻辑讲清楚。1. 先把问题摆清楚事件循环在 Express.js 里到底管什么1.1 一个接口卡住为什么其他接口也变慢假设你有一个 Express 服务暴露了/user和/order两个接口。某天/order查询数据量大响应时间从 100ms 变成 5s接着你发现/user也跟着变慢了。很多人第一反应是“数据库连接被打满”但冷静下来拉一下 Node 进程的 CPU会发现某个同步while循环或JSON.stringify占着主线程不放。原因是所有 JS 业务代码包括 Express 路由、中间件、普通对象处理都跑在同一个事件循环线程里。只要有一段同步代码耗时长后续所有回调都要排队。这不是 Express 的 bug而是 Node.js 的设计。Express.js 本身就是用回调组成的中间件链请求到达、路由匹配、业务处理、响应返回每一步都在事件循环里完成。1.2 事件循环不是某个库而是运行时的工作台Node.js 不像传统 Java Web 服务器那样一个请求分配一个线程。它维护一个事件循环不断从任务队列里取出回调执行。I/O 操作完成后libuv 会把对应回调放回队列。所以事件循环不是 Express 的插件也不是 Node.js 的可选模块而是整个运行时的工作方式。理解这一点后很多面试题和实际故障就能串起来为什么setTimeout(0)不能“立刻执行”因为它要等当前同步代码执行完再等事件循环轮转到 timers 阶段。为什么一个 CPU 密集计算会让所有请求变慢因为它占住了事件循环唯一的主线程。为什么异步文件读取不会卡住页面因为文件 I/O 的等待交给了底层主线程没有等。Node.js 是单线程的这句话更准确的说法是你的 JS 业务代码运行在单线程里。事件循环负责调度这些代码。2. 事件驱动是怎么工作的从排队叫号到回调函数2.1 同步模型为什么在 I/O 密集场景下浪费资源传统同步 Web 模型里一个请求进来线程可能阻塞在数据库查询、文件读取、外部 API 调用上。等待期间线程不干活但依然占用内存和系统资源。如果同时来 100 个请求就需要 100 个线程在那里等。Web 场景的大部分时间其实是等待网络、磁盘、第三方服务真正的计算很少。让一个线程干等是非常浪费的。Node.js 的做法是把等待交给内核或 libuv 的线程池主线程继续处理下一个已经就绪的任务。等某个 I/O 完成了再把对应的回调放回队里等事件循环轮转到时执行。这种模型就是事件驱动系统不主动轮询每个请求的状态而是“当事件发生时才通知”。2.2 回调函数如何让出执行权你调用fs.readFile(path, callback)时readFile 发起一个底层 I/O 请求后立即返回Node.js 主线程不会阻塞在文件数据上。底层完成读操作后回调会被放进待处理队列等事件循环下次轮询时取出来执行。这就是“让出执行权”发起方不等待结果而是提供一个回调让结果回来后通知自己。写代码的时候这个理念表现为fs.readFile(/path/to/file, utf8, (err, data) { if (err) { console.error(读取失败, err); return; } console.log(读取成功长度, data.length); }); console.log(这行会先执行);后面的console.log会先执行因为readFile并不会阻塞等待文件读完。事件驱动改变了代码的书写顺序这是新手最容易不习惯的地方。2.3 异步不等于新线程很多人以为“异步”就是“开了另一个线程执行”这是最常见的误解。fs.readFile的底层确实可能使用 libuv 线程池但它对调用方来说并不是创建了一个业务线程。你的业务代码依然只有一个主线程执行回调。所以“异步”解决的是“不要阻塞等待”而不是“同时执行多段 JS”。如果你把 CPU 密集计算放进一个 Promise 的 executor 里它照样会阻塞主线程。Promise 并不会把它丢到另一个线程。app.get(/heavy, (req, res) { return Promise.resolve().then(() { const end Date.now() 3000; while (Date.now() end) {} res.send(done); }); });这个/heavy接口同样会卡住所有请求。因为Promise.then里的同步while循环仍然在主线程上执行。Event Loop 不会因为你用了 Promise 就变得不阻塞。3. Event Loop 的六个阶段代码到底按什么顺序执行3.1 六个阶段分别处理什么Event Loop 是一个不断旋转的循环由多个阶段组成。每个阶段都有自己的任务队列按固定顺序执行阶段主要作用timers执行setTimeout和setInterval到期的回调pending callbacks处理系统层面的 I/O 回调比如 TCP 连接错误idle, prepareNode.js 内部使用一般不需要关心poll处理新的 I/O 事件执行与 I/O 相关的回调这里经常停留等待新事件check执行setImmediate回调close callbacks处理 socket 或 handle 关闭时的回调事件循环按这个顺序不断转圈。它不是一条大队列从头到尾而是按阶段分组每转一圈就按顺序处理每个阶段。setTimeout的回调在 timers 阶段执行setImmediate的回调在 check 阶段执行普通文件读取、网络连接的回调大部分在 poll 阶段处理。这就是为什么有些回调看起来都是“过一会儿执行”但实际执行的优先级不同。3.2 微任务在什么时候插入process.nextTick和Promise的回调属于微任务不属于 Event Loop 的六个阶段。它们会在每个阶段切换时被优先处理直到清空微任务队列。顺序上process.nextTick队列通常会先于 Promise 队列执行。这意味着不要在process.nextTick里无限递归否则事件循环永远进不了下一个阶段整个程序会卡死。这个机制也解释了为什么Promise.resolve().then(...)的执行顺序经常比setTimeout(0)更靠前Promise 的回调走微任务队列会在下一轮 timers 阶段之前被处理。3.3 一组顺序实验创建一个order.js文件console.log(1); setTimeout(() { console.log(2); }, 0); setImmediate(() { console.log(3); }); process.nextTick(() { console.log(4); }); Promise.resolve().then(() { console.log(5); }); console.log(6);运行node order.js我这里的输出通常是1 6 4 5 2 3这个结果说明同步代码先全部执行完。然后process.nextTick回调执行。接着 Promise 微任务执行。最后才进入 timers 或 check 阶段执行setTimeout和setImmediate。但有一点要注意setTimeout(0)和setImmediate在顶层代码同时注册时先后顺序并不是绝对的。多跑几次偶尔会出现2在3之前偶尔会反过来。这是因为顶层执行时事件循环刚启动具体进入哪个阶段受很多细节影响。而在 I/O 回调里setImmediate回调基本总能在setTimeout(0)之前执行因为 poll 阶段之后就是 check 阶段。这个差异本身就说明不能只看某一组输出就总结死规律要理解阶段顺序。3.4 为什么 setTimeout(0) 不是“立刻执行”setTimeout(0)的意思是“至少等待 0 毫秒后在 timers 阶段执行”。它不是插队到当前代码前面也不是立即执行。如果你的同步代码执行了 3 秒那么这个setTimeout(0)至少等这 3 秒跑完再进入 timers 阶段才能执行。很多人拿它来做“延时执行”但在事件循环被阻塞的进程里这个延时会被大幅拉长。4. 用 Express.js 做一次事件循环阻塞实验4.1 环境准备先准备一个干净的目录。我这里按最普通的 Node.js 项目来不需要额外工具链。如果你使用 nvm 管理多个 Node 版本可以先确认当前的版本node -v npm -v搜索资料里经常看到“node.js not found”或安装完重启后找不到命令的问题所以跑实验前单独确认环境能少踩很多坑。版本差异主要影响 API 细节事件循环的核心机制在不同维护中的 LTS 版本上基本一致。创建项目mkdir event-loop-demo cd event-loop-demo npm init -y npm install express然后创建一个server.js和一个order.js分别用于实验。4.2 实验一同步 while 循环阻塞事件循环编写server.jsconst express require(express); const app express(); app.get(/fast, (req, res) { res.json({ ok: true, message: fast response }); }); app.get(/slow, (req, res) { const end Date.now() 3000; while (Date.now() end) { // 模拟 CPU 密集型任务 } res.json({ ok: true, message: done after 3s }); }); app.listen(3000, () { console.log(server listen on 3000); });启动服务node server.js然后在终端 A 请求/slowcurl http://localhost:3000/slow在终端 B 立刻请求/fastcurl -w \ntime_total: %{time_total}s\n http://localhost:3000/fast实验结果很明显/fast会等/slow的while循环结束后才返回响应时间大约是 3 秒。原因就是前面说的while循环是同步代码事件循环被完全占住。/fast的回调、HTTP 响应、Express 的路由调度全部排在同一队列里等着执行。这个实验是最直观的事件循环阻塞现象。它和生产环境里“某个接口偶发 CPU 高导致其他接口全部变慢”的问题是一样的。4.3 实验二异步文件读取不阻塞主线程在同一个项目目录里生成一个大一点的文本文件。Windows 下可以用 Node 脚本node -e const fsrequire(fs);const bufBuffer.alloc(20 * 1024 * 1024, a);fs.writeFileSync(big-file.txt, buf);然后修改server.js增加一个/read接口const fs require(fs); const path require(path); app.get(/read, (req, res) { const filePath path.join(__dirname, big-file.txt); fs.readFile(filePath, utf8, (err, data) { if (err) { return res.status(500).json({ error: read failed }); } res.json({ size: data.length }); }); });重启服务node server.js先在终端 A 请求/readcurl http://localhost:3000/read然后在终端 B 立刻请求/fastcurl -w \ntime_total: %{time_total}s\n http://localhost:3000/fast你会发现/fast响应依然很快没有被/read的文件读取拖住。因为fs.readFile是异步 API底层文件读取由 libuv 线程池处理主线程不需要等待文件读完。回调在文件读完后才会被放回事件循环进入 poll 阶段执行。事件循环在这段时间里可以继续处理/fast请求。4.4 实验结果判断标准现象原因下一步/fast在/slow执行期间也变慢主线程被同步代码占用检查/slow里的同步计算/read执行期间/fast仍正常I/O 被交给 libuv 或底层异步机制不必过度担心异步文件 API 阻塞事件循环并发上来后/read也变慢线程池排队或文件系统瓶颈看线程池并发、文件大小、磁盘 IOPS这个实验告诉我们事件循环只关心任务是否能及时执行不关心你的业务代码是否合理。你把同步计算写在主线程里它就阻塞你用异步 I/O它就放行。5. 真实项目里最常见的五种事件循环阻塞5.1 同步文件读写fs.readFileSync、fs.writeFileSync出现在请求链路里时文件越大阻塞越明显。很多人会用require动态加载 JSON 配置文件。require本身是同步的虽然带缓存但动态加载一个大 JSON 文件依然会阻塞主线程。如果一个接口每次请求都动态读取一个几十 MB 的配置文件事件循环会被反复卡住。高频请求路径上尽量使用异步 API。读小文件可能感觉不到差别但大文件、高并发场景下差别会非常明显。5.2 大对象 JSON 序列化JSON.stringify是同步 CPU 密集操作。一个接口要返回一个几十 MB 的对象序列化时间可能达到几百毫秒。如果代码里还有JSON.parse大字符串同理。这类问题在“后台管理系统导出数据”的场景里经常出现查出一大批数据直接JSON.stringify后返回给前端。数据量小没问题数据量一大事件循环就被占住其他接口跟着遭殃。处理思路是控制单次返回的数据量或者用流式处理不要一次性序列化超大对象。5.3 密集正则或字符串处理正则回溯是最隐蔽的 CPU 杀手。一个看似正常的正则在特定输入下可能让 CPU 跑几秒甚至几分钟。比如嵌套重复量词对某些字符串会产生灾难性回溯。如果接口需要校验用户输入建议给正则设置超时机制或者用专门的校验库。同时准备一组恶意输入做测试不要只测正常数据。这类问题很难通过代码 review 发现通常要等线上出现 CPU 飙升后用 CPU profile 才能定位到具体的正则函数。5.4 滥用 console.logconsole.log在高频路径下会显著拖慢主线程。尤其是日志内容大、输出到终端又开启颜色高亮时一次console.log是同步的在大量请求下会放大成明显的 CPU 开销。我自己的习惯是开发环境可以随便打印生产环境要把日志量压到最低。如果确实需要日志先记录到内存队列或异步日志库再批量输出。不要在每个请求的中间件里无脑打印整段请求体。5.5 数据库连接池与 libuv 线程池被打满数据库客户端本身是异步的但如果连接池没有空闲连接创建连接或等待连接也会阻塞请求回调。文件系统同样受 libuv 线程池默认并发限制。出现大面积慢请求时不一定都是事件循环主线程的问题。可能请求在等待数据库连接也可能在等待线程池里的文件操作完成。这时候 CPU 反而不高事件循环延迟也不高但请求就是慢。判断方式很简单看 CPU 和事件循环延迟。CPU 高偏向同步计算CPU 不高偏向等待外部资源。6. 事件循环是否健康怎么量化判断6.1 先看现象再定位瓶颈不要一看到接口慢就说是事件循环被阻塞。先看 CPUCPU 很高主线程或 worker 在大量计算。CPU 不高但接口慢卡在等待数据库、网络、锁或线程池。单接口慢优先看该接口内部的同步操作和外部调用。所有接口都慢优先怀疑事件循环被全局阻塞或者服务器资源耗尽。6.2 用 curl 和请求耗时日志做初步判断curl可以看整体耗时curl -w \ntime_total: %{time_total}s\ntime_starttransfer: %{time_starttransfer}s\n http://localhost:3000/slow如果time_starttransfer就接近总耗时说明服务端处理慢如果连接和响应很快但内容下载慢可能是网络或响应体过大。在 Express 中间件里记录单次请求耗时也很简单app.use((req, res, next) { const start Date.now(); res.on(finish, () { const cost Date.now() - start; console.log(${req.method} ${req.url} ${cost}ms); }); next(); });但这个日志只能看到单个请求的处理耗时看不到主线程是否被其他任务占用。要判断是否被占用需要做并发测试同时开多个请求观察相互影响。6.3 用 perf_hooks 监控事件循环延迟Node.js 自带perf_hooks模块可以监控事件循环延迟。这个指标比拍脑袋卡顿更可靠。const { monitorEventLoopDelay } require(perf_hooks); const histogram monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); setInterval(() { console.log(p50:, (histogram.percentile(50) / 1e6).toFixed(2), ms); console.log(p95:, (histogram.percentile(95) / 1e6).toFixed(2), ms); console.log(p99:, (histogram.percentile(99) / 1e6).toFixed(2), ms); histogram.reset(); }, 10000);monitorEventLoopDelay返回的是纳秒除以1e6变成毫秒。在空闲的 Express 服务上p95 延迟通常是个位数毫秒。如果 p95 长期超过几十毫秒甚至上百毫秒说明事件循环经常被阻塞要尽快定位。这个指标可以加到监控脚本里也可以临时开启排查问题。6.4 用 --cpu-prof 做 CPU 画像当怀疑某个函数占用大量 CPU 时可以用 Node.js 自带的 CPU 分析node --cpu-prof --cpu-prof-dirprof server.js运行一段时间后会在prof目录下生成.cpuprofile文件。用 Chrome DevTools 的 Performance 面板导入就能看到哪些函数占用了大量时间。看 CPU profile 时先找业务代码里的函数不要被 V8 内部函数干扰。如果看到某个路由回调或某个工具函数占用比例特别高基本就定位到了。6.5 常见的定位顺序我自己排查时会按这个顺序来先明确现象是慢、卡死、CPU 高还是无响应。确定时间范围什么操作之后开始峰值发生在什么时段。看资源CPU、内存、磁盘先排除外部资源问题。看代码有没有同步文件操作、JSON 序列化、正则、while 循环。看依赖数据库连接池、缓存连接、第三方服务是否正常。用工具CPU profile、事件循环延迟监控、日志统计。不要第一步就去改架构或加机器。很多“性能问题”其实是同步代码阻塞和机器配置没有关系。7. 低配置环境下的优化边界不要把 worker_threads 当万能药7.1 先判断瓶颈是 CPU 还是 I/O低配置服务器上看到 CPU 高、接口慢先别急着优化线程模型。如果代码里有明显的同步 CPU 计算事件循环被阻塞加再多进程也要看每个 worker 是否还在做同样的计算。如果是 I/O 等待导致请求慢比如数据库查询慢、外部 API 响应慢重点应该放在连接池配置、缓存、超时设置上而不是盲目开线程。7.2 worker_threads 适合什么Node.js 的worker_threads适合 CPU 密集型任务比如图像处理、PDF 生成、复杂计算。它可以让一部分计算脱离主线程执行从而避免阻塞事件循环。但要注意worker 与主线程通信有数据拷贝成本。如果任务本身只需要几毫秒通信开销可能大于收益。先做一个基准测试再决定要不要上 worker。我自己在低配置机器上验证过简单计算任务开 worker反而比直接在主线程里跑更慢。只有在单次计算耗时长、数据需要频繁跨线程传输的任务上worker 才有明显价值。7.3 cluster 和 PM2 解决什么问题cluster 可以利用多核 CPU 启动多个 Node 进程PM2 负责进程守护、重启和管理。它们解决的是单进程可靠性、部署管理以及把流量分散到多进程。但它们不能消除每个进程内部的事件循环阻塞。一个进程的同步阻塞只会影响该进程如果 PM2 fork 多个进程且启用负载均衡其他进程还能继续服务。这确实能提高整体可用性但没有解决根因。如果每个 worker 进程都在执行同样的 CPU 密集任务即使有 4 个进程CPU 依然可能打满接口依然会慢。7.4 更常见的优化其实是让路由只做调度在 Express 项目里不要把所有工作都塞进路由回调。一个路由函数如果既要查数据库、又要处理文件、又要做复杂计算它天然容易阻塞事件循环。更稳妥的做法是让路由只做参数校验、调度和响应把重任务放到独立队列或独立服务处理。比如生成报表、发送邮件、批量处理文件完全可以丢给队列异步执行接口先返回“任务已接收”。第一版能跑通后再根据瓶颈决定是否引入更复杂的架构。真正落地时最该盯住的不是功能列表而是输入格式、资源占用和失败重试。8. 最后说几句我的判断习惯我自己的排查顺序一般是先复制一条线上请求跑本地看是否稳定复现然后看代码里有没有同步 I/O 和 CPU 循环再用monitorEventLoopDelay监控延迟最后才考虑加 worker 或换架构。很多时候问题不是 Node.js 不行而是你把一件本该交给异步队列的事情写成了同步等待。先把单任务跑稳再考虑批量和并发这是最省时间的路径。如果只是学习默认配置通常够用。如果要长期维护就要把日志、输出目录和任务队列提前整理好。踩过几次之后我发现很多故障不是工具能力不够而是前置环境和输入材料没有处理干净。