循环依赖加 @Lazy 后异常漂移?Spring 三级缓存为什么没兜住?

循环依赖加 @Lazy 后异常漂移?Spring 三级缓存为什么没兜住?


阅读说明

本文是一篇真实项目排障复盘,不是 Spring 三级缓存源码教程。

文章背景来自我个人的 SaaS 笔记系统。之前有一段时间对笔记模块做了一次职责拆分,把一个 2500+ 行的"上帝类"拆解成若干职责单一的 Service 和切面组件。这篇文章不讨论拆分过程本身,只聚焦拆完之后暴露出的 Spring 启动问题。

重点在三件事:排障链路的推进过程、中途的误判和修正、以及最后的依赖结构调整。如果你想看三级缓存源码分析,这篇可能不是你想要的;但如果你曾经在 Spring 启动报错里迷失过方向,也许这篇有点参考价值。

如果只看文中的代码片段还不够,我也把当时时间线上的代码 Fork 了一个探索分支,可以配合文章一起看:

循环依赖探索分支:qing-del/middleware at explore-circular


本篇核心收获

  1. Spring 启动异常不能只看最上层,要一路翻到最底层 Caused by,上层往往是被下层拖垮的。
  2. SqlSessionTemplate 出现在异常链里,不一定说明 MyBatis 是根因。
  3. 三级缓存能解决一部分循环依赖,但解决不了混乱的业务依赖方向。
  4. @Lazy 可以缓解启动问题,但不是工程上的根治方案。

写在前面

前段时间在做个人 SaaS 笔记系统的一次模块重构。笔记核心服务之前是一个典型的"上帝类",将近 2500 行,几乎什么逻辑都往里塞。做了一次职责拆分之后,变成了 NoteFacadeNoteCoreServiceNoteContextServiceNoteRelationService 等几个组件,同时引入了 CheckMissingInfoAspect 这个 AOP 切面,用来统一处理笔记信息完整性校验。

拆完之后,原本以为模块职责更清晰,依赖关系更干净。结果重启项目,直接给了我一个 Bean 创建失败。

这篇文章不展开讲拆分过程,只讲拆完之后暴露出的这个问题------以及我是怎么一步步查到根因的。


一、第一眼看到的异常:我以为是 SqlSessionTemplate 的问题

报错的起点长这样:

vbnet 复制代码
org.springframework.beans.factory.UnsatisfiedDependencyException: 
Error creating bean with name 'checkMissingInfoAspect': 
Unsatisfied dependency expressed through field 'noteRelationService': 
Error creating bean with name 'noteRelationServiceImpl': 
Unsatisfied dependency expressed through field 'noteTagMappingMapper': 
Error creating bean with name 'noteTagMappingMapper' defined in file [...]: 
Cannot resolve reference to bean 'sqlSessionTemplate' 
while setting bean property 'sqlSessionTemplate'

第一眼看到 sqlSessionTemplate,我的直觉反应是:MyBatis 出了什么问题?或者是CheckMissingInfoAspect中的 SQL 相关的 Bean 出了什么问题?

但是我先打开CheckMissingInfoAspect看了一眼,然后我看不出来存在什么问题,于是乎拿着这个报错去问了一下 AI,它给我的回复就是可能在 Spring 的 Bean 生命周期有问题,于是去查了 Spring 的 Bean 生命周期和 MyBatis 的初始化流程,然后尝试把异常路径拼起来:

  • CheckMissingInfoAspect 是 AOP Bean,初始化时需要注入 NoteRelationService
  • NoteRelationService 实现类里注入了 NoteTagMappingMapper 等 Mapper;
  • Mapper Bean 的创建依赖 sqlSessionTemplate
  • 于是 sqlSessionTemplate 在某个时机被触发创建,但失败了。

切面类的依赖注入是这样的:

java 复制代码
public class CheckMissingInfoAspect {
    @Autowired private TransactionTemplate transactionTemplate;
    @Autowired private NoteRelationService noteRelationService;
    @Autowired private NoteCoreService noteCoreService;
    // ...
}

结合查到的资料,我当时的判断是:很有可能 AOP Bean 优先级较高,初始化过程中触发了 NoteRelationService 的提前加载,进而让 Mapper 过早尝试创建,导致此时 SqlSessionTemplate 还没准备好,于是整条链断掉了。

