ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Cloud Bus消息总线原理与微服务配置热刷新实战

Spring Cloud Bus消息总线原理与微服务配置热刷新实战

微服务圈子里有一个非常经典的名场面:配置中心里改了一个开关,想让所有服务立刻生效,于是打开终端,对着十几个服务、几十个实例挨个执行 refresh。改一次刷一次,改两次刷两次,赶上业务高峰期一边被催着改配置,一边还要担心哪个实例漏刷或者刷挂了。Spring Cloud Bus 就是来终结这种日子的。它把微服务集群里的所有节点通过一个轻量级的消息中间件连成一张网,你在任意一个节点上触发一次刷新,整条链路上所有订阅了总线的服务都会收到通知,自动重新加载配置。这篇文章我结合自己维护交易链路时做配置中心改造的实际经历,把 Bus 的设计思路、集成步骤、排错方法完整写下来,给准备上手或者正被配置刷新折磨的同行一个参考。内容偏实战,代码可以直接抄,坑也帮你们提前踩过了。

1. 在没有消息总线之前,微服务配置刷新有多折腾

1.1 一次普通配置变更的完整流程

先还原一下没有 Bus 时的日常。假设线上有订单、库存、支付三条业务线,每条业务线下又有两三个实例,配置放在 Git 仓库里统一管理。要改一个促销开关,标准动作是这样的:先改 Git 里的配置文件,提交并推送,然后登录到每一台服务器,挨个调用每个实例的POST /actuator/refresh接口,再翻日志确认有没有生效。

这套流程在服务少的时候勉强能用,服务一多就变味了。我当时维护的配置中心挂在三十几个实例上,每次全量刷新要写一个 for 循环脚本去逐个调用,脚本跑完还要人工核对日志。最难受的是,脚本是 shell 写的,实例列表是硬编码在文件里的,加了一个新节点忘了更新列表,那这个节点的配置可能带着旧值跑一整天,直到线上出问题才被发现。

1.2 逐个 refresh 的隐藏风险

逐个刷新的问题不只是麻烦,还有几个比较隐蔽的风险。

第一是漏刷。实例列表是静态的,但微服务的实例是动态的,扩容、缩容、灰度发布都会改变实例集合。脚本里的列表一旦和实际部署不一致,就会被漏掉。

第二是误刷。脚本如果写得不严谨,很容易把别的环境、别的服务的实例也带进去。尤其是有多个团队共用一套发布平台的时候,刷新脚本被改来改去,很容易把范围扩出去。

第三是失败无感知。某个实例网络抖动、连接池打满、应用正在重启,refresh 请求可能直接超时,但脚本默认"没报错就是成功",问题就被掩盖了。等配置没生效的现象暴露出来,已经是几小时之后。

第四是暴露面扩大。为了让公网能够调用,每个实例都要开放 refresh 端点,这意味着攻击面也扩大了。只要有任何一个实例的安全配置没跟上,整个集群的配置节点就可能被打穿。

1.3 Bus 给出的答案

Spring Cloud Bus 把上面这些痛点一次性解决了。它做的事情本质上只有一件:把 Spring 的事件机制延伸到整个微服务集群。你在任意一个节点上调用POST /actuator/busrefresh,这个节点会把一个"刷新"事件发到消息中间件,所有接入总线的服务都会收到这个事件,然后各自执行刷新。没有脚本,没有漏刷,没有逐个调用。

这里有个很容易被忽略的点:配置中心服务端本身也要接总线。因为配置中心是从 Git 仓库读配置的,如果只有客户端接总线,客户端刷新时配置中心返回的仍然是自己缓存的旧快照。我当时图省事,只给客户端接了 Bus,结果改完配置刷出来的还是旧值,排查半天才意识到是配置中心没有重读 Git。这算是第一个要记住的坑。

2. 消息总线的核心原理:一条刷新消息的完整旅程

2.1 总线传递的核心对象:RemoteApplicationEvent

