三级缓存到底缓存了什么:一次 @Transactional 代理把循环依赖炸在启动阶段的复盘
2026/8/4 22:05:53 网站建设 项目流程

title: 三级缓存到底缓存了什么:一次 @Transactional 代理把循环依赖炸在启动阶段的复盘
tags: [Spring, 循环依赖, 三级缓存, 源码分析, Java]
category: 后端


一个周三早上,订单服务起不来了

我们组的订单服务在测试环境发布后启动失败,控制台甩出这么一段:

org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'orderServiceImpl': Bean with name 'orderServiceImpl' has been injected into other beans [couponServiceImpl] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean.

关键词是最后半句:其他 bean 拿到的不是最终版本的 bean

改动本身很小:OrderServiceImpl上加了一个@Transactional,因为要把「扣库存 + 写订单」放进同一个事务。加之前一切正常,加之后启动直接崩。当时组里第一反应是「Spring 不是能自动解决循环依赖吗」,于是就有了这次把三级缓存从头翻了一遍的排查。

Spring Boot 版本 2.3.12,Spring Framework 5.2.15,JDK 8。这个组合很关键,后面会说为什么。

先把现场还原出来

去掉业务逻辑后,最小复现代码是这样:

@Service public class OrderServiceImpl implements OrderService { // 构造器之外的字段注入,Spring 允许循环依赖 @Autowired private CouponService couponService; @Override @Transactional(rollbackFor = Exception.class) // 罪魁祸首在这一行 public void createOrder(Long userId, Long skuId) { couponService.deduct(userId); // ... 写订单 } } @Service public class CouponServiceImpl implements CouponService { @Autowired private OrderService orderService; // 反向依赖,闭环形成 @Override public void deduct(Long userId) { // ... 扣券 } }

逐行看这段代码的要害:

