使用 Bucket4j 对 Spring API 进行限流

更新于 2025-12-30

Priyank Srivastava 2020-05-27

1. 概述

在本教程中,我们将重点介绍如何使用 Bucket4j 对 Spring REST API 进行限流。

我们将探讨 API 限流的概念,了解 Bucket4j,并通过几种方式在 Spring 应用中对 REST API 实施限流。

2. API 限流

限流是一种限制 API 访问的策略。它限制客户端在特定时间范围内可发起的 API 调用次数。这有助于防止 API 被过度使用,无论是无意还是恶意行为。

限流通常通过追踪 IP 地址实现,也可以采用更业务导向的方式,如 API 密钥或访问令牌。当客户端达到限流阈值时,作为 API 开发者,我们有以下几种处理选项:

  • 将请求排队,直到当前时间窗口结束
  • 立即允许请求,但对此类请求额外收费
  • 拒绝请求(返回 HTTP 429 Too Many Requests)

3. Bucket4j 限流库

3.1 什么是 Bucket4j?

Bucket4j 是一个基于令牌桶算法(Token-Bucket Algorithm)的 Java 限流库。该库是线程安全的,既可用于独立的 JVM 应用,也可用于集群环境。它还通过 JCache(JSR107)规范支持内存或分布式缓存。

3.2 令牌桶算法

让我们从 API 限流的角度直观理解该算法。

假设我们有一个“桶”,其容量由它能容纳的令牌数量定义。每当消费者想要访问某个 API 端点时,必须从桶中获取一个令牌。如果桶中有可用令牌,我们就移除一个并接受该请求;反之,若桶中没有令牌,则拒绝该请求。

随着请求不断消耗令牌,我们也会以固定速率补充令牌,确保桶中的令牌数不会超过其容量。

例如,假设我们的 API 限流为每分钟 100 次请求。我们可以创建一个容量为 100 的桶,并设置每分钟补充 100 个令牌。

如果在一分钟内收到 70 个请求,就会消耗 70 个令牌,桶中剩余 30 个。在下一分钟开始时,无论上一分钟是否还有剩余令牌,桶都会被重新填满至 100 个。这样可以确保每个时间窗口开始时桶总是满的。如果所有 100 个令牌在该分钟结束前就被耗尽,那么后续请求将被拒绝,直到新的令牌被补充。

4. 快速上手 Bucket4j

4.1 Maven 配置

首先,在 pom.xml 中添加 bucket4j 依赖:

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.15.0</version>
</dependency>

4.2 术语说明

在使用 Bucket4j 前,我们先简要介绍一些核心类及其在令牌桶算法模型中的含义:

  • Bucket 接口:表示具有最大容量的令牌桶,提供 tryConsumetryConsumeAndReturnRemaining 等方法用于消费令牌。这些方法在请求符合限流规则且成功消费令牌时返回 true
  • Bandwidth:是构建桶的关键组件,用于定义桶的限制条件,包括容量和补充速率。
  • Refill:用于定义令牌以固定速率补充到桶中的策略。例如,每秒补充 10 个令牌,或每 5 分钟补充 200 个令牌等。

BuckettryConsumeAndReturnRemaining 方法返回一个 ConsumptionProbe 对象,其中不仅包含消费结果,还包含桶的状态信息,例如剩余令牌数、下一次可获取令牌所需等待的时间等。

4.3 基础用法

让我们测试一些基本的限流模式。

对于“每分钟 10 次请求”的限流规则,我们创建一个容量为 10、每分钟补充 10 个令牌的桶:

Refill refill = Refill.intervally(10, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, refill);
Bucket bucket = Bucket.builder()
    .addLimit(limit)
    .build();

for (int i = 1; i <= 10; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

Refill.intervally 表示在每个时间窗口开始时一次性补充全部令牌(本例中为每分钟开始时补充 10 个)。

再看一个动态补充的例子:

Bandwidth limit = Bandwidth.classic(1, Refill.intervally(1, Duration.ofSeconds(2)));
Bucket bucket = Bucket.builder()
    .addLimit(limit)
    .build();
assertTrue(bucket.tryConsume(1));     // 第一次请求
Executors.newScheduledThreadPool(1)   // 2 秒后安排下一次请求
    .schedule(() -> assertTrue(bucket.tryConsume(1)), 2, TimeUnit.SECONDS); 

假设我们希望限制“每分钟最多 10 次请求”,同时避免在前 5 秒内就耗尽所有令牌(即防止突发流量)。Bucket4j 支持在一个桶上设置多个 Bandwidth 限制。例如,再增加一个“20 秒内最多 5 次请求”的限制:

Bucket bucket = Bucket.builder()
    .addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
    .addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofSeconds(20))))
    .build();

