Redisson 限流器原理
版本说明
本文源码分析基于 Redisson 3.24.3,文中 Lua 脚本与结论均以该版本为准;涉及历史行为对比时另行标注版本号。
核心设计
Redisson 的 RRateLimiter 采用"发放记账、到期回收"的滑动窗口模型,状态拆成三个 Redis 结构(以 key 为 mykey、60 秒放行 1 次、OVERALL 模式为例):
| 结构 | key | 职责 |
|---|---|---|
| Hash | mykey | 限流配置:rate(速率)、interval(窗口时长,毫秒)、type(模式) |
| String | {mykey}:value | 当前可用令牌数,纯数字读写快,相当于缓存 |
| ZSet | {mykey}:permits | 每次发放的记录:member = 随机串 + permits 数打包,score = 发放时间戳;回收与纠偏都以它为准,是权威数据源 |
key 中的大括号是 Redis Cluster 的 hash-tag:五个相关 key 会被哈希到同一个槽位,Lua 脚本里的跨 key 操作才能原子执行。
type 决定令牌桶的作用范围:
OVERALL(type=0):所有客户端共享一个桶,即分布式限流;PER_CLIENT(type=1):每个 Redisson 客户端实例一个独立桶,此时 value/permits 换用{mykey}:value:{实例id}、{mykey}:permits:{实例id}。
初始化:trySetRateAsync
org.redisson.RedissonRateLimiter#trySetRateAsync 用三个 hsetnx 写入配置——字段已存在时不覆盖,所以 trySetRate 只在首次调用时生效:
-- 假设 60s 1次,type=0 OVERALL 模式(所有客户端共享令牌桶)
-- KEYS[1]: mykey
-- ARGV[1]: 1, ARGV[2]: 60000, ARGV[3]: 0
-- hsetnx mykey rate 1
redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);
-- hsetnx mykey interval 60000
redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);
-- hsetnx mykey type 0
return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);获取令牌的整体流程
tryAcquireAsync 的一次完整执行流程如下,可先对照此图建立整体印象,再阅读脚本原文:
获取令牌:tryAcquireAsync
org.redisson.RedissonRateLimiter#tryAcquireAsync(org.redisson.client.protocol.RedisCommand<T>, java.lang.Long):
-- KEYS[1] = mykey
-- KEYS[2] = {mykey}:value
-- KEYS[4] = {mykey}:permits
-- KEYS[3] = {mykey}:value:uuid
-- KEYS[5] = {mykey}:permits:uuid
-- ARGV[1] = permits: 1
-- ARGV[2] = now(客户端系统时钟 System.currentTimeMillis(),非 Redis 服务端时间)
-- ARGV[3] = 16 字节随机串(仅用于保证 ZSet member 唯一)
-- hget mykey rate: 1
local rate = redis.call('hget', KEYS[1], 'rate');
-- hget mykey interval: 60000
local interval = redis.call('hget', KEYS[1], 'interval');
-- hget mykey type: 0
local type = redis.call('hget', KEYS[1], 'type');
-- 字段 rate、interval、type 必须都存在,否则报错
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
-- valueName = {mykey}:value
local valueName = KEYS[2];
-- permitsName = {mykey}:permits
local permitsName = KEYS[4];
-- 如果是 PER_CLIENT 模式(每个 Redisson 客户端实例独立维护令牌桶)
if type == '1' then
valueName = KEYS[3];
permitsName = KEYS[5];
end;
-- 如果请求的令牌数大于 rate,报错
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
-- 获取当前可用令牌数
local currentValue = redis.call('get', valueName);
-- 最终返回需要等待的毫秒数,nil 代表立即成功
local res;
-- 如果非首次获取令牌
if currentValue ~= false then
-- 看一个窗口时间前有多少令牌可被回收
local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
-- 可被回收的令牌数量
local released = 0;
for i, v in ipairs(expiredValues) do
local random, permits = struct.unpack('Bc0I', v);
released = released + permits;
end;
if released > 0 then
-- 回收一个窗口前的所有令牌,将它们放入令牌桶
redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
if tonumber(currentValue) + released > tonumber(rate) then
-- 纠偏机制:超过桶容量,则重新计算当前可用令牌数:当前可用令牌数 = 桶容量 - 当前时间窗已发放令牌数
currentValue = tonumber(rate) - redis.call('zcard', permitsName);
else
currentValue = tonumber(currentValue) + released;
end;
-- 保存当前可用令牌数
redis.call('set', valueName, currentValue);
end;
-- 如果当前不够发放令牌
if tonumber(currentValue) < tonumber(ARGV[1]) then
local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores');
-- 估算距离下一次回收到令牌,最快还要多久(3 是一个保守兜底经验值——覆盖网络往返 + Redis 调度延迟)
res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]));
-- 如果当前令牌够发放
else
-- 记录令牌发放时间和令牌发放数量
redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
-- 发放令牌
redis.call('decrby', valueName, ARGV[1]);
res = nil;
end;
-- 如果是首次获取令牌
else
-- 设置当前令牌数为桶容量
redis.call('set', valueName, rate);
-- 记录令牌发放时间和令牌发放数量
-- 这里用到 Redis Lua 脚本内置的 struct 库,把随机串和当前请求的令牌数打包成二进制串(确保 member 唯一),进行存储
redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1]));
-- 发放令牌
redis.call('decrby', valueName, ARGV[1]);
res = nil;
end;
local ttl = redis.call('pttl', KEYS[1]);
-- 如果 key 设置了 ttl
if ttl > 0 then
-- 那么相应的 valueName 和 permitsName 都设置相同的 ttl
redis.call('pexpire', valueName, ttl);
redis.call('pexpire', permitsName, ttl);
end;
return res;关键机制解析
回收:令牌到期自动归还
每次获取前,脚本先用 zrangebyscore 找出 score ≤ now − interval 的记录,即发放时间早于一个窗口的令牌,累加出可回收数量 released,再 zremrangebyscore 清掉、加回 valueName。效果是:每个令牌在发放满一个窗口后自动归还,任意时刻往前数一个窗口,发放总量不会超过 rate。窗口随每次请求平滑滑动,不存在固定窗口在边界处的突发放行。
纠偏机制何时触发
顺着脚本可以推出一条守恒关系:发放是 decrby 配 zadd,回收是 zrem 配加回,一边少多少另一边就多多少,任何时刻都满足:
当前可用令牌数 + ZSet 在途记录的令牌总和 = rate脚本在 Redis 中原子执行,不存在并发插队的空隙。所以只要两份数据没被动过手脚,currentValue + released 最多正好等于 rate,正常情况下永远走不进纠偏分支。能走进去,说明 valueName 和 permitsName 已经脱钩——桶里的可用数比在途记录凭空多出一截。
最典型的触发路径是 valueName 单独丢失。假设 rate = 10、窗口 60s:10 次获取后 valueName 减到 0,ZSet 里存着 10 条记录;此时 valueName 被误删(或故障恢复后数据不完整),下一个请求会走首次获取分支,把桶重新灌满再扣 1——valueName 变成 9,ZSet 却有 11 条记录,凭空多出 10 个令牌。等旧记录陆续滑出窗口,9 + released 一超过 10,纠偏分支不再做加法,改用 rate - zcard 重算,把 currentValue 强行拉回与 ZSet 对齐。没有这步兜底,旧记录全部回收后 valueName 会被一路加到 19,限流形同虚设。
这个分支的来历与 setRate 的历史行为有关:3.12.5 还是"计数器 + SET px 整桶过期"的旧实现;3.16.8 重写为滑动窗口时,setRateAsync 只清理共享的 value/permits,PER_CLIENT 模式下各实例自己的 key 不受影响,调小 rate 后旧记录一回收就会溢出。3.24.3 已改为按 type 清理对应的 key,这个洞补上了,纠偏分支保留下来作为保险。
两点补充:
- 客户端时钟偏差不会触发纠偏。偏差只是让记录的 score 偏移、令牌早一点或晚一点被回收,发放与回收的总量始终守恒,表现是限流偏松或偏紧,而非计数溢出。
- 纠偏公式用 zcard 数的是记录条数,不是在途令牌数的总和,隐含"一条记录 = 一个令牌"的假设。
tryAcquire()一次取 1 个时两者相等;tryAcquire(n)一次取多个时,一条记录打包 n 个令牌,纠偏会高估可用数。
等待时间估算与重试
令牌不足时脚本不阻塞,而是返回建议等待的毫秒数:
res = 3 + interval - (now - 最早一条记录的 score)其中 interval - (now - firstScore) 是最早的在途令牌归还所需的剩余时间,3ms 是覆盖网络往返与 Redis 调度的保守兜底值。Java 侧拿到 res 后由调度线程延迟 res 毫秒重新执行脚本,循环往复直到拿到令牌——这是 acquire() 阻塞语义的实现;tryAcquire(timeout) 则在剩余超时不足等待时间时直接返回 false。
ARGV[2] 取的是客户端时钟
脚本中的 now 并非 Redis 服务端时间,而是发起请求的客户端系统时钟(System.currentTimeMillis())。OVERALL 模式下所有客户端共享同一个令牌桶,若客户端之间时钟偏差较大,过期回收的边界(now - interval)会随之偏移,限流精度受时钟偏差影响。
struct 二进制打包
struct 格式串 'Bc0I' 含义
B:1 字节无符号数,存随机串长度
c0:紧跟长度对应的随机串
I:无符号整数,存 permits 数
实战:Spring Boot + 注解 + AOP 封装
原理讲完,落到工程里。目标是业务代码只留一个注解,限流的获取、回收、等待全部交给 Redisson:
// 同一个用户 60 秒内最多提交 5 次
@PostMapping("/submit")
@RateLimit(key = "'order:submit:' + #userId", rate = 5, interval = 60, unit = RateIntervalUnit.SECONDS)
public String submit(@RequestParam long userId) {
return "下单成功";
}
// 接口维度全局限流:所有用户共享,每秒最多 100 次
@GetMapping("/detail")
@RateLimit(key = "'order:detail'", rate = 100, interval = 1, unit = RateIntervalUnit.SECONDS)
public String detail() {
return "订单详情";
}key 支持 SpEL,#userId 会被解析成方法实参,实现按人限流;不拼参数则是全局桶。下面是三件套的实现。
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.24.3</version>
</dependency>starter 会按 spring.data.redis 配置自动装配 RedissonClient,直接注入即可。
注解定义:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
/** 限流 key,支持 SpEL,如 "'order:submit:' + #userId" */
String key();
/** 时间窗口内最多放行的请求数 */
long rate() default 10;
/** 时间窗口大小 */
long interval() default 1;
/** 时间窗口单位 */
RateIntervalUnit unit() default RateIntervalUnit.SECONDS;
/** OVERALL:所有实例共享一个桶;PER_CLIENT:每个实例独立一个桶 */
RateType mode() default RateType.OVERALL;
/** 被限流时的提示语 */
String message() default "请求太频繁,请稍后再试";
}切面:
@Aspect
@Component
public class RateLimitAspect {
private static final String KEY_PREFIX = "rate_limit:";
private final RedissonClient redissonClient;
private final SpelExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();
public RateLimitAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String key = KEY_PREFIX + parseSpelKey(pjp, rateLimit.key());
RRateLimiter limiter = redissonClient.getRateLimiter(key);
// 内部是三个 hsetnx,配置只在 key 首次创建时生效
limiter.trySetRate(rateLimit.mode(), rateLimit.rate(), rateLimit.interval(), rateLimit.unit());
if (!limiter.tryAcquire()) {
throw new RateLimitException(rateLimit.message());
}
return pjp.proceed();
}
/** 把 "#userId" 这类 SpEL 表达式解析成方法实参的实际值 */
private String parseSpelKey(ProceedingJoinPoint pjp, String spel) {
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
EvaluationContext context = new MethodBasedEvaluationContext(
null, method, pjp.getArgs(), nameDiscoverer);
return parser.parseExpression(spel).getValue(context, String.class);
}
}限流异常:
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}配合 @RestControllerAdvice 把 RateLimitException 统一转成 429 响应即可,属于常规全局异常处理,不再展开。
几个实战注意点:
trySetRate只在首次生效(内部是三个hsetnx,见「初始化:trySetRateAsync」一节)。线上调整注解参数对已存在的 key 无效——要么用setRate覆盖(会清空桶重新计数),要么发布时换 key。这是最容易踩的坑。- key 的粒度就是限流粒度:拼上
#userId是按人限流,不拼就是全局限流;同一个 key 对应的注解参数要保持一致,否则以首次写入为准。 tryAcquire拿不到令牌立即返回 false,适合绝大多数接口;需要"排队等令牌"的阻塞语义用acquire,它依赖的正是「等待时间估算与重试」一节里的重试机制。- 多实例部署时 mode 保持默认的
OVERALL;换成PER_CLIENT后每个实例各限各的,总放行量会是 rate × 实例数。
跑起来后可以用 redis-cli 验证:KEYS *rate_limit* 能看到三件套——hash(rate/interval/type 配置)、{...}:value(剩余令牌数)、{...}:permits(发放记录),正好对应「核心设计」的表格;持续压测时 ZRANGE {key}:permits 0 -1 WITHSCORES 还能亲眼看到每一笔发放的时间戳和滑动回收的过程。
通俗总结:一个会自动腾车位的停车场
把整个 RRateLimiter 想象成一家停车场管理公司:
- 总车位就是 rate,门口的 LED 剩余车位屏就是 valueName,闸机的入场登记本就是 permitsName。司机瞄一眼屏就知道有没有位置(读 String,快),公司对账永远以登记本为准(ZSet,权威)。
- 这家停车场有条特殊规矩:一个名额只绑定 interval 时长。不管你的车实际停多久,入场满 60 分钟名额自动腾出。每次有车想进场,闸机顺手翻一遍登记本,把入场超过 60 分钟的记录划掉,屏幕数字同步加上——这就是"发放记一笔、到期收一笔"的滑动窗口。
- 如果 LED 屏被人改过数字、或断电后重置了(valueName 与登记本脱钩),划记录时发现加回来会超过总车位数,就翻开登记本重算屏显——纠偏机制。
- 车位满了不拦着人走,闸机只会告知"最早入场的那辆车还有约 X 秒腾位子,X 毫秒后再来刷一次"——等待估算加上 Java 侧的定时重试,合起来就是
acquire()的阻塞效果。 - OVERALL 相当于全城共用一个停车场;PER_CLIENT 相当于每个分店自建小停车场,互不挤占。
- 唯一将就的地方:入场时间按每位司机自己的手表记录(客户端时钟),表不准只会让名额早腾或晚腾,账不会算错。
一句话总结:ZSet 记账,String 缓存,窗口内守恒,脱钩时以账本为准。