漏桶算法:恒定速率才是它的灵魂
漏桶算法,这个名字很形象。你想想看,一个桶底有个小洞,水以恒定速度往下漏。上面不管倒多少水进来,下面出去的流量始终是那个速率。这就是漏桶的核心思想——强行把流量整形成恒定速率。
我个人习惯把漏桶算法看作「流量整形器」,而不是单纯的限流器。它跟令牌桶最大的区别就在这儿:令牌桶允许一定程度的突发,漏桶不允许。说白了,漏桶就是要把你的流量变得像心电图一样平直。
漏桶的工作原理
漏桶模型其实很简单,就三个要素:
- 桶的容量(Capacity):最多能积压多少请求
- 漏水速率(Leak Rate):每秒能处理多少个请求
- 当前水位(Water Level):桶里积压了多少请求
当一个请求到达时,流程是这样的:
- 检查当前水位是否已满(达到桶容量)
- 如果满了,直接丢弃请求(溢出)
- 如果没满,请求进入桶中等待
- 桶以恒定速率「漏水」,也就是处理请求
嗯,这里要注意:漏桶的「漏水」是持续进行的,不管有没有请求进来,桶都在漏。这个机制保证了出口速率恒定。
核心结论:漏桶算法的输出速率是严格恒定的,不会因为输入流量波动而改变。这是它最大的特点,也是它最大的局限。
恒定速率处理:为什么这么设计?
我在项目中遇到过这样一个场景:一个老旧的数据库系统,它的连接池只能承受每秒 100 个查询。一旦超过这个数,连接就会超时,甚至导致数据库崩溃。这时候令牌桶就不太合适了——因为令牌桶允许短时间内的突发,虽然平均速率是 100,但瞬间可能冲到 200,数据库扛不住。
漏桶就完美解决了这个问题。它把出口速率死死地锁在 100 QPS,不管前端怎么突发,后端看到的永远是平滑的流量。这就是恒定速率处理的价值——保护脆弱的后端系统。
我曾经接手过一个支付系统的限流改造。原来的方案用的是计数器算法,结果每到整点秒杀,数据库连接池就被打满。后来换成漏桶,出口速率限制在 500 TPS,数据库再也没出过问题。说白了,有些场景下,你不需要高吞吐,你需要的是「别把后端搞死」。
| 特性 | 漏桶算法 | 令牌桶算法 |
|---|---|---|
| 输出速率 | 严格恒定 | 允许短时突发 |
| 应对突发 | 积压或丢弃 | 消耗令牌 |
| 适用场景 | 后端保护、流量整形 | 前端限流、允许突发 |
| 实现复杂度 | 低 | 中 |
应对突发流量能力分析
这个问题很有意思。很多人觉得漏桶不能应对突发流量,其实不完全对。漏桶能应对突发,但方式跟你想的不一样。
漏桶应对突发的方式是「积压」。突发流量来了,桶里水位上涨,请求排队等待。只要桶没满,请求就不会丢。但代价是延迟增加——先到的请求先处理,后到的请求排队等。
举个例子:
- 桶容量:100 个请求
- 漏水速率:10 请求/秒
- 突发来了 200 个请求
前 100 个请求进入桶中,后 100 个直接丢弃。桶里的 100 个请求以 10/秒的速率处理,需要 10 秒才能清空。这 10 秒内,新来的请求要么排队(如果桶没满),要么被丢弃(如果桶满了)。
我的建议:如果你的业务能接受延迟波动,漏桶是个好选择。但如果你的业务对延迟敏感(比如实时交易),漏桶的排队机制可能会让你头疼。
漏桶的两种实现方式
我在实际项目中用过两种实现方式,各有优劣。
方式一:队列实现(FIFO)
用一个队列来存储请求,后台线程以固定速率从队列中取出请求处理。这是最直观的实现方式。
// 漏桶 - 队列实现(伪代码)
class LeakyBucket {
Queue<Request> queue;
int capacity;
int leakRate; // 每秒处理数
void process() {
while (true) {
Thread.sleep(1000 / leakRate);
Request req = queue.poll();
if (req != null) {
handle(req);
}
}
}
boolean accept(Request req) {
if (queue.size() < capacity) {
queue.offer(req);
return true;
}
return false; // 桶满了,丢弃
}
}
方式二:计数器实现(更高效)
不需要真正的队列,用时间戳和计数器来模拟漏水过程。这种方式性能更好,适合高并发场景。
// 漏桶 - 计数器实现(伪代码)
class LeakyBucketCounter {
long lastLeakTime;
int waterLevel;
int capacity;
int leakRate; // 每秒处理数
boolean accept() {
long now = System.currentTimeMillis();
// 计算这段时间漏掉了多少水
long elapsed = now - lastLeakTime;
int leaked = (int)(elapsed * leakRate / 1000);
waterLevel = Math.max(0, waterLevel - leaked);
lastLeakTime = now;
if (waterLevel < capacity) {
waterLevel++;
return true;
}
return false;
}
}
避坑指南:我曾经在计数器实现中踩过一个坑——时间精度问题。如果两次请求间隔太短(毫秒级),elapsed 可能为 0,导致漏水计算不准确。解决方案是使用纳秒级时间戳,或者设置最小时间粒度。
漏桶算法的优缺点总结
做了这么多年后端,我对漏桶算法的评价是:简单、可靠、但不够灵活。
- 优点:
- 实现简单,容易理解
- 输出速率恒定,保护后端
- 内存占用低(计数器实现)
- 适合流量整形场景
- 缺点:
- 无法应对突发流量(只能积压或丢弃)
- 延迟不稳定(排队导致)
- 参数调优困难(容量和速率需要精确匹配业务)
- 不适合对延迟敏感的场景
漏桶算法核心流程
下面这张图展示了漏桶算法的完整工作流程,从请求到达到最终处理或丢弃:
实际应用场景
根据我的经验,漏桶算法最适合以下场景:
- 数据库连接池保护:防止突发查询打垮数据库
- 消息队列消费:以固定速率消费消息,避免下游处理不过来
- 第三方 API 调用:对方限制了调用频率,漏桶可以保证不超限
- 日志写入:防止日志系统被突发日志冲垮
我的经验:如果你不确定该用漏桶还是令牌桶,记住这个原则——「保护后端用漏桶,允许突发用令牌桶」。我在做网关限流时,通常会在网关层用令牌桶(允许用户突发),在服务层用漏桶(保护数据库)。