漏桶算法
漏桶算法是一个非常经典的限流算法,它的特点是匀速的放行,好比是一个底部开洞的桶,外部流量就像加水,可以一直加,如果超出桶容量,水就会溢出(流量抛弃),但是底下的洞始终以恒定的速率滴水。因此这种算法不适合应对流量突发的场景,更适合对流量进行整型。
实现分析
- 容量和速率必须要有。
- 要判定桶里还有没有水,所以需要一个字段记录当前水量。
- 滴水量可以通过上次放行时间与当前时间的差值来计算,速率就是滴水量。
- 可能存在边界时间点,速率升高的异常,可以通过缩小窗口期来减少影响。
限流器实现
java
体验AI代码助手
代码解读
复制代码
/** * 单机限流器 * * 漏桶算法,按秒算 */ 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的窗口,这样能降低边界问题带来的影响,对于绝大多数不需要高并发的场景来说,缩小窗口的方案足够了!