背景
在互联网应用中,系统通常会面临一个重大挑战:大流量高并发访问。比如秒杀抢购等。短时间内巨大的访问流量下,如何让系统在处理高并发的同时还能保证自身系统的稳定?
机器不能无限制的加,在有限的资源下面对大流量访问,不得已采取其他措施来保护我们的后端系统,比如缓存,异步,降级,限流,静态化等。
限流
限流指的是:在高并发场景下对于请求进行限速来保护我们的系统,一旦达到限制的速度,我们可以
- 拒绝服务(提示友好信息或者跳转错误页面)
- 配对或者等待处理
- 降级(返回默认数据)
算法
-
计数器法
最简单粗暴的一种算法,比如说一个接口一分钟内的请求不能超过60次
- 划分时间窗口
- 窗口内请求,计数器加一
- 超过限制,本窗口内拒绝请求,等到下个窗口,计数器重置,再执行请求
应用场景:短信限流,比如一个用户一分钟内一条短信,可以使用计数器限流
问题:临界问题,两个时间窗口交界处,可能会执行任务,近似于通知执行两倍量的任务
-
漏桶算法
内部维护一个容器,以恒定的速度出水,不管上游的速度多快。请求到达时直接放入到漏桶中,如果容量达到上限(阈值),则进行丢弃(执行限流策略)。
漏桶算法无法短时间处理突发流量,是一种恒定的限流算法。
-
令牌算法(rateLimter)
令牌桶是网络流量整形(Traffic Shaping)和速度限制(Rate Limting)中最常使用的算法,对于每个请求,都需要从令牌桶中获得一个令牌,如果没有令牌则触发限流策略。
系统以一个恒定的速度往固定容量的容器放入令牌,如果有请求,则需要从令牌桶中取出令牌才能放行。
由于令牌桶有固定大小,当请求速度<生成令牌速度,令牌桶会被填满,因此令牌桶可以处理突发流量。
-
滑动窗口算法
在固定窗口划分多个小窗口,分别在小时间窗口里记录访问次数,随着时间推动窗口滑动并删除过期窗口,即去除之前的访问次数,最终统计所有小窗口的可访问总数。
demo:将1min分为4个小窗口,每个小窗口能处理25个请求。通过虚线表示窗口大小(当前窗口大小为2,所以能处理50个请求)。同时窗口可以随时间滑动,比如15s后,窗口滑动到(15s-45s)这个范围内,然后在新的窗口里进行统计
当滑动窗口的小时间窗口划分越多,那么滑动窗口的滑动就越平滑,限流的统计就更精确。
实现
分布式RateLimter
-
QuickStart
@Autowired
private RedissonClient redissonClient;
//声明限流器
RRateLimiter myRateLimiter = redissonClient.getRateLimiter("myRateLimiter");
//设置速率
myRateLimiter.setRate(RateType.OVERALL,10,1, RateIntervalUnit.SECONDS);
//尝试获得1个令牌,成功返回true
myRateLimiter.tryAcquire(1);
-
流程
暂时无法在文档外展示此内容
-
源码分析
-
声明限速器
public class RedissonRateLimiter extends RedissonExpirable implements RRateLimiter {
public RedissonRateLimiter(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
}
}
- 设置速率
public class RedissonRateLimiter extends RedissonExpirable implements RRateLimiter {
/**
* Initializes RateLimiter's state and stores config to Redis server.
* 初始化限流器状态,存储配置到redis服务器
*
* @param mode - rate mode
* @param rate - rate 速率
* @param rateInterval - rate time interval 时间间隔
* @param rateIntervalUnit - rate time interval unit 时间单位
* @return 成功返回true 否则返回false
*
*/
@Override
public boolean trySetRate(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
return get(trySetRateAsync(type, rate, rateInterval, unit));
}
@Override
public RFuture<Boolean> trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
return commandExecutor.evalWriteAsync(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());
}
}
- 获得令牌
/**
* 所有许可证都可用时,才获取给定数量的许可证
*/
@Override
public boolean tryAcquire(long permits) {
return get(tryAcquireAsync(RedisCommands.EVAL_NULL_BOOLEAN, permits));
}
private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {
return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
//获得之前设置的限流请求数,时间窗口大小,类型等信息
"local rate = redis.call('hget', KEYS[1], 'rate');"
+ "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')"
//valueName对应的是当前阈值的key
+ "local valueName = KEYS[2];"
//permitsName 是一个zset, 记录了请求的令牌数,score为请求的时间戳
+ "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); "
// 第一次请求currentValue是null, 所以进入else, else中会给currentValue赋值
+ "if currentValue ~= false then "
//滑动时间窗口:zset移除(-00, now - interval] 范围中中所有元素,重置周期 ARGV[2] = System.currentTimeMillis()
+ "local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); "
+ "local released = 0; "
+ "for i, v in ipairs(expiredValues) do "
// unpack 时获得过期请求的permits数
+ "local random, permits = struct.unpack('fI', v);"
//累加到要刷新的令牌数
+ "released = released + permits;"
+ "end; "
//released大于0,表示有可以恢复的令牌
+ "if released > 0 then "
//滑动时间窗口:zset移除(-00, now - interval] 范围中中所有元素,重置周期 ARGV[2] = System.currentTimeMillis()
+ "redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); "
//增加当前令牌数
+ "currentValue = tonumber(currentValue) + released; "
+ "redis.call('set', valueName, currentValue);"
+ "end;"
// 当前令牌数<请求令牌数,满足不了
+ "if tonumber(currentValue) < tonumber(ARGV[1]) then "
//找到最近那个请求
+ "local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), '+inf', 'withscores', 'limit', 0, 1); "
// 返回需要等待的时间,还需要多久才能有足够的令牌
+ "return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);"
+ "else "
//当前令牌数够用,更新zset
+ "redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); "
+ "redis.call('decrby', valueName, ARGV[1]); "
+ "return nil; "
+ "end; "
+ "else "
//第一次请求
+ "redis.call('set', valueName, rate); "
//pack时,将permits 请求数 也打包进去了
+ "redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); "
+ "redis.call('decrby', valueName, ARGV[1]); "
+ "return nil; "
+ "end;",
Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()),
value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong());
}
使用了滑动窗口算法