接口限流技术调研

1. 限流算法

工作中对外提供的API 接口设计很多时候要考虑限流,如果不考虑,可能会造成系统的连锁反应,轻者响应缓慢,重者系统宕机。而比较成熟的限流算法有令牌桶算法,本篇介绍令牌桶算法

原理如上图,系统以恒定速率不断产生令牌,令牌桶有最大容量,超过最大容量则丢弃,同时用户请求接口,如果此时令牌桶中有令牌则能访问获取数据,否则直接拒绝用户请求

2. 单机解决方案

Guava rateLimiter实现

//单机全局限流器,QPS为1

private static final RateLimiter RATE_LIMITER = RateLimiter.create(1);

@ApiOperation("单机限流")

@GetMapping("/test1")

public ResultDTO test1() {

    if(!RATE_LIMITER.tryAcquire()){

        log.info("限流了..");

        return ResultDTO.error("限流了..");

    }

    log.info("请求成功");

    return ResultDTO.ok("请求成功");

}

执行结果:

2021-06-28 14:01:22.803  INFO 20212 --- [nio-8080-exec-1] c.r.d.controller.RedisLimitController    : 请求成功

2021-06-28 14:01:22.987  INFO 20212 --- [nio-8080-exec-2] c.r.d.controller.RedisLimitController    : 请求成功

2021-06-28 14:01:23.159  INFO 20212 --- [nio-8080-exec-3] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:23.347  INFO 20212 --- [nio-8080-exec-4] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:23.520  INFO 20212 --- [nio-8080-exec-5] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:23.695  INFO 20212 --- [nio-8080-exec-6] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:23.876  INFO 20212 --- [nio-8080-exec-7] c.r.d.controller.RedisLimitController    : 请求成功

2021-06-28 14:01:24.045  INFO 20212 --- [nio-8080-exec-8] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:24.234  INFO 20212 --- [nio-8080-exec-9] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:24.406  INFO 20212 --- [io-8080-exec-10] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:24.594  INFO 20212 --- [nio-8080-exec-1] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:24.766  INFO 20212 --- [nio-8080-exec-2] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:24.948  INFO 20212 --- [nio-8080-exec-3] c.r.d.controller.RedisLimitController    : 请求成功

2021-06-28 14:01:25.128  INFO 20212 --- [nio-8080-exec-4] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:25.308  INFO 20212 --- [nio-8080-exec-5] c.r.d.controller.RedisLimitController    : 限流了..

2021-06-28 14:01:25.492  INFO 20212 --- [nio-8080-exec-6] c.r.d.controller.RedisLimitController    : 限流了..

3. 分布式环境下解决方案

Redis rateLimiter


需要限流的接口使用该注解

/**

 * 1(时间)分钟(单位)允许某个ip请求的最大次数(max)

 *

 * 如每隔2分钟,单IP限定访问次数不能超过10次

 */

@Target(ElementType.METHOD)

@Retention(RetentionPolicy.RUNTIME)

public @interface RedisRateLimiter {

    /**

     * 默认根据IP拦截

     */

    LimitType limitType() default LimitType.GENERAL;

    enum LimitType{

        GENERAL,IP,USERID;

    }

    /**

     * 限制时间长度

     */

    long timeLimitLength() default 1;

    /**

     * 限制时间长度的单位

     */

    TimeUnit timeLimitLengthUnit() default TimeUnit.SECONDS;

    /**

     * 允许时间内最大访问数

     */

    long max() default 1;

}

/**

 * 自定义注解:标记限流key

 * @author dengyingxiang

 */

@Retention(value = RetentionPolicy.RUNTIME)

@Target(value = {ElementType.PARAMETER,ElementType.FIELD})

@Documented

public @interface LimitKey {

}

切面

@Slf4j

@Component

@Aspect

@RequiredArgsConstructor(onConstructor_ = @Autowired)

public class RedisRateLimitAspect {

    private final static String REDIS_RATE_LIMIT_KEY_PREFIX = "limit:";

    private final StringRedisTemplate stringRedisTemplate;

    private final RedisScript<Long> limitRedisScript;

    @Pointcut("@annotation(com.rock.demo.annotation.RedisRateLimiter)")

    public void rateLimit() {

    }

    @Before("rateLimit()")

