【Java项目技术亮点】灰度发布金丝雀发布

写在前面:说实话,我见过太多团队上线新版本,上来就是全量发布。结果凌晨两点接到电话,线上出 Bug 了,回滚要半小时,用户投诉满天飞。这个坑我踩过,而且不止一次。后来我们团队引入了灰度发布,新版本先给 5% 的用户用,观察没问题再扩大。今天把这套方案完整分享出来,看完你也能让上线变得"稳稳的幸福"。

文章目录

    • 一、为什么需要灰度发布?
      • [1.1 场景引入:全量发布的噩梦](#1.1 场景引入:全量发布的噩梦)
      • [1.2 生活类比:新药上市](#1.2 生活类比:新药上市)
      • [1.3 核心价值](#1.3 核心价值)
    • 二、灰度发布核心概念
      • [2.1 三种发布方式](#2.1 三种发布方式)
        • [金丝雀发布(Canary Release)](#金丝雀发布(Canary Release))
        • [蓝绿发布(Blue-Green Deployment)](#蓝绿发布(Blue-Green Deployment))
        • [滚动发布(Rolling Update)](#滚动发布(Rolling Update))
      • [2.2 三种发布方式对比](#2.2 三种发布方式对比)
    • 三、灰度发布的技术实现
      • [3.1 基于权重的流量分配](#3.1 基于权重的流量分配)
      • [3.2 基于用户特征的灰度](#3.2 基于用户特征的灰度)
      • [3.3 基于 Header 的灰度](#3.3 基于 Header 的灰度)
      • [3.4 灰度标记透传(全链路灰度)](#3.4 灰度标记透传(全链路灰度))
    • [四、Spring Cloud Gateway 灰度实现](#四、Spring Cloud Gateway 灰度实现)
      • [4.1 整体架构](#4.1 整体架构)
      • [4.2 Nacos 服务注册带上版本号](#4.2 Nacos 服务注册带上版本号)
      • [4.3 自定义 GrayFilter(Gateway 过滤器)](#4.3 自定义 GrayFilter(Gateway 过滤器))
      • [4.4 自定义负载均衡策略](#4.4 自定义负载均衡策略)
      • [4.5 灰度配置动态调整(Nacos 配置中心)](#4.5 灰度配置动态调整(Nacos 配置中心))
    • 五、灰度发布的监控与回滚
      • [5.1 灰度期间监控指标](#5.1 灰度期间监控指标)
      • [5.2 自动回滚策略](#5.2 自动回滚策略)
      • [5.3 灰度发布 SOP(标准操作流程)](#5.3 灰度发布 SOP(标准操作流程))
    • 六、踩坑指南
    • 七、问题与解答
      • [Q1:灰度发布和 AB 测试有什么区别?](#Q1:灰度发布和 AB 测试有什么区别?)
      • [Q2:灰度比例怎么定?10% 还是 5%?](#Q2:灰度比例怎么定?10% 还是 5%?)
      • Q3:灰度期间数据库怎么兼容?
    • 八、面试高频考点汇总
      • [考点 1:什么是灰度发布?和蓝绿发布有什么区别?](#考点 1:什么是灰度发布?和蓝绿发布有什么区别?)
      • [考点 2:你们项目中是怎么做灰度发布的?](#考点 2:你们项目中是怎么做灰度发布的?)
      • [考点 3:灰度发布中如何保证数据库兼容性?](#考点 3:灰度发布中如何保证数据库兼容性?)
      • [考点 4:Gateway 怎么实现按用户 ID 灰度?](#考点 4:Gateway 怎么实现按用户 ID 灰度?)
      • [考点 5:灰度流量标记怎么做到全链路透传?](#考点 5:灰度流量标记怎么做到全链路透传?)
    • 九、模拟面试官提问和参考答案
      • [场景题 1:设计一个支持多维度灰度的路由系统](#场景题 1:设计一个支持多维度灰度的路由系统)
      • [场景题 2:灰度期间发现 Bug,如何做到秒级回滚?](#场景题 2:灰度期间发现 Bug,如何做到秒级回滚?)
      • [场景题 3:如何实现灰度版本的 A/B 测试数据隔离?](#场景题 3:如何实现灰度版本的 A/B 测试数据隔离?)
      • [场景题 4:微服务架构中,Gateway 灰度了,但内部服务调用怎么保证一致性?](#场景题 4:微服务架构中,Gateway 灰度了,但内部服务调用怎么保证一致性?)
      • [场景题 5:如何设计一个灰度发布的配置中心,支持实时调整?](#场景题 5:如何设计一个灰度发布的配置中心,支持实时调整?)
    • 十、互动话题
    • 十一、参考资料

一、为什么需要灰度发布?

1.1 场景引入:全量发布的噩梦

想象这个场景:

复制代码
周五晚上 10 点,团队兴高采烈地全量发布了新版本。
10:05,监控告警:错误率飙升到 15%。
10:10,客服群里炸锅:用户反馈支付失败。
10:30,开始回滚,发现回滚脚本有问题...
凌晨 2 点,终于恢复,全员加班。

这不是段子,这是真实发生过的故事。全量发布的问题:

  • 一旦出问题,影响 100% 用户
  • 回滚时间长,故障窗口大
  • 测试环境测不出来的问题,生产环境暴露

1.2 生活类比:新药上市

新药研发出来后,能直接给所有人吃吗?当然不能。

复制代码
第一阶段:实验室验证
第二阶段:小规模临床试验(几十人)
第三阶段:大规模临床试验(几千人)
第四阶段:正式上市

灰度发布就是这个思路: 新版本先在少量用户身上"试用",观察有没有"副作用",没问题再扩大范围。

1.3 核心价值

价值点 说明
降低上线风险 出问题只影响少量用户
快速回滚 发现问题秒级切换回旧版本
收集真实反馈 生产环境的数据比测试环境真实 100 倍
验证性能 小流量验证新版本的性能表现
渐进式交付 大版本可以分多次灰度,降低单次变更量

二、灰度发布核心概念

2.1 三种发布方式

金丝雀发布(Canary Release)

名字来源于煤矿工人的金丝雀------矿工带金丝雀下井,鸟对有害气体敏感,鸟死了就说明有危险。

原理: 新版本部署少量实例,只承接一小部分流量,观察没问题后逐步增加流量比例。

复制代码
初始状态:旧版本 100%(10 台机器)
         
灰度阶段:旧版本 90%(9 台) + 新版本 10%(1 台)
         
扩大阶段:旧版本 50%(5 台) + 新版本 50%(5 台)
         
完成阶段:旧版本 0%(0 台) + 新版本 100%(10 台)
蓝绿发布(Blue-Green Deployment)

原理: 同时部署两套完全一样的环境(蓝环境和绿环境),一套对外提供服务,另一套 standby。发布时直接切换流量。

复制代码
蓝环境(v1.0):对外提供服务 ← 流量
绿环境(v2.0):standby,不接收流量

发布时:
蓝环境(v1.0):不接收流量
绿环境(v2.0):对外提供服务 ← 流量

回滚时:切回蓝环境,秒级完成
滚动发布(Rolling Update)

原理: 逐个替换实例,先停一台旧版本,启动一台新版本,依次进行。

复制代码
初始:A(v1) B(v1) C(v1) D(v1) E(v1)

第1轮:A(v2) B(v1) C(v1) D(v1) E(v1)
第2轮:A(v2) B(v2) C(v1) D(v1) E(v1)
第3轮:A(v2) B(v2) C(v2) D(v1) E(v1)
...
完成:A(v2) B(v2) C(v2) D(v2) E(v2)

2.2 三种发布方式对比

对比维度 金丝雀发布 蓝绿发布 滚动发布
资源成本 低(只需少量额外实例) 高(需要两套完整环境) 中(逐个替换,临时多一台)
回滚速度 快(调整权重即可) 极快(切流量即可) 慢(需要重新逐个替换)
风险程度 低(小流量验证) 中(全量切换,无验证期) 中(发布过程中新旧共存)
适用场景 大多数场景,尤其大版本 需要秒级回滚的金融场景 资源受限,不能双倍扩容
用户体验 部分用户用新版 全部用户同时切换 发布过程中新旧共存
数据库兼容 要求低(可逐步迁移) 要求高(瞬间切换) 要求中(渐进式)

我的建议:

  • 日常迭代:金丝雀发布(最常用)
  • 金融支付:蓝绿发布(回滚最快)
  • 资源紧张:滚动发布(K8s 默认方式)

三、灰度发布的技术实现

3.1 基于权重的流量分配

最基础的灰度方式:按百分比分配流量。

复制代码
灰度阶段 1:新版本 10% 流量
灰度阶段 2:新版本 30% 流量
灰度阶段 3:新版本 50% 流量
灰度阶段 4:新版本 100% 流量

每个阶段观察一段时间(比如 30 分钟),指标正常再进入下一阶段。

3.2 基于用户特征的灰度

按用户 ID 取模
java 复制代码
/**
 * 基于用户 ID 取模的灰度策略
 */
public class UserIdGrayStrategy {
    
    /**
     * 判断当前用户是否命中灰度
     * @param userId 用户 ID
     * @param grayRatio 灰度比例(0-100)
     */
    public boolean isGray(String userId, int grayRatio) {
        // 将 userId 哈希后取模
        int hash = Math.abs(userId.hashCode() % 100);
        return hash < grayRatio;
    }
}

// 使用示例
public class OrderService {
    
    @Autowired
    private UserIdGrayStrategy grayStrategy;
    
    public void createOrder(String userId, OrderRequest request) {
        if (grayStrategy.isGray(userId, 10)) {
            // 10% 的用户走新版本逻辑
            createOrderV2(request);
        } else {
            // 90% 的用户走旧版本逻辑
            createOrderV1(request);
        }
    }
}

优点: 同一个用户永远命中同一版本,体验一致。

按地域灰度
java 复制代码
/**
 * 按地域灰度:先给北京用户上新版本
 */
public class RegionGrayStrategy {
    
    private Set<String> grayRegions = new HashSet<>(Arrays.asList("北京", "上海"));
    
    public boolean isGray(String region) {
        return grayRegions.contains(region);
    }
}
按设备类型灰度
java 复制代码
/**
 * 按设备类型灰度:先给 iOS 用户上新功能
 */
public class DeviceGrayStrategy {
    
    public boolean isGray(String deviceType, String appVersion) {
        // iOS 且版本号 >= 5.0.0 的用户命中灰度
        return "iOS".equals(deviceType) 
            && compareVersion(appVersion, "5.0.0") >= 0;
    }
}

3.3 基于 Header 的灰度

在请求头中带上灰度标记,网关根据 Header 路由。

http 复制代码
GET /api/order/create HTTP/1.1
Host: api.example.com
X-Gray-Version: v2.0.0
User-Id: 12345

3.4 灰度标记透传(全链路灰度)

这是最容易被忽略的点。灰度流量从 Gateway 进入后,要经过多个微服务,最终可能落到数据库。如果中间某个服务没透传灰度标记,流量就会"迷路"。

复制代码
用户请求
    │
    ▼
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   Gateway   │───→│  Order服务  │───→│  Pay服务    │───→│   DB(影子) │
│  X-Gray=v2  │    │  X-Gray=v2  │    │  X-Gray=v2  │    │  灰度库      │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
         │
         │ 如果 Order 服务没透传 Header
         ▼
      Pay 服务收不到灰度标记 → 走到旧版本 → 数据写错库!

透传方案:

java 复制代码
/**
 * 灰度上下文:用 ThreadLocal 透传灰度标记
 */
public class GrayContext {
    
    private static final ThreadLocal<String> GRAY_VERSION = new ThreadLocal<>();
    
    public static void setGrayVersion(String version) {
        GRAY_VERSION.set(version);
    }
    
    public static String getGrayVersion() {
        return GRAY_VERSION.get();
    }
    
    public static void clear() {
        GRAY_VERSION.remove();
    }
    
    /**
     * 判断是否命中灰度
     */
    public static boolean isGray() {
        return getGrayVersion() != null;
    }
}
java 复制代码
/**
 * Feign 拦截器:自动透传灰度标记
 */
@Component
public class GrayFeignInterceptor implements RequestInterceptor {
    
    @Override
    public void apply(RequestTemplate template) {
        String grayVersion = GrayContext.getGrayVersion();
        if (grayVersion != null) {
            template.header("X-Gray-Version", grayVersion);
        }
    }
}
java 复制代码
/**
 * 网关过滤器:解析灰度标记并放入上下文
 */
@Component
public class GrayGatewayFilter implements GlobalFilter, Ordered {
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String grayVersion = exchange.getRequest()
            .getHeaders()
            .getFirst("X-Gray-Version");
        
        if (grayVersion != null) {
            GrayContext.setGrayVersion(grayVersion);
        }
        
        return chain.filter(exchange)
            .doFinally(signalType -> GrayContext.clear());
    }
    
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

四、Spring Cloud Gateway 灰度实现

4.1 整体架构

复制代码
                    ┌─────────────────┐
                    │   Nacos 注册中心  │
                    │  (服务 + 配置)    │
                    └────────┬────────┘
                             │
        ┌────────────────────┼────────────────────┐
        │                    │                    │
        ▼                    ▼                    ▼
┌───────────────┐    ┌───────────────┐    ┌───────────────┐
│  Order-v1.0   │    │  Order-v1.0   │    │  Order-v2.0   │
│  metadata:    │    │  metadata:    │    │  metadata:    │
│  version=1.0  │    │  version=1.0  │    │  version=2.0  │
└───────────────┘    └───────────────┘    └───────────────┘
        ▲                    ▲                    ▲
        │                    │                    │
        └────────────────────┼────────────────────┘
                             │
                    ┌────────┴────────┐
                    │  Spring Cloud   │
                    │    Gateway      │
                    │  (灰度路由决策)  │
                    └─────────────────┘

4.2 Nacos 服务注册带上版本号

yaml 复制代码
# application.yml
spring:
  application:
    name: order-service
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
        metadata:
          version: 2.0.0  # 关键:带上版本号
java 复制代码
/**
 * 启动时动态设置版本号(可以从配置中心读取)
 */
@Component
public class NacosMetadataInitializer implements ApplicationRunner {
    
    @Autowired
    private NacosRegistration registration;
    
    @Value("${app.version:1.0.0}")
    private String version;
    
    @Override
    public void run(ApplicationArguments args) {
        Map<String, String> metadata = new HashMap<>();
        metadata.put("version", version);
        registration.setMetadata(metadata);
        System.out.println("服务注册成功,版本号:" + version);
    }
}

4.3 自定义 GrayFilter(Gateway 过滤器)

java 复制代码
/**
 * 灰度路由过滤器:根据灰度策略决定路由到哪个版本
 */
@Component
public class GrayFilter implements GlobalFilter, Ordered {
    
    @Autowired
    private NacosDiscoveryClient discoveryClient;
    
    @Autowired
    private GrayProperties grayProperties;
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        // 1. 判断当前请求是否命中灰度
        boolean isGray = checkGray(request);
        
        // 2. 构建带版本标记的请求
        ServerHttpRequest newRequest = request.mutate()
            .header("X-Gray-Route", isGray ? "v2" : "v1")
            .build();
        
        // 3. 放入上下文,供负载均衡器使用
        exchange.getAttributes().put("gray.route", isGray ? "v2" : "v1");
        
        return chain.filter(exchange.mutate().request(newRequest).build());
    }
    
    /**
     * 灰度判断逻辑
     */
    private boolean checkGray(ServerHttpRequest request) {
        // 优先级1:Header 强制指定
        String forceGray = request.getHeaders().getFirst("X-Gray-Force");
        if ("true".equals(forceGray)) {
            return true;
        }
        
        // 优先级2:用户 ID 取模
        String userId = request.getHeaders().getFirst("User-Id");
        if (userId != null) {
            int hash = Math.abs(userId.hashCode() % 100);
            return hash < grayProperties.getRatio();
        }
        
        // 优先级3:Cookie 标记
        HttpCookie grayCookie = request.getCookies().getFirst("gray_version");
        if (grayCookie != null && "v2".equals(grayCookie.getValue())) {
            return true;
        }
        
        return false;
    }
    
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 100;
    }
}

4.4 自定义负载均衡策略

java 复制代码
/**
 * 灰度负载均衡器:根据版本号选择实例
 */
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
    
    private ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
    private String serviceId;
    
    public GrayLoadBalancer(ObjectProvider<ServiceInstanceListSupplier> provider, 
                           String serviceId) {
        this.serviceInstanceListSupplierProvider = provider;
        this.serviceId = serviceId;
    }
    
    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider
            .getIfAvailable(() -> new DefaultServiceInstanceListSupplier());
        
        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 grayRoute = "v1";  // 默认走 v1
        if (request.getContext() instanceof GrayRequestContext) {
            grayRoute = ((GrayRequestContext) request.getContext()).getGrayRoute();
        }
        
        // 筛选对应版本的实例
        String targetVersion = "v2".equals(grayRoute) ? "2.0.0" : "1.0.0";
        List<ServiceInstance> targetInstances = instances.stream()
            .filter(inst -> targetVersion.equals(
                inst.getMetadata().get("version")))
            .collect(Collectors.toList());
        
        // 如果目标版本没有实例,降级到旧版本
        if (targetInstances.isEmpty()) {
            targetInstances = instances;
        }
        
        // 随机选择一个实例
        int index = ThreadLocalRandom.current().nextInt(targetInstances.size());
        return new DefaultResponse(targetInstances.get(index));
    }
}

4.5 灰度配置动态调整(Nacos 配置中心)

yaml 复制代码
# gray-config.yaml(Nacos 配置中心)
gray:
  enabled: true
  ratio: 10           # 灰度比例 10%
  force-users:        # 强制灰度用户
    - "user_001"
    - "user_002"
  force-regions:      # 强制灰度地域
    - "北京"
  white-list:         # 白名单(永远不走灰度)
    - "admin"
java 复制代码
/**
 * 灰度配置类:支持 Nacos 动态刷新
 */
@Data
@Component
@ConfigurationProperties(prefix = "gray")
@NacosConfigurationProperties(dataId = "gray-config.yaml", autoRefreshed = true)
public class GrayProperties {
    
    private boolean enabled = false;
    private int ratio = 0;
    private List<String> forceUsers = new ArrayList<>();
    private List<String> forceRegions = new ArrayList<>();
    private List<String> whiteList = new ArrayList<>();
    
    /**
     * 判断用户是否强制灰度
     */
    public boolean isForceGray(String userId, String region) {
        if (whiteList.contains(userId)) {
            return false;
        }
        return forceUsers.contains(userId) || forceRegions.contains(region);
    }
}

五、灰度发布的监控与回滚

5.1 灰度期间监控指标

灰度不是发完就完事儿了,必须盯着指标看。

监控维度 核心指标 告警阈值
系统层面 错误率、响应时间 P99、QPS 错误率 > 0.5%
业务层面 订单转化率、支付成功率 比基线下降 > 5%
资源层面 CPU、内存、GC 频率 CPU > 70%
用户体验 页面加载时间、接口超时率 P99 > 2s
java 复制代码
/**
 * 灰度监控切面:自动收集灰度版本的指标
 */
@Aspect
@Component
public class GrayMetricsAspect {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public Object recordMetrics(ProceedingJoinPoint point) throws Throwable {
        String version = GrayContext.isGray() ? "v2" : "v1";
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            Object result = point.proceed();
            // 记录成功指标
            meterRegistry.counter("api.success", "version", version).increment();
            return result;
        } catch (Exception e) {
            // 记录失败指标
            meterRegistry.counter("api.error", "version", version, 
                "exception", e.getClass().getSimpleName()).increment();
            throw e;
        } finally {
            sample.stop(meterRegistry.timer("api.latency", "version", version));
        }
    }
}

5.2 自动回滚策略

java 复制代码
/**
 * 灰度自动回滚检测器
 */
@Component
public class AutoRollbackChecker {
    
    @Autowired
    private GrayProperties grayProperties;
    
    @Autowired
    private NacosConfigManager configManager;
    
    /**
     * 定时检查灰度指标,异常时自动回滚
     */
    @Scheduled(fixedRate = 60000)  // 每分钟检查一次
    public void checkAndRollback() {
        if (!grayProperties.isEnabled()) {
            return;
        }
        
        // 获取 v2 版本的错误率
        double v2ErrorRate = getErrorRate("v2");
        double v1ErrorRate = getErrorRate("v1");
        
        // 如果灰度版本错误率比旧版本高 1% 以上,自动回滚
        if (v2ErrorRate - v1ErrorRate > 0.01) {
            System.err.println("【告警】灰度版本错误率异常,触发自动回滚!");
            System.err.println("v2 错误率:" + v2ErrorRate + ",v1 错误率:" + v1ErrorRate);
            
            rollback();
        }
    }
    
    private void rollback() {
        // 将灰度比例调整为 0
        grayProperties.setRatio(0);
        grayProperties.setEnabled(false);
        
        // 发送告警通知
        sendAlert("灰度版本已自动回滚,请排查问题");
    }
    
    private double getErrorRate(String version) {
        // 从 Prometheus / Micrometer 查询指标
        // 实际实现省略...
        return 0.0;
    }
    
    private void sendAlert(String message) {
        // 发送钉钉/企业微信告警
    }
}

5.3 灰度发布 SOP(标准操作流程)

复制代码
【灰度发布 SOP】

Step 1:发布前准备
  □ 代码 Review 完成
  □ 测试用例全部通过
  □ 数据库变更脚本准备就绪
  □ 回滚方案确认
  □ 监控大盘检查正常

Step 2:灰度发布
  □ 部署 1 台新版本实例
  □ 配置灰度比例 5%
  □ 观察 15 分钟,检查错误率、响应时间
  □ 无异常 → 扩大到 20%
  □ 观察 30 分钟
  □ 无异常 → 扩大到 50%
  □ 观察 30 分钟
  □ 无异常 → 扩大到 100%

Step 3:发布后观察
  □ 全量发布后观察 1 小时
  □ 核心业务指标正常
  □ 旧版本实例保留 24 小时(应急回滚)

Step 4:清理
  □ 24 小时后下线旧版本实例
  □ 更新发布文档

六、踩坑指南

踩坑提醒 1:数据库 schema 兼容性

新旧版本共用数据库,这是灰度发布最大的坑。如果新版本加了字段,旧版本代码插入数据时会报错。我的做法:

  • 新增字段必须允许 NULL,且有默认值
  • 先发布数据库变更,观察没问题再发布代码
  • 删除字段要分两步:先停用到删除,间隔至少一个发布周期
sql 复制代码
-- 正确的字段新增方式
ALTER TABLE orders ADD COLUMN new_field VARCHAR(100) NULL DEFAULT '';

-- 错误的字段新增方式(旧版本会报错)
ALTER TABLE orders ADD COLUMN new_field VARCHAR(100) NOT NULL;

踩坑提醒 2:缓存兼容性

新旧版本缓存格式不同,会导致反序列化失败。我见过一个项目,新版本把 User 对象加了字段,旧版本从缓存读出来反序列化直接抛异常。解决方案:

  • 缓存 Key 带版本号:user:v2:12345
  • 或者使用 Protobuf / JSON 等向前兼容的序列化方式
java 复制代码
/**
 * 带版本号的缓存 Key 生成器
 */
@Component
public class VersionedCacheKeyGenerator {
    
    @Value("${app.version:1}")
    private String version;
    
    public String generate(String prefix, String id) {
        return prefix + ":v" + version + ":" + id;
    }
}

踩坑提醒 3:MQ 消息格式兼容性

生产者发新格式消息,消费者还是旧版本,消费失败导致消息堆积。解决方案:

  • 消息加版本号 Header
  • 消费者根据版本号路由到不同的处理方法
  • 或者新旧格式共存一段时间
java 复制代码
/**
 * 兼容多版本的消息消费
 */
@RocketMQMessageListener(topic = "order-topic", consumerGroup = "order-consumer")
public class OrderMessageConsumer implements RocketMQListener<MessageExt> {
    
    @Override
    public void onMessage(MessageExt message) {
        String version = message.getUserProperty("message.version");
        String body = new String(message.getBody());
        
        if ("2.0".equals(version)) {
            handleV2(body);
        } else {
            handleV1(body);  // 兼容旧版本
        }
    }
}

踩坑提醒 4:灰度流量标记丢失

这是最容易被忽略的问题。Gateway 带了灰度标记,但异步调用(@Async、CompletableFuture、MQ)时 ThreadLocal 会丢失。解决方案:

  • 异步调用前手动传递灰度标记
  • 或者使用 TransmittableThreadLocal(TTL)
java 复制代码
// 错误示例:ThreadLocal 在异步线程中丢失
@Async
public void asyncProcess() {
    // 这里 GrayContext.getGrayVersion() 返回 null!
}

// 正确示例:使用 TTL 透传
public void asyncProcessCorrect() {
    String grayVersion = GrayContext.getGrayVersion();
    
    CompletableFuture.runAsync(() -> {
        GrayContext.setGrayVersion(grayVersion);
        try {
            // 业务逻辑
        } finally {
            GrayContext.clear();
        }
    });
}

七、问题与解答

Q1:灰度发布和 AB 测试有什么区别?

A: 两者很像,但目的不同。

维度 灰度发布 AB 测试
目的 降低上线风险 验证哪个方案更好
流量分配 逐步扩大 固定比例(通常 50:50)
持续时间 短期,直到全量 长期,直到有统计显著性
回滚 发现问题立即回滚 不会回滚,只是收集数据
技术实现 可以相同 可以相同

一句话: 灰度发布是为了"安全上线",AB 测试是为了"验证效果"。

Q2:灰度比例怎么定?10% 还是 5%?

A: 没有标准答案,取决于业务和风险承受能力。

我的建议:

  • 第一次灰度:1%-5%(最小可用流量)
  • 常规迭代:10%-20%
  • 大版本重构:5%-10%,每阶段观察时间拉长到 1 小时
  • 紧急 Bug 修复:可以直接 50%,因为变更范围小

关键原则: 宁可保守一点,也不要冒险。

Q3:灰度期间数据库怎么兼容?

A: 这是灰度发布最头疼的问题。核心原则是"先兼容,后清理"。

具体做法:

  1. 新增字段: 允许 NULL,旧版本不填,新版本填
  2. 删除字段: 先代码里不用(一个发布周期),再删字段(下一个周期)
  3. 改字段类型: 几乎不要直接改,新增一个字段,双写一段时间,再切换
  4. 索引变更: 优先加索引,删索引要确认没用到
sql 复制代码
-- 发布前:加字段(兼容旧版本)
ALTER TABLE orders ADD COLUMN new_status INT NULL DEFAULT 0;

-- 灰度期间:新旧版本共存
-- v1 代码:INSERT INTO orders (old_field) VALUES (...)
-- v2 代码:INSERT INTO orders (old_field, new_status) VALUES (...)

-- 全量后:旧版本下线,可以清理兼容代码

八、面试高频考点汇总

考点 1:什么是灰度发布?和蓝绿发布有什么区别?

答案:

灰度发布是指新版本上线时,先让一小部分用户使用,观察没问题后再逐步扩大范围,直到全量。

和蓝绿发布的区别:

  • 灰度发布: 新旧版本同时运行,按流量比例分配。资源占用少,可以小流量验证。
  • 蓝绿发布: 同时部署两套完整环境,瞬间切换流量。回滚最快,但资源占用翻倍。

适用场景:

  • 灰度:大多数日常发布
  • 蓝绿:金融支付等对回滚速度要求极高的场景

考点 2:你们项目中是怎么做灰度发布的?

答案:

我在 XX 项目中基于 Spring Cloud Gateway + Nacos 实现了灰度发布,核心方案:

  1. 服务注册: 每个服务实例在 Nacos 注册时带上 version 元数据
  2. 网关路由: Gateway 自定义 GrayFilter,根据用户 ID 取模判断灰度
  3. 负载均衡: 自定义 GrayLoadBalancer,根据灰度标记选择对应版本实例
  4. 全链路透传: ThreadLocal + Feign 拦截器,确保灰度标记透传到所有微服务
  5. 动态配置: 灰度比例放在 Nacos 配置中心,支持实时调整
  6. 监控回滚: 基于 Prometheus + Grafana 监控,错误率异常自动回滚

考点 3:灰度发布中如何保证数据库兼容性?

答案:

核心原则是"先兼容,后清理",具体做法:

  1. 新增字段: 允许 NULL,旧版本不填
  2. 删除字段: 先代码里弃用(一个发布周期),再删字段
  3. 改字段类型: 新增字段双写,切换后再删旧字段
  4. 索引: 优先加,删索引要确认没用到

数据库变更要领先代码发布至少一个周期。

考点 4:Gateway 怎么实现按用户 ID 灰度?

答案:

java 复制代码
// 1. Gateway 过滤器解析用户 ID
String userId = request.getHeaders().getFirst("User-Id");
int hash = Math.abs(userId.hashCode() % 100);
boolean isGray = hash < grayRatio;

// 2. 将灰度标记放入请求头
request.mutate().header("X-Gray-Route", isGray ? "v2" : "v1");

// 3. 自定义负载均衡器选择实例
List<ServiceInstance> instances = // 从注册中心获取
instances.stream()
    .filter(inst -> targetVersion.equals(inst.getMetadata().get("version")))
    .collect(Collectors.toList());

考点 5:灰度流量标记怎么做到全链路透传?

答案:

  1. Gateway 层: 解析请求,设置 ThreadLocal
  2. 同步调用: Feign / RestTemplate 拦截器从 ThreadLocal 读取并放入 Header
  3. 异步调用: 使用 TransmittableThreadLocal(TTL)替代普通 ThreadLocal
  4. MQ: 消息发送时把灰度标记放入消息属性,消费时读取
java 复制代码
// Feign 拦截器透传
public class GrayFeignInterceptor implements RequestInterceptor {
    public void apply(RequestTemplate template) {
        String gray = GrayContext.getGrayVersion();
        if (gray != null) {
            template.header("X-Gray-Version", gray);
        }
    }
}

九、模拟面试官提问和参考答案

场景题 1:设计一个支持多维度灰度的路由系统

题目: 需要同时支持按用户 ID、地域、设备类型、业务线等多个维度做灰度,不同维度之间可以有"与/或"关系。比如:"北京的用户"且"iOS 设备"才命中灰度。怎么设计?

参考答案:

java 复制代码
/**
 * 灰度规则接口
 */
public interface GrayRule {
    boolean match(GrayContext context);
}

/**
 * 用户 ID 规则
 */
public class UserIdRule implements GrayRule {
    private int ratio;
    
    @Override
    public boolean match(GrayContext context) {
        int hash = Math.abs(context.getUserId().hashCode() % 100);
        return hash < ratio;
    }
}

/**
 * 地域规则
 */
public class RegionRule implements GrayRule {
    private Set<String> regions;
    
    @Override
    public boolean match(GrayContext context) {
        return regions.contains(context.getRegion());
    }
}

/**
 * 组合规则:支持 AND / OR
 */
public class CompositeRule implements GrayRule {
    private List<GrayRule> rules;
    private Operator operator;  // AND / OR
    
    @Override
    public boolean match(GrayContext context) {
        if (operator == Operator.AND) {
            return rules.stream().allMatch(r -> r.match(context));
        } else {
            return rules.stream().anyMatch(r -> r.match(context));
        }
    }
    
    enum Operator { AND, OR }
}

/**
 * 规则引擎:从配置加载规则
 */
@Component
public class GrayRuleEngine {
    
    private GrayRule rootRule;
    
    @PostConstruct
    public void init() {
        // 从 Nacos / 数据库加载规则配置
        // 示例:北京 AND iOS AND 10% 用户
        rootRule = new CompositeRule(Arrays.asList(
            new RegionRule(new HashSet<>(Arrays.asList("北京"))),
            new DeviceRule(new HashSet<>(Arrays.asList("iOS"))),
            new UserIdRule(10)
        ), CompositeRule.Operator.AND);
    }
    
    public boolean isGray(GrayContext context) {
        return rootRule.match(context);
    }
}

场景题 2:灰度期间发现 Bug,如何做到秒级回滚?

题目: 新版本灰度到 50% 时,发现严重 Bug,要求 10 秒内切回旧版本。怎么设计?

参考答案:

方案一:Gateway 层切流量(最快)

java 复制代码
@RestController
public class EmergencyController {
    
    @Autowired
    private GrayProperties grayProperties;
    
    /**
     * 紧急回滚接口:一键切回旧版本
     */
    @PostMapping("/emergency/rollback")
    public String rollback() {
        grayProperties.setRatio(0);
        grayProperties.setEnabled(false);
        
        // 刷新 Gateway 路由
        // 所有流量立即走旧版本
        return "回滚完成,当前灰度比例:0%";
    }
}

方案二:Nacos 元数据动态调整

java 复制代码
// 把新版本实例的权重设为 0
public void rollbackByWeight() {
    List<Instance> instances = namingService
        .getAllInstances("order-service");
    
    for (Instance inst : instances) {
        if ("2.0.0".equals(inst.getMetadata().get("version"))) {
            inst.setWeight(0);  // 权重设为 0,不再接收流量
            namingService.registerInstance("order-service", inst);
        }
    }
}

方案三:蓝绿发布(最快,但资源成本高)

直接切 DNS 或 SLB 到蓝环境(旧版本),秒级完成。

场景题 3:如何实现灰度版本的 A/B 测试数据隔离?

题目: 灰度期间需要对比新旧版本的转化率,但数据不能混在一起。怎么设计?

参考答案:

java 复制代码
/**
 * 灰度数据隔离:按版本号分表 / 分库
 */
@Service
public class GrayDataService {
    
    /**
     * 动态选择数据源
     */
    public void saveOrder(Order order) {
        if (GrayContext.isGray()) {
            // 灰度版本写入影子表
            order.setTableSuffix("_v2");
            orderDao.saveToShadowTable(order);
        } else {
            // 旧版本写入正常表
            orderDao.save(order);
        }
    }
    
    /**
     * 对比指标
     */
    public void compareMetrics() {
        // v1 转化率
        double v1Rate = orderDao.calculateConversionRate("orders");
        // v2 转化率
        double v2Rate = orderDao.calculateConversionRate("orders_v2");
        
        System.out.println("v1 转化率:" + v1Rate);
        System.out.println("v2 转化率:" + v2Rate);
    }
}

更简单的方案: 数据不隔离,但在记录上加 version 字段,查询时按 version 分组统计。

sql 复制代码
ALTER TABLE orders ADD COLUMN app_version VARCHAR(10);

-- 统计对比
SELECT app_version, 
       COUNT(*) as total,
       SUM(CASE WHEN status='PAID' THEN 1 ELSE 0 END) as paid_count,
       SUM(CASE WHEN status='PAID' THEN 1 ELSE 0 END) * 1.0 / COUNT(*) as conversion_rate
FROM orders 
WHERE created_at > '2024-01-01'
GROUP BY app_version;

场景题 4:微服务架构中,Gateway 灰度了,但内部服务调用怎么保证一致性?

题目: 用户请求走 Gateway 命中了灰度,但内部服务 A 调用服务 B 时,B 怎么知道这是灰度流量?

参考答案:

核心方案:全链路灰度标记透传

复制代码
用户请求 → Gateway(标记 X-Gray=v2)
              │
              ▼
         Order服务(接收 X-Gray=v2,放入 ThreadLocal)
              │
              ├── Feign 调用 Pay服务
              │       │
              │       └── Feign 拦截器:从 ThreadLocal 读取 X-Gray,放入 Header
              │               │
              │               ▼
              │           Pay服务(接收 X-Gray=v2)
              │               │
              │               └── 写入影子数据库
              │
              └── MQ 发送消息
                      │
                      └── 消息属性带上 X-Gray=v2
                              │
                              ▼
                          消费者读取 X-Gray,写入影子库
java 复制代码
/**
 * 全链路灰度上下文
 */
public class GrayTraceContext {
    
    private static final ThreadLocal<Map<String, String>> CONTEXT = 
        new ThreadLocal<>();
    
    public static void set(String key, String value) {
        Map<String, String> map = CONTEXT.get();
        if (map == null) {
            map = new HashMap<>();
            CONTEXT.set(map);
        }
        map.put(key, value);
    }
    
    public static String get(String key) {
        Map<String, String> map = CONTEXT.get();
        return map != null ? map.get(key) : null;
    }
    
    public static Map<String, String> getAll() {
        return CONTEXT.get() != null ? new HashMap<>(CONTEXT.get()) : new HashMap<>();
    }
    
    public static void clear() {
        CONTEXT.remove();
    }
}

场景题 5:如何设计一个灰度发布的配置中心,支持实时调整?

题目: 运营同学需要在后台界面实时调整灰度比例,不需要重启服务。怎么设计?

参考答案:

java 复制代码
/**
 * 灰度配置中心:支持动态刷新
 */
@Component
public class GrayConfigCenter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String GRAY_CONFIG_KEY = "gray:config";
    
    /**
     * 从 Redis 加载配置(支持多服务共享)
     */
    public GrayConfig loadConfig() {
        String configJson = redisTemplate.opsForValue().get(GRAY_CONFIG_KEY);
        if (configJson == null) {
            return GrayConfig.defaultConfig();
        }
        return JSON.parseObject(configJson, GrayConfig.class);
    }
    
    /**
     * 更新配置(后台管理界面调用)
     */
    public void updateConfig(GrayConfig config) {
        redisTemplate.opsForValue().set(GRAY_CONFIG_KEY, JSON.toJSONString(config));
        // 发布配置变更事件,各服务监听刷新
        redisTemplate.convertAndSend("gray:config:changed", "refresh");
    }
}

/**
 * 配置监听器:实时刷新本地缓存
 */
@Component
public class GrayConfigListener implements MessageListener {
    
    @Autowired
    private GrayProperties grayProperties;
    
    @Autowired
    private GrayConfigCenter configCenter;
    
    @Override
    public void onMessage(Message message, byte[] pattern) {
        // 收到配置变更通知,重新加载
        GrayConfig config = configCenter.loadConfig();
        grayProperties.refresh(config);
        System.out.println("灰度配置已刷新:" + config);
    }
}

配置数据结构:

json 复制代码
{
  "enabled": true,
  "ratio": 15,
  "rules": [
    {
      "type": "userId",
      "ratio": 15
    },
    {
      "type": "region",
      "values": ["北京", "上海"]
    }
  ],
  "forceUsers": ["user_001"],
  "whiteList": ["admin"],
  "updatedAt": "2024-01-15T10:30:00"
}

十、互动话题

你们团队现在是怎么做发布的?是"周五晚上全量发布然后祈祷不出问题",还是已经有灰度机制了?有没有因为没做灰度而踩过大坑?评论区聊聊,点赞最高的送《微服务设计》电子版!


十一、参考资料

  1. Nacos 官方文档 - 服务元数据
  2. Spring Cloud Gateway 官方文档 - 自定义过滤器