1. 为什么你的NestJS应用必须做好限流?

朋友们,咱们做后端开发的,尤其是用NestJS这种现代框架的,是不是经常遇到这样的场景:你的API刚上线,运行得好好的,突然有一天,用户反馈说“页面卡死了”、“登录一直转圈圈”。你一看监控,好家伙,某个接口的QPS(每秒查询率)直接冲上了天,数据库CPU也快爆了。这很可能就是你的服务被“打爆”了,要么是遇到了恶意爬虫,要么是某个功能被高频调用,甚至可能是简单的程序bug导致的死循环请求。

限流,听起来是个挺“防御性”的功能,但它其实是保障你应用高可用性的第一道,也是最重要的一道防线。它的核心目标就两个:保护自己和公平对待用户。保护自己,是防止单个接口或用户的行为拖垮整个服务;公平对待用户,是确保有限的服务器资源能被所有正常用户合理共享,而不是被少数几个请求独占。

在NestJS的生态里,限流远不止是给请求速度设个上限那么简单。我经历过不少项目,从简单的单机内存计数,到后来必须上分布式Redis集群,这中间的坑踩了不少。一个健壮的限流方案,应该能做到多维度控制。比如,对普通用户的登录接口,我们可以限制得严格一些,防止暴力破解;但对内部的管理员接口或者合作伙伴的API,可能就需要更宽松的策略,甚至完全放开。再比如,针对不同的业务场景,有的需要按IP限制(防刷),有的需要按用户ID限制(防滥用),有的甚至需要结合请求的路径和参数来动态调整。

所以,咱们今天聊的,不是那种“五分钟配置完”的玩具级限流,而是一套从单机到集群、从基础到高级、并且可观测、可管理的实战方案。我会带你一步步走完这个全过程,让你不仅能配出来,更能理解为什么这么配,以及在不同业务压力下该如何调整。准备好了吗?咱们先从最基础的开始。

2. 快速上手:用 @nestjs/throttler 实现基础限流

2.1 安装与最简配置

NestJS官方提供了一个非常趁手的限流模块:@nestjs/throttler。它开箱即用,对于单实例应用来说,配置起来简直不要太简单。

首先,用你喜欢的包管理器把它装上。我习惯用pnpm,速度快,磁盘空间也省。

pnpm add @nestjs/throttler

接下来,在你的根模块 app.module.ts 里引入并配置它。这里有两个最核心的参数你需要理解:

  • ttl (Time To Live):时间窗口的长度,单位是秒。意思是,我们统计多长时间内的请求次数。比如设为60,就是统计“过去60秒内”的请求。
  • limit:在ttl定义的时间窗口内,允许的最大请求次数。
// app.module.ts
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60, // 时间窗口:60秒
      limit: 100, // 在60秒内,最多允许100次请求
    }),
  ],
})
export class AppModule {}

这个配置是全局生效的。意味着你的应用里所有的路由,在默认情况下,都会遵守“60秒内最多100次请求”这个规则。这已经能挡住大部分无意识的请求风暴了。

2.2 在控制器和方法上应用限流

全局配置有了,但有时候我们需要更精细的控制。比如,登录接口 POST /auth/login 肯定要比查询公共信息的 GET /news 限制得更严格。这时候,我们可以使用 @Throttle() 装饰器来覆盖全局配置。

假设我们想对登录接口实施更严格的防护:1分钟内最多尝试5次。

// auth.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';

@Controller('auth')
export class AuthController {
  @Throttle({ default: { ttl: 60, limit: 5 } }) // 覆盖全局配置:60秒内最多5次
  @Post('login')
  async login(@Body() loginDto: LoginDto) {
    // 你的登录逻辑...
    return { message: '登录成功' };
  }
}

这里 @Throttle() 装饰器里的 default 是一个预定义的策略名。你也可以定义多个策略,比如 short、long,然后在不同的方法上按需引用,非常灵活。

2.3 理解背后的原理与“坑”

配置很简单,对吧?但咱们不能只停留在表面。@nestjs/throttler 默认使用的是内存存储(ThrottlerStorage)。这意味着它会在你应用进程的内存里,维护一个类似 Map 的结构,来记录每个“追踪器”(默认是请求IP)在最近时间窗口内的请求次数。

