【限流】从0开始实现常见的四种限流算法,基于Redis结合AOP实现【固定窗口】、【滑动窗口】、【令牌桶算法】、【漏桶算法】

前言

https://blog.csdn.net/lucky_morning/article/details/121619047
本片文章将使用redis中间件结合Spring框架的AOP加自定义注解来无侵入式的实现这四种常见的限流方式。在生产环境中,出于种种考虑,比如服务器并发性能、防止恶意访问等等,我们期望某些接口在单位时间内至多被访问N次,这个时候我们就要使用到限流算法来实现。

一、项目创建,引入相关依赖和配置

1、引入必要Maven依赖

使用idea直接new一个SpringBoot项目,引入spring-web、redis、aop依赖

        <!-- Spring AOP -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
            <version>2.5.6</version>
        </dependency>

        <!-- Redis 相关 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>


        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

2、基本配置

新建application.yml配置文件,配置如下

spring:
  redis:
    host: localhost
    port: 6379
    password:
  application:
    name: request-limit-aop
server:
  port: 8001
  servlet:
    context-path: /api/request-limit

二、自定义注解结合AOP的实现

1、新建注解接口类 【RequestLimit】


/**
 * <p>
 * 限流注解
 * </p>
 *
 * @author wangchen
 * @since 2021/11/23
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Order(Ordered.HIGHEST_PRECEDENCE)
public @interface RequestLimit {

    /**
     * 限流类型 ,具体见枚举类 com.cn.lucky.morning.limit.enmus.RequestLimitType
     */
    RequestLimitType type() default RequestLimitType.FIXED_WINDOW;

    /**
     * 限流访问数
     */
    int limitCount() default 100;

    /**
     * 限流时间段
     */
    long time() default 60;

    /**
     * 限流时间段 时间单位
     */
    TimeUnit unit() default TimeUnit.SECONDS;

    /**
     * 漏出或者生成令牌时间间隔,单位 毫秒  (当type为TOKEN、LEAKY_BUCKET时生效)
     */
    long period() default 1000;

    /**
     * 每次生成令牌数或者漏出水滴数  (当type为TOKEN、LEAKY_BUCKET时生效)
     */
    int limitPeriodCount() default 10;
}

2、新建AOP切面类 【RequestLimitAop】

一般限流我们是在接口调用之前进行拦截判断,所以aop里面我们只使用前置切点,AOP详细的注解解析,各位看官就自行了解哦,我这里默认大家都明白Spring注解AOP的基本上实现,所以就不再赘述了

/**
 * <p>
 * 限流AOP
 * </p>
 *
 * @author wangchen
 * @since 2021/11/23
 */
@Aspect
@Component
public class RequestLimitAop {
    private static final Logger LOGGER = LoggerFactory.getLogger(RequestLimitAop.class);

    /**
     * 切入点
     */
    @Pointcut(value = "@annotation(com.cn.lucky.morning.limit.annotation.RequestLimit)")
    public void aspect() {
        // 切入点方法
    }

    /**
     * 前置切点
     *
     * @param joinPoint 切入点
     */
    @Before("aspect()")
    public void doBefore(JoinPoint joinPoint) {
        LOGGER.info("-------------------------------doBefore begin------------------------------------");
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method targetMethod = methodSignature.getMethod();
        RequestLimit limit = targetMethod.getAnnotation(RequestLimit.class);
        LOGGER.info("方法【{}】限流方式:【{}】", signature.getName(), limit.type().getValue());
        LOGGER.info("-------------------------------doBefore end------------------------------------");
    }
}

3、新增Controller类【LimitController】,自定义注解测试AOP拦截效果


/**
 * <p>
 * 测试限流控制器
 * </p>
 *
 * @author wangchen
 * @since 2021/11/23
 */
@RestController
@RequestMapping
public class LimitController {

    /**
     * 测试AOP拦截
     *
     * @return 返回结果
     */
    @RequestLimit
    @GetMapping("/aop-test")
    public String aopTest() {
        return "测试AOP拦截效果";
    }
}

4、调用接口测试

调用接口地址:http://localhost:8001/api/request-limit/aop-test
在这里插入图片描述

三、固定窗口算法

1、算法说明

固定窗口算法算是四种算法中最简单,最暴力的算法了,在单位时间的开始进行计数,当访问次数超过阈值,后续的所有访问均被丢弃,直到下一个单位时间开始,计数被置为0。我们使用RedisTemplate中的原子整型RedisAtomicInteger来实现

2、算法缺陷