Spring Cloud Bus 并不是一套全新的消息系统,它是在 Spring 事件机制基础上做了一层跨进程的扩展。Spring 本身有ApplicationEvent和@EventListener,但那是进程内的:同一个 JVM 里发布、同一个 JVM 里消费。Bus 把这件事推广到了分布式环境:进程 A 发布事件,进程 B、进程 C、进程 D 都能收到并处理。

这个跨进程事件的核心抽象是RemoteApplicationEvent,它有几个关键字段:source 事件源、originService 发起事件的服务、destination 目标服务、id 事件唯一编号。Bus 内置了几个常用的事件类型:RefreshRemoteApplicationEvent触发配置刷新、EnvironmentChangeRemoteApplicationEvent通知环境变量变更、AckRemoteApplicationEvent做确认回执。我们自己也可以继承RemoteApplicationEvent定义业务事件,后面会专门讲。

整体的数据流是这样的:本地服务发布一个 Spring 事件,Bus 的监听器捕获到RemoteApplicationEvent的子类,把它序列化成 JSON,投递到消息中间件;另一端从消息中间件消费到 JSON,反序列化还原成事件对象,再用 Spring 的ApplicationEventPublisher在本地重新发布一遍。对业务代码来说,收发两端感知到的就是一个普通 Spring 事件,中间经过了多少跳,它们并不关心。

2.2 从触发到收尾,一个事件走过了哪些节点

以最常用的 busrefresh 为例,完整链路可以拆成下面几步:

  1. 任意服务收到POST /actuator/busrefresh请求。
  2. 该服务的BusRefreshEndpoint发布一个RefreshRemoteApplicationEvent,destination 为空,表示广播给所有服务。
  3. Bus 的发送监听器把事件序列化,发送到 RabbitMQ 的springCloudBus交换机。
  4. 总线上每一个服务实例的队列都绑定在这个交换机上,消息被复制送到每条队列。
  5. 每个实例消费消息,在本地发布反序列化后的RefreshRemoteApplicationEvent。
  6. 每个实例执行 refresh:配置中心重新读取 Git 仓库,配置客户端重新向配置中心拉取配置,@RefreshScope管理的 Bean 被销毁重建。

第 6 步是容易被误解的地方。很多人以为 busrefresh 只是通知客户端重新拉配置,实际上配置中心也是总线的一员,它收到事件后同样会重置自己的环境仓库缓存,重新去 Git 拉最新提交。所以整个链路是"配置中心重读仓库 + 客户端重拉配置"两个动作一起完成的,两边的时序配合也是开箱即用的,不需要额外写代码。

2.3 busrefresh 和 refresh 到底差在哪

有人会问,既然客户端自己调refresh也能生效,为什么非要引入一个消息中间件?区别就在影响范围和控制方式上。

维度/actuator/refresh/actuator/busrefresh
影响范围单个实例总线内符合目标条件的全部服务
触发次数每个实例都要调一次任意一个节点调一次
是否支持定向刷新不支持,只能刷自己支持按服务、按实例定向刷新
配置中心重读 Git不触发触发
对基础设施的要求无需要 RabbitMQ 或 Kafka

refresh适合单机调试,比如本地开发时改了下配置不想重启。busrefresh才是生产环境该用的方式。尤其要注意,refresh只能刷新当前实例自身的上下文,它不会触发配置中心重读仓库,所以哪怕你对每个实例都调了一遍 refresh,只要配置中心还缓存着旧快照,客户端拉到的还是旧值。

2.4 传输层选型:RabbitMQ 还是 Kafka

Spring Cloud Bus 本身不实现消息队列,它只是对接。传输层目前最常用的是 AMQP(RabbitMQ)和 Kafka,对应依赖分别是spring-cloud-starter-bus-amqp和spring-cloud-starter-bus-kafka。选哪个,主要看你已有的基础设施。

