2842 字
约 9 分钟
22
S07-A06 · 启动速度与打包形态:Spring Boot 的部署决策

同一份 jar,笔记本上 Started Application in 3.1s,进容器和镜像仓库后变成四十秒。不是 JVM 变慢,而是启动被拆成几段可分别计价的工作,部署形态决定每段的权重。启动时间在单体里是开发体验问题,在弹性伸缩下是容量问题——副本还没就绪就被判死,等于买了机器不产生吞吐。

判断顺序:先量清那几十秒落在哪一段,再决定改代码、改打包还是改探针。跳过测量直接开 lazy-initialization,最常见结局是把启动期错误推迟成运行期 500。


1. 把启动切成五段分别计时

Spring Boot 3 的 ApplicationStartup 提供了标准的阶段埋点,用 BufferingApplicationStartup 就能在日志里打出全部分段。

public static void main(String[] args) {
    SpringApplication app = new SpringApplication(ClarityWebApplication.class);
    app.setApplicationStartup(new BufferingApplicationStartup(2048));
    app.run(args);   // 启动后遍历 getStartupEvents,按 duration 倒序打日志
}
阶段 内容 典型量级 主要削减手段 代价
类加载与校验 解压 jar、读 class、链接 秒级,类数越多越长 精简依赖、CDS/AppCDS、分层 jar 缓存 CDS 需随镜像重建
组件扫描 遍历 classpath 匹配注解 与包规模相关 收窄 scanBasePackages、去掉无用 starter 改错一行就注入失败
自动配置 条件评估 + Bean 实例化 与 starter 数量相关 排除不需要的自动配置 排除后需要手写装配
连接池与外部资源 建连、认证、TLS、预热 网络 RTT × 连接数 懒建连、minimumIdle 调小 首请求延迟抬高
数据预热 缓存、字典、规则加载 取决于数据量 异步预热、分批 就绪判定要跟着改

先看分段再开药:如果 40 秒里有 25 秒在等建连超时,优化扫描包毫无用处。


2. 组件扫描与自动配置:先减依赖,再谈启动

多数"启动慢"的真实原因是 classpath 上躺着一堆用不到的 starter。判断方法很朴素:逐个注释掉 starter、启动一次、比对分段耗时。

// 收窄扫描面:默认从主类所在包向下扫,显式声明能省掉无关子树
@SpringBootApplication(scanBasePackages = "top.clxhxhhr.web")
public class ClarityWebApplication { }

// 排除确认不用的自动配置,比 scanBasePackages 更细粒度
@SpringBootApplication(exclude = {
        DataSourceAutoConfiguration.class,
        RedisAutoConfiguration.class })

这类改动收益确定、风险也确定:删 starter 与收窄扫描面改错就启动报错,不会潜伏到运行期,所以最该先做。


3. spring.main.lazy-initialization:把错误搬了个位置

全局开关:启动时只创建被依赖链强制需要的 Bean,其余延迟到首次使用。

spring:
  main:
    lazy-initialization: true
  # 反例:全局懒加载 + 关键 Bean 也懒加载 = 第一个请求吃全部成本
维度 立即初始化 全局懒初始化
启动耗时 全部 Bean 建完 只建入口链路
配置错误暴露时机 启动期,进程起不来 运行期,第一个请求 500
首请求延迟 平稳 尖峰,可能叠加建连与预热
内存峰值 启动即达峰 渐进抬升,更难做容量判断
适合场景 发布频率低、要求"起来就是对的" 短生命周期、Worker 型进程

反直觉的结论:懒加载几乎不减少总工作量,只把工作量挪到请求路径。对常驻服务,它把集中、可观测的故障换成分散、随机的故障。逐 Bean 挑"重且不急"的(报表聚合、字典同步)标 @Lazy,比全局开关可控得多。


4. AOT 与 native image:把启动收益和构建成本一起算

Spring Boot 3 的 AOT 发生在构建期,产出额外生成的 Java 源码与反射元数据;native image 再把这些静态编译成二进制。二者常被混称,解决的是不同问题。

# 构建期生成 AOT 产物(类路径模式,仍跑在 JVM 上)
mvn -Pnative spring-boot:process-aot

# 完整 native 构建,耗时为分钟到十几分钟量级,按机器与依赖规模核对
mvn -Pnative native:compile
维度 JVM + fat jar native image
构建时长 分钟级 十几分钟到几十分钟量级,需单独流水线与缓存
启动耗时 秒级到数十秒 明显更低,具体倍数随应用构成变化
反射/动态代理 运行期自由使用 必须显式注册,缺一条就 ClassNotFound
常驻内存下限 堆 + Metaspace,可调 无 JIT/Metaspace,但代码段与元数据占位固定
峰值吞吐 JIT 优化后可高于静态编译 通常低于长跑 JIT
调试难度 常规 attach、堆转储 堆转储与采样受限,排障工具链更窄

决策口径是进程寿命:跑几十秒的短生命周期进程,构建成本一次性付出、启动收益每次兑现;跑几十天的常驻服务,启动只发生一次,为它牺牲峰值吞吐和调试能力不划算。


