一、从一个排队的事说起

想象一个场景:小区门口的快递驿站,空间不大,只能同时站十个人。门口一个大爷拿着喇叭,喊“一次进三个,出来一个再进一个”。不管外面多少人排队,里面处理包裹的速度基本固定。这样一来,驿站里面的工作不会因为外面大拨人涌进来而乱套。

这个场景其实就是“漏桶”的雏形。桶就像驿站,水就像请求。水进来得快,桶满就溢出去;水漏下去的速度由桶底的口决定。在微服务架构里,系统之间的调用经常会出现“一阵子请求暴增”的情况,如果直接冲向下游,很容易把数据库或者第三方接口打垮。这时候,漏桶算法就能派上用场。

可能有人会说,限流不都是用“令牌桶”吗?别急,下面咱们慢慢聊。漏桶算法在微服务里做“服务间适配”,有它独特的价值,但也有一些坑。这篇文章会分享我会怎样实现它,以及上线后常常遇到的问题和解决办法。

二、漏桶算法是个啥

2.1 核心思想

漏桶算法的名字很形象。你有一个桶,桶底有个小洞,水以固定的速度往下漏。你往桶里倒水,不管倒多猛,从洞里流出来的速度始终是固定值。如果倒水的速度超过漏洞的出水速度,桶里的水位就会上升;等水满了,再倒进去的水就会溢出来,相当于被拒绝。

在计算机世界里,水换成请求。桶就是一个缓冲区,请求进来先占个位置,然后按固定速度被处理。桶的容量决定了最多能积压多少请求,漏水的速度决定了下游服务每秒最多能处理多少个请求。这种机制能有效把“忽大忽小”的流量变成“涓涓细流”,保护下游系统不被冲垮。

2.2 在微服务里干嘛用

微服务之间通过网络调用,比如订单服务要调用用户服务。如果订单服务突然遇到促销,每秒发来大量请求,用户服务如果只用一台小机器,肯定招架不住。如果订单服务在调用用户服务之前,把请求先“倒进”一个漏桶里,用户服务就能以稳定的速率收到请求,而不是被一大波请求打得措手不及。

这通常叫“服务间适配”,就是让服务之间的流量形态与下游的处理能力相匹配。你可以把漏桶放在调用方(客户端),也可以放在被调用方(服务端),甚至放在网关层统一限制。

2.3 关联对比:令牌桶算法

提到漏桶,大家总会想起令牌桶。这俩是哥们儿,但脾气不一样。

令牌桶是这样:桶里放令牌,以固定速度往桶里加令牌,桶最多存一些令牌。请求来了必须拿到令牌才放行。如果桶里令牌充足,短时间内的突发请求也能全部通过,因为可以一次性拿光所有令牌。

漏桶则相反:它不管你桶里有多少水位,出口速度始终固定。所以漏桶擅长“削峰”,把突发流量压平;令牌桶则允许有一定程度的突发。

用生活化的话说:令牌桶像“有钱就可以买,有存款可以猛花”;漏桶像“银行柜员下班时间固定,不管前面排多长,处理速度就是那么快”。在服务间适配时,如果下游处理能力极其固定,漏桶更合适;如果下游能承受一定突发,令牌桶更友好。

2.4 漏桶的优缺点

优点很突出:

  • 平滑流量。不管上游怎么“抽风”,下游收到的流量基本是一条平稳的线。
  • 实现思路简单。一个桶、一个速率、一个容量,几句话能说明白。
  • 能起到保护作用。下游不会因为瞬时流量过大而崩溃。

缺点也很明显:

  • 不擅长处理突发请求。就算桶是空的,请求也只能按固定速率出去,想“借点力”不行。
  • 等待时间可能很长。如果桶里积压的请求多,后面的请求要等很久,甚至等到超时。
  • 参数调起来费劲。容量和速率怎么设,需要仔细观察业务流量,设置不合理就会误伤正常请求。

三、用Java写一个简单的漏桶

技术栈说明:下面的代码统一使用 Java 8+ 实现,不依赖第三方框架,方便看清核心思路。

