vivo互联网技术

vivo互联网技术 查看完整档案

填写现居城市  |  填写毕业院校vivo  |  技术编辑 编辑填写个人主网站
编辑

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。欢迎关注官方公众号“vivo互联网技术”(微信号ID:vivoVMIC)

个人动态

vivo互联网技术 发布了文章 · 1月20日

谈谈统计学正态分布阈值原理在数据分析工作中的运用

一、背景

0.0 神说,要有正态分布,于是就有了正态分布。

0.1 神看正态分布是好的,就让随机误差都随了正态分布。

0.2 正态分布的奇妙之处,就是许多看似随机事件竟然服从一个表达式就能表达的分布,如同上帝之手特意为之。

神觉得抛硬币是好的,于是定义每个抛出硬币正面记+1分,反面记-1分。创世纪从0分开始,神只抛1次硬币,有2种可能:一半的概率+1分,一半的概率-1分。此时概率分布大概是这样的:

神决定扔10个硬币,此时概率分布如下:

如果画图来感受,数据分布大概如下:

如果是100个,甚至是无穷多个呢?平均分数分布情况大概是什么样呢?画个图感受一下:

——《创世纪·数理统计·正态分布的前世今生》

开头摘自统计学中非常经典的一本书籍,由此可见正态分布是非常经典和随处可见的,为什么正态分布这么常见呢?因为通常情况下,一个事物的影响因素都是多个,好比每个人的学习成绩,受到多个因素的影响,比如:

  • 本人的智商情况。
  • 上课听讲的认真程度,课前的预习程度,与老师的互动程度。
  • 课后是否及时复习,有没有及时温习知识点呢,有没有做好作业巩固。

每一天的因素,每天的行为,对于学生的成绩不是产生正面因素就是负面因素,这些因素对于成绩的影响不是正面就是负面的,反复累计加持就像上图的抛硬币一样,让成绩最后呈现出正态分布。数据呈现正态分布其实背后是有中心极限定理原理支持,根据中心极限定理,如果事物受到多种因素的影响,不管每个因素单独本身是什么分布,他们加总后结果的平均值就是正态分布。

二、引用

正是因为日常分析工作中数据呈现是正态分布的,处于两个极端的值往往是异常的,与我们挑选异常值天然契合。在业务方寻求一种自动监控方案的过程中,我们选择了该方案。根据数据分析工作中,结合统计学的数据阈值分布原理,通过自动划分数据级别范围,确定异常值,如下图箱线图,箱线图是一个能够通过5个数字来描述数据的分布的标准方式,这5个数字包括:最小值,第一分位,中位数,第三分位数,最大值,箱线图能够明确的展示离群点的信息,同时能够让我们了解数据是否对称,数据如何分组、数据的峰度。

箱线图是一个能够通过5个数字来描述数据的分布的标准方式,这5个数字包括:最小值,第一分位,中位数,第三分位数,最大值,箱线图能够明确的展示离群点的信息,同时能够让我们了解数据是否对称,数据如何分组、数据的峰度,对于某些分布/数据集,会发现除了集中趋势(中位数,均值和众数)的度量之外,还需要更多信息。

(图片来源于网络)

需要有关数据变异性或分散性的信息。箱形图是一张图表,它很好地指示数据中的值如何分布,尽管与直方图或密度图相比,箱线图似乎是原始的,但它们具有占用较少空间的优势,这在比较许多组或数据集之间的分布时非常有用。——适用于大批量的数据波动监控。

(图片来源于网络)

 箱线图是一种基于五位数摘要(“最小”,第一四分位数(Q1),中位数,第三四分位数(Q3)和“最大”)显示数据分布的标准化方法。

  1. 中位数(Q2 / 50th百分位数):数据集的中间值;
  2. 第一个四分位数(Q1 / 25百分位数):最小数(不是“最小值”)和数据集的中位数之间的中间数;
  3. 第三四分位数(Q3 / 75th Percentile):数据集的中位数和最大值之间的中间值(不是“最大值”);
  4. 四分位间距(IQR):第25至第75个百分点的距离;
  5. 晶须(蓝色显示);
  6. 离群值(显示为绿色圆圈);
  7. “最大”:Q3 + 1.5 * IQR;
  8. “最低”:Q1 -1.5 * IQR。

(图片来源于网络)

上图是近似正态分布的箱线图与正态分布的概率密度函数(pdf)的比较, 两侧0.35%的数据就能够被视为异常数据。

回到这次的监控方案,由中位数向两边扩散,划分一级二级三级四级五级数据,传入连续时间段内指标的同环比,根据同环比分布的区间确定四个异常类型:异常上涨(同环比分布同时大于等于正三级)、异常(同环比分布在一正一负大于等于三级的范围)、异常下降(同环比分布低于等于负三级)、无异常(同环比分布低于三级的范围)。

三、落地

实现三部曲

1. 代码实现


/*

*数据分析API服务

*/

public class DataAnalysis{

    /*

*波动分析

*input:json,分析源数据(样例)

{
"org_data": [
          { "date":"2020-02-01",  "data":"10123230" },  日期类型、long类型
          { "date":"2020-02-02", "data":"9752755" },
         { "date":"2020-02-03",  "data":"12123230" },
         .......
   ]
}
*output:json,分析结果

{
  "type": 1,  --调用正常返回1,异常返回0
  "message":"", --异常原因
"date": 2020-02-14,--输入数据中按日期升序排列的最后一组的日期
"data": 6346231,--输入数据中按日期升序排列的最后一组的数据值
"rate1": -0.3,--同比值
"rate2": -0.6,--环比值
"level1": 4,--同比等级,5个类型:1、2、3、4、5
"level2": 3,--环比等级,5个类型:1、2、3、4、5
"result":"异常下降",--四个类型:异常上涨、异常、异常下降、无异常
}
*/

