最近我把手头一个基于yudao的权限管理系统做GraalVM Native打包,整个过程比预想中曲折不少。uydao本身是个功能很全的快速开发平台,权限、多租户、代码生成、工作流、支付这些模块都有,日常用Spring Boot的普通JVM方式部署一点问题没有。但一旦想上Native Image,事情就没那么轻松了:启动快是快,内存也确实降下来了,可光是Spring Boot 3的AOT适配、MyBatis Plus的反射注册、Redis客户端的SPI加载这几个环节,就够折腾一阵子。
这篇文章我把从准备环境、修改配置、编译构建到最终运行的一整条路梳理出来,重点放在那些我在真实项目中踩过的坑和对应的解决办法。如果你也打算把yudao打成Native包,或者只是想了解Spring Boot项目转Native Image会遇到哪些典型问题,这篇应该能帮你省下不少排查时间。
1. 为什么要把yudao打成Native包:动机、收益与应用场景
1.1 三条不得不做的理由
第一个理由是启动速度。yudao的功能模块很多,Spring Boot启动时要做组件扫描、自动配置加载、Bean初始化,在我的机器上冷启动普遍要8到15秒。打成Native可执行文件之后,启动基本在1秒以内,容器编排场景下这个差异非常明显。第二个理由是内存占用,Native Image使用的是AOT编译,运行时不再需要JIT编译器来逐层优化热点代码,堆外元数据和类元数据也大幅缩减,实测同样的yudao服务从原始内存占用四五百兆降到二百兆左右,对内存受限的服务器环境很有价值。第三个理由是部署形态,Native包是一个独立的二进制文件,不需要目标机器预装JDK,拷贝过去加执行权限就能跑,镜像尺寸也小很多。
但这里必须说清楚,Native不是免费的午餐。它意味着按需编译,动态能力被大幅压缩,反射、动态代理、JNI、资源加载这些原本JVM环境里很随意的事情,在Native下都需要提前“打报告”注册好。yudao这种重度依赖Spring Boot自动配置、MyBatis Plus的Mapper扫描、Sa-Token权限注解、乃至Redis缓存的项目,恰恰是Native最不友好的那一类应用。
1.2 动手前先确认版本红线
如果你用的是yudao的Spring Boot 2.7版本,我的建议是先别折腾Native。Spring官方对Spring Boot 2.x的GraalVM支持一直停留在实验状态,AOT插件和native-build-tools的配合也不是很成熟。我实际试下来,2.7版本虽然硬着头皮也能编出可执行文件,但运行期各种反射缺失的问题层出不穷,光补配置就花了大量时间,得不偿失。
yudao的项目主线目前已经有基于Spring Boot 3.x的版本(yudao-boot和yudao-cloud都有升级分支),优先选择Spring Boot 3.2以上、MyBatis Plus 3.5.3以上,最好还带着JDK 17的条件。Spring Boot 3的AOT引擎能自动处理大量Spring内部的反射、代理和资源问题,我们只需要关注业务代码和框架边缘的补充配置。这是整个方案能走下去的基石。
1.3 预期收益与代价清单
我把预期收益和代价列成一个清单,方便你评估是否值得做:
| 项目 | 普通JVM部署 | Native打包 |
|---|---|---|
| 启动时间 | 8-15秒 | 0.5-1.5秒 |
| 内存占用 | 400-600MB | 200MB左右 |
| 部署环境 | 需要JDK | 仅二进制文件 |
| 构建时间 | 十几秒 | 5-15分钟(取决于机器) |
| 构建内存 | 1-2GB | 建议8GB以上 |
| 动态能力 | 完整 | 受限,需显式注册 |
从表格能看出来,收益主要在线下运行资源,代价则集中在构建和配置阶段。我的判断是:如果是做交付型项目,客户环境资源有限,或者要做成标准化镜像分发给多套环境,Native打包值得投入;如果只是内部系统,服务器资源充足,那普通JVM部署反而省心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与构建基础
2.1 JDK与GraalVM版本怎么选
环境选型是整个环节最容易翻车的部分。我强烈建议不用Oracle JDK或OpenJDK,而是直接下载GraalVM JDK本身,因为它自带native-image组件,版本一致性问题少很多。这里有个容易混淆的点:GraalVM有Community Edition和Enterprise Edition,社区版足够我们用,没必要纠结企业版。
具体版本上,我使用的是GraalVM for JDK 17,搭配Spring Boot 3.2.x。如果你要用JDK 21也可以,但我实测下来JDK 17在生态兼容性上最稳,起码不需要处理一些第三方库对Java 21新特性的不兼容问题。安装完之后务必确认JAVA_HOME指向GraalVM,并且java -version能看到GraalVM字样,否则后续native-image工具会从错误的环境去找JDK。
2.2 native-image工具链安装
GraalVM安装好之后,native-image组件需要单独用gu命令安装,这一步很多人会漏掉。命令很简单:
bash复制gu install native-image
装完可以用native-image --version验证。如果你在Windows上开发,需要额外安装Visual Studio的C++开发工具链和Windows SDK,因为native-image底层要调用目标平台的链接器。我自己是直接用Linux服务器做构建的,省掉了本地环境这些麻烦事。
这里说一个非常重要的建议:构建Native镜像用Docker更可控,因为本机环境哪怕一个小库缺失,都会导致链接阶段失败。GraalVM官方维护了一个容器镜像ghcr.io/graalvm/graalvm-ce,也可以在Dockerfile里直接基于debian装GraalVM。我建议你在CI里用Docker容器做构建,不要依赖开发者本机的环境,否则换台机器就编译失败的情况会频繁出现。
2.3 用Maven插件还是跑命令
如果徒手敲native-image命令,你需要手动指定很多参数,比如--no-fallback、-H:+ReportExceptionStackTraces,还要把classpath、主类这些全带上去,非常繁琐,而且Spring Boot的自动配置不会自动参与。推荐的方式是使用org.graalvm.buildtools:native-maven-plugin这个官方Maven插件,它能自动识别Spring Boot应用并完成AOT处理。
在yudao根服务的pom.xml里加插件配置:
xml复制<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.2</version>
<configuration>
<fallback>false</fallback>
<buildArgs>
<buildArg>-H:+ReportingClassUnloading</buildArg>
</buildArgs>
</configuration>
</plugin>
版本号我用的是0.10.2,和Spring Boot 3.2可以正常配合。注意这里的fallback false很关键,如果设为true,产物可能是一个需要JVM才能运行的瘦二进制,那就失去Native的意义了。理想情况下,我们期望的产物是完全独立的可执行文件。
2.4 Docker镜像与多阶段构建
构建完成后,可以放在多阶段Dockerfile里把可执行文件COPY到精简镜像。一个典型的Dockerfile长这样:
dockerfile复制FROM ghcr.io/graalvm/graalvm-ce:ol8-java17 AS native
WORKDIR /app
COPY . /app
RUN microdnf install -y findutils && ./mvnw -Pnative native:compile -DskipTests
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=native /app/target/yudao-server /app/yudao-server
EXPOSE 8080
ENTRYPOINT ["/app/yudao-server"]
我用的是debian作为运行基础镜像,因为GraalVM产物依赖glibc,Alpine的musl libc不兼容,除非你专门做musl版的GraalVM。这一点也提醒你:别想当然地用Alpine镜像跑Native可执行文件,链接器会直接报错。
3. yudao的Native配置改造
3.1 配置文件的坑:从application.yaml开始
yudao的配置在application.yaml里有很多自定义项,比如yudao的captcha开关、文件上传本地路径、Sa-Token的token配置等。Native打包时,Spring Boot的AOT阶段会扫描配置属性,但一些通过@Value注入或者运行时才拼接的路径,AOT是感知不到的。
实际运行中我遇到过一个典型的启动报错:Configuration property 'yudao.captcha.enable' is not valid之类的提示,实际上是因为配置元数据没有被正确生成。解决办法一般是确保没有在代码里写死“以代码方式注册配置属性”,用标准的@ConfigurationProperties方式定义配置类。这里额外注意一点:application.yaml里的配置值默认会被Spring Boot打包进运行时资源,但由于Native的类路径是一个可执行文件内部的镜像,日志配置文件、mapper XML这些一定要确保能被Spring Boot的资源扫描逻辑找到。
如果你的Native可执行文件在运行时报“Cannot load XML mapper”或者提示找不到配置文件,十有八九是资源注册问题。Spring Boot 3提供@RegisterResourceHint这类注解,也可以用resource-config.json来手动登记。我的做法是直接用一个RuntimeHintsRegistrar实现类,把所有关键的resources路径都注册进去,比如:
java复制public class YudaoResourceHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("classpath:mapper/**/*.xml");
hints.resources().registerPattern("classpath*:mapper/**/*.xml");
hints.resources().registerPattern("classpath:i18n/*.properties");
hints.resources().registerPattern("classpath:*.yaml");
}
}
注册完之后还要在自动配置类或者启动类上显式引入,否则Spring不会加载这个Hints实现。
3.2 反射、代理与资源注册
Native Image最核心的问题就是反射和动态代理。yudao里用到的反射点非常多:MyBatis Plus的实体类属性映射、Sa-Token的登录用户对象反序列化、Jackson在接口返回时对请求参数的解析等。
Spring Boot 3提供@RegisterReflectionForBinding注解,可以直接对DTO、VO类做批量注册。比如在启动类或者配置类上写:
java复制@RegisterReflectionForBinding({
AdminUserRespVO.class,
DeptRespVO.class,
RoleRespVO.class,
LoginReqVO.class,
LoginRespVO.class
})
不过项目实体很多,一行行写不现实。我的建议是运行阶段先开启GraalVM的配置采集Agent,让程序自己收集需要哪些反射配置。在开发环境用:
bash复制java -agentlib:native-image-agent=config-output-dir=native-config -jar yudao-server.jar
跑一遍核心流程(登录、权限校验、CRUD、代码生成等),agent会在native-config目录下生成reflect-config.json、proxy-config.json、resource-config.json等文件,然后再把这些配置作为构建参数传入native-image。这是目前Spring Boot生态里最常用、也最可靠的方式。不要试图全靠手写配置,业务一多根本不可能记住所有反射点。
3.3 MyBatis的Native适配
MyBatis Plus在Native下最头疼的是Mapper接口的动态代理创建。Spring Boot的AOT会尝试在构建期分析MapperScan逻辑,但实际运行中Mapper接口的InvocationHandler还是动态生成的,Native下如果没把Mapper接口提前注册进代理配置,会抛出类似:
text复制java.lang.IllegalArgumentException: Unsupported proxy class
或者ClassNotFoundException: com.sun.proxy.$Proxy...。
这里我把MyBatis Plus升级到了3.5.3以上版本,它在Spring Boot 3的原生支持上做了不少改进。另外建议显式关掉MyBatis的一些运行时期行为,比如不需要在构建期扫描别的地方的jar包里的Mapper,只扫描自己模块的Mapper接口:
yaml复制mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: cn.iocoder.yudao.**.dal.dataobject
还有一点容易忽略:MyBatis的TypeHandler。如果你在yudao里自定义了TypeHandler(比如把JSON字段映射成对象),这些TypeHandler类也需要反射注册,否则运行期对ResultSet做处理时找不到对应的构造器。
3.4 Redis、Redisson与连接池配置
yudao默认会接Redis缓存,常见的有Spring Data Redis和Redisson两种方式。Redisson我在Native下踩了不少坑,它的内部大量使用ASM动态生成类,Native Image对这类运行期字节码生成是没法支持的。所以我的建议是:用Redis缓存的时候优先走Spring Data Redis + Lettuce客户端,这是Spring官方在Native支持里测试过的组合。
如果你还是想用Redisson,可以关注它官方有没有提供GraalVM兼容的版本。但就我的经验,设计简化远大于硬扛Redisson,换成Lettuce之后配置也简单:
yaml复制spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
lettuce:
pool:
max-active: 8
连接池这块,HikariCP对Native的支持相对好,但有一点要注意:HikariCP默认通过DriverManager加载驱动,Native下需要显式配置驱动类名:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/yudao?useSSL=false
username: root
password: 123456
hikari:
maximum-pool-size: 10
MySQL驱动本身对Native有一些坑,比如com.mysql.cj.protocol.StandardSocketFactory的反射调用。我建议把MySQL驱动升级到8.0.33以后,它在GraalVM适配方面好很多。如果数据库是PostgreSQL,驱动这块反而相对省心。
3.5 定时任务、线程池与异步注解的坑
如果你在yudao里用到了@Scheduled定时任务或者@Async异步注解,你也要注意Native下的线程行为。Spring Boot的AOT会对这类注解做分析,但运行期的动态定时任务(比如动态添加Cron表达式)依然可能出问题。
如果用的是Quartz,那还要额外注意:Quartz的Job类是通过反射创建的,JobDataMap里放的对象也需要注册反射。传统做法是把Job实现类和相关的DTO都用@RegisterReflectionForBinding注册上。我实际跑下来发现,Spring Boot + Quartz的AOT支持还是比较完善的,反而yudao里自定义的异步Runnable比较麻烦。
拿异步流程举例,如果你的业务里写了一个Lambda表达式传给线程池,Native下面Lambda的生成机制是会有变化的。Lambda表达式本身会被编译成隐藏类,在Native Image里默认是无法动态生成的,好在GraalVM在JDK 17之后提供了--enable-preview和支持Lambda的机制,但一旦遇到序列化Lambda或者需要反射获取实现接口的Lambda,还可能因为缺少接口元数据而失败。我的建议是:Async逻辑尽量封装成有名字的类,少用无状态Lambda,排查起来会舒服很多。
4. 构建、运行与常见问题排查实录
4.1 构建命令与资源限制
构建命令本身不复杂,在项目根目录执行:
bash复制mvn -Pnative native:compile -DskipTests
但这里要提醒你两件事。第一,Native编译超吃内存,特别是中间会有一个分析阶段,把所有代码路径遍历一遍。构建机器内存低于8GB的时候,我遇到过Native image build failure: Out of memory during compilation,后面在构建参数里加:
code复制-J-Xmx12g
来限制native-image进程的堆大小。我更建议CI机器准备16GB内存,不然非常容易在最后链接阶段崩溃。第二,编译时间很长,小项目都要好几分钟,yudao这种多模块大项目,动辄十几分钟,属于正常现象,别以为卡死了。
构建完成之后,bin目录下的可执行文件或target目录下的同名二进制文件就是我们的成品。先直接本地跑一下:
bash复制./target/yudao-server
看看启动日志能不能正常打到“Started YudaoServerApplication”。如果有报错,顺着异常堆栈去补配置,我下面就列几个最常见的。
4.2 启动期异常:从ClassNotFound到NoSuchMethod
启动期的报错通常是最密集的,因为很多类在JVM环境里由ClassLoader懒加载,Native则把所有类都打包进二进制里,反射调用时如果类没注册,就会在启动时直接抛异常。
我碰到最多的是这两个:
java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver:驱动类没被注册。解决方式是在reflect-config.json里加入驱动类的全限定名,或者通过resource-config.json注册SPI服务文件META-INF/services/java.sql.Driver。java.lang.NoSuchMethodError: javax.validation.Validator:Spring Boot 3把javax换成jakarta,部分第三方库还存在旧坐标的兼容类,导致运行期方法签名对不上。这个需要统一依赖的javax.validation版本,或者排除掉旧坐标的传递依赖。
如果看到这类启动日志,先用-agentlib:native-image-agent跑一遍普通JVM流程,收集配置,再重新构建,大部分启动期反射问题都能这样扫出来。
4.3 运行期异常:反射调用与动态代理
启动成功只是第一步,运行期才是重灾区。比如你登录yudao后台,点击某个菜单,触发了一个新的反射调用,如果这个类事先没有注册,会直接抛:
text复制java.lang.reflect.InaccessibleObjectException: Unable to make field private ... accessible
这种问题最麻烦,因为它一定是在你操作到某个具体功能的时候才暴露。好在GraalVM提供了--trace-class-initialization和--trace-object-instantiation参数,可以在构建时把类初始化和实例化路径打出来,辅助定位。运行时也可以加:
bash复制-H:VerifyMetaspace=true
来确认是否存在元空间访问异常。
我的个人建议是把所有的查询VO、请求VO、DO对象都批量注册反射,不要只注册少数几个。yudao里DO和VO非常多,推荐写个基于包扫描的RuntimeHintsRegistrar,类路径下匹配cn.iocoder.yudao.**.dataobject.**、**.vo.**等包,一次性注册完。省得后面一个功能一个功能地补。
动态代理这块也要特别说明:yudao的Sa-Token在鉴权时会对登录用户对象做序列化和反序列化,如果登录用户的类型没有提前注册,登录接口本身可能正常,但后续每次请求解析Token时都会出错。我的做法是把所有用户相关的DTO和BO都加进proxy-config.json和reflect-config.json,双保险。
4.4 问题速查表
我在整个过程中整理了一张速查表,遇到对应报错可以直接查:
| 报错信息 | 根因 | 解决方案 |
|---|---|---|
| ClassNotFoundException: com.mysql.cj.jdbc.Driver | 驱动类未注册反射/SPI | 注册META-INF/services/java.sql.Driver和驱动类反射 |
| Cannot load XML mapper | mapper XML资源未注册 | hints.resources().registerPattern("classpath*:mapper/**/*.xml") |
| Unsupported proxy class | Mapper接口动态代理未注册 | 将Mapper接口加入proxy-config.json |
| InaccessibleObjectException | 类反射未注册 | 批量注册DO/VO/DTO类 |
| NoSuchMethodError: javax.validation | 依赖旧坐标冲突 | 统一使用jakarta.validation坐标 |
| Out of memory during compilation | 构建内存不足 | 构建机内存加到16GB,加-J-Xmx12g |
| Exit code 127: libXXX.so not found | 运行镜像缺少glibc/依赖库 | 使用debian/ubuntu基础镜像,不要用alpine |
| Native image build failure: Unsupported feature | 运行期字节码生成 | 排查ASM/CGLIB/动态字节码库,替换实现 |
4.5 排查工具:native-image的调试参数
排查Native问题有一点很关键:不要直接看它“黑盒”的二进制,要善用native-image暴露的调试参数。构建时加上:
bash复制-H:+ReportExceptionStackTraces
-H:+TraceClassInitialization
这两个参数能让异常堆栈更多、类初始化过程更清晰。跑程序时也可以加上-Dspring.native.remove-unused-autoconfig=true,看哪些自动配置被无效移除。另一个好用的做法是保留构建中间产物:
bash复制-H:+GenerateDebugInfo
这样如果有段错误(segfault),可以直接用调试器看崩溃点。
还有一点值得提:搜索同样问题的时候,尽量用“Spring Boot native + 具体报错”这样的关键词组合,因为互联网上大量叫“Native”的内容其实是React Native、Node原生模块之类的东西,完全不相关。比如你搜“native启动白屏”会混进React Native的帖子,搜“cannot find native binding”会混进npm optional dependencies的问题,这些都是前端或Node领域的内容。我在排查时一开始就被这些噪声干扰过,后来才明白yudao的Native问题必须聚焦在GraalVM、Spring AOT这条技术线上。
写在最后的一些体会
把这个流程跑通之后,我现在再遇到类似“要不要Native打包”的问题,第一反应不是技术可行性,而是业务付出比。对于yudao这种模块多、依赖复杂、持续迭代的应用,建议保留一条Native流水线作为可选的交付形态,但日常开发依然以普通JVM运行为主,两者并行,而不是完全替代。Native包的构建时间毕竟有十几分钟,迭代调试期间全走编译链路,效率是完全不可接受的。
如果真的决定上Native,最关键的是把构建环境标准化,锁死GraalVM版本、JDK版本、插件版本,并且把“采集Agent配置”这一步固化到日常回归流程里。只要业务代码改动后,运行期反射点变了,旧的config目录就需要重新采集。我在实践中就是把agent采集做成一个profile,每次发版本前跑一遍冒烟用例,自动刷新配置,再走Native编译。这么做之后,生产环境因为反射缺失翻车的概率就小很多了。
