需求场景
最初,公共服务平台提供的是,基于某个开源 RPC 框架的 RPC 格式的接口。在上线一段时间后,我们发现这个开源 RPC 框架的 Bug 很多,多次因为框架本身的 Bug,导致整个公共服务平台的接口不可用,但又因为团队成员对框架源码不熟悉,并且框架的代码质量本身也不高,排查、修复起来花费了很长时间,影响面非常大。所以,我们评估下来,觉着这个框架的可靠性不够,维护成本、二次开发成本都太高,最终决定替换掉它。对于引入新的框架,我们的要求是成熟、简单,并且与我们现有的技术栈(Spring)相吻合。这样,即便出了问题,我们也能利用之前积累的知识、经验来快速解决。所以,我们决定直接使用 Spring 框架来提供 RESTful 格式的远程接口。
把 RPC 接口替换成 RESTful 接口,除了需要修改公共服务平台的代码之外,调用方的接口调用代码也要做相应的修改。除此之外,对于公共服务平台的代码,尽管我们只是改动接口暴露方式,对业务代码基本上没有改动,但是,我们也并不能保证就完全不出问题。所以,为了保险起见,我们希望灰度替换掉老的 RPC 服务,而不是一刀切,在某个时间点上,让所有的调用方一下子都变成调用新的 RESTful 接口。因为替换的过程是灰度的,所以老的 RPC 服务不能下线,同时还要部署另外一套新的 RESTful 服务。我们先让业务不是很重要、流量不大的某个调用方,替换成调用新的 RESTful 接口。经过这个调用方一段时间的验证之后,如果新的 RESTful 接口没有问题,我们再逐步让其他调用方,替换成调用新的 RESTful 接口。
但是,如果万一中途出现问题,我们就需要将调用方的代码回滚,再重新部署,这就会导致调用方一段时间内服务不可用。而且,如果新的代码还包含调用方自身新的业务代码,简单通过 Git 回滚代码重新部署,会导致新的业务代码也被回滚。所以,为了避免这种情况的发生,我们就得手动将调用新的 RESTful 接口的代码删除,再改回为调用老的 RPC 接口;除此之外,为了不影响调用方本身业务的开发进度,调用方基于回滚之后的老代码,来做新功能开发,那替换成新的 RESTful 接口的那部分代码,要想再重新 merge 回去就比较难了,有可能会出现代码冲突,需要再重新开发。
在替换新的接口调用方式的时候,调用方并不直接将调用 RPC 接口的代码逻辑删除,而是新增调用 RESTful 接口的代码,通过一个功能开关,灵活切换走老的代码逻辑还是新的代码逻辑。代码示例如下所示。如果 callRestfulApi 为 true,就会走新的代码逻辑,调用 RESTful 接口,相反,就会走老的代码逻辑,继续调用 RPC 接口:
1 | boolean callRestfulApi = true; |
不过,更改 callRestfulApi 的值需要修改代码,而修改代码就要重新部署,这样的设计还是不够灵活。优化的方法,我想你应该已经想到了,把这个值放到配置文件或者配置中心就可以了。为了更加保险,不只是使用功能开关做新老接口调用方式的切换,我们还希望调用方在替换某个接口的时候,先让小部分接口请求,调用新的 RESTful 接口,剩下的大部分接口请求,还是调用老的 RPC 接口,验证没有问题之后,再逐步加大调用新接口的请求比例,最终,将所有的接口请求,都替换成调用新的接口。这就是所谓的“灰度”。
首先,我们要决定使用什么来做灰度,也就是灰度的对象。我们可以针对请求携带的时间戳信息、业务 ID 等信息,按照区间、比例或者具体的值来做灰度。我举个例子来解释一下,假设,我们要灰度的是根据用户 ID 查询用户信息接口。接口请求会携带用户 ID 信息,所以,我们就可以把用户 ID 作为灰度的对象。为了实现逐渐放量,我们先配置用户 ID 是 918、879、123(具体的值)的查询请求调用新接口,验证没有问题之后,我们再扩大范围,让用户 ID 在 1020~1120(区间值)之间的查询请求调用新接口。
如果验证之后还是没有问题,我们再继续扩大范围,让 10% 比例(比例值)的查询请求调用新接口(对应用户 ID 跟 10 取模求余小于 1 的请求)。以此类推,灰度范围逐步扩大到 20%、30%、50% 直到 100%。当灰度比例达到 100%,并且运行一段时间没有问题之后,调用方就可以把老的代码逻辑删除掉了。
实际上,类似的灰度需求场景还有很多。比如,在金融产品的清结算系统中,我们修改了清结算的算法。为了安全起见,我们可以灰度替换新的算法,把贷款 ID 作为灰度对象,先对某几个贷款应用新的算法,如果没有问题,再继续按照区间或者比例,扩大灰度范围。除此之外,为了保证代码万无一失,提前做好预案,添加或者修改一些复杂功能、核心功能,即便不做灰度,我们也建议通过功能开关,灵活控制这些功能的上下线。在不需要重新部署和重启系统的情况,做到快速回滚或新老代码逻辑的切换。
需求分析
从实现的角度来讲,调用方只需要把灰度规则和功能开关,按照某种事先约定好的格式,存储到配置文件或者配置中心,在系统启动的时候,从中读取配置到内存中,之后,看灰度对象是否落在灰度范围内,以此来判定是否执行新的代码逻辑。但为了避免每个调用方都重复开发,我们把功能开关和灰度相关的代码,抽象封装为一个灰度组件,提供给各个调用方来复用。这里需要强调一点,我们这里的灰度,是代码级别的灰度,目的是保证项目质量,规避重大代码修改带来的不确定性风险。实际上,我们平时经常讲的灰度,一般都是产品层面或者系统层面的灰度。
所谓产品层面,有点类似 A/B Testing,让不同的用户看到不同的功能,对比两组用户的使用体验,收集数据,改进产品;所谓系统层面的灰度,往往不在代码层面上实现,一般是通过配置负载均衡或者 API-Gateway,来实现分配流量到不同版本的系统上。系统层面的灰度也是为了平滑上线功能,但比起我们讲到的代码层面的灰度,就没有那么细粒度了,开发和运维成本也相对要高些。
我们还是从使用的角度来分析。组件使用者需要设置一个 key 值,来唯一标识要灰度的功能,然后根据自己业务数据的特点,选择一个灰度对象(比如用户 ID),在配置文件或者配置中心中,配置这个 key 对应的灰度规则和功能开关。配置的格式类似下面这个样子:
1 | features: |
灰度组件在业务系统启动的时候,会将这个灰度配置,按照事先定义的语法,解析并加载到内存对象中,业务系统直接使用组件提供的灰度判定接口,给业务系统使用,来判定某个灰度对象是否灰度执行新的代码逻辑。配置的加载解析、灰度判定逻辑这部分代码,都不需要业务系统来从零开发:
1 | public interface DarkFeature { |
所以,总结一下的话,灰度组件跟限流框架很类似,它也主要包含两部分功能:
- 灰度规则配置解析;
- 提供编程接口(DarkFeature)判定是否灰度;
非功能性需求
对于限流框架,我们主要从易用性、扩展性、灵活性、性能、容错性这几个方面,来分析它的非功能性需求。对于灰度组件,我们同样也从这几个方面来分析。
易用性
因为接口的限流和幂等跟具体的业务是无关的,我们可以把限流和幂等相关的逻辑,跟业务代码解耦,统一放到公共的地方来处理(比如 Spring AOP 切面中)。但是,对于灰度来说,我们实现的灰度功能是代码级别的细粒度的灰度,而替代掉原来的 if-else 逻辑,是针对一个业务一个业务来做的,跟业务强相关,要做到跟业务代码完全解耦,是不现实的。所以,在侵入性这一点上,灰度组件只能做妥协,容忍一定程度的侵入。
除此之外,在灰度的过程中,我们要不停地修改灰度规则,在测试没有出现问题的情况下,逐渐放量。从运维的角度来说,如果每次修改灰度规则都要重启系统,显然是比较麻烦的。所以,我们希望支持灰度规则的热更新,也就是说,当我们在配置文件中,修改了灰度规则之后,系统在不重启的情况下会自动加载、更新灰度规则。
扩展性、灵活性
跟限流框架一样,我们希望支持不同格式(JSON、YAML、XML 等)、不同存储方式(本地配置文件、Redis、Zookeeper 或者自研配置中心等)的灰度规则配置方式。
除此之外,对于灰度规则本身,在上一节课的示例中,我们定义了三种灰度规则语法格式:具体值(比如 893)、区间值(比如 1020-1120)、比例值(比如 %30)。不过,这只能处理比较简单的灰度规则。如果我们要支持更加复杂的灰度规则,比如只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度,现在的灰度规则语法就无法支持了。所以,如何支持更加灵活的、复杂的灰度规则,也是我们设计实现的重点和难点。
性能
对于灰度组件来说,灰度的判断逻辑非常简单,而且不涉及访问外部存储,所以性能一般不会有太大问题。不过,我们仍然需要把灰度规则组织成快速查找的数据结构,能够支持快速判定某个灰度对象(darkTarget,比如用户 ID)是否落在灰度规则设定的区间内。
容错性
两种对异常的处理思路:
- 尽可能捕获所有异常,并且内部“消化”掉,不要往上层业务代码中抛出;
- 按照 fail-fast 原则,如果异常导致幂等逻辑无法正常执行,让业务代码也中止;
对于灰度组件来说,上面的两种对异常的处理思路都是可以接受的。在灰度组件出现异常时,我们既可以选择中止业务,也可以选择让业务继续执行。如果让业务继续执行,本不应该被灰度到的业务对象,就有可能被执行,这是否能接受,还是要看具体的业务。不过,我个人倾向于采用类似幂等框架的处理思路,在出现异常时中止业务。
框架设计思路
在性能和容错性方面,灰度组件并没有需要特别要处理的地方,重点需要关注的是易用性、扩展性、灵活性。详细来说,主要包括这样两点:支持更灵活、更复杂的灰度规则和支持灰度规则热更新。
支持更灵活、更复杂的灰度规则
灰度规则的配置也是跟业务强相关的。业务方需要根据要灰度的业务特点,找到灰度对象(上节课中的 darkTarget,比如用户 ID),然后按照给出的灰度规则语法格式,配置相应的灰度规则。对于像刚刚提到的那种复杂的灰度规则(只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度),通过定义语法规则来支持,是很难实现的。所以,针对复杂灰度规则,我们换个思路来实现。我暂时想到了两种解决方法:
- 使用规则引擎,比如 Drools,可以在配置文件中调用 Java 代码;
- 支持编程实现灰度规则,这样做灵活性更高。不过,缺点是更新灰度规则需要更新代码,重新部署;
对于大部分业务的灰度,我们使用前面定义的最基本的语法规则(具体值、区间值、比例值)就能满足了。对于极个别复杂的灰度规则,我们借鉴 Spring 的编程式配置,由业务方编程实现。之所以选择第二种实现方式,而不是使用 Drools 规则引擎,主要是出于不想为了不常用的功能,引入复杂的第三方框架,提高开发成本和灰度框架本身的学习成本。
实现灰度规则热更新
灰度规则的热更新实现起来并不难。我们创建一个定时器,每隔固定时间(比如 1 分钟),从配置文件中,读取灰度规则配置信息,并且解析加载到内存中,替换掉老的灰度规则。需要特别强调的是,更新灰度规则,涉及读取配置、解析、构建等一系列操作,会花费比较长的时间,我们不能因为更新规则,就暂停了灰度服务。所以,在设计和实现灰度规则更新的时候,我们要支持更新和查询并发执行。
灰度组件功能需求整理
我们还是按照老套路,从中剥离出 V1 版本要实现的内容:
- 灰度规则的格式和存储方式:我们希望支持不同格式(JSON、YAML、XML 等)、不同存储方式(本地配置文件、Redis、Zookeeper、或者自研配置中心等)的灰度规则配置方式;
- 灰度规则的语法格式:我们支持三种灰度规则语法格式:具体值(比如 893)、区间值(比如 1020-1120)、比例值(比如 %30)。除此之外,对于更加复杂的灰度规则,比如只对 30 天内购买过某某商品并且退货次数少于 10 次的用户进行灰度,我们通过编程的方式来实现;
- 灰度规则的内存组织方式:我们需要把灰度规则组织成支持快速查找的数据结构,能够快速判定某个灰度对象(darkTarget,比如用户 ID),是否落在灰度规则设定的范围内;
- 灰度规则热更新:修改了灰度规则之后,我们希望不重新部署和重启系统,新的灰度规则就能生效,所以,我们需要支持灰度规则热更新;
实现灰度组件基本功能
在第一步中,我们先实现基于 YAML 格式的本地文件的灰度规则配置方式,以及灰度规则热更新,并且只支持三种基本的灰度规则语法格式。我们先把这个基本功能的开发需求,用代码实现出来。它的目录结构及其 Demo 示例如下所示:
1 | // 代码目录结构 |
从 Demo 代码中,我们可以看出,对于业务系统来说,灰度组件的两个直接使用的类是 DarkLaunch 类和 DarkFeature 类。
我们先来看 DarkLaunch 类。这个类是灰度组件的最顶层入口类,它用来组装其他类对象,串联整个操作流程,提供外部调用的接口。DarkLaunch 类先读取灰度规则配置文件,映射为内存中的 Java 对象(DarkRuleConfig),然后再将这个中间结构,构建成一个支持快速查询的数据结构(DarkRule)。除此之外,它还负责定期更新灰度规则,也就是前面提到的灰度规则热更新。为了避免更新规则和查询规则的并发执行冲突,在更新灰度规则的时候,我们并非直接操作老的 DarkRule,而是先创建一个新的 DarkRule,然后等新的 DarkRule 都构建好之后,再“瞬间”赋值给老的 DarkRule。你可以结合着下面的代码一块看下:
1 | public class DarkLaunch { |
我们再来看下 DarkRuleConfig 类。这个类功能非常简单,只是用来将灰度规则映射到内存中。具体的代码如下所示:
1 | public class DarkRuleConfig { |
从代码中,我们可以看出来,DarkRuleConfig 类嵌套了一个内部类 DarkFeatureConfig。这两个类跟配置文件的两层嵌套结构完全对应。我把对应关系标注在了下面的示例中,你可以对照着代码看下:
1 | <!--对应 DarkRuleConfig--> |
我们再来看下 DarkRule。DarkRule 包含所有要灰度的业务功能的灰度规则。它用来支持根据业务功能标识(feature key),快速查询灰度规则(DarkFeature)。代码也比较简单,具体如下所示:
1 | public class DarkRule { |
我们最后来看下 DarkFeature 类。DarkFeature 类表示每个要灰度的业务功能的灰度规则。DarkFeature 将配置文件中灰度规则,解析成一定的结构(比如 RangeSet),方便快速判定某个灰度对象是否落在灰度规则范围内。具体的代码如下所示:
1 | public class DarkFeature { |
添加、优化灰度组件功能
在第二步中,我们再实现基于编程的灰度规则配置方式,用来支持更加复杂、更加灵活的灰度规则。我们需要对于第一步实现的代码,进行一些改造。改造之后的代码目录结构如下所示。其中,DarkFeature、DarkRuleConfig 的基本代码不变,新增了 IDarkFeature 接口,DarkLaunch、DarkRule 的代码有所改动,用来支持编程实现灰度规则:
1 | // 第一步的代码目录结构 |
我们先来看下 IDarkFeature 接口,它用来抽象从配置文件中得到的灰度规则,以及编程实现的灰度规则。具体代码如下所示:
1 | public interface IDarkFeature { |
基于这个抽象接口,业务系统可以自己编程实现复杂的灰度规则,然后添加到 DarkRule 中。为了避免配置文件中的灰度规则热更新时,覆盖掉编程实现的灰度规则,在 DarkRule 中,我们对从配置文件中加载的灰度规则和编程实现的灰度规则分开存储。按照这个设计思路,我们对 DarkRule 类进行重构。重构之后的代码如下所示:
1 | public class DarkRule { |
因为 DarkRule 代码有所修改,对应地,DarkLaunch 的代码也需要做少许改动,主要有一处修改和一处新增代码,具体如下所示:
1 | public class DarkLaunch { |
我们再通过一个 Demo 来看下,目前实现的灰度组件该如何使用。结合着 Demo,再去理解上面的代码,会更容易些。Demo 代码如下所示:
1 | // 灰度规则配置(dark-rule.yaml),放到 classpath 路径下 |