今天我们就来说说GuavaRateLimiter是如何解决高并发场景下的限流问题的。Guava是Google开源的Java类库,它提供了一个工具类RateLimiter。先来看看RateLimiter的使用,让大家对限流有个感官印象。假设我们有一个每秒只能处理两个任务的线程池。如果提交的任务太快,系统可能会变得不稳定。这时候就需要限流了。在下面的示例代码中,我们创建了一个流量限制器,流量为2个请求/秒。这里的流量怎么理解?直观上,2个请求/秒意味着每秒最多允许2个请求通过限流器。其实在Guava中,流量还有更深的含义:它是一个匀速的概念,2个请求/秒相当于1个请求/500毫秒。在提交任务到线程池之前,调用acquire()方法可以起到限流的作用。根据示例代码的执行结果,向线程池提交任务的时间间隔基本稳定在500毫秒。//Limiter流量:2个请求/秒RateLimiterlimiter=RateLimiter.create(2.0);//执行任务的线程池ExecutorServicees=Executors.newFixedThreadPool(1);//记录上次执行时间prev=System.nanoTime();//测试执行20次for(inti=0;i<20;i++){//limitercurrentlimiter.acquire();//提交任务异步执行es.execute(()->{longcur=System.nanoTime();//打印时间间隔:毫秒System.out.println((cur-prev)/1000_000);prev=cur;});}输出结果:...500499499500499经典限流算法:令牌桶算法Guava的限流器使用起来还是很简单的,那么它是如何实现的呢?Guava采用令牌桶算法,其核心是获取令牌以通过限流器。换句话说,只要我们能限制代币发行的速度,我们就能控制流量。令牌桶算法的详细描述如下:以固定速率向令牌桶中添加令牌,假设限流速率为r/秒,则每1/r秒添加一个令牌;假设令牌桶的容量为b,如果令牌桶已满,新令牌将被丢弃;请求能通过限流器的前提是令牌桶中有令牌。在这个算法中,限流的速率r比较好理解,但是令牌桶的容量b怎么理解呢?b其实是burst的缩写,意思是限流器允许的最大突发流量。例如b=10,令牌桶中的令牌已满。此时限流器允许10个请求同时通过限流器。当然,这只是突发流量。这10个请求会带走10个token,所以Subsequentflow只能以速率r通过restrictor。Java中如何实现令牌桶算法?很可能你的直觉会告诉你生产者-消费者模型:一个生产者线程定期向阻塞队列中添加令牌,试图通过限流器的线程充当消费者线程,只从阻塞队列中获取令牌。卡,允许通过限流器。这个算法看起来很完美,实现起来也很简单。如果并发量不大,这个实现没有问题。但实际情况是,大部分使用限流的场景都是高并发场景,系统压力已经逼近极限。这个时候,这个实现就有问题了。问题出在计时器上。在高并发场景下,当系统压力接近极限时,定时器的精度误差会非常大。同时定时器本身会创建一个调度线程,也会影响系统的性能。那么有什么更好的方法来实现它呢?当然,Guava的实现并没有使用定时器。让我们来看看它是如何实现的。Guava如何实现令牌桶算法Guava使用了一种非常简单的方法来实现令牌桶算法。关键是记录并动态计算下一次代币发行的时间。下面我们用最简单的场景来介绍算法的执行过程。假设令牌桶容量b=1,限流速率r=1请求/秒,如下图,如果当前令牌桶没有令牌,下一个令牌会在3秒,而在第二秒,有一个线程T1在请求token,这时候怎么处理呢?线程T1请求token示意图对于这个请求token的线程,很明显需要等待1秒,因为1秒后(第3秒)就能拿到token。此时需要注意下一次代币发行时间也会增加1秒,为什么呢?因为第3秒发出的token已经被线程T1抢占了。处理后如下图所示。线程T1请求结束示意图假设T1在第3秒抢占令牌后,另一个线程T2立即请求令牌,如下图所示。线程T2请求结束示意图上面的线程T1和T2都在下一个token生成时间之前请求token。如果线程在下一个令牌生成时间之后请求令牌会发生什么?假设线程T1请求token后5秒,也就是第7秒,线程T3请求token,如下图。线程T3请求token示意图由于在第5秒已经产生了token,线程T3可以直接拿到token,无需等待。在第7秒,实际限制器能够产生3个令牌,5、6、7秒各产生一个令牌。由于我们假设令牌桶的容量为1,因此第6秒和第7秒产生的令牌将被丢弃。其实等价的,你也可以把它们看成是第7秒的预留令牌,第5秒和第6秒的丢弃令牌。也就是说第7秒的token被线程T3占用了,所以下一个token的生成时间应该是第8秒,如下图。线程T3请求结束示意图通过上面的简单分析,你会发现我们只需要记录下一个token产生的时间,并动态更新,就可以轻松完成限流功能。我们可以对上面的算法进行编码。示例代码如下,仍然假设令牌桶的容量为1。关键是reserve()方法,为请求令牌的线程预先分配一个令牌,并返回该线程获得令牌的时间令牌。实现逻辑如前所述:如果线程请求token时间在下一次token生成时间之后,则线程可以立即拿到token;反之,如果请求时间早于下一个令牌生成时间,则线程要在下一个令牌生成时获取令牌。由于此时下一个token已经被本线程预留,所以需要在生成下一个token的时间上加上1秒。classSimpleLimiter{//下一个令牌生成时间longnext=System.nanoTime();//token发行间隔:nanosecondslonginterval=1000_000_000;//保留令牌,返回获取令牌的时间synchronizedlongreserve(longnow){//请求时间在下一次令牌生成时间之后//重新计算下一次令牌生成时间if(now>next){//将下一个令牌生成时间重置为当前时间next=now;}//获取token的时间到了longat=next;//设置下一次令牌生成时间next+=interval;//返回线程需要等待的时间returnMath.max(at,0L);}//申请令牌voidacquire(){//申请令牌的时间longnow=System.nanoTime();//预留代币longat=reserve(now);longwaitTime=max(at-now,0);//根据条件等待if(waitTime>0){try{TimeUnit.NANOSECONDS.sleep(waitTime);}catch(InterruptedExceptione){e.printStackTrace();}}}}如果令牌桶容量大于1,如何处理?根据令牌桶算法,令牌应该先从令牌桶中取出,所以我们需要按需计算令牌桶中的个数,当一个线程请求令牌时,要从令牌桶中取出第一的。具体代码实现如下。我们添加了一个resync()方法。在该方法中,如果线程在下一次令牌生成时间之后请求令牌,则令牌桶中令牌的数量将重新计算。新生成的token的计算公式是:(now-next)/interval,大家可以参考上图理解。在reserve()方法中,先添加了从令牌桶中取出令牌的逻辑,但是需要注意的是,如果令牌已经从令牌桶中取出,那么next就不需要添加间隔了。classSimpleLimiter{//当前令牌桶中的令牌数量longstoredPermits=0;//令牌桶的容量longmaxPermits=3;//下一个令牌生成时间longnext=System.nanoTime();//token发行间隔:纳秒longinterval=1000_000_000;//请求时间在下一次令牌生成时间之后,则//1.重新计算令牌桶中的令牌数量//2.下一次令牌发行时间重置为当前时间voidresync(longnow){if(now>next){//新生成的代币数量longnewPermits=(now-next)/interval;//新的令牌被添加到令牌桶中storedPermits=min(maxPermits,storedPermits+newPermits);//重置下一次代币发行时间为当前时间next=now;}}//保留token并返回可以获取到token的时间synchronizedlongreserve(longnow){resync(now);//获取token的时间longat=next;//令牌桶中可用的令牌longfb=min(1,storedPermits);//代币净需求:减去代币桶中的第一个代币longnr=1-fb;//重新计算下一次令牌生成时间next=next+nr*interval;//重新计算令牌桶中的令牌this.storedPermits-=fb;返回;}//申请tokenvoidacquire(){//申请token的时间longnow=System.nanoTime();//预留代币longat=reserve(now);longwaitTime=max(at-now,0);//根据条件等待如果(waitTime>0){尝试{TimeUnit.NANOSECONDS.sleep(waitTime);}catch(InterruptedExceptione){e.printStackTrace();}}}}总结经典的限流算法有两种,一种是令牌桶算法(TokenBucket),另一种是漏桶算法(LeakyBucket)。令牌桶算法是定时向令牌桶发送令牌,请求在通过限流器之前可以从令牌桶中获取令牌;而在漏桶算法中,请求像水一样被注入到漏桶中,漏桶会按照一定的速率自动漏水。只有当漏水桶中仍然可以注入水时,请求才能通过限流器。令牌桶算法和漏桶算法就像硬币的正反面,所以可以参考令牌桶算法的实现来实现漏桶算法。上面我们介绍了Guava是如何实现令牌桶算法的。我们的示例代码是GuavaRateLimiter的简化版。GuavaRateLimiter扩展了标准的令牌桶算法,例如它还可以支持预热功能。对于按需加载的缓存,缓存预热后可以支持50000TPS并发,但是预热前的50000TPS并发会直接破坏缓存,所以如果需要对缓存限流,限流设备也需要支持预热功能。初始阶段,限制流量r很小,但会动态增加。预热功能的实现非常复杂。Guava内置了一个积分函数来解决这个问题。感兴趣的可以继续深入研究。
