Skip to content

微服务发布策略:灰度、金丝雀、蓝绿、流量染色与回滚

微服务发布不是把镜像版本改成 v2 然后等平台滚动完成。真实商业系统里,一次发布同时影响入口路由、服务发现、负载均衡、长连接、缓存、数据库结构、消息格式、配置开关、观测指标和回滚窗口。发布策略的目标不是“新版本上线”,而是让新版本在可控流量下暴露问题,能用证据判断是否放量,出问题时能把影响面收回来。

本页按生产调用链讲清楚:用户请求怎样被 Gateway 打标签,标签怎样通过 Feign 或 RPC 透传,LoadBalancer 怎样按服务元数据选择实例,Service Mesh 怎样按权重和 Header 分流,数据和消息怎样兼容,最后怎样观测、放量和回滚。

一、学习目标

学完本页应能做到:

  1. 区分滚动发布、蓝绿发布、金丝雀发布、灰度发布、镜像流量、暗发布和功能开关。
  2. 解释 Gateway 灰度、客户端负载均衡灰度、Service Mesh 灰度分别在调用链哪一层生效。
  3. 设计基于用户、租户、医院、Region、Header、Cookie 和权重的灰度规则。
  4. 解释 Feign、LoadBalancer、Nacos/Eureka Metadata、Istio VirtualService 的分工。
  5. 说明为什么灰度比例不一定精确,为什么扩容或回滚后仍有短暂旧连接流量。
  6. 设计数据库、API、MQ 事件的兼容发布,避免新旧版本并存时互相打挂。
  7. 用 P95/P99、错误率、业务成功率、重试次数和回滚条件判断是否继续放量。
  8. 回答“回滚是不是把镜像改回去”这类面试追问。

二、常见发布策略全景

策略核心做法适合场景主要风险
滚动发布 Rolling Update分批替换实例,新旧版本短时间并存普通无状态服务、小改动新旧版本必须兼容,长连接和缓存可能残留
蓝绿发布 Blue/Green同时保留蓝、绿两套完整环境,一次性切流大版本升级、需要快速整体回退成本高,数据库和外部副作用不能简单双环境
金丝雀 Canary先让极少量流量进入新版本,再逐步放大高风险逻辑、新依赖、新性能参数样本太小会误判,灰度用户可能不代表全量
灰度发布 Gray Release按用户、租户、地域、标签或规则让特定人群使用新版本B端租户、医院、商户、VIP分层标签传播错误会串流量
镜像流量 Shadow/Mirror真实请求复制一份给新版本,只观察不影响用户新搜索引擎、新风控模型、新解析器不能执行真实写副作用,成本和隐私风险高
暗发布 Dark Launch代码已上线但功能入口关闭,通过开关逐步打开前端入口、新规则、新能力预埋开关治理差会产生配置漂移
功能开关 Feature Flag用配置控制功能、算法或路径启停需要快速止损的业务功能分支长期存在会让代码复杂

一句话记忆:滚动解决“怎么替换实例”,蓝绿解决“怎么切环境”,金丝雀解决“先让少量流量试错”,灰度解决“让指定人群试错”,镜像流量解决“只观察不承诺结果”,功能开关解决“功能是否对外启用”。

三、一次灰度请求完整链路

以订单服务调用库存服务为例:

mermaid
flowchart TD
    A["用户或租户进入请求"] --> B["Gateway认证并生成可信灰度标签"]
    B --> C["Gateway按路由规则选择入口版本"]
    C --> D["订单服务接收标签并写入上下文"]
    D --> E["Feign拦截器透传标签到库存服务调用"]
    E --> F["LoadBalancer读取实例元数据"]
    F --> G["过滤version、zone、weight候选实例"]
    G --> H["HTTP Client发起真实请求"]
    H --> I["库存服务v2处理并记录版本指标"]
    I --> J["Metrics、Trace、日志按版本聚合"]

这里要分清职责:

环节谁负责说明
识别灰度用户Gateway、认证中心、灰度平台不能完全相信客户端自己传的 Header
入口路由Gateway Route、Predicate、Filter决定请求进入哪个服务或哪个 URI
服务间透传Feign RequestInterceptor、RPC Filter、Trace Baggage把可信标签带到下游
实例候选Nacos/Eureka/Consul/K8s Endpoint提供实例地址、健康状态和元数据
实例选择Spring Cloud LoadBalancer、Ribbon、Dubbo Cluster、Envoy过滤和选择最终实例
真实发请求HTTP Client、Netty、Dubbo Client、Envoy负载均衡选完地址后才发网络请求
观测判断Metrics、Trace、Logs、业务对账决定继续、暂停、回滚或扩大

所以不能简单说“Feign 从 Nacos 拿地址然后发请求”。更准确的是:Feign 生成调用代理并组织 HTTP 请求,服务实例列表由注册发现组件提供,LoadBalancer 在本地候选列表里选一个实例,底层 HTTP Client 才真正建立连接并发送请求。

四、Gateway 灰度怎么做

Gateway 适合做入口级灰度,因为所有外部请求都会先经过它。常见规则:

规则示例适合场景
HeaderX-Gray-Tag: v2内部测试、自动化压测
Cookiegray=v2前端灰度、用户粘性
用户IDuserId 哈希后命中 1%C端小流量
租户ID医院A先进入新版采集逻辑B端租户灰度
Region华东先灰度多地域部署
权重1%、5%、20%、50%无明确用户分组时逐步放量

Gateway 示例配置:

yaml
spring:
  cloud:
    gateway:
      routes:
        - id: order-gray
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
            - Header=X-Gray-Tag, v2
          filters:
            - AddRequestHeader=X-Route-Version, v2
        - id: order-stable
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - AddRequestHeader=X-Route-Version, v1

上面只解决入口打标,不代表 lb://order-service 一定会选择 v2 实例。若服务发现列表里 v1/v2 都存在,还需要 LoadBalancer 或下游服务理解 X-Route-Version

4.1 不要信任客户端伪造灰度 Header

错误做法:

java
String gray = request.getHeader("X-Gray-Tag");
if ("v2".equals(gray)) {
    routeToV2();
}

问题是任何外部用户都能伪造 Header,绕过灰度平台。正确做法是 Gateway 先完成认证,再根据可信身份计算灰度标签,并覆盖客户端同名 Header:

java
public class GrayHeaderFilter {
    public String resolveGrayTag(String userId, String tenantId) {
        if ("hospital-a".equals(tenantId)) {
            return "v2";
        }
        int bucket = Math.abs(userId.hashCode()) % 100;
        return bucket < 5 ? "v2" : "v1";
    }
}

生产项目中应先删除外部传入的 X-Gray-TagX-User-IdX-Tenant-Id 等敏感 Header,再由网关写入可信值。

五、Spring Cloud LoadBalancer 灰度

Spring Cloud 服务间调用常见链路是:

mermaid
flowchart TD
    A["业务调用Feign接口"] --> B["Feign动态代理生成RequestTemplate"]
    B --> C["RequestInterceptor追加灰度标签"]
    C --> D["LoadBalancer获取服务实例快照"]
    D --> E["按metadata过滤版本、区域、权重"]
    E --> F["选择一个ServiceInstance"]
    F --> G["HTTP Client向实例IP端口发送请求"]

Nacos 或 Eureka 实例元数据可以这样标记:

yaml
spring:
  cloud:
    nacos:
      discovery:
        metadata:
          version: v2
          zone: shanghai
          weight: "10"

Feign 透传灰度标签示例,兼容 Java 8 写法:

java
@Configuration
public class FeignGrayConfig {

    @Bean
    public RequestInterceptor grayRequestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                String grayTag = GrayContext.getGrayTag();
                if (grayTag != null && grayTag.length() > 0) {
                    template.header("X-Gray-Tag", grayTag);
                }
            }
        };
    }
}

简化版上下文:

java
public final class GrayContext {
    private static final ThreadLocal<String> GRAY_TAG = new ThreadLocal<String>();

    private GrayContext() {
    }

    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();
    }
}

线程池和异步任务要特别小心:ThreadLocal 不会天然跨线程传播。如果订单服务把调用放到异步线程池执行,必须在提交任务时捕获灰度标签,执行时恢复,结束后清理。否则入口是 v2 用户,下游可能随机走到 v1。

