熔断阈值设成 0.5 那天,健康的下游被我们自己掐死了:Sentinel 滑动窗口的 5 个格子怎么算
2026/8/6 16:37:31 网站建设 项目流程

title: 熔断阈值设成 0.5 那天,健康的下游被我们自己掐死了:Sentinel 滑动窗口的 5 个格子怎么算
tags: Sentinel,熔断降级,滑动窗口,微服务,Java
category: 后端


凌晨两点,一个没坏的服务被判了死刑

今年 3 月的一次大促预热,商品详情页的价格接口开始大面积返回降级兜底价。监控上看,价格服务本身的 CPU 只有 23%,P99 是 68ms,慢查询没有,GC 也正常——它压根没出问题。出问题的是调用方:网关侧的 Sentinel 把它熔断了。

熔断规则是半年前配的,配置很朴素:

DegradeRule rule = new DegradeRule("priceService:queryPrice"); rule.setGrade(RuleConstant.DEGRADE_GRADE_RT); // 慢调用比例 rule.setCount(200); // RT 阈值 200ms rule.setSlowRatioThreshold(0.5); // 慢调用比例 50% rule.setMinRequestAmount(5); // 最小请求数 5 rule.setStatIntervalMs(1000); // 统计窗口 1 秒 rule.setTimeWindow(10); // 熔断 10 秒 DegradeRuleManager.loadRules(Collections.singletonList(rule));

逐行解释这几个参数在 Sentinel 1.8.x 里各自管什么:

  • 第 2 行DEGRADE_GRADE_RT表示按慢调用比例熔断,不是按异常。
  • 第 3 行count在 RT 模式下是"多慢算慢"的分界线,200ms 以上算一次慢调用。
  • 第 4 行slowRatioThreshold是慢调用占比的触发线,0.5 表示一半以上请求慢就熔断。
  • 第 5 行minRequestAmount是熔断的最小样本数。这里的 5 是致命的。
  • 第 6 行statIntervalMs是统计窗口长度,1 秒。
  • 第 7 行timeWindow是熔断持续时间,单位秒。

问题出在第 5 行和第 6 行的组合上。价格接口在预热阶段流量并不高,网关每个实例大概每秒 8-12 个请求。1 秒窗口 + 最小 5 个请求,意味着只要一秒内有 3 个请求超过 200ms,比例就到了 0.6,直接熔断 10 秒。

而那天恰好赶上一次 Kubernetes 节点上的邻居 Pod 在做镜像拉取,网络抖了几下,价格接口偶发出现 300-400ms 的响应。三个慢请求,熔断 10 秒;10 秒后半开,探测请求又赶上一次抖动,继续熔断。一个健康的服务,被一个采样数只有 5 的窗口反复判死刑。

先把滑动窗口的格子数清楚

Sentinel 的统计不是一个大计数器,而是一圈格子。核心类是LeapArrayStatisticNode里持有两个:秒级和分钟级。

// com.alibaba.csp.sentinel.node.StatisticNode private transient volatile Metric rollingCounterInSecond = new ArrayMetric( SampleCountProperty.SAMPLE_COUNT, IntervalProperty.INTERVAL); private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000, false);

默认SAMPLE_COUNT = 2INTERVAL = 1000,也就是 1 秒切成 2 个格子,每格 500ms。分钟级是 60 个格子,每格 1 秒。

取格子的逻辑在LeapArray.currentWindow()

