开头前100字需要自然融入核心关键词,所以我直接用“yudao”和“Native打包”开头。
接手yudao项目之后,我一直在琢磨怎么把启动时间从10多秒压到1秒以内,内存占用也从几百MB降到100MB以内。试了不少方案,最后确定走GraalVM Native Image这条路,把yudao打成真正的本地可执行文件。这篇就完整记录我在做基于yudao的Native打包时用的方案,以及踩过的坑和对应的解决办法,希望能帮到也在折腾同类项目的朋友。
1. yudao Native打包的选型逻辑
1.1 先判断你的项目该不该碰Native
在动手之前,必须想清楚一个核心问题:Native Image并不适合所有项目,尤其是一个功能齐全的中后台系统。yudao的定位是快速开发脚手架,集成了用户权限、流程审批、定时任务、消息通知、文件管理、多租户等模块,代码量大、框架依赖深、动态性很强。如果把这种项目直接丢给GraalVM编译,大概率会在“反射元数据缺失”“动态代理注册不全”“资源加载不到”这些地方卡很久。
但反过来说,yudao恰好又是最适合做Native优化的一类项目。因为中后台服务追求的是快速扩容、弹性伸缩、低成本运行,而这些需求正好对应Native Image的三大优势:启动快、内存低、免JVM依赖。我在本机实测过,传统java -jar启动yudao的system模块,冷启动大约10秒,常驻内存300MB左右;打成Native可执行文件之后,冷启动0.6秒,内存占用降到90MB左右。这个差距在Kubernetes多副本场景下非常值钱,节约内存就是节约成本,加快启动就是加快弹性伸缩。
1.2 基于yudao的打包方案选型对比
我实际调研并试过三种方案,最终选了GraalVM Native Image的Maven插件方案。
第一种是传统JVM + Spring Boot Jar包,这是yudao默认的部署方式,交付简单、文档多、兼容性最好,缺点就是启动慢、内存高。第二种是Spring Boot 3自带的AOT处理 + GraalVM Native Build Tools,这是官方推荐路线,能自动生成大量反射和资源metadata,但配合yudao这种大量使用MyBatis、RedisTemplate、Jackson的模块化项目,仍然有不少盲区,需要手工补metadata。第三种是纯手工编译Native,也就是直接写native-image命令并人工维护所有配置文件,灵活度最高,但维护成本极高,不适合yudao这种持续更新迭代的框架。
我最终采用“Spring Boot AOT自动生成 + 手工补录遗漏项 + 单测辅助收集”的组合方案。这样既利用了Spring官方对AOT的大量积累,又能针对yudao项目自身特点快速补齐边界配置,平衡了构建效率和稳定性。
提示:如果你的yudao版本还停在Spring Boot 2.x,那先要升级到Spring Boot 3.x再考虑Native打包,因为只有3.x对GraalVM的支持才相对成熟。
1.3 Native打包适合哪些部署场景
经过多次实操,我总结出yudao Native打包最值得投入的场景:一是云原生环境,需要快速启动、批量扩容、缩容时回收资源;二是边缘节点或内网小型服务器,没有JDK环境,希望一个二进制文件拷过去就能跑;三是对内存消耗敏感的商业化SaaS交付场景,一个租户一个进程跑起来才几十MB,密度能提高好几倍。
不太适合的场景也有:如果项目里大量依赖动态字节码生成、自定义类加载器、复杂反射调用链,或者很多第三方库没有提供native配置,那强行打包会让你陷入无穷无尽的metadata补全,维护成本远超收益。yudao在这方面总体可控,但如果你又集成了额外的报表引擎、规则引擎、脚本引擎,建议先评估它们对GraalVM的兼容性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 打包前的环境与依赖准备
2.1 GraalVM版本与JDK的选择
这一步是决定成败的基础。我一开始图省事用OpenJDK 17直接跑native编译,结果各种报错,后来换成GraalVM官方JDK 17才顺畅。核心原因很简单:Native Image编译器需要GraalVM自身提供的native-image工具链,它和JDK版本强绑定。
我用的是GraalVM for JDK 17.0.10,搭配Spring Boot 3.2.x。建议不要用太新的GraalVM 21或22版本,虽然对Java 21支持更强,但Spring Boot AOT和部分开源库的适配可能有滞后。在macOS上安装GraalVM可以直接用SDKMAN:
bash复制sdk install java 17.0.10-graalce
sdk use java 17.0.10-graalce
安装后需要单独安装native-image组件:
bash复制gu install native-image
装好后执行native-image --version确认版本一致,同时确认JAVA_HOME已经指向GraalVM。我这里踩过一个坑,就算SDKMAN切换了默认版本,IDEA里的Maven还沿用老的JAVA_HOME,导致编译时用了OpenJDK,编译出的镜像在运行时和AOT阶段行为不一致。
2.2 yudao模块结构和依赖梳理
yudao本身是多模块结构,核心模块包括yudao-server(启动入口)、yudao-module-system、yudao-module-infra、yudao-module-bpm等。我强烈建议不要一次性把全部模块都打进去,Native编译的时间成本和内存成本随着代码体量近似线性增长不说,metadata漏配的概率也随模块数量急剧上升。
我的做法是:先只编译yudao-server + yudao-module-system这个最小闭合集,验证Native打包流程跑通后,再逐步加其他模块。每加一个模块,就回归一次启动和核心接口测试。如果一上来就全模块启动,报错的时候你根本分不清是哪个模块缺了反射配置还是资源文件。
另外要注意,yudao的依赖里有不少通过SPI机制加载的组件,比如各种AutoConfiguration。Native Image对SPI支持依靠META-INF/services资源文件注册,打包时这些文件必须原样保留。Spring Boot AOT会自动处理大部分,但涉及自定义SPI时还是要查一遍。
2.3 关键插件与依赖调整
在yudao-server的pom.xml里,我加了GraalVM Native Build Tools插件,核心配置如下:
xml复制<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.2</version>
<configuration>
<imageName>yudao-native</imageName>
<mainClass>cn.iocoder.yudao.server.YudaoServerApplication</mainClass>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http</buildArg>
<buildArg>--enable-all-security-services</buildArg>
<buildArg>--initialize-at-build-time=org.assertj.core.api.Assertions</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
</buildArgs>
<metadataRepository>
<enabled>true</enabled>
</metadataRepository>
</configuration>
<executions>
<execution>
<goals>
<goal>compile-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
metadataRepository的enabled要设为true,它会自动下载并合并GraalVM官方维护的第三方库metadata,比如Jackson、Netty、HikariCP这些,能省掉大量手工工作。--no-fallback是为了避免因元数据缺失而生成需要JVM的fallback镜像,如果允许fallback,看起来构建成功了,但运行时会退化成普通JVM,完全失去Native的意义。--enable-url-protocols=http是因为yudao的定时任务和部分模块会发起HTTP回调,我自己就在运行回调任务时遇到了java.net.URL协议未加载的报错。
依赖层面,非必要的动态功能我建议先关。比如yudao的spring-boot-admin监控客户端在Native下兼容性一般,如果只是内部看监控,可以先去掉或仅在开发环境保留。另外,Lombok在编译期完成工作,不影响运行时metadata,但要注意别在代码里反编译时依赖Lombok生成的内部方法名,否则反射调用时容易踩坑。
3. 核心Metadata配置与手工补录
3.1 Spring AOT自动生成了什么
在运行native编译之前,Spring Boot会先执行AOT处理。这个阶段会分析Spring容器里Bean的定义、方法、构造函数、字段的反射访问需求,并生成reachability-metadata.json、reflect-config.json、proxy-config.json、serialization-config.json等文件。这些文件默认存放在target/native-image目录下,编译时会被Native Image自动读取。
但我必须提醒一句,AOT只能处理“编译期能看得到”的元数据。对于yudao这种大量使用MyBatis动态代理、FastJSON/Jackson动态序列化、Spring Data Redis各模板、参数校验注解的框架,AOT的覆盖范围远远不够。你需要通过实际运行测试来不断暴露缺失点,再手工补充配置。这个“跑起来发现缺啥补啥”的过程,就是Native打包最耗时但有价值的部分。
3.2 反射配置:MyBatis Mapper和Jackson
yudao在持久层使用MyBatis Plus,它依赖动态代理生成Mapper实现类,而且大量实体和VO的字段序列化走Jackson。Native Image要求在构建期就知道哪些类会被反射访问,否则运行时就抛ClassNotFoundException或NoSuchMethodException。
我的经验是分两步处理反射配置。
第一步,把yudao模块下所有POJO、DO、DTO、VO包路径加入自动扫描。推荐用GraalVM reachability-metadata的格式,或者在reflect-config.json里用通配模式注册:
json复制[
{
"name": "cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO",
"allDeclaredConstructors": true,
"allDeclaredFields": true,
"allDeclaredMethods": true
},
{
"name": "cn.iocoder.yudao.module.system.controller.admin.auth.vo.AuthLoginReqVO",
"allDeclaredConstructors": true,
"allDeclaredFields": true,
"allDeclaredMethods": true
}
]
手工一个个写当然不现实。我是用一段简单的扫描脚本,把target/classes下所有cn.iocoder.yudao包下的.class文件读取出来,自动生成JSON条目。脚本本身不复杂,就是遍历文件、转类名、生成配置,重点是把那些被Jackson序列化、被MyBatis映射的类全部包含进去,宁多勿少。
第二步,对MyBatis的Mapper接口,需要注册动态代理。MyBatis Plus的Mapper代理实际是MapperProxy生成,需要在proxy-config.json里注册:
json复制[
{
"interfaces": ["cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper"]
}
]
这一步经常被漏掉。漏掉后启动阶段通常不报错,但一调用某个Mapper方法就会抛Proxy class not found,非常难排查。我的做法是写一个简单的启动冒烟测试,在打包后把主要Mapper的selectById都跑一遍,确保代理注册完整。
3.3 资源文件与配置文件处理
yudao的资源文件分散在多处:application.yaml、各种banner.txt、i18n国际化文件、MyBatis XML映射文件、模板文件(如邮件模板、短信模板)、静态文件目录。Native Image的resource配置默认只打包“经过AOT分析确定需要”的资源,不匹配就留下空白。
我在resource-config.json里统一注册了这些目录:
json复制{
"resources": {
"includes": [
{"pattern": "application.*\\.ya?ml"},
{"pattern": "banner\\.txt"},
{"pattern": "i18n/.*"},
{"pattern": "mapper/.*\\.xml"},
{"pattern": "\\QMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports\\E"},
{"pattern": "\\QMETA-INF/services/.*\\E"}
]
}
}
其中mapper/.*\\.xml特别关键。yudao虽然MyBatis Plus能通过注解写SQL,但不少复杂查询还是用了XML文件,如果XML没被打进Native镜像,运行时会报Invalid bound statement (not found)。我当时就是漏了这个,跑定时报表任务时百思不得其解,最后才发现resources里根本没有XML。
3.4 代理、序列化和初始化配置补充
除了反射和资源,还有三类配置经常坑人。
一是动态代理。除了MyBatis Mapper,yudao里还有用JDK动态代理处理AOP切面(如日志切面、数据权限切面)的类。虽然Spring AOT会处理不少,但自定义的切面表达式如果代理的是接口类型,也必须在proxy-config.json里补上。
二是Java序列化。yudao的Redis缓存、Session持久化都涉及对象序列化。Native Image默认屏蔽了Java原生序列化,开启方式是加--enable-serialization参数,同时在serialization-config.json里注册需要序列化的类。如果用了FastJSON或Jackson序列化对象到Redis,其实不走Java序列化,问题不大;但如果依赖了Serializable对象做Session共享,那就必须配置。
三是类初始化时机。某些类要求必须在构建期完成静态初始化,比如随机数、时区、编码检测相关的类。否则运行期首次访问会产生昂贵的初始化开销,甚至触发RuntimeInitializationException。我处理过net.jpountz(LZ4压缩)的类初始化问题,在buildArgs里加了--initialize-at-build-time=net.jpountz.*才稳定。
注意:配置metadata时要遵循“按需最小化”原则。有些团队图省事,使用
--reflect-config的“所有类全部注册”策略,靠-H:+...通配符把所有包都加进反射白名单。这样做虽然能掩盖问题,但会显著增大可执行文件体积、拖慢启动,完全违背Native优化的初衷。yudao的正确做法是精确扫描模块代码中用到的类,再汇总合并。
4. 完整打包实操与效果调优
4.1 构建命令和第一步冒烟验证
在项目根目录执行以下命令,但注意提前确认Maven用的是GraalVM环境:
bash复制mvn -pl yudao-server -am clean package -DskipTests -Dnative
首次编译时间取决于机器配置,我给一个参考值:8核16GB的MacBook Pro,编译yudao最小闭合集大约需要5到8分钟,输出产物300MB左右(在Linux下会小一些,大约200MB)。这个等待过程看着长,但还好是一次性成本,之后每次增量编译只处理变更部分。
编译完成后,检查yudao-server/target下是否生成了yudao-native可执行文件,然后直接跑:
bash复制./target/yudao-native --spring.profiles.active=local --server.port=8080
第一次启动如果没报错,只是在等数据库连接或Redis连接成功,那说明核心镜像已经能跑起来了。接着,我建议用curl快速验证关键接口:
bash复制curl http://127.0.0.1:8080/admin-api/system/auth/login
curl http://127.0.0.1:8080/admin-api/system/user/get
但我必须强调,启动成功只是第一关,不等于功能没问题。很多缺失的反射或资源在启动阶段根本不会被触达,要真正跑业务逻辑才会暴露。所以我做了一套冒烟测试,让打包后的可执行文件启动后自动调用用户列表、登录认证、验证码生成、文件上传、定时任务执行这几个核心链路。
4.2 运行参数调节与性能实测对比
Native Image默认的运行时内存策略和JVM完全不同,它没有JIT,没有方法区,GC选型也不同。yudao默认连接HikariCP、Redis、MySQL,需要关注几个运行时参数:
bash复制./target/yudao-native -Xmx128m -Xms64m -XX:+UseSerialGC -Dfile.encoding=UTF-8
我实测出来的结果,最明显的是内存收益。传统JVM跑yudao,即使-Xmx512m,实际常驻RSS也有300MB上下;Native跑起来,初始内存70MB左右,跑一段压力测试后稳定在90MB。启动时间从10秒降到0.8秒。这个对比在资源受限的容器环境里特别有意义,同样一台2核4GB的机器,JVM版本只能跑6~8个实例,Native版本可以跑30个以上。
不过有得必有失。Native版本在纯计算密集场景下,峰值吞吐量可能不如JVM版本,因为JVM有JIT可以不断优化热点代码。对yudao这种IO密集型的中后台系统,这个差异几乎可以忽略。
4.3 构建产物与Docker封装
Native打包后的可执行文件不依赖JDK,所以Docker镜像可以做得特别小。我用的基础镜像是debian:stable-slim,实际镜像里除了可执行文件,只放一个application-prod.yaml和一个健康检查脚本,最终镜像压缩后体积约80MB。对比传统Jar包的镜像,动辄300MB以上,削掉了近三分之二。
Dockerfile大致是这样:
dockerfile复制FROM debian:stable-slim
ENV JAVA_HOME=/nonexistent
RUN useradd -m -u 1000 yudao
USER yudao
WORKDIR /app
COPY --from=build /workspace/yudao-native /app/yudao-native
COPY --from=build /workspace/application-prod.yaml /app/application-prod.yaml
EXPOSE 8080
ENTRYPOINT ["/app/yudao-native", "--spring.config.location=file:/app/application-prod.yaml"]
这里要注意两点。第一,Native可执行文件是用glibc链接的,基础镜像必须是包含glibc的Linux发行版,不能直接用alpine(它是musl libc),除非你在构建时就针对musl做了静态编译。第二,容器内运行不要用root,Native Image虽然是单文件,但保持最小权限是好习惯,尤其dubbo、Redis连接这些可能涉及网络端口绑定的场景。
5. 高频问题与排障记录
5.1 启动过程中的经典报错与处理
我在打包和运行yudao Native时,遇到的第一类高频报错都集中在启动阶段。这里整理几张我自己整理的排障表。
| 报错现象 | 根因 | 处理方式 |
|---|---|---|
java.lang.ClassNotFoundException: org.springframework.boot.autoconfigure.condition.OnPropertyCondition |
spring-boot-autoconfigure中某些条件注解类未被反射注册 | 核对AOT自动生成的reflect配置是否完整,或手工补上缺失类 |
java.lang.reflect.InvocationTargetException 同时伴随 NoSuchMethodError |
常见于Lombok生成的getter/setter,在Native镜像中未被识别 | 在reflect-config.json里注册对应实体类,并带上allDeclaredMethods |
Unable to make field ... accessible |
JDK模块系统对反射的限制与Native元数据注册冲突 | 添加-H:+AllowIncompleteClasspath或补充反射配置,但最好定位到具体类手工注册 |
Invalid bound statement (not found) |
MyBatis的XML映射文件未被打入资源 | 在resource-config.json中加入mapper/.*\\.xml |
Unsupported features : 某个第三方库使用了JNI |
Native Image默认不支持部分JNI功能 | 优先换掉这个库,或在jni-config.json里注册对应的Java类和方法 |
5.2 运行时反射缺失问题
这一类是最防不胜防的。启动阶段明明很顺利,但一旦跑某个接口,就突然抛出NoSuchMethodException或ClassNotFoundException。典型场景是yudao的规则引擎、动态数据字典、菜单权限校验中点到了某个没注册的类。
我的排查方法比较机械但有效:在buildArgs里加上-H:+ReportExceptionStackTraces,然后复现报错,从堆栈里找到缺失类的全限定名,把它加入对应的反射配置。这个过程我最高纪录一次补了20多个类,大多集中在cn.iocoder.yudao.framework.*的通用模块里,因为这些通用类被很多业务模块引用,但AOT分析时未必能覆盖所有使用路径。
为了避免反复手工补,我写了一个JUnit测试,用ClassGraph类库扫描yudao所有模块的class文件,再排除接口和注解类,然后批量生成reflect配置。这个方法虽然会让配置偏大一点,但换来的是稳定可靠,特别是团队协作时新成员可能随手加一个DTO,不跑这个扫描就会漏。
5.3 连接池与Redisson的兼容性问题
yudao里默认集成了HikariCP和Redisson。Redisson在Native下问题比较多,它内部大量使用Netty和反射,而且对ScheduledExecutorService的动态生成依赖很深。我们的实际做法是:在Native打包时,暂时把Redisson客户端换成Lettuce或Jedis直连Redis,核心缓存和分布式锁逻辑不依赖Redisson特有的RBucket、RMap时,这种替换完全可行。
如果坚持要保留Redisson,必须把它所有用到的类手动补进reflect和resource配置,并在serialization-config.json里注册它的编解码类。Redisson自己的文档对Native支持也不太乐观,所以一般不建议在Native化早期引入它。
HikariCP则相对友好,最新版本对GraalVM做了适配,但要注意驱动类名必须显式注册。我在reflect-config.json里加过com.mysql.cj.jdbc.Driver和org.postgresql.Driver的全量反射配置,否则会报Failed to load driver class。
5.4 前端启动白屏与构建告警的处理
说到Native打包,如果交付物里顺带包含前端资源,比如yudao的后台管理界面,还需要注意把前端构建产物放到Spring Boot的静态资源目录里,并在Native打包时让这些静态文件进入资源配置。如果前端部署方式和后端分开,那就简单得多,直接把静态文件交给nginx托管,后端只负责API。
这里补充一个热搜词里反复出现的现象——“react native 启动白屏”。如果你的项目里恰好有React Native编写的App端,那白屏问题很可能发生在Native编译后的后端接口响应异常,导致前端拿不到数据,而不是前端本身渲染错误。我的经验是分两头排查:后端先确认/admin-api/**接口在Native模式下是否正常返回,前端再用浏览器开发者工具的Network面板看接口状态。如果接口正常但白屏,再去查前端工程里的metro.config.js或构建缓存,执行npm start -- --reset-cache清掉缓存后再试。
另外构建过程中出现npm warn deprecated node-domexception@1.0.0: use your platform's native dome这种警告,多半是某个npm包依赖了旧包。这类警告一般不影响产物,但要留意会不会引出"cannot find native binding"的报错,那个通常才是导致打包失败的真凶。cannot find native binding本质上是Node的optional dependencies没装好,常见解决办法是:
bash复制npm install --force
node ./node_modules/node-gyp/rebuild.js
如果一次不行,把node_modules和package-lock.json删掉重新安装,基本能解决。但要用这个经验时,先确认问题是不是真的出现在前端构建链路,不要把它错套到后端Native构建上。
5.5 高负载下的稳定性问题
Native镜像的运行稳定性在常规压力下没问题,但在高并发下有几点和JVM模式不同,我单独说一下。
第一,锁竞争敏感。Native模式下没有JIT,某些热点锁优化策略失效,如果yudao里自定义了大量synchronized块或ReentrantLock,可能出现并发性能下降。解决思路是调整锁粒度,或者用--gc=G1配合调优。第二,堆外内存要小心。Native Image使用MemorySegment或DirectByteBuffer时更容易出现内存泄漏,要特别注意Netty、Broken、数据库驱动里的缓冲池配置。第三,不要盲目开高线程数。JVM模式里很多线程是为了弥补IO等待,但Native模式下线程创建成本相对更高,建议根据实际压测结果合理调小线程池大小。
我压测过一个场景:跑yudao的验证码登录接口,Native模式在200并发时,TPS和JVM模式几乎持平;到500并发时,Native模式整机CPU更高,TPS略低5%~8%,但内存增长幅度明显更低。如果你追求的是单位内存吞吐量,Native仍然更划算,因为在相同内存配额下,所能运行的实例数和总吞吐量都会更高。
结尾
最后说一点个人体会。yudao做Native打包不是一次配置就能收工的,它更像一个持续维护的过程,每加一个新功能,都要重新跑一遍冒烟测试,确认没有新增反射、资源或代理的缺口。我目前的做法是在CI里增加一个Native打包的流水线,每次合并代码后自动编译并跑核心接口测试,确保metadata不退化。如果你们团队短期资源紧张,也可以先做到“发布前手工跑一遍冒烟”,成本高一点,但踩过的坑多一次就会长记性。
按我踩坑后的总结:先用最小模块集打通流程,再逐步扩展;优先关注MyBatis XML、Redis客户端、Jackson序列化和各类SPI文件;别信AOT能自动搞定一切,手工metadata永远要保留一条补充通道。希望这套基于yudao的Native打包方案能帮你在Native路上少走几段冤枉路。
