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

打印 上一主题 下一主题

主题 869|帖子 869|积分 2607

滑动窗口算法是一种基于时间窗口的限流算法,它将时间划分为若干个固定巨细的窗口,每个窗口内记录了该时间段内的请求次数。通过动态地滑动窗口,可以动态调整限流的速率,以应对差别的流量变化。
整个限流可以概括为两个主要步骤:

  • 统计窗口内的请求数量
  • 应用限流规则
Redis有序聚集每个value有一个score(分数),基于score我们可以界说一个时间窗口,然后每次一个请求进来就设置一个value,这样就可以统计窗口内的请求数量。key可以是资源名,比如一个url,或者ip+url,用户标识+url等。value在这里不那么重要,因为我们只需要统计数量,因此value可以就设置成时间戳,但是如果value类似的话就会被覆盖,所以我们可以把请求的数据做一个hash,将这个hash值当value,或者如果每个请求有流水号的话,可以用请求流水号当value,总之就是要能唯一标识一次请求的。
所以,简化后的命令就变成了:
  1. ZADD  资源标识   时间戳   请求标识
复制代码
 Java代码
  1. public boolean isAllow(String key) {
  2.     ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet();
  3.     //  获取当前时间戳
  4.     long currentTime = System.currentTimeMillis();
  5.     //  当前时间 - 窗口大小 = 窗口开始时间
  6.     long windowStart = currentTime - period;
  7.     //  删除窗口开始时间之前的所有数据
  8.     zSetOperations.removeRangeByScore(key, 0, windowStart);
  9.     //  统计窗口中请求数量
  10.     Long count = zSetOperations.zCard(key);
  11.     //  如果窗口中已经请求的数量超过阈值,则直接拒绝
  12.     if (count >= threshold) {
  13.         return false;
  14.     }
  15.     //  没有超过阈值,则加入集合
  16.     String value = "请求唯一标识(比如:请求流水号、哈希值、MD5值等)";
  17.     zSetOperations.add(key, String.valueOf(currentTime), currentTime);
  18.     //  设置一个过期时间,及时清理冷数据
  19.     stringRedisTemplate.expire(key, period, TimeUnit.MILLISECONDS);
  20.     //  通过
  21.     return true;
  22. }
复制代码
上面代码中涉及到三条Redis命令,并发请求下可能存在问题,所以我们把它们写成Lua脚本
  1. local key = KEYS[1]
  2. local current_time = tonumber(ARGV[1])
  3. local window_size = tonumber(ARGV[2])
  4. local threshold = tonumber(ARGV[3])
  5. redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window_size)
  6. local count = redis.call('ZCARD', key)
  7. if count >= threshold then
  8.     return tostring(0)
  9. else
  10.     redis.call('ZADD', key, tostring(current_time), current_time)
  11.     return tostring(1)
  12. end
