☰
Sentinel源码解析:LeapArray滑动窗口实现原理与并发控制
2026/10/15 17:50:11 网站建设 项目流程

在限流熔断这个领域,Alibaba Sentinel 是绕不开的参考实现。它用很小的性能代价扛住了大规模流量下的实时统计需求,而这一切的起点,都收敛在一个叫 LeapArray 的滑动窗口结构上。简单说,LeapArray 负责把时间切成一个个固定长度的桶,请求进来时往对应桶里计数,统计的时候再把最近一段时间的桶拼成一个整体来看。限流阈值调没调准、熔断规则判断准不准、系统自适应保护灵不灵,最终都取决于这个底层数据结构。

这篇文章我打算直接以源码阅读笔记的方式展开,默认你看的是 1.8.x 版本的 sentinel-core,重点关注LeapArray、WindowWrap、MetricBucket这几个类。不同小版本在实现细节上会有一点差异,比如加锁方式和字段命名,但骨架基本一致,只要你不纠结逐行一致,就能把核心设计吃透。

如果你正准备啃 Sentinel 源码,我强烈建议第一个精读的类就是 LeapArray。它不是最复杂的类,却是整个限流统计模型的轴心。读完它,你能顺带搞懂 Sentinel 的那些 QPS 曲线、滑动窗口指标、熔断状态判断到底是怎么算出来的,比自己瞎猜要快得多。

1. 为什么读懂 LeapArray:从限流痛点说起

1.1 固定窗口的临界问题

要理解 LeapArray 的价值,得先看朴素的时间窗口限流是怎么做的。最常见的版本是:维护一个计数器,在某个固定时间窗口内每来一个请求就加一,超过阈值就拒绝,窗口到期后清零重来。这种实现非常简单,性能也极高,一条原子指令就能搞定。

但它有一个特别明显的破绽:窗口边界处的流量会被“偷渡”。

举个例子,某接口限制 100 QPS,固定窗口是 1 秒。假设在第一个窗口的最后 200ms 内打进来 80 个请求,第二个窗口的前 200ms 又打进来 80 个请求。单看两个窗口,各自都只有 80 次,都没超过阈值 100,但实际在 400ms 内系统承受了 160 次请求,远远超过限流目标。这就是经典的“窗口临界突发”问题。

我在项目X里第一次做限流时正踩在这个坑上,本地压测看着限流阈值设置得很合理,线上总在整秒边界附近出现一波尖刺。后来才反应过来,固定窗口只保证“每个窗口内”不超过阈值,完全不保证“任意连续 1 秒”不超过阈值,而这恰恰是限流真正需要承诺的事情。

解决办法就是把窗口滑动起来:让统计区间能以较小步长连续移动,而不是生硬地整秒切割。滑动窗口的粒度越细,越接近“任意连续窗口”的理想统计,但代价是存储和计算量成倍上升。

1.2 滑动窗口的实现思路与铁三角权衡

最简单的滑动窗口实现是直接记录每个请求的时间戳,统计时过滤出最近一个周期内的数据。这种方案精确度高,但存在两个问题:第一,内存占用会随请求量线性增长,必须定期清理过期时间戳;第二,每次统计都要遍历大量时间戳,QPS 一高就会成为热点。

再聪明一点的方案是时间桶聚合:把时间切成一连串固定长度的槽,每来一个请求往当前槽里累加,滑动窗口需要统计时,只取最近几个槽的数据。这个方案的内存占用取决于窗口分桶数,是固定的,统计开销也能控制在 O(1) 左右。

LeapArray 就是时间桶方案的一种经典落地,它把精度、内存、并发做了一个工程上的平衡:

  • 精度由桶大小决定,桶越小越精确,但桶数量会变多;
  • 内存由桶数量决定,一个桶一个对象,全程固定;
  • 并发能力则取决于窗口翻转时的竞争控制,LeapArray 用环形数组加 CAS 尽量把竞争限制在窗口换页那一瞬间。

读源码的时候最好带着这个“铁三角”去理解,很多细节设计都能对号入座。

1.3 Sentinel 为什么把统计单独抽成模块

Sentinel 里限流、熔断、系统自适应保护看起来是三个功能,但它们都依赖同一件事:实时、准确地知道当前一段时间内的请求量、异常量、RT 等指标。因此统计模块必须独立出来,被所有上层规则共用。

LeapArray 的定位很纯粹:只负责维护“时间分桶”和“窗口滑动”这件事,至于桶里具体放什么指标,由泛型 T 决定。常见的实现是MetricBucket,里面有 Pass、Block、Success、Exception 等多个计数器;如果要换一套指标模型,只要替换泛型参数、实现newEmptyBucket方法即可。

