创作不易,如果觉得这篇文章对你有帮助,欢迎各位老铁点个赞支持下呗,您的支持是我创作的最大动力!

文章目录

  • 1 前言
  • 2 为什么要对Api接口限流
  • 3 限流方案的选择
  • 4 基于Redis实现限流
  • 4.1 实现的思路
  • 4.2 实现限流
  • 4.2.1 定义限流注解
  • 4.2.2 定义切面类,拦截需要限流的方法
  • 4.2.3 业务方法添加限流注解
  • 4.3 对以上示例简要分析


1 前言

随着时代的发展,互联网也发生了巨大的变化。其中重要的一个变化时,为了应对高流量,服务的架构从集中式架构,演变成了分布式架构

什么是分布式架构?
简单来说,就是之前的一个单体应用(后台管理系统),通过拆分,拆分成用户中心、产品中心、客户中心等多个小应用服务,这种把一个大的单体应用项目,拆分成多个小应用项目的方式,就是分布式系统应用架构

本文将以一种最高效的方式,实现分布式环境下,接口方法的限流。

2 为什么要对Api接口限流

为了满足各种应用场景,有时候不得不对接口Api进行限流。比如说:短信服务,供应商可能会要求每秒访问不超过400条,如果超过了这个访问量,请求就会被供应商拒绝,从而导致漏发短信。

还有的接口,第三方Api会做限制,他们为了限制访问,设定一分钟只能请求接口20次,超过了就会超时或者响应异常。

总而言之,限流,在好多场景用的还是挺多的。

3 限流方案的选择

限流,方案有很多,具体的典型的限流方案介绍,请参考我的另一篇博文:分布式环境下限流方案的思考

最终选择的方案是基于Redis实现的限流,该方案是目前最流行,也是最高效的一种方式。

4 基于Redis实现限流

4.1 实现的思路

思路: 借助于RedisINCR操作来实现Limit限流

  • 将INCR key中储存的数字值增一,如果key不存在,那么key的值会先被初始化为 0 ,然后再执行INCR操作。
    如果值包含错误的类型,或字符串类型的值不能表示为数字,那么返回一个错误,本操作的值限制在 64 位(bit)有符号数字表示之内。
  • 当API被调用时,在调用API前进行INCR key,key可以是ip地址相关,用户相关,业务参数相关,或是全局的一个key。如果返回值为1,则表示刚开始调用,赋予key过期时间,然后判断返回值是否大于设定的Limit限流数量,如果大于抛异常或者阻塞重试。

4.2 实现限流

4.2.1 定义限流注解

代码示例如下:

/**
 * <p>
 * 自定义,限流注解(默认一分钟,限流500次)
 * <p/>
 *
 * @author smilehappiness
 * @Date 2020/7/5 20:05
 */
@Order(Ordered.HIGHEST_PRECEDENCE)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface ApiLimit {

    /**
     * 限制某时间段内可以访问的次数,默认设置500
     */
    int limitCounts() default 500;

    /**
     * @return
     * @Description: 限制访问的某一个时间段,单位为秒,默认值1分钟
     */
    int timeSecond() default 60;
}

4.2.2 定义切面类,拦截需要限流的方法

代码示例如下:

package cn.smilehappiness.aspect;

import cn.smilehappiness.annotation.ApiLimit;
import cn.smilehappiness.model.SmsMessage;
import com.alibaba.fastjson.JSON;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;


/**
 * Api Limit切面类
 *
 * @author smilehappiness
 * @Date 2020/7/5 19:55
 */
@Aspect
@Component
public class ApiLimitAspect {

    private Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * 通过构造注入的方式,注入redisTemplate
     */
    private final RedisTemplate<String, Object> redisTemplate;