    public String fluctuationAnalysis (String org_data){



        //第一步,校验输入数据

        if(checkOrgdata(org_data)) return {"result": 0, "message":""}



        //第二步,计算同环比

        computeOrgdata(org_data)



        //第三步,数据升序排序,获取数组大小、最后数据中按日期升序排列的最后一组

        //以上面样例为了,数组大小14,最后一组数据{ "date":"2020-02-14", "data":"6346231" ,同比:-0.3,环比:-0.6}

对同比、环比、(data暂不做)分别做如下处理

-------

        //第四步,按照数据升序排序及数据大小,将数据(不算上面找出的最后一组数据)平均分为4等份(这样会在数组中插入三个桩),并计算出第一个桩和第三个桩的值,以上面为例子,原来数组大小14,去掉最后一个,13个

//判断,给结论

(1) 如果同比等级> 2 and 环比等级 > 2 (表示一定有异常)

/*

*数据分析API服务

*/

public class DataAnalysis{

    /*

*波动分析

*input:json,分析源数据(样例)

{
"org_data": [
          { "date":"2020-02-01",  "data":"10123230" },  日期类型、long类型
          { "date":"2020-02-02", "data":"9752755" },
         { "date":"2020-02-03",  "data":"12123230" },
         .......
   ]
}
*output:json,分析结果

{
  "type": 1,  --调用正常返回1,异常返回0
  "message":"", --异常原因
"date": 2020-02-14,--输入数据中按日期升序排列的最后一组的日期
"data": 6346231,--输入数据中按日期升序排列的最后一组的数据值
"rate1": -0.3,--同比值
"rate2": -0.6,--环比值
"level1": 4,--同比等级,5个类型:1、2、3、4、5
"level2": 3,--环比等级,5个类型:1、2、3、4、5
"result":"异常下降",--四个类型:异常上涨、异常、异常下降、无异常
}
*/

    public String fluctuationAnalysis (String org_data){



        //第一步,校验输入数据

        if(checkOrgdata(org_data)) return {"result": 0, "message":""}



        //第二步,计算同环比

        computeOrgdata(org_data)



        //第三步,数据升序排序,获取数组大小、最后数据中按日期升序排列的最后一组

        //以上面样例为了,数组大小14,最后一组数据{ "date":"2020-02-14", "data":"6346231" ,同比:-0.3,环比:-0.6}



对同比、环比、(data暂不做)分别做如下处理

-------

        //第四步,按照数据升序排序及数据大小,将数据(不算上面找出的最后一组数据)平均分为4等份(这样会在数组中插入三个桩),并计算出第一个桩和第三个桩的值,以上面为例子,原来数组大小14,去掉最后一个,13个

//判断,给结论

(1)   如果同比等级> 2 and 环比等级 > 2  (表示一定有异常)

And

case when 同比值<0 and 环比值<0 then '异常下降'

       when 同比值>0 and 环比值>0 then '异常上涨'

       else '异常'

end as res



return

(2)其他,无异常波动

}

public bool checkOrgdata (String org_data){



        //检验数组中日期是否全部连续,数量至少要14天数据

        …

        if(日期不连续 || 数量小于14)

           return false;

        return ture;

}

public String computeOrgdata (String org_data){

// 计算输入数据中,每一行的同环比

环比=(今日data/昨日data-1)*100%,

同比=(今日data/上周同日data-1)*100%

return

{
"org_data": [
          { "date":"2020-02-01",  "data":"10123230" ,同比:null,环比:null },
          { "date":"2020-02-02", "data":"9752755" ,同比:null,环比:0.9},
         { "date":"2020-02-03",  "data":"12123230" ,同比:null,环比:0.9},
          .......
   ]
}
}

对于输入数据的几个关键点:

(1)要求是连续的日期,并且至少14天的数据,建议100天的数据

(2)api中当同环比计算为null时,统一处理为0

(3)当传入的数量大于90天,取最近90天作为样本,当数量小于90天,拿所有上传作为样本

2. API封装

(1)提供已封装好的API服务为大家使用:

API使用:

传入数据示例(json)

 {
        "org_data": [
          { "date":"2020-02-01",  "data":"10123230" ,同比:null,环比:null },
          { "date":"2020-02-02", "data":"9752755" ,同比:null,环比:0.9},
         { "date":"2020-02-03",  "data":"12123230" ,同比:null,环比:0.9},
         .....
      ]
    }

返回结果解释:

{
       "type": 1,  --调用正常返回1,异常返回0
       "message":"", --异常原因
        "date": 2020-02-14,--输入数据中按日期升序排列的最后一组的日期
        "data": 6346231,--输入数据中按日期升序排列的最后一组的数据值
        "rate1": -0.3,--同比值
        "rate2": -0.6,--环比值
        "level1": 4,--同比等级,5个类型:1、2、3、4、5
        "level2": 3,--环比等级,5个类型:1、2、3、4、5
        "result":"异常下降",--四个类型:异常上涨、异常、异常下降、无异常
     }

注意问题点:

对于输入数据的几个关键点:

(1)要求是连续的日期,并且至少14天的数据,建议100天的数据

(2)api中当同环比计算为null时,统一处理为0

(3)当传入的数量大于90天,取最近90天作为样本,当数量小于90天,拿所有上传作为样本

3. JAR 包提供

大数据中心日常数据开发工作以HQL为主,我们将API服务封装成JAR包,可直接适用于数仓开发使用。

四、运用场景

目前成功运用于大数据中心多个重点业务和平台,对其日常指标进行监控,以应用商店为例。

1、获取da表数据到  da\_appstore\_core\_data\_di

2、监控数据统一处理  da\_appstore\_core\_data\_result_di

3、每条记录调用上述UDF函数,输出判定结果,异常值可对业务发送提醒,帮助排除业务风险

参考文献

  1. 《创世纪·数理统计·正态分布的前世今生》
  2. 知乎朋友-小尧、jinzhao 关于正态分布阈值原理的部分阐述
作者:vivo 互联网大数据团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 1月19日

深入剖析 RSA 密钥原理及实践

一、前言

在经历了人生的很多至暗时刻后,你读到了这篇文章,你会后悔甚至愤怒:为什么你没有早点写出这篇文章?!

你的至暗时刻包括:

1.你所在的项目需要对接银行,对方需要你提供一个加密证书。你手上只有一个六级英语证书,不确定这个是否满足对方需求。由于你迟迟无法提供正确的证书,项目因此延期,加薪计划泡汤,月供断了,女朋友分手了,你感觉人生完了。

2. 你老骥伏枥 2 个月,终于搞懂了.crt 格式证书。加入到新项目,项目在进行证书托管改造。哈哈,这题我会,就是把证书文件上传到托管系统。你对项目组成员大喝一声,放开那些证书,让我来!挤进去一看,是陈年老项目了,根本没有证书,当时使用是公钥和私钥,如何公钥和私钥变成证书⋯⋯由于你迟迟无法提供正确的证书,项目因此延期,加薪计划泡汤,月供断了,女朋友分手了,你感觉人生完了。

3. 你卧薪尝胆 3 个月,摸清楚了 SSL 证书的来龙去脉。踌躇满志加入到新项目,你向项目经理痛陈血泪史,经此一役,你已经成长为安全证书方面的专家。项目经理喜出望外,正好项目在进行数据安全改造,数据库需要启用 SSL,来得正是时候,不着急,明天下班前提供几个密钥文件就行。越明日,下班前半小时,你缓缓走向项目经理,“你要的货到了”,便排出三个证书,这个是 key 文件,这个是公钥文件,这个是证书文件。项目经理点点头又摇摇头,我要的是JKS 文件呀。你说,明天提供。越明日,下班前的半个小时,你把 JKS 格式文件交给项目经理,项目经理点点头又摇摇头,密码呢?没有密码怎么行?由于你迟迟无法提供正确的证书,项目因此延期,加薪计划泡汤,月供断了,女朋友分手了,你感觉人生完了。

本文将从以下几部分来揭示 RSA 密钥文件的鲜为人知的秘密:

  • RSA 算法数学基础
  • RSA秘钥体系六层模型
  • RSA 工具使用
  • RSA密钥使用场景
注:虽然密钥与证书严格意义上并不等同,但为了表述方便,没有特殊指定的话,本文中的密钥一词涵盖了公钥,私钥,证书等概念。

二、RSA 算法数学基础

RSA 算法是基于数论的,RSA算法的复杂性的基础在于一个大数的素数分解是NP难题,非常难破解。RSA 算法相关的数学概念:

对于任意一个数 x,可以计算出 y:

通过 y,可以计算出 x:

也就是说,x 通过数对 (m,e) 生成了 y 后,可以通过数对 (m, d) 将 y 还原成 x。

这里,我们实际上演示了RSA加密解密的数学过程。通过公式 (1),根据 x 计算得出 y 的过程就是加密,通过公式 (2),根据 y 计算得出 x 的过程就是解密。

在实际应用中,RSA 算法通过公钥进行加密,私钥进行解密,因此数对 (m,e) 就是公钥,(m, d) 就是私钥。

实际上为了提高私钥解密速度,私钥会保存一些中间结果,例如 p, q, e, 等等。

所以在实际应用中,可以通过私钥导出公钥。

三、 RSA秘钥六层模型

为了方便理解RSA密钥的原理,本人创造性地发明了RSA密钥六层模型概念。每一层定义了自己的职责和边界,层级越低,其表示的内容越倾向于抽象和理论;层级越高,其表示的内容越倾向于实际应用。

  • Data:数据层,定义了RSA密钥的数学概念(m,e,p,q等)或者参与实体(subject, issuer等)。
  • Serialization:序列化层,定义了将复杂数据结构序列化的方法。
  • Structure:结构层,定义了不同格式的RSA密钥的数据组织形式。
  • Text:文本层,定义了将二进制的密钥转换成文本的方法。
  • Presentation:表现层,定义了文本格式密钥的表现形式。
  • Application:应用层,定义了RSA密钥使用的各种场景。

下面对每一层进行具体说明。

3.2 数据层

从上文可知,秘钥是一个数据结构,每个结构包含了 2 个或更多的成员(公钥包含 m 和 e,私钥包含 m,d,e 以及其他一些中间结果)。为了将这些数据结构保存在文件中,需要定义某种格式对秘钥进行序列化。

3.3 序列化层

目前常见的定义数据结构的格式包括 JSON 和 XML 等文本格式。

比如,理论上我们可以把公钥定义为一个 JSON:

JSON格式密钥

{
    "m":"15",
    "e":"3"
}

或者,也可以把私钥定义为一个 XML:

<?xml>
<key>
    <module>15</module>
    <e>3</e>
    <d>3</d>
    <p>3</p>
    <q>5</q>
<key>

但是 RSA 发明的时候,这两种格式都还不存在。因此科学家们选择了当时比较流行的语法格式ASN.1。

3.3.1 ASN.1

ASN.1 全称是 Abstract Syntax Notation dot one,(抽象语法记号第1版)。数字1被ISO加在ASN的后边,是为了保持ASN的开放性,可以让以后功能更加强大的ASN被命名为ASN.2等,但至今也没有出现。

ASN.1描述了一种对数据进行表示、编码、传输和解码的数据格式。它提供了一整套正规的格式用于描述对象的结构,而不管语言上如何执行及这些数据的具体指代,也不用去管到底是什么样的应用程序。

3.3.2 ASN.1 编码规则

ASN.1的具体语法可以参考维基百科(https://zh.wikipedia.org/wiki/ASN.1),在此只作简要说明。

ASN.1 中数据类型表示是 T-L-V 的形式:头 2 个字节代表数据类型,接下来的 2 个字节代表字节长度,V 代表具体值。常见的基础类型的值包括 Integer, UTF8String, 复合结构包括 SEQUENCE, SET.秘钥和证书都是 SEQUENCE 类型,而 SEQUENCE 的 type 是 0x30,且长度是大于 127 的,因此第2 个字节是 0x82. ASN.1 编码表示的数据是二进制数据,通常通过 BASE64 转化成字符串保存在 pem 文件中,而 0x3082 经过 BASE64 编码后,就是字符串 MI,因此所有 PEM 文件存储的秘钥开始的前两个字符是 MI。

BER, CER, DER 是 ASN.1 编码规则。其中 DER(Distinguish Encode Rules) 是无歧义编码规则,保证相同的数据结构产生的序列化结果也相同的。

ASN.1 只是定义了抽象数据的序列化方式,但是具体的编码还需要进一步定义。

严格来说,ASN.1 还不是一种定义数据的格式,而是一种语法标准,按照这种标准,可以制定各种各样的格式。

3.4 结构层

根据秘钥文件用途不同,以下标准定义了不同的结构来对秘钥数据进行 ASN.1 编码。通常而言,不同格式的秘钥暗示了不同的结构。

  • pkcs#1 用于定义 RSA 公钥、私钥结构
  • pkcs#7 用于定义证书链
  • pkcs#8 用于定义任何算法公私钥
  • pkcs#12 用于定义私钥证书
  • X.509 定义公钥证书

这些格式的具体区别比较参见下文3.5.2

3.5 表现层

可以看到 ASN.1 及其编码规则(BER, CER, DER)定义的是二进制规则,保存在文件中也是二进制格式。由于当时的电子邮件标准不支持二进制内容的传输,如果秘钥文件通过电子邮件传输,就需要将二进制文件转换成文本文件。这就是 PEM(Privacy-Enhanced Mail, 私密增强邮件)的由来。因此,PEM 文件中保存的秘钥内容是 ASN.1 编码生成的二进制内容,再进行 base64 编码后的文本。

另外,为了方便用户识别是何种格式,中文件的首尾加上一行表示身份的文本。PEM 文件一般包含三部分:首行标签,BASE64 编码的文本数据,尾行标签。

-----BEGIN <label>-----
<BASE64 ENCODED DATA>
-----END <label>-----

针对不同的格式,<label> 值不一样。

3.5.2 PEM 文件格式小结

3.6 应用层

在实际使用中,不仅仅需要使用公私钥对数据进行加解密,还需要根据不同的使用场景,解决密钥的分发、验证等。第5节列举了RSA密钥的一些常见使用场景。

四、工具

4.1 openssl

注意:下面的命令中-RSAPublicKey\_in, -RSAPublicKey\_out选项需要openssl1.0以上版本支持,如果报错,请检查 openssl 版本。

4.1.1 创建秘钥文件

# 生成 pkcs#1 格式2048位的私钥
openssl genrsa -out private.pem 2048
 
#从私钥中提取 pkcs#8 格式公钥
openssl rsa -in private.pem -out public.pem -pubout
 
#从私钥中提取 pkcs#1 格式公钥
openssl rsa -in private.pem -out public.pem -RSAPublicKey_out

4.1.2 秘钥文件格式转换

#pkcs#1 公钥转换成 pkcs#8 公钥
openssl rsa -in public.pem -out public-pkcs8.pem -RSAPublicKey_in
 
#pkcs#8 公钥转换成 pkcs#1 公钥
openssl rsa -in public-pkcs8.pem -out public-pkcs1.pem -pubin -RSAPublicKey_out
 
#pkcs#1 私钥转换成 pkcs#8 私钥
openssl pkcs8 -in private.pem -out private-pkcs8.pem -topk8
 
#pkcs#8 私钥转换成 pkcs#1 私钥
openssl rsa -in private-pkcs8.pem -out private-pkcs1.pem

4.1.3 查看秘钥文件信息

#查看公钥信息
openssl rsa -in public.pem -pubin -text -noout
 
#查看私钥信息
openssl rsa -in private.pem -text -noout

4.1.4 证书

RSA证书

#从现有私钥创建 CSR 文件
openssl req -key private.pem -out request.csr -new
 
#从现有 CSR 文件和私钥中创建证书,有效期365天
openssl x509 -req -in request.csr -signkey private.pem -out cert.crt -days 365
 
#生成全新证书和私钥
openssl req -nodes -newkey rsa:2048 -keyout root.key -out root.crt -x509 -days 365
 
#通 过 现 有 证 书 和 私 钥 (作 为CA ) 为 其 他 CSR 文 件 签 名
openssl x509 -req -in child.csr -days 365 -CA root.crt -CAkey root.key -set_serial 01 -out child.crt
 
#查看证书信息
openssl x509 -in child.crt -text -noout
 
 
#从证书中提取公钥
openssl x509 -pubkey -noout -in child.crt  > public.pem

4.1.5 JKS

#将CA证书转换成JKS格式
keytool -importcert -alias Cacert -file ca.crt  -keystore truststoremysql.jks -storepass password123
 
#将client.crt和client.key转换成PKCS#12格式
openssl pkcs12 -export -in client.crt -inkey client.key -name "mysqlclient" -passout pass:mypassword -out client-keystore.p12
 
#将PKCS#12格式转换成JKS格式
keytool -importkeystore -srckeystore client-keystore.p12 -srcstoretype pkcs12 -srcstorepass mypassword -destkeystore clientstore.jks -deststoretype JKS -deststorepass password456

五、 RSA密钥使用场景

5.1 HTTPS单向认证

由于HTTP协议是明文传输,为了保证HTTP报文不被泄露和篡改,HTTPS通过SSL/TLS协议对HTTP报文进行加解密。

简单来说,HTTPS协议要求客户端和服务端建立连接的过程中,首先进行会话密钥交换,然后使用该会话密钥对通信报文进行加解密。整个通信过程如下:

  1. 服务端通过4.1.4所示方法创建RSA证书server.crt和私钥server.key,并在WEB服务器中进行配置。
  2. 客户端与服务端建立连接,服务端向客户端发送证书server.crt。
  3. 客户端对服务端证书进行校验,并随机生成会话密钥,将通过服务端证书对会话密钥进行加密,传给服务端。
  4. 服务端通过server.key对加密后的会话密钥进行解密,获得会话密钥原文。
  5. 客户端通过会话密钥对HTTP报文进行加密,传给服务端。
  6. 服务端通过会话密钥对HTTP加密报文进行解密,获得HTTP报文原文。
  7. 服务端通过会话密钥对HTTP响应报文进行加密,返回给客户端。
  8. 客户端通过会话密钥对HTTP响应报文进行解密,获得HTTP响应报文原文。

(图1. HTTPS单向认证)

5.2 HTTPS双向认证

5.1节描述的HTTPS场景是一个通用场景,整个过程只有客户端对于服务端的验证,即客户端拿到服务端的证书后,会对证书进行有效性验证,比如是否是CA签名的,是否仍处于有效期内等。这种单向验证在浏览器访问等场景中没有问题,因为这种服务设计地目的就是对外数以万计的用户提供服务。但是在某些场景,比如说仅对特定企业、商户提供服务,服务端需要对客户端进行验证,通过验证的受信客户端才能正常。

访问服务端时,就需要用到HTTPS双向认证。

HTTPS双向认证的过程,就是在HTTPS单向认证的基础之上,增进服务端对客户端的认证。解决方案的思路就是,客户端保存客户端证书client.crt,但是客户端证书不是客户端自己签名或者CA签名,而是由服务端的root.key进行签名。在HTTPS双向认证过程中,客户端需要将客户端证书client.crt发送给服务端,服务端使用root.key进行验证无误后,方可进行后续通信;否则,该客户端即非受信客户端,服务端拒绝提供后续服务。

具体通信过程如下所示:

  1. 服务端通过4.1.4所示方法创建RSA证书server.crt和私钥server.key,并在WEB服务器中进行配置。
  2. 客户端与服务端建立连接,服务端向客户端发送证书server.crt。
  3. 客户端对服务端证书进行校验,验证通过后继续后续流程;验证不通过则断开连接,流程结束。
  4. 服务端向客户端发送报文,请求客户端发送客户端证书。
  5. 客户端向服务端发送客户端证书。
  6. 服务端通过root.key对客户端证书进行验证,验证无误进行后续流程;否则断开连接,流程结束。
  7. 客户端随机生成会话密钥,将通过服务端证书对会话密钥进行加密,传给服务端。
  8. 服务端通过server.key对加密后的会话密钥进行解密,获得会话密钥原文。
  9. 客户端通过会话密钥对HTTP报文进行加密,传给服务端。
  10. 服务端通过会话密钥对HTTP加密报文进行解密,获得HTTP报文原文。
  11. 服务端通过会话密钥对HTTP响应报文进行加密,返回给客户端。
  12. 客户端通过会话密钥对HTTP响应报文进行解密,获得HTTP响应报文原文。

可以看出,向较于HTTPS单向认证过程,HTTPS双向认证过程在客户端验证服务端证书之后,在向服务端发送加密的会话密钥之前,会增加客户端向服务端发送客户端证书client.crt,服务端对该证书进行验证的过程。

(图2. HTTPS双向认证)

5.3 MySQL开启 SSL

MySQL提供SSL的原理,与HTTPS类似,不同之处在于MySQL提供的服务的对象不会是成千上万的普通用户,因此对于CA的需求并不高。

因此实际CA证书通常都是服务端自己生成。

与HTTPS类似,MySQL提供两种形式的SSL认证机制:单向认证和双向认证。

5.3.1 MySQL的SSL单向认证

(1)服务端配置文件:ca.crt, server.crt, server.key,其中server.crt由ca.crt签名生成。

(2)客户端配置文件:ca.crt,ca.crt与服务端的ca.crt相同。

(3)客户端生成JKS文件

keytool -importcert -alias Cacert -file ca.crt -keystore truststoremysql.jks -storepass password123

(4)通过jdbc字符串配置SSL选项和JKS文件

verifyServerCertificate=true&useSSL=true&requireSSL=true&trustCertificateKeyStoreUrl=file:./truststoremysql.jks&trustCertificateKeyStorePassword=password123

5.3.2 MySQL的SSL双向认证

(1)服务端配置文件:ca.crt, server.crt, server.key, 其中server.crt由ca.crt签名生成。

(2)客户端配置文件:ca.crt, client.crt, client.key, 其中ca.crt与服务端的ca.crt相同, client.crt由ca.crt签名生成。

(3)客户端生成trustKeyStore文件

keytool -importcert -alias Cacert -file ca.crt -keystore truststore.jks -storepass password123

(4)客户端生成clientKeyStore文件

keytool -importcert -alias Cacert -file ca.crt -keystore clientstore.jks -storepass password456

(5)通过jdbc字符串配置SSL选项和JKS文件

verifyServerCertificate=true&useSSL=true&requireSSL=true&trustCertificateKeyStoreUrl=file:./truststore.jks&trustCertificateKeyStorePassword=password123&clientCertificateKeyStoreUrl=file:./clientstore.jks&clientCertificateKeyStorePassword=password456

关于MySQL的SSL认证更多细节可以参考:

附录A  不同格式的 ASN.1 编码

A.1 pkcs#1

A.1.1 公钥

RSAPublicKey ::= SEQUENCE {
    modulus INTEGER , -- n
    publicExponent INTEGER -- e
}

A.1.2 私钥

RSAPrivateKey ::= SEQUENCE {
    version Version ,
    modulus INTEGER , -- n
    publicExponent INTEGER , -- e
    privateExponent INTEGER , -- d
    prime1 INTEGER , -- p
    prime2 INTEGER , -- q
    exponent1 INTEGER , -- d mod (p-1)
    exponent2 INTEGER , -- d mod (q-1)
    coefficient INTEGER , -- (inverse of q) mod p
    otherPrimeInfos OtherPrimeInfos OPTIONAL
}

A.2 pkcs#8

A.2.1 pkcs#8 公钥

PublicKeyInfo ::= SEQUENCE {
    algorithm AlgorithmIdentifier ,
    PublicKey BIT STRING
}
AlgorithmIdentifier ::= SEQUENCE {
    algorithm OBJECT IDENTIFIER ,
    parameters ANY DEFINED BY algorithm OPTIONAL
}

A.2.2 pkcs#8 私钥

OneAsymmetricKey ::= SEQUENCE {
    version Version ,
    privateKeyAlgorithm PrivateKeyAlgorithmIdentifier ,
    privateKey PrivateKey ,
    attributes [0] Attributes OPTIONAL ,
    ...,
    [[2: publicKey [1] PublicKey OPTIONAL ]],
    ...
}
PrivateKey ::= OCTET STRING
    -- Content varies based on type of key. The
    -- algorithm identifier dictates the format of
    -- the key.

A.3 X.509

A.3.1 X.509 证书

Certificate ::= SEQUENCE {
    tbsCertificate TBSCertificate ,
    signatureAlgorithm AlgorithmIdentifier ,
    signatureValue BIT STRING
}
 
TBSCertificate ::= SEQUENCE {
    version [0] EXPLICIT Version DEFAULT v1,
    serialNumber CertificateSerialNumber ,
    signature AlgorithmIdentifier ,
    issuer Name,
    validity Validity ,
    subject Name,
    subjectPublicKeyInfo SubjectPublicKeyInfo ,
    issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL ,
        -- If present , version MUST be v2 or v3
    subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL ,
        -- If present , version MUST be v2 or v3
     extensions [3] EXPLICIT Extensions OPTIONAL
        -- If present , version MUST be v3
}
 
Version ::= INTEGER { v1(0), v2(1), v3(2) }
 
CertificateSerialNumber ::= INTEGER
 
Validity ::= SEQUENCE {
    notBefore Time,
    notAfter Time
}
 
Time ::= CHOICE {
    utcTime UTCTime ,
    generalTime GeneralizedTime
}
 
 
UniqueIdentifier ::= BIT STRING
 
SubjectPublicKeyInfo ::= SEQUENCE {
    algorithm AlgorithmIdentifier ,
    subjectPublicKey BIT STRING
}
 
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
 
Extension ::= SEQUENCE {
    extnID OBJECT IDENTIFIER ,
    critical BOOLEAN DEFAULT FALSE ,
    extnValue OCTET STRING
        -- contains the DER encoding of an ASN.1 value
        -- corresponding to the extension type identified
        -- by extnID
}
作者:Zhu Ran ,来自vivo互联网技术团队
查看原文

赞 2 收藏 1 评论 0

vivo互联网技术 发布了文章 · 1月18日

Kafka 原理以及分区分配策略剖析

一、简介

 Apache Kafka 是一个分布式的流处理平台(分布式的基于发布/订阅模式的消息队列【Message Queue】)。

流处理平台有以下3个特性:

  • 可以让你发布和订阅流式的记录。这一方面与消息队列或者企业消息系统类似。
  • 可以储存流式的记录,并且有较好的容错性。
  • 可以在流式记录产生时就进行处理。

1.1 消息队列的两种模式

1.1.1 点对点模式

生产者将消息发送到queue中,然后消费者从queue中取出并且消费消息。消息被消费以后,queue中不再存储,所以消费者不可能消费到已经被消费的消息。Queue支持存在多个消费者,但是对一个消息而言,只能被一个消费者消费。

1.1.2 发布/订阅模式

生产者将消息发布到topic中,同时可以有多个消费者订阅该消息。和点对点方式不同,发布到topic的消息会被所有订阅者消费。

1.2 Kafka 适合什么样的场景

它可以用于两大类别的应用:

  • 构造实时流数据管道,它可以在系统或应用之间可靠地获取数据。(相当于message queue)。
  • 构建实时流式应用程序,对这些流数据进行转换或者影响。(就是流处理,通过kafka stream topic和topic之间内部进行变化)。

为了理解Kafka是如何做到以上所说的功能,从下面开始,我们将深入探索Kafka的特性。

首先是一些概念:

  • Kafka作为一个集群,运行在一台或者多台服务器上。
  • Kafka 通过 topic 对存储的流数据进行分类。
  • 每条记录中包含一个key,一个value和一个timestamp(时间戳)。

1.3 主题和分区

Kafka的消息通过主题(Topic)进行分类,就好比是数据库的表,或者是文件系统里的文件夹。主题可以被分为若干个分区(Partition),一个分区就是一个提交日志。消息以追加的方式写入分区,然后以先进先出的顺序读取。注意,由于一个主题一般包含几个分区,因此无法在整个主题范围内保证消息的顺序,但可以保证消息在单个分区内的顺序。主题是逻辑上的概念,在物理上,一个主题是横跨多个服务器的。

Kafka 集群保留所有发布的记录(无论他们是否已被消费),并通过一个可配置的参数——保留期限来控制(可以同时配置时间和消息大小,以较小的那个为准)。举个例子, 如果保留策略设置为2天,一条记录发布后两天内,可以随时被消费,两天过后这条记录会被抛弃并释放磁盘空间。

有时候我们需要增加分区的数量,比如为了扩展主题的容量、降低单个分区的吞吐量或者要在单个消费者组内运行更多的消费者(因为一个分区只能由消费者组里的一个消费者读取)。从消费者的角度来看,基于键的主题添加分区是很困难的,因为分区数量改变,键到分区的映射也会变化,所以对于基于键的主题来说,建议在一开始就设置好分区,避免以后对其进行调整。

(注意:不能减少分区的数量,因为如果删除了分区,分区里面的数据也一并删除了,导致数据不一致。如果一定要减少分区的数量,只能删除topic重建)

1.4 生产者和消费者

生产者(发布者)创建消息,一般情况下,一个消息会被发布到一个特定的主题上。生产者在默认情况下把消息均衡的分布到主题的所有分区上,而并不关心特定消息会被写入哪个分区。不过,生产者也可以把消息直接写到指定的分区。这通常通过消息键和分区器来实现,分区器为键生成一个散列值,并将其映射到指定的分区上。生产者也可以自定义分区器,根据不同的业务规则将消息映射到分区。

消费者(订阅者)读取消息,消费者可以订阅一个或者多个主题,并按照消息生成的顺序读取它们。消费者通过检查消息的偏移量来区分已经读取过的消息。偏移量是一种元数据,它是一个不断递增的整数值,在创建消息时,kafka会把它添加到消息里。在给定的分区里,每个消息的偏移量都是唯一的。消费者把每个分区最后读取的消息偏移量保存在zookeeper或者kafka上,如果消费者关闭或者重启,它的读取状态不会丢失。

消费者是消费者组的一部分,也就是说,会有一个或者多个消费共同读取一个主题。消费者组保证每个分区只能被同一个组内的一个消费者使用。如果一个消费者失效,群组里的其他消费者可以接管失效消费者的工作。

1.5 broker和集群

broker:一个独立的kafka服务器被称为broker。broker接收来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存。broker为消费者提供服务,对读取分区的请求作出相应,返回已经提交到磁盘上的消息。

集群:交给同一个zookeeper集群来管理的broker节点就组成了kafka的集群。

broker是集群的组成部分,每个集群都有一个broker同时充当集群控制器的角色。控制器负责管理工作,包括将分区分配给broker和监控broker。在broker中,一个分区从属于一个broker,该broker被称为分区的首领。一个分区可以分配给多个broker(Topic设置了多个副本的时候),这时会发生分区复制。如下图:

broker如何处理请求:broker会在它所监听的每个端口上运行一个Acceptor线程,这个线程会创建一个连接并把它交给Processor线程去处理。Processor线程(也叫网络线程)的数量是可配的,Processor线程负责从客户端获取请求信息,把它们放进请求队列,然后从响应队列获取响应信息,并发送给客户端。如下图所示:

生产请求和获取请求都必须发送给分区的首领副本(分区Leader)。如果broker收到一个针对特定分区的请求,而该分区的首领在另外一个broker上,那么发送请求的客户端会收到一个“非分区首领”的错误响应。Kafka客户端要自己负责把生产请求和获取请求发送到正确的broker上。

客户端如何知道该往哪里发送请求呢?客户端使用了另外一种请求类型——元数据请求。这种请求包含了客户端感兴趣的主题列表,服务器的响应消息里指明了这些主题所包含的分区、每个分区都有哪些副本,以及哪个副本是首领。元数据请求可以发给任意一个broker,因为所有的broker都缓存了这些信息。客户端缓存这些元数据,并且会定时从broker请求刷新这些信息。此外如果客户端收到“非首领”错误,它会在尝试重新发送请求之前,先刷新元数据。

1.6 Kafka 基础架构

 二、Kafka架构深入

2.1 Kafka工作流程及文件存储机制

2.1.1 工作流程

Kafka中消息是以topic进行分类的,生产者生产消息,消费者消费消息,都是面向topic的。

Topic是逻辑上的概念,而partition(分区)是物理上的概念,每个partition对应于一个log文件,该log文件中存储的就是producer生产的数据。Producer生产的数据会被不断追加到该log文件末端,且每条数据都有自己的offset。消费者组中的每个消费者,都会实时记录自己消费到哪个offset,以便出错恢复时,从上次的位置继续消费。

2.1.2 文件存储机制

由于生产者生产的消息会不断追加到log文件末尾,为防止log文件过大导致数据定位效率低下,Kafka采取了分片和索引的机制,将每个partition分为多个segment。(由log.segment.bytes决定,控制每个segment的大小,也可通过log.segment.ms控制,指定多长时间后日志片段会被关闭)每个segment对应两个文件——“.index”文件和“.log”文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号。例如:bing这个topic有3个分区,则其对应的文件夹为:bing-0、bing-1和bing-2。

 索引文件和日志文件命名规则:每个 LogSegment 都有一个基准偏移量,用来表示当前 LogSegment 中第一条消息的 offset。偏移量是一个 64位的长整形数,固定是20位数字,长度未达到,用 0 进行填补。如下图所示:

index和log文件以当前segment的第一条消息的offset命名。index文件记录的是数据文件的offset和对应的物理位置,正是有了这个index文件,才能对任一数据写入和查看拥有O(1)的复杂度,index文件的粒度可以通过参数log.index.interval.bytes来控制,默认是是每过4096字节记录一条index。下图为index文件和log文件的结构示意图:

查找message的流程(比如要查找offset为170417的message):

  1. 首先用二分查找确定它是在哪个Segment文件中,其中0000000000000000000.index为最开始的文件,第二个文件为0000000000000170410.index(起始偏移为170410+1 = 170411),而第三个文件为0000000000000239430.index(起始偏移为239430+1 = 239431)。所以这个offset = 170417就落在第二个文件中。其他后续文件可以依此类推,以起始偏移量命名并排列这些文件,然后根据二分查找法就可以快速定位到具体文件位置。
  2. 用该offset减去索引文件的编号,即170417 - 170410 = 7,也用二分查找法找到索引文件中等于或者小于7的最大的那个编号。可以看出我们能够找到[4,476]这组数据,476即offset=170410 + 4 = 170414的消息在log文件中的偏移量。
  3. 打开数据文件(0000000000000170410.log),从位置为476的那个地方开始顺序扫描直到找到offset为170417的那条Message。

2.1.3 数据过期机制

当日志片段大小达到log.segment.bytes指定的上限(默认是1GB)或者日志片段打开时长达到log.segment.ms时,当前日志片段就会被关闭,一个新的日志片段被打开。如果一个日志片段被关闭,就开始等待过期。当前正在写入的片段叫做活跃片段,活跃片段永远不会被删除,所以如果你要保留数据1天,但是片段包含5天的数据,那么这些数据就会被保留5天,因为片段被关闭之前,这些数据无法被删除。

2.2 Kafka生产者

2.2.1 分区策略

  1. 多Partition分布式存储,利于集群数据的均衡。
  2. 并发读写,加快读写速度。
  3. 加快数据恢复的速率:当某台机器挂了,每个Topic仅需恢复一部分的数据,多机器并发。

分区的原则

  1. 指明partition的情况下,使用指定的partition;
  2. 没有指明partition,但是有key的情况下,将key的hash值与topic的partition数进行取余得到partition值;
  3. 既没有指定partition,也没有key的情况下,第一次调用时随机生成一个整数(后面每次调用在这个整数上自增),将这个值与topic可用的partition数取余得到partition值,也就是常说的round-robin算法。

public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
    List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
    int numPartitions = partitions.size();
    if (keyBytes == null) {
        //key为空时,获取一个自增的计数,然后对分区做取模得到分区编号
        int nextValue = nextValue(topic);
        List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
        if (availablePartitions.size() > 0) {
            int part = Utils.toPositive(nextValue) % availablePartitions.size();
            return availablePartitions.get(part).partition();
        } else {
            // no partitions are available, give a non-available partition
            return Utils.toPositive(nextValue) % numPartitions;
        }
    } else {
        // hash the keyBytes to choose a partition
        // key不为空时,通过key的hash对分区取模(疑问:为什么这里不像上面那样,使用availablePartitions呢?)
        // 根据《Kafka权威指南》Page45理解:为了保证相同的键,总是能路由到固定的分区,如果使用可用分区,那么因为分区数变化,会导致相同的key,路由到不同分区
        // 所以如果要使用key来映射分区,最好在创建主题的时候就把分区规划好
        return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
    }
}
 
private int nextValue(String topic) {
    //为每个topic维护了一个AtomicInteger对象,每次获取时+1
    AtomicInteger counter = topicCounterMap.get(topic);
    if (null == counter) {
        counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
        AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
        if (currentCounter != null) {
            counter = currentCounter;
        }
    }
    return counter.getAndIncrement();
}

2.2.2 数据可靠性保证

kafka提供了哪些方面的保证

  • kafka可以保证分区消息的顺序。如果使用同一个生产者往同一个分区写入消息,而且消息B在消息A之后写入,那么kafka可以保证消息B的偏移量比消息A的偏移量大,而且消费者会先读取到消息A再读取消息B。
  • 只有当消息被写入分区的所有副本时,它才被认为是“已提交”的。生产者可以选择接收不同类型的确认,比如在消息被完全提交时的确认、在消息被写入分区首领时的确认,或者在消息被发送到网络时的确认。
  • 只要还有一个副本是活跃的,那么已经提交的信息就不会丢失。
  • 消费者只能读取到已经提交的消息。

复制

Kafka的复制机制和分区的多副本架构是kafka可靠性保证的核心。把消息写入多个副本可以使kafka在发生奔溃时仍能保证消息的持久性。

kafka的topic被分成多个分区,分区是基本的数据块。每个分区可以有多个副本,其中一个是首领。所有事件都是发给首领副本,或者直接从首领副本读取事件。其他副本只需要与首领副本保持同步,并及时复制最新的事件。

Leader维护了一个动态的in-sync replica set(ISR),意为和leader保持同步的follower集合。当ISR中的follower完成数据同步后,leader就会发送ack。如果follower长时间未向leader同步数据,则该follower将被踢出ISR,该时间阈值由replica.lag.time.max.ms参数设定。Leader不可用时,将会从ISR中选举新的leader。满足以下条件才能被认为是同步的:

  • 与zookeeper之间有一个活跃的会话,也就是说,它在过去的6s(可配置)内向zookeeper发送过心跳。
  • 在过去的10s(可配置)内从首领那里获取过最新的数据。

影响Kafka消息存储可靠性的配置

ack应答机制

对于某些不太重要的数据,对数据的可靠性要求不是很高,能够容忍数据的少量丢失,所以没有必要等ISR中的follower全部接收成功。所以Kafka提供了三种可靠性级别,用户可以根据对可靠性和延迟的要求进行权衡。acks:

  •  0: producer不等待broker的ack,这一操作提供了一个最低的延迟,broker一接收到还没写入磁盘就已经返回,当broker故障时可能丢失数据;
  •  1: producer等待leader的ack,partition的leader落盘成功后返回ack,如果在follower同步成功之前leader故障,那么将会丢失数据;
  •  -1(all):producer等待broker的ack,partition的leader和ISR里的follower全部落盘成功后才返回ack。但是如果在follower同步完成后,broker发送ack之前,leader发生故障,那么会造成重复数据。(极端情况下也有可能丢数据:ISR中只有一个Leader时,相当于1的情况)。

消费一致性保证

(1)follower故障

 follower发生故障后会被临时踢出ISR,待该follower恢复后,follower会读取本地磁盘记录的上次的HW,并将log文件高于HW的部分截取掉,从HW开始向leader进行同步。

等该follower的LEO大于等于该Partition的HW,即follower追上leader之后,就可以重新加入ISR了。

(2)leader故障

 leader发生故障后,会从ISR中选出一个新的leader,之后为了保证多个副本之间的数据一致性,其余的follower会先将各自的log文件高于HW的部分截掉,然后从新的leader同步数据。

 注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

2.2.3 消息发送流程

Kafka 的producer 发送消息采用的是异步发送的方式。在消息发送过程中,涉及到了两个线程——main线程和sender线程,以及一个线程共享变量——RecordAccumulator。main线程将消息发送给RecordAccumulator,sender线程不断从RecordAccumulator中拉取消息发送到Kafka broker。

为了提高效率,消息被分批次写入kafka。批次就是一组消息,这些消息属于同一个主题和分区。(如果每一个消息都单独穿行于网络,会导致大量的网络开销,把消息分成批次传输可以减少网络开销。不过要在时间延迟和吞吐量之间做出权衡:批次越大,单位时间内处理的消息就越多,单个消息的传输时间就越长)。批次数据会被压缩,这样可以提升数据的传输和存储能力,但要做更多的计算处理。

相关参数:

  • batch.size:只有数据积累到batch.size后,sender才会发送数据。(单位:字节,注意:不是消息个数)。
  • linger.ms如果数据迟迟未达到batch.size,sender等待 linger.ms之后也会发送数据。(单位:毫秒)。
  • client.id该参数可以是任意字符串,服务器会用它来识别消息的来源,还可用用在日志和配额指标里。
  • max.in.flight.requests.per.connection:该参数指定了生产者在收到服务器响应之前可以发送多少个消息。它的值越高,就会占用越多的内存,不过也会提升吞吐量。把它设置为1可以保证消息时按发送的顺序写入服务器的,即使发生了重试。

2.3 Kafka消费者

2.3.1 消费方式

 consumer采用pull(拉)的模式从broker中读取数据。

 push(推)模式很难适应消费速率不同的消费者,因为消息发送速率是由broker决定的。它的目标是尽可能以最快的速度传递消息,但是这样容易造成consumer来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而pull模式可以根据consumer的消费能力以适当的速率消费消息。

 pull模式的不足之处是,如果kafka没有数据,消费者可能会陷入循环中,一直返回空数据。针对这一点,kafka的消费者在消费数据时会传入一个时长参数timeout,如果当前没有数据可消费,consumer会等待一段时间后再返回。

2.3.2 分区分配策略

 一个consumer group中有多个consumer,一个topic有多个partition,所以必然会涉及到partition的分配问题,即确定哪个partition由哪个consumer来消费。Kafka提供了3种消费者分区分配策略:RangeAssigor、RoundRobinAssignor、StickyAssignor。

 PartitionAssignor接口用于用户定义实现分区分配算法,以实现Consumer之间的分区分配。消费组的成员订阅它们感兴趣的Topic并将这种订阅关系传递给作为订阅组协调者的Broker。协调者选择其中的一个消费者来执行这个消费组的分区分配并将分配结果转发给消费组内所有的消费者。Kafka默认采用RangeAssignor的分配算法。

2.3.2.1 RangeAssignor

 RangeAssignor对每个Topic进行独立的分区分配。对于每一个Topic,首先对分区按照分区ID进行排序,然后订阅这个Topic的消费组的消费者再进行排序,之后尽量均衡的将分区分配给消费者。这里只能是尽量均衡,因为分区数可能无法被消费者数量整除,那么有一些消费者就会多分配到一些分区。分配示意图如下:

分区分配的算法如下:


@Override
public Map<String, List<TopicPartition>> assign(Map<String, Integer> partitionsPerTopic,
                                                Map<String, Subscription> subscriptions) {
    Map<String, List<String>> consumersPerTopic = consumersPerTopic(subscriptions);
    Map<String, List<TopicPartition>> assignment = new HashMap<>();
    for (String memberId : subscriptions.keySet())
        assignment.put(memberId, new ArrayList<TopicPartition>());
    //for循环对订阅的多个topic分别进行处理
    for (Map.Entry<String, List<String>> topicEntry : consumersPerTopic.entrySet()) {
        String topic = topicEntry.getKey();
        List<String> consumersForTopic = topicEntry.getValue();
 
        Integer numPartitionsForTopic = partitionsPerTopic.get(topic);
        if (numPartitionsForTopic == null)
            continue;
        //对消费者进行排序
        Collections.sort(consumersForTopic);
        //计算平均每个消费者分配的分区数
        int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
        //计算平均分配后多出的分区数
        int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();
 
        List<TopicPartition> partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
        for (int i = 0, n = consumersForTopic.size(); i < n; i++) {
            //计算第i个消费者,分配分区的起始位置
            int start = numPartitionsPerConsumer * i + Math.min(i, consumersWithExtraPartition);
            //计算第i个消费者,分配到的分区数量
            int length = numPartitionsPerConsumer + (i + 1 > consumersWithExtraPartition ? 0 : 1);
            assignment.get(consumersForTopic.get(i)).addAll(partitions.subList(start, start + length));
        }
    }
    return assignment;
}

这种分配方式明显的一个问题是随着消费者订阅的Topic的数量的增加,不均衡的问题会越来越严重,比如上图中4个分区3个消费者的场景,C0会多分配一个分区。如果此时再订阅一个分区数为4的Topic,那么C0又会比C1、C2多分配一个分区,这样C0总共就比C1、C2多分配两个分区了,而且随着Topic的增加,这个情况会越来越严重。分配结果:

订阅2个Topic,每个Topic4个分区,共3个Consumer

  • C0:[T0P0,T0P1,T1P0,T1P1]
  • C1:[T0P2,T1P2]
  • C2:[T0P3,T1P3]

2.3.2.2 RoundRobinAssignor

RoundRobinAssignor的分配策略是将消费组内订阅的所有Topic的分区及所有消费者进行排序后尽量均衡的分配(RangeAssignor是针对单个Topic的分区进行排序分配的)。如果消费组内,消费者订阅的Topic列表是相同的(每个消费者都订阅了相同的Topic),那么分配结果是尽量均衡的(消费者之间分配到的分区数的差值不会超过1)。如果订阅的Topic列表是不同的,那么分配结果是不保证“尽量均衡”的,因为某些消费者不参与一些Topic的分配。

以上两个topic的情况,相比于之前RangeAssignor的分配策略,可以使分区分配的更均衡。不过考虑这种情况,假设有三个消费者分别为C0、C1、C2,有3个Topic T0、T1、T2,分别拥有1、2、3个分区,并且C0订阅T0,C1订阅T0和T1,C2订阅T0、T1、T2,那么RoundRobinAssignor的分配结果如下:

看上去分配已经尽量的保证均衡了,不过可以发现C2承担了4个分区的消费而C1订阅了T1,是不是把T1P1交给C1消费能更加的均衡呢?

2.3.2.3 StickyAssignor

StickyAssignor分区分配算法,目的是在执行一次新的分配时,能在上一次分配的结果的基础上,尽量少的调整分区分配的变动,节省因分区分配变化带来的开销。Sticky是“粘性的”,可以理解为分配结果是带“粘性的”——每一次分配变更相对上一次分配做最少的变动。其目标有两点:

  • 分区的分配尽量的均衡。
  • 每一次重分配的结果尽量与上一次分配结果保持一致。

当这两个目标发生冲突时,优先保证第一个目标。第一个目标是每个分配算法都尽量尝试去完成的,而第二个目标才真正体现出StickyAssignor特性的。

StickyAssignor算法比较复杂,下面举例来说明分配的效果(对比RoundRobinAssignor),前提条件:

  • 有4个Topic:T0、T1、T2、T3,每个Topic有2个分区。
  • 有3个Consumer:C0、C1、C2,所有Consumer都订阅了这4个分区。

上面红色的箭头代表的是有变动的分区分配,可以看出,StickyAssignor的分配策略,变动较小。

2.3.3 offset的维护

由于Consumer在消费过程中可能会出现断电宕机等故障,Consumer恢复后,需要从故障前的位置继续消费,所以Consumer需要实时记录自己消费到哪个位置,以便故障恢复后继续消费。Kafka0.9版本之前,Consumer默认将offset保存在zookeeper中,从0.9版本开始,Consumer默认将offset保存在Kafka一个内置的名字叫_consumeroffsets的topic中。默认是无法读取的,可以通过设置consumer.properties中的exclude.internal.topics=false来读取。

2.3.4 kafka高效读写数据(了解)

顺序写磁盘

Kafka 的 producer生产数据,要写入到log文件中,写的过程是一直追加到文件末端,为顺序写。数据表明,同样的磁盘,顺序写能到600M/s,而随机写只有100K/s。这与磁盘的机械结构有关,顺序写之所以快,是因为其省去了大量磁头寻址的时间。

零拷贝技术

零拷贝主要的任务就是避免CPU将数据从一块存储拷贝到另外一块存储,主要就是利用各种零拷贝技术,避免让CPU做大量的数据拷贝任务,减少不必要的拷贝,或者让别的组件来做这一类简单的数据传输任务,让CPU解脱出来专注于别的任务。这样就可以让系统资源的利用更加有效。

参考文献

  1. Kafka中文文档
  2. [Kafka系列]之指定了一个offset,怎么查找到对应的消息?
  3. 尚硅谷 Kafka 教程( Kafka 框架快速入门)
  4. Kafka分区分配策略分析——重点:StickyAssignor
  5. Kafka 日志存储
  6. 浅析Linux中的零拷贝技术
  7. 《Kafka权威指南》
作者:Li Xiaobing,来自vivo互联网技术团队
查看原文

赞 5 收藏 4 评论 0

vivo互联网技术 发布了文章 · 1月12日

初识 D3.js :打造专属可视化

一、前言

随着现在自定义可视化的需求日益增长,Highcharts、echarts等高度封装的可视化框架已经无法满足用户各种强定制性的可视化需求了,这个时候D3的无限定制的能力就脱颖而出。

如果想要通过D3完成可视化,除了对于D3本身API的学习, 关于web标准的HTML, SVG, CSS, Javascript 和 数据可视化的概念以及标准都是需要学习的。这无疑带来了较高的学习门槛,但这也是值得的,因为掌握 D3 后,我们几乎可以实现任何 2d 的可视化需求。

本文通过对D3核心模块分析以及进行具体案例实践的方式,来帮助初学者学习了解D3的绘图思路。

二、D3是什么

D3的全称是 Data-Driven Documents(数据驱动文档),是基于数据来操作文档的 JavaScript 库,其核心在于使用绘图指令对数据进行转换,在源数据的基础上创建新的可绘制数据, 生成SVG路径以及通过数据和方法在DOM中创建数据可视化元素(如轴)。

相对于Echats等开箱即用的可视化框架来说,D3更接近底层,它可以直接控制原生的SVG元素,并且不直接提供任何一种现成的可视化图表,所有的图表都需我们在它的库里挑选合适的方法构建而成,这也大大提高了它的可视化定制能力。而且D3 没有引入新的图形元素,它遵循了web标准(HTML, CSS, SVG 以及 Canvas )来展示数据 ,所以它可以不需要依赖其他框架独立运行在现代浏览器中。

三、D3 核心模块

在V4版本后,D3的 API 现在已经被拆分成一个个模块,我们可以根据自己的可视化需求进行按需加载。根据泛义可以将D3 API模块分为以下的几大类:DOM操作、数据处理,数据分析转换、地理路径,行为等

这里我们主要对 D3-selection 和 D3-scale 模块进行解析:

3.1  D3-selection

D3-selection(选择集) 是 D3js的核心模块,主要是用来进行选择元素,设置属性、数据绑定,事件绑定等操作。

选择元素:D3-selection 提供了两种方法来获取目标元素,d3.select():返回目标元素的第一个节点,d3.selectAll():返回目标元素的集合,乍一看有点类似原生API 的 querySelector 和 querySelectorAll,但是 d3.select 返回的是一个 selection 对象,querySelector 返回的是一个 NodeList 数组。通过控制台打印的信息,可以看到 selection 下的 groups 存放了所有选择的元素集合,parents 存放了所有选中元素的父节点。

设置属性或者绑定事件:我们不需要关心 groups 的结构是怎么样的。当调用 selection.attr  或者 selection.style 的时候, selection 中的所有 group 的所有子元素都会被调用,group 存在的唯一影响是: 当我们传参是一个function 的时候,例如 selection.attr('attrName', function(data, i)) 或 selection.on('click', function(data, i)) 时, 传递的 function(data, i) 中, 第二个参数 i 是元素在 group 中的索引而不是在整个 selection 中的索引。

数据绑定:实际上是给选择的DOM元素的 \_\_data\_\_ 属性赋值,这里提供了3种方式进行数据绑定:

(1)给每一个单独的 DOM 元素调用 selection.datum:d3.select('body').datum(20) 等价于 document.body.\_\_data\_\_ = 20

(2)从父节点中继承来数据, 比如: append , insert , select,子节点会主动继承父节点的数据:

(3) 调用 selection.data() 方法,支持传入装有基础数据类型的数据,也支持传入一个function(parentNode, groupIndex)根据节点索引与数据做映射,data()方法引入了 d3 中非常重要的 join 思想: 

绑定 data 到 DOM 元素, 在D3中是通过比较 data 和 DOM 的 key 值来找到对应关系的。 如果我们没有单独设置 key 值,那么默认根据 data 的下标索引来设定,但是当数据顺序发生改变,这个默认下标 key 值 就变得不可靠了,这时我们可以使用 selection.data(data, keyFunction) 中的第二个参数 keyFunction,根据当前的数据返回一个对应的 key 值。通过下面的图例可以看出,不管是有一个还是多个 group(每个group 都是独立的),只要我们保证在任意一个 group 中的 key 值是唯一的,数据一旦发生变化都会反映给对应的 DOM 元素( update 的过程):

3.2 Join 思想

上面提到的都是data数据和DOM元素数量相同的情况下的数据绑定,那如果data数据和DOM元素数量不相同时,我们来看看 D3 又是如何进行数据绑定的:现在终于可以来介绍 D3-selecion 模块的核心 Join 思想了,这个思想简单来说就是 “不应该告诉D3去怎么创建元素, 而是告诉D3,.selectAll() 得到的 selecion 集合应该和  .data(data) 绑定的数据要怎么一一对应”。  

从上图可以看出,在进行 d3.data(data) 数据绑定的时候,会产生三种状态的选择集:

  • Update:  已经和data数据绑定的DOM元素集合
  • Enter:data数据没有找到与之对应的DOM元素集合(就是缺失的DOM元素)
  • Exit: 没有被数据绑定的DOM元素集合(多余的DOM元素)

用 Join 的方式来理解意味着,我们要做的事情仅仅是声明 DOM集合和数据集合之间的关系, 并且通过处理三个不同状态的集合 enter、update 、 exit 来描述这种关系。这种方式可以大大简化我们对DOM元素的操作,我们不需要再用 if 和 for 循环的方式来进行复杂的逻辑判断,来得到我们需要得到的元素集合。并且在处理动态数据的时候,可以通过处理这三种状态,轻松的展示实时数据和添加平滑的动态交互效果。

3. 3 D3-scale

D3-scale(比列尺) 提供多种不同类型的比例尺。经常和 D3-axis 坐标轴模块一起使用。

D3-scale 提供了多种连续性和非连续性的比例尺,总体可以将他们分为三大类:

  • 连续性输入(domain)和连续性输出(range)
  • 连续性输入(domain)和离散性输出(range)
  • 离散性输入(domain)和离散性输出(range)

常用的一些比例尺:

(1)d3-scaleLinear 线性比例尺(连续性输入和连续性输出)

可以看出,调用d3.scaleLinear()可以生成线性比例尺,domain()是输入域,range()是输出域,相当于将domain中的数据集映射到range的数据集中。

使用示例:

映射关系:

(2)d3-scaleTime 时间比例尺(连续性输入和连续性输出)

时间比例尺与线性比例尺类似,只不过输入域变成了一个时间轴。正常我们使用比例尺都是个正序的过程,但是D3也提供了invert()以及invertExtent()方法,我们可以通过输出域中的具体值得出对应输入域的值。

使用示例:

(3)d3.scaleQuantize  量化比例尺(连续性输入和离散性输出)

量化比例尺是将连续的输入域根据输出域被分割为均匀的片段,所以它的输出域是离散的。

使用示例:

映射关系:

(4)d3. scaleThreshold 阈值比例尺(连续性输入和离散性输出)

阈值比例尺可以为一组连续数据指定分割阈值,阈值比例尺默认的 domain:[0.5] 以及默认的 range:[0, 1] ,因此默认的 d3.scaleThreshold() 等价于 Math.round 函数。 阈值比例尺输入域为 N 的话,输出域必须为 N + 1,否则比例尺对某些值可能会返回 undefined,或者输出域多余的值会被忽略。

使用示例:

存在三种映射关系:

a. 当domain和range的数据是 N : N+1   

b. 当domain和range的数据是 N : N + 大于1         

c. 当domain和range的数据是 N + 大于0 :  N 

(5)d3.scaleOrdinal 序数比例尺(离散性输入和离散性输出)

与scaleLinear等连续性比例尺不同,序数比例尺的输出域和输入域都是离散的。

使用示例:

存在三种映射关系:

a.当domain和range的数据是一一对应     

b.当domain少于range的数据

c.当domain多于range的数据

四、实战

通过以上的学习,应该对d3是如何操作DOM以及坐标轴的数据映射为相应的可视化表现有了一定的了解,下面我们来实际运用这两个模块,来实现我们常见的可视化图表:柱状图。

(1)首先添加一个SVG元素。

(2)根据我们上面说到 d3.scale 模块以及 d3.axis 模块绘制坐标轴,d3.scaleBand() 叫做序数分段比例尺,类似我们说的 d3.scaleOrdinal() 序数比例尺,但是它支持连续的数值类型的输出域,离散的输入域可以将连续的范围划分为均匀的分段。这里再讲一个细节,在绘制网格的时候,我们并没有额外添加 line 元素来实现,而是通过 d3.axis 坐标轴模块的 axis.ticks() 方法对坐标轴刻度进行了设置,通过 tickSIze() 设置了刻度线长度,来模拟和图表宽度相等的网格线,并且还可以通过 tickFormat() 对Y轴刻度值进行格式化转换。

(3)坐标轴绘制好了后,我们通过数据绑定来绘制与之对应的矩形(rect)元素了。

(4)这个时候柱状图已经基本绘制好了,我们再丰富内容展示,添加标签、标题等提示信息。

(5)最后我们通过给柱子绑定监听事件,实现tooltips的信息浮层交互。

五、总结

通过对 d3.selection 、d3.scale 以及 d3.axis等模块的学习,我们已经可以绘制出常用的柱状图等图表,我们也可以通过d3提供的其他模块绘制出更加复杂的可视化效果,例如通过  d3-hierarchy(层级模块) 实现层级树图可视化,d3-geo(地理投影) 实现地图数据可视化等,本文讲解的内容还只是D3库的冰山一角。所以等我们掌握了D3后,限制我们实现可视化的不再是技术而是想象力。

作者:Ray
查看原文

赞 13 收藏 7 评论 0

vivo互联网技术 发布了文章 · 1月11日

Java 并发编程之 JMM & volatile 详解

本文从计算机模型开始,以及CPU与内存、IO总线之间的交互关系到CPU缓存一致性协议的逻辑进行了阐述,并对JMM的思想与作用进行了详细的说明。针对volatile关键字从字节码以及汇编指令层面解释了它是如何保证可见性与有序性的,最后对volatile进行了拓展,从实战的角度更了解关键字的运用。

一、现代计算机理论模型与工作原理

1.1 冯诺依曼计算机模型

让我们来一起回顾一下大学计算机基础,现代计算机模型——冯诺依曼计算机模型,是一种将程序指令存储器和数据存储器合并在一起的计算机设计概念结构。依据冯·诺伊曼结构设计出的计算机称做冯.诺依曼计算机,又称存储程序计算机。

计算机在运行指令时,会从存储器中一条条指令取出,通过译码(控制器),从存储器中取出数据,然后进行指定的运算和逻辑等操作,然后再按地址把运算结果返回内存中去。

接下来,再取出下一条指令,在控制器模块中按照规定操作。依此进行下去。直至遇到停止指令。

程序与数据一样存贮,按程序编排的顺序,一步一步地取出指令,自动地完成指令规定的操作是计算机最基本的工作模型。这一原理最初是由美籍匈牙利数学家冯.诺依曼于1945年提出来的,故称为冯.诺依曼计算机模型。

  • 五大核心组成部分:
  1. 运算器:顾名思义,主要进行计算,算术运算、逻辑运算等都由它来完成。
  2. 存储器:这里存储器只是内存,不包括内存,用于存储数据、指令信息。实际就是我们计算机中内存(RAM)
  3. 控制器:控制器是是所有设备的调度中心,系统的正常运行都是有它来调配。CPU包含控制器和运算器。
  4. 输入设备:负责向计算机中输入数据,如鼠标、键盘等。
  5. 输出设备:负责输出计算机指令执行后的数据,如显示器、打印机等。
  • 现代计算机硬件结构:

图中结构可以关注两个重点:

I/O总线:所有的输入输出设备都与I/O总线对接,保存我们的内存条、USB、显卡等等,就好比一条公路,所有的车都在上面行驶,但是毕竟容量有限,IO频繁或者数据较大时就会引起“堵车”

CPU:当CPU运行时最直接也最快的获取存储的是寄存器,然后会通过CPU缓存从L1->L2->L3寻找,如果缓存都没有则通过I/O总线到内存中获取,内存中获取到之后会依次刷入L3->L2->L1->寄存器中。现代计算机上我们CPU一般都是 1.xG、2.xG的赫兹,而我们内存的速度只有每秒几百M,所以为了为了不让内存拖后腿也为了尽量减少I/O总线的交互,才有了CPU缓存的存在,CPU型号的不同有的是两级缓存,有的是三级缓存,运行速度对比:寄存器 \> L1 > L2 > L3 > 内存条

1.2 CPU多级缓存和内存

CPU缓存即高速缓冲存储器,是位于CPU与主内存之间容量很小但速度很高的存储器。CPU直接从内存中存取数据后会保存到缓存中,当CPU再次使用时可以直接从缓存中调取。如果有数据修改,也是先修改缓存中的数据,然后经过一段时间之后才会重新写回主内存中。

CPU缓存最小单元是缓存行(cache line),目前主流计算机的缓存行大小为64Byte,CPU缓存也会有LRU、Random等缓存淘汰策略。CPU的三级缓存为多个CPU共享的。

  • CPU读取数据时的流程:

(1)先读取寄存器的值,如果存在则直接读取

(2)再读取L1,如果存在则先把cache行锁住,把数据读取出来,然后解锁

(3)如果L1没有则读取L2,如果存在则先将L2中的cache行加锁,然后将数据拷贝到L1,再执行读L1的过程,最后解锁

(4)如果L2没有则读取L3,同上先加锁,再往上层依次拷贝、加锁,读取到之后依次解锁

(5)如果L3也没有数据则通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

  • 缓存一致性问题:

在多处理器系统中,每个处理器都有自己的缓存,于是也引入了新的问题:缓存一致性。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI等等。

1.3 MESI缓存一致性协议

缓存一致性协议中应用最广泛的就是MESI协议。主要原理是 CPU 通过总线嗅探机制(监听)可以感知数据的变化从而将自己的缓存里的数据失效,缓存行中具体的几种状态如下:

以上图为例,假设主内存中有一个变量x=1,CPU1和CPU2中都会读写,MESI的工作流程为:

(1)假设CPU1需要读取x的值,此时CPU1从主内存中读取到缓存行后的状态为E,代表只有当前缓存中独占数据,并利用CPU嗅探机制监听总线中是否有其他缓存读取x的操作。

(2)此时如果CPU2也需要读取x的值到缓存行,则在CPU2中缓存行的状态为S,表示多个缓存中共享,同时CPU1由于嗅探到CPU2也缓存了x所以状态也变成了S。并且CPU1和CPU2会同时嗅探是否有另缓存失效获取独占缓存的操作。

(3)当CPU1有写入操作需要修改x的值时,CPU1中缓存行的状态变成了M。

(4)CPU2由于嗅探到了CPU1的修改操作,则会将CPU2中缓存的状态变成 I 无效状态。

(5)此时CPU1中缓存行的状态重新变回独占E的状态,CPU2要想读取x的值的话需要重新从主内存中读取。

二、JMM模型

2.1  Java 线程与系统内核的关系

Java线程在JDK1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现。因此,在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。

用户线程:指不需要内核支持而在用户程序中实现的线程,其不依赖于操作系统核心,应用进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。另外,用户线程是由应用进程利用线程库创建和管理,不依赖于操作系统核心。不需要用户态/核心态切换,速度快。操作系统内核不知道多线程的存在,因此一个线程阻塞将使得整个进程(包括它的所有线程)阻塞。由于这里的处理器时间片分配是以进程为基本单位,所以每个线程执行的时间相对减少。

内核线程: 线程的所有管理操作都是由操作系统内核完成的。内核保存线程的状态和上下文信息,当一个线程执行了引起阻塞的系统调用时,内核可以调度该进程的其他线程执行。在多处理器系统上,内核可以分派属于同一进程的多个线程在多个处理器上运行,提高进程执行的并行度。由于需要内核完成线程的创建、调度和管理,所以和用户级线程相比这些操作要慢得多,但是仍然比进程的创建和管理操作要快。

基于线程的区别,我们可以引出java内存模型的结构。

2.2  什么是 JMM 模型

Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,从某个程度上讲应该包括了JVM中的堆和方法区。多条线程对同一个变量进行访问可能会发生线程安全问题。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。所以则应该包括JVM中的程序计数器、虚拟机栈以及本地方法栈。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

2.3 JMM 详解

需要注意的是JMM只是一种抽象的概念,一组规范,并不实际存在。对于真正的计算机硬件来说,计算机内存只有寄存器、缓存内存、主内存的概念。不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

工作内存同步到主内存之间的实现细节,JMM定义了以下八种操作:

如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

  • 同步规则分析

(1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

(2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。

(3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。

(4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。

(5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

(6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

2.4 JMM 如何解决多线程并发引起的问题

多线程并发下存在:原子性、可见性、有序性三种问题。

  • 原子性:

问题:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。但是当线程运行的过程中,由于CPU上下文的切换,则线程内的多个操作并不能保证是保持原子执行。

解决:除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。

  • 可见性

问题:之前我们分析过,程序运行的过程中是分工作内存和主内存,工作内存将主内存中的变量拷贝到副本中缓存,假如两个线程同时拷贝一个变量,但是当其中一个线程修改该值,另一个线程是不可见的,这种工作内存和主内存之间的数据同步延迟就会造成可见性问题。另外由于指令重排也会造成可见性的问题。

解决:volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。

有序性

问题:在单线程下我们认为程序是顺序执行的,但是多线程环境下程序被编译成机器码的后可能会出现指令重排的现象,重排后的指令与原指令未必一致,则可能会造成程序结果与预期的不同。

解决:在Java里面,可以通过volatile关键字来保证一定的有序性。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

三、volatile关键字

3.1 volatile 的作用

volatile是 Java 虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用:

  • 保证被volatile修饰的共享变量对所有线程总数可见,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知
  • 禁止指令重排序优化

3.2 volatile 保证可见性

以下是一段多线程场景下存在可见性问题的程序。

public class VolatileTest extends Thread {
    private int index = 0;
    private boolean flag = false;
 
    @Override
    public void run() {
        while (!flag) {
            index++;
        }
    }
 
    public static void main(String[] args) throws Exception {
        VolatileTest volatileTest = new VolatileTest();
        volatileTest.start();
 
        Thread.sleep(1000);
 
        // 模拟多次写入,并触发JIT
        for (int i = 0; i < 10000000; i++) {
            volatileTest.flag = true;
        }
        System.out.println(volatileTest.index);
    }
}

运行可以发现,当 volatileTest.index 输出打印之后程序仍然未停止,表示线程依然处于运行状态,子线程读取到的flag的值仍为false。

private volatile boolean flag = false;

尝试给flag增加volatile关键字后程序可以正常结束, 则表示子线程读取到的flag值为更新后的true。

那么为什么volatile可以保证可见性呢?

可以尝试在JDK中下载hsdis-amd64.dll后使用参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 运行程序,可以看到程序被翻译后的汇编指令,发现增加volatile关键字后给flag赋值时汇编指令多了一段 "lock addl $0x0,(%rsp)"

说明volatile保证了可见性正是这段lock指令起到的作用,查阅IA-32手册,可以得知该指令的主要作用:

  • 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。
  • lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据。
  • 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序。

3.3 volatile 禁止指令重排

Java 语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

以下是源代码到最终执行的指令集的示例图:

as-if-serial原则:不管怎么重排序,单线程程序下编译器和处理器不能对存在数据依赖关系的操作做重排序。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

下面是一段经典的发生指令重排导致结果预期不符的例子:

public class VolatileTest {
 
    int a, b, x, y;
 
    public boolean test() throws InterruptedException {
        a = b = 0;
        x = y = 0;
        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread t2 = new Thread(() -> {
            b = 1;
            y = a;
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        if (x == 0 && y == 0) {
            return true;
        } else {
            return false;
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            VolatileTest volatileTest = new VolatileTest();
            if (volatileTest.test()) {
                System.out.println(i);
                break;
            }
        }
    }
}

按照我们正常的逻辑理解,在不出现指令重排的情况下,x、y永远只会有下面三种情况,不会出现都为0,即循环永远不会退出。

  1. x = 1、y = 1
  2. x = 1、y = 0
  3. x = 0、y = 1

但是当我们运行的时候会发现一段时间之后循环就会退出,即出现了x、y都为0的情况,则是因为出现了指令重排,时线程内的对象赋值顺序发生了变化。

而这个问题给参数增加volatile关键字即可以解决,此处是因为JMM针对重排序问题限制了规则表。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。一个读的操作为load,写的操作为store。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

以上图为例,普通写与volatile写之间会插入一个StoreStore屏障,另外有一点需要注意的是,volatile写后面可能有的volatile读/写操作重排序,因为编译器常常无法准确判断是否需要插入StoreLoad屏障。

则JMM采用了比较保守的策略:在每个volatile写的后面插入一个StoreLoad屏障。

那么存汇编指令的角度,CPU是怎么识别到不同的内存屏障的呢:

(1)sfence:实现Store Barrior 会将store buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见。

(2)lfence:实现Load Barrior 会将invalidate queue失效,强制读取入L1 cache中,而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性)。

(3)mfence:实现Full Barrior 同时刷新store buffer和invalidate queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后写操作结果全局可见之前,mfence之前写操作结果全局可见。

(4)lock:用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果。

所以可以发现我们上述分析到的"lock addl"指令也是可以实现内存屏障效果的。

四、volatile 拓展

4.1 滥用 volatile 的危害

经过上述的总结我们可以知道volatile的实现是根据MESI缓存一致性协议实现的,而这里会用到CPU的嗅探机制,需要不断对总线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此滥用volatile可能会引起总线风暴,除了volatile之外大量的CAS操作也可能会引发这个问题。所以我们使用过程中要视情况而定,适当的场景下可以加锁来保证线程安全。

4.2 如何不用 volatile 不加锁禁止指令重排?

指令重排的示例中我们既然已经知道了插入内存屏障可以解决重排问题,那么用什么方式可以手动插入内存屏障呢?

JDK1.8之后可以在Unsafe魔术类中发现新增了插入屏障的方法。

/**
 * Ensures lack of reordering of loads before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void loadFence();
 
/**
 * Ensures lack of reordering of stores before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void storeFence();
 
/**
 * Ensures lack of reordering of loads or stores before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void fullFence();

(1)loadFence()表示该方法之前的所有load操作在内存屏障之前完成。

(2)storeFence()表示该方法之前的所有store操作在内存屏障之前完成。

(3)fullFence()表示该方法之前的所有load、store操作在内存屏障之前完成。

可以看到这三个方法正式对应了CPU插入内存屏障的三个指令lfence、sfence、mfence。

因此我们如果想手动添加内存屏障的话,可以用Unsafe的这三个native方法完成,另外由于Unsafe必须由bootstrap类加载器加载,所以我们想使用的话需要用反射的方式拿到实例对象。

/**
 * 反射获取到unsafe
 */
private Unsafe reflectGetUnsafe() throws NoSuchFieldException, IllegalAccessException {
    Field field = Unsafe.class.getDeclaredField("theUnsafe");
    field.setAccessible(true);
    return (Unsafe) field.get(null);
}
 
 
// 上述示例中手动插入内存屏障
Thread t1 = new Thread(() -> {
    a = 1;
    // 插入LoadStore()屏障
    reflectGetUnsafe().storeFence();
    x = b;
});
Thread t2 = new Thread(() -> {
    b = 1;
    // 插入LoadStore()屏障
    reflectGetUnsafe().storeFence();
    y = a;
});

4.3 单例模式的双重检查锁为什么需要用 volatile

以下是单例模式双重检查锁的初始化方式:

private volatile static Singleton instance = null;
 
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

因为synchronized虽然加了锁,但是代码块内的程序是无法保证指令重排的,其中instance = new Singleton(); 方法其实是拆分成多个指令,我们用javap -c 查看字节码,可以发现这段对象初始化操作是分成了三步:

(1)new :创建对象实例,分配内存空间

(2)invokespecial :调用构造器方法,初始化对象

(3)aload_0 :存入局部方法变量表

以上三步如果顺序执行的话是没问题的,但是如果2、3步发生指令重排,则极端并发情况下可能出现下面这种情况:

所以,为了保证单例对象顺利的初始化完成,应该给对象加上volatile关键字禁止指令重排。

五、总结

随着计算机和CPU的逐步升级,CPU缓存帮我们大大提高了数据读写的性能,在高并发的场景下,CPU通过MESI缓存一致性协议针对缓存行的失效进行处理。基于JMM模型,将用户态和内核态进行了划分,通过java提供的关键字和方法可以帮助我们解决原子性、可见性、有序性的问题。其中volatile关键字的使用最为广泛,通过添加内存屏障、lock汇编指令的方式保证了可见性和有序性,在我们开发高并发系统的过程中也要注意volatile关键字的使用,但是不能滥用,否则会导致总线风暴。

参考资料

  1. 书籍:《java并发编程实战》
  2.  IA-32手册
  3. 双重检查锁为什么要使用volatile?
  4.  java内存模型总结
  5. Java 8 Unsafe: xxxFence() instructions
作者:push
查看原文

赞 7 收藏 6 评论 0

vivo互联网技术 发布了文章 · 2020-12-29

深入浅出 ZooKeeper

ZooKeeper 是一个分布式协调服务 ,由 Apache 进行维护。

ZooKeeper 可以视为一个高可用的文件系统。

ZooKeeper 可以用于发布/订阅、负载均衡、命令服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能 。

一、ZooKeeper 简介

1.1 ZooKeeper 是什么

ZooKeeper 是 Apache 的顶级项目。ZooKeeper 为分布式应用提供了高效且可靠的分布式协调服务,提供了诸如统一命名服务、配置管理和分布式锁等分布式的基础服务。在解决分布式数据一致性方面,ZooKeeper 并没有直接采用 Paxos 算法,而是采用了名为 ZAB 的一致性协议。

ZooKeeper 主要用来解决分布式集群中应用系统的一致性问题,它能提供基于类似于文件系统的目录节点树方式的数据存储。但是 ZooKeeper 并不是用来专门存储数据的,它的作用主要是用来维护和监控存储数据的状态变化。通过监控这些数据状态的变化,从而可以达到基于数据的集群管理。

很多大名鼎鼎的框架都基于 ZooKeeper 来实现分布式高可用,如:Dubbo、Kafka 等。

1.2 ZooKeeper 的特性

ZooKeeper 具有以下特性:

  • 顺序一致性:所有客户端看到的服务端数据模型都是一致的;从一个客户端发起的事务请求,最终都会严格按照其发起顺序被应用到 ZooKeeper 中。具体的实现可见下文:原子广播。
  • 原子性:所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,即整个集群要么都成功应用了某个事务,要么都没有应用。实现方式可见下文:事务。
  • 单一视图:无论客户端连接的是哪个 Zookeeper 服务器,其看到的服务端数据模型都是一致的。
  • 高性能:ZooKeeper 将数据全量存储在内存中,所以其性能很高。需要注意的是:由于 ZooKeeper 的所有更新和删除都是基于事务的,因此 ZooKeeper 在读多写少的应用场景中有性能表现较好,如果写操作频繁,性能会大大下滑。
  • 高可用:ZooKeeper 的高可用是基于副本机制实现的,此外 ZooKeeper 支持故障恢复,可见下文:选举 Leader。

1.3 ZooKeeper 的设计目标

  • 简单的数据模型
  • 可以构建集群
  • 顺序访问
  • 高性能

二、ZooKeeper 核心概念

2.1  数据模型

ZooKeeper 的数据模型是一个树形结构的文件系统。

树中的节点被称为 znode,其中根节点为 /,每个节点上都会保存自己的数据和节点信息。znode 可以用于存储数据,并且有一个与之相关联的 ACL(详情可见 ACL)。ZooKeeper 的设计目标是实现协调服务,而不是真的作为一个文件存储,因此 znode 存储数据的大小被限制在 1MB 以内。

ZooKeeper 的数据访问具有原子性。其读写操作都是要么全部成功,要么全部失败。

znode 通过路径被引用。znode 节点路径必须是绝对路径。

znode 有两种类型:

  • 临时的( EPHEMERAL ):客户端会话结束时,ZooKeeper 就会删除临时的 znode。
  • 持久的(PERSISTENT ):除非客户端主动执行删除操作,否则 ZooKeeper 不会删除持久的 znode。

2.2  节点信息

znode 上有一个顺序标志( SEQUENTIAL )。如果在创建 znode 时,设置了顺序标志( SEQUENTIAL ),那么 ZooKeeper 会使用计数器为 znode 添加一个单调递增的数值,即 zxid。ZooKeeper 正是利用 zxid 实现了严格的顺序访问控制能力。

每个 znode 节点在存储数据的同时,都会维护一个叫做 Stat 的数据结构,里面存储了关于该节点的全部状态信息。如下:

2.3 集群角色

Zookeeper 集群是一个基于主从复制的高可用集群,每个服务器承担如下三种角色中的一种。

  • Leader:它负责 发起并维护与各 Follwer 及 Observer 间的心跳。所有的写操作必须要通过 Leader 完成再由 Leader 将写操作广播给其它服务器。一个 Zookeeper 集群同一时间只会有一个实际工作的 Leader。
  • Follower:它会响应 Leader 的心跳。Follower 可直接处理并返回客户端的读请求,同时会将写请求转发给 Leader 处理,并且负责在 Leader 处理写请求时对请求进行投票。一个 Zookeeper 集群可能同时存在多个 Follower。
  • Observer:角色与 Follower 类似,但是无投票权。

2.4 ACL

ZooKeeper 采用 ACL(Access Control Lists)策略来进行权限控制。

每个 znode 创建时都会带有一个 ACL 列表,用于决定谁可以对它执行何种操作。

ACL 依赖于 ZooKeeper 的客户端认证机制。ZooKeeper 提供了以下几种认证方式:

  • digest:用户名和密码 来识别客户端
  • sasl:通过 kerberos 来识别客户端
  • ip:通过 IP 来识别客户端

ZooKeeper 定义了如下五种权限:

  • CREATE:允许创建子节点;
  • READ:允许从节点获取数据并列出其子节点;
  • WRITE:允许为节点设置数据;
  • DELETE:允许删除子节点;
  • ADMIN:允许为节点设置权限。

三、ZooKeeper 工作原理

3.1 读操作

Leader/Follower/Observer 都可直接处理读请求,从本地内存中读取数据并返回给客户端即可。

由于处理读请求不需要服务器之间的交互,Follower/Observer 越多,整体系统的读请求吞吐量越大,也即读性能越好。

3.2 写操作

所有的写请求实际上都要交给 Leader 处理。Leader 将写请求以事务形式发给所有 Follower 并等待 ACK,一旦收到半数以上 Follower 的 ACK,即认为写操作成功。

3.2.1 写 Leader

由上图可见,通过 Leader 进行写操作,主要分为五步:

  1. 客户端向 Leader 发起写请求。
  2. Leader 将写请求以事务 Proposal 的形式发给所有 Follower 并等待 ACK。
  3. Follower 收到 Leader 的事务 Proposal 后返回 ACK。
  4. Leader 得到过半数的 ACK(Leader 对自己默认有一个 ACK)后向所有的 Follower 和 Observer 发送 Commmit。
  5. Leader 将处理结果返回给客户端。

注意

  • Leader 不需要得到 Observer 的 ACK,即 Observer 无投票权。
  • Leader 不需要得到所有 Follower 的 ACK,只要收到过半的 ACK 即可,同时 Leader 本身对自己有一个 ACK。上图中有 4 个 Follower,只需其中两个返回 ACK 即可,因为 $$(2+1) / (4+1) > 1/2$$ 。
  • Observer 虽然无投票权,但仍须同步 Leader 的数据从而在处理读请求时可以返回尽可能新的数据。

3.2.2 写 Follower/Observer

Follower/Observer 均可接受写请求,但不能直接处理,而需要将写请求转发给 Leader 处理。

除了多了一步请求转发,其它流程与直接写 Leader 无任何区别。

3.3 事务

对于来自客户端的每个更新请求,ZooKeeper 具备严格的顺序访问控制能力。

为了保证事务的顺序一致性,ZooKeeper 采用了递增的事务 id 号(zxid)来标识事务。

Leader 服务会为每一个 Follower 服务器分配一个单独的队列,然后将事务 Proposal 依次放入队列中,并根据 FIFO(先进先出) 的策略进行消息发送。Follower 服务在接收到 Proposal 后,会将其以事务日志的形式写入本地磁盘中,并在写入成功后反馈给 Leader 一个 Ack 响应。当 Leader 接收到超过半数 Follower 的 Ack 响应后,就会广播一个 Commit 消息给所有的 Follower 以通知其进行事务提交,之后 Leader 自身也会完成对事务的提交。而每一个 Follower 则在接收到 Commit 消息后,完成事务的提交。

所有的提议(proposal)都在被提出的时候加上了 zxid。zxid 是一个 64 位的数字,它的高 32 位是 epoch 用来标识 Leader 关系是否改变,每次一个 Leader 被选出来,它都会有一个新的 epoch,标识当前属于那个 leader 的统治时期。低 32 位用于递增计数。

详细过程如下:

  • Leader 等待 Server 连接;
  • Follower 连接 Leader,将最大的 zxid 发送给 Leader;
  • Leader 根据 Follower 的 zxid 确定同步点;
  • 完成同步后通知 follower 已经成为 uptodate 状态;
  • Follower 收到 uptodate 消息后,又可以重新接受 client 的请求进行服务了。

3.4 观察

客户端注册监听它关心的 znode,当 znode 状态发生变化(数据变化、子节点增减变化)时,ZooKeeper 服务会通知客户端。

客户端和服务端保持连接一般有两种形式:

  • 客户端向服务端不断轮询
  • 服务端向客户端推送状态

Zookeeper 的选择是服务端主动推送状态,也就是观察机制( Watch )。

ZooKeeper 的观察机制允许用户在指定节点上针对感兴趣的事件注册监听,当事件发生时,监听器会被触发,并将事件信息推送到客户端。

客户端使用 getData 等接口获取 znode 状态时传入了一个用于处理节点变更的回调,那么服务端就会主动向客户端推送节点的变更:

从这个方法中传入的 Watcher 对象实现了相应的 process 方法,每次对应节点出现了状态的改变,WatchManager 都会通过以下的方式调用传入 Watcher 的方法:

Set<Watcher> triggerWatch(String path, EventType type, Set<Watcher> supress) {
    WatchedEvent e = new WatchedEvent(type, KeeperState.SyncConnected, path);
    Set<Watcher> watchers;
    synchronized (this) {
        watchers = watchTable.remove(path);
    }
    for (Watcher w : watchers) {
        w.process(e);
    }
    return

Zookeeper 中的所有数据其实都是由一个名为 DataTree 的数据结构管理的,所有的读写数据的请求最终都会改变这颗树的内容,在发出读请求时可能会传入 Watcher 注册一个回调函数,而写请求就可能会触发相应的回调,由 WatchManager 通知客户端数据的变化。

通知机制的实现其实还是比较简单的,通过读请求设置 Watcher 监听事件,写请求在触发事件时就能将通知发送给指定的客户端。

3.5 会话

ZooKeeper 客户端通过 TCP 长连接连接到 ZooKeeper 服务集群。会话 (Session) 从第一次连接开始就已经建立,之后通过心跳检测机制来保持有效的会话状态。通过这个连接,客户端可以发送请求并接收响应,同时也可以接收到 Watch 事件的通知。

每个 ZooKeeper 客户端配置中都配置了 ZooKeeper 服务器集群列表。启动时,客户端会遍历列表去尝试建立连接。如果失败,它会尝试连接下一个服务器,依次类推。

一旦一台客户端与一台服务器建立连接,这台服务器会为这个客户端创建一个新的会话。每个会话都会有一个超时时间,若服务器在超时时间内没有收到任何请求,则相应会话被视为过期。一旦会话过期,就无法再重新打开,且任何与该会话相关的临时 znode 都会被删除。

通常来说,会话应该长期存在,而这需要由客户端来保证。客户端可以通过心跳方式(ping)来保持会话不过期。

ZooKeeper 的会话具有四个属性:

  • sessionID:会话 ID,唯一标识一个会话,每次客户端创建新的会话时,Zookeeper 都会为其分配一个全局唯一的 sessionID。
  • TimeOut:会话超时时间,客户端在构造 Zookeeper 实例时,会配置 sessionTimeout 参数用于指定会话的超时时间,Zookeeper 客户端向服务端发送这个超时时间后,服务端会根据自己的超时时间限制最终确定会话的超时时间。
  • TickTime:下次会话超时时间点,为了便于 Zookeeper 对会话实行”分桶策略”管理,同时为了高效低耗地实现会话的超时检查与清理,Zookeeper 会为每个会话标记一个下次会话超时时间点,其值大致等于当前时间加上 TimeOut。
  • isClosing:标记一个会话是否已经被关闭,当服务端检测到会话已经超时失效时,会将该会话的 isClosing 标记为”已关闭”,这样就能确保不再处理来自该会话的新请求了。

Zookeeper 的会话管理主要是通过 SessionTracker 来负责,其采用了分桶策略(将类似的会话放在同一区块中进行管理)进行管理,以便 Zookeeper 对会话进行不同区块的隔离处理以及同一区块的统一处理。

四、ZAB 协议

ZooKeeper 并没有直接采用 Paxos 算法,而是采用了名为 ZAB 的一致性协议。ZAB 协议不是 Paxos 算法,只是比较类似,二者在操作上并不相同。

ZAB 协议是 Zookeeper 专门设计的一种支持崩溃恢复的原子广播协议。

ZAB 协议是 ZooKeeper 的数据一致性和高可用解决方案。

ZAB 协议定义了两个可以无限循环的流程:

  • 选举 Leader:用于故障恢复,从而保证高可用。
  • 原子广播:用于主从同步,从而保证数据一致性。

4.1 选举 Leader

ZooKeeper 的故障恢复

ZooKeeper 集群采用一主(称为 Leader)多从(称为 Follower)模式,主从节点通过副本机制保证数据一致。

  • 如果 Follower 节点挂了 - ZooKeeper 集群中的每个节点都会单独在内存中维护自身的状态,并且各节点之间都保持着通讯,只要集群中有半数机器能够正常工作,那么整个集群就可以正常提供服务。
  • 如果 Leader 节点挂了 - 如果 Leader 节点挂了,系统就不能正常工作了。此时,需要通过 ZAB 协议的选举 Leader 机制来进行故障恢复。

ZAB 协议的选举 Leader 机制简单来说,就是:基于过半选举机制产生新的 Leader,之后其他机器将从新的 Leader 上同步状态,当有过半机器完成状态同步后,就退出选举 Leader 模式,进入原子广播模式。

4.1.1 术语

myid:每个 Zookeeper 服务器,都需要在数据文件夹下创建一个名为 myid 的文件,该文件包含整个 Zookeeper 集群唯一的 ID(整数)。

zxid:类似于 RDBMS 中的事务 ID,用于标识一次更新操作的 Proposal ID。为了保证顺序性,该 zkid 必须单调递增。因此 Zookeeper 使用一个 64 位的数来表示,高 32 位是 Leader 的 epoch,从 1 开始,每次选出新的 Leader,epoch 加一。低 32 位为该 epoch 内的序号,每次 epoch 变化,都将低 32 位的序号重置。这样保证了 zkid 的全局递增性。

4.1.2 服务器状态

  • LOOKING:不确定 Leader 状态。该状态下的服务器认为当前集群中没有 Leader,会发起 Leader 选举。
  • FOLLOWING:跟随者状态。表明当前服务器角色是 Follower,并且它知道 Leader 是谁。
  • LEADING:领导者状态。表明当前服务器角色是 Leader,它会维护与 Follower 间的心跳。
  • OBSERVING:观察者状态。表明当前服务器角色是 Observer,与 Folower 唯一的不同在于不参与选举,也不参与集群写操作时的投票。

4.1.3 选票数据结构

每个服务器在进行领导选举时,会发送如下关键信息:

  • logicClock:每个服务器会维护一个自增的整数,名为 logicClock,它表示这是该服务器发起的第多少轮投票。
  • state:当前服务器的状态。
  • self_id:当前服务器的 myid。
  • self_zxid:当前服务器上所保存的数据的最大 zxid。
  • vote_id:被推举的服务器的 myid。
  • vote_zxid:被推举的服务器上所保存的数据的最大 zxid。

4.1.4 投票流程

(1)自增选举轮次

Zookeeper 规定所有有效的投票都必须在同一轮次中。每个服务器在开始新一轮投票时,会先对自己维护的 logicClock 进行自增操作。

(2)初始化选票

每个服务器在广播自己的选票前,会将自己的投票箱清空。该投票箱记录了所收到的选票。例:服务器 2 投票给服务器 3,服务器 3 投票给服务器 1,则服务器 1 的投票箱为(2, 3), (3, 1), (1, 1)。票箱中只会记录每一投票者的最后一票,如投票者更新自己的选票,则其它服务器收到该新选票后会在自己票箱中更新该服务器的选票。

(3)发送初始化选票

每个服务器最开始都是通过广播把票投给自己。

(4)接收外部投票

服务器会尝试从其它服务器获取投票,并记入自己的投票箱内。如果无法获取任何外部投票,则会确认自己是否与集群中其它服务器保持着有效连接。如果是,则再次发送自己的投票;如果否,则马上与之建立连接。

(5)判断选举轮次

收到外部投票后,首先会根据投票信息中所包含的 logicClock 来进行不同处理:

  • 外部投票的 logicClock大于自己的 logicClock。说明该服务器的选举轮次落后于其它服务器的选举轮次,立即清空自己的投票箱并将自己的 logicClock 更新为收到的 logicClock,然后再对比自己之前的投票与收到的投票以确定是否需要变更自己的投票,最终再次将自己的投票广播出去。
  • 外部投票的 logicClock小于自己的 logicClock。当前服务器直接忽略该投票,继续处理下一个投票。
  • 外部投票的 logickClock 与自己的相等。当时进行选票 PK。

(6)选票 PK

选票 PK 是基于(self\_id, self\_zxid)与(vote\_id, vote\_zxid)的对比:

  • 外部投票的 logicClock大于自己的 logicClock,则将自己的 logicClock 及自己的选票的 logicClock 变更为收到的 logicClock。
  • logicClock一致,则对比二者的 vote\_zxid,若外部投票的 vote\_zxid 比较大,则将自己的票中的 vote\_zxid 与 vote\_myid 更新为收到的票中的 vote\_zxid 与 vote\_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。如果票箱内已存在(self\_myid, self\_zxid)相同的选票,则直接覆盖。
  • 若二者vote_zxid一致,则比较二者的 vote\_myid,若外部投票的 vote\_myid 比较大,则将自己的票中的 vote\_myid 更新为收到的票中的 vote\_myid 并广播出去,另外将收到的票及自己更新后的票放入自己的票箱。

(7)统计选票

如果已经确定有过半服务器认可了自己的投票(可能是更新后的投票),则终止投票。否则继续接收其它服务器的投票。

(8)更新服务器状态

投票终止后,服务器开始更新自身状态。若过半的票投给了自己,则将自己的服务器状态更新为 LEADING,否则将自己的状态更新为 FOLLOWING。

通过以上流程分析,我们不难看出:要使 Leader 获得多数 Server 的支持,则 ZooKeeper 集群节点数必须是奇数。且存活的节点数目不得少于 N + 1

每个 Server 启动后都会重复以上流程。在恢复模式下,如果是刚从崩溃状态恢复的或者刚启动的 server 还会从磁盘快照中恢复数据和会话信息,zk 会记录事务日志并定期进行快照,方便在恢复时进行状态恢复。

4.2 原子广播(Atomic Broadcast)

ZooKeeper 通过副本机制来实现高可用。

那么,ZooKeeper 是如何实现副本机制的呢?答案是:ZAB 协议的原子广播。

ZAB 协议的原子广播要求:

所有的写请求都会被转发给 Leader,Leader 会以原子广播的方式通知 Follow。当半数以上的 Follow 已经更新状态持久化后,Leader 才会提交这个更新,然后客户端才会收到一个更新成功的响应。这有些类似数据库中的两阶段提交协议。

在整个消息的广播过程中,Leader 服务器会每个事物请求生成对应的 Proposal,并为其分配一个全局唯一的递增的事务 ID(ZXID),之后再对其进行广播。

五、ZooKeeper 应用

ZooKeeper 可以用于发布/订阅、负载均衡、命令服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能 。

5.1 命名服务

在分布式系统中,通常需要一个全局唯一的名字,如生成全局唯一的订单号等,ZooKeeper 可以通过顺序节点的特性来生成全局唯一 ID,从而可以对分布式系统提供命名服务。

5.2 配置管理

利用 ZooKeeper 的观察机制,可以将其作为一个高可用的配置存储器,允许分布式应用的参与者检索和更新配置文件。

5.3 分布式锁

可以通过 ZooKeeper 的临时节点和 Watcher 机制来实现分布式锁。

举例来说,有一个分布式系统,有三个节点 A、B、C,试图通过 ZooKeeper 获取分布式锁。

(1)访问 /lock (这个目录路径由程序自己决定),创建 带序列号的临时节点(EPHEMERAL) 。

(2)每个节点尝试获取锁时,拿到 /locks节点下的所有子节点(id\_0000,id\_0001,id_0002),判断自己创建的节点是不是最小的。

  • 如果是,则拿到锁。

    释放锁:执行完操作后,把创建的节点给删掉。

  • 如果不是,则监听比自己要小 1 的节点变化。

(3)释放锁,即删除自己创建的节点。

图中,NodeA 删除自己创建的节点 id_0000,NodeB 监听到变化,发现自己的节点已经是最小节点,即可获取到锁。

5.4 集群管理

ZooKeeper 还能解决大多数分布式系统中的问题:

  • 如可以通过创建临时节点来建立心跳检测机制。如果分布式系统的某个服务节点宕机了,则其持有的会话会超时,此时该临时节点会被删除,相应的监听事件就会被触发。
  • 分布式系统的每个服务节点还可以将自己的节点状态写入临时节点,从而完成状态报告或节点工作进度汇报。
  • 通过数据的订阅和发布功能,ZooKeeper 还能对分布式系统进行模块的解耦和任务的调度。
  • 通过监听机制,还能对分布式系统的服务节点进行动态上下线,从而实现服务的动态扩容。

5.5 选举 Leader 节点

分布式系统一个重要的模式就是主从模式 (Master/Salves),ZooKeeper 可以用于该模式下的 Matser 选举。可以让所有服务节点去竞争性地创建同一个 ZNode,由于 ZooKeeper 不能有路径相同的 ZNode,必然只有一个服务节点能够创建成功,这样该服务节点就可以成为 Master 节点。

5.6 队列管理

ZooKeeper 可以处理两种类型的队列:

  • 当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达,这种是同步队列。
  • 队列按照 FIFO 方式进行入队和出队操作,例如实现生产者和消费者模型。

同步队列用 ZooKeeper 实现的实现思路如下:

创建一个父目录 /synchronizing,每个成员都监控标志(Set Watch)位目录 /synchronizing/start 是否存在,然后每个成员都加入这个队列,加入队列的方式就是创建 /synchronizing/member\_i 的临时目录节点,然后每个成员获取 / synchronizing 目录的所有目录节点,也就是 member\_i。判断 i 的值是否已经是成员的个数,如果小于成员个数等待 /synchronizing/start 的出现,如果已经相等就创建 /synchronizing/start。

参考资料

官方

书籍

文章

作者:ZhangPeng
查看原文

赞 15 收藏 10 评论 1

vivo互联网技术 发布了文章 · 2020-12-28

vivo 互联网业务就近路由技术实战

一、问题背景

在vivo互联网业务高速发展的同时,支撑的服务实例规模也越来越大,然而单个机房能承载的机器容量是有限的,于是同城多机房甚至多地域部署就成为了业务在实际部署过程中不得不面临的场景。

一般情况下,同一个机房内部的网络调用平均时延在0.1ms左右,同城多个机房之间的平均时延在1ms左右,跨地域机房之间的网络时延则更大,例如北京到上海的平均时延达到了30ms以上。

在业务多机房部署场景中,内部服务如果存在大量的跨机房、甚至跨地域的网络调用,则请求时延会显著加大,会直接影响到服务质量,甚至是用户体验。

二、解决方案

要解决以上问题,只需要实现服务消费者和服务提供者之间的网络调用尽量是同机房内部、或者是同地域即可,即服务消费者在调用服务提供者时,优先调用部署在本机房的服务提供者,当本机房没有部署该服务提供者时,再跨机房调用同地域的同城机房、甚至是跨地域机房的服务。

以上策略我们内部称为 就近路由。业务的就近路由示意图如下:

为了简单易用,vivo内部的服务间调用均使用了RPC框架来实现,在Java技术栈方向,我们选择了阿里巴巴开源的RPC框架 Dubbo,针对该场景的问题,我们扩展了Dubbo框架的源码实现,提供了 Dubbo就近路由能力。

三、技术原理说明

在服务提供者注册服务时,把该实例节点的机房信息【我们内部将机房标签定义为 app\_loc 字段】注册到注册中心,在开启了就近路由策略后,消费者在过滤服务列表时,会自动筛选、匹配和消费者自己 app\_loc 字段相同的服务提供者列表,从而实现就近路由访问。我们实现的就近路由策略,在本机房存在对应服务提供者的情况下,消费者会优先调用本机房的服务。

四、实现方案

开源版本的Dubbo框架并不提供就近路由能力,我们需要基于Dubbo框架源码扩展实现。Dubbo框架整体设计如下:【左侧为服务消费者使用的接口,右侧为服务提供方使用的接口】。

我们知道,Dubbo框架中服务消费者选择具体的服务提供者实例调用的匹配、筛选逻辑是在 Consumer 侧完成的,在 Cluster 层中,消费者会先应用用户配置的 Router 规则,然后再符合规则的服务提供者列表中,使用 LoadBalance 策略选择具体的服务提供者节点进行调用。

结合Dubbo框架源码实现,我们选择基于Dubbo 2.7.x版本的router层扩展口 org.apache.dubbo.rpc.cluster.Router 实现一种新的路由方式,即就近路由(我们内部标识为 NearestRouter)

有了具体的解决方案,我们很快就完成了代码开发,内部也发布了一个集成就近路由策略的Dubbo版本,但在实际的线上灰度和业务推广过程中,我们实现的初版就近路由碰到了新的问题:

  • 基于机房容量、机器成本等因素考虑,并不是所有的业务都实现了多机房部署,即有部分业务只实现了单机房部署,这部分业务的消费方无法实现同机房内部调用;
  • 即使部分业务实现了多机房部署,但多个机房之间能提供的服务容量并不是相同的,对于服务容量较小的机房,如果一部分服务节点不可用,剩下的服务节点能提供的服务容量无法支撑本机房的消费方调用时,会造成该机房内的服务节点雪崩;
  • 业务侧在开启就近路由策略时,希望消费服务的业务方能逐个开启,有一段时间的灰度观察过程,保证更平滑的升级验证;而不是比较粗暴的要么开启,要么关闭;
  • 部分消费者的Dubbo版本较低,不支持就近路由功能,或者不支持配置应用维度的就近路由,在业务灰度过程中,希望能实现向前兼容,业务侧不报错。

基于以上问题,我们细化了实现方案:

  • 就近路由策略默认不强制执行,即当本机房不存在服务提供者时,不再区分本机房、跨机房,就近路由策略自动失效,优先保障服务之间的正常调用;
  • 支持设置就近路由策略的降级阈值,在调用本机房服务的过程中,当 本机房服务实例数量 / 集群服务实例数量 得到的数值小于设置的降级阈值时,我们认为当前机房的服务容量无法支撑本机房的消费方调用,就近路由策略自动失效;
  • 支持配置应用维度的就近路由策略,即配置的就近路由策略可只针对配置的应用生效,实现应用维度的灰度效果;
  • 实现Dubbo版本自动校验能力,不满足开启就近路由策略条件的业务,提示用户不开启。

有了以上细化方案,我们梳理的就近路由大致逻辑流程如下:

扩展Dubbo框架 Router 接口实现的 NearestRouter 核心代码如下:


public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL consumerUrl, Invocation invocation) throws RpcException {
        // validate application name (this.url -> routerUrl)
        String applicationName = getProperty(APP_NAME, consumerUrl.getParameter(CommonConstants.APPLICATION_KEY, ""));
        boolean validAppFlag = application.equals(applicationName) || CommonConstants.ANY_VALUE.equals(application);
        if (!validAppFlag) {
            return invokers;
        }
 
        String local = getProperty(APP_LOC);
        if (invokers == null || invokers.size() == 0) {
            return invokers;
        }
        List<Invoker<T>> result = new ArrayList<Invoker<T>>();
        for (Invoker invoker: invokers) {
            String invokerLoc = getProperty(invoker, invocation, APP_LOC);
            if (local.equals(invokerLoc)) {
                result.add(invoker);
            }
        }
 
        if (result.size() > 0) {
            if (fallback){
                // 开启服务降级,available.ratio = 当前机房可用服务节点数量 / 集群可用服务节点数量
                int curAvailableRatio = (int) Math.floor(result.size() * 100.0d / invokers.size());
                if (curAvailableRatio <= availableRatio) {
                    return invokers;
                }
            }
 
            return result;
        } else if (force) {
            LOGGER.warn("The route result is empty and force execute. consumerIp: " + NetUtils.getLocalHost()
                    + ", service: " + consumerUrl.getServiceKey() + ", appLoc: " + local
                    + ", routerName: " + this.getUrl().getParameterAndDecoded("name"));
            return result;
        } else {
            return invokers;
        }
}

在vivo内部的服务治理平台上,我们提供了可视化的配置能力,页面内容如下:

通过扩展Dubbo框架,服务治理平台能力支持,我们以上问题都得到了较好的解决。

五、写在将来

虽然以上解决方案能覆盖我们当前业务场景中的大部分问题,随着业务的高速发展,新的业务问题也接踵而至。

  • 当前就近路由策略代码实现集成在Dubbo框架中,业务侧需升级Dubbo框架版本才能完成升级,升级周期长,框架碎片化问题趋向严重;
  • 当前业务服务注册时携带的机房信息比较有限,例如缺失服务实例所在的机架信息,和当前服务同城的其他机房信息等,该类信息可支持我们实现更丰富的就近路由策略;
  • 业务侧的流量灰度策略较丰富,除了就近路由策略之外、往往还需要结合Dubbo框架自带的条件路由、标签路由策略才能实现;
  • 在特定场景下,就近路由策略失效由框架自动完成,业务侧缺少及时的感知能力。

针对以上问题,我们的解决思路如下:

  • 适配云原生ServiceMesh技术方案,实现RPC框架复杂逻辑和业务代码的分层解耦,业务侧集成的SDK功能偏向简单,版本迭代较少,版本碎片化问题能得到较好解决;框架的复杂逻辑下沉到Agent端,框架升级业务基本无感知。幸运的是Dubbo 3.0 版本已规划增加对云原生的支持,我们将持续关注;
  • Dubbo框架的注册中心和内部CMDB系统结合,可以获取到更多维度的信息,不限于该服务节点所在的机架、同城的其他机房信息等。目前vivo内部已启动自研注册中心,可在该场景的能力建设上走的更远;
  • 服务治理平台结合业务侧常用的流量灰度策略,提供结合就近路由、条件路由、标签路由的组合式路由策略,提供更丰富的流量路由能力;
  • 针对框架错误、异常、自适应变更等的告警需求,vivo内部规划建设统一的框架SDK侧的监控告警能力,对于常见的错误、异常、自适应变更等,从SDK侧直接捕获,并和告警中心进行联动,缩短整个监控告警的链路路径,让业务侧能及时感知,甚至是提前感知。
作者:LuoLiang
查看原文

赞 1 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-28

vivo 全球商城:订单中心架构设计与实践

一、背景

随着用户量级的快速增长,vivo 官方商城 v1.0 的单体架构逐渐暴露出弊端:模块愈发臃肿、开发效率低下、性能出现瓶颈、系统维护困难。

从2017年开始启动的 v2.0 架构升级,基于业务模块进行垂直的系统物理拆分,拆分出来业务线各司其职,提供服务化的能力,共同支撑主站业务。

订单模块是电商系统的交易核心,不断累积的数据即将达到单表存储瓶颈,系统难以支撑新品发布和大促活动期间的流量,服务化改造势在必行。

本文将介绍 vivo 商城 订单系统建设的过程中遇到的问题和解决方案,分享架构设计经验。

二、系统架构

将订单模块从商城拆分出来,独立为订单系统,使用独立的数据库,为商城相关系统提供订单、支付、物流、售后等标准化服务。

系统架构如下图所示:

三、技术挑战

3.1 数据量和高并发问题

首先面对的挑战来自存储系统:

  • 数据量问题

    随着历史订单不断累积,MySQL中订单表数据量已达千万级。

    我们知道InnoDB存储引擎的存储结构是B+树,查找时间复杂度是O(log n),因此当数据总量n变大时,检索速度必然会变慢, 不论如何加索引或者优化都无法解决,只能想办法减小单表数据量。

    数据量大的解决方案有:数据归档、分表

  • 高并发问题

    商城业务处于高速发展期,下单量屡创新高,业务复杂度也在提升,应用程序对MySQL的访问量越来越高。

    单机MySQL的处理能力是有限的,当压力过大时,所有请求的访问速度都会下降,甚至有可能使数据库宕机。

    并发量高的解决方案有:使用缓存、读写分离、分库

下面对这些方案进行简单描述:

  • 数据归档

    订单数据具备时间属性,存在热尾效应,大部分情况下检索的都是最近的订单,而订单表里却存储了大量使用频率较低的老数据。

    那么就可以将新老数据分开存储,将历史订单移入另一张表中,并对代码中的查询模块做一些相应改动,便能有效解决数据量大的问题。

  • 使用缓存

    使用Redis作为MySQL的前置缓存,可以挡住大部分的查询请求,并降低响应时延。

    缓存对商品系统这类与用户关系不大的系统效果特别好,但对订单系统而言,每个用户的订单数据都不一样,缓存命中率不算高,效果不是太好。

  • 读写分离

    主库负责执行数据更新请求,然后将数据变更实时同步到所有从库,用多个从库来分担查询请求。

    但订单数据的更新操作较多,下单高峰时主库的压力依然没有得到解决。且存在主从同步延迟,正常情况下延迟非常小,不超过1ms,但也会导致在某一个时刻的主从数据不一致。

    那就需要对所有受影响的业务场景进行兼容处理,可能会做一些妥协,比如下单成功后先跳转到一个下单成功页,用户手动点击查看订单后才能看到这笔订单。

  • 分库

    分库又包含垂直分库和水平分库。

    ① 水平分库:把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上。

    ② 垂直分库:按照业务将表进行分类,分布到不同的数据库上面,每个库可以放在不同的服务器上,它的核心理念是专库专用。

  • 分表

    分表又包含垂直分表和水平分表。

    *① 水平分表:*在同一个数据库内,把一个表的数据按一定规则拆到多个表中。

    *② 垂直分表:*将一个表按照字段分成多表,每个表存储其中一部分字段。

我们综合考虑了改造成本、效果和对现有业务的影响,决定直接使用最后一招:分库分表

3.2 分库分表技术选型

分库分表的技术选型主要从这几个方向考虑:

  1. 客户端sdk开源方案
  2. 中间件proxy开源方案
  3. 公司中间件团队提供的自研框架
  4. 自己动手造轮子

参考之前项目经验,并与公司中间件团队沟通后,采用了开源的Sharding-JDBC方案。现已更名为Sharding-Sphere。

  • Github:https://github.com/sharding-sphere/
  • 文档:官方文档比较粗糙,但是网上资料、源码解析、demo比较丰富
  • 社区:活跃
  • 特点:jar包方式提供,属于client端分片,支持xa事务

 

3.2.1 分库分表策略

结合业务特性,选取用户标识作为分片键,通过计算用户标识的哈希值再取模来得到用户订单数据的库表编号.
假设共有n个库,每个库有m张表,

则库表编号的计算方式为:

- 库序号:Hash(userId) / m % n

- 表序号:Hash(userId) % m

路由过程如下图所示:

3.2.2 分库分表的局限性和应对方案

分库分表解决了数据量和并发问题,但它会极大限制数据库的查询能力,有一些之前很简单的关联查询,在分库分表之后可能就没法实现了,那就需要单独对这些Sharding-JDBC不支持的SQL进行改写。

除此之外,还遇到了这些挑战:

(1)全局唯一ID设计

分库分表后,数据库自增主键不再全局唯一,不能作为订单号来使用,但很多内部系统间的交互接口只有订单号,没有用户标识这个分片键,如何用订单号来找到对应的库表呢?

原来,我们在生成订单号时,就将库表编号隐含在其中了。这样就能在没有用户标识的场景下,从订单号中获取库表编号。

(2)历史订单号没有隐含库表信息

用一张表单独存储历史订单号和用户标识的映射关系,随着时间推移,这些订单逐渐不在系统间交互,就慢慢不再被用到。

(3)管理后台需要根据各种筛选条件,分页查询所有满足条件的订单

将订单数据冗余存储在搜索引擎Elasticsearch中,仅用于后台查询。

3.3 怎么做 MySQL 到 ES 的数据同步

上面说到为了便于管理后台的查询,我们将订单数据冗余存储在Elasticsearch中,那么,如何在MySQL的订单数据变更后,同步到ES中呢?

这里要考虑的是数据同步的时效性和一致性、对业务代码侵入小、不影响服务本身的性能等。

  • MQ方案

    ES更新服务作为消费者,接收订单变更MQ消息后对ES进行更新

  • Binlog方案

    ES更新服务借助canal等开源项目,把自己伪装成MySQL的从节点,接收Binlog并解析得到实时的数据变更信息,然后根据这个变更信息去更新ES。

其中BinLog方案比较通用,但实现起来也较为复杂,我们最终选用的是MQ方案。

因为ES数据只在管理后台使用,对数据可靠性和同步实时性的要求不是特别高。

考虑到宕机和消息丢失等极端情况,在后台增加了按某些条件手动同步ES数据的功能来进行补偿。

3.4 如何安全地更换数据库

如何将数据从原来的单实例数据库迁移到新的数据库集群,也是一大技术挑战

不但要确保数据的正确性,还要保证每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。

我们考虑了停机迁移和不停机迁移的两种方案:

(1)不停机迁移方案:

  • 把旧库的数据复制到新库中,上线一个同步程序,使用 Binlog等方案实时同步旧库数据到新库。
  • 上线双写订单新旧库服务,只读写旧库。
  • 开启双写,同时停止同步程序,开启对比补偿程序,确保新库数据和旧库一致。
  • 逐步将读请求切到新库上。
  • 读写都切换到新库上,对比补偿程序确保旧库数据和新库一致。
  • 下线旧库,下线订单双写功能,下线同步程序和对比补偿程序。

(2)停机迁移方案:

  • 上线新订单系统,执行迁移程序将两个月之前的订单同步到新库,并对数据进行稽核。
  • 将商城V1应用停机,确保旧库数据不再变化。
  • 执行迁移程序,将第一步未迁移的订单同步到新库并进行稽核。
  • 上线商城V2应用,开始测试验证,如果失败则回退到商城V1应用(新订单系统有双写旧库的开关)。

考虑到不停机方案的改造成本较高,而夜间停机方案的业务损失并不大,最终选用的是停机迁移方案。

3.5 分布式事务问题

电商的交易流程中,分布式事务是一个经典问题,比如:

  • 用户支付成功后,需要通知发货系统给用户发货。
  • 用户确认收货后,需要通知积分系统给用户发放购物奖励的积分。

我们是如何保证微服务架构下数据的一致性呢?

不同业务场景对数据一致性的要求不同,业界的主流方案中,用于解决强一致性的有两阶段提交(2PC)、三阶段提交(3PC),解决最终一致性的有TCC、本地消息、事务消息和最大努力通知等。

这里不对上述方案进行详细的描述,介绍一下我们正在使用的本地消息表方案:在本地事务中将要执行的异步操作记录在消息表中,如果执行失败,可以通过定时任务来补偿。

下图以订单完成后通知积分系统赠送积分为例。

3.6 系统安全和稳定性

  • 网络隔离

    只有极少数第三方接口可通过外网访问,且都会验证签名,内部系统交互使用内网域名和RPC接口。

  • 并发锁

    任何订单更新操作之前,会通过数据库行级锁加以限制,防止出现并发更新。

  • 幂等性

    所有接口均具备幂等性,不用担心对方网络超时重试所造成的影响。

  • 熔断

    使用Hystrix组件,对外部系统的实时调用添加熔断保护,防止某个系统故障的影响扩大到整个分布式系统中。

  • 监控和告警

    通过配置日志平台的错误日志报警、调用链的服务分析告警,再加上公司各中间件和基础组件的监控告警功能,让我们能够能够第一时间发现系统异常。

3.7  踩过的坑

采用MQ消费的方式同步数据库的订单相关数据到ES中,遇到的写入数据不是订单最新数据问题

下图左边是原方案:

在消费订单数据同步的MQ时,如果线程A在先执行,查出数据,这时候订单数据被更新了,线程B开始执行同步操作,查出订单数据后先于线程A一步写入ES中,线程A执行写入时就会将线程B写入的数据覆盖,导致ES中的订单数据不是最新的。

解决方案是在查询订单数据时加行锁,整个业务执行在事务中,执行完成后再执行下一个线程。

sharding-jdbc 分组后排序分页查询出所有数据问题

示例:select a  from  temp group by a,b order by a  desc limit 1,10。

执行是Sharding-jdbc里group by 和 order by 字段和顺序不一致是将10置为Integer.MAX_VALUE, 导致分页查询失效。


io.shardingsphere.core.routing.router.sharding.ParsingSQLRouter#processLimit

private void processLimit(final List<Object> parameters, final SelectStatement selectStatement, final boolean isSingleRouting) {
     boolean isNeedFetchAll = (!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems();
    selectStatement.getLimit().processParameters(parameters, isNeedFetchAll, databaseType, isSingleRouting);
}

io.shardingsphere.core.parsing.parser.context.limit.Limit#processParameters

/**
* Fill parameters for rewrite limit.
*
* @param parameters parameters
* @param isFetchAll is fetch all data or not
* @param databaseType database type
* @param isSingleRouting is single routing or not
*/
public void processParameters(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
    fill(parameters);
    rewrite(parameters, isFetchAll, databaseType, isSingleRouting);
}


private void rewrite(final List<Object> parameters, final boolean isFetchAll, final DatabaseType databaseType, final boolean isSingleRouting) {
    int rewriteOffset = 0;
    int rewriteRowCount;
    if (isFetchAll) {
        rewriteRowCount = Integer.MAX_VALUE;
    } else if (isNeedRewriteRowCount(databaseType) && !isSingleRouting) {
         rewriteRowCount = null == rowCount ? -1 : getOffsetValue() + rowCount.getValue();
    } else {
       rewriteRowCount = rowCount.getValue();
    }
    if (null != offset && offset.getIndex() > -1 && !isSingleRouting) {
       parameters.set(offset.getIndex(), rewriteOffset);
     }
     if (null != rowCount && rowCount.getIndex() > -1) {
        parameters.set(rowCount.getIndex(), rewriteRowCount);
      }
}

正确的写法应该是  select a  from  temp group by a desc ,b limit 1,10 ;  使用的版本是sharing-jdbc的3.1.1。

ES分页查询如果排序字段存在重复的值,最好加一个唯一的字段作为第二排序条件,避免分页查询时漏掉数据、查出重复数据,比如用的是订单创建时间作为唯一排序条件,同一时间如果存在很多数据,就会导致查询的订单存在遗漏或重复,需要增加一个唯一值作为第二排序条件或者直接使用唯一值作为排序条件。

四、成果

  • 一次性上线成功,稳定运行了一年多
  • 核心服务性能提升十倍以上
  • 系统解耦,迭代效率大幅提升
  • 能够支撑商城至少五年的高速发展

五、结语

我们在系统设计时并没有一味追求前沿技术和思想,面对问题时也不是直接采用主流电商的解决方案,而是根据业务实际状况来选取最合适的办法。

个人觉得,一个好的系统不是在一开始就被大牛设计出来的,一定是随着业务的发展和演进逐渐被迭代出来的,持续预判业务发展方向,提前制定架构演进方案,简单来说就是:走到业务的前面去!

作者:vivo官网商城开发团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-23

发布会直播技术及业务实践

一、背景

随着直播行业的近年来的发展,直播技术现已日趋成熟。本文主要介绍目前主流的直播技术原理,以及在直播在发布会场景下的应用以及过程中遇到的问题及解决方案。

二、直播原理

2.1 流媒体技术

2.1.1 流媒体简介

目前在网络中传输音视频的多媒体信息主要有两种方式——下载方式和流式传输。下载方式很好理解,用户需要将一个音视频完全下载后才可以进行播放;流式传输指将音视频通过服务器向客户端进行连续的实时传输。基于流式传输的特点,就不难理解流媒体的定义——流媒体(streaming media)是在由提供商提供的同时能够不断的被终端用户接收,并呈现给终端用户的多媒体。动词“流”(streaming)很好的描述了这种媒体的传递和获取的过程。

好比一个完整的word文档,我需要全部下载才可以打开,甚至下载完给我来一个损坏提示,相比这时候心情一定十分恼火,但如果一页一页进行下载,像小人书一样呢?

图1:word示意图

图2:小人书示意

2.1.2 流式传输概述

所谓流式传输是指将整个A/V等多媒体文件经过特殊的压缩方式分成一个一个的压缩包,通过服务器将这一个个的压缩包向终端用户连续、实时发送,用户还没有接收到完整的多媒体信息前就能处理已接收的部分信息,并对该部分音视频进行播放,剩余部分继续在服务器后台下载。(类比计算机处理问文件的方式)。

 流媒体传输主要分为以下两种方式:

  • 顺序流式传输(Progressive Streaming)即用户在观看在线媒体的同时下载文件,在某一时刻用户只能观看已下载的部分而不能跳转到未下载的部分进行观看。顺序流式传输能够很好的保证节目播放质量,比较适合在网站上发布的、可供用户点播的、高质量的视频。典型例子——电视节目。
  • 实时流式传输(Real-Time Streaming)这种传输方式需要保证媒体信号带宽与网络连接匹配,使得音视频可以被实时的观看,一般需要专门的流媒体服务器与传输协议。这种方式允许用户通过服务器对媒体发送更多级别的控制如快进、快退等,同时在传输过程中音视频质量能够根据用户的网络带宽进行调整。

2.1.3 流媒体技术与直播

由于流媒体技术的快速发展在一定程度上突破了网络带宽对多媒体信息的限制,将过去的传统媒体的“推”式传播转变为用户可选择的“拉”式传播,用户可根据自己的喜好选择性的接收自己需要的媒体信息。正是这种技术和时代的进步使得如今直播平台快速发展,丰富的业务也能扎根直播完成其使命。

2.2 直播流程

了解直播所需的基本概念后,咱们再一步步走进直播的原理。

首先在聊直播的时候,我们需要了解直播涉及到的三大角色——主播、流媒体服务器和观看用户。顾名思义,主播就是视频的发起者,我们称为视频录制端或视频推流端;观看用户就是收看直播节目的受众,我们称为视频播放端(拉流端);而流媒体服务器是两端的桥梁,承担着处理流媒体及分发的任务。这三者构成了直播的整体流程。

图3:直播流程

2.2.1 视频推流端

 视频推流端工作流程主要分为四步:

  1. 采集:主播/导播通过摄像机、手机等录制设备进行音画采集;
  2. 处理:在导播台将原始音画进行一定的处理如美颜、加水印、滤镜等;
  3. 编码和封装:当原始视频被处理完毕后通过编码器如H.264、H.265等进行编码,将庞大的音视频源文件压缩,并通过封装将编码好的媒体内容封装为规范的媒体文件格式如AVI、MOV、MPEG格等;编码的核心思想是去除媒体信息中的冗余,包括:空间、时间、编码、视觉等;而封装的意义就好比用何种货车去运输货物(媒体内容),让播放变得简单。
  4. 此步骤对流媒体传输有着至关重要的作用,涉及规范和知识也十分丰富,有兴趣的同学可以寻找相关文章进行了解,在此不进行详细说明。
  5. 推流:最终通过推流工具将该流媒体向服务器推送,“推”一动词形象地描述了主播主动将流推送至服务器的过程。到此步被成为直播的第一里程碑。

2.2.2 流媒体服务器

流媒体服务器是流媒体应用中的核心系统,是运营商向用户提供流媒体服务的关键平台。

当主播使用推流软件将流媒体推送至各种流媒体服务器之后,流媒体服务器可以将获取到的媒体内容进行加强、转码等各式各样的操作,同时服务器可根据解析出来的媒体内容进行额外的业务拓展如安全审核等,最终服务器会将处理好的媒体内容以合适的流媒体协议将流媒体内容传输至CDN,观看用户端便可以随时拉取媒体内容进行播放。

2.2.3 播放端

也称拉流端,用户可使用各种流媒体播放app拉取想要获取的信息。

播放端的步骤如下:

  1. 拉流:通过播放app并选取合适的拉流协议进行拉取媒体内容;
  2. 解复用:将处于“多媒体文件格式”的流解复用,成为“视频编码格式”和“音频编码格式”,通俗点说就是把“音视频信号源”分流出单独的“视频数据”和“音频数据”。(如.FLV解复用后得到H.264视频数据和AAC音频数据)。
  3. 解码:硬解码(GPU解码+CPU辅助)或软解码(CPU解码)将解复用得到的音视频数据进行解码,解码后得到YUV或RGB格式的视频以及PCM格式的音频。
  4. 音画同步并输出播放:解码后终端会执行音画同步的操作,并将同步后的音频输送至音频设备进行播放,视频通过输送至视频输出设备进行播放。

通过以上几个步骤,用户端就能够顺利的开始播放直播啦。

2.2.4 直播架构

为完成2.2所述直播流程,一般直播架构如下:

图4:简单直播架构示意

2.3 直播协议

光有思路了还不够,数据传输需要一定的规范,否则各家按各家行事到时候都只能在自己的框架里才能玩得转,不符合互联网共享精神,这时候就需要有一套规范的协议来约束数据的传输。

常见的直播协议有RTMP、HTTP-FLV、HLS 等,下面我们来简单介绍一下这三种协议。

2.3.1 RTMP

RTMP协议是Real Time Message Protocol(实时信息传输协议)的缩写,最初是Macromedia(现归Adobe所有)开发的专有协议,用来解决多媒体数据传输流的多路复用(Multiplexing)和分包(packetizing)的问题,可在Flash播放器和服务器之间通过Internet流式传输音频,视频和数据。是一种应用层协议,同时支持推流和拉流。

RTMP传输时,会对数据内容进行格式化,这种消息格式被成为RTMP Message,该消息的构成如下:

图5:RTMP 消息构成

可以看到根据Message的TypeId可以划分为7种信息,这7种信息包含了对端连接、数据信息及控制信息等,用以完成流媒体的播放及操纵。

根据视频数据“压缩分段”的思想,RTMP在实际传输过程中会将Message划分为一个个带ID小块,称为Chunk,每一个Chunk可能是一个单独的Message,也可能是一个Message的部分。每一个Message都有一个唯一的标识——Message Stream ID,在还原Message时,每一个Chunk就通过这个标识来辨别是否为同一个Message,最终在收到Chunk后进行组装。

RTMP的推拉流流程如下:

图6:RTMP 推拉流过程

感兴趣的小伙伴可以参考Adobe官网发布的 RTMP specification v1.0对RTMP进行更深入的了解,本文不再赘述。

2.3.2 HLS

不管是RTMP还是HTTP-FLV,都逃不过一点——Flash。在12年Adobe发布RTMP specification v1.0时,本以为拿到流媒体金钥匙的Adobe未曾想未来是个HTML5的世界,一个不需要Flash的世界。

在09年三月iphone 3.0发布会上,苹果在新的操作系统宣传中添加了关于流媒体功能的微妙暗示。同年五月HLS协议正式发布。HLS协议的出现最初是为了解决苹果原生环境中的流媒体播放问题,这个协议可以方便的让Mac和iphone播放视频流而不依赖Adobe。依赖自己往往是最大的力量保障。

HLS全称HTTP Live Streaming,是一个由 Apple 公司实现的基于 HTTP 的媒体流传输协议。工作原理是通过将整条流切割成一个小的可以通过 HTTP 下载的媒体文件, 然后提供一个配套的媒体列表文件, 提供给客户端, 让客户端顺序地拉取这些媒体文件播放。

HLS协议包含三个部分:Server、Distribution 和 Client.

  • 首先需要从导播端获取视频数据,通过其他支持推流的协议,将视频流传输到服务器上去。
  • 其中Stream segmenter(流切片器)会从Media encoder(编码器)读取数据,然后将着这些数据一组相等时间间隔的小媒体文件。虽然每一个片段都是一个单独的文件,但是他们的来源是一个连续的流,切完照样可以无缝重构回去。
  • 切片器在切片同时会创建一个索引文件(Index file),索引文件会包含这些切片文件的引用。每当一个切片文件生成后,索引文件都会进行更新。索引用于追踪切片文件的有效性和定位切片文件的位置。切片器同时也可以对你的媒体片段进行加密并且创建一个密钥文件作为整个过程的一部分。
  • Distribution相当于一个Http文件服务器,Client通过访问Distribution中的索引文件,便可以自动播放HLS流了。

图7:HLS 处理流程

通过上述流程,用户拿到索引文件就相当于拿到了流媒体的钥匙,那么HLS生成的索引文件又是啥呢?让我们来解开他的神秘面纱——m3u8文件。

m3u8文件实际实质上是一个播放列表(playlist),其主要的两种形式为主播放列表(Master Playlist)和流媒体播放表(Media Playlist),也称一级索引和二级索引。

图8:m3u8文件内容

可以看到主播放列表(一级索引)主要标识有带宽(BANDWIDTH)、视频分辨率(RESOLUTION)、音频编码格式(CODECS)以及二级索引路径;流媒体播放列表(二级索引)关键属性为切片持续时间(EXTINF)以及视频切片路径(按上图实例,该切片至少有9.009+9.009+3.003秒左右的延迟);浏览器通过不断访问最新m3u8文件即可获得每个切片的地址并通过浏览器进行播放。

图9:直播 m3u8请求

2.3.3 HTTP-FLV

HTTP-FLV本质上是RTMP在HTTP协议上的封装,同样都是传输的flv格式的数据,由于通过80端口通信,相比RTMP其穿透性更强。HTTP-FLV在本文中不再过多阐述。

2.3.4 三种直播协议对比及选型

三种直播协议的对比如下:

图10:三种协议对比

根据三种常用的协议对比,在直播协议的选择上面可根据各自的特点进行选择——实时性要求(互动直播等):RTMP/HTTP-FLV  稳定性要求(发布会等):HLS

2.4 小结

本章对直播原理及常用的三种协议进行分析,供读者了解直播技术中涉及的基本概念。可能有些读者对枯燥的技术知识表示“抗议”,在此以物流系统对直播系统进行类比以供大家轻松愉快地理解:

相信大家平时都有网上购物习惯,举个例子:小明要在网上给女朋友买一个加热出现照片的定制杯子,他会经历网上下单,物流运输、驿站存取等步骤,而商家担任产品的制作需要经历杯子加工、打包、发货等步骤;

在这里商家就是主播,杯子就是原始视频,加上小明女朋友照片的杯子就是经过了处理工序,而打包就是编码封装按一定的规格给快递公司才给正常发货嘛,而发货就是相当于推流,此时物流公司就担任流分发的重任,将快递分发到各个驿站(CDN),而小明的快递便传递到了最近的驿站;

小明想起来要去拿快递了,于是凭借着取货码(拉流协议),去拿到这个杯子,这个提货的过程就相当于拉流,整个过程如下图所示:

图11:物流系统类比图

相信通过这个形象的例子,大家一定对直播架构有了基本概念。

三、vivo官网发布会直播技术保障

3.1 发布会直播面临的挑战

本章主要介绍在vivo官网发布会直播过程中所遇到的问题,以及其一般性解决思路。

新品发布会的影响力可想而知,特别是在发布会push发放节点以及发布会开始节点,会有大量的观众进入到官网直播间,此时的瞬时压力成为面临的第一道挑战,而随着发布会的进行,持续大流量也成为系统的隐患之一;当然由于发布会的特殊属性,一切都建立在不容出错的基础之上,保证发布会无异常发生。

可以看出发布会直播面临的问题实际上就是一个典型的高并发系统所面临的问题。而保护高并发系统的三大利器——缓存、降级、限流,便是发布会技术保障其中的一部分。

3.2技术保障

3.2.1 发布会缓存方案

正所谓网站性能优化第一定律就是考虑使用缓存针对发布会我们做了许多缓存优化方案。

细心的小伙伴会发现官网直播涉及到内容轮询获取,如直播人数、直播评论、点赞数等,而优化的第一步是调整轮询的时间间距,减小峰值QPS;而降低QPS后仍然会有大量请求过来,这时候作场景考虑,部分配置信息以及围观人数、点赞等对发布会主流程无决定性影响且实时性要求并不高,而采用单纯的redis集群会使得集群压力剧增,最初直接集群访问的模式已不再适应业务的发展,此时在技术层面考虑了这部分数据采用本地缓存进行存储。虽然会造成短暂的数据不一致的“损失”,但在对业务没有实质性影响的情况下,极大的提升了直播业务的稳定性。

图12:多级缓存演变示意图

3.2.2 发布会降级方案

发布会场景的核心是保证直播音视频的正常播放,给用户了解咱们最新的产品。由于实际业务的需要与拓展,在发布会场景涉及到许多感知与互动元素,而如果因为这些业务功能导致发布会核心被拖垮显然得不偿失。所以在设计之初对这些业务设置了一些降级策略以防止这种情况的发生。在此举一种典型的场景以供大家理解进入直播页的第一道“工序”就是去查询直播的基础配置,而当访问量到一定程度通过观察日志发现部分请求超时甚至异常时,咱们就会开启强制熔断,后续查询就会走到fallback方法指向查询本地缓存中的内容,降低服务压力的同时也不会对用户的观看体验造成很大影响。

图13:熔断示意图

虽然上述方案在实际发布会过程中均未触发,但未雨绸缪有备无患总是比出现问题再解决问题来的更加从容自然。

3.2.3 发布会限流方案

发布会直播评论抽奖作为典型流量场景,使用限流手段让部分抽奖评论排队等待,后续等处理完毕后再告知用户中奖信息。常见的限流算法有漏桶算法和令牌桶算法,而评论抽奖的特性是到时间段的瞬间有大量评论进入抽奖逻辑,限流既要考虑流量整形,又要考虑突发请求,令牌桶算法再适合不过。

图14:令牌桶算法示意图

3.3 内容安全保障

由于面向公众,同时存在互动类玩法,内容安全性在发布会直播中也尤为重要,而在直播内容安全(即视频无敏感内容)的前提下,与用户侧相关的文字内容安全(涉黄、涉政、暴恐、违禁内容等)处理成为需要解决的一大问题。当然在内部谛听团队的支持下这一问题也迎刃而解,避免重复造轮子。

3.4 小结

通过本章作者希望大家理解直播的核心是给大家看视频听声音的,为了保证这一需求不出错的同时在上面进行一些拓展性的业务提升用户互动性需要面临诸多挑战,作为开发者需要意识到这些挑战并做好接受挑战的准备,为业务的提升打好基石。

四、写在最后

如今直播的业务存在形式多种多样,如发布会、游戏直播、教育直播、直播带货等,作者希望通过本文的介绍相信大家或多或少对直播技术有了一定的认知,直播涉及到的各个细节都有一个庞大的知识体系支撑,由于篇幅及个人知识经验有限,无法为大家呈现更多细节,有兴趣的读者可以以本文为参考对直播进行深入的了解。

作者:vivo 官网商城开发团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-22

vivo 商城架构升级-SSR 实战篇

一、前言

在前面几篇文章中,相信大家对vivo官网商城的前端架构演变有了一定的了解,从稳步推进前后端分离到小程序多端探索实践,团队不断创新尝试。

在本文中,我们来分享一下vivo官网商城在Node 服务端渲染(Server Side Rendering, SSR)方面的实战经验。本文主要围绕以下几个方面进行阐述:

  • CSR与SSR的对比
  • 性能优化
  • 自动化部署
  • 容灾、降级
  • 日志、监控

二、背景

vivo官网商城目前前后端分离采用的是SPA单页模式,SPA会把所有 JS 整体打包,无法忽视的问题就是文件太大,导致渲染前等待很长时间。特别是网速差的时候,让用户等待白屏结束并非一个很好的体验。因此 vivo 官网商城前端团队尝试引入了SSR技术,以此来加快页面首屏的访问速度,从而提升用户体验。

三、SSR简介

3.1 什么是SSR?

页面渲染主要分为客户端渲染(Client Side Render)和服务端渲染(Server Side Rendering):

  • 客户端渲染(CSR)

服务端只返回一个基本的html模板,浏览器根据html内容去加载js,获取数据,渲染出页面内容;

  • 服务端渲染(SSR)

页面的内容是在服务端渲染完成,返回到浏览器直接展示。

3.2 为什么要使用SSR?

与传统 SPA (单页应用程序 (Single-Page Application)) 相比,SSR的优势主要在于:

  • 更好的搜索引擎优化(SEO),SPA应用程序初始展示loading菊花图,然后通过Ajax获取内容,搜索引擎并不会等待异步完成后再行抓取页面内容;
  • 更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备,无需等待所有的JavaScript都完成下载并执行,才显示服务器渲染的标记,用户能够更快速地看到完整渲染的页面,提升用户体验。下图能够更直观的反应加载时效果。

CSR和SSR页面渲染对比:

四、SSR 实践

vivo官网商城项目的技术栈是Vue, 考虑到从头搭建一套服务端渲染的应用比较复杂,所以选择了Vue官方推荐的Nuxt.js框架,这是基于 Vue 生态的更高层的框架,为开发服务端渲染的 Vue 应用提供了极其便利的开发体验。

这里不做基础使用的分享,有兴趣的同学可以到Nuxt.js官网学习基础用法;我们主要聚焦于在整个实践过程中,主要遇到的一些挑战:

  • 性能:如何进行性能优化,提升QPS,节约服务器资源?
  • 容灾:如何做好容灾处理,实现自动降级?
  • 日志:如何接入日志,方便问题定位?
  • 监控:如何对Node服务进行监控?
  • 部署:如何打通公司CI/CD流程,实现自动化部署?

4.1 性能优化

虽然Vue SSR渲染速度已经很快,但是由于创建组件实例和虚拟DOM节点的开销,与基于字符串拼接的模板引擎的性能相差很大,在高并发情况下,服务器响应会变慢,极大的影响用户体验,因此必须进行性能优化。

4.1.1 方案1 启用缓存

a、页面缓存: 在创建render实例时利用LRU-Cache来缓存渲染好的html,当再有请求访问该页面时,直接将缓存中的html字符串返回。

nuxt.config.js增加配置:

serverMiddleware: ["~/serverMiddleware/pageCache.js"]

根目录创建serverMiddleware/pageCache.js

b、组件缓存: 将渲染后的组件DOM存入缓存,定时刷新,有效期内取缓存中DOM。主要适用于重复使用的组件,多用于列表,例如商品列表。

配置文件nuxt.config.js:

const LRU = require('lru-cache')
module.exports = {
  render: {
    bundleRenderer: {
      cache: LRU({
        max: 1000,                   // 最大的缓存个数
        maxAge: 1000 * 60 * 5        // 缓存5分钟
      })
    }
  }
}

缓存组件增加name及serverCacheKey作为唯一键值:

export default { 
    name: 'productList', 
    props: ['productId'], 
    serverCacheKey: props => props.productId
}

c、API缓存: Node服务器需要先调用后台接口,获取到数据,然后才能进行渲染,获取接口速度的快慢,直接影响到渲染的时间,对接口的缓存可以加快每个请求的处理速度,更快地释放掉请求,从而提高性能。API缓存主要适用于数据基本保持不变,变更不是很频繁,与用户个人数据无关的接口。

4.1.2 方案2 接口并发请求

同一个页面,在Node层可能会同时调用多个接口,如果是串行调用,需要等待的时间会比较长,如果是并发请求,会缩小等待时间。

例如:

let data1 = await $axios.get('接口1')
let data2 = await $axios.get('接口2')
let data3 = await $axios.get('接口3')

可以改成:

let {data1,data2,data3} = await Promise.all([
    $axios.get('接口1'),
    $axios.get('接口2'),
    $axios.get('接口3')
])

4.1.3 方案3 首屏最小化

影响用户体验主要是首屏的白屏时间,而第二屏、第三屏...,并不需要立即显示。以商品详情页为例,如下图:

可以对页面结构进行拆分,首屏元素采用SSR,非首屏元素通过CSR;SSR数据需要通过asyncData方法来获取,CSR数据可以在mounted中获取。

CSR写法如下:

<client-only>
    客户端渲染dom
</client-only>

4.1.4 方案4 部分页面采用CSR

并不是所有页面对体验、SEO要求都很高,像商城这样的业务,可以只对首页、商品详情页等核心页面做SSR,这样可以大大减少服务端的压力。

4.1.5 优化前后性能压测对比

优化前:

优化后:

从上图可以看出,未经优化前QPS只有125,经过一系列优化QPS达到了6000,提升了接近 50倍。

这里的降级是指将SSR降级为CSR,使用Node做SSR,瓶颈在于CPU和内存,在高并发情况下,很容易导致CPU飙升,用户访问页面时间变长,如果Node服务器挂了,直接会导致页面访问不了。所以为了保证项目上线之后平稳运行,需要提供容灾、降级方案。

Nuxt.js可以同时支持CSR和SSR,我们在打包时,既生成SSR的包,同时生成CSR的包,分别进行部署。

项目中采用了以下几种降级方案:

4.2 降级策略

4.2.1 监控系统降级

Node服务器上启动一个服务,用来监测Node进程的CPU和内存使用率,设定一个阈值,当达到这个阈值时,停止SSR,直接将CSR的入口文件index.html返回,实现降级。

4.2.2 Nginx降级策略

4.2.2.1全平台降级

例如618,双11等大促期间,我们事先知道流量会很大,可以提前通过修改Nginx配置,将请求转发到静态服务器,返回index.html,切换到CSR。

4.2.2.2单次访问降级

当偶发性的Node服务器返回5xx错误码,或者Node服务器直接挂了,我们可以通过如下Nginx配置,做到自动切换到CSR,保证用户能正常访问。

Nginx配置如下:

  location / {
      proxy_pass Node服务器地址;
      proxy_intercept_errors on;
      error_page 408 500 501 502 503 504 =200 @spa_page;  
  }

  location @spa_page {
      rewrite ^/*  /spa/200.html break;
      proxy_pass  静态服务器;
  }

4.2.2.3指定渲染方式

在url中增加参数isCsr=true,Nginx层对参数isCsr进行拦截,如果带上该参数,指向CSR,否则指向SSR;这样就可以通过url参数配置来进行页面分流,减轻Node服务器压力。

4.3 CI/CD 自动化部署

基于公司的CI/CD,我们实现了Docker部署和Shell脚本部署两种自动化部署方案。

4.3.1 方案1 Shell脚本构建、部署

对于Shell脚本的方式,我们主要解决的问题是如何通过脚本来安装指定Node的版本,这里我们可以分为两步:

1、安装nvm, nvm 是Node.js 的版本管理器(version manager)
2、通过nvm安装或者切换成对于的Node版本
# 定义安装nvm的方法
install_nvm() {
  echo "env $app_env install nvm ..."
  wget --header='Authorization:Basic dml2b2Rldm9wczp4TFFidmtMbW9ZKn4x' -nv -P .nvm http://xxx/download/nvm-master.zip

  unzip -qo .nvm/nvm-master.zip
  mv nvm-master/* $NVM_DIR
  rm -rf .nvm
  rm -rf nvm-master

  . "$NVM_DIR/nvm.sh"
  if [[ $? = 1 ]];
  then
    echo "install nvm fail"
  else
    echo "install nvm success"
  fi
}
# 定义安装Node的方法
install_node() {
   # command_args为用户自定义的Node版本号
  local USE_NODEVER=$command_args

  echo "will install NodeJs $USE_NODEVER"

  nvm install $USE_NODEVER >/dev/null

  echo "success change to NodeJs version" $(node -v)
}
# Node环境安装
prepare() {
   if [[ -s "$NVM_DIR/nvm.sh" ]];
  then
    . "$NVM_DIR/nvm.sh"
  else
    install_nvm
  fi
  echo "nvm version $(nvm --version)"

  install_node
}

4.3.2 方案2 Docker构建、部署

Docker是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的 Linux或Windows 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。

# 基础镜像
FROM node:12.16.0

# 创建文件存放目录
RUN mkdir -p /home/docker-demo
WORKDIR /home/docker-demo
COPY . /home/docker-demo

# 安装依赖
RUN yarn install

# 打包,并把静态资源进行md5压缩
RUN yarn prod

# 静态资源部署CDN
RUN yarn deploy

# 端口号
EXPOSE 3000

# 项目启动命令
CMD npm start

相比较而言,Docker部署具有很大优势:

  • 构建、部署更加方便
  • 一致的运行环境「这段代码在我机器上没问题啊」
  • 弹性伸缩
  • 更高效的利用系统资源
  • 快 \- 管理操作(启动,停止,开始,重启等)都是以秒或毫秒为单位

4.4 监控、告警

监控是整个产品生命周期中非常重要的一环,事前及时预警发现故障,事后提供详实的数据用于追查定位问题。
在应用出现故障时,需要有合适的工具链来支撑问题的定位修复,我们引入了开源的企业级 Node.js 应用性能监控与线上故障定位解决方案Easy-Monitor,可以更好地监控 Node.js 应用状态,来面对性能和稳定性方面的挑战。我们在内网部署了这套系统,并进行了二次开发,集成了内网域登录,并可以通过内部聊天工具推送告警信息。

 

4.5 日志

应用上线后,一旦发生异常,第一件事情就是要弄清当时发生了什么,比如用户当时如何操作、数据如何响应等,此时日志信息就给我们提供了第一手资料。因此我们需要接入公司的日志系统。

4.5.1 实现

日志组件基于log4js封装,对接公司的日志中心

在nuxt.config.js中增加:

export default {
  // ...
  modules: [
      "@vivo/nuxt-vivo-logger"
  ],
  vivoLog: {
      logPath:process.env.NODE_ENV === "dev"?"./logs":"/data/logs/",
      logName:'aa.log'
  }
}

4.5.2 使用

async asyncData({ $axios,$vivoLog }) {
    try {
      const resData =  await $axios.$get('/api/aaa')
      if (process.server) $vivoLog.info(resData)
    } catch (e) {
      if (process.server) $vivoLog.error(e)
    }
},

4.5.3 结果

五、写在结尾

用户体验的提升是一个永久的话题,vivo官网商城前端团队一直致力于技术的不断创新,希望能通过技术的探索给用户带来更好的体验;以上是vivo官网商城前端团队在SSR技术方面实践的一些经验,分享出来希望能和大家一起学习、探讨。

作者:vivo 官网商城前端团队

查看原文

赞 2 收藏 2 评论 0

vivo互联网技术 发布了文章 · 2020-12-21

vivo 微服务 API 网关架构实践

一、背景介绍

网关作为微服务生态中的重要一环,由于历史原因,中间件团队没有统一的微服务API网关,为此准备技术预研打造一个功能齐全、可用性高的业务网关。

二、技术选型

常见的开源网关按照语言分类有如下几类:

  • Nginx+Lua:OpenResty、Kong 等;
  • Java:Zuul1/Zuul2、Spring Cloud Gateway、gravitee-gateway、Dromara Soul 等;
  • Go:janus、GoKu API Gateway 等;
  • Node.js:Express Gateway、MicroGateway 等。

由于团队内成员基本上为Java技术栈,因此并不打算深入研究非Java语言的网关。接下来我们主要调研了Zuul1、Zuul2、Spring Cloud Gateway、Dromara Soul。

业界主流的网关基本上可以分为下面三种:

  • Servlet + 线程池
  • NIO(Tomcat / Jetty) + Servlet 3.0 异步
  • NettyServer + NettyClient

在进行技术选型的时候,主要考虑功能丰富度、性能、稳定性。在反复对比之后,决定选择基于Netty框架进行网关开发;但是考虑到时间的紧迫性,最终选择为针对 Zuul2 进行定制化开发,在 Zuul2 的代码骨架之上去完善网关的整个体系。

三、Zuul2 介绍

接下来我们简要介绍一下 Zuul2 关键知识点。

Zuul2 的架构图:

为了解释上面这张图,接下来会分别介绍几个点

  • 如何解析 HTTP 协议
  • Zuul2 的数据流转
  • 两个责任链:Netty ChannelPipeline责任链 + Filter责任链

3.1 如何解析 HTTP 协议

学习Zuul2需要一定的铺垫知识,比如:Google Guice、RxJava、Netflix archaius等,但是更关键的应该是:如何解析HTTP协议,会影响到后续Filter责任链的原理解析,为此先分析这个关键点。

首先我们介绍官方文档中的一段话:

By default Zuul doesn't buffer body content, meaning it streams the received headers to the origin before the body has been received.

This streaming behavior is very efficient and desirable, as long as your filter logic depends on header data.

翻译成中文:

默认情况下Zuul2并不会缓存请求体,也就意味着它可能会先发送接收到的请求Headers到后端服务,之后接收到请求体再继续发送到后端服务,发送请求体的时候,也不是组装为一个完整数据之后才发,而是接收到一部分,就转发一部分。

这个流式行为是高效的,只要Filter过滤的时候只依赖Headers的数据进行逻辑处理,而不需要解析RequestBody。

上面这段话映射到Netty Handler中,则意味着Zuul2并没有使用HttpObjectAggregator。

我们先看一下常规的Netty Server处理HTTP协议的样例:

NettyServer样例

@Slf4j
public class ConfigServerBootstrap {
 
    public static final int WORKER_THREAD_COUNT = Runtime.getRuntime().availableProcessors();
 
    public void start(){
        int port = 8080;
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup(WORKER_THREAD_COUNT);
 
        final BizServerHandler bizServerHandler = new BizServerHandler();
 
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
 
            serverBootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<Channel>() {
                        @Override
                        protected void initChannel(Channel ch) throws Exception {
                            ChannelPipeline pipeline = ch.pipeline();
                            pipeline.addLast(new IdleStateHandler(10, 10, 0));
                            pipeline.addLast(new HttpServerCodec());
                            pipeline.addLast(new HttpObjectAggregator(500 * 1024 * 1024));
                            pipeline.addLast(bizServerHandler);
                        }
                    });
            log.info("start netty server, port:{}", port);
            serverBootstrap.bind(port).sync();
        } catch (InterruptedException e) {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
            log.error(String.format("start netty server error, port:%s", port), e);
        }
    }
}

这个例子中的两个关键类为:HttpServerCodec、HttpObjectAggregator。

HttpServerCodec是HttpRequestDecoder、HttpResponseEncoder的组合器。

  • HttpRequestDecoder职责:将输入的ByteBuf解析成HttpRequest、HttpContent对象。
  • HttpResponseEncoder职责:将HttpResponse、HttpContent对象转换为ByteBuf,进行网络二进制流的输出。

HttpObjectAggregator的作用:组装HttpMessage、HttpContent为一个完整的FullHttpRequest或者FullHttpResponse。

当你不想关心chunked分块传输的时候,使用HttpObjectAggregator是非常有用的。

HTTP协议通常使用Content-Length来标识body的长度,在服务器端,需要先申请对应长度的buffer,然后再赋值。如果需要一边生产数据一边发送数据,就需要使用"Transfer-Encoding: chunked" 来代替Content-Length,也就是对数据进行分块传输。

接下来我们看一下Zuul2为了解析HTTP协议做了哪些处理。

Zuul的源码:https://github.com/Netflix/zuul,基于v2.1.5。

// com.netflix.zuul.netty.server.BaseZuulChannelInitializer#addHttp1Handlers
protected void addHttp1Handlers(ChannelPipeline pipeline) {
    pipeline.addLast(HTTP_CODEC_HANDLER_NAME, createHttpServerCodec());
 
    pipeline.addLast(new Http1ConnectionCloseHandler(connCloseDelay));
    pipeline.addLast("conn_expiry_handler",
            new Http1ConnectionExpiryHandler(maxRequestsPerConnection, maxRequestsPerConnectionInBrownout, connectionExpiry));
}
// com.netflix.zuul.netty.server.BaseZuulChannelInitializer#createHttpServerCodec
protected HttpServerCodec createHttpServerCodec() {
    return new HttpServerCodec(
            MAX_INITIAL_LINE_LENGTH.get(),
            MAX_HEADER_SIZE.get(),
            MAX_CHUNK_SIZE.get(),
            false
    );
}

通过对比上面的样例发现,Zuul2并没有添加HttpObjectAggregator,也就是需要自行去处理chunked分块传输问题、自行组装请求体数据。

为了解决上面说的chunked分块传输问题,Zuul2通过判断是否LastHttpContent,来判断是否接收完成。

3.2 Zuul2 数据流转

如上图所示,Netty自带的HttpServerCodec会将网络二进制流转换为Netty的HttpRequest对象,再通过ClientRequestReceiver编解码器将HttpRequest转换为Zuul的请求对象HttpRequestMessageImpl;

请求体RequestBody在Netty自带的HttpServerCodec中被映射为HttpContent对象,ClientRequestReceiver编解码器依次接收HttpContent对象。

完成了上述数据的转换之后,就流转到了最重要的编解码ZuulFilterChainHandler,里面会执行Filter链,也会发起网络请求到真正的后端服务,这一切都是在ZuulFilterChainHandler中完成的。

得到了后端服务的响应结果之后,也经过了Outbound Filter的过滤,接下来就是通过ClientResponseWriter把Zuul自定义的响应对象HttpResponseMessageImpl转换为Netty的HttpResponse对象,然后通过HttpServerCodec转换为ByteBuf对象,发送网络二进制流,完成响应结果的输出。

这里需要特别说明的是:由于Zuul2默认不组装一个完整的请求对象/响应对象,所以Zuul2是分别针对请求头+请求Headers、请求体进行Filter过滤拦截的,也就是说对于请求,会走两遍前置Filter链,对于响应结果,也是会走两遍后置Filter链拦截。

3.3  两个责任链

3.3.1 Netty ChannelPipeline责任链

Netty的ChannelPipeline设计,通过往ChannelPipeline中动态增减Handler进行定制扩展。

接下来看一下Zuul2 Netty Server中的pipeline有哪些Handler?

接着继续看一下Zuul2 Netty Client的Handler有哪些?

本文不针对具体的Handler进行详细解释,主要是给大家一个整体的视图。

3.3.2 Filter责任链

请求发送到Netty Server中,先进行Inbound Filters的拦截处理,接着会调用Endpoint Filter,这里默认为ProxyEndPoint(里面封装了Netty Client),发送请求到真实后端服务,获取到响应结果之后,再执行Outbound Filters,最终返回响应结果。

三种类型的Filter之间是通过nextStage属性来衔接的。

Zuul2存在一个定时任务线程GroovyFilterFileManagerPoller,定期扫描特定的目录,通过比对文件的更新时间戳,来判断是否发生变化,如果有变化,则重新编译并放入到内存中。

通过定位任务实现了Filter的动态加载。

四、功能介绍

上面介绍了Zuul2的部分知识点,接下来介绍网关的整体功能。

4.1 服务注册发现

网关承担了请求转发的功能,需要一定的方法用于动态发现后端服务的机器列表。

这里提供两种方式进行服务的注册发现:

集成网关SDK

  • 网关SDK会在服务启动之后,监听ContextRefreshedEvent事件,主动操作zk登记信息到zookeeper注册中心,这样网关服务、网关管理后台就可以订阅节点信息。
  • 网关SDK添加了ShutdownHook,在服务下线的时候,会删除登记在zk的节点信息,用于通知网关服务、网关管理后台,节点已下线。

手工配置服务的机器节点信息

  • 在网关管理后台,手工添加、删除机器节点。
  • 在网关管理后台,手工设置节点上线、节点下线操。

为了防止zookeeper故障,网关管理后台已提供HTTP接口用于注册、取消注册作为兜底措施。

4.2 动态路由

动态路由分为:机房就近路由、灰度路由(类似于Dubbo的标签路由功能)。

  • 机房就近路由:请求最好是不要跨机房,比如请求打到网关服务的X机房,那么也应该是将请求转发给X机房的后端服务节点,如果后端服务不存在X机房的节点,则请求到其他机房的节点。
  • 灰度路由:类似于Dubbo的标签路由功能,如果希望对后端服务节点进行分组隔离,则需要给后端服务一个标签名,建立"标签名→节点列表"的映射关系,请求方携带这个标签名,请求到相应的后端服务节点。

网关管理后台支持动态配置路由信息,动态开启/关闭路由功能。

4.3 负载均衡

当前支持的负载均衡策略:加权随机算法、加权轮询算法、一致性哈希算法。

可以通过网关管理后台动态调整负载均衡策略,支持API接口级别、应用级别的配置。

负载均衡机制并未采用Netflix Ribbon,而是仿造Dubbo负载均衡的算法实现的。

4.4 动态配置

API网关支持一套自洽的动态配置功能,在不依赖第三方配置中心的条件下,仍然支持实时调整配置项,并且配置项分为全局配置、应用级别治理配置、API接口级别治理配置。

在自洽的动态配置功能之外,网关服务也与公司级别的配置中心进行打通,支持公司级配置中心配置相应的配置项。

4.5 API管理

API管理支持网关SDK自动扫描上报,也支持在管理后台手工配置。

4.6 协议转换

后端的服务有很多是基于Dubbo框架的,网关服务支持HTTP→HTTP的请求转发,也支持HTTP→Dubbo的协议转换。

同时C++技术栈,采用了tars框架,网关服务也支持HTTP → tras协议转换。

4.7 安全机制

API网关提供了IP黑白名单、OAuth认证授权、appKey&appSecret验签、矛盾加解密、vivo登录态校验的功能。

4.8 监控/告警

API网关通过对接通用监控上报请求访问信息,对API接口的QPS、请求响应吗、请求响应时间等进行监控与告警;

通过对接基础监控,对网关服务自身节点进行CPU、IO、内存、网络连接等数据进行监控。

4.9 限流/熔断

API网关与限流熔断系统进行打通,可以在限流熔断系统进行API接口级别的配置,比如熔断配置、限流配置,而无需业务系统再次对接限流熔断组件。

限流熔断系统提供了对Netflix Hystrix、Alibaba Sentinel组件的封装。

4.10 无损发布

业务系统的无损发布,这里分为两种场景介绍:

  • 集成了网关SDK:网关SDK添加了ShutdownHook,会主动从zookeeper删除登记的节点信息,从而避免请求打到即将下线的节点。
  • 未集成网关SDK:如果什么都不做,则只能依赖网关服务的心跳检测功能,会有15s的流量损失。庆幸的是管理后台提供了流量摘除、流量恢复的操作按钮,支持动态的上线、下线机器节点。

网关集群的无损发布:我们考虑了后端服务的无损发布,但是也需要考虑网关节点自身的无损发布,这里我们不再重复造轮子,直接使用的是CICD系统的HTTP无损发布功能(Nginx动态摘除/上线节点)。

4.11 网关集群分组隔离

网关集群的分组隔离指的是业务与业务之间的请求应该是隔离的,不应该被部分业务请求打垮了网关服务,从而导致了别的业务请求无法处理。

这里我们会对接入网关的业务进行分组归类,不同的业务使用不同的分组,不同的网关分组,会部署独立的网关集群,从而隔离了风险,不用再担心业务之间的互相影响。

五、系统架构

5.1 模块交互图

5.2 网关管理后台

模块划分

5.3 通信机制

由于需要动态的下发配置,比如全局开关、应用级别的治理配置、接口级别的治理配置,就需要网关管理后台可以与网关服务进行通信,比如推拉模式。

两种设计方案

  • 基于注册中心的订阅通知机制
  • 基于HTTP的推模式 + 定时拉取

这里并未采用第一种方案,主要是因为以下缺点:

  • 严重依赖zk集群的稳定性
  • 信息不私密(zk集群权限管控能力较弱、担心被误删)
  • 无法灰度下发配置,比如只对其中的一台网关服务节点配置生效

5.3.1 基于HTTP的推模式

因为Zuul2本身就自带了Netty Server,同理也可以再多启动一个Netty Server提供HTTP服务,让管理后台发送HTTP请求到网关服务,进而发送配置数据到网关服务了。

所以图上的蓝色标记Netty Server用于接收客户端请求转发到后端节点,紫色标记Netty Server用于提供HTTP服务,接收配置数据。

5.3.2 全量配置拉取

网关服务在启动之初,需要发送HTTP请求到管理后台拉取全部的配置数据,并且也需要拉取归属当前节点的灰度配置(只对这个节点生效的试验性配置)。

5.3.3 增量配置定时拉取

上面提到了"基于HTTP的推模式"进行配置的动态推送,也介绍了全局配置拉取,为了保险起见,网关服务还是新增了一个定时任务,用于定时拉取增量配置。

可以理解为兜底操作,就好比配置中心支持长轮询获取数据实时变更+定时任务获取全部数据。

在拉取到增量配置之后,会比对内存中的配置数据是否一致,如果一致,则不操作直接丢弃。

5.3.4 灰度配置下发

上面也提到了"灰度配置"这个词,这里详细解释一下什么是灰度配置?

比如当编辑了某个接口的限流信息,希望在某个网关节点运行一段时间,如果没有问题,则调整配置让全部的网关服务节点生效,如果有问题,则也只是其中一个网关节点的请求流量出问题。

这样可以降低出错的概率,当某个比较大的改动或者版本上线的时候,可以控制灰度部署一台机器,同时配置也只灰度到这台机器,这样风险就降低了很多。

灰度配置:可以理解为只在某些网关节点生效的配置。

灰度配置下发其实也是通过"5.3.1基于HTTP的推模式"来进行下发的。

5.4 网关SDK

网关SDK旨在完成后端服务节点的注册与下线、API接口列表数据上报,通过接入网关SDK即可减少手工操作。网关SDK通过 ZooKeeper client操作节点的注册与下线,通过发起HTTP请求进行API接口数据的上报。

支持SpringMVC、SpringBoot的web接口自动扫描、Dubbo新老版本的Service接口扫描。

Dubbo 接口上报:

  • 旧版Dubbo:自定义BeanPostProcessor,用于提取到ServiceBean,放入线程池异步上报到网关后台。
  • 新版Dubbo:自定义ApplicationListener,用于监听ServiceBeanExportedEvent事件,提取event信息,上报到网关后台。

HTTP 接口上报:

  • 自定义BeanPostProcessor,用于提取到Controller、RestController的RequestMapping注解,放入线程池异步上报API信息。

六、改造之路

6.1 动态配置

关联知识点:

Zuul2依赖的动态配置为archaius,通过扩展ConcurrentMapConfiguration添加到ConcurrentCompositeConfiguration中。

新增GatewayConfigConfiguration,用于存储全局配置、治理配置、节点信息、API数据等。

@Singleton
public class GatewayConfigConfiguration extends ConcurrentMapConfiguration {
 
    public GatewayConfigConfiguration() {
        /**
         * 设置这个值为true,才可以避免archaius强行去除value的类型,导致获取报错
         * see com.netflix.config.ConcurrentMapConfiguration#setPropertyImpl(java.lang.String, java.lang.Object)
         */
        this.setDelimiterParsingDisabled(Boolean.TRUE);
    }
 
}

通过Google Guice控制Bean的加载顺序,在较早的时机,执行ConfigurationManager.getConfigInstance(),获取到ConcurrentCompositeConfiguration,完成GatewayConfigConfiguration的初始化,然后再插入到第一个位置。

后续只需要对GatewayConfigConfiguration进行配置的增删查改操作即可。

6.2 路由机制

路由机制也是仿造的Dubbo路由机制,灰度路由是仿造的Dubbo的标签路由,就近路由可以理解为同机房路由。

请求处理过程:

客户端请求过来的时候,网关服务会通过path前缀提取到对应的后端服务名或者在请求Header中指定传递对应的serviceName,然后只在匹配到的后端服务中,继续API匹配操作,如果匹配到API,则筛选出对应的后端机器列表,然后进行路由、负载均衡,最终选中一台机器,将请求转发过去。

这里会有个疑问,如果不希望只在某个后端服务中进行请求路由匹配,是希望在一堆后端服务中进行匹配,需要怎么操作?

在后面的第七章节会解答这个疑问,请耐心阅读。

6.2.1 就近路由

当请求到网关服务,会提取网关服务自身的机房loc属性值,读取全局、应用级别的开关,如果就近路由开关打开,则筛选服务列表的时候,会过滤相同loc的后端机器,负载均衡的时候,在相同loc的机器列表中挑选一台进行请求。

如果没有相同loc的后端机器,则降级从其他loc的后端机器中进行挑选。

 

其中loc信息就是机房信息,每个后端服务节点在SDK上报或者手工录入的时候,都会携带这个值。

6.2.2 灰度路由

灰度路由需要用户传递Header属性值,比如gray=canary_gray。

网关管理后台配置灰度路由的时候,会建立grayName -> List<Server>映射关系,当网关管理后台增量推送到网关服务之后,网关服务就可以通过grayName来提取配置下的后端机器列表,然后再进行负载均衡挑选机器。

如下图所示:

6.3 API映射匹配

网关在进行请求转发的时候,需要明确知道请求哪一个服务的哪一个API,这个过程就是API匹配。

因为不同的后端服务可能会拥有相同路径的API,所以网关要求请求传递serviceName,serviceName可以放置于请求Header或者请求参数中。

携带了serviceName之后,就可以在后端服务的API中去匹配了,有一些是相等匹配,有些是正则匹配,因为RESTFul协议,需要支持 /* 通配符匹配。

这里会有人疑问了,难道请求一定需要显式传递serviceName吗?

为了解决这个问题,创建了一个gateway\_origin\_mapping表,用于path前缀或者域名前缀 映射到 serviceName,通过在管理后台建立这个映射关系,然后推送到网关服务,即可解决显式传递serviceName的问题,会自动提取请求的path前缀、域名前缀,找到对应的serviceName。

如果不希望是在一个后端服务中进行API匹配,则需阅读后面的第七章节。

6.4 负载均衡

替换 ribbon 组件,改为仿造 Dubbo 的负载均衡机制。

public interface ILoadBalance {
 
    /**
     * 从服务列表中筛选一台机器进行调用
     * @param serverList
     * @param originName
     * @param requestMessage
     * @return
     */
    DynamicServer select(List<DynamicServer> serverList, String originName, HttpRequestMessage requestMessage);
 
}

替换的理由:ribbon的服务列表更新只是定期更新,如果不考虑复杂的筛选过滤,是满足要求的,但是如果想要灵活的根据请求头、请求参数进行筛选,ribbon则不太适合。

6.5 心跳检测

核心思路:当网络请求正常返回的时候,心跳检测是不需要,此时后端服务节点肯定是正常的,只需要定期检测未被请求的后端节点,超过一定的错误阈值,则标记为不可用,从机器列表中剔除。

第一期先实现简单版本:通过定时任务定期去异步调用心跳检测Url,如果超过失败阈值,则从从负载均衡列表中剔除。

异步请求采用httpasyncclient组件处理。

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpasyncclient</artifactId>
    <version>4.1.4</version>
</dependency>

方案为:HealthCheckScheduledExecutor + HealthCheckTask + HttpAsyncClient。

6.6 日志异步化改造

Zuul2默认采用的log4j进行日志打印,是同步阻塞操作,需要修改为异步化操作,改为使用logback的AsyncAppender。

日志打印也是影响性能的一个关键点,需要特别注意,后续会衡量是否切换为log4j2。

6.7 协议转换

HTTP -> HTTP

Zuul2采用的是ProxyEndpoint用于支持HTTP -> HTTP协议转发。

通过Netty Client的方式发起网络请求到真实的后端服务。

HTTP -> Dubbo

采用Dubbo的泛化调用实现HTTP -> Dubbo协议转发,可以采用$invokeAsync。

HTTP → Tars

基于tars-java采用类似于Dubbo的泛化调用的方式实现协议转发,基于https://github.com/TarsCloud/TarsGateway改造而来的。

6.8 无损发布

网关作为请求转发,当然希望在业务后端机器部署的期间,不应该把请求转发到还未部署完成的节点。

业务后端机器节点的无损发布,这里分为两种场景介绍:

  • 集成了网关SDK 网关SDK添加了ShutdownHook,会主动从zookeeper删除登记的节点信息,从而避免请求打到即将下线的节点。
  • 未集成网关SDK  如果什么都不做,则只能依赖网关服务的心跳检测功能,会有15s的流量损失。庆幸的是管理后台提供了流量摘除、流量恢复的操作按钮,支持动态的上线、下线机器节点。

设计方案

我们给后端机器节点dynamic\_forward\_server表新增了一个字段online,如果online=1,则代表在线,接收流量,反之,则代表下线,不接收流量。

网关服务gateway-server新增一个路由:OnlineRouter,从后端机器列表中筛选online=1的机器,过滤掉不在线的机器,则完成了无损发布的功能。

public interface IRouter {
 
    /**
     * 过滤
     * @param serverList
     * @param originName
     * @param requestMessage
     * @return
     */
    List<DynamicServer> route(List<DynamicServer> serverList, String originName, HttpRequestMessage requestMessage);
 
}

6.9 网关集群分组隔离

网关集群的分组隔离指的是业务与业务之间的请求应该是隔离的,不应该被部分业务请求打垮了网关服务,从而导致了别的业务请求无法处理。

这里我们会对接接入网关的业务进行分组归类,不同的业务使用不同的分组,不同的网关分组,会部署独立的网关集群,从而隔离了风险,不用再担心业务之间的互相影响。

举例:

金融业务在生产环境存在一个灰度点检环境,为了配合金融业务的迁移,这边也必须有一套独立的环境为之服务,那是否重新部署一套全新的系统呢(独立的前端+独立的管理后台+独立的网关集群)

其实不必这么操作,我们只需要部署一套独立的网关集群即可,因为网关管理后台,可以同时配置多个网关分组的数据。

创建一个新的网关分组finance-gray,而新的网关集群只需要拉取finance-gray分组的配置数据即可,不会对其他网关集群造成任何影响。

七、.如何快速迁移业务

在业务接入的时候,现有的网关出现了一个尴尬的问题,当某些业务方自行搭建了一套Spring Cloud Gateway网关,里面的服务没有清晰的path前缀、独立的域名拆分,虽然是微服务体系,但是大家共用一个域名,接口前缀也没有良好的划分,混用在一起。

这个时候如果再按照原有的请求处理流程,则需要业务方进行Nginx的大量修改,需要在location的地方都显式传递serviceName参数,但是业务方不愿意进行这一个调整。

针对这个问题,其实本质原因在于请求匹配逻辑的不一致性,现有的网关是先匹配服务应用,再进行API匹配,这样效率高一些,而Spring Cloud Gateway则是先API匹配,命中了才知道是哪个后端服务。

为了解决这个问题,网关再次建立了一个 "微服务集" → "微服务应用列表" 的映射关系,管理后台支持这个映射关系的推送。

一个网关分组下面会有很多应用服务,这里可以拆分为子集合,可以理解为微服务集就是里面的子集合。

客户端请求传递过来的时候,需要在请求Header传递scTag 参数,scTag用来标记是哪个微服务集,然后提取到scTag对应的所有后端服务应用列表,依次去对应的应用服务列表中进行API匹配,如果命中了,则代表请求转发到当前应用的后端节点,而对原有的架构改造很小。

如果不想改动客户端请求,则需要在业务域名的Nginx上进行调整,传递scTag请求Header。

作者:Lin Chengjun
查看原文

赞 1 收藏 1 评论 0

vivo互联网技术 发布了文章 · 2020-12-16

SPI 在 Dubbo中 的应用

通过本文的学习,可以了解 Dubbo SPI 的特性及实现原理,希望对大家的开发设计有一定的启发性。

一、概述

SPI 全称为 Service Provider Interface,是一种模块间组件相互引用的机制。其方案通常是提供方将接口实现类的全名配置在classPath下的指定文件中,由调用方读取并加载。这样需要替换某个组件时,只需要引入新的JAR包并在其中包含新的实现类和配置文件即可,调用方的代码无需任何调整。优秀的SPI框架能够提供单接口多实现类时的优先级选择,由用户指定选择哪个实现。

得益于这些能力,SPI对模块间的可插拔机制和动态扩展提供了非常好的支撑。

本文将简单介绍JDK自带的SPI,分析SPI和双亲委派的关系,进而重点分析DUBBO的SPI机制;比较两者有何不同,DUBBO的SPI带来了哪些额外的能力。

二、JDK自带SPI

提供者在classPath或者jar包的META-INF/services/目录创建以服务接口命名的文件,调用者通过java.util.ServiceLoader加载文件内容中指定的实现类。

1. 代码示例

  • 首先定义一个接口Search

search示例接口

package com.example.studydemo.spi;
public interface Search {
    void search();
}
  • 实现类FileSearchImpl实现该接口

文件搜索实现类

package com.example.studydemo.spi;
public class FileSearchImpl implements Search {
    @Override
    public void search() {
        System.out.println("文件搜索");
    }
}
  • 实现类DataBaseSearchImpl实现该接口

数据库搜索实现类

package com.example.studydemo.spi;
public class DataBaseSearchImpl implements Search {
    @Override
    public void search() {
        System.out.println("数据库搜索");
    }
}
  • 在项目的META-INF/services文件夹下,创建Search文件

文件内容为:

com.example.studydemo.spi.DataBaseSearchImpl
com.example.studydemo.spi.FileSearchImpl

测试:

import java.util.ServiceLoader;
public class JavaSpiTest {
    public static void main(String[] args) {
        ServiceLoader<Search> searches = ServiceLoader.load(Search.class);
        searches.forEach(Search::search);
    }
}

结果为:

2. 简单分析

ServiceLoader作为JDK提供的一个服务实现查找工具类,调用自身load方法加载Search接口的所有实现类,然后可以使用for循环遍历实现类进行方法调用。

有一个疑问:META-INF/services/目录是硬编码的吗,其它路径行不行?答案是不行。

跟进到ServiceLoader类中,第一行代码就是private static final String PREFIX = “META-INF/services/”,所以SPI配置文件只能放在classPath或者jar包的这个指定目录下面。

ServiceLoader的文件载入路径

public final class ServiceLoader<S>
    implements Iterable<S>
{
    //硬编码写死了文件路径
    private static final String PREFIX = "META-INF/services/";
 
    // The class or interface representing the service being loaded
    private final Class<S> service;
 
    // The class loader used to locate, load, and instantiate providers
    private final ClassLoader loader;

JDK SPI的使用比较简单,做到了基本的加载扩展组件的功能,但有以下几点不足:

  • 需要遍历所有的实现并实例化,想要找到某一个实现只能循环遍历,一个一个匹配;
  • 配置文件中只是简单的列出了所有的扩展实现,而没有给他们命名,导致在程序中很难去准确的引用它们;
  • 扩展之间彼此存在依赖,做不到自动注入和装配,不提供上下文内的IOC和AOP功能;
  • 扩展很难和其他的容器框架集成,比如扩展依赖了一个外部spring容器中的bean,原生的JDK SPI并不支持。

三、SPI与双亲委派

1. SPI加载到何处

基于类加载的双亲委派原则,由JDK内部加载的class默认应该归属于bootstrap类加载器,那么SPI机制加载的class是否也属于bootstrap呢 ?

答案是否定的,原生SPI机制通过ServiceLoader.load方法由外部指定类加载器,或者默认取Thread.currentThread().getContextClassLoader()线程上下文的类加载器,从而避免了class被载入bootstrap加载器。

2.SPI是否破坏了双亲委派

双亲委派的本质涵义是在rt.jar包和外部class之间建立一道classLoader的鸿沟,即rt.jar内的class不应由外部classLoader加载,外部class不应由bootstrap加载。

SPI仅是提供了一种在JDK代码内部干预外部class文件加载的机制,并未强制指定加载到何处;外部的class还是由外部的classLoader加载,未跨越这道鸿沟,也就谈不上破坏双亲委派。

原生ServiceLoader的类加载器

//指定类加载器
public static <S> ServiceLoader<S> load(Class<S> service,ClassLoader loader)
//默认取前线程上下文的类加载器
public static <S> ServiceLoader<S> load(Class<S> service)

四、Dubbo SPI

Dubbo借鉴了Java SPI的思想,与JDK的ServiceLoader相对应的,Dubbo设计了ExtensionLoader类,其提供的功能比JDK更为强大。

1. 基本概念

首先介绍一些基本概念,让大家有一个初步的认知。

  • 扩展点(Extension Point):是一个Java的接口。
  • 扩展(Extension):扩展点的实现类
  • 扩展实例(Extension Instance):扩展点实现类的实例。
  • 自适应扩展实例(Extension Adaptive Instance)
自适应扩展实例其实就是一个扩展类的代理对象,它实现了扩展点接口。在调用扩展点的接口方法时,会根据实际的参数来决定要使用哪个扩展。

比如一个Search的扩展点,有一个search方法。有两个实现FileSearchImpl和DataBaseSearchImpl。Search的自适应实例在调用接口方法的时候,会根据search方法中的参数,来决定要调用哪个Search的实现。

如果方法参数中有name=FileSearchImpl,那么就调用FileSearchImpl的search方法。如果name=DataBaseSearchImpl,就调用DataBaseSearchImpl的search方法。 自适应扩展实例在Dubbo中的使用非常广泛。

在Dubbo中每一个扩展点都可以有自适应的实例,如果我们没有使用@Adaptive人工指定,Dubbo会使用字节码工具自动生成一个。

  • SPI Annotation

    作用于扩展点的接口上,表明该接口是一个扩展点,可以被Dubbo的ExtentionLoader加载
  • Adaptive
@Adaptive注解可以使用在类或方法上。用在方法上表示这是一个自适应方法,Dubbo生成自适应实例时会在方法中植入动态代理的代码。方法内部会根据方法的参数来决定使用哪个扩展。

@Adaptive注解用在类上代表该实现类是一个自适应类,属于人为指定的场景,Dubbo就不会为该SPI接口生成代理类,最典型的应用如AdaptiveCompiler、AdaptiveExtensionFactory等。

@Adaptive注解的值为字符串数组,数组中的字符串是key值,代码中要根据key值来获取对应的Value值,进而加载相应的extension实例。比如new String[]{“key1”,”key2”},表示会先在URL中寻找key1的值,

如果找到则使用此值加载extension,如果key1没有,则寻找key2的值,如果key2也没有,则使用SPI注解的默认值,如果SPI注解没有默认值,则将接口名按照首字母大写分成多个部分,

然后以’.’分隔,例如org.apache.dubbo.xxx.YyyInvokerWrapper接口名会变成yyy.invoker.wrapper,然后以此名称做为key到URL寻找,如果仍没有找到则抛出IllegalStateException异常。

  • ExtensionLoader
    类似于Java SPI的ServiceLoader,负责扩展的加载和生命周期维护。ExtensionLoader的作用包括:解析配置文件加载extension类、生成extension实例并实现IOC和AOP、创建自适应的extension等,下文会重点分析。
  • 扩展名
    和Java SPI不同,Dubbo中的扩展都有一个名称,用于在应用中引用它们。比如
    registry=com.alibaba.dubbo.registry.integration.RegistryProtocol
    dubbo=com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol
  • 加载路径
    Java SPI从/META-INF/services目录加载扩展配置,Dubbo从以下路径去加载扩展配置文件:
    META-INF/dubbo/internal
    META-INF/dubbo
    META-INF/services
    其中META-INF/dubbo对开发者发放,META-INF/dubbo/internal 这个路径是用来加载Dubbo内部的拓展点的。

2. 代码示例

定义一个接口,标注上dubbo的SPI注解,赋予默认值,并提供两个extension实现类

package com.example.studydemo.spi;
@SPI("dataBase")
public interface Search {
    void search();
}
public class FileSearchImpl implements Search {
    @Override
    public void search() {
        System.out.println("文件搜索");
    }
}
public class DataBaseSearchImpl implements Search {
    @Override
    public void search() {
        System.out.println("数据库搜索");
    }
}

在META-INF/dubbo 路径下创建Search文件

文件内容如下:

dataBase=com.example.studydemo.spi.DataBaseSearchImpl
file=com.example.studydemo.spi.FileSearchImpl

编写测试类进行测试,内容如下:

public class DubboSpiTest {
    public static void main(String[] args) {
        ExtensionLoader<Search> extensionLoader = ExtensionLoader.getExtensionLoader(Search.class);
        Search fileSearch = extensionLoader.getExtension("file");
        fileSearch.search();
        Search dataBaseSearch = extensionLoader.getExtension("dataBase");
        dataBaseSearch.search();
        System.out.println(extensionLoader.getDefaultExtensionName());
        Search defaultSearch = extensionLoader.getDefaultExtension();
        defaultSearch.search();
    }
}

结果为:

从代码示例上来看,Dubbo SPI与Java SPI在这几方面是类似的:

  • 接口及相应的实现
  • 配置文件
  • 加载类及加载具体实现

3源码分析

下面深入到源码看看SPI在Dubbo中是怎样工作的,以Protocol接口为例进行分析。

//1、得到Protocol的扩展加载对象extensionLoader,由这个加载对象获得对应的自适应扩展类
Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension();
//2、根据扩展名获取对应的扩展类
Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getExtension("dubbo");

在获取扩展实例前要先获取Protocol接口的ExtensionLoader组件,通过ExtensionLoader来获取相应的Protocol实例Dubbo实际是为每个SPI接口都创建了一个对应的ExtensionLoader。

ExtensionLoader组件

public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
    if (type == null)
        throw new IllegalArgumentException("Extension type == null");
    if(!type.isInterface()) {
        throw new IllegalArgumentException("Extension type(" + type + ") is not interface!");
    }
    if(!withExtensionAnnotation(type)) {
        throw new IllegalArgumentException("Extension type(" + type +
                ") is not extension, because WITHOUT @" + SPI.class.getSimpleName() + " Annotation!");
    }
    //EXTENSION_LOADERS为ConcurrentMap,存储Class对应的ExtensionLoader
    ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    if (loader == null) {
        EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
        loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    }
    return loader;
}

EXTENSION_LOADERS是一个 ConcurrentMap,以接口Protocol为key,以ExtensionLoader对象为value;保存的是Protocol扩展的加载类,第一次加载的时候Protocol还没有自己的接口加载类,需要实例化一个。

再看new ExtensionLoader<T>(type) 这个操作,下面为ExtensionLoader的构造方法:

rivate ExtensionLoader(Class<?> type) {
    this.type = type;
    objectFactory = (type == ExtensionFactory.class ? null : ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension());
}

每一个ExtensionLoader都包含2个值:type和objectFactory,此例中type就是Protocol,objectFactory就是ExtensionFactory。

对于ExtensionFactory接口来说,它的加载类中objectFactory值为null。

对于其他的接口来说,objectFactory都是通过ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension()来获取;objectFactory的作用就是为dubbo的IOC提供依赖注入的对象,可以认为是进程内多个组件容器的一个上层引用,

随着这个方法的调用次数越来越多,EXTENSION_LOADERS 中存储的 loader 也会越来越多。

自适应扩展类与IOC

得到ExtensionLoader组件之后,再看如何获得自适应扩展实例。

public T getAdaptiveExtension() {
    //cachedAdaptiveInstance为缓存的自适应对象,第一次调用时还没有创建自适应类,所以instance为null
    Object instance = cachedAdaptiveInstance.get();
    if (instance == null) {
        if(createAdaptiveInstanceError == null) {
            synchronized (cachedAdaptiveInstance) {
                instance = cachedAdaptiveInstance.get();
                if (instance == null) {
                    try {
                        //创建自适应对象实例
                        instance = createAdaptiveExtension();
                        //将自适应对象放到缓存中
                        cachedAdaptiveInstance.set(instance);
                    } catch (Throwable t) {
                        createAdaptiveInstanceError = t;
                        throw new IllegalStateException("fail to create adaptive instance: " + t.toString(), t);
                    }
                }
            }
        }
        else {
            throw new IllegalStateException("fail to create adaptive instance: " + createAdaptiveInstanceError.toString(), createAdaptiveInstanceError);
        }
    }
 
    return (T) instance;
}

首先从cachedAdaptiveInstance缓存中获取,第一次调用时还没有相应的自适应扩展,需要创建自适应实例,创建后再将该实例放到cachedAdaptiveInstance缓存中。

创建自适应实例参考createAdaptiveExtension方法,该方法包含两部分内容:创建自适应扩展类并利用反射实例化、利用IOC机制为该实例注入属性。

private T createAdaptiveExtension() {
    try {
        //得到自适应扩展类并利用反射实例化,然后注入属性值
        return injectExtension((T) getAdaptiveExtensionClass().newInstance());
    } catch (Exception e) {
        throw new IllegalStateException("Can not create adaptive extenstion " + type + ", cause: " + e.getMessage(), e);
    }
}

再来分析getAdaptiveExtensionClass方法,以Protocol接口为例,该方法会做以下事情:获取所有实现Protocol接口的扩展类、如果有自适应扩展类直接返回、如果没有则创建自适应扩展类。

//该动态代理生成的入口
private Class<?> getAdaptiveExtensionClass() {
    //1.获取所有实现Protocol接口的扩展类
    getExtensionClasses();
    //2.如果有自适应扩展类,则返回
    if (cachedAdaptiveClass != null) {
        return cachedAdaptiveClass;
    }
    //3.如果没有,则创建自适应扩展类
    return cachedAdaptiveClass = createAdaptiveExtensionClass();
}

getExtensionClasses方法会加载所有实现Protocol接口的扩展类,首先从缓存中获取,缓存中没有则调用loadExtensionClasses方法进行加载并设置到缓存中,如下图所示:

private Map<String, Class<?>> getExtensionClasses() {
    //从缓存中获取
    Map<String, Class<?>> classes = cachedClasses.get();
    if (classes == null) {
        synchronized (cachedClasses) {
            classes = cachedClasses.get();
            if (classes == null) {
                //从SPI配置文件中解析
                classes = loadExtensionClasses();
                cachedClasses.set(classes);
            }
        }
    }
    return classes;
}

loadExtensionClasses方法如下:首先获取SPI注解中的value值,作为默认扩展名称,在Protocol接口中SPI注解的value为dubbo,因此DubboProtocol就是Protocol的默认实现扩展。其次加载三个配置路径下的所有的Protocol接口的扩展实现。

// 此方法已经getExtensionClasses方法同步过。
private Map<String, Class<?>> loadExtensionClasses() {
    final SPI defaultAnnotation = type.getAnnotation(SPI.class);
    if(defaultAnnotation != null) {
        String value = defaultAnnotation.value();
        if(value != null && (value = value.trim()).length() > 0) {
            String[] names = NAME_SEPARATOR.split(value);
            if(names.length > 1) {
                throw new IllegalStateException("more than 1 default extension name on extension " + type.getName()
                        + ": " + Arrays.toString(names));
            }
            if(names.length == 1) cachedDefaultName = names[0];
        }
    }
     
    Map<String, Class<?>> extensionClasses = new HashMap<String, Class<?>>();
    //分别从三个路径加载
    loadFile(extensionClasses, DUBBO_INTERNAL_DIRECTORY);
    loadFile(extensionClasses, DUBBO_DIRECTORY);
    loadFile(extensionClasses, SERVICES_DIRECTORY);
    return extensionClasses;
}
 
 
private static final String SERVICES_DIRECTORY = "META-INF/services/";
private static final String DUBBO_DIRECTORY = "META-INF/dubbo/";
private static final String DUBBO_INTERNAL_DIRECTORY = DUBBO_DIRECTORY + "internal/";

在加载配置路径下的实现中,其中有一个需要关注的点,如果其中某个实现类上有Adaptive注解,说明用户指定了自适应扩展类,那么该实现类就会被赋给cachedAdaptiveClass,在getAdaptiveExtensionClass方法中会被直接返回。

如果该变量为空,则需要通过字节码工具来创建自适应扩展类。

private Class<?> createAdaptiveExtensionClass() {
    //生成类代码
    String code = createAdaptiveExtensionClassCode();
    //找到类加载器
    ClassLoader classLoader = findClassLoader();
    //获取编译器实现类,此处为AdaptiveCompiler,此类上有Adaptive注解
    com.alibaba.dubbo.common.compiler.Compiler compiler = ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.common.compiler.Compiler.class).getAdaptiveExtension();
    //将类代码编译为Class
    return compiler.compile(code, classLoader);
}

createAdaptiveExtensionClass方法生成的类代码如下:

package com.alibaba.dubbo.rpc;
 
import com.alibaba.dubbo.common.extension.ExtensionLoader;
 
public class Protocol$Adpative implements com.alibaba.dubbo.rpc.Protocol {
    public void destroy() {
        throw new UnsupportedOperationException("method public abstract void com.alibaba.dubbo.rpc.Protocol.destroy() of interface com.alibaba.dubbo.rpc.Protocol is not adaptive method!");
    }
 
    public int getDefaultPort() {
        throw new UnsupportedOperationException("method public abstract int com.alibaba.dubbo.rpc.Protocol.getDefaultPort() of interface com.alibaba.dubbo.rpc.Protocol is not adaptive method!");
    }
 
    public com.alibaba.dubbo.rpc.Invoker refer(java.lang.Class arg0, com.alibaba.dubbo.common.URL arg1) throws com.alibaba.dubbo.rpc.RpcException {
        if (arg1 == null) throw new IllegalArgumentException("url == null");
        com.alibaba.dubbo.common.URL url = arg1;
        String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());
        if (extName == null)
            throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.rpc.Protocol) name from url(" + url.toString() + ") use keys([protocol])");
        com.alibaba.dubbo.rpc.Protocol extension = (com.alibaba.dubbo.rpc.Protocol) ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.rpc.Protocol.class).getExtension(extName);
        return extension.refer(arg0, arg1);
    }
 
    public com.alibaba.dubbo.rpc.Exporter export(com.alibaba.dubbo.rpc.Invoker arg0) throws com.alibaba.dubbo.rpc.RpcException {
        if (arg0 == null) throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument == null");
        if (arg0.getUrl() == null)
            throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument getUrl() == null");
        com.alibaba.dubbo.common.URL url = arg0.getUrl();
        String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());
        if (extName == null)
            throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.rpc.Protocol) name from url(" + url.toString() + ") use keys([protocol])");
        com.alibaba.dubbo.rpc.Protocol extension = (com.alibaba.dubbo.rpc.Protocol) ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.rpc.Protocol.class).getExtension(extName);
        return extension.export(arg0);
    }
}

由字节码工具生成的类Protocol$Adpative在方法末尾调用了ExtensionLoader.getExtensionLoader(xxx).getExtension(extName)来满足adaptive的自适应动态特性。

传入的extName就是从url中获取的动态参数,用户只需要在代表DUBBO全局上下文信息的URL中指定protocol参数的取值,adaptiveExtentionClass就可以去动态适配不同的扩展实例。

再看属性注入方法injectExtension,针对public的只有一个参数的set方法进行处理,利用反射进行方法调用来实现属性注入,此方法是Dubbo SPI实现IOC功能的关键。

private T injectExtension(T instance) {
    try {
        if (objectFactory != null) {
            for (Method method : instance.getClass().getMethods()) {
                if (method.getName().startsWith("set")
                        && method.getParameterTypes().length == 1
                        && Modifier.isPublic(method.getModifiers())) {
                    Class<?> pt = method.getParameterTypes()[0];
                    try {
                        String property = method.getName().length() > 3 ? method.getName().substring(3, 4).toLowerCase() + method.getName().substring(4) : "";
                        Object object = objectFactory.getExtension(pt, property);
                        if (object != null) {
                            method.invoke(instance, object);
                        }
                    } catch (Exception e) {
                        logger.error("fail to inject via method " + method.getName()
                                + " of interface " + type.getName() + ": " + e.getMessage(), e);
                    }
                }
            }
        }
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    }
    return instance;

Dubbo IOC 是通过set方法注入依赖,Dubbo首先会通过反射获取到实例的所有方法,然后再遍历方法列表,检测方法名是否具有set方法特征。若有则通过ObjectFactory获取依赖对象。

最后通过反射调用set方法将依赖设置到目标对象中。objectFactory在创建加载类ExtensionLoader的时候已经创建了,因为@Adaptive是打在类AdaptiveExtensionFactory上,所以此处就是AdaptiveExtensionFactory。

AdaptiveExtensionFactory持有所有ExtensionFactory对象的集合,dubbo内部默认实现的对象工厂是SpiExtensionFactory和SpringExtensionFactory,他们经过TreeSet排好序,查找顺序是优先先从SpiExtensionFactory获取,如果返回空在从SpringExtensionFactory获取。

//有Adaptive注解说明该类是自适应类,不需要程序自己创建代理类
@Adaptive
public class AdaptiveExtensionFactory implements ExtensionFactory {
    //factories拥有所有ExtensionFactory接口的实现对象
    private final List<ExtensionFactory> factories;
     
    public AdaptiveExtensionFactory() {
        ExtensionLoader<ExtensionFactory> loader = ExtensionLoader.getExtensionLoader(ExtensionFactory.class);
        List<ExtensionFactory> list = new ArrayList<ExtensionFactory>();
        for (String name : loader.getSupportedExtensions()) {
            list.add(loader.getExtension(name));
        }
        factories = Collections.unmodifiableList(list);
    }
    //查找时会遍历factories,顺序优先从SpiExtensionFactory中获取,再从SpringExtensionFactory中获取,原因为初始化时getSupportedExtensions方法中使用TreeSet已经排序,见下图
    public <T> T getExtension(Class<T> type, String name) {
        for (ExtensionFactory factory : factories) {
            T extension = factory.getExtension(type, name);
            if (extension != null) {
                return extension;
            }
        }
        return null;
    }
}
public Set<String> getSupportedExtensions() {
    Map<String, Class<?>> clazzes = getExtensionClasses();
    return Collections.unmodifiableSet(new TreeSet<String>(clazzes.keySet()));
}

虽然有过度设计的嫌疑,但我们不得不佩服dubbo SPI设计的精巧。

  • 提供@Adaptive注解,既可以加在方法上通过参数动态适配到不同的扩展实例;又可以加在类上直接指定自适应扩展类。
  • 利用AdaptiveExtensionFactory统一了进程中的不同容器,将ExtensionLoader本身视为一个独立的容器,依赖注入时将会分别从Spring容器和ExtensionLoader容器中查找。

扩展实例和AOP

getExtension方法比较简单,重点在于createExtension方法,根据扩展名创建扩展实例。

public T getExtension(String name) {
   if (name == null || name.length() == 0)
       throw new IllegalArgumentException("Extension name == null");
   if ("true".equals(name)) {
       return getDefaultExtension();
   }
   Holder<Object> holder = cachedInstances.get(name);
   if (holder == null) {
       cachedInstances.putIfAbsent(name, new Holder<Object>());
       holder = cachedInstances.get(name);
   }
   Object instance = holder.get();
   if (instance == null) {
       synchronized (holder) {
            instance = holder.get();
            if (instance == null) {
                //根据扩展名创建扩展实例
                instance = createExtension(name);
                holder.set(instance);
            }
        }
   }
   return (T) instance;
}

createExtension方法中的部分内容上文已经分析过了,getExtensionClasses方法获取接口的所有实现类,然后通过name获取对应的Class。紧接着通过clazz.newInstance()来实例化该实现类,调用injectExtension为实例注入属性。

private T createExtension(String name) {
    //getExtensionClasses方法之前已经分析过,获取所有的扩展类,然后根据扩展名获取对应的扩展类
    Class<?> clazz = getExtensionClasses().get(name);
    if (clazz == null) {
        throw findException(name);
    }
    try {
        T instance = (T) EXTENSION_INSTANCES.get(clazz);
        if (instance == null) {
            EXTENSION_INSTANCES.putIfAbsent(clazz, (T) clazz.newInstance());
            instance = (T) EXTENSION_INSTANCES.get(clazz);
        }
        //属性注入
        injectExtension(instance);
        Set<Class<?>> wrapperClasses = cachedWrapperClasses;
        if (wrapperClasses != null && wrapperClasses.size() > 0) {
            for (Class<?> wrapperClass : wrapperClasses) {
                //包装类的创建及属性注入
                instance = injectExtension((T) wrapperClass.getConstructor(type).newInstance(instance));
            }
        }
        return instance;
    } catch (Throwable t) {
        throw new IllegalStateException("Extension instance(name: " + name + ", class: " +
                type + ")  could not be instantiated: " + t.getMessage(), t);
    }
}

在方法的最后有一段对于WrapperClass包装类的处理逻辑,如果接口存在包装类实现,那么就会返回包装类实例。实现AOP的关键就是WrapperClass机制,判断一个扩展类是否是WrapperClass的依据,是看其constructor函数中是否包含当前接口参数。

如果有就认为是一个wrapperClass,最终创建的实例是一个经过多个wrapperClass层层包装的结果;在每个wrapperClass中都可以编入面向切面的代码,从而就简单实现了AOP功能。

Activate活性扩展

对应ExtensionLoader的getActivateExtension方法,根据多个过滤条件从extension集合中智能筛选出您所需的那一部分。

getActivateExtension方法

public List<T> getActivateExtension(URL url, String[] names, String group);

首先这个方法只会返回带有Activate注解的扩展类,但并非带有注解的扩展类都会被返回。

names是明确指定所需要的那部分扩展类,非明确指定的扩展类需要满足group过滤条件和Activate注解本身指定的key过滤条件,非明确指定的会按照Activate注解中指定的排序规则进行排序;

getActivateExtension的返回结果是上述两种扩展类的总和。

Activate注解类

*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Activate {
    /**
     * Group过滤条件。
     */
    String[] group() default {};
 
    /**
     * Key过滤条件。包含{@link ExtensionLoader#getActivateExtension}的URL的参数Key中有,则返回扩展。
     */
    String[] value() default {};
 
    /**
     * 排序信息,可以不提供。
     */
    String[] before() default {};
 
    /**
     * 排序信息,可以不提供。
     */
    String[] after() default {};
 
    /**
     * 排序信息,可以不提供。
     */
    int order() default 0;
}

活性Extension最典型的应用是rpc invoke时获取filter链条,各种filter有明确的执行优先级,同时也可以人为增添某些filter,filter还可以根据服务提供者和消费者进行分组过滤。

Dubbo invoke获取filter链条

List<Filter> filters = ExtensionLoader.getExtensionLoader(Filter.class).getActivateExtension(invoker.getUrl(), Constants.SERVICE_FILTER_KEY, Constants.PROVIDER);

以TokenFilter为例,其注解为@Activate(group = Constants.PROVIDER, value = Constants.TOKEN_KEY),表示该过滤器只在服务提供方才会被加载,同时会验证注册地址url中是否带了token参数,如果有token表示服务端注册时指明了要做token验证,自然就需要加载该filter。

反之则不用加载;此filter加载后的执行逻辑则是从url中获取服务端注册时预设的token,再从rpc请求的attachments中获取消费方设置的remote token,比较两者是否一致,若不一致抛出RPCExeption异常阻止消费方的正常调用。

五、总结

Dubbo 所有的接口几乎都预留了扩展点,根据用户参数来适配不同的实现。如果想增加新的接口实现,只需要按照SPI的规范增加配置文件,并指向新的实现即可。

用户配置的Dubbo属性都会体现在URL全局上下文参数中,URL贯穿了整个Dubbo架构,是Dubbo各个layer组件间相互调用的纽带。

总结一下 Dubbo SPI 相对于 Java SPI 的优势:

  • Dubbo的扩展机制设计默认值,每个扩展类都有自己的名称,方便查找。
  • Dubbo的扩展机制支持IOC,AOP等高级功能。
  • Dubbo的扩展机制能和第三方IOC容器兼容,默认支持Spring Bean,也可扩展支持其他容器。
  • Dubbo的扩展类通过@Adaptive注解实现了动态代理功能,更强大的是它可以通过一个proxy映射多个不同的扩展类。
  • Dubbo的扩展类通过@Activate注解实现了不同扩展类的分组、过滤、排序功能,能够更好的适配较复杂的业务场景。
作者: Xie Xiaopeng
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-15

Sentinel 是如何做限流的

限流是保障服务高可用的方式之一,尤其是在微服务架构中,对接口或资源进行限流可以有效地保障服务的可用性和稳定性。

之前的项目中使用的限流措施主要是Guava的RateLimiter。RateLimiter是基于令牌桶流控算法,使用非常简单,但是功能相对比较少。

而现在,我们有了一种新的选择,阿里提供的Sentinel。

Sentinel 是阿里巴巴提供的一种限流、熔断中间件,与RateLimiter相比,Sentinel提供了丰富的限流、熔断功能。它支持控制台配置限流、熔断规则,支持集群限流,并可以将相应服务调用情况可视化。

目前已经有很多项目接入了Sentinel,而本文主要是对Sentinel的限流功能做一次详细的分析,至于Sentinel的其他能力,则不作深究。

一、总体流程

先来了解一下总体流程:

( 引用于Sentinel官网)

上面的图是官网的图,

从设计模式上来看,典型的的责任链模式。外部请求进来后,要经过责任链上各个节点的处理,而Sentinel的限流、熔断就是通过责任链上的这些节点实现的。

从限流算法来看,Sentinel使用滑动窗口算法来进行限流。要想深入了解原理,还是得从源码上入手,下面,直接进入Sentinel的源码阅读。

二、源码阅读

1. 源码阅读入口及总体流程

读源码先得找到源码入口。我们经常使用@ SentinelResource来标记一个方法,可以将这个被@ SentinelResource标记的方法看成是一个Sentinel资源。因此,我们以@ SentinelResource为入口,找到其切面,看看切面拦截后所做的工作,就可以明确Sentinel的工作原理了。直接看注解@SentinelResource的切面代码(SentinelResourceAspect)。

可以清晰的看到Sentinel的行为方式。进入SentinelResource切面后,会执行SphU.entry方法,在这个方法中会对被拦截方法做限流和熔断的逻辑处理。

如果触发熔断和限流,会抛出BlockException,我们可以指定blockHandler方法来处理BlockException。而对于业务上的异常,我们也可以配置fallback方法来处理被拦截方法调用产生的异常。

所以,Sentinel熔断限流的处理主要是在SphU.entry方法中,其主要处理逻辑见下图源码。

可见,在SphU.entry方法中,Sentinel实现限流、熔断等功能的流程可以总结如下:

  • 获取Sentinel上下文(Context);
  • 获取资源对应的责任链;
  • 生成资源调用凭证(Entry);
  • 执行责任链中各个节点。

接下来,围绕这几个方面,对Sentinel的服务机制做一个系统的阐述。

2. 获取Sentinel上下文(Context)

Context,顾名思义,就是Sentinel熔断限流执行的上下文,包含资源调用的节点和Entry信息。

来看看Context的特征:

  • Context是线程持有的,利用ThreadLocal与当前线程绑定。

  • Context包含的内容

这里就引出了Sentinel的三个比较重要的概念:Conetxt,Node,Entry。这三个类是Sentinel的核心类,提供了资源调用路径、资源调用统计等信息。

Context

Context是当前线程所持有的Sentinel上下文。

进入Sentinel的逻辑时,会首先获取当前线程的Context,如果没有则新建。当任务执行完毕后,会清除当前线程的context。Context 代表调用链路上下文,贯穿一次调用链路中的所有 Entry。

Context 维持着入口节点(entranceNode)、本次调用链路的 当前节点(curNode)、调用来源(origin)等信息。Context 名称即为调用链路入口名称。

Node

Node是对一个@SentinelResource标记的资源的统计包装。

Context中记录本当前线程资源调用的入口节点。

我们可以通过入口节点的childList,可以追溯资源的调用情况。而每个节点都对应一个@SentinelResource标记的资源及其统计数据,例如:passQps,blockQps,rt等数据。

Entry

Entry是Sentinel中用来表示是否通过限流的一个凭证,如果能正常返回,则说明你可以访问被Sentinel保护的后方服务,否则Sentinel会抛出一个BlockException。

另外,它保存了本次执行entry()方法的一些基本信息,包括资源的Context、Node、对应的责任链等信息,后续完成资源调用后,还需要更具获得的这个Entry去执行一些善后操作,包括退出Entry对应的责任链,完成节点的一些统计信息更新,清除当前线程的Context信息等。

3.  获取@SentinelResource标记资源对应的责任链

资源对应的责任链是限流逻辑具体执行的地方,采用的是典型的责任链模式。

先来看看默认的的责任链的组成:

 

默认的责任链中的处理节点包括NodeSelectorSlot、ClusterBuilderSlot、StatisticSlot、FlowSlot、DegradeSlot等。调用链(ProcessorSlotChain)和其中包含的所有Slot都实现了ProcessorSlot接口,采用责任链的模式执行各个节点的处理逻辑,并调用下一个节点。

每个节点都有自己的作用,后面将会看到这些节点具体是干什么的。

此外,相同资源(@SentinelResource标记的方法)对应的责任链是一致的。也就是说,每个资源对应一条单独的责任链,可以看下源码中资源责任链的获取逻辑:先从缓存获取,没有则新建。

4. 生成调用凭证Entry

生成的Entry是CtEntry。其构造参数包括资源包装(ResourceWrapper)、资源对应的责任链以及当前线程的Context。

可以看到,新建CtEntry记录了当前资源的责任链和Context,同时更新Context,将Context的当前Entry设置为自己。可以看到,CtEntry是一个双向链表,构建了Sentinel资源的调用链路。

5. 责任链的执行

接下来就进入了责任链的执行。责任链和其中的Slot都实现了ProcessorSlot,责任链的entry方法会依次执行责任链各个slot,所以下面就进入了责任链中的各个Slot。为了突出重点,这次本文只研究与限流功能有关的Slot。

5.1 NodeSelectorSlot -- 获取当前资源对应Node,构建节点调用树

此节点负责获取或者构建当前资源对应的Node,这个Node被用于后续资源调用的统计及限流和熔断条件的判断。同时,NodeSelectorSlot还会完成调用链路构建。来看源码:

熟悉的代码风格。我们知道一个资源对应一个责任链。每个调用链中都有NodeSelectorSlot。NodeSelectSlot中的node缓存map是非静态变量,所以map只对当前这个资源共用,不同的资源对应的NodeSelectSlot及Node的缓存都是不一样的,资源和Node缓存map的关系可见下图。

所以NodeSelectorSlot的的作用是:

  • 在资源对应的调用链执行时,获取当前context对应的Node,这个Node代表着这个资源的调用情况。
  • 将获取到的node设为当前node,添加到之前的node后面,形成树状的调用路径。(通过Context中的当前Entry进行)
  • 触发下一个Slot的执行。

这里有个很有趣的问题,就是我们在责任链的NodeSelectorSlot中获取资源对应的Node时,为什么用的是Context的name,而不是SentinelResource的name呢?

首先,我们知道一个资源对应一条责任链。但是进入一个资源调用的Context却可能是不同的。如果使用资源名来作为key,获取对应的Node,那么通过不同context进来的调用方法获取到的Node就都是同一个了。所以通过这种方式,可以将相同resource对应的node按Context区分开。

举个例子,Sentinel功能的实现不仅仅可以通过@SentinelResource注解方法来实现,也可以通过引入相关依赖(sentinel-dubbo-adapter),利用Dubbo的Filter机制直接对DUBBO接口进行保护。我们来比较@SentinelResource和Dubbo方式生成Context的区别:

@SentinelResource

生成的context的name是:sentinel\_default\_context。所有资源对应的Context都是这个值。

Dubbo Filter方式

生成的context的name是Dubbo的接口限定名或者方法限定名。

如果出现嵌套在Dubbo Filter方式下面的其他SentinelResource的资源调用,那么这些资源调用的就会就会出现不同的Context。

所以有这样一种情况,不同的dubbo接口进来,这些dubbo接口都调用了同一个@SentinelResource标记的方法,那么这个方法对应的SentinelReource的在执行时对应的Context就是不同的。

另一个问题是,既然资源按Context分出了不同的node,那我们想看资源总数统计是怎么办呢?这就涉及到ClusterNode了。详细可见ClusterBuilderSlot。

5.2 ClusterBuilderSlot -- 聚合相同资源不同Context的Node

此节点负责聚合相同资源不同Context对应的Node,以供后续限流判断使用。

可以看到,ClusterNode的获取是以资源名为key。ClusterNode将会成为当前node的一个属性,主要目的是为了聚合同一个资源不同Context情况下的多个node。默认的限流条件判断就是依据ClusterNode中的统计信息来进行的。

5.3 StatisticSlot -- 资源调用统计

此节点主要负责资源调用的统计信息的计算和更新。与前面以及后面的slot不同,StatisticSlot的执行时先触发下一个slot的执行,等下面的slot执行完才会执行自己的逻辑。

这也很好理解,作为统计组件,总要等熔断或者限流处理完之后才能做统计吧。下面看一下具体的统计过程。

上面这张图已经很清晰的描述了StatisticSlot的数据统计的过程。可以注意一下无异常和阻塞异常的情况,主要是更新线程数、通过请求数量和阻塞请求数量。不管是DefaultNode,还是ClusterNode,都继承自StatisticNode。所以Node的数据更新要来到StatisticNode。

参考Sentinel数据统计框图,描述了Node统计数据更新的大体流程如下:

我们从StatisticNode.addPassRequest()方法入手,以passQps为例,探究StatisticNode是如何更新通过请求的QPS计数的。

从源码可见,计数变量rollingCounterInSecond和rollingCounterInMinute都是Metric,两个变量的时间维度分别是秒和分钟。rollingCounterInSecond和rollingCounterInMinute用的是Metric的实现类ArrayMetric。

从ArrayMetric追溯下去:

统计信息都是保存到ArrayMetric的data,也就是LeapArray<MertricBucket>中的。

LeapArray是时间窗口数组。基本信息包括:时间窗口长度(ms,windowLengthInMs),取样数(也就是时间窗口的数量,sampleCount),时间间隔(ms,intervalInMs),以及时间窗口数组(array)。时间窗口长度、取样数及时间间隔有下面的关系:

windowLengthInMs = intervalInMs / sampleCount

代码中rollingCounterInSecond使用的intervalInMs 是1000(ms),也就是1s,sampleCount=2。所以,窗口时长就是windowLengthInMs = 500ms。rollingCounterInMinute使用的intervalInMs 是60 * 1000(ms),也就是60s。sampleCount=60,所以,windowLengthInMs = 1000ms,也就是1s。

时间窗口数组(array)是类型是AtomicReferenceArray,可见这是一个原子操作的的数组引用。数组元素类型是WindowWrap<MetricBucket>。windowWrap是对时间窗口的一个包装,包括窗口的开始时间(windowStart)及窗口的长度(windowLengthInMs),以及本窗口的计数器(value,类型为MetricBucket)。窗口实际的计数是由MetricBucket进行的,计数信息是保存在MetricBucket里计数器counters(类型为(LongAdder))。可以看一下下图计数组件的组成框图:

回到StatisticNode.addPassRequest方法,以rollingCounterInSecond.addPass(count)为例,探究Sentinel如何进行滑动窗口计数的。

5.3.1 获取当前时间窗口

(1)取当前时间戳对应的数组下标

long timeId = time / windowLength

int idx = (int)(timeId % array.length());

time为当前时间,windowLength为时间窗口长度,rollingCounterInSecond的时间窗口长度是500ms。array 是单位时间内时间窗口的数量,rollingCounterInSecond的单位时间(1s)时间窗口数是2。timeId是当前时间对时间窗口的整除。time每增加一个windowLength的长度,timeId就会增加1,时间窗口就会往前滑动一个。

(2)计算窗口开始时间

窗口开始时间 = 当前时间(ms)-当前时间(ms)%时间窗口长度(ms)

获取的窗口开始时间均为时间窗口的整数倍。

(3)获取时间窗口

首先,根据数组下标从LeapArray的数组中获取时间窗口。

  • 如果获取到的时间窗口自为空,则新建时间窗口(CAS)。
  • 如果获取到的时间窗口非空,且时间窗口的开始时间等于我们计算的开始时间,说明当前时间正好在这个时间窗口里,直接返回该时间窗口。
  •  如果获取到的时间窗口非空,且时间窗口的开始时间小于我们计算的开始时间,说明时间窗口已经过期(距离上次获取时间窗口已经过去比较久的场景),需要更新时间窗口(加锁操作),将时间窗口的开始时间设为计算出来的开始时间,将时间窗口里的计数器重置为0。
  •  如果获取到的时间窗口非空,且时间窗口的开始时间大于我们计算的开始时间,创建新的时间窗口。这个一般不会走进这个分支,因为说明当前时间已经落后于时间窗口了,获取到的时间窗口是将来的时间,那就没有意义了。

5.3.2 对时间窗口的计数器进行累加

时间窗口计数器是一个LongAdder数组,这个数组用于存放通过请求数、异常请求数、阻塞请求数等数据。如下图:

其中,通过计数、阻塞计数、异常计数为执行StatisticSlot的entry方法时更新。成功计数及响应时间是执行StatisticSlot的exit方法时更新。其实就是分别在被拦截方法执行前和执行后进行相应计数的更新。当然,addPass就是在计数数组的第一个元素上进行累加。

计数数组元素类型是LongAdder。LongAdder是JDK8添加到JUC中的。它是一个线程安全的、比Atomic*系工具性能更好的"计数器"。

5.4 FlowSlot -- 限流判断

FlowSlot是进行限流条件判断的节点。之前在StatisticSlot对相关资源调用做的统计,在FlowSlot限流判断时将会得到使用。

直接来到限流操作的核心逻辑–限流规则检查器(FlowRuleChecker):

主要的流程包括:

  • 获取资源对应的限流规则
  • 根据限流规则检查是否被限流

如果被限流,则抛出限流异常FlowException。FlowException继承自BlockException。

那么FlowSlot检查是否限流的过程是怎么样的?

默认情况下,限流使用的节点是当前节点的cluster node。主要分析的限流方式是QPS限流。来看一下限流的关键代码(DefaultController):

  • 获取节点的当前qps计数;
  • 判断获取新的计数后是否超过阈值
  • 超过阈值单返回false,表示被限流,后面会抛出FlowException。否则返回true,不被限流。

可以看到限流判断非常简单,只需要对qps计数进行检查就可以了。这归功于StatisticSlot做的数据统计。

5.5 责任链小结

通过上面的讲解,再来看下面这张图,是不是很清晰了?

( 引用于Sentinel官网)

NodeSelectorSlot用于获取资源对应的Node,并构建Node调用树,将SentinelSource的调用链路以Node Tree的形式组起来。ClusterBuilderSlot为当前Node创建对应的ClusterNode,聚合相同资源对应的不同Context的Node,后续的限流依据就是这个ClusterNode。

ClusterNode继承自StatisticNode,记录着相应资源处理的一些统计数据。StatisticSlot用于更新资源调用的相关计数,用于后续的限流判断使用。FlowSlot根据资源对应Node的调用计数,判断是否进行限流。至此,Sentinel的责任链执行逻辑就完整了。

6. Sentienl 的收尾工作

无论执行成功还是失败,或者是阻塞,都会执行Entry.exit()方法,来看一下这个方法。

  • 判断要退出的entry是否是当前context的当前entry;
  • 如果要退出的entry不是当前context的当前entry,则不退出此entry,而是退出context的的当前entry及其所有父entry,并抛出异常;
  • 如果要退出的entry是当前context的当前entry(这种是正常情况),先退出当前entry对应的责任链的所有slot。在这一步,StatisticSlot会更新node的success计数和RT计数;
  • 将context的当前entry置为被退出的entry的父entry;
  • 如果被退出entry的父entry为空,且context为默认context,自动退出默认context(清除ThreadLocal)。
  • 清除被退出entry的context引用

7. 总结

通过阅读Sentinel的源码,可以很清晰的理解Sentinel的限流过程了,而对上面的源码阅读,总结如下:

  • 三大组件Context、Entry、Node,是Sentinel的核心组件,各类信息及资源调用情况都由这三大类持有;
  • 采用责任链模式完成Sentinel的信息统计、熔断、限流等操作;
  • 责任链中NodeSelectSlot负责选择当前资源对应的Node,同时构建node调用树;
  • 责任链中ClusterBuilderSlot负责构建当前Node对应的ClusterNode,用于聚合同一资源对应不同Context的Node;
  • 责任链中的StatisticSlot用于统计当前资源的调用情况,更新Node与其对用的ClusterNode的各种统计数据;
  • 责任链中的FlowSlot根据当前Node对应的ClusterNode(默认)的统计信息进行限流;
  • 资源调用统计数据(例如PassQps)使用滑动时间窗口进行统计;
  • 所有工作执行完毕后,执行退出流程,补充一些统计数据,清理Context。

三、参考文献

https://github.com/alibaba/Sentinel/wiki

作者:Sun Yi
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-15

领域驱动设计(DDD)实践之路(四):领域驱动在微服务设计中的应用

这是“领域驱动设计实践之路”系列的第四篇文章,从单体架构的弊端引入微服务,结合领域驱动的概念介绍了如何做微服务划分、设计领域模型并展示了整体的微服务化的系统架构设计。结合分层架构、六边形架构和整洁架构的思想,以实际使用场景为背景,展示了一个微服务的程序结构设计。

一、单体架构的弊端

单体结构示例(引用自互联网)

一般在业务发展的初期,整个应用涉及的功能需求较少,相对比较简单,单体架构的应用比较容易部署、测试,横向扩展也比较易实现。

然而,随着需求的不断增加, 越来越多的人加入开发团队,代码库也在飞速地膨胀。慢慢地,单体应用变得越来越臃肿,可维护性、灵活性逐渐降低,维护成本越来越高。

下面分析下单体架构应用存在的一些弊端:

1、复杂性高

在项目初期应该有人可以做到对应用各个功能和实现了如指掌,随着业务需求的增多,各种业务流程错综复杂的揉在一起,整个系统变得庞大且复杂,以至于很少有开发者清楚每一个功能和业务流程细节。

这样会使得新业务的需求评估或者异常问题定位会占用较多的时间,同时也蕴含着未知风险。更糟糕的是,这种极度的复杂性会形成一种恶性循环,每一次更改都会使得系统变得更复杂,更难懂。

2.技术债务多

随着时间推移、需求变更和人员更迭,会逐渐形成应用程序的技术债务,并且越积越多。比如,团队必须长期使用一套相同的技术栈,很难采用新的框架和编程语言。有时候想引入一些新的工具时,就会使得项目中需要同时维护多套技术框架,比如同时维护Hibernate和Mybatis,使得成本变高。

3.错误难隔离

由于业务项目的所有功能模块都在一个应用上承担,包括核心和非核心模块,任何一个模块或者一个小细节的地方,因为设计不合理、代码质量差等原因,都有可能造成应用实例的崩溃,从而使得业务全面受到影响。其根本原因就是核心和非核心功能的代码都运行在同一个环境中。

4. 项目团队间协同成本高,业务响应越来越慢

多个类似的业务项目之间势必会存在类似的功能模块,如果都采用单体模式,就会带来重复功能建设和维护。而且,有时候还需要互相产生交互,打通单体系统之间的交互集成和协作的成本也需要额外付出。

再者,当项目大到一定程度,不同的模块可能是不同的团队来维护,迭代联调的冲突,代码合并分支的冲突都会影响整个开发进度,从而使得业务响应速度越来越慢。

5.扩展成本高

随着业务的发展,系统在出现业务处理瓶颈的时候,往往是由于某一个或几个功能模块负载较高造成的,但因为所有功能都打包在一起,在出现此类问题时,只能通过增加应用实例的方式分担负载,没办法对单独的几个功能模块进行服务能力的扩展,从而带来资源额外配置的消耗,成本较高。

针对以上痛点,近年来越来越多的互联网公司采用“微服务”架构构建自身的业务平台,而“微服务”也获得了越来越多技术人员的肯定。

微服务其实是SOA的一种演变后的形态,与SOA的方法和原则没有本质区别。SOA理念的核心价值是,松耦合的服务带来业务的复用,按照业务而不是技术的维度,结合高内聚、低耦合的原则来划分微服务,这正好与领域驱动设计所倡导的理念相契合。

二、微服务设计

1. 微服务划分

从广义上讲,领域即是一个组织所做的事情以及其中包含的一切。每个组织都有它自己的业务范围和做事方式,这个业务范围以及在其中所进行的活动便是领域。

DDD的子域和限界上下文的概念,可以很好地跟微服务架构中的服务进行匹配。而且,微服务架构中的自治化团队负责服务开发的概念,也与DDD中每个领域模型都由一个独立团队负责开发的概念吻合。DDD倡导按业务领域来划分系统,微服务架构更强调从业务维度去做分治来应对系统复杂度,跳过业务架构设计出来的架构关注点不在业务响应上,可能就是个大泥球,在面临需求迭代或响应市场变化时就很痛苦。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

以电商的资源订购系统为例,典型业务用例场景包括查看资源,购买资源,查询用户已购资源等。

领域驱动为每一个子域定义单独的领域模型,子域是领域的一部分,从业务的角度分析我们需要覆盖的业务用例场景,以高内聚低耦合的思想,结合单一职责原则(SRP)和闭包原则(CCP),从业务领域的角度,划分出用户管理子域,资源管理子域,订单子域和支付子域共四个子域。

每个子域对应一个限界上下文。限界上下文是一种概念上的边界,领域模型便工作于其中,每个限界上下文都有自己的通用语言。限界上下文使得你在领域模型周围加上了一个显式的、清晰的边界。当然,限界上下文不仅仅包含领域模型。当使用微服务架构时,每个限界上下文对应一个微服务。

2.领域模型

聚合是一个边界内领域对象的集群,可以将其视为一个单元,它由根实体和可能的一个或多个其他实体和值对象组成。聚合将领域模型分解为,每个聚合都可以作为一个单元进行处理。

聚合根是聚合中唯一可以由外部类引用的部分,客户端只能通过调用聚合根上的方法来更新聚合。

聚合代表了一致的边界,对于一个设计良好的聚合来说,无论由于何种业务需求而发生改变,在单个事务中,聚合中的所有不变条件都是一致的。聚合的一个很重要的经验设计原则是,一个事务中只修改一个聚合实例。更新聚合时需要更新整个聚合而不是聚合中的一部分,否则容易产生一致性问题。

比如A和B同时在网上购买东西,使用同一张订单,同时意识到自己购买的东西超过预算,此时A减少点心数量,B减少面包数量,两个消费者并发执行事务,那么订单总额可能会低于最低订单限额要求,但对于一个消费者来说是满足最低限额要求的。所以应该站在聚合根的角度执行更新操作,这会强制执行一致性业务规则。

另外,我们不应该设计过大的聚合,处理大聚合构成的"巨无霸"对象时,容易出现不同用例同时需要修改其中的某个部分,因为聚合设计时考虑的一致性约束是对整个聚合产生作用的,所以对聚合的修改会造成对聚合整体的变更,如果采用乐观并发,这样就容易产生某些用例会被拒绝的场景,而且还会影响系统的性能和可伸缩性。

使用大聚合时,往往为了完成一项基本操作,需要将成百上千个对象一同加载到内存中,造成资源的浪费。所以应尽量采用小聚合,一方面使用根实体来表示聚合,其中只包含最小数量的属性或值类型属性,这里的最小数量表示所需的最小属性集合,不多也不少。必须与其他属性保持一致的属性是所需的属性。

在聚合中,如果你认为有些被包含部分应该建模成一个实体,此时,思考下这个部分是否会随着时间而改变,或者该部分是否能被全部替换。如果可以全部替换,那么可以建模成值对象,而非实体。因为值对象本身是不可变的,只能进行全部替换,使用起来更安全,所以,一般情况下优先使用值对象。很多情况下,许多建模成实体的概念都可以重构成值对象。小聚合还有助于事务的成功执行,即它可以减少事务提交冲突,这样不仅可以提升系统的性能和可伸缩性,另外系统的可用性也得到了增强。

另外聚合直接的引用通过唯一标识实现,而不是通过对象引用,这样不仅减少聚合的使用空间,更重要的是可以实现聚合直接的松耦合。如果聚合是另一个服务的一部分,则不会出现跨服务的对象引用问题,当然在聚合内部对象之间是可以相互引用的。

上述关于聚合的主要使用原则总结起来可以归纳为以下几点:

  1. 只引用聚合根。
  2.  通过唯一标识引用其他聚合。
  3. 一个事务中只能创建或修改一个聚合。
  4. 聚合边界之外使用最终一致性。

当然在实际使用的过程中,比如某一个业务用例需要获取到聚合中的某个领域对象,但该领域对象的获取路径较繁琐,为了兼容该特殊场景,可以将聚合中的属性(实体或值对象)直接返回给应用层,使得应用层直接操作该领域对象。

我们经常会遇到在一个聚合上执行命令方法时,还需要在其他聚合上执行额外的业务规则,尽量使用最终一致性,因为最终一致性可以按聚合维度分步骤处理各个环节,从而提升系统的吞吐量。对于一个业务用例,如果应该由执行该用例的用户来保证数据的一致性,那么可以考虑使用事务一致性,当然此时依然需要遵循其他聚合原则。如果需要其他用户或者系统来保证数据一致性,那么使用最终一致性。实际上,最终一致性可以支持绝大部分的业务场景。

基于上面对电商的资源订购系统业务子域的划分,设计出资源聚合,订单聚合,支付聚合和用户聚合,资源聚合与订单聚合之间通过资源ID进行关联,订单聚合与支付聚合之间通过订单ID和用户ID进行关联,支付聚合和用户聚合之间通过用户ID进行关联。资源聚合根中包含多个资源包值对象,一个资源包值对象又包含多个预览图值对象。当然在实际开发的过程中,根据实际情况聚合根中也可以包含实体对象。每个聚合对应一个微服务,对于特别复杂的系统,一个子域可能包含多个聚合,也就包含多个微服务。

3.微服务系统架构设计

基于上面对电商的资源订购系统子域的分析,服务器后台使用用户服务,资源服务,订单服务和支付服务四个微服务实现。上图中的API Gateway也是一种服务,同时可以看成是DDD中的应用层,类似面向对象设计中的外观(Facade)模式。

作为整个后端架构的统一门面,封装了应用程序内部架构,负责业务用例的任务协调,每个用例对应了一个服务方法,调用多个微服务并将聚合结果返回给客户端。它还可能有其他职责,比如身份验证,访问授权,缓存,速率限制等。以查询已购资源为例,API Gateway需要查询订单服务获取当前用户已购的资源ID列表,然后根据资源ID列表查询资源服务获取已购资源的详细信息,最终将聚合结果返回给客户端。

当然在实际应用的过程中,我们也可以根据API请求的复杂度,从业务角度,将API Gateway划分为多个不同的服务,防止又回归到API Gateway的单体瓶颈。

另外,有时候从业务领域角度划分出来的某些子域比较小,从资源利用率的角度,单独放到一个微服务中有点单薄。这个时候我们可以打破一个限界上下文对应一个微服务的理念,将多个子域合并到同一个微服务中,由微服务自己的应用层实现多子域任务的协调。

所以,在我们的系统架构中可能会出现微服务级别的小应用层和API Gateway级别的大应用层使用场景,理论固然是理论,还是需要结合实际情况灵活应用。

三、领域驱动概念在单个微服务设计中的应用

1.架构选择分析

分层架构图(引用自互联网)

六边形架构图(引用自互联网)

整洁架构图(引用自互联网)

上面整洁架构图中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高。

整洁架构的依赖关系规则告诉我们,源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。换句话说,任何属于内层圆中的代码都不应该牵涉外层圆中的代码,尤其是内层圆中的代码不应该引用外层圆中代码所声明的名字,包括函数、类、变量以及一切其他有命名的软件实体。同样,外层圆使用的数据格式也不应该被内层圆中的代码所使用,尤其是当数据格式由外层圆的框架所生成时。

总之,不应该让外层圆中发生的任何变更影响到内层圆的代码。业务实体这一层封装的是整个业务领域中最通用、最高层的业务逻辑,它们应该属于系统中最不容易受外界影响而变动的部分,也就是说一般情况下我们的核心领域模型部分是比较稳定的,不应该因为外层的基础设施比如数据存储技术选型的变化,或者UI展示方式等的变化受影响,从而需要做相应的改动。

在以往的项目经验中,大多数同学习惯也比较熟悉分层架构,一般包括展示层、应用层,领域层和基础设施层。六边形架构的一个重要好处是它将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开来,业务逻辑不依赖于表示层逻辑或数据访问层逻辑,由于这种分离,单独测试业务逻辑要容易得多。

另一个好处是,可以通过多个适配器调用业务逻辑,每个适配器实现特定的API或用户界面。业务逻辑还可以调用多个适配器,每个适配器调用不同的外部系统。所以六边形架构是描述微服务架构中每个服务的架构的好方法。

根据我们具体的实践经验,比如在我们平时的项目中最常见的就是MySQL和Redis存储,而且也很少改变为其他存储结构。这里将分层架构和六边形架构进行思想融合,目的是一方面希望我们的微服务设计结构更优美,另一方面希望在已有编程习惯的基础上,更容易接受新的整洁架构思想。

我们项目中微服务的实现结合分层架构,六边形架构和整洁架构的思想,以实际使用场景为背景,采用的应用程序结构图如下。

从上图可以看到,我们一个应用总共包含应用层application,领域层domain和基础设施层infrastructure。领域服务的facade接口需要暴露给其他三方系统,所以单独封装为一个模块。因为我们一般习惯于分层架构模式构建系统,所以按照分层架构给各层命名。

站在六边形架构的角度,应用层application等同于入站适配器,基础设施层infrastructure等同于出站适配器,所以实际上应用层和基础设施层同属外层,可以认为在同一层。

facade模块其实是从领域层domain剥离出来的,站在整洁架构的角度,领域层就是内核业务实体,这里封装的是整个业务领域中最通用、最高层的业务逻辑,一般情况下核心领域模型部分是比较稳定的,不受外界影响而变动。facade是微服务暴露给外界的领域服务能力,一般情况下接口的设定应符合当前领域服务的边界界定,所以facade模块属于内核领域层。

facade接口的实现在应用层application的impl部分,符合整洁架构外层依赖内层的思想,对于impl输入端口和入站适配器,可以采用不同的协议和技术框架实现,比如dubbo或HSF等。下面对各个模块的构成进行逐一解释。

2. 领域层Domain

工厂Factory

对象的创建本身是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能会产生难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏装配对象的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合。

复杂对象的创建是领域层的职责,但这项任务并不属于那些用于表示模型的对象。所以一般使用一个单独的工厂类或者在领域服务中提供一个构造领域对象的接口来负责领域对象的创建。

这里,我们选择给领域服务增加一个领域对象创建接口来承担工厂的角色。

/**
 * description: 资源领域服务
 *
 * @author Gao Ju
 * @date 2020/7/27
 */
public class ResourceServiceImpl implements ResourceService {
 
    /**
     * 创建资源聚合模型
     *
     * @param resourceCreateCommand 创建资源命令
     * @return
     */
    @Override
    public ResourceModel createResourceModel(ResourceCreateCommand resourceCreateCommand) {
        ResourceModel resourceModel = new ResourceModel();
        Long resId = SequenceUtil.generateUuid();
        resourceModel.setResId(resId);
        resourceModel.setName(resourceCreateCommand .getName());
        resourceModel.setAuthor(resourceCreateCommand .getAuthor());
        List<PackageItem> packageItemList = new ArrayList<>();
        ...
        resourceModel.setPackageItemList(packageItemList);
        return resourceModel;
    }
}

资源库Repository

通常将聚合实例存放在资源库中,之后再通过该资源库来获取相同的实例。

如果修改了某个聚合,那么这种改变将被资源库持久化,如果从资源库中移除了某个实例,则将无法从资源库中重新获取该实例。

资源库是针对聚合维度创建的,聚合类型与资源库存在一对一的关系。

简单来说,资源库是对聚合的CRUD操作的封装。资源库内部采用哪种存储设施MySQL,MongoDB或者Redis等,对领域层来说其实是不感知的。

资源repository构成图

在我们的项目中采用MySQL作为资源repository的持久化存储,上图中每个DO对应一个数据库表,当然你也可以采用其他存储结构或设计为其他表结构,具体的处理流程均由repository进行封装,对领域服务来说只感知Resource聚合维度的CRUD操作,示例代码如下。

/**
 * description: 资源仓储
 *
 * @author Gao Ju
 * @date 2020/08/23
 */
@Repository("resourceRepository")
public class ResourceRepositoryImpl implements ResourceRepository {
 
    /**
     * 资源Mapper
     */
    @Resource
    private ResourceMapper resourceMapper;
 
    /**
     * 资源包Mapper
     */
    @Resource
    private PackageMapper packageMapper;
 
    /**
     * 资源包预览图Mapper
     */
    @Resource
    private PackagePreviewMapper packagePreviewMapper;
 
    /**
     * 创建订单信息
     *
     * @param resourceModel 资源聚合模型
     * @return
     */
    @Override
    public void add(ResourceModel resourceModel) {
        ResourceDO resourceDO = new ResourceDO();
        resourceDO.setName(resourceModel.getName());
        resourceDO.setAuthor(resourceModel.getAuthor());
        List<PackageDO> packageDOList = new ArrayList<>();
        List<PackagePreviewDO> packagePreviewDOList = new ArrayList<>();
        for (PackageItem packageItem : resourceModel.getPackageItemList()) {
            PackageDO packageDO = new PackageDO();
            packageDO.setResId(resourceModel.getResId());
            Long packageId = SequenceUtil.generateUuid();
            packageDO.setPackageId(packageId);
            for (PreviewItem previewItem: packageItem.getPreviewItemList()) {
                PackagePreviewDO packagePreviewDO = new PackagePreviewDO();
                ...
                packagePreviewDOList.add(packagePreviewDO);
            }
            packageDOList.add(packageDO);
        }
 
        resourceMapper.insert(resourceDO);
        packageMapper.insertBatch(packageDOList);
        packagePreviewMapper.insertBatch(packagePreviewDOList);
    }
}

你可能有疑问,按照整洁架构的思想,repository的接口定义在领域层,repository的实现应该定义在基础设施层,这样就符合外层依赖稳定度较高的内层了。

结合我们实际开发过程,一般存储结构选定或者表结构设定后,一般不太容易做很大的调整,所以就按照习惯的分层结构使用,领域层直接依赖基础设施层实现,降低编码时带来的额外习惯上的成本。

领域服务Service

领域驱动强调我们应该创建充血领域模型,将数据和行为封装在一起,将领域模型与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到各个领域对象中。

领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。当某个操作不适合放在领域对象上时,最好的方式是使用领域服务。

简单总结领域服务本身所承载的职责,就是通过串联领域对象、资源库,生成并发布领域事件,执行事务控制等一系列领域内的对象的行为,为上层应用层提供交互的接口。

/**
 * description: 订单领域服务
 *
 * @author Gao Ju
 * @date 2020/8/24
 */
public class UserOrderServiceImpl implements UserOrderService {
 
    /**
     * 订单仓储
     */
    @Autowired
    private OrderRepository orderRepository;
 
    /**
     * 消息发布器
     */
    @Autowired
    private MessagePublisher messagePublisher;
 
    /**
     * 订单逻辑处理
     *
     * @param userOrder 用户订单
     */
    @Override
    public void createOrder(UserOrder userOrder) {
        orderRepository.add(userOrder);
        OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent();
        orderCreatedEvent.setUserId(userOrder.getUserId());
        orderCreatedEvent.setOrderId(userOrder.getOrderId());
        orderCreatedEvent.setPayPrice(userOrder.getPayPrice());
        messagePublisher.send(orderCreatedEvent);
    }
}

在实践的过程中,为了简单方便,我们仍然采用贫血领域模型,将领域对象自身行为和不属于领域对象的行为都放在领域服务中实现。

大部分场景领域服务返回聚合根或者简单类型,某些特殊场景也可以将聚合根中包含的实体或值对象返回给调用方。领域服务也可以同时操作多个领域对象,多个聚合,将其转换为另外的输出。

介于我们实际的使用场景,领域比较简单,领域服务只操作一个领域的对象,只操作一个聚合,由应用服务来协调多个领域对象。

3. 领域事件DomainEvent

在领域驱动设计的上下文中,聚合在被创建时,或发生其他重大更改时发布领域事件,领域事件是聚合状态更改时所触发的。

领域事件命名时,一般选择动词的过去分词,因为状态改变时就代表当前事件已经发生,领域事件的每个属性都是原始类型值或值对象,比如事件ID和创建时间等,事件ID也可以用来做幂等用。

从概念上讲,领域事件由聚合负责发布,聚合知道其状态何时发生变化,从而知道要发布的事件。

由于聚合不能使用依赖注入,需要通过方法参数的形式将消息发布器传递给聚合,但这将基础设施和业务逻辑交织在一起,有悖于我们解耦设计的原则。

更好的方法是将事件发布放到领域服务中,因为服务可以使用依赖注入来获取对消息发布器的引用,从而轻松发布事件。只要状态发生变化,聚合就会生成事件,聚合方法的返回值中包括一个事件列表,并将它们返回给领域服务。

Saga是一种在微服务架构中维护数据一致性的机制,Sage由一连串的本地事务组成,每一个本地事务负责更新它所在服务的私有数据库,通过异步消息的方式来协调一系列本地事务,从而维护多个服务之间数据的最终一致性。Saga包括协同式和编排式,

我们采用协同式来实现分布式事务,发布的领域事件以命令式消息的方式发送给Saga参与方。如果领域事件是自我发布自我消费,不依赖消息中间件实现,则可以使用事件总线模式来进行管理。下面以购买资源的过程为例进行说明。

购买资源的过程

  • 提交创建订单请求,OrderService创建一个处于PAYING状态的UserOrder,并发布OrderCreated事件。
  • UserService消费OrderCreated事件,验证用户是否可以下单,并发布UserVerified事件。
  • PaymentService消费UserVerified事件,进行实际的支付操作,并发布PaySuccess事件。
  • OrderService接收PaySuccess事件,将UserOrder状态改为PAY_SUCCESS。

补偿过程

  • PaymentService消费UserVerified事件,进行实际的支付操作,若支付失败,并发布PayFailed事件。
  • OrderService接收PayFailed事件,将UserOrder状态改为PAY_FAILED。

在Saga的概念中,

第1步叫可补偿性事务,因为后面的步骤可能会失败。

第3步叫关键性事务,因为它后面跟着不可能失败的步骤。第4步叫可重复性事务,因为其总是会成功。

/**
 * description: 领域事件基类
 *
 * @author Gao Ju
 * @date 2020/7/27
 */
public class BaseEvent {
    /**
     * 消息唯一ID
     */
    private String messageId;
 
    /**
     * 事件类型
     */
    private Integer eventType;
 
    /**
     * 事件创建时间
     */
    private Date createTime;
 
    /**
     * 事件修改时间
     */
    private Date modifiedTime;
}
 
 
/**
 * description: 订单创建事件
 *
 * @author Gao Ju
 * @date 2020/8/24
 */
public class OrderCreatedEvent extends BaseEvent {
 
    /**
     * 用户ID
     */
    private String userId;
 
    /**
     * 订单ID
     */
    private String orderId;
 
    /**
     * 支付价格
     */
    private Integer payPrice;
}

4.Facade模块

facade和domain属于同一层,某些提供给三方使用的类定义在facade,比如资源类型枚举CategoryEnum限制三方资源使用范围,然后domain依赖facade中enum定义。

另外,根据迪米特法则和告诉而非询问原则,客户端应该尽量少地知道服务对象内部结构,通过调用服务对象的公共接口的方式来告诉服务对象所要执行的操作。

所以,我们不应该把领域模型泄露到微服务之外,对外提供facade服务时,根据领域对象包装出一个数据传输对象DTO(Data Transfer Object),来实现和外部三方系统的交互,比如上图中的ResourceDTO。

5.应用层Application

应用层是业务逻辑的入口,由入站适配器调用。facade的实现,定时任务的执行和消息监听处理器都属于入站适配器,所以他们都位于应用层。

正常情况下一个微服务对应一个聚合,实践过程中,某些场景下一个微服务可以包含多个聚合,应用层负责用例流的任务协调。领域服务依赖注入应用层,通过领域服务执行领域业务规则,应用层还会处理授权认证,缓存,DTO与领域对象之间的防腐层转换等非领域操作。

/**
 * description: 订单facade
 *
 * @author Gao Ju
 * @date 2020/8/24
 */
public class UserOrderFacadeImpl implements UserOrderFacade {
 
    /**
     * 订单服务
     */
    @Resource
    private UserOrderService userOrderService;
 
    /**
     * 创建订单信息
     *
     * @param orderPurchaseParam 订单交易参数
     * @return
     */
    @Override
    public FacadeResponse<UserOrderPurchase> createOrder(OrderPurchaseParam orderPurchaseParam ) {
        UserOrder userOrder = new UserOrder();
        userOrder.setUserId(request.getUserId());
        userOrder.setResId(request.getResId());
        userOrder.setPayPrice(request.getPayAmount());
        userOrder.setOrderStatus(OrderStatusEnum.Create.getCode());
        userOrderService.handleOrder(userOrder);
        userOrderPurchase.setOrderId(userOrderDO.getId());
        userOrderPurchase.setCreateTime(new Date());
        return FacadeResponseFactory.getSuccessInstance(userOrderPurchase);
    }
}

6.基础设施层 Infrastructure

基础设施的职责是为应用程序的其他部分提供技术支持。与数据库的交互dao模块,与Redis缓存,本地缓存交互的cache模块,与参数中心,三方rpc服务的交互,消息框架消息发布者都封装在基础设施层。

另外,程序中用到的工具类util模块和异常类exception也统一封装在基础设施层。

从分层架构的角度,领域层可以依赖基础设施层实现与其他外设的交互。另外,无论从分层架构的上层application层还是从六边形架构的角度的输入端口和适配器application,都可以依赖作为底层或处于同层的输出端口和适配器的infrastructure层,比如调用util或者exception模块。

四、结束语

其实,无论是面向服务架构SOA,微服务,领域驱动,还是中台,其目的都是在说,我们做架构设计的时候,应该从业务视角出发,对所涉及的业务领域,基于高内聚、低耦合的思想进行划分,最大限度且合理的实现业务重用。

这样不仅方便提供专业且稳定的业务服务,更有利于业务的沉淀和可持续发展。业务之下是基于技术的系统实现,技术造就业务,业务引领技术,两者相辅相成,共同为社会进步做出贡献。

五、参考文献

  • [1] 《领域驱动设计软件核心复杂性应对之道》Eric Evans著, 赵俐 盛海燕 刘霞等译,人民邮电出版社
  • [2] 《实现领域驱动设计》Vaughn Vernon著, 滕云译, 张逸审,电子工业出版社
  • [3] 《微服务架构设计模式》[美]克里斯.理查森(Chris Richardson) 著, 喻勇译,机械工业出版社
  • [4] 《架构整洁之道》[美]Robert C.Martin 著,孙宇聪 译,电子工业出版社
  • [5] 《企业IT架构转型之道阿里巴巴中台战略思想与架构实践》钟华编著,机械工业出版社
  • [6]领域驱动设计(DDD)实践之路(二):事件驱动与CQRS,vivo互联网技术
  • [7]领域驱动设计在互联网业务开发中的实践,美团技术团队
作者:Angel Gao
查看原文

赞 1 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-14

vivo 全球商城:从 0 到 1 代销业务的融合之路

代销是 vivo 商城已经落地的成熟业务,本文提供给各位读者 vivo 商城代销业务中两个异构系统业务融合的对接经验和架构思路。

一、业务背景

近两年,内销商城业务的发展十分迅速,vivo 商城系统的架构也完成了从单体往分布式的演进。我们在 vivo 商城服务化方向做了很多的努力,基础服务的能力逐渐沉淀下来。

2019年我们也开始在产品功能上玩起了多元化的营销业务。目前手机品类仍是我们销售的主力,但是非手机品类的sku单品数量还是很少,巧妇难为无米之炊。

为了解决非手机品类商品丰富度问题,运营考虑和电商平台进行合作,希望用代销的方式为商城扩充更多的非手机类商品品类。

先跟大家解释下“采销”和“代销”的区别,简单来讲就是一个货权归属的问题,采销是买货到我们仓库进行售卖,可自由定价;代销是货权归属于平台方,也就是货还放在对方的仓库,用“以销定采”的方式进行售卖,有利润空间,但是自主定价受限。

所以代销业务一开始就带着明确的目标出发了,我们希望通过接入其他的电商平台能够做到以下几点:

  • 改变商品品类匮乏的现状,品类变多能进一步丰富促销玩法;
  • 提升运营效率,解决代销类商品从选品到商城上架周期过长的痛点。

二、平台选择

一开始选择了几个平台方,为了提升选择效率,我们先对平台方定了几个重要的参考指标,目的是要能过滤存在对接风险的平台。

对接参考指标:

  • 【文档清晰】必须要有完整的API文档,文档逻辑要清晰,不能模棱两可;
  • 【接口完备】至少具备满足全流程的数据同步接口(能实现数据的全量和增量)、异步通知(商品变更、订单状态流转等异步变更)等;
  • 【商品模型】提供的商品模型要结构化。例:遇到sku信息采用内嵌html的方式,尝试用Demo预研后,效果很差;
  • 【成功案例】这个具有非常重要的参考作用。一般在对方的站点上都会宣传一些成功案例,可以去体验下,因为很大可能最终接入的效果会和他们一样;
  • 【沟通机制】健全的沟通机制也很重要(刚开始我们没有重点考虑,导致对接过程沟通还是不太通畅)。

但是参考指标只能作为一个筛选器排除不能对接的。剩下的平台方,需要我们从技术和产品的角度进行深度的调研分析,预研期间也主动和各个平台方电话沟通了几次,最终我们挑了一个各个方面都比较合适的候选人“网易严选”。

三、挑战

这次对接任务是通过对方提供的API接口,把他们的商品同步过来在我们商城进行售卖。

我们要求的是商城能正常地展示,用户能正常的下单并支付,同时支持逆向的用户取消订单,退款退货等等。

但是外部系统的对接存在很多的风险,尤其是非公司的第三方异构系统,而且对接的是包含商品、订单的全流程,我们要面对很多的挑战。

1. 未知挑战

  • 首先要考虑商品数据能不能完美地融合进来?
  • 其次订单的正逆向流程能不能打通?
  • 对方是否有环境能够测试验证?生产环境接入有没有过多的限制?安全性如何保证?
  • 对方能不能及时、准确地解决我们遇到的问题?

2. 前期预研

为了保证全流程完整接入的可控,我们先和对方沟通了测试环境配置,尝试调通他们的接口。

紧接着我们开发人员分成了两批,一批尝试通过手工组装字段把对方商品接入vivo商城,一批预研订单正逆向的全流程,这次我们尝试接入的目的很明确“打通全流程”。

3. 预研结果

最终通过使用 Demo 拉取对方报文,手工组装数据,快速试错,实验结果和前期预研的一样,通过实践证明全流程确实可行,链路能打通!实验结果让大家信心大增,也预示着我们可以整装待发,正式踏上代销系统的架构之路了。

四、代销业务的设计

1. 开篇

在正式设计之前,我们需要对新系统有点畅想,因为我们不仅仅只是完成这次对接的任务,我们更希望它拥有更抽象的业务功能,具备一定的扩展能力。

架构代销平台的意义:

  • 对接外部系统,提升我们自身的系统架构、设计的能力;
  • 填补代销的空白,未来我们希望这个平台能够达到快速对接的目的;
  • 积累一定的经验,未来定制我们自己的标准API,低门槛接入品质好的第三方。

2. 系统设计

我们把系统简单地归类,划分成了三部分,从左往右分别是平台方 → 代销平台 → 商城内部系统。

代销系统整体的设计的思路有点类似API网关,但在细节方面又有很多不同,代销系统的设计还是整体偏业务实现,也提供了额外的平台方信息的查询服务供内部系统调用。

2.1 平台方系统

对我们来说是个黑盒,他们的系统设计、数据交互我们是无法得知的,不过如果仔细研读文档的话,也能从他们提供的API中窥探一二,但是相比于对方的设计我们更需要把精力花在和他们的沟通和环境联调上。

2.2 代销平台

是这次设计的重点,它承接着外部和内部系统,是系统交互的重要纽带。目前摆在我们面前的还是一张白纸,我们需要先梳理下所有的基础功能,把它的框架先搭建出来。

为此,我们制定了简单的设计原则“无侵入,对内屏蔽掉底层适配的细节,关注服务本身,并具备一定的业务抽象和扩展能力”。然后在此设计原则基础上我们做如下比较精心的设计:

模块化设计

对接严选之后,再对接其它平台方的话。我们希望各个平台分成模块化进行对接。保证各接入方之间业务完全隔离,互不侵入。

能够做到可插拔,如遇到合作终止的情况,能以最小的成本关闭对接通道。

路由层

  • 商城内部系统发过来的报文/请求能识别并路由到对应的平台模块中处理。
  • 平台方回调的报文能够准确解析并路由到模块中去。

适配层

  • 来自于适配模式(Adapter Pattern),适配能力是整个系统横向的核心能力。
  • 模块化设计之后,各个对接的平台都会具备独立的适配层。
  • 商城 ↔ 平台方的商品和订单维度的字段映射。
  • 持久化双方的主键映射关系,如:<vivoSpuId, YxSpuId>,<vivoOrderId, YxOrderId>。

服务暴露

  • 提供映射关系查询,如:商品spuId、订单关联关系映射。
  • 提供对账信息查询。
  • 提供平台方冗余信息查询,如:平台方更丰富的商品信息。

统一回调

  • 开放外网访问接口,提供接口给平台方进行消息的异步回调。
  • 建立访问白名单,接收并将消息通过路由层分发到各平台模块进行处理。
  • 异步回调的消息包含:商品信息变更、库存预警&校准、订单状态流转等。

统一配置

  • 利用配置中心统一管理各平台方对接账号、秘钥等信息。
  • 系统监控指标阈值、告警、开关等。

横向能力

  • 接口调用异常监控、业务异常告警等。
  • HTTP底层通信服务。

2.3 内部系统

2.3.1 商品中心

【高可用】

之前对外提供的都是高QPS的只读Dubbo接口,这次要开Dubbo写接口提供给代销系统来创建商品。

了解接口设计的小伙伴都比较了解,写接口最主要的是保证事务的同时要能做到防刷防重。为此我们在写接口上加了统一验签(内部系统弱校验)和接口幂等性设计。

【接口设计】

大家平时在浏览电商网站的时候,商详页呈现出来的内容是十分丰富的,这也间接说明了商品的模型本身还是比较复杂的,所以在设计商品写接口的时候,开发内部产生了争论,讨论出了两个方案。

方案一:部分开发认为应该按现在的商品模型,把接口打散,代销平台对接多个写接口,好处是局部更新比较方便,也避免一次性提交一个大事务;

方案二:另一波同学认为就应该是一个接口,否则会出现接口散乱、乱序的情况。

最终我们还是采纳一次性提交的方案二,虽然会有大事务的风险,但是可以通过代码层面来缓解,而且架构设计本身就是一种权衡,设计接口的时候还是要多站在调用方的角度,对方肯定不会希望服务方的接口是散乱、无序的,这会增加调用和维护的成本。

2.3.2 订单中心

【正逆向的流程】

订单的难题最主要还是双方流程的融合,所以我们针对代销逻辑做了部分的流程改造。

这边只给大家举一个下单的例子,严选提供的创建订单API实际上包含的是下单+支付两个动作,为了适配他们的流程,我们做了点改造。

当用户在商城下完订单之后,代销系统仅对接严选做库存和配送区域的校验,确保订单能下成功。等用户在商城侧支付完成后,代销再次发起创建订单的请求给到严选。如果遇到创建异常(从数据上看很少几乎没有),我们也会有自动的取消流程。

3. 严选对接逻辑

上面的设计确定了各个模块的功能,划分了业务的边界,让我们看清了整体的骨架和脉络。但是我们还需要画一张切实可行的调用关系图,让整块逻辑立体丰满起来,最终让每个开发都知道这次网易严选该怎么去对接。

五、呈现效果

代销商品覆盖了官网商城列表页、活动频道页、选购页,展示效果如下:

 

六、展望未来

我们在前期也遇到有些品类比较好的平台方,但是由于技术原因无法和我们系统完成对接,也挺遗憾的。未来我们希望代销平台也能自己制定一套API标准,对方按照我们的标准,自己开发接口把数据传给我们就行了,这样就少了很多沟通和接入的成本。

作者:vivo官网商城开发团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-14

vivo 全球商城:架构演进之路

本文讲述 vivo 官方商城从单体应用到具备综合能力电商平台的演进,系统架构往服务化、中台化的变迁历程。

一、前言

vivo官方商城,是vivo官方的线上电商平台,主营vivo手机及专属配件。经过几年发展,已经完成了从单体应用到具备综合能力电商平台的演进,整体系统架构也逐步往服务化、中台化变迁。我们在这条系统架构升级的道路中,实践出了一些系统架构经验。

通过本篇文章,可以让对电商感兴趣的小伙伴们,更为全面地了解最基础的电商业务模式,了解电商体系具备的技术和架构,了解系统在不同时期的架构演进。

二、架构变迁史

“冰冻三尺,非一日之寒”。任何一个电商系统的架构升级,都不是一蹴而就的,都需要一个稳步发展的过程,不同阶段业务发展的形态和体量决定着系统架构。下面从一张图开始,给大家描述下商城近几年架构变迁的历史。

(图1.1 vivo官方商城架构变迁历程)

2015年之前,vivo官方商城是外包项目,采用了市面上比较成熟的ECStore(企业级开源网上电商系统)电商产品作为系统基础,主语言是PHP。

项目版本就是在此基础上进行二次开发迭代。

和大多数电商平台早期的发展一样,满足快速部署、快速上线。

同时弊端也很明显:

  • 性能很差,根本无法支撑稍大一点的运营活动。当有新品、大促活动,系统负载高,业务基本处于不可用状态,无法满足运营活动需求。
  • 需求沟通效率,研发效率低下,外包研发、产品异地办公,需求沟通困难。
  • 核心项目受制于人,vivo 官方商城必须掌握在自己手中。

为了解决这些问题,架构迫切需要升级、系统需要重构。

2.1 商城 v1.0 单体时期

2015年5月,vivo官方商城正式启动重构计划。vivo启用自己的研发团队,目标很明确,自研一套属于自己的vivo官方商城,为用户提供更好的购物体验。

在2016年1月,属于我们自己的vivo官方商城正式上线了。

商城v1.0以主流的 Java 作为开发语言,采用经典的 MVC 框架,开发出了一个囊括了各个业务模块的单体应用,整体业务模块如下图所示:

(图2.1 商城v1.0系统架构)

相比之前,这次重构最重要的指导思想就是“分层”。

业务上对各个模块进行逻辑分层。划分出了商品模块、订单模块、营销模块、结算模块等等,使得代码逻辑更为清晰。

架构上也进行分层解耦:

  • 【表现层】– 最贴近用户的一层,主要用来处理数据展示逻辑并渲染数据;
  • 【服务层】– 负责表现层与数据层之间的业务逻辑;
  • 【数据层】– 负责数据的落地存储,存储介质使用常用的Mysql和Redis;

单体应用的时期,vivo官方商城业务发展尚处于初期,业务复杂度不高。首页、商详页、结算页逻辑比较简单轻量。

v1.0的架构完全能够满足支撑日常的新品及活动运营,且版本迭代更为快速。相比于ECStore 性能提升了至少两个量级,所以商城v1.0的重构非常成功。

2.2 商城 v2.0  服务化

官方商城 v1.0 架构升级之后,平稳地度过了一段时间。近两年,vivo手机产品越来越多,线上业务开始迅猛发展。

随之而来的是用户量级的快速增长,商城v1.0的单体架构弊端也逐渐暴露:

  • 飞速增长的用户访问流量让性能再次出现瓶颈,单体的数据库和Redis难以抵挡。
  • v1.0 架构对业务模块进行了分层,分层仅限于代码模块级别的拆分,没有从物理上进行隔离,单体应用愈发臃肿。
  • 所有研发维护同一套代码,项目工程维护变得困难。快速迭代的版本让模块之间分层的界限变得模糊,代码腐化严重,开发效率变得低下。

基于以上问题,我们开始基于业务模块进行垂直的系统物理拆分。新的系统架构采用主流的SOA架构(Service Oriented Architecture,即面向服务的架构)。

商城 v2.0 从2017年开始,以服务化为核心稳步进行拆分独立。我们得保证既有的业务不受丝毫影响的情况下独立模块,有人形容这个过程为“高速换轮胎”,动作稍有不慎,对系统来说都是致命的。

最终在花了近一年半的时间,我们实现了活动、商品、订单、优惠券四大核心系统的拆分。拆分出来业务线开始各司其职,提供服务化的能力,共同支撑主站业务。

(图2.2 商城 v2.0系统架构)

下面将介绍各个系统拆分的整个过程。

2.2.1 活动系统独立

官方商城作为vivo的唯一线上官方渠道,承载着所有新品的线上活动需求。每次的新品发布会,都是由商城系统负责完成。大量频繁的活动需求,引起频繁的商城版本变更、上线,引发我们的思考。

相比电商的核心交易链路,活动系统本身比较独立,不应与主线交易耦合在一起。因此在2017年年中,将商城中的专题页配置,新品发布会,抽奖,预约功能剥离出来,独立出了商城活动系统。

2017年8月,活动系统独立上线。新的活动系统开始承接新品、大促等各种促销活动需求。随着活动系统不断迭代发展,目前已经成为电商平台一个重要组成部分。

2.2.2 商品系统独立

商品系统是支撑整个电商平台的核心,是电商系统中最重要的组成部分。商品连接着用户和平台,通过商品的详情页可以完美地向用户展示产品内容,诠释产品内涵。

商城 v2.0 服务化,商品是这次整改的重点。

我们在思考v1.0架构带来系统性问题的时候,也开始思考如何通过这次拆分来对应未来的业务增长。商城v1.0商品模块亟待解决的问题:

  • 商品的品类创建受限,只有垂直类的手机和配件,无法支持全品类。
  • 商品不支持店铺、品牌维度,比较单一。
  • v1.0商品模块的查询性能低下,单实例Redis无法满足高性能、高可用。
  • 历史 v1.0商品接口和模型已经渗透到各个模块,完整地剥离出来比较困难。

商品系统的独立是带着以上的问题和思考进行的,大的目标是划清业务边界,彻底和商城解耦。我们希望分离后的商品系统能够更好、更快速地承接未来全品类的扩展,全面服务化。为进一步服务好商城主体业务夯实基础。

2.2.3 优惠券系统独立

优惠券是业界内常用的营销手段之一,每到大促、节假日、新品,都会发放大量的优惠券。与外部广告商合作、内购福利、保值换新等也以优惠券的形式承载。

随着营销活动力度加大,优惠券使用场景增多,优惠券系统问题也逐渐暴露:

  • 海量优惠券的发放,达到优惠券单库、单表存储瓶颈。
  • 与商城系统的高耦合,也直接影响了商城整站接口性能。
  • 针对多品类优惠券,技术层面没有沉淀通用优惠券能力。

优惠券系统独立需要解决的就是以上问题,独立后优惠券存储能力提升,支撑未来5年内的优惠券发放量级。整体发券接口性能也得到提升,发券由原来的异步发券、异步到账,优化到同步发券、实时到账。同时提供平台级优惠券能力,面向全公司业务,提供通用的优惠券营销能力。

2.2.4 订单系统独立

订单系统也有与优惠券同样的问题,随着用户量级的爆发式增长,性能问题逐渐暴露:

  • 数据不断累积,快要达到单表存储瓶颈,导致订单的查询和修改速度很慢。
  • 单机MySQL处理能力有限,并发量上来时直接拖垮整个商城的所有业务。

订单系统的独立,首次引入了 ES,Sharding-JDBC 等技术组件,解决数据量和高并发的痛点。订单系统上线后,无论是订单的存储量级还是下单的并发量级,都提高了不止十倍,至少满足未来 5 年的业务高速发展。

至此,商城核心系统拆分完成,各系统提供统一标准化服务,具备更纯粹的业务基础能力,与商城主站解耦,迭代效率大幅提升。

2.3 商城 v3.0  业务系统拓展

商城 v3.0 是针对商城业务快速发展,进行的业务系统完善。

这一阶段由于商城业务渠道不断扩展,促销玩法不断增多,商城衍生出很多独立的业务子系统。其中包含代销系统、CPS系统、促销系统3 大业务系统。

(图2.3 商城 v3.0系统架构)

2.3.1 代销系统:商城与代销商品纽带

为了丰富自身的商品品类,支撑起更多的运营玩法,我们开始探索代销的业务,尝试对接品类优质的平台方。很多平台方也都支持系统对接,采用以销定采的销售模式。

代销系统就在此背景下诞生了。我们希望代销系统能够成为外部平台方和vivo商城之间的“粘合剂”,并能够提供以下的主要功能:

  • 支持两个平台商品数据模型的转换,支持部分信息二次编辑,更加本地化。
  • 实时同步平台方商品库存、价格、订单正逆向信息的同步。
  • 支持vivo商城用户的商品浏览、以及下订单服务,满足用户购物的完美体验。

代销系统是我们对接外部系统,引入外部商品售卖的一次尝试。代销的通用能力被我们完全沉淀了下来,能够持续支撑后续其他平台商品接入。

2.3.2 CPS系统:商城返利平台

CPS 系统的定位是 vivo 官方商城体系下的推广返利平台系统。商城的业务不断扩展,商城的业务群体也开始向外拓展。主要针对一些带货能力强的大V以及一些外部推广平台,以返佣的形式,最大限度发挥其带货能力。

随着用户群体以及推广平台接入,CPS 系统逐渐沉淀一些基础能力,目前支持 toB、 toC 通用接入能力。

2.3.3 促销系统:商城营销百花齐放

促销系统是商城的促销中心,承载着商城所有的营销玩法。

促销系统的独立,源于商城v2.0 架构无法满足不断增加的活动玩法,它解决了商城原有促销的以下痛点:

  • 繁杂的活动堆砌,没有严格活动优先级关系。
  • 新的活动需求的加入,改动量和影响点范围广,无法准确评估。
  • 促销性能无法满足活动量级,往往会影响商城主站的性能。

促销系统独立,与商城解耦,提供纯粹的商城营销活动玩法。促销系统还包括:商品计价与商品价格监控基础能力。

三、国际化

随着经济全球化日益加深,国产品牌纷纷布局海外,印度作为海外最大单一市场,拥有非常广阔的市场前景,顺应当地市场的需求,上线印度版官方商城提上日程。

2017年12月,印度vivo官方商城正式上线运营。

印度官方语言共有22种,目前已登记的语言超过1600种,支持多语言是国际化进程中首要课题。传统的 i18n 方案,能够解决基本的文案配置问题,但是项目需要走发布流程,维护成本非常高。

多语言文案系统标准化了文案需求的提出、翻译、测试、发布等流程,极大地提升了发布效率和文案质量。

(图3.1  多语言文案中心)

2020年11月,泰国 vivo 官方商城也正式上线运营。

与国内电商相比,海外电商业务需要覆盖多个国家/地区,每个地区都有自己的语言、时区、货币等等,如何使用一套代码同时支持多个地区,是我们必须要面对并且解决的问题。

经过3年时间的摸索和打磨,我们打造出了一套通用的全球化解决方案,包括多语言文案系统、多时区通用组件、多国家隔离框架、多机房域名部署方案等等,已经能够较好的支撑当前业务的发展需要。

(图3.2  多时区通用组件)

(图3.3  多国家隔离框架)

上述方案,抽象公共配置的思想以及相应的隔离技术点,即使是在非国际化场景中,也具有较大的参考价值。

海外市场复杂多变,语言文字、文化差异、地区标准、法律法规等不尽相同,地区发展阶段和基础设施成熟度也有较大差异。

挑战与机会并存,我们既要全力支撑业务发展,也要优先完成合规整改要求;我们既要提炼一套通用的国际化架构,也要满足本地化定制需求;我们既要合理应用发达地区高网速,也要兼顾欠发达地区页面加载性能优化。

“more local more global”,随着全球化进程的加深,我们会继续锤炼全球化架构,锻造出更加健壮的国际化/本地化产品。

写在最后,本篇主要是简要的介绍vivo官方商城这5年来的一些大的架构历史变迁,不做过多的技术解读。

这里的介绍只是商城技术背后的冰山一角,后续我们会出更多相关系列文章,去详细介绍每个系统的架构与核心技术。

作者:vivo 官网商城开发团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-09

当我们谈前端性能的时候,我们谈的是什么

本文结合谷歌官方工具 Lighthouse,分析了最新的前端页面性能评分标准,帮助大家更好地理解各项性能指标,以提升并优化相关的前端项目。

一、前端页面性能及其分析工具

前端页面的性能,一直都是大家持续关注的一个领域,因为用户的留存率和页面加载性能息息相关。根据google做出的数据统计,页面访问时长从1s增加到3s,用户跳出率增加32%。

对于前端页面性能的评估,一般是两种形式:一种是使用性能分析工具,在线对网页各项指标进行打分评估;一种是使用性能监控,通过 performance api 或者自定义的埋点上报用户网络真实的访问情况,然后进行统计分析。

虽然通过统计用户的数据更加真实,但是为了对页面性能能够有一个统一的量化标准,我们往往选择使用标准的打分工具对页面的性能进行评估。

性能分析初期,我们会从 chrome 的开发者工具对网页进行分析,包括查看 load 和 DOMContentLoaded 等事件触发的时间。后来又有了一系列的性能分析工具,例如 webpage analyzer、WebPageTest、Yslow等。

现在由于google官方已经将他们自己开发的 Lighthouse 嵌入到开发者工具的选项卡中,因此我们就把 Lighthouse 当做是标准评估工具。

Lighthouse 是一款开源的web页面性能分析工具,并且会给出页面最佳实践的一些相关建议。除了可以直接在 chrome DevTools 中使用外,也支持使用浏览器插件(chrome和Firefox)或者npm包(node api或者CLI)。

像google的网页measure以及PageSpeed Insight工具都是调用的 Lighthouse 对页面进行分析。

二、如何为页面的性能打分

1. Lighthouse 的迭代与性能指标变化

Lighthouse 第一个开源的版本可以追溯到2016年,目前(2020年10月)最新的版本是6.4.1,已经迭代89个版本。而在这几年中 Lighthouse 对于性能指标的选取也一直在更新。

google在最新的6.X版本中,相比于5.X版本,更新了三个新的性能指标:去掉了 FMP(First Meaningful Paint)、FCI(First CPU Idle) 和 mpFID(Max Potential First Input Delay);

加入了 TBT(Total Blocking Time)、LCP(Largest Contentful Paint) 和 CLS(Cumulative Layout Shift)。后文会针对这些指标进行详细的讲解。

之前5.X版本的Lighthouse

现在的Lighthouse(6.X版本)

2.  如何计算页面性能分数

如下图所示,在页面性能部分,Lighthouse 会综合目前的6个关键指标的表现情况,计算出页面的性能分数。

以最新的6.X计算方法来看,每个性能指标的数值会对应一个该指标的分数。例如上图中的FCP、SI、LCP、TTI、TBT、CLS等数值,对应的单项分数就依次为78、62、37、5、99、92分。一般来说数值越小该指标分数越高。

而这6个指标对应的权重分别为15%、15%、25%、15%、25%、5%,通过加权平均计算出性能总分为图中的60分。

如果想知道每个指标数值与其对应分数的具体计算方法,可以参考文章末尾的资料5和6。

三、关键的性能指标

在6.0版本的 Lighthouse 中,被去掉的关键性能指标分别是FMP(首次有意义的渲染帧)、FCI(首次CPU空闲)以及用户mpFID(潜在最大首次输入延迟)。

下面我们从这三个被废弃指标的定义开始切入,更好的理解现在版本的指标选取依据。

1.  什么是 FMP,和 FCP 有什么区别

说起FMP之前,我们必须要先介绍一下 First Contentful Paint(FCP):首次内容渲染时间。

如其名所示,只要首次触发了浏览器的 The First Page Paint 事件,此刻的时间点就是FCP。但此时渲染的不一定是重要的页面信息,比如仅仅是绘制了一个头部的 action bar 等,甚至不一定会渲染出可见的元素。虽然在Lighthouse6.0中得到了保留,但对性能得分的权重从23%降低到15%。

因此,FCP 不能作为一个从用户视角准确衡量页面性能好坏的指标。

在这个背景下,FMP(First Meaningful Paint:首次有意义的渲染帧)应运而生。根据官方的定义,FMP 是指从页面加载开始,到大部分或者主要内容已经在首屏上渲染的时间点。

那么 FMP 时间点是怎么确认的呢,我们先看下最基本的计算方法:

我们首先计算布局对象(layout objects)数量(使用 LayoutAnalyzer 测试计算,详见资料17)。

根据下图可以看出,页面加载的过程其实就是布局对象逐步进入 layout tree 并进行渲染的过程。

layoutAnalyzer 会收集 layout objects 数量,有一个计数器叫做 

LayoutObjectsThatHadNeverHadLayout,即首次新增的 layout objects 的个数。

通过测试发现相比于其他计数器,它变化最大的时刻,往往就是页面最重要的元素渲染了的时刻。

因此 FMP 指标的计算方法为:LayoutObjectsThatHadNeverHadLayout(新增的布局对象)发生变化最大的下一个时刻(paint that follows biggest layout change)。

当然,也存在一些场景不适用上述的情况:

a)、如果页面为长页面,那么会存在不可见布局对象增加的个数比首屏内可见对象增加个数更多的情况,此时 FMP 就是不准确的

b)、有加载web字体的情况,文字会使用降级字体进行布局,但是默认在 loadstart 开始的3s内,不进行绘制,这样也会影响FMP的计算

针对场景1,FMP 通过引入了 layout signifcance(布局重要度)的概念来解决该问题;针对场景2,FMP 通过推迟统计的时间,来让指标更加合理反映页面的情况。更加详细的解决方案可以参考资料18。

google也针对上述 FMP 的不同场景对近200个页面做了试验,通过人工看页面截图,与用户感受到的 FMP 准确度做对比,结果如下:

不过最终 FMP 在6.0的时候被废弃,主要是因为以下两点:

  • 在生产环境中,FMP 对页面细微的变化太过敏感,容易导致结果不一致。
  • 该指标的定义比较依赖于浏览器具体的实现细节,不具有可参考的标准性。

2. 代替 FMP 的 LCP 来了

上一个小章节中提到的了 FCP、FMP 的不足,因此W3C的性能小组也一直在想找一个合适的指标,更加真实反映用户看到页面主要内容的时间。

有时候简单点也许会更好(Sometimes simpler is better),根据多方关于页面性能的讨论,终于找到了一个更加准确衡量页面主要内容是否加载的方法,那就是 LCP(Largest Contentful Paint)。

LCP 指的是在视窗内,最大的内容元素被渲染的时间。这个指标在 Lighthouse 6.0中也正式加入,并且在最终性能评分中,有高达25%的权重。

LCP 应该是除了FCP以外最容易定义的指标,从定义可以看出,关键点就2个,选取哪些元素进行比较和如何确定元素的大小。

根据官方文档,下列元素会被纳入Largest Contentful 元素的考虑范围:

  • <img>
  • <svg> 里面的 <image>
  • <video>
  • 通过 url() 函数加载背景图片的元素
  • 包含 text node 的块级元素或者 inline text 的子元素

那我们如何确定元素的大小?主要是以下 4 个规则:

  • 在 viewport 内可见元素的大小,如果是超出可视区域或者被裁减、遮挡等,都不算入该元素大小
  • 对于图片元素来说,大小是取图片实际大小和原始大小的较小值,即Min(实际大小,原始大小)
  • 对于文字元素,只取能够覆盖文字的最小矩形面积
  • 对所有元素,margin、padding、border 等都不算

google对该指标的评价如下:LCP 是一个十分重要并且以用户感受为中心的指标;它反映了感知层面上页面的加载速度;它标记了页面主要内容中最大内容元素加载完成的时间点;LCP 较短的页面能够让用户更快感觉到页面是可用的。

3. 被废弃的 FCI 是什么,为什么和 TTI 联系这么紧密

FCI(First Cpu Idle:首次CPU空闲),这个指标用来衡量一个页面需要多久才能达到 minimally interactive(最低限度交互)的标准。

而最低可交互的确认需要同时满足以下两个条件:

a) 大部分在屏幕上的 UI元素都是可交互的

b) 页面对大部分用户的输入响应,平均时间在一个合理的范围内

TTI(Time To Interactive:页面可交互时间),指的是页面达到完全可交互状态所需要的时间。

完全可交互指的是同时满足下面三个条件:

a) FCP 之后,页面已经呈现了有用的内容

