冷启动 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 jar | GraalVM 原生镜像 |
|---|---|---|
| 打包耗时 | 20 秒 | 4 分 12 秒 |
| 启动时间 | 3.0 秒 | 0.1 秒 |
| RSS 内存(空闲) | 380 MB | 48 MB |
| 二进制大小 | jar 28 MB | 可执行 80 MB |
适用场景评估:不是所有服务都该上
我画了条决策线:
- 适合:FaaS/Serverless 函数、CLI 工具、短时任务——冷启动敏感、生命周期短,原生镜像的启动和内存优势直接变现。
- 不适合:长生命周期、依赖大量反射/动态特性的复杂 Web 应用——构建慢、调反射配置心累,且失去 JIT 在长时间运行后的峰值吞吐优化。
- 勉强:我们那个图片处理函数,最终上了原生镜像,冷启动从 30 秒降到 1.5 秒,效果显著。
小结
- 原生镜像是 AOT,把类加载/反射解析提前到构建期,启动快、内存小,但二进制大、构建慢。
- 反射、动态代理、资源必须显式登记,这是迁移里最费工的部分。
- 构建时间是真实成本:4 分钟一次,CI 流水线要重新评估。
- 2022 年它仍是实验性能力,选短生命周期、反射少的场景试点最稳。
原生镜像像一把手术刀:用对地方(冷启动敏感的函数)立竿见影,拿去切大系统反而到处是反射配置的伤口。我们现在的策略是只把它用在该用的边缘场景,核心服务老老实实跑 JVM。