Java

Spring Boot 启动卡在 Bean 初始化?先别说框架慢,先看它在等什么

启动日志卡在某个 Bean 附近时,问题往往不是 Spring 自己慢,而是这个 Bean 把远程调用、数据预热或一整串依赖都拖进了启动关键路径。

  • Spring Boot
  • Bean 初始化
  • 启动慢
  • 依赖注入
  • 故障排查
14 分钟阅读

Bean 初始化把服务卡住这种事,很多团队都见过。

日志刷着刷着停在某个 Bean 名字附近,应用既没完全挂,也迟迟起不来,最后大家很容易把它概括成一句:Spring Boot 启动卡住了。

但真到现场里,这句话通常不够用。

因为你看到的“卡在某个 Bean”,背后可能完全不是一回事:

  • 有的是依赖树太长,真正慢的是前面一串注入和代理
  • 有的是 @PostConstruct 里顺手干了很多重活
  • 有的是 Bean 初始化只是把数据库、配置中心、下游 HTTP 这些等待暴露了出来
  • 还有些是服务其实快起来了,只是 readiness 很晚,平台先把它当失败处理了

所以我现在排这类问题,已经很少先问“哪个 Bean 卡住了”,而更想先问:它到底卡在创建、初始化,还是外部等待;它到底是在做事,还是在等别人。

这两个问题一旦问清楚,后面通常就不会在“Spring 到底哪里慢”这种大词里打转。

真实项目里,这类现场通常有两个很像的开场

一种是日志明确停在某个 Bean 附近

比如:

  • 某个配置类之后就没动静了
  • 某个 service 创建到一半很久不返回
  • 某个客户端 Bean 初始化之后应用迟迟不 ready

这种时候大家会本能地盯住这个 Bean。

这当然没错,但要注意:日志停在它这里,不代表慢的一定就是它自己。

它可能只是那条启动链上,最后一个把问题暴露出来的节点。

另一种是“应用就是很慢”,但最后线程栈或日志都落到 Bean 初始化附近

这种更容易误判。

因为你表面上看到的是整体启动慢,不一定一开始就能确认问题真在 Bean 初始化。很多时候是先查启动慢,最后才发现线程一直挂在某个初始化回调、某段远程握手、或者某个数据预热动作上。

也就是说,Bean 初始化卡住往往不是一个独立专题,它常常是“启动慢”这个大现象里最后落下来的那个点。

我自己排这类问题,通常先问一个很土但很有用的问题:它在等什么

很多排查文章喜欢先分生命周期、先列注解、先讲 Bean 创建流程。

这些当然没错,但到真实现场,我最先想确认的反而是:

这个卡住的 Bean,到底是在 CPU 上忙,还是在线程里等。

因为这直接决定你后面该往哪个方向走。

如果它主要是在“忙”

更像这些情况:

  • 依赖树拉得很长
  • 初始化回调里做了大量计算或装配
  • 启动时全量加载规则、字典、租户配置
  • 读大文件、编译表达式、构建索引

这种问题的核心通常不在外部可达性,而在你把太多必须完成的工作压进了启动关键路径。

如果它主要是在“等”

更像这些情况:

  • 等数据库连通
  • 等配置中心返回
  • 等某个下游 HTTP / RPC 服务
  • 等 Redis、MQ、ES 首次握手
  • 等 DNS、网络、证书、代理

这时“卡在 Bean 初始化”只是表面。真正的启动门槛,其实已经被你放到了容器外面。

我会特别在意这层,是因为很多团队会把这两种情况混说成同一类慢启动,结果优化动作完全不一样。

最容易出事的,不是 Bean 名字,而是启动时顺手做的那些事

真实项目里,最危险的往往不是某个 Bean 本身,而是它在初始化时顺手做的那些动作。

尤其是下面这些位置:

  • @PostConstruct
  • afterPropertiesSet
  • 自定义 initMethod
  • CommandLineRunner
  • ApplicationRunner

这些地方特别适合“顺便做点事”,也特别容易越做越重。

一开始可能只是:

  • 拉一份基础配置
  • 初始化一个本地缓存
  • 预热一小批数据

后来项目一长,就会慢慢变成:

  • 启动时全量查库
  • 顺手把历史状态扫一遍
  • 把规则、字典、租户配置全部拉进来
  • 远程取元数据、校验连接、做启动自检

到最后,服务启动已经不只是“把应用拉起来”,而是变成“先把一堆业务前置动作都做完”。

这才是很多 Bean 初始化问题真正的根。

还有一类很典型:看起来卡在这个 Bean,其实是它背后那条依赖链太长