b) 对大部分的可见页面元素而言,已经注册了事件回调

c) 页面对用户交互的响应在50ms以内

2017年,First Interactive 指标被分成了 First Interactive 和 Consistently Interactive 两个指标;第二年7月,First Interactive指标改名为 FCI,同时 Consistently Interactive 改名为 TTI 。可见,FCI 和 TTI 这两个指标是一对反映用户交互响应的指标。

那么最低可交互和完全可交互是怎么计算的呢?在介绍具体的计算方法之前,我们需要知道这两个指标都是模糊的并且在不同的情况下可以持续被优化改进的。

  • FCI 的最低限度可交互时间

在主线程的时间线中,从 FMP 开始且某个任务结束后,寻找到长度为 f(t) 的时间窗口 W。如果 W 满足在其任意时间段内,没有长度大于250ms的连续任务集,且前后1s内都没有长任务(js执行时间超过50ms的任务为长任务),那么该任务结束的时刻就是我们定义的 FCI 。其中 f(t) = 4 e^(-0.045 t) + 1。

下图红框中所指的时间点即为 FCI

具体的推导过程可以参考资料11,可以更深刻的理解到为什么 FCI 是模糊的一个概念。

  • TTI 的完全可交互时间

从网络和主线程的时间线中,找到第一个5s的窗口期 W,在 W 时间段满足:任意时刻没有超过两个同时进行中的网络请求、没有超过50ms的长任务。则W前的最后一个长任务结束时刻,就是我们说的 TTI。