复制代码
完整的代码如下:
  1. package com.example.demo.controller;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.data.redis.core.StringRedisTemplate;
  4. import org.springframework.data.redis.core.ZSetOperations;
  5. import org.springframework.data.redis.core.script.DefaultRedisScript;
  6. import org.springframework.stereotype.Service;
  7. import java.util.Collections;
  8. import java.util.concurrent.TimeUnit;
  9. /**
  10. * 基于Redis有序集合实现滑动窗口限流
  11. * @Author: ChengJianSheng
  12. * @Date: 2024/12/26
  13. */
  14. @Service
  15. public class SlidingWindowRatelimiter {
  16.     private long period = 60*1000;  //  1分钟
  17.     private int threshold = 3;      //  3次
  18.     @Autowired
  19.     private StringRedisTemplate stringRedisTemplate;
  20.     /**
  21.      * RedisTemplate
  22.      */
  23.     public boolean isAllow(String key) {
  24.         ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet();
  25.         //  获取当前时间戳
  26.         long currentTime = System.currentTimeMillis();
  27.         //  当前时间 - 窗口大小 = 窗口开始时间
  28.         long windowStart = currentTime - period;
  29.         //  删除窗口开始时间之前的所有数据
  30.         zSetOperations.removeRangeByScore(key, 0, windowStart);
  31.         //  统计窗口中请求数量
  32.         Long count = zSetOperations.zCard(key);
  33.         //  如果窗口中已经请求的数量超过阈值,则直接拒绝
  34.         if (count >= threshold) {
  35.             return false;
  36.         }
  37.         //  没有超过阈值,则加入集合
  38.         String value = "请求唯一标识(比如:请求流水号、哈希值、MD5值等)";
  39.         zSetOperations.add(key, String.valueOf(currentTime), currentTime);
  40.         //  设置一个过期时间,及时清理冷数据
  41.         stringRedisTemplate.expire(key, period, TimeUnit.MILLISECONDS);
  42.         //  通过
  43.         return true;
  44.     }
  45.     /**
  46.      * Lua脚本
  47.      */
  48.     public boolean isAllow2(String key) {
  49.         String luaScript = "local key = KEYS[1]\n" +
  50.                 "local current_time = tonumber(ARGV[1])\n" +
  51.                 "local window_size = tonumber(ARGV[2])\n" +
  52.                 "local threshold = tonumber(ARGV[3])\n" +
  53.                 "redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window_size)\n" +
  54.                 "local count = redis.call('ZCARD', key)\n" +
  55.                 "if count >= threshold then\n" +
  56.                 "    return tostring(0)\n" +
  57.                 "else\n" +
  58.                 "    redis.call('ZADD', key, tostring(current_time), current_time)\n" +
  59.                 "    return tostring(1)\n" +
  60.                 "end";
  61.         long currentTime = System.currentTimeMillis();
  62.         DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(luaScript, String.class);
  63.         String result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key), String.valueOf(currentTime), String.valueOf(period), String.valueOf(threshold));
  64.         //  返回1表示通过,返回0表示拒绝
  65.         return "1".equals(result);
  66.     }
  67. }
复制代码
这里用StringRedisTemplate执行Lua脚本,先把Lua脚本封装成DefaultRedisScript对象。留意,万万留意,Lua脚本的返回值必须是字符串,参数也最好都是字符串,用整型的话可能类型转换错误。
  1. String requestId = UUID.randomUUID().toString();
  2. DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(luaScript, String.class);
  3. String result = stringRedisTemplate.execute(redisScript,
  4.         Collections.singletonList(key),
  5.         requestId,
  6.         String.valueOf(period),
  7.         String.valueOf(threshold));
复制代码
好了,上面就是基于Redis有序聚集实现的滑动窗口限流。顺带提一句,Redis List类型也可以用来实现滑动窗口。
接下来,我们来完善一下上面的代码,通过AOP来拦截请求达到限流的目的
为此,我们必须自界说注解,然后根据注解参数,来个性化的控制限流。那么,问题来了,如果获取注解参数呢?
举例说明:
  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Target(ElementType.METHOD)
  3. public @interface MyAnnotation {
  4.     String value();
  5. }
  6. @Aspect
  7. @Component
  8. public class MyAspect {
  9.     @Before("@annotation(myAnnotation)")
  10.     public void beforeMethod(JoinPoint joinPoint, MyAnnotation myAnnotation) {
  11.         // 获取注解参数
  12.         String value = myAnnotation.value();
  13.         System.out.println("Annotation value: " + value);
  14.         // 其他业务逻辑...
  15.     }
  16. }
复制代码
留意看,切点是怎么写的 @Before("@annotation(myAnnotation)")
是@Before("@annotation(myAnnotation)"),而不是@Before("@annotation(MyAnnotation)")
myAnnotation,是参数,而MyAnnotation则是注解类

