50天独立打造企业级API网关(六):高级路由与负载均衡实战

21 阅读24分钟

系列文章第6篇 | 50天手搓Spring Cloud Gateway:44项功能+561测试用例的完整实践


系列导航

  • 第一篇:控制平面/数据平面架构设计与动态路由实现
  • 第二篇:安全防护体系与性能优化
  • 第三篇:弹性设计与限流降级
  • 第四篇:全链路可观测性与AI Copilot智能运维
  • 第五篇:Kubernetes部署与测试保障
  • 第六篇:高级路由与负载均衡实战 ← 本篇

前言

在前5篇文章中,我们介绍了网关的架构设计、安全防护、弹性设计、可观测性和Kubernetes部署。但一个企业级API网关的路由能力远不止这些。

在实际的生产环境中,你会遇到这些场景:

  1. 灰度发布:新版本上线,需要10%流量先验证,没问题再逐步放量
  2. 多版本共存:user-service有v1、v2两个版本,需要按Header/cookie分配流量
  3. 遗留系统集成:有些后端服务没有注册到Nacos,只有固定IP:Port
  4. 节点动态调整:在Nacos控制台修改节点上下线、权重,网关需要实时生效
  5. 健康检查:如何判断节点是否健康?不健康节点如何处理?

这些需求,我们的网关全部支持

本文将深度解析以下核心功能:

  • 灰度发布系统设计(4种灰度规则 + 百分比流量分配)
  • 两层负载均衡(服务级 + 节点级)
  • lb:// vs static:// 双协议设计
  • Nacos控制台节点管理(上下线、权重、健康状态)
  • 混合健康检查机制(主动 + 被动 + 即时)
  • 节点故障降级策略(可用性优先设计哲学)
  • 4种负载均衡算法(平滑加权轮询、随机、一致性Hash、简单轮询)

一、灰度发布系统设计

1.1 为什么需要灰度发布?

在生产环境发布新版本服务时,直接全量上线风险极大:

全量发布(高风险):
  user-v2 直接替换 user-v1
  → 如果有Bug,100%用户受影响
  → 回滚需要时间,损失已经造成

灰度发布(低风险):
  user-v1 (90%) + user-v2 (10%)
  → 只有10%用户受影响
  → 发现问题立即回滚,损失可控

1.2 灰度规则配置

灰度规则存储在路由的 metadata 中,通过 JSON 配置:

{
  "routeId": "user-service-route",
  "uri": "lb://user-v1",
  "metadata": {
    "multiServiceConfig": {
      "mode": "MULTI",
      "services": [
        {"serviceId": "user-v1", "version": "v1", "weight": 90, "enabled": true},
        {"serviceId": "user-v2", "version": "v2", "weight": 10, "enabled": true}
      ],
      "grayRules": {
        "enabled": true,
        "rules": [
          {
            "type": "HEADER",
            "name": "X-Version",
            "value": "v2",
            "targetVersion": "v2"
          },
          {
            "type": "COOKIE",
            "name": "version",
            "value": "beta",
            "targetVersion": "v2"
          },
          {
            "type": "QUERY",
            "name": "preview",
            "value": "true",
            "targetVersion": "v2"
          },
          {
            "type": "WEIGHT",
            "value": "10",
            "targetVersion": "v2"
          }
        ]
      }
    }
  }
}

1.3 4种灰度规则类型

规则类型匹配方式使用场景
HEADER匹配 HTTP Header 值内部测试:设置 X-Version: v2 路由到新版本
COOKIE匹配 Cookie 值Beta用户:设置 version=beta 体验新版本
QUERY匹配 URL Query 参数临时验证:访问 ?preview=true 查看新版本
WEIGHT百分比流量分配灰度放量:10%流量自动路由到新版本

1.4 灰度规则匹配逻辑

private String matchGrayRules(ServerWebExchange exchange, MultiServiceConfig config) {
    GrayRuleConfig grayRules = config.getGrayRules();
    if (grayRules == null || !grayRules.isEnabled()) {
        return null;
    }

    // 按配置顺序匹配,First-Match-Wins
    for (GrayRule rule : grayRules.getRules()) {
        String matchedVersion = matchSingleRule(exchange, rule, config);
        if (matchedVersion != null) {
            return matchedVersion;  // 匹配成功,立即返回目标版本
        }
    }
    return null;  // 所有规则都未匹配,走权重分配
}

匹配流程:

请求到达
    │
    ▼
按顺序检查灰度规则
    │
    ├─ HEADER: X-Version=v2? ──是──> 路由到 v2
    │
    ├─ COOKIE: version=beta? ──是──> 路由到 v2
    │
    ├─ QUERY: preview=true? ──是──> 路由到 v2
    │
    └─ WEIGHT: 10%概率? ──是──> 路由到 v2
    │
    ▼
都未匹配 → 走权重分配(平滑加权轮询)

1.5 百分比流量分配实现

private boolean shouldRouteByPercentage(int percentage) {
    return ThreadLocalRandom.current().nextInt(100) < percentage;
}

原理:

  • 使用 ThreadLocalRandom 生成 0-99 的随机数
  • 如果随机数 < 百分比值(如10),则路由到目标版本
  • 每次请求独立判断,保证长期流量分布接近设定比例

效果:

1000个请求:
  ~100个  v2 (WEIGHT规则匹配)
  ~900个  走权重分配