下图红框中所指的时间点即为 TTI:

尽管有些人指出,FCI 在某些时候比 TTI 更有意义,但是它们之间的差异还是不足以让 Lighthouse 保留两个相似的指标。

因此在 Lighthouse 6.0里,最终选择还是使用 TTI 来取代 FCI。

4. mpFID 与新增的 TBT 指标

mpFID(max potential First Input Delay)指的是从用户输入到页面真正开始处理事件回调的潜在(可能)最大延迟时间。

mpFID 具体的计算方法,是从以 FCP 为开始到以 TTI 为结束的这段时间里,选择其中js执行时间最长的任务,用它消耗的时间再减去50ms。

但是 mpFID 表示的只是一个最大延迟时间,与用户实际输入的延迟时间是有差距的,用户在不同时刻的输入得到的 FID 也会不同,因此 mpFID 并不能真实反映页面对用户输入的响应时间。

在5.X版本计算性能分数的时候,mpFID 权重为0,并没有参与评分。虽然这个指标不再显示在报告中,但其实在JSON数据中还有保留,而 mpFID 也是官方依然认可的一个关键用户体验指标。

那究竟什么是 TBT(Total Blocking Time),为什么要在性能报告中选择用它代替 FID 呢?

先看下定义:TBT 指的是页面响应用户输入时,已经被阻塞的时间总和。

