写在前面:说实话,我见过太多团队上线新版本,上来就是全量发布。结果凌晨两点接到电话,线上出 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 三种发布方式对比)
- 三、灰度发布的技术实现
- [四、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: 这是灰度发布最头疼的问题。核心原则是"先兼容,后清理"。
具体做法:
- 新增字段: 允许 NULL,旧版本不填,新版本填
- 删除字段: 先代码里不用(一个发布周期),再删字段(下一个周期)
- 改字段类型: 几乎不要直接改,新增一个字段,双写一段时间,再切换
- 索引变更: 优先加索引,删索引要确认没用到
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 实现了灰度发布,核心方案:
- 服务注册: 每个服务实例在 Nacos 注册时带上
version元数据 - 网关路由: Gateway 自定义 GrayFilter,根据用户 ID 取模判断灰度
- 负载均衡: 自定义 GrayLoadBalancer,根据灰度标记选择对应版本实例
- 全链路透传: ThreadLocal + Feign 拦截器,确保灰度标记透传到所有微服务
- 动态配置: 灰度比例放在 Nacos 配置中心,支持实时调整
- 监控回滚: 基于 Prometheus + Grafana 监控,错误率异常自动回滚
考点 3:灰度发布中如何保证数据库兼容性?
答案:
核心原则是"先兼容,后清理",具体做法:
- 新增字段: 允许 NULL,旧版本不填
- 删除字段: 先代码里弃用(一个发布周期),再删字段
- 改字段类型: 新增字段双写,切换后再删旧字段
- 索引: 优先加,删索引要确认没用到
数据库变更要领先代码发布至少一个周期。
考点 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:灰度流量标记怎么做到全链路透传?
答案:
- Gateway 层: 解析请求,设置 ThreadLocal
- 同步调用: Feign / RestTemplate 拦截器从 ThreadLocal 读取并放入 Header
- 异步调用: 使用 TransmittableThreadLocal(TTL)替代普通 ThreadLocal
- 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"
}
十、互动话题
你们团队现在是怎么做发布的?是"周五晚上全量发布然后祈祷不出问题",还是已经有灰度机制了?有没有因为没做灰度而踩过大坑?评论区聊聊,点赞最高的送《微服务设计》电子版!