Administrator
发布于 2021-11-19 / 2296 阅读
39

Spring 循环依赖到底该不该允许

同事的提问:改成构造器注入就启动失败了

11 月初,组里在推"统一用构造器注入",小杨改完一个服务发现起不来了:

org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'orderService': Requested bean is currently in
creation: Is there an unresolvable circular reference?

他的疑惑很合理:同样的两个类,用 @Autowired 字段注入能正常跑,换成构造器注入就报循环依赖。代码是这样的:

// 原来:字段注入,能启动
@Service
public class OrderService {
    @Autowired
    private CouponService couponService;
}

@Service
public class CouponService {
    @Autowired
    private OrderService orderService;
}
// 改后:构造器注入,启动失败
@Service
public class OrderService {
    private final CouponService couponService;
    public OrderService(CouponService couponService) {
        this.couponService = couponService;
    }
}

要回答这个问题,得先说清楚 Spring 是怎么"允许"循环依赖的。

三级缓存:循环依赖是怎么被解决的

DefaultSingletonBeanRegistry 里有三个 Map:

/** 一级缓存:完整的单例 Bean,初始化全部完成 */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

/** 二级缓存:提前暴露的半成品 Bean,属性还没填完 */
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);

/** 三级缓存:Bean 工厂,用来生成早期引用 */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

两个 Bean 循环依赖时的完整流程:

  1. 容器要创建 orderService,先标记它为"创建中"(singletonsCurrentlyInCreation 集合)。
  2. 实例化:调用 OrderService 的构造器,得到一个还没有注入任何属性的空对象。
  3. 把这个空对象包装成 ObjectFactory 放进三级缓存 singletonFactories
  4. 填充属性:发现需要 couponService,转去创建它。
  5. 创建 couponService,同样走实例化 → 三级缓存 → 填充属性。
  6. 填充时发现需要 orderService,于是去缓存里找:一级没有(还没创建完)、二级没有、三级有。调用 ObjectFactory.getObject() 拿到 orderService 的早期引用,放进二级缓存,并从三级缓存移除。
  7. couponService 拿到 orderService 的引用(是个半成品,但引用地址是对的),完成初始化,进一级缓存。
  8. 回到第 4 步,orderService 拿到 couponService,完成初始化,进一级缓存。

关键在于第 2 步和第 3 步的顺序:必须先实例化(调构造器),才能把引用暴露出去

而构造器注入的问题就出在这里——OrderService 的构造器需要一个 CouponService 实例,但那时候 orderService 还没实例化完,couponService 也没创建。两边都在等对方的构造器先跑完,死锁。所以构造器注入的循环依赖,三级缓存救不了

为什么三级缓存不是两级

这是个经典的追问。既然二级缓存存的是半成品对象,直接把实例化后的对象放进去不就行了?

答案是 AOP。如果 OrderService 被切面代理了,那注入给 CouponService 的应该是代理对象,而不是原始对象。但代理是在初始化完成后才生成的(AnnotationAwareAspectJAutoProxyCreatorpostProcessAfterInitialization)。

三级缓存存的是工厂而不是对象,就是为了把这个决策往后推。看源码里那段 ObjectFactory

addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
        for (BeanPostProcessor bp : getBeanPostProcessors()) {
            if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
                SmartInstantiationAwareBeanPostProcessor ibp =
                        (SmartInstantiationAwareBeanPostProcessor) bp;
                exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
            }
        }
    }
    return exposedObject;
}

只有在真的发生了循环依赖时,才会调用这个工厂提前生成代理。没有循环依赖的话,代理还是按正常时机在初始化后生成。这个设计挺精巧,但也说明 Spring 为了兼容循环依赖付了不少复杂度。

允许循环依赖的代价

上面那段流程看着很完美,但有个细节很危险:第 6 步注入进 couponServiceorderService,是一个 @PostConstruct 还没执行、属性还没填完的对象

