设计一个灰度发布系统:流量切分、用户分组、快速回滚
设计一个灰度发布系统:流量切分、用户分组、快速回滚
适读人群:Java中高级工程师、需要做发布风险管控的技术人员 | 阅读时长:约18分钟 | 难度:★★★★☆
开篇故事
我见过最惨的一次全量发布:我们把一个核心功能直接推给了所有用户,结果有个边界case在测试环境没有复现,生产上有1%的用户触发了这个bug,直接引发了数据错误。那1%就是2万用户,客服电话打爆了。
如果当时有灰度发布,先放5%的流量跑半小时,发现异常指标上升就立刻回滚,最多影响1000个用户,损失小十倍以上。
从那以后,团队规定:任何涉及核心业务的变更,必须灰度。这篇文章把灰度发布系统的完整设计和实现讲清楚。
一、需求分析与规模估算
灰度发布的维度
- 按比例切流: 5%的流量走新版本,95%走旧版本
- 按用户分组: 内部测试用户、特定城市的用户、高价值用户先体验
- 按请求特征: 特定的请求头、Cookie、IP段
- A/B测试: 实验对照,对比新旧版本的业务指标
规模估算
以一个有50个微服务、每天100亿次接口调用的平台为例:
流量决策QPS: 每次请求到达网关时需要做一次灰度决策,100亿/86400 = 11.6万 QPS
决策延迟要求: < 1ms(不能显著影响请求延迟)
灰度规则数: 同时运行的灰度实验:最多50个
关键数字: 11.6万 QPS的流量决策,每次都要查灰度规则,必须全内存计算,不能访问Redis或DB。
二、灰度发布的技术方案
方案一:网关层流量切分
在API网关(Nginx/Spring Cloud Gateway)根据请求特征决定路由到旧版本还是新版本。
优点: 对应用透明,不需要改业务代码
缺点: 只能做粗粒度的接口级灰度,无法做到功能级灰度(同一接口内部的逻辑差异)
方案二:功能开关(Feature Flag)
在代码里用条件判断,根据功能开关决定执行新逻辑还是旧逻辑。
优点: 非常灵活,可以做到代码行级别的灰度
缺点: 需要在代码里写 if (featureFlag.isEnabled("new-feature")) 的判断,代码侵入性强
方案三:服务实例版本化(推荐)
部署新旧两个版本的服务实例,网关根据灰度规则决定请求走哪个版本的实例。
三、系统架构设计
四、关键代码实现
4.1 灰度规则模型
/**
* 灰度规则定义
*/
@Data
@Builder
public class GrayRule {
private String ruleId;
private String ruleName;
private String serviceId; // 目标服务
private String grayVersion; // 灰度版本标识
// 灰度条件(多个条件AND关系)
private List<GrayCondition> conditions;
// 流量比例(0-100,用于随机按比例切流)
private Integer trafficPercent;
// 是否启用
private boolean enabled;
@Data
@Builder
public static class GrayCondition {
private ConditionType type; // USER_ID/REGION/HEADER/COOKIE/UID_MOD
private String key; // 字段名(如Header名)
private String operator; // in/not_in/equals/starts_with/mod
private List<String> values; // 匹配值列表
private Integer modValue; // 取模值(用于按用户ID比例切流)
private Integer modTarget; // 取模目标值
}
public enum ConditionType {
USER_ID, // 指定用户ID白名单
REGION, // 按地区
HEADER, // 按请求头
COOKIE, // 按Cookie
UID_MOD, // 用户ID取模(用于百分比切流)
IP_RANGE // 按IP段
}
}4.2 网关灰度Filter(核心决策逻辑)
@Component
@Slf4j
public class GrayRoutingFilter implements GlobalFilter, Ordered {
@Autowired
private GrayRuleCache grayRuleCache; // 本地缓存,从Nacos加载
private static final String GRAY_VERSION_HEADER = "X-Gray-Version";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String serviceId = extractServiceId(request);
// 获取该服务的灰度规则
List<GrayRule> rules = grayRuleCache.getRules(serviceId);
if (rules == null || rules.isEmpty()) {
return chain.filter(exchange);
}
// 构建请求上下文(用于规则匹配)
GrayContext context = buildContext(request);
// 依次匹配规则,找到第一个匹配的灰度规则
for (GrayRule rule : rules) {
if (rule.isEnabled() && matchRule(rule, context)) {
// 匹配到灰度规则:在请求头中标记灰度版本
// 下游负载均衡器根据这个Header选择对应版本的实例
ServerHttpRequest modifiedRequest = request.mutate()
.header(GRAY_VERSION_HEADER, rule.getGrayVersion())
.build();
log.debug("灰度路由命中, serviceId={}, ruleId={}, version={}",
serviceId, rule.getRuleId(), rule.getGrayVersion());
return chain.filter(exchange.mutate()
.request(modifiedRequest).build());
}
}
// 没有命中灰度规则,走正常流量
return chain.filter(exchange);
}
/**
* 规则匹配(核心逻辑)
*/
private boolean matchRule(GrayRule rule, GrayContext context) {
// 按比例切流:随机决定
if (rule.getTrafficPercent() != null && rule.getConditions() == null) {
return ThreadLocalRandom.current().nextInt(100) < rule.getTrafficPercent();
}
if (rule.getConditions() == null) return false;
// 多个条件AND关系,全部满足才命中
for (GrayRule.GrayCondition condition : rule.getConditions()) {
if (!matchCondition(condition, context)) {
return false;
}
}
return true;
}
private boolean matchCondition(GrayRule.GrayCondition cond, GrayContext ctx) {
switch (cond.getType()) {
case USER_ID:
return cond.getValues().contains(ctx.getUserId());
case UID_MOD:
// 用户ID取模:比如所有userId % 100 < 10的用户(约10%用户)
if (ctx.getUserId() == null) return false;
try {
long userId = Long.parseLong(ctx.getUserId());
return (int)(userId % 100) < cond.getModValue();
} catch (NumberFormatException e) {
return false;
}
case HEADER:
String headerVal = ctx.getHeaders().get(cond.getKey());
return headerVal != null && cond.getValues().contains(headerVal);
case REGION:
return cond.getValues().contains(ctx.getRegion());
case IP_RANGE:
return isInIpRange(ctx.getClientIp(), cond.getValues());
default:
return false;
}
}
private GrayContext buildContext(ServerHttpRequest request) {
return GrayContext.builder()
.userId(request.getHeaders().getFirst("X-User-Id"))
.clientIp(getClientIp(request))
.region(request.getHeaders().getFirst("X-Region"))
.headers(request.getHeaders().toSingleValueMap())
.build();
}
@Override
public int getOrder() {
return -50; // 在路由前执行
}
}4.3 基于Spring Cloud LoadBalancer的版本路由
/**
* 自定义负载均衡器:根据灰度版本Header选择对应版本的实例
*/
@Configuration
public class GrayLoadBalancerConfig {
@Bean
@Primary
public ReactorLoadBalancer<ServiceInstance> grayLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new GrayVersionLoadBalancer(
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
name
);
}
}@Slf4j
public class GrayVersionLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private static final String GRAY_VERSION_HEADER = "X-Gray-Version";
private static final String VERSION_METADATA_KEY = "version";
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
private final String serviceId;
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier =
serviceInstanceListSupplierProvider.getIfAvailable();
return supplier.get(request).next().map(serviceInstances -> {
// 从请求头中获取灰度版本标记
String grayVersion = extractGrayVersion(request);
List<ServiceInstance> filtered;
if (grayVersion != null) {
// 按版本过滤:选择标记了对应版本的实例
filtered = serviceInstances.stream()
.filter(instance -> grayVersion.equals(
instance.getMetadata().get(VERSION_METADATA_KEY)))
.collect(Collectors.toList());
if (filtered.isEmpty()) {
log.warn("没有找到版本{}的实例,降级到所有实例", grayVersion);
filtered = serviceInstances;
}
} else {
// 无灰度标记:选择非灰度版本的实例
filtered = serviceInstances.stream()
.filter(instance -> {
String version = instance.getMetadata().get(VERSION_METADATA_KEY);
return version == null || "stable".equals(version);
})
.collect(Collectors.toList());
if (filtered.isEmpty()) {
filtered = serviceInstances;
}
}
// 随机选择一个实例
ServiceInstance chosen = filtered.get(
ThreadLocalRandom.current().nextInt(filtered.size()));
return new DefaultResponse(chosen);
});
}
private String extractGrayVersion(Request request) {
if (request.getContext() instanceof RequestDataContext) {
RequestData requestData = ((RequestDataContext) request.getContext()).getClientRequest();
return requestData.getHeaders().getFirst(GRAY_VERSION_HEADER);
}
return null;
}
}4.4 快速回滚机制
@RestController
@RequestMapping("/api/gray")
public class GrayRollbackController {
@Autowired
private NacosConfigService nacosConfigService;
@Autowired
private AlertService alertService;
/**
* 手动回滚:禁用灰度规则
*/
@PostMapping("/rollback/{ruleId}")
public ApiResponse<Void> rollback(@PathVariable String ruleId) {
// 从Nacos中获取当前灰度规则
GrayRule rule = grayRuleCache.getRule(ruleId);
if (rule == null) throw new BusinessException("规则不存在");
// 禁用规则(所有流量回到旧版本)
rule.setEnabled(false);
// 推送到Nacos(所有网关节点会自动更新)
nacosConfigService.publishConfig(
"gray-rules-" + rule.getServiceId(),
"DEFAULT_GROUP",
JsonUtils.toJson(getAllRules(rule.getServiceId()))
);
log.warn("灰度规则已回滚, ruleId={}, ruleName={}", ruleId, rule.getRuleName());
return ApiResponse.success();
}
/**
* 自动回滚:监控到指标异常时触发
*/
@PostMapping("/auto-rollback")
public void autoRollback(@RequestBody AutoRollbackRequest request) {
String serviceId = request.getServiceId();
// 比较灰度版本和基线版本的错误率
double grayErrorRate = metricsService.getErrorRate(
serviceId, request.getGrayVersion(), "5m");
double baselineErrorRate = metricsService.getErrorRate(
serviceId, "stable", "5m");
// 灰度版本错误率比基线高出2倍,触发自动回滚
if (grayErrorRate > baselineErrorRate * 2 && grayErrorRate > 0.01) {
log.error("灰度版本异常,触发自动回滚! serviceId={}, grayError={}%, baselineError={}%",
serviceId, grayErrorRate * 100, baselineErrorRate * 100);
rollback(request.getRuleId());
alertService.sendUrgentAlert(
String.format("【自动回滚】%s灰度版本错误率超阈值(%.2f%%),已自动回滚",
serviceId, grayErrorRate * 100)
);
}
}
}五、扩展性设计
全链路灰度
微服务场景下,一个用户请求会经过多个服务(A调B调C),灰度标记需要在整个调用链中传递。
实现方式:在Feign/RestTemplate的拦截器中,把灰度标记从当前请求的Header复制到下游请求的Header,确保整个调用链都走同一套版本。
六、踩坑实录
坑1:灰度切流不均匀
按比例切流(10%走新版本),但实际观察发现某些服务器节点几乎没有灰度流量,而另一些节点上灰度流量占了30%。原因是随机数在短时间内分布不均匀,加上网关节点数量少,导致抽样偏差。
解决方案:用用户ID取模代替随机数。userId % 100 < 10表示精确的10%用户,结果更稳定,而且同一用户每次访问都走相同版本(用户体验一致)。
坑2:数据库不兼容导致无法回滚
新版本上线时做了数据库的Schema变更(加了一列),回滚到旧版本时,旧代码不认识新字段,运行报错。
解决方案:数据库变更必须向后兼容(新字段有默认值,旧代码忽略新字段),先部署数据库变更,再部署代码变更,确保任意时刻的代码都能正常运行。
坑3:灰度规则缓存更新不及时
Nacos推送规则更新后,部分网关节点的本地缓存没有及时更新(网络抖动导致推送失败),这些节点还在用旧规则路由,导致同样的用户请求被路由到不同版本。
解决方案:本地缓存设置兜底的定时拉取(每5秒主动拉取一次Nacos),即使推送失败,5秒后也能自动同步到最新规则。
七、总结
灰度发布系统的核心要素:
| 要素 | 设计方案 |
|---|---|
| 流量识别 | 网关Filter提取请求特征 |
| 灰度决策 | 本地缓存规则,<1ms决策 |
| 版本路由 | 自定义LoadBalancer |
| 规则管理 | Nacos配置中心,实时推送 |
| 快速回滚 | 禁用规则,流量立即回切 |
| 自动防护 | 指标对比,异常自动回滚 |
灰度发布的核心价值是:把变更的风险从"全量用户"降低到"小部分用户",把发现问题的时间从"几小时后客服电话响"提前到"5分钟内指标告警"。
