RedissonRateLimiter限流器原理解析

基于redisson3.24.3版本源码(一个没什么特别,项目恰好依赖的版本)

RedissonRateLimiter依托redis的string、hash和sorted set三个数据结构;实现一个原理更像是令牌桶的限流器

实现:在规定时间窗口内,发放指定数目的通过令牌的功能。

下面具体介绍其原理主要分为

  1. api介绍
  2. 源码解析
  3. 获取令牌整体流程

api介绍

创建限流器

通过redisson客户端获取一个限流器对象时,只需要指定限流器名称即可获取

参考代码如下

import org.redisson.Redisson;
import org.redisson.api.RRateLimiter;
import org.redisson.api.RateIntervalUnit;
import org.redisson.api.RateType;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class Main {
    public static RRateLimiter createLimiter() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379");//如果有密码还要链式设置密码
        RedissonClient redisson = Redisson.create(config);
        // 获取一个限流器
        RRateLimiter rateLimiter = redisson.getRateLimiter("yourLimiterName");
        // 初始化
        // 最大流速 = 每1秒钟产生1个令牌
        rateLimiter.trySetRate(RateType.OVERALL, 1, 1, RateIntervalUnit.SECONDS);
        return rateLimiter;
    }
}

初始化设置一个限流器

public boolean trySetRate(RateType type, long rate, long rateInterval, RateIntervalUnit unit)

参数解析
type对应枚举类org.redisson.api.RateType
两个类型
OVERALL:所有RateLimiter实例的总费率
PER_CLIENT:使用同一Redison实例的所有RateLimiter实例的总费率
一般使用第一个类型;第二个划分的有点细
rate限流速率(单位为个)
rateInterval速率间隔 即每一个rateInterval,生成 rate个令牌
unitrateInterval的单位时间

创建限流器解析

获取令牌

public RFuture tryAcquireAsync(long permits, long timeout, TimeUnit unit)
| 参数 | 解析 |
| ------- | ------------------------------------------------------------ |
| permits | 要获取的令牌数;
如果使用重载方法不传递这个参数的情况下,默认为1 |
| timeout | 获取不到令牌时的等待时间
负数表示一直等待,直到获取到对应数量的令牌(与permits不做正负数判断和限制不同,这里是会进行判断的)
0表表示不做等待立即返回结果
正数代表等待指定的时间配合参数unit使用 |
| unit | 时间单位 |

一个令牌只能初始化一次,一旦初始化之后就不能更改,只能删除后重新初始化

删除限流器

由redisson顶层抽象类提供: org.redisson.RedissonObject#delete

RedissonRateLimiter进行覆盖,调用保护方法,传入所有的key,清理限流器使用到的所有三个key

设置过期时间

由过期时间管理抽象类提供:org.redisson.RedissonExpirable#expire(long, java.util.concurrent.TimeUnit)

RedissonRateLimiter进行覆盖,调用保护方法,传入所有的key,给限流器使用到的所有三个key设置过期时间

其他API

其他的创建和获取令牌的api基本都是复用上面介绍的两个api,会缺失一些参数,而使用默认值替代

还有一些没有返回值的创建限流器和获取令牌的方法;以及清理过期时间等redisson提供的基本方法不在介绍

源码解析

初始化限流器源码解析

这是使用限流器所必现的操作

//调用下面返回RFuture对象的方法
public boolean trySetRate(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
    return get(trySetRateAsync(type, rate, rateInterval, unit));
}

//执行lua脚本
//使用hash哈希结构存储限流器信息
//对应三个命令:
//hsetnx [创建限流器时传入的限流器名称] 'rate' [自定义速率]
//hsetnx [创建限流器时传入的限流器名称] 'interval' [自定义时间间隔]
//hsetnx [创建限流器时传入的限流器名称] 'type' [传入的限流器类型]
public RFuture<Boolean> trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
    return commandExecutor.evalWriteNoRetryAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);"
          + "redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);"
          + "return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);",
            Collections.singletonList(getRawName()), rate, unit.toMillis(rateInterval), type.ordinal());
}

hsetnx命令介绍

命令格式:hsetnx key field value

命令接受三个参数:

key 对应redis的键

filed对应键 下的对应属性

value 要给属性赋予的值

命令首先检查key是否存在,不存在则创建key并设置属性然后返回1;

如果key存在,则校验对应的 key下是否有对应的field

如果没有对应的属性,则设置对应属性,并返回1;如果有对应的属性则跳过,并返回0

指的注意的时,上面 创建限流器 源码中执行的lua脚本成功与否主要依托与最后一行脚本的执行结果

即:

return redis.call(‘hsetnx’, KEYS[1], ‘type’, ARGV[3]);

也就是说尽管redis中存在了你设定的这个key,甚至有了 rate 和 interval,这个初始化指令并不会影响原来的配置;

只要返回结果只和type挂钩;

获取令牌源码解析(java方法解析)

