手写一个基于nestjs的 限流器

前言

限流是大型系统必备的保护措施,常用的限流算法主要有固定时间窗口,滑动时间窗口,漏桶,令牌桶等。本文将会写道的方案是使用 滑动时间窗口 算法,通过拒绝请求的方式来达到限流的目的。 本文的实现方式是 redis , lua 脚本 以及 Nestjs Guard 来实现 限流的效果。

概念浅析

这里简单说一下 固定时间窗口 和滑动时间窗口的概念

固定时间窗口 它可以解决 每 时间单位(可以是秒或者分钟等等),允许访问的次数。但是无法控制频率。举例1分钟允许访问100 次,可能前10 秒访问了90次,后面只有10次机会了。 还有一个问题就是在两个时间单位的临界值上可能会超出阈值,继续用前面的例子,第59秒访问了60次,第二个时间单位前10秒访问了50 次,在横跨两个时间单位的20秒中,超出了阈值 (110>100)

滑动时间窗口 可以改善固定窗口的所带来超出阈值的问题。它将每个单位之间分割成若干小周期,当前时间单位不再是固定的,而是根据当前请求时间往后移动,即所谓滑动窗口。每个周期分的越小,限流控制的越精细。

具体实现

使用的主要包的版本 nestjs 8.0.0 ioredis 5.3.2

我们主要实现以下几个东西

  • 一个 guard 文件 用于实现限流的业务逻辑
  • 一个 decorator文件 , 装饰器,用于设置当前接口限流的频率,允许访问次数等字段
  • 一个 redis 类 和一个lua 脚本

redis 相关

主要就是通过lua 脚本进行计数,达到限流的目的。这里做了一个优化,对执行lua 取了hash 值,在redis 运行一次后 ,可以使用evalsha 直接运行脚本,避免二次载入脚本。

ts 复制代码
import { Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { ConfigService } from '@app/common';
import { createHash } from 'crypto'
import { v4 as uuidv4 } from 'uuid';

const rateLimitScript = ""// 后面单独列出

@Injectable()
export class RedisService {
    private readonly redisClient: Redis.Redis;
    private luaScript: any;
    constructor(
        private readonly configService: ConfigService,
    ) {

        const self = this;
        const connConfig = this.configService.get("redisService")
        this.redisClient = new Redis.Redis(connConfig)
        this.luaScript = {
            rateLimit: {
                script: rateLimitScript,
                hash: self.hashStr(rateLimitScript)
            },
        }
        
    }

    private hashStr(value: string) {
        return createHash("sha1").update(value).digest('hex')
    }

    async rateLimit(opts: any): Promise<boolean> {

        const { key, limit, windowSize } = opts;
        const uuid = uuidv4()
        let result;
        const { script, hash } = this.luaScript.rateLimit
  
        try {

            const shaResult = await this.redisClient.evalsha(hash, 1, key, limit, windowSize, uuid)
            result = shaResult

        } catch (error) {

            const shaResult = await this.redisClient.eval(script, 1, key, limit, windowSize, uuid)
            result = shaResult

        }
        return result == 1
    }
}

接下来展示lua 脚本

lua 复制代码
--传入四个参数 分别是key,限制次数,时间范围,唯一值
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2]) --单位毫秒
local uuid = ARGV[3] -- 唯一值是为了防止zset 重复

-- 使用redis 来获取时间,防止多进程生成相似的边界导致超频。时间单位是微秒
local date = redis.call("time")
local now = tonumber(date[1]) * 1000000 + tonumber(date[2])
local startTime = now - windowSize * 1000
local endTime = now +  1000000

-- 计算过期时间 时间单位是秒
local expireSec = tonumber(math.ceil(windowSize / 1000)) + 1

-- 统计当前zset数组里的数据,超出范围则返回0,
-- 否则做3件事,然后返回1
-- 1、向数组里增加新值
-- 2、删除数组中开始时间之前的数据,防止数组过大
-- 3、给数组续过期时间
local count = tonumber(redis.call('zcount', key, startTime, endTime))

if count + 1 > limit then
    return 0
else
    redis.call('zadd', key, now, uuid)
    redis.call('zremrangebyscore', key, 0, startTime - 100000)
    redis.call('expire', key, expireSec)
    return 1
end

装饰器相关

这个很简单就是,设置一下redis 键值的前缀,允许访问的次数和 单位之间的长度。在这里设置了之后可以在 guard 里通过反射拿到这些值

ts 复制代码
import { SetMetadata } from '@nestjs/common';

