ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Cloud 升级篇:GateWay 网关、Config 配置中心与 Bus 总线实践指南

Spring Cloud 升级篇:GateWay 网关、Config 配置中心与 Bus 总线实践指南

Spring Cloud 玩到第十章这个阶段,其实已经不是入门了。前面的注册中心、服务调用、负载均衡、熔断降级这些基础组件都通了之后,真正让人头疼的是两件事:一是所有微服务的流量入口太散,每个服务都要单独管鉴权、限流、跨域;二是配置散落在各个服务里,改一个参数要逐个改、逐个重启,线上环境动一下就提心吊胆。这一章讲的 GateWay、Config、Bus,恰好就是冲着这两个痛点去的,常被叫做升级篇,意思是这套东西不是非有不可,但微服务规模一旦上来,没有它们架构就撑不住。

这次的升级篇适合两类人:一类是已经跑通注册中心和服务间调用、准备把系统做得更规范的同学,另一类是正在被“改配置要重启三十个服务”折磨的运维和开发。我会把三个组件的原理、配置、实操和踩坑一起讲清楚,全程用我实际调试过的配置和场景来说。

1. 升级篇的定位:为什么把网关、配置、总线放在一起讲

先不急着贴配置,聊聊这三个组件为什么总被放在一起讲。它们各自解决一个问题,但组合起来才是一个完整的升级链路。

1.1 这套组合拳解决的核心痛点

传统微服务架构很容易出现两个失控场面。

第一个场面是流量入口失控。服务A、服务B、服务C都直接暴露给前端,权限校验每个服务各写一套,IP白名单各配一份,限流逻辑也是各写各的。等到对接第三方系统或者做统一安全审计时,你会发现根本没法管理,只能靠人肉协调,这是典型的“没有网关”的病。另一个更隐蔽的问题是前端要记一堆服务地址,服务一旦拆分变细,客户端那边就先崩了。

第二个场面是配置管理失控。假设你有三十个微服务,每个服务都有各自的数据库连接、业务开关、线程池参数。这些配置在早期都写在每个服务的配置文件里,看起来简洁,改起来却跟拆炸弹一样。某个配置项需要调整时,你得先记住哪些服务用到了它,然后逐个改、逐个发布,中间要是漏了一个,排查半天还找不到原因。

升级篇的核心思路,就是用 GateWay 把所有的外部请求收敛到一个统一的入口上,用 Config 把散落的配置集中到同一个地方统一管理,再用 Bus 把配置变更的消息在微服务之间传播开来。三者首尾相接:请求走网关,配置管服务,总线负责通知,配成一套之后,日常运维里的很多重复劳动都能消掉。

1.2 适合谁看,需要什么基础

这套东西并不是零基础就能直接学的。动手之前建议已经具备几个基础条件:注册中心能正常注册和发现服务,微服务之间的 OpenFeign 或 RestTemplate 调用已经跑通,对 YAML 配置结构不陌生。如果这些点都还不太熟,建议先把前面的章节过一遍再来看升级篇,否则网关路由写出来不生效,会很难判断是网关配置的问题还是服务注册的问题。

另外需要说明的是,本文里的示例是基于比较主流的 Spring Cloud 技术栈写法,配置的骨架可以直接参考。版本不同会有细节差异,比如旧项目用 bootstrap.yml,新项目用 spring.config.import,我在后面遇到的地方都会专门标注出来。

2. 服务网关 GateWay:互联网流量的统一入口

微服务架构里的网关,说直白点就是所有请求的“总闸门”。前端、移动端、第三方系统的所有请求都先打到网关,再由网关根据规则转发到后面的具体服务。这样一来,鉴权、限流、日志、跨域这些通用逻辑就不需要每个服务各写一套了。

2.1 为什么选 GateWay 而不是 Zuul

