ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Cloud Gateway自定义Instance选择器:实现灰度路由实战

Spring Cloud Gateway自定义Instance选择器:实现灰度路由实战

在做网关灰度方案那段时间,我折腾最多的就是 Gateway自定义Instance选择器。当时场景很典型:某业务系统在注册中心里部署了多个 provider-service 实例,新版本 v2 只上线了两台,我们希望拿测试流量甚至带特定请求头的用户能稳定打到 v2,而不是被网关默认的负载均衡规则平均散落。默认的 Spring Cloud LoadBalancer 根本不关心请求头和实例元数据,调度方式就是机械的轮询和随机,版本差异在它眼里并不存在。如果你也卡在“按版本路由”“按标签路由”“按区域路由”这类需求上,这篇文章应该能省掉不少自己翻源码的时间。

1. 默认负载均衡为何满足不了灰度路由

1.1 从“平均分发”到“按版本分发”

网关层的负载均衡,最常见的诉求其实是“别把所有请求打到同一台机器”。默认的 RoundRobin 和 Random 把这一件事做好了,所以绝大多数业务跑得挺安全。

但灰度发布一出现,逻辑就不一样了。上线新版本时,你希望的是:老实例继续承担大量流量,新实例只接收一小部分“愿意试错”的流量,或者只有携带指定标记的请求才能访问新版本。这时候“平均”反而是最危险的,它会在瞬间把新版本的隐性故障放大到所有用户面前。

我见过不少团队在网关层用笨办法做灰度,比如拆路由:单独给 v2 实例配一个新域名,测试用户走新域名。这个方案不是不行,但维护成本很高,每发一个版本都要动一层路由配置,而且请求经过的网关过滤器链路也会重复维护。

更优雅的做法,是把分发策略下沉到 LoadBalancer 这一层,让“选择哪个实例”这件事能感知请求内容和实例元数据。这就是自定义 Instance 选择器的价值所在。

1.2 注册中心里的实例,并不只有 IP 和端口

很多人在第一次接触负载均衡时,脑子里对“服务实例”的想象就是{"ip": "10.10.1.2", "port": 8080}这样的二元组。实际上,注册中心返回的ServiceInstance除了 IP、端口之外,还带着一个灵活度极高的 metadata 字段。

在 Nacos 里,你可以这样给实例打标:

spring: cloud: nacos: discovery: metadata: version: v2 region: hz canary: true

在 Eureka 里对应的是:

eureka: instance: metadata-map: version: v2 region: hz

这些 metadata 平时没什么存在感,但它就是实例侧自定义路由规则的数据底座。网关做灰度时,本质上只需要回答一个问题:当前请求想找的版本,在这些实例的 metadata 里存不存在,存在就只从匹配的实例里选。

1.3 自定义的切入点藏在 LoadBalancer 的两层抽象里

Spring Cloud LoadBalancer 在 2022.x 之后,把“选出目标实例”这件事拆成了两层职责,理解这两层,后面写代码才会顺手:

第一层是ServiceInstanceListSupplier,它负责从注册中心拉取候选实例列表,可以认为它是“候选池”。默认实现会从 DiscoveryClient 拿到全量实例。

第二层是InstanceSelector,它负责从候选池里最终挑一个实例,轮询、随机都是它的具体实现。

灰度路由的两种常见做法,其实分别落在两层上:你可以改造候选池,把不符合当前请求版本的实例过滤掉;也可以改造最终选择器,在拿到全量列表后用自定义策略选一个最合适的。后面我会把两条路都写出来,因为不同 Spring Cloud 版本里的可行写法并不完全一致。

2. 从网关请求到达实例选择,这条链路到底怎么走

2.1 路由 URI 里的 lb:// 协议是关键开关

网关里最常见的转发配置长这样:

spring: cloud: gateway: routes: - id: provider-route uri: lb://provider-service predicates: - Path=/api/provider/**

注意这个lb://,它是整个自定义选择器能否生效的前置条件。只要 URI 写的是http://provider-service,网关注册到某个下游服务时拿到的是一串字符串,直接交给底层 HTTP client 去解析域名,这个请求压根就不会经过 LoadBalancer 的实例选择流程。只有看到lb://,网关的负载均衡客户端才会接管 instance 的选择。

这个点看着基础,但我确实见过有人排查了很久的自定义选择器不生效,最后发现路由里一直写的http://。如果你的选择器一个日志都没打出来,先检查这里。

