Sentinel自定义熔断:基于业务指标扩展Slot链的实战指南

📅 发布时间:2026/10/9 6:46:39
Sentinel自定义熔断:基于业务指标扩展Slot链的实战指南
去年压测的时候我们遇到过一个很典型的问题接口的QPS没超阈值RT也正常Sentinel默认的熔断规则一条都没触发但订单的下单成功率却在缓慢下跌。最后排查了半天根因不在应用本身而是下游结算系统在处理一批异常账单时产生延迟。这个故障留给我一个很深的印象——Sentinel默认的熔断条件其实只覆盖了技术指标对业务健康度的感知是缺失的。所以后来我们专门做了一件事给Sentinel扩展自定义熔断条件让它能结合业务指标来决策。这篇文章就聊聊当时的完整思路、核心实现和落地过程中踩过的坑项目基于Sentinel 1.8.6希望对想做同类扩展的团队有点参考价值。1. 默认熔断规则覆盖不到的业务健康度盲区1.1 默认熔断到底在看什么指标Sentinel的熔断降级规则DegradeRule从很早开始就支持三种判定维度异常比例、异常数、响应时间。1.8.0之后引入了CircuitBreaker体系慢调用比例也纳入其中。这些指标有一个共同的特点——它们全部来自Sentinel自身的调用链统计数据本质上反映的是“调用这个资源时技术层面发生了什么”。举个例子我们给下单接口配一条熔断规则{ resource: order:create, grade: 0, count: 100, timeWindow: 30 }这条规则的含义是order:create这个资源在统计窗口内异常数超过100次就打开熔断30秒。异常数从哪里来是从StatisticSlot注入的运行时统计里读出来的。换句话说只有当这个资源真的抛了异常、返回了错误码或者RT明显变长Sentinel才会动。这是Sentinel默认行为的边界也是很多团队接手后容易忽略的地方它并不是一个业务监控系统它观察的是调用健康度不是业务指标。所谓业务指标指的是支付成功率、对账延迟、库存水位、渠道转化率、账务积压量这类完全来自业务语义层面的数据。1.2 技术指标正常、业务已经出问题的典型场景我见过好几类故障都是业务指标先恶化技术指标后才跟着变化有些甚至始终不变。第一类是下游不报错但数据不完整。比如支付回调服务最终一致性地把消息发到了MQ但消费积压导致结算延迟。这时候下单接口本身调用成功RT也正常但支付成功的业务数据在下降。如果你只看Sentinel默认的异常数和RT熔断根本发现不了这个故障。第二类是依赖服务存在“慢性病”。比如某个老系统的接口在特定数据量下会变成坏味道但又不抛错调用方拿到的结果里包含大量无效数据。这种故障往往表现为业务口径的失败率上升但HTTP状态码依然200。第三类是业务资源瓶颈。大促时营销系统提供的优惠券池快见底了或者某个热销SKU的库存水位跌到了警戒线以下。这种场景下QPS不高但每一个请求都在消耗稀缺资源继续放量只会放大损失。这类情况很适合做业务指标的定向熔断——比如库存水位低于阈值时直接熔断下单入口。这些场景有一个共同点默认的异常比例、异常数、RT都感知不到。所以我们需要一套机制让Sentinel的熔断决策能读入业务层面的指标并且仍然采用规则驱动、动态下发的方式而不是写死在业务代码里。1.3 为什么不能靠硬编码在业务代码里解决有同学可能会说那我直接在业务代码里判断一下库存水位水位不够就抛个自定义异常不就行了确实可以但这会带来几个问题。第一业务代码会被熔断逻辑污染。判断指标、维护熔断状态、时间窗口、半开探测这些逻辑如果散落在每个Service方法里代码会非常难维护。第二规则变更不灵活。今天判断库存明天判断转化率后天要调整阈值每次都要发版这在生产环境是不可接受的。第三很难享受Sentinel已有的生态能力比如BlockException的统一处理、资源的调用链统计、与Dashboard/Nacos的联动。所以更合理的方案是走Sentinel的扩展点把业务指标熔断做成一个独立的Slot挂在槽位链上通过规则配置驱动。业务代码只需要正常用SphU.entry包裹资源即可完全感知不到熔断判断的存在。2. Slot链是自定义熔断条件的技术底座2.1 ProcessorSlotChain的执行顺序Sentinel整个限流熔断的执行流程不是靠一个大方法串起来的而是靠一条责任链。每个节点都是一个ProcessorSlot责任链由SlotChainBuilder构建。默认的链路顺序大致如下NodeSelectorSlot - ClusterBuilderSlot - LogSlot - StatisticSlot - AuthoritySlot - SystemSlot - FlowSlot - DegradeSlot - ParamFlowSlotNodeSelectorSlot负责构建调用树的节点ClusterBuilderSlot负责集群节点统计StatisticSlot负责实时数据的统计和落盘AuthoritySlot处理黑白名单SystemSlot做系统自适应保护FlowSlot做QPS/并发线程数限流DegradeSlot做熔断降级ParamFlowSlot做热点参数限流。这条链路非常关键因为Slot是有顺序的。你的自定义逻辑插在哪个位置直接决定了它和限流、熔断之间的优先级。比如你想让业务指标熔断优先于RT熔断那就要把自定义Slot放在DegradeSlot之前。2.2 DegradeSlot的局限与扩展入口DegradeSlot内部做的事情本质上是根据DegradeRule去匹配对应的CircuitBreaker然后逐条判断当前资源是否应该熔断。CircuitBreaker的决策依据有三个维度异常比例、异常数、慢调用RT。它没有预留“外部业务指标”的接口。所以要在不修改Sentinel源码的前提下接入业务指标常规做法有两个一是自定义Slot二是自定义CircuitBreaker。自定义CircuitBreaker需要实现AbstractCircuitBreaker并注册到DegradeRuleManager里好处是可以复用现有的降级规则配置模型坏处是CircuitBreaker内部与现有的滑动窗口耦合较紧代码复杂度高。自定义Slot则更直接——你在槽位链上插入一个新节点先判断业务指标再决定是放行还是抛BlockException。我们最终选了自定义Slot的方案理由就是边界清晰、测试方便、代价最小。这里有个设计上的细节需要提前想清楚自定义Slot和原生DegradeSlot是并存还是二选一我的建议是并存。业务指标熔断只负责它自己的判定触发后抛出BlockException原生熔断继续处理自己的异常比例、RT维度。两者相互独立互不干扰。这样即使业务指标配置有误也不会破坏原生熔断的保底能力。2.3 自定义Slot插入位置的设计考量插入位置不是随便定的。我最初的实现把自定义Slot放在了整条链的最后结果发现原生DegradeSlot先跑了RT一高就直接触发熔断业务指标Slot根本没有机会执行。后来调整到FlowSlot之后、DegradeSlot之前逻辑才合理FlowSlot - BizMetricSlot - DegradeSlot - ParamFlowSlot为什么要放在FlowSlot之后因为限流判断属于最基本的防护QPS超了直接拦掉没必要再做业务指标判断。为什么放在DegradeSlot之前因为业务指标的熔断优先级应该高于技术指标熔断。业务层面已经判定不健康了就没必要等技术指标慢慢累积到阈值。还有一个实际考量BlockException的语义。Sentinel的限流异常和熔断异常都继承自BlockException业务代码可以通过BlockExceptionHandler统一处理。自定义Slot抛出的BlockException最好单独定义一个子类比如BizMetricBlockException这样在统一处理时能区分到底是普通限流还是业务指标熔断方便返回不同的提示文案。3. 业务指标熔断器的完整实现3.1 规则模型BizMetricRule自定义熔断的第一步是定义自己的规则模型。这个模型不能太复杂但必须包含几个关键字段资源名、指标类型、阈值、统计窗口、最小请求数、熔断持续时间。public class BizMetricRule { private String resource; private String bizMetricType; private double threshold; private int windowIntervalMs 60000; private int minRequestAmount 10; private long stateWindowMs 30000; private boolean enabled true; // getter/setter 省略 }bizMetricType是核心我们用字符串标识指标类型比如PAY_FAIL_RATE代表支付失败率、QUEUE_DEPTH代表队列积压量、STOCK_DEPTH代表库存水位、CHANNEL_RATE代表渠道转化率。规则管理器只需要维护一个资源名到规则的映射参考Sentinel自己的RuleManager实现即可。public final class BizMetricRuleManager { private static volatile MapString, BizMetricRule rules new ConcurrentHashMap(); public static BizMetricRule getRule(String resource) { return rules.get(resource); } public static void register(String resource, BizMetricRule rule) { rules.put(resource, rule); } public static void loadRules(ListBizMetricRule newRules) { MapString, BizMetricRule newMap new ConcurrentHashMap(); for (BizMetricRule rule : newRules) { newMap.put(rule.getResource(), rule); } rules newMap; } }这里用volatile引用切换整个Map保证规则更新的可见性同时避免遍历时被并发修改。3.2 指标采集器不在Slot里做慢操作指标从哪里来是整个自定义熔断里最需要想清楚的部分。一个核心原则是采集动作发生在Slot的entry方法里它处于每次调用的热路径上任何同步IO操作都要尽量避免。我给指标采集定义了一个接口public interface BizMetricCollector { double collect(BizMetricRule rule, Context context, Object... args); }接口很薄真正的实现差异很大。这里举两种典型实现。第一种本地滑动窗口统计。比如支付失败率我们可以用Caffeine Cache维护每个资源在最近一分钟内的成功/失败计数失败率超过阈值就判定不健康。这类指标不依赖外部存储计算成本低适合作为熔断依据。public class LocalFailRateCollector implements BizMetricCollector { private final CacheString, LongAdder failCounters; private final CacheString, LongAdder totalCounters; Override public double collect(BizMetricRule rule, Context context, Object... args) { // 从args或者ThreadLocal中提取业务维度比如渠道 String channel BizContext.getChannel(); long fail failCounters.get(channel, k - new LongAdder()).sum(); long total totalCounters.get(channel, k - new LongAdder()).sum(); if (total 0) { return 0; } return fail * 100.0 / total; } }第二种读取外部缓存的业务水位。比如库存量通常我们会把库存水位异步同步到本地Caffeine缓存或Redis采集器只在Slot里做一次缓存查询。这里要特别提醒不要直接查数据库否则高并发下第一个压垮的就是你的指标采集链路。3.3 熔断状态机从关闭到半开熔断不能只做一次性判断必须有状态机。Sentinel原生的CircuitBreaker有CLOSED、OPEN、HALF_OPEN三种状态我们自定义的熔断器也复用这套思想但实现可以更轻量。public class BizMetricBreaker { public enum State { CLOSED, OPEN, HALF_OPEN } private final AtomicReferenceState state new AtomicReference(State.CLOSED); private volatile long openStartTime 0L; private volatile long lastProbeTime 0L; private final int minRequestAmount; private final long stateWindowMs; public boolean tryPass() { State current state.get(); if (current State.CLOSED) { return true; } if (current State.OPEN) { if (System.currentTimeMillis() - openStartTime stateWindowMs) { // 时间窗口到了进入半开放一个探测请求 state.compareAndSet(State.OPEN, State.HALF_OPEN); lastProbeTime System.currentTimeMillis(); return true; } return false; } // HALF_OPEN状态只放行一个探测请求其他请求直接拦截 return System.currentTimeMillis() - lastProbeTime 0 ? false : false; } }状态机最核心的逻辑是OPEN状态下经过stateWindowMs时间后自动进入HALF_OPEN放行少量探测流量如果探测请求的指标恢复正常则关闭熔断如果仍然异常则重新打开并重置计时。这块代码大家可以根据自己的业务场景细化但状态转移的思路一定要对上。3.4 自定义Slot把指标和熔断串起来有了规则、采集器、状态机剩下的就是Slot本身。Slot的entry方法里我们先查规则再看熔断状态然后采集指标判断是否需要打开熔断。public class BizMetricSlot extends AbstractLinkedProcessorSlotDefaultNode { Override public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable { BizMetricRule rule BizMetricRuleManager.getRule(resourceWrapper.getName()); if (rule ! null rule.isEnabled()) { BizMetricBreaker breaker BizMetricBreakerManager.getBreaker(resourceWrapper.getName(), rule); // 先看熔断是否已打开 if (!breaker.tryPass()) { throw new BizMetricBlockException(resourceWrapper.getName(), rule.getBizMetricType()); } // 采集业务指标 double value BizMetricCollectorManager.collect(resourceWrapper.getName(), rule, context, args); breaker.record(value, rule); if (breaker.shouldOpen()) { throw new BizMetricBlockException(resourceWrapper.getName(), rule.getBizMetricType()); } } fireEntry(context, resourceWrapper, node, count, prioritized, args); } Override public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) { fireExit(context, resourceWrapper, count, args); } }这里需要特别说明tryPass和shouldOpen的配合逻辑tryPass处理的是已经打开后的拦截shouldOpen处理的是当前请求触发了打开条件。两者都用状态机的公开方法职责清晰不会出现重复计数的问题。自定义的BlockException必须继承com.alibaba.csp.sentinel.slots.block.BlockExceptionpublic class BizMetricBlockException extends BlockException { public BizMetricBlockException(String resource, String bizMetricType) { super(String.format(biz metric blocked, resource%s, metricType%s, resource, bizMetricType)); } }有了这个异常类后续在使用Spring Cloud Alibaba的BlockExceptionHandler统一处理时就能通过instanceof精确识别业务指标熔断返回不同格式的提示信息。4. 把自定义Slot挂进Sentinel的两种方式4.1 自定义SlotChainBuilder并覆盖默认编排Sentinel构建责任链的入口是SlotChainProvider它通过SPI加载SlotChainBuilder。默认实现是DefaultSlotChainBuilder它会按固定顺序构建那一串Slot。我们要做的就是写一个自定义Builder复刻默认顺序并在中间插入BizMetricSlot。public class CustomSlotChainBuilder implements SlotChainBuilder { Override public ProcessorSlotChain build() { ProcessorSlotChain chain new DefaultProcessorSlotChain(); chain.addLast(new NodeSelectorSlot()); chain.addLast(new ClusterBuilderSlot()); chain.addLast(new LogSlot()); chain.addLast(new StatisticSlot()); chain.addLast(new AuthoritySlot()); chain.addLast(new SystemSlot()); chain.addLast(new FlowSlot()); // 插入自定义业务指标熔断Slot chain.addLast(new BizMetricSlot()); chain.addLast(new DegradeSlot()); chain.addLast(new ParamFlowSlot()); return chain; } }注意这里的顺序必须和当前Sentinel版本源码保持一致。我在1.8.6上测试过这个顺序是准确的但如果你用的是其他版本建议先打开DefaultSlotChainBuilder源码核对一遍不要想当然。4.2 SPI配置细节与版本兼容说明定义好Builder之后需要在resources目录下新建SPI文件META-INF/services/com.alibaba.csp.sentinel.slotchain.SlotChainBuilder文件内容就一行com.example.sentinel.extension.CustomSlotChainBuilder这里有一个必须提醒的坑Sentinel框架自身也有一个DefaultSlotChainBuilder的SPI声明如果你的应用classpath下有两个SlotChainBuilder实现SPI加载时可能会冲突。稳妥的做法是在JVM启动参数里显式指定-Dcsp.sentinel.spi.slotchain.builder.classcom.example.sentinel.extension.CustomSlotChainBuilder这样Sentinel就会优先使用指定的Builder避开SPI冲突问题。如果你的项目用的是Spring Boot还可以考虑用环境变量CSP_SENTINEL_SPI_SLOTCHAIN_BUILDER_CLASS来指定效果一样。这个参数在新老版本里都有效是我们目前最推荐的注册方式。5. 结合Nacos实现业务指标规则的动态下发5.1 依赖与基础配置前面提到过规则不能写死在代码里。我们团队已经用Nacos管理Sentinel限流和降级规则自定义熔断规则也走同一套体系。先加依赖dependency groupIdcom.alibaba.csp/groupId artifactIdsentinel-datasource-nacos/artifactId version1.8.6/version /dependency普通限流和降级规则可以直接通过Spring Cloud Alibaba的配置项接入Nacos但自定义规则不在RuleType枚举内所以更通用的做法是用NacosDataSource直接注册。这样既摆脱了对Spring Cloud配置项的依赖也方便在非Spring项目里复用。String serverAddr 127.0.0.1:8848; String groupId SENTINEL_GROUP; String dataId sentinel-biz-metric-rules; ReadableDataSourceString, ListBizMetricRule ds new NacosDataSource(serverAddr, groupId, dataId, source - JSON.parseArray(source, BizMetricRule.class)); BizMetricRuleManager.register2Property(ds.getProperty());register2Property方法内部要处理属性的监听逻辑规则一变就调用loadRules重新加载。这块代码不复杂但要注意加载过程中不能出现短暂的规则为空否则熔断器会全部失效。5.2 自定义规则JSON样例在Nacos配置中心里dataId为sentinel-biz-metric-rules、group为SENTINEL_GROUP的配置内容如下[ { resource: order:create, bizMetricType: PAY_FAIL_RATE, threshold: 5, windowIntervalMs: 60000, minRequestAmount: 20, stateWindowMs: 30000, enabled: true }, { resource: stock:deduct, bizMetricType: STOCK_DEPTH, threshold: 10, windowIntervalMs: 30000, minRequestAmount: 5, stateWindowMs: 15000, enabled: true } ]第一条规则表示order:create资源在最近60秒内请求数不少于20次的前提下如果支付失败率超过5%熔断30秒。第二条规则表示stock:deduct资源在库存水位低于10时熔断15秒。这里minRequestAmount是用来防止冷启动误判的。流量很小时一两次失败就可能把失败率推到很高的数值如果没有最小请求数的保护规则会产生大量误报。这个参数强烈建议大家都配上。5.3 限流规则与熔断规则共用Nacos数据源很多团队最开始接触SentinelNacos通常是从限流配置开始的。顺手给一个标准限流规则样例这些规则也可以放在同一个groupId下的另一个dataId中结构和刚才自定义熔断规则完全一致[ { resource: order:create, grade: 1, count: 200, limitApp: default, strategy: 0, controlBehavior: 0 } ]grade为1代表按QPS限流count为200代表每秒最大200个请求strategy为0代表直接拒绝controlBehavior为0代表快速失败。项目的落地经验是限流规则放一个dataId熔断规则放一个dataId自定义熔断规则单独放一个dataId三者互不干扰出错时也更容易定位。Nacos的GroupId可以都统一用SENTINEL_GROUP业务上按dataId区分即可。6. 落地过程中的压测表现与踩坑清单6.1 压测时暴露的问题第一轮压测我们就暴露了问题。最开始我把指标采集器设计成每次请求都从Redis读取库存水位结果QPS一上到500整个链路的RT从5毫秒涨到了接近80毫秒。原因很清晰Slot的entry方法在热点路径上Redis的RTT虽然只有1到2毫秒但并发放大后连接池很快就成了瓶颈。后来改成用Caffeine本地缓存异步刷新库存水位30毫秒同步一次RT才回到正常水平。另一个压测表现不理想的问题是半开状态的处理。最初的设计里HALF_OPEN状态只放行一个请求如果这个请求恰好碰到业务指标的瞬时波动熔断会反复打开关闭造成振荡。我们后面调整为半开状态持续2秒在这2秒内放行最多5%的流量如果这5%流量的业务指标整体恢复正常才关闭熔断。振荡问题才彻底消失。6.2 必须避开的六个坑第一个坑是线程池下的ThreadLocal丢失。指标采集时经常要用到渠道、租户ID这些业务上下文我们一开始存在ThreadLocal里结果异步线程池执行任务时上下文全丢了。解决方式是引入TransmittableThreadLocal并在线程池提交任务时做值传递。第二个坑是Slot插入顺序。我们第一版把BizMetricSlot插在链尾结果和预期完全相反业务熔断永远轮不到执行。费了很大劲排查才意识到是顺序问题。方案上线前一定要打印一次SlotChain的完整顺序确认BizMetricSlot的位置。第三个坑是采集器必须独立于业务调用链。不要把业务指标的计算逻辑挂在被保护资源的内部否则指标采集本身会成为被保护的一部分一旦采集器慢整个业务就慢了。采集器应该是旁路逻辑数据来源是预聚合的本地状态或独立线程刷新的缓存。第四个坑是规则热更新时要保证原子性。BizMetricRuleManager.loadRules方法直接用新Map替换旧Map看起来是原子操作但如果有请求正在读取旧Map规则切换的瞬间可能出现前后不一致。解决方案是把规则对象也设计成不可变类型每次更新替换整个对象引用。第五个坑是自定义BlockException容易漏接。如果项目里没有配置BlockExceptionHandlerBizMetricBlockException会直接向上抛最终被框架包装成SentinelBlockException或者直接落到业务代码的catch块里导致返回结果不一致。我们在入口处统一配置了WebCallbackManager.setBlockHandler根据异常类型做差异化响应。第六个坑是压测时业务指标采集的冷启动。服务刚启动时本地缓存里没有历史数据第一次采集到的失败率可能为0也可能因为少量失败请求瞬间飙高。minRequestAmount此时是最好的保护建议值设在20到50之间具体根据业务流量规模调整。6.3 我最后的调参心得生产环境上线这套自定义熔断后我们用了两周时间观察指标分布再回填阈值。以支付失败率为例正常业务高峰期的失败率大约在0.8%到1.5%之间抖动我们最终把阈值定在5%给足了两到三倍的安全空间。库存水位熔断则定得比较保守阈值10代表还能支撑约10个并发订单低于这个水位才触发熔断。这里有一个个人建议自定义熔断的阈值和持续时间不要一开始就按理想值配置。先在指标采集端多埋几个观测点让数据跑几天看到真实分布后再确定阈值比任何经验公式都靠谱。熔断持续时间也不宜过短15到30秒是相对合理的区间太短会导致下游刚恢复又来一波流量形成来回颠簸。我个人在实际操作中的体会是业务指标熔断这套东西真正难的其实不是Slot怎么写而是指标定义与业务语义的校准。技术指标有标准定义业务指标没有。同一个“支付失败率”不同团队统计口径可能完全不同。所以落地之前最值得花时间的不是代码而是指标口径的确认。另外再分享一个小技巧业务指标采集器一定要设计成可插拔的后续接新业务时只需要新增一个Collector实现然后在规则里配bizMetricType就行不需要改动Slot本身的逻辑。这套架构我们用了大半年接入了支付失败率、库存水位、优惠券池余量、对账积压量四类指标稳定性和可维护性都经受住了验证。