Spring Cloud 灰度发布实战——基于流量标签的金丝雀部署完整方案
Spring Cloud 灰度发布实战——基于流量标签的金丝雀部署完整方案
适读人群:负责微服务发布策略的后端/DevOps 工程师 | 阅读时长:约17分钟 | 核心价值:从零实现可落地的灰度发布方案,不再靠运气发版
凌晨两点的上线惨剧
2022 年冬天,老陈所在的公司发了一次大版本。他们做电商,促销引擎改造了核心定价逻辑,改动涉及 7 个微服务。为了稳妥,上线时间选在凌晨两点,理论上流量最小。
结果还是出了事。新版本上线 20 分钟后,客服电话被打爆——部分用户发现自己购物车里的商品价格变了,有的变高了,有的变低了,两个版本的定价逻辑同时在跑,产生了数据不一致。回滚花了 40 分钟,期间又有一批用户下了"错误价格"的订单,退款处理持续了一整周。
"要是当时能只让 1% 的用户用新版本就好了。"老陈后来跟我说这话的时候,还在用新版本发布灰度 Bug 。
这就是灰度发布要解决的核心问题:让变化先影响一小部分流量,验证正确后再全量。今天这篇文章,我把基于 Spring Cloud 实现流量标签灰度的完整方案系统梳理一遍。
一、灰度发布的本质:流量路由
灰度发布(Canary Release,也叫金丝雀发布)的核心是流量路由——根据某些维度(用户 ID、地区、设备、请求头标签等),把流量分流到不同版本的服务实例上。
在 Spring Cloud 体系里,流量路由发生在两个层面:
- 网关层:入口流量根据规则打标签或直接路由
- 服务间调用层:Feign/RestTemplate 调用时,负载均衡器根据标签选择目标实例
这两层都必须处理,否则:网关把请求路由到新版 A 服务,A 服务调用 B 服务时没有灰度标签,B 服务的请求会被随机分配到新旧版本,导致链路混乱。
二、方案设计:基于 ThreadLocal + 请求头的标签传递
2.1 整体架构
用户请求
↓
Spring Cloud Gateway(染色)
→ 匹配灰度规则 → 在请求头添加 X-Gray-Tag: gray
↓
Order Service(新版 / 旧版)
→ 读取 X-Gray-Tag
→ 存入 ThreadLocal
→ Feign 调用时透传标签头
↓
Inventory Service(根据标签选实例)
→ LoadBalancer 过滤器读取标签
→ 优先选择 metadata.version=gray 的实例2.2 实例元数据打标
在 Nacos 中,给灰度实例打上版本标签:
# 灰度版本的 application.yml
spring:
cloud:
nacos:
discovery:
metadata:
version: gray # 灰度实例打 gray 标签
# 正式版本不设置这个字段,或设置为 stable2.3 网关层染色
// Spring Cloud Gateway 自定义过滤器,负责灰度规则判断和流量染色
@Component
public class GrayRoutingFilter implements GlobalFilter, Ordered {
// 灰度用户白名单(实际项目中应从配置中心动态获取)
private static final Set<String> GRAY_USER_IDS = new HashSet<>(
Arrays.asList("10001", "10002", "10003")
);
// 灰度流量比例(0-100),从配置中心读取
@Value("${gray.traffic.percentage:0}")
private int grayPercentage;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 策略1:用户白名单灰度
String userId = request.getHeaders().getFirst("X-User-Id");
if (userId != null && GRAY_USER_IDS.contains(userId)) {
return chain.filter(exchange.mutate()
.request(request.mutate()
.header("X-Gray-Tag", "gray")
.build())
.build());
}
// 策略2:按比例灰度(基于用户ID哈希,保证同一用户始终落到同一版本)
if (userId != null && grayPercentage > 0) {
int hash = Math.abs(userId.hashCode()) % 100;
if (hash < grayPercentage) {
return chain.filter(exchange.mutate()
.request(request.mutate()
.header("X-Gray-Tag", "gray")
.build())
.build());
}
}
// 不符合灰度规则,走正式流量
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -100; // 优先级要高,在其他过滤器之前执行
}
}2.4 灰度标签在服务间的传递
使用 RequestInterceptor 让 Feign 调用时自动透传灰度标签:
// Feign 请求拦截器,透传灰度标签
@Component
public class GrayFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String grayTag = GrayContext.getGrayTag();
if (StringUtils.hasText(grayTag)) {
template.header("X-Gray-Tag", grayTag);
}
}
}
// 灰度上下文(基于 InheritableThreadLocal,支持子线程传递)
public class GrayContext {
private static final ThreadLocal<String> GRAY_TAG =
new InheritableThreadLocal<>();
public static void setGrayTag(String tag) {
GRAY_TAG.set(tag);
}
public static String getGrayTag() {
return GRAY_TAG.get();
}
public static void clear() {
GRAY_TAG.remove(); // 请求结束时必须清理,防止内存泄漏
}
}
// Spring MVC 拦截器,从请求头读取灰度标签并存入 ThreadLocal
@Component
public class GrayHandlerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
String grayTag = request.getHeader("X-Gray-Tag");
if (StringUtils.hasText(grayTag)) {
GrayContext.setGrayTag(grayTag);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
GrayContext.clear(); // 请求完成后清理 ThreadLocal
}
}2.5 负载均衡器的灰度路由
这是整个方案的核心:自定义 Spring Cloud LoadBalancer,根据灰度标签选择目标实例。
// 自定义灰度负载均衡器
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private static final Logger log = LoggerFactory.getLogger(GrayLoadBalancer.class);
private final String serviceId;
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
public GrayLoadBalancer(String serviceId,
ObjectProvider<ServiceInstanceListSupplier> provider) {
this.serviceId = serviceId;
this.serviceInstanceListSupplierProvider = provider;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier =
serviceInstanceListSupplierProvider.getIfAvailable();
return supplier.get(request).next()
.map(instances -> getInstanceResponse(instances, request));
}
private Response<ServiceInstance> getInstanceResponse(
List<ServiceInstance> instances, Request request) {
if (instances.isEmpty()) {
return new EmptyResponse();
}
// 从请求上下文中获取灰度标签
String grayTag = extractGrayTag(request);
if ("gray".equals(grayTag)) {
// 优先选灰度实例
List<ServiceInstance> grayInstances = instances.stream()
.filter(i -> "gray".equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
if (!grayInstances.isEmpty()) {
// 灰度实例中随机选一个
ServiceInstance instance = grayInstances.get(
ThreadLocalRandom.current().nextInt(grayInstances.size()));
log.debug("灰度路由: service={}, instance={}:{}",
serviceId, instance.getHost(), instance.getPort());
return new DefaultResponse(instance);
}
// 没有灰度实例,降级到正式实例(避免灰度流量无法服务)
log.warn("没有找到灰度实例,降级到正式实例: service={}", serviceId);
}
// 正式流量:排除灰度实例(可选,如果不希望正式流量打到灰度实例)
List<ServiceInstance> stableInstances = instances.stream()
.filter(i -> !"gray".equals(i.getMetadata().get("version")))
.collect(Collectors.toList());
List<ServiceInstance> candidates = stableInstances.isEmpty() ? instances : stableInstances;
return new DefaultResponse(candidates.get(
ThreadLocalRandom.current().nextInt(candidates.size())));
}
private String extractGrayTag(Request request) {
// 从 Feign 请求头中提取,或从 ThreadLocal 中获取
try {
RequestDataContext context = (RequestDataContext) request.getContext();
HttpHeaders headers = context.getClientRequest().getHeaders();
return headers.getFirst("X-Gray-Tag");
} catch (Exception e) {
return GrayContext.getGrayTag();
}
}
}
// 配置类,替换默认的负载均衡器
@Configuration
@LoadBalancerClients(defaultConfiguration = GrayLoadBalancerConfig.class)
public class GrayLoadBalancerConfiguration {
}
public class GrayLoadBalancerConfig {
@Bean
public ReactorServiceInstanceLoadBalancer grayLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String serviceId = environment.getProperty(
LoadBalancerClientFactory.PROPERTY_NAME);
return new GrayLoadBalancer(serviceId,
loadBalancerClientFactory.getLazyProvider(
serviceId, ServiceInstanceListSupplier.class));
}
}三、踩坑实录
踩坑一:异步线程池导致灰度标签丢失
现象:服务内部使用 @Async 或者线程池处理异步任务,异步线程里发起的 Feign 调用没有灰度标签,全部打到了正式实例。
原因:InheritableThreadLocal 只在父线程创建子线程时传递,线程池中的线程是复用的,不会重新从父线程继承。
解法:使用 TransmittableThreadLocal(TTL),它专门解决线程池场景下的 ThreadLocal 传递问题。同时,需要用 TTL 提供的线程池装饰器包装你的线程池。
// 使用 TTL 替换 InheritableThreadLocal
private static final TransmittableThreadLocal<String> GRAY_TAG =
new TransmittableThreadLocal<>();
// 线程池配置,使用 TTL 装饰器
@Bean
public Executor asyncExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 50, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
// TTL 装饰器,确保异步任务能正确继承父线程的 ThreadLocal
return TtlExecutors.getTtlExecutorService(executor);
}踩坑二:灰度版本数据库 Schema 不兼容
现象:灰度版本新增了一个字段,结果正式版本的代码写数据时没有这个字段,读灰度版本写入的数据时报错。
原因:数据层的灰度和代码层的灰度没有协同,Schema 变更影响了所有版本。
解法:数据库变更必须保持向后兼容。先加字段(允许 null),再部署新代码使用该字段,最后等所有旧版本下线后再设置 NOT NULL 约束。绝对不能在灰度期间做破坏性的 Schema 变更。
踩坑三:灰度实例权重设置不合理导致热点
现象:设置了 5% 的灰度流量,但某些接口的灰度实例 CPU 飙升,正式实例却很空闲。
原因:5% 的流量比例是基于请求数,但如果灰度用户恰好是高活跃用户(比如内部测试用户),实际负载可能远超 5%。
解法:灰度用户选择要有代表性,最好用随机采样而不是固定白名单。同时在灰度实例上设置好资源隔离和限流,防止灰度实例被压垮影响整体稳定性。
四、灰度发布的完整操作流程
- 发布前:在 Nacos 配置灰度规则(哪些用户、多少比例),部署灰度实例并打 metadata 标签
- 验证期(通常 30 分钟到数小时):监控灰度实例的错误率、延迟、业务指标,与正式版本对比
- 全量:验证通过后,逐步把比例从 5% → 20% → 50% → 100%,每步观察一段时间
- 回滚预案:灰度规则存在配置中心,随时可以把灰度比例改为 0,立刻切回正式版本
灰度发布不是银弹,它需要团队在流程、监控、代码设计上都做好配合。但一旦体系建立起来,发布的信心会大大增加——你不再是在凌晨两点赌博,而是在白天做受控实验。