假如我们设置一分钟只能访问N次,如果有一个用户在疯狂的恶意请求接口,在一分钟的第一秒疯狂请求,那这一分钟的接口访问额度将会在这一秒被耗光,导致其他正常的用户无法访问到接口

3、代码实现

我们使用Spring中的RedisTemplate的原子整型来实现该功能,在接口第一次触发时,设置增加键值,并将键的有效期设置为单位时间,后续接口访问调用getAndIncrement()方法获取当前值,并将redis中值增加1,然后判断当前值是否超过访问阈值,如果超过则直接丢弃请求。该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码

/**
 * FixedWindowRequestLimitServiceImpl
 * 固定窗口 限流
 *
 * @author wangchen
 * @group com.cn.lucky.morning.limit.service.impl
 * @date 2021/11/24 16:17
 */
@Service
public class FixedWindowRequestLimitServiceImpl implements RequestLimitService {
    private static final Logger LOGGER = LoggerFactory.getLogger(FixedWindowRequestLimitServiceImpl.class);

    @Autowired
    private RedisConnectionFactory factory;

    @Override
    public boolean checkRequestLimit(RequestLimitDTO dto) {
        String key = RedisKeyConstant.RequestLimit.QPS_FIXED_WINDOW + dto.getKey();
        RequestLimit limit = dto.getLimit();
        RedisAtomicInteger atomicCount = new RedisAtomicInteger(key, factory);
        int count = atomicCount.getAndIncrement();
        if (count == 0) {
            atomicCount.expire(limit.time(), limit.unit());
        }
        LOGGER.debug("限流配置:{} {} 内允许访问 {} 次", limit.time(), limit.unit(), limit.limitCount());
        LOGGER.debug("访问时间【{}】", LocalTime.now());
        // 检测是否到达限流值
        if (count >= limit.limitCount()) {
            String msg = "【" + key + "】限流控制," + limit.time() + " " + limit.unit().name() + "内只允许访问 " + limit.limitCount() + " 次";
            LOGGER.debug(msg);
            return true;
        } else {
            LOGGER.debug("未达到限流值,放行 {}/{}", count, limit.limitCount());
            return false;
        }
    }

    @Override
    public RequestLimitType getType() {
        return RequestLimitType.FIXED_WINDOW;
    }
}

4、在Controller类中增加接口,使用自定义注解,类型为固定窗口

我们配置接口为5秒内最多调用2次

    /**
     * 测试AOP拦截 固定窗口限流
     *
     * @return 返回结果
     */
    @RequestLimit(type = RequestLimitType.FIXED_WINDOW, limitCount = 2, time = 5)
    @GetMapping("/aop-fixed-window-test")
    public String aopFixedWindowTest() {
        return "固定窗口限流 - 接口返回";
    }

5、调用接口测试

接口调用地址:http://localhost:8001/api/request-limit/aop-fixed-window-test
在这里插入图片描述

四、滑动窗口算法

1、算法说明

滑动窗口算法我们以每次接口调用的当前时间,回推单位时间,计算这段时间接口调用的数量是否超出阈值,未超出则放行,超出则丢弃。在计算时,我们每次都以当前调用时间往回推单位时间,就像是一个固定长度的窗口一直在顺着时间滑动,所以叫做滑动窗口。我们这里使用redis的zset来实现,当每一次请求进来的时候,value保持唯一,可以用UUID生成,而score可以用当前时间戳表示,因为redis提供了range方法可以用score来计算区间值数量。

2、算法缺陷

随着访问接口的增多,zset里面堆积的数据将会越来越多

3、代码实现

该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码

/**
 * <p>
 * 滑动窗口 限流
 * </p>
 *
 * @author wangchen
 * @since 2021/11/25
 */
@Service
public class SlideWindowRequestLimitServiceImpl implements RequestLimitService {
    private static final Logger LOGGER = LoggerFactory.getLogger(SlideWindowRequestLimitServiceImpl.class);

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public boolean checkRequestLimit(RequestLimitDTO dto) {
        String key = RedisKeyConstant.RequestLimit.QPS_SLIDE_WINDOW + dto.getKey();
        RequestLimit limit = dto.getLimit();
        long current = System.currentTimeMillis();
        long duringTime = limit.unit().toMillis(limit.time());
        Long count = redisTemplate.opsForZSet().count(key, current - duringTime, current);
        // 清除有效期外的数据
        redisTemplate.opsForZSet().removeRangeByScore(key, 0, current - duringTime - 1f);

        LOGGER.info("限流配置:{} {} 内允许访问 {} 次", limit.time(), limit.unit(), limit.limitCount());
        LOGGER.info("访问时间【{}】", LocalTime.now());
        // 检测是否到达限流值
        if (count != null && count >= limit.limitCount()) {
            String msg = "【" + key + "】限流控制," + limit.time() + " " + limit.unit().name() + "内只允许访问 " + limit.limitCount() + " 次";
            LOGGER.info(msg);
            return true;
        } else {
            redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), System.currentTimeMillis());
            LOGGER.info("未达到限流值,放行 {}/{}", count, limit.limitCount());
            return false;
        }
    }

    @Override
    public RequestLimitType getType() {
        return RequestLimitType.SLIDE_WINDOW;
    }
}

