浅析redis lua+aop限流及其可视化qps

148 阅读4分钟

效果展示

写了个图表实时修改限流方案及其展示负载量

image.png

v2-6058ea85a1e8872acb9bc61c127cd66e_720w.png

达到设置的负载上限10之后将会触发控流,直接返回服务器忙。

大体思路:

1.程序启动时扫描所有接口,然后存入数据库,将数据库中的所有接口的控流方案(每秒通过上限)存入redis中

2.使用AOP切入点,切入注解@PostMapping和@GetMapping,在@Before内执行控流逻辑

3.控流逻辑:{

*窗口算法

*令牌桶算法 }

首先应用redis依赖

<!--redis--> 
             <dependency> 
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
             </dependency>

扫描接口,存入每个接口的控流方案存入数据库和redis

SpringBoot提供的一种简单的实现方案就是添加一个model并实现CommandLineRunner接口,实现功能的 代码放在实现的run方法中 也就是项目一启动之后,就立即需要执行的动作


@Slf4j
@Component
public class AddInterFace  implements CommandLineRunner {
    @Resource private WebApplicationContext applicationContext;
    @Resource private RedisTemplate redisTemplate;

    /**
     * 程序启动时执行,扫描所有接口,并插入数据库和redis
     * @param args
     * @throws Exception
     */
    @Override
    public void run(String... args) throws Exception {}
}    


在run方法中执行扫描接口

使用applicationContext.getBeansWithAnnotation(RestController.class)方法获取有@RestController的类对象
        Map<String, Object> controllers = applicationContext.getBeansWithAnnotation(RestController.class);
        其中 key为类名,value为对象

遍历每个controller层,拿到controller层下的url

for (Map.Entry<String, Object> entry : controllers.entrySet()) {
            Object value = entry.getValue();
            System.out.println("拿到controller:"+entry.getKey()+",拿到value:"+value);
            Class<?> aClass = AopUtils.getTargetClass(value);
//            System.out.println("拿到Class:"+aClass);

            String classAnontationName="";
            RequestMapping annotation = aClass.getAnnotation(RequestMapping.class);
            if (annotation!=null){
                classAnontationName=String.join(",",annotation.value());
            }
}

annotaion.png 得到@RequestMapping("/userInfo")中的参数:“/userInfo”

获取@PostMapping和@GetMapping中的内容,并将数据库中不存在的接口生成默认限流方案存入数据库

            List<Method> declaredMethods = Arrays.asList(aClass.getDeclaredMethods());
            for (int i = 0; i < declaredMethods.size() ; i++) {
                // 下面开始根据注解类型进行输出统计
                GetMapping getMapping = declaredMethods.get(i).getAnnotation(GetMapping.class);
                PostMapping postMapping = declaredMethods.get(i).getDeclaredAnnotation(PostMapping.class);
                if (getMapping!=null){
                    String join = String.join(",", getMapping.value());
//                    System.out.println("Get相关的:"+classAnontationName+ join);
                    //获取到接口,插入数据库
                    String name = classAnontationName + join;
                    FlowCtrl flowCtrl = new FlowCtrl();
                    flowCtrl.setInterfaceName(name);
                    flowCtrl.setRequestMethod("GET");
                    flowCtrl.setTime(0);
                    flowCtrl.setFlowNum(0);
                    if (allInterFace.contains(name)) {
                        //当前接口已在数据库中存在
                        continue;
                    }
                    addList.add(name);
                    flowCtrlMapper.insert(flowCtrl);
                }
                if (postMapping!=null){
                    String join = String.join(",", postMapping.value());
//                    System.out.println("Post相关的:"+classAnontationName+join);
                    String name = classAnontationName + join;
//                    postInterFaceName.add(classAnontationName+join);
                    FlowCtrl flowCtrl = new FlowCtrl();
                    flowCtrl.setInterfaceName(name);
                    flowCtrl.setRequestMethod("POST");
                    flowCtrl.setTime(0);
                    flowCtrl.setFlowNum(0);
                    if (allInterFace.contains(name)) {
                        //当前接口已在数据库中存在
                        continue;
                    }
                    addList.add(name);
                    flowCtrlMapper.insert(flowCtrl);
                }
            }

