Spring Data AOT仓库性能对比:启动时间缩短15%的编译时革命  

编译时就把活儿干完!Spring Data AOT仓库如何让启动快得像个笑话  !Spring Data AOT仓库把运行时代理搬到编译时生成代码,启动时间从10秒降到8.7秒,构建时间翻倍但换来运行时反射清零。  

Java生态这几年卷性能卷出新高度。从JIT编译器到AOT编译,再到原生镜像,Spring和Micronaut这些大框架一路跟跑。Spring Boot 4搞出一个新玩意儿叫Spring Data AOT仓库,把原本应用启动时干的那些仓库初始化脏活累活,全部提前到编译时完成。  

我们用最朴素的User实体和UserRepository做了三组对比测试。第一组是传统Spring Data仓库,全靠运行时反射和动态代理。第二组是Spring 6的AOT优化版本,预编译了一些元数据。第三组就是最新的Spring Data AOT仓库,直接生成实现类的源代码。  

结果挺有意思。启动时间从10.1秒降到9.9秒再降到8.7秒,AOT仓库比传统方式快了将近15%。构建时间从11秒涨到17秒再涨到25秒,编译慢了一倍多,但换来运行时几乎零反射。内存占用反而略微上升,这个反转我们后面细说。  

下面我们按顺序拆解整个实验过程。  

依赖配置:Spring Boot 4.0.5里程碑版才能跑的实验

我们只用两个核心依赖跑通整个演示。第一个是spring-boot-starter-web,负责提供一个REST接口来验证仓库真的能干活。第二个是spring-boot-starter-data-jpa,包含Spring跟数据库打交道的全套工具。  


<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>4.0.5</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>4.0.5</version>
</dependency>


这里有个坑需要提前说清楚。Spring Boot主版本4目前只发布了早期里程碑版,我们用的是稳定的4.0.5。这意味着如果你想在生产环境复现这个实验,得先把项目迁移到Spring Boot 4,这事儿本身就够写另一篇文章了。  

依赖配完之后,整个项目结构就是一个标准的Spring Web应用加上JPA数据访问层。我们故意把依赖减到最少,就是为了排除其他组件对性能测试的干扰。  

领域模型:三字段的User实体和五个查询方法的仓库

我们设计了一个极其简单的User实体,只包含id、firstName和lastName三个字段。这个模型足够演示所有类型的仓库方法,又不会因为字段太多影响性能测试的准确性。  

java
@Entity
@Table(name = "USERS")
public class User {
    @Id
    @GeneratedValue
    private Long id;
    @Column(name = "first_name")
    private String firstName;
    @Column(name = "last_name")
    private String lastName;
    // 构造方法、setter、getter、equals等
}

实体类用了标准的JPA注解,没有任何黑魔法。@GeneratedValue让数据库自动生成主键,@Column指定了数据库列名。这种写法跟传统Spring Data项目完全一致。  

对应的UserRepository接口继承了Repository父接口,我们故意没继承JpaRepository,就是为了更清楚地展示AOT仓库生成代码的细节。接口里定义了五个方法。  

第一个是save方法,这是Spring Data内置实现的方法,不需要我们写任何代码。第二个是findAll方法,同样由框架自动翻译成SQL。第三个是findAllById,按主键批量查询,也是自动翻译。  

第四和第五个方法都用@Query注解标记了。第四个是nativeQueryFindAllUsers,用的是原生SQL查询,直接写SELECT * FROM users。第五个是queryFindAllUsers,用的是JPQL查询,写SELECT u FROM User u。  

public interface UserRepository extends Repository<User, Long> {
    User save(User user);
    @Transactional(readOnly = true)
    List<User> findAll();
    List<User> findAllById(Iterable<Long> longs);
    @Query(value = "SELECT * FROM users", nativeQuery = true)
    List<User> nativeQueryFindAllUsers();
    @Query(value =
"SELECT u FROM User u")
    List<User> queryFindAllUsers();
}


