简介
本文将对常见的限流算法进行学习,理解实现原理,分析他们的应用场景,对比他们之间的不同。常见的限流算法如下:
- 计数器限流算法
- 滑动窗口限流算法
- 漏桶算法
- 令牌桶算法
一、计数器限流算法
算法概述:定义一个时间窗口和最大的请求数,统计在时间窗口内的请求数量,如果超过了最大请求数就会触发限流。
代码实现:
private final static int MAX_COUNT = 20; //单位时间内允许的最大请求数量
private final static long TIME_WINDOW = 1000L;//单位时间
private long timestamp = System.currentTimeMillis();//当前时间
private int count = 0;
@Override
public synchronized boolean limit() {
long now = System.currentTimeMillis();
if(timestamp + TIME_WINDOW > now){
//在时间窗口内,判断请求数量是否超过限制
return ++count > MAX_COUNT;
}else{
//开启一个新的计数窗口
timestamp = System.currentTimeMillis();
count = 1;
}
return false;
}
问题分析:
这种固定窗口的实现最简单,但是存在的问题也最明显。关键点在于他的窗口是固定的,无法处理在窗口边界处的流量。假想一下如果请求发生在前一个窗口的后半段和后一个窗口的前半段,他们在各自的窗口内都没有超过最大请求数,但是加在一起可能达到2N倍的请求。如下图前1s最后请求量100,后1s开始请求量也是100,他们在各自的窗口内都没有超过100,但是加在一起达到了200.
所以这种限流实现,很少用在实际的生产中
,他的存在主要是为了引出滑动窗口限流算法。
二、滑动窗口限流算法
算法概述:为了解决固定窗口边界的问题,滑动窗口统计的时间窗口不再是固定的,他会在新的请求到来的时候动态的计算一个时间窗口,也就是 (当前请求时间-窗口时间),来实现窗口的动态滑动更新。
这里提供纯JAVA和Redis+Lua两种实现方式。其中纯JAVA实现适用于单体服务,而Redis更适用于分布式服务。
1.JAVA实现
定义一个双端队列Deque<Long>
,队列中存放的是每个请求发生的时间。每当有新的请求发生的时候,先计算当前的窗口(now - TIME_WINDOW),将窗口之外的请求出队。然后再统计窗口内的请求数量是否超过最大值。
//时间窗口
private final static long TIME_WINDOW = 1000L;
//单位时间内的最大访问量
private final static int MAX_COUNT = 20;
//定义一个队列来放请求
private final Deque<Long> requestQueue = new LinkedList<>();
@Override
public synchronized boolean limit() {
long now = System.currentTimeMillis();
long startTime = now - TIME_WINDOW;
while(!requestQueue.isEmpty() && requestQueue.peekFirst() < startTime){
//排除在窗口外的请求
requestQueue.pollFirst();
}
int curReqCount = requestQueue.size();
if (++curReqCount > MAX_COUNT){
return true;
}
requestQueue.addLast(now);
return false;
}
2.Redis实现
基于redis中的zset数据结构,原理和上面一样。 每当有请求进来的时候
- 先计算新窗口,删除窗口前的数据,这里用的是zremrangeByScore方法。 其中score存的是每个请求的时间
redis.call(‘zremrangeByScore’, KEYS[1], 0, tonumber(ARGV[2])-tonumber(ARGV[1])) - 判断请求是否超过阈值,如果超过直接拒绝,如果没有就调用zadd方法将请求添加的set中。
- 将redis操作都封装到LUA脚本中,保证了操作的原子性。
JAVA代码
@Autowired
private StringRedisTemplate redisTemplate;
//LUA脚本
private static DefaultRedisScript<Long> redisScript;
//时间窗口
private final static long TIME_WINDOW = 1000L;
//单位时间内的最大访问量
private final static int MAX_COUNT = 3;
static {
redisScript = new DefaultRedisScript<>();
redisScript.setResultType(Long.class);
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limit.lua")));
}
@Override
public boolean limit() {
//可以根据业务来设定唯一的key
String key = "UNIQUE_BY_BUSINESS";
//当前请求时间
long now = System.currentTimeMillis();
//value不要直接设置为当前时间戳,高并发的情况下可能导致分数一样,而set集合中重复的元素是会覆盖的,导致统计的请求数少于实际的
//可以采用雪花算法或者UUID
//hutool工具类提供的雪花算法
long value = IdUtil.getSnowflake().nextId();
try {
Long res = redisTemplate.execute(redisScript, Collections.singletonList(key), String.valueOf(TIME_WINDOW), String.valueOf(now), String.valueOf(MAX_COUNT), String.valueOf(value));
return Objects.nonNull(res) && res.intValue() == 1;
} catch (Exception e) {
//redis挂了或者lua执行出错了, 需要系统告警,这里先放行
System.out.println("execute lua fail ====" + e.getCause());
return false;
}
}
limit.lua
--KEYS[1]: key
--ARGV[1]: 窗口
--ARGV[2]: 当前时间戳score
--ARGV[3]: 阈值
--ARGV[4]: scoreValue
--1.删除开始窗口前的数据
redis.call('zremrangeByScore', KEYS[1], 0, tonumber(ARGV[2])-tonumber(ARGV[1]))
--2.统计当前元素数量
local res = redis.call('zcard', KEYS[1])
--3.是否超过阈值
if (res == nil) or (res < tonumber(ARGV[3])) then
redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
redis.call('expire', KEYS[1], tonumber(ARGV[1])/1000)
return 0
else
return 1
end
三、漏桶算法
算法概述
漏桶算法,将请求放到一个桶中,桶以固定的速率放出请求,超过桶容量的请求将被拒绝。
优点:稳定的流量控制,易于实现。
缺点:缺乏弹性,无法应对突发流量。
适用于稳定流量处理,且对突发流量要求不高。比如视频流的传输,以恒定速率进行传输避免卡顿。
简单代码实现
//定义桶的消息
private final static int capacity = 3;
//单位时间处理的请求
private final static int rate = 3;
//桶内已有请求数量
private double water = 0;
private long timeStamp = System.currentTimeMillis();
@Override
public synchronized boolean limit() {
long now = System.currentTimeMillis();
water = Math.max(0, water - (now - timeStamp)/1000.0 * rate);
timeStamp = now;
return ++water > capacity;
}
四、令牌桶算法
算法概述
每个请求到的时候会先尝试从令牌桶中获取令牌,如果获取成功放行,失败则限流。令牌以一定的速率添加,可以自己控制添加令牌的速度,使得限流更加平滑。
优点:灵活性高,令牌桶算法可以根据实际情况动态调整生成令牌的速率,从而实现较高精度的限流。
比如Sentinel中的预热模式限流就是采用令牌桶算法来实现的,适用于系统刚启动的时候需要创建各种资源,此时并发能力相对较差,等后续系统稳定后并发能力会逐渐提升。这时候只要控制开始的时候令牌生成的速度慢一些,等系统稳定后在提升令牌生成速度。
Redisson和guava中都有令牌桶算法的实现,可以直接用
Redisson实现
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.27.2</version>
</dependency>
//引入redisson客户端
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://ip:port").setPassword("password");
return Redisson.create(config);
}
//具体实现
@Autowired
private RedissonClient redissonClient;
private RRateLimiter rateLimiter;
//单位时间内添加令牌的速度
private static final long RATE = 3;
//时间间隔
private static final long INTERVAL = 1;
@PostConstruct
public void init(){
rateLimiter = redissonClient.getRateLimiter("uniqueKey");
rateLimiter.setRate(RateType.OVERALL, RATE, INTERVAL, RateIntervalUnit.SECONDS);
}
@Override
public boolean limit() {
return !rateLimiter.tryAcquire();
}
Guava实现
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.3.1-jre</version>
</dependency>
private RateLimiter rateLimiter;
@PostConstruct
public void init(){
rateLimiter = RateLimiter.create(3);
}
@Override
public boolean limit() {
return !rateLimiter.tryAcquire();
}
总结
限流在微服务中使用的场景很多,有很多组件都实现了限流。如网关SpringCloundGateway、Nginx,阿里的限流框架Sentinel, Google的Guava等,他们的底层也使用到了上述的限流算法,平时我们可以根据需要灵活的选择需要使用哪种。