目的
在流量高峰期或因为活动、热点事件、恶意攻击等原因导致流量突增时,通过对请求流量速率进行限制,保证请求流量在合理范围内,避免因为超出预期的大流量导致服务整体性能下降、响应缓慢或不可用。在触发限流后,可以通过拒绝部分服务、等待、排队、降级等策略保护业务系统。
常用限流算法
固定窗口算法
- 定义:限制固定时间段内,特定api的最大请求量,如限制每秒最多请求100次,超出100次触发限流策略。
- 优点:实现简单直观
- 缺点:
- 请求分布不均,不能平滑流量,容易出现部分时刻“服务不可用”的表现。如1秒内,前10ms请求100次,导致后990ms全部请求被拒绝。即请求分布不均,部分请求在某个时刻聚集,导致这个时刻所属的时间窗口内的其他时刻被拒绝。
- 出现流量尖峰,容易导致单位时间超出服务器预期负载,如预期单位时间1s内请求100次,上一秒的最后10ms请求100次,下一秒的最早10ms请求100次,则在单位时间1秒内,出现200次请求,超出服务承载预期。
滑动窗口算法
定义
将一个时间区间划分为N个长度固定的小时间窗口,每一次请求考察当前时间往前N个连续时间窗口的请求量总和,如果超过阈值则触发限流策略。如配置请求阈值100,将1s时间划分为5个连续的时间窗口,每个窗口长度250ms,则意味这上一秒当前250ms加上一秒的最后750ms请求量不会100个请求。
优点
解决了固定窗口流量尖峰的问题,确保在任意时刻,过去窗口时间内的请求不会超出阈值。
缺点
- 不能解决请求分布不均的问题,即无法平滑流量
- 实现更复杂,需要维护时间窗口,占用内存更多,计算时间复杂度也相应变大。
令牌桶算法
定义
存在一个令牌桶计数器,每隔一定时间放入一块令牌,每次请求取出一块令牌。,令牌桶有最大容量限制,当计数器令牌数达到最大值,则不再放入,如果计数器令牌数为0,请求则触发限流策略。如每隔10ms放入一块令牌,最大令牌数为100,假设某个时刻令牌桶放满令牌,则这个时刻的前10ms能请求100次,清20ms能请求101次。
优点
- 解决了固定窗口流量尖峰的问题,确保在任意时刻,过去窗口时间内的请求不会超出阈值。
- 可以有效平滑流量,因为令牌桶的令牌是匀速放入的
- 相对滑动窗口更节省内存
缺点
实现复杂,时间复杂度高
注意事项
- 令牌桶预热
漏桶算法
定义
相对于令牌桶是固定速率放入令牌,在没有令牌的时候拒绝请求,漏桶则是固定数据漏出请求,当请求量过大,流入请求超过桶容量,则触发限流策略。具体实现时,可以将漏桶想象成一个有流量限制的先进先出的队列,在队列不为空情况下,每隔一定时间取出一个队列请求进行处理相应,如果队列已经满了,再来请求则触发限流相关策略。如限制每10ms漏出一个请求,桶容量为100个请求,则每10ms最多能处理一个请求。
优点
实现相对简单,可以限制服务请求速率,并且稳定在一个常速。
缺点
对于特发流量处理效率过低,在没有到达服务器负载阈值,也只能串行处理请求。
分布式限流
可以基于Redis实现分布式限流。
基于固定窗口的分布式限流实现
我们可以基于Redis来实现固定窗口的分布式限流算法:
可以为每个请求路径配置一个有超时时间的计数器,每次请求给计数器加一,如果超过限制阈值,则返回限流标识,具体实现如下:
// 限流桶
private static final String BUCKET = "BUCKET";
/**
* 基于redis的固定窗口分布式限流
* @param point 限流方法标识
* @param limit 限流阈值
* @param timeout 固定时间窗口大小
* @return 如果触发限流,返回-1,否则返回当前请求在固定窗口里的请求计数
*/
public long acquireTokenFromBucket(String point, int limit, int timeout) {
Jedis jedis = jedisPool.getResource();
try {
// 标识当前请求
Long counter = jedis.incr(BUCKET + point);
if (counter > limit) {
// 触发限流值
return -1;
}
if (counter <= 1) {
// 说明是第一个有效限流,之前的已经过期,需要设置过期时间
jedis.expire(BUCKET + point, timeout);
}
return counter;
} finally {
jedisPool.returnResource(jedis);
}
}
基于滑动窗口的分布式限流实现
基于滑动窗口的实现可以利用redis的sortedset,以时间为分数来维护一个滑动窗口,每次请求时,清理滑动窗口之前的计数,而后尝试在窗口内,以一个单调递增的计数插入一个记录,并计算其排名,如果排名超过限流阈值,说明当前请求在滑动窗口内超过请求次数阈值,则触发限流。
在具体算法中,我们维护了三个变量:
- BUCKET:任意时刻维护一个滑动窗口大小的限流桶,在redis中以sortedset实现,分数是一个递增计数,可以表示在滑动窗口内的请求顺序(排名)
- BUCKET_COUNT:维护一个递增计数,每次触发一次请求,会给计数器加一,用来标识当前请求的顺序。
- BUCKET_MONITOR:底层也是一个sortedset实现,和BUCKET不同的是,BUCKET_MONITOR的分数为时间戳,用于滑动窗口的边界计算,每次调用会先根据分数清理BUCKET_MONITOR滑动窗口外的请求记录,而后与BUCKET取交集,来完成对真正的限流桶BUCKET的滑动窗口清理。
具体限流算法如下:
// 限流桶
private static final String BUCKET = "BUCKET";
// 请求桶计数
private static final String BUCKET_COUNT = "BUCKET_COUNT";
// 用于记录请求时间,在超时后请求请求记录,只保留滑动窗口内的请求记录
private static final String BUCKET_MONITOR = "BUCKET_MONITOR";
/**
* 基于redis的固定窗口分布式限流
* @param point 限流方法标识
* @param limit 限流阈值
* @param timeout 滑动窗口大小
* @return 如果触发限流,返回null,否则返回当前请求标识作为token
*/
public String acquireTokenFromBucket(String point, int limit, long timeout) {
Jedis jedis = jedisPool.getResource();
try {
// 标识当前请求
String identifier = UUID.randomUUID().toString();
// 获取当前时间
long now = System.currentTimeMillis();
// 开启事务操作
Transaction transaction = jedis.multi();
// 移除负无穷时间到当前减去超时时间的请求信号,可以将timeout理解为一个滑动窗口
transaction.zremrangeByScore((BUCKET_MONITOR + point).getBytes(), "-inf".getBytes(), String.valueOf(now - timeout).getBytes());
// 初始化权重参数
ZParams params = new ZParams();
// 指定权重因子,1.0对应BUCKET + point,0.0对应BUCKET_MONITOR + point
params.weightsByDouble(1.0, 0.0);
// 取交集,将(BUCKET + point)的分数从-inf~(now - timeout)的信号数据消除
// 同时注意到(BUCKET_MONITOR + point)部分权重为0,即只保留(BUCKET + point)中(now - timeout)~now的数据
transaction.zinterstore(BUCKET + point, params, BUCKET + point, BUCKET_MONITOR + point);
//计数器自增
transaction.incr(BUCKET_COUNT);
List<Object> results = transaction.exec();
// 获取最后一个事务操作拿到的自增计数
long counter = (Long) results.get(results.size() - 1);
// 开始新事务
transaction = jedis.multi();
// 插入当前计数时间,便于超时清除
transaction.zadd(BUCKET_MONITOR + point, now, identifier);
// 添加请求记录,counter可以标识在当前请求滑动窗口内的请求顺序
transaction.zadd(BUCKET + point, counter, identifier);
// 计算当前请求在计数桶内的排名
transaction.zrank(BUCKET + point, identifier);
results = transaction.exec();
//获取排名,判断是否超过限制以判断是否触发限流
long rank = (Long) results.get(results.size() - 1);
if (rank < limit) {
// 未超过限制,返回当前请求标识作为token,标识获取成功
return identifier;
} else {
// 超过限流,清理之前放入redis中垃圾数据
transaction = jedis.multi();
transaction.zrem(BUCKET_MONITOR + point, identifier);
transaction.zrem(BUCKET + point, identifier);
transaction.exec();
}
// 返回null,表示获取token失败
return null;
} finally {
jedisPool.returnResource(jedis);
}
}
在实际处理中,需要额外以计数为分数的桶BUCKET,而非直接以时间戳标识请求顺序的BUCKET_MONITOR作为限流桶,主要是考虑到在分布式环境中,不同机器的时钟可能不一致,如果某一机器的时钟偏早,可能会导致它的排名考前,但实际已经到达限流阈值,导致限流不够精确。
实战应用
基于特定的限流算法,我们有以下基于具体应用场景的限流策略:
- 服务限流:通过正则或字符匹配,限制特定服务方法的单位时间请求流量。
- 单机限流:限制单台机器的单位时间服务请求流量
- 集群限流:限制特定集群多台机器的单位时间服务请求流量
- 集群限额:要求请求附带来源标识,限制特定来源的单位时间请求频次
- 集群配额:要求请求附带来源标识,限制特定来源的一段时间内的请求频次,区分于集群限额限制QPS,配额可能为需要限制一天总请求次数