📖 开场:新菜品试吃
想象你是餐厅老板,推出新菜品 🍜:
方案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. 监控对比 │
│ - 成功率 │
│ - 响应时间 │
│ - 错误数 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了灰度发布系统的设计!🎊
核心要点:
- 流量切分:用户ID哈希,灰度百分比
- 版本路由:灰度用户 → V2,普通用户 → V1
- Ribbon负载均衡:自定义IRule,元数据标记版本
- 灰度管理:配置比例,逐步放量,一键回滚
- 监控对比:成功率、响应时间、错误数
下次面试,这样回答:
"灰度发布系统通过流量切分和版本路由实现。流量切分基于用户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⭐!