基于redisson3.24.3版本源码(一个没什么特别,项目恰好依赖的版本)
RedissonRateLimiter依托redis的string、hash和sorted set三个数据结构;实现一个原理更像是令牌桶的限流器
实现:在规定时间窗口内,发放指定数目的通过令牌的功能。
下面具体介绍其原理主要分为
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个令牌 |
unit | rateInterval的单位时间 |
获取令牌
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脚本解析)
首先贴一个大佬解析的原文
我的稍加修改版本
把代码复制到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;
获取令牌整体流程
拓展介绍
翻看上文中的源码介绍,我们可以惊奇的发现:
- 初始化限流器时,并没有限制速率rate一定是个正数;它其实可以为0,或者负数的
- 获取令牌时,要获取的令牌数量permits其实也没有做正数校验,也就是它也可以是0,或者负数
如果permits=0:那么每次tryAcquireAsync都会返回true 因为不会减少剩余令牌数目
如果permits<0:那么调用tryAcquireAsync方法会释放对应permits绝对值数量的令牌
甚至这个数量不受rate数量的限制;可以无限向上增长
基于RedissonRateLimiter的这个特性可以很轻易的把他改造成一个统计指定api在规定时间内调用次数的工具