漏桶算法:恒定速率才是它的灵魂

漏桶算法,这个名字很形象。你想想看,一个桶底有个小洞,水以恒定速度往下漏。上面不管倒多少水进来,下面出去的流量始终是那个速率。这就是漏桶的核心思想——强行把流量整形成恒定速率。

我个人习惯把漏桶算法看作「流量整形器」,而不是单纯的限流器。它跟令牌桶最大的区别就在这儿:令牌桶允许一定程度的突发,漏桶不允许。说白了,漏桶就是要把你的流量变得像心电图一样平直。

漏桶的工作原理

漏桶模型其实很简单,就三个要素:

  • 桶的容量(Capacity):最多能积压多少请求
  • 漏水速率(Leak Rate):每秒能处理多少个请求
  • 当前水位(Water Level):桶里积压了多少请求

当一个请求到达时,流程是这样的:

  1. 检查当前水位是否已满(达到桶容量)
  2. 如果满了,直接丢弃请求(溢出)
  3. 如果没满,请求进入桶中等待
  4. 桶以恒定速率「漏水」,也就是处理请求

嗯,这里要注意:漏桶的「漏水」是持续进行的,不管有没有请求进来,桶都在漏。这个机制保证了出口速率恒定。

核心结论:漏桶算法的输出速率是严格恒定的,不会因为输入流量波动而改变。这是它最大的特点,也是它最大的局限。

恒定速率处理:为什么这么设计?

我在项目中遇到过这样一个场景:一个老旧的数据库系统,它的连接池只能承受每秒 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,导致漏水计算不准确。解决方案是使用纳秒级时间戳,或者设置最小时间粒度。

漏桶算法的优缺点总结

做了这么多年后端,我对漏桶算法的评价是:简单、可靠、但不够灵活。

  • 优点:
    • 实现简单,容易理解
    • 输出速率恒定,保护后端
    • 内存占用低(计数器实现)
    • 适合流量整形场景
  • 缺点:
    • 无法应对突发流量(只能积压或丢弃)
    • 延迟不稳定(排队导致)
    • 参数调优困难(容量和速率需要精确匹配业务)
    • 不适合对延迟敏感的场景

漏桶算法核心流程

下面这张图展示了漏桶算法的完整工作流程,从请求到达到最终处理或丢弃:

漏桶算法核心工作流程 请求到达 桶是否已满? waterLevel >= capacity 丢弃请求 进入桶中排队 漏桶 容量: 100 当前水位: 60 漏水速率: 10/s 恒定速率处理 关键说明 • 桶满时新请求直接丢弃 • 桶内请求排队等待处理 • 出口速率严格恒定 • 延迟随水位上升而增加

实际应用场景

根据我的经验,漏桶算法最适合以下场景:

  • 数据库连接池保护:防止突发查询打垮数据库
  • 消息队列消费:以固定速率消费消息,避免下游处理不过来
  • 第三方 API 调用:对方限制了调用频率,漏桶可以保证不超限
  • 日志写入:防止日志系统被突发日志冲垮

我的经验:如果你不确定该用漏桶还是令牌桶,记住这个原则——「保护后端用漏桶,允许突发用令牌桶」。我在做网关限流时,通常会在网关层用令牌桶(允许用户突发),在服务层用漏桶(保护数据库)。