Spring中的限流实现

限流介绍

为什么要限流?

今天儿童节第二天,俗称‘62节’(杭州的一个说法,哈哈哈,不知道其他地方有没有)。马上又到了618,很多朋友都会在这天上某东、某宝等平台抢购各种商品。对于抢购,顾名思义就是大量用户同时发起下单请求,此时系统将面临突发的大量用户请求,若处理不好,可能导致系统宕机直接被流量打垮导致无法对外提供服务。那为了防止出现这种情况,解决方案当然也有很多,例如无脑增加机器硬件设施,通过监控进行动态扩容,停止其他暂时不重要的服务,全力保障抢购服务的稳定性等等。当然限流也是其中一种方式。

例如,12306购票系统,在面对高并发的情况下,就是采用了限流。 在流量高峰期间经常会出现提示语;“当前排队人数较多,请稍后再试!”

限流方式?算法?

先看一张图
在这里插入图片描述

固定窗口限流

固定窗口限流,把窗口想象成时间,即在固定时间内限制请求次数,如上图,1-13数字上面的第 1 秒包含 1~6 ,第 2 秒包含 7~12 ,每秒内包含 6 个请求数,这样看上去没有任何问题。但是仔细想想,假如我在第 1 秒中的前 100 毫秒突然请求6次,但是在最后 900 毫秒就已经被限流了,需要等到下一个 1 秒才能刷新请求数,相当于会有个空窗期或者说请求不均匀(均匀请求应该是 1/6 秒)

滑动窗口限流

滑动窗口限流和固定窗口相比,滑动窗口在连续的请求中,能做到请求比较均匀,如上图,1-13数字下面第 1 秒包含 1~6 ,第 2 秒包含 2~7(应该是第 1/6 -7/6 秒,这里没表示清楚)。每过 1/6 秒往后移动一格(即可以请求一次)。

计数器

计数器是最简单粗暴的限流算法,通过控制单位时间内的并发数进行限流。

如:用AtomicInteger、redis自增统计当前并发数,如超过最大值则拒绝请求。

桶算法

包含漏桶和令牌桶
漏桶:假设现在有一个水桶,把请求比做是水,它的原理就是注水漏水的过程,往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,水会溢出,即请求被丢弃了,因为桶容量是不变的,保证了整体的速率。(有点类似线程池的拒绝策略,当队列满了后拒绝后续任务)

令牌桶:令牌桶其实是漏桶的一种改进,假设有一个管理员,以固定的速率往一个桶里面放令牌。每个请求先判断桶内是否有令牌,如果有则请求成功,如果没有则请求失败。当然桶也是有固定容量的,超过容量也就不会继续放令牌。

限流案例

本次案例采用Spring AOP的方式实现,以下代码仍有优化的地方,如实现方式可以使用策略模式优化代码等

/**
 * 定义限流类型
 * @Author h-bingo
 * @Date 2023-04-26 09:28
 * @Version 1.0
 */
public enum LimitType implements DescEnum {

    DEFAULT("全局默认"),
    USER("根据用户限流"),
    IP("根据IP限流"),


    ;

    private String desc;

    LimitType(String desc) {
        this.desc = desc;
    }

    @Override
    public String getDesc() {
        return desc;
    }
}
/**
 * 定义限流方式
 * @Author h-bingo
 * @Date 2023-04-26 11:22
 * @Version 1.0
 */
public enum LimitRealize implements DescEnum {
    TOKEN_BUCKET("令牌桶"),

    SLIDING_WINDOW("滑动窗口"),

    FIXED_WINDOW("固定窗口"),
    ;

    private String desc;

    LimitRealize(String desc) {
        this.desc = desc;
    }

    @Override
    public String getDesc() {
        return desc;
    }
}
/**
 * 定义限流注解 默认每 60 秒每 ip 访问 100 次
 * <p>
 * 默认采用redission令牌桶的方式
 *
 * @Author h-bingo
 * @Date 2023-04-26 09:26
 * @Version 1.0
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {

    /**
     * 时间 单位: s
     *
     * @return
     */
    long time() default 60;

    /**
     * 请求次数
     *
     * @return
     */
    long count() default 100;

    /**
     * 限流类型
     *
     * @return
     */
    LimitType limitType() default LimitType.IP;

    /**
     * 限流实现方式
     *
     * @return
     */
    LimitRealize limitRealize() default LimitRealize.TOKEN_BUCKET;
}
/**
 * 限流实现
 * @Author h-bingo
 * @Date 2023-04-26 09:32
 * @Version 1.0
 */
@Slf4j
@Aspect
@ConditionalOnMissingBean(RateLimiterAspect.class)
public class RateLimiterAspect implements InitializingBean {

    private static final String LIMITER_KEY = "rateLimiter:";

    @Autowired
    private RedisService redisService;

