三级缓存到底缓存了什么:一次 @Transactional 代理把循环依赖炸在启动阶段的复盘

📅 发布时间:2026/8/4 22:05:57
三级缓存到底缓存了什么:一次 @Transactional 代理把循环依赖炸在启动阶段的复盘 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.12Spring Framework 5.2.15JDK 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 MapString, Object singletonObjects new ConcurrentHashMap(256); /** 三级缓存bean 的工厂用来按需生成早期引用 */ private final MapString, ObjectFactory? singletonFactories new HashMap(16); /** 二级缓存从三级工厂产出的早期引用可能已经是代理 */ private final MapString, 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会走一遍SmartInstantiationAwareBeanPostProcessorAOP 的AbstractAutoProxyCreator正是在这里把 bean 提前包成代理。如果只有两级缓存直接把原始对象放进二级那么所有bean 在被提前暴露时都得先判断要不要代理等于把 AOP 时机整体前移破坏了「初始化后织入」的语义。三级缓存的价值就是这个「延迟决策」。到这里我们的报错为什么会发生也就清楚了正常情况下getEarlyBeanReference提前生成的代理会被记住最后exposedObject会和它保持一致。而我们那次的问题在于——OrderServiceImpl上还挂了一个自定义的BeanPostProcessor它在postProcessAfterInitialization里又包了一层自定义代理导致最终对象和早期暴露的代理不是同一个。Spring 在doCreateBean尾部做一致性校验时发现对不上就抛了那个异常。排查过程中最耗时间的两小时说实话最初我们完全跑偏了。因为异常信息里带着couponServiceImpl我们花了将近两小时在优惠券服务上找问题把它的依赖树画了一遍甚至怀疑是 Feign 客户端的代理。真正的转折点是加了一行调试Component public class BeanRefDebugger implements ApplicationListenerContextRefreshedEvent { 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-referencestrue一行配置把设计问题掩盖Spring Boot 2.6 默认关它是有道理的不建议只当临时止血Lazy注入一个注解注入的是代理首次调用才初始化调试栈变深可接受适合救火ObjectProviderT延迟获取改几行代码语义清晰无隐式代理推荐比Lazy显式抽公共逻辑到第三个 bean改动最大需要重新划分职责长期方案我们最后选的这个改用 setter 注入绕开构造器循环中等只是绕过环还在不解决根因我们最终的处理是把OrderServiceImpl里调用优惠券的那段抽成OrderCouponFacade让两个 service 都依赖它环直接断掉。改完之后不仅启动正常链路也清爽了——之前那个环本身就是职责划分没做好的信号。如果只是想快速上线ObjectProvider是我更推荐的临时方案Service public class CouponServiceImpl implements CouponService { private final ObjectProviderOrderService orderServiceProvider; public CouponServiceImpl(ObjectProviderOrderService 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是在实例化之后、属性填充之前调用的而构造器注入需要在实例化那一刻就拿到依赖。欢迎在评论区写下你的答案也欢迎贴出你遇到过的最离谱的一个循环依赖现场。