这里就引出了第一个实战中的大坑:单实例内存存储的局限性。

  1. 重启失效:应用一重启,内存里的计数全没了,限流从头开始。攻击者可能会利用这一点,在你重启后发起新一轮攻击。
  2. 无法分布式扩展:这是最要命的。如果你的服务部署了多个实例(比如用Kubernetes做了水平扩展),负载均衡器会把请求随机打到不同的实例上。实例A记录了某个IP的5次请求,实例B完全不知道。这样,限流就形同虚设了,总请求量可能会变成 limit * 实例数。
  3. 内存压力:如果攻击者用海量不同的IP来请求,这个内存Map会变得非常大,可能拖慢应用甚至导致内存溢出。

所以,这个基础方案只适用于开发环境、测试环境,或者确定只会以单实例运行的、流量非常小的内部服务。一旦你的服务需要面对真实用户,或者准备上生产环境,我们必须立刻考虑下一步:引入一个集中式的存储,也就是Redis。

3. 分布式集群的核心:集成Redis限流器

3.1 为什么必须是Redis?

当我们把应用从单机扩展到多实例集群时,限流状态必须能被所有实例共享和访问。我们需要一个高速、可靠、支持原子操作的集中式存储。Redis几乎是为这个场景量身定做的:

  • 性能极高:内存操作,能承受极高的QPS,不会成为性能瓶颈。
  • 数据结构丰富:它的 String、Hash、Sorted Set 都非常适合实现各种限流算法(如滑动窗口)。
  • 原子性:INCR、EXPIRE 等命令可以保证在高并发下计数的准确性,不会出现竞态条件。
  • 持久化可选:虽然限流数据通常可以丢失(重启后重新计数),但Redis也提供RDB/AOF持久化,增加可靠性。

@nestjs/throttler 模块设计得很好,它允许我们自定义 storage。社区已经有现成的 ThrottlerStorageRedisService 实现,我们直接拿来用就行。

3.2 配置Redis存储

首先,安装必要的依赖。我们需要 cache-manager(NestJS缓存模块的底层)和对应的Redis驱动,以及社区提供的Redis存储适配器。

pnpm add cache-manager cache-manager-ioredis ioredis @nest-lab/throttler-storage-redis

注意:@nestjs/throttler 官方仓库的示例可能指向不同的包名,@nest-lab/throttler-storage-redis 是一个广泛使用且维护良好的社区实现。

接下来,我们改造 app.module.ts,使用异步工厂模式来配置,这样能方便地注入ConfigService来读取环境变量。

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import Redis from 'ioredis';

@Module({
  imports: [
    ConfigModule.forRoot(), // 引入配置模块
    ThrottlerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const redisConfig = {
          host: configService.get('REDIS_HOST', 'localhost'),
          port: configService.get('REDIS_PORT', 6379),
          password: configService.get('REDIS_PASSWORD', ''),
          db: configService.get('REDIS_DB', 0),
        };
        return {
          ttl: 60,
          limit: 100,
          storage: new ThrottlerStorageRedisService(
            new Redis(redisConfig) // 创建Redis实例
          ),
          // 一个很实用的配置:忽略本地请求
          skipIf: (context) => {
            const req = context.switchToHttp().getRequest();
            return req.ip === '127.0.0.1' || req.ip === '::1';
          },
        };
      },
    }),
  ],
})
export class AppModule {}

这里我做了几件重要的事:

  1. 连接配置外部化:Redis的地址、端口、密码等都从环境变量读取,不同环境(开发、测试、生产)可以轻松切换。
  2. 创建了Redis客户端实例:并将其传递给 ThrottlerStorageRedisService。
  3. 添加了 skipIf 选项:这是一个非常实用的功能,它允许你定义一个函数,如果返回 true,则跳过本次限流检查。这里我让本机请求(比如健康检查)直接绕过限流。

3.3 验证与效果

配置完成后,重启你的应用。你可以写一个简单的测试接口,然后快速连续地请求它。当超过限制后,你会收到一个 429 Too Many Requests 的HTTP状态码响应,并且响应头里会包含 Retry-After 告诉你需要等待多少秒。

现在,无论你启动多少个应用实例,它们都连接着同一个Redis。某个IP的请求计数会在Redis中集中累加,真正的分布式限流就实现了。你可以尝试重启其中一个应用实例,然后继续请求,会发现计数并没有清零,因为数据存在Redis里。