5.1 自定义实例过滤的思想

灰度过滤的本质不是“改 Feign”,而是改候选实例集合:

java
public List<ServiceInstance> filterByVersion(List<ServiceInstance> instances, String grayTag) {
    List<ServiceInstance> matched = new ArrayList<ServiceInstance>();
    for (ServiceInstance instance : instances) {
        String version = instance.getMetadata().get("version");
        if (grayTag != null && grayTag.equals(version)) {
            matched.add(instance);
        }
    }
    if (!matched.isEmpty()) {
        return matched;
    }
    return instances;
}

重点边界:

  1. 灰度实例为空时不能盲目返回空,否则调用方会报 No instances available
  2. 是否 fallback 到稳定版本要按业务风险决定。只读查询可以回退,写请求可能会造成版本语义不一致。
  3. 过滤后还要继续执行负载均衡算法,不能永远选第一个实例。
  4. 规则变更要有版本号、审计和回滚,不要散落在代码常量里。

5.2 怎么保证本地服务列表尽量最新

先说结论:分布式系统不能保证每个调用方本地服务列表在任意瞬间都绝对最新,只能通过订阅推送、定期刷新、健康检查、缓存过期、优雅上下线和失败兜底,把“旧列表窗口”控制到可接受范围。

原因是注册中心属于控制面,业务请求属于数据面。服务上下线要经过多个异步步骤:

mermaid
flowchart TD
    A["服务实例状态变化"] --> B["注册中心更新服务表"]
    B --> C["推送或等待客户端拉取"]
    C --> D["调用方更新本地实例快照"]
    D --> E["LoadBalancer使用新快照选址"]
    E --> F["HTTP连接池逐步释放旧连接"]

每一步都可能有延迟,所以“注册中心控制台已经没有旧实例”不等于“所有调用方马上不再访问旧实例”。尤其是 HTTP Keep-Alive、HTTP/2、gRPC、连接池预连接和客户端缓存,会让旧地址在短时间内继续被使用。

不同注册中心的典型机制:

组件本地列表怎样更新为什么仍可能短暂不新
Eureka客户端定时拉取注册表并缓存拉取周期、服务端剔除周期、自我保护都会形成窗口
Nacos客户端订阅服务,服务端推送变更,本地也有缓存和刷新推送延迟、网络抖动、客户端处理延迟、缓存快照切换都有窗口
Consul客户端查询Catalog或Health API,可用Blocking Query长轮询长轮询返回、索引推进和客户端更新不是瞬时
KubernetesEndpointSlice更新后由DNS、kube-proxy/eBPF和客户端连接共同生效DNS缓存、连接复用、Readiness传播和数据面规则同步都有延迟
Service Mesh控制面下发xDS,Envoy ACK后使用新配置xDS推送、Warming、ACK/NACK和已有连接排空需要时间

因此生产上要做的是组合防线:

防线作用不做会怎样
Readiness先摘流让新请求不再进入准备下线实例进程正在停,调用方仍把新请求打进去
优雅停机等存量请求结束,再关闭Web容器、消费者和连接池请求执行一半被杀,调用方看到连接重置
客户端定期刷新和订阅让本地快照最终收敛到注册中心事实调用方长期拿旧实例
健康过滤LoadBalancer过滤DOWN、不Ready、权重0实例仍选到不该接流量的实例
短超时和有限重试旧实例不可达时快速失败并尝试其他实例线程长时间卡死,故障扩散
连接池最大存活时间避免旧连接无限复用旧地址回滚或摘流后仍持续有旧连接流量
实例级熔断或摘除慢实例或坏实例持续失败时降低命中注册中心健康但业务已经不可用
版本指标和Trace证明请求到底打到了哪个版本只能猜是不是调了旧服务

5.3 如果调到了旧服务怎么办

先不要把它简单归因成“注册中心不实时”。排查要分清是旧版本仍被允许接流量,还是本地列表或连接池残留,还是灰度规则把请求导向了旧版本

