先讲一个我最近刚帮人定位的线上事故,过程相当典型。业务团队给下单接口加了@SentinelResource(value = "createOrder", fallback = "createOrderFallback"),控制台限流规则也配得好好的,可一旦触发限流,用户拿到的却不是代码里写的降级文案,而是那一句让人头皮发麻的Blocked by Sentinel (flow limiting)。日志里还有一条fallback method cannot be found的警告。
当时组里几个人的第一反应都是“规则没生效”,还有人怀疑是不是控制台推送有问题。忙活了一个多小时,最后发现问题跟规则一点关系都没有——纯粹是fallback和blockHandler这两个参数没搞明白,签名写得不对,导致兜底方法压根没被反射到。
这个场景我在不同团队里反复见过。很多人把fallback和blockHandler当成同一个东西的两个别名,接线时随便挑一个写上,结果线上行为跟预期相距十万八千里。这篇文章就把它们之间的差异、签名匹配规则、外部处理类用法和真实排障过程一次说透。
1. 两个异常来源:限流和业务失败从来不是一回事
要理解fallback和blockHandler,先得建立一个最底层的认知:一个被@SentinelResource保护的资源方法,在运行期可能抛出的异常分成两大类,它们来自完全不同的阶段,处理方式也随之分道扬镳。
1.1 业务代码自身抛出的异常
第一类异常出自方法体内部。比如下单逻辑里发现库存不足,抛出一个StockNotEnoughException;或者调用下游服务超时,抛出一个TimeoutException。这类异常跟 Sentinel 存在与否没有任何关系——即使你没引入这个框架,代码该抛还是会抛。
它发生在“方法已经进入了执行阶段”之后,是业务逻辑自身的失败。正常情况下这类异常要么由局部 try-catch 消化,要么向上抛给全局异常处理器。
1.2 Sentinel 规则触发后抛出的 BlockException
第二类异常来自方法入口处的外部拦截。当请求触发流控规则、熔断降级规则、热点参数规则或系统保护规则时,Sentinel 根本不会让你走进业务方法体,直接在入口就把调用拦下来,抛出一个BlockException的子类。
这个动作发生在 sentinel-core 内部,跟你方法里的库存、超时、数据库连接统统无关。它只表达一件事:本次调用被规则挡在门外,请走另一条路。
两类异常的来源完全不同,所以@SentinelResource才设计了两个独立的兜底出口。blockHandler专门接第二类,fallback主要接第一类。这也是名称里block(阻塞)和fallback(回落)的来历。很多文章喜欢把两套 handler 混着讲,根子就在这里没捋直。
2. 触发逻辑差异:who 在前面拦截,谁在后面兜底
当两个参数都配置齐全时,整个触发逻辑可以用一句话概括:blockHandler管“哨兵不让进”,fallback管“业务自己出问题”。下面用一个完整例子把行为拆开。
2.1 blockHandler 的典型配置与触发场景
@SentinelResource( value = "createOrder", blockHandler = "createOrderBlockHandler" ) public Order createOrder(Long userId, Long skuId, int count) { // 业务方法体 return doCreateOrder(userId, skuId, count); } public Order createOrderBlockHandler(Long userId, Long skuId, int count, BlockException ex) { log.warn("[block] resource=createOrder, ruleType={}", ex.getClass().getSimpleName()); return Order.fallbackResult("当前访问量过大,请稍后重试", null); }注意第 2 个参数类型BlockException,这是 Sentinel 拦截异常的父类。它下面常见子类包括:
| 子类 | 触发场景 |
|---|---|
FlowException | 并发线程数或 QPS 超过流控规则阈值 |
DegradeException | 熔断降级规则生效,服务进入降级状态 |
ParamFlowException | 热点参数限流规则命中 |
SystemBlockException | 系统自适应保护触发 |
AuthorityException | 授权规则拦截,黑白名单未通过 |
当并发瞬间冲上来,方法体还没被执行,blockHandler就被调用了。所以它内部不要写任何“依赖业务结果”的补偿逻辑,只做一件事最好:输出一个固定的降级响应,然后把ex.getClass().getSimpleName()打进日志。线上一旦出现并发尖峰,看到FlowException就是流控在起作用,不需要翻业务代码。
2.2 fallback 的业务异常主战场
再看fallback的常见写法:
@SentinelResource( value = "createOrder", fallback = "createOrderFallback" ) public Order createOrder(Long userId, Long skuId, int count) { int stock = stockService.getStock(skuId); if (stock < count) { throw new StockNotEnoughException("库存不足"); } return doCreateOrder(userId, skuId, count); } public Order createOrderFallback(Long userId, Long skuId, int count, Throwable ex) { log.warn("createOrder failed, skuId={}", skuId, ex); return Order.fallbackResult("下单失败,请稍后重试", null); }注意末尾参数是Throwable,范围比BlockException大得多。方法是真正执行到一半,库存、锁、外部调用抛出任何异常,都会被它接住。这也是很多人只写fallback写顺手了的原因——它覆盖面广,连限流异常都能接(细节见下文)。
2.3 只配置 fallback 时,BlockException 会被谁接住
这是最容易产生误解的一个行为。只说结论:当只配置fallback而没有blockHandler时,Sentinel 在捕获到BlockException后,如果发现对应的blockHandler不存在,会退而检查有没有fallback,有的话一样交给 fallback 处理。
所以很多人的日志里,fallback 方法捕获到的异常类型是FlowException,他们因此得出“fallback 和 blockHandler 是同一个东西”的错误结论。实际上这只是 Sentinel 为了不让你在生产裸奔而做的一层容错。从业务侧看,效果确实是兜住了;但从职责划分看,fallback 的首要定位依然是业务异常,blockHandler 缺失时的兜底行为属于“退路”。
这也反过来解释了为什么 fallback 的最后一个参数必须写Throwable——因为你不知道走到 fallback 的到底是StockNotEnoughException还是FlowException,只有Throwable能保证全部接得住。
2.4 两个都配置时的实测优先级
还有人问,是不是配置了两个之后,Sentinel 按顺序先试blockHandler,再试fallback?准确地说,它的判断逻辑跟顺序有关,但本质是按异常类型分流:
- 入口被拦,抛
BlockException→ 优先使用blockHandler - 方法体内部抛业务异常 → 使用
fallback - 入口被拦时没有
blockHandler→ 使用fallback - 方法体抛异常时没有
fallback→ 原样向上抛
那“blockHandler 优先于 fallback”这种说法对吗?在同时配置的前提下,对BlockException而言 blockHandler 确实优先;但对普通业务异常而言,压根不会进入 blockHandler 的判断。所以更准确的说法是:两套 handler 服务不同的异常分支,各有各的入口。
3. 反射匹配:签名规则决定兜底能不能找到
@SentinelResource的底层是基于切面对资源方法的包装。运行期通过反射调用你的 handler 方法,它怎么知道该调用哪个方法?靠的就是一套机械、严格的签名匹配规则。签名不满足,编译不会报错,运行期才给你脸色看。
3.1 方法名同名,参数列表必须满足“原参数 + 异常参数”
对blockHandler和fallback来说,Sentinel 在查找同名方法时按下面顺序试探:
- 找“原方法参数列表 + 一个异常参数”的同名方法,异常参数类型分别要求是
BlockException或Throwable; - 找不到时,找“参数个数和原方法完全一致”的同名方法。
比如原方法是:
public Order createOrder(Long userId, Long skuId, int count)那么 fallback 可以写成:
public Order createOrderFallback(Long userId, Long skuId, int count, Throwable ex)也可以写成:
public Order createOrderFallback(Long userId, Long skuId, int count)但不能写成:
public Order createOrderFallback(Throwable ex, Long userId, Long skuId, int count)异常参数必须在整个参数列表的最后一个位置。这个限制很机械,却让排查变得很容易:把两个方法签名并排贴出来对比,一眼就能确认。
3.2 返回类型必须与原方法保持一致
接下来是最容易被忽略的硬性要求:返回类型。原方法返回Order,fallback和blockHandler就必须返回Order。返回String、返回void、返回一个八竿子打不着的类型,都可能被 Sentinel 判定为方法不可用,然后默默退回默认阻塞响应。
这个设计在业务上是合理的:外层调用方声明的返回类型已经写死,降级响应也必须能直接向上兼容。如果降级类型和正常返回类型不一致,反序列化层会先崩溃,降级就没有意义。
顺带一提,如果确实想提供一种“无参版本的兜底方法”,Sentinel 也支持defaultFallback。它用于:没有匹配到具体 fallback/blockHandler 时,提供一个不依赖原方法参数、只返回固定降级结果的兜底。签名可以写成无参数,也可以写成(Throwable ex)形式。
3.3 BlockException 形参越具体,越容易踩空
一个资源可能同时被多种规则保护。流控可能触发FlowException,熔断可能触发DegradeException。如果你把blockHandler末尾参数写成:
public Order createOrderBlockHandler(Long userId, Long skuId, int count, FlowException ex)那么只有触发流控时反射才能正确找到它;一旦熔断触发,实际抛出的DegradeException跟形参不匹配,方法查找失败,降级失效。
所以blockHandler的最后一个参数我建议一律写成BlockException。想在方法体里区分具体规则类型,再用ex instanceof FlowException这类判断,不要在形参上过度收窄。
4. 拆出去:fallbackClass 与 blockHandlerClass 的正确玩法
当服务类里挂了十几二十个被保护方法,每个方法旁边都跟着两个兜底方法,类的体积立马膨胀。这时候就该用fallbackClass和blockHandlerClass把降级逻辑挪到独立的类里。
4.1 独立类改造的完整示例
@SentinelResource( value = "createOrder", fallbackClass = OrderFallbackHandler.class, fallback = "createOrderFallback", blockHandlerClass = OrderBlockHandler.class, blockHandler = "createOrderBlockHandler" ) public Order createOrder(Long userId, Long skuId, int count) { return doCreateOrder(userId, skuId, count); }独立的兜底处理类长这样:
public final class OrderFallbackHandler { private OrderFallbackHandler() { } public static Order createOrderFallback(Long userId, Long skuId, int count, Throwable ex) { log.warn("createOrder fallback, skuId={}", skuId, ex); return Order.fallbackResult("下单失败,请稍后重试", null); } }第一点要注意的是:一旦指定了fallbackClass或blockHandlerClass,Sentinel 只会到对应类里找同名静态方法。方法如果不是 static,运行期直接报方法找不到。所以铁律第一条:独立类里的 handler 必须是静态方法。
4.2 static 方法不能注入 Spring Bean,怎么解决
这是拆出去之后最常踩的坑。静态方法天然脱离 Spring 容器,你在里面用@Autowired或@Resource注入的 Bean,基本拿不到。不少人把降级方法挪到独立类后,想在里面访问 Redis 查缓存数据,结果一触发就空指针。
为了解决“静态降级方法里也要访问业务组件”的问题,我见过几种解法:
- 方式一:在应用启动阶段,用一个配置类把 Bean 塞进静态字段。
- 方式二:把兜底类注册为 Spring 组件,但降级方法保持静态,静态字段在初始化时赋值。
- 方式三:降级逻辑尽量只依赖入参,内部不发远程调用、不查数据库。
我个人的建议是,降级方法保持“纯函数”状态,只依赖入参,返回固定结构的降级响应。原因很简单:降级响应讲究确定性,同样入参,无论什么时刻触发,结果都应该一致。如果降级逻辑里还去查数据库、调远程服务,万一这些组件同样被限流或宕机,你的降级响应还能不能构造出来都得打个问号。
4.3 兜底类命名与代码组织建议
兜底类和方法名最好跟资源名保持强相关,比如OrderFallbackHandler.createOrderFallback,而不是CommonFallback.fallback1。线上要查日志时,进入OrderFallbackHandler一眼就能看出是哪个业务资源出了问题。几十个方法里 grep 一个通用 fallback 名字,体验会非常糟糕。
5. 真实踩坑复盘:从警告日志到根因定位
理论讲再多,不如把实际出问题的链路完整走一遍。我选取三个典型故障场景,按排查顺序写出来,希望能帮你少走这些弯路。
5.1 限流触发了,但降级响应是默认的 Sentinel 文案
现场表现:接口被限流,日志出现大量Blocked by Sentinel (flow limiting)默认提示,用户看到的是英文阻塞文案,没有走到业务自定义降级。
我的排查链路:
- 先去 Sentinel 控制台确认流控规则确实命中,资源名、阈值都对得上;
- 回应用日志,搜
fallback method和blockHandler method关键词,果然有类似blockHandler method cannot be found的警告; - 顺着警告去对比方法签名,把原方法和 handler 方法并排贴出来;
- 最终发现:
blockHandler末尾参数写成了具体的ParamFlowException,而规则触发时实际抛出的异常类型是FlowException,形参匹配不上。
修复方式:把末尾参数统一收敛为BlockException,并在方法内做instanceof区分。修复后本地压测复现,限流响应恢复正常。
5.2 只配 fallback,业务异常和限流异常都进来了
现场表现:团队为了省代码,所有资源只配fallback,不配blockHandler。结果某个接口被限流后,降级方法日志里出现FlowException,新人认为是“fallback 也处理限流,没问题”,但后来发现另一个资源里的 fallback 参数类型写的是业务自定义异常StockNotEnoughException,限流触发的FlowException就匹配不上了,降级依旧失效。
我的排查链路:
- 先确认控制台限流规则确实命中;
- 看日志确认 fallback 有没有被调用——没被调用,说明根本匹配不到;
- 翻 fallback 签名,发现末尾参数是
StockNotEnoughException,直接把FlowException挡在门外; - 修复为
Throwable后,两类异常都能进入 fallback。
结论:只要 fallback 有可能接住BlockException(即没配 blockHandler 时),末尾参数就只能写Throwable,没有商量余地。
5.3 降级方法自身爆了,导致异常链升级
现场表现:限流规则触发,blockHandler 被调用,但用户在接口层拿到的不是降级响应,而是一个NullPointerException。
我的排查链路:
- 第一反应是降级没有生效,但日志里明明有 blockHandler 的执行痕迹;
- 顺着堆栈往下看,发现 NPE 发生在 blockHandler 方法内部,因为它内部访问了一个未初始化的静态 Bean;
- 调用方拿到的其实是“降级方法自己的异常”,而不是 Sentinel 抛出的
FlowException。
修复方式:把 blockHandler 内部对静态 Bean 的依赖全部移除,改为只返回固定 JSON 结构。同时我在规范里加了一条:兜底方法内部必须保证不自爆。降级逻辑尽量短、尽量纯,任何可能抛异常的分支都要在方法内 try-catch 掉。
6. 落地方案:把降级策略做得可观测、可维护
工具层面的区别讲完了,最后聊聊方法论。@SentinelResource只是给方法挂上规则触发的入口,真正决定降级好不好用的,是你在这两个 handler 里到底写了什么。
6.1 降级返回必须能区分“为什么降级”
return null式的降级我强烈反对。调用方拿到 null 时,你根本分不清是业务异常降级、流控降级还是代码 bug。降级方法内部必须把触发原因记录下来。
public static Order blockOrderFallback(Long userId, Long skuId, int count, BlockException ex) { log.warn("[sentinel-block] resource=createOrder, ruleType={}, message={}", ex.getClass().getSimpleName(), ex.getMessage()); return Order.fallbackResult("当前访问量过大,请稍后再试", null); }日志里带上规则类型,监控平台再配一条告警规则,一旦FlowException或DegradeException出现频率突增,就能自动提醒值班同学。这一步看着简单,生产中能帮你节省大量定位时间。
6.2 统一降级响应模型
团队内部最好有一个统一降级响应:
code:固定值,如 50001 表示熔断降级,50002 表示流控降级,50003 表示业务异常降级;message:面向用户的文案;data:保持和正常返回一致的结构。
这样前端只需要处理code != 0的分支,后端新增降级逻辑时也复用同一个响应构造器,不会出现每套代码各写一种降级格式的情况。
6.3 读接口与写接口的降级策略要分开
最后是一个经常被忽视的场景:写操作。下单、扣款、发券这类接口如果降级返回“失败”,本地状态可能已经发生部分变更,比读接口降级危险得多。
我的做法是:读接口可以放心配置 fallback/blockHandler;写接口的降级里不能自己做补偿逻辑,宁可让异常抛出去,由全局基础设施去处理对账和补偿。因为 fallback 的本质是快速失败,你在降级方法里没法可靠区分“还没开始写”和“写了一半”。硬要在降级里做补偿,大概率会引入脏数据。
回到最初的问题,fallback和blockHandler到底怎么选?我的建议是:默认两个都配。blockHandler管限流熔断,fallback管业务异常,各自签名严格按规范来。如果实在嫌方法多,至少配一个fallback,确保末尾参数是Throwable,这样两类异常都能兜住。代码膨胀后就用fallbackClass和blockHandlerClass拆出去,记住静态方法、不依赖注入两条铁律。
最后再分享一个我坚持了很久的小习惯:任何降级逻辑,不要只靠线上观察验证。写一条带单测的固化路径,本地模拟抛出FlowException和业务异常,分别断言 handler 被正确调用、返回类型正确、日志字段齐全。把这条验证前置到开发阶段,@SentinelResource才不会成为线上事故的盲区。