灰度是按用户路由,不是按机器
很多人把灰度理解成“先发一台机器,观察一天”。这在单体时代勉强可用,在微服务里基本无效:一次下单请求会经过网关、订单、库存、支付、优惠券五个服务,只把订单服务的一台机器换成新版本,请求照样会打到老版本的下游,新逻辑根本走不完整。
真正的灰度要解决的是一个请求在整条链路上都走同一套版本,也就是全链路灰度。它的核心不是部署,而是路由:给请求打上标记,让标记在每一跳都活着。
染色、透传、路由三个环节
染色发生在入口。规则通常有三类:白名单用户、按比例抽样、按设备或渠道。按比例时要用稳定的哈希(比如 userId % 100 < 10),否则同一个用户在两次请求里会随机进入不同版本,体验和数据都乱。
透传是最容易断的一环。HTTP 调用要带请求头,Feign、RestTemplate、WebClient 都要加拦截器;异步线程池会丢 ThreadLocal;MQ 消息如果不带标记,消费端就只能猜。任何一处断了,链路就从那里开始“掉回”老版本。
路由发生在每个调用方。服务实例注册时要带上版本标签,负载均衡器优先选同标签实例;如果找不到同标签实例,必须能降级回稳定版本,否则灰度流量直接 503。
一个可用的标签路由实现
先让实例带上标签。注册中心里给实例加元数据即可:
spring:
cloud:
nacos:
discovery:
metadata:
gray-tag: v2
再让负载均衡器按当前请求的标签选实例:
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private static final String GRAY_TAG = "gray-tag";
private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
private final String serviceId;
public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> supplierProvider, String serviceId) {
this.supplierProvider = supplierProvider;
this.serviceId = serviceId;
}
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = supplierProvider
.getIfAvailable(NoopServiceInstanceListSupplier::new);
return supplier.get(request).next().map(instances -> pick(instances));
}
private Response<ServiceInstance> pick(List<ServiceInstance> instances) {
if (instances.isEmpty()) {
return new EmptyResponse();
}
String tag = GrayContext.getTag();
if (tag == null || tag.isBlank()) {
return new DefaultResponse(instances.get(
ThreadLocalRandom.current().nextInt(instances.size())));
}
List<ServiceInstance> matched = instances.stream()
.filter(instance -> tag.equals(instance.getMetadata().get(GRAY_TAG)))
.toList();
// 没有同标签实例时降级到全部实例,避免灰度流量直接失败
List<ServiceInstance> candidates = matched.isEmpty() ? instances : matched;
return new DefaultResponse(candidates.get(
ThreadLocalRandom.current().nextInt(candidates.size())));
}
}
GrayContext 用 ThreadLocal 保存标签,入口 Filter 写入、finally 清理,Feign 拦截器读取后写进请求头,线程池加 TaskDecorator 传递。这三件事缺一不可。
如果链路已经接入 Istio,路由这一步可以交给 VirtualService 的 header 匹配加 DestinationRule 的 subset,应用侧就只剩透传。但要注意:网格只负责网络层,进程内的线程池和 MQ 依然需要自己处理。
数据与配置也要灰度
新版本写了新字段、新表,老版本读不懂,这是灰度最危险的部分。原则是数据库变更必须双向兼容:先加字段(可空、有默认值),新老版本都能写;等全量上线后再收紧约束。
缓存 key 要带版本前缀,否则灰度的新格式数据可能被老版本读到并解析失败。MQ 消息新增字段要保证老消费者能忽略未知字段,涉及语义变更时应该新开 topic,而不是原地改格式。
配置灰度是另一个维度:配置中心一般支持按 IP 或标签推送,先推给灰度实例,观察指标正常后再全量。配置的灰度比代码的灰度更容易回滚,适合承载开关类变更。
常见坑
只在一层路由。 网关把 10% 流量给了新版本,但服务之间全走默认负载均衡,新版本又随机调到老下游,验证结果毫无意义。
没有同标签实例时直接失败。 灰度期间某个下游没有发新版本,调用全部报错。降级回稳定版本是必须实现的分支。
按比例抽样用了随机数。 同一用户时而新版时而旧版,购物车、会话、幂等全部错乱。哈希要稳定。
异步链路不带标签。 定时任务、MQ 消费、异步补偿默认没有请求上下文,这些路径上的数据往往是最需要验证的,却最容易被漏掉。
灰度期太长。 长期维持两套版本,代码分支和数据库兼容逻辑会越积越多,最后没人敢删。灰度应该有明确的时间盒,到点要么全量要么回滚。
验证指标只看错误率。 新版本的性能退化、缓存命中率下降、下游调用量异常都可能是问题,灰度的观察指标要包含延迟、流量比例和关键业务指标。
评论