常用限流算法与应用场景
一、概述
限流是在微服务接口时,面对高并发场景必须考虑的问题。现在限流算法主要有以下几种:
- 计数器
- 滑动窗口
- 漏斗算法
- 令牌桶算法
其中令牌桶算法变种还可以分为
- 单速率三色标记算法
- 双速率三色标记算法
二、计数器算法
2.1 简介
计数器法是限流算法里最简单也是最容易实现的一种算法。一般是限制一段时间能够通过的请求数,比如某个接口规定5秒钟的访问次数不能超过10次,那么我们我们可以设置一个计数器counter,其有效时间为5秒钟(即每5秒钟计数器就会被重置为0),每当一个请求过来的时候,counter就+1,如果counter的值大于等于10,就直接返回。
2.2 代码实现
/**
* 限制为5秒10次
*/
private Long rangeSeconds = 5L;
private Integer maxRate = 10;
/**
* 优点:编码简单
* 缺点:边界值统计不准确
*/
@RequestMapping(value = "setnxlimit")
public Object setnxlimit(Long userId) {
String key = "setnxlimit:userid:" + userId;
Long increment = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, rangeSeconds, TimeUnit.SECONDS);
if (increment > maxRate) {
return "限流了!";
}
return increment;
}
2.3 小结
- 优点:实现简单,容易理解。
- 缺点:流量曲线可能不够平滑,有“突刺现象”,这样会有两个问题:
- 一段时间内(不超过时间窗口)系统服务不可用。比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第1ms来了100个请求,然后第2ms-999ms的请求就都会被拒绝,这段时间用户会感觉系统服务不可用。
- 窗口切换时可能会产生两倍于阈值流量的请求。比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第999ms来了100个请求,窗口前期没有请求,所以这100个请求都会通过。再恰好,下一个窗口的第1ms有来了100个请求,也全部通过了,那也就是在2ms之内通过了200个请求,而我们设定的阈值是100,通过的请求达到了阈值的两倍。
三、滑动窗口
3.1 简介
滑动窗口(rolling window)。解决了计数器切换时可能会产生两倍于阈值流量请求的缺点。
滑动窗口的主要原理比较简单,将时间单位进行拆分,比如5秒的统计范围,会将它划分成5个1秒钟。当请求进来的时候,会判断当前请求属于哪个时间片,然后将对应的时间片的统计值+1,再判断当前值加上前4个时间片的总值是否超过设置的阈值。当时间已经到达第6个时间片时,会先删除第一个时间片。统计值会随着时间片的滚动不停的按照时间片进行统计。
具体要将单位时间拆分为多少片,要根据实际情况来决定。当然,毫无疑问的是切分的越小,毛刺现象也越少。系统统计也越准确,随之就是内存占用会越大,因为你的这个窗口的数组会更大。
3.2 解决方案
指定时间T内,只允许发生N次。我们可以将这个指定时间T,看成一个滑动时间窗口(定宽)。我们采用Redis的zset基本数据类型的score来圈出这个滑动时间窗口。在实际操作zset的过程中,我们只需要保留在这个滑动时间窗口以内的数据,其他的数据不处理即可。
- 每个用户的行为采用一个zset存储,score为毫秒时间戳,value也使用毫秒时间戳(比UUID更加节省内存)
- 只保留滑动窗口时间内的行为记录,如果zset为空,则移除zset,不再占用内存(节省内存)
3.3 实现
代码实现思路就是定义好分片数量,每个分片都有一个独立的计数器,所有的分片合计为一个数组。当请求来时,按照分片规则,判断请求应该划分到哪个分片中去。要判断是否超过阈值,就将前N个统计值相加,对比定义的阈值即可。
3.3.1 zset实现
/**
* 滑动窗口
* 优点:统计精准,可以解决边界值
* 缺点:如果限制范围长,则数据量可能会比较大
*/
@RequestMapping(value = "windowLimit")
public Object windowLimit(Long userId) {
String key = "windowLimit:userid:" + userId;
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
long currentTimeMillis = System.currentTimeMillis();
long startTimeMillis = currentTimeMillis - rangeSeconds * 1000;
zSetOperations.add(key, currentTimeMillis + "", currentTimeMillis);
//干掉当前时间5秒前的数据
zSetOperations.removeRangeByScore(key, 0, startTimeMillis);
//统计
Long count = zSetOperations.zCard(key);
//加个过期时间,防止数据过多
redisTemplate.expire(key, rangeSeconds, TimeUnit.SECONDS);
if (count > maxRate) {
return "限制流了.当前:" + count;
}
return count;
}
3.3.2 pipelined实现
/**
* 判断行为是否被允许
*
* @param period 限流周期
* @param maxCount 最大请求次数(滑动窗口大小)
* @return
*/
public boolean isActionAllowed(int period, int maxCount) {
String key = "windowLimit:userid:" + userId;
long ts = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
pipe.multi();
pipe.zadd(key, ts, String.valueOf(ts));
// 移除滑动窗口之外的数据
pipe.zremrangeByScore(key, 0, ts - (period * 1000));
Response<Long> count = pipe.zcard(key);
// 设置行为的过期时间,如果数据为冷数据,zset将会删除以此节省内存空间
pipe.expire(key, period);
pipe.exec();
pipe.close();
return count.get() <= maxCount;
}
3.3.3 lua实现
/**
* lua脚本限流
*
* @param period
* @param maxCount
* @return
*/
public boolean isActionAllowedByLua(int period, int maxCount) {
String luaScript = this.buildLuaScript();
String key = "windowLimit:userid:" + userId;
long ts = System.currentTimeMillis();
System.out.println(ts);
ImmutableList<String> keys = ImmutableList.of(key);
ImmutableList<String> args = ImmutableList.of(String.valueOf(ts),String.valueOf((ts - period * 1000)), String.valueOf(period));
Number count = (Number) jedis.eval(luaScript, keys, args);
return count != null && count.intValue() <= maxCount;
}
/**
* 针对某个key使用lua脚本限流
*
* @return
*/
private String buildLuaScript() {
return "redis.call('ZADD', KEYS[1], tonumber(ARGV[1]), ARGV[1])" +
"\nlocal c" +
"\nc = redis.call('ZCARD', KEYS[1])" +
"\nredis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[2]))" +
"\nredis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))" +
"\nreturn c;";
3.4 小结
- 避免了计数器固定窗口算法固定窗口切换时可能会产生两倍于阈值流量请求的问题;
- 和漏斗算法相比,新来的请求也能够被处理到,避免了漏斗算法的饥饿问题。
四、漏斗限流算法
4.1 简介
漏斗(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,而当入小于出的情况下,漏桶不起任何作用。可以看出漏桶算法能强行限制数据的传输速率。
4.2 实现
/**
* 漏斗算法限流<br>
*/
@RequestMapping(value = "funnelLimit")
public Object funnelLimit(Long userId) {
String key = "funnelLimit:userid:" + userId;
HashOperations<String, Object, Object> opsForHash = redisTemplate.opsForHash();
Object capacityValue = opsForHash.get(key, "capacity");
Object passRateValue = opsForHash.get(key, "passRate");
if (capacityValue == null) {
//初始化漏斗容量,最多10个请求,
opsForHash.put(key, "capacity", maxRate.longValue() + "");
//初始化上次操作时间
opsForHash.put(key, "lastOperationTime", System.currentTimeMillis() + "");
//通过速率,每秒最多处理几个请求,2杯水
opsForHash.put(key, "passRate", 2L + "");
//当前水量
opsForHash.put(key, "currentWater", 0L + "");
return true;
} else {
//获取出上次的请求时间
Long lastOperationTime = Long.valueOf(opsForHash.get(key, "lastOperationTime") + "");
//当前时间
Long currentTimeMillis = System.currentTimeMillis();
//计算本次请求到上次请求期间的水量;将时间差转为秒,再乘以每秒允许通过的速率;水继续流着
long waterPass = ((currentTimeMillis - lastOperationTime) / 1000)
* Long.valueOf(passRateValue + "");
//获取出当前水量
Long currentWater = Long.valueOf(opsForHash.get(key, "currentWater") + "");
//桶里的水量-这段期间应该通过的水量
currentWater = Math.max(0, currentWater - waterPass);
//判断桶内剩余空间是否足够;
if (Long.valueOf(capacityValue + "") >= currentWater + 1) {
//允许通过;加水
currentWater = currentWater + 1;
opsForHash.put(key, "currentWater", currentWater + "");
opsForHash.put(key, "lastOperationTime", currentTimeMillis + "");
return true;
} else {
return "已限流!" + (currentWater + 1);
}
}
}
4.3 小结
- 漏桶的漏出速率是固定的,可以起到整流的作用。即虽然请求的流量可能具有随机性,忽大忽小,但是经过漏斗算法之后,变成了有固定速率的稳定流量,从而对下游的系统起到保护作用。
- 不能解决流量突发的问题。假设漏斗速率是2个/秒,然后突然来了10个请求,受限于漏斗的容量,只有5个请求被接受,另外5个被拒绝。你可能会说,漏斗速率是2个/秒,然后瞬间接受了5个请求,这不就解决了流量突发的问题吗?不,这5个请求只是被接受了,但是没有马上被处理,处理的速度仍然是我们设定的2个/秒,所以没有解决流量突发的问题。而接下来我们要谈的令牌桶算法能够在一定程度上解决流量突发的问题。
五、令牌桶限流算法
5.1 简介
令牌桶算法(token bucket)和漏斗限流算法效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了,令牌就溢出了。如果桶未满,令牌可以积累。新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务。
5.2 实现
/**
* 令牌桶
*/
@RequestMapping(value = "tokenBucketLimit")
public Object tokenBucketLimit(Long userId) {
String key = "tokenBucketLimit:userid:" + userId;
//令牌桶容量
int tokenBucketCapacity = 10;
//token生成速率
int tokenProduceRate = 2;
Object lastOperationTime = redisTemplate.opsForHash().get(key, "lastOperationTime");
if (lastOperationTime == null) {
//令牌桶初始化
redisTemplate.opsForHash().put(key, "lastOperationTime", System.currentTimeMillis() + "");
//当前剩余令牌数
redisTemplate.opsForHash().put(key, "leftTokenCount", 0 + "");
} else {
Long lastOperationTimeValue = Long.valueOf(lastOperationTime + "");
Object leftTokenCountValue = redisTemplate.opsForHash().get(key, "leftTokenCount");
//剩余令牌数
Long leftTokenCount = Long.valueOf(leftTokenCountValue + "");
//距离上一次请求产生的token数
long newGenrateToken = ((System.currentTimeMillis() - lastOperationTimeValue) / 1000)
* tokenProduceRate;
leftTokenCount = Math.min(tokenBucketCapacity, leftTokenCount + newGenrateToken);
if ((leftTokenCount) > tokenBucketCapacity) {
//剩余token数自减
redisTemplate.opsForHash().put(key, "leftTokenCount", (--leftTokenCount) + "");
return true;
} else {
return "已限流";
}
}
return null;
}
5.3 小结
令牌桶算法是对漏桶算法的一种改进,除了能够在限制调用的平均速率的同时还允许一定程度的流量突发。
六、总结
计数器固定窗口算法实现简单,容易理解。和漏斗算法相比,新来的请求也能够被马上处理到。但是流量曲线可能不够平滑,有“突刺现象”,在窗口切换时可能会产生两倍于阈值流量的请求。而计数器滑动窗口算法作为计数器固定窗口算法的一种改进,有效解决了窗口切换时可能会产生两倍于阈值流量请求的问题。
漏斗算法能够对流量起到整流的作用,让随机不稳定的流量以固定的速率流出,但是不能解决流量突发的问题。令牌桶算法作为漏斗算法的一种改进,除了能够起到平滑流量的作用,还允许一定程度的流量突发。
以上四种限流算法都有自身的特点,具体使用时还是要结合自身的场景进行选取,没有最好的算法,只有最合适的算法。比如令牌桶算法一般用于保护自身的系统,对调用者进行限流,保护自身的系统不被突发的流量打垮。如果自身的系统实际的处理能力强于配置的流量限制时,可以允许一定程度的流量突发,使得实际的处理速率高于配置的速率,充分利用系统资源。而漏斗算法一般用于保护第三方的系统,比如自身的系统需要调用第三方的接口,为了保护第三方的系统不被自身的调用打垮,便可以通过漏斗算法进行限流,保证自身的流量平稳的打到第三方的接口上。