    public ApiLimitAspect(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * <p>
     * 定义切面表达式(类的维度拦截,可以拦截更多的方法)
     * <p/>
     *
     * @param
     * @return void
     * @Date 2020/7/5 20:07
     */
    @Pointcut("execution(* cn.smilehappiness..service..*Impl.*(..))")
    private void myPointCut() {
    }

    /**
     * <p>
     * 实现对添加ApiLimit注解的方法进行拦截,Limit处理(方式一:切面类拦截)
     * <p/>
     *
     * @param joinPoint
     * @return void
     * @Date 2020/7/5 20:10
     */
    //@Around("myPointCut()")
    public void requestLimit(ProceedingJoinPoint joinPoint) throws Throwable {
        ApiLimit apiLimit = this.getAnnotation(joinPoint);
        //对业务方法进行全局限流
        if (apiLimit != null) {
            dealLimit(apiLimit, joinPoint, false);
        }
    }

    /**
     * <p>
     * 针对业务方法进行Limit限流处理,如果第一次请求被限制了,等待10秒后重试,如果再次失败,则抛出异常
     * <p/>
     *
     * @param apiLimit
     * @param joinPoint
     * @param flag      判断是否进行过一次重试了,如果重试过一次还是被限制,就抛异常
     * @return void
     * @Date 2020/7/5 20:30
     */
    private void dealLimit(ApiLimit apiLimit, ProceedingJoinPoint joinPoint, Boolean flag) throws Throwable {
        String msgKey = checkParam(apiLimit, joinPoint);
        //业务方法中,参数唯一key
        String cacheKey = "smsService:sendLimit:" + msgKey;

        ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
        long methodCounts = valueOperations.increment(cacheKey, 1);
        // 如果该key不存在,则从0开始计算,并且当count为1的时候,设置过期时间
        if (methodCounts == 1) {
            redisTemplate.expire(cacheKey, apiLimit.timeSecond(), TimeUnit.SECONDS);
        }

        // 如果redis中的count大于限制的次数,则等待10秒重试
        if (methodCounts > apiLimit.limitCounts()) {
            if (!flag) {
                //等待10秒后,第一次重试
                Thread.sleep(10 * 1000);
                logger.warn("等待10秒后,第一次重试...");

                // 递归,再次请求业务方法
                dealLimit(apiLimit, joinPoint, true);
            } else {
                //如果第一次请求被限制了,等待10秒后重试,如果再次失败,则抛出异常,当然,如果不需要重试,直接抛异常或者逻辑处理即可
                throw new RuntimeException("短信发送三方Api接口超限,请30秒后再试!");
            }
        } else {
            //正常执行业务方法
            joinPoint.proceed();
        }

    }

    /**
     * <p>
     * 业务限流方法,业务参数非空检验
     * <p/>
     *
     * @param apiLimit
     * @param joinPoint
     * @return java.lang.String
     * @Date 2020/7/5 20:40
     */
    private String checkParam(ApiLimit apiLimit, ProceedingJoinPoint joinPoint) {
        if (apiLimit == null) {
            throw new RuntimeException("限流方法dealLimit处理异常!");
        }

        //获取方法的参数,通过参数设置缓存的唯一key
        Object[] args = joinPoint.getArgs();
        if (args == null) {
            throw new RuntimeException("业务方法参数不允许为空!");
        }

        SmsMessage smsMessage = null;
        if (args[0] instanceof SmsMessage) {
            smsMessage = (SmsMessage) args[0];
        }
        if (smsMessage == null) {
            throw new RuntimeException("HttpServletRequest请求,参数异常!");
        }

        // 获取HttpRequest
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new RuntimeException("HttpServletRequest请求,attributes参数异常!");
        }

        HttpServletRequest request = attributes.getRequest();
        // 判断request不能为空
        if (request == null) {
            throw new RuntimeException("HttpServletRequest请求,参数异常!");
        }

        String ip = request.getRemoteAddr();
        String uri = request.getRequestURI();
        logger.debug("请求的参数ip:【{}】, uri:【{}】,请求参数:【{}】", ip, uri, JSON.toJSONString(smsMessage));

        return smsMessage.getMsgKey();
    }

    /**
     * <p>
     * 环绕通知,对使用ApiLimit注解的方法进行拦截,限流处理(方式二:直接对注解进行拦截)
     * <p/>
     *
     * @param joinPoint
     * @return void
     * @Date 2020/7/5 21:03
     */
    @Around("@annotation(cn.smilehappiness.annotation.ApiLimit)")
    public void requestLimitByAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        if (method == null) {
            return;
        }

        if (method.isAnnotationPresent(ApiLimit.class)) {
            dealLimit(method.getAnnotation(ApiLimit.class), joinPoint, false);
        }
    }

    /**
     * <p>
     * 获取注解
     * <p/>
     *      
     * @param joinPoint
     * @return cn.smilehappiness.annotation.ApiLimit
     * @Date 2020/7/5 21:45
     */
    private ApiLimit getAnnotation(JoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        if (method == null) {
            return null;
        }

        return method.getAnnotation(ApiLimit.class);
    }
}

4.2.3 业务方法添加限流注解

/**
     * <p>
     * 根据消息模板以及内容,发送短信
     * 默认一分钟,限流500次,可以根据实际情况进行限流
     * <p/>
     *
     * @param smsMessage
     * @return void
     * @Date 2020/7/6 21:35
     */
    @ApiLimit(limitCounts = 10, timeSecond = 120)
    @Override
    public void sendSmsMessage(SmsMessage smsMessage) {
        // 注意:一般三方可能会限制,400/s,即每秒最多发送400条,超过这个限制的短信发送请求会被拒绝,所以需要限流,在高流量下,需要在业务端限制,每秒访问不要超过400次
        // 这里只是模拟这种限流的场景,具体的限流大小,根据实际场景去设置

        // TODO 调用第三方发送短信
        System.out.println("调用三方短信服务,发送短信成功!");
    }

4.3 对以上示例简要分析

主要就就是对需要限流的业务方法,添加了@ApiLimit(limitCounts = 10, timeSecond = 120)注解,表示2分钟只允许Api方法被调用10次,否则就会限流。第一次限流进行重试,如果10秒后还不能调用,则抛出异常,待业务端处理。

以上示例中,使用了两种方式进行拦截,一种是定义切入点的方式,一种是直接拦截使用ApiLimit注解的方法,这两种方式都可以。

完整代码示例,我已分享到GitHub上,需要的童鞋们可以下载:distributed-limit-api

好啦,本篇到这里就介绍完毕了,有问题的可以评论交流哈,希望对老铁们有所帮助!

写博客是为了记住自己容易忘记的东西,另外也是对自己工作的总结,希望尽自己的努力,做到更好,大家一起努力进步!

如果有什么问题,欢迎大家评论,一起探讨,代码如有问题,欢迎各位大神指正!

给自己的梦想添加一双翅膀,让它可以在天空中自由自在的飞翔!