此处参考
https://www.cnblogs.com/javaxubo/p/16556924.html
https://blog.csdn.net/qq_40977118/article/details/119488358
https://blog.51cto.com/knifeedge/5529885
言归正传,我们起首界说一个注解
  1. package com.example.demo.controller;
  2. import java.lang.annotation.*;
  3. /**
  4. * 请求速率限制
  5. * @Author: ChengJianSheng
  6. * @Date: 2024/12/26
  7. */
  8. @Documented
  9. @Target(ElementType.METHOD)
  10. @Retention(RetentionPolicy.RUNTIME)
  11. public @interface RateLimit {
  12.     /**
  13.      * 窗口大小(默认:60秒)
  14.      */
  15.     long period() default 60;
  16.     /**
  17.      * 阈值(默认:3次)
  18.      */
  19.     long threshold() default 3;
  20. }
复制代码
界说切面
  1. package com.example.demo.controller;
  2. import jakarta.servlet.http.HttpServletRequest;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.aspectj.lang.JoinPoint;
  5. import org.aspectj.lang.annotation.Aspect;
  6. import org.aspectj.lang.annotation.Before;
  7. import org.springframework.beans.factory.annotation.Autowired;
  8. import org.springframework.data.redis.core.StringRedisTemplate;
  9. import org.springframework.data.redis.core.ZSetOperations;
  10. import org.springframework.stereotype.Component;
  11. import org.springframework.web.context.request.RequestContextHolder;
  12. import org.springframework.web.context.request.ServletRequestAttributes;
  13. import org.springframework.web.servlet.support.RequestContextUtils;
  14. import java.util.concurrent.TimeUnit;
  15. /**
  16. * @Author: ChengJianSheng
  17. * @Date: 2024/12/26
  18. */
  19. @Slf4j
  20. @Aspect
  21. @Component
  22. public class RateLimitAspect {
  23.     @Autowired
  24.     private StringRedisTemplate stringRedisTemplate;
  25. //    @Autowired
  26. //    private SlidingWindowRatelimiter slidingWindowRatelimiter;
  27.     @Before("@annotation(rateLimit)")
  28.     public void doBefore(JoinPoint joinPoint, RateLimit rateLimit) {
  29.         //  获取注解参数
  30.         long period = rateLimit.period();
  31.         long threshold = rateLimit.threshold();
  32.         //  获取请求信息
  33.         ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  34.         HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest();
  35.         String uri = httpServletRequest.getRequestURI();
  36.         Long userId = 123L;     //  模拟获取用户ID
  37.         String key = "limit:" + userId + ":" + uri;
  38.         /*
  39.         if (!slidingWindowRatelimiter.isAllow2(key)) {
  40.             log.warn("请求超过速率限制!userId={}, uri={}", userId, uri);
  41.             throw new RuntimeException("请求过于频繁!");
  42.         }*/
  43.         ZSetOperations<String, String> zSetOperations = stringRedisTemplate.opsForZSet();
  44.         //  获取当前时间戳
  45.         long currentTime = System.currentTimeMillis();
  46.         //  当前时间 - 窗口大小 = 窗口开始时间
  47.         long windowStart = currentTime - period * 1000;
  48.         //  删除窗口开始时间之前的所有数据
  49.         zSetOperations.removeRangeByScore(key, 0, windowStart);
  50.         //  统计窗口中请求数量
  51.         Long count = zSetOperations.zCard(key);
  52.         //  如果窗口中已经请求的数量超过阈值,则直接拒绝
  53.         if (count < threshold) {
  54.             //  没有超过阈值,则加入集合
  55.             zSetOperations.add(key, String.valueOf(currentTime), currentTime);
  56.             //  设置一个过期时间,及时清理冷数据
  57.             stringRedisTemplate.expire(key, period, TimeUnit.SECONDS);
  58.         } else {
  59.             throw new RuntimeException("请求过于频繁!");
  60.         }
  61.     }
  62. }
复制代码
加注解
  1. @RestController
  2. @RequestMapping("/hello")
  3. public class HelloController {
  4.     @RateLimit(period = 30, threshold = 2)
  5.     @GetMapping("/sayHi")
  6.     public void sayHi() {
  7.     }
  8. }
复制代码
最后,看Redis中的数据结构

最后的最后,流量控制发起看看阿里巴巴 Sentinel
https://sentinelguard.io/zh-cn/
 

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

万有斥力

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表