上周把公司的一个内部工具升级到Spring Boot 3.2,顺手开了AOT编译,想试试GraalVM原生镜像。启动速度确实快了,但一跑反射代码全挂了。记录一下踩坑过程,供有同样计划的朋友参考。

什么是AOT编译

传统Java是JIT(Just-In-Time),运行时编译。AOT(Ahead-of-Time)是编译时就把代码转成机器码,运行时直接执行,省了JIT预热时间。

Spring Boot 3.2支持AOT,配合GraalVM原生镜像,启动速度提升明显,内存占用降低。听起来美好,但实际有坑。

我的项目背景

内部工具,功能不复杂:读取配置、连接数据库、执行定时任务、输出报表。用了Spring Boot 3.2 + MyBatis-Plus + PostgreSQL。

开AOT编译

<!-- pom.xml -->
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
    <configuration>
        <imageName>report-tool</imageName>
    </configuration>
</plugin>

执行 mvn spring-boot:process-aot 生成AOT元数据,再 mvn native:compile 编译原生镜像。

编译成功,启动时间从8秒降到1.2秒,内存从512MB降到180MB。爽。

一跑就挂

问题一:MyBatis-Plus的Mapper接口

@Mapper
public interface ReportMapper extends BaseMapper<Report> {
    @Select("SELECT * FROM report WHERE create_time > #{startTime}")
    List<Report> selectByTime(@Param("startTime") LocalDateTime startTime);
}

报错: MapperProxyFactory 找不到。MyBatis-Plus的Mapper接口是动态代理生成的,AOT编译时不知道哪些接口要生成代理。

解决:加反射配置

// reflect-config.json
{
  "name": "com.example.mapper.ReportMapper",
  "allDeclaredMethods": true
}

但项目有20个Mapper,手写不现实。用MyBatis-Plus的 @MapperScan ,AOT扫描不到。

最后方案:写了个脚本,编译前扫描所有Mapper接口,自动生成 reflect-config.json 。

问题二:Jackson的序列化

public class Report {
    private LocalDateTime createTime;
    // getter/setter
}

报错: JavaTimeModule 找不到。Jackson的 LocalDateTime 序列化用反射找 JavaTimeModule ,AOT编译时没注册。

解决:显式注册

@Bean
public ObjectMapper objectMapper() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    return mapper;
}

但项目里还有自定义的 Mixin 、自定义的 Serializer ,全部要显式注册。排查了一下午。

问题三:配置属性绑定

@ConfigurationProperties(prefix = "report")
public class ReportProperties {
    private String outputPath;
    private List<String> emailReceivers;
}

报错: ConfigurationPropertiesBindingPostProcessor 找不到属性。AOT编译时, @ConfigurationProperties 的绑定用反射找setter方法,但AOT没生成。

解决:加 @Bindable

@Bindable
@ConfigurationProperties(prefix = "report")
public class ReportProperties {
    // ...
}

但 @Bindable 是Spring Boot 3.2的新注解,文档里说"可选",实际AOT场景下是"必需"。

问题四:定时任务的Cron表达式

@Scheduled(cron = "${report.cron:0 0 1 * * ?}")
public void generateReport() {
    // ...
}

报错: CronExpression 解析失败。AOT编译时, @Scheduled 的cron表达式是字符串,运行时解析,但AOT没生成解析代码。

解决:用固定表达式

@Scheduled(cron = "0 0 1 * * ?") // 不能用占位符
public void generateReport() {
    // ...
}

或者配置类里动态注册:

@Bean
public TaskScheduler taskScheduler() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setCronExpression(reportProperties.getCron());
    return scheduler;
}

但这样失去了 @Scheduled 的简洁性。

最终配置

reflect-config.json 里加了200多个类, proxy-config.json 里加了50多个接口,资源文件里加了10个配置文件。

编译时间从30秒变成12分钟,生成的原生镜像从45MB变成78MB(反射配置占空间)。

问题五:动态数据源

项目有动态数据源切换,根据用户选择切换不同数据库。AOT编译时,数据源是固定的,运行时切换报错。

解决:放弃动态数据源

改成固定数据源,或者启动时加载所有数据源配置。但这样失去了动态性,代码改动大。

问题六:日志配置

logback-spring.xml 里有 springProfile 动态配置,AOT编译时解析不了,运行时日志格式错乱。

解决:写死日志配置

去掉 springProfile ,写死 dev 和 prod 两套配置,编译时选一套。

现在的用法

内部工具,简单场景,AOT编译用着还行。但复杂项目(动态代理、反射多、配置复杂),AOT的维护成本太高。

我的结论

AOT编译适合:

无状态微服务

启动速度敏感(Serverless、K8s弹性伸缩)

反射少、配置简单的项目

不适合:

复杂企业应用(MyBatis、大量反射)

动态配置多的项目

需要频繁调试的项目(编译时间太长)

一句话总结

AOT编译是双刃剑,启动快了,但反射、动态代理、配置绑定都要显式处理。简单项目收益明显,复杂项目维护成本可能抵消收益。

最后说个槽点

Spring Boot 3.2的AOT文档,示例全是简单CRUD,没有MyBatis、没有复杂配置。我踩的坑,文档里一个都没提。官方说"生产就绪",实际"生产踩坑"。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