Administrator
发布于 2023-11-01 / 6545 阅读
81

Spring 循环依赖与 AOT 的冲突

为了启动快,走上 GraalVM 原生镜像

FaaS 场景下单实例冷启动 4 秒太慢,用户第一次请求要等很久。我们想用 GraalVM 把订单服务编译成原生镜像,目标启动 50ms 内。Spring Boot 3 的 AOT 引擎能提前做大部分 Bean 的初始化分析,理论上开箱即用。可一上来就卡在循环依赖上——这成了我们做 AOT 化的第一道坎。

循环依赖在原生镜像下为什么崩

Spring 常规运行期解决循环依赖靠三级缓存和"提前暴露半成品 Bean"。但原生镜像在构建期(build time)就要确定所有 Bean 的创建顺序和依赖图,它无法像运行期那样动态补一个半成品引用。如果 A 依赖 B、B 又依赖 A:

@Component
public class A {
    @Autowired private B b;
}
@Component
public class B {
    @Autowired private A a;  // 和 A 互相依赖
}

Spring AOT 在编译期分析依赖图时发现这个环,直接报错,构建中断:

Error: Cyclic dependency between beans 'a' and 'b' detected.
Native image build cannot resolve it at build time.

根因:运行期技巧在构建期失效

三级缓存那套"先放一个引用进去,等会儿再填属性"的逻辑,依赖运行时的反射和动态代理。GraalVM 在封闭世界假设(closed-world)下,所有可达代码必须在构建期确定,运行期不能凭空变出一个代理。所以循环依赖这种要靠运行期时机才能解开的环,构建期无解。这也是为什么 @Lazy 能临时绕过去——它把时机推到运行期,但代价是运行时才真正创建,背离了 AOT 的初衷。

重构是必要的,不是绕路

我们借机把耦合拆了。A 和 B 真正共享的是一段校验逻辑,抽成独立 Bean C:

@Component
public class C {  // 无依赖的纯逻辑
    public boolean validate(Order o) { ... }
}
@Component
public class A { @Autowired private C c; }
@Component
public class B { @Autowired private C c; }

环被打破,AOT 顺利分析出 A→C、B→C 的 DAG,构建通过。镜像启动 47ms,比 JVM 冷启动快 85 倍,FaaS 场景下用户体验立刻不一样。

顺带的好处

  • 当时用 @Lazy 临时捂住的几个循环,重构后全删了,代码更直白,新人也能看懂依赖;
  • Bean 作用域更清晰,原来图省事互相注入,现在职责单一,A 只关心校验;
  • 运行时反射少了,镜像更小,只有 68MB,比 JVM 版小了一个数量级;
  • 构建期就把依赖图算清,运行时少了大量反射,这也是启动快的关键之一。

小结

循环依赖在 GraalVM 原生镜像下不是"能不能配开关"的问题,而是构建期的硬限制。与其用 @Lazy 粉饰,不如借 AOT 化的契机把设计做对。顺便,Spring 6 已经开始在运行时对构造器注入的循环依赖告警,方向一致:别靠框架擦屁股,把依赖理顺。这次重构让我们整个订单域的 Bean 依赖更干净了。

参考