全网最容易懂的漏桶算法实现

261 阅读3分钟

漏桶算法

漏桶算法是一个非常经典的限流算法,它的特点是匀速的放行,好比是一个底部开洞的桶,外部流量就像加水,可以一直加,如果超出桶容量,水就会溢出(流量抛弃),但是底下的洞始终以恒定的速率滴水。因此这种算法不适合应对流量突发的场景,更适合对流量进行整型。

实现分析

  1. 容量和速率必须要有。
  2. 要判定桶里还有没有水,所以需要一个字段记录当前水量。
  3. 滴水量可以通过上次放行时间与当前时间的差值来计算,速率就是滴水量。
  4. 可能存在边界时间点,速率升高的异常,可以通过缩小窗口期来减少影响。

限流器实现

/**
 * 单机限流器
 *
 * 漏桶算法,按秒算
 */
public class LeakyBucketLimiter {
    private int capacity; // 容量
    private int rate; // 速率

    private long water = 0L;
    private long lastTime = 0L;
    private long window; // 窗口,增加平滑性

    public LeakyBucketLimiter(int capacity, int rate) {
        this.capacity = capacity;
        this.rate = rate;
        this.window = 1000L / rate;
    }

    @Override
    public boolean tryAcquire(long timeout) {
        long start = System.currentTimeMillis();
        // 自旋尝试获取通行
        while (true) {
            if (acquire()) {
                return true;
            }
            // 超时判定
            if (System.currentTimeMillis() - start >= timeout) {
                return false;
            }
            try {
                Thread.sleep(window);
            } catch (InterruptedException e) {
                // 阻塞,可不加
            }
        }
    }

    @Override
    public synchronized boolean acquire() {
        // 先加水
        fillWater();

        if (water > 0L) {
            // 恒定速率放水
            long now = System.currentTimeMillis();
            if (now - lastTime > window) {// 计算时差,每个窗口允许1个并发,大于窗口即为可通行
                water--;
                lastTime = now;
                return true;
            }
        }
        return false;
    }

    /**
     * 填充水
     */
    private void fillWater() {
        long now = System.currentTimeMillis();
        long delta = now - lastTime; // 时差
        water += delta / 1000 * rate;
        water = water > capacity ? capacity : water; // 桶满
    }

    // 测试代码
    public static void main(String[] args) throws InterruptedException {
        // 测试用例1:基础速率测试(每秒1令牌,容量3)
        testBasicRateLimit();

        // 测试用例2:并发请求测试(10线程同时请求)
        testConcurrentRequests();
    }

    // 测试基础速率限制
    private static void testBasicRateLimit() throws InterruptedException {
        LeakyBucketLimiter limiter = new LeakyBucketLimiter(3, 1);

        // 第1次请求(成功)
        System.out.println("首次请求应允许通过: " + limiter.acquire());

        // 等待1秒后再次请求(允许)
        Thread.sleep(1100); // 超过1秒确保令牌补充
        System.out.println("1s后应允许通过: " + limiter.acquire());

        // 再次请求(不允许)
        Thread.sleep(100);
        System.out.println("100ms后不允许通过: " + limiter.acquire());
    }

    // 测试并发请求
    private static void testConcurrentRequests() throws InterruptedException {
        LeakyBucketLimiter limiter = new LeakyBucketLimiter(5, 10); // qps10,容量5
        ExecutorService executor = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(10);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                if (limiter.tryAcquire(1000)) { // 尝试1s
                    successCount.incrementAndGet();
                }
                latch.countDown();
            });
        }

        latch.await();
        executor.shutdown();

        // 预期:最多5次成功(桶容量为5)
        System.out.println("并发请求应通过5,实际通过:" + successCount.get());
    }
}

以上代码实现了一个简单的同步漏桶限流器。

边界问题

举个例子,比如我们设置 QPS 为 10。

在[0s,0.5s]这个区间没有收到流量,在[0.5s,1s]这个区间收到10个请求,如果窗口期是1秒,那么10个请求都会放行,在[0s,1s]这个区间的 QPS 就是10。

在[1s,1.5s]这个区间收到10个请求,如果窗口期是1秒,那么10个请求都会放行,在[1.5s,2s]这个区间也收到10个请求,但是因为之前处理10个了,所以全部拒绝,那么在[1s,2s]这个区间的 QPS 也是10。

整体来看,我们拖动窗口到[0.5s,1.5s]这个区间内,发现通过的请求量是20,QPS 竟然加倍了!

这是因为我们采用了固定窗口策略。

解决这个问题的方法就是采用滑动窗口策略,每次计算都以当前时间往前推1s作为窗口。或者采用更简单,但是不彻底的做法:将窗口缩小,把1s的窗口分为10个0.1s的窗口,这样能降低边界问题带来的影响,对于绝大多数不需要高并发的场景来说,缩小窗口的方案足够了!

边界问题