资讯动态

Sentinel源码解析:LeapArray滑动窗口实现原理与并发控制

发布时间:2026/10/15 17:50:32 来源:尧图企业网站定制
在限流熔断这个领域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 LeapArrayT { protected int sampleCount; // 采样窗口个数 protected int intervalInMs; // 滑动窗口总时长 protected int windowLengthInMs; // 每个桶的时间长度 protected AtomicReferenceArrayWindowWrapT array; // 环形桶数组 protected volatile int writeIndex; // 最近写入位置用于遍历排序 private final ReentrantLock updateLock new ReentrantLock(); // 窗口重置锁 }几个关键参数的关系是固定的intervalInMs是整个滑动窗口的时间跨度比如 1000mssampleCount是我们要切成几个桶比如 2 个windowLengthInMs intervalInMs / sampleCount即每个桶的时长。windowLengthInMs这个值直接决定了统计精度。把 1 秒切成 2 个桶每个桶 500ms滑动窗口的“颗粒感”很粗切成 10 个桶每个桶 100ms统计曲线会更平滑但需要维护 10 个桶对象。array是一个环形数组长度一般等于sampleCount。它用AtomicReferenceArray是因为高并发下需要保证数组内元素读写的可见性和原子性。每个数组元素都是一个WindowWrapT对象可以理解为“时间桶包装器”。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 0idx 0 % 2 0windowStart 300 - 300 % 500 0属于 0~499ms 这个桶当timeMillis 800时timeId 800 / 500 1idx 1 % 2 1windowStart 800 - 800 % 500 500属于 500~999ms 这个桶当timeMillis 1300时timeId 1300 / 500 2idx 2 % 2 0windowStart 1300 - 1300 % 500 1000又回到下标 0。第三组数据就是“环形”的精髓时间不断往前走但数组下标通过取模循环复用。时间来到了 1000ms下标又回到 0但此时下标 0 里放的还是最早那个windowStart0的老桶如果不做处理新请求就会和老请求的计数混在一起。所以每个桶还必须携带自己的windowStart用它与当前时间的windowStart做对比判断这个桶是否过期。这也是为什么WindowWrap要单独包装一层里面除了数据值还有窗口起始时间。2.3 桶复用既要覆盖又要不误伤环形数组带来的最大挑战是桶复用。当新时间戳定位到旧桶位置时不能直接往里累计因为里面还存着上一个周期的数据。必须判断当前桶和请求时间是否属于同一个统计周期如果属于直接复用如果不属于就要把旧桶重置成新窗口。这里就引出整个 LeapArray 最核心的方法currentWindow它的整个流程本质上就是一个“定位—判断—复用或重建”的过程。理解了这个你就理解了 LeapArray 的 80%。3. 核心方法源码解析currentWindow 与窗口翻转3.1 currentWindow 的四步分支我基于常见版本的逻辑整理出下面这段精简代码顺序可能有细微差别但判断分支是一样的public WindowWrapT currentWindow(long timeMillis) { if (timeMillis 0) { return null; } int idx calculateTimeIdx(timeMillis); long windowStart calculateWindowStart(timeMillis); while (true) { WindowWrapT old array.get(idx); if (old null) { // 当前位置还没有桶创建新桶并原子写入 WindowWrapT 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 WindowWrapT resetWindow(WindowWrapT 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 WindowWrapT previousWindow(long timeMillis) { if (timeMillis 0) { return null; } long newTime timeMillis - windowLengthInMs; int idx calculateTimeIdx(newTime); WindowWrapT 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必须二次确认桶的开始时间设置sampleCount2后曲线很粗糙桶粒度太大滑动窗口近似度过高加大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 代码时未必注意得到但遇到线上时钟问题的那天你会回来感谢它的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