Skip to content

性能优化

一、性能优化原则

先测量,后优化。 不要凭直觉优化——先用工具找到真正的瓶颈:

定位瓶颈的工具:
- clinic.js:Node.js 专用火焰图分析
- Artillery / k6:负载测试
- pg_stat_statements:PostgreSQL 慢查询统计
- Redis SLOWLOG:Redis 慢命令
- NestJS 请求日志:接口响应时间

常见瓶颈排序(按出现频率):

  1. 数据库查询(N+1、缺少索引、大量 JOIN)
  2. 无缓存的热点数据
  3. 同步阻塞操作(CPU 密集型任务在主线程)
  4. 内存泄漏(未释放的监听器、循环引用)
  5. HTTP 框架(Express vs Fastify)

二、切换为 Fastify

Fastify 比 Express 快约 2-3x,适合高 QPS API:

bash
npm install @nestjs/platform-fastify
typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import { NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({
      logger: false,          // 使用 NestJS 日志系统
      trustProxy: true,       // 信任代理(nginx 后面时需要)
      bodyLimit: 10485760,    // 10MB 请求体限制
    }),
  );

  // Fastify 中 helmet 和 compression 的安装方式不同
  await app.register(require('@fastify/helmet'));
  await app.register(require('@fastify/compress'));

  await app.listen(3000, '0.0.0.0');
}

⚠️ 迁移注意事项:

  • res.setHeader()res.header()
  • req.ip 获取方式可能不同
  • 某些 Express 中间件与 Fastify 不兼容,需要找对应的 Fastify 插件

三、响应缓存

基础内存缓存

typescript
// app.module.ts
import { CacheModule } from '@nestjs/cache-manager';

@Module({
  imports: [
    CacheModule.register({
      isGlobal: true,
      ttl: 60 * 1000,  // 默认 60 秒(ms)
      max: 1000,       // 最多缓存 1000 条
    }),
  ],
})
typescript
// Controller 级别缓存
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';

@UseInterceptors(CacheInterceptor)  // 自动以 URL 为 key 缓存
@Controller('posts')
export class PostsController {
  @Get()
  @CacheTTL(30 * 1000)   // 30 秒(覆盖默认值)
  findAll() {
    return this.postsService.findAll();
  }

  @Get('popular')
  @CacheKey('popular-posts')   // 自定义缓存 key
  @CacheTTL(5 * 60 * 1000)    // 热门文章缓存 5 分钟
  findPopular() {
    return this.postsService.findPopular();
  }
}

Redis 缓存(多实例必须用)

bash
npm install @nestjs/cache-manager cache-manager-redis-yet
typescript
CacheModule.registerAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    store: redisStore,
    url: config.get('REDIS_URL', 'redis://localhost:6379'),
    ttl: 60 * 1000,
  }),
})

手动缓存控制(更精细)

typescript
@Injectable()
export class PostsService {
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
  ) {}

  async findOne(id: number): Promise<Post> {
    const cacheKey = `post:${id}`;
    const cached = await this.cacheManager.get<Post>(cacheKey);
    if (cached) return cached;

    const post = await this.postRepo.findOne({ where: { id } });
    if (!post) throw new NotFoundException();

    await this.cacheManager.set(cacheKey, post, 5 * 60 * 1000);
    return post;
  }

  async update(id: number, dto: UpdatePostDto) {
    const post = await this.postRepo.save({ id, ...dto });
    // 更新时清除缓存(缓存失效策略)
    await this.cacheManager.del(`post:${id}`);
    return post;
  }

  async invalidateUserCache(userId: number) {
    // 批量清除某用户相关的所有缓存
    const keys = await this.cacheManager.store.keys(`user:${userId}:*`);
    await Promise.all(keys.map(k => this.cacheManager.del(k)));
  }
}

四、数据库查询优化

只查需要的字段

typescript
// ❌ 查询所有字段(包括大文本字段)
const posts = await this.postRepo.find();

// ✅ 只查列表页需要的字段
const posts = await this.postRepo.find({
  select: {
    id: true,
    title: true,
    authorId: true,
    createdAt: true,
    // content 不查(可能很大)
  },
  relations: { author: true },
});

避免 N+1

typescript
// ❌ N+1:查 10 篇文章,再查 10 次作者
const posts = await this.postRepo.find();
for (const post of posts) {
  const author = await this.userRepo.findOne({ where: { id: post.authorId } });
}

// ✅ 一次 JOIN 查询
const posts = await this.postRepo.find({
  relations: { author: true },
});

// ✅ QueryBuilder 选择性字段 JOIN
const posts = await this.postRepo
  .createQueryBuilder('post')
  .leftJoin('post.author', 'author')
  .addSelect(['author.id', 'author.name'])  // 不查 author.password 等
  .getMany();

分页最佳实践

typescript
// ❌ 大偏移量分页(OFFSET 1000000 全表扫描)
const posts = await this.postRepo.find({
  skip: 100000,
  take: 10,
});