仅介绍上文提到过的,有返回值的创建限流器的api


    //调用下面返回RFuture的方法
    @Override
    public boolean tryAcquire(long permits, long timeout, TimeUnit unit) {
        return get(tryAcquireAsync(permits, timeout, unit));
    }

    //转换等待时间,然后调用内部方法,最后对CompletableFuture进行封装
    @Override
    public RFuture<Boolean> tryAcquireAsync(long permits, long timeout, TimeUnit unit) {
        long timeoutInMillis = -1;
        //如果设置的超时时间大于等于0 转换成毫秒,否则默认-1 不设置超时时间
        if (timeout >= 0) {
            timeoutInMillis = unit.toMillis(timeout);
        }
        CompletableFuture<Boolean> f = tryAcquireAsync(permits, timeoutInMillis);
        return new CompletableFutureWrapper<>(f);
    }

    //获取令牌的java核心方法,里面还会调用执行lua脚本的方法
    private CompletableFuture<Boolean> tryAcquireAsync(long permits, long timeoutInMillis) {
        //请求令牌的处理开始时间 下面成为开始时间s
        long s = System.currentTimeMillis();
        //执行lua脚本;返回结果如果是null则代表获取到令牌,否则返回等待时间
        RFuture<Long> future = tryAcquireAsync(RedisCommands.EVAL_LONG, permits);
        return future.thenCompose(delay -> {
            //获取到令牌,直接返回true
            //delay代表最老的一个被获取的令牌的释放时间 下面称为 下一次令牌释放时间delay
            if (delay == null) {
                return CompletableFuture.completedFuture(true);
            }
            // 超时时间如果没有设置 则递归调用获取令牌方法;
            // 这里不会一直重复循环调用,而是会等待 释放时间到之后递归调用自己
            if (timeoutInMillis == -1) {
                CompletableFuture<Boolean> f = new CompletableFuture<>();
                getServiceManager().getGroup().schedule(() -> {
                    //递归调用
                    CompletableFuture<Boolean> r = tryAcquireAsync(permits, timeoutInMillis);
                    commandExecutor.transfer(r, f);
                }, delay, TimeUnit.MILLISECONDS);
                return f;
            }
            // 请求开始到现在为止的时间差
            long el = System.currentTimeMillis() - s;
            // 设置的超时时间和程序处理时间el的差值 下面称为 超时剩余时间remains
            long remains = timeoutInMillis - el;
            // 如果剩余时间已经小于0代表已经超时,直接返回false  
            if (remains <= 0) {
                return CompletableFuture.completedFuture(false);
            }
            CompletableFuture<Boolean> f = new CompletableFuture<>();
            // 超时剩余时间remains 小于 下一次令牌释放时间delay 的话代表超时时间内都不会获取到令牌,这里等超时剩余时间结束直接返回false
            if (remains < delay) {
                getServiceManager().getGroup().schedule(() -> {
                    f.complete(false);
                }, remains, TimeUnit.MILLISECONDS);
            } else {

                // 再次记录下当前时间 延时任务开始时间
                long start = System.currentTimeMillis();
                // 一个延时任务:主要逻辑是等待一个 下一次令牌释放时间delay 后重新执行lua脚本,尝试获取令牌
                getServiceManager().getGroup().schedule(() -> {
                    // 延时任务开始执行时间和当前时间的差值 延时任务差值时间elapsed
                    long elapsed = System.currentTimeMillis() - start;
                    // 如果延时任务开始执行时已经大于 超时剩余时间remains 直接返回false
                    if (remains <= elapsed) {
                        f.complete(false);
                        return;
                    }
                    // 下一次令牌释放时间delay 已过,已有新令牌释放, 递归调用自己重新尝试获取指定数目的令牌
                    // 这里如果permits为1 一定是可以获取到令牌的,如果大于1 则不一定,有可能还要循环递归
                    CompletableFuture<Boolean> r = tryAcquireAsync(permits, remains - elapsed);
                    commandExecutor.transfer(r, f);
                }, delay, TimeUnit.MILLISECONDS);
            }
            return f;
        }).toCompletableFuture();
    }

    //执行lua脚本的方法,具体解释可以继续看下文
    private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {
        byte[] random = getServiceManager().generateIdArray();

        return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                "lua 脚本省略,具体可以看下文",
                Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()),
                value, System.currentTimeMillis(), random);
    }

获取令牌源码解析(lua脚本解析)

首先贴一个大佬解析的原文

大佬解析lua脚本的原文

我的稍加修改版本

把代码复制到idea新建一个.lua文件,装上idea的lua脚本插件,观看更直观

-- 速率
local rate = redis.call('hget', KEYS[1], 'rate');
-- 时间区间(ms)
local interval = redis.call('hget', KEYS[1], 'interval');
local type = redis.call('hget', KEYS[1], 'type');
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')