    public void pointCut(JoinPoint joinPoint) throws IllegalAccessException {

        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        Method method = signature.getMethod();

        // 通过 AnnotationUtils.findAnnotation 获取 RateLimiter 注解

        RedisRateLimiter redisRateLimit = AnnotationUtils.findAnnotation(method, RedisRateLimiter.class);

        RedisRateLimiter.LimitType limitType = redisRateLimit.limitType();

        if (redisRateLimit != null) {

            //获取时间限制

            long timeLimitLength = redisRateLimit.timeLimitLength();

            //获取时间限制单位

            TimeUnit timeLimitLengthUnit = redisRateLimit.timeLimitLengthUnit();

            //时间单位最大访问数目

            long max = redisRateLimit.max();

            String limitKey = "";

            //ip限流则用ip做limitKeyValue,其他的从参数中获取

            if (RedisRateLimiter.LimitType.IP.name().equals(limitType.name())) {

                HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

                limitKey = IpUtil.getIpAddr(request);

            else {

                Object[] args = joinPoint.getArgs();

                // 1.入参是(String id)

                limitKey = getIdForSingle(joinPoint, args);

                if (StringUtils.isEmpty(limitKey)) {

                    // 2.入参是DTO,LimitKey注解放到DTO.id上。

                    limitKey = getIdForParamsDTO(args);

                }

            }

            //redis存储的key;"limit:"${className}"."${methodName}:${limitKey}

            String storeKey = REDIS_RATE_LIMIT_KEY_PREFIX + method.getDeclaringClass().getSimpleName() + "." + method.getName() + ":" + limitKey;

            long now = System.currentTimeMillis();

            //将2分钟转化为毫秒时间戳,以获得2分钟前时间

            long limitTimeLengthMills = timeLimitLengthUnit.toMillis(timeLimitLength);

            //应该移除的分值区间

            long removeScore = now - limitTimeLengthMills;

            Long r = stringRedisTemplate.execute(

                    limitRedisScript,

                    Lists.newArrayList(storeKey),

                    "" + now,

                    "" + limitTimeLengthMills,   //设置key的保存时间,该key在2分钟的允许时间内做zadd操作

                    "" + removeScore,     //移除当前时间2分钟前过期的score

                    "" + max);//当前接口访问上线

            if (r != null) {

                if (r == 0) {

                    log.error("【{}】在 " + timeLimitLength + formatTimeUnit(timeLimitLengthUnit) + " 内已达到访问上限,当前接口上限 {}", storeKey, max);

                    throw new RuntimeException("手速太快了,慢点儿吧~");

                else {

                    log.info("【{}】在 " + timeLimitLength + formatTimeUnit(timeLimitLengthUnit) + " 内访问 {} 次", storeKey, r);

                }

            }

        }

    }

    private String formatTimeUnit(TimeUnit timeUnit) {

        if (timeUnit == TimeUnit.MINUTES) {

            return "分钟";

        else if (timeUnit == TimeUnit.SECONDS) {

            return "秒";

        else if (timeUnit == TimeUnit.HOURS) {

            return "小时";

        }

        return "illegal timeUnit args";

    }

    private String getIdForSingle(JoinPoint joinPoint, Object[] args) {

        if (Objects.nonNull(args) && args.length > 0) {

            MethodSignature signature = (MethodSignature) joinPoint.getSignature();

            Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations();

            if (Objects.isNull(parameterAnnotations)) {

                return null;

            }

            // 循环判断是否有limitkey

            for (int i = 0; i < parameterAnnotations.length; i++) {

                for (Annotation annotation : parameterAnnotations[i]) {

                    if (annotation instanceof LimitKey) {

                        return (String)args[i];

                    }

                }

            }

        }

        return null;

    }

    private String getIdForParamsDTO( Object[] args) throws IllegalAccessException {

        for (Object arg : args) {

            String id = getIdForDto(arg);

            if(StringUtils.isNotEmpty(id)){

                return id;

            }

        }

        return null;

    }

    private String getIdForDto(Object arg) throws IllegalAccessException {

        Field[] fields = arg.getClass().getDeclaredFields();

        if(Objects.nonNull(fields) && fields.length > 0){

            for (Field field : fields) {

                field.setAccessible(true);

                if (field.getType().equals(String.class) && field.isAnnotationPresent(LimitKey.class)) {

                    return (String)field.get(arg);

                }

            }

        }

        return null;

    }

}

redis配置文件

@Configuration

public class RedisConfig {

    @Bean

    @SuppressWarnings("unchecked")

    public RedisScript<Long> limitRedisScript() {

        DefaultRedisScript redisScript = new DefaultRedisScript<>();

        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/redis/limit.lua")));

        redisScript.setResultType(Long.class);

        return redisScript;

    }

}

LUA脚本

local key = KEYS[1]

local now = tonumber(ARGV[1])

local limitTimeLengthMills = tonumber(ARGV[2])

local removeScore = tonumber(ARGV[3])

local max = tonumber(ARGV[4])

redis.call('ZREMRANGEBYSCORE',key,0,removeScore)

local current = tonumber(redis.call('ZCARD',key))

local next = current+1

if next>max then

    return 0

else

    redis.call('ZADD',key,now,now)

    redis.call('PEXPIRE',key,limitTimeLengthMills)

    return next

end

脚本说明:

1.先删除已过期时间端的数据;

     ZREMRANGEBYSCORE key min max

     移除有序集 key 中,所有 score 值介于 min 和 max 之间(包括等于 min 或 max )的成员。

2.统计sortedset里面的数据量;

     ZCARD key

     返回有序集 key 的基数。

3.判断不通过返回0;

4.判断通过则,添加一条记录,并返回1

控制层

@ApiOperation("分布式ip限流")

@RedisRateLimiter(max = 1,limitType = RedisRateLimiter.LimitType.IP,timeLimitLength = 1,timeLimitLengthUnit = TimeUnit.SECONDS)

@GetMapping("/test2")

public ResultDTO test2() {

    log.info("【test2】被执行了。。。。。");

    return ResultDTO.ok("成功访问到api [2]~");

}

@ApiOperation("分布式关键字限流")

@RedisRateLimiter(max = 1,limitType = RedisRateLimiter.LimitType.GENERAL,timeLimitLength = 1,timeLimitLengthUnit = TimeUnit.SECONDS)

@GetMapping("/test3/{limitKey}")

public ResultDTO test3(@LimitKey @ApiParam(value = "限流的key", required = true@PathVariable("limitKey") String limitKey) {

    log.info("【test3】被执行了。。。。。");

    return ResultDTO.ok("成功访问到api [3]~");

}

执行结果:

2021-06-28 14:22:58.324  INFO 20212 --- [io-8080-exec-10] c.rock.demo.aspect.RedisRateLimitAspect  : 【limit:RedisLimitController.test3:111】在 1秒 内访问 1 

2021-06-28 14:22:58.325  INFO 20212 --- [io-8080-exec-10] c.r.d.controller.RedisLimitController    : 【test3】被执行了。。。。。

2021-06-28 14:22:58.892 ERROR 20212 --- [nio-8080-exec-1] c.rock.demo.aspect.RedisRateLimitAspect  : 【limit:RedisLimitController.test3:111】在 1秒 内已达到访问上限,当前接口上限 1

2021-06-28 14:22:58.894 ERROR 20212 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.RuntimeException: 手速太快了,慢点儿吧~] with root cause

java.lang.RuntimeException: 手速太快了,慢点儿吧~

    at com.rock.demo.aspect.RedisRateLimitAspect.pointCut(RedisRateLimitAspect.java:98) ~[classes/:na]

    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_271]

    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_271]

    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_271]

    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_271]

    at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:644) ~[spring-aop-5.2.14.RELEASE.jar:5.2.14.RELEASE]

    at org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:626) ~[spring-aop-5.2.14.RELEASE.jar:5.2.14.RELEASE]

    at org.springframework.aop.aspectj.AspectJMethodBeforeAdvice.before(AspectJMethodBeforeAdvice.java:44) ~[spring-aop-5.2.14.RELEASE.jar:5.2.14.RELEASE]

    at org.springframework.aop.framework.adapter.MethodBeforeAdviceInterceptor.invoke(MethodBeforeAdviceInterceptor.java:55) ~[spring-aop-5.2.14.RELEASE.jar:5.2.14.RELEASE]

    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:175) ~[sp

参考文档:

接口限流&令牌桶算法&Redis分布式限流 - 简书

Redis 命令参考 — Redis 命令参考

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值