sn: 22
batch: 5
round: 9
topic: 微服务熔断降级:Sentinel 滑动窗口算法
大促压测那天出现了一个诡异现象:我们给接口配了 QPS 1000 的限流,压测机打 1000 QPS,实际通过的只有 600 出头,Sentinel 后台显示的 PASS QPS 也是 600。流量没丢在服务端,是 Sentinel 的统计窗口把真实流量"看低"了。排查半天发现是统计间隔配置问题——为了省内存把 windowInterval 配大了,粒度太粗,突刺流量被窗口平均掉了,限流判断基于失真的统计值。
要真正理解这类问题,就得钻进 Sentinel 的统计核心:LeapArray 滑动窗口。这篇拆它的源码结构、时间片轮转机制,以及我配置统计窗口的三条实战经验。版本:Sentinel 1.8.x。
固定窗口的原罪:为什么必须滑起来
先看反面教材。固定窗口限流把 1 秒切成一个格子计数,超过就拒绝。它有个数学上无解的缺陷:窗口边界的突刺。假设限流 1000/秒,前 1 秒的最后 100ms 放过 1000 个请求,后 1 秒的最前 100ms 又放过 1000 个——短短 200ms 内通过了 2000 个请求,瞬时压力是设定的两倍。而且窗口越大,突刺越猛。
Sentinel 的解法是把时间轴切成小格子(WindowWrap),统计时把覆盖当前时刻的连续多个格子加起来。这就是滑动窗口。
LeapArray 的数据结构:环形数组 + 时间分片
LeapArray 的核心是个AtomicReferenceArray<WindowWrap<T>>环形数组,每个槽位是一个带时间归属的统计格子:
public WindowWrap<T> currentWindow(long timeMillis) { // 1. 算出当前时间落在哪个槽位:时间戳 / 格子宽度 对数组长度取模 int idx = calculateTimeIdx(timeMillis); // 2. 算出这个槽位本应归属的窗口起始时间 long windowStart = calculateWindowStart(timeMillis); while (true) { WindowWrap<T> old = array.get(idx); if (old == null) { // 3. 槽位没人用(冷启动),新建一个窗口 CAS 放进去 WindowWrap<T> window = new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); if (array.compareAndSet(idx, null, window)) { return window; } Thread.yield(); } else if (windowStart == old.windowStart()) { // 4. 时间对上了,就是当前窗口,直接返回 return old; } else if (windowStart > old.windowStart()) { // 5. 槽位里是旧窗口(环形数组转了一圈回来了),CAS 复位成新窗口 if (updateLock.tryLock()) { try { resetWindowTo(old, windowStart); } finally { updateLock.unlock(); } return old; } } else if (windowStart < old.windowStart()) { // 6. 理论上不该出现(时钟回拨),防御性返回 return new WindowWrap<>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } }逐行拆:第 1 行的取模是环形数组的关键——1 秒统计窗口、2 个格子的话,格子宽度 500ms,时间戳 1500ms 落在槽位(1500/500) % 2 = 0,即槽 0;第 5 行是这个设计最妙的地方:数组只有有限个槽位,时间往前走,环形数组会转回来复用旧槽位,此时用 CAS + 锁把旧格子清零复位成新窗口,旧统计数据自然被丢弃——不需要后台线程定时清理,内存不会涨,逻辑全部收敛在写入路径上。
统计读取(getWindows)则遍历数组,只取windowStart在"当前时间 - 统计时长"范围内的格子求和。因为窗口起点对不上就被排除,所以读到的永远是最近 N 毫秒的真实统计——这就是"滑动"的含义:没有物理滑动,是按时间筛选。
回到事故:统计窗口粒度怎么配
Sentinel 默认sampleCount = 2, intervalInMs = 1000,即 1 秒 2 个格子,每个 500ms。我们的事故配置是 interval 配成了 10 秒、格子 2 个,每个 5 秒——粒度粗到把 1000 QPS 的压测流量在 5 秒窗口里平均成 600 的有效值,限流判断拿这个失真值去比阈值,自然放不满。
我的三条配置经验:第一,限流场景格子宽度不要超过 250ms,突刺响应速度才有保障;第二,统计总窗口(interval)和阈值语义要对齐——你说"1000 QPS",统计窗口就得是 1 秒,配成 10 秒统计却拿 1 秒阈值比,数学上就是错的;第三,statisticMaxQueueingTimeMs这类排队等待参数别乱开,它和快速失败是两种完全不同的过载策略,混用会导致流量整形行为不可预期。
再看 Sentinel 的 QPS 判断入口,理解阈值怎么和统计值配合:
// DefaultTrafficShapingController.canPass 的核心逻辑 public boolean canPass(Node node, int acquireCount, boolean prioritized) { int qps = count; // 1. 配置的阈值,比如 1000 if (grade == RuleConstant.FLOW_GRADE_QPS) { // 2. 拉取"当前时间往前一个统计窗口"的通过数 + 已占用配额 long curPass = node.passQps(); long occupyPass = node.occupyPassQps(); // 3. 提前占坑的部分(排队等待场景) long curIntervalPass = curPass + occupyPass; // 4. 最近窗口内的已通过数 + 本次请求量 与阈值比较 long remaining = qps - curIntervalPass; return remaining >= acquireCount; } // 5. 并发线程数模式:直接比较当前线程数 return node.curThreadNum() < qps; }逐行说:第 2 行的 passQps 就是滑动窗口算出来的最近窗口通过量;第 3 行是 Sentinel 1.5+ 的"匀速排队"特性预留的占坑计数,快速失败模式下为 0;第 4 行的比较发生在请求执行前,这是限流"事前拦截"的语义——和熔断"事后统计"形成互补。
熔断与限流的配合:我的实战分层
滑动窗口除了支撑限流,也是熔断统计的数据源。Sentinel 的慢调用比例熔断(RT 超过阈值算慢调用,比例超线触发熔断)底层同样是 LeapArray 统计。我的分层配置:
| 层次 | 手段 | 参数要点 |
|---|---|---|
| 入口层 | QPS 快速失败 | 阈值=压测容量的 80%,窗口 1 秒 4 格 |
| 服务层 | 并发线程数限制 | 每服务 50-100,防止线程池耗尽级联 |
| 依赖层 | 慢调用比例熔断 | RT 阈值 200ms,比例 0.5,熔断 10 秒起 |
| 兜底层 | 系统自适应限流 | Load 兜底,防止上游全失效 |
一点个人判断:很多团队只在入口配 QPS 限流就完事,但入口限流挡不住"合法流量打垮下游"——A 服务的 1000 QPS 全量打到只有 200 并发容量的 B 服务,B 照样挂。所以依赖层的熔断和线程数隔离才是防雪崩的主力,入口限流只是第一道过滤。我们那次统计窗口事故后补齐了这套分层,大促再没出现过局部故障拖垮全链路的情况。
从代码看一个完整的限流 + 降级闭环
把规则编码进代码,展示 Sentinel 资源埋点和兜底回调的完整闭环:
@SentinelResource( value = "createOrder", // 1. 资源名,规则面板里按这个名字配规则 blockHandler = "createOrderBlocked", // 2. 被限流/熔断时的处理方法 fallback = "createOrderFallback") // 3. 业务异常时的降级方法 public OrderVO createOrder(CreateOrderRequest req) { return orderService.create(req); } // 4. blockHandler:限流或熔断触发时走这里,签名必须与原方法一致且多一个 BlockException public OrderVO createOrderBlocked(CreateOrderRequest req, BlockException ex) { // 5. 返回业务可理解的降级结果,而不是裸抛异常 throw new BizException("当前下单人数较多,请稍后重试"); } // 6. fallback:业务抛异常时的降级,比如查不到库存就返回预估数据 public OrderVO createOrderFallback(CreateOrderRequest req, Throwable t) { metrics.counter("order.fallback", "reason", t.getClass().getSimpleName()).increment(); throw new BizException("下单服务繁忙,请稍后重试"); }逐行拆:第 2 行和第 3 行的区分是 Sentinel 的语义设计——blockHandler 处理"规则拦截"(限流、熔断、系统保护),fallback 处理"业务异常降级",两者混用会导致限流时的行为不可控;第 4 行的签名约束(参数一致 + 尾部 BlockException)是很多人编译不过的原因;第 6 行给降级本身也埋了指标,降级次数、降级原因是排障和容量规划的重要数据,只降级不埋点是黑箱操作。
再补一个动态规则管理的实践。规则硬编码在代码里改一次发一次版不可接受,我们用 Nacos 做 Sentinel 的规则数据源:
ReadableDataSource<String, List<FlowRule>> ds = new NacosDataSource<>( "nacos-server:8848", "sentinel-rules", "flow-rules", // 1. Nacos 地址、分组、dataId source -> JSON.parseObject(source, new TypeReference<List<FlowRule>>() {})); FlowRuleManager.register2Property(ds.getProperty()); // 2. 注册后规则变更自动推送生效第 1 行的 dataId 对应 Nacos 上的一份 JSON 配置,运营在控制台改规则 → 写 Nacos → Sentinel 客户端监听到变更秒级生效,发布零成本。注意第 2 行只注册了"读",规则的持久化要靠 Sentinel 控制台的规则推送写回 Nacos(官方 dashboard 默认存内存,重启就丢,这个坑我们踩过一次:值班凌晨改了限流规则,早上重启全丢,后来接了 writeable datasource 才闭环)。
思考题
LeapArray 用环形数组 + CAS 复位实现窗口轮转,如果改成 HashMap + 定时清理过期 key,内存和 CPU 特征会有什么不同?为什么 Sentinel 选择了前者?