  • 第 5 行@Autowired字段注入。Spring 只对字段/setter 注入的循环依赖提供了补救手段,构造器注入的循环依赖从来救不了。
  • 第 9 行@Transactional会让OrderServiceImpl在初始化后被 AOP 包成一个代理对象。加上这行之后,容器里最终存的不再是那个原始对象
  • 第 18 行的反向注入让两个 bean 形成闭环,谁先创建谁就要走「提前暴露」的流程。

问题就出在:CouponServiceImpl在半路拿到的是OrderServiceImpl的原始对象引用,而容器最后放进去的是代理对象。两个引用对不上,Spring 检测到不一致,直接抛异常。

三级缓存到底是哪三级

DefaultSingletonBeanRegistry的源码,三个 Map 的定义就在类顶部:

public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry { /** 一级缓存:完全初始化好的成品 bean */ private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256); /** 三级缓存:bean 的工厂,用来按需生成早期引用 */ private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16); /** 二级缓存:从三级工厂产出的早期引用(可能已经是代理) */ private final Map<String, Object> earlySingletonObjects = new HashMap<>(16); protected Object getSingleton(String beanName, boolean allowEarlyReference) { Object singletonObject = this.singletonObjects.get(beanName); if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) { synchronized (this.singletonObjects) { singletonObject = this.earlySingletonObjects.get(beanName); if (singletonObject == null && allowEarlyReference) { ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName); if (singletonFactory != null) { singletonObject = singletonFactory.getObject(); // 关键调用 this.earlySingletonObjects.put(beanName, singletonObject); this.singletonFactories.remove(beanName); } } } } return singletonObject; } }

这段代码值得逐行读:

  • 查找顺序是一级 → 二级 → 三级,逐级降级。命中一级说明 bean 已完全就绪,直接返回。
  • isSingletonCurrentlyInCreation是循环依赖的判定开关:只有当这个 bean 正在创建中,才允许去翻二三级缓存。
  • singletonFactory.getObject()是整个机制的核心,它不是简单地返回原始对象,而是执行一段可能产生代理的逻辑
  • 产出后立刻把结果挪到二级缓存并删掉三级工厂,保证同一个 bean 的早期引用只生成一次,多次注入拿到的是同一个对象。

很多人背八股背成「一级放成品、二级放半成品、三级放工厂」就停了。真正该问的是:为什么不能只用两级?答案就在getObject()里。

三级缓存的真正用途:给 AOP 留后门

三级缓存里放的工厂,是在doCreateBean里注册的:

// AbstractAutowireCapableBeanFactory#doCreateBean 片段 boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName)); if (earlySingletonExposure) { // 注册三级工厂:注意这里传的是 lambda,不是对象本身 addSingletonFactory(beanName, () -> getEarlyBeanReference(mbd, beanName, bean)); } // 填充属性(此处触发对 couponService 的注入,进而递归创建 CouponServiceImpl) populateBean(beanName, mbd, instanceWrapper); // 执行初始化,AOP 在这里正常织入 exposedObject = initializeBean(beanName, exposedObject, mbd); protected Object getEarlyBeanReference(RootBeanDefinition mbd, String beanName, Object bean) { Object exposedObject = bean; for (BeanPostProcessor bp : getBeanPostProcessors()) { if (bp instanceof SmartInstantiationAwareBeanPostProcessor) { SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp; // AbstractAutoProxyCreator 在这里提前把 bean 包成代理 exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName); } } return exposedObject; }

理解要点:

  • addSingletonFactory传进去的是lambda 而非对象。也就是说「要不要生成代理」这个决定被延后了——没有循环依赖就永远不执行,有循环依赖才执行。
  • getEarlyBeanReference会走一遍SmartInstantiationAwareBeanPostProcessor,AOP 的AbstractAutoProxyCreator正是在这里把 bean 提前包成代理。
  • 如果只有两级缓存(直接把原始对象放进二级),那么所有bean 在被提前暴露时都得先判断要不要代理,等于把 AOP 时机整体前移,破坏了「初始化后织入」的语义。三级缓存的价值就是这个「延迟决策」。

到这里,我们的报错为什么会发生也就清楚了:正常情况下getEarlyBeanReference提前生成的代理会被记住,最后exposedObject会和它保持一致。而我们那次的问题在于——OrderServiceImpl上还挂了一个自定义的BeanPostProcessor,它在postProcessAfterInitialization里又包了一层自定义代理,导致最终对象和早期暴露的代理不是同一个。Spring 在doCreateBean尾部做一致性校验时发现对不上,就抛了那个异常。

排查过程中最耗时间的两小时

说实话,最初我们完全跑偏了。因为异常信息里带着couponServiceImpl,我们花了将近两小时在优惠券服务上找问题,把它的依赖树画了一遍,甚至怀疑是 Feign 客户端的代理。

真正的转折点是加了一行调试:

@Component public class BeanRefDebugger implements ApplicationListener<ContextRefreshedEvent> { @Autowired private ApplicationContext ctx; @Override public void onApplicationEvent(ContextRefreshedEvent event) { Object order = ctx.getBean("orderServiceImpl"); CouponServiceImpl coupon = ctx.getBean(CouponServiceImpl.class); // 打印两处引用的 identityHashCode,看是不是同一个对象 System.out.println("容器中的 order = " + System.identityHashCode(order) + ", class = " + order.getClass().getName()); System.out.println("coupon 持有的 order = " + System.identityHashCode(ReflectionTestUtils.getField(coupon, "orderService"))); } }

打印结果一目了然:容器里的是OrderServiceImpl$$EnhancerBySpringCGLIB$$xxx,而couponServiceImpl手里握的是另一个 hash 的对象。问题从来不在优惠券服务,在我们自己那个多余的 BeanPostProcessor 上。

这个教训后来写进了组内的排查手册:遇到循环依赖报错,先别看异常里点名的那个 bean,先用identityHashCode把「容器里的」和「被注入的」两个引用打出来比一比,比读一小时源码管用。

几种解法的真实取舍

方案改动量副作用我的态度
spring.main.allow-circular-references=true一行配置把设计问题掩盖,Spring Boot 2.6+ 默认关它是有道理的不建议,只当临时止血
@Lazy注入一个注解注入的是代理,首次调用才初始化;调试栈变深可接受,适合救火
ObjectProvider<T>延迟获取改几行代码语义清晰,无隐式代理推荐,比@Lazy显式
抽公共逻辑到第三个 bean改动最大需要重新划分职责长期方案,我们最后选的这个
改用 setter 注入绕开构造器循环中等只是绕过,环还在不解决根因

我们最终的处理是:把OrderServiceImpl里调用优惠券的那段抽成OrderCouponFacade,让两个 service 都依赖它,环直接断掉。改完之后不仅启动正常,链路也清爽了——之前那个环本身就是职责划分没做好的信号。

如果只是想快速上线,ObjectProvider是我更推荐的临时方案:

@Service public class CouponServiceImpl implements CouponService { private final ObjectProvider<OrderService> orderServiceProvider; public CouponServiceImpl(ObjectProvider<OrderService> orderServiceProvider) { // 构造器里只存 provider,不触发 OrderService 的创建 this.orderServiceProvider = orderServiceProvider; } @Override public void deduct(Long userId) { // 真正用到时才去容器里取,此时对方已经是完整的代理对象 OrderService orderService = orderServiceProvider.getObject(); orderService.markCouponUsed(userId); } }

这段的好处是:构造器注入的语义保住了(依赖仍然是 final 的),同时把「取实例」的时机推到方法调用时,环在启动阶段就不成立了。相比@Lazy,读代码的人一眼能看出这里做了延迟,不需要去猜注解背后的行为。

复盘数据:几个真实数字

  • 从报错到定位根因:2 小时 40 分钟,其中 2 小时浪费在错误的 bean 上。
  • 涉及的 bean 数量:整个环只有 2 个类,但被间接牵连的自动装配 bean 有17 个
  • 升级到 Spring Boot 2.6.6 后同样的代码:启动阶段直接被allow-circular-references默认关闭拦下,报错更早、信息更明确。2.6 那个「不友好」的默认值,其实帮我们提前暴露了问题。
  • 重构后订单服务启动时间从 18.4s 降到 16.9s,虽然不是重构目标,但少了一层多余代理确实有收益。

我的判断

三级缓存是一套为已有设计缺陷兜底的补救机制,不是可以依赖的特性。Spring 官方从 2.6 开始默认禁止循环依赖,态度已经很明确了。

我不建议在新代码里靠allow-circular-references过日子。它能让你今天上线,但会让下一个人在某个周三早上对着BeanCurrentlyInCreationException发呆。真正划算的做法是:看到环,先怀疑职责划分,而不是先找 Spring 的开关

反过来说,如果你维护的是一个五年以上的老系统,几十个 bean 缠在一起,那么理解三级缓存的意义就完全不同了——它是你在不重构的前提下继续活下去的知识储备。这类系统里,ObjectProvider加注释说明是我见过性价比最高的处理方式。

留个问题

如果OrderServiceImpl改成构造器注入CouponService,三级缓存还救得了吗?为什么?

提示:想想addSingletonFactory是在实例化之后、属性填充之前调用的,而构造器注入需要在实例化那一刻就拿到依赖。

欢迎在评论区写下你的答案,也欢迎贴出你遇到过的最离谱的一个循环依赖现场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询