这种现场我见得也很多。

日志最后停在 A Bean,于是大家围着 A 改半天。结果后来一层层展开才发现:

  • A 依赖 B
  • B 又带起 C、D、E
  • 其中还有自动配置出来的客户端和工厂 Bean
  • 真正慢的是后面一长串依赖创建和代理装配

这类问题最容易让人有一种错觉:好像只要把最后这个 Bean 优化一下,启动就会恢复。

但很多时候,最后这个 Bean 只是依赖链最末端的“背锅位”。

如果你看到:

  • 一个核心 Bean 背后挂了很多对象
  • 自动配置、自定义配置和业务 Bean 混在一起
  • 创建顺序绕得很复杂

那重点通常不该只放在那个最终停下来的 Bean 名字上,而应该去看整条依赖注入链是不是已经太重了。

另一类更像事故现场:初始化里混进了远程调用

这类问题特别像“本地没事,线上一扩容就出事”。

为什么?因为本地环境里:

  • 数据量小
  • 网络近
  • 下游通常也在本机或测试环境
  • 启动次数少,大家不太在意那几秒差距

可到了线上,尤其是扩容、滚动发布、节点重启、故障恢复时,这类初始化逻辑就会突然变成风险点。

最典型的是这些动作被塞进启动期:

  • 启动时请求配置中心
  • 启动时连数据库并做一轮检查
  • 启动时拉远程规则
  • 启动时访问别的服务确认状态

只要其中一个下游抖一下,整个启动链就跟着变长。

然后日志上看起来像“卡在某个 Bean”,本质却是这个 Bean 把外部依赖变成了启动门槛。

数据预热这件事,也最容易被理所当然地塞进启动前

还有一种现场,不是外部依赖挂了,也不是某段代码真写得很烂,而是大家默认:这批数据反正迟早要用,干脆启动时一次性预热掉。

于是就会出现:

  • 启动时全量加载字典
  • 启动时拉全租户配置
  • 启动时回填缓存
  • 启动时扫描历史状态

一开始感觉很合理,甚至还有点“启动完更稳”的味道。

但数据量一旦变大,这些动作就会开始吞掉启动时间。而更麻烦的是,它们不一定真的都值得阻塞 ready。

很多服务最后不是因为 Spring 太慢,而是因为它被要求在 ready 之前先做完太多“其实可以晚一点做”的事。

我更信的排查线,不是“第几步”,而是先把现场钉死

如果日志已经停在 Bean 初始化附近,我通常会优先把下面几件事钉住:

先确认:卡住的是创建、初始化,还是外部等待

这个特别关键。

  • 如果还没完成创建,更像依赖注入链和装配问题
  • 如果已经创建出来,但初始化不返回,更像回调里做了重活
  • 如果线程栈明显卡在 IO、网络、客户端握手,更像它在等外部系统

再确认:这个 Bean 自己慢,还是它背后整条依赖链慢

别只盯最后一个 Bean 名字。要看它是不是整条依赖树最后露出来的那个点。

然后确认:启动期到底塞进了哪些本不该阻塞的动作

比如:

  • 查库
  • 拉远程配置
  • 做全量预热
  • 跑批量校验
  • 初始化大量缓存或索引

很多问题到这里就已经很清楚了,不一定需要继续在 Bean 生命周期概念里来回绕。

这类问题里,最常见的几个误判

误判一:日志停在某个 Bean,就说明一定是它自己慢

不一定。它可能只是依赖链上最后暴露问题的那个节点。

误判二:Bean 初始化卡住,就是 Spring Boot 本身太重

更常见的根因还是业务把太多事塞进了启动期,包括依赖装配、远程调用和数据预热。

误判三:预热迟早都要做,所以放到启动前最稳

这不总成立。很多预热动作只是“迟早要做”,不代表“必须在 ready 前做完”。

误判四:本地启动没事,说明设计没问题

本地不代表线上。线上更大的数据量、更远的网络和更复杂的下游现实,才会把启动模型的问题放大出来。

误判五:把探针超时调长,就算解决启动卡顿了

有时这是止血,不是解决。它只能减少误杀,不能替代你重新收拾启动关键路径。

最后收一句

Spring Boot 启动卡在 Bean 初始化,很多时候不是框架在无缘无故地慢,而是某个 Bean 把一长串依赖、远程等待或数据预热拖进了启动关键路径。

所以比起先问“哪个 Bean 有问题”,更有用的往往是先问:它到底在做什么,它到底在等什么,以及这些动作到底有没有必要继续挡在服务 ready 之前。