    @Resource(name = "rateLimiterScript")
    private RedisScript<Long> redisScript;

    @Autowired
    private RedissonClient redissonClient;

    @Autowired(required = false)
    private LimitUserFactory limitUserFactory;

    @Pointcut("@annotation(com.bingo.study.common.component.limiter.annotation.RateLimiter)")
    public void rateLimiter() {
    }

    @Before("rateLimiter()&&@annotation(limiter)")
    public void doBefore(JoinPoint point, RateLimiter limiter) {
        if (LimitRealize.TOKEN_BUCKET == limiter.limitRealize()) { // 令牌桶
            tokenBucket(point, limiter);
        } else if (LimitRealize.FIXED_WINDOW == limiter.limitRealize()) { // 固定窗口
            fixedWindow(point, limiter);
        } else if (LimitRealize.SLIDING_WINDOW == limiter.limitRealize()) { // 滑动窗口
            slidingWindow(point, limiter);
        }
    }

    private void tokenBucket(JoinPoint point, RateLimiter limiter) {
        String redisLimiterKey = getRedisLimiterKey(point, limiter);
        RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisLimiterKey);
        	// 设置令牌桶速率
        rateLimiter.trySetRate(RateType.OVERALL, limiter.count(), limiter.time(), RateIntervalUnit.SECONDS);
        if (!rateLimiter.tryAcquire(1)) {
            log.info("方法已限流: {}", redisLimiterKey);
            throw new RateLimiterException("访问过于频繁,请稍候再试");
        }
    }

    private void fixedWindow(JoinPoint point, RateLimiter limiter) {
        String redisLimiterKey = getRedisLimiterKey(point, limiter);
        Long number = redisService.redisTemplate().execute(redisScript, Collections.singletonList(redisLimiterKey),
                limiter.count(), limiter.time());
        if (number != null && number > limiter.count()) {
            log.info("方法已限流: {}", redisLimiterKey);
            throw new RateLimiterException("访问过于频繁,请稍候再试");
        }
    }

    private void slidingWindow(JoinPoint point, RateLimiter limiter) {
        String redisLimiterKey = getRedisLimiterKey(point, limiter);
        long currentTime = SystemClock.now();
        Set<Object> range = redisService.opsForZSet().rangeByScore(redisLimiterKey,
                currentTime - limiter.time() * 1000, currentTime);
        int currentCount = range == null ? 0 : range.size();
        if (currentCount >= limiter.count()) { // size 从0开始
            log.info("方法已限流: {}", redisLimiterKey);
            throw new RateLimiterException("访问过于频繁,请稍候再试");
        }
        // 移除过期的
        redisService.opsForZSet().removeRangeByScore(redisLimiterKey, 0, currentTime - limiter.time() * 1000);
        // 添加当前时间值
        redisService.opsForZSet().add(redisLimiterKey, currentTime + RandomUtil.randomInt(), currentTime);
    }

    /**
     * 获取限流key
     * <p>
     * 组成:applicationName + {@link RateLimiterAspect#LIMITER_KEY} + 方法名 + 限流方式 + (ip)(用户id)
     *
     * @Param [point, limiter]
     * @Return java.lang.String
     * @Date 2023-04-26 11:46
     */
    private String getRedisLimiterKey(JoinPoint point, RateLimiter limiter) {
        StringBuilder builder = new StringBuilder(LIMITER_KEY);
        builder.append(AspectUtil.getMethodIntactName(point));
        builder.append(":").append(limiter.limitRealize().name());

        if (limiter.limitType() == LimitType.IP) {
            builder.append(":").append(IPUtil.getIpAddr(ServletUtil.getRequest()));
        } else if (limiter.limitType() == LimitType.USER) {
            if (limitUserFactory == null) {
                log.warn("未实现获取用户id方法: {}", LimitUserFactory.class.getTypeName());
                throw new RateLimiterException("未实现获取用户id方法");
            }
            builder.append(":").append(limitUserFactory.getUserId());
        }

        return RedisKeyUtil.getCacheKey(builder.toString(), false, true);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        log.info("接口限流功能已开启,请在需要限流接口添加: @RateLimiter");
    }
}
/**
 * 开启限流功能注解,这种方式在上一期 Spring中的@Import注解妙用 中讲过
 * @Author h-bingo
 * @Date 2023-04-26 10:39
 * @Version 1.0
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({RateLimiterAspect.class})
public @interface EnableRateLimiter {
}
测试结果

接口如下:这里我们设置的 10 秒请求5次
在这里插入图片描述
这里我们使用Apipost进行测试
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
从日志结果可以看到,只有5个请求拿到令牌,其他请求没有拿到令牌则直抛异常,通过全局异常捕获处理。

结束语

本次分享关于限流的案例到此就结束了。欢迎指正,也希望和大家共同探讨和进步~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Tiny丶bingo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值