刚接触 Node.js 时,我就有一个疑问:JavaScript 不是单线程吗?如果一个接口正在查询数据库、读取文件或请求第三方服务,为什么服务器还能继续处理其他请求?

答案在于 Node.js 的事件循环(Event Loop)。它让 JavaScript 主线程不必傻等大多数 I/O 操作完成,而是把等待交给操作系统或底层运行时;等结果准备好后,再把后续回调放回队列,交由主线程执行。

但也要先纠正一句常见说法:Node.js 不是“不会阻塞”,而是“可以高效地避免等待 I/O 时阻塞”。 如果你在主线程做了大量同步计算、同步文件读取或巨大的 JSON 处理,它一样会卡住所有接口。

本文从一次 HTTP 请求出发,拆解 Node.js 事件循环的工作方式,并给出后端开发中真正有用的性能实践。

一、先区分两个问题:单线程与非阻塞 I/O

Node.js 中,执行你写的 JavaScript 代码的主线程通常只有一个。这意味着同一时刻,它只能运行一段 JavaScript。

但“只有一个 JavaScript 线程”不等于“只能同时处理一个请求”。关键在于,很多耗时操作并不需要 CPU 一直计算:

  • 等待 MySQL 返回查询结果;
  • 等待 Redis 响应;
  • 等待 HTTP 请求到达第三方服务器;
  • 等待网卡收到数据;
  • 等待磁盘完成异步读写。

这些都是 I/O 等待。若主线程在等待期间什么也做不了,吞吐量会很低;Node.js 的做法是发起操作后立即继续处理其他任务,等结果就绪时再继续执行后续逻辑。

下面是典型的异步代码:

1
2
3
4
app.get('/users/:id', async (req, res) => {
const user = await userRepository.findById(req.params.id)
res.json(user)
})

await 不代表整个 Node.js 进程停止。它只表示当前这个 async 函数暂停到此处;主线程可以转而执行其他请求的 JavaScript、定时器回调或已完成 I/O 的回调。数据库查询完成后,这个函数的后半部分会在之后某个合适时机恢复执行。

二、一次接口请求发生了什么

以“查询用户资料”为例,流程可以粗略理解为:

1
2
3
4
5
6
7
8
9
10
11
客户端请求

Node.js 接收连接,执行路由处理函数

发起数据库异步查询

当前函数暂停,事件循环继续处理其他工作

数据库结果到达,恢复该请求的后续代码

序列化响应并发送给客户端

这里的核心角色有三个:

  1. JavaScript 调用栈(Call Stack):正在执行的同步 JavaScript 代码所在的位置。
  2. Node.js 运行时与操作系统:负责网络、文件、定时器等底层能力;部分操作会使用 libuv 的线程池。
  3. 事件循环与任务队列:协调“什么时候把准备好的回调交给 JavaScript 主线程执行”。

JavaScript 主线程必须先把当前调用栈清空,才能执行队列中的下一个任务。因此,任何长时间不返回的同步代码,都会延迟所有后续请求的处理。

三、事件循环不是一个队列,而是一组阶段

Node.js 的事件循环由 libuv 驱动,可以粗略分为若干阶段。日常开发不必背诵底层源码,但理解顺序能帮助你解释定时器、I/O 回调和 setImmediate 的行为。

事件循环阶段
事件循环阶段

每个阶段都有自己的队列。事件循环会在阶段之间移动,并在合适时机处理队列中的回调。

1. timers:不是“精确计时器”

1
2
3
setTimeout(() => {
console.log('timer')
}, 100)

100 的含义不是“第 100 毫秒一定执行”,而是“至少等待约 100 毫秒后,才有资格被执行”。如果主线程正忙,真正执行时间会更晚。

1
2
3
4
5
6
setTimeout(() => console.log('timer'), 0)

const start = Date.now()
while (Date.now() - start < 500) {
// 同步忙等:阻塞主线程约 500ms
}

上面的定时器即便延迟设为 0,也要等这段循环结束后才能运行。

2. poll:大多数 I/O 回调的主场

网络请求、Socket 事件、部分文件 I/O 的结果,通常会在 poll 阶段进入 JavaScript 世界。poll 阶段会根据是否有待执行的 I/O 回调、是否有即将到期的计时器,决定继续处理还是前往下一阶段。

3. check:setImmediate

1
2
3
setImmediate(() => {
console.log('immediate')
})

setImmediate 的回调会在 check 阶段执行。一个实用规律是:在 I/O 回调内部同时调度 setImmediatesetTimeout(..., 0) 时,setImmediate 通常更早执行,因为 I/O 的 poll 阶段之后紧接着就是 check 阶段。

1
2
3
4
5
6
import { readFile } from 'node:fs'