RabbitMQ 的优点是轻量、部署简单、管理界面直观,消息走 exchange 和 queue 的路由模型,和 Bus 的发布订阅模型天然契合。中小团队没有现成消息中间件的话,单独起一个 RabbitMQ 非常方便,内存占用也不高。Kafka 的优势在于吞吐量大、可靠性强,如果你们的微服务体系里本来就有 Kafka 集群(比如日志采集、埋点上报),那直接用 bus-kafka,省一套运维成本。

我个人建议是:没有特殊诉求就选 RabbitMQ。它调试起来太友好了,管理界面里能看到交换机上有几条队列、每个队列绑定了什么路由键、消息是否被消费,排障体验比 Kafka 直观得多。下文所有实操也都以 RabbitMQ 为例。

3. 集成前的准备:版本选型与依赖引入

3.1 版本组合别乱配

Spring Cloud 的版本命名在 2020.0.x 之后改成了年份风格,但每个版本对 Spring Boot 的版本是强绑定的,选错组合会出现各种莫名其妙的类找不到、方法不存在的问题。

Spring BootSpring CloudSpring Cloud Bus
2.4.x2020.0.x (Ilford)3.0.x
2.6.x ~ 2.7.x2021.0.x (Jubilee)3.1.x
3.0.x / 3.1.x2022.0.x (Kilburn)4.0.x
3.2.x2023.0.x (Leyton)4.1.x

强烈建议用 Maven 的 BOM 管理版本,不要手写各个组件的版本号。引入spring-cloud-dependencies和spring-boot-dependencies两个 BOM,子模块只写 artifactId 不写 version,让 BOM 统一兜底。版本冲突踩过一次就知道,手写版本号配出来的依赖树,经常在运行期才炸,编译期完全看不出来。

3.2 三个角色各需要哪些依赖

接入 Bus 的场景里有三类角色:配置中心服务端、配置客户端、以及只监听事件的普通服务。前两者必须接总线,普通服务看需求。先说配置中心服务端的依赖:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-config-server</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bus-amqp</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>

配置客户端的依赖是这个:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-config</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bus-amqp</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-bootstrap</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>

两个依赖清单几乎一样,区别只在服务端多了spring-cloud-config-server,客户端多了spring-cloud-starter-config。这里要注意:任何一个希望收到总线事件的服务,都必须同时具备 bus 依赖和 actuator 依赖,两者缺一不可。bus 负责事件收发,actuator 负责暴露 refresh 端点和接收管理指令。

3.3 关键配置项逐个过一遍

看一段完整的配置,我习惯把配置中心服务端的 application.yml 写成这样:

server: port: 8888 spring: application: name: config-server rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest virtual-host: / cloud: config: server: git: uri: http://git.example.com/cloud-config.git default-label: main bus: trace: enabled: true management: endpoints: web: exposure: include: busrefresh,refresh,health,info

逐项说明一下。spring.rabbitmq.*是连接 RabbitMQ 的配置,注意virtual-host要和 RabbitMQ 里的 vhost 对上,默认是/,如果你建了独立 vhost,这里要改。spring.cloud.config.server.git.uri是配置仓库地址,线上推荐用 SSH 协议,本地调试用 HTTP 就行。spring.cloud.bus.trace.enabled=true建议一上来就打开,它会打印事件收发的跟踪日志,排障的时候这些日志能救命,后面讲排查时还会提到。

management.endpoints.web.exposure.include千万不能漏。actuator 端点默认只暴露 health,如果不在这里把busrefresh和refresh加进去,调接口永远返回 404。有的文章喜欢写成include: '*',省事但会暴露一堆端点,生产环境不要这么干,用最小化列表就好。

4. 实操全流程:搭一条可用的配置自动刷新链路

4.1 配置中心服务端接入总线

先起配置中心。启动类上加@EnableConfigServer,这是配置中心的老规矩了:

@SpringBootApplication @EnableConfigServer public class ConfigServerApplication { public static void main(String[] args) { SpringApplication.run(ConfigServerApplication.class, args); } }