这么设计的目的很明确。我们想看看AOT仓库对三种方法类型分别怎么处理。第一种是框架自动翻译的派生查询方法,第二种是用JPQL注解的查询,第三种是用原生SQL注解的查询。  

传统方式:运行时反射加动态代理,启动慢得像个树懒

在没有AOT优化的年代,我们执行mvn clean install编译代码,得到的只是一个普通的class文件。Java编译器不会对仓库接口做任何特殊处理,该什么样还是什么样。  

真正的魔法发生在应用启动时。Spring启动后会扫描所有Repository接口,把它们交给JpaRepositoryFactory。这个工厂会为每个接口创建一个SimpleJpaRepository实例,然后包装成一个动态代理对象。  

当你调用userRepository.findAll()方法时,动态代理会把调用路由到SimpleJpaRepository对应的实现方法上。如果是自定义的查询方法,代理还会解析方法名、生成JPQL、创建Query对象,整个过程全是运行时反射。  

这意味着什么?意味着你的应用每次启动都要重复做同一套解析工作。方法名解析、实体元数据读取、查询语句生成,这些东西明明编译时就能确定,偏偏要等到用户点击启动按钮之后才开始算。  

我们实测这个模式下启动耗时10.1秒,内存占用289MB。对于只有一个实体类和一个仓库接口的最小应用来说,这个启动时间已经算慢了。如果是大型项目有几十个仓库,启动时间轻松突破一分钟。  

过渡方案:Spring 6 AOT加反射元数据预计算,启动时间勉强挤进10秒

Spring 6引入了AOT编译优化,但还没有专门针对Spring Data做深度改造。要启用这个特性,我们需要做两件事。  

第一件事是在编译时执行spring-boot:process-aot任务,或者在构建插件里把AOT编译设成默认开启。第二件事是在启动应用时把spring.aot.enabled属性设为true。  

这个模式下的仓库实现跟传统方式没有本质区别。Spring仍然使用SimpleJpaRepository,仍然用反射,仍然用动态代理,仍然在运行时解析方法名和生成查询语句。  

唯一的改进是AOT编译过程会生成一个额外的类,叫UserRepository__BeanDefinitions。这个类里预计算了一些反射元数据和其他信息,相当于提前把运行时要反射读取的部分数据写死在字节码里。  

我们实测这个模式的启动耗时降到了9.9秒,内存占用291MB。启动时间只改善了不到200毫秒,内存反而还涨了一点。这个结果让人有点哭笑不得,花了大功夫搞AOT编译,结果就这?  

更尴尬的是,这个模式下如果JPQL方法写错了语法错误,编译阶段完全发现不了。你得等到应用启动,Spring Data去解析那个方法的时候才会抛异常。这种延迟报错在大型项目里特别讨厌,一个笔误就能让整个应用起不来。  

AOT仓库:编译时生成实现类源代码,反射清零启动时间砍到8.7秒

Spring Data 4带来的AOT仓库才是真正的重头戏。这个特性把原本应用启动时做的所有仓库准备工作,全部挪到了编译时完成。  

具体怎么做的?Spring会针对你的每个仓库接口,生成一个完整的实现类源代码。生成过程依赖存储特定类型的仓库语义,比如JPA模块知道怎么把findAll翻译成SELECT u FROM User u。  

对于已经实现好的方法,比如save方法,SimpleJpaRepository里已经有具体代码了,Spring就不需要再生成。但对于那些派生查询方法和@Query注解的方法,Spring会直接生成实现代码。  

我们来看编译后target/classes文件夹里多出来的UserRepositoryImpl__AotRepository类。这个类继承了AotRepositoryFragmentSupport,包含两个主要字段和一个构造方法。  

第一个字段是RepositoryFactoryBeanSupport.FragmentCreationContext,用于片段创建上下文。第二个字段是EntityManager,这才是真正干活的东西。  

