限流器需要回答两个问题:允许多少请求通过,以及以什么节奏通过。令牌桶给出的答案是:平均速率由令牌生成速度决定,突发规模由桶容量决定。把桶放进 Redis,是因为分布式部署下多个实例共享同一个限流状态,必须有一个集中式的存储来统一计数。这里采用 Hash 保存两个字段:当前剩余令牌数和上次补充令牌的时间戳。每次请求到来时,先在内存中计算应当补充多少令牌,再判断是否足够扣除,最后把新状态写回 Redis。这三个动作如果在客户端分开执行,就存在并发覆盖和超发风险,而 Redis 的 Lua 脚本可以在服务端一次性完成,天然规避这个问题。

令牌桶算法解决什么问题
固定窗口计数是最容易想到的限流方案,例如限制接口每分钟最多 600 次。但如果上一分钟的最后 10 秒和下一分钟的前 10 秒分别用完 600 次,实际在 20 秒内放过了 1200 次,远超出系统设计的平均速率。滑动窗口可以缓解边界问题,但实现成本和存储开销更高。漏桶算法则严格平滑流量,所有请求像从桶底小孔流出,不管上游来得多猛,下游速率始终一致,代价是突发流量被强行削平,延迟敏感的业务可能因此超时。
令牌桶处于两者之间。系统以固定速率生成令牌,桶的容量有限,放满后新令牌直接丢弃。请求到达时必须从桶中取走一个或多个令牌,取不到就拒绝或等待。因为令牌可以积攒,所以允许一定程度的突发;因为生成速率恒定,所以长时间来看平均通过量不会超过预设值。这一特性非常适合 API 网关、消息发送、第三方接口调用等既要保护后端又要容忍瞬时高峰的场景。
在分布式环境中,单机内存实现的令牌桶无法保证全局统一。多个实例各自维护桶状态,最终限制值会被放大。Redis 作为集中式存储,天然适合保存桶的剩余令牌和时间戳。配合 Lua 脚本,还能把计算、比较和写回做成一个原子步骤,避免分布式锁带来的额外复杂度和性能损失。
基于 Redis Hash 和 Lua 的原子实现
最简单的数据结构是一个 Redis Hash,键为业务限流标识,例如 rate:user:123:api:login,字段 tokens 保存当前剩余令牌数,字段 ts 保存上次补充令牌的时间戳。之所以不用两个独立的 String 键,是因为如果分开读写,仍然存在读到一半状态的问题;Hash 可以一次性取出多个字段,减少往返次数。
下面这段 Lua 脚本会先读取当前桶数据,如果是首次访问则初始化桶为满容量。然后根据当前时间与上次补充时间的差值,乘以每秒生成速率,得到这段间隔内应该新增的令牌数。新增令牌与剩余令牌相加后不能超过容量,这是令牌桶最关键的封顶逻辑。最后判断剩余令牌是否足够扣除本次请求所需的令牌数,足够则扣减并返回 1,不足则保持原状返回 0。
-- KEYS[1] 限流键
-- ARGV[1] 桶容量
-- ARGV[2] 每秒生成令牌数
-- ARGV[3] 本次请求需要的令牌数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
local time = redis.call('TIME')
local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local last_refill = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_refill = now_ms
end
local delta = math.max(0, now_ms - last_refill)
local refill = delta * (rate / 1000.0)
if refill > 0 then
tokens = math.min(capacity, tokens + refill)
last_refill = now_ms
end
local allowed = false
if tokens >= requested then
tokens = tokens - requested
allowed = true
end
redis.call('HSET', key, 'tokens', tokens, 'ts', last_refill)
redis.call('PEXPIRE', key, 60000)
return {allowed and 1 or 0, math.floor(tokens)}
脚本中有两个容易忽略的地方。第一,首次访问时把 last_refill 初始化为当前时间,而不是 0,否则会用当前时间减 0 得到巨大差值,瞬间把桶补满,使隐藏的旧键获得错误放行。第二,补充动作只在 refill > 0 时执行,避免每次请求都写 Redis。即使 refill 等于 0,后续仍会执行 HSET 写回当前令牌数,才能保证无令牌时状态准确。
Lua 脚本由 Redis 服务端执行,执行期间不会处理其他命令,因此脚本内部的读改写天然是原子的。只要所有限流请求都走这个脚本,就不会出现两个请求同时判断令牌足够后一起扣减的情况。另一个好处是网络往返少,客户端只需一次 EVAL 就能拿到是否允许的结果,适用于高频调用的入口。
Java 客户端调用示例
实际项目中,通常把 Lua 脚本定义成常量,通过 Jedis 或 Lettuce 发送到 Redis。下面的示例使用 Jedis,先声明一段脚本文本,再在 tryAcquire 方法中把限流键、容量、速率和请求令牌数作为参数传入。方法返回 true 表示本次请求可以放行,返回 false 表示需要限流。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Arrays;
import java.util.Collections;
public class RedisTokenBucketLimiter {
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local requested = tonumber(ARGV[3])\n" +
"local time = redis.call('TIME')\n" +
"local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)\n" +
"local data = redis.call('HMGET', key, 'tokens', 'ts')\n" +
"local tokens = tonumber(data[1])\n" +
"local last_refill = tonumber(data[2])\n" +
"if tokens == nil then\n" +
" tokens = capacity\n" +
" last_refill = now_ms\n" +
"end\n" +
"local delta = math.max(0, now_ms - last_refill)\n" +
"local refill = delta * (rate / 1000.0)\n" +
"if refill > 0 then\n" +
" tokens = math.min(capacity, tokens + refill)\n" +
" last_refill = now_ms\n" +
"end\n" +
"local allowed = false\n" +
"if tokens >= requested then\n" +
" tokens = tokens - requested\n" +
" allowed = true\n" +
"end\n" +
"redis.call('HSET', key, 'tokens', tokens, 'ts', last_refill)\n" +
"redis.call('PEXPIRE', key, 60000)\n" +
"return {allowed and 1 or 0, math.floor(tokens)}";
private final JedisPool jedisPool;
public RedisTokenBucketLimiter(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
public boolean tryAcquire(String key, int capacity, double rate, int requested) {
try (Jedis jedis = jedisPool.getResource()) {
Object result = jedis.eval(LUA_SCRIPT,
Collections.singletonList(key),
Arrays.asList(String.valueOf(capacity), String.valueOf(rate), String.valueOf(requested)));
return ((Long) result) == 1L;
}
}
}
在 Java 字符串拼接中,Lua 脚本的换行通过 \n 表达,这样可以保持脚本可读性,也避免把大段文本写在一行。若使用的客户端版本较新,eval 方法返回类型可能因脚本返回数组而变化,这里因为返回值固定为数组,Jedis 通常返回 Long 或包含一个元素的 List,不同版本需做一次类型判断,避免强转异常。
生产环境不建议每次请求都重新创建 Jedis,应使用连接池复用连接。也不太建议开启 EVALSHA 缓存逻辑之外的复杂降级,如果 Redis 短暂不可用,可以选择直接放行或直接拒绝,具体取决于业务可承受的风险。对于关键路径,建议配置超时时间,防止连接池被限流调用拖垮。
容量、速率与精度调优
桶容量 capacity 并不等于最大 QPS,而是允许的单次突发量。如果每秒生成 10 个令牌,容量也是 10,那么空桶后第 1 秒最多能取走 20 个令牌,其中 10 个是积攒的存量,10 个是这一秒新增的。这个细节经常被误解,于是把容量设置成每秒限流值,结果高峰瞬间还是放过了接近两倍流量。如果希望严格限制任意 1 秒内的通过量,应把容量设置得更小,或者直接改用滑动窗口方案。
速率参数需要统一单位。上文脚本传入的是每秒令牌数,内部转换为毫秒增量。由于 Redis TIME 命令能返回微秒级时间,毫秒计算足够覆盖绝大多数业务。如果限流间隔极短或要求纳秒级精度,单靠 Redis 时间戳和 Lua 脚本还不够,建议在客户端预生成时间参数或者使用专业网关组件。
还有一个维护性问题:每个限流键必须在限流周期结束后清理,否则会占用内存。脚本中设置了 PEXPIRE,过期时间应当大于桶容量除以速率得到的最长静默时间,同时也要考虑业务方希望保留多久的窗口状态。如果使用 Redis Cluster,还应注意 Lua 脚本中只能访问同一个哈希槽内的键,通常限流键就在一个槽上,不会跨键,但多键操作时需用哈希标签保证落到同一节点。