为了让演示更真实,我在配置仓库里准备两个配置文件,分别对应两个业务服务。仓库结构大概是这样的:

cloud-config/ ├── order-service.yml └── user-service.yml

order-service.yml 里放一个演示用的配置项:

app: notice: v1-message

user-service.yml 也放一个同结构但值不同的配置项。启动配置中心后,先确认它能把 Git 里的配置拉出来:浏览器访问http://localhost:8888/order-service/default,能看到app.notice: v1-message就说明配置中心这一层通了。

4.2 两个配置客户端的订阅配置

接下来起两个客户端。以 order-service 为例,端口设成 8081。bootstrap.yml 里指定配置中心地址:

spring: cloud: config: uri: http://localhost:8888

application.yml 里的内容很关键:

server: port: 8081 spring: application: name: order-service rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest virtual-host: / cloud: bus: id: order-service:${server.port} trace: enabled: true management: endpoints: web: exposure: include: busrefresh,refresh,health,info

spring.cloud.bus.id这个配置强烈建议显式设置,格式是服务名:端口,比如order-service:8081。总线靠这个 ID 识别节点,定向刷新也依赖它。如果不设置,Spring 会自动生成一个 ID,虽然也能工作,但后面你想定向刷新某个实例时根本不知道它叫什么,排障也不方便。

客户端里写一个测试接口,用来观察配置值是否刷新:

@RestController @RefreshScope public class MessageController { @Value("${app.notice}") private String notice; @GetMapping("/notice") public String notice() { return notice; } }

@RefreshScope是整条链路里最容易漏的一环。Bus 只是负责把"配置变了"这件事通知到服务,真正让配置值重新加载的是@RefreshScope管理的 Bean 重建。如果这个注解没加,事件也收了、日志也打了,但接口返回的还是旧值,看起来就像总线没生效。user-service 复制同样一套,端口改成 8082,spring.cloud.bus.id改成user-service:8082,Bean 名字和@Value的 key 保持一致。

两个客户端都启动后,访问http://localhost:8081/notice和http://localhost:8082/notice,分别能看到各自配置的值。这时候总线链路还没被真正触发过,RabbitMQ 管理界面上应该已经能看到名为springCloudBus的交换机,以及每个实例各一条的队列。

4.3 修改配置并一键广播

现在模拟一次线上配置变更。把 Git 仓库里的 order-service.yml 改成:

app: notice: v2-message

提交并推送。然后任意挑一个客户端执行:

curl -X POST http://localhost:8081/actuator/busrefresh

注意是任意一个节点,不一定是配置中心,也不需要每个节点都调一次。执行完这条命令,观察两个客户端的日志,会看到它们都收到了刷新请求并重建了配置上下文。再访问http://localhost:8081/notice,值已经变成v2-message。而 user-service 的配置没动过,它的http://localhost:8082/notice值保持不变,这也符合预期。

这里要特意说一个时序问题:配置值不会在 busrefresh 到达的瞬间立刻变成 Git 里的最新值,因为中间隔着"配置中心重读 Git"和"客户端重新拉取"两步。如果配置中心没接总线,客户端刷新时可能拿到配置中心的旧缓存,出现"刷了个寂寞"的现象。所以我在生产环境里的做法是,配置中心必须接总线,而且要先用配置中心验证一遍它是否能正确拉到 Git 的最新值,再验证客户端链路。

4.4 定向刷新:只刷某个服务或某个实例

广播是总线的基本能力,但生产环境更常用的是定向刷新。比如你只改了订单服务的配置,没必要让用户服务也跟着刷一遍,白白产生一次上下文重建,还会带来短暂的请求抖动。Bus 的 destination 机制就是干这个的。

刷新某个服务的全部实例:

curl -X POST http://localhost:8081/actuator/busrefresh/order-service

刷新某个服务的某个具体实例:

curl -X POST http://localhost:8081/actuator/busrefresh/order-service:8081

