令牌桶算法的限流组件实现
前言
在高并发场景下,为了保护系统资源不被过度消耗,限流是一种非常重要的技术手段。
限流(Rate Limiting)是指限制系统在单位时间内处理请求的数量,防止系统因为突发流量而崩溃。常见的限流算法包括:
- 固定窗口计数器:在固定时间窗口内统计请求数量
- 滑动窗口计数器:更精确的时间窗口统计
- 漏桶算法(Leaky Bucket):以固定速率处理请求,超出部分丢弃或排队
- 令牌桶算法(Token Bucket):以固定速率生成令牌,请求需要获取令牌才能执行
本文介绍的组件采用的是令牌桶算法
令牌桶算法原理
令牌桶算法的核心思想:
- 令牌生成:系统以恒定速率(如每秒 100 个)向桶中添加令牌
- 令牌存储:桶有最大容量,令牌满了就不再添加
- 令牌消费:每个请求需要从桶中获取一个令牌才能执行
- 请求处理 :
- 如果桶中有令牌,取出令牌,请求通过
- 如果桶中没有令牌,请求被拒绝或等待

单机版限流组件(基于 Guava RateLimiter)
核心注解定义
首先定义一个 @Limit 注解,用于标记需要限流的方法:
java
package com.example.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
/**
* 资源的唯一标识 key
* 不同的接口可以使用不同的 key 实现独立的流量控制
*/
String key() default "";
/**
* 每秒允许的请求数量(QPS)
* 这是令牌桶的生成速率
*/
double permitsPerSecond();
/**
* 获取令牌的超时时间
* 0 表示立即返回,不等待
*/
long timeout();
/**
* 超时时间的单位
* 默认为毫秒
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
/**
* 限流时返回的提示信息
*/
String msg() default "系统繁忙,请稍后再试";
}
注解参数说明:
key:资源标识符,用于区分不同的接口或资源,实现细粒度的流量控制permitsPerSecond:每秒允许通过的请求数,即 QPS(Queries Per Second)timeout:尝试获取令牌的等待时间,0 表示非阻塞模式timeunit:时间单位,可以是毫秒、秒等msg:触发限流时返回给用户的提示信息
AOP 切面实现
使用 Spring AOP 拦截带有 @Limit 注解的方法:
java
package com.example.aop;
import com.example.annotation.Limit;
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Aspect
@Component
public class LimitAspect {
/**
* 使用 ConcurrentHashMap 存储不同资源的 RateLimiter
* key: 资源标识符
* value: 对应的令牌桶限流器
*/
private final Map<String, RateLimiter> limitMap = new ConcurrentHashMap<>();
/**
* 环绕通知:拦截所有带有 @Limit 注解的方法
*/
@Around("@annotation(com.example.annotation.Limit)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 1. 获取方法签名和注解信息
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
Limit limit = method.getAnnotation(Limit.class);
if (limit != null) {
String key = limit.key();
// 2. 获取或创建 RateLimiter(线程安全)
// computeIfAbsent 保证了在并发情况下,同一个 key 只会创建一个 RateLimiter
RateLimiter rateLimiter = limitMap.computeIfAbsent(key, k -> {
log.info("新建了令牌桶={},容量={}", k, limit.permitsPerSecond());
return RateLimiter.create(limit.permitsPerSecond());
});
// 3. 记录请求信息
long requestTime = System.currentTimeMillis();
log.info("请求到达,key={},时间={},当前速率={}",
key, requestTime, rateLimiter.getRate());
// 4. 尝试获取令牌
boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
// 5. 令牌获取失败,触发限流
if (!acquire) {
log.warn("令牌桶={},获取令牌失败,时间={}", key, System.currentTimeMillis());
throw new RuntimeException(limit.msg());
}
// 6. 令牌获取成功,记录日志
log.info("令牌桶={},获取令牌成功,时间={}", key, System.currentTimeMillis());
}
// 7. 执行目标方法
return pjp.proceed();
}
}
实现细节解析:
-
ConcurrentHashMap 的使用:
- 保证多线程环境下的线程安全
- 每个资源 key 对应一个独立的 RateLimiter 实例
- 不同接口之间的限流互不影响
-
computeIfAbsent 的妙用:
- 原子性操作,避免并发创建多个 RateLimiter
- 只在 key 不存在时才创建新实例
- 懒加载模式,节省资源
-
tryAcquire 方法:
- 非阻塞或限时阻塞获取令牌
- timeout=0 时立即返回,不等待
- timeout>0 时会等待指定时间,期间如果有令牌生成则获取成功
-
日志记录:
- 记录令牌桶的创建
- 记录每次请求的到达时间和当前速率
- 记录令牌获取的成功或失败
使用示例
在 Controller 中使用 @Limit 注解:
java
package com.example.controller;
import com.example.annotation.Limit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class TestController {
/**
* 限流配置:
* - key: "limit" - 资源标识
* - permitsPerSecond: 100 - 每秒允许 100 个请求
* - timeout: 0 - 不等待,立即返回
* - msg: 自定义限流提示信息
*/
@Limit(key = "limit", permitsPerSecond = 100, timeout = 0,
msg = "系统繁忙,请稍后再试!")
@GetMapping("/limit/test")
public String test() {
log.info("Test");
return "Hello World";
}
}
单机版的优缺点
优点:
- ✅ 实现简单,代码量少
- ✅ 性能优秀,基于内存操作
- ✅ Guava RateLimiter 久经考验,稳定可靠
- ✅ 支持平滑限流和突发流量处理
缺点:
- ❌ 只能在单机环境下使用
- ❌ 无法在分布式系统中共享限流状态
- ❌ 应用重启后限流状态丢失
- ❌ 多实例部署时,总 QPS = 单实例 QPS × 实例数
Redis 限流原理
Redis 实现限流主要有两种方案:
- 基于 Redis 计数器:使用 INCR + EXPIRE 实现固定窗口计数
- 基于 Lua 脚本的令牌桶:使用 Lua 脚本保证原子性
本文采用 Lua 脚本实现令牌桶算法,确保分布式环境下的一致性。
Redis 版注解定义
java
package com.example.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface RedisLimit {
/**
* 资源的唯一标识 key
* 会自动添加前缀 "rate_limit:"
*/
String key() default "";
/**
* 每秒允许的请求数量(QPS)
*/
double permitsPerSecond();
/**
* 令牌桶的最大容量
* 默认等于 permitsPerSecond,可以设置更大以支持突发流量
*/
double maxPermits() default 0;
/**
* 获取令牌的超时时间
*/
long timeout() default 0;
/**
* 超时时间的单位
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
/**
* 限流时返回的提示信息
*/
String msg() default "系统繁忙,请稍后再试";
}
Lua 脚本实现令牌桶
创建 Lua 脚本文件 rate_limiter.lua:
lua
-- 令牌桶限流 Lua 脚本
-- KEYS[1]: 令牌桶的 key
-- ARGV[1]: 令牌桶容量(最大令牌数)
-- ARGV[2]: 每秒生成的令牌数
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 需要消耗的令牌数(通常为 1)
local key = KEYS[1]
local max_permits = tonumber(ARGV[1])
local permits_per_second = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
local required_permits = tonumber(ARGV[4])
-- 获取当前令牌桶状态
local bucket = redis.call('HMGET', key, 'last_time', 'current_permits')
local last_time = tonumber(bucket[1])
local current_permits = tonumber(bucket[2])
-- 初始化令牌桶
if last_time == nil then
last_time = current_time
current_permits = max_permits
end
-- 计算时间间隔(秒)
local time_elapsed = math.max(0, (current_time - last_time) / 1000)
-- 计算新增的令牌数
local new_permits = math.min(max_permits, current_permits + time_elapsed * permits_per_second)
-- 尝试获取令牌
local allowed = 0
if new_permits >= required_permits then
new_permits = new_permits - required_permits
allowed = 1
end
-- 更新令牌桶状态
redis.call('HMSET', key, 'last_time', current_time, 'current_permits', new_permits)
redis.call('EXPIRE', key, 10) -- 设置过期时间,防止内存泄漏
return allowed
Lua 脚本优势:
- 原子性:整个脚本在 Redis 中原子执行,不会被其他命令打断
- 高性能:减少网络往返次数
- 一致性:保证分布式环境下的数据一致性
Redis 版 AOP 切面实现
java
package com.example.aop;
import com.example.annotation.RedisLimit;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.Collections;
@Slf4j
@Aspect
@Component
public class RedisLimitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* Lua 脚本对象
*/
private DefaultRedisScript<Long> rateLimiterScript;
/**
* 初始化 Lua 脚本
*/
@PostConstruct
public void init() {
rateLimiterScript = new DefaultRedisScript<>();
rateLimiterScript.setResultType(Long.class);
// 从 classpath 加载 Lua 脚本
rateLimiterScript.setScriptSource(
new ResourceScriptSource(new ClassPathResource("scripts/rate_limiter.lua"))
);
}
/**
* 环绕通知:拦截所有带有 @RedisLimit 注解的方法
*/
@Around("@annotation(com.example.annotation.RedisLimit)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// 1. 获取方法签名和注解信息
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
RedisLimit limit = method.getAnnotation(RedisLimit.class);
if (limit != null) {
String key = "rate_limit:" + limit.key();
double permitsPerSecond = limit.permitsPerSecond();
double maxPermits = limit.maxPermits() > 0 ?
limit.maxPermits() : permitsPerSecond;
// 2. 记录请求信息
long requestTime = System.currentTimeMillis();
log.info("Redis限流 - 请求到达,key={},时间={},速率={}/s",
key, requestTime, permitsPerSecond);
// 3. 执行 Lua 脚本获取令牌
Long result = redisTemplate.execute(
rateLimiterScript,
Collections.singletonList(key),
String.valueOf(maxPermits),
String.valueOf(permitsPerSecond),
String.valueOf(requestTime),
"1" // 需要消耗的令牌数
);
// 4. 判断是否获取到令牌
if (result == null || result == 0) {
log.warn("Redis限流 - 获取令牌失败,key={},时间={}",
key, System.currentTimeMillis());
throw new RuntimeException(limit.msg());
}
// 5. 令牌获取成功
log.info("Redis限流 - 获取令牌成功,key={},时间={}",
key, System.currentTimeMillis());
}
// 6. 执行目标方法
return pjp.proceed();
}
}
实现要点:
. Lua 脚本加载:
- 使用
@PostConstruct在 Bean 初始化时加载脚本 - 脚本文件放在
resources/scripts/目录下
两种方案对比
| 特性 | Guava 单机版 | Redis 分布式版 |
|---|---|---|
| 适用场景 | 单机应用 | 分布式系统 |
| 性能 | 极高(内存操作) | 较高(网络 IO) |
| 一致性 | 单机一致 | 分布式一致 |
| 状态持久化 | 不支持 | 支持 |
| 实现复杂度 | 简单 | 中等 |
| 依赖 | Guava | Redis |
| 扩展性 | 差 | 好 |
| 故障影响 | 单机故障 | Redis 故障影响全局 |