2.2 候选列表与选择器的协作机制

当请求进入lb://provider-service后,Gateway 的负载均衡过滤器会构造一个Request,其中带着当前请求的上下文。随后框架会做这样几件事:

  1. 取到为该服务配置的ServiceInstanceListSupplier。
  2. 调用get(Request request)获取候选实例列表。
  3. 拿到InstanceSelector,让它从列表中挑最终实例。
  4. 将最终实例的 host 和 port 重写为 HTTP client 要请求的地址。

默认组合里,候选列表来自注册中心,选择器是 RoundRobin,两者都不感知请求上下文。我们要做的,就是替换或包装其中一个环节。

2.3 请求数据是怎么传到选择器手里的

这是整个方案里最容易一头雾水的地方。InstanceSelector的方法签名里确实有Request request参数,但很多人不知道这个 Request 里到底装的啥。

在 Gateway 场景下,这个 Request 的具体实现是DefaultRequest<RequestDataContext>,而RequestDataContext里塞着当前的ServerWebExchange。只要拿到 exchange,就等同于拿到了完整的ServerHttpRequest,请求头、查询参数、Cookie 全在手边。

所以自定义选择器取灰度标记时,典型的姿势是这样的:

private String extractVersion(Request request) { if (request.getContext() instanceof RequestDataContext context) { ServerWebExchange exchange = context.getExchange(); if (exchange == null) { return null; } String headerVersion = exchange.getRequest().getHeaders().getFirst("x-version"); if (StringUtils.hasText(headerVersion)) { return headerVersion; } return exchange.getRequest().getQueryParams().getFirst("version"); } return null; }

这条信息链路搞懂了,后面写选择器就自然多了。

3. 自定义 Instance 选择器的完整落地过程

3.1 先定协议:请求头、查询参数和实例元数据

动手写代码之前,一定要先和前端、后端约好两件事:

  • 请求侧用什么字段表达“我想访问哪个版本”;
  • 实例侧用什么 metadata key 表达“我是哪个版本”。

我习惯的约定是:请求头用x-version: v2,查询参数备用version=v2;实例 metadata 用version字段。如果团队有标签路由需求,metadata 里再多一个tag字段,逻辑完全一样。

这里想提醒一句:协议定得越简单,后面维护越省心。不要搞出一套“请求头传版本号 + 请求参数传灰度组 + metadata 传环境”的多路同时生效规则,很容易在排查问题时绕晕。

3.2 实现 InstanceSelector:按请求头匹配版本元数据

在当前主流的 Spring Cloud 2022.x / 2023.x 中,最直接的做法是实现InstanceSelector接口。

