基于Redis的分布式限流器Java实现

常见的限流方案

从实现方式上来讲,限流可分为简单计数器限流、滑动窗口限流,基于漏桶和令牌桶算法的限流。

从是否支持多机拓展上来讲,又分为单机限流和分布式限流。单机限流大多通过线程锁的方式实现,而分布式限流多借助于Redis等中间件。

简单计数器限流

通过维护单位时间内的请求次数来实现限流,当请求次数超过最大限制时拒绝访问。这种实现方式的好处是实现起来较为简单,缺点是可能会产生“毛刺”。如下图。
在这里插入图片描述

滑动窗口限流

滑动窗口也是维护单位时间内的请求次数,其与简单计数器的区别是,滑动窗口的粒度更细,将一个大的时间窗口划分为若干个小的时间窗口,通过滑动时间删除小的时间窗口,以此来避免简单计数器的“毛刺”问题。如下图。
在这里插入图片描述

基于漏桶算法限流

漏桶算法将流量放入一个固定容量的“漏斗”中,以恒定速度将流量进行输出。当“漏斗”装满时,拒绝掉涌入的流量。

基于令牌桶算法限流

令牌桶算法每隔一段时间就将一定量的令牌放入桶中,获取到令牌的请求直接访问后段的服务,没有获取到令牌的请求会被拒绝。同时令牌桶有一定的容量,当桶中的令牌数达到最大值后,不再放入令牌。
在这里插入图片描述

几种方案各有优劣,需要结合实际场景进行选型。

方案优势劣势
计数器实现最为简单方便"毛刺"现象
滑动窗口应对突发流量能力强,可配置性强取决于窗口粒度,非严格均匀,流量整形效果弱
漏桶流量整形效果最好,输出流量最平滑(均匀输出)应对突发流量效果差
令牌桶相较漏桶,有一定的应对突发流量的能力各方面都比较平庸,实现起来最为复杂
这里有一个流量整形的概念。所谓流量整形,是指流量经过我们的限流器后,其形状发生了变化,将短时间的大流量整形为长时间的平缓流量。而显然,通过计数器及滑动窗口的方式实现的限流,通过暴力拒绝掉部分流量,仅仅是对流量进行了“裁剪”,并没有对流量进行时间维度上的重新分配。而漏桶算法与令牌桶算法,通过一定的阻塞机制,真正改变了流量的时间分布,实现了一定的削峰填谷的效果。

限流器整体结构

整体设计思路上是通过注解实现对controller无侵入的限流,通过拦截请求,获取请求中相应的参数进行定制化的限流逻辑处理,并调用redis脚本进行是否限流的判断。

因此整体分为三部分代码

  • 限流注解,主要是一些限流参数的指定
  • redis限流脚本,限流方法的具体实现
  • 切面层,拦截请求,定制化限流逻辑,调用限流脚本实现限流。

限流注解如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {

    /**
     * 限流key
     * @return
     */
    String key() default "rate:limiter";
    /**
     * 窗口允许最大请求数
     * @return
     */
    long maxCount() default 10;

    /**
     * 窗口宽度,单位为ms
     * @return
     */
    long winWidth() default 1000;

    /**
     * 限流提示语
     * @return
     */
    String message() default "false";
}

这里的限流key只是一个基本key,对于特定的业务逻辑,可以有一些定制化的限流,如对于我的使用场景下,需要对不同的租户进行分开的限流,那么就可以在限流逻辑中对key进行一个定制化,以实现拓展的效果。下面是切面层。

@Component
@Aspect
@Slf4j
public class RateLimitAspect {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    private DefaultRedisScript<Long> getRedisScript;

