第 15 章 · 灰度发布与流量治理
本章目标:掌握 金丝雀发布(Canary)、蓝绿部署、滚动更新 概念;在 gateway-svc 与 Nacos 元数据实现基于权重的灰度路由;使用 Spring Cloud LoadBalancer 自定义 灰度规则;结合 K8s / Istio(ch13)理解平台层灰度;建立 发布检查清单 与快速回滚流程。
学时建议:5~6 小时(含 2 小时双版本联调)
前置:spring-cloud-web ch01~ch14;Gateway 路由(ch03);Nacos 元数据(ch02);选修 Istio ch13。
15.1 为什么需要灰度发布
直接将 product-svc v2 全量替换 v1 风险高:
| 风险 | 灰度缓解 |
|---|---|
| 新 Bug 影响全部用户 | 先 5% 流量验证 |
| 性能回归未发现 | 对比 v1/v2 指标 |
| 数据库迁移不兼容 | 与功能开关配合 |
发布前: 100% ──► product-svc v1
金丝雀: 90% ──► v1
10% ──► v2 (观察错误率、P99)
全量: 100% ──► v2 (或回滚到 v1)
15.2 三种部署策略对比
| 策略 | 原理 | 资源 | 回滚速度 | 适用 |
|---|---|---|---|---|
| 滚动更新 | 逐步替换 Pod/实例 | 低 | 中 | K8s 默认 |
| 蓝绿 | 两套环境切换流量 | 高(双倍) | 快(切回蓝) | 关键发布 |
| 金丝雀 | 小比例引流新版本 | 中 | 快(调权重) | 持续交付 |
svc-spring-demo 教学:金丝雀(Nacos 权重 + Gateway Header)。
15.3 Nacos 实例元数据
启动 product-svc 两版本,注册时带 metadata:
# product-svc v1
spring:
cloud:
nacos:
discovery:
metadata:
version: v1
env: prod
# product-svc v2(不同端口或 K8s Deployment)
spring:
cloud:
nacos:
discovery:
metadata:
version: v2
env: prod
Nacos 控制台可设置实例权重(0~1),LoadBalancer 默认考虑权重。
15.4 自定义灰度 LoadBalancer
实现 ReactorServiceInstanceLoadBalancer,按请求头 X-Gray-Version 匹配实例的 Nacos metadata.version:
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.DefaultResponse;
import org.springframework.cloud.client.EmptyResponse;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
import org.springframework.cloud.loadbalancer.core.RequestDataContext;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.beans.factory.ObjectProvider;
import reactor.core.publisher.Mono;
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final ObjectProvider<ServiceInstanceListSupplier> supplier;
private final String serviceId;
public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> supplier, String serviceId) {
this.supplier = supplier;
this.serviceId = serviceId;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
// Gateway 场景下 context 为 RequestDataContext,可取出原始请求头
String version = null;
if (request.getContext() instanceof RequestDataContext ctx) {
version = ctx.getClientRequest().getHeaders().getFirst("X-Gray-Version");
}
ServiceInstanceListSupplier listSupplier = supplier.getIfAvailable();
if (listSupplier == null) {
return Mono.just(new EmptyResponse());
}
String grayVersion = version;
return listSupplier.get(request).next()
.map(instances -> select(instances, grayVersion));
}
private Response<ServiceInstance> select(List<ServiceInstance> instances, String version) {
List<ServiceInstance> candidates = instances;
if (version != null) {
List<ServiceInstance> matched = instances.stream()
.filter(i -> version.equals(i.getMetadata().get("version")))
.toList();
if (!matched.isEmpty()) {
candidates = matched; // 有匹配走灰度组;无匹配回退全部实例(兜底策略)
}
}
if (candidates.isEmpty()) {
return new EmptyResponse();
}
return new DefaultResponse(
candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())));
}
}
注册配置(@LoadBalancerClients 是类型级注解,不能标在 @Bean 方法上):
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/** 对所有 lb:// 服务启用灰度 LB;只对个别服务启用改用 @LoadBalancerClient(name = "product-svc", ...) */
@LoadBalancerClients(defaultConfiguration = GrayLoadBalancerClientConfig.class)
@Configuration
public class GrayLoadBalancerClientsConfig {
}
/** 每个服务一个 LB 实例,由 LoadBalancerClientFactory 按服务名创建 */
class GrayLoadBalancerClientConfig {
@Bean
ReactorServiceInstanceLoadBalancer grayLoadBalancer(
Environment environment, LoadBalancerClientFactory factory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new GrayLoadBalancer(
factory.getLazyProvider(name, ServiceInstanceListSupplier.class), name);
}
}
Gateway 按用户灰度:YAML 过滤器参数里的 SpEL 拿不到 exchange 变量,应写一个 GlobalFilter(写法参考 ch14 的 TenantResolveFilter)按规则注入灰度头:
@Component
public class GrayVersionFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 规则示例:内部员工 Header X-Beta-User: true → v2;或 userId % 100 < 10 → v2
String version = resolveVersion(exchange);
if (version == null) {