这里直接照搬下百度百科的定义:灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。
在互联网公司中,产品新功能上线通常会涉及多个团队或应用模块一并发布,而线上发布是一个比较重的操作。如果没有相对完善的发布快速回滚机制,就会对线上真实用户带来负面体验。灰度发布就是为了解决「发布没有回头路」,让新功能平稳上线!
系统级、接口级的灰度可以在Nginx层(由运维负责)、API网关层(由基础架构负责)动态设置。但作为后端开发,前面两种灰度方式几乎派不上用场。我们需要的是对负责的应用模块,有针对性的开展灰度,比如按用户、按城市、按运营渠道甚至多个维度的组合。显然,这些灰度策略Nginx或API网关不太可能支持,因为实在是跟业务强相关,粒度也比较细,不具备通用性。
最近一年,个人接手并负责了某业务线的交易模块。交易系统的重要性,直接决定了每一次功能迭代,都需要思考如何做好灰度发布以保障功能平稳上线。经过数次需求迭代和实践,笔者从以往的灰度实践中找出了一些共性,抽象出一套灰度分流工具。见本码农的的Github: abtest (仅依赖Jdk、gson、lombok;核心代码<200行,代码量少到没机会写bug;跑下测试用例,5分钟上手不是问题)。后文会以案例的方式,循序渐进讲述这套工具是如何演进来的,相信读者只要看懂了思路,就能用所熟悉的编程语言,2小时内造出来。
产品:业务要上个新功能,下周要上线。
开发:好的!
需求评完后,抓起键盘就是干。作为一个还算有经验的码农,深知新功能上线,至少得搞个开关配置。虽然产品大大没这方面考虑,但毕竟搞出问题,锅还是自己背。于是到配置中心,配上一个开关,代码调式一把,开关生效,美滋滋收工~
key=xxx_feature_switch
value=false
boolean switchOpened = config.getBooleanProperty("xxx_feature_switch", false);
if (switchOpened) {
// 灰度逻辑代码
} else {
// 老逻辑代码
}
画外音:如果你自信代码写得稳,这样搞没毛病。这种方式确实简单有效,一个if语句就搞定。但缺点也很明显,流量一刀切,完全没有灰度放量的过程。
产品:业务要上个新功能,下周要上线。新功能先限制只让A渠道的用户看到,后续业务上会放开到所有渠道。
开发:好的!
需求评完,看到产品终于有点灰度意识了,颇感欣慰!作为一名有追求的码农,肯定不会满足像上次样做个开关配置一行if搞定灰度,毕竟需求还有个限渠道的要求,开关搞不定啊。不禁陷入深深的1分钟思考:倘若要是后面再加个限城市的要求,又该怎么办?找到变化,抽象变化,一不做二不休是时候搞个通用的灰度配置了,说干就干!于是就有了下面的灰度方案设计:
key=xxx_feature_gray_conf
value={"grayUidList":[],"grayPhoneList":[],"graySourceList":["A"],"grayCityIdList":[],"globalGray":false}
@Data
public class CommonGrayConf {
/* 按uid灰度白名单 */
private List<String> grayUidList = Lists.newArrayList();
/* 按手机号灰度白名单 */
private List<String> grayPhoneList = Lists.newArrayList();
/* 按渠道灰度白名单 */
private List<Integer> graySourceList = Lists.newArrayList();
/* 按城市灰度白名单 */
private List<Integer> grayCityIdList = Lists.newArrayList();
/** 全量开关 */
private boolean globalGray = false;
/** 扩展配置内容 */
private String data;
public boolean hitGray(String uid, String phone, int source, int cityId) {
if (globalGray) {
return true;
}
if (!CollectionUtils.isEmpty(grayUidList) && grayUidList.contains(uid)) {
return true;
}
if (!CollectionUtils.isEmpty(grayPhoneList) && grayPhoneList.contains(phone)) {
return true;
}
if (!CollectionUtils.isEmpty(graySourceList) && graySourceList.contains(source)) {
return true;
}
if (!CollectionUtils.isEmpty(grayCityIdList) && grayCityIdList.contains(cityId)) {
return true;
}
return false;
}
}
@Slf4j
public abstract class GrayBucket {
private static Config config = ConfigService.getConfig("application");
private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
public static CommonGrayConf bucket(@NonNull String grayConfigKey) {
try {
String grayConf = config.getProperty(grayConfigKey, "");
return gson.fromJson(grayConf, CommonGrayConf.class);
} catch (Throwable t) {
log.error("item=query_gray_conf_exception||grayConfigKey={}||errmsg={}", grayConfigKey, t.getMessage(), t);
CommonGrayConf defaultConf = new CommonGrayConf();
defaultConf.setGlobalGray(false);
return defaultConf;
}
}
}
CommonGrayConf grayConf = GrayBucket.bucket("xxx_feature_gray_conf");
boolean hitGray = grayConf.hitGray("user_abc", "156****8583", 1, 0);
if (hitGray) {
// 灰度逻辑
} else {
// 老逻辑
}
可能有人会发问,用户成千上万,你灰度配置项加上用户grayUidList
有用吗,能配置得过来吗?
当然有用,举个栗子:如果产品要求的是按渠道来灰度,代码上线之初,是不会一下子把要灰度的渠道给配置上。稳妥起见,QA还会走一个线上回归的流程。这时候,我们就会把QA的测试账号,加到用户灰度配置项grayUidList
里面,QA验证灰度逻辑和老逻辑都没问题后,就可以真正做渠道的灰度了。
画外音:这一次改进做得还可以,新增代码也少,不仅能应付当前限端灰度需求,后续产品上限用户、限城市都可以做!但缺点有两个:1、代码比较定制化,事先写死了当前业务线可能会涉及到的灰度维度(用户id、手机号、渠道、城市);2、没考虑分流设计,比如上线第一天放量2%,第二天10%,第X天全量。
产品:业务要上个新功能,下周要上线。新功能A渠道用户只能在C1、C2两个城市看到
开发:好的!
几天后。。。
产品:线上新功能灰度A渠道,除了C1、C2两个城市外,再新增一个城市C3;同时,业务上还希望能拓展到B渠道用户,先在城市C10试点,能不能行?
开发:能啊,需要改代码,让测试再验证下吧。
如果没有上面面追加的需求,上一个灰度方案就能搞定了,但需求的变化就是这么没有一点点防备。仔细分析下需求,这次不一样的点,在于渠道和城市两个维度各有两种不同的变化,导致不能复用之前的配置项,否则会造成,
都能看到新功能,这不符合需求!
key=xxx_feature_gray_conf
value={"grayUidList":[],"grayPhoneList":[],"graySourceList":["A","B"],"grayCityIdList":["C1","C2","C3","C10"],"globalGray":false}
怎么办?既然是两个维度的组合变化,貌似可以通过配置一个[CommonGrayConf,CommonGrayConf]
列表来解决!草稿上推演一番,确实可以。嗯,那就改吧,看着以前的那些写死的维度(用户id、手机号、渠道、城市)怎么着都不顺眼,一并干掉吧。于是,就有了下面的灰度配置(这个过程其实有点长。。。)
key=xxx_feature_gray_conf
,值的内容如下:[
{
"layer": {
"id": "layer1",
"data": "something1"
},
"grayRules": [
{
"name": "source", // 规则名称,其实叫什么无所谓,与代码里面的传参对齐即可
"enabled": true,
"include": [ // 限制渠道A
"A"
],
"exclude": [],
"global": false
},
{
"name": "city",
"enabled": true,
"include": [ // 限制城市C1、C2、C3
"C1",
"C2",
"C3"
],
"exclude": [],
"global": false
}
]
},
{
"layer": {
"id": "layer2",
"data": "something2"
},
"grayRules": [
{
"name": "source",
"enabled": true,
"include": [ // 限制渠道B
"B"
],
"exclude": [],
"global": false
},
{
"name": "city",
"enabled": true,
"include": [ // 限制城市C10
"C10"
],
"exclude": [],
"global": false
}
]
}
]
这一次,灰度配置跟之前的认知已完全不一样,已经不能称改动,叫重构才对得起这样大的变化!再耐心点,看看代码上有什么改动:
CommonGrayConf
里面的各字段,统一结构并独立成类GrayRule
,这样就可以做到无限扩展灰度规则/**
* 灰度规则规范
* eg: {"name":"uid","enabled":true,"include":[],"exclude":[],"global":false}
*/
@Data
public class GrayRule {
/** 规则名称, eg: "uid"、"city" */
@NonNull
private String name;
/** 规则是否启用 */
private boolean enabled;
/** 黑名单 */
private List<String> exclude;
/** 白名单 */
private List<String> include;
/** 全量标识 */
private boolean global;
/**
* 校验是否命中灰度规则
* 1、先看是否在黑名单中,若在,则永远不会命中灰度,即使开全量也不行
* 2、再看是否在白名单中,若在,则必定命中灰度
* 3、若未命中黑、白名单,则看是否已全量
*/
public boolean hitRule(String value) {
if (exclude != null && exclude.size() > 0) {
if (exclude.contains(value)) {
return false;
}
}
if (include != null && include.size() > 0) {
if (include.contains(value)) {
return true;
}
}
return global;
}
}
BasicAbTestPolicy
类有一个灰度规则列表:可以包含城市规则、渠道规则、用户规则等hitGray
方法是重点,可以对各规则链式进行校验;其余的地方先忽略,后面会再提到/**
* 校验灰度规则和分流的实际执行类
*/
class BasicAbTestPolicy implements AbTestPolicy {
/** 层标识 */
private final Layer layer;
/** 灰度规则 */
private final List<GrayRule> grayRules;
/** 分流规则 */
private final DivRule divRule;
private BasicAbTestPolicy(@NonNull Layer layer, @NonNull List<GrayRule> grayRules, @NonNull DivRule divRule) {
this.layer = layer;
this.grayRules = grayRules;
this.divRule = divRule;
}
@Override
public Layer layer() {
return layer;
}
@Override
public AbTestPolicy hitGray(GrayPoint grayPoint) {
Optional<GrayRule> optRule = grayRules.stream()
.filter(rule -> rule.isEnabled()
&& rule.getName().equals(grayPoint.getName()))
.findFirst();
if (!optRule.isPresent()) {
return AbTestPolicy.NULL;
}
boolean hitRule = optRule.get().hitRule(grayPoint.getValue());
if (!hitRule) {
return AbTestPolicy.NULL;
}
// 返回自身, 做到可链式调用
return this;
}
@Override
public DivResult hitDiv(DivMethod divMethod) {
boolean b = divRule.hitRule(divMethod.calcIndicator());
return new DivResult(b, layer.getId(), layer.getData());
}
}
AbTestFactory
的build
方法负责解析灰度配置,并构建出AbTestFacade
;该类是一个组合类,对比AbTestFacade
、BasicAbTestPolicy
就能看出,这里隐含一个组合设计模式。public abstract class AbTestFactory {
private static Gson gson = new GsonBuilder().disableHtmlEscaping().create();
public static AbTestFacade build(@NonNull String abTestConfigs) {
Type type = new TypeToken<List<AbTestConfig>>() {
}.getType();
List<AbTestConfig> configs = gson.fromJson(abTestConfigs, type);
return build(configs);
}
public static AbTestFacade build(@NonNull List<AbTestConfig> abTestConfigs) {
List<AbTestPolicy> policyList = abTestConfigs.stream()
.map(x -> build(x.getLayer(), x.getGrayRules(), x.getDivRule()))
.collect(Collectors.toList());
return new AbTestFacade(policyList);
}
private static AbTestPolicy build(Layer layer, List<GrayRule> grayRules, DivRule divRule) {
return new BasicAbTestPolicy(layer, grayRules, divRule);
}
/**
* 组合策略类,当做入口类对外暴露
*/
public static class AbTestFacade implements AbTestPolicy {
private final List<AbTestPolicy> policyList;
private AbTestFacade(@NonNull List<AbTestPolicy> policyList) {
this.policyList = policyList;
}
/**
* 分层
*/
@Override
public AbTestPolicy hitGray(GrayPoint grayPoint) {
AbTestPolicy curPolicy = AbTestPolicy.NULL;
for (AbTestPolicy policy : policyList) {
curPolicy = policy.hitGray(grayPoint);
if (curPolicy.getClass() == BasicAbTestPolicy.class) {
break;
}
}
return curPolicy;
}
/**
* 分流
*/
@Override
public DivResult hitDiv(DivMethod divMethod) {
throw new UnsupportedOperationException("Please call 'hitGray' first");
}
}
}
// 这里的conf见上文配置中心配置的值
AbTestFactory.AbTestFacade facade = AbTestFactory.build(conf);
// 链式调用判断当前请求是否命中灰度
DivResult result = facade.hitGray(GrayPoints.source("A"))
.hitGray(GrayPoints.city("C1"))
.hitDiv(DivMethods.global());
if (result.isHit()) {
// 灰度逻辑
} else {
// 老逻辑
}
以上合起来概括下整个流程:
AbTestFactory
构建出实际执行的灰度策略门面AbTestFacade
;AbTestFacade
的hitGray
方法,即可完成灰度校验。画外音:这一次代码虽然变得复杂了点,但GrayRule
、BasicAbTestPolicy
、AbTestFactory
几个都具备通用性,可单独抽取到jar包中,整个灰度方案设计的扩展性增强了不少。但是,说好的分流
又在哪里?
谈分流之前,先说一个业务背景。笔者所在的业务线,在10月份做了各渠道订单打通的项目:用户有在小程序、滴X车主、第三方外部APP等都产生过订单,但由于历史原因,同一用户这些渠道的订单是互不可见。导致用户的支付体验不好、客服进线多,且业务上坏账风控也不好做。
业务述求并不复杂,无非就是让同一个用户能看到自己全部渠道的订单并且能走通支付流程,实现细节就不展开。这里想强调的是:不能一下子对所有用户做订单打通的灰度,否则用户一下子冒出N多个未支付的订单会很茫然,导致客服大量进线(之前做某个风控需求,踩过此坑,按下不表)。所以,最终我们明确了方案,订单打通这个需求必须要对用户进行分流:比如上线第一天分流1%、第二天5%、第N天x%,直至全量。
这里可能有人会问:按城市或渠道来灰度,也能做到逐步放量,一天配一个城市,慢慢来也能做到流量可控。嗯,看起来是可以,但是在我们业务系统中,涉及到用户订单查询相关的接口有15+,且大部分接口的请求参数都没法携带城市、渠道信息,但是每个接口都能获得到用户信息!
好吧,基于以上用例,我们就开始思考怎么在灰度基础上支持分流。好在这一切并不复杂,稍加完善就有了下面的灰度+分流配置:
key=xx_feature_gray_conf
,值的内容如下:[
{
"layer": {
"id": "layer1",
"data": "something1"
},
"grayRules": [
{
"name": "source",
"enabled": true,
"include": [
"A"
],
"exclude": [],
"global": false
},
{
"name": "city",
"enabled": true,
"include": [
"C1",
"C2",
"C3"
],
"exclude": [],
"global": false
}
],
"divRule": {
"percent": 10 // 分流10%
}
},
{
"layer": {
"id": "layer2",
"data": "something2"
},
"grayRules": [
{
"name": "source",
"enabled": true,
"include": [
"B"
],
"exclude": [],
"global": false
},
{
"name": "city",
"enabled": true,
"include": [
"C10"
],
"exclude": [],
"global": false
}
],
"divRule": {
"percent":5 // 分流10%
}
}
]
与之前的配置内容对比,这次就新增了divRule
一块内容,里面的percent
见名知意就是分流百分比;就这么简单?
对!怎么用,回顾下BasicAbTestPolicy
这个类,焦点切到hitDiv
方法,实际执行的是里面DivRule
的方法;跟进去看一下,就明白原来就是根据传入的一个指标,计算下是否比percent
小,若是,则表示这个流量可以通过。
/**
* 校验灰度规则和分流的实际执行类
*/
class BasicAbTestPolicy implements AbTestPolicy {
/** 层标识 */
private final Layer layer;
/** 灰度规则 */
private final List<GrayRule> grayRules;
/** 分流规则 */
private final DivRule divRule;
private BasicAbTestPolicy(@NonNull Layer layer, @NonNull List<GrayRule> grayRules, @NonNull DivRule divRule) {
this.layer = layer;
this.grayRules = grayRules;
this.divRule = divRule;
}
@Override
public Layer layer() {
return layer;
}
@Override
public AbTestPolicy hitGray(GrayPoint grayPoint) {
Optional<GrayRule> optRule = grayRules.stream()
.filter(rule -> rule.isEnabled()
&& rule.getName().equals(grayPoint.getName()))
.findFirst();
if (!optRule.isPresent()) {
return AbTestPolicy.NULL;
}
boolean hitRule = optRule.get().hitRule(grayPoint.getValue());
if (!hitRule) {
return AbTestPolicy.NULL;
}
// 返回自身, 做到可链式调用
return this;
}
@Override
public DivResult hitDiv(DivMethod divMethod) {
boolean b = divRule.hitRule(divMethod.calcIndicator());
return new DivResult(b, layer.getId(), layer.getData());
}
}
/**
* 分流规则
*/
@Value
public class DivRule {
/** 分流比例 */
private int percent;
public boolean hitRule(int indicator) {
if (indicator < 0 || indicator >= 100) {
throw new IllegalStateException("Indicator should be [0,100)");
}
return indicator < percent;
}
}
看到这里,应该就只剩一个疑问,hitRule
方法入参的是怎么计算出来的?非常简单,结合下面的使用方式看一下:原来就是对用户id哈希并模除100计算得到!这样就实现了按用户切流,OMG。除了Hash取模外,还有随机模除100、如果入参是数值型,可以用直接模除100。如果都不满足需求,可以仿照DivMethods
类自定义分流方法。
// 这里的uid是从请求上下文获取的
String uid = "userIdxxx";
// 这里的conf见上文配置中心配置的值
AbTestFactory.AbTestFacade facade = AbTestFactory.build(conf);
// 链式调用判断当前请求是否命中灰度
DivResult result = facade.hitGray(GrayPoints.uid(uid))
.hitDiv(DivMethods.hashMod(uid)); // 按hash(uid) % 100 分流
if (result.isHit()) {
// 灰度逻辑
} else {
// 老逻辑
}
画外音:至此,就建立了一个较为完善的灰度+分流组件。实现逻辑还算简单,没什么外部组件依赖,用百度的一句口号来总结:简单可依赖!