readFile('demo.txt', () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})

在这个 I/O 回调中,通常先打印 immediate。而在顶层代码中两者先后顺序受环境和时机影响,不应把偶然结果当作业务逻辑依据。

四、微任务:Promise 为什么常常比定时器先执行

除了事件循环的阶段队列,JavaScript 还有微任务(microtask)队列。常见来源是:

  • Promise.thencatchfinally
  • async / await 暂停后的续执行;
  • queueMicrotask

Node.js 还提供了一个优先级更高的 process.nextTick 队列。

一个典型例子:

1
2
3
4
5
6
7
8
9
console.log('1')

setTimeout(() => console.log('2: timeout'), 0)

Promise.resolve().then(() => console.log('3: promise'))

process.nextTick(() => console.log('4: nextTick'))

console.log('5')

通常输出顺序为:

1
2
3
4
5
1
5
4: nextTick
3: promise
2: timeout

可以先记住一个对 Node.js 足够实用的规则:

  1. 当前同步代码执行完;
  2. 清空 process.nextTick 队列;
  3. 清空 Promise 微任务队列;
  4. 再进入或继续事件循环阶段,执行定时器、I/O 等回调;
  5. 每次回调执行后,仍会优先处理这些微任务。

不要滥用 process.nextTick

process.nextTick 优先级很高。如果递归不断塞入 nextTick,事件循环就没有机会处理 I/O 和定时器:

1
2
3
4
5
function dangerous() {
process.nextTick(dangerous)
}

dangerous()

这会造成事件循环“饿死”(starvation)。普通异步续执行优先用 Promise 或 queueMicrotask;需要将计算让出给事件循环时,可考虑 setImmediate

五、哪些工作真的会把接口卡住?

非阻塞 I/O 并不会自动消除所有阻塞。下面这些操作都可能占满 JavaScript 主线程。

1. 大量同步计算

1
2
3
4
5
6
7
app.get('/bad-report', (_req, res) => {
let sum = 0
for (let i = 0; i < 5_000_000_000; i++) {
sum += i
}
res.json({ sum })
})

循环运行期间,Node.js 无法执行其他请求的 JavaScript 回调,连接可能堆积、延迟会飙升。

2. 同步 API

1
2
3
import { readFileSync } from 'node:fs'

const content = readFileSync('/large-file.csv', 'utf8')

readFileSync 会阻塞当前线程直到文件读取完成。在 Web 服务请求路径中,应使用 Promise API 或回调 API:

1
2
3
import { readFile } from 'node:fs/promises'

const content = await readFile('/large-file.csv', 'utf8')

异步读取不会在等待磁盘时占住 JavaScript 主线程,但注意:读取超大文件后再一次性 JSON.parsesplit 或压缩,仍可能造成 CPU 和内存压力。

3. 大对象序列化与正则灾难

JSON.stringifyJSON.parse、复杂正则表达式、巨型数组排序都是同步 CPU 工作。尤其是存在灾难性回溯的正则,在恶意输入下可能占满一个事件循环周期甚至更久。

接口返回时应分页、限制字段和响应体大小;正则要避免嵌套量词等高风险写法,并为输入设置合理上限。

4. 加密、压缩与 DNS 等操作

部分底层任务会交给 libuv 线程池,例如某些文件系统操作、crypto 中的 PBKDF2、zlib 压缩、DNS 的 lookup。这不会直接阻塞 JavaScript 主线程,但线程池是有限资源;大量重任务会让后续任务排队,表现为接口变慢。

六、libuv 线程池:异步不等于无限并行

Node.js 的异步能力来源并不完全相同:

  • 网络 Socket 多由操作系统的事件通知机制处理;
  • 部分没有合适系统异步接口的任务,会交给 libuv 的工作线程池;
  • JavaScript 回调最终仍回到主线程执行。

默认线程池大小有限。假设服务同时收到很多文件压缩或密码哈希任务,它们会在工作线程中排队;此时即使事件循环没有被 JavaScript 计算堵住,用户仍会感到响应慢。

可以通过环境变量调整线程池大小:

1
UV_THREADPOOL_SIZE=16 node dist/server.js

但这不是万能优化。线程数过多会增加上下文切换和 CPU 竞争。正确做法是先确认瓶颈:是事件循环延迟、线程池排队、数据库连接池耗尽,还是下游服务慢;再决定是否调整配置或拆分任务。

七、await 的并发陷阱:串行与并行

事件循环能在 I/O 等待期间处理其他工作,但同一个请求里是否并发发起 I/O,仍由代码决定。

不必要的串行等待

1
2
3
const user = await getUser(userId)
const orders = await getOrders(userId)
const coupons = await getCoupons(userId)