具体的计算方法比较明确,统计从 FCP 到 TTI 之间的所有长任务,并将它们的阻塞部分时间进行求和,即为 TBT。其中阻塞部分时间指的是长任务执行时间超过50ms的部分,例如一个长任务执行了70ms,那么阻塞时间就是20ms。

可以看出,TBT 相比于 mpFID 是一个更加稳定的指标,相较而言能更加真实地反映页面对于用户输入的响应平均延时。

5.  新增的CLS

CLS (Cumulative Layout Shift:累积布局变化),它是用来衡量视觉界面稳定性的一个指标。

这个数据的获取是由 Layout Instability API(详见参考资料14)提供的,计算方法如下:

layout shift score = impact fraction * distance fraction

其中 impact fraction 指的是对整个视窗的多少造成了影响。例如下图中的文字占整个视窗的50%,并且下一帧比上一帧向下移动了25%,因此对整个页面的75%造成了影响,因此 impact fraction 为0.75。

distance fraction 比较好理解,就是发生变化距离占整个视窗的比例,比如上面的例子,移动25%即 distance fraction 为0.25。

综上,图示demo的 CLS 值即为0.75 * 0.25 = 0.1875。更详细的计算方法可以参考资料13、14。

举一个 CLS 实际影响用户体验的例子:如下图所示,用户想点取消按钮,结果页面突然发生了布局变化,确认按钮出现在了之前取消的位置…

