源码分析:高性能限流器Guava RateLimiter

主题

本章我们来分析Guava RateLimiter 是如何解决高并发场景下的限流问题的

Guava 是 Google 开源的 Java 类库, 提供了一个工具类RateLimiter 。使用时候必须加入以下依赖:

        
            com.google.guava
            guava
            ${version}
        

其中 ${version} 参考 
https://search.maven.org/search?q=g:com.google.guava%20AND%20a:guava&core=gav

我们先来看下RateLimiter 的使用,让你对限流有个感官的印象。假设我们有一个线程池,它每秒只能处理两个任务,如果提交任务过快,可能导致系统不稳定,这个时候就需要用到限流。

先上代码:

    public class LimiterMain {

    public static void main(String[] args) throws Exception {

        System.out.println("Limiter");
        //限流器流速:2个请求/秒
        RateLimiter limiter = RateLimiter.create(2);
        //执行任务的线程池
        ExecutorService service = Executors.newSingleThreadExecutor();
        //记录间隔时间
        final List list = Lists.newArrayList();

        for (int i = 0; i < 20; i++) {
            limiter.acquire();
            service.execute(() -> {
                long cur = System.currentTimeMillis();
                list.add(cur);
            });
        }
        //等待线程完全执行完毕
        Thread.sleep(10000);
        //打印输出间隔时间
        for (int i = 1; i < list.size(); i++) {
            System.out.println((list.get(i) - list.get(i - 1)));
        }
    }
}

输出结果:
407
498
499
500
500
498
500
499
500
498
500
500
500
500
499
499
499
500
499

在上面的示例中,我们创建了一个流速为2 个请求 / 秒的限流器, 这里的流速该怎么理解呢?直观的看,2 个请求 / 秒指的是每秒最多允许 2 个请求通过限流器, 其实在Guava 中,流速还有更深一层的意思:是一种匀速的概念,2个请求/秒等价于1个请求/500毫秒。

在向线程池提交任务之前,调用acquire() 方法起到限流的作用。所以可以看到输出的结果 基本在 500毫秒输出一次结果。

经典限流算法:令牌桶算法

Guava 的限流器使用上还是很简单的,那它是如何实现的呢?Guava 采用的是令牌桶算法,其核心思想是 要想通过限流器,必须先拿到令牌。也就是说,只要我们能够限制发送令牌的速率,那么就能控制流速了。令牌桶算法的详细描述如下:

  1. 令牌以固定的速率添加到令牌桶,假设限流的速率是 r/秒,则令牌以每1/r秒会添加一个;
  2. 假设令牌桶的容量是b,如果令牌桶已满,则新的令牌会被丢弃;
  3. 请求能够通过限流器的前提是令牌桶中有令牌;

这个算法中限流的速率r还是比较容易理解的,但是令牌桶的容量b该怎么理解呢?b 其实是 burst 的简写,意义是限流器允许的最大突发流量。比如b=10, 而且令牌桶中的令牌已满,此时限流器允许10个请求同时通过限流器,当然只是突发流量而已,这 10个请求会带走10个令牌,所以后续的流量只能按照速率r通过限流器。

令牌桶这算法,如何用java实现呢?很可能直觉会告诉你 生产者-消费者模式:一个生产者线程定时向阻塞队列添加令牌,而试图通过限流器的线程则作为消费者,只有从阻塞队中中获取到令牌,才能通过限流器。

这个算法看上去非常完美,而且实现起来非常简单。如果并发量不大,这个实现并么有什么问题。可实际情况是使用限流的场景大部分都是高并发场景,而且系统压力已经接近极限了,此时这个实现就有问题了。问题出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。

那还有什么好的实现方式呢?当然有,Guava 的现实就没有通过定时器,下面我们看看它如何实现。

Guava 如何实现令牌桶算法

Guava 实现令牌桶算法, 用了一个很简单的方法。其关键是记录并动态计算下一次令牌发放的时间。下面我们用一个简单的场景来介绍该算法的执行过程。假设令牌桶的容量为b=1, 限流速率 r = 1 个请求 / 秒, 如下图所示,如果当前令牌桶中没有令牌, 下一个令牌的发放时间是在第 3 秒, 而在第 2 秒的时候有一个线程 T1 请求令牌,此时该如何处理呢?

img

对于这个请求令牌的线程而言,很显然需要等待1秒,因为1秒以后(第 3 秒) ,他就能拿到令牌了。此时需要注意的是,下一个令牌的发放时间也要增加1秒,为什么呢?因为第3秒发放的令牌已经被线程T1预占了,处理之后如下图:

img

假设T1在预占了第3秒的令牌之后,马上又有一个线程T2请求令牌,如下图所示:

img

很显然由于下一个令牌的产生时间是第4秒,所以线程T2需要等待2秒的时间,才能获取到令牌。同时由于T2预占了第4秒的令牌,所以下一令牌产生的时间增加1秒,完全处理之后,如下图:

img

面线程 T1、T2 都是在下一令牌产生时间之前请求令牌, 如果线程在下一令牌产生之后请求令牌会如何呢?假设在线程T1请求令牌之后的第5秒,也就是第7秒,线程T3请求令牌,如下图:

img

由于第5秒已经产生了一个令牌,所以此时线程T3可以直接拿到令牌,而无需等待。在第7秒,实际上限流器能够产生3个令牌,第5、6、7秒各产生一个令牌,由于我们假设令牌桶的容量是1,所以第6、7秒产生的令牌就丢弃了,其实等价的你也可以认为是保留了第7秒的令牌,丢弃的第5、6秒的令牌。也就是说第7秒的令牌被线程T3占有了,于是下一令牌产生的时间应该是8秒,如下图所示:

img

通过上面的简要分析你会发现,我们只需要记录一个下一个令牌产生的时间,并动态更新它,就能够轻松完成限流功能。我们可以将上面的想法代码化,依然假设令牌桶的容量是1,关键是reserve() 方法, 这个方法为请求令牌桶的线程预分配令牌,同时返回该线程能获取令牌的时间,其实现逻辑:如果线程请求令牌时间在下一令牌产生时间之后那么该线程就可以立即获取令牌。反之请求时间在下一令牌产生之前,那么该线程在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程占有,所以下一令牌产生的时间需要加上1秒。

public class SimpleLimiter {
    /**
     * 下一令牌产生时间
     */
    long next     = System.nanoTime();

    /**
     * 发放令牌间隔:纳秒
     */
    long interval = 1000_000_000;

    synchronized long reserve(long now) {
        //请求时间在下一令牌产生之后,重新计算下一令牌时间
        if (now > next) {
            //将下一令牌产生时间置为当前时间
            next = now;
        }
        //能够获取令牌的时间
        long at = next;
        //设置下一令牌产生时间
        next += interval;
        //返回线程需要等待时间
        return Math.max(at, 0L);
    }

    void acquire() {
        //申请令牌时间
        long now = System.nanoTime();
        //预占令牌
        long at = reserve(now);
        long waitTime = Math.max(at - now, 0L);
        //按照条件等待
        if (waitTime > 0) {
            try {
                TimeUnit.NANOSECONDS.sleep(waitTime);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

总结

经典限流算法有两个:一个是令牌桶算法,一个是漏桶算法。令牌桶算法是定时向令牌桶发送令牌,漏桶算法会按照一定的速率自动将水漏掉。只要漏桶里面还能注入水,请求才能通过限流器。

你可能感兴趣的:(源码分析:高性能限流器Guava RateLimiter)