4、在Controller类中增加接口,使用自定义注解,类型为滑动窗口

我们将限流配置设置5秒内最多调用2次

    /**
     * 测试AOP拦截 滑动窗口 限流
     *
     * @return 返回结果
     */
    @RequestLimit(type = RequestLimitType.SLIDE_WINDOW, limitCount = 2, time = 5)
    @GetMapping("/aop-slide-window-test")
    public String aopSlideWindowTest() {
        return "滑动窗口限流 - 接口返回";
    }

5、调用接口测试

在这里插入图片描述

五、令牌桶算法

1、算法说明

令牌桶算法为我们以一个恒定的速度向一个桶内存放令牌,我们调用接口时从令牌桶内取令牌,如果能取到令牌,那么接口就放行, 如果没有取到令牌,则表示接口调用到达阈值,进行拦截。这里我们使用redis的list来实现,我们使用leftPush和rightPop即左进右出来对list数据进行操作,因为redis的这两个指令是具有原子性的,所以也不存在多线程下限流判断异常的问题

2、代码实现

该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码

/**
 * TokenRequestLimitServiceImpl
 * 令牌桶 限流
 *
 * @author wangchen
 * @group com.cn.lucky.morning.limit.service.impl
 * @date 2021/11/26 12:15
 */
@Service
public class TokenRequestLimitServiceImpl implements RequestLimitService {
    private static final Logger LOGGER = LoggerFactory.getLogger(TokenRequestLimitServiceImpl.class);

    @javax.annotation.Resource(name = "tokenPushThreadPoolScheduler")
    private ThreadPoolTaskScheduler scheduler;

    @Value("${request-limit.scan-package:}")
    private String scanPackage;

    @Autowired
    private ResourcePatternResolver resourcePatternResolver;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public boolean checkRequestLimit(RequestLimitDTO dto) {
        Object pop = redisTemplate.opsForList().rightPop(RedisKeyConstant.RequestLimit.QPS_TOKEN + dto.getKey());
        RequestLimit limit = dto.getLimit();
        LOGGER.info("限流配置:每 {} 毫秒 生成 {} 个令牌,最大令牌数:{}", limit.period(), limit.limitPeriodCount(), limit.limitCount());
        if (pop == null) {
            LOGGER.info("【{}】限流控制,令牌桶中不存在令牌,请求被拦截", dto.getKey());
            return true;
        } else {
            LOGGER.info("【{}】令牌桶存在令牌,未达到限流值,放行", dto.getKey());
            return false;
        }
    }

    @Override
    public RequestLimitType getType() {
        return RequestLimitType.TOKEN;
    }

    /**
     * 定速生成令牌
     */
    @PostConstruct
    public void pushToken() {
        // 扫描出所有使用了自定义注解并且限流类型为令牌算法的方法信息
        List<RequestLimitDTO> list = this.getTokenLimitList(resourcePatternResolver, RequestLimitType.TOKEN, scanPackage);
        if (list.isEmpty()) {
            LOGGER.info("未扫描到使用 令牌限流 注解的方法,结束生成令牌线程");
            return;
        }

        // 每个接口方法更具注解配置信息提交定时任务,生成令牌进令牌桶
        list.forEach(limit -> scheduler.scheduleAtFixedRate(() -> {

            String key = RedisKeyConstant.RequestLimit.QPS_TOKEN + limit.getKey();
            Long tokenSize = redisTemplate.opsForList().size(key);

            int size = tokenSize == null ? 0 : tokenSize.intValue();
            if (size >= limit.getLimit().limitCount()) {
                LOGGER.info("【{}】令牌数量已达最大值【{}】,丢弃新生成令牌", key, size);
                return;
            }
            // 判断添加令牌数量
            int addSize = Math.min(limit.getLimit().limitPeriodCount(), limit.getLimit().limitCount() - size);
            List<String> addList = new ArrayList<>(addSize);
            for (int index = 0; index < addSize; index++) {
                addList.add(UUID.randomUUID().toString());
            }
            redisTemplate.opsForList().leftPushAll(key, addList);
            LOGGER.info("【{}】生成令牌丢入令牌桶,当前令牌数:{},令牌桶容量:{}", key, size + addSize, limit.getLimit().limitCount());
        }, limit.getLimit().period()));
    }
}