这个推论听起来相当合理。但很快,我发现了两处说不通的地方。


二、两个让我开始怀疑的地方

疑点一:StorageHandler 为什么没事?

项目里有另一个切面相关的类 StorageHandler,它也注入了多个 Mapper:

java 复制代码
public class StorageHandler {
    @Autowired private ImageMapper imageMapper;
    @Autowired private UserMapper userMapper;
    @Autowired private NoteMapper noteMapper;
    @Autowired private RoleMapper roleMapper;
    // ...
}

如果"AOP Bean 初始化时注入 Mapper,导致 SqlSessionTemplate 过早被创建"这个推论成立,那 StorageHandler 也应该出问题------它也是切面相关的类,它也注入了 Mapper。但它一直跑得好好的,因为它很久之前就存在了,一直没有任何报错。

一个推论如果解释不了所有现象,就应该被重新怀疑。这里这个推论明显有漏洞。问题不是简单的"切面类 + Mapper 注入 = 过早初始化"。

疑点二:加了 @Lazy 之后,异常开始漂移

既然怀疑是切面类和 Service 之间的初始化顺序问题,我尝试在部分注入点加上 @Lazy,让 Spring 推迟真实 Bean 的获取。

加完之后,sqlSessionTemplate 相关的异常确实消失了------但同样的异常,开始往 DataInitializer 等其他 Bean 传播。

异常没消失,只是漂移了。

这个现象让我意识到:SqlSessionTemplate 的报错很可能不是根因,它只是被异常链里某个更底层的问题拖出来的。我一直在追一个烟雾弹。


三、真正的转折:往最底层 Caused by 翻

意识到问题之后,我重新把完整异常栈从头到尾读了一遍,这次不再停在顶部,一路往最底层 Caused by 翻。

翻到最底部,发现了这个:

css 复制代码
Caused by: org.xml.sax.SAXParseException; 
lineNumber: 1; columnNumber: 14; 
不允许有匹配 "[xX][mM][lL]" 的处理指令目标。

这是一个 XML 解析错误。

问题本质是某个 Mapper XML 文件格式有问题------XML 声明行之前多了空白字符或者不可见字符,导致解析器在第一行第 14 列就直接拒绝读取。这个错误和 AOP 没关系,和循环依赖没关系,和 SqlSessionTemplate 也没关系。它就是一个最普通的文件格式错误,只不过藏在异常链的最底部,被上面层层的依赖注入失败给包住了。

修掉这个 XML 格式问题之后,SqlSessionTemplate 相关的异常全部消失,项目重新启动------这次 Spring 报出了一个干净的错误,真正的问题终于暴露出来了。

这里有一个教训值得记:Spring 启动失败时,最上层的异常往往是"最后一块倒掉的骨牌",它描述的是结果,不是原因。真正的原因在最底层的 Caused by 拿到一条几十行的异常栈,先不要急着分析第一行,先翻到最底部看看根因是什么。


四、修完 XML 后,真正的循环依赖出现了

修掉 XML 之后,正当我兴高采烈,准备可以启动项目的时候,Spring 给了我当头一棒:

makefile 复制代码
Description:

The dependencies of some of the beans in the application context form a cycle:

   checkMissingInfoAspect (field private com.jacolp.service.NoteCoreService 
       com.jacolp.aspect.CheckMissingInfoAspect.noteCoreService)
      ↓
   noteCoreServiceImpl (field private com.jacolp.service.AuditService 
       com.jacolp.service.impl.NoteCoreServiceImpl.auditService)
┌─────┐
|  auditServiceImpl (field private com.jacolp.service.TopicService 
|      com.jacolp.service.impl.AuditServiceImpl.topicService)
↑     ↓
|  topicServiceImpl (field private com.jacolp.service.AuditService 
|      com.jacolp.service.impl.TopicServiceImpl.auditService)
└─────┘

这个环的路径很清晰:

复制代码
CheckMissingInfoAspect
  → NoteCoreService
  → AuditService
  → TopicService
  → AuditService(回环)

好家伙,打完了小的,来了一个大的。

其实这个问题的出现会导致大家有一个疑惑,不是说 Spring 存在三级缓存缓解循环依赖问题吗?

