Node.js 为什么会慢?从 Event Loop、CPU、内存到并发控制聊性能问题

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 往下了解。

相关推荐
用户921080262861 小时前
AI 应用平台为什么要拆分 Java 后端和 Python AI 服务
后端
星火10241 小时前
【LangChain4j系列08】Agentic AI 多智能体协作
人工智能·后端
凤山老林1 小时前
零停机数据库演进:Spring Boot 集成 Flyway 与平滑DDL变更策略
数据库·spring boot·后端
步行cgn1 小时前
MyBatis <sql> 标签详解:SQL 片段的定义与复用
后端
想要成为糕糕手1 小时前
🏭 设计模式之工厂模式:从蜜雪冰城到 NestJS,把「new」外包出去
后端·nestjs
抓哇小菜鸡1 小时前
Spring Boot + 本地大模型(Ollama/DeepSeek) + MyBatis-Plus 企业级智能体数据分析系统从零到一源码全解析
spring boot·后端·mybatis
星火10241 小时前
【LangChain4j系列07】结构化输出与类型安全
人工智能·后端
用户298698530141 小时前
3 种方法,轻松将 PowerPoint 转换为 PDF 格式
人工智能·后端·c#
吃饱了得干活1 小时前
为什么你的Service越写越臃肿?三层架构的“业务逻辑层”是个黑盒
java·后端·架构