这里有个经典的选型问题,也是我经常被问到的。早年的 Spring Cloud 生态里,网关默认是 Zuul,但它有两代:Zuul 1 是基于 Servlet 的阻塞模型,换一个请求线程被占用一个,在高并发场景下很容易把线程池打满;Zuul 2 好一些,但 Spring Cloud 集成它的过程相当曲折。GateWay 不一样,它基于 Spring WebFlux,底层是 Netty,走的是非阻塞异步模型,性能上更贴合微服务场景。用生活化的话类比,Zuul 1 像人工窗口,一人办完一个业务才能叫下一位;GateWay 则像一个双通道超市收银台,扫码、收钱、装袋可以并行处理,吞吐量自然上去了。

我实际测试过,同样的模拟并发场景,GateWay 的响应耗时曲线比 Zuul 1 平缓很多。这也是新项目选型的普遍共识,除非你维护的是一个老得不能动的项目,否则新写网关基本都是 GateWay。

2.2 三个核心概念:路由、断言、过滤器

GateWay 的使用模型可以浓缩成一句话:给请求找到匹配的路由,通过过滤器链处理后转发到目标服务。三个核心概念正好对应这个过程:

  • Route(路由):一条路由就是一条转发规则,包含唯一ID、目标地址和一组断言与过滤器。
  • Predicate(断言):用来判断请求是否符合这条路由的条件。符合就执行转发,不符合就继续匹配下一条。
  • Filter(过滤器):在请求通过路由前后对请求或响应做处理,比如加请求头、改路径、做鉴权。

这三个概念在实际配置里的关系需要捋清楚。一条路由至少得有一个断言,否则所有请求都会命中它,路由顺序就完全乱了。而过滤器是可以叠加的,GateWay 内置了很多现成过滤器,比如 StripPrefix 用来去掉路径前缀,AddRequestHeader 用来往请求头里塞信息,也可以自己写一个 GlobalFilter 做全局逻辑。

2.3 落地一个路由配置

假设我有一个用户服务,注册中心里的服务名是 user-service,对外的调用路径要求是 /api/user/,但实际服务的接口路径是 /user/。网关要做的就是把外部路径去掉 /api 前缀,再转发给用户服务。配置如下:

spring: cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2

这里需要注意几个关键点。uri 我写的是 lb://user-service,这个 lb 前缀很关键,它表示让网关通过注册中心去发现 user-service 的真实实例地址,然后在多个实例之间负载均衡。如果没有这个前缀,而是直接写 http://localhost:8080,那网关就不会走服务发现,相当于把负载均衡绕过去了,服务实例一变多就出问题。

StripPrefix=2 的含义是去掉路径中的前两段。外部请求 /api/user/info,前两段是 /api 和 /user,去掉后变成 /info,正好是用户服务上的实际路径。这里的段数取值要和承载的路径层级严格对齐,我曾经遇到过有人配成 StripPrefix=1,导致请求被转发到 /user/info,然后用户服务找不到这个接口,排查了半天才意识到是前缀截断得不够。 请求进来之后先经过路由匹配,再经过整体过滤链。每条路由内部可以有自己的局部过滤器,但像日志、鉴权这类全局逻辑,更适合写在全局过滤器里,避免每条路由重复定义。

2.4 过滤器里最容易翻车的点

用过滤器实现全局鉴权是最常见的需求,但也是问题最多的地方。最容易翻车的有几个点,我都遇到过,列出来帮各位避坑。

第一,写全局过滤器时容易忘记设置执行顺序。GateWay 里多个全局过滤器同时存在时,需要实现 Ordered 接口,用 getOrder() 决定先后顺序。鉴权过滤器必须排在很前面,否则请求都已经往后端转发了,你才想起来没校验身份,那这个过滤器基本等于摆设。建议 order 值设成一个较小的值,负数也行,越小优先级越高。

第二,过滤器里对响应要做统一包装时,要特别注意响应对象是否已经被写过。有一次我在过滤器里给响应加统一返回格式,结果发现页面返回的是乱码,后来定位到是过滤器拿到响应对象后先写了部分内容,再交给后端,导致响应头里的 Content-Length 对不上。规范的写法是把响应包装成一个装饰类,在真正返回之前统一提交。

第三,跨域过滤器很容易被忽略。微服务接网关之后,前端请求的域名变成了网关地址,跨域策略如果只在后端服务里配置,那浏览器发预检请求时会在网关这一层就被拦下来。更合理的做法是在网关层配置全局 CORS 过滤器,各服务内部就不需要再处理跨域了。我见过的一个场景是前端调用接口抛跨域错误,开发改了半天后端代码没效果,最后发现网关层根本就没开跨域。