destination 的匹配规则很直观:order-service匹配所有属于该服务的实例,order-service:8081只匹配那一个实例。我在灰度发布场景里用得很多:先给一台实例刷成新配置跑一段时间,确认稳定后再广播给整个服务,最后全量切完。没有定向刷新的话,灰度配置就只能靠重启实例来实现,成本高得多。

4.5 关于 bootstrap 和 config.import 的补充说明

Spring Cloud Config 在 2021.0.x 之后默认关闭了 bootstrap 上下文机制,配置要从spring.config.import引入。很多新项目用的是这种方式:

spring: config: import: optional:configserver:http://localhost:8888

这种写法同样可以结合 Bus 工作,新版 Spring Cloud Config 里已经有对应的组件负责在刷新事件触发后重新拉取配置。但如果你用的是比较老的版本,或者对"刷新后能不能自动重拉"心里没底,我建议老老实实加spring-cloud-starter-bootstrap依赖走 bootstrap 方式。bootstrap 方式下,refresh 事件触发的配置重拉行为在历史上被验证得最充分,网上能查到的资料也最多。踩坑少,比"优雅"重要。

5. 常见问题与排查技巧实录

5.1 RabbitMQ 的队列你真正看懂了吗

排障的第一步是看懂 RabbitMQ 管理界面。打开管理端,进 Exchanges 页签,能看到一个名为springCloudBus的 topic 类型交换机。点进去,在 Bindings 区域会看到每个已接入总线的服务实例都有一条自己的队列,队列名形如springCloudBus.order-service:8081这种格式。

这里有个重要的认知:总线用的是发布订阅模型,不是竞争消费模型。每一条队列都会收到同一条消息的完整副本,各实例各自消费,不存在"哪台实例抢到消息哪台才刷新"的情况。所以如果你的服务部署了三个实例,得到的结果是三个实例都会刷新,这是对的,不是重复消费。

如果管理界面上看不到springCloudBus交换机,说明服务端和 RabbitMQ 的连接没建立成功,检查地址、端口、账号、vhost。如果交换机有了但队列只有一条,说明只有一个实例连上了,另一个实例大概率是spring.rabbitmq.*配置写错或者还没完全启动。

5.2 收到事件但配置没变的排查顺序

这是提问率最高的一类问题:busrefresh 调了,消息也发出去了,接口返回的配置值还是旧的。按下面的顺序排查,基本能定位到 90% 的情况。

第一步,确认有没有收到事件。打开 trace 日志后,总线上每个实例在收到消息时都会打印类似Received remote refresh request的日志。哪个实例没打印,问题就出在哪个实例的连接或订阅上。

第二步,确认配置中心有没有重读 Git。手动访问配置中心的接口,看返回值是否已经是最新提交的值。如果配置中心返回的还是旧值,说明它没有正确重读仓库,检查 Git 仓库的认证、分支名、本地缓存目录。

第三步,确认客户端有没有重新拉取。这一步要看客户端日志里有没有重新走配置拉取的记录。没有的话,多半是客户端没有正确接入总线,或者 bus 事件的 destination 把它过滤掉了。

第四步,确认@RefreshScope有没有加。这个注解漏掉的概率极高,尤其是有缓存的组件、静态工具类、非 Spring 管理的对象,加在@Value字段所在的类上才有用。

第五步,确认配置值没有被其他组件缓存。比如 Caffeine 缓存、本地内存字典、静态变量,它们不会因为 refresh 自动失效,需要额外写清理逻辑。

5.3 端点 404 的常见原因

POST /actuator/busrefresh返回 404,无外乎三个原因。

第一,没引 actuator 依赖。busrefresh 端点由 actuator 体系托管,缺了spring-boot-starter-actuator自然找不到。第二,没在management.endpoints.web.exposure.include里放行。只引依赖不放行等于没引,默认只暴露 health。第三,Spring Security 把请求拦了。如果应用里配了安全过滤器,需要放行 actuator 相关的路径,或者调用时带认证信息。这三个原因里,第二个最常见,而且发生的场景往往不是"从来没配过",而是"原来配了,升级后配置被覆盖了"。

