当前位置:   article > 正文

SpringBoot + Redis 实现接口限流_springboot redis限流

springboot redis限流

一、思路

使用接口限流的主要目的在于提高系统的稳定性,防止接口被恶意打击(短时间内大量请求)。

比如要求某接口在1分钟内请求次数不超过1000次,那么应该如何设计代码呢?

下面讲两种思路,如果想看代码可直接翻到后面的代码部分。

1.1 固定时间段(旧思路)

1.1.1 思路描述

该方案的思路是:使用Redis记录固定时间段内某用户IP访问某接口的次数,其中:

  • Redis的key:用户IP + 接口方法名

  • Redis的value:当前接口访问次数。

当用户在近期内第一次访问该接口时,向Redis中设置一个包含了用户IP和接口方法名的key,value的值初始化为1(表示第一次访问当前接口)。同时,设置该key的过期时间(比如为60秒)。

之后,只要这个key还未过期,用户每次访问该接口都会导致value自增1次。

用户每次访问接口前,先从Redis中拿到当前接口访问次数,如果发现访问次数大于规定的次数(如超过1000次),则向用户返回接口访问失败的标识。

图片

1.1.2 思路缺陷

该方案的缺点在于,限流时间段是固定的。

比如要求某接口在1分钟内请求次数不超过1000次,观察以下流程:

图片

图片

可以发现,00:59和01:01之间仅仅间隔了2秒,但接口却被访问了1000+999=1999次,是限流次数(1000次)的2倍!

所以在该方案中,限流次数的设置可能不起作用,仍然可能在短时间内造成大量访问。

1.2 滑动窗口(新思路)

1.2.1 思路描述

为了避免出现方案1中由于键过期导致的短期访问量增大的情况,我们可以改变一下思路,也就是把固定的时间段改成动态的:

假设某个接口在10秒内只允许访问5次。用户每次访问接口时,记录当前用户访问的时间点(时间戳),并计算前10秒内用户访问该接口的总次数。如果总次数大于限流次数,则不允许用户访问该接口。这样就能保证在任意时刻用户的访问次数不会超过1000次。

如下图,假设用户在0:19时间点访问接口,经检查其前10秒内访问次数为5次,则允许本次访问。

图片

假设用户0:20时间点访问接口,经检查其前10秒内访问次数为6次(超出限流次数5次),则不允许本次访问。

图片

1.2.2 Redis部分的实现

1)选用何种 Redis 数据结构

首先是需要确定使用哪个Redis数据结构。用户每次访问时,需要用一个key记录用户访问的时间点,而且还需要利用这些时间点进行范围检查。

2)为何选择 zSet 数据结构

为了能够实现范围检查,可以考虑使用Redis中的zSet有序集合。

添加一个zSet元素的命令如下:

ZADD [key] [score] [member]

它有一个关键的属性score,通过它可以记录当前member的优先级。

于是我们可以把score设置成用户访问接口的时间戳,以便于通过score进行范围检查。key则记录用户IP和接口方法名,至于member设置成什么没有影响,一个member记录了用户访问接口的时间点。因此member也可以设置成时间戳。

3)zSet 如何进行范围检查(检查前几秒的访问次数)

思路是,把特定时间间隔之前的member都删掉,留下的member就是时间间隔之内的总访问次数。然后统计当前key中的member有多少个即可。

① 把特定时间间隔之前的member都删掉。

zSet有如下命令,用于删除score范围在[min~max]之间的member:

Zremrangebyscore [key] [min] [max]

假设限流时间设置为5秒,当前用户访问接口时,获取当前系统时间戳为currentTimeMill,那么删除的score范围可以设置为:

  1. min = 0
  2. max = currentTimeMill - 5 * 1000

相当于把5秒之前的所有member都删除了,只留下前5秒内的key。

② 统计特定key中已存在的member有多少个。

zSet有如下命令,用于统计某个key的member总数:

 ZCARD [key]

