Sentinel 为什么用滑动窗口而不是计数器:一次熔断误杀把核心链路打挂的复盘

📅 2026/7/30 16:39:10
Sentinel 为什么用滑动窗口而不是计数器:一次熔断误杀把核心链路打挂的复盘
去年大促前压测我们一个商品详情接口配置了错误率超 50% 就熔断用的是最早的固定窗口计数器。压测刚起量接口就被熔断了而监控显示实际错误率只有 3%。排查发现固定窗口在窗口边界把上一窗口末尾的少量错误和下一窗口开头的正常请求错误地叠加瞬间误判超阈值。那次我们才意识到熔断的算法选错了比不熔断还危险——它会把健康的服务自己干掉。后来换成 Sentinel 的滑动窗口统计误判基本消失。这篇讲清楚固定窗口的边界陷阱、Sentinel 滑动窗口怎么算、以及熔断器状态机附我们线上的配置踩坑。固定窗口的边界陷阱先说为什么简单的计数器不够用。固定窗口把时间切成 1 秒一段每段内统计错误数超了就熔断。问题出在窗口切换的瞬间// 固定窗口计数器的典型实现有边界漏洞 public class FixedWindowCounter { private final int limit 50; // 1. 窗口内允许的最大错误数 private int errorCount 0; // 2. 当前窗口错误计数 private long windowStart System.currentTimeMillis(); // 3. 窗口起点 public synchronized boolean allow() { long now System.currentTimeMillis(); if (now - windowStart 1000) { // 4. 超过 1 秒就重置窗口 errorCount 0; windowStart now; } if (errorCount limit) return false; // 5. 超限拒绝熔断 return true; } public synchronized void recordError() { errorCount; } // 6. 记录错误 }逐行第 4 行是漏洞根源——窗口在整秒边界硬切换。假设第 0.9 秒来了 49 个错误窗口 A 末尾第 1.1 秒窗口 B 开头又来 2 个错误两个窗口各自的计数都没超 50系统认为健康但如果流量在边界附近集中真实 1 秒内的错误可能是 49251固定窗口却看不出。更严重的是相反情况窗口 A 末尾 49 个错误 窗口 B 开头立刻又来几个瞬间被判定超阈值熔断而实际上这两段加起来才刚过线、且 B 开头已经恢复正常。我们用它压测时就是被这种边界瞬时叠加误杀了。Sentinel 滑动窗口把时间切成样本Sentinel 不用整段窗口它把时间轴切成很多小样本默认 1 秒 2 个样本点或更细统计时按当前时间落在哪个滑动区间滚动累加平滑掉边界突变// Sentinel 滑动窗口核心思路简化还原 // 以 ArrayMetric LeapArray 为例时间轴切成 sampleCount 个桶 public class SlidingWindowMetric { private final int sampleCount 2; // 1. 1 秒内分 2 个桶可配置 private final int intervalMs 1000; // 2. 统计区间 1 秒 private final Bucket[] buckets new Bucket[sampleCount]; // 3. 环形数组存桶 // 4. 根据当前时间算出落在哪个桶旧桶复用时间窗口滑动 public Bucket currentBucket() { long timeId System.currentTimeMillis() / (intervalMs / sampleCount); int idx (int) (timeId % sampleCount); Bucket b buckets[idx]; if (b null || b.isOld(timeId)) { b new Bucket(); buckets[idx] b; // 5. 旧桶重置复用 } return b; } public long errorInWindow() { /* 6. 累加当前滑动区间内所有桶的错误数 */ } }逐行第 1、2 行把 1 秒切成 2 个 500ms 的小桶Sentinel 实际可配更细比固定窗口的1 秒 1 段粒度更细第 4、5 行是滑动的关键——根据时间算出当前该写哪个桶过期的旧桶直接重置复用形成环形数组统计时永远只看最近 1 秒内有效的桶不会出现固定窗口那种窗口硬切把数据劈成两半的断点第 6 行统计错误率是把最近一个区间内的桶求和再除以总请求边界处的瞬时流量被分摊到相邻桶不会被某次切换瞬间放大。我们换成这个算法后压测时错误率曲线平滑了熔断再也没有在刚起量时被误触发。熔断器状态机CLOSED / OPEN / HALF_OPEN光有准确统计还不够熔断本身是个状态机。Sentinel 的CircuitBreaker就是三态切换// 熔断器三态以 Sentinel DegradeRule 为例的配置与语义 // 状态CLOSED正常- OPEN熔断拒绝所有- HALF_OPEN探测- CLOSED/OPEN DegradeRule rule new DegradeRule(); rule.setResource(getItemDetail); // 1. 受保护的资源名 rule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO); // 2. 按异常比例熔断 rule.setCount(0.5); // 3. 异常比例超 50% 触发 rule.setTimeWindow(10); // 4. 熔断持续 10 秒 rule.setMinRequestAmount(20); // 5. 最少 20 个请求才统计防小样本误判 rule.setStatIntervalMs(1000); // 6. 统计窗口 1 秒配滑动窗口 // 7. 熔断 10 秒后进入 HALF_OPEN放少量请求探测成功则恢复 CLOSED逐行第 2 行选的是异常比例策略还有慢调用比例、异常数两种第 3 行 50% 是阈值但我们后来把核心接口调到了 0.6因为 0.5 在抖动时还是偏敏感第 5 行MinRequestAmount很关键——请求量太小比如 1 秒内只有 3 个请求其中 2 个错就触发熔断是灾难设 20 能避开小样本误判第 7 行是三态的核心OPEN 持续TimeWindow后转 HALF_OPEN放一小撮请求试探成功就回 CLOSED失败继续 OPEN。我们的教训曾经TimeWindow设成 60 秒一次真实故障恢复后熔断还死死拦着 60 秒等于故障被自己延长了。后来核心接口压到 10 秒恢复灵敏度上来就少背了锅。降级 fallback熔断后怎么兜底熔断只是不调用但调用方得有退路否则熔断了用户看到的是报错页。Sentinel 的SentinelResource配 fallback// 熔断后的降级方法别让用户直面异常 SentinelResource(value getItemDetail, fallback getItemDetailFallback) // 1. 熔断/限流时走这个方法 public ItemDetail getItemDetail(long itemId) { return itemClient.query(itemId); // 2. 正常远程调用 } // 3. 降级方法签名要和原方法一致可多一个 Throwable 参数 public ItemDetail getItemDetailFallback(long itemId, Throwable e) { return ItemDetail.fromCache(itemId); // 4. 返回缓存/默认值保证页面不崩 }逐行第 1 行声明 fallback 方法名一旦资源被熔断或限流Sentinel 自动转发到它第 3、4 行降级方法返回缓存数据或默认值让用户至少看到不那么准但能用的页面。关键点降级方法里绝不能再去调那个已经被熔断的依赖否则等于在熔断里又触发熔断栈溢出。我们早期 fallback 里顺手又查了一次数据库想补最新数据结果数据库压力随熔断放大雪崩。降级就老老实实返回缓存/静态值。我的取舍熔断阈值别抄默认我的观点Sentinel 的默认阈值异常比例 0.5、TimeWindow 看版本是直接抄会出事的必须按接口的重要性调。核心交易链路我倾向宁晚勿错——阈值放到 0.6~0.7、TimeWindow 压到 10 秒避免健康时被误杀非核心的查询接口可以激进点早熔断保护下游。另外熔断解决的是下游已经不行了别再雪崩给它不是让接口变快它挡的是故障扩散不是性能优化。真正要做的是 fallback 必须返回能用的兜底数据而不是抛异常否则熔断了用户体验更差。补充一个容易被忽略的细节Sentinel 的熔断策略不止异常比例一种。除了DEGRADE_GRADE_EXCEPTION_RATIO还有DEGRADE_GRADE_RT慢调用比例响应时间超阈值的请求占比和DEGRADE_GRADE_EXCEPTION_COUNT异常数。我们商品详情接口后来改用了 RT 策略——只要 P99 响应超 500ms 的比例过线就熔断比等异常抛出来更早拦住慢而不错的依赖那种接口没报错但把线程池耗干的慢调用靠异常比例根本发现不了。另外 HALF_OPEN 阶段的探测不是只放一个请求Sentinel 用halfOpenRequests探测期内允许通过的请求数默认 5和maxAllowedRtMs探测成功的最大 RT来控制恢复节奏这两个值调小能让恢复更谨慎避免刚打开熔断、一波探测流量就把还没完全恢复的依赖又冲垮。还有一个坑熔断规则如果用硬编码写死重启就丢。我们用的是 Sentinel 1.8.x Dashboard 推送到 Nacos 动态配置规则存 Nacos 而不是内存这样规则变更不用发版、重启也不丢。曾经有次规则只写在代码里发布后规则没加载熔断形同虚设下游抖动直接把上游拖死。思考题如果你的熔断阈值设成异常比例 0.5 但 MinRequestAmount 没设默认 5在流量极低的内网接口上会发生什么如果要让熔断恢复后渐进放量而不是一次性全开状态机要怎么改写在最后熔断器选错算法比不熔断还危险——固定窗口的边界漏洞能在流量刚起量时误杀健康服务。Sentinel 用滑动窗口把时间切碎、平滑统计配合 CLOSED/OPEN/HALF_OPEN 三态和合理的阈值、TimeWindow、MinRequestAmount才真正兜住故障扩散。但记住熔断只管挡挡完用户看到什么取决于你的 fallback 兜不兜得住。