最精彩的部分是那两个查询方法的具体实现。nativeQueryFindAllUsers方法里,Spring直接写死了SQL语句字符串"SELECT * FROM users",然后调用entityManager.createNativeQuery执行。  

queryFindAllUsers方法也是类似,写死了JPQL字符串"SELECT u FROM User u",然后用createQuery生成Query对象再执行。  

java
@Generated
public class UserRepositoryImpl__AotRepository extends AotRepositoryFragmentSupport {
    private final RepositoryFactoryBeanSupport.FragmentCreationContext context;
    private final EntityManager entityManager;
    public List nativeQueryFindAllUsers() {
        String var1 = "SELECT * FROM users";
        Query var2 = this.entityManager.createNativeQuery(var1, User.class);
        return var2.getResultList();
    }
    public List queryFindAllUsers() {
        String var1 = "SELECT u FROM User u";
        Query var2 = this.entityManager.createQuery(var1);
        return var2.getResultList();
    }
}

你看这个代码,跟你自己手写DAO实现类几乎一模一样。这就是关键所在。Spring不再需要动态代理,不再需要运行时反射解析方法名,直接调用这个生成类的具体方法就行。  

除了这个实现类,AOT仓库还生成了两个额外文件。一个是UserRepository__BeanDefinitions,跟之前版本的AOT一样,包含Bean定义的元数据。另一个是UserRepository.json,这是个给native-image工具用的提示文件,告诉GraalVM哪些反射配置需要保留。  

要启用这个特性,启动命令里需要同时设置两个属性。spring.aot.enabled必须为true,spring.aot.repositories.enabled也必须为true。  

bash
mvn spring-boot:run -Dspring.aot.enabled=true -Dspring.aot.repositories.enabled=true

我们实测这个模式的启动时间降到了8.7秒,内存占用292MB。跟传统模式比,启动快了1.4秒,降幅将近15%。跟过渡模式比,启动快了1.2秒,降幅也有12%。  

内存占用反而从289MB涨到了292MB,这个反转很有意思。按理说反射少了应该内存更低才对。原因在于编译时生成的额外类文件加载到JVM元空间里,占了一部分内存。  

构建时间对比:编译慢一倍换运行时快15%,这个买卖看你认不认

我们测了三组构建时间,每组执行五次取平均值。传统模式用mvn clean install,没有任何AOT处理,总耗时11秒。  

过渡模式启用AOT但不启用AOT仓库,编译时多跑了process-aot任务,总耗时17.2秒,比传统模式慢了55%。  

AOT仓库模式同时启用AOT和AOT仓库,编译时除了process-aot还要生成仓库实现类和JSON提示文件,总耗时25.4秒,比传统模式慢了129%,比过渡模式慢了48%。  

构建时间翻了一倍多,这个代价不小。但对于生产环境来说,构建一次跑25秒还是跑11秒,区别其实不大。因为构建通常是CI/CD流水线做的,开发者本地也就一天跑几次。  

更重要的是,启用AOT仓库后,JPQL方法的语法错误会在编译阶段直接报错,而不是等到运行时才暴露。这个收益远超构建时间增加的代价。  

试想一下这个场景。你写了一个复杂的@Query,里面JOIN了四个表,写错了一个字段名。传统模式下你得等到应用启动,Spring Data解析那个方法的时候才会抛异常。如果这个仓库方法在某个边缘功能里,可能部署到生产环境一周后才被第一次调用,那时候才炸。  

有了AOT仓库,同样的错误在编译时就红了。mvn compile直接失败,IDE里也实时提示错误。这个体验的提升,比那14秒的构建时间重要得多。  

启动时间对比:从10.1秒到8.7秒,每次重启都能省出喝口水的时间

我们写了一个自动化脚本做启动时间测试。脚本同时做两件事,第一件启动Spring Boot应用,第二件每秒轮询一个调用仓库方法的REST接口。  

