1. 先问对问题:熔断降级代码到底散落在哪几个位置
半夜被告警叫起来,排查一圈发现根子不在业务逻辑,而在一个fallback方法上——下游库存服务超时,订单服务走了兜底,兜底里return null,上层没做判空,一堆 NPE 顺着调用链往上炸,最后整条下单链路全红。那次事故之后,我把项目里所有熔断降级相关的类翻了个底朝天,才发现真正的问题不是代码写得烂,而是这些类散落得到处都是:有的在common里,有的在api里,有的在业务服务的impl包下,还有的干脆复制粘贴了三份。这就是“微服务中的服务熔断与降级类该写在哪个模块”这个问题的现实来源,它不是一道架构考试题,而是一个会直接决定故障半径的工程问题。
先把话说清楚:这篇内容讨论的是在多模块微服务工程里,熔断、降级、限流、超时这类稳定性治理代码应该落在哪个 Maven/Gradle 模块、哪个包路径下,以及背后的依赖方向理由。它面向的是已经跑起了几个微服务、模块已经拆开、正在被“公共代码到底能不能往 common 里塞”折磨的开发者和技术负责人。如果你还在单体阶段,或者项目只有一个src/main/java,这些内容可以先存着,等拆模块那天再回来看。
我见过太多团队在这件事上的处理方式是“谁先写谁决定”,结果半年后common模块变成了一个谁也说不清里面有什么的杂物间,改一行代码要重新发布八个服务,谁都不敢动。所以我打算从代码资产的分类讲起,把三种主流放法摊开对比,再给一套可以直接抄的模块骨架和实操步骤,最后把踩过的坑整理成速查表。
1.1 一个真实的复制粘贴现场
先说个具体场景。某电商系统拆成了订单、库存、支付、用户四个服务,订单服务要调库存和支付。最初的写法很朴素,订单服务里维护了两个客户端类:StockClient和PayClient,各自带一个fallback。后来用户服务也要调库存做商品详情展示,于是把StockClient从订单服务复制了一份过去,fallback也一起复制。再后来支付服务自己也要查库存做风控校验,又复制一次。
三份StockClient的接口定义是同一份,但fallback的实现各不相同:订单服务的兜底是“返回锁定的库存快照”,用户服务的兜底是“返回暂无库存”,风控场景的兜底是“跳过校验放行”。这个差异本身是合理的——兜底逻辑天然是消费方的业务决策,不是提供方的能力。但问题在于,三个服务各自的StockClient接口签名已经开始漂移:有的加了traceId参数,有的改了返回类型,有的把超时时间写死在注解里。等到库存服务真的做了一次接口升级,三个消费方要改的地方完全不一样,改漏一个就是线上事故。
这个案例给出的第一个结论是:接口契约要收拢,兜底实现要下沉到消费方自己手里。这两件事必须分开放,而绝大多数“熔断降级类放哪”的争论,本质上是把这两件事混成了一件。
1.2 把“熔断降级类”拆成四类代码资产
笼统说“熔断降级类”是没法做架构决策的,因为它在代码里至少对应四类完全不同的东西,归属原则也完全不同。
| 类别 | 典型形态 | 归属原则 | 不该放的地方 |
|---|---|---|---|
| 契约类 | Feign 接口、DTO、错误码枚举 | 谁提供谁定义,作为 SDK 发布 | 消费方的业务模块 |
| 兜底类 | fallback/fallbackFactory实现、默认返回值构造 | 谁调用谁负责 | 提供方的 api 模块、全局 common |
| 规则类 | 熔断阈值、慢调用 RT、限流 QPS 配置 | 外置到配置中心,代码里只留加载与监听 | 硬编码在任意 Java 类里 |
| 基础设施类 | 规则加载器、统一异常转换、上下文透传、指标上报 | 与业务无关的纯技术组件,可入 common(要克制) | 具体业务服务的领域层 |
这张表是我这些年反复用的分类法,它能解决 80% 的争论。比如有人问“StockClientFallbackFactory能不能放common”,你按表一查:它是兜底类,兜底类跟着调用方走,所以不能放全局 common——除非这个兜底逻辑真的对所有调用方都完全一致,且不掺任何业务语义(这种情况极少,一般只有“返回空列表”这种)。
再比如有人问“熔断规则的阈值配置文件放哪”,它是规则类,答案是不放在任何 Java 模块里,扔配置中心,代码里只留一个监听刷新规则的组件,这个组件属于基础设施类,可以进 common。
注意:判断一个类能不能进公共模块,标准只有一个——它是否同时满足“与具体业务语义无关”和“所有使用方实现完全一致”。只要有一条不满足,就不要进 common。这条标准看起来严苛,但它能为半年后的你省下大量返工。
1.3 为什么“放哪”会变成一个真问题
在单体应用里,类放哪个包基本靠 IDE 自动补全,没人较真。到了微服务多模块工程,模块位置直接绑定三件事:编译依赖、发布节奏、故障半径。
编译依赖好理解,A 模块依赖 B 模块,B 改了 A 就要重新编译。如果所有服务的兜底类都塞在common里,那common就被所有服务依赖,任何一次改动都会触发全量重新构建和全量回归。
发布节奏更隐蔽。common通常作为内部二方包发布,版本号一升,下游服务要么跟着升(承担回归成本),要么锁着旧版本(承担版本分裂成本)。我见过一个团队的common一年发了两百多个版本,原因就是里面塞了太多“顺手放进来”的东西,包括好几个服务的fallback实现。
故障半径是最要命的。兜底逻辑写错会直接放大故障,如果这个兜底逻辑在公共模块里被多个服务共用,那它错了就是多个服务同时错。前面提到的return null事故,如果只在一个服务的兜底里,影响面是局部;如果这个兜底在 common 里,那就是全局雪崩。
所以“熔断降级类放哪个模块”这个问题的正经答案,不是一句“放 common”或者“放各自服务”,而是要先建立一套按职责分层的归属规则,然后让模块结构去承载这套规则。下面就把三种主流放法摊开对比。
2. 三种主流放法的对比与取舍
我在不同团队见过三种典型的放法,每一种都有它的合理性,也都有明确的失效场景。这里不做“哪个最好”的空泛判断,而是把每种方案的边界条件讲清楚,你可以对着自己项目的现状去匹配。
2.1 方案A:全部塞进公共 common 模块
这是最省事、也是最快出问题的做法。新建一个xxx-common模块,里面放统一返回体、工具类、常量、异常基类,然后有人顺手把OrderClientFallback也放进去了,理由是“反正好几个服务都用”。
短期内它确实解决了重复代码的问题,但代价是:
第一,common开始反向依赖业务 SDK。OrderClientFallback要返回订单对象,就得依赖订单服务的 DTO,于是 common 依赖了order-api。等哪天order-api又依赖了 common(因为要用统一返回体),循环依赖就出来了。Maven 会直接报错,于是有人用provided或者排除依赖的方式绕过去,构建能过,但模块边界已经彻底烂了。
第二,兜底逻辑被迫“取最大公约数”。三个服务对库存降级的处理本来不一样,放进 common 后只能写一个通用版本,最后变成“返回空对象”,然后每个调用方在使用处再判空。判空逻辑散落各处,等于绕了一圈又回到了原点,还多背了一个公共模块的负担。
第三,也是最难排查的:common升级带来连锁反应。改一个 fallback 里的日志级别,全公司服务都要跟着升级验证。
我的判断是:方案 A 在只有两三个服务、团队三五个人、并且common严格只放技术无关代码的前提下勉强能用,但它没有扩展性。一旦服务数量上到两位数,这个方案必然失控。如果你现在的common已经出现了“Fallback”字样的类,我建议尽早迁出去,越晚成本越高。
2.2 方案B:跟着 Feign 客户端走,放 API 契约模块
这个方案的支持者通常是这么想的:既然OrderClient定义在order-api里,那它的fallback自然也放这儿,配套齐全,调用方直接引依赖就能用,多方便。
方便是真方便,但方向错了。order-api是订单服务对外发布的契约,它的定位是“我提供什么能力”。而fallback表达的是“你调我失败时你打算怎么办”,这是消费者的决策,凭什么叫提供方替我决定?
举个具体例子。订单服务发布order-api时带了一个OrderClientFallback,实现是“查询失败返回空列表”。这个默认值对商品详情页可能合适,但对账务对账任务就是灾难——对账任务拿不到数据会认为“这个用户没订单”,直接跳过,最后账对不平。账务团队只能不用这个默认 fallback,自己再写一个,于是order-api里的那个成了死代码,还白白增加了发布体积。
还有一个现实问题:order-api里放 fallback,就意味着order-api要依赖熔断组件的注解和fallbackFactory接口。这会让一个本该只有 DTO 和接口定义的纯契约模块,绑死在某一个具体的熔断实现上。将来想从一种组件换到另一种,所有服务的api包都要跟着改。
不过方案 B 有一个变体是值得采用的:在order-api里提供一个“最小可用兜底”作为默认实现,但不作为唯一选项。比如提供一个OrderClientFallbackDefault,明确标注“仅用于非关键路径,关键业务请自行实现”。这属于“给个方便,不强加约束”,和把 fallback 硬编码进契约是两回事。区别在于,前者是可选的、可覆盖的,后者是绑定的。
2.3 方案C:收进各服务自己的基础设施层
这是我最终采用的方案,也是我认为在服务数量超过五个之后唯一站得住的做法。核心思路是:契约在提供方的 api 模块,兜底在消费方的基础设施层,规则在配置中心,通用技术组件才进 common。
具体到代码位置,消费方服务里会有一个专门放外部适配的包,比如:
order-service └── order-infrastructure └── src/main/java/.../infrastructure/gateway/ ├── StockGateway.java // 对库存服务的调用封装 ├── StockGatewayFallback.java // 兜底实现,带业务语义 └── StockGatewayConfig.java // 熔断资源绑定与参数StockGateway内部持有一个order-api里的StockClient,或者干脆自己用 HTTP 客户端调。兜底类StockGatewayFallback就在同一层,它知道“订单场景下库存不可用时应该返回什么”——这是纯业务决策,放在基础设施层恰到好处,因为它既不污染领域层,也不要求被别的服务复用。
这个方案的好处是故障半径可控:兜底错了,只影响这一个服务的这一条链路。发布节奏也独立:改兜底不用动任何公共包。缺点是会有重复——用户服务也要写自己的库存兜底。但这个“重复”是语义上的重复,不是代码上的重复,两份兜底本来逻辑就不同,强行合并才是错的。
2.4 三种方案的对比与我的选型结论
| 维度 | 方案A:全局 common | 方案B:api 契约模块 | 方案C:消费方基础设施层 |
|---|---|---|---|
| 代码复用 | 高(但常是伪复用) | 中 | 低(有意为之) |
| 依赖方向 | 易形成环 | 契约模块被污染 | 干净,单向 |
| 故障半径 | 全局 | 所有使用该 api 的服务 | 单个服务 |
| 发布独立性 | 差 | 中 | 好 |
| 业务语义表达 | 被迫取公约数 | 由提供方臆断 | 由消费方精确表达 |
| 适用规模 | 3 个服务以内 | 契约稳定的成熟期 | 5 个服务以上 |
我的结论是分层组合使用:规则配置外置,不落在任何 Java 模块;兜底实现下沉到消费方基础设施层;契约放提供方 api 模块且不携带强制兜底;只有与业务无关的技术组件(规则加载、异常转换、上下文透传、指标上报)才进 common。
这个组合看起来比“全塞 common”麻烦,但麻烦的部分是一次性的结构成本,换来的是长期的独立演进能力。下一节讲怎么用依赖方向把这个结构固化下来。
3. 依赖方向决定一切:模块分层与包结构落地
模块划分的争论,剥到最里层其实是依赖方向之争。谁的代码依赖谁,决定了谁能改、谁要跟着改、改了会波及谁。把依赖方向定死,模块归属自然就没有争议了。
3.1 依赖倒置:为什么兜底实现不能反过来依赖业务
先讲一个最容易踩的坑。很多项目的初始结构是这样的:领域层定义业务逻辑,基础设施层实现数据库访问和远程调用,然后领域层为了“方便”直接调用了基础设施层的网关类。依赖方向是 领域 → 基础设施,看起来能跑,但只要基础设施层里混进了兜底逻辑,问题就来了——兜底里往往要读领域对象、要用领域服务算默认值,于是基础设施又反过来依赖领域,环就形成了。
正确的做法是依赖倒置:领域层定义端口接口(我需要对库存做什么),基础设施层实现这个端口(我用什么方式、什么兜底去调库存)。依赖方向永远是 基础设施 → 领域,兜底逻辑作为端口的实现细节,天然待在基础设施层,永远不会反过来污染领域。
用生活化的类比:领域层是“仓库管理员”,他只管说“我要 100 件货”;基础设施层是“运输队”,它负责开车去取,路上堵了怎么办、车坏了怎么办,都是运输队自己的事。运输队不会跑到仓库里指挥管理员怎么记账,这就是依赖方向。
落到包结构上,我常用的是四层:
order-service ├── order-domain // 领域模型、领域服务、端口接口定义 ├── order-application // 用例编排、事务边界、DTO 转换 ├── order-infrastructure // 端口实现、外部调用、兜底、持久化 └── order-adapter // HTTP 入口、消息消费入口、入站限流兜底类全部落在order-infrastructure下的gateway或client包,规则加载器落在order-infrastructure下的resilience包。order-adapter里只放入站侧的限流和参数校验,不放任何出站调用的兜底。
3.2 一套可以直接抄的模块骨架
给出具体的 Maven 模块结构和依赖关系,可以直接对照改造。
parent ├── shared-common // 纯技术组件:统一返回体、异常基类、工具、规则加载器抽象 ├── shared-contract-stock // 库存服务契约:StockClient 接口、DTO、错误码 ├── shared-contract-pay // 支付服务契约:PayClient 接口、DTO、错误码 ├── order-service │ ├── order-domain │ ├── order-application │ ├── order-infrastructure │ └── order-adapter ├── stock-service │ └── ... └── pay-service └── ...依赖关系必须严格单向:
<!-- order-infrastructure 的 pom 片段 --> <dependencies> <!-- 依赖自己的领域层,方向正确 --> <dependency> <groupId>com.example</groupId> <artifactId>order-domain</artifactId> </dependency> <!-- 依赖契约,只拿接口和 DTO,不拿兜底 --> <dependency> <groupId>com.example</groupId> <artifactId>shared-contract-stock</artifactId> </dependency> <!-- 技术组件 --> <dependency> <groupId>com.example</groupId> <artifactId>shared-common</artifactId> </dependency> </dependencies>这里有个关键点:shared-contract-stock的 pom 里不允许出现对order-infrastructure或任何业务服务的依赖。它只声明接口,接口的方法签名用 DTO 表达,不引入 Feign 之外的东西。这样契约模块的发布可以非常克制,接口不变就不发版。
而shared-common里必须守住一条底线:不允许出现任何以业务名词命名的类。一旦发现Order、Stock、Pay这些词出现在 common 的类名里,说明它已经开始越界了。
3.3 包结构约定与命名规范
命名规范不是形式主义,它是让新人一眼看出“这个类该放哪”的最低成本手段。我在项目里推行的是这套:
| 位置 | 包路径 | 类名后缀 | 职责 |
|---|---|---|---|
| 契约 | contract.stock | Client、Request、Response | 只定义签名和数据结构 |
| 端口 | domain.port | Gateway、Repository | 领域层声明的对外能力需求 |
| 出站实现 | infrastructure.gateway | GatewayImpl | 编排调用、错误转换 |
| 兜底 | infrastructure.gateway.fallback | XxxFallback | 业务降级决策 |
| 规则 | infrastructure.resilience | XxxRuleLoader、XxxRuleListener | 规则加载与刷新 |
| 入站保护 | adapter.web.interceptor | XxxLimitInterceptor | 入站限流、参数校验 |
这套命名有个好处:看到XxxFallback就知道它一定在infrastructure下,看到XxxGatewayImpl就知道它旁边应该有个fallback包。新人不需要问人,按后缀就能定位。
注意:如果你发现某个
XxxFallback需要被另一个服务复用,正确的处理不是把它提到 common,而是先问一句——两个服务的降级语义真的一样吗?答案几乎总是“不一样”。如果确实一样,那说明这两个服务可能本就是一个服务的两个部署单元,值得重新审视拆分粒度。
4. 实操:从零搭一套熔断降级分层
理论讲完,进入能动手的部分。这一节用一个“订单服务调库存服务”的完整例子,把契约、网关、兜底、规则四层代码写出来,每一步都说明为什么这么写。
4.1 契约模块只放接口和数据结构
契约模块的使命是“让调用方知道怎么调”,不掺任何实现。写法上尽量朴素:
// shared-contract-stock / contract/stock/StockClient.java public interface StockClient { /** * 查询可售库存。调用方需自行处理熔断与降级。 */ StockQueryResponse queryAvailable(StockQueryRequest request); /** * 锁定库存。属于写操作,禁止调用方自行降级为“成功”。 */ StockLockResponse lock(StockLockRequest request); }注意注释里那句“调用方需自行处理熔断与降级”。这不是废话,它是契约层面的显式约定——它告诉所有使用方,这个接口不提供默认兜底,别指望复制个类就完事。写操作(锁定库存)额外标注“禁止降级为成功”,这是把风险提示写进契约,比写在文档里靠谱得多。
契约模块的 pom 只依赖必要的 HTTP 客户端抽象,不依赖任何特定熔断组件。这样即便将来整个技术栈换掉,契约模块一行不用改。
4.2 消费方网关层:编排调用与错误转换
网关层是基础设施层里唯一直接接触契约的地方,它的职责是把“远程调用”包装成“领域能理解的结果”,并且把各种异常收敛成有限的几种类型。
// order-infrastructure / infrastructure/gateway/StockGatewayImpl.java @Component @RequiredArgsConstructor public class StockGatewayImpl implements StockGateway { private final StockClient stockClient; @Override public AvailableStock queryAvailable(String skuId) { try { StockQueryResponse resp = stockClient.queryAvailable( StockQueryRequest.builder().skuId(skuId).build()); if (!resp.isSuccess()) { throw new RemoteBizException(resp.getCode(), resp.getMessage()); } return new AvailableStock(resp.getSkuId(), resp.getQuantity()); } catch (RemoteBizException e) { throw e; // 业务异常,交给兜底处理 } catch (Exception e) { throw new RemoteInvokeException("库存查询失败", e); // 技术异常,统一收敛 } } }这里的关键设计是异常分类:业务异常(比如商品不存在)和技术异常(超时、连接拒绝、熔断打开)要分开抛。因为这两类在兜底时的处理方式完全不同——业务异常通常应该原样透传给用户,技术异常才应该走降级返回默认值。如果不分类,兜底里就得分一堆if判断异常类型,非常难维护。
4.3 兜底实现:业务降级决策落在这里
兜底类是这一整套结构里唯一允许出现业务语义的地方,所以它必须精确表达“这个场景下降级成什么”。
// order-infrastructure / infrastructure/gateway/fallback/StockGatewayFallback.java @Component @Slf4j public class StockGatewayFallback implements StockGateway { private final StockSnapshotCache snapshotCache; @Override public AvailableStock queryAvailable(String skuId) { // 降级策略:优先读本地快照缓存,没有则返回“不建议下单”的保守值 Optional<AvailableStock> cached = snapshotCache.get(skuId); if (cached.isPresent()) { log.warn("库存服务不可用,使用快照兜底, skuId={}", skuId); return cached.get(); } log.warn("库存服务不可用且无快照, skuId={}, 返回保守值", skuId); return AvailableStock.unavailable(skuId); } }这段代码有三个细节值得说。
第一,兜底不是“返回 null”或“返回 0”,而是返回一个有明确语义的对象unavailable。上层拿到这个对象时,它的状态是明确的,不会因为判空遗漏炸掉。前面那起事故的根因就是兜底返回 null。
第二,兜底里读了本地缓存。这在很多团队是争议点——降级逻辑里能不能有 IO?我的看法是可以有,但必须是有界的本地读。比如 Caffeine 这类进程内缓存,读取耗时可忽略,不会因为降级逻辑本身变慢而拖垮主链路。绝对不能接受的是在兜底里再发一次远程调用,那等于把一次超时变成两次。
第三,日志用warn而不是error。降级是预期内的保护行为,不是系统故障,用error会污染监控告警,让真正的错误被淹没。但日志里必须带业务标识(这里是skuId),否则事后无法定位影响范围。
写操作(锁定库存)我不提供兜底实现。这是刻意的:库存锁定失败时,正确做法是让订单创建失败并提示用户重试,而不是“假装锁成功了”。写操作的降级必须是“快速失败”,不能是“返回成功”。这个判断必须在架构层面就定死,不能交给开发者在代码里自由发挥。
4.4 规则与阈值:怎么算出来的,不是拍脑袋定的
规则类不落在 Java 模块里,但阈值怎么算必须说清楚,因为瞎定阈值比不设阈值更危险。
以慢调用比例熔断为例,核心参数有三个:慢调用 RT 阈值、统计时长、最小请求数。计算逻辑是这样:
先看线上真实分布。假设通过监控拿到库存查询接口的 P50 是 12ms、P90 是 45ms、P99 是 120ms。RT 阈值怎么定?我的经验公式是P99 的 1.2 到 1.5 倍,这里取 150ms。理由是这个值足够宽松,正常波动不会误触发;又足够严格,一旦下游真的劣化,能及时切断。
再看统计窗口。假设这个接口在高峰期的 QPS 约 200,那么 10 秒窗口内大约有 2000 次调用,样本量足够。如果 QPS 只有 5,10 秒才 50 次调用,比例计算会非常不稳定,这时应该把窗口拉长到 60 秒,或者干脆改用异常数阈值。
最小请求数怎么定?它的作用是防止低峰期样本太少导致误熔断。经验值是统计窗口内预期请求数的 1% 到 5%,且不低于 10。上面这个场景,2000 次调用的 1% 是 20,所以最小请求数设 20 是合理的。
慢调用比例阈值本身,我一般从 0.5 起步,观察一周再调整。设太低(比如 0.1)会导致频繁误熔断,设太高(比如 0.9)则反应迟钝,等熔断打开时下游早就被打垮了。
| 参数 | 取值 | 计算依据 |
|---|---|---|
| 慢调用 RT 阈值 | 150ms | 线上 P99(120ms)× 1.25 |
| 统计时长 | 10s | 高峰期窗口内请求数约 2000,样本充足 |
| 最小请求数 | 20 | 窗口请求数的 1%,且 ≥ 10 |
| 慢调用比例阈值 | 0.5 | 起步值,后续按误熔断率调优 |
| 熔断时长 | 10s | 与下游平均恢复时间对齐,不宜过长 |
| 半开探测请求数 | 5 | 少量放行,避免恢复瞬间打垮下游 |
这些值写成 YAML 放在配置中心,服务启动时加载,改动时动态刷新:
resilience: stock-query: slow-call-rt-ms: 150 stat-window-seconds: 10 min-request-amount: 20 slow-call-ratio-threshold: 0.5 circuit-open-seconds: 10 half-open-probe-count: 5 stock-lock: # 写操作不熔断降级,只设超时快速失败 timeout-ms: 800stock-lock只配超时不配熔断,这个差异是刻意的,和 4.3 里“写操作不给兜底”的决定呼应。规则配置和代码策略在同一个层面保持一致,运维看配置就知道业务意图,不用去翻代码。
规则加载器本身是技术组件,放 common 里没问题:
// shared-common / resilience/RuleLoader.java public interface RuleLoader<T> { T load(String key); void addListener(String key, Consumer<T> onChange); }注意它是个接口,具体实现(从哪个配置中心读)放在各服务自己的infrastructure/resilience下。这样 common 只定义“怎么用规则”,不绑定“规则从哪来”。
5. 踩坑记录与常见问题速查
结构定好之后,落地过程里真正折磨人的是各种“明明配了却不生效”。这一节把我在多个项目里踩过的坑整理出来,按发生频率排序。
5.1 兜底不生效的六种典型原因
第一种,方法签名对不上。兜底方法的参数列表必须和原方法完全一致,返回类型也必须一致。如果兜底类实现的是同一个接口,这条自然满足;但很多人图省事写成同类的另一个方法,这时签名差一个参数就静默失效,不报错,只是永远走不到。我建议优先用“实现同一接口”的写法,编译期就能卡住。
第二种,异常类型不匹配。熔断组件默认只对特定异常触发兜底,业务异常通常被排除在外(这是对的)。如果代码里把所有异常包成了业务异常,兜底就永远不触发,表现是“熔断规则明明打开了,但请求还是全部失败”。排查方法是打日志看实际抛出的异常类型,别猜。
第三种,兜底类和原实现被注册成了同一个 Bean。某些写法下两者都标注了组件注解,容器里出现两个同类型 Bean,注入时按名称匹配到了错误的那个。典型症状是“兜底逻辑一次都没执行过”。解决办法是给兜底类显式指定 Bean 名称,或者在注入处用限定符。
第四种,资源名写错。熔断规则绑定的是资源名,代码里注解的资源名和配置里的 key 必须完全一致,包括大小写和分隔符。这类错误几乎无法从代码审查发现,只能靠启动时校验——我一般会在规则加载器里加一条校验,配置里出现但代码里不存在的资源名,启动直接失败。
第五种,规则没刷新。配置中心里改了阈值,服务没感知,因为监听器没注册或者注册在了错误的 Bean 生命周期上。表现是“改了配置要重启才生效”。这个坑排查起来最耗时,因为代码看起来完全正确,问题出在刷新时机。
第六种,兜底里又抛了异常。兜底方法自己出错,异常会向上抛,表现和没兜底一样,但日志里能看到兜底方法栈。最常见的原因是兜底里读了缓存但缓存未初始化,直接 NPE。
注意:兜底方法内部必须做到“绝不抛异常”。我的做法是在兜底方法最外层包一层 try-catch,任何异常都吞掉并返回最保守的默认值,同时打一条 error 日志。听起来粗暴,但兜底是最后一道防线,它挂了就没有下一层了。
5.2 循环依赖与 Bean 冲突
这两个问题通常成对出现。循环依赖的典型形态是:StockGatewayImpl依赖StockGatewayFallback,而StockGatewayFallback又实现了StockGateway,Spring 在判断注入类型时可能陷入歧义。
处理办法有两个。一是不要让兜底类实现网关接口,而是让兜底类作为普通组件被网关持有,网关内部捕获异常后显式调用兜底方法。这样类型上不冲突,但代价是要手写 try-catch,且无法直接用注解式熔断。
二是用限定符明确区分,同时确保网关实现类不反向依赖兜底类。我倾向第二种,因为注解式熔断的侵入性更小,规则调整不需要改代码。但必须严格保证:网关实现只依赖契约,兜底只依赖网关端口接口,两者互不直接引用。
还有一种隐蔽的冲突来源是多个服务共用同一个契约模块,而契约模块里如果带了默认兜底类(前面讨论的方案 B 变体),消费方又自定义了兜底,两者会撞名。解决方式很简单:默认兜底的类名加上Default后缀,且不标注组件注解,由消费方显式引用。
5.3 降级逻辑里的雪崩陷阱
这是最危险的一类问题,因为它往往在上线很久之后才暴露。
陷阱一:降级里调用另一个远程服务。库存查不到,兜底去调商品服务补数据,结果商品服务也挂了,一次故障变成两次。前文提过,兜底里只允许有界本地操作。
陷阱二:降级返回的值触发了重试风暴。兜底返回“库存不足”,上层逻辑认为可以重试,于是疯狂重试,把已经熔断的下游又打了一遍。解决方式是兜底结果必须携带“不可重试”标记,或者干脆在网关层就把重试关掉。
陷阱三:降级路径没有容量评估。降级逻辑通常比正常路径轻,但不等于零成本。如果它要读缓存、要序列化、要打日志,在高并发下依然是负担。上线前应该专门压测降级路径,确认它能在全量流量下撑住。
陷阱四:半开状态放行过多。熔断进入半开时如果放行请求太多,会瞬间把刚恢复的下游再次打垮,进入“熔断-恢复-再熔断”的循环。半开探测请求数要小,一般 3 到 5 个足够,且探测间隔要有梯度退避。
5.4 问题速查表
| 现象 | 最可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 兜底从未执行 | 异常类型不匹配 / 资源名不一致 | 打印实际异常类型,核对资源名 | 调整异常分类,统一资源名常量 |
| 改了阈值不生效 | 监听器未注册 | 查看规则加载日志 | 在 Bean 初始化阶段注册监听 |
| 兜底执行但结果异常 | 缓存未初始化 | 查看兜底方法栈 | 兜底外层加 try-catch |
| 频繁误熔断 | 最小请求数太小 / RT 阈值太紧 | 拉取监控看请求量分布 | 调大最小请求数,放宽 RT |
| 熔断后反复震荡 | 半开探测请求过多 | 观察半开期间流量 | 减少探测数,加退避 |
| 启动报 Bean 冲突 | 兜底与实现同类型 | 看容器启动日志 | 加限定符或改实现方式 |
| 降级后下游仍被打垮 | 降级里发起远程调用 | 检查兜底方法依赖 | 移除远程调用,改本地缓存 |
6. 几条被反复验证过的经验
结构、代码、排查都讲完了,最后说几条偏“手感”的经验,这些内容在文档里基本找不到,但实际项目里特别管用。
降级的结果一定要有明确的业务语义。null、空对象、0、false这些值看起来省事,但它们把“为什么是这个值”的解释责任推给了上层。我现在的做法是每个降级结果都带一个来源标记,比如AvailableStock.unavailable(skuId)内部会标记source=FALLBACK,上层日志和监控能直接看到这条数据是降级来的。这个标记在上线初期几乎没用,但出故障时是定位问题的关键线索。
兜底类的单元测试比业务类更重要。业务逻辑的测试写不写,最多影响质量;兜底逻辑的测试不写,故障时就是盲盒。我要求每个兜底类至少覆盖三个用例:下游超时、下游返回业务错误、缓存为空。这三个用例覆盖了绝大多数降级路径,写完基本能放心。
规则配置要做版本管理和灰度。阈值调整不是改个数字那么简单,它直接影响线上行为。我的习惯是把规则配置纳入版本控制,每次调整记录调整原因和观察期数据,新增规则先在单个实例上灰度,观察半小时再全量。这套流程听起来重,但它把“拍脑袋调参”变成了可回溯的决策。
团队层面必须约定“谁能往 common 里加东西”。技术上再怎么设计,最终还是要靠人守。我在项目里定的规则是:往 common 加类需要两个人评审,其中一个必须是模块 owner;类名里出现业务名词直接打回。规则不复杂,但坚持半年之后,common 的体积基本稳定了,不再变成杂物间。
别过早追求统一。刚开始拆微服务时,看到几个服务各写各的兜底会觉得“重复代码好多”,忍不住想合并。我的建议是忍一忍。等三五个服务的降级逻辑都跑过一轮真实故障之后,再回头看哪些是真重复。实践下来,真正能合并的比例不到两成,剩下的都是“看起来一样、语义不同”,合并了反而埋雷。
最后分享一个小技巧。我在每个服务的infrastructure/resilience包里放一个ResilienceInventory类,用代码把所有熔断资源名、兜底类、规则 key 列成一张静态清单,启动时打印成一行日志。这行日志的价值在于:当你半夜排查问题时,不需要翻代码确认“到底哪些调用配了熔断”,一眼就能看到全貌。这个类本身不参与任何业务逻辑,纯粹是给运维和自己看的索引,成本极低,收益极高。