当前大多数项目都是分布式架构,某些情况下高并发的场景需要对请求进行限流。如果是单节点我们可以使用google为我们提供的guava包下的RateLimiter进行限流,它使用的是令牌桶算法,分布式场景下也可以使用网关进行限流,如Spring Clound Gateway,其实还有很多开源的限流框架如阿里的Sentinel,甚至我们可以利用redis+lua脚本自己来实现限流。
下面简单介绍一下常见的限流算法
1.固定窗口计数法
在这种算法中,将一段时间划分为多个时间窗口,在每个窗口内进行请求时会进行请求数+1计数,当请求数超出限制时,其余的请求会被丢失,当一个窗口时间结束时,将当前请求数置为0。但这种方式存在一个很大的缺点,在两个相邻的窗口的交界处,可能会出现双倍的请求数,如下图所示:
假设一个窗口内请求数最大为5个,那么如果它在上一个窗口的后半段发出5个请求,在当前窗口的前半段发出5个请求,此时相当于在一个完整的窗口时间内总共通过了双倍的请求,超出了系统单位时间请求处理数最大限制,有可能造成系统崩溃。
2.滑动窗口计数法
该算法中会将一个时间窗口划分成一个一个的小区间,在每一个区间内对请求数进行统计,然后进行加和,这种算法避免了固定窗口计数器带来的双倍突发请求,当新的窗口区间到来时就抛弃最老的一个区间,如果想要提高精度就需要将单位时间窗口内区间划分的越多。
3.漏桶算法
漏桶算法是将请求当成水滴,滴入桶中;桶按照固定的速率漏出水滴进行请求处理;如果水滴滴入的速率大于漏出的速率,那么桶就有可能装满,桶满后多余的请求会被直接丢弃。漏桶算法大多用有界队列实现,服务的提供方则按照固定的速率从队列中取出请求并执行,过多的请求则放在队列中排队或直接拒绝。漏桶算法的缺点也很明显,当有瞬时大量请求到来时,即使当前服务并没有任何负载,那么它也需要在桶中等待一段时间后才能被运行。
4.令牌桶算法
该算法是以固定的速率生成令牌放入桶中,每一个请求到来都需要从桶中获取令牌,获取成功则可以进行执行,否则则被丢弃;如果桶中的令牌满了,那么多余令牌会被直接丢弃;如果桶空了请求也会被直接丢弃。文章开头说的googel的guava中的RateLimiter就是一个单点实现的令牌桶。
从上面分析可知,令牌桶是比较合适的限流算法,我们可以使用redis实现自己的限流。下面简单给出分布式限流lua脚本,令牌桶初始默认是满的,它的过期时间就是从当前桶中令牌数量恢复到最大值所需的时间。
//利用redis的hash结构,存储key所对应令牌桶的上次获取时间和上次获取后桶中令牌数量
local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token_num')
local last_time = ratelimit_info[1]
local current_token_num = tonumber(ratelimit_info[2])
//tonumber是将value转换为数字,此步是取出桶中最大令牌数、生成令牌的速率(每秒生成多少个)、当前时间
local max_token_num = tonumber(ARGV[1])
local token_rate = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
//reverse_time 即多少毫秒生成一个令牌
local reverse_time = 1000/token_rate
//如果current_token_num不存在则说明令牌桶首次获取或已过期,即说明它是满的
if current_token_num == nil then
current_token_num = max_token_num
last_time = current_time
else
local past_time = current_time-last_time //计算出距上次获取已过去多长时间
local reverse_token_num = math.floor(past_time/reverse_time) //在这一段时间内可产生多少令牌
current_token_num = current_token_num +reverse_token_num
last_time = reverse_time * reverse_token_num + last_time
if current_token_num > max_token_num then
current_token_num = max_token_num
end
end
local result = 0
if(current_token_num > 0) then
result = 1
current_token_num = current_token_num - 1
end
//将最新得出的令牌获取时间和当前令牌数量进行存储,并设置过期时间
redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token_num',current_token_num)
redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token_num - current_token_num)+(current_time-last_time)))
return result
下面再给出来如何使用redis调用lua脚本
@Bean
public RedisScript RedisReteLimitScript() throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver( getClass().getClassLoader() );
Resource[] resource = resolver.getResources( "classpath:redislock/RedisReteLimitScript.lua");
String script = StreamUtils.copyToString( resource[0].getInputStream(), StandardCharsets.UTF_8 );
return new DefaultRedisScript<>( script, String.class );
}
public boolean rateLimit(String key, int max, int rate) {
List keyList = new ArrayList<>(1);
keyList.add(key);
return "1".equals(stringRedisTemplate.execute(
new RedisReteLimitScript(),
keyList,
Integer.toString(max),
Integer.toString(rate),
Long.toString(System.currentTimeMillis()))
);
}
RedisTemplate.execute(RedisScript
有一个小小的问题,由于此Lua脚本是通过请求时传入的时间做计算,因此务必保证分布式节点上获取的时间同步,如果时间不同步会导致限流无法正常运作。
在lua脚本中直接使用会报错
local now = redis.call(‘time’)[1]
redis.call('SET','now ',now);
在redis4.0中使用了redis.replicate_commands() 支持了lua脚本中的随机操作,那么在lua脚本中也可以使用 如下命令直接获取当前时间,
redis.replicate_commands()
local now = redis.call(‘time’)[1]
redis.call('SET','now ',now);
由于redis集群存在主从当我们使用aof进行存储时,执行lua脚本时会将相同的脚本直接复制给salve和追加到aof中,也就是说同样的参数使用同样的脚本在主从节点执行应该是同样的结果,但是time命令执行时在主从节点时间是不同的,它们获得时间肯定不一样,reids不允许写命令出现在不确定命令之后。当使用redis.replicate_commands()命令复制模式之后,就只复制写命令,time的获取过程就直接以中间结果的形式在set写命令中一起存储,不再单独计算,持久化和复制的不再是整个Lua脚本,而是一个确定的中间值。