1.什么是限流器
限流器是在高并发场景下限制流量的工具。
常见的限流器的实现方式有以下几种:
- 计数器算法
- 漏桶算法
- 令牌桶算法
关于三种算法的优缺点可以参考这篇博客逐行拆解Guava限流器RateLimiter。
由于令牌桶算法相较于计算器算法和漏桶算法,没有明显的缺点,不会出现异常流量峰值也可以处理突发流量的情况,所以限流器实现上一般会使用令牌桶算法。
2.基于令牌桶算法实现一个限流器
下面我们基于令牌桶算法实现一个简单的限流器,限流器接口定义如下:
public interface IRateLimiter {
/**
* 返回true,表示拿到令牌,请求可以通过,
* 返回falase,表示没有拿到令牌,请求不能通过
* @return
*/
boolean tryAcquire();
}
下面我们自定义的限流器就实现这个接口, MyRateLimiter:
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class MyRateLimiter implements IRateLimiter {
private final AtomicInteger tokenBucket;
private final int qps;
// 生成一个令牌需要的微秒数
private final long mircroSecondsPerToken;
private final ScheduledExecutorService scheduledExecutorService =
Executors.newScheduledThreadPool(1);
/**
* 构造方法
* @param qps 每秒钟请求数量
*/
public MyRateLimiter(int qps) {
this.qps = qps;
this.tokenBucket = new AtomicInteger(qps);
// 生成一个令牌需要的微妙数
this.mircroSecondsPerToken = 1 * 1000 * 1000 / qps;
scheduledExecutorService.schedule(() -> {
/**
* 创建一个定时任务,每隔 ${mircroSecondsPerToken} 生成一个令牌
* 虽然判断”令牌桶是否满了“和“给令牌桶中添加一枚令牌”这两个操作不是原子的,
* 但是因为这两个操作是单线程的,且添加令牌只在这个线程中进行,所以这两步操作没有必要加锁来保证原子性
*/
if (this.tokenBucket.get() < qps) {
this.tokenBucket.incrementAndGet();
}
}, mircroSecondsPerToken, TimeUnit.MICROSECONDS);
}
@Override
public boolean tryAcquire() {
if (this.tokenBucket.get() < 1) {
return false;
} else {
synchronized (this) {
if (this.tokenBucket.get() < 1) {
return false;
} else {
this.tokenBucket.decrementAndGet();
}
}
return true;
}
}
}
上述实现的这个限流器其实是有缺陷的,mircroSecondsPerToken的类型是long类型,所以输入的qps必须是10^n,0<=n<=6。这里将mircroSecondsPerToken改成double类型的化,精度会更高一些,但是因为我们这里是使用ScheduledExecutorService来创建定时任务作为生产者来生产令牌,而ScheduledExecutorService的schedule方法中,时间大小只能为long,并没有提供时间单位为double的方法。
除此之外,还存在其他缺陷:我们使用一个定时线程去作为provider,来按照设置的频率往tokenBucket放令牌,当令牌桶是满且没有消费者来消费令牌,这个时候我们的provider线程还是会继续运行,尝试往tokenBucket中放入令牌,这就相当于是做无用功了,有点浪费资源。如果根据tokenBucket的状态来自动启停provider线程,又会极大的增加并发复杂度。
下面我们来看看GUAVA的RateLimiter是如何实现的,看看能否按照GUAVA RateLimiter的实现方式来优化上述我们自己的限流器。
3.GUAVA的RateLimiter
3.1.介绍
Guava RateLimiter是一个谷歌提供的限流工具,RateLimiter基于令牌桶算法,可以有效限定单个JVM实例上某个接口的流量。
3.2.关键代码分析
创建一个Guava RateLimiter,qps为2,注意这里qps的类型为double:
// 最终rateLimiter是一个SmoothBursty实例,SmoothBursty继承自SmoothRateLimiter,记住SmoothRateLimiter,后面还会提到它
RateLimiter rateLimiter = RateLimiter.create(2D);
public static RateLimiter create(double permitsPerSecond) {
return create(permitsPerSecond, SleepingStopwatch.createFromSystemTimer());
}
static RateLimiter create(double permitsPerSecond, SleepingStopwatch stopwatch) {
RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
rateLimiter.setRate(permitsPerSecond);
return rateLimiter;
}
获取一个令牌:
rateLimiter.tryAcquire();
我们来看RateLimiter.tryAcquire()方法内部是如何实现:
public boolean tryAcquire() {
// 默认获取一个令牌, 超时时间设置为0
return tryAcquire(1, 0, MICROSECONDS);
}
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
// 因为是从tryAcquire()方法进来,所以这里timeoutMicros=0
long timeoutMicros = max(unit.toMicros(timeout), 0);
// 检查获要获取的令牌个数,permits>0才合法
checkPermits(permits);
//
long microsToWait;
// RateLimiter实例中的所有操作都使用同一个对象来加锁
synchronized (mutex()) {
// 获取当前微秒时间戳
long nowMicros = stopwatch.readMicros();
// canAcquire方法见下面
if (!canAcquire(nowMicros, timeoutMicros)) {
return false;
} else {
// permits在这里为1,nowMicros是当前微秒时间戳,
// reserveAndGetWaitLength方法计算出从现在开始获取1一个令牌需要等待的时间
microsToWait = reserveAndGetWaitLength(permits, nowMicros);
}
}
// 使用SleepingStopwatch去等待指定实现,当时间到了,就表示获取到锁了,返回true
stopwatch.sleepMicrosUninterruptibly(microsToWait);
return true;
}
// 判断当前时间(微秒时间戳),在等待指定时间后是否晚于“能获取令牌的时间”,如果晚于,返回true,表示能够获取到令牌。
// “能获取令牌的时间”保存在SmoothRateLimiter的nextFreeTicketMicros上,如果时间在nextFreeTicketMicros之前,不能获取令牌,反之,则可以。
private boolean canAcquire(long nowMicros, long timeoutMicros) {
return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}
//
final long reserveAndGetWaitLength(int permits, long nowMicros) {
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
return max(momentAvailable - nowMicros, 0);
}
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// resync这个方法很关键,是guava的SmoothRateLimiter的精髓, 采用“惰性”方式创建令牌
resync(nowMicros);
// 返回上一个“能够开始创建令牌的时间”
long returnValue = nextFreeTicketMicros;
// storedPermitsToSpend = 请求令牌数和令牌桶中令牌数较小的那一个
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
// 当令牌桶中的令牌足够的话,freshPermits = 0,当令牌桶中令牌不够的话,freshPermits = 还缺少的令令牌数
double freshPermits = requiredPermits - storedPermitsToSpend;
// 生产出不够部分的令牌所需要的时间
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
// 关键部分,限流器并没有让当前线程继续阻塞等待在这里${waitMicros}微秒,而是将${waitMicros}的等待时间加到了nextFreeTicketMicros,这样子,“下一个开始生产令牌的时间”就往后推迟了,相当于是当前请求超前消费了,然后这笔费用由之后的请求去埋单。
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
// 从令牌桶中减去实际消费掉的令牌数
this.storedPermits -= storedPermitsToSpend;
return returnValue;
}
void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
// coolDownIntervalMicros()返回的是SmoothRateLimiter中的stableIntervalMicros属性
// stableIntervalMicros 属性表示的是当前的限流器,每隔多长创建一个令牌需要的微秒数
// 所以计算出来的newPermits,表示从下一个开始能够开始创建令牌的时间到当前时间,一共创建出来的令牌数。
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
// 将令牌数加到SmoothRateLimiter#storedPermits上
storedPermits = min(maxPermits, storedPermits + newPermits);
// 将下一个能够开始创建令牌的时间更新为当前时间
nextFreeTicketMicros = nowMicros;
}
}
3.3.惰性加载的思想
经过3.2章节对GUAVA RateLimiter的tryAcquire源码的分析,我们可以很明显的发现,GUAVA RateLimiter虽然也采用令牌桶的方式,但是它没有单独采用一个线程来作为provider,定时去生成令牌。而是采用了一种“惰性加载”的方式,只有当请求令牌的时候,才会去根据当前的时间戳,和令牌开始生产的时间戳,来计算当前令牌桶中应该有多少个令牌:
创建令牌的时间间隔 = 1秒 / qps // qps即每秒钟允许被消费的令牌个数
令牌桶中的令牌数量 = Math.min(令牌桶的容量, ( 当前时时间戳 - 令牌开始生产的时间戳 ) / 创建令牌的时间间隔)
计算出当前令牌桶中的令牌数量之后,再让consumer来消费,假如令牌数不够,就让consumer提前消费,再计算出生产这些不够的令牌数,需要的时间:
生产不够的令牌数所需要的时间 = 创建令牌的时间间隔 * 不够的令牌数
然后更新令牌开始生产的时间戳,即在{令牌开始生产的时间戳},才会生产出第一个令牌:
令牌开始生产的时间戳 = 当前的时间戳 + 生产不够的令牌数所需要的时间
之后如果要再从这个RateLimiter中请求令牌数,就会判断当前时间是否在${令牌开始生产的时间戳}之前,如果是,就请求不到令牌。
上述,GUAVA RateLimiter这种方式就是“惰性加载”思想的一种体现,当需要请求令牌的时候,再去根据时间戳来计算出当前令牌桶中应该要有的令牌数量。避免了在RateLimiter不工作的时候,系统还要分配资源给RateLimiter去进行运算的情况。
4.借鉴惰性加载的思想修改我们的限流器代码
借鉴惰性加载的思想修改我们在上文中的限流器代码
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class MyRateLimiter implements IRateLimiter {
private double tokenBucket;
private final double qps;
// 生成一个令牌需要的纳秒数
private final double nanoSecondsPerToken;
// 开始创建令牌的纳秒时间戳
private long timeBeginToCreateToken;
/**
* 构造方法
* @param qps 每秒钟请求数量
*/
public MyRateLimiter(double qps) {
this.qps = qps;
this.tokenBucket = qps;
// 生成一个令牌需要的微妙数
this.nanoSecondsPerToken = 1 * 1000 * 1000 * 1000 / qps;
this.timeBeginToCreateToken = System.nanoTime();
}
@Override
public boolean tryAcquire() {
synchronized (this) {
long now = System.nanoTime();
// 创建令牌
double newToken = (now - timeBeginToCreateToken) / nanoSecondsPerToken;
// 更新令牌桶中的令牌数
tokenBucket = Math.min(qps, newToken + tokenBucket);
// 更新开始创建令牌的时间
timeBeginToCreateToken = now;
if (tokenBucket > 0) {
System.out.println("tokenBucket = " + tokenBucket);
tokenBucket = tokenBucket - 1;
return true;
} else {
return false;
}
}
}
5.总结
GUAVA RateLimiter的实现方式中的“惰性加载”的思想很值得我们学习,在日常编码中有很多地方使用可以使用这种思想来编码,比如当一个资源不难获取,但是获取之后很占用空间或者获取之后还会继续消耗系统资源,这种情况下我们就可以使用“惰性加载”。