1. 常见的分布式限流算法
1.1 固定窗口算法
原理:固定窗口算法是一种简单的限流算法。在固定时间窗口内,记录请求的数量。例如,设定每10秒钟最多允许5次请求,如果在当前窗口内的请求数超过限制,则拒绝请求。
优点:简单易实现。
缺点:存在"流量突刺"的问题,也就是在窗口切换时可能会产生两倍于阈值流量的请求
适用场景: 适合于对于突发流量容忍度高的场景。
1.2 滑动窗口算法
原理: 滑动窗口算法在固定窗口的基础上,将一个计时窗口分成了若干个小窗口,然后每个小窗口维护一个独立的计数器。当请求的时间大于当前窗口的最大时间时,则将计时窗口向前平移一个小窗口。平移时,将第一个小窗口的数据丢弃,然后将第二个小窗口设置为第一个小窗口,同时在最后面新增一个小窗口,将新的请求放在新增的小窗口中。同时要保证整个窗口中所有小窗口的请求数目之后不能超过设定的阈值。
优点: 相比滑动窗口,它能够更精确地控制请求频率,避免了"流量突刺"问题。
缺点: 滑动窗口算法是固定窗口的一种改进,但从根本上并没有真正解决固定窗口算法的临界突发流量问题。实现相对复杂,要求记录每个时间窗口的请求数,需要更多的存储和计算资源。
适用场景: 比较适合对于不能容忍临界突发流量的场景。
1.3 漏桶算法
原理: 漏桶算法将请求放入一个桶中,桶以固定速率漏出请求。如果桶满了,新的请求将被丢弃。请求的流入速率可以不均匀,但漏出的速率是固定的,确保请求按照固定速率被处理。
优点: 适用于需要平滑处理突发流量的场景,能够把瞬时流量平滑到平稳流量。
缺点: 请求的流入速率过快会导致请求丢失,需要合理设置桶的容量和漏水速率。不支持突发流量。
适用场景: 例如保护数据库的限流,先把对数据库的访问加入到桶中,工作线程再以数据库能够承受的请求压力从桶中取出请求,去访问数据库。
1.4 令牌桶算法
原理:令牌桶算法是对漏斗算法的一种改进,除了能够起到限流的作用外,还允许一定程度的流量突发。在令牌桶算法中,存在一个令牌桶,算法中存在一种机制以恒定的速率向令牌桶中放入令牌。令牌桶也有一定的容量,如果满了令牌就无法放进去了。当请求来时,会首先到令牌桶中去拿令牌,如果拿到了令牌,则该请求会被处理,并消耗掉拿到的令牌;如果令牌桶为空,则该请求会被丢弃。
优点: 允许突发流量,且能平滑处理请求流量。
缺点:对桶的大小和令牌生成速率要求较高,对存储资源的需求较大,因为需要维护令牌桶。
适用场景: 适合电商抢购或者微博出现热点事件这种场景,因为在限流的同时可以应对一定的突发流量。如果采用漏桶那样的均匀速度处理请求的算法,在发生热点时间的时候,会造成大量的用户无法访问,对用户体验的损害比较大。
2. Redisson分布式限流的使用
Redisson 是一款基于 Redis 的 Java 分布式工具库。它封装了 Redis 的底层操作,提供了分布式锁、分布式集合、分布式队列、分布式执行器等功能,帮助开发者更高效地实现分布式系统。Redisson 提供了一系列高级的数据结构接口(如 RMap
, RSet
, RLock
等),并支持多种部署模式(单节点、主从、哨兵、集群等)。
在生产环境下,我们使用的更多的是其分布式锁功能,但是Redisson还提供了分布式限流的功能,相比于 Guava 提供的单机限流、Sentinel提供的接口限流,Redisson的分布式限流更便于用户去自定义符合自身业务的限流规则。
2.1 引入Maven依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.43.0</version>
</dependency>
2.2 封装Redisson限流工具类
我这里针对Redisson做了二次封装,配合配置文件实现更灵活的限流策略
限流配置类RateLimitProperties
:
java
import lombok.Data;
import org.redisson.api.RateIntervalUnit;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@ConfigurationProperties("rate-limit")
@Data
public class RateLimitProperties {
private Config defaultConfig;
private List<Config> configs;
@Data
public static class Config {
private String keyPrefix;
private long timeOut;
private RateIntervalUnit rateIntervalUnit;
private long count;
}
/**
* 根据key获取对应匹配的前缀的限流配置
* @param key key
* @return 限流配置
*/
public Config getRateLimitConfig(String key) {
return configs.stream()
.filter(e -> key.startsWith(e.getKeyPrefix()))
.findFirst()
.orElse(defaultConfig);
}
}
限流配置文件application.yaml
:
yaml
# 限流配置
rate-limit:
# 默认限流配置,如果前缀没有匹配上,则使用默认配置
default-config:
model-name-prefix: default
time-out: 30
rate-interval-unit: seconds
count: 1
configs:
- key-prefix: test
time-out: 1
rate-interval-unit: seconds
count: 2
限流工具类RateLimitUtil
:
java
import com.gzb.app.config.RateLimitProperties;
import com.gzb.app.constant.RedisConstant;
import org.redisson.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 限流工具类
*/
@Component
public class RateLimitUtil {
@Autowired
private RateLimitProperties rateLimitProperties;
@Autowired
private RedissonClient redissonClient;
/**
* 尝试获取一个限流器许可,如果当前没有足够的令牌,调用线程将被阻塞,直到有足够的令牌
* @param key 限流器的唯一标识
*/
public void acquire(String key) {
getRedissonRateLimiter(key).acquire();
}
/**
* 尝试立即获取一个限流器许可,如果当前没有足够的令牌,立即返回 false
* @param key 限流器的唯一标识
* @return 成功:true 失败:false
*/
public boolean tryAcquire(String key) {
return getRedissonRateLimiter(key).tryAcquire();
}
/**
* 尝试在指定时间内获取一个限流器许可,如果在指定时间内没有足够的令牌,立即返回 false
* @param key 限流器的唯一标识
* @param time 等待的时间长度
* @param timeUnit 时间单位
* @return 成功:true 失败:false
*/
public boolean tryAcquire(String key, long time, TimeUnit timeUnit) {
return getRedissonRateLimiter(key).tryAcquire(time, timeUnit);
}
/**
* 获取限流器,如果限流器不存在,则根据配置创建一个新的限流器
* @param key 限流器的唯一标识
* @return RedissonRateLimiter 限流器实例
*/
public RRateLimiter getRedissonRateLimiter(String key) {
RateLimitProperties.Config config = rateLimitProperties.getRateLimitConfig(key);
long count = config.getCount();
long timeOut = config.getTimeOut();
RateIntervalUnit rateIntervalUnit = config.getRateIntervalUnit();
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
// 如果限流器不存在,就创建一个限流器
if (!rateLimiter.isExists()) {
rateLimiter.trySetRate(RateType.OVERALL, count, timeOut, rateIntervalUnit);
return rateLimiter;
}
checkIfConfigNeedUpdate(rateLimiter, config);
return rateLimiter;
}
/**
* 检查限流器配置是否发生修改,如果配置发生变化,则删除旧的限流器并重新创建
*
* @param rateLimiter 限流器
* @param rateLimitConfig 限流配置
*/
private void checkIfConfigNeedUpdate(RRateLimiter rateLimiter, RateLimitProperties.Config rateLimitConfig) {
long count = rateLimitConfig.getCount();
long timeOut = rateLimitConfig.getTimeOut();
RateIntervalUnit rateIntervalUnit = rateLimitConfig.getRateIntervalUnit();
// 获取之前限流器的配置信息
RateLimiterConfig config = rateLimiter.getConfig();
if (rateIntervalUnit.toMillis(timeOut) != config.getRateInterval() || count != config.getRate()) {
// 如果当前限流器的配置与配置文件不一致,说明服务器重启过
rateLimiter.delete();
// 配置文件为准,重新设置
rateLimiter.trySetRate(RateType.OVERALL, count, timeOut, rateIntervalUnit);
}
}
}
2.3 测试代码
java
@RestController
@Slf4j
public class RedissonController {
@Autowired
private RateLimitUtil rateLimitUtil;
@GetMapping("/rateLimit/{key}")
public void rateLimit(@PathVariable String key) {
log.info("{} start get access token, current time = {}", Thread.currentThread().getName(), System.currentTimeMillis());
rateLimitUtil.acquire(key);
log.info("{} successfully start get access token, current time = {}", Thread.currentThread().getName(), System.currentTimeMillis());
}
}
3. Redisson分布式限流原理
3.1 设置限流配置方法trySetRate
在使用限流器之前,都需要提前设置限流配置,否则限流代码会报错(后面会提到具体哪一行代码)
跟进RedissonRateLimiter
的trySetRate
方法,可以找到下图中的方法,可见设置限流配置很简单,只是一段很简单的Lua脚本,在脚本中往Hash
数据结构中写入了我们之前配置的限流参数。
3.2 acquire
限流方法原理
跟进RedissonRateLimiter
的acquire
方法,可以找到一段Lua脚本代码,在展开讲解Lua脚本之前,需要先了解一下限流需要用到哪些Redis键值
图片中我使用的key是test:
-
首先是{key},对应着图片中的test,是哈希数据类型,里面存储着限流配置,比如:rate、interval等信息,这里的keepAliveTime先不做考虑,与本次要讲解的核心限流逻辑无太大关系。
-
接着是{key}:value,对应着图片中的{test}:value,是字符串数据类型,value是一个数字,表示可用的令牌桶数量。
-
再接着是{key}:permits,对应着图片中的{test}:permits,是一个有序集合,保存着限流过程中的访问信息。有序集合中的value使用随机数和你要获取的token数量拼接作为value,防止value的重复,score存储着访问时候的时间戳。
lua
-- 获取限流器的配置值:rate、interval 和 type
local rate = redis.call('hget', KEYS[1], 'rate'); -- 获取限制速率(每秒允许的请求次数)
local interval = redis.call('hget', KEYS[1], 'interval'); -- 获取限流的时间间隔(单位通常为秒)
local type = redis.call('hget', KEYS[1], 'type'); -- 获取限流类型(如 OVERALL 或 PRE_WARMING)
-- 如果配置没有初始化(即获取的值为空),抛出错误
-- 这里对应着一开始要设置好限流配置参数,否则Lua脚本执行过程会抛出异常
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
-- 当前请求令牌数的键
local valueName = KEYS[2];
-- 请求令牌记录的键
local permitsName = KEYS[4];
-- 如果限流类型是 PRE_WARMING,调整键值名
if type == '1' then
valueName = KEYS[3]; -- 如果是 PRE_WARMING,则使用不同的令牌数键
permitsName = KEYS[5]; -- 如果是 PRE_WARMING,则使用不同的令牌记录键
end;
-- 检查请求的令牌数是否超过了令牌桶的大小
-- 例如:rate设置为10,在acqure的时候大于10,Lua脚本就会报错
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount cannot exceed defined rate');
-- 获取当前剩余可用的令牌数
local currentValue = redis.call('get', valueName);
-- 变量 res 用于存储返回值,指示是否可以获取令牌
-- 如果是nil表示令牌充足,获取令牌成功
-- 如果是一个数字,则表示令牌不足,需要要等待的时间
local res;
-- 表明不是第一次获取令牌
if currentValue ~= false then
-- 获取在过去一段时间内过期的请求记录
local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
local released = 0; -- 已释放的令牌数
-- 统计过期的请求记录中的令牌数
for i, v in ipairs(expiredValues) do
local random, permits = struct.unpack('Bc0I', v);
released = released + permits;
end;
-- 如果有过期的令牌,进行释放处理
if released > 0 then
-- 从令牌记录中移除过期的项
redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
-- 以下if-else代码用于保证可用的令牌个数和令牌访问记录中使用的令牌个数之和小于桶的容量rate
if tonumber(currentValue) + released > tonumber(rate) then
local values = redis.call('zrange', permitsName, 0, -1); -- 获取所有令牌记录
local used = 0; -- 已使用的令牌数
-- 统计所有已使用的令牌数
for i, v in ipairs(values) do
local random, permits = struct.unpack('Bc0I', v);
used = used + permits;
end;
-- 调整当前令牌数,确保不会超过桶的大小
currentValue = tonumber(rate) - used;
else
-- 否则,直接增加释放的令牌数
currentValue = tonumber(currentValue) + released;
end;
-- 更新令牌数
redis.call('set', valueName, currentValue);
end;
-- 如果当前令牌数小于请求的令牌数,计算下一次可以获取令牌的时间
if tonumber(currentValue) < tonumber(ARGV[1]) then
local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores'); -- 获取最早的请求记录
res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2])); -- 计算等待时间
else
-- 如果当前令牌数足够,记录请求并减少令牌数
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;
-- 如果是第一次获取令牌,则设置当前可用的令牌数,添加访问记录,扣减可用令牌数量
else
-- 设置当前可用的令牌数
redis.call('set', valueName, rate);
-- 新增领票访问记录
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;
-- 返回结果,可能是等待时间或空值
-- 如果是nil表示令牌充足,获取令牌成功
-- 如果是一个数字,则表示令牌不足,需要要等待的时间
return res;
这里有一张流程图可以很好的演示Lua脚本的限流逻辑:
3.3 使用Redisson分布式限流的注意事项
-
限流配置Rate不要设置过大
从Lua脚本中我们可以看到,Rate的值限制着有序集合中的限流访问记录,如果Rate值过大可能会导致有序集合中存储的数据激增,从而Redis的内存增加,并且使得Lua脚本的执行效率下降,紧跟着就是限流功能的效率下降,倾向于小Rate+小时间窗口的方式,这种设置方式请求也会更均匀一些。
-
限流的上限取决于Redis的性能
RRateLimiter的限流能力受制于Redis实例的性能上限。例如,如果Redis实例的QPS上限是1w,那么通过RRateLimiter实现2w QPS限流是不可能的。要突破单个Redis实例性能的限制,可以通过拆分多个限流器来实现。具体做法是创建多个限流器,使用不同的名称,并在各台机器上随机选择一个限流器进行限流,这样总流量就可以被分散到多个限流器上,从而提升整体限流上限。