排查顺序:

  1. 查调用方日志和 Trace,确认目标 ip:port、服务名、版本标签、routeId 和请求时间。
  2. 查注册中心控制台和接口,确认该实例当时是否还存在、健康状态是什么、metadata 版本是什么。
  3. 查调用方本地实例快照,确认 LoadBalancer 最终候选列表里是否仍有旧实例。
  4. 查 HTTP Client 连接池,确认是否复用了旧连接,而不是重新选址。
  5. 查 Gateway、Feign、RPC Filter 是否丢失灰度标签。
  6. 查 Kubernetes Readiness、preStop、terminationGracePeriod 是否给了足够摘流和排空时间。
  7. 查是否存在多注册中心、多 namespace、多 group 或多 cluster,调用方看的不是你以为的那份服务表。

处理策略按业务风险区分:

场景建议处理
只读查询打到旧版本通常可接受短暂窗口,但要确认响应字段兼容,必要时降低旧版本权重
写请求打到旧版本不要自动重放到新版本;先用业务幂等号查询事实,再决定补偿
调到已停止实例调整下线流程、超时、连接池回收和重试策略
灰度用户打到稳定版本修复标签生成、透传和实例过滤规则
回滚后仍打到新版本先摘除新版本入口流量,再排空连接和处理异步副作用

面试里可以这样回答:

text
本地服务列表不可能在任意瞬间绝对最新。注册中心变更、客户端订阅或拉取、本地快照替换、
负载均衡选择和连接池释放都是异步过程,所以一定存在传播窗口。生产上通过Readiness摘流、
优雅停机、订阅推送、定期刷新、健康过滤、短超时、有限重试、连接池最大存活时间和实例级
指标来降低旧实例命中概率。

如果真的调到旧服务,要先通过Trace和日志确认目标ip、版本和route,再查注册中心事实、
调用方本地实例快照、LoadBalancer候选列表、HTTP连接池以及灰度标签是否丢失。只读请求
可以按兼容策略容忍短窗口;写请求不能简单重试到新服务,要用幂等号查询业务事实后补偿。

六、Service Mesh 与 Istio 灰度

如果使用 Istio 这类 Service Mesh,应用进程可以不知道灰度规则,流量由 Envoy Sidecar 根据控制面下发的配置处理。

典型配置:

yaml
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: order
spec:
  host: order-service
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order
spec:
  hosts:
    - order-service
  http:
    - match:
        - headers:
            x-gray-tag:
              exact: v2
      route:
        - destination:
            host: order-service
            subset: v2
    - route:
        - destination:
            host: order-service
            subset: v1
          weight: 95
        - destination:
            host: order-service
            subset: v2
          weight: 5

Mesh 的优势是治理集中、语言无关、规则可统一观测;代价是多了一层代理和控制面,排查时要同时看应用、Sidecar、xDS 配置、连接池和目标实例。

6.1 为什么权重不一定精确

5% 权重不是“每 100 个用户精确 5 个进 v2”。原因包括:

原因解释
样本太小10 个请求里可能 0 个或 2 个命中 v2
长连接复用HTTP/2 或 gRPC 多个请求复用同一连接
客户端本地 LB每个调用方独立随机,不是全局统一抽签
可用区和实例过滤先按 zone 或标签过滤后,权重只在子集合中生效
重试第一次失败后第二次可能打到另一个版本
慢实例请求数相近但并发和耗时不相近

判断灰度是否符合预期,要按版本统计 QPS、并发、连接数、P95/P99、错误率和业务成功率,不能只看配置权重。

七、数据、接口和消息必须兼容

发布策略救不了不兼容变更。微服务滚动期间新旧版本必然并存,所以必须先让两边都能工作。

7.1 API 兼容

安全做法:

  1. 新增可选字段,不删除旧字段。
  2. 新字段有默认值,旧客户端不传也能处理。
  3. 响应新增字段不影响旧客户端解析。
  4. 删除字段前确认所有旧版本、灾备版本和外部调用方都已退出。

危险做法:

json
{
  "patientName": "张三"
}

直接改成:

json
{
  "name": "张三"
}