4. 进阶策略:实现多维度的自定义限流规则

4.1 默认策略的不足

默认情况下,@nestjs/throttler 使用请求的IP地址作为追踪的Key。这很通用,但不够精细。在实际业务中,我们可能需要:

  • 按用户限流:防止单个用户滥用某个功能(比如疯狂发送短信验证码)。
  • 按接口+用户组合限流:限制用户对某个特定接口的调用频率。
  • 按请求参数限流:比如对同一个手机号发送短信的限流。

这就需要我们自定义 ThrottlerGuard,重写它的 getTracker 方法,来生成我们想要的Key。

4.2 创建自定义的Guard

假设我们的用户信息已经通过JWT等认证方式,挂载到了 request.user 对象上。我们希望优先按用户ID限流,对于未登录的请求,再回退到按IP限流。

// custom-throttler.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerException } from '@nestjs/throttler';
import { Request } from 'express';

@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
  // 重写此方法来生成自定义的追踪Key
  protected async getTracker(req: Request): Promise<string> {
    // 尝试从已认证的用户中获取ID
    const userId = (req as any).user?.id;
    if (userId) {
      return `user:${userId}`; // 按用户限流
    }
    // 未登录用户,使用IP地址
    // 注意:在生产环境中,需要考虑代理(如Nginx)传递的真实IP(X-Forwarded-For)
    const ip = req.ip || req.connection?.remoteAddress;
    return `ip:${ip}`;
  }

  // 可选:重写抛出异常的方法,以便记录更详细的日志
  protected throwThrottlingException(context: ExecutionContext): void {
    const req = context.switchToHttp().getRequest();
    const tracker = this.getTracker(req);
    console.warn(`[限流拦截] 追踪器: ${tracker}, 路径: ${req.method} ${req.url}`);
    // 调用父类方法,抛出标准的429异常
    super.throwThrottlingException(context);
  }
}

4.3 应用自定义Guard并实现更复杂的策略

创建好Guard后,我们需要在应用层面使用它,替换掉默认的全局Guard。

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomThrottlerGuard } from './guards/custom-throttler.guard';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 使用自定义的限流守卫
  app.useGlobalGuards(new CustomThrottlerGuard());
  await app.listen(3000);
}
bootstrap();

现在,我们的限流Key就变成了 user:123 或 ip:192.168.1.1 这样的格式,存储在Redis里一目了然。

但这还不够。想象一个场景:我们有一个“发表评论”的接口,我们希望限制每个用户每天最多发表50条评论,但同时,针对每个文章,我们又希望限制每分钟最多收到10条新评论(防止刷屏)。这就是一个多维度、多时间窗口的复杂策略。

我们可以进一步扩展 CustomThrottlerGuard,通过重写 generateKey 或直接在 getTracker 中组合更多信息:

protected async getTracker(req: Request): Promise<string> {
  const userId = (req as any).user?.id;
  const articleId = req.params.articleId; // 假设路径中有 :articleId
  const path = req.route.path;

  if (path === '/articles/:articleId/comments' && req.method === 'POST') {
    // 针对发表评论接口,生成复合Key
    if (userId) {
      // 用户维度:user_123_article_456
      return `limit:comment:user:${userId}:article:${articleId}`;
    }
  }
  // ... 其他情况的默认逻辑
}

然后,你可以在 ThrottlerModule.forRoot 配置中定义多个不同 ttl 和 limit 的命名策略,并在装饰器里指定使用哪个策略,来实现不同时间窗口的限流。这种灵活性,正是NestJS限流模块强大之处。

5. 可观测性与闭环:接入监控与告警

5.1 仅有拦截是不够的

限流策略生效了,请求被拦截了,返回429了。然后呢?如果没人知道,那它只是在默默地工作。对于一个线上系统,我们必须知道:

  • 限流被触发的频率有多高?是偶发现象还是持续攻击?
  • 是哪些用户或IP触发了限流?
  • 被限流的请求都访问了哪些接口?

这就需要我们把限流事件接入到我们的可观测性体系里,主要包括:日志、指标和告警。

5.2 记录详细的限流日志

我们在上面的 CustomThrottlerGuard.throwThrottlingException 方法里已经加了一行 console.warn。但在生产环境,我们应该将其发送到结构化的日志系统,比如ELK(Elasticsearch, Logstash, Kibana)或 Loki。

