Sentinel滑动时间窗口算法:从设计动机到源码实现 这两年但凡做微服务限流这块基本绕不开 Sentinel。可我发现一个很有意思的现象很多人把 Sentinel 用得很熟规则配得飞起但你要是问他滑动时间窗口到底是怎么滑的他多半会愣一下然后给你来一句不就是统计 QPS 嘛。这话对了一半。统计 QPS 确实是它的直接目的但滑动时间窗口这个数据结构本身才是 Sentinel 在性能、精度和内存占用之间做出的关键取舍。我之前在某高并发项目里被流量毛刺打穿过一次回头啃了几天源码把这套机制彻底捋了一遍才发现以前对限流的理解确实太表面了。这篇文章我就把 Sentinel 滑动时间窗口算法从设计动机到源码实现一层层掰开揉碎了讲包括它对比固定窗口解决了什么问题、LeapArray 循环数组怎么设计的、统计和限流判断的完整链路长什么样以及我实际配置和压测时踩过的几个坑。适合刚接触限流想弄懂原理的读者也适合已经在用 Sentinel 但想深入源码的同学。1. 固定窗口计数器的临界问题滑动窗口到底在补什么窟窿先说一个很多教程里都讲过、但真正手写代码时容易忽略的问题。最早实现限流的时候大家最常用的就是固定窗口计数器。思路很直白把时间切成一段一段的窗口比如每秒一个窗口每个窗口内维护一个计数器请求来了就加一超过阈值就拒绝窗口一过就重置计数。你猜怎么着这套逻辑在大多数场景下看着挺正常但一旦遇到突刺流量它就有个很严重的破绽。我举个具体例子假设你的规则是单机限流 500 QPS窗口长度 1 秒。在这 1 秒的最后 100 毫秒突然冲进来 500 个请求好这个窗口的计数满了按规矩后面的请求要被拒绝。但问题是在下一个窗口的最初 100 毫秒又冲进来 500 个请求。因为新窗口刚开启计数器是从 0 开始的所以这 500 个请求也能顺利通过。你算算从第 1 秒的 900 毫秒到第 2 秒的 100 毫秒中间这 200 毫秒里系统实际扛了 1000 个请求。平均下来看每秒确实是 500 的均值但瞬时压力直接翻倍。下游服务要是扛不住这翻倍的瞬时流量该熔断还是熔断。这就是固定窗口算法最著名的临界问题。那怎么修核心思路就是不要用整点对齐的固定窗口而是让窗口跟着时间连续滑动。刚才那个场景下如果有个窗口能覆盖900ms 到 1900ms这个区间并且窗口的计数是动态更新的那么这 1000 个请求就会被同一个窗口统计到阈值自然就拦截住了。Sentinel 的滑动时间窗口算法本质上就是把一个大的时间区间比如 1 秒切成很多个小格子每个格子独立计数。判断当前 QPS 时不是只看某一个格子里的数字而是把当前时刻往前推一个窗口长度覆盖到的所有格子的计数加起来。这样窗口的边界就是连续的刚才那种跨边界的流量毛刺会被同一个滑动窗口正确统计问题就解掉了。所以你看滑动窗口并不是什么高深莫测的东西它是在固定窗口的缺陷上做的一次精度升级。理解了这个动机后面看 LeapArray 的源码就顺理成章了。2. LeapArray 循环数组的设计格子怎么切时间怎么算Sentinel 里承载滑动窗口统计的核心类叫 LeapArray这个Leap寓意就是跳动的窗口。它在 sentinel-core 的com.alibaba.csp.sentinel.slots.statistic.base包下面内部结构其实是一个环形数组。我在读源码之前一直以为它会用什么高深的数据结构比如环形链表、时间轮什么的。结果打开一看人家就是用了一个数组配合精巧的下标计算完成了窗口的滑动这个设计确实值得学。2.1 三个关键字段和窗口长度的推导LeapArray 的成员变量不多核心就三个sampleCount一个周期内格子的数量也就是要把窗口切几格。intervalInMs整个时间窗口的长度单位毫秒。array真正的存储结构类型是AtomicReferenceArrayWindowWrapT。这三个字段一旦确定每个格子的时间跨度也就定了windowLength intervalInMs / sampleCount。比如默认配置下1 秒的统计窗口切成 2 个格子每个格子就是 500 毫秒。AtomicReferenceArray这个选择很有讲究。首先它保证了数组的元素在并发环境下可见性没有问题其次它的getAndSet这类 CAS 操作能支持无锁更新。在高并发限流场景下每个请求都要读写统计锁竞争是性能大敌所以这里的无锁设计是 Sentinel 高性能的基础之一。2.2 当前时间落在第几个格子的换算窗口要滑动核心就是每次来请求时要能快速算出当前时间应该落在数组的哪个下标上。这个计算在calculateTimeIdx方法里逻辑非常简单int timeId (int)(currentTime / windowLength); int idx timeId % array.length();一句话先算出当前时间在整个时间轴上处于第几个窗口周期再对这个周期取模得到它在环状数组里的下标。因为数组是环状复用的所以不管时间走了多远下标始终落在0 ~ length-1之间。这个取模决定了整个数据结构的滚动特性。我把数组想象成一圈表盘时间在走指针在转格子会被反复覆盖。覆盖之前先看一下这个格子里的数据是不是已经过期了如果过期就重置没有过期就继续累加。这就是下一步要看的currentWindow方法。2.3 currentWindow 的三个分支逻辑currentWindow是整个滑动窗口算法最核心的方法它的作用就是拿到当前时间对应的那个格子而且保证这个格子肯定是可用的。源码大概是这样public WindowWrapT currentWindow(long currentTime) { int idx calculateTimeIdx(currentTime); long windowStart currentTime - currentTime % windowLength; while (true) { WindowWrapT old array.get(idx); if (old null) { // 分支一格子还是空的直接创建新窗口 WindowWrapT window new WindowWrap(windowLength, windowStart, newEmptyBucket()); if (array.compareAndSet(idx, null, window)) { return window; } else { Thread.yield(); } } else if (currentTime old.windowStart() old.windowLength()) { // 分支二当前时间还在这个格子的有效期内直接复用 return old; } else if (windowStart old.windowStart()) { // 分支三格子已经过期加锁重置窗口开始时间并清空数据 if (updateLock.tryLock()) { try { return resetWindowTo(old, windowStart); } finally { updateLock.unlock(); } } else { Thread.yield(); } } else if (currentTime old.windowStart()) { // 分支四时钟回拨返回一个落后的空窗 return new WindowWrap(windowLength, currentTime, newEmptyBucket()); } } }这里有几个细节值得展开讲。第一个分支数组初始化的时候格子是 null第一个请求进来直接 CAS 设置新窗口设置失败就让出 CPU 等下一次循环。CAS 失败说明有其他线程抢先了让出 CPU 是很有必要的不然就是死循环空转。第二个分支是正常情况当前时间还在旧格子的生命周期内直接返回旧格子累加计数。这里为什么判断条件是currentTime old.windowStart() old.windowLength()因为这个格子的开始时间加上它的长度就是格子的过期边界。拿 500ms 的格子举例如果窗口开始时间是 1000ms那么 1000ms 到 1500ms 之间的时间都属于这个格子超过 1500ms 就算过期。第三个分支是窗口滚动的核心当前时间已经超过了旧格子的过期边界说明这格子该重置了。重置前先拿锁防止多个线程同时来重置同一个格子导致数据错乱。正常情况下old.windowStart()是上一个周期的开始时间windowStart是当前周期的开始时间后者必然更大所以会进入这个分支把窗口开始时间推进到当前周期。这里我想多说一句为什么重置窗口时要加锁而不是像分支一那样用 CAS因为重置操作不是简单地替换引用而是要修改已有WindowWrap对象的windowStart属性再清空里面的计数器。在这个修改过程中可能会有其他线程正在往这个格子里累加计数。如果不加锁可能出现清空数据之后别的线程又累加了一部分同时窗口开始时间已经被推进这种半新半旧的状态。锁在这里是必要的虽然会带来一点竞争成本但窗口重置的频率远低于普通请求的频率所以对整体性能影响很小。2.4 为什么用环形数组而不是链表这个点我觉得值得单独拿出来说因为它直接关系到 Sentinel 为什么能支撑高并发。如果用一个普通的List每次滑动都要移动元素或扩容时间复杂度和空间复杂度都不理想。而环形数组的下标计算是 O(1) 的取模操作在 CPU 层面非常快。更重要的是数组的内存是连续的对 CPU 缓存友好。在高并发限流场景下每个请求进来都要做一次窗口定位和计数累加这一步如果能命中 CPU 缓存而不是去内存里随机访问性能差距在千万级请求下是非常可观的。我之前做压测的时候对比过改成链表实现的版本同样的规则下TPS 大概差了将近一倍这个差距很大程度上就来自缓存局部性。3. WindowWrap 和 MetricBucket一个格子里面到底放了什么窗口容器有了接下来就要回答一个问题每个格子里面存的数据是什么Sentinel 在这层抽象做得很好用泛型把窗口容器和统计数据解耦了。容器只负责管理时间数据由具体的 Bucket 决定。public class WindowWrapT { private final long windowLengthInMs; private long windowStart; private T value; }WindowWrap里面就三样东西窗口长度、窗口开始时间、实际的数据对象。窗口长度是 final 的因为每个格子一旦创建它的时间跨度就固定了会跟着数组一直复用下去。3.1 MetricBucket 的计数器组成默认情况下WindowWrap里装的数据类型是MetricBucket。这个类内部维护了一个LongAdder[]数组每个数组元素对应一种事件类型的计数器。Sentinel 预定义了几种事件通过PASS、拒绝BLOCK、成功SUCCESS、异常EXCEPTION、RT 耗时等。public class MetricBucket { private final LongAdder[] counters; private volatile long minRt; }这里用LongAdder而不是AtomicLong我之前也奇怪过后来想明白了。在高并发下多个线程同时对一个AtomicLong执行 CAS 操作竞争会很激烈性能会急剧下降。而LongAdder内部维护了多个累加单元不同线程分散到不同的单元上累加最后再求和相当于把竞争拆散了。这在 Sentinel 这种每个请求都要计数的场景下非常适用代价是读到的计数可能稍微滞后一点但限流本来就不是要求毫秒级精确的事情这点滞后完全可接受。3.2 计数更新时的两步动作当一个请求通过或拦截时指标数据的更新不是直接往一个全局变量上累加而是先定位当前时间所在的窗口再往窗口里的MetricBucket加计数。以通过请求为例核心方法是public void addPass(int count) { WindowWrapMetricBucket wrap data.currentWindow(); wrap.value().addPass(count); }这两步缺一不可。第一步拿到当前窗口如果窗口过期就重置出一个新的第二步才是在格子内部做累加。这也意味着如果不小心在第一步丢掉了对窗口的持有第二步再取一次可能就取到下一个周期的窗口了数据就错了。所以源码里经常能看到this.data.currentWindow()只调用一次把返回值存成局部变量再复用。3.3 一个格子到底要占多少内存别小看这个简单的数据结构它在高并发下的内存开销是实打实的。我算过一笔账默认配置下一分钟的统计窗口采样格子数量是 120每个MetricBucket里大概有 7 个LongAdder每个LongAdder内部又有一组 Cell 数组。一个格子总体下来大概要占几百字节到 1KB 左右的内存。如果为了统计更精确把sampleCount从 120 调到 600那一个窗口光数组就要占几百 KB。在微服务每台机器上可能有几百个资源内存累积下来还是相当可观的。所以这个参数千万不能盲目调大后面我会单独说配置选择的问题。4. 从统计到限流判断整个链路是怎么串起来的格子里面有了数据下一步就是消费数据做限流判断了。滑动窗口算法在这里的角色是数据底座它只负责统计真正判断要不要拦截的逻辑是另外一套。这两部分分开设计我觉得是 Sentinel 做得比较好的地方。4.1 FlowRuleChecker 的判断流程流量进入后会走到FlowRuleChecker.checkFlow方法。这里会拿到当前资源对应的所有 FlowRule逐个检查是否触发限流。核心逻辑在DefaultController里public boolean canPassCheck(/* ... */) { double threshold rule.getCount(); double currentCount getCurrentCount(); return currentCount count threshold; }看起来很简单就是拿当前统计值加上本次请求数和阈值比较。但它背后有很多细节每个规则都有独立的计数器同一条资源可以配置多条不同维度的限流规则比如按 QPS、按线程数、按并发数等。在 Sentinel 里这些计数器都在StatisticSlot里维护。StatisticSlot是个责任链节点它前后分别计算通过和拦截两种数据。请求通过后会在一个统计节点上累加 PASS 计数被拦截后会在另一个节点上累加 BLOCK 计数。这样设计是为了让数据统计和规则判断各司其职后续加新的限流模式不需要改动统计模块。4.2 每秒 QPS 的具体计算方式Sentinel 计算当前的 QPS 时不是直接用光一个格子的计数而是把当前窗口往前一个完整窗口周期的所有格子加起来。以 QPS 统计为例默认情况下intervalInMs是 1000 毫秒sampleCount是 2所以每次取当前窗口和上一个窗口的计数之和。用代码表示就是long getQps() { long passCnt 0; // 遍历当前时间向前一个周期内的所有窗口 for (int i 0; i sampleCount; i) { WindowWrapMetricBucket wrap array.get(calculateTimeIdx(currentTime - windowLength * i)); if (wrap ! null) { passCnt wrap.value().pass(); } } return passCnt; }这个往前累加的操作就是滑动窗口滑的本质。每时每刻窗口的覆盖范围都在跟着当前时间移动而不是固定在某个边界上。这样一来刚才说的临界问题就自然地解决了。4.3 和其他限流算法的横向对比每次讲滑动窗口总会有人问那它和令牌桶、漏桶比哪个更好我把我的理解梳理一下放在一个表里算法核心思想优点缺点Sentinel 中的位置固定窗口计数器按固定周期重置计数实现简单存在临界双倍流量问题已弃用保留在扩展点滑动窗口计数器窗口随当前时间滑动累加精度高、实现优秀内存占用随格子数量增加默认的 QPS 统计基础令牌桶以固定速率往桶里放令牌允许一定突发流量实现复杂需要维护令牌生成WarmUpController 中使用漏桶请求先进入队列匀速流出绝对平滑削峰填谷对突发流量不友好RateLimiterController 中使用简单说Sentinel 之所以默认用滑动窗口不是为了追求复杂而是在足够精确和足够高效之间取得了一个很好的平衡。令牌桶和漏桶在处理突发流量、平滑流速上有各自的优势Sentinel 也把它们做成了不同的 Controller 策略但底层统计数据的更新仍然离不开滑动窗口这套基础能力。5. 参数配置踩坑与调优实践为了更平滑付出的代价最后这部分我想写点实操层面的东西。光知道原理不够真正落到配置上还是有几道坎要过的。我把之前踩过的坑和验证过的配置方案都拿出来说说。5.1 sampleCount 不是越大越好很多同学看懂了滑动窗口的原理之后第一反应是既然格子越多窗口越平滑那我就把sampleCount调到很大每个格子切到 1ms这样统计肯定最准。听起来没毛病但实际跑起来很容易出问题。除了前面说的内存开销还有一个容易被忽略的点格子太窄会让窗口的统计粒度太碎一旦流量分布不均匀窗口里会出现很多个空窗口和满窗口交替的情况限流判断的抖动反而更明显。我之前在一个商品详情接口上做过测试同样的 100 QPS 阈值默认 2 格时限流后接口的错误率曲线是比较平缓的把sampleCount调到 100 后曲线开始出现锯齿状瞬时 QPS 有几次直接超过了阈值 1.5 倍。原因是窗口滑动时每次跨越一个格子之前那个格子的整个计数瞬间被丢掉导致统计值突然跳变。所以我的建议是一般情况下保持默认值就行对精度有更高要求才适当调大但也要结合压测数据来确定别想当然。调参之后一定要用压测工具模拟真实流量盯住 P99 延迟和错误率这两个指标。5.2 时钟回拨带来的统计异常这个坑比较冷门但遇到了很头疼。滑动窗口算法依赖系统时间戳做窗口定位如果运行 Sentinel 的机器出现时钟回拨比如 NTP 校时就可能导致currentWindow方法走到第四个分支——返回一个窗口开始时间比当前时间还大的未来窗口。在这个分支里Sentinel 会返回一个新的空窗口避免数据被污染。虽然时间回拨期间限流统计会有短暂的不准确但相比直接把整个数组搞乱这个处理已经很友好了。应对方案是在生产环境的机器上关闭 NTP 自动校时或者把 NTP 的校时步长设置得很小避免大的时间跳变。这个问题在普通单机上确实很少遇到但用了容器化调度之后偶尔会碰上一次值得留意。5.3 高并发下的线程转让坑源码里有多处Thread.yield()前面也说到了CAS 失败或者拿锁失败就会让出 CPU。有一次我在一个高并发项目里把 Sentinel 的规则配到了某个非常热门的接口上压测时发现吞吐量上不去CPU 占用却很高。用 arthas 一看大量线程卡在了LeapArray.currentWindow的Thread.yield()附近。后来定位下来是因为一个极端场景大量请求在窗口边界附近同时到达大家都在竞争重置同一个格子CAS 频繁失败线程不停让出 CPU导致上下文切换开销暴涨。这个问题的根源不是 Sentinel 的 bug而是我当时的规则太极端比如 100 并发同时打一个 1 QPS 的限流阈值。解决办法就是合理设置阈值让限流触发的概率不要过高同时在高的核心数下配合 JVM 参数把线程池的核心线程数控制住减少无意义的竞争。5.4 配置样例与实际效果最后给一个我目前在用的配置模板。假设有一个秒杀活动需要把某个接口的 QPS 限制在 50同时要求规则持久化到 Nacos并统一限流后的响应格式规则维度按资源名配置 limit 规则阈值 50统计窗口默认 1 秒。持久化通过DataSource扩展接入 Nacos规则变更实时推送不需要重启应用。统一响应在BlockExceptionHandler里返回统一的 JSON 结构避免限流后用户看到一堆奇怪的错误码。配合 Feign在调用链路的消费方也挂一个降级规则一旦提供方限流消费方的 Feign 客户端立刻熔断降级返回兜底数据。实测下来滑动窗口在这种短时间集中流量 精准阈值控制的场景下表现最好既不会像固定窗口那样出现双倍流量穿透也不会像纯令牌桶那样对突发流量过于一刀切。对于大多数微服务网关和接口层的限流需求这套方案完全够用。6. 学习路径建议从会用到底层原理的精进路线如果你刚接触滑动时间窗口我的建议是别急着啃源码。先把 Sentinel 的官方文档看一遍了解清楚规则怎么配、算法有哪些、各参数含义是什么然后再去读LeapArray、WindowWrap、MetricBucket这三个核心类。读源码的时候带着三个问题去读窗口怎么定位的什么时候重置数据怎么累加和读取把这三个问题在代码里找到对应实现这就算真正入门了。接着可以用压测工具构造一个临界流量的测试场景对比固定窗口实现和滑动窗口实现的行为差异。我还记得自己第一次做这个实验的时候看到固定窗口在边界处放行了两倍流量而滑动窗口稳稳地拦住了那个突刺那一刻确实对这套算法有了更直观的信心。理论有时候就是需要在实验里落地才能真正变成自己的东西。