若三次查询互不依赖,以上代码总耗时接近三者耗时之和。

互不依赖时使用 Promise.all

1
2
3
4
5
const [user, orders, coupons] = await Promise.all([
getUser(userId),
getOrders(userId),
getCoupons(userId),
])

这样能同时发起三个 I/O,请求总耗时通常接近最慢的那一个。但也要避免不受控制地并发:一次 Promise.all 启动几千个数据库查询,可能把数据库连接池和下游服务压垮。批量任务应配合并发限制。

1
2
3
4
import pLimit from 'p-limit'

const limit = pLimit(10)
const results = await Promise.all(ids.map((id) => limit(() => fetchDetail(id))))

八、CPU 密集型任务应该放到哪里

当接口确实需要进行图片处理、报表聚合、视频转码、复杂加密或大规模计算时,不能期待事件循环“自动解决”。常见方案如下:

方案 适合场景 特点
Worker Threads 可在同一服务内拆分的 CPU 计算 可共享进程资源,通信成本较低
Child Process 调用独立程序或隔离性更强的任务 进程隔离更彻底,开销也更大
消息队列 + Worker 服务 耗时长、允许异步完成的任务 可削峰、重试、水平扩容
独立计算服务 视频、AI、报表等专业工作负载 可按任务特征独立扩容

例如,使用 Worker Threads 将 CPU 计算移出请求主线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// main.ts
import { Worker } from 'node:worker_threads'

function runReportJob(input: ReportInput): Promise<ReportResult> {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./report-worker.js', import.meta.url), {
workerData: input,
})
worker.once('message', resolve)
worker.once('error', reject)
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exited with ${code}`))
})
})
}

生产环境通常会复用 Worker,而不是每个请求都新建一个线程;对于耗时很长的任务,更适合返回任务 ID,再由后台异步处理并让客户端轮询或订阅结果。

九、如何观察事件循环是否被卡住

用户看到的是接口慢,工程上要进一步判断“慢在哪里”。Node.js 可以使用 perf_hooks 监控事件循环延迟:

1
2
3
4
5
6
7
8
9
10
import { monitorEventLoopDelay } from 'node:perf_hooks'

const histogram = monitorEventLoopDelay({ resolution: 20 })
histogram.enable()

setInterval(() => {
const p99Ms = histogram.percentile(99) / 1e6
console.log({ eventLoopP99Ms: p99Ms.toFixed(2) })
histogram.reset()
}, 10_000).unref()

如果 p99 事件循环延迟持续很高,说明主线程可能被同步代码、GC 压力或过多回调占用。接下来可结合 CPU 使用率、慢接口日志、火焰图和 APM trace 定位具体函数。

还应同时监控:

  • HTTP 请求的 p50、p95、p99 延迟与错误率;
  • 数据库、Redis、第三方服务的耗时;
  • 连接池等待时间与线程池任务排队;
  • 进程内存、GC 次数和 CPU 使用率;
  • 队列积压与后台任务耗时。

只看平均响应时间很容易掩盖少量长尾请求;对交互接口来说,p95 和 p99 往往更能反映真实体验。

十、Web 服务中的实践清单

把事件循环原理落到接口开发,可以归纳为以下几条:

  1. 请求路径中避免 fs.*Syncchild_process.*Sync 和长时间循环。
  2. 大文件采用流式处理,不要整块读进内存再处理。
  3. 用分页、字段裁剪和体积限制控制 JSON 序列化成本。
  4. 互不依赖的 I/O 使用合理的并发,而不是无意义地串行 await
  5. 对数据库和第三方请求设置超时、重试策略与并发上限。
  6. CPU 密集型工作交给 Worker Threads、后台队列或专用服务。
  7. 不要递归滥用 process.nextTick 或无限微任务。
  8. 通过事件循环延迟、慢查询和链路追踪持续验证,而不是只凭感觉优化。

结语

Node.js 能以较少的线程支撑大量并发 I/O,并不是因为 JavaScript 同时执行了很多段代码,而是因为它在“等待”时主动让出主线程。事件循环负责接住已完成的异步结果,再让 JavaScript 在恰当的时间继续执行。

理解这一点后,很多现象就不再神秘:await 不会停住整台服务器;慢数据库会拖慢对应请求却不必阻塞其他请求;而一个大循环、同步 I/O 或昂贵的 JSON 处理,却能让所有用户一起等待。

写 Node.js 后端时,最重要的习惯就是区分 I/O 密集和 CPU 密集任务:前者善用异步、连接池与并发控制,后者主动拆分、迁移或异步化。这样,事件循环才能真正成为接口吞吐量的优势,而不是性能问题的盲区。