可见,CLS 是更从用户体验出发,而新增的一个性能评判指标。

目前 CLS 作为新晋指标所占权重还不大,仅为5%,但是 Lighthouse 已经在考虑下一个大版本中增加其权重了。

6. 一直都在的 Speed Index

速度指数 Speed Index(SI)是用来衡量页面可见内容填充快慢的指标,计算过程使用是开源工具 speedline(资料16)。

speedline 通过对页面进行视频录制,并统计首帧与最后一帧变化的时间差来计算 speed Index 的值。

值得一提的是 SI 的最终分数,会通过和数据库中真实网站的 SI 进行比较算出。目前 SI 分数与得分标准如下表所示:

四、总结与展望

回顾上述指标的更替过程我们可以发现,不论是从 FMP 到 LCP、FCI 到 TTI 还是 FID 到 TBT,目前在性能指标的选取上,都已经在朝着更加稳定的方向前进:指标的定义越来越简明清晰,并且计算的方式也趋于标准化。

但是我们也应该知道这里没有银弹,每个指标都会有其局限性。在很多场景下,并不能认为得分低,就一定代表着页面的体验差。而我们也只有了解了这些指标的背后原理,才能更加科学地结合性能得分对页面做出评价。

由于性能相关的技术更新速度迭代较快,如果文中有疏漏的地方,欢迎交流指正。

五、参考资料

  1. Find out how you stack up to new industry benchmarks for mobile page speed 
  2. Performance.timing Api 
  3. What's New in Lighthouse 6.0
  4. Web Vitals
  5. Lighthouse Scoring Calculator
  6. Lighthouse performance scoring
  7. WebPageTest Demo
  8. Time to First Meaningful Paint
  9. First Interactive and Consistently Interactive
  10. Largest Contentful Paint (LCP)
  11. First Interactive and Consistently Interactive
  12. How Mercado Libre optimized for Web Vitals (TBT/FID)
  13. Cumulative Layout Shift (CLS)
  14. layout-instability
  15. Speed Index
  16. speedline
  17. Layout Analyzer
  18. Time to First Meaningful Paint
作者:Huang Wenjia
查看原文

赞 2 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-08

异步编程之事件循环机制

JavaScript 是一门单线程语言,我们可以通过异步编程的方式来实现实现类似于多线程语言的并发操作。

本文着重讲解通过事件循环机制来实现多个异步操作的有序执行、并发执行;通过事件队列实现同级多个并发操作的先后执行顺序,通过微任务和宏任务的概念来讲解不同阶段任务执行的先后顺序,最后通过将浏览器和 Node 下的事件循环机制进行对比,对比其事件循环机制的不同之处,以及在 Node 端通过libuv引擎来实现多个异步任务的并发执行。

一、前言

我们知道JavaScript 是一门单线程语言,对于大多数人而言,单线程最大的好处是不用像多线程那样处处在意状态的同步问题,这里没有死锁的存在,也没有像多线程之间来回切换带来性能上的开销。同样,单线程也存在自身的弱点,主要表现在以下几个方面:

  1. 无法利用多核cpu,一个简单的例子,在一个位置从同一台服务器拉取不同的资源,如果采用单线程同步的方式去拉取,代码大致如下:

    getData(‘from_db’),//耗时为M,
    getData(‘from_db_api’),//耗时为N,
    如果采用同步单线程的方式总共耗时为:M+N
  2. js代码错误或者耗时过长会阻塞后面代码的执行,例如页面在进行dom渲染时,如果页面的js代码报错会引起整个页面白屏的现象。
  3. 大量计算占用CPU导致无法继续调用异步I/O。
    后来HTML5定制了Web Workers能够创建多线程来进行计算,但是使用Web Workers技术开的多线程有着诸多的限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些简单的计算任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了 JavaScript 语言的单线程本质。

    所以我们可以预见,未来的 JavaScript 依然会是一门单线程语言,因此JavaScript采用异步编程方式实现程序“非阻塞”的特点,那么我们如何实现这一特征了,答案就是我们今天要讲的——event loop(事件循环)。

二、浏览器下的事件循环机制

1、执行栈

JavaScript变量主要存储在堆和栈两个位置,其中,堆里主要存储对象,栈主要存储基本类型的变量以及指针变量。当我们调用一个方法时,JS 会生成一个与这个方法对应的执行环境,又叫执行上下文,当一系列方法被调用时,由于我们的js是单线程的,所以这些方法会被单独排在一个地方,这个地方叫做执行栈。
当一个脚本第一次执行的时候,JS  引擎会解析这段代码,并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么 JS 会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码 执行完毕并返回结果后,JS 会退出这个执行环境并把这个执行环境销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。

2、事件队列

以上说的都是 JS 同步代码的执行,那么当程序执行异步代码后会如何进行呢?我们前面提到过 JS 最大的特点是非阻塞,下面我们说一下实现这一点的关键在于这项机制——事件队列。

当js引擎遇到一个异步事件后不会一直等待返回结果,这个事件会先挂起,继续执行执行栈中的其他任务,直到这个异步事件的结果返回,JS 引擎会将这个事件放入与当前执行栈不同的一个队列中,我们称之为事件队列。

被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码...,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。

(图片来源:网络)

3、微任务和宏任务

关于微任务和宏任务我们可以用一张图来说明:

(图片来源:网络)

在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回调加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈...如此反复,进入循环。

宏任务主要包含:script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)

微任务主要包含:Promise、MutaionObserver、process.nextTick(Node.js 环境)

三、Node环境下的事件循环模型

与浏览器有何异同?

在 Node 中,事件循环表现出的状态与浏览器中大致相同。不同的是 Node  中有一套自己的模型。Node  中事件循环的实现是依靠的libuv引擎。我们知道 Node  选择Chrome V8引擎作为js解释器,V8引擎将js代码分析后去调用对应的Node   api,而这些api最后则由libuv引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。因此实际上 Node  中的事件循环存在于libuv引擎中。

(图片来源:网络)

从上面这个模型中,我们可以大致分析出 Node  中的事件循环的顺序:

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...

以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。这些阶段大致的功能如下:

timers: 这个阶段执行定时器队列中的回调如 setTimeout() 和 setInterval()。

  • I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括close事件,定时器和setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的I/O事件,node在一些特殊情况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如socket.on('close', ...)这种close事件的回调。

四、小结

JavaScript事件循环是非常重要的一个基础概念,我们可以通过这种机制实现异步编程,解决JavaScript同步单线程无法实现并发操作的问题,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行。

作者:Liu Gang
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-08

分布式搜索引擎 Elasticsearch 的架构分析

一、写在前面

 ES(Elasticsearch下文统一称为ES)越来越多的企业在业务场景是使用ES存储自己的非结构化数据,例如电商业务实现商品站内搜索,数据指标分析,日志分析等,ES作为传统关系型数据库的补充,提供了关系型数据库不具备的一些能力。

ES最先进入大众视野的是其能够实现全文搜索的能力,也是由于基于Lucene的实现,内部有一种倒排索引的数据结构。

本文作者将介绍ES的分布式架构,以及ES的存储索引机制,本文不会详细介绍ES的API,会从整体架构层面进行分析,后续作者会有其他文章对ES的使用进行介绍。

二、什么是倒排索引

要讲明白什么是倒排索引,首先我们先梳理下什么索引,比如一本书,书的目录页,有章节,章节名称,我们想看哪个章节,我们通过目录页,查到对应章节和页码,就能定位到具体的章节内容,通过目录页的章节名称查到章节的页码,进而看到章节内容,这个过程就是一个索引的过程,那么什么是倒排索引呢?

比如查询《java编程思想》这本书的文章,翻开书本可以看到目录页,记录这个章节名字和章节地址页码,通过查询章节名字“继承”可以定位到“继承”这篇章节的具体地址,查看到文章的内容,我们可以看到文章内容中包含很多“对象”这个词。

那么如果我们要在这本书中查询所有包含有“对象”这个词的文章,那该怎么办呢?

按照现在的索引方式无疑大海捞针,假设我们有一个“对象”--→文章的映射关系,不就可以了吗?类似这样的反向建立映射关系的就叫倒排索引。

如图1所示,将文章进行分词后得到关键词,在根据关键词建立倒排索引,关键词构建成一个词典,词典中存放着一个个词条(关键词),每个关键词都有一个列表与其对应,这个列表就是倒排表,存放的是章节文档编号和词频等信息,倒排列表中的每个元素就是一个倒排项,最后可以看到,整个倒排索引就像一本新华字典,所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件被称之为倒排文件。

(图1)

词典和倒排文件是Lucene的两种基本数据结构,但是存储方式不同,词典在内存中存储,倒排文件在磁盘上。本文不会去介绍分词,tf-idf,BM25,向量空间相似度等构建倒排索引和查询倒排索引所用到的技术,读者只需要对倒排索引有个基本的认识即可。

三、ES的集群架构

1. 集群节点

一个ES集群可以有多个节点构成,一个节点就是一个ES服务实例,通过配置集群名称cluster.name加入集群。那么节点是如何通过配置相同的集群名称加入集群的呢?要搞明白这个问题,我们必须先搞清楚ES集群中节点的角色。

ES中节点有角色的区分的,通过配置文件conf/elasticsearch.yml中配置以下配置进行角色的设定。

node.master: true/false
node.data: true/false

集群中单个节点既可以是候选主节点也可以是数据节点,通过上面的配置可以进行两两组合形成四大分类:

(1)仅为候选主节点
(2)既是候选主节点也是数据节点
(3)仅为数据节点
(4)既不是候选主节点也不是数据节点

候选主节点:只有是候选主节点才可以参与选举投票,也只有候选主节点可以被选举为主节点。

主节点:负责索引的添加、删除,跟踪哪些节点是群集的一部分,对分片进行分配、收集集群中各节点的状态等,稳定的主节点对集群的健康是非常重要。

数据节点:负责对数据的增、删、改、查、聚合等操作,数据的查询和存储都是由数据节点负责,对机器的CPU,IO以及内存的要求比较高,一般选择高配置的机器作为数据节点。

此外还有一种节点角色叫做协调节点,其本身不是通过设置来分配的,用户的请求可以随机发往任何一个节点,并由该节点负责分发请求、收集结果等操作,而不需要主节点转发。这种节点可称之为协调节点,集群中的任何节点都可以充当协调节点的角色。每个节点之间都会保持联系。

(图2)

2. 发现机制

前文说到通过设置一个集群名称,节点就可以加入集群,那么ES是如何做到这一点的呢?

这里就要讲一讲ES特殊的发现机制ZenDiscovery。

ZenDiscovery是ES的内置发现机制,提供单播和多播两种发现方式,主要职责是集群中节点的发现以及选举Master节点。

多播也叫组播,指一个节点可以向多台机器发送请求。生产环境中ES不建议使用这种方式,对于一个大规模的集群,组播会产生大量不必要的通信。

单播,当一个节点加入一个现有集群,或者组建一个新的集群时,请求发送到一台机器。当一个节点联系到单播列表中的成员时,它就会得到整个集群所有节点的状态,然后它会联系Master节点,并加入集群。

只有在同一台机器上运行的节点才会自动组成集群。ES 默认被配置为使用单播发现,单播列表不需要包含集群中的所有节点,它只是需要足够的节点,当一个新节点联系上其中一个并且通信就可以了。如果你使用 Master 候选节点作为单播列表,你只要列出三个就可以了。

这个配置在 elasticsearch.yml 文件中:

discovery.zen.ping.unicast.hosts: ["host1", "host2:port"]

集群信息收集阶段采用了 Gossip 协议,上面配置的就相当于一个seed nodes,Gossip协议这里就不多做赘述了。

ES官方建议unicast.hosts配置为所有的候选主节点,ZenDiscovery 会每隔ping\_interval(配置项)ping一次,每次超时时间是discovery.zen.ping\_timeout(配置项),3次(ping_retries配置项)ping失败则认为节点宕机,宕机的情况下会触发failover,会进行分片重分配、复制等操作。

如果宕机的节点不是Master,则Master会更新集群的元信息,Master节点将最新的集群元信息发布出去,给其他节点,其他节点回复Ack,Master节点收到discovery.zen.minimum\_master\_nodes的值-1个 候选主节点的回复,则发送Apply消息给其他节点,集群状态更新完毕。如果宕机的节点是Master,则其他的候选主节点开始Master节点的选举流程。

2.1 选主

Master的选主过程中要确保只有一个master,ES通过一个参数quorum的代表多数派阈值,保证选举出的master被至少quorum个的候选主节点认可,以此来保证只有一个master。

选主的发起由候选主节点发起,当前候选主节点发现自己不是master节点,并且通过ping其他节点发现无法联系到主节点,并且包括自己在内已经有超过minimum\_master\_nodes个节点无法联系到主节点,那么这个时候则发起选主。

选主流程图

(图3)

选主的时候按照集群节点的参数<stateVersion, id> 排序。stateVersion从大到小排序,以便选出集群元信息较新的节点作为Master,id从小到大排序,避免在stateVersion相同时发生分票无法选出 Master。

排序后第一个节点即为Master节点。当一个候选主节点发起一次选举时,它会按照上述排序策略选出一个它认为的Master。     

2.2 脑裂

提到分布式系统选主,不可避免的会提到脑裂这样一个现象,什么是脑裂呢?如果集群中选举出多个Master节点,使得数据更新时出现不一致,这种现象称之为脑裂。

简而言之集群中不同的节点对于 Master的选择出现了分歧,出现了多个Master竞争。

  一般而言脑裂问题可能有以下几个原因造成:

  • 网络问题:集群间的网络延迟导致一些节点访问不到Master,认为Master 挂掉了,而master其实并没有宕机,而选举出了新的Master,并对Master上的分片和副本标红,分配新的主分片。
  • 节点负载:主节点的角色既为Master又为Data,访问量较大时可能会导致 ES 停止响应(假死状态)造成大面积延迟,此时其他节点得不到主节点的响应认为主节点挂掉了,会重新选取主节点。
  • 内存回收:主节点的角色既为Master又为Data,当Data节点上的ES进程占用的内存较大,引发JVM的大规模内存回收,造成ES进程失去响应。

如何避免脑裂:我们可以基于上述原因,做出优化措施:

  • 适当调大响应超时时间,减少误判。通过参数 discovery.zen.ping_timeout 设置节点ping超时时间,默认为 3s,可以适当调大。
  • 选举触发,我们需要在候选节点的配置文件中设置参数 discovery.zen.munimum\_master\_nodes 的值。这个参数表示在选举主节点时需要参与选举的候选主节点的节点数,默认值是 1,官方建议取值(master\_eligibel\_nodes/2)+1,其中 master\_eligibel\_nodes 为候选主节点的个数。这样做既能防止脑裂现象的发生,也能最大限度地提升集群的高可用性,因为只要不少于 discovery.zen.munimum\_master\_nodes 个候选节点存活,选举工作就能正常进行。当小于这个值的时候,无法触发选举行为,集群无法使用,不会造成分片混乱的情况。
  • 角色分离,即是上面我们提到的候选主节点和数据节点进行角色分离,这样可以减轻主节点的负担,防止主节点的假死状态发生,减少对主节点宕机的误判。

四、索引如何写入的

1.  写索引原理

1.1 分片

ES支持PB级全文搜索,通常我们数据量很大的时候,查询性能都会越来越慢,我们能想到的一个方式的将数据分散到不同的地方存储,ES也是如此,ES通过水平拆分的方式将一个索引上的数据拆分出来分配到不同的数据块上,拆分出来的数据库块称之为一个分片Shard,很像MySQL的分库分表。

不同的主分片分布在不同的节点上,那么在多分片的索引中数据应该被写入哪里?肯定不能随机写,否则查询的时候就无法快速检索到对应的数据了,这需要有一个路由策略来确定具体写入哪一个分片中,怎么路由我们下文会介绍。在创建索引的时候需要指定分片的数量,并且分片的数量一旦确定就不能修改。

1.2 副本

副本就是对分片的复制,每个主分片都有一个或多个副本分片,当主分片异常时,副本可以提供数据的查询等操作。主分片和对应的副本分片是不会在同一个节点上的,避免数据的丢失,当一个节点宕机的时候,还可以通过副本查询到数据,副本分片数的最大值是 N-1(其中 N 为节点数)。

对doc的新建、索引和删除请求都是写操作,这些写操作是必须在主分片上完成,然后才能被复制到对应的副本上。ES为了提高写入的能力这个过程是并发写的,同时为了解决并发写的过程中数据冲突的问题,ES通过乐观锁的方式控制,每个文档都有一个 _version号,当文档被修改时版本号递增。

一旦所有的副本分片都报告写成功才会向协调节点报告成功,协调节点向客户端报告成功。

(图4)

1.3 Elasticsearch 的写索引流程

上面提到了写索引是只能写在主分片上,然后同步到副本分片,那么如图4所示,这里有四个主分片分别是S0、S1、S2、S3,一条数据是根据什么策略写到指定的分片上呢?这条索引数据为什么被写到S0上而不写到 S1 或 S2 上?这个过程是根据下面这个公式决定的。

shard = hash(routing) % number_of_primary_shards

以上公式的值是在0到number\_of\_primary\_shards-1之间的余数,也就是数据档所在分片的位置。routing通过Hash函数生成一个数字,然后这个数字再除以number\_of\_primary\_shards(主分片的数量)后得到余数。routing是一个可变值,默认是文档的_id ,也可以设置成一个自定义的值。

在一个写请求被发送到某个节点后,该节点按照前文所述,会充当协调节点,会根据路由公式计算出写哪个分片,当前节点有所有其他节点的分片信息,如果发现对应的分片是在其他节点上,再将请求转发到该分片的主分片节点上。

在ES集群中每个节点都通过上面的公式知道数据的在集群中的存放位置,所以每个节点都有接收读写请求的能力。

那么为什么在创建索引的时候就确定好主分片的数量,并且不可修改?因为如果数量变化了,那么所有之前路由计算的值都会无效,数据也就再也找不到了。

( 图5)

如上图5所示,当前一个数据通过路由计算公式得到的值是 shard=hash(routing)%4=0,则具体流程如下:

(1)数据写请求发送到 node1 节点,通过路由计算得到值为1,那么对应的数据会应该在主分片S1上。
(2)node1节点将请求转发到 S1 主分片所在的节点node2,node2 接受请求并写入到磁盘。
(3)并发将数据复制到三个副本分片R1上,其中通过乐观并发控制数据的冲突。一旦所有的副本分片都报告成功,则节点 node2将向node1节点报告成功,然后node1节点向客户端报告成功。

这种模式下,只要有副本在,写入延时最小也是两次单分片的写入耗时总和,效率会较低,但是这样的好处也很明显,避免写入后单个机器硬件故障导致数据丢失,在数据完整性和性能方面,一般都是优先选择数据,除非一些允许丢数据的特殊场景。

在ES里为了减少磁盘IO保证读写性能,一般是每隔一段时间(比如30分钟)才会把数据写入磁盘持久化,对于写入内存,但还未flush到磁盘的数据,如果发生机器宕机或者掉电,那么内存中的数据也会丢失,这时候如何保证?

对于这种问题,ES借鉴数据库中的处理方式,增加CommitLog模块,在ES中叫transLog,在下面的ES存储原理中会介绍。

2.  存储原理

上面介绍了在ES内部的写索引处理流程,数据在写入到分片和副本上后,目前数据在内存中,要确保数据在断电后不丢失,还需要持久化到磁盘上。

我们知道ES是基于Lucene实现的,内部是通过Lucene完成的索引的创建写入和搜索查询,Lucene 工作原理如下图所示,当新添加一片文档时,Lucene进行分词等预处理,然后将文档索引写入内存中,并将本次操作写入事务日志(transLog),transLog类似于mysql的binlog,用于宕机后内存数据的恢复,保存未持久化数据的操作日志。

默认情况下,Lucene每隔1s(refresh_interval配置项)将内存中的数据刷新到文件系统缓存中,称为一个segment(段)。一旦刷入文件系统缓存,segment才可以被用于检索,在这之前是无法被检索的。

因此refresh_interval决定了ES数据的实时性,因此说ES是一个准实时的系统。segment 在磁盘中是不可修改的,因此避免了磁盘的随机写,所有的随机写都在内存中进行。随着时间的推移,segment越来越多,默认情况下,Lucene每隔30min或segment 空间大于512M,将缓存中的segment持久化落盘,称为一个commit point,此时删掉对应的transLog。

当我们在进行写操作的测试的时候,可以通过手动刷新来保障数据能够被及时检索到,但是不要在生产环境下每次索引一个文档都去手动刷新,刷新操作会有一定的性能开销。一般业务场景中并不都需要每秒刷新。

可以通过在 Settings 中调大 refresh\_interval = "30s" 的值,来降低每个索引的刷新频率,设值时需要注意后面带上时间单位,否则默认是毫秒。当 refresh\_interval=-1 时表示关闭索引的自动刷新。

(图6)

索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?

  • 新增,新增很好处理,由于数据是新的,所以只需要对当前文档新增一个段就可以了。
  • 删除,由于不可修改,所以对于删除操作,不会把文档从旧的段中移除而是通过新增一个 .del 文件,文件中会列出这些被删除文档的段信息,这个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。
  • 更新,不能修改旧的段来进行文档的更新,其实更新相当于是删除和新增这两个动作组成。会将旧的文档在 .del 文件中标记删除,然后文档的新版本中被索引到一个新的段。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就会被移除。

segment被设定为不可修改具有一定的优势也有一定的缺点。

优点:

  • 不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
  • 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升.
  • 其它缓存(像 Filter 缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  • 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和需要被缓存到内存的索引的使用量。

缺点:

  • 当对旧数据进行删除时,旧数据不会马上被删除,而是在 .del 文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。
  •  若有一条数据频繁的更新,每次更新都是新增新的,标记旧的,则会有大量的空间浪费。
  • 每次新增数据时都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源例如文件句柄的消耗会非常大。
  • 在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,这增加了查询的负担。

2.1  段合并

由于每当刷新一次就会新建一个segment(段),这样会导致短时间内的段数量暴增,而segment数目太多会带来较大的麻烦。大量的segment会影响数据的读性能。每一个segment都会消耗文件句柄、内存和CPU 运行周期。

更重要的是,每个搜索请求都必须轮流检查每个segment然后合并查询结果,所以segment越多,搜索也就越慢。

因此Lucene会按照一定的策略将segment合并,合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档不会被拷贝到新的大segment中。

合并的过程中不会中断索引和搜索,倒排索引的数据结构使得文件的合并是比较容易的。

段合并在进行索引和搜索时会自动进行,合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中,这些段既可以是未提交的也可以是已提交的。

合并结束后老的段会被删除,新的段被刷新到磁盘,同时写入一个包含新段且排除旧的和较小的段的新提交点,新的段被打开,可以用来搜索。段合并的计算量庞大,而且还要吃掉大量磁盘 I/O,并且段合并会拖累写入速率,如果任其发展会影响搜索性能。

ES在默认情况下会对合并流程进行资源限制,所以搜索性能可以得到保证。

(图7)

五、写在最后

作者对ES的架构原理和索引存储和写机制进行介绍,ES的整体架构体系相对比较巧妙,我们在进行系统设计的时候可以借鉴其设计思路,本文只介绍ES整体架构部分,更多的内容,后续作者会在其他文章中继续分享。

作者:vivo官网商城开发团队
查看原文

赞 0 收藏 0 评论 0

vivo互联网技术 发布了文章 · 2020-12-07

Seata是什么?一文了解其实现原理

一、背景

随着业务发展,单体系统逐渐无法满足业务的需求,分布式架构逐渐成为大型互联网平台首选。伴随而来的问题是,本地事务方案已经无法满足,分布式事务相关规范和框架应运而生。

在这种情况下,大型厂商根据分布式事务实现规范,实现了不同的分布式框架,以简化业务开发者处理分布式事务相关工作,让开发者专注于核心业务开发。

Seata就是这么一个分布式事务处理框架,Seata是由阿里开源,前身为Fescar,经过品牌升级变身Seata。

二、分布式事务规范

1.分布式事务相关概念

事务:一个程序执行单元,是用户定义的一组操作序列,需要满足ACID属性。

本地事务:事务由本地资源管理器管理。

分布式事务:事务的操作位于不同的节点。

分支事务:在分布式事务中,由资源管理器管理的本地事务。

全局事务:一次性操作多个资源管理器完成的事务,由一组分支事务组成。

2. 分布式事务实现规范

对于本地事务,可以借助DBMS系统来实现事务的管理,但是对于分布式事务,它就无能为力了。对于分布式事务,目前主要有2种思路:XA协议的强一致规范以及柔性事务的最终一致性规范。

2.1 XA

XA是基于2阶段提交协议设计的接口标准,实现了XA规范的资源管理器就可以参与XA全局事务。应用承担事务管理器TM工作,数据库承担资源管理器RM工作,TM生成全局事务id,控制RM的提交和回滚。

2.2 柔性事务的最终一致性

该规范主要有3种实现方式,TCC、MQ事务消息、本地消息表。(还存在其他一些不常用实现方式如Saga)。

TCC:try/confirm/cancel,在try阶段锁定资源,confirm阶段进行提交,资源锁定失败执行cancel阶段释放资源。

MQ事务消息:前提消息系统需要支持事务如RocketMQ,在本地事务执行前,发送事务消息prepare,本地事务执行成功,发送事务消息commit,实现分布式事务最终一致性。如果事务消息commit失败,RocketMQ会回查消息发送者确保消息正常提交,如果步骤5执行失败,进行重试,达到最终一致性。

本地消息表:跟MQ事务消息类似,区别在于MQ不支持事务消息,需要借助本地数据库的事务管理能力。在步骤1中将需要发送的消息和本地事务一起提交到DB,借助DB的事务管理确保消息持久化。步骤2应用通过本地消息表扫描,重试发送,确保消息可以发送成功。

三、Seata 架构

1. 系统组成

Seata有三个核心组件:

  • Transaction Coordinator(TC,事务协调器)

    维护全局事务和分支事务的状态,驱动全局事务提交或回滚。

  • Transaction Manager(TM,事务管理器)

    定义全局事务的范围,开始事务、提交事务、回滚事务。

  • Resource Manager(RM,资源管理器):

    管理分支事务上的资源,向TC注册分支事务,汇报分支事务状态,驱动分支事务的提交或回滚。

三个组件相互协作,TC 以 Server 形式独立部署,TM和RM集成在应用中启动,其整体交互如下:

2.工作模式

Seata 支持四种工作模式:

2.1 AT(Auto Transaction)

AT模式是Seata默认的工作模式。需要基于支持本地 ACID 事务的关系型数据库,Java 应用,通过 JDBC 访问数据库。

2.1.1 整体机制

该模式是XA协议的演变,XA协议是基于资源管理器实现,而AT并不是如此。AT的2个阶段分别是:

  • 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
  • 二阶段:提交异步化,非常快速地完成;回滚通过一阶段的回滚日志进行反向补偿。

下图中,步骤1开启全局事务;步骤2注册分支事务,这里对应着一阶段;步骤3提交或者回滚分支事务,对应着二阶段。

2.1.2 特点

  • 优点:对代码无侵入;并发度高,本地锁在一阶段就会释放;不需要数据库对XA协议的支持。
  • 缺点:只能用在支持ACID的关系型数据库;SQL解析还不能支持全部语法。

2.2 TCC

该模式工作分为三个阶段:prepare/commit/cancel。

2.2.1 整体机制

  • TM向TC申请全局事务XID,传播给各个子调用。
  • 子调用的所在TM向TC注册分支事务,并执行本地prepare,并向TC报告执行结果。
  • TC根据各分支事务的执行结果确定二阶段是执行commit或rollback。

2.2.2 特点

  • 优点:不依赖本地事务。
  • 缺点:回滚逻辑依赖手动编码;业务侵入性较大。

2.3 Saga 模式

2.3.1 Saga 是什么?

1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas,讲述的是如何处理long lived transaction(长活事务)。Saga是一个长活事务可被分解成可以交错运行的子事务集合。论文见这里。  

简单来说,Saga将一个长事务(T)分解成一系列Sub事务(Ti),每个Sub事务都有对应的补偿动作(Ci),用于撤销Ti事务产生的影响。Sub事务是直接提交到库,在出现异常时,逆向进行补偿。

因此Saga事务的组成有2种:  

  • T1, T2, T3, ..., Tn
  • T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n

第一种就是正常提交的情况,第二种在提交Tj事务出现异常,开始逆向补偿的情况。

Saga模式是Seata提供的长事务解决方案。例如全局事务中涉及到外部系统,无法管理它的资源管理器,让它改造成TCC也不好实行,这时就可以采用此类方案。

2.3.2 整体机制

在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

上图中对于多个分支事务,省略了多次出现的 2.* 步骤。对于全局事务执行过程中业务应用宕机情况,业务应用集群中对等节点会通过从TC获取相关会话,从DB加载详细信息来恢复状态机。

2.3.3 特点

  • 优点:一阶段提交本地事务,无锁,高性能;事件驱动架构,参与者可异步执行,高吞吐;补偿服务易于实现。
  • 缺点:不保证隔离性。

2.4 XA模式

XA是基于二阶段提交设计的接口标准。对于支持XA的资源管理器,借助Seata框架的XA模式,会使XA方案更简单易用。使用前提:需要分支数据库支持XA 事务,应用为 Java应用,且使用JDBC访问数据库。

2.4.1 整体机制

在 Seata 定义的分布式事务框架内,利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种 事务模式。

  • 执行阶段:业务sql在XA分支中执行,由分支事务的RM管理器管理,然后执行XA prepare。  
  • 完成阶段:TM根据各个分支执行结果通过TC通知各个分支执行XA commit或者XA rollback。

2.4.2 特点

  • 优点:继承了XA协议的优势,事务具有强一致性。  
  • 缺点:同样继承了XA协议的劣势,由于分支事务长时间开启,并发度低。

2.5  Seata 各模式对比

分布式事务方案没有银弹,根据自己的业务特性选择合适的模式。例如追求强一致性,可以选择AT和XA,存在和外部系统对接,可以选择Saga模式,不能依赖本地事务,可以采用TCC等等。结合各模式的优缺点进行选择。

四、AT 模式核心实现

鉴于Seata支持的模式较多,而其默认的模式是AT,为节省篇幅,以下围绕AT模式分析其相关的核心模块实现。

1. 事务协调器的启动

TC(事务协调器)以独立的服务启动,作为Server,维护全局事务和分支事务的状态,驱动全局事务提交或回滚。下面是TC的启动流程:

 

2. 事务管理器的启动

TM(事务管理器)集成在应用中启动,负责定义全局事务的范围,开始事务、提交事务、回滚事务。
TM所在应用中需要配置GlobalTransactionScannerbean,在应用启动时会进行如下初始化流程:

3资源管理器的启动

RM(资源管理器)集成在应用中启动,负责管理分支事务上的资源,向TC注册分支事务,汇报分支事务状态,驱动分支事务的提交或回滚。
RM所在的应用中除了需要跟TM一样配置GlobalTransactionScanner以启动RMClient,还需要配置DataSourceProxy,以实现对数据源访问代理。该数据源代理实现了sql的解析 → 生成undo-log → 业务sql和undo-log一并本地提交等操作。

4. 全局事务的工作流程

下面以一个简单的例子来说明全局事务的工作原理:

  • BusinessService:发起购买服务
  • StorageService:库存管理服务

购买操作实现在businessService.purchase中,purchase方法实现上通过GlobalTransaction注解,通过Dubbo服务,调用了库存服务deduct方法方法,样例如下:

@GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx")
public void purchase(String userId, String commodityCode, int orderCount) {
    storageService.deduct(commodityCode, orderCount);
    // throw new RuntimeException("xxx");
}

4.1 成功的全局事务处理流程

4.2 成功的全局事务处理流程

这里设定BusinessService在成功调用StorageService后,本地出现异常。

5. 写隔离实现

全局事务未提交,分支事务本地已经提交的情况下(假设修改了资源A),如何避免其他事务在此时修改资源A?Seata采用全局锁来实现,其流程如下:

6. 读隔离实现

在数据库本地隔离级别为读已提交或以上的基础上,Seata提供了读未提交,这个很好理解,全局事务提交前分支事务本地已经提交。如果想要实现读已提交,则需要在select语句上加for update。

五、总结

Seata是Java领域很强大的分布式事务框架,其支持了多种模式。其中默认支持的AT模式,相比于传统的2PC协议(基于数据库的XA协议),很好地解决了2PC长期锁资源的问题,提高了并发度。Seata支持的各个模式中,AT模式对业务零入侵实现分布式事务,对于开发者更加友好。另外Seata的Server在选择合适的存储介质时可以进行集群模式,减少单点故障影响。

本文主要参考官网和部分博客,同时阅读了AT模式实现源码,如果有不对的地方,望指出,一起讨论交流。

六、参考

作者:vivo官网商城开发团队
查看原文

赞 0 收藏 0 评论 0