所以你可以把 LeapArray 理解成一个“时间分段回收站”:它不关心你往格子里放的是苹果还是香蕉,只管保证格子按时间被正确清空和复用。这种职责单一的设计,让它很容易被独立阅读、测试和扩展。

2. 核心设计拆解:环形数组与时间桶的协作方式

2.1 关键字段:一段代码看懂结构

先看核心字段,我直接贴简化版,方便对照:

public abstract class LeapArray<T> { protected int sampleCount; // 采样窗口个数 protected int intervalInMs; // 滑动窗口总时长 protected int windowLengthInMs; // 每个桶的时间长度 protected AtomicReferenceArray<WindowWrap<T>> array; // 环形桶数组 protected volatile int writeIndex; // 最近写入位置,用于遍历排序 private final ReentrantLock updateLock = new ReentrantLock(); // 窗口重置锁 }

几个关键参数的关系是固定的:

  • intervalInMs是整个滑动窗口的时间跨度,比如 1000ms;
  • sampleCount是我们要切成几个桶,比如 2 个;
  • windowLengthInMs = intervalInMs / sampleCount,即每个桶的时长。

windowLengthInMs这个值直接决定了统计精度。把 1 秒切成 2 个桶,每个桶 500ms,滑动窗口的“颗粒感”很粗;切成 10 个桶,每个桶 100ms,统计曲线会更平滑,但需要维护 10 个桶对象。

array是一个环形数组,长度一般等于sampleCount。它用AtomicReferenceArray是因为高并发下需要保证数组内元素读写的可见性和原子性。每个数组元素都是一个WindowWrap<T>对象,可以理解为“时间桶包装器”。

writeIndex不是用来定位写入位置的,它只是一个最近写入位置的标记,主要在遍历排序时用。定位写入位置完全靠时间戳运算,这一点很多人刚读时会误判。

2.2 两步定位:时间戳如何变成桶下标

每次拿到一个时间戳,LeapArray 会做两次计算:先算出时间对应的下标,再算出该桶的起始时间。

private int calculateTimeIdx(long timeMillis) { long timeId = timeMillis / windowLengthInMs; return (int) (timeId % array.length()); } protected long calculateWindowStart(long timeMillis) { return timeMillis - timeMillis % windowLengthInMs; }

用一个例子跑一遍:假设intervalInMs = 1000、sampleCount = 2,所以windowLengthInMs = 500,数组长度是 2。

