一、什么是令牌桶限流?
令牌桶(Token Bucket)算法是一种常用的流量控制机制,用于限制系统在单位时间内的请求处理速率,以防止突发流量导致服务过载。
其核心思想是:系统以恒定速率向桶中添加令牌,桶具有最大容量。当桶满时,新生成的令牌将被丢弃。每个请求需消耗一个令牌,若桶中无可用令牌,则请求被拒绝。
与漏桶(Leaky Bucket)算法相比,令牌桶允许一定程度的突发流量(只要桶中有足够令牌),因此在实际业务场景中更为常用。
二、代码实现
1、常量定义
public class RedisConstants {
// 令牌桶 Redis Key 模板:rate_limit:bucket:{userId}:{fieldId}
public static final String TOKEN_BUCKET_KEY = "rate_limit:bucket:%d:%d";
}
2、限流方法
@Service
public class RateLimitService {
@Autowired
private RedisTemplate redisTemplate;
// 每秒生成令牌数,可根据业务需求调整
private static final double RATE = 2.0;
// 令牌桶的最大容量,容量越大,允许的突发容量越高
private static final double CAPACITY = 10.0; // 桶最大容量
/**
* 判断是否允许执行请求操作(消耗一个令牌)
* @param userId 用户ID
* @param fieldId 参数ID
* @return true 表示允许,false 表示被限流
*/
public boolean tryAcquire(Long userId, Long fieldId) {
// 构造令牌桶唯一键
String key = String.format(RedisConstants.TOKEN_BUCKET_KEY, userId, fieldId);
// 获取当前系统时间
long now = System.currentTimeMillis();
Boolean result = redisTemplate.execute((RedisCallback<Boolean>) connection -> {
// Redis底层使用byte[]
byte[] rawKey = key.getBytes(StandardCharsets.UTF_8);
// 当前令牌数
byte[] tokensField = "tokens".getBytes(StandardCharsets.UTF_8);
// 上次请求时间戳
byte[] lastTimeField = "last_time".getBytes(StandardCharsets.UTF_8);
// 从 Redis Hash 中读取当前令牌数和上次更新时间
byte[] tokensBytes = connection.hGet(rawKey, tokensField);
byte[] lastTimeBytes = connection.hGet(rawKey, lastTimeField);
// 解析当前令牌数:若 key 不存在(首次访问),则初始化为桶的最大容量
double currentTokens = (tokensBytes != null)
? Double.parseDouble(new String(tokensBytes, StandardCharsets.UTF_8))
: CAPACITY;
// 解析上次更新时间:若不存在,则从当前时间开始计时
long lastTime = (lastTimeBytes != null)
? Long.parseLong(new String(lastTimeBytes, StandardCharsets.UTF_8))
: now;
// 计算自上次以来新增的令牌数(按秒为单位)
// 注意:此处按秒取整,牺牲毫秒级精度以简化逻辑,适用于大多数业务场景
long deltaSeconds = (now - lastTime) / 1000;
// 新令牌数 = 当前令牌 + 新增令牌,但不超过桶的最大容量
double newTokens = Math.min(CAPACITY, currentTokens + deltaSeconds * RATE);
// 尝试消费一个令牌
if (newTokens >= 1) {
newTokens -= 1;
// 更新令牌数和时间戳
connection.hSet(rawKey, tokensField, String.valueOf(newTokens).getBytes(StandardCharsets.UTF_8));
connection.hSet(rawKey, lastTimeField, String.valueOf(now).getBytes(StandardCharsets.UTF_8));
// 设置过期时间:略大于桶填满所需时间,避免长期不活跃的 key 永久占用内存
connection.expire(rawKey, (int) (CAPACITY / RATE + 2));
return true;
} else {
// 令牌不足,拒绝请求
return false;
}
});
return Boolean.TRUE.equals(result);
}
}
3、请求示例
@Autowired
private RateLimitService rateLimitService;
@PostMapping("/save")
public Result<String> acquire(@RequestParam Long userId, @RequestParam Long fieldId) {
if (!rateLimitService.tryAcquire(userId, fileId)) {
throw new BaseException("请求过于频繁,请稍后再试!")
}
// 执行保存逻辑...
return Result.su***ess("请求成功!")
}
三、核心概念
1. 为什么使用令牌桶(Token Bucket),而不是漏桶(Leaky Bucket)?
漏桶算法以恒定速率处理请求,即使系统空闲也无法应对突发流量,而令牌桶允许在桶未满时积累令牌,从而支持短时间内的突发请求(burst traffic)。因此,令牌桶在保证平均速率的同时,提供了更好的用户体验和系统弹性。
2. 为什么选择 Redis Hash 存储令牌桶状态,而不是 String、List 或多个独立 Key?
首先明确一点,令牌桶需要维护两个状态,分别是当前令牌数量(tokens)和上次有效请求时间戳(last_time)。因此对于 String 就无法保证两个 Key 操作的原子性,在并发状态下会导致状态覆盖。
Redis List 是有序的字符串集合,无法通过字段名(tokens)访问特定值,只能通过索引进行访问,若希望修改某个值需事先知道其结构,如位置0是 tokens,因此语义不清晰、易出错。并且List 本质是为序列数据设计的,而非结构化状态。
综上,Hash 是存储结构化状态的最优选择。
3、为什么整个逻辑要放在 RedisCallback 中执行,而不是多次调用 redisTemplate.opsForHash()?
redisTemplate 的高层 API(如 opsForHash().get())每次调用都会创建新的连接和序列化开销,且无法保证多步操作的原子性,而 RedisCallback 允许我们在同一个 Redis 连接中连续执行多条原生命令,不仅减少网络往返(RTT),更重要的是在 Redis 单线程模型下,这些命令不会被其他客户端打断,逻辑上等效原子,而且避免了“读-改-写”过程中的竞态条件(race condition)。
四、拓展
1、Lua 脚本
当前实现依赖 RedisCallback 在 Java 端计算逻辑,仍存在多次 Redis 命令往返,更优方案是将整个令牌桶逻辑封装为 Lua 脚本,从而实现在 Redis 服务端原子执行。
-- token_bucket.lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local tokens = redis.call('HGET', key, 'tokens')
local last_time = redis.call('HGET', key, 'last_time')
tokens = tokens and tonumber(tokens) or capacity
last_time = last_time and tonumber(last_time) or now
local delta = math.floor((now - last_time) / 1000)
local new_tokens = math.min(capacity, tokens + delta * rate)
if new_tokens >= 1 then
new_tokens = new_tokens - 1
redis.call('HSET', key, 'tokens', tostring(new_tokens))
redis.call('HSET', key, 'last_time', tostring(now))
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 2)
return 1 -- 允许请求
else
return 0 -- 拒绝请求
end
2、拒绝策略优化
在系统开发中我们需要明白:限流的最终目的不是阻止用户,而是保护系统的同时提供可预期的反馈。
当请求被限流时,若直接返回 false,用户将面临“操作失败”却不知如何应对,体验较差。
更友好的做法是:
-
返回明确的重试提示(如 HTTP 429 状态码 +
Retry-After头); - 启用降级策略,例如将请求暂存至延迟队列,稍后自动重试;
- 引入熔断机制,当某用户或接口在短时间内连续触发限流超过阈值时,可暂时关闭非核心功能或引导用户稍后再试,避免系统持续承压。
本文基于 Redis 实现了一个轻量、高效、可扩展的分布式令牌桶限流方案,无需引入复杂中间件,仅依赖 Spring 与 Redis 即可满足大多数业务场景下的流量控制需求。通过合理使用 Redis Hash 结构、原子化操作与时间窗口计算,在保证线程安全的同时,兼顾了性能与语义清晰性。
限流不是终点,而是系统韧性设计的起点。结合监控、动态配置与友好反馈,才能构建真正用户友好且稳定可靠的后端服务。