Spring Cloud启动双上下文:引导阶段与主启动阶段深度解析
如果你用 Spring Cloud 搭过微服务一定见过这种场景启动日志里环境准备、绑定属性源、加载配置跑了一轮紧接着又出现一轮 Environment 相关日志然后才是常见的 Spring Boot 启动横幅和类加载输出。很多人第一反应是“启动过程重复了”其实不是重复而是 Spring Cloud 老版启动机制里确实存在两个上下文一个引导上下文一个主应用上下文。对应的阶段就是标题里说的引导阶段和主启动阶段。这俩上下文到底差在哪为什么微服务启动要比普通 Spring Boot 应用多绕一圈我最早被这个问题拦住是在排查一个“Nacos 配置改了但不生效”的故障时。顺着实现一路追下去才发现问题根源不在配置中心的推送而在于启动早期那一次不起眼的引导上下文创建。追完源码之后再看启动日志整个人的理解完全不一样了。这篇文章就专门把这件事拆开讲清楚两个上下文是怎么创建的、引导阶段到底在做什么、主启动阶段如何衔接上一个上下文、以及新版 Spring Cloud 为什么把这套机制悄悄“请出”了默认流程。适合三类人看被启动日志和配置优先级搞晕的 Spring Cloud 使用者准备把老项目从 Hoxton 等旧版本升级到 2020.0 的维护者以及想从源码层面理解微服务启动原理的读者。1. 启动时为什么要“先套一个小容器再造大容器”1.1 配置先行业务殿后每个 Spring Boot 应用的启动核心就是一句话读配置建 Bean。Spring Cloud 应用在此基础上多了一个需求配置本身可能来自远程配置中心Config Server、Nacos、Consul服务注册发现的客户端也得在业务 Bean 起来之前完成初始化。可偏偏“要读远程配置”就得先建立网络连接而连接地址本身又是配置。这就形成了一个“先有鸡还是先有蛋”的闭环业务 Bean 初始化时想知道数据库地址但数据库地址要等配置中心连接完成才能拿到。Spring Cloud 的解法简单粗暴在正式跑业务容器之前先创建一个非常小、职责单一的容器专干“识别配置来源”这件事。它加载 bootstrap.yml把 spring.cloud.config.uri、spring.cloud.nacos.discovery.server-addr 这类“基础设施配置”读进来再通过 PropertySourceLocator 去远程拉取真正的业务配置。拉到的配置被放进一个特殊的属性源中提前塞到主流程将要使用的 Environment 里。这样等主启动阶段真正开始业务 Bean 开始组装时需要的配置已经全部就位。这个“极小容器”就是引导上下文。它本身也是一个 ApplicationContext但里面不装业务对象只有跟远端交互所需的少量配置和组件。它被设置成主应用上下文的 parent于是形成父子上下文结构子上下文主应用上下文可以访问父上下文引导上下文的 Bean父上下文不会反向依赖子上下文。1.2 两个上下文不是把业务跑两遍很多朋友第一次听说“两个上下文”都会问那我的业务 Bean 是不是初始化两遍不是。引导上下文只加载极小一部分组件常见的有 bootstrap 配置类、PropertySourceLocator、加密解密相关 Bean。Controller、Service、Repository 这些业务 Bean全部只在主应用上下文里初始化一次。之所以日志看起来像“跑了两遍”是因为引导阶段内部本身也执行了一次小型的 SpringApplication 流程也有 Environment 准备、事件发布、上下文刷新这些动作。你可以理解为出门前先穿一件外套再套一件大衣不是穿了两件大衣而是两层功能不同的衣服。两件事的名称容易混淆但职责完全不一样。维度引导上下文主应用上下文创建时机SpringApplicationEnvironmentPreparedEvent 之后触发SpringApplication.run 正常创建加载配置bootstrap.yml / bootstrap.propertiesapplication.yml / application.properties 及引导阶段拉取的远程配置承载内容基础设施客户端、PropertySourceLocator、少量早期组件全部业务 Bean 和常规配置生命周期常驻作为主上下文 parent应用主容器承载业务逻辑可见性不依赖子上下文可以通过 parent 看到引导上下文 Bean2. 引导阶段在业务环境“饥肠辘辘”时先把饭做熟2.1 引导阶段到底是谁触发的Spring Boot 本身并不知道 Spring Cloud 的存在它只会老老实实执行自己的 SpringApplication.run()。Spring Cloud 之所以能中途“插一脚”靠的是 spring-cloud-context 里的一个监听器BootstrapApplicationListener。这个监听器在启动早期被注册进 SpringApplication 的监听器列表一旦 Spring Boot 环境准备阶段发布 SpringApplicationEnvironmentPreparedEvent 事件它就立刻开始行动。用伪代码描述它的核心动作大致如下// 伪代码BootstrapApplicationListener 的核心动作 onApplicationEvent(event) { ConfigurableEnvironment environment event.getEnvironment(); if (bootstrapEnabled bootstrapPropertySourceNotExists) { // 1. 构建一个专门用于 bootstrap 的 Environment ConfigurableEnvironment bootstrapEnvironment new StandardEnvironment(); // 2. 加载 bootstrap.yml / bootstrap.properties SpringApplicationBuilder builder new SpringApplicationBuilder(...); builder.environment(bootstrapEnvironment); // 3. 通过 PropertySourceLocator 连接远程配置中心 // 4. 以该环境刷新一个小型 ApplicationContext - bootstrap context // 5. 将 bootstrap context 设置为主 SpringApplication 的 parent event.getSpringApplication().setParent(bootstrapContext); } }也就是说Spring Boot 还没走到创建主应用上下文那一步Spring Cloud 已经在中途切了进去自己先在旁边搭了一个小容器。这个小容器随后作为主容器创建时的 parent 参与后续流程。整个过程通常只有几十到几百毫秒具体时长取决于和外部配置中心交互的耗时。如果配置中心响应慢你会在启动日志里看到一段明显的“停顿”那就是引导阶段在等远程配置返回。2.2 bootstrap.yml 加载的先后顺序普通 Spring Boot 应用读的是 application.yml而引导阶段的配置文件名是 bootstrap.yml 或 bootstrap.properties。为什么不直接用 application.yml 一次搞定因为两者职责完全不同bootstrap.yml 里放的是“我该去哪里找配置”比如 spring.cloud.config.uri、spring.cloud.nacos.server-addrapplication.yml 里放的是业务配置比如数据库连接、Redis 地址、业务开关。完整的加载顺序可以拆成几步来看主 Spring Boot 流程先创建一个最原始的 Environment此时里面可能只有系统属性、环境变量。BootstrapApplicationListener 触发后单独创建 bootstrap Environment并加载 bootstrap.yml 或 bootstrap.properties。bootstrap 上下文使用该环境进行 refresh期间 PropertySourceLocator 开始工作ConfigServicePropertySourceLocator连接 Spring Cloud Config Server按应用名和 profile 拉取配置NacosPropertySourceLocator / ConsulPropertySourceLocator从 Nacos / Consul 拉取配置到本地每个 Locator 返回的 PropertySource 会被归集起来。拉到的远程配置被放到主应用 Environment 的比较靠前的位置使其优先级高于本地 application.yml。主流程继续创建主应用上下文刷新时便已经能读到远程配置。第四步是这个阶段最容易被忽略、也最重要的一点引导阶段的成果不是“创建完一个小容器就结束”而是要把远程属性源提前挂到主环境的头部。否则就无法解释“Nacos 里写了一个配置本地 application.yml 里也有同名配置最后生效的却是 Nacos”。2.3 引导上下文里都放了哪些东西严格来说引导上下文不是空壳。在引导阶段Spring Cloud 会初始化这些组件PropertySourceBootstrapConfiguration负责收集所有 PropertySourceLocator 找到的属性源并注册进环境配置中心客户端、服务发现客户端相关 Bean视引入的依赖而定自定义 BootstrapConfiguration通过在 META-INF/spring.factories 里注册 org.springframework.cloud.bootstrap.BootstrapConfiguration 可以配置特殊早期组件加解密相关 Bean如果配置了 Vault 或 JCE 加解密。由于引导上下文是父上下文主应用上下文可以通过 getParent() 引用其中的 Bean。不过业务代码一般不会直接依赖这些早期组件它们存在的意义主要是为 Spring Cloud 自身的组件服务比如 RefreshScope 需要知道属性源来自哪里配置刷新后才能重新绑定。3. 主启动阶段真正的 Spring Boot 流程只是多了个“爸爸”3.1 主上下文是如何创建的BootstrapApplicationListener 处理完引导上下文后主 SpringApplication.run 流程继续向下执行。Spring Boot 的正常路径是准备环境、创建 ApplicationContext、执行 Bean 定义加载、刷新容器、启动内置 Web 服务器。此时有一个关键差异主 SpringApplication 的 parent 已经被设置为引导上下文。于是创建主 ApplicationContext 时Spring 会带着这个 parent 去构建比如创建 AnnotationConfigServletWebServerApplicationContext再调用 setParent(bootstrapContext)。上下文刷新时Spring 会先确保父容器可用再初始化子容器最终完成业务 Bean 的装配。主上下文里的配置类、组件扫描、自动配置全部照常执行。业务 Bean 中通过 Value 和 ConfigurationProperties 读取配置的时机已经能命中引导阶段拉回来的远程配置。这也是为什么远程配置中心可以在业务代码完全启动之前就“悄无声息”地影响主流程的走向。3.2 父子上下文的环境属性关系很多初学者会问引导上下文里的配置跟主应用上下文里的 Environment 到底是什么关系可以简化成一个结论引导阶段拉回的远程配置被合并进了主环境的属性源列表而不是通过父子 BeanFactory 传递的。更具体地说PropertySourceBootstrapConfiguration 会在引导环境刷新时收集所有远程配置并把这些属性源注册到主环境的 CompositePropertySource 中位置在比较靠前的地方。之后主上下文执行 environment.getProperty(server.port) 时会从这些头部属性源开始查找于是远程配置自然覆盖同名本地配置。这里隐藏了一条优先级规则属性源在 Environment 里越靠前优先级越高。引导阶段拉回来的远程配置通常排得比本地 application.yml 靠前所以才能“压过”本地配置。如果你发现本地配置反而覆盖了远程配置优先怀疑远程属性源没被加进主环境或者被加进去的位置偏后。3.3 主上下文能看见父容器里的 BeanSpring 的父子 ApplicationContext 机制下子上下文可以访问父上下文的 Bean反之不行。这意味着主上下文里的业务代码理论上可以拿到引导上下文注册的 Bean只是实际用得很少。但这里藏着一个经典坑如果在两个上下文里定义了同名 Bean子上下文里的 Bean 会隐藏父上下文里的同名 Bean。某些老版本里两个上下文同时加载了相同组件会出现 ClassCastException 或 BeanNotOfRequiredTypeException排查起来非常痛苦。这也是新版 Spring Cloud 后来干脆默认关闭引导上下文的一个重要动因——父子反噬比它解决的问题还多。4. Spring Cloud 2020.0 为何把这个机制“请出”了默认流程4.1 Spring Boot 2.4 的 config data 革命Spring Boot 2.4 对配置加载机制做了一次比较大的调整从原来的 PropertySource 线性处理改成了 ConfigData 树式流程。ConfigData 可以理解成一种“配置数据包”它允许用 spring.config.import 显式声明该额外导入哪些配置源。外部配置导入从此成为 Spring Boot 本身的原生能力不再需要 Spring Cloud 单独造一个引导上下文来做这件事。对应的Spring Cloud 2020.0Ilford发布后默认不再创建引导上下文。换句话说标题里描述的“两个上下文”现象在新版本默认行为下其实少了一个。如果你现在用 Spring Cloud 2021.0 或 2022.0 搭配 Spring Boot 2.6/2.7直接跑老项目会发现很多诡异情况bootstrap.yml 不生效Nacos 配置拉不回来服务注册地址全是默认值。不是你写错了而是启动模型变了。4.2 新老机制如何共存与迁移如果老项目确实还需要 bootstrap 方式官方留了兼容入口。先额外引入依赖dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-bootstrap/artifactId /dependency然后在 application.yml 里按需要配置spring: cloud: bootstrap: enabled: true不过这是恢复旧行为不建议新项目采用。新项目建议直接使用 config data 方式spring: config: import: - configserver:http://localhost:8888这个是 Config Server 场景下的标准写法。Nacos、Consul 的适配方式也大方向类似本质是把“远程配置”当作一种可导入的 ConfigData 资源。选择哪种方案取决于你的配置中心客户端版本但核心认知要先建立起来引导上下文不是永久方案它只是 Spring Cloud 早期为了解决外部配置导入而设计的过渡手段如今 Spring Boot 已经原生支持同等能力。4.3 对“两个上下文”认知的影响如果你现在还在维护旧版项目比如 Hoxton 及更早版本那么“两个上下文、两个阶段”的认知依然适用如果项目跑在 2020.0 及以上版本这个机制默认关闭、可选开启。做技术判断时先看 Spring Cloud 版本再下结论。很多线上问题排查从一开始就站错队就是因为用了新版 Spring Cloud却还按旧版启动模型分析或者反过来。5. 实践中的典型问题与排查5.1 配置中心配置一直不生效现象Nacos 或 Config Server 里的配置已经改过了应用重启后本地 application.yml 的值仍然在生效远程配置始终覆盖不了。建议从四个方向排查确认 Spring Cloud 版本。如果是 2020.0 以上检查是否引入了 spring-cloud-starter-bootstrap以及 spring.cloud.bootstrap.enabled 是否开启确认 bootstrap.yml 是否放在 classpath 根目录以及是否真的被加载检查属性源顺序。可以在启动类里临时打印 Environment 里的 PropertySource 名称依次查看远程属性源和本地属性源的相对位置检查 PropertySourceLocator 是否真的返回了配置。很多场景下配置中心连接失败应用却默认继续启动于是本地配置顶替上去表现就是“远程配置覆盖不了”。排查时的打印代码很简单for (PropertySource? ps : environment.getPropertySources()) { System.out.println(ps.getName()); }属性源顺序一眼就能看出来。5.2 启动日志里只有一次 Environment 准备是不是没创建两个上下文有这个疑问很正常。首先要明确一点如果项目用的是新版 Spring Cloud 默认行为它确实只创建一个上下文这是正常现象。如果是旧版通常能看到两次环境准备相关日志以及一些 Located property source 字样。也有一种情况是日志级别被调整过INFO 级别的 BootstrapApplicationListener 日志被隐藏了。可以启动时在代码里直接检查父子关系ConfigurableApplicationContext ctx SpringApplication.run(DemoApplication.class, args); System.out.println(ctx.getId()); if (ctx.getParent() ! null) { System.out.println(parent context: ctx.getParent().getId()); }如果能看到 parent context说明确实创建了两个上下文。看不到就说明只有一个那就别再往双上下文的方向排查了。5.3 自定义 BootstrapConfiguration 里的 Bean 在主上下文里拿不到现象在 META-INF/spring.factories 里定义了 BootstrapConfiguration引导阶段也创建了里面的 Bean但主上下文注入时却报 NoSuchBeanDefinitionException。理论上主上下文可以通过父子关系访问父上下文里的 Bean。常见的坑是你注入的是父上下文中的实现类但子上下文同时也扫描到了同类型接口按类型匹配时产生歧义。另一个更隐蔽的情况是引导上下文里的 Bean 并没有被主上下文真正“看到”因为某些早期组件是在主环境准备完成之前创建的状态并不完整。这类问题没有万能解法但有一个排查原则引导上下文里只放配置中心、注册中心客户端相关的组件不要放过多的业务类。引导上下文越“肥”父子冲突概率越高。5.4 升级到 2020.0 后 bootstrap.yml 突然失效现象是依赖升级之后配置中心连不上注册中心地址也没了。绝大多数原因就是默认引导上下文被关闭解决方式参考 4.2 章节。但要注意即便恢复旧模式Spring Boot 2.4 以后对配置处理顺序还有额外要求某些老式 bootstrap.yml 写法需要调整成 spring.config.import 方式或者开启 legacy processing。这属于迁移清单里的必查项。5.5 两个上下文之间的 Bean 名称冲突旧版里如果引导上下文和主上下文加载了相同组件会出很奇怪的问题。比如两个上下文扫描了同一个包Bean 名一样但类型不同于是出现 BeanNotOfRequiredTypeException又比如两个上下文各自持有生命周期的 Bean关闭时出现 destroy 方法重复触发。排查思路有两条一是看 ApplicationContext.getId()父子上下文的 ID 通常不同二是用 actuator 的 beans 端点查看同一个 Bean 名是否出现两次。长期停留旧版的项目建议把引导上下文里无关的组件尽量清干净只保留远程配置和基础设施相关的东西。6 实操总结亲手验证一次“两个上下文”的存在6.1 用断点和日志确认启动阶段实际操作中推荐两个断点位置第一个是 BootstrapApplicationListener.onApplicationEvent 方法。这里能看到 Spring Boot 环境准备事件触发时Spring Cloud 才开始介入。这个断点一打你会发现引导阶段创建过程远比想象中清晰代码就是按上一节伪代码的路线走的。第二个是 SpringApplication.run 方法里 prepareContext 之后的某个位置。此时可以看到主 SpringApplication 的 parent 已经被设置成了引导上下文。这个断点验证“父子关系”非常直观。本地调试用断点线上就靠日志。建议开启这些包的 DEBUG 级日志org.springframework.cloud.bootstraporg.springframework.cloud.config.clientorg.springframework.cloud.context启动后留意 bootstrap context created、Located property source 这类关键字。6.2 识别“双启动”日志的套路旧版启动日志里常见特征有三个主 Banner 出现之前先有和 PropertySource / configuration source 相关的日志环境准备过程出现明显两次两次之间间隔可能拉得很长如果配置中心连接慢第二次环境准备和第一次之间的停顿尤其明显。很多老手靠这些日志就能预判出“是不是 bootstrap 卡住了”而不是傻等启动完成再翻异常堆栈。6.3 我个人的建议代码层面选新版还是旧版业务侧最该关注的是配置加载顺序是否可预期。旧版“两个上下文”机制虽然能干活但间接引入了父子 Bean 可见性、属性源优先级、监听器行为变化等一堆问题。新版 config data 机制把配置加载回归到 Spring Boot 原生模型心智负担小很多。能升级就升级不能升级也要知道引导上下文的存在否则很多配置问题根本无从下手。最后聊一句个人体会遇到最折磨人的一次线上事故就是客户端改了 Nacos 配置但服务不生效。当时查了很久推送链路最后真相非常朴素——本地 application.yml 里有一个同名配置因为引导阶段没把远程属性源放到正确位置优先级反而低于本地配置。把两个上下文的启动机制弄明白以后这类问题基本半小时内能定位。希望这篇能帮你把 Spring Cloud 启动时两个上下文、两个阶段的来龙去脉理清下次再看到“双启动”日志你不会再觉得是异常而是能直接判断出引导阶段和主启动阶段各自干了什么。