5. 打包形态决定镜像层的缓存命中

fat jar 是不可分的大文件,改一行代码整层失效,推送量与拉取时间跟着走。分层 jar 把不变的依赖、loader 与变化的应用类拆开。

FROM eclipse-temurin:17-jre AS builder
WORKDIR /app
COPY target/clarity-web.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]
形态 层缓存收益 启动影响 适用
fat jar 单层 无,改一行全层失效 基线 本地开发、临时环境
分层 jar(layertools / tools extract) 依赖层稳定命中,日常发布只推应用层 与基线接近 常规容器部署
依赖外置 + 瘦 jar 依赖层完全不变,镜像可复用 类加载路径变多,略降 多服务共用同一基础依赖集
镜像内 -XX:SharedArchiveFile 类加载段再缩短 需随 JDK 与依赖重建 CDS 已确认启动是瓶颈

jarmode 在 3.x 小版本上有 layertools 与 tools 两种写法,按自己项目的版本核对。


6. 瘦 jar 与依赖外置:容器里值得做,但不是免费

瘦 jar 把 BOOT-INF/lib 挪出 jar,用 loader.path 或 classpath 目录加载。

java -Dloader.path=/app/lib -jar clarity-web-thin.jar

代价三条:类加载要扫更多目录项,启动段轻微回退;依赖与应用的版本关系从"jar 内自洽"变成"运维必须对齐",错配表现为 NoSuchMethodError 而不是启动失败;-Dloader.path 属 Spring Boot loader 行为,换打包插件需重新验证。收益在镜像复用与增量发布。


7. 探针:startupProbe 存在的理由

livenessProbe 的语义是"进程是否该被重启",readinessProbe 是"是否该接流量"。启动慢的服务如果只配这两个,会出现"应用还在建连、liveness 已经判死"的循环重启。

startupProbe:            # 只负责"给它时间",成功前不跑 liveness
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  failureThreshold: 30
  periodSeconds: 2
livenessProbe:
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  periodSeconds: 10
readinessProbe:          # 只负责"能不能接流量",失败只摘流量不重启
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  periodSeconds: 5
现象 机制原因 处理方向
副本反复重启,日志停在建连阶段 failureThreshold × periodSeconds 小于真实启动时间 补 startupProbe,阈值按 P95 启动时间留余量
起来了但一直不接流量 readiness 判据含预热完成,却从没完成 把预热做成可观测状态,别只靠时间猜
滚动发布卡住 maxUnavailable 与启动时间叠加,新副本迟迟不可用 拆 readiness 与 liveness,缩短就绪路径
扩容时被杀 弹性伸缩的 scaleUp 与探针容忍不匹配 冷启动时间必须进扩容预算

冷启动与扩容的交互最易被忽略:扩容决策基于"当前 CPU 高",新副本却要几十秒才就绪,这段时间旧副本继续被压。启动时间大于流量增长的时间常数时,扩容即无效扩容;它也是容量模型的一部分。


8. 排错:五种"启动了但不像预期"

现象 最可能的原因 核对方式
本地快、容器慢 CPU limit 过低导致 JIT 编译线程被节流 对比容器内外 jcmd <pid> VM.flags 与 nproc
启动耗时忽长忽短 建连重试、DNS 解析、配置中心长轮询超时 看分段耗时里"外部资源"段的抖动
启动后第一批请求全超时 懒加载 + 连接池 minimumIdle 同时调小 抓首请求的线程栈与池指标
镜像拉取占了大半发布时长 单层 fat jar,每次全量推送 docker history 看层大小分布
native 镜像启动报缺反射配置 动态代理/序列化类未注册 启动日志里的 --enable-url-protocols 类提示与 trace 文件

9. 边界:什么时候优化启动就是浪费

场景 判断 理由
单体应用,一天发布一次 不优化 一年只多等几十分钟,改打包的验证成本更高
常驻不重启,靠滚动发布 优先降低单批不可用窗口 启动时间只在发布期兑现
已确认启动在 SLA 可接受区间 转向请求路径优化 用户感知的是 P99 而不是 Started in
弹性伸缩依赖秒级扩容 值得投入 启动时间直接决定扩容有效性
短生命周期批处理/函数 值得投入 每次执行都付启动成本
团队没有分段埋点 先埋点 没有分段数据,任何改动都无法归因

最后一条是入口:启动优化需要基线。没有分段耗时与发布频率这两个数字,讨论 lazy-initialization 或 native image 只是交换立场。


10. 自测

  1. 容器里启动比本地慢十倍,你会先看哪两个数字?
  2. lazy-initialization 把哪一类故障从启动期挪到了运行期?
  3. 分层 jar 的核心收益是什么,它和"启动更快"是同一件事吗?
  4. startupProbe 与 livenessProbe 的语义差别是什么,为什么不能合并?
  5. 什么情况下扩容了却没有增加吞吐?
  6. 说出三个"优化启动时间属于浪费"的具体场景。
S07-A06 · 启动速度与打包形态:Spring Boot 的部署决策
https://www.clxhxhhr.top/posts/6188/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。