提示:GateWay 底层是 WebFlux,如果项目里不小心引入了 spring-boot-starter-web 的 MVC 依赖,启动时可能会出现类型冲突,导致网关服务起不来。写网关工程时,要把依赖范围理清楚。

3. 服务配置 Config:把散落在各服务的配置收拢起来

服务多了以后,配置管理是绕不开的坎。Config 组件做的事情很简单:单独提供一个配置中心服务,把原来每个服务里的配置文件统一托管到一个配置仓库(常见的是 Git 仓库,也可以用本地目录),其他服务启动时从配置中心拉取配置。

3.1 配置私有化带来的痛苦

配置私有化的痛苦我举个例子。刚接手某套系统时,发现每个服务里都有一份 datasource 配置和一份 Redis 配置,DBA 通知数据库密码要轮换时,我要登录十一台服务器,改几十个文件再重新发布,全部搞定已经过去了大半天。第二天通知说有一台漏了,系统连着数据库报错,整个人都是懵的。

用 Config 之后,数据库连接、Redis、业务开关这些共用配置全部收拢到一个地方,一次修改,全局生效,这才算把配置管理这笔账还回来。

3.2 搭建 Config Server 和 Client

Config Server 本身的工程非常简单,创建一个 Spring Boot 项目,加上依赖后,在启动类上标注开启配置中心的注解即可。

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

配置文件里指定配置来源于哪个 Git 仓库:

spring: application: name: config-server cloud: config: server: git: uri: https://your-git-repo/config-repo default-label: main server: port: 8888

这里有个日常叫法容易混淆的点。Config Server 的“配置文件”和“被管理的业务配置”不是一回事。上面的 application.yml 是 Config Server 自身的启动配置;而被管理的那些服务配置文件,是放在 Git 仓库里的,比如 order-service.yml、common.yml。Config Server 负责读取 Git 仓库里的这些文件,而业务服务作为 Config Client 去 Config Server 上拉取。

业务服务接入时,传统写法是在 bootstrap.yml 里配置 Config Server 地址:

spring: application: name: order-service cloud: config: uri: http://config-server:8888 profile: dev

这里 spring.application.name 对应配置仓库里的文件名前缀,profile 对应环境标识。比如 order-service.yml 配置了一个自定义参数 order.timeout,业务代码里用 @Value("${order.timeout}") 就能拿到。

需要提醒的是,新版 Spring Cloud 已经不建议使用 bootstrap.yml 这种引导机制,改为在 application.yml 里通过 spring.config.import 导入配置中心。但老项目短期内还是会遇到 bootstrap 写法的,两种都要认识。判断标准很简单:项目里有没有 spring-cloud-starter-bootstrap 的依赖,有就是旧写法,没有就用 spring.config.import。

spring: config: import: optional:configserver:http://config-server:8888

optional 前缀的意思是,即使配置中心暂时连不上,服务也能先用本地配置启动,这在本地开发调试时很有用。但生产环境我通常不建议加 optional,因为配置拉不到还强行启动,后面跑起来大概率会出诡异问题,不如启动时直接失败,早暴露早解决。

3.3 配置加密的实操

配置集中到一个仓库后,新的问题来了:数据库密码、密钥这些敏感信息全都明文放在 Git 仓库里了。正常做法是让 Config Server 支持对称加密,在 Config Server 的配置里配一个密钥,然后需要加密的配置项用密文存储。

操作分两步。第一步在 Config Server 加入加密相关能力,第二步调用接口生成密文再填入配置仓库。

encrypt: key: your-secret-key

然后用 POST 请求调用 Config Server 的加密接口:

curl -X POST http://config-server:8888/encrypt -d 'plain-password'

返回一串密文,把这串密文以 {cipher} 开头填入 Git 仓库的配置文件里。业务服务拿到配置后,Config Server 会自动解密。需要提醒的是,encrypt.key 本身也是敏感信息,生产环境不要明文写死在配置里,可以用环境变量注入。这个点看起来不起眼,但确实有人把密钥提交到了同一个仓库里,加密等于没做。

