尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

异环破解指南:从循环依赖到配置环的排查与修复实践

异环破解指南:从循环依赖到配置环的排查与修复实践 在服务端开发圈里摸爬滚打久了看到“【异环】关于我在异世界捡到青梅竹马这件事”这个标题第一反应确实很像某部轻小说或者二次元企划的名字。但如果你是一名正在接手遗留系统的后端工程师大概率会从“异环”两个字里品出另一层味道——循环依赖、配置环、模块之间的环形引用。这类问题很像“在异世界捡到青梅竹马”代码还是那套代码功能看起来也正常但当你真正接手、准备扩展或升级时才发现它和周边模块之间的依赖关系早已绕成了一个剪不断理还乱的环。本文不聊剧情只聊技术结合一个可复现的多模块 Spring Boot 项目把“异环”从现象、原理到排查、修复完整拆解一遍。哪怕你平时没有接触过这种诡异项目读完也能掌握一套识别依赖环、解开配置环的实用方法。1. 背景与核心概念1.1 什么是循环依赖循环依赖也叫环形依赖可以发生在不同的代码层级上。最常见的理解是对象层面的循环依赖A 对象在创建时需要注入 B 对象B 对象在创建时又需要注入 A 对象。代码写出来就是互相 new、互相注入但本质上运行时会形成一个永远无法先完成初始化的闭环。模块层面的循环依赖也很常见。你用 Maven 或 Gradle 维护多模块工程时模块 A 的 pom.xml 里声明了依赖模块 B而模块 B 的 pom.xml 又声明了依赖模块 A。编译器在某些场景下能勉强通过但到打包、发布、启动时问题会集中爆发。配置层面的循环依赖则更容易被忽视。配置中心里的配置项 A 引用配置项 B配置项 B 又引用配置项 A或者两个命名空间互相引用。这种环不一定会让应用启动失败但会造成配置解析顺序不稳定、取值结果因启动顺序不同而变化的问题。1.2 什么是配置环配置环是“异环”在配置领域的具体表现。现代项目普遍使用 Apollo、Nacos、Spring Cloud Config 这类配置中心配置之间可以用${other.key}的方式互相引用。如果 A 配置引用 BB 配置又引用 A配置中心在解析时就会遇到一个无法收敛的循环。这类问题比代码循环依赖更隐蔽。代码循环依赖通常在应用启动时就会报错逻辑清楚、堆栈明确配置环往往只在特定环境、特定顺序下才暴露甚至表现为“这次启动正常下次启动报错”“本地正常生产环境偶尔异常”的随机性问题。1.3 为什么“捡到青梅竹马”会变成事故现场把标题里的“捡到”换成开发场景就是“接手一个陌生项目”。你在新环境里发现了一套很久没人动的老代码它像一个失散多年的老朋友功能还在性格却让人捉摸不透。你准备在上面加一个模块、升级一个框架版本或者只是把日志框架统一一下结果牵一发而动全身。这就是“异世界”的残酷之处异世界有异世界的物理规则老项目有老项目的依赖规则。如果一开始不把环识别出来你加的任何新代码都会在这个环里打转。等你终于理清 A 依赖 B、B 依赖 C、C 又依赖 A 时通常已经消耗了大量时间。本文要做的就是教你在动手改代码之前先把这些环画出来、找出来、拆掉。2. 环境准备与版本说明2.1 本文示例环境示例环境没有特别冷门的要求常见配置即可操作系统Windows 10/11、macOS 或 Linux 均可JDK8 或 11 以上构建工具Maven 3.6或 Gradle 6.8框架Spring Boot 2.7.x 或 3.x配置中心以 Apollo 为例社区版即可没有也可以用本地配置文件模拟需要说明的是Spring Boot 3.x 基于 Jakarta EE包名从javax变成了jakarta但循环依赖的核心原理和处理思路是一致的。本文中的代码以 Spring Boot 2.7 示例为主如果你用的是 3.x只需要调整依赖版本不需要改变代码结构。另外Spring Boot 2.6 版本开始默认禁止了循环依赖这是官方态度的重要变化。如果你的项目从 2.5 升级到 2.6 之后突然出现循环依赖报错不要惊讶这不是代码坏了而是框架默认策略变严格了。2.2 示例项目结构为了还原“异世界捡到老代码”的氛围我准备了一个最简单的多模块工程先把结构摆出来。demo-heterogeneous-ring/ ├── pom.xml ├── common/ │ ├── pom.xml │ └── src/main/java/com/example/common/ │ └── CommonConfig.java ├── user-service/ │ ├── pom.xml │ └── src/main/java/com/example/user/ │ ├── UserApplication.java │ ├── controller/UserController.java │ ├── service/UserService.java │ └── service/UserServiceImpl.java └── order-service/ ├── pom.xml └── src/main/java/com/example/order/ ├── OrderApplication.java ├── controller/OrderController.java ├── service/OrderService.java └── service/OrderServiceImpl.java从目录结构上看这似乎是一个标准的微服务工程common是公共模块user-service和order-service是两个独立服务。但魔鬼藏在细节里——user-service和order-service的 pom 中互相引用了对方的坐标两个服务的 Service 实现类又互相注入了对方的 Service。这个环一旦启动就会立刻引爆。3. 核心原理拆解3.1 构造器循环依赖为什么注定失败Spring 管理 Bean 的生命周期时创建顺序一般遵循“先创建依赖项再创建当前 Bean”。使用构造器注入时Spring 必须先把构造器参数中的所有依赖准备齐全才能调用当前 Bean 的构造方法。如果 A 和 B 都使用构造器注入A 的创建需要 BB 的创建又需要 ASpring 在创建时就会进入一个逻辑上的死锁它不知道该先创建谁。最终结果是抛出异常提示BeanCurrentlyInCreationException或者The dependencies of some of the beans in the application context form a cycle。字段注入和 setter 注入之所以能在某些场景下“幸免”是因为 Spring 可以先将 Bean 的实例创建出来再通过反射或 setter 把依赖填充进去相当于允许一个半成品先存在。但这个“幸免”只是创建阶段的妥协依赖环本身仍然是架构坏味道可能导致后续行为不可预测也会让单元测试变得非常困难。3.2 Maven 模块循环依赖为什么能编译却隐患重重Maven 构建多模块项目时会先按照依赖关系确定 reactor 构建顺序。如果模块 A 依赖模块 BB 依赖模块 C那么构建顺序应为 C → B → A。一旦 A 和 B 互相依赖Maven 在计算 reactor 顺序时就会陷入两难。你可能会在命令行里看到构建成功那是因为 Maven 对某些模块间循环做了自动调整或者你只在 IDE 里通过“全部构建”侥幸通过。但在干净环境里执行mvn clean install或者推到 CI 服务器从零构建时就会报出类似The projects in the reactor contain a cyclic reference的错误。CI 环境没有 IDE 的历史编译缓存问题立刻现出原形。3.3 配置环Apollo 命名空间互相引用如何造成加载死锁Apollo 支持配置中心内的占位符引用一个配置项可以直接引用另一个配置项的值order.timeout${user.default-timeout}这种能力很方便但也带来了配置间隐式依赖。如果 A 命名空间里的配置引用了 B 命名空间的 keyB 命名空间里的配置又引用了 A 命名空间的 key那么配置中心在按需加载命名空间时就会陷入依赖等待。配置环最麻烦的特点是它具有随机性。Apollo 客户端加载顺序可能与启动时间、网络延迟、缓存状态有关环境不同结果就不同。你可能在测试环境完全正常到了生产环境偶发配置为空因为两个命名空间互相等待谁也没能在超时时间内拿到对方的值。3.4 如何快速识别环识别环不需要高深工具核心思路是画出依赖关系图然后看图中是否存在“闭合回路”。你可以用以下三种方式快速检测代码层面查看 Bean 的构造器或Autowired字段把“谁依赖谁”整理成一张有向图。模块层面用 Maven 的dependency:tree插件导出依赖树检查是否有 A → B 和 B → A 同时存在。配置层面在配置中心把所有Value和${...}占位符收集起来检查相互引用是否构成闭环。如果懒得人工梳理也可以通过脚本扫描源码中的依赖注入注解生成文本形式的依赖列表再人工核对。下面是一个简化思路的伪代码示例grep -rn Autowired\|Resource --include*.java . | awk -F/ {print $1} | sort | uniq -c | sort -rn这个命令的作用是统计哪些目录下存在依赖注入并不能直接画出环但可以作为扫描入口帮你快速定位“哪些模块依赖特别多”再针对性地检查是否存在双向依赖。4. 完整实战案例复现并修复一个异环问题4.1 场景设定假设user-service中的UserServiceImpl需要调用order-service的OrderService来查询用户最近订单而order-service中的OrderServiceImpl又需要调用user-service的UserService来获取用户基础信息。业务上都有场景但从架构上看两个服务形成了互相依赖。我们将按照下面四个步骤来复现和修复编写能复现循环依赖报错的代码。使用 Maven 依赖树命令定位模块环。通过设计调整拆解环。重新运行并验证结果。4.2 第一步编写能复现错误的代码先创建user-service的 Service 接口这是基础契约。// 文件路径user-service/src/main/java/com/example/user/service/UserService.java package com.example.user.service; public interface UserService { String getUserName(String userId); }再创建order-service的 Service 接口。// 文件路径order-service/src/main/java/com/example/order/service/OrderService.java package com.example.order.service; public interface OrderService { String getLatestOrderId(String userId); }下面是复现循环依赖的核心代码请将两个实现类分别放到对应模块中。// 文件路径user-service/src/main/java/com/example/user/service/UserServiceImpl.java package com.example.user.service; import com.example.order.service.OrderService; import org.springframework.stereotype.Service; Service public class UserServiceImpl implements UserService { private final OrderService orderService; public UserServiceImpl(OrderService orderService) { this.orderService orderService; } Override public String getUserName(String userId) { // 调订单服务获取用户最近订单仅为演示依赖关系 String orderId orderService.getLatestOrderId(userId); if (U001.equals(userId)) { return 张三最近订单 orderId; } return 未知用户最近订单 orderId; } }// 文件路径order-service/src/main/java/com/example/order/service/OrderServiceImpl.java package com.example.order.service; import com.example.user.service.UserService; import org.springframework.stereotype.Service; Service public class OrderServiceImpl implements OrderService { private final UserService userService; public OrderServiceImpl(UserService userService) { this.userService userService; } Override public String getLatestOrderId(String userId) { // 调用户服务获取用户名称仅为演示依赖关系 String userName userService.getUserName(userId); if (U001.equals(userId)) { return ORD-2025001用户 userName ; } return ORD-2025002用户 userName ; } }同时为了让两个模块互相依赖成立还需要在各自的 pom.xml 中加入对方的依赖。在 Maven 多模块项目中这样的写法就是模块环的直接来源。!-- 文件路径user-service/pom.xml -- dependencies dependency groupIdcom.example/groupId artifactIdorder-service/artifactId version1.0.0-SNAPSHOT/version /dependency /dependencies!-- 文件路径order-service/pom.xml -- dependencies dependency groupIdcom.example/groupId artifactIduser-service/artifactId version1.0.0-SNAPSHOT/version /dependency /dependencies配置完成后启动任意一个服务的 Application 类Spring Boot 会在启动阶段直接抛出循环依赖异常项目无法正常起来。这一步的价值在于先让问题稳定复现修复才有据可依。4.3 第二步用依赖树命令定位模块环如果你没有 IDE 报错提示或者项目过于庞大可以先用 Maven 命令检查模块依赖关系。进入项目根目录执行mvn dependency:tree -Dverbose -Dincludescom.example-Dincludescom.example表示只查看com.example组下的依赖避免输出被第三方库刷屏。理论上你会在输出中看到类似下面这样的内容com.example:user-service:jar:1.0.0-SNAPSHOT └─ com.example:order-service:jar:1.0.0-SNAPSHOT而在order-service的依赖树里又会看到user-service的影子。两个模块互相出现在对方的依赖树中模块环基本可以确认。如果你使用的是 Gradle可以执行./gradlew :user-service:dependencies --configuration runtimeClasspath同样地检查order-service的依赖输出重点看com.example组下的依赖是否互相包含。4.4 第三步通过设计重构解环定位到环之后不能简单粗暴地删除某一个依赖因为业务上两个服务确实有调用需求。解环的核心思路是打破“直接互相依赖”采用以下三种常见方案之一。方案一抽取公共模块。把两个服务都要用的模型类或 API 接口下沉到common模块两个服务都依赖common但彼此不再直接依赖。这个方案最清晰也是我推荐优先尝试的做法。新建接口放在common模块中。// 文件路径common/src/main/java/com/example/common/api/UserOrderFacade.java package com.example.common.api; public interface UserOrderFacade { String getUserName(String userId); String getLatestOrderId(String userId); }方案二使用消息中间件解耦。如果一个服务只需要另一个服务的最终结果不需要同步返回可以考虑引入 MQ。比如订单服务下单成功后发送一条“订单已创建”消息用户服务监听这条消息做后续处理。这样两个服务不再有同步调用依赖环自然消失。代价是引入了新的中间件分布式事务和消息补偿的复杂度也随之而来。方案三通过第三方服务编排。在微服务架构中可以引入聚合层或 BFFBackend for Frontend层由聚合层同时调用用户服务和订单服务不让两个服务直接互相调用。这样 UserService 和 OrderService 都只与聚合层交互环被转移到了聚合层实现了物理上的解环。在本文的恢复示例中我选择方案一抽取公共接口。// 文件路径common/src/main/java/com/example/common/api/UserOrderFacade.java package com.example.common.api; public interface UserOrderFacade { String getUserName(String userId); String getLatestOrderId(String userId); }接着修改UserServiceImpl让它不再直接调用OrderService而是调用UserOrderFacade中获取订单信息的方法。// 文件路径user-service/src/main/java/com/example/user/service/UserServiceImpl.java package com.example.user.service; import com.example.common.api.UserOrderFacade; import org.springframework.stereotype.Service; Service public class UserServiceImpl implements UserService { private final UserOrderFacade userOrderFacade; public UserServiceImpl(UserOrderFacade userOrderFacade) { this.userOrderFacade userOrderFacade; } Override public String getUserName(String userId) { String orderId userOrderFacade.getLatestOrderId(userId); if (U001.equals(userId)) { return 张三最近订单 orderId; } return 未知用户最近订单 orderId; } }OrderServiceImpl也改为依赖UserOrderFacade// 文件路径order-service/src/main/java/com/example/order/service/OrderServiceImpl.java package com.example.order.service; import com.example.common.api.UserOrderFacade; import org.springframework.stereotype.Service; Service public class OrderServiceImpl implements OrderService { private final UserOrderFacade userOrderFacade; public OrderServiceImpl(UserOrderFacade userOrderFacade) { this.userOrderFacade userOrderFacade; } Override public String getLatestOrderId(String userId) { String userName userOrderFacade.getUserName(userId); if (U001.equals(userId)) { return ORD-2025001用户 userName ; } return ORD-2025002用户 userName ; } }注意上面的代码只是演示了接口拆分思路。实际落地时UserOrderFacade需要一个具体实现这个实现类可以在common模块提供默认实现也可以由其中一个服务提供具体取决于你的工程模块划分规则。如果你的服务调用是跨进程的则更合理的方式是用 OpenFeign 定义 API 接口而不是在公共模块直接放实现类。同时两个服务模块的 pom.xml 中要把互相依赖改成共同依赖common!-- 文件路径user-service/pom.xml -- dependencies dependency groupIdcom.example/groupId artifactIdcommon/artifactId version1.0.0-SNAPSHOT/version /dependency /dependencies!-- 文件路径order-service/pom.xml -- dependencies dependency groupIdcom.example/groupId artifactIdcommon/artifactId version1.0.0-SNAPSHOT/version /dependency /dependencies这一步之后user-service和order-service之间不再存在直接依赖环被打破了。4.5 第四步运行与验证修复完成后先执行一次全量编译确保模块构建顺序正常。mvn clean install -DskipTests如果控制台输出BUILD SUCCESS说明 Maven reactor 已经能正确计算出构建顺序不再存在模块环。接着启动user-service的UserApplication观察启动日志。如果你使用了本文的示例代码会看到应用成功启动控制台输出类似Started UserApplication in 2.315 seconds再启动order-service同样可以正常启动不再抛出BeanCurrentlyInCreationException或循环依赖相关异常。为了进一步验证配置环可以打开application.yml加入互相引用的两个占位符观察启动时是否出现解析异常或空值。很多配置环在启动阶段不会报错需要你主动检查日志中的配置加载记录。5. 常见问题与排查思路5.1 常见报错场景问题现象常见原因解决思路Spring Boot 启动报The dependencies of some of the beans in the application context form a cycleBean 之间存在构造器或字段循环注入使用spring-boot的依赖分析定位环重构代码打断循环Maven 构建报The projects in the reactor contain a cyclic reference模块 pom.xml 互相依赖抽取公共模块或调整模块边界应用启动偶尔成功、偶尔失败配置环或某个 Bean 加载顺序随机检查配置中心命名空间相互引用减少跨命名空间引用升级 Spring Boot 2.6 后突然出现循环依赖报错新版本默认禁止循环依赖优先重构代码不建议直接配置spring.main.allow-circular-referencestrueApollo 配置项取值为空或为${...}原样输出配置引用形成环解析超时去掉循环引用改为单一来源配置5.2 排查清单如果你遇到一个无法解释的依赖异常建议按下面的顺序排查一遍使用mvn dependency:tree -Dincludescom.example检查模块依赖确认是否存在 A → B → A。使用 IDE 的依赖分析功能画出模块依赖图肉眼识别环。搜索代码中所有Autowired、Resource、构造器注入的字段检查是否存在双向注入。搜索配置文件中的${...}占位符把 key 整理成表格检查是否有反向引用。查看完整启动日志定位第一个报错的 Bean 是哪个从它的依赖链逐步回溯。临时将循环依赖的报错信息打印到日志记录当时的创建顺序帮助还原依赖环路径。如果项目比较大优先用自动化脚本扫描而不是人工肉眼排查。6. 最佳实践与工程建议6.1 依赖治理原则解环不能只靠“出了 bug 再修”更要在日常开发中建立依赖治理意识。第一优先使用构造器注入。字段注入写起来方便但会隐藏依赖关系让 IDE 和静态分析工具难以发现循环。构造器注入虽然代码稍长但每个依赖都明确暴露在构造参数中环很容易被识别。第二模块依赖必须单向流动。多模块工程的依赖方向应该像分层架构一样清晰。比如api层被service层依赖service层被controller层依赖不允许controller模块反过来依赖service层其他模块更不允许两个模块互相依赖。第三公共代码及时下沉。一旦发现两个模块都在使用同一组模型或工具类应该立刻抽取到common模块。不要因为“只是几个字段”就复制粘贴复制粘贴会让依赖关系越来越模糊最终积重难返。6.2 配置环防范配置中心的强大功能需要配合规范使用。建议在团队内约定以下规则配置项尽量避免相互引用。如果一个配置项必须引用另一个配置项请保证引用方向是单向的、可追踪的。跨命名空间引用要谨慎。Apollo 中的多个 namespace 本身是隔离单元跨 namespace 引用会隐藏真实的依赖关系。配置变更遵循审批流程。修改配置前先检查是否有人依赖当前 key可以在配置中心后台查看引用关系。核心配置增加本地默认值。即使配置中心解析失败应用也能用默认值启动避免完全不可用。6.3 代码评审与 CI 检查代码评审是拦截依赖环的第一道防线但人总有疏忽建议把依赖检查纳入 CI 流程。Maven 项目可以引入maven-enforcer-plugin在构建阶段检测模块循环依赖。示例配置如下build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-enforcer-plugin/artifactId version3.4.1/version executions execution idenforce-no-cyclic-dependencies/id goals goalenforce/goal /goals configuration rules dependencyConvergence/ /rules /configuration /execution /executions /plugin /plugins /builddependencyConvergence规则主要用于检查依赖版本冲突并非专门检测循环依赖但它能在一定程度上暴露模块间的异常依赖关系。如果你的项目对依赖治理要求更高可以自定义 Enforcer 规则或者使用 ArchUnit 在单元测试阶段校验包依赖方向。6.4 遗留项目接手时的动作如果你正准备接手一个“异世界”老项目不要急着加功能先做下面三件事让项目在干净环境里完整编译通过一次。生成依赖树和模块依赖图保存为文档。梳理核心 Bean 的注入关系标记出所有双向依赖。这三件事做完你对项目的整体结构就有了底稿。后面每一次改动都可以对比底稿判断是否引入了新的环。在没有备份的情况下不要直接删除 pom.xml 中的依赖更不要为了绕过循环依赖把spring.main.allow-circular-references设置为 true。这个配置是双刃剑它能让你暂时启动成功但不会消除环只会把隐患埋得更深。7. 总结与下一步这篇文章从“异环”这个特殊的项目名切入梳理了循环依赖的三种典型形态对象循环注入、模块循环依赖和配置环。我们通过一个最小可复现的 Spring Boot 多模块工程经历了从复现报错、定位环到抽取公共接口解环的完整过程并整理了常见报错排查清单和依赖治理建议。如果你正在维护一个老项目建议先花半天时间把项目的依赖关系画出来。画图的过程可能会让你发现很多平时忽略的隐性关联。等环拆干净了再去做框架升级、模块拆分、性能优化都会顺畅很多。在真实业务中“异世界捡到青梅竹马”往往不是一次浪漫的相遇而是一场需要耐心和细心的工程考古。先把环找出来再动手改不盲目删依赖不轻易开全局开关这是一条稳妥的路。如果本文对你有帮助可以收藏备用如果你在排查中也遇到过更诡异的依赖环欢迎在评论区交流。
返回列表