旧客户端还在读 patientName,灰度期间就会空指针或展示异常。更稳的是先同时输出两个字段,再推动调用方迁移,最后删除旧字段。

7.2 数据库 Expand-Migrate-Contract

mermaid
flowchart TD
    A["Expand:新增兼容字段或新表"] --> B["发布双写或兼容读代码"]
    B --> C["Migrate:回填历史数据并校验"]
    C --> D["灰度切读新字段"]
    D --> E["确认旧版本退出"]
    E --> F["Contract:删除旧字段或旧逻辑"]

不要在一个发布里同时“删字段、改代码、切流量”。一旦出问题,旧版本也无法回滚,因为数据库结构已经不支持旧代码。

7.3 MQ 事件兼容

事件可能被延迟消费、重放或从灾备恢复。规则:

  1. 已发布字段语义不要改变。
  2. 新增字段要可选。
  3. 消费者要能忽略未知字段。
  4. 事件要有 eventIdeventTypeschemaVersion 和业务版本。
  5. 新消费者上线前要用历史消息样本回放测试。

订单事件示例:

json
{
  "eventId": "evt-202607190001",
  "eventType": "OrderCreated",
  "schemaVersion": 2,
  "orderId": "O1001",
  "tenantId": "hospital-a",
  "items": [
    {
      "skuId": "P100",
      "quantity": 1
    }
  ]
}

八、灰度观察指标和放量门禁

灰度不是“等 10 分钟没报错”。要提前定义放量门禁:

指标为什么看
QPS和流量占比确认真实流量是否进入新版本
错误率判断是否出现功能故障
P95/P99发现尾延迟、慢实例、GC和连接池等待
业务成功率技术200不代表下单、支付、采集成功
重试次数重试可能掩盖真实失败并放大流量
熔断、限流、降级次数判断系统是否靠保护机制硬撑
线程池和连接池判断是否排队和资源耗尽
DB慢SQL和锁等待新逻辑可能触发坏查询或热点行
MQ Lag和最老消息年龄异步链路是否被新版本拖慢
版本维度Trace证明慢点发生在哪个版本和哪一段

放量示例:

text
1%内部用户 -> 5%指定租户 -> 20%低风险用户 -> 50% -> 100%

每一步都要设置停止条件。例如:

text
v2错误率连续5分钟高于v1两倍,或核心业务成功率下降0.2%,立即停止放量。
v2 P99连续10分钟超过SLO 30%,进入排查;若影响核心链路,回滚流量。

九、回滚不是简单改回镜像

代码回滚只能解决“新代码继续接流量”的问题,不能自动回滚所有副作用。

对象能否简单回滚原因
无状态代码通常可以只要新旧契约和配置兼容
数据库字段删除不可以旧代码可能依赖已删除字段
数据语义变更不可以已写入的数据可能无法按旧逻辑解释
MQ Offset不可以随便回退会重复消费,需要幂等和重放计划
外部支付、短信、库存不可以外部副作用已经发生
配置中心需要版本化回滚只改代码不改配置可能仍走新逻辑
缓存需要清理或兼容新旧缓存结构可能互不兼容

可靠回滚闭环:

mermaid
flowchart TD
    A["发现指标越过停止条件"] --> B["冻结发布并记录版本证据"]
    B --> C["先停止新流量进入v2"]
    C --> D["排空或有界等待旧连接"]
    D --> E["回滚代码、配置或路由"]
    E --> F["处理数据、消息和外部副作用"]
    F --> G["对账确认业务事实"]
    G --> H["复盘并补充自动化门禁"]

十、商业常用场景

10.1 医疗数据采集新解析器灰度

风险:不同医院接口字段不一致,新解析器可能把检验报告字段解析错。

方案:

  1. 按医院租户灰度,而不是随机用户灰度。
  2. 新解析器先镜像解析,只记录结果差异,不入主表。
  3. 差异率低于阈值后,让低风险医院切到 v2。
  4. 入库同时保留原始报文、解析版本和字段差异。
  5. 回滚时只把路由切回 v1,已入库差异数据按审计记录修正。

10.2 订单库存扣减新逻辑

风险:重复扣减、超卖、事务补偿失败。

