
title: 熔断阈值设成 0.5 那天健康的下游被我们自己掐死了Sentinel 滑动窗口的 5 个格子怎么算tags: Sentinel,熔断降级,滑动窗口,微服务,Javacategory: 后端凌晨两点一个没坏的服务被判了死刑今年 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 WindowWrapT currentWindow(long timeMillis) { int idx calculateTimeIdx(timeMillis); // 算出该落在哪个格子 long windowStart calculateWindowStart(timeMillis); // 该格子的起始时间戳 while (true) { WindowWrapT old array.get(idx); if (old null) { WindowWrapT window new WindowWrapT(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 WindowWrapT(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } }这段代码有几处我觉得设计得很实在第 2 行calculateTimeIdx的实现是(timeMillis / windowLengthInMs) % array.length()一次除法一次取模没有加锁也没有额外内存分配。第 9 行用 CAS 创建格子避免多线程重复建。第 15-16 行是滑动的真相数组长度固定格子不新建也不销毁时间走过去就地清零复用。所谓滑动窗口物理上是一个环形数组逻辑上靠windowStart判断新旧。第 17 行只有清零这一步用了tryLock因为清零要保证只做一次。拿不到锁的线程Thread.yield()后重来不阻塞。聚合的时候values()会遍历整个数组把还在有效期内的格子加起来public ListT values(long timeMillis) { ListT result new ArrayListT(array.length()); for (int i 0; i array.length(); i) { WindowWrapT 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; } ListSlowRequestCounter 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四处改动的理由分别是count从 200 提到 500。原来的 200ms 是照着接口的 P99 直接抄的但 P99 本身就有 1% 的请求会超。熔断阈值应该定在明显异常的位置我们取 P99 的 2.5 到 3 倍。slowRatioThreshold从 0.5 提到 0.7。一半请求慢就熔断太激进了下游只是抖一下就被判死。minRequestAmount从 5 提到 20。这是最关键的一处。样本数太小的时候比例这个统计量本身就不可信——3/5 和 30/50 在数学上是一个比例在置信度上差了一个量级。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 QPSminRequestAmount该按哪个量级定固定值肯定顾此失彼你们是用多套规则按时段切换还是接受凌晨基本不熔断欢迎在评论区说说你们的处理方式。