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