NestJS 性能优化有哪些方法?
为什么 NestJS 应用需要性能优化
NestJS 默认基于 Express 构建,开箱即用时很多配置偏向开发便利而非运行效率。当并发量上来之后,数据库查询堆积、内存泄漏、序列化开销大等问题会集中暴露。优化的核心思路就三条:减少不必要的计算、减少不必要的等待、让该并行的并行起来。
一、换掉 Express,用 Fastify 适配器
这是投入产出比最高的一步。Fastify 的路由匹配和序列化性能显著优于 Express,NestJS 官方原生支持切换,代码改动极小:
typescriptimport { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { FastifyAdapter } from '@nestjs/platform-fastify'; async function bootstrap() { const app = await NestFactory.create(AppModule, new FastifyAdapter()); await app.listen(3000); } bootstrap();
切换后主要注意点:Fastify 使用 fastify-static 而非 express.static,文件上传用 fastify-multipart 而非 multer,中间件写法也有差异。官方文档列出了完整的迁移清单,大部分项目半天内就能完成。
基准测试中,同样的接口 Fastify 吞吐量通常是 Express 的 2-3 倍。如果你的项目还在用 Express 适配器,这是第一个该做的事。
二、数据库层优化
数据库往往是瓶颈所在,优化收益也最大。
2.1 索引不能乱加,但该加的必须加
索引加速查询但拖慢写入,需要根据实际查询模式来设计。一个常见错误是给低基数字段(如性别、状态枚举)建索引,这种索引几乎不会被优化器使用。正确做法是对高频 WHERE 条件、JOIN 字段、ORDER BY 字段建索引,并用 EXPLAIN 验证索引是否被命中:
typescript@Entity('orders') export class Order { @PrimaryGeneratedColumn() id: number; @Index() @Column() userId: number; // 高频 JOIN 和 WHERE 条件 @Index() @Column({ type: 'timestamp' }) createdAt: Date; // 高频排序字段 @Column() status: string; // 低基数,不建索引 }
2.2 消灭 N+1 查询
N+1 是 ORM 项目中最常见的性能杀手。症状是查询 100 条记录却产生 101 条 SQL:
typescript// N+1 —— 100 个用户产生 101 次查询 const users = await this.userRepo.find(); for (const user of users) { user.orders = await this.orderRepo.find({ where: { userId: user.id } }); }
用 relations 或 QueryBuilder 的 leftJoinAndSelect 一次搞定:
typescript// 1 次查询搞定 const users = await this.userRepo.find({ relations: ['orders'] }); // 或者需要更精细控制时 const users = await this.userRepo .createQueryBuilder('user') .leftJoinAndSelect('user.orders', 'order') .getMany();
2.3 连接池调优
默认连接池大小往往不够用或用不满。关键参数:
typescriptTypeOrmModule.forRoot({ type: 'mysql', // ... extra: { connectionLimit: 50, // 活跃连接上限 waitForConnections: true, // 连接耗尽时排队等待 queueLimit: 0, // 排队不限人数 acquireTimeout: 30000, // 等待连接超时 30s }, poolSize: 50, // TypeORM 原生参数(PostgreSQL 用这个) })
连接池大小有个经验公式:连接数 = (CPU 核心数 * 2) + 有效磁盘数。但实际要根据监控数据调整——如果活跃连接数长期接近上限就加,如果大部分时间空闲就减。
2.4 分页必须做
不分页的列表查询是定时炸弹,数据量增长后直接拖垮数据库:
typescriptasync findPage(page: number, limit: number) { const [data, total] = await this.repo.findAndCount({ skip: (page - 1) * limit, take: limit, order: { createdAt: 'DESC' }, }); return { data, total, page, totalPages: Math.ceil(total / limit) }; }
深度分页(page 很大时)性能差,可以考虑用游标分页(基于 ID 的 where id > lastId)替代 offset 分页。
三、缓存策略
3.1 Redis 缓存热点数据
频繁查询且变更少的数据(用户信息、配置项、热门内容)必须缓存。NestJS 的 @nestjs/cache-manager 配合 cache-manager-redis-store 可以快速接入:
typescriptimport { CacheModule, CacheInterceptor } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-store'; @Module({ imports: [ CacheModule.register({ store: redisStore, host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, ttl: 3600, // 默认过期时间 1 小时 }), ], }) export class AppModule {}
然后直接用拦截器缓存整个接口响应:
typescript@UseInterceptors(CacheInterceptor) @Get('hot-articles') getHotArticles() { return this.articleService.findHot(); }
注意:POST 请求默认不被缓存,缓存 key 基于 URL,带查询参数的接口要确保参数一致时 key 也一致。
3.2 多级缓存
单层 Redis 缓存的问题是每次都要走网络。如果某些数据读多写极少,可以在 Redis 前面加一层内存缓存:
typescript@Injectable() export class ConfigService { private localCache = new Map<string, { value: any; expires: number }>(); async getConfig(key: string) { // L1: 内存缓存 const local = this.localCache.get(key); if (local && local.expires > Date.now()) return local.value; // L2: Redis const redisVal = await this.cacheManager.get(key); if (redisVal) { this.localCache.set(key, { value: redisVal, expires: Date.now() + 60000 }); return redisVal; } // L3: 数据库 const dbVal = await this.configRepo.findOne({ where: { key } }); await this.cacheManager.set(key, dbVal, 3600000); this.localCache.set(key, { value: dbVal, expires: Date.now() + 60000 }); return dbVal; } }
内存缓存 TTL 要比 Redis 短(比如 1 分钟 vs 1 小时),这样即使内存缓存没及时失效,最多延迟 1 分钟就能拿到新数据。
四、异步与并发
4.1 能并行就不要串行
多个互不依赖的异步操作,串行等就是浪费:
typescript// 串行 —— 总耗时 = 查用户 + 查订单 + 查通知 async getUserDashboard(userId: number) { const user = await this.userRepo.findOne({ where: { id: userId } }); const orders = await this.orderRepo.find({ where: { userId } }); const notifications = await this.notifRepo.find({ where: { userId } }); return { user, orders, notifications }; } // 并行 —— 总耗时 = max(查用户, 查订单, 查通知) async getUserDashboard(userId: number) { const [user, orders, notifications] = await Promise.all([ this.userRepo.findOne({ where: { id: userId } }), this.orderRepo.find({ where: { userId } }), this.notifRepo.find({ where: { userId } }), ]); return { user, orders, notifications }; }
注意 Promise.all 有一个失败全部失败的特性。如果部分请求允许失败,用 Promise.allSettled 代替。
4.2 耗时任务丢进队列
发邮件、生成报表、数据同步这类不需要即时返回结果的操作,同步处理会阻塞请求。用 Bull 队列异步处理:
typescriptimport { BullModule } from '@nestjs/bull'; import { Processor, Process } from '@nestjs/bull'; // 注册队列 @Module({ imports: [BullModule.registerQueue({ name: 'email' })], }) export class EmailModule {} // 生产者 @Injectable() export class EmailService { constructor(@InjectQueue('email') private emailQueue: Queue) {} async sendWelcome(userId: string) { await this.emailQueue.add('welcome', { userId }, { attempts: 3, // 失败重试 3 次 backoff: 5000, // 重试间隔 5 秒 removeOnComplete: true, }); } } // 消费者 @Processor('email') export class EmailConsumer { @Process('welcome') async handleWelcome(job: Job<{ userId: string }>) { const user = await this.userService.findOne(job.data.userId); await this.mailerService.sendMail({ to: user.email, subject: '欢迎' }); } }
4.3 CPU 密集型任务用 Worker Threads
图片处理、加密计算、大数据序列化等 CPU 密集操作会阻塞 Node.js 的事件循环。NestJS 没有内置 Worker Threads 封装,但自己集成不难:
typescriptimport { Worker } from 'worker_threads'; @Injectable() export class ImageService { async resizeImage(inputPath: string, width: number): Promise<Buffer> { return new Promise((resolve, reject) => { const worker = new Worker('./workers/image-resize.worker.js', { workerData: { inputPath, width }, }); worker.on('message', resolve); worker.on('error', reject); worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exited with code ${code}`)); }); }); } }
关键点:Worker 有启动开销,不要为每个请求都创建新 Worker。用 Worker 池(如 piscina 库)来复用 Worker 实例。
五、响应压缩
启用 Gzip 或 Brotli 压缩可以大幅减少传输体积,尤其是 JSON API 响应,压缩率通常在 60%-80%:
typescriptimport compression from 'compression'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(compression({ threshold: 1024, // 小于 1KB 的响应不压缩,开销大于收益 level: 6, // 压缩级别 1-9,6 是性价比最优 })); await app.listen(3000); }
如果用 Nginx 做反向代理,在 Nginx 层开启压缩更合适(gzip on; gzip_types application/json;),因为 Nginx 的压缩对 CPU 的开销更低。
六、HTTP Keep-Alive 和连接复用
Node.js 默认的 HTTP Agent 对同一域名只保持 5 个连接。当你的 NestJS 应用频繁请求第三方 API 时,连接复用能显著减少 TCP 握手开销:
typescriptimport { Agent } from 'http'; const keepAliveAgent = new Agent({ keepAlive: true, maxSockets: 50, // 同一域名最大连接数 keepAliveMsecs: 30000, // Keep-Alive 探测间隔 }); // Axios 中使用 const axiosInstance = Axios.create({ httpAgent: keepAliveAgent });
服务端也要配置 Keep-Alive 超时:
typescriptasync function bootstrap() { const app = await NestFactory.create(AppModule); const server = app.getHttpServer(); server.keepAliveTimeout = 65000; // 65 秒 server.headersTimeout = 66000; // 必须大于 keepAliveTimeout await app.listen(3000); }
headersTimeout 必须大于 keepAliveTimeout,否则 Nginx 等反向代理在 Keep-Alive 探测时会收到 408 错误。
七、模块懒加载
大型应用启动时加载所有模块会拖慢启动速度。NestJS 支持模块懒加载,只在首次请求时初始化:
typescriptimport { Module } from '@nestjs/common'; import { LazyModuleLoader } from '@nestjs/core'; @Module({ providers: [LazyModuleLoader], }) export class AppModule { constructor(private lazyLoader: LazyModuleLoader) {} async onModuleInit() { // 按需加载后台管理模块,不影响主流程启动 const { AdminModule } = await import('./admin/admin.module'); const moduleRef = await this.lazyLoader.load(() => AdminModule); } }
实际项目中,管理后台、报表导出、数据迁移等低频功能模块适合懒加载。核心业务模块还是建议预加载,避免首次请求延迟。
八、序列化优化
NestJS 默认用 class-transformer 做序列化,开启 excludeExtraneousValues 后每次序列化都要遍历所有属性,数据量大时开销不可忽视:
typescript// 全局开启严格序列化 app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, // 自动剔除未装饰的属性 forbidNonWhitelisted: true, // 有未装饰属性直接报错 }), );
如果某个接口返回数据量大且不需要复杂转换,可以绕过 class-transformer 直接返回 Plain Object:
typescript@Get('raw-list') async getRawList() { // 直接用 QueryBuilder 的 raw 模式,跳过实体转换 return this.repo .createQueryBuilder('item') .select(['item.id', 'item.name', 'item.price']) .getRawMany(); }
getRawMany() 返回的是纯对象而非实体实例,少了实例化和关系映射的开销,适合只读列表场景。
九、内存泄漏防范
Node.js 进程内存持续增长但不回落,大概率是泄漏了。常见原因:
- 闭包持有大对象引用
- 全局 Map/数组无限增长
- 事件监听器重复注册
- 定时器未清理
用 heapdump 或 node --inspect + Chrome DevTools 可以抓堆快照对比定位:
bashnode --inspect dist/main.js # 然后打开 chrome://inspect,点击 Profile 抓堆快照
代码层面的防御性写法:
typescript@Injectable() export class CacheService implements OnModuleDestroy { private cache = new Map<string, { value: any; expires: number }>(); private cleanupTimer: NodeJS.Timeout; constructor() { // 定期清理过期缓存 this.cleanupTimer = setInterval(() => this.evictExpired(), 60000); } set(key: string, value: any, ttlMs: number = 3600000) { this.cache.set(key, { value, expires: Date.now() + ttlMs }); } private evictExpired() { const now = Date.now(); for (const [key, entry] of this.cache) { if (entry.expires < now) this.cache.delete(key); } } onModuleDestroy() { clearInterval(this.cleanupTimer); // 模块销毁时清理定时器 this.cache.clear(); } }
十、生产部署优化
10.1 PM2 集群模式
单实例无法利用多核 CPU。PM2 的 cluster 模式自动 fork 多个进程:
javascript// ecosystem.config.js module.exports = { apps: [{ name: 'nestjs-app', script: './dist/main.js', instances: 'max', // 按 CPU 核心数创建实例 exec_mode: 'cluster', max_memory_restart: '1G', // 内存超限自动重启 env: { NODE_ENV: 'production', }, }], };
注意:集群模式下内存缓存不共享,必须用 Redis 等外部缓存;定时任务会重复执行,需要用分布式锁或指定单实例执行。
10.2 Nginx 反向代理
生产环境不要让 Node.js 直接面对公网,用 Nginx 做反向代理可以处理 SSL 终止、静态文件、负载均衡:
nginxupstream nestjs_backend { least_conn; server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; keepalive 64; } server { listen 80; server_name api.example.com; location / { proxy_pass http://nestjs_backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }
keepalive 64 让 Nginx 与 Node.js 之间也保持连接池,避免每次请求都建新 TCP 连接。
10.3 监控不可少
没有监控的优化是盲目的。推荐方案:
- 性能拦截器:记录每个接口的响应时间,找慢接口
- APM 工具:Elastic APM、New Relic 或 Datadog,自动追踪请求链路
- Node.js 运行指标:用
prom-client暴露事件循环延迟、堆内存、活跃句柄数等指标
typescriptimport { NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; @Injectable() export class PerformanceInterceptor implements NestInterceptor { private readonly logger = new Logger('Performance'); intercept(context: ExecutionContext, next: CallHandler) { const start = Date.now(); const req = context.switchToHttp().getRequest(); return next.handle().pipe( tap(() => { const duration = Date.now() - start; if (duration > 500) { this.logger.warn(`${req.method} ${req.url} took ${duration}ms`); } }), ); } }
响应超过 500ms 的请求自动告警,比全量打日志更实用。
优化不是一次性的
性能优化没有终点。上线后要持续关注监控数据,根据实际瓶颈选择优化方向。优先做收益高、改动小的事——比如切换 Fastify 适配器和加索引可能只要半天,效果却比花一周重构代码架构明显得多。