我们可以集成Winston这样的日志库,在Guard里注入Logger服务。

// custom-throttler.guard.ts (部分代码)
import { Injectable, ExecutionContext, Logger } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
  private readonly logger = new Logger(CustomThrottlerGuard.name);

  protected throwThrottlingException(context: ExecutionContext): void {
    const req = context.switchToHttp().getRequest();
    const tracker = this.getTracker(req); // 需要是同步或调整getTracker

    // 记录结构化日志
    this.logger.warn({
      message: '请求被限流拦截',
      tracker: tracker,
      ip: req.ip,
      method: req.method,
      url: req.url,
      userAgent: req.headers['user-agent'],
      timestamp: new Date().toISOString(),
    });

    super.throwThrottlingException(context);
  }
}

这样,每一条限流拦截记录都会包含丰富的信息,方便后续在日志平台中搜索、分析和聚合。

5.3 暴露Prometheus指标

对于监控系统(如Prometheus + Grafana)来说,日志的实时聚合分析可能不够直观。我们更需要一个可以绘制成图表、设置告警规则的指标。

我们可以使用 @nestjs/metrics 或 prom-client 库来创建一个计数器(Counter),每当限流触发时就增加一次。

// throttler-metrics.service.ts
import { Injectable } from '@nestjs/common';
import * as client from 'prom-client';

@Injectable()
export class ThrottlerMetricsService {
  public readonly throttledRequestsCounter: client.Counter;

  constructor() {
    this.throttledRequestsCounter = new client.Counter({
      name: 'http_requests_throttled_total',
      help: 'Total number of throttled HTTP requests',
      labelNames: ['tracker_type', 'path', 'method'], // 添加标签以便细分
    });
  }

  increment(tracker: string, req: Request) {
    const trackerType = tracker.startsWith('user:') ? 'user' : 'ip';
    this.throttledRequestsCounter.inc({
      tracker_type: trackerType,
      path: req.route?.path || req.url,
      method: req.method,
    });
  }
}

然后在我们的Guard中注入并使用这个Service:

// custom-throttler.guard.ts
constructor(private readonly metricsService: ThrottlerMetricsService) {
  super();
}
protected throwThrottlingException(context: ExecutionContext): void {
  const req = context.switchToHttp().getRequest();
  const tracker = this.getTracker(req);
  // 记录指标
  this.metricsService.increment(tracker, req);
  // ... 记录日志
  super.throwThrottlingException(context);
}

最后,别忘了设置一个端点(比如 /metrics)来暴露这些指标数据,让Prometheus来抓取。这样,你就能在Grafana上创建一个仪表盘,实时查看限流触发的趋势图,并且可以设置告警规则,例如“当每分钟限流次数超过100次时,发送告警通知”。

5.4 建立告警机制

当日志和指标都就位后,告警就是水到渠成的事:

  • 基于日志的告警:可以在ELK或类似系统中设置规则,当特定关键词(如“请求被限流拦截”)在短时间内出现次数激增时触发告警。
  • 基于指标的告警:在Prometheus Alertmanager中配置规则,如 rate(http_requests_throttled_total[5m]) > 10,表示5分钟内平均限流频率超过10次/分钟就告警。

告警通知可以发送到钉钉、企业微信、Slack或者PagerDuty等平台,确保开发运维团队能第一时间感知到异常。

6. 精细化运营:白名单、豁免与动态配置

6.1 实现限流白名单

任何规则都有例外。我们的限流系统需要具备“开关”和“豁免”能力。常见的豁免场景包括:

  • 内部网络或管理后台IP:公司办公室的IP段,运维人员的访问IP。
  • 可信的第三方服务IP:比如支付回调的服务器IP。
  • 特定的高权限用户:比如系统管理员。
  • 健康检查端点:Kubernetes的存活探针和就绪探针。

我们可以在自定义Guard的 canActivate 逻辑最前面加入白名单检查。

