Skip to content

Node.js / NestJS

Node 的核心是"单线程事件循环 + 线程池"的配合:I/O 用事件驱动不阻塞主线程,CPU 密集任务才交给 libuv 线程池。本文从运行时机制讲起,落到 Buffer/流与背压、框架选型与多进程部署,最后看 NestJS 如何用依赖注入组织工程——性能与内存结论均在本机实测(Node v22.22.1 / macOS 12 逻辑核,框架版本:Express 5.2.1、Fastify 5.12.1、@nestjs/core 10.4.22)。

一、事件循环:单线程为什么能扛高并发

运行机制

Node 主进程只有一个 JS 线程,但所有 I/O(网络、文件、DNS)都交给系统异步完成,完成后把回调排进事件循环。所以单线程能并发处理大量请求:等待期间不占线程,只占一个挂起的回调

每一轮事件循环依次经历几个阶段,各阶段有自己的任务队列:

阶段做什么常见回调来源
timers执行到期回调setTimeout / setInterval
pending callbacks上一轮遗留的 I/O 回调系统级回调
poll阻塞等待并处理 I/O 事件fs 读完成、网络数据到达
check执行即时回调setImmediate
close callbacks清理关闭事件socket.on('close')

关键规则:

  • 微任务在每个阶段结束后、进入下一阶段前执行;Node 的微任务队列里 process.nextTick 优先于 Promise;
  • 同步代码最先跑完,然后才进入事件循环;
  • fs.readFile 等 I/O 完成是在 poll 阶段被取出的(对应回调之后,会先走 check 阶段,再进入下一轮 timers)。

实测:谁先谁后

js
// 运行环境:Node v22.22.1
const fs = require('fs');

