一个隐性而致命的问题
微服务团队成长到一定规模后,几乎都会遇到服务之间的循环依赖:
| |
朴素的解决思路:“只是调用关系而已,能跑就行”——但这种循环会带来一系列问题:
- 启动顺序混乱:A 启动时要连 B 的健康检查,B 启动时要连 A 的——谁先启动?
- 故障爆炸式扩散:A 挂 → B 挂 → A 挂——单点故障变成集群故障
- 测试灾难:要起 A 必须先起 B、起 B 必须先起 A
- 架构腐化:循环让"职责边界"完全消失——任何修改都要全链条考虑
- 死锁/雪崩风险:链路超时、回调嵌套、连接池耗尽
- 重构寸步难行:想拆分服务时所有循环都拦着
循环依赖不是技术问题,是架构问题。本文从识别、分析、解决三个层面讲清楚。
一、循环依赖的几种典型形态
1. 直接循环(A ↔ B)
最常见的——双方都要对方的数据。
2. 间接循环(A → B → C → A)
链路一长就难发现——往往是新增功能时引入的。
3. 通过事件的"软循环"
虽然是异步,但积分服务回头查订单服务——本质上还是循环。
4. 启动期循环
A 启动时要从 B 拉某个全局配置,B 启动时也要从 A 拉某个配置——启动期死锁。
二、为什么循环依赖会出现
业务视角看是"一不小心引入"——但本质原因通常是:
1. 实体边界没分清
订单和用户都是核心实体——从用户视角"查我的订单"、从订单视角"查谁下的单"——两个视角看起来都合理,于是双向都建了 RPC。
2. 共享数据没有归属
“用户最近一笔订单”——这个数据算用户属性还是订单属性?没人 say 清楚归谁,最后就两边都查。
3. 微服务过细
把"用户登录"和"用户资料"拆成两个服务——结果它们成天互相调用。过度拆分是循环的温床。
4. 业务驱动反向调用
最常见的——“产品要订单详情页面也展示用户头像”:
| |
下一个版本,“用户主页要展示最近订单”:
| |
业务一推动就改,没人检查依赖图——循环就此诞生。
三、识别:怎么发现循环
1. 静态扫描
整理所有服务间的 OpenFeign / RestTemplate 调用:
| |
输出每个服务依赖哪些服务,画个有向图——用 Graphviz 或简单的脚本。
2. 链路追踪
SkyWalking / Zipkin / Jaeger 在生产里聚合调用拓扑——自动看到 A 调 B、B 调 A。
3. 服务注册中心
Nacos / Consul 看每个服务的调用方列表——能看到反向依赖。
4. 在 CI 加检查
| |
把检查变成机制——比每次 review 时人工排查靠谱。
四、解决方案
方案 1:合并服务(最直接)
如果 A 和 B 长期互相调用——它们可能就该是一个服务。微服务不是越细越好——业务边界不清的两个服务合并回去是合理的。
判断标准:
- 数据强一致需求
- 改动通常一起来
- 链路上 80% 都是双向调用
方案 2:抽出共享层
A 和 B 都依赖某段共享逻辑/数据——抽到 C:
| |
例如"用户基础信息"抽到 user-base 服务——A 和 B 都查 C,互不依赖。
方案 3:事件驱动 + 数据冗余
最优雅但工程量大——A 改了数据后发事件,B 订阅事件维护自己一份冗余:
B 不再实时调 A 取数据——读自己的本地冗余。代价是数据有微小延迟,但循环彻底消除。
方案 4:依赖反转
A 调 B 是为了"通知 B 一件事"——改成 B 主动监听:
| |
依赖方向倒过来——A 不再依赖 B。
方案 5:API Gateway / BFF 聚合
页面需要 A + B 的数据——不应该让 B 调 A,应该让 BFF(Backend For Frontend)调 A 和 B 聚合:
A 和 B 的依赖被 BFF 拆开——它们之间不再相互依赖。
方案 6:批量查询代替单次
如果 B 调 A 是为了"批量查 1000 个用户的订单"——让 A 提供批量接口,B 拿到 ID 列表后批量请求一次:
| |
调用次数大幅降低 → 即使保留依赖,影响也小。
五、典型场景的具体解法
场景 1:订单展示要用户头像,用户主页要展示订单数
| 重构前 | 重构后 |
|---|---|
| 订单服务 ↔ 用户服务(互相调用) | BFF 聚合:BFF → 订单服务、BFF → 用户服务 |
| 订单服务里冗余存 user_name + avatar_url |
场景 2:积分服务通过事件加积分,但要查订单金额
| 重构前 | 重构后 |
|---|---|
| 订单 → 发事件 → 积分 → RPC 查订单 | 订单发事件时事件载荷里就带金额 |
场景 3:风控决策要查多个上下文
| 重构前 | 重构后 |
|---|---|
| 风控服务 ↔ 订单 ↔ 用户 ↔ 风控 | 调用方主动把上下文打包传给风控 |
六、架构层面的纪律
1. 严格的"上下游"层级
把服务分层——只允许上层调下层,下层永不调上层:
任何"下层调上层"PR 直接 reject。
2. 共享数据归属唯一
每个数据的"主人"只有一个服务——其他服务要用,要么调主人,要么订阅事件维护冗余。
3. 增量加 PR 时强制依赖检查
| |
ArchUnit 这种工具能在测试里强制架构规则——机制保证大于人为约束。
4. 定期审视依赖图
每个迭代结束 review 一次依赖图——发现"上次没有的反向依赖"立刻处理。
七、Spring Cloud 特有的注意点
1. OpenFeign 自调本服务的隐患
服务内部代码注入指向"本服务名"的 FeignClient:
| |
这种用法不会立刻报错——Feign 通过注册中心负载均衡转发,请求会落到本服务的某个实例(可能是自己也可能是同名其他实例)。但它是个反模式:
- 普通自调虽不立刻失败,但会绕一跳网络、多一段 trace、丢失本地事务上下文
- 若调用链最终回到调用方所在的同一个接口(A 通过 LB 调到 A 自己,又走相同方法),就会形成无限递归 → 栈溢出 / 连接池耗尽
- 调用链路在 trace 里突然多一段,排查困难
结论:本服务的内部能力直接走 Bean 调用,不要通过 FeignClient 绕一圈。要复用接口契约可以把 Client 接口和 Controller 实现拆出 SPI 模块共享。
2. Eureka / Nacos 服务发现的循环
A 启动时连不上 B,B 启动时连不上 A——注册中心会等到双方都活着才允许调用。但如果配置了"启动期健康检查",就会双方互等。
解决:启动期检查只用基础设施(DB、Redis),不要互相检查。
3. 配置中心的循环
A 服务启动时要从配置中心拉 B 的地址——但配置中心要先注册到 A 才能正常工作?这种设计是错的。配置中心应该是最底层无依赖的服务。
八、一份"循环依赖"治理清单
发现循环后按这个流程处理:
- 明确循环类型——直接 / 间接 / 软 / 启动期
- 画出当前依赖图
- 判断业务边界——是不是该合并服务?
- 如果不合并——选方案:抽共享层 / 事件 + 冗余 / 依赖反转 / BFF
- 设计冗余数据的同步机制——保证最终一致
- 测试覆盖——确保循环消除后业务不退化
- 加 ArchUnit 检查防止回退
九、一些反例
反例 1:为了"性能"双向冗余
A 存 B 的数据、B 存 A 的数据——两边都要同步对方的变化。最终变成"两个真相"——数据不一致。冗余应该单向。
反例 2:用 MQ 假装解耦
| |
虽然走 MQ 不直接调用,但本质还是循环——只是把同步等待换成异步等待。问题没解决。
反例 3:用注释"承认循环"
| |
写注释承认问题等于不解决——技术债越堆越多。循环依赖应作为架构债务专项跟踪,原则上不允许长期存在。
小结
把全文压一句:
服务间循环依赖不是『可以容忍的小毛病』——它是架构腐化的早期信号,会慢慢腐蚀整个系统。发现一个解决一个。
工程纪律:
- 服务分层,禁止上下层逆向调
- 数据归属唯一,跨域用事件 + 冗余
- 共享逻辑下沉到独立服务
- 页面聚合走 BFF
- CI 自动检查,不靠人工
- 循环依赖出现立刻处理,不容忍
把这套做对,微服务才能真正"独立、可演进、可扩展"——而不是变成"分布式单体"。