我们先写一个最简单的“基于时间戳”的漏桶限流器,它没有真正的排队队列,而是通过计算“水位”来决定是否放行。之所以不直接用队列,是因为直接用队列需要额外线程去处理,容易把代码搞复杂,而且不适合在 HTTP 请求线程里用。

// LeakyBucketRateLimiter.java

/**
 * 一个简单的漏桶限流器
 * 思想:把“处理能力”当作漏水速度,水位表示当前积压的请求量。
 */
public class LeakyBucketRateLimiter {
    private final int capacity;          // 桶的容量,最多能积压多少请求
    private final double leakRate;       // 每秒漏掉多少请求(即下游每秒能处理多少请求)
    private double water;                // 当前水位(积压的请求数)
    private long lastLeakTime;           // 上次“漏水”的时间戳(毫秒)

    public LeakyBucketRateLimiter(int capacity, double leakRate) {
        this.capacity = capacity;
        this.leakRate = leakRate;
        this.water = 0;
        this.lastLeakTime = System.currentTimeMillis();
    }

    /**
     * 尝试获取一个处理许可
     * @return true 表示可以继续调用下游;false 表示桶已满,请求应该被拒绝
     */
    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 在这段时间内,按照漏水速率漏掉了多少水量
        double leaked = (now - lastLeakTime) / 1000.0 * leakRate;
        // 水位减去漏掉的水,但不能小于0
        water = Math.max(0, water - leaked);
        // 更新上次漏水时间
        lastLeakTime = now;

        // 如果桶没满,就允许这个请求进来
        if (water < capacity) {
            water = water + 1;
            return true;
        }
        // 桶满了,拒绝请求
        return false;
    }
}

这段代码里,leakRate 是每秒能漏掉多少个请求。比如下游服务每秒最多能处理 5 个请求,那就把 leakRate 设为 5。capacity 是桶的最大积压量,可以认为是允许瞬间超出正常速率的“缓冲量”。比如速率是 5,容量是 10,那么最多允许一瞬间积压 10 个请求。

写一个简单的测试类,模拟 20 个请求连续打过来,看看漏桶的效果:

// LeakyBucketDemo.java
public class LeakyBucketDemo {
    public static void main(String[] args) throws InterruptedException {
        // 容量5,每秒漏水2个请求
        LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(5, 2);

        // 模拟循环发送20个请求
        for (int i = 1; i <= 20; i++) {
            boolean allowed = limiter.tryAcquire();
            System.out.println("请求 " + i + " -> " + (allowed ? "通过" : "被拒绝"));
            Thread.sleep(200); // 每200毫秒发一个请求
        }
    }
}

运行这个测试,你会看到“通过”和“被拒绝”交替出现。原因是请求速度超过了漏水速度,桶里的水不断积累,满了以后就拒绝。这就是漏桶在流量控制上的直观表现。

实际工程里,你可以在 tryAcquire 返回 true 后,再去调用下游服务;返回 false 时,可以选择直接返回“服务繁忙”给上游,或者把请求放到缓冲队列里等其他时机再试。

四、服务间适配的落地办法

4.1 适配方式选择

服务间适配一般有两种做法:把漏桶放在网关层,或者放在调用方客户端里。

网关层适配适合统一管理。比如所有经过网关的请求都先过一次漏桶,不管后面对应哪个服务,力度好控制。缺点是不够灵活,因为不同服务的处理能力不一样,一个全局速率很难照顾到所有后端。

客户端适配更精准。每个调用方针对自己关心的下游单独设置速率。比如订单服务调用用户服务和商品服务,用户服务容量小一些,就给慢一点;商品服务容量大,就给快一点。缺点是每个服务都得自己写一套,如果服务多,维护成本高。

下面演示一个客户端适配的例子,技术栈是 Spring Boot + RestTemplate。订单服务在调用订单下游时,先经过漏桶限流。

4.2 客户端适配示例

// OrderServiceClient.java
package com.example.order.client;

import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