for (int i = 1; i <= 5; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

5. 在 Spring API 中使用 Bucket4j 进行限流

5.1 面积计算器 API

我们实现一个简单但非常流行的面积计算器 REST API。目前它接收矩形的长宽参数,并返回面积:

@RestController
class AreaCalculationController {

    @PostMapping(value = "/api/v1/area/rectangle")
    public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }
}

验证 API 是否正常运行:

$ curl -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

{ "shape":"rectangle","area":120.0 }

5.2 应用限流

现在我们引入一个简单的限流策略:每分钟最多 20 次请求。即如果在 1 分钟内已收到 20 个请求,则拒绝第 21 个请求。

修改 Controller,创建一个带限流规则的 Bucket

@RestController
class AreaCalculationController {

    private final Bucket bucket;

    public AreaCalculationController() {
        Bandwidth limit = Bandwidth.classic(20, Refill.greedy(20, Duration.ofMinutes(1)));
        this.bucket = Bucket.builder()
            .addLimit(limit)
            .build();
    }
    //..
}

在处理请求时,尝试从桶中消费一个令牌。如果已达上限,则返回 HTTP 429:

public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
    if (bucket.tryConsume(1)) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }

    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}

测试第 21 次请求(1 分钟内):

$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429

5.3 API 客户与定价计划

现在我们有了基础限流,接下来引入定价计划以实现更贴近业务的限流策略。

定价计划有助于我们对 API 进行商业化。假设我们为客户提供以下三种套餐:

  • Free(免费):每小时 20 次请求
  • Basic(基础):每小时 40 次请求
  • Professional(专业):每小时 100 次请求

每个客户拥有唯一的 API Key,必须随每次请求一起发送,以便我们识别其对应的定价计划。

首先定义各套餐的限流规则(Bandwidth):

enum PricingPlan {
    FREE {
        Bandwidth getLimit() {
            return Bandwidth.classic(20, Refill.intervally(20, Duration.ofHours(1)));
        }
    },
    BASIC {
        Bandwidth getLimit() {
            return Bandwidth.classic(40, Refill.intervally(40, Duration.ofHours(1)));
        }
    },
    PROFESSIONAL {
        Bandwidth getLimit() {
            return Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1)));
        }
    };
    //..
}

然后添加一个方法,根据 API Key 解析对应的套餐:

enum PricingPlan {
    
    static PricingPlan resolvePlanFromApiKey(String apiKey) {
        if (apiKey == null || apiKey.isEmpty()) {
            return FREE;
        } else if (apiKey.startsWith("PX001-")) {
            return PROFESSIONAL;
        } else if (apiKey.startsWith("BX001-")) {
            return BASIC;
        }
        return FREE;
    }
    //..
}

接着,我们需要为每个 API Key 存储并检索对应的 Bucket

class PricingPlanService {

    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();

    public Bucket resolveBucket(String apiKey) {
        return cache.computeIfAbsent(apiKey, this::newBucket);
    }

    private Bucket newBucket(String apiKey) {
        PricingPlan pricingPlan = PricingPlan.resolvePlanFromApiKey(apiKey);
        return Bucket.builder()
            .addLimit(pricingPlan.getLimit())
            .build();
    }
}

现在我们有了一个基于 API Key 的内存级桶存储。修改 Controller 以使用 PricingPlanService

@RestController
class AreaCalculationController {

    private PricingPlanService pricingPlanService;

    public ResponseEntity<AreaV1> rectangle(@RequestHeader(value = "X-api-key") String apiKey,
        @RequestBody RectangleDimensionsV1 dimensions) {

        Bucket bucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            return ResponseEntity.ok()
                .header("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens()))
                .body(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
        }
        
        long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill))
            .build();
    }
}

说明

  • 客户端通过 X-api-key 请求头发送 API Key。
  • 我们使用 PricingPlanService 获取对应桶,并尝试消费令牌。
  • 为了提升客户端体验,我们在响应中添加了两个自定义头部:
    • X-Rate-Limit-Remaining:当前时间窗口内剩余令牌数
    • X-Rate-Limit-Retry-After-Seconds:距离下次补充令牌还需等待的秒数

调用示例:

## 成功请求
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 11
{"shape":"rectangle","area":120.0}

## 被拒绝的请求
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 583

5.4 使用 Spring MVC 拦截器

假设我们现在需要新增一个计算三角形面积的端点:

@PostMapping(value = "/triangle")
public ResponseEntity<AreaV1> triangle(@RequestBody TriangleDimensionsV1 dimensions) {
    return ResponseEntity.ok(new AreaV1("triangle", 0.5d * dimensions.getHeight() * dimensions.getBase()));
}

显然,这个新端点也需要限流。我们可以复制粘贴之前的限流代码,但更好的做法是使用 Spring MVC 的 HandlerInterceptor,将限流逻辑与业务逻辑解耦。