// ✅ 游标分页(基于最后一条记录的 ID 跳转)
async findNextPage(lastId: number, limit: number = 10) {
  return this.postRepo
    .createQueryBuilder('post')
    .where('post.id > :lastId', { lastId })
    .orderBy('post.id', 'ASC')
    .take(limit)
    .getMany();
}

索引策略

typescript
@Entity('posts')
@Index(['authorId', 'createdAt'])          // 复合索引:按作者分页查询
@Index(['title'], { fulltext: true })      // 全文索引(MySQL)
export class Post {
  @Column()
  @Index()                                 // 单列索引:频繁过滤的字段
  published: boolean;

  @Column()
  authorId: number;
}

五、异步与并发优化

并行请求(Promise.all)

typescript
// ❌ 串行:总耗时 = 100ms + 200ms + 150ms = 450ms
const user = await this.usersService.findOne(id);       // 100ms
const posts = await this.postsService.findByUser(id);   // 200ms
const stats = await this.statsService.getUserStats(id); // 150ms

// ✅ 并行:总耗时 = max(100, 200, 150) = 200ms
const [user, posts, stats] = await Promise.all([
  this.usersService.findOne(id),
  this.postsService.findByUser(id),
  this.statsService.getUserStats(id),
]);

// 允许部分失败(不影响其他结果)
const results = await Promise.allSettled([...]);
const user = results[0].status === 'fulfilled' ? results[0].value : null;

CPU 密集型任务外包

typescript
// ❌ 在主线程处理大图片(会阻塞所有请求)
async processImage(buffer: Buffer) {
  return sharp(buffer).resize(800).jpeg().toBuffer();  // CPU 密集
}

// ✅ 用 Worker Thread 或独立进程处理
import { Worker } from 'worker_threads';

async processImageAsync(buffer: Buffer): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./image-worker.js', {
      workerData: { buffer },
    });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

六、限流与熔断

typescript
// @nestjs/throttler 全局配置(见安全实践章节详细说明)
ThrottlerModule.forRoot([{
  ttl: 60000,
  limit: 100,
}])

连接池配置

typescript
// TypeORM 连接池
TypeOrmModule.forRoot({
  extra: {
    max: parseInt(process.env.DB_POOL_MAX ?? '10'),  // 最大连接数
    min: 2,
    acquireTimeoutMillis: 30000,   // 等待连接的最大时间
    idleTimeoutMillis: 600000,     // 空闲连接超时
  },
})

// Redis 连接池(ioredis)
new Redis({
  maxRetriesPerRequest: 3,
  connectTimeout: 5000,
  lazyConnect: true,      // 延迟连接(启动时不阻塞)
  enableReadyCheck: false,
})

七、性能监控

typescript
// 自定义指标拦截器(上报响应时间到监控系统)
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const start = process.hrtime.bigint();
    const req = context.switchToHttp().getRequest();

    return next.handle().pipe(
      tap(() => {
        const duration = Number(process.hrtime.bigint() - start) / 1e6;  // 纳秒 → 毫秒
        const route = `${req.method} ${req.route?.path ?? req.url}`;

        // 记录 P50/P95/P99(在真实项目中上报到 Prometheus/Datadog)
        if (duration > 1000) {
          console.warn(`慢接口: ${route} ${duration.toFixed(2)}ms`);
        }
      }),
    );
  }
}

八、性能优化检查清单

数据库层:
  ☐ 高频查询字段有索引
  ☐ 分页查询有限制(TAKE < 100)
  ☐ 无 N+1 问题(检查 TypeORM 日志)
  ☐ 只 SELECT 需要的字段
  ☐ 连接池大小与并发量匹配

缓存层:
  ☐ 热点只读数据有缓存
  ☐ 缓存有合理 TTL(不是永不过期)
  ☐ 写操作时同步清除缓存
  ☐ 多实例用 Redis 而非内存缓存

应用层:
  ☐ 无关操作并行(Promise.all)
  ☐ CPU 密集型任务用 Worker Thread
  ☐ 大文件流式响应(不全加载到内存)
  ☐ 响应开启 gzip 压缩

可运行 Demo: practice/07-extensions — 缓存 + 队列异步化 + DataLoader N+1 优化示例


常见错误

错误原因解决
缓存雪崩大量 key 同时过期触发 DB 查询风暴TTL 加随机抖动:baseTtl + Math.random() * 0.2 * baseTtl
N+1 查询循环中单独查每条关联数据DataLoader 批处理,或 JOIN 一次性加载
内存泄漏全局 Map/Set 无限增长用 LRU Cache 限制大小;REQUEST 作用域的服务在请求结束后会被 GC
Fastify 与某些 Express 中间件不兼容Fastify 不使用 Express 中间件接口迁移到 Fastify 前检查依赖的中间件是否有 Fastify 版本

NestJS 深度学习体系