Spring Cloud Gateway自定义负载均衡:权重与灰度路由实战
1. 为什么内置负载均衡策略总差那么点意思做过微服务架构的朋友大概率都经历过这个阶段项目初期用 Spring Cloud Gateway 做统一入口路由转发配上默认的轮询策略一切看起来都很美好。等到业务稍微复杂一点问题就来了——有的后端节点部署在配置更高的机器上有的节点因为网络区域不同响应速度差异明显还有的节点正在做灰度发布只能接少量流量。这时候你会发现Gateway 自带的RoundRobinLoadBalancer和RandomLoadBalancer完全不够用。Spring Cloud Gateway 在早期版本中依赖 Ribbon 做负载均衡后来官方逐步转向了 Spring Cloud LoadBalancer 体系。这个切换过程本身就埋了不少坑很多人在升级版本后发现原来的自定义 Rule 不生效了或者配置方式完全变了。而真正让人头疼的是官方提供的负载均衡抽象虽然灵活但文档相对分散想要实现一个贴合自己业务场景的策略需要打通好几个环节服务实例的获取方式、负载均衡器的注册机制、与 Gateway 过滤链的集成方式以及健康检查与权重动态调整的配合。这篇文章面向的是已经用过 Spring Cloud Gateway 做基础路由转发、但希望进一步掌控流量分发逻辑的开发者。我会从负载均衡在 Gateway 中的实际执行链路讲起拆解自定义负载均衡器的完整实现路径包括服务实例供应、权重计算、缓存策略、动态刷新这几个核心环节。文章里给出的代码和配置都经过实际项目验证你可以直接参考到自己的工程里。需要说明的是下面涉及的具体实现细节是基于常见工程实践做的合理补充不同版本之间可能有细微差异我会在关键位置标注版本注意事项。2. 请求进入 Gateway 之后负载均衡到底在哪一层生效2.1 从一次路由转发看负载均衡的触发时机很多人以为负载均衡是在路由匹配阶段就决定了目标实例实际上并非如此。当一个请求到达 Spring Cloud Gateway 时执行顺序大致是这样的首先RoutePredicateHandlerMapping根据断言规则匹配到对应的 Route然后请求进入 Gateway 的过滤器链。真正触发负载均衡的是ReactiveLoadBalancerClientFilter这个全局过滤器它会在过滤器链的某个特定顺序位置执行。这个过滤器做的事情很直接从请求的 URL 中解析出服务名比如lb://user-service/api/users其中lb是固定的协议前缀user-service就是服务名。拿到服务名之后它调用LoadBalancerClientFactory获取对应的ReactiveLoadBalancer实例再通过choose方法选出一个ServiceInstance最后把 URL 中的服务名替换成实际的host:port交给后续的NettyRoutingFilter发起真正的 HTTP 请求。理解这个链路非常关键因为它决定了你自定义的负载均衡器需要实现哪个接口、注册到哪个工厂、以及如何与 Gateway 的过滤器链协同工作。如果你在错误的位置做扩展比如试图在断言阶段就决定实例那整个方案就走偏了。2.2 ReactorLoadBalancer 与 ServiceInstanceListSupplier 的协作关系Spring Cloud LoadBalancer 的核心抽象是ReactorLoadBalancerT extends ServiceInstance它只有一个关键方法FluxServiceInstance choose(Request request);注意返回值是Flux而不是单个对象这意味着负载均衡的选实例过程本身是响应式的可以异步、可以重试、可以组合多个数据源。这个设计比早期 Ribbon 的同步接口要灵活得多但也提高了上手门槛。而实例列表从哪里来答案是ServiceInstanceListSupplier。这是一个响应式供应器接口负责提供某个服务的所有可用实例。默认实现有从注册中心拉取的比如 Nacos、Eureka、Consul 各自的实现也有基于配置文件的ConfigurationBasedServiceInstanceListSupplier。自定义负载均衡器通常不需要直接实现这个接口而是通过组合现有的 Supplier 来获取实例列表然后在此基础上做筛选和排序。这里有一个容易踩的坑ServiceInstanceListSupplier的实例是被缓存的默认缓存时间由spring.cloud.loadbalancer.cache.ttl控制默认 35 秒。如果你在自定义策略中依赖实例的实时状态比如实时权重就需要考虑这个缓存带来的延迟。后面我会专门讲怎么处理这个问题。2.3 默认负载均衡配置的加载路径与覆盖规则Spring Cloud LoadBalancer 的配置加载有一套自己的规则。每个服务可以有自己的负载均衡配置通过LoadBalancerClient或LoadBalancerClients注解指定配置类。全局默认配置则通过LoadBalancerClientConfiguration或者直接在配置文件中设置。关键点在于自定义配置类不能放在ComponentScan能扫描到的包路径下否则它会变成全局配置影响所有服务。正确的做法是把它放在一个独立的包中通过注解显式引用。这个规则和 Ribbon 时代的RibbonClient配置类规则类似但很多人从 Ribbon 迁移过来时会忽略这一点导致自定义策略意外地作用到了所有服务上。另外配置类的优先级是服务级配置 全局配置 默认配置。如果你同时定义了多个层级的ReactorLoadBalancerBean只有最具体的那一个会生效。这个优先级规则在排查为什么我的自定义策略没生效时非常有用。3. 手写一个带权重的响应式负载均衡器3.1 定义权重元数据与实例筛选逻辑假设我们的业务场景是这样的后端服务部署了多个实例每个实例的机器配置不同我们希望流量按照配置比例分发。比如两台 4 核 8G 的机器各承担 30% 流量一台 8 核 16G 的机器承担 40% 流量。这个需求用默认的轮询策略无法满足需要自定义。第一步是定义权重的存储方式。最直接的做法是利用服务注册中心的元数据metadata功能。以 Nacos 为例在服务注册时可以通过spring.cloud.nacos.discovery.metadata.weight30这样的配置给每个实例打上权重标签。Eureka 和 Consul 也都有类似的 metadata 机制。# 实例 A 的配置 spring: cloud: nacos: discovery: metadata: weight: 30 zone: zone-a # 实例 B 的配置 spring: cloud: nacos: discovery: metadata: weight: 40 zone: zone-b拿到实例列表后筛选逻辑需要做几件事过滤掉没有权重元数据的实例给默认权重、过滤掉权重为 0 的实例表示不接收流量、解析权重值为整数。这里要注意元数据的值都是字符串类型需要做健壮的解析避免因为格式错误导致整个负载均衡器抛异常。private int resolveWeight(ServiceInstance instance) { String weightStr instance.getMetadata().get(weight); if (weightStr null || weightStr.isBlank()) { return DEFAULT_WEIGHT; } try { int weight Integer.parseInt(weightStr.trim()); return Math.max(weight, 0); } catch (NumberFormatException e) { log.warn(实例 {} 的权重值 {} 无法解析使用默认权重, instance.getInstanceId(), weightStr); return DEFAULT_WEIGHT; } }这段代码看起来简单但实际项目中我见过因为权重值配了30%这种带百分号的格式导致解析失败的案例。防御性编程在这里不是过度设计而是必要措施。3.2 基于权重的随机选择算法实现权重确定之后选择算法本身有多种方案。常见的有加权轮询、加权随机、平滑加权轮询等。这里我选择加权随机作为示例因为它的实现足够简单且在大多数场景下分布效果可以接受。加权随机的核心思路是计算所有实例的权重总和生成一个[0, totalWeight)之间的随机数然后遍历实例列表用随机数依次减去每个实例的权重当结果小于等于 0 时当前实例就是选中者。public class WeightedRandomLoadBalancer implements ReactorLoadBalancerServiceInstance { private final String serviceId; private final ServiceInstanceListSupplier supplier; private final Random random new Random(); public WeightedRandomLoadBalancer(String serviceId, ServiceInstanceListSupplier supplier) { this.serviceId serviceId; this.supplier supplier; } Override public FluxServiceInstance choose(Request request) { return supplier.get(request) .next() .flatMapMany(instances - { ListServiceInstance candidates instances.stream() .filter(this::isEligible) .collect(Collectors.toList()); if (candidates.isEmpty()) { return Flux.error(new IllegalStateException( 服务 serviceId 没有可用实例)); } ServiceInstance selected selectByWeight(candidates); return Flux.just(selected); }); } private ServiceInstance selectByWeight(ListServiceInstance candidates) { int totalWeight candidates.stream() .mapToInt(this::resolveWeight) .sum(); if (totalWeight 0) { return candidates.get(random.nextInt(candidates.size())); } int hit random.nextInt(totalWeight); int cumulative 0; for (ServiceInstance instance : candidates) { cumulative resolveWeight(instance); if (hit cumulative) { return instance; } } return candidates.get(candidates.size() - 1); } }这里有几个设计决策值得说明。第一supplier.get(request).next()取的是实例列表的第一个发射值因为ServiceInstanceListSupplier通常发射一个包含所有实例的列表。第二当所有权重都为 0 时降级为均匀随机避免出现无实例可选的死局。第三Flux.error的使用让调用方能够感知到实例耗尽的情况而不是静默返回空。3.3 把自定义负载均衡器接入 Gateway 的配置体系实现类写好了接下来要让它生效。需要创建一个配置类在其中声明ReactorLoadBalancerBeanpublic class WeightedLoadBalancerConfiguration { Bean public ReactorLoadBalancerServiceInstance weightedLoadBalancer( Environment environment, LoadBalancerClientFactory loadBalancerClientFactory) { String name environment.getProperty( LoadBalancerClientFactory.PROPERTY_NAME); return new WeightedRandomLoadBalancer(name, loadBalancerClientFactory.getLazyProvider( name, ServiceInstanceListSupplier.class)); } }注意LoadBalancerClientFactory.PROPERTY_NAME这个属性的获取方式它是由框架在创建服务级配置上下文时注入的代表当前配置对应的服务名。通过getLazyProvider获取ServiceInstanceListSupplier是为了避免循环依赖这是官方推荐的做法。然后在 Gateway 的启动类或配置类上指定这个配置LoadBalancerClients(defaultConfiguration WeightedLoadBalancerConfiguration.class) SpringBootApplication public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } }如果你只想对特定服务生效可以这样写LoadBalancerClients({ LoadBalancerClient(name user-service, configuration WeightedLoadBalancerConfiguration.class) })注意WeightedLoadBalancerConfiguration类上不要加Configuration注解也不要放在主应用的包扫描路径下。加了Configuration会导致它变成全局配置放在扫描路径下会导致所有服务都使用这个策略。这个坑我在两个项目里都见人踩过。4. 实例列表缓存与权重动态调整的配合4.1 默认缓存机制带来的权重延迟问题前面提到ServiceInstanceListSupplier有默认 35 秒的缓存。这意味着你通过注册中心控制台修改了某个实例的权重最长要等 35 秒才会在 Gateway 侧生效。对于需要快速调整流量的场景比如突发流量转移、故障实例摘除这个延迟可能无法接受。解决思路有两条一是缩短缓存 TTL二是绕过缓存直接获取最新实例。缩短 TTL 最简单在配置文件中设置spring: cloud: loadbalancer: cache: ttl: 5s enabled: true但 TTL 设得太短会增加注册中心的查询压力尤其是服务实例数量多、Gateway 节点多的时候。5 秒是一个比较平衡的值再短就要评估注册中心的承载能力了。另一条路是在自定义负载均衡器中不使用缓存的 Supplier而是直接调用注册中心的查询接口。这种做法实时性最好但实现复杂度高且失去了缓存带来的性能优势。我的建议是如果权重调整频率不高比如一天几次用默认缓存加手动刷新就够了如果需要秒级响应再考虑绕过缓存。4.2 通过事件机制触发实例列表刷新Spring Cloud LoadBalancer 提供了一个LoadBalancerCacheManager和相关的缓存抽象但没有直接暴露清除某个服务缓存的 API。不过我们可以通过发布RefreshEvent或者自定义事件来间接触发刷新。一个实用的做法是结合配置中心如 Nacos Config的监听能力。当权重配置在配置中心发生变更时监听器收到通知然后清除对应的负载均衡缓存Component public class WeightRefreshListener { private final LoadBalancerCacheManager cacheManager; public WeightRefreshListener(LoadBalancerCacheManager cacheManager) { this.cacheManager cacheManager; } EventListener public void onWeightChange(WeightChangeEvent event) { Cache cache cacheManager.getCache( LoadBalancerCacheManager.SERVICE_INSTANCE_CACHE_NAME); if (cache ! null) { cache.clear(); } } }cache.clear()会清空所有服务的实例缓存粒度比较粗但实现简单。如果你需要更细粒度的控制可以研究LoadBalancerCacheManager的内部结构它本质上是一个ConcurrentMapCacheManager理论上可以按 key 清除但 key 的生成规则没有公开文档需要看源码确认。4.3 健康检查与权重为 0 的实例摘除权重为 0 的实例在自定义负载均衡器中会被过滤掉但这里有一个隐患如果所有实例的权重都被误配为 0或者注册中心元数据丢失导致所有权重解析失败负载均衡器会降级为均匀随机把流量打到本应被摘除的实例上。更稳妥的做法是引入健康检查维度。Spring Cloud LoadBalancer 支持通过spring.cloud.loadbalancer.health-check相关配置开启健康检查只有健康状态为 UP 的实例才会进入候选列表。这个健康检查是基于 Actuator 的/actuator/health端点实现的需要确保后端服务暴露了该端点。spring: cloud: loadbalancer: health-check: initial-delay: 10s interval: 15s path: default: /actuator/health健康检查和权重过滤是互补的健康检查负责摘除真正不可用的实例权重负责在可用实例之间分配流量比例。两者结合才能构成完整的流量治理方案。我在实际项目中遇到过权重配置正确但实例已经假死的情况如果没有健康检查兜底流量会持续打到假死实例上表现为间歇性的请求超时。5. 灰度发布场景下的标签路由扩展5.1 用元数据标签实现版本路由权重负载均衡解决的是分多少的问题而灰度发布还需要解决分给谁的问题。典型场景是新版本实例打上versionv2标签希望只有特定用户比如内部测试账号的请求才路由到 v2 实例其他用户继续走 v1。这个需求可以在自定义负载均衡器中通过请求上下文来实现。Gateway 的过滤器链中请求头、查询参数等信息都可以通过Request对象获取。我们可以在负载均衡器的choose方法中读取请求头中的版本标识然后筛选出匹配的实例private boolean matchesVersion(ServiceInstance instance, Request request) { String targetVersion extractVersionFromRequest(request); if (targetVersion null) { return true; // 没有版本要求所有实例都可选 } String instanceVersion instance.getMetadata().get(version); return targetVersion.equals(instanceVersion); }extractVersionFromRequest可以从请求头如X-Service-Version、查询参数或者用户身份信息中提取目标版本。具体从哪取取决于你的灰度规则设计常见的是结合用户 ID 做哈希后决定是否走新版本。5.2 标签路由与权重策略的组合使用实际生产中标签路由和权重策略往往是组合使用的。比如v2 实例只占 10% 的权重且只有灰度用户能访问v1 实例占 90% 权重所有用户都能访问。这种组合逻辑需要在负载均衡器中分层处理先按标签筛选出候选集再在候选集内按权重选择。Override public FluxServiceInstance choose(Request request) { return supplier.get(request) .next() .flatMapMany(instances - { // 第一层按版本标签筛选 ListServiceInstance versionFiltered instances.stream() .filter(i - matchesVersion(i, request)) .collect(Collectors.toList()); // 如果标签筛选后为空降级到全量实例 if (versionFiltered.isEmpty()) { versionFiltered instances; } // 第二层按权重选择 ListServiceInstance eligible versionFiltered.stream() .filter(this::isEligible) .collect(Collectors.toList()); if (eligible.isEmpty()) { return Flux.error(new IllegalStateException(无可用实例)); } return Flux.just(selectByWeight(eligible)); }); }这里有一个重要的降级逻辑当标签筛选后没有匹配实例时回退到全量实例。这个设计是为了避免因为标签配置错误导致服务完全不可用。灰度发布本身是高风险操作任何环节的容错都值得认真对待。5.3 灰度规则变更时的实例列表同步灰度规则比如哪些用户走新版本通常配置在配置中心或数据库中变更频率可能比较高。如果每次请求都去查询规则性能开销不可忽视。合理的做法是在 Gateway 本地缓存规则通过配置中心的推送机制更新缓存。规则缓存和实例列表缓存的更新时机需要协调。如果规则更新了但实例列表还是旧的可能出现规则指向 v2 但实例列表里没有 v2 实例的情况。我的经验是规则变更时同时触发实例列表刷新确保两者的一致性。具体实现可以复用前面提到的LoadBalancerCacheManager清除逻辑。另外灰度规则的计算逻辑如果比较复杂比如基于用户画像的多条件组合建议下沉到独立的规则引擎或服务中Gateway 侧只做简单的规则匹配。把复杂逻辑放在 Gateway 里会让过滤器链变得臃肿影响排查问题的效率。6. 压测中暴露的几个典型问题与修复6.1 权重计算中的整数溢出与精度问题在一次压测中我们发现当实例权重配置得比较大比如 10000且实例数量多50 个以上时totalWeight的累加出现了溢出导致选择结果异常。int类型的最大值约 21 亿50 个实例每个权重 10000 就是 50 万远不到溢出阈值。但如果有实例权重被误配为Integer.MAX_VALUE累加就会溢出。修复方式很简单用long类型做累加并在解析权重时做上限校验private static final int MAX_WEIGHT 100000; private int resolveWeight(ServiceInstance instance) { // ... 解析逻辑 return Math.min(weight, MAX_WEIGHT); } private ServiceInstance selectByWeight(ListServiceInstance candidates) { long totalWeight candidates.stream() .mapToLong(this::resolveWeight) .sum(); // ... 后续逻辑用 long 计算 }这个问题的教训是永远不要信任外部输入的数值范围即使它来自你自己的配置。配置错误在生产环境中并不罕见防御性代码能避免小错误演变成大故障。6.2 高并发下 Random 实例的线程安全问题java.util.Random本身是线程安全的但在高并发场景下多个线程竞争同一个Random实例的种子更新会导致性能下降。压测中我们观察到当 QPS 超过 5000 时Random.nextInt的调用成为了瓶颈之一。替换方案是使用ThreadLocalRandomint hit ThreadLocalRandom.current().nextInt(totalWeight);ThreadLocalRandom为每个线程维护独立的随机数生成器避免了竞争。这个改动在压测中将负载均衡环节的 P99 延迟从 3ms 降到了 0.5ms 以下。虽然绝对值不大但在整个请求链路中每个环节省一点累积起来就很可观了。6.3 实例列表为空时的快速失败与降级压测中模拟了注册中心不可用的场景此时ServiceInstanceListSupplier返回空列表负载均衡器抛出异常Gateway 返回 503。这个行为本身是合理的但异常信息不够明确排查时很难快速定位是注册中心问题还是服务本身没有实例。改进方式是区分不同的空列表原因给出更有针对性的错误信息if (instances.isEmpty()) { return Flux.error(new IllegalStateException( 服务 serviceId 的实例列表为空请检查注册中心连接状态)); }同时可以在 Gateway 层面配置一个降级路由当负载均衡失败时转发到静态的兜底服务。这属于更高层面的容错设计不在负载均衡器本身的职责范围内但两者配合才能构成完整的可用性保障。7. 版本升级中的兼容性处理经验Spring Cloud 的版本迭代比较快负载均衡相关的 API 在不同版本之间有变化。从 Hoxton 到 2020.x 再到 2021.xReactorLoadBalancer的接口签名和配置方式都有调整。如果你正在做版本升级有几个点需要特别注意。第一LoadBalancerClientFactory的包路径在 2020.0.0 版本从org.springframework.cloud.loadbalancer迁移到了org.springframework.cloud.client.loadbalancer导入语句需要相应修改。第二LoadBalancerClients注解的defaultConfiguration属性在较新版本中才支持老版本需要用LoadBalancerClient逐个指定。第三ServiceInstanceListSupplier的默认实现类名和 Bean 名称在不同版本中可能不同如果你通过名称注入升级时可能报找不到 Bean。我的建议是在自定义负载均衡器的实现中尽量依赖接口而不是具体实现类尽量通过类型注入而不是名称注入。这样在版本升级时需要改动的代码最少。另外升级前先在测试环境验证负载均衡策略是否仍然生效不要假设 API 兼容性。还有一个容易被忽略的点Spring Cloud Gateway 和 Spring Cloud LoadBalancer 的版本需要匹配。Gateway 的ReactiveLoadBalancerClientFilter依赖 LoadBalancer 的特定版本如果两者版本不匹配可能出现过滤器不执行或者执行顺序错乱的问题。升级时最好参考官方发布的版本兼容矩阵不要单独升级某一个组件。8. 几个只有踩过才知道的实操细节关于自定义负载均衡器的调试有一个非常实用的技巧在choose方法中打日志时不要用log.info打印每个请求的选择结果高 QPS 下日志量会爆炸。可以用条件日志或者采样日志比如只对特定用户或特定比例的请求打印。我通常会在排查阶段用log.debug配合动态日志级别调整问题定位后立即关掉。另一个细节是关于Request对象的使用。ReactorLoadBalancer.choose(Request request)中的Request是 Spring Cloud LoadBalancer 自己的抽象不是 Gateway 的ServerWebExchange。如果你需要从 Gateway 的请求上下文中获取信息比如请求头需要通过RequestContext或者自定义的Request实现来传递。这个转换关系在官方文档中讲得比较隐晦我第一次实现标签路由时在这里卡了半天。还有一点自定义负载均衡器的 Bean 名称不要和框架默认的冲突。默认的轮询负载均衡器 Bean 名称是roundRobinLoadBalancer随机的是randomLoadBalancer。如果你把自己的实现也命名为类似的名字可能出现 Bean 覆盖或者注入歧义。用一个有辨识度的名称比如weightedRandomLoadBalancer能避免很多莫名其妙的问题。最后分享一个排查思路当你不确定自定义负载均衡器是否生效时可以在choose方法的第一行打一个带唯一标识的日志然后发一个请求看日志是否输出。如果没输出说明配置类没有被加载检查包路径和注解配置如果输出了但选择结果不对检查权重解析和筛选逻辑。这个二分法能快速缩小问题范围比盲目看代码高效得多。