3.4 动态刷新和它的限制

配置集中管理之后,最直接的好处是修改全局参数方便了。但这里还有一个隐藏问题:Config Client 服务启动时拉取一次配置,之后配置仓库再修改,运行中的服务不会感知。于是需要引入“刷新”的概念。

Spring Cloud 提供了一个刷新机制:Config Client 暴露一个/actuator/refresh 端点,调用它会重新从配置中心拉取配置并刷新 Spring 上下文里带有 @ConfigurationProperties 或 @RefreshScope 的 Bean。注意这里有两个关键点,一个是 @RefreshScope,没有标注的 Bean 即使刷新也不会更新;另一个是 /actuator/refresh 只刷新当前这个服务实例,对于多个实例的服务,你得挨个调用。

这就是为什么社区里会有那句吐槽:“配置是统一了,但刷新还是得人肉。”也正是因为刷新这件事本身具备广播需求,才引出了 Spring Cloud Bus 存在的意义,两者结合起来才是完整的动态配置方案。

4. 服务总线 Bus:让配置变更消息跑起来

Bus 这个组件本质上是一个消息总线。它本身不做业务,它的职责是把一条消息通过消息中间件广播给所有订阅的微服务,让它们执行相同的动作。

4.1 为什么刷新还需要一条总线

手动逐个刷新服务实例的痛,只有做过的人才知道。一个 order-service 启动了三个实例,要刷配置得敲三次 curl。如果一次改了五个服务的配置,那就是十几二十个请求。更难受的是,你很难保证手速快、路径对,敲错一个实例忘了刷新,线上行为就会不一致。

Bus 解决这个问题的思路非常直接:把“调用刷新端点”这个消息发送到消息中间件,所有监听在同一个消息通道上的服务实例都会收到通知,然后各自执行配置刷新。这样一来,几十个服务的配置刷新,一条命令就搞定了。

4.2 引入 Bus 并绑定消息中间件

引入 Bus 之后,架构发生了变化。Config Server、注册中心、各个业务服务、消息中间件,四者之间形成联动。常见形态是配置仓库变更、调用 Config Server 的刷新端点(或者直接调用任意一个服务的刷新端点)、Bus 把刷新事件发到消息中间件、所有监听该话题的服务收到事件并刷新配置。

要搭建这套机制,需要做几件事。第一,确保消息中间件可用,所有服务和 Config Server 都能连上。第二,给 Config Server 和各个业务服务引入 Bus 相关依赖。第三,在它们的配置里补上消息中间件的连接信息。

spring: cloud: bus: enabled: true rabbitmq: host: your-mq-host port: 5672 username: your-username password: your-password

依赖引入后,前端负责通知的端点是 /actuator/bus-refresh。用 POST 请求调用其中一个服务的这个端点,Bus 会把刷新事件发到消息中间件,其他服务实例收到后去 Config Server 拉取最新配置。

这里有个很容易误解的技术点,我需要展开讲一下。Bus 的事件传播路径是“服务实例 → 消息中间件 → 其他服务实例”,而不是“Config Server → 其他服务实例”。Config Server 只负责提供配置内容的存储和读取,不负责主动推送。很多人以为配了 Config 就能自动刷新,其实中间还隔着一层 Bus 的播报。只有 Config 没有 Bus,配置是能改但不能自动刷新;只有 Bus 没有 Config,广播谁去拉取配置就不明确。两个组件配合使用,动态刷新链路才算闭合。

4.3 三种刷新模式的选择

实际操作中,刷新配置有以下三种方式,我会根据场景选择:

第一种是逐实例刷新。直接调用某个服务实例的 /actuator/refresh,只影响当前实例。这种模式适合灰度验证新配置是否正常,先在单个实例上验证,再扩散到全量。好处是风险可控,缺点是效率低,适合配置刚开始改、心里没底的时候。

第二种是全部广播刷新。调用任意一个实例的 /actuator/bus-refresh,所有监听总线的实例都会刷新。这是日常最常用的方式,适合配置变更已经测试过、需要全量生效的场景。

