灰度发布

返回 Spring Cloud

灰度发布(Canary Release / Gray Release)是将新版本只开放给一部分用户或流量,逐步扩大比例直到全量,以降低发布风险。


核心流程

全量流量
  ▼
Gateway(流量染色)
  ├── 规则匹配(Header / Cookie / 用户ID / 比例)
  │     ├── 命中灰度规则 → 打上标记 X-Gray: true
  │     └── 未命中      → X-Gray: false / 不打标
  ▼
LoadBalancer(根据标记选实例)
  ├── X-Gray: true  → 路由到 v2 实例
  └── X-Gray: false → 路由到 v1 实例

实例通过 Nacos 元数据(metadata) 声明自己的版本:

# v2 实例的 bootstrap.yml
spring:
  cloud:
    nacos:
      discovery:
        metadata:
          version: v2   # v1 实例写 v1

方案一:Nacos 元数据 + 自定义 LoadBalancer

自定义负载均衡策略

@Bean
@LoadBalanced
public ReactorLoadBalancer<ServiceInstance> grayLoadBalancer(
    Environment env, LoadBalancerClientFactory factory) {
  String name = env.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
  return new GrayLoadBalancer(factory.getLazyProvider(name, ServiceInstanceListSupplier.class));
}
 
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
 
    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        HttpHeaders headers = ((RequestDataContext) request.getContext()).getClientRequest().getHeaders();
        boolean isGray = "true".equals(headers.getFirst("X-Gray"));
 
        return serviceInstanceListSupplierProvider.getIfAvailable()
            .get(request)
            .next()
            .map(instances -> {
                List<ServiceInstance> targets = instances.stream()
                    .filter(i -> {
                        String v = i.getMetadata().get("version");
                        return isGray ? "v2".equals(v) : !"v2".equals(v);
                    })
                    .toList();
                // 如果没有目标实例,降级到全量列表
                if (targets.isEmpty()) targets = instances;
                return new DefaultResponse(targets.get(ThreadLocalRandom.current().nextInt(targets.size())));
            });
    }
}

Gateway 流量染色过滤器

@Component
public class GrayMarkFilter implements GlobalFilter, Ordered {
 
    // 灰度用户列表(实际项目从 Redis / Nacos Config 动态加载)
    private final Set<String> grayUserIds = Set.of("user_001", "user_002");
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
        boolean isGray = userId != null && grayUserIds.contains(userId);
 
        ServerHttpRequest mutated = exchange.getRequest().mutate()
            .header("X-Gray", String.valueOf(isGray))
            .build();
        return chain.filter(exchange.mutate().request(mutated).build());
    }
 
    @Override
    public int getOrder() { return -90; }
}

方案二:Gateway 按比例分流

Spring Cloud Gateway 内置权重路由,无需修改 LoadBalancer:

spring:
  cloud:
    gateway:
      routes:
        - id: order-v1
          uri: lb://order-service-v1
          predicates:
            - Path=/api/order/**
            - Weight=group1, 90      # 90% 流量
        - id: order-v2
          uri: lb://order-service-v2
          predicates:
            - Path=/api/order/**
            - Weight=group1, 10      # 10% 流量(灰度)

此方案需要 v1/v2 注册为不同的服务名,适合蓝绿部署场景。


方案三:基于请求头的精准灰度

适合 AB 测试或内测用户:

spring:
  cloud:
    gateway:
      routes:
        - id: gray
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
            - Header=X-Beta-User, true  # 只有携带此头的请求走灰度实例
          filters:
            - AddRequestHeader=X-Gray, true
        - id: stable
          uri: lb://order-service
          predicates:
            - Path=/api/order/**

Feign 传递灰度标记

灰度标记需要跨服务传递,否则只有第一跳生效:

@Component
public class GrayFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        // 从当前请求上下文取出灰度标记,注入到 Feign 请求
        String gray = RequestContextHolder.currentAttributes() != null
            ? (String) RequestContextHolder.currentAttributes().getAttribute("X-Gray", 0)
            : null;
        if (gray != null) {
            template.header("X-Gray", gray);
        }
    }
}

发布流程

1. 部署 v2 实例(少量,metadata.version=v2)
2. 配置灰度规则(指定用户 or 1% 比例)
3. 观察监控(错误率、P99、业务指标)
4. 逐步扩大灰度比例(5% → 20% → 50% → 100%)
5. 全量后下线 v1 实例

一键回滚:修改 Nacos Config 中的灰度规则,将比例降为 0%,流量立即全部回到 v1。


注意事项

问题处理方式
数据库兼容v1/v2 必须兼容同一份 Schema,否则需先做 DB 迁移
缓存污染灰度请求产生的缓存可能被 v1 读取,注意 key 隔离
链路标记丢失Feign 拦截器需透传 X-Gray 头
会话粘性同一用户在灰度期间的请求应路由到固定版本,避免体验割裂

相关链接

本目录

架构