// custom-throttler.guard.ts
async canActivate(context: ExecutionContext): Promise<boolean> {
  const request = context.switchToHttp().getRequest();
  const response = context.switchToHttp().getResponse();

  // 1. 检查IP白名单 (从配置或数据库读取)
  const clientIp = request.ip;
  const ipWhitelist = ['10.0.0.0/8', '192.168.1.100']; // 示例,应来自配置
  if (this.isIpInWhitelist(clientIp, ipWhitelist)) {
    return true; // 直接放行
  }

  // 2. 检查用户白名单
  const user = (request as any).user;
  const userWhitelist = ['admin-user-id-123', 'service-account-456'];
  if (user && userWhitelist.includes(user.id)) {
    return true; // 直接放行
  }

  // 3. 检查路径白名单 (如健康检查)
  const pathWhitelist = ['/health', '/metrics'];
  if (pathWhitelist.includes(request.route?.path)) {
    return true;
  }

  // 4. 如果不是白名单,则执行正常的限流逻辑
  return super.canActivate(context);
}

private isIpInWhitelist(ip: string, cidrList: string[]): boolean {
  // 这里需要实现一个CIDR匹配的逻辑,可以使用库如 `ipaddr.js`
  // 简单示例,仅做精确匹配
  return cidrList.some(cidr => cidr === ip);
}

6.2 动态调整限流策略

线上服务的流量模式不是一成不变的。在大促期间,我们可能需要临时调高某些接口的限流阈值;在发现某个异常爬虫时,又可能需要临时对某个IP段实施更严格的限制。

硬编码在代码或配置文件里的策略,重启才能生效,这太不灵活了。我们可以考虑将策略配置存储在数据库(如MySQL、PostgreSQL)或配置中心(如Apollo、Nacos)里。

思路是创建一个 ThrottlerStrategyService,让它从动态配置源读取策略。然后在自定义Guard中,不再使用固定的 ttl 和 limit,而是调用这个服务来获取当前请求对应的策略。

// throttler-strategy.service.ts
import { Injectable } from '@nestjs/common';

interface RateLimitRule {
  key: string; // 匹配规则,如 'path:/api/v1/sms/*', 'ip:1.2.3.*'
  ttl: number;
  limit: number;
  enabled: boolean;
}

@Injectable()
export class ThrottlerStrategyService {
  private rules: RateLimitRule[] = [];

  // 定时从数据库或配置中心拉取最新规则
  async refreshRules() {
    // ... 从数据源获取规则
    this.rules = fetchedRules;
  }

  // 根据请求匹配最具体的规则
  getRuleForRequest(req: Request): RateLimitRule | undefined {
    const path = req.path;
    const ip = req.ip;
    // 这里可以实现一个匹配逻辑,例如优先匹配路径+IP组合规则,再匹配路径规则,最后是默认规则
    // 返回匹配到的第一条规则
    return this.rules.find(rule => this.matchRule(rule, path, ip));
  }

  private matchRule(rule: RateLimitRule, path: string, ip: string): boolean {
    // 实现你的匹配逻辑
    return true;
  }
}

然后在Guard中:

async canActivate(context: ExecutionContext): Promise<boolean> {
  // ... 白名单检查 ...
  const request = context.switchToHttp().getRequest();
  const rule = await this.strategyService.getRuleForRequest(request);

  if (!rule) {
    // 没有匹配规则,使用全局默认或直接放行
    return true;
  }
  if (!rule.enabled) {
    return true;
  }

  // 这里需要调用底层的限流逻辑,并传入动态的 rule.ttl 和 rule.limit
  // 可能需要更深入地继承和重写 ThrottlerGuard 的内部方法
  return this.handleThrottling(context, rule);
}

实现动态策略需要更深入地理解 @nestjs/throttler 的内部机制,复杂度较高,但它能带来极大的运维灵活性。对于大多数应用,从静态配置开始,结合白名单机制,已经能解决80%的问题。当业务发展到一定规模,确实需要精细化的、实时的流量管控时,再考虑引入动态策略也不迟。

走到这里,我们已经搭建了一个从单机到分布式、从基础到可观测、并具备一定管理能力的NestJS限流体系。这套体系不是一蹴而就的,你可以根据自己项目的实际阶段和复杂度,从最简单的 @nestjs/throttler 全局配置开始,逐步引入Redis、自定义Guard、监控,最后再到动态策略。记住,技术方案的核心是解决问题,而不是追求复杂度。先让限流跑起来,保护你的服务,然后再去迭代优化它。在实际项目中,我建议先把日志记录和基础监控做好,这样你才能清晰地看到限流是否在起作用,以及它拦截了哪些请求,为后续的调优提供数据支持。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