NestJS+Redis實(shí)現(xiàn)手寫一個(gè)限流器
前言
限流是大型系統(tǒng)必備的保護(hù)措施,常用的限流算法主要有固定時(shí)間窗口,滑動(dòng)時(shí)間窗口,漏桶,令牌桶等。本文將會(huì)寫道的方案是使用 滑動(dòng)時(shí)間窗口 算法,通過拒絕請(qǐng)求的方式來達(dá)到限流的目的。 本文的實(shí)現(xiàn)方式是 redis
, lua 腳本
以及 Nestjs Guard
來實(shí)現(xiàn) 限流的效果。
概念淺析
這里簡單說一下 固定時(shí)間窗口 和滑動(dòng)時(shí)間窗口的概念
固定時(shí)間窗口
它可以解決 每 時(shí)間單位(可以是秒或者分鐘等等),允許訪問的次數(shù)。但是無法控制頻率。舉例1分鐘允許訪問100 次,可能前10 秒訪問了90次,后面只有10次機(jī)會(huì)了。 還有一個(gè)問題就是在兩個(gè)時(shí)間單位的臨界值上可能會(huì)超出閾值,繼續(xù)用前面的例子,第59秒訪問了60次,第二個(gè)時(shí)間單位前10秒訪問了50 次,在橫跨兩個(gè)時(shí)間單位的20秒中,超出了閾值 (110>100)
滑動(dòng)時(shí)間窗口
可以改善固定窗口的所帶來超出閾值的問題。它將每個(gè)單位之間分割成若干小周期,當(dāng)前時(shí)間單位不再是固定的,而是根據(jù)當(dāng)前請(qǐng)求時(shí)間往后移動(dòng),即所謂滑動(dòng)窗口。每個(gè)周期分的越小,限流控制的越精細(xì)。
具體實(shí)現(xiàn)
使用的主要包的版本 nestjs 8.0.0
ioredis 5.3.2
我們主要實(shí)現(xiàn)以下幾個(gè)東西
- 一個(gè) guard 文件 用于實(shí)現(xiàn)限流的業(yè)務(wù)邏輯
- 一個(gè) decorator文件 , 裝飾器,用于設(shè)置當(dāng)前接口限流的頻率,允許訪問次數(shù)等字段
- 一個(gè) redis 類 和一個(gè)lua 腳本
redis 相關(guān)
主要就是通過lua 腳本進(jìn)行計(jì)數(shù),達(dá)到限流的目的。這里做了一個(gè)優(yōu)化,對(duì)執(zhí)行l(wèi)ua 取了hash 值,在redis 運(yùn)行一次后 ,可以使用evalsha 直接運(yùn)行腳本,避免二次載入腳本。
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 = ""http:// 后面單獨(dú)列出 @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 腳本
--傳入四個(gè)參數(shù) 分別是key,限制次數(shù),時(shí)間范圍,唯一值 local key = KEYS[1] local limit = tonumber(ARGV[1]) local windowSize = tonumber(ARGV[2]) --單位毫秒 local uuid = ARGV[3] -- 唯一值是為了防止zset 重復(fù) -- 使用redis 來獲取時(shí)間,防止多進(jìn)程生成相似的邊界導(dǎo)致超頻。時(shí)間單位是微秒 local date = redis.call("time") local now = tonumber(date[1]) * 1000000 + tonumber(date[2]) local startTime = now - windowSize * 1000 local endTime = now + 1000000 -- 計(jì)算過期時(shí)間 時(shí)間單位是秒 local expireSec = tonumber(math.ceil(windowSize / 1000)) + 1 -- 統(tǒng)計(jì)當(dāng)前zset數(shù)組里的數(shù)據(jù),超出范圍則返回0, -- 否則做3件事,然后返回1 -- 1、向數(shù)組里增加新值 -- 2、刪除數(shù)組中開始時(shí)間之前的數(shù)據(jù),防止數(shù)組過大 -- 3、給數(shù)組續(xù)過期時(shí)間 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
裝飾器相關(guān)
這個(gè)很簡單就是,設(shè)置一下redis 鍵值的前綴,允許訪問的次數(shù)和 單位之間的長度。在這里設(shè)置了之后可以在 guard 里通過反射拿到這些值
import { SetMetadata } from '@nestjs/common'; export interface rateLimitOptions { keyPrefix: string, limit: number, windowSize: number } export const RateLimit = (options: rateLimitOptions): MethodDecorator => SetMetadata('rateLimit', options)
guard 相關(guān)
guard 就是把之前的部分整合了一下,如果當(dāng)前接口沒有設(shè)置限流參數(shù)則啟用默認(rèn)參數(shù),keyprefix 取當(dāng)前接口的路徑。
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) { // 通過反射拿到前面設(shè)置的值 const rateLimitConfig = this.reflector.get<rateLimitOptions>("rateLimit", context.getHandler()); if (!rateLimitConfig) { // 當(dāng)前接口如果沒設(shè)置參數(shù)則定義默認(rèn)參數(shù) 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) { // 返回自定義的錯(cuò)誤 throw new BusinessException("RATE_LIMIT_EXCEEDED_LIMIT") } return true } }
使用方法
引入guand 和RateLimit 裝飾器,可以給特定路由增加限流保護(hù)
@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); } }
或者基于模塊的也可以,這樣路由里的就可以省略了,如果某些接口沒設(shè)置RateLimit 參數(shù),guard 內(nèi)部就會(huì)使用默認(rèn)統(tǒng)一參數(shù)。
@Module({ providers: [ { provide: APP_GUARD, useClass: RateLimitGuard } ], })
使用ab 測(cè)試一下結(jié)果,為了便于測(cè)試設(shè)置為每5秒可以請(qǐng)求3次。用 ab
進(jìn)行兩次測(cè)試,結(jié)果如下
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":"請(qǐng)求頻率過快"} - {}
2023-11-25 17:50:39 - error - HttpExceptionFilter - 1065a900-bb55-4514-9b55-08cc57509e37 : {"validatorCode":30000,"validatorMessage":"請(qǐng)求頻率過快"} - {}
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":"請(qǐng)求頻率過快"} - {}
2023-11-25 17:50:45 - error - HttpExceptionFilter - 7c77c1df-bfe6-4f72-b75a-0d9a1211fe64 : {"validatorCode":30000,"validatorMessage":"請(qǐng)求頻率過快"} - {}
達(dá)到要求,收工。
以上就是NestJS+Redis實(shí)現(xiàn)手寫一個(gè)限流器的詳細(xì)內(nèi)容,更多關(guān)于NestJS Redis限流器的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
在Ubuntu?14.04系統(tǒng)上備份和恢復(fù)Redis數(shù)據(jù)詳細(xì)步驟
這篇文章主要給大家介紹了關(guān)于在Ubuntu?14.04系統(tǒng)上備份和恢復(fù)Redis數(shù)據(jù)的詳細(xì)步驟,文中通過代碼介紹的非常詳細(xì),對(duì)大家學(xué)習(xí)或者使用Redis具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2024-04-04Redis連接池監(jiān)控(連接池是否已滿)與優(yōu)化方法
本文詳細(xì)講解了如何在Linux系統(tǒng)中監(jiān)控Redis連接池的使用情況,以及如何通過連接池參數(shù)配置、系統(tǒng)資源使用情況、Redis命令監(jiān)控、外部監(jiān)控工具等多種方法進(jìn)行檢測(cè)和優(yōu)化,以確保系統(tǒng)在高并發(fā)場(chǎng)景下的性能和穩(wěn)定性,討論了連接池的概念、工作原理、參數(shù)配置,以及優(yōu)化策略等內(nèi)容2024-09-09Redis中l(wèi)ua腳本實(shí)現(xiàn)及其應(yīng)用場(chǎng)景
本文主要介紹了Redis中l(wèi)ua腳本實(shí)現(xiàn)及其應(yīng)用場(chǎng)景,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-04-04redis使用不當(dāng)導(dǎo)致應(yīng)用卡死bug的過程解析
本文主要記一次找因redis使用不當(dāng)導(dǎo)致應(yīng)用卡死bug的過程,文中通過示例代碼介紹的非常詳細(xì),需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-07-07RedisTemplate 實(shí)現(xiàn)基于Value 操作的簡易鎖機(jī)制(示例代碼)
本文將介紹如何使用 RedisTemplate 的 opsForValue().setIfAbsent() 方法來實(shí)現(xiàn)一種簡單的鎖機(jī)制,并提供一個(gè)示例代碼,展示如何在 Java 應(yīng)用中利用這一機(jī)制來保護(hù)共享資源的訪問,感興趣的朋友跟隨小編一起看看吧2024-05-05使用寶塔在服務(wù)器上配置Redis的詳細(xì)圖文教程
這篇文章主要給大家介紹了關(guān)于使用寶塔在服務(wù)器上配置Redis的相關(guān)資料,包括下載和安裝Redis,開放端口,修改配置文件以允許遠(yuǎn)程訪問和設(shè)置密碼,該過程對(duì)于理解Redis在項(xiàng)目部署中的配置提供了實(shí)用指導(dǎo),需要的朋友可以參考下2024-11-11詳解Redis中的BigKey如何發(fā)現(xiàn)和處理
這篇文章主要為大家詳細(xì)介紹了Redis中的BigKey如何發(fā)現(xiàn)和處理,文中給大家詳細(xì)講解了BigKey危害和如何解決這些問題,文章通過代碼示例和圖文介紹的非常詳細(xì),需要的朋友可以參考下2023-10-10