ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring循环依赖排查与解决:从BeanCurrentlyInCreationException到LDAP数据模型

Spring循环依赖排查与解决:从BeanCurrentlyInCreationException到LDAP数据模型 1. 循环引用日志长什么样先学会读异常链路先说个真实场景。我上周帮一个团队排查LDAP相关服务的启动失败问题现象非常典型Spring Boot服务一启动就崩控制台刷出一大段以BeanCurrentlyInCreationException开头的红色错误开头几行大概是这样Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name ldapUserService: Requested bean is currently in creation: Is there an unresolvable circular reference?很多同学看到circular reference就直接想到循环引用但接下来不知道怎么做。真正排查这类问题第一步不是翻代码而是把整段日志完整读一遍找出这些关键信息报错Bean是谁异常里会直接点出ldapUserService或类似的名字这就是循环链中的一环。Chain of dependency不完整时怎么判断老版本Spring会打印一条依赖链跟踪信息新版本默认只提示currently in creation这时需要靠BeanCreationException的堆栈来倒推是哪几个Bean互相等待。排查的突破口看日志里最后的那组Creating bean with name...序列比如日志先创建了ldapConfig再去创建ldapUserService然后ldapUserService又需要ldapConfig而ldapConfig还没创建完——这就锁定了循环的两个端点。这就像你去餐厅排队A窗口点菜需要B窗口的餐券B窗口又只认A窗口的盖章两边都卡在第一步。日志的作用就是告诉你这两个窗口分别叫什么。另外要注意一个细节LDAP项目里循环引用可能有两种完全不同的含义它俩长得像但解决路径完全不同类型报错时机典型异常产生位置Spring容器级循环依赖启动阶段BeanCurrentlyInCreationException配置类、服务类注入关系数据/模型级循环引用运行期访问数据时StackOverflowError、JsonMappingExceptionLdapUser / LdapGroup 实体映射文章后面会同时讲这两种情况因为我在实际排查中见过不少团队把第一种解决了第二天又被第二种绊倒而且这两种在日志上确实容易混淆。2. 为什么Spring能处理大多数循环引用这次却崩了先说一个让很多人困惑的问题网上都说循环引用问题很多但为什么我平时写的Service互相注入好像也没事那是因为Spring在默认配置下Spring Boot 2.6.0之前确实能自动兜住一部分setter/field注入的循环依赖。2.1 三级缓存的先给半成品逻辑Spring容器创建单例Bean时走的是这样一个流程实例化new出一个对象→ 属性填充给字段赋值→ 初始化执行afterPropertiesSet等逻辑。问题出在属性填充这一步。假设LdapUserService需要注入LdapGroupService而LdapGroupService又需要注入LdapUserService。Spring的处理逻辑是开始创建LdapUserService此时对象刚new出来属性还没赋值。在属性填充阶段发现需要LdapGroupService于是转去创建它。创建LdapGroupService时发现它需要LdapUserService于是又回来找LdapUserService。如果没有中间机制这一步就死锁了。Spring的解法是第三级缓存当Bean刚实例化完、还没填充属性时就把这个半成品提前暴露到一个单例工厂里。第二步再次来找LdapUserService时Spring直接把那个半成品返回给LdapGroupService于是LdapGroupService先拿着半成品完成自己的创建回头再让LdapUserService把属性补齐。之所以能这么干核心原因是大多数Bean的方法调用不会在构造期间发生半成品虽然没赋全属性但只要你不在创建过程中去调它内部的业务方法就不会出问题。2.2 构造器注入为什么绕不过这个坎但是如果你用了构造器注入情况就完全不同了。Service public class LdapUserService { private final LdapGroupService ldapGroupService; public LdapUserService(LdapGroupService ldapGroupService) { this.ldapGroupService ldapGroupService; } }构造器注入意味着必须先拿到完整的LdapGroupService实例才能开始newLdapUserService。而LdapGroupService的构造器又需要完整的LdapUserService。两边都要求对方先完整出生谁也没法先new出来三级缓存里存的半成品无从谈起——因为压根没有对象可提前暴露。这也就是为什么Spring官方一直推荐构造器注入但循环依赖却是构造器注入的唯一死穴。LDAP项目里Service层经常要同时操作用户、组、组织单元三类核心对象职责耦合度高构造器注入的循环依赖出现概率远高于普通业务系统。2.3 从日志时间线还原启动顺序排查时一个容易被忽视的技巧是不要只看报错的那一秒要往前翻几十行日志。Spring创建Bean是有顺序的这些顺序本身就是一张拓扑图。我通常的操作是# 把带时间戳的日志导出来按Bean创建语句过滤 grep -E Creating|Instantiating|BeanCreationException app.log | head -100看输出里 Bean 创建的先后顺序。循环依赖形成的时候日志里会出现一个回环某个Bean的名字第一次出现后隔了若干行又出现一次中间夹着其他Bean对这个Bean的依赖请求。找到那条回去的路就找到了循环的另几个节点。另外还要注意Spring Boot 2.6.0 起默认禁止循环引用如果你升级过版本以前碰巧能跑的代码会突然启动失败。这不是代码变了而是容器的容忍度变了。报错日志里如果出现The dependencies of some of the beans in the application context form a cycle基本上就是版本升级后的循环依赖暴露。3. 五种解法与适用场景从最小改动到重构循环依赖没有银弹只有适合当前场景的方案。我把实际中用过的解法按改动成本从低到高排序结合LDAP项目的特点讲。3.1 方案一Lazy代理破环最简单改动最小、见效最快的做法是在注入点上加LazyService public class LdapUserService { private final LdapGroupService ldapGroupService; public LdapUserService(Lazy LdapGroupService ldapGroupService) { this.ldapGroupService ldapGroupService; } }原理是Spring不再尝试在创建LdapUserService时立即获取完整的LdapGroupService而是注入一个代理对象。这个代理在第一次被调用业务方法时才会去容器里真正解析目标Bean。这样两个Bean的创建就解耦了——你创建你的我创建我的谁都不用等谁。适用场景非常明确循环依赖只在启动期存在运行时调用方向是单向的比如LdapUserService在启动时通过构造器传递引用了LdapGroupService但真正调用LdapGroupService方法是在请求进来之后。不想大动干戈重构只是想先让服务跑起来。但要提醒一句Lazy是治标不是治本。它把创建期的矛盾推迟到了运行期。如果运行时出现了真正的双向实时调用代理对象反而会让调用链路变得难以调试因为你断点进去看到的不是目标对象而是CGLIB代理。我见过有人全项目到处加Lazy最后变成技术债不推荐大范围使用。3.2 方案二重构注入点为ObjectProvider如果你用的是Spring Framework 5.0或Spring Boot 2.x可以改用ObjectProvider做延迟获取Service public class LdapUserService { private final ObjectProviderLdapGroupService ldapGroupServiceProvider; public LdapUserService(ObjectProviderLdapGroupService ldapGroupServiceProvider) { this.ldapGroupServiceProvider ldapGroupServiceProvider; } }与Lazy代理不同ObjectProvider是手动拉取真正需要时才调用getIfAvailable()获取实例。它在解决循环依赖的同时把什么时候真正依赖对方的主动权交到了业务代码手里灵活性比Lazy更好。public LdapGroup getGroupData(String groupId) { LdapGroupService groupService ldapGroupServiceProvider.getIfAvailable(); if (groupService ! null) { return groupService.findGroupById(groupId); } return null; }这种模式的另外一个好处是未来的重构方向更清晰。等你某天想改成事件驱动或异步解耦ObjectProvider这层间接引用天然就是一个可以替换的缝。3.3 方案三setter/field注入临时验证不推荐用于生产最偷懒的办法是把构造器注入改成setter注入Service public class LdapUserService { private LdapGroupService ldapGroupService; Autowired public void setLdapGroupService(LdapGroupService ldapGroupService) { this.ldapGroupService ldapGroupService; } }因为setter注入允许对象先创建出来再慢慢赋值Spring的三级缓存就能兜住。我明确不建议你在生产环境用这个方法但它有一个极其实用的调试价值当你分不清报错究竟是循环依赖还是配置错误时临时改成setter注入如果启动通过了说明循环依赖坐实如果还是报错说明Bean的创建还有其他问题。用完之后记得改回来它只是诊断工具不是生活方案。3.4 方案四重新梳理Bean职责把公共依赖下沉这是我最推荐的长期解法。LDAP项目的Service层循环依赖本质上是两个服务都在同时操作对方的领域对象。比如LdapUserService.findUsersInGroup(String groupDn) // 需要知道组信息 LdapGroupService.findGroupsForUser(String userDn) // 需要知道用户信息这种业务的业务本质确实需要双向查询但不等于两个Service必须互相持有对方。更好的做法是引入一层公共依赖或数据访问层public interface LdapDirectoryRepository { ListLdapUser getUsersInGroup(String groupDn); ListLdapGroup getGroupsForUser(String userDn); }LdapUserService和LdapGroupService都依赖这个Repository循环立刻被打断。这一步可能需要动一些代码但换来的是依赖图变得干净、可测试性大幅提升——你可以轻松用Mock的Repository测试两个Service不用再启动一整个Spring容器。判断是否需要走这个方案的标准很简单如果两个Service互相调用的方法超过三个或者你在为哪些Bean放哪个类而纠结那就该重构了。循环引用在依赖图上呈现为环环的存在意味着你的领域模型边界没划清楚。这一刀迟早要切晚切不如早切。3.5 方案五配置开关allow-circular-references补充聊聊配置开关这个下策。Spring Boot 2.6.0默认禁止循环引用但提供了开关让你恢复旧版行为spring.main.allow-circular-referencestrue一个迫不得已的情况下才应该开的开关。它相当于告诉Spring我知道有循环依赖你别管了默认允许吧。 我之所以把它放在最后是因为它掩盖了问题而不是解决问题而且后面每个新加入的Bean都可能无意中加入这个循环引用大网最终变得不可收拾。如果你已经决定用这个方案至少要配合一个注释说明// TODO: 循环依赖临时开关需在Q3重构后移除 spring.main.allow-circular-referencestrue3.6 方案对比与选择参考方案改动量破坏性适用场景长期影响Lazy代理一行注解低少量Bean循环、运行时单向调用中代理调试困难ObjectProvider构造器类型修改低需要延迟获取、未来可能重构低灵活性高setter注入方法级修改中仅用于临时诊断验证高不推荐生产下沉公共依赖需抽出接口/类高循环涉及业务语义交叉低最健康配置开关一行配置极低遗留系统短期过渡高掩盖问题我的选择顺序是先用Lazy或ObjectProvider让项目恢复启动然后立刻规划重构最终把循环依赖从结构上消灭。没有一个合格的长期项目应该靠Lazy注解活着。4. LDAP目录模型里的数据级循环引用另一种容易混淆的坑解决了Spring启动期的循环依赖问题后LDAP项目还有另一个隐藏坑实体模型层面的循环引用。我排查过好几次团队刚解决了启动崩溃运行期访问用户组数据时又出现StackOverflowError。4.1 LdapUser和LdapGroup互相引用导致的死循环典型的LDAP数据模型是这样设计的public class LdapUser { private String dn; private String uid; private ListLdapGroup groups; // 用户所属的组 } public class LdapGroup { private String dn; private String cn; private ListLdapUser members; // 组内的成员 }看到问题了吗一个用户包含了他所属的组列表每个组又包含了它的成员列表成员里又有用户对象。当你用Jackson把LdapUser序列化成JSON返回给前端时{ dn: uidzhangsan,oupeople,dcexample,dccom, groups: [ { dn: cnadmin,ougroups,dcexample,dccom, members: [ { dn: uidzhangsan,..., groups: [...] } ] } ] }理论上这个嵌套可以无限展开Jackson的默认序列化器就会一直递归下去直到栈内存溢出。日志里会看到java.lang.StackOverflowError: null at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:166)如果只盯着StackOverflowError而没意识到这是循环引用排查方向就完全歪了——你可能去调整JVM栈大小而不是去修正数据模型。4.2 序列化循环与依赖循环的排查区分怎么区分是Spring的Bean依赖循环还是LDAP实体模型的序列化循环我总结了两条经验报错时机Spring依赖循环几乎只在启动阶段报BeanCurrentlyInCreationException序列化循环只有在访问接口、触发JSON转换时才爆栈。日志堆栈顶部类名序列化循环的栈顶大概率是BeanSerializer、ObjectMapper、JsonGenerator这类Jackson类Bean依赖循环的栈顶是DefaultListableBeanFactory、AbstractAutowireCapableBeanFactory。简单记启动看Factory运行看Serializer。4.3 处理数据级循环引用的几个实用技巧第一招在实体上直接打断双向序列化用JsonIgnore或JsonManagedReference/JsonBackReference。public class LdapGroup { private String dn; private String cn; JsonBackReference // 序列化时不展开这个方向 private ListLdapUser members; } public class LdapUser { private String dn; private String uid; JsonManagedReference // 作为正向引用正常序列化 private ListLdapGroup groups; }这样JSON只展开其中一个方向不会无限递归。但代价是前端拿到用户对象时看不到用户组完整信息需要再发一次请求去查。第二招在业务层用DTO隔离。这是我个人最推荐的办法也是在大型项目里最常见的实践。public class LdapUserDTO { private String dn; private String uid; private ListString groupDns new ArrayList(); // 只保留组的DN不展开组对象 }服务层从LDAP目录读取数据后手动把实体转换成DTO。这样不仅彻底解决了序列化循环还顺带解决了LDAP实体被Jackson注解污染的问题——实体类不再依赖JSON序列化细节职责更单一。转换逻辑放在哪我习惯放在服务层里做一个独立的toDTO方法或Mapper类public LdapUserDTO toDTO(LdapUser user) { LdapUserDTO dto new LdapUserDTO(); dto.setDn(user.getDn()); dto.setUid(user.getUid()); dto.setGroupDns( user.getGroups().stream().map(LdapGroup::getDn).collect(Collectors.toList()) ); return dto; }第三招如果确实需要完整的组和成员嵌套信息不要一次性全量加载。LDAP本身支持懒加载属性你可以借助Spring LDAP的ContextMapper在读取时只映射基础属性等上层调用fetchGroupMembers()时才去目录里查成员数据。数据量一大这种按需加载比一锅端靠谱得多。5. 排查链路复盘从异常爆出到定位根因的完整路线这部分我完整走一遍排查流程方便你下次遇到时照着操作。5.1 在高并发或复杂依赖下为什么日志顺序会误导你先提醒一个反直觉的坑报错日志里的最后一行不一定是最先发生的问题。Spring创建Bean是层层嵌套的有点像递归调用。当循环依赖发生时最内层的Bean先爆异常外层Bean捕获后继续包装最终抛给应用的最外层。因此日志从下往上看往往能看到更底层的根因从上往下看反而是一堆没有价值的顶层包装。我曾经在一个依赖链有七八层的项目里排查循环引用光看最上面几条日志差点把问题的两个Bean搞反后来是从Caused by链的最深处开始追踪才定位准确。5.2 借助依赖图与断点快速定位如果项目能用IDEA我建议先看Spring的Beans Graph Output或者直接用Dependency Matrix视图它能以图形化方式展示Bean与Bean之间的依赖关系循环环一眼就能看出来。万一你的开发环境看不了图还有一个朴素但高效的办法二分注释法。具体操作先从报错日志里锁定两个Bean比如A和B。临时把A依赖B的那个注入点注释掉启动一次。如果启动通过说明问题确实在A–B这条依赖上。再把注释还原临时把B依赖A的注入点注释掉再启动一次确认双向链路。确认之后逐个排查是哪个注入方向导致了启动失败再选择第3节的方案。这种方法听起来笨但在依赖关系复杂、项目里各种Bean叠加了很多自定义逻辑时它能最小化干扰地确认问题。两个方向都注释掉才能证明这个环是死环至少一方依赖构造器注入而不只是一个方向慢启动造成的假象。5.3 修改后的验证流程改完代码不是启动成功就完事了。循环依赖有个特点问题可能潜伏在冷启动路径上你这次没触发不代表下次扩容、重启时不触发。我的验证清单通常包括冷启动一次确认启动成功必须清掉target/out目录避免吃旧的编译产物。启动过程中观察日志确认没有currently in creation、cycle类警告。即使用Lazy解决了启动Spring也会在一些版本里打WARN日志别忽略。手动调用涉及两个Bean的接口比如查询某用户的组列表、某组的成员列表确认运行时调用正常。如果改动了实体模型或DTO用真实LDAP数据跑一遍确认memberOf、uniqueMember等属性映射后没有丢数据也没出现JSON空指针。这套流程走下来才算真正闭环。6. 再往后想一步哪些循环引用是设计上的坏味道最后聊几句我的个人体会。每一次循环引用bug本质上都是设计层面发出的信号。你可以用Lazy快速止血但如果不回头审视领域边界类似的坑会在下一个迭代里换个马甲再出现。6.1 循环依赖最容易出现的三个位置根据我在LDAP项目及其他Java服务里的观察循环依赖集中在这三个位置Service层互相查询最普遍两个业务服务都需要对方的领域信息。配置类互相引用两个Configuration类里的Bean方法相互调用。这种尤其隐蔽因为报错往往指向某个MethodInvokingBean不仔细看根本发现不了是配置类引起的。事件监听器与发布者某个Service既是事件的发布者又需要监听另一个Service发布的事件如果处理不当就会形成创建期依赖。6.2 启动即全量初始化与懒加载的取舍LDAP目录操作相比数据库访问延迟更高很多团队为了防止运行期性能问题习惯在启动阶段把用户、组、组织结构预加载到内存缓存里。这个做法一旦配合构造器注入循环依赖概率会急剧上升——因为每个Bean启动时都要拉其它Bean的数据启动顺序变得极度敏感。我的建议是依赖注入解决的是Bean之间的协作不是启动时数据预热的工具。预加载缓存应该独立成专门的CacheInitializer或EventListener(ApplicationReadyEvent.class)在容器启动完成之后再去拉数据。这样既保证了运行期性能又不干扰Bean的创建拓扑。6.3 一个小技巧用启动拓扑监测防范复发如果你想在团队里彻底杜绝循环依赖回潮可以在测试里加一个简单的约束Test void contextLoads() { // 只要容器能正常启动就证明当前没有不可解决的循环依赖 }这算是最基础的防线。更进一步还可以在CI流程里加上依赖分析步骤扫描类文件中的构造器引用提前发现潜在的循环链路。不要小看这个动作它能把线上启动崩溃变成合并代码前就发现成本极低、收益直观。6.4 我个人的处理经验小结踩过太多循环引用的坑之后我现在接到这类报错的第一反应不是怎么解而是为什么会循环。两个Bean一旦形成环只有两种可能要么其中一个Bean的职责越界要么它们的依赖粒度太粗。先想清楚这个再动手改代码往往比研究用什么注解更快。LDAP项目还有个特殊点目录结构天然是树状的组套组、用户加组都会形成层级关系。在代码里模拟这种层级关系时很容易让对象互相引用。建议不管数据量大小实体模型一律单向引用父指向子反向关系通过查询去实时获取这样从源头就规避了大部分模型级循环引用问题。DTO在该切的地方切一刀业务逻辑清爽前端拿到的数据也干净。如果下次你的LDAP项目启动报circular reference不妨按这个顺序走一遍读日志找环的端点、判断是Bean依赖还是模型引用、最小改动止血、结构性重构最后别忘了加个启动测试兜底。按这套流程走基本一小时内能定位半天内能收干净。
返回列表