3、在Controller类中增加接口,使用自定义注解,类型为令牌桶算法

我们配置为令牌桶容量为2,每三秒钟生成一个令牌

    /**
     * 测试AOP拦截 令牌桶 限流
     *
     * @return 返回结果
     */
    @RequestLimit(type = RequestLimitType.TOKEN, limitCount = 2, limitPeriodCount = 1, period = 3000)
    @GetMapping("/aop-token-test")
    public String aopTokenTest() {
        return "【" + RequestLimitType.TOKEN.getValue() + "】限流 - 接口返回";
    }

4、调用接口测试

在这里插入图片描述

六、漏桶算法

1、算法说明

漏桶算法刚好和令牌桶算法相逆,漏桶算法为以恒定的速度从桶中漏出水滴,而我们调用接口为向桶中加水滴,当桶中容量未满时,则表示接口调用量未达到阈值,放行,若桶中容量已满,无法增加水滴进入,则表示拦截请求

2、代码实现

该处只贴出了关键代码,详细代码请在 " 示例代码仓库 "中查看源代码

/**
 * LeakyBucketRequestLimitServiceImpl
 * 漏桶算法 限流
 *
 * @author wangchen
 * @group com.cn.lucky.morning.limit.service.impl
 * @date 2021/11/26 17:49
 */
@Service
public class LeakyBucketRequestLimitServiceImpl implements RequestLimitService {
    private static final Logger LOGGER = LoggerFactory.getLogger(LeakyBucketRequestLimitServiceImpl.class);

    @javax.annotation.Resource(name = "leakyBucketPopThreadPoolScheduler")
    private ThreadPoolTaskScheduler scheduler;

    @Value("${request-limit.scan-package:}")
    private String scanPackage;

    @Autowired
    private ResourcePatternResolver resourcePatternResolver;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public boolean checkRequestLimit(RequestLimitDTO dto) {
        String key = RedisKeyConstant.RequestLimit.QPS_LEAKY_BUCKET + dto.getKey();
        Long size = redisTemplate.opsForList().size(key);

        if (size != null && size >= dto.getLimit().limitCount()) {
            LOGGER.info("【{}】限流控制,漏桶中容量已满,请求被拦截", dto.getKey());
            return true;
        } else {
            LOGGER.info("【{}】漏桶中容量未满,放行,当前水滴容量:{},漏桶容量:{}", dto.getKey(), size, dto.getLimit().limitCount());
            redisTemplate.opsForList().leftPush(key, UUID.randomUUID().toString());
            return false;
        }
    }

    /**
     * 定数流出令牌
     */
    @PostConstruct
    public void popToken() {
        List<RequestLimitDTO> list = this.getTokenLimitList(resourcePatternResolver, RequestLimitType.LEAKY_BUCKET, scanPackage);
        if (list.isEmpty()) {
            LOGGER.info("未扫描到使用 漏桶限流 注解的方法,结束生成令牌线程");
            return;
        }

        list.forEach(limit -> scheduler.scheduleAtFixedRate(() -> {
            String key = RedisKeyConstant.RequestLimit.QPS_LEAKY_BUCKET + limit.getKey();
            redisTemplate.opsForList().trim(key, limit.getLimit().limitPeriodCount(), -1);
            LOGGER.info("【{}】漏出 {} 个水滴", key, limit.getLimit().limitPeriodCount());
        }, limit.getLimit().period()));
    }

    @Override
    public RequestLimitType getType() {
        return RequestLimitType.LEAKY_BUCKET;
    }
}

3、在Controller类中增加接口,使用自定义注解,类型为漏桶算法

我们配置为漏桶容量为2,每隔三秒漏出一滴水

    /**
     * 测试AOP拦截 漏桶 限流
     *
     * @return 返回结果
     */
    @RequestLimit(type = RequestLimitType.LEAKY_BUCKET, limitCount = 2, limitPeriodCount = 1, period = 3000)
    @GetMapping("/aop-leaky-bucket-test")
    public String aopLeakyBucketTest() {
        return "【" + RequestLimitType.LEAKY_BUCKET.getValue() + "】限流 - 接口返回";
    }

4、调用接口测试

在这里插入图片描述

七、示例代码仓库,详细代码

GITHUB地址:https://github.com/luckymorning/simple-example-code/tree/master/request-limit-aop

  • 5
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值