🎨 设计一个灰度发布系统:小心翼翼的试探!

29 阅读8分钟

📖 开场:新菜品试吃

想象你是餐厅老板,推出新菜品 🍜:

方案1:全量发布(冒险)

研发新菜品:鱼香肉丝2.0 🍲
    ↓
今天开始,所有顾客都吃新版
    ↓
结果:
- 90%顾客:好吃 ✅
- 10%顾客:太咸了 ❌
    ↓
但已经全量上线,来不及了 💀
    ↓
10%顾客流失 ❌

问题:
- 没有试错机会 ❌
- 风险太大 ❌

方案2:灰度发布(安全)

研发新菜品:鱼香肉丝2.0 🍲
    ↓
第1天:10%顾客试吃(灰度10%)
    ↓
观察反馈:
- 8个好评 ✅
- 2个说太咸 ⚠️
    ↓
调整配方,减少盐
    ↓
第2天:30%顾客试吃(灰度30%)
    ↓
反馈全是好评 ✅
    ↓
第3天:100%全量发布 ✅

结果:
- 风险可控 ✅
- 用户满意 ✅

这就是灰度发布:小范围试错,逐步放量!


🤔 为什么需要灰度发布?

问题:全量发布风险大 💀

新版本上线:
开发:代码没问题 ✅
测试:功能测试通过 ✅
    ↓
全量发布给所有用户
    ↓
线上发现严重Bug 💀
    ↓
影响所有用户 ❌
回滚也需要时间 ❌

结果:
- 用户投诉 ❌
- 品牌受损 ❌

有灰度发布

新版本上线:
    ↓
灰度5%用户
    ↓
观察24小时,发现Bug 🐛
    ↓
只影响5%用户 ⚠️
    ↓
立即回滚,修复Bug
    ↓
重新灰度,逐步放量 ✅

结果:
- 风险可控 ✅
- 用户影响小 ✅

🎯 核心设计

设计1:流量切分(基于用户ID)⭐⭐⭐

@Service
public class GrayReleaseService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String GRAY_CONFIG_KEY = "gray:config";
    
    /**
     * ⭐ 判断用户是否在灰度范围
     */
    public boolean isGrayUser(Long userId) {
        // 1. 获取灰度配置
        GrayConfig config = getGrayConfig();
        
        if (config == null || !config.isEnabled()) {
            return false;  // 灰度未开启
        }
        
        // ⭐ 2. 根据用户ID哈希,判断是否在灰度范围
        int hash = Math.abs(userId.hashCode() % 100);  // 0-99
        
        return hash < config.getGrayPercent();  // 灰度百分比
    }
    
    /**
     * 获取灰度配置(从Redis)
     */
    private GrayConfig getGrayConfig() {
        String json = redisTemplate.opsForValue().get(GRAY_CONFIG_KEY);
        if (json == null) {
            return null;
        }
        return JSON.parseObject(json, GrayConfig.class);
    }
}

/**
 * 灰度配置
 */
@Data
public class GrayConfig {
    private boolean enabled;  // 是否开启灰度
    private int grayPercent;  // 灰度百分比(0-100)
    private String version;  // 灰度版本号
}

设计2:版本路由 🚦

@RestController
@RequestMapping("/order")
public class OrderController {
    
    @Autowired
    private GrayReleaseService grayReleaseService;
    
    @Autowired
    private OrderServiceV1 orderServiceV1;  // 老版本
    
    @Autowired
    private OrderServiceV2 orderServiceV2;  // 新版本
    
    /**
     * ⭐ 创建订单(根据灰度规则路由到不同版本)
     */
    @PostMapping("/create")
    public Result<Order> createOrder(@RequestBody OrderRequest request,
                                    @RequestHeader("User-Id") Long userId) {
        // ⭐ 判断是否灰度用户
        if (grayReleaseService.isGrayUser(userId)) {
            // 灰度用户 → 新版本
            return Result.success(orderServiceV2.createOrder(request));
        } else {
            // 普通用户 → 老版本
            return Result.success(orderServiceV1.createOrder(request));
        }
    }
}

设计3:Spring Cloud灰度(基于Ribbon)⭐⭐⭐

