【高并发】亿级流量场景下如何实现分布式限流?自定义分布式限流注解

522 阅读5分钟

分布式限流的关键就是需要将限流服务做成全局的,统一的。可以采用Redis+Lua技术实现,通过这种技术可以实现高并发和高性能的限流。

Lua是一种轻量小巧的脚本编程语言,用标准的C语言编写的开源脚本,其设计的目的是为了嵌入到应用程序中,为应用程序提供灵活的扩展和定制功能。

Redis+Lua脚本实现分布式限流思路

我们可以使用Redia+Lua脚本的方式来对我们的分布式系统进行统一的全局限流,Redis+Lua实现的Lua脚本:

-- 下标从 1 开始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大访问量
local max = tonumber(ARGV[4])

-- 清除过期的数据
-- 移除指定分数区间内的所有元素,expired 即已经过期的 score
-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired
redis.call('zremrangebyscore', key, 0, expired)

-- 获取 zset 中的当前元素个数
local current = tonumber(redis.call('zcard', key))
local next = current + 1

if next > max then
  -- 达到限流大小 返回 0
  return 0;
else
  -- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score]
  redis.call("zadd", key, now, now)
  -- 每次访问均重新设置 zset 的过期时间,单位毫秒
  redis.call("pexpire", key, ttl)
  return next
end

(1)在Lua脚本中,有两个全局变量,用来接收Redis应用端传递的键和其他参数,分别为:KEYS、ARGV;

(2)在应用端传递KEYS时是一个数组列表,在Lua脚本中通过索引下标方式获取数组内的值。

(3)在应用端传递ARGV时参数比较灵活,可以是一个或多个独立的参数,但对应到Lua脚本中统一用ARGV这个数组接收,获取方式也是通过数组下标获取。

(4)以上操作是在一个Lua脚本中,又因为我当前使用的是Redis 5.0版本(Redis 6.0支持多线程),执行的请求是单线程的,因此,Redis+Lua的处理方式是线程安全的,并且具有原子性。

这里,需要注意一个知识点,那就是原子性操作:如果一个操作时不可分割的,是多线程安全的,我们就称为原子性操作。

接下来,我们可以使用如下Java代码来判断是否需要限流。

Redis+Lua脚本实现分布式限流案例

创建注解

// FileName: RateLimiter.java
package com.xkcoding.ratelimit.redis.annotation;
import org.springframework.core.annotation.AliasFor;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
    long DEFAULT_REQUEST = 10;
    /**
     * max 最大请求数
     */
    @AliasFor("max") long value() default DEFAULT_REQUEST;

    /**
     * max 最大请求数
     */
    @AliasFor("value") long max() default DEFAULT_REQUEST;

    /**
     * 限流key
     */
    String key() default "";

    /**
     * 超时时长,默认1分钟
     */
    long timeout() default 1;

    /**
     * 超时时间单位,默认 分钟
     */
    TimeUnit timeUnit() default TimeUnit.MINUTES;

}

在RateLimiter注解内部,我们为value属性添加了别名limit,在我们真正使用@RateLimiter注解时,即可以使用@RateLimiter(10),也可以使用@RateLimiter(value=10),还可以使用

创建切面类 创建注解后,我们就来创建一个切面类RateLimiterAspect,RateLimiterAspect类的作用主要是解析@RateLimiter注解,并且执行限流的规则。这样,就不需要我们在每个需要限流的方法中执行具体的限流逻辑了,只需要我们在需要限流的方法上添加@RateLimiter注解即可,具体代码如下所示。