第三种是定向刷新。如果只想刷新某一个服务(比如只想刷新 order-service,不想动 user-service),可以带上目标服务的路径调用,类似 /actuator/bus-refresh/order-service。这种精确到服务的刷新方式,在多服务共享一条总线时非常实用,避免一次广播把无关服务的线程池参数也刷了。

三种模式分别对应不同的发布场景,没有哪一个绝对最好,我的习惯是:先定向,再灰度单个实例,最后广播或定向扩散。

4.4 总线联调时的常见断点

Bus 链路涉及服务、消息中间件、配置中心三方,排查起来比普通接口要复杂一点。我遇到过的几个典型问题,给各位打打底。

第一,调用 bus-refresh 没反应,也没有报错。最常见的原因是消息中间件的连接配置写在了某个服务的 application.yml 里,但引入 Bus 后配置中心优先从远端拉取配置,本地配置里的连接信息根本没被真实生效。排查时先去配置中心看有没有可用的 MQ 连接配置,而不是盯着本地文件改半天。

第二,事件被消费了,但配置没有变。这个问题多数出在配置没有被 @RefreshScope 标注,或者业务代码里的配置是静态类加载的。刷新端点只负责触发 Bean 的重新创建,如果你的 Bean 生命周期没有被 Spring 容器管理,刷多少次都不管用。

第三,总线端点返回 401。这是安全配置拦截了总线相关的 actuator 端点。Bus 刷新端点是 POST 请求,如果项目里接了安全框架,需要在配置里把这个端点放开,否则服务端直接拒绝。

提示:Bus 依赖消息中间件,如果消息中间件出现抖动,Bus 刷新的消息可能会晚到,甚至丢失。不过 Config 刷新跟普通消息不太一样,服务重启后还会重新拉取配置,所以短时间的消息延迟不太会造成数据不一致,但依赖 Bus 做配置发布时,还是要盯一下消息中间件的健康状态。

5. 把它们串起来:一次完整的配置变更发布演练

讲了这么多原理和单点配置,很多同学应该已经等着一套完整的“发布流程演示”了。下面我用一个真实常见的场景,把 GateWay、Config、Bus 串起来完整走一遍。

5.1 目标架构与请求链路

假设我有一个订单中心系统,包括 user-service、order-service、gateway-service、config-server,还接了一个消息中间件做总线。业务上需要把订单超时时间从 30 分钟改成 10 分钟,并且网关要对某个新接口开通匿名访问。

整体请求链路是这样的:客户端请求打到 gateway-service,网关根据路由断言匹配到 user-service 或 order-service,转发前经过全局过滤器做鉴权;order-service 启动时从 config-server 拉取配置,运行时通过 Bus 监听配置刷新事件;config-server 的配置内容放在 Git 仓库里。

在这个架构里,改配置不再需要登录任何业务服务器,只需要改 Git 仓库、触发一次刷新事件,剩下的交给链路自己跑完。

5.2 实际操作步骤

第一步,修改配置仓库中的 order-service.yml:

order: timeout: 10

同步到 Git 仓库的主分支。

第二步,调用 Config Server 的监控接口确认配置已可读取:

curl http://config-server:8888/order-service/dev

返回结果里应该能看到 order.timeout 已经是 10。这一步千万不要省,它能提前确认配置仓库的拉取链路是否正常,如果这里都读不到新值,后面刷多少个服务都没用。

第三步,先做定向刷新,只刷新 order-service,避免影响其他服务。调用 order-service 某个实例的总线刷新端口:

curl -X POST http://order-service:8082/actuator/bus-refresh/order-service

第四步,验证刷新结果。找一个会返回 order.timeout 值的接口,连续调用两次,观察返回值是否变成 10。如果接口没有直接暴露这个配置,可以临时加个调试接口,或者看日志里配置加载时的输出。

第五步,全量确认后,再处理网关侧的匿名访问需求。在 gateway-service 的配置里修改路由,给目标接口补充一条匹配规则,并调整全局过滤器,跳过对该路径的鉴权。网关自身的配置如果也走了 Config,同样可以用 Bus 一条命令刷新;如果网关配置是本地文件,改完重启网关即可。

