循环依赖加 @Lazy 后异常漂移?Spring 三级缓存为什么没兜住?
阅读说明
本文是一篇真实项目排障复盘,不是 Spring 三级缓存源码教程。
文章背景来自我个人的 SaaS 笔记系统。之前有一段时间对笔记模块做了一次职责拆分,把一个 2500+ 行的"上帝类"拆解成若干职责单一的 Service 和切面组件。这篇文章不讨论拆分过程本身,只聚焦拆完之后暴露出的 Spring 启动问题。
重点在三件事:排障链路的推进过程、中途的误判和修正、以及最后的依赖结构调整。如果你想看三级缓存源码分析,这篇可能不是你想要的;但如果你曾经在 Spring 启动报错里迷失过方向,也许这篇有点参考价值。
如果只看文中的代码片段还不够,我也把当时时间线上的代码 Fork 了一个探索分支,可以配合文章一起看:
本篇核心收获
- Spring 启动异常不能只看最上层,要一路翻到最底层
Caused by,上层往往是被下层拖垮的。 SqlSessionTemplate出现在异常链里,不一定说明 MyBatis 是根因。- 三级缓存能解决一部分循环依赖,但解决不了混乱的业务依赖方向。
@Lazy可以缓解启动问题,但不是工程上的根治方案。
写在前面
前段时间在做个人 SaaS 笔记系统的一次模块重构。笔记核心服务之前是一个典型的"上帝类",将近 2500 行,几乎什么逻辑都往里塞。做了一次职责拆分之后,变成了 NoteFacade、NoteCoreService、NoteContextService、NoteRelationService 等几个组件,同时引入了 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 存在三级缓存缓解循环依赖问题吗?
其实,本质就是在纯业务层之间:AuditService 和 TopicService 互相依赖,形成了一个环,这个环被 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 本身也处于"创建到一半"的状态。两边都在等,谁也等不到。
- 这里我们再来一个流程图来看一下
这里叠加了几个不利因素:
- AOP Bean 介入了初始化链的顶端,带来了代理时序约束;
- 业务 Service 之间形成了真实的依赖环;
- 环的节点(
AuditService)同时也是需要被代理的对象; - Spring 在尝试提前暴露早期引用时,缺少一个"已经准备好的代理"可以交出去。
这里我不能简单地说"三级缓存完全没工作",更准确地说,是这条依赖链已经超过了我应该让三级缓存兜底的范围。它不只是普通 A ↔ B 属性注入循环,而是 AOP 切面初始化、业务 Service 回环、代理对象早期暴露混在一起。即使 Spring 有早期引用机制,这种结构也不应该依赖容器去硬救。
七、为什么 @Lazy 不是根治方案
我尝试过给这条链路使用@Lazy来做问题依赖线的切断,加 @Lazy 之后,它可以让启动时不报循环依赖的错误。
首先我们大概来说一@Lazy的原理:在注入点使用 @Lazy 时,Spring 不会立即获取真实 Bean,而是先注入一个动态代理占位,等到真正调用这个依赖时才触发实际的 Bean 获取。这样可以绕过启动期的循环依赖检测。
但绕过不等于解决。
AuditService → TopicService → AuditService 这个环还在,只是被延迟了。依赖方向没变,业务逻辑的耦合没变,@Lazy 只是把问题从启动期推迟到了运行期。如果之后运行时真的走到了这条依赖链,问题可能以更隐蔽的方式重新出现------而且那时候更难排查,因为启动期没有任何报错提示你这里有问题。
@Lazy 更像是一颗止痛药:在紧急情况下可以先让项目跑起来,给你时间去真正修复,但它本身不是修复。
如果业务模块已经互相依赖成环,长期靠 @Lazy 维持,代码会越来越难读,某天出了奇怪的运行时行为,你很可能根本意识不到是当初那个没解的环在作怪。
八、最后我是怎么改的:把互相调用改成 Facade 编排
真正的修复方向只有一个:让依赖方向变成单向的。
问题的根源在于 AuditService、TopicService、TagService、ImageService 之间互相调用。改之前的结构大致是这样:
几个 Service 都往 AuditService 里扎,AuditService 自己又调了别人,整个图不是 DAG,是一张网。
解决方案是引入 AuditFacade,把跨模块的编排逻辑上移:
改完之后,每个 Service 只负责自己那块:
AuditService专注操作审核相关的 Mapper,不再知道TopicService的存在;TopicService专注处理主题业务,不再知道AuditService的存在;- 跨模块的业务动作,比如"审核通过一篇笔记,同时更新主题计数、更新笔记状态、处理图片关系",全部交给
AuditFacade来编排,由它依次调用各个 Service。
这样依赖图里不再有环,做拓扑排序是 DAG,Spring 也能正常完成 Bean 装配,项目终于顺利启动了。
这种改法还有一个附带好处:当你之后想知道"审核通过这个操作到底干了什么",只需要去 AuditFacade 里找,不需要跟着一串互相调用的 Service 绕圈子。可读性和可维护性都比之前好很多。
九、这次排障给我的几个结论
-
Spring 启动失败时,不要只看最上层异常。 最上层的
UnsatisfiedDependencyException通常是结果,不是原因,要一路翻到最底层Caused by。 -
SqlSessionTemplate出现在异常栈里,不一定说明 MyBatis 是根因。 它可能只是被更底层的问题拖垮的,第一眼看到不要急着往 MyBatis 配置方向排查。 -
如果一个推论解释不了所有现象,就应该重新怀疑它。
StorageHandler没出问题,说明"AOP + Mapper 注入 = 过早初始化"这个推论本身就有问题,不能强行自圆其说。 -
@Lazy可以缓解启动问题,但不能替代依赖结构治理。 止痛药和手术不是一回事,该改依赖图的时候不要靠@Lazy拖着。 -
三级缓存解决的是 Bean 创建过程中的部分循环依赖,不是业务模块设计的免死金牌。 它的边界是明确的,不要指望它帮你兜底糟糕的架构。
-
真正应该做的是让依赖方向单向流动。 把跨模块的编排逻辑放到 Facade,底层 Service 之间尽量不要互相知道对方。依赖图是 DAG,Spring 才能优雅地处理。
十、小结
回顾整个排障过程:问题的外观是 sqlSessionTemplate 创建异常,顺着这条线查进了"AOP 过早触发 Bean 初始化"的误判,加了 @Lazy 发现异常漂移而不是消失,最后翻到最底层才发现是一个 XML 格式错误遮住了真正的循环依赖。
修掉 XML,循环依赖才暴露出来;看清楚依赖图,才发现根因是 AuditService 和 TopicService 之间互相依赖成环。
这次绕的弯路不少,但每一步其实都有迹可循:推论解释不了 StorageHandler 正常这个事实,说明推论有问题;加了 @Lazy 问题漂移不消失,说明根因不在那里;不翻到最底层就看不到真正的硬错误。
这篇文章真正想说的不是"Spring 三级缓存为什么不行"。三级缓存没有不行,它在自己的边界范围内一直在发力。需要反思的,是我自己把业务依赖图写成了一个不该让 Spring 承担的环。
Spring 可以帮我们兜住一部分 Bean 创建问题,但它不应该替我们承担糟糕的模块依赖设计。