mirror of
https://github.com/zhwei820/learn.lianglianglee.com.git
synced 2025-09-18 01:06:41 +08:00
fix img
This commit is contained in:
parent
66970f3e38
commit
3d6528675a
@ -195,7 +195,7 @@ Hello, 世界
|
||||
<h3>Go 语言环境搭建</h3>
|
||||
<p>要想搭建 Go 语言开发环境,需要先下载 Go 语言开发包。你可以从官网 <a href="https://golang.org/dl/">https://golang.org/dl/</a> 和 <a href="https://golang.google.cn/dl/">https://golang.google.cn/dl/</a> 下载(第一个链接是国外的官网,第二个是国内的官网,如果第一个访问不了,可以从第二个下载)。</p>
|
||||
<p>下载时可以根据自己的操作系统选择相应的开发包,比如 Window、MacOS 或是 Linux 等,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-ikW2AdldmAABgiiXVyCo654.png" alt="go_sdk_download.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ikW2AdldmAABgiiXVyCo654.png" alt="png" /></p>
|
||||
<h4>Windows MSI 下安装</h4>
|
||||
<p>MSI 安装的方式比较简单,在 Windows 系统上推荐使用这种方式。现在的操作系统基本上都是 64 位的,所以选择 64 位的 go1.15.windows-amd64.msi 下载即可,如果操作系统是 32 位的,选择 go1.15.windows-386.msi 进行下载。</p>
|
||||
<p>下载后双击该 MSI 安装文件,按照提示一步步地安装即可。在默认情况下,Go 语言开发工具包会被安装到 c:\Go 目录,你也可以在安装过程中选择自己想要安装的目录。</p>
|
||||
|
@ -188,7 +188,7 @@ fmt.Println("the sum is",sum)
|
||||
<pre><code>array:=[5]string{"a","b","c","d","e"}
|
||||
</code></pre>
|
||||
<p>数组在内存中都是连续存放的,下面通过一幅图片形象地展示数组在内存中如何存放:</p>
|
||||
<p><img src="assets/Ciqc1F-pBzmAWUQ0AAAttSjgTjQ158.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-pBzmAWUQ0AAAttSjgTjQ158.png" alt="png" /></p>
|
||||
<p>可以看到,数组的每个元素都是连续存放的,每一个元素都有一个下标(Index)。下标从 0 开始,比如第一个元素 a 对应的下标是 0,第二个元素 b 对应的下标是 1。以此类推,通过 array+[下标] 的方式,我们可以快速地定位元素。</p>
|
||||
<p>如下面代码所示,运行它,可以看到输出打印的结果是 c,也就是数组 array 的第三个元素:</p>
|
||||
<p><em><strong>ch04/main.go</strong></em></p>
|
||||
|
@ -348,7 +348,7 @@ type address struct {
|
||||
</code></pre>
|
||||
<p>意思就是类型 person 没有实现 Stringer 接口。这就证明了<strong>以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。</strong></p>
|
||||
<p>我用如下表格为你总结这两种接收者类型的接口实现规则:</p>
|
||||
<p><img src="assets/Ciqc1F-yPMSAZ4k7AABU_GW4VxE080.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-yPMSAZ4k7AABU_GW4VxE080.png" alt="png" /></p>
|
||||
<p>可以这样解读:</p>
|
||||
<ul>
|
||||
<li>当值类型作为接收者时,person 类型和*person类型都实现了该接口。</li>
|
||||
|
@ -362,7 +362,7 @@ if errors.As(err,&cm){
|
||||
<p>小提示:interface{} 是空接口的意思,在 Go 语言中代表任意类型。</p>
|
||||
</blockquote>
|
||||
<p>panic 异常是一种非常严重的情况,会让程序中断运行,使程序崩溃,所以<strong>如果是不影响程序运行的错误,不要使用 panic,使用普通错误 error 即可。</strong></p>
|
||||
<p><img src="assets/CgqCHl-15ZSAAsw5AAUnpsfN34w061.png" alt="pDE7ppQNyfRSIn1Q__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-15ZSAAsw5AAUnpsfN34w061.png" alt="png" /></p>
|
||||
<h3>Recover 捕获 Panic 异常</h3>
|
||||
<p>通常情况下,我们不对 panic 异常做任何处理,因为既然它是影响程序运行的异常,就让它直接崩溃即可。但是也的确有一些特例,比如在程序崩溃前做一些资源释放的处理,这时候就需要从 panic 异常中恢复,才能完成处理。</p>
|
||||
<p>在 Go 语言中,可以通过内置的 recover 函数恢复 panic 异常。因为在程序 panic 异常崩溃的时候,只有被 defer 修饰的函数才能被执行,所以 recover 函数要结合 defer 关键字使用才能生效。</p>
|
||||
|
@ -195,7 +195,7 @@ First defer
|
||||
<p>讲并发就绕不开线程,不过在介绍线程之前,我先为你介绍什么是进程。</p>
|
||||
<h4>进程</h4>
|
||||
<p>在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄,还有下面要讲的线程等。下面的图片就是我的电脑上运行的进程:</p>
|
||||
<p><img src="assets/CgqCHl-7fwyAdSu_AADl16erQwg589.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-7fwyAdSu_AADl16erQwg589.png" alt="png" /></p>
|
||||
<p>(电脑运行的进程)</p>
|
||||
<p>那么线程是什么呢?</p>
|
||||
<h4>线程</h4>
|
||||
@ -267,7 +267,7 @@ First defer
|
||||
<pre><code>cacheCh:=make(chan int,5)
|
||||
</code></pre>
|
||||
<p>我创建了一个容量为 5 的 channel,内部的元素类型是 int,也就是说这个 channel 内部最多可以存放 5 个类型为 int 的元素,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-7fzmAVLu0AACSjW-neAE188.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-7fzmAVLu0AACSjW-neAE188.png" alt="png" /></p>
|
||||
<p>(有缓冲 channel)</p>
|
||||
<p>一个有缓冲 channel 具备以下特点:</p>
|
||||
<ol>
|
||||
|
@ -322,7 +322,7 @@ func watchDog(ctx context.Context,name string) {
|
||||
<li><strong>值 Context</strong>:用于存储一个 key-value 键值对。</li>
|
||||
</ol>
|
||||
<p>从下图 Context 的衍生树可以看到,最顶部的是空 Context,它作为整棵 Context 树的根节点,在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context。</p>
|
||||
<p><img src="assets/CgqCHl_EyHOARbBqAAKzKmhclWo807.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_EyHOARbBqAAKzKmhclWo807.png" alt="png" /></p>
|
||||
<p>(四种 Context 的衍生树)</p>
|
||||
<p>有了根节点 Context 后,这颗 Context 树要怎么生成呢?需要使用 Go 语言提供的四个函数。</p>
|
||||
<ol>
|
||||
@ -347,7 +347,7 @@ go func() {
|
||||
</code></pre>
|
||||
<p>示例中增加了两个监控狗,也就是增加了两个协程,这样一个 Context 就同时控制了三个协程,一旦 Context 发出取消信号,这三个协程都会取消退出。</p>
|
||||
<p>以上示例中的 Context 没有子 Context,如果一个 Context 有子 Context,在该 Context 取消时会发生什么呢?下面通过一幅图说明:</p>
|
||||
<p><img src="assets/Ciqc1F_EyIyAAO_TAADuPjzGt5U321.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_EyIyAAO_TAADuPjzGt5U321.png" alt="png" /></p>
|
||||
<p>(Context 取消)</p>
|
||||
<p>可以看到,当节点 Ctx2 取消时,它的子节点 Ctx4、Ctx5 都会被取消,如果还有子节点的子节点,也会被取消。也就是说根节点为 Ctx2 的所有节点都会被取消,其他节点如 Ctx1、Ctx3 和 Ctx6 则不会。</p>
|
||||
<h3>Context 传值</h3>
|
||||
|
@ -194,7 +194,7 @@ name变量的内存地址为: 0xc000010200
|
||||
</blockquote>
|
||||
<p>以上示例中 nameP 指针的类型是 *string,用于指向 string 类型的数据。在 Go 语言中使用类型名称前加 * 的方式,即可表示一个对应的指针类型。比如 int 类型的指针类型是 *int,float64 类型的指针类型是 *float64,自定义结构体 A 的指针类型是 *A。总之,指针类型就是在对应的类型前加 * 号。</p>
|
||||
<p>下面我通过一个图让你更好地理解普通类型变量、指针类型变量、内存地址、内存等之间的关系。</p>
|
||||
<p><img src="assets/Ciqc1F_OA06AI435AADN1ZPvtvs400.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_OA06AI435AADN1ZPvtvs400.png" alt="png" /></p>
|
||||
<p>(指针变量、内存地址指向示意图)</p>
|
||||
<p>上图就是我刚举的例子所对应的示意图,从图中可以看到普通变量 name 的值“飞雪无情”被放到内存地址为 0xc000010200 的内存块中。指针类型变量也是变量,它也需要一块内存用来存储值,这块内存对应的地址就是 0xc00000e028,存储的值是 0xc000010200。相信你已经看到关键点了,指针变量 nameP 的值正好是普通变量 name 的内存地址,所以就建立指向关系。</p>
|
||||
<blockquote>
|
||||
@ -272,7 +272,7 @@ func modifyAge(age *int) {
|
||||
<li>可以修改指向数据的值;</li>
|
||||
<li>在变量赋值,参数传值的时候可以节省内存。</li>
|
||||
</ol>
|
||||
<p><img src="assets/CgqCHl_OA2eANW2SAAU88P9foow113.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_OA2eANW2SAAU88P9foow113.png" alt="png" /></p>
|
||||
<p>不过 Go 语言作为一种高级语言,在指针的使用上还是比较克制的。它在设计的时候就对指针进行了诸多限制,比如指针不能进行运行,也不能获取常量的指针。所以在思考是否使用时,我们也要保持克制的心态。</p>
|
||||
<p>我根据实战经验总结了以下几点使用指针的建议,供你参考:</p>
|
||||
<ol>
|
||||
|
@ -349,11 +349,11 @@ modifyMap函数:p的内存地址为0xc000060180
|
||||
<h3>类型的零值</h3>
|
||||
<p>在 Go 语言中,定义变量要么通过声明、要么通过 make 和 new 函数,不一样的是 make 和 new 函数属于显式声明并初始化。如果我们声明的变量没有显式声明初始化,那么该变量的默认值就是对应类型的零值。</p>
|
||||
<p>从下面的表格可以看到,可以称为引用类型的零值都是 nil。</p>
|
||||
<p><img src="assets/Ciqc1F_QqlyAItQJAABQMWd6pSU650.png" alt="112.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_QqlyAItQJAABQMWd6pSU650.png" alt="png" /></p>
|
||||
<p>(各种类型的零值)</p>
|
||||
<h3>总结</h3>
|
||||
<p>在 Go 语言中,<strong>函数的参数传递只有值传递</strong>,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map、chan 等),那么就可以在函数中修改原始数据。</p>
|
||||
<p><img src="assets/CgqCHl_QqryAEqYQAAVkYmbnDIM013.png" alt="Lark20201209-184447.png" /></p>
|
||||
<p><img src="assets/CgqCHl_QqryAEqYQAAVkYmbnDIM013.png" alt="png" /></p>
|
||||
<p>所以我们在创建一个函数的时候,要根据自己的真实需求决定参数的类型,以便更好地服务于我们的业务。</p>
|
||||
<p>这节课中,我讲解 chan 的时候没有举例,你自己可以自定义一个有 chan 参数的函数,作为练习题。</p>
|
||||
<p>下节课我将介绍“内存分配:new 还是 make?什么情况下该用谁?”记得来听课!</p>
|
||||
|
@ -270,7 +270,7 @@ ok gotour/ch18 0.367s coverage: 85.7% of statements
|
||||
<pre><code>➜ go tool cover -html=ch18.cover -o=ch18.html
|
||||
</code></pre>
|
||||
<p>命令运行后,会在当前目录下生成一个 ch18.html 文件,使用浏览器打开它,可以看到图中的内容:</p>
|
||||
<p><img src="assets/CgpVE1_i7P2ALPmDAACtzdHE7Jo110.png" alt="image.png" /></p>
|
||||
<p><img src="assets/CgpVE1_i7P2ALPmDAACtzdHE7Jo110.png" alt="png" /></p>
|
||||
<p>单元测试覆盖率报告</p>
|
||||
<p>红色标记的部分是没有测试到的,绿色标记的部分是已经测试到的。这就是单元测试覆盖率报告的好处,通过它你可以很容易地检测自己写的单元测试是否完全覆盖。</p>
|
||||
<p>根据报告,我再修改一下单元测试,把没有覆盖的代码逻辑覆盖到,代码如下:</p>
|
||||
|
@ -361,7 +361,7 @@ func main() {
|
||||
<p>从以上代码可以看到,只需要把建立链接的方法从 Dial 换成 DialHTTP 即可。</p>
|
||||
<p>现在分别运行服务端和客户端代码,就可以看到输出的结果了,和上面使用TCP 链接时是一样的。</p>
|
||||
<p>此外,Go 语言 net/rpc 包提供的 HTTP 协议的 RPC 还有一个调试的 URL,运行服务端代码后,在浏览器中输入 http://localhost:1234/debug/rpc 回车,即可看到服务端注册的RPC 服务,以及每个服务的方法,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_7zbWAb5PXAAA7zm9tcRE148.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F_7zbWAb5PXAAA7zm9tcRE148.png" alt="png" /></p>
|
||||
<p>如上图所示,<strong>注册的 RPC 服务</strong>、<strong>方法的签名</strong>、<strong>已经被调用的次数</strong>都可以看到。</p>
|
||||
<h3>JSON RPC 跨平台通信</h3>
|
||||
<p>以上我实现的RPC 服务是基于 gob 编码的,这种编码在跨语言调用的时候比较困难,而当前在微服务架构中,RPC 服务的实现者和调用者都可能是不同的编程语言,因此我们实现的 RPC 服务要支持多语言的调用。</p>
|
||||
|
@ -201,7 +201,7 @@ function hide_canvas() {
|
||||
<p>你好,我是高洪涛,前华为云技术专家、前当当网系统架构师和 Oracle DBA,也是 Apache ShardingSphere PMC 成员。作为创始团队核心成员,我深度参与的 Apache ShardingShpere 目前已经服务于国内外上百家企业,并得到了业界广泛的认可。</p>
|
||||
<p>我在分布式数据库设计与研发领域工作近 5 年,也经常参与和组织一些行业会议,比如中国数据库大会、Oracle 嘉年华等,与业界人士交流分布式数据库领域的最新动向和发展趋势。</p>
|
||||
<p>近十年来,整个行业都在争先恐后地进入这个领域,从而大大加速了技术进步。特别是近五年,云厂商相继发布重量级分布式数据库产品,普通用户接触这门技术的门槛降低了,越来越多人正在参与其中,整个领域生态呈现出“百花齐放”的态势。</p>
|
||||
<p><img src="assets/Cip5yGABRteAYTZyAADXPevWOF0943.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yGABRteAYTZyAADXPevWOF0943.png" alt="png" /></p>
|
||||
<p>2021 年数据大会上,阿里云发布了分布式数据库使用率统计图</p>
|
||||
<h3>学好分布式数据库将给你带来哪些机会?</h3>
|
||||
<p>但在生产实践过程中我们会发现,许多技术人员对分布式数据库还停留在一知半解的状态,比如下面这些疑问:</p>
|
||||
@ -244,7 +244,7 @@ function hide_canvas() {
|
||||
<li><strong>模块三,分布式数据库的高扩展性保证——分布式系统</strong>。详细介绍分布式数据库中所蕴含的系统设计原理、算法等,包含但不限于错误侦测、领导选举、数据可靠传播、分布式事务、共识算法等内容。虽然分布式内容很多,但我不会面面俱到,而是帮你提炼精华,基于实例为你建立知识体系。</li>
|
||||
<li><strong>模块四,知识拓展</strong>。我会和你探讨当代最成功的分布式数据库(传统&新型),探讨它们成功的关键,同时将它们与之前模块中所介绍的技术原理进行相应的映射,让你的知识体系更加丰富。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Cip5yGABRxaAUamaAANhzb0pQa4104.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yGABRxaAUamaAANhzb0pQa4104.png" alt="png" /></p>
|
||||
<h3>讲师寄语</h3>
|
||||
<p>本课程的设计目标是,尽最大程度解决你的实际问题,让你在不同的工程实践中,对分布式场景下的数据库存储有更加专业的认知,并对技术趋势建立深入的洞察。</p>
|
||||
</div>
|
||||
|
@ -211,7 +211,7 @@ function hide_canvas() {
|
||||
<h3>基本概念</h3>
|
||||
<p>分布式数据库,从名字上可以拆解为:分布式+数据库。用一句话总结为:由多个独立实体组成,并且彼此通过网络进行互联的数据库。</p>
|
||||
<p>理解新概念最好的方式就是通过已经掌握的知识来学习,下表对比了大家熟悉的分布式数据库与集中式数据库之间主要的 5 个差异点。</p>
|
||||
<p><img src="assets/CgpVE2ABTo6AR5YmAAEPyUn_Xrc581.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgpVE2ABTo6AR5YmAAEPyUn_Xrc581.png" alt="png" /></p>
|
||||
<p>从表中,我们可以总结出<strong>分布式数据库的核心——数据分片、数据同步</strong>。</p>
|
||||
<h4>1. 数据分片</h4>
|
||||
<p>该特性是分布式数据库的技术创新。它可以突破中心化数据库单机的容量限制,从而将数据分散到多节点,以更灵活、高效的方式来处理数据。这是分布式理论带给数据库的一份礼物。</p>
|
||||
@ -226,7 +226,7 @@ function hide_canvas() {
|
||||
<p>当然分布式数据库还有其他特点,但把握住以上两点,已经足够我们理解它了。下面我将从这两个特性出发,探求技术史上分布式数据库的发展脉络。我会以互联网、云计算等较新的时间节点来进行断代划分,毕竟我们的核心还是着眼现在、面向未来。</p>
|
||||
<h3>商业数据库</h3>
|
||||
<p>互联网浪潮之前的数据库,特别是前大数据时代。谈到分布式数据库绕不开的就是 Oracle RAC。</p>
|
||||
<p><img src="assets/CgqCHmABT3OAWKNmAADhjXR2H_U089.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHmABT3OAWKNmAADhjXR2H_U089.png" alt="png" /></p>
|
||||
<p>Oracle RAC 是典型的大型商业解决方案,且为软硬件一体化解决方案。我在早年入职国内顶级电信行业解决方案公司的时候,就被其强大的性能所震撼,又为它高昂的价格所深深折服。它是那个时代数据库性能的标杆和极限,是完美方案与商业成就的体现。</p>
|
||||
<p>我们试着用上面谈到的两个特性来简单分析一下 RAC:它确实是做到了数据分片与同步。每一层都是离散化的,特别在底层存储使用了 ASM 镜像存储技术,使其看起来像一块完整的大磁盘。</p>
|
||||
<p>这样做的好处是实现了极致的使用体验,即使用单例数据库与 RAC 集群数据库,在使用上没有明显的区别。它的分布式存储层提供了完整的磁盘功能,使其对应用透明,从而达到扩展性与其他性能之间的平衡。甚至在应对特定规模的数据下,其经济性又有不错的表现。</p>
|
||||
@ -234,17 +234,17 @@ function hide_canvas() {
|
||||
<p>该规模在当时的环境下是完全够用的,但是随着互联网的崛起,一场轰轰烈烈的“运动”将会打破 Oracle RAC 的不败金身。</p>
|
||||
<h3>大数据</h3>
|
||||
<p>我们知道 Oracle、DB2 等商业数据库均为 OLTP 与 OLAP 融合数据库。而首先在分布式道路上寻求突破的是 OLAP 领域。在 2000 年伊始,以 Hadoop 为代表的大数据库技术凭借其“无共享”(share nothing)的技术体系,开始向以 Oracle 为代表的关系型数据库发起冲击。</p>
|
||||
<p><img src="assets/CgpVE2ABT4iAci6AAAE2nfoHLwM617.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgpVE2ABT4iAci6AAAE2nfoHLwM617.png" alt="png" /></p>
|
||||
<p>这是一次水平扩展与垂直扩展,通用经济设备与专用昂贵服务,开源与商业这几组概念的首次大规模碰撞。<strong>拉开了真正意义上分布式数据库的帷幕</strong>。</p>
|
||||
<p>当然从一般的观点出发,Hadoop 一类的大数据处理平台不应称为数据库。但是从前面我们归纳的两点特性看,它们又确实非常满足。因此我们可以将它们归纳为早期面向商业分析场景的分布式数据库。<strong>从此 OLAP 型数据库开始了自己独立演化的道路</strong>。</p>
|
||||
<p>除了 Hadoop,另一种被称为 MPP(大规模并行处理)类型的数据库在此段时间也经历了高速的发展。MPP 数据库的架构图如下:</p>
|
||||
<p><img src="assets/CgpVE2ABT4-AdI5VAAE42YTeOoQ273.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgpVE2ABT4-AdI5VAAE42YTeOoQ273.png" alt="png" /></p>
|
||||
<p>我们可以看到这种数据库与大数据常用的 Hadoop 在架构层面上非常类似,但理念不同。简而言之,它是对 SMP(对称多处理器结构)、NUMA(非一致性存储访问结构)这类硬件体系的创新,采用 shared-nothing 架构,通过网络将多个 SMP 节点互联,使它们协同工作。</p>
|
||||
<p>MPP 数据库的特点是首先支持 PB 级的数据处理,同时支持比较丰富的 SQL 分析查询语句。同时,该领域是商业产品的战场,其中不仅仅包含独立厂商,如 Teradata,还包含一些巨头玩家,如 HP 的 Vertica、EMC 的 Greenplum 等。</p>
|
||||
<p>大数据技术的发展使 OLAP 分析型数据库,从原来的关系型数据库之中独立出来,形成了完整的发展分支路径。而随着互联网浪潮的发展,OLTP 领域迎来了发展的机遇。</p>
|
||||
<h3>互联网化</h3>
|
||||
<p>国内数据库领域进入互联网时代第一个重大事件就是“去 IOE”。</p>
|
||||
<p><img src="assets/Cip5yGABT5qAM34oAAE2hs8yVAU932.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Cip5yGABT5qAM34oAAE2hs8yVAU932.png" alt="png" /></p>
|
||||
<p>其中尤以“去 Oracle 数据库”产生的影响深远。十年前,阿里巴巴喊出的这个口号深深影响了国内数据库领域,这里我们不去探讨其中细节,也不去评价它正面或负面的影响。但从对于分布式数据库的影响来说,它至少带来两种观念的转变。</p>
|
||||
<ol>
|
||||
<li>应用成为核心:去 O 后,开源数据库需要配合数据库中间件(proxy)去使用,但这种组合无法实现传统商业库提供的一些关键功能,如丰富的 SQL 支持和 ACID 级别的事务。因此应用软件需要进行精心设计,从而保障与新数据库平台的配合。应用架构设计变得非常关键,整个技术架构开始脱离那种具有调侃意味的“面向数据库” 编程,转而变为以应用系统为核心。</li>
|
||||
@ -267,11 +267,11 @@ function hide_canvas() {
|
||||
<p>首先,由于云服务天生的“超卖”特性,造成其采购成本较低,从而使终端用户尝试分布式数据库的门槛大大降低。</p>
|
||||
<p>其次,来自云服务厂商的支撑人员可以与用户可以进行深度的合作,形成了高效的反馈机制。这种反馈机制促使云原生的分布式数据库有机会进行快速的迭代,从而可以积极响应客户的需求。</p>
|
||||
<p>这就是云原生带给分布式数据库的变化,它是<strong>通过生态系统的优化完成了对传统商业数据库的超越</strong>。以下来自 DB-Engines 的分析数据说明了未来的数据库市场属于分布式数据库,属于云原生数据库。</p>
|
||||
<p><img src="assets/CgqCHmABT_aAByOoAAH2ctjuqy4281.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHmABT_aAByOoAAH2ctjuqy4281.png" alt="png" /></p>
|
||||
<p>随着分布式数据库的发展,我们又迎来了新的一次融合:那就是 OLTP 与 OLAP 将再一次合并为 HTAP(融合交易分析处理)数据库。</p>
|
||||
<p>该趋势的产生主要来源于云原生 OLTP 型分布式数据库的日趋成熟。同时由于整个行业的发展,客户与厂商对于实时分析型数据库的需求越来越旺盛,但传统上大数据技术包括开源与 MPP 类数据库,强调的是离线分析。</p>
|
||||
<p>如果要进行秒级的数据处理,那么必须将交易数据与分析数据尽可能地贴近,并减少非实时 ELT 的引入,这就促使了 OLTP 与 OLAP 融合为 HTAP。下图就是阿里云 PolarDB 的 HTAP 架构。</p>
|
||||
<p><img src="assets/Ciqc1GABT_6AVFtwAAHdreedW2k751.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABT_6AVFtwAAHdreedW2k751.png" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>用《三国演义》的第一句话来说:“天下大势,分久必合,合久必分。”而我们观察到的分布式数据库,乃至数据库本身的发展正暗合了这句话。</p>
|
||||
<p><strong>分布式数据库发展就是一个由合到分,再到合的过程</strong>:</p>
|
||||
|
@ -221,7 +221,7 @@ function hide_canvas() {
|
||||
<p>NoSQL 数据库因具有庞大的数据存储需求,常被用于大数据和 C 端互联网应用。例如,Twitter、Facebook、阿里和腾讯这样的公司,每天都利用其收集几十甚至上百 TB 的用户数据。</p>
|
||||
<p>那么 NoSQL 数据库与 SQL 数据库的区别表现在哪呢?如下表所示。</p>
|
||||
<p>表 NoSQL 数据库与 SQL 数据库的区别</p>
|
||||
<p><img src="assets/CgqCHmABUO2ARErIAAC-JxCHpDg212.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHmABUO2ARErIAAC-JxCHpDg212.png" alt="png" /></p>
|
||||
<p>NoSQL 除了不是 SQL 外,另外一个广泛的解释是 Not Only SQL。其背后暗含:我们没有 SQL,但是有一项比 SQL 要吸引人的东西,那就是——分布式。</p>
|
||||
<p>在 NoSQL 出现之前的商业数据库,多节点部署的难度很大且费用高昂,甚至需要使用专用的硬件。虽然理论上规模应该足够大,但其实不然。而后出现的 NoSQL,大部分在设计层面天然考虑了使用廉价硬件进行系统扩容,同时由于其放弃了 ACID,性能才没有随着系统规模的扩大而衰减。</p>
|
||||
<p>当然 NoSQL 的缺点也比较明显:由于缺乏 ACID,应用时需要非常小心地处理数据一致性问题;同时由于其数据模型往往只针对特定场景,一般不能使用一种 NoSQL 数据库来完成整个应用的构建,导致设计层面的复杂和维护的困难。</p>
|
||||
|
@ -210,7 +210,7 @@ function hide_canvas() {
|
||||
<li>垂直分片:在不同的数据库节点中存储表不同的表列。</li>
|
||||
</ol>
|
||||
<p>如下图所示,水平和垂直这两个概念来自原关系型数据库表模式的可视化直观视图。</p>
|
||||
<p><img src="assets/CgpVE2ABUUmAL3H4AAGTDb7sQOQ568.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgpVE2ABUUmAL3H4AAGTDb7sQOQ568.png" alt="png" /></p>
|
||||
<p>图 1 可视化直观视图</p>
|
||||
<p>分片理念其实来源于经济学的边际收益理论:如果投资持续增加,但收益的增幅开始下降时,被称为边际收益递减状态。而刚好要开始下降的那个点被称为边际平衡点。</p>
|
||||
<p>该理论应用在数据库计算能力上往往被表述为:如果数据库处理能力遇到瓶颈,最简单的方式是持续提高系统性能,如更换更强劲的 CPU、更大内存等,这种模式被称为垂直扩展。当持续增加资源以提升数据库能力时,垂直扩展有其自身的限制,最终达到边际平衡,收益开始递减。</p>
|
||||
@ -224,11 +224,11 @@ function hide_canvas() {
|
||||
<h4>哈希分片</h4>
|
||||
<p>哈希分片,首先需要获取分片键,然后根据特定的哈希算法计算它的哈希值,最后使用哈希值确定数据应被放置在哪个分片中。数据库一般对所有数据使用统一的哈希算法(例如 ketama),以促成哈希函数在服务器之间均匀地分配数据,从而降低了数据不均衡所带来的热点风险。通过这种方法,数据不太可能放在同一分片上,从而使数据被随机分散开。</p>
|
||||
<p>这种算法非常适合随机读写的场景,能够很好地分散系统负载,但弊端是不利于范围扫描查询操作。下图是这一算法的工作原理。</p>
|
||||
<p><img src="assets/Cip5yGABUVOANTI_AACPCvFkQMQ491.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yGABUVOANTI_AACPCvFkQMQ491.png" alt="png" /></p>
|
||||
<p>图 2 哈希分片</p>
|
||||
<h4>范围分片</h4>
|
||||
<p>范围分片根据数据值或键空间的范围对数据进行划分,相邻的分片键更有可能落入相同的分片上。每行数据不像哈希分片那样需要进行转换,实际上它们只是简单地被分类到不同的分片上。下图是范围分片的工作原理。</p>
|
||||
<p><img src="assets/Cip5yGABUXSATworAABCLehE-pM870.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cip5yGABUXSATworAABCLehE-pM870.png" alt="png" /></p>
|
||||
<p>图 3 范围分片</p>
|
||||
<p>范围分片需要选择合适的分片键,这些分片键需要尽量不包含重复数值,也就是其候选数值尽可能地离散。同时数据不要单调递增或递减,否则,数据不能很好地在集群中离散,从而造成热点。</p>
|
||||
<p>范围分片非常适合进行范围查找,但是其随机读写性能偏弱。</p>
|
||||
@ -261,7 +261,7 @@ function hide_canvas() {
|
||||
<p>ShardingShpere 首先提供了分布式的主键生成,这是生成分片键的关键。由于分布式数据库内一般由多个数据库节点参与,因此基于数据库实例的主键生成并不适合分布式场景。</p>
|
||||
<p>常用的算法有 UUID 和 Snowfalke 两种无状态生成算法。</p>
|
||||
<p>UUID 是最简单的方式,但是生成效率不高,且数据离散度一般。因此目前生产环境中会采用后一种算法。下图就是用该算法生成的分片键的结构。</p>
|
||||
<p><img src="assets/Cip5yGABUWmAW6olAAECEkYbH8U406.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Cip5yGABUWmAW6olAAECEkYbH8U406.png" alt="png" /></p>
|
||||
<p>图 4 分片键结构</p>
|
||||
<p>其中有效部分有三个。</p>
|
||||
<ol>
|
||||
@ -280,21 +280,21 @@ function hide_canvas() {
|
||||
<p>用户通过以上多种分片工具,可以灵活和统一地制定数据库分片策略。</p>
|
||||
<h4>自动分片</h4>
|
||||
<p>ShardingShpere 提供了 Sharding-Scale 来支持数据库节点弹性伸缩,该功能就是其对自动分片的支持。下图是自动分片功能展示图,可以看到经过 Sharding-Scale 的特性伸缩,原有的两个数据库扩充为三个。</p>
|
||||
<p><img src="assets/CgqCHmABUYSAb4GHAAQVlwbl-X4314.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHmABUYSAb4GHAAQVlwbl-X4314.png" alt="png" /></p>
|
||||
<p>图 5 自动分片功能展示</p>
|
||||
<p>自动分片包含下图所示的四个过程。</p>
|
||||
<p><img src="assets/Ciqc1GABUY2ACpn4AAM1n2uEO-A067.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABUY2ACpn4AAM1n2uEO-A067.png" alt="png" /></p>
|
||||
<p>图 6 自动分片过程</p>
|
||||
<p>从图 6 中可以看到,通过该工作量,ShardingShpere 可以支持复杂的基于哈希的自动分片。同时我们也应该看到,没有专业和自动化的弹性扩缩容工具,想要实现自动化分片是非常困难的。</p>
|
||||
<p>以上就是分片算法的实际案例,使用的是经典的水平分片模式。而目前水平和垂直分片有进一步合并的趋势,下面要介绍的 TiDB 正代表着这种融合趋势。</p>
|
||||
<h3>垂直与水平分片融合案例</h3>
|
||||
<p>TiDB 就是一个垂直与水平分片融合的典型案例,同时该方案也是 HATP 融合方案。</p>
|
||||
<p>其中水平扩展依赖于底层的 TiKV,如下图所示。</p>
|
||||
<p><img src="assets/Ciqc1GABUZWAF6UYAACmuUoCK3Y948.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABUZWAF6UYAACmuUoCK3Y948.png" alt="png" /></p>
|
||||
<p>图 7 TiKV</p>
|
||||
<p>TiKV 使用范围分片的模式,数据被分配到 Region 组里面。一个分组保持三个副本,这保证了高可用性(相关内容会在“05 | 一致性与 CAP 模型:为什么需要分布式一致性?”中详细介绍)。当 Region 变大后,会被拆分,新分裂的 Region 也会产生多个副本。</p>
|
||||
<p>TiDB 的水平扩展依赖于 TiFlash,如下图所示。</p>
|
||||
<p><img src="assets/Ciqc1GABUZ-AAH-KAAJGbLaxtiI142.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABUZ-AAH-KAAJGbLaxtiI142.png" alt="png" /></p>
|
||||
<p>图 8 TiFlash</p>
|
||||
<p>从图 8 中可以看到 TiFlash 是 TiKV 的列扩展插件,数据异步从 TiKV 里面复制到 TiFlash,而后进行列转换,其中要使用 MVCC 技术来保证数据的一致性。</p>
|
||||
<p>上文所述的 Region 会增加一个新的异步副本,而后该副本进行了数据切分,并以列模式组合到 TiFlash 中,从而达到了水平和垂直扩展在同一个数据库的融合。这是两种数据库引擎的融合。</p>
|
||||
|
@ -204,7 +204,7 @@ function hide_canvas() {
|
||||
<p>现在让我们开始学习单主复制,其中不仅介绍了该技术本身,也涉及了一些复制领域的话题,如复制延迟、高可用和复制方式等。</p>
|
||||
<h3>单主复制</h3>
|
||||
<p>单主复制,也称主从复制。写入主节点的数据都需要复制到从节点,即存储数据库副本的节点。当客户要写入数据库时,他们必须将请求发送给主节点,而后主节点将这些数据转换为复制日志或修改数据流发送给其所有从节点。从使用者的角度来看,从节点都是只读的。下图就是经典的主从复制架构。</p>
|
||||
<p><img src="assets/Ciqc1GAJV6SADprzAACli5qqAMo678.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAJV6SADprzAACli5qqAMo678.png" alt="png" /></p>
|
||||
<p>这种模式是最早发展起来的复制模式,不仅被广泛应用在传统数据库中,如 PostgreSQL、MySQL、Oracle、SQL Server;它也被广泛应用在一些分布式数据库中,如 MongoDB、RethinkDB 和 Redis 等。</p>
|
||||
<p>那么接下来,我们就从复制同步模式、复制延迟、复制与高可用性以及复制方式几个方面来具体说说这个概念。</p>
|
||||
<h4>复制同步模式</h4>
|
||||
@ -292,7 +292,7 @@ function hide_canvas() {
|
||||
<p>下面我就从第一代复制技术开始说起。</p>
|
||||
<h4>MHA 复制控制</h4>
|
||||
<p>下图是 MHA 架构图。</p>
|
||||
<p><img src="assets/Cip5yGAJV9qAVnjXAAC85xLxhaU613.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yGAJV9qAVnjXAAC85xLxhaU613.png" alt="png" /></p>
|
||||
<p>MHA 作为第一代复制架构,有如下适用场景:</p>
|
||||
<ol>
|
||||
<li>MySQL 的版本≤5.5,这一点说明它很古老;</li>
|
||||
@ -317,7 +317,7 @@ function hide_canvas() {
|
||||
<li>这一代开始需要支持跨 IDC 复制。需要引入监控 Monitor,配合 consul 注册中心。多个 IDC 中 Monitor 组成分布式监控,把健康的 MySQL 注册到 consul 中,同时将从库复制延迟情况也同步到 consul 中。</li>
|
||||
</ol>
|
||||
<p>下图就是带有 consul 注册中心与监控模块的半同步复制架构图。</p>
|
||||
<p><img src="assets/Cip5yGAJV-KAAg5HAAF8syZ9vQM483.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cip5yGAJV-KAAg5HAAF8syZ9vQM483.png" alt="png" /></p>
|
||||
<p>第二代复制技术也有自身的一些缺陷。</p>
|
||||
<ol>
|
||||
<li>存在幻读的情况。当事务同步到从库但没有 ACK 时,主库发生宕机;此时主库没有该事务,而从库有。</li>
|
||||
@ -329,7 +329,7 @@ function hide_canvas() {
|
||||
<p>这一代复制技术采用的是增强半同步。首先主从的复制都是用独立的线程来运行;其次主库采用 binlog group commit,也就是组提交来提供数据库的写入性能;而从库采用并行复制,它是基于事务的,通过数据参数调整线程数量来提高性能。这样主库可以并行,从库也可以并行。</p>
|
||||
<p>这一代技术体系强依赖于增强半同步,利用半同步保证 RPO,对于 RTO,则取决于复制延迟。</p>
|
||||
<p>下面我们用 Xenon 来举例说明,请看下图(图片来自官网)。</p>
|
||||
<p><img src="assets/CgpVE2AJV-mAE6vWAAB_JZptW8Y497.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgpVE2AJV-mAE6vWAAB_JZptW8Y497.png" alt="png" /></p>
|
||||
<p>从图中可以看到。每个节点上都有一个独立的 agent,这些 agent 利用 raft 构建一致性集群,利用 GTID 做索引选举主节点;而后主节点对外提供写服务,从节点提供读服务。</p>
|
||||
<p>当主节点发生故障后,agent 会通过 ping 发现该故障。由于 GTID 和增强半同步的加持,从节点与主节点数据是一致的,因此很容易将从节点提升为主节点。</p>
|
||||
<p>第三代技术也有自身的缺点,如增强半同步中存在幽灵事务。这是由于数据写入 binlog 后,主库掉电。由于故障恢复流程需要从 binlog 中恢复,那么这份数据就在主库。但是如果它没有被同步到从库,就会造成从库不能切换为主库,只能去尝试恢复原崩溃的主库。</p>
|
||||
|
@ -223,7 +223,7 @@ function hide_canvas() {
|
||||
<p>CAP 意味着即使所有节点都在运行中,我们也可能会遇到一致性问题,这是因为它们之间存在连接性问题。CAP 理论常常用三角形表示,就好像我们可以任意匹配三个参数一样。然而,尽管我们可以调整可用性和一致性,但分区容忍性是我们无法实际放弃的。</p>
|
||||
<p>如果我们选择了 CA 而放弃了 P,那么当发生分区现象时,为了保证 C,系统需要禁止写入。也就是,当有写入请求时,系统不可用。这与 A 冲突了,因为 A 要求系统是可用的。因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。</p>
|
||||
<p>如下图所示,其实 CA 类系统是不存在的,这里你需要特别注意。</p>
|
||||
<p><img src="assets/Cip5yGAJWLmAJW7kAABPImLZRig108.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yGAJWLmAJW7kAABPImLZRig108.png" alt="png" /></p>
|
||||
<p>图 1 CAP 理论</p>
|
||||
<p>CAP 中的可用性也不同于上述的高可用性,CAP 定义对请求的延迟没有任何限制。此外,与 CAP 相反,数据库的高可用性并不需要每个在线节点都可以提供服务。</p>
|
||||
<p>CAP 里面的 C 代表线性一致,除了它以外,还有其他的一致模式,我们现在来具体介绍一下。</p>
|
||||
@ -232,7 +232,7 @@ function hide_canvas() {
|
||||
<p>从用户的角度看,分布式数据库就像具有共享存储的单机数据库一样,节点间的通信和消息传递被隐藏到了数据库内部,这会使用户产生“分布式数据库是一种共享内存”的错觉。一个支持读取和写入操作的单个存储单元通常称为寄存器,我们可以把代表分布式数据库的共享存储看作是一组这样的寄存器。</p>
|
||||
<p>每个读写寄存器的操作被抽象为“调用”和“完成”两个动作。如果“调用”发生后,但在“完成”之前该操作崩溃了,我们将操作定义为失败。如果一个操作的调用和完成事件都在另一个操作被调用之前发生,我们说这个操作在另一个操作之前,并且这两个操作是顺序的;否则,我们说它们是并发的。</p>
|
||||
<p>如下图所示,a)是顺序操作,b)和 c)是并发操作。</p>
|
||||
<p><img src="assets/Ciqc1GAJWMaAahgyAAA9-0_mXvY966.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAJWMaAahgyAAA9-0_mXvY966.png" alt="png" /></p>
|
||||
<p>图 2 顺序操作&并发操作</p>
|
||||
<p>多个读取或写入操作可以同时访问一个寄存器。对寄存器的读写操作不是瞬间完成的,需要一些时间,即调用和完成两个动作之间的时间。由不同进程执行的并发读/写操作不是串行的,根据寄存器在操作重叠时的行为,它们的顺序可能不同,并且可能产生不同的结果。</p>
|
||||
<p>当我们讨论数据库一致性时,可以从两个维度来区别。</p>
|
||||
@ -265,7 +265,7 @@ function hide_canvas() {
|
||||
<li>第三次读只能返回 2,因为第二次写是在第一次写之后进行的。</li>
|
||||
</ol>
|
||||
<p>下图正是现象一致性的直观展示。</p>
|
||||
<p><img src="assets/Ciqc1GAJWNaABNY5AACytLnfuEE642.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAJWNaABNY5AACytLnfuEE642.png" alt="png" /></p>
|
||||
<p>图 3 线性一致性</p>
|
||||
<p>线性一致性的代价是很高昂的,甚至 CPU 都不会使用线性一致性。有并发编程经验的朋友一定知道 CAS 操作,该操作可以实现操作的线性化,是高性能并发编程的关键,它就是通过编程手段来模拟线性一致。</p>
|
||||
<p>一个比较常见的误区是,使用一致性算法可以实现线性一致,如 Paxos 和 Raft 等。但实际是不行的,以 Raft 为例,算法只是保证了复制 Log 的线性一致,而没有描述 Log 是如何写入最终的状态机的,这就暗含状态机本身不是线性一致的。</p>
|
||||
@ -273,10 +273,10 @@ function hide_canvas() {
|
||||
<h4>顺序一致性</h4>
|
||||
<p>由于线性一致的代价高昂,因此人们想到,既然全局时钟导致严格一致性很难实现,那么顺序一致性就是放弃了全局时钟的约束,改为分布式逻辑时钟实现。顺序一致性是指所有的进程以相同的顺序看到所有的修改。读操作未必能及时得到此前其他进程对同一数据的写更新,但是每个进程读到的该数据的不同值的顺序是一致的。</p>
|
||||
<p>下图展示了 P1、P2 写入两个值后,P3 和 P4 是如何读取的。以真实的时间衡量,1 应该是在 2 之前被写入,但是在顺序一致性下,1 是可以被排在 2 之后的。同时,尽管 P3 已经读取值 1,P4 仍然可以读取 2。但是需要注意的是这两种组合:1->2 和 2 ->1,P3 和 P4 从它们中选择一个,并保持一致。下图正是展示了它们读取顺序的一种可能:2->1。</p>
|
||||
<p><img src="assets/CgqCHmAJWOCABAs2AABs-o-Dn-I630.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHmAJWOCABAs2AABs-o-Dn-I630.png" alt="png" /></p>
|
||||
<p>图 4 顺序一致性</p>
|
||||
<p>我们使用下图来进一步区分线性一致和顺序一致。</p>
|
||||
<p><img src="assets/Ciqc1GAJWOaAT1zmAAB5GZRY2aI676.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAJWOaAT1zmAAB5GZRY2aI676.png" alt="png" /></p>
|
||||
<p>图 5 区分线性一致和顺序一致</p>
|
||||
<p>其中,图 a 满足了顺序一致性,但是不满足线性一致性。原因在于,从全局时钟的观点来看,P2 进程对变量 x 的读操作在 P1 进程对变量 x 的写操作之后,然而读出来的却是旧的数据。但是这个图却是满足顺序一致性,因为两个进程 P1 和 P2 的一致性并没有冲突。</p>
|
||||
<p>图 b 满足线性一致性,因为每个读操作都读到了该变量的最新写的结果,同时两个进程看到的操作顺序与全局时钟的顺序一样。</p>
|
||||
@ -292,10 +292,10 @@ function hide_canvas() {
|
||||
<li>闭包传递:和时钟向量里面定义的一样,如果 a->b、b->c,那么肯定也有 a->c。</li>
|
||||
</ol>
|
||||
<p>那么,为什么需要因果关系,以及没有因果关系的写法如何传播?下图中,进程 P1 和 P2 进行的写操作没有因果关系,也就是最终一致性。这些操作的结果可能会在不同时间,以乱序方式传播到读取端。进程 P3 在看到 2 之前将看到值 1,而 P4 将先看到 2,然后看到 1。</p>
|
||||
<p><img src="assets/Ciqc1GAJWPCATmnsAACWjAazgFM942.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAJWPCATmnsAACWjAazgFM942.png" alt="png" /></p>
|
||||
<p>图 6 因果一致性</p>
|
||||
<p>而下图显示进程 P1 和 P2 进行因果相关的写操作并按其逻辑顺序传播到 P3 和 P4。因果写入除了写入数据外,还需要附加一个逻辑时钟,用这个时钟保证两个写入是有因果关系的。这可以防止我们遇到上面那张图所示的情况。你可以在两个图中比较一下 P3 和 P4 的历史记录。</p>
|
||||
<p><img src="assets/CgqCHmAJWPiAexxkAACijWQR6zY931.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHmAJWPiAexxkAACijWQR6zY931.png" alt="png" /></p>
|
||||
<p>图 7 逻辑时钟</p>
|
||||
<p>而实现这个逻辑时钟的一种主要方式就是向量时钟。向量时钟算法利用了向量这种数据结构,将全局各个进程的逻辑时间戳广播给所有进程,每个进程发送事件时都会将当前进程已知的所有进程时间写入到一个向量中,而后进行传播。</p>
|
||||
<p>因果一致性典型案例就是 COPS 系统,它是基于 causal+一致性模型的 KV 数据库。它定义了 dependencies,操作了实现因果一致性。这对业务实现分布式数据因果关系很有帮助。另外在亚马逊 Dynamo 基于向量时钟,也实现了因果一致性。</p>
|
||||
@ -308,7 +308,7 @@ function hide_canvas() {
|
||||
<p>那么它们之间的联系如何呢?其实就是事务的隔离性与一致模型有关联。</p>
|
||||
<p>如果把上面线性一致的例子看作多个并行事务,你会发现它们是没有隔离性的。因为在开始和完成之间任意一点都会读取到这份数据,原因是一致性模型关心的是单一操作,而事务是由一组操作组成的。</p>
|
||||
<p>现在我们看另外一个例子,这是展示事务缺乏一致性后所导致的问题。</p>
|
||||
<p><img src="assets/CgqCHmAJWQGAARkoAAB7ZMQP49s438.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHmAJWQGAARkoAAB7ZMQP49s438.png" alt="png" /></p>
|
||||
<p>图 8 事务与一致性</p>
|
||||
<p>其中三个事务满足隔离性。可以看到 T2 读取到了 T1 入的值。但是这个系统缺乏一致性保障,造成 T3 可以读取到早于 T2 读取值之前的值,这就会造成应用的潜在 Bug。</p>
|
||||
<p>那现在给出结论:事务隔离是描述并行事务之间的行为,而一致性是描述非并行事务之间的行为。其实广义的事务隔离应该是经典隔离理论与一致性模型的一种混合。</p>
|
||||
|
@ -225,7 +225,7 @@ function hide_canvas() {
|
||||
<p>以上两点互相作用,从而使现在很多组织和技术团队都开始去构建属于自己的分布式数据库。</p>
|
||||
<h3>设计分布式数据库案例</h3>
|
||||
<p>熟悉我的朋友可能知道,我另外一个身份是 Apache SkyWalking 的创始成员,它是一个开源的 APM 系统。其架构图可以在官网找到,如下所示。</p>
|
||||
<p><img src="assets/Cip5yGASjYiAJEr8ABbiUnhbcXQ434.png" alt="image" /></p>
|
||||
<p><img src="assets/Cip5yGASjYiAJEr8ABbiUnhbcXQ434.png" alt="png" /></p>
|
||||
<p>可以看到其中的 Storage Option,也就是数据库层面可以有多种选择。除了单机内存版本的 H2 以外,其余生产级别的数据库均为分布式数据库。</p>
|
||||
<p>选择多一方面证明了 SkyWalking 有很强的适应能力,但更重要的是目前业界没有一款数据库可以很好地满足其使用场景。</p>
|
||||
<p>那么现在我们来尝试给它设计一个数据库。这里我简化了设计流程,只给出了需求分析与概念设计,目的是展示设计方式,帮助你更好地体会分布式数据库的关键点。</p>
|
||||
|
@ -255,7 +255,7 @@ function hide_canvas() {
|
||||
<p>目前有很多种不同的数据结构可以在内存中存储有序的数据。在分布式数据库的存储引擎中,有一种结构因其简单而被广泛地使用,那就是跳表(SkipList)。</p>
|
||||
<p>跳表的优势在于其实现难度比简单的链表高不了多少,但是其时间复杂度可以接近负载平衡的搜索树结构。</p>
|
||||
<p>跳表在插入和更新时避免对节点做旋转或替换,而是使用了随机平衡的概念来使整个表平衡。跳表由一系列节点组成,它们又由不同的高度组成。连续访问高度较高的节点可以跳过高度较低的节点,有点像蜘蛛侠利用高楼在城市内快速移动一样,这也就是跳表名称的来源。现在我们用一个例子来说明跳表的算法细节。请看下面的图片。</p>
|
||||
<p><img src="assets/CioPOWAc-pCAW-h1AACAT7yvNXU780.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CioPOWAc-pCAW-h1AACAT7yvNXU780.png" alt="png" /></p>
|
||||
<p>如果我们以寻找 15 为例来说明跳表的查找顺序。</p>
|
||||
<ol>
|
||||
<li>首先查找跳表中高度最高的节点,从图中可以看到是10。</li>
|
||||
|
@ -215,10 +215,10 @@ function hide_canvas() {
|
||||
<p>可以看到双树操作是比较简单明了的,而且可以作为一种 B 树类的索引结构而存在。但实际上几乎没有存储引擎去使用它,主要原因是它的合并操作是同步的,也就是刷盘的时候要同步进行合并。而刷盘本身是个相对频繁的操作,这样会造成写放大,也就是会影响写入效率且会占用非常大的磁盘空间。</p>
|
||||
<p><strong>多树结构是在双树的基础上提出的,内存数据刷盘时不进行合并操作</strong>,而是完全把内存数据写入到单独的文件中。那这个时候另外的问题就出现了:随着刷盘的持续进行,磁盘上的文件会快速增加。这时,读取操作就需要在很多文件中去寻找记录,这样读取数据的效率会直线下降。</p>
|
||||
<p>为了解决这个问题,此种结构会引入合并操作(Compaction)。该操作是异步执行的,它从这众多文件中选择一部分出来,读取里面的内容而后进行合并,最后写入一个新文件中,而后老文件就被删除掉了。如下图所示,这就是典型的多树结构合并操作。而这种结构也是本讲介绍的主要结构。</p>
|
||||
<p><img src="assets/Cgp9HWAqWPaAI1cVAAF0GY8NUFc418.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Cgp9HWAqWPaAI1cVAAF0GY8NUFc418.png" alt="png" /></p>
|
||||
<p>最后,我再为你详细介绍一下刷盘的流程。</p>
|
||||
<p>首先定义几种角色,如下表所示。</p>
|
||||
<p><img src="assets/Cgp9HWAqWQKAYYdQAAChiD3W3lQ653.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Cgp9HWAqWQKAYYdQAAChiD3W3lQ653.png" alt="png" /></p>
|
||||
<p>数据首先写入当前内存表,当数据量到达阈值后,当前数据表把自身状态转换为刷盘中,并停止接受写入请求。此时会新建另一个内存表来接受写请求。刷盘完成后,由于数据在磁盘上,除了废弃内存表的数据外,还对提交日志进行截取操作。而后将新数据表设置为可以读取状态。</p>
|
||||
<p>在合并操作开始时,将被合并的表设置为合并中状态,此时它们还可以接受读取操作。完成合并后,原表作废,新表开始启用提供读取服务。</p>
|
||||
<p>以上就是经典的 LSM 树的结构和一些操作细节。下面我们开始介绍如何对其进行查询、更新和删除等操作。</p>
|
||||
@ -236,14 +236,14 @@ function hide_canvas() {
|
||||
<p>常见的合并策略有 Size-Tiered Compaction 和 Leveled Compaction。</p>
|
||||
<h4>Size-Tiered Compaction</h4>
|
||||
<p>下图就是这种策略的合并过程。</p>
|
||||
<p><img src="assets/CioPOWAqWQ6AH7acAACcL3NKUVQ048.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CioPOWAqWQ6AH7acAACcL3NKUVQ048.png" alt="png" /></p>
|
||||
<p>其中,数据表按照大小进行合并,较小的数据表逐步合并为较大的数据表。第一层保存的是系统内最小的数据表,它们是刚刚从内存表中刷新出来的。合并过程就是将低层较小的数据表合并为高层较大的数据表的过程。Apache Cassandra 使用过这种合并策略。</p>
|
||||
<p>该策略的优点是比较简单,容易实现。但是它的空间放大性很差,合并时层级越高该问题越严重。比如有两个 5GB 的文件需要合并,那么磁盘至少要保留 10GB 的空间来完成这次操作,可想而知此种容量压力是巨大的,必然会造成系统不稳定。</p>
|
||||
<p>那么有没有什么策略能缓解空间放大呢?答案就是 Leveled Compaction。</p>
|
||||
<h4>Leveled Compaction</h4>
|
||||
<p>如名称所示,该策略是将数据表进行分层,按照编号排成 L0 到 Ln 这样的多层结构。L0 层是从内存表刷盘产生的数据表,该层数据表中间的 key 是可以相交的;L1 层及以上的数据,将 Size-Tiered Compaction 中原本的大数据表拆开,成为多个 key 互不相交的小数据表,每层都有一个最大数据量阈值,当到达该值时,就出发合并操作。每层的阈值是按照指数排布的,例如 RocksDB 文档中介绍了一种排布:L1 是 300MB、L2 是 3GB、L3 是 30GB、L4 为 300GB。</p>
|
||||
<p>该策略如下图所示。</p>
|
||||
<p><img src="assets/Cgp9HWAqWRmAPoPlAACQe1Ek6yI202.png" alt="4.png" /></p>
|
||||
<p><img src="assets/Cgp9HWAqWRmAPoPlAACQe1Ek6yI202.png" alt="png" /></p>
|
||||
<p>上图概要性地展示了从 L1 层开始,每个小数据表的容量都是相同的,且数据量阈值是按 10 倍增长。即 L1 最多可以有 10 个数据表,L2 最多可以有 100 个,以此类推。</p>
|
||||
<p>随着数据表不断写入,L1 的数据量会超过阈值。这时就会选择 L1 中的至少一个数据表,将其数据合并到 L2 层与其 key 有交集的那些文件中,并从 L1 中删除这些数据。</p>
|
||||
<p>仍然以上图为例,一个 L1 层数据表的 key 区间大致能够对应到 10 个 L2 层的数据表,所以一次合并会影响 11 个文件。该次合并完成后,L2 的数据量又有可能超过阈值,进而触发 L2 到 L3 的合并,如此往复。</p>
|
||||
|
@ -262,7 +262,7 @@ function hide_canvas() {
|
||||
<h3>总结</h3>
|
||||
<p>这一讲是模块三的引导课,我首先为你介绍了失败模型的概念,它是描述分布式数据库内各种可能行为的一个准则;而后根据失败模型为你梳理了本模块的讲解思路。</p>
|
||||
<p>分布式算法根据目标不同可能分为下面几种行为模式,这些模式与对应的课时如下表所示。</p>
|
||||
<p><img src="assets/CioPOWA3btaADEsJAADf09O0yw4036.png" alt="image.png" /></p>
|
||||
<p><img src="assets/CioPOWA3btaADEsJAADf09O0yw4036.png" alt="png" /></p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -224,16 +224,16 @@ function hide_canvas() {
|
||||
<li>一个节点向周围节点以一个固定的频率发送特定的数据包(称为心跳包),周围节点根据接收的频率判断该节点的健康状态。如果超出规定时间,未收到数据包,则认为该节点已经离线。</li>
|
||||
</ol>
|
||||
<p>可以看到这两种方法虽然实现细节不同,但都包含了一个所谓“规定时间”的概念,那就是超时机制。我们现在以第一种模式来详细介绍这种算法,请看下面这张图片。</p>
|
||||
<p><img src="assets/Cgp9HWA4wn-ACDStAABEuFgWB6c085.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cgp9HWA4wn-ACDStAABEuFgWB6c085.png" alt="png" /></p>
|
||||
<p>图 1 模拟两个连续心跳访问</p>
|
||||
<p>上面的图模拟了两个连续心跳访问,节点 1 发送 ping 包,在规定的时间内节点 2 返回了 pong 包。从而节点 1 判断节点 2 是存活的。但在现实场景中经常会发生图 2 所示的情况。</p>
|
||||
<p><img src="assets/CioPOWA4woqAFrAbAABF2-Jmi34588.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CioPOWA4woqAFrAbAABF2-Jmi34588.png" alt="png" /></p>
|
||||
<p>图 2 现实场景下的心跳访问</p>
|
||||
<p>可以看到节点 1 发送 ping 后,节点没有在规定时间内返回 pong,此时节点 1 又发送了另外的 ping。此种情况表明,节点 2 存在延迟情况。偶尔的延迟在分布式场景中是极其常见的,故基于超时的心跳检测算法需要设置一个超时总数阈值。当超时次数超过该阈值后,才判断远程节点是离线状态,从而避免偶尔产生的延迟影响算法的准确性。</p>
|
||||
<p>由上面的描述可知,基于超时的心跳检测法会为了调高算法的准确度,从而牺牲算法的效率。那有没有什么办法能改善算法的效率呢?下面我就要介绍一种不基于超时的心跳检测算法。</p>
|
||||
<h4>不基于超时</h4>
|
||||
<p>不基于超时的心跳检测算法是基于异步系统理论的。它保存一个全局节点的心跳列表,上面记录了每一个节点的心跳状态,从而可以直观地看到系统中节点的健康度。由此可知,该算法除了可以提高检测的效率外,还可以非常容易地获得所有节点的健康状态。那么这个全局列表是如何生成的呢?下图展示了该列表在节点之间的流转过程。</p>
|
||||
<p><img src="assets/CioPOWA4wpWAZg3ZAABADm-xENc006.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CioPOWA4wpWAZg3ZAABADm-xENc006.png" alt="png" /></p>
|
||||
<p>图 3 全局列表在节点之间的流转过程</p>
|
||||
<p>由图可知,该算法需要生成一个节点间的主要路径,该路径就是数据流在节点间最常经过的一条路径,该路径同时要包含集群内的所有节点。如上图所示,这条路径就是从节点 1 经过节点 2,最后到达节点 3。</p>
|
||||
<p>算法开始的时候,节点首先将自己记录到表格中,然后将表格发送给节点 2;节点 2 首先将表格中的节点 1 的计数器加 1,然后将自己记录在表格中,而后发送给节点 3;节点 3 如节点 2 一样,将其中的所有节点计数器加 1,再把自己记录进去。一旦节点 3 发现所有节点全部被记录了,就停止这个表格的传播。</p>
|
||||
@ -242,7 +242,7 @@ function hide_canvas() {
|
||||
<p>那么有没有方法能提高对于单一节点的判断呢?现在我就来介绍一种间接的检测方法。</p>
|
||||
<h4>间接检测</h4>
|
||||
<p>间接检测法可以有效提高算法的稳定性。它是将整个网络进行分组,我们不需要知道网络中所有节点的健康度,而只需要在子网中选取部分节点,它们会告知其相邻节点的健康状态。</p>
|
||||
<p><img src="assets/Cgp9HWA4wp2AffksAABafzwFuLM251.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Cgp9HWA4wp2AffksAABafzwFuLM251.png" alt="png" /></p>
|
||||
<p>图 4 间接检测法</p>
|
||||
<p>如图所示,节点 1 无法直接去判断节点 2 是否存活,这个时候它转而询问其相邻节点 3。由节点 3 去询问节点 2 的健康情况,最后将此信息由节点 3 返回给节点 1。</p>
|
||||
<p>这种算法的好处是不需要将心跳检测进行广播,而是通过有限的网络连接,就可以检测到集群中各个分组内的健康情况,从而得知整个集群的健康情况。此种方法由于使用了组内的多个节点进行检测,其算法的准确度相比于一个节点去检测提高了很多。同时我们可以并行进行检测,算法的收敛速度也是很快的。因此可以说,<strong>间接检测法在准确度和效率上取得了比较好的平衡</strong>。</p>
|
||||
|
@ -202,7 +202,7 @@ function hide_canvas() {
|
||||
<p>现在我就和你一起,把一致性模型的知识体系补充完整。</p>
|
||||
<h3>完整的一致性模型</h3>
|
||||
<p>完整的一致性模型如下图所示。</p>
|
||||
<p><img src="assets/Cgp9HWBCAs-AXQ4kAABf1EJoKHo006.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBCAs-AXQ4kAABf1EJoKHo006.png" alt="png" /></p>
|
||||
<p>图中不同的颜色代表了可用性的程度,下面我来具体说说。</p>
|
||||
<ol>
|
||||
<li>粉色代表网络分区后完全不可用。也就是 CP 类的数据库。</li>
|
||||
@ -265,7 +265,7 @@ function hide_canvas() {
|
||||
<p>由于目前 CRDT 算法仍然处于高速发展的阶段,为了方便你理解,我这里选取携程网内部 Redis 集群一致性方案,它的技术选型相对实用。如果你对 CRDT 有兴趣,可以进一步研究,这里就不对诸如 PN-Counter、G-Set 等做进一步说明了。</p>
|
||||
<p>由于 Redis 最常用的处理手段是设置字符串数据,故需要使用 CRDT 中的 register 进行处理。携程团队选择了经典的 LWW Regsiter,也就是最后写入胜利的冲突处理方案。</p>
|
||||
<p>这种方案,最重要的是数据上需要携带时间戳。我们用下图来说明它的流程。</p>
|
||||
<p><img src="assets/Cgp9HWBCAuiAPfgGAABs8POB6vo270.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBCAuiAPfgGAABs8POB6vo270.png" alt="png" /></p>
|
||||
<p>从图中可以看到,每个节点的数据是一个二元组,分别是 value 和 timestamp。可以看到节点间合并数据是根据 timestamp,也就是谁的 timestamp 大,合并的结果就以哪个值为准。使用 LWW Register 可以保证高并发下合并结果最终一致。</p>
|
||||
<p>而删除时,就需要另外一种算法了。那就是 Observed-Remove SET(OR Set),其主要的目的是解决一般算法无法删除后重新增加该值的情况。</p>
|
||||
<p>它相较于 LWW-Register 会复杂一些,除了时间戳以外,还需要给每个值标记一个唯一的 tag。比如上图中 P1 设置(1,3),实际需要设置(1α,3);而后如果删除 1,集合就为空;再添加 1 时,标签就需要与一开始不同,为(1β,5)。这样就保证步骤 2 中的删除操作不会影响步骤 3 中的增加操作。因为它们虽然数值相同,但是标签不同,所以都是唯一的。</p>
|
||||
|
@ -210,7 +210,7 @@ function hide_canvas() {
|
||||
<p>随着熵逐步增加,系统进入越来越混乱的状态。但是如果没有读取操作,这种混乱其实是不会暴露出去的。那么人们就有了一个思路,我们可以在读取操作发生的时候再来修复不一致的数据。</p>
|
||||
<p>具体操作是,请求由一个总的协调节点来处理,这个协调节点会从一组节点中查询数据,如果这组节点中某些节点有数据缺失,该协调节点就会把缺失的数据发送给这些节点,从而修复这些节点中的数据,达到反熵的目的。</p>
|
||||
<p>有的同学可能会发现,这个思路与上一讲的可调节一致性有一些关联。因为在可调节一致性下,读取操作为了满足一致性要求,会从多个节点读取数据从而发现最新的数据结果。而读修复会更进一步,在此以后,会将落后节点数据进行同步修复,最后将最新结果发送回客户端。这一过程如下图所示。</p>
|
||||
<p><img src="assets/CioPOWBIL8yAMlGZAAGZjMMkMrI651.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CioPOWBIL8yAMlGZAAGZjMMkMrI651.png" alt="png" /></p>
|
||||
<p>当修复数据时,读修复可以使用阻塞模式与异步模式两种。阻塞模式如上图所示,在修复完成数据后,再将最终结果返还给客户端;而异步模式会启动一个异步任务去修复数据,而不必等待修复完成的结果,即可返回到客户端。</p>
|
||||
<p>你可以回忆一下,阻塞的读修复模式其实满足了上一讲中客户端一致性提到的<strong>读单增</strong>。因为一个值被读取后,下一次读取数据一定是基于上一次读取的。也就是说,同步修复的数据可以保证在下一次读取之前就被传播到目标节点;而异步修复就没有如此保证。但是阻塞修复同时丧失了一定的可用性,因为它需要等待远程节点修复数据,而异步修复就没有此问题。</p>
|
||||
<p>在进行消息比较的时候,我们有一个优化的手段是使用散列来比较数据。比如协调节点收到客户端请求后,只向一个节点发送读取请求,而向其他节点发送散列请求。而后将完全请求的返回值进行散列计算,与其他节点返回的散列值进行比较。如果它们是相等的,就直接返回响应;如果不相等,将进行上文所描述的修复过程。</p>
|
||||
@ -218,7 +218,7 @@ function hide_canvas() {
|
||||
<p>以上就是在读取操作中进行的反熵操作,那么在写入阶段我们如何进行修复呢?下面我来介绍暗示切换。</p>
|
||||
<h4>暗示切换</h4>
|
||||
<p>暗示切换名字听起来很玄幻。其实原理非常明了,让我们看看它的过程,如下图所示。</p>
|
||||
<p><img src="assets/Cgp9HWBIL9WALPvqAAGcHTvEnf0629.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBIL9WALPvqAAGcHTvEnf0629.png" alt="png" /></p>
|
||||
<p>客户端首先写入协调节点。而后协调节点将数据分发到两个节点中,这个过程与可调节一致性中的写入是类似的。正常情况下,可以保证写入的两个节点数据是一致的。如果其中的一个节点失败了,系统会启动一个新节点来接收失败节点之后的数据,这个结构一般会被实现为一个队列(Queue),即暗示切换队列(HHQ)。</p>
|
||||
<p>一旦失败的节点恢复了回来,HHQ 会把该节点离线这一个时间段内的数据同步到该节点中,从而修复该节点由于离线而丢失的数据。这就是在写入节点进行反熵的操作。</p>
|
||||
<p>以上介绍的前台同步操作其实都有一个限制,就是需要假设此种熵增过程发生的概率不高且范围有限。如果熵增大范围产生,那么修复读会造成读取延迟增高,即使使用异步修复也会产生很高的冲突。而暗示切换队列的问题是其容量是有限的,这意味着对于一个长期离线的节点,HHQ 可能无法保存其全部的消息。</p>
|
||||
@ -228,7 +228,7 @@ function hide_canvas() {
|
||||
<p>而后台方案与前台方案的关注点是不同的。前台方案重点放在修复数据,而后台方案由于需要比较和处理大量的非活跃数据,故需要重点解决如何使用更少的资源来进行数据比对。我将要为你介绍两种比对技术:Merkle 树和位图版本向量。</p>
|
||||
<h4>Merkle 树</h4>
|
||||
<p>如果想要检查数据的差异,我们一般能想到最直观的方式是进行全量比较。但这种思路效率是很低的,在实际生产中不可能实行。而通过 Merkle 树我们可以快速找到两份数据之间的差异,下图就是一棵典型的 Merkle 树。</p>
|
||||
<p><img src="assets/Cgp9HWBIL96AR7YaAAA7C1vVQBU503.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBIL96AR7YaAAA7C1vVQBU503.png" alt="png" /></p>
|
||||
<p>树构造的过程是:</p>
|
||||
<ol>
|
||||
<li>将数据划分为多个连续的段。而后计算每个段的哈希值,得到 hash1 到 hash4 这四个值;</li>
|
||||
@ -240,7 +240,7 @@ function hide_canvas() {
|
||||
<h4>位图版本向量</h4>
|
||||
<p>最近的研究发现,大部分数据差异还是发生在距离当前时间不远的时间段。那么我们就可以针对此种场景进行优化,从而避免像 Merkle 树那样计算全量的数据。而位图版本向量就是根据这个想法发展起来的。</p>
|
||||
<p>这种算法利用了位图这一种对内存非常友好的高密度数据格式,将节点近期的数据同步状态记录下来;而后通过比较各个节点间的位图数据,从而发现差异,修复数据。下面我用一个例子为你展示这种算法的执行过程,请看下图。</p>
|
||||
<p><img src="assets/CioPOWBIL-eAF5kCAAAo07ziIqo508.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CioPOWBIL-eAF5kCAAAo07ziIqo508.png" alt="png" /></p>
|
||||
<p>如果有三个节点,每个节点包含了一组与其他节点数据同步的向量。上图表示节点 2 的数据同步情况。目前系统中存在 8 条数据,从节点 2 的角度看,每个节点都没有完整的数据。其中深灰色的部分表明同步的数据是连续的,我们用一个压缩的值表示。节点 1 到 3 这个压缩的值分别为 3、5 和 2。可以看到节点 2 自己的数据是连续的。</p>
|
||||
<p>数据同步一旦出现不连续的情况,也就是出现了空隙,我们就转而使用位图来存储。也就是图中浅灰色和白色的部分。比如节点 2 观察节点 1,可以看到有三个连续的数据同步,而后状态用 00101 来表示(浅灰色代表 1,白色代表 0)。其中 1 是数据同步了,而 0 是数据没有同步。节点 2 可以从节点 1 和节点 3 获取完整的 8 条数据。</p>
|
||||
<p>这种向量列表除了具有内存优势外,我们还可以很容易发现需要修复数据的目标。但是它的一个明显缺点与暗示切换队列 HHQ 类似,就是存储是有限的,如果数据偏差非常大,向量最终会溢出,从而不能比较数据间的差异。但不要紧,我们可以用上面提到的 Merkle 来进行全量比较。</p>
|
||||
|
@ -239,15 +239,15 @@ function hide_canvas() {
|
||||
<li>bal:lock 存 start_ts=>(primary cell),Primary cell 是 Rowkey 和列名的组合,它在提交容错处理和事务冲突时使用,用来清理由于协调器失败导致的事务失败留下的锁信息。</li>
|
||||
</ol>
|
||||
<p>我们现在用一个例子来介绍一下整个过程,请看下图。</p>
|
||||
<p><img src="assets/Cgp9HWBPCKiAbXCoAAB0tHTHvis535.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBPCKiAbXCoAAB0tHTHvis535.png" alt="png" /></p>
|
||||
<p>一个账户表中,Bob 有 10 美元,Joe 有 2 美元。我们可以看到 Bob 的记录在 write 字段中最新的数据是 <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="f793968396b7c2">[email protected]</a>,它表示当前最新的数据是 ts=5 那个版本的数据,ts=5 版本中的数据是 10 美元,这样读操作就会读到这个 10 美元。同理,Joe 的账号是 2 美元。</p>
|
||||
<p><img src="assets/Cgp9HWBPCK-ABB4wAAC8TLGF6II238.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBPCK-ABB4wAAC8TLGF6II238.png" alt="png" /></p>
|
||||
<p>现在我们要做一个转账操作,从 Bob 账户转 7 美元到 Joe 账户。这需要操作多行数据,这里是两行。首先需要加锁,Percolator 从要操作的行中随机选择一行作为 Primary Row,其余为 Secondary Row。对 Primary Row 加锁,成功后再对 Secondary Row 加锁。从上图我们看到,在 ts=7 的行 lock 列写入了一个锁:I am primary,该行的 write 列是空的,数据列值为 3(10-7=3)。 此时 ts=7 为 start_ts。</p>
|
||||
<p><img src="assets/Cgp9HWBPCLqAAl_ZAAE417OCMCw175.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBPCLqAAl_ZAAE417OCMCw175.png" alt="png" /></p>
|
||||
<p>然后对 Joe 账户加锁,同样是 ts=7,在 Joe 账户的加锁信息中包含了指向 Primary lock 的引用,如此这般处于同一个事务的行就关联起来了。Joe 的数据列写入 9(2+7=9),write 列为空,至此完成 Prewrite 阶段。</p>
|
||||
<p><img src="assets/Cgp9HWBPCMKAR9C1AAEYmGw4fnE874.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBPCMKAR9C1AAEYmGw4fnE874.png" alt="png" /></p>
|
||||
<p>接下来事务就要 Commit 了。Primary Row 首先执行 Commit,只要 Primary Row Commit 成功了,事务就成功了。Secondary Row 失败了也不要紧,后续会有补救措施。Commit 操作首先清除 Primary Row 的锁,然后写入 ts=8 的行(因为时间是单向递增的,这里是 commit_ts),该行可以称为 Commit Row,因为它不包含数据,只是在 write 列中写入 <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4f2b2e3b2e0f78">[email protected]</a>,标识 ts=7 的数据已经可见了,此刻以后的读操作可以读到版本 ts=7 的数据了。</p>
|
||||
<p><img src="assets/CioPOWBPCMmARbSjAAC7HQrDF0I862.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CioPOWBPCMmARbSjAAC7HQrDF0I862.png" alt="png" /></p>
|
||||
<p>接下来就是 commit Secondary Row 了,和 Primary Row 的逻辑是一样的。Secondary Row 成功 commit,事务就完成了。</p>
|
||||
<p>如果 Primary Row commit 成功,Secondary Row commit 失败会怎么样,数据的一致性如何保障?由于 Percolator 没有中心化的事务管理器组件,处理这种异常,只能在下次读操作发起时进行。如果一个读请求发现要读的数据存在 Secondary 锁,它会根据 Secondary Row 锁去检查其对应的 Primary Row 的锁是不是还存在,若存在说明事务还没有完成;若不存在则说明,Primary Row 已经 Commit 了,它会清除 Secondary Row 的锁,使该行数据变为可见状态(commit)。这是一个 Roll forward 的概念。</p>
|
||||
<p>我们可以看到,在这样一个存储系统中,并非所有的行都是数据,还包含了一些事务控制行,或者称为 Commit Row。它的数据 Column 为空,但 write 列包含了可见数据的 TS。它的作用是标示事务完成,并指引读请求读到新的数据。随着时间的推移,会产生大量冗余的数据行,无用的数据行会被 GC 线程定时清理。</p>
|
||||
|
@ -216,7 +216,7 @@ function hide_canvas() {
|
||||
<li>快照读:顾名思义,Spanner 实现了 MVCC 和快照隔离,故读取操作在整个事务内部是一致的。同时这也暗示了,Spanner 可以保存同一份数据的多个版本。</li>
|
||||
</ol>
|
||||
<p>了解了事务模型后,我们深入其内部,看看 Spanner 的核心组件都有哪些。下面是一张 Spanner 的架构图。</p>
|
||||
<p><img src="assets/Cgp9HWBRzJSAaOkIAAHp1kcPK04475.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBRzJSAaOkIAAHp1kcPK04475.png" alt="png" /></p>
|
||||
<p>其中我们看到,每个 replica 保存了多个 tablet;同时这些 replica 组成了 Paxos Group。Paxos Group 选举出一个 leader 用来在多分片事务中与其他 Paxos Group 的 leader 进行协调(有关 Paxos 算法的细节我将在下一讲中介绍)。</p>
|
||||
<p>写入操作必须通过 leader 来进行,而读取操作可以在任何一个同步完成的 replica 上进行。同时我们看到 leader 中有锁管理器,用来实现并发控制中提到的锁管理。事务管理器用来处理多分片分布式事务。当进行同步写入操作时,必须要获取锁,而快照读取操作是无锁操作。</p>
|
||||
<p>我们可以看到,最复杂的操作就是多分片的写入操作。其过程就是由 leader 参与的两阶段提交。在准备阶段,提交的数据写入到协调器的 Paxos Group 中,这解决了如下两个问题。</p>
|
||||
@ -233,7 +233,7 @@ function hide_canvas() {
|
||||
<p>Spanner 引入了很多新技术去改善分布式事务的性能,但我们发现其流程整体还是传统的二阶段提交,并没有在结构上发生重大的改变,而 Calvin 却充满了颠覆性。让我们来看看它是怎么处理分布式事务的。</p>
|
||||
<p>首先,传统分布式事务处理使用到了锁来保证并发竞争的事务满足隔离级别的约束。比如,序列化级别保证了事务是一个接一个运行的。而每个副本的执行顺序是无法预测的,但结果是可以预测的。Calvin 的方案是让事务在每个副本上的执行顺序达到一致,那么执行结果也肯定是一致的。这样做的好处是避免了众多事务之间的锁竞争,从而大大提高了高并发度事务的吞吐量。同时,节点崩溃不影响事务的执行。因为事务执行步骤已经分配,节点恢复后从失败处接着运行该事务即可,<strong>这种模式使分布式事务的可用性也大大提高</strong>。目前实现了 Calvin 事务模式的数据库是 FaunaDB。</p>
|
||||
<p>其次,将事务进行排序的组件被称为 sequencer。它搜集事务信息,而后将它们拆解为较小的 epoch,这样做的目的是减小锁竞争,并提高并行度。一旦事务被准备好,sequencer 会将它们发送给 scheduler。scheduler 根据 sequencer 处理的结果,适时地并行执行部分事务步骤,同时也保证顺序执行的步骤不会被并行。因为这些步骤已经排好了顺序,scheduler 执行的时候不需要与 sequencer 进行交互,从而提高了执行效率。Calvin 事务的处理组件如下图所示。</p>
|
||||
<p><img src="assets/Cgp9HWBRzJ-AHNkNAAIDRYF-wko605.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBRzJ-AHNkNAAIDRYF-wko605.png" alt="png" /></p>
|
||||
<p>Calvin 也使用了 Paxos 算法,不同于 Spanner 每个分片有一个 Paxos Group。Calvin 使用 Paxos 或者异步复制来决定哪个事务需要进入哪个 epoch 里面。</p>
|
||||
<p>同时 Calvin 事务有 read set 和 write set 的概念。前者表示事务需要读取的数据,后者表示事务影响的数据。这两个集合需要在事务开始前就进行确定,故<strong>Calvin 不支持在事务中查询动态数据而后影响最终结果集的行为。这一点很重要,是这场战争的核心</strong>。</p>
|
||||
<p>在你了解了两种事务模型之后,我就要带你进入“刺激战场”了。在两位实力相当的选手中,Calvin 一派首先挑起了战争。</p>
|
||||
|
@ -213,7 +213,7 @@ function hide_canvas() {
|
||||
<h3>TiDB:使用乐观事务打造悲观事务</h3>
|
||||
<p>在分布式事务那一讲,我提到 TiDB 的乐观事务使用了 Google 的 Percolator 模式,同时 TiDB 也对该模式进行了改进。可以说一提到 Percolator 模式事务的数据库,国内外都绕不过 TiDB。</p>
|
||||
<p>TiDB 在整体架构上基本参考了 Google Spanner 和 F1 的设计,分两层为 TiDB 和 TiKV。 TiDB 对应的是 Google F1,是一层无状态的 SQL Layer,兼容绝大多数 MySQL 语法,对外暴露 MySQL 网络协议,负责解析用户的 SQL 语句,生成分布式的 Query Plan,翻译成底层 Key Value 操作发送给 TiKV。TiKV 是真正的存储数据的地方,对应的是 Google Spanner,是一个分布式 Key Value 数据库,支持弹性水平扩展,自动地灾难恢复和故障转移,以及 ACID 跨行事务。下面的图展示了 TiDB 的架构。</p>
|
||||
<p><img src="assets/CioPOWBbAfyAX5EPAAHEn31cNYM835.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CioPOWBbAfyAX5EPAAHEn31cNYM835.png" alt="png" /></p>
|
||||
<p>对于事务部分,TiDB 实现悲观事务的方式是非常简洁的。其团队在仔细研究了 Percolator 的模型后发现,其实只要将在客户端调用 Commit 时候进行两阶段提交这个行为稍微改造一下,将第一阶段上锁和等锁提前到事务中执行 DML 的过程中,就可以简单高效地支持悲观事务场景。</p>
|
||||
<p>TiDB 的悲观锁实现的原理是,在一个事务执行 DML(UPDATE/DELETE)的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,等到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程和原来保持一致即可。</p>
|
||||
<p>这个方案在很大程度上兼容了原有的事务实现,其扩展性、高可用和灵活性都有保证。同时该方案尽最大可能复用了原有 Percolator 的乐观事务方案,减少了事务模型整体的复杂度。</p>
|
||||
@ -239,11 +239,11 @@ function hide_canvas() {
|
||||
<p>Cassandra 的可调节一致性如我在本模块一致性那一讲介绍的一样,分为写一致性与读一致性。</p>
|
||||
<h4>写一致性</h4>
|
||||
<p>写一致性声明了需要写入多少个节点才算一次成功的写入。Cassandra 的写一致性是可以在强一致到弱一致之间进行调整的。我总结了下面的表格来为你说明。</p>
|
||||
<p><img src="assets/CioPOWBbAgqAAj2MAACioCvzYAU571.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CioPOWBbAgqAAj2MAACioCvzYAU571.png" alt="png" /></p>
|
||||
<p>我们可以看到 ANY 级别实际上对应了最终一致性。Cassandra 使用了反熵那一讲提到的暗示切换技术来保障写入的数据的可靠,也就是写入节点一旦失败,数据会暂存在暗示切换队列中,等到节点恢复后数据可以被还原出来。</p>
|
||||
<h4>读一致性</h4>
|
||||
<p>对于读操作,一致性级别指定了返回数据之前必须有多少个副本节点响应这个读查询。这里同样给你整理了一个表格。</p>
|
||||
<p><img src="assets/CioPOWBbAhKAF13EAACR3twAd-Q592.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CioPOWBbAhKAF13EAACR3twAd-Q592.png" alt="png" /></p>
|
||||
<p>Cassandra 在读取的时候使用了读修复来修复副本上的过期数据,该修复过程是一个后台线程,故不会阻塞读取。</p>
|
||||
<p>以上就是 Apache Cassandra 实现可调节一致性的一些细节。AWS 的 DynamoDB、Azure 的 CosmosDB 都有类似的可调节一致性供用户进行选择。你可以比照 Cassandra 的模式和这些数据库的文档进行学习。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -248,10 +248,10 @@ function hide_canvas() {
|
||||
<p>单体开源数据要向分布式数据库演进,就要解决写入性能不足的问题。</p>
|
||||
<p>最简单直接的办法就是分库分表。分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库。代理节点分为“客户端 + 单体数据库”和“中间件 + 单体数据库”两个模式。</p>
|
||||
<p>客户端组件 + 单体数据库通过独立的逻辑层建立数据分片和路由规则,实现单体数据库的初步管理,使应用能够对接多个单体数据库,实现并发、存储能力的扩展。其作为应用系统的一部分,对业务侵入比较深。这种客户端组件的典型产品是 Apache ShardingShpere 的 JDBC 客户端模式,下图就是该模式的架构图。</p>
|
||||
<p><img src="assets/CioPOWBhewWAL2D3AADkzhnu3iE396.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CioPOWBhewWAL2D3AADkzhnu3iE396.png" alt="png" /></p>
|
||||
<p>Apache ShardingShpere 的 JDBC 客户端模式架构图</p>
|
||||
<p>代理中间件 + 单体数据库以独立中间件的方式,管理数据规则和路由规则,以独立进程存在,与业务应用层和单体数据库相隔离,减少了对应用的影响。随着代理中间件的发展,还会衍生出部分分布式事务处理能力。这种中间件的典型产品是 MyCat、Apache ShardingShpere 的 Proxy 模式。</p>
|
||||
<p><img src="assets/CioPOWBhexWAcIBeAAJA7FyRXw0760.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CioPOWBhexWAcIBeAAJA7FyRXw0760.png" alt="png" /></p>
|
||||
<p>Apache ShardingShpere 的 Proxy 模式架构图</p>
|
||||
<p>代理节点需要实现三个主要功能,它们分别是客户端接入、简单的查询处理器和进程管理中的访问控制。另外,分库分表方案还有一个重要的功能,那就是分片信息管理,分片信息就是数据分布情况。不过考虑分片信息也存在多副本的一致性的问题,大多数情况下它会独立出来。显然,如果把每一次的事务写入都限制在一个单体数据库内,业务场景就会很受局限。</p>
|
||||
<p>因此,跨库事务成为必不可少的功能,但是单体数据库是不感知这个事情的,所以我们就要在代理节点增加分布式事务组件。同时,简单的分库分表不能满足全局性的查询需求,因为每个数据节点只能看到一部分数据,有些查询运算是无法处理的,比如排序、多表关联等。所以,代理节点要增强查询计算能力,支持跨多个单体数据库的查询。更多相关内容我会在下一讲介绍。</p>
|
||||
|
@ -216,7 +216,7 @@ function hide_canvas() {
|
||||
<p>下面就按照我给出的定义中的关键点来向你详细介绍 NewSQL 数据库。</p>
|
||||
<h3>创新的架构</h3>
|
||||
<p>使用创新的数据库架构是 NewSQL 数据库非常引人注目的特性。这种新架构一般不会依靠任何遗留的代码,这与我在“22 | 发展与局限:传统数据库在分布式领域的探索”中介绍的依赖传统数据库作为计算存储节点非常不同。我们以 TiDB 这个典型的 NewSQL 数据库为例。</p>
|
||||
<p><img src="assets/Cgp9HWBmw-WATtXZAAHEn31cNYM969.png" alt="image.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBmw-WATtXZAAHEn31cNYM969.png" alt="png" /></p>
|
||||
<p>可以看到其中的创新点有以下几个。</p>
|
||||
<ol>
|
||||
<li>存储引擎没有使用传统数据库。而使用的是新型基于 LSM 的 KV 分布式存储引擎,有些数据库使用了完全内存形式的存储引擎,比如 NuoDB。</li>
|
||||
|
@ -211,7 +211,7 @@ function hide_canvas() {
|
||||
<li>高效的异地数据同步。</li>
|
||||
</ol>
|
||||
<p>如下面的架构图所示,应用层通过 Cobar 访问数据库。</p>
|
||||
<p><img src="assets/Cgp9HWBzrSaABPOUAAH9MECqjKQ062.png" alt="image.png" /></p>
|
||||
<p><img src="assets/Cgp9HWBzrSaABPOUAAH9MECqjKQ062.png" alt="png" /></p>
|
||||
<p>其对数据库的访问分为读操作(select)和写操作(update、insert和delete)。写操作会在数据库上产生变更记录,MySQL 的变更记录叫 binlog,Oracle 的变更记录叫 redolog。Erosa 产品解析这些变更记录,并以统一的格式缓存至 Eromanga 中,后者负责管理变更数据的生产者、Erosa 和消费者之间的关系,负责跨机房数据库同步的 Otter 是这些变更数据的消费者之一。</p>
|
||||
<p>Cobar 可谓 OLTP 分布式数据库解决方案的先驱,至今其中的思想还可以从现在的中间件,甚至 NewSQL 数据库中看到。但在阿里集团服役三年后,由于人员变动而逐步停止维护。这个时候 MyCAT 开源社区接过了该项目的衣钵,在其上增加了诸多功能并进行 bug 修改,最终使其在多个行业中占用自己的位置。</p>
|
||||
<p>但是就像我曾经介绍的那样,中间件产品并不是真正的分布式数据库,它有自己的局限。比如 SQL 支持、查询性能、分布式事务、运维能力,等等,都有不可逾越的天花板。而有一些中间件产品幸运地得以继续进阶,最终演化为 NewSQL,甚至是云原生产品。阿里云的 PolarDB 就是这种类型的代表,它的前身是阿里云的分库分表中间件产品 DRDS,而 DRDS 来源于淘宝系的 TDDL 中间件。</p>
|
||||
|
@ -165,10 +165,10 @@ function hide_canvas() {
|
||||
<h4>第 3 幕:谁来拯救微服务</h4>
|
||||
<p>2015 年,互联网技术的飞速发展带给了我们无限发展的空间。越来越多的行业在思考:如何转型互联网?如何开展互联网业务?这时,一个互联网转型的利器——微服务,它恰恰能够帮助很多行业很好地应对互联网业务。于是乎,我们加入了微服务转型的滚滚洪流之中。</p>
|
||||
<p>但是,<strong>微服务也不是银弹,它也有很多的“坑”</strong>。</p>
|
||||
<p><img src="assets/Ciqc1F-yNB-ADKSxAAUaE9dRRdU695.png" alt="ddd.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-yNB-ADKSxAAUaE9dRRdU695.png" alt="png" /></p>
|
||||
<p>当按照模块拆分微服务以后才发现,每次变更都需要修改多个微服务,不但多个团队都要变更,还要同时打包、同时升级,不仅没有降低维护成本,还使得系统的发布比过去更麻烦,真不如不用微服务。<strong>是微服务不好吗</strong>?我又陷入了沉思。</p>
|
||||
<p>这时我才注意到 <strong>Martin Flower 在定义微服务时提到的“小而专”,很多人理解了“小”却忽略了“专”,就会带来微服务系统难于维护的糟糕境地</strong>。这里的“专”,就是要“小团队独立维护”,也就是尽量让每次的需求变更交给某个小团队独立完成,让需求变更落到某个微服务上进行变更,唯有这样才能发挥微服务的优势。</p>
|
||||
<p><img src="assets/Ciqc1F-yIsyAONSrAAB8S9PQpFM405.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-yIsyAONSrAAB8S9PQpFM405.png" alt="png" /></p>
|
||||
<p>通过这样的一番解析,才发现微服务的设计真的不仅仅是一个技术架构更迭的事情,而是对原有的设计提出了更高的要求,即“微服务内高内聚,微服务间低耦合”。<strong>如何才能更好地做到这一点呢?答案还是 DDD。</strong></p>
|
||||
<p>我们转型微服务的重要根源之一就是系统的复杂性,即系统规模越来越大,维护越来越困难,才需要拆分微服务。然而,拆分成微服务以后,并不意味着每个微服务都是各自独立地运行,而是彼此协作地组织在一起。这就好像一个团队,规模越大越需要一些方法来组织,而 DDD 恰恰就是那个帮助我们组织微服务的实践方法。</p>
|
||||
<h4>第 4 幕:DDD,想说爱你不容易</h4>
|
||||
@ -176,7 +176,7 @@ function hide_canvas() {
|
||||
<p>有了这个技术中台的支持,开发团队就可以把更多的精力放到对用户业务的理解,对业务痛点的理解,快速开发用户满意的功能并快速交付上。这样,不仅编写代码减少了,技术门槛降低了,还使得日后的变更更加容易,技术更迭也更加方便。因此,我又开始苦苦求索。</p>
|
||||
<p>很快,Bob 大叔的整洁架构(Clean Architecture)给了我全新的思路。整洁架构最核心的是业务(图中的黄色与红色部分),即我们通过领域模型分析,最后形成的那些 Service、Entity 与 Value Object。</p>
|
||||
<p>然而,整洁架构最关键的设计思想是通过一系列的适配器(图中的绿色部分),将业务代码与技术框架解耦。通过这样的解耦,上层业务开发人员更专注地去开发他们的业务代码,技术门槛得到降低;底层平台架构师则更低成本地进行架构演化,不断地跟上市场与技术的更迭。唯有这样,才能跟上日益激烈的市场竞争。</p>
|
||||
<p><img src="assets/Ciqc1F-yItuAPhLzAAEmftrVMI8732.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-yItuAPhLzAAEmftrVMI8732.png" alt="png" /></p>
|
||||
<p>图片来自 Robert C. Martin 的《架构整洁之道》</p>
|
||||
<p>不仅如此,我在实践摸索过程中,还创新性地提出了单 Controller、通用仓库、通用工厂,以及完美支持 DDD + 微服务的技术中台架构设计。通过这些设计,开发团队能够更好地将 DDD 落地到项目开发中,真正地打造出一支支理解业务、高质量开发与快速交付的团队。</p>
|
||||
<h3>这门课能让你学到什么?</h3>
|
||||
|
@ -158,7 +158,7 @@ function hide_canvas() {
|
||||
<p>然而,在面对全新业务、全新增长点的时候,我们能不能把握住这样的机遇呢?我们期望能把握住,但每次回到现实,回到正在维护的系统时,却令人沮丧。我们的软件总是经历着这样的轮回,<strong>软件设计质量最高的时候是第一次设计的那个版本,当第一个版本设计上线以后就开始各种需求变更,这常常又会打乱原有的设计。</strong></p>
|
||||
<p>因此,需求变更一次,软件就修改一次,软件修改一次,质量就下降一次。不论第一次的设计质量有多高,软件经历不了几次变更,就进入一种低质量、难以维护的状态。进而,团队就不得不在这样的状态下,以高成本的方式不断地维护下去,维护很多年。</p>
|
||||
<p>这时候,维护好原有的业务都非常不易,又如何再去期望未来更多的全新业务呢?比如,这是一段电商网站支付功能的设计,最初的版本设计质量还是不错的:</p>
|
||||
<p><img src="assets/Ciqc1F-uW4mAMW8uAACi9O_bw00391.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uW4mAMW8uAACi9O_bw00391.png" alt="png" /></p>
|
||||
<p>当第一个版本上线以后,很快就迎来了第一次变更,变更的需求是增加商品折扣功能,并且这个折扣功能还要分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣。当我们拿到这个需求时怎么做呢?很简单,增加一个 if 语句,if 限时折扣就怎么怎么样,if 限量折扣就怎么怎么样……代码开始膨胀了。</p>
|
||||
<p>接着,第二次变更需要增加 VIP 会员,除了增加各种金卡、银卡的折扣,还要为会员发放各种福利,让会员享受各种特权。为了实现这些需求,我们又要在 payoff() 方法中加入更多的代码。</p>
|
||||
<p>第三次变更增加的是支付方式,除了支付宝支付,还要增加微信支付、各种银行卡支付、各种支付平台支付,此时又要塞入一大堆代码。经过这三次变更,你可以想象现在的 payoff() 方法是什么样子了吧,变更是不是就可以结束了呢?其实不能,接着还要增加更多的秒杀、预订、闪购、众筹,以及各种返券。程序变得越来越乱而难以阅读,每次变更也变得越来越困难。</p>
|
||||
@ -171,7 +171,7 @@ function hide_canvas() {
|
||||
<p><strong>在我们不断地修复 Bug,实现新需求的过程中,软件的业务逻辑也会越来越接近真实世界,使得我们的软件越来越专业,让用户感觉越来越好用。但是,在软件越来越接近真实世界的过程中,业务逻辑就会变得越来越复杂,软件规模也越来越庞大</strong>。</p>
|
||||
<p>你一定有这样一个认识:简单软件有简单软件的设计,复杂软件有复杂软件的设计。</p>
|
||||
<p>比如,现在的需求就是将用户订单按照“单价 × 数量”公式来计算应付金额,那么在一个 PaymentBus 类中增加一个 payoff() 方法即可,这样的设计没有问题。不过,如果现在的需求需要在付款的过程中计算各种折扣、各种优惠、各种返券,那么我们必然会做成一个复杂的程序结构。</p>
|
||||
<p><img src="assets/Ciqc1F-x5CiASyG1AAFOkD8Rz1U365.png" alt="Lark20201116-102936.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-x5CiASyG1AAFOkD8Rz1U365.png" alt="png" /></p>
|
||||
<p>但是,真实情况却不是这样的。真实情况是,起初我们拿到的需求是那个简单需求,然后在简单需求的基础上进行了设计开发。但随着软件的不断变更,软件业务逻辑变得越来越复杂,软件规模不断扩大,逐渐由一个简单软件转变成一个复杂软件。</p>
|
||||
<p>这时,如果要保持软件设计质量不退化,就应当逐步调整软件的程序结构,逐渐由简单的程序结构转变为复杂的程序结构。如果我们总是这样做,就能始终保持软件的设计质量,不过非常遗憾的是,我们以往在维护软件的过程中却不是这样做的,而是不断地在原有简单软件的程序结构下,往 payoff() 方法中塞代码,这样做必然会造成软件的退化。</p>
|
||||
<p>也就是说,<strong>软件退化的根源不是软件变更,软件变更只是一个诱因</strong>。如果每次软件变更时,适时地进行解耦,进行功能扩展,再实现新的功能,就能保持高质量的软件设计。但如果在每次软件变更时没有调整程序结构,而是在原有的程序结构上不断地塞代码,软件就会退化。这就是软件发展的规律,软件退化的根源。</p>
|
||||
@ -187,7 +187,7 @@ function hide_canvas() {
|
||||
<li>不折扣</li>
|
||||
</ul>
|
||||
<p>以往我们拿到这个需求,就很不冷静地开始改代码,修改成了如下一段代码:</p>
|
||||
<p><img src="assets/Ciqc1F-uXC2APP3nAAFByY8Jh7U062.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uXC2APP3nAAFByY8Jh7U062.png" alt="png" /></p>
|
||||
<p>这里增加了一段 if 语句,并不是一种好的变更方式。如果每次都这样变更,那么软件必然就会退化,进入难以维护的状态。这种变更为什么就不好呢?因为它违反了“开放-封闭原则”。</p>
|
||||
<p><strong>开放-封闭原则(OCP)</strong> 分为开放原则与封闭原则两部分。</p>
|
||||
<ul>
|
||||
@ -202,7 +202,7 @@ function hide_canvas() {
|
||||
<li>实现新的功能。</li>
|
||||
</ul>
|
||||
<p>按以上案例为例,为了实现新的功能,我们在原有代码的基础上,在不添加新功能的前提下调整原有程序结构,我们抽取出了 Strategy 这样一个接口和“不折扣”这个实现类。这时,原有程序变了吗?没有。但是程序结构却变了,增加了这样一个接口,称为“可扩展点”。在这个可扩展点的基础上再实现各种折扣,既能满足“开放-封闭原则”来保证程序质量,又能够满足新的需求。当日后发生新的变更时,什么类型的折扣就修改哪个实现类,添加新的折扣类型就增加新的实现类,维护成本得到降低。</p>
|
||||
<p><img src="assets/Ciqc1F-uXESAX4mlAAHXdslwIpQ810.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uXESAX4mlAAHXdslwIpQ810.png" alt="png" /></p>
|
||||
<p>“两顶帽子”的设计方式意义重大。过去,我们每次在设计软件时总是担心日后的变更,就很不冷静地设计了很多所谓的“灵活设计”。然而,每一种“灵活设计”只能应对一种需求变更,而我们又不是先知,不知道日后会发生什么样的变更。最后的结果就是,我们期望的变更并没有发生,所做的设计都变成了摆设,它既不起什么作用,还增加了程序复杂度;我们没有期望的变更发生了,原有的程序依然不能解决新的需求,程序又被打回了原形。因此,这样的设计不能真正解决未来变更的问题,被称为“<strong>过度设计</strong>”。</p>
|
||||
<p><strong>有了“两顶帽子”,我们不再需要焦虑,不再需要过度设计</strong>,正确的思路应当是“活在今天的格子里做今天的事儿”,也就是为当前的需求进行设计,使其刚刚满足当前的需求。所谓的“高质量的软件设计”就是要掌握一个平衡,一方面要满足当前的需求,另一方面要让设计刚刚满足需求,从而使设计最简化、代码最少。这样做,不仅软件设计质量提高了,设计难点也得到了大幅度降低。</p>
|
||||
<p>简而言之,<strong>保持软件设计不退化的关键在于每次需求变更的设计,只有保证每次需求变更时做出正确的设计,才能保证软件以一种良性循环的方式不断维护下去。这种正确的设计方式就是“两顶帽子”。</strong></p>
|
||||
|
@ -165,15 +165,15 @@ function hide_canvas() {
|
||||
<li>一个用户可以有多个用户地址,但每个订单只能有一个用户地址;</li>
|
||||
<li>此外,一个订单对应多个订单明细,每个订单明细对应一个商品,每个商品对应一个供应商。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl-ziNWAAo44AAC1mEZLzQ4146.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziNWAAo44AAC1mEZLzQ4146.png" alt="png" /></p>
|
||||
<p>最后,我们对订单可以进行“下单”“付款”“查看订单状态”等操作。因此形成了以下领域模型图:</p>
|
||||
<p><img src="assets/CgqCHl-ziMaANHZ2AABItYXLHGw993.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziMaANHZ2AABItYXLHGw993.png" alt="png" /></p>
|
||||
<p>有了这样的领域模型,就可以通过该模型进行以下程序设计:</p>
|
||||
<p><img src="assets/Ciqc1F-ziN2Ado7rAABZ7uoFaUk291.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ziN2Ado7rAABZ7uoFaUk291.png" alt="png" /></p>
|
||||
<p>通过领域模型的指导,将“订单”分为订单 Service 与值对象,将“用户”分为用户 Service 与值对象,将“商品”分为商品 Service 与值对象……然后,在此基础上实现各自的方法。</p>
|
||||
<h3>商品折扣的需求变更</h3>
|
||||
<p>当电商网站的付款功能按照领域模型完成了第一个版本的设计后,很快就迎来了<strong>第一次需求变更,即增加折扣功能</strong>,并且该折扣功能分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣与不折扣。当我们拿到这个需求时应当怎样设计呢?很显然,在 payoff() 方法中去插入 if 语句是不 OK 的。这时,按照领域驱动设计的思想,应当将需求变更还原到领域模型中进行分析,进而根据领域模型背后的真实世界进行变更。</p>
|
||||
<p><img src="assets/CgqCHl-ziOaAfB4LAAC5Mx6eA2E697.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziOaAfB4LAAC5Mx6eA2E697.png" alt="png" /></p>
|
||||
<p>这是上一个版本的领域模型,现在我们要在这个模型的基础上增加折扣功能,并且还要分为限时折扣、限量折扣、某类商品的折扣等不同类型。这时,我们应当怎么分析设计呢?</p>
|
||||
<p><strong>首先要分析付款与折扣的关系。</strong></p>
|
||||
<p>付款与折扣是什么关系呢?你可能会认为折扣是在付款的过程中进行的折扣,因此就应当将折扣写到付款中。这样思考对吗?我们应当基于什么样的思想与原则来设计呢?这时,另外一个重量级的设计原则应该出场了,那就是“单一职责原则”。</p>
|
||||
@ -195,10 +195,10 @@ function hide_canvas() {
|
||||
<li>……</li>
|
||||
</ul>
|
||||
<p>最后发现,不同类型的折扣也是软件变化不同的原因。将它们放在同一个类、同一个方法中,合适吗?通过以上分析,我们做出了如下设计:</p>
|
||||
<p><img src="assets/CgqCHl-ziPSAEj-iAACZkldRtxU363.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziPSAEj-iAACZkldRtxU363.png" alt="png" /></p>
|
||||
<p>在该设计中,将折扣功能从付款功能中独立出去,做出了一个接口,然后以此为基础设计了各种类型的折扣实现类。这样的设计,当付款功能发生变更时不会影响折扣,而折扣发生变更的时候不会影响付款。同样,当“限时折扣”发生变更时只与“限时折扣”有关,“限量折扣”发生变更时也只与“限量折扣”有关,与其他折扣类型无关。变更的范围缩小了,维护成本就降低了,设计质量提高了。这样的设计就是“单一职责原则”的真谛。</p>
|
||||
<p>接着,在这个版本的领域模型的基础上进行程序设计,在设计时还可以加入一些设计模式的内容,因此我们进行了如下的设计:</p>
|
||||
<p><img src="assets/CgqCHl-ziPyAAJUgAABRUq9tq5Y944.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziPyAAJUgAABRUq9tq5Y944.png" alt="png" /></p>
|
||||
<p>显然,在该设计中加入了“策略模式”的内容,将折扣功能做成了一个折扣策略接口与各种折扣策略的实现类。当哪个折扣类型发生变更时就修改哪个折扣策略实现类;当要增加新的类型的折扣时就再写一个折扣策略实现类,设计质量得到了提高。</p>
|
||||
<h3>VIP 会员的需求变更</h3>
|
||||
<p>在第一次变更的基础上,很快迎来了第二次变更,这次是要<strong>增加 VIP 会员</strong>,业务需求如下。</p>
|
||||
@ -220,13 +220,13 @@ function hide_canvas() {
|
||||
<li>而“付款”与“VIP 会员”的关系是在付款的过程中去调用会员折扣、会员福利与会员特权。</li>
|
||||
</ul>
|
||||
<p>通过以上的分析,我们做出了以下版本的领域模型:</p>
|
||||
<p><img src="assets/Ciqc1F-ziQaAevJdAACdptUfGFQ317.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ziQaAevJdAACdptUfGFQ317.png" alt="png" /></p>
|
||||
<p><strong>有了这些领域模型的变更,然后就可以以此作为基础,指导后面程序代码的变更了。</strong></p>
|
||||
<h3>支付方式的需求变更</h3>
|
||||
<p>同样,第三次变更是<strong>增加更多的支付方式</strong>,我们在领域模型中分析“付款”与“支付方式”之间的关系,发现它们也是软件变化不同的原因。因此,我们果断做出了这样的设计:</p>
|
||||
<p><img src="assets/CgqCHl-ziQ6ABA-UAABxRFWKpk4419.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ziQ6ABA-UAABxRFWKpk4419.png" alt="png" /></p>
|
||||
<p>而在设计实现时,因为要与各个第三方的支付系统对接,也就是要与外部系统对接。为了使第三方的外部系统的变更对我们的影响最小化,在它们中间果断加入了“适配器模式”,设计如下:</p>
|
||||
<p><img src="assets/Ciqc1F-ziRWARy4TAAB8IgTzU7A472.png" alt="Drawing 16.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ziRWARy4TAAB8IgTzU7A472.png" alt="png" /></p>
|
||||
<p>通过加入适配器模式,订单 Service 在进行支付时调用的不再是外部的支付接口,而是“支付方式”接口,与外部系统解耦。只要保证“支付方式”接口是稳定的,那么订单 Service 就是稳定的。比如:</p>
|
||||
<ul>
|
||||
<li>当支付宝支付接口发生变更时,影响的只限于支付宝 Adapter;</li>
|
||||
|
@ -149,10 +149,10 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>03 DDD 是如何落地到数据库设计的?</h1>
|
||||
<p>过去,系统的软件设计是以数据库设计为核心,当需求确定下来以后,团队首先开始进行数据库设计。因为数据库是各个模块唯一的接口,当整个团队将数据库设计确定下来以后,就可以按照模块各自独立地进行开发了,如下图所示。</p>
|
||||
<p><img src="assets/Ciqc1F-3acSAZh7sAABwL1KJalI503.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3acSAZh7sAABwL1KJalI503.png" alt="png" /></p>
|
||||
<p>在上面的过程中,为了提高团队开发速度,尽量让各个模块不要交互,从而达到各自独立开发的效果。但是,随着系统规模越来越大,业务逻辑越来越复杂,我们越来越难于保证各个模块独立不交互了。</p>
|
||||
<p><strong>随着软件业的不断发展,软件系统变得越来越复杂,各个模块间的交互也越来越频繁,这时,原有的设计过程已经不能满足我们的需要了。</strong> 因为如果要先进行数据库设计,但数据库设计只能描述数据结构,而不能描述系统对这些数据结构的处理。因此,在第一次对整个系统的梳理过程中,只能梳理系统的所有数据结构,形成数据库设计;接着,又要再次梳理整个系统,分析系统对这些数据结构的处理过程,形成程序设计。为什么不能一次性地把整个系统的设计梳理到位呢?</p>
|
||||
<p><img src="assets/Ciqc1F-3adKAUmTTAAEESdHRe1A033.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3adKAUmTTAAEESdHRe1A033.png" alt="png" /></p>
|
||||
<p>现如今,我们已经按照<strong>面向对象的软件设计过程</strong>来分析设计系统了。当开始需求分析时,首先进行<strong>用例模型</strong>的设计,分析整个系统要实现哪些功能;接着进行<strong>领域模型</strong>的设计,分析系统的业务实体。在领域模型分析中,采用类图的形式,每个类可以通过它的属性来表述数据结构,又可以通过添加方法来描述对这个数据结构的处理。因此,在领域模型的设计过程中,既完成了对数据结构的梳理,又确定了系统对这些数据结构的处理,这样就把两项工作一次性地完成了。</p>
|
||||
<p>在这个设计过程中,其核心是<strong>领域模型的设计</strong>。以领域模型作为核心,可以指导系统的数据库设计与程序设计,此时,数据库设计就弱化为了领域对象持久化设计的一种实现方式。</p>
|
||||
<h3>领域对象持久化的思想</h3>
|
||||
@ -168,9 +168,9 @@ function hide_canvas() {
|
||||
<p>数据库设计在发生剧烈的变化,但唯一不变的是领域对象。这样,当系统在大数据转型时,可以保证业务代码不变,变化的是<strong>数据访问层</strong>(DAO)。这将使得日后大数据转型的成本更低,让我们更快地跟上技术快速发展的脚步。</p>
|
||||
<h3>领域模型的设计</h3>
|
||||
<p>此外,这里有个有趣的问题值得探讨:领域模型的设计到底是谁的职责,是需求分析人员还是设计开发人员?我认为,它是两个角色相互协作的产物。而未来敏捷开发的组织形成,团队将更加扁平化。过去是需求分析人员做需求分析,然后交给设计人员设计开发,这种方式就使得软件设计质量低下而结构臃肿。未来“大前端”的思想将支持更多设计开发人员直接参与需求分析,实现<strong>从需求分析到设计开发的一体化组织形式</strong>。这样,领域模型就成为了设计开发人员快速理解需求的利器。</p>
|
||||
<p><img src="assets/Ciqc1F-3ad-AASFqAAClQIvmC-A196.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3ad-AASFqAAClQIvmC-A196.png" alt="png" /></p>
|
||||
<p>总之,**DDD 的数据库设计实际上已经变成了:以领域模型为核心,如何将领域模型转换成数据库设计的过程。**那么怎样进行转换呢?在领域模型中是一个一个的类,而在数据库设计中是一个一个的表,因此就是将类转换成表的过程。</p>
|
||||
<p><img src="assets/Ciqc1F-3aeaAZwhMAAD1h66weqU767.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3aeaAZwhMAAD1h66weqU767.png" alt="png" /></p>
|
||||
<p>上图是一个绩效考核系统的领域模型图,该绩效考核系统首先进行自动考核,发现一批过错,然后再给一个机会,让过错责任人对自己的过错进行申辩。这时,过错责任人可以填写一张申辩申请单,在申辩申请单中有多个明细,每个明细对应一个过错行为,每个过错行为都对应了一个过错类型,这样就形成了一个领域模型。</p>
|
||||
<p>接着,要将这个领域模型转换成数据库设计,怎么做呢?很显然,领域模型中的一个类可以转换成数据库中的一个表,类中的属性可以转换成表中的字段。但这里的关键是如何处理类与类之间的关系,如何转换成表与表之间的关系。这时候,就有 5 种类型的关系需要转换,即传统的 4 种关系 + 继承关系。</p>
|
||||
<h4>传统的 4 种关系</h4>
|
||||
@ -178,15 +178,15 @@ function hide_canvas() {
|
||||
<p><strong>1. 一对一关系</strong></p>
|
||||
<p>在以上案例中,“申辩申请单明细”与“过错行为”就是一对“一对一”关系。在该关系中,一个“申辩申请单明细”必须要对应一个“过错行为”,没有一个“过错行为”的对应就不能成为一个“申辩申请单明细”。这种约束在数据库设计时,可以通过外键来实现。但是,一对一关系还有另外一个约束,那就是一个“过错行为”最多只能有一个“申辩申请单明细”与之对应。</p>
|
||||
<p>也就是说,一个“过错行为”可以没有“申辩申请单明细”与之对应,但如果有,最多只能有一个“申辩申请单明细”与之对应,这个约束暗含的是一种<strong>唯一性</strong>的约束。因此,将过错行为表中的主键,作为申辩申请单明细表的外键,并将该字段升级为申辩申请单明细表的主键。</p>
|
||||
<p><img src="assets/Ciqc1F-3afiAclP8AADSpJIgZtk127.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3afiAclP8AADSpJIgZtk127.png" alt="png" /></p>
|
||||
<p><strong>2. 多对一关系</strong></p>
|
||||
<p>是日常的分析设计中最常见的一种关系。在以上案例中,一个过错行为对应一个税务人员、一个纳税人与一个过错类型;同时,一个税务人员,或纳税人,或过错类型,都可以对应多个过错行为。它们就形成了“多对一”关系。在数据库设计时,通过外键就可以建立这种“多对一”关系。因此,我们进行了如下数据库的设计:</p>
|
||||
<p><img src="assets/Ciqc1F-3agKAZjXlAADiEoPJQjY142.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3agKAZjXlAADiEoPJQjY142.png" alt="png" /></p>
|
||||
<p>多对一关系在数据库设计上比较简单,然而落实到程序设计时,需要好好探讨一下。比如,以上案例,在按照这样的方式设计以后,在查询时往往需要在查询过错行为的同时,显示它们对应的税务人员、纳税人与过错类型。这时,以往的设计是增加一个 join 语句。然而,这样的设计在随着数据量不断增大时,查询性能将受到极大的影响。</p>
|
||||
<p>也就是说,join 操作往往是关系型数据库在面对大数据时最大的瓶颈之一。因此,一个更好的方案就是先查询过错行为表,分页,然后再补填当前页的其他关联信息。这时,就需要在“过错行为”这个值对象中通过属性变量,增加对税务人员、纳税人与过错类型等信息的引用。</p>
|
||||
<p><strong>3. 一对多关系</strong></p>
|
||||
<p>该关系往往表达的是一种<strong>主-子表</strong>的关系。譬如,以上案例中的“申辩申请单”与“申辩申请单明细”就是一对“一对多”关系。除此之外,订单与订单明细、表单与表单明细,都是一对多关系。一对多关系在数据库设计上比较简单,就是在子表中增加一个外键去引用主表中的主键。比如本案例中,申辩申请单明细表通过一个外键去引用申辩申请单表中的主键,如下图所示。</p>
|
||||
<p><img src="assets/CgqCHl-3aguAJYCpAAEDn1e1xxo024.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3aguAJYCpAAEDn1e1xxo024.png" alt="png" /></p>
|
||||
<p>除此之外,在程序的值对象设计时,主对象中也应当有一个集合的属性变量去引用子对象。如本例中,在“申辩申请单”值对象中有一个集合属性去引用“申辩申请单明细”。这样,当通过申辩申请单号查找到某个申辩申请单时,同时就可以获得它的所有申辩申请单明细,如下代码所示:</p>
|
||||
<pre><code>
|
||||
```java
|
||||
@ -209,30 +209,30 @@ public class Sbsqd {
|
||||
</code></pre>
|
||||
<p><strong>4. 多对多关系</strong></p>
|
||||
<p>比较典型的例子就是“用户角色”与“功能权限”。一个“用户角色”可以申请多个“功能权限”;而一个“功能权限”又可以分配给多个“用户角色”使用,这样就形成了一个“多对多”关系。这种多对多关系在对象设计时,可以通过一个“功能-角色关联类”来详细描述。因此,在数据库设计时就可以添加一个“角色功能关联表”,而该表的主键就是关系双方的主键进行的组合,形成的联合主键,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-3ahiANZHJAADjMcnHvGU252.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3ahiANZHJAADjMcnHvGU252.png" alt="png" /></p>
|
||||
<p>以上是领域模型和数据库都有的 4 种关系。因此,在数据库设计时,直接将相应的关系转换成数据库设计就可以了。同时,在数据库设计时还要将它们进一步细化。如在领域模型中,不论对象还是属性,在命名时都采用中文,这样有利于沟通与理解。但到了数据库设计时,就要将它们细化为英文命名,或者汉语拼音首字母,同时还要确定它们的字段类型与是否为空等其他属性。</p>
|
||||
<h4>继承关系的 3 种设计</h4>
|
||||
<p><strong>第 5 种关系就不太一样了:继承关系是在领域模型设计中有,但在数据库设计中却没有。</strong> 如何将领域模型中的继承关系转换成数据库设计呢?有 3 种方案可以选择。</p>
|
||||
<p><strong>1. 继承关系的第一种方案</strong></p>
|
||||
<p>首先,看看以上案例。“执法行为”通过继承分为“正确行为”和“过错行为”。如果这种继承关系的子类不多(一般就 2 ~ 3 个),并且每个子类的个性化字段也不多(3 个以内)的话,则可以使用一个表来记录整个继承关系。在这个表的中间有一个<strong>标识字段</strong>,标识表中的每条记录到底是哪个子类,这个字段的前面部分罗列的是父类的字段,后面依次罗列各个子类的个性化字段。</p>
|
||||
<p><img src="assets/CgqCHl-3aieANAcbAADCuMgZxLM844.png" alt="Drawing 16.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3aieANAcbAADCuMgZxLM844.png" alt="png" /></p>
|
||||
<p>采用这个方案的优点是简单,整个继承关系的数据全部都保存在这个表里。但是,它会造成“表稀疏”。在该案例中,如果是一条“正确行为”的记录,则字段“过错类型”与“扣分”永远为空;如果是一条“过错行为”的记录,则字段“加分”永远为空。假如这个继承关系中各子类的个性化字段很多,就会造成该表中出现大量字段为空,称为“表稀疏”。在关系型数据库中,为空的字段是要占用空间的。因此,这种“表稀疏”既会浪费大量存储空间,又会影响查询速度,是需要极力避免的。所以,当子类比较多,或者子类个性化字段多的情况是不适合该方案(第一种方案)的。</p>
|
||||
<p><strong>2. 继承关系的第二种方案</strong></p>
|
||||
<p>如果执法行为按照考核指标的类型进行继承,分为“考核指标1”“考核指标2”“考核指标3”……如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-3ajKAJNKMAACqVt4BcCg820.png" alt="Drawing 18.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3ajKAJNKMAACqVt4BcCg820.png" alt="png" /></p>
|
||||
<p>并且每个子类都有很多的个性化字段,则采用前面那个方案就不合适了。这时,用另外两个方案进行数据库设计。其中一个方案是将每个子类都对应到一个表,有几个子类就有几个表,这些表共用一个主键,即这几个表的主键生成器是一个,某个主键值只能存在于某一个表中,不能存在于多个表中。每个表的前面是父类的字段,后面罗列各个子类的字段,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-3al6ALu26AAEi7O2kSYM838.png" alt="Drawing 20.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3al6ALu26AAEi7O2kSYM838.png" alt="png" /></p>
|
||||
<p>如果业务需求是在前端查询时,每次只能查询某一个指标,那么采用这种方案就能将每次查询落到某一个表中,方案就最合适。但如果业务需求是要查询某个过错责任人涉及的所有指标,则采用这种方案就必须要在所有的表中进行扫描,那么查询效率就比较低,并不适用。</p>
|
||||
<p><strong>3. 继承关系的第三种方案</strong></p>
|
||||
<p>如果业务需求是要查询某个过错责任人涉及的所有指标,则更适合采用以下方案,将父类做成一个表,各个子类分别对应各自的表(如图所示)。这样,当需要查询某个过错责任人涉及的所有指标时,只需要查询父类的表就可以了。如果要查看某条记录的详细信息,再根据主键与类型字段,查询相应子类的个性化字段。这样,这种方案就可以完美实现该业务需求。</p>
|
||||
<p><img src="assets/CgqCHl-3amaAJ2OJAAD7sc4bAEs837.png" alt="Drawing 22.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3amaAJ2OJAAD7sc4bAEs837.png" alt="png" /></p>
|
||||
<p>综上所述,将领域模型中的继承关系转换成数据库设计有 3 种方案,并且每个方案都有各自的优缺点。因此,需要根据业务场景的特点与需求去评估,选择哪个方案更适用。</p>
|
||||
<h3>NoSQL 数据库的设计</h3>
|
||||
<p>前面我们讲的数据库设计,还是基于传统的关系型数据库、基于第三范式的数据库设计。但是,随着互联网高并发与分布式技术的发展,另一种全新的数据库类型孕育而生,那就是<strong>NoSQL 数据库</strong>。正是由于互联网应用带来的高并发压力,采用关系型数据库进行集中式部署不能满足这种高并发的压力,才使得分布式 NoSQL 数据库得到快速发展。</p>
|
||||
<p>也正因为如此,NoSQL 数据库与关系型数据库的设计套路是完全不同的。关系型数据库的设计是遵循第三范式进行的,它使得数据库能够大幅度降低冗余,但又从另一个角度使得数据库查询需要频繁使用 join 操作,在高并发场景下性能低下。</p>
|
||||
<p>所以,NoSQL 数据库的设计思想就是尽量干掉 join 操作,即将需要 join 的查询在写入数据库表前先进行 join 操作,然后直接写到一张单表中进行分布式存储,这张表称为“宽表”。这样,在面对海量数据进行查询时,就不需要再进行 join 操作,直接在这个单表中查询。同时,因为 NoSQL 数据库自身的特点,使得它在存储为空的字段时不占用空间,不担心“表稀疏”,不影响查询性能。</p>
|
||||
<p><strong>因此,NoSQL 数据库在设计时的套路就是,尽量在单表中存储更多的字段,只要避免数据查询中的 join 操作,即使出现大量为空的字段也无所谓了。</strong></p>
|
||||
<p><img src="assets/CgqCHl-3anKAXAb9AAdzT_g9VxU679.png" alt="Drawing 23.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3anKAXAb9AAdzT_g9VxU679.png" alt="png" /></p>
|
||||
<p>增值税发票票样图</p>
|
||||
<p>正因为 NoSQL 数据库在设计上有以上特点,因此将领域模型转换成 NoSQL 数据库时,设计就完全不一样了。比如,这样一张增值税发票,如上图所示,在数据库设计时就需要分为发票信息表、发票明细表与纳税人表,而在查询时需要进行 4 次 join 才能完成查询。但在 NoSQL 数据库设计时,将其设计成这样一张表:</p>
|
||||
<pre><code>{ _id: ObjectId(7df78ad8902c)
|
||||
|
@ -165,12 +165,12 @@ function hide_canvas() {
|
||||
<p>服务、实体与值对象是领域驱动设计的<strong>基本元素</strong>。然而,要将业务领域模型最终转换为程序设计,还要加入相应的设计。通常,将业务领域模型转换为程序设计,有两种设计思路:<strong>贫血模型</strong>与<strong>充血模型</strong>。</p>
|
||||
<h4>贫血模型与充血模型</h4>
|
||||
<p>事情是这样的:2004 年,软件大师 Eric Evans 发表了他的不朽著作《领域驱动设计》。虽然已经过去十多年了,这本书直到今天依然对我们有相当大的帮助。接着,另一位软件大师 Martin Fowler 在自己的博客中提出了“<strong>贫血模型</strong>”的概念。这位“马大叔”有一个非常大的特点,那就是软件行业中各种名词都是他发明的,包括如今业界影响巨大的软件重构、微服务,也是他的杰作。然而,马大叔在提出“贫血模型”的时候,却将其作为反模式提出来批评:所谓的“贫血模型”,就是在软件设计中,有很多的 POJO(Plain Ordinary Java Object)对象,它们除了有一堆 get/set 方法,几乎没有任何业务逻辑。这样的设计被称为“贫血模型”。</p>
|
||||
<p><img src="assets/Ciqc1F-8wNGAXF-pAAC7CszMUB8535.png" alt="image.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-8wNGAXF-pAAC7CszMUB8535.png" alt="png" /></p>
|
||||
<p>如上图所示,在领域模型中有 VIP 会员的领域对象,该对象除了有一堆属性以外,还有“会员打折”“会员福利”“会员特权”等方法。如果将该领域模型按照贫血模型进行设计,就会设计一个 VIP 会员的实体对象与 Service,实体对象包含该对象的所有属性,以及这些属性包含的数据;然后,将所有的方法都放入 Service 中,在调用它们的时候,必须将领域对象作为参数进行传输。这样的设计,将领域对象中的这些方法,以及这些方法在执行过程中所需的数据,割裂到两个不同的对象中,打破了对象的封装性。它会带来什么问题呢?</p>
|
||||
<p><img src="assets/CgqCHl-8wN-AemLyAAD80XA9jt0166.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl-8wN-AemLyAAD80XA9jt0166.png" alt="png" /></p>
|
||||
<p>如上图所示,在领域模型中的 VIP 会员通过继承分为了“金卡会员”与“银卡会员”。如果将该领域模型按照贫血模型进行设计,则会设计出一个“金卡会员”的实体对象与 Service,同时设计出一个“银卡会员”的实体对象与 Service。“金卡会员”的实体对象应当调用“金卡会员”的 Service,如果将“金卡会员”的实体对象去调用了“银卡会员”的 Service,系统就会出错。所以,除了进行以上设计以外,还需要有一个客户程序去判断,当前的实体对象是“金卡会员”还是“银卡会员”?这时,系统变更就变得没有那么灵活了。</p>
|
||||
<p>比如,现在需要在原有基础上,再增加一个“铂金会员”,那么不仅要增加一个“铂金会员”的实体对象与 Service,还要修改客户程序的判断,系统变更成本就会提高。</p>
|
||||
<p><img src="assets/Ciqc1F-8wOmADldnAAC_kd7-7Ts661.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F-8wOmADldnAAC_kd7-7Ts661.png" alt="png" /></p>
|
||||
<p>针对贫血模型的问题,马大叔提出了“充血模型”的概念。所谓“充血模型”,就是将领域模型的原貌直接转换为程序中领域对象的设计。这时,各种业务操作就不再在“服务”中实现了,而是在领域对象中实现。如图所示,在程序设计时,既有父类的“VIP 会员”,又有子类“金卡会员”与“银卡会员”。</p>
|
||||
<p>但充血模型与贫血模型不同的是:</p>
|
||||
<ul>
|
||||
@ -193,10 +193,10 @@ function hide_canvas() {
|
||||
<p><strong>1. 贫血模型比充血模型更加简单易行</strong></p>
|
||||
<p>充血模型是将领域模型的原貌直接映射成了程序设计,因此在程序设计时需要增加更多的诸如仓库、工厂的组件,对设计能力与架构提出了更高的要求。</p>
|
||||
<p>譬如,现在要设计一个订单系统,在领域建模时,每个订单需要有多个订单明细,还要对应相关的客户信息、商品信息。因此,在装载一个订单时,需要同时查出它的订单明细,以及对应的客户信息、商品信息,这些需要有强大的订单工厂进行装配;装载订单以后,还需要放到仓库中进行缓存,需要订单仓库具备缓存的能力;此外,在保存订单的时候,还需要同时保存订单和订单明细,并将它们放到一个事务中。所有这些都需要强有力的技术平台的支持。</p>
|
||||
<p><img src="assets/Ciqc1F-8wPaAeLA0AACXsgk7a30591.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F-8wPaAeLA0AACXsgk7a30591.png" alt="png" /></p>
|
||||
<p>相反,贫血模型就显得更加贫民化。在贫血模型中,<strong>MVC 层</strong>直接调用 Service,Service 通过<strong>DAO</strong>进行数据访问。在这个过程中,每个 DAO 都只查询数据库中的某个表,然后直接交给 Service 去使用,去完成各种处理。</p>
|
||||
<p>以订单系统为例,订单有订单 DAO,负责查询订单;订单明细有订单明细 DAO,负责查询订单明细。它们查询出来以后,不需要装配,而是直接交给订单 Service 使用。在保存订单时,订单 DAO 负责保存订单,订单明细 DAO 负责保存订单明细。它们都是通过订单 Service 进行组织,并建立事务。贫血模型不需要仓库,不需要工厂,也不需要缓存,一切都显得那么简单粗暴但一目了然。</p>
|
||||
<p><img src="assets/CgqCHl-8wQKAA-qqAACS75JqiHM949.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl-8wQKAA-qqAACS75JqiHM949.png" alt="png" /></p>
|
||||
<p><strong>2. 充血模型需要具备更强的设计与协作能力</strong></p>
|
||||
<p>充血模型的设计实现给开发人员提出了更高的<strong>能力要求</strong>,需要具有更强的 OOA/D(面向对象分析/设计) 能力、分析业务、业务建模与设计能力。譬如,在订单系统这个案例中,开发人员要先进行领域建模,分析清楚该场景中的订单、订单明细、用户、商品等领域对象的关联关系;还要分析各个领域对象在真实世界中都有什么行为,对应到软件设计中都有什么方法,在此基础上再进行设计开发。</p>
|
||||
<p>同时,充血模型需要有较强的<strong>团队协作能力</strong>。比如,在该场景中,当订单在进行创建时,需要对用户以及用户地址的相关信息进行查询。此时,订单 Service 不能直接去查询用户和用户地址的相关表,而是去调用用户 Service 的相关接口,由用户 Service 去完成对用户相关表的查询。这时候,开发订单模块的团队,需要向开发用户模块的团队提出接口需求。</p>
|
||||
@ -204,7 +204,7 @@ function hide_canvas() {
|
||||
<p><img src="assets/Ciqc1F-8wSCAOU-fAAEaUozd6TI978.png" alt="DDD 04--金句.png" /></p>
|
||||
<p><strong>3. 贫血模型更容易应对复杂的业务处理场景</strong></p>
|
||||
<p>充血模型在进行设计时,是将所有的业务处理过程在领域对象的相应方法中实现的。这样的设计,如果业务处理过程比较简单,还可以从容应对;但如果是面对非常<strong>复杂的业务处理场景</strong>时,就有一些力不从心。在这些复杂的业务处理场景中,如果采用贫血模型,可以将复杂的业务处理场景,划分成多个相对独立的步骤;然后将这些独立的步骤分配给多个 Service 串联起来执行。这样,各个步骤就是以一种松耦合的形式串联地组织在一起,以领域对象作为参数在各个Service 中进行传递。</p>
|
||||
<p><img src="assets/CgqCHl-8wQ6AYR90AAA2JpCgIHE892.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl-8wQ6AYR90AAA2JpCgIHE892.png" alt="png" /></p>
|
||||
<p>在这样的设计中,领域对象既可以作为各个方法调用的输入,又可以作为它们的输出。比如,在上图的案例中,领域对象作为参数首先调用 ServiceA;调用完以后将结果数据写入领域对象的前 5 个字段,传递给 ServiceB;ServiceB 拿到领域对象以后,既可以作为输入去读取前 5 个字段,又可以作为输出将执行结果写入中间 5 个字段;最后,将领域对象传递给 ServiceC,执行完操作以后去写后面 5 个字段;当所有字段都写入完成以后,存入数据库,完成所有操作。</p>
|
||||
<p>在这个过程中,如果日后需要变更,要增加一个处理过程,或者去掉一个处理过程,再或者调整它们的执行顺序,都是比较容易的。这样的设计要求处理过程必须在领域对象之外,在 Service 中实现。然而,如果采用的是充血模型的设计,就必须要将所有的处理过程都写入这个领域对象中去实现,无论这些处理过程有多复杂。这样的设计势必会加大日后变更维护的成本。</p>
|
||||
<p>所以,不论是贫血模型还是充血模型,它们各有优缺点,到底应当采用贫血模型还是充血模型,争执了这么多年,但我认为它们并不是熊掌和鱼的关系,我们应当把它们<strong>结合起来,取长补短,合理利用</strong>。关键是要先弄清楚它们的差别,也就是业务逻辑应当在哪里实现:贫血模型的业务逻辑在 Service 中实现,但充血模型是在领域对象中实现。清楚了这一点,在今后的软件设计时,可以将那些需要封装的业务逻辑放到领域对象中,按照充血模型去设计;除此之外的其他业务逻辑放到 Service 中,按照贫血模型去设计。</p>
|
||||
|
@ -170,7 +170,7 @@ function hide_canvas() {
|
||||
<p>因此,应当将读取<strong>用户信息</strong>的操作交给“<strong>用户信息管理</strong>”限界上下文,“用户下单”限界上下文只是对它的接口进行调用。通过这样的划分,实现了<strong>限界上下文内的高内聚和限界上下文间的低耦合</strong>,可以很好地降低日后代码变更的成本、提高软件设计质量。而限界上下文之间的这种相互关系,称为“<strong>上下文地图</strong>”(Context Map)。</p>
|
||||
<h3>限界上下文与微服务</h3>
|
||||
<p><strong>所谓“限界上下文内的高内聚”,也就是每个限界上下文内实现的功能,都是软件变化的同一个原因的代码</strong>。因为这个原因的变化才需要修改这个限界上下文,而不是这个原因的变化就不需要修改这个限界上下文,修改与它无关。正是因为限界上下文有如此好的特性,才使得现在很多微服务团队,<strong>运用限界上下文作为微服务拆分的原则</strong>,即每个限界上下文对应一个微服务。</p>
|
||||
<p><img src="assets/CgqCHl_GBb6AA9--AACyYcIHmOI823.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GBb6AA9--AACyYcIHmOI823.png" alt="png" /></p>
|
||||
<p>按照这样的原则拆分出来的微服务系统,在今后变更维护时,可以很好地将每次的需求变更,快速落到某个微服务中变更。这样,变更这个微服务就实现了该需求,升级该服务后就可以交付用户使用了。这样的设计,使得越来越多的规划开发团队,今后可以实现<strong>低成本维护与快速交付</strong>,进而快速适应市场变化而提升企业竞争力。</p>
|
||||
<p>譬如,在电商网站的购物过程中,购物、下单、支付、物流,都是软件变化不同的原因,因此,按照不同的业务场景划分限界上下文,然后以此拆分微服务。那么,当购物变更时就修改购物微服务,下单变更就修改下单微服务,但它们在业务处理过程中都需要读取商品信息,因此调用“商品管理”微服务来获取商品信息。这样,一旦商品信息发生变更,只与“商品管理”微服务有关,与其他微服务无关,那么维护成本将得到降低,交付速度得到提升。</p>
|
||||
<p><strong>所谓“限界上下文间的低耦合”,就是限界上下文通过上下文地图相互调用时,通过接口进行调用</strong>。如下图所示,模块 A 需要调用模块 B,那么它就与模块 B 形成了一种耦合,这时:</p>
|
||||
@ -179,7 +179,7 @@ function hide_canvas() {
|
||||
<li>如果模块 B 还要依赖模块 C,模块 C 还要依赖模块 D,那么所有使用模块 A 的地方都必须有模块 B、C、D,使用模块 A 的成本就会非常高昂。</li>
|
||||
</ul>
|
||||
<p>然而,如果模块 A 不是依赖模块 B,而是依赖接口 B',那么所有需要模块 A 的地方就不一定需要模块 B;如果模块 F 实现了接口 B',那么模块 A 调用模块 F 就可以了。这样,调用方和被调用方的耦合就被解开。</p>
|
||||
<p><img src="assets/CgqCHl_GBcuAXFTmAABd2sloOjA913.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GBcuAXFTmAABd2sloOjA913.png" alt="png" /></p>
|
||||
<p>在代码实现时,可以<strong>通过微服务来实现“限界上下文间”的“低耦合”</strong>。比如,“下单”微服务要去调用“支付”微服务。在设计时:</p>
|
||||
<ul>
|
||||
<li>首先在“下单”微服务中增加一个“支付”接口,这样在“下单”微服务中所有对支付的调用,都是对该接口的调用;</li>
|
||||
@ -192,10 +192,10 @@ function hide_canvas() {
|
||||
<li>现在,采用领域驱动设计,读取用户信息的职责交给了“用户管理”限界上下文,其他模块都是调用它的接口,这样,当用户信息表发生变更时,只与“用户管理”限界上下文有关,与其他模块无关,变更维护成本就降低了。通过限界上下文将整个系统按照逻辑进行了划分,但从物理上它们都还是一个项目、运行在一个 JVM 中,这种<strong>限界上下文只是“逻辑边界”</strong>。</li>
|
||||
<li>今后,将单体应用转型成微服务架构以后,各个限界上下文都是运行在各自不同的微服务中,是不同的项目、不同的 JVM。不仅如此,进行微服务拆分的同时,数据库也进行了拆分,每个微服务都是使用不同的数据库。这样,当各个微服务要访问用户信息时,它们没有访问用户数据库的权限,就只能通过远程接口去调用“用户”微服务开放的相关接口。这时,这种<strong>限界上下文就真正变成了“物理边界”</strong>,如下图所示:</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F_GBdmAfKv0AAKMCqSpP-Y270.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_GBdmAfKv0AAKMCqSpP-Y270.png" alt="png" /></p>
|
||||
<h3>微服务拆分的困局</h3>
|
||||
<p>现如今,许多软件团队都在加入微服务转型的行列,将原有的越来越复杂的单体应用,拆分为一个一个简单明了的微服务,以降低系统微服务的复杂性,这是没有问题的。然而,现在最大的问题是<strong>微服务应当如何拆分</strong>。</p>
|
||||
<p><img src="assets/CgqCHl_GBeGAVcrZAACD8DaCIMw474.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GBeGAVcrZAACD8DaCIMw474.png" alt="png" /></p>
|
||||
<p>如上图所示,以往许多的系统是这样设计的。现在,如果还按照这样的设计思路简单粗暴地拆分为多个微服务以后,对系统日后的维护将是灾难性的。</p>
|
||||
<ul>
|
||||
<li>当多个模块都要读取商品信息表时,是直接通过 JDBC(Java Database Connectivity)去读取这个表。</li>
|
||||
|
@ -152,7 +152,7 @@ function hide_canvas() {
|
||||
<p>用“领域驱动设计”是业界普遍认可的解决方案,也就是解决微服务如何拆分,以及实现微服务的高内聚与单一职责的问题。但是,领域驱动设计应当怎样进行呢?怎样从需求分析到软件设计,用正确的方式一步一步设计微服务呢?现在我们用一个在线订餐系统实战演练一下微服务的设计过程。</p>
|
||||
<h3>在线订餐系统项目实战</h3>
|
||||
<p>相信我们都使用过在线订餐系统,比如美团、大众点评、百度外卖等,具体的业务流程如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_GC1CAIiNuAAXvExRcOfE315.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GC1CAIiNuAAXvExRcOfE315.png" alt="png" /></p>
|
||||
<p>在线订餐系统的业务流程图</p>
|
||||
<ul>
|
||||
<li>当我们进入在线订餐系统时,首先看到的是各个饭店,进入每个饭店都能看到他们的菜单;</li>
|
||||
@ -165,7 +165,7 @@ function hide_canvas() {
|
||||
<p>现在,我们要以此为背景,按照微服务架构来设计开发一个在线订餐系统。那么,我们应当如何从分析理解需求开始,一步一步通过前面讲解的领域驱动设计,最后落实到拆分微服务,把这个系统拆分出来呢?</p>
|
||||
<h3>统一语言建模</h3>
|
||||
<p><strong>软件开发的最大风险是需求分析</strong>,因为在这个过程中谁都说不清楚能让对方了解的需求。</p>
|
||||
<p><img src="assets/Ciqc1F_J28KAVMGVAAEuq941Nzw844.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F_J28KAVMGVAAEuq941Nzw844.png" alt="png" /></p>
|
||||
<h4>研发不懂客户、客户也不懂研发</h4>
|
||||
<p>在这个过程中,对于客户来说:</p>
|
||||
<ul>
|
||||
@ -202,22 +202,22 @@ function hide_canvas() {
|
||||
<p>说到底,一个信息管理系统的作用,就是存储这些事实,对这些事实进行管理与跟踪,进而起到提高工作效率的作用。因此,分析一个信息管理系统的业务需求,就是准确地抓住业务进行过程中那些需要存储的关键事实,并围绕着这些事实进行分析设计、领域建模,这就是“事件风暴”的精髓。</p>
|
||||
<h4>召开事件风暴会议</h4>
|
||||
<p>因此,<strong>实践“事件风暴”方法,就是让开发人员与领域专家坐在一起,开事件风暴会议</strong>。<strong>会议的目的就是与领域专家一起进行领域建模</strong>,而会议前的准备就是在会场准备一个大大的白板与各色的便笺纸,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_GC3OAV1fMAAMURptGIOs120.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_GC3OAV1fMAAMURptGIOs120.png" alt="png" /></p>
|
||||
<p>事件风暴会议图</p>
|
||||
<p>当开始事件风暴会议以后,通常分为这样几个步骤。</p>
|
||||
<p>首先,在产品经理的引导下,与业务专家开始梳理当前的业务中有哪些领域事件,即已经发生并需要保存下来的那些事实。这时,是按照业务流程依次去梳理领域事件的。例如,在本案例中,整个在线订餐过程分为:已下单、已接单、已就绪、已派送和已送达,这几个领域事件。注意,领域事件是已发生的事实,因此,在命名的时候应当<strong>采用过去时态</strong>。</p>
|
||||
<p>这里有一个十分有趣的问题值得探讨。在用户下单之前,用户首先是选餐。那么,“用户选餐”是不是领域事件呢?注意,领域事件是那些已经发生并且需要保存的重要事实。这里,“用户选餐”仅仅是一个查询操作,并不需要数据库保存,因此不能算领域事件。那么,难道这些查询功能不在需求分析的过程中吗?</p>
|
||||
<p>注意,DDD 有自己的适用范围,它往往应用于系统增删改的业务场景中,而查询场景的分析往往不用 DDD,而是通过其他方式进行分析。分析清楚了领域事件以后,就用橘黄色便笺纸,将所有的领域事件罗列在白板上,确保领域中所有事件都已经被覆盖。</p>
|
||||
<p>紧接着,针对每一个领域事件,项目组成员开始不断地围绕着它进行业务分析,增加各种命令与事件,进而思考与之相关的资源、外部系统与时间。例如,在本案例中,首先分析“已下单”事件,分析它触发的命令、与之相关的人与事儿,以及发生的时间。命令使用蓝色便笺,人和事儿使用黄色便笺,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_GC36AZB4BAADY1e6dHOY773.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_GC36AZB4BAADY1e6dHOY773.png" alt="png" /></p>
|
||||
<p>“已下单”的领域事件分析图</p>
|
||||
<p>“已下单”事件触发的命令是“下单”,执行者是“用户”(画一个小人作为标识),执行时间是“下单时间”。与它相关的人和事儿有“饭店”与“订单”。在此基础上进一步分析,用户关联到用户地址,饭店关联到菜单,订单关联到菜品明细。</p>
|
||||
<p>然后,就是识别模型中可能涉及的聚合及其聚合根。第 05 讲谈到,所谓的“聚合”就是整体与部分的关系,譬如,饭店与菜单是否是聚合关系,关键看它俩的数据是如何组织的。如果菜单在设计时是独立于饭店之外的,如“宫保鸡丁”是独立于饭店的菜单,每个饭店都是在引用这条记录,那么菜单与饭店就不是聚合关系,即使删除了这个饭店,这个菜单依然存在。</p>
|
||||
<p>但如果菜单在设计时,每个饭店都有自己独立的菜单,譬如同样是“宫保鸡丁”,饭店 A 与饭店 B 使用的都是各自不同的记录。这时,菜单在设计上就是饭店的一个部分,删除饭店就直接删除了它的所有菜单,那么菜单与饭店就是聚合关系。在这里,那个代表“整体”的就是聚合根,所有客户程序都必须要通过聚合根去访问整体中的各个部分。</p>
|
||||
<p>通过以上分析,我们认为用户与地址、饭店与菜单、订单与菜品明细,都是聚合关系。如果是聚合关系,就在该关系上贴一张紫色便笺。</p>
|
||||
<p>按照以上步骤,一个一个地去分析每个领域事件:</p>
|
||||
<p><img src="assets/CgqCHl_GC6CAe3wiAAEmEkjuc5E956.png" alt="Drawing 4.png" />
|
||||
<img src="assets/CgqCHl_GC6WAJbNbAAFIuWmPKd8182.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GC6CAe3wiAAEmEkjuc5E956.png" alt="png" />
|
||||
<img src="assets/CgqCHl_GC6WAJbNbAAFIuWmPKd8182.png" alt="png" /></p>
|
||||
<p>在线订餐系统的领域事件分析图</p>
|
||||
<p>当所有的领域事件都分析完成以后,最后再站在全局对整个系统进行模块的划分,划分为多个限界上下文,并在各个限界上下文之间,定义它们的接口,规划上下文地图。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -158,16 +158,16 @@ function hide_canvas() {
|
||||
<h3>子域划分与限界上下文</h3>
|
||||
<p>正如第 06 讲中谈到,领域模型的绘制,不是将整个系统的领域对象都绘制在一张大图上,那样绘制很费劲,阅读也很费劲,不利于相互的交流。因此,领域建模就是将一个系统划分成了多个子域,每个子域都是一个独立的业务场景。围绕着这个业务场景进行分析建模,该业务场景会涉及许多领域对象,而这些领域对象又可能与其他子域的对象进行关联。这样,每个子域的实现就是“限界上下文”,而它们之间的关联关系就是“上下文地图”。</p>
|
||||
<p>在本案例中,围绕着领域事件“已下单”进行分析。它属于“用户下单”这个限界上下文,但与之相关的“用户”及其“地址”来源于“用户注册”这个限界上下文,与之相关的“饭店”及其“菜单”来源于“饭店管理”这个限界上下文。因此,<strong>在这个业务场景中</strong>,“<strong>用户下单</strong>”<strong>限界上下文属于</strong>“<strong>主题域</strong>”,<strong>而</strong>“<strong>用户注册</strong>”<strong>与</strong>“<strong>饭店管理</strong>”<strong>限界上下文属于</strong>“<strong>支撑域</strong>”。同理,围绕着本案例的各个领域事件进行了如下一些设计:</p>
|
||||
<p><img src="assets/CgqCHl_PFjeATu8NAAC_hYefOkM066.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PFjeATu8NAAC_hYefOkM066.png" alt="png" /></p>
|
||||
<p>“已下单”的限界上下文分析图</p>
|
||||
<p>通过这样的设计,就能将“用户下单”限界上下文的范围,与之相关的上下文地图以及如何接口,分析清楚了。有了这些设计,就可以按照限界上下文进行微服务拆分。按照这样的设计拆分的微服务,所有与用户下单相关的需求变更都在“用户下单”微服务中实现。但是,订单在读取用户信息的时候,不是直接去 join 用户信息表,而是调用“用户注册”微服务的接口。这样,当用户信息发生变更时,与“用户下单”微服务无关,只需要在“用户注册”微服务中独立开发、独立升级,从而使系统维护的成本得到降低。</p>
|
||||
<p><img src="assets/Ciqc1F_PFkSANv_cAADHnRBTKv4044.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PFkSANv_cAADHnRBTKv4044.png" alt="png" /></p>
|
||||
<p>“已接单”与“已就绪”的限界上下文分析图</p>
|
||||
<p>同样,如上图所示,我们围绕着“已接单”与“已就绪”的限界上下文进行了分析,并将它们都划分到“饭店接单”限界上下文中,后面就会设计成“饭店接单”微服务。这些场景的主题域就是“饭店接单”限界上下文,而与之相关的支撑域就是“用户注册”与“用户下单”限界上下文。通过这些设计,不仅合理划分了微服务的范围,也明确了微服务之间的接口,实现了微服务内的高内聚与微服务间的低耦合。</p>
|
||||
<h3>领域事件通知机制</h3>
|
||||
<p>按照 07 讲所讲到的领域模型设计,以及基于该模型的限界上下文划分,将整个系统划分为了“用户下单”“饭店接单”“骑士派送”等微服务。但是,在设计实现的时候,还有一个设计难题,即<strong>领域事件该如何通知</strong>。譬如,当用户在“用户下单”微服务中下单,那么会在该微服务中形成一个订单;但是,“饭店接单”是另外一个微服务,它必须要及时获得已下单的订单信息,才能执行接单。那么,如何通知“饭店接单”微服务已经有新的订单。诚然,可以让“饭店接单”微服务按照一定的周期不断地去查询“用户下单”微服务中已下单的订单信息。然而,这样的设计,不仅会加大“用户下单”与“饭店接单”微服务的系统负载,形成资源的浪费,还会带来这两个微服务之间的耦合,不利于之后的维护。因此,<strong>最有效的方式就是通过消息队列</strong>,<strong>实现领域事件在微服务间的通知</strong>。</p>
|
||||
<p><img src="assets/CgqCHl_PFlaADZxNAACzNn8_lDg752.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PF4OAaM-IAAJp9ADRF8I804.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PFlaADZxNAACzNn8_lDg752.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PF4OAaM-IAAJp9ADRF8I804.png" alt="png" /></p>
|
||||
<p>在线订餐系统的领域事件通知</p>
|
||||
<p>如上图所示,具体的设计就是,当“用户下单”微服务在完成下单并保存订单以后,将该订单做成一个消息发送到消息队列中;这时,“饭店接单”微服务就会有一个守护进程不断监听消息队列;一旦有消息就会触发接收消息,并向饭店发送“接收订单”的通知。在这样的设计中:</p>
|
||||
<ul>
|
||||
@ -190,7 +190,7 @@ function hide_canvas() {
|
||||
<li>同时,按照领域模型<strong>设计各个微服务的数据库</strong>。</li>
|
||||
</ul>
|
||||
<p>最后,将以上的设计最终落实到微服务之间的调用、领域事件的通知,以及前端微服务的设计。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_PF5aAIjkkAAXZApoF_cg761.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PF5aAIjkkAAXZApoF_cg761.png" alt="png" /></p>
|
||||
<p>在线订餐系统的微服务设计</p>
|
||||
<p>这里可以看到,前端微服务与后端微服务的设计是不一致的。前面讲的都是后端微服务的设计,而前端微服务的设计与用户 UI 是密切关联的,因此通过不同角色的规划,将前端微服务划分为用户 App、饭店 Web 与骑士 App。在用户 App 中,所有面对用户的诸如“用户注册”“用户下单”“用户选购”等功能都设计在用户 App 中。它相当于一个聚合服务,用于接收用户请求:</p>
|
||||
<ul>
|
||||
|
@ -156,7 +156,7 @@ function hide_canvas() {
|
||||
<li>然后将我们对业务的理解绘制成领域模型;</li>
|
||||
<li>再通过领域模型指导数据库和程序的设计。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl_PHjCAMbspAAEZnIFmIZ0660.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PHjCAMbspAAEZnIFmIZ0660.png" alt="png" /></p>
|
||||
<p>图 1 领域驱动设计的真谛</p>
|
||||
<p>过去,我们认为软件就是,<strong>用户怎么提需求,软件就怎么开发</strong>。这种开发模式使得我们对需求的认知浅薄,不得不随着用户的需求变动反复地改来改去,导致我们很累而用户还不满意,软件研发风险巨大。</p>
|
||||
<p>正是 DDD 改变了这一切,它要求我们更加**主动地去理解业务,掌握业务领域知识。**这样,我们对业务的理解越深刻,开发出来的产品就越专业,那么客户就越喜欢购买和使用我们的产品。</p>
|
||||
@ -172,11 +172,11 @@ function hide_canvas() {
|
||||
<h3>基于限界上下文的领域建模</h3>
|
||||
<p>回到 08 讲微服务设计部分,当在线订餐系统完成了事件风暴的分析以后,接着应当怎样设计呢?通过划分限界上下文,已经将系统划分为了“用户注册”“用户下单”“饭店接单”“骑士派送”与“饭店管理”等几个限界上下文,这样的划分也是后端微服务的划分。紧接着,就开始为每一个限界上下文进行领域建模。</p>
|
||||
<p>首先,<strong>从</strong>“<strong>用户下单</strong>”<strong>上下文开始</strong>。通过业务领域分析,绘制出了如图 2 所示的领域模型,该模型的核心是“订单”,通过“订单”关联了用户与用户地址。一个订单有多个菜品明细,而每个菜品明细都对应了一个菜单,每个菜单隶属于一个饭店。此外,一个订单还关联了它的支付与发票。起初,它们的属性和方法没有那么全面,随着设计的不断深入,不断地细化与完善模型。</p>
|
||||
<p><img src="assets/Ciqc1F_TJRqAG1xCAAF5cwFJos4897.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_TJRqAG1xCAAF5cwFJos4897.png" alt="png" /></p>
|
||||
<p>在这样的基础上开始划分限界上下文,用户与用户地址属于“用户注册”上下文,饭店与菜单属于“饭店管理”上下文。它们对于“用户下单”上下文来说都是支撑域,即给“用户下单”上下文提供接口调用的。真正属于“用户下单”上下文的,就只有订单、菜品明细、支付、发票这几个类,它们最终形成了“用户下单”微服务及其数据库设计。由于用户姓名、地址、电话等信息,都在“用户注册”上下文中,每次都需要远程接口调用来获得。这时就需要从系统优化的角度,适当将它们冗余到“订单”领域对象中,以提升查询效率。同样,“菜品名称”也进行了冗余,设计更新如图 3 所示:</p>
|
||||
<p><img src="assets/CgqCHl_TJSiAAYNyAAGLorZ5CTk428.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_TJSiAAYNyAAGLorZ5CTk428.png" alt="png" /></p>
|
||||
<p>完成了“用户下单”上下文以后,开始<strong>设计</strong>“<strong>饭店接单</strong>”<strong>上下文</strong>,设计如图 4 所示。上一讲谈到,“用户下单”微服务通过<strong>事件通知机制</strong>,将订单以消息的形式发送给“饭店接单”微服务。具体来说,就是将订单与菜品明细发送给“饭店接单”上下文。“饭店接单”上下文会将它们存储在自己的数据库中,并在此基础上增加“饭店接单”类,它与订单是一对一的关系。</p>
|
||||
<p><img src="assets/Ciqc1F_TJTqAJ682AAF46Z4VQ9M315.png" alt="3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_TJTqAJ682AAF46Z4VQ9M315.png" alt="png" /></p>
|
||||
<p>同样的思路,通过领域事件通知“骑士派送”上下文,完成“<strong>骑士派送</strong>”<strong>的领域建模</strong>。</p>
|
||||
<p>通过以上设计,就将上一讲的微服务拆分,进一步落实到每一个微服务的设计。紧接着,将每一个微服务的设计,按照第 03 讲的思路落实数据库设计,按照第 04 讲的思路落实贫血模型与充血模型的设计。</p>
|
||||
<p>特别值得注意的是,<strong>订单与菜品明细是一对聚合</strong>。过去按照贫血模型的设计,分别为它们设计订单值对象、Service 与 Dao,菜品明细值对象、Service 与 Dao;现在按照充血模型的设计,只有订单领域对象、Service、仓库、工厂与菜品明细包含在订单对象中,而订单 Dao 被包含在订单仓库中。贫血模型与充血模型在设计上有明显的差别。关于聚合的实现,下一讲再详细探讨。</p>
|
||||
@ -199,7 +199,7 @@ function hide_canvas() {
|
||||
<p><strong>订单状态的跟踪</strong></p>
|
||||
<p>当用户下单后,往往会不断地跟踪订单状态是“已下单”“已接单”“已就绪”还是“已派送”。然而,这些状态信息被分散到了各个微服务中,就不可能在“用户下单”上下文中实现了。如何从这些微服务中采集订单的状态信息,又可以保持微服务间的松耦合呢?解决思路还是<strong>领域事件的通知</strong>。</p>
|
||||
<p>通过消息队列,每个微服务在执行完某个领域事件的操作以后,就将领域事件封装成消息发送到消息队列中。比如,“用户下单”微服务在完成用户下单以后,将下单事件放到消息队列中。这样,不仅“饭店接单”微服务可以接收这个消息,完成后续的接单操作;而且“订单查询”微服务也可以接收这个消息,实现订单的跟踪。如图 5 所示。</p>
|
||||
<p><img src="assets/Ciqc1F_PHmeASNLIAAPIbRv-4po922.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PHmeASNLIAAPIbRv-4po922.png" alt="png" /></p>
|
||||
<p>图 5 订单状态的跟踪图</p>
|
||||
<p>通过领域事件的通知与消息队列的设计,使微服务间调用的设计松耦合,“订单查询”微服务可以像外挂一样采集各种订单状态,同时不影响原有的微服务设计,使得微服务之间实现解耦,降低系统维护的成本。而“订单查询”微服务通过冗余,将“下单时间”“取消时间”“接单时间”“就绪时间”等订单在不同状态下的时间,以及其他相关信息,都保存到订单表中,甚至增加一个“订单状态”记录当前状态,并增加 Redis 缓存的功能。这样的设计就保障了订单跟踪查询的高效。要知道,面对大数据的高效查询,通常都是通过冗余来实现的。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -160,9 +160,9 @@ function hide_canvas() {
|
||||
<p>上一讲谈到 DDD 落地微服务的分析设计过程,然后将这些设计最终落实到每个微服务的设计开发中。微服务的落地其实并没有那么简单,需要解决诸多设计与实现的技术难题,这一讲我们就来探讨一下吧。</p>
|
||||
<h3>如何发挥微服务的优势</h3>
|
||||
<p>微服务也不是银弹,它有很多的“坑”。开篇词中提到,当我们将一个庞大的业务系统拆分为一个个简单的微服务时,就是希望通过合理的微服务设计,尽量让每次的需求变更都交给某个小团队独立完成,让需求变更落到某个微服务上进行变更。唯有这样,每次变更只需独立地修改这个微服务,独立打包、独立升级,新需求就实现啦,才能发挥微服务的优势。</p>
|
||||
<p><img src="assets/CgqCHl_YfiGANvdnAAB4Gw-K8Ng757.png" alt="image.png" /></p>
|
||||
<p><img src="assets/CgqCHl_YfiGANvdnAAB4Gw-K8Ng757.png" alt="png" /></p>
|
||||
<p>然而,过去很多系统都是这样设计的(如上图所示),多个模块都需要读取商品信息表,因此都通过 JDBC 直接读取。现在要转型微服务了,起初采用<strong>数据共享</strong>的微服务设计,就是数据库不变,然后简单粗暴地直接按照功能模块进行微服务拆分。这时,多个微服务都需要读取商品信息表,都通过 SQL 直接访问。这样的设计,一旦商品信息表发生变更,那么多个微服务都需要变更。这样的设计就使得微服务的<strong>变更与发布变得复杂</strong>,微服务的<strong>优势无法发挥</strong>。</p>
|
||||
<p><img src="assets/CgqCHl_YfjCAZKv9AABt2_Tef90440.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl_YfjCAZKv9AABt2_Tef90440.png" alt="png" /></p>
|
||||
<p>通过前面 DDD 的指导,是希望做“小而专”的微服务设计。按照这样的思路设计微服务,对商品信息表的读写只有“商品维护”微服务。当其他微服务需要读写商品信息时,就不能直接读取商品信息表,而是通过 API 接口去调用“商品维护”微服务。这样,日后因商品信息变更而修改的代码就只限于“商品维护”微服务。只要“商品维护”微服务对外的 API 接口不变,这个变更则与其他微服务无关。只有这样的设计,才能真正发挥微服务的优势。</p>
|
||||
<p>为了规范“小而专”的微服务设计,在微服务转型之初,先按照 DDD 对数据库表按照用户权限进行划分。每个微服务只能通过自己的账号访问自己的表。当需要访问其他的表时,只能通过接口访问相应的微服务。这样的划分,就为日后真正的数据库拆分做好了准备,微服务转型将更加平稳。</p>
|
||||
<h3>怎样提供微服务接口</h3>
|
||||
@ -177,7 +177,7 @@ function hide_canvas() {
|
||||
<li>接着,“用户接单Service”常常要查找用户表信息,但前面说了,它没有查询用户表权限,因为用户表在“用户注册”微服务中。这时,“用户接单 Service”通过同步调用“用户注册 Service”的相关接口。</li>
|
||||
</ul>
|
||||
<p>具体设计实现上,就是在“用户接单”微服务的本地,增加一个“用户注册 Service”的 feign 接口。这样,“用户接单 Service”就像本地调用一样调用“用户注册 Service”,再通过这个 feign 接口实现远程调用。这样的设计叫作“<strong>防腐层</strong>”的设计。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_PHxGAKZQJAADJmVVEwcE418.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PHxGAKZQJAADJmVVEwcE418.png" alt="png" /></p>
|
||||
<p>微服务的拆分与防腐层的设计图</p>
|
||||
<p>譬如,大家想象这样一个场景。过去,“用户注册 Service”是在“用户下单”微服务中的。后来,随着微服务设计的不断深入,需要将“用户注册 Service”拆分到另外一个微服务中。这时,“用户下单Service”与“取消订单 Service”,以及其他对“用户注册 Service”的调用都会报错,都需要修改,维护成本就很高。这时,在微服务的本地放一个“用户注册 Service”的 feign 接口,那么其他的 Service 都不需要修改了,维护成本将得以降低。这就是“防腐层”的作用,即<strong>接口变更时降低维护成本</strong>。</p>
|
||||
<h3>去中心化的数据管理</h3>
|
||||
@ -188,11 +188,11 @@ function hide_canvas() {
|
||||
<li>微服务“经营分析”与“订单查询”这样的查询分析业务,则选用 NoSQL 数据库或大数据平台,通过读写分离将生产库上的数据同步过来进行分布式存储,然后经过一系列的预处理,就能应对海量历史数据的决策分析与秒级查询。</li>
|
||||
</ul>
|
||||
<p><strong>基于以上这些设计</strong>,<strong>就能完美地应对互联网应用的高并发与大数据</strong>,<strong>有效提高系统性能</strong>。设计如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_PHyaAXlokAAPzBHQJW_U719.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PHyaAXlokAAPzBHQJW_U719.png" alt="png" /></p>
|
||||
<p>在线订餐系统的去中心化数据管理图</p>
|
||||
<h3>数据关联查询的难题</h3>
|
||||
<p>此外,各个微服务在业务进行过程需要进行的各种查询,由于数据库的拆分,就不能像以前那样进行 join 操作了,而是通过接口调用的方式进行数据补填。比如“用户下单”“饭店接单”“骑士派送”等微服务,由于数据库的拆分,它们已经没有访问用户表与饭店表的权限,就不能像以往那样进行 join 操作了。这时,需要重构查询的过程。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_YfnGAdDcRAABzT74i0y8432.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl_YfnGAdDcRAABzT74i0y8432.png" alt="png" /></p>
|
||||
<p>查询的过程分为 2 个步骤。</p>
|
||||
<ol>
|
||||
<li>查询订单数据,但不执行 join 操作。这样的查询结果可能有 1 万条,但通过翻页,返回给微服务的只是那一页的 20 条数据。</li>
|
||||
|
@ -187,7 +187,7 @@ function hide_canvas() {
|
||||
<li>与其他外部系统的交互。</li>
|
||||
</ul>
|
||||
<p>整洁架构的精华在于其中间的<strong>适配器层</strong>,它通过适配器将核心的业务代码,与外围的技术框架进行解耦。因此,如何设计适配层,让业务代码与技术框架解耦,让业务开发团队与技术架构团队各自独立地工作,成了整洁架构落地的核心。</p>
|
||||
<p><img src="assets/CgpVE1_Yge6AEg27AAOLJ2FjYts902.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgpVE1_Yge6AEg27AAOLJ2FjYts902.png" alt="png" /></p>
|
||||
<p>整洁架构设计的细化图,图片来自《软件架构编年史》</p>
|
||||
<p>如图,进一步细化整洁架构,将其划分为 2 个部分:<strong>主动适配器</strong>与<strong>被动适配器</strong>。</p>
|
||||
<ul>
|
||||
@ -195,7 +195,7 @@ function hide_canvas() {
|
||||
<li>被动适配器,又称为“南向适配器”,就是在业务领域层完成各种业务处理以后,以某种形式持久化存储最终的结果数据。最终的数据可以存储到关系型数据库、NoSQL 数据库、NewSQL 数据库、Redis 缓存中,或者以消息队列的形式发送给其他应用系统。但不论采用什么形式,业务领域层只有一套,但持久化存储可以有各种不同形式。<strong>南向适配器将业务逻辑与存储技术解耦</strong>。</li>
|
||||
</ul>
|
||||
<h3>整洁架构的落地</h3>
|
||||
<p><img src="assets/Cip5yF_bMWWAYtJwAAEgzkERkwU966.png" alt="image" /></p>
|
||||
<p><img src="assets/Cip5yF_bMWWAYtJwAAEgzkERkwU966.png" alt="png" /></p>
|
||||
<p>按照整洁架构的思想如何落地架构设计呢?如上图所示,在这个架构中,将适配器层通过数据接入层、数据访问层与接口层等几个部分的设计,实现与业务的解耦。</p>
|
||||
<p>首先,用户可以用浏览器、客户端、移动 App、微信端、物联网专用设备等不同的前端形式,<strong>多渠道地接入到系统中</strong>,不同的渠道的接入形式是不同的。通过数据接入层进行解耦,然后以同样的方式去调用上层业务代码,就能将前端的多渠道接入,与后台的业务逻辑实现了解耦。这样,前端不管怎么变,有多少种渠道形式,后台业务只需要编写一套,维护成本将大幅度降低。</p>
|
||||
<p>接着,<strong>通过数据访问层将业务逻辑与数据库解耦</strong>。前面说了,在未来三五年时间里,我们又将经历一轮大数据转型。转型成大数据以后,数据存储的设计可能不再仅限于关系型数据库与 3NF的思路设计,而是通过 JSON、增加冗余、设计宽表等设计思路,将其存储到 NoSQL 数据库中,设计思想将发生巨大的转变。但无论怎么转变,都只是存储形式的转变,不变的是<strong>业务逻辑层中的业务实体</strong>。因此,通过数据访问层的解耦,今后系统向大数据转型的时候,业务逻辑层不需要做任何修改,只需要重新编写数据访问层的实现,就可以转型成大数据技术。转型成本将大大降低,转型将更加容易。</p>
|
||||
|
@ -159,21 +159,21 @@ function hide_canvas() {
|
||||
<p>清楚了这些概念,你就清楚了支持 DDD 与微服务的技术中台的设计思路。它是将 DDD 与微服务的底层技术进行封装,从而支持开发团队在未来实现快速交付,以应对激烈竞争的市场。因此,首先必须要清楚实现快速交付的技术痛点,才能清楚这个技术中台该如何建设。</p>
|
||||
<h3>打造快速交付团队</h3>
|
||||
<p>许多团队都有这样一个经历:项目初期,由于业务简单,参与的人少,往往可以获得一个较快的交付速度;但随着项目的不断推进,业务变得越来越复杂,参与的人越来越多,交付速度就变得越来越慢,使得团队越来越不能适应市场的快速变化,从而处于竞争的劣势。然而,软件规模化发展是所有软件发展的必然趋势。因此,<strong>解决规模化团队与软件快速交付的矛盾</strong>就成了我们不得不面对的难题。</p>
|
||||
<p><img src="assets/CgpVE1_bMJqAQUcNAARYP35oK-g668.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgpVE1_bMJqAQUcNAARYP35oK-g668.png" alt="png" /></p>
|
||||
<p>烟囱式的开发团队</p>
|
||||
<p>为什么团队越大交付速度越慢呢?如上图是我们从需求到交付的整个过程。在这个过程中,我们要经历多个部门的交互,才能完成最终的交付,大量的时间被耗费在部门间的沟通协调中。这样的团队被称为“烟囱式的开发团队”。</p>
|
||||
<p><img src="assets/Cip5yF_bMKWAVarpAAaPFOHoZag107.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yF_bMKWAVarpAAaPFOHoZag107.png" alt="png" /></p>
|
||||
<p>烟囱式的软件开发</p>
|
||||
<p><strong>烟囱式的开发团队又会导致烟囱式的软件开发</strong>。如上图所示,在大多数软件项目中,每个功能都要设计自己的页面、Controller、Service 以及 DAO,需要编写大量的代码,并且很多都是重复代码。代码写得越多 Bug 就越多,日后变更也越困难。</p>
|
||||
<p><img src="assets/Ciqc1F_bMLiANOY3AALR38INMnE341.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_bMLiANOY3AALR38INMnE341.png" alt="png" /></p>
|
||||
<p>最后,统一的发布也制约了交付的速度。如上图,当业务负责人将需求分配给多个团队开发时,A 团队的工作可能只需要 1 周就能完成。但是,当 A 团队完成了他们的工作以后,能立即交付给客户吗?答案是不能,因为 B 团队需要开发 2 周,A 团队只能等 B 团队开发完成以后才能统一发布。统一的发布制约了系统的交付速度,即使 A 团队的开发速度再快,不能立即交付用户就不能产生用户价值。</p>
|
||||
<p>随着系统规模越来越大,功能越来越多、越来越复杂,开发系统的团队规模也越来越大。这样就会导致开发团队的工作效率越来越低,交付周期越来越长,技术转型也越来越困难。</p>
|
||||
<p><img src="assets/Ciqc1F_bMMaASUd4AAQs_lNcK94723.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_bMMaASUd4AAQs_lNcK94723.png" alt="png" /></p>
|
||||
<p>特性团队的组织形式</p>
|
||||
<p>如何解决这一问题呢?如上图,首先,需要调整团队的组织架构,将筒状的架构竖过来,称为“特性团队”。在特性团队中,每个团队都直接面对终端客户。比如购物团队面对的是购物功能,所有与购物相关的功能都是他们来负责完成,包括从需求到研发,从 UI 到应用再到数据库。最后,经过测试,也是这个团队负责上线部署。这样,整个交付过程都是这个团队负责,没有了那么多团队间的沟通协调,交付速度自然就提升了。</p>
|
||||
<h3>大前端+技术中台</h3>
|
||||
<p>有了特性团队的组织形式,如果还是统一发布,那么交付速度依然提升不了。因此,在特性团队的基础上,软件架构采用了微服务的架构,即每个特性团队各自维护各自的微服务。这样,当该团队完成了一次开发,则自己独立打包、独立发布,不再需要等待其他团队。这样,交付速度就可以得到大幅度提升。如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_bMNKAIMDtAAUI10V4bmM923.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Cip5yF_bMNKAIMDtAAUI10V4bmM923.png" alt="png" /></p>
|
||||
<p>大前端 + 技术中台的组织形式</p>
|
||||
<p>特性团队 + 微服务架构,可以有效地提高规模化团队的交付速度。然而,仔细思考一下就会惊奇地发现,要这样组建一个特性团队,成本是非常高昂的。团队每个成员都必须既要懂业务,也要懂开发;既要懂 UI、应用,还要懂数据库,甚至大数据,做全栈工程师。如果每个特性团队都是这样组建,每个成员都是全栈工程师,成本过高,是没有办法真正落地的。那么,这个问题该怎么解决呢?</p>
|
||||
<p>解决问题的关键在于<strong>底层的架构团队</strong>。这里的架构团队就不再是架构师一个人,而是一个团队。</p>
|
||||
@ -197,12 +197,12 @@ function hide_canvas() {
|
||||
<li>你写的代码越少,Bug 就越少,日后维护与变更就越容易。</li>
|
||||
</ul>
|
||||
<p>俗话说:小船好掉头,泰坦尼克号看见冰山了为什么要撞上去?因为它实在太大了,根本来不及掉头。写代码也是一样的,一段 10 来行的代码变更会很容易,但一段数百上千行的代码变更就非常复杂。因此,我们设计软件应当秉承这样的态度:<strong>宁愿花更多的时间去分析设计</strong>,<strong>让软件设计精简到极致</strong>,<strong>从而花更少的时间去编码</strong>。俗话说:磨刀不误砍柴工。用这样的态度编写出来的代码,既快又易于维护。</p>
|
||||
<p><img src="assets/CgpVE1_bMOCAH-V9AAC5_N9tccI208.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgpVE1_bMOCAH-V9AAC5_N9tccI208.png" alt="png" /></p>
|
||||
<p>接着,看一看在以往软件研发过程中存在的问题。以往的软件项目在研发的过程中需要编写太多的代码了,每个功能都要编写自己的 UI、Controller、Service 和 DAO。并且,在每一个层次中都有不同格式的数据,因此我们编写的大量代码都是在进行各个层次之间的数据格式转换。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_bMOmAZ06lAABwXn1h7VE758.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl_bMOmAZ06lAABwXn1h7VE758.png" alt="png" /></p>
|
||||
<p>譬如,前端以 Form 的形式传输到后台,这时后台由 MVC 层从 Model 或者 Request 中获得,然后将其转换成值对象,接着去调用 Service。然而,从 Model 或者 Request 中获得数据以后,由于我们在 MVC 层的 Controller 中写了太多的判断与操作,再将其塞入值对象中,所以这里耗费了太多的代码。</p>
|
||||
<p>接着,在 Service 中经过各种业务操作,最后要存盘的时候,又要将 VO 转换为 PO,将数据持久化存储到数据库中。这时,又要为每一个功能编写一个 DAO。我们写的代码越多,日后维护与变更就越困难。那么,能不能将这些转换统一成公用代码下沉到技术中台中呢?基于这样的思想,系统架构调整为这样:</p>
|
||||
<p><img src="assets/CgqCHl_bMPSAPbN_AACkjNF2b14665.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl_bMPSAPbN_AACkjNF2b14665.png" alt="png" /></p>
|
||||
<p>在这个架构中,将各个层次的数据都统一成值对象,这是怎样统一的呢?首先,在前端的数据,现在越来越多的前端框架都是以 JSON 的形式传递的。JSON 的数据格式实际上是一种名 - 值对。因此,可以制订一个开发规范,要求前端 JSON 对象的设计,与后台值对象的格式一一对应。这样,当 JSON 对象传递到后台后,MVC 层就只需要一个通用的程序,以统一的形式将 JSON 对象转换为值对象。这样,还需要为每个功能编写 Controller 吗?不用了,整个系统只需要一个 Controller,并将其下沉到技术中台中。</p>
|
||||
<p>同样,Service 在经过了一系列的业务操作,最后要存盘的时候,可以这样做:制作一个vObj.xml 的配置文件来建立对应关系,将每个值对象都对应数据库中的一个表,哪个属性就对应哪个字段。这样,DAO 拿到哪个值对象,就知道该对象中的数据应当保存到数据库的哪张表中。这时,还需要为每个功能编写一个 DAO 吗?不用了,整个系统只需要一个 DAO。</p>
|
||||
<p>通过以上的设计思想架构的系统,开发工作量将极大地降低。在业务开发时,每个功能都不用再编写 MVC 层了,就不会将业务代码写到 Controller 中,而是规范地将业务代码编写到 Service或值对象中。接着,整个系统只有一个 DAO,每个功能的 Service 注入的都是这一个 DAO。这样,真正需要业务开发人员编写的仅限于前端 UI、Service 和值对象。而 Service 和值对象都是源于领域模型的映射,因此业务开发人员就会将更多的精力用于功能设计与前端 UI,给用户更好的用户体验,也提高了交付速度。</p>
|
||||
|
@ -157,9 +157,9 @@ function hide_canvas() {
|
||||
<li>所有的查询功能则不适用于领域驱动设计,而应当采用<strong>事务脚本模式</strong>(Transaction Script),即直接通过 SQL 语句进行查询。</li>
|
||||
</ul>
|
||||
<p>遵循该设计模式,是我们在许多软件项目中总结出来的最佳实践。因此,技术中台在建设时,对业务系统的支持也分为<strong>增删改</strong>与<strong>查询</strong>两个部分。</p>
|
||||
<p><img src="assets/Cip5yF_kdC6AKyj5AACxhqoIRjw348.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_kdC6AKyj5AACxhqoIRjw348.png" alt="png" /></p>
|
||||
<h3>增删改的架构设计</h3>
|
||||
<p><img src="assets/CgpVE1_kdD6AZg1HAALxpOUS2sc783.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_kdD6AZg1HAALxpOUS2sc783.png" alt="png" /></p>
|
||||
<p>增删改部分的技术中台架构设计</p>
|
||||
<p>在增删改部分中,采用了前面提到的单 Controller、单 Dao 的架构设计。如上图所示,各功能都有各自的前端 UI。但与以往架构不同的是,每个功能的前端 UI 对后台请求时,不再调用各自的 Controller,而是统一调用一个 Controller。然而,每个功能的前端在调用这一个 Controller 时,传递的参数是不一样的。首先从前端传递的是 bean,这个 bean 是什么呢?后台各功能都有一个 Service,将该 Service 注入 Dao 以后,会在 Spring 框架中配置成一个bean。这时,前端只知道调用的是这个 bean,但不知道它是哪个 Service。</p>
|
||||
<p>这样的设计,既保障了<strong>安全性</strong>(前端不知道具体是哪个类),又有效地实现了<strong>前后端分离</strong>,将前端代码与后端解耦。</p>
|
||||
@ -244,7 +244,7 @@ function hide_canvas() {
|
||||
<p>4.通过 SQL 语句执行数据库操作。</p>
|
||||
<h3>查询功能的架构设计</h3>
|
||||
<p>接着,是查询功能的技术中台设计,如图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_kdKOAU7-vAAUEADk6Fzw219.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_kdKOAU7-vAAUEADk6Fzw219.png" alt="png" /></p>
|
||||
<p>查询功能的技术中台架构设计</p>
|
||||
<p>与增删改部分一样的是,查询功能中,每个功能的前端 UI 也是统一调用一个 Controller。但与增删改的部分不一样的是,查询功能的前端 UI 传递的参数不同,因此是另一个类 QueryController。</p>
|
||||
<p>在调用时,首先需要传递的还是 bean。但与增删改不同的是,查询功能的 Service 只有一个,那就是 QueryService。但是,该 Service 在 Spring 中配置的时候,往 Service 中注入的是不同的 Dao,就可以装配成各种不同的 bean。这样,前端调用的是不同的 bean,最后执行的就是不同的查询。</p>
|
||||
|
@ -155,7 +155,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>因此,还需要有一个强有力的技术中台的支持,来简化 DDD 的设计实现,解决“最后一公里”的问题。唯有这样,DDD 才能在项目中真正落地。</p>
|
||||
<h3>传统 DDD 的架构设计</h3>
|
||||
<p><img src="assets/CgqCHl_q05-AX01vAAB19K5BZDQ843.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_q05-AX01vAAB19K5BZDQ843.png" alt="png" /></p>
|
||||
<p>通常,在支持领域驱动的软件项目中,架构设计如上图所示。</p>
|
||||
<ul>
|
||||
<li><strong>展现层</strong>是前端的 UI,它通过网络与后台的应用层交互。</li>
|
||||
@ -164,7 +164,7 @@ function hide_canvas() {
|
||||
<li>最后,通过仓库将领域对象中的<strong>数据持久化到数据库</strong>;使用工厂将数据从数据库中<strong>读取</strong>、<strong>拼装</strong>并<strong>还原</strong>成领域对象。</li>
|
||||
</ul>
|
||||
<p>这些都是将领域驱动落地到软件设计时所采用的方式。从架构分层上说,DDD 的仓库和工厂的设计介于业务领域层与基础设施层之间,即接口在业务领域层,而实现在基础设施层。DDD 的基础设施层相当于支撑 DDD 的基础技术架构,通过各种技术框架支持软件系统完成除了领域驱动以外的各种功能。</p>
|
||||
<p><img src="assets/Cip5yF_tSR6AMldHAAElu3pD1us550.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Cip5yF_tSR6AMldHAAElu3pD1us550.png" alt="png" /></p>
|
||||
<p>然而,传统的软件系统采用 DDD 进行架构设计时,需要在各个层次之间进行各种数据结构的转换:</p>
|
||||
<ul>
|
||||
<li>首先,前端的数据结构是 JSON,传递到后台数据接入层时需要将其转换为数据传输对象DTO;</li>
|
||||
@ -172,10 +172,10 @@ function hide_canvas() {
|
||||
<li>最后,将数据持久化到数据库时,又要将 DO 转换为持久化对象 PO。</li>
|
||||
</ul>
|
||||
<p>在这个过程中,需要编写大量代码进行<strong>数据的转换</strong>,无疑将加大软件开发的工作量与日后变更的维护成本。因此,我们可不可以考虑上一讲所提到的设计,将各个层次的数据结构统一起来呢?</p>
|
||||
<p><img src="assets/Ciqc1F_q07uAXmqhAAERd9ZE2WA787.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_q07uAXmqhAAERd9ZE2WA787.png" alt="png" /></p>
|
||||
<p>另外,传统的软件系统在采用 DDD 进行架构设计时,需要为每一个功能模块编写各自的仓库与工厂,如订单模块有订单仓库与订单工厂、库存模块有库存仓库与库存工厂。各个模块在编写仓库与工厂时,虽然实现了各自不同的业务,却形成了<strong>大量重复的代码</strong>。这样的问题与前面探讨的 Dao 的问题一样,是否可以通过配置与建模,设计成一个统一的仓库与工厂。如果是这样,那么仓库与工厂又与 Dao 是什么关系呢?基于对以上问题的思考,我提出了统一数据建模、内置聚合的实现、通用仓库和工厂,来简化 DDD 业务开发。因此,进行了如下的架构设计。</p>
|
||||
<h3>通用仓库与通用工厂的设计</h3>
|
||||
<p><img src="assets/CgqCHl_q08eAJ2WOAAIrg-1_zI4002.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl_q08eAJ2WOAAIrg-1_zI4002.png" alt="png" /></p>
|
||||
<p>该设计与上一讲的架构设计相比,差别仅是将单 Dao 替换为了通用仓库与通用工厂。也就是说,与 Dao 相比,DDD 的仓库就是在 Dao 的基础上<strong>扩展了一些新的功能</strong>。</p>
|
||||
<ul>
|
||||
<li>例如在<strong>装载或查询订单</strong>时,不仅要查询订单表,还要补填与订单相关的订单明细与客户信息、商品信息,并装配成一个订单对象。在这个过程中,查询订单是 Dao 的功能,但其他类似补填、装配等操作,则是仓库在 Dao 基础上进行的功能扩展。</li>
|
||||
@ -183,7 +183,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>这就是 DDD 的仓库与 Dao 的关系。</p>
|
||||
<p>基于这种扩展关系,该如何设计这个通用仓库呢?如果熟悉设计模式,则会想到“装饰者模式”。“装饰者模式”的目的,就是在原有功能的基础上进行“透明功能扩展”。这种“透明功能扩展”,既可以扩展原有功能,又不影响原有的客户程序,使客户程序不用修改任何代码就能实现新功能,从而降低变更的维护成本。因此,将“通用仓库”设计成了这样。</p>
|
||||
<p><img src="assets/CgqCHl_q08-AagbGAAA1dTvg3n0066.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHl_q08-AagbGAAA1dTvg3n0066.png" alt="png" /></p>
|
||||
<p>即在原有的 BasicDao 与 BasicDaoImpl 的基础上,增加了通用仓库 Repository。将 Repository 设计成装饰者,它也是接口 BasicDao 的实现类,是通过一个属性变量引用的 BasicDao。使用时,在 BasicDaoImpl 的基础上包一个 Repository,就可以扩展出那些 DDD 的功能。因此,所有的 Service 在注入 Dao 的时候:</p>
|
||||
<ul>
|
||||
<li>如果不使用 DDD,则像以前一样注入BasicDaoImpl;</li>
|
||||
|
@ -156,7 +156,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h3>解决技术不确定性的问题</h3>
|
||||
<p>如今的微服务架构,基本已经形成了 Spring Cloud 一统天下的局势。然而,在 Spring Cloud 框架下的各种技术组件依然存在<strong>诸多不确定性</strong>,如:注册中心是否采用 Eureka、服务网关是采用 Zuul 还是 Gateway,等等。同时,服务网格 Service Mesh 方兴未艾,不排除今后所有的微服务都要切换到 Service Mesh 的可能。在这种情况下如何<strong>决策微服务的技术架构</strong>?代码尽量不要与 Spring Cloud 耦合,才能在将来更容易地切换到 Service Mesh。那么,具体又该如何做到呢?</p>
|
||||
<p><img src="assets/Cip5yF_q1GWABFvEAAK9qvAoHxc276.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_q1GWABFvEAAK9qvAoHxc276.png" alt="png" /></p>
|
||||
<p>单 Controller、单 Dao 的设计在微服务架构的应用</p>
|
||||
<p>如上图所示,当前端通过服务网关访问微服务时,首先要<strong>访问聚合层的微服务</strong>。这时,在聚合层的微服务中,采用单 Controller 接收前端请求。这样,只有该 Controller 与 MVC 框架耦合,后面所有的 Service 不会耦合,从而实现了<strong>业务代码与技术框架的分离</strong>。</p>
|
||||
<p>同样的,当 Service 执行各种操作调用原子服务层的微服务时,不是通过 Ribbon 进行远程调用,而是将原子服务层的微服务开放的接口,在聚合层微服务的本地编写一个 Feign 接口。那么,聚合层微服务在调用原子微服务时,实际调用的是<strong>自己本地的接口</strong>,再由这个接口通过加载 Feign 注解,去实现远程调用。</p>
|
||||
@ -171,11 +171,11 @@ function hide_canvas() {
|
||||
<h3>采用 Feign 接口实现远程调用</h3>
|
||||
<p>每个微服务都是一个独立的进程,运行在各自独立的 JVM,甚至不同的物理节点上,通过网络访问。因此,微服务与微服务之间的调用必然是<strong>远程调用</strong>。以往,我们对微服务间的调用采用 Ribbon 的方式,在程序中的任意一个位置,只要注入一个 restTemplate,就可以进行远程调用。</p>
|
||||
<p>这样的代码过于随意,会越来越难于阅读与变更维护。比如,原来某个微服务中有两个模块 A 与 B,都需要调用模块 C。随着业务变得越来越复杂,需要进行微服务拆分,将模块 C 拆分到了另外一个微服务中。这时,原来的模块 A 与 B 就不能像原来一样调用模块 C,否则就会报错。</p>
|
||||
<p><img src="assets/CgpVE1_q1HuAOBfeAAC9Nd34EOU948.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_q1HuAOBfeAAC9Nd34EOU948.png" alt="png" /></p>
|
||||
<p>Ribbon 的远程调用方式</p>
|
||||
<p>如何解决以上问题呢?需要同时改造模块 A 与 B,分别加入 restTemplate 实现远程调用,来调用模块 C。也就是说,这时所有调用模块 C 的程序都需要改造,改造的成本与风险就会比较高。</p>
|
||||
<p>因此,在实现微服务间调用时,我们通常会采用另外一个方案:Feign。Feign 不是另起炉灶,而是对 Ribbon 的封装,目的是使代码更加规范、变更更加易于维护。采用的方案是,不修改模块 A 与 B 的任何代码,而是在该微服务的本地再制作一个模块 C 的接口 C′。该接口与模块 C 一模一样,拥有模块 C 的所有方法,因此模块 A 与 B 还可以像以前一样在本地调用接口 C′。但接口 C′ 只是一个接口,什么都做不了,因此需要通过添加 Feign 的注解,实现远程调用,去调用模块 C。这个方案,既没有修改模块 A 与 B,又没有修改模块 C,而仅仅添加了一个接口 C′,使维护成本降到了最低。</p>
|
||||
<p><img src="assets/Cip5yF_q1IOAXztgAAC6-Yd2p84518.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cip5yF_q1IOAXztgAAC6-Yd2p84518.png" alt="png" /></p>
|
||||
<p>Feign 的远程调用方式</p>
|
||||
<p>如何通过 Feign 实现微服务的远程调用呢?</p>
|
||||
<p>首先,创建项目时,在 POM.xml 文件中添加 Eureka Client、Hystrix 与 Actuator 等组件以外,将 ribbon 改为 feign:</p>
|
||||
|
@ -279,7 +279,7 @@ function hide_canvas() {
|
||||
<p>传统的 DDD 设计,每个模块都有自己的<strong>仓库与工厂</strong>,工厂是领域对象创建与装配的地方,是生命周期的开始。创建出来后放到仓库的缓存中,供上层应用访问。当领域对象在经过一系列操作以后,最后通过仓库完成数据的持久化。这个领域对象数据持久化的过程,对于普通领域对象来说就是存入某个单表,然而对于有聚合关系的领域对象来说,需要存入多个表中,并将其放到同一事务中。</p>
|
||||
<p>在这个过程中,<strong>聚合关系会出现跨库的事务操作吗</strong>?即具有聚合关系的多个领域对象会被拆分为多个微服务吗?我认为是不可能的,因为聚合就是一种<strong>强相关的封装</strong>,是不可能因微服务而拆分的。如果出现了,要么不是聚合关系,要么就是微服务设计出现了问题。因此,仓库是不可能完成跨库的事务处理的。</p>
|
||||
<p>弄清楚了传统的 DDD 设计,与以往 Dao 的设计进行比较,就会发现仓库和工厂就是对 Dao 的替换。然而,这种替换不是简单的替换,它们对 Dao 替换的同时,还<strong>扩展了许多的功能</strong>,如数据的补填、领域对象的映射与装配、聚合的处理,等等。当我们把这些关系思考清楚了,通用仓库与工厂的设计就出来了。</p>
|
||||
<p><img src="assets/CgpVE1_4DGKAEhouAABuNiM-xKQ436.png" alt="Lark20210108-153942.png" /></p>
|
||||
<p><img src="assets/CgpVE1_4DGKAEhouAABuNiM-xKQ436.png" alt="png" /></p>
|
||||
<p>如上图所示,仓库就是一个 Dao,它实现了 BasicDao 的接口。然而,仓库在读写数据库时,是把 BasicDao 实现类的代码重新 copy 一遍吗?不!那样只会形成大量重复代码,不利于日后的变更与维护。因此,仓库通过一个属性变量将 BasicDao 包在里面。这样,当仓库要读写数据库时,实际上调用的是 BasicDao 实现类,仓库仅仅实现在 BasicDao 实现类基础上扩展的那些功能。这样,仓库与 BasicDao 实现类彼此之间的职责与边界就划分清楚了。</p>
|
||||
<p>有了这样的设计,原有的遗留系统要通过改造转型为 DDD,除了通过领域建模增加 vObj.xml以外,将原来注入 Dao 改为注入仓库,就可以快速完成领域驱动的转型。同样的道理,要在仓库中增加缓存的功能,不是直接去修改仓库,而是在仓库的基础上包一个RepositoryWithCache,专心实现缓存的功能。这样设计,既使各个类的职责划分非常清楚,日后因哪种缘由变更就改哪个类,又使得系统设计松耦合,可以通过组件装配满足各种需求。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -193,7 +193,7 @@ function hide_canvas() {
|
||||
<h3>基于消息的领域事件发布</h3>
|
||||
<p>前面讲解了领域溯源的设计思路,最后要落地到项目实践中,依然需要<strong>技术中台的相应支持</strong>。譬如,业务系统的发布者只负责事件的发布,订阅者只负责事件的后续操作。但这个过程该如何发布事件呢?发布事件到底要做什么呢?又如何实现事件的订阅呢?这就需要下沉到技术中台去设计。</p>
|
||||
<p>首先,事件的发布方在发布事件的同时,需要在<strong>数据库中予以记录</strong>。数据库可以进行如下设计:</p>
|
||||
<p><img src="assets/Cip5yF_9BX6AHn0MAABKrvbrCO8701.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_9BX6AHn0MAABKrvbrCO8701.png" alt="png" /></p>
|
||||
<p>接着,领域事件还需要通过<strong>消息队列进行发布</strong>,这里可以采用 Spring Cloud Stream 的设计方案。Spring Cloud Stream 是 Spring Cloud 技术框架中一个实现<strong>消息驱动的技术框架</strong>。它的底层可以支持 RabbitMQ、Kafka 等主流消息队列,通过它的封装实现统一的设计编码。</p>
|
||||
<p>譬如,以 RabbitMQ 为例,首先需要在项目的 POM.xml 中加入依赖:</p>
|
||||
<pre><code> <dependencies>
|
||||
|
@ -189,7 +189,7 @@ function hide_canvas() {
|
||||
<h2>软件架构模式的演进</h2>
|
||||
<p>在进入今天的主题之前,我们先来了解下背景。</p>
|
||||
<p>我们知道,这些年来随着设备和新技术的发展,软件的架构模式发生了很大的变化。软件架构模式大体来说经历了从单机、集中式到分布式微服务架构三个阶段的演进。随着分布式技术的快速兴起,我们已经进入到了微服务架构时代。</p>
|
||||
<p><img src="assets/1628872362791.png" alt="1628872362791" /></p>
|
||||
<p><img src="assets/1628872362791.png" alt="png" /></p>
|
||||
<p><strong>我们可以用三步来划定领域模型和微服务的边界。</strong></p>
|
||||
<p>第一步:在事件风暴中梳理业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出领域实体等领域对象。</p>
|
||||
<p>第二步:根据领域实体之间的业务关联性,将业务紧密相关的实体进行组合形成聚合,同时确定聚合中的聚合根、值对象和实体。在这个图里,聚合之间的边界是第一层边界,它们在同一个微服务实例中运行,这个边界是逻辑边界,所以用虚线表示。</p>
|
||||
|
@ -194,7 +194,7 @@ function hide_canvas() {
|
||||
<p>领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。</p>
|
||||
<p>我们知道,DDD 是一种处理高度复杂领域的设计思想,它试图分离技术实现的复杂度。那么面对错综复杂的业务领域,DDD 是如何使业务从复杂变得简单,更容易让人理解,技术实现更容易呢?</p>
|
||||
<p>其实很好理解,DDD 的研究方法与自然科学的研究方法类似。当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题一步一步地细分,再针对细分出来的问题域,逐个深入研究,探索和建立所有子域的知识体系。当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。</p>
|
||||
<p><img src="assets/1628872456555.png" alt="1628872456555" /></p>
|
||||
<p><img src="assets/1628872456555.png" alt="png" /></p>
|
||||
<p>我们来看一下上面这张图。这个例子是在讲如何给桃树建立一个完整的生物学知识体系。初中生物课其实早就告诉我们研究方法了。<strong>它的研究过程是这样的。</strong></p>
|
||||
<p>第一步:确定研究对象,即研究领域,这里是一棵桃树。</p>
|
||||
<p>第二步:对研究对象进行细分,将桃树细分为器官,器官又分为营养器官和生殖器官两种。其中营养器官包括根、茎和叶,生殖器官包括花、果实和种子。桃树的知识体系是我们已经确定要研究的问题域,对应 DDD 的领域。根、茎、叶、花、果实和种子等器官则是细分后的问题子域。这个过程就是 DDD 将领域细分为多个子域的过程。</p>
|
||||
|
@ -295,11 +295,11 @@ function hide_canvas() {
|
||||
<h3>为什么要学习 Dubbo</h3>
|
||||
<p>我们在谈论任何一项技术的时候,都需要强调它所适用的业务场景,因为: <strong>技术之所以有价值,就是因为它解决了一些业务场景难题。</strong></p>
|
||||
<p>一家公司由小做大,业务会不断发展,随之而来的是 DAU、订单量、数据量的不断增长,用来支撑业务的系统复杂度也会不断提高,模块之间的依赖关系也会日益复杂。这时候我们一般会从单体架构进入集群架构(如下图所示),在集群架构中通过负载均衡技术,将流量尽可能均摊到集群中的每台机器上,以此克服单台机器硬件资源的限制,做到横向扩展。</p>
|
||||
<p><img src="assets/Ciqc1F8eQ_eAbHovAAC1BISTRQE943.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eQ_eAbHovAAC1BISTRQE943.png" alt="png" /></p>
|
||||
<p>单体架构 VS 集群架构</p>
|
||||
<p>之后,又由于业务系统本身的实现较为复杂、扩展性较差、性能也有上限,代码和功能的复用能力较弱,我们会将一个巨型业务系统拆分成多个微服务,根据不同服务对资源的不同要求,选择更合理的硬件资源。例如,有些流量较小的服务只需要几台机器构成的集群即可,而核心业务则需要成百上千的机器来支持,这样就可以最大化系统资源的利用率。</p>
|
||||
<p>另外一个好处是,可以在服务维度进行重用,在需要某个服务的时候,直接接入即可,从而提高开发效率。拆分成独立的服务之后(如下图所示),整个服务可以最大化地实现重用,也可以更加灵活地扩展。</p>
|
||||
<p><img src="assets/Ciqc1F8eRAqAKU1gAAGcFDQgvSc126.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRAqAKU1gAAGcFDQgvSc126.png" alt="png" /></p>
|
||||
<p>微服务架构图</p>
|
||||
<p>但是在微服务架构落地的过程中,我们需要解决的问题有很多,如:</p>
|
||||
<ul>
|
||||
@ -318,15 +318,15 @@ function hide_canvas() {
|
||||
<p>简单地说, <strong>Dubbo 是一个分布式服务框架,致力于提供高性能、透明化的 RPC 远程服务调用方案以及服务治理方案,以帮助我们解决微服务架构落地时的问题。</strong></p>
|
||||
<p>Dubbo 是由阿里开源,后来加入了 Apache 基金会,目前已经从孵化器毕业,成为 Apache 的顶级项目。Apache Dubbo 目前已经有接近 32.8 K 的 Star、21.4 K 的 Fork,其热度可见一斑, <strong>很多互联网大厂(如阿里、滴滴、去哪儿网等)都是直接使用 Dubbo 作为其 RPC 框架,也有些大厂会基于 Dubbo 进行二次开发实现自己的 RPC 框架</strong> ,如当当网的 DubboX。</p>
|
||||
<p>作为一名 Java 工程师,深入掌握 Dubbo 的原理和实现已经是大势所趋,并且成为你职场竞争力的关键项。拉勾网显示,研发工程师、架构师等高薪岗位,都要求你熟悉并曾经深入使用某种 RPC 框架,一线大厂更是要求你至少深入了解一款 RPC 框架的原理和核心实现。</p>
|
||||
<p><img src="assets/CgqCHl8eRBiAdm-9AACm_9hf8ac565.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRCaAdTaTAACqwi3sVSo733.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRC2Acm_pAACV8eY8-C8598.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRBiAdm-9AACm_9hf8ac565.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRCaAdTaTAACqwi3sVSo733.png" alt="png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRC2Acm_pAACV8eY8-C8598.png" alt="png" /></p>
|
||||
<p>(职位信息来源:拉勾网)</p>
|
||||
<p>而 Dubbo 就是首选。Dubbo 和 Spring Cloud 是目前主流的微服务框架,阿里、京东、小米、携程、去哪儿网等互联网公司的基础设施早已落成,并且后续的很多项目还是以 Dubbo 为主。Dubbo 重启之后,已经开始规划 3.0 版本,相信后面还会有更加惊艳的表现。</p>
|
||||
<p>另外,RPC 框架的核心原理和设计都是相通的,阅读过 Dubbo 源码之后,你再去了解其他 RPC 框架的代码,就是一件非常简单的事情了。</p>
|
||||
<h3>阅读 Dubbo 源码的痛点</h3>
|
||||
<p>学习和掌握一项技能的时候,一般都是按照“是什么”“怎么用”“为什么”(原理)逐层深入的:</p>
|
||||
<p><img src="assets/CgqCHl8eRDyAaP3fAABjUNh74KM999.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRDyAaP3fAABjUNh74KM999.png" alt="png" /></p>
|
||||
<p>同样,你可以通过阅读官方文档或是几篇介绍性的文章,迅速了解 Dubbo 是什么;接下来,再去上手,用 Dubbo 写几个项目,从而更加全面地熟悉 Dubbo 的使用方式和特性,成为一名“熟练工”,但这也是很多开发者所处的阶段。而“有技术追求”的开发者,一般不会满足于每天只是写写业务代码,而是会开始研究 Dubbo 的源码实现以及底层原理,这就对应了上图中的核心层:“原理”。</p>
|
||||
<p>而开始阅读源码时,不少开发者会提前去网上查找资料,或者直接埋头钻研源码,并因为这样的学习路径而普遍面临一些痛点问题:</p>
|
||||
<ul>
|
||||
|
@ -300,7 +300,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h3>Dubbo 架构简介</h3>
|
||||
<p>为便于你更好理解和学习,在开始搭建 Dubbo 源码环境之前,我们先来简单介绍一下 Dubbo 架构中的核心角色,帮助你简单回顾一下 Dubbo 的架构,也帮助不熟悉 Dubbo 的小伙伴快速了解 Dubbo。下图展示了 Dubbo 核心架构:</p>
|
||||
<p><img src="assets/CgqCHl8eRaCAW4-LAAB7_C-aKWA601.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRaCAW4-LAAB7_C-aKWA601.png" alt="png" /></p>
|
||||
<p>Dubbo 核心架构图</p>
|
||||
<ul>
|
||||
<li><strong>Registry:注册中心。</strong> 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。</li>
|
||||
@ -324,38 +324,38 @@ function hide_canvas() {
|
||||
<p>然后,在 IDEA 中导入源码,因为这个导入过程中会下载所需的依赖包,所以会耗费点时间。</p>
|
||||
<h3>Dubbo源码核心模块</h3>
|
||||
<p>在 IDEA 成功导入 Dubbo 源码之后,你看到的项目结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8eRcOAdzNmAADHxcenG7I722.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRcOAdzNmAADHxcenG7I722.png" alt="png" /></p>
|
||||
<p>下面我们就来简单介绍一下这些核心模块的功能,至于详细分析,在后面的课时中我们还会继续讲解。</p>
|
||||
<ul>
|
||||
<li><strong>dubbo-common 模块:</strong> Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,例如课程后面紧接着要介绍的 Dubbo SPI 实现、时间轮实现、动态编译器等。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl8eRfWANQSTAAHowsC6F8s134.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRfWANQSTAAHowsC6F8s134.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-remoting 模块:</strong> Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念,在其他子模块中依赖其他开源组件进行实现,例如,dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信,dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F8eRgCAR30EAABc4PYop3w206.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRgCAR30EAABc4PYop3w206.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-rpc 模块:</strong> Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象,其他子模块是针对具体协议的实现,例如,dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F8eRguAA8jOAABqHomePJk138.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRguAA8jOAABqHomePJk138.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-cluster 模块:</strong> Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider,这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。</li>
|
||||
<li><strong>dubbo-registry 模块:</strong> Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象,其他子模块是针对具体开源注册中心组件的具体实现,例如,dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl8eRhWANEiTAAB2ATuQ2vc619.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRhWANEiTAAB2ATuQ2vc619.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-monitor 模块:</strong> Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。</li>
|
||||
<li><strong>dubbo-config 模块:</strong> Dubbo 对外暴露的配置都是由该模块进行解析的。例如,dubbo-config-api 子模块负责处理 API 方式使用时的相关配置,dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl8eRhyAVJ43AAAaPAwMeQ4525.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRhyAVJ43AAAaPAwMeQ4525.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-metadata 模块:</strong> Dubbo 的元数据模块(本课程后续会详细介绍元数据的内容)。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl8eRiSAPFIYAABXCRqgsNA891.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRiSAPFIYAABXCRqgsNA891.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>dubbo-configcenter 模块:</strong> Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl8eRiuAM7LfAAA9BmMR2zY483.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRiuAM7LfAAA9BmMR2zY483.png" alt="png" /></p>
|
||||
<h3>Dubbo 源码中的 Demo 示例</h3>
|
||||
<p>在 Dubbo 源码中我们可以看到一个 dubbo-demo 模块,共包括三个非常基础 的 Dubbo 示例项目,分别是: <strong>使用 XML 配置的 Demo 示例、使用注解配置的 Demo 示例</strong> 以及 <strong>直接使用 API 的 Demo 示例</strong> 。下面我们将从这三个示例的角度,简单介绍 Dubbo 的基本使用。同时,这三个项目也将作为后续 Debug Dubbo 源码的入口,我们会根据需要在其之上进行修改 。不过在这儿之前,你需要先启动 ZooKeeper 作为注册中心,然后编写一个业务接口作为 Provider 和 Consumer 的公约。</p>
|
||||
<h4>启动 ZooKeeper</h4>
|
||||
@ -378,7 +378,7 @@ Starting zookeeper ... STARTED # 启动成功
|
||||
<li>Consumer ,如何使用服务、使用的服务名称是什么、需要传入什么参数、会得到什么响应。</li>
|
||||
</ul>
|
||||
<p>dubbo-demo-interface 模块就是定义业务接口的地方,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8eRlWAPwvCAACx42Xn9Dk409.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRlWAPwvCAACx42Xn9Dk409.png" alt="png" /></p>
|
||||
<p>其中,DemoService 接口中定义了两个方法:</p>
|
||||
<pre><code>public interface DemoService {
|
||||
String sayHello(String name); // 同步调用
|
||||
@ -391,7 +391,7 @@ Starting zookeeper ... STARTED # 启动成功
|
||||
<h4>Demo 1:基于 XML 配置</h4>
|
||||
<p>在 dubbo-demo 模块下的 dubbo-demo-xml 模块,提供了基于 Spring XML 的 Provider 和 Consumer。</p>
|
||||
<p>我们先来看 dubbo-demo-xml-provider 模块,其结构如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8eRmKAT8LjAADV8C5fM8E391.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/CgqCHl8eRmKAT8LjAADV8C5fM8E391.png" alt="png" /></p>
|
||||
<p>在其 pom.xml 中除了一堆 dubbo 的依赖之外,还有依赖了 DemoService 这个公共接口:</p>
|
||||
<pre><code><dependency>
|
||||
<groupId>org.apache.dubbo</groupId>
|
||||
@ -412,7 +412,7 @@ Starting zookeeper ... STARTED # 启动成功
|
||||
</code></pre>
|
||||
<p>最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 即可。</p>
|
||||
<p>接下来再看 dubbo-demo-xml-consumer 模块,结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8eRnuAWnTAAAE7eBUfEoA405.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8eRnuAWnTAAAE7eBUfEoA405.png" alt="png" /></p>
|
||||
<p>在 pom.xml 中同样依赖了 dubbo-demo-interface 这个公共模块。</p>
|
||||
<p>在 dubbo-consumer.xml 配置文件中,会指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能从 ZooKeeper 中拉取到 Provider 暴露的服务列表信息:</p>
|
||||
<pre><code><!-- Zookeeper地址 -->
|
||||
|
@ -387,19 +387,19 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>我们会看到,在生成的 RegistryFactory$Adaptive 类中会自动实现 getRegistry() 方法,其中会根据 URL 的 Protocol 确定扩展名称,从而确定使用的具体扩展实现类。我们可以找到 RegistryProtocol 这个类,并在其 getRegistry() 方法中打一个断点, Debug 启动上一课时介绍的任意一个 Demo 示例中的 Provider,得到如下图所示的内容:</p>
|
||||
<p><img src="assets/Ciqc1F8j2R2AO15wAAGHCEMA4ig361.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8j2R2AO15wAAGHCEMA4ig361.png" alt="png" /></p>
|
||||
<p>这里传入的 registryUrl 值为:</p>
|
||||
<pre><code>zookeeper://127.0.0.1:2181/org.apache.dubbo...
|
||||
</code></pre>
|
||||
<p>那么在 RegistryFactory$Adaptive 中得到的扩展名称为 zookeeper,此次使用的 Registry 扩展实现类就是 ZookeeperRegistryFactory。至于 Dubbo SPI 的完整内容,我们将在下一课时详细介绍,这里就不再展开了。</p>
|
||||
<h4>2. URL 在服务暴露中的应用</h4>
|
||||
<p>我们再来看另一个与 URL 相关的示例。上一课时我们在介绍 Dubbo 的简化架构时提到,Provider 在启动时,会将自身暴露的服务注册到 ZooKeeper 上,具体是注册哪些信息到 ZooKeeper 上呢?我们来看 ZookeeperRegistry.doRegister() 方法,在其中打个断点,然后 Debug 启动 Provider,会得到下图:</p>
|
||||
<p><img src="assets/Ciqc1F8j2aGAJmTVAAI-2XB7V7o382.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8j2aGAJmTVAAI-2XB7V7o382.png" alt="png" /></p>
|
||||
<p>传入的 URL 中包含了 Provider 的地址(172.18.112.15:20880)、暴露的接口(org.apache.dubbo.demo.DemoService)等信息, toUrlPath() 方法会根据传入的 URL 参数确定在 ZooKeeper 上创建的节点路径,还会通过 URL 中的 dynamic 参数值确定创建的 ZNode 是临时节点还是持久节点。</p>
|
||||
<h4>3. URL 在服务订阅中的应用</h4>
|
||||
<p>Consumer 启动后会向注册中心进行订阅操作,并监听自己关注的 Provider。那 Consumer 是如何告诉注册中心自己关注哪些 Provider 呢?</p>
|
||||
<p>我们来看 ZookeeperRegistry 这个实现类,它是由上面的 ZookeeperRegistryFactory 工厂类创建的 Registry 接口实现,其中的 doSubscribe() 方法是订阅操作的核心实现,在第 175 行打一个断点,并 Debug 启动 Demo 中 Consumer,会得到下图所示的内容:</p>
|
||||
<p><img src="assets/CgqCHl8j822Aa3VpAAPpUoCBlf4288.png" alt="Lark20200731-183202.png" /></p>
|
||||
<p><img src="assets/CgqCHl8j822Aa3VpAAPpUoCBlf4288.png" alt="png" /></p>
|
||||
<p>我们看到传入的 URL 参数如下:</p>
|
||||
<pre><code>consumer://...?application=dubbo-demo-api-consumer&category=providers,configurators,routers&interface=org.apache.dubbo.demo.DemoService...
|
||||
</code></pre>
|
||||
|
@ -297,7 +297,7 @@ function hide_canvas() {
|
||||
<h4>1. JDK SPI 机制</h4>
|
||||
<p>当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。</p>
|
||||
<p>下面我们通过一个简单的示例演示下 JDK SPI 的基本使用方式:</p>
|
||||
<p><img src="assets/CgqCHl8o_UCAI01eAABGsg2cqbw825.png" alt="image" />.png]</p>
|
||||
<p><img src="assets/CgqCHl8o_UCAI01eAABGsg2cqbw825.png" alt="png" />.png]</p>
|
||||
<p>首先我们需要创建一个 Log 接口,来模拟日志打印的功能:</p>
|
||||
<pre><code>public interface Log {
|
||||
void log(String info);
|
||||
@ -340,7 +340,7 @@ com.xxx.impl.Logback
|
||||
<h4>2. JDK SPI 源码分析</h4>
|
||||
<p>通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load() 方法,接下来我们就对其具体实现进行深入分析。</p>
|
||||
<p>在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader(获取当前线程绑定的 ClassLoader,查找失败后使用 SystemClassLoader),然后调用 reload() 方法,调用关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8o_V6AR93jAABeDIu_Kso211.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F8o_V6AR93jAABeDIu_Kso211.png" alt="png" /></p>
|
||||
<p>在 reload() 方法中,首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,用于读取 SPI 配置文件并实例化实现类对象。</p>
|
||||
<p>ServiceLoader.reload() 方法的具体实现,如下所示:</p>
|
||||
<pre><code>// 缓存,用来缓存 ServiceLoader创建的实现对象
|
||||
@ -351,7 +351,7 @@ public void reload() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>在前面的示例中,main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator 实现的。Iterator 接口有两个关键方法:hasNext() 方法和 next() 方法。这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法,hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8o_WmAZSkmAABmcc0uM54214.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F8o_WmAZSkmAABmcc0uM54214.png" alt="png" /></p>
|
||||
<p>首先来看 LazyIterator.hasNextService() 方法,该方法主要<strong>负责查找 META-INF/services 目录下的 SPI 配置文件</strong>,并进行遍历,大致实现如下所示:</p>
|
||||
<pre><code>private static final String PREFIX = "META-INF/services/";
|
||||
Enumeration<URL> configs = null;
|
||||
|
@ -313,9 +313,9 @@ function hide_canvas() {
|
||||
<p>下面我们正式进入 Dubbo SPI 核心实现的介绍。</p>
|
||||
<h4>1. @SPI 注解</h4>
|
||||
<p>Dubbo 中某个接口被 @SPI注解修饰时,就表示该接口是<strong>扩展接口</strong>,前文示例中的 org.apache.dubbo.rpc.Protocol 接口就是一个扩展接口:</p>
|
||||
<p><img src="assets/CgqCHl8s936AYuePAABLd6cRz6w646.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl8s936AYuePAABLd6cRz6w646.png" alt="png" /></p>
|
||||
<p>@SPI 注解的 value 值指定了默认的扩展名称,例如,在通过 Dubbo SPI 加载 Protocol 接口实现时,如果没有明确指定扩展名,则默认会将 @SPI 注解的 value 值作为扩展名,即加载 dubbo 这个扩展名对应的 org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,相关的 SPI 配置文件在 dubbo-rpc-dubbo 模块中,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8s94mAaj2mAABcaXHNXqc467.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl8s94mAaj2mAABcaXHNXqc467.png" alt="png" /></p>
|
||||
<p><strong>那 ExtensionLoader 是如何处理 @SPI 注解的呢?</strong></p>
|
||||
<p>ExtensionLoader 位于 dubbo-common 模块中的 extension 包中,功能类似于 JDK SPI 中的 java.util.ServiceLoader。Dubbo SPI 的核心逻辑几乎都封装在 ExtensionLoader 之中(其中就包括 @SPI 注解的处理逻辑),其使用方式如下所示:</p>
|
||||
<pre><code>Protocol protocol = ExtensionLoader
|
||||
@ -327,7 +327,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<pre><code> DubboInternalLoadingStrategy > DubboLoadingStrategy > ServicesLoadingStrateg
|
||||
</code></pre>
|
||||
<p><img src="assets/Ciqc1F8s95mANXYKAADUVwBlgxs297.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8s95mANXYKAADUVwBlgxs297.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>EXTENSION_LOADERS(ConcurrentMap<Class, ExtensionLoader>类型)</strong>
|
||||
:Dubbo 中一个扩展接口对应一个 ExtensionLoader 实例,该集合缓存了全部 ExtensionLoader 实例,其中的 Key 为扩展接口,Value 为加载其扩展实现的 ExtensionLoader 实例。</li>
|
||||
@ -407,7 +407,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h4>2. @Adaptive 注解与适配器</h4>
|
||||
<p>@Adaptive 注解用来实现 Dubbo 的适配器功能,那什么是适配器呢?这里我们通过一个示例进行说明。Dubbo 中的 ExtensionFactory 接口有三个实现类,如下图所示,ExtensionFactory 接口上有 @SPI 注解,AdaptiveExtensionFactory 实现类上有 @Adaptive 注解。</p>
|
||||
<p><img src="assets/Ciqc1F8s-D6AZFtdAAC318rtQ-I710.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8s-D6AZFtdAAC318rtQ-I710.png" alt="png" /></p>
|
||||
<p>AdaptiveExtensionFactory 不实现任何具体的功能,而是用来适配 ExtensionFactory 的 SpiExtensionFactory 和 SpringExtensionFactory 这两种实现。AdaptiveExtensionFactory 会根据运行时的一些状态来选择具体调用 ExtensionFactory 的哪个实现。</p>
|
||||
<p>@Adaptive 注解还可以加到接口方法之上,Dubbo 会动态生成适配器类。例如,Transporter接口有两个被 @Adaptive 注解修饰的方法:</p>
|
||||
<pre><code>@SPI("netty")
|
||||
@ -441,7 +441,7 @@ public interface Transporter {
|
||||
</code></pre>
|
||||
<p>生成 Transporter$Adaptive 这个类的逻辑位于 ExtensionLoader.createAdaptiveExtensionClass() 方法,若感兴趣你可以看一下相关代码,其中涉及的 javassist 等方面的知识,在后面的课时中我们会进行介绍。</p>
|
||||
<p>明确了 @Adaptive 注解的作用之后,我们回到 ExtensionLoader.createExtension() 方法,其中在扫描 SPI 配置文件的时候,会调用 loadClass() 方法加载 SPI 配置文件中指定的类,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8s-H2AJE1LAACILXqbtHY819.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl8s-H2AJE1LAACILXqbtHY819.png" alt="png" /></p>
|
||||
<p>loadClass() 方法中会识别加载扩展实现类上的 @Adaptive 注解,将该扩展实现的类型缓存到 cachedAdaptiveClass 这个实例字段上(volatile修饰):</p>
|
||||
<pre><code>private void loadClass(){
|
||||
if (clazz.isAnnotationPresent(Adaptive.class)) {
|
||||
@ -635,7 +635,7 @@ public <T> T getExtension(Class<T> type, String name) {
|
||||
}
|
||||
</code></pre>
|
||||
<p>最后举个简单的例子说明上述处理流程,假设 cachedActivates 集合缓存的扩展实现如下表所示:</p>
|
||||
<p><img src="assets/CgqCHl8tNGCAIw8fAACXC_dle_g809.png" alt="11.png" /></p>
|
||||
<p><img src="assets/CgqCHl8tNGCAIw8fAACXC_dle_g809.png" alt="png" /></p>
|
||||
<p>在 Provider 端调用 getActivateExtension() 方法时传入的 values 配置为 "demoFilter3、-demoFilter2、default、demoFilter1",那么根据上面的逻辑:</p>
|
||||
<ol>
|
||||
<li>得到默认激活的扩展实实现集合中有 [ demoFilter4, demoFilter6 ];</li>
|
||||
|
@ -293,15 +293,15 @@ function hide_canvas() {
|
||||
<p>在很多开源框架中,都需要定时任务的管理功能,例如 ZooKeeper、Netty、Quartz、Kafka 以及 Linux 操作系统。</p>
|
||||
<p>JDK 提供的 java.util.Timer 和 DelayedQueue 等工具类,可以帮助我们实现简单的定时任务管理,其底层实现使用的是<strong>堆</strong>这种数据结构,存取操作的复杂度都是 O(nlog(n)),无法支持大量的定时任务。在定时任务量比较大、性能要求比较高的场景中,为了将定时任务的存取操作以及取消操作的时间复杂度降为 O(1),一般会使用<strong>时间轮</strong>的方式。</p>
|
||||
<p><strong>时间轮是一种高效的、批量管理定时任务的调度模型</strong>。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。</p>
|
||||
<p><img src="assets/CgqCHl8yQfKAEM41AAB8fTu5PCY623.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl8yQfKAEM41AAB8fTu5PCY623.png" alt="png" /></p>
|
||||
<p>时间轮环形结构示意图</p>
|
||||
<p>需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用<strong>多级时间轮</strong>以及<strong>持久化存储与时间轮结合</strong>的方案。</p>
|
||||
<p>那在 Dubbo 中,时间轮的具体实现方式是怎样的呢?本课时我们就重点探讨下。Dubbo 的时间轮实现位于 dubbo-common 模块的 org.apache.dubbo.common.timer 包中,下面我们就来分析时间轮涉及的核心接口和实现。</p>
|
||||
<h3>核心接口</h3>
|
||||
<p>在 Dubbo 中,所有的定时任务都要继承 <strong>TimerTask 接口</strong>。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象,我们不仅可以查看定时任务的状态,还可以操作定时任务(例如取消关联的定时任务)。Timeout 接口中的方法如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8yQiKAGNLJAABUa6k9juY124.png" alt="image" />.png</p>
|
||||
<p><img src="assets/CgqCHl8yQiKAGNLJAABUa6k9juY124.png" alt="png" />.png</p>
|
||||
<p>Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法:提交一个定时任务(TimerTask)并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。</p>
|
||||
<p><img src="assets/CgqCHl8yQiuAC-1HAABO-eN3cPE094.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl8yQiuAC-1HAABO-eN3cPE094.png" alt="png" /></p>
|
||||
<h3>HashedWheelTimeout</h3>
|
||||
<p>HashedWheelTimeout 是 Timeout 接口的唯一实现,是 HashedWheelTimer 的内部类。HashedWheelTimeout 扮演了两个角色:</p>
|
||||
<ul>
|
||||
|
@ -292,13 +292,13 @@ function hide_canvas() {
|
||||
<div><h1>06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上)</h1>
|
||||
<p>在前面我们介绍 Dubbo 简化架构的时候提到过,Dubbo Provider 在启动时会将自身的服务信息整理成 URL 注册到注册中心,Dubbo Consumer 在启动时会向注册中心订阅感兴趣的 Provider 信息,之后 Provider 和 Consumer 才能建立连接,进行后续的交互。可见,<strong>一个稳定、高效的注册中心对基于 Dubbo 的微服务来说是至关重要的</strong>。</p>
|
||||
<p>Dubbo 目前支持 Consul、etcd、Nacos、ZooKeeper、Redis 等多种开源组件作为注册中心,并且在 Dubbo 源码也有相应的接入模块,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F81FQ2ANt6EAADZ01G_QYM489.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F81FQ2ANt6EAADZ01G_QYM489.png" alt="png" /></p>
|
||||
<p><strong>Dubbo 官方推荐使用 ZooKeeper 作为注册中心</strong>,它是在实际生产中最常用的注册中心实现,这也是我们本课时要介绍 ZooKeeper 核心原理的原因。</p>
|
||||
<p>要与 ZooKeeper 集群进行交互,我们可以使用 ZooKeeper 原生客户端或是 ZkClient、Apache Curator 等第三方开源客户端。在后面介绍 dubbo-registry-zookeeper 模块的具体实现时你会看到,Dubbo 底层使用的是 Apache Curator。<strong>Apache Curator 是实践中最常用的 ZooKeeper 客户端。</strong></p>
|
||||
<h3>ZooKeeper 核心概念</h3>
|
||||
<p><strong>Apache ZooKeeper 是一个针对分布式系统的、可靠的、可扩展的协调服务</strong>,它通常作为统一命名服务、统一配置管理、注册中心(分布式集群管理)、分布式锁服务、Leader 选举服务等角色出现。很多分布式系统都依赖与 ZooKeeper 集群实现分布式系统间的协调调度,例如:Dubbo、HDFS 2.x、HBase、Kafka 等。<strong>ZooKeeper 已经成为现代分布式系统的标配</strong>。</p>
|
||||
<p>ZooKeeper 本身也是一个分布式应用程序,下图展示了 ZooKeeper 集群的核心架构。</p>
|
||||
<p><img src="assets/CgqCHl82OOeARx1GAAEjvCaXdEE505.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl82OOeARx1GAAEjvCaXdEE505.png" alt="png" /></p>
|
||||
<p>ZooKeeper 集群的核心架构图</p>
|
||||
<ul>
|
||||
<li><strong>Client 节点</strong>:从业务角度来看,这是分布式应用中的一个节点,通过 ZkClient 或是其他 ZooKeeper 客户端与 ZooKeeper 集群中的一个 Server 实例维持长连接,并定时发送心跳。从 ZooKeeper 集群的角度来看,它是 ZooKeeper 集群的一个客户端,可以主动查询或操作 ZooKeeper 集群中的数据,也可以在某些 ZooKeeper 节点(ZNode)上添加监听。当被监听的 ZNode 节点发生变化时,例如,该 ZNode 节点被删除、新增子节点或是其中数据被修改等,ZooKeeper 集群都会立即通过长连接通知 Client。</li>
|
||||
@ -307,7 +307,7 @@ function hide_canvas() {
|
||||
<li><strong>Observer 节点</strong>:ZooKeeper 集群中特殊的从节点,不会参与 Leader 节点的选举,其他功能与 Follower 节点相同。引入 Observer 角色的目的是增加 ZooKeeper 集群读操作的吞吐量,如果单纯依靠增加 Follower 节点来提高 ZooKeeper 的读吞吐量,那么有一个很严重的副作用,就是 ZooKeeper 集群的写能力会大大降低,因为 ZooKeeper 写数据时需要 Leader 将写操作同步给半数以上的 Follower 节点。引入 Observer 节点使得 ZooKeeper 集群在写能力不降低的情况下,大大提升了读操作的吞吐量。</li>
|
||||
</ul>
|
||||
<p>了解了 ZooKeeper 整体的架构之后,我们再来了解一下 ZooKeeper 集群存储数据的逻辑结构。ZooKeeper 逻辑上是按照<strong>树型结构</strong>进行数据存储的(如下图),其中的节点称为 <strong>ZNode</strong>。每个 ZNode 有一个名称标识,即树根到该节点的路径(用 “/” 分隔),ZooKeeper 树中的每个节点都可以拥有子节点,这与文件系统的目录树类似。</p>
|
||||
<p><img src="assets/CgqCHl82OR6AJaDUAAEpNFJyW_0507.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl82OR6AJaDUAAEpNFJyW_0507.png" alt="png" /></p>
|
||||
<p>ZooKeeper 树型存储结构</p>
|
||||
<p>ZNode 节点类型有如下四种:</p>
|
||||
<ul>
|
||||
@ -317,7 +317,7 @@ function hide_canvas() {
|
||||
<li><strong>临时顺序节点。</strong> 基本特性与临时节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。</li>
|
||||
</ul>
|
||||
<p>在每个 ZNode 中都维护着一个 stat 结构,记录了该 ZNode 的元数据,其中包括版本号、操作控制列表(ACL)、时间戳和数据长度等信息,如下表所示:</p>
|
||||
<p><img src="assets/CgqCHl81FVuAFUp7AARJWw4cOq4421.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl81FVuAFUp7AARJWw4cOq4421.png" alt="png" /></p>
|
||||
<p>我们除了可以通过 ZooKeeper Client 对 ZNode 进行增删改查等基本操作,还可以注册 Watcher 监听 ZNode 节点、其中的数据以及子节点的变化。一旦监听到变化,则相应的 Watcher 即被触发,相应的 ZooKeeper Client 会立即得到通知。Watcher 有如下特点:</p>
|
||||
<ul>
|
||||
<li><strong>主动推送。</strong> Watcher 被触发时,由 ZooKeeper 集群主动将更新推送给客户端,而不需要客户端轮询。</li>
|
||||
@ -337,7 +337,7 @@ function hide_canvas() {
|
||||
<li>最后,Follower 节点会返回 Client 写请求相应的响应。</li>
|
||||
</ol>
|
||||
<p>下图展示了写操作的核心流程:</p>
|
||||
<p><img src="assets/Ciqc1F81FXOAKEC8AAKkiqE6rHY039.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F81FXOAKEC8AAKkiqE6rHY039.png" alt="png" /></p>
|
||||
<p>写操作核心流程图</p>
|
||||
<h3>崩溃恢复</h3>
|
||||
<p>上面写请求处理流程中,如果发生 Leader 节点宕机,整个 ZooKeeper 集群可能处于如下两种状态:</p>
|
||||
|
@ -305,7 +305,7 @@ function hide_canvas() {
|
||||
<h3>Apache Curator 基础</h3>
|
||||
<p><strong>Apache Curator 是 Apache 基金会提供的一款 ZooKeeper 客户端,它提供了一套易用性和可读性非常强的 Fluent 风格的客户端 API ,可以帮助我们快速搭建稳定可靠的 ZooKeeper 客户端程序。</strong></p>
|
||||
<p>为便于你更全面了解 Curator 的功能,我整理出了如下表格,展示了 Curator 提供的 jar 包:</p>
|
||||
<p><img src="assets/Ciqc1F87iUKAAAs2AAE2Xaps_KE511.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F87iUKAAAs2AAE2Xaps_KE511.png" alt="png" /></p>
|
||||
<p>下面我们从最基础的使用展开,逐一介绍 Apache Curator 在实践中常用的核心功能,开始我们的 Apache Curator 之旅。</p>
|
||||
<h4>1. 基本操作</h4>
|
||||
<p>简单了解了 Apache Curator 各个组件的定位之后,下面我们立刻通过一个示例上手使用 Curator。首先,我们创建一个 Maven 项目,并添加 Apache Curator 的依赖:</p>
|
||||
@ -519,7 +519,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>接下来,我们打开 ZooKeeper 的命令行客户端,在 /user 节点下先后添加两个子节点,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F87iXuAQVanAABhI9RRD8M252.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F87iXuAQVanAABhI9RRD8M252.png" alt="png" /></p>
|
||||
<p>此时我们只得到一行输出:</p>
|
||||
<pre><code>NodeChildrenChanged,/user
|
||||
</code></pre>
|
||||
@ -616,13 +616,13 @@ TreeCache,type=NODE_ADDED path=/user/test2
|
||||
TreeCache,type=INITIALIZED
|
||||
</code></pre>
|
||||
<p>接下来,我们在 ZooKeeper 命令行客户端中<strong>更新 /user 节点中的数据</strong>:</p>
|
||||
<p><img src="assets/Ciqc1F87iY6ACWnvAAA8jA9QVgM875.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F87iY6ACWnvAAA8jA9QVgM875.png" alt="png" /></p>
|
||||
<p>得到如下输出:</p>
|
||||
<pre><code>TreeCache,type=NODE_UPDATED path=/user
|
||||
NodeCache节点路径:/user,节点数据为:userData
|
||||
</code></pre>
|
||||
<p><strong>创建 /user/test3 节点</strong>:</p>
|
||||
<p><img src="assets/CgqCHl87iZqAaG93AABwFnQJA7o497.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl87iZqAaG93AABwFnQJA7o497.png" alt="png" /></p>
|
||||
<p>得到输出:</p>
|
||||
<pre><code>TreeCache,type=NODE_ADDED path=/user/test3
|
||||
2020-06-26T08:35:22.393 CHILD_ADDED
|
||||
@ -630,7 +630,7 @@ PathChildrenCache添加子节点:/user/test3
|
||||
PathChildrenCache子节点数据:xxx3
|
||||
</code></pre>
|
||||
<p><strong>更新 /user/test3 节点的数据</strong>:</p>
|
||||
<p><img src="assets/Ciqc1F87iaSAFZLpAABDyAm7vuE120.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F87iaSAFZLpAABDyAm7vuE120.png" alt="png" /></p>
|
||||
<p>得到输出:</p>
|
||||
<pre><code>TreeCache,type=NODE_UPDATED path=/user/test3
|
||||
2020-06-26T08:43:54.604 CHILD_UPDATED
|
||||
@ -638,7 +638,7 @@ PathChildrenCache修改子节点路径:/user/test3
|
||||
PathChildrenCache修改子节点数据:xxx33
|
||||
</code></pre>
|
||||
<p><strong>删除 /user/test3 节点</strong>:</p>
|
||||
<p><img src="assets/CgqCHl87ia6AYvijAABBmFLfzx4213.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl87ia6AYvijAABBmFLfzx4213.png" alt="png" /></p>
|
||||
<p>得到输出:</p>
|
||||
<pre><code>TreeCache,type=NODE_REMOVED path=/user/test3
|
||||
2020-06-26T08:44:06.329 CHILD_REMOVED
|
||||
@ -650,7 +650,7 @@ PathChildrenCache删除子节点:/user/test3
|
||||
<ul>
|
||||
<li><strong>ServiceInstance。</strong> 这是 curator-x-discovery 扩展包对服务实例的抽象,由 name、id、address、port 以及一个可选的 payload 属性构成。其存储在 ZooKeeper 中的方式如下图展示的这样。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl87icOABt59AADHccHcE1Q955.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl87icOABt59AADHccHcE1Q955.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>ServiceProvider。</strong> 这是 curator-x-discovery 扩展包的核心组件之一,提供了多种不同策略的服务发现方式,具体策略有轮询调度、随机和黏性(总是选择相同的一个)。得到 ServiceProvider 对象之后,我们可以调用其 getInstance() 方法,按照指定策略获取 ServiceInstance 对象(即发现可用服务实例);还可以调用 getAllInstances() 方法,获取所有 ServiceInstance 对象(即获取全部可用服务实例)。</li>
|
||||
<li><strong>ServiceDiscovery。</strong> 这是 curator-x-discovery 扩展包的入口类。开始必须调用 start() 方法,当使用完成应该调用 close() 方法进行销毁。</li>
|
||||
|
@ -294,7 +294,7 @@ function hide_canvas() {
|
||||
<p>本课时我们主要从基础知识开始讲起,首先介绍代理模式的基本概念,之后重点介绍 JDK 动态代理的使用以及底层实现原理,同时还会说明 JDK 动态代理的一些局限性,最后再介绍基于字节码生成的动态代理。</p>
|
||||
<h3>代理模式</h3>
|
||||
<p>代理模式是 23 种面向对象的设计模式中的一种,它的类图如下所示:</p>
|
||||
<p><img src="assets/CgqCHl8_hxqAY6vaAAGcUb0A8A4971.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl8_hxqAY6vaAAGcUb0A8A4971.png" alt="png" /></p>
|
||||
<p>图中的 Subject 是程序中的<strong>业务逻辑接口</strong>,RealSubject 是实现了 Subject 接口的<strong>真正业务类</strong>,Proxy 是实现了 Subject 接口的<strong>代理类</strong>,封装了一个 RealSubject 引用。<strong>在程序中不会直接调用 RealSubject 对象的方法,而是使用 Proxy 对象实现相关功能。</strong></p>
|
||||
<p>Proxy.operation() 方法的实现会调用其中封装的 RealSubject 对象的 operation() 方法,执行真正的业务逻辑。代理的作用不仅仅是正常地完成业务逻辑,还会在业务逻辑前后添加一些代理逻辑,也就是说,Proxy.operation() 方法会在 RealSubject.operation() 方法调用前后进行一些预处理以及一些后置处理。这就是我们常说的“<strong>代理模式</strong>”。</p>
|
||||
<p><strong>使用代理模式可以控制程序对 RealSubject 对象的访问</strong>,如果发现异常的访问,可以直接限流或是返回,也可以在执行业务处理的前后进行相关的预处理和后置处理,帮助上层调用方屏蔽底层的细节。例如,在 RPC 框架中,代理可以完成序列化、网络 I/O 操作、负载均衡、故障恢复以及服务发现等一系列操作,而上层调用方只感知到了一次本地调用。</p>
|
||||
@ -443,7 +443,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>这两个组件的使用与 JDK 动态代理中的 Proxy 和 InvocationHandler 相似。</p>
|
||||
<p>下面我们通过一个示例简单介绍 CGLib 的使用。在使用 CGLib 创建动态代理类时,首先需要定义一个 Callback 接口的实现, CGLib 中也提供了多个Callback接口的子接口,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8_h1uAcXB-AAKCT9cNDBw713.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl8_h1uAcXB-AAKCT9cNDBw713.png" alt="png" /></p>
|
||||
<p>这里以 MethodInterceptor 接口为例进行介绍,首先我们引入 CGLib 的 maven 依赖:</p>
|
||||
<pre><code><dependency>
|
||||
<groupId>cglib</groupId>
|
||||
|
@ -309,14 +309,14 @@ function hide_canvas() {
|
||||
<p>在进行网络 I/O 操作的时候,用什么样的方式读写数据将在很大程度上决定了 I/O 的性能。作为一款优秀的网络基础库,Netty 就采用了 NIO 的 I/O 模型,这也是其高性能的重要原因之一。</p>
|
||||
<h4>1. 传统阻塞 I/O 模型</h4>
|
||||
<p>在传统阻塞型 I/O 模型(即我们常说的 BIO)中,如下图所示,每个请求都需要独立的线程完成读数据、业务处理以及写回数据的完整操作。</p>
|
||||
<p><img src="assets/CgqCHl9EvKaAF18_AACJ4Y62QAY004.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9EvKaAF18_AACJ4Y62QAY004.png" alt="png" /></p>
|
||||
<p>一个线程在同一时刻只能与一个连接绑定,如下图所示,当请求的并发量较大时,就需要创建大量线程来处理连接,这就会导致系统浪费大量的资源进行线程切换,降低程序的性能。我们知道,网络数据的传输速度是远远慢于 CPU 的处理速度,连接建立后,并不总是有数据可读,连接也并不总是可写,那么线程就只能阻塞等待,CPU 的计算能力不能得到充分发挥,同时还会导致大量线程的切换,浪费资源。</p>
|
||||
<p><img src="assets/CgqCHl9EvLSAQzfFAACIPU0Pqkg586.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9EvLSAQzfFAACIPU0Pqkg586.png" alt="png" /></p>
|
||||
<h4>2. I/O 多路复用模型</h4>
|
||||
<p>针对传统的阻塞 I/O 模型的缺点,I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可,无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时,操作系统会通知线程,线程从阻塞状态返回,开始进行读写操作以及后续的业务逻辑处理。I/O 复用的模型如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9EvNOACOC5AADhkXKnAFg681.png" alt="4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9EvNOACOC5AADhkXKnAFg681.png" alt="png" /></p>
|
||||
<p>Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在,可以同时并发处理成百上千个网络连接,大大增加了服务器的处理能力。另外,Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9EvOOADRMzAACeQMLGfbs278.png" alt="6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9EvOOADRMzAACeQMLGfbs278.png" alt="png" /></p>
|
||||
<p>从数据处理的角度来看,传统的阻塞 I/O 模型处理的是字节流或字符流,也就是以流式的方式顺序地从一个数据流中读取一个或多个字节,并且不能随意改变读取指针的位置。而在 NIO 中则抛弃了这种传统的 I/O 流概念,引入了 Channel 和 Buffer 的概念,可以从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。Buffer 不像传统 I/O 中的流那样必须顺序操作,在 NIO 中可以读写 Buffer 中任意位置的数据。</p>
|
||||
<h3>Netty 线程模型设计</h3>
|
||||
<p>服务器程序在读取到二进制数据之后,首先需要通过编解码,得到程序逻辑可以理解的消息,然后将消息传入业务逻辑进行处理,并产生相应的结果,返回给客户端。编解码逻辑、消息派发逻辑、业务处理逻辑以及返回响应的逻辑,是放到一个线程里面串行执行,还是分配到不同的线程中执行,会对程序的性能产生很大的影响。所以,优秀的线程模型对一个高性能网络库来说是至关重要的。</p>
|
||||
@ -324,23 +324,23 @@ function hide_canvas() {
|
||||
<p>为了帮助你更好地了解 Netty 线程模型的设计理念,我们将从最基础的单 Reactor 单线程模型开始介绍,然后逐步增加模型的复杂度,最终到 Netty 目前使用的非常成熟的线程模型设计。</p>
|
||||
<h4>1. 单 Reactor 单线程</h4>
|
||||
<p>Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。当然,该过程中也可能会出现连接不可读或不可写等情况,该单线程会去执行其他 Handler 的逻辑,而不是阻塞等待。具体情况如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9EvVGAPXATAAEj0pK8ONM000.png" alt="7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9EvVGAPXATAAEj0pK8ONM000.png" alt="png" /></p>
|
||||
<p>单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。</p>
|
||||
<p>但其缺点也非常明显,那就是<strong>性能瓶颈问题</strong>,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在<strong>客户端</strong>使用这种线程模型。</p>
|
||||
<h4>2. 单 Reactor 多线程</h4>
|
||||
<p>在单 Reactor 多线程的架构中,Reactor 监控到客户端请求之后,如果连接建立的请求,则由Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,<strong>唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池</strong>。</p>
|
||||
<p><img src="assets/CgqCHl9EvWqAJ5jpAAFbymUVJ8o272.png" alt="8.png" /></p>
|
||||
<p><img src="assets/CgqCHl9EvWqAJ5jpAAFbymUVJ8o272.png" alt="png" /></p>
|
||||
<p>单 Reactor 多线程模型</p>
|
||||
<p>很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。</p>
|
||||
<h4>3. 主从 Reactor 多线程</h4>
|
||||
<p>为了解决单 Reactor 多线程模型中的问题,我们可以引入多个 Reactor。其中,Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件,当Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听。</p>
|
||||
<p>当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件(OP_READ)发生时,Reactor 子线程就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后,Handler 会根据处理结果调用 send 将响应返回给客户端,当然此时连接要有可写事件(OP_WRITE)才能发送数据。</p>
|
||||
<p><img src="assets/CgqCHl9EvXuARvm7AAF3raiQza8716.png" alt="9.png" /></p>
|
||||
<p><img src="assets/CgqCHl9EvXuARvm7AAF3raiQza8716.png" alt="png" /></p>
|
||||
<p>主从 Reactor 多线程模型</p>
|
||||
<p>主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。<strong>主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件</strong>。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。</p>
|
||||
<h4>4. Netty 线程模型</h4>
|
||||
<p>Netty 同时支持上述几种线程模式,Netty 针对服务器端的设计是在主从 Reactor 多线程模型的基础上进行的修改,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9EvZyAZsQlAAMdGh4CXMI139.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9EvZyAZsQlAAMdGh4CXMI139.png" alt="png" /></p>
|
||||
<p><strong>Netty 抽象出两组线程池:BossGroup 专门用于接收客户端的连接,WorkerGroup 专门用于网络的读写</strong>。BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,相当于一个事件循环组,其中包含多个事件循环 ,每一个事件循环是 NioEventLoop。</p>
|
||||
<p>NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理。每个 NioEventLoopGroup 可以含有多个 NioEventLoop,也就是多个线程。</p>
|
||||
<p>每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。</p>
|
||||
|
@ -307,7 +307,7 @@ function hide_canvas() {
|
||||
<h3>ChannelPipeline&ChannelHandler</h3>
|
||||
<p>提到 Pipeline,你可能最先想到的是 Linux 命令中的管道,它可以实现将一条命令的输出作为另一条命令的输入。Netty 中的 ChannelPipeline 也可以实现类似的功能:<strong>ChannelPipeline 会将一个 ChannelHandler 处理后的数据作为下一个 ChannelHandler 的输入</strong>。</p>
|
||||
<p>下图我们引用了 Netty Javadoc 中对 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常是如何处理 I/O 事件的。Netty 中定义了两种事件类型:<strong>入站(Inbound)事件</strong>和<strong>出站(Outbound)事件</strong>。这两种事件就像 Linux 管道中的数据一样,在 ChannelPipeline 中传递,事件之中也可能会附加数据。ChannelPipeline 之上可以注册多个 ChannelHandler(ChannelInboundHandler 或 ChannelOutboundHandler),我们在 ChannelHandler 注册的时候决定处理 I/O 事件的顺序,这就是典型的<strong>责任链模式</strong>。</p>
|
||||
<p><img src="assets/Ciqc1F9Ill2AMrCEAAFZL4VXrW8624.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9Ill2AMrCEAAFZL4VXrW8624.png" alt="png" /></p>
|
||||
<p>从图中我们还可以看到,I/O 事件不会在 ChannelPipeline 中自动传播,而是需要调用ChannelHandlerContext 中定义的相应方法进行传播,例如:fireChannelRead() 方法和 write() 方法等。</p>
|
||||
<p>这里我们举一个简单的例子,如下所示,在该 ChannelPipeline 上,我们添加了 5 个 ChannelHandler 对象:</p>
|
||||
<pre><code>ChannelPipeline p = socketChannel.pipeline();
|
||||
@ -326,12 +326,12 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<p>在 Netty 中就提供了很多 Encoder 的实现用来解码读取到的数据,Encoder 会处理多次 channelRead() 事件,等拿到有意义的数据之后,才会触发一次下一个 ChannelInboundHandler 的 channelRead() 方法。</p>
|
||||
<p><strong>出站(Outbound)事件与入站(Inbound)事件相反,一般是由用户触发的。</strong></p>
|
||||
<p>ChannelHandler 接口中并没有定义方法来处理事件,而是由其子类进行处理的,如下图所示,ChannelInboundHandler 拦截并处理入站事件,ChannelOutboundHandler 拦截并处理出站事件。</p>
|
||||
<p><img src="assets/Ciqc1F9IlmmABbbRAADcN9APiZs099.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9IlmmABbbRAADcN9APiZs099.png" alt="png" /></p>
|
||||
<p>Netty 提供的 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter 主要是帮助完成事件流转功能的,即自动调用传递事件的相应方法。这样,我们在自定义 ChannelHandler 实现类的时候,就可以直接继承相应的 Adapter 类,并覆盖需要的事件处理方法,其他不关心的事件方法直接使用默认实现即可,从而提高开发效率。</p>
|
||||
<p>ChannelHandler 中的很多方法都需要一个 ChannelHandlerContext 类型的参数,ChannelHandlerContext 抽象的是 ChannleHandler 之间的关系以及 ChannelHandler 与ChannelPipeline 之间的关系。<strong>ChannelPipeline 中的事件传播主要依赖于ChannelHandlerContext 实现</strong>,在 ChannelHandlerContext 中维护了 ChannelHandler 之间的关系,所以我们可以从 ChannelHandlerContext 中得到当前 ChannelHandler 的后继节点,从而将事件传播到后续的 ChannelHandler。</p>
|
||||
<p>ChannelHandlerContext 继承了 AttributeMap,所以提供了 attr() 方法设置和删除一些状态属性信息,我们可将业务逻辑中所需使用的状态属性值存入到 ChannelHandlerContext 中,然后这些属性就可以随它传播了。Channel 中也维护了一个 AttributeMap,与 ChannelHandlerContext 中的 AttributeMap,从 Netty 4.1 开始,都是作用于整个 ChannelPipeline。</p>
|
||||
<p>通过上述分析,我们可以了解到,<strong>一个 Channel 对应一个 ChannelPipeline,一个 ChannelHandlerContext 对应一个ChannelHandler。</strong> 如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9Ixi-APR5UAADY4pM97IU060.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9Ixi-APR5UAADY4pM97IU060.png" alt="png" /></p>
|
||||
<p>最后,需要注意的是,如果要在 ChannelHandler 中执行耗时较长的逻辑,例如,操作 DB 、进行网络或磁盘 I/O 等操作,一般会在注册到 ChannelPipeline 的同时,指定一个线程池异步执行 ChannelHandler 中的操作。</p>
|
||||
<h3>NioEventLoop</h3>
|
||||
<p>在前文介绍 Netty 线程模型的时候,我们简单提到了 NioEventLoop 这个组件,当时为了便于理解,只是简单将其描述成了一个线程。</p>
|
||||
@ -341,7 +341,7 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<ul>
|
||||
<li><strong>普通任务队列</strong>。用户产生的普通任务可以提交到该队列中暂存,NioEventLoop 发现该队列中的任务后会立即执行。这是一个多生产者、单消费者的队列,Netty 使用该队列将外部用户线程产生的任务收集到一起,并在 Reactor 线程内部用单线程的方式串行执行队列中的任务。例如,外部非 I/O 线程调用了 Channel 的 write() 方法,Netty 会将其封装成一个任务放入 TaskQueue 队列中,这样,所有的 I/O 操作都会在 I/O 线程中串行执行。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9IxjqAJwMsAAFsUFuiU6A398.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9IxjqAJwMsAAFsUFuiU6A398.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>定时任务队列</strong>。当用户在非 I/O 线程产生定时操作时,Netty 将用户的定时操作封装成定时任务,并将其放入该定时任务队列中等待相应 NioEventLoop 串行执行。</li>
|
||||
</ul>
|
||||
@ -349,10 +349,10 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<h3>NioEventLoopGroup</h3>
|
||||
<p><strong>NioEventLoopGroup 表示的是一组 NioEventLoop</strong>。Netty 为了能更充分地利用多核 CPU 资源,一般会有多个 NioEventLoop 同时工作,至于多少线程可由用户决定,Netty 会根据实际上的处理器核数计算一个默认值,具体计算公式是:CPU 的核心数 * 2,当然我们也可以根据实际情况手动调整。</p>
|
||||
<p>当一个 Channel 创建之后,Netty 会调用 NioEventLoopGroup 提供的 next() 方法,按照一定规则获取其中一个 NioEventLoop 实例,并将 Channel 注册到该 NioEventLoop 实例,之后,就由该 NioEventLoop 来处理 Channel 上的事件。EventLoopGroup、EventLoop 以及 Channel 三者的关联关系,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9IloyAHiBiAALeke8Ln5c195.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9IloyAHiBiAALeke8Ln5c195.png" alt="png" /></p>
|
||||
<p>前面我们提到过,在 Netty 服务器端中,会有 BossEventLoopGroup 和 WorkerEventLoopGroup 两个 NioEventLoopGroup。通常一个服务端口只需要一个ServerSocketChannel,对应一个 Selector 和一个 NioEventLoop 线程。</p>
|
||||
<p>BossEventLoop 负责接收客户端的连接事件,即 OP_ACCEPT 事件,然后将创建的 NioSocketChannel 交给 WorkerEventLoopGroup; WorkerEventLoopGroup 会由 next() 方法选择其中一个 NioEventLoopGroup,并将这个 NioSocketChannel 注册到其维护的 Selector 并对其后续的I/O事件进行处理。</p>
|
||||
<p><img src="assets/CgqCHl9RrYCAVrHFAAF5lFo-Hgc219.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl9RrYCAVrHFAAF5lFo-Hgc219.png" alt="png" /></p>
|
||||
<p>如上图,BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个 Selector 对象,其上注册了一个 ServerSocketChannel,BoosEventLoop 会不断轮询 Selector 监听连接事件,在发生连接事件时,通过 accept 操作与客户端创建连接,创建 SocketChannel 对象。然后将 accept 操作得到的 SocketChannel 交给 WorkerEventLoopGroup,在Reactor 模式中 WorkerEventLoopGroup 中会维护多个 EventLoop,而每个 EventLoop 都会监听分配给它的 SocketChannel 上发生的 I/O 事件,并将这些具体的事件分发给业务线程池处理。</p>
|
||||
<h3>ByteBuf</h3>
|
||||
<p>通过前文的介绍,我们了解了 Netty 中数据的流向,这里我们再来介绍一下<strong>数据的容器——ByteBuf</strong>。</p>
|
||||
@ -360,7 +360,7 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<p>ByteBuf 类似于一个字节数组,其中维护了一个读索引和一个写索引,分别用来控制对 ByteBuf 中数据的读写操作,两者符合下面的不等式:</p>
|
||||
<pre><code>0 <= readerIndex <= writerIndex <= capacity
|
||||
</code></pre>
|
||||
<p><img src="assets/CgqCHl9IlsaAJYcYAAA31kf6i_g696.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl9IlsaAJYcYAAA31kf6i_g696.png" alt="png" /></p>
|
||||
<p>ByteBuf 提供的读写操作 API 主要操作底层的字节容器(byte[]、ByteBuffer 等)以及读写索引这两指针,你若感兴趣的话,可以查阅相关的 API 说明,这里不再展开介绍。</p>
|
||||
<p>Netty 中主要分为以下三大类 ByteBuf:</p>
|
||||
<ul>
|
||||
@ -377,13 +377,13 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<p>下面我们从如何高效分配和释放内存、如何减少内存碎片以及在多线程环境下如何减少锁竞争这三个方面介绍一下 Netty 提供的 ByteBuf 池化技术。</p>
|
||||
<p>Netty 首先会向系统申请一整块连续内存,称为 Chunk(默认大小为 16 MB),这一块连续的内存通过 PoolChunk 对象进行封装。之后,Netty 将 Chunk 空间进一步拆分为 Page,每个 Chunk 默认包含 2048 个 Page,每个 Page 的大小为 8 KB。</p>
|
||||
<p>在同一个 Chunk 中,Netty 将 Page 按照不同粒度进行分层管理。如下图所示,从下数第 1 层中每个分组的大小为 1 * PageSize,一共有 2048 个分组;第 2 层中每个分组大小为 2 * PageSize,一共有 1024 个组;第 3 层中每个分组大小为 4 * PageSize,一共有 512 个组;依次类推,直至最顶层。</p>
|
||||
<p><img src="assets/CgqCHl9IlteANQ8lAADm9qN5mgE993.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9IlteANQ8lAADm9qN5mgE993.png" alt="png" /></p>
|
||||
<h4>1. 内存分配&释放</h4>
|
||||
<p>当服务向内存池请求内存时,Netty 会将请求分配的内存数向上取整到最接近的分组大小,然后在该分组的相应层级中从左至右寻找空闲分组。例如,服务请求分配 3 * PageSize 的内存,向上取整得到的分组大小为 4 * PageSize,在该层分组中找到完全空闲的一组内存进行分配即可,如下图:</p>
|
||||
<p><img src="assets/Ciqc1F9IluuAFgouAADoPKhjuW4842.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9IluuAFgouAADoPKhjuW4842.png" alt="png" /></p>
|
||||
<p>当分组大小 4 * PageSize 的内存分配出去后,为了方便下次内存分配,分组被标记为全部已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)。</p>
|
||||
<p>Netty 使用<strong>完全平衡树的结构</strong>实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9IlvKACdYpAAF2w22m4sQ981.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9IlvKACdYpAAF2w22m4sQ981.png" alt="png" /></p>
|
||||
<p>具体的实现逻辑这里就不再展开讲述了,你若感兴趣的话,可以参考 Netty 代码。</p>
|
||||
<h4>2. 大对象&小对象的处理</h4>
|
||||
<p>当申请分配的对象是超过 Chunk 容量的大型对象,Netty 就不再使用池化管理方式了,在每次请求分配内存时单独创建特殊的非池化 PoolChunk 对象进行管理,当对象内存释放时整个PoolChunk 内存释放。</p>
|
||||
@ -393,16 +393,16 @@ p.addLast("5", new InboundOutboundHandlerX());
|
||||
<li>小型对象:规整后的大小为 2 的幂,如 512、1024、2048、4096,一共 4 种大小。</li>
|
||||
</ul>
|
||||
<p>Netty 的实现会先从 PoolChunk 中申请空闲 Page,同一个 Page 分为相同大小的小 Buffer 进行存储;这些 Page 用 PoolSubpage 对象进行封装,PoolSubpage 内部会记录它自己能分配的小 Buffer 的规格大小、可用内存数量,并通过 bitmap 的方式记录各个小内存的使用情况(如下图所示)。虽然这种方案不能完美消灭内存碎片,但是很大程度上还是减少了内存浪费。</p>
|
||||
<p><img src="assets/CgqCHl9Ilv-ABhKnAAFcBHa9_3E953.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl9Ilv-ABhKnAAFcBHa9_3E953.png" alt="png" /></p>
|
||||
<p>为了解决单个 PoolChunk 容量有限的问题,Netty 将多个 PoolChunk 组成链表一起管理,然后用 PoolChunkList 对象持有链表的 head。</p>
|
||||
<p><strong>Netty 通过 PoolArena 管理 PoolChunkList 以及 PoolSubpage</strong>。</p>
|
||||
<p>PoolArena 内部持有 6 个 PoolChunkList,各个 PoolChunkList 持有的 PoolChunk 的使用率区间有所不同,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9IlwaAMIzVAACq5iIjcZk639.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgqCHl9IlwaAMIzVAACq5iIjcZk639.png" alt="png" /></p>
|
||||
<p>6 个 PoolChunkList 对象组成双向链表,当 PoolChunk 内存分配、释放,导致使用率变化,需要判断 PoolChunk 是否超过所在 PoolChunkList 的限定使用率范围,如果超出了,需要沿着 6 个 PoolChunkList 的双向链表找到新的合适的 PoolChunkList ,成为新的 head。同样,当新建 PoolChunk 分配内存或释放空间时,PoolChunk 也需要按照上面逻辑放入合适的PoolChunkList 中。</p>
|
||||
<p><img src="assets/CgqCHl9Ilw2ATgO0AACWza-dQV4641.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/CgqCHl9Ilw2ATgO0AACWza-dQV4641.png" alt="png" /></p>
|
||||
<p>从上图可以看出,这 6 个 PoolChunkList 额定使用率区间存在交叉,这样设计的原因是:如果使用单个临界值的话,当一个 PoolChunk 被来回申请和释放,内存使用率会在临界值上下徘徊,这就会导致它在两个 PoolChunkList 链表中来回移动。</p>
|
||||
<p>PoolArena 内部持有 2 个 PoolSubpage 数组,分别存储微型 Buffer 和小型 Buffer 的PoolSubpage。相同大小的 PoolSubpage 组成链表,不同大小的 PoolSubpage 链表的 head 节点保存在 tinySubpagePools 或者 smallSubpagePools 数组中,如下图:</p>
|
||||
<p><img src="assets/Ciqc1F9IlxSAWAuXAADUtE1ddhw421.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9IlxSAWAuXAADUtE1ddhw421.png" alt="png" /></p>
|
||||
<h4>3. 并发处理</h4>
|
||||
<p>内存分配释放不可避免地会遇到多线程并发场景,PoolChunk 的完全平衡树标记以及 PoolSubpage 的 bitmap 标记都是多线程不安全的,都是需要加锁同步的。为了减少线程间的竞争,Netty 会提前创建多个 PoolArena(默认数量为 2 * CPU 核心数),当线程首次请求池化内存分配,会找被最少线程持有的 PoolArena,并保存线程局部变量 PoolThreadCache 中,实现线程与 PoolArena 的关联绑定。</p>
|
||||
<p>Netty 还提供了延迟释放的功能,来提升并发性能。当内存释放时,PoolArena 并没有马上释放,而是先尝试将该内存关联的 PoolChunk 和 Chunk 中的偏移位置等信息存入 ThreadLocal 的固定大小缓存队列中,如果该缓存队列满了,则马上释放内存。当有新的分配请求时,PoolArena 会优先访问线程本地的缓存队列,查询是否有缓存可用,如果有,则直接分配,提高分配效率。</p>
|
||||
|
@ -292,7 +292,7 @@ function hide_canvas() {
|
||||
<div><h1>11 简易版 RPC 框架实现(上)</h1>
|
||||
<p>这是“基础知识”部分的最后一课时,我们将会运用前面介绍的基础知识来做一个实践项目 —— 编写一个简易版本的 RPC 框架,作为“基础知识”部分的总结和回顾。</p>
|
||||
<p>RPC 是“远程过程调用(Remote Procedure Call)”的缩写形式,比较通俗的解释是:<strong>像本地方法调用一样调用远程的服务</strong>。虽然 RPC 的定义非常简单,但是相对完整的、通用的 RPC 框架涉及很多方面的内容,例如注册发现、服务治理、负载均衡、集群容错、RPC 协议等,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9N7ryADlJBAAIlSORjRt8863.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9N7ryADlJBAAIlSORjRt8863.png" alt="png" /></p>
|
||||
<p>简易 RPC 框架的架构图</p>
|
||||
<p>本课时我们主要实现<strong>RPC 框架的基石部分——远程调用</strong>,简易版 RPC 框架一次远程调用的核心流程是这样的:</p>
|
||||
<ol>
|
||||
@ -306,7 +306,7 @@ function hide_canvas() {
|
||||
<p>这个远程调用的过程,就是我们简易版本 RPC 框架的核心实现,只有理解了这个流程,才能进行后续的开发。</p>
|
||||
<h3>项目结构</h3>
|
||||
<p>了解了简易版 RPC 框架的工作流程和实现目标之后,我们再来看下项目的结构,为了方便起见,这里我们将整个项目放到了一个 Module 中了,如下图所示,你可以按照自己的需求进行模块划分。</p>
|
||||
<p><img src="assets/Ciqc1F9N7suABhc9AAEN07V6uf8238.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F9N7suABhc9AAEN07V6uf8238.png" alt="png" /></p>
|
||||
<p>那这各个包的功能是怎样的呢?我们就来一一说明。</p>
|
||||
<ul>
|
||||
<li>protocol:简易版 RPC 框架的自定义协议。</li>
|
||||
@ -321,7 +321,7 @@ function hide_canvas() {
|
||||
<p>从功能角度考虑,HTTP 协议在 1.X 时代,只支持半双工传输模式,虽然支持长连接,但是不支持服务端主动推送数据。从效率角度来看,在一次简单的远程调用中,只需要传递方法名和加个简单的参数,此时,HTTP 请求中大部分数据都被 HTTP Header 占据,真正的有效负载非常少,效率就比较低。</p>
|
||||
<p>当然,HTTP 协议也有自己的优势,例如,天然穿透防火墙,大量的框架和开源软件支持 HTTP 接口,而且配合 REST 规范使用也是很便捷的,所以有很多 RPC 框架直接使用 HTTP 协议,尤其是在 HTTP 2.0 之后,如 gRPC、Spring Cloud 等。</p>
|
||||
<p>这里我们自定义一个简易版的 Demo RPC 协议,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9N7tiAMnZdAAC77BEcyZk305.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F9N7tiAMnZdAAC77BEcyZk305.png" alt="png" /></p>
|
||||
<p>在 Demo RPC 的消息头中,包含了整个 RPC 消息的一些控制信息,例如,版本号、魔数、消息类型、附加信息、消息 ID 以及消息体的长度,在附加信息(extraInfo)中,按位进行划分,分别定义消息的类型、序列化方式、压缩方式以及请求类型。当然,你也可以自己扩充 Demo RPC 协议,实现更加复杂的功能。</p>
|
||||
<p>Demo RPC 消息头对应的实体类是 Header,其定义如下:</p>
|
||||
<pre><code>public class Header {
|
||||
@ -391,7 +391,7 @@ public class Response implements Serializable {
|
||||
<h3>编解码实现</h3>
|
||||
<p>了解了自定义协议的结构之后,我们再来解决协议的编解码问题。</p>
|
||||
<p>前面课时介绍 Netty 核心概念的时候我们提到过,Netty 每个 Channel 绑定一个 ChannelPipeline,并依赖 ChannelPipeline 中添加的 ChannelHandler 处理接收到(或要发送)的数据,其中就包括字节到消息(以及消息到字节)的转换。Netty 中提供了 ByteToMessageDecoder、 MessageToByteEncoder、MessageToMessageEncoder、MessageToMessageDecoder 等抽象类来实现 Message 与 ByteBuf 之间的转换以及 Message 之间的转换,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9N89uAPRjFAAE8pTlyXls424.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl9N89uAPRjFAAE8pTlyXls424.png" alt="png" /></p>
|
||||
<p>Netty 提供的 Decoder 和 Encoder 实现</p>
|
||||
<p>在 Netty 的源码中,我们可以看到对很多已有协议的序列化和反序列化都是基于上述抽象类实现的,例如,HttpServerCodec 中通过依赖 HttpServerRequestDecoder 和 HttpServerResponseEncoder 来实现 HTTP 请求的解码和 HTTP 响应的编码。如下图所示,HttpServerRequestDecoder 继承自 ByteToMessageDecoder,实现了 ByteBuf 到 HTTP 请求之间的转换;HttpServerResponseEncoder 继承自 MessageToMessageEncoder,实现 HTTP 响应到其他消息的转换(其中包括转换成 ByteBuf 的能力)。</p>
|
||||
<p><img src="assets/CgqCHl9OAneAfCv0AADjLyEPSpc098.png" alt="HttpServerCodec结构图.png" /></p>
|
||||
|
@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<p>正如前文介绍 Netty 线程模型的时候提到,我们不能在 Netty 的 I/O 线程中执行耗时的业务逻辑。在 Demo RPC 框架的 Server 端接收到请求时,首先会通过上面介绍的 DemoRpcDecoder 反序列化得到请求消息,之后我们会通过一个自定义的 ChannelHandler(DemoRpcServerHandler)将请求提交给业务线程池进行处理。</p>
|
||||
<p>在 Demo RPC 框架的 Client 端接收到响应消息的时候,也是先通过 DemoRpcDecoder 反序列化得到响应消息,之后通过一个自定义的 ChannelHandler(DemoRpcClientHandler)将响应返回给上层业务。</p>
|
||||
<p>DemoRpcServerHandler 和 DemoRpcClientHandler 都继承自 SimpleChannelInboundHandler,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9R3QOAbbKRAAD4lAEEjtg767.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9R3QOAbbKRAAD4lAEEjtg767.png" alt="png" /></p>
|
||||
<p>DemoRpcClientHandler 和 DemoRpcServerHandler 的继承关系图</p>
|
||||
<p>下面我们就来看一下这两个自定义的 ChannelHandler 实现:</p>
|
||||
<pre><code>public class DemoRpcServerHandler extends
|
||||
@ -431,7 +431,7 @@ public class DemoRpcClientHandler extends
|
||||
}
|
||||
</code></pre>
|
||||
<p>通过 DemoRpcClient 的代码我们可以看到其 ChannelHandler 的执行顺序如下:</p>
|
||||
<p><img src="assets/Ciqc1F9R35eARBOdAAEUxDl6DGE227.png" alt="Lark20200904-143159.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9R35eARBOdAAEUxDl6DGE227.png" alt="png" /></p>
|
||||
<p>客户端 ChannelHandler 结构图</p>
|
||||
<p>另外,在创建EventLoopGroup时并没有直接使用NioEventLoopGroup,而是在 NettyEventLoopFactory 中根据当前操作系统进行选择,对于 Linux 系统,会使用 EpollEventLoopGroup,其他系统则使用 NioEventLoopGroup。</p>
|
||||
<p>接下来我们再看<strong>DemoRpcServer 的具体实现</strong>:</p>
|
||||
@ -475,13 +475,13 @@ public class DemoRpcClientHandler extends
|
||||
}
|
||||
</code></pre>
|
||||
<p>通过对 DemoRpcServer 实现的分析,我们可以知道每个 Channel 上的 ChannelHandler 顺序如下:</p>
|
||||
<p><img src="assets/CgqCHl9R34eAEosNAAEZMNHAB1c561.png" alt="Lark20200904-143204.png" /></p>
|
||||
<p><img src="assets/CgqCHl9R34eAEosNAAEZMNHAB1c561.png" alt="png" /></p>
|
||||
<p>服务端 ChannelHandler 结构图</p>
|
||||
<h3>registry 相关实现</h3>
|
||||
<p>介绍完客户端和服务端的通信之后,我们再来看简易 RPC 框架的另一个基础能力——服务注册与服务发现能力,对应 demo-rpc 项目源码中的 registry 包。</p>
|
||||
<p>registry 包主要是依赖 Apache Curator 实现了一个简易版本的 ZooKeeper 客户端,并基于 ZooKeeper 实现了注册中心最基本的两个功能:Provider 注册以及 Consumer 订阅。</p>
|
||||
<p>这里我们先定义一个 Registry 接口,其中提供了注册以及查询服务实例的方法,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9R3WuAd1UPAAA82c309GI280.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9R3WuAd1UPAAA82c309GI280.png" alt="png" /></p>
|
||||
<p>ZooKeeperRegistry 是基于 curator-x-discovery 对 Registry 接口的实现类型,其中封装了之前课时介绍的 ServiceDiscovery,并在其上添加了 ServiceCache 缓存提高查询效率。ZooKeeperRegistry 的具体实现如下:</p>
|
||||
<pre><code>public class ZookeeperRegistry<T> implements Registry<T> {
|
||||
private InstanceSerializer serializer =
|
||||
@ -628,7 +628,7 @@ public class DemoRpcClientHandler extends
|
||||
<p>你若感兴趣的话可以尝试进行扩展,以实现一个更加完善的代理层。</p>
|
||||
<h3>使用方接入</h3>
|
||||
<p>介绍完 Demo RPC 的核心实现之后,下面我们讲解下Demo RPC 框架的使用方式。这里涉及Consumer、DemoServiceImp、Provider三个类以及 DemoService 业务接口。</p>
|
||||
<p><img src="assets/Ciqc1F9R3YiAXV8hAAAtXArd3J0997.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9R3YiAXV8hAAAtXArd3J0997.png" alt="png" /></p>
|
||||
<p>使用接入的相关类</p>
|
||||
<p>首先,我们定义<strong>DemoService 接口</strong>作为业务 Server 接口,具体定义如下:</p>
|
||||
<pre><code>public interface DemoService {
|
||||
|
@ -291,7 +291,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>13 本地缓存:降低 ZooKeeper 压力的一个常用手段</h1>
|
||||
<p>从这一课时开始,我们就进入了第二部分:注册中心。注册中心(Registry)在微服务架构中的作用举足轻重,有了它,<strong>服务提供者(Provider)</strong> 和<strong>消费者(Consumer)</strong> 就能感知彼此。从下面的 Dubbo 架构图中可知:</p>
|
||||
<p><img src="assets/CgqCHl9W91KABCfoAAB7_C-aKWA893.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl9W91KABCfoAAB7_C-aKWA893.png" alt="png" /></p>
|
||||
<p>Dubbo 架构图</p>
|
||||
<ul>
|
||||
<li>Provider 从容器启动后的初始化阶段便会向注册中心完成注册操作;</li>
|
||||
@ -301,27 +301,27 @@ function hide_canvas() {
|
||||
<p>Registry 只是 Consumer 和 Provider 感知彼此状态变化的一种便捷途径而已,它们彼此的实际通讯交互过程是直接进行的,对于 Registry 来说是透明无感的。Provider 状态发生变化了,会由 Registry 主动推送订阅了该 Provider 的所有 Consumer,这保证了 Consumer 感知 Provider 状态变化的及时性,也将和具体业务需求逻辑交互解耦,提升了系统的稳定性。</p>
|
||||
<p>Dubbo 中存在很多概念,但有些理解起来就特别费劲,如本文的 Registry,翻译过来的意思是“注册中心”,但它其实是应用本地的注册中心客户端,<strong>真正的“注册中心”服务是其他独立部署的进程,或进程组成的集群,比如 ZooKeeper 集群</strong>。本地的 Registry 通过和 ZooKeeper 等进行实时的信息同步,维持这些内容的一致性,从而实现了注册中心这个特性。另外,就 Registry 而言,Consumer 和 Provider 只是个用户视角的概念,它们被抽象为了一条 URL 。</p>
|
||||
<p>从本课时开始,我们就真正开始分析 Dubbo 源码了。首先看一下本课程第二部分内容在 Dubbo 架构中所处的位置(如下图红框所示),可以看到这部分内容在整个 Dubbo 体系中还是相对独立的,没有涉及 Protocol、Invoker 等 Dubbo 内部的概念。等介绍完这些概念之后,我们还会回看图中 Registry 红框之外的内容。</p>
|
||||
<p><img src="assets/CgqCHl9W92uAEdHNAC1YtFrPHGA595.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9W92uAEdHNAC1YtFrPHGA595.png" alt="png" /></p>
|
||||
<p>整个 Dubbo 体系图</p>
|
||||
<h3>核心接口</h3>
|
||||
<p>作为“注册中心”部分的第一课时,我们有必要介绍下 dubbo-registry-api 模块中的核心抽象接口,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9W94aAIB3iAAE7RxqxFDw401.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9W94aAIB3iAAE7RxqxFDw401.png" alt="png" /></p>
|
||||
<p>在 Dubbo 中,一般使用 Node 这个接口来抽象节点的概念。<strong>Node</strong>不仅可以表示 Provider 和 Consumer 节点,还可以表示注册中心节点。Node 接口中定义了三个非常基础的方法(如下图所示):</p>
|
||||
<p><img src="assets/Ciqc1F9W942AJdaYAAAlxcqD4vE542.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9W942AJdaYAAAlxcqD4vE542.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>getUrl() 方法返回表示当前节点的 URL;</li>
|
||||
<li>isAvailable() 检测当前节点是否可用;</li>
|
||||
<li>destroy() 方法负责销毁当前节点并释放底层资源。</li>
|
||||
</ul>
|
||||
<p><strong>RegistryService 接口</strong>抽象了注册服务的基本行为,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9W95SAEiTBAABRqhrI6ig390.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9W95SAEiTBAABRqhrI6ig390.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>register() 方法和 unregister() 方法分别表示<strong>注册</strong>和<strong>取消注册</strong>一个 URL。</li>
|
||||
<li>subscribe() 方法和 unsubscribe() 方法分别表示<strong>订阅</strong>和<strong>取消订阅</strong>一个 URL。订阅成功之后,当订阅的数据发生变化时,注册中心会主动通知第二个参数指定的 NotifyListener 对象,NotifyListener 接口中定义的 notify() 方法就是用来接收该通知的。</li>
|
||||
<li>lookup() 方法能够<strong>查询</strong>符合条件的注册数据,它与 subscribe() 方法有一定的区别,subscribe() 方法采用的是 push 模式,lookup() 方法采用的是 pull 模式。</li>
|
||||
</ul>
|
||||
<p><strong>Registry 接口</strong>继承了 RegistryService 接口和 Node 接口,如下图所示,它表示的就是一个拥有注册中心能力的节点,其中的 reExportRegister() 和 reExportUnregister() 方法都是委托给 RegistryService 中的相应方法。</p>
|
||||
<p><img src="assets/Ciqc1F9W952Aesi9AAAjKOjjN0I785.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9W952Aesi9AAAjKOjjN0I785.png" alt="png" /></p>
|
||||
<p><strong>RegistryFactory 接口</strong>是 Registry 的工厂接口,负责创建 Registry 对象,具体定义如下所示,其中 @SPI 注解指定了默认的扩展名为 dubbo,@Adaptive 注解表示会生成适配器类并根据 URL 参数中的 protocol 参数值选择相应的实现。</p>
|
||||
<pre><code>@SPI("dubbo")
|
||||
public interface RegistryFactory {
|
||||
@ -330,9 +330,9 @@ public interface RegistryFactory {
|
||||
}
|
||||
</code></pre>
|
||||
<p>通过下面两张继承关系图可以看出,每个 Registry 实现类都有对应的 RegistryFactory 工厂实现,每个 RegistryFactory 工厂实现只负责创建对应的 Registry 对象。</p>
|
||||
<p><img src="assets/CgqCHl9W96aAbyVRAAIzHNPLhSM843.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl9W96aAbyVRAAIzHNPLhSM843.png" alt="png" /></p>
|
||||
<p>RegistryFactory 继承关系图</p>
|
||||
<p><img src="assets/Ciqc1F9W97CAdPcXAAG1fsVxaeI019.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9W97CAdPcXAAG1fsVxaeI019.png" alt="png" /></p>
|
||||
<p>Registry 继承关系图</p>
|
||||
<p>其中,RegistryFactoryWrapper 是 RegistryFactory 接口的 Wrapper 类,它在底层 RegistryFactory 创建的 Registry 对象外层封装了一个 ListenerRegistryWrapper ,ListenerRegistryWrapper 中维护了一个 RegistryServiceListener 集合,会将 register()、subscribe() 等事件通知到 RegistryServiceListener 监听器。</p>
|
||||
<p>AbstractRegistryFactory 是一个实现了 RegistryFactory 接口的抽象类,提供了规范 URL 的操作以及缓存 Registry 对象的公共能力。其中,缓存 Registry 对象是使用 HashMap<String, Registry> 集合实现的(REGISTRIES 静态字段)。在规范 URL 的实现逻辑中,AbstractRegistryFactory 会将 RegistryService 的类名设置为 URL path 和 interface 参数,同时删除 export 和 refer 参数。</p>
|
||||
@ -404,7 +404,7 @@ protected void notify(URL url, NotifyListener listener,
|
||||
<p>subscribe() 方法会将当前节点作为 Consumer 的 URL 以及相关的 NotifyListener 记录到 subscribed 集合,unsubscribe() 方法会将当前节点的 URL 以及关联的 NotifyListener 从 subscribed 集合删除。</p>
|
||||
<p>这四个方法都是简单的集合操作,这里我们就不再展示具体代码了。</p>
|
||||
<p>单看 AbstractRegistry 的实现,上述四个基础的注册、订阅方法都是内存操作,但是 Java 有继承和多态的特性,AbstractRegistry 的子类会覆盖上述四个基础的注册、订阅方法进行增强。</p>
|
||||
<p><img src="assets/Ciqc1F9W9-eAHUVPAACO6kbGAbU855.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9W9-eAHUVPAACO6kbGAbU855.png" alt="png" /></p>
|
||||
<h4>3. 恢复/销毁</h4>
|
||||
<p>AbstractRegistry 中还有另外两个需要关注的方法:<strong>recover() 方法</strong>和<strong>destroy() 方法</strong>。</p>
|
||||
<p>在 Provider 因为网络问题与注册中心断开连接之后,会进行重连,重新连接成功之后,会调用 recover() 方法将 registered 集合中的全部 URL 重新走一遍 register() 方法,恢复注册数据。同样,recover() 方法也会将 subscribed 集合中的 URL 重新走一遍 subscribe() 方法,恢复订阅监听器。recover() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话,可以参考源码进行学习。</p>
|
||||
|
@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<p>Dubbo 本身是一个分布式的 RPC 开源框架,各个依赖于 Dubbo 的服务节点都是单独部署的,为了让 Provider 和 Consumer 能够实时获取彼此的信息,就得依赖于一个<strong>一致性的服务发现组件</strong>实现注册和订阅。Dubbo 可以接入多种服务发现组件,例如,ZooKeeper、etcd、Consul、Eureka 等。其中,Dubbo 特别推荐使用 ZooKeeper。</p>
|
||||
<p><strong>ZooKeeper 是为分布式应用所设计的高可用且一致性的开源协调服务</strong>。它是一个树型的目录服务,支持变更推送,非常适合应用在生产环境中。</p>
|
||||
<p>下面是 Dubbo 官方文档中的一张图,展示了 Dubbo 在 Zookeeper 中的节点层级结构:</p>
|
||||
<p><img src="assets/Ciqc1F9gay-AdrWMAAGjEWP00aQ382.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9gay-AdrWMAAGjEWP00aQ382.png" alt="png" /></p>
|
||||
<p>Zookeeper 存储的 Dubbo 数据</p>
|
||||
<p>图中的“dubbo”节点是 Dubbo 在 Zookeeper 中的根节点,“dubbo”是这个根节点的默认名称,当然我们也可以通过配置进行修改。</p>
|
||||
<p>图中 Service 这一层的节点名称是服务接口的全名,例如 demo 示例中,该节点的名称为“org.apache.dubbo.demo.DemoService”。</p>
|
||||
@ -303,7 +303,7 @@ function hide_canvas() {
|
||||
<p>根据不同的 Type 节点,图中 URL 这一层中的节点包括:Provider URL 、Consumer URL 、Routes URL 和 Configurations URL。</p>
|
||||
<h3>ZookeeperRegistryFactory</h3>
|
||||
<p>在前面第 13 课时介绍 Dubbo 注册中心核心概念的时候,我们讲解了 RegistryFactory 这个工厂接口以及其子类 AbstractRegistryFactory,AbstractRegistryFactory 仅仅是提供了缓存 Registry 对象的功能,并未真正实现 Registry 的创建,具体的创建逻辑是由子类完成的。在 dubbo-registry-zookeeper 模块中的 SPI 配置文件(目录位置如下图所示)中,指定了<strong>RegistryFactory 的实现类—— ZookeeperRegistryFactory</strong>。</p>
|
||||
<p><img src="assets/CgqCHl9ga02AUesuAABPhgP1Voc406.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ga02AUesuAABPhgP1Voc406.png" alt="png" /></p>
|
||||
<p>RegistryFactory 的 SPI 配置文件位置</p>
|
||||
<p>ZookeeperRegistryFactory 实现了 AbstractRegistryFactory,其中的 createRegistry() 方法会创建 ZookeeperRegistry 实例,后续将由该 ZookeeperRegistry 实例完成与 Zookeeper 的交互。</p>
|
||||
<p>另外,ZookeeperRegistryFactory 中还提供了一个 setZookeeperTransporter() 方法,你可以回顾一下之前我们介绍的 Dubbo SPI 机制,会通过 SPI 或 Spring Ioc 的方式完成自动装载。</p>
|
||||
@ -319,7 +319,7 @@ public interface ZookeeperTransporter {
|
||||
}
|
||||
</code></pre>
|
||||
<p>我们从代码中可以看到,ZookeeperTransporter 接口被 @SPI 注解修饰,成为一个扩展点,默认选择扩展名 “curator” 的实现,其中的 connect() 方法用于创建 ZookeeperClient 实例(该方法被 @Adaptive 注解修饰,我们可以通过 URL 参数中的 client 或 transporter 参数覆盖 @SPI 注解指定的默认扩展名)。</p>
|
||||
<p><img src="assets/CgqCHl9ga2CAVhNZAACNo2yx1q4384.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ga2CAVhNZAACNo2yx1q4384.png" alt="png" /></p>
|
||||
<p>按照前面对 Registry 分析的思路,作为一个抽象实现,AbstractZookeeperTransporter 肯定是实现了创建 ZookeeperClient 之外的其他一些增强功能,然后由子类继承。不然的话,直接由 CuratorZookeeperTransporter 实现 ZookeeperTransporter 接口创建 ZookeeperClient 实例并返回即可,没必要在继承关系中再增加一层抽象类。</p>
|
||||
<pre><code>public class CuratorZookeeperTransporter extends
|
||||
AbstractZookeeperTransporter {
|
||||
@ -360,15 +360,15 @@ public interface ZookeeperTransporter {
|
||||
<ul>
|
||||
<li><strong>StateListener</strong>:主要负责监听 Dubbo 与 Zookeeper 集群的连接状态,包括 SESSION_LOST、CONNECTED、RECONNECTED、SUSPENDED 和 NEW_SESSION_CREATED。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9ga4GAQmYSAAAtjyGIDtE504.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ga4GAQmYSAAAtjyGIDtE504.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>DataListener</strong>:主要监听某个节点存储的数据变化。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9ga4qAVm-6AAAzoshbsio688.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ga4qAVm-6AAAzoshbsio688.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>**ChildListener:**主要监听某个 ZNode 节点下的子节点变化。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9ga4-Aa-4IAABLF9PT8ls256.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ga4-Aa-4IAABLF9PT8ls256.png" alt="png" /></p>
|
||||
<p>在 AbstractZookeeperClient 中维护了 stateListeners、listeners 以及 childListeners 三个集合,分别管理上述三种类型的监听器。虽然监听内容不同,但是它们的管理方式是类似的,所以这里我们只分析 listeners 集合的操作:</p>
|
||||
<pre><code>public void addDataListener(String path,
|
||||
DataListener listener, Executor executor) {
|
||||
@ -456,11 +456,11 @@ public interface ZookeeperTransporter {
|
||||
<p>在 ZookeeperRegistry 的构造方法中,会通过 ZookeeperTransporter 创建 ZookeeperClient 实例并连接到 Zookeeper 集群,同时还会添加一个连接状态的监听器。在该监听器中主要关注RECONNECTED 状态和 NEW_SESSION_CREATED 状态,在当前 Dubbo 节点与 Zookeeper 的连接恢复或是 Session 恢复的时候,会重新进行注册/订阅,防止数据丢失。这段代码比较简单,我们就不展开分析了。</p>
|
||||
<p>doRegister() 方法和 doUnregister() 方法的实现都是通过 ZookeeperClient 找到合适的路径,然后创建(或删除)相应的 ZNode 节点。这里唯一需要注意的是,doRegister() 方法注册 Provider URL 的时候,会根据 dynamic 参数决定创建临时 ZNode 节点还是持久 ZNode 节点(默认创建临时 ZNode 节点),这样当 Provider 端与 Zookeeper 会话关闭时,可以快速将变更推送到 Consumer 端。</p>
|
||||
<p>这里注意一下 toUrlPath() 这个方法得到的路径,是由下图中展示的方法拼装而成的,其中每个方法对应本课时开始展示的 Zookeeper 节点层级图中的一层。</p>
|
||||
<p><img src="assets/Ciqc1F9ga6qAOzWsAAGn7w4zPbo192.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ga6qAOzWsAAGn7w4zPbo192.png" alt="png" /></p>
|
||||
<p>doSubscribe() 方法的核心是通过 ZookeeperClient 在指定的 path 上添加 ChildListener 监听器,当订阅的节点发现变化的时候,会通过 ChildListener 监听器触发 notify() 方法,在 notify() 方法中会触发传入的 NotifyListener 监听器。</p>
|
||||
<p>从 doSubscribe() 方法的代码结构可看出,doSubscribe() 方法的逻辑分为了两个大的分支。</p>
|
||||
<p>一个分支是处理:订阅 URL 中明确指定了 Service 层接口的订阅请求。该分支会从 URL 拿到 Consumer 关注的 category 节点集合,然后在每个 category 节点上添加 ChildListener 监听器。下面是 Demo 示例中 Consumer 订阅的三个 path,图中展示了构造 path 各个部分的相关方法:</p>
|
||||
<p><img src="assets/Ciqc1F9gc_WAYTGzAAEKDnK-16Q791.png" alt="Lark20200915-155646.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9gc_WAYTGzAAEKDnK-16Q791.png" alt="png" /></p>
|
||||
<p>下面是这个分支的核心源码分析:</p>
|
||||
<pre><code>List<URL> urls = new ArrayList<>();
|
||||
for (String path : toCategoriesPath(url)) { // 要订阅的所有path
|
||||
|
@ -316,7 +316,7 @@ function hide_canvas() {
|
||||
<p><strong>Protobuf(Google Protocol Buffers)是 Google 公司开发的一套灵活、高效、自动化的、用于对结构化数据进行序列化的协议</strong>。但相比于常用的 JSON 格式,Protobuf 有更高的转化效率,时间效率和空间效率都是 JSON 的 5 倍左右。Protobuf 可用于通信协议、数据存储等领域,它本身是语言无关、平台无关、可扩展的序列化结构数据格式。目前 Protobuf提供了 C++、Java、Python、Go 等多种语言的 API,gRPC 底层就是使用 Protobuf 实现的序列化。</p>
|
||||
<h3>dubbo-serialization</h3>
|
||||
<p>Dubbo 为了支持多种序列化算法,单独抽象了一层 Serialize 层,在整个 Dubbo 架构中处于最底层,对应的模块是 dubbo-serialization 模块。 dubbo-serialization 模块的结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9gbIiAdyaqAAB4bHnToKs832.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9gbIiAdyaqAAB4bHnToKs832.png" alt="png" /></p>
|
||||
<p>dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下:</p>
|
||||
<pre><code>@SPI("hessian2") // 被@SPI注解修饰,默认是使用hessian2序列化算法
|
||||
public interface Serialization {
|
||||
@ -335,7 +335,7 @@ public interface Serialization {
|
||||
}
|
||||
</code></pre>
|
||||
<p>Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9gbJKAFOslAAFjEeB7nf0890.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9gbJKAFOslAAFjEeB7nf0890.png" alt="png" /></p>
|
||||
<p>这里我们<strong>以默认的 hessian2 序列化方式为例</strong>,介绍 Serialization 接口的实现以及其他相关实现。 Hessian2Serialization 实现如下所示:</p>
|
||||
<pre><code>public class Hessian2Serialization implements Serialization {
|
||||
public byte getContentTypeId() {
|
||||
@ -353,11 +353,11 @@ public interface Serialization {
|
||||
}
|
||||
</code></pre>
|
||||
<p>Hessian2Serialization 中的 serialize() 方法创建的 ObjectOutput 接口实现为 Hessian2ObjectOutput,继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9gbOiAG_1mAABH4c18z9c011.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9gbOiAG_1mAABH4c18z9c011.png" alt="png" /></p>
|
||||
<p>在 DataOutput 接口中定义了序列化 Java 中各种数据类型的相应方法,如下图所示,其中有序列化 boolean、short、int、long 等基础类型的方法,也有序列化 String、byte[] 的方法。</p>
|
||||
<p><img src="assets/Ciqc1F9gbO6AExKqAAB_Dm_zMt0793.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9gbO6AExKqAAB_Dm_zMt0793.png" alt="png" /></p>
|
||||
<p>ObjectOutput 接口继承了 DataOutput 接口,并在其基础之上,添加了序列化对象的功能,具体定义如下图所示,其中的 writeThrowable()、writeEvent() 和 writeAttachments() 方法都是调用 writeObject() 方法实现的。</p>
|
||||
<p><img src="assets/CgqCHl9gbPOATpsmAABH5ZuVc6E438.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9gbPOATpsmAABH5ZuVc6E438.png" alt="png" /></p>
|
||||
<p>Hessian2ObjectOutput 中会封装一个 Hessian2Output 对象,需要注意,这个对象是 ThreadLocal 的,与线程绑定。在 DataOutput 接口以及 ObjectOutput 接口中,序列化各类型数据的方法都会委托给 Hessian2Output 对象的相应方法完成,实现如下:</p>
|
||||
<pre><code>public class Hessian2ObjectOutput implements ObjectOutput {
|
||||
private static ThreadLocal<Hessian2Output> OUTPUT_TL = ThreadLocal.withInitial(() -> {
|
||||
@ -378,7 +378,7 @@ public interface Serialization {
|
||||
}
|
||||
</code></pre>
|
||||
<p>Hessian2Serialization 中的 deserialize() 方法创建的 ObjectInput 接口实现为 Hessian2ObjectInput,继承关系如下所示:</p>
|
||||
<p><img src="assets/CgqCHl9gbQ6AXSDeAABIcO3u8aY906.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9gbQ6AXSDeAABIcO3u8aY906.png" alt="png" /></p>
|
||||
<p>Hessian2ObjectInput 具体的实现与 Hessian2ObjectOutput 类似:在 DataInput 接口中实现了反序列化各种类型的方法,在 ObjectInput 接口中提供了反序列化 Java 对象的功能,在 Hessian2ObjectInput 中会将所有反序列化的实现委托为 Hessian2Input。</p>
|
||||
<p>了解了 Dubbo Serialize 层的核心接口以及 Hessian2 序列化算法的接入方式之后,你就可以亲自动手,去阅读其他序列化算法对应模块的代码。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -291,17 +291,17 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?</h1>
|
||||
<p>在本专栏的第二部分,我们深入介绍了 Dubbo 注册中心的相关实现,下面我们开始介绍 dubbo-remoting 模块,该模块提供了多种客户端和服务端通信的功能。在 Dubbo 的整体架构设计图中,我们可以看到最底层红色框选中的部分即为 Remoting 层,其中包括了 Exchange、Transport和Serialize 三个子层次。这里我们要介绍的 dubbo-remoting 模块主要对应 Exchange 和 Transport 两层。</p>
|
||||
<p><img src="assets/CgqCHl9ptP2ADxEXAAuW94W_upc465.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptP2ADxEXAAuW94W_upc465.png" alt="png" /></p>
|
||||
<p>Dubbo 整体架构设计图</p>
|
||||
<p>Dubbo 并没有自己实现一套完整的网络库,而是使用现有的、相对成熟的第三方网络库,例如,Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。</p>
|
||||
<p>下图展示了 dubbo-remoting 模块的结构,其中每个子模块对应一个第三方 NIO 框架,例如,dubbo-remoting-netty4 子模块使用 Netty4 实现 Dubbo 的远程通信,dubbo-remoting-grizzly 子模块使用 Grizzly 实现 Dubbo 的远程通信。</p>
|
||||
<p><img src="assets/Ciqc1F9ptRqAJLQnAABcIxQfCkc811.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ptRqAJLQnAABcIxQfCkc811.png" alt="png" /></p>
|
||||
<p>其中的 dubbo-remoting-zookeeper,我们在前面第 15 课时介绍基于 Zookeeper 的注册中心实现时已经讲解过了,它使用 Apache Curator 实现了与 Zookeeper 的交互。</p>
|
||||
<h3>dubbo-remoting-api 模块</h3>
|
||||
<p>需要注意的是,<strong>Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的</strong>,依赖关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9ptY2ADzl8AAEVDPN3HVo908.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptY2ADzl8AAEVDPN3HVo908.png" alt="png" /></p>
|
||||
<p>我们先来看一下 dubbo-remoting-api 中对整个 Remoting 层的抽象,dubbo-remoting-api 模块的结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9ptduASJsQAACrkCpgiGg477.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ptduASJsQAACrkCpgiGg477.png" alt="png" /></p>
|
||||
<p>一般情况下,我们会将功能类似或是相关联的类放到一个包中,所以我们需要先来了解 dubbo-remoting-api 模块中各个包的功能。</p>
|
||||
<ul>
|
||||
<li>buffer 包:定义了缓冲区相关的接口、抽象类以及实现类。缓冲区在NIO框架中是一个不可或缺的角色,在各个 NIO 框架中都有自己的缓冲区实现。这里的 buffer 包在更高的层面,抽象了各个 NIO 框架的缓冲区,同时也提供了一些基础实现。</li>
|
||||
@ -313,14 +313,14 @@ function hide_canvas() {
|
||||
<h3>传输层核心接口</h3>
|
||||
<p>在 Dubbo 中会抽象出一个“<strong>端点(Endpoint)</strong>”的概念,我们可以通过一个 ip 和 port 唯一确定一个端点,两个端点之间会创建 TCP 连接,可以双向传输数据。Dubbo 将 Endpoint 之间的 TCP 连接抽象为<strong>通道(Channel)</strong>,将发起请求的 Endpoint 抽象为<strong>客户端(Client)</strong>,将接收请求的 Endpoint 抽象为<strong>服务端(Server)</strong>。这些抽象出来的概念,也是整个 dubbo-remoting-api 模块的基础,下面我们会逐个进行介绍。</p>
|
||||
<p>Dubbo 中<strong>Endpoint 接口</strong>的定义如下:</p>
|
||||
<p><img src="assets/CgqCHl9pteqACl0cAABxWeZ6ox0288.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9pteqACl0cAABxWeZ6ox0288.png" alt="png" /></p>
|
||||
<p>如上图所示,这里的 get*() 方法是获得 Endpoint 本身的一些属性,其中包括获取 Endpoint 的本地地址、关联的 URL 信息以及底层 Channel 关联的 ChannelHandler。send() 方法负责数据发送,两个重载的区别在后面介绍 Endpoint 实现的时候我们再详细说明。最后两个 close() 方法的重载以及 startClose() 方法用于关闭底层 Channel ,isClosed() 方法用于检测底层 Channel 是否已关闭。</p>
|
||||
<p>Channel 是对两个 Endpoint 连接的抽象,好比连接两个位置的传送带,两个 Endpoint 传输的消息就好比传送带上的货物,消息发送端会往 Channel 写入消息,而接收端会从 Channel 读取消息。这与第 10 课时介绍的 Netty 中的 Channel 基本一致。</p>
|
||||
<p><img src="assets/CgqCHl9ptsaAeodMAACTIzdsI8g890.png" alt="Lark20200922-162359.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptsaAeodMAACTIzdsI8g890.png" alt="png" /></p>
|
||||
<p>下面是<strong>Channel 接口</strong>的定义,我们可以看出两点:一个是 Channel 接口继承了 Endpoint 接口,也具备开关状态以及发送数据的能力;另一个是可以在 Channel 上附加 KV 属性。</p>
|
||||
<p><img src="assets/Ciqc1F9ptfKAeNrwAADvN7mxisw072.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ptfKAeNrwAADvN7mxisw072.png" alt="png" /></p>
|
||||
<p><strong>ChannelHandler 是注册在 Channel 上的消息处理器</strong>,在 Netty 中也有类似的抽象,相信你对此应该不会陌生。下图展示了 ChannelHandler 接口的定义,在 ChannelHandler 中可以处理 Channel 的连接建立以及连接断开事件,还可以处理读取到的数据、发送的数据以及捕获到的异常。从这些方法的命名可以看到,它们都是动词的过去式,说明相应事件已经发生过了。</p>
|
||||
<p><img src="assets/CgqCHl9ptf-AM7HwAABIy1ahqFw153.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptf-AM7HwAABIy1ahqFw153.png" alt="png" /></p>
|
||||
<p>需要注意的是:ChannelHandler 接口被 @SPI 注解修饰,表示该接口是一个扩展点。</p>
|
||||
<p>在前面课时介绍 Netty 的时候,我们提到过有一类特殊的 ChannelHandler 专门负责实现编解码功能,从而实现字节数据与有意义的消息之间的转换,或是消息之间的相互转换。在dubbo-remoting-api 中也有相似的抽象,如下所示:</p>
|
||||
<pre><code>@SPI
|
||||
@ -339,9 +339,9 @@ public interface Codec2 {
|
||||
<p>这里需要关注的是 Codec2 接口被 @SPI 接口修饰了,表示该接口是一个扩展接口,同时其 encode() 方法和 decode() 方法都被 @Adaptive 注解修饰,也就会生成适配器类,其中会根据 URL 中的 codec 值确定具体的扩展实现类。</p>
|
||||
<p>DecodeResult 这个枚举是在处理 TCP 传输时粘包和拆包使用的,之前简易版本 RPC 也处理过这种问题,例如,当前能读取到的数据不足以构成一个消息时,就会使用 NEED_MORE_INPUT 这个枚举。</p>
|
||||
<p>接下来看<strong>Client 和 RemotingServer 两个接口</strong>,分别抽象了客户端和服务端,两者都继承了 Channel、Resetable 等接口,也就是说两者都具备了读写数据能力。</p>
|
||||
<p><img src="assets/CgqCHl9ptgaAPRDbAAA7kgy1X5k082.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptgaAPRDbAAA7kgy1X5k082.png" alt="png" /></p>
|
||||
<p>Client 和 Server 本身都是 Endpoint,只不过在语义上区分了请求和响应的职责,两者都具备发送的能力,所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel,而 Server 可以接收多个 Client 发起的 Channel 连接。所以在 RemotingServer 接口中定义了查询 Channel 的相关方法,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9pthSAPWv0AAA0yX1lW-Y033.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9pthSAPWv0AAA0yX1lW-Y033.png" alt="png" /></p>
|
||||
<p>Dubbo 在 Client 和 Server 之上又封装了一层<strong>Transporter 接口</strong>,其具体定义如下:</p>
|
||||
<pre><code>@SPI("netty")
|
||||
public interface Transporter {
|
||||
@ -355,10 +355,10 @@ public interface Transporter {
|
||||
</code></pre>
|
||||
<p>我们看到 Transporter 接口上有 @SPI 注解,它是一个扩展接口,默认使用“netty”这个扩展名,@Adaptive 注解的出现表示动态生成适配器类,会先后根据“server”“transporter”的值确定 RemotingServer 的扩展实现类,先后根据“client”“transporter”的值确定 Client 接口的扩展实现。</p>
|
||||
<p>Transporter 接口的实现有哪些呢?如下图所示,针对每个支持的 NIO 库,都有一个 Transporter 接口实现,散落在各个 dubbo-remoting-* 实现模块中。</p>
|
||||
<p><img src="assets/CgqCHl9pthuAFNMOAABRJaJXls0493.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHl9pthuAFNMOAABRJaJXls0493.png" alt="png" /></p>
|
||||
<p>这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。</p>
|
||||
<p><img src="assets/Ciqc1F9ptiCAHkUSAADCSKg5KhY994.png" alt="Drawing 10.png" />
|
||||
<img src="assets/CgqCHl9pti-AHj3DAACwPfuEgm8435.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9ptiCAHkUSAADCSKg5KhY994.png" alt="png" />
|
||||
<img src="assets/CgqCHl9pti-AHj3DAACwPfuEgm8435.png" alt="png" /></p>
|
||||
<p>相信看到这里,你应该已经发现 Transporter 这一层抽象出来的接口,与 Netty 的核心接口是非常相似的。那为什么要单独抽象出 Transporter层,而不是像简易版 RPC 框架那样,直接让上层使用 Netty 呢?</p>
|
||||
<p>其实这个问题的答案也呼之欲出了,Netty、Mina、Grizzly 这个 NIO 库对外接口和使用方式不一样,如果在上层直接依赖了 Netty 或是 Grizzly,就依赖了具体的 NIO 库实现,而不是依赖一个有传输能力的抽象,后续要切换实现的话,就需要修改依赖和接入的相关代码,非常容易改出 Bug。这也不符合设计模式中的开放-封闭原则。</p>
|
||||
<p>有了 Transporter 层之后,我们可以通过 Dubbo SPI 修改使用的具体 Transporter 扩展实现,从而切换到不同的 Client 和 RemotingServer 实现,达到底层 NIO 库切换的目的,而且无须修改任何代码。即使有更先进的 NIO 库出现,我们也只需要开发相应的 dubbo-remoting-* 实现模块提供 Transporter、Client、RemotingServer 等核心接口的实现,即可接入,完全符合开放-封闭原则。</p>
|
||||
@ -404,7 +404,7 @@ public interface Transporter {
|
||||
<li>无论是 Client 还是 RemotingServer,都会使用 ChannelHandler 处理 Channel 中传输的数据,其中负责编解码的 ChannelHandler 被抽象出为 Codec2 接口。</li>
|
||||
</ul>
|
||||
<p>整个架构如下图所示,与 Netty 的架构非常类似。</p>
|
||||
<p><img src="assets/CgqCHl9ptlyABsjpAAGGk7pFIzQ293.png" alt="Lark20200922-162354.png" /></p>
|
||||
<p><img src="assets/CgqCHl9ptlyABsjpAAGGk7pFIzQ293.png" alt="png" /></p>
|
||||
<p>Transporter 层整体结构图</p>
|
||||
<h3>总结</h3>
|
||||
<p>本课时我们首先介绍了 dubbo-remoting 模块在 Dubbo 架构中的位置,以及 dubbo-remoting 模块的结构。接下来分析了 dubbo-remoting 模块中各个子模块之间的依赖关系,并重点介绍了 dubbo-remoting-api 子模块中各个包的核心功能。最后我们还深入分析了整个 Transport 层的核心接口,以及这些接口抽象出来的 Transporter 架构。</p>
|
||||
|
@ -291,7 +291,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工</h1>
|
||||
<p>Buffer 是一种字节容器,在 Netty 等 NIO 框架中都有类似的设计,例如,Java NIO 中的ByteBuffer、Netty4 中的 ByteBuf。Dubbo 抽象出了 ChannelBuffer 接口对底层 NIO 框架中的 Buffer 设计进行统一,其子类如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9pudyACkPPAABei6G8kSc033.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl9pudyACkPPAABei6G8kSc033.png" alt="png" /></p>
|
||||
<p>ChannelBuffer 继承关系图</p>
|
||||
<p>下面我们就按照 ChannelBuffer 的继承结构,从顶层的 ChannelBuffer 接口开始,逐个向下介绍,直至最底层的各个实现类。</p>
|
||||
<h3>ChannelBuffer 接口</h3>
|
||||
@ -303,7 +303,7 @@ function hide_canvas() {
|
||||
<li>capacity()、clear()、copy() 等辅助方法用来获取 ChannelBuffer 容量以及实现清理、拷贝数据的功能,这里不再赘述。</li>
|
||||
<li>factory() 方法:该方法返回创建 ChannelBuffer 的工厂对象,ChannelBufferFactory 中定义了多个 getBuffer() 方法重载来创建 ChannelBuffer,如下图所示,这些 ChannelBufferFactory的实现都是单例的。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9pugWAMFoIAABVU01bqiI007.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9pugWAMFoIAABVU01bqiI007.png" alt="png" /></p>
|
||||
<p>ChannelBufferFactory 继承关系图</p>
|
||||
<p><strong>AbstractChannelBuffer 抽象类</strong>实现了 ChannelBuffer 接口的大部分方法,其核心是维护了以下四个索引。</p>
|
||||
<ul>
|
||||
@ -358,7 +358,7 @@ public ChannelBuffer getBuffer(byte[] array, int offset, int length) {
|
||||
<li>factory(ChannelBufferFactory 类型),用于创建被修饰的 HeapChannelBuffer 对象的 ChannelBufferFactory 工厂,默认为 HeapChannelBufferFactory。</li>
|
||||
</ul>
|
||||
<p>DynamicChannelBuffer 需要关注的是 ensureWritableBytes() 方法,该方法实现了动态扩容的功能,在每次写入数据之前,都需要调用该方法确定当前可用空间是否足够,调用位置如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9puiWABaDpAACaisslR0Q430.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9puiWABaDpAACaisslR0Q430.png" alt="png" /></p>
|
||||
<p>ensureWritableBytes() 方法如果检测到底层 ChannelBuffer 对象的空间不足,则会创建一个新的 ChannelBuffer(空间扩大为原来的两倍),然后将原来 ChannelBuffer 中的数据拷贝到新 ChannelBuffer 中,最后将 buffer 字段指向新 ChannelBuffer 对象,完成整个扩容操作。ensureWritableBytes() 方法的具体实现如下:</p>
|
||||
<pre><code>public void ensureWritableBytes(int minWritableBytes) {
|
||||
if (minWritableBytes <= writableBytes()) {
|
||||
@ -404,10 +404,10 @@ public void setBytes(int index, byte[] src, int srcIndex, int length) {
|
||||
<p>NettyBackedChannelBuffer 对 ChannelBuffer 接口的实现都是调用底层封装的 Netty ByteBuf 实现的,这里就不再展开介绍,你若感兴趣的话也可以参考相关代码进行学习。</p>
|
||||
<h3>相关 Stream 以及门面类</h3>
|
||||
<p>在 ChannelBuffer 基础上,Dubbo 提供了一套输入输出流,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9puj2AXLalAALcfencKx0331.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9puj2AXLalAALcfencKx0331.png" alt="png" /></p>
|
||||
<p>ChannelBufferInputStream 底层封装了一个 ChannelBuffer,其实现 InputStream 接口的 read*() 方法全部都是从 ChannelBuffer 中读取数据。ChannelBufferInputStream 中还维护了一个 startIndex 和一个endIndex 索引,作为读取数据的起止位置。ChannelBufferOutputStream 与 ChannelBufferInputStream 类似,会向底层的 ChannelBuffer 写入数据,这里就不再展开,你若感兴趣的话可以参考源码进行分析。</p>
|
||||
<p>最后要介绍 ChannelBuffers 这个<strong>门面类</strong>,下图展示了 ChannelBuffers 这个门面类的所有方法:</p>
|
||||
<p><img src="assets/CgqCHl9pukOAT_8kAACo0xRQ2po574.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9pukOAT_8kAACo0xRQ2po574.png" alt="png" /></p>
|
||||
<p>对这些方法进行分类,可归纳出如下这些方法。</p>
|
||||
<ul>
|
||||
<li>dynamicBuffer() 方法:创建 DynamicChannelBuffer 对象,初始化大小由第一个参数指定,默认为 256。</li>
|
||||
|
@ -293,7 +293,7 @@ function hide_canvas() {
|
||||
<p>在第 17 课时中,我们详细介绍了 dubbo-remoting-api 模块中 Transporter 相关的核心抽象接口,本课时将继续介绍 dubbo-remoting-api 模块的其他内容。这里我们依旧从 Transporter 层的 RemotingServer、Client、Channel、ChannelHandler 等核心接口出发,介绍这些核心接口的实现。</p>
|
||||
<h3>AbstractPeer 抽象类</h3>
|
||||
<p>首先,我们来看 AbstractPeer 这个抽象类,它同时实现了 Endpoint 接口和 ChannelHandler 接口,如下图所示,它也是 AbstractChannel、AbstractEndpoint 抽象类的父类。</p>
|
||||
<p><img src="assets/Ciqc1F9wb8eAHyD_AAFkwn8xp18694.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wb8eAHyD_AAFkwn8xp18694.png" alt="png" /></p>
|
||||
<p>AbstractPeer 继承关系</p>
|
||||
<blockquote>
|
||||
<p>Netty 中也有 ChannelHandler、Channel 等接口,但无特殊说明的情况下,这里的接口指的都是 Dubbo 中定义的接口。如果涉及 Netty 中的接口,会进行特殊说明。</p>
|
||||
@ -338,7 +338,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h3>Server 继承路线分析</h3>
|
||||
<p>AbstractServer 和 AbstractClient 都实现了 AbstractEndpoint 抽象类,我们先来看 AbstractServer 的实现。AbstractServer 在继承了 AbstractEndpoint 的同时,还实现了 RemotingServer 接口,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9wb-iAMAgtAACJWi59iSc812.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wb-iAMAgtAACJWi59iSc812.png" alt="png" /></p>
|
||||
<p>AbstractServer 继承关系图</p>
|
||||
<p><strong>AbstractServer 是对服务端的抽象,实现了服务端的公共逻辑</strong>。AbstractServer 的核心字段有下面几个。</p>
|
||||
<ul>
|
||||
@ -390,7 +390,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>在 createExecutor() 方法中,会通过 Dubbo SPI 查找 ThreadPool 接口的扩展实现,并调用其 getExecutor() 方法创建线程池。ThreadPool 接口被 @SPI 注解修饰,默认使用 FixedThreadPool 实现,但是 ThreadPool 接口中的 getExecutor() 方法被 @Adaptive 注解修饰,动态生成的适配器类会优先根据 URL 中的 threadpool 参数选择 ThreadPool 的扩展实现。ThreadPool 接口的实现类如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9wcBeAYMZ1AABRTGzl5uY627.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcBeAYMZ1AABRTGzl5uY627.png" alt="png" /></p>
|
||||
<p>ThreadPool 继承关系图</p>
|
||||
<p>不同实现会根据 URL 参数创建不同特性的线程池,这里以<strong>CacheThreadPool</strong>为例进行分析:</p>
|
||||
<pre><code>public Executor getExecutor(URL url) {
|
||||
@ -514,12 +514,12 @@ protected void afterExecute(Runnable r, Throwable t) {
|
||||
</code></pre>
|
||||
<p>看完 NettyServer 实现的 doOpen() 方法之后,你会发现它和简易版 RPC 框架中启动一个 Netty 的 Server 端基本流程类似:初始化 ServerBootstrap、创建 Boss EventLoopGroup 和 Worker EventLoopGroup、创建 ChannelInitializer 指定如何初始化 Channel 上的 ChannelHandler 等一系列 Netty 使用的标准化流程。</p>
|
||||
<p>其实在 Transporter 这一层看,功能的不同其实就是注册在 Channel 上的 ChannelHandler 不同,通过 doOpen() 方法得到的 Server 端结构如下:</p>
|
||||
<p><img src="assets/Ciqc1F9y4LaAIHSsAADBytWDQ3U695.png" alt="5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9y4LaAIHSsAADBytWDQ3U695.png" alt="png" /></p>
|
||||
<p>NettyServer 模型</p>
|
||||
<h4>核心 ChannelHandler</h4>
|
||||
<p>下面我们来逐个看看这四个 ChannelHandler 的核心功能。</p>
|
||||
<p>首先是<strong>decoder 和 encoder</strong>,它们都是 NettyCodecAdapter 的内部类,如下图所示,分别继承了 Netty 中的 ByteToMessageDecoder 和 MessageToByteEncoder:</p>
|
||||
<p><img src="assets/CgqCHl9wcESANfPCAABDUdzhtNU066.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcESANfPCAABDUdzhtNU066.png" alt="png" /></p>
|
||||
<p>还记得 AbstractEndpoint 抽象类中的 codec 字段(Codec2 类型)吗?InternalDecoder 和 InternalEncoder 会将真正的编解码功能委托给 NettyServer 关联的这个 Codec2 对象去处理,这里以 InternalDecoder 为例进行分析:</p>
|
||||
<pre><code>private class InternalDecoder extends ByteToMessageDecoder {
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> out) throws Exception {
|
||||
@ -550,17 +550,17 @@ protected void afterExecute(Runnable r, Throwable t) {
|
||||
<p>InternalEncoder 的具体实现就不再展开讲解了,你若感兴趣可以翻看源码进行研究和分析。</p>
|
||||
<p>接下来是<strong>IdleStateHandler</strong>,它是 Netty 提供的一个工具型 ChannelHandler,用于定时心跳请求的功能或是自动关闭长时间空闲连接的功能。它的原理到底是怎样的呢?在 IdleStateHandler 中通过 lastReadTime、lastWriteTime 等几个字段,记录了最近一次读/写事件的时间,IdleStateHandler 初始化的时候,会创建一个定时任务,定时检测当前时间与最后一次读/写时间的差值。如果超过我们设置的阈值(也就是上面 NettyServer 中设置的 idleTimeout),就会触发 IdleStateEvent 事件,并传递给后续的 ChannelHandler 进行处理。后续 ChannelHandler 的 userEventTriggered() 方法会根据接收到的 IdleStateEvent 事件,决定是关闭长时间空闲的连接,还是发送心跳探活。</p>
|
||||
<p>最后来看<strong>NettyServerHandler</strong>,它继承了 ChannelDuplexHandler,这是 Netty 提供的一个同时处理 Inbound 数据和 Outbound 数据的 ChannelHandler,从下面的继承图就能看出来。</p>
|
||||
<p><img src="assets/Ciqc1F9wcFKAQQZ3AAB282frbWw282.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wcFKAQQZ3AAB282frbWw282.png" alt="png" /></p>
|
||||
<p>NettyServerHandler 继承关系图</p>
|
||||
<p>在 NettyServerHandler 中有 channels 和 handler 两个核心字段。</p>
|
||||
<ul>
|
||||
<li>channels(Map<String,Channel>集合):记录了当前 Server 创建的所有 Channel,从下图中可以看到,连接创建(触发 channelActive() 方法)、连接断开(触发 channelInactive()方法)会操作 channels 集合进行相应的增删。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9wcFuABJWsAAaIoTwCIA0958.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wcFuABJWsAAaIoTwCIA0958.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>handler(ChannelHandler 类型):NettyServerHandler 内几乎所有方法都会触发该 Dubbo ChannelHandler 对象(如下图)。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9wcGOAE_ykAAFvy5a4X58367.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcGOAE_ykAAFvy5a4X58367.png" alt="png" /></p>
|
||||
<p>这里以 write() 方法为例进行简单分析:</p>
|
||||
<pre><code>public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
|
||||
super.write(ctx, msg, promise); // 将发送的数据继续向下传递
|
||||
@ -575,10 +575,10 @@ protected void afterExecute(Runnable r, Throwable t) {
|
||||
<pre><code>final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this);
|
||||
</code></pre>
|
||||
<p>其中第二个参数传入的是 NettyServer 这个对象,你可以追溯一下 NettyServer 的继承结构,会发现它的最顶层父类 AbstractPeer 实现了 ChannelHandler,并且将所有的方法委托给其中封装的 ChannelHandler 对象,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9wcGuADQi3AAD6EEURlNU871.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wcGuADQi3AAD6EEURlNU871.png" alt="png" /></p>
|
||||
<p>也就是说,NettyServerHandler 会将数据委托给这个 ChannelHandler。</p>
|
||||
<p>到此为止,Server 这条继承线就介绍完了。你可以回顾一下,从 AbstractPeer 开始往下,一路继承下来,NettyServer 拥有了 Endpoint、ChannelHandler 以及RemotingServer多个接口的能力,关联了一个 ChannelHandler 对象以及 Codec2 对象,并最终将数据委托给这两个对象进行处理。所以,上层调用方只需要实现 ChannelHandler 和 Codec2 这两个接口就可以了。</p>
|
||||
<p><img src="assets/Ciqc1F9y4MyAR8XLAABTLdOZqrc228.png" alt="6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9y4MyAR8XLAABTLdOZqrc228.png" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>本课时重点介绍了 Dubbo Transporter 层中 Server 相关的实现。</p>
|
||||
<p>首先,我们介绍了 AbstractPeer 这个最顶层的抽象类,了解了 Server、Client 和 Channel 的公共属性。接下来,介绍了 AbstractEndpoint 抽象类,它提供了编解码等 Server 和 Client 所需的公共能力。最后,我们深入分析了 AbstractServer 抽象类以及基于 Netty 4 实现的 NettyServer,同时,还深入剖析了涉及的各种组件,例如,ExecutorRepository、NettyServerHandler 等。</p>
|
||||
|
@ -339,7 +339,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>得到的 NettyClient 结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F90P1yAYThvAADLV6SJeac973.png" alt="Lark20200930-161759.png" /></p>
|
||||
<p><img src="assets/Ciqc1F90P1yAYThvAADLV6SJeac973.png" alt="png" /></p>
|
||||
<p>NettyClient 结构图</p>
|
||||
<p>NettyClientHandler 的实现方法与上一课时介绍的 NettyServerHandler 类似,同样是实现了 Netty 中的 ChannelDuplexHandler,其中会将所有方法委托给 NettyClient 关联的 ChannelHandler 对象进行处理。两者在 userEventTriggered() 方法的实现上有所不同,NettyServerHandler 在收到 IdleStateEvent 事件时会断开连接,而 NettyClientHandler 则会发送心跳消息,具体实现如下:</p>
|
||||
<pre><code>public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
|
||||
@ -364,7 +364,7 @@ function hide_canvas() {
|
||||
<li>active(AtomicBoolean):用于标识当前 Channel 是否可用。</li>
|
||||
</ul>
|
||||
<p>另外,在 NettyChannel 中还有一个静态的 Map 集合(CHANNEL_MAP 字段),用来缓存当前 JVM 中 Netty 框架 Channel 与 Dubbo Channel 之间的映射关系。从下图的调用关系中可以看到,NettyChannel 提供了读写 CHANNEL_MAP 集合的方法:</p>
|
||||
<p><img src="assets/CgqCHl9wcRiAZFTaAADTxIPND7k175.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcRiAZFTaAADTxIPND7k175.png" alt="png" /></p>
|
||||
<p>NettyChannel 中还有一个要介绍的是 send() 方法,它会通过底层关联的 Netty 框架 Channel,将数据发送到对端。其中,可以通过第二个参数指定是否等待发送操作结束,具体实现如下:</p>
|
||||
<pre><code>public void send(Object message, boolean sent) throws RemotingException {
|
||||
// 调用AbstractChannel的send()方法检测连接是否可用
|
||||
@ -388,7 +388,7 @@ function hide_canvas() {
|
||||
<h3>ChannelHandler 继承线分析</h3>
|
||||
<p>前文介绍的 AbstractServer、AbstractClient 以及 Channel 实现,都是通过 AbstractPeer 实现了 ChannelHandler 接口,但只是做了一层简单的委托(也可以说成是装饰器),将全部方法委托给了其底层关联的 ChannelHandler 对象。</p>
|
||||
<p>这里我们就深入分析 ChannelHandler 的其他实现类,涉及的实现类如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F9wcSGAXo7JAANZ2BjquOE739.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wcSGAXo7JAANZ2BjquOE739.png" alt="png" /></p>
|
||||
<p>ChannelHandler 继承关系图</p>
|
||||
<p>其中<strong>ChannelHandlerDispatcher</strong>在[第 17 课时]已经介绍过了,它负责将多个 ChannelHandler 对象聚合成一个 ChannelHandler 对象。</p>
|
||||
<p><strong>ChannelHandlerAdapter</strong>是 ChannelHandler 的一个空实现,TelnetHandlerAdapter 继承了它并实现了 TelnetHandler 接口。至于Dubbo 对 Telnet 的支持,我们会在后面的课时中单独介绍,这里就先不展开分析了。</p>
|
||||
@ -420,7 +420,7 @@ function hide_canvas() {
|
||||
<p>通过上述介绍,我们发现 AbstractChannelHandlerDelegate 下的三个实现,其实都是在原有 ChannelHandler 的基础上添加了一些增强功能,这是典型的装饰器模式的应用。</p>
|
||||
<h4>Dispatcher 与 ChannelHandler</h4>
|
||||
<p>接下来,我们介绍 ChannelHandlerDelegate 接口的另一条继承线——<strong>WrappedChannelHandler</strong>,其子类主要是决定了 Dubbo 以何种线程模型处理收到的事件和消息,就是所谓的“消息派发机制”,与前面介绍的 ThreadPool 有紧密的联系。</p>
|
||||
<p><img src="assets/CgqCHl9wcTGAdInYAAJOSSxusf4539.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcTGAdInYAAJOSSxusf4539.png" alt="png" /></p>
|
||||
<p>WrappedChannelHandler 继承关系图</p>
|
||||
<p>从上图中我们可以看到,每个 WrappedChannelHandler 实现类的对象都由一个相应的 Dispatcher 实现类创建,下面是 Dispatcher 接口的定义:</p>
|
||||
<pre><code>@SPI(AllDispatcher.NAME) // 默认扩展名是all
|
||||
@ -520,7 +520,7 @@ public interface Dispatcher {
|
||||
<p>老版本中没有 ExecutorRepository 的概念,不会根据 URL 复用同一个线程池,而是通过 SPI 找到 ThreadPool 实现创建新线程池。</p>
|
||||
</blockquote>
|
||||
<p>此时,Dubbo Consumer 同步请求的线程模型如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9wcUWAY3b0AAFKI4e5Oa0017.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcUWAY3b0AAFKI4e5Oa0017.png" alt="png" /></p>
|
||||
<p>Dubbo Consumer 同步请求线程模型</p>
|
||||
<p>从图中我们可以看到下面的请求-响应流程:</p>
|
||||
<ol>
|
||||
@ -531,7 +531,7 @@ public interface Dispatcher {
|
||||
</ol>
|
||||
<p>在这个设计里面,Consumer 端会维护一个线程池,而且线程池是按照连接隔离的,即每个连接独享一个线程池。这样,当面临需要消费大量服务且并发数比较大的场景时,例如,典型网关类场景,可能会导致 Consumer 端线程个数不断增加,导致线程调度消耗过多 CPU ,也可能因为线程创建过多而导致 OOM。</p>
|
||||
<p>为了解决上述问题,Dubbo 在 2.7.5 版本之后,<strong>引入了 ThreadlessExecutor</strong>,将线程模型修改成了下图的样子:</p>
|
||||
<p><img src="assets/CgqCHl9wcVCAQdJjAAFE8eFivcY750.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcVCAQdJjAAFE8eFivcY750.png" alt="png" /></p>
|
||||
<p>引入 ThreadlessExecutor 后的结构图</p>
|
||||
<ol>
|
||||
<li>业务线程发出请求之后,拿到一个 Future 对象。</li>
|
||||
@ -586,10 +586,10 @@ public interface Dispatcher {
|
||||
}
|
||||
</code></pre>
|
||||
<p>结合前面的分析,我们可以得到下面这张图:</p>
|
||||
<p><img src="assets/Ciqc1F9wcV-AFAcTAADpElrp-Wc888.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9wcV-AFAcTAADpElrp-Wc888.png" alt="png" /></p>
|
||||
<p>Server 端 ChannelHandler 结构图</p>
|
||||
<p>我们可以在创建 NettyServerHandler 的地方添加断点 Debug 得到下图,也印证了上图的内容:</p>
|
||||
<p><img src="assets/CgqCHl9wcWaAJVA3AACBSF4eCzg786.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9wcWaAJVA3AACBSF4eCzg786.png" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>本课时我们重点介绍了 Dubbo Transporter 层中 Client、 Channel、ChannelHandler 相关的实现以及优化。</p>
|
||||
<p>首先我们介绍了 AbstractClient 抽象接口以及基于 Netty 4 的 NettyClient 实现。接下来,介绍了 AbstractChannel 抽象类以及 NettyChannel 实现。最后,我们深入分析了 ChannelHandler 接口实现,其中详细分析 WrappedChannelHandler 等关键 ChannelHandler 实现,以及 ThreadlessExecutor 优化。</p>
|
||||
|
@ -328,10 +328,10 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h3>ExchangeChannel & DefaultFuture</h3>
|
||||
<p>在前面的课时中,我们介绍了 Channel 接口的功能以及 Transport 层对 Channel 接口的实现。在 Exchange 层中定义了 ExchangeChannel 接口,它在 Channel 接口之上抽象了 Exchange 层的网络连接。ExchangeChannel 接口的定义如下:</p>
|
||||
<p><img src="assets/Ciqc1F90Q-OAE4K1AADklLgEs0k481.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F90Q-OAE4K1AADklLgEs0k481.png" alt="png" /></p>
|
||||
<p>ExchangeChannel 接口</p>
|
||||
<p>其中,request() 方法负责发送请求,从图中可以看到这里有两个重载,其中一个重载可以指定请求的超时时间,返回值都是 Future 对象。</p>
|
||||
<p><img src="assets/CgqCHl90Q_SAIt4sAAAzhH5TZiw571.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl90Q_SAIt4sAAAzhH5TZiw571.png" alt="png" /></p>
|
||||
<p>HeaderExchangeChannel 继承关系图</p>
|
||||
<p><strong>从上图中可以看出,HeaderExchangeChannel 是 ExchangeChannel 的实现</strong>,它本身是 Channel 的装饰器,封装了一个 Channel 对象,其 send() 方法和 request() 方法的实现都是依赖底层修饰的这个 Channel 对象实现的。</p>
|
||||
<pre><code>public void send(Object message, boolean sent) throws RemotingException {
|
||||
@ -454,10 +454,10 @@ private void notifyTimeout(DefaultFuture future) {
|
||||
<h3>HeaderExchangeHandler</h3>
|
||||
<p>在前面介绍 DefaultFuture 时,我们简单说明了请求-响应的流程,其实无论是发送请求还是处理响应,都会涉及 HeaderExchangeHandler,所以这里我们就来介绍一下 HeaderExchangeHandler 的内容。</p>
|
||||
<p><strong>HeaderExchangeHandler 是 ExchangeHandler 的装饰器</strong>,其中维护了一个 ExchangeHandler 对象,ExchangeHandler 接口是 Exchange 层与上层交互的接口之一,上层调用方可以实现该接口完成自身的功能;然后再由 HeaderExchangeHandler 修饰,具备 Exchange 层处理 Request-Response 的能力;最后再由 Transport ChannelHandler 修饰,具备 Transport 层的能力。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-FWUqAVkr0AADiEwO4wK4124.png" alt="Lark20201013-153600.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FWUqAVkr0AADiEwO4wK4124.png" alt="png" /></p>
|
||||
<p>ChannelHandler 继承关系总览图</p>
|
||||
<p>HeaderExchangeHandler 作为一个装饰器,其 connected()、disconnected()、sent()、received()、caught() 方法最终都会转发给上层提供的 ExchangeHandler 进行处理。这里我们需要聚焦的是 HeaderExchangeHandler 本身对 Request 和 Response 的处理逻辑。</p>
|
||||
<p><img src="assets/Ciqc1F-FWVeAbsckAAGeD-_NNHc225.png" alt="Lark20201013-153557.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FWVeAbsckAAGeD-_NNHc225.png" alt="png" /></p>
|
||||
<p>received() 方法处理的消息分类</p>
|
||||
<p>结合上图,我们可以看到在<strong>received() 方法</strong>中,对收到的消息进行了分类处理。</p>
|
||||
<ul>
|
||||
|
@ -299,7 +299,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>因此,HeaderExchangeClient 侧重定时轮资源的分配、定时任务的创建和取消。</p>
|
||||
<p>HeaderExchangeClient 实现的是 ExchangeClient 接口,如下图所示,间接实现了 ExchangeChannel 和 Client 接口,ExchangeClient 接口是个空接口,没有定义任何方法。</p>
|
||||
<p><img src="assets/Ciqc1F-AF6OAT7YOAAAw9BR-aXg961.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-AF6OAT7YOAAAw9BR-aXg961.png" alt="png" /></p>
|
||||
<p>HeaderExchangeClient 继承关系图</p>
|
||||
<p>HeaderExchangeClient 中有以下两个核心字段。</p>
|
||||
<ul>
|
||||
@ -327,7 +327,7 @@ function hide_canvas() {
|
||||
<p>其实,startReconnectTask() 方法的具体实现与前面展示的 startHeartBeatTask() 方法类似,这里就不再赘述。</p>
|
||||
<p>下面我们继续回到心跳定时任务进行分析,你可以回顾第 20 课时介绍的 NettyClient 实现,其 canHandleIdle() 方法返回 true,表示该实现可以自己发送心跳请求,无须 HeaderExchangeClient 再启动一个定时任务。NettyClient 主要依靠 IdleStateHandler 中的定时任务来触发心跳事件,依靠 NettyClientHandler 来发送心跳请求。</p>
|
||||
<p>对于无法自己发送心跳请求的 Client 实现,HeaderExchangeClient 会为其启动 HeartbeatTimerTask 心跳定时任务,其继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-AF7eAHJWXAABGVZRbaEE743.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl-AF7eAHJWXAABGVZRbaEE743.png" alt="png" /></p>
|
||||
<p>TimerTask 继承关系图</p>
|
||||
<p>我们先来看 AbstractTimerTask 这个抽象类,它有三个字段。</p>
|
||||
<ul>
|
||||
@ -377,7 +377,7 @@ function hide_canvas() {
|
||||
<p>在 HeaderExchangeChannel.close(timeout) 方法中首先会将自身的 closed 字段设置为 true,这样就不会继续发送请求。如果当前 Channel 上还有请求未收到响应,会循环等待至收到响应,如果超时未收到响应,会自己创建一个状态码将连接关闭的 Response 交给 DefaultFuture 处理,与收到 disconnected 事件相同。然后会关闭 Transport 层的 Channel,以 NettyChannel 为例,NettyChannel.close() 方法会先将自身的 closed 字段设置为 true,清理 CHANNEL_MAP 缓存中的记录,以及 Channel 的附加属性,最后才是关闭 io.netty.channel.Channel。</p>
|
||||
<h3>HeaderExchangeServer</h3>
|
||||
<p>下面再来看 HeaderExchangeServer,其继承关系如下图所示,其中 Endpoint、RemotingServer、Resetable 这三个接口我们在前面已经详细介绍过了,这里不再重复。</p>
|
||||
<p><img src="assets/CgqCHl-AF8eAUB8nAACKkTpW9nc845.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-AF8eAUB8nAACKkTpW9nc845.png" alt="png" /></p>
|
||||
<p>HeaderExchangeServer 的继承关系图</p>
|
||||
<p>与前面介绍的 HeaderExchangeClient 一样,HeaderExchangeServer 是 RemotingServer 的装饰器,实现自 RemotingServer 接口的大部分方法都委托给了所修饰的 RemotingServer 对象。</p>
|
||||
<p>在 HeaderExchangeServer 的构造方法中,会启动一个 CloseTimerTask 定时任务,定期关闭长时间空闲的连接,具体的实现方式与 HeaderExchangeClient 中的两个定时任务类似,这里不再展开分析。</p>
|
||||
@ -423,7 +423,7 @@ public interface Exchanger {
|
||||
}
|
||||
</code></pre>
|
||||
<p>Dubbo 只为 Exchanger 接口提供了 HeaderExchanger 这一个实现,其中 connect() 方法创建的是 HeaderExchangeClient 对象,bind() 方法创建的是 HeaderExchangeServer 对象,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-AF9aANkhOAAB5TgtrSDg780.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-AF9aANkhOAAB5TgtrSDg780.png" alt="png" /></p>
|
||||
<p>HeaderExchanger 门面类</p>
|
||||
<p>从 HeaderExchanger 的实现可以看到,它会在 Transport 层的 Client 和 Server 实现基础之上,添加前文介绍的 HeaderExchangeClient 和 HeaderExchangeServer 装饰器。同时,为上层实现的 ExchangeHandler 实例添加了 HeaderExchangeHandler 以及 DecodeHandler 两个修饰器:</p>
|
||||
<pre><code>public class HeaderExchanger implements Exchanger {
|
||||
@ -441,7 +441,7 @@ public interface Exchanger {
|
||||
<h3>再谈 Codec2</h3>
|
||||
<p>在前面第 17 课时介绍 Dubbo Remoting 核心接口的时候提到,Codec2 接口提供了 encode() 和 decode() 两个方法来实现消息与字节流之间的相互转换。需要注意与 DecodeHandler 区分开来,<strong>DecodeHandler 是对请求体和响应结果的解码,Codec2 是对整个请求和响应的编解码</strong>。</p>
|
||||
<p>这里重点介绍 Transport 层和 Exchange 层对 Codec2 接口的实现,涉及的类如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-AF9-AQVPBAAByBFYPxkE786.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-AF9-AQVPBAAByBFYPxkE786.png" alt="png" /></p>
|
||||
<p><strong>AbstractCodec</strong>抽象类并没有实现 Codec2 中定义的接口方法,而是提供了几个给子类用的基础方法,下面简单说明这些方法的功能。</p>
|
||||
<ul>
|
||||
<li>getSerialization() 方法:通过 SPI 获取当前使用的序列化方式。</li>
|
||||
@ -451,7 +451,7 @@ public interface Exchanger {
|
||||
<p>接下来看<strong>TransportCodec</strong>,我们可以看到这类上被标记了 @Deprecated 注解,表示已经废弃。TransportCodec 的实现非常简单,其中根据 getSerialization() 方法选择的序列化方法对传入消息或 ChannelBuffer 进行序列化或反序列化,这里就不再介绍 TransportCodec 实现了。</p>
|
||||
<p><strong>TelnetCodec</strong>继承了 TransportCodec 序列化和反序列化的基本能力,同时还提供了对 Telnet 命令处理的能力。</p>
|
||||
<p>最后来看<strong>ExchangeCodec</strong>,它在 TelnetCodec 的基础之上,添加了处理协议头的能力。下面是 Dubbo 协议的格式,能够清晰地看出协议中各个数据所占的位数:</p>
|
||||
<p><img src="assets/CgqCHl-AF-eAdTmiAADznCJnMrw389.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl-AF-eAdTmiAADznCJnMrw389.png" alt="png" /></p>
|
||||
<p>Dubbo 协议格式</p>
|
||||
<p>结合上图,我们来深入了解一下 Dubbo 协议中各个部分的含义:</p>
|
||||
<ul>
|
||||
|
@ -291,15 +291,15 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>23 核心接口介绍,RPC 层骨架梳理</h1>
|
||||
<p>在前面的课程中,我们深入介绍了 Dubbo 架构中的 Dubbo Remoting 层的相关内容,了解了 Dubbo 底层的网络模型以及线程模型。从本课时开始,我们就开始介绍 Dubbo Remoting 上面的一层—— Protocol 层(如下图所示),<strong>Protocol 层是 Remoting 层的使用者</strong>,会通过 Exchangers 门面类创建 ExchangeClient 以及 ExchangeServer,还会创建相应的 ChannelHandler 实现以及 Codec2 实现并交给 Exchange 层进行装饰。</p>
|
||||
<p><img src="assets/Ciqc1F-FS2eAdVorABDINpNLpXY061.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FS2eAdVorABDINpNLpXY061.png" alt="png" /></p>
|
||||
<p>Dubbo 架构中 Protocol 层的位置图</p>
|
||||
<p><strong>Protocol 层在 Dubbo 源码中对应的是 dubbo-rpc 模块</strong>,该模块的结构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-FS4aAMyvkAABpKhWTC9Q132.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FS4aAMyvkAABpKhWTC9Q132.png" alt="png" /></p>
|
||||
<p>dubbo-rpc 模块结构图</p>
|
||||
<p>我们可以看到有很多模块,和 dubbo-remoting 模块类似,其中 <strong>dubbo-rpc-api 是对具体协议、服务暴露、服务引用、代理等的抽象,是整个 Protocol 层的核心</strong>。剩余的模块,例如,dubbo-rpc-dubbo、dubbo-rpc-grpc、dubbo-rpc-http 等,都是 Dubbo 支持的具体协议,可以看作dubbo-rpc-api 模块的具体实现。</p>
|
||||
<h3>dubbo-rpc-api</h3>
|
||||
<p>这里我们首先来看 dubbo-rpc-api 模块的包结构,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-FS5CAP7kCAADYKrhf28A273.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-FS5CAP7kCAADYKrhf28A273.png" alt="png" /></p>
|
||||
<p>dubbo-rpc-api 模块的包结构图</p>
|
||||
<p>根据上图展示的 dubbo-rpc-api 模块的结构,我们可以看到 dubbo-rpc-api 模块包括了以下几个核心包。</p>
|
||||
<ul>
|
||||
@ -314,7 +314,7 @@ function hide_canvas() {
|
||||
<p>在 Dubbo RPC 层中涉及的核心接口有 Invoker、Invocation、Protocol、Result、Exporter、ProtocolServer、Filter 等,这些接口分别抽象了 Dubbo RPC 层的不同概念,看似相互独立,但又相互协同,一起构建出了 DubboRPC 层的骨架。下面我们将逐一介绍这些核心接口的含义。</p>
|
||||
<p>首先要介绍的是 Dubbo 中非常重要的一个接口——<strong>Invoker 接口</strong>。可以说,Invoker 渗透在整个 Dubbo 代码实现里,Dubbo 中的很多设计思路都会向 Invoker 这个概念靠拢,但这对于刚接触这部分代码的同学们来说,可能不是很友好。</p>
|
||||
<p>这里我们借助如下这样一个精简的示意图来对比说明两种最关键的 Invoker:服务提供 Invoker 和服务消费 Invoker。</p>
|
||||
<p><img src="assets/Ciqc1F-FWQuAb1ypAAC0qPg0sWQ701.png" alt="Lark20201013-153553.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FWQuAb1ypAAC0qPg0sWQ701.png" alt="png" /></p>
|
||||
<p>Invoker 核心示意图</p>
|
||||
<p>以 dubbo-demo-annotation-consumer 这个示例项目中的 Consumer 为例,它会拿到一个 DemoService 对象,如下所示,这其实是一个代理(即上图中的 Proxy),这个 Proxy 底层就会通过 Invoker 完成网络调用:</p>
|
||||
<pre><code>@Component("demoServiceComponent")
|
||||
|
@ -291,13 +291,13 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>24 从 Protocol 起手,看服务暴露和服务引用的全流程(上)</h1>
|
||||
<p>在上一课时我们讲解了 Protocol 的核心接口,那本课时我们就以 Protocol 接口为核心,详细介绍整个 Protocol 的核心实现。下图展示了 Protocol 接口的继承关系:</p>
|
||||
<p><img src="assets/Ciqc1F-FTHGAOVGKAAJe5PD5u9A015.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FTHGAOVGKAAJe5PD5u9A015.png" alt="png" /></p>
|
||||
<p>Protocol 接口继承关系图</p>
|
||||
<p>其中,<strong>AbstractProtocol</strong>提供了一些 Protocol 实现需要的公共能力以及公共字段,它的核心字段有如下三个。</p>
|
||||
<ul>
|
||||
<li>exporterMap(Map<String, Exporter<?>>类型):用于存储出去的服务集合,其中的 Key 通过 ProtocolUtils.serviceKey() 方法创建的服务标识,在 ProtocolUtils 中维护了多层的 Map 结构(如下图所示)。首先按照 group 分组,在实践中我们可以根据需求设置 group,例如,按照机房、地域等进行 group 划分,做到就近调用;在 GroupServiceKeyCache 中,依次按照 serviceName、serviceVersion、port 进行分类,最终缓存的 serviceKey 是前面三者拼接而成的。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F-JXfmAJK8RAAHUliqXmBc629.png" alt="Lark20201016-164613.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JXfmAJK8RAAHUliqXmBc629.png" alt="png" /></p>
|
||||
<p>groupServiceKeyCacheMap 结构图</p>
|
||||
<ul>
|
||||
<li>serverMap(Map<String, ProtocolServer>类型):记录了全部的 ProtocolServer 实例,其中的 Key 是 host 和 port 组成的字符串,Value 是监听该地址的 ProtocolServer。ProtocolServer 就是对 RemotingServer 的一层简单封装,表示一个服务端。</li>
|
||||
@ -338,13 +338,13 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h4>1. DubboExporter</h4>
|
||||
<p>这里涉及的第一个点是 DubboExporter 对 Invoker 的封装,DubboExporter 的继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-FTJSAd9oTAAAm0DgOmVo715.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-FTJSAd9oTAAAm0DgOmVo715.png" alt="png" /></p>
|
||||
<p>DubboExporter 继承关系图</p>
|
||||
<p>AbstractExporter 中维护了一个 Invoker 对象,以及一个 unexported 字段(boolean 类型),在 unexport() 方法中会设置 unexported 字段为 true,并调用 Invoker 对象的 destory() 方法进行销毁。</p>
|
||||
<p>DubboExporter 也比较简单,其中会维护底层 Invoker 对应的 ServiceKey 以及 DubboProtocol 中的 exportMap 集合,在其 unexport() 方法中除了会调用父类 AbstractExporter 的 unexport() 方法之外,还会清理该 DubboExporter 实例在 exportMap 中相应的元素。</p>
|
||||
<h4>2. 服务端初始化</h4>
|
||||
<p>了解了 Exporter 实现之后,我们继续看 DubboProtocol 中服务发布的流程。从下面这张调用关系图中可以看出,openServer() 方法会一路调用前面介绍的 Exchange 层、Transport 层,并最终创建 NettyServer 来接收客户端的请求。</p>
|
||||
<p><img src="assets/CgqCHl-FTKGAJNO8AAElldtvsRM104.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-FTKGAJNO8AAElldtvsRM104.png" alt="png" /></p>
|
||||
<p>export() 方法调用栈</p>
|
||||
<p>下面我们将逐个介绍 export() 方法栈中的每个被调用的方法。</p>
|
||||
<p>首先,在 openServer() 方法中会根据 URL 判断当前是否为服务端,只有服务端才能创建 ProtocolServer 并对外服务。如果是来自服务端的调用,会依靠 serverMap 集合检查是否已有 ProtocolServer 在监听 URL 指定的地址;如果没有,会调用 createServer() 方法进行创建。openServer() 方法的具体实现如下:</p>
|
||||
@ -398,7 +398,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>在 createServer() 方法中还有几个细节需要展开分析一下。第一个是创建 ExchangeServer 时,使用的 Codec2 接口实现实际上是 DubboCountCodec,对应的 SPI 配置文件如下:</p>
|
||||
<p><img src="assets/CgqCHl-FTK-AUlLCAADTWhhySe8432.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-FTK-AUlLCAADTWhhySe8432.png" alt="png" /></p>
|
||||
<p>Codec2 SPI 配置文件</p>
|
||||
<p>DubboCountCodec 中维护了一个 DubboCodec 对象,编解码的能力都是 DubboCodec 提供的,DubboCountCodec 只负责在解码过程中 ChannelBuffer 的 readerIndex 指针控制,具体实现如下:</p>
|
||||
<pre><code>public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
|
||||
@ -429,7 +429,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>DubboCountCodec、DubboCodec 都实现了第 22 课时介绍的 Codec2 接口,其中 DubboCodec 是 ExchangeCodec 的子类。</p>
|
||||
<p><img src="assets/Ciqc1F-FTLuAZ-AoAACeZ02hpEg723.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FTLuAZ-AoAACeZ02hpEg723.png" alt="png" /></p>
|
||||
<p>DubboCountCodec 及 DubboCodec 继承关系图</p>
|
||||
<p>我们知道 ExchangeCodec 只处理了 Dubbo 协议的请求头,而 DubboCodec 则是通过继承的方式,在 ExchangeCodec 基础之上,添加了解析 Dubbo 消息体的功能。在第 22 课时介绍 ExchangeCodec 实现的时候,我们重点分析了 encodeRequest() 方法,即 Request 请求的编码实现,其中会调用 encodeRequestData() 方法完成请求体的编码。</p>
|
||||
<p>DubboCodec 中就覆盖了 encodeRequestData() 方法,按照 Dubbo 协议的格式编码 Request 请求体,具体实现如下:</p>
|
||||
@ -461,7 +461,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>RpcInvocation 实现了上一课时介绍的 Invocation 接口,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-FTMSAYeP7AAA_pzU2CPA016.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-FTMSAYeP7AAA_pzU2CPA016.png" alt="png" /></p>
|
||||
<p>RpcInvocation 继承关系图</p>
|
||||
<p>下面是 RpcInvocation 中的核心字段,通过读写这些字段即可实现 Invocation 接口的全部方法。</p>
|
||||
<ul>
|
||||
@ -478,7 +478,7 @@ function hide_canvas() {
|
||||
<li>invokeMode(InvokeMode类型):此次调用的模式,分为 SYNC、ASYNC 和 FUTURE 三类。</li>
|
||||
</ul>
|
||||
<p>我们在上面的继承图中看到 RpcInvocation 的一个子类—— DecodeableRpcInvocation,它是用来支持解码的,其实现的 decode() 方法正好是 DubboCodec.encodeRequestData() 方法对应的解码操作,在 DubboCodec.decodeBody() 方法中就调用了这个方法,调用关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-FTM2Ae73pAAC0_daI0N4088.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FTM2Ae73pAAC0_daI0N4088.png" alt="png" /></p>
|
||||
<p>decode() 方法调用栈</p>
|
||||
<p>这个解码过程中有个细节,在 DubboCodec.decodeBody() 方法中有如下代码片段,其中会根据 DECODE_IN_IO_THREAD_KEY 这个参数决定是否在 DubboCodec 中进行解码(DubboCodec 是在 IO 线程中调用的)。</p>
|
||||
<pre><code>// decode request.
|
||||
@ -557,7 +557,7 @@ return req;
|
||||
}
|
||||
</code></pre>
|
||||
<p>SerializableClassRegistry 底层维护了一个 static 的 Map(REGISTRATIONS 字段),registerClass() 方法就是将待优化的类写入该集合中暂存,在使用 Kryo、FST 等序列化算法时,会读取该集合中的类,完成注册操作,相关的调用关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-FTOGAEWu7AADOU3xBmjA069.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-FTOGAEWu7AADOU3xBmjA069.png" alt="png" /></p>
|
||||
<p>getRegisteredClasses() 方法的调用位置</p>
|
||||
<p>按照 Dubbo 官方文档的说法,即使不注册任何类进行优化,Kryo 和 FST 的性能依然普遍优于Hessian2 和 Dubbo 序列化。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -332,10 +332,10 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>当使用独享连接的时候,对每个 Service 建立固定数量的 Client,每个 Client 维护一个底层连接。如下图所示,就是针对每个 Service 都启动了两个独享连接:</p>
|
||||
<p><img src="assets/CgqCHl-OqnqAD_WFAAGYtk5Nou4688.png" alt="Lark20201020-171207.png" /></p>
|
||||
<p><img src="assets/CgqCHl-OqnqAD_WFAAGYtk5Nou4688.png" alt="png" /></p>
|
||||
<p>Service 独享连接示意图</p>
|
||||
<p>当使用共享连接的时候,会区分不同的网络地址(host:port),一个地址只建立固定数量的共享连接。如下图所示,Provider 1 暴露了多个服务,Consumer 引用了 Provider 1 中的多个服务,共享连接是说 Consumer 调用 Provider 1 中的多个服务时,是通过固定数量的共享 TCP 长连接进行数据传输,这样就可以达到减少服务端连接数的目的。</p>
|
||||
<p><img src="assets/Ciqc1F-OqoOAHURKAAF2m0HX5qU972.png" alt="Lark20201020-171159.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-OqoOAHURKAAF2m0HX5qU972.png" alt="png" /></p>
|
||||
<p>Service 共享连接示意图</p>
|
||||
<p>那怎么去创建共享连接呢?<strong>创建共享连接的实现细节是在 getSharedClient() 方法中</strong>,它首先从 referenceClientMap 缓存(Map<String, List<code><ReferenceCountExchangeClient></code>> 类型)中查询 Key(host 和 port 拼接成的字符串)对应的共享 Client 集合,如果查找到的 Client 集合全部可用,则直接使用这些缓存的 Client,否则要创建新的 Client 来补充替换缓存中不可用的 Client。示例代码如下:</p>
|
||||
<pre><code>private List<ReferenceCountExchangeClient> getSharedClient(URL url, int connectNum) {
|
||||
@ -379,7 +379,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<p>这里使用的 ExchangeClient 实现是 ReferenceCountExchangeClient,它是 ExchangeClient 的一个装饰器,在原始 ExchangeClient 对象基础上添加了引用计数的功能。</p>
|
||||
<p>ReferenceCountExchangeClient 中除了持有被修饰的 ExchangeClient 对象外,还有一个 referenceCount 字段(AtomicInteger 类型),用于记录该 Client 被应用的次数。从下图中我们可以看到,在 ReferenceCountExchangeClient 的构造方法以及 incrementAndGetCount() 方法中会增加引用次数,在 close() 方法中则会减少引用次数。</p>
|
||||
<p><img src="assets/Ciqc1F-OqqeAHAStAAF3BXy1LnA608.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-OqqeAHAStAAF3BXy1LnA608.png" alt="png" /></p>
|
||||
<p>referenceCount 修改调用栈</p>
|
||||
<p>这样,对于同一个地址的共享连接,就可以满足两个基本需求:</p>
|
||||
<ol>
|
||||
@ -417,7 +417,7 @@ private void replaceWithLazyClient() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>LazyConnectExchangeClient 也是 ExchangeClient 的装饰器,它会在原有 ExchangeClient 对象的基础上添加懒加载的功能。LazyConnectExchangeClient 在构造方法中不会创建底层持有连接的 Client,而是在需要发送请求的时候,才会调用 initClient() 方法进行 Client 的创建,如下图调用关系所示:</p>
|
||||
<p><img src="assets/CgqCHl-OqrqAHcvUAAC9KpqKEBQ887.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-OqrqAHcvUAAC9KpqKEBQ887.png" alt="png" /></p>
|
||||
<p>initClient() 方法的调用位置</p>
|
||||
<p>initClient() 方法的具体实现如下:</p>
|
||||
<pre><code>private void initClient() throws RemotingException {
|
||||
|
@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<p>DubboProtocol.protocolBindingRefer() 方法则会将底层的 ExchangeClient 集合封装成 DubboInvoker,然后由上层逻辑封装成代理对象,这样业务层就可以像调用本地 Bean 一样,完成远程调用。</p>
|
||||
<h3>深入 Invoker</h3>
|
||||
<p>首先,我们来看 AbstractInvoker 这个抽象类,它继承了 Invoker 接口,继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-Oq-uAdi4nAABRchTw_kQ666.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-Oq-uAdi4nAABRchTw_kQ666.png" alt="png" /></p>
|
||||
<p>AbstractInvoker 继承关系示意图</p>
|
||||
<p>从图中可以看到,最核心的 DubboInvoker 继承自AbstractInvoker 抽象类,AbstractInvoker 的核心字段有如下几个。</p>
|
||||
<ul>
|
||||
@ -470,7 +470,7 @@ private static final InternalThreadLocal<RpcContext> SERVER_LOCAL = ...
|
||||
}
|
||||
</code></pre>
|
||||
<p>oneway 指的是客户端发送消息后,不需要得到响应。所以,对于那些不关心服务端响应的请求,就比较适合使用 oneway 通信,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-SkLWAaPzTAACgt5rmWHg530.png" alt="Lark20201023-161312.png" /></p>
|
||||
<p><img src="assets/CgqCHl-SkLWAaPzTAACgt5rmWHg530.png" alt="png" /></p>
|
||||
<p>oneway 和 twoway 通信方式对比图</p>
|
||||
<p>可以看到发送 oneway 请求的方式是send() 方法,而后面发送 twoway 请求的方式是 request() 方法。通过之前的分析我们知道,request() 方法会相应地创建 DefaultFuture 对象以及检测超时的定时任务,而 send() 方法则不会创建这些东西,它是直接将 Invocation 包装成 oneway 类型的 Request 发送出去。</p>
|
||||
<p>在服务端的 HeaderExchangeHandler.receive() 方法中,会针对 oneway 请求和 twoway 请求执行不同的分支处理:twoway 请求由 handleRequest() 方法进行处理,其中会关注调用结果并形成 Response 返回给客户端;oneway 请求则直接交给上层的 DubboProtocol.requestHandler,完成方法调用之后,不会返回任何 Response。</p>
|
||||
|
@ -305,7 +305,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<p>InvokeMode 有三个可选值,分别是 SYNC、ASYNC 和 FUTURE。这里对于 SYNC 模式返回的线程池是 ThreadlessExecutor,至于其他两种异步模式,会根据 URL 选择对应的共享线程池。</p>
|
||||
<p><strong>SYNC 表示同步模式</strong>,是 Dubbo 的默认调用模式,具体含义如下图所示,客户端发送请求之后,客户端线程会阻塞等待服务端返回响应。</p>
|
||||
<p><img src="assets/CgqCHl-X8UOAOTRbAACy-uBf52M689.png" alt="Lark20201027-180625.png" /></p>
|
||||
<p><img src="assets/CgqCHl-X8UOAOTRbAACy-uBf52M689.png" alt="png" /></p>
|
||||
<p>SYNC 调用模式图</p>
|
||||
<p>在拿到线程池之后,DubboInvoker 就会调用 ExchangeClient.request() 方法,将 Invocation 包装成 Request 请求发送出去,同时会创建相应的 DefaultFuture 返回。注意,这里还加了一个回调,取出其中的 AppResponse 对象。AppResponse 表示的是服务端返回的具体响应,其中有三个字段。</p>
|
||||
<ul>
|
||||
@ -424,7 +424,7 @@ private BiConsumer<Result, Throwable> afterContext = (appResponse, t) ->
|
||||
</code></pre>
|
||||
<p>ThreadlessExecutor 针对同步请求的优化,我们在前面的第 20 课时已经详细介绍过了,这里不再重复。</p>
|
||||
<p>最后要说明的是,<strong>AsyncRpcResult 实现了 Result 接口</strong>,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-WqdmAbppOAABOGWzVljY775.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-WqdmAbppOAABOGWzVljY775.png" alt="png" /></p>
|
||||
<p>AsyncRpcResult 继承关系图</p>
|
||||
<p>AsyncRpcResult 对 Result 接口的实现,例如,getValue() 方法、recreate() 方法、getAttachments() 方法等,都会先调用 getAppResponse() 方法从 responseFuture 中拿到 AppResponse 对象,然后再调用其对应的方法。这里我们以 recreate() 方法为例,简单分析一下:</p>
|
||||
<pre><code>public Result getAppResponse() { // 省略异常处理的逻辑
|
||||
@ -468,7 +468,7 @@ public Object recreate() throws Throwable {
|
||||
<p>另外,CompletableFuture 提供了良好的回调方法,例如,whenComplete()、whenCompleteAsync() 等方法都可以在逻辑完成后,执行该方法中添加的 action 逻辑,实现回调的逻辑。同时,CompletableFuture 很好地支持了 Future 间的相互协调或组合,例如,thenApply()、thenApplyAsync() 等方法。</p>
|
||||
<p>正是由于 CompletableFuture 的增强,我们可以更加流畅地使用回调,不必因为等待一个响应而阻塞着调用线程,而是通过前面介绍的方法告诉 CompletableFuture 完成当前逻辑之后,就去执行某个特定的函数。在 Demo 示例(即 dubbo-demo 模块中的 Demo )中,返回 CompletableFuture 的 sayHelloAsync() 方法就是使用的 FUTURE 模式。</p>
|
||||
<p>好了,DubboInvoker 涉及的同步调用、异步调用的原理和底层实现就介绍到这里了,我们可以通过一张流程图进行简单总结,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F-X8WuACaAKAAEb-X6qf4Y710.png" alt="Lark20201027-180621.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-X8WuACaAKAAEb-X6qf4Y710.png" alt="png" /></p>
|
||||
<p>DubboInvoker 核心流程图</p>
|
||||
<p>在 Client 端发送请求时,首先会创建对应的 DefaultFuture(其中记录了请求 ID 等信息),然后依赖 Netty 的异步发送特性将请求发送到 Server 端。需要说明的是,这整个发送过程是不会阻塞任何线程的。之后,将 DefaultFuture 返回给上层,在这个返回过程中,DefaultFuture 会被封装成 AsyncRpcResult,同时也可以添加回调函数。</p>
|
||||
<p>当 Client 端接收到响应结果的时候,会交给关联的线程池(ExecutorService)或是业务线程(使用 ThreadlessExecutor 场景)进行处理,得到 Server 返回的真正结果。拿到真正的返回结果后,会将其设置到 DefaultFuture 中,并调用 complete() 方法将其设置为完成状态。此时,就会触发前面注册在 DefaulFuture 上的回调函数,执行回调逻辑。</p>
|
||||
@ -477,7 +477,7 @@ public Object recreate() throws Throwable {
|
||||
<h4>1. ListenerInvokerWrapper</h4>
|
||||
<p>在前面的第 23 课时中简单提到过 InvokerListener 接口,我们可以提供其实现来监听 refer 事件以及 destroy 事件,相应地要实现 referred() 方法以及 destroyed() 方法。</p>
|
||||
<p>ProtocolListenerWrapper 是 Protocol 接口的实现之一,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-WqfyAZ0TzAAAbeTUMLT0465.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-WqfyAZ0TzAAAbeTUMLT0465.png" alt="png" /></p>
|
||||
<p>ProtocolListenerWrapper 继承关系图</p>
|
||||
<p>ProtocolListenerWrapper 本身是 Protocol 接口的装饰器,在其 export() 方法和 refer() 方法中,会分别在原有 Invoker 基础上封装一层 ListenerExporterWrapper 和 ListenerInvokerWrapper。</p>
|
||||
<p><strong>ListenerInvokerWrapper 是 Invoker 的装饰器</strong>,其构造方法参数列表中除了被修饰的 Invoker 外,还有 InvokerListener 列表,在构造方法内部会遍历整个 InvokerListener 列表,并调用每个 InvokerListener 的 referred() 方法,通知它们 Invoker 被引用的事件。核心逻辑如下:</p>
|
||||
|
@ -291,13 +291,13 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>28 复杂问题简单化,代理帮你隐藏了多少底层细节?</h1>
|
||||
<p>在前面介绍 DubboProtocol 的相关实现时,我们知道 Protocol 这一层以及后面介绍的 Cluster 层暴露出来的接口都是 Dubbo 内部的一些概念,业务层无法直接使用。为了让业务逻辑能够无缝使用 Dubbo,我们就需要将业务逻辑与 Dubbo 内部概念打通,这就用到了动态生成代理对象的功能。Proxy 层在 Dubbo 架构中的位置如下所示(虽然在架构图中 Proxy 层与 Protocol 层距离很远,但 Proxy 的具体代码实现就位于 dubbo-rpc-api 模块中):</p>
|
||||
<p><img src="assets/CgqCHl-WrK6Af2f9ACAFYNhhHPc051.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-WrK6Af2f9ACAFYNhhHPc051.png" alt="png" /></p>
|
||||
<p>Dubbo 架构中 Proxy 层的位置图</p>
|
||||
<p>在 Consumer 进行调用的时候,Dubbo 会通过<strong>动态代理</strong>将业务接口实现对象转化为相应的 Invoker 对象,然后在 Cluster 层、Protocol 层都会使用 Invoker。在 Provider 暴露服务的时候,也会有 Invoker 对象与业务接口实现对象之间的转换,这同样也是通过<strong>动态代理</strong>实现的。</p>
|
||||
<p>实现动态代理的常见方案有:JDK 动态代理、CGLib 动态代理和 Javassist 动态代理。这些方案的应用都还是比较广泛的,例如,Hibernate 底层使用了 Javassist 和 CGLib,Spring 使用了 CGLib 和 JDK 动态代理,MyBatis 底层使用了 JDK 动态代理和 Javassist。</p>
|
||||
<p>从性能方面看,Javassist 与 CGLib 的实现方式相差无几,两者都比 JDK 动态代理性能要高,具体高多少,这就要看具体的机器、JDK 版本、测试基准的具体实现等条件了。</p>
|
||||
<p><strong>Dubbo 提供了两种方式来实现代理,分别是 JDK 动态代理和 Javassist</strong>。我们可以在 proxy 这个包内,看到相应工厂类,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-WrLqALvCWAACKW4k6bv4319.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-WrLqALvCWAACKW4k6bv4319.png" alt="png" /></p>
|
||||
<p>ProxyFactory 核心实现的位置</p>
|
||||
<p>了解了 Proxy 存在的必要性以及 Dubbo 提供的两种代理生成方式之后,下面我们就开始对 Proxy 层的实现进行深入分析。</p>
|
||||
<h3>ProxyFactory</h3>
|
||||
@ -316,7 +316,7 @@ public interface ProxyFactory {
|
||||
</code></pre>
|
||||
<p>看到 ProxyFactory 上的 @SPI 注解我们知道,其默认实现使用 Javassist 来创建代码对象。</p>
|
||||
<p><strong>AbstractProxyFactory 是代理工厂的抽象类</strong>,继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-WrMiAXWheAACKwcyiNxw669.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-WrMiAXWheAACKwcyiNxw669.png" alt="png" /></p>
|
||||
<p>AbstractProxyFactory 继承关系图</p>
|
||||
<h3>AbstractProxyFactory</h3>
|
||||
<p>AbstractProxyFactory 主要处理的是需要代理的接口,具体实现在 getProxy() 方法中:</p>
|
||||
@ -639,7 +639,7 @@ synchronized (cache) { // 加锁
|
||||
}
|
||||
</code></pre>
|
||||
<p>在前面两个课时中我们已经介绍了 Invoker 接口的一个重要实现分支—— AbstractInvoker 以及它的一个实现 DubboInvoker。AbstractProxyInvoker 是 Invoker 接口的另一个实现分支,继承关系如下图所示,其实现类都是 ProxyFactory 实现中的匿名内部类。</p>
|
||||
<p><img src="assets/CgqCHl-WrSOAU0FXAABwy4VTTF4402.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-WrSOAU0FXAABwy4VTTF4402.png" alt="png" /></p>
|
||||
<p>在 AbstractProxyInvoker 实现的 invoke() 方法中,会将 doInvoke() 方法返回的结果封装成 CompletableFuture 对象,然后再封装成 AsyncRpcResult 对象返回,具体实现如下:</p>
|
||||
<pre><code>public Result invoke(Invocation invocation) throws RpcException {
|
||||
// 执行doInvoke()方法,调用业务实现
|
||||
|
@ -294,7 +294,7 @@ function hide_canvas() {
|
||||
<p>Filter 链的组装逻辑设计得非常灵活,其中可以通过“-”配置手动剔除 Dubbo 原生提供的、默认加载的 Filter,通过“default”来代替 Dubbo 原生提供的 Filter,这样就可以很好地控制哪些 Filter 要加载,以及 Filter 的真正执行顺序。</p>
|
||||
<p><strong>Filter 是扩展 Dubbo 功能的首选方案</strong>,并且 Dubbo 自身也提供了非常多的 Filter 实现来扩展自身功能。在回顾了 ProtocolFilterWrapper 加载 Filter 的大致逻辑之后,我们本课时就来深入介绍 Dubbo 内置的多种 Filter 实现类,以及自定义 Filter 扩展 Dubbo 的方式。</p>
|
||||
<p>在开始介绍 Filter 接口实现之前,我们需要了解一下 Filter 在 Dubbo 架构中的位置,这样才能明确 Filter 链处理请求/响应的位置,如下图红框所示:</p>
|
||||
<p><img src="assets/CgqCHl-lLz2APEb2ABSTPPnfqGQ345.png" alt="Lark20201106-191028.png" /></p>
|
||||
<p><img src="assets/CgqCHl-lLz2APEb2ABSTPPnfqGQ345.png" alt="png" /></p>
|
||||
<p>Filter 在 Dubbo 架构中的位置</p>
|
||||
<h3>ConsumerContextFilter</h3>
|
||||
<p>ConsumerContextFilter 是一个非常简单的 Consumer 端 Filter 实现,它会在当前的 RpcContext 中记录本地调用的一些状态信息(会记录到 LOCAL 对应的 RpcContext 中),例如,调用相关的 Invoker、Invocation 以及调用的本地地址、远端地址信息,具体实现如下:</p>
|
||||
@ -573,7 +573,7 @@ public AccessLogFilter() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>在 LoggerFactory 中维护了一个 LOGGERS 集合(Map<String, FailsafeLogger> 类型),其中维护了当前使用的全部 FailsafeLogger 对象;FailsafeLogger 对象中封装了一个 Logger 对象,这个 Logger 接口是 Dubbo 自己定义的接口,Dubbo 针对每种第三方框架都提供了一个 Logger 接口的实现,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-lL4eAGvorAAEnucS-mWg399.png" alt="Lark20201106-191032.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-lL4eAGvorAAEnucS-mWg399.png" alt="png" /></p>
|
||||
<p>Logger 接口的实现</p>
|
||||
<p>FailsafeLogger 是 Logger 对象的装饰器,它在每个 Logger 日志写入操作之外,都添加了 try/catch 异常处理。其他的 Dubbo Logger 实现类则是封装了相应第三方的 Logger 对象,并将日志输出操作委托给第三方的 Logger 对象完成。这里我们以 Log4j2Logger 为例进行简单分析:</p>
|
||||
<pre><code>public class Log4j2Logger implements Logger {
|
||||
@ -604,7 +604,7 @@ public AccessLogFilter() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>LoggerAdapter 被 @SPI 注解修饰,是一个扩展接口,如下图所示,LoggerAdapter 对应每个第三方框架的一个相应实现,用于创建相应的 Dubbo Logger 实现对象。</p>
|
||||
<p><img src="assets/CgqCHl-lL4GAWy4JAAFMZJwzrp8801.png" alt="Lark20201106-191036.png" /></p>
|
||||
<p><img src="assets/CgqCHl-lL4GAWy4JAAFMZJwzrp8801.png" alt="png" /></p>
|
||||
<p>LoggerAdapter 接口实现</p>
|
||||
<p>以 Log4j2LoggerAdapter 为例,其核心在 getLogger() 方法中,主要是创建 Log4j2Logger 对象,具体实现如下:</p>
|
||||
<pre><code>public class Log4j2LoggerAdapter implements LoggerAdapter {
|
||||
|
@ -302,12 +302,12 @@ function hide_canvas() {
|
||||
<li>……</li>
|
||||
</ul>
|
||||
<p>为了解决上述问题,<strong>Dubbo 独立出了一个实现集群功能的模块—— dubbo-cluster</strong>。</p>
|
||||
<p><img src="assets/Ciqc1F-qN92ADHx8AACiY_cvusQ921.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-qN92ADHx8AACiY_cvusQ921.png" alt="png" /></p>
|
||||
<p>dubbo-cluster 结构图</p>
|
||||
<p>作为 dubbo-cluster 模块分析的第一课时,我们就首先来了解一下 dubbo-cluster 模块的架构以及最核心的 Cluster 接口。</p>
|
||||
<h3>Cluster 架构</h3>
|
||||
<p>dubbo-cluster 模块的主要功能是将多个 Provider 伪装成一个 Provider 供 Consumer 调用,其中涉及集群的容错处理、路由规则的处理以及负载均衡。下图展示了 dubbo-cluster 的核心组件:</p>
|
||||
<p><img src="assets/Ciqc1F-qY-CAQ08VAAFAZaC5kyU044.png" alt="Lark20201110-175555.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-qY-CAQ08VAAFAZaC5kyU044.png" alt="png" /></p>
|
||||
<p>Cluster 核心接口图</p>
|
||||
<p>由图我们可以看出,dubbo-cluster 主要包括以下四个核心接口:</p>
|
||||
<ul>
|
||||
@ -335,7 +335,7 @@ function hide_canvas() {
|
||||
<p><strong>AbstractDirectory 是 Directory 接口的抽象实现</strong>,其中除了维护 Consumer 端的 URL 信息,还维护了一个 RouterChain 对象,用于记录当前使用的 Router 对象集合,也就是后面课时要介绍的路由规则。</p>
|
||||
<p>AbstractDirectory 对 list() 方法的实现也比较简单,就是直接委托给了 doList() 方法,doList() 是个抽象方法,由 AbstractDirectory 的子类具体实现。</p>
|
||||
<p><strong>Directory 接口有 RegistryDirectory 和 StaticDirectory 两个具体实现</strong>,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-qN_-AMVHmAAA3C6TAxsA315.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-qN_-AMVHmAAA3C6TAxsA315.png" alt="png" /></p>
|
||||
<p>Directory 接口继承关系图</p>
|
||||
<p>其中,<strong>RegistryDirectory 实现</strong>中维护的 Invoker 集合会随着注册中心中维护的注册信息<strong>动态</strong>发生变化,这就依赖了 ZooKeeper 等注册中心的推送能力;<strong>StaticDirectory 实现</strong>中维护的 Invoker 集合则是<strong>静态</strong>的,在 StaticDirectory 对象创建完成之后,不会再发生变化。</p>
|
||||
<p>下面我们就来分别介绍 Directory 接口的这两个具体实现。</p>
|
||||
@ -402,7 +402,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>我们看到除了作为 NotifyListener 监听器之外,RegistryDirectory 内部还有两个 ConfigurationListener 的内部类(继承关系如下图所示),为了保持连贯,这两个监听器的具体原理我们在后面的课时中会详细介绍,这里先不展开讲述。</p>
|
||||
<p><img src="assets/CgqCHl-qOBmAbzKkAABZPyC5mIA963.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-qOBmAbzKkAABZPyC5mIA963.png" alt="png" /></p>
|
||||
<p>RegistryDirectory 内部的 ConfigurationListener 实现</p>
|
||||
<p>通过前面对 Registry 的介绍我们知道,在注册 NotifyListener 的时候,监听的是 providers、configurators 和 routers 三个目录,所以在这三个目录下发生变化的时候,就会触发 RegistryDirectory 的 notify() 方法。</p>
|
||||
<p>在 RegistryDirectory.notify() 方法中,首先会按照 category 对发生变化的 URL 进行分类,分成 configurators、routers、providers 三类,并分别对不同类型的 URL 进行处理:</p>
|
||||
|
@ -342,10 +342,10 @@ public interface RouterFactory {
|
||||
}
|
||||
</code></pre>
|
||||
<p>RouterFactory 接口有很多实现类,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-qOLWAf_G5AACv9PqZOrc667.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-qOLWAf_G5AACv9PqZOrc667.png" alt="png" /></p>
|
||||
<p>RouterFactory 继承关系图</p>
|
||||
<p>下面我们就来深入介绍下每个 RouterFactory 实现类以及对应的 Router 实现对象。<strong>Router 决定了一次 Dubbo 调用的目标服务,Router 接口的每个实现类代表了一个路由规则</strong>,当 Consumer 访问 Provider 时,Dubbo 根据路由规则筛选出合适的 Provider 列表,之后通过负载均衡算法再次进行筛选。Router 接口的继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-qOL2AAXYIAACMVPC1qW0732.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-qOL2AAXYIAACMVPC1qW0732.png" alt="png" /></p>
|
||||
<p>Router 继承关系图</p>
|
||||
<p>接下来我们就开始介绍 RouterFactory 以及 Router 的具体实现。</p>
|
||||
<h3>ConditionRouterFactory&ConditionRouter</h3>
|
||||
@ -453,7 +453,7 @@ public interface RouterFactory {
|
||||
<pre><code>host = 2.2.2.2,1.1.1.1,3.3.3.3 & method !=get => host = 1.2.3.4
|
||||
</code></pre>
|
||||
<p>经过 ROUTE_PATTERN 正则表达式的分组之后,我们得到如下分组:</p>
|
||||
<p><img src="assets/CgqCHl-uM9aALLGaAAFMMnXRAPw685.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-uM9aALLGaAAFMMnXRAPw685.png" alt="png" /></p>
|
||||
<p>Rule 分组示意图</p>
|
||||
<p>我们先来看 <code>=></code> 之前的 Consumer 匹配规则的处理。</p>
|
||||
<ol>
|
||||
@ -464,10 +464,10 @@ public interface RouterFactory {
|
||||
<li>分组 5 中,separator 为 "!=" 字符串,content 为 "get" 字符串。处理该分组时,会进入 parseRule() 方法中(5)处的分支,向步骤 4 新建的 MatchPair 对象中的 mismatches 集合添加 "get" 字符串。</li>
|
||||
</ol>
|
||||
<p>最后,我们得到的 whenCondition 集合如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-uM-OABiPoAADt1lcbl7U975.png" alt="3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uM-OABiPoAADt1lcbl7U975.png" alt="png" /></p>
|
||||
<p>whenCondition 集合示意图</p>
|
||||
<p>同理,parseRule() 方法解析上述表达式 <code>=></code> 之后的规则得到的 thenCondition 集合,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-uM-6AXnrOAAB6hJLFL50095.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl-uM-6AXnrOAAB6hJLFL50095.png" alt="png" /></p>
|
||||
<p>thenCondition 集合示意图</p>
|
||||
<p>了解了 ConditionRouter 解析规则的流程以及 MatchPair 内部的匹配原则之后,ConditionRouter 中最后一个需要介绍的内容就是它的 route() 方法了。</p>
|
||||
<p>ConditionRouter.route() 方法首先会尝试前面创建的 whenCondition 集合,判断此次发起调用的 Consumer 是否符合表达式中 <code>=></code> 之前的 Consumer 过滤条件,若不符合,直接返回整个 invokers 集合;若符合,则通过 thenCondition 集合对 invokers 集合进行过滤,得到符合 Provider 过滤条件的 Invoker 集合,然后返回给上层调用方。ConditionRouter.route() 方法的核心实现如下:</p>
|
||||
|
@ -294,7 +294,7 @@ function hide_canvas() {
|
||||
<h3>FileRouterFactory</h3>
|
||||
<p><strong>FileRouterFactory 是 ScriptRouterFactory 的装饰器</strong>,其扩展名为 file,FileRouterFactory 在 ScriptRouterFactory 基础上<strong>增加了读取文件的能力</strong>。我们可以将 ScriptRouter 使用的路由规则保存到文件中,然后在 URL 中指定文件路径,FileRouterFactory 从中解析到该脚本文件的路径并进行读取,调用 ScriptRouterFactory 去创建相应的 ScriptRouter 对象。</p>
|
||||
<p>下面我们来看 FileRouterFactory 对 getRouter() 方法的具体实现,其中完成了 file 协议的 URL 到 script 协议 URL 的转换,如下是一个转换示例,首先会将 file:// 协议转换成 script:// 协议,然后会添加 type 参数和 rule 参数,其中 type 参数值根据文件后缀名确定,该示例为 js,rule 参数值为文件内容。</p>
|
||||
<p><img src="assets/Ciqc1F-zkA2AduheAAGQTzCOwl8784.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-zkA2AduheAAGQTzCOwl8784.png" alt="png" /></p>
|
||||
<p>我们可以再结合接下来这个示例分析 getRouter() 方法的具体实现:</p>
|
||||
<pre><code>public Router getRouter(URL url) {
|
||||
// 默认使用script协议
|
||||
@ -329,21 +329,21 @@ function hide_canvas() {
|
||||
<p>目前,Dubbo 提供了动态和静态两种方式给 Provider 打标签,其中动态方式就是通过服务治理平台动态下发标签,静态方式就是在 XML 等静态配置中打标签。Consumer 端可以在 RpcContext 的 attachment 中添加 request.tag 附加属性,注意<strong>保存在 attachment 中的值将会在一次完整的远程调用中持续传递</strong>,我们只需要在起始调用时进行设置,就可以达到标签的持续传递。</p>
|
||||
<p>了解了 Tag 的基本概念和功能之后,我们再简单介绍一个 Tag 的使用示例。</p>
|
||||
<p>在实际的开发测试中,一个完整的请求会涉及非常多的 Provider,分属不同团队进行维护,这些团队每天都会处理不同的需求,并在其负责的 Provider 服务中进行修改,如果所有团队都使用一套测试环境,那么测试环境就会变得很不稳定。如下图所示,4 个 Provider 分属不同的团队管理,Provider 2 和 Provider 4 在测试环境测试,部署了有 Bug 的版本,这样就会导致整个测试环境无法正常处理请求,在这样一个不稳定的测试环境中排查 Bug 是非常困难的,因为可能排查到最后,发现是别人的 Bug。</p>
|
||||
<p><img src="assets/CgqCHl-zkBuACzVCAABuM5-1_s4317.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl-zkBuACzVCAABuM5-1_s4317.png" alt="png" /></p>
|
||||
<p>不同状态的 Provider 节点</p>
|
||||
<p>为了解决上述问题,我们可以针对每个需求分别独立出一套测试环境,但是这个方案会占用大量机器,前期的搭建成本以及后续的维护成本也都非常高。</p>
|
||||
<p>下面是一个通过 Tag 方式实现环境隔离的架构图,其中,需求 1 对 Provider 2 的请求会全部落到有需求 1 标签的 Provider 上,其他 Provider 使用稳定测试环境中的 Provider;需求 2 对 Provider 4 的请求会全部落到有需求 2 标签的 Provider 4 上,其他 Provider 使用稳定测试环境中的 Provider。</p>
|
||||
<p><img src="assets/CgqCHl-zkCyANtuuAADgH2I1upA475.png" alt="4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-zkCyANtuuAADgH2I1upA475.png" alt="png" /></p>
|
||||
<p>依赖 Tag 实现的测试环境隔离方案</p>
|
||||
<p>在一些特殊场景中,会有 Tag 降级的场景,比如找不到对应 Tag 的 Provider,会按照一定的规则进行降级。如果在 Provider 集群中不存在与请求 Tag 对应的 Provider 节点,则默认将降级请求 Tag 为空的 Provider;如果希望在找不到匹配 Tag 的 Provider 节点时抛出异常的话,我们需设置 request.tag.force = true。</p>
|
||||
<p>如果请求中的 request.tag 未设置,只会匹配 Tag 为空的 Provider,也就是说即使集群中存在可用的服务,若 Tag 不匹配也就无法调用。一句话总结,<strong>携带 Tag 的请求可以降级访问到无 Tag 的 Provider,但不携带 Tag 的请求永远无法访问到带有 Tag 的 Provider</strong>。</p>
|
||||
<h4>TagRouter</h4>
|
||||
<p>下面我们再来看 TagRouter 的具体实现。在 TagRouter 中持有一个 TagRouterRule 对象的引用,在 TagRouterRule 中维护了一个 Tag 集合,而在每个 Tag 对象中又都维护了一个 Tag 的名称,以及 Tag 绑定的网络地址集合,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-zkEGALTHPAADFZZM7Y2A139.png" alt="5.png" /></p>
|
||||
<p><img src="assets/CgqCHl-zkEGALTHPAADFZZM7Y2A139.png" alt="png" /></p>
|
||||
<p>TagRouter、TagRouterRule、Tag 与 address 映射关系图</p>
|
||||
<p>另外,在 TagRouterRule 中还维护了 addressToTagnames、tagnameToAddresses 两个集合(都是 Map<String, List<code><String></code>> 类型),分别记录了 Tag 名称到各个 address 的映射以及 address 到 Tag 名称的映射。在 TagRouterRule 的 init() 方法中,会根据 tags 集合初始化这两个集合。</p>
|
||||
<p>了解了 TagRouterRule 的基本构造之后,我们继续来看 TagRouter 构造 TagRouterRule 的过程。TagRouter 除了实现了 Router 接口之外,还实现了 ConfigurationListener 接口,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-zkEyAMNXQAAF_oit25-o273.png" alt="6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-zkEyAMNXQAAF_oit25-o273.png" alt="png" /></p>
|
||||
<p>TagRouter 继承关系图</p>
|
||||
<p><strong>ConfigurationListener 用于监听配置的变化,其中就包括 TagRouterRule 配置的变更</strong>。当我们通过动态更新 TagRouterRule 配置的时候,就会触发 ConfigurationListener 接口的 process() 方法,TagRouter 对 process() 方法的实现如下:</p>
|
||||
<pre><code>public synchronized void process(ConfigChangedEvent event) {
|
||||
@ -370,10 +370,10 @@ tags:
|
||||
addresses: []
|
||||
</code></pre>
|
||||
<p>经过 TagRuleParser 解析得到的 TagRouterRule 结构,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F-zkF6AHgUEAAE3K8dR6QQ826.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-zkF6AHgUEAAE3K8dR6QQ826.png" alt="png" /></p>
|
||||
<p>TagRouterRule 结构图</p>
|
||||
<p>除了上图展示的几个集合字段,TagRouterRule 还从 AbstractRouterRule 抽象类继承了一些控制字段,后面介绍的 ConditionRouterRule 也继承了 AbstractRouterRule。</p>
|
||||
<p><img src="assets/CgqCHl-zkGmAYDBMAAFODGWwRfo125.png" alt="9.png" /></p>
|
||||
<p><img src="assets/CgqCHl-zkGmAYDBMAAFODGWwRfo125.png" alt="png" /></p>
|
||||
<p>AbstractRouterRule继承关系图</p>
|
||||
<p>AbstractRouterRule 中核心字段的具体含义大致可总结为如下。</p>
|
||||
<ul>
|
||||
@ -455,10 +455,10 @@ tags:
|
||||
</code></pre>
|
||||
<h3>ServiceRouter & AppRouter</h3>
|
||||
<p>除了前文介绍的 TagRouterFactory 继承了 CacheableRouterFactory 之外,<strong>ServiceRouterFactory 也继承 CachabelRouterFactory,具有了缓存的能力</strong>,具体继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-zkHqAH3diAAGWl6aQJy8860.png" alt="8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-zkHqAH3diAAGWl6aQJy8860.png" alt="png" /></p>
|
||||
<p>CacheableRouterFactory 继承关系图</p>
|
||||
<p>ServiceRouterFactory 创建的 Router 实现是 ServiceRouter,与 ServiceRouter 类似的是 AppRouter,<strong>两者都继承了 ListenableRouter 抽象类</strong>(虽然 ListenableRouter 是个抽象类,但是没有抽象方法留给子类实现),继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-zkISAPopjAAH9Njd3pOE049.png" alt="7.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-zkISAPopjAAH9Njd3pOE049.png" alt="png" /></p>
|
||||
<p>ListenableRouter 继承关系图</p>
|
||||
<p><strong>ListenableRouter 在 ConditionRouter 基础上添加了动态配置的能力</strong>,ListenableRouter 的 process() 方法与 TagRouter 中的 process() 方法类似,对于 ConfigChangedEvent.DELETE 事件,直接清空 ListenableRouter 中维护的 ConditionRouterRule 和 ConditionRouter 集合的引用;对于 ADDED、UPDATED 事件,则通过 ConditionRuleParser 解析事件内容,得到相应的 ConditionRouterRule 对象和 ConditionRouter 集合。这里的 ConditionRuleParser 同样是以 yaml 文件的格式解析 ConditionRouterRule 的相关配置。ConditionRouterRule 中维护了一个 conditions 集合(List<code><String></code> 类型),记录了多个 Condition 路由规则,对应生成多个 ConditionRouter 对象。</p>
|
||||
<p>整个解析 ConditionRouterRule 的过程,与前文介绍的解析 TagRouterRule 的流程类似,这里不再赘述。</p>
|
||||
|
@ -377,11 +377,11 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>ConfiguratorFactory 接口是一个扩展接口,Dubbo 提供了两个实现类,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-3eLqAL0VKAAGW_mZ-1yE800.png" alt="Lark20201120-160501.png" /></p>
|
||||
<p><img src="assets/CgqCHl-3eLqAL0VKAAGW_mZ-1yE800.png" alt="png" /></p>
|
||||
<p>ConfiguratorFactory 继承关系图</p>
|
||||
<p>其中,OverrideConfiguratorFactory 对应的扩展名为 override,创建的 Configurator 实现是 OverrideConfigurator;AbsentConfiguratorFactory 对应的扩展名是 absent,创建的 Configurator 实现类是 AbsentConfigurator。</p>
|
||||
<p>Configurator 接口的继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-3eMCAP6pVAAGo31X0Wd8652.png" alt="Lark20201120-160505.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-3eMCAP6pVAAGo31X0Wd8652.png" alt="png" /></p>
|
||||
<p>Configurator 继承关系图</p>
|
||||
<p>其中,AbstractConfigurator 中维护了一个 configuratorUrl 字段,记录了完整的配置 URL。<strong>AbstractConfigurator 是一个模板类,其核心实现是 configure() 方法</strong>,具体实现如下:</p>
|
||||
<pre><code>public URL configure(URL url) {
|
||||
|
@ -291,7 +291,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上)</h1>
|
||||
<p>在前面的课时中,我们已经详细介绍了 dubbo-cluster 模块中的 Directory 和 Router 两个核心接口以及核心实现,同时也介绍了这两个接口相关的周边知识。本课时我们继续按照下图的顺序介绍 LoadBalance 的相关内容。</p>
|
||||
<p><img src="assets/Ciqc1F-81uuAdW51AAH-O1mrOoA018.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-81uuAdW51AAH-O1mrOoA018.png" alt="png" /></p>
|
||||
<p>LoadBalance 核心接口图</p>
|
||||
<p><strong>LoadBalance(负载均衡)的职责是将网络请求或者其他形式的负载“均摊”到不同的服务节点上,从而避免服务集群中部分节点压力过大、资源紧张,而另一部分节点比较空闲的情况。</strong></p>
|
||||
<p>通过合理的负载均衡算法,我们希望可以让每个服务节点获取到适合自己处理能力的负载,<strong>实现处理能力和流量的合理分配</strong>。常用的负载均衡可分为<strong>软件负载均衡</strong>(比如,日常工作中使用的 Nginx)和<strong>硬件负载均衡</strong>(主要有 F5、Array、NetScaler 等,不过开发工程师在实践中很少直接接触到)。</p>
|
||||
@ -306,7 +306,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h3>LoadBalance 接口</h3>
|
||||
<p>上述 Dubbo 提供的负载均衡实现,都是 LoadBalance 接口的实现类,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-81vaAYmqRAAFYpTlQI0s741.png" alt="Lark20201124-174750.png" /></p>
|
||||
<p><img src="assets/CgqCHl-81vaAYmqRAAFYpTlQI0s741.png" alt="png" /></p>
|
||||
<p>LoadBalance 继承关系图</p>
|
||||
<p><strong>LoadBalance 是一个扩展接口,默认使用的扩展实现是 RandomLoadBalance</strong>,其定义如下所示,其中的 @Adaptive 注解参数为 loadbalance,即动态生成的适配器会按照 URL 中的 loadbalance 参数值选择扩展实现类。</p>
|
||||
<pre><code>@SPI(RandomLoadBalance.NAME)
|
||||
@ -378,16 +378,16 @@ public interface LoadBalance {
|
||||
hash(请求参数) % 2^32
|
||||
</code></pre>
|
||||
<p>Provider 地址和请求经过对 2^32 取模得到的结果值,都会落到一个 Hash 环上,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-81wSAO1YfAAFfH6Qgse0640.png" alt="Lark20201124-174752.png" /></p>
|
||||
<p><img src="assets/CgqCHl-81wSAO1YfAAFfH6Qgse0640.png" alt="png" /></p>
|
||||
<p>一致性 Hash 节点均匀分布图</p>
|
||||
<p>我们按顺时针的方向,依次将请求分发到对应的 Provider。这样,当某台 Provider 节点宕机或增加新的 Provider 节点时,只会影响这个 Provider 节点对应的请求。</p>
|
||||
<p>在理想情况下,一致性 Hash 算法会将这三个 Provider 节点均匀地分布到 Hash 环上,请求也可以均匀地分发给这三个 Provider 节点。但在实际情况中,这三个 Provider 节点地址取模之后的值,可能差距不大,这样会导致大量的请求落到一个 Provider 节点上,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-81w2ATT5qAAFjvpkgTNM463.png" alt="Lark20201124-174755.png" /></p>
|
||||
<p><img src="assets/CgqCHl-81w2ATT5qAAFjvpkgTNM463.png" alt="png" /></p>
|
||||
<p>一致性 Hash 节点非均匀分布图</p>
|
||||
<p>这就出现了数据倾斜的问题。<strong>所谓数据倾斜是指由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到少量请求的情况</strong>。</p>
|
||||
<p>为了解决一致性 Hash 算法中出现的数据倾斜问题,又演化出了 Hash 槽的概念。</p>
|
||||
<p>Hash 槽解决数据倾斜的思路是:既然问题是由 Provider 节点在 Hash 环上分布不均匀造成的,那么可以虚拟出 n 组 P1、P2、P3 的 Provider 节点 ,让多组 Provider 节点相对均匀地分布在 Hash 环上。如下图所示,相同阴影的节点均为同一个 Provider 节点,比如 P1-1、P1-2……P1-99 表示的都是 P1 这个 Provider 节点。引入 Provider 虚拟节点之后,让 Provider 在圆环上分散开来,以避免数据倾斜问题。</p>
|
||||
<p><img src="assets/CgqCHl-81xaAEUSbAAG0t7C-hcQ544.png" alt="Lark20201124-174743.png" /></p>
|
||||
<p><img src="assets/CgqCHl-81xaAEUSbAAG0t7C-hcQ544.png" alt="png" /></p>
|
||||
<p>数据倾斜解决示意图</p>
|
||||
<h4>2. ConsistentHashSelector 实现分析</h4>
|
||||
<p>了解了一致性 Hash 算法的基本原理之后,我们再来看一下 ConsistentHashLoadBalance 一致性 Hash 负载均衡的具体实现。首先来看 doSelect() 方法的实现,其中会根据 ServiceKey 和 methodName 选择一个 ConsistentHashSelector 对象,<strong>核心算法都委托给 ConsistentHashSelector 对象完成。</strong></p>
|
||||
@ -476,7 +476,7 @@ private Invoker<T> selectForKey(long hash) {
|
||||
<h3>RandomLoadBalance</h3>
|
||||
<p>RandomLoadBalance 使用的负载均衡算法是<strong>加权随机算法</strong>。RandomLoadBalance 是一个简单、高效的负载均衡实现,它也是 Dubbo 默认使用的 LoadBalance 实现。</p>
|
||||
<p>这里我们通过一个示例来说明加权随机算法的核心思想。假设我们有三个 Provider 节点 A、B、C,它们对应的权重分别为 5、2、3,权重总和为 10。现在把这些权重值放到一维坐标轴上,[0, 5) 区间属于节点 A,[5, 7) 区间属于节点 B,[7, 10) 区间属于节点 C,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-81ySAdj_7AAAxc2j-s5k730.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-81ySAdj_7AAAxc2j-s5k730.png" alt="png" /></p>
|
||||
<p>权重坐标轴示意图</p>
|
||||
<p>下面我们通过随机数生成器在 [0, 10) 这个范围内生成一个随机数,然后计算这个随机数会落到哪个区间中。例如,随机生成 4,就会落到 Provider A 对应的区间中,此时 RandomLoadBalance 就会返回 Provider A 这个节点。</p>
|
||||
<p>接下来我们再来看 RandomLoadBalance 中 doSelect() 方法的实现,其核心逻辑分为三个关键点:</p>
|
||||
|
@ -365,7 +365,7 @@ function hide_canvas() {
|
||||
<p>每个 Provider 节点有两个权重:一个权重是配置的 weight,该值在负载均衡的过程中不会变化;另一个权重是 currentWeight,该值会在负载均衡的过程中动态调整,初始值为 0。</p>
|
||||
<p>当有新的请求进来时,RoundRobinLoadBalance 会遍历 Invoker 列表,并用对应的 currentWeight 加上其配置的权重。遍历完成后,再找到最大的 currentWeight,将其减去权重总和,然后返回相应的 Invoker 对象。</p>
|
||||
<p>下面我们通过一个示例说明 RoundRobinLoadBalance 的执行流程,这里我们依旧假设 A、B、C 三个节点的权重比例为 5:1:1。</p>
|
||||
<p><img src="assets/CgqCHl_ArGSAfxA6AAHyWL4Af1o908.png" alt="Lark20201127-153527.png" /></p>
|
||||
<p><img src="assets/CgqCHl_ArGSAfxA6AAHyWL4Af1o908.png" alt="png" /></p>
|
||||
<ol>
|
||||
<li>处理第一个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [0, 0, 0] 变为 [5, 1, 1]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-2, 1, 1]。</li>
|
||||
<li>处理第二个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-2, 1, 1] 变为 [3, 2, 2]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-4, 2, 2]。</li>
|
||||
|
@ -299,7 +299,7 @@ function hide_canvas() {
|
||||
<p>了解了上述背景知识之后,下面我们就正式开始介绍 Cluster 接口。</p>
|
||||
<h3>Cluster 接口与容错机制</h3>
|
||||
<p>Cluster 的工作流程大致可以分为两步(如下图所示):①<strong>创建 Cluster Invoker 实例</strong>(在 Consumer 初始化时,Cluster 实现类会创建一个 Cluster Invoker 实例,即下图中的 merge 操作);②<strong>使用 Cluster Invoker 实例</strong>(在 Consumer 服务消费者发起远程调用请求的时候,Cluster Invoker 会依赖前面课时介绍的 Directory、Router、LoadBalance 等组件得到最终要调用的 Invoker 对象)。</p>
|
||||
<p><img src="assets/Ciqc1F_GAzqAFfi0AAEr0jR4WUk707.png" alt="Lark20201201-164714.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_GAzqAFfi0AAEr0jR4WUk707.png" alt="png" /></p>
|
||||
<p>Cluster 核心流程图</p>
|
||||
<p>Cluster Invoker 获取 Invoker 的流程大致可描述为如下:</p>
|
||||
<ol>
|
||||
@ -327,10 +327,10 @@ public interface Cluster {
|
||||
}
|
||||
</code></pre>
|
||||
<p>Cluster 接口的实现类如下图所示,分别对应前面提到的多种容错策略:</p>
|
||||
<p><img src="assets/Ciqc1F_GA0WADFvhAAFaDfyWGXU780.png" alt="Lark20201201-164718.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_GA0WADFvhAAFaDfyWGXU780.png" alt="png" /></p>
|
||||
<p>Cluster 接口继承关系</p>
|
||||
<p><strong>在每个 Cluster 接口实现中,都会创建对应的 Invoker 对象,这些都继承自 AbstractClusterInvoker 抽象类</strong>,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_GA0-AcVvrAAGLJ3YaO2Q177.png" alt="Lark20201201-164728.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GA0-AcVvrAAGLJ3YaO2Q177.png" alt="png" /></p>
|
||||
<p>AbstractClusterInvoker 继承关系图</p>
|
||||
<p>通过上面两张继承关系图我们可以看出,Cluster 接口和 Invoker 接口都会有相应的抽象实现类,这些抽象实现类都实现了一些公共能力。下面我们就来深入介绍 AbstractClusterInvoker 和 AbstractCluster 这两个抽象类。</p>
|
||||
<h3>AbstractClusterInvoker</h3>
|
||||
@ -543,7 +543,7 @@ public <T> Invoker<T> join(Directory<T> directory) throws RpcE
|
||||
}
|
||||
</code></pre>
|
||||
<p>Dubbo 提供了两个 ClusterInterceptor 实现类,分别是 ConsumerContextClusterInterceptor 和 ZoneAwareClusterInterceptor,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_GA2GAYY4rAAGXJIXwK1k980.png" alt="Lark20201201-164721.png" /></p>
|
||||
<p><img src="assets/CgqCHl_GA2GAYY4rAAGXJIXwK1k980.png" alt="png" /></p>
|
||||
<p>ClusterInterceptor 继承关系图</p>
|
||||
<p>在 ConsumerContextClusterInterceptor 的 before() 方法中,会在 RpcContext 中设置当前 Consumer 地址、此次调用的 Invoker 等信息,同时还会删除之前与当前线程绑定的 Server Context。在 after() 方法中,会删除本地 RpcContext 的信息。ConsumerContextClusterInterceptor 的具体实现如下:</p>
|
||||
<pre><code>public void before(AbstractClusterInvoker<?> invoker, Invocation invocation) {
|
||||
|
@ -722,10 +722,10 @@ private void rePut(Timeout timeout) {
|
||||
}
|
||||
</code></pre>
|
||||
<p>在 Dubbo 中使用多个注册中心的架构如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_IvtuAXngKAADJgn-frEE576.png" alt="Lark20201203-183149.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_IvtuAXngKAADJgn-frEE576.png" alt="png" /></p>
|
||||
<p>双注册中心结构图</p>
|
||||
<p>Consumer 可以使用 ZoneAwareClusterInvoker 先在多个注册中心之间进行选择,选定注册中心之后,再选择 Provider 节点,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_IvuOAFBfoAAD_GvyhrZY880.png" alt="Lark20201203-183145.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_IvuOAFBfoAAD_GvyhrZY880.png" alt="png" /></p>
|
||||
<p>ZoneAwareClusterInvoker 在多注册中心之间进行选择的策略有以下四种。</p>
|
||||
<ol>
|
||||
<li>找到<strong>preferred 属性为 true 的注册中心,它是优先级最高的注册中心</strong>,只有该中心无可用 Provider 节点时,才会回落到其他注册中心。</li>
|
||||
|
@ -340,7 +340,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h3>ArrayMerger</h3>
|
||||
<p>在 Dubbo 中提供了处理不同类型返回值的 Merger 实现,其中不仅有处理 boolean[]、byte[]、char[]、double[]、float[]、int[]、long[]、short[] 等<strong>基础类型数组</strong>的 Merger 实现,还有处理 List、Set、Map 等<strong>集合类</strong>的 Merger 实现,具体继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_PFWiAbmfPAAPxSnmLN4s499.png" alt="Lark20201208-135542.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PFWiAbmfPAAPxSnmLN4s499.png" alt="png" /></p>
|
||||
<p>Merger 继承关系图</p>
|
||||
<p>我们首先来看 ArrayMerger 实现:<strong>当服务接口的返回值为数组的时候,会使用 ArrayMerger 将多个数组合并成一个数组,也就是将二维数组拍平成一维数组</strong>。ArrayMerger.merge() 方法的具体实现如下:</p>
|
||||
<pre><code>public Object[] merge(Object[]... items) {
|
||||
|
@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<p>在前面第 38 课时中,我们深入介绍了 Dubbo 提供的多种 Cluster 实现以及相关的 Cluster Invoker 实现,其中的 ZoneAwareClusterInvoker 就涉及了 MockClusterInvoker 的相关内容。本课时我们就来介绍 Dubbo 中 Mock 机制的全链路流程,不仅包括与 Cluster 接口相关的 MockClusterWrapper 和 MockClusterInvoker,我们还会回顾前面课程的 Router 和 Protocol 接口,分析它们与 Mock 机制相关的实现。</p>
|
||||
<h3>MockClusterWrapper</h3>
|
||||
<p>Cluster 接口有两条继承线(如下图所示):一条线是 AbstractCluster 抽象类,这条继承线涉及的全部 Cluster 实现类我们已经在[第 37 课时]中深入分析过了;另一条线是 MockClusterWrapper 这条线。</p>
|
||||
<p><img src="assets/Ciqc1F_PExSAAkePAABxACxNLzc156.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_PExSAAkePAABxACxNLzc156.png" alt="png" /></p>
|
||||
<p>Cluster 继承关系图</p>
|
||||
<p><strong>MockClusterWrapper 是 Cluster 对象的包装类</strong>,我们在之前[第 4 课时]介绍 Dubbo SPI 机制时已经分析过 Wrapper 的功能,MockClusterWrapper 类会对 Cluster 进行包装。下面是 MockClusterWrapper 的具体实现,其中会在 Cluster Invoker 对象的基础上使用 MockClusterInvoker 进行包装:</p>
|
||||
<pre><code>public class MockClusterWrapper implements Cluster {
|
||||
@ -382,7 +382,7 @@ function hide_canvas() {
|
||||
</code></pre>
|
||||
<h3>MockInvokersSelector</h3>
|
||||
<p>在[第 32 课时]和[第 33 课时]中,我们介绍了 Router 接口多个实现类,但当时并没有深入介绍 Mock 相关的 Router 实现类—— MockInvokersSelector,它的继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_PEyqAeilHAAAnrF4cOr8848.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_PEyqAeilHAAAnrF4cOr8848.png" alt="png" /></p>
|
||||
<p>MockInvokersSelector 继承关系图</p>
|
||||
<p><strong>MockInvokersSelector 是 Dubbo Mock 机制相关的 Router 实现</strong>,在未开启 Mock 机制的时候,会返回正常的 Invoker 对象集合;在开启 Mock 机制之后,会返回 MockInvoker 对象集合。MockInvokersSelector 的具体实现如下:</p>
|
||||
<pre><code>public <T> List<Invoker<T>> route(final List<Invoker<T>> invokers,
|
||||
|
@ -352,7 +352,7 @@ function hide_canvas() {
|
||||
}
|
||||
</code></pre>
|
||||
<p>这里我们重点关注的是<strong>exportServices() 方法,它是服务发布核心逻辑的入口</strong>,其中每一个服务接口都会转换为对应的 ServiceConfig 实例,然后通过代理的方式转换成 Invoker,最终转换成 Exporter 进行发布。服务发布流程中涉及的核心对象转换,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_YdkGABhTFAACpT-2oDtw867.png" alt="Lark20201215-163844.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_YdkGABhTFAACpT-2oDtw867.png" alt="png" /></p>
|
||||
<p>服务发布核心流程图</p>
|
||||
<p>exportServices() 方法的具体实现如下:</p>
|
||||
<pre><code>private void exportServices() {
|
||||
@ -667,7 +667,7 @@ anyhost=true
|
||||
<li>触发 RegistryProtocolListener 监听器。</li>
|
||||
</ol>
|
||||
<p>远程发布的详细流程如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_YNDaATl3fAAFcJTJOw3M699.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_YNDaATl3fAAFcJTJOw3M699.png" alt="png" /></p>
|
||||
<p>服务发布详细流程图</p>
|
||||
<h3>总结</h3>
|
||||
<p>本课时我们重点介绍了 Dubbo 服务发布的核心流程。</p>
|
||||
|
@ -294,7 +294,7 @@ function hide_canvas() {
|
||||
<p>在微服务架构中,服务是基本单位,而 Dubbo 架构中服务的基本单位是 Java 接口,这种架构上的差别就会带来一系列挑战。<strong>从 2.7.5 版本开始,Dubbo 引入了服务自省架构,来应对微服务架构带来的挑战</strong>。具体都有哪些挑战呢?下面我们就来详细说明一下。</p>
|
||||
<h3>注册中心面临的挑战</h3>
|
||||
<p>在开始介绍注册中心面临的挑战之前,我们先来回顾一下前面课时介绍过的 Dubbo 传统架构以及这个架构中最核心的组件:</p>
|
||||
<p><img src="assets/Cip5yF_hcH2AVI2PAAB7_C-aKWA247.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_hcH2AVI2PAAB7_C-aKWA247.png" alt="png" /></p>
|
||||
<p>Dubbo 核心架构图</p>
|
||||
<p>结合上面这张架构图,我们可以一起回顾一下这些核心组件的功能。</p>
|
||||
<ul>
|
||||
@ -315,7 +315,7 @@ function hide_canvas() {
|
||||
<h3>Dubbo 的改进方案</h3>
|
||||
<p>Dubbo 从 2.7.0 版本开始增加了<strong>简化 URL</strong>的特性,从 URL 中抽出的数据会被存放至元数据中心。但是这次优化只是缩短了 URL 的长度,从内存使用量以及降低通知频繁度的角度降低了注册中心的压力,并没有减少注册中心 URL 的数量,所以注册中心所承受的压力还是比较明显的。</p>
|
||||
<p>Dubbo 2.7.5 版本引入了<strong>服务自省架构</strong>,进一步降低了注册中心的压力。在此次优化中,Dubbo 修改成应用为粒度的服务注册与发现模型,最大化地减少了 Dubbo 服务元信息注册数量,其核心流程如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_hcJqACV_gAAEpu4IHuz4068.png" alt="Lark20201222-120323.png" /></p>
|
||||
<p><img src="assets/CgqCHl_hcJqACV_gAAEpu4IHuz4068.png" alt="png" /></p>
|
||||
<p>服务自省架构图</p>
|
||||
<p>上图展示了引入服务自省之后的 Dubbo 服务注册与发现的核心流程,Dubbo 会按照顺序执行这些操作(当其中一个操作失败时,后续操作不会执行)。</p>
|
||||
<p>我们首先来看 Provider 侧的执行流程:</p>
|
||||
@ -338,7 +338,7 @@ function hide_canvas() {
|
||||
<p>在有的场景中,我们会在线上部署两组不同配置的服务节点,来验证某些配置是否生效。例如,共有 100 个服务节点,平均分成 A、B 两组,A 组服务节点超时时间(即 timeout)设置为 3000 ms,B 组的超时时间(即 timeout)设置为 2000 ms,这样的话该服务就有了两组不同的元数据。</p>
|
||||
<p>按照前面介绍的优化方案,在订阅服务的时候,会得到 100 个 ServiceInstance,因为每个 ServiceInstance 发布的服务元数据都有可能不一样,所以我们需要调用每个 ServiceInstance 的 MetadataService 服务获取元数据。</p>
|
||||
<p>为了减少 MetadataService 服务的调用次数,Dubbo 提出了<strong>服务修订版本的优化方案</strong>,其核心思想是:将每个 ServiceInstance 发布的服务 URL 计算一个 hash 值(也就是 revision 值),并随 ServiceInstance 一起发布到注册中心;在 Consumer 端进行订阅的时候,对于 revision 值相同的 ServiceInstance,不再调用 MetadataService 服务,直接共用一份 URL 即可。下图展示了 Dubbo 服务修订的核心逻辑:</p>
|
||||
<p><img src="assets/Cip5yF_hcMyALC7UAAEPa7NIifA395.png" alt="Lark20201222-120318.png" /></p>
|
||||
<p><img src="assets/Cip5yF_hcMyALC7UAAEPa7NIifA395.png" alt="png" /></p>
|
||||
<p>引入 Dubbo 服务修订的 Consumer 端交互图</p>
|
||||
<p>通过该流程图,我们可以看到 Dubbo Consumer 端实现服务修订的流程如下。</p>
|
||||
<ol>
|
||||
|
@ -338,12 +338,12 @@ function hide_canvas() {
|
||||
<li>methods(List 类型):接口中定义的全部方法描述信息。在 MethodDefinition 中记录了方法的名称、参数类型、返回值类型以及方法参数涉及的所有 TypeDefinition。</li>
|
||||
<li>types(List 类型):接口定义中涉及的全部类型描述信息,包括方法的参数和字段,如果遇到复杂类型,TypeDefinition 会递归获取复杂类型内部的字段。在 dubbo-metadata-api 模块中,提供了多种类型对应的 TypeBuilder 用于创建对应的 TypeDefinition,对于没有特定 TypeBuilder 实现的类型,会使用 DefaultTypeBuilder。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl_lrYOAIzjeAAFEk3cUdpg570.png" alt="6.png" /></p>
|
||||
<p><img src="assets/CgqCHl_lrYOAIzjeAAFEk3cUdpg570.png" alt="png" /></p>
|
||||
<p>TypeBuilder 接口实现关系图</p>
|
||||
<p><strong>在服务发布的时候,会将服务的 URL 中的部分数据封装为 FullServiceDefinition 对象,然后作为元数据存储起来</strong>。FullServiceDefinition 继承了 ServiceDefinition,并在 ServiceDefinition 基础之上扩展了 params 集合(Map<String, String> 类型),用来存储 URL 上的参数。</p>
|
||||
<h3>MetadataService</h3>
|
||||
<p>接下来看 MetadataService 接口,在上一讲我们提到<strong>Dubbo 中的每个 ServiceInstance 都会发布 MetadataService 接口供 Consumer 端查询元数据</strong>,下图展示了 MetadataService 接口的继承关系:</p>
|
||||
<p><img src="assets/CgpVE1_lrZGANC4vAAGdcllZU9o940.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_lrZGANC4vAAGdcllZU9o940.png" alt="png" /></p>
|
||||
<p>MetadataService 接口继承关系图</p>
|
||||
<p>在 MetadataService 接口中定义了查询当前 ServiceInstance 发布的元数据的相关方法,具体如下所示:</p>
|
||||
<pre><code>public interface MetadataService {
|
||||
@ -481,7 +481,7 @@ private boolean doFunction(BiFunction<WritableMetadataService, URL, Boolean&g
|
||||
<p>元数据中心是 Dubbo 2.7.0 版本之后新增的一项优化,其主要目的是将 URL 中的一部分内容存储到元数据中心,从而减少注册中心的压力。</p>
|
||||
<p><strong>元数据中心的数据只是给本端自己使用的,改动不需要告知对端</strong>,例如,Provider 修改了元数据,不需要实时通知 Consumer。这样,在注册中心存储的数据量减少的同时,还减少了因为配置修改导致的注册中心频繁通知监听者情况的发生,很好地减轻了注册中心的压力。</p>
|
||||
<p><strong>MetadataReport 接口是 Dubbo 节点与元数据中心交互的桥梁</strong>,其继承关系如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_lramAYf82AAFkkbA0N2g785.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Cip5yF_lramAYf82AAFkkbA0N2g785.png" alt="png" /></p>
|
||||
<p>MetadataReport 继承关系图</p>
|
||||
<p>我们先来看一下 MetadataReport 接口的核心定义:</p>
|
||||
<pre><code>public interface MetadataReport {
|
||||
@ -675,10 +675,10 @@ private boolean doHandleMetadataCollection(Map<MetadataIdentifier, Object>
|
||||
</code></pre>
|
||||
<p>在 AbstractMetadataReport 的构造方法中,会根据 reportServerURL(也就是后面的 metadataReportURL)参数启动一个“天”级别的定时任务,该定时任务会执行 publishAll() 方法,其中会通过 doHandleMetadataCollection() 方法将 allMetadataReports 集合中的全部元数据重新进行上报。该定时任务默认是在凌晨 02:00~06:00 启动,每天执行一次。</p>
|
||||
<p>到此为止,AbstractMetadataReport 为子类实现的公共能力就介绍完了,其他方法都是委托给了相应的 do*() 方法,这些 do*() 方法都是在 AbstractMetadataReport 子类中实现的。</p>
|
||||
<p><img src="assets/CgqCHl_hcauAR9AQAAG7kMJSlc8827.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl_hcauAR9AQAAG7kMJSlc8827.png" alt="png" /></p>
|
||||
<h4>2. BaseMetadataIdentifier</h4>
|
||||
<p>在 AbstractMetadataReport 上报元数据的时候,元数据对应的 Key 都是BaseMetadataIdentifier 类型的对象,其继承关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_lrb-Ad3a5AAGJ2ySdyBE643.png" alt="3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_lrb-Ad3a5AAGJ2ySdyBE643.png" alt="png" /></p>
|
||||
<p>BaseMetadataIdentifier 继承关系图</p>
|
||||
<ul>
|
||||
<li>MetadataIdentifier 中包含了服务接口、version、group、side 和 application 五个核心字段。</li>
|
||||
@ -695,7 +695,7 @@ public interface MetadataReportFactory {
|
||||
</code></pre>
|
||||
<p>MetadataReportFactory 是个扩展接口,从 @SPI 注解的默认值可以看出<strong>Dubbo 默认使用 Redis 实现元数据中心</strong>。
|
||||
Dubbo 提供了针对 ZooKeeper、Redis、Consul 等作为元数据中心的 MetadataReportFactory 实现,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_lrcmAEIYBAAFSOVpEU1Y779.png" alt="4.png" /></p>
|
||||
<p><img src="assets/CgqCHl_lrcmAEIYBAAFSOVpEU1Y779.png" alt="png" /></p>
|
||||
<p>MetadataReportFactory 继承关系图</p>
|
||||
<p>这些 MetadataReportFactory 实现都继承了 AbstractMetadataReportFactory,在 AbstractMetadataReportFactory 提供了缓存 MetadataReport 实现的功能,并定义了一个 createMetadataReport() 抽象方法供子类实现。另外,AbstractMetadataReportFactory 实现了 MetadataReportFactory 接口的 getMetadataReport() 方法,下面我们就来简单看一下该方法的实现:</p>
|
||||
<pre><code>public MetadataReport getMetadataReport(URL url) {
|
||||
@ -763,7 +763,7 @@ String getNodePath(BaseMetadataIdentifier metadataIdentifier) {
|
||||
}
|
||||
</code></pre>
|
||||
<p><strong>MetadataServiceExporter 只有 ConfigurableMetadataServiceExporter 这一个实现</strong>,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_hcfmAMtHdAABVR_mzQyg047.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Cip5yF_hcfmAMtHdAABVR_mzQyg047.png" alt="png" /></p>
|
||||
<p>MetadataServiceExporter 继承关系图</p>
|
||||
<p>ConfigurableMetadataServiceExporter 的核心实现是 export() 方法,其中会创建一个 ServiceConfig 对象完成 MetadataService 服务的发布:</p>
|
||||
<pre><code>public ConfigurableMetadataServiceExporter export() {
|
||||
|
@ -368,10 +368,10 @@ public interface ServiceDiscovery extends Prioritized {
|
||||
}
|
||||
</code></pre>
|
||||
<p>ServiceDiscovery 接口被 @SPI 注解修饰,是一个扩展点,针对不同的注册中心,有不同的 ServiceDiscovery 实现,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_q45aAGn14AAEh58Guyew441.png" alt="Lark20201229-160604.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_q45aAGn14AAEh58Guyew441.png" alt="png" /></p>
|
||||
<p>ServiceDiscovery 继承关系图</p>
|
||||
<p>在 Dubbo 创建 ServiceDiscovery 对象的时候,会通过 ServiceDiscoveryFactory 工厂类进行创建。ServiceDiscoveryFactory 接口也是一个扩展接口,Dubbo 只提供了一个默认实现—— DefaultServiceDiscoveryFactory,其继承关系如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_q4_iAZ8ARAAEu4mMS65Y213.png" alt="Lark20201229-160606.png" /></p>
|
||||
<p><img src="assets/CgpVE1_q4_iAZ8ARAAEu4mMS65Y213.png" alt="png" /></p>
|
||||
<p>ServiceDiscoveryFactory 继承关系图</p>
|
||||
<p>在 AbstractServiceDiscoveryFactory 中维护了一个 ConcurrentMap<String, ServiceDiscovery> 类型的集合(discoveries 字段)来缓存 ServiceDiscovery 对象,并提供了一个 createDiscovery() 抽象方法来创建 ServiceDiscovery 实例。</p>
|
||||
<pre><code>public ServiceDiscovery getServiceDiscovery(URL registryURL) {
|
||||
@ -427,7 +427,7 @@ public static org.apache.curator.x.discovery.ServiceInstance<ZookeeperInstanc
|
||||
<p>除了上述服务实例发布的功能之外,在服务实例订阅的时候,还会用到 ZookeeperServiceDiscovery 查询服务实例的信息,这些方法都是直接依赖 Apache Curator 实现的,例如,getServices() 方法会调用 Curator ServiceDiscovery 的 queryForNames() 方法查询 Service Name,getInstances() 方法会通过 Curator ServiceDiscovery 的 queryForInstances() 方法查询 Service Instance。</p>
|
||||
<h3>EventListener 接口</h3>
|
||||
<p>ZookeeperServiceDiscovery 除了实现了 ServiceDiscovery 接口之外,还实现了 EventListener 接口,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_petCAV9sXAAB9u4EYOqk073.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Cip5yF_petCAV9sXAAB9u4EYOqk073.png" alt="png" /></p>
|
||||
<p>ZookeeperServiceDiscovery 继承关系图</p>
|
||||
<p>也就是说,<strong>ZookeeperServiceDiscovery 本身也是 EventListener 实现,可以作为 EventListener 监听某些事件</strong>。下面我们先来看 Dubbo 中 EventListener 接口的定义,其中关注三个方法:onEvent() 方法、getPriority() 方法和 findEventType() 工具方法。</p>
|
||||
<pre><code>@SPI
|
||||
@ -461,7 +461,7 @@ public interface EventListener<E extends Event> extends java.util.EventLis
|
||||
}
|
||||
</code></pre>
|
||||
<p>Dubbo 中有很多 EventListener 接口的实现,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_petmAI2w9AAC5QQrgGjY394.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Cip5yF_petmAI2w9AAC5QQrgGjY394.png" alt="png" /></p>
|
||||
<p>EventListener 继承关系图</p>
|
||||
<p>我们先来重点关注 ZookeeperServiceDiscovery 这个实现,在其 onEvent() 方法(以及 addServiceInstancesChangedListener() 方法)中会调用 registerServiceWatcher() 方法重新注册:</p>
|
||||
<pre><code>public void onEvent(ServiceInstancesChangedEvent event) {
|
||||
@ -517,7 +517,7 @@ public interface EventDispatcher extends Listenable<EventListener<?>>
|
||||
}
|
||||
</code></pre>
|
||||
<p>EventDispatcher 接口被 @SPI 注解修饰,是一个扩展点,Dubbo 提供了两个具体实现——ParallelEventDispatcher 和 DirectEventDispatcher,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_pew-AdtkyAAB-Epfg96E814.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Cip5yF_pew-AdtkyAAB-Epfg96E814.png" alt="png" /></p>
|
||||
<p>EventDispatcher 继承关系图</p>
|
||||
<p>在 AbstractEventDispatcher 中维护了两个核心字段。</p>
|
||||
<ul>
|
||||
|
@ -291,7 +291,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>46 加餐:深入服务自省方案中的服务发布订阅(下)</h1>
|
||||
<p>在课程第二部分(13~15 课时)中介绍 Dubbo 传统框架中的注册中心部分实现时,我们提到了 Registry、RegistryFactory 等与注册中心交互的接口。<strong>为了将 ServiceDiscovery 接口的功能与 Registry 融合,Dubbo 提供了一个 ServiceDiscoveryRegistry 实现</strong>,继承关系如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F_pe3KAQs8SAAPkHLoWbUM655.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_pe3KAQs8SAAPkHLoWbUM655.png" alt="png" /></p>
|
||||
<p>ServiceDiscoveryRegistry 、ServiceDiscoveryRegistryFactory 继承关系图</p>
|
||||
<p>由图我们可以看到:ServiceDiscoveryRegistryFactory(扩展名称是 service-discovery-registry)是 ServiceDiscoveryRegistry 对应的工厂类,继承了 AbstractRegistryFactory 提供的公共能力。</p>
|
||||
<p><strong>ServiceDiscoveryRegistry 是一个面向服务实例(ServiceInstance)的注册中心实现</strong>,其底层依赖前面两个课时介绍的 ServiceDiscovery、WritableMetadataService 等组件。</p>
|
||||
@ -476,7 +476,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
|
||||
</code></pre>
|
||||
<p>关于 ServiceInstanceCustomizer 接口,这里需要关注三个点:①该接口被 @SPI 注解修饰,是一个扩展点;②该接口继承了 Prioritized 接口;③该接口中定义的 customize() 方法可以用来自定义 ServiceInstance 信息,其中就包括控制 metadata 集合中的数据。</p>
|
||||
<p>也就说,<strong>ServiceInstanceCustomizer 的多个实现可以按序调用,实现 ServiceInstance 的自定义</strong>。下图展示了 ServiceInstanceCustomizer 接口的所有实现类:</p>
|
||||
<p><img src="assets/CgpVE1_pe6SAT90SAAC2xP9_c7c171.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_pe6SAT90SAAC2xP9_c7c171.png" alt="png" /></p>
|
||||
<p>ServiceInstanceCustomizer 继承关系图</p>
|
||||
<p>我们首先来看 ServiceInstanceMetadataCustomizer 这个抽象类,它主要是对 ServiceInstance 中 metadata 这个 KV 集合进行自定义修改,这部分逻辑在 customize() 方法中,如下所示:</p>
|
||||
<pre><code>public final void customize(ServiceInstance serviceInstance) {
|
||||
@ -624,7 +624,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
|
||||
}
|
||||
</code></pre>
|
||||
<p>这里涉及一个新的接口——<strong>MetadataServiceProxyFactory,它是用来创建 MetadataService 本地代理的工厂类</strong>,继承关系如下所示:</p>
|
||||
<p><img src="assets/CgpVE1_pe72AFUTPAADh6TOy_Ak061.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgpVE1_pe72AFUTPAADh6TOy_Ak061.png" alt="png" /></p>
|
||||
<p>MetadataServiceProxyFactory 继承关系图</p>
|
||||
<p>在 BaseMetadataServiceProxyFactory 中提供了缓存 MetadataService 本地代理的公共功能,其中维护了一个 proxies 集合(HashMap<String, MetadataService> 类型),Key 是 Service Name 与一个 ServiceInstance 的 revision 值的组合,Value 是该 ServiceInstance 对应的 MetadataService 服务的本地代理对象。创建 MetadataService 本地代理的功能是在 createProxy() 抽象方法中实现的,这个方法由 BaseMetadataServiceProxyFactory 的子类具体实现。</p>
|
||||
<p><strong>下面来看 BaseMetadataServiceProxyFactory 的两个实现——DefaultMetadataServiceProxyFactory 和 RemoteMetadataServiceProxyFactory。</strong></p>
|
||||
@ -650,7 +650,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
|
||||
}
|
||||
</code></pre>
|
||||
<p>这里我们来看 MetadataServiceURLBuilder 接口中创建 MetadataService 服务对应的 URL 的逻辑,下图展示了 MetadataServiceURLBuilder 接口的实现:</p>
|
||||
<p><img src="assets/Ciqc1F_pe8eARHUJAACyQ5kof-I149.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_pe8eARHUJAACyQ5kof-I149.png" alt="png" /></p>
|
||||
<p>MetadataServiceURLBuilder 继承关系图</p>
|
||||
<p>其中,SpringCloudMetadataServiceURLBuilder 是兼容 Spring Cloud 的实现,这里就不深入分析了。我们重点来看 StandardMetadataServiceURLBuilder 的实现,其中会根据 ServiceInstance.metadata 携带的 URL 参数、Service Name、ServiceInstance 的 host 等信息构造 MetadataService 服务对应 URL,如下所示:</p>
|
||||
<pre><code>public List<URL> build(ServiceInstance serviceInstance) {
|
||||
@ -680,7 +680,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
|
||||
}
|
||||
</code></pre>
|
||||
<p>接下来我们看 RemoteMetadataServiceProxyFactory 这个实现类,其中的 createProxy() 方法会直接创建一个 RemoteMetadataServiceProxy 对象并返回。在前面第 44 课时介绍 MetadataService 接口的时候,我们重点介绍的是 WritableMetadataService 这个子接口下的实现,并没有提及 RemoteMetadataServiceProxy 这个实现。下图是 RemoteMetadataServiceProxy 在继承体系中的位置:</p>
|
||||
<p><img src="assets/Ciqc1F_pe9GAEnNjAAA4bEzwZzw039.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_pe9GAEnNjAAA4bEzwZzw039.png" alt="png" /></p>
|
||||
<p>RemoteMetadataServiceProxy 继承关系图</p>
|
||||
<p>RemoteMetadataServiceProxy 作为 RemoteWritableMetadataService 的本地代理,其 getExportedURLs()、getServiceDefinition() 等方法的实现,完全依赖于 MetadataReport 进行实现。这里以 getExportedURLs() 方法为例:</p>
|
||||
<pre><code>public SortedSet<String> getExportedURLs(String serviceInterface, String group, String version, String protocol) {
|
||||
|
@ -306,11 +306,11 @@ function hide_canvas() {
|
||||
</ol>
|
||||
<h3>Configuration</h3>
|
||||
<p><strong>Configuration 接口是 Dubbo 中所有配置的基础接口</strong>,其中定义了根据指定 Key 获取对应配置值的相关方法,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_zz3yABBYdAACqAETTGm0778.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_zz3yABBYdAACqAETTGm0778.png" alt="png" /></p>
|
||||
<p>Configuration 接口核心方法</p>
|
||||
<p>从上图中我们可以看到,Configuration 针对不同的 boolean、int、String 返回值都有对应的 get*() 方法,同时还提供了带有默认值的 get*() 方法。<strong>这些 get<p style="text-align:center">*() 方法底层首先调用 getInternalProperty() 方法获取配置值</strong>,然后调用 convert() 方法将获取到的配置值转换成返回值的类型之后返回。getInternalProperty() 是一个抽象方法,由 Configuration 接口的子类具体实现。</p>
|
||||
<p>下图展示了 Dubbo 中提供的 Configuration 接口实现,包括:SystemConfiguration、EnvironmentConfiguration、InmemoryConfiguration、PropertiesConfiguration、CompositeConfiguration、ConfigConfigurationAdapter 和 DynamicConfiguration。下面我们将结合具体代码逐个介绍其实现。</p>
|
||||
<p><img src="assets/Cip5yF_zz6eAMN_oAACEEj9pVjg547.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yF_zz6eAMN_oAACEEj9pVjg547.png" alt="png" /></p>
|
||||
<p>Configuration 继承关系图</p>
|
||||
<h4>SystemConfiguration & EnvironmentConfiguration</h4>
|
||||
<p>SystemConfiguration 是从 Java Properties 配置(也就是 -D 配置参数)中获取相应的配置项,EnvironmentConfiguration 是从使用环境变量中获取相应的配置。两者的 getInternalProperty() 方法实现如下:</p>
|
||||
@ -435,7 +435,7 @@ public interface OrderedPropertiesProvider {
|
||||
</code></pre>
|
||||
<h4>ConfigConfigurationAdapter</h4>
|
||||
<p>Dubbo 通过 AbstractConfig 类来抽象实例对应的配置,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_zz8WAHdY3AAMJFKW_uQE360.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_zz8WAHdY3AAMJFKW_uQE360.png" alt="png" /></p>
|
||||
<p>AbstractConfig 继承关系图</p>
|
||||
<p>这些 AbstractConfig 实现基本都对应一个固定的配置,也定义了配置对应的字段以及 getter/setter() 方法。例如,RegistryConfig 这个实现类就对应了注册中心的相关配置,其中包含了 address、protocol、port、timeout 等一系列与注册中心相关的字段以及对应的 getter/setter() 方法,来接收用户通过 XML、Annotation 或是 API 方式传入的注册中心配置。</p>
|
||||
<p><strong>ConfigConfigurationAdapter 是 AbstractConfig 与 Configuration 之间的适配器</strong>,它会将 AbstractConfig 对象转换成 Configuration 对象。在 ConfigConfigurationAdapter 的构造方法中会获取 AbstractConfig 对象的全部字段,并转换成一个 Map<String, String> 集合返回,该 Map<String, String> 集合将会被 ConfigConfigurationAdapter 的 metaData 字段引用。相关示例代码如下:</p>
|
||||
@ -485,9 +485,9 @@ public interface DynamicConfigurationFactory {
|
||||
}
|
||||
</code></pre>
|
||||
<p>DynamicConfigurationFactory 接口的继承关系以及 DynamicConfiguration 接口对应的继承关系如下:</p>
|
||||
<p><img src="assets/CgqCHl_0L-GAPVy9AAEqog2bl7U068.png" alt="11.png" /></p>
|
||||
<p><img src="assets/CgqCHl_0L-GAPVy9AAEqog2bl7U068.png" alt="png" /></p>
|
||||
<p>DynamicConfigurationFactory 继承关系图</p>
|
||||
<p><img src="assets/Cip5yF_zz9iAM1YYAAB_QXlLDcU550.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Cip5yF_zz9iAM1YYAAB_QXlLDcU550.png" alt="png" /></p>
|
||||
<p>DynamicConfiguration 继承关系图</p>
|
||||
<p>我们先来看 AbstractDynamicConfigurationFactory 的实现,其中会维护一个 dynamicConfigurations 集合(Map<String, DynamicConfiguration> 类型),在 getDynamicConfiguration() 方法中会填充该集合,实现<strong>缓存</strong>DynamicConfiguration 对象的效果。同时,AbstractDynamicConfigurationFactory 提供了一个 createDynamicConfiguration() 方法给子类实现,来<strong>创建</strong>DynamicConfiguration 对象。</p>
|
||||
<p>以 ZookeeperDynamicConfigurationFactory 实现为例,其 createDynamicConfiguration() 方法创建的就是 ZookeeperDynamicConfiguration 对象:</p>
|
||||
@ -575,7 +575,7 @@ public interface DynamicConfigurationFactory {
|
||||
}
|
||||
</code></pre>
|
||||
<p>CacheListener 中调用的监听器都是 ConfigurationListener 接口实现,如下图所示,这里涉及[第 33 课时]介绍的 TagRouter、AppRouter 和 ServiceRouter,它们主要是监听路由配置的变化;还涉及 RegistryDirectory 和 RegistryProtocol 中的四个内部类(AbstractConfiguratorListener 的子类),它们主要监听 Provider 和 Consumer 的配置变化。</p>
|
||||
<p><img src="assets/CgqCHl_0L9WAYbfVAAGH_E-l-UU432.png" alt="222.png" /></p>
|
||||
<p><img src="assets/CgqCHl_0L9WAYbfVAAGH_E-l-UU432.png" alt="png" /></p>
|
||||
<p>ConfigurationListener 继承关系图</p>
|
||||
<p>这些 ConfigurationListener 实现在前面的课程中已经详细介绍过了,这里就不再重复。ZookeeperDynamicConfiguration 中还提供了 addListener()、removeListener() 两个方法用来增删 ConfigurationListener 监听器,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考<a href="https://github.com/xxxlxy2008/dubbo">源码</a>进行学习。</p>
|
||||
<p>介绍完 ZookeeperDynamicConfiguration 的初始化过程之后,我们再来看 ZookeeperDynamicConfiguration 中<strong>读取配置、写入配置</strong>的相关操作。相关方法的实现如下:</p>
|
||||
|
@ -497,7 +497,7 @@ compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter))
|
||||
<p>随后,DubboBootstrap 执行 checkGlobalConfigs() 方法完成 ProviderConfig、ConsumerConfig、MetadataReportConfig 等一系列 AbstractConfig 的检查和初始化,具体实现比较简单,这里就不再展示。</p>
|
||||
<p>再紧接着,DubboBootstrap 会通过 initMetadataService() 方法初始化 MetadataReport、MetadataReportInstance 以及 MetadataService、MetadataServiceExporter,这些元数据相关的组件在前面的课时中已经深入分析过了,这里的初始化过程并不复杂,你若感兴趣的话可以参考<a href="https://github.com/xxxlxy2008/dubbo">源码</a>进行学习。</p>
|
||||
<p>在 DubboBootstrap 初始化的最后,会调用 initEventListener() 方法将 DubboBootstrap 作为 EventListener 监听器添加到 EventDispatcher 中。DubboBootstrap 继承了 GenericEventListener 抽象类,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_z0G2AfVK7AABzPAVnhNE632.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_z0G2AfVK7AABzPAVnhNE632.png" alt="png" /></p>
|
||||
<p>EventListener 继承关系图</p>
|
||||
<p><strong>GenericEventListener 是一个泛型监听器,它可以让子类监听任意关心的 Event 事件,只需定义相关的 onEvent() 方法即可</strong>。在 GenericEventListener 中维护了一个 handleEventMethods 集合,其中 Key 是 Event 的子类,即监听器关心的事件,Value 是处理该类型 Event 的相应 onEvent() 方法。</p>
|
||||
<p>在 GenericEventListener 的构造方法中,通过反射将当前 GenericEventListener 实现的全部 onEvent() 方法都查找出来,并记录到 handleEventMethods 字段中。具体查找逻辑在 findHandleEventMethods() 方法中实现:</p>
|
||||
@ -530,7 +530,7 @@ compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter))
|
||||
}
|
||||
</code></pre>
|
||||
<p>我们可以查看 DubboBootstrap 的所有方法,目前并没有发现符合 isHandleEventMethod() 条件的方法。但在 GenericEventListener 的另一个实现—— LoggingEventListener 中,可以看到多个符合 isHandleEventMethod() 条件的方法(如下图所示),在这些 onEvent() 方法重载中会输出 INFO 日志。</p>
|
||||
<p><img src="assets/CgqCHl_z0HeARRBdAAF6NMV2xrI252.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_z0HeARRBdAAF6NMV2xrI252.png" alt="png" /></p>
|
||||
<p>LoggingEventListener 中 onEvent 方法重载</p>
|
||||
<p>至此,DubboBootstrap 整个初始化过程,以及该过程中与配置中心相关的逻辑就介绍完了。</p>
|
||||
<h3>总结</h3>
|
||||
|
@ -191,14 +191,14 @@ function hide_canvas() {
|
||||
<p>近些年来,无论是使用规模、开发者人数,还是技术生态成熟度、相关工具的丰富程度,Java 都当之无愧是后端开发语言中不可撼动的王者,也是开发各类业务系统的首选语言。</p>
|
||||
<p>时至今日,整个 IT 招聘市场上,Java 开发工程师依然是缺口最大,需求最多的热门职位。另外,从整个市场环境看,传统企业的信息化,传统 IT 系统的互联网化,都还有非常大的发展空间,由此推断未来 Java 开发的市场前景广阔,从业人员的行业红利还可以持续很长时间。</p>
|
||||
<p>从权威的 TIOBE 编程语言排行榜 2019 年 11 月数据来看,Java 的流行程度也是稳居第一。</p>
|
||||
<p><img src="assets/1en79.jpg" alt="1c91b731-e86d-4b59-85b6-8b7ec53e87d6.jpg" /></p>
|
||||
<p><img src="assets/arybg.jpg" alt="4fead80b-dc2e-4c40-852f-c4bd22bab207.jpg" /></p>
|
||||
<p><img src="assets/1en79.jpg" alt="png" /></p>
|
||||
<p><img src="assets/arybg.jpg" alt="png" /></p>
|
||||
<p>拉勾网 2019 年 9 月统计的招聘岗位比例,也可以看到 Java 和 JavaScript 是最高的,不过 Java 的求职难度只有 JavaScript 的 1/7。</p>
|
||||
<p><img src="assets/gdniz.jpg" alt="f5b072d7-2235-4814-ac63-3e90f0633629.jpg" /></p>
|
||||
<p><img src="assets/gdniz.jpg" alt="png" /></p>
|
||||
<p>Java 平均一个岗位有 4 个人竞争,而 JavaScript 则是 28 个,Perl 最夸张,超过 30 个。</p>
|
||||
<p><img src="assets/cloph.jpg" alt="d70b22b6-177c-443e-8ef1-957531028c60.jpg" /></p>
|
||||
<p><img src="assets/cloph.jpg" alt="png" /></p>
|
||||
<p>而通过职友网的数据统计,北京、上海、杭州、深圳的 Java 程序员平均薪酬在 16-21K 之间,在广州、成都、苏州、南京等城市也有 11K-13K 的平均收入,远超一般行业的收入水平。</p>
|
||||
<p><img src="assets/xvr6f.jpg" alt="fd19dbb9-87e6-40bd-9d67-4455f1ee2513.jpg" /></p>
|
||||
<p><img src="assets/xvr6f.jpg" alt="png" /></p>
|
||||
<p>所以学习 Java 目前还是一个非常有优势的职业发展选择。</p>
|
||||
<p>而了解 JVM 则是深入学习 Java 必不可少的一环,也是 Java 开发人员迈向更高水平的一个阶梯。我们不仅要会用 Java 写代码做系统,更要懂得如何理解和分析 Java 程序运行起来以后内部发生了什么,然后可以怎么让它运行的更好。</p>
|
||||
<p>就像我们要想多年开车的老司机,仅仅会开车肯定不能当一个好司机。车开多了,总会有一些多多少少大大小小的故障毛病。老司机需要知道什么现象说明有了什么毛病,需要怎么处理,不然就会导致经常抛锚,影响我们的行程。</p>
|
||||
|
@ -208,10 +208,10 @@ function hide_canvas() {
|
||||
<li>JDK = JRE + 开发工具</li>
|
||||
<li>JRE = JVM + 类库</li>
|
||||
</ul>
|
||||
<p><img src="assets/alvxv.png" alt="0.18346271077222331.png" /></p>
|
||||
<p><img src="assets/alvxv.png" alt="png" /></p>
|
||||
<p>三者在开发运行 Java 程序时的交互关系:</p>
|
||||
<p>简单的说,就是通过 JDK 开发的程序,编译以后,可以打包分发给其他装有 JRE 的机器上去运行。而运行的程序,则是通过 Java 命令启动的一个 JVM 实例,代码逻辑的执行都运行在这个 JVM 实例上。</p>
|
||||
<p><img src="assets/mbl7s.png" alt="0.9484384203409852.png" /></p>
|
||||
<p><img src="assets/mbl7s.png" alt="png" /></p>
|
||||
<p>Java 程序的开发运行过程为:</p>
|
||||
<p>我们利用 JDK (调用 Java API)开发 Java 程序,编译成字节码或者打包程序。然后可以用 JRE 则启动一个 JVM 实例,加载、验证、执行 Java 字节码以及依赖库,运行 Java 程序。而 JVM 将程序和依赖库的 Java 字节码解析并变成本地代码执行,产生结果。</p>
|
||||
<h3>1.2 JDK 的发展过程与版本变迁</h3>
|
||||
@ -348,8 +348,8 @@ function hide_canvas() {
|
||||
<p>常规的 JDK,一般指 OpenJDK 或者 Oracle JDK,当然 Oracle 还有一个新的 JVM 叫 GraalVM,也非常有意思。除了 Sun/Oracle 的 JDK 以外,原 BEA 公司(已被 Oracle 收购)的 JRockit,IBM 公司的 J9,Azul 公司的 Zing JVM,阿里巴巴公司的分支版本 DragonWell 等等。</p>
|
||||
</blockquote>
|
||||
<h3>1.3 安装 JDK</h3>
|
||||
<p>JDK 通常是从 <a href="https://www.oracle.com/">Oracle 官网</a>下载, 打开页面翻到底部,找 <code>Java for Developers</code> 或者 <code>Developers</code>, 进入 <a href="https://www.oracle.com/technetwork/java/index.html">Java 相应的页面</a> 或者 <a href="https://www.oracle.com/technetwork/java/javase/overview/index.html">Java SE 相应的页面</a>, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 <img src="assets/tzok8.jpg" alt="891e2fe6-e872-4aa9-b00d-d176e947f11f.jpg" /></p>
|
||||
<p>建议安装比较新的 JDK8 版本, 如 <a href="https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html">JDK8u231</a>。 <img src="assets/igolf.png" alt="3bbdc5e9-149c-407d-b757-69a061581aae.png" /></p>
|
||||
<p>JDK 通常是从 <a href="https://www.oracle.com/">Oracle 官网</a>下载, 打开页面翻到底部,找 <code>Java for Developers</code> 或者 <code>Developers</code>, 进入 <a href="https://www.oracle.com/technetwork/java/index.html">Java 相应的页面</a> 或者 <a href="https://www.oracle.com/technetwork/java/javase/overview/index.html">Java SE 相应的页面</a>, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 <img src="assets/tzok8.jpg" alt="png" /></p>
|
||||
<p>建议安装比较新的 JDK8 版本, 如 <a href="https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html">JDK8u231</a>。 <img src="assets/igolf.png" alt="png" /></p>
|
||||
<blockquote>
|
||||
<p>注意:从 Oracle 官方安装 JDK 需要注册和登录 Oracle 账号。现在流行将下载链接放到页面底部,很多工具都这样。当前推荐下载 JDK8。 今后 JDK11 可能成为主流版本,因为 Java11 是 LTS 长期支持版本,但可能还需要一些时间才会普及,而且 JDK11 的文件目录结构与之前不同, 很多工具可能不兼容其 JDK 文件的目录结构。</p>
|
||||
</blockquote>
|
||||
@ -361,7 +361,7 @@ function hide_canvas() {
|
||||
<blockquote>
|
||||
<p>brew cask install java</p>
|
||||
</blockquote>
|
||||
<p>如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): <img src="assets/5rwjt.png" alt="035a0b3e-de33-4e97-946c-c9adb8b68ae7.png" /></p>
|
||||
<p>如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): <img src="assets/5rwjt.png" alt="png" /></p>
|
||||
<p>如果网络不好,可以从我的百度网盘共享获取:</p>
|
||||
<blockquote>
|
||||
<p><a href="https://pan.baidu.com/s/16WmRDZSiBD7a2PMjhSiGJw">https://pan.baidu.com/s/16WmRDZSiBD7a2PMjhSiGJw</a></p>
|
||||
@ -404,7 +404,7 @@ find / -name javac
|
||||
<p>找到满足 <code>$JAVA_HOME/bin/javac</code> 的路径即可。</p>
|
||||
<p>Windows 系统,安装在哪就是哪,默认在<code>C:\Program Files (x86)\Java</code>下。通过任务管理器也可以查看某个程序的路径,注意 <code>JAVA_HOME</code> 不可能是 <code>C:\Windows\System32</code> 目录。</p>
|
||||
<p>然后我们就可以在 JDK 安装路径下看到很多 JVM 工具,例如在 Mac 上:</p>
|
||||
<p><img src="assets/d5uc5.png" alt="54940291.png" /> 在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。</p>
|
||||
<p><img src="assets/d5uc5.png" alt="png" /> 在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。</p>
|
||||
<h3>1.4 验证 JDK 安装完成</h3>
|
||||
<p>安装完成后,Java 环境一般来说就可以使用了。 验证的脚本命令为:</p>
|
||||
<pre><code class="language-shell">$ java -version
|
||||
|
@ -190,13 +190,13 @@ function hide_canvas() {
|
||||
<blockquote>
|
||||
<p>前面一节课阐述了 JDK 的发展过程,以及怎么安装一个 JDK,在正式开始进行 JVM 的内容之前,我们先了解一下性能相关的一些基本概念和原则。</p>
|
||||
</blockquote>
|
||||
<p><img src="assets/bnt0w.png" alt="0.260488235671565.png" /></p>
|
||||
<p><img src="assets/bnt0w.png" alt="png" /></p>
|
||||
<p>如果要问目前最火热的 JVM 知识是什么? 很多同学的答案可能是 “<code>JVM 调优</code>” 或者 “<code>JVM 性能优化</code>”。但是具体需要从哪儿入手,怎么去做呢?</p>
|
||||
<p>其实“调优”是一个诊断和处理手段,我们最终的目标是让系统的处理能力,也就是“性能”达到最优化,这个过程我们就像是一个医生,诊断和治疗“应用系统”这位病人。我们以作为医生给系统看病作为对比,“性能优化”就是实现“把身体的大小毛病治好,身体达到最佳健康状态”的目标。</p>
|
||||
<p>那么去医院看病,医生会是怎么一个处理流程呢?先简单的询问和了解基本情况,发烧了没有,咳嗽几天了,最近吃了什么,有没有拉肚子一类的,然后给患者开了一系列的检查化验单子:去查个血、拍个胸透、验个尿之类的。然后就会有医生使用各项仪器工具,依次把去做这些项目的检查,检查的结果就是很多标准化的具体指标(这里就是我们对 JVM 进行信息收集,变成各项指标)。</p>
|
||||
<p>然后拿过来给医生诊断用,医生根据这些指标数据判断哪些是异常的,哪些是正常的,这些异常指标说明了什么问题(对系统问题进行分析排查),比如是白细胞增多(系统延迟和抖动增加,偶尔宕机),说明可能有炎症(比如 JVM 配置不合理)。最后要“对症下药”,开出一些阿莫西林或者头孢(对 JVM 配置进行调整),叮嘱怎么频率,什么时间点服药,如果问题比较严重,是不是要住院做手术(系统重构和调整),同时告知一些注意事项(对日常运维的要求和建议),最后经过一段时间治疗,逐渐好转,最终痊愈(系统延迟降低,不在抖动,不再宕机)。通过了解 JVM 去让我们具有分析和诊断能力,是本课程的核心主题。</p>
|
||||
<h3>2.1 量化性能相关指标</h3>
|
||||
<p><img src="assets/9iyxk.png" alt="0.7784482211178771.png" /></p>
|
||||
<p><img src="assets/9iyxk.png" alt="png" /></p>
|
||||
<p>"没有量化就没有改进",所以我们需要先了解和度量性能指标,就像在医院检查以后得到的检验报告单一样。因为人的主观感觉是不靠谱的,个人经验本身也是无法复制的,而定义了量化的指标,就意味着我们有了一个客观度量体系。哪怕我们最开始定义的指标不是特别精确,我们也可以在使用过程中,随着真实的场景去验证指标有效性,进而替换或者调整指标,逐渐的完善这个量化的指标体系,成为一个可以复制和复用的有效工具。就像是上图的<code>血常规检查报告单</code>,一旦成为这种标准化的指标,那么使用它得到的结果,也就是这个报告单,给任何一个医生看,都是有效的,一般也能得到一致的判断结果。</p>
|
||||
<p>那么系统性能的诊断要做些什么指标呢?我们先来考虑,进行要做诊断,那么程序或 JVM 可能出现了问题,而我们排查程序运行中出现的问题,比如排查程序 BUG 的时候,要优先保证正确性,这时候就不仅仅是 JVM 本身的问题,例如死锁等等,程序跑在 JVM 里,现象出现在 JVM 上,很多时候还要深入分析业务代码和逻辑确定 Java 程序哪里有问题。</p>
|
||||
<ol>
|
||||
@ -229,7 +229,7 @@ function hide_canvas() {
|
||||
<li><strong>业务需求指标</strong>:如吞吐量(QPS、TPS)、响应时间(RT)、并发数、业务成功率等。</li>
|
||||
<li><strong>资源约束指标</strong>:如 CPU、内存、I/O 等资源的消耗情况。</li>
|
||||
</ul>
|
||||
<p><img src="assets/uc0za.png" alt="0.3186824516633562.png" /></p>
|
||||
<p><img src="assets/uc0za.png" alt="png" /></p>
|
||||
<blockquote>
|
||||
<p>详情可参考: <a href="https://www.jianshu.com/p/62cf2690e6eb">性能测试中服务器关键性能指标浅析</a></p>
|
||||
</blockquote>
|
||||
@ -246,7 +246,7 @@ function hide_canvas() {
|
||||
<li>调整 JVM 启动参数,GC 策略等等</li>
|
||||
</ul>
|
||||
<h3>2.3 性能调优总结</h3>
|
||||
<p><img src="assets/wgj7v.png" alt="9b861ce8-8350-4943-ac1f-d6fb4fa2f127.png" /></p>
|
||||
<p><img src="assets/wgj7v.png" alt="png" /></p>
|
||||
<p>性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 TPS 和 QPS,就是极限值。知道了极限值,我们就可以按业务发展测算流量和系统压力,以此做容量规划,准备机器资源和预期的扩容计划。最后在系统的日常运行过程中,持续观察,逐步重做和调整以上步骤,长期改善改进系统性能。</p>
|
||||
<p>我们经常说“<code>脱离场景谈性能都是耍流氓</code>”,实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到 3000TPS 如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到 3100TPS 就没有什么意义,同样地如果花一倍成本去优化到 5000TPS 也没有意义。</p>
|
||||
<p>Donald Knuth 曾说过“<code>过早的优化是万恶之源</code>”,我们需要考虑在恰当的时机去优化系统。在业务发展的早期,量不大,性能没那么重要。我们做一个新系统,先考虑整体设计是不是 OK,功能实现是不是 OK,然后基本的功能都做得差不多的时候(当然整体的框架是不是满足性能基准,可能需要在做项目的准备阶段就通过 POC(概念证明)阶段验证。),最后再考虑性能的优化工作。因为如果一开始就考虑优化,就可能要想太多导致过度设计了。而且主体框架和功能完成之前,可能会有比较大的改动,一旦提前做了优化,可能这些改动导致原来的优化都失效了,又要重新优化,多做了很多无用功。</p>
|
||||
|
@ -197,7 +197,7 @@ function hide_canvas() {
|
||||
<p>我们都知道 Java 是一种基于虚拟机的静态类型编译语言。那么常见的语言可以怎么分类呢?</p>
|
||||
<h4>1)编程语言分类</h4>
|
||||
<p>首先,我们可以把形形色色的编程从底向上划分为最基本的三大类:机器语言、汇编语言、高级语言。</p>
|
||||
<p><img src="assets/g6xl5.png" alt="66340662.png" /></p>
|
||||
<p><img src="assets/g6xl5.png" alt="png" /></p>
|
||||
<p>按《计算机编程语言的发展与应用》一文里的定义:计算机编程语言能够实现人与机器之间的交流和沟通,而计算机编程语言主要包括汇编语言、机器语言以及高级语言,具体内容如下:</p>
|
||||
<ul>
|
||||
<li>机器语言:这种语言主要是利用二进制编码进行指令的发送,能够被计算机快速地识别,其灵活性相对较高,且执行速度较为可观,机器语言与汇编语言之间的相似性较高,但由于具有局限性,所以在使用上存在一定的约束性。</li>
|
||||
@ -238,8 +238,8 @@ function hide_canvas() {
|
||||
<p>现在我们聊聊跨平台,为什么要跨平台,因为我们希望所编写的代码和程序,在源代码级别或者编译后,可以运行在多种不同的系统平台上,而不需要为了各个平台的不同点而去实现两套代码。典型地,我们编写一个 web 程序,自然希望可以把它部署到 Windows 平台上,也可以部署到 Linux 平台上,甚至是 MacOS 系统上。</p>
|
||||
<p>这就是跨平台的能力,极大地节省了开发和维护成本,赢得了商业市场上的一致好评。</p>
|
||||
<p>这样来看,一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。</p>
|
||||
<p>1、典型的源码跨平台(C++): <img src="assets/2hieg.png" alt="71212109.png" /></p>
|
||||
<p>2、典型的二进制跨平台(Java 字节码): <img src="assets/987sb.png" alt="71237637.png" /></p>
|
||||
<p>1、典型的源码跨平台(C++): <img src="assets/2hieg.png" alt="png" /></p>
|
||||
<p>2、典型的二进制跨平台(Java 字节码): <img src="assets/987sb.png" alt="png" /></p>
|
||||
<p>可以看到,C++ 里我们需要把一份源码,在不同平台上分别编译,生成这个平台相关的二进制可执行文件,然后才能在相应的平台上运行。 这样就需要在各个平台都有开发工具和编译器,而且在各个平台所依赖的开发库都需要是一致或兼容的。 这一点在过去的年代里非常痛苦,被戏称为 “依赖地狱”。</p>
|
||||
<p>C++ 的口号是“一次编写,到处(不同平台)编译”,但实际情况上是一编译就报错,变成了 “一次编写,到处调试,到处找依赖、改配置”。 大家可以想象,你编译一份代码,发现缺了几十个依赖,到处找还找不到,或者找到了又跟本地已有的版本不兼容,这是一件怎样令人绝望的事情。</p>
|
||||
<p>而 Java 语言通过虚拟机技术率先解决了这个难题。 源码只需要编译一次,然后把编译后的 class 文件或 jar 包,部署到不同平台,就可以直接通过安装在这些系统中的 JVM 上面执行。 同时可以把依赖库(jar 文件)一起复制到目标机器,慢慢地又有了可以在各个平台都直接使用的 Maven 中央库(类似于 linux 里的 yum 或 apt-get 源,macos 里的 homebrew,现代的各种编程语言一般都有了这种包依赖管理机制:python 的 pip,dotnet 的 nuget,NodeJS 的 npm,golang 的 dep,rust 的 cargo 等等)。这样就实现了让同一个应用程序在不同的平台上直接运行的能力。</p>
|
||||
|
@ -393,7 +393,7 @@ SourceFile: "HelloByteCode.java"
|
||||
<p>想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。</p>
|
||||
<p>JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储<code>栈帧</code>(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。<code>栈帧</code> 由 <code>操作数栈</code>, <code>局部变量数组</code> 以及一个<code>class 引用</code>组成。<code>class 引用</code> 指向当前方法在运行时常量池中对应的 class)。</p>
|
||||
<p>我们在前面反编译的代码中已经看到过这些内容。</p>
|
||||
<p><img src="assets/y6bxd.jpg" alt="c0463778-bb4c-43ab-9660-558d2897b364.jpg" /></p>
|
||||
<p><img src="assets/y6bxd.jpg" alt="png" /></p>
|
||||
<p><code>局部变量数组</code> 也称为 <code>局部变量表</code>(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。</p>
|
||||
<p>有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。</p>
|
||||
<h3>4.7 方法体中的字节码解读</h3>
|
||||
@ -408,11 +408,11 @@ SourceFile: "HelloByteCode.java"
|
||||
<p>例如, <code>new</code> 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。</p>
|
||||
<p>因此,下一条指令 <code>dup</code> 的索引从 <code>3</code> 开始。</p>
|
||||
<p>如果将这个方法体变成可视化数组,那么看起来应该是这样的:</p>
|
||||
<p><img src="assets/2wcmu.jpg" alt="2087a5ff-61b1-49ab-889e-698a73ceb41e.jpg" /></p>
|
||||
<p><img src="assets/2wcmu.jpg" alt="png" /></p>
|
||||
<p>每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示:</p>
|
||||
<p><img src="assets/76qr6.jpg" alt="b75bd86b-45c4-4b05-9266-1b7151c7038f.jpg" /></p>
|
||||
<p><img src="assets/76qr6.jpg" alt="png" /></p>
|
||||
<p>甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:</p>
|
||||
<p><img src="assets/poywn.jpg" alt="9f8bf31f-e936-47c6-a3d1-f0c0de0fc898.jpg" /> (此图由开源文本编辑软件Atom的hex-view插件生成)</p>
|
||||
<p><img src="assets/poywn.jpg" alt="png" /> (此图由开源文本编辑软件Atom的hex-view插件生成)</p>
|
||||
<p>粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。</p>
|
||||
<p>其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 <code>类加载器</code>,其他主题则留待以后探讨。</p>
|
||||
<h3>4.8 对象初始化指令:new 指令, init 以及 clinit 简介</h3>
|
||||
@ -452,13 +452,13 @@ SourceFile: "HelloByteCode.java"
|
||||
<li><code>dup_x1</code> 将复制栈顶元素的值,并在栈顶插入两次(图中示例5);</li>
|
||||
<li><code>dup2_x1</code> 则复制栈顶两个元素的值,并插入第三个值(图中示例6)。</li>
|
||||
</ul>
|
||||
<p><img src="assets/kg99w.jpg" alt="9d1a9509-c0ca-4320-983c-141257b0ddf5.jpg" /></p>
|
||||
<p><img src="assets/kg99w.jpg" alt="png" /></p>
|
||||
<p><code>dup_x1</code> 和 <code>dup2_x1</code> 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?</p>
|
||||
<p>请看一个实际案例:怎样交换 2 个 double 类型的值?</p>
|
||||
<p>需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。</p>
|
||||
<p>要执行交换,你可能想到了 <code>swap</code> 指令,但问题是 <code>swap</code> 只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。</p>
|
||||
<p>怎么办呢? 解决方法就是使用 <code>dup2_x2</code> 指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 <code>pop2</code> 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示:</p>
|
||||
<p><img src="assets/yttg7.jpg" alt="17ee9537-a42f-4a49-bb87-9a03735ab83a.jpg" /></p>
|
||||
<p><img src="assets/yttg7.jpg" alt="png" /></p>
|
||||
<h4><code>dup</code>、<code>dup_x1</code>、<code>dup2_x1</code> 指令补充说明</h4>
|
||||
<p>指令的详细说明可参考 <a href="https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html">JVM 规范</a>:</p>
|
||||
<p><strong>dup 指令</strong></p>
|
||||
@ -656,7 +656,7 @@ public class LocalVariableTest {
|
||||
<p>关于 <code>LocalVariableTable</code> 有个有意思的事情,就是最前面的槽位会被方法参数占用。</p>
|
||||
<p>在这里,因为 <code>main</code> 是静态方法,所以槽位0中并没有设置为 <code>this</code> 引用的地址。 但是对于非静态方法来说, <code>this</code> 会将分配到第 0 号槽位中。</p>
|
||||
<blockquote>
|
||||
<p>再次提醒: 有过反射编程经验的同学可能比较容易理解: <code>Method#invoke(Object obj, Object... args)</code>; 有JavaScript编程经验的同学也可以类比: <code>fn.apply(obj, args) && fn.call(obj, arg1, arg2);</code> <img src="assets/te9bw.jpg" alt="1e17af1a-6b6b-4992-a75c-9eac959bc467.jpg" /></p>
|
||||
<p>再次提醒: 有过反射编程经验的同学可能比较容易理解: <code>Method#invoke(Object obj, Object... args)</code>; 有JavaScript编程经验的同学也可以类比: <code>fn.apply(obj, args) && fn.call(obj, arg1, arg2);</code> <img src="assets/te9bw.jpg" alt="png" /></p>
|
||||
</blockquote>
|
||||
<p>理解这些字节码的诀窍在于:</p>
|
||||
<p>给局部变量赋值时,需要使用相应的指令来进行 <code>store</code>,如 <code>astore_1</code>。<code>store</code> 类的指令都会删除栈顶值。 相应的 <code>load</code> 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。</p>
|
||||
@ -748,11 +748,11 @@ javap -c -verbose demo/jvm0104/ForLoopTest
|
||||
<p>Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(<code>int</code>, <code>long</code>, <code>double</code>, <code>float</code>),都有加,减,乘,除,取反的指令。</p>
|
||||
<p>那么 <code>byte</code> 和 <code>char</code>, <code>boolean</code> 呢? JVM 是当做 <code>int</code> 来处理的。另外还有部分指令用于数据类型之间的转换。</p>
|
||||
<blockquote>
|
||||
<p>算术操作码和类型 <img src="assets/58uua.jpg" alt="30666bbb-50a0-4114-9675-b0626fd0167b.jpg" /></p>
|
||||
<p>算术操作码和类型 <img src="assets/58uua.jpg" alt="png" /></p>
|
||||
</blockquote>
|
||||
<p>当我们想将 <code>int</code> 类型的值赋值给 <code>long</code> 类型的变量时,就会发生类型转换。</p>
|
||||
<blockquote>
|
||||
<p>类型转换操作码 <img src="assets/yzjfe.jpg" alt="e8c82cb5-6e86-4d52-90cc-40cde0fabaa0.jpg" /></p>
|
||||
<p>类型转换操作码 <img src="assets/yzjfe.jpg" alt="png" /></p>
|
||||
</blockquote>
|
||||
<p>在前面的示例中, 将 <code>int</code> 值作为参数传递给实际上接收 <code>double</code> 的 <code>submit()</code> 方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码:</p>
|
||||
<pre><code> 31: iload 5
|
||||
|
@ -219,7 +219,7 @@ function hide_canvas() {
|
||||
<p>按照 Java 语言规范和 Java 虚拟机规范的定义, 我们用 “<code>类加载</code>(Class Loading)” 来表示: 将 class/interface 名称映射为 Class 对象的一整个过程。 这个过程还可以划分为更具体的阶段: 加载,链接和初始化(loading, linking and initializing)。</p>
|
||||
<p>那么加载 class 的过程中到底发生了些什么呢?我们来详细看看。</p>
|
||||
<h3>5.1 类的生命周期和加载过程</h3>
|
||||
<p><img src="assets/6zd6i.png" alt="3de64ff2-77de-4468-af3a-c61bbb8cd944.png" /></p>
|
||||
<p><img src="assets/6zd6i.png" alt="png" /></p>
|
||||
<p>一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。</p>
|
||||
<p>其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就分别来说一下这五个过程。</p>
|
||||
<p>1)<strong>加载</strong> 加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的“<code>class 文件</code>”。 如果找不到二进制表示形式,则会抛出 <code>NoClassDefFound</code> 错误。</p>
|
||||
@ -284,14 +284,14 @@ function hide_canvas() {
|
||||
<li>应用类加载器(AppClassLoader)</li>
|
||||
</ul>
|
||||
<p>一般启动类加载器是由 JVM 内部实现的,在 Java 的 API 里无法拿到,但是我们可以侧面看到和影响它(后面的内容会演示)。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在中<code>sun.misc.Launcher</code>定义的,扩展类加载器和应用类加载器一般都继承自<code>URLClassLoader</code>类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。</p>
|
||||
<p><img src="assets/esz0u.png" alt="c32f4986-0e72-4268-a90a-7451e1931161.png" /></p>
|
||||
<p><img src="assets/esz0u.png" alt="png" /></p>
|
||||
<ol>
|
||||
<li>启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class)。它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 <code>null</code>。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。</li>
|
||||
<li>扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。</li>
|
||||
<li>应用类加载器(app class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。</li>
|
||||
</ol>
|
||||
<p>此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。</p>
|
||||
<p><img src="assets/csrk7.png" alt="8a806e88-cd41-4a28-b552-76efb0a1fdba.png" /></p>
|
||||
<p><img src="assets/csrk7.png" alt="png" /></p>
|
||||
<p>类加载机制有三个特点:</p>
|
||||
<ol>
|
||||
<li>双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。</li>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user