local valueName = KEYS[2];
local permitsName = KEYS[4];
if type == '1' then
    valueName = KEYS[3];
    permitsName = KEYS[5];
end ;
-- 判断速率要大于请求的令牌数;否则请求失败
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
-- 获取当前剩余的令牌数
local currentValue = redis.call('get', valueName);
local res;
-- 获取到说明不是第一次请求;否则只需要记录此次请求信息直接返回即可
if currentValue ~= false then
    -- 从zset请求信息集合中获取数据,范围是0 ~ (第二次请求时间戳 - 令牌生产的时间)
    -- 这里获取到的请求信息都是过期的时间,因为已经超过了一个 interval 时间
    local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
    local released = 0;
    -- 遍历expiredValues,获取所有过期请求中记录的令牌数量;这也是可以释放的令牌数量
    for i, v in ipairs(expiredValues) do
        local random, permits = struct.unpack('Bc0I', v);
        released = released + permits;
    end ;
    -- 清理过期的 请求数据;保持zset的整洁 ;如果没有过期的令牌这里就不会执行
    if released > 0 then
        -- 移除zset中所有过期元素
        redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
        -- 判断当前剩余的令牌数和释放的令牌数之和是否大于速率 目的是解决 issues编号为3639的短暂令牌超限问题
        if tonumber(currentValue) + released > tonumber(rate) then
            -- 当前剩余的令牌数和释放令牌数之和超过了速率: 当前可获得令牌数 = 速率 - 当前仍然被持有的令牌数
            -- 当然这里也是有明显bug的: 修复的commit hash 947cbfbd9b0445ceeb7d5acb2fd1a8a1d6699bfa;这里仅仅简单的统计了所有有效的请求条数;如果有请求持有的令牌数不是1,那么这个计数就是错误的
            currentValue = tonumber(rate) - redis.call('zcard', permitsName);
        else
            -- 剩余令牌数 = 原本剩余的 + 本次释放的
            currentValue = tonumber(currentValue) + released;
        end ;
        redis.call('set', valueName, currentValue);
    end ;

    -- 如果当前令牌数 < 请求的令牌数 代表此次请求无法获取足够数量的令牌,需要计算出下次令牌释放时间,然后返回
    if tonumber(currentValue) < tonumber(ARGV[1]) then
        -- 获取最近一次过期请求的信息
        -- 使用的命令是:zrang key 0 0 withscores 按照score 从小到大排序,从index 0 开始取 取到index 为0 结束;也就是取最小分值的一个
        local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores');
        -- 计算出下一次令牌释放的时间 最前面的3就是一个偏移量,延时 3ms
        -- 3 + 时间区间大小 - (请求开始时间 -最后一次成功获取令牌的请求时间)
        res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]));
    else
        -- 如果当前令牌数 ≥ 请求的令牌数,表示令牌够多,更新zset ,加入此次请求记录
        -- 请求记录的值是:传入的随机数的长度 + 随机数 + 本次请求令牌数 进行struct.pack编码
        redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
        -- valueName存的是当前总令牌数,这里需要减去此次请求获取到的令牌数量
        redis.call('decrby', valueName, ARGV[1]);
        res = nil;
    end ;
else
    -- 第一次请求获取令牌时初始化令牌数和zset
    -- set一个key-value数据 记录当前限流器的令牌数 先设置,后面还要减,为啥不后面直接计算好要设置的值一次性设置上去?
    redis.call('set', valueName, rate);
    -- 建了一个以当前限流器名称相关的zset,并存入 以score为当前时间戳,以lua格式化字符串{当前时间戳为种子的随机数、请求的令牌数}为value的值。
    redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
    -- 从总的令牌数中减去本次请求的令牌数
    redis.call('decrby', valueName, ARGV[1]);
    res = nil;
end ;

-- 获取 限流器配置的过期时间
local ttl = redis.call('pttl', KEYS[1]);
if ttl > 0 then
    -- 如果限流器有过期时间那么重置下 value key 和permits key 的过期时间 保持三个key的过期时间一致性
    redis.call('pexpire', valueName, ttl);
    redis.call('pexpire', permitsName, ttl);
end ;
return res;

获取令牌整体流程

RedissonRateLimiter流程图new

拓展介绍

翻看上文中的源码介绍,我们可以惊奇的发现:

  1. 初始化限流器时,并没有限制速率rate一定是个正数;它其实可以为0,或者负数的
  2. 获取令牌时,要获取的令牌数量permits其实也没有做正数校验,也就是它也可以是0,或者负数

如果permits=0:那么每次tryAcquireAsync都会返回true 因为不会减少剩余令牌数目
如果permits<0:那么调用tryAcquireAsync方法会释放对应permits绝对值数量的令牌
甚至这个数量不受rate数量的限制;可以无限向上增长

基于RedissonRateLimiter的这个特性可以很轻易的把他改造成一个统计指定api在规定时间内调用次数的工具

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值