package com.example.gateway.loadbalancer; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.RequestDataContext; import org.springframework.cloud.loadbalancer.core.InstanceSelector; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.util.StringUtils; import java.util.List; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; public class VersionInstanceSelector implements InstanceSelector { @Override public ServiceInstance choose(ServiceInstanceListSupplier supplier, Request request) { String version = extractVersion(request); // 这里通过 supplier 动态获取候选列表,逻辑上等价于框架传入的当前实例池 List<ServiceInstance> instances = supplier.get(request).next().block(); if (instances == null || instances.isEmpty()) { return null; } if (StringUtils.hasText(version)) { List<ServiceInstance> matched = instances.stream() .filter(instance -> version.equals(instance.getMetadata().get("version"))) .collect(Collectors.toList()); if (!matched.isEmpty()) { return randomChoose(matched); } } // 默认兜底,避免灰度标记打错后完全没有可用实例 return randomChoose(instances); } private ServiceInstance randomChoose(List<ServiceInstance> instances) { if (instances.size() == 1) { return instances.get(0); } return instances.get(ThreadLocalRandom.current().nextInt(instances.size())); } private String extractVersion(Request request) { if (request.getContext() instanceof RequestDataContext context) { var exchange = context.getExchange(); if (exchange != null) { String header = exchange.getRequest().getHeaders().getFirst("x-version"); if (StringUtils.hasText(header)) { return header; } return exchange.getRequest().getQueryParams().getFirst("version"); } } return null; } }

代码里有必要解释清楚两个设计决策。

第一,这里用next().block()拿候选列表,是为了把示例逻辑写得直观,让大家看清楚筛选步骤。真实生产环境里我不太建议在响应式链路上 block,你完全可以用后面的DelegatingServiceInstanceListSupplier思路,在响应式流内完成筛选。

第二,请求头带了版本号但匹配不到任何实例时,我选择回退全量实例,而不是直接返回 null。返回 null 意味着网关可能报 503“没有可用实例”,在灰度期间如果新版本实例还没完全注册上来,这会让本可正常服务的用户直接挂掉。回退全量虽然做不到严格隔离,但至少保证了可用性。

3.3 用 Supplier 组合的方式注册自定义选择器

上面写好了InstanceSelector,还需要通过组合ServiceInstanceListSupplier的方式把它挂载到指定服务上。

package com.example.gateway.config.loadbalancer; import com.example.gateway.loadbalancer.VersionInstanceSelector; import org.springframework.cloud.client.loadbalancer.LoadBalancerClientFactory; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.context.annotation.Bean; public class ProviderLoadBalancerConfig { @Bean public ServiceInstanceListSupplier providerServiceInstanceListSupplier( LoadBalancerClientFactory factory) { return ServiceInstanceListSupplier.builder() .withDiscoveryClient() .withCaching() .filteredBy(VersionInstanceSelector.class) .build(factory.getContext("provider-service")); } }

.withCaching()会包一层实例列表缓存,降低每次请求都去注册中心拉取列表的压力;.filteredBy()则是告诉框架用我们定义的 selector 来做过滤和选择。

然后注册到网关主配置上:

@Configuration @LoadBalancerClient(name = "provider-service", configuration = ProviderLoadBalancerConfig.class) public class GatewayConfiguration { }

注意ProviderLoadBalancerConfig这个类我刻意没有加@Configuration注解,只作为@LoadBalancerClient的配置类被加载。原因后面踩坑小节会细说,简单讲就是避免被主应用包扫描成全局配置,从而污染其他服务的负载均衡行为。

3.4 老版本项目的替代写法:从列表源头过滤

如果你的项目还在用 Spring Cloud 2021.0.x,InstanceSelector接口可能还不完整,或者ServiceInstanceListSupplier.builder().filteredBy()还不可用。这时候更稳的写法是继承DelegatingServiceInstanceListSupplier,在列表层面做过滤。

package com.example.gateway.loadbalancer; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.RequestDataContext; import org.springframework.cloud.loadbalancer.core.DelegatingServiceInstanceListSupplier; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import reactor.core.publisher.Flux; import java.util.List; import java.util.stream.Collectors; public class VersionServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier { public VersionServiceInstanceListSupplier(ServiceInstanceListSupplier delegate) { super(delegate); } @Override public Flux<List<ServiceInstance>> get(Request request) { return getDelegate().get().map(instances -> filterInstances(instances, request)); } private List<ServiceInstance> filterInstances(List<ServiceInstance> instances, Request request) { String version = extractVersion(request); if (version == null || version.isBlank()) { return instances; } List<ServiceInstance> matched = instances.stream() .filter(instance -> version.equals(instance.getMetadata().get("version"))) .collect(Collectors.toList()); return matched.isEmpty() ? instances : matched; } private String extractVersion(Request request) { if (request.getContext() instanceof RequestDataContext context) { var exchange = context.getExchange(); if (exchange != null) { return exchange.getRequest().getHeaders().getFirst("x-version"); } } return null; } }

之后配置 bean 时直接生成这个 supplier:

@Bean public ServiceInstanceListSupplier versionServiceInstanceListSupplier( LoadBalancerClientFactory factory) { ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder() .withDiscoveryClient() .build(factory.getContext("provider-service")); return new VersionServiceInstanceListSupplier(delegate); }

这个思路的本质是:先缩小白鼠范围,再让默认选择器在缩小后的范围内轮询或随机。它对版本的适配性更好,也是我在 2021.x 项目里推荐的做法。

4. 实现之外必须处理的边界问题

4.1 实例列表缓存会把新版本“藏起来”

加了.withCaching()之后,实例列表会被缓存一段时间,默认的 TTL 大约是 30 秒。这个缓存对常规负载均衡很友好,但在灰度场景里容易让人困惑:新实例注册上来,灰度标记打好了,但网关里看到的还是老列表,整整半分钟不刷新。

如果灰度发布要求秒级生效,我会关掉缓存或者把 TTL 调小:

spring: cloud: loadbalancer: cache: ttl: 5s capacity: 1024

代价是注册中心会被拉取得更勤快。建议根据你们注册中心实例数量和请求量权衡,如果实例几百上千,TTL 压到 3 秒要评估一下压力。

4.2 筛选为空时,别让用户承担 503

灰度期间最容易出现的异常情况是:请求头写着x-version: v2,但注册中心里 v2 实例刚好在发布窗口还没注册完成,或者已经全部下线。这时候如果选择器返回空列表,网关对大量测试请求会直接返回 503,体验非常差,而且会把“发布中”的预期状态放大成线上事故。

我个人建议的回退策略分三个档次:

  • 匹配到同版本实例:只返回这些实例;
  • 没匹配到同版本实例:返回全量实例,同时打 WARN 日志;
  • 全量实例都没有:返回 null,让网关走默认无实例异常处理。

WARN 日志一定要打,不然灰度比例异常时你根本不知道是实例没注册还是选择器逻辑写错了。日志内容至少包含服务名、请求版本号、当前实例数、已匹配实例数。

4.3 选择器必须保持无状态

写InstanceSelector时最容易埋的一个雷,就是顺手在实现类里存了一个实例字段:

public class MySelector implements InstanceSelector { private String lastVersion; // 千万别这么干 ... }

LoadBalancer 的 bean 在网关里是共享的,高并发下多个请求会同时进入选择器,你存进字段里的请求信息一定会串。正确的做法是把所有请求相关数据全部从方法参数Request中派生,类内部只允许存放无状态逻辑和不可变配置。

响应式环境里还要特别注意,不要用 ThreadLocal 传递版本信息。一个请求在网关里可能横跨多个线程,ThreadLocal 要么丢,要么串到其他请求上,排查起来极其隐形。

4.4 实例 metadata 的 key 别写错

getMetadata().get("version")和getMetadata().get("Version")是两码事。注册中心 metadata 的 key 通常要做统一规范,全小写。我在某个项目里就遇到过运维配的元数据 key 是Version,我代码里写的是version,匹配始终为空,回退逻辑又兜底了,导致灰度开关看起来像没反应。这种问题日志、监控都不容易暴露,最好在适配时用小工具先打印一份实例 metadata 确认字段名后再定规则。

5. 踩坑实录:从“配置不生效”到“选择器无效”

5.1 配置类被全局扫描,结果所有服务都变了样

第一次实现时,我把ProviderLoadBalancerConfig写在了主配置包下面,并且老老实实加了@Configuration注解。结果启动之后,不仅是 provider-service,其他通过网关转发的服务全都开始按自定义选择器过滤了。症状表现为:普通服务偶尔 503,日志里还能看到 VersionInstanceSelector 的执行记录。

根因很简单:@LoadBalancerClient的configuration类如果落在@SpringBootApplication的扫描路径里,它会作为全局配置被加载,而不只是为一个服务加载。Spring 官方文档其实对此有提示,但太容易踩了。

我的解决办法是把负载均衡相关的配置和主配置包错开,要么放到com.example.gateway.config.loadbalancer这样的独立子包并确保不触发包扫描,要么就不给配置类加@Configuration,让它只被LoadBalancerClientFactory在子容器中加载。后者我实践下来更稳,代码上也少一层包结构出错的担忧。

5.2 新版本接口变了,旧写法编译不过

排查另一个项目时,同事反馈说自己照着 2021.x 的博客实现的ServiceInstanceListSupplier,升级到 2022.x 之后突然编译不通过。我去看了下,发现新版本已经把选择逻辑拆成了InstanceSelector接口,旧的RequestBasedInstanceSelector相关 API 改了名字,导致他get(Request)的方法签名对不上。

遇到这种问题,别急着改代码,先确认三件事:

  • 当前 Spring Cloud 版本对应的spring-cloud-loadbalancer版本;
  • 该项目用的是ServiceInstanceListSupplier.get()还是get(Request);
  • 是选择器实现方案,还是列表过滤方案。

然后按版本的官方 API 调整写法。这一节前面两种实现方式,基本能覆盖 2021.x 到 2023.x 的常见迁移路径。

5.3 实例下线后,灰度流量依然打向旧实例

有一次灰度发布过程中,我们把 v1 实例正常下掉了,但测试请求还是不断打到一台已经下线的 v1 实例,网关报连接异常。

排查链路是这样的:

  1. 先看注册中心,v1 实例确实被摘除;
  2. 再看网关日志,选择器每次选出的都是另一个 v1 实例 IP;
  3. 翻配置,发现withCaching()的缓存 TTL 还是默认 30 秒,而服务下线通知基本秒级,网关缓存里始终保留了已下线实例的列表。

修复方式就是前面提到的调低 TTL。如果对实时性要求更严格,可以去掉withCaching(),让每次请求都从注册中心重新拉取。实例量不大的业务,这点开销完全能接受。

5.4 自定义选择器没进链路,日志完全没输出

还有一次更冤:代码全写好了,@LoadBalancerClient也配了,但日志里始终不见选择器执行。最后定位到是路由 URI 写成了http://provider-service,负载均衡这条路压根没走。

这类问题的排查顺序很有代表性,我后来都按这个顺序快速定位:

  • 第一,确认路由 URI 是lb://开头;
  • 第二,确认@LoadBalancerClient的 name 和路由 URI 里的服务名一致;
  • 第三,确认配置类没有被全局扫描;
  • 第四,确认注册中心里实例 metadata 确实带有目标字段;
  • 第五,确认缓存未过期。

这五步走完,百分之九十的自定义选择器问题都能找到答案。

6. 从版本筛选扩展:权重灰度、同区域优先与 A/B 分流

6.1 权重灰度的实现思路

版本筛选能做到“有或无”,但实际灰度发布更常见的是“一部分流量进新版本”。这时候可以在 metadata 里约定一个weight字段,比如 v2 实例的 weight 配成 5,v1 实例的 weight 配成 95,选择逻辑变成加权随机。

简化的实现思路是这样的:

int totalWeight = instances.stream() .mapToInt(this::parseWeight) .sum(); int randomValue = ThreadLocalRandom.current().nextInt(totalWeight); for (ServiceInstance instance : instances) { int currentWeight = parseWeight(instance); if (randomValue < currentWeight) { return instance; } randomValue -= currentWeight; } return instances.get(0);

这个方案和版本过滤完全不冲突,可以先按x-version过滤出候选集合,再在候选集合内做加权随机。灰度比例调整时只需要改注册中心里的 metadata 或通过配置中心下发占位符,不用重启网关。

6.2 同区域优先选择的思路

多机房部署时,实例 metadata 里通常会有zone或region字段。选择器可以先判断当前网关实例属于哪个区域,然后只从同区域候选实例里挑选目标,本地没有可用实例再放宽到全局。

这里要重点提醒:同区域优先的写法要格外小心区域配置缺失的默认值问题。如果某些实例没有 zone 字段,按 null 匹配会全盘落空,必须有一个回退逻辑,比如同区域为空时再尝试所有实例,否则跨机房故障时整个服务会直接不可用。

6.3 把决策逻辑放到网关过滤器里

前面一直是用请求头里的x-version来决定版本,这在一些场景下不够灵活。比如想按用户 ID 分桶,或者按设备类型分流,你总不能要求每个客户端都自己构造版本头。

更干净的方案是在 Gateway 过滤器里把决策结果写进ServerWebExchange的 attribute:

exchange.getAttributes().put("routeVersion", "v2");

选择器里直接读取 attribute:

Object version = context.getExchange().getAttribute("routeVersion");

这样一来,客户端完全无感知,分流规则由网关统一控制,后续接配置中心动态调整也会更顺手。

6.4 组合拆分建议

做了一套自定义选择器之后,千万别把所有规则塞进同一个类。我建议按职责拆分:

  • 候选过滤层:负责版本、标签、区域这些“缩小范围”的逻辑;
  • 最终选择层:负责权重、随机、轮询这些“从范围内选一个”的逻辑;
  • 决策上下层:负责从请求头、exchange attribute、配置中心读取分流依据。

这样拆分之后,每层都可以独立测试。遇到生产问题时,也能快速定位是候选池没过滤对,还是最终选择算法出了问题。

我个人体会最深的一点是:自定义 Instance 选择器的难点从来不在怎么写代码,而在于你能不能把请求上下文、实例元数据、缓存机制三件事同时想明白。先把版本号约好,再把回退策略定死,之后不管做权重灰度还是同区域优先,都只是在这个骨架上添砖加瓦。

RELATED READING

延伸阅读

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