文章目录
-
- 0、分布式系统灰度要实现的功能清单
- 1、携带灰度因子
-
- 1.1、Http请求中
- 1.2、JVM中
- 1.3、调用链中
- 2、前端资源灰度
-
- 2.1、方案一:基于verynginx
- 2.2、方案二:基于istio Envoy
- 3、Rest API请求灰度
- 4、分布式RPC(Dubbo)调用灰度
-
- 4.1、概览
- 4.1、实现原理拆解
- 4.2、Dubbo URL
- 4.3、Zookeeper与Dubbo
- 4.4、开发点-1:Provider支持自动注册为Grey版本的Dubbo Service
- 4.5、开发点-2:Consumer支持自动记录Grey版本的Dubbo Service
- 4.6、开发点-3:Dubbo应用本地记录灰度生效规则
- 4.7、开发点-4:Consumer支持动态生效灰度规则配置
- 5、MQ生产/消费灰度
-
- 5.1、区分Topic的方式
- 5.2、不区分Topic的方式
- 6、数据库灰度
- 7、灰度管理控制台
0、分布式系统灰度要实现的功能清单
1.1、灰度因子可全链路传递
-
后端/前端往cookie里携带灰度因子
-
浏览器端发起时自动携带
-
后端分布式节点间自动透传
1.2、前端资源灰度
- 非灰度/灰度前端资源目录区分存放
- 前端请求灰度路由
1.3、Rest API请求灰度
- 非灰度/灰度web并存运行
- 前端请求灰度路由
1.4、分布式RPC(Dubbo)调用灰度
隔离性
- 灰度应用的Dubbo服务API,不被环境里其他非灰度应用所调用
- 灰度应用的Dubbo服务API,可被环境里上游灰度应用所调用
- 灰度应用调用下游其他Dubbo服务APIs时,按灰度规则进行非灰度/灰度的调用
1.5、MQ生产/消费灰度
隔离性
- 灰度应用产生的MQ,只被下游灰度应用消费
- 非灰度应用产生的MQ,只被下游非灰度应用消费
1.6、数据库灰度
一致性
- 灰度应用对应DB数据的相对(非灰度应用)一致性。
- [按需]灰度关闭时的(相对非灰度DB的)数据持久性
- TBD
1、携带灰度因子
1.1、Http请求中
携带位置:
- Cookie
一般是以下几种灰度因子可参考:
- Client IP:Http请求自动携带
- 业务编码:
- 【方式一】向登录模块提需求,在登录时往cookie里携带你需要的灰度因子(笔者的场景:租户Code)
- 【方式二】在前置版本中,让前端在发起请求时,将业务编码塞在cookie中。针对前台可匿名访问的页面。
- (按流量比例:2C会用。2B因参与角色的多样性,不适用单按流量比例)
1.2、JVM中
-
在JVM启动参数上带上自定义标识:useGreyMode
-
java -DuseGreyMode=1
-
-
在运行期代码中,读取此标识是否为True
-
public Boolean isGreyMode(){String useGreyMode = System.getProperty("useGreyMode", "0");if("1".equals(useGreyMode)){//灰度模式return Boolean.TRUE;}return Boolean.FALSE; }
-
1.3、调用链中
- 在Web层,从Http报文中解析得到灰度因子,例如:租户Code、IP
- 将租户Code、IP放入ThreadLocal进行调用链上的透传
- 在RPC层面,自动将ThreadLocal中的Code、IP放入RPC报文的指定位置,并由RPC的接收方进行解析,同样再次放入下游调用链的ThreadLocal中。
- 注意点:ThreadLocal使用TransmittableThreadLocal(https://github.com/alibaba/transmittable-thread-local)
- 为什么?在RPC和框架层面往往会有线程池模型,而常规的ThreadLocal在跨线程时就会值丢失。目前市面上只有TransmittableThreadLocal是支持线程池级别的ThreadLocal传递的。
2、前端资源灰度
要点:
- 非灰度/灰度前端资源目录区分存放
- 前端请求按灰度规则路由
- 灰度规则可动态下发生效
2.1、方案一:基于verynginx
为什么verynginx?
- 【为什么多一层】在nginx之下,再加一个本业务专用的verynginx,与其他业务的转发规则分离,可将转发规则的影响控制到本业务。
- 【为什么选verynginx】verynginx 内部是基于OpenResty,而OpenResty = nginx + lua 的框架。即nginx能做的转发和静态资源路由,verynginx都支持。而且,verynginx对配置更友好:界面化、json化、支持被动(不用轮询去查)变更生效,整体上对于灰度控制的场景使用更简单。
- https://github.com/alexazhou/VeryNginx
verynginx的配置动态生效方式:
config.json配置示例:
- 注:这里会遇到一个开发点
- 自研的灰度管理控制台中的“灰度规则”,是一种数据配置结构。
- verynginx中config.json,又是一种数据配置结构。
- 最终,是要将灰度管理控制台的具体规则,体现到verynginx中的配置文件中去。
- 在这里,有通过python实现一个转换写入工具。
具体实操步骤:
1、【前端资源目录-灰度发布】联系运维,在发布系统中,增加一个前端资源的灰度发布选项。执行灰度发布时,本次构建的前端资源目录不覆盖线上原有verynginx机器上前端资源目录,而是存放在“隔壁”的灰度前端资源目录中。
2、【verynginx配置转换&同步工具】熟悉灰度管理控制台配置数据结构和verynginx配置数据结构,实现一个转换、写入工具(例如:python写一个)。
- python程序生成verynginx用的的config.json转发配置文件后,覆盖原config.json即可。配置立马会生效。免去了verynginx主动轮询。
2.2、方案二:基于istio Envoy
- TBD
3、Rest API请求灰度
即Web层的灰度
(跟前端资源灰度路由逻辑一样,差异在于matcher匹配后的转发)
在前端资源灰度路由的基础上,增量开发【verynginx配置转换&同步工具】,实现灰度管理控制台Rest API URL级的灰度配置转换和同步至verynginx。
4、分布式RPC(Dubbo)调用灰度
4.1、概览
服务端分布式多实例环境中,灰度的关键点:灰度和非灰度应用之间的调用隔离
- 灰度应用的Dubbo服务API,不被环境里其他非灰度应用所调用
- 灰度应用的Dubbo服务API,可被环境里上游灰度应用所调用
- 灰度应用调用下游其他Dubbo服务APIs时,按灰度规则进行非灰度/灰度的调用
4.1、实现原理拆解
1、Dubbo分为Consumer和Provider,分别对应Dubbo内部的Invoker和Exporter对象。一个Dubbo服务,可同时具备Consumer和Provider。
2、想要:灰度应用的Dubbo服务API,被环境里其他非灰度/灰度应用有选择的调用,要实现几个点:
- 灰度应用暴露、注册Dubbo Provider服务时,需要带有灰度标记,供调用方识别。
- 其他关注、订阅此Dubbo Provider的应用,需能同时订阅感知到此App的非灰度版本的Dubbo Provider和灰度版本的Dubbo Provider。并维护记录在本地Invoker List中。
- 当非灰度应用和灰度应用做向外接口调用时,按对应Dubbo Service API是否被灰度,来选择正常的Invoker还是选择灰度的Invoker。
3、向外调用其他Dubbo API的灰度控制粒度是Dubbo Service级别(一个Dubbo Service是一个Interface定义)。
4.2、Dubbo URL
Dubbo通过Dubbo URL,在分布式系统里完成Dubbo服务信息暴露和订阅。
Dubbo通过URL中要素的匹配,完成关心的Dubbo Service的命中和维护。
- Dubbo服务注册:
dubbo://10.31.21.75:58090/com.example.service.item.ItemStockServiceFacade?anyhost=true&application=
item-service&dubbo=2.5.3-RC1&heartbeat=10000&interface=com.example.service.item.facade.
ItemStockServiceFacade&logger=slf4j&methods=xxx,xxx,xxx&pid=1&retries=0&revision=1.0.0&
side=provider&threads=500×tamp=1611044123684&version=1.0.0
- Dubbo服务订阅:
consumer://10.31.24.15/com.example.service.item.facade.ItemStockServiceFacade?application=
order-center&category=consumers&check=false&default.check=false&default.retries=0&dubbo=2.5.3-RC1&
interface=com.example.service.item.facade.ItemStockServiceFacade&methods=xxx,xxx,xxx&pid=1&
revision=1.0.0&side=consumer&timeout=10000×tamp=1610941900528&version=1.0.0
4.3、Zookeeper与Dubbo
上述Dubbo URL的信息,会在Zookeeper以ZNode节点树的形式存储。
并辅以Zookeeper的临时ZNode完成服务下线处理。辅以Watch机制完成服务变更的通知、处理。
4.4、开发点-1:Provider支持自动注册为Grey版本的Dubbo Service
4.3.1、Dubbo的SPI点:Configurator
Dubbo Configurator SPI扩展点:
-
支持以扩展实现点的方式,对Dubbo Provider以Dubbo Service URL协议向注册中心前,对Dubbo Service URL进行干预:编辑/改写。
-
Dubbo源码调用路径:export ——> doExport ——> doExportUrls ——> doExportUrlsFor1Protocol ——> ExtensionLoader.getExtensionLoader(ConfiguratorFactory.class).getExtension(url.getProtocol()).getConfigurator(url).configure(url);
4.3.2、注册为Grey版本的Dubbo Service的代码示例:
- 实现一个Dubbo ConfiguratorFactory
public class GreyConfiguratorFactory implements ConfiguratorFactory {@Overridepublic Configurator getConfigurator(URL url) {return new GreyConfigurator();}
}
(SPI的机制)并在resouces/META-INF/dubbo/internal/下,新建一个com.alibaba.dubbo.rpc.cluster.ConfiguratorFactory
的文件,文件内容为
dubbo=com.frame.sdk.grey.config.GreyConfiguratorFactory
- 实现GreyConfigurator
public class GreyConfigurator implements Configurator {@Overridepublic URL getUrl() {return null;}@Overridepublic URL configure(URL url) {String greyMode = System.getProperty("useGreyMode", "0");//开启灰度if("1".equals(greyMode)){url = doGrey(url);}return url;}private URL doGrey(URL url){//非提供者不更改版本号if(!"provider".equals(url.getParameter(Constants.SIDE_KEY))){return url;}url = url.addParameter(Constants.VERSION_KEY, "grey_1.0.0");return url;}@Overridepublic int compareTo(Configurator o) {return 0;}
}
4.5、开发点-2:Consumer支持自动记录Grey版本的Dubbo Service
改写思路:
- 根据Dubbo URL在Zookeeper里的存储方式,Dubbo verson被存储在ZNode树的叶子节点的data上。
- 故,在某个Dubbo Service的ZNode树中,增加/移除某个灰度Dubbo版本的URL的叶子节点,仍会触发ZNode变更事件,最终对应订阅了此Dubbo Service的所有Dubbo应用都会收到一个zookeeper watch事件,去处理分布式环境下dubbo service的变化。
- 那么,从dubbo consumer的Zookeeper watch事件的处理回调开始分析。
那,那怎么办呢?———— 自己实现一个替换掉
先锁定我们需要加逻辑的代码位置:
ZookeeperRegistryFactory ——> ZookeeperRegistry ( doSubscribe ——> listener ——> childChanged ——> ZookeeperRegistry.this.notify(…) ) ——> FailbackRegistry#notify ——> AbstractRegistry#notify ——> UrlUtils.isMatch
其实,要改的地方就是这么一处。但由于没有扩展点,故,一路上所有的代码均需要从dubbo源码里拷贝出来,然后只针对这个isMatch方法,扩展一下识别灰度版本provider的逻辑。
4.6、开发点-3:Dubbo应用本地记录灰度生效规则
应用记录有关dubbo service的灰度规则,主要是用于灰度dubbo服务的上下游调用串联。
一般由consumer端记录,然后在做具体下游dubbo服务负载均衡调用时,按灰度规则路由到对应dubbo provider。
具体,与下面#4.7章节一起讲述
4.7、开发点-4:Consumer支持动态生效灰度规则配置
有了第二步,支持本地记录"grey_1.0.0"版本的dubbo service后,那么剩下的工作就是在dubbo invoke调用的时候,能按灰度生效情况进行区别的调用grey_1.0.0版本的dubbo provider和非grey版本的dubbo provider了。
先阅读下dubbo做API调用时的源码实现:
如果不熟悉源码实现思路,也可以debug断点跟踪一下调用链路,找到发起调用的点。
那代码怎么写呢?
- dubbo的LoadBalance是支持SPI扩展的,那么就自己实现带灰度的GreyLoadBalance,并使其生效。(记得在resouces下增加SPI声明,否则没效果哦)
- 在GreyLoadBalance里,在每次调用时,去读取#4.6章节记录在应用本地的灰度规则进行匹配。最终在重载的doSelect方法里,根据灰度策略返回灰度的invoker或非灰度的invoker。
代码实现:
改写的部分,主要是多做了一次灰度和非灰度Invokers的分离,并按灰度规则择其一,传入getInvoker方法进行具体某一个invoker的选择,而getInvoker内的实现可以完全拷贝dubbo源码的实现:Random、RoundRobin等。
5、MQ生产/消费灰度
隔离性
- 灰度应用产生的MQ,只被下游灰度应用消费
- 非灰度应用产生的MQ,只被下游非灰度应用消费
(具体使用时,需要分析下是否涉及MQ业务,若MQ业务未变化,可以不处理MQ的灰度,MQ方面视为正常应用就行)
以RocketMQ为例
讨论之下,有两种思路:
1、区分Topic:灰度应用动态自适应的用“灰度Topic”发消息。且自适应的订阅“灰度Topic”的消息。
- 【推荐】隔离性好。
2、不区分Topic:灰度和非灰度应用保持同一个Topic不变。在consumer端根据上下文以及灰度因子有选择性的消费MQ。此处,灰度应用的consumer需以“广播消费”的模式订阅消息,使得能收到此topic下所有的消息,才能有选择的消费。
- 次选方案。且,灰度和非灰度的应用都要接入MQ消费的SDK,才能对MQ的消费进行灰度策略的控制。
》》 两种方案有共性的难点,需要对MQ的“订阅、生产、消费”进行额外的逻辑包装。如果团队技术建设中,本身就对MQ有做一次接口抽象包装,那么改造和替换起来相对顺利些。否则成本会非常大,或者用难度高的动态字节码织入的方式。
5.1、区分Topic的方式
RocketMQ知识点铺垫:
1、RocketMQ的架构图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CZ5lY1xY-1611387861864)(分布式系统灰度实践.assets/image-20210123143344027.png)]
- 【Name Server】Name Server是互相独立的,每个Name Server含有全量的信息(例如Topic以及Topic对应的broker)
- 【broker】与所有Name Server建立长连接
- 【producer】与某个Name Server建立连接,查询topic所在broker,并与broker master建立连接
- 【consumer】与某个Name Server建立连接,查询topic所在broker,与broker master 和broker slave 分别建立连接。broker master会根据情况建议consumer从哪里拉消息
2、RocketMQ的生产/消费代码样例:
故,做一下实现分析:
-
【消息生产端】
- 切面/代理拦截MQ SDK中的各种SendXXXMessage的方法(推荐字节码织入)
- 判断本应用是否是灰度模式,若是,进入下面逻辑
- 替换入参msg中Topic为灰度Topic,就可以了。MQ内部实现会做好TopicPublishInfo的获取。
-
【消息消费端】
- 同理,切面/代理拦截MQ SDK中DefaultMQPushConsumer#subscribe(String topic, … )方法(推荐字节码织入),改写Topic。
-
【MQ链路传递灰度因子】
- 工具类:灰度因子ThreadLocal To MQ、MQ To ThreadLocal的工具类,供切面代码调用。
- 对于生产端是sendXXXMessage系列接口
- 对于消费端是consumeMessage系列接口
5.2、不区分Topic的方式
笔者团队是有自己的MQ抽象包装层的,用于适配多环境下的多种MQ选型。故,实际代码层面仅贴有参考性的部分。讲思路为主。
开发改造点:
1、【必要条件】MQ中需有能传递灰度因子的“位置”,RocketMQ中是Message基础机构就有properties这个Map可以扩展存放自定义信息。
2、【工具类】灰度因子ThreadLocal To MQ、MQ To ThreadLocal的工具类
3、【灰度消费端】consumer实例初始化时,识别灰度状态,设置消费模式为广播模式:consumer.setMessageModel(MessageModel.BROADCASTING);
4、【灰度消费端】consumer实例的MessageListener回调,在判断消费接收到的消息判断方法中,增加灰度规则匹配的逻辑。
相关代码:
6、数据库灰度
一般灰度会侧重在“前端和业务逻辑”的多版本并行看效果,很少涉及需要数据库也要一起灰度的,数据库层面仍保持一套数据库模型。
若因Java业务逻辑的变更,涉及需要区分两套数据库模型的情况:
1、灰度版本有增加库表字段的情况:数据库加字段可保持量版本兼容。(前提:业务SQL里无select * 的情况)
2、灰度版本有删除库表字段的情况:灰度版本SQL不读不写这些字段即可。
3、灰度版本对已有资源做定义转变不兼容的:在分布式系统中,一般是禁止这种行为的。因为这种行为会反应就在业务模块透出的API返回值DTO上,导致API行为不兼容,给调用方造成改造工作量。一般操作上是铺垫新字段做过渡。
4、灰度版本是大重构版本,库表模型大改的情况:那么在Java DataSource层面就按灰度模式进行区分了。灰度的写灰度的模型,非灰度的写灰度的模型。
- 但是,要考虑:
- 灰度版本是在线上真实运行,承接一部分真实流量和业务的。
- 若最终将灰度版本下线后,正式版本上应仍能查询到之前灰度版本处理过的数据。
- 若最终将灰度版本升级为正式版本,这2个库表模型的数据要融合成一个模型。
- 这里就出现复杂性了
- 灰度版本和灰度数据库上线运行时,需要将已有业务数据库的里数据“dump+增量”的方式同步到灰度数据库。因为线上数据库里实时都在产生数据变更。
- 灰度版本数据落DB时,需要进行双写,一份写灰度DB,一份写非灰度DB。非灰度版本数据落DB时,是否需要双写按场景而定。
- (PS:灰度版本和非灰度版本能支持互相数据双写,那一定程度上数据库模型仍是兼容,是否可以做好兼容而简化方案)
还有一种情况,灰度版本DB和非灰度版本DB可以完全隔离,SaaS租户化场景下可能存在的业务场景。
这就与全链路压测的“影子库表”的思路接近了,与DB实时双写的方案相比,也会简单很多。
总之,DB层面是否灰度需要细细分析业务场景,而且优先考虑兼容,一套库表。
7、灰度管理控制台
7.1、灰度配合的单位
以一个分布式服务应用为一个灰度配置单位,在应用上去挂在对应应用级的灰度策略。
7.2、灰度策略示例