为了启动快,走上 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 依赖更干净了。