Redisson RRateLimiter 与 Resilience4j CircuitBreaker 底层是怎么实现的
在微服务里,限流和熔断经常会放在一起讨论,他们有什么区别呢?
Redisson 的 RRateLimiter 要解决的是分布式环境下的流量配额问题。一个服务可能部署了几十个实例,但这些实例需要共享“每秒最多调用 1000 次”这份额度,所以限流状态必须放在一个协调节点中,这个时候就是redis起到协调作用并通过 Lua 脚本保证多个实例同时抢占额度时不会出现并发问题。
熔断解决的则是另一个问题。服务在调用支付、库存或者第三方接口时,需要持续观察这些调用是否成功、是否变慢。一旦失败率或者慢调用比例超过阈值,就暂时拒绝后续调用,让请求快速失败,防止导致服务雪崩,即由于一个服务不可用导致整个环境都不可用。
Redisson RRateLimiter
Redis 数据结构 + Lua 原子脚本
Resilience4j CircuitBreaker
本地滑动窗口 + 有限状态机
RRateLimiter 保存了三类状态
从 RedissonRateLimiter 的源码来看,它主要维护三类逻辑数据。
第一类是一个 Hash,用来保存限流器配置:
rate
interval
type
keepAliveTime
rate 表示一个时间区间内最多可以获取多少许可,interval 表示时间区间长度,type 表示使用全局限流还是每个 Redisson 实例单独限流。
第二类是一个 String,用来保存当前剩余的许可数量,可以把它理解成 currentValue。
第三类是一个 Sorted Set,用来记录已经成功获取的许可。Sorted Set 的 score 是获取许可时的时间戳,member 中则编码了本次获取的许可数量和一个随机值。随机值的作用是避免多个请求在同一毫秒获取许可时产生相同 member,导致 Sorted Set 中的记录相互覆盖。
它的逻辑关系可以简单理解为:
Hash
保存限流规则
String
还剩多少许可
Sorted Set
哪些许可在什么时候被取走了
为什么有了剩余许可数,还需要一个 Sorted Set?
因为许可不是永久消失的。某个请求获取许可并经过一个完整的 interval 后,这份许可需要重新变成可用状态。Redisson 必须知道每一批许可是什么时候被取走的,才能判断它们什么时候应该归还。
获取许可时 Lua 脚本做了什么
tryAcquire() 最终会执行一段 Redis Lua 脚本。
这段脚本的第一步,是从配置 Hash 中读取 rate、interval 和 type,然后根据 type 选择全局的剩余许可 Key 和 Sorted Set,或者选择当前 Redisson 客户端对应的 Key。
接下来,它读取当前剩余许可数:
local currentValue = redis.call('get', valueName)
如果限流器之前还没有使用过,源码会把剩余许可初始化为配置中的 rate。
如果已经使用过,则需要先回收过期许可。Lua 脚本会从 Sorted Set 中查询时间戳早于 now - interval 的所有记录:
local expiredValues =
redis.call(
'zrangebyscore',
permitsName,
0,
now - interval
)
这些记录表示对应的许可已经离开当前时间窗口,可以重新使用。
脚本会遍历这些 member,通过 struct.unpack 取出每条记录包含的许可数量,将它们累加起来,然后从 Sorted Set 中删除:
redis.call(
'zremrangebyscore',
permitsName,
0,
now - interval
)
最后再把释放出来的许可加回 currentValue。citeturn229578view1turn229578view3
完成回收后,脚本才会判断当前许可是否足够。
如果许可充足,就把这次获取写进 Sorted Set:
redis.call(
'zadd',
permitsName,
now,
packedPermit
)
然后从剩余许可中扣除对应数量:
redis.call(
'decrby',
valueName,
requestedPermits
)
如果许可不足,脚本不会扣减,而是查询 Sorted Set 中最早的一条记录,根据它的 score 计算最近一批许可还有多久到期,并把这个等待时间返回给 Java 客户端。citeturn229578view3
整个过程可以压缩成:
读取规则
↓
清理已经过期的许可记录
↓
将过期许可加回剩余数量
↓
判断当前许可是否足够
↓
足够:记录本次获取并扣减
不足:计算下一批许可的恢复时间
为什么一定要放在 Lua 脚本里
如果把这个过程拆成普通 Redis 命令,会产生很明显的并发问题。
假设 Redis 中还剩一个许可,两个服务实例同时执行:
GET currentValue
两个实例都可能读到 1,然后都认为自己能够获取许可,最终一个许可被使用两次。
加锁当然可以解决,但每次限流都先加锁、读状态、修改状态、再释放锁,成本会比较高,实现也更加复杂。
Redisson 直接把读取、过期回收、判断和扣减全部放进一段 Lua 脚本。Redis 执行 Lua 脚本期间不会穿插执行其他命令,因此对于外部调用方来说,整个获取过程就是一次原子操作。
所以 RRateLimiter 的线程安全和分布式一致性,并不是靠 Java 中的 synchronized,而是依赖 Redis 对 Lua 脚本的原子执行。
RRateLimiter 到底是什么限流算法
RRateLimiter 经常被笼统地称为令牌桶,但如果按照源码行为严格分析,它并不是一个经典的、按照固定速率连续生成令牌的 Token Bucket。
它也不是简单的固定窗口计数器。
固定窗口一般会把时间切成:
10:00:00 ~ 10:00:01
10:00:01 ~ 10:00:02
然后在整个窗口切换时一次性重置计数。这样会产生明显的边界问题:前一个窗口末尾通过一批请求,下一个窗口开始又立即通过一批请求。
RRateLimiter 并不会在某个整点统一重置所有许可。每次成功获取许可都会产生一条带时间戳的记录,这批许可只有在自己的获取时间经过一个完整 interval 后才会恢复。
例如配置一秒最多 10 个许可:
0ms 获取了 4 个
200ms 获取了 3 个
700ms 获取了 3 个
那么这三批许可大致会分别在:
1000ms
1200ms
1700ms
重新变为可用。
从源码行为来看,它更接近一种“剩余许可计数 + 按获取时间延迟归还”的滑动回收模型。它限制的是最近一个完整时间区间内被占用的许可总量,而不是在固定窗口边界统一清零。这个分类是根据其 Sorted Set 时间记录和过期归还逻辑得出的。citeturn941883search0turn229578view3
不过它也不会主动把请求均匀地摊开。
如果当前有 100 个许可,一个请求可以一次性拿走 100 个。也就是说,它允许一定程度的突发流量,只是在接下来的时间区间内不会再有许可可用。
acquire 阻塞时 Redis 在做什么
调用 tryAcquire() 时,如果没有许可,方法可以直接返回 false。
调用带等待时间的 tryAcquire 或者 acquire 时,表面上看线程会一直等待许可,但 Redis 中并没有运行一个长期阻塞的 Lua 脚本。
Lua 脚本发现许可不足后,会根据最早一条许可记录计算等待时间并立即返回。
Java 客户端收到这个 delay 后,通过 Redisson 内部的定时器注册一个延迟任务:
getServiceManager().newTimeout(
task -> tryAcquireAsync(...),
delay,
TimeUnit.MILLISECONDS
);
延迟到期后,客户端重新执行获取许可的 Lua 脚本。设置了总超时时间时,客户端还会计算剩余等待时间,超时后直接完成 false。citeturn229578view0turn229578view1
因此这里的阻塞模型实际上是:
执行 Lua
↓
许可不足,返回 delay
↓
客户端定时等待
↓
到期后重新执行 Lua
Redis 不会为了某个等待许可的请求长期占用执行线程,也没有通过 Redis Pub/Sub 精确唤醒等待者。
这也是为什么官方文档明确说明 RRateLimiter 不保证公平性。多个客户端同时到期后,会重新竞争许可,谁先把 Lua 请求送到 Redis,谁就更可能先获得许可。citeturn257602view2
RRateLimiter 的实现代价
这种实现比简单的 INCR + EXPIRE 更精确,但也有额外成本。
Sorted Set 中保存的不是每一个 permit 一条记录,而是每一次成功获取操作一条记录。例如一次获取 20 个许可,只会写入一个 member,member 中记录数量 20。
但如果系统每次只获取一个许可,并且调用量非常高,那么 Sorted Set 中会保存最近一个 interval 内的大量获取记录。每次获取许可时还需要查询并清理过期数据。
因此,它的内存和执行成本不仅与限流额度有关,也与最近一个时间区间内的成功获取次数有关。
另外,源码将 Java 客户端的 System.currentTimeMillis() 传给 Lua,而不是统一使用 Redis 服务端时间。多个 Redisson 客户端共享 OVERALL 限流器时,如果机器之间的时钟偏差较大,许可的过期回收时间也会受到影响。citeturn229578view3
这意味着分布式部署时仍然需要保证机器时钟基本同步。
Resilience4j 熔断器的入口是什么
再来看熔断。
Resilience4j 并不会把熔断逻辑硬编码进 HTTP Client,而是通过装饰器包裹一次函数调用。
大致过程是:
申请调用权限
↓
执行真实函数
↓
记录执行时间
↓
根据结果调用 onSuccess 或 onError
↓
更新滑动窗口
↓
判断是否需要切换状态
所以不管被保护的是 Feign、RestClient、数据库访问还是普通 Java 方法,熔断器真正关心的只有三件事:
这次调用是否允许执行
这次调用成功还是失败
这次调用花了多长时间
使用 Spring 的 @CircuitBreaker 时,AOP 最终也是在方法外面套上类似的调用流程。核心逻辑仍然落在 Resilience4j 自身的 CircuitBreaker 和 CircuitBreakerStateMachine 中。
熔断器首先是一个状态机
CircuitBreakerStateMachine 内部使用一个 AtomicReference<CircuitBreakerState> 保存当前状态对象:
private final AtomicReference<CircuitBreakerState>
stateReference;
这里保存的不只是一个简单枚举,而是一个实现了 CircuitBreakerState 接口的状态对象。
不同状态对象分别实现:
tryAcquirePermission()
acquirePermission()
releasePermission()
onSuccess()
onError()
getMetrics()
因此,调用方不需要写大量这样的判断:
if (state == CLOSED) {
...
} else if (state == OPEN) {
...
} else if (state == HALF_OPEN) {
...
}
外层只需要调用:
stateReference.get().tryAcquirePermission();
当前是 ClosedState,就执行关闭状态的逻辑;当前是 OpenState,就执行打开状态的逻辑。这是一个比较典型的状态模式。citeturn257602view0turn619968view3
状态切换则通过 AtomicReference.getAndUpdate 完成:
CircuitBreakerState previousState =
stateReference.getAndUpdate(currentState -> {
currentState.preTransitionHook();
return newStateGenerator.apply(currentState);
});
旧状态对象会被替换为新的状态对象,然后发布状态变化事件。citeturn619968view4
CLOSED 状态怎么工作
熔断器处于 CLOSED 时,说明调用链目前被认为是健康的。
ClosedState.tryAcquirePermission() 基本会直接返回 true,因此所有请求都可以正常执行。
调用结束后,Resilience4j 会根据结果调用:
onSuccess(duration, unit)
或者:
onError(duration, unit, throwable)
ClosedState 会把结果交给 CircuitBreakerMetrics,后者将调用结果写入滑动窗口,并返回当前是否超过失败率或者慢调用率阈值。
如果超过阈值,ClosedState 会尝试从 CLOSED 切换到 OPEN。这里还使用了一个 AtomicBoolean isClosed:
if (thresholdExceeded
&& isClosed.compareAndSet(true, false)) {
transitionToOpenState();
}
这是为了防止多个并发请求同时发现阈值超标,然后重复执行状态切换。最终只有一个线程能够把 isClosed 从 true 改为 false。citeturn619968view0turn619968view1
调用结果是怎么分类的
并不是所有异常都一定算失败。
CircuitBreakerStateMachine 会先判断异常是否命中 ignoreExceptionPredicate。被忽略的异常既不算成功,也不算失败。
如果没有被忽略,则继续判断 recordExceptionPredicate。命中的异常会进入 onError,没有命中的异常甚至可以按照成功调用统计。
正常返回的结果也不一定算成功。通过 recordResultPredicate,某些返回值可以被主动记录为失败。例如接口正常返回 HTTP 200,但响应体中的业务状态表示下游不可用,就可以根据返回结果触发失败统计。citeturn377691view1
因此,熔断器统计的并不是简单的“Java 方法有没有抛异常”,而是配置规则判断后的调用结果。
这一步非常关键。库存不足、参数不合法之类的正常业务异常,如果错误地计入熔断器,可能导致下游服务明明正常,熔断器却因为业务失败率过高而打开。
滑动窗口到底存了什么
熔断器需要知道最近一段时间的调用成功率和失败率。
最直接的实现方式是保存最近所有调用:
成功
失败
成功
慢调用
失败
……
每次判断时再遍历整个集合,统计失败率。
但这种方式会产生两个问题。
一方面,每个调用都要创建一条记录,流量越大,内存开销越大。另一方面,每次获取失败率都需要遍历窗口,时间复杂度会随着窗口大小增长。
Resilience4j 使用的是预聚合滑动窗口。它不需要在计算指标时重新遍历所有历史调用,而是在每次调用结束时增量更新统计数据。
滑动窗口有两种模式:
COUNT_BASED
最近 N 次调用
TIME_BASED
最近 N 秒内的调用
``` citeturn257602view1
## COUNT_BASED 如何实现
按调用次数计算时,Resilience4j 使用一个固定长度的环形数组。
假设窗口大小是 10,就创建 10 个 `Measurement`:
```text
[0][1][2][3][4][5][6][7][8][9]
每次新的调用结果到来,head 指针向前移动一格。即将被覆盖的旧 Measurement 会先从总聚合数据中减掉,然后清空,再写入新的调用结果。
核心思想叫做 Subtract-on-Evict,也就是旧桶被移出窗口时,从总统计中做减法。
例如总统计当前是:
总调用数:10
失败数:3
慢调用数:2
总耗时:800ms
最旧的一条失败调用即将被覆盖,就先把它从总统计中减掉:
总调用数:9
失败数:2
慢调用数:2
然后再把新的调用结果加进去。
由于总统计一直都已经计算好了,读取 Snapshot 时只需要复制当前聚合结果,时间复杂度是 O(1),不需要遍历整个数组。citeturn994874view1turn257602view1
同步版本通过 ReentrantLock 保护 record 和 getSnapshot。当前源码也支持无锁滑动窗口实现,具体选择由 SlidingWindowSynchronizationStrategy 决定。citeturn994874view0
TIME_BASED 如何实现
时间窗口仍然使用环形数组,但数组里的每个位置不再代表一次调用,而是代表一秒。
如果配置最近 10 秒,就创建 10 个 Bucket:
第 1 秒
第 2 秒
第 3 秒
……
第 10 秒
同一秒发生的一千次调用不会创建一千条对象,而是全部聚合到当前秒对应的 Bucket 中。
每个 Bucket 主要记录:
失败调用数
慢调用数
总调用数
总调用耗时
窗口向前移动时,过期秒对应的 Bucket 会从总聚合中减掉,然后被重置为新的 epoch second。citeturn994874view2turn257602view1
因此,时间窗口的内存占用主要取决于窗口秒数,而不是这段时间内到底执行了一千次还是一百万次调用。
这也是 Resilience4j 没有保存每次调用明细的原因。熔断器只需要计算失败率和慢调用率,不需要依靠内部窗口还原完整调用日志。
什么时候从 CLOSED 进入 OPEN
每次记录成功或者失败时,CircuitBreakerMetrics 都会生成当前 Snapshot,然后计算失败率和慢调用率。
在计算比例之前,还会先判断调用数量是否达到 minimumNumberOfCalls。
假设配置:
minimumNumberOfCalls = 10
failureRateThreshold = 50%
当前只有两次调用,并且两次全部失败,失败率虽然是 100%,但熔断器仍然不会打开,因为样本数量还不到 10。
达到最小调用数量后,只要失败率大于等于阈值,或者慢调用率大于等于阈值,就会返回超标结果,触发 CLOSED 到 OPEN 的状态变化。citeturn994874view0turn257602view1
这里的慢调用和失败调用是两个独立维度。
某次调用可以是:
快速成功
缓慢成功
快速失败
缓慢失败
源码中的 Outcome 也对应这几种情况。即使接口最终没有报错,但大量请求耗时已经超过 slowCallDurationThreshold,慢调用率同样可以让熔断器提前打开。
OPEN 状态怎么拒绝请求
熔断器进入 OPEN 状态后,会计算一个 retryAfterWaitDuration。
在等待时间到期之前,OpenState.tryAcquirePermission() 直接返回 false,并增加未允许调用的指标。外层最终抛出 CallNotPermittedException,真实的下游函数根本不会执行。citeturn619968view1turn619968view2
这就是所谓的快速失败。
它不是调用下游以后发现失败,再执行 fallback,而是在调用发生之前就被熔断器挡住。
等待时间结束后,有两种进入 HALF_OPEN 的方式。
默认情况下,OPEN 状态不会一直启动一个后台线程轮询时间。下一次请求到来时,tryAcquirePermission() 检查当前时间是否已经超过 retryAfterWaitDuration,如果超过,就尝试切换到 HALF_OPEN。
如果启用了自动转换,OpenState 会通过调度器创建一个延迟任务,在等待时间结束后主动执行状态切换。citeturn619968view1turn619968view2
HALF_OPEN 如何控制探测请求
进入 HALF_OPEN 并不意味着重新放开全部流量。
这个状态只允许配置数量的探测请求通过,其余请求仍然会被拒绝。Resilience4j 的状态对象会维护剩余探测许可,通过原子计数控制并发请求最多领取指定数量的许可。官方配置中的 permittedNumberOfCallsInHalfOpenState 就是这个数量。citeturn257602view1turn743139search0
HALF_OPEN 会创建一套新的 CircuitBreakerMetrics,并且使用按调用次数计算的窗口,窗口大小就是允许的探测调用数量。citeturn994874view0
例如允许 5 个探测请求:
成功
成功
失败
成功
成功
这五个调用完成后,熔断器会重新计算失败率和慢调用率。
如果指标仍然超过阈值,重新进入 OPEN,再等待一段时间。
如果没有超过阈值,则进入 CLOSED,并创建一套新的正常滑动窗口。
这样做的目的很明确:不能刚过等待时间,就把全部生产流量立刻压回一个尚未确认恢复的下游服务。先放少量请求探测,确认恢复以后再完全关闭熔断器。
熔断器并不限制并发量
这里有一个很容易混淆的地方。
假设 slidingWindowSize 配置为 100,并不代表最多只能有 100 个请求并发执行。
CLOSED 状态下,tryAcquirePermission() 基本直接放行。如果同时有 1000 个线程进入,而熔断器当时还处于 CLOSED,那么这 1000 个线程都可能调用下游。
滑动窗口只负责统计最近的调用结果,不负责限制正在执行的调用数量。Resilience4j 官方文档也明确指出,CircuitBreaker 不会把真实函数调用放进同步临界区,否则一个慢请求就可能阻塞其他所有请求。需要限制并发量时,应当使用 Bulkhead。citeturn257602view1
因此:
CircuitBreaker
判断下游是否健康
Bulkhead
限制同时有多少请求访问下游
RateLimiter
限制一段时间内允许多少请求
三者不能相互替代。
为什么 RRateLimiter 是分布式的,而熔断器通常是本地的
到这里可以看到两套实现最本质的区别。
RRateLimiter 保护的是整个集群共享的流量预算。如果十个服务实例共同约定每秒只能向数据库发送 1000 个请求,那么这 1000 个额度必须存放在一个所有实例都能访问的位置。因此 Redisson 选择 Redis,并通过 Lua 解决跨实例竞争。
Resilience4j CircuitBreaker 保护的是当前服务实例访问某个下游的调用链健康状态。
不同服务实例观察到的下游状态可能并不相同。某个 JVM 的连接池耗尽、某个节点到下游的网络路径异常,可能只是局部故障。如果所有实例通过 Redis 共享一个熔断状态,一个节点的局部问题就可能把整个集群全部熔断。
更重要的是,熔断器的目标是快速失败。如果每次调用下游前还要先通过网络查询 Redis 是否处于 OPEN,就给保护逻辑本身增加了新的远程依赖。
所以 Resilience4j 使用 JVM 内的 AtomicReference、环形数组和本地时钟实现状态机。它的判断不需要网络往返,失败率统计和状态检查都在本地内存完成。
这也意味着每个应用实例都有自己的熔断状态。熔断器名称相同,不代表不同 JVM 自动共享状态。
两套实现真正值得记住的地方
Redisson RRateLimiter 的核心不是一个简单 Redis 计数器,而是:
配置 Hash
+
剩余许可 String
+
许可获取记录 Sorted Set
+
Lua 原子脚本
Sorted Set 记录每批许可的获取时间,Lua 脚本在下一次操作时回收过期许可,然后原子地完成判断和扣减。许可不足时,Lua 返回等待时间,Redisson 客户端通过定时器延迟后重新竞争。
Resilience4j CircuitBreaker 的核心则是:
AtomicReference 状态机
+
预聚合滑动窗口
+
失败率和慢调用率计算
+
CLOSED / OPEN / HALF_OPEN 状态对象
CLOSED 负责放行并收集指标,OPEN 负责快速拒绝,HALF_OPEN 通过有限探测请求判断下游是否已经恢复。环形数组采用增量聚合和 Subtract-on-Evict,因此读取失败率不需要遍历所有历史调用。
限流是在回答:
现在还有没有流量额度?
熔断是在回答:
这个下游现在还值不值得继续调用?
一个通过 Redis 协调多个服务实例,一个通过本地状态机判断调用链健康。把这两条实现链路分开以后,限流和熔断的底层关系就比较清楚了。