setTimeout(() => console.log('setTimeout(0)'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('Promise 微任务'));
process.nextTick(() => console.log('nextTick'));

fs.readFile(__filename, () => {
  setTimeout(() => console.log('I/O 内 setTimeout'), 0);
  setImmediate(() => console.log('I/O 内 setImmediate'));
});

本次实测输出(顺序稳定的是微任务与 I/O 内的部分):

text
(同步代码)
nextTick
Promise 微任务
setImmediate
setTimeout(0)
I/O 内 setImmediate
I/O 内 setTimeout

解读:

  • nextTick 先于 Promise —— 都属微任务,但 nextTick 队列优先级更高;
  • 模块顶层setTimeout(0)setImmediate 谁先不保证(取决于进程启动耗时,Node 官方文档明确此点,本例 check 阶段先执行了);
  • I/O 回调内 setImmediate 稳定先于 setTimeout(0):poll 阶段取到回调执行完,紧接着就是 check 阶段,而新的 timer 要等下一轮才轮得到。

  • setTimeout(0) 实际有 1ms 的钳制,靠它"让出主线程"时别假设严格 0ms;
  • 循环里的同步 CPU 计算会卡死整个事件循环(定时器、I/O 全部停摆),见下一节线程池/worker_threads;
  • process.nextTick 递归调用会饿死事件循环(永不停机),需要"尽快执行但别无限递归"时优先用 Promise.resolve().then()queueMicrotask

二、libuv 线程池与 CPU 密集任务

运行机制

默认 UV_THREADPOOL_SIZE = 4crypto(哈希/签名)、fs(部分文件操作)、zlibdns.lookup 这些"看似异步"的 API 并不是事件驱动的,而是把任务丢进线程池排队执行——线程池空了任务才开跑,跑完回调再回到事件循环。

所以 Node 的并发模型是:主线程处理事件调度,4 个线程干脏活。并发 I/O 请求超过线程池容量时,任务会分批排队,这是吞吐的隐藏瓶颈。

实测:池大小对并发耗时的影响

crypto.pbkdf2 是典型的线程池任务,8 个任务同时发起(本机 12 逻辑核):

js
// 运行环境:Node v22.22.1
// 用法: node tp.js <并发数>;可用 UV_THREADPOOL_SIZE=n 调整池大小
const crypto = require('crypto');
const { performance } = require('perf_hooks');
const N = Number(process.argv[2] || 8);
const ITER = 310000;

function hash(i, cb) { crypto.pbkdf2('secret-' + i, 'salt', ITER, 32, 'sha256', cb); }

(async () => {
  const t0 = performance.now();
  await new Promise((resolve) => {
    let done = 0;
    for (let i = 0; i < N; i++) hash(i, () => { if (++done === N) resolve(); });
  });
  console.log(`并发 ${N} 次 pbkdf2 耗时: ${Math.round(performance.now() - t0)}ms`);
})();

实测结果:

任务数池大小耗时说明
44(默认)274ms一批刚好跑完
84(默认)512ms分两批,后 4 个排队
88389ms一批跑完,接近理论最优
164(默认)943ms分四批
1612719ms池加大接近一批

结论:

  • 并发任务数 > 池大小时,多余的会排队,表现为耗时近似翻倍;
  • UV_THREADPOOL_SIZE 调到核心数以内(如 8~12)通常收益明显,但不是越大越好:每个线程都有栈内存,且会与主线程争 CPU;
  • 进程启动早期设置才有效:UV_THREADPOOL_SIZE=8 node app.js

  • 纯 JS 的密集计算(大 JSON 序列化、正则回溯、加密自实现)不进线程池,直接在事件循环里跑,会冻结所有请求——这类任务用 worker_threads 起真正的并行线程;
  • 一个进程的线程池被某类任务占满时,其他依赖线程池的 API 一起变慢(比如大量同步 crypto 会拖慢文件读)——考虑拆服务或用 worker_threads 隔离;
  • 线程池大小是进程级配置,cluster 的每个 worker 各自拥有自己的池。

三、Buffer 与 TypedArray:小心共享内存

运行机制

Buffer 是 Node 对 Uint8Array 的实现,底层是一块 ArrayBuffer。三个关键事实:

  • Buffer.alloc(n) 清零分配(安全);Buffer.from(...) 按参数拷贝或共享视图
  • Buffer.from(existingBuffer.buffer)buf.subarray(a, b)(旧 API buf.slice)都不拷贝,与原 buffer 共享同一块内存
  • 修改子视图会改到原数据,反之亦然——这是最常见的 Buffer bug 来源。
js
// 运行环境:Node v22.22.1
const a = Buffer.from([1, 2, 3, 4, 5]);
const s = a.subarray(0, 3);   // 共享视图,不是拷贝!
s[0] = 100;
console.log(a[0]);            // 100 —— 原数据被"子视图"改掉了

// 需要真正拷贝的两种姿势:
const c1 = Buffer.from(a);              // 拷贝一份
const c2 = Buffer.allocUnsafe(a.length); a.copy(c2);
c1[1] = 0; c2[2] = 0;
console.log(a);                         // 不再受影响

字符串互转默认 UTF-8,中文 1 字 ≈ 3 字节;二进制协议常用 base64/hex:

js
const u = Buffer.from('你好,世界', 'utf8');
console.log(u.length);                       // 15(4 字 ×3 + 顿号 3)
console.log(u.toString('base64').slice(0, 8)); // 5L2g5aW9
console.log(u.toString('hex').slice(0, 8));    // e4bda0e5a5bd

allocUnsafe 快在哪(实测)

Buffer.allocUnsafe 不初始化内存,省掉清零成本;但遗留旧数据可能被读到,只用于马上整块覆盖的场景。小对象反复分配时差异可能被引擎优化抹平(本机 allocUnsafe 93ms vs alloc 88ms,几乎无差),真正的差距在一次分配很大的块分配后立即整块覆盖的路径(如网络分包、zlib 缓冲)。

  • 接第三方库/协议时,data 事件的 chunk 只在本回调内有效:异步保存前必须拷贝Buffer.from(chunk));
  • Buffer.from(buffer.buffer) 是把整块 ArrayBuffer包成视图,若原 buffer 只是其中一段,长度和内容都不对——需要 offset/length 参数或改用 subarray 拷贝;
  • HTTP body / 文件处理里用 chunk.toString() 拼接大字符串是 O(n²),用数组收集 chunk 再 Buffer.concat

四、流与背压:内存与文件大小解耦

运行机制

流分四类:可读(Readable)、可写(Writable)、双向(Duplex)、转换(Transform,如 gzip)。核心价值:不需要把整个数据放内存,一边读一边处理。

highWaterMark 是内部缓冲水位:写入端 write() 的返回 false 表示"缓冲区已满",必须等 drain 事件再继续写——这个"生产者暂停 → 消费者跟上 → drain 恢复"的机制就是背压pipe / pipeline 已内置这套逻辑,手工 write 时容易漏掉。

实测:整读 vs 流式(80MB 文件,独立进程)

方式耗时峰值 RSS内存与文件大小的关系
readFileSync 整读并保留50ms110.9MB(基线 30.2)必须同时容纳整个文件(+80.7MB ≈ 文件大小)
createReadStream 分块处理144ms58.7MB(基线 29.9)与文件大小基本无关,只受 highWaterMark 影响