长期统计:
  v1: 90% ± 2%
  v2: 10% ± 2%

1.6 灰度规则实战示例

场景1:内部测试新版本

# 设置Header,强制路由到v2
curl -H "X-Version: v2" http://gateway/api/user/123

场景2:Beta用户体验

# 设置Cookie,Beta用户访问新版本
curl -b "version=beta" http://gateway/api/user/123

场景3:临时验证

# URL参数,快速验证新功能
curl "http://gateway/api/user/123?preview=true"

场景4:自动灰度放量

// 配置10%流量自动走v2,无需任何标记
{"type": "WEIGHT", "value": "10", "targetVersion": "v2"}

二、两层负载均衡架构

2.1 为什么需要两层负载均衡?

大多数网关教程只讲一层负载均衡(服务实例级别),但我们的网关实现了两层负载均衡

第一层:服务级负载均衡
  └─ 从多个服务版本中选择(user-v1, user-v2)
  └─ 基于灰度规则 + 权重分配

第二层:节点级负载均衡
  └─ 从服务实例中选择具体节点(192.168.1.10:8080, 192.168.1.11:8080)
  └─ 基于4种算法(加权轮询、随机、一致性Hash、简单轮询)

2.2 完整请求链路

graph TB
    A[请求到达]
    B[MultiServiceLoadBalancerFilter]
    C{灰度规则匹配?}
    D[选择服务版本]
    E[设置 lb:// 或 static://]
    F[NacosDiscoveryLoadBalancerFilter]
    G[DiscoveryLoadBalancerFilter]
    H[SCG ReactiveLoadBalancer]
    I[后端节点]
    
    A --> B
    B --> C
    C -->|是| D
    C -->|否| D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I
    
    style B fill:#e1f5ff
    style C fill:#fff4e1
    style D fill:#e1ffe1
    style F fill:#f0e1ff
    style G fill:#ffe1e1

2.2.1 Filter 执行顺序的源码级设计

这是理解 Spring Cloud Gateway 源码的关键!

很多读者会问:为什么 lb://static:// 能互不影响?它们都用了 lb:// 协议头,不会冲突吗?

答案就在 Filter Order 的精心设计SCG 的 Filter 链执行机制 中。

Spring Cloud Gateway 原生 Filter 执行顺序:

RouteToRequestUrlFilter (Order = 10000)
  └─ 作用:拼接最终请求URL(路径+Query)
  └─ 设置 GATEWAY_REQUEST_URL_ATTR
  
ReactiveLoadBalancerClientFilter (Order = 10150)
  └─ 作用:SCG 原生的负载均衡过滤器
  └─ 只处理 lb:// 协议的URI
  └─ 调用 Spring Cloud LoadBalancer 选择实例

我们自定义的 Filter 如何嵌入?

RouteToRequestUrlFilter (10000)
    ↓
MultiServiceLoadBalancerFilter (10001) ← 我们的灰度+服务选择器
    ├─ 读取路由metadata中的multiServiceConfig
    ├─ 执行灰度规则匹配(Header/Cookie/Query/Weight)
    ├─ 选择目标服务版本(user-v1 vs user-v2)
    ├─ 设置 TARGET_SERVICE_ID_ATTR 和 SERVICE_BINDING_TYPE_ATTR
    └─ 转换URI:lb://serviceId 或 static://serviceIdNacosDiscoveryLoadBalancerFilter (10100) ← 我们的Nacos namespace/group覆盖
    ├─ 只对 lb:// 协议生效
    ├─ 读取 SERVICE_NAMESPACE_ATTR 和 SERVICE_GROUP_ATTR
    ├─ 覆盖默认的Nacos Namespace和Group
    └─ 不修改URI,仅设置上下文信息
    ↓
ReactiveLoadBalancerClientFilter (10150) ← SCG 原生负载均衡过滤器
    ├─ 检查URI协议是否为 lb://
    ├─ 如果是 lb:// → 调用 LoadBalancer 选择实例
    └─ 如果是 static:// → 跳过,不处理DiscoveryLoadBalancerFilter (10150) ← 我们的静态节点负载均衡器
    ├─ 只对 static:// 协议生效
    ├─ 读取静态服务配置(IP:Port列表)
    ├─ 执行健康检查过滤(排除不健康节点)
    ├─ 执行负载均衡(4种算法选择节点)
    └─ 转换 static:// 为 http://ip:portNettyRoutingFilter (最终发送请求)

关键设计原则:

FilterOrder处理协议跳过条件
MultiServiceLoadBalancerFilter10001全部无multiServiceConfig
NacosDiscoveryLoadBalancerFilter10100lb://无namespace/group覆盖
ReactiveLoadBalancerClientFilter10150lb://URI不是lb://
DiscoveryLoadBalancerFilter10150static://URI不是static://

为什么不会冲突?

// ReactiveLoadBalancerClientFilter (SCG原生)
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    URI requestUrl = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
    
    // 关键判断:只处理 lb:// 协议
    if (scheme.equals("lb")) {
        return loadBalancer.choose(serviceId)
            .flatMap(instance -> {
                // 转换 lb://serviceId → http://ip:port
                URI uri = UriComponentsBuilder.fromUri(requestUrl)
                    .scheme("http")
                    .host(instance.getHost())
                    .port(instance.getPort())
                    .build()
                    .toUri();
                exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri);
                return chain.filter(exchange);
            });
    }
    
    // static:// 协议直接跳过,交给 DiscoveryLoadBalancerFilter 处理
    return chain.filter(exchange);
}