5.3 出问题时的排查顺序速查表

这套链路跑起来之后,问题定位是门手艺。我习惯按“链路从上到下”排查,范围逐级缩小,效率比瞎猜高很多。

现象排查顺序常用检查点
配置刷新后没生效Config Server 是否能读到新值 → 服务是否收到刷新事件 → Bean 是否有 @RefreshScope配置文件命名、分支、拉取地址
总线刷新无反应MQ 连接是否正常 → 是否误用 refresh 而不是 bus-refresh → 服务是否引入总线依赖连接配置、依赖、端点路径
网关路由 404注册中心里服务是否存在 → GateWay 断言是否精确 → StripPrefix 段数服务注册、路由断言、前缀截断
服务重启后配置是旧的Git 仓库默认分支是否正确 → Config Client 是否从正确环境拉取default-label、profile

这张表基本覆盖了我日常缠斗最多的问题。每次看到“刷新没生效”这种话术,我第一反应不是去服务里打日志,而是先把 Config Server 的读值验证做掉,这一招能过滤掉大量的配置仓库问题。

如果已经按这个顺序排查完还是找不到原因,我会再检查一个隐蔽点:服务实例是否真的注册在了正确的注册中心命名空间下。总线事件是按服务名广播的,注册中心的 namespace 或者服务名跟前缀对不上,bus-refresh/order-service 这种定向刷新就不会命中任何实例。

6. 升级路上的经验沉淀与避坑清单

文章最后这部分,我不打算再重复讲配置细节,而是把这些年实施 GateWay、Config、Bus 升级时攒下来的几条思考做个沉淀。这些经验不一定写在哪本官方文档里,但在真实项目里遇到过太多次了。

6.1 我在多次升级中总结的几条原则

第一,配置变更永远遵循“单实例验证 → 定向刷新 → 全量广播”的顺序。我见过有人图省事,改完配置直接一条 bus-refresh 广播出去,结果有个服务因为配置从字符串变成了数字,消费方直接启动失败,网上系统瞬间告警。升级篇教你的不只是怎么用工具,更重要的是怎么控制风险面。

第二,网关的断言规则宁可写细一点,也不要图省事写宽。Path=/api/user/** 这种精确匹配,比直板地写 Path=/** 靠谱得多。路由写宽了之后,新加的服务很容易被旧路由截胡,请求转发到了错误的服务,日志还看不出明显异常,这种问题最难查。

第三,配置中心里的敏感信息必须加密。不用我多说,配置一集中,所有密码一起暴露,风险面是集中放大的。加密配置不要写死在配置文件里,用环境变量注入密钥,这份谨慎能帮你躲过很多安全事故。

第四,总线事件依赖消息中间件,消息中间件的可用性必须纳入监控。总线虽然只管配置刷新,真出故障时影响面却是全链路的。建议把消息中间件的连接状态、队列堆积情况一起接进监控大盘。

6.2 最后一个实用小技巧

最后分享一个小技巧:网关的路由配置变化其实不一定要重启网关。如果网关自身也接入了 Config 和 Bus,修改路由配置并广播刷新时,GateWay 会自动重载路由定义。这个特性我用了很久,改动路由不需要发布网关应用,发布窗口压缩到了秒级。

但这里有一个坑必须提醒:路由配置频繁变更会让网关的路由信息版本飘忽不定,建议把路由定义和业务参数分文件管理,路由文件提交变更时保留 Git 历史,方便快速回滚。我的习惯是每次路由变更都带上清晰的 commit message,比如“add anonymous path for user-info”,回滚时直接看历史记录点过去就行。这套习惯配合 Bus 的定向刷新,实际用起来是非常顺手的。

升级篇这三个组件,单个看都是工具,连起来才是一套完整的治理方案。网关管住入口,配置管住状态,总线管住变更传播,再加上注册中心、服务调用和监控,一套微服务的骨架才算真正立了起来。希望这篇内容能帮你少踩几个我当年蹚过的坑,升级路上更稳一点。

RELATED READING

延伸阅读

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