基于Redis有序集合实现滑动窗口限流

滑动窗口算法是一种基于时间窗口的限流算法,它将时间划分为若干个固定大小的窗口,每个窗口内记录了该时间段内的请求次数。通过动态地滑动窗口,可以动态调整限流的速率,以应对不同的流量变化。

整个限流可以概括为两个主要步骤:

  1. 统计窗口内的请求数量
  2. 应用限流规则

Redis有序集合每个value有一个score(分数),基于score我们可以定义一个时间窗口,然后每次一个请求进来就设置一个value,这样就可以统计窗口内的请求数量。key可以是资源名,比如一个url,或者ip+url,用户标识+url等。value在这里不那么重要,因为我们只需要统计数量,因此value可以就设置成时间戳,但是如果value相同的话就会被覆盖,所以我们可以把请求的数据做一个hash,将这个hash值当value,或者如果每个请求有流水号的话,可以用请求流水号当value,总之就是要能唯一标识一次请求的。

所以,简化后的命令就变成了:

```
ZADD  资源标识   时间戳   请求标识
```

Java代码

```
public boolean isAllow(String key) {
    ZSetOperations zSetOperations = stringRedisTemplate.opsForZSet();
    //  获取当前时间戳
    long currentTime = System.currentTimeMillis();
    //  当前时间 - 窗口大小 = 窗口开始时间
    long windowStart = currentTime - period;
    //  删除窗口开始时间之前的所有数据
    zSetOperations.removeRangeByScore(key, 0, windowStart);
    //  统计窗口中请求数量
    Long count = zSetOperations.zCard(key);
    //  如果窗口中已经请求的数量超过阈值,则直接拒绝
    if (count >= threshold) {
        return false;
    }
    //  没有超过阈值,则加入集合
    String value = "请求唯一标识(比如:请求流水号、哈希值、MD5值等)";
    zSetOperations.add(key, String.valueOf(currentTime), currentTime);
    //  设置一个过期时间,及时清理冷数据
    stringRedisTemplate.expire(key, period, TimeUnit.MILLISECONDS);
    //  通过
    return true;
}
```

上面代码中涉及到三条Redis命令,并发请求下可能存在问题,所以我们把它们写成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有序集合实现滑动窗口限流
 * @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 zSetOperations = stringRedisTemplate.opsForZSet();
        //  获取当前时间戳
        long currentTime = System.currentTimeMillis();
        //  当前时间 - 窗口大小 = 窗口开始时间
        long windowStart = currentTime - period;
        //  删除窗口开始时间之前的所有数据
        zSetOperations.removeRangeByScore(key, 0, windowStart);
        //  统计窗口中请求数量
        Long count = zSetOperations.zCard(key);
        //  如果窗口中已经请求的数量超过阈值,则直接拒绝
        if (count >= threshold) {
            return false;
        }
        //  没有超过阈值,则加入集合
        String value = "请求唯一标识(比如:请求流水号、哈希值、MD5值等)";
        zSetOperations.add(key, String.valueOf(currentTime), currentTime);
        //  设置一个过期时间,及时清理冷数据
        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 thenn" +
                "    return tostring(0)n" +
                "elsen" +
                "    redis.call('ZADD', key, tostring(current_time), current_time)n" +
                "    return tostring(1)n" +
                "end";

        long currentTime = System.currentTimeMillis();

        DefaultRedisScript 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执行Lua脚本,先把Lua脚本封装成DefaultRedisScript对象。注意,千万注意,Lua脚本的返回值必须是字符串,参数也最好都是字符串,用整型的话可能类型转换错误。

```
String requestId = UUID.randomUUID().toString();

DefaultRedisScript redisScript = new DefaultRedisScript<>(luaScript, String.class);

String result = stringRedisTemplate.execute(redisScript,
        Collections.singletonList(key),
        requestId,
        String.valueOf(period),
        String.valueOf(threshold));
```

好了,上面就是基于Redis有序集合实现的滑动窗口限流。顺带提一句,Redis List类型也可以用来实现滑动窗口。

接下来,我们来完善一下上面的代码,通过AOP来拦截请求达到限流的目的

为此,我们必须自定义注解,然后根据注解参数,来个性化的控制限流。那么,问题来了,如果获取注解参数呢?

举例说明:

```
@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) {
        // 获取注解参数
        String value = myAnnotation.value();
        System.out.println("Annotation value: " + value);

        // 其他业务逻辑...
    }
}
```

注意看,切点是怎么写的 @Before("@annotation(myAnnotation)")

