一、限流的作用
由于 API 接口无法控制调用方的行为,因此当遇到瞬时请求量激增时,会导致接口占用过多服务器资源,使得其他请求响应速度降低或是超时,更有甚者可能导致服务器宕机。
限流指对应用服务的请求进行限制,例如某一接口的请求限制为 100 个每秒,对超过限制的请求则进行快速失败或丢弃。
限流可以应对:
? 热点业务带来的突发请求;
? 调用方 bug 导致的突发请求;
? 恶意攻击请求。
因此,对于公开的接口最好采取限流措施。
二、为什么要分布式限流
当应用为单点应用时,只要应用进行了限流,那么应用所依赖的各种服务也都得到了保
护。
但线上业务出于各种原因考虑,多是分布式系统,单节点的限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。
而如果实现了分布式限流,那么就可以方便地控制整个服务集群的请求限制,且由于整个集群的请求数量得到了限制,因此服务依赖的各种资源也得到了限流的保护。
三、限流的算法
实现限流有很多办法,几种最常用的限流算法:
- 固定窗口计数器;
- 滑动窗口计数器;
- 漏桶;
- 令牌桶。
对应本次内容要讲解的重点Redisson分布式限流器背后就是令牌桶的算法。
其余几种算法,因为不是本次的重点,大家如果感兴趣可以自己进行相关查询即可。
令牌桶算法
令牌桶算法概念如下:
- 令牌以固定速率生成;
- 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行;
- 如果桶空了,那么尝试取令牌的请求会被直接丢弃。
令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法。
四、Redisson分布式限流器
Redisson库中的RRateLimiter实现了分布式限流,关于Redission可能很多人都有所耳闻,它其实是在Redis能力上构建的开发库,除了支持Redis的基础操作外,还封装了布隆过滤器、分布式锁、限流器……等工具。今天要说的RRateLimiter及时其实现的限流器。接下来详细介绍下RRateLimiter的具体使用方式、实现原理还有一些注意事项,最后简单谈谈我对分布式限流底层原理的理解。
RRateLimiter使用
??RRateLimiter的使用方式异常的简单,参数也不多。只要创建出RedissonClient,就可以从client中获取到RRateLimiter对象,直接看代码示例。
1.1 创建限流器
/**
* Returns rate limiter instance by name
*
* @param name of rate limiter
* @return RateLimiter object
*/
RRateLimiter getRateLimiter(String name);
/**
* Initializes RateLimiter's state and stores config to Redis server.
*
* @param mode - rate mode
* @param rate - rate
* @param rateInterval - rate time interval
* @param rateIntervalUnit - rate time interval unit
* @return true if rate was set and false otherwise
*/
boolean trySetRate(RateType mode, long rate, long rateInterval, RateIntervalUnit rateIntervalUnit);
trySetRate 用于设置限流参数。其中 RateType 包含 OVERALL 和 PER_CLIENT 两个枚举常量,分别表示全局限流和单机限流。后面三个参数表明了令牌的生成速率,即每 rateInterval 生成 rate 个令牌,rateIntervalUnit 为 rateInterval 的时间单位。
/**
* Acquires a specified permits from this RateLimiter,
* blocking until one is available.
*
* Acquires the given number of permits, if they are available
* and returns immediately, reducing the number of available permits
* by the given amount.
*
* @param permits the number of permits to acquire
*/
void acquire(long permits);
/**
* Acquires the given number of permits only if all are available
* within the given waiting time.
*
* Acquires the given number of permits, if all are available and returns immediately,
* with the value true, reducing the number of available permits by one.
*
* If no permit is available then the current thread becomes
* disabled for thread scheduling purposes and lies dormant until
* the specified waiting time elapses.
*
* If a permits is acquired then the value true is returned.
*
* If the specified waiting time elapses then the value false
* is returned. If the time is less than or equal to zero, the method
* will not wait at all.
*
* @param permits amount
* @param timeout the maximum time to wait for a permit
* @param unit the time unit of the timeout argument
* @return true if a permit was acquired and false
* if the waiting time elapsed before a permit was acquired
*/
boolean tryAcquire(long permits, long timeout, TimeUnit unit);
acquire 和 tryAcquire 均可用于获取指定数量的令牌,不过 acquire 会阻塞等待,而 tryAcquire 会等待 timeout 时间,如果仍然没有获得指定数量的令牌直接返回 false。
@Slf4j
@SpringBootTest
class RateLimiterTest {
@Autowired
private RedissonClient redissonClient;
private static final int threadCount = 10;
@Test
void test() throws InterruptedException {
RRateLimiter rateLimiter = redissonClient.getRateLimiter("my_limiter");
rateLimiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
rateLimiter.tryAcquire(5, 3, TimeUnit.SECONDS);
latch.countDown();
log.info("latch count {}", latch.getCount());
}).start();
}
latch.await();
}
}
使用起来还是很简单的嘛,以上代码中的两种方式都是同步调用,但Redisson还同样提供了异步方法acquireAsync()和tryAcquireAsync()。
重点提示:不要被简单的demo迷惑了,demo中只是提供了操作的示例,如果想集成到项目,需要进行对应的针对性的开发,因为每个人对项目中限流操作的理解不同,所以,基于redisson的限流器的编码也不同,但是,对应的视线基本上是通过注解来进行接口限流的。因为受限于本文的篇幅,我们后面专门拿出来一张内容进行讲解项目中的具体实现。
如特别对限流内容集成到项目感兴趣,可添加文章最后的联系方式。
五、redisson分布式限流器的实现原理
RRateLimiter 在创建限流器时通过下面 Lua 脚本设置限流器的相关参数:
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]);
而获取令牌则是通过以下的 Lua 脚本实现:
-- 请求参数示例
-- KEYS[1] my_limiter
-- KEYS[2] {my_limiter}:value
-- KEYS[4] {my_limiter}:permits
-- ARGV[1] 3 本次请求的令牌数
-- ARGV[2] 1705396021850 System.currentTimeMillis()
-- ARGV[3] 6966135962453115904 ThreadLocalRandom.current().nextLong()
-- 读取 RRateLimiter.trySetRate 中配置的限流器信息
local rate = redis.call('hget', KEYS[1], 'rate'); -- 10 一个时间窗口内产生的令牌数
local interval = redis.call('hget', KEYS[1], 'interval'); -- 1000 一个时间窗口对应的毫秒数
local type = redis.call('hget', KEYS[1], 'type'); -- 0 全局限流
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
local valueName = KEYS[2]; -- {my_limiter}:value 当前可用令牌数字符串的 key
local permitsName = KEYS[4]; -- {my_limiter}:permits 授权记录有序集合的 key
-- 单机限流配置 无需考虑
if type == '1' then
valueName = KEYS[3];
permitsName = KEYS[5];
end;
-- 查询当前可用的令牌数 查询失败表明是首次请求令牌
local currentValue = redis.call('get', valueName);
if currentValue == false then -- 首次请求令牌
-- 单次请求的令牌数不能超过一个时间窗口内产生的令牌数
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
-- 更新当前可用令牌数以及令牌授权记录 {my_limiter}:permits
-- set {my_limiter}:permits 10
redis.call('set', valueName, rate);
-- zadd {my_limiter}:permits 1705396021850 6966135962453115904_1
redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
-- decrby {my_limiter}:permits 3
redis.call('decrby', valueName, ARGV[1]);
return nil;
else -- 再次请求令牌
-- 查询可以回收的令牌对应的授权记录 即一个时间窗口前的所有授权记录且包括一个时间窗口前这一时刻
-- 旧令牌回收的本质是新令牌的加入 如果一个令牌是在一个时间窗口前被分配的 那经过一个时间窗口后这个空出的位置应该已经由新令牌填充
-- zrangebyscore {my_limiter}:permits 0 1705396020850
local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); -- [1936135962853113704_2, 536135765023123704_5]
-- 统计可以回收的令牌数
local released = 0;
for i, v in ipairs(expiredValues) do
local random, permits = struct.unpack('fI', v);
-- released = released + 2
-- released = released + 5
released = released + permits;
end;
-- 删除授权记录并回收令牌
if released > 0 then
-- zrem {my_limiter}:permits 1936135962853113704_2 536135765023123704_5
redis.call('zrem', permitsName, unpack(expiredValues));
currentValue = tonumber(currentValue) + released;
-- incrby {my_limiter}:value 7
redis.call('set', valueName, currentValue);
end;
if tonumber(currentValue) < tonumber(ARGV[1]) then
-- 如果回收后可用令牌数仍然不足 返回需要等待的时间
-- zrangebyscore {my_limiter}:permits (1705396020850 1705396021850 withscores limit 0 1
local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), tonumber(ARGV[2]), 'withscores', 'limit', 0, 1);
local random, permits = struct.unpack('fI', nearest[1]);
-- 1705396021650 - 1705396021850 + 1000 = 800
return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);
else
redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
redis.call('decrby', valueName, ARGV[1]);
return nil;
end;
end;
即便是加了注释,相信你还是很难一下子看懂这段代码的,接下来我就以其在Redis中的数据存储形式,然辅以流程图让大家彻底了解其实现实现原理。
首先用RRateLimiter有个name,在我代码中就是xindoo.limiter,用这个作为KEY你就可以在Redis中找到一个map,里面存储了limiter的工作模式(type)、可数量(rate)、时间窗口大小(interval),这些都是在limiter创建时写入到的redis中的,在上面的lua代码中也使用到了。
次还俩很重要的key,valueName和permitsName,其中在我的代码实现中valueName是{xindoo.limiter}:value ,它存储的是当前可用的许可数量。我代码中permitsName的具体值是{xindoo.limiter}:permits,它是一个zset,其中存储了当前所有的许可授权记录(含有许可授权时间戳),其中SCORE直接使用了时间戳,而VALUE中包含了8字节的随机值和许可的数量,如下图:
{xindoo.limiter}:permits这个zset中存储了所有的历史授权记录,知道了这些信息,相信你也就理解了RRateLimiter的实现原理,我们还是将上面的那大段Lua代码的流程图绘制出来,整个执行的流程会更直观。
看到这大家应该能理解这段Lua代码的逻辑了,可以看到Redis用了多个字段来存储限流的信息,也有各种各样的操作,那Redis是如何保证在分布式下这些限流信息数据的一致性的?答案是不需要保证,在这个场景下,信息天然就是一致性的。原因是Redis的单进程数据处理模型,在同一个Key下,所有的eval请求都是串行的,所有不需要考虑数据并发操作的问题。在这里,Redisson也使用了HashTag,保证所有的限流信息都存储在同一个Redis实例上。
六、RRateLimiter使用时注意事项
6.1、RRateLimiter是非公平限流器
这个是我查阅资料得知,并且在自己代码实践的过程中也得到了验证,具体表现就是如果多个实例(机器)取竞争这些许可,很可能某些实例会获取到大部分,而另外一些实例可怜巴巴仅获取到少量的许可,也就是说容易出现旱的旱死 涝的涝死的情况。在使用过程中,你就必须考虑你能否接受这种情况,如果不能接受就得考虑用某些方式尽可能让其变公平。
6.2、限流的上限取决于Redis单实例的性能
从原理上看,RRateLimiter在Redis上所存储的信息都必须在一个Redis实例上,所以它的限流QPS的上限就是Redis单实例的上限,比如你Redis实例就是1w QPS,你想用RRateLimiter实现一个2w QPS的限流器,必然实现不了。那有没有突破Redis单实例性能上限的方式?单限流器肯定是实现不了的,我们可以拆分多个限流器,比如我搞10个限流器,名词用不一样的,然后每台机器随机使用一个限流器限流,实际的流量不就被分散到不同的限流器上了吗,总的限流上线不也就上来了。
6.3、分布式限流的本质
分布式限流的本质实际上就是协同,协同的本质就是信息交换,信息交换最重要的的就是信息的准确性和一致性。更简单粗暴理解,分布式限流的本质原理其实还是分布式数据一致性的原理,而限流只是数据结果的一种决策。所以只要以任何方式能让信息同步,且保证信息的正确性就可以实现一个分布式限流器了,这也是分布式限流器的本质思路。
以上为全部内容。
本文暂时没有评论,来添加一个吧(●'◡'●)