export interface rateLimitOptions {
    keyPrefix: string,
    limit: number,
    windowSize: number
}

export const RateLimit = (options: rateLimitOptions): MethodDecorator => SetMetadata('rateLimit', options)

guard 相关

guard 就是把之前的部分整合了一下,如果当前接口没有设置限流参数则启用默认参数,keyprefix 取当前接口的路径。

ts 复制代码
import { Injectable, ExecutionContext, CanActivate } from "@nestjs/common";
import { Reflector } from '@nestjs/core';
import { RedisService, rateLimitOptions } from "@app/common";
import { BusinessException } from "@app/common";

@Injectable()
export class RateLimitGuard implements CanActivate {
    constructor(
        private reflector: Reflector,
        private redisService: RedisService
    ) { }

    private getIpFromRequest(request: { ip: string }): string {
        return request.ip?.match(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/)?.[0]
    }

    async canActivate(context: ExecutionContext) {

         // 通过反射拿到前面设置的值
        const rateLimitConfig = this.reflector.get<rateLimitOptions>("rateLimit", context.getHandler());
           if (!rateLimitConfig) {
			 // 当前接口如果没设置参数则定义默认参数
            const cMethod = this.reflector.get("method", context.getHandler());// 是GET,POST 等http method
            const cPath = this.reflector.get("path", context.getHandler());// 接口的具体路径
            rateLimitConfig = {
                keyPrefix: cMethod + ":" + cPath,
                limit: 1,
                windowSize: 5000
            }
        }

        const { keyPrefix, limit, windowSize } = rateLimitConfig
        const request = context.switchToHttp().getRequest();
        const ip = this.getIpFromRequest(request)
        const key = keyPrefix + ":" + ip

        const isPass = await this.redisService.rateLimit({
            key,
            limit,
            windowSize
        })
        if (!isPass) {
            // 返回自定义的错误
            throw new BusinessException("RATE_LIMIT_EXCEEDED_LIMIT")
        }
        return true
    }
}

使用方法

引入guand 和RateLimit 装饰器,可以给特定路由增加限流保护

ts 复制代码
@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) { }
  
  @Public()
  @RateLimit({ keyPrefix: "login", limit: 3, windowSize: 1000 })
  @UseGuards(RateLimitGuard)
  @Post('register')
  register(@Body() createUserDto: CreateUserDto) {
    return this.userService.register(createUserDto);
   }
}

或者基于模块的也可以,这样路由里的就可以省略了,如果某些接口没设置RateLimit 参数,guard 内部就会使用默认统一参数。

ts 复制代码
@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RateLimitGuard
    }
  ],
})

使用ab 测试一下结果,为了便于测试设置为每5秒可以请求3次。用 ab进行两次测试,结果如下

shell 复制代码
2023-11-25 17:50:39 - error - HttpExceptionFilter - d1385d48-183c-4fbf-b751-4d0b6786f5ba : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:39 - error - HttpExceptionFilter - 65f0e427-92e4-4854-a6cc-116c70daac61 : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:39 - error - HttpExceptionFilter - b24b8f7e-f961-45d1-a909-36219fc5d112 : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:39 - error - HttpExceptionFilter - 9c65d452-8eeb-4c40-a76e-b5bf01524ebb : {"validatorCode":30000,"validatorMessage":"请求频率过快"} - {}
2023-11-25 17:50:39 - error - HttpExceptionFilter - 1065a900-bb55-4514-9b55-08cc57509e37 : {"validatorCode":30000,"validatorMessage":"请求频率过快"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - a8176f1c-2788-4e2a-8267-4e2ffadb6238 : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - db911d44-ee8b-4da2-a87b-fa0bcb433c45 : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - 3c59e335-441b-4907-80e4-0d807e5bfb01 : {"validatorCode":10005,"validatorMessage":"用户已存在"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - 6f48108b-bc8e-4fb6-8231-fa5a9cd22b5f : {"validatorCode":30000,"validatorMessage":"请求频率过快"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - 7c77c1df-bfe6-4f72-b75a-0d9a1211fe64 : {"validatorCode":30000,"validatorMessage":"请求频率过快"} - {}

达到要求,收工。

相关推荐
IT大白鼠2 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz2 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙2 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除2 天前
redis--018
redis
两锭子2 天前
Redis基础
redis
烟沙九洲2 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索2 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞2 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
小二李2 天前
第15章 NestJS 项目部署
nestjs
Rain的Java大神之路3 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试