// DiscoveryLoadBalancerFilter (我们的静态节点负载均衡)
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    URI requestUrl = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
    
    // 关键判断:只处理 static:// 协议
    if (!scheme.equals("static")) {
        return chain.filter(exchange);  // lb:// 直接跳过
    }
    
    // 执行静态节点的负载均衡
    List<ServiceInstance> instances = staticDiscoveryService.getInstances(serviceId);
    List<ServiceInstance> filtered = instanceFilter.filter(instances);  // 健康检查过滤
    ServiceInstance selected = instanceSelector.select(filtered, strategy, exchange);
    
    // 转换 static://serviceId → http://ip:port
    URI uri = UriComponentsBuilder.fromUri(requestUrl)
        .scheme("http")
        .host(selected.getHost())
        .port(selected.getPort())
        .build()
        .toUri();
    exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, uri);
    return chain.filter(exchange);
}

设计精髓:

  1. 协议隔离:通过 scheme.equals("lb")scheme.equals("static") 精确判断
  2. Order 协同:10001 → 10100 → 10150,层层递进,各司其职
  3. 属性传递TARGET_SERVICE_ID_ATTRSERVICE_BINDING_TYPE_ATTR 在Filter间传递上下文
  4. 零侵入:不修改SCG原生Filter,仅通过Order优先级嵌入

这就是对 SCG 源码级的深度理解!

2.3 第一层:服务级负载均衡

核心代码 (MultiServiceLoadBalancerFilter.java):

public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
    MultiServiceConfig config = extractMultiServiceConfig(route);
    
    if (config.getMode() == RoutingMode.SINGLE) {
        // 单服务模式:直接路由
        return handleSingleServiceMode(exchange, chain, config);
    } else {
        // 多服务模式:灰度 + 权重
        return handleMultiServiceMode(exchange, chain, config);
    }
}

private Mono<Void> handleMultiServiceMode(...) {
    // Step 1: 检查灰度规则
    String targetVersion = matchGrayRules(exchange, config);
    
    // Step 2: 未匹配灰度规则 → 使用权重分配
    if (targetVersion == null) {
        targetVersion = selectByWeight(routeId, config);
    }
    
    // Step 3: 获取服务绑定信息
    ServiceBinding selectedBinding = getServiceBindingByVersion(config, targetVersion);
    
    // Step 4: 设置目标服务ID和类型
    exchange.getAttributes().put(TARGET_SERVICE_ID_ATTR, selectedBinding.getServiceId());
    exchange.getAttributes().put(SERVICE_BINDING_TYPE_ATTR, selectedBinding.getType());
    
    // Step 5: 转换URI(lb:// 或 static://)
    transformUriForServiceType(exchange, selectedBinding.getServiceId(), selectedBinding.getType());
    
    return chain.filter(exchange);
}

2.4 第二层:节点级负载均衡

第一层选定服务版本后,第二层负责从该版本的具体实例中选择一个节点:

选定了 user-v1 (lb://user-v1)
    ↓
Nacos返回3个实例:
  - 192.168.1.10:8080 (weight=3)
  - 192.168.1.11:8080 (weight=2)
  - 192.168.1.12:8080 (weight=1)
    ↓
InstanceSelector 选择具体节点
    ↓
192.168.1.10:8080 (加权轮询选中)

关键设计:两层负载均衡互不影响

  • 第一层:选择服务版本(user-v1 vs user-v2)
  • 第二层:选择具体节点(192.168.1.10 vs 192.168.1.11)
  • 两层的配置独立,可以灵活组合

三、负载均衡算法深度解析

3.1 4种负载均衡算法

算法策略名适用场景实现位置
平滑加权轮询weighted (默认)节点性能不均匀,需要按权重分配selectByWeightedRoundRobin()
简单轮询round-robin节点性能均匀,轮流分配selectByRoundRobin()
加权随机random节点性能不均匀,随机选择selectByRandom()
一致性Hashconsistent-hash需要会话保持,相同Hash到相同节点selectByConsistentHash()

3.2 平滑加权轮询算法(Nginx风格)

这是默认算法,也是最有技术含量的一个。

核心思想:

每个实例维护一个 currentWeight(当前权重)

每次选择时:
  1. currentWeight += 配置权重
  2. 选择 currentWeight 最大的实例
  3. 选中实例的 currentWeight -= 总权重

代码实现 (InstanceSelector.java):

private ServiceInstance selectByWeightedRoundRobin(List<ServiceInstance> instances) {
    // 1. 初始化权重
    for (ServiceInstance instance : instances) {
        String key = instance.getHost() + ":" + instance.getPort();
        currentWeights.putIfAbsent(key, 0.0);
    }
    
    // 2. 累加当前权重
    double totalWeight = 0;
    for (ServiceInstance instance : instances) {
        String key = instance.getHost() + ":" + instance.getPort();
        double weight = getWeight(instance);
        currentWeights.put(key, currentWeights.get(key) + weight);
        totalWeight += weight;
    }
    
    // 3. 选择最大当前权重的实例
    ServiceInstance selected = null;
    double maxWeight = -1;
    for (ServiceInstance instance : instances) {
        String key = instance.getHost() + ":" + instance.getPort();
        double currentWeight = currentWeights.get(key);
        if (currentWeight > maxWeight) {
            maxWeight = currentWeight;
            selected = instance;
        }
    }
    
    // 4. 减去总权重
    if (selected != null) {
        String key = selected.getHost() + ":" + selected.getPort();
        currentWeights.put(key, currentWeights.get(key) - totalWeight);
    }
    return selected;
}

实际效果演示:

假设有3个节点,权重分别为 3:2:1

请求序号累加后当前权重选中节点减去总权重后
1[3, 2, 1]节点A (3)[-3, 2, 1]
2[0, 4, 2]节点B (4)[0, -2, 2]
3[3, 0, 3]节点A (3)[-3, 0, 3]
4[0, 2, 4]节点C (4)[0, 2, -2]
5[3, 4, -1]节点B (4)[3, -2, -1]
6[6, 0, 0]节点A (6)[0, 0, 0]

6次请求分布:

  • 节点A:3次 (50%)
  • 节点B:2次 (33.3%)
  • 节点C:1次 (16.7%)

完美符合 3:2:1 的权重比例!

优势:

  • 避免"批量倾斜":不会出现连续多次都选同一节点
  • 平滑分配:流量均匀分散
  • 支持动态权重:权重调整后自动适应

3.3 一致性Hash算法

适用场景: 需要会话保持,相同用户始终路由到相同节点。

private ServiceInstance selectByConsistentHash(List<ServiceInstance> instances, ServerWebExchange exchange) {
    // 1. 获取Hash Key(优先X-Hash-Key Header,否则用IP)
    String hashKey = getHashKey(exchange);
    
    // 2. 构建一致性Hash环
    String serviceId = instances.get(0).getServiceId();
    ConsistentHashRing hashRing = hashRingCache.computeIfAbsent(serviceId, k -> buildHashRing(instances));
    
    // 3. 从Hash环上找到节点
    return hashRing.getNode(hashKey);
}

private String getHashKey(ServerWebExchange exchange) {
    // 优先使用自定义Header
    String hashKey = exchange.getRequest().getHeaders().getFirst("X-Hash-Key");
    if (hashKey != null) return hashKey;
    
    // 其次使用客户端真实IP
    String forwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
    if (forwardedFor != null) return forwardedFor.split(",")[0].trim();
    
    // 最后使用直连IP
    return exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
}

Hash环构建:

private ConsistentHashRing buildHashRing(List<ServiceInstance> instances) {
    ConsistentHashRing ring = new ConsistentHashRing();
    int virtualNodes = 150;  // 每个真实节点对应150个虚拟节点
    
    for (ServiceInstance instance : instances) {
        // 权重越高,虚拟节点越多
        int nodes = virtualNodes * (int) getWeight(instance);
        for (int i = 0; i < nodes; i++) {
            ring.addNode(instance.getHost() + ":" + instance.getPort() + "#" + i, instance);
        }
    }
    return ring;
}

原理:

Hash环(0 ~ 2^64-1):

  0 ──────────────────────────────────────────────── 2^64-1
      │         │      │    │         │       │
    A#1       B#1    A#2  C#1       B#2     A#3

请求Hash值:
  - 用户A (IP: 1.2.3.4) → Hash = 12345 → 顺时针找到 A#1
  - 用户B (IP: 5.6.7.8) → Hash = 67890 → 顺时针找到 B#1

优势: 
  - 相同用户始终到相同节点(会话保持)
  - 节点增减时,影响范围最小化

3.4 算法选择建议

场景推荐算法配置值
节点性能均匀,无需会话保持平滑加权轮询weighted
节点性能均匀,简单轮询即可简单轮询round-robin
节点性能不均匀加权随机/加权轮询random / weighted
需要会话保持(如WebSocket)一致性Hashconsistent-hash

四、lb:// vs static:// 双协议设计

4.1 为什么需要 static:// 协议?

很多教程只讲 lb://(服务发现协议),但实际生产环境中,你会遇到:

  1. 遗留系统:有些老服务没有注册到Nacos,只有固定IP:Port
  2. 外部API:调用第三方API,IP固定,不需要服务发现
  3. 混合架构:部分服务上云,部分还在物理机

如果只支持 lb://,这些场景就无法处理。所以我们需要自定义 static:// 协议。

4.2 两种协议对比

特性lb:// (DISCOVERY)static:// (STATIC)
URI示例lb://user-servicestatic://legacy-backend
服务发现Nacos/Consul 注册中心动态发现静态配置(JSON中定义IP:Port列表)
负载均衡SCG 原生 ReactiveLoadBalancer自定义 DiscoveryLoadBalancerFilter
健康检查委托给Nacos,网关信任Nacos的健康状态网关自主执行(混合健康检查)
节点上下线Nacos控制台操作Nacos配置中心更新服务JSON
使用场景云原生微服务遗留系统、外部API、固定IP后端

4.3 协议推断逻辑

private ServiceBindingType inferServiceTypeFromUri(URI uri) {
    if (uri == null) {
        return ServiceBindingType.STATIC;  // 默认为STATIC
    }
    String scheme = uri.getScheme();
    if ("lb".equalsIgnoreCase(scheme)) {
        return ServiceBindingType.DISCOVERY;
    }
    return ServiceBindingType.STATIC;  // 其他情况都是STATIC
}

规则:

  • lb:// → DISCOVERY(服务发现模式)
  • static:// → STATIC(静态配置模式)
  • 无协议头 → 默认 STATIC

4.4 static:// 配置示例

{
  "name": "legacy-backend",
  "type": "STATIC",
  "instances": [
    {"ip": "192.168.1.100", "port": 8080, "weight": 1, "enabled": true},
    {"ip": "192.168.1.101", "port": 8080, "weight": 2, "enabled": true},
    {"ip": "192.168.1.102", "port": 8080, "weight": 1, "enabled": false}
  ],
  "loadBalancer": "weighted"
}

4.5 两种协议如何互不影响?

Filter执行链:

RouteToRequestUrlFilter (MIN_VALUE)
    ↓
MultiServiceLoadBalancerFilter (10001)
  ├─ 选择服务版本
  ├─ 设置 lb:// 或 static://
  └─ 设置 SERVICE_BINDING_TYPE_ATTR
    ↓
NacosDiscoveryLoadBalancerFilter (10100)
  └─ 处理 lb:// 带 namespace/group 覆盖的情况
    ↓
SCG ReactiveLoadBalancer (10150)
  └─ 处理原生 lb://(Nacos服务发现)
    ↓
DiscoveryLoadBalancerFilter (10150)
  └─ 处理 static://(静态配置 + 健康检查 + 负载均衡)

关键设计:

  • MultiServiceLoadBalancerFilter 设置 SERVICE_BINDING_TYPE_ATTR 标记
  • SCG ReactiveLoadBalancer 只处理 lb://,不处理 static://
  • DiscoveryLoadBalancerFilter 只处理 static://,不处理 lb://
  • 两者通过 Filter Order 和类型标记隔离,互不干扰

4.6 lb:// 服务配置示例

{
  "routeId": "user-service-route",
  "uri": "lb://user-service",
  "metadata": {
    "multiServiceConfig": {
      "mode": "SINGLE",
      "serviceId": "user-service",
      "serviceType": "DISCOVERY",
      "serviceNamespace": "production",  // 可选:覆盖默认Namespace
      "serviceGroup": "DEFAULT_GROUP"    // 可选:覆盖默认Group
    }
  }
}

五、Nacos控制台节点管理

5.1 节点上下线机制

场景1:lb:// 服务(Nacos注册中心)

在Nacos控制台操作 → Gateway 实时感知:

Nacos操作Gateway行为生效时间
实例下线(enabled=false)强制排除,不再路由到该节点实时
实例上线(enabled=true)加入负载均衡列表实时
修改权重(0-100)更新加权轮询参数实时
健康检查失败Nacos自动排除,网关只收到健康实例列表实时

核心代码 (NacosDiscoveryLoadBalancerFilter.java):

// 过滤掉用户在Nacos下线的实例
List<ServiceInstance> enabledInstances = instances.stream()
    .filter(ServiceInstance::isEnabled)  // enabled=false的强制排除
    .collect(Collectors.toList());

if (enabledInstances.isEmpty()) {
    return Mono.empty();  // 503 Service Unavailable
}

场景2:static:// 服务(静态配置)

通过Nacos配置中心更新 → ServiceRefresher 监听变更:

@Service
public class ServiceRefresher {
    
    @NacosConfigListener(dataId = "services-index")
    public void onIndexChange(String content) {
        // 解析服务索引变更
    }
    
    @NacosConfigListener(dataId = "{serviceId}")
    public void onSingleServiceChange(String serviceId, String content) {
        if (content == null || content.isBlank()) {
            // 服务下线
            serviceManager.clearServiceCache(serviceId);
            hybridHealthChecker.clearServiceInstances(serviceId);
        } else {
            // 服务创建或更新
            serviceManager.parseAndCacheService(serviceId, serviceNode);
            triggerHealthCheckForService(serviceId);  // 触发健康检查
        }
    }
}

5.2 权重调整

lb:// 服务:

  • 在Nacos控制台直接设置实例权重(0-100)
  • Gateway 通过 instance.getWeight() 读取
  • 用于加权轮询负载均衡

static:// 服务:

  • 在Nacos配置中心修改服务JSON
  • 修改 instances 数组中的 weight 字段
  • ServiceRefresher 监听变更,实时更新

效果:

调整前:
  节点A: weight=125%流量
  节点B: weight=125%流量
  节点C: weight=125%流量
  节点D: weight=125%流量

节点B配置升级为高配机器,调整权重:
  节点A: weight=116.7%流量
  节点B: weight=233.3%流量  ← 流量翻倍
  节点C: weight=116.7%流量
  节点D: weight=116.7%流量
  节点E: weight=116.7%流量(新增)

5.3 节点禁用实战

场景: 节点B需要维护,在Nacos控制台设置为 enabled=false

Nacos控制台操作:
  实例列表:
    - 192.168.1.10:8080  enabled=true   ✓
    - 192.168.1.11:8080  enabled=false  ✗ ← 维护中
    - 192.168.1.12:8080  enabled=true   ✓

Gateway行为:
  1. NacosDiscoveryLoadBalancerFilter 强制排除 192.168.1.11
  2. 负载均衡只在 192.168.1.10 和 192.168.1.12 之间分配
  3. 维护完成后,在Nacos控制台恢复 enabled=true
  4. Gateway 自动将该节点加入负载均衡列表

无需重启网关,配置实时生效!


六、混合健康检查机制

6.1 设计哲学

为什么只检查 static:// 节点?

服务类型健康检查责任方原因
lb:// (DISCOVERY)NacosNacos已经做了完善的主动健康检查,网关无需重复
static:// (STATIC)Gateway静态配置节点没有注册中心管理,需要网关自主检查

Gateway信任Nacos的健康状态:

Nacos健康检查 → 只返回健康实例给Gateway → Gateway直接使用

Gateway自主检查静态节点:

静态节点配置 → Gateway主动发起TCP/HTTP检查 → 记录健康状态 → 路由时过滤

6.2 三种检查方式

检查类型触发时机检查内容性能开销
被动检查 (PASSIVE)每次业务请求请求成功 → recordSuccess() → 标记健康无额外开销
主动检查 (ACTIVE)定时调度TCP端口探测 + 可选HTTP /actuator/health定期开销
即时检查 (IMMEDIATE)路由选择前新节点/无记录节点立即探测按需开销

6.3 主动检查逻辑

public void probe(String serviceId, String ip, int port) {
    // Step 1: TCP端口检查(基本连通性)
    boolean tcpReachable = checkPortOpen(ip, port, timeoutMs);
    if (!tcpReachable) {
        handleUnhealthy(..., "TCP_UNREACHABLE");
        return;
    }
    
    // Step 2: 可选HTTP健康检查(应用级健康)
    if (httpCheckEnabled) {
        boolean httpHealthy = checkHttpHealth(ip, port);
        if (!httpHealthy) {
            handleUnhealthy(..., "HTTP_UNHEALTHY");
            return;
        }
    }
    
    // 全部通过 → 标记健康
    hybridHealthChecker.markHealthy(serviceId, ip, port, "ACTIVE");
}

6.4 三级检查频率

模式间隔适用节点配置项
Regular (常规)30秒健康节点、新不健康节点gateway.health.regular-check-interval
Stable (稳定)2分钟连续健康检查≥10次的节点gateway.health.stable-check-interval
Degraded (降级)3分钟连续不健康检查≥5次的节点gateway.health.degraded-check-interval

设计亮点:

  • 健康节点降低检查频率(从30s → 2min),减少无效探测
  • 不健康节点也降低频率(避免频繁报错日志)
  • 网络抖动检测:≥10个节点同时变化 → 判定为网络抖动 → 跳过上报

6.5 健康状态数据结构

public class InstanceHealth {
    // 基本信息
    private String serviceId;
    private String ip;
    private int port;
    
    // 健康状态
    private volatile boolean healthy;
    private int consecutiveFailures;           // 连续失败次数
    private String checkType;                  // PASSIVE/ACTIVE/INIT
    private String unhealthyReason;            // TCP_UNREACHABLE/HTTP_UNHEALTHY
    
    // 降级模式
    private int totalUnhealthyChecks;          // 连续不健康检查次数
    private boolean degradedCheckMode;         // 是否进入降级检查模式
    private Long degradedModeEnteredTime;      // 降级模式进入时间
    
    // 稳定模式
    private int totalHealthyChecks;            // 连续健康检查次数
    private boolean stableCheckMode;           // 是否进入稳定检查模式
    
    // 时间戳
    private long lastCheckTime;
    private long lastRequestTime;
}

Key设计:

// Key = ip:port(跨服务共享)
// 同一IP:Port在不同服务中健康状态一致
String key = ip + ":" + port;

6.6 被动检查机制

请求成功时:

public void recordSuccess(String serviceId, String ip, int port) {
    InstanceHealth health = healthCache.getIfPresent(key);
    
    boolean wasHealthy = health.isHealthy();
    
    // 重置失败计数,标记健康
    health.setConsecutiveFailures(0);
    health.setHealthy(true);
    health.setUnhealthyReason(null);
    health.setCheckType("PASSIVE");
    
    // 如果状态改变(不健康→健康),立即推送给Admin
    if (!wasHealthy) {
        log.info("Instance {}:{} recovered to HEALTHY via successful request", ip, port);
        queueForBatchPush(serviceId, ip, port, true);
    }
}

请求失败时:

public void recordFailure(String serviceId, String ip, int port, String reason) {
    InstanceHealth health = healthCache.getIfPresent(key);
    
    // 累计失败次数
    health.setConsecutiveFailures(health.getConsecutiveFailures() + 1);
    
    // 超过阈值 → 标记不健康
    if (health.getConsecutiveFailures() >= failureThreshold) {
        health.setHealthy(false);
        health.setUnhealthyReason(reason);
    }
    
    // 检测网络抖动
    int stateChangeCount = countRecentStateChanges();
    if (stateChangeCount >= networkFlapThreshold) {
        log.warn("Network flap detected: {} instances changed state", stateChangeCount);
        skipStatePush();  // 跳过上报,避免误判
    }
}

七、节点故障降级策略

7.1 降级优先级设计

设计哲学:可用性优先

所有实例
    │
    ▼
排除 enabled=false 的节点(用户明确选择,强制排除)
    │
    ▼
分类:healthy / pending(无记录) / unhealthy
    │
    ▼
pending节点处理:
  - 如果没有健康节点 → 立即执行健康检查 → 通过则加入healthy
  - 如果有健康节点 → pending暂不处理,后台检查
    │
    ▼
返回策略:
  if (!healthy.isEmpty()) return healthy;    // 优先返回健康节点
  return unhealthy;                           // 没有健康节点时,返回不健康节点尝试

7.2 核心过滤代码

public List<ServiceInstance> filter(List<ServiceInstance> instances) {
    // Step 1: 排除禁用实例(用户显式选择)
    List<ServiceInstance> enabled = instances.stream()
        .filter(this::isEnabled)
        .collect(Collectors.toList());
    
    if (enabled.isEmpty()) {
        return enabled;  // 503
    }
    
    // Step 2: 按健康状态分类
    List<ServiceInstance> healthy = new ArrayList<>();
    List<ServiceInstance> pendingCheck = new ArrayList<>();
    List<ServiceInstance> unhealthy = new ArrayList<>();
    
    for (ServiceInstance inst : enabled) {
        InstanceHealth health = healthChecker.getHealth(serviceId, inst.getHost(), inst.getPort());
        
        if (health == null || "INIT".equals(health.getCheckType())) {
            pendingCheck.add(inst);  // 无健康记录
        } else if (health.isHealthy()) {
            healthy.add(inst);
        } else {
            unhealthy.add(inst);
        }
    }
    
    // Step 3: 处理pending节点
    if (!pendingCheck.isEmpty() && healthy.isEmpty()) {
        // 没有健康节点,必须立即检查pending节点
        for (ServiceInstance inst : pendingCheck) {
            activeHealthChecker.probe(serviceId, inst.getHost(), inst.getPort());
            // 根据检查结果重新分类...
        }
    }
    
    // Step 4: 返回策略
    if (!healthy.isEmpty()) {
        return healthy;  // 优先返回健康节点
    }
    
    // 没有健康节点 → 返回不健康节点(可用性优先)
    log.warn("No healthy instances for {}. Returning unhealthy for LB to choose", serviceId);
    return unhealthy;
}

7.3 为什么返回不健康节点?

这是一个反直觉但正确的设计决策:

场景:

服务只有一个节点:192.168.1.10:8080
健康检查标记为不健康(可能是网络抖动、GC停顿等)

如果直接返回503:
  → 100%请求失败
  → 但实际上节点可能已经恢复

如果返回不健康节点让LB尝试:
  → 可能成功(节点已恢复)
  → 可能失败(LB重试机制会找替代节点)
  → 最差情况:返回真实错误(比直接503更有价值)

核心考量:

  • 健康检查的状态更新有延迟(30秒检查间隔)
  • 节点此时不健康,但可能已经恢复
  • "宁可错杀,不可漏过":尝试比直接拒绝更有价值

7.4 重试降级策略

当请求失败时,InstanceRetryExecutor 执行降级:

public Mono<Void> executeWithRetry(ServerWebExchange exchange, List<ServiceInstance> instances) {
    Set<String> triedInstances = new HashSet<>();
    
    // 第一次尝试
    ServiceInstance selected = instanceSelector.select(instances, strategy, exchange);
    triedInstances.add(selected.getHost() + ":" + selected.getPort());
    
    try {
        return routeToInstance(exchange, selected);
    } catch (Exception e) {
        // 记录失败
        healthChecker.recordFailure(serviceId, selected.getHost(), selected.getPort(), e.getMessage());
        
        // 寻找替代节点
        ServiceInstance alternative = instanceFilter.findAlternative(serviceId, instances, triedInstances);
        
        if (alternative == null) {
            // 无替代节点 → 返回真实503
            return Mono.error(new GatewayException("No available instance", 503));
        }
        
        // 重试替代节点
        return routeToInstance(exchange, alternative);
    }
}

替代节点查找优先级:

1. 健康节点(优先)
2. pending节点(即时检查后尝试)
3. 不健康节点(最后手段,可能已恢复)

八、完整实战演示

8.1 灰度发布全流程

场景: user-service 从 v1 升级到 v2

Step 1:部署v2版本

# Nacos注册user-v2
curl -X POST http://nacos:8848/nacos/v1/ns/instance \
  -d "serviceName=user-v2&ip=192.168.2.10&port=8080"

Step 2:配置灰度路由

{
  "routeId": "user-service-route",
  "uri": "lb://user-v1",
  "metadata": {
    "multiServiceConfig": {
      "mode": "MULTI",
      "services": [
        {"serviceId": "user-v1", "version": "v1", "weight": 90, "enabled": true},
        {"serviceId": "user-v2", "version": "v2", "weight": 10, "enabled": true}
      ],
      "grayRules": {
        "enabled": true,
        "rules": [
          {"type": "HEADER", "name": "X-Version", "value": "v2", "targetVersion": "v2"},
          {"type": "WEIGHT", "value": "10", "targetVersion": "v2"}
        ]
      }
    }
  }
}

Step 3:验证灰度

# 内部测试(强制走v2)
curl -H "X-Version: v2" http://gateway/api/user/123

# 普通用户(10%概率走v2)
for i in {1..100}; do
  curl http://gateway/api/user/123
done

# 查看日志,v1:v2 ≈ 90:10

Step 4:逐步放量

// 第一阶段:10%灰度
{"type": "WEIGHT", "value": "10", "targetVersion": "v2"}

// 第二阶段:50%灰度(验证通过)
{"type": "WEIGHT", "value": "50", "targetVersion": "v2"}

// 第三阶段:100%全量(v2稳定)
{"type": "WEIGHT", "value": "100", "targetVersion": "v2"}

Step 5:完成升级

// 切换默认版本
{
  "services": [
    {"serviceId": "user-v2", "version": "v2", "weight": 100, "enabled": true}
  ],
  "grayRules": {"enabled": false}
}

8.2 静态节点健康检查实战

场景: 遗留系统 legacy-backend 有3个静态节点

配置:

{
  "name": "legacy-backend",
  "type": "STATIC",
  "instances": [
    {"ip": "192.168.1.100", "port": 8080, "weight": 1, "enabled": true},
    {"ip": "192.168.1.101", "port": 8080, "weight": 1, "enabled": true},
    {"ip": "192.168.1.102", "port": 8080, "weight": 1, "enabled": true}
  ],
  "loadBalancer": "weighted"
}

健康检查过程:

Gateway启动
    ↓
InstanceDiscoveryService发现需要检查的节点
    ↓
ActiveHealthChecker发起TCP端口检查
    ├─ 192.168.1.100:8080 → TCP OK → HTTP /actuator/health OK → 标记健康
    ├─ 192.168.1.101:8080 → TCP OK → HTTP /actuator/health OK → 标记健康
    └─ 192.168.1.102:8080 → TCP FAIL → 标记不健康(TCP_UNREACHABLE)
    ↓
每30秒定期检查
    ↓
192.168.1.102恢复
    ├─ TCP OK → HTTP OK → 标记健康
    └─ 推送给Admin UI(状态改变通知)

请求路由:

请求到达 → InstanceFilter.filter()
    ↓
排除 enabled=false 的节点(无)
    ↓
分类:
  - healthy: [192.168.1.100, 192.168.1.101]
  - unhealthy: [192.168.1.102]
    ↓
返回 healthy 节点
    ↓
InstanceSelector 选择具体节点(加权轮询)
    ↓
192.168.1.100:8080(选中)

九、核心代码文件索引

功能文件路径说明
多服务负载均衡器my-gateway/src/main/java/com/leoli/gateway/filter/loadbalancer/MultiServiceLoadBalancerFilter.java灰度规则 + 服务级负载均衡
实例选择器my-gateway/src/main/java/com/leoli/gateway/filter/loadbalancer/InstanceSelector.java4种负载均衡算法
实例过滤器my-gateway/src/main/java/com/leoli/gateway/filter/loadbalancer/InstanceFilter.java健康/禁用节点过滤
Nacos发现负载均衡my-gateway/src/main/java/com/leoli/gateway/filter/loadbalancer/NacosDiscoveryLoadBalancerFilter.javalb:// namespace/group覆盖
静态发现负载均衡my-gateway/src/main/java/com/leoli/gateway/filter/loadbalancer/DiscoveryLoadBalancerFilter.javastatic:// 负载均衡
混合健康检查器my-gateway/src/main/java/com/leoli/gateway/health/HybridHealthChecker.java被动+主动健康检查
主动健康检查器my-gateway/src/main/java/com/leoli/gateway/health/ActiveHealthChecker.javaTCP+HTTP探测
服务刷新器my-gateway/src/main/java/com/leoli/gateway/refresher/ServiceRefresher.javaNacos配置监听
服务管理器my-gateway/src/main/java/com/leoli/gateway/manager/ServiceManager.java服务缓存 + 权重解析

十、总结

本文深入介绍了企业级API网关的高级路由与负载均衡实战

核心亮点回顾

  1. 灰度发布系统:4种灰度规则(Header/Cookie/Query/Weight)+ 百分比流量分配
  2. 两层负载均衡:服务级(版本选择)+ 节点级(实例选择),互不影响
  3. 4种负载均衡算法:平滑加权轮询(Nginx风格)、简单轮询、加权随机、一致性Hash
  4. lb:// vs static:// 双协议:云原生+遗留系统双支持
  5. Nacos控制台实时管理:节点上下线、权重调整、健康状态,无需重启网关
  6. 混合健康检查:PASSIVE(被动)+ ACTIVE(主动)+ IMMEDIATE(即时),三级检查频率
  7. 可用性优先降级策略:没有健康节点时,尝试不健康节点(考虑健康检查延迟)

设计哲学

可用性优先:宁可尝试不健康节点,也不要直接返回503

  • 健康检查有延迟,节点可能已恢复
  • 尝试比直接拒绝更有价值
  • 重试机制兜底,最差情况返回真实错误

职责分离

  • lb:// 健康检查委托给Nacos
  • static:// 健康检查由Gateway自主执行
  • 两种协议互不干扰,各司其职

平滑过渡

  • 灰度发布支持渐进式放量
  • 平滑加权轮询避免流量倾斜
  • 节点权重调整自动适应

参考资料


关于作者

李朝,网关开发,7年+分布式系统经验,专注于API网关、微服务架构、云原生技术领域。

50天独立开发企业级API网关平台,涵盖44项核心功能、561个测试用例,从架构设计到生产环境部署全流程实践。


专业服务

如果你需要构建类似的API网关或微服务平台,我可以提供以下服务:

  • API网关定制开发:根据业务需求定制开发网关功能
  • 架构设计与咨询:微服务架构设计、技术选型、性能优化
  • 性能调优:JVM调优、连接池优化、限流降级方案
  • AI集成:AI Copilot开发、智能运维、自动化诊断

联系方式

需要API网关或微服务架构方面的帮助? 欢迎通过邮件或Upwork联系我,提供技术咨询和定制开发服务。