ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Cloud双上下文详解:引导上下文与主应用上下文的关系与演进

Spring Cloud双上下文详解:引导上下文与主应用上下文的关系与演进 最近“上下文”这三个字在大模型圈子里属实是被说烂了厂商一个比一个能吹动辄几十万上百万 token 的上下文窗口。但你要是转到 Java 微服务这边听到“上下文”还真得先换个思路在 Spring Cloud 里上下文指的是 ApplicationContext是 IoC 容器本体是每个 Bean 的“户籍所在地”。Spring Cloud 应用启动时有个非常容易被忽略的事实一次SpringApplication.run()实际会创建两个 ApplicationContext而不是一个。第一个叫引导上下文bootstrap context负责在最早期把配置准备好第二个才是你平时挂在嘴边的主应用上下文承载 Controller、Service、配置中心、注册中心这些业务组件。启动过程也因此被明确切成两个阶段引导阶段bootstrap phase和主启动阶段main startup phase。我第一次真正意识到这一点是在排查一个诡异 bug 的时候同一个定时任务莫名其妙在启动后半分钟内跑了两遍后来顺着日志往下挖才发现第二个容器的存在。如果你正在用 Spring Cloud Config、Nacos 或 Sentinel或者刚把 Spring Boot 升到 2.4 以上却发现bootstrap.yml失效又或者听到“双上下文”这个概念但一直没搞懂来龙去脉这篇内容就是为你准备的。我会把两个上下文到底怎么回事、引导阶段和主启动阶段分别做了什么、老版本和新版本有什么区别、以及我踩过的那些坑一次性讲透。1. 先搞清楚这两个“上下文”到底是什么1.1 上下文指的是 ApplicationContext不是 AI 圈那个“上下文窗口”大模型圈子的“上下文窗口”是给 Token 住的决定了模型能“记住”多少内容Spring Cloud 里的上下文则是给 Bean 住的决定了一个 Bean 由谁创建、由谁注入、由谁管理。两者除了中文都叫“上下文”没有任何关系。Spring 里的 ApplicationContext 本质上就是一个容器。一个 Java 进程里存在多个 ApplicationContext 完全合法也一点都不神秘。单元测试里你随手new ClassPathXmlApplicationContext(test.xml)就会在当前 JVM 里多出一个独立的容器两个容器互不知晓各扫各的包、各管各的 Bean。Spring Cloud 只是把这种“多容器”机制用到了生产环境的启动过程里。很多开发者第一次遇到“双上下文”时第一反应是“Spring Boot 是不是启动了两次”然后盯着启动日志找第二个 Spring 图标。其实引导上下文是一个刻意为之的轻量容器它先跑完自己的生命周期再把接力棒交回主启动流程。如果你能先把这个前提想明白后面理解配置源、排查 Bean 冲突时就不会被绕晕。1.2 引导上下文是父主上下文是子两个上下文不是平级关系而是父子关系引导上下文是主应用上下文的 parent。父子容器的规则很简单——子上下文能拿到父上下文里定义的 Bean父上下文反过来看不到子上下文里的 Bean。这也是为什么你在 bootstrap 阶段初始化的配置解析器、加解密组件主上下文里可以直接注入使用。引导上下文通常是一个非 Web 的AnnotationConfigApplicationContext里面只放了极少量的 BootstrapConfiguration Bean规模很小主上下文则是那个带 Tomcat/Jetty 的 Servlet Web 容器你的 Controller、Service、Mapper 全部住在里面。把这句话翻译成白话就是先悄悄搭一个小容器把“地图”摸清楚再光明正大地把业务主容器拉起来。我自己验证这个父子关系时会在启动类里临时加一段代码把两个上下文的 id 和类型打出来。后面第 5 章我会给出可直接复制的代码片段你可以直接拿去在你项目里跑一遍比看任何资料都直观。2. 启动过程拆解引导阶段和主启动阶段分别做了什么2.1 一个监听器在早期事件里“截胡”了启动流程我们写的SpringApplication.run(DemoApplication.class, args)内部并不是一口气跑到底的。它在创建 Environment 之后、装配主上下文之前会发布一系列生命周期事件其中最早的一批里就有ApplicationEnvironmentPreparedEvent。spring-cloud-commons在spring.factories里注册了一个BootstrapApplicationListener专门监听这个事件。事件一到监听器会做一件相当“不安分”的事它中止主流程的推进以当前环境为基础再构建一个独立的 SpringApplication用BootstrapImportSelectorConfiguration作为配置来源运行起来由它创建引导上下文。也就是说引导阶段不是另外开一个 main 方法而是在主启动过程的某个事件节点上嵌套地执行了一次“小启动”。等它跑完主启动流程再继续往下走。理解这个机制有两个实际收益。一是看启动日志时不会把嵌套“小启动”里的日志当成异常二是遇到配置加载顺序问题时知道去事件监听机制里找依据而不是靠猜。整个启动时序大致如下阶段触发机制主要动作环境准备SpringApplication.run 启动创建 Environment准备基础 PropertySource引导阶段ApplicationEnvironmentPreparedEvent → BootstrapApplicationListener加载 bootstrap.yml实例化 BootstrapConfiguration执行 PropertySourceLocator 拉取远程配置主启动阶段主 SpringApplication 恢复执行加载 application.yml创建并刷新主上下文启动内嵌 Web 服务器就绪ApplicationReadyEvent执行 Runner、对外提供服务2.2 引导阶段的三件核心任务第一件加载bootstrap.yml/bootstrap.properties。默认文件名是 bootstrap也可以通过spring.cloud.bootstrap.name和spring.cloud.bootstrap.location调整。注意这个文件里放的通常不是业务配置而是“怎么去拿业务配置”的配置——比如 Spring Cloud Config Server 的地址、Nacos 的 server-addr 和命名空间、加解密密钥的位置。它存在的意义就是先把“地图”拿到手。第二件实例化BootstrapConfiguration里声明的少量 Bean。这些 Bean 通过spring.factories文件里org.springframework.cloud.bootstrap.BootstrapConfiguration这个 key 注册典型代表是各种PropertySourceLocator实现Nacos、Config Server、Consul 的 starter 都会把自己的 Locator 挂在这里。引导上下文很小远远不会去扫描你的业务包所以它启动很快。第三件执行PropertySourceLocator.locate()把远程配置拉到本地 Environment。比如 Spring Cloud Config 的ConfigServerPropertySourceLocator会拿 bootstrap.yml 里的地址发起 HTTP 请求Nacos 的 Locator 会根据 dataId、group、namespace 拉对应配置。拉回来的配置源会被合并进主环境并且通常放在比较靠前的位置所以典型效果是远程配置能覆盖本地默认值这也是“配置优先”这个设计的直观体现。注意不同版本对“远程配置和 application.yml 到底谁优先”的处理有细微差别还受spring.cloud.config.override-none、spring.cloud.config.allow-override这类参数影响。遇到覆盖关系不对时先拿 Environment 的属性源清单核对不要凭记忆下结论。2.3 主启动阶段剩下的路引导上下文跑完之后控制权交回给主 SpringApplication。此刻主 Environment 里已经“多了一坨”来自引导阶段的配置源所以主上下文创建好之后Value注入、配置绑定就能直接拿到远程值。接下来就是常规的 Spring Boot 启动流程解析启动类所在包扫描业务组件执行自动配置装配 SpringMVC、数据源、连接池等调用AbstractApplicationContext.refresh()完成 Bean 实例化内嵌 Web 服务器绑定端口发布ApplicationReadyEvent执行ApplicationRunner和CommandLineRunner。如果你把日志级别调到 DEBUG这段过程里能看到两次上下文创建相关的日志context id 不一样。很多人第一次在这里慌了其实只要确认其中一个是非 Web 的父上下文、另一个是 Web 主上下文就对上了预期。3. 双上下文设计的能力与代价3.1 为什么值得多建一个容器最大的理由是解决“先有鸡还是先有蛋”的问题。Bean 里的Value(${spring.datasource.url})在创建 Bean 的时候就必须有值而远程配置源要等配置中心连上才能拿到。没有引导上下文的话你只能把配置中心地址硬编码到代码里或者完全依赖系统环境变量这在多环境部署里会非常难受。引导上下文把这个问题拆成两步先用一个轻量容器获取配置再让主容器基于完整配置装配业务。除此之外加解密密钥的提前准备、配置中心的 fail-fast 机制也都依赖这个早期执行窗口。比如spring.cloud.config.fail-fasttrue时配置中心连不上直接启动失败而不是让应用带着残缺配置先跑起来。这种“宁可不出门也不裸奔”的行为只能放在引导阶段去实现。3.2 多一个容器的现实代价代价首先在资源上。每个服务都多一个 ApplicationContext就意味着多一份容器元数据、多一批 Bean 定义内存占用有实打实的增量。几百个微服务实例同时跑这个开销会被放大。监控时要把它当成固定成本别在某次内存告警时才发现多了一个没见过名字的容器。其次是 Bean 隔离带来的心智负担。父上下文里的 Bean 子上下文可见反过来不可见如果两边定义了同名 Bean子上下文中会遮蔽父上下文里的那个。调试时看到一个类被初始化了两次不要立刻断定是“重复扫描”先确认是不是两个上下文各自初始化了一遍。定时任务、消息监听这类启动即跑的逻辑尤其容易在引导上下文里“提前开跑”我在第 5 章会展开说。3.3 配置优先级双上下文带来的另一种混乱引导阶段不仅创建了第二个容器还往 Environment 里塞了额外的 PropertySource。加上bootstrap.yml、远程配置、application.yml、系统属性、环境变量配置来源排成一条长长的优先级链。我见过不止一次因为远程配置覆盖了本地配置、或者反过来被本地配置压住导致环境差异问题的现场。排查配置优先级最直接的办法是把 Environment 里的 PropertySource 名称按顺序打出来看。顺序确定之后再判断是哪一层把你想用的值“吞”了。经验是永远不要靠“我记得应该这样”来定位直接打印属性源清单十次里有九次能省下几个小时的排查时间。4. 版本断代Spring Cloud 2020.0 之后引导上下文默认关闭4.1 一次让无数人踩坑的调整Spring Boot 2.4 和 Spring Cloud 2020.0Ilford发布之后Spring Cloud 官方做了一个重要决定新项目里不再默认创建引导上下文bootstrap.yml默认也不会被加载。取而代之的是新的 config data 机制通过spring.config.import在环境准备阶段直接导入远程配置。这个调整的本意是简化模型把双上下文收敛成单上下文让配置加载链路更统一、更可预测。但对存量项目来说这是一次典型的“破坏性升级”。如果你之前用的是一套依赖 bootstrap.yml 的脚手架国内非常流行的若依微服务版就是典型——每个服务靠 bootstrap.yml 连接 Nacos你把 Spring Boot 一升到 2.4 以上大概率会看到服务起不来Nacos 地址读不到、服务名变成 unknown、注册中心里看不到实例。4.2 config data 模式和双上下文模式的对比新老模式各有各的适用场景我习惯用一张表来做对比也方便你根据项目现状快速判断方向维度传统 Bootstrap 模式config data 模式上下文数量两个引导 主一个配置文件bootstrap.yml application.yml统一 application.yml远程配置加载时机主上下文创建之前环境准备阶段需要额外依赖2020.0 起需 spring-cloud-starter-bootstrap无需适合场景存量老项目、开源脚手架新项目、Boot 2.4如果你已经上车 config data 模式Nacos 的写法大致是这样的spring: application: name: order-service config: import: - nacos:order-service.yaml?groupDEFAULT_GROUPrefreshEnabledtrue - nacos:common.yaml?groupDEFAULT_GROUP cloud: nacos: config: server-addr: 127.0.0.1:8848 namespace: dev4.3 老项目想保留双上下文怎么办如果你不打算动存量代码官方也给了退路往依赖里加上spring-cloud-starter-bootstrap引导上下文就会恢复默认开启bootstrap.yml重新生效。这个 starter 的作用就是把那个“不安分的”监听器重新接回启动链路。这里有一个很容易犯的错只设置spring.cloud.bootstrap.enabledtrue却不引入 starter-bootstrap在 Spring Cloud 2020.0 以上是无效的。因为从那个版本开始bootstrap 相关的装配本身已经不在默认链路里了。我的建议是只要确定继续用 bootstrap 模式就老老实实显式引入依赖并且在所有服务里保持一致。千万别只改部分模块否则会出现“有的服务正常、有的服务配置文件失效”这种非常难查的诡异现象。4.4 框架维护状态变化带来的连锁反应Spring Cloud Alibaba 在 2023 年对外宣布进入维护状态社区重心转向修护和适配而不是快速迭代新功能。这个消息传开后大量基于 Nacos、Sentinel 的项目开始重新审视自己的技术选型。对普通业务团队来说这不等于必须立刻弃用但至少提醒了两件事。一是脚手架包括知名开源脚手架和自建脚手架的更新速度会变慢长线项目要有兜底预案二是尽量把核心业务代码与具体框架解耦比如把配置加载、注册发现这些能力抽象成独立模块将来无论是继续留在老模式还是迁移到 config data改动面都能控制在入口层。5. 实际操作中的常见问题与排查实录5.1 bootstrap.yml 完全不生效症状升级 Spring Boot/Spring Cloud 之后原来写在 bootstrap.yml 里的 Nacos 地址、配置中心地址全部失效应用要么起不来要么拿到默认值。排查顺序第一步确认依赖里有没有spring-cloud-starter-bootstrap第二步检查spring.cloud.bootstrap.enabled的值第三步看启动日志里是否出现两个上下文创建的痕迹第四步确认 bootstrap.yml 确实在 classpath 根路径下且没有被构建插件排除。多数情况下前两步就能定位问题。5.2 定时任务、消息监听被“提前执行”了一遍症状任务类明明只写了一份运行起来却在启动阶段就执行了一次随后主上下文又执行一次造成重复调度、重复消费。原因就是双上下文各自初始化了包含任务的 Bean。如果任务类所在的包被 BootstrapConfiguration 或spring.factories里的某个配置类扫到引导上下文就会把它也实例化。排查时看任务线程的堆栈、触发时间与启动日志里两个上下文创建的时间点对齐基本就能确认它是从哪个容器里跑出来的。解决方式是把任务扫描路径从 bootstrap 配置里摘干净确保引导上下文只管配置获取不碰业务 Bean。5.3 Sentinel 规则、流控配置在启动瞬间是空的症状应用启动后的前几分钟Sentinel 控制台显示规则没生效或者 datasource 报错连不上 Redis 集群/Nacos。Sentinel 的 datasource 初始化时序和配置来源关系很大。如果你的规则放在远程 datasourceNacos、Redis 集群里而连接信息写在 bootstrap.yml那么引导阶段就该完成连接初始化主上下文起来时规则才就绪。如果引导阶段没有执行datasource 的创建会推迟到主上下文刷新时规则上线自然就慢了几拍。这类问题我在 Redis 集群场景里踩过不止一次。建议是把 datasource 连接、规则 key 这类“启动即需用”的配置尽量前置并在压测环境里验证“从进程启动到第一条规则生效”的间隔如果确实有秒级滞后再考虑用spring.cloud.sentinel.datasource相关的预加载参数去控制具体配置以你当前版本的官方文档为准。5.4 优雅停机时关闭顺序引发的资源清理问题引导上下文作为父容器关闭顺序与创建顺序相反主上下文先关闭引导上下文后关闭。如果引导上下文里存放了连接池、后台线程、资源句柄而这些资源又在主上下文里被引用那么主上下文关闭时就可能触发资源访问进而报“容器已关闭”或者连接获取超时的错误。排查优雅停机的报错重点看停机时两个上下文的关闭日志顺序。如果确认问题出在引导上下文的资源清理可以在 BootstrapConfiguration 里注册一个监听ContextClosedEvent的清理组件显式管理连接池和后台线程的释放。这块官方文档讲得少基本靠现场日志逐步收窄。5.5 三个立等可用的排查手段第一个手段打印上下文关系。在启动类里临时加一段代码SpringBootApplication public class DemoApplication { public static void main(String[] args) { ConfigurableApplicationContext mainCtx SpringApplication.run(DemoApplication.class, args); ApplicationContext parent mainCtx.getParent(); System.out.println(main context mainCtx.getId()); System.out.println(parent context (parent null ? none : parent.getId())); System.out.println(main is web (mainCtx instanceof org.springframework.web.context.WebApplicationContext)); System.out.println(parent is web (parent instanceof org.springframework.web.context.WebApplicationContext)); } }第二个手段打印属性源清单看配置优先级顺序ConfigurableEnvironment env mainCtx.getEnvironment(); env.getPropertySources().forEach(ps - System.out.println(ps.getName()));第三个手段打开 bootstrap 相关日志logging.level.org.springframework.cloud.bootstrapDEBUG logging.level.org.springframework.cloud.configDEBUG logging.level.com.alibaba.nacos.client.configDEBUG日志、上下文关系、属性源顺序这三板斧配合使用绝大多数双上下文相关的问题都能在半小时内收敛到根因。6. 我个人的实操体会与建议翻过几次车之后我对这两个上下文的态度变得非常务实。新项目一律走 config data 模式单上下文、spring.config.import语义清晰排查成本低老项目如果已经深度依赖 bootstrap 模式就统一引入spring-cloud-starter-bootstrap所有服务保持一致绝不出现一半 bootstrap 一半 config data 的混合状态。还有一个小技巧值得分享把“确认上下文数量”写进启动自检清单。在 CI 里跑一次冒烟测试断言mainCtx.getParent() ! null或者断言属性源里有没有 bootstrap 相关的源。这样升级依赖时上下文模式有没有变化会被测试自动暴露出来而不是等上线后被 Nacos 连接超时教育一次。双上下文本身不是 bug它是 Spring Cloud 在某个历史阶段为解决“配置先行”问题交出的答卷后来官方用 config data 给出了更优解。读懂这段演进不是为了怀旧而是为了在升级、排障、选型时少交学费。我个人在实际操作中的体会是框架怎么变其实不可怕可怕的是你没意识到它已经变了还在用旧地图走新路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进