将数据库中的所有控流方案存入redis

  List<FlowCtrl> interFaceListInfo = flowCtrlMapper.selectList(null);
        redisTemplate.opsForValue().set("flowCtrls",JSONObject.toJSONString(interFaceListInfo).toString());

这样我们就把所有的接口的控流方案存入数据库和redis中了

dataBase.png

redis.png

使用切面,在接口运行前判断限流

使用aop,切入点为postmapping注解和getmapping注解

@Aspect
@Order(5)
@Slf4j
@Component
public class AspectTest {
    @Resource
    private RedisTemplate redisTemplate;


    @Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)|| @annotation(org.springframework.web.bind.annotation.GetMapping)")
    public void requestTest() {}
}

在@Before中执行限流操作 用于指定。@Before:【advice】的类型,表示该【advice】在切点方法之前执行

拿到当前请求的url

    @Before("requestTest()")
    public void before(JoinPoint joinPoint){
        //获取请求url
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        System.out.println(request.getRequestURI());
        String interFaceName =request.getRequestURI();
        }

从redis中获取当前接口控流方案

        log.info(interFaceName);
        Object ctrls = redisTemplate.opsForValue().get("flowCtrls");
        List<FlowCtrl> flowCtrls = JSONObject.parseArray(ctrls.toString(), FlowCtrl.class);
        if (flowCtrls.isEmpty()){
            return;
        }
        Optional<FlowCtrl> first = flowCtrls.stream().filter(flowCtrl -> flowCtrl.getInterfaceName().equals(interFaceName)).findFirst();
        if (first==null){
            return;
        }
        //当前控流方案
        FlowCtrl ctrl = first.get();

窗口限流

简而言之就是对单位时间内每次请求做记数,记录的请求数大于控流阈值时就执行控流

比如我们需要在5秒内限定20个请求,那么我们在setnx的时候可以设置过期时间5,当请求的setnx数量达到20时候即达到了限流效果。

代码实现:

        Object value = redisTemplate.opsForValue().get(interFaceName);//从redis中获取当前是否有限流存在        

        Integer flowNum=ctrl.getFlowNum();//限流量
        
        Integer time = ctrl.getTime();//限流时间
        String nowTime = DateTimeHelper.formatDateTimetoString(new Date(), DateTimeHelper.FMT_yyyyMMddHHmmss);
        log.info("限流:"+time+"分钟 "+flowNum+"次请求");
        if (value!=null) {
            Integer onlineNum = Integer.valueOf(value.toString());
            log.info("有key:"+interFaceName+" value"+onlineNum );
            if (onlineNum>=flowNum){
                System.out.println("限流");
                log.info(nowTime);
                throw new IllegalMonitorStateException();//限流,抛出错误
            }else {
                Long expire = redisTemplate.getExpire(interFaceName, TimeUnit.MILLISECONDS);
               

                redisTemplate.opsForValue().set(interFaceName,onlineNum+1,expire, TimeUnit.MILLISECONDS);
                Long thenExpire = redisTemplate.getExpire(interFaceName, TimeUnit.MILLISECONDS);
                log.info(nowTime);
                log.info("key:"+(onlineNum+1));
            }

        }else {
            log.info("无key");
            redisTemplate.opsForValue().set(interFaceName,1,time, TimeUnit.SECONDS);
            log.info(nowTime);

        }

使用lua脚本

/**
 * 使用lua脚本
 * flowNum 限流量
 * time 限流时限
 */

String luaScript = buildLuaScript();
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
ImmutableList<String> keys = ImmutableList.of(StringUtils.join(interFaceName+":lua"));
Number count = redisTemplate.execute(redisScript, keys,flowNum, time);
log.info(count+":lua脚本");

if (count!=null&&count.intValue()<=flowNum){
    log.info("通过");

}else {
    log.info("限流");
    throw new IllegalMonitorStateException();
}


