Featured image of post Spring Cloud 微服务之间如何避免循环依赖

Spring Cloud 微服务之间如何避免循环依赖

服务 A 调 B、B 调 A——这种循环不只是难看,运行起来真出问题。本文从架构识别到工程治理讲清避免姿势

一个隐性而致命的问题

微服务团队成长到一定规模后,几乎都会遇到服务之间的循环依赖

1
订单服务 → 用户服务 → 订单服务

朴素的解决思路:“只是调用关系而已,能跑就行”——但这种循环会带来一系列问题:

  • 启动顺序混乱: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
订单服务(主)→ 用户服务 ✓ 正常依赖

下一个版本,“用户主页要展示最近订单”:

1
用户服务(主)→ 订单服务  ❌ 反向依赖来了

业务一推动就改,没人检查依赖图——循环就此诞生


三、识别:怎么发现循环

1. 静态扫描

整理所有服务间的 OpenFeign / RestTemplate 调用:

1
grep -r "@FeignClient" --include="*.java"

输出每个服务依赖哪些服务,画个有向图——用 Graphviz 或简单的脚本。

2. 链路追踪

SkyWalking / Zipkin / Jaeger 在生产里聚合调用拓扑——自动看到 A 调 B、B 调 A

3. 服务注册中心

Nacos / Consul 看每个服务的调用方列表——能看到反向依赖。

4. 在 CI 加检查

1
2
3
# 每次 PR 检查依赖图,新增反向调用直接 fail
- name: Check service dependencies
  run: ./scripts/check-deps.sh

把检查变成机制——比每次 review 时人工排查靠谱。


四、解决方案

方案 1:合并服务(最直接)

如果 A 和 B 长期互相调用——它们可能就该是一个服务。微服务不是越细越好——业务边界不清的两个服务合并回去是合理的。

判断标准:

  • 数据强一致需求
  • 改动通常一起来
  • 链路上 80% 都是双向调用

方案 2:抽出共享层

A 和 B 都依赖某段共享逻辑/数据——抽到 C:

1
2
重构前:A ↔ B
重构后:A → C ← B

例如"用户基础信息"抽到 user-base 服务——A 和 B 都查 C,互不依赖。

方案 3:事件驱动 + 数据冗余

最优雅但工程量大——A 改了数据后发事件,B 订阅事件维护自己一份冗余

B 不再实时调 A 取数据——读自己的本地冗余。代价是数据有微小延迟,但循环彻底消除

方案 4:依赖反转

A 调 B 是为了"通知 B 一件事"——改成 B 主动监听:

1
2
重构前:A → B(通知 B 用户状态变化)
重构后:A 发事件 → 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
2
之前:B 循环 1000 次调 A
之后:B 一次性发 1000 个 ID 给 A

调用次数大幅降低 → 即使保留依赖,影响也小。


五、典型场景的具体解法

场景 1:订单展示要用户头像,用户主页要展示订单数

重构前重构后
订单服务 ↔ 用户服务(互相调用)BFF 聚合:BFF → 订单服务、BFF → 用户服务
订单服务里冗余存 user_name + avatar_url

场景 2:积分服务通过事件加积分,但要查订单金额

重构前重构后
订单 → 发事件 → 积分 → RPC 查订单订单发事件时事件载荷里就带金额

场景 3:风控决策要查多个上下文

重构前重构后
风控服务 ↔ 订单 ↔ 用户 ↔ 风控调用方主动把上下文打包传给风控

六、架构层面的纪律

1. 严格的"上下游"层级

把服务分层——只允许上层调下层,下层永不调上层

任何"下层调上层"PR 直接 reject。

2. 共享数据归属唯一

每个数据的"主人"只有一个服务——其他服务要用,要么调主人,要么订阅事件维护冗余

3. 增量加 PR 时强制依赖检查

1
2
3
# 检查是否引入新的 cross-tier 依赖
- name: Check architecture
  run: archunit-test

ArchUnit 这种工具能在测试里强制架构规则——机制保证大于人为约束

4. 定期审视依赖图

每个迭代结束 review 一次依赖图——发现"上次没有的反向依赖"立刻处理。


七、Spring Cloud 特有的注意点

1. OpenFeign 自调本服务的隐患

服务内部代码注入指向"本服务名"的 FeignClient:

1
2
3
4
5
6
7
8
@FeignClient(name = "user-service")
public interface UserClient { ... }

// 用户服务里
@Service
class UserService {
    @Autowired private UserClient userClient;   // 指向本服务
}

这种用法不会立刻报错——Feign 通过注册中心负载均衡转发,请求会落到本服务的某个实例(可能是自己也可能是同名其他实例)。但它是个反模式:

  • 普通自调虽不立刻失败,但会绕一跳网络、多一段 trace、丢失本地事务上下文
  • 若调用链最终回到调用方所在的同一个接口(A 通过 LB 调到 A 自己,又走相同方法),就会形成无限递归 → 栈溢出 / 连接池耗尽
  • 调用链路在 trace 里突然多一段,排查困难

结论:本服务的内部能力直接走 Bean 调用,不要通过 FeignClient 绕一圈。要复用接口契约可以把 Client 接口和 Controller 实现拆出 SPI 模块共享。

2. Eureka / Nacos 服务发现的循环

A 启动时连不上 B,B 启动时连不上 A——注册中心会等到双方都活着才允许调用。但如果配置了"启动期健康检查",就会双方互等。

解决:启动期检查只用基础设施(DB、Redis),不要互相检查。

3. 配置中心的循环

A 服务启动时要从配置中心拉 B 的地址——但配置中心要先注册到 A 才能正常工作?这种设计是错的。配置中心应该是最底层无依赖的服务


八、一份"循环依赖"治理清单

发现循环后按这个流程处理:

  1. 明确循环类型——直接 / 间接 / 软 / 启动期
  2. 画出当前依赖图
  3. 判断业务边界——是不是该合并服务?
  4. 如果不合并——选方案:抽共享层 / 事件 + 冗余 / 依赖反转 / BFF
  5. 设计冗余数据的同步机制——保证最终一致
  6. 测试覆盖——确保循环消除后业务不退化
  7. 加 ArchUnit 检查防止回退

九、一些反例

反例 1:为了"性能"双向冗余

A 存 B 的数据、B 存 A 的数据——两边都要同步对方的变化。最终变成"两个真相"——数据不一致。冗余应该单向

反例 2:用 MQ 假装解耦

1
2
A → MQ → B
B → MQ → A

虽然走 MQ 不直接调用,但本质还是循环——只是把同步等待换成异步等待。问题没解决。

反例 3:用注释"承认循环"

1
2
// TODO: 这里是循环依赖,暂时无法避免
@Autowired private UserClient userClient;

写注释承认问题等于不解决——技术债越堆越多。循环依赖应作为架构债务专项跟踪,原则上不允许长期存在


小结

把全文压一句:

服务间循环依赖不是『可以容忍的小毛病』——它是架构腐化的早期信号,会慢慢腐蚀整个系统。发现一个解决一个。

工程纪律:

  1. 服务分层,禁止上下层逆向调
  2. 数据归属唯一,跨域用事件 + 冗余
  3. 共享逻辑下沉到独立服务
  4. 页面聚合走 BFF
  5. CI 自动检查,不靠人工
  6. 循环依赖出现立刻处理,不容忍

把这套做对,微服务才能真正"独立、可演进、可扩展"——而不是变成"分布式单体"。

使用 Hugo 构建
主题 StackJimmy 设计