    @PostConstruct
    public void init() {
        getRedisScript = new DefaultRedisScript<>();
        getRedisScript.setResultType(Long.class);
        getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("rateLimiterSlidingWindow.lua")));
        log.info("RateLimiter[分布式限流处理器]脚本加载完成");
    }

    @Pointcut("@annotation(com.tencent.cloud.iov.ivm.annotations.RateLimiter)")
    public void rateLimiter() {}

    @Around("@annotation(rateLimiter)")
    public Object around(ProceedingJoinPoint proceedingJoinPoint, RateLimiter rateLimiter) throws Throwable {
        if (log.isDebugEnabled()) {
            log.debug("RateLimiter[分布式限流处理器]开始执行限流操作");
        }
        Signature signature = proceedingJoinPoint.getSignature();
        if (!(signature instanceof MethodSignature)) {
            throw new IllegalArgumentException("the Annotation @RateLimiter must used on method!");
        }
        /**
         * 获取注解参数
         */
        /** 限流模块key
         *  按业务需求定制化处理
         *  这里用tenantId作为key的一部分,实现分租户限流的目的*/
        String limitKey = rateLimiter.key();
        RequestVo arg = (RequestVo)proceedingJoinPoint.getArgs()[0];
        limitKey += "-" + arg.getTenantId();
        Preconditions.checkNotNull(limitKey);
        /**时间窗口内可接受的最大请求次数*/
        Long maxCount = rateLimiter.maxCount();
        /**时间窗口宽度*/
        Long winWidth = rateLimiter.winWidth();
        if (log.isDebugEnabled()) {
            log.debug("RateLimiterHandler[分布式限流处理器]参数值为-maxCount={},winWidth={}", maxCount, winWidth);
        }
        // 限流提示语
        String message = rateLimiter.message();
        if (StringUtils.isBlank(message)) {
            message = "false";
        }
        /**
         * 执行Lua脚本
         */
        List<String> keyList = new ArrayList();
        // 设置key值为注解中的值
        keyList.add(limitKey);
        /**
         * 调用脚本并执行
         */
        log.info("keyList={}, maxCount={}, winWidth={}", keyList, maxCount, winWidth);
        Long result = stringRedisTemplate.execute(getRedisScript, keyList, maxCount.toString(), winWidth.toString());
        if (result == 0) {
            String msg = "由于超过窗口宽度=" + winWidth + "-允许" + limitKey + "的请求次数=" + maxCount + "[触发限流]";
            log.debug(msg);
            throw new BusinessException(BusinessCode.EXCEEDING_LIMIT_ERROR);
        }
        if (log.isDebugEnabled()) {
            log.debug("RateLimiterHandler[分布式限流处理器]限流执行结果-result={},请求[正常]响应", result);
        }
        return proceedingJoinPoint.proceed();
    }
}

限流脚本

这里将redis的限流方法解耦开,通过使用不同的脚本,以实现不同方案的限流。

简单计数器

首先是简单计数器的限流。
redis限流脚本如下:

--获取KEY
local key1 = KEYS[1]

local val = redis.call('incr', key1)
local ttl = redis.call('ttl', key1)

--获取ARGV内的参数并打印
local expire = ARGV[1]
local times = ARGV[2]

redis.log(redis.LOG_DEBUG,tostring(times))
redis.log(redis.LOG_DEBUG,tostring(expire))

redis.log(redis.LOG_NOTICE, "incr "..key1.." "..val);
if val == 1 then
    redis.call('expire', key1, tonumber(expire))
else
    if ttl == -1 then
        redis.call('expire', key1, tonumber(expire))
    end
end

if val > tonumber(times) then
    return 0
end

return 1

比较简单,主要是利用了Redis脚本的原子性,这里不再过多介绍了。

滑动窗口的实现方式

限流脚本如下:

redis.replicate_commands();

--获取KEY
local key = KEYS[1]

--获取ARGV内的参数并打印
local max_quantity = ARGV[1]
local window_width = ARGV[2]

--获取当前时间及时间边界
local time = redis.call('TIME') --返回值为当前所过去的秒数,当前秒所过去的微秒数
local timestamp = time[1] * 1000 + math.floor(time[2] / 1000)

local left_border = timestamp - window_width

--移除窗口外的值
redis.call('zremrangebyscore', key, 0, left_border)

--统计窗口内元素个数
local count = redis.call('zcard', key)

if count < tonumber(max_quantity) then
    redis.call('zadd', key, timestamp, timestamp)
    return 1
else
    return 0
end

每次获取当前的时间戳,并移除时间窗口外的元素,随后判断当前是否出发限流,由于这里时间的粒度是毫秒,因此限流的效果还是比较平滑的。

这里要注意通过redis.replicate_commands()开启命令复制模式。这是因为redis在集群模式下,对于获取时间这种命令,由于到达各台机器的时间不一致,因此会出现数据不一致的问题。而采用命令复制模式,会直接复制时间值而非获取时间的命令。

漏桶实现方式

主要是利用了redis 4.0的cell命令。

--获取KEY
local key1 = KEYS[1]

--获取ARGV内的参数并打印
local max_quantity = ARGV[1]
local window_width = ARGV[2]

--这里漏桶的容量直接写死了,后续应该作为参数传入
local res = redis.call('CL.THROTTLE', key, 1000, max_quantity, window_width)

if res[1] == 0 then
    return 1
else
    return 0
end

这里要注意redis-cell模块需要额外安装。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值