基于Redis有序集合實(shí)現(xiàn)滑動(dòng)窗口限流的步驟
滑動(dòng)窗口算法是一種基于時(shí)間窗口的限流算法,它將時(shí)間劃分為若干個(gè)固定大小的窗口,每個(gè)窗口內(nèi)記錄了該時(shí)間段內(nèi)的請求次數(shù)。通過動(dòng)態(tài)地滑動(dòng)窗口,可以動(dòng)態(tài)調(diào)整限流的速率,以應(yīng)對不同的流量變化。
整個(gè)限流可以概括為兩個(gè)主要步驟:
- 統(tǒng)計(jì)窗口內(nèi)的請求數(shù)量
- 應(yīng)用限流規(guī)則
Redis有序集合每個(gè)value有一個(gè)score(分?jǐn)?shù)),基于score我們可以定義一個(gè)時(shí)間窗口,然后每次一個(gè)請求進(jìn)來就設(shè)置一個(gè)value,這樣就可以統(tǒng)計(jì)窗口內(nèi)的請求數(shù)量。key可以是資源名,比如一個(gè)url,或者ip+url,用戶標(biāo)識+url等。value在這里不那么重要,因?yàn)槲覀冎恍枰y(tǒng)計(jì)數(shù)量,因此value可以就設(shè)置成時(shí)間戳,但是如果value相同的話就會(huì)被覆蓋,所以我們可以把請求的數(shù)據(jù)做一個(gè)hash,將這個(gè)hash值當(dāng)value,或者如果每個(gè)請求有流水號的話,可以用請求流水號當(dāng)value,總之就是要能唯一標(biāo)識一次請求的。
所以,簡化后的命令就變成了:
ZADD 資源標(biāo)識 時(shí)間戳 請求標(biāo)識
public boolean isAllow(String key) { ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet(); // 獲取當(dāng)前時(shí)間戳 long currentTime = System.currentTimeMillis(); // 當(dāng)前時(shí)間 - 窗口大小 = 窗口開始時(shí)間 long windowStart = currentTime - period; // 刪除窗口開始時(shí)間之前的所有數(shù)據(jù) zSetOperations.removeRangeByScore(key, 0, windowStart); // 統(tǒng)計(jì)窗口中請求數(shù)量 Long count = zSetOperations.zCard(key); // 如果窗口中已經(jīng)請求的數(shù)量超過閾值,則直接拒絕 if (count >= threshold) { return false; } // 沒有超過閾值,則加入集合 String value = "請求唯一標(biāo)識(比如:請求流水號、哈希值、MD5值等)"; zSetOperations.add(key, String.valueOf(currentTime), currentTime); // 設(shè)置一個(gè)過期時(shí)間,及時(shí)清理冷數(shù)據(jù) stringRedisTemplate.expire(key, period, TimeUnit.MILLISECONDS); // 通過 return true; }
上面代碼中涉及到三條Redis命令,并發(fā)請求下可能存在問題,所以我們把它們寫成Lua腳本
local key = KEYS[1] local current_time = tonumber(ARGV[1]) local window_size = tonumber(ARGV[2]) local threshold = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window_size) local count = redis.call('ZCARD', key) if count >= threshold then return tostring(0) else redis.call('ZADD', key, tostring(current_time), current_time) return tostring(1) end
完整的代碼如下:
package com.example.demo.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.concurrent.TimeUnit; /** * 基于Redis有序集合實(shí)現(xiàn)滑動(dòng)窗口限流 * @Author: ChengJianSheng * @Date: 2024/12/26 */ @Service public class SlidingWindowRatelimiter { private long period = 60*1000; // 1分鐘 private int threshold = 3; // 3次 @Autowired private StringRedisTemplate stringRedisTemplate; /** * RedisTemplate */ public boolean isAllow(String key) { ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet(); // 獲取當(dāng)前時(shí)間戳 long currentTime = System.currentTimeMillis(); // 當(dāng)前時(shí)間 - 窗口大小 = 窗口開始時(shí)間 long windowStart = currentTime - period; // 刪除窗口開始時(shí)間之前的所有數(shù)據(jù) zSetOperations.removeRangeByScore(key, 0, windowStart); // 統(tǒng)計(jì)窗口中請求數(shù)量 Long count = zSetOperations.zCard(key); // 如果窗口中已經(jīng)請求的數(shù)量超過閾值,則直接拒絕 if (count >= threshold) { return false; } // 沒有超過閾值,則加入集合 String value = "請求唯一標(biāo)識(比如:請求流水號、哈希值、MD5值等)"; zSetOperations.add(key, String.valueOf(currentTime), currentTime); // 設(shè)置一個(gè)過期時(shí)間,及時(shí)清理冷數(shù)據(jù) stringRedisTemplate.expire(key, period, TimeUnit.MILLISECONDS); // 通過 return true; } /** * Lua腳本 */ public boolean isAllow2(String key) { String luaScript = "local key = KEYS[1]\n" + "local current_time = tonumber(ARGV[1])\n" + "local window_size = tonumber(ARGV[2])\n" + "local threshold = tonumber(ARGV[3])\n" + "redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window_size)\n" + "local count = redis.call('ZCARD', key)\n" + "if count >= threshold then\n" + " return tostring(0)\n" + "else\n" + " redis.call('ZADD', key, tostring(current_time), current_time)\n" + " return tostring(1)\n" + "end"; long currentTime = System.currentTimeMillis(); DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(luaScript, String.class); String result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key), String.valueOf(currentTime), String.valueOf(period), String.valueOf(threshold)); // 返回1表示通過,返回0表示拒絕 return "1".equals(result); } }
這里用StringRedisTemplate執(zhí)行Lua腳本,先把Lua腳本封裝成DefaultRedisScript對象。注意,千萬注意,Lua腳本的返回值必須是字符串,參數(shù)也最好都是字符串,用整型的話可能類型轉(zhuǎn)換錯(cuò)誤。
String requestId = UUID.randomUUID().toString(); DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(luaScript, String.class); String result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key), requestId, String.valueOf(period), String.valueOf(threshold));
好了,上面就是基于Redis有序集合實(shí)現(xiàn)的滑動(dòng)窗口限流。順帶提一句,Redis List類型也可以用來實(shí)現(xiàn)滑動(dòng)窗口。
接下來,我們來完善一下上面的代碼,通過AOP來攔截請求達(dá)到限流的目的
為此,我們必須自定義注解,然后根據(jù)注解參數(shù),來個(gè)性化的控制限流。那么,問題來了,如果獲取注解參數(shù)呢?
舉例說明:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface MyAnnotation { String value(); } @Aspect @Component public class MyAspect { @Before("@annotation(myAnnotation)") public void beforeMethod(JoinPoint joinPoint, MyAnnotation myAnnotation) { // 獲取注解參數(shù) String value = myAnnotation.value(); System.out.println("Annotation value: " + value); // 其他業(yè)務(wù)邏輯... } }
注意看,切點(diǎn)是怎么寫的 @Before("@annotation(myAnnotation)")
是@Before("@annotation(myAnnotation)"),而不是@Before("@annotation(MyAnnotation)")
myAnnotation,是參數(shù),而MyAnnotation則是注解類
此處參考資料
https://www.cnblogs.com/javaxubo/p/16556924.html
言歸正傳,我們首先定義一個(gè)注解
package com.example.demo.controller; import java.lang.annotation.*; /** * 請求速率限制 * @Author: ChengJianSheng * @Date: 2024/12/26 */ @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { /** * 窗口大?。J(rèn):60秒) */ long period() default 60; /** * 閾值(默認(rèn):3次) */ long threshold() default 3; }
定義切面
package com.example.demo.controller; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.support.RequestContextUtils; import java.util.concurrent.TimeUnit; /** * @Author: ChengJianSheng * @Date: 2024/12/26 */ @Slf4j @Aspect @Component public class RateLimitAspect { @Autowired private StringRedisTemplate stringRedisTemplate; // @Autowired // private SlidingWindowRatelimiter slidingWindowRatelimiter; @Before("@annotation(rateLimit)") public void doBefore(JoinPoint joinPoint, RateLimit rateLimit) { // 獲取注解參數(shù) long period = rateLimit.period(); long threshold = rateLimit.threshold(); // 獲取請求信息 ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest(); String uri = httpServletRequest.getRequestURI(); Long userId = 123L; // 模擬獲取用戶ID String key = "limit:" + userId + ":" + uri; /* if (!slidingWindowRatelimiter.isAllow2(key)) { log.warn("請求超過速率限制!userId={}, uri={}", userId, uri); throw new RuntimeException("請求過于頻繁!"); }*/ ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet(); // 獲取當(dāng)前時(shí)間戳 long currentTime = System.currentTimeMillis(); // 當(dāng)前時(shí)間 - 窗口大小 = 窗口開始時(shí)間 long windowStart = currentTime - period * 1000; // 刪除窗口開始時(shí)間之前的所有數(shù)據(jù) zSetOperations.removeRangeByScore(key, 0, windowStart); // 統(tǒng)計(jì)窗口中請求數(shù)量 Long count = zSetOperations.zCard(key); // 如果窗口中已經(jīng)請求的數(shù)量超過閾值,則直接拒絕 if (count < threshold) { // 沒有超過閾值,則加入集合 zSetOperations.add(key, String.valueOf(currentTime), currentTime); // 設(shè)置一個(gè)過期時(shí)間,及時(shí)清理冷數(shù)據(jù) stringRedisTemplate.expire(key, period, TimeUnit.SECONDS); } else { throw new RuntimeException("請求過于頻繁!"); } } }
加注解
@RestController @RequestMapping("/hello") public class HelloController { @RateLimit(period = 30, threshold = 2) @GetMapping("/sayHi") public void sayHi() { } }
最后,看Redis中的數(shù)據(jù)結(jié)構(gòu)
最后的最后,流量控制建議看看阿里巴巴 Sentinel
https://sentinelguard.io/zh-cn/
到此這篇關(guān)于基于Redis有序集合實(shí)現(xiàn)滑動(dòng)窗口限流的文章就介紹到這了,更多相關(guān)基于Redis有序集合實(shí)現(xiàn)滑動(dòng)窗口限流內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis中Redisson布隆過濾器的學(xué)習(xí)
布隆過濾器是一個(gè)非常長的二進(jìn)制向量和一系列隨機(jī)哈希函數(shù)的組合,可用于檢索一個(gè)元素是否存在,本文就詳細(xì)的介紹一下Redisson布隆過濾器,具有一定的參考價(jià)值,感興趣的可以了解一下2022-05-05redis key命名規(guī)范的設(shè)計(jì)
如果結(jié)構(gòu)規(guī)劃不合理、命令使用不規(guī)范,會(huì)造成系統(tǒng)性能達(dá)到瓶頸、活動(dòng)高峰系統(tǒng)可用性下降,也會(huì)增大運(yùn)維難度,本文主要介紹了redis key命名規(guī)范的設(shè)計(jì),感興趣的可以了解一下2024-03-03淺談redis的maxmemory設(shè)置以及淘汰策略
下面小編就為大家?guī)硪黄獪\談redis的maxmemory設(shè)置以及淘汰策略。小編覺得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-03-03Spark刪除redis千萬級別set集合數(shù)據(jù)實(shí)現(xiàn)分析
這篇文章主要為大家介紹了Spark刪除redis千萬級別set集合數(shù)據(jù)實(shí)現(xiàn)過程分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06Centos7 Redis主從搭建配置的實(shí)現(xiàn)
這篇文章主要介紹了Centos7 Redis主從搭建配置的實(shí)現(xiàn),小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-06-06