其实,本质就是在纯业务层之间:AuditServiceTopicService 互相依赖,形成了一个环,这个环被 CheckMissingInfoAspect 的初始化链触发,导致 Spring 容器无法完成装配。

各位先别急,我们接下来慢慢分析。


五、Spring 三级缓存到底想解决什么

在说"为什么三级缓存没救回来"之前,先把三级缓存是什么梳理一下。这里只讲够用的部分,不做源码展开。

Spring 的三级缓存结构大致是这样:

复制代码
一级缓存(singletonObjects):已经完成初始化的单例对象,可能是原始对象,也可能是代理对象。  
二级缓存(earlySingletonObjects):提前暴露的早期单例引用,可能是原始对象,也可能是早期代理对象。  
三级缓存(singletonFactories):ObjectFactory,用于在需要时生成早期引用;涉及 AOP 时,可能在这里生成早期代理引用。

它主要用来解决这种场景:

css 复制代码
A 开始创建 → 把自己的 ObjectFactory 放入三级缓存
A 需要注入 B → 触发 B 的创建
B 需要注入 A → Spring 从三级缓存取出 A 的 ObjectFactory
             → 调用工厂生成 A 的早期引用(必要时生成代理)
B 拿到 A 的早期引用,完成创建
A 继续完成剩余初始化

通过这种机制,Spring 能处理一部分单例 Bean 之间由"属性注入"形成的循环依赖。关键在于,它能在 Bean 还没完全初始化好的情况下,提前暴露出一个可用的引用,打破相互等待的僵局。

但三级缓存有明确的边界条件:

  • 处理的是单例 Bean + 属性注入的循环依赖;构造器注入的循环依赖它处理不了,因为三级缓存依赖于可以存在一种属性未填充,但是可以提前弄出代理对象的条件,构造器注入天然无法满足"属性未填充"的条件。
  • 涉及 AOP 代理对象时,ObjectFactory 需要在合适的时机完成代理,如果此时 AOP 初始化本身还没结束,链路会出问题。(重点)
  • 最重要的一点:三级缓存是 Spring 容器层面的补救机制,它处理的是"Bean 创建过程中的依赖时序"问题,不是用来兜底业务模块架构设计的。

六、为什么这次三级缓存没救回来

把依赖链拆开来看,就清楚了。

CheckMissingInfoAspect 是切面类,初始化时需要注入 NoteCoreService

java 复制代码
public class CheckMissingInfoAspect {
    @Autowired private TransactionTemplate transactionTemplate;
    @Autowired private NoteRelationService noteRelationService;
    @Autowired private NoteCoreService noteCoreService;
    // ...
}

NoteCoreServiceImpl 又依赖 AuditService

java 复制代码
@Service
@Slf4j
public class NoteCoreServiceImpl implements NoteCoreService {
    @Autowired private NoteMapper noteMapper;
    @Autowired private AuditService auditService;
    // ...
}

AuditServiceImpl 依赖 TopicService

java 复制代码
@Slf4j
@Service
public class AuditServiceImpl implements AuditService {
    // ... 其他依赖
    @Autowired private TopicService topicService;
    // ...
}

TopicServiceImpl 反过来又依赖 AuditService

java 复制代码
@Service
@Slf4j
public class TopicServiceImpl implements TopicService {
    @Autowired private TopicMapper topicMapper;
    @Autowired private AuditService auditService;
    // ...
}

这条链走下来,发生的事情是这样的:

rust 复制代码
① AOP Bean(CheckMissingInfoAspect)开始初始化 -> CheckMissingInfoAspect 需要 NoteCoreService
② 触发 NoteCoreService 的创建 -> NoteCoreService 需要 AuditService
③ 触发 AuditService 的创建 -> AuditService 被放入三级缓存 -> AuditService 需要 TopicService
④ 触发 TopicService 的创建 -> TopicService 需要 AuditService
⑤ Spring 尝试从三级缓存取出 AuditService 的早期引用
⑥ 调用 getEarlyBeanReference() 尝试生成早期代理
⑦ 问题出现:早期引用暴露、AOP 代理创建和业务 Service 回环叠在一起,Spring 无法在这条链路里交出一个稳定可用的 Bean 引用