如果 CouponService 在构造器或者初始化方法里直接用了 orderService 的某个字段,拿到的就是 null。这种 bug 特别难查,因为字段名看起来明明注入了。

@Service
public class CouponService {
    @Autowired
    private OrderService orderService;

    @PostConstruct
    public void init() {
        // 这里 orderService 可能还只是个半成品,它的内部字段可能还是 null
        orderService.someMethod();     // 潜在的 NPE,而且和 Bean 创建顺序有关
    }
}

更麻烦的是它和行为和 Bean 的创建顺序绑定:A 先创建和 B 先创建,结果可能不一样。改一个类名、加一个 Bean 都可能触发。

所以 Spring Boot 2.6 做了一个明确的决定。

Spring Boot 2.6:默认不允许循环依赖了

Spring Boot 2.6.0(2021 年 11 月 17 日发布)把 spring.main.allow-circular-references 的默认值改成了 false。升级之后,原来"能跑"的循环依赖会直接启动失败:

***************************
APPLICATION FAILED TO START
***************************

Description:

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

   orderController
      ↓
   orderService
      ↓
   couponService
      ↓
   orderService

Action:

Relying upon circular references is discouraged and they are prohibited by default.
Update your application to remove the dependency cycle between beans. As a last
resort, it may be possible to break the cycle automatically by setting
spring.main.allow-circular-references to true.

报错信息比之前友好太多,直接把环给画出来了。

我的看法是这个默认值改得对。循环依赖几乎总是设计问题的信号:两个类互相需要对方的全部能力,说明职责划分有问题

如果确实要临时放行(比如老系统一次性改不完):

spring:
  main:
    allow-circular-references: true

但这是缓兵之计,记个 TODO 尽快重构。

构造器注入 vs 字段注入

既然要改,顺便把这两种注入方式的争论说清楚。Spring 官方(包括 Spring Data 团队的 Oliver Drotbohm 在各种场合)推荐构造器注入,理由我觉得站得住脚:

维度构造器注入字段注入
不可变性可以声明 final,依赖不会被改字段必须是非 final,随时可改
依赖可见性构造器签名就是依赖清单,一眼看全扫一遍所有字段才知道
循环依赖启动时立刻暴露被三级缓存掩盖,可能埋雷
单元测试new OrderService(mockCouponService)必须靠 @MockBean 或者反射设字段
NPE 风险不可能注入失败(构造都过不了)绕过容器 new 出来的实例字段是 null
代码量依赖多时构造器很长,要写 Lombok 的 @RequiredArgsConstructor简洁

字段注入唯一的优势是写得少,而用 Lombok 的 @RequiredArgsConstructor 之后这个优势也没了:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final CouponService couponService;
    private final InventoryService inventoryService;
    private final OrderMapper orderMapper;
}

Lombok 会为所有 final 字段生成构造器,Spring 4.3 起单构造器场景不需要写 @Autowired。这段代码和字段注入一样短,但拿到了不可变性和明确依赖。

我自己的习惯:必需的依赖用构造器注入,可选的依赖(有默认值)用 setter 注入。字段注入基本不写了。

怎么重构掉循环依赖

我们那个 OrderServiceCouponService 互相依赖,最后是这样拆的。先看清它们各自需要对方什么:

// 重构前
OrderService  需要 CouponService 的:计算优惠金额
CouponService 需要 OrderService  的:查询订单商品明细

方案一(我们用的):抽出第三方。两个类都需要的"订单金额计算"逻辑既不属于订单也不属于优惠券,抽成独立的服务。

@Service
@RequiredArgsConstructor
public class OrderPriceCalculator {
    private final OrderItemRepository itemRepository;    // 从 OrderService 里挪出来
    private final CouponRuleRepository ruleRepository;   // 从 CouponService 里挪出来