自定义负载均衡规则

@Component
public class GrayLoadBalancer implements IRule {
    
    @Autowired
    private GrayReleaseService grayReleaseService;
    
    private ILoadBalancer lb;
    
    @Override
    public Server choose(Object key) {
        // 1. 获取所有可用服务
        List<Server> servers = lb.getAllServers();
        
        if (servers.isEmpty()) {
            return null;
        }
        
        // ⭐ 2. 获取当前用户ID
        Long userId = UserContext.getUserId();
        
        // ⭐ 3. 判断是否灰度用户
        boolean isGray = grayReleaseService.isGrayUser(userId);
        
        // 4. 过滤服务器
        List<Server> targetServers = servers.stream()
            .filter(server -> {
                String version = getVersion(server);
                if (isGray) {
                    // 灰度用户 → 灰度服务器(version=2.0)
                    return "2.0".equals(version);
                } else {
                    // 普通用户 → 正式服务器(version=1.0)
                    return "1.0".equals(version);
                }
            })
            .collect(Collectors.toList());
        
        // 5. 如果没有匹配的服务器,降级到所有服务器
        if (targetServers.isEmpty()) {
            targetServers = servers;
        }
        
        // 6. 随机选择一个
        int index = ThreadLocalRandom.current().nextInt(targetServers.size());
        return targetServers.get(index);
    }
    
    /**
     * 从元数据获取版本号
     */
    private String getVersion(Server server) {
        if (server instanceof DiscoveryEnabledServer) {
            DiscoveryEnabledServer discoveryServer = (DiscoveryEnabledServer) server;
            return discoveryServer.getInstanceInfo().getMetadata().get("version");
        }
        return "1.0";
    }
    
    @Override
    public void setLoadBalancer(ILoadBalancer lb) {
        this.lb = lb;
    }
    
    @Override
    public ILoadBalancer getLoadBalancer() {
        return lb;
    }
}

服务注册时标记版本

# application.yml(灰度服务器)
eureka:
  instance:
    metadata-map:
      version: 2.0  # ⭐ 灰度版本
      
spring:
  application:
    name: order-service
# application.yml(正式服务器)
eureka:
  instance:
    metadata-map:
      version: 1.0  # 正式版本
      
spring:
  application:
    name: order-service

设计4:灰度管理平台 🖥️

@RestController
@RequestMapping("/gray")
public class GrayController {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String GRAY_CONFIG_KEY = "gray:config";
    
    /**
     * ⭐ 更新灰度配置
     */
    @PostMapping("/config")
    public Result<Void> updateGrayConfig(@RequestBody GrayConfig config) {
        // 校验参数
        if (config.getGrayPercent() < 0 || config.getGrayPercent() > 100) {
            return Result.fail("灰度百分比必须在0-100之间");
        }
        
        // 保存到Redis
        String json = JSON.toJSONString(config);
        redisTemplate.opsForValue().set(GRAY_CONFIG_KEY, json);
        
        // 记录变更日志
        logGrayChange(config);
        
        return Result.success();
    }
    
    /**
     * 查询灰度配置
     */
    @GetMapping("/config")
    public Result<GrayConfig> getGrayConfig() {
        String json = redisTemplate.opsForValue().get(GRAY_CONFIG_KEY);
        if (json == null) {
            return Result.fail("灰度配置不存在");
        }
        
        GrayConfig config = JSON.parseObject(json, GrayConfig.class);
        return Result.success(config);
    }
    
    /**
     * ⭐ 灰度发布流程
     */
    @PostMapping("/release")
    public Result<Void> grayRelease(@RequestBody GrayReleaseRequest request) {
        // 1. 5%灰度
        updateGrayConfig(new GrayConfig(true, 5, request.getVersion()));
        
        // 2. 等待1小时,观察指标
        waitAndCheck(1);
        
        // 3. 30%灰度
        updateGrayConfig(new GrayConfig(true, 30, request.getVersion()));
        
        // 4. 等待1小时,观察指标
        waitAndCheck(1);
        
        // 5. 100%全量
        updateGrayConfig(new GrayConfig(true, 100, request.getVersion()));
        
        return Result.success();
    }
    