三级缓存在第 ⑤ 步确实发力了,但它碰到的局面是:整条链的顶端是一个 AOP Bean 的初始化,AOP 代理还没准备好,同时 AuditService 本身也处于"创建到一半"的状态。两边都在等,谁也等不到。

  • 这里我们再来一个流程图来看一下
flowchart TD subgraph AOP["切面类"] A["CheckMissingInfoAspect"] O["其他切面类"] end C["NoteCoreService"] D["AuditService"] E["TopicService"] A -->|"①"| C C -->|"②"| D D -->|"③"| E E -->|"④ 触发尝试获取早期引用"| D D -->|"⑥ 问题出现"| AOP

这里叠加了几个不利因素:

  1. AOP Bean 介入了初始化链的顶端,带来了代理时序约束;
  2. 业务 Service 之间形成了真实的依赖环;
  3. 环的节点(AuditService)同时也是需要被代理的对象;
  4. Spring 在尝试提前暴露早期引用时,缺少一个"已经准备好的代理"可以交出去。

这里我不能简单地说"三级缓存完全没工作",更准确地说,是这条依赖链已经超过了我应该让三级缓存兜底的范围。它不只是普通 A ↔ B 属性注入循环,而是 AOP 切面初始化、业务 Service 回环、代理对象早期暴露混在一起。即使 Spring 有早期引用机制,这种结构也不应该依赖容器去硬救。


七、为什么 @Lazy 不是根治方案

我尝试过给这条链路使用@Lazy来做问题依赖线的切断,加 @Lazy 之后,它可以让启动时不报循环依赖的错误。

首先我们大概来说一@Lazy的原理:在注入点使用 @Lazy 时,Spring 不会立即获取真实 Bean,而是先注入一个动态代理占位,等到真正调用这个依赖时才触发实际的 Bean 获取。这样可以绕过启动期的循环依赖检测。

flowchart TD A["某个被@Lazy修饰的Bean"] subgraph E["懒加载代理对象"] Ea["检查-单例/原型"] Eb["检查是否填充到属性"] Ec["getBean()"] Ed["使用 getMethod().invoke() 执行"] Ee["返回结果"] end B["Bean工厂"] A-->|"尝试使用 Bean 执行对应方法"|E Ea-->|"单例"|Eb Ea-->|"原型"|Ec Eb-->|"已填充"|Ed Eb-->|"未填充"|Ec Ec-->|"执行getBean()"|B Ec-->Ed Ed-->Ee

但绕过不等于解决。

AuditService → TopicService → AuditService 这个环还在,只是被延迟了。依赖方向没变,业务逻辑的耦合没变,@Lazy 只是把问题从启动期推迟到了运行期。如果之后运行时真的走到了这条依赖链,问题可能以更隐蔽的方式重新出现------而且那时候更难排查,因为启动期没有任何报错提示你这里有问题。

@Lazy 更像是一颗止痛药:在紧急情况下可以先让项目跑起来,给你时间去真正修复,但它本身不是修复。

如果业务模块已经互相依赖成环,长期靠 @Lazy 维持,代码会越来越难读,某天出了奇怪的运行时行为,你很可能根本意识不到是当初那个没解的环在作怪。


八、最后我是怎么改的:把互相调用改成 Facade 编排

真正的修复方向只有一个:让依赖方向变成单向的。

问题的根源在于 AuditServiceTopicServiceTagServiceImageService 之间互相调用。改之前的结构大致是这样:

graph TD AdminAuditController[admin-AuditController] --> auditService auditService --> TopicService auditService --> TagService auditService --> imageService auditService --> NoteCoreService auditService --> NoteRelationService TopicService --> auditService TagService --> auditService imageService --> auditService

几个 Service 都往 AuditService 里扎,AuditService 自己又调了别人,整个图不是 DAG,是一张网。

解决方案是引入 AuditFacade,把跨模块的编排逻辑上移:

graph TD AdminAuditController[admin-AuditController] --> AuditFacade UserAuditController[user-AuditController] --> AuditFacade AuditFacade --> auditService AuditFacade --> TopicService AuditFacade --> TagService AuditFacade --> imageService AuditFacade --> NoteCoreService AuditFacade --> NoteRelationService auditService --> MetaAuditMapper auditService --> ImageAuditMapper auditService --> NoteAuditMapper NoteCoreService --> NoteMapper NoteRelationService --> NoteEachMappingMapper NoteRelationService --> NoteTagMappingMapper NoteRelationService --> NoteImageMappingMapper

