最近负责了两个接口层的项目,一个对接的是公司内部的爬虫,另一个对接的是公司外部的中航信。虽然接口层不涉及过多的业务,但是想把接口层做好也并不是那么容易的。遇到的一个很大的问题就是限流。数据组的同事经常反馈说我们的调用量在某一时间内过于频繁,导致抓取服务压力过大,而且频繁的请求有IP被封的危险。因此限流对那些下游是抓取的(或者下游有限流策略的)服务来说十分重要。
在谈限流策略前,我们首先明确一下什么是限流。这里说的限流并不具有一般性,只是针对面临到的业务场景。我们约定,本文中的限流就是限制一段时间内(多为秒级,这个时间在下文中也会被称为时间窗)的请求次数。例如,我们下游服务能承受的最大TPS为2,因此我们希望控制请求量每秒不超过两个。
(还有一点需要说明的就是TPS和QPS的区别,这一点在网上查了很多但是一直没有找到满意的回答,大多是针对浏览器端访问页面的,不适用我们的接口层。我个人认为,TPS是每秒事务数,可以认为是服务器每秒处理的请求数,QPS呢是每秒查询数。乍一看好像这两个一样,但是如果对方服务端也有限流策略就不同了。假设我们下游服务给我们提供的TPS是10,我们一秒发了20个请求过去,结果对方这1秒把所有请求都返回了,但是只处理了10个,多出来的10个快速失败。那么我们从流量统计上看QPS是20,但是实际处理的事务数(TPS)是10。以上均为个人观点,如有不正确指出还请指出)
下面介绍三种限流方式(这里只考虑限流,至于被限流后如何处理,是简单的丢弃还是将其加入重试队列,需要根据业务自行决定,这些暂不列入讨论范围内):
信号量是一种锁,严格意义上说它并不能够做到限流,只能限制并发数。适用于对上游服务的请求限流,但是无法对下游服务限流,尤其是下游服务已经有了限流策略的时候。
// 初始化
Semaphore semaphore = new Semaphore(limit);
// 获取执行权
public boolean acquire() {
return semaphore.tryAcquire();
}
// 释放执行权
public void release() {
return semaphore.release();
}
在对接中航信的服务时,对方给我们提供的最大TPS是10,我们一开始使用的是信号量的方式进行限流。但是发现有大量的请求被航信限流,后来降低信号量阈值,一直降低到6还是有大量请求被航信限流。后来猜测航信使用的限流策略是基于时间窗口的,1s内最多处理10个请求,即使一个时间窗口内只用前0.5s就处理完了10个请求,那么时间窗的后0.5s对所有请求返回失败,而且是快速失败,由于信号量只能限制并发量而不是单位时间的请求量,因此在这个时间窗的后0.5s内仍然有请求发送,这样就可以解释请求中有大量失败的现象了。后来和航信技术部门沟通果然是这样。举这个例子是想说明信号量并不契合限流这种场景,使用需谨慎。
rateLimiter是基于令牌桶限流算法的限流组件(令牌桶算法也就是每经过一段时间生成一些令牌放到桶里,每次执行从桶里获取若干个令牌)。它可以限制单机每秒访问数。而且在获取不到执行权时可以等待一段时间。
// 初始化
RateLimiter rateLimiter = RateLimiter.create(10);
// 获取执行权
public boolean acquire(String type) {
return rateLimiter.tryAcquire();
}
不过guava的RateLimiter有两个需要注意的点:
RateLimiter rateLimiter = RateLimiter.create(5);
for(int i = 0; i < 10; i++) {
System.out.println(rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS));
}
我们可以猜测一下这段代码的执行结果是什么样子的,正常情况下大家可能会猜测输出5个true和5个false交替出现,执行完的时间为1s,但是实际上只有第一个请求为true,其他的9个请求全为false,执行时间非常快,快到我们的请求根本就没有等待。后来看guava的注释才知道如果等待时间内不会有令牌产生则不会等待,而是直接快速失败。
Acquires a permit from this {@code RateLimiter} if it can be obtained without exceeding the specified {@code timeout}, or returns {@code false} immediately (without waiting) if the permit would not have been granted before the timeout expired.
这个方法实现过程中,关键信息是下面三个:
storedPermits 当前令牌桶中的剩余令牌数
freshPermits 需要新分配的令牌数
nextFreeTicketMicros 可以生产新令牌的时间(这里的新指的是没有被占用,因为ratelimiter允许预支未生成的令牌)
流程:
当执行tryAcquire方法时,首先判断超时时间内能否等到新令牌生产(因为存在令牌被预支的情况),如果不行那么快速返回false,否则开始计算等待时间
等待时间即为当前时间和nextFreeTicketMicros的差,这里可以看出等待时间并不是生产所有令牌的时间。假设我们的请求需要10个令牌,等待时间即为当前时间与生产出的第一个可用的令牌的时间差
计算出等待时间后,更新storedPermits和nextFreeTicketMicros的值
在使用过程中,我们一般调用的permit值为1,因此就称为了平滑限流。为了避免前面提到的的平滑限流导致限流失败的情况,我们一定要传入时间窗口。即避免使用RateLimiter#tryAcquire()方法,而是用带参数的RateLimiter#tryAcquire(long timeout, TimeUnit unit)方法,并且传入的超时时间不得小于时间窗的时间。
这个方法实现过程中,关键信息是下面三个:
流程:
1. 当执行tryAcquire方法时,首先判断超时时间内能否等到新令牌生产(因为存在令牌被预支的情况),如果不行那么快速返回false,否则开始计算等待时间
等待时间即为当前时间和nextFreeTicketMicros的差,这里可以看出等待时间并不是生产所有令牌的时间。假设我们的请求需要10个令牌,等待时间即为当前时间与生产出的第一个可用的令牌的时间差
2. 计算出等待时间后,更新storedPermits和nextFreeTicketMicros的值
在使用过程中,我们一般调用的permit值为1,因此就称为了平滑限流。为了避免前面提到的的平滑限流导致限流失败的情况,我们一定要传入时间窗口。即避免使用RateLimiter#tryAcquire()方法,而是用带参数的RateLimiter#tryAcquire(long timeout, TimeUnit unit)方法,并且传入的超时时间不得小于时间窗的时间。
使用redis限流很简单,由于squirel的incrBy方法是原子的。因此我们可以申请一个失效时间为1s的category,然后使用下面的方法进行限流,非常简单。
public boolean acquire(String key, long limit) {
StoreKey storeKey = new StoreKey(categoryName, key);
return storeClient.incrBy(storeKey, 1) <= limit;
}
如果我们的时间窗口不是1s,我们可以添加一行判断来设置失效时间:
public boolean acquire(String key, long limit) {
StoreKey storeKey = new StoreKey(categoryName, key);
long count = storeClient.incrBy(storeKey, 1);
if (count == 1) {
// 设置失效时间 如果并发量很大在incrBy和expire操作之间有较多请求进入会使得限流不准确,最好的方式还是将这两个操作合并为一个原子操作的在redis中执行
storeClient.expire(storeKey, invalid_time);
}
return count <= limit;
}
redis并不支持失败后的额外处理,例如重试或者等待。如果需要我们要自己实现这些策略,例如借助guava的retryer来实现这些功能。
我们设想如下情景。假设我们需要对接的下游业务方对我们的请求进行了限流,阈值为10次/s(非平滑),超过阈值则快速失败。为此我们在已经实现了10次/s的限流策略(使用redis)。那么我们是否就能够保证向业务方发送的请求都能成功了呢,答案是否定的。事实上出现最坏情况时平均每秒仍然会有5个请求被限流。(可以思考下什么时候会出现这种情况以及为什么不会有大于5个的请求被限流)
之所以会出现这种最坏情况是因为我们虽然实现了秒级的限流,但是在这1s的时间内请求并不是平滑的。什么意思呢,有可能在1s时间窗口内的前0.1s同时进来10个请求,后0.9s没有任何请求进入。如下图:
这也就导致了最坏情况的出现,因此我们除了实现秒级限流外最好使1s内的请求平滑传递给下游服务,从而避免因为时间窗口不匹配导致的失败问题。
限流策略 | 优点 | 缺点 |
---|---|---|
信号量 | 简单 | 单机限流;不契合限流场景,尤其是对下游业务方限流;阈值在应用初始化时设定,不支持动态修改 |
RateLimiter | 简单;支持失败后等待;可以使得时间窗内的请求平滑 | 仅限单机限流 |
redis | 全局限流 | 强依赖redis;未获取到执行权直接返回失败,如果添加重试或等待策略较为复杂;时间窗内的请求不平滑 |
如果应用对限流精度不是太高,RateLimiter就可以满足多数需要。如果需要使用redis进行限流,那么需要考虑降级方案,以及等待重试机制等。