/**
 * 订单服务调用“订单详情服务”的客户端
 * 在发起远程调用之前,先让请求经过漏桶限流
 */
@Component
public class OrderServiceClient {

    // 漏桶:容量20,每秒放行5个请求
    private final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(20, 5);
    private final RestTemplate restTemplate;

    public OrderServiceClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    /**
     * 查询订单详情
     * @param orderId 订单号
     * @return 订单详情 JSON 字符串,或者繁忙提示
     */
    public String getOrderDetail(String orderId) {
        // 拿不到许可,说明桶满了,直接返回降级文案
        if (!rateLimiter.tryAcquire()) {
            return "当前订单服务繁忙,请稍后重试";
        }

        // 拿到许可,调用远程服务
        String url = "http://order-detail-service/orders/" + orderId;
        return restTemplate.getForObject(url, String.class);
    }
}

这里 LeakyBucketRateLimiter 使用的是上一节实现的那个类,没有额外引入重量级框架。需要注意的是,tryAcquire() 是同步的,如果并发高,用 synchronized 可能成为瓶颈。实际项目中可以用原子类或并发包里的工具来优化,不过这是后话。

4.3 参数配置建议

很多人刚用漏桶时,最头疼的是容量和速率怎么定。我的经验是:

  • 先搞清楚下游服务的实际处理能力。用压测工具测一下它在正常情况下每秒能处理多少请求,把 leakRate 设为这个值的 80% 左右,留出安全余量。
  • 容量参考“最多能容忍多少请求排队”。如果下游处理一般延迟是 50 毫秒,你希望排队不超过 2 秒,那容量可以设为 2 秒内能处理的请求数。比如速率是每秒 5 个,容量就设为 10。
  • 千万不要把容量拍脑袋设成一个特别大的数,那样漏桶就失去了保护意义。

五、运维中的常见问题及解决

漏桶算法虽然原理简单,但真正在微服务环境里跑起来,还是会遇到各种坑。以下是我在运维中经常碰到的问题和对应的解决办法。

5.1 容量设太小,正常流量被误杀

现象:服务没到高峰期,但日志里大量出现“被拒绝”。检查之后发现,容量只有一个很小的值,比如 5,而平时正常业务请求在很短时间内就能超过 5 个排队,导致一部分请求被丢掉。

解决办法:把容量调大一些,让它能容纳正常流量下的峰值排队量。同时,最好把“被拒绝”的请求和“进入桶”的请求分别记录下来,观察调整后的效果。

5.2 漏水速率设置不合理

速率设置太快,等于没限流;设置太慢,则会导致大量请求被拒,甚至服务处理不过来。这个速率的合理值不是一成不变的,业务在变,下游数据库也在变。

解决办法:每隔一段时间做一次压测,重新校准速率。另外,建议把漏桶的配置放到配置中心,比如 Nacos、Apollo,这样改完不用重启服务。下面是一个配置中心的配置片段示例:

# application.yaml
leaky:
  bucket:
    capacity: 50          # 桶容量
    leak-rate: 20         # 每秒放行请求数

然后在 Java 代码里读取这段配置,而不是硬编码。

5.3 多实例部署导致限流失效

漏桶限流器是单机内存的,服务启动了 10 个实例,每个实例都有自己的独立桶。原本希望整体限流每秒 100 个请求,实际变成了每秒 1000 个,因为每个实例都放行了 100 个。这在微服务里是非常常见的坑。

解决办法:把限流状态放到公共存储里,比如 Redis。每个实例都从 Redis 读取和更新同一个桶的状态。如果不想自己写分布式漏桶,可以直接使用成熟的分布式限流组件,比如 Redisson 的 RRateLimiter。虽然它的默认实现是令牌桶,但思路是相通的:把所有限流计数放到 Redis 里,保证全局一致。

这里要额外提醒一点:用 Redis 做限流会带来一次额外的网络调用,对性能有一定影响。可以考虑在应用内存做一层“粗略检查”,通过粗略检查后再请求 Redis 做精确判断,这样既快又稳。

5.4 请求被拒绝后,客户端处理不当导致雪崩