改完之后,每个 Service 只负责自己那块:

  • AuditService 专注操作审核相关的 Mapper,不再知道 TopicService 的存在;
  • TopicService 专注处理主题业务,不再知道 AuditService 的存在;
  • 跨模块的业务动作,比如"审核通过一篇笔记,同时更新主题计数、更新笔记状态、处理图片关系",全部交给 AuditFacade 来编排,由它依次调用各个 Service。

这样依赖图里不再有环,做拓扑排序是 DAG,Spring 也能正常完成 Bean 装配,项目终于顺利启动了。

这种改法还有一个附带好处:当你之后想知道"审核通过这个操作到底干了什么",只需要去 AuditFacade 里找,不需要跟着一串互相调用的 Service 绕圈子。可读性和可维护性都比之前好很多。


九、这次排障给我的几个结论

  1. Spring 启动失败时,不要只看最上层异常。 最上层的 UnsatisfiedDependencyException 通常是结果,不是原因,要一路翻到最底层 Caused by

  2. SqlSessionTemplate 出现在异常栈里,不一定说明 MyBatis 是根因。 它可能只是被更底层的问题拖垮的,第一眼看到不要急着往 MyBatis 配置方向排查。

  3. 如果一个推论解释不了所有现象,就应该重新怀疑它。 StorageHandler 没出问题,说明"AOP + Mapper 注入 = 过早初始化"这个推论本身就有问题,不能强行自圆其说。

  4. @Lazy 可以缓解启动问题,但不能替代依赖结构治理。 止痛药和手术不是一回事,该改依赖图的时候不要靠 @Lazy 拖着。

  5. 三级缓存解决的是 Bean 创建过程中的部分循环依赖,不是业务模块设计的免死金牌。 它的边界是明确的,不要指望它帮你兜底糟糕的架构。

  6. 真正应该做的是让依赖方向单向流动。 把跨模块的编排逻辑放到 Facade,底层 Service 之间尽量不要互相知道对方。依赖图是 DAG,Spring 才能优雅地处理。


十、小结

回顾整个排障过程:问题的外观是 sqlSessionTemplate 创建异常,顺着这条线查进了"AOP 过早触发 Bean 初始化"的误判,加了 @Lazy 发现异常漂移而不是消失,最后翻到最底层才发现是一个 XML 格式错误遮住了真正的循环依赖。

修掉 XML,循环依赖才暴露出来;看清楚依赖图,才发现根因是 AuditServiceTopicService 之间互相依赖成环。

这次绕的弯路不少,但每一步其实都有迹可循:推论解释不了 StorageHandler 正常这个事实,说明推论有问题;加了 @Lazy 问题漂移不消失,说明根因不在那里;不翻到最底层就看不到真正的硬错误。

这篇文章真正想说的不是"Spring 三级缓存为什么不行"。三级缓存没有不行,它在自己的边界范围内一直在发力。需要反思的,是我自己把业务依赖图写成了一个不该让 Spring 承担的环。

Spring 可以帮我们兜住一部分 Bean 创建问题,但它不应该替我们承担糟糕的模块依赖设计。


相关推荐
铁皮饭盒1 小时前
Bun 的三种并发"暗器":reusePort、Worker、spawn,能硬刚 Java 吗?
前端·javascript·后端
Nturmoils1 小时前
从 MySQL 到 KingbaseES:Database、Schema、User 一次讲透
数据库·后端
CodeSheep1 小时前
宇树科技,即将上市!
前端·后端·程序员
MacroZheng1 小时前
这款DeepSeek V4终端编程神器,在GitHub上火了!
人工智能·后端·deepseek
虎妞05001 小时前
Go 语言并发模型深度解析:GMP 与实战模式
后端·go语言·并发编程·gmp·goroutine
techdashen1 小时前
深入理解 Rust Futures:从零开始,一头扎到底
开发语言·后端·rust
AskHarries1 小时前
为什么不要一开始做平台
后端
IT_陈寒2 小时前
Redis缓存雪崩,原来我一直在用错误的方式设置过期时间
前端·人工智能·后端
qq_2518364572 小时前
基于Spring Boot的数据标注与质检系统设计与实现
java·spring boot·后端