文件越大差距越悬殊:800MB 的文件整读要 +800MB,流式仍只需 ~60MB。处理大文件/大响应/大请求体一律用流

背压触发演示(highWaterMark: 1024 的慢速写入端,每块 2ms):

js
const { Writable } = require('stream');
const slow = new Writable({
  highWaterMark: 1024,
  write(chunk, enc, cb) { setTimeout(cb, 2); },  // 模拟慢消费者
});
let sent = 0;
function push() {
  while (sent < 5000) {
    const ok = slow.write(Buffer.alloc(4096));
    sent++;
    if (!ok) {                                   // 背压:缓冲满了
      console.log('write 返回 false,暂停写入');
      return slow.once('drain', () => { console.log('收到 drain,恢复'); push(); });
    }
  }
  slow.end();
}
push();

实测中每次写入都立即 返回 false → 等 drain → 恢复,证明内存压力确实被限在缓冲水位内。

  • 不要 JSON.parse(await readFileSync(large.json)) 处理大 JSON,用流式解析(JSONStreamstream-json);
  • 手工循环 write必须检查返回值,否则小水管接大源头会内存暴涨(经典线上事故);
  • 新代码用 stream/promisespipeline(自动处理背压与错误传播),少用手动 pipe(错误不会自动向源头传播);
  • 可读流忘了消费(没加 data 监听也没 resume)会停在 paused 状态,既不报错也不前进。

五、Express / Fastify:一快一稳

实测:同机 1 万请求、并发 100、返回 JSON

框架版本总耗时QPS平均延迟p95错误
Express5.2.12.56s3,90625.35ms39.05ms0
Fastify5.12.11.09s9,13610.79ms16.43ms0

Fastify 本机约 2.3 倍吞吐。差距来源:Fastify 默认用 fast-json-stringify 编译 JSON 序列化器(按响应 schema 预生成最优代码),且路由处理比 Express 的中间件栈更直接。注意这是空路由基准,真实业务里数据库/外部 I/O 才是大头,框架差距会被稀释。

两个框架的最小写法:

js
// Express 5(中间件栈模型)
const express = require('express');
const app = express();
app.get('/json', (req, res) => res.json({ hello: 'world' }));
app.listen(3001);
js
// Fastify 5(自带 schema 校验 + 快速序列化)
const fastify = require('fastify')();
fastify.get('/json', async () => ({ hello: 'world' }));
fastify.listen({ port: 3002 });

选型建议

维度ExpressFastify
生态/中间件最全,社区事实标准够用,插件体系独立
性能一般(schema 校验 + 编译序列化)
TS 类型一般(需 @types)内置较好
学习成本低~中
适合快速原型、需要海量现成中间件对吞吐敏感的新 API、RPC 网关

NestJS 默认跑在 Express 上,也可切换 Fastify 平台——框架层再包一层,性能通常不如直接用 Fastify,但换来的是工程结构(见第七节)。

  • Express 4 里 async handler 抛错要手动 next(err),Express 5 已自动捕获——确认用的主版本再决定要不要写错误包装;
  • 不要为省几毫秒手写 JSON.stringifyres.end——会丢掉框架的错误处理与内容协商,收益有限;
  • 框架压测对比要同机器、同路径、同序列化方式,否则结论失真(曾见"差 10 倍"其实是没关日志/没开 keep-alive)。

六、cluster:用满多核 CPU

运行机制

Node 单进程只用一个核。clustermaster 进程 fork 多个 worker,worker 各自跑事件循环、共享同一个监听端口,由 master 分发连接(默认 round-robin)。适合无共享状态的 HTTP 服务:worker 挂了 master 自动拉起。

js
// 运行环境:Node v22.22.1 —— cluster.js 直接运行
const cluster = require('cluster');
const os = require('os');
const http = require('http');

if (cluster.isMaster) {
  const count = Math.min(4, os.cpus().length);
  for (let i = 0; i < count; i++) {
    const w = cluster.fork();
    w.on('exit', (code, signal) => {          // worker 崩溃自动拉起
      console.log(`worker ${w.process.pid} 退出,拉起新 worker`);
      cluster.fork();
    });
  }
  cluster.on('online', (w) => console.log('worker 上线', w.process.pid));
} else {
  http.createServer((req, res) => res.end('pid=' + process.pid)).listen(3000);
}

实测:round-robin 与故障恢复

