基于Redis有序集合實(shí)現(xiàn)滑動(dòng)窗口限流的步驟
滑動(dòng)窗口算法是一種基于時(shí)間窗口的限流算法,它將時(shí)間劃分為若干個(gè)固定大小的窗口,每個(gè)窗口內(nèi)記錄了該時(shí)間段內(nèi)的請(qǐng)求次數(shù)。通過動(dòng)態(tài)地滑動(dòng)窗口,可以動(dòng)態(tài)調(diào)整限流的速率,以應(yīng)對(duì)不同的流量變化。
整個(gè)限流可以概括為兩個(gè)主要步驟:
- 統(tǒng)計(jì)窗口內(nèi)的請(qǐng)求數(shù)量
- 應(yīng)用限流規(guī)則
Redis有序集合每個(gè)value有一個(gè)score(分?jǐn)?shù)),基于score我們可以定義一個(gè)時(shí)間窗口,然后每次一個(gè)請(qǐng)求進(jìn)來就設(shè)置一個(gè)value,這樣就可以統(tǒng)計(jì)窗口內(nèi)的請(qǐng)求數(shù)量。key可以是資源名,比如一個(gè)url,或者ip+url,用戶標(biāo)識(shí)+url等。value在這里不那么重要,因?yàn)槲覀冎恍枰y(tǒng)計(jì)數(shù)量,因此value可以就設(shè)置成時(shí)間戳,但是如果value相同的話就會(huì)被覆蓋,所以我們可以把請(qǐng)求的數(shù)據(jù)做一個(gè)hash,將這個(gè)hash值當(dāng)value,或者如果每個(gè)請(qǐng)求有流水號(hào)的話,可以用請(qǐng)求流水號(hào)當(dāng)value,總之就是要能唯一標(biāo)識(shí)一次請(qǐng)求的。
所以,簡(jiǎn)化后的命令就變成了:
ZADD 資源標(biāo)識(shí) 時(shí)間戳 請(qǐng)求標(biāo)識(shí)
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ì)窗口中請(qǐng)求數(shù)量
Long count = zSetOperations.zCard(key);
// 如果窗口中已經(jīng)請(qǐng)求的數(shù)量超過閾值,則直接拒絕
if (count >= threshold) {
return false;
}
// 沒有超過閾值,則加入集合
String value = "請(qǐng)求唯一標(biāo)識(shí)(比如:請(qǐng)求流水號(hà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ā)請(qǐng)求下可能存在問題,所以我們把它們寫成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ì)窗口中請(qǐng)求數(shù)量
Long count = zSetOperations.zCard(key);
// 如果窗口中已經(jīng)請(qǐng)求的數(shù)量超過閾值,則直接拒絕
if (count >= threshold) {
return false;
}
// 沒有超過閾值,則加入集合
String value = "請(qǐng)求唯一標(biāo)識(shí)(比如:請(qǐng)求流水號(hà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對(duì)象。注意,千萬(wàn)注意,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來攔截請(qǐng)求達(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.*;
/**
* 請(qǐng)求速率限制
* @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();
// 獲取請(qǐng)求信息
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("請(qǐng)求超過速率限制!userId={}, uri={}", userId, uri);
throw new RuntimeException("請(qǐng)求過于頻繁!");
}*/
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ì)窗口中請(qǐng)求數(shù)量
Long count = zSetOperations.zCard(key);
// 如果窗口中已經(jīng)請(qǐ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("請(qǐng)求過于頻繁!");
}
}
}加注解
@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)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
阿里云官方Redis開發(fā)規(guī)范總結(jié)
本文主要介紹了阿里云官方Redis開發(fā)規(guī)范總結(jié),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-08-08
springboot中redis并發(fā)鎖的等待時(shí)間設(shè)置長(zhǎng)短的方法
在SpringBoot應(yīng)用中,Redis鎖的等待時(shí)間設(shè)置不當(dāng)可能導(dǎo)致資源浪費(fèi)、響應(yīng)時(shí)間增加、死鎖風(fēng)險(xiǎn)升高、系統(tǒng)負(fù)載增加、業(yè)務(wù)邏輯延遲以及故障恢復(fù)慢等問題,建議合理設(shè)置等待時(shí)間,并考慮使用其他分布式鎖實(shí)現(xiàn)方式提高性能2024-10-10
Redis分布式鎖的使用和實(shí)現(xiàn)原理詳解
這篇文章主要給大家介紹了關(guān)于Redis分布式鎖的使用和實(shí)現(xiàn)原理的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-11-11
redis發(fā)布和訂閱_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
這篇文章主要為大家詳細(xì)介紹了redis發(fā)布和訂閱的相關(guān)資料,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08
Redis的六種底層數(shù)據(jù)結(jié)構(gòu)(小結(jié))
本文主要介紹了Redis的六種底層數(shù)據(jù)結(jié)構(gòu),文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-01-01