    public PriceResult calculate(Long orderId) {
        List<OrderItem> items = itemRepository.findByOrderId(orderId);
        List<CouponRule> rules = ruleRepository.findActiveRules();
        return PriceResult.of(items, rules);
    }
}

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderPriceCalculator priceCalculator;    // 只依赖计算器
}

@Service
@RequiredArgsConstructor
public class CouponService {
    private final OrderPriceCalculator priceCalculator;    // 只依赖计算器
}

依赖方向变成 OrderService → CalculatorCouponService → Calculator,环没了。

方案二:事件驱动。如果一方只是"通知"另一方,不需要返回值,用事件最干净。

// CouponService 不再依赖 OrderService,只发事件
@Service
@RequiredArgsConstructor
public class CouponService {
    private final ApplicationEventPublisher publisher;

    public void writeOff(Long couponId, Long orderId) {
        // ... 核销逻辑
        publisher.publishEvent(new CouponUsedEvent(couponId, orderId));
    }
}

// OrderService 监听,单向依赖
@Service
public class OrderService implements ApplicationListener<CouponUsedEvent> {
    @Override
    public void onApplicationEvent(CouponUsedEvent event) {
        // 更新订单的优惠金额
    }
}

注意 @TransactionalEventListener 的坑:默认的 ApplicationEvent 是同步的,在发布者的事务里执行。如果需要事务提交后才处理,用 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

方案三:依赖倒置。定义一个接口,让依赖方向反过来。

public interface OrderQueryService {
    List<OrderItem> getItems(Long orderId);
}

@Service
public class CouponService {
    private final OrderQueryService orderQuery;    // 依赖抽象,不依赖具体
}

@Service
public class OrderService implements OrderQueryService {
    // 实现接口,不再需要 CouponService
}

方案四:@Lazy。这是最后的选择,本质上没有解决设计问题,只是把注入时机推迟。

@Service
@RequiredArgsConstructor
public class OrderService {
    @Lazy
    private final CouponService couponService;    // 注入一个代理,第一次调用时才真正创建
}

@Lazy 会注入一个 CGLIB 代理,第一次调用方法时才去容器里拿真实的 Bean。能用,但把问题藏起来了,我不推荐,除非是改不动的遗留代码。

我们的改造结果

整个服务里有 5 处循环依赖(在 Spring Boot 2.6 升级时全部暴露出来):

位置处理方式
OrderService ↔ CouponService抽出 OrderPriceCalculator
UserService ↔ MemberLevelService抽出 MemberLevelCalculator
PayService → NotifyService → PayServiceNotifyService 改成监听 PaySuccessEvent
ReportService ↔ ExportServiceExportService 依赖 ReportQuery 接口(依赖倒置)
SmsService ↔ TemplateServiceTemplateService 不再需要 SmsService,删掉反向调用即可

5 处改完,代码行数净减少 180 行(抽出的类各有复用),单元测试里去掉了 12 个 @MockBean。启动时间没变化,但心里踏实多了。

小结

  • 三级缓存的解决能力有限:只能处理单例 + 非构造器注入。prototype 和构造器注入的循环依赖直接报错。
  • 三级缓存存 ObjectFactory 而不是对象,是为了在发生循环依赖时才提前生成 AOP 代理。没有循环依赖时,代理仍在初始化后按正常时机生成。
  • 允许循环依赖的隐患:注入的对方可能是 @PostConstruct 还没跑的半成品,且行为依赖 Bean 创建顺序。这类 bug 极难复现。
  • Spring Boot 2.6 起 spring.main.allow-circular-references 默认 false,升级会直接启动失败,报错信息会画出完整的环,照着改就行。
  • 重构优先级:抽第三方 > 事件驱动 > 依赖倒置 > @Lazy@Lazy 只是藏问题,别当常规手段。
  • 构造器注入配合 Lombok 的 @RequiredArgsConstructor,代码量和字段注入差不多,还能拿到不可变性和明确的依赖清单。Spring 4.3 起单构造器不用写 @Autowired

参考