    /**
     * ⭐ 回滚
     */
    @PostMapping("/rollback")
    public Result<Void> rollback() {
        // 关闭灰度,全部流量走老版本
        GrayConfig config = new GrayConfig();
        config.setEnabled(false);
        
        String json = JSON.toJSONString(config);
        redisTemplate.opsForValue().set(GRAY_CONFIG_KEY, json);
        
        return Result.success();
    }
}

设计5:灰度监控 📊

@Service
public class GrayMonitorService {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    /**
     * ⭐ 记录版本访问量
     */
    public void recordAccess(String version, boolean success) {
        Counter counter = Counter.builder("gray.access")
            .tag("version", version)  // 版本号
            .tag("success", String.valueOf(success))  // 是否成功
            .register(meterRegistry);
        
        counter.increment();
    }
    
    /**
     * ⭐ 记录响应时间
     */
    public void recordResponseTime(String version, long responseTime) {
        Timer.builder("gray.response.time")
            .tag("version", version)
            .register(meterRegistry)
            .record(responseTime, TimeUnit.MILLISECONDS);
    }
    
    /**
     * 查询版本指标对比
     */
    public GrayMetrics compareVersions() {
        GrayMetrics metrics = new GrayMetrics();
        
        // V1指标
        metrics.setV1SuccessRate(getSuccessRate("1.0"));
        metrics.setV1AvgResponseTime(getAvgResponseTime("1.0"));
        
        // V2指标
        metrics.setV2SuccessRate(getSuccessRate("2.0"));
        metrics.setV2AvgResponseTime(getAvgResponseTime("2.0"));
        
        return metrics;
    }
    
    private double getSuccessRate(String version) {
        // 从Prometheus查询成功率
        // rate(gray_access{version="1.0",success="true"}[5m])
        return 0.0;  // 示例
    }
    
    private double getAvgResponseTime(String version) {
        // 从Prometheus查询平均响应时间
        return 0.0;  // 示例
    }
}

设计6:AB测试 🧪

@Service
public class ABTestService {
    
    /**
     * ⭐ AB测试:根据用户ID分组
     */
    public String getGroup(Long userId) {
        int hash = Math.abs(userId.hashCode() % 2);
        
        if (hash == 0) {
            return "A";  // 对照组(老版本)
        } else {
            return "B";  // 实验组(新版本)
        }
    }
    
    /**
     * 记录用户行为
     */
    public void recordBehavior(Long userId, String group, String action) {
        // 存储到数据分析平台
        // 例如:用户123,B组,下单成功
        analyticsService.track(userId, group, action);
    }
}

@RestController
@RequestMapping("/product")
public class ProductController {
    
    @Autowired
    private ABTestService abTestService;
    
    /**
     * ⭐ 商品详情页(AB测试)
     */
    @GetMapping("/{id}")
    public Result<ProductVO> getProduct(@PathVariable Long id,
                                       @RequestHeader("User-Id") Long userId) {
        // 获取用户分组
        String group = abTestService.getGroup(userId);
        
        ProductVO product;
        
        if ("A".equals(group)) {
            // A组:老版详情页
            product = productService.getProductV1(id);
        } else {
            // B组:新版详情页(增加推荐模块)
            product = productService.getProductV2(id);
        }
        
        // 记录曝光
        abTestService.recordBehavior(userId, group, "view_product");
        
        return Result.success(product);
    }
}

🎓 面试题速答

Q1: 什么是灰度发布?

A: 小范围试错,逐步放量

流程:
5%用户试用 → 观察24小时 → 无问题
    ↓
30%用户 → 观察24小时 → 无问题
    ↓
100%全量发布 ✅

优点:
- 风险可控 ✅
- 出问题影响小 ✅

Q2: 如何实现流量切分?

A: 用户ID哈希

public boolean isGrayUser(Long userId) {
    // 用户ID哈希到0-99
    int hash = Math.abs(userId.hashCode() % 100);
    
    // 灰度10%:hash < 10
    return hash < grayPercent;
}

Q3: Spring Cloud如何实现灰度?

A: 自定义Ribbon负载均衡

