Java 应用冷启动优化——从 14 秒到 3 秒的完整优化历程
Java 应用冷启动优化——从 14 秒到 3 秒的完整优化历程
适读人群:有 Spring Boot 项目经验的 Java 开发者 | 阅读时长:约 16 分钟 | 核心价值:系统性梳理冷启动优化方法,附真实数据与踩坑经验
2023年11月的一个周四早上,我们做灰度发布,一个 Pod 重启之后,前端页面转圈转了将近 14 秒才出来第一个接口响应。产品经理站在我旁边,脸色不太好看。
14.3 秒。我当时看着监控面板,心里是有点崩的。
那台服务是我们的核心订单服务,Spring Boot 2.7 + Spring Cloud + Mybatis-Plus + 大量自研 starter,类还不少,光是 classpath 扫描出来的 Bean 就有 1847 个。我当时加入这个团队才三个月,这锅算是接了个烫手的。
后来花了将近三周,把启动时间压到 2.87 秒。这篇文章把整个过程完整写下来,不藏私。
先搞清楚时间都花在哪了
优化的第一步不是动代码,是先量。我见过很多同事上来就说"类太多了,要懒加载",然后改了半天,启动时间从 14 秒变成 13.8 秒,差点自欺欺人。
我的选择是 Spring Boot Actuator + startup endpoint。
不选其他方案的理由很直接:startup endpoint 是官方支持的,不需要额外依赖,输出的数据细到每个 Bean 的初始化耗时,够用。
加依赖和配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>management:
endpoints:
web:
exposure:
include: startup
endpoint:
startup:
enabled: true启动类加一行:
package com.example.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(OrderServiceApplication.class);
// 缓冲 2048 个启动事件,超过这个数会丢弃最早的——我们项目 Bean 多,调大了
app.setApplicationStartup(new BufferingApplicationStartup(2048));
app.run(args);
}
}启动之后访问 http://localhost:8080/actuator/startup,把 JSON 存下来,用 jq 或者直接导入 Excel 排序,看哪个 Bean 最慢。
我当时的结果是这样的(截取前几名):
| Bean 名称 | 耗时 |
|---|---|
| dataSourceInitializerInvoker | 2.34s |
| redisConnectionFactory | 1.87s |
| xxlJobSpringExecutor | 1.23s |
| elasticsearchClient | 0.98s |
| mybatisPlusAutoConfiguration | 0.67s |
光这前五项就 7 秒多。方向有了。
踩坑实录一:数据源初始化 2.34 秒,根本不是连接池的锅
看到 dataSourceInitializerInvoker 排第一,我第一反应是连接池配置有问题,上来就改 HikariCP 的参数:
spring:
datasource:
hikari:
minimum-idle: 1 # 从 10 改成 1
maximum-pool-size: 10
connection-timeout: 3000
initialization-fail-timeout: -1 # 禁用启动时验证改完重启,2.31 秒。基本没变化。
我意识到问题不在连接池,连接池的初始化只是其中一小部分。真正的大头是 数据源初始化器在启动时执行了 schema.sql 和 data.sql。我们有一个 starter 里内嵌了一段 SQL 脚本,在测试环境里用来初始化表结构,不知道什么时候被带到了生产环境的配置里。
找到根源:
# 这个配置在某个 starter 的 application.yml 里,被我们引入了
spring:
sql:
init:
mode: always # 每次启动都执行 SQL 脚本,生产环境不需要这个改成:
spring:
sql:
init:
mode: never启动时间砍掉 2.1 秒,直接降到 12.2 秒。这一步是整个优化过程中最爽的一次,因为代价接近零。
教训:不要以为你了解自己引入的每个 starter 干了什么。专门排查一遍 starter 里的 auto-configuration 是必要的。
踩坑实录二:Redis 连接工厂慢,是因为我们配了 DNS 解析
Redis 的 1.87 秒让我纳闷,我们的 Redis 是局域网内的,延迟应该在 1ms 以内,怎么连接要 1.87 秒?
排查了一圈,发现问题在 Redis 连接地址配置:
spring:
redis:
host: redis.internal.ourcompany.com # 用了域名
port: 6379这个域名在 DNS 解析上花了约 1.6 秒。我们内网的 DNS 服务器不太稳定,有时候解析得快,有时候超时重试。
解法是直接用 IP:
spring:
redis:
host: 10.20.31.47 # 直接用 IP,不走 DNS
port: 6379降了 1.5 秒,到 10.7 秒。
但我想多说一句:改成 IP 有运维上的代价,IP 变了要改配置。更合理的做法是在 /etc/hosts 里加一条记录,或者修复内网 DNS。我们当时为了速度直接改 IP,后来确实被运维骂了一次,因为 Redis 迁移的时候没人记得改这个配置。
懒加载:能用,但要小心
Spring Boot 2.2 开始支持全局懒加载:
spring:
main:
lazy-initialization: true这个配置我用了,效果是启动时间少了约 3 秒,从 10.7 秒降到 7.8 秒。
但是!懒加载不是没代价的。有几个问题需要注意:
- 启动后第一次请求会更慢,因为这时候才真正初始化 Bean。我们的订单接口第一次调用从平均 80ms 变成了 350ms 左右,持续了几秒。
- 配置错误在启动时不会暴露。以前启动时就能发现 Bean 注入失败,现在要等到真正用到才报错,排查难度上升。
- 某些 Bean 不能懒加载,比如
@Scheduled定时任务的 Bean,必须在启动时初始化,不然定时任务不会执行。
我的实际方案是:开启全局懒加载,然后对关键 Bean 单独标注:
package com.example.order.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
@Configuration
public class SchedulerConfig {
// 这个 Bean 不能懒加载,不然定时任务启动不了
// 之前踩过这个坑,定时任务静默失败,排查了很久
@Bean
// @Lazy // 注意:这里不能加 @Lazy
public OrderCleanupScheduler orderCleanupScheduler() {
return new OrderCleanupScheduler();
}
// 这个 Bean 只有在 HTTP 请求时才用到,可以懒加载
@Bean
@Lazy
public OrderExportService orderExportService() {
return new OrderExportService();
}
}XXL-Job 注册器慢:连接远程调度中心超时
xxlJobSpringExecutor 的 1.23 秒,排查下来是因为我们的测试环境执行器在初始化时会向调度中心注册,而调度中心地址在测试环境有时候不可达,等了 TCP 超时时间。
解法一:调小 XXL-Job 的连接超时(治标)。
解法二:在容器启动完成后再注册,而不是在 Bean 初始化时(治本)。
package com.example.order.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component
public class XxlJobLateInitializer implements ApplicationListener<ApplicationReadyEvent> {
private final XxlJobSpringExecutor xxlJobSpringExecutor;
public XxlJobLateInitializer(XxlJobSpringExecutor xxlJobSpringExecutor) {
this.xxlJobSpringExecutor = xxlJobSpringExecutor;
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
// 延迟到应用完全 Ready 之后再启动 XXL-Job 执行器
// 这样不影响启动时间,也不会让健康检查在调度中心连接失败时失败
try {
xxlJobSpringExecutor.afterPropertiesSet();
} catch (Exception e) {
// 记录日志但不抛异常,调度功能失败不影响核心业务
log.warn("XXL-Job executor start failed, scheduled tasks will not work", e);
}
}
}这个改法让启动时间又少了约 1 秒,到 6.8 秒。
踩坑实录三:@ComponentScan 扫描范围太宽
我们的启动类在 com.example 包下,而 @SpringBootApplication 默认扫描启动类所在包及其子包。但问题是,我们有几个独立的工具模块,包名也在 com.example 下,导致大量不必要的类被扫描和初始化。
用 startup endpoint 发现,有一大批 Bean 是我们完全不需要的分析工具 Bean,它们的初始化加起来有 0.8 秒左右。
package com.example.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
// 精确指定扫描包,不让它乱扫
@SpringBootApplication(scanBasePackages = {
"com.example.order", // 核心业务
"com.example.common", // 公共组件
"com.example.infra" // 基础设施
// com.example.analytics 不扫描,那里是数据分析模块
})
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(OrderServiceApplication.class);
app.setApplicationStartup(new BufferingApplicationStartup(2048));
app.run(args);
}
}减少了 0.8 秒,到 6 秒。
JVM 参数调优:GC 和类加载
这部分是我后期做的精细调整,单独每项效果不大,加起来有约 1.5 秒。
# 我们生产环境用的 JVM 参数(Java 17)
java \
-server \
-Xms2g -Xmx2g \
# 不要让 JVM 启动时分配最小堆再慢慢增长,直接分配到位
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
# 禁用 Biased Locking,Java 15+ 已弃用,但在 Java 11 环境中能减少启动时 revocation
# -XX:-UseBiasedLocking \
# 关闭启动时的字节码验证(只在你信任代码来源的情况下用,测试环境别用)
# -Xverify:none \ # 这个我最终没用,风险太高
-XX:TieredStopAtLevel=1 \ # 只用 C1 编译,牺牲运行时性能换启动速度
-Djava.security.egd=file:/dev/./urandom \ # 解决 SecureRandom 阻塞问题
-jar order-service.jar特别说一下 -XX:TieredStopAtLevel=1:这个参数让 JVM 只做 C1(客户端)编译,不做 C2(服务端)编译。启动快了约 0.8 秒,但代价是热路径代码的运行性能会差一些,在流量上来之后 CPU 会略高。这个取舍要根据你的业务场景判断——如果是 serverless 场景、短生命周期 Pod,值得用;如果是长期运行的服务,建议不用。
Spring AOT 和 GraalVM Native:我的判断
Spring Boot 3.x 推出了 AOT 编译和 GraalVM Native Image 支持,很多文章说能把启动时间压到几百毫秒甚至几十毫秒。
我评估了一下,没有采用,原因是:
- 反射依赖问题:我们项目大量用了反射,包括 Mybatis-Plus 的动态 SQL、自研的字段加密框架,GraalVM Native 需要提前声明所有反射调用,改造成本极高。
- 调试困难:Native Image 的调试体验比传统 JVM 差很多,出了问题不好排查。
- 我们的收益有限:2.87 秒对我们的场景够用了,不值得为了再快 2 秒付出这么大的改造代价。
如果你是新项目,或者项目依赖干净,GraalVM 值得研究。但对于有历史包袱的项目,我的判断是别急着上。
最终结果
| 优化项 | 减少时间 | 备注 |
|---|---|---|
| 禁用 SQL 初始化脚本 | -2.1s | 检查 starter 里的隐藏配置 |
| Redis 改用 IP | -1.5s | 根本原因是 DNS 不稳定 |
| 开启懒加载 | -2.9s | 注意关键 Bean 要排除 |
| XXL-Job 延迟注册 | -1.0s | 不影响业务功能 |
| 精确 ComponentScan | -0.8s | 避免扫描无用模块 |
| JVM 参数调整 | -0.8s | TieredStopAtLevel=1 等 |
| 其他小优化 | -3.2s | Elasticsearch 懒连接等 |
| 合计 | -12.3s | 从 14.3s 到 2.0s(含后续持续优化) |
严格来说最终到了 2.0 秒,比我一开始说的 2.87 秒还好,那是在我写这篇文章之前又做了几轮小优化之后的结果。
最重要的教训:不要猜,要量。 量了之后,很多你以为的性能问题根本不是问题,而真正的问题往往出乎意料(比如那个 DNS 解析)。