统计的key的member总数,就是当前接口已经访问的次数。如果该数目大于限流次数,则说明当前的访问应被限流。

二、代码实现

主要是使用注解 + AOP的形式实现。

2.1 固定时间段思路

使用了lua脚本。

  • 参考:https://blog.csdn.net/qq_43641418/article/details/127764462

2.1.1 限流注解
  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Target(ElementType.METHOD)
  3. public @interface RateLimiter {
  4.     /**
  5.      * 限流时间,单位秒
  6.      */
  7.     int time() default 5;
  8.     /**
  9.      * 限流次数
  10.      */
  11.     int count() default 10;
  12. }
2.1.2 定义lua脚本

resources/lua下新建limit.lua

  1. -- 获取redis键
  2. local key = KEYS[1]
  3. -- 获取第一个参数(次数)
  4. local count = tonumber(ARGV[1])
  5. -- 获取第二个参数(时间)
  6. local time = tonumber(ARGV[2])
  7. -- 获取当前流量
  8. local current = redis.call('get'key);
  9. -- 如果current值存在,且值大于规定的次数,则拒绝放行(直接返回当前流量)
  10. if current and tonumber(current) > count then
  11.     return tonumber(current)
  12. end
  13. -- 如果值小于规定次数,或值不存在,则允许放行,当前流量数+1  (值不存在情况下,可以自增变为1)
  14. current = redis.call('incr'key);
  15. -- 如果是第一次进来,那么开始设置键的过期时间。
  16. if tonumber(current) == 1 then 
  17.     redis.call('expire'keytime);
  18. end
  19. -- 返回当前流量
  20. return tonumber(current)
2.1.3 注入Lua执行脚本

关键代码是limitScript()方法

  1. @Configuration
  2. public class RedisConfig {
  3.     @Bean
  4.     public RedisTemplate<ObjectObject> redisTemplate(RedisConnectionFactory connectionFactory) {
  5.         RedisTemplate<ObjectObject> redisTemplate = new RedisTemplate<>();
  6.         redisTemplate.setConnectionFactory(connectionFactory);
  7.         // 使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化)
  8.         Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
  9.         ObjectMapper om = new ObjectMapper();
  10.         om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
  11.         om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
  12.         jackson2JsonRedisSerializer.setObjectMapper(om);
  13.         redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);
  14.         redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
  15.         redisTemplate.setHashKeySerializer(jackson2JsonRedisSerializer);
  16.         redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
  17.         return redisTemplate;
  18.     }
  19.     /**
  20.      * 解析lua脚本的bean
  21.      */
  22.     @Bean("limitScript")
  23.     public DefaultRedisScript<Long> limitScript() {
  24.         DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
  25.         redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/limit.lua")));
  26.         redisScript.setResultType(Long.class);
  27.         return redisScript;
  28.     }
  29. }
2.1.3 定义Aop切面类
  1. @Slf4j
  2. @Aspect
  3. @Component
  4. public class RateLimiterAspect {
  5.  @Autowired
  6.     private RedisTemplate redisTemplate;
  7.     @Autowired
  8.     private RedisScript<Long> limitScript;
  9.  @Before("@annotation(rateLimiter)")
  10.     public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
  11.         int time = rateLimiter.time();
  12.         int count = rateLimiter.count();
  13.         String combineKey = getCombineKey(rateLimiter.type(), point);
  14.         List<String> keys = Collections.singletonList(combineKey);
  15.         try {
  16.             Long number = (Long) redisTemplate.execute(limitScript, keys, counttime);
  17.             // 当前流量number已超过限制,则抛出异常
  18.             if (number == null || number.intValue() > count) {
  19.              throw new RuntimeException("访问过于频繁,请稍后再试");
  20.             }
  21.             log.info("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'"countnumber.intValue(), combineKey);
  22.         } catch (Exception ex) {
  23.             ex.printStackTrace();
  24.             throw new RuntimeException("服务器限流异常,请稍候再试");
  25.         }
  26.     }
  27.     
  28.     /**
  29.      * 把用户IP和接口方法名拼接成 redis 的 key
  30.      * @param point 切入点
  31.      * @return 组合key
  32.      */
  33.     private String getCombineKey(JoinPoint point) {
  34.         StringBuilder sb = new StringBuilder("rate_limit:");
  35.         ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  36.         HttpServletRequest request = attributes.getRequest();
  37.         sb.append( Utils.getIpAddress(request) );
  38.         
  39.         MethodSignature signature = (MethodSignature) point.getSignature();
  40.         Method method = signature.getMethod();
  41.         Class<?> targetClass = method.getDeclaringClass();
  42.         // keyPrefix + "-" + class + "-" + method
  43.         return sb.append("-").append( targetClass.getName() )
  44.                 .append("-").append(method.getName()).toString();
  45.     }
  46. }