当接口第一次返回200状态码时,脚本记录当前时间戳,减去启动命令发起的时间戳,得到的就是应用真正可用的启动耗时。这个方法比看控制台日志里的Started Application更准确,因为它验证了仓库真的能干活。  

传统模式跑了10轮,平均启动时间10.15秒,最快一次9.98秒,最慢一次10.42秒。内存RSS平均289MB,CPU时间平均28秒。  

过渡模式平均启动时间9.89秒,最快9.71秒,最慢10.13秒。内存291MB,CPU时间25秒。启动快了0.26秒,提升约2.6%,几乎可以忽略不计。  

AOT仓库模式平均启动时间8.75秒,最快8.51秒,最慢9.02秒。内存292MB,CPU时间23秒。跟传统模式比快了1.4秒,提升约14.8%。跟过渡模式比快了1.14秒,提升约11.5%。  

这个提升在微服务和Serverless环境里尤其值钱。AWS Lambda的冷启动按毫秒计费,每个请求的冷启动时间直接决定账单金额。如果你的Spring Boot函数用了AOT仓库,每次冷启动省下1.4秒,一天调用一万次就省下将近四个小时的计费时间。  

还有一个细节值得提。启动日志里,传统模式打印的是Starting Application。AOT模式打印的是Starting AOT-processed Application。这行字虽然小,但代表了Spring官方对这两种启动路径的明确区分。  

负载测试:吞吐量和平均耗时反而没赢,这个反转有点尴尬

我们做了第三组测试,用JMeter脚本向每个实现发送持续五分钟的流量。每秒发送50个请求,每个请求调用一次UserRepository的findAll方法。  

传统模式在五分钟内处理了6688个请求,成功率100%,平均响应时间51.8毫秒,最大内存占用332MB。  

过渡模式处理了7664个请求,成功率100%,平均响应时间43.9毫秒,最大内存占用334MB。这个结果明显优于传统模式,处理量多了14.6%,响应时间快了15.2%。  

AOT仓库模式处理了6673个请求,成功率100%,平均响应时间49.1毫秒,最大内存占用338MB。这个结果很有意思,它甚至比传统模式还略差一点,处理量少了0.2%,响应时间慢了5.2%。  

为什么AOT仓库在负载测试里没有赢?我们仔细分析了可能的原因。  

第一个原因是JIT编译器的优化。传统模式和过渡模式虽然启动慢,但运行一段时间后,JIT会把热点代码编译成机器码。动态代理和反射的调用在JIT优化后,性能其实不差。  

第二个原因是代码体积。AOT仓库生成的实现类代码是直接写死的字符串和查询逻辑,缺少了JIT可以做的内联优化空间。传统模式的动态代理虽然启动慢,但JIT可以针对实际调用路径做激进优化。  

第三个原因是测试规模。我们只用了五个实体类和五个仓库接口,这个规模太小了。JIT还没来得及做深度优化,测试就跑完了。Spring官方博客上展示的内存和性能提升,是在上百个实体类的大项目里测出来的。  

这个反转恰恰说明了性能优化的一个基本原则。没有银弹。AOT仓库主要解决的是启动时间和反射开销,不是运行时吞吐量。如果你的应用需要长时间运行并且对启动时间不敏感,传统模式加上JIT可能更合适。  

总结

Spring Data AOT仓库把运行时代理搬到编译时生成代码,启动时间从10.1秒降到8.7秒,构建时间从11秒涨到25秒,运行时反射几乎清零。  

我们实测了这个特性在Spring Boot 4.0.5上的表现。启动时间改善了15%,但内存占用反而略微上升,负载测试下吞吐量也没有明显提升。  

这个取舍的核心在于你的使用场景。如果是Serverless、微服务、函数计算这类频繁冷启动的环境,AOT仓库的收益非常明显。

如果是长时间运行的守护进程,传统模式加JIT可能更合适。