本页定位 · Node.js 进阶

《Node.js 全栈》讲了事件循环、Express、NestJS 入门、部署。本页进入企业级 Node 服务真正该补的深度:事件循环与 libuv 的底层时序、TypeScript 类型体操、NestJS 的全套请求处理管道、ORM 的 N+1 与事务、测试与优雅部署、以及性能诊断与并发模型。我会像《Java 进阶》那样,把每个点拆成"是什么 → 为什么 → 怎么用 → 踩过什么坑"。基线:Node 20 LTS+、NestJS 10+、Prisma、Vitest。

1

事件循环与 libuv:宏任务 / 微任务 / Phase

Event Loop · libuv · Macrotask · Microtask

"Node 是单线程"这句话只说对一半。它靠事件循环 + 非阻塞 IO 用一条主线程扛住高并发,但一旦你没搞清"谁的回调先跑",就会出现"明明先写的却后执行"的诡异 bug。这一章把时序讲透。

单线程为什么能高并发

论用"等"的时间换"算"的线程

多数 Web 服务的时间花在等(等数据库、等网络、等磁盘)而不是算。多线程模型为每个连接开一个线程,等的时候线程干瞪眼还占内存。Node 反过来:一条主线程只负责"编排",把"等"交给 libuv 和操作系统,等到了再回调——所以几千并发也只要一条线程,内存友好。

模型并发单位等待时代价
多线程(如 Java Tomcat)每请求一线程线程阻塞挂起线程栈内存、上下文切换
事件循环(Node)单线程 + 回调主线程去干别的怕 CPU 密集阻塞主线程

事件循环六阶段(Phases)

每次"滴答(tick)",事件循环按固定顺序走一遍阶段,每阶段处理一类回调。理解顺序才能预判时序。

阶段处理什么
timerssetTimeout / setInterval 到期的回调
pending callbacks上轮推迟的 I/O 回调(如 TCP 错误)
idle/prepare内部使用
poll取 I/O 事件(文件/网络),没活则在此停留
checksetImmediate 的回调
close关闭事件(如 socket.on('close'))

宏任务 vs 微任务:优先级的关键

每个阶段之间,事件循环会清空所有微任务(microtask)再进下一阶段。微任务包括 Promise.then、queueMicrotask、process.nextTick(nextTick 比普通微任务还早)。所以"微任务总插队在宏任务前"。

// 经典顺序题:猜输出 console.log('1 同步'); setTimeout(() => console.log('4 setTimeout(宏任务)'), 0); Promise.resolve().then(() => console.log('3 Promise(微任务)')); process.nextTick(() => console.log('2 nextTick(更早的微任务)')); // 输出顺序:1 → 2 → 3 → 4 // 同步先跑;本轮宏任务(setTimeout)前,先清空 nextTick 队列再清空微任务队列

论为什么微任务会"插队"

微任务的设计是为了让"状态变更后的后续处理"尽快、连续地执行(比如 Promise 链)。但滥用 queueMicrotask 或无限 Promise.then 会饿死事件循环——微任务队列永远清不完,宏任务(含 I/O)永远排不上,服务假死。所以微任务里别搞无限递归。

libuv 线程池:有些"异步"其实是假异步

网络 IO(HTTP、TCP)是真异步(交给操作系统 epoll/kqueue)。但 fs、crypto、dns 等是 libuv 用固定大小线程池(默认 4 个)模拟异步。线程池满了,这些"异步"操作也会排大队。

// 调整 libuv 线程池大小(进程级,须在首次调用前设置) process.env.UV_THREADPOOL_SIZE = '16'; // 大文件/加密密集时调大 // 默认 4 个线程:大量 fs.readFile 同时跑会被这 4 个线程限流 import { readFile } from 'node:fs/promises'; const files = await Promise.all(paths.map(p => readFile(p)));
线程池耗尽 = 全盘变慢

默认的 4 个 libuv 线程被密集的 fs/crypto.pbkdf2 占满后,连 DNS 解析都跟着卡。高 IO 密集服务记得调 UV_THREADPOOL_SIZE,或把重加密丢进 Worker Threads。

时序陷阱:setImmediate vs setTimeout(0)