是@Before("@annotation(myAnnotation)"),而不是@Before("@annotation(MyAnnotation)")

myAnnotation,是参数,而MyAnnotation则是注解类

基于Redis有序集合实现滑动窗口限流

此处参考

https://www.cnblogs.com/javaxubo/p/16556924.html

https://blog.csdn.net/qq_40977118/article/details/119488358

https://blog.51cto.com/knifeedge/5529885

言归正传,我们首先定义一个注解

```
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 {
    /**
     * 窗口大小(默认:60秒)
     */
    long period() default 60;

    /**
     * 阈值(默认: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) {
        //  获取注解参数
        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 zSetOperations = stringRedisTemplate.opsForZSet();
        //  获取当前时间戳
        long currentTime = System.currentTimeMillis();
        //  当前时间 - 窗口大小 = 窗口开始时间
        long windowStart = currentTime - period * 1000;
        //  删除窗口开始时间之前的所有数据
        zSetOperations.removeRangeByScore(key, 0, windowStart);
        //  统计窗口中请求数量
        Long count = zSetOperations.zCard(key);
        //  如果窗口中已经请求的数量超过阈值,则直接拒绝
        if (count < threshold) {
            //  没有超过阈值,则加入集合
            zSetOperations.add(key, String.valueOf(currentTime), currentTime);
            //  设置一个过期时间,及时清理冷数据
            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中的数据结构

基于Redis有序集合实现滑动窗口限流

最后的最后,流量控制建议看看阿里巴巴 Sentinel

https://sentinelguard.io/zh-cn/

文章整理自互联网,只做测试使用。发布者:Lomu,转转请注明出处:https://www.it1024doc.com/5009.html

(0)
LomuLomu
上一篇 2024 年 12 月 31 日 上午3:45
下一篇 2024 年 12 月 31 日

相关推荐

  • Java 技术前沿:大数据在智能医疗远程手术中的创新应用(215)

    ✨亲爱的技术爱好者们,诚挚欢迎您访问【青云科技博客】!在这个充满机遇的数字时代,我们致力于打造一个专业的技术交流平台。这里汇聚了前沿技术资讯和实用开发技巧,期待与您共同探讨技术奥秘,携手迈向智能未来!✨全网平台(微信公众号/CSDN/抖音/华为/支付宝/微博):青云科技一、加入【技术精英社群】快速加入通道1:[青云技术精英社群(最新)]快速加入通道2:[CS…

    2025 年 5 月 19 日
    39500
  • 阿里一面:那我把线程池coreSize配置成0会怎样?

    写在前面 设想一下,在我们的项目中存在一个边缘的业务流程,它并不频繁地被触发。在设计线程池时,我回想起了线程池的常见配置原则。为了最大限度地节省资源,我将核心线程数(corePoolSize)设置为0。这样的配置是否能够顺利执行任务呢? 线程池配置原则回顾 在任务提交时,线程池会根据以下策略进行处理: 如果线程池中的线程数少于核心线程数,则创建一个新线程来执…

    2024 年 12 月 26 日
    45000
  • SpringBoot3整合Swagger3时出现Type javax.servlet.http.HttpServletRequest not present错误

    目录 错误详情 错误原因 解决方法 引入依赖 修改配置信息 创建文件 访问 错误详情 错误原因 SpringBoot3和Swagger3版本不匹配 解决方法 使用springdoc替代springfox,具体步骤如下: 引入依赖 在pom.xml文件中添加如下依赖: org.springdoc springdoc-openapi-starter-webmvc…

    2025 年 1 月 15 日
    34300
  • Redis Java 集成到 Spring Boot

    # Hi~!这里是奋斗的明志,很荣幸您能阅读我的文章,诚请评论指点,欢迎欢迎~~ ## 📚 本系列文章为个人学习笔记,在这里撰写成文一为巩固知识,二为展示我的学习过程及理解。文笔、排版拙劣,望见谅。 ![在这里插入图片描述](https://pic.it1024doc.com/csdn/202412/ca054e533844dc927c24e57c1713f…

    2024 年 12 月 27 日
    34800
  • 比想象中更复杂一点的MySQL Slow Query Log

    1. 问题概述 在分析 Slow Query Log 时,记录下的SQL语句,明明会对一张表执行全表扫描,可为什么慢日志中的 Rows_sent 、Rows_examined 和表的真实记录数也是不一样,甚至相差N多倍。还有一个细节就是上述的SQL语句,执行多次,在慢日志中记录下多条记录,记录之间Rows_sent 、Rows_examined也差别明显。 …

    未分类 2025 年 1 月 16 日
    30500

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信