方案:

  1. 新旧版本共享幂等表和库存冻结记录。
  2. 写接口不允许从 v2 失败自动回退到 v1 重新执行,避免双扣。
  3. 超时后返回处理中,通过订单号查询事实。
  4. 灰度观察库存冻结成功率、扣减补偿率和订单最终状态。

10.3 ES搜索新索引灰度

风险:排序变化、召回下降、查询慢。

方案:

  1. MySQL仍是事实源,ES是搜索视图。
  2. 新索引使用新别名或版本索引。
  3. 小流量用户读新别名,同时记录查询词、命中文档、耗时和点击转化。
  4. 写入链路保持双写或 CDC 同步到两个索引。
  5. 回滚只切读别名,不删除旧索引,待确认后再清理。

十一、常见故障 Runbook

11.1 灰度流量没有进入新版本

排查顺序:

  1. Gateway 是否真的生成可信 X-Gray-Tag
  2. 路由 Predicate 是否匹配路径、方法、Header。
  3. Feign 或 RPC 是否透传标签。
  4. 调用方 LoadBalancer 候选实例是否包含 v2。
  5. v2 实例元数据 version 是否注册成功。
  6. 是否被健康、zone、权重或自定义过滤器过滤掉。
  7. 观测指标是否按版本打标签,避免其实进了但看不到。

11.2 5%灰度实际不是5%

先确认统计窗口和样本量。再查权重是入口层、客户端层还是Mesh层生效;是否存在HTTP/2长连接、调用方本地负载均衡、zone过滤、sticky会话、重试和慢实例。最终以版本维度 QPS、连接数、并发和业务请求数共同判断。

11.3 灰度用户串到稳定版本

重点查标签传播。Gateway、应用线程池、Feign拦截器、异步消息、RPC Filter 都可能丢标签。若跨线程使用 ThreadLocal,必须捕获、恢复和清理。若通过 MQ 继续处理,要把灰度版本写入消息头或消息体,并明确消费者是否应该继承灰度。

11.4 回滚后仍有 v2 流量

可能原因:

  1. Gateway、Envoy 或客户端本地缓存还没刷新。
  2. HTTP/2、gRPC 或连接池仍复用旧连接。
  3. 已进入 v2 的请求还没完成。
  4. 异步消息已由 v2 生产,消费者还在处理。
  5. 配置中心开关没有回滚。

处理方式是停止新流量、观察连接排空、必要时摘除 v2 实例、核对配置版本,并按业务事实处理异步副作用。

11.5 数据不兼容导致旧版本失败

先停止继续发布,不要急着删数据。检查最近 DDL、字段语义、缓存结构、事件 Schema 和配置。若旧代码无法读取新数据,要评估兼容补丁、数据修正脚本和只读保护。事故后必须补齐 Expand-Migrate-Contract 和契约测试。

十二、面试标准回答

text
微服务发布不能只理解为滚动替换实例。生产上要先保证API、数据库和消息事件向前向后兼容,
再通过Gateway、LoadBalancer或Service Mesh做入口灰度、版本路由和权重分流。

一次灰度请求通常先在Gateway根据可信用户、租户或规则生成灰度标签,然后服务间通过Feign
拦截器或RPC Filter透传标签;注册中心提供带metadata的实例列表,LoadBalancer按version、
zone、weight等规则过滤并选择实例,底层HTTP或RPC客户端才真正发请求。

灰度过程中要按版本观察QPS、错误率、P95/P99、业务成功率、重试、线程池、连接池、DB慢SQL
和MQ Lag。灰度比例不一定精确,因为样本量、长连接、本地负载均衡、zone过滤和重试都会影响
实际流量。回滚也不是简单改回镜像,还要处理配置、数据库、缓存、MQ Offset和外部副作用。

十三、关联知识点

知识点入口
Spring Cloud完整调用链服务调用链路
Gateway认证、限流和路由Spring Cloud Gateway
Spring Cloud负载均衡调用链与负载均衡
Service Mesh灰度Service Mesh
API与事件契约治理契约治理
微服务稳定性超时、重试、熔断和隔离
RT、P95和P99可观测性
MySQL与ES一致性ES同步与一致性