Node.js 有一个很经典的面试问题:
Node.js 的 JavaScript 主要跑在单线程上,为什么还能处理大量并发请求?
这个问题往往会引出 Event Loop(事件循环)、非阻塞 I/O、libuv 等概念。
但实际开发中还有另一面:Node.js 明明很擅长处理大量请求,一个耗时循环、一次大 JSON 解析,甚至一个看起来很合理的 Promise.all,却可能让接口突然变慢。
所以比"Node.js 为什么能高并发"更值得讨论的问题其实是:
Node.js 到底会在什么情况下变慢?
Node.js 并不是天然性能差。很多性能问题都和几件事有关:任务在哪里执行、一次执行多久、同时创建多少任务,以及内存里堆积了多少数据。
这篇文章不准备完整复述一遍 Event Loop 的各个执行阶段,而是从几个更常见的开发场景入手:
- Event Loop 为什么会被阻塞?
async/await为什么解决不了 CPU 密集计算?- Worker Threads(工作线程)什么时候有用?
Promise.all为什么可能让系统压力突然变大?- 大文件为什么推荐使用 Stream(流)?
- Node.js 服务运行久了为什么可能越来越慢?
- 线上接口变慢时,应该先查什么?
先从 Node.js 最基本的运行方式说起。
1. Node.js 是"单线程"的吗?
"Node.js 是单线程的"这句话经常出现,但严格来说并不完整。
更准确的说法是:
Node.js 中执行 JavaScript 的主线程通常只有一个,但 Node.js 整个运行环境并不是只有一个线程。
对于第一次接触 Node.js 的读者,可以先不用纠结底层细节,把它简单理解成下面这样:
text
JavaScript
│
▼
┌───────────┐
│ Event Loop│
│ 事件循环 │
└─────┬─────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Network I/O Worker Pool JS Callback
网络 I/O 工作线程池 JS 回调
│ │
│ fs / crypto /
│ zlib 等任务
▼
Operating System
操作系统
比如一个很常见的接口:
js
const user = await queryDatabase()
从代码上看,我们正在"等待数据库查询完成"。
但这并不意味着 JavaScript 主线程会一直停在这里,什么都做不了。
数据库查询发出去以后,Node.js 可以先处理其他请求。等数据库返回结果,再继续执行这个请求后面的逻辑。
所以对于下面这种典型 Web 服务:
text
请求 A → 查询用户信息
请求 B → 请求第三方接口
请求 C → 查询 Redis 缓存
请求 D → 查询订单数据
很多时间其实都花在"等待结果",而不是持续进行 CPU 计算。
这也是 Node.js 很适合下面这些场景的原因:
- HTTP API
- BFF(Backend For Frontend,服务于前端的后端层)
- API 网关
- 实时消息服务
- 大量网络 I/O 的应用
不过,这种模型有一个很重要的前提:
Event Loop 必须能够及时继续处理下一个任务。
如果某段 JavaScript 一直计算个不停,问题就来了。
2. 为什么一个耗时任务能拖慢其他接口?
先看一个故意放大的例子:
js
app.get('/report', (req, res) => {
let result = 0
for (let i = 0; i < 5_000_000_000; i++) {
result += i
}
res.json({ result })
})
现实项目当然不太会直接写这样一个循环,但类似情况并不少见。
例如:
- 接口里处理几十万条数据
- 对大量订单进行统计
- 生成复杂报表
- 对一个很大的 JSON 做转换
- 执行耗时算法
假设 /report 正在生成一份大型报表,这时另一个用户访问:
text
GET /health
/health 本身可能非常简单:
js
app.get('/health', (req, res) => {
res.send('ok')
})
按业务逻辑来看,两者完全没有关系。
但如果 /report 中的计算长时间占着 JavaScript 主线程,/health 也可能迟迟得不到执行机会:
text
Request A: /report
│
▼
Event Loop(事件循环)
│
▼
████████████████████████ 大量 CPU 计算
│
Request B: /health ──────┘ 等待
Request C: /user ──────┘ 等待
这就是 Event Loop Blocking(事件循环阻塞)。
可以把 Event Loop 想象成一个只有一名工作人员的窗口。
如果每个人的事情都很简单:
text
用户 A → 处理一下 → 下一个
用户 B → 处理一下 → 下一个
用户 C → 处理一下 → 下一个
整个队伍流转得会很快。
但如果某个人突然占着窗口处理十分钟,后面哪怕只是想问一句话的人,也只能等着。
所以 Node.js 并不是简单地"怕请求多"。
相比很多正在等待数据库或网络返回的请求,一个长时间霸占 JavaScript 主线程的任务往往更加危险。
实际开发中比较值得注意的包括:
- 大量循环和数组计算
- 大型数据转换
- 大 JSON 的
JSON.parse()/JSON.stringify() - CPU 密集算法
- 某些复杂正则表达式
- 同步文件操作
- 同步压缩和加密
例如接口中这样读取一个文件:
js
const content = fs.readFileSync('./data.json')
readFileSync() 中的 Sync 就表示同步。
文件没有读取完成之前,当前 JavaScript 会一直等。
如果换成:
js
const content = await fs.promises.readFile('./data.json')
文件读取就可以通过异步方式完成,等待文件的过程中,不需要一直占着 JavaScript 主线程。
当然,这不是说 readFileSync() 不能用。
比如:
- 服务启动时读取一次配置
- CLI 命令行工具
- 构建脚本
- 一次性的开发工具
使用同步 API 往往完全没问题。
真正需要关注的是:
这个同步操作是不是出现在用户请求频繁经过的关键路径上。
3. async/await 并不会把代码送到另一个线程
另一个很容易误解的问题是:
函数加上
async,是不是就不会阻塞了?
比如:
js
async function calculate() {
let result = 0
for (let i = 0; i < 5_000_000_000; i++) {
result += i
}
return result
}
虽然函数前面加了 async,里面这个循环还是在原来的 JavaScript 主线程上执行。
下面这种写法也一样:
js
Promise.resolve().then(() => {
let result = 0
for (let i = 0; i < 5_000_000_000; i++) {
result += i
}
})
Promise 改变了任务什么时候执行,但不会自动创建一个新的 CPU 线程帮我们计算。
所以这里需要区分两个很容易混淆的概念。
异步
异步更接近:
当前任务正在等待结果,我可以先去处理其他事情。
比如:
js
await fetch(...)
请求发出去之后,大部分时间是在等网络返回。
并行
并行则是:
两个计算任务真的在不同线程或不同进程中同时运行。
所以:
js
await calculateHugeTask()
如果 calculateHugeTask() 内部还是一段持续几秒的同步 CPU 计算,那么加上 await 并不会减少它对 Event Loop 的影响。
这类任务就需要换一种处理方式。
什么是 CPU 密集型计算?
前面反复提到了"CPU 密集型任务"。第一次接触这个概念时,可以先把程序里的耗时任务粗略分成两类:I/O 密集型 和CPU 密集型。
I/O 密集型任务的大部分时间是在"等"。例如查询数据库、请求第三方接口、等待网络数据,这些操作虽然可能耗时几百毫秒甚至几秒,但 CPU 并不需要在整个过程中持续计算。
CPU 密集型任务则不同,它的大部分时间都在真正使用 CPU 做计算。例如:
- 对几十万条订单做统计和聚合
- 图片处理
- 数据压缩和解压
- 加密、哈希计算
- 大规模 JSON 或数据转换
- 复杂算法计算
可以简单理解成:
text
I/O 密集型
请求数据库 → 等待 → 返回结果
↑
CPU 不需要一直计算
CPU 密集型
数据 → 计算 → 计算 → 计算 → 得到结果
↑
CPU 持续工作
这也是为什么 Node.js 很适合 I/O 密集型服务,但遇到执行时间较长的 CPU 密集型 JavaScript 时需要格外小心:如果这些计算直接运行在主线程上,就会长时间占住 Event Loop。
那么,这类任务应该放到哪里执行?这就要用到下面的 Worker Threads(工作线程)。
4. CPU 密集任务什么时候该用 Worker Threads?
Node.js 比较擅长的是 I/O 密集型工作,而不是长时间占用 CPU 的计算。
现实开发里,CPU 密集任务可能包括:
- 对大量订单做复杂统计
- 批量处理图片
- 加密计算
- 大规模文本处理
- 数据转换
- 压缩和解压
- 某些算法计算
如果一次计算持续时间比较长,就不适合一直放在 Event Loop 上执行。
这时可以考虑 worker_threads,也就是 Worker Threads(工作线程)。
结构大致可以理解成:
text
HTTP Request
│
▼
Main Thread
主线程
│
│ 提交计算任务
▼
┌────────────────────┐
│ Worker Threads │
│ 工作线程 │
│ │
│ Worker 1 │
│ Worker 2 │
│ Worker 3 │
└────────────────────┘
主线程继续负责:
- 接收 HTTP 请求
- 处理普通业务逻辑
- 等待数据库和网络 I/O
- 返回响应
而耗 CPU 的 JavaScript 可以交给 Worker Thread 处理。
这样做的目的不是让普通接口凭空变快,而是:
不要让一个耗时计算把负责处理其他请求的主线程长期占住。
不过,这里还有一个容易踩的坑。
如果每来一个请求都:
js
new Worker(...)
高并发时就会不断创建和销毁线程。
线程本身也需要消耗资源,所以更常见的工程方案是使用 Worker Pool(工作线程池)。
text
Task Queue
任务队列
│
▼
┌──────────────────────────┐
│ Worker Pool │
│ 工作线程池 │
│ │
│ Worker 1 │
│ Worker 2 │
│ Worker 3 │
│ Worker 4 │
└──────────────────────────┘
例如预先准备 4 个 Worker。
有任务时先放进队列,哪个 Worker 空闲,就让哪个 Worker 处理。
这其实已经开始涉及另一个非常常见的性能问题:
资源是有限的,所以并发数量也不能无限增加。
5. Promise.all 为什么有时候反而让系统更慢?
Promise.all 是日常 Node.js 开发中非常常见的写法。
比如一个页面同时需要:
- 用户信息
- 订单数据
- 会员信息
三个查询之间互不依赖:
js
const [user, orders, membership] = await Promise.all([
queryUser(),
queryOrders(),
queryMembership(),
])
相比一个一个等待,这样通常更快。
问题一般不是 Promise.all 本身,而是我们很容易继续写出:
js
await Promise.all(
users.map(user => fetchUserDetail(user.id))
)
如果只有 10 个用户,通常没有什么问题。
但如果这是一个"批量同步 10 万用户"的任务呢?
程序可能在很短时间里创建大量操作:
text
100000 个任务
│
├── 大量 Promise
├── 大量 HTTP 请求
├── 大量数据库查询
└── 大量内存对象
最终最先撑不住的可能根本不是 Node.js。
也可能是:
- 数据库
- 第三方接口
- 数据库连接池
- 网络连接
- 内存
- 对方接口的请求频率限制
假设数据库的 Connection Pool(连接池) 最多允许同时使用 20 个连接:
text
1000 queries
1000 个查询
│
▼
┌───────────────────┐
│ DB Pool = 20 │
│ 数据库连接池 = 20 │
└───────────────────┘
│
├── 20 个正在执行
└── 980 个等待
这 1000 个查询并不会真的同时执行。
大量任务只是在提前排队,同时占用 Promise、请求上下文和内存。
如果新的任务还在继续进入,等待队列会越来越长。
所以批量任务通常需要 Concurrency Limit(并发限制)。
例如处理 10000 条数据,不一定是:
text
10000 个任务同时开始
更合理的方式可能是:
text
每次最多处理 20 个
20 → 20 → 20 → 20 → ...
常见方式包括:
- Concurrency Limit(并发限制)
- Batch(分批处理)
- Queue(任务队列)
- Semaphore(信号量,用来限制同时执行的任务数量)
- Rate Limit(请求限流)
- Connection Pool(连接池)
这里有一个很实用的结论:
并发数越大,不代表整体处理速度一定越快。
如果数据库只能稳定处理 20 个查询,同时塞进去 1000 个,更多时候只是让 980 个任务提前排队。
6. "异步"任务也可能排队:libuv Worker Pool
还有一种情况比较容易被忽略:
代码明明使用的是异步 API,为什么还是越来越慢?
原因之一是,不同异步操作背后的执行方式并不完全相同。
很多网络 I/O 可以依靠操作系统提供的能力完成。
而一些:
- 文件系统操作
- 加密计算
- 压缩、解压
- 部分 DNS 操作
会使用 Node.js 底层 libuv 提供的 Worker Pool(工作线程池)。
可以简单理解成:
text
fs 文件操作
crypto 加密
zlib 压缩
│
▼
┌─────────────────────┐
│ libuv Worker Pool │
│ 工作线程池 │
│ │
│ Worker │
│ Worker │
│ Worker │
│ Worker │
└─────────────────────┘
但这个线程池也不是无限大的。
假设某个接口短时间内提交了大量文件处理或加密任务:
text
Worker 1 █████████
Worker 2 █████████
Worker 3 █████████
Worker 4 █████████
后面的任务:
等待...
等待...
等待...
前面的 Worker 都在忙,后面的任务自然只能等待。
这种情况通常叫 Worker Pool Saturation(工作线程池饱和)。
它和前面说的 Event Loop Blocking 很容易混淆,因为用户看到的现象可能都是:
接口怎么突然变慢了?
但两者的原因不同:
| 问题 | Event Loop Blocking(事件循环阻塞) | Worker Pool Saturation(工作线程池饱和) |
|---|---|---|
| 忙的是谁 | JavaScript 主线程 | libuv 工作线程池 |
| 常见原因 | CPU 密集 JavaScript | 文件、加密、压缩等任务过多 |
| 主要影响 | 很多 JS 回调都不能及时执行 | 依赖线程池的任务开始排队 |
| 常见处理 | Worker Threads、减少耗时计算 | 限制并发、任务队列、调整任务设计 |
所以"使用了异步 API"只能说明调用不会像同步 API 那样直接等待,并不代表背后的计算和线程资源是无限的。
7. 为什么大文件处理通常推荐 Stream?
再看一种非常常见的场景:文件下载和数据导出。
假设系统提供一个接口:
下载一个 2GB 的日志文件。
如果直接:
js
const file = await fs.promises.readFile('./logs.zip')
res.end(file)
整个过程大致是:
text
Disk(磁盘)
↓
2GB Buffer
↓
Node.js Memory(内存)
↓
HTTP Response
也就是说,需要先把整个文件读进内存,然后再发送给用户。
如果只有一个小文件,这种方式很方便。
但如果:
- 文件本身很大
- 同时有很多用户下载
- 服务器内存有限
问题就会越来越明显。
这时可以使用 Stream(流)。
Stream 的思路不是:
先把整个文件全部准备好。
而是:
读取一部分,就发送一部分。
text
Disk
↓
一小块数据
↓
Network
Disk
↓
一小块数据
↓
Network
这样就不用在内存里同时保存完整的 2GB 文件。
所以 Stream 很适合:
- 文件上传
- 文件下载
- 视频传输
- CSV / Excel 大数据导出
- 日志处理
- 数据压缩
- HTTP Proxy(HTTP 代理)
- AI 流式响应
例如现在大模型回答时"一个字一个字"输出,本质上也是一种流式数据处理场景。
不过 Stream 还需要解决另一个问题:
如果读取数据特别快,但发送数据特别慢怎么办?
8. Backpressure(背压):上游太快怎么办?
假设服务器从磁盘读取文件的速度是:
text
500MB/s
但用户当前网络只能接收:
text
20MB/s
这时候读取端明显比发送端快:
text
读取文件 >>>>>>>>>>> 网络发送
如果程序一直不停读取:
text
读取 500MB
网络只发送 20MB
剩余数据只能暂存在内存里
随着时间增长,中间等待发送的数据会越来越多:
text
Buffer(缓冲区) ↑
Memory(内存) ↑
GC(垃圾回收) ↑
所以 Stream 中还有一个很重要的概念:
Backpressure(背压)。
第一次接触这个词,可以把它理解成:
下游处理不过来了,就告诉上游先慢一点。
结构大概是:
text
Producer(生产者)
│
▼
Buffer(缓冲区)
│
▼
Consumer(消费者)
当缓冲区里的数据已经比较多:
text
Buffer 快满了
│
▼
上游暂时停止继续读取
│
▼
下游继续消费
│
▼
有空间以后继续读取
这个机制避免了数据无限堆积到内存。
其实前面讲的并发控制也是类似的思想。
数据库只能一次处理 20 个任务,就不要一次塞进去 1000 个。
网络每秒只能发送 20MB,就没有必要每秒读取 500MB 数据堆在内存里。
归根结底都是:
上游的生产速度,需要和下游真正能够处理的速度匹配。
9. 为什么 Node.js 服务有时候运行越久越慢?
有些性能问题不会马上出现。
服务刚启动时:
text
CPU 正常
内存正常
接口也很快
运行几个小时甚至几天后,却变成:
text
内存越来越高
CPU 偶尔突然升高
接口延迟变大
最终甚至 OOM
这里的 OOM 指 Out Of Memory(内存耗尽)。
这时就需要开始考虑内存泄漏或者内存使用方式的问题。
例如一个非常常见的缓存:
js
const cache = new Map()
app.get('/user/:id', async (req, res) => {
const user = await getUser(req.params.id)
cache.set(req.params.id, user)
res.json(user)
})
一开始这段代码看起来很正常。
查询过的用户放进缓存,下次就可以直接使用。
但如果系统一直出现新的用户 ID,同时这个 Map 从来不删除旧数据:
text
100MB
↓
300MB
↓
800MB
↓
1.5GB
↓
...
它最终就会变成一个没有上限的缓存。
类似的内存问题还包括:
- 无限增长的 Map / Set
- 没有数量或过期时间限制的缓存
- 全局数组持续追加数据
- Timer(定时器)长期没有清理
- EventEmitter(事件监听器)不断增加
- Closure(闭包)意外长期引用大对象
- Buffer 长时间被引用
- 请求数据被错误保存到全局对象
- 一次创建大量 Promise
而内存问题并不是只有等到 OOM 才算问题。
在真正内存耗尽之前,GC(Garbage Collection,垃圾回收) 就可能已经开始影响性能。
10. 内存越来越高,GC 为什么也会影响性能?
JavaScript 有自动垃圾回收机制,所以普通开发中通常不用手动释放对象。
比如:
js
let user = { name: 'Tom' }
user = null
如果之前那个对象已经没有任何地方继续引用,V8 会在合适的时候把它占用的内存回收掉。
但垃圾回收本身也需要 CPU 时间。
如果程序不断创建大量对象:
text
不断创建对象
│
▼
占用的 Heap 越来越多
│
▼
垃圾回收工作增加
│
▼
CPU 消耗增加
│
▼
接口延迟可能出现波动
这里的 Heap(堆内存),可以简单理解成:
JavaScript 对象主要存放的一块内存区域。
实际开发中,下面这些行为都可能增加内存分配压力:
- 短时间创建大量数组
- 大对象频繁转换
- 解析大型 JSON
- 大量字符串处理
- 短时间创建大量 Promise
- 不断生成临时对象
所以如果发现 GC 很频繁,不一定是"V8 的垃圾回收机制太慢"。
更应该继续看看:
为什么程序在不停创建这么多对象?有没有一些对象本来应该释放,却一直被引用?
Node.js 可以先通过:
js
process.memoryUsage()
查看当前进程的内存信息。
常见字段包括:
text
rss
heapTotal
heapUsed
external
arrayBuffers
第一次接触这些字段不用全部记住。
可以先关注两个概念:
heapUsed:JavaScript 堆当前实际使用了多少内存rss:整个 Node.js 进程实际占用了多少物理内存
所以:
heapUsed 并不等于 Node.js 进程全部的内存占用。
如果发现进程内存长期只涨不降,再进一步使用:
- Heap Snapshot(堆快照)
- Heap Profile(堆内存分析)
- GC Log(垃圾回收日志)
去看究竟是什么对象一直留在内存里,会比直接把 Node.js 内存限制调大更有效。
11. Node.js 不是只能使用一个 CPU Core
还有一个很常见的问题:
Node.js 不是单线程吗?那一台 8 核服务器是不是浪费了 7 个 CPU 核?
也不能这么简单理解。
假设一个 Node.js 进程正在主线程里执行大量 CPU 计算,确实可能出现:
text
8 Core CPU
Core 1 ██████████
Core 2 ░░░░░░░░░░
Core 3 ░░░░░░░░░░
Core 4 ░░░░░░░░░░
...
但这里其实有两个不同的问题。
第一种:某一个计算任务需要使用更多 CPU
例如一批图片需要进行复杂处理。
这种情况下可以考虑:
text
Worker Threads(工作线程)
把计算拆到多个线程。
第二种:整个 HTTP 服务希望利用整台机器
例如一台 8 核服务器上,可以运行多个 Node.js 服务实例:
text
Load Balancer
负载均衡
│
┌───────────┼───────────┐
▼ ▼ ▼
Node A Node B Node C
Load Balancer(负载均衡器)负责把请求分发给不同 Node.js 实例。
生产环境中还可能使用:
- Process Manager(进程管理器)
- Docker(容器)
- Kubernetes(容器编排系统)
- 云厂商负载均衡
来管理多个服务实例。
所以:
text
Worker Threads
→ 一个 Node.js 进程内部,把 CPU 计算拆到多个线程
Multiple Instances(多实例)
→ 启动多个 Node.js 服务进程,共同处理请求
两者解决的不是同一个问题。
12. 接口慢,问题可能根本不在 Node.js
前面讲了很多 Node.js 自身的性能问题,但真实线上系统里还有一种情况非常常见:
Node.js 本身其实没有多慢。
例如一个"查询订单详情"的接口可能经历:
text
Browser(浏览器)
│
▼
Node.js
│
├── Redis
├── Database(数据库)
└── Third-party API(第三方接口)
最终整个接口用了 1500ms:
text
Node.js 业务代码 20ms
Redis 5ms
Database 300ms
第三方 HTTP API 1100ms
其他 75ms
这时候去花大量时间优化一段:
js
array.map(...)
几乎没有意义。
真正需要先回答的是:
这 1500ms 到底花在哪里了?
所以线上服务通常会结合:
- Metrics(指标监控)
- Logs(日志)
- Tracing(链路追踪)
- APM(应用性能监控)
去分析完整请求。
例如把一次请求拆成:
text
Request Total(请求总耗时) 1500ms
├── Node Processing(Node 处理) 20ms
├── Redis 5ms
├── Database(数据库) 300ms
├── HTTP API 1100ms
└── Other(其他) 75ms
这样才能知道真正应该优化哪一层。
所以实际做性能优化时,一个非常重要的原则是:
先定位慢在哪里,再决定怎么优化。
否则很容易花很多时间优化了一段只占总耗时 1% 的代码。
13. Node.js 接口变慢,可以先这样排查
如果线上突然出现:
接口响应时间明显变长。
不用一上来就去看 Event Loop。
可以先从几个大的方向判断:
text
接口变慢
│
├── CPU 很高?
│ │
│ ├── 某一个 CPU 核特别高
│ │ └── 可能存在耗 CPU 的 JavaScript
│ │
│ └── 整体 CPU 都很高
│ └── 整体负载过高
│
├── Event Loop Delay(事件循环延迟)很高?
│ └── 可能存在 Event Loop Blocking
│
├── Memory(内存)持续增长?
│ └── 检查内存泄漏
│
├── GC(垃圾回收)非常频繁?
│ └── 检查内存压力
│
└── Node.js 本身指标正常?
│
├── Database(数据库)
├── Redis
├── Network(网络)
└── Third-party API(第三方接口)
不同问题,对应的排查方式也不同。
CPU 持续升高
可以看:
- CPU Profile(CPU 性能分析)
- Flame Graph(火焰图)
它们主要帮助回答:
CPU 时间到底花在哪些函数里?
比如最后发现:
text
70% CPU
↓
generateReport()
↓
transformOrders()
问题方向就比较明确了。
Event Loop 延迟明显增加
可以关注:
- Event Loop Delay(事件循环延迟)
- Event Loop Utilization,ELU(事件循环利用率)
判断 JavaScript 主线程是不是长期处于忙碌状态。
内存持续上涨
可以看:
process.memoryUsage()- Heap Snapshot(堆快照)
- Heap Profile(堆内存分析)
重点寻找:
哪些对象一直留在内存中,没有被回收?
Node.js 指标正常,但接口依然很慢
这时候更应该看:
- Tracing(链路追踪)
- Metrics(指标监控)
- Database Slow Query(数据库慢查询)
- HTTP Duration(HTTP 请求耗时)
很多时候最后会发现:
Node.js 只用了几十毫秒,真正慢的是一条数据库 SQL,或者一个第三方接口。
14. 最好自己制造一次性能问题
这些概念如果只看文章,很容易停留在"知道这个词"。
如果想真正建立直觉,可以自己做一个很小的:
text
node-performance-demo
不用做成完整项目,几个简单接口就够了:
text
GET /normal
GET /cpu-block
GET /cpu-worker
GET /sync-file
GET /async-file
GET /large-file
GET /large-file-stream
GET /promise-all
GET /concurrency-limit
GET /memory-leak
分别观察:
| 接口 | 模拟的现实问题 |
|---|---|
/normal |
普通用户查询接口,作为基准 |
/cpu-block |
模拟大量订单统计导致主线程阻塞 |
/cpu-worker |
把统计任务交给 Worker Thread |
/sync-file |
模拟请求中同步读取文件 |
/async-file |
改成异步文件读取 |
/large-file |
模拟大文件一次进入内存 |
/large-file-stream |
改成 Stream 流式返回 |
/promise-all |
模拟大量用户数据同时查询 |
/concurrency-limit |
给批量查询增加并发限制 |
/memory-leak |
模拟无限增长的缓存 |
然后按一个很简单的流程观察:
text
先运行问题版本
│
▼
发送一些请求
│
▼
观察 CPU / 内存 / 接口耗时
│
▼
修改实现
│
▼
再次运行
│
▼
比较前后差异
例如:
一边不断请求:
text
/normal
一边调用:
text
/cpu-block
看看原本很简单的 /normal 会不会也突然变慢。
然后把计算迁移到:
text
/cpu-worker
再观察区别。
又或者模拟批量查询:
text
一次 Promise.all 发送 10000 个任务
再和:
text
最多同时执行 20 个任务
比较:
- Memory(内存)
- Timeout(超时)
- Response Time(响应时间)
- 下游数据库压力
自己复现一次之后,很多 Event Loop、并发控制、Worker Thread 的概念就不再只是面试术语了。
15. 回到最开始的问题:Node.js 为什么会慢?
到这里再回头看,Node.js 常见的性能问题其实可以归纳成几类:
text
Node.js 服务变慢
│
├── Event Loop 被耗时任务阻塞
├── CPU 密集计算放在了主线程
├── 一次创建了太多并发任务
├── libuv Worker Pool 工作线程池排队
├── 大量数据一次性进入内存
├── 内存泄漏和 GC 压力
└── 数据库或外部服务本身很慢
对应的处理思路也比较清楚:
text
CPU-heavy Task(CPU 密集任务)
↓
Worker Threads(工作线程)
大量异步任务
↓
Concurrency Limit / Queue
并发限制 / 任务队列
Large Data(大数据)
↓
Stream + Backpressure
流 + 背压
Memory Growth(内存持续增长)
↓
Heap / GC Analysis
堆内存 / 垃圾回收分析
Request Slow(接口变慢)
↓
Metrics / Tracing / Profile
指标监控 / 链路追踪 / 性能分析
所以相比把 Node.js 性能优化理解成"怎么让 JavaScript 跑得更快",我更愿意把它理解成一个资源使用和任务调度问题。
CPU 是有限的,线程是有限的,数据库连接是有限的,内存也是有限的。
Node.js 擅长处理大量 I/O,并不意味着这些资源突然变成了无限。
真正需要做的是:
不要让一个任务长期占住 Event Loop,不要无限制造并发,不要把没必要一次处理完的数据全部塞进内存,同时先找到真正的性能瓶颈在哪里。
理解这些原则之后,再去看 Event Loop(事件循环)、Worker Threads(工作线程)、Stream(流)、Backpressure(背压)、Connection Pool(连接池),它们就不再是一组孤立的面试知识点。
它们其实都在解决同一个问题:
怎样让有限的系统资源持续、稳定地处理更多工作。
延伸实践:AI Mind
如果你对这些问题在 AI 应用里的实际落地感兴趣,我也在持续维护一个开源项目 AI Mind。
它从 AI Chat 出发,逐步实践流式响应、Tool Calling、MCP、受控 Agent、LangGraph Memory 等能力。项目开发过程中,也会实际遇到这篇文章里提到的一些工程问题,比如流式数据处理、工具执行、多步骤 Agent 工作流,以及不同任务之间的资源调度。
- GitHub:
https://github.com/HWYD/ai-mind - 在线 Demo:
https://ai.hwyblog.cloud/instant-mind
如果你想继续看看这些能力在真实项目里是怎么实现的,也可以从源码和 Demo 往下了解。