分布式限流是后端系统中绕不开的话题。做过高并发业务的人都知道,单纯靠单机 Guava RateLimiter 根本无法应对多实例部署的场景------限流必须是全局的、一致的。Redisson 提供的 RRateLimiter 是目前 Java 生态里使用最广泛的分布式限流方案之一,但它的内部到底怎么运作的,能深入说清楚的文章并不多。
这篇文章不会停留在"引入依赖、加个注解"的层面。我们将从限流算法选型开始,一路拆到 Redis 里那几行 Lua 脚本的执行逻辑,弄清楚它为什么快、为什么准、边界条件又在哪里。
一、为什么是令牌桶
做限流的算法方向其实就那么几种:计数器窗口、滑动窗口、漏桶、令牌桶。计数器窗口最简单,但临界值的毛刺问题几乎无解;滑动窗口解决了毛刺,但实现成本和精度之间的平衡不好把握;漏桶虽然能平滑流量,但它强制的恒定速率在突发场景下过于死板。
令牌桶之所以成为 Redisson 的选择,核心在于它天然支持突发流量。桶里攒下来的令牌可以在短时间内被一次性取走,这对很多业务场景来说是有意义的------秒杀下单、抢票瞬时流量就是典型的例子。Redisson 在令牌桶的基础上做了两层关键的工程化改进:一是整个限流状态全部存在 Redis 里,所有实例共享同一份数据;二是核心逻辑用 Lua 脚本原子化执行,杜绝了"读-判断-写"之间的竞态窗口。
二、数据结构:Redis 里到底存了什么
打开 Redisson 源码,每个限流器实例在 Redis 里都对应一个 HASH 结构,key 就是你给限流器起的名字。这个 HASH 里有三个字段,缺一个都不行:
| 字段 | 含义 |
|---|---|
rate |
令牌产生速率,单位是个/毫秒 |
interval |
令牌产生的时间间隔,单位是毫秒 |
value |
当前剩余令牌数 |
前两个字段在初始化时写入,之后不再变化。value 字段则是整个限流器的核心状态------每次获取令牌,就是对这个字段做一次带时间补偿的扣减。
没有用 String 存单个值,是因为初始化时 rate 和 interval 需要一起落地,两次 SET 不具备原子性。HASH 里的 HMSET 天然保证多个字段一步写完,这也是分布式场景下的一个基本取舍。
三、核心 Lua 脚本:逐行拆解
Redisson 限流的灵魂全在一段 Lua 脚本里。这段脚本不长,但每一行都有精确的意图。我把伪代码还原成可读的逻辑,如下:
-- KEYS[1]: 限流器的 Redis key
-- ARGV[1]: rate (令牌产生速率)
-- ARGV[2]: interval (间隔)
-- ARGV[3]: 当前时间戳
-- ARGV[4]: 请求的令牌数
local rate = tonumber(ARGV[1])
local interval = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 1. 从 HASH 取当前剩余令牌和上一次更新时间
local bucket = redis.call('HMGET', KEYS[1], 'value', 'rate', 'interval')
local value = tonumber(bucket[1])
local storedRate = tonumber(bucket[2])
local storedInterval = tonumber(bucket[3])
-- 2. 首次使用或配置变更,重置桶
if value == nil or storedRate ~= rate or storedInterval ~= interval then
value = rate
redis.call('HMSET', KEYS[1], 'rate', rate, 'interval', interval, 'value', value)
end
-- 3. 计算这段时间应该补充多少令牌(核心算法)
local elapsed = now - redis.call('TIME')[1]
local generated = math.floor(elapsed / interval)
value = math.min(rate, value + generated)
-- 4. 时间补偿:向前推进到期时间
if generated > 0 then
redis.call('HMSET', KEYS[1], 'value', value)
-- 记录本次补充对应的时间点
end
-- 5. 判断令牌是否足够
if value >= requested then
value = value - requested
redis.call('HSET', KEYS[1], 'value', value)
return 1 -- 获取成功
else
return 0 -- 被限流
end
上面这段逻辑的精妙之处在第 3 步------时间补偿机制。限流器不会有一个后台线程定时往桶里加令牌。每次请求进来时,它实时计算"从上次扣减到现在过了多久、理论上应该生成多少令牌",然后一次性补上。这意味着限流器是懒计算的:没请求就不算,来了请求才按时间差补偿。
这个设计的工程意义很直接------省资源。不需要定时器、不需要后台 job,也不会因为某个限流器业务上根本没流量就白白消耗 Redis 的 CPU。在高并发场景下,每次请求多算一次 math.floor(elapsed / interval) 的开销可以忽略不计,远好过维护一堆定时任务。
四、rate 和 interval 的设定逻辑
Redisson 初始化 RRateLimiter 时,调用的是 trySetRate(RateType type, long rate, long rateInterval, RateIntervalUnit unit)。这里面有一个容易让人困惑的点:rate 和 rateInterval 到底怎么映射到 rate 和 interval 两个字段?
实际转换逻辑是这样的:
-
如果
type = OVERALL(全局限流):HASH 中的rate= 你传入的rate,interval=rateInterval(换算成毫秒)。比如trySetRate(OVERALL, 10, 1, SECONDS),意味着每 1000ms 产生 10 个令牌,interval= 1000,rate= 10。 -
如果
type = PER_CLIENT(单客户端限流):逻辑完全一样,只是 key 的命名带上了客户端 ID,限流维度从全局变成一个客户端实例。
这里有一个隐含的行为要注意:rate 字段并不是传统令牌桶的"桶容量"。Redisson 里桶的容量就等同于 rate。这意味着瞬时并发最多只能吃掉一个周期内产出的全部令牌,没法像经典令牌桶那样积攒多个周期的额度。这个设计的优点是内存占用和计算复杂度都极低,缺点是没有真正的"蓄洪"能力。如果你的业务需要积攒数分钟的配额,Redisson 的默认实现不一定合适。
五、TIME 命令的作用:为什么不用传时间戳
上面的 Lua 脚本里有一行值得单独拿出来说:
local elapsed = now - redis.call('TIME')[1]
now 是客户端传进来的时间戳,redis.call('TIME') 返回的是 Redis 服务端的时间(秒级精度)。两个时间做差,用来计算"距离上次补充令牌过去了多久"。
为什么要拿服务端时间做基准?因为分布式环境下各台机器的时钟不可能完全对齐。假设机器 A 慢了 500ms,机器 B 快了 300ms,如果两边的请求都用自己的时间戳去算令牌补充量,限流就完全失准了。以 Redis 服务端时间为基准,所有客户端共享同一个时钟源,误差只取决于各自和 Redis 之间的网络延迟------这个差异在毫秒级,对限流来说完全可以接受。
不过这里有一个细节:TIME 返回的是秒,而限流间隔通常是毫秒级(比如每 100ms 产生一个令牌)。秒级精度意味着在极端高频场景下(例如 10000 QPS 级别的限流),时间补偿可能会有细微误差。但对于绝大多数业务来说,这一点误差远构不成问题。
六、获取令牌的完整调用链
从 Java 代码到 Redis 执行,整个链路分三步:
-
RRateLimiter.tryAcquire():用户调用的入口。最终会调用tryAcquireAsync(),进入异步模式。 -
RedisExecutor发送EVAL命令 :将前面的 Lua 脚本和参数一起发给 Redis。这里用的是EVAL而非EVALSHA,因为 Redisson 没有预加载脚本缓存。这个选择对于限流场景影响不大------限流调用频率远低于缓存读写,EVAL带来的额外网络开销可以接受。 -
Redis 单线程执行 Lua :脚本在 Redis 服务端原子化执行,从
HMGET读取、计算时间差、判断令牌、HSET扣减一气呵成,中间不会被任何其他命令打断。这是分布式限流"准确"的根基------不是靠分布式锁,而是靠 Redis 的单线程执行模型天然保证了脚本的串行化。
整个流程没有加锁、没有 ZK 的临时节点、没有复杂的共识协议。Redisson 选择了最简单的方案:信任 Redis 的单线程模型 + Lua 原子化,把所有复杂问题交给一层间接性来解决。
七、边界情况与坑点
实际落地中有几个点值得留意:
令牌被凭空浪费 :trySetRate 重复调用时,Lua 脚本检测到 rate 或 interval 变化会直接重置桶,当前的剩余令牌全部丢弃。如果业务里需要动态调限流阈值,建议新建一个限流器实例,而不是反复 trySetRate。
没有预热机制 :Guava RateLimiter 有个经典的 warmupPeriod 参数,允许令牌桶从 0 开始慢慢加速到满速率,防止冷启动瞬间把下游打崩。Redisson 目前没有实现这个特性,从零开始就是满速率。
过期时间缺失:Redisson 的限流器 key 默认不过期。如果某个限流器业务下线了,对应的 key 会一直留在 Redis 里直到手动清理。长期运行的系统建议配合监控做定期回收。
间隔的倍数陷阱 :interval 是毫秒精度,如果你设成 rate=1000000, interval=1ms,实际上 Lua 里的 math.floor(elapsed / 1) 每次都会算出大量令牌,这本身没问题,但 Redis 的 TIME 命令秒级精度在这个量级下会引入不可忽略的误差。
八、总结
Redisson 的分布式限流实现思路其实非常克制:选定令牌桶模型,用 Redis HASH 存状态,靠 Lua 脚本原子化执行核心逻辑,利用时间补偿机制懒计算令牌产出。没有引入额外的中间件、没有复杂的分布式协调、没有繁重的线程模型。
几个核心要点再归纳一下:
-
令牌桶天然支持突发流量,适合大多数互联网业务;
-
HASH 结构保证初始化数据原子落地;
-
Lua 脚本在 Redis 单线程模型中天然串行化,不需要分布式锁;
-
时间补偿使限流器零维护成本运行,按需计算;
-
桶容量 =
rate值,没有多周期蓄洪能力,这是取舍而非缺陷; -
服务端时钟作为基准,规避了分布式系统的时间不同步问题。
理解这些之后,再去用 @RateLimiter 注解或者手动 tryAcquire,心里就有一张完整的执行地图了。分布式限流不再是黑盒------它就是在 Redis 里跑着的几十行 Lua,仅此而已。