1. 为什么要灰度发布?

互联网产品迭代速度越来越快。

传统的“蓝绿部署”或“停服更新”已经无法满足需求。一旦新版本上线出现问题,影响的是所有用户,回滚成本极高。

灰度发布的核心思想是:让一部分用户先用上新版本。如果稳定,再逐步扩大范围。如果不稳定,只影响小部分用户,能快速回滚。

简单说,灰度发布 = 小范围验证 + 平滑过渡 + 风险可控

但在微服务架构中,一个请求往往要经过 A -> B -> C 等多个服务。如果只给入口服务(比如 Gateway)配置了灰度规则,流量进入 B 服务后,B 服务如何知道该调用老版本的 C 还是新版本的 C?

这就需要 流量染色

2. 什么是流量染色?

它的基本思想是:在请求入口处,给请求打上一个标记(颜色)。这个标记会随着请求,在全链路中传递。下游服务根据这个标记,决定调用哪个版本的服务。

我们可以这样理解:每个请求就像一个快递。灰度请求是“加急件”,普通请求是“平邮件”。每个微服务就像中转站,看到“加急件”就送新仓库,看到“平邮件”就送旧仓库。

3. Spring Cloud 实现方案(基于 Spring Cloud Gateway + Nacos)

下面是一个具体的实现例子。我们使用 Spring Cloud Gateway 作为流量入口,Nacos 作为注册中心和配置中心。

3.1 版本控制:元数据标记

首先,我们需要让服务实例知道自己属于哪个版本。在 Nacos 中,可以通过 元数据(Metadata) 来标记。

服务提供者 B(老版本)application.yml

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        metadata:
          version: v1.0.0   # 标记为老版本

服务提供者 B(新版本)application.yml

spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        metadata:
          version: v2.0.0   # 标记为新版本

这样,同一个服务名 service-b,在 Nacos 里就有了两个不同版本的实例。

3.2 网关染色:给请求打标签

接下来,在网关层解析 HTTP 请求头。我们约定,灰度用户会携带请求头 X-User-Version: gray

在 Spring Cloud Gateway 中编写一个 全局过滤器(GlobalFilter),读取这个头,并将其转化为内部标记。

Gateway 过滤器代码:

@Component
public class GrayPublishFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 获取请求头
        String userVersion = exchange.getRequest().getHeaders().getFirst("X-User-Version");

        // 2. 如果是灰度用户,则染色(这里染色方式是在请求头中加入新标记)
        if ("gray".equals(userVersion)) {
            ServerWebExchange mutatedExchange = exchange.mutate()
                .request(builder -> builder.header("X-Gray-Tag", "true")) // 关键:加上灰度标记
                .build();
            return chain.filter(mutatedExchange);
        }

        // 3. 非灰度用户,不加标记,直接放行
        return chain.filter(exchange);
    }
}

3.3 传递染色标记:让标记在链路中流动

上面的代码只解决了入口染色。请求调用 Service B 时,B 必须把这个 X-Gray-Tag 传递给 Service C。

在 Spring Cloud 中,服务间调用通常使用 Feign Client。我们需要配置 Feign 的拦截器,让它自动传递这个 Header。

Feign 拦截器实现:

@Configuration
public class FeignGrayConfig {
    @Bean
    public RequestInterceptor requestInterceptor() {
        return requestTemplate -> {
            // 从当前上下文(RequestContextHolder)中获取灰度标记
            ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attrs != null) {
                String grayTag = attrs.getRequest().getHeader("X-Gray-Tag");
                if (StringUtils.hasText(grayTag)) {
                    requestTemplate.header("X-Gray-Tag", grayTag); // 传递下去
                }
            }
        };
    }
}

注意RequestContextHolder 依赖于 ThreadLocal。在异步调用时,需要手动配置线程池,将上下文传递到子线程。

3.4 服务发现与路由:下游服务的选择

现在,标记已经传递到 Service B 了。当 Service B 需要调用 Service C 时,它应该调用 C 的老版本还是新版本?

这里的关键是 修改 Ribbon(或 Spring Cloud LoadBalancer)的负载均衡规则。我们需要实现一个自定义规则:如果请求带有灰度标记,就优先选择 version=v2.0.0 的 Service C 实例;如果没有,就选择 version=v1.0.0 的实例。