5.4 集群部署下的重复消费和多环境隔离

接总线之前,团队里一定有人会问:多实例部署是不是会重复执行刷新?前面讲过,总线是发布订阅模式,每个实例一条队列,所以不存在竞争消费。但有一个需要注意的地方:spring.cloud.bus.id必须保证唯一。如果两个实例的 bus id 撞了,它们在 RabbitMQ 里声明的队列会冲突,轻则消息被抢,重则其中一个实例报错退出。

另一个实战细节是多环境隔离。如果开发、测试、生产三套环境共享同一个 RabbitMQ,那一定要用不同的 virtual-host 把它们隔开。不然开发环境随手点一个 busrefresh,消息会广播到同一 RabbitMQ 上所有接入总线的队列,生产环境的服务也会跟着收到刷新事件,那画面太美我不敢看。我的习惯是每个环境一个 vhost,RabbitMQ 的账号也按环境分开,这个成本很低,收益很高。

5.5 自定义总线事件:把总线玩成控制通道

Bus 的价值不止刷配置这一件事。它本质上是微服务集群的"控制面",可以传输任何自定义的RemoteApplicationEvent。我举一个实际用过的例子:全局缓存清空。交易链路里有个数据字典服务,缓存在本地 JVM 里,字典一改需要全集群立刻清缓存。用 Bus 完全可以实现。

自定义事件类长这样:

public class DictRefreshEvent extends RemoteApplicationEvent { private String dictType; protected DictRefreshEvent() { super(); } public DictRefreshEvent(Object source, String originService, String destination, String dictType) { super(source, originService, destination); this.dictType = dictType; } public String getDictType() { return dictType; } }

关键在于接收端要加@RemoteApplicationEventScan,让 Bus 知道要把这个自定义类型注册到消息转换器里,不然反序列化会失败。发送端只需用 Spring 自带的ApplicationEventPublisher发布即可:

@RestController public class DictController { @Autowired private ApplicationEventPublisher publisher; @PostMapping("/dict/refresh") public void refresh(@RequestParam String type) { publisher.publishEvent(new DictRefreshEvent(this, "order-service:8081", null, type)); } }

Bus 会把本地发布的事件包装、序列化、投递到消息中间件,远端所有实例消费后还原成本地 Spring 事件,再由各自的@EventListener处理。这样一套机制实现下来,全集群的字典缓存清空从原来的"逐台登录执行命令"变成"一个接口搞定",操作时间从十几分钟缩短到几秒。

6. 几个值得记住的实践心得

先说最核心的一条:Bus 是控制面,不是数据面。它适合传"命令""通知""状态变更"这种轻量消息,不适合传业务数据,更不能塞大 payload。你要是想把某个接口的查询结果通过 Bus 发给别的服务,方向就错了,该用 RPC 或消息队列做业务通信就老老实实去用。

其次是端点的安全保护。busrefresh一旦暴露在公网,任何人都可以触发一次全集群配置刷新,后果是上下文重建带来的线程池抖动、连接池重建、缓存击穿。所以 actuator 端点要么在内网访问,要么加认证,exposure.include永远不要用*。

再就是 trace 日志。上线初期就把spring.cloud.bus.trace.enabled打开,它打印的收发日志是排障的重要依据。跑稳定了再关掉也行,但排查问题的时候它真的能节省大量时间。我见过太多人一上来就关 trace,出了问题再打开已经晚了。

最后是刷新动作的流程化。我的建议是把"改配置 + 跑 busrefresh"绑定成一步操作,比如做成发布平台的一个按钮,或者写成一个脚本封装进发布流程。这样能避免"配置改完了忘了刷新""刷新了但刷的范围不对"这类人为失误。微服务的基础设施每稳定一分,留给业务的精力就多一分。总线这层做扎实了,配置变更这种事就不再是晚上十一点的电话了。

RELATED READING

延伸阅读

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