2.2 滑动窗口思路

2.2.1 限流注解
  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Target(ElementType.METHOD)
  3. public @interface RateLimiter {
  4.     /**
  5.      * 限流时间,单位秒
  6.      */
  7.     int time() default 5;
  8.     /**
  9.      * 限流次数
  10.      */
  11.     int count() default 10;
  12. }
2.2.2 定义Aop切面类
  1. @Slf4j
  2. @Aspect
  3. @Component
  4. public class RateLimiterAspect {
  5.     @Autowired
  6.     private RedisTemplate redisTemplate;
  7.     /**
  8.      * 实现限流(新思路)
  9.      * @param point
  10.      * @param rateLimiter
  11.      * @throws Throwable
  12.      */
  13.     @SuppressWarnings("unchecked")
  14.     @Before("@annotation(rateLimiter)")
  15.     public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
  16.         // 在 {time} 秒内仅允许访问 {count} 次。
  17.         int time = rateLimiter.time();
  18.         int count = rateLimiter.count();
  19.         // 根据用户IP(可选)和接口方法,构造key
  20.         String combineKey = getCombineKey(rateLimiter.type(), point);
  21.         
  22.         // 限流逻辑实现
  23.         ZSetOperations zSetOperations = redisTemplate.opsForZSet();
  24.         // 记录本次访问的时间结点
  25.         long currentMs = System.currentTimeMillis();
  26.         zSetOperations.add(combineKey, currentMs, currentMs);
  27.         // 这一步是为了防止member一直存在于内存中
  28.         redisTemplate.expire(combineKey, time, TimeUnit.SECONDS);
  29.         // 移除{time}秒之前的访问记录(滑动窗口思想)
  30.         zSetOperations.removeRangeByScore(combineKey, 0, currentMs - time * 1000);
  31.         
  32.         // 获得当前窗口内的访问记录数
  33.         Long currCount = zSetOperations.zCard(combineKey);
  34.         // 限流判断
  35.         if (currCount > count) {
  36.             log.error("[limit] 限制请求数'{}',当前请求数'{}',缓存key'{}'"count, currCount, combineKey);
  37.             throw new RuntimeException("访问过于频繁,请稍后再试!");
  38.         }
  39.     }
  40.     /**
  41.      * 把用户IP和接口方法名拼接成 redis 的 key
  42.      * @param point 切入点
  43.      * @return 组合key
  44.      */
  45.     private String getCombineKey(JoinPoint point) {
  46.         StringBuilder sb = new StringBuilder("rate_limit:");
  47.         ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  48.         HttpServletRequest request = attributes.getRequest();
  49.         sb.append( Utils.getIpAddress(request) );
  50.         
  51.         MethodSignature signature = (MethodSignature) point.getSignature();
  52.         Method method = signature.getMethod();
  53.         Class<?> targetClass = method.getDeclaringClass();
  54.         // keyPrefix + "-" + class + "-" + method
  55.         return sb.append("-").append( targetClass.getName() )
  56.                 .append("-").append(method.getName()).toString();
  57.     }
  58. }
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/weixin_40725706/article/detail/454975
推荐阅读
相关标签
  

闽ICP备14008679号