package com.xkcoding.ratelimit.redis.aspect;
import cn.hutool.core.util.StrUtil;
import com.xkcoding.ratelimit.redis.annotation.RateLimiter;
import com.xkcoding.ratelimit.redis.util.IpUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class RateLimiterAspect {
    private final static String SEPARATOR = ":";
    private final static String REDIS_LIMIT_KEY_PREFIX = "limit:";
    private final StringRedisTemplate stringRedisTemplate;
    private final RedisScript<Long> limitRedisScript;
     @Pointcut("@annotation(com.xkcoding.ratelimit.redis.annotation.RateLimiter)")
    public  void  rateLimit(){

     }
     @Around("rateLimit()")
    public  Object pointCut(ProceedingJoinPoint point) throws Throwable {
         MethodSignature methodSignature= (MethodSignature) point.getSignature();
         Method method=methodSignature.getMethod();
         RateLimiter rateLimiter= AnnotationUtils.findAnnotation(method,RateLimiter.class);
         if (rateLimiter!=null){
             String key=rateLimiter.key();
             if (StringUtils.isEmpty(key)){
                 key = method.getDeclaringClass().getName()+ StrUtil.DOT+method.getName();
             }
               key=key+SEPARATOR+ IpUtil.getIpAddr();
             long max = rateLimiter.max();
             long timeout = rateLimiter.timeout();
             TimeUnit timeUnit = rateLimiter.timeUnit();

             boolean limited=shouldLimited(key,timeout,timeUnit,max);
             if (limited){
                 throw  new RuntimeException("手速太快了,慢点儿吧~");
             }

         }


      return  point.proceed();

     }

     private  boolean shouldLimited(String key,Long timeout,TimeUnit timeUnit,Long max){
         // 最终的 key 格式为:
         // limit:自定义key:IP
         // limit:类名.方法名:IP
         key = REDIS_LIMIT_KEY_PREFIX + key;
         // 统一使用单位毫秒
         long ttl = timeUnit.toMillis(timeout);
         // 当前时间毫秒数
         long now = Instant.now().toEpochMilli();
         long expired = now - ttl;
         Long executeTimes=stringRedisTemplate.execute(limitRedisScript, Collections.singletonList(key),ttl+"",now+"",expired+"",max+"");
         if(executeTimes!=null){
             if(executeTimes==0){
                 log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max);
                 return  true;
             }else {
                 log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max);
                 return  false;
             }
         }
         return  false;
     }

}

上述代码会读取项目classpath目录下的limit.lua脚本文件来确定是否执行限流的操作,调用limit.lua文件执行的结果返回0则表示执行限流逻辑,否则不执行限流逻辑。既然,项目中需要使用Lua脚本,那么,接下来,我们就需要在项目中创建Lua脚本。

创建limit.lua脚本文件

在项目的classpath目录下创建limit.lua脚本文件,文件的内容如下所示。

-- 下标从 1 开始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大访问量
local max = tonumber(ARGV[4])

-- 清除过期的数据
-- 移除指定分数区间内的所有元素,expired 即已经过期的 score
-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired
redis.call('zremrangebyscore', key, 0, expired)

-- 获取 zset 中的当前元素个数
local current = tonumber(redis.call('zcard', key))
local next = current + 1

if next > max then
  -- 达到限流大小 返回 0
  return 0;
else
  -- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score]
  redis.call("zadd", key, now, now)
  -- 每次访问均重新设置 zset 的过期时间,单位毫秒
  redis.call("pexpire", key, ttl)
  return next
end

接口添加注解

注解类、解析注解的切面类、Lua脚本文件都已经准备好。那么,接下来,我们在TestController类中在sendMessage2()方法上添加@RateLimiter注解,并且将limit属性设置为10,如下所示。

@RateLimiter(limit = 10)
@RequestMapping("/boot/send/message2")
public String sendMessage2(){
    //记录返回接口
    String result = "";
    boolean flag = messageService.sendMessage("恭喜您成长值+1");
    if (flag){
        result = "短信发送成功!";
        return result;
    }
    result = "哎呀,服务器开小差了,请再试一下吧";
    return result;
}

好了,今天就聊到这儿吧!别忘了点个赞,给个在看和转发,让更多的人看到,一起学习,一起进步!!