本页定位 · 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)",事件循环按固定顺序走一遍阶段,每阶段处理一类回调。理解顺序才能预判时序。
| 阶段 | 处理什么 |
| timers | setTimeout / setInterval 到期的回调 |
| pending callbacks | 上轮推迟的 I/O 回调(如 TCP 错误) |
| idle/prepare | 内部使用 |
| poll | 取 I/O 事件(文件/网络),没活则在此停留 |
| check | setImmediate 的回调 |
| 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 子项速查
| 选项 | 作用 |
strictNullChecks | null/undefined 不再可赋值给任意类型 |
noImplicitAny | 不允许隐式 any,必须显式标注 |
strictPropertyInitialization | 属性必须在构造时初始化 |
noUnusedLocals | 未使用变量报错,逼你清理 |
noFallthroughCasesInSwitch | switch 漏 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
| Prisma | TypeORM |
| 风格 | 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 排查"亲手跑通。