Administrator
发布于 2022-07-28 / 1760 阅读
43

GraalVM 原生镜像:Spring Boot 启动从 3 秒到 0.1 秒

冷启动 3 秒,Serverless 上却要等 30 秒

我们的一个图片处理函数被搬上了函数计算(FaaS),按调用计费、闲置回收。问题来了:JVM 冷启动要 3 秒,加上函数平台拉镜像、建实例,一次冷启动用户要等 30 秒以上,体验很差。同事问能不能"像 Go 那样秒起"。我试了 GraalVM 原生镜像(Native Image)——把 Spring Boot 应用编译成机器码,启动直接到 0.1 秒级。

必须说明:这是 2022 年的实验性能力。当时 Spring 官方的原生支持要等到 11 月的 Spring Boot 3.0,我们现在是基于 Spring Native 0.11(实验项目) + GraalVM 22.2 做的预研,生产落地要谨慎。

AOT 编译:把"运行时做的事"提前到"构建时"

普通 java -jar 是 JIT:类加载、字节码校验、反射/代理的解析都发生在运行时。原生镜像走的是 AOT(Ahead-Of-Time):在构建阶段就通过闭包分析把 reachable 的代码、对象图全部确定下来,直接编译成不依赖 JVM 的机器码。

正因如此,它有两项"反直觉"的限制:

  • 静态分析看不到的东西都会丢失。反射、动态代理、JNI、资源加载,如果没在构建时声明,运行时就不存在。
  • 没有类懒加载,所有代码编译进二进制,所以二进制很大(我们那个小服务有 80 MB),但启动极快、内存占用低。

反射配置:最磨人的一步

Spring 大量用反射和动态代理,原生镜像默认不知道要保留哪些类。我们需要提供反射配置,让构建器把相关类"登记"进去:

# reflect-config.json,告诉 GraalVM 这些类运行时通过反射访问
[
  {
    "name": "com.xxx.ImageRequest",
    "allDeclaredConstructors": true,
    "allDeclaredMethods": true,
    "fields": [{ "name": "url" }, { "name": "width" }]
  }
]

Spring Native 提供了 @NativeHint 注解和运行时反射提示机制,能自动为很多常见场景生成配置,但业务里自己 Class.forName 或者 JSON 序列化用到的 DTO,经常还是得手动补。我们花了两天把 Jackson 要序列化的几十个 DTO 一个个登记上。

构建代价:慢,而且很吃内存

原生镜像的代价在构建阶段。一次完整 native build 在我们 8 核机器上跑了 4 分 12 秒,且峰值占用 6 GB 内存(闭包分析很重)。对比普通 jar 打包只要 20 秒:

指标传统 JVM jarGraalVM 原生镜像
打包耗时20 秒4 分 12 秒
启动时间3.0 秒0.1 秒
RSS 内存(空闲)380 MB48 MB
二进制大小jar 28 MB可执行 80 MB

适用场景评估:不是所有服务都该上

我画了条决策线:

  • 适合:FaaS/Serverless 函数、CLI 工具、短时任务——冷启动敏感、生命周期短,原生镜像的启动和内存优势直接变现。
  • 不适合:长生命周期、依赖大量反射/动态特性的复杂 Web 应用——构建慢、调反射配置心累,且失去 JIT 在长时间运行后的峰值吞吐优化。
  • 勉强:我们那个图片处理函数,最终上了原生镜像,冷启动从 30 秒降到 1.5 秒,效果显著。

小结

  • 原生镜像是 AOT,把类加载/反射解析提前到构建期,启动快、内存小,但二进制大、构建慢。
  • 反射、动态代理、资源必须显式登记,这是迁移里最费工的部分。
  • 构建时间是真实成本:4 分钟一次,CI 流水线要重新评估。
  • 2022 年它仍是实验性能力,选短生命周期、反射少的场景试点最稳。

原生镜像像一把手术刀:用对地方(冷启动敏感的函数)立竿见影,拿去切大系统反而到处是反射配置的伤口。我们现在的策略是只把它用在该用的边缘场景,核心服务老老实实跑 JVM

参考