漏桶高负载时会拒绝新请求。如果客户端收到“被拒绝”后,马上重新发起重试,会让情况更糟。重试的请求也会进桶,结果又是一次拒绝,形成“重试风暴”。

解决办法:在客户端设置合理的重试策略,比如最多重试一次、退避重试(等 500 毫秒再试)。更重要的是,调用方应该有能力识别“服务繁忙”的语义,及时做降级处理,比如快速返回一个缓存结果。

5.5 监控缺失,问题无法定位

很多团队上线了漏桶,但没有任何监控。等用户在群里喊“接口又失败了”,才发现是限流把请求拦了。

解决办法:把漏桶的关键指标用日志或者监控系统记录下来,比如桶容量、当前水位、每秒通过数、每秒拒绝数。下面是一段简单的日志输出示例:

// 在 tryAcquire 方法里增加日志埋点
// 因为这里用 synchronized 保护,所以打印日志也要考虑性能
// 可以用统计累加的方式,定期输出,避免高频打印
if (water < capacity) {
    water = water + 1;
    System.out.println("[漏桶] 通过请求,当前水位:" + water);
    return true;
} else {
    System.out.println("[漏桶] 拒绝请求,当前水位已满:" + water);
    return false;
}

生产环境建议使用监控框架,比如 Micrometer,把指标上传到 Prometheus,然后配上 Grafana 看板。这样“被拒绝”数量一上升,就能立刻发现是限流在起作用还是漏桶参数有误。

5.6 时间窗口不同步引发边界问题

有些漏桶实现是基于固定时间窗口的,比如“每秒漏 5 个”,但每个实例的系统时钟可能存在毫秒级偏差。不过对于单机实现,这个问题影响不大。真正需要注意的是一天内的流量峰值,如果峰值出现在整点,比如 10:00:00,而你的桶刚好在 9:59:59 被填满,那么 10:00 的请求会被误伤。

解决办法:不要把容量设得太小,给边界情况留出空间。同时,如果使用 Redis 实现,要注意 Redis 服务器和应用服务器之间的时间同步,可以使用 NTP 保证时间一致。

六、注意事项

上面说了不少问题,这里再把最重要的几条注意事项汇总一下。

第一,漏桶算法不是万能的。如果你的业务需要支持一定程度的突发流量,令牌桶可能更合适。不要因为“漏桶听起来更稳定”就乱用。

第二,参数一定要基于真实压力测试去调。不要拍脑袋,也不要照抄别人的配置。不同的服务,哪怕接口长得一样,处理能力也可能差别很大。

第三,限流器的实现要保证线程安全。如果是分布式环境,一定要使用分布式锁或 Redis 原子操作,否则会出现并发漏洞。

第四,拒绝请求时要给上游一个明确的“繁忙”信号。HTTP 状态码用 429(Too Many Requests)比较合适,别用 500,否则上游会以为是服务出错,可能会触发警报。

第五,不要忘记处理回调结果。限流器本身不保证下游正常,在下游真的出问题的时候,漏桶会把请求都拦住,但这并不能解决下游故障。所以还是要结合熔断、降级等机制一起用。

第六,日志和监控是必须的。没有监控的限流等于盲人骑瞎马。

七、文章总结

漏桶算法是一种简单实用的流量控制算法。它通过固定速率的“漏水”让请求以平稳的速度进入下游,很适合服务间适配的场景,尤其是下游处理能力固定、容易被打满的系统。

在微服务架构中,可以把漏桶放在客户端或者网关层,配合配置中心、监控系统、分布式存储,做成一套完整的限流方案。但要注意,漏桶并不擅长处理突发流量,参数设置不合理或者多实例部署时,都会带来很多意料之外的问题。只有结合业务实际情况仔细调优,并做好监控和应对策略,才能真正发挥它的价值。

希望这篇文章能帮你理解漏桶算法的原理和落地细节。下次你在微服务里遇到“突然流量暴增,下游扛不住”的情况,可以试试用漏桶,让流量“漏”得慢一点,系统也就稳一点。