  • 当timeMillis = 300时,timeId = 300 / 500 = 0,idx = 0 % 2 = 0,windowStart = 300 - 300 % 500 = 0,属于 0~499ms 这个桶;
  • 当timeMillis = 800时,timeId = 800 / 500 = 1,idx = 1 % 2 = 1,windowStart = 800 - 800 % 500 = 500,属于 500~999ms 这个桶;
  • 当timeMillis = 1300时,timeId = 1300 / 500 = 2,idx = 2 % 2 = 0,windowStart = 1300 - 1300 % 500 = 1000,又回到下标 0。

第三组数据就是“环形”的精髓:时间不断往前走,但数组下标通过取模循环复用。时间来到了 1000ms,下标又回到 0,但此时下标 0 里放的还是最早那个windowStart=0的老桶,如果不做处理,新请求就会和老请求的计数混在一起。

所以每个桶还必须携带自己的windowStart,用它与当前时间的windowStart做对比,判断这个桶是否过期。这也是为什么WindowWrap要单独包装一层,里面除了数据值,还有窗口起始时间。

2.3 桶复用:既要覆盖,又要不误伤

环形数组带来的最大挑战是桶复用。当新时间戳定位到旧桶位置时,不能直接往里累计,因为里面还存着上一个周期的数据。必须判断当前桶和请求时间是否属于同一个统计周期,如果属于,直接复用;如果不属于,就要把旧桶重置成新窗口。

这里就引出整个 LeapArray 最核心的方法currentWindow,它的整个流程本质上就是一个“定位—判断—复用或重建”的过程。理解了这个,你就理解了 LeapArray 的 80%。

3. 核心方法源码解析:currentWindow 与窗口翻转

3.1 currentWindow 的四步分支

我基于常见版本的逻辑整理出下面这段精简代码,顺序可能有细微差别,但判断分支是一样的:

public WindowWrap<T> currentWindow(long timeMillis) { if (timeMillis < 0) { return null; } int idx = calculateTimeIdx(timeMillis); long windowStart = calculateWindowStart(timeMillis); while (true) { WindowWrap<T> old = array.get(idx); if (old == null) { // 当前位置还没有桶,创建新桶并原子写入 WindowWrap<T> window = new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); if (array.compareAndSet(idx, null, window)) { return window; } else { Thread.yield(); } } else if (windowStart == old.windowStart()) { // 桶还活着,直接复用 return old; } else if (windowStart > old.windowStart()) { // 桶已过期,需要重置 if (updateLock.tryLock()) { try { return resetWindow(old, windowStart); } finally { updateLock.unlock(); } } else { Thread.yield(); } } else if (windowStart < old.windowStart()) { // 时间回拨,不覆盖,直接返回一个临时桶 return new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } }

第一个分支是“空桶创建”。如果当前位置old == null,说明这个下标还从没被写入过,直接new一个WindowWrap,用compareAndSet放入数组。CAS 成功就返回,失败说明有其他线程抢先创建了,当前线程让出 CPU 后重新循环。

这里用 CAS 而不是加锁,是因为创建新桶这个动作本身就是一次原子引用替换。多个线程同时创建,最终只有一个能成功,失败的重试即可,完全不需要重型锁。

第二个分支是“正常复用”。当windowStart == old.windowStart(),说明当前桶的起始时间和请求时间所处窗口的起始时间完全一致,这就是我们想要的那个活桶,直接返回。

这是最高频路径,也是性能最好的路径,没有任何锁操作。一个活跃窗口内,所有请求几乎都走这里。

3.2 过期窗口的 resetWindow:为什么需要锁

第三个分支是“桶过期”,也就是时间已经走进了新窗口,但数组里那个位置还留着旧窗口。此时不能直接返回旧桶,也不能简单往里累加,必须先把旧桶重置成新窗口。

重置动作看起来简单,但有个并发隐患:重置包含两步,第一步把windowStart更新为新窗口起始时间,第二步把桶内的计数清零。如果两个线程同时进入重置逻辑,可能一个线程刚把windowStart改成新值,另一个线程也在改,或者有线程在旧值上继续累加,数据就乱了。

于是这里引入了updateLock,并且用了一个很有意思的策略:tryLock而不是lock。

tryLock拿不到锁就立刻返回失败,然后Thread.yield()让出 CPU,外层循环重试。这么设计的动机是:窗口翻转只发生在每个桶边界那一瞬间,竞争窗口极短。如果用阻塞式lock,大量请求会在窗口切换时排队等待,延迟瞬间拉高;而tryLock加自旋重试的方式,能让绝大部分线程在几个纳秒后重新读到已经重置完成的桶,直接走第二个分支成功返回。

再看resetWindow的内部实现:

private WindowWrap<T> resetWindow(WindowWrap<T> old, long startTime) { old.resetTo(startTime); if (old.value() instanceof MetricBucket) { ((MetricBucket) old.value()).reset(); } return old; }

它没有把数组里的旧对象整个替换掉,而是复用同一个WindowWrap对象,原地把起始时间改成新值,并把内部的MetricBucket计数器重置清零。

为什么不直接array.set(idx, new WindowWrap(...))?因为可能有线程已经通过array.get(idx)拿到了旧引用,此时你把新对象 set 回去,旧线程还在往旧对象上累加数据,两个对象分裂,统计会漏掉一部分请求。复用同一个对象则保证所有线程看到的引用都一致,重置动作被锁保护,重置完成后大家继续往同一个对象上累计,不会分裂。

3.3 previousWindow 与相邻窗口统计

很多限流统计不只关心当前桶,还要把上一个桶拉出来一起算。比如用 1 秒滑动窗口、2 个桶时,当前时刻的真实 QPS 应该等于当前桶加上一个桶的总计数。

previousWindow的实现思路很直接:把时间往前拨一个窗口长度,再走一遍定位逻辑。

public WindowWrap<T> previousWindow(long timeMillis) { if (timeMillis < 0) { return null; } long newTime = timeMillis - windowLengthInMs; int idx = calculateTimeIdx(newTime); WindowWrap<T> old = array.get(idx); if (old != null && old.windowStart() == calculateWindowStart(newTime)) { return old; } return null; }

注意这里最后有一个二次校验:虽然下标是用newTime算出来的,但拿到的old有可能是过期窗口。比如该窗口还没被重置,或者已经被后续请求重置成更晚的窗口了,哪怕下标对,也不一定就是我们要的前一个窗口。所以必须确认old.windowStart()正好等于newTime所在窗口的起始时间,才说明它确实是当前时刻的“前一个有效桶”。

我曾见过有人自己参照这个思路实现指标聚合时,只用下标去取前一个窗口,结果在窗口切换的瞬间把两个完全不连续周期的数据加在了一起,限流阈值明明没到却被误杀,排查了很久。这个二次校验不是可有可无的防御,而是必须守住的底线。

4. 并发正确性与实现细节

4.1 桶内计数为什么敢用 LongAdder

窗口翻转时并发控制靠锁,但窗口内计数是完全无锁的。Sentinel 的MetricBucket内部维护了一个LongAdder数组,每个指标一个计数器。

LongAdder本质上是通过分段累加再加和来降低竞争。高并发下,大量线程同时对一个计数器做加法,如果只用AtomicLong,所有线程会争抢同一个缓存行,性能衰减非常明显。LongAdder把计数分散到多个槽位,线程各自累加到不同的槽,最终sum()时再汇总,这样能大幅降低写竞争。

这也解释了为什么桶重置时要调用reset()把所有槽位清零。因为LongAdder没有提供一次性覆盖为指定值的方法,只能遍历槽位逐个 reset,这个操作虽然比单个原子变量慢一点,但只在窗口翻转时发生,频率很低,完全可以接受。

4.2 读操作为什么不需要加锁

currentWindow的第二个分支,也就是正常复用分支,全程没有锁。多个线程同时读同一个WindowWrap并往它内部的MetricBucket里累加,靠的是LongAdder自己的并发控制。

这里有一个值得思考的点:如果某个线程在currentWindow中拿到了窗口 A,但另一个线程随后发现窗口 A 已过期,持锁把它重置成窗口 B,那第一个线程还在往窗口 A 的旧对象上累加计数吗?答案是可能累加到一个已经被重置的桶上。

这在严格的并发模型里属于数据不一致,但 Sentinel 接受这种统计误差。原因很简单:限流本身就是一个概率性控制,窗口切换瞬间丢失或错算几个请求,对整体限流效果的影响趋近于零,远小于用一个全局大锁把所有请求串行化带来的性能损失。

在设计中,“精确但慢”和“近似但快”之间,Sentinel 明确选择了后者。读源码时如果拿着“线程安全 = 绝对一致”的尺子去量,会觉得有些地方违反直觉,但实际上这是一种务实的取舍。

4.3 时间回拨:那个看起来多余的分支其实是保命符

currentWindow最后一个分支专门处理windowStart < old.windowStart()的情况。按正常逻辑,时间只会往前走,新窗口的起始时间不可能比旧桶的起始时间更早。但生产环境里服务器时钟可能被修正,或者调用方传入了一个更早的时间戳,就会出现这种“时间倒退”。

Sentinel 的做法是:不回写数组,直接 new 一个临时窗口返回。这个临时窗口不会被放进环形数组,用完之后被垃圾回收,不会污染任何已有数据。

为什么不能像过期窗口那样重置旧桶?因为旧桶的起始时间比当前时间还晚,说明旧桶可能属于“未来”。如果强行重置,会破坏未来的统计。比较好的策略就是让这次请求在临时桶里单独结算,等系统时间追平后,一切恢复如常。

我在维护项目X时遇到过时钟被同步服务拨动的情况,当时观察到的现象是 QPS 曲线在某个点出现了一个极低值甚至零值。排查到最终才发现是服务器时钟短时间回拨了大约几十毫秒,触发的就是这个分支。所以千万不要删掉这个分支,也别以为它永远走不到。

5. 常见问题与排查技巧实录

5.1 几个容易踩的坑

围绕 LeapArray,实际使用中容易踩坑的地方不少,我整理成一张速查表:

问题现场根因排查思路与对策
QPS 统计值突然归零或翻倍窗口翻转时把过期桶数据统计进来了检查统计代码是否校验windowStart,别只按下标取桶
限流阈值未到但请求被误杀前一个窗口取错,叠加了远古数据previousWindow必须二次确认桶的开始时间
设置sampleCount=2后曲线很粗糙桶粒度太大,滑动窗口近似度过高加大sampleCount,比如 10,观察内存与翻转开销
系统时间被调整后指标异常时间回拨分支被触发,或时间前进太快引入受控时钟源,或对离群窗口做过滤
直接用System.currentTimeMillis()作为时间参数和内部统计时钟不一致,出现窗口错位统一使用TimeUtil.currentTimeMillis()

第 4 条关于时间回拨的问题很隐蔽,建议每条限流规则的真实指标曲线都接上监控,发现毛刺先看服务器时钟,再排查自己的统计逻辑。

5.2 源码阅读中的三个经典疑问

很多人在群里问过我三个问题,我这里一起回答。

第一个问题:空桶创建用 CAS,过期桶重置用锁,为什么不统一用锁?因为空桶创建是把null替换成新对象,这是一个单步原子操作,CAS 足够;而重置旧桶需要把windowStart和内部计数都改掉,属于多步操作,CAS 无法保证中间状态的正确性,必须拿锁。

第二个问题:为什么拿不到锁不直接阻塞等待,而是Thread.yield()循环重试?因为窗口重置的临界区非常短,只做两个字段更新,线程重新调度回来的成本远低于阻塞唤醒的成本。用yield可以更快地完成“让一下、下一轮重试就成功”的路径。

第三个问题:writeIndex有什么用?它不是用于定位写入下标,而是记录最近写入的桶下标,方便在遍历数组时确定一个稳定的起始点。定位永远靠时间戳计算,这个字段只是个辅助标记。

5.3 线上问题定位实录

某开发者在项目X里用 Sentinel 的指标数据做容量预估,发现某个服务的 QPS 统计每隔一段时间就会出现一个接近零值的凹坑。限流规则本身没问题,流量也没有真实下降,只有统计曲线异常。

排查过程分了三步。第一步,先把listAll返回的每个WindowWrap的windowStart和当前时间窗口打印出来,发现有个桶的windowStart明显大于其他桶,明显是未来时间。第二步,对比应用服务器时间与统一时钟源,发现该机器时钟被轻微调快。第三步,确认是时钟问题后,在部署层面对所有机器做了时钟校准,并统一走内部时间接口,统计曲线恢复平滑。

这个案例说明,LeapArray 的设计再严谨,也扛不住底层时钟抖动。做实时统计的系统,先把时间源头管好,比调任何参数都重要。

6. 从 LeapArray 出发的延伸阅读

6.1 上层是如何消费窗口数据的

Sentinel 的请求会依次经过多个 Slot,其中StatisticSlot负责统计,FlowSlot负责限流判断。流量进来时,StatisticSlot会调用Node的方法,Node内部对应的就是一个BucketLeapArray,请求量、通过量、异常量都会累加到当前窗口的MetricBucket中。

流控时,FlowSlot会从当前时间和上一个时间窗口中读取统计值,与规则阈值比较。你可以看到,整个限流链路的核心指标读写的落点,全都在 LeapArray 的窗口上。如果窗口翻转逻辑出错,什么规则都救不回来。

了解了这一层,你就知道为什么 Sentinel 的统计模块要设计得这么“轴”了:所有上层判断都把宝压在实时统计上,底层数据结构必须既快又稳,还要在极端情况下有兜底策略。

6.2 OccupiableBucketLeapArray 与未来窗口

LeapArray 还有个扩展实现,叫OccupiableBucketLeapArray,它额外维护了一个“未来窗口”的支持。这个机制主要用于系统自适应限流,进程可以占用未来的配额,从而让系统负载评估更平滑。

它的做法简单说就是:在统计当前窗口的同时,允许借用未来窗口的容量,再通过一个内部占用计数器让未来窗口知道已经被透支了多少。如果不看 LeapArray 本身的设计,这个扩展理解起来会比较吃力;但看完基础类再看它,你会发现这就是“继承 + 泛型 + 多一个字段”的组合运用,整体思路非常清晰。

6.3 如果你要设计自己的指标统计器

读完 LeapArray,我建议你尝试思考三个问题:如果只需要秒级限流,sampleCount设为 2 真的够吗?如果提升到 10,多出来的 8 个桶对象会多占多少内存?如果单机 QPS 上到百万级,currentWindow的resetWindow加锁会不会成为瓶颈,你有什么优化思路?

这些问题没有标准答案,但能把源码里的设计取舍转化为你自己的工程直觉。比如第一个问题的答案是:够不够,取决于你能接受多大的统计误差;第二个问题需要你估算对象开销;第三个问题则可以往分桶、锁剥离、分段统计方向去想。

我个人读源码的习惯是,先把currentWindow画成一张状态图,把空桶、同窗口、过期、回拨四个分支标注清楚;再写一个小压测程序,打印每次窗口翻转时旧桶和新桶的windowStart变化。等对这些分支的触发条件有了画面感,再看上层调用会轻松很多。

最后说一个实战小技巧:如果你在自己的项目里要基于类似思路做统计,不要直接照搬System.currentTimeMillis(),最好封装一个可控的时钟接口,方便测试时推进时间、排查时不背锅。这个细节,你在读 Sentinel 代码时未必注意得到,但遇到线上时钟问题的那天,你会回来感谢它的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询