如果你搜到这篇文章,大概率和我面对的是同一件事:yudao 这种基于 Spring Boot 的后台脚手架,平时用 java -jar 跑得好好的,但一旦到了“服务要批量部署”“容器要快速扩容”“冷启动不能等太久”这类场景,传统 JVM 应用那几秒到十几秒的启动时间就变得很扎眼。Native 打包,也就是把 yudao 编译成一个原生可执行文件,是我最近在调的一条路。
先说结论:yudao 不是不能做 Native 打包,但比打包一个纯粹的 Spring Boot Demo 要麻烦不少。麻烦主要来自 yudao 的模块划分、MyBatis Plus 的 XML Mapper、Redis 序列化、验证码字体资源这些“隐式依赖”。真正的难点不在编译,而在把运行时需要的反射、资源、代理信息全部提前交代给 GraalVM。
这篇文章把我自己实际跑通的方案、用到的配置、关键代码,以及踩坑之后完整的问题解决链路写清楚。目标很明确:让你拿到一篇能照着操作的东西,而不是一篇只讲概念的文章。
1. 概念对齐:这个 Native 指的是 GraalVM,不是移动端原生
1.1 为什么先要对齐语义
“Native 打包”这个词在两拨人嘴里完全是两回事。做移动端的人一说 Native,先想到 React Native、uni-app 原生打包、Android WebView 壳;做 Java 后端的人说 Native,通常指 GraalVM Native Image。yudao 是一个 Java 后端脚手架,所以这里的 Native 严格按照 GraalVM 的方向理解。
GraalVM Native Image 干的事情很简单:在构建阶段对你的应用做静态分析,把 JVM 字节码提前编译成机器码,最终产出一个不依赖 JDK 就能运行的原生可执行文件。Spring 应用跑在这个文件里,启动过程不再需要类加载、字节码解释、JIT 预热,所以启动时间能压到几百毫秒到一秒多。
这个思路和移动端打包没有任何关系,大家不要在搜索时被热词带偏。yudao 的前端部分如果要打 App 壳,那是另一套流程,跟标题里的 Native 打包不是一个东西。
1.2 为什么值得让 yudao 吃一次亏
yudao 是典型的“全家桶式”后台项目,模块多、功能全、监控和基础设施模块都有。这类项目放在 JVM 上运行没有任何问题,问题出在三个指标上:
| 指标 | 传统 java -jar | Native 可执行文件 |
|---|---|---|
| 冷启动到可服务 | 我这里是 6 到 9 秒 | 实测 1 秒上下 |
| 稳定运行 RSS 内存 | 跑起来后 800MB 往上 | 260MB 到 350MB 之间 |
| 产物大小 | Fat Jar 大约 170MB | 原生可执行文件约 80 到 100MB |
| 弹性扩容 | 加上预热,Pod 就绪很慢 | 启动快,便于快速扩容 |
| 构建时长 | 分钟级 | 首次十几分钟,需配置缓存 |
如果你的 yudao 只跑一两个内部后台节点,第三列的优势并不明显,Native 的构建问题反而会拖慢开发。但如果你要把 yudao 服务部署成十几个节点,或者要面对大量周期性任务、按量拉起服务,启动速度和内存占用就是实打实的成本。为了这个收益,值得把打包链路的问题梳理一次。
1.3 哪些模块适合先做 Native
yudao 这种工程一般拆成 yudao-server、yudao-module-system、yudao-module-infra,以及框架层的一堆 starter。 Native 化建议从 yudao-server 的 API 服务端开始,其他模块作为 Maven 依赖一起参与编译。
如果你只需要把某个模块或某个调度任务单独打成原生可执行文件,原理一样,只是入口类不同。我的经验是先让 yudao-server 整体跑通,再回头去做精简版本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前置准备:GraalVM 环境与 Spring Boot 版本改造
2.1 GraalVM 版本选择和安装
Native Image 需要一个带 native-image 组件的 GraalVM。我当前用的是 GraalVM for JDK 17 的较新版本,实际版本号不关键,关键是别用一个很老的 GraalVM 去配新版本 Spring Boot,否则会出现一堆“找不到 native-image”或者编译参数不认的错误。
安装步骤是很基础的:
bash复制# 以 macOS 或 Linux 为例
sdk install java 17.0.12-graalce
sdk use java 17.0.12-graalce
# 安装 native-image 组件
gu install native-image
验证环境:
bash复制java -version
native-image --version
另外确认 JAVA_HOME 指向的是 GraalVM,而不是系统里原来的 JDK。这一步看起来简单,但我在实际项目里见过不止一次:用 IDES 跑 Maven 时识别的是 JDK 17,命令行里却是另一个 JDK,最后 native 插件报错,排查半天发现是环境变量串了。
2.2 yudao 的 Spring Boot 版本决定了可行路径
这是最关键的一个前提。Spring Boot 对 GraalVM Native Image 的支持分水岭是 3.0,因为从 3.0 开始 Spring 官方把 AOT 引擎内置,原来的 Spring Native 实验项目停止演进。
如果你的 yudao 还是基于 Spring Boot 2.7 的分支,做 Native 需要引入 Spring Native 那一套实验性依赖,配置复杂而且很多组件不兼容。我建议直接基于 Spring Boot 3.2 以上的 yudao 版本来做。yudao 本身升 Boot 3 的分支已经比较成熟,主要是切换 Jakarta 命名空间、调整部分配置项,整体改动量可控。
我自己在文案版本上做的事主要有三件:
- 把 Spring Boot 版本统一到 3.x,确保依赖树里没有 Spring Boot 2.x 的残留。
- 确认
javax.*包全部换成了jakarta.*,MyBatis Plus、Sa-Token、Redisson 这些关键依赖都要有对应 Boot 3 的版本。 - 检查自定义 starter 里有没有直接 new ClassPathResource、Class.forName、动态代理之类代码,这些都可能是 Native 打包的定时炸弹。
Spring Boot 3.x 并不是“改完就能直接 native:compile”。它只是把官方支持打通了,具体到 yudao 这种业务复杂的项目,还得靠自己的 RuntimeHints 补齐信息。
2.3 提前清理不符合 AOT 条件的代码
在开始打包之前,建议在 yudao 全局搜一下这些模式:
text复制Class.forName
new URLClassLoader
Thread.currentThread().getContextClassLoader()
ServiceLoader.load
反射调用某个未知类的方法
搜到不一定要改,但要逐个判断:这些调用会不会在启动阶段执行,会不会影响到业务路径。Native Image 的静态分析依赖“归纳出所有被调用的代码”,遇到动态加载行为但没明确提示,它就很容易漏掉。
yudao 的模块设计里,很多地方是用 Spring 的自动装配完成的,比如各个 yudao-spring-boot-starter-* 在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里注册配置类。这种机制 Spring AOT 能处理,不用太担心。真正需要担心的还是 MyBatis Mapper 和 JSON 序列化这两个大头。
3. 核心改造:借助 RuntimeHints 把隐式信息全部交代清楚
3.1 yudao 里最常见的五类隐式元数据
Native 打包报错,十有八九是缺运行时元数据。GraalVM 在设计上做了“关闭反射”的默认策略,而 Spring Boot 应用偏偏到处是反射。Spring Boot AOT 能处理 Spring 容器内部那部分,但处理不了业务代码里隐式的扫描和动态代理。
在 yudao 项目里,我归纳出五类最常见的缺信息场景:
- MyBatis Plus 的 Mapper 接口被 JDK 动态代理,代理类没有在构建期注册,运行时报 Invalid bound statement 或 Mapper method 找不到。
- Mapper XML 里的 SQL 文件没有被打入原生产物,导致 SQL 语句找不到。
- Redis 序列化用到的 DTO、VO、DO 对象没有注册反射,Jackson 或 Fastjson 反序列化时直接失败。
- 验证码组件、图片验证、头像裁剪用到的字体和图片资源没有注册,运行时不报错但渲染空白。
- 动态生成的告警通知、任务回调消息等类型,如果只在运行时通过反射创建,也会被漏掉。
解决思路不是一股脑把整个 classpath 都注册进去。那样虽然不会漏,但会显著增大原生可执行文件和编译时间,违背了 Native 打包的一部分意义。更合理的方式是按需注册。
3.2 在 yudao-server 里落地一个 RuntimeHintsRegistrar
Spring Boot 3 提供了 RuntimeHintsRegistrar 机制,让我们在构建 AOT 阶段把资源、反射、代理、序列化等信息写入提示文件。
我在 yudao-server 里单独建了一个包,专门放 Native 相关的配置。下面是一个基础示例:
java复制package cn.iocoder.yudao.server.nativehint;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.aot.hint.TypeReference;
import java.util.Set;
public class YudaoRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// 1. Mapper XML 与公共配置文件
hints.resources().registerPattern("mapper/**/*.xml");
hints.resources().registerPattern("application*.yaml");
hints.resources().registerPattern("application*.yml");
// 2. 验证码、字体、模板文件
hints.resources().registerPattern("**.ttf");
hints.resources().registerPattern("**.ttc");
hints.resources().registerPattern("templates/**");
// 3. 通过反射创建的 DO/VO
hints.reflection().registerType(
TypeReference.of("cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO"),
MemberCategory.INTROSPECTED_PUBLIC_METHODS,
MemberCategory.PUBLIC_METHODS,
MemberCategory.DECLARED_FIELDS,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
hints.reflection().registerType(
TypeReference.of("cn.iocoder.yudao.module.infra.dal.dataobject.file.FileContentDO"),
MemberCategory.INTROSPECTED_PUBLIC_METHODS,
MemberCategory.PUBLIC_METHODS,
MemberCategory.DECLARED_FIELDS,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
// 4. JDK 动态代理类型
hints.proxies().registerJdkProxy(
TypeReference.of("org.apache.ibatis.session.SqlSession"),
TypeReference.of("cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper"));
}
}
真实项目里 DO、VO 非常多,一个一个注册不现实。更常见的做法是在每个模块下放一个自动配置类,利用自定义注解或 Spring 的扫描把模块里需要的类型统一注册。比如单独做一个注解 @RegisterNativeType,然后在 Registrar 里通过 ClassPathScanningCandidateComponentProvider 扫描指定包:
java复制ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(RegisterNativeType.class));
// 扫描 cn.iocoder.yudao.module.**.dal.dataobject
再把扫描到的类型注册进反射和序列化提示。这种方式比我上面那种硬编码好维护,适合长期迭代。
启动类上加上 @ImportRuntimeHints 让配置生效:
java复制@ImportRuntimeHints(YudaoRuntimeHintsRegistrar.class)
@SpringBootApplication
public class YudaoServerApplication {
public static void main(String[] args) {
SpringApplication.run(YudaoServerApplication.class, args);
}
}
3.3 MyBatis Plus 的 Mapper 代理与 XML 资源要特殊处理
MyBatis Plus 在 yudao 里用得非常多。Mapper 接口本身是通过 MyBatis 的 MapperProxyFactory 创建的 JDK 动态代理,Native 镜像构建时必须提前知道这些代理类型。如果构建日志里出现了:
text复制Could not register JDK proxy for ...
十有八九是 Mapper 接口没有被发现。排查时别急着往 Proxy 里加,先看看该 Mapper 是否被 @MapperScan 或者单独的 @Mapper 注解包进去了。Mapper XML 有另外一个问题:它属于资源文件,不是 Java 类,native-image 不会主动把它打进可执行文件。
我处理 XML 资源时用的是一个通用注册:
java复制hints.resources().registerPattern("mapper/**/*.xml");
同时确认 pom.xml 里不要把这些 XML 过滤掉。Spring Boot Maven 插件默认会把 src/main/resources 下的文件打包,但如果你在模块里有额外的 <resources> 配置覆盖了默认行为,XML 就不会出现在 classpath 里。
怎么确认 XML 有没有进产物?最简单的方法是在 native-image 构建日志里找资源是否被识别。Spring Boot AOT 生成提示文件时,一般会输出类似于 Registering resource pattern: mapper/**/*.xml 的日志。看不到就加 -H:+ReportUnsupportedElementsAtRuntime 或把日志级别调高再构建一次。
4. 打包命令与参数调优:从 jar 到原生可执行文件
4.1 推荐的最低打包命令
yudao 是一个多模块 Maven 工程,直接在整个根目录执行 native 插件会扫描到大量无关模块。我一般只针对 yudao-server 做,但又要把它依赖的模块一起装进来,所以用 -am 参数。
bash复制mvn -pl yudao-server -am \
-DskipTests \
-Dmaven.test.skip=true \
-Pnative \
native:compile
执行前提是 yudao-server 的 pom 里配好了 native profile。没有 profile 的话,原生插件就不会被激活。下面是在 yudao-server pom.xml 里加插件的最小示例:
xml复制<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.3</version>
<extensions>true</extensions>
<configuration>
<mainClass>cn.iocoder.yudao.server.YudaoServerApplication</mainClass>
<imageName>yudao-server</imageName>
<buildArgs>
<buildArg>--enable-url-protocols=http</buildArg>
<buildArg>--enable-url-protocols=https</buildArg>
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
这里有几个点要说明:
extensions>true一定要有,它让 native-maven-plugin 在编译阶段介入。--enable-url-protocols是给那些启动时可能访问 http/https 资源的模块用的。yudao 的第三方登录、支付回调场景可能会用到。-H:+ReportExceptionStackTraces建议在调试期打开,它能在运行时报错时给出更完整的堆栈,否则 native 环境的异常信息经常被“最简模式”压缩得没法看。
4.2 控制构建时间与内存的关键参数
GraalVM Native Image 的本质是一次静态编译,需要大量内存和时间。我第一次编译 yudao-server 时,构建过程多次因为内存不足被杀掉。后来把 Gradle 或 Maven 的堆内存给足,并且给 native-image 单独加大堆内存上限才稳定。
常见参数:
bash复制# 给 Maven 进程加大内存
export MAVEN_OPTS="-Xms4g -Xmx8g"
# native-image 构建进程自身的内存参数
# 如果你用命令行执行,可以直接加
native-image ...
-J-Xmx6g \
-J-Xms2g
如果用 native-maven-plugin,可以在 <buildArgs> 里这样配:
xml复制<buildArg>-J-Xmx6g</buildArg>
构建机器建议至少 8GB 内存、4 核以上。内存太小不是不能跑,而是编译时间会拉得非常长,频繁触发 GC 后还会产生一些奇怪的中断。我在本地 8G 内存的开发机上首次构建耗时大约 12 分钟,后续因为 Maven 本地仓库有缓存、AOT 中间产物有缓存,时间降到 6 到 8 分钟。
4.3 检查产物与运行方式
编译成功后,在 yudao-server/target/native-image/ 下会生成一个可执行文件,名字对应 <imageName>。例如 yudao-server。
运行方式比 java -jar 简单:
bash复制./yudao-server \
--server.port=8080 \
--spring.profiles.active=local \
--spring.datasource.url=jdbc:mysql://127.0.0.1:3306/yudao ...
这里的启动参数和 JVM 参数是两回事。Native 可执行文件已经没有 JVM 了,-Xms、-Xmx 这些参数不能直接用。但 GraalVM 原生镜像支持 -XX:MaximumHeapSizePercent、-XX:MaxRAMPercentage 这类运行时参数,可以在一些操作系统层面限制堆内存。
如果你要把可执行文件往容器里放,可以做一个很小的镜像。镜像里的基础层不再需要完整的 JDK,只需要一个包含必要的动态库的底层系统。比如基于纯 ubuntu 或 debian 镜像,把编译好的 yudao-server COPY 进去即可。这个体积优势与“不需要 JDK”是 Native 打包最直接的红利。
4.4 配置外部化:不要让原生程序去读 Jar 内部的路径
yudao 原生可执行文件跑起来后,工作目录和 jar 包场景有很大区别。原来在 jar 包里的 classpath: 资源仍然能读到,但动态写入 ./logs、./file 这类相对路径,行为会和 JVM 下不完全一样。
我实际遇到的是本地文件模块。yudao 的本地存储默认会写到某个相对目录,在 java -jar 模式下因为相对路径依赖启动命令的位置,所以没注意。换成原生可执行文件后,工作目录一旦不同,文件可能写到不可预期的位置。
解决办法是把动态路径全部外部化,通过配置项指定绝对路径,并把目标目录提前创建好。例如:
yaml复制yudao:
file:
local:
base-path: /opt/yudao/file
上线前还建议把日志路径也切到固定的 /var/log/yudao。这不是 Native 独有的问题,但 Native 模式下问题会被放大,因为你少了一个“重启后打印启动类路径”的核验机会。
5. 问题解决实录:从 Invalid bound statement 到验证码空白的完整链路
5.1 排障先说方法论
GraalVM Native Image 的报错和普通 Java 应用报错有一个很大区别:很多错误不会在编译期暴露,而是编译成功、运行几分钟后才炸。炸的时候堆栈通常还缺了本来该有的行号,看起来很吓人。
我的排障顺序是固定的:
- 先编译,编译不过看编译日志;编译过了就跑起来。
- 启动阶段报错,优先怀疑
RuntimeHintsRegistrar漏注册。 - 启动成功后某个业务接口报错,优先怀疑 Mapper XML、反射、序列化没注册。
- 运行不报错但结果不对,比如验证码空白、图片打不开,优先怀疑资源没有注册。
下面这四类问题是我这次实际从头到尾排查过一遍的,统一记录在这里。
5.2 坑一:所有 Mapper SQL 都报 Invalid bound statement
现象很清晰:native 可执行文件能成功启动,登录接口能收到请求,但执行 SQL 时报:
text复制org.apache.ibatis.binding.BindingException: Invalid bound statement (not found):
cn.iocoder.yudao.module.system.dal.mysql.user.AdminUserMapper.selectList
第一个反应不要先改代码,先还原场景。在 JVM 模式下,同一个 Mapper 是正常的,说明问题不在业务代码,而在 Native 产物的构建信息。
然后我列出了三个嫌疑点:
- Mapper 接口没有被注册为 JDK 代理。
- Mapper XML 文件没有被打包进去。
- Mapper XML 里的 namespace 和接口不一致。
用排除法看:第三个嫌疑不用查,因为 JVM 模式下同一个文件是通的。嫌疑落在一和二。
排查 Mapper XML 时,我先看 Maven 构建日志里有没有处理 mapper/**/*.xml。如果 AOT 日志没出现注册资源模式,排查方向就明确了。最终我把 mapper/**/*.xml 加到资源注册里,同时确认 pom 里的资源路径没有过滤掉 XML,重新编译后才把这条解决掉。
如果你的项目还有 @MapperScan(basePackages = "cn.iocoder.yudao.module.**.dal.mysql") 这类写法,建议打成更具体的包列表。Maven 打包后,mapper 接口的位置在模块 jar 里,扫描时不能覆盖到也是常见诱因。
5.3 坑二:Redis 客户端在反序列化时报 InaccessibleObjectException
项目里有 Redis 缓存,启动阶段没报错,等真正访问某个缓存 key 时才报:
text复制java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.util.Set ... accessible
错误信息里出现 “Unable to make field ... accessible”,说明某个代码路径通过反射访问了一个没有被允许访问的字段。遇到这个错,很多人第一反应是给运行命令加 --add-opens,但在 Native Image 环境里这不是正确解法。
正确做法是定位到具体是哪个类在反射谁。如果你的 Redis 序列化使用的是 fastjson2 或 Jackson,而序列化对象是一个自定义 DO/VO,大概率是这类对象没有注册反射。解决办法是把涉及缓存的对象类型加进 RuntimeHints 的 reflection 注册里。
后来我还发现一个隐藏点:yudao 的 Redis 缓存 key 很多,某些逻辑通过一个公共 DTO 在多个模块之间传递,这个 DTO 不在 Spring 管理的 Bean 里,只是作为 Jackson 的反序列化目标出现。这种情况下 Spring AOT 扫描不到它,必须手动注册。
5.4 坑三:验证码图片能出来,但整张图是空白
这个坑最隐蔽。它在 JVM 模式下完全正常,Native 模式下也不报异常,就是验证码图片输出空白。不是登录失败,也不是 500,而是验证码上没有任何字符。
我花了不少时间怀疑 Base64 编码、response 输出流,后来才想到字体。验证码需要加载系统中的字体文件来绘制字符。Native 可执行文件运行在一个非常精简的环境里,它未必能访问到宿主机的字体目录,或者 AOT 阶段根本不会把字体资源带到镜像里。
处理办法分两步:
- 把验证码渲染要用的字体文件,比如某个 TTF,放到
src/main/resources/下。 - 在 RuntimeHintsRegistrar 里把字体所在的资源路径注册进去:
java复制hints.resources().registerPattern("fonts/*.ttf");
hints.resources().registerPattern("fonts/*.ttc");
字体资源到底有没有进产物,最直观的验证方式是打开验证码图片,如果仍然空白,再看日志里有没有 font 相关的 ClassNotFoundException 或字体解析失败。排障顺序一定是“资源有没有注册”在前,“绘制代码逻辑”在后。
5.5 坑四:启动成功但日志目录、临时文件目录不对
yudao 本身带有异步任务、文件上传、日志记录这些能力。在 java -jar 模式下,这些能力会自动往当前工作目录写入内容。Native 可执行文件如果从一个 cron 脚本启动,工作目录很可能是脚本所在目录,不是你预想的项目目录。
结果是日志文件被写到意想不到的地方,本地文件模块上传的临时文件也会丢失。这个问题不致命,但很干扰排障,因为你以为服务没写日志,其实日志写到别处去了。
解决方法是用绝对路径覆盖所有动态写入点。启动命令里显式指定:
bash复制./yudao-server \
--logging.file.path=/var/log/yudao \
--yudao.file.local.base-path=/opt/yudao/file
同时在容器或 systemd 单元里把工作目录固定下来。Native 可执行文件不再有 java.io.tmpdir 指向一个临时安装目录的逻辑,相关目录需要你亲手准备好。
5.6 问题排查小结
| 现象 | 根因 | 解决方向 |
|---|---|---|
| Invalid bound statement | Mapper XML 缺失或 Mapper 代理未注册 | 注册 mapper XML 资源 + Mapper 代理类型 |
| InaccessibleObjectException | Redis/JSON 序列化对象未注册反射 | 补对象类型的反射配置,先定位是谁在反射 |
| 验证码图片空白 | 字体资源未被打入原生镜像 | 把字体放到 resources 并注册资源 pattern |
| 日志/临时文件目录错乱 | 相对路径依赖工作目录 | 外部化路径并用绝对路径启动 |
| 启动报 NoClassDefFoundError | 动态加载的类未进入构建闭环 | 排查 Class.forName、反射调用,看是否需要增加初始化配置 |
6. 实际效果与上线前还需要处理的几件事
6.1 同一分支下的对比数据
我用同一份 yudao-server 代码,分别跑 Fat Jar 和 Native 可执行文件。硬件是普通的 4 核 8G 开发机,数据库和 Redis 都指向同一个外部实例。
Fat Jar 模式,从执行 java -jar 到日志出现“Started YudaoServerApplication”大概 7 到 9 秒,稳定后占用内存约 850MB,没访问任何接口的时候进程就已经占着这么多了。
Native 模式,可执行文件从启动到服务可用,大概 1 秒上下;稳定后内存约 300MB,业务高峰时也没超过 450MB。内存红利主要来自两个方面:一是没有 JVM 的元空间和 GC 框架开销,二是 GraalVM 在构建期已经把很多初始化逻辑处理完,不需要在运行期做重复计算。
启动速度对日常开发影响不大,但对“故障后自动拉起”“定时任务在非高峰时段短时运行”这类场景非常有用。
6.2 上线前务必要做的配置收敛
Native 可执行文件一旦生成,运行期改配置的能力比 JVM 弱很多。你没法在启动后通过 JMX 改日志级别,也不能热加载一个类。所以上线前建议把 yudao 的配置收敛成三层:
- 不变的本地配置写在
application.yaml里。 - 按环境区分的配置用
--spring.profiles.active=$ENV指定。 - 真正的动态配置放到 Nacos 或 Apollo 这类配置中心。
yudao 本身有配置中心接入能力,这部分逻辑不用改,只需要确认在 Native 模式下配置中心客户端能够正常工作。比如 Nacos 客户端的服务发现和配置拉取如果通过反射实例化对象,也需要在 RuntimeHints 里补齐。
日志、异常堆栈、监控端点都要提前验证。生产环境里如果某个接口出错,而你给 Native 可执行文件关了详细堆栈,排障会非常痛苦。建议至少保留 -H:+ReportExceptionStackTraces 编译参数,或者使用独立的 APM agent 来收集运行细节。
6.3 CI/CD 里的 Native 构建要不要缓存
如果团队打算把 Native 打包放进 Jenkins 或 GitLab CI,一定要把 Maven 本地仓库缓存起来,否则每次构建都要重新下载依赖、重新跑一遍 AOT,耗时非常长。
我这边在流水线里加了两个缓存:
- Maven 本地仓库缓存,路径一般是
~/.m2/repository。 - native-image 的构建缓存,GraalVM 提供
-H:CacheDir参数,建议设置到 CI 的持久化目录。
示例参数:
xml复制<buildArg>-H:CacheDir=${project.build.directory}/native-cache</buildArg>
有了缓存以后,后续构建时间能够明显回落。如果你不设置缓存,那每次代码改动都会触发一次从上到下的完整编译,团队迭代速度会被拖垮。
6.4 我的建议:这几种情况暂时可以不折腾 Native
最后说点反话。
如果你的 yudao 项目仍停留在 Spring Boot 2.7 之前,升级 Boot 3 的代价大于收益,那我不建议为了 Native 而强行升级。如果团队没有容器化、服务节点就两三个、冷启动时间完全可以接受,Native 的收益也不明显。GraalVM Native Image 对反射的限制会让一些二方 SDK 暴露问题,排查成本可能会超出省下的那点内存费用。
我的实际体会是:Native 打包适合 yudao “已经跑得比较稳定、需要规模化部署”的阶段,不适合在快速迭代功能的时候引入。如果你已经决定要走这条路,那先把运行时元数据梳理清楚,再谈打包速度和镜像瘦身。把我上面列的这些坑过一遍之后,你大概率能少走一半弯路。
