ElIauk

微服务配置灰度与全链路灰度

灰度是按用户路由,不是按机器 很多人把灰度理解成“先发一台机器,观察一天”。这在单体时代勉强可用,在微服务里基本无效:一次下单请求会经过网关、订单、库存、支付、优惠券五个服务,只把订单服务的一台机器换成新版本,请求照样会打到老版本的下游,新逻辑根本走不完整。 真正的灰度要解决的是一个请求在整条链路上

评论
微服务配置灰度与全链路灰度

灰度是按用户路由,不是按机器

很多人把灰度理解成“先发一台机器,观察一天”。这在单体时代勉强可用,在微服务里基本无效:一次下单请求会经过网关、订单、库存、支付、优惠券五个服务,只把订单服务的一台机器换成新版本,请求照样会打到老版本的下游,新逻辑根本走不完整。

真正的灰度要解决的是一个请求在整条链路上都走同一套版本,也就是全链路灰度。它的核心不是部署,而是路由:给请求打上标记,让标记在每一跳都活着。

染色、透传、路由三个环节

染色发生在入口。规则通常有三类:白名单用户、按比例抽样、按设备或渠道。按比例时要用稳定的哈希(比如 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 消费、异步补偿默认没有请求上下文,这些路径上的数据往往是最需要验证的,却最容易被漏掉。

灰度期太长。 长期维持两套版本,代码分支和数据库兼容逻辑会越积越多,最后没人敢删。灰度应该有明确的时间盒,到点要么全量要么回滚。

验证指标只看错误率。 新版本的性能退化、缓存命中率下降、下游调用量异常都可能是问题,灰度的观察指标要包含延迟、流量比例和关键业务指标。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者