@Override
public Server choose(Object key) {
    Long userId = UserContext.getUserId();
    boolean isGray = grayService.isGrayUser(userId);
    
    // 过滤服务器
    List<Server> servers = allServers.stream()
        .filter(s -> {
            String version = getVersion(s);
            return isGray ? "2.0".equals(version) 
                         : "1.0".equals(version);
        })
        .collect(Collectors.toList());
    
    // 随机选择
    return servers.get(random.nextInt(servers.size()));
}

Q4: 灰度发布如何回滚?

A: 关闭灰度开关

@PostMapping("/rollback")
public Result<Void> rollback() {
    // 关闭灰度
    GrayConfig config = new GrayConfig();
    config.setEnabled(false);
    
    // 保存到Redis
    redisTemplate.set(GRAY_CONFIG_KEY, config);
    
    // 所有流量回到老版本 ✅
    return Result.success();
}

Q5: AB测试 vs 灰度发布?

A: 区别

维度灰度发布AB测试
目的验证稳定性验证效果
流量逐步放量(5%→30%→100%)固定分组(A组50%、B组50%)
时间短期(几天)长期(几周)
指标错误率、响应时间转化率、留存率

Q6: 如何监控灰度效果?

A: 关键指标

1. 成功率:
   V1: 99.9%
   V2: 99.8% ⚠️ (新版本偏低)

2. 响应时间:
   V1: 100ms
   V2: 150ms ⚠️ (新版本偏慢)

3. 错误数:
   V1: 10次/小时
   V2: 50次/小时ⅹ  (新版本有问题!)
   
决策:立即回滚 

🎬 总结

       灰度发布系统核心

┌────────────────────────────────────┐
│ 1. 流量切分 ⭐                      │
│    - 用户ID哈希                    │
│    - 灰度百分比                    │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 版本路由                        │
│    - 灰度用户 → V2                 │
│    - 普通用户 → V1                 │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. Ribbon负载均衡                  │
│    - 自定义IRule                   │
│    - 元数据标记版本                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 灰度管理平台                    │
│    - 配置灰度比例                  │
│    - 逐步放量                      │
│    - 一键回滚                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 监控对比                        │
│    - 成功率                        │
│    - 响应时间                      │
│    - 错误数                        │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了灰度发布系统的设计!🎊

核心要点

  1. 流量切分:用户ID哈希,灰度百分比
  2. 版本路由:灰度用户 → V2,普通用户 → V1
  3. Ribbon负载均衡:自定义IRule,元数据标记版本
  4. 灰度管理:配置比例,逐步放量,一键回滚
  5. 监控对比:成功率、响应时间、错误数

下次面试,这样回答

"灰度发布系统通过流量切分和版本路由实现。流量切分基于用户ID哈希,将用户ID对100取模得到0-99的hash值,如果hash值小于灰度百分比,则判定为灰度用户。灰度配置存储在Redis,包含是否开启、灰度百分比、版本号。

版本路由在Controller层判断用户是否灰度,灰度用户调用V2服务,普通用户调用V1服务。微服务场景使用Ribbon自定义负载均衡规则。服务注册时在元数据标记version,灰度服务器标记2.0,正式服务器标记1.0。负载均衡时从ThreadLocal获取用户ID,判断是否灰度用户,过滤出目标版本的服务器列表,随机选择一个。

灰度管理平台提供配置灰度比例的接口。典型流程是5%灰度观察24小时,无问题增加到30%,再观察24小时,最后100%全量。每次调整通过Redis更新配置,实时生效。如果发现问题,调用回滚接口关闭灰度开关,所有流量回到老版本。

监控对比新老版本的关键指标:成功率、平均响应时间、错误数。使用Micrometer在代码中打点,记录版本访问量和响应时间,数据上报Prometheus。Grafana展示对比图表,如果新版本成功率低于老版本,或错误数明显增加,自动告警并建议回滚。"

面试官:👍 "很好!你对灰度发布的设计理解很深刻!"


本文完 🎬

上一篇: 224-设计一个全链路压测系统.md
下一篇: 226-线上服务接口响应慢排查和优化.md

作者注:写完这篇,我觉得灰度发布太重要了!🎨
如果这篇文章对你有帮助,请给我一个Star⭐!