自定义负载均衡规则(以 Ribbon 为例):

public class GrayLoadBalanceRule extends AbstractLoadBalancerRule {
    @Override
    public Server choose(Object key) {
        List<Server> servers = getLoadBalancer().getReachableServers();
        
        // 1. 获取灰度标记(这里假设标记存在请求头中,实际情况需要通过上下文传递)
        // 为了简化,我们从 ThreadLocal 或其他上下文获取
        boolean isGray = GrayContextHolder.isGray();

        // 2. 筛选版本
        List<Server> targetServers = servers.stream()
            .filter(server -> {
                // 从 Nacos 元数据中获取版本
                String version = getMetadata(server).get("version");
                if (isGray) {
                    return "v2.0.0".equals(version); // 灰度流量找新版本
                } else {
                    return "v1.0.0".equals(version); // 正常流量找老版本
                }
            }).collect(Collectors.toList());

        // 3. 如果没找到匹配的,fallback 到所有可用节点
        if (targetServers.isEmpty()) {
            return servers.get(0); // 简单返回第一个
        }

        // 4. 随机选择一个
        return targetServers.get(new Random().nextInt(targetServers.size()));
    }
}

3.5 配置中心动态开关

我们可以把灰度规则放到 Nacos 配置中心,实现动态生效。

例如,在 Nacos 中配置一个规则:

{
  "grayUsers": ["user001", "user002"],
  "grayVersion": "v2.0.0",
  "normalVersion": "v1.0.0"
}

网关定时拉取这份配置。这样,我们不需要重启网关,就能动态调整哪些用户走灰度版本。

4. 一个完整的例子

假设有一个电商系统:Gateway -> Order-Service -> Stock-Service。

  1. 我们新上线了“秒杀功能”,部署了新的 Order-Service (v2.0.0) 和 Stock-Service (v2.0.0)。
  2. 运维在 Nacos 配置中心配置:用户ID为 1001、1002 的走灰度版本。
  3. 用户 1001 发起请求,携带 X-User-Id: 1001
  4. Gateway 解析用户ID,匹配规则,在请求头中加入 X-Gray-Tag: true
  5. Gateway 调用 Order-Service (v2.0.0),因为 Gateway 自身的负载均衡规则看到了灰度标记,选择了 v2.0.0 的 Order 实例。
  6. Order-Service 处理过程中,需要调用 Stock-Service。
  7. Feign 拦截器自动将 X-Gray-Tag: true 传递过去。
  8. Order-Service 的负载均衡规则(类似上面的 GrayLoadBalanceRule)根据灰度标记,选择 Stock-Service 的 v2.0.0 实例。
  9. 整个链路完成,用户 1001 体验到了新功能。而用户 1003 则全程走老版本。

5. 常见误区(经验之谈)

流量染色看似简单,实践中容易踩坑。

  • 线程池丢失上下文:使用了 @Async 或者 Hystrix 线程隔离,ThreadLocal 里的标记就丢了。需要手动传递。
  • 网关层过度耦合:网关只应该做路由和简单的染色,复杂的业务规则(如根据 IP 或 UserID 判断是否灰度)不要写在网关里,应该后置到配置中心或专门的规则服务。
  • 元数据不一致:确保所有服务实例的 Nacos 元数据格式一致。如果某个实例忘了配 version,可能导致流量错乱。

6. 总结

灰度发布是微服务治理的核心能力。

流量染色是一种优雅的实现方式。它的基本思想是给请求打标记,让标记随着调用链传递。下游服务根据标记选择不同版本的服务提供者。

通过 Spring Cloud Gateway + Nacos + 自定义负载均衡,我们可以构建一套相对完整的全链路灰度发布方案。

这套方案的核心在于:

  • 标记传递:通过 Header 在服务间传递染色标记。
  • 规则解耦:将灰度规则放在配置中心,动态调整。
  • 版本感知:服务实例通过元数据暴露自己的版本号。

希望这篇文章能帮助你理解并落地灰度发布。技术在迭代,架构在演进,控制风险永远是第一位的。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