text
[master] worker 上线 pid=35388
[master] worker 上线 pid=35389
[master] worker 上线 pid=35390
[master] worker 上线 pid=35391
[master] 响应: pid=35388   ← 请求被 4 个 worker 轮流处理
[master] 响应: pid=35389
[master] 响应: pid=35388
[master] worker 35388 退出 code=null signal=SIGKILL   ← 模拟崩溃
[master] worker 35392 上线                            ← 自动拉起新 worker

  • worker 之间不共享内存:进程内缓存/内存会话在 cluster 下各自为政,跨请求状态要放到 Redis/DB;
  • 只 fork 核心数的 worker 即可,fork 太多反而争抢 CPU;
  • 部署层可选 PM2 / Node 内置 cluster:PM2 提供日志、监控与优雅重启,内置 cluster 更轻但缺运维能力;
  • master 要负责优雅退出(先停止接新连接,等存量请求完成再退出 worker),否则发布时断请求。

七、NestJS:依赖注入与模块化

核心概念

NestJS 把工程组织成模块树,用装饰器声明依赖,运行时由 IoC 容器注入:

元素装饰器职责
Module@Module({ imports, controllers, providers })组织边界(类似"特性包")
Controller@Controller('users') + @Get/@Post路由与请求参数绑定
Service@Injectable() 注册进 providers业务逻辑,被控制器注入
Provider@Injectable()任何可注入对象(服务、仓库、客户端)
管道/守卫/拦截器@Injectable() + 特定接口参数校验、鉴权、横切逻辑

依赖注入的核心好处:模块间不 new、只声明,测试时用 mock 替换 provider 即可,不会牵一发动全身。Nest 默认 provider 是单例,构造函数注入在容器启动时完成(图里可看到 AppModule dependencies initialized)。

最小可运行工程(已实测)

ts
// main.ts —— 入口
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(18091, '127.0.0.1');
}
bootstrap();
ts
// app.module.ts —— 把控制器与服务登记到模块
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({ controllers: [AppController], providers: [AppService] })
export class AppModule {}
ts
// app.service.ts —— 业务逻辑,可注入(@Injectable)
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  hello(name?: string): string {
    return `Hello, ${name || 'World'}!`;
  }
}
ts
// app.controller.ts —— 构造器注入 AppService,Nest 自动解析
import { Controller, Get, Query } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}   // ← 依赖注入发生在这里

  @Get('hello')
  hello(@Query('name') name?: string) {
    return this.appService.hello(name);
  }
}

实测启动日志与请求:

text
[Nest] 39809  InstanceLoader   AppModule dependencies initialized +13ms
[Nest] 39809  RoutesResolver   AppController {/}:
[Nest] 39809  RouterExplorer   Mapped {/hello, GET} route
[Nest] 39809  NestApplication  Nest application successfully started

$ curl "http://127.0.0.1:18091/hello?name=Nest世界"
Hello, Nest世界!
$ curl "http://127.0.0.1:18091/hello"
Hello, World!

(工程运行环境:Node v22.22.1 / TypeScript 7.0.2 / @nestjs/core 10.4.22,tsc 编译后 node dist/main.js 启动;tsconfig 需开 emitDecoratorMetadata + experimentalDecorators。)

NestJS 底层默认是 Express(@nestjs/platform-express),Middleware/Guard/Interceptor 的执行顺序是:请求 → 中间件 → 守卫 → 管道 → 控制器 → 拦截器(前) → 服务 → 拦截器(后) → 响应

  • 循环依赖(A 注入 B、B 注入 A):容器无法决定构造顺序,需 @Inject(forwardRef(() => B)) 显式延迟解析——能避免时先避免(把公共逻辑下沉到第三个模块);
  • 忘了 tsconfig 的 emitDecoratorMetadata,依赖注入会退化为 Object 类型解析失败,报 Nest can't resolve dependencies
  • provider 默认单例:不要把请求级数据存在 service 字段里,需要每请求实例时用 @Injectable({ scope: Scope.REQUEST })(要理解其开销);
  • 加了 ValidationPipe 却没在 DTO 上用装饰器,等于没校验——管道只对声明了校验规则的 DTO 生效。

八、状态与参考

下一步

  • [ ] 补 NestJS 实战:模块拆分样例 + TypeORM/Prisma 集成 + 全局异常过滤器
  • [ ] 沉淀 Node 服务的内存分析流程(heap snapshot、--inspect 定位泄漏)
  • [ ] worker_threads 与 cluster 混合部署模式对比

基于 VitePress 构建 · 内容以知识共享方式沉淀