public WindowWrap<T> currentWindow(long timeMillis) { int idx = calculateTimeIdx(timeMillis); // 算出该落在哪个格子 long windowStart = calculateWindowStart(timeMillis); // 该格子的起始时间戳 while (true) { WindowWrap<T> old = array.get(idx); if (old == null) { WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); if (array.compareAndSet(idx, null, window)) { return window; } Thread.yield(); } else if (windowStart == old.windowStart()) { return old; // 命中当前格子,直接累加 } else if (windowStart > old.windowStart()) { if (updateLock.tryLock()) { try { return resetWindowTo(old, windowStart); // 格子过期,原地复用并清零 } finally { updateLock.unlock(); } } Thread.yield(); } else { return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } }

这段代码有几处我觉得设计得很实在:

  • 第 2 行calculateTimeIdx的实现是(timeMillis / windowLengthInMs) % array.length(),一次除法一次取模,没有加锁,也没有额外内存分配。
  • 第 9 行用 CAS 创建格子,避免多线程重复建。
  • 第 15-16 行是"滑动"的真相:数组长度固定,格子不新建也不销毁,时间走过去就地清零复用。所谓滑动窗口,物理上是一个环形数组,逻辑上靠windowStart判断新旧。
  • 第 17 行只有清零这一步用了tryLock,因为清零要保证只做一次。拿不到锁的线程Thread.yield()后重来,不阻塞。

聚合的时候,values()会遍历整个数组,把还在有效期内的格子加起来:

public List<T> values(long timeMillis) { List<T> result = new ArrayList<T>(array.length()); for (int i = 0; i < array.length(); i++) { WindowWrap<T> windowWrap = array.get(i); if (windowWrap == null || isWindowDeprecated(timeMillis, windowWrap)) { continue; // 过期格子直接跳过 } result.add(windowWrap.value()); } return result; }

第 5 行的isWindowDeprecated判断的是当前时间 - 格子起始时间 > intervalInMs。这意味着统计的实际时间跨度不是精确的 1 秒,而是"1 秒到 1.5 秒之间"——取决于当前时刻落在格子的哪个位置。

为什么不用固定计数器

固定窗口计数器的问题在临界点。假设限流阈值 100 QPS,用一个每秒清零的计数器:

  • 00:00:00.900 来了 100 个请求,全部放行。
  • 00:00:01.000 计数器清零。
  • 00:00:01.100 又来 100 个请求,全部放行。

200 毫秒内进了 200 个请求,实际瞬时 QPS 是阈值的两倍。下游按 100 QPS 做的容量规划,这一下就被打穿了。

滑动窗口把这个尖峰摊平:格子越多,窗口边界移动得越平滑,临界突刺越小。代价是内存和遍历开销——60 个格子的分钟级统计,每次values()都要遍历 60 次。

方案临界突刺内存统计精度Sentinel 中的位置
固定窗口计数器最高 2 倍阈值O(1)未使用
滑动窗口(本文)取决于格子数O(n)默认策略
滑动日志O(请求数)精确未使用,内存不可控
令牌桶/漏桶O(1)精确匀速排队模式

我的看法是,Sentinel 选滑动窗口而不是滑动日志,是一次很清醒的工程取舍:滑动日志要存每个请求的时间戳,高 QPS 下内存直接爆炸;滑动窗口用固定长度的环形数组,内存与流量无关。精度换稳定性,在中间件里几乎总是对的。

熔断器的三个状态是怎么切的

Sentinel 1.8 之后把熔断器抽成了独立的CircuitBreaker,状态机比 1.7 清晰很多:

// com.alibaba.csp.sentinel.slots.block.degrade.circuitbreaker.ResponseTimeCircuitBreaker @Override public void onRequestComplete(Context context) { SlowRequestCounter counter = slidingCounter.currentWindow().value(); Entry entry = context.getCurEntry(); long completeTime = entry.getCompleteTimestamp(); if (completeTime <= 0) { completeTime = TimeUtil.currentTimeMillis(); } long rt = completeTime - entry.getCreateTimestamp(); if (rt > maxAllowedRt) { counter.slowCount.add(1); // 慢调用计数 +1 } counter.totalCount.add(1); // 总数 +1 handleStateChangeWhenThresholdExceeded(rt); } private void handleStateChangeWhenThresholdExceeded(long rt) { if (currentState.get() == State.OPEN) { return; // 已熔断,不处理 } if (currentState.get() == State.HALF_OPEN) { if (rt > maxAllowedRt) { fromHalfOpenToOpen(1.0d); // 探测请求还是慢,回到 OPEN } else { fromHalfOpenToClose(); // 探测通过,恢复 } return; } List<SlowRequestCounter> counters = slidingCounter.values(); long slowCount = 0, totalCount = 0; for (SlowRequestCounter c : counters) { slowCount += c.slowCount.sum(); totalCount += c.totalCount.sum(); } if (totalCount < minRequestAmount) { return; // 样本不够,不熔断 } double currentRatio = slowCount * 1.0d / totalCount; if (currentRatio > maxSlowRequestRatio) { transformToOpen(currentRatio); } }

值得注意的几点:

  • 第 9 行的 RT 是completeTimestamp - createTimestamp,包含了整个 Sentinel 链路的耗时,不只是业务方法本身。所以配阈值时不能直接拿业务方法的耗时来对齐。
  • 第 22-28 行是半开状态的处理:只用一个探测请求决定生死。这个探测请求如果撞上一次 GC 或者网络抖动,就会重新回到 OPEN。我们那晚遇到的正是这种情况。
  • 第 34-37 行的minRequestAmount检查在比例计算之前。样本数不够时直接返回,一个请求都不熔断。

这段源码解释了我们那次事故的完整链路:低流量 + 小样本 + 单探测请求恢复 = 熔断器长期卡在 OPEN 和 HALF_OPEN 之间来回跳。

我们最后改了什么

调整后的规则:

DegradeRule rule = new DegradeRule("priceService:queryPrice"); rule.setGrade(RuleConstant.DEGRADE_GRADE_RT); rule.setCount(500); // 200ms -> 500ms,对齐真实业务 P99 的 3 倍 rule.setSlowRatioThreshold(0.7); // 0.5 -> 0.7 rule.setMinRequestAmount(20); // 5 -> 20 rule.setStatIntervalMs(10_000); // 1s -> 10s rule.setTimeWindow(30); // 10s -> 30s

四处改动的理由分别是:

  1. count从 200 提到 500。原来的 200ms 是照着接口的 P99 直接抄的,但 P99 本身就有 1% 的请求会超。熔断阈值应该定在"明显异常"的位置,我们取 P99 的 2.5 到 3 倍。
  2. slowRatioThreshold从 0.5 提到 0.7。一半请求慢就熔断太激进了,下游只是抖一下就被判死。
  3. minRequestAmount从 5 提到 20。这是最关键的一处。样本数太小的时候,比例这个统计量本身就不可信——3/5 和 30/50 在数学上是一个比例,在置信度上差了一个量级。
  4. statIntervalMs从 1 秒放到 10 秒。窗口拉长后,偶发抖动会被更多正常请求稀释。

timeWindow从 10 秒改到 30 秒看起来是"更严格",其实是为了减少半开探测的频率。10 秒探测一次、探测失败又熔断,一分钟能反复 6 次;30 秒一次,同样时间只探 2 次,日志和告警都清爽了很多。

复盘数据

改造后连续观察了 4 周:

指标改造前(3 月)改造后(4 月)
价格接口熔断触发次数每天 40-120 次每天 0-3 次
误熔断占比(下游实际健康)约 85%约 10%
兜底价曝光量日均 21 万次日均 4000 次
真实故障时的熔断响应延迟约 1.2 秒约 6 秒

最后一行是这次调整付出的代价:窗口从 1 秒拉到 10 秒,真出故障时熔断触发慢了约 5 秒。我们权衡下来接受了——下游真挂的时候,5 秒内的失败请求会被上游重试和超时兜住;而误熔断带来的兜底价曝光,是直接的用户体验和交易损失。

顺带说一个容易被忽略的点:statIntervalMs必须能被SAMPLE_COUNT整除,否则 Sentinel 在ArrayMetric构造时会算出不均匀的格子长度。10000 / 2 = 5000ms 一格,正好。如果你写 7000,格子就是 3500ms,统计边界会很别扭。

一点个人判断

熔断这套东西,参数配错的杀伤力比不配还大。不配熔断,最坏是被下游拖慢;配错熔断,是自己主动把好服务掐了。我现在给团队定的规矩是:任何熔断规则上线前,必须能回答三个问题——这个接口正常的 QPS 是多少、minRequestAmount 相对这个 QPS 是几秒的量、RT 阈值是 P99 的几倍。答不上来就不许配。

对低流量接口(每分钟几十个请求),我更倾向于干脆不配 RT 熔断,只配异常数熔断(DEGRADE_GRADE_EXCEPTION_COUNT)。慢调用比例这个指标在小样本下没有统计意义,配了就是给自己埋雷。

顺着这个思路问一个问题:如果一个接口的流量在白天是 2000 QPS、凌晨只有 20 QPS,minRequestAmount该按哪个量级定?固定值肯定顾此失彼,你们是用多套规则按时段切换,还是接受凌晨基本不熔断?欢迎在评论区说说你们的处理方式。

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

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

立即咨询