很多刚开始写 Express 的同学都会遇到一个奇怪的现象:明明接口逻辑很简单,数据库查询也不慢,但某个请求只要一进来,整个服务的所有接口都跟着变慢,甚至直接卡死。如果你打开任务管理器,会发现 Node.js 进程的 CPU 占用率非常高,但代码里并没有复杂的计算任务。这时候大部分人的第一反应是“服务器配置不够”,于是加内存、加 CPU,问题却依然存在。
真正的原因,往往出在 Node.js 的底层运行机制上:事件驱动模型和事件循环(Event Loop)。这不是一个只存在于面试题里的理论概念,而是每天都在影响 Express 接口响应速度、服务稳定性和资源利用率的实战知识。如果你不理解 Event Loop,你就很难解释“为什么同步代码会阻塞所有请求”,也很难定位线上偶发超时的根因。
这篇文章会用通俗的类比、可运行的代码实验和 Express 实际场景,把 Node.js 的事件驱动与 Event Loop 讲清楚。读完你可以理解 Node.js 单线程为什么还能支撑高并发,也能在写 Express 接口时避开最常见的性能陷阱。
1. 为什么每个 Node.js 开发者都必须理解 Event Loop
先说一个判断:Event Loop 是 Node.js 性能模型的灵魂,不理解它,就等于在盲写 Node.js 代码。你写的每一行 Express 路由代码,最终都要被放进 Event Loop 里调度。你的接口是快是慢,是稳定还是抖动,很大程度上不取决于语法写得多漂亮,而取决于你有没有无意中阻塞事件循环。
传统 Web 开发中,很多开发者习惯了“一个请求一个线程”的模型:服务器为每个请求分配一个线程,线程阻塞了就等它,操作系统会帮忙调度。这种模型思维简单,但线程的创建、切换、销毁都有成本,高并发时内存压力很大。
Node.js 选择了另一条路:单线程 + 非阻塞 I/O + 事件驱动。这里说的“单线程”并不是真的只有一个线程,而是 JavaScript 代码的执行只有一个主线程。I/O 操作(读文件、查数据库、发 HTTP 请求)不会让主线程干等,而是先交出去,等结果回来后通过事件通知主线程处理。这个“等待结果并分配处理顺序”的机制,就是 Event Loop。
Express.js 本身就是建立在 Node.js 这套机制之上的 Web 框架。你写的app.get('/api/user', handler),本质上是往事件驱动的体系里注册了一个事件处理函数。请求到达时,Node.js 把请求交给 Express,Express 匹配路由后调用你的 handler。你的 handler 是同步耗时还是异步等待,直接决定了这个请求会不会堵住后面所有的请求。
理解 Event Loop 之后,你会发现很多线上问题的排查思路完全不一样:不用再盲目加机器,而是先看代码里有没有阻塞事件循环的同步操作,看外部调用有没有正确处理异步等待,看有没有不必要的 CPU 密集计算塞进了请求主链路。
2. 事件驱动模型的核心:从“排队叫号”到“奶茶店出杯”
要理解事件驱动,可以用一个生活场景来类比。
想象你去一家奶茶店点单。传统做法是:一个店员接待一个顾客,从点单、制作到交付全程都由这个店员负责,期间店员不能离开,其他顾客只能排队等着。顾客一多,店员再多也不够用,而且大部分时间店员都耗在等待制作设备出杯上,人力利用率很低。
事件驱动模型则像一家高效率的奶茶店:前台只负责接单,收到订单后把制作任务交给后面的机器,机器做完后通过叫号通知顾客取杯。前台店员不会被某一个订单拖住,而是可以不断接待新的顾客。顾客不需要站在原地等,只需要留意叫号。
Node.js 就是这个“前台店员”。它不亲自去做耗时的事情(读文件、查数据库、网络请求),而是把这些事情交给系统底层的线程池或其他进程,自己继续接收新任务。当耗时操作完成时,系统会向 Node.js 发出一个“事件”,Node.js 把这个事件放进队列,等到合适的时机去执行对应的回调函数。
这个机制带来一个关键优势:同一个进程可以在等待 I/O 结果的同时,继续处理新的请求。这就是 Node.js 在高并发 I/O 场景下表现优异的核心原因。它不是没有等待,而是把等待时间让给了其他请求。
但这里有一个致命前提:交给 Node.js 主线程的代码,必须是异步的、非阻塞的。如果你在请求处理中写了一段同步的 CPU 密集型计算,比如一个大循环、图像缩放、JSON 序列化超大对象,那么 Node.js 就会真的被这段代码占住,无法去处理其他请求。这时候,前台店员被一个订单锁死了,其他顾客只能干等。
Event Loop 就是回答“做完的事情什么时候处理、按照什么顺序处理”的机制。理解了这一点,接下来就能深入它的内部结构。
3. 认识 JavaScript 的单线程与异步 API
JavaScript 语言本身是单线程执行的,这意味着同一时刻只能执行一段代码。Node.js 没有改变这个事实,它只是在这个单线程之上构建了一套事件驱动的调度系统。
很多初学者会混淆“单线程”和“慢”的概念。实际上,单线程并不等于性能差。Node.js 单线程处理的是 JavaScript 回调逻辑,真正的耗时 I/O 操作由 libuv 提供的线程池(通常默认 4 个线程)来处理。文件读取、DNS 查询、部分加密操作等,都会在线程池中执行,完成后把结果通过事件机制交回主线程。
你写代码时接触到的异步 API 大致分两类:
- 基于回调或 Promise 的异步 I/O API:比如
fs.readFile、dns.lookup、crypto.pbkdf2等,这类操作会离开主线程执行。 - 定时器与事件回调:比如
setTimeout、setImmediate、process.nextTick,这类操作控制的是回调函数在 Event Loop 中的执行时机。
在 Express 开发中,你接触最多的可能是数据库驱动、HTTP 请求库(如 axios)和文件操作库,它们底层都封装了异步机制。当你用await等待一个数据库查询时,Node.js 会把这次查询交给对应的异步接口,主线程不会被阻塞,可以继续处理其他请求。等查询结果返回后,系统会安排一个事件,让 Event Loop 在合适的时候恢复你这个异步函数的执行。
理解这个模型的一个常见误区是:“用了 async/await 就一定是非阻塞的。” 不一定。如果 async 函数内部第一行代码就是一段耗时的同步循环,那么这个 async 函数同样会阻塞主线程。async/await 只是语法糖,它让异步代码看起来像同步,但并没有改变代码的实际执行方式。排查问题时,要看的不是函数是不是 async,而是函数内部有没有同步占用主线程的操作。
还有一部分开发者会把 Node.js 和浏览器 JavaScript 混为一谈。浏览器里也有 Event Loop,但两者的任务队列模型不完全一样,比如浏览器有requestAnimationFrame、Node.js 有setImmediate。本文以 Node.js 的 Event Loop 为主。
4. Event Loop 的内部结构与执行顺序
想让代码不踩坑,光知道“有事件循环”是不够的,必须要知道异步回调是按什么顺序执行的。
Node.js 的 Event Loop 是一个循环,每一轮循环里会依次处理几种不同类型的事件。官方文档把每一轮循环分成几个阶段(phase),常见的阶段包括:
| 阶段 | 说明 |
|---|---|
| timers | 执行setTimeout和setInterval的回调 |
| pending callbacks | 执行延迟到下一轮循环的 I/O 回调 |
| idle, prepare | 仅供内部使用 |
| poll | 获取新的 I/O 事件,执行 I/O 相关回调 |
| check | 执行setImmediate的回调 |
| close callbacks | 执行socket.on('close')等关闭回调 |
每一轮循环结束后,Node.js 会检查是否有待处理的微任务。微任务包括Promise.then、Promise.catch、Promise.finally、process.nextTick等。这里要特别注意:process.nextTick的优先级非常高,它会在当前操作完成后、下一阶段开始前立即执行,甚至优先于 Promise 的微任务。
一个需要记住的关键顺序是:同步代码先执行,然后执行微任务,然后进入 Event Loop 的各个阶段。在一个阶段内部,宏任务队列按顺序执行;一个宏任务执行完后,Node.js 会先清空微任务队列,再执行下一个宏任务。
为了验证这个顺序,可以运行下面这段代码:
// 文件路径:event-loop-order.js console.log('1. 同步代码开始'); setTimeout(() => { console.log('2. setTimeout 回调'); }, 0); setImmediate(() => { console.log('3. setImmediate 回调'); }); Promise.resolve().then(() => { console.log('4. Promise 微任务'); }); process.nextTick(() => { console.log('5. process.nextTick'); }); console.log('6. 同步代码结束');运行方式:
node event-loop-order.js在多数情况下,输出顺序是:
1. 同步代码开始 6. 同步代码结束 5. process.nextTick 4. Promise 微任务 2. setTimeout 回调 3. setImmediate 回调这里有一个容易让新手困惑的点:setTimeout(fn, 0)和setImmediate(fn)的顺序并不总是固定。如果两者都在主模块中调用,执行顺序取决于进程启动耗时和系统当前状态,有时 setTimeout 先执行,有时 setImmediate 先执行。但如果是在 I/O 回调中调用,setImmediate几乎总是先于setTimeout执行,因为setImmediate位于 check 阶段,会在 poll 阶段之后立即运行。
实际项目中,不要依赖setTimeout和setImmediate之间微妙的执行顺序,否则很容易写出“碰运气”的代码。需要相对明确的“下一轮”操作时,优先使用setImmediate;需要把操作放在当前阶段之后尽快执行时,使用process.nextTick,但要谨慎使用,避免递归 nextTick 饿死其他事件。
5. 环境准备:安装并验证 Node.js 与 Express
接下来进入实操部分。这段内容适合刚开始搭建 Node.js 环境的读者,如果你已经能正常运行 Express 项目,可以快速跳过,直接看第 6 节的代码实验。
本文的示例代码基于 Node.js LTS 版本和 Express 4.x/5.x 通用写法,具体版本请以实际项目为准,重点是演示事件驱动与 Event Loop 的通用思路,不需要绑定某一个具体版本。
5.1 安装 Node.js
在开始之前,先确认你的机器上有没有 Node.js。打开终端(Windows 上推荐 PowerShell 或 CMD),执行:
node -v npm -v如果两个命令都输出了版本号,说明环境已经就绪。如果提示“无法识别 node 命令”,说明 Node.js 没有安装或没有加入系统 PATH。
安装 Node.js,最推荐的方式是使用官方安装包,选择 LTS 版本,一直点下一步即可。安装过程中有一个需要注意的点:官方安装包默认会自动配置 PATH,不需要手动改环境变量。
如果你需要在多个 Node.js 版本之间切换,推荐使用版本管理工具。例如 nvm-windows 或 nvm。安装完成后,用下面的命令安装指定版本:
nvm install lts nvm use lts nvm list很多安装问题都集中在 Windows 环境,比如缺少 Microsoft Visual C++ Runtime、安装后node -v无输出、卸载失败报 2053 错误等。这里给一个稳妥的排查顺序:
- 如果是缺失运行库,安装官方提示对应的 Visual C++ Redistributable。
- 如果
node -v无输出,先检查环境变量 PATH 中是否包含 Node.js 安装目录,然后重新打开终端。 - 如果使用 nvm 安装时提示某个版本不可用,先执行
nvm list available查看可用的版本列表,再选择实际存在的版本。
5.2 初始化 Express 项目
环境就绪后,创建一个项目目录并初始化:
mkdir express-event-loop-demo cd express-event-loop-demo npm init -y安装 Express:
npm install express安装完成后,在项目根目录创建server.js文件。先写一个最简单的服务验证环境:
// 文件路径:server.js const express = require('express'); const app = express(); const port = 3000; app.get('/', (req, res) => { res.send('Hello Express'); }); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });启动服务:
node server.js打开浏览器访问http://localhost:3000,如果能看到Hello Express,说明 Node.js 和 Express 环境已经正常工作。
6. 用 Express 实验理解事件驱动:阻塞与异步的对比
环境准备好之后,我们开始做一个非常重要的实验。这个实验会让你直观感受到“事件驱动”到底是怎么回事。
6.1 同步阻塞实验:为什么一个慢请求卡住所有接口
在server.js中增加两个接口,一个正常返回,另一个模拟耗时同步计算:
// 文件路径:server.js const express = require('express'); const app = express(); const port = 3000; // 正常接口 app.get('/', (req, res) => { res.send('Hello Express'); }); // 模拟同步阻塞的接口 app.get('/slow-sync', (req, res) => { // 用循环模拟耗时的 CPU 计算,实际项目中请勿直接这样写 const start = Date.now(); let count = 0; while (Date.now() - start < 5000) { count++; } res.send(`sync blocked, count=${count}`); }); app.get('/fast', (req, res) => { res.send('fast response'); }); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });重启服务:
node server.js然后打开两个浏览器标签页:
- 标签页 A 访问
http://localhost:3000/slow-sync。 - 立刻让标签页 B 访问
http://localhost:3000/fast。
你会看到,标签页 B 的请求必须等标签页 A 的 5 秒结束后才返回。原因很简单:/slow-sync接口里的while循环是一个同步的 CPU 密集操作,它占用了 Node.js 唯一的主线程。在这 5 秒内,Event Loop 无法处理任何新的任务,所有请求都被堵在外面。
这就是事件驱动模型最核心的“雷区”:任何同步阻塞主线程的代码,都会让整个服务的并发能力归零。
6.2 异步改造实验:把耗时操作丢出主线程
现实中的接口不太会用 while 循环耗时,但会经常遇到真正的异步 I/O,比如读取文件、查询数据库、调用第三方 HTTP 接口。下面模拟一个“异步等待外部资源”的场景:
// 文件路径:server.js const express = require('express'); const app = express(); const port = 3000; function fakeAsyncTask(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } app.get('/async-task', async (req, res) => { await fakeAsyncTask(3000); res.send('async task done'); }); app.get('/fast', (req, res) => { res.send('fast response'); }); app.listen(port, () => { console.log(`Server running at http://localhost:${port}`); });重启后,再次用两个标签页访问:先访问/async-task,立刻访问/fast。这次你会发现/fast不需要等待 3 秒,可以立刻返回。
原因在于fakeAsyncTask的 3 秒等待是通过setTimeout交给事件循环调度的,主线程没有被占用。在等待期间,Event Loop 仍然可以处理新的请求。
这个实验展示了事件驱动模型的核心优势:在等待异步 I/O 结果时,主线程是空闲的,可以继续服务其他请求。但要注意,去掉了async改动,本质上只是把“等待”交给了异步 API,并没有减少总耗时。如果同时有 1000 个请求访问/async-task,它们各自需要等待 3 秒,服务端并不会让它们并发完成,但服务端可以同时接收大量请求,而不会因为等待而拒绝新请求。
6.3 验证接口耗时
为了更准确地观察请求耗时,可以写一个简单的脚本,或者用 curl 命令:
curl -w "耗时: %{time_total}s\n" http://localhost:3000/async-task curl -w "耗时: %{time_total}s\n" http://localhost:3000/fast在异步版本中,两个接口互不阻塞,/fast的耗时应该在几十毫秒以内。在同步阻塞版本中,/fast的耗时会被拉长到接近/slow-sync的总耗时。
7. 微任务与宏任务在 Express 中的实际表现
理论上的微任务和宏任务,在 Express 项目中也有真实体现。比如你在一个请求处理流程里同时使用了 Promise 和setTimeout,它们执行的先后顺序会影响你的业务逻辑是否正确。
来看一个典型例子:
// 文件路径:server.js 中的请求日志实验 app.get('/order', (req, res) => { console.log('1. 请求进入 handler'); setTimeout(() => { console.log('2. setTimeout 回调'); }, 0); Promise.resolve().then(() => { console.log('3. Promise 微任务'); }); process.nextTick(() => { console.log('4. process.nextTick 微任务'); }); res.send('order created'); });启动后访问一次/order,控制台输出顺序通常是:
1. 请求进入 handler 4. process.nextTick 微任务 3. Promise 微任务 2. setTimeout 回调这个顺序说明了一个关键点:在 handler 同步代码执行完成后,Node.js 会先处理微任务队列,再进入 Event Loop 的下一阶段。如果你在业务代码中依赖了某个定时器回调来修改状态,但状态本身是在 Promise 微任务里设置的,那么执行顺序不同,结果就会不同。
在实际的 Express 项目中,最常见的组合是 async/await 和 Promise。因为await本身就是基于 Promise 的,所以你的异步函数恢复执行时,实际上就是微任务队列在处理回调。只要不混用大量process.nextTick和setTimeout,一般不会出大问题。但如果你在做 SDK、框架或中间件开发,就必须精确理解这些队列的优先级,否则很容易写出难以排查的隐性 bug。
8. 事件驱动在 Express 中间件和路由中的设计思路
理解了事件驱动机制后,再回头看 Express 的中间件设计,会更容易明白它为什么是这种风格。
Express 中间件本质上就是一个函数,接收req、res和next。你调用next()时,就是把控制权交还给 Express 的下一个匹配中间件或路由。这种“链式传递”的设计非常契合 Node.js 事件驱动的思路:每个中间件只负责自己该做的事,完成后通过回调(next)触发下一个环节,而不是阻塞等待。
一个常见的坑是:在中间件里做了耗时的同步操作,导致之后的所有中间件都无法执行。比如:
// 错误的中间件写法 app.use((req, res, next) => { // 假设这里有一段大文件解析的同步逻辑 const data = require('fs').readFileSync('/path/to/large-file.json', 'utf8'); req.appData = JSON.parse(data); next(); });这个中间件如果在请求量大的时候执行,会让整个服务阻塞。正确的做法是改用异步读取:
// 推荐的中间件写法 const fs = require('fs'); app.use((req, res, next) => { fs.readFile('/path/to/large-file.json', 'utf8', (err, data) => { if (err) { return next(err); } req.appData = JSON.parse(data); next(); }); });不过实际工程里更推荐把大文件解析放到专门的队列或 Worker 线程中,而不是直接在请求链路里做。这个原则先记住,后面最佳实践部分会展开说。
9. 常见误区与性能陷阱
Event Loop 的话题里,有四个常见误区几乎每个新手都会踩,我列出来,你可以对照自己的代码检查一下。
误区一:async 函数一定不会阻塞
这是最普遍的一个误区。async 函数是否阻塞,取决于函数里面是否包含同步的 CPU 密集操作。下面这个 async 函数会让整个事件循环卡死:
app.get('/bad', async (req, res) => { for (let i = 0; i < 1000000000; i++) { // 纯 CPU 计算 } res.send('done'); });虽然它是 async,但 for 循环本身是同步的,主线程会在这里耗尽时间。async/await 只能把异步等待变成非阻塞,不能把同步计算变成非阻塞。
误区二:setTimeout(fn, 0) 一定立刻执行
setTimeout(fn, 0)并不是马上执行,而是把回调放入定时器阶段,等待当前同步代码执行完、事件循环到达定时器阶段后才会触发。如果主线程有大量同步任务,这个 0 延迟也会被拖得很久。
误区三:Node.js 单线程只能跑一个核心
Node.js 的主线程是单线程,但操作系统的线程池、Worker Threads、Cluster 模块都可以利用多核 CPU。如果需要做 CPU 密集任务,应该考虑使用 worker_threads 或 child_process 把它们从主线程分离出去。
误区四:数据库查询慢就加索引,不需要看 Event Loop
如果数据库查询本身不慢,但接口响应慢,问题可能出在 Node.js 进程上。比如某个请求在事件循环里塞了大量微任务,导致其他请求的 I/O 回调迟迟得不到处理。此时加再多数据库索引都没用,要先解决事件循环阻塞。
10. 常见问题与排查方法
下面整理一份 Express + Node.js 项目里和事件驱动相关的高频问题排查表。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 单个请求变慢,所有接口跟着卡顿 | 主线程被同步 CPU 密集操作阻塞 | 查看进程 CPU 占用率,检查接口内是否有大循环或同步 IO | 改成异步 IO,将 CPU 密集任务交给 Worker Threads |
| 并发量上升后响应时间陡增 | Event Loop 延迟过高,回调排队严重 | 通过日志打印事件循环延迟,观察是否有阶段积压 | 减少微任务递归,优化业务逻辑,使用 Cluster 扩展进程 |
| 使用 async/await 后接口仍然阻塞 | async 函数内部有同步循环或同步 IO | 检查函数体,确认是否存在 readFileSync、JSON.parse 大对象等操作 | 替换为异步 API,拆分任务 |
| 定时器执行时间不准确 | 主线程负载高,timer 阶段被推后 | 在定时器回调中打印实际时间与计划时间差 | 避免主线程高负载,调整任务调度策略 |
| 大量 Promise 微任务导致其他回调饥饿 | 递归或循环中持续创建微任务 | 查看代码中是否有未终止的递归 Promise | 改用迭代或限制任务量,合理分片处理 |
| 使用 Cluster 后内存占用异常 | 每个进程都持有独立的事件循环和资源 | 检查共享资源是否被重复连接 | 使用外部存储或连接池,合理设置进程数 |
排查线上问题时,有一个非常实用的手段:在关键接口中记录事件循环延迟。可以这样写:
// 文件路径:event-loop-delay.js const express = require('express'); const app = express(); app.get('/delay-check', (req, res) => { const start = Date.now(); setImmediate(() => { const delay = Date.now() - start; res.json({ eventLoopDelay: delay, message: delay > 100 ? '事件循环可能繁忙' : '事件循环正常', }); }); }); app.listen(3000);通过这种检查接口,可以快速判断服务是否处于满载状态。如果 delay 持续偏高,说明事件循环被阻塞了,需要进一步定位阻塞源。
11. 最佳实践与工程建议
理解了 Event Loop 之后,有一些工程层面的建议,可以帮助你在写 Express 项目时少走弯路。
11.1 保持主线程无事可做
这是 Node.js 开发的第一原则。主线程应该只做最简单的逻辑分发和少量计算,所有可能耗时的操作都应该异步化。遇到 CPU 密集任务,比如图像处理、加解密循环、复杂排序,优先考虑放进worker_threads或独立微服务。
11.2 谨慎使用 process.nextTick
process.nextTick的优先级高于 Promise 微任务,滥用时很容易造成其他回调饥饿。官方其实更推荐用setImmediate来安排“下一轮事件循环”的任务,除非你明确知道自己在做什么。
11.3 合理设置外部调用超时
当 Express 接口调用第三方服务时,必须设置超时。否则一个下游服务变慢,会让你的服务大量请求堆积在等待队列中,进而拖垮整个 Event Loop。标准做法是使用 axios 的 timeout 配置,或在数据库连接池中设置 acquireTimeout。
11.4 用 PM2 或 Kubernetes 多副本部署
单进程 Node.js 只能用一个 CPU 核心。生产环境中,使用 PM2 的 cluster 模式或 Kubernetes 多副本部署,可以充分利用多核能力。但要注意,每个进程都有自己独立的事件循环和内存,不要把本地内存当成共享存储,否则会出现数据不一致。
11.5 做好幂等和重试
事件驱动模型的一个特点是回调可能被重复触发或乱序处理。比如消息队列消费场景中,消费方可能重复收到同一条消息。接口设计成幂等,可以有效避免这类问题。在 Express 项目中,写更新操作时可以通过唯一请求 ID 做去重。
11.6 日志里记录关键的时序信息
排查 Event Loop 问题时,最重要的依据是时间线。日志里至少应该记录请求进入时间、中间件处理完成时间、外部调用耗时、响应发送时间。这样在问题发生时,可以快速比对各环节耗时,判断是网络耗时还是事件循环排队耗时。
11.7 不要迷信“完全无阻塞”的代码
即使你的业务代码全部异步化,外部依赖也可能成为阻塞源。比如数据库连接池满、下游接口超时、DNS 解析缓慢,都会让请求在等待途中消耗事件循环的调度能力。所以一定要有熔断、限流、降级机制,而不能只关注代码本身。
12. 从 Express 走向更底层的 Node.js 能力边界
当你理解了事件驱动与 Event Loop,你对 Express 的认知也会跟着升级。你会发现,Express 能做的不只是“写接口”,它更像是一个事件分发器:接收请求事件、根据路由分发、异步处理、返回响应。你写在路由里的每一个回调,都在参与 Node.js 的事件循环运转。
更进一步,你可以去了解 Node.js 的worker_threads、child_process、Stream 流式处理,以及 libuv 的线程池机制。它们都是在“单线程事件驱动”这个核心模型之上,为不同场景提供的扩展能力。理解主线程和线程池的关系,你才能更准确地判断哪些任务适合放主线程,哪些应该交给 worker。
比如在 Express 里需要处理大文件上传下载时,用 Stream 而不是一次性读入内存,是因为 Stream 本质上也是事件驱动的产物:它通过data、end等事件逐块处理数据,避免大对象占用主线程内存。
如果你发现某个接口需要大量计算,比如将图片批量压缩后上传,这时候就需要把计算任务从请求链路里拆出去,放进消息队列或 worker 中。否则,即使你用了异步 IO,计算本身仍然会占据主线程时间片。
事件驱动模型不是一个只能背诵的概念,它决定了 Node.js 的强项和边界。强项是 I/O 密集、高并发、实时交互类应用;边界是 CPU 密集、计算量大、同步阻塞明显的任务。
下次你再看到 Express 接口在某个请求之后突然变慢,先别急着加机器,去代码里找找有没有不被注意的同步循环、有没有大文件的同步读取、有没有在请求链路上做不必要的 CPU 计算。真正的瓶颈往往就藏在这些看似无害的代码里。