// 在主模块里:setImmediate 在 check 阶段,setTimeout(0) 在 timers 阶段,下一轮 setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); // 多次运行可能先 timeout 也可能先 immediate(取决于进入循环时机) // 但在 I/O 回调里,setImmediate 一定先于 setTimeout(0) import fs from 'node:fs'; fs.readFile('x', () => { setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); // 总是先打 immediate });

阻塞事件循环的罪魁

CPU 密集任务会拖垮整个服务

在事件循环里做大 JSON.parse、加解密、复杂正则、大循环,会让所有请求排队——因为主线程被占着,谁都进不来。这类活一定要进 Worker Threads 或拆成异步任务,主线程只做 IO 编排。

// 反例:在主线程同步解析 200MB JSON,期间所有请求卡死 const data = JSON.parse(hugeString); // 阻塞! // 正解:用流或 Worker 处理,或改 readFile 后 await const data = await parseInWorker(hugeString); // 不阻塞主线程

async/await 的本质:还是微任务那套

await 看起来像"同步等待",本质是被编译成 Promise 链:await x 之后的代码被包成 .then(...) 回调,作为微任务在 x resolve 后执行。所以"await 之后"不阻塞主线程,只是把后续注册成微任务,让位给别的活。

async function main() { console.log('A'); await Promise.resolve(); // 后面的代码被塞进微任务 console.log('B'); } main(); console.log('C'); // 输出 A → C → B:main 在 await 处让出,先跑完同步的 C

论await 不等于 sleep 阻塞

很多新手以为 await 期间"线程停住了"。其实停的是"这个函数后续的代码",主线程立刻去干别的(比如上面先打 C)。这正是 Node 高并发的来源:等的时候别人在跑。但如果在 await 前后共享可变状态,要注意"让出期间状态可能已被别的回调改了"——这是异步 bug 的高发地。

2

TypeScript strict 与类型体操

strict · Generics · Conditional Types · Mapped Types

Node 原生跑 JS;要享受 TS 的类型安全,需要执行/编译层,并在 tsconfig 里打开严格模式。"类型体操"不是炫技,是把运行时错误挡在编译期、让 IDE 精准提示的本事。

strict 不是矫情,是护栏

# 开发用 tsx 直接跑 TS,免编译 npx tsx src/main.ts # tsconfig.json 关键严格项 { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }

论strict 把漏洞挡在编译期

strict 把 null/undefined 的可能性强制你处理,noUncheckedIndexedAccess 让数组取值变成 T | undefined。初学觉得"加类型好烦",维护期会觉得"幸好有类型"。NestJS 大量用装饰器元数据,配合 TS 类型能省很多运行时 bug。

常用 strict 子项速查

选项作用
strictNullChecksnull/undefined 不再可赋值给任意类型
noImplicitAny不允许隐式 any,必须显式标注
strictPropertyInitialization属性必须在构造时初始化
noUnusedLocals未使用变量报错,逼你清理
noFallthroughCasesInSwitchswitch 漏 break 报错

论渐进开启 strict,别一上来劝退团队

老项目直接 "strict": true 可能报几百错。可分步:先开 strictNullChecks,再 noImplicitAny,最后补齐其余。配合 // @ts-expect-error 临时压制、逐步清。CI 里设 tsc --noEmit 门禁,防止类型回退。

泛型基础:让类型跟着数据走

// 泛型函数:入参类型和返回类型绑定 function first<T>(arr: T[]): T | undefined { return arr[0]; } const n = first([1, 2, 3]); // T 推断为 number const s = first(['a', 'b']); // T 推断为 string // 泛型约束:只接受有 id 的对象 function byId<T extends { id: string }>(x: T): string { return x.id; }

条件类型:类型层面的三元表达式

// 语法:T extends U ? X : Y type IsString<T> = T extends string ? 'yes' : 'no'; type A = IsString<string>; // 'yes' type B = IsString<number>; // 'no' // infer:在条件里"捕获"类型,提取 Promise 里的真实类型 type Unwrap<T> = T extends Promise<infer V> ? V : T; type C = Unwrap<Promise<User>>; // User

论条件类型 why 有用

它让类型"根据输入自动推导输出",比如你写一个 ReturnType<T> 提取函数返回类型、写 Awaited<T> 解开 Promise。库作者用它生成精确的派生类型,调用方拿到零成本的自动补全与校验。

映射类型:批量改造已有类型

// 内置工具类型本质就是映射类型 type MyPartial<T> = { [K in keyof T]?: T[K] }; // 全部可选 type MyReadonly<T> = { readonly [K in keyof T]: T[K] }; // key 重映射:把每个字段名加前缀 type Prefixed<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };

类型守卫与判别联合

// 判别联合:用一个字段区分多种形状,TS 自动收窄 type Evt = | { kind: 'click'; x: number; y: number } | { kind: 'key'; code: string }; function handle(e: Evt) { if (e.kind === 'click') console.log(e.x, e.y); // 此处 e 已收窄为 click else console.log(e.code); // 此处为 key } // 自定义类型守卫:返回值带类型断言 function isUser(v: unknown): v is User { return typeof v === 'object' && v !== null && 'id' in v; }
类型体操别玩脱

过度嵌套的条件/映射类型会让编译变慢、报错信息天书化、同事看不懂。原则:业务代码用现成工具类型 + 判别联合即可;只在写 SDK/通用库时才上深层体操,并配单元测试锁定类型行为。

3

NestJS:模块化 / DI / 守卫 / 拦截器 / 管道 / 异常过滤器

Module · DI · Guard · Interceptor · Pipe · Filter

Express 灵活但"各写各的",大项目容易成面条。NestJS 借鉴 Spring 的模块化、依赖注入、装饰器驱动,并给请求处理配了一整条可插拔的管道。这一章把每个零件讲清楚。

模块化:边界清晰,新人看目录就懂

// 一个 Module 把 controller / service / 依赖收敛在一起 @Module({ imports: [DbModule], // 依赖的其他模块导出的 provider controllers: [UserController], providers: [UserService], exports: [UserService], // 想被别的模块用,必须导出 }) export class UserModule {}

论约定优于配置

用 @Controller / @Injectable / @Module 明确边界,DI 容器自动管理依赖。新人进来"看目录就知道东西在哪",比散落的 Express 回调好维护得多。代价是学习曲线和框架约束——大项目这笔买卖很值。

依赖注入:构造器注入最稳

@Injectable() export class UserService { constructor(private readonly repo: UserRepo) {} // 声明即注入 findOne(id: string) { return this.repo.findById(id); } } // 自定义 provider:可注入接口/配置/工厂结果 @Injectable() export class UserController { constructor(private readonly svc: UserService) {} @Get(':id') findOne(@Param('id') id: string) { return this.svc.findOne(id); } }

守卫 Guards:在路由前做鉴权

// 守卫返回 true 放行、false/抛异常则拒 @Injectable() export class AuthGuard implements CanActivate { canActivate(ctx: ExecutionContext): boolean { const req = ctx.switchToHttp().getRequest(); return !!req.user; // 没登录就拦下 } } // 使用:Controller 或路由上挂 @UseGuards(AuthGuard) @Get('me') me() { ... }

拦截器 Interceptors:包住整个调用

// 拦截器可改入参、记日志、包装响应、换返回值 @Injectable() export class LoggingInterceptor implements NestInterceptor { intercept(ctx: ExecutionContext, next: CallHandler) { const t = Date.now(); return next.handle().pipe( // 响应是 Observable,可加工 tap(() => log.info(`%s 耗时 %sms`, ctx.getHandler().name, Date.now() - t)), ); } }

论请求处理管道顺序

一个请求进来依次经过:中间件 → 守卫 → 拦截器(前) → 管道 → 控制器 → 拦截器(后) → 异常过滤器。把"鉴权"放守卫、"校验转换"放管道、"日志/响应包装"放拦截器、"统一错误"放过滤器,职责清晰、可全局复用。

组件典型职责能否改响应
中间件全局前置处理(CORS、日志)可(直接响应)
守卫 Guard鉴权、放行判断否(只拦/放)
拦截器日志、响应包装、缓存可(pipe 加工)
管道 Pipe校验、类型转换否(改入参值)
异常过滤器统一错误结构可(返回错误体)

管道 Pipes:校验与转换入参

// 内置 ValidationPipe:用 class-validator 装饰器校验 DTO @Post() async create(@Body() new CreateUserDto) { ... } class CreateUserDto { @IsEmail() email: string; @Min(0) @Max(120) age: number; // 不合法直接 400 } // 自定义管道:把字符串 id 转成数字 @Injectable() export class ParseIntPipe implements PipeTransform { transform(v: string) { return Number(v); } }

异常过滤器:把错误变成统一响应

// 全局异常过滤器:业务异常 → 结构化错误响应 @Catch(BizException) export class BizFilter implements ExceptionFilter { catch(ex: BizException, host: ArgumentsHost) { const res = host.switchToHttp().getResponse(); res.status(400).json({ code: ex.code, msg: ex.message }); } } // 配合全局注册:app.useGlobalFilters(new BizFilter())
别在生产抛原始错误

直接把数据库异常、堆栈甩给前端,既泄露内部信息又难解析。统一用异常过滤器转成 { code, msg } 结构;敏感细节(SQL、堆栈)只进日志,响应里给安全的提示码。

4

ORM 与 N+1 / 事务

Prisma · TypeORM · N+1 · Eager/Lazy · DataLoader · Transaction

手写 SQL 灵活但易错、难维护。ORM 把表和对象映射起来,但用不好会踩性能坑。这一章聚焦最致命的 N+1、加载策略、批处理与事务。

ORM 选型:Prisma vs TypeORM

PrismaTypeORM
风格Schema 优先、类型生成装饰器、entity 类
类型安全极强(代码生成)中(需手写类型)
迁移声明式 migrate命令行 migrate
学习曲线低(约定清晰)中(概念多)

N+1 查询:ORM 头号性能坑

// 坏例子:先查 100 个用户,再循环里每人查一次文章 → 1 + 100 次查询 const users = await prisma.user.findMany(); for (const u of users) { u.posts = await prisma.post.findMany({ where: { authorId: u.id } }); // N 次! }
N+1 拖垮接口的真相

1 次列表查询 + N 次关联查询,100 个用户就是 101 次 round-trip,每次都有网络/驱动开销,接口从几十 ms 飙到几秒。上线前用慢查询日志 / ORM 日志盯一下"同一请求里的相似查询数"。

预加载(include / join):一次带出关联

// 好例子:用 include 一次取出用户及其文章,避免 N+1 const users = await prisma.user.findMany({ include: { posts: true } // 一条 JOIN 搞定 }); // TypeORM 同理:relations 预加载 const users = await repo.find({ relations: ['posts'] });

论预加载也要节制

include 整棵关联树会让单条查询又重又宽(笛卡尔积爆炸)。只 include 你真正需要的字段/关联,配合 select 瘦身。深层嵌套用分页 + 按需加载,别一次性把全树拉进内存。

eager / lazy 加载策略

TypeORM 的 { eager: true } 会让关联在查主实体时自动带出;lazy 则返回 Promise,访问时才查。Prisma 没有 eager,默认都要显式 include——反而更可控、更不容易"不知不觉触发一堆查询"。

// TypeORM:eager 自动带出,省心但易引发隐式 N+1 @ManyToOne(() => User, u => u.posts, { eager: true }) author: User; // Prisma:没有隐式加载,必须写 include,意图更清晰 prisma.post.findUnique({ where: { id }, include: { author: true } });

DataLoader:把分散查询批处理成一条

// 同一 tick 内多次按 id 取用户,DataLoader 合并成一条 IN 查询 const userLoader = new DataLoader<string, User>(async (ids) => { const users = await prisma.user.findMany({ where: { id: { in: ids } } }); return ids.map(id => users.find(u => u.id === id)!); // 按入参顺序回填 }); // GraphQL 解析器里多次调用 userLoader.load(id),实际只打 1 次库 const a = await userLoader.load('1'); const b = await userLoader.load('2');

论什么时候用 DataLoader 而不是 include

include 适合"固定结构的一次性查询"(REST 详情页)。DataLoader 适合调用点分散、按需触发的场景(GraphQL 每个字段解析器各取各的),它在同一个事件循环 tick 内把 N 次 load 合并成 1 次,天然解决 N+1。

事务:保证多步操作的原子性

// Prisma: interactive transaction,全成功或全回滚 await prisma.$transaction(async (tx) => { await tx.account.update({ where: { id: from }, data: { bal: { decrement: amt } } }); await tx.account.update({ where: { id: to }, data: { bal: { increment: amt } } }); }); // TypeORM:用 QueryRunner 手动管理 const qr = repo.manager.connection.createQueryRunner(); await qr.startTransaction(); try { await qr.manager.save(a); await qr.manager.save(b); await qr.commitTransaction(); } catch (e) { await qr.rollbackTransaction(); throw e; } finally { await qr.release(); }
事务里的坑

① 事务过长:持有连接/锁太久,并发下互相阻塞,务必缩到最小范围。② 长事务里做网络调用:外部 API 慢会让事务挂半天。③ 忘了 release:连接泄漏,连接池耗尽。④ 跨多个数据源:单事务管不了,需要 Saga/消息最终一致。

连接池与迁移:被忽视的命脉

论连接池不是越大越好

连接池过小,请求排队等连接;过大,数据库被压垮、上下文切换暴涨。经验起点:池大小 ≈ (核心数 × 2) + 磁盘数(PostgreSQL 官方公式),再按压测调。Prisma 默认连接数保守,容器多副本时总连接数 = 副本数 × 单副本连接数,要留意数据库 max_connections 上限,别被副本数悄悄打满。

// Prisma:通过连接字符串参数控制池(非交互式场景) // DATABASE_URL="postgresql://u:p@host/db?connection_limit=10&pool_timeout=20" // 迁移要进版本管理,生产用 migrate deploy(不是 dev,避免交互/破坏) // prisma migrate deploy # CI/部署时应用已生成的迁移
5

测试 / 部署 / 优雅退出

Vitest · Jest · supertest · Cluster · PM2 · Docker

能跑在本地 ≠ 能扛生产。测试保证行为不漂移,部署要处理进程守护与优雅退出,配置与密钥要外置。这一章把"可交付"讲明白。

论配置外置,别把环境绑死在代码里

同一份构建物要在 dev/staging/prod 跑,靠的是配置外置:用环境变量(dotenv / 容器 env)注入数据库连接、端口、开关,密钥走密钥管理(Vault / 云 Secrets)绝不进仓库或镜像。这样"改环境不用重新打包",也避免密钥泄露。CI 里用类型安全的 config 模块(如 t3-oss/env)在启动期就校验缺漏的配置。

测试分层:投入看性价比

论测试分层的性价比

纯函数/工具写快单测;Service/Controller 用 supertest 或集成测覆盖关键路径;外部依赖(DB、第三方)用 mock 或测试容器。覆盖率别盲目追 100%,盯住核心业务分支(金额、状态、权限)。

层工具管什么
单元Vitest / Jest纯逻辑、工具函数
集成(HTTP)supertest路由、中间件、校验
集成(DB)测试库 / Testcontainers真实 SQL、事务

单测 + supertest 集成测

// Vitest 单测 + supertest 发真实 HTTP 请求 import request from 'supertest'; import { app } from '../src/app'; it('GET /users/1 返回用户', async () => { const res = await request(app).get('/users/1'); expect(res.status).toBe(200); expect(res.body.id).toBe('1'); });

Mock 与测试数据库

// 单元层:mock 掉外部依赖,专注逻辑 vi.mock('../src/repo', () => ({ findOne: vi.fn().mockResolvedValue({ id: '1' }) })); // 集成层:用真实测试库(或 Testcontainers 起 Postgres) beforeAll(async () => { await db.migrate(); }); afterEach(async () => { await db.clean(); });

部署:Cluster 吃满多核

Node 单进程只用一核。生产用 cluster 模块 fork 出 CPU 核数个进程,由主进程转发请求,再配合反向代理负载。

// cluster:主进程 fork,子进程共享端口 import cluster from 'node:cluster'; import os from 'node:os'; if (cluster.isPrimary) { os.cpus().forEach(() => cluster.fork()); // 每核一个 worker } else { startServer(); // worker 真正起服务 }

论三类并发手段怎么选

Cluster:fork 多进程吃满多核,配反向代理做负载(最常用)。Worker Threads:同进程内多线程,适合 CPU 密集(图像/加密),共享部分内存。子进程/消息队列:把重活(视频转码、批量导出)丢出去异步做,主服务只接请求。

PM2 与 Docker:守护与可复现

# 多核进程守护 + 零停机重启 pm2 start dist/main.js -i max pm2 reload all # Docker 多阶段构建:小镜像、只含生产依赖 # FROM node:20-alpine AS build ... RUN npm ci && npm run build # FROM node:20-alpine RUN npm ci --omit=dev # CMD ["node","dist/main.js"] # 关键:暴露 /health、读环境变量、日志走 stdout

优雅退出:别让在途请求被一刀切断

// 收到 SIGTERM(K8s/PM2 发):先停收新请求,处理完在途再退出 let server = startServer(); async function shutdown(sig: string) { log.info(`收到 ${sig},开始优雅退出`); server.close(); // 不再 accept 新连接 await db.$disconnect(); // 释放连接 process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));
没有优雅退出 = 用户看到 502

滚动发布时若直接 kill 进程,正在处理的请求被硬断,用户收到 502/超时。务必监听 SIGTERM、先 server.close() 拒绝新连接、等空闲再退,并给 K8s 配 terminationGracePeriodSeconds 留足时间。

6

性能与诊断:泄漏 / Profiling / Worker Threads

heapdump · CPU Profiling · Worker Threads · Event Loop Lag

上生产后,慢与崩都会找上门。内存泄漏让 RSS 只涨不跌,CPU 热点拖慢一切,事件循环延迟让接口忽快忽慢。这一章给一套可落地的诊断手段。

先看全貌:诊断工具箱

现象工具
内存只涨不跌heapdump + Chrome DevTools / clinic.js heap-prof
CPU 高、慢--prof + 0x / clinic.js flame
接口偶尔卡event-loop-lag / 0x
线上不重启看node --inspect + 远程 DevTools

内存泄漏:找"收不走的引用"

// 常见泄漏:全局数组/缓存无限增长、闭包持大对象、监听器没移除 const cache = []; // 反例:永远 push 不清 setInterval(() => cache.push(new BigObj()), 100); // 抓堆快照对比:哪类对象次数只增不减,就是泄漏源 // node --inspect 后,DevTools → Memory → 拍两张 heap snapshot 对比 // 或程序内触发: import { writeHeapSnapshot } from 'node:v8'; writeHeapSnapshot('dump.heapsnapshot');
这些写法最容易导致泄漏

① 忘记退订的事件/DOM 监听(Node 里是 EventEmitter 的 'data'/'error')。② 闭包引用外部大对象且长期存活。③ 无界缓存只增不删(用 LRU 或带 TTL)。④ Promise 未 await 导致队列堆积。定位靠"两次快照 diff 增长最多的类"。

CPU Profiling:一眼看热点

# 1) 采样生成 cpuprofile node --prof dist/main.js && node --prof-process isolate-*.log > profile.txt # 2) 用 0x 直接出火焰图(推荐) npx 0x dist/main.js # 访问服务施压后 Ctrl+C,自动开火焰图 # 3) clinic.js 全家桶 npx clinic flame -- node dist/main.js

论火焰图怎么读

横轴是调用栈、宽度是耗时占比。最宽的那一摞就是优化重点——常见是"序列化/正则/重复计算"。别一上来优化整体,先砍最宽的柱子,性价比最高。CPU profiling 有开销,只在排查时开,别常驻生产。

Worker Threads:把 CPU 重活移出主线程

// main.js:发重活给 worker,不阻塞事件循环 import { Worker } from 'node:worker_threads'; const w = new Worker('./heavy.js'); w.postMessage(bigInput); w.on('message', r => res.json(r)); // 结果回来再响应 // heavy.js:在 worker 线程里算,主线程继续接别的请求 import { parentPort } from 'node:worker_threads'; parentPort.on('message', (data) => { const out = expensiveCompute(data); // 几百万次循环也不卡主线程 parentPort.postMessage(out); });

事件循环延迟与 perf 钩子

// 监控事件循环延迟:延迟高说明主线程被占用 import { monitorEventLoopDelay } from 'node:perf_hooks'; const h = monitorEventLoopDelay({ resolution: 1000 }); h.enable(); setInterval(() => { if (h.max > 500) log.warn(`事件循环延迟 ${h.max}ms,主线程可能被阻塞`); }, 5000); // perf_hooks 量具体函数耗时 import { performance } from 'node:perf_hooks'; const t = performance.now(); expensiveCompute(); log.info(`耗时 ${performance.now() - t}ms`);

论P99 延迟比平均更重要

平均延迟好看、P99 暴涨,说明偶发卡顿(GC、长任务、慢查询)。事件循环延迟、P99 这些"尾部指标"才是用户体验的真实下限。把延迟告警设在 P99 而非均值,才能在用户投诉前发现问题。

压测闭环:用 autocannon 验证优化

优化不能拍脑袋。先压测拿基线,再针对性优化,再压测对比——形成闭环,用数据说话。

# 对接口打 30s、100 并发,看 RPS 与延迟分布 npx autocannon -c 100 -d 30 http://localhost:3000/api/users/1 # 输出含 P2.5/P50/P97.5 延迟、吞吐量、错误数;优化后重跑对比

论压测要贴近真实混合流量

单接口压测只能看单点;真实流量是读多写少、有突发峰值。用 k6/autocannon 脚本模拟混合场景,并在压力下观察事件循环延迟与内存曲线,才看得出真实瓶颈,而不是"Hello World 能跑 10 万 QPS"的假象。

结
本页要点

Node 用单线程事件循环 + libuv 非阻塞 IO 扛高并发:循环按 timers→poll→check 等阶段走,微任务(Promise/nextTick)每阶段间清空、总是插队宏任务;网络 IO 真异步,fs/crypto/dns 走 libuv 线程池(默认 4 个,可调)。TS strict 把 null/undefined 漏洞挡在编译期,泛型/条件类型(infer)/映射类型支撑精确派生类型,判别联合 + 类型守卫做安全收窄。NestJS 用模块化+DI 把结构定下来,请求经"中间件→守卫→拦截器→管道→控制器→过滤器"管道;ORM 提升可维护性但要防 N+1(用 include/join 或 DataLoader 批处理)、注意事务范围与连接释放。测试按层性价比投入(Vitest/Jest + supertest);部署用 Cluster/PM2 吃多核、Docker 多阶段、务必优雅退出(SIGTERM);性能诊断靠 heapdump 找泄漏、0x/flame 看 CPU 热点、Worker Threads 移出 CPU 重活、perf_hooks 量延迟。

自测 · 看你是否真懂

1.下面代码输出顺序是什么?为什么?

查看答案

输出 1 → 2 → 3 → 4。同步代码先跑;本轮宏任务(setTimeout)执行前,事件循环会先清空 process.nextTick 队列(2)再清空微任务队列(3 Promise);最后才到下一轮的 timers 阶段执行 setTimeout(4)。

2.ORM 的 N+1 问题是什么?怎么解决?

查看答案

查列表后每条再查关联,变成 1+N 次查询,接口被海量 round-trip 拖慢。解法:用预加载(include/join)一次带出关联;或调用点分散时用 DataLoader 在同 tick 内批处理成一条 IN 查询;上线前用慢查询日志盯相似查询数。

3.Node 单线程,怎么利用多核?CPU 密集任务为什么不能放主线程?

查看答案

用 Cluster 模块 fork 多个进程(配反向代理),或用容器多副本;CPU 密集任务用 Worker Threads 移出主线程。因为事件循环是单线程,主线程做大数据解析/加解密会让所有请求排队,整站变慢——这类活必须异步化或进 Worker。

4.为什么生产环境要监听 SIGTERM 做优雅退出?密钥为什么不能写进代码?

查看答案

滚动发布时直接 kill 会硬断在途请求,用户收到 502。优雅退出先 server.close() 拒绝新连接、释放 DB 连接再退,配合 terminationGracePeriodSeconds 留时间。密钥写代码会进版本库/镜像/日志,泄露面大;应走环境变量或密钥管理,便于按环境切换与轮换。

5.TS 的 infer 关键字有什么用?什么场景会用到深层"类型体操"?

查看答案

infer 在条件类型里"捕获"被匹配的类型片段(如 T extends Promise<infer V> ? V : T 提取 Promise 内部类型)。深层类型体操主要在写 SDK/通用库时用来生成精确派生类型;业务代码用现成工具类型 + 判别联合即可,过度嵌套会拖慢编译且难维护。

下一步往哪走

路学完 Node 进阶之后

① 看前端补全页:Node 后端配前端安全(XSS/CSRF)、性能,是全栈闭环。

② 看 DevOps 页:Docker、CI、可观测正是部署与诊断这两章的延伸。

③ 动手:用 NestJS + Prisma + Vitest + Docker + 优雅退出搭一个可部署的小服务,并把"事件循环延迟监控 + 一次 heapdump 排查"亲手跑通。