/**
 * 
 * @description 编写 redis Lua 限流脚本
 * @date 2022/12/1 17:48 马上下班了,lua脚本下次在更新吧T_T
 */
public String buildLuaScript() {
    StringBuilder lua = new StringBuilder();
    lua.append("local c");
    lua.append("\nc = redis.call('get',KEYS[1])");
    // 调用不超过最大值,则直接返回
    lua.append("\nif c and tonumber(c) > tonumber(ARGV[1]) then");
    lua.append("\nreturn c;");
    lua.append("\nend");
    // 执行计算器自加
    lua.append("\nc = redis.call('incr',KEYS[1])");
    lua.append("\nif tonumber(c) == 1 then");
    // 从第一次调用开始限流,设置对应键值的过期
    lua.append("\nredis.call('expire',KEYS[1],ARGV[2])");
    lua.append("\nend");
    lua.append("\nreturn c;");
    return lua.toString();
}


令牌桶限流

每隔一段时间生成一定数量的令牌,请求拿到令牌后才能通过,没能拿到令牌的请求过滤掉

  • 使用定时任务添加令牌

下面的代码为每十秒运行一次,每次运行是检查令牌是否到上限2,没到上限则添加到上限,到上限则不再添加

@Component
@Slf4j
@EnableScheduling
public class SchduleTast implements SchedulingConfigurer {
    @Resource private RedisTemplate redisTemplate;
    private String cron="*/10 * * * * ?";
    private Integer flowContrlNum=2;
    public String getCron() {
        return cron;
    }


    public void setFlowContrlNum(Integer Num){this.flowContrlNum=Num;}
    public void setCron(String cron) {
        this.cron = cron;
    }
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {

        taskRegistrar.addTriggerTask(new Runnable() {
            @Override
            public void run() {
                try {

                    log.info(DateTimeHelper.formatDateTimetoString(new Date(),DateTimeHelper.FMT_yyyyMMddHHmmss));

                    log.info("添加令牌");
                    String key="limit_Test";
            //         1S的速率往令牌桶中添加UUID,只为保证唯一性
            //        redisTemplate.opsForList().rightPush("limit_Test", UUID.randomUUID().toString());
                    Long tokenSize = redisTemplate.opsForList().size(key);
                    int size = tokenSize == null ? 0 : tokenSize.intValue();
                    if (size>=flowContrlNum){
                        log.info("【{}】令牌数量已达最大值【{}】,丢弃新生成令牌", key, size);
                        return;
                    }
                    // 判断添加令牌数量
                    int addSize =flowContrlNum-size;

                    List<String> addList = new ArrayList<>(addSize);
                    for (int index = 0; index < addSize; index++) {
                        addList.add(UUID.randomUUID().toString());
                    }
                    redisTemplate.opsForList().leftPushAll(key, addList);
                    log.info("【{}】生成令牌丢入令牌桶,当前令牌数:{},令牌桶容量:{}", key, size + addSize, flowContrlNum);



                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, new Trigger() {
            @Override
            public Date nextExecutionTime(TriggerContext triggerContext) {
                System.out.println("-----------"+cron);
                if ("".equals(cron) || cron == null)
                    return null;
                CronTrigger trigger = new CronTrigger(cron);// 定时任务触发,可修改定时任务的执行周期
                Date nextExecDate = trigger.nextExecutionTime(triggerContext);
                return nextExecDate;
            }
        });



    }
}

  • 切面中

获取不到令牌则限流

      /**
         * 令牌桶算法
         */

        Long tokenNum = redisTemplate.opsForList().size("limit_Test");

        log.info("令牌数量:"+tokenNum);
        if (tokenNum>0){
            //通过,并且令牌减少1
            redisTemplate.opsForList().leftPop("limit_Test");
        }else {
            //未获取到令牌,执行限流操作
            log.info("限流");

        }

总结

当笔记记录一下两种简单实现限流的方案,但选哪种还要结合具体的业务场景。