创建 RateLimitInterceptor,在 preHandle 方法中实现限流:

public class RateLimitInterceptor implements HandlerInterceptor {

    private PricingPlanService pricingPlanService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 
      throws Exception {
        String apiKey = request.getHeader("X-api-key");
        if (apiKey == null || apiKey.isEmpty()) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing Header: X-api-key");
            return false;
        }

        Bucket tokenBucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = tokenBucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            return true;
        } else {
            long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill));
            response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(),
              "You have exhausted your API Request Quota"); 
            return false;
        }
    }
}

最后,将拦截器注册到 InterceptorRegistry

public class Bucket4jRateLimitApp implements WebMvcConfigurer {
    
    private RateLimitInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptor)
            .addPathPatterns("/api/v1/area/**");
    }
}

该拦截器会拦截所有 /api/v1/area/** 路径下的请求。

测试新端点:

## 成功请求
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 9
{"shape":"triangle","area":60.0}

## 被拒绝的请求
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 299
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

至此,我们可以随意添加新端点,拦截器会自动为其应用限流。

6. Bucket4j Spring Boot Starter

下面我们介绍另一种在 Spring 应用中使用 Bucket4j 的方式:Bucket4j Spring Boot Starter。它提供了自动配置功能,允许我们通过 application.ymlapplication.properties 声明式地实现 API 限流,而无需编写任何应用代码。

6.1 限流过滤器

在前面的例子中,我们使用 X-api-key 请求头的值作为限流键。

Bucket4j Spring Boot Starter 提供了几种预定义的限流键配置方式:

  • 默认的简单限流过滤器
  • 基于 IP 地址的过滤器
  • 基于表达式的过滤器(Expression-based filters)

表达式过滤器使用 Spring 表达式语言(SpEL),可以访问 HttpServletRequest 等根对象,从而基于 IP 地址(getRemoteAddr())、请求头(getHeader('X-api-key'))等构建过滤表达式。

该库还支持在表达式中使用自定义类(详见官方文档)。

6.2 Maven 配置

首先,在 pom.xml 中添加 starter 依赖:

<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.13.0</version>
</dependency>

之前我们使用 ConcurrentHashMap 在内存中存储每个 API Key 对应的 Bucket。现在可以使用 Spring 的缓存抽象,配合 Caffeine 或 Guava 实现内存存储。

添加缓存相关依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
    <version>3.5.7</version>
</dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.2.3</version>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>jcache</artifactId>
    <version>3.2.3</version>
</dependency>

注意:我们额外添加了 jcache 依赖,以兼容 Bucket4j 的缓存支持。

别忘了在任意配置类上添加 @EnableCaching 注解以启用缓存功能。

6.3 应用配置

现在配置应用以使用 Bucket4j Starter。

首先,配置 Caffeine 缓存,用于在内存中存储 API Key 与 Bucket 的映射:

spring:
  cache:
    cache-names:
    - rate-limit-buckets
    caffeine:
      spec: maximumSize=100000,expireAfterAccess=3600s

然后,配置 Bucket4j:

bucket4j:
  enabled: true
  filters:
  - cache-name: rate-limit-buckets
    url: /api/v1/area.*
    strategy: first
    http-response-body: "{ \"status\": 429, \"error\": \"Too Many Requests\", \"message\": \"You have exhausted your API Request Quota\" }"
    rate-limits:
    - cache-key: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('PX001-')"
      bandwidths:
      - capacity: 100
        time: 1
        unit: hours
    - cache-key: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('BX001-')"
      bandwidths:
      - capacity: 40
        time: 1
        unit: hours
    - cache-key: "getHeader('X-api-key')"
      bandwidths:
      - capacity: 20
        time: 1
        unit: hours

配置说明

  • bucket4j.enabled=true:启用 Bucket4j 自动配置
  • bucket4j.filters.cache-name:从指定缓存中获取 Bucket
  • bucket4j.filters.url:对匹配该路径的请求应用限流
  • bucket4j.filters.strategy=first:使用第一个匹配的限流规则
  • bucket4j.filters.rate-limits.cache-key:使用 SpEL 表达式提取限流键(这里是 API Key)
  • bucket4j.filters.rate-limits.execute-condition:使用 SpEL 决定是否执行该限流规则
  • bucket4j.filters.rate-limits.bandwidths:定义限流参数(容量、时间、单位)

通过以上配置,我们用一组声明式的限流规则替代了之前的 PricingPlanServiceRateLimitInterceptor

测试效果:

## 成功请求
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 20, "base": 7 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 7
{"shape":"triangle","area":70.0}

## 被拒绝的请求
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 7, "base": 20 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 212
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

7. 结论

本文展示了使用 Bucket4j 对 Spring API 进行限流的多种方法。如需深入了解,请查阅 官方文档