背景

项目中已有基于 @RateLimit 注解的方法级限流,通过 Redis Lua 滑动窗口实现。但它存在两个问题:

  1. 入口即 Controller:限流在 AOP 切面触发,恶意请求已经走完了 Filter Chain、Security 上下文构建等流程
  2. 不支持突发流量:滑动窗口是严格的计数限流,超了就拒,无法应对合理突发

因此引入网关层令牌桶作为第一道防线,与方法层滑动窗口形成二级分层限流。


架构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
请求进入


┌─────────────────────────────────────────┐
│ ① TokenBucketInterceptor (网关层) │ Bucket4j 分布式令牌桶
│ 维度:IP + URI │ 粗粒度防刷,支持突发
│ 存储:Redis (Redisson CAS) │
│ 顺序:order(0) 最先执行 │
└───────────────┬─────────────────────────┘
│ 放行

┌─────────────────────────────────────────┐
│ ② RateLimitAspect (方法层) │ Redis Lua 滑动窗口
│ 维度:GLOBAL / IP / USER │ 细粒度业务限流
│ 注解:@RateLimit │
└───────────────┬─────────────────────────┘
│ 放行

Controller

为什么是令牌桶而非漏桶?

算法 突发流量 平滑性 适用场景
计数器(滑动窗口) ❌ 不友好 接口级精确限流
漏桶 ❌ 严格平滑 网络流量整形
令牌桶 ✅ 桶容量内允许突发 网关限流

令牌桶可以设置 capacity=30, refill=20/min,意味着稳态每秒约 0.33 个请求,但短时间内可以突发 30 个请求——符合真实用户行为。


技术选型

组件 版本 作用
Bucket4j 8.10.1 令牌桶算法核心库
bucket4j-redis 8.10.1 Redis 分布式代理
Redisson 3.29.0 Redis 客户端,提供 CAS 操作
Caffeine Spring Boot 内置 本地缓存桶代理引用

关键依赖:

1
2
3
4
5
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-redis</artifactId>
<version>8.10.1</version>
</dependency>

注意:Bucket4j 8.x 的 groupId 已从 com.github.vladimir-bukhtoyarov 迁移至 com.bucket4j


核心实现

1. 配置属性类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
@Component
@ConfigurationProperties(prefix = "rate-limit")
public class BucketConfig {

/** 按接口路径配置的限流规则 */
private Map<String, Rule> rules = new HashMap<>();

/** 匿名路径默认规则 */
private Rule anonymousDefault = new Rule(30, 20);

/** 已认证路径默认规则 */
private Rule authenticatedDefault = new Rule(60, 40);

@Data
public static class Rule {
/** 桶容量(允许的最大突发请求数) */
@Min(1)
private int capacity;
/** 每分钟补充的令牌数(稳态速率) */
@Min(1)
private long refillPerMinute;
}
}

配置文件示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rate-limit:
anonymous-default:
capacity: 30
refill-per-minute: 20
authenticated-default:
capacity: 60
refill-per-minute: 40
rules:
/api/homework/submit:
capacity: 5
refill-per-minute: 3
/api/dashboard/:
capacity: 60
refill-per-minute: 30
/api/rag/:
capacity: 30
refill-per-minute: 15

2. 分布式代理管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Configuration
public class Bucket4jRedisConfig {

@Bean
public ProxyManager<String> bucketProxyManager(
CommandAsyncExecutor commandAsyncExecutor) {

return RedissonBasedProxyManager
.<String>builderFor(commandAsyncExecutor)
.withExpirationStrategy(
ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(
Duration.ofMinutes(60)))
.build();
}

@Bean("bucketLocalCache")
public Cache<String, Bucket> bucketLocalCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(Duration.ofMinutes(10))
.recordStats()
.build();
}
}

这里有两个 Bean:

  • ProxyManager:Bucket4j 的 Redis 代理,底层通过 Redisson CAS 实现原子性;桶状态持久化在 Redis,多实例共享
  • Caffeine Cache:本地缓存桶代理引用,避免每次请求都重新构建代理对象,但令牌状态仍在 Redis 端维护

3. 限流器核心组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
public class DistributedRateLimiter {

private final ProxyManager<String> proxyManager;
private final Cache<String, Bucket> localCache;

public boolean tryConsume(String key, int capacity,
long refillPerMinute) {
Bucket bucket = resolveBucket(key, capacity, refillPerMinute);
return bucket.tryConsume(1);
}

private Bucket resolveBucket(String key, int capacity,
long refillPerMinute) {
// 参数变更时需重建桶,将参数编入缓存键
String cacheKey = key + "|" + capacity + "|" + refillPerMinute;
return localCache.get(cacheKey, k ->
buildDistributedBucket(key, capacity, refillPerMinute));
}

private Bucket buildDistributedBucket(String key, int capacity,
long refillPerMinute) {
Refill refill = Refill.greedy(refillPerMinute, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(capacity, refill);
BucketConfiguration configuration = BucketConfiguration.builder()
.addLimit(limit)
.build();

return proxyManager.builder()
.build(key, configuration);
}
}

关键设计:

  • Refill.greedy:令牌连续平滑补充,而非 intervally 的梯级补充,更符合真实流量模型
  • Cache Key 编码参数:当配置热更新时(capacity/refillRate 变化),自动重建桶
  • ProxyManager.builder().build(key, config):返回 BucketProxy,其 tryConsume 通过 Redis Lua 脚本原子执行

4. 网关拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Slf4j
@RequiredArgsConstructor
public class TokenBucketInterceptor implements HandlerInterceptor {

private final DistributedRateLimiter rateLimiter;
private final BucketConfig bucketConfig;
private final ObjectMapper objectMapper;

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String clientIp = getClientIp(request);
String uri = request.getRequestURI();
String bucketKey = "bucket:{" + clientIp + "}:" + uri;

BucketConfig.Rule rule = resolveRule(uri, request);

if (rateLimiter.tryConsume(bucketKey,
rule.getCapacity(), rule.getRefillPerMinute())) {
return true;
}

// 429 Too Many Requests
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(Map.of(
"code", 429,
"message", "请求过于频繁,请稍后重试"
)));
return false;
}
}

规则匹配策略:

  1. 精确匹配 /api/homework/submit
  2. 前缀匹配 /api/dashboard/ → 匹配 /api/dashboard/metrics
  3. 默认回退 根据是否认证选择 anonymousDefault / authenticatedDefault

5. 注册拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenBucketInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/auth/**", // 登录注册不限流
"/actuator/**", // 健康检查不限流
"/error"
)
.order(0); // 最先执行
}
}

6. Redis 配置调整

原有 RedisConfig 需额外暴露 CommandAsyncExecutor Bean(Bucket4j 8.x 的 Redisson 集成需要):

1
2
3
4
5
6
private Redisson redissonInstance;

@Bean
public CommandAsyncExecutor commandAsyncExecutor() {
return redissonInstance.getCommandExecutor();
}

Redis Key 设计

网关层令牌桶(Bucket4j)

1
2
bucket:{192.168.1.100}:/api/homework/submit
bucket:{192.168.1.100}:/api/dashboard/metrics
  • {ip} 包裹保证同一 IP 的 key 落在相同 Redis hash slot,兼容 Cluster 模式
  • 桶状态包含:当前令牌数、最后补充时间、配置信息
  • 超期 60 分钟未访问自动清理

方法层滑动窗口(保持不动)

1
2
3
ratelimit:{ClassName:methodName}:global
ratelimit:{ClassName:methodName}:ip:192.168.1.100
ratelimit:{ClassName:methodName}:user:12345

错误码体系

层级 HTTP 状态码 响应格式
网关层 429 Too Many Requests {"code":429,"message":"请求过于频繁,请稍后重试"}
方法层 429 Too Many Requests RateLimitExceededExceptionGlobalExceptionHandler

两层都返回 429,前端可以统一处理。通过日志区分触发层级。


监控集成建议

1
2
3
4
5
6
7
8
9
10
11
12
13
// 暴露令牌桶剩余量给 Actuator
@RestController
@RequestMapping("/actuator")
public class RateLimitMetricsEndpoint {

@GetMapping("/ratelimit/{uri}")
public Map<String, Object> getMetrics(@PathVariable String uri,
HttpServletRequest request) {
long tokens = rateLimiter.getAvailableTokens(
"bucket:{" + request.getRemoteAddr() + "}:" + uri, 30, 20);
return Map.of("availableTokens", tokens);
}
}

配合 Prometheus + Grafana 可展示:

  • 各接口限流触发次数
  • 桶令牌余量趋势
  • Caffeine 缓存命中率(recordStats() 已开启)

总结

维度 改动前 改动后
限流层级 仅方法层 网关层 + 方法层 双层
算法 滑动窗口(一种) 令牌桶 + 滑动窗口(两种)
突发流量 不支持 桶容量内支持
分布式 Redis Lua(单机也可) Bucket4j + Redisson CAS
配置灵活性 注解硬编码 配置文件 + 注解
多实例 已支持 已支持

面试时一句话表述:

“构建了网关级令牌桶 + 方法级滑动窗口的分层限流体系,网关层基于 Bucket4j + Redisson CAS 实现分布式令牌桶,支持按接口路径差异化配置和突发流量;方法层基于 Redis Lua 滑动窗口实现全局/IP/用户多维度精确限流。两层均兼容 Redis Cluster 模式。”


参考