diff --git a/专栏/22 讲通关 Go 语言-完/01 基础入门:编写你的第一个 Go 语言程序.md.html b/专栏/22 讲通关 Go 语言-完/01 基础入门:编写你的第一个 Go 语言程序.md.html index 122060d3..c2c8844a 100644 --- a/专栏/22 讲通关 Go 语言-完/01 基础入门:编写你的第一个 Go 语言程序.md.html +++ b/专栏/22 讲通关 Go 语言-完/01 基础入门:编写你的第一个 Go 语言程序.md.html @@ -195,7 +195,7 @@ Hello, 世界
要想搭建 Go 语言开发环境,需要先下载 Go 语言开发包。你可以从官网 https://golang.org/dl/ 和 https://golang.google.cn/dl/ 下载(第一个链接是国外的官网,第二个是国内的官网,如果第一个访问不了,可以从第二个下载)。
下载时可以根据自己的操作系统选择相应的开发包,比如 Window、MacOS 或是 Linux 等,如下图所示:
-MSI 安装的方式比较简单,在 Windows 系统上推荐使用这种方式。现在的操作系统基本上都是 64 位的,所以选择 64 位的 go1.15.windows-amd64.msi 下载即可,如果操作系统是 32 位的,选择 go1.15.windows-386.msi 进行下载。
下载后双击该 MSI 安装文件,按照提示一步步地安装即可。在默认情况下,Go 语言开发工具包会被安装到 c:\Go 目录,你也可以在安装过程中选择自己想要安装的目录。
diff --git a/专栏/22 讲通关 Go 语言-完/04 集合类型:如何正确使用 array、slice 和 map?.md.html b/专栏/22 讲通关 Go 语言-完/04 集合类型:如何正确使用 array、slice 和 map?.md.html index 041b97ef..f2787dd1 100644 --- a/专栏/22 讲通关 Go 语言-完/04 集合类型:如何正确使用 array、slice 和 map?.md.html +++ b/专栏/22 讲通关 Go 语言-完/04 集合类型:如何正确使用 array、slice 和 map?.md.html @@ -188,7 +188,7 @@ fmt.Println("the sum is",sum)array:=[5]string{"a","b","c","d","e"}
数组在内存中都是连续存放的,下面通过一幅图片形象地展示数组在内存中如何存放:
-可以看到,数组的每个元素都是连续存放的,每一个元素都有一个下标(Index)。下标从 0 开始,比如第一个元素 a 对应的下标是 0,第二个元素 b 对应的下标是 1。以此类推,通过 array+[下标] 的方式,我们可以快速地定位元素。
如下面代码所示,运行它,可以看到输出打印的结果是 c,也就是数组 array 的第三个元素:
ch04/main.go
diff --git a/专栏/22 讲通关 Go 语言-完/06 struct 和 interface:结构体与接口都实现了哪些功能?.md.html b/专栏/22 讲通关 Go 语言-完/06 struct 和 interface:结构体与接口都实现了哪些功能?.md.html index cc13b2aa..e84c7755 100644 --- a/专栏/22 讲通关 Go 语言-完/06 struct 和 interface:结构体与接口都实现了哪些功能?.md.html +++ b/专栏/22 讲通关 Go 语言-完/06 struct 和 interface:结构体与接口都实现了哪些功能?.md.html @@ -348,7 +348,7 @@ type address struct {意思就是类型 person 没有实现 Stringer 接口。这就证明了以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。
我用如下表格为你总结这两种接收者类型的接口实现规则:
-可以这样解读:
小提示:interface{} 是空接口的意思,在 Go 语言中代表任意类型。
panic 异常是一种非常严重的情况,会让程序中断运行,使程序崩溃,所以如果是不影响程序运行的错误,不要使用 panic,使用普通错误 error 即可。
-通常情况下,我们不对 panic 异常做任何处理,因为既然它是影响程序运行的异常,就让它直接崩溃即可。但是也的确有一些特例,比如在程序崩溃前做一些资源释放的处理,这时候就需要从 panic 异常中恢复,才能完成处理。
在 Go 语言中,可以通过内置的 recover 函数恢复 panic 异常。因为在程序 panic 异常崩溃的时候,只有被 defer 修饰的函数才能被执行,所以 recover 函数要结合 defer 关键字使用才能生效。
diff --git a/专栏/22 讲通关 Go 语言-完/08 并发基础:Goroutines 和 Channels 的声明与使用.md.html b/专栏/22 讲通关 Go 语言-完/08 并发基础:Goroutines 和 Channels 的声明与使用.md.html index 7a0b4f16..24238799 100644 --- a/专栏/22 讲通关 Go 语言-完/08 并发基础:Goroutines 和 Channels 的声明与使用.md.html +++ b/专栏/22 讲通关 Go 语言-完/08 并发基础:Goroutines 和 Channels 的声明与使用.md.html @@ -195,7 +195,7 @@ First defer讲并发就绕不开线程,不过在介绍线程之前,我先为你介绍什么是进程。
在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄,还有下面要讲的线程等。下面的图片就是我的电脑上运行的进程:
-(电脑运行的进程)
那么线程是什么呢?
cacheCh:=make(chan int,5)
我创建了一个容量为 5 的 channel,内部的元素类型是 int,也就是说这个 channel 内部最多可以存放 5 个类型为 int 的元素,如下图所示:
-(有缓冲 channel)
一个有缓冲 channel 具备以下特点:
从下图 Context 的衍生树可以看到,最顶部的是空 Context,它作为整棵 Context 树的根节点,在 Go 语言中,可以通过 context.Background() 获取一个根节点 Context。
-(四种 Context 的衍生树)
有了根节点 Context 后,这颗 Context 树要怎么生成呢?需要使用 Go 语言提供的四个函数。
示例中增加了两个监控狗,也就是增加了两个协程,这样一个 Context 就同时控制了三个协程,一旦 Context 发出取消信号,这三个协程都会取消退出。
以上示例中的 Context 没有子 Context,如果一个 Context 有子 Context,在该 Context 取消时会发生什么呢?下面通过一幅图说明:
-(Context 取消)
可以看到,当节点 Ctx2 取消时,它的子节点 Ctx4、Ctx5 都会被取消,如果还有子节点的子节点,也会被取消。也就是说根节点为 Ctx2 的所有节点都会被取消,其他节点如 Ctx1、Ctx3 和 Ctx6 则不会。
以上示例中 nameP 指针的类型是 *string,用于指向 string 类型的数据。在 Go 语言中使用类型名称前加 * 的方式,即可表示一个对应的指针类型。比如 int 类型的指针类型是 *int,float64 类型的指针类型是 *float64,自定义结构体 A 的指针类型是 *A。总之,指针类型就是在对应的类型前加 * 号。
下面我通过一个图让你更好地理解普通类型变量、指针类型变量、内存地址、内存等之间的关系。
-(指针变量、内存地址指向示意图)
上图就是我刚举的例子所对应的示意图,从图中可以看到普通变量 name 的值“飞雪无情”被放到内存地址为 0xc000010200 的内存块中。指针类型变量也是变量,它也需要一块内存用来存储值,这块内存对应的地址就是 0xc00000e028,存储的值是 0xc000010200。相信你已经看到关键点了,指针变量 nameP 的值正好是普通变量 name 的内存地址,所以就建立指向关系。
@@ -272,7 +272,7 @@ func modifyAge(age *int) {- 可以修改指向数据的值;
- 在变量赋值,参数传值的时候可以节省内存。
不过 Go 语言作为一种高级语言,在指针的使用上还是比较克制的。它在设计的时候就对指针进行了诸多限制,比如指针不能进行运行,也不能获取常量的指针。所以在思考是否使用时,我们也要保持克制的心态。
我根据实战经验总结了以下几点使用指针的建议,供你参考:
在 Go 语言中,定义变量要么通过声明、要么通过 make 和 new 函数,不一样的是 make 和 new 函数属于显式声明并初始化。如果我们声明的变量没有显式声明初始化,那么该变量的默认值就是对应类型的零值。
从下面的表格可以看到,可以称为引用类型的零值都是 nil。
-(各种类型的零值)
在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map、chan 等),那么就可以在函数中修改原始数据。
-所以我们在创建一个函数的时候,要根据自己的真实需求决定参数的类型,以便更好地服务于我们的业务。
这节课中,我讲解 chan 的时候没有举例,你自己可以自定义一个有 chan 参数的函数,作为练习题。
下节课我将介绍“内存分配:new 还是 make?什么情况下该用谁?”记得来听课!
diff --git a/专栏/22 讲通关 Go 语言-完/18 质量保证:Go 语言如何通过测试保证质量?.md.html b/专栏/22 讲通关 Go 语言-完/18 质量保证:Go 语言如何通过测试保证质量?.md.html index 1619af50..3aaea1ea 100644 --- a/专栏/22 讲通关 Go 语言-完/18 质量保证:Go 语言如何通过测试保证质量?.md.html +++ b/专栏/22 讲通关 Go 语言-完/18 质量保证:Go 语言如何通过测试保证质量?.md.html @@ -270,7 +270,7 @@ ok gotour/ch18 0.367s coverage: 85.7% of statements➜ go tool cover -html=ch18.cover -o=ch18.html
命令运行后,会在当前目录下生成一个 ch18.html 文件,使用浏览器打开它,可以看到图中的内容:
-单元测试覆盖率报告
红色标记的部分是没有测试到的,绿色标记的部分是已经测试到的。这就是单元测试覆盖率报告的好处,通过它你可以很容易地检测自己写的单元测试是否完全覆盖。
根据报告,我再修改一下单元测试,把没有覆盖的代码逻辑覆盖到,代码如下:
diff --git a/专栏/22 讲通关 Go 语言-完/22 网络编程:Go 语言如何通过 RPC 实现跨平台服务?.md.html b/专栏/22 讲通关 Go 语言-完/22 网络编程:Go 语言如何通过 RPC 实现跨平台服务?.md.html index 2b640a9a..e2575857 100644 --- a/专栏/22 讲通关 Go 语言-完/22 网络编程:Go 语言如何通过 RPC 实现跨平台服务?.md.html +++ b/专栏/22 讲通关 Go 语言-完/22 网络编程:Go 语言如何通过 RPC 实现跨平台服务?.md.html @@ -361,7 +361,7 @@ func main() {从以上代码可以看到,只需要把建立链接的方法从 Dial 换成 DialHTTP 即可。
现在分别运行服务端和客户端代码,就可以看到输出的结果了,和上面使用TCP 链接时是一样的。
此外,Go 语言 net/rpc 包提供的 HTTP 协议的 RPC 还有一个调试的 URL,运行服务端代码后,在浏览器中输入 http://localhost:1234/debug/rpc 回车,即可看到服务端注册的RPC 服务,以及每个服务的方法,如下图所示:
-如上图所示,注册的 RPC 服务、方法的签名、已经被调用的次数都可以看到。
以上我实现的RPC 服务是基于 gob 编码的,这种编码在跨语言调用的时候比较困难,而当前在微服务架构中,RPC 服务的实现者和调用者都可能是不同的编程语言,因此我们实现的 RPC 服务要支持多语言的调用。
diff --git a/专栏/24讲吃透分布式数据库-完/00 开篇词 吃透分布式数据库,提升职场竞争力.md.html b/专栏/24讲吃透分布式数据库-完/00 开篇词 吃透分布式数据库,提升职场竞争力.md.html index 7b80ee04..77f91783 100644 --- a/专栏/24讲吃透分布式数据库-完/00 开篇词 吃透分布式数据库,提升职场竞争力.md.html +++ b/专栏/24讲吃透分布式数据库-完/00 开篇词 吃透分布式数据库,提升职场竞争力.md.html @@ -201,7 +201,7 @@ function hide_canvas() {你好,我是高洪涛,前华为云技术专家、前当当网系统架构师和 Oracle DBA,也是 Apache ShardingSphere PMC 成员。作为创始团队核心成员,我深度参与的 Apache ShardingShpere 目前已经服务于国内外上百家企业,并得到了业界广泛的认可。
我在分布式数据库设计与研发领域工作近 5 年,也经常参与和组织一些行业会议,比如中国数据库大会、Oracle 嘉年华等,与业界人士交流分布式数据库领域的最新动向和发展趋势。
近十年来,整个行业都在争先恐后地进入这个领域,从而大大加速了技术进步。特别是近五年,云厂商相继发布重量级分布式数据库产品,普通用户接触这门技术的门槛降低了,越来越多人正在参与其中,整个领域生态呈现出“百花齐放”的态势。
-2021 年数据大会上,阿里云发布了分布式数据库使用率统计图
但在生产实践过程中我们会发现,许多技术人员对分布式数据库还停留在一知半解的状态,比如下面这些疑问:
@@ -244,7 +244,7 @@ function hide_canvas() {本课程的设计目标是,尽最大程度解决你的实际问题,让你在不同的工程实践中,对分布式场景下的数据库存储有更加专业的认知,并对技术趋势建立深入的洞察。
diff --git a/专栏/24讲吃透分布式数据库-完/01 导论:什么是分布式数据库?聊聊它的前世今生.md.html b/专栏/24讲吃透分布式数据库-完/01 导论:什么是分布式数据库?聊聊它的前世今生.md.html index 4220bc89..4b4b5d46 100644 --- a/专栏/24讲吃透分布式数据库-完/01 导论:什么是分布式数据库?聊聊它的前世今生.md.html +++ b/专栏/24讲吃透分布式数据库-完/01 导论:什么是分布式数据库?聊聊它的前世今生.md.html @@ -211,7 +211,7 @@ function hide_canvas() {分布式数据库,从名字上可以拆解为:分布式+数据库。用一句话总结为:由多个独立实体组成,并且彼此通过网络进行互联的数据库。
理解新概念最好的方式就是通过已经掌握的知识来学习,下表对比了大家熟悉的分布式数据库与集中式数据库之间主要的 5 个差异点。
-从表中,我们可以总结出分布式数据库的核心——数据分片、数据同步。
该特性是分布式数据库的技术创新。它可以突破中心化数据库单机的容量限制,从而将数据分散到多节点,以更灵活、高效的方式来处理数据。这是分布式理论带给数据库的一份礼物。
@@ -226,7 +226,7 @@ function hide_canvas() {当然分布式数据库还有其他特点,但把握住以上两点,已经足够我们理解它了。下面我将从这两个特性出发,探求技术史上分布式数据库的发展脉络。我会以互联网、云计算等较新的时间节点来进行断代划分,毕竟我们的核心还是着眼现在、面向未来。
互联网浪潮之前的数据库,特别是前大数据时代。谈到分布式数据库绕不开的就是 Oracle RAC。
-Oracle RAC 是典型的大型商业解决方案,且为软硬件一体化解决方案。我在早年入职国内顶级电信行业解决方案公司的时候,就被其强大的性能所震撼,又为它高昂的价格所深深折服。它是那个时代数据库性能的标杆和极限,是完美方案与商业成就的体现。
我们试着用上面谈到的两个特性来简单分析一下 RAC:它确实是做到了数据分片与同步。每一层都是离散化的,特别在底层存储使用了 ASM 镜像存储技术,使其看起来像一块完整的大磁盘。
这样做的好处是实现了极致的使用体验,即使用单例数据库与 RAC 集群数据库,在使用上没有明显的区别。它的分布式存储层提供了完整的磁盘功能,使其对应用透明,从而达到扩展性与其他性能之间的平衡。甚至在应对特定规模的数据下,其经济性又有不错的表现。
@@ -234,17 +234,17 @@ function hide_canvas() {该规模在当时的环境下是完全够用的,但是随着互联网的崛起,一场轰轰烈烈的“运动”将会打破 Oracle RAC 的不败金身。
我们知道 Oracle、DB2 等商业数据库均为 OLTP 与 OLAP 融合数据库。而首先在分布式道路上寻求突破的是 OLAP 领域。在 2000 年伊始,以 Hadoop 为代表的大数据库技术凭借其“无共享”(share nothing)的技术体系,开始向以 Oracle 为代表的关系型数据库发起冲击。
-这是一次水平扩展与垂直扩展,通用经济设备与专用昂贵服务,开源与商业这几组概念的首次大规模碰撞。拉开了真正意义上分布式数据库的帷幕。
当然从一般的观点出发,Hadoop 一类的大数据处理平台不应称为数据库。但是从前面我们归纳的两点特性看,它们又确实非常满足。因此我们可以将它们归纳为早期面向商业分析场景的分布式数据库。从此 OLAP 型数据库开始了自己独立演化的道路。
除了 Hadoop,另一种被称为 MPP(大规模并行处理)类型的数据库在此段时间也经历了高速的发展。MPP 数据库的架构图如下:
-我们可以看到这种数据库与大数据常用的 Hadoop 在架构层面上非常类似,但理念不同。简而言之,它是对 SMP(对称多处理器结构)、NUMA(非一致性存储访问结构)这类硬件体系的创新,采用 shared-nothing 架构,通过网络将多个 SMP 节点互联,使它们协同工作。
MPP 数据库的特点是首先支持 PB 级的数据处理,同时支持比较丰富的 SQL 分析查询语句。同时,该领域是商业产品的战场,其中不仅仅包含独立厂商,如 Teradata,还包含一些巨头玩家,如 HP 的 Vertica、EMC 的 Greenplum 等。
大数据技术的发展使 OLAP 分析型数据库,从原来的关系型数据库之中独立出来,形成了完整的发展分支路径。而随着互联网浪潮的发展,OLTP 领域迎来了发展的机遇。
国内数据库领域进入互联网时代第一个重大事件就是“去 IOE”。
-其中尤以“去 Oracle 数据库”产生的影响深远。十年前,阿里巴巴喊出的这个口号深深影响了国内数据库领域,这里我们不去探讨其中细节,也不去评价它正面或负面的影响。但从对于分布式数据库的影响来说,它至少带来两种观念的转变。
首先,由于云服务天生的“超卖”特性,造成其采购成本较低,从而使终端用户尝试分布式数据库的门槛大大降低。
其次,来自云服务厂商的支撑人员可以与用户可以进行深度的合作,形成了高效的反馈机制。这种反馈机制促使云原生的分布式数据库有机会进行快速的迭代,从而可以积极响应客户的需求。
这就是云原生带给分布式数据库的变化,它是通过生态系统的优化完成了对传统商业数据库的超越。以下来自 DB-Engines 的分析数据说明了未来的数据库市场属于分布式数据库,属于云原生数据库。
-随着分布式数据库的发展,我们又迎来了新的一次融合:那就是 OLTP 与 OLAP 将再一次合并为 HTAP(融合交易分析处理)数据库。
该趋势的产生主要来源于云原生 OLTP 型分布式数据库的日趋成熟。同时由于整个行业的发展,客户与厂商对于实时分析型数据库的需求越来越旺盛,但传统上大数据技术包括开源与 MPP 类数据库,强调的是离线分析。
如果要进行秒级的数据处理,那么必须将交易数据与分析数据尽可能地贴近,并减少非实时 ELT 的引入,这就促使了 OLTP 与 OLAP 融合为 HTAP。下图就是阿里云 PolarDB 的 HTAP 架构。
-用《三国演义》的第一句话来说:“天下大势,分久必合,合久必分。”而我们观察到的分布式数据库,乃至数据库本身的发展正暗合了这句话。
分布式数据库发展就是一个由合到分,再到合的过程:
diff --git a/专栏/24讲吃透分布式数据库-完/02 SQL vs NoSQL:一次搞清楚五花八门的“SQL”.md.html b/专栏/24讲吃透分布式数据库-完/02 SQL vs NoSQL:一次搞清楚五花八门的“SQL”.md.html index 842e74b6..8e8e1fbf 100644 --- a/专栏/24讲吃透分布式数据库-完/02 SQL vs NoSQL:一次搞清楚五花八门的“SQL”.md.html +++ b/专栏/24讲吃透分布式数据库-完/02 SQL vs NoSQL:一次搞清楚五花八门的“SQL”.md.html @@ -221,7 +221,7 @@ function hide_canvas() {NoSQL 数据库因具有庞大的数据存储需求,常被用于大数据和 C 端互联网应用。例如,Twitter、Facebook、阿里和腾讯这样的公司,每天都利用其收集几十甚至上百 TB 的用户数据。
那么 NoSQL 数据库与 SQL 数据库的区别表现在哪呢?如下表所示。
表 NoSQL 数据库与 SQL 数据库的区别
-NoSQL 除了不是 SQL 外,另外一个广泛的解释是 Not Only SQL。其背后暗含:我们没有 SQL,但是有一项比 SQL 要吸引人的东西,那就是——分布式。
在 NoSQL 出现之前的商业数据库,多节点部署的难度很大且费用高昂,甚至需要使用专用的硬件。虽然理论上规模应该足够大,但其实不然。而后出现的 NoSQL,大部分在设计层面天然考虑了使用廉价硬件进行系统扩容,同时由于其放弃了 ACID,性能才没有随着系统规模的扩大而衰减。
当然 NoSQL 的缺点也比较明显:由于缺乏 ACID,应用时需要非常小心地处理数据一致性问题;同时由于其数据模型往往只针对特定场景,一般不能使用一种 NoSQL 数据库来完成整个应用的构建,导致设计层面的复杂和维护的困难。
diff --git a/专栏/24讲吃透分布式数据库-完/03 数据分片:如何存储超大规模的数据?.md.html b/专栏/24讲吃透分布式数据库-完/03 数据分片:如何存储超大规模的数据?.md.html index 43ef6e95..999e8245 100644 --- a/专栏/24讲吃透分布式数据库-完/03 数据分片:如何存储超大规模的数据?.md.html +++ b/专栏/24讲吃透分布式数据库-完/03 数据分片:如何存储超大规模的数据?.md.html @@ -210,7 +210,7 @@ function hide_canvas() {如下图所示,水平和垂直这两个概念来自原关系型数据库表模式的可视化直观视图。
-图 1 可视化直观视图
分片理念其实来源于经济学的边际收益理论:如果投资持续增加,但收益的增幅开始下降时,被称为边际收益递减状态。而刚好要开始下降的那个点被称为边际平衡点。
该理论应用在数据库计算能力上往往被表述为:如果数据库处理能力遇到瓶颈,最简单的方式是持续提高系统性能,如更换更强劲的 CPU、更大内存等,这种模式被称为垂直扩展。当持续增加资源以提升数据库能力时,垂直扩展有其自身的限制,最终达到边际平衡,收益开始递减。
@@ -224,11 +224,11 @@ function hide_canvas() {哈希分片,首先需要获取分片键,然后根据特定的哈希算法计算它的哈希值,最后使用哈希值确定数据应被放置在哪个分片中。数据库一般对所有数据使用统一的哈希算法(例如 ketama),以促成哈希函数在服务器之间均匀地分配数据,从而降低了数据不均衡所带来的热点风险。通过这种方法,数据不太可能放在同一分片上,从而使数据被随机分散开。
这种算法非常适合随机读写的场景,能够很好地分散系统负载,但弊端是不利于范围扫描查询操作。下图是这一算法的工作原理。
-图 2 哈希分片
范围分片根据数据值或键空间的范围对数据进行划分,相邻的分片键更有可能落入相同的分片上。每行数据不像哈希分片那样需要进行转换,实际上它们只是简单地被分类到不同的分片上。下图是范围分片的工作原理。
-图 3 范围分片
范围分片需要选择合适的分片键,这些分片键需要尽量不包含重复数值,也就是其候选数值尽可能地离散。同时数据不要单调递增或递减,否则,数据不能很好地在集群中离散,从而造成热点。
范围分片非常适合进行范围查找,但是其随机读写性能偏弱。
@@ -261,7 +261,7 @@ function hide_canvas() {ShardingShpere 首先提供了分布式的主键生成,这是生成分片键的关键。由于分布式数据库内一般由多个数据库节点参与,因此基于数据库实例的主键生成并不适合分布式场景。
常用的算法有 UUID 和 Snowfalke 两种无状态生成算法。
UUID 是最简单的方式,但是生成效率不高,且数据离散度一般。因此目前生产环境中会采用后一种算法。下图就是用该算法生成的分片键的结构。
-图 4 分片键结构
其中有效部分有三个。
用户通过以上多种分片工具,可以灵活和统一地制定数据库分片策略。
ShardingShpere 提供了 Sharding-Scale 来支持数据库节点弹性伸缩,该功能就是其对自动分片的支持。下图是自动分片功能展示图,可以看到经过 Sharding-Scale 的特性伸缩,原有的两个数据库扩充为三个。
-图 5 自动分片功能展示
自动分片包含下图所示的四个过程。
-图 6 自动分片过程
从图 6 中可以看到,通过该工作量,ShardingShpere 可以支持复杂的基于哈希的自动分片。同时我们也应该看到,没有专业和自动化的弹性扩缩容工具,想要实现自动化分片是非常困难的。
以上就是分片算法的实际案例,使用的是经典的水平分片模式。而目前水平和垂直分片有进一步合并的趋势,下面要介绍的 TiDB 正代表着这种融合趋势。
TiDB 就是一个垂直与水平分片融合的典型案例,同时该方案也是 HATP 融合方案。
其中水平扩展依赖于底层的 TiKV,如下图所示。
-图 7 TiKV
TiKV 使用范围分片的模式,数据被分配到 Region 组里面。一个分组保持三个副本,这保证了高可用性(相关内容会在“05 | 一致性与 CAP 模型:为什么需要分布式一致性?”中详细介绍)。当 Region 变大后,会被拆分,新分裂的 Region 也会产生多个副本。
TiDB 的水平扩展依赖于 TiFlash,如下图所示。
-图 8 TiFlash
从图 8 中可以看到 TiFlash 是 TiKV 的列扩展插件,数据异步从 TiKV 里面复制到 TiFlash,而后进行列转换,其中要使用 MVCC 技术来保证数据的一致性。
上文所述的 Region 会增加一个新的异步副本,而后该副本进行了数据切分,并以列模式组合到 TiFlash 中,从而达到了水平和垂直扩展在同一个数据库的融合。这是两种数据库引擎的融合。
diff --git a/专栏/24讲吃透分布式数据库-完/04 数据复制:如何保证数据在分布式场景下的高可用?.md.html b/专栏/24讲吃透分布式数据库-完/04 数据复制:如何保证数据在分布式场景下的高可用?.md.html index 96f38417..7fe32db7 100644 --- a/专栏/24讲吃透分布式数据库-完/04 数据复制:如何保证数据在分布式场景下的高可用?.md.html +++ b/专栏/24讲吃透分布式数据库-完/04 数据复制:如何保证数据在分布式场景下的高可用?.md.html @@ -204,7 +204,7 @@ function hide_canvas() {现在让我们开始学习单主复制,其中不仅介绍了该技术本身,也涉及了一些复制领域的话题,如复制延迟、高可用和复制方式等。
单主复制,也称主从复制。写入主节点的数据都需要复制到从节点,即存储数据库副本的节点。当客户要写入数据库时,他们必须将请求发送给主节点,而后主节点将这些数据转换为复制日志或修改数据流发送给其所有从节点。从使用者的角度来看,从节点都是只读的。下图就是经典的主从复制架构。
-这种模式是最早发展起来的复制模式,不仅被广泛应用在传统数据库中,如 PostgreSQL、MySQL、Oracle、SQL Server;它也被广泛应用在一些分布式数据库中,如 MongoDB、RethinkDB 和 Redis 等。
那么接下来,我们就从复制同步模式、复制延迟、复制与高可用性以及复制方式几个方面来具体说说这个概念。
下面我就从第一代复制技术开始说起。
下图是 MHA 架构图。
-MHA 作为第一代复制架构,有如下适用场景:
下图就是带有 consul 注册中心与监控模块的半同步复制架构图。
-第二代复制技术也有自身的一些缺陷。
这一代复制技术采用的是增强半同步。首先主从的复制都是用独立的线程来运行;其次主库采用 binlog group commit,也就是组提交来提供数据库的写入性能;而从库采用并行复制,它是基于事务的,通过数据参数调整线程数量来提高性能。这样主库可以并行,从库也可以并行。
这一代技术体系强依赖于增强半同步,利用半同步保证 RPO,对于 RTO,则取决于复制延迟。
下面我们用 Xenon 来举例说明,请看下图(图片来自官网)。
-从图中可以看到。每个节点上都有一个独立的 agent,这些 agent 利用 raft 构建一致性集群,利用 GTID 做索引选举主节点;而后主节点对外提供写服务,从节点提供读服务。
当主节点发生故障后,agent 会通过 ping 发现该故障。由于 GTID 和增强半同步的加持,从节点与主节点数据是一致的,因此很容易将从节点提升为主节点。
第三代技术也有自身的缺点,如增强半同步中存在幽灵事务。这是由于数据写入 binlog 后,主库掉电。由于故障恢复流程需要从 binlog 中恢复,那么这份数据就在主库。但是如果它没有被同步到从库,就会造成从库不能切换为主库,只能去尝试恢复原崩溃的主库。
diff --git a/专栏/24讲吃透分布式数据库-完/05 一致性与 CAP 模型:为什么需要分布式一致性?.md.html b/专栏/24讲吃透分布式数据库-完/05 一致性与 CAP 模型:为什么需要分布式一致性?.md.html index 70ba8872..29f09148 100644 --- a/专栏/24讲吃透分布式数据库-完/05 一致性与 CAP 模型:为什么需要分布式一致性?.md.html +++ b/专栏/24讲吃透分布式数据库-完/05 一致性与 CAP 模型:为什么需要分布式一致性?.md.html @@ -223,7 +223,7 @@ function hide_canvas() {CAP 意味着即使所有节点都在运行中,我们也可能会遇到一致性问题,这是因为它们之间存在连接性问题。CAP 理论常常用三角形表示,就好像我们可以任意匹配三个参数一样。然而,尽管我们可以调整可用性和一致性,但分区容忍性是我们无法实际放弃的。
如果我们选择了 CA 而放弃了 P,那么当发生分区现象时,为了保证 C,系统需要禁止写入。也就是,当有写入请求时,系统不可用。这与 A 冲突了,因为 A 要求系统是可用的。因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。
如下图所示,其实 CA 类系统是不存在的,这里你需要特别注意。
-图 1 CAP 理论
CAP 中的可用性也不同于上述的高可用性,CAP 定义对请求的延迟没有任何限制。此外,与 CAP 相反,数据库的高可用性并不需要每个在线节点都可以提供服务。
CAP 里面的 C 代表线性一致,除了它以外,还有其他的一致模式,我们现在来具体介绍一下。
@@ -232,7 +232,7 @@ function hide_canvas() {从用户的角度看,分布式数据库就像具有共享存储的单机数据库一样,节点间的通信和消息传递被隐藏到了数据库内部,这会使用户产生“分布式数据库是一种共享内存”的错觉。一个支持读取和写入操作的单个存储单元通常称为寄存器,我们可以把代表分布式数据库的共享存储看作是一组这样的寄存器。
每个读写寄存器的操作被抽象为“调用”和“完成”两个动作。如果“调用”发生后,但在“完成”之前该操作崩溃了,我们将操作定义为失败。如果一个操作的调用和完成事件都在另一个操作被调用之前发生,我们说这个操作在另一个操作之前,并且这两个操作是顺序的;否则,我们说它们是并发的。
如下图所示,a)是顺序操作,b)和 c)是并发操作。
-图 2 顺序操作&并发操作
多个读取或写入操作可以同时访问一个寄存器。对寄存器的读写操作不是瞬间完成的,需要一些时间,即调用和完成两个动作之间的时间。由不同进程执行的并发读/写操作不是串行的,根据寄存器在操作重叠时的行为,它们的顺序可能不同,并且可能产生不同的结果。
当我们讨论数据库一致性时,可以从两个维度来区别。
@@ -265,7 +265,7 @@ function hide_canvas() {下图正是现象一致性的直观展示。
-图 3 线性一致性
线性一致性的代价是很高昂的,甚至 CPU 都不会使用线性一致性。有并发编程经验的朋友一定知道 CAS 操作,该操作可以实现操作的线性化,是高性能并发编程的关键,它就是通过编程手段来模拟线性一致。
一个比较常见的误区是,使用一致性算法可以实现线性一致,如 Paxos 和 Raft 等。但实际是不行的,以 Raft 为例,算法只是保证了复制 Log 的线性一致,而没有描述 Log 是如何写入最终的状态机的,这就暗含状态机本身不是线性一致的。
@@ -273,10 +273,10 @@ function hide_canvas() {由于线性一致的代价高昂,因此人们想到,既然全局时钟导致严格一致性很难实现,那么顺序一致性就是放弃了全局时钟的约束,改为分布式逻辑时钟实现。顺序一致性是指所有的进程以相同的顺序看到所有的修改。读操作未必能及时得到此前其他进程对同一数据的写更新,但是每个进程读到的该数据的不同值的顺序是一致的。
下图展示了 P1、P2 写入两个值后,P3 和 P4 是如何读取的。以真实的时间衡量,1 应该是在 2 之前被写入,但是在顺序一致性下,1 是可以被排在 2 之后的。同时,尽管 P3 已经读取值 1,P4 仍然可以读取 2。但是需要注意的是这两种组合:1->2 和 2 ->1,P3 和 P4 从它们中选择一个,并保持一致。下图正是展示了它们读取顺序的一种可能:2->1。
-图 4 顺序一致性
我们使用下图来进一步区分线性一致和顺序一致。
-图 5 区分线性一致和顺序一致
其中,图 a 满足了顺序一致性,但是不满足线性一致性。原因在于,从全局时钟的观点来看,P2 进程对变量 x 的读操作在 P1 进程对变量 x 的写操作之后,然而读出来的却是旧的数据。但是这个图却是满足顺序一致性,因为两个进程 P1 和 P2 的一致性并没有冲突。
图 b 满足线性一致性,因为每个读操作都读到了该变量的最新写的结果,同时两个进程看到的操作顺序与全局时钟的顺序一样。
@@ -292,10 +292,10 @@ function hide_canvas() {那么,为什么需要因果关系,以及没有因果关系的写法如何传播?下图中,进程 P1 和 P2 进行的写操作没有因果关系,也就是最终一致性。这些操作的结果可能会在不同时间,以乱序方式传播到读取端。进程 P3 在看到 2 之前将看到值 1,而 P4 将先看到 2,然后看到 1。
-图 6 因果一致性
而下图显示进程 P1 和 P2 进行因果相关的写操作并按其逻辑顺序传播到 P3 和 P4。因果写入除了写入数据外,还需要附加一个逻辑时钟,用这个时钟保证两个写入是有因果关系的。这可以防止我们遇到上面那张图所示的情况。你可以在两个图中比较一下 P3 和 P4 的历史记录。
-图 7 逻辑时钟
而实现这个逻辑时钟的一种主要方式就是向量时钟。向量时钟算法利用了向量这种数据结构,将全局各个进程的逻辑时间戳广播给所有进程,每个进程发送事件时都会将当前进程已知的所有进程时间写入到一个向量中,而后进行传播。
因果一致性典型案例就是 COPS 系统,它是基于 causal+一致性模型的 KV 数据库。它定义了 dependencies,操作了实现因果一致性。这对业务实现分布式数据因果关系很有帮助。另外在亚马逊 Dynamo 基于向量时钟,也实现了因果一致性。
@@ -308,7 +308,7 @@ function hide_canvas() {那么它们之间的联系如何呢?其实就是事务的隔离性与一致模型有关联。
如果把上面线性一致的例子看作多个并行事务,你会发现它们是没有隔离性的。因为在开始和完成之间任意一点都会读取到这份数据,原因是一致性模型关心的是单一操作,而事务是由一组操作组成的。
现在我们看另外一个例子,这是展示事务缺乏一致性后所导致的问题。
-图 8 事务与一致性
其中三个事务满足隔离性。可以看到 T2 读取到了 T1 入的值。但是这个系统缺乏一致性保障,造成 T3 可以读取到早于 T2 读取值之前的值,这就会造成应用的潜在 Bug。
那现在给出结论:事务隔离是描述并行事务之间的行为,而一致性是描述非并行事务之间的行为。其实广义的事务隔离应该是经典隔离理论与一致性模型的一种混合。
diff --git a/专栏/24讲吃透分布式数据库-完/06 实践:设计一个最简单的分布式数据库.md.html b/专栏/24讲吃透分布式数据库-完/06 实践:设计一个最简单的分布式数据库.md.html index cd6026f1..50c4439a 100644 --- a/专栏/24讲吃透分布式数据库-完/06 实践:设计一个最简单的分布式数据库.md.html +++ b/专栏/24讲吃透分布式数据库-完/06 实践:设计一个最简单的分布式数据库.md.html @@ -225,7 +225,7 @@ function hide_canvas() {以上两点互相作用,从而使现在很多组织和技术团队都开始去构建属于自己的分布式数据库。
熟悉我的朋友可能知道,我另外一个身份是 Apache SkyWalking 的创始成员,它是一个开源的 APM 系统。其架构图可以在官网找到,如下所示。
-可以看到其中的 Storage Option,也就是数据库层面可以有多种选择。除了单机内存版本的 H2 以外,其余生产级别的数据库均为分布式数据库。
选择多一方面证明了 SkyWalking 有很强的适应能力,但更重要的是目前业界没有一款数据库可以很好地满足其使用场景。
那么现在我们来尝试给它设计一个数据库。这里我简化了设计流程,只给出了需求分析与概念设计,目的是展示设计方式,帮助你更好地体会分布式数据库的关键点。
diff --git a/专栏/24讲吃透分布式数据库-完/08 分布式索引:如何在集群中快速定位数据?.md.html b/专栏/24讲吃透分布式数据库-完/08 分布式索引:如何在集群中快速定位数据?.md.html index 940ba267..1514f22e 100644 --- a/专栏/24讲吃透分布式数据库-完/08 分布式索引:如何在集群中快速定位数据?.md.html +++ b/专栏/24讲吃透分布式数据库-完/08 分布式索引:如何在集群中快速定位数据?.md.html @@ -255,7 +255,7 @@ function hide_canvas() {目前有很多种不同的数据结构可以在内存中存储有序的数据。在分布式数据库的存储引擎中,有一种结构因其简单而被广泛地使用,那就是跳表(SkipList)。
跳表的优势在于其实现难度比简单的链表高不了多少,但是其时间复杂度可以接近负载平衡的搜索树结构。
跳表在插入和更新时避免对节点做旋转或替换,而是使用了随机平衡的概念来使整个表平衡。跳表由一系列节点组成,它们又由不同的高度组成。连续访问高度较高的节点可以跳过高度较低的节点,有点像蜘蛛侠利用高楼在城市内快速移动一样,这也就是跳表名称的来源。现在我们用一个例子来说明跳表的算法细节。请看下面的图片。
-如果我们以寻找 15 为例来说明跳表的查找顺序。
可以看到双树操作是比较简单明了的,而且可以作为一种 B 树类的索引结构而存在。但实际上几乎没有存储引擎去使用它,主要原因是它的合并操作是同步的,也就是刷盘的时候要同步进行合并。而刷盘本身是个相对频繁的操作,这样会造成写放大,也就是会影响写入效率且会占用非常大的磁盘空间。
多树结构是在双树的基础上提出的,内存数据刷盘时不进行合并操作,而是完全把内存数据写入到单独的文件中。那这个时候另外的问题就出现了:随着刷盘的持续进行,磁盘上的文件会快速增加。这时,读取操作就需要在很多文件中去寻找记录,这样读取数据的效率会直线下降。
为了解决这个问题,此种结构会引入合并操作(Compaction)。该操作是异步执行的,它从这众多文件中选择一部分出来,读取里面的内容而后进行合并,最后写入一个新文件中,而后老文件就被删除掉了。如下图所示,这就是典型的多树结构合并操作。而这种结构也是本讲介绍的主要结构。
-最后,我再为你详细介绍一下刷盘的流程。
首先定义几种角色,如下表所示。
-数据首先写入当前内存表,当数据量到达阈值后,当前数据表把自身状态转换为刷盘中,并停止接受写入请求。此时会新建另一个内存表来接受写请求。刷盘完成后,由于数据在磁盘上,除了废弃内存表的数据外,还对提交日志进行截取操作。而后将新数据表设置为可以读取状态。
在合并操作开始时,将被合并的表设置为合并中状态,此时它们还可以接受读取操作。完成合并后,原表作废,新表开始启用提供读取服务。
以上就是经典的 LSM 树的结构和一些操作细节。下面我们开始介绍如何对其进行查询、更新和删除等操作。
@@ -236,14 +236,14 @@ function hide_canvas() {常见的合并策略有 Size-Tiered Compaction 和 Leveled Compaction。
下图就是这种策略的合并过程。
-其中,数据表按照大小进行合并,较小的数据表逐步合并为较大的数据表。第一层保存的是系统内最小的数据表,它们是刚刚从内存表中刷新出来的。合并过程就是将低层较小的数据表合并为高层较大的数据表的过程。Apache Cassandra 使用过这种合并策略。
该策略的优点是比较简单,容易实现。但是它的空间放大性很差,合并时层级越高该问题越严重。比如有两个 5GB 的文件需要合并,那么磁盘至少要保留 10GB 的空间来完成这次操作,可想而知此种容量压力是巨大的,必然会造成系统不稳定。
那么有没有什么策略能缓解空间放大呢?答案就是 Leveled Compaction。
如名称所示,该策略是将数据表进行分层,按照编号排成 L0 到 Ln 这样的多层结构。L0 层是从内存表刷盘产生的数据表,该层数据表中间的 key 是可以相交的;L1 层及以上的数据,将 Size-Tiered Compaction 中原本的大数据表拆开,成为多个 key 互不相交的小数据表,每层都有一个最大数据量阈值,当到达该值时,就出发合并操作。每层的阈值是按照指数排布的,例如 RocksDB 文档中介绍了一种排布:L1 是 300MB、L2 是 3GB、L3 是 30GB、L4 为 300GB。
该策略如下图所示。
-上图概要性地展示了从 L1 层开始,每个小数据表的容量都是相同的,且数据量阈值是按 10 倍增长。即 L1 最多可以有 10 个数据表,L2 最多可以有 100 个,以此类推。
随着数据表不断写入,L1 的数据量会超过阈值。这时就会选择 L1 中的至少一个数据表,将其数据合并到 L2 层与其 key 有交集的那些文件中,并从 L1 中删除这些数据。
仍然以上图为例,一个 L1 层数据表的 key 区间大致能够对应到 10 个 L2 层的数据表,所以一次合并会影响 11 个文件。该次合并完成后,L2 的数据量又有可能超过阈值,进而触发 L2 到 L3 的合并,如此往复。
diff --git a/专栏/24讲吃透分布式数据库-完/13 概要:分布式系统都要解决哪些问题?.md.html b/专栏/24讲吃透分布式数据库-完/13 概要:分布式系统都要解决哪些问题?.md.html index b23f9024..f352646b 100644 --- a/专栏/24讲吃透分布式数据库-完/13 概要:分布式系统都要解决哪些问题?.md.html +++ b/专栏/24讲吃透分布式数据库-完/13 概要:分布式系统都要解决哪些问题?.md.html @@ -262,7 +262,7 @@ function hide_canvas() {这一讲是模块三的引导课,我首先为你介绍了失败模型的概念,它是描述分布式数据库内各种可能行为的一个准则;而后根据失败模型为你梳理了本模块的讲解思路。
分布式算法根据目标不同可能分为下面几种行为模式,这些模式与对应的课时如下表所示。
-可以看到这两种方法虽然实现细节不同,但都包含了一个所谓“规定时间”的概念,那就是超时机制。我们现在以第一种模式来详细介绍这种算法,请看下面这张图片。
-图 1 模拟两个连续心跳访问
上面的图模拟了两个连续心跳访问,节点 1 发送 ping 包,在规定的时间内节点 2 返回了 pong 包。从而节点 1 判断节点 2 是存活的。但在现实场景中经常会发生图 2 所示的情况。
-图 2 现实场景下的心跳访问
可以看到节点 1 发送 ping 后,节点没有在规定时间内返回 pong,此时节点 1 又发送了另外的 ping。此种情况表明,节点 2 存在延迟情况。偶尔的延迟在分布式场景中是极其常见的,故基于超时的心跳检测算法需要设置一个超时总数阈值。当超时次数超过该阈值后,才判断远程节点是离线状态,从而避免偶尔产生的延迟影响算法的准确性。
由上面的描述可知,基于超时的心跳检测法会为了调高算法的准确度,从而牺牲算法的效率。那有没有什么办法能改善算法的效率呢?下面我就要介绍一种不基于超时的心跳检测算法。
不基于超时的心跳检测算法是基于异步系统理论的。它保存一个全局节点的心跳列表,上面记录了每一个节点的心跳状态,从而可以直观地看到系统中节点的健康度。由此可知,该算法除了可以提高检测的效率外,还可以非常容易地获得所有节点的健康状态。那么这个全局列表是如何生成的呢?下图展示了该列表在节点之间的流转过程。
-图 3 全局列表在节点之间的流转过程
由图可知,该算法需要生成一个节点间的主要路径,该路径就是数据流在节点间最常经过的一条路径,该路径同时要包含集群内的所有节点。如上图所示,这条路径就是从节点 1 经过节点 2,最后到达节点 3。
算法开始的时候,节点首先将自己记录到表格中,然后将表格发送给节点 2;节点 2 首先将表格中的节点 1 的计数器加 1,然后将自己记录在表格中,而后发送给节点 3;节点 3 如节点 2 一样,将其中的所有节点计数器加 1,再把自己记录进去。一旦节点 3 发现所有节点全部被记录了,就停止这个表格的传播。
@@ -242,7 +242,7 @@ function hide_canvas() {那么有没有方法能提高对于单一节点的判断呢?现在我就来介绍一种间接的检测方法。
间接检测法可以有效提高算法的稳定性。它是将整个网络进行分组,我们不需要知道网络中所有节点的健康度,而只需要在子网中选取部分节点,它们会告知其相邻节点的健康状态。
-图 4 间接检测法
如图所示,节点 1 无法直接去判断节点 2 是否存活,这个时候它转而询问其相邻节点 3。由节点 3 去询问节点 2 的健康情况,最后将此信息由节点 3 返回给节点 1。
这种算法的好处是不需要将心跳检测进行广播,而是通过有限的网络连接,就可以检测到集群中各个分组内的健康情况,从而得知整个集群的健康情况。此种方法由于使用了组内的多个节点进行检测,其算法的准确度相比于一个节点去检测提高了很多。同时我们可以并行进行检测,算法的收敛速度也是很快的。因此可以说,间接检测法在准确度和效率上取得了比较好的平衡。
diff --git a/专栏/24讲吃透分布式数据库-完/16 再谈一致性:除了 CAP 之外的一致性模型还有哪些?.md.html b/专栏/24讲吃透分布式数据库-完/16 再谈一致性:除了 CAP 之外的一致性模型还有哪些?.md.html index 8b8d43c4..fb5f5b15 100644 --- a/专栏/24讲吃透分布式数据库-完/16 再谈一致性:除了 CAP 之外的一致性模型还有哪些?.md.html +++ b/专栏/24讲吃透分布式数据库-完/16 再谈一致性:除了 CAP 之外的一致性模型还有哪些?.md.html @@ -202,7 +202,7 @@ function hide_canvas() {现在我就和你一起,把一致性模型的知识体系补充完整。
完整的一致性模型如下图所示。
-图中不同的颜色代表了可用性的程度,下面我来具体说说。
由于目前 CRDT 算法仍然处于高速发展的阶段,为了方便你理解,我这里选取携程网内部 Redis 集群一致性方案,它的技术选型相对实用。如果你对 CRDT 有兴趣,可以进一步研究,这里就不对诸如 PN-Counter、G-Set 等做进一步说明了。
由于 Redis 最常用的处理手段是设置字符串数据,故需要使用 CRDT 中的 register 进行处理。携程团队选择了经典的 LWW Regsiter,也就是最后写入胜利的冲突处理方案。
这种方案,最重要的是数据上需要携带时间戳。我们用下图来说明它的流程。
-从图中可以看到,每个节点的数据是一个二元组,分别是 value 和 timestamp。可以看到节点间合并数据是根据 timestamp,也就是谁的 timestamp 大,合并的结果就以哪个值为准。使用 LWW Register 可以保证高并发下合并结果最终一致。
而删除时,就需要另外一种算法了。那就是 Observed-Remove SET(OR Set),其主要的目的是解决一般算法无法删除后重新增加该值的情况。
它相较于 LWW-Register 会复杂一些,除了时间戳以外,还需要给每个值标记一个唯一的 tag。比如上图中 P1 设置(1,3),实际需要设置(1α,3);而后如果删除 1,集合就为空;再添加 1 时,标签就需要与一开始不同,为(1β,5)。这样就保证步骤 2 中的删除操作不会影响步骤 3 中的增加操作。因为它们虽然数值相同,但是标签不同,所以都是唯一的。
diff --git a/专栏/24讲吃透分布式数据库-完/17 数据可靠传播:反熵理论如何帮助数据库可靠工作?.md.html b/专栏/24讲吃透分布式数据库-完/17 数据可靠传播:反熵理论如何帮助数据库可靠工作?.md.html index 4e76201b..ce18f43f 100644 --- a/专栏/24讲吃透分布式数据库-完/17 数据可靠传播:反熵理论如何帮助数据库可靠工作?.md.html +++ b/专栏/24讲吃透分布式数据库-完/17 数据可靠传播:反熵理论如何帮助数据库可靠工作?.md.html @@ -210,7 +210,7 @@ function hide_canvas() {随着熵逐步增加,系统进入越来越混乱的状态。但是如果没有读取操作,这种混乱其实是不会暴露出去的。那么人们就有了一个思路,我们可以在读取操作发生的时候再来修复不一致的数据。
具体操作是,请求由一个总的协调节点来处理,这个协调节点会从一组节点中查询数据,如果这组节点中某些节点有数据缺失,该协调节点就会把缺失的数据发送给这些节点,从而修复这些节点中的数据,达到反熵的目的。
有的同学可能会发现,这个思路与上一讲的可调节一致性有一些关联。因为在可调节一致性下,读取操作为了满足一致性要求,会从多个节点读取数据从而发现最新的数据结果。而读修复会更进一步,在此以后,会将落后节点数据进行同步修复,最后将最新结果发送回客户端。这一过程如下图所示。
-当修复数据时,读修复可以使用阻塞模式与异步模式两种。阻塞模式如上图所示,在修复完成数据后,再将最终结果返还给客户端;而异步模式会启动一个异步任务去修复数据,而不必等待修复完成的结果,即可返回到客户端。
你可以回忆一下,阻塞的读修复模式其实满足了上一讲中客户端一致性提到的读单增。因为一个值被读取后,下一次读取数据一定是基于上一次读取的。也就是说,同步修复的数据可以保证在下一次读取之前就被传播到目标节点;而异步修复就没有如此保证。但是阻塞修复同时丧失了一定的可用性,因为它需要等待远程节点修复数据,而异步修复就没有此问题。
在进行消息比较的时候,我们有一个优化的手段是使用散列来比较数据。比如协调节点收到客户端请求后,只向一个节点发送读取请求,而向其他节点发送散列请求。而后将完全请求的返回值进行散列计算,与其他节点返回的散列值进行比较。如果它们是相等的,就直接返回响应;如果不相等,将进行上文所描述的修复过程。
@@ -218,7 +218,7 @@ function hide_canvas() {以上就是在读取操作中进行的反熵操作,那么在写入阶段我们如何进行修复呢?下面我来介绍暗示切换。
暗示切换名字听起来很玄幻。其实原理非常明了,让我们看看它的过程,如下图所示。
-客户端首先写入协调节点。而后协调节点将数据分发到两个节点中,这个过程与可调节一致性中的写入是类似的。正常情况下,可以保证写入的两个节点数据是一致的。如果其中的一个节点失败了,系统会启动一个新节点来接收失败节点之后的数据,这个结构一般会被实现为一个队列(Queue),即暗示切换队列(HHQ)。
一旦失败的节点恢复了回来,HHQ 会把该节点离线这一个时间段内的数据同步到该节点中,从而修复该节点由于离线而丢失的数据。这就是在写入节点进行反熵的操作。
以上介绍的前台同步操作其实都有一个限制,就是需要假设此种熵增过程发生的概率不高且范围有限。如果熵增大范围产生,那么修复读会造成读取延迟增高,即使使用异步修复也会产生很高的冲突。而暗示切换队列的问题是其容量是有限的,这意味着对于一个长期离线的节点,HHQ 可能无法保存其全部的消息。
@@ -228,7 +228,7 @@ function hide_canvas() {而后台方案与前台方案的关注点是不同的。前台方案重点放在修复数据,而后台方案由于需要比较和处理大量的非活跃数据,故需要重点解决如何使用更少的资源来进行数据比对。我将要为你介绍两种比对技术:Merkle 树和位图版本向量。
如果想要检查数据的差异,我们一般能想到最直观的方式是进行全量比较。但这种思路效率是很低的,在实际生产中不可能实行。而通过 Merkle 树我们可以快速找到两份数据之间的差异,下图就是一棵典型的 Merkle 树。
-树构造的过程是:
最近的研究发现,大部分数据差异还是发生在距离当前时间不远的时间段。那么我们就可以针对此种场景进行优化,从而避免像 Merkle 树那样计算全量的数据。而位图版本向量就是根据这个想法发展起来的。
这种算法利用了位图这一种对内存非常友好的高密度数据格式,将节点近期的数据同步状态记录下来;而后通过比较各个节点间的位图数据,从而发现差异,修复数据。下面我用一个例子为你展示这种算法的执行过程,请看下图。
-如果有三个节点,每个节点包含了一组与其他节点数据同步的向量。上图表示节点 2 的数据同步情况。目前系统中存在 8 条数据,从节点 2 的角度看,每个节点都没有完整的数据。其中深灰色的部分表明同步的数据是连续的,我们用一个压缩的值表示。节点 1 到 3 这个压缩的值分别为 3、5 和 2。可以看到节点 2 自己的数据是连续的。
数据同步一旦出现不连续的情况,也就是出现了空隙,我们就转而使用位图来存储。也就是图中浅灰色和白色的部分。比如节点 2 观察节点 1,可以看到有三个连续的数据同步,而后状态用 00101 来表示(浅灰色代表 1,白色代表 0)。其中 1 是数据同步了,而 0 是数据没有同步。节点 2 可以从节点 1 和节点 3 获取完整的 8 条数据。
这种向量列表除了具有内存优势外,我们还可以很容易发现需要修复数据的目标。但是它的一个明显缺点与暗示切换队列 HHQ 类似,就是存储是有限的,如果数据偏差非常大,向量最终会溢出,从而不能比较数据间的差异。但不要紧,我们可以用上面提到的 Merkle 来进行全量比较。
diff --git a/专栏/24讲吃透分布式数据库-完/18 分布式事务(上):除了 XA,还有哪些原子提交算法吗?.md.html b/专栏/24讲吃透分布式数据库-完/18 分布式事务(上):除了 XA,还有哪些原子提交算法吗?.md.html index 3a3fccbf..bfb81cc3 100644 --- a/专栏/24讲吃透分布式数据库-完/18 分布式事务(上):除了 XA,还有哪些原子提交算法吗?.md.html +++ b/专栏/24讲吃透分布式数据库-完/18 分布式事务(上):除了 XA,还有哪些原子提交算法吗?.md.html @@ -239,15 +239,15 @@ function hide_canvas() {我们现在用一个例子来介绍一下整个过程,请看下图。
-一个账户表中,Bob 有 10 美元,Joe 有 2 美元。我们可以看到 Bob 的记录在 write 字段中最新的数据是 [email protected],它表示当前最新的数据是 ts=5 那个版本的数据,ts=5 版本中的数据是 10 美元,这样读操作就会读到这个 10 美元。同理,Joe 的账号是 2 美元。
-现在我们要做一个转账操作,从 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。
-然后对 Joe 账户加锁,同样是 ts=7,在 Joe 账户的加锁信息中包含了指向 Primary lock 的引用,如此这般处于同一个事务的行就关联起来了。Joe 的数据列写入 9(2+7=9),write 列为空,至此完成 Prewrite 阶段。
-接下来事务就要 Commit 了。Primary Row 首先执行 Commit,只要 Primary Row Commit 成功了,事务就成功了。Secondary Row 失败了也不要紧,后续会有补救措施。Commit 操作首先清除 Primary Row 的锁,然后写入 ts=8 的行(因为时间是单向递增的,这里是 commit_ts),该行可以称为 Commit Row,因为它不包含数据,只是在 write 列中写入 [email protected],标识 ts=7 的数据已经可见了,此刻以后的读操作可以读到版本 ts=7 的数据了。
-接下来就是 commit Secondary Row 了,和 Primary Row 的逻辑是一样的。Secondary Row 成功 commit,事务就完成了。
如果 Primary Row commit 成功,Secondary Row commit 失败会怎么样,数据的一致性如何保障?由于 Percolator 没有中心化的事务管理器组件,处理这种异常,只能在下次读操作发起时进行。如果一个读请求发现要读的数据存在 Secondary 锁,它会根据 Secondary Row 锁去检查其对应的 Primary Row 的锁是不是还存在,若存在说明事务还没有完成;若不存在则说明,Primary Row 已经 Commit 了,它会清除 Secondary Row 的锁,使该行数据变为可见状态(commit)。这是一个 Roll forward 的概念。
我们可以看到,在这样一个存储系统中,并非所有的行都是数据,还包含了一些事务控制行,或者称为 Commit Row。它的数据 Column 为空,但 write 列包含了可见数据的 TS。它的作用是标示事务完成,并指引读请求读到新的数据。随着时间的推移,会产生大量冗余的数据行,无用的数据行会被 GC 线程定时清理。
diff --git a/专栏/24讲吃透分布式数据库-完/19 分布式事务(下):Spanner 与 Calvin 的巅峰对决.md.html b/专栏/24讲吃透分布式数据库-完/19 分布式事务(下):Spanner 与 Calvin 的巅峰对决.md.html index 77d8aa93..38135a57 100644 --- a/专栏/24讲吃透分布式数据库-完/19 分布式事务(下):Spanner 与 Calvin 的巅峰对决.md.html +++ b/专栏/24讲吃透分布式数据库-完/19 分布式事务(下):Spanner 与 Calvin 的巅峰对决.md.html @@ -216,7 +216,7 @@ function hide_canvas() {了解了事务模型后,我们深入其内部,看看 Spanner 的核心组件都有哪些。下面是一张 Spanner 的架构图。
-其中我们看到,每个 replica 保存了多个 tablet;同时这些 replica 组成了 Paxos Group。Paxos Group 选举出一个 leader 用来在多分片事务中与其他 Paxos Group 的 leader 进行协调(有关 Paxos 算法的细节我将在下一讲中介绍)。
写入操作必须通过 leader 来进行,而读取操作可以在任何一个同步完成的 replica 上进行。同时我们看到 leader 中有锁管理器,用来实现并发控制中提到的锁管理。事务管理器用来处理多分片分布式事务。当进行同步写入操作时,必须要获取锁,而快照读取操作是无锁操作。
我们可以看到,最复杂的操作就是多分片的写入操作。其过程就是由 leader 参与的两阶段提交。在准备阶段,提交的数据写入到协调器的 Paxos Group 中,这解决了如下两个问题。
@@ -233,7 +233,7 @@ function hide_canvas() {Spanner 引入了很多新技术去改善分布式事务的性能,但我们发现其流程整体还是传统的二阶段提交,并没有在结构上发生重大的改变,而 Calvin 却充满了颠覆性。让我们来看看它是怎么处理分布式事务的。
首先,传统分布式事务处理使用到了锁来保证并发竞争的事务满足隔离级别的约束。比如,序列化级别保证了事务是一个接一个运行的。而每个副本的执行顺序是无法预测的,但结果是可以预测的。Calvin 的方案是让事务在每个副本上的执行顺序达到一致,那么执行结果也肯定是一致的。这样做的好处是避免了众多事务之间的锁竞争,从而大大提高了高并发度事务的吞吐量。同时,节点崩溃不影响事务的执行。因为事务执行步骤已经分配,节点恢复后从失败处接着运行该事务即可,这种模式使分布式事务的可用性也大大提高。目前实现了 Calvin 事务模式的数据库是 FaunaDB。
其次,将事务进行排序的组件被称为 sequencer。它搜集事务信息,而后将它们拆解为较小的 epoch,这样做的目的是减小锁竞争,并提高并行度。一旦事务被准备好,sequencer 会将它们发送给 scheduler。scheduler 根据 sequencer 处理的结果,适时地并行执行部分事务步骤,同时也保证顺序执行的步骤不会被并行。因为这些步骤已经排好了顺序,scheduler 执行的时候不需要与 sequencer 进行交互,从而提高了执行效率。Calvin 事务的处理组件如下图所示。
-Calvin 也使用了 Paxos 算法,不同于 Spanner 每个分片有一个 Paxos Group。Calvin 使用 Paxos 或者异步复制来决定哪个事务需要进入哪个 epoch 里面。
同时 Calvin 事务有 read set 和 write set 的概念。前者表示事务需要读取的数据,后者表示事务影响的数据。这两个集合需要在事务开始前就进行确定,故Calvin 不支持在事务中查询动态数据而后影响最终结果集的行为。这一点很重要,是这场战争的核心。
在你了解了两种事务模型之后,我就要带你进入“刺激战场”了。在两位实力相当的选手中,Calvin 一派首先挑起了战争。
diff --git a/专栏/24讲吃透分布式数据库-完/21 知识串讲:如何取得性能和可扩展性的平衡?.md.html b/专栏/24讲吃透分布式数据库-完/21 知识串讲:如何取得性能和可扩展性的平衡?.md.html index 1624fbbd..ec14de2e 100644 --- a/专栏/24讲吃透分布式数据库-完/21 知识串讲:如何取得性能和可扩展性的平衡?.md.html +++ b/专栏/24讲吃透分布式数据库-完/21 知识串讲:如何取得性能和可扩展性的平衡?.md.html @@ -213,7 +213,7 @@ function hide_canvas() {在分布式事务那一讲,我提到 TiDB 的乐观事务使用了 Google 的 Percolator 模式,同时 TiDB 也对该模式进行了改进。可以说一提到 Percolator 模式事务的数据库,国内外都绕不过 TiDB。
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 的架构。
-对于事务部分,TiDB 实现悲观事务的方式是非常简洁的。其团队在仔细研究了 Percolator 的模型后发现,其实只要将在客户端调用 Commit 时候进行两阶段提交这个行为稍微改造一下,将第一阶段上锁和等锁提前到事务中执行 DML 的过程中,就可以简单高效地支持悲观事务场景。
TiDB 的悲观锁实现的原理是,在一个事务执行 DML(UPDATE/DELETE)的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,等到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程和原来保持一致即可。
这个方案在很大程度上兼容了原有的事务实现,其扩展性、高可用和灵活性都有保证。同时该方案尽最大可能复用了原有 Percolator 的乐观事务方案,减少了事务模型整体的复杂度。
@@ -239,11 +239,11 @@ function hide_canvas() {Cassandra 的可调节一致性如我在本模块一致性那一讲介绍的一样,分为写一致性与读一致性。
写一致性声明了需要写入多少个节点才算一次成功的写入。Cassandra 的写一致性是可以在强一致到弱一致之间进行调整的。我总结了下面的表格来为你说明。
-我们可以看到 ANY 级别实际上对应了最终一致性。Cassandra 使用了反熵那一讲提到的暗示切换技术来保障写入的数据的可靠,也就是写入节点一旦失败,数据会暂存在暗示切换队列中,等到节点恢复后数据可以被还原出来。
对于读操作,一致性级别指定了返回数据之前必须有多少个副本节点响应这个读查询。这里同样给你整理了一个表格。
-Cassandra 在读取的时候使用了读修复来修复副本上的过期数据,该修复过程是一个后台线程,故不会阻塞读取。
以上就是 Apache Cassandra 实现可调节一致性的一些细节。AWS 的 DynamoDB、Azure 的 CosmosDB 都有类似的可调节一致性供用户进行选择。你可以比照 Cassandra 的模式和这些数据库的文档进行学习。
单体开源数据要向分布式数据库演进,就要解决写入性能不足的问题。
最简单直接的办法就是分库分表。分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库。代理节点分为“客户端 + 单体数据库”和“中间件 + 单体数据库”两个模式。
客户端组件 + 单体数据库通过独立的逻辑层建立数据分片和路由规则,实现单体数据库的初步管理,使应用能够对接多个单体数据库,实现并发、存储能力的扩展。其作为应用系统的一部分,对业务侵入比较深。这种客户端组件的典型产品是 Apache ShardingShpere 的 JDBC 客户端模式,下图就是该模式的架构图。
-Apache ShardingShpere 的 JDBC 客户端模式架构图
代理中间件 + 单体数据库以独立中间件的方式,管理数据规则和路由规则,以独立进程存在,与业务应用层和单体数据库相隔离,减少了对应用的影响。随着代理中间件的发展,还会衍生出部分分布式事务处理能力。这种中间件的典型产品是 MyCat、Apache ShardingShpere 的 Proxy 模式。
-Apache ShardingShpere 的 Proxy 模式架构图
代理节点需要实现三个主要功能,它们分别是客户端接入、简单的查询处理器和进程管理中的访问控制。另外,分库分表方案还有一个重要的功能,那就是分片信息管理,分片信息就是数据分布情况。不过考虑分片信息也存在多副本的一致性的问题,大多数情况下它会独立出来。显然,如果把每一次的事务写入都限制在一个单体数据库内,业务场景就会很受局限。
因此,跨库事务成为必不可少的功能,但是单体数据库是不感知这个事情的,所以我们就要在代理节点增加分布式事务组件。同时,简单的分库分表不能满足全局性的查询需求,因为每个数据节点只能看到一部分数据,有些查询运算是无法处理的,比如排序、多表关联等。所以,代理节点要增强查询计算能力,支持跨多个单体数据库的查询。更多相关内容我会在下一讲介绍。
diff --git a/专栏/24讲吃透分布式数据库-完/24 现状解读:分布式数据库的最新发展情况.md.html b/专栏/24讲吃透分布式数据库-完/24 现状解读:分布式数据库的最新发展情况.md.html index 85048e79..ec3d7974 100644 --- a/专栏/24讲吃透分布式数据库-完/24 现状解读:分布式数据库的最新发展情况.md.html +++ b/专栏/24讲吃透分布式数据库-完/24 现状解读:分布式数据库的最新发展情况.md.html @@ -216,7 +216,7 @@ function hide_canvas() {下面就按照我给出的定义中的关键点来向你详细介绍 NewSQL 数据库。
使用创新的数据库架构是 NewSQL 数据库非常引人注目的特性。这种新架构一般不会依靠任何遗留的代码,这与我在“22 | 发展与局限:传统数据库在分布式领域的探索”中介绍的依赖传统数据库作为计算存储节点非常不同。我们以 TiDB 这个典型的 NewSQL 数据库为例。
-可以看到其中的创新点有以下几个。
如下面的架构图所示,应用层通过 Cobar 访问数据库。
-其对数据库的访问分为读操作(select)和写操作(update、insert和delete)。写操作会在数据库上产生变更记录,MySQL 的变更记录叫 binlog,Oracle 的变更记录叫 redolog。Erosa 产品解析这些变更记录,并以统一的格式缓存至 Eromanga 中,后者负责管理变更数据的生产者、Erosa 和消费者之间的关系,负责跨机房数据库同步的 Otter 是这些变更数据的消费者之一。
Cobar 可谓 OLTP 分布式数据库解决方案的先驱,至今其中的思想还可以从现在的中间件,甚至 NewSQL 数据库中看到。但在阿里集团服役三年后,由于人员变动而逐步停止维护。这个时候 MyCAT 开源社区接过了该项目的衣钵,在其上增加了诸多功能并进行 bug 修改,最终使其在多个行业中占用自己的位置。
但是就像我曾经介绍的那样,中间件产品并不是真正的分布式数据库,它有自己的局限。比如 SQL 支持、查询性能、分布式事务、运维能力,等等,都有不可逾越的天花板。而有一些中间件产品幸运地得以继续进阶,最终演化为 NewSQL,甚至是云原生产品。阿里云的 PolarDB 就是这种类型的代表,它的前身是阿里云的分库分表中间件产品 DRDS,而 DRDS 来源于淘宝系的 TDDL 中间件。
diff --git a/专栏/DDD 微服务落地实战/00 开篇词 让我们把 DDD 的思想真正落地.md.html b/专栏/DDD 微服务落地实战/00 开篇词 让我们把 DDD 的思想真正落地.md.html index 3fd1fc84..5996d153 100644 --- a/专栏/DDD 微服务落地实战/00 开篇词 让我们把 DDD 的思想真正落地.md.html +++ b/专栏/DDD 微服务落地实战/00 开篇词 让我们把 DDD 的思想真正落地.md.html @@ -165,10 +165,10 @@ function hide_canvas() {2015 年,互联网技术的飞速发展带给了我们无限发展的空间。越来越多的行业在思考:如何转型互联网?如何开展互联网业务?这时,一个互联网转型的利器——微服务,它恰恰能够帮助很多行业很好地应对互联网业务。于是乎,我们加入了微服务转型的滚滚洪流之中。
但是,微服务也不是银弹,它也有很多的“坑”。
-当按照模块拆分微服务以后才发现,每次变更都需要修改多个微服务,不但多个团队都要变更,还要同时打包、同时升级,不仅没有降低维护成本,还使得系统的发布比过去更麻烦,真不如不用微服务。是微服务不好吗?我又陷入了沉思。
这时我才注意到 Martin Flower 在定义微服务时提到的“小而专”,很多人理解了“小”却忽略了“专”,就会带来微服务系统难于维护的糟糕境地。这里的“专”,就是要“小团队独立维护”,也就是尽量让每次的需求变更交给某个小团队独立完成,让需求变更落到某个微服务上进行变更,唯有这样才能发挥微服务的优势。
-通过这样的一番解析,才发现微服务的设计真的不仅仅是一个技术架构更迭的事情,而是对原有的设计提出了更高的要求,即“微服务内高内聚,微服务间低耦合”。如何才能更好地做到这一点呢?答案还是 DDD。
我们转型微服务的重要根源之一就是系统的复杂性,即系统规模越来越大,维护越来越困难,才需要拆分微服务。然而,拆分成微服务以后,并不意味着每个微服务都是各自独立地运行,而是彼此协作地组织在一起。这就好像一个团队,规模越大越需要一些方法来组织,而 DDD 恰恰就是那个帮助我们组织微服务的实践方法。
有了这个技术中台的支持,开发团队就可以把更多的精力放到对用户业务的理解,对业务痛点的理解,快速开发用户满意的功能并快速交付上。这样,不仅编写代码减少了,技术门槛降低了,还使得日后的变更更加容易,技术更迭也更加方便。因此,我又开始苦苦求索。
很快,Bob 大叔的整洁架构(Clean Architecture)给了我全新的思路。整洁架构最核心的是业务(图中的黄色与红色部分),即我们通过领域模型分析,最后形成的那些 Service、Entity 与 Value Object。
然而,整洁架构最关键的设计思想是通过一系列的适配器(图中的绿色部分),将业务代码与技术框架解耦。通过这样的解耦,上层业务开发人员更专注地去开发他们的业务代码,技术门槛得到降低;底层平台架构师则更低成本地进行架构演化,不断地跟上市场与技术的更迭。唯有这样,才能跟上日益激烈的市场竞争。
-图片来自 Robert C. Martin 的《架构整洁之道》
不仅如此,我在实践摸索过程中,还创新性地提出了单 Controller、通用仓库、通用工厂,以及完美支持 DDD + 微服务的技术中台架构设计。通过这些设计,开发团队能够更好地将 DDD 落地到项目开发中,真正地打造出一支支理解业务、高质量开发与快速交付的团队。
然而,在面对全新业务、全新增长点的时候,我们能不能把握住这样的机遇呢?我们期望能把握住,但每次回到现实,回到正在维护的系统时,却令人沮丧。我们的软件总是经历着这样的轮回,软件设计质量最高的时候是第一次设计的那个版本,当第一个版本设计上线以后就开始各种需求变更,这常常又会打乱原有的设计。
因此,需求变更一次,软件就修改一次,软件修改一次,质量就下降一次。不论第一次的设计质量有多高,软件经历不了几次变更,就进入一种低质量、难以维护的状态。进而,团队就不得不在这样的状态下,以高成本的方式不断地维护下去,维护很多年。
这时候,维护好原有的业务都非常不易,又如何再去期望未来更多的全新业务呢?比如,这是一段电商网站支付功能的设计,最初的版本设计质量还是不错的:
-当第一个版本上线以后,很快就迎来了第一次变更,变更的需求是增加商品折扣功能,并且这个折扣功能还要分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣。当我们拿到这个需求时怎么做呢?很简单,增加一个 if 语句,if 限时折扣就怎么怎么样,if 限量折扣就怎么怎么样……代码开始膨胀了。
接着,第二次变更需要增加 VIP 会员,除了增加各种金卡、银卡的折扣,还要为会员发放各种福利,让会员享受各种特权。为了实现这些需求,我们又要在 payoff() 方法中加入更多的代码。
第三次变更增加的是支付方式,除了支付宝支付,还要增加微信支付、各种银行卡支付、各种支付平台支付,此时又要塞入一大堆代码。经过这三次变更,你可以想象现在的 payoff() 方法是什么样子了吧,变更是不是就可以结束了呢?其实不能,接着还要增加更多的秒杀、预订、闪购、众筹,以及各种返券。程序变得越来越乱而难以阅读,每次变更也变得越来越困难。
@@ -171,7 +171,7 @@ function hide_canvas() {在我们不断地修复 Bug,实现新需求的过程中,软件的业务逻辑也会越来越接近真实世界,使得我们的软件越来越专业,让用户感觉越来越好用。但是,在软件越来越接近真实世界的过程中,业务逻辑就会变得越来越复杂,软件规模也越来越庞大。
你一定有这样一个认识:简单软件有简单软件的设计,复杂软件有复杂软件的设计。
比如,现在的需求就是将用户订单按照“单价 × 数量”公式来计算应付金额,那么在一个 PaymentBus 类中增加一个 payoff() 方法即可,这样的设计没有问题。不过,如果现在的需求需要在付款的过程中计算各种折扣、各种优惠、各种返券,那么我们必然会做成一个复杂的程序结构。
-但是,真实情况却不是这样的。真实情况是,起初我们拿到的需求是那个简单需求,然后在简单需求的基础上进行了设计开发。但随着软件的不断变更,软件业务逻辑变得越来越复杂,软件规模不断扩大,逐渐由一个简单软件转变成一个复杂软件。
这时,如果要保持软件设计质量不退化,就应当逐步调整软件的程序结构,逐渐由简单的程序结构转变为复杂的程序结构。如果我们总是这样做,就能始终保持软件的设计质量,不过非常遗憾的是,我们以往在维护软件的过程中却不是这样做的,而是不断地在原有简单软件的程序结构下,往 payoff() 方法中塞代码,这样做必然会造成软件的退化。
也就是说,软件退化的根源不是软件变更,软件变更只是一个诱因。如果每次软件变更时,适时地进行解耦,进行功能扩展,再实现新的功能,就能保持高质量的软件设计。但如果在每次软件变更时没有调整程序结构,而是在原有的程序结构上不断地塞代码,软件就会退化。这就是软件发展的规律,软件退化的根源。
@@ -187,7 +187,7 @@ function hide_canvas() {以往我们拿到这个需求,就很不冷静地开始改代码,修改成了如下一段代码:
-这里增加了一段 if 语句,并不是一种好的变更方式。如果每次都这样变更,那么软件必然就会退化,进入难以维护的状态。这种变更为什么就不好呢?因为它违反了“开放-封闭原则”。
开放-封闭原则(OCP) 分为开放原则与封闭原则两部分。
按以上案例为例,为了实现新的功能,我们在原有代码的基础上,在不添加新功能的前提下调整原有程序结构,我们抽取出了 Strategy 这样一个接口和“不折扣”这个实现类。这时,原有程序变了吗?没有。但是程序结构却变了,增加了这样一个接口,称为“可扩展点”。在这个可扩展点的基础上再实现各种折扣,既能满足“开放-封闭原则”来保证程序质量,又能够满足新的需求。当日后发生新的变更时,什么类型的折扣就修改哪个实现类,添加新的折扣类型就增加新的实现类,维护成本得到降低。
-“两顶帽子”的设计方式意义重大。过去,我们每次在设计软件时总是担心日后的变更,就很不冷静地设计了很多所谓的“灵活设计”。然而,每一种“灵活设计”只能应对一种需求变更,而我们又不是先知,不知道日后会发生什么样的变更。最后的结果就是,我们期望的变更并没有发生,所做的设计都变成了摆设,它既不起什么作用,还增加了程序复杂度;我们没有期望的变更发生了,原有的程序依然不能解决新的需求,程序又被打回了原形。因此,这样的设计不能真正解决未来变更的问题,被称为“过度设计”。
有了“两顶帽子”,我们不再需要焦虑,不再需要过度设计,正确的思路应当是“活在今天的格子里做今天的事儿”,也就是为当前的需求进行设计,使其刚刚满足当前的需求。所谓的“高质量的软件设计”就是要掌握一个平衡,一方面要满足当前的需求,另一方面要让设计刚刚满足需求,从而使设计最简化、代码最少。这样做,不仅软件设计质量提高了,设计难点也得到了大幅度降低。
简而言之,保持软件设计不退化的关键在于每次需求变更的设计,只有保证每次需求变更时做出正确的设计,才能保证软件以一种良性循环的方式不断维护下去。这种正确的设计方式就是“两顶帽子”。
diff --git a/专栏/DDD 微服务落地实战/02 以电商支付功能为例演练 DDD.md.html b/专栏/DDD 微服务落地实战/02 以电商支付功能为例演练 DDD.md.html index d57b6bc6..4bcaf4c3 100644 --- a/专栏/DDD 微服务落地实战/02 以电商支付功能为例演练 DDD.md.html +++ b/专栏/DDD 微服务落地实战/02 以电商支付功能为例演练 DDD.md.html @@ -165,15 +165,15 @@ function hide_canvas() {最后,我们对订单可以进行“下单”“付款”“查看订单状态”等操作。因此形成了以下领域模型图:
-有了这样的领域模型,就可以通过该模型进行以下程序设计:
-通过领域模型的指导,将“订单”分为订单 Service 与值对象,将“用户”分为用户 Service 与值对象,将“商品”分为商品 Service 与值对象……然后,在此基础上实现各自的方法。
当电商网站的付款功能按照领域模型完成了第一个版本的设计后,很快就迎来了第一次需求变更,即增加折扣功能,并且该折扣功能分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣与不折扣。当我们拿到这个需求时应当怎样设计呢?很显然,在 payoff() 方法中去插入 if 语句是不 OK 的。这时,按照领域驱动设计的思想,应当将需求变更还原到领域模型中进行分析,进而根据领域模型背后的真实世界进行变更。
-这是上一个版本的领域模型,现在我们要在这个模型的基础上增加折扣功能,并且还要分为限时折扣、限量折扣、某类商品的折扣等不同类型。这时,我们应当怎么分析设计呢?
首先要分析付款与折扣的关系。
付款与折扣是什么关系呢?你可能会认为折扣是在付款的过程中进行的折扣,因此就应当将折扣写到付款中。这样思考对吗?我们应当基于什么样的思想与原则来设计呢?这时,另外一个重量级的设计原则应该出场了,那就是“单一职责原则”。
@@ -195,10 +195,10 @@ function hide_canvas() {最后发现,不同类型的折扣也是软件变化不同的原因。将它们放在同一个类、同一个方法中,合适吗?通过以上分析,我们做出了如下设计:
-在该设计中,将折扣功能从付款功能中独立出去,做出了一个接口,然后以此为基础设计了各种类型的折扣实现类。这样的设计,当付款功能发生变更时不会影响折扣,而折扣发生变更的时候不会影响付款。同样,当“限时折扣”发生变更时只与“限时折扣”有关,“限量折扣”发生变更时也只与“限量折扣”有关,与其他折扣类型无关。变更的范围缩小了,维护成本就降低了,设计质量提高了。这样的设计就是“单一职责原则”的真谛。
接着,在这个版本的领域模型的基础上进行程序设计,在设计时还可以加入一些设计模式的内容,因此我们进行了如下的设计:
-显然,在该设计中加入了“策略模式”的内容,将折扣功能做成了一个折扣策略接口与各种折扣策略的实现类。当哪个折扣类型发生变更时就修改哪个折扣策略实现类;当要增加新的类型的折扣时就再写一个折扣策略实现类,设计质量得到了提高。
在第一次变更的基础上,很快迎来了第二次变更,这次是要增加 VIP 会员,业务需求如下。
@@ -220,13 +220,13 @@ function hide_canvas() {通过以上的分析,我们做出了以下版本的领域模型:
-有了这些领域模型的变更,然后就可以以此作为基础,指导后面程序代码的变更了。
同样,第三次变更是增加更多的支付方式,我们在领域模型中分析“付款”与“支付方式”之间的关系,发现它们也是软件变化不同的原因。因此,我们果断做出了这样的设计:
-而在设计实现时,因为要与各个第三方的支付系统对接,也就是要与外部系统对接。为了使第三方的外部系统的变更对我们的影响最小化,在它们中间果断加入了“适配器模式”,设计如下:
-通过加入适配器模式,订单 Service 在进行支付时调用的不再是外部的支付接口,而是“支付方式”接口,与外部系统解耦。只要保证“支付方式”接口是稳定的,那么订单 Service 就是稳定的。比如:
过去,系统的软件设计是以数据库设计为核心,当需求确定下来以后,团队首先开始进行数据库设计。因为数据库是各个模块唯一的接口,当整个团队将数据库设计确定下来以后,就可以按照模块各自独立地进行开发了,如下图所示。
-在上面的过程中,为了提高团队开发速度,尽量让各个模块不要交互,从而达到各自独立开发的效果。但是,随着系统规模越来越大,业务逻辑越来越复杂,我们越来越难于保证各个模块独立不交互了。
随着软件业的不断发展,软件系统变得越来越复杂,各个模块间的交互也越来越频繁,这时,原有的设计过程已经不能满足我们的需要了。 因为如果要先进行数据库设计,但数据库设计只能描述数据结构,而不能描述系统对这些数据结构的处理。因此,在第一次对整个系统的梳理过程中,只能梳理系统的所有数据结构,形成数据库设计;接着,又要再次梳理整个系统,分析系统对这些数据结构的处理过程,形成程序设计。为什么不能一次性地把整个系统的设计梳理到位呢?
-现如今,我们已经按照面向对象的软件设计过程来分析设计系统了。当开始需求分析时,首先进行用例模型的设计,分析整个系统要实现哪些功能;接着进行领域模型的设计,分析系统的业务实体。在领域模型分析中,采用类图的形式,每个类可以通过它的属性来表述数据结构,又可以通过添加方法来描述对这个数据结构的处理。因此,在领域模型的设计过程中,既完成了对数据结构的梳理,又确定了系统对这些数据结构的处理,这样就把两项工作一次性地完成了。
在这个设计过程中,其核心是领域模型的设计。以领域模型作为核心,可以指导系统的数据库设计与程序设计,此时,数据库设计就弱化为了领域对象持久化设计的一种实现方式。
数据库设计在发生剧烈的变化,但唯一不变的是领域对象。这样,当系统在大数据转型时,可以保证业务代码不变,变化的是数据访问层(DAO)。这将使得日后大数据转型的成本更低,让我们更快地跟上技术快速发展的脚步。
此外,这里有个有趣的问题值得探讨:领域模型的设计到底是谁的职责,是需求分析人员还是设计开发人员?我认为,它是两个角色相互协作的产物。而未来敏捷开发的组织形成,团队将更加扁平化。过去是需求分析人员做需求分析,然后交给设计人员设计开发,这种方式就使得软件设计质量低下而结构臃肿。未来“大前端”的思想将支持更多设计开发人员直接参与需求分析,实现从需求分析到设计开发的一体化组织形式。这样,领域模型就成为了设计开发人员快速理解需求的利器。
-总之,**DDD 的数据库设计实际上已经变成了:以领域模型为核心,如何将领域模型转换成数据库设计的过程。**那么怎样进行转换呢?在领域模型中是一个一个的类,而在数据库设计中是一个一个的表,因此就是将类转换成表的过程。
-上图是一个绩效考核系统的领域模型图,该绩效考核系统首先进行自动考核,发现一批过错,然后再给一个机会,让过错责任人对自己的过错进行申辩。这时,过错责任人可以填写一张申辩申请单,在申辩申请单中有多个明细,每个明细对应一个过错行为,每个过错行为都对应了一个过错类型,这样就形成了一个领域模型。
接着,要将这个领域模型转换成数据库设计,怎么做呢?很显然,领域模型中的一个类可以转换成数据库中的一个表,类中的属性可以转换成表中的字段。但这里的关键是如何处理类与类之间的关系,如何转换成表与表之间的关系。这时候,就有 5 种类型的关系需要转换,即传统的 4 种关系 + 继承关系。
1. 一对一关系
在以上案例中,“申辩申请单明细”与“过错行为”就是一对“一对一”关系。在该关系中,一个“申辩申请单明细”必须要对应一个“过错行为”,没有一个“过错行为”的对应就不能成为一个“申辩申请单明细”。这种约束在数据库设计时,可以通过外键来实现。但是,一对一关系还有另外一个约束,那就是一个“过错行为”最多只能有一个“申辩申请单明细”与之对应。
也就是说,一个“过错行为”可以没有“申辩申请单明细”与之对应,但如果有,最多只能有一个“申辩申请单明细”与之对应,这个约束暗含的是一种唯一性的约束。因此,将过错行为表中的主键,作为申辩申请单明细表的外键,并将该字段升级为申辩申请单明细表的主键。
-2. 多对一关系
是日常的分析设计中最常见的一种关系。在以上案例中,一个过错行为对应一个税务人员、一个纳税人与一个过错类型;同时,一个税务人员,或纳税人,或过错类型,都可以对应多个过错行为。它们就形成了“多对一”关系。在数据库设计时,通过外键就可以建立这种“多对一”关系。因此,我们进行了如下数据库的设计:
-多对一关系在数据库设计上比较简单,然而落实到程序设计时,需要好好探讨一下。比如,以上案例,在按照这样的方式设计以后,在查询时往往需要在查询过错行为的同时,显示它们对应的税务人员、纳税人与过错类型。这时,以往的设计是增加一个 join 语句。然而,这样的设计在随着数据量不断增大时,查询性能将受到极大的影响。
也就是说,join 操作往往是关系型数据库在面对大数据时最大的瓶颈之一。因此,一个更好的方案就是先查询过错行为表,分页,然后再补填当前页的其他关联信息。这时,就需要在“过错行为”这个值对象中通过属性变量,增加对税务人员、纳税人与过错类型等信息的引用。
3. 一对多关系
该关系往往表达的是一种主-子表的关系。譬如,以上案例中的“申辩申请单”与“申辩申请单明细”就是一对“一对多”关系。除此之外,订单与订单明细、表单与表单明细,都是一对多关系。一对多关系在数据库设计上比较简单,就是在子表中增加一个外键去引用主表中的主键。比如本案例中,申辩申请单明细表通过一个外键去引用申辩申请单表中的主键,如下图所示。
-除此之外,在程序的值对象设计时,主对象中也应当有一个集合的属性变量去引用子对象。如本例中,在“申辩申请单”值对象中有一个集合属性去引用“申辩申请单明细”。这样,当通过申辩申请单号查找到某个申辩申请单时,同时就可以获得它的所有申辩申请单明细,如下代码所示:
```java
@@ -209,30 +209,30 @@ public class Sbsqd {
4. 多对多关系
比较典型的例子就是“用户角色”与“功能权限”。一个“用户角色”可以申请多个“功能权限”;而一个“功能权限”又可以分配给多个“用户角色”使用,这样就形成了一个“多对多”关系。这种多对多关系在对象设计时,可以通过一个“功能-角色关联类”来详细描述。因此,在数据库设计时就可以添加一个“角色功能关联表”,而该表的主键就是关系双方的主键进行的组合,形成的联合主键,如下图所示:
-以上是领域模型和数据库都有的 4 种关系。因此,在数据库设计时,直接将相应的关系转换成数据库设计就可以了。同时,在数据库设计时还要将它们进一步细化。如在领域模型中,不论对象还是属性,在命名时都采用中文,这样有利于沟通与理解。但到了数据库设计时,就要将它们细化为英文命名,或者汉语拼音首字母,同时还要确定它们的字段类型与是否为空等其他属性。
第 5 种关系就不太一样了:继承关系是在领域模型设计中有,但在数据库设计中却没有。 如何将领域模型中的继承关系转换成数据库设计呢?有 3 种方案可以选择。
1. 继承关系的第一种方案
首先,看看以上案例。“执法行为”通过继承分为“正确行为”和“过错行为”。如果这种继承关系的子类不多(一般就 2 ~ 3 个),并且每个子类的个性化字段也不多(3 个以内)的话,则可以使用一个表来记录整个继承关系。在这个表的中间有一个标识字段,标识表中的每条记录到底是哪个子类,这个字段的前面部分罗列的是父类的字段,后面依次罗列各个子类的个性化字段。
-采用这个方案的优点是简单,整个继承关系的数据全部都保存在这个表里。但是,它会造成“表稀疏”。在该案例中,如果是一条“正确行为”的记录,则字段“过错类型”与“扣分”永远为空;如果是一条“过错行为”的记录,则字段“加分”永远为空。假如这个继承关系中各子类的个性化字段很多,就会造成该表中出现大量字段为空,称为“表稀疏”。在关系型数据库中,为空的字段是要占用空间的。因此,这种“表稀疏”既会浪费大量存储空间,又会影响查询速度,是需要极力避免的。所以,当子类比较多,或者子类个性化字段多的情况是不适合该方案(第一种方案)的。
2. 继承关系的第二种方案
如果执法行为按照考核指标的类型进行继承,分为“考核指标1”“考核指标2”“考核指标3”……如下图所示:
-并且每个子类都有很多的个性化字段,则采用前面那个方案就不合适了。这时,用另外两个方案进行数据库设计。其中一个方案是将每个子类都对应到一个表,有几个子类就有几个表,这些表共用一个主键,即这几个表的主键生成器是一个,某个主键值只能存在于某一个表中,不能存在于多个表中。每个表的前面是父类的字段,后面罗列各个子类的字段,如下图所示:
-如果业务需求是在前端查询时,每次只能查询某一个指标,那么采用这种方案就能将每次查询落到某一个表中,方案就最合适。但如果业务需求是要查询某个过错责任人涉及的所有指标,则采用这种方案就必须要在所有的表中进行扫描,那么查询效率就比较低,并不适用。
3. 继承关系的第三种方案
如果业务需求是要查询某个过错责任人涉及的所有指标,则更适合采用以下方案,将父类做成一个表,各个子类分别对应各自的表(如图所示)。这样,当需要查询某个过错责任人涉及的所有指标时,只需要查询父类的表就可以了。如果要查看某条记录的详细信息,再根据主键与类型字段,查询相应子类的个性化字段。这样,这种方案就可以完美实现该业务需求。
-综上所述,将领域模型中的继承关系转换成数据库设计有 3 种方案,并且每个方案都有各自的优缺点。因此,需要根据业务场景的特点与需求去评估,选择哪个方案更适用。
前面我们讲的数据库设计,还是基于传统的关系型数据库、基于第三范式的数据库设计。但是,随着互联网高并发与分布式技术的发展,另一种全新的数据库类型孕育而生,那就是NoSQL 数据库。正是由于互联网应用带来的高并发压力,采用关系型数据库进行集中式部署不能满足这种高并发的压力,才使得分布式 NoSQL 数据库得到快速发展。
也正因为如此,NoSQL 数据库与关系型数据库的设计套路是完全不同的。关系型数据库的设计是遵循第三范式进行的,它使得数据库能够大幅度降低冗余,但又从另一个角度使得数据库查询需要频繁使用 join 操作,在高并发场景下性能低下。
所以,NoSQL 数据库的设计思想就是尽量干掉 join 操作,即将需要 join 的查询在写入数据库表前先进行 join 操作,然后直接写到一张单表中进行分布式存储,这张表称为“宽表”。这样,在面对海量数据进行查询时,就不需要再进行 join 操作,直接在这个单表中查询。同时,因为 NoSQL 数据库自身的特点,使得它在存储为空的字段时不占用空间,不担心“表稀疏”,不影响查询性能。
因此,NoSQL 数据库在设计时的套路就是,尽量在单表中存储更多的字段,只要避免数据查询中的 join 操作,即使出现大量为空的字段也无所谓了。
-增值税发票票样图
正因为 NoSQL 数据库在设计上有以上特点,因此将领域模型转换成 NoSQL 数据库时,设计就完全不一样了。比如,这样一张增值税发票,如上图所示,在数据库设计时就需要分为发票信息表、发票明细表与纳税人表,而在查询时需要进行 4 次 join 才能完成查询。但在 NoSQL 数据库设计时,将其设计成这样一张表:
{ _id: ObjectId(7df78ad8902c)
diff --git a/专栏/DDD 微服务落地实战/04 领域模型是如何指导程序设计的?.md.html b/专栏/DDD 微服务落地实战/04 领域模型是如何指导程序设计的?.md.html
index b4d0ebf4..79c3ffad 100644
--- a/专栏/DDD 微服务落地实战/04 领域模型是如何指导程序设计的?.md.html
+++ b/专栏/DDD 微服务落地实战/04 领域模型是如何指导程序设计的?.md.html
@@ -165,12 +165,12 @@ function hide_canvas() {
服务、实体与值对象是领域驱动设计的基本元素。然而,要将业务领域模型最终转换为程序设计,还要加入相应的设计。通常,将业务领域模型转换为程序设计,有两种设计思路:贫血模型与充血模型。
贫血模型与充血模型
事情是这样的:2004 年,软件大师 Eric Evans 发表了他的不朽著作《领域驱动设计》。虽然已经过去十多年了,这本书直到今天依然对我们有相当大的帮助。接着,另一位软件大师 Martin Fowler 在自己的博客中提出了“贫血模型”的概念。这位“马大叔”有一个非常大的特点,那就是软件行业中各种名词都是他发明的,包括如今业界影响巨大的软件重构、微服务,也是他的杰作。然而,马大叔在提出“贫血模型”的时候,却将其作为反模式提出来批评:所谓的“贫血模型”,就是在软件设计中,有很多的 POJO(Plain Ordinary Java Object)对象,它们除了有一堆 get/set 方法,几乎没有任何业务逻辑。这样的设计被称为“贫血模型”。
-
+
如上图所示,在领域模型中有 VIP 会员的领域对象,该对象除了有一堆属性以外,还有“会员打折”“会员福利”“会员特权”等方法。如果将该领域模型按照贫血模型进行设计,就会设计一个 VIP 会员的实体对象与 Service,实体对象包含该对象的所有属性,以及这些属性包含的数据;然后,将所有的方法都放入 Service 中,在调用它们的时候,必须将领域对象作为参数进行传输。这样的设计,将领域对象中的这些方法,以及这些方法在执行过程中所需的数据,割裂到两个不同的对象中,打破了对象的封装性。它会带来什么问题呢?
-
+
如上图所示,在领域模型中的 VIP 会员通过继承分为了“金卡会员”与“银卡会员”。如果将该领域模型按照贫血模型进行设计,则会设计出一个“金卡会员”的实体对象与 Service,同时设计出一个“银卡会员”的实体对象与 Service。“金卡会员”的实体对象应当调用“金卡会员”的 Service,如果将“金卡会员”的实体对象去调用了“银卡会员”的 Service,系统就会出错。所以,除了进行以上设计以外,还需要有一个客户程序去判断,当前的实体对象是“金卡会员”还是“银卡会员”?这时,系统变更就变得没有那么灵活了。
比如,现在需要在原有基础上,再增加一个“铂金会员”,那么不仅要增加一个“铂金会员”的实体对象与 Service,还要修改客户程序的判断,系统变更成本就会提高。
-
+
针对贫血模型的问题,马大叔提出了“充血模型”的概念。所谓“充血模型”,就是将领域模型的原貌直接转换为程序中领域对象的设计。这时,各种业务操作就不再在“服务”中实现了,而是在领域对象中实现。如图所示,在程序设计时,既有父类的“VIP 会员”,又有子类“金卡会员”与“银卡会员”。
但充血模型与贫血模型不同的是:
@@ -193,10 +193,10 @@ function hide_canvas() {
1. 贫血模型比充血模型更加简单易行
充血模型是将领域模型的原貌直接映射成了程序设计,因此在程序设计时需要增加更多的诸如仓库、工厂的组件,对设计能力与架构提出了更高的要求。
譬如,现在要设计一个订单系统,在领域建模时,每个订单需要有多个订单明细,还要对应相关的客户信息、商品信息。因此,在装载一个订单时,需要同时查出它的订单明细,以及对应的客户信息、商品信息,这些需要有强大的订单工厂进行装配;装载订单以后,还需要放到仓库中进行缓存,需要订单仓库具备缓存的能力;此外,在保存订单的时候,还需要同时保存订单和订单明细,并将它们放到一个事务中。所有这些都需要强有力的技术平台的支持。
-
+
相反,贫血模型就显得更加贫民化。在贫血模型中,MVC 层直接调用 Service,Service 通过DAO进行数据访问。在这个过程中,每个 DAO 都只查询数据库中的某个表,然后直接交给 Service 去使用,去完成各种处理。
以订单系统为例,订单有订单 DAO,负责查询订单;订单明细有订单明细 DAO,负责查询订单明细。它们查询出来以后,不需要装配,而是直接交给订单 Service 使用。在保存订单时,订单 DAO 负责保存订单,订单明细 DAO 负责保存订单明细。它们都是通过订单 Service 进行组织,并建立事务。贫血模型不需要仓库,不需要工厂,也不需要缓存,一切都显得那么简单粗暴但一目了然。
-
+
2. 充血模型需要具备更强的设计与协作能力
充血模型的设计实现给开发人员提出了更高的能力要求,需要具有更强的 OOA/D(面向对象分析/设计) 能力、分析业务、业务建模与设计能力。譬如,在订单系统这个案例中,开发人员要先进行领域建模,分析清楚该场景中的订单、订单明细、用户、商品等领域对象的关联关系;还要分析各个领域对象在真实世界中都有什么行为,对应到软件设计中都有什么方法,在此基础上再进行设计开发。
同时,充血模型需要有较强的团队协作能力。比如,在该场景中,当订单在进行创建时,需要对用户以及用户地址的相关信息进行查询。此时,订单 Service 不能直接去查询用户和用户地址的相关表,而是去调用用户 Service 的相关接口,由用户 Service 去完成对用户相关表的查询。这时候,开发订单模块的团队,需要向开发用户模块的团队提出接口需求。
@@ -204,7 +204,7 @@ function hide_canvas() {

3. 贫血模型更容易应对复杂的业务处理场景
充血模型在进行设计时,是将所有的业务处理过程在领域对象的相应方法中实现的。这样的设计,如果业务处理过程比较简单,还可以从容应对;但如果是面对非常复杂的业务处理场景时,就有一些力不从心。在这些复杂的业务处理场景中,如果采用贫血模型,可以将复杂的业务处理场景,划分成多个相对独立的步骤;然后将这些独立的步骤分配给多个 Service 串联起来执行。这样,各个步骤就是以一种松耦合的形式串联地组织在一起,以领域对象作为参数在各个Service 中进行传递。
-
+
在这样的设计中,领域对象既可以作为各个方法调用的输入,又可以作为它们的输出。比如,在上图的案例中,领域对象作为参数首先调用 ServiceA;调用完以后将结果数据写入领域对象的前 5 个字段,传递给 ServiceB;ServiceB 拿到领域对象以后,既可以作为输入去读取前 5 个字段,又可以作为输出将执行结果写入中间 5 个字段;最后,将领域对象传递给 ServiceC,执行完操作以后去写后面 5 个字段;当所有字段都写入完成以后,存入数据库,完成所有操作。
在这个过程中,如果日后需要变更,要增加一个处理过程,或者去掉一个处理过程,再或者调整它们的执行顺序,都是比较容易的。这样的设计要求处理过程必须在领域对象之外,在 Service 中实现。然而,如果采用的是充血模型的设计,就必须要将所有的处理过程都写入这个领域对象中去实现,无论这些处理过程有多复杂。这样的设计势必会加大日后变更维护的成本。
所以,不论是贫血模型还是充血模型,它们各有优缺点,到底应当采用贫血模型还是充血模型,争执了这么多年,但我认为它们并不是熊掌和鱼的关系,我们应当把它们结合起来,取长补短,合理利用。关键是要先弄清楚它们的差别,也就是业务逻辑应当在哪里实现:贫血模型的业务逻辑在 Service 中实现,但充血模型是在领域对象中实现。清楚了这一点,在今后的软件设计时,可以将那些需要封装的业务逻辑放到领域对象中,按照充血模型去设计;除此之外的其他业务逻辑放到 Service 中,按照贫血模型去设计。
diff --git a/专栏/DDD 微服务落地实战/06 限界上下文:冲破微服务设计困局的利器.md.html b/专栏/DDD 微服务落地实战/06 限界上下文:冲破微服务设计困局的利器.md.html
index d694b1d2..a7f84df1 100644
--- a/专栏/DDD 微服务落地实战/06 限界上下文:冲破微服务设计困局的利器.md.html
+++ b/专栏/DDD 微服务落地实战/06 限界上下文:冲破微服务设计困局的利器.md.html
@@ -170,7 +170,7 @@ function hide_canvas() {
因此,应当将读取用户信息的操作交给“用户信息管理”限界上下文,“用户下单”限界上下文只是对它的接口进行调用。通过这样的划分,实现了限界上下文内的高内聚和限界上下文间的低耦合,可以很好地降低日后代码变更的成本、提高软件设计质量。而限界上下文之间的这种相互关系,称为“上下文地图”(Context Map)。
限界上下文与微服务
所谓“限界上下文内的高内聚”,也就是每个限界上下文内实现的功能,都是软件变化的同一个原因的代码。因为这个原因的变化才需要修改这个限界上下文,而不是这个原因的变化就不需要修改这个限界上下文,修改与它无关。正是因为限界上下文有如此好的特性,才使得现在很多微服务团队,运用限界上下文作为微服务拆分的原则,即每个限界上下文对应一个微服务。
-
+
按照这样的原则拆分出来的微服务系统,在今后变更维护时,可以很好地将每次的需求变更,快速落到某个微服务中变更。这样,变更这个微服务就实现了该需求,升级该服务后就可以交付用户使用了。这样的设计,使得越来越多的规划开发团队,今后可以实现低成本维护与快速交付,进而快速适应市场变化而提升企业竞争力。
譬如,在电商网站的购物过程中,购物、下单、支付、物流,都是软件变化不同的原因,因此,按照不同的业务场景划分限界上下文,然后以此拆分微服务。那么,当购物变更时就修改购物微服务,下单变更就修改下单微服务,但它们在业务处理过程中都需要读取商品信息,因此调用“商品管理”微服务来获取商品信息。这样,一旦商品信息发生变更,只与“商品管理”微服务有关,与其他微服务无关,那么维护成本将得到降低,交付速度得到提升。
所谓“限界上下文间的低耦合”,就是限界上下文通过上下文地图相互调用时,通过接口进行调用。如下图所示,模块 A 需要调用模块 B,那么它就与模块 B 形成了一种耦合,这时:
@@ -179,7 +179,7 @@ function hide_canvas() {
- 如果模块 B 还要依赖模块 C,模块 C 还要依赖模块 D,那么所有使用模块 A 的地方都必须有模块 B、C、D,使用模块 A 的成本就会非常高昂。
然而,如果模块 A 不是依赖模块 B,而是依赖接口 B',那么所有需要模块 A 的地方就不一定需要模块 B;如果模块 F 实现了接口 B',那么模块 A 调用模块 F 就可以了。这样,调用方和被调用方的耦合就被解开。
-
+
在代码实现时,可以通过微服务来实现“限界上下文间”的“低耦合”。比如,“下单”微服务要去调用“支付”微服务。在设计时:
- 首先在“下单”微服务中增加一个“支付”接口,这样在“下单”微服务中所有对支付的调用,都是对该接口的调用;
@@ -192,10 +192,10 @@ function hide_canvas() {
- 现在,采用领域驱动设计,读取用户信息的职责交给了“用户管理”限界上下文,其他模块都是调用它的接口,这样,当用户信息表发生变更时,只与“用户管理”限界上下文有关,与其他模块无关,变更维护成本就降低了。通过限界上下文将整个系统按照逻辑进行了划分,但从物理上它们都还是一个项目、运行在一个 JVM 中,这种限界上下文只是“逻辑边界”。
- 今后,将单体应用转型成微服务架构以后,各个限界上下文都是运行在各自不同的微服务中,是不同的项目、不同的 JVM。不仅如此,进行微服务拆分的同时,数据库也进行了拆分,每个微服务都是使用不同的数据库。这样,当各个微服务要访问用户信息时,它们没有访问用户数据库的权限,就只能通过远程接口去调用“用户”微服务开放的相关接口。这时,这种限界上下文就真正变成了“物理边界”,如下图所示:
-
+
微服务拆分的困局
现如今,许多软件团队都在加入微服务转型的行列,将原有的越来越复杂的单体应用,拆分为一个一个简单明了的微服务,以降低系统微服务的复杂性,这是没有问题的。然而,现在最大的问题是微服务应当如何拆分。
-
+
如上图所示,以往许多的系统是这样设计的。现在,如果还按照这样的设计思路简单粗暴地拆分为多个微服务以后,对系统日后的维护将是灾难性的。
- 当多个模块都要读取商品信息表时,是直接通过 JDBC(Java Database Connectivity)去读取这个表。
diff --git a/专栏/DDD 微服务落地实战/07 在线订餐场景中是如何开事件风暴会议的?.md.html b/专栏/DDD 微服务落地实战/07 在线订餐场景中是如何开事件风暴会议的?.md.html
index fa89aa5c..5f19c59b 100644
--- a/专栏/DDD 微服务落地实战/07 在线订餐场景中是如何开事件风暴会议的?.md.html
+++ b/专栏/DDD 微服务落地实战/07 在线订餐场景中是如何开事件风暴会议的?.md.html
@@ -152,7 +152,7 @@ function hide_canvas() {
用“领域驱动设计”是业界普遍认可的解决方案,也就是解决微服务如何拆分,以及实现微服务的高内聚与单一职责的问题。但是,领域驱动设计应当怎样进行呢?怎样从需求分析到软件设计,用正确的方式一步一步设计微服务呢?现在我们用一个在线订餐系统实战演练一下微服务的设计过程。
在线订餐系统项目实战
相信我们都使用过在线订餐系统,比如美团、大众点评、百度外卖等,具体的业务流程如下图所示:
-
+
在线订餐系统的业务流程图
- 当我们进入在线订餐系统时,首先看到的是各个饭店,进入每个饭店都能看到他们的菜单;
@@ -165,7 +165,7 @@ function hide_canvas() {
现在,我们要以此为背景,按照微服务架构来设计开发一个在线订餐系统。那么,我们应当如何从分析理解需求开始,一步一步通过前面讲解的领域驱动设计,最后落实到拆分微服务,把这个系统拆分出来呢?
统一语言建模
软件开发的最大风险是需求分析,因为在这个过程中谁都说不清楚能让对方了解的需求。
-
+
研发不懂客户、客户也不懂研发
在这个过程中,对于客户来说:
@@ -202,22 +202,22 @@ function hide_canvas() {
说到底,一个信息管理系统的作用,就是存储这些事实,对这些事实进行管理与跟踪,进而起到提高工作效率的作用。因此,分析一个信息管理系统的业务需求,就是准确地抓住业务进行过程中那些需要存储的关键事实,并围绕着这些事实进行分析设计、领域建模,这就是“事件风暴”的精髓。
召开事件风暴会议
因此,实践“事件风暴”方法,就是让开发人员与领域专家坐在一起,开事件风暴会议。会议的目的就是与领域专家一起进行领域建模,而会议前的准备就是在会场准备一个大大的白板与各色的便笺纸,如下图所示:
-
+
事件风暴会议图
当开始事件风暴会议以后,通常分为这样几个步骤。
首先,在产品经理的引导下,与业务专家开始梳理当前的业务中有哪些领域事件,即已经发生并需要保存下来的那些事实。这时,是按照业务流程依次去梳理领域事件的。例如,在本案例中,整个在线订餐过程分为:已下单、已接单、已就绪、已派送和已送达,这几个领域事件。注意,领域事件是已发生的事实,因此,在命名的时候应当采用过去时态。
这里有一个十分有趣的问题值得探讨。在用户下单之前,用户首先是选餐。那么,“用户选餐”是不是领域事件呢?注意,领域事件是那些已经发生并且需要保存的重要事实。这里,“用户选餐”仅仅是一个查询操作,并不需要数据库保存,因此不能算领域事件。那么,难道这些查询功能不在需求分析的过程中吗?
注意,DDD 有自己的适用范围,它往往应用于系统增删改的业务场景中,而查询场景的分析往往不用 DDD,而是通过其他方式进行分析。分析清楚了领域事件以后,就用橘黄色便笺纸,将所有的领域事件罗列在白板上,确保领域中所有事件都已经被覆盖。
紧接着,针对每一个领域事件,项目组成员开始不断地围绕着它进行业务分析,增加各种命令与事件,进而思考与之相关的资源、外部系统与时间。例如,在本案例中,首先分析“已下单”事件,分析它触发的命令、与之相关的人与事儿,以及发生的时间。命令使用蓝色便笺,人和事儿使用黄色便笺,如下图所示:
-
+
“已下单”的领域事件分析图
“已下单”事件触发的命令是“下单”,执行者是“用户”(画一个小人作为标识),执行时间是“下单时间”。与它相关的人和事儿有“饭店”与“订单”。在此基础上进一步分析,用户关联到用户地址,饭店关联到菜单,订单关联到菜品明细。
然后,就是识别模型中可能涉及的聚合及其聚合根。第 05 讲谈到,所谓的“聚合”就是整体与部分的关系,譬如,饭店与菜单是否是聚合关系,关键看它俩的数据是如何组织的。如果菜单在设计时是独立于饭店之外的,如“宫保鸡丁”是独立于饭店的菜单,每个饭店都是在引用这条记录,那么菜单与饭店就不是聚合关系,即使删除了这个饭店,这个菜单依然存在。
但如果菜单在设计时,每个饭店都有自己独立的菜单,譬如同样是“宫保鸡丁”,饭店 A 与饭店 B 使用的都是各自不同的记录。这时,菜单在设计上就是饭店的一个部分,删除饭店就直接删除了它的所有菜单,那么菜单与饭店就是聚合关系。在这里,那个代表“整体”的就是聚合根,所有客户程序都必须要通过聚合根去访问整体中的各个部分。
通过以上分析,我们认为用户与地址、饭店与菜单、订单与菜品明细,都是聚合关系。如果是聚合关系,就在该关系上贴一张紫色便笺。
按照以上步骤,一个一个地去分析每个领域事件:
-
-
+
+
在线订餐系统的领域事件分析图
当所有的领域事件都分析完成以后,最后再站在全局对整个系统进行模块的划分,划分为多个限界上下文,并在各个限界上下文之间,定义它们的接口,规划上下文地图。
总结
diff --git a/专栏/DDD 微服务落地实战/08 DDD 是如何解决微服务拆分难题的?.md.html b/专栏/DDD 微服务落地实战/08 DDD 是如何解决微服务拆分难题的?.md.html
index f207e5ee..cbcfea0c 100644
--- a/专栏/DDD 微服务落地实战/08 DDD 是如何解决微服务拆分难题的?.md.html
+++ b/专栏/DDD 微服务落地实战/08 DDD 是如何解决微服务拆分难题的?.md.html
@@ -158,16 +158,16 @@ function hide_canvas() {
子域划分与限界上下文
正如第 06 讲中谈到,领域模型的绘制,不是将整个系统的领域对象都绘制在一张大图上,那样绘制很费劲,阅读也很费劲,不利于相互的交流。因此,领域建模就是将一个系统划分成了多个子域,每个子域都是一个独立的业务场景。围绕着这个业务场景进行分析建模,该业务场景会涉及许多领域对象,而这些领域对象又可能与其他子域的对象进行关联。这样,每个子域的实现就是“限界上下文”,而它们之间的关联关系就是“上下文地图”。
在本案例中,围绕着领域事件“已下单”进行分析。它属于“用户下单”这个限界上下文,但与之相关的“用户”及其“地址”来源于“用户注册”这个限界上下文,与之相关的“饭店”及其“菜单”来源于“饭店管理”这个限界上下文。因此,在这个业务场景中,“用户下单”限界上下文属于“主题域”,而“用户注册”与“饭店管理”限界上下文属于“支撑域”。同理,围绕着本案例的各个领域事件进行了如下一些设计:
-
+
“已下单”的限界上下文分析图
通过这样的设计,就能将“用户下单”限界上下文的范围,与之相关的上下文地图以及如何接口,分析清楚了。有了这些设计,就可以按照限界上下文进行微服务拆分。按照这样的设计拆分的微服务,所有与用户下单相关的需求变更都在“用户下单”微服务中实现。但是,订单在读取用户信息的时候,不是直接去 join 用户信息表,而是调用“用户注册”微服务的接口。这样,当用户信息发生变更时,与“用户下单”微服务无关,只需要在“用户注册”微服务中独立开发、独立升级,从而使系统维护的成本得到降低。
-
+
“已接单”与“已就绪”的限界上下文分析图
同样,如上图所示,我们围绕着“已接单”与“已就绪”的限界上下文进行了分析,并将它们都划分到“饭店接单”限界上下文中,后面就会设计成“饭店接单”微服务。这些场景的主题域就是“饭店接单”限界上下文,而与之相关的支撑域就是“用户注册”与“用户下单”限界上下文。通过这些设计,不仅合理划分了微服务的范围,也明确了微服务之间的接口,实现了微服务内的高内聚与微服务间的低耦合。
领域事件通知机制
按照 07 讲所讲到的领域模型设计,以及基于该模型的限界上下文划分,将整个系统划分为了“用户下单”“饭店接单”“骑士派送”等微服务。但是,在设计实现的时候,还有一个设计难题,即领域事件该如何通知。譬如,当用户在“用户下单”微服务中下单,那么会在该微服务中形成一个订单;但是,“饭店接单”是另外一个微服务,它必须要及时获得已下单的订单信息,才能执行接单。那么,如何通知“饭店接单”微服务已经有新的订单。诚然,可以让“饭店接单”微服务按照一定的周期不断地去查询“用户下单”微服务中已下单的订单信息。然而,这样的设计,不仅会加大“用户下单”与“饭店接单”微服务的系统负载,形成资源的浪费,还会带来这两个微服务之间的耦合,不利于之后的维护。因此,最有效的方式就是通过消息队列,实现领域事件在微服务间的通知。
-
-
+
+
在线订餐系统的领域事件通知
如上图所示,具体的设计就是,当“用户下单”微服务在完成下单并保存订单以后,将该订单做成一个消息发送到消息队列中;这时,“饭店接单”微服务就会有一个守护进程不断监听消息队列;一旦有消息就会触发接收消息,并向饭店发送“接收订单”的通知。在这样的设计中:
@@ -190,7 +190,7 @@ function hide_canvas() {
- 同时,按照领域模型设计各个微服务的数据库。
最后,将以上的设计最终落实到微服务之间的调用、领域事件的通知,以及前端微服务的设计。如下图所示:
-
+
在线订餐系统的微服务设计
这里可以看到,前端微服务与后端微服务的设计是不一致的。前面讲的都是后端微服务的设计,而前端微服务的设计与用户 UI 是密切关联的,因此通过不同角色的规划,将前端微服务划分为用户 App、饭店 Web 与骑士 App。在用户 App 中,所有面对用户的诸如“用户注册”“用户下单”“用户选购”等功能都设计在用户 App 中。它相当于一个聚合服务,用于接收用户请求:
diff --git a/专栏/DDD 微服务落地实战/09 DDD 是如何落地微服务设计实现的?.md.html b/专栏/DDD 微服务落地实战/09 DDD 是如何落地微服务设计实现的?.md.html
index 29ffd77a..b75ccdd7 100644
--- a/专栏/DDD 微服务落地实战/09 DDD 是如何落地微服务设计实现的?.md.html
+++ b/专栏/DDD 微服务落地实战/09 DDD 是如何落地微服务设计实现的?.md.html
@@ -156,7 +156,7 @@ function hide_canvas() {
- 然后将我们对业务的理解绘制成领域模型;
- 再通过领域模型指导数据库和程序的设计。
-
+
图 1 领域驱动设计的真谛
过去,我们认为软件就是,用户怎么提需求,软件就怎么开发。这种开发模式使得我们对需求的认知浅薄,不得不随着用户的需求变动反复地改来改去,导致我们很累而用户还不满意,软件研发风险巨大。
正是 DDD 改变了这一切,它要求我们更加**主动地去理解业务,掌握业务领域知识。**这样,我们对业务的理解越深刻,开发出来的产品就越专业,那么客户就越喜欢购买和使用我们的产品。
@@ -172,11 +172,11 @@ function hide_canvas() {
基于限界上下文的领域建模
回到 08 讲微服务设计部分,当在线订餐系统完成了事件风暴的分析以后,接着应当怎样设计呢?通过划分限界上下文,已经将系统划分为了“用户注册”“用户下单”“饭店接单”“骑士派送”与“饭店管理”等几个限界上下文,这样的划分也是后端微服务的划分。紧接着,就开始为每一个限界上下文进行领域建模。
首先,从“用户下单”上下文开始。通过业务领域分析,绘制出了如图 2 所示的领域模型,该模型的核心是“订单”,通过“订单”关联了用户与用户地址。一个订单有多个菜品明细,而每个菜品明细都对应了一个菜单,每个菜单隶属于一个饭店。此外,一个订单还关联了它的支付与发票。起初,它们的属性和方法没有那么全面,随着设计的不断深入,不断地细化与完善模型。
-
+
在这样的基础上开始划分限界上下文,用户与用户地址属于“用户注册”上下文,饭店与菜单属于“饭店管理”上下文。它们对于“用户下单”上下文来说都是支撑域,即给“用户下单”上下文提供接口调用的。真正属于“用户下单”上下文的,就只有订单、菜品明细、支付、发票这几个类,它们最终形成了“用户下单”微服务及其数据库设计。由于用户姓名、地址、电话等信息,都在“用户注册”上下文中,每次都需要远程接口调用来获得。这时就需要从系统优化的角度,适当将它们冗余到“订单”领域对象中,以提升查询效率。同样,“菜品名称”也进行了冗余,设计更新如图 3 所示:
-
+
完成了“用户下单”上下文以后,开始设计“饭店接单”上下文,设计如图 4 所示。上一讲谈到,“用户下单”微服务通过事件通知机制,将订单以消息的形式发送给“饭店接单”微服务。具体来说,就是将订单与菜品明细发送给“饭店接单”上下文。“饭店接单”上下文会将它们存储在自己的数据库中,并在此基础上增加“饭店接单”类,它与订单是一对一的关系。
-
+
同样的思路,通过领域事件通知“骑士派送”上下文,完成“骑士派送”的领域建模。
通过以上设计,就将上一讲的微服务拆分,进一步落实到每一个微服务的设计。紧接着,将每一个微服务的设计,按照第 03 讲的思路落实数据库设计,按照第 04 讲的思路落实贫血模型与充血模型的设计。
特别值得注意的是,订单与菜品明细是一对聚合。过去按照贫血模型的设计,分别为它们设计订单值对象、Service 与 Dao,菜品明细值对象、Service 与 Dao;现在按照充血模型的设计,只有订单领域对象、Service、仓库、工厂与菜品明细包含在订单对象中,而订单 Dao 被包含在订单仓库中。贫血模型与充血模型在设计上有明显的差别。关于聚合的实现,下一讲再详细探讨。
@@ -199,7 +199,7 @@ function hide_canvas() {
订单状态的跟踪
当用户下单后,往往会不断地跟踪订单状态是“已下单”“已接单”“已就绪”还是“已派送”。然而,这些状态信息被分散到了各个微服务中,就不可能在“用户下单”上下文中实现了。如何从这些微服务中采集订单的状态信息,又可以保持微服务间的松耦合呢?解决思路还是领域事件的通知。
通过消息队列,每个微服务在执行完某个领域事件的操作以后,就将领域事件封装成消息发送到消息队列中。比如,“用户下单”微服务在完成用户下单以后,将下单事件放到消息队列中。这样,不仅“饭店接单”微服务可以接收这个消息,完成后续的接单操作;而且“订单查询”微服务也可以接收这个消息,实现订单的跟踪。如图 5 所示。
-
+
图 5 订单状态的跟踪图
通过领域事件的通知与消息队列的设计,使微服务间调用的设计松耦合,“订单查询”微服务可以像外挂一样采集各种订单状态,同时不影响原有的微服务设计,使得微服务之间实现解耦,降低系统维护的成本。而“订单查询”微服务通过冗余,将“下单时间”“取消时间”“接单时间”“就绪时间”等订单在不同状态下的时间,以及其他相关信息,都保存到订单表中,甚至增加一个“订单状态”记录当前状态,并增加 Redis 缓存的功能。这样的设计就保障了订单跟踪查询的高效。要知道,面对大数据的高效查询,通常都是通过冗余来实现的。
总结
diff --git a/专栏/DDD 微服务落地实战/10 微服务落地的技术实践.md.html b/专栏/DDD 微服务落地实战/10 微服务落地的技术实践.md.html
index 106f971e..2b1dd0e1 100644
--- a/专栏/DDD 微服务落地实战/10 微服务落地的技术实践.md.html
+++ b/专栏/DDD 微服务落地实战/10 微服务落地的技术实践.md.html
@@ -160,9 +160,9 @@ function hide_canvas() {
上一讲谈到 DDD 落地微服务的分析设计过程,然后将这些设计最终落实到每个微服务的设计开发中。微服务的落地其实并没有那么简单,需要解决诸多设计与实现的技术难题,这一讲我们就来探讨一下吧。
如何发挥微服务的优势
微服务也不是银弹,它有很多的“坑”。开篇词中提到,当我们将一个庞大的业务系统拆分为一个个简单的微服务时,就是希望通过合理的微服务设计,尽量让每次的需求变更都交给某个小团队独立完成,让需求变更落到某个微服务上进行变更。唯有这样,每次变更只需独立地修改这个微服务,独立打包、独立升级,新需求就实现啦,才能发挥微服务的优势。
-
+
然而,过去很多系统都是这样设计的(如上图所示),多个模块都需要读取商品信息表,因此都通过 JDBC 直接读取。现在要转型微服务了,起初采用数据共享的微服务设计,就是数据库不变,然后简单粗暴地直接按照功能模块进行微服务拆分。这时,多个微服务都需要读取商品信息表,都通过 SQL 直接访问。这样的设计,一旦商品信息表发生变更,那么多个微服务都需要变更。这样的设计就使得微服务的变更与发布变得复杂,微服务的优势无法发挥。
-
+
通过前面 DDD 的指导,是希望做“小而专”的微服务设计。按照这样的思路设计微服务,对商品信息表的读写只有“商品维护”微服务。当其他微服务需要读写商品信息时,就不能直接读取商品信息表,而是通过 API 接口去调用“商品维护”微服务。这样,日后因商品信息变更而修改的代码就只限于“商品维护”微服务。只要“商品维护”微服务对外的 API 接口不变,这个变更则与其他微服务无关。只有这样的设计,才能真正发挥微服务的优势。
为了规范“小而专”的微服务设计,在微服务转型之初,先按照 DDD 对数据库表按照用户权限进行划分。每个微服务只能通过自己的账号访问自己的表。当需要访问其他的表时,只能通过接口访问相应的微服务。这样的划分,就为日后真正的数据库拆分做好了准备,微服务转型将更加平稳。
怎样提供微服务接口
@@ -177,7 +177,7 @@ function hide_canvas() {
- 接着,“用户接单Service”常常要查找用户表信息,但前面说了,它没有查询用户表权限,因为用户表在“用户注册”微服务中。这时,“用户接单 Service”通过同步调用“用户注册 Service”的相关接口。
具体设计实现上,就是在“用户接单”微服务的本地,增加一个“用户注册 Service”的 feign 接口。这样,“用户接单 Service”就像本地调用一样调用“用户注册 Service”,再通过这个 feign 接口实现远程调用。这样的设计叫作“防腐层”的设计。如下图所示:
-
+
微服务的拆分与防腐层的设计图
譬如,大家想象这样一个场景。过去,“用户注册 Service”是在“用户下单”微服务中的。后来,随着微服务设计的不断深入,需要将“用户注册 Service”拆分到另外一个微服务中。这时,“用户下单Service”与“取消订单 Service”,以及其他对“用户注册 Service”的调用都会报错,都需要修改,维护成本就很高。这时,在微服务的本地放一个“用户注册 Service”的 feign 接口,那么其他的 Service 都不需要修改了,维护成本将得以降低。这就是“防腐层”的作用,即接口变更时降低维护成本。
去中心化的数据管理
@@ -188,11 +188,11 @@ function hide_canvas() {
- 微服务“经营分析”与“订单查询”这样的查询分析业务,则选用 NoSQL 数据库或大数据平台,通过读写分离将生产库上的数据同步过来进行分布式存储,然后经过一系列的预处理,就能应对海量历史数据的决策分析与秒级查询。
基于以上这些设计,就能完美地应对互联网应用的高并发与大数据,有效提高系统性能。设计如下图所示:
-
+
在线订餐系统的去中心化数据管理图
数据关联查询的难题
此外,各个微服务在业务进行过程需要进行的各种查询,由于数据库的拆分,就不能像以前那样进行 join 操作了,而是通过接口调用的方式进行数据补填。比如“用户下单”“饭店接单”“骑士派送”等微服务,由于数据库的拆分,它们已经没有访问用户表与饭店表的权限,就不能像以往那样进行 join 操作了。这时,需要重构查询的过程。如下图所示:
-
+
查询的过程分为 2 个步骤。
- 查询订单数据,但不执行 join 操作。这样的查询结果可能有 1 万条,但通过翻页,返回给微服务的只是那一页的 20 条数据。
diff --git a/专栏/DDD 微服务落地实战/11 解决技术改造困局的钥匙:整洁架构.md.html b/专栏/DDD 微服务落地实战/11 解决技术改造困局的钥匙:整洁架构.md.html
index b9ecdf2a..ad390f4b 100644
--- a/专栏/DDD 微服务落地实战/11 解决技术改造困局的钥匙:整洁架构.md.html
+++ b/专栏/DDD 微服务落地实战/11 解决技术改造困局的钥匙:整洁架构.md.html
@@ -187,7 +187,7 @@ function hide_canvas() {
- 与其他外部系统的交互。
整洁架构的精华在于其中间的适配器层,它通过适配器将核心的业务代码,与外围的技术框架进行解耦。因此,如何设计适配层,让业务代码与技术框架解耦,让业务开发团队与技术架构团队各自独立地工作,成了整洁架构落地的核心。
-
+
整洁架构设计的细化图,图片来自《软件架构编年史》
如图,进一步细化整洁架构,将其划分为 2 个部分:主动适配器与被动适配器。
@@ -195,7 +195,7 @@ function hide_canvas() {
- 被动适配器,又称为“南向适配器”,就是在业务领域层完成各种业务处理以后,以某种形式持久化存储最终的结果数据。最终的数据可以存储到关系型数据库、NoSQL 数据库、NewSQL 数据库、Redis 缓存中,或者以消息队列的形式发送给其他应用系统。但不论采用什么形式,业务领域层只有一套,但持久化存储可以有各种不同形式。南向适配器将业务逻辑与存储技术解耦。
整洁架构的落地
-
+
按照整洁架构的思想如何落地架构设计呢?如上图所示,在这个架构中,将适配器层通过数据接入层、数据访问层与接口层等几个部分的设计,实现与业务的解耦。
首先,用户可以用浏览器、客户端、移动 App、微信端、物联网专用设备等不同的前端形式,多渠道地接入到系统中,不同的渠道的接入形式是不同的。通过数据接入层进行解耦,然后以同样的方式去调用上层业务代码,就能将前端的多渠道接入,与后台的业务逻辑实现了解耦。这样,前端不管怎么变,有多少种渠道形式,后台业务只需要编写一套,维护成本将大幅度降低。
接着,通过数据访问层将业务逻辑与数据库解耦。前面说了,在未来三五年时间里,我们又将经历一轮大数据转型。转型成大数据以后,数据存储的设计可能不再仅限于关系型数据库与 3NF的思路设计,而是通过 JSON、增加冗余、设计宽表等设计思路,将其存储到 NoSQL 数据库中,设计思想将发生巨大的转变。但无论怎么转变,都只是存储形式的转变,不变的是业务逻辑层中的业务实体。因此,通过数据访问层的解耦,今后系统向大数据转型的时候,业务逻辑层不需要做任何修改,只需要重新编写数据访问层的实现,就可以转型成大数据技术。转型成本将大大降低,转型将更加容易。
diff --git a/专栏/DDD 微服务落地实战/12 如何设计支持快速交付的技术中台战略?.md.html b/专栏/DDD 微服务落地实战/12 如何设计支持快速交付的技术中台战略?.md.html
index 96a8161f..389ac2af 100644
--- a/专栏/DDD 微服务落地实战/12 如何设计支持快速交付的技术中台战略?.md.html
+++ b/专栏/DDD 微服务落地实战/12 如何设计支持快速交付的技术中台战略?.md.html
@@ -159,21 +159,21 @@ function hide_canvas() {
清楚了这些概念,你就清楚了支持 DDD 与微服务的技术中台的设计思路。它是将 DDD 与微服务的底层技术进行封装,从而支持开发团队在未来实现快速交付,以应对激烈竞争的市场。因此,首先必须要清楚实现快速交付的技术痛点,才能清楚这个技术中台该如何建设。
打造快速交付团队
许多团队都有这样一个经历:项目初期,由于业务简单,参与的人少,往往可以获得一个较快的交付速度;但随着项目的不断推进,业务变得越来越复杂,参与的人越来越多,交付速度就变得越来越慢,使得团队越来越不能适应市场的快速变化,从而处于竞争的劣势。然而,软件规模化发展是所有软件发展的必然趋势。因此,解决规模化团队与软件快速交付的矛盾就成了我们不得不面对的难题。
-
+
烟囱式的开发团队
为什么团队越大交付速度越慢呢?如上图是我们从需求到交付的整个过程。在这个过程中,我们要经历多个部门的交互,才能完成最终的交付,大量的时间被耗费在部门间的沟通协调中。这样的团队被称为“烟囱式的开发团队”。
-
+
烟囱式的软件开发
烟囱式的开发团队又会导致烟囱式的软件开发。如上图所示,在大多数软件项目中,每个功能都要设计自己的页面、Controller、Service 以及 DAO,需要编写大量的代码,并且很多都是重复代码。代码写得越多 Bug 就越多,日后变更也越困难。
-
+
最后,统一的发布也制约了交付的速度。如上图,当业务负责人将需求分配给多个团队开发时,A 团队的工作可能只需要 1 周就能完成。但是,当 A 团队完成了他们的工作以后,能立即交付给客户吗?答案是不能,因为 B 团队需要开发 2 周,A 团队只能等 B 团队开发完成以后才能统一发布。统一的发布制约了系统的交付速度,即使 A 团队的开发速度再快,不能立即交付用户就不能产生用户价值。
随着系统规模越来越大,功能越来越多、越来越复杂,开发系统的团队规模也越来越大。这样就会导致开发团队的工作效率越来越低,交付周期越来越长,技术转型也越来越困难。
-
+
特性团队的组织形式
如何解决这一问题呢?如上图,首先,需要调整团队的组织架构,将筒状的架构竖过来,称为“特性团队”。在特性团队中,每个团队都直接面对终端客户。比如购物团队面对的是购物功能,所有与购物相关的功能都是他们来负责完成,包括从需求到研发,从 UI 到应用再到数据库。最后,经过测试,也是这个团队负责上线部署。这样,整个交付过程都是这个团队负责,没有了那么多团队间的沟通协调,交付速度自然就提升了。
大前端+技术中台
有了特性团队的组织形式,如果还是统一发布,那么交付速度依然提升不了。因此,在特性团队的基础上,软件架构采用了微服务的架构,即每个特性团队各自维护各自的微服务。这样,当该团队完成了一次开发,则自己独立打包、独立发布,不再需要等待其他团队。这样,交付速度就可以得到大幅度提升。如下图所示:
-
+
大前端 + 技术中台的组织形式
特性团队 + 微服务架构,可以有效地提高规模化团队的交付速度。然而,仔细思考一下就会惊奇地发现,要这样组建一个特性团队,成本是非常高昂的。团队每个成员都必须既要懂业务,也要懂开发;既要懂 UI、应用,还要懂数据库,甚至大数据,做全栈工程师。如果每个特性团队都是这样组建,每个成员都是全栈工程师,成本过高,是没有办法真正落地的。那么,这个问题该怎么解决呢?
解决问题的关键在于底层的架构团队。这里的架构团队就不再是架构师一个人,而是一个团队。
@@ -197,12 +197,12 @@ function hide_canvas() {
- 你写的代码越少,Bug 就越少,日后维护与变更就越容易。
俗话说:小船好掉头,泰坦尼克号看见冰山了为什么要撞上去?因为它实在太大了,根本来不及掉头。写代码也是一样的,一段 10 来行的代码变更会很容易,但一段数百上千行的代码变更就非常复杂。因此,我们设计软件应当秉承这样的态度:宁愿花更多的时间去分析设计,让软件设计精简到极致,从而花更少的时间去编码。俗话说:磨刀不误砍柴工。用这样的态度编写出来的代码,既快又易于维护。
-
+
接着,看一看在以往软件研发过程中存在的问题。以往的软件项目在研发的过程中需要编写太多的代码了,每个功能都要编写自己的 UI、Controller、Service 和 DAO。并且,在每一个层次中都有不同格式的数据,因此我们编写的大量代码都是在进行各个层次之间的数据格式转换。如下图所示:
-
+
譬如,前端以 Form 的形式传输到后台,这时后台由 MVC 层从 Model 或者 Request 中获得,然后将其转换成值对象,接着去调用 Service。然而,从 Model 或者 Request 中获得数据以后,由于我们在 MVC 层的 Controller 中写了太多的判断与操作,再将其塞入值对象中,所以这里耗费了太多的代码。
接着,在 Service 中经过各种业务操作,最后要存盘的时候,又要将 VO 转换为 PO,将数据持久化存储到数据库中。这时,又要为每一个功能编写一个 DAO。我们写的代码越多,日后维护与变更就越困难。那么,能不能将这些转换统一成公用代码下沉到技术中台中呢?基于这样的思想,系统架构调整为这样:
-
+
在这个架构中,将各个层次的数据都统一成值对象,这是怎样统一的呢?首先,在前端的数据,现在越来越多的前端框架都是以 JSON 的形式传递的。JSON 的数据格式实际上是一种名 - 值对。因此,可以制订一个开发规范,要求前端 JSON 对象的设计,与后台值对象的格式一一对应。这样,当 JSON 对象传递到后台后,MVC 层就只需要一个通用的程序,以统一的形式将 JSON 对象转换为值对象。这样,还需要为每个功能编写 Controller 吗?不用了,整个系统只需要一个 Controller,并将其下沉到技术中台中。
同样,Service 在经过了一系列的业务操作,最后要存盘的时候,可以这样做:制作一个vObj.xml 的配置文件来建立对应关系,将每个值对象都对应数据库中的一个表,哪个属性就对应哪个字段。这样,DAO 拿到哪个值对象,就知道该对象中的数据应当保存到数据库的哪张表中。这时,还需要为每个功能编写一个 DAO 吗?不用了,整个系统只需要一个 DAO。
通过以上的设计思想架构的系统,开发工作量将极大地降低。在业务开发时,每个功能都不用再编写 MVC 层了,就不会将业务代码写到 Controller 中,而是规范地将业务代码编写到 Service或值对象中。接着,整个系统只有一个 DAO,每个功能的 Service 注入的都是这一个 DAO。这样,真正需要业务开发人员编写的仅限于前端 UI、Service 和值对象。而 Service 和值对象都是源于领域模型的映射,因此业务开发人员就会将更多的精力用于功能设计与前端 UI,给用户更好的用户体验,也提高了交付速度。
diff --git a/专栏/DDD 微服务落地实战/13 如何实现支持快速交付的技术中台设计?.md.html b/专栏/DDD 微服务落地实战/13 如何实现支持快速交付的技术中台设计?.md.html
index b0697e8f..45099460 100644
--- a/专栏/DDD 微服务落地实战/13 如何实现支持快速交付的技术中台设计?.md.html
+++ b/专栏/DDD 微服务落地实战/13 如何实现支持快速交付的技术中台设计?.md.html
@@ -157,9 +157,9 @@ function hide_canvas() {
- 所有的查询功能则不适用于领域驱动设计,而应当采用事务脚本模式(Transaction Script),即直接通过 SQL 语句进行查询。
遵循该设计模式,是我们在许多软件项目中总结出来的最佳实践。因此,技术中台在建设时,对业务系统的支持也分为增删改与查询两个部分。
-
+
增删改的架构设计
-
+
增删改部分的技术中台架构设计
在增删改部分中,采用了前面提到的单 Controller、单 Dao 的架构设计。如上图所示,各功能都有各自的前端 UI。但与以往架构不同的是,每个功能的前端 UI 对后台请求时,不再调用各自的 Controller,而是统一调用一个 Controller。然而,每个功能的前端在调用这一个 Controller 时,传递的参数是不一样的。首先从前端传递的是 bean,这个 bean 是什么呢?后台各功能都有一个 Service,将该 Service 注入 Dao 以后,会在 Spring 框架中配置成一个bean。这时,前端只知道调用的是这个 bean,但不知道它是哪个 Service。
这样的设计,既保障了安全性(前端不知道具体是哪个类),又有效地实现了前后端分离,将前端代码与后端解耦。
@@ -244,7 +244,7 @@ function hide_canvas() {
4.通过 SQL 语句执行数据库操作。
查询功能的架构设计
接着,是查询功能的技术中台设计,如图所示:
-
+
查询功能的技术中台架构设计
与增删改部分一样的是,查询功能中,每个功能的前端 UI 也是统一调用一个 Controller。但与增删改的部分不一样的是,查询功能的前端 UI 传递的参数不同,因此是另一个类 QueryController。
在调用时,首先需要传递的还是 bean。但与增删改不同的是,查询功能的 Service 只有一个,那就是 QueryService。但是,该 Service 在 Spring 中配置的时候,往 Service 中注入的是不同的 Dao,就可以装配成各种不同的 bean。这样,前端调用的是不同的 bean,最后执行的就是不同的查询。
diff --git a/专栏/DDD 微服务落地实战/14 如何设计支持 DDD 的技术中台?.md.html b/专栏/DDD 微服务落地实战/14 如何设计支持 DDD 的技术中台?.md.html
index bebdfa34..a6dc47b8 100644
--- a/专栏/DDD 微服务落地实战/14 如何设计支持 DDD 的技术中台?.md.html
+++ b/专栏/DDD 微服务落地实战/14 如何设计支持 DDD 的技术中台?.md.html
@@ -155,7 +155,7 @@ function hide_canvas() {
因此,还需要有一个强有力的技术中台的支持,来简化 DDD 的设计实现,解决“最后一公里”的问题。唯有这样,DDD 才能在项目中真正落地。
传统 DDD 的架构设计
-
+
通常,在支持领域驱动的软件项目中,架构设计如上图所示。
- 展现层是前端的 UI,它通过网络与后台的应用层交互。
@@ -164,7 +164,7 @@ function hide_canvas() {
- 最后,通过仓库将领域对象中的数据持久化到数据库;使用工厂将数据从数据库中读取、拼装并还原成领域对象。
这些都是将领域驱动落地到软件设计时所采用的方式。从架构分层上说,DDD 的仓库和工厂的设计介于业务领域层与基础设施层之间,即接口在业务领域层,而实现在基础设施层。DDD 的基础设施层相当于支撑 DDD 的基础技术架构,通过各种技术框架支持软件系统完成除了领域驱动以外的各种功能。
-
+
然而,传统的软件系统采用 DDD 进行架构设计时,需要在各个层次之间进行各种数据结构的转换:
- 首先,前端的数据结构是 JSON,传递到后台数据接入层时需要将其转换为数据传输对象DTO;
@@ -172,10 +172,10 @@ function hide_canvas() {
- 最后,将数据持久化到数据库时,又要将 DO 转换为持久化对象 PO。
在这个过程中,需要编写大量代码进行数据的转换,无疑将加大软件开发的工作量与日后变更的维护成本。因此,我们可不可以考虑上一讲所提到的设计,将各个层次的数据结构统一起来呢?
-
+
另外,传统的软件系统在采用 DDD 进行架构设计时,需要为每一个功能模块编写各自的仓库与工厂,如订单模块有订单仓库与订单工厂、库存模块有库存仓库与库存工厂。各个模块在编写仓库与工厂时,虽然实现了各自不同的业务,却形成了大量重复的代码。这样的问题与前面探讨的 Dao 的问题一样,是否可以通过配置与建模,设计成一个统一的仓库与工厂。如果是这样,那么仓库与工厂又与 Dao 是什么关系呢?基于对以上问题的思考,我提出了统一数据建模、内置聚合的实现、通用仓库和工厂,来简化 DDD 业务开发。因此,进行了如下的架构设计。
通用仓库与通用工厂的设计
-
+
该设计与上一讲的架构设计相比,差别仅是将单 Dao 替换为了通用仓库与通用工厂。也就是说,与 Dao 相比,DDD 的仓库就是在 Dao 的基础上扩展了一些新的功能。
- 例如在装载或查询订单时,不仅要查询订单表,还要补填与订单相关的订单明细与客户信息、商品信息,并装配成一个订单对象。在这个过程中,查询订单是 Dao 的功能,但其他类似补填、装配等操作,则是仓库在 Dao 基础上进行的功能扩展。
@@ -183,7 +183,7 @@ function hide_canvas() {
这就是 DDD 的仓库与 Dao 的关系。
基于这种扩展关系,该如何设计这个通用仓库呢?如果熟悉设计模式,则会想到“装饰者模式”。“装饰者模式”的目的,就是在原有功能的基础上进行“透明功能扩展”。这种“透明功能扩展”,既可以扩展原有功能,又不影响原有的客户程序,使客户程序不用修改任何代码就能实现新功能,从而降低变更的维护成本。因此,将“通用仓库”设计成了这样。
-
+
即在原有的 BasicDao 与 BasicDaoImpl 的基础上,增加了通用仓库 Repository。将 Repository 设计成装饰者,它也是接口 BasicDao 的实现类,是通过一个属性变量引用的 BasicDao。使用时,在 BasicDaoImpl 的基础上包一个 Repository,就可以扩展出那些 DDD 的功能。因此,所有的 Service 在注入 Dao 的时候:
- 如果不使用 DDD,则像以前一样注入BasicDaoImpl;
diff --git a/专栏/DDD 微服务落地实战/15 如何设计支持微服务的技术中台?.md.html b/专栏/DDD 微服务落地实战/15 如何设计支持微服务的技术中台?.md.html
index 36b3a69d..975d7cc9 100644
--- a/专栏/DDD 微服务落地实战/15 如何设计支持微服务的技术中台?.md.html
+++ b/专栏/DDD 微服务落地实战/15 如何设计支持微服务的技术中台?.md.html
@@ -156,7 +156,7 @@ function hide_canvas() {
解决技术不确定性的问题
如今的微服务架构,基本已经形成了 Spring Cloud 一统天下的局势。然而,在 Spring Cloud 框架下的各种技术组件依然存在诸多不确定性,如:注册中心是否采用 Eureka、服务网关是采用 Zuul 还是 Gateway,等等。同时,服务网格 Service Mesh 方兴未艾,不排除今后所有的微服务都要切换到 Service Mesh 的可能。在这种情况下如何决策微服务的技术架构?代码尽量不要与 Spring Cloud 耦合,才能在将来更容易地切换到 Service Mesh。那么,具体又该如何做到呢?
-
+
单 Controller、单 Dao 的设计在微服务架构的应用
如上图所示,当前端通过服务网关访问微服务时,首先要访问聚合层的微服务。这时,在聚合层的微服务中,采用单 Controller 接收前端请求。这样,只有该 Controller 与 MVC 框架耦合,后面所有的 Service 不会耦合,从而实现了业务代码与技术框架的分离。
同样的,当 Service 执行各种操作调用原子服务层的微服务时,不是通过 Ribbon 进行远程调用,而是将原子服务层的微服务开放的接口,在聚合层微服务的本地编写一个 Feign 接口。那么,聚合层微服务在调用原子微服务时,实际调用的是自己本地的接口,再由这个接口通过加载 Feign 注解,去实现远程调用。
@@ -171,11 +171,11 @@ function hide_canvas() {
采用 Feign 接口实现远程调用
每个微服务都是一个独立的进程,运行在各自独立的 JVM,甚至不同的物理节点上,通过网络访问。因此,微服务与微服务之间的调用必然是远程调用。以往,我们对微服务间的调用采用 Ribbon 的方式,在程序中的任意一个位置,只要注入一个 restTemplate,就可以进行远程调用。
这样的代码过于随意,会越来越难于阅读与变更维护。比如,原来某个微服务中有两个模块 A 与 B,都需要调用模块 C。随着业务变得越来越复杂,需要进行微服务拆分,将模块 C 拆分到了另外一个微服务中。这时,原来的模块 A 与 B 就不能像原来一样调用模块 C,否则就会报错。
-
+
Ribbon 的远程调用方式
如何解决以上问题呢?需要同时改造模块 A 与 B,分别加入 restTemplate 实现远程调用,来调用模块 C。也就是说,这时所有调用模块 C 的程序都需要改造,改造的成本与风险就会比较高。
因此,在实现微服务间调用时,我们通常会采用另外一个方案:Feign。Feign 不是另起炉灶,而是对 Ribbon 的封装,目的是使代码更加规范、变更更加易于维护。采用的方案是,不修改模块 A 与 B 的任何代码,而是在该微服务的本地再制作一个模块 C 的接口 C′。该接口与模块 C 一模一样,拥有模块 C 的所有方法,因此模块 A 与 B 还可以像以前一样在本地调用接口 C′。但接口 C′ 只是一个接口,什么都做不了,因此需要通过添加 Feign 的注解,实现远程调用,去调用模块 C。这个方案,既没有修改模块 A 与 B,又没有修改模块 C,而仅仅添加了一个接口 C′,使维护成本降到了最低。
-
+
Feign 的远程调用方式
如何通过 Feign 实现微服务的远程调用呢?
首先,创建项目时,在 POM.xml 文件中添加 Eureka Client、Hystrix 与 Actuator 等组件以外,将 ribbon 改为 feign:
diff --git a/专栏/DDD 微服务落地实战/17 基于 DDD 的微服务设计演示(含支持微服务的 DDD 技术中台设计).md.html b/专栏/DDD 微服务落地实战/17 基于 DDD 的微服务设计演示(含支持微服务的 DDD 技术中台设计).md.html
index 398dff20..d0dd59f3 100644
--- a/专栏/DDD 微服务落地实战/17 基于 DDD 的微服务设计演示(含支持微服务的 DDD 技术中台设计).md.html
+++ b/专栏/DDD 微服务落地实战/17 基于 DDD 的微服务设计演示(含支持微服务的 DDD 技术中台设计).md.html
@@ -279,7 +279,7 @@ function hide_canvas() {
传统的 DDD 设计,每个模块都有自己的仓库与工厂,工厂是领域对象创建与装配的地方,是生命周期的开始。创建出来后放到仓库的缓存中,供上层应用访问。当领域对象在经过一系列操作以后,最后通过仓库完成数据的持久化。这个领域对象数据持久化的过程,对于普通领域对象来说就是存入某个单表,然而对于有聚合关系的领域对象来说,需要存入多个表中,并将其放到同一事务中。
在这个过程中,聚合关系会出现跨库的事务操作吗?即具有聚合关系的多个领域对象会被拆分为多个微服务吗?我认为是不可能的,因为聚合就是一种强相关的封装,是不可能因微服务而拆分的。如果出现了,要么不是聚合关系,要么就是微服务设计出现了问题。因此,仓库是不可能完成跨库的事务处理的。
弄清楚了传统的 DDD 设计,与以往 Dao 的设计进行比较,就会发现仓库和工厂就是对 Dao 的替换。然而,这种替换不是简单的替换,它们对 Dao 替换的同时,还扩展了许多的功能,如数据的补填、领域对象的映射与装配、聚合的处理,等等。当我们把这些关系思考清楚了,通用仓库与工厂的设计就出来了。
-
+
如上图所示,仓库就是一个 Dao,它实现了 BasicDao 的接口。然而,仓库在读写数据库时,是把 BasicDao 实现类的代码重新 copy 一遍吗?不!那样只会形成大量重复代码,不利于日后的变更与维护。因此,仓库通过一个属性变量将 BasicDao 包在里面。这样,当仓库要读写数据库时,实际上调用的是 BasicDao 实现类,仓库仅仅实现在 BasicDao 实现类基础上扩展的那些功能。这样,仓库与 BasicDao 实现类彼此之间的职责与边界就划分清楚了。
有了这样的设计,原有的遗留系统要通过改造转型为 DDD,除了通过领域建模增加 vObj.xml以外,将原来注入 Dao 改为注入仓库,就可以快速完成领域驱动的转型。同样的道理,要在仓库中增加缓存的功能,不是直接去修改仓库,而是在仓库的基础上包一个RepositoryWithCache,专心实现缓存的功能。这样设计,既使各个类的职责划分非常清楚,日后因哪种缘由变更就改哪个类,又使得系统设计松耦合,可以通过组件装配满足各种需求。
总结
diff --git a/专栏/DDD 微服务落地实战/18 基于事件溯源的设计开发.md.html b/专栏/DDD 微服务落地实战/18 基于事件溯源的设计开发.md.html
index e9ce64e2..dc6178b1 100644
--- a/专栏/DDD 微服务落地实战/18 基于事件溯源的设计开发.md.html
+++ b/专栏/DDD 微服务落地实战/18 基于事件溯源的设计开发.md.html
@@ -193,7 +193,7 @@ function hide_canvas() {
基于消息的领域事件发布
前面讲解了领域溯源的设计思路,最后要落地到项目实践中,依然需要技术中台的相应支持。譬如,业务系统的发布者只负责事件的发布,订阅者只负责事件的后续操作。但这个过程该如何发布事件呢?发布事件到底要做什么呢?又如何实现事件的订阅呢?这就需要下沉到技术中台去设计。
首先,事件的发布方在发布事件的同时,需要在数据库中予以记录。数据库可以进行如下设计:
-
+
接着,领域事件还需要通过消息队列进行发布,这里可以采用 Spring Cloud Stream 的设计方案。Spring Cloud Stream 是 Spring Cloud 技术框架中一个实现消息驱动的技术框架。它的底层可以支持 RabbitMQ、Kafka 等主流消息队列,通过它的封装实现统一的设计编码。
譬如,以 RabbitMQ 为例,首先需要在项目的 POM.xml 中加入依赖:
<dependencies>
diff --git a/专栏/DDD实战课/01 领域驱动设计:微服务设计为什么要选择DDD.md.html b/专栏/DDD实战课/01 领域驱动设计:微服务设计为什么要选择DDD.md.html
index a9034093..39b9bcd6 100644
--- a/专栏/DDD实战课/01 领域驱动设计:微服务设计为什么要选择DDD.md.html
+++ b/专栏/DDD实战课/01 领域驱动设计:微服务设计为什么要选择DDD.md.html
@@ -189,7 +189,7 @@ function hide_canvas() {
软件架构模式的演进
在进入今天的主题之前,我们先来了解下背景。
我们知道,这些年来随着设备和新技术的发展,软件的架构模式发生了很大的变化。软件架构模式大体来说经历了从单机、集中式到分布式微服务架构三个阶段的演进。随着分布式技术的快速兴起,我们已经进入到了微服务架构时代。
-
+
我们可以用三步来划定领域模型和微服务的边界。
第一步:在事件风暴中梳理业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出领域实体等领域对象。
第二步:根据领域实体之间的业务关联性,将业务紧密相关的实体进行组合形成聚合,同时确定聚合中的聚合根、值对象和实体。在这个图里,聚合之间的边界是第一层边界,它们在同一个微服务实例中运行,这个边界是逻辑边界,所以用虚线表示。
diff --git a/专栏/DDD实战课/02 领域、子域、核心域、通用域和支撑域:傻傻分不清?.md.html b/专栏/DDD实战课/02 领域、子域、核心域、通用域和支撑域:傻傻分不清?.md.html
index f623f778..fcd1709f 100644
--- a/专栏/DDD实战课/02 领域、子域、核心域、通用域和支撑域:傻傻分不清?.md.html
+++ b/专栏/DDD实战课/02 领域、子域、核心域、通用域和支撑域:傻傻分不清?.md.html
@@ -194,7 +194,7 @@ function hide_canvas() {
领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。
我们知道,DDD 是一种处理高度复杂领域的设计思想,它试图分离技术实现的复杂度。那么面对错综复杂的业务领域,DDD 是如何使业务从复杂变得简单,更容易让人理解,技术实现更容易呢?
其实很好理解,DDD 的研究方法与自然科学的研究方法类似。当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题一步一步地细分,再针对细分出来的问题域,逐个深入研究,探索和建立所有子域的知识体系。当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。
-
+
我们来看一下上面这张图。这个例子是在讲如何给桃树建立一个完整的生物学知识体系。初中生物课其实早就告诉我们研究方法了。它的研究过程是这样的。
第一步:确定研究对象,即研究领域,这里是一棵桃树。
第二步:对研究对象进行细分,将桃树细分为器官,器官又分为营养器官和生殖器官两种。其中营养器官包括根、茎和叶,生殖器官包括花、果实和种子。桃树的知识体系是我们已经确定要研究的问题域,对应 DDD 的领域。根、茎、叶、花、果实和种子等器官则是细分后的问题子域。这个过程就是 DDD 将领域细分为多个子域的过程。
diff --git a/专栏/Dubbo源码解读与实战-完/00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力.md.html b/专栏/Dubbo源码解读与实战-完/00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力.md.html
index 8a723ffd..70d8cfcb 100644
--- a/专栏/Dubbo源码解读与实战-完/00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力.md.html
+++ b/专栏/Dubbo源码解读与实战-完/00 开篇词 深入掌握 Dubbo 原理与实现,提升你的职场竞争力.md.html
@@ -295,11 +295,11 @@ function hide_canvas() {
为什么要学习 Dubbo
我们在谈论任何一项技术的时候,都需要强调它所适用的业务场景,因为: 技术之所以有价值,就是因为它解决了一些业务场景难题。
一家公司由小做大,业务会不断发展,随之而来的是 DAU、订单量、数据量的不断增长,用来支撑业务的系统复杂度也会不断提高,模块之间的依赖关系也会日益复杂。这时候我们一般会从单体架构进入集群架构(如下图所示),在集群架构中通过负载均衡技术,将流量尽可能均摊到集群中的每台机器上,以此克服单台机器硬件资源的限制,做到横向扩展。
-
+
单体架构 VS 集群架构
之后,又由于业务系统本身的实现较为复杂、扩展性较差、性能也有上限,代码和功能的复用能力较弱,我们会将一个巨型业务系统拆分成多个微服务,根据不同服务对资源的不同要求,选择更合理的硬件资源。例如,有些流量较小的服务只需要几台机器构成的集群即可,而核心业务则需要成百上千的机器来支持,这样就可以最大化系统资源的利用率。
另外一个好处是,可以在服务维度进行重用,在需要某个服务的时候,直接接入即可,从而提高开发效率。拆分成独立的服务之后(如下图所示),整个服务可以最大化地实现重用,也可以更加灵活地扩展。
-
+
微服务架构图
但是在微服务架构落地的过程中,我们需要解决的问题有很多,如:
@@ -318,15 +318,15 @@ function hide_canvas() {
简单地说, Dubbo 是一个分布式服务框架,致力于提供高性能、透明化的 RPC 远程服务调用方案以及服务治理方案,以帮助我们解决微服务架构落地时的问题。
Dubbo 是由阿里开源,后来加入了 Apache 基金会,目前已经从孵化器毕业,成为 Apache 的顶级项目。Apache Dubbo 目前已经有接近 32.8 K 的 Star、21.4 K 的 Fork,其热度可见一斑, 很多互联网大厂(如阿里、滴滴、去哪儿网等)都是直接使用 Dubbo 作为其 RPC 框架,也有些大厂会基于 Dubbo 进行二次开发实现自己的 RPC 框架 ,如当当网的 DubboX。
作为一名 Java 工程师,深入掌握 Dubbo 的原理和实现已经是大势所趋,并且成为你职场竞争力的关键项。拉勾网显示,研发工程师、架构师等高薪岗位,都要求你熟悉并曾经深入使用某种 RPC 框架,一线大厂更是要求你至少深入了解一款 RPC 框架的原理和核心实现。
-
-
-
+
+
+
(职位信息来源:拉勾网)
而 Dubbo 就是首选。Dubbo 和 Spring Cloud 是目前主流的微服务框架,阿里、京东、小米、携程、去哪儿网等互联网公司的基础设施早已落成,并且后续的很多项目还是以 Dubbo 为主。Dubbo 重启之后,已经开始规划 3.0 版本,相信后面还会有更加惊艳的表现。
另外,RPC 框架的核心原理和设计都是相通的,阅读过 Dubbo 源码之后,你再去了解其他 RPC 框架的代码,就是一件非常简单的事情了。
阅读 Dubbo 源码的痛点
学习和掌握一项技能的时候,一般都是按照“是什么”“怎么用”“为什么”(原理)逐层深入的:
-
+
同样,你可以通过阅读官方文档或是几篇介绍性的文章,迅速了解 Dubbo 是什么;接下来,再去上手,用 Dubbo 写几个项目,从而更加全面地熟悉 Dubbo 的使用方式和特性,成为一名“熟练工”,但这也是很多开发者所处的阶段。而“有技术追求”的开发者,一般不会满足于每天只是写写业务代码,而是会开始研究 Dubbo 的源码实现以及底层原理,这就对应了上图中的核心层:“原理”。
而开始阅读源码时,不少开发者会提前去网上查找资料,或者直接埋头钻研源码,并因为这样的学习路径而普遍面临一些痛点问题:
diff --git a/专栏/Dubbo源码解读与实战-完/01 Dubbo 源码环境搭建:千里之行,始于足下.md.html b/专栏/Dubbo源码解读与实战-完/01 Dubbo 源码环境搭建:千里之行,始于足下.md.html
index 4e9744e5..de708995 100644
--- a/专栏/Dubbo源码解读与实战-完/01 Dubbo 源码环境搭建:千里之行,始于足下.md.html
+++ b/专栏/Dubbo源码解读与实战-完/01 Dubbo 源码环境搭建:千里之行,始于足下.md.html
@@ -300,7 +300,7 @@ function hide_canvas() {
Dubbo 架构简介
为便于你更好理解和学习,在开始搭建 Dubbo 源码环境之前,我们先来简单介绍一下 Dubbo 架构中的核心角色,帮助你简单回顾一下 Dubbo 的架构,也帮助不熟悉 Dubbo 的小伙伴快速了解 Dubbo。下图展示了 Dubbo 核心架构:
-
+
Dubbo 核心架构图
- Registry:注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。
@@ -324,38 +324,38 @@ function hide_canvas() {
然后,在 IDEA 中导入源码,因为这个导入过程中会下载所需的依赖包,所以会耗费点时间。
Dubbo源码核心模块
在 IDEA 成功导入 Dubbo 源码之后,你看到的项目结构如下图所示:
-
+
下面我们就来简单介绍一下这些核心模块的功能,至于详细分析,在后面的课时中我们还会继续讲解。
- dubbo-common 模块: Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,例如课程后面紧接着要介绍的 Dubbo SPI 实现、时间轮实现、动态编译器等。
-
+
- dubbo-remoting 模块: Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念,在其他子模块中依赖其他开源组件进行实现,例如,dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信,dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。
-
+
- dubbo-rpc 模块: Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象,其他子模块是针对具体协议的实现,例如,dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。
-
+
- dubbo-cluster 模块: Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider,这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。
- dubbo-registry 模块: Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象,其他子模块是针对具体开源注册中心组件的具体实现,例如,dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。
-
+
- dubbo-monitor 模块: Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。
- dubbo-config 模块: Dubbo 对外暴露的配置都是由该模块进行解析的。例如,dubbo-config-api 子模块负责处理 API 方式使用时的相关配置,dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。
-
+
- dubbo-metadata 模块: Dubbo 的元数据模块(本课程后续会详细介绍元数据的内容)。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。
-
+
- dubbo-configcenter 模块: Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。
-
+
Dubbo 源码中的 Demo 示例
在 Dubbo 源码中我们可以看到一个 dubbo-demo 模块,共包括三个非常基础 的 Dubbo 示例项目,分别是: 使用 XML 配置的 Demo 示例、使用注解配置的 Demo 示例 以及 直接使用 API 的 Demo 示例 。下面我们将从这三个示例的角度,简单介绍 Dubbo 的基本使用。同时,这三个项目也将作为后续 Debug Dubbo 源码的入口,我们会根据需要在其之上进行修改 。不过在这儿之前,你需要先启动 ZooKeeper 作为注册中心,然后编写一个业务接口作为 Provider 和 Consumer 的公约。
启动 ZooKeeper
@@ -378,7 +378,7 @@ Starting zookeeper ... STARTED # 启动成功
- Consumer ,如何使用服务、使用的服务名称是什么、需要传入什么参数、会得到什么响应。
dubbo-demo-interface 模块就是定义业务接口的地方,如下图所示:
-
+
其中,DemoService 接口中定义了两个方法:
public interface DemoService {
String sayHello(String name); // 同步调用
@@ -391,7 +391,7 @@ Starting zookeeper ... STARTED # 启动成功
Demo 1:基于 XML 配置
在 dubbo-demo 模块下的 dubbo-demo-xml 模块,提供了基于 Spring XML 的 Provider 和 Consumer。
我们先来看 dubbo-demo-xml-provider 模块,其结构如下图所示:
-
+
在其 pom.xml 中除了一堆 dubbo 的依赖之外,还有依赖了 DemoService 这个公共接口:
<dependency>
<groupId>org.apache.dubbo</groupId>
@@ -412,7 +412,7 @@ Starting zookeeper ... STARTED # 启动成功
最后,在 Application 中写个 main() 方法,指定 Spring 配置文件并启动 ClassPathXmlApplicationContext 即可。
接下来再看 dubbo-demo-xml-consumer 模块,结构如下图所示:
-
+
在 pom.xml 中同样依赖了 dubbo-demo-interface 这个公共模块。
在 dubbo-consumer.xml 配置文件中,会指定注册中心地址(就是前面 ZooKeeper 的地址),这样 Dubbo 才能从 ZooKeeper 中拉取到 Provider 暴露的服务列表信息:
<!-- Zookeeper地址 -->
diff --git a/专栏/Dubbo源码解读与实战-完/02 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo.md.html b/专栏/Dubbo源码解读与实战-完/02 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo.md.html
index 49070682..17b38c7d 100644
--- a/专栏/Dubbo源码解读与实战-完/02 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo.md.html
+++ b/专栏/Dubbo源码解读与实战-完/02 Dubbo 的配置总线:抓住 URL,就理解了半个 Dubbo.md.html
@@ -387,19 +387,19 @@ function hide_canvas() {
}
我们会看到,在生成的 RegistryFactory$Adaptive 类中会自动实现 getRegistry() 方法,其中会根据 URL 的 Protocol 确定扩展名称,从而确定使用的具体扩展实现类。我们可以找到 RegistryProtocol 这个类,并在其 getRegistry() 方法中打一个断点, Debug 启动上一课时介绍的任意一个 Demo 示例中的 Provider,得到如下图所示的内容:
-
+
这里传入的 registryUrl 值为:
zookeeper://127.0.0.1:2181/org.apache.dubbo...
那么在 RegistryFactory$Adaptive 中得到的扩展名称为 zookeeper,此次使用的 Registry 扩展实现类就是 ZookeeperRegistryFactory。至于 Dubbo SPI 的完整内容,我们将在下一课时详细介绍,这里就不再展开了。
2. URL 在服务暴露中的应用
我们再来看另一个与 URL 相关的示例。上一课时我们在介绍 Dubbo 的简化架构时提到,Provider 在启动时,会将自身暴露的服务注册到 ZooKeeper 上,具体是注册哪些信息到 ZooKeeper 上呢?我们来看 ZookeeperRegistry.doRegister() 方法,在其中打个断点,然后 Debug 启动 Provider,会得到下图:
-
+
传入的 URL 中包含了 Provider 的地址(172.18.112.15:20880)、暴露的接口(org.apache.dubbo.demo.DemoService)等信息, toUrlPath() 方法会根据传入的 URL 参数确定在 ZooKeeper 上创建的节点路径,还会通过 URL 中的 dynamic 参数值确定创建的 ZNode 是临时节点还是持久节点。
3. URL 在服务订阅中的应用
Consumer 启动后会向注册中心进行订阅操作,并监听自己关注的 Provider。那 Consumer 是如何告诉注册中心自己关注哪些 Provider 呢?
我们来看 ZookeeperRegistry 这个实现类,它是由上面的 ZookeeperRegistryFactory 工厂类创建的 Registry 接口实现,其中的 doSubscribe() 方法是订阅操作的核心实现,在第 175 行打一个断点,并 Debug 启动 Demo 中 Consumer,会得到下图所示的内容:
-
+
我们看到传入的 URL 参数如下:
consumer://...?application=dubbo-demo-api-consumer&category=providers,configurators,routers&interface=org.apache.dubbo.demo.DemoService...
diff --git a/专栏/Dubbo源码解读与实战-完/03 Dubbo SPI 精析,接口实现两极反转(上).md.html b/专栏/Dubbo源码解读与实战-完/03 Dubbo SPI 精析,接口实现两极反转(上).md.html
index 57ae5592..140cb447 100644
--- a/专栏/Dubbo源码解读与实战-完/03 Dubbo SPI 精析,接口实现两极反转(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/03 Dubbo SPI 精析,接口实现两极反转(上).md.html
@@ -297,7 +297,7 @@ function hide_canvas() {
1. JDK SPI 机制
当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。
下面我们通过一个简单的示例演示下 JDK SPI 的基本使用方式:
-
.png]
+
.png]
首先我们需要创建一个 Log 接口,来模拟日志打印的功能:
public interface Log {
void log(String info);
@@ -340,7 +340,7 @@ com.xxx.impl.Logback
2. JDK SPI 源码分析
通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load() 方法,接下来我们就对其具体实现进行深入分析。
在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader(获取当前线程绑定的 ClassLoader,查找失败后使用 SystemClassLoader),然后调用 reload() 方法,调用关系如下图所示:
-
+
在 reload() 方法中,首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,用于读取 SPI 配置文件并实例化实现类对象。
ServiceLoader.reload() 方法的具体实现,如下所示:
// 缓存,用来缓存 ServiceLoader创建的实现对象
@@ -351,7 +351,7 @@ public void reload() {
}
在前面的示例中,main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator 实现的。Iterator 接口有两个关键方法:hasNext() 方法和 next() 方法。这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法,hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示:
-
+
首先来看 LazyIterator.hasNextService() 方法,该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历,大致实现如下所示:
private static final String PREFIX = "META-INF/services/";
Enumeration<URL> configs = null;
diff --git a/专栏/Dubbo源码解读与实战-完/04 Dubbo SPI 精析,接口实现两极反转(下).md.html b/专栏/Dubbo源码解读与实战-完/04 Dubbo SPI 精析,接口实现两极反转(下).md.html
index 684ff492..ca370c14 100644
--- a/专栏/Dubbo源码解读与实战-完/04 Dubbo SPI 精析,接口实现两极反转(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/04 Dubbo SPI 精析,接口实现两极反转(下).md.html
@@ -313,9 +313,9 @@ function hide_canvas() {
下面我们正式进入 Dubbo SPI 核心实现的介绍。
1. @SPI 注解
Dubbo 中某个接口被 @SPI注解修饰时,就表示该接口是扩展接口,前文示例中的 org.apache.dubbo.rpc.Protocol 接口就是一个扩展接口:
-
+
@SPI 注解的 value 值指定了默认的扩展名称,例如,在通过 Dubbo SPI 加载 Protocol 接口实现时,如果没有明确指定扩展名,则默认会将 @SPI 注解的 value 值作为扩展名,即加载 dubbo 这个扩展名对应的 org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,相关的 SPI 配置文件在 dubbo-rpc-dubbo 模块中,如下图所示:
-
+
那 ExtensionLoader 是如何处理 @SPI 注解的呢?
ExtensionLoader 位于 dubbo-common 模块中的 extension 包中,功能类似于 JDK SPI 中的 java.util.ServiceLoader。Dubbo SPI 的核心逻辑几乎都封装在 ExtensionLoader 之中(其中就包括 @SPI 注解的处理逻辑),其使用方式如下所示:
Protocol protocol = ExtensionLoader
@@ -327,7 +327,7 @@ function hide_canvas() {
DubboInternalLoadingStrategy > DubboLoadingStrategy > ServicesLoadingStrateg
-
+
- EXTENSION_LOADERS(ConcurrentMap<Class, ExtensionLoader>类型)
:Dubbo 中一个扩展接口对应一个 ExtensionLoader 实例,该集合缓存了全部 ExtensionLoader 实例,其中的 Key 为扩展接口,Value 为加载其扩展实现的 ExtensionLoader 实例。
@@ -407,7 +407,7 @@ function hide_canvas() {
2. @Adaptive 注解与适配器
@Adaptive 注解用来实现 Dubbo 的适配器功能,那什么是适配器呢?这里我们通过一个示例进行说明。Dubbo 中的 ExtensionFactory 接口有三个实现类,如下图所示,ExtensionFactory 接口上有 @SPI 注解,AdaptiveExtensionFactory 实现类上有 @Adaptive 注解。
-
+
AdaptiveExtensionFactory 不实现任何具体的功能,而是用来适配 ExtensionFactory 的 SpiExtensionFactory 和 SpringExtensionFactory 这两种实现。AdaptiveExtensionFactory 会根据运行时的一些状态来选择具体调用 ExtensionFactory 的哪个实现。
@Adaptive 注解还可以加到接口方法之上,Dubbo 会动态生成适配器类。例如,Transporter接口有两个被 @Adaptive 注解修饰的方法:
@SPI("netty")
@@ -441,7 +441,7 @@ public interface Transporter {
生成 Transporter$Adaptive 这个类的逻辑位于 ExtensionLoader.createAdaptiveExtensionClass() 方法,若感兴趣你可以看一下相关代码,其中涉及的 javassist 等方面的知识,在后面的课时中我们会进行介绍。
明确了 @Adaptive 注解的作用之后,我们回到 ExtensionLoader.createExtension() 方法,其中在扫描 SPI 配置文件的时候,会调用 loadClass() 方法加载 SPI 配置文件中指定的类,如下图所示:
-
+
loadClass() 方法中会识别加载扩展实现类上的 @Adaptive 注解,将该扩展实现的类型缓存到 cachedAdaptiveClass 这个实例字段上(volatile修饰):
private void loadClass(){
if (clazz.isAnnotationPresent(Adaptive.class)) {
@@ -635,7 +635,7 @@ public <T> T getExtension(Class<T> type, String name) {
}
最后举个简单的例子说明上述处理流程,假设 cachedActivates 集合缓存的扩展实现如下表所示:
-
+
在 Provider 端调用 getActivateExtension() 方法时传入的 values 配置为 "demoFilter3、-demoFilter2、default、demoFilter1",那么根据上面的逻辑:
- 得到默认激活的扩展实实现集合中有 [ demoFilter4, demoFilter6 ];
diff --git a/专栏/Dubbo源码解读与实战-完/05 海量定时任务,一个时间轮搞定.md.html b/专栏/Dubbo源码解读与实战-完/05 海量定时任务,一个时间轮搞定.md.html
index c20c9cc4..5b741b5b 100644
--- a/专栏/Dubbo源码解读与实战-完/05 海量定时任务,一个时间轮搞定.md.html
+++ b/专栏/Dubbo源码解读与实战-完/05 海量定时任务,一个时间轮搞定.md.html
@@ -293,15 +293,15 @@ function hide_canvas() {
在很多开源框架中,都需要定时任务的管理功能,例如 ZooKeeper、Netty、Quartz、Kafka 以及 Linux 操作系统。
JDK 提供的 java.util.Timer 和 DelayedQueue 等工具类,可以帮助我们实现简单的定时任务管理,其底层实现使用的是堆这种数据结构,存取操作的复杂度都是 O(nlog(n)),无法支持大量的定时任务。在定时任务量比较大、性能要求比较高的场景中,为了将定时任务的存取操作以及取消操作的时间复杂度降为 O(1),一般会使用时间轮的方式。
时间轮是一种高效的、批量管理定时任务的调度模型。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。
-
+
时间轮环形结构示意图
需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储与时间轮结合的方案。
那在 Dubbo 中,时间轮的具体实现方式是怎样的呢?本课时我们就重点探讨下。Dubbo 的时间轮实现位于 dubbo-common 模块的 org.apache.dubbo.common.timer 包中,下面我们就来分析时间轮涉及的核心接口和实现。
核心接口
在 Dubbo 中,所有的定时任务都要继承 TimerTask 接口。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象,我们不仅可以查看定时任务的状态,还可以操作定时任务(例如取消关联的定时任务)。Timeout 接口中的方法如下图所示:
-
.png
+
.png
Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法:提交一个定时任务(TimerTask)并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。
-
+
HashedWheelTimeout
HashedWheelTimeout 是 Timeout 接口的唯一实现,是 HashedWheelTimer 的内部类。HashedWheelTimeout 扮演了两个角色:
diff --git a/专栏/Dubbo源码解读与实战-完/06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上).md.html b/专栏/Dubbo源码解读与实战-完/06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上).md.html
index 07d5c456..9cb6acfe 100644
--- a/专栏/Dubbo源码解读与实战-完/06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上).md.html
@@ -292,13 +292,13 @@ function hide_canvas() {
06 ZooKeeper 与 Curator,求你别用 ZkClient 了(上)
在前面我们介绍 Dubbo 简化架构的时候提到过,Dubbo Provider 在启动时会将自身的服务信息整理成 URL 注册到注册中心,Dubbo Consumer 在启动时会向注册中心订阅感兴趣的 Provider 信息,之后 Provider 和 Consumer 才能建立连接,进行后续的交互。可见,一个稳定、高效的注册中心对基于 Dubbo 的微服务来说是至关重要的。
Dubbo 目前支持 Consul、etcd、Nacos、ZooKeeper、Redis 等多种开源组件作为注册中心,并且在 Dubbo 源码也有相应的接入模块,如下图所示:
-
+
Dubbo 官方推荐使用 ZooKeeper 作为注册中心,它是在实际生产中最常用的注册中心实现,这也是我们本课时要介绍 ZooKeeper 核心原理的原因。
要与 ZooKeeper 集群进行交互,我们可以使用 ZooKeeper 原生客户端或是 ZkClient、Apache Curator 等第三方开源客户端。在后面介绍 dubbo-registry-zookeeper 模块的具体实现时你会看到,Dubbo 底层使用的是 Apache Curator。Apache Curator 是实践中最常用的 ZooKeeper 客户端。
ZooKeeper 核心概念
Apache ZooKeeper 是一个针对分布式系统的、可靠的、可扩展的协调服务,它通常作为统一命名服务、统一配置管理、注册中心(分布式集群管理)、分布式锁服务、Leader 选举服务等角色出现。很多分布式系统都依赖与 ZooKeeper 集群实现分布式系统间的协调调度,例如:Dubbo、HDFS 2.x、HBase、Kafka 等。ZooKeeper 已经成为现代分布式系统的标配。
ZooKeeper 本身也是一个分布式应用程序,下图展示了 ZooKeeper 集群的核心架构。
-
+
ZooKeeper 集群的核心架构图
- Client 节点:从业务角度来看,这是分布式应用中的一个节点,通过 ZkClient 或是其他 ZooKeeper 客户端与 ZooKeeper 集群中的一个 Server 实例维持长连接,并定时发送心跳。从 ZooKeeper 集群的角度来看,它是 ZooKeeper 集群的一个客户端,可以主动查询或操作 ZooKeeper 集群中的数据,也可以在某些 ZooKeeper 节点(ZNode)上添加监听。当被监听的 ZNode 节点发生变化时,例如,该 ZNode 节点被删除、新增子节点或是其中数据被修改等,ZooKeeper 集群都会立即通过长连接通知 Client。
@@ -307,7 +307,7 @@ function hide_canvas() {
- Observer 节点:ZooKeeper 集群中特殊的从节点,不会参与 Leader 节点的选举,其他功能与 Follower 节点相同。引入 Observer 角色的目的是增加 ZooKeeper 集群读操作的吞吐量,如果单纯依靠增加 Follower 节点来提高 ZooKeeper 的读吞吐量,那么有一个很严重的副作用,就是 ZooKeeper 集群的写能力会大大降低,因为 ZooKeeper 写数据时需要 Leader 将写操作同步给半数以上的 Follower 节点。引入 Observer 节点使得 ZooKeeper 集群在写能力不降低的情况下,大大提升了读操作的吞吐量。
了解了 ZooKeeper 整体的架构之后,我们再来了解一下 ZooKeeper 集群存储数据的逻辑结构。ZooKeeper 逻辑上是按照树型结构进行数据存储的(如下图),其中的节点称为 ZNode。每个 ZNode 有一个名称标识,即树根到该节点的路径(用 “/” 分隔),ZooKeeper 树中的每个节点都可以拥有子节点,这与文件系统的目录树类似。
-
+
ZooKeeper 树型存储结构
ZNode 节点类型有如下四种:
@@ -317,7 +317,7 @@ function hide_canvas() {
- 临时顺序节点。 基本特性与临时节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。
在每个 ZNode 中都维护着一个 stat 结构,记录了该 ZNode 的元数据,其中包括版本号、操作控制列表(ACL)、时间戳和数据长度等信息,如下表所示:
-
+
我们除了可以通过 ZooKeeper Client 对 ZNode 进行增删改查等基本操作,还可以注册 Watcher 监听 ZNode 节点、其中的数据以及子节点的变化。一旦监听到变化,则相应的 Watcher 即被触发,相应的 ZooKeeper Client 会立即得到通知。Watcher 有如下特点:
- 主动推送。 Watcher 被触发时,由 ZooKeeper 集群主动将更新推送给客户端,而不需要客户端轮询。
@@ -337,7 +337,7 @@ function hide_canvas() {
- 最后,Follower 节点会返回 Client 写请求相应的响应。
下图展示了写操作的核心流程:
-
+
写操作核心流程图
崩溃恢复
上面写请求处理流程中,如果发生 Leader 节点宕机,整个 ZooKeeper 集群可能处于如下两种状态:
diff --git a/专栏/Dubbo源码解读与实战-完/07 ZooKeeper 与 Curator,求你别用 ZkClient 了(下).md.html b/专栏/Dubbo源码解读与实战-完/07 ZooKeeper 与 Curator,求你别用 ZkClient 了(下).md.html
index 4c3edf0f..a4ef40d1 100644
--- a/专栏/Dubbo源码解读与实战-完/07 ZooKeeper 与 Curator,求你别用 ZkClient 了(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/07 ZooKeeper 与 Curator,求你别用 ZkClient 了(下).md.html
@@ -305,7 +305,7 @@ function hide_canvas() {
Apache Curator 基础
Apache Curator 是 Apache 基金会提供的一款 ZooKeeper 客户端,它提供了一套易用性和可读性非常强的 Fluent 风格的客户端 API ,可以帮助我们快速搭建稳定可靠的 ZooKeeper 客户端程序。
为便于你更全面了解 Curator 的功能,我整理出了如下表格,展示了 Curator 提供的 jar 包:
-
+
下面我们从最基础的使用展开,逐一介绍 Apache Curator 在实践中常用的核心功能,开始我们的 Apache Curator 之旅。
1. 基本操作
简单了解了 Apache Curator 各个组件的定位之后,下面我们立刻通过一个示例上手使用 Curator。首先,我们创建一个 Maven 项目,并添加 Apache Curator 的依赖:
@@ -519,7 +519,7 @@ function hide_canvas() {
}
接下来,我们打开 ZooKeeper 的命令行客户端,在 /user 节点下先后添加两个子节点,如下所示:
-
+
此时我们只得到一行输出:
NodeChildrenChanged,/user
@@ -616,13 +616,13 @@ TreeCache,type=NODE_ADDED path=/user/test2
TreeCache,type=INITIALIZED
接下来,我们在 ZooKeeper 命令行客户端中更新 /user 节点中的数据:
-
+
得到如下输出:
TreeCache,type=NODE_UPDATED path=/user
NodeCache节点路径:/user,节点数据为:userData
创建 /user/test3 节点:
-
+
得到输出:
TreeCache,type=NODE_ADDED path=/user/test3
2020-06-26T08:35:22.393 CHILD_ADDED
@@ -630,7 +630,7 @@ PathChildrenCache添加子节点:/user/test3
PathChildrenCache子节点数据:xxx3
更新 /user/test3 节点的数据:
-
+
得到输出:
TreeCache,type=NODE_UPDATED path=/user/test3
2020-06-26T08:43:54.604 CHILD_UPDATED
@@ -638,7 +638,7 @@ PathChildrenCache修改子节点路径:/user/test3
PathChildrenCache修改子节点数据:xxx33
删除 /user/test3 节点:
-
+
得到输出:
TreeCache,type=NODE_REMOVED path=/user/test3
2020-06-26T08:44:06.329 CHILD_REMOVED
@@ -650,7 +650,7 @@ PathChildrenCache删除子节点:/user/test3
- ServiceInstance。 这是 curator-x-discovery 扩展包对服务实例的抽象,由 name、id、address、port 以及一个可选的 payload 属性构成。其存储在 ZooKeeper 中的方式如下图展示的这样。
-
+
- ServiceProvider。 这是 curator-x-discovery 扩展包的核心组件之一,提供了多种不同策略的服务发现方式,具体策略有轮询调度、随机和黏性(总是选择相同的一个)。得到 ServiceProvider 对象之后,我们可以调用其 getInstance() 方法,按照指定策略获取 ServiceInstance 对象(即发现可用服务实例);还可以调用 getAllInstances() 方法,获取所有 ServiceInstance 对象(即获取全部可用服务实例)。
- ServiceDiscovery。 这是 curator-x-discovery 扩展包的入口类。开始必须调用 start() 方法,当使用完成应该调用 close() 方法进行销毁。
diff --git a/专栏/Dubbo源码解读与实战-完/08 代理模式与常见实现.md.html b/专栏/Dubbo源码解读与实战-完/08 代理模式与常见实现.md.html
index ed876950..3526cc27 100644
--- a/专栏/Dubbo源码解读与实战-完/08 代理模式与常见实现.md.html
+++ b/专栏/Dubbo源码解读与实战-完/08 代理模式与常见实现.md.html
@@ -294,7 +294,7 @@ function hide_canvas() {
本课时我们主要从基础知识开始讲起,首先介绍代理模式的基本概念,之后重点介绍 JDK 动态代理的使用以及底层实现原理,同时还会说明 JDK 动态代理的一些局限性,最后再介绍基于字节码生成的动态代理。
代理模式
代理模式是 23 种面向对象的设计模式中的一种,它的类图如下所示:
-
+
图中的 Subject 是程序中的业务逻辑接口,RealSubject 是实现了 Subject 接口的真正业务类,Proxy 是实现了 Subject 接口的代理类,封装了一个 RealSubject 引用。在程序中不会直接调用 RealSubject 对象的方法,而是使用 Proxy 对象实现相关功能。
Proxy.operation() 方法的实现会调用其中封装的 RealSubject 对象的 operation() 方法,执行真正的业务逻辑。代理的作用不仅仅是正常地完成业务逻辑,还会在业务逻辑前后添加一些代理逻辑,也就是说,Proxy.operation() 方法会在 RealSubject.operation() 方法调用前后进行一些预处理以及一些后置处理。这就是我们常说的“代理模式”。
使用代理模式可以控制程序对 RealSubject 对象的访问,如果发现异常的访问,可以直接限流或是返回,也可以在执行业务处理的前后进行相关的预处理和后置处理,帮助上层调用方屏蔽底层的细节。例如,在 RPC 框架中,代理可以完成序列化、网络 I/O 操作、负载均衡、故障恢复以及服务发现等一系列操作,而上层调用方只感知到了一次本地调用。
@@ -443,7 +443,7 @@ function hide_canvas() {
这两个组件的使用与 JDK 动态代理中的 Proxy 和 InvocationHandler 相似。
下面我们通过一个示例简单介绍 CGLib 的使用。在使用 CGLib 创建动态代理类时,首先需要定义一个 Callback 接口的实现, CGLib 中也提供了多个Callback接口的子接口,如下图所示:
-
+
这里以 MethodInterceptor 接口为例进行介绍,首先我们引入 CGLib 的 maven 依赖:
<dependency>
<groupId>cglib</groupId>
diff --git a/专栏/Dubbo源码解读与实战-完/09 Netty 入门,用它做网络编程都说好(上).md.html b/专栏/Dubbo源码解读与实战-完/09 Netty 入门,用它做网络编程都说好(上).md.html
index 95db0a1c..3003bf4b 100644
--- a/专栏/Dubbo源码解读与实战-完/09 Netty 入门,用它做网络编程都说好(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/09 Netty 入门,用它做网络编程都说好(上).md.html
@@ -309,14 +309,14 @@ function hide_canvas() {
在进行网络 I/O 操作的时候,用什么样的方式读写数据将在很大程度上决定了 I/O 的性能。作为一款优秀的网络基础库,Netty 就采用了 NIO 的 I/O 模型,这也是其高性能的重要原因之一。
1. 传统阻塞 I/O 模型
在传统阻塞型 I/O 模型(即我们常说的 BIO)中,如下图所示,每个请求都需要独立的线程完成读数据、业务处理以及写回数据的完整操作。
-
+
一个线程在同一时刻只能与一个连接绑定,如下图所示,当请求的并发量较大时,就需要创建大量线程来处理连接,这就会导致系统浪费大量的资源进行线程切换,降低程序的性能。我们知道,网络数据的传输速度是远远慢于 CPU 的处理速度,连接建立后,并不总是有数据可读,连接也并不总是可写,那么线程就只能阻塞等待,CPU 的计算能力不能得到充分发挥,同时还会导致大量线程的切换,浪费资源。
-
+
2. I/O 多路复用模型
针对传统的阻塞 I/O 模型的缺点,I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可,无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时,操作系统会通知线程,线程从阻塞状态返回,开始进行读写操作以及后续的业务逻辑处理。I/O 复用的模型如下图所示:
-
+
Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在,可以同时并发处理成百上千个网络连接,大大增加了服务器的处理能力。另外,Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。如下图所示:
-
+
从数据处理的角度来看,传统的阻塞 I/O 模型处理的是字节流或字符流,也就是以流式的方式顺序地从一个数据流中读取一个或多个字节,并且不能随意改变读取指针的位置。而在 NIO 中则抛弃了这种传统的 I/O 流概念,引入了 Channel 和 Buffer 的概念,可以从 Channel 中读取数据到 Buffer 中或将数据从 Buffer 中写入到 Channel。Buffer 不像传统 I/O 中的流那样必须顺序操作,在 NIO 中可以读写 Buffer 中任意位置的数据。
Netty 线程模型设计
服务器程序在读取到二进制数据之后,首先需要通过编解码,得到程序逻辑可以理解的消息,然后将消息传入业务逻辑进行处理,并产生相应的结果,返回给客户端。编解码逻辑、消息派发逻辑、业务处理逻辑以及返回响应的逻辑,是放到一个线程里面串行执行,还是分配到不同的线程中执行,会对程序的性能产生很大的影响。所以,优秀的线程模型对一个高性能网络库来说是至关重要的。
@@ -324,23 +324,23 @@ function hide_canvas() {
为了帮助你更好地了解 Netty 线程模型的设计理念,我们将从最基础的单 Reactor 单线程模型开始介绍,然后逐步增加模型的复杂度,最终到 Netty 目前使用的非常成熟的线程模型设计。
1. 单 Reactor 单线程
Reactor 对象监听客户端请求事件,收到事件后通过 Dispatch 进行分发。如果是连接建立的事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立的事件,而是数据的读写事件,则 Reactor 会将事件分发对应的 Handler 来处理,由这里唯一的线程调用 Handler 对象来完成读取数据、业务处理、发送响应的完整流程。当然,该过程中也可能会出现连接不可读或不可写等情况,该单线程会去执行其他 Handler 的逻辑,而不是阻塞等待。具体情况如下图所示:
-
+
单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。
但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。
2. 单 Reactor 多线程
在单 Reactor 多线程的架构中,Reactor 监控到客户端请求之后,如果连接建立的请求,则由Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池。
-
+
单 Reactor 多线程模型
很明显,单 Reactor 多线程的模型可以充分利用多核 CPU 的处理能力,提高整个系统的吞吐量,但引入多线程模型就要考虑线程并发、数据共享、线程调度等问题。在这个模型中,只有一个线程来处理 Reactor 监听到的所有 I/O 事件,其中就包括连接建立事件以及读写事件,当连接数不断增大的时候,这个唯一的 Reactor 线程也会遇到瓶颈。
3. 主从 Reactor 多线程
为了解决单 Reactor 多线程模型中的问题,我们可以引入多个 Reactor。其中,Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件,当Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听。
当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件(OP_READ)发生时,Reactor 子线程就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后,Handler 会根据处理结果调用 send 将响应返回给客户端,当然此时连接要有可写事件(OP_WRITE)才能发送数据。
-
+
主从 Reactor 多线程模型
主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。
4. Netty 线程模型
Netty 同时支持上述几种线程模式,Netty 针对服务器端的设计是在主从 Reactor 多线程模型的基础上进行的修改,如下图所示:
-
+
Netty 抽象出两组线程池:BossGroup 专门用于接收客户端的连接,WorkerGroup 专门用于网络的读写。BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,相当于一个事件循环组,其中包含多个事件循环 ,每一个事件循环是 NioEventLoop。
NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理。每个 NioEventLoopGroup 可以含有多个 NioEventLoop,也就是多个线程。
每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。
diff --git a/专栏/Dubbo源码解读与实战-完/10 Netty 入门,用它做网络编程都说好(下).md.html b/专栏/Dubbo源码解读与实战-完/10 Netty 入门,用它做网络编程都说好(下).md.html
index 7c36544c..087e9f80 100644
--- a/专栏/Dubbo源码解读与实战-完/10 Netty 入门,用它做网络编程都说好(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/10 Netty 入门,用它做网络编程都说好(下).md.html
@@ -307,7 +307,7 @@ function hide_canvas() {
ChannelPipeline&ChannelHandler
提到 Pipeline,你可能最先想到的是 Linux 命令中的管道,它可以实现将一条命令的输出作为另一条命令的输入。Netty 中的 ChannelPipeline 也可以实现类似的功能:ChannelPipeline 会将一个 ChannelHandler 处理后的数据作为下一个 ChannelHandler 的输入。
下图我们引用了 Netty Javadoc 中对 ChannelPipeline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常是如何处理 I/O 事件的。Netty 中定义了两种事件类型:入站(Inbound)事件和出站(Outbound)事件。这两种事件就像 Linux 管道中的数据一样,在 ChannelPipeline 中传递,事件之中也可能会附加数据。ChannelPipeline 之上可以注册多个 ChannelHandler(ChannelInboundHandler 或 ChannelOutboundHandler),我们在 ChannelHandler 注册的时候决定处理 I/O 事件的顺序,这就是典型的责任链模式。
-
+
从图中我们还可以看到,I/O 事件不会在 ChannelPipeline 中自动传播,而是需要调用ChannelHandlerContext 中定义的相应方法进行传播,例如:fireChannelRead() 方法和 write() 方法等。
这里我们举一个简单的例子,如下所示,在该 ChannelPipeline 上,我们添加了 5 个 ChannelHandler 对象:
ChannelPipeline p = socketChannel.pipeline();
@@ -326,12 +326,12 @@ p.addLast("5", new InboundOutboundHandlerX());
在 Netty 中就提供了很多 Encoder 的实现用来解码读取到的数据,Encoder 会处理多次 channelRead() 事件,等拿到有意义的数据之后,才会触发一次下一个 ChannelInboundHandler 的 channelRead() 方法。
出站(Outbound)事件与入站(Inbound)事件相反,一般是由用户触发的。
ChannelHandler 接口中并没有定义方法来处理事件,而是由其子类进行处理的,如下图所示,ChannelInboundHandler 拦截并处理入站事件,ChannelOutboundHandler 拦截并处理出站事件。
-
+
Netty 提供的 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter 主要是帮助完成事件流转功能的,即自动调用传递事件的相应方法。这样,我们在自定义 ChannelHandler 实现类的时候,就可以直接继承相应的 Adapter 类,并覆盖需要的事件处理方法,其他不关心的事件方法直接使用默认实现即可,从而提高开发效率。
ChannelHandler 中的很多方法都需要一个 ChannelHandlerContext 类型的参数,ChannelHandlerContext 抽象的是 ChannleHandler 之间的关系以及 ChannelHandler 与ChannelPipeline 之间的关系。ChannelPipeline 中的事件传播主要依赖于ChannelHandlerContext 实现,在 ChannelHandlerContext 中维护了 ChannelHandler 之间的关系,所以我们可以从 ChannelHandlerContext 中得到当前 ChannelHandler 的后继节点,从而将事件传播到后续的 ChannelHandler。
ChannelHandlerContext 继承了 AttributeMap,所以提供了 attr() 方法设置和删除一些状态属性信息,我们可将业务逻辑中所需使用的状态属性值存入到 ChannelHandlerContext 中,然后这些属性就可以随它传播了。Channel 中也维护了一个 AttributeMap,与 ChannelHandlerContext 中的 AttributeMap,从 Netty 4.1 开始,都是作用于整个 ChannelPipeline。
通过上述分析,我们可以了解到,一个 Channel 对应一个 ChannelPipeline,一个 ChannelHandlerContext 对应一个ChannelHandler。 如下图所示:
-
+
最后,需要注意的是,如果要在 ChannelHandler 中执行耗时较长的逻辑,例如,操作 DB 、进行网络或磁盘 I/O 等操作,一般会在注册到 ChannelPipeline 的同时,指定一个线程池异步执行 ChannelHandler 中的操作。
NioEventLoop
在前文介绍 Netty 线程模型的时候,我们简单提到了 NioEventLoop 这个组件,当时为了便于理解,只是简单将其描述成了一个线程。
@@ -341,7 +341,7 @@ p.addLast("5", new InboundOutboundHandlerX());
- 普通任务队列。用户产生的普通任务可以提交到该队列中暂存,NioEventLoop 发现该队列中的任务后会立即执行。这是一个多生产者、单消费者的队列,Netty 使用该队列将外部用户线程产生的任务收集到一起,并在 Reactor 线程内部用单线程的方式串行执行队列中的任务。例如,外部非 I/O 线程调用了 Channel 的 write() 方法,Netty 会将其封装成一个任务放入 TaskQueue 队列中,这样,所有的 I/O 操作都会在 I/O 线程中串行执行。
-
+
- 定时任务队列。当用户在非 I/O 线程产生定时操作时,Netty 将用户的定时操作封装成定时任务,并将其放入该定时任务队列中等待相应 NioEventLoop 串行执行。
@@ -349,10 +349,10 @@ p.addLast("5", new InboundOutboundHandlerX());
NioEventLoopGroup
NioEventLoopGroup 表示的是一组 NioEventLoop。Netty 为了能更充分地利用多核 CPU 资源,一般会有多个 NioEventLoop 同时工作,至于多少线程可由用户决定,Netty 会根据实际上的处理器核数计算一个默认值,具体计算公式是:CPU 的核心数 * 2,当然我们也可以根据实际情况手动调整。
当一个 Channel 创建之后,Netty 会调用 NioEventLoopGroup 提供的 next() 方法,按照一定规则获取其中一个 NioEventLoop 实例,并将 Channel 注册到该 NioEventLoop 实例,之后,就由该 NioEventLoop 来处理 Channel 上的事件。EventLoopGroup、EventLoop 以及 Channel 三者的关联关系,如下图所示:
-
+
前面我们提到过,在 Netty 服务器端中,会有 BossEventLoopGroup 和 WorkerEventLoopGroup 两个 NioEventLoopGroup。通常一个服务端口只需要一个ServerSocketChannel,对应一个 Selector 和一个 NioEventLoop 线程。
BossEventLoop 负责接收客户端的连接事件,即 OP_ACCEPT 事件,然后将创建的 NioSocketChannel 交给 WorkerEventLoopGroup; WorkerEventLoopGroup 会由 next() 方法选择其中一个 NioEventLoopGroup,并将这个 NioSocketChannel 注册到其维护的 Selector 并对其后续的I/O事件进行处理。
-
+
如上图,BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个 Selector 对象,其上注册了一个 ServerSocketChannel,BoosEventLoop 会不断轮询 Selector 监听连接事件,在发生连接事件时,通过 accept 操作与客户端创建连接,创建 SocketChannel 对象。然后将 accept 操作得到的 SocketChannel 交给 WorkerEventLoopGroup,在Reactor 模式中 WorkerEventLoopGroup 中会维护多个 EventLoop,而每个 EventLoop 都会监听分配给它的 SocketChannel 上发生的 I/O 事件,并将这些具体的事件分发给业务线程池处理。
ByteBuf
通过前文的介绍,我们了解了 Netty 中数据的流向,这里我们再来介绍一下数据的容器——ByteBuf。
@@ -360,7 +360,7 @@ p.addLast("5", new InboundOutboundHandlerX());
ByteBuf 类似于一个字节数组,其中维护了一个读索引和一个写索引,分别用来控制对 ByteBuf 中数据的读写操作,两者符合下面的不等式:
0 <= readerIndex <= writerIndex <= capacity
-
+
ByteBuf 提供的读写操作 API 主要操作底层的字节容器(byte[]、ByteBuffer 等)以及读写索引这两指针,你若感兴趣的话,可以查阅相关的 API 说明,这里不再展开介绍。
Netty 中主要分为以下三大类 ByteBuf:
@@ -377,13 +377,13 @@ p.addLast("5", new InboundOutboundHandlerX());
下面我们从如何高效分配和释放内存、如何减少内存碎片以及在多线程环境下如何减少锁竞争这三个方面介绍一下 Netty 提供的 ByteBuf 池化技术。
Netty 首先会向系统申请一整块连续内存,称为 Chunk(默认大小为 16 MB),这一块连续的内存通过 PoolChunk 对象进行封装。之后,Netty 将 Chunk 空间进一步拆分为 Page,每个 Chunk 默认包含 2048 个 Page,每个 Page 的大小为 8 KB。
在同一个 Chunk 中,Netty 将 Page 按照不同粒度进行分层管理。如下图所示,从下数第 1 层中每个分组的大小为 1 * PageSize,一共有 2048 个分组;第 2 层中每个分组大小为 2 * PageSize,一共有 1024 个组;第 3 层中每个分组大小为 4 * PageSize,一共有 512 个组;依次类推,直至最顶层。
-
+
1. 内存分配&释放
当服务向内存池请求内存时,Netty 会将请求分配的内存数向上取整到最接近的分组大小,然后在该分组的相应层级中从左至右寻找空闲分组。例如,服务请求分配 3 * PageSize 的内存,向上取整得到的分组大小为 4 * PageSize,在该层分组中找到完全空闲的一组内存进行分配即可,如下图:
-
+
当分组大小 4 * PageSize 的内存分配出去后,为了方便下次内存分配,分组被标记为全部已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)。
Netty 使用完全平衡树的结构实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示:
-
+
具体的实现逻辑这里就不再展开讲述了,你若感兴趣的话,可以参考 Netty 代码。
2. 大对象&小对象的处理
当申请分配的对象是超过 Chunk 容量的大型对象,Netty 就不再使用池化管理方式了,在每次请求分配内存时单独创建特殊的非池化 PoolChunk 对象进行管理,当对象内存释放时整个PoolChunk 内存释放。
@@ -393,16 +393,16 @@ p.addLast("5", new InboundOutboundHandlerX());
- 小型对象:规整后的大小为 2 的幂,如 512、1024、2048、4096,一共 4 种大小。
Netty 的实现会先从 PoolChunk 中申请空闲 Page,同一个 Page 分为相同大小的小 Buffer 进行存储;这些 Page 用 PoolSubpage 对象进行封装,PoolSubpage 内部会记录它自己能分配的小 Buffer 的规格大小、可用内存数量,并通过 bitmap 的方式记录各个小内存的使用情况(如下图所示)。虽然这种方案不能完美消灭内存碎片,但是很大程度上还是减少了内存浪费。
-
+
为了解决单个 PoolChunk 容量有限的问题,Netty 将多个 PoolChunk 组成链表一起管理,然后用 PoolChunkList 对象持有链表的 head。
Netty 通过 PoolArena 管理 PoolChunkList 以及 PoolSubpage。
PoolArena 内部持有 6 个 PoolChunkList,各个 PoolChunkList 持有的 PoolChunk 的使用率区间有所不同,如下图所示:
-
+
6 个 PoolChunkList 对象组成双向链表,当 PoolChunk 内存分配、释放,导致使用率变化,需要判断 PoolChunk 是否超过所在 PoolChunkList 的限定使用率范围,如果超出了,需要沿着 6 个 PoolChunkList 的双向链表找到新的合适的 PoolChunkList ,成为新的 head。同样,当新建 PoolChunk 分配内存或释放空间时,PoolChunk 也需要按照上面逻辑放入合适的PoolChunkList 中。
-
+
从上图可以看出,这 6 个 PoolChunkList 额定使用率区间存在交叉,这样设计的原因是:如果使用单个临界值的话,当一个 PoolChunk 被来回申请和释放,内存使用率会在临界值上下徘徊,这就会导致它在两个 PoolChunkList 链表中来回移动。
PoolArena 内部持有 2 个 PoolSubpage 数组,分别存储微型 Buffer 和小型 Buffer 的PoolSubpage。相同大小的 PoolSubpage 组成链表,不同大小的 PoolSubpage 链表的 head 节点保存在 tinySubpagePools 或者 smallSubpagePools 数组中,如下图:
-
+
3. 并发处理
内存分配释放不可避免地会遇到多线程并发场景,PoolChunk 的完全平衡树标记以及 PoolSubpage 的 bitmap 标记都是多线程不安全的,都是需要加锁同步的。为了减少线程间的竞争,Netty 会提前创建多个 PoolArena(默认数量为 2 * CPU 核心数),当线程首次请求池化内存分配,会找被最少线程持有的 PoolArena,并保存线程局部变量 PoolThreadCache 中,实现线程与 PoolArena 的关联绑定。
Netty 还提供了延迟释放的功能,来提升并发性能。当内存释放时,PoolArena 并没有马上释放,而是先尝试将该内存关联的 PoolChunk 和 Chunk 中的偏移位置等信息存入 ThreadLocal 的固定大小缓存队列中,如果该缓存队列满了,则马上释放内存。当有新的分配请求时,PoolArena 会优先访问线程本地的缓存队列,查询是否有缓存可用,如果有,则直接分配,提高分配效率。
diff --git a/专栏/Dubbo源码解读与实战-完/11 简易版 RPC 框架实现(上).md.html b/专栏/Dubbo源码解读与实战-完/11 简易版 RPC 框架实现(上).md.html
index 250d51b2..13349a21 100644
--- a/专栏/Dubbo源码解读与实战-完/11 简易版 RPC 框架实现(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/11 简易版 RPC 框架实现(上).md.html
@@ -292,7 +292,7 @@ function hide_canvas() {
11 简易版 RPC 框架实现(上)
这是“基础知识”部分的最后一课时,我们将会运用前面介绍的基础知识来做一个实践项目 —— 编写一个简易版本的 RPC 框架,作为“基础知识”部分的总结和回顾。
RPC 是“远程过程调用(Remote Procedure Call)”的缩写形式,比较通俗的解释是:像本地方法调用一样调用远程的服务。虽然 RPC 的定义非常简单,但是相对完整的、通用的 RPC 框架涉及很多方面的内容,例如注册发现、服务治理、负载均衡、集群容错、RPC 协议等,如下图所示:
-
+
简易 RPC 框架的架构图
本课时我们主要实现RPC 框架的基石部分——远程调用,简易版 RPC 框架一次远程调用的核心流程是这样的:
@@ -306,7 +306,7 @@ function hide_canvas() {
这个远程调用的过程,就是我们简易版本 RPC 框架的核心实现,只有理解了这个流程,才能进行后续的开发。
项目结构
了解了简易版 RPC 框架的工作流程和实现目标之后,我们再来看下项目的结构,为了方便起见,这里我们将整个项目放到了一个 Module 中了,如下图所示,你可以按照自己的需求进行模块划分。
-
+
那这各个包的功能是怎样的呢?我们就来一一说明。
- protocol:简易版 RPC 框架的自定义协议。
@@ -321,7 +321,7 @@ function hide_canvas() {
从功能角度考虑,HTTP 协议在 1.X 时代,只支持半双工传输模式,虽然支持长连接,但是不支持服务端主动推送数据。从效率角度来看,在一次简单的远程调用中,只需要传递方法名和加个简单的参数,此时,HTTP 请求中大部分数据都被 HTTP Header 占据,真正的有效负载非常少,效率就比较低。
当然,HTTP 协议也有自己的优势,例如,天然穿透防火墙,大量的框架和开源软件支持 HTTP 接口,而且配合 REST 规范使用也是很便捷的,所以有很多 RPC 框架直接使用 HTTP 协议,尤其是在 HTTP 2.0 之后,如 gRPC、Spring Cloud 等。
这里我们自定义一个简易版的 Demo RPC 协议,如下图所示:
-
+
在 Demo RPC 的消息头中,包含了整个 RPC 消息的一些控制信息,例如,版本号、魔数、消息类型、附加信息、消息 ID 以及消息体的长度,在附加信息(extraInfo)中,按位进行划分,分别定义消息的类型、序列化方式、压缩方式以及请求类型。当然,你也可以自己扩充 Demo RPC 协议,实现更加复杂的功能。
Demo RPC 消息头对应的实体类是 Header,其定义如下:
public class Header {
@@ -391,7 +391,7 @@ public class Response implements Serializable {
编解码实现
了解了自定义协议的结构之后,我们再来解决协议的编解码问题。
前面课时介绍 Netty 核心概念的时候我们提到过,Netty 每个 Channel 绑定一个 ChannelPipeline,并依赖 ChannelPipeline 中添加的 ChannelHandler 处理接收到(或要发送)的数据,其中就包括字节到消息(以及消息到字节)的转换。Netty 中提供了 ByteToMessageDecoder、 MessageToByteEncoder、MessageToMessageEncoder、MessageToMessageDecoder 等抽象类来实现 Message 与 ByteBuf 之间的转换以及 Message 之间的转换,如下图所示:
-
+
Netty 提供的 Decoder 和 Encoder 实现
在 Netty 的源码中,我们可以看到对很多已有协议的序列化和反序列化都是基于上述抽象类实现的,例如,HttpServerCodec 中通过依赖 HttpServerRequestDecoder 和 HttpServerResponseEncoder 来实现 HTTP 请求的解码和 HTTP 响应的编码。如下图所示,HttpServerRequestDecoder 继承自 ByteToMessageDecoder,实现了 ByteBuf 到 HTTP 请求之间的转换;HttpServerResponseEncoder 继承自 MessageToMessageEncoder,实现 HTTP 响应到其他消息的转换(其中包括转换成 ByteBuf 的能力)。

diff --git a/专栏/Dubbo源码解读与实战-完/12 简易版 RPC 框架实现(下).md.html b/专栏/Dubbo源码解读与实战-完/12 简易版 RPC 框架实现(下).md.html
index 540dbae7..0827feba 100644
--- a/专栏/Dubbo源码解读与实战-完/12 简易版 RPC 框架实现(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/12 简易版 RPC 框架实现(下).md.html
@@ -295,7 +295,7 @@ function hide_canvas() {
正如前文介绍 Netty 线程模型的时候提到,我们不能在 Netty 的 I/O 线程中执行耗时的业务逻辑。在 Demo RPC 框架的 Server 端接收到请求时,首先会通过上面介绍的 DemoRpcDecoder 反序列化得到请求消息,之后我们会通过一个自定义的 ChannelHandler(DemoRpcServerHandler)将请求提交给业务线程池进行处理。
在 Demo RPC 框架的 Client 端接收到响应消息的时候,也是先通过 DemoRpcDecoder 反序列化得到响应消息,之后通过一个自定义的 ChannelHandler(DemoRpcClientHandler)将响应返回给上层业务。
DemoRpcServerHandler 和 DemoRpcClientHandler 都继承自 SimpleChannelInboundHandler,如下图所示:
-
+
DemoRpcClientHandler 和 DemoRpcServerHandler 的继承关系图
下面我们就来看一下这两个自定义的 ChannelHandler 实现:
public class DemoRpcServerHandler extends
@@ -431,7 +431,7 @@ public class DemoRpcClientHandler extends
}
通过 DemoRpcClient 的代码我们可以看到其 ChannelHandler 的执行顺序如下:
-
+
客户端 ChannelHandler 结构图
另外,在创建EventLoopGroup时并没有直接使用NioEventLoopGroup,而是在 NettyEventLoopFactory 中根据当前操作系统进行选择,对于 Linux 系统,会使用 EpollEventLoopGroup,其他系统则使用 NioEventLoopGroup。
接下来我们再看DemoRpcServer 的具体实现:
@@ -475,13 +475,13 @@ public class DemoRpcClientHandler extends
}
通过对 DemoRpcServer 实现的分析,我们可以知道每个 Channel 上的 ChannelHandler 顺序如下:
-
+
服务端 ChannelHandler 结构图
registry 相关实现
介绍完客户端和服务端的通信之后,我们再来看简易 RPC 框架的另一个基础能力——服务注册与服务发现能力,对应 demo-rpc 项目源码中的 registry 包。
registry 包主要是依赖 Apache Curator 实现了一个简易版本的 ZooKeeper 客户端,并基于 ZooKeeper 实现了注册中心最基本的两个功能:Provider 注册以及 Consumer 订阅。
这里我们先定义一个 Registry 接口,其中提供了注册以及查询服务实例的方法,如下图所示:
-
+
ZooKeeperRegistry 是基于 curator-x-discovery 对 Registry 接口的实现类型,其中封装了之前课时介绍的 ServiceDiscovery,并在其上添加了 ServiceCache 缓存提高查询效率。ZooKeeperRegistry 的具体实现如下:
public class ZookeeperRegistry<T> implements Registry<T> {
private InstanceSerializer serializer =
@@ -628,7 +628,7 @@ public class DemoRpcClientHandler extends
你若感兴趣的话可以尝试进行扩展,以实现一个更加完善的代理层。
使用方接入
介绍完 Demo RPC 的核心实现之后,下面我们讲解下Demo RPC 框架的使用方式。这里涉及Consumer、DemoServiceImp、Provider三个类以及 DemoService 业务接口。
-
+
使用接入的相关类
首先,我们定义DemoService 接口作为业务 Server 接口,具体定义如下:
public interface DemoService {
diff --git a/专栏/Dubbo源码解读与实战-完/13 本地缓存:降低 ZooKeeper 压力的一个常用手段.md.html b/专栏/Dubbo源码解读与实战-完/13 本地缓存:降低 ZooKeeper 压力的一个常用手段.md.html
index 938d9d74..405a283c 100644
--- a/专栏/Dubbo源码解读与实战-完/13 本地缓存:降低 ZooKeeper 压力的一个常用手段.md.html
+++ b/专栏/Dubbo源码解读与实战-完/13 本地缓存:降低 ZooKeeper 压力的一个常用手段.md.html
@@ -291,7 +291,7 @@ function hide_canvas() {
13 本地缓存:降低 ZooKeeper 压力的一个常用手段
从这一课时开始,我们就进入了第二部分:注册中心。注册中心(Registry)在微服务架构中的作用举足轻重,有了它,服务提供者(Provider) 和消费者(Consumer) 就能感知彼此。从下面的 Dubbo 架构图中可知:
-
+
Dubbo 架构图
- Provider 从容器启动后的初始化阶段便会向注册中心完成注册操作;
@@ -301,27 +301,27 @@ function hide_canvas() {
Registry 只是 Consumer 和 Provider 感知彼此状态变化的一种便捷途径而已,它们彼此的实际通讯交互过程是直接进行的,对于 Registry 来说是透明无感的。Provider 状态发生变化了,会由 Registry 主动推送订阅了该 Provider 的所有 Consumer,这保证了 Consumer 感知 Provider 状态变化的及时性,也将和具体业务需求逻辑交互解耦,提升了系统的稳定性。
Dubbo 中存在很多概念,但有些理解起来就特别费劲,如本文的 Registry,翻译过来的意思是“注册中心”,但它其实是应用本地的注册中心客户端,真正的“注册中心”服务是其他独立部署的进程,或进程组成的集群,比如 ZooKeeper 集群。本地的 Registry 通过和 ZooKeeper 等进行实时的信息同步,维持这些内容的一致性,从而实现了注册中心这个特性。另外,就 Registry 而言,Consumer 和 Provider 只是个用户视角的概念,它们被抽象为了一条 URL 。
从本课时开始,我们就真正开始分析 Dubbo 源码了。首先看一下本课程第二部分内容在 Dubbo 架构中所处的位置(如下图红框所示),可以看到这部分内容在整个 Dubbo 体系中还是相对独立的,没有涉及 Protocol、Invoker 等 Dubbo 内部的概念。等介绍完这些概念之后,我们还会回看图中 Registry 红框之外的内容。
-
+
整个 Dubbo 体系图
核心接口
作为“注册中心”部分的第一课时,我们有必要介绍下 dubbo-registry-api 模块中的核心抽象接口,如下图所示:
-
+
在 Dubbo 中,一般使用 Node 这个接口来抽象节点的概念。Node不仅可以表示 Provider 和 Consumer 节点,还可以表示注册中心节点。Node 接口中定义了三个非常基础的方法(如下图所示):
-
+
- getUrl() 方法返回表示当前节点的 URL;
- isAvailable() 检测当前节点是否可用;
- destroy() 方法负责销毁当前节点并释放底层资源。
RegistryService 接口抽象了注册服务的基本行为,如下图所示:
-
+
- register() 方法和 unregister() 方法分别表示注册和取消注册一个 URL。
- subscribe() 方法和 unsubscribe() 方法分别表示订阅和取消订阅一个 URL。订阅成功之后,当订阅的数据发生变化时,注册中心会主动通知第二个参数指定的 NotifyListener 对象,NotifyListener 接口中定义的 notify() 方法就是用来接收该通知的。
- lookup() 方法能够查询符合条件的注册数据,它与 subscribe() 方法有一定的区别,subscribe() 方法采用的是 push 模式,lookup() 方法采用的是 pull 模式。
Registry 接口继承了 RegistryService 接口和 Node 接口,如下图所示,它表示的就是一个拥有注册中心能力的节点,其中的 reExportRegister() 和 reExportUnregister() 方法都是委托给 RegistryService 中的相应方法。
-
+
RegistryFactory 接口是 Registry 的工厂接口,负责创建 Registry 对象,具体定义如下所示,其中 @SPI 注解指定了默认的扩展名为 dubbo,@Adaptive 注解表示会生成适配器类并根据 URL 参数中的 protocol 参数值选择相应的实现。
@SPI("dubbo")
public interface RegistryFactory {
@@ -330,9 +330,9 @@ public interface RegistryFactory {
}
通过下面两张继承关系图可以看出,每个 Registry 实现类都有对应的 RegistryFactory 工厂实现,每个 RegistryFactory 工厂实现只负责创建对应的 Registry 对象。
-
+
RegistryFactory 继承关系图
-
+
Registry 继承关系图
其中,RegistryFactoryWrapper 是 RegistryFactory 接口的 Wrapper 类,它在底层 RegistryFactory 创建的 Registry 对象外层封装了一个 ListenerRegistryWrapper ,ListenerRegistryWrapper 中维护了一个 RegistryServiceListener 集合,会将 register()、subscribe() 等事件通知到 RegistryServiceListener 监听器。
AbstractRegistryFactory 是一个实现了 RegistryFactory 接口的抽象类,提供了规范 URL 的操作以及缓存 Registry 对象的公共能力。其中,缓存 Registry 对象是使用 HashMap<String, Registry> 集合实现的(REGISTRIES 静态字段)。在规范 URL 的实现逻辑中,AbstractRegistryFactory 会将 RegistryService 的类名设置为 URL path 和 interface 参数,同时删除 export 和 refer 参数。
@@ -404,7 +404,7 @@ protected void notify(URL url, NotifyListener listener,
subscribe() 方法会将当前节点作为 Consumer 的 URL 以及相关的 NotifyListener 记录到 subscribed 集合,unsubscribe() 方法会将当前节点的 URL 以及关联的 NotifyListener 从 subscribed 集合删除。
这四个方法都是简单的集合操作,这里我们就不再展示具体代码了。
单看 AbstractRegistry 的实现,上述四个基础的注册、订阅方法都是内存操作,但是 Java 有继承和多态的特性,AbstractRegistry 的子类会覆盖上述四个基础的注册、订阅方法进行增强。
-
+
3. 恢复/销毁
AbstractRegistry 中还有另外两个需要关注的方法:recover() 方法和destroy() 方法。
在 Provider 因为网络问题与注册中心断开连接之后,会进行重连,重新连接成功之后,会调用 recover() 方法将 registered 集合中的全部 URL 重新走一遍 register() 方法,恢复注册数据。同样,recover() 方法也会将 subscribed 集合中的 URL 重新走一遍 subscribe() 方法,恢复订阅监听器。recover() 方法的具体实现比较简单,这里就不再展示,你若感兴趣的话,可以参考源码进行学习。
diff --git a/专栏/Dubbo源码解读与实战-完/15 ZooKeeper 注册中心实现,官方推荐注册中心实践.md.html b/专栏/Dubbo源码解读与实战-完/15 ZooKeeper 注册中心实现,官方推荐注册中心实践.md.html
index 48b8ed99..e1b57ba0 100644
--- a/专栏/Dubbo源码解读与实战-完/15 ZooKeeper 注册中心实现,官方推荐注册中心实践.md.html
+++ b/专栏/Dubbo源码解读与实战-完/15 ZooKeeper 注册中心实现,官方推荐注册中心实践.md.html
@@ -295,7 +295,7 @@ function hide_canvas() {
Dubbo 本身是一个分布式的 RPC 开源框架,各个依赖于 Dubbo 的服务节点都是单独部署的,为了让 Provider 和 Consumer 能够实时获取彼此的信息,就得依赖于一个一致性的服务发现组件实现注册和订阅。Dubbo 可以接入多种服务发现组件,例如,ZooKeeper、etcd、Consul、Eureka 等。其中,Dubbo 特别推荐使用 ZooKeeper。
ZooKeeper 是为分布式应用所设计的高可用且一致性的开源协调服务。它是一个树型的目录服务,支持变更推送,非常适合应用在生产环境中。
下面是 Dubbo 官方文档中的一张图,展示了 Dubbo 在 Zookeeper 中的节点层级结构:
-
+
Zookeeper 存储的 Dubbo 数据
图中的“dubbo”节点是 Dubbo 在 Zookeeper 中的根节点,“dubbo”是这个根节点的默认名称,当然我们也可以通过配置进行修改。
图中 Service 这一层的节点名称是服务接口的全名,例如 demo 示例中,该节点的名称为“org.apache.dubbo.demo.DemoService”。
@@ -303,7 +303,7 @@ function hide_canvas() {
根据不同的 Type 节点,图中 URL 这一层中的节点包括:Provider URL 、Consumer URL 、Routes URL 和 Configurations URL。
ZookeeperRegistryFactory
在前面第 13 课时介绍 Dubbo 注册中心核心概念的时候,我们讲解了 RegistryFactory 这个工厂接口以及其子类 AbstractRegistryFactory,AbstractRegistryFactory 仅仅是提供了缓存 Registry 对象的功能,并未真正实现 Registry 的创建,具体的创建逻辑是由子类完成的。在 dubbo-registry-zookeeper 模块中的 SPI 配置文件(目录位置如下图所示)中,指定了RegistryFactory 的实现类—— ZookeeperRegistryFactory。
-
+
RegistryFactory 的 SPI 配置文件位置
ZookeeperRegistryFactory 实现了 AbstractRegistryFactory,其中的 createRegistry() 方法会创建 ZookeeperRegistry 实例,后续将由该 ZookeeperRegistry 实例完成与 Zookeeper 的交互。
另外,ZookeeperRegistryFactory 中还提供了一个 setZookeeperTransporter() 方法,你可以回顾一下之前我们介绍的 Dubbo SPI 机制,会通过 SPI 或 Spring Ioc 的方式完成自动装载。
@@ -319,7 +319,7 @@ public interface ZookeeperTransporter {
}
我们从代码中可以看到,ZookeeperTransporter 接口被 @SPI 注解修饰,成为一个扩展点,默认选择扩展名 “curator” 的实现,其中的 connect() 方法用于创建 ZookeeperClient 实例(该方法被 @Adaptive 注解修饰,我们可以通过 URL 参数中的 client 或 transporter 参数覆盖 @SPI 注解指定的默认扩展名)。
-
+
按照前面对 Registry 分析的思路,作为一个抽象实现,AbstractZookeeperTransporter 肯定是实现了创建 ZookeeperClient 之外的其他一些增强功能,然后由子类继承。不然的话,直接由 CuratorZookeeperTransporter 实现 ZookeeperTransporter 接口创建 ZookeeperClient 实例并返回即可,没必要在继承关系中再增加一层抽象类。
public class CuratorZookeeperTransporter extends
AbstractZookeeperTransporter {
@@ -360,15 +360,15 @@ public interface ZookeeperTransporter {
- StateListener:主要负责监听 Dubbo 与 Zookeeper 集群的连接状态,包括 SESSION_LOST、CONNECTED、RECONNECTED、SUSPENDED 和 NEW_SESSION_CREATED。
-
+
- DataListener:主要监听某个节点存储的数据变化。
-
+
- **ChildListener:**主要监听某个 ZNode 节点下的子节点变化。
-
+
在 AbstractZookeeperClient 中维护了 stateListeners、listeners 以及 childListeners 三个集合,分别管理上述三种类型的监听器。虽然监听内容不同,但是它们的管理方式是类似的,所以这里我们只分析 listeners 集合的操作:
public void addDataListener(String path,
DataListener listener, Executor executor) {
@@ -456,11 +456,11 @@ public interface ZookeeperTransporter {
在 ZookeeperRegistry 的构造方法中,会通过 ZookeeperTransporter 创建 ZookeeperClient 实例并连接到 Zookeeper 集群,同时还会添加一个连接状态的监听器。在该监听器中主要关注RECONNECTED 状态和 NEW_SESSION_CREATED 状态,在当前 Dubbo 节点与 Zookeeper 的连接恢复或是 Session 恢复的时候,会重新进行注册/订阅,防止数据丢失。这段代码比较简单,我们就不展开分析了。
doRegister() 方法和 doUnregister() 方法的实现都是通过 ZookeeperClient 找到合适的路径,然后创建(或删除)相应的 ZNode 节点。这里唯一需要注意的是,doRegister() 方法注册 Provider URL 的时候,会根据 dynamic 参数决定创建临时 ZNode 节点还是持久 ZNode 节点(默认创建临时 ZNode 节点),这样当 Provider 端与 Zookeeper 会话关闭时,可以快速将变更推送到 Consumer 端。
这里注意一下 toUrlPath() 这个方法得到的路径,是由下图中展示的方法拼装而成的,其中每个方法对应本课时开始展示的 Zookeeper 节点层级图中的一层。
-
+
doSubscribe() 方法的核心是通过 ZookeeperClient 在指定的 path 上添加 ChildListener 监听器,当订阅的节点发现变化的时候,会通过 ChildListener 监听器触发 notify() 方法,在 notify() 方法中会触发传入的 NotifyListener 监听器。
从 doSubscribe() 方法的代码结构可看出,doSubscribe() 方法的逻辑分为了两个大的分支。
一个分支是处理:订阅 URL 中明确指定了 Service 层接口的订阅请求。该分支会从 URL 拿到 Consumer 关注的 category 节点集合,然后在每个 category 节点上添加 ChildListener 监听器。下面是 Demo 示例中 Consumer 订阅的三个 path,图中展示了构造 path 各个部分的相关方法:
-
+
下面是这个分支的核心源码分析:
List<URL> urls = new ArrayList<>();
for (String path : toCategoriesPath(url)) { // 要订阅的所有path
diff --git a/专栏/Dubbo源码解读与实战-完/16 Dubbo Serialize 层:多种序列化算法,总有一款适合你.md.html b/专栏/Dubbo源码解读与实战-完/16 Dubbo Serialize 层:多种序列化算法,总有一款适合你.md.html
index 28d0cf02..df43a014 100644
--- a/专栏/Dubbo源码解读与实战-完/16 Dubbo Serialize 层:多种序列化算法,总有一款适合你.md.html
+++ b/专栏/Dubbo源码解读与实战-完/16 Dubbo Serialize 层:多种序列化算法,总有一款适合你.md.html
@@ -316,7 +316,7 @@ function hide_canvas() {
Protobuf(Google Protocol Buffers)是 Google 公司开发的一套灵活、高效、自动化的、用于对结构化数据进行序列化的协议。但相比于常用的 JSON 格式,Protobuf 有更高的转化效率,时间效率和空间效率都是 JSON 的 5 倍左右。Protobuf 可用于通信协议、数据存储等领域,它本身是语言无关、平台无关、可扩展的序列化结构数据格式。目前 Protobuf提供了 C++、Java、Python、Go 等多种语言的 API,gRPC 底层就是使用 Protobuf 实现的序列化。
dubbo-serialization
Dubbo 为了支持多种序列化算法,单独抽象了一层 Serialize 层,在整个 Dubbo 架构中处于最底层,对应的模块是 dubbo-serialization 模块。 dubbo-serialization 模块的结构如下图所示:
-
+
dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下:
@SPI("hessian2") // 被@SPI注解修饰,默认是使用hessian2序列化算法
public interface Serialization {
@@ -335,7 +335,7 @@ public interface Serialization {
}
Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示:
-
+
这里我们以默认的 hessian2 序列化方式为例,介绍 Serialization 接口的实现以及其他相关实现。 Hessian2Serialization 实现如下所示:
public class Hessian2Serialization implements Serialization {
public byte getContentTypeId() {
@@ -353,11 +353,11 @@ public interface Serialization {
}
Hessian2Serialization 中的 serialize() 方法创建的 ObjectOutput 接口实现为 Hessian2ObjectOutput,继承关系如下图所示:
-
+
在 DataOutput 接口中定义了序列化 Java 中各种数据类型的相应方法,如下图所示,其中有序列化 boolean、short、int、long 等基础类型的方法,也有序列化 String、byte[] 的方法。
-
+
ObjectOutput 接口继承了 DataOutput 接口,并在其基础之上,添加了序列化对象的功能,具体定义如下图所示,其中的 writeThrowable()、writeEvent() 和 writeAttachments() 方法都是调用 writeObject() 方法实现的。
-
+
Hessian2ObjectOutput 中会封装一个 Hessian2Output 对象,需要注意,这个对象是 ThreadLocal 的,与线程绑定。在 DataOutput 接口以及 ObjectOutput 接口中,序列化各类型数据的方法都会委托给 Hessian2Output 对象的相应方法完成,实现如下:
public class Hessian2ObjectOutput implements ObjectOutput {
private static ThreadLocal<Hessian2Output> OUTPUT_TL = ThreadLocal.withInitial(() -> {
@@ -378,7 +378,7 @@ public interface Serialization {
}
Hessian2Serialization 中的 deserialize() 方法创建的 ObjectInput 接口实现为 Hessian2ObjectInput,继承关系如下所示:
-
+
Hessian2ObjectInput 具体的实现与 Hessian2ObjectOutput 类似:在 DataInput 接口中实现了反序列化各种类型的方法,在 ObjectInput 接口中提供了反序列化 Java 对象的功能,在 Hessian2ObjectInput 中会将所有反序列化的实现委托为 Hessian2Input。
了解了 Dubbo Serialize 层的核心接口以及 Hessian2 序列化算法的接入方式之后,你就可以亲自动手,去阅读其他序列化算法对应模块的代码。
总结
diff --git a/专栏/Dubbo源码解读与实战-完/17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?.md.html b/专栏/Dubbo源码解读与实战-完/17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?.md.html
index 1f2acb8a..ccb67be6 100644
--- a/专栏/Dubbo源码解读与实战-完/17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?.md.html
+++ b/专栏/Dubbo源码解读与实战-完/17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?.md.html
@@ -291,17 +291,17 @@ function hide_canvas() {
17 Dubbo Remoting 层核心接口分析:这居然是一套兼容所有 NIO 框架的设计?
在本专栏的第二部分,我们深入介绍了 Dubbo 注册中心的相关实现,下面我们开始介绍 dubbo-remoting 模块,该模块提供了多种客户端和服务端通信的功能。在 Dubbo 的整体架构设计图中,我们可以看到最底层红色框选中的部分即为 Remoting 层,其中包括了 Exchange、Transport和Serialize 三个子层次。这里我们要介绍的 dubbo-remoting 模块主要对应 Exchange 和 Transport 两层。
-
+
Dubbo 整体架构设计图
Dubbo 并没有自己实现一套完整的网络库,而是使用现有的、相对成熟的第三方网络库,例如,Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。
下图展示了 dubbo-remoting 模块的结构,其中每个子模块对应一个第三方 NIO 框架,例如,dubbo-remoting-netty4 子模块使用 Netty4 实现 Dubbo 的远程通信,dubbo-remoting-grizzly 子模块使用 Grizzly 实现 Dubbo 的远程通信。
-
+
其中的 dubbo-remoting-zookeeper,我们在前面第 15 课时介绍基于 Zookeeper 的注册中心实现时已经讲解过了,它使用 Apache Curator 实现了与 Zookeeper 的交互。
dubbo-remoting-api 模块
需要注意的是,Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的,依赖关系如下图所示:
-
+
我们先来看一下 dubbo-remoting-api 中对整个 Remoting 层的抽象,dubbo-remoting-api 模块的结构如下图所示:
-
+
一般情况下,我们会将功能类似或是相关联的类放到一个包中,所以我们需要先来了解 dubbo-remoting-api 模块中各个包的功能。
- buffer 包:定义了缓冲区相关的接口、抽象类以及实现类。缓冲区在NIO框架中是一个不可或缺的角色,在各个 NIO 框架中都有自己的缓冲区实现。这里的 buffer 包在更高的层面,抽象了各个 NIO 框架的缓冲区,同时也提供了一些基础实现。
@@ -313,14 +313,14 @@ function hide_canvas() {
传输层核心接口
在 Dubbo 中会抽象出一个“端点(Endpoint)”的概念,我们可以通过一个 ip 和 port 唯一确定一个端点,两个端点之间会创建 TCP 连接,可以双向传输数据。Dubbo 将 Endpoint 之间的 TCP 连接抽象为通道(Channel),将发起请求的 Endpoint 抽象为客户端(Client),将接收请求的 Endpoint 抽象为服务端(Server)。这些抽象出来的概念,也是整个 dubbo-remoting-api 模块的基础,下面我们会逐个进行介绍。
Dubbo 中Endpoint 接口的定义如下:
-
+
如上图所示,这里的 get*() 方法是获得 Endpoint 本身的一些属性,其中包括获取 Endpoint 的本地地址、关联的 URL 信息以及底层 Channel 关联的 ChannelHandler。send() 方法负责数据发送,两个重载的区别在后面介绍 Endpoint 实现的时候我们再详细说明。最后两个 close() 方法的重载以及 startClose() 方法用于关闭底层 Channel ,isClosed() 方法用于检测底层 Channel 是否已关闭。
Channel 是对两个 Endpoint 连接的抽象,好比连接两个位置的传送带,两个 Endpoint 传输的消息就好比传送带上的货物,消息发送端会往 Channel 写入消息,而接收端会从 Channel 读取消息。这与第 10 课时介绍的 Netty 中的 Channel 基本一致。
-
+
下面是Channel 接口的定义,我们可以看出两点:一个是 Channel 接口继承了 Endpoint 接口,也具备开关状态以及发送数据的能力;另一个是可以在 Channel 上附加 KV 属性。
-
+
ChannelHandler 是注册在 Channel 上的消息处理器,在 Netty 中也有类似的抽象,相信你对此应该不会陌生。下图展示了 ChannelHandler 接口的定义,在 ChannelHandler 中可以处理 Channel 的连接建立以及连接断开事件,还可以处理读取到的数据、发送的数据以及捕获到的异常。从这些方法的命名可以看到,它们都是动词的过去式,说明相应事件已经发生过了。
-
+
需要注意的是:ChannelHandler 接口被 @SPI 注解修饰,表示该接口是一个扩展点。
在前面课时介绍 Netty 的时候,我们提到过有一类特殊的 ChannelHandler 专门负责实现编解码功能,从而实现字节数据与有意义的消息之间的转换,或是消息之间的相互转换。在dubbo-remoting-api 中也有相似的抽象,如下所示:
@SPI
@@ -339,9 +339,9 @@ public interface Codec2 {
这里需要关注的是 Codec2 接口被 @SPI 接口修饰了,表示该接口是一个扩展接口,同时其 encode() 方法和 decode() 方法都被 @Adaptive 注解修饰,也就会生成适配器类,其中会根据 URL 中的 codec 值确定具体的扩展实现类。
DecodeResult 这个枚举是在处理 TCP 传输时粘包和拆包使用的,之前简易版本 RPC 也处理过这种问题,例如,当前能读取到的数据不足以构成一个消息时,就会使用 NEED_MORE_INPUT 这个枚举。
接下来看Client 和 RemotingServer 两个接口,分别抽象了客户端和服务端,两者都继承了 Channel、Resetable 等接口,也就是说两者都具备了读写数据能力。
-
+
Client 和 Server 本身都是 Endpoint,只不过在语义上区分了请求和响应的职责,两者都具备发送的能力,所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel,而 Server 可以接收多个 Client 发起的 Channel 连接。所以在 RemotingServer 接口中定义了查询 Channel 的相关方法,如下图所示:
-
+
Dubbo 在 Client 和 Server 之上又封装了一层Transporter 接口,其具体定义如下:
@SPI("netty")
public interface Transporter {
@@ -355,10 +355,10 @@ public interface Transporter {
我们看到 Transporter 接口上有 @SPI 注解,它是一个扩展接口,默认使用“netty”这个扩展名,@Adaptive 注解的出现表示动态生成适配器类,会先后根据“server”“transporter”的值确定 RemotingServer 的扩展实现类,先后根据“client”“transporter”的值确定 Client 接口的扩展实现。
Transporter 接口的实现有哪些呢?如下图所示,针对每个支持的 NIO 库,都有一个 Transporter 接口实现,散落在各个 dubbo-remoting-* 实现模块中。
-
+
这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。
-
-
+
+
相信看到这里,你应该已经发现 Transporter 这一层抽象出来的接口,与 Netty 的核心接口是非常相似的。那为什么要单独抽象出 Transporter层,而不是像简易版 RPC 框架那样,直接让上层使用 Netty 呢?
其实这个问题的答案也呼之欲出了,Netty、Mina、Grizzly 这个 NIO 库对外接口和使用方式不一样,如果在上层直接依赖了 Netty 或是 Grizzly,就依赖了具体的 NIO 库实现,而不是依赖一个有传输能力的抽象,后续要切换实现的话,就需要修改依赖和接入的相关代码,非常容易改出 Bug。这也不符合设计模式中的开放-封闭原则。
有了 Transporter 层之后,我们可以通过 Dubbo SPI 修改使用的具体 Transporter 扩展实现,从而切换到不同的 Client 和 RemotingServer 实现,达到底层 NIO 库切换的目的,而且无须修改任何代码。即使有更先进的 NIO 库出现,我们也只需要开发相应的 dubbo-remoting-* 实现模块提供 Transporter、Client、RemotingServer 等核心接口的实现,即可接入,完全符合开放-封闭原则。
@@ -404,7 +404,7 @@ public interface Transporter {
- 无论是 Client 还是 RemotingServer,都会使用 ChannelHandler 处理 Channel 中传输的数据,其中负责编解码的 ChannelHandler 被抽象出为 Codec2 接口。
整个架构如下图所示,与 Netty 的架构非常类似。
-
+
Transporter 层整体结构图
总结
本课时我们首先介绍了 dubbo-remoting 模块在 Dubbo 架构中的位置,以及 dubbo-remoting 模块的结构。接下来分析了 dubbo-remoting 模块中各个子模块之间的依赖关系,并重点介绍了 dubbo-remoting-api 子模块中各个包的核心功能。最后我们还深入分析了整个 Transport 层的核心接口,以及这些接口抽象出来的 Transporter 架构。
diff --git a/专栏/Dubbo源码解读与实战-完/18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工.md.html b/专栏/Dubbo源码解读与实战-完/18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工.md.html
index 24f485ea..6bd4f215 100644
--- a/专栏/Dubbo源码解读与实战-完/18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工.md.html
+++ b/专栏/Dubbo源码解读与实战-完/18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工.md.html
@@ -291,7 +291,7 @@ function hide_canvas() {
18 Buffer 缓冲区:我们不生产数据,我们只是数据的搬运工
Buffer 是一种字节容器,在 Netty 等 NIO 框架中都有类似的设计,例如,Java NIO 中的ByteBuffer、Netty4 中的 ByteBuf。Dubbo 抽象出了 ChannelBuffer 接口对底层 NIO 框架中的 Buffer 设计进行统一,其子类如下图所示:
-
+
ChannelBuffer 继承关系图
下面我们就按照 ChannelBuffer 的继承结构,从顶层的 ChannelBuffer 接口开始,逐个向下介绍,直至最底层的各个实现类。
ChannelBuffer 接口
@@ -303,7 +303,7 @@ function hide_canvas() {
- capacity()、clear()、copy() 等辅助方法用来获取 ChannelBuffer 容量以及实现清理、拷贝数据的功能,这里不再赘述。
- factory() 方法:该方法返回创建 ChannelBuffer 的工厂对象,ChannelBufferFactory 中定义了多个 getBuffer() 方法重载来创建 ChannelBuffer,如下图所示,这些 ChannelBufferFactory的实现都是单例的。
-
+
ChannelBufferFactory 继承关系图
AbstractChannelBuffer 抽象类实现了 ChannelBuffer 接口的大部分方法,其核心是维护了以下四个索引。
@@ -358,7 +358,7 @@ public ChannelBuffer getBuffer(byte[] array, int offset, int length) {
- factory(ChannelBufferFactory 类型),用于创建被修饰的 HeapChannelBuffer 对象的 ChannelBufferFactory 工厂,默认为 HeapChannelBufferFactory。
DynamicChannelBuffer 需要关注的是 ensureWritableBytes() 方法,该方法实现了动态扩容的功能,在每次写入数据之前,都需要调用该方法确定当前可用空间是否足够,调用位置如下图所示:
-
+
ensureWritableBytes() 方法如果检测到底层 ChannelBuffer 对象的空间不足,则会创建一个新的 ChannelBuffer(空间扩大为原来的两倍),然后将原来 ChannelBuffer 中的数据拷贝到新 ChannelBuffer 中,最后将 buffer 字段指向新 ChannelBuffer 对象,完成整个扩容操作。ensureWritableBytes() 方法的具体实现如下:
public void ensureWritableBytes(int minWritableBytes) {
if (minWritableBytes <= writableBytes()) {
@@ -404,10 +404,10 @@ public void setBytes(int index, byte[] src, int srcIndex, int length) {
NettyBackedChannelBuffer 对 ChannelBuffer 接口的实现都是调用底层封装的 Netty ByteBuf 实现的,这里就不再展开介绍,你若感兴趣的话也可以参考相关代码进行学习。
相关 Stream 以及门面类
在 ChannelBuffer 基础上,Dubbo 提供了一套输入输出流,如下图所示:
-
+
ChannelBufferInputStream 底层封装了一个 ChannelBuffer,其实现 InputStream 接口的 read*() 方法全部都是从 ChannelBuffer 中读取数据。ChannelBufferInputStream 中还维护了一个 startIndex 和一个endIndex 索引,作为读取数据的起止位置。ChannelBufferOutputStream 与 ChannelBufferInputStream 类似,会向底层的 ChannelBuffer 写入数据,这里就不再展开,你若感兴趣的话可以参考源码进行分析。
最后要介绍 ChannelBuffers 这个门面类,下图展示了 ChannelBuffers 这个门面类的所有方法:
-
+
对这些方法进行分类,可归纳出如下这些方法。
- dynamicBuffer() 方法:创建 DynamicChannelBuffer 对象,初始化大小由第一个参数指定,默认为 256。
diff --git a/专栏/Dubbo源码解读与实战-完/19 Transporter 层核心实现:编解码与线程模型一文打尽(上).md.html b/专栏/Dubbo源码解读与实战-完/19 Transporter 层核心实现:编解码与线程模型一文打尽(上).md.html
index 94176525..86ff3435 100644
--- a/专栏/Dubbo源码解读与实战-完/19 Transporter 层核心实现:编解码与线程模型一文打尽(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/19 Transporter 层核心实现:编解码与线程模型一文打尽(上).md.html
@@ -293,7 +293,7 @@ function hide_canvas() {
在第 17 课时中,我们详细介绍了 dubbo-remoting-api 模块中 Transporter 相关的核心抽象接口,本课时将继续介绍 dubbo-remoting-api 模块的其他内容。这里我们依旧从 Transporter 层的 RemotingServer、Client、Channel、ChannelHandler 等核心接口出发,介绍这些核心接口的实现。
AbstractPeer 抽象类
首先,我们来看 AbstractPeer 这个抽象类,它同时实现了 Endpoint 接口和 ChannelHandler 接口,如下图所示,它也是 AbstractChannel、AbstractEndpoint 抽象类的父类。
-
+
AbstractPeer 继承关系
Netty 中也有 ChannelHandler、Channel 等接口,但无特殊说明的情况下,这里的接口指的都是 Dubbo 中定义的接口。如果涉及 Netty 中的接口,会进行特殊说明。
@@ -338,7 +338,7 @@ function hide_canvas() {
Server 继承路线分析
AbstractServer 和 AbstractClient 都实现了 AbstractEndpoint 抽象类,我们先来看 AbstractServer 的实现。AbstractServer 在继承了 AbstractEndpoint 的同时,还实现了 RemotingServer 接口,如下图所示:
-
+
AbstractServer 继承关系图
AbstractServer 是对服务端的抽象,实现了服务端的公共逻辑。AbstractServer 的核心字段有下面几个。
@@ -390,7 +390,7 @@ function hide_canvas() {
}
在 createExecutor() 方法中,会通过 Dubbo SPI 查找 ThreadPool 接口的扩展实现,并调用其 getExecutor() 方法创建线程池。ThreadPool 接口被 @SPI 注解修饰,默认使用 FixedThreadPool 实现,但是 ThreadPool 接口中的 getExecutor() 方法被 @Adaptive 注解修饰,动态生成的适配器类会优先根据 URL 中的 threadpool 参数选择 ThreadPool 的扩展实现。ThreadPool 接口的实现类如下图所示:
-
+
ThreadPool 继承关系图
不同实现会根据 URL 参数创建不同特性的线程池,这里以CacheThreadPool为例进行分析:
public Executor getExecutor(URL url) {
@@ -514,12 +514,12 @@ protected void afterExecute(Runnable r, Throwable t) {
看完 NettyServer 实现的 doOpen() 方法之后,你会发现它和简易版 RPC 框架中启动一个 Netty 的 Server 端基本流程类似:初始化 ServerBootstrap、创建 Boss EventLoopGroup 和 Worker EventLoopGroup、创建 ChannelInitializer 指定如何初始化 Channel 上的 ChannelHandler 等一系列 Netty 使用的标准化流程。
其实在 Transporter 这一层看,功能的不同其实就是注册在 Channel 上的 ChannelHandler 不同,通过 doOpen() 方法得到的 Server 端结构如下:
-
+
NettyServer 模型
核心 ChannelHandler
下面我们来逐个看看这四个 ChannelHandler 的核心功能。
首先是decoder 和 encoder,它们都是 NettyCodecAdapter 的内部类,如下图所示,分别继承了 Netty 中的 ByteToMessageDecoder 和 MessageToByteEncoder:
-
+
还记得 AbstractEndpoint 抽象类中的 codec 字段(Codec2 类型)吗?InternalDecoder 和 InternalEncoder 会将真正的编解码功能委托给 NettyServer 关联的这个 Codec2 对象去处理,这里以 InternalDecoder 为例进行分析:
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) {
InternalEncoder 的具体实现就不再展开讲解了,你若感兴趣可以翻看源码进行研究和分析。
接下来是IdleStateHandler,它是 Netty 提供的一个工具型 ChannelHandler,用于定时心跳请求的功能或是自动关闭长时间空闲连接的功能。它的原理到底是怎样的呢?在 IdleStateHandler 中通过 lastReadTime、lastWriteTime 等几个字段,记录了最近一次读/写事件的时间,IdleStateHandler 初始化的时候,会创建一个定时任务,定时检测当前时间与最后一次读/写时间的差值。如果超过我们设置的阈值(也就是上面 NettyServer 中设置的 idleTimeout),就会触发 IdleStateEvent 事件,并传递给后续的 ChannelHandler 进行处理。后续 ChannelHandler 的 userEventTriggered() 方法会根据接收到的 IdleStateEvent 事件,决定是关闭长时间空闲的连接,还是发送心跳探活。
最后来看NettyServerHandler,它继承了 ChannelDuplexHandler,这是 Netty 提供的一个同时处理 Inbound 数据和 Outbound 数据的 ChannelHandler,从下面的继承图就能看出来。
-
+
NettyServerHandler 继承关系图
在 NettyServerHandler 中有 channels 和 handler 两个核心字段。
- channels(Map<String,Channel>集合):记录了当前 Server 创建的所有 Channel,从下图中可以看到,连接创建(触发 channelActive() 方法)、连接断开(触发 channelInactive()方法)会操作 channels 集合进行相应的增删。
-
+
- handler(ChannelHandler 类型):NettyServerHandler 内几乎所有方法都会触发该 Dubbo ChannelHandler 对象(如下图)。
-
+
这里以 write() 方法为例进行简单分析:
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) {
final NettyServerHandler nettyServerHandler = new NettyServerHandler(getUrl(), this);
其中第二个参数传入的是 NettyServer 这个对象,你可以追溯一下 NettyServer 的继承结构,会发现它的最顶层父类 AbstractPeer 实现了 ChannelHandler,并且将所有的方法委托给其中封装的 ChannelHandler 对象,如下图所示:
-
+
也就是说,NettyServerHandler 会将数据委托给这个 ChannelHandler。
到此为止,Server 这条继承线就介绍完了。你可以回顾一下,从 AbstractPeer 开始往下,一路继承下来,NettyServer 拥有了 Endpoint、ChannelHandler 以及RemotingServer多个接口的能力,关联了一个 ChannelHandler 对象以及 Codec2 对象,并最终将数据委托给这两个对象进行处理。所以,上层调用方只需要实现 ChannelHandler 和 Codec2 这两个接口就可以了。
-
+
总结
本课时重点介绍了 Dubbo Transporter 层中 Server 相关的实现。
首先,我们介绍了 AbstractPeer 这个最顶层的抽象类,了解了 Server、Client 和 Channel 的公共属性。接下来,介绍了 AbstractEndpoint 抽象类,它提供了编解码等 Server 和 Client 所需的公共能力。最后,我们深入分析了 AbstractServer 抽象类以及基于 Netty 4 实现的 NettyServer,同时,还深入剖析了涉及的各种组件,例如,ExecutorRepository、NettyServerHandler 等。
diff --git a/专栏/Dubbo源码解读与实战-完/20 Transporter 层核心实现:编解码与线程模型一文打尽(下).md.html b/专栏/Dubbo源码解读与实战-完/20 Transporter 层核心实现:编解码与线程模型一文打尽(下).md.html
index 7dfe424e..03f82c12 100644
--- a/专栏/Dubbo源码解读与实战-完/20 Transporter 层核心实现:编解码与线程模型一文打尽(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/20 Transporter 层核心实现:编解码与线程模型一文打尽(下).md.html
@@ -339,7 +339,7 @@ function hide_canvas() {
}
得到的 NettyClient 结构如下图所示:
-
+
NettyClient 结构图
NettyClientHandler 的实现方法与上一课时介绍的 NettyServerHandler 类似,同样是实现了 Netty 中的 ChannelDuplexHandler,其中会将所有方法委托给 NettyClient 关联的 ChannelHandler 对象进行处理。两者在 userEventTriggered() 方法的实现上有所不同,NettyServerHandler 在收到 IdleStateEvent 事件时会断开连接,而 NettyClientHandler 则会发送心跳消息,具体实现如下:
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
@@ -364,7 +364,7 @@ function hide_canvas() {
- active(AtomicBoolean):用于标识当前 Channel 是否可用。
另外,在 NettyChannel 中还有一个静态的 Map 集合(CHANNEL_MAP 字段),用来缓存当前 JVM 中 Netty 框架 Channel 与 Dubbo Channel 之间的映射关系。从下图的调用关系中可以看到,NettyChannel 提供了读写 CHANNEL_MAP 集合的方法:
-
+
NettyChannel 中还有一个要介绍的是 send() 方法,它会通过底层关联的 Netty 框架 Channel,将数据发送到对端。其中,可以通过第二个参数指定是否等待发送操作结束,具体实现如下:
public void send(Object message, boolean sent) throws RemotingException {
// 调用AbstractChannel的send()方法检测连接是否可用
@@ -388,7 +388,7 @@ function hide_canvas() {
ChannelHandler 继承线分析
前文介绍的 AbstractServer、AbstractClient 以及 Channel 实现,都是通过 AbstractPeer 实现了 ChannelHandler 接口,但只是做了一层简单的委托(也可以说成是装饰器),将全部方法委托给了其底层关联的 ChannelHandler 对象。
这里我们就深入分析 ChannelHandler 的其他实现类,涉及的实现类如下所示:
-
+
ChannelHandler 继承关系图
其中ChannelHandlerDispatcher在[第 17 课时]已经介绍过了,它负责将多个 ChannelHandler 对象聚合成一个 ChannelHandler 对象。
ChannelHandlerAdapter是 ChannelHandler 的一个空实现,TelnetHandlerAdapter 继承了它并实现了 TelnetHandler 接口。至于Dubbo 对 Telnet 的支持,我们会在后面的课时中单独介绍,这里就先不展开分析了。
@@ -420,7 +420,7 @@ function hide_canvas() {
通过上述介绍,我们发现 AbstractChannelHandlerDelegate 下的三个实现,其实都是在原有 ChannelHandler 的基础上添加了一些增强功能,这是典型的装饰器模式的应用。
Dispatcher 与 ChannelHandler
接下来,我们介绍 ChannelHandlerDelegate 接口的另一条继承线——WrappedChannelHandler,其子类主要是决定了 Dubbo 以何种线程模型处理收到的事件和消息,就是所谓的“消息派发机制”,与前面介绍的 ThreadPool 有紧密的联系。
-
+
WrappedChannelHandler 继承关系图
从上图中我们可以看到,每个 WrappedChannelHandler 实现类的对象都由一个相应的 Dispatcher 实现类创建,下面是 Dispatcher 接口的定义:
@SPI(AllDispatcher.NAME) // 默认扩展名是all
@@ -520,7 +520,7 @@ public interface Dispatcher {
老版本中没有 ExecutorRepository 的概念,不会根据 URL 复用同一个线程池,而是通过 SPI 找到 ThreadPool 实现创建新线程池。
此时,Dubbo Consumer 同步请求的线程模型如下图所示:
-
+
Dubbo Consumer 同步请求线程模型
从图中我们可以看到下面的请求-响应流程:
@@ -531,7 +531,7 @@ public interface Dispatcher {
在这个设计里面,Consumer 端会维护一个线程池,而且线程池是按照连接隔离的,即每个连接独享一个线程池。这样,当面临需要消费大量服务且并发数比较大的场景时,例如,典型网关类场景,可能会导致 Consumer 端线程个数不断增加,导致线程调度消耗过多 CPU ,也可能因为线程创建过多而导致 OOM。
为了解决上述问题,Dubbo 在 2.7.5 版本之后,引入了 ThreadlessExecutor,将线程模型修改成了下图的样子:
-
+
引入 ThreadlessExecutor 后的结构图
- 业务线程发出请求之后,拿到一个 Future 对象。
@@ -586,10 +586,10 @@ public interface Dispatcher {
}
结合前面的分析,我们可以得到下面这张图:
-
+
Server 端 ChannelHandler 结构图
我们可以在创建 NettyServerHandler 的地方添加断点 Debug 得到下图,也印证了上图的内容:
-
+
总结
本课时我们重点介绍了 Dubbo Transporter 层中 Client、 Channel、ChannelHandler 相关的实现以及优化。
首先我们介绍了 AbstractClient 抽象接口以及基于 Netty 4 的 NettyClient 实现。接下来,介绍了 AbstractChannel 抽象类以及 NettyChannel 实现。最后,我们深入分析了 ChannelHandler 接口实现,其中详细分析 WrappedChannelHandler 等关键 ChannelHandler 实现,以及 ThreadlessExecutor 优化。
diff --git a/专栏/Dubbo源码解读与实战-完/21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上).md.html b/专栏/Dubbo源码解读与实战-完/21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上).md.html
index baa568cc..dd11996d 100644
--- a/专栏/Dubbo源码解读与实战-完/21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/21 Exchange 层剖析:彻底搞懂 Request-Response 模型(上).md.html
@@ -328,10 +328,10 @@ function hide_canvas() {
ExchangeChannel & DefaultFuture
在前面的课时中,我们介绍了 Channel 接口的功能以及 Transport 层对 Channel 接口的实现。在 Exchange 层中定义了 ExchangeChannel 接口,它在 Channel 接口之上抽象了 Exchange 层的网络连接。ExchangeChannel 接口的定义如下:
-
+
ExchangeChannel 接口
其中,request() 方法负责发送请求,从图中可以看到这里有两个重载,其中一个重载可以指定请求的超时时间,返回值都是 Future 对象。
-
+
HeaderExchangeChannel 继承关系图
从上图中可以看出,HeaderExchangeChannel 是 ExchangeChannel 的实现,它本身是 Channel 的装饰器,封装了一个 Channel 对象,其 send() 方法和 request() 方法的实现都是依赖底层修饰的这个 Channel 对象实现的。
public void send(Object message, boolean sent) throws RemotingException {
@@ -454,10 +454,10 @@ private void notifyTimeout(DefaultFuture future) {
HeaderExchangeHandler
在前面介绍 DefaultFuture 时,我们简单说明了请求-响应的流程,其实无论是发送请求还是处理响应,都会涉及 HeaderExchangeHandler,所以这里我们就来介绍一下 HeaderExchangeHandler 的内容。
HeaderExchangeHandler 是 ExchangeHandler 的装饰器,其中维护了一个 ExchangeHandler 对象,ExchangeHandler 接口是 Exchange 层与上层交互的接口之一,上层调用方可以实现该接口完成自身的功能;然后再由 HeaderExchangeHandler 修饰,具备 Exchange 层处理 Request-Response 的能力;最后再由 Transport ChannelHandler 修饰,具备 Transport 层的能力。如下图所示:
-
+
ChannelHandler 继承关系总览图
HeaderExchangeHandler 作为一个装饰器,其 connected()、disconnected()、sent()、received()、caught() 方法最终都会转发给上层提供的 ExchangeHandler 进行处理。这里我们需要聚焦的是 HeaderExchangeHandler 本身对 Request 和 Response 的处理逻辑。
-
+
received() 方法处理的消息分类
结合上图,我们可以看到在received() 方法中,对收到的消息进行了分类处理。
diff --git a/专栏/Dubbo源码解读与实战-完/22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下).md.html b/专栏/Dubbo源码解读与实战-完/22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下).md.html
index b6987e7b..9fd38072 100644
--- a/专栏/Dubbo源码解读与实战-完/22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/22 Exchange 层剖析:彻底搞懂 Request-Response 模型(下).md.html
@@ -299,7 +299,7 @@ function hide_canvas() {
因此,HeaderExchangeClient 侧重定时轮资源的分配、定时任务的创建和取消。
HeaderExchangeClient 实现的是 ExchangeClient 接口,如下图所示,间接实现了 ExchangeChannel 和 Client 接口,ExchangeClient 接口是个空接口,没有定义任何方法。
-
+
HeaderExchangeClient 继承关系图
HeaderExchangeClient 中有以下两个核心字段。
@@ -327,7 +327,7 @@ function hide_canvas() {
其实,startReconnectTask() 方法的具体实现与前面展示的 startHeartBeatTask() 方法类似,这里就不再赘述。
下面我们继续回到心跳定时任务进行分析,你可以回顾第 20 课时介绍的 NettyClient 实现,其 canHandleIdle() 方法返回 true,表示该实现可以自己发送心跳请求,无须 HeaderExchangeClient 再启动一个定时任务。NettyClient 主要依靠 IdleStateHandler 中的定时任务来触发心跳事件,依靠 NettyClientHandler 来发送心跳请求。
对于无法自己发送心跳请求的 Client 实现,HeaderExchangeClient 会为其启动 HeartbeatTimerTask 心跳定时任务,其继承关系如下图所示:
-
+
TimerTask 继承关系图
我们先来看 AbstractTimerTask 这个抽象类,它有三个字段。
@@ -377,7 +377,7 @@ function hide_canvas() {
在 HeaderExchangeChannel.close(timeout) 方法中首先会将自身的 closed 字段设置为 true,这样就不会继续发送请求。如果当前 Channel 上还有请求未收到响应,会循环等待至收到响应,如果超时未收到响应,会自己创建一个状态码将连接关闭的 Response 交给 DefaultFuture 处理,与收到 disconnected 事件相同。然后会关闭 Transport 层的 Channel,以 NettyChannel 为例,NettyChannel.close() 方法会先将自身的 closed 字段设置为 true,清理 CHANNEL_MAP 缓存中的记录,以及 Channel 的附加属性,最后才是关闭 io.netty.channel.Channel。
HeaderExchangeServer
下面再来看 HeaderExchangeServer,其继承关系如下图所示,其中 Endpoint、RemotingServer、Resetable 这三个接口我们在前面已经详细介绍过了,这里不再重复。
-
+
HeaderExchangeServer 的继承关系图
与前面介绍的 HeaderExchangeClient 一样,HeaderExchangeServer 是 RemotingServer 的装饰器,实现自 RemotingServer 接口的大部分方法都委托给了所修饰的 RemotingServer 对象。
在 HeaderExchangeServer 的构造方法中,会启动一个 CloseTimerTask 定时任务,定期关闭长时间空闲的连接,具体的实现方式与 HeaderExchangeClient 中的两个定时任务类似,这里不再展开分析。
@@ -423,7 +423,7 @@ public interface Exchanger {
}
Dubbo 只为 Exchanger 接口提供了 HeaderExchanger 这一个实现,其中 connect() 方法创建的是 HeaderExchangeClient 对象,bind() 方法创建的是 HeaderExchangeServer 对象,如下图所示:
-
+
HeaderExchanger 门面类
从 HeaderExchanger 的实现可以看到,它会在 Transport 层的 Client 和 Server 实现基础之上,添加前文介绍的 HeaderExchangeClient 和 HeaderExchangeServer 装饰器。同时,为上层实现的 ExchangeHandler 实例添加了 HeaderExchangeHandler 以及 DecodeHandler 两个修饰器:
public class HeaderExchanger implements Exchanger {
@@ -441,7 +441,7 @@ public interface Exchanger {
再谈 Codec2
在前面第 17 课时介绍 Dubbo Remoting 核心接口的时候提到,Codec2 接口提供了 encode() 和 decode() 两个方法来实现消息与字节流之间的相互转换。需要注意与 DecodeHandler 区分开来,DecodeHandler 是对请求体和响应结果的解码,Codec2 是对整个请求和响应的编解码。
这里重点介绍 Transport 层和 Exchange 层对 Codec2 接口的实现,涉及的类如下图所示:
-
+
AbstractCodec抽象类并没有实现 Codec2 中定义的接口方法,而是提供了几个给子类用的基础方法,下面简单说明这些方法的功能。
- getSerialization() 方法:通过 SPI 获取当前使用的序列化方式。
@@ -451,7 +451,7 @@ public interface Exchanger {
接下来看TransportCodec,我们可以看到这类上被标记了 @Deprecated 注解,表示已经废弃。TransportCodec 的实现非常简单,其中根据 getSerialization() 方法选择的序列化方法对传入消息或 ChannelBuffer 进行序列化或反序列化,这里就不再介绍 TransportCodec 实现了。
TelnetCodec继承了 TransportCodec 序列化和反序列化的基本能力,同时还提供了对 Telnet 命令处理的能力。
最后来看ExchangeCodec,它在 TelnetCodec 的基础之上,添加了处理协议头的能力。下面是 Dubbo 协议的格式,能够清晰地看出协议中各个数据所占的位数:
-
+
Dubbo 协议格式
结合上图,我们来深入了解一下 Dubbo 协议中各个部分的含义:
diff --git a/专栏/Dubbo源码解读与实战-完/23 核心接口介绍,RPC 层骨架梳理.md.html b/专栏/Dubbo源码解读与实战-完/23 核心接口介绍,RPC 层骨架梳理.md.html
index d551a4d8..a2528cb0 100644
--- a/专栏/Dubbo源码解读与实战-完/23 核心接口介绍,RPC 层骨架梳理.md.html
+++ b/专栏/Dubbo源码解读与实战-完/23 核心接口介绍,RPC 层骨架梳理.md.html
@@ -291,15 +291,15 @@ function hide_canvas() {
23 核心接口介绍,RPC 层骨架梳理
在前面的课程中,我们深入介绍了 Dubbo 架构中的 Dubbo Remoting 层的相关内容,了解了 Dubbo 底层的网络模型以及线程模型。从本课时开始,我们就开始介绍 Dubbo Remoting 上面的一层—— Protocol 层(如下图所示),Protocol 层是 Remoting 层的使用者,会通过 Exchangers 门面类创建 ExchangeClient 以及 ExchangeServer,还会创建相应的 ChannelHandler 实现以及 Codec2 实现并交给 Exchange 层进行装饰。
-
+
Dubbo 架构中 Protocol 层的位置图
Protocol 层在 Dubbo 源码中对应的是 dubbo-rpc 模块,该模块的结构如下图所示:
-
+
dubbo-rpc 模块结构图
我们可以看到有很多模块,和 dubbo-remoting 模块类似,其中 dubbo-rpc-api 是对具体协议、服务暴露、服务引用、代理等的抽象,是整个 Protocol 层的核心。剩余的模块,例如,dubbo-rpc-dubbo、dubbo-rpc-grpc、dubbo-rpc-http 等,都是 Dubbo 支持的具体协议,可以看作dubbo-rpc-api 模块的具体实现。
dubbo-rpc-api
这里我们首先来看 dubbo-rpc-api 模块的包结构,如下图所示:
-
+
dubbo-rpc-api 模块的包结构图
根据上图展示的 dubbo-rpc-api 模块的结构,我们可以看到 dubbo-rpc-api 模块包括了以下几个核心包。
@@ -314,7 +314,7 @@ function hide_canvas() {
在 Dubbo RPC 层中涉及的核心接口有 Invoker、Invocation、Protocol、Result、Exporter、ProtocolServer、Filter 等,这些接口分别抽象了 Dubbo RPC 层的不同概念,看似相互独立,但又相互协同,一起构建出了 DubboRPC 层的骨架。下面我们将逐一介绍这些核心接口的含义。
首先要介绍的是 Dubbo 中非常重要的一个接口——Invoker 接口。可以说,Invoker 渗透在整个 Dubbo 代码实现里,Dubbo 中的很多设计思路都会向 Invoker 这个概念靠拢,但这对于刚接触这部分代码的同学们来说,可能不是很友好。
这里我们借助如下这样一个精简的示意图来对比说明两种最关键的 Invoker:服务提供 Invoker 和服务消费 Invoker。
-
+
Invoker 核心示意图
以 dubbo-demo-annotation-consumer 这个示例项目中的 Consumer 为例,它会拿到一个 DemoService 对象,如下所示,这其实是一个代理(即上图中的 Proxy),这个 Proxy 底层就会通过 Invoker 完成网络调用:
@Component("demoServiceComponent")
diff --git a/专栏/Dubbo源码解读与实战-完/24 从 Protocol 起手,看服务暴露和服务引用的全流程(上).md.html b/专栏/Dubbo源码解读与实战-完/24 从 Protocol 起手,看服务暴露和服务引用的全流程(上).md.html
index 273b414a..f97cee46 100644
--- a/专栏/Dubbo源码解读与实战-完/24 从 Protocol 起手,看服务暴露和服务引用的全流程(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/24 从 Protocol 起手,看服务暴露和服务引用的全流程(上).md.html
@@ -291,13 +291,13 @@ function hide_canvas() {
24 从 Protocol 起手,看服务暴露和服务引用的全流程(上)
在上一课时我们讲解了 Protocol 的核心接口,那本课时我们就以 Protocol 接口为核心,详细介绍整个 Protocol 的核心实现。下图展示了 Protocol 接口的继承关系:
-
+
Protocol 接口继承关系图
其中,AbstractProtocol提供了一些 Protocol 实现需要的公共能力以及公共字段,它的核心字段有如下三个。
- exporterMap(Map<String, Exporter<?>>类型):用于存储出去的服务集合,其中的 Key 通过 ProtocolUtils.serviceKey() 方法创建的服务标识,在 ProtocolUtils 中维护了多层的 Map 结构(如下图所示)。首先按照 group 分组,在实践中我们可以根据需求设置 group,例如,按照机房、地域等进行 group 划分,做到就近调用;在 GroupServiceKeyCache 中,依次按照 serviceName、serviceVersion、port 进行分类,最终缓存的 serviceKey 是前面三者拼接而成的。
-
+
groupServiceKeyCacheMap 结构图
- serverMap(Map<String, ProtocolServer>类型):记录了全部的 ProtocolServer 实例,其中的 Key 是 host 和 port 组成的字符串,Value 是监听该地址的 ProtocolServer。ProtocolServer 就是对 RemotingServer 的一层简单封装,表示一个服务端。
@@ -338,13 +338,13 @@ function hide_canvas() {
1. DubboExporter
这里涉及的第一个点是 DubboExporter 对 Invoker 的封装,DubboExporter 的继承关系如下图所示:
-
+
DubboExporter 继承关系图
AbstractExporter 中维护了一个 Invoker 对象,以及一个 unexported 字段(boolean 类型),在 unexport() 方法中会设置 unexported 字段为 true,并调用 Invoker 对象的 destory() 方法进行销毁。
DubboExporter 也比较简单,其中会维护底层 Invoker 对应的 ServiceKey 以及 DubboProtocol 中的 exportMap 集合,在其 unexport() 方法中除了会调用父类 AbstractExporter 的 unexport() 方法之外,还会清理该 DubboExporter 实例在 exportMap 中相应的元素。
2. 服务端初始化
了解了 Exporter 实现之后,我们继续看 DubboProtocol 中服务发布的流程。从下面这张调用关系图中可以看出,openServer() 方法会一路调用前面介绍的 Exchange 层、Transport 层,并最终创建 NettyServer 来接收客户端的请求。
-
+
export() 方法调用栈
下面我们将逐个介绍 export() 方法栈中的每个被调用的方法。
首先,在 openServer() 方法中会根据 URL 判断当前是否为服务端,只有服务端才能创建 ProtocolServer 并对外服务。如果是来自服务端的调用,会依靠 serverMap 集合检查是否已有 ProtocolServer 在监听 URL 指定的地址;如果没有,会调用 createServer() 方法进行创建。openServer() 方法的具体实现如下:
@@ -398,7 +398,7 @@ function hide_canvas() {
}
在 createServer() 方法中还有几个细节需要展开分析一下。第一个是创建 ExchangeServer 时,使用的 Codec2 接口实现实际上是 DubboCountCodec,对应的 SPI 配置文件如下:
-
+
Codec2 SPI 配置文件
DubboCountCodec 中维护了一个 DubboCodec 对象,编解码的能力都是 DubboCodec 提供的,DubboCountCodec 只负责在解码过程中 ChannelBuffer 的 readerIndex 指针控制,具体实现如下:
public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
@@ -429,7 +429,7 @@ function hide_canvas() {
}
DubboCountCodec、DubboCodec 都实现了第 22 课时介绍的 Codec2 接口,其中 DubboCodec 是 ExchangeCodec 的子类。
-
+
DubboCountCodec 及 DubboCodec 继承关系图
我们知道 ExchangeCodec 只处理了 Dubbo 协议的请求头,而 DubboCodec 则是通过继承的方式,在 ExchangeCodec 基础之上,添加了解析 Dubbo 消息体的功能。在第 22 课时介绍 ExchangeCodec 实现的时候,我们重点分析了 encodeRequest() 方法,即 Request 请求的编码实现,其中会调用 encodeRequestData() 方法完成请求体的编码。
DubboCodec 中就覆盖了 encodeRequestData() 方法,按照 Dubbo 协议的格式编码 Request 请求体,具体实现如下:
@@ -461,7 +461,7 @@ function hide_canvas() {
}
RpcInvocation 实现了上一课时介绍的 Invocation 接口,如下图所示:
-
+
RpcInvocation 继承关系图
下面是 RpcInvocation 中的核心字段,通过读写这些字段即可实现 Invocation 接口的全部方法。
@@ -478,7 +478,7 @@ function hide_canvas() {
- invokeMode(InvokeMode类型):此次调用的模式,分为 SYNC、ASYNC 和 FUTURE 三类。
我们在上面的继承图中看到 RpcInvocation 的一个子类—— DecodeableRpcInvocation,它是用来支持解码的,其实现的 decode() 方法正好是 DubboCodec.encodeRequestData() 方法对应的解码操作,在 DubboCodec.decodeBody() 方法中就调用了这个方法,调用关系如下图所示:
-
+
decode() 方法调用栈
这个解码过程中有个细节,在 DubboCodec.decodeBody() 方法中有如下代码片段,其中会根据 DECODE_IN_IO_THREAD_KEY 这个参数决定是否在 DubboCodec 中进行解码(DubboCodec 是在 IO 线程中调用的)。
// decode request.
@@ -557,7 +557,7 @@ return req;
}
SerializableClassRegistry 底层维护了一个 static 的 Map(REGISTRATIONS 字段),registerClass() 方法就是将待优化的类写入该集合中暂存,在使用 Kryo、FST 等序列化算法时,会读取该集合中的类,完成注册操作,相关的调用关系如下图所示:
-
+
getRegisteredClasses() 方法的调用位置
按照 Dubbo 官方文档的说法,即使不注册任何类进行优化,Kryo 和 FST 的性能依然普遍优于Hessian2 和 Dubbo 序列化。
总结
diff --git a/专栏/Dubbo源码解读与实战-完/25 从 Protocol 起手,看服务暴露和服务引用的全流程(下).md.html b/专栏/Dubbo源码解读与实战-完/25 从 Protocol 起手,看服务暴露和服务引用的全流程(下).md.html
index 8de3a370..6d6a57f8 100644
--- a/专栏/Dubbo源码解读与实战-完/25 从 Protocol 起手,看服务暴露和服务引用的全流程(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/25 从 Protocol 起手,看服务暴露和服务引用的全流程(下).md.html
@@ -332,10 +332,10 @@ function hide_canvas() {
}
当使用独享连接的时候,对每个 Service 建立固定数量的 Client,每个 Client 维护一个底层连接。如下图所示,就是针对每个 Service 都启动了两个独享连接:
-
+
Service 独享连接示意图
当使用共享连接的时候,会区分不同的网络地址(host:port),一个地址只建立固定数量的共享连接。如下图所示,Provider 1 暴露了多个服务,Consumer 引用了 Provider 1 中的多个服务,共享连接是说 Consumer 调用 Provider 1 中的多个服务时,是通过固定数量的共享 TCP 长连接进行数据传输,这样就可以达到减少服务端连接数的目的。
-
+
Service 共享连接示意图
那怎么去创建共享连接呢?创建共享连接的实现细节是在 getSharedClient() 方法中,它首先从 referenceClientMap 缓存(Map<String, List<ReferenceCountExchangeClient>
> 类型)中查询 Key(host 和 port 拼接成的字符串)对应的共享 Client 集合,如果查找到的 Client 集合全部可用,则直接使用这些缓存的 Client,否则要创建新的 Client 来补充替换缓存中不可用的 Client。示例代码如下:
private List<ReferenceCountExchangeClient> getSharedClient(URL url, int connectNum) {
@@ -379,7 +379,7 @@ function hide_canvas() {
这里使用的 ExchangeClient 实现是 ReferenceCountExchangeClient,它是 ExchangeClient 的一个装饰器,在原始 ExchangeClient 对象基础上添加了引用计数的功能。
ReferenceCountExchangeClient 中除了持有被修饰的 ExchangeClient 对象外,还有一个 referenceCount 字段(AtomicInteger 类型),用于记录该 Client 被应用的次数。从下图中我们可以看到,在 ReferenceCountExchangeClient 的构造方法以及 incrementAndGetCount() 方法中会增加引用次数,在 close() 方法中则会减少引用次数。
-
+
referenceCount 修改调用栈
这样,对于同一个地址的共享连接,就可以满足两个基本需求:
@@ -417,7 +417,7 @@ private void replaceWithLazyClient() {
}
LazyConnectExchangeClient 也是 ExchangeClient 的装饰器,它会在原有 ExchangeClient 对象的基础上添加懒加载的功能。LazyConnectExchangeClient 在构造方法中不会创建底层持有连接的 Client,而是在需要发送请求的时候,才会调用 initClient() 方法进行 Client 的创建,如下图调用关系所示:
-
+
initClient() 方法的调用位置
initClient() 方法的具体实现如下:
private void initClient() throws RemotingException {
diff --git a/专栏/Dubbo源码解读与实战-完/26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上).md.html b/专栏/Dubbo源码解读与实战-完/26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上).md.html
index 282b5258..55fecb82 100644
--- a/专栏/Dubbo源码解读与实战-完/26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/26 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(上).md.html
@@ -295,7 +295,7 @@ function hide_canvas() {
DubboProtocol.protocolBindingRefer() 方法则会将底层的 ExchangeClient 集合封装成 DubboInvoker,然后由上层逻辑封装成代理对象,这样业务层就可以像调用本地 Bean 一样,完成远程调用。
深入 Invoker
首先,我们来看 AbstractInvoker 这个抽象类,它继承了 Invoker 接口,继承关系如下图所示:
-
+
AbstractInvoker 继承关系示意图
从图中可以看到,最核心的 DubboInvoker 继承自AbstractInvoker 抽象类,AbstractInvoker 的核心字段有如下几个。
@@ -470,7 +470,7 @@ private static final InternalThreadLocal<RpcContext> SERVER_LOCAL = ...
}
oneway 指的是客户端发送消息后,不需要得到响应。所以,对于那些不关心服务端响应的请求,就比较适合使用 oneway 通信,如下图所示:
-
+
oneway 和 twoway 通信方式对比图
可以看到发送 oneway 请求的方式是send() 方法,而后面发送 twoway 请求的方式是 request() 方法。通过之前的分析我们知道,request() 方法会相应地创建 DefaultFuture 对象以及检测超时的定时任务,而 send() 方法则不会创建这些东西,它是直接将 Invocation 包装成 oneway 类型的 Request 发送出去。
在服务端的 HeaderExchangeHandler.receive() 方法中,会针对 oneway 请求和 twoway 请求执行不同的分支处理:twoway 请求由 handleRequest() 方法进行处理,其中会关注调用结果并形成 Response 返回给客户端;oneway 请求则直接交给上层的 DubboProtocol.requestHandler,完成方法调用之后,不会返回任何 Response。
diff --git a/专栏/Dubbo源码解读与实战-完/27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下).md.html b/专栏/Dubbo源码解读与实战-完/27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下).md.html
index 89232ec1..19117f1c 100644
--- a/专栏/Dubbo源码解读与实战-完/27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/27 加餐:直击 Dubbo “心脏”,带你一起探秘 Invoker(下).md.html
@@ -305,7 +305,7 @@ function hide_canvas() {
InvokeMode 有三个可选值,分别是 SYNC、ASYNC 和 FUTURE。这里对于 SYNC 模式返回的线程池是 ThreadlessExecutor,至于其他两种异步模式,会根据 URL 选择对应的共享线程池。
SYNC 表示同步模式,是 Dubbo 的默认调用模式,具体含义如下图所示,客户端发送请求之后,客户端线程会阻塞等待服务端返回响应。
-
+
SYNC 调用模式图
在拿到线程池之后,DubboInvoker 就会调用 ExchangeClient.request() 方法,将 Invocation 包装成 Request 请求发送出去,同时会创建相应的 DefaultFuture 返回。注意,这里还加了一个回调,取出其中的 AppResponse 对象。AppResponse 表示的是服务端返回的具体响应,其中有三个字段。
@@ -424,7 +424,7 @@ private BiConsumer<Result, Throwable> afterContext = (appResponse, t) ->
ThreadlessExecutor 针对同步请求的优化,我们在前面的第 20 课时已经详细介绍过了,这里不再重复。
最后要说明的是,AsyncRpcResult 实现了 Result 接口,如下图所示:
-
+
AsyncRpcResult 继承关系图
AsyncRpcResult 对 Result 接口的实现,例如,getValue() 方法、recreate() 方法、getAttachments() 方法等,都会先调用 getAppResponse() 方法从 responseFuture 中拿到 AppResponse 对象,然后再调用其对应的方法。这里我们以 recreate() 方法为例,简单分析一下:
public Result getAppResponse() { // 省略异常处理的逻辑
@@ -468,7 +468,7 @@ public Object recreate() throws Throwable {
另外,CompletableFuture 提供了良好的回调方法,例如,whenComplete()、whenCompleteAsync() 等方法都可以在逻辑完成后,执行该方法中添加的 action 逻辑,实现回调的逻辑。同时,CompletableFuture 很好地支持了 Future 间的相互协调或组合,例如,thenApply()、thenApplyAsync() 等方法。
正是由于 CompletableFuture 的增强,我们可以更加流畅地使用回调,不必因为等待一个响应而阻塞着调用线程,而是通过前面介绍的方法告诉 CompletableFuture 完成当前逻辑之后,就去执行某个特定的函数。在 Demo 示例(即 dubbo-demo 模块中的 Demo )中,返回 CompletableFuture 的 sayHelloAsync() 方法就是使用的 FUTURE 模式。
好了,DubboInvoker 涉及的同步调用、异步调用的原理和底层实现就介绍到这里了,我们可以通过一张流程图进行简单总结,如下所示:
-
+
DubboInvoker 核心流程图
在 Client 端发送请求时,首先会创建对应的 DefaultFuture(其中记录了请求 ID 等信息),然后依赖 Netty 的异步发送特性将请求发送到 Server 端。需要说明的是,这整个发送过程是不会阻塞任何线程的。之后,将 DefaultFuture 返回给上层,在这个返回过程中,DefaultFuture 会被封装成 AsyncRpcResult,同时也可以添加回调函数。
当 Client 端接收到响应结果的时候,会交给关联的线程池(ExecutorService)或是业务线程(使用 ThreadlessExecutor 场景)进行处理,得到 Server 返回的真正结果。拿到真正的返回结果后,会将其设置到 DefaultFuture 中,并调用 complete() 方法将其设置为完成状态。此时,就会触发前面注册在 DefaulFuture 上的回调函数,执行回调逻辑。
@@ -477,7 +477,7 @@ public Object recreate() throws Throwable {
1. ListenerInvokerWrapper
在前面的第 23 课时中简单提到过 InvokerListener 接口,我们可以提供其实现来监听 refer 事件以及 destroy 事件,相应地要实现 referred() 方法以及 destroyed() 方法。
ProtocolListenerWrapper 是 Protocol 接口的实现之一,如下图所示:
-
+
ProtocolListenerWrapper 继承关系图
ProtocolListenerWrapper 本身是 Protocol 接口的装饰器,在其 export() 方法和 refer() 方法中,会分别在原有 Invoker 基础上封装一层 ListenerExporterWrapper 和 ListenerInvokerWrapper。
ListenerInvokerWrapper 是 Invoker 的装饰器,其构造方法参数列表中除了被修饰的 Invoker 外,还有 InvokerListener 列表,在构造方法内部会遍历整个 InvokerListener 列表,并调用每个 InvokerListener 的 referred() 方法,通知它们 Invoker 被引用的事件。核心逻辑如下:
diff --git a/专栏/Dubbo源码解读与实战-完/28 复杂问题简单化,代理帮你隐藏了多少底层细节?.md.html b/专栏/Dubbo源码解读与实战-完/28 复杂问题简单化,代理帮你隐藏了多少底层细节?.md.html
index c95e4dff..5572dada 100644
--- a/专栏/Dubbo源码解读与实战-完/28 复杂问题简单化,代理帮你隐藏了多少底层细节?.md.html
+++ b/专栏/Dubbo源码解读与实战-完/28 复杂问题简单化,代理帮你隐藏了多少底层细节?.md.html
@@ -291,13 +291,13 @@ function hide_canvas() {
28 复杂问题简单化,代理帮你隐藏了多少底层细节?
在前面介绍 DubboProtocol 的相关实现时,我们知道 Protocol 这一层以及后面介绍的 Cluster 层暴露出来的接口都是 Dubbo 内部的一些概念,业务层无法直接使用。为了让业务逻辑能够无缝使用 Dubbo,我们就需要将业务逻辑与 Dubbo 内部概念打通,这就用到了动态生成代理对象的功能。Proxy 层在 Dubbo 架构中的位置如下所示(虽然在架构图中 Proxy 层与 Protocol 层距离很远,但 Proxy 的具体代码实现就位于 dubbo-rpc-api 模块中):
-
+
Dubbo 架构中 Proxy 层的位置图
在 Consumer 进行调用的时候,Dubbo 会通过动态代理将业务接口实现对象转化为相应的 Invoker 对象,然后在 Cluster 层、Protocol 层都会使用 Invoker。在 Provider 暴露服务的时候,也会有 Invoker 对象与业务接口实现对象之间的转换,这同样也是通过动态代理实现的。
实现动态代理的常见方案有:JDK 动态代理、CGLib 动态代理和 Javassist 动态代理。这些方案的应用都还是比较广泛的,例如,Hibernate 底层使用了 Javassist 和 CGLib,Spring 使用了 CGLib 和 JDK 动态代理,MyBatis 底层使用了 JDK 动态代理和 Javassist。
从性能方面看,Javassist 与 CGLib 的实现方式相差无几,两者都比 JDK 动态代理性能要高,具体高多少,这就要看具体的机器、JDK 版本、测试基准的具体实现等条件了。
Dubbo 提供了两种方式来实现代理,分别是 JDK 动态代理和 Javassist。我们可以在 proxy 这个包内,看到相应工厂类,如下图所示:
-
+
ProxyFactory 核心实现的位置
了解了 Proxy 存在的必要性以及 Dubbo 提供的两种代理生成方式之后,下面我们就开始对 Proxy 层的实现进行深入分析。
ProxyFactory
@@ -316,7 +316,7 @@ public interface ProxyFactory {
看到 ProxyFactory 上的 @SPI 注解我们知道,其默认实现使用 Javassist 来创建代码对象。
AbstractProxyFactory 是代理工厂的抽象类,继承关系如下图所示:
-
+
AbstractProxyFactory 继承关系图
AbstractProxyFactory
AbstractProxyFactory 主要处理的是需要代理的接口,具体实现在 getProxy() 方法中:
@@ -639,7 +639,7 @@ synchronized (cache) { // 加锁
}
在前面两个课时中我们已经介绍了 Invoker 接口的一个重要实现分支—— AbstractInvoker 以及它的一个实现 DubboInvoker。AbstractProxyInvoker 是 Invoker 接口的另一个实现分支,继承关系如下图所示,其实现类都是 ProxyFactory 实现中的匿名内部类。
-
+
在 AbstractProxyInvoker 实现的 invoke() 方法中,会将 doInvoke() 方法返回的结果封装成 CompletableFuture 对象,然后再封装成 AsyncRpcResult 对象返回,具体实现如下:
public Result invoke(Invocation invocation) throws RpcException {
// 执行doInvoke()方法,调用业务实现
diff --git a/专栏/Dubbo源码解读与实战-完/30 Filter 接口,扩展 Dubbo 框架的常用手段指北.md.html b/专栏/Dubbo源码解读与实战-完/30 Filter 接口,扩展 Dubbo 框架的常用手段指北.md.html
index 309b09ba..eefbf731 100644
--- a/专栏/Dubbo源码解读与实战-完/30 Filter 接口,扩展 Dubbo 框架的常用手段指北.md.html
+++ b/专栏/Dubbo源码解读与实战-完/30 Filter 接口,扩展 Dubbo 框架的常用手段指北.md.html
@@ -294,7 +294,7 @@ function hide_canvas() {
Filter 链的组装逻辑设计得非常灵活,其中可以通过“-”配置手动剔除 Dubbo 原生提供的、默认加载的 Filter,通过“default”来代替 Dubbo 原生提供的 Filter,这样就可以很好地控制哪些 Filter 要加载,以及 Filter 的真正执行顺序。
Filter 是扩展 Dubbo 功能的首选方案,并且 Dubbo 自身也提供了非常多的 Filter 实现来扩展自身功能。在回顾了 ProtocolFilterWrapper 加载 Filter 的大致逻辑之后,我们本课时就来深入介绍 Dubbo 内置的多种 Filter 实现类,以及自定义 Filter 扩展 Dubbo 的方式。
在开始介绍 Filter 接口实现之前,我们需要了解一下 Filter 在 Dubbo 架构中的位置,这样才能明确 Filter 链处理请求/响应的位置,如下图红框所示:
-
+
Filter 在 Dubbo 架构中的位置
ConsumerContextFilter
ConsumerContextFilter 是一个非常简单的 Consumer 端 Filter 实现,它会在当前的 RpcContext 中记录本地调用的一些状态信息(会记录到 LOCAL 对应的 RpcContext 中),例如,调用相关的 Invoker、Invocation 以及调用的本地地址、远端地址信息,具体实现如下:
@@ -573,7 +573,7 @@ public AccessLogFilter() {
}
在 LoggerFactory 中维护了一个 LOGGERS 集合(Map<String, FailsafeLogger> 类型),其中维护了当前使用的全部 FailsafeLogger 对象;FailsafeLogger 对象中封装了一个 Logger 对象,这个 Logger 接口是 Dubbo 自己定义的接口,Dubbo 针对每种第三方框架都提供了一个 Logger 接口的实现,如下图所示:
-
+
Logger 接口的实现
FailsafeLogger 是 Logger 对象的装饰器,它在每个 Logger 日志写入操作之外,都添加了 try/catch 异常处理。其他的 Dubbo Logger 实现类则是封装了相应第三方的 Logger 对象,并将日志输出操作委托给第三方的 Logger 对象完成。这里我们以 Log4j2Logger 为例进行简单分析:
public class Log4j2Logger implements Logger {
@@ -604,7 +604,7 @@ public AccessLogFilter() {
}
LoggerAdapter 被 @SPI 注解修饰,是一个扩展接口,如下图所示,LoggerAdapter 对应每个第三方框架的一个相应实现,用于创建相应的 Dubbo Logger 实现对象。
-
+
LoggerAdapter 接口实现
以 Log4j2LoggerAdapter 为例,其核心在 getLogger() 方法中,主要是创建 Log4j2Logger 对象,具体实现如下:
public class Log4j2LoggerAdapter implements LoggerAdapter {
diff --git a/专栏/Dubbo源码解读与实战-完/31 加餐:深潜 Directory 实现,探秘服务目录玄机.md.html b/专栏/Dubbo源码解读与实战-完/31 加餐:深潜 Directory 实现,探秘服务目录玄机.md.html
index b4b204e3..cef0f5e0 100644
--- a/专栏/Dubbo源码解读与实战-完/31 加餐:深潜 Directory 实现,探秘服务目录玄机.md.html
+++ b/专栏/Dubbo源码解读与实战-完/31 加餐:深潜 Directory 实现,探秘服务目录玄机.md.html
@@ -302,12 +302,12 @@ function hide_canvas() {
- ……
为了解决上述问题,Dubbo 独立出了一个实现集群功能的模块—— dubbo-cluster。
-
+
dubbo-cluster 结构图
作为 dubbo-cluster 模块分析的第一课时,我们就首先来了解一下 dubbo-cluster 模块的架构以及最核心的 Cluster 接口。
Cluster 架构
dubbo-cluster 模块的主要功能是将多个 Provider 伪装成一个 Provider 供 Consumer 调用,其中涉及集群的容错处理、路由规则的处理以及负载均衡。下图展示了 dubbo-cluster 的核心组件:
-
+
Cluster 核心接口图
由图我们可以看出,dubbo-cluster 主要包括以下四个核心接口:
@@ -335,7 +335,7 @@ function hide_canvas() {
AbstractDirectory 是 Directory 接口的抽象实现,其中除了维护 Consumer 端的 URL 信息,还维护了一个 RouterChain 对象,用于记录当前使用的 Router 对象集合,也就是后面课时要介绍的路由规则。
AbstractDirectory 对 list() 方法的实现也比较简单,就是直接委托给了 doList() 方法,doList() 是个抽象方法,由 AbstractDirectory 的子类具体实现。
Directory 接口有 RegistryDirectory 和 StaticDirectory 两个具体实现,如下图所示:
-
+
Directory 接口继承关系图
其中,RegistryDirectory 实现中维护的 Invoker 集合会随着注册中心中维护的注册信息动态发生变化,这就依赖了 ZooKeeper 等注册中心的推送能力;StaticDirectory 实现中维护的 Invoker 集合则是静态的,在 StaticDirectory 对象创建完成之后,不会再发生变化。
下面我们就来分别介绍 Directory 接口的这两个具体实现。
@@ -402,7 +402,7 @@ function hide_canvas() {
}
我们看到除了作为 NotifyListener 监听器之外,RegistryDirectory 内部还有两个 ConfigurationListener 的内部类(继承关系如下图所示),为了保持连贯,这两个监听器的具体原理我们在后面的课时中会详细介绍,这里先不展开讲述。
-
+
RegistryDirectory 内部的 ConfigurationListener 实现
通过前面对 Registry 的介绍我们知道,在注册 NotifyListener 的时候,监听的是 providers、configurators 和 routers 三个目录,所以在这三个目录下发生变化的时候,就会触发 RegistryDirectory 的 notify() 方法。
在 RegistryDirectory.notify() 方法中,首先会按照 category 对发生变化的 URL 进行分类,分成 configurators、routers、providers 三类,并分别对不同类型的 URL 进行处理:
diff --git a/专栏/Dubbo源码解读与实战-完/32 路由机制:请求到底怎么走,它说了算(上).md.html b/专栏/Dubbo源码解读与实战-完/32 路由机制:请求到底怎么走,它说了算(上).md.html
index f4646b01..b46aa215 100644
--- a/专栏/Dubbo源码解读与实战-完/32 路由机制:请求到底怎么走,它说了算(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/32 路由机制:请求到底怎么走,它说了算(上).md.html
@@ -342,10 +342,10 @@ public interface RouterFactory {
}
RouterFactory 接口有很多实现类,如下图所示:
-
+
RouterFactory 继承关系图
下面我们就来深入介绍下每个 RouterFactory 实现类以及对应的 Router 实现对象。Router 决定了一次 Dubbo 调用的目标服务,Router 接口的每个实现类代表了一个路由规则,当 Consumer 访问 Provider 时,Dubbo 根据路由规则筛选出合适的 Provider 列表,之后通过负载均衡算法再次进行筛选。Router 接口的继承关系如下图所示:
-
+
Router 继承关系图
接下来我们就开始介绍 RouterFactory 以及 Router 的具体实现。
ConditionRouterFactory&ConditionRouter
@@ -453,7 +453,7 @@ public interface RouterFactory {
host = 2.2.2.2,1.1.1.1,3.3.3.3 & method !=get => host = 1.2.3.4
经过 ROUTE_PATTERN 正则表达式的分组之后,我们得到如下分组:
-
+
Rule 分组示意图
我们先来看 =>
之前的 Consumer 匹配规则的处理。
@@ -464,10 +464,10 @@ public interface RouterFactory {
- 分组 5 中,separator 为 "!=" 字符串,content 为 "get" 字符串。处理该分组时,会进入 parseRule() 方法中(5)处的分支,向步骤 4 新建的 MatchPair 对象中的 mismatches 集合添加 "get" 字符串。
最后,我们得到的 whenCondition 集合如下图所示:
-
+
whenCondition 集合示意图
同理,parseRule() 方法解析上述表达式 =>
之后的规则得到的 thenCondition 集合,如下图所示:
-
+
thenCondition 集合示意图
了解了 ConditionRouter 解析规则的流程以及 MatchPair 内部的匹配原则之后,ConditionRouter 中最后一个需要介绍的内容就是它的 route() 方法了。
ConditionRouter.route() 方法首先会尝试前面创建的 whenCondition 集合,判断此次发起调用的 Consumer 是否符合表达式中 =>
之前的 Consumer 过滤条件,若不符合,直接返回整个 invokers 集合;若符合,则通过 thenCondition 集合对 invokers 集合进行过滤,得到符合 Provider 过滤条件的 Invoker 集合,然后返回给上层调用方。ConditionRouter.route() 方法的核心实现如下:
diff --git a/专栏/Dubbo源码解读与实战-完/33 路由机制:请求到底怎么走,它说了算(下).md.html b/专栏/Dubbo源码解读与实战-完/33 路由机制:请求到底怎么走,它说了算(下).md.html
index b495f67a..9a9e9dc0 100644
--- a/专栏/Dubbo源码解读与实战-完/33 路由机制:请求到底怎么走,它说了算(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/33 路由机制:请求到底怎么走,它说了算(下).md.html
@@ -294,7 +294,7 @@ function hide_canvas() {
FileRouterFactory
FileRouterFactory 是 ScriptRouterFactory 的装饰器,其扩展名为 file,FileRouterFactory 在 ScriptRouterFactory 基础上增加了读取文件的能力。我们可以将 ScriptRouter 使用的路由规则保存到文件中,然后在 URL 中指定文件路径,FileRouterFactory 从中解析到该脚本文件的路径并进行读取,调用 ScriptRouterFactory 去创建相应的 ScriptRouter 对象。
下面我们来看 FileRouterFactory 对 getRouter() 方法的具体实现,其中完成了 file 协议的 URL 到 script 协议 URL 的转换,如下是一个转换示例,首先会将 file:// 协议转换成 script:// 协议,然后会添加 type 参数和 rule 参数,其中 type 参数值根据文件后缀名确定,该示例为 js,rule 参数值为文件内容。
-
+
我们可以再结合接下来这个示例分析 getRouter() 方法的具体实现:
public Router getRouter(URL url) {
// 默认使用script协议
@@ -329,21 +329,21 @@ function hide_canvas() {
目前,Dubbo 提供了动态和静态两种方式给 Provider 打标签,其中动态方式就是通过服务治理平台动态下发标签,静态方式就是在 XML 等静态配置中打标签。Consumer 端可以在 RpcContext 的 attachment 中添加 request.tag 附加属性,注意保存在 attachment 中的值将会在一次完整的远程调用中持续传递,我们只需要在起始调用时进行设置,就可以达到标签的持续传递。
了解了 Tag 的基本概念和功能之后,我们再简单介绍一个 Tag 的使用示例。
在实际的开发测试中,一个完整的请求会涉及非常多的 Provider,分属不同团队进行维护,这些团队每天都会处理不同的需求,并在其负责的 Provider 服务中进行修改,如果所有团队都使用一套测试环境,那么测试环境就会变得很不稳定。如下图所示,4 个 Provider 分属不同的团队管理,Provider 2 和 Provider 4 在测试环境测试,部署了有 Bug 的版本,这样就会导致整个测试环境无法正常处理请求,在这样一个不稳定的测试环境中排查 Bug 是非常困难的,因为可能排查到最后,发现是别人的 Bug。
-
+
不同状态的 Provider 节点
为了解决上述问题,我们可以针对每个需求分别独立出一套测试环境,但是这个方案会占用大量机器,前期的搭建成本以及后续的维护成本也都非常高。
下面是一个通过 Tag 方式实现环境隔离的架构图,其中,需求 1 对 Provider 2 的请求会全部落到有需求 1 标签的 Provider 上,其他 Provider 使用稳定测试环境中的 Provider;需求 2 对 Provider 4 的请求会全部落到有需求 2 标签的 Provider 4 上,其他 Provider 使用稳定测试环境中的 Provider。
-
+
依赖 Tag 实现的测试环境隔离方案
在一些特殊场景中,会有 Tag 降级的场景,比如找不到对应 Tag 的 Provider,会按照一定的规则进行降级。如果在 Provider 集群中不存在与请求 Tag 对应的 Provider 节点,则默认将降级请求 Tag 为空的 Provider;如果希望在找不到匹配 Tag 的 Provider 节点时抛出异常的话,我们需设置 request.tag.force = true。
如果请求中的 request.tag 未设置,只会匹配 Tag 为空的 Provider,也就是说即使集群中存在可用的服务,若 Tag 不匹配也就无法调用。一句话总结,携带 Tag 的请求可以降级访问到无 Tag 的 Provider,但不携带 Tag 的请求永远无法访问到带有 Tag 的 Provider。
TagRouter
下面我们再来看 TagRouter 的具体实现。在 TagRouter 中持有一个 TagRouterRule 对象的引用,在 TagRouterRule 中维护了一个 Tag 集合,而在每个 Tag 对象中又都维护了一个 Tag 的名称,以及 Tag 绑定的网络地址集合,如下图所示:
-
+
TagRouter、TagRouterRule、Tag 与 address 映射关系图
另外,在 TagRouterRule 中还维护了 addressToTagnames、tagnameToAddresses 两个集合(都是 Map<String, List<String>
> 类型),分别记录了 Tag 名称到各个 address 的映射以及 address 到 Tag 名称的映射。在 TagRouterRule 的 init() 方法中,会根据 tags 集合初始化这两个集合。
了解了 TagRouterRule 的基本构造之后,我们继续来看 TagRouter 构造 TagRouterRule 的过程。TagRouter 除了实现了 Router 接口之外,还实现了 ConfigurationListener 接口,如下图所示:
-
+
TagRouter 继承关系图
ConfigurationListener 用于监听配置的变化,其中就包括 TagRouterRule 配置的变更。当我们通过动态更新 TagRouterRule 配置的时候,就会触发 ConfigurationListener 接口的 process() 方法,TagRouter 对 process() 方法的实现如下:
public synchronized void process(ConfigChangedEvent event) {
@@ -370,10 +370,10 @@ tags:
addresses: []
经过 TagRuleParser 解析得到的 TagRouterRule 结构,如下所示:
-
+
TagRouterRule 结构图
除了上图展示的几个集合字段,TagRouterRule 还从 AbstractRouterRule 抽象类继承了一些控制字段,后面介绍的 ConditionRouterRule 也继承了 AbstractRouterRule。
-
+
AbstractRouterRule继承关系图
AbstractRouterRule 中核心字段的具体含义大致可总结为如下。
@@ -455,10 +455,10 @@ tags:
ServiceRouter & AppRouter
除了前文介绍的 TagRouterFactory 继承了 CacheableRouterFactory 之外,ServiceRouterFactory 也继承 CachabelRouterFactory,具有了缓存的能力,具体继承关系如下图所示:
-
+
CacheableRouterFactory 继承关系图
ServiceRouterFactory 创建的 Router 实现是 ServiceRouter,与 ServiceRouter 类似的是 AppRouter,两者都继承了 ListenableRouter 抽象类(虽然 ListenableRouter 是个抽象类,但是没有抽象方法留给子类实现),继承关系如下图所示:
-
+
ListenableRouter 继承关系图
ListenableRouter 在 ConditionRouter 基础上添加了动态配置的能力,ListenableRouter 的 process() 方法与 TagRouter 中的 process() 方法类似,对于 ConfigChangedEvent.DELETE 事件,直接清空 ListenableRouter 中维护的 ConditionRouterRule 和 ConditionRouter 集合的引用;对于 ADDED、UPDATED 事件,则通过 ConditionRuleParser 解析事件内容,得到相应的 ConditionRouterRule 对象和 ConditionRouter 集合。这里的 ConditionRuleParser 同样是以 yaml 文件的格式解析 ConditionRouterRule 的相关配置。ConditionRouterRule 中维护了一个 conditions 集合(List<String>
类型),记录了多个 Condition 路由规则,对应生成多个 ConditionRouter 对象。
整个解析 ConditionRouterRule 的过程,与前文介绍的解析 TagRouterRule 的流程类似,这里不再赘述。
diff --git a/专栏/Dubbo源码解读与实战-完/34 加餐:初探 Dubbo 动态配置的那些事儿.md.html b/专栏/Dubbo源码解读与实战-完/34 加餐:初探 Dubbo 动态配置的那些事儿.md.html
index a2675114..b584ef22 100644
--- a/专栏/Dubbo源码解读与实战-完/34 加餐:初探 Dubbo 动态配置的那些事儿.md.html
+++ b/专栏/Dubbo源码解读与实战-完/34 加餐:初探 Dubbo 动态配置的那些事儿.md.html
@@ -377,11 +377,11 @@ function hide_canvas() {
}
ConfiguratorFactory 接口是一个扩展接口,Dubbo 提供了两个实现类,如下图所示:
-
+
ConfiguratorFactory 继承关系图
其中,OverrideConfiguratorFactory 对应的扩展名为 override,创建的 Configurator 实现是 OverrideConfigurator;AbsentConfiguratorFactory 对应的扩展名是 absent,创建的 Configurator 实现类是 AbsentConfigurator。
Configurator 接口的继承关系如下图所示:
-
+
Configurator 继承关系图
其中,AbstractConfigurator 中维护了一个 configuratorUrl 字段,记录了完整的配置 URL。AbstractConfigurator 是一个模板类,其核心实现是 configure() 方法,具体实现如下:
public URL configure(URL url) {
diff --git a/专栏/Dubbo源码解读与实战-完/35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md.html b/专栏/Dubbo源码解读与实战-完/35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md.html
index 84d53763..161c0f4f 100644
--- a/专栏/Dubbo源码解读与实战-完/35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上).md.html
@@ -291,7 +291,7 @@ function hide_canvas() {
35 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(上)
在前面的课时中,我们已经详细介绍了 dubbo-cluster 模块中的 Directory 和 Router 两个核心接口以及核心实现,同时也介绍了这两个接口相关的周边知识。本课时我们继续按照下图的顺序介绍 LoadBalance 的相关内容。
-
+
LoadBalance 核心接口图
LoadBalance(负载均衡)的职责是将网络请求或者其他形式的负载“均摊”到不同的服务节点上,从而避免服务集群中部分节点压力过大、资源紧张,而另一部分节点比较空闲的情况。
通过合理的负载均衡算法,我们希望可以让每个服务节点获取到适合自己处理能力的负载,实现处理能力和流量的合理分配。常用的负载均衡可分为软件负载均衡(比如,日常工作中使用的 Nginx)和硬件负载均衡(主要有 F5、Array、NetScaler 等,不过开发工程师在实践中很少直接接触到)。
@@ -306,7 +306,7 @@ function hide_canvas() {
LoadBalance 接口
上述 Dubbo 提供的负载均衡实现,都是 LoadBalance 接口的实现类,如下图所示:
-
+
LoadBalance 继承关系图
LoadBalance 是一个扩展接口,默认使用的扩展实现是 RandomLoadBalance,其定义如下所示,其中的 @Adaptive 注解参数为 loadbalance,即动态生成的适配器会按照 URL 中的 loadbalance 参数值选择扩展实现类。
@SPI(RandomLoadBalance.NAME)
@@ -378,16 +378,16 @@ public interface LoadBalance {
hash(请求参数) % 2^32
Provider 地址和请求经过对 2^32 取模得到的结果值,都会落到一个 Hash 环上,如下图所示:
-
+
一致性 Hash 节点均匀分布图
我们按顺时针的方向,依次将请求分发到对应的 Provider。这样,当某台 Provider 节点宕机或增加新的 Provider 节点时,只会影响这个 Provider 节点对应的请求。
在理想情况下,一致性 Hash 算法会将这三个 Provider 节点均匀地分布到 Hash 环上,请求也可以均匀地分发给这三个 Provider 节点。但在实际情况中,这三个 Provider 节点地址取模之后的值,可能差距不大,这样会导致大量的请求落到一个 Provider 节点上,如下图所示:
-
+
一致性 Hash 节点非均匀分布图
这就出现了数据倾斜的问题。所谓数据倾斜是指由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到少量请求的情况。
为了解决一致性 Hash 算法中出现的数据倾斜问题,又演化出了 Hash 槽的概念。
Hash 槽解决数据倾斜的思路是:既然问题是由 Provider 节点在 Hash 环上分布不均匀造成的,那么可以虚拟出 n 组 P1、P2、P3 的 Provider 节点 ,让多组 Provider 节点相对均匀地分布在 Hash 环上。如下图所示,相同阴影的节点均为同一个 Provider 节点,比如 P1-1、P1-2……P1-99 表示的都是 P1 这个 Provider 节点。引入 Provider 虚拟节点之后,让 Provider 在圆环上分散开来,以避免数据倾斜问题。
-
+
数据倾斜解决示意图
2. ConsistentHashSelector 实现分析
了解了一致性 Hash 算法的基本原理之后,我们再来看一下 ConsistentHashLoadBalance 一致性 Hash 负载均衡的具体实现。首先来看 doSelect() 方法的实现,其中会根据 ServiceKey 和 methodName 选择一个 ConsistentHashSelector 对象,核心算法都委托给 ConsistentHashSelector 对象完成。
@@ -476,7 +476,7 @@ private Invoker<T> selectForKey(long hash) {
RandomLoadBalance
RandomLoadBalance 使用的负载均衡算法是加权随机算法。RandomLoadBalance 是一个简单、高效的负载均衡实现,它也是 Dubbo 默认使用的 LoadBalance 实现。
这里我们通过一个示例来说明加权随机算法的核心思想。假设我们有三个 Provider 节点 A、B、C,它们对应的权重分别为 5、2、3,权重总和为 10。现在把这些权重值放到一维坐标轴上,[0, 5) 区间属于节点 A,[5, 7) 区间属于节点 B,[7, 10) 区间属于节点 C,如下图所示:
-
+
权重坐标轴示意图
下面我们通过随机数生成器在 [0, 10) 这个范围内生成一个随机数,然后计算这个随机数会落到哪个区间中。例如,随机生成 4,就会落到 Provider A 对应的区间中,此时 RandomLoadBalance 就会返回 Provider A 这个节点。
接下来我们再来看 RandomLoadBalance 中 doSelect() 方法的实现,其核心逻辑分为三个关键点:
diff --git a/专栏/Dubbo源码解读与实战-完/36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md.html b/专栏/Dubbo源码解读与实战-完/36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md.html
index 4454c3dd..745f31a2 100644
--- a/专栏/Dubbo源码解读与实战-完/36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/36 负载均衡:公平公正物尽其用的负载均衡策略,这里都有(下).md.html
@@ -365,7 +365,7 @@ function hide_canvas() {
每个 Provider 节点有两个权重:一个权重是配置的 weight,该值在负载均衡的过程中不会变化;另一个权重是 currentWeight,该值会在负载均衡的过程中动态调整,初始值为 0。
当有新的请求进来时,RoundRobinLoadBalance 会遍历 Invoker 列表,并用对应的 currentWeight 加上其配置的权重。遍历完成后,再找到最大的 currentWeight,将其减去权重总和,然后返回相应的 Invoker 对象。
下面我们通过一个示例说明 RoundRobinLoadBalance 的执行流程,这里我们依旧假设 A、B、C 三个节点的权重比例为 5:1:1。
-
+
- 处理第一个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [0, 0, 0] 变为 [5, 1, 1]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-2, 1, 1]。
- 处理第二个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-2, 1, 1] 变为 [3, 2, 2]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-4, 2, 2]。
diff --git a/专栏/Dubbo源码解读与实战-完/37 集群容错:一个好汉三个帮(上).md.html b/专栏/Dubbo源码解读与实战-完/37 集群容错:一个好汉三个帮(上).md.html
index cb5dc16f..894a9d7d 100644
--- a/专栏/Dubbo源码解读与实战-完/37 集群容错:一个好汉三个帮(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/37 集群容错:一个好汉三个帮(上).md.html
@@ -299,7 +299,7 @@ function hide_canvas() {
了解了上述背景知识之后,下面我们就正式开始介绍 Cluster 接口。
Cluster 接口与容错机制
Cluster 的工作流程大致可以分为两步(如下图所示):①创建 Cluster Invoker 实例(在 Consumer 初始化时,Cluster 实现类会创建一个 Cluster Invoker 实例,即下图中的 merge 操作);②使用 Cluster Invoker 实例(在 Consumer 服务消费者发起远程调用请求的时候,Cluster Invoker 会依赖前面课时介绍的 Directory、Router、LoadBalance 等组件得到最终要调用的 Invoker 对象)。
-
+
Cluster 核心流程图
Cluster Invoker 获取 Invoker 的流程大致可描述为如下:
@@ -327,10 +327,10 @@ public interface Cluster {
}
Cluster 接口的实现类如下图所示,分别对应前面提到的多种容错策略:
-
+
Cluster 接口继承关系
在每个 Cluster 接口实现中,都会创建对应的 Invoker 对象,这些都继承自 AbstractClusterInvoker 抽象类,如下图所示:
-
+
AbstractClusterInvoker 继承关系图
通过上面两张继承关系图我们可以看出,Cluster 接口和 Invoker 接口都会有相应的抽象实现类,这些抽象实现类都实现了一些公共能力。下面我们就来深入介绍 AbstractClusterInvoker 和 AbstractCluster 这两个抽象类。
AbstractClusterInvoker
@@ -543,7 +543,7 @@ public <T> Invoker<T> join(Directory<T> directory) throws RpcE
}
Dubbo 提供了两个 ClusterInterceptor 实现类,分别是 ConsumerContextClusterInterceptor 和 ZoneAwareClusterInterceptor,如下图所示:
-
+
ClusterInterceptor 继承关系图
在 ConsumerContextClusterInterceptor 的 before() 方法中,会在 RpcContext 中设置当前 Consumer 地址、此次调用的 Invoker 等信息,同时还会删除之前与当前线程绑定的 Server Context。在 after() 方法中,会删除本地 RpcContext 的信息。ConsumerContextClusterInterceptor 的具体实现如下:
public void before(AbstractClusterInvoker<?> invoker, Invocation invocation) {
diff --git a/专栏/Dubbo源码解读与实战-完/38 集群容错:一个好汉三个帮(下).md.html b/专栏/Dubbo源码解读与实战-完/38 集群容错:一个好汉三个帮(下).md.html
index 97c788ef..5f6ef98d 100644
--- a/专栏/Dubbo源码解读与实战-完/38 集群容错:一个好汉三个帮(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/38 集群容错:一个好汉三个帮(下).md.html
@@ -722,10 +722,10 @@ private void rePut(Timeout timeout) {
}
在 Dubbo 中使用多个注册中心的架构如下图所示:
-
+
双注册中心结构图
Consumer 可以使用 ZoneAwareClusterInvoker 先在多个注册中心之间进行选择,选定注册中心之后,再选择 Provider 节点,如下图所示:
-
+
ZoneAwareClusterInvoker 在多注册中心之间进行选择的策略有以下四种。
- 找到preferred 属性为 true 的注册中心,它是优先级最高的注册中心,只有该中心无可用 Provider 节点时,才会回落到其他注册中心。
diff --git a/专栏/Dubbo源码解读与实战-完/39 加餐:多个返回值不用怕,Merger 合并器来帮忙.md.html b/专栏/Dubbo源码解读与实战-完/39 加餐:多个返回值不用怕,Merger 合并器来帮忙.md.html
index 76de89ad..8e9344af 100644
--- a/专栏/Dubbo源码解读与实战-完/39 加餐:多个返回值不用怕,Merger 合并器来帮忙.md.html
+++ b/专栏/Dubbo源码解读与实战-完/39 加餐:多个返回值不用怕,Merger 合并器来帮忙.md.html
@@ -340,7 +340,7 @@ function hide_canvas() {
ArrayMerger
在 Dubbo 中提供了处理不同类型返回值的 Merger 实现,其中不仅有处理 boolean[]、byte[]、char[]、double[]、float[]、int[]、long[]、short[] 等基础类型数组的 Merger 实现,还有处理 List、Set、Map 等集合类的 Merger 实现,具体继承关系如下图所示:
-
+
Merger 继承关系图
我们首先来看 ArrayMerger 实现:当服务接口的返回值为数组的时候,会使用 ArrayMerger 将多个数组合并成一个数组,也就是将二维数组拍平成一维数组。ArrayMerger.merge() 方法的具体实现如下:
public Object[] merge(Object[]... items) {
diff --git a/专栏/Dubbo源码解读与实战-完/40 加餐:模拟远程调用,Mock 机制帮你搞定.md.html b/专栏/Dubbo源码解读与实战-完/40 加餐:模拟远程调用,Mock 机制帮你搞定.md.html
index 6ac3917e..ebfaed6b 100644
--- a/专栏/Dubbo源码解读与实战-完/40 加餐:模拟远程调用,Mock 机制帮你搞定.md.html
+++ b/专栏/Dubbo源码解读与实战-完/40 加餐:模拟远程调用,Mock 机制帮你搞定.md.html
@@ -295,7 +295,7 @@ function hide_canvas() {
在前面第 38 课时中,我们深入介绍了 Dubbo 提供的多种 Cluster 实现以及相关的 Cluster Invoker 实现,其中的 ZoneAwareClusterInvoker 就涉及了 MockClusterInvoker 的相关内容。本课时我们就来介绍 Dubbo 中 Mock 机制的全链路流程,不仅包括与 Cluster 接口相关的 MockClusterWrapper 和 MockClusterInvoker,我们还会回顾前面课程的 Router 和 Protocol 接口,分析它们与 Mock 机制相关的实现。
MockClusterWrapper
Cluster 接口有两条继承线(如下图所示):一条线是 AbstractCluster 抽象类,这条继承线涉及的全部 Cluster 实现类我们已经在[第 37 课时]中深入分析过了;另一条线是 MockClusterWrapper 这条线。
-
+
Cluster 继承关系图
MockClusterWrapper 是 Cluster 对象的包装类,我们在之前[第 4 课时]介绍 Dubbo SPI 机制时已经分析过 Wrapper 的功能,MockClusterWrapper 类会对 Cluster 进行包装。下面是 MockClusterWrapper 的具体实现,其中会在 Cluster Invoker 对象的基础上使用 MockClusterInvoker 进行包装:
public class MockClusterWrapper implements Cluster {
@@ -382,7 +382,7 @@ function hide_canvas() {
MockInvokersSelector
在[第 32 课时]和[第 33 课时]中,我们介绍了 Router 接口多个实现类,但当时并没有深入介绍 Mock 相关的 Router 实现类—— MockInvokersSelector,它的继承关系如下图所示:
-
+
MockInvokersSelector 继承关系图
MockInvokersSelector 是 Dubbo Mock 机制相关的 Router 实现,在未开启 Mock 机制的时候,会返回正常的 Invoker 对象集合;在开启 Mock 机制之后,会返回 MockInvoker 对象集合。MockInvokersSelector 的具体实现如下:
public <T> List<Invoker<T>> route(final List<Invoker<T>> invokers,
diff --git a/专栏/Dubbo源码解读与实战-完/41 加餐:一键通关服务发布全流程.md.html b/专栏/Dubbo源码解读与实战-完/41 加餐:一键通关服务发布全流程.md.html
index c438952f..88930042 100644
--- a/专栏/Dubbo源码解读与实战-完/41 加餐:一键通关服务发布全流程.md.html
+++ b/专栏/Dubbo源码解读与实战-完/41 加餐:一键通关服务发布全流程.md.html
@@ -352,7 +352,7 @@ function hide_canvas() {
}
这里我们重点关注的是exportServices() 方法,它是服务发布核心逻辑的入口,其中每一个服务接口都会转换为对应的 ServiceConfig 实例,然后通过代理的方式转换成 Invoker,最终转换成 Exporter 进行发布。服务发布流程中涉及的核心对象转换,如下图所示:
-
+
服务发布核心流程图
exportServices() 方法的具体实现如下:
private void exportServices() {
@@ -667,7 +667,7 @@ anyhost=true
- 触发 RegistryProtocolListener 监听器。
远程发布的详细流程如下图所示:
-
+
服务发布详细流程图
总结
本课时我们重点介绍了 Dubbo 服务发布的核心流程。
diff --git a/专栏/Dubbo源码解读与实战-完/43 服务自省设计方案:新版本新方案.md.html b/专栏/Dubbo源码解读与实战-完/43 服务自省设计方案:新版本新方案.md.html
index bcd0aef5..43c5c713 100644
--- a/专栏/Dubbo源码解读与实战-完/43 服务自省设计方案:新版本新方案.md.html
+++ b/专栏/Dubbo源码解读与实战-完/43 服务自省设计方案:新版本新方案.md.html
@@ -294,7 +294,7 @@ function hide_canvas() {
在微服务架构中,服务是基本单位,而 Dubbo 架构中服务的基本单位是 Java 接口,这种架构上的差别就会带来一系列挑战。从 2.7.5 版本开始,Dubbo 引入了服务自省架构,来应对微服务架构带来的挑战。具体都有哪些挑战呢?下面我们就来详细说明一下。
注册中心面临的挑战
在开始介绍注册中心面临的挑战之前,我们先来回顾一下前面课时介绍过的 Dubbo 传统架构以及这个架构中最核心的组件:
-
+
Dubbo 核心架构图
结合上面这张架构图,我们可以一起回顾一下这些核心组件的功能。
@@ -315,7 +315,7 @@ function hide_canvas() {
Dubbo 的改进方案
Dubbo 从 2.7.0 版本开始增加了简化 URL的特性,从 URL 中抽出的数据会被存放至元数据中心。但是这次优化只是缩短了 URL 的长度,从内存使用量以及降低通知频繁度的角度降低了注册中心的压力,并没有减少注册中心 URL 的数量,所以注册中心所承受的压力还是比较明显的。
Dubbo 2.7.5 版本引入了服务自省架构,进一步降低了注册中心的压力。在此次优化中,Dubbo 修改成应用为粒度的服务注册与发现模型,最大化地减少了 Dubbo 服务元信息注册数量,其核心流程如下图所示:
-
+
服务自省架构图
上图展示了引入服务自省之后的 Dubbo 服务注册与发现的核心流程,Dubbo 会按照顺序执行这些操作(当其中一个操作失败时,后续操作不会执行)。
我们首先来看 Provider 侧的执行流程:
@@ -338,7 +338,7 @@ function hide_canvas() {
在有的场景中,我们会在线上部署两组不同配置的服务节点,来验证某些配置是否生效。例如,共有 100 个服务节点,平均分成 A、B 两组,A 组服务节点超时时间(即 timeout)设置为 3000 ms,B 组的超时时间(即 timeout)设置为 2000 ms,这样的话该服务就有了两组不同的元数据。
按照前面介绍的优化方案,在订阅服务的时候,会得到 100 个 ServiceInstance,因为每个 ServiceInstance 发布的服务元数据都有可能不一样,所以我们需要调用每个 ServiceInstance 的 MetadataService 服务获取元数据。
为了减少 MetadataService 服务的调用次数,Dubbo 提出了服务修订版本的优化方案,其核心思想是:将每个 ServiceInstance 发布的服务 URL 计算一个 hash 值(也就是 revision 值),并随 ServiceInstance 一起发布到注册中心;在 Consumer 端进行订阅的时候,对于 revision 值相同的 ServiceInstance,不再调用 MetadataService 服务,直接共用一份 URL 即可。下图展示了 Dubbo 服务修订的核心逻辑:
-
+
引入 Dubbo 服务修订的 Consumer 端交互图
通过该流程图,我们可以看到 Dubbo Consumer 端实现服务修订的流程如下。
diff --git a/专栏/Dubbo源码解读与实战-完/44 元数据方案深度剖析,如何避免注册中心数据量膨胀?.md.html b/专栏/Dubbo源码解读与实战-完/44 元数据方案深度剖析,如何避免注册中心数据量膨胀?.md.html
index 0f8620a4..a6357671 100644
--- a/专栏/Dubbo源码解读与实战-完/44 元数据方案深度剖析,如何避免注册中心数据量膨胀?.md.html
+++ b/专栏/Dubbo源码解读与实战-完/44 元数据方案深度剖析,如何避免注册中心数据量膨胀?.md.html
@@ -338,12 +338,12 @@ function hide_canvas() {
- methods(List 类型):接口中定义的全部方法描述信息。在 MethodDefinition 中记录了方法的名称、参数类型、返回值类型以及方法参数涉及的所有 TypeDefinition。
- types(List 类型):接口定义中涉及的全部类型描述信息,包括方法的参数和字段,如果遇到复杂类型,TypeDefinition 会递归获取复杂类型内部的字段。在 dubbo-metadata-api 模块中,提供了多种类型对应的 TypeBuilder 用于创建对应的 TypeDefinition,对于没有特定 TypeBuilder 实现的类型,会使用 DefaultTypeBuilder。
-
+
TypeBuilder 接口实现关系图
在服务发布的时候,会将服务的 URL 中的部分数据封装为 FullServiceDefinition 对象,然后作为元数据存储起来。FullServiceDefinition 继承了 ServiceDefinition,并在 ServiceDefinition 基础之上扩展了 params 集合(Map<String, String> 类型),用来存储 URL 上的参数。
MetadataService
接下来看 MetadataService 接口,在上一讲我们提到Dubbo 中的每个 ServiceInstance 都会发布 MetadataService 接口供 Consumer 端查询元数据,下图展示了 MetadataService 接口的继承关系:
-
+
MetadataService 接口继承关系图
在 MetadataService 接口中定义了查询当前 ServiceInstance 发布的元数据的相关方法,具体如下所示:
public interface MetadataService {
@@ -481,7 +481,7 @@ private boolean doFunction(BiFunction<WritableMetadataService, URL, Boolean&g
元数据中心是 Dubbo 2.7.0 版本之后新增的一项优化,其主要目的是将 URL 中的一部分内容存储到元数据中心,从而减少注册中心的压力。
元数据中心的数据只是给本端自己使用的,改动不需要告知对端,例如,Provider 修改了元数据,不需要实时通知 Consumer。这样,在注册中心存储的数据量减少的同时,还减少了因为配置修改导致的注册中心频繁通知监听者情况的发生,很好地减轻了注册中心的压力。
MetadataReport 接口是 Dubbo 节点与元数据中心交互的桥梁,其继承关系如下图所示:
-
+
MetadataReport 继承关系图
我们先来看一下 MetadataReport 接口的核心定义:
public interface MetadataReport {
@@ -675,10 +675,10 @@ private boolean doHandleMetadataCollection(Map<MetadataIdentifier, Object>
在 AbstractMetadataReport 的构造方法中,会根据 reportServerURL(也就是后面的 metadataReportURL)参数启动一个“天”级别的定时任务,该定时任务会执行 publishAll() 方法,其中会通过 doHandleMetadataCollection() 方法将 allMetadataReports 集合中的全部元数据重新进行上报。该定时任务默认是在凌晨 02:00~06:00 启动,每天执行一次。
到此为止,AbstractMetadataReport 为子类实现的公共能力就介绍完了,其他方法都是委托给了相应的 do*() 方法,这些 do*() 方法都是在 AbstractMetadataReport 子类中实现的。
-
+
2. BaseMetadataIdentifier
在 AbstractMetadataReport 上报元数据的时候,元数据对应的 Key 都是BaseMetadataIdentifier 类型的对象,其继承关系如下图所示:
-
+
BaseMetadataIdentifier 继承关系图
- MetadataIdentifier 中包含了服务接口、version、group、side 和 application 五个核心字段。
@@ -695,7 +695,7 @@ public interface MetadataReportFactory {
MetadataReportFactory 是个扩展接口,从 @SPI 注解的默认值可以看出Dubbo 默认使用 Redis 实现元数据中心。
Dubbo 提供了针对 ZooKeeper、Redis、Consul 等作为元数据中心的 MetadataReportFactory 实现,如下图所示:
-
+
MetadataReportFactory 继承关系图
这些 MetadataReportFactory 实现都继承了 AbstractMetadataReportFactory,在 AbstractMetadataReportFactory 提供了缓存 MetadataReport 实现的功能,并定义了一个 createMetadataReport() 抽象方法供子类实现。另外,AbstractMetadataReportFactory 实现了 MetadataReportFactory 接口的 getMetadataReport() 方法,下面我们就来简单看一下该方法的实现:
public MetadataReport getMetadataReport(URL url) {
@@ -763,7 +763,7 @@ String getNodePath(BaseMetadataIdentifier metadataIdentifier) {
}
MetadataServiceExporter 只有 ConfigurableMetadataServiceExporter 这一个实现,如下图所示:
-
+
MetadataServiceExporter 继承关系图
ConfigurableMetadataServiceExporter 的核心实现是 export() 方法,其中会创建一个 ServiceConfig 对象完成 MetadataService 服务的发布:
public ConfigurableMetadataServiceExporter export() {
diff --git a/专栏/Dubbo源码解读与实战-完/45 加餐:深入服务自省方案中的服务发布订阅(上).md.html b/专栏/Dubbo源码解读与实战-完/45 加餐:深入服务自省方案中的服务发布订阅(上).md.html
index d6affb58..bc5cc0ec 100644
--- a/专栏/Dubbo源码解读与实战-完/45 加餐:深入服务自省方案中的服务发布订阅(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/45 加餐:深入服务自省方案中的服务发布订阅(上).md.html
@@ -368,10 +368,10 @@ public interface ServiceDiscovery extends Prioritized {
}
ServiceDiscovery 接口被 @SPI 注解修饰,是一个扩展点,针对不同的注册中心,有不同的 ServiceDiscovery 实现,如下图所示:
-
+
ServiceDiscovery 继承关系图
在 Dubbo 创建 ServiceDiscovery 对象的时候,会通过 ServiceDiscoveryFactory 工厂类进行创建。ServiceDiscoveryFactory 接口也是一个扩展接口,Dubbo 只提供了一个默认实现—— DefaultServiceDiscoveryFactory,其继承关系如下图所示:
-
+
ServiceDiscoveryFactory 继承关系图
在 AbstractServiceDiscoveryFactory 中维护了一个 ConcurrentMap<String, ServiceDiscovery> 类型的集合(discoveries 字段)来缓存 ServiceDiscovery 对象,并提供了一个 createDiscovery() 抽象方法来创建 ServiceDiscovery 实例。
public ServiceDiscovery getServiceDiscovery(URL registryURL) {
@@ -427,7 +427,7 @@ public static org.apache.curator.x.discovery.ServiceInstance<ZookeeperInstanc
除了上述服务实例发布的功能之外,在服务实例订阅的时候,还会用到 ZookeeperServiceDiscovery 查询服务实例的信息,这些方法都是直接依赖 Apache Curator 实现的,例如,getServices() 方法会调用 Curator ServiceDiscovery 的 queryForNames() 方法查询 Service Name,getInstances() 方法会通过 Curator ServiceDiscovery 的 queryForInstances() 方法查询 Service Instance。
EventListener 接口
ZookeeperServiceDiscovery 除了实现了 ServiceDiscovery 接口之外,还实现了 EventListener 接口,如下图所示:
-
+
ZookeeperServiceDiscovery 继承关系图
也就是说,ZookeeperServiceDiscovery 本身也是 EventListener 实现,可以作为 EventListener 监听某些事件。下面我们先来看 Dubbo 中 EventListener 接口的定义,其中关注三个方法:onEvent() 方法、getPriority() 方法和 findEventType() 工具方法。
@SPI
@@ -461,7 +461,7 @@ public interface EventListener<E extends Event> extends java.util.EventLis
}
Dubbo 中有很多 EventListener 接口的实现,如下图所示:
-
+
EventListener 继承关系图
我们先来重点关注 ZookeeperServiceDiscovery 这个实现,在其 onEvent() 方法(以及 addServiceInstancesChangedListener() 方法)中会调用 registerServiceWatcher() 方法重新注册:
public void onEvent(ServiceInstancesChangedEvent event) {
@@ -517,7 +517,7 @@ public interface EventDispatcher extends Listenable<EventListener<?>>
}
EventDispatcher 接口被 @SPI 注解修饰,是一个扩展点,Dubbo 提供了两个具体实现——ParallelEventDispatcher 和 DirectEventDispatcher,如下图所示:
-
+
EventDispatcher 继承关系图
在 AbstractEventDispatcher 中维护了两个核心字段。
diff --git a/专栏/Dubbo源码解读与实战-完/46 加餐:深入服务自省方案中的服务发布订阅(下).md.html b/专栏/Dubbo源码解读与实战-完/46 加餐:深入服务自省方案中的服务发布订阅(下).md.html
index 0f7d7c01..c638ebe5 100644
--- a/专栏/Dubbo源码解读与实战-完/46 加餐:深入服务自省方案中的服务发布订阅(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/46 加餐:深入服务自省方案中的服务发布订阅(下).md.html
@@ -291,7 +291,7 @@ function hide_canvas() {
46 加餐:深入服务自省方案中的服务发布订阅(下)
在课程第二部分(13~15 课时)中介绍 Dubbo 传统框架中的注册中心部分实现时,我们提到了 Registry、RegistryFactory 等与注册中心交互的接口。为了将 ServiceDiscovery 接口的功能与 Registry 融合,Dubbo 提供了一个 ServiceDiscoveryRegistry 实现,继承关系如下所示:
-
+
ServiceDiscoveryRegistry 、ServiceDiscoveryRegistryFactory 继承关系图
由图我们可以看到:ServiceDiscoveryRegistryFactory(扩展名称是 service-discovery-registry)是 ServiceDiscoveryRegistry 对应的工厂类,继承了 AbstractRegistryFactory 提供的公共能力。
ServiceDiscoveryRegistry 是一个面向服务实例(ServiceInstance)的注册中心实现,其底层依赖前面两个课时介绍的 ServiceDiscovery、WritableMetadataService 等组件。
@@ -476,7 +476,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
关于 ServiceInstanceCustomizer 接口,这里需要关注三个点:①该接口被 @SPI 注解修饰,是一个扩展点;②该接口继承了 Prioritized 接口;③该接口中定义的 customize() 方法可以用来自定义 ServiceInstance 信息,其中就包括控制 metadata 集合中的数据。
也就说,ServiceInstanceCustomizer 的多个实现可以按序调用,实现 ServiceInstance 的自定义。下图展示了 ServiceInstanceCustomizer 接口的所有实现类:
-
+
ServiceInstanceCustomizer 继承关系图
我们首先来看 ServiceInstanceMetadataCustomizer 这个抽象类,它主要是对 ServiceInstance 中 metadata 这个 KV 集合进行自定义修改,这部分逻辑在 customize() 方法中,如下所示:
public final void customize(ServiceInstance serviceInstance) {
@@ -624,7 +624,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
}
这里涉及一个新的接口——MetadataServiceProxyFactory,它是用来创建 MetadataService 本地代理的工厂类,继承关系如下所示:
-
+
MetadataServiceProxyFactory 继承关系图
在 BaseMetadataServiceProxyFactory 中提供了缓存 MetadataService 本地代理的公共功能,其中维护了一个 proxies 集合(HashMap<String, MetadataService> 类型),Key 是 Service Name 与一个 ServiceInstance 的 revision 值的组合,Value 是该 ServiceInstance 对应的 MetadataService 服务的本地代理对象。创建 MetadataService 本地代理的功能是在 createProxy() 抽象方法中实现的,这个方法由 BaseMetadataServiceProxyFactory 的子类具体实现。
下面来看 BaseMetadataServiceProxyFactory 的两个实现——DefaultMetadataServiceProxyFactory 和 RemoteMetadataServiceProxyFactory。
@@ -650,7 +650,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
}
这里我们来看 MetadataServiceURLBuilder 接口中创建 MetadataService 服务对应的 URL 的逻辑,下图展示了 MetadataServiceURLBuilder 接口的实现:
-
+
MetadataServiceURLBuilder 继承关系图
其中,SpringCloudMetadataServiceURLBuilder 是兼容 Spring Cloud 的实现,这里就不深入分析了。我们重点来看 StandardMetadataServiceURLBuilder 的实现,其中会根据 ServiceInstance.metadata 携带的 URL 参数、Service Name、ServiceInstance 的 host 等信息构造 MetadataService 服务对应 URL,如下所示:
public List<URL> build(ServiceInstance serviceInstance) {
@@ -680,7 +680,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
}
接下来我们看 RemoteMetadataServiceProxyFactory 这个实现类,其中的 createProxy() 方法会直接创建一个 RemoteMetadataServiceProxy 对象并返回。在前面第 44 课时介绍 MetadataService 接口的时候,我们重点介绍的是 WritableMetadataService 这个子接口下的实现,并没有提及 RemoteMetadataServiceProxy 这个实现。下图是 RemoteMetadataServiceProxy 在继承体系中的位置:
-
+
RemoteMetadataServiceProxy 继承关系图
RemoteMetadataServiceProxy 作为 RemoteWritableMetadataService 的本地代理,其 getExportedURLs()、getServiceDefinition() 等方法的实现,完全依赖于 MetadataReport 进行实现。这里以 getExportedURLs() 方法为例:
public SortedSet<String> getExportedURLs(String serviceInterface, String group, String version, String protocol) {
diff --git a/专栏/Dubbo源码解读与实战-完/47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上).md.html b/专栏/Dubbo源码解读与实战-完/47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上).md.html
index 81ad802c..5dcef889 100644
--- a/专栏/Dubbo源码解读与实战-完/47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上).md.html
+++ b/专栏/Dubbo源码解读与实战-完/47 配置中心设计与实现:集中化配置 and 本地化配置,我都要(上).md.html
@@ -306,11 +306,11 @@ function hide_canvas() {
Configuration
Configuration 接口是 Dubbo 中所有配置的基础接口,其中定义了根据指定 Key 获取对应配置值的相关方法,如下图所示:
-
+
Configuration 接口核心方法
从上图中我们可以看到,Configuration 针对不同的 boolean、int、String 返回值都有对应的 get*() 方法,同时还提供了带有默认值的 get*() 方法。这些 get*() 方法底层首先调用 getInternalProperty() 方法获取配置值
,然后调用 convert() 方法将获取到的配置值转换成返回值的类型之后返回。getInternalProperty() 是一个抽象方法,由 Configuration 接口的子类具体实现。
下图展示了 Dubbo 中提供的 Configuration 接口实现,包括:SystemConfiguration、EnvironmentConfiguration、InmemoryConfiguration、PropertiesConfiguration、CompositeConfiguration、ConfigConfigurationAdapter 和 DynamicConfiguration。下面我们将结合具体代码逐个介绍其实现。
-
+
Configuration 继承关系图
SystemConfiguration & EnvironmentConfiguration
SystemConfiguration 是从 Java Properties 配置(也就是 -D 配置参数)中获取相应的配置项,EnvironmentConfiguration 是从使用环境变量中获取相应的配置。两者的 getInternalProperty() 方法实现如下:
@@ -435,7 +435,7 @@ public interface OrderedPropertiesProvider {
ConfigConfigurationAdapter
Dubbo 通过 AbstractConfig 类来抽象实例对应的配置,如下图所示:
-
+
AbstractConfig 继承关系图
这些 AbstractConfig 实现基本都对应一个固定的配置,也定义了配置对应的字段以及 getter/setter() 方法。例如,RegistryConfig 这个实现类就对应了注册中心的相关配置,其中包含了 address、protocol、port、timeout 等一系列与注册中心相关的字段以及对应的 getter/setter() 方法,来接收用户通过 XML、Annotation 或是 API 方式传入的注册中心配置。
ConfigConfigurationAdapter 是 AbstractConfig 与 Configuration 之间的适配器,它会将 AbstractConfig 对象转换成 Configuration 对象。在 ConfigConfigurationAdapter 的构造方法中会获取 AbstractConfig 对象的全部字段,并转换成一个 Map<String, String> 集合返回,该 Map<String, String> 集合将会被 ConfigConfigurationAdapter 的 metaData 字段引用。相关示例代码如下:
@@ -485,9 +485,9 @@ public interface DynamicConfigurationFactory {
}
DynamicConfigurationFactory 接口的继承关系以及 DynamicConfiguration 接口对应的继承关系如下:
-
+
DynamicConfigurationFactory 继承关系图
-
+
DynamicConfiguration 继承关系图
我们先来看 AbstractDynamicConfigurationFactory 的实现,其中会维护一个 dynamicConfigurations 集合(Map<String, DynamicConfiguration> 类型),在 getDynamicConfiguration() 方法中会填充该集合,实现缓存DynamicConfiguration 对象的效果。同时,AbstractDynamicConfigurationFactory 提供了一个 createDynamicConfiguration() 方法给子类实现,来创建DynamicConfiguration 对象。
以 ZookeeperDynamicConfigurationFactory 实现为例,其 createDynamicConfiguration() 方法创建的就是 ZookeeperDynamicConfiguration 对象:
@@ -575,7 +575,7 @@ public interface DynamicConfigurationFactory {
}
CacheListener 中调用的监听器都是 ConfigurationListener 接口实现,如下图所示,这里涉及[第 33 课时]介绍的 TagRouter、AppRouter 和 ServiceRouter,它们主要是监听路由配置的变化;还涉及 RegistryDirectory 和 RegistryProtocol 中的四个内部类(AbstractConfiguratorListener 的子类),它们主要监听 Provider 和 Consumer 的配置变化。
-
+
ConfigurationListener 继承关系图
这些 ConfigurationListener 实现在前面的课程中已经详细介绍过了,这里就不再重复。ZookeeperDynamicConfiguration 中还提供了 addListener()、removeListener() 两个方法用来增删 ConfigurationListener 监听器,具体实现比较简单,这里就不再展示,你若感兴趣的话可以参考源码进行学习。
介绍完 ZookeeperDynamicConfiguration 的初始化过程之后,我们再来看 ZookeeperDynamicConfiguration 中读取配置、写入配置的相关操作。相关方法的实现如下:
diff --git a/专栏/Dubbo源码解读与实战-完/48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下).md.html b/专栏/Dubbo源码解读与实战-完/48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下).md.html
index fa63860e..dce0166d 100644
--- a/专栏/Dubbo源码解读与实战-完/48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下).md.html
+++ b/专栏/Dubbo源码解读与实战-完/48 配置中心设计与实现:集中化配置 and 本地化配置,我都要(下).md.html
@@ -497,7 +497,7 @@ compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter))
随后,DubboBootstrap 执行 checkGlobalConfigs() 方法完成 ProviderConfig、ConsumerConfig、MetadataReportConfig 等一系列 AbstractConfig 的检查和初始化,具体实现比较简单,这里就不再展示。
再紧接着,DubboBootstrap 会通过 initMetadataService() 方法初始化 MetadataReport、MetadataReportInstance 以及 MetadataService、MetadataServiceExporter,这些元数据相关的组件在前面的课时中已经深入分析过了,这里的初始化过程并不复杂,你若感兴趣的话可以参考源码进行学习。
在 DubboBootstrap 初始化的最后,会调用 initEventListener() 方法将 DubboBootstrap 作为 EventListener 监听器添加到 EventDispatcher 中。DubboBootstrap 继承了 GenericEventListener 抽象类,如下图所示:
-
+
EventListener 继承关系图
GenericEventListener 是一个泛型监听器,它可以让子类监听任意关心的 Event 事件,只需定义相关的 onEvent() 方法即可。在 GenericEventListener 中维护了一个 handleEventMethods 集合,其中 Key 是 Event 的子类,即监听器关心的事件,Value 是处理该类型 Event 的相应 onEvent() 方法。
在 GenericEventListener 的构造方法中,通过反射将当前 GenericEventListener 实现的全部 onEvent() 方法都查找出来,并记录到 handleEventMethods 字段中。具体查找逻辑在 findHandleEventMethods() 方法中实现:
@@ -530,7 +530,7 @@ compositeDynamicConfiguration.addConfiguration(prepareEnvironment(configCenter))
}
我们可以查看 DubboBootstrap 的所有方法,目前并没有发现符合 isHandleEventMethod() 条件的方法。但在 GenericEventListener 的另一个实现—— LoggingEventListener 中,可以看到多个符合 isHandleEventMethod() 条件的方法(如下图所示),在这些 onEvent() 方法重载中会输出 INFO 日志。
-
+
LoggingEventListener 中 onEvent 方法重载
至此,DubboBootstrap 整个初始化过程,以及该过程中与配置中心相关的逻辑就介绍完了。
总结
diff --git a/专栏/JVM 核心技术 32 讲(完)/01 阅读此专栏的正确姿势.md.html b/专栏/JVM 核心技术 32 讲(完)/01 阅读此专栏的正确姿势.md.html
index fb614152..39199747 100644
--- a/专栏/JVM 核心技术 32 讲(完)/01 阅读此专栏的正确姿势.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/01 阅读此专栏的正确姿势.md.html
@@ -191,14 +191,14 @@ function hide_canvas() {
近些年来,无论是使用规模、开发者人数,还是技术生态成熟度、相关工具的丰富程度,Java 都当之无愧是后端开发语言中不可撼动的王者,也是开发各类业务系统的首选语言。
时至今日,整个 IT 招聘市场上,Java 开发工程师依然是缺口最大,需求最多的热门职位。另外,从整个市场环境看,传统企业的信息化,传统 IT 系统的互联网化,都还有非常大的发展空间,由此推断未来 Java 开发的市场前景广阔,从业人员的行业红利还可以持续很长时间。
从权威的 TIOBE 编程语言排行榜 2019 年 11 月数据来看,Java 的流行程度也是稳居第一。
-
-
+
+
拉勾网 2019 年 9 月统计的招聘岗位比例,也可以看到 Java 和 JavaScript 是最高的,不过 Java 的求职难度只有 JavaScript 的 1/7。
-
+
Java 平均一个岗位有 4 个人竞争,而 JavaScript 则是 28 个,Perl 最夸张,超过 30 个。
-
+
而通过职友网的数据统计,北京、上海、杭州、深圳的 Java 程序员平均薪酬在 16-21K 之间,在广州、成都、苏州、南京等城市也有 11K-13K 的平均收入,远超一般行业的收入水平。
-
+
所以学习 Java 目前还是一个非常有优势的职业发展选择。
而了解 JVM 则是深入学习 Java 必不可少的一环,也是 Java 开发人员迈向更高水平的一个阶梯。我们不仅要会用 Java 写代码做系统,更要懂得如何理解和分析 Java 程序运行起来以后内部发生了什么,然后可以怎么让它运行的更好。
就像我们要想多年开车的老司机,仅仅会开车肯定不能当一个好司机。车开多了,总会有一些多多少少大大小小的故障毛病。老司机需要知道什么现象说明有了什么毛病,需要怎么处理,不然就会导致经常抛锚,影响我们的行程。
diff --git a/专栏/JVM 核心技术 32 讲(完)/02 环境准备:千里之行,始于足下.md.html b/专栏/JVM 核心技术 32 讲(完)/02 环境准备:千里之行,始于足下.md.html
index e47fc5e6..df4c52ee 100644
--- a/专栏/JVM 核心技术 32 讲(完)/02 环境准备:千里之行,始于足下.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/02 环境准备:千里之行,始于足下.md.html
@@ -208,10 +208,10 @@ function hide_canvas() {
- JDK = JRE + 开发工具
- JRE = JVM + 类库
-
+
三者在开发运行 Java 程序时的交互关系:
简单的说,就是通过 JDK 开发的程序,编译以后,可以打包分发给其他装有 JRE 的机器上去运行。而运行的程序,则是通过 Java 命令启动的一个 JVM 实例,代码逻辑的执行都运行在这个 JVM 实例上。
-
+
Java 程序的开发运行过程为:
我们利用 JDK (调用 Java API)开发 Java 程序,编译成字节码或者打包程序。然后可以用 JRE 则启动一个 JVM 实例,加载、验证、执行 Java 字节码以及依赖库,运行 Java 程序。而 JVM 将程序和依赖库的 Java 字节码解析并变成本地代码执行,产生结果。
1.2 JDK 的发展过程与版本变迁
@@ -348,8 +348,8 @@ function hide_canvas() {
常规的 JDK,一般指 OpenJDK 或者 Oracle JDK,当然 Oracle 还有一个新的 JVM 叫 GraalVM,也非常有意思。除了 Sun/Oracle 的 JDK 以外,原 BEA 公司(已被 Oracle 收购)的 JRockit,IBM 公司的 J9,Azul 公司的 Zing JVM,阿里巴巴公司的分支版本 DragonWell 等等。
1.3 安装 JDK
-JDK 通常是从 Oracle 官网下载, 打开页面翻到底部,找 Java for Developers
或者 Developers
, 进入 Java 相应的页面 或者 Java SE 相应的页面, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 
-建议安装比较新的 JDK8 版本, 如 JDK8u231。 
+JDK 通常是从 Oracle 官网下载, 打开页面翻到底部,找 Java for Developers
或者 Developers
, 进入 Java 相应的页面 或者 Java SE 相应的页面, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 
+建议安装比较新的 JDK8 版本, 如 JDK8u231。 
注意:从 Oracle 官方安装 JDK 需要注册和登录 Oracle 账号。现在流行将下载链接放到页面底部,很多工具都这样。当前推荐下载 JDK8。 今后 JDK11 可能成为主流版本,因为 Java11 是 LTS 长期支持版本,但可能还需要一些时间才会普及,而且 JDK11 的文件目录结构与之前不同, 很多工具可能不兼容其 JDK 文件的目录结构。
@@ -361,7 +361,7 @@ function hide_canvas() {
brew cask install java
-如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): 
+如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): 
如果网络不好,可以从我的百度网盘共享获取:
https://pan.baidu.com/s/16WmRDZSiBD7a2PMjhSiGJw
@@ -404,7 +404,7 @@ find / -name javac
找到满足 $JAVA_HOME/bin/javac
的路径即可。
Windows 系统,安装在哪就是哪,默认在C:\Program Files (x86)\Java
下。通过任务管理器也可以查看某个程序的路径,注意 JAVA_HOME
不可能是 C:\Windows\System32
目录。
然后我们就可以在 JDK 安装路径下看到很多 JVM 工具,例如在 Mac 上:
-
在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。
+
在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。
1.4 验证 JDK 安装完成
安装完成后,Java 环境一般来说就可以使用了。 验证的脚本命令为:
$ java -version
diff --git a/专栏/JVM 核心技术 32 讲(完)/03 常用性能指标:没有量化,就没有改进.md.html b/专栏/JVM 核心技术 32 讲(完)/03 常用性能指标:没有量化,就没有改进.md.html
index 2218ee40..20f823fc 100644
--- a/专栏/JVM 核心技术 32 讲(完)/03 常用性能指标:没有量化,就没有改进.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/03 常用性能指标:没有量化,就没有改进.md.html
@@ -190,13 +190,13 @@ function hide_canvas() {
前面一节课阐述了 JDK 的发展过程,以及怎么安装一个 JDK,在正式开始进行 JVM 的内容之前,我们先了解一下性能相关的一些基本概念和原则。
-
+
如果要问目前最火热的 JVM 知识是什么? 很多同学的答案可能是 “JVM 调优
” 或者 “JVM 性能优化
”。但是具体需要从哪儿入手,怎么去做呢?
其实“调优”是一个诊断和处理手段,我们最终的目标是让系统的处理能力,也就是“性能”达到最优化,这个过程我们就像是一个医生,诊断和治疗“应用系统”这位病人。我们以作为医生给系统看病作为对比,“性能优化”就是实现“把身体的大小毛病治好,身体达到最佳健康状态”的目标。
那么去医院看病,医生会是怎么一个处理流程呢?先简单的询问和了解基本情况,发烧了没有,咳嗽几天了,最近吃了什么,有没有拉肚子一类的,然后给患者开了一系列的检查化验单子:去查个血、拍个胸透、验个尿之类的。然后就会有医生使用各项仪器工具,依次把去做这些项目的检查,检查的结果就是很多标准化的具体指标(这里就是我们对 JVM 进行信息收集,变成各项指标)。
然后拿过来给医生诊断用,医生根据这些指标数据判断哪些是异常的,哪些是正常的,这些异常指标说明了什么问题(对系统问题进行分析排查),比如是白细胞增多(系统延迟和抖动增加,偶尔宕机),说明可能有炎症(比如 JVM 配置不合理)。最后要“对症下药”,开出一些阿莫西林或者头孢(对 JVM 配置进行调整),叮嘱怎么频率,什么时间点服药,如果问题比较严重,是不是要住院做手术(系统重构和调整),同时告知一些注意事项(对日常运维的要求和建议),最后经过一段时间治疗,逐渐好转,最终痊愈(系统延迟降低,不在抖动,不再宕机)。通过了解 JVM 去让我们具有分析和诊断能力,是本课程的核心主题。
2.1 量化性能相关指标
-
+
"没有量化就没有改进",所以我们需要先了解和度量性能指标,就像在医院检查以后得到的检验报告单一样。因为人的主观感觉是不靠谱的,个人经验本身也是无法复制的,而定义了量化的指标,就意味着我们有了一个客观度量体系。哪怕我们最开始定义的指标不是特别精确,我们也可以在使用过程中,随着真实的场景去验证指标有效性,进而替换或者调整指标,逐渐的完善这个量化的指标体系,成为一个可以复制和复用的有效工具。就像是上图的血常规检查报告单
,一旦成为这种标准化的指标,那么使用它得到的结果,也就是这个报告单,给任何一个医生看,都是有效的,一般也能得到一致的判断结果。
那么系统性能的诊断要做些什么指标呢?我们先来考虑,进行要做诊断,那么程序或 JVM 可能出现了问题,而我们排查程序运行中出现的问题,比如排查程序 BUG 的时候,要优先保证正确性,这时候就不仅仅是 JVM 本身的问题,例如死锁等等,程序跑在 JVM 里,现象出现在 JVM 上,很多时候还要深入分析业务代码和逻辑确定 Java 程序哪里有问题。
@@ -229,7 +229,7 @@ function hide_canvas() {
- 业务需求指标:如吞吐量(QPS、TPS)、响应时间(RT)、并发数、业务成功率等。
- 资源约束指标:如 CPU、内存、I/O 等资源的消耗情况。
-
+
详情可参考: 性能测试中服务器关键性能指标浅析
@@ -246,7 +246,7 @@ function hide_canvas() {
- 调整 JVM 启动参数,GC 策略等等
2.3 性能调优总结
-
+
性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 TPS 和 QPS,就是极限值。知道了极限值,我们就可以按业务发展测算流量和系统压力,以此做容量规划,准备机器资源和预期的扩容计划。最后在系统的日常运行过程中,持续观察,逐步重做和调整以上步骤,长期改善改进系统性能。
我们经常说“脱离场景谈性能都是耍流氓
”,实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到 3000TPS 如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到 3100TPS 就没有什么意义,同样地如果花一倍成本去优化到 5000TPS 也没有意义。
Donald Knuth 曾说过“过早的优化是万恶之源
”,我们需要考虑在恰当的时机去优化系统。在业务发展的早期,量不大,性能没那么重要。我们做一个新系统,先考虑整体设计是不是 OK,功能实现是不是 OK,然后基本的功能都做得差不多的时候(当然整体的框架是不是满足性能基准,可能需要在做项目的准备阶段就通过 POC(概念证明)阶段验证。),最后再考虑性能的优化工作。因为如果一开始就考虑优化,就可能要想太多导致过度设计了。而且主体框架和功能完成之前,可能会有比较大的改动,一旦提前做了优化,可能这些改动导致原来的优化都失效了,又要重新优化,多做了很多无用功。
diff --git a/专栏/JVM 核心技术 32 讲(完)/04 JVM 基础知识:不积跬步,无以至千里.md.html b/专栏/JVM 核心技术 32 讲(完)/04 JVM 基础知识:不积跬步,无以至千里.md.html
index c20fe0fc..816d0a41 100644
--- a/专栏/JVM 核心技术 32 讲(完)/04 JVM 基础知识:不积跬步,无以至千里.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/04 JVM 基础知识:不积跬步,无以至千里.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
我们都知道 Java 是一种基于虚拟机的静态类型编译语言。那么常见的语言可以怎么分类呢?
1)编程语言分类
首先,我们可以把形形色色的编程从底向上划分为最基本的三大类:机器语言、汇编语言、高级语言。
-
+
按《计算机编程语言的发展与应用》一文里的定义:计算机编程语言能够实现人与机器之间的交流和沟通,而计算机编程语言主要包括汇编语言、机器语言以及高级语言,具体内容如下:
- 机器语言:这种语言主要是利用二进制编码进行指令的发送,能够被计算机快速地识别,其灵活性相对较高,且执行速度较为可观,机器语言与汇编语言之间的相似性较高,但由于具有局限性,所以在使用上存在一定的约束性。
@@ -238,8 +238,8 @@ function hide_canvas() {
现在我们聊聊跨平台,为什么要跨平台,因为我们希望所编写的代码和程序,在源代码级别或者编译后,可以运行在多种不同的系统平台上,而不需要为了各个平台的不同点而去实现两套代码。典型地,我们编写一个 web 程序,自然希望可以把它部署到 Windows 平台上,也可以部署到 Linux 平台上,甚至是 MacOS 系统上。
这就是跨平台的能力,极大地节省了开发和维护成本,赢得了商业市场上的一致好评。
这样来看,一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。
-1、典型的源码跨平台(C++): 
-2、典型的二进制跨平台(Java 字节码): 
+1、典型的源码跨平台(C++): 
+2、典型的二进制跨平台(Java 字节码): 
可以看到,C++ 里我们需要把一份源码,在不同平台上分别编译,生成这个平台相关的二进制可执行文件,然后才能在相应的平台上运行。 这样就需要在各个平台都有开发工具和编译器,而且在各个平台所依赖的开发库都需要是一致或兼容的。 这一点在过去的年代里非常痛苦,被戏称为 “依赖地狱”。
C++ 的口号是“一次编写,到处(不同平台)编译”,但实际情况上是一编译就报错,变成了 “一次编写,到处调试,到处找依赖、改配置”。 大家可以想象,你编译一份代码,发现缺了几十个依赖,到处找还找不到,或者找到了又跟本地已有的版本不兼容,这是一件怎样令人绝望的事情。
而 Java 语言通过虚拟机技术率先解决了这个难题。 源码只需要编译一次,然后把编译后的 class 文件或 jar 包,部署到不同平台,就可以直接通过安装在这些系统中的 JVM 上面执行。 同时可以把依赖库(jar 文件)一起复制到目标机器,慢慢地又有了可以在各个平台都直接使用的 Maven 中央库(类似于 linux 里的 yum 或 apt-get 源,macos 里的 homebrew,现代的各种编程语言一般都有了这种包依赖管理机制:python 的 pip,dotnet 的 nuget,NodeJS 的 npm,golang 的 dep,rust 的 cargo 等等)。这样就实现了让同一个应用程序在不同的平台上直接运行的能力。
diff --git a/专栏/JVM 核心技术 32 讲(完)/05 Java 字节码技术:不积细流,无以成江河.md.html b/专栏/JVM 核心技术 32 讲(完)/05 Java 字节码技术:不积细流,无以成江河.md.html
index 824feddd..71bd2ad6 100644
--- a/专栏/JVM 核心技术 32 讲(完)/05 Java 字节码技术:不积细流,无以成江河.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/05 Java 字节码技术:不积细流,无以成江河.md.html
@@ -393,7 +393,7 @@ SourceFile: "HelloByteCode.java"
想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。
JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈(JVM stack),用于存储栈帧
(Frame)。每一次方法调用,JVM都会自动创建一个栈帧。栈帧
由 操作数栈
, 局部变量数组
以及一个class 引用
组成。class 引用
指向当前方法在运行时常量池中对应的 class)。
我们在前面反编译的代码中已经看到过这些内容。
-
+
局部变量数组
也称为 局部变量表
(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。
有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。
4.7 方法体中的字节码解读
@@ -408,11 +408,11 @@ SourceFile: "HelloByteCode.java"
例如, new
就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。
因此,下一条指令 dup
的索引从 3
开始。
如果将这个方法体变成可视化数组,那么看起来应该是这样的:
-
+
每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示:
-
+
甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:
-
(此图由开源文本编辑软件Atom的hex-view插件生成)
+
(此图由开源文本编辑软件Atom的hex-view插件生成)
粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。
其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 类加载器
,其他主题则留待以后探讨。
4.8 对象初始化指令:new 指令, init 以及 clinit 简介
@@ -452,13 +452,13 @@ SourceFile: "HelloByteCode.java"
dup_x1
将复制栈顶元素的值,并在栈顶插入两次(图中示例5);
dup2_x1
则复制栈顶两个元素的值,并插入第三个值(图中示例6)。
-
+
dup_x1
和 dup2_x1
指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?
请看一个实际案例:怎样交换 2 个 double 类型的值?
需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。
要执行交换,你可能想到了 swap
指令,但问题是 swap
只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。
怎么办呢? 解决方法就是使用 dup2_x2
指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 pop2
指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示:
-
+
dup
、dup_x1
、dup2_x1
指令补充说明
指令的详细说明可参考 JVM 规范:
dup 指令
@@ -656,7 +656,7 @@ public class LocalVariableTest {
关于 LocalVariableTable
有个有意思的事情,就是最前面的槽位会被方法参数占用。
在这里,因为 main
是静态方法,所以槽位0中并没有设置为 this
引用的地址。 但是对于非静态方法来说, this
会将分配到第 0 号槽位中。
-再次提醒: 有过反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args)
; 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

+再次提醒: 有过反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args)
; 有JavaScript编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

理解这些字节码的诀窍在于:
给局部变量赋值时,需要使用相应的指令来进行 store
,如 astore_1
。store
类的指令都会删除栈顶值。 相应的 load
指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。
@@ -748,11 +748,11 @@ javap -c -verbose demo/jvm0104/ForLoopTest
Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(int
, long
, double
, float
),都有加,减,乘,除,取反的指令。
那么 byte
和 char
, boolean
呢? JVM 是当做 int
来处理的。另外还有部分指令用于数据类型之间的转换。
-算术操作码和类型 
+算术操作码和类型 
当我们想将 int
类型的值赋值给 long
类型的变量时,就会发生类型转换。
-类型转换操作码 
+类型转换操作码 
在前面的示例中, 将 int
值作为参数传递给实际上接收 double
的 submit()
方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码:
31: iload 5
diff --git a/专栏/JVM 核心技术 32 讲(完)/06 Java 类加载器:山不辞土,故能成其高.md.html b/专栏/JVM 核心技术 32 讲(完)/06 Java 类加载器:山不辞土,故能成其高.md.html
index 126c508d..6c361aec 100644
--- a/专栏/JVM 核心技术 32 讲(完)/06 Java 类加载器:山不辞土,故能成其高.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/06 Java 类加载器:山不辞土,故能成其高.md.html
@@ -219,7 +219,7 @@ function hide_canvas() {
按照 Java 语言规范和 Java 虚拟机规范的定义, 我们用 “类加载
(Class Loading)” 来表示: 将 class/interface 名称映射为 Class 对象的一整个过程。 这个过程还可以划分为更具体的阶段: 加载,链接和初始化(loading, linking and initializing)。
那么加载 class 的过程中到底发生了些什么呢?我们来详细看看。
5.1 类的生命周期和加载过程
-
+
一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。
其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就分别来说一下这五个过程。
1)加载 加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的“class 文件
”。 如果找不到二进制表示形式,则会抛出 NoClassDefFound
错误。
@@ -284,14 +284,14 @@ function hide_canvas() {
- 应用类加载器(AppClassLoader)
一般启动类加载器是由 JVM 内部实现的,在 Java 的 API 里无法拿到,但是我们可以侧面看到和影响它(后面的内容会演示)。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher
定义的,扩展类加载器和应用类加载器一般都继承自URLClassLoader
类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。
-
+
- 启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class)。它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个
null
。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。
- 扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。
- 应用类加载器(app class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。
此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。
-
+
类加载机制有三个特点:
- 双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。
diff --git a/专栏/JVM 核心技术 32 讲(完)/07 Java 内存模型:海不辞水,故能成其深.md.html b/专栏/JVM 核心技术 32 讲(完)/07 Java 内存模型:海不辞水,故能成其深.md.html
index da8dcbe1..f15dd18d 100644
--- a/专栏/JVM 核心技术 32 讲(完)/07 Java 内存模型:海不辞水,故能成其深.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/07 Java 内存模型:海不辞水,故能成其深.md.html
@@ -200,7 +200,7 @@ function hide_canvas() {
6.1 JVM 内存结构
我们先来看看 JVM 整体的内存概念图:
JVM 内部使用的 Java 内存模型, 在逻辑上将内存划分为 线程栈
(thread stacks)和堆内存
(heap)两个部分。 如下图所示:
-
+
JVM 中,每个正在运行的线程,都有自己的线程栈。 线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息。
所以线程栈又被称为“方法栈
”或“调用栈
”(call stack)。线程在执行代码时,调用栈中的信息会一直在变化。
线程栈里面保存了调用链上正在执行的所有方法中的局部变量。
@@ -216,7 +216,7 @@ function hide_canvas() {
- 不管是创建一个对象并将其赋值给局部变量, 还是赋值给另一个对象的成员变量, 创建的对象都会被保存到堆内存中。
下图演示了线程栈上的调用栈和局部变量,以及存储在堆内存中的对象:
-
+
- 如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上。
- 如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中。
@@ -230,21 +230,21 @@ function hide_canvas() {
- 如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。
示意图如下所示:
-
+
总结一下:虽然各个线程自己使用的局部变量都在自己的栈上,但是大家可以共享堆上的对象,特别地各个不同线程访问同一个对象实例的基础类型的成员变量,会给每个线程一个变量的副本。
6.2 栈内存的结构
根据以上内容和对 JVM 内存划分的理解,制作了几张逻辑概念图供大家参考。
先看看栈内存(Stack)的大体结构:
-
+
每启动一个线程,JVM 就会在栈空间栈分配对应的线程栈, 比如 1MB 的空间(-Xss1m
)。
线程栈也叫做 Java 方法栈。 如果使用了 JNI 方法,则会分配一个单独的本地方法栈(Native Stack)。
线程执行过程中,一般会有多个方法组成调用栈(Stack Trace), 比如 A 调用 B,B 调用 C……每执行到一个方法,就会创建对应的栈帧(Frame)。
-
+
栈帧是一个逻辑上的概念,具体的大小在一个方法编写完成后基本上就能确定。
比如 返回值
需要有一个空间存放吧,每个局部变量
都需要对应的地址空间,此外还有给指令使用的 操作数栈
,以及 class 指针(标识这个栈帧对应的是哪个类的方法, 指向非堆里面的 Class 对象)。
6.3 堆内存的结构
Java 程序除了栈内存之外,最主要的内存区域就是堆内存了。
-
+
堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。
但 JVM 的具体实现一般会有各种优化。比如将逻辑上的 Java 堆,划分为堆(Heap)
和非堆(Non-Heap)
两个部分.。这种划分的依据在于,我们编写的 Java 代码,基本上只能使用 Heap 这部分空间,发生内存分配和回收的主要区域也在这部分,所以有一种说法,这里的 Heap 也叫 GC 管理的堆(GC Heap)。
GC 理论中有一个重要的思想,叫做分代。 经过研究发现,程序中分配的对象,要么用过就扔,要么就能存活很久很久。
@@ -267,7 +267,7 @@ function hide_canvas() {
写过程序的人都知道,同样的计算,可以有不同的实现方式。 硬件指令设计同样如此,比如说我们的系统需要实现某种功能,那么复杂点的办法就是在 CPU 中封装一个逻辑运算单元来实现这种的运算,对外暴露一个专用指令。
当然也可以偷懒,不实现这个指令,而是由程序编译器想办法用原有的那些基础的,通用指令来模拟和拼凑出这个功能。那么随着时间的推移,实现专用指令的 CPU 指令集就会越来越复杂, ,被称为复杂指令集。 而偷懒的 CPU 指令集相对来说就会少很多,甚至砍掉了很多指令,所以叫精简指令集计算机。
不管哪一种指令集,CPU 的实现都是采用流水线的方式。如果 CPU 一条指令一条指令地执行,那么很多流水线实际上是闲置的。简单理解,可以类比一个 KFC 的取餐窗口就是一条流水线。于是硬件设计人员就想出了一个好办法: “指令乱序
”。 CPU 完全可以根据需要,通过内部调度把这些指令打乱了执行,充分利用流水线资源,只要最终结果是等价的,那么程序的正确性就没有问题。但这在如今多 CPU 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。
-
+
CPU 是多个核心一起执行,同时 JVM 中还有多个线程在并发执行,这种多对多让局面变得异常复杂,稍微控制不好,程序的执行结果可能就是错误的。
6.5 JMM 背景
目前的 JMM 规范对应的是 “JSR-133. Java Memory Model and Thread Specification” ,这个规范的部分内容润色之后就成为了《Java语言规范》的 $17.4. Memory Model章节。可以看到,JSR133 的最终版修订时间是在 2014 年,这是因为之前的 Java 内存模型有些坑,所以在 Java 1.5 版本的时候进行了重新设计,并一直沿用到今天。
diff --git a/专栏/JVM 核心技术 32 讲(完)/08 JVM 启动参数详解:博观而约取、厚积而薄发.md.html b/专栏/JVM 核心技术 32 讲(完)/08 JVM 启动参数详解:博观而约取、厚积而薄发.md.html
index fb60029d..7ab7f941 100644
--- a/专栏/JVM 核心技术 32 讲(完)/08 JVM 启动参数详解:博观而约取、厚积而薄发.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/08 JVM 启动参数详解:博观而约取、厚积而薄发.md.html
@@ -199,7 +199,7 @@ java [options] -jar filename [args]
如果是使用 Tomcat 之类自带 startup.sh 等启动脚本的程序,我们一般把相关参数都放到一个脚本定义的 JAVA_OPTS 环境变量中,最后脚本启动 JVM 时会把 JAVA_OPTS 变量里的所有参数都加到命令的合适位置。
如果是在 IDEA 之类的 IDE 里运行的话,则可以在“Run/Debug Configurations”里看到 VM 选项和程序参数两个可以输入参数的地方,直接输入即可。
-
+
上图输入了两个 VM 参数,都是环境变量,一个是指定文件编码使用 UTF-8,一个是设置了环境变量 a 的值为 1。
Java 和 JDK 内置的工具,指定参数时都是一个 -
,不管是长参数还是短参数。有时候,JVM 启动参数和 Java 程序启动参数,并没必要严格区分,大致知道都是一个概念即可。
JVM 的启动参数, 从形式上可以简单分为:
diff --git a/专栏/JVM 核心技术 32 讲(完)/09 JDK 内置命令行工具:工欲善其事,必先利其器.md.html b/专栏/JVM 核心技术 32 讲(完)/09 JDK 内置命令行工具:工欲善其事,必先利其器.md.html
index ef15ae3e..863829fe 100644
--- a/专栏/JVM 核心技术 32 讲(完)/09 JDK 内置命令行工具:工欲善其事,必先利其器.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/09 JDK 内置命令行工具:工欲善其事,必先利其器.md.html
@@ -791,7 +791,7 @@ jinfo -flags 36663
不加参数过滤,则打印所有信息。
jinfo 在 Windows 上比较稳定。在 macOS 上需要 root 权限,或是需要在提示下输入当前用户的密码。
-
+
然后就可以看到如下信息:
jinfo 36663
Attaching to process ID 36663, please wait...
diff --git a/专栏/JVM 核心技术 32 讲(完)/10 JDK 内置图形界面工具:海阔凭鱼跃,天高任鸟飞.md.html b/专栏/JVM 核心技术 32 讲(完)/10 JDK 内置图形界面工具:海阔凭鱼跃,天高任鸟飞.md.html
index 858f9065..d555741e 100644
--- a/专栏/JVM 核心技术 32 讲(完)/10 JDK 内置图形界面工具:海阔凭鱼跃,天高任鸟飞.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/10 JDK 内置图形界面工具:海阔凭鱼跃,天高任鸟飞.md.html
@@ -193,9 +193,9 @@ function hide_canvas() {
JConsole
JConsole,顾名思义,就是“Java 控制台”,在这里,我们可以从多个维度和时间范围去监控一个 Java 进程的内外部指标。进而通过这些指标数据来分析判断 JVM 的状态,为我们的调优提供依据。
在 Windows 或 macOS 的运行窗口或命令行输入 jconsole,然后回车,可以看到如下界面:
-
+
本地进程列表列出了本机的所有 Java 进程(远程进程我们在 JMX 课程进行讲解),选择一个要连接的 Java 进程,点击连接,然后可以看到如下界面:
-
+
注意,点击右上角的绿色连接图标,即可连接或断开这个 Java 进程。
上图中显示了总共 6 个标签页,每个标签页对应一个监控面板,分别为:
@@ -221,10 +221,10 @@ function hide_canvas() {
当我们想关注最近 1 小时或者 1 分钟的数据,就可以选择对应的档。旁边的 3 个标签页(内存、线程、类),也都支持选择时间范围。
内存
-
+
内存监控,是 JConsole 中最常用的面板。内存面板的主区域中展示了内存占用量随时间变化的图像,可以通过这个图表,非常直观地判断内存的使用量和变化趋势。
同时在左上方,我们可以在图表后面的下拉框中选择不同的内存区:
-
+
本例中,我们使用的是 JDK 8,默认不配置 GC 启动参数。关于 GC 参数的详情请关注后面的 GC 内容,可以看到,这个 JVM 提供的内存图表包括:
- 堆内存使用量,主要包括老年代(内存池“PS Old Gen”)、新生代(“PS Eden Space”)、存活区(“PS Survivor Space”);
@@ -240,19 +240,19 @@ function hide_canvas() {
打开一段时间以后,我们可以看到内存使用量出现了直线下降(见下图),这表明刚经过了一次 GC,也就是 JVM 执行了垃圾回收。
其实我们可以注意到,内存面板其实相当于是 jstat -gc
或 jstat -gcutil
命令的图形化展示,它们的本质是一样的,都是通过采样的方式拿到JVM各个内存池的数据进行统计,并展示出来。
其实图形界面存在一个问题,如果 GC 特别频繁,每秒钟执行了很多次 GC,实际上图表方式就很难反应出每一次的变化信息。
-
+
线程
线程面板展示了线程数变化信息,以及监测到的线程列表。
- 我们可以常根据名称直接查看线程的状态(运行还是等待中)和调用栈(正在执行什么操作)。
- 特别地,我们还可以直接点击“检测死锁”按钮来检测死锁,如果没有死锁则会提示“未检测到死锁”。
-
+
类
类监控面板,可以直接看到 JVM 加载和卸载的类数量汇总信息。
-
+
VM 概要
-
+
VM 概要的数据也很有用,可以看到总共有五个部分:
- 第一部分是虚拟机的信息;
@@ -268,47 +268,47 @@ function hide_canvas() {
$ jvisualvm
JVisualVM 启动后的界面大致如下:
-
+
在其中可以看到本地的 JVM 实例。
通过双击本地进程或者右键打开,就可以连接到某个 JVM,此时显示的基本信息如下图所示:
-
+
可以看到,在概述页签中有 PID、启动参数、系统属性等信息。
切换到监视页签:
-
+
在监视页签中可以看到 JVM 整体的运行情况。比如 CPU、堆内存、类、线程等信息。还可以执行一些操作,比如“强制执行垃圾回收”、“堆 Dump”等。
"线程"页签则展示了 JVM 中的线程列表。再一次看出在程序中对线程(池)命名的好处。
-
+
与 JConsole 只能看线程的调用栈和状态信息相比,这里可以直观看到所有线程的状态颜色和运行时间,从而帮助我们分析过去一段时间哪些线程使用了较多的 CPU 资源。
抽样器与 Profiler
JVisualVM 默认情况下,比 JConsole 多了抽样器和 Profiler 这两个工具。
例如抽样,可以配合我们在性能压测的时候,看压测过程中,各个线程发生了什么、或者是分配了多少内存,每个类直接占用了多少内存等等。
-
-
+
+
使用 Profiler 时,需要先校准分析器。
-
+
然后可以像抽样器一样使用了。
-
-
+
+
从这个面板直接能看到热点方法与执行时间、占用内存以及比例,还可以设置过滤条件。
同时我们可以直接把当前的数据和分析,作为快照保存,或者将数据导出,以后可以继续加载和分析。
插件
JVisualVM 最强大的地方在于插件。
JDK 8 需要安装较高版本(如 Java SE 8u211),才能从官方服务器安装/更新 JVisualVM 的插件(否则只能凭运气找对应的历史版本)。
-
+
JVisualVM 安装 MBeans 插件的步骤:
通过工具(T)–插件(G)–可用插件–勾选具体的插件–安装–下一步–等待安装完成。
-
+
最常用的插件是 VisualGC 和 MBeans。
如果看不到可用插件,请安装最新版本,或者下载插件到本地安装。 先排除网络问题,或者检查更新,重新启动试试。
-
+
安装完成后,重新连接某个 JVM,即可看到新安装的插件。
切换到 VisualGC 页签:
-
+
在其中可以看到各个内存池的使用情况,以及类加载时间、GC 总次数、GC 总耗时等信息。比起命令行工具要简单得多。
切换到 MBeans 标签:
-
+
一般人可能不怎么关注 MBean,但 MBean 对于理解 GC的原理倒是挺有用的。
主要看 java.lang 包下面的 MBean。比如内存池或者垃圾收集器等。
从图中可以看到,Metaspace 内存池的 Type 是 NON_HEAP。
@@ -325,7 +325,7 @@ function hide_canvas() {
根据经验,这些信息对分析GC性能来说,不能得出什么结论。只有编写程序,获取GC相关的 JMX 信息来进行统计和分析。
下面看怎么执行远程实时监控。
-
+
如上图所示,从文件菜单中,我们可以选择“添加远程主机”,以及“添加 JMX 连接”。
比如“添加 JMX 连接”,填上 IP 和端口号之后,勾选“不要求 SSL 连接”,点击“确定”按钮即可。
关于目标 JVM 怎么启动 JMX 支持,请参考后面的 JMX 小节。
@@ -333,29 +333,29 @@ function hide_canvas() {
JMC 图形界面客户端
JMC 和 JVisualVM 功能类似,因为 JMC 的前身是 JRMC,JRMC 是 BEA 公司的 JRockit JDK 自带的分析工具,被 Oracle 收购以后,整合成了 JMC 工具。Oracle 试图用 JMC 来取代 JVisualVM,在商业环境使用 JFR 需要付费获取授权。
在命令行输入 jmc 后,启动后的界面如下:
-
+
点击相关的按钮或者菜单即可启用对应的功能,JMC 提供的功能和 JVisualVM 差不多。
飞行记录器
除了 JConsole 和 JVisualVM 的常见功能(包括 JMX 和插件)以外,JMC 最大的亮点是飞行记录器。
在进程上点击“飞行记录器”以后,第一次使用时需要确认一下取消锁定商业功能的选项:
-
+
然后就可以看到飞行记录向导:
-
+
点击下一步可以看到更多的配置:
-
+
这里我们可以把堆内存分析、类加载两个选型也勾选上。点击完成,等待一分钟,就可以看到飞行记录。
-
+
概况里可以使用仪表盘方式查看堆内存、CPU 占用率、GC 暂停时间等数据。
内存面板则可以看到 GC 的详细分析:
-
-
+
+
代码面板则可以看到热点方法的执行情况:
-
+
线程面板则可以看到线程的锁争用情况等:
-
+
跟 JConsole 和 JVisualVM 相比,这里已经有了很多分析数据了,内存分配速率、GC 的平均时间等等。
最后,我们也可以通过保存飞行记录为 jfr 文件,以后随时查看和分析,或者发给其他人员来进行分析。
-
+
JStatD 服务端工具
JStatD 是一款强大的服务端支持工具,用于配合远程监控,所以放到图形界面这一篇介绍。
但因为涉及暴露一些服务器信息,所以需要配置安全策略文件。
diff --git a/专栏/JVM 核心技术 32 讲(完)/11 JDWP 简介:十步杀一人,千里不留行.md.html b/专栏/JVM 核心技术 32 讲(完)/11 JDWP 简介:十步杀一人,千里不留行.md.html
index fb831442..15cf8386 100644
--- a/专栏/JVM 核心技术 32 讲(完)/11 JDWP 简介:十步杀一人,千里不留行.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/11 JDWP 简介:十步杀一人,千里不留行.md.html
@@ -268,11 +268,11 @@ jdb -attach 8888
可以看到使用 JDB 调试的话非常麻烦,所以我们一般还是在开发工具 IDE(IDEA、Eclipse)里调试代码。
开发工具 IDEA 中使用远程调试
下面介绍 IDEA 中怎样使用远程调试。与常规的 Debug 配置类似,进入编辑:
-
+
添加 Remote(不是 Tomcat 下面的那个 Remote Server):
-
+
然后配置端口号,比如 8888。
-
+
然后点击应用(Apply)按钮。
点击 Debug 的那个按钮即可启动远程调试,连上之后就和调试本地程序一样了。当然,记得加断点或者条件断点。
注意:远程调试时,需要保证服务端 JVM 中运行的代码和本地完全一致,否则可能会有莫名其妙的问题。
diff --git a/专栏/JVM 核心技术 32 讲(完)/12 JMX 与相关工具:山高月小,水落石出.md.html b/专栏/JVM 核心技术 32 讲(完)/12 JMX 与相关工具:山高月小,水落石出.md.html
index e46c7364..214e5b2d 100644
--- a/专栏/JVM 核心技术 32 讲(完)/12 JMX 与相关工具:山高月小,水落石出.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/12 JMX 与相关工具:山高月小,水落石出.md.html
@@ -302,11 +302,11 @@ public class MXBeanTest {
以 JConsole 为例,我们看一下,连接到了远程 JVM 以后,在最后一个面板即可看到 MBean 信息。
例如,我们可以查看 JVM 的一些信息:
-
+
也可以直接调用方法,例如查看 VM 参数:
-
+
如果启动的进程是 Tomcat 或者是 Spring Boot 启动的嵌入式 Tomcat,那么我们还可以看到很多 Tomcat 的信息:
-
+
JMX 的 MBean 创建和远程访问
前面讲了在同一个 JVM 里获取 MBean,现在我们再来写一个更完整的例子:创建一个 MBean,然后远程访问它。
先定义一个 UserMBean 接口(必须以 MBean 作为后缀):
@@ -386,11 +386,11 @@ public class UserJmxServer {
打开 JConsole,在远程输入:
service:jmx:rmi:///jndi/rmi://localhost:1099/user
-
+
查看 User 的属性:
-
+
直接修改 UserName 的值:
-
+
使用 JMX 远程访问 MBean
我们先使用 JMXUrl 来创建一个 MBeanServerConnection,连接到 MBeanServer,然后就可以通过 ObjectName,也可以看做是 MBean 的地址,像反射一样去拿服务器端 MBean 里的属性,或者调用 MBean 的方法。示例如下:
package io.github.kimmking.jvmstudy.jmx;
diff --git a/专栏/JVM 核心技术 32 讲(完)/13 常见的 GC 算法(GC 的背景与原理).md.html b/专栏/JVM 核心技术 32 讲(完)/13 常见的 GC 算法(GC 的背景与原理).md.html
index ffc3c699..a96f24cb 100644
--- a/专栏/JVM 核心技术 32 讲(完)/13 常见的 GC 算法(GC 的背景与原理).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/13 常见的 GC 算法(GC 的背景与原理).md.html
@@ -188,7 +188,7 @@ function hide_canvas() {
13 常见的 GC 算法(GC 的背景与原理)
GC 是英文词汇 Garbage Collection 的缩写,中文一般直译为“垃圾收集”。当然有时候为了让文字更流畅,也会说“垃圾回收”。一般认为“垃圾回收”和“垃圾收集”是同样的意思。此外,GC 也有“垃圾收集器”的意思,英文表述为 Garbage Collector。本节我们就来详细讲解常用的 GC 算法。
-
+
闲话 GC
假如我们做生意,需要仓库来存放物资。如果所有仓库都需要公司自建,那成本就太高了,一般人玩不转,而且效率也不高,成本控制不好就很难赚到钱。所以现代社会就有了一种共享精神和租赁意识,大幅度提高了整个社会的资源利用率。
比如说一条供应链,A 公司转给 B 公司,B 公司转给 C 公司,那么每个公司自己的加工车间和私有仓库,就类似于线程空间,工厂内部会有相应的流水线。因为每个公司/业务员的精力有限,这个私有空间不可能无限大。
@@ -213,9 +213,9 @@ function hide_canvas() {
GC 垃圾收集器就像这个仓库部门,负责分配内存,负责追踪这些内存的使用情况,并在适当的时候进行释放。
于是仓库部门就建立起来,专门管理这些仓库。怎么管理呢?
先是想了一个办法,叫做“引用计数法”。有人办业务需要来申请仓库,就找个计数器记下次数 1,后续哪个业务用到呢都需要登记一下,继续加 1,每个业务办完计数器就减一。如果一个仓库(对象使用的内存)的计数到降了 0,就说明可以人使用这个仓库了,我们就可以随时在方便的时候去归还/释放这个仓库。(需要注意:一般不是一个仓库到 0 了就立即释放,出于效率考虑,系统总是会等一批仓库一起处理,这样更加高效。)
-
+
但是呢,如果业务变得更复杂。仓库之间需要协同工作,有了依赖关系之后。
-
+
这时候单纯的引用计数就会出问题,循环依赖的仓库/对象没办法回收,就像数据库的死锁一样让人讨厌,你没法让它自己变成 0。
这种情况在计算机中叫做“内存泄漏”,该释放的没释放,该回收的没回收。
如果依赖关系更复杂,计算机的内存资源很可能用满,或者说不够用,内存不够用则称为“内存溢出”。
@@ -253,7 +253,7 @@ function hide_canvas() {
- 在创建新对象时,JVM 在连续的块中分配内存。如果碎片问题很严重,直至没有空闲片段能存放下新创建的对象,就会发生内存分配错误(allocation error)。
要避免这类问题,JVM 必须确保碎片问题不失控。因此在垃圾收集过程中,不仅仅是标记和清除,还需要执行“内存碎片整理”过程。这个过程让所有可达对象(reachable objects)依次排列,以消除(或减少)碎片。就像是我们把棋盘上剩余的棋子都聚集到一起,留出来足够大的空余区域。示意图如下所示:
-
+
说明:
JVM 中的引用是一个抽象的概念,如果 GC 移动某个对象,就会修改(栈和堆中)所有指向该对象的引用。
移动/拷贝/提升/压缩一般来说是一个 STW 的过程,所以修改对象引用是一个安全的行为。但要更新所有的引用,可能会影响应用程序的性能。
@@ -264,13 +264,13 @@ function hide_canvas() {
- 还有一部分不会立即无用,但也不会持续太长时间。
这些观测形成了 弱代假设(Weak Generational Hypothesis),即我们可以根据对象的不同特点,把对象进行分类。基于这一假设,VM 中的内存被分为年轻代(Young Generation)和老年代(Old Generation)。老年代有时候也称为年老区(Tenured)。
-
+
拆分为这样两个可清理的单独区域,我们就可以根据对象的不同特点,允许采用不同的算法来大幅提高 GC 的性能。
天下没有免费的午餐,所以这种方法也不是没有任何问题。例如,在不同分代中的对象可能会互相引用,在收集某一个分代时就会成为“事实上的”GC root。
当然,要着重强调的是,分代假设并不适用于所有程序。因为分代 GC 算法专门针对“要么死得快”、“否则活得长”这类特征的对象来进行优化,此时 JVM 管理那种存活时间半长不长的对象就显得非常尴尬了。
内存池划分
堆内存中的内存池划分也是类似的,不太容易理解的地方在于各个内存池中的垃圾收集是如何运行的。请注意:不同的 GC 算法在实现细节上可能会有所不同,但和本章所介绍的相关概念都是一致的。
-
+
新生代(Eden Space)
Eden Space,也叫伊甸区,是内存中的一个区域,用来分配新创建的对象。通常会有多个线程同时创建多个对象,所以 Eden 区被划分为多个 线程本地分配缓冲区(Thread Local Allocation Buffer,简称 TLAB)。通过这种缓冲区划分,大部分对象直接由 JVM 在对应线程的 TLAB 中分配,避免与其他线程的同步操作。
如果 TLAB 中没有足够的内存空间,就会在共享 Eden 区(shared Eden space)之中分配。如果共享 Eden 区也没有足够的空间,就会触发一次 年轻代 GC 来释放内存空间。如果 GC 之后 Eden 区依然没有足够的空闲内存区域,则对象就会被分配到老年代空间(Old Generation)。
@@ -283,7 +283,7 @@ function hide_canvas() {
存活区(Survivor Spaces)
Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。
空的那个存活区用于在下一次年轻代 GC 时存放收集的对象。年轻代中所有的存活对象(包括 Eden 区和非空的那个“from”存活区)都会被复制到 ”to“ 存活区。GC 过程完成后,“to”区有对象,而“from”区里没有对象。两者的角色进行正好切换,from 变成 to,to 变成 from。
-
+
存活的对象会在两个存活区之间复制多次,直到某些对象的存活 时间达到一定的阀值。分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间。
这类“年老”的对象因此被提升(promoted)到老年代。提升的时候,存活区的对象不再是复制到另一个存活区,而是迁移到老年代,并在老年代一直驻留,直到变为不可达对象。
为了确定一个对象是否“足够老”,可以被提升(Promotion)到老年代,GC 模块跟踪记录每个存活区对象存活的次数。每次分代 GC 完成后,存活对象的年龄就会增长。当年龄超过提升阈值(tenuring threshold),就会被提升到老年代区域。
@@ -318,7 +318,7 @@ function hide_canvas() {
第一步,记录(census)所有的存活对象,在垃圾收集中有一个叫做 标记(Marking) 的过程专门干这件事。
标记可达对象(Marking Reachable Objects)
现代 JVM 中所有的 GC 算法,第一步都是找出所有存活的对象。下面的示意图对此做了最好的诠释:
-
+
首先,有一些特定的对象被指定为 Garbage Collection Roots(GC 根元素)。包括:
- 当前正在执行的方法里的局部变量和输入参数
@@ -336,15 +336,15 @@ function hide_canvas() {
清除(Sweeping)
**Mark and Sweep(标记—清除)**算法的概念非常简单:直接忽略所有的垃圾。也就是说在标记阶段完成后,所有不可达对象占用的内存空间,都被认为是空闲的,因此可以用来分配新对象。
这种算法需要使用空闲表(free-list),来记录所有的空闲区域,以及每个区域的大小。维护空闲表增加了对象分配时的开销。此外还存在另一个弱点 —— 明明还有很多空闲内存,却可能没有一个区域的大小能够存放需要分配的对象,从而导致分配失败(在 Java 中就是 OutOfMemoryError)。
-
+
整理(Compacting)
标记—清除—整理算法(Mark-Sweep-Compact),将所有被标记的对象(存活对象),迁移到内存空间的起始处,消除了“标记—清除算法”的缺点。
相应的缺点就是 GC 暂停时间会增加,因为需要将所有对象复制到另一个地方,然后修改指向这些对象的引用。
此算法的优势也很明显,碎片整理之后,分配新对象就很简单,只需要通过指针碰撞(pointer bumping)即可。使用这种算法,内存空间剩余的容量一直是清楚的,不会再导致内存碎片问题。
-
+
复制(Copying)
**标记—复制算法(Mark and Copy)**和“标记—整理算法”(Mark and Compact)十分相似:两者都会移动所有存活的对象。区别在于,“标记—复制算法”是将内存移动到另外一个空间:存活区。“标记—复制方法”的优点在于:标记和复制可以同时进行。缺点则是需要一个额外的内存区间,来存放所有的存活对象。
-
+
下一小节,我们将介绍 JVM 中具体的 GC 算法和实现。
diff --git a/专栏/JVM 核心技术 32 讲(完)/14 常见的 GC 算法(ParallelCMSG1).md.html b/专栏/JVM 核心技术 32 讲(完)/14 常见的 GC 算法(ParallelCMSG1).md.html
index 19d903c3..df13ed5a 100644
--- a/专栏/JVM 核心技术 32 讲(完)/14 常见的 GC 算法(ParallelCMSG1).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/14 常见的 GC 算法(ParallelCMSG1).md.html
@@ -234,17 +234,17 @@ function hide_canvas() {
为什么 CMS 不管年轻代了呢?前面不是刚刚完成 minor GC 嘛,再去收集年轻代估计也没什么效果。
看看示意图:
-
+
阶段 2:Concurrent Mark(并发标记)
在此阶段,CMS GC 遍历老年代,标记所有的存活对象,从前一阶段“Initial Mark”找到的根对象开始算起。“并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。请注意,并非所有老年代中存活的对象都在此阶段被标记,因为在标记过程中对象的引用关系还在发生变化。
-
+
在上面的示意图中,“当前处理的对象”的一个引用就被应用线程给断开了,即这个部分的对象关系发生了变化(下面会讲如何处理)。
阶段 3:Concurrent Preclean(并发预清理)
此阶段同样是与应用线程并发执行的,不需要停止应用线程。
因为前一阶段“并发标记”与程序并发运行,可能有一些引用关系已经发生了改变。如果在并发标记过程中引用关系发生了变化,JVM 会通过“Card(卡片)”的方式将发生了改变的区域标记为“脏”区,这就是所谓的“卡片标记(Card Marking)”。
-
+
在预清理阶段,这些脏对象会被统计出来,它们所引用的对象也会被标记。此阶段完成后,用以标记的 card 也就会被清空。
-
+
此外,本阶段也会进行一些必要的细节处理,还会为 Final Remark 阶段做一些准备工作。
阶段 4:Concurrent Abortable Preclean(可取消的并发预清理)
此阶段也不停止应用线程。本阶段尝试在 STW 的 Final Remark 阶段 之前尽可能地多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件(如迭代次数,有用工作量,消耗的系统时间等等)。
@@ -256,7 +256,7 @@ function hide_canvas() {
在 5 个标记阶段完成之后,老年代中所有的存活对象都被标记了,然后 GC 将清除所有不使用的对象来回收老年代空间。
阶段 6:Concurrent Sweep(并发清除)
此阶段与应用程序并发执行,不需要 STW 停顿。JVM 在此阶段删除不再使用的对象,并回收它们占用的内存空间。
-
+
阶段 7:Concurrent Reset(并发重置)
此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。
总之,CMS 垃圾收集器在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程。当然,CMS 也有一些缺点,其中最大的问题就是老年代内存碎片问题(因为不压缩),在某些情况下 GC 会造成不可预测的暂停时间,特别是堆内存较大的情况下。
@@ -269,9 +269,9 @@ function hide_canvas() {
G1 GC 的特点
为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。
首先,堆不再分成年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的 小块堆区域(smaller heap regions)。每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代,如下图所示:
-
+
这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块,见下图带对号的部分:
-
+
G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:垃圾最多的小块会被优先收集。这也是 G1 名称的由来。
通过以下选项来指定 G1 垃圾收集器:
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
@@ -334,13 +334,13 @@ function hide_canvas() {
Remembered Sets(历史记忆集)用来支持不同的小堆块进行独立回收。
例如,在回收小堆块 A、B、C 时,我们必须要知道是否有从 D 区或者 E 区指向其中的引用,以确定它们的存活性. 但是遍历整个堆需要相当长的时间,这就违背了增量收集的初衷,因此必须采取某种优化手段。类似于其他 GC 算法中的“卡片”方式来支持年轻代的垃圾收集,G1 中使用的则是 Remembered Sets。
如下图所示,每个小堆块都有一个 Remembered Set,列出了从外部指向本块的所有引用。这些引用将被视为附加的 GC 根。注意,在并发标记过程中,老年代中被确定为垃圾的对象会被忽略,即使有外部引用指向它们:因为在这种情况下引用者也是垃圾(如垃圾对象之间的引用或者循环引用)。
-
+
接下来的行为,和其他垃圾收集器一样:多个 GC 线程并行地找出哪些是存活对象,确定哪些是垃圾:
-
+
最后,存活对象被转移到存活区(survivor regions),在必要时会创建新的小堆块。现在,空的小堆块被释放,可用于存放新的对象了。
-
+
GC 选择的经验总结
-
+
通过本节内容的学习,你应该对 G1 垃圾收集器有了一定了解。当然,为了简洁我们省略了很多实现细节,例如如何处理“巨无霸对象(humongous objects)”。
综合来看,G1 是 JDK11 之前 HotSpot JVM 中最先进的准产品级(production-ready) 垃圾收集器。重要的是,HotSpot 工程师的主要精力都放在不断改进 G1 上面。在更新的 JDK 版本中,将会带来更多强大的功能和优化。
可以看到,G1 作为 CMS 的代替者出现,解决了 CMS 中的各种疑难问题,包括暂停时间的可预测性,并终结了堆内存的碎片化。对单业务延迟非常敏感的系统来说,如果 CPU 资源不受限制,那么 G1 可以说是 HotSpot 中最好的选择,特别是在最新版本的 JVM 中。当然这种降低延迟的优化也不是没有代价的:由于额外的写屏障和守护线程,G1 的开销会更大。如果系统属于吞吐量优先型的,又或者 CPU 持续占用 100%,而又不在乎单次 GC 的暂停时间,那么 CMS 是更好的选择。
diff --git a/专栏/JVM 核心技术 32 讲(完)/15 Java11 ZGC 和 Java12 Shenandoah 介绍:苟日新、日日新、又日新.md.html b/专栏/JVM 核心技术 32 讲(完)/15 Java11 ZGC 和 Java12 Shenandoah 介绍:苟日新、日日新、又日新.md.html
index 4de2157d..71fae402 100644
--- a/专栏/JVM 核心技术 32 讲(完)/15 Java11 ZGC 和 Java12 Shenandoah 介绍:苟日新、日日新、又日新.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/15 Java11 ZGC 和 Java12 Shenandoah 介绍:苟日新、日日新、又日新.md.html
@@ -257,12 +257,12 @@ Option -XX:+UseZGC not supported
官方介绍说停顿时间在 10ms 以下,其实这个数据是非常保守的值。
根据基准测试(见参考材料里的 PDF 链接),在 128G 的大堆下,最大停顿时间只有 1.68ms,远远低于 10ms;和 G1 算法比起来相比,改进非常明显。
请看下图:
-
+
左边的图是线性坐标,右边是指数坐标。
可以看到,不管是平均值、95 线、99 线还是最大暂停时间,ZGC 都优胜于 G1 和并行 GC 算法。
根据我们在生产环境的监控数据来看(16G~64G 堆内存),每次暂停都不超过 3ms。
比如下图是一个低延迟网关系统的监控信息,几十 GB 的堆内存环境中,ZGC 表现得毫无压力,暂停时间非常稳定。
-
+
像 G1 和 ZGC 之类的现代 GC 算法,只要空闲的堆内存足够多,基本上不触发 FullGC。
所以很多时候,只要条件允许,加内存才是最有效的解决办法。
既然低延迟是 ZGC 的核心看点,而 JVM 低延迟的关键是 GC 暂停时间,那么我们来看看有哪些方法可以减少 GC 暂停时间:
@@ -284,7 +284,7 @@ Option -XX:+UseZGC not supported
ZGC 的原理
ZCG 的 GC 周期如图所示:
-
+
每个 GC 周期分为 6 个小阶段:
- 暂停—标记开始阶段:第一次暂停,标记根对象集合指向的对象;
@@ -305,11 +305,11 @@ Option -XX:+UseZGC not supported
ZGC 使用着色指针来标记所处的 GC 阶段。
着色指针是从 64 位的指针中,挪用了几位出来标识表示 Marked0、Marked1、Remapped、Finalizable。所以不支持 32 位系统,也不支持指针压缩技术,堆内存的上限是 4TB。
从这些标记上就可以知道对象目前的状态,判断是不是可以执行清理压缩之类的操作。
-
+
读屏障
对于 GC 线程与用户线程并发执行时,业务线程修改对象的操作可能带来的不一致问题,ZGC 使用的是读屏障,这点与其他 GC 使用写屏障不同。
有读屏障在,就可以留待之后的其他阶段,根据指针颜色快速的处理。并且不是所有的读操作都需要屏障,例如下面只有第一种语句(加载指针时)需要读屏障,后面三种都不需要,又或者是操作原生类型的时候也不需要。
-
+
著名的 JVM 技术专家 RednaxelaFX 提到:ZGC 的 Load Value Barrier,与 Red Hat 的 Shenandoah 收集器使用的屏障不同,后者选择了 70 年代比较基础的 Brooks Pointer,而 ZGC 则是在古老的 Baker barrier 基础上增加了 self healing 特性。
可以把“读屏障”理解为一段代码,或者是一个指令,后面挂着对应的处理函数。
比如下面的代码:
@@ -321,7 +321,7 @@ Object b = obj.x;
着色指针和读屏障,相当于在内存管理和应用程序代码之间加了一个中间层,通过这个中间层就可以实现更多的功能。但是也可以看到算法本身有一定的开销,也带来了很多复杂性。
ZGC 的参数介绍
除了上面提到的 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
参数可以用来启用 ZGC 以外,ZGC 可用的参数见下表:
-
+
一些常用的参数介绍:
-XX:ZCollectionInterval
:固定时间间隔进行 GC,默认值为0。
@@ -364,7 +364,7 @@ Object b = obj.x;
Shenandoah 团队对外宣称 Shenandoah GC 的暂停时间与堆大小无关,无论是 200 MB 还是 200 GB 的堆内存,都可以保障具有很低的暂停时间(注意:并不像 ZGC 那样保证暂停时间在 10ms 以内)。
Shenandoah GC 原理介绍
Shenandoah GC 的原理,跟 ZGC 非常类似。
-
+
部分日志内容如下:
GC(3) Pause Init Mark 0.771ms
GC(3) Concurrent marking 76480M->77212M(102400M) 633.213ms
@@ -394,7 +394,7 @@ GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
需要提醒,并非只有 GC 停顿会导致应用程序响应时间变长。 除了GC长时间停顿会导致系统响应变慢,其他诸如 消息队列延迟、网络延迟、计算逻辑过于复杂、以及外部服务的延时,操作提供的调度程序抖动等都可能导致响应变慢。
使用 Shenandoah 时需要全面了解系统运行情况,综合分析系统响应时间。下图是官方给出的各种 GC 工作负载对比:
-
+
可以看到,相对于 CMS、G1、Parallel GC,Shenandoah 在系统负载增加的情况下,延迟时间稳定在非常低的水平,而其他几种 GC 都会迅速上升。
常用参数介绍
推荐几个配置或调试 Shenandoah 的 JVM 参数:
@@ -437,7 +437,7 @@ GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms
同时针对于内存分配失败时的策略,可以通过调节 ShenandoahPacing
和 ShenandoahDegeneratedGC
参数,对线程进行一定的调节控制。如果还是没有足够的内存,最坏的情况下可能会产生 Full GC,以使得系统有足够的内存不至于发生 OOM。
更多有关如何配置、调试 Shenandoah 的参数信息,请参阅 Shenandoah 官方 Wiki 页面。
各版本 JDK 对 Shenandoah 的集成情况
-
+
这张图展示了 Shenandoah GC 目前在各个 JDK 版本上的进展情况,可以看到 OpenJDK 12 和 13 上都可以用。
在 Red Hat Enterprise Linux、Fedora 系统中则可以在 JDK 8 和 JDK 11 版本上使用(肯定的,这两个 Linux 发行版都是 Red Hat 的,谁让这个 GC 也是 Red Hat 开发维护的呢)。
diff --git a/专栏/JVM 核心技术 32 讲(完)/16 Oracle GraalVM 介绍:会当凌绝顶、一览众山小.md.html b/专栏/JVM 核心技术 32 讲(完)/16 Oracle GraalVM 介绍:会当凌绝顶、一览众山小.md.html
index 2a675f29..c7787470 100644
--- a/专栏/JVM 核心技术 32 讲(完)/16 Oracle GraalVM 介绍:会当凌绝顶、一览众山小.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/16 Oracle GraalVM 介绍:会当凌绝顶、一览众山小.md.html
@@ -201,7 +201,7 @@ function hide_canvas() {
GraalVM 有什么特点
GraalVM 既可以独立运行,也可以在不同的部署场景中使用,比如在 OpenJDK 虚拟机环境、Node.js 环境,或者 Oracle、MySQL 数据库等环境中运行。下图来自 GraalVM 官网,展示了目前支持的平台技术。
-
+
GraalVM 支持大量的语言,包括:
- 基于 JVM 的语言(例如 Java、Scala、Groovy、Kotlin、Clojure 等);
@@ -230,9 +230,9 @@ function hide_canvas() {
- 占用内存更低
启动时间对比:
-
+
占用内存对比:
-
+
解决了哪些痛点
GraalVM 提供了一个全面的生态系统,消除编程语言之间的隔离,打通了不同语言之间的鸿沟,在共享的运行时中实现了互操作性,让我们可以进行混合式多语言编程。
用 Graal 执行的语言可以互相调用,允许使用来自其他语言的库,提供了语言的互操作性。同时结合了对编译器技术的最新研究,在高负载场景下 GraalVM 的性能比传统 JVM 要好得多。
@@ -306,7 +306,7 @@ function hide_canvas() {
从 GitHub 下载页面 中找到下载链接。
如下图所示:
-
+
这里区分操作系统(macOS/darwin、Linux、Windows)、CPU 架构(AArch64、AMD64(Intel/AMD))、以及 JDK 版本。 我们根据自己的系统选择对应的下载链接。
比如 macOS 系统的 JDK 11 版本,对应的下载文件为:
# GraalVM 主程序绿色安装包
diff --git a/专栏/JVM 核心技术 32 讲(完)/18 GC 日志解读与分析(实例分析上篇).md.html b/专栏/JVM 核心技术 32 讲(完)/18 GC 日志解读与分析(实例分析上篇).md.html
index 0369d831..50c2a345 100644
--- a/专栏/JVM 核心技术 32 讲(完)/18 GC 日志解读与分析(实例分析上篇).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/18 GC 日志解读与分析(实例分析上篇).md.html
@@ -276,7 +276,7 @@ CommandLine flags:
通过这么分析下来,同学们应该发现,我们关注的主要是两个数据:GC 暂停时间,以及 GC 之后的内存使用量/使用率。
此次 GC 事件的示意图如下所示:
-
+
Full GC 日志分析
分析完第一次 GC 事件之后,我们心中应该有个大体的模式了。一起来看看另一次 GC 事件的日志:
2019-12-15T15:18:37.081-0800: 0.908:
@@ -306,7 +306,7 @@ CommandLine flags:
FullGC,我们主要关注 GC 之后内存使用量是否下降,其次关注暂停时间。简单估算,GC 后老年代使用量为 220MB 左右,耗时 50ms。如果内存扩大 10 倍,GC 后老年代内存使用量也扩大 10 倍,那耗时可能就是 500ms 甚至更高,就会系统有很明显的影响了。这也是我们说串行 GC 性能弱的一个原因,服务端一般是不会采用串行 GC 的。
此次 GC 事件的内存变化情况,可以表示为下面的示意图:
-
+
年轻代看起来数据几乎没变化,怎么办?因为上下文其实还有其他的 GC 日志记录,我们照着这个格式去解读即可。
Parallel GC 日志解读
并行垃圾收集器对年轻代使用“标记—复制(mark-copy)”算法,对老年代使用“标记—清除—整理(mark-sweep-compact)”算法。
@@ -383,7 +383,7 @@ demo.jvm0204.GCLogAnalysis
年轻代 GC,我们可以关注暂停时间,以及 GC 后的内存使用率是否正常,但不用特别关注 GC 前的使用量,而且只要业务在运行,年轻代的对象分配就少不了,回收量也就不会少。
此次 GC 的内存变化示意图为:
-
+
Full GC 日志分析
前面介绍了并行 GC 清理年轻代的 GC 日志,下面来看看清理整个堆内存的 GC 日志:
2019-12-18T00:37:47.486-0800: 0.713:
@@ -412,7 +412,7 @@ demo.jvm0204.GCLogAnalysis
Full GC 时我们更关注老年代的使用量有没有下降,以及下降了多少。如果 FullGC 之后内存不怎么下降,使用率还很高,那就说明系统有问题了。
此次 GC 的内存变化示意图为:
-
+
细心的同学可能会发现,此次 FullGC 事件和前一次 MinorGC 事件是紧挨着的:0.690+0.02secs~0.713。因为 Minor GC 之后老年代使用量达到了 93%,所以接着就触发了 Full GC。
本节到此就结束了,下节我们接着分析 CMS GC 日志。
diff --git a/专栏/JVM 核心技术 32 讲(完)/19 GC 日志解读与分析(实例分析中篇).md.html b/专栏/JVM 核心技术 32 讲(完)/19 GC 日志解读与分析(实例分析中篇).md.html
index 02bc3b5d..1c47708e 100644
--- a/专栏/JVM 核心技术 32 讲(完)/19 GC 日志解读与分析(实例分析中篇).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/19 GC 日志解读与分析(实例分析中篇).md.html
@@ -288,7 +288,7 @@ CommandLine flags:
GC 之后呢?年轻代使用量为 17311K ~= 17%,下降了 119107K。堆内存使用量为 360181K ~= 71%,只下降了 82197K。两个下降值相减,就是年轻代提升到老年代的内存量:119107-82197=36910K。
那么老年代空间有多大?老年代使用量是多少?正在阅读的同学,请开动脑筋,用这些数字算一下。
此次 GC 的内存变化示意图为:
-
+
哇塞,这个数字不得了,老年代使用量 98% 了,非常高了。后面紧跟着就是一条 Full GC 的日志,请接着往下看。
Full GC 日志分析
实际上这次截取的年轻代 GC 日志和 FullGC 日志是紧连着的,我们从间隔时间也能大致看出来,1.067 + 0.02secs ~ 1.091
。
@@ -484,7 +484,7 @@ CommandLine flags:
参照前面年轻代 GC 日志的分析方法,我们推算出来,上面的 CMS Full GC 之后,老年代的使用量应该是:445134K-153242K=291892K,老年代的总容量 506816K-157248K=349568K,所以 Full GC 之后老年代的使用量占比是 291892K/349568K=83%。
这个占比不低。说明什么问题呢? 一般来说就是分配的内存小了,毕竟我们才指定了 512MB 的最大堆内存。
按照惯例,来一张 GC 前后的内存使用情况示意图:
-
+
总之,CMS 垃圾收集器在减少停顿时间上做了很多给力的工作,很大一部分 GC 线程是与应用线程并发运行的,不需要暂停应用线程,这样就可以在一般情况下每次暂停的时候较少。当然,CMS 也有一些缺点,其中最大的问题就是老年代的内存碎片问题,在某些情况下 GC 会有不可预测的暂停时间,特别是堆内存较大的情况下。
透露一个学习 CMS 的诀窍:参考上面各个阶段的示意图,请同学们自己画一遍。
diff --git a/专栏/JVM 核心技术 32 讲(完)/20 GC 日志解读与分析(实例分析下篇).md.html b/专栏/JVM 核心技术 32 讲(完)/20 GC 日志解读与分析(实例分析下篇).md.html
index cd44b0d1..aed7790c 100644
--- a/专栏/JVM 核心技术 32 讲(完)/20 GC 日志解读与分析(实例分析下篇).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/20 GC 日志解读与分析(实例分析下篇).md.html
@@ -347,7 +347,7 @@ Heap
[Free CSet: 0.0 ms]
:将回收集中被释放的小堆归还所消耗的时间,以便他们能用来分配新的对象。
此次 Young GC 对应的示意图如下所示:
-
+
Concurrent Marking(并发标记)
当堆内存的总体使用比例达到一定数值时,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。
阶段 1:Initial Mark(初始标记)
@@ -410,7 +410,7 @@ Heap
标记周期一般只在碰到 region 中一个存活对象都没有的时候,才会顺手处理一把,大多数情况下都不释放内存。
示意图如下所示:
-
+
Evacuation Pause(mixed)(转移暂停:混合模式)
并发标记完成之后,G1 将执行一次混合收集(mixed collection),不只清理年轻代,还将一部分老年代区域也加入到 collection set 中。
混合模式的转移暂停(Evacuation Pause)不一定紧跟并发标记阶段。
@@ -462,9 +462,9 @@ Heap
因为我们的堆内存空间很小,存活对象的数量也不多,所以这里看到的 Full GC 暂停时间很短。
此次 Full GC 的示意图如下所示:
-
+
在堆内存较大的情况下(8G+),如果 G1 发生了 Full GC,暂停时间可能会退化,达到几十秒甚至更多。如下面这张图片所示:
-
+
从其中的 OldGen 部分可以看到,118 次 Full GC 消耗了 31 分钟,平均每次达到 20 秒,按图像比例可粗略得知,吞吐率不足 30%。
这张图片所表示的场景是在压测 Flink 按时间窗口进行聚合计算时发生的,主要原因是对象太多,堆内存空间不足而导致的,修改对象类型为原生数据类型之后问题得到缓解,加大堆内存空间,满足批处理/流计算的需求之后 GC 问题不再复现。
发生持续时间很长的 Full GC 暂停时,就需要我们进行排查和分析,确定是否需要修改 GC 配置,或者增加内存,还是需要修改程序的业务逻辑。关于 G1 的调优,我们在后面的调优部分再进行介绍。
diff --git a/专栏/JVM 核心技术 32 讲(完)/22 JVM 的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器.md.html b/专栏/JVM 核心技术 32 讲(完)/22 JVM 的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器.md.html
index ab4cef52..c92314df 100644
--- a/专栏/JVM 核心技术 32 讲(完)/22 JVM 的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/22 JVM 的线程堆栈等数据分析:操千曲而后晓声、观千剑而后识器.md.html
@@ -323,7 +323,7 @@ java.lang.InterruptedException
在 Java 线程启动时会创建底层线程(native Thread),在任务执行完成后会自动回收。
JVM 中所有线程都交给操作系统来负责调度,以将线程分配到可用的 CPU 上执行。
根据对 Hotspot 线程模型的理解,我们制作了下面这下示意图:
-
+
从图中可以看到,调用 Thread 对象的 start() 方法后,JVM 会在内部执行一系列的操作。
因为 Hotspot JVM 是使用 C++ 语言编写的,所以在 JVM 层面会有很多和线程相关的 C++ 对象。
@@ -635,9 +635,9 @@ Found 1 deadlock.
可以看到,这些工具会自动发现死锁,并将相关线程的调用栈打印出来。
使用可视化工具发现死锁
当然我们也可以使用前面介绍过的可视化工具 jconsole,示例如下:
-
+
也可以使用 JVisualVM:
-
+
各种工具导出的线程转储内容都差不多,参考前面的内容。
有没有自动分析线程的工具呢?请参考后面的章节《fastthread 相关的工具介绍》。
参考资料
diff --git a/专栏/JVM 核心技术 32 讲(完)/23 内存分析与相关工具上篇(内存布局与分析工具).md.html b/专栏/JVM 核心技术 32 讲(完)/23 内存分析与相关工具上篇(内存布局与分析工具).md.html
index a68c1b10..97f94061 100644
--- a/专栏/JVM 核心技术 32 讲(完)/23 内存分析与相关工具上篇(内存布局与分析工具).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/23 内存分析与相关工具上篇(内存布局与分析工具).md.html
@@ -195,7 +195,7 @@ function hide_canvas() {
请思考一个问题: 一个对象具有 100 个属性,与 100 个对象每个具有 1 个属性,哪个占用的内存空间更大?
为了回答这个问题,我们来看看 JVM 怎么表示一个对象:
-
+
说明
- alignment(外部对齐):比如 8 字节的数据类型 long,在内存中的起始地址必须是 8 字节的整数倍。
@@ -484,16 +484,16 @@ public class ClearRequestCacheFilter implements Filter{
双击打开 MemoryAnalyzer.exe,打开 MAT 分析工具,选择菜单 File –> Open File… 选择对应的 dump 文件。
选择 Leak Suspects Report 并确定,分析内存泄露方面的报告。
-
+
3. 内存报告
然后等待,分析完成后,汇总信息如下:
-
+
分析报告显示,占用内存最大的问题根源 1:
-
+
占用内存最大的问题根源 2:
-
+
占用内存最大的问题根源 3:
-
+
可以看到,总的内存占用才 2GB 左右。问题根源 1 和根源 2,每个占用 800MB,问题很可能就在他们身上。
当然,根源 3 也有一定的参考价值,表明这时候有很多 JDBC 操作。
查看问题根源 1,其说明信息如下:
@@ -532,12 +532,12 @@ http-nio-8086-exec-8
当然,还可以分析这个根源下持有的各个类的对象数量。
点击根源 1 说明信息下面的 Details »
链接,进入详情页面。
查看其中的 “Accumulated Objects in Dominator Tree”:
-
+
可以看到占用内存最多的是 2 个 ArrayList 对象。
鼠标左键点击第一个 ArrayList 对象,在弹出的菜单中选择 Show objects by class –> by outgoing references。
-
+
打开 class_references 标签页:
-
+
展开后发现 PO 类对象有 113 万个。加载的确实有点多,直接占用 170MB 内存(每个对象约 150 字节)。
事实上,这是将批处理任务,放到实时的请求中进行计算,导致的问题。
MAT 还提供了其他信息,都可以点开看看,也可以为我们诊断问题提供一些依据。
diff --git a/专栏/JVM 核心技术 32 讲(完)/24 内存分析与相关工具下篇(常见问题分析).md.html b/专栏/JVM 核心技术 32 讲(完)/24 内存分析与相关工具下篇(常见问题分析).md.html
index 08c1462e..48045aa5 100644
--- a/专栏/JVM 核心技术 32 讲(完)/24 内存分析与相关工具下篇(常见问题分析).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/24 内存分析与相关工具下篇(常见问题分析).md.html
@@ -188,7 +188,7 @@ function hide_canvas() {
24 内存分析与相关工具下篇(常见问题分析)
Java 程序的内存可以分为几个部分:堆(Heap space)、非堆(Non-Heap)、栈(Stack)等等,如下图所示:
-
+
最常见的 java.lang.OutOfMemoryError 可以归为以下类型。
OutOfMemoryError: Java heap space
JVM 限制了 Java 程序的最大内存使用量,由 JVM 的启动参数决定。
@@ -233,7 +233,7 @@ function hide_canvas() {
而“java.lang.OutOfMemoryError: GC overhead limit exceeded”这种错误发生的原因是:程序基本上耗尽了所有的可用内存,GC 也清理不了。
原因分析
JVM 抛出“java.lang.OutOfMemoryError: GC overhead limit exceeded”错误就是发出了这样的信号:执行垃圾收集的时间比例太大,有效的运算量太小。默认情况下,如果 GC 花费的时间超过 98%,并且 GC 回收的内存少于 2%,JVM 就会抛出这个错误。就是说,系统没法好好干活了,几乎所有资源都用来去做 GC,但是 GC 也没啥效果。此时系统就像是到了癌症晚期,身体的营养都被癌细胞占据了,真正用于身体使用的非常少了,而且就算是调用所有营养去杀灭癌细胞也晚了,因为杀的效果很差了,还远远没有癌细胞复制的速度快。
-
+
注意,“java.lang.OutOfMemoryError: GC overhead limit exceeded”错误只在连续多次 GC 都只回收了不到 2% 的极端情况下才会抛出。假如不抛出 GC overhead limit 错误会发生什么情况呢?那就是 GC 清理的这么点内存很快会再次填满,迫使 GC 再次执行。这样就形成恶性循环,CPU 使用率一直是 100%,而 GC 却没有任何成果。系统用户就会看到系统卡死——以前只需要几毫秒的操作,现在需要好几分钟甚至几小时才能完成。
这也是一个很好的快速失败原则的案例。
@@ -394,7 +394,7 @@ public class MicroGenerator {
Java 程序本质上是多线程的,可以同时执行多项任务。类似于在播放视频的时候,可以拖放窗口中的内容,却不需要暂停视频播放,即便是物理机上只有一个 CPU。
线程(thread)可以看作是干活的工人(workers)。如果只有一个工人,在同一时间就只能执行一项任务。假若有很多工人,那么就可以同时执行多项任务。
和现实世界类似,JVM 中的线程也需要内存空间来执行自己的任务。如果线程数量太多,就会引入新的问题:
-
+
“java.lang.OutOfMemoryError: Unable to create new native thread”错误是程序创建的线程数量已达到上限值的异常信息。
原因分析
JVM 向操作系统申请创建新的 native thread(原生线程)时,就有可能会碰到“java.lang.OutOfMemoryError: Unable to create new native thread”错误。如果底层操作系统创建新的 native thread 失败,JVM 就会抛出相应的 OutOfMemoryError。
@@ -471,7 +471,7 @@ max user processes (-u) 1800
一种解决办法是执行线程转储(thread dump)来分析具体情况,我们会在后面的章节讲解。
OutOfMemoryError: Out of swap space
JVM 启动参数指定了最大内存限制,如 -Xmx
以及相关的其他启动参数。假若 JVM 使用的内存总量超过可用的物理内存,操作系统就会用到虚拟内存(一般基于磁盘文件)。
-
+
错误信息“java.lang.OutOfMemoryError: Out of swap space”表明,交换空间(swap space/虚拟内存)不足,此时由于物理内存和交换空间都不足,所以导致内存分配失败。
原因分析
如果 native heap 内存耗尽,内存分配时 JVM 就会抛出“java.lang.OutOfmemoryError: Out of swap space”错误消息,告诉用户,请求分配内存的操作失败了。
@@ -494,7 +494,7 @@ swapon swapfile
因为垃圾收集器需要清理整个内存空间,所以虚拟内存对 Java GC 来说是难以忍受的。存在内存交换时,执行垃圾收集的暂停时间会增加上百倍,所以最好不要增加,甚至是不要使用虚拟内存(毕竟访问内存的速度和磁盘的速度,差了几个数量级)。
OutOfMemoryError: Requested array size exceeds VM limit
Java 平台限制了数组的最大长度。各个版本的具体限制可能稍有不同,但范围都在 1~21 亿之间。(想想看,为什么是 21 亿?)
-
+
如果程序抛出“java.lang.OutOfMemoryError: Requested array size exceeds VM limit”错误,就说明程序想要创建的数组长度超过限制。
原因分析
这个错误是在真正为数组分配内存之前,JVM 会执行一项检查:要分配的数据结构在该平台是否可以寻址(addressable)。当然,这个错误比你所想的还要少见得多。
@@ -546,7 +546,7 @@ java.lang.OutOfMemoryError: Requested array size exceeds VM limit
我们知道,操作系统(operating system)构建在进程(process)的基础上。进程由内核作业(kernel jobs)进行调度和维护,其中有一个内核作业称为“Out of memory killer(OOM 终结者)”,与本节所讲的 OutOfMemoryError 有关。
Out of memory killer 在可用内存极低的情况下会杀死某些进程。只要达到触发条件就会激活,选中某个进程并杀掉。通常采用启发式算法,对所有进程计算评分(heuristics scoring),得分最低的进程将被 kill 掉。
因此“Out of memory: Kill process or sacrifice child”和前面所讲的 OutOfMemoryError 都不同,因为它既不由 JVM 触发,也不由 JVM 代理,而是系统内核内置的一种安全保护措施。
-
+
如果可用内存(含 swap)不足,就有可能会影响系统稳定,这时候 Out of memory killer 就会设法找出流氓进程并杀死他,也就是引起“Out of memory: kill process or sacrifice child”错误。
原因分析
默认情况下,Linux kernels(内核)允许进程申请的量超过系统可用内存。这是因为, 在大多数情况下,很多进程申请了很多内存,但实际使用的量并没有那么多。
diff --git a/专栏/JVM 核心技术 32 讲(完)/25 FastThread 相关的工具介绍:欲穷千里目,更上一层楼.md.html b/专栏/JVM 核心技术 32 讲(完)/25 FastThread 相关的工具介绍:欲穷千里目,更上一层楼.md.html
index 2ee2121d..5c144213 100644
--- a/专栏/JVM 核心技术 32 讲(完)/25 FastThread 相关的工具介绍:欲穷千里目,更上一层楼.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/25 FastThread 相关的工具介绍:欲穷千里目,更上一层楼.md.html
@@ -382,40 +382,40 @@ Found 1 deadlock.
两种方式步骤都差不多,选择 RAW 方式上传文本字符串,然后点击分析按钮。
分析结果页面
等待片刻,自动跳转到分析结果页面。
-
+
这里可以看到基本信息,以及右边的一些链接:
- 分享报告,可以很方便地把报告结果发送给其他小伙伴。
线程数汇总
把页面往下拉,可以看到线程数量汇总报告。
-
+
从这个报告中可以很直观地看到,线程总数为 26,其中 19 个运行状态线程,5 个等待状态的线程,2 个阻塞状态线程。
右边还给了一个饼图,展示各种状态所占的比例。
线程组分析
接着是将线程按照名称自动分组。
-
+
这里就看到线程命名的好处了吧!如果我们的线程池统一命名,那么相关资源池的使用情况就很直观。
所以在代码里使用线程池的时候,统一添加线程名称就是一个好的习惯!
守护线程分析
接下来是守护线程分析:
-
+
这里可以看到守护线程与前台线程的统计信息。
死锁情况检测
当然,也少不了死锁分析:
-
+
可以看到,各个工具得出的死锁检测结果都差不多。并不难分析,其中给出了线程名称,以及方法调用栈信息,等待的是哪个锁。
线程调用栈情况
以及线程调用情况:
-
+
后面是这些线程的详情:
-
+
这块信息只是将相关的方法调用栈展示出来。
热点方法统计
热点方法是一个需要注意的重点,调用的越多,说明这一块可能是系统的性能瓶颈。
-
+
这里展示了此次快照中正在执行的方法。如果只看热点方法抽样的话,更精确的工具是 JDK 内置的 hprof。
但如果有很多方法阻塞或等待,则线程快照中展示的热点方法位置可以快速确定问题出现的代码行。
CPU 消耗信息
@@ -426,18 +426,18 @@ Found 1 deadlock.
这里看到 GC 线程数是 8 个,这个值跟具体的 CPU 内核数量相差不大就算是正常的。
GC 线程数如果太多或者太少,会造成很多问题,我们在后面的章节中通过案例进行讲解。
线程栈深度
-
+
这里都小于10,说明堆栈都不深。
复杂死锁检测
接下来是复杂死锁检测和 Finalizer 线程的信息。
-
+
简单死锁是指两个线程之间互相死等资源锁。那么什么复杂死锁呢? 这个问题留给同学们自己搜索。
火焰图
-
+
火焰图挺有趣,将所有线程调用栈汇总到一张图片中。
调用栈树
如果我们把所有的调用栈合并到一起,整体来看呢?
-
+
树形结构在有些时候也很有用,比如大量线程都在执行类似的调用栈路径时。
以上这些信息,都有助于我们去分析和排查 JVM 问题,而图形工具相对于命令行工具的好处是直观、方便、快速,帮我们省去过滤一些不必要的干扰信息的时间。
参考链接
diff --git a/专栏/JVM 核心技术 32 讲(完)/26 面临复杂问题时的几个高级工具:它山之石,可以攻玉.md.html b/专栏/JVM 核心技术 32 讲(完)/26 面临复杂问题时的几个高级工具:它山之石,可以攻玉.md.html
index 75e4dd2c..9cd7066d 100644
--- a/专栏/JVM 核心技术 32 讲(完)/26 面临复杂问题时的几个高级工具:它山之石,可以攻玉.md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/26 面临复杂问题时的几个高级工具:它山之石,可以攻玉.md.html
@@ -289,7 +289,7 @@ May 21 12:05:23 web1 kernel: CPU: 2 PID: 10467 Comm: jstatd Not tainted 3.10.0-5
- btrace-bin.zip
下载完成后解压即可使用:
-
+
可以看到,bin 目录下是可执行文件,samples 目录下是脚本示例。
示例程序
我们先编写一个有入参有返回值的方法,示例如下:
@@ -325,25 +325,25 @@ public class RandomSample {
细心的同学可能已经发现,在安装 JVisualVM 的插件时,有一款插件叫做“BTrace Workbench”。安装这款插件之后,在对应的 JVM 实例上点右键,就可以进入 BTrace 的操作界面。
1. BTrace 插件安装
打开 VisualVM,选择菜单“工具–插件(G)”:
-
+
然后在插件安装界面中,找到“可用插件”:
-
+
勾选“BTrace Workbench”之后,点击“安装(I)”按钮。
如果插件不显示,请更新 JDK 到最新版。
-
+
按照引导和提示,继续安装即可。
-
+
接受协议,并点击安装。
-
+
等待安装完成:
-
+
点击“完成”按钮即可。
BTrace 插件使用
-
+
打开后默认的界面如下:
-
+
可以看到这是一个 Java 文件的样子。然后我们参考官方文档,加一些脚本进去。
BTrace 脚本示例
我们下载的 BTrace 项目中,samples 目录下有一些脚本示例。 参照这些示例,编写一个简单的 BTrace 脚本:
@@ -380,7 +380,7 @@ public class TracingScript {
执行结果
可以看到,输出了简单的执行结果:
-
+
可以和示例程序的控制台输出比对一下。
更多示例
BTrace 提供了很多示例,照着改一改就能执行简单的监控。
@@ -443,9 +443,9 @@ java -jar arthas-boot.jar
使用示例
启动之后显示的信息大致如下图所示:
-
+
然后我们输入需要连接(Attach)的 JVM 进程,例如 1,然后回车。
-
+
如果需要退出,输入 exit 即可。
接着我们输入 help 命令查看帮助,返回的信息大致如下。
[[email protected]]$ help
@@ -492,11 +492,11 @@ java -jar arthas-boot.jar
help thread
如果查看 JVM 信息,输入命令 jvm 即可。
-
+
环境变量 sysenv:
-
+
查看线程信息,输入命令 thread:
-
+
查看某个线程的信息:
[[email protected]]$ thread 1
"main" Id=1 TIMED_WAITING
@@ -506,18 +506,18 @@ java -jar arthas-boot.jar
at demo.jvm0209.RandomSample.main(Unknown Source)
查看 JVM 选项 vmoption:
-
+
某些选项可以设置,这里给出了示例 vmoption PrintGCDetails true
。
查找类 sc:
-
+
反编译代码 jad:
-
+
堆内存转储 heapdump:
-
+
跟踪方法执行时间 trace:
-
+
观察方法执行 watch:
-
+
可以看到,支持条件表达式,类似于代码调试中的条件断点。 功能非常强大,并且作为一个 JVM 分析的集成环境,使用起来也比一般工具方便。更多功能请参考 Arthas 用户文档。
抽样分析器(Profilers)
下面介绍分析器(profilers,Oracle 官方翻译是“抽样器”)。
@@ -570,11 +570,11 @@ java -jar arthas-boot.jar
- 让程序运行一段时间,以收集关于对象分配的足够信息。
- 单击下方的“Snapshot”(快照)按钮,可以获取收集到的快照信息。
-
+
完成上面的步骤后,可以得到类似这样的信息:
-
+
上图按照每个类被创建的对象数量多少来排序。看第一行可以知道,创建的最多的对象是 int[] 数组。鼠标右键单击这行,就可以看到这些对象都在哪些地方创建的:
-
+
与 hprof 相比,JVisualVM 更加容易使用 —— 比如上面的截图中,在一个地方就可以看到所有 int[] 的分配信息,所以多次在同一处代码进行分配的情况就很容易发现。
AProf
AProf 是一款重要的分析器,是由 Devexperts 开发的 AProf。内存分配分析器 AProf 也被打包为 Java agent 的形式。
diff --git a/专栏/JVM 核心技术 32 讲(完)/27 JVM 问题排查分析上篇(调优经验).md.html b/专栏/JVM 核心技术 32 讲(完)/27 JVM 问题排查分析上篇(调优经验).md.html
index ac2f19ee..aaaf3830 100644
--- a/专栏/JVM 核心技术 32 讲(完)/27 JVM 问题排查分析上篇(调优经验).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/27 JVM 问题排查分析上篇(调优经验).md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
- 以猜测来驱动,凭历史经验进行排查。
如果您倾向于选择后一种方式,那么可能会浪费大量的时间,效果得看运气。更糟糕的是,因为基本靠蒙,所以这个过程是完全不可预测的,如果时间很紧张,就会在团队内部造成压力,甚至升级为甩锅和互相指责。
-
+
系统出现性能问题或者故障,究竟是不是 JVM 的问题,得从各个层面依次进行排查。
为什么问题排查这么困难?
生产环境中进行故障排查的困难
@@ -305,7 +305,7 @@ function hide_canvas() {
做好监控,定位问题,验证结果,总结归纳。
下面我们看看 JVM 领域有哪些问题.
-
+
从上图可以看到,JVM 可以划分为这些部分:
- 执行引擎,包括:GC、JIT 编译器
diff --git a/专栏/JVM 核心技术 32 讲(完)/28 JVM 问题排查分析下篇(案例实战).md.html b/专栏/JVM 核心技术 32 讲(完)/28 JVM 问题排查分析下篇(案例实战).md.html
index 5524e093..3b60e565 100644
--- a/专栏/JVM 核心技术 32 讲(完)/28 JVM 问题排查分析下篇(案例实战).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/28 JVM 问题排查分析下篇(案例实战).md.html
@@ -198,16 +198,16 @@ function hide_canvas() {
问题现象描述
最近一段时间,通过监控指标发现,有一个服务节点的最大 GC 暂停时间经常会达到 400ms 以上。
如下图所示:
-
+
从图中可以看到,GC 暂停时间的峰值达到了 546ms,这里展示的时间点是 2020 年 02 月 04 日 09:20:00 左右。
客户表示这种情况必须解决,因为服务调用的超时时间为 1s,要求最大 GC 暂停时间不超过 200ms,平均暂停时间达到 100ms 以内,对客户的交易策略产生了极大的影响。
CPU 负载
CPU 的使用情况如下图所示:
-
+
从图中可以看到:系统负载为 4.92,CPU使用率 7% 左右,其实这个图中隐含了一些重要的线索,但我们此时并没有发现什么问题。
GC 内存使用情况
然后我们排查了这段时间的内存使用情况:
-
+
从图中可以看到,大约 09:25 左右 old_gen 使用量大幅下跌,确实是发生了 FullGC。
但 09:20 前后,老年代空间的使用量在缓慢上升,并没有下降,也就是说引发最大暂停时间的这个点并没有发生 FullGC。
当然,这些是事后复盘分析得出的结论。当时对监控所反馈的信息并不是特别信任,怀疑就是触发了 FullGC 导致的长时间 GC 暂停。
@@ -232,16 +232,16 @@ function hide_canvas() {
-Xmx4g -Xms4g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
接着服务启动成功,等待健康检测自动切换为新的服务节点,继续查看指标。
-
+
看看暂停时间,每个节点的 GC 暂停时间都降下来了,基本上在 50ms 以内,比较符合我们的预期。
嗯!事情到此结束了?远远没有。
“彩蛋”惊喜
过了一段时间,我们发现了个下面这个惊喜(也许是惊吓),如下图所示:
-
+
中奖了,运行一段时间后,最大 GC 暂停时间达到了 1300ms。
情况似乎更恶劣了。
继续观察,发现不是个别现象:
-
+
内心是懵的,觉得可能是指标算错了,比如把 10s 内的暂停时间全部加到了一起。
注册 GC 事件监听
于是想了个办法,通过 JMX 注册 GC 事件监听,把相关的信息直接打印出来。
@@ -292,7 +292,7 @@ for (GarbageCollectorMXBean mbean
-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
重新启动,希望这次能排查出问题的原因。
-
+
运行一段时间,又发现了超长的暂停时间。
分析 GC 日志
因为不涉及敏感数据,那么我们把 GC 日志下载到本地进行分析。
@@ -356,7 +356,7 @@ CommandLine flags:
看到这么多的 GC 工作线程我就开始警惕了,毕竟堆内存才指定了 4GB。
按照一般的 CPU 和内存资源配比,常见的比例差不多是 4 核 4GB、4 核 8GB 这样的。
看看对应的 CPU 负载监控信息:
-
+
通过和运维同学的沟通,得到这个节点的配置被限制为 4 核 8GB。
这样一来,GC 暂停时间过长的原因就定位到了:
@@ -383,7 +383,7 @@ CommandLine flags:
设置并发标记的 GC 线程数量。默认值大约是 ParallelGCThreads 的四分之一。
一般来说不用指定并发标记的 GC 线程数量,只用指定并行的即可。
重新启动之后,看看 GC 暂停时间指标:
-
+
红色箭头所指示的点就是重启的时间点,可以发现,暂停时间基本上都处于 50ms 范围内。
后续的监控发现,这个参数确实解决了问题。
那么还有没有其他的办法呢?请关注后续的章节《应对容器时代面临的挑战》。
diff --git a/专栏/JVM 核心技术 32 讲(完)/29 GC 疑难情况问题排查与分析(上篇).md.html b/专栏/JVM 核心技术 32 讲(完)/29 GC 疑难情况问题排查与分析(上篇).md.html
index 98c95815..c4cca426 100644
--- a/专栏/JVM 核心技术 32 讲(完)/29 GC 疑难情况问题排查与分析(上篇).md.html
+++ b/专栏/JVM 核心技术 32 讲(完)/29 GC 疑难情况问题排查与分析(上篇).md.html
@@ -411,7 +411,7 @@ function hide_canvas() {
请注意,只能根据 Minor GC 计算提升速率。Full GC 的日志不能用于计算提升速率,因为 Major GC 会清理掉老年代中的一部分对象。
提升速率的意义
和分配速率一样,提升速率也会影响 GC 暂停的频率。但分配速率主要影响 minor GC,而提升速率则影响 major GC 的频率。有大量的对象提升,自然很快将老年代填满。老年代填充的越快,则 Major GC 事件的频率就会越高。
-
+
前面章节提到过,Full GC 通常需要更多的时间,因为需要处理更多的对象,还要执行碎片整理等额外的复杂过程。
示例
让我们看一个过早提升的示例。这个程序创建/获取大量的对象/数据,并暂存到集合之中,达到一定数量后进行批处理:
diff --git a/专栏/Java 性能优化实战-完/01 理论分析:性能优化,有哪些衡量指标?需要注意什么?.md.html b/专栏/Java 性能优化实战-完/01 理论分析:性能优化,有哪些衡量指标?需要注意什么?.md.html
index 929265bd..a2c5f4fa 100644
--- a/专栏/Java 性能优化实战-完/01 理论分析:性能优化,有哪些衡量指标?需要注意什么?.md.html
+++ b/专栏/Java 性能优化实战-完/01 理论分析:性能优化,有哪些衡量指标?需要注意什么?.md.html
@@ -167,13 +167,13 @@ function hide_canvas() {
加载缓慢的网站,会受到搜索排名算法的惩罚,从而导致网站排名下降。 因此加载的快慢是性能优化是否合理的一个非常直观的判断因素,但性能指标不仅仅包括单次请求的速度,它还包含更多因素。
接下来看一下,都有哪些衡量指标能够帮我们进行决策。
衡量指标有哪些?
-
+
1. 吞吐量和响应速度
分布式的高并发应用并不能把单次请求作为判断依据,它往往是一个统计结果。其中最常用的衡量指标就是吞吐量和响应速度,而这两者也是考虑性能时非常重要的概念。要理解这两个指标的意义,我们可以类比为交通环境中的十字路口。
在交通非常繁忙的情况下,十字路口是典型的瓶颈点,当红绿灯放行时间非常长时,后面往往会排起长队。
从我们开车开始排队,到车经过红绿灯,这个过程所花费的时间,就是响应时间。
当然,我们可以适当地调低红绿灯的间隔时间,这样对于某些车辆来说,通过时间可能会短一些。但是,如果信号灯频繁切换,反而会导致单位时间内通过的车辆减少,换一个角度,我们也可以认为这个十字路口的车辆吞吐量减少了。
-
+
像我们平常开发中经常提到的,QPS 代表每秒查询的数量,TPS 代表每秒事务的数量,HPS 代表每秒的 HTTP 请求数量等,这都是常用的与吞吐量相关的量化指标。
在性能优化的时候,我们要搞清楚优化的目标,到底是吞吐量还是响应速度。 有些时候,虽然响应速度比较慢,但整个吞吐量却非常高,比如一些数据库的批量操作、一些缓冲区的合并等。虽然信息的延迟增加了,但如果我们的目标就是吞吐量,那么这显然也可以算是比较大的性能提升。
一般情况下,我们认为:
@@ -190,7 +190,7 @@ function hide_canvas() {
除非服务在一段时间内出现了严重的问题,否则平均响应时间都会比较平缓。因为高并发应用请求量都特别大,所以长尾请求的影响会被很快平均,导致很多用户的请求变慢,但这不能体现在平均耗时指标中。
为了解决这个问题,另外一个比较常用的指标,就是百分位数(Percentile)。
(2)百分位数
-
+
这个也比较好理解。我们圈定一个时间范围,把每次请求的耗时加入一个列表中,然后按照从小到大的顺序将这些时间进行排序。这样,我们取出特定百分位的耗时,这个数字就是 TP 值。可以看到,TP 值(Top Percentile)和中位数、平均数等是类似的,都是一个统计学里的术语。
它的意义是,超过 N% 的请求都在 X 时间内返回。比如 TP90 = 50ms,意思是超过 90th 的请求,都在 50ms 内返回。
这个指标也是非常重要的,它能够反映出应用接口的整体响应情况。比如,某段时间若发生了长时间的 GC,那它的某个时间段之上的指标就会产生严重的抖动,但一些低百分位的数值却很少有变化。
@@ -219,7 +219,7 @@ function hide_canvas() {
基准测试(Benchmark)并不是简单的性能测试,是用来测试某个程序的最佳性能。
应用接口往往在刚启动后都有短暂的超时。在测试之前,我们需要对应用进行预热,消除 JIT 编译器等因素的影响。而在 Java 里就有一个组件,即 JMH,就可以消除这些差异。
注意点
-
+
1. 依据数字而不是猜想
有些同学对编程有很好的感觉,能够靠猜测列出系统的瓶颈点,这种情况固然存在,但却非常不可取。复杂的系统往往有多个影响因素,我们应将性能分析放在第一位,把性能优化放在次要位置,直觉只是我们的辅助,但不能作为下结论的工具。
进行性能优化时,我们一般会把分析后的结果排一个优先级(根据难度和影响程度),从大处着手,首先击破影响最大的点,然后将其他影响因素逐一击破。
diff --git a/专栏/Java 性能优化实战-完/02 理论分析:性能优化有章可循,谈谈常用的切入点.md.html b/专栏/Java 性能优化实战-完/02 理论分析:性能优化有章可循,谈谈常用的切入点.md.html
index 1b00bd13..e8b556e7 100644
--- a/专栏/Java 性能优化实战-完/02 理论分析:性能优化有章可循,谈谈常用的切入点.md.html
+++ b/专栏/Java 性能优化实战-完/02 理论分析:性能优化有章可循,谈谈常用的切入点.md.html
@@ -165,7 +165,7 @@ function hide_canvas() {
了解了优化目标后,那接下来应该从哪些方面入手呢?本课时主要侧重于理论分析,我们从整体上看一下 Java 性能优化都有哪些可以遵循的规律。本课主讲理论,关于实践,后面的课时会用较多的案例来细化本课时的知识点,适合反复思考和归纳。
性能优化的 7 类技术手段
性能优化根据优化的类别,分为业务优化和技术优化。业务优化产生的效果也是非常大的,但它属于产品和管理的范畴。同作为程序员,在平常工作中,我们面对的优化方式,主要是通过一系列的技术手段,来完成对既定的优化目标。这一系列的技术手段,我大体归纳为如图以下 7 类:
-
+
可以看到,优化方式集中在对计算资源和存储资源的规划上。优化方法中有多种用空间换时间的方式,但只照顾计算速度,而不考虑复杂性和空间问题,也是不可取的。我们要做的,就是在照顾性能的前提下,达到资源利用的最优状态。
接下来,我简要介绍一下这 7 种优化方式。如果你感觉比较枯燥,那也没关系,我们本课时的目的,就是让你的脑海里有一个总分的概念,以及对理论基础有一个整体的认识。
1. 复用优化
@@ -177,7 +177,7 @@ function hide_canvas() {
- 缓存(Cache),常见于对已读取数据的复用,通过将它们缓存在相对高速的区域,缓存主要针对的是读操作。
与之类似的,是对于对象的池化操作,比如数据库连接池、线程池等,在 Java 中使用得非常频繁。由于这些对象的创建和销毁成本都比较大,我们在使用之后,也会将这部分对象暂时存储,下次用的时候,就不用再走一遍耗时的初始化操作了。
-
+
2. 计算优化
(1)并行执行
现在的 CPU 发展速度很快,绝大多数硬件,都是多核。要想加快某个任务的执行,最快最优的解决方式,就是让它并行执行。并行执行有以下三种模式。
@@ -190,7 +190,7 @@ function hide_canvas() {
异步操作可以方便地支持横向扩容,也可以缓解瞬时压力,使请求变得平滑。同步请求,就像拳头打在钢板上;异步请求,就像拳头打在海绵上。你可以想象一下这个过程,后者肯定是富有弹性的,体验更加友好。
(3)惰性加载
最后一种,就是使用一些常见的设计模式来优化业务,提高体验,比如单例模式、代理模式等。举个例子,在绘制 Swing 窗口的时候,如果要显示比较多的图片,就可以先加载一个占位符,然后通过后台线程慢慢加载所需要的资源,这就可以避免窗口的僵死。
-
+
3. 结果集优化
接下来介绍一下对结果集的优化。举个比较直观的例子,我们都知道 XML 的表现形式是非常好的,那为什么还有 JSON 呢?除了书写要简单一些,一个重要的原因就是它的体积变小了,传输效率和解析效率变高了,像 Google 的 Protobuf,体积就更小了一些。虽然可读性降低,但在一些高并发场景下(如 RPC),能够显著提高效率,这是典型的对结果集的优化。
这是由于我们目前的 Web 服务,都是 C/S 模式。数据从服务器传输到客户端,需要分发多份,这个数据量是急剧膨胀的,每减少一小部分存储,都会有比较大的传输性能和成本提升。
@@ -198,14 +198,14 @@ function hide_canvas() {
了解了这个道理,我们就能看到对于结果集优化的一般思路,你要尽量保持返回数据的精简。一些客户端不需要的字段,那就在代码中,或者直接在 SQL 查询中,就把它去掉。
对于一些对时效性要求不高,但对处理能力有高要求的业务。我们要吸取缓冲区的经验,尽量减少网络连接的交互,采用批量处理的方式,增加处理速度。
结果集合很可能会有二次使用,你可能会把它加入缓存中,但依然在速度上有所欠缺。这个时候,就需要对数据集合进行处理优化,采用索引或者 Bitmap 位图等方式,加快数据访问速度。
-
+
4. 资源冲突优化
我们在平常的开发中,会涉及很多共享资源。这些共享资源,有的是单机的,比如一个 HashMap;有的是外部存储,比如一个数据库行;有的是单个资源,比如 Redis 某个 key 的Setnx;有的是多个资源的协调,比如事务、分布式事务等。
现实中的性能问题,和锁相关的问题是非常多的。大多数我们会想到数据库的行锁、表锁、Java 中的各种锁等。在更底层,比如 CPU 命令级别的锁、JVM 指令级别的锁、操作系统内部锁等,可以说无处不在。
只有并发,才能产生资源冲突。也就是在同一时刻,只能有一个处理请求能够获取到共享资源。解决资源冲突的方式,就是加锁。再比如事务,在本质上也是一种锁。
按照锁级别,锁可分为乐观锁和悲观锁,乐观锁在效率上肯定是更高一些;按照锁类型,锁又分为公平锁和非公平锁,在对任务的调度上,有一些细微的差别。
对资源的争用,会造成严重的性能问题,所以会有一些针对无锁队列之类的研究,对性能的提升也是巨大的。
-
+
5. 算法优化
算法能够显著提高复杂业务的性能,但在实际的业务中,往往都是变种。由于存储越来越便宜,在一些 CPU 非常紧张的业务中,往往采用空间换取时间的方式,来加快处理速度。
算法属于代码调优,代码调优涉及很多编码技巧,需要使用者对所使用语言的 API 也非常熟悉。有时候,对算法、数据结构的灵活使用,也是代码优化的一个重要内容。比如,常用的降低时间复杂度的方式,就有递归、二分、排序、动态规划等。
diff --git a/专栏/Java 性能优化实战-完/03 深入剖析:哪些资源,容易成为瓶颈?.md.html b/专栏/Java 性能优化实战-完/03 深入剖析:哪些资源,容易成为瓶颈?.md.html
index 24a18d3e..f8693e40 100644
--- a/专栏/Java 性能优化实战-完/03 深入剖析:哪些资源,容易成为瓶颈?.md.html
+++ b/专栏/Java 性能优化实战-完/03 深入剖析:哪些资源,容易成为瓶颈?.md.html
@@ -173,7 +173,7 @@ function hide_canvas() {
具体情况如下。
1.top 命令 —— CPU 性能
如下图,当进入 top 命令后,按 1 键即可看到每核 CPU 的运行指标和详细性能。
-
+
CPU 的使用有多个维度的指标,下面分别说明:
- us 用户态所占用的 CPU 百分比,即引用程序所耗费的 CPU;
@@ -188,7 +188,7 @@ function hide_canvas() {
一般地,我们比较关注空闲 CPU 的百分比,它可以从整体上体现 CPU 的利用情况。
2.负载 —— CPU 任务排队情况
如果我们评估 CPU 任务执行的排队情况,那么需要通过负载(load)来完成。除了 top 命令,使用 uptime 命令也能够查看负载情况,load 的效果是一样的,分别显示了最近 1min、5min、15min 的数值。
-
+
如上图所示,以单核操作系统为例,将 CPU 资源抽象成一条单向行驶的马路,则会发生以下三种情况:
- 马路上的车只有 4 辆,车辆畅通无阻,load 大约是 0.5;
@@ -205,7 +205,7 @@ function hide_canvas() {
所以,对于一个 load 到了 10,却是 16 核的机器,你的系统还远没有达到负载极限。
3.vmstat —— CPU 繁忙程度
要看 CPU 的繁忙程度,可以通过 vmstat 命令,下图是 vmstat 命令的一些输出信息。
-
+
比较关注的有下面几列:
- b 如果系统有负载问题,就可以看一下 b 列(Uninterruptible Sleep),它的意思是等待 I/O,可能是读盘或者写盘动作比较多;
@@ -224,7 +224,7 @@ nonvoluntary_ctxt_switches: 171204
我们在平常写完代码后,比如写了一个 C++ 程序,去查看它的汇编,如果看到其中的内存地址,并不是实际的物理内存地址,那么应用程序所使用的,就是逻辑内存。学过计算机组成结构的同学应该都有了解。
逻辑地址可以映射到两个内存段上:物理内存和虚拟内存,那么整个系统可用的内存就是两者之和。比如你的物理内存是 4GB,分配了 8GB 的 SWAP 分区,那么应用可用的总内存就是 12GB。
1. top 命令
-
+
如上图所示,我们看一下内存的几个参数,从 top 命令可以看到几列数据,注意方块框起来的三个区域,解释如下:
- VIRT 这里是指虚拟内存,一般比较大,不用做过多关注;
@@ -267,14 +267,14 @@ cache_alignment : 64
这样,启动时虽然慢了些,但运行时的性能会增加。
I/O
I/O 设备可能是计算机里速度最慢的组件了,它指的不仅仅是硬盘,还包括外围的所有设备。那硬盘有多慢呢?我们不去探究不同设备的实现细节,直接看它的写入速度(数据未经过严格测试,仅作参考)。
-
+
如上图所示,可以看到普通磁盘的随机写与顺序写相差非常大,但顺序写与 CPU 内存依旧不在一个数量级上。
缓冲区依然是解决速度差异的唯一工具,但在极端情况下,比如断电时,就产生了太多的不确定性,这时这些缓冲区,都容易丢。由于这部分内容的篇幅比较大,我将在第 06 课时专门讲解。
1. iostat
最能体现 I/O 繁忙程度的,就是 top 命令和 vmstat 命令中的 wa%。如果你的应用写了大量的日志,I/O wait 就可能非常高。
-
+
很多同学反馈到,不知道有哪些便捷好用的查看磁盘 I/O 的工具,其实 iostat 就是。你可以通过 sysstat 包进行安装。
-
+
上图中的指标详细介绍如下所示。
- %util:我们非常关注这个数值,通常情况下,这个数字超过 80%,就证明 I/O 的负荷已经非常严重了。
diff --git a/专栏/Java 性能优化实战-完/04 工具实践:如何获取代码性能数据?.md.html b/专栏/Java 性能优化实战-完/04 工具实践:如何获取代码性能数据?.md.html
index 6c07a314..58c5cdfe 100644
--- a/专栏/Java 性能优化实战-完/04 工具实践:如何获取代码性能数据?.md.html
+++ b/专栏/Java 性能优化实战-完/04 工具实践:如何获取代码性能数据?.md.html
@@ -167,7 +167,7 @@ function hide_canvas() {
nmon —— 获取系统性能数据
除了在上一课时中介绍的 top、free 等命令,还有一些将资源整合在一起的监控工具,
nmon 便是一个老牌的 Linux 性能监控工具,它不仅有漂亮的监控界面(如下图所示),还能产出细致的监控报表。
-
+
我在对应用做性能评估时,通常会加上 nmon 的报告,这会让测试结果更加有说服力。你在平时工作中也可如此尝试。
上一课时介绍的一些操作系统性能指标,都可从 nmon 中获取。它的监控范围很广,包括 CPU、内存、网络、磁盘、文件系统、NFS、系统资源等信息。
nmon 在 sourceforge 发布,我已经下载下来并上传到了仓库中。比如我的是 CentOS 7 系统,选择对应的版本即可执行。
@@ -182,12 +182,12 @@ function hide_canvas() {
root 2228 1 0 16:33 pts/0 00:00:00 ./nmon_x86_64_centos7 -f -s 5 -c 12 -m .
使用 nmonchart 工具(见仓库),即可生成 html 文件。下面是生成文件的截图。
-
+
nmonchart 报表
jvisualvm —— 获取 JVM 性能数据
jvisualvm 原是随着 JDK 发布的一个工具,Java 9 之后开始单独发布。通过它,可以了解应用在运行中的内部情况。我们可以连接本地或者远程的服务器,监控大量的性能数据。
通过插件功能,jvisualvm 能获得更强大的扩展。如下图所示,建议把所有的插件下载下来进行体验。
-
+
jvisualvm 插件安装
要想监控远程的应用,还需要在被监控的 App 上加入 jmx 参数。
-Dcom.sun.management.jmxremote.port=14000
@@ -196,7 +196,7 @@ jvisualvm 插件安装
上述配置的意义是开启 JMX 连接端口 14000,同时配置不需要 SSL 安全认证方式连接。
对于性能优化来说,我们主要用到它的采样器。注意,由于抽样分析过程对程序运行性能有较大的影响,一般我们只在测试环境中使用此功能。
-
+
jvisualvm CPU 性能采样图
对于一个 Java 应用来说,除了要关注它的 CPU 指标,垃圾回收方面也是不容忽视的性能点,我们主要关注以下三点。
@@ -215,48 +215,48 @@ jcmd <pid> JFR.stop
JMC 集成了 JFR 的功能,下面介绍一下 JMC 的使用。
1.录制
下图是录制了一个 Tomcat 一分钟之后的结果,从左边的菜单栏即可进入相应的性能界面。
-
+
JMC 录制结果主界面
通过录制数据,可以清晰了解到某一分钟内,操作系统资源,以及 JVM 内部的性能数据情况。
2.线程
选择相应的线程,即可了解线程的执行情况,比如 Wait、Idle 、Block 等状态和时序。
以 C2 编译器线程为例,可以看到详细的热点类,以及方法内联后的代码大小。如下图所示,C2 此时正在疯狂运转。
-
+
JMC 录制结果 线程界面
3.内存
通过内存界面,可以看到每个时间段内内存的申请情况。在排查内存溢出、内存泄漏等情况时,这个功能非常有用。
-
+
JMC 录制结果 内存界面
4.锁
一些竞争非常严重的锁信息,以及一些死锁信息,都可以在锁信息界面中找到。
可以看到,一些锁的具体 ID,以及关联的线程信息,都可以进行联动分析。
-
+
JMC 录制结果 锁信息界面
5.文件和 Socket
文件和 Socket 界面能够监控对 I/O 的读写,界面一目了然。如果你的应用 I/O 操作比较繁重,比如日志打印比较多、网络读写频繁,就可以在这里监控到相应的信息,并能够和执行栈关联起来。
-
+
JMC 录制结果 文件和 Socket 界面
6.方法调用
这个和 jvisualvm 的功能类似,展示的是方法调用信息和排行。从这里可以看到一些高耗时方法和热点方法。
-
+
JMC 录制结果 方法调用
7.垃圾回收
如果垃圾回收过于频繁,就会影响应用的性能。JFR 对垃圾回收进行了详细的记录,比如什么时候发生了垃圾回收,用的什么垃圾回收器,每次垃圾回收的耗时,甚至是什么原因引起的等问题,都可以在这里看到。
-
+
JMC 录制结果 垃圾回收
8.JIT
JIT 编译后的代码,执行速度会特别快,但它需要一个编译过程。编译界面显示了详细的 JIT 编译过程信息,包括生成后的 CodeCache 大小、方法内联信息等。
-
+
JMC 录制结果 JIT 信息
9.TLAB
JVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配,这就是 TLAB(Thread Local Allocation Buffer)的概念。这个 buffer,就放在 Eden 区。
原理和 Java 语言中的 ThreadLocal 类似,能够避免对公共区的操作,可以减少一些锁竞争。如下图所示的界面,详细地显示了这个分配过程。
-
+
JMC 录制结果 TLAB 信息
在后面的课时中,我们会有多个使用此工具的分析案例。
Arthas —— 获取单个请求的调用链耗时
Arthas 是一个 Java 诊断工具,可以排查内存溢出、CPU 飙升、负载高等内容,可以说是一个 jstack、jmap 等命令的大集合。
-
+
Arthas 启动界面
Arthas 支持很多命令,我们以 trace 命令为例。
有时候,我们统计到某个接口的耗时非常高,但又无法找到具体原因时,就可以使用这个 trace 命令。该命令会从方法执行开始记录整个链路上的执行情况,然后统计每个节点的性能开销,最终以树状打印,很多性能问题一眼就能看出来。
diff --git a/专栏/Java 性能优化实战-完/05 工具实践:基准测试 JMH,精确测量方法性能.md.html b/专栏/Java 性能优化实战-完/05 工具实践:基准测试 JMH,精确测量方法性能.md.html
index f4a6eb1e..cc5b2a7a 100644
--- a/专栏/Java 性能优化实战-完/05 工具实践:基准测试 JMH,精确测量方法性能.md.html
+++ b/专栏/Java 性能优化实战-完/05 工具实践:基准测试 JMH,精确测量方法性能.md.html
@@ -188,7 +188,7 @@ System.out.println("Logic cost : " + cost);
下面,我们介绍一下这个工具的使用。
JMH 是一个 jar 包,它和单元测试框架 JUnit 非常像,可以通过注解进行一些基础配置。这部分配置有很多是可以通过 main 方法的 OptionsBuilder 进行设置的。
-
+
上图是一个典型的 JMH 程序执行的内容。通过开启多个进程,多个线程,先执行预热,然后执行迭代,最后汇总所有的测试数据进行分析。在执行前后,还可以根据粒度处理一些前置和后置操作。
一段简单的 JMH 代码如下所示:
@BenchmarkMode(Mode.Throughput)
@@ -251,7 +251,7 @@ timeUnit = TimeUnit.SECONDS)
一般来说,基准测试都是针对比较小的、执行速度相对较快的代码块,这些代码有很大的可能性被 JIT 编译、内联,所以在编码时保持方法的精简,是一个好的习惯。具体优化过程,我们将在 18 课时介绍。
说到预热,就不得不提一下在分布式环境下的服务预热。在对服务节点进行发布的时候,通常也会有预热过程,逐步放量到相应的服务节点,直到服务达到最优状态。如下图所示,负载均衡负责这个放量过程,一般是根据百分比进行放量。
-
+
2. @Measurement
样例如下:
@Measurement(
@@ -312,7 +312,7 @@ BenchmarkTest.shift thrpt 5 480599.263 ± 20752.609 ops/ms
那么 fork 到底是在进程还是线程环境里运行呢?
我们追踪一下 JMH 的源码,发现每个 fork 进程是单独运行在 Proccess 进程里的,这样就可以做完全的环境隔离,避免交叉影响。
它的输入输出流,通过 Socket 连接的模式,发送到我们的执行终端。
-
+
在这里分享一个小技巧。其实 fork 注解有一个参数叫作 jvmArgsAppend,我们可以通过它传递一些 JVM 的参数。
@Fork(value = 3, jvmArgsAppend = {"-Xmx2048m", "-server", "-XX:+AggressiveOpts"})
@@ -379,12 +379,12 @@ public class JMHSample_27_Params {
值得注意的是,如果你设置了非常多的参数,这些参数将执行多次,通常会运行很长时间。比如参数 1 M 个,参数 2 N 个,那么总共要执行 M*N 次。
下面是一个执行结果的截图:
-
+
10. @CompilerControl
这可以说是一个非常有用的功能了。
Java 中方法调用的开销是比较大的,尤其是在调用量非常大的情况下。拿简单的getter/setter 方法来说,这种方法在 Java 代码中大量存在。我们在访问的时候,就需要创建相应的栈帧,访问到需要的字段后,再弹出栈帧,恢复原程序的执行。
如果能够把这些对象的访问和操作,纳入目标方法的调用范围之内,就少了一次方法调用,速度就能得到提升,这就是方法内联的概念。如下图所示,代码经过 JIT 编译之后,效率会有大的提升。
-
+
这个注解可以用在类或者方法上,能够控制方法的编译行为,常用的有 3 种模式:
强制使用内联(INLINE),禁止使用内联(DONT_INLINE),甚至是禁止方法编译(EXCLUDE)等。
将结果图形化
@@ -403,16 +403,16 @@ public class JMHSample_27_Params {
- LATEX 导出到 latex,一种基于 ΤΕΧ 的排版系统。
一般来说,我们导出成 CSV 文件,直接在 Excel 中操作,生成如下相应的图形就可以了。
-
+
2. 结果图形化制图工具
JMH Visualizer
这里有一个开源的项目,通过导出 json 文件,上传至 JMH Visualizer(点击链接跳转),可得到简单的统计结果。由于很多操作需要鼠标悬浮在上面进行操作,所以个人认为它的展示方式并不是很好。
JMH Visual Chart
相比较而言, JMH Visual Chart(点击链接跳转)这个工具,就相对直观一些。
-
+
meta-chart
一个通用的 在线图表生成器(点击链接跳转),导出 CSV 文件后,做适当处理,即可导出精美图像。
-
+
像 Jenkins 等一些持续集成工具,也提供了相应的插件,用来直接显示这些测试结果。
小结
本课时主要介绍了 基准测试工具— JMH,官方的 JMH 有非常丰富的示例,比如伪共享(FalseSharing)的影响等高级话题。我已经把它放在了 Gitee(点击链接跳转)上,你可以将其导入至 Idea 编辑器进行测试。
diff --git a/专栏/Java 性能优化实战-完/06 案例分析:缓冲区如何让代码加速.md.html b/专栏/Java 性能优化实战-完/06 案例分析:缓冲区如何让代码加速.md.html
index 13b85ba1..43eee5d1 100644
--- a/专栏/Java 性能优化实战-完/06 案例分析:缓冲区如何让代码加速.md.html
+++ b/专栏/Java 性能优化实战-完/06 案例分析:缓冲区如何让代码加速.md.html
@@ -173,11 +173,11 @@ function hide_canvas() {
- 优化用户体验,比如常见的音频/视频缓冲加载,通过提前缓冲数据,达到流畅的播放效果。
缓冲在 Java 语言中被广泛应用,在 IDEA 中搜索 Buffer,可以看到长长的类列表,其中最典型的就是文件读取和写入字符流。
-
+
文件读写流
接下来,我会以文件读取和写入字符流为例进行讲解。
Java 的 I/O 流设计,采用的是装饰器模式,当需要给类添加新的功能时,就可以将被装饰者通过参数传递到装饰者,封装成新的功能方法。下图是装饰器模式的典型示意图,就增加功能来说,装饰模式比生成子类更为灵活。
-
+
在读取和写入流的 API 中,BufferedInputStream 和 BufferedReader 可以加快读取字符的速度,BufferedOutputStream 和 BufferedWriter 可以加快写入的速度。
下面是直接读取文件的代码实现:
int result = 0;
@@ -259,7 +259,7 @@ private void fill() throws IOException {
这就是一个权衡的问题,缓冲区开得太大,会增加单次读写的时间,同时内存价格很高,不能无限制使用,缓冲流的默认缓冲区大小是 8192 字节,也就是 8KB,算是一个比较折中的值。
这好比搬砖,如果一块一块搬,时间便都耗费在往返路上了;但若给你一个小推车,往返的次数便会大大降低,效率自然会有所提升。
下图是使用 FileReader 和 BufferedReader 读取文件的 JMH 对比(相关代码见仓库),可以看到,使用了缓冲,读取效率有了很大的提升(暂未考虑系统文件缓存)。
-
+
日志缓冲
日志是程序员们最常打交道的地方。在高并发应用中,即使对日志进行了采样,日志数量依旧惊人,所以选择高速的日志组件至关重要。
SLF4J 是 Java 里标准的日志记录库,它是一个允许你使用任何 Java 日志记录库的抽象适配层,最常用的实现是 Logback,支持修改后自动 reload,它比 Java 自带的 JUL 还要流行。
@@ -276,7 +276,7 @@ private void fill() throws IOException {
<appender-ref ref ="FILE"/>
</appender>
-
+
如上图,异步日志输出之后,日志信息将暂存在 ArrayBlockingQueue 列表中,后台会有一个 Worker 线程不断地获取缓冲区内容,然后写入磁盘中。
上图中有三个关键参数:
@@ -288,22 +288,22 @@ private void fill() throws IOException {
毫无疑问缓冲区是可以提高性能的,但它通常会引入一个异步的问题,使得编程模型变复杂。
通过文件读写流和 Logback 两个例子,我们来看一下对于缓冲区设计的一些常规操作。
如下图所示,资源 A 读取或写入一些操作到资源 B,这本是一个正常的操作流程,但由于中间插入了一个额外的存储层,所以这个流程被生生截断了,这时就需要你手动处理被截断两方的资源协调问题。
-
+
根据资源的不同,对正常业务进行截断后的操作,分为同步操作和异步操作。
1.同步操作
同步操作的编程模型相对简单,在一个线程中就可完成,你只需要控制缓冲区的大小,并把握处理的时机。比如,缓冲区大小达到阈值,或者缓冲区的元素在缓冲区的停留时间超时,这时就会触发批量操作。
由于所有的操作又都在单线程,或者同步方法块中完成,再加上资源 B 的处理能力有限,那么很多操作就会阻塞并等待在调用线程上。比如写文件时,需要等待前面的数据写入完毕,才能处理后面的请求。
-
+
2.异步操作
异步操作就复杂很多。
缓冲区的生产者一般是同步调用,但也可以采用异步方式进行填充,一旦采用异步操作,就涉及缓冲区满了以后,生产者的一些响应策略。
此时,应该将这些策略抽象出来,根据业务的属性选择,比如直接抛弃、抛出异常,或者直接在用户的线程进行等待。你会发现它与线程池的饱和策略是类似的,这部分的详细概念将在 12 课时讲解。
许多应用系统还会有更复杂的策略,比如在用户线程等待,设置一个超时时间,以及成功进入缓冲区之后的回调函数等。
对缓冲区的消费,一般采用开启线程的方式,如果有多个线程消费缓冲区,还会存在信息同步和顺序问题。
-
+
3.Kafka缓冲区示例
这里以一个常见的面试题来讲解上面的知识点:Kafka 的生产者,有可能会丢数据吗?
-
+
如图,要想解答这个问题,需要先了解 Kafka 对生产者的一些封装,其中有一个对性能影响非常大的点,就是缓冲。
生产者会把发送到同一个 partition 的多条消息,封装在一个 batch(缓冲区)中。当 batch 满了(参数 batch.size),或者消息达到了超时时间(参数 linger.ms),缓冲区中的消息就会被发送到 broker 上。
这个缓冲区默认是 16KB,如果生产者的业务突然断电,这 16KB 数据是没有机会发送出去的。此时,就造成了消息丢失。
diff --git a/专栏/Java 性能优化实战-完/07 案例分析:无处不在的缓存,高并发系统的法宝.md.html b/专栏/Java 性能优化实战-完/07 案例分析:无处不在的缓存,高并发系统的法宝.md.html
index 497478f0..5b8a2a9d 100644
--- a/专栏/Java 性能优化实战-完/07 案例分析:无处不在的缓存,高并发系统的法宝.md.html
+++ b/专栏/Java 性能优化实战-完/07 案例分析:无处不在的缓存,高并发系统的法宝.md.html
@@ -164,7 +164,7 @@ function hide_canvas() {
和缓冲类似,缓存可能是软件中使用最多的优化技术了,比如:在最核心的 CPU 中,就存在着多级缓存;为了消除内存和存储之间的差异,各种类似 Redis 的缓存框架更是层出不穷。
缓存的优化效果是非常好的,它既可以让原本载入非常缓慢的页面,瞬间秒开,也能让本是压力山大的数据库,瞬间清闲下来。
缓存,本质上是为了协调两个速度差异非常大的组件,如下图所示,通过加入一个中间层,将常用的数据存放在相对高速的设备中。
-
+
在我们平常的应用开发中,根据缓存所处的物理位置,一般分为进程内缓存和进程外缓存。
本课时我们主要聚焦在进程内缓存上,在 Java 中,进程内缓存,就是我们常说的堆内缓存。Spring 的默认实现里,就包含 Ehcache、JCache、Caffeine、Guava Cache 等。
Guava 的 LoadingCache
@@ -182,7 +182,7 @@ function hide_canvas() {
</dependency>
下面介绍一下 LC 的常用操作:
-
+
1.缓存初始化
首先,我们可以通过下面的参数设置一下 LC 的大小。一般,我们只需给缓存提供一个上限。
@@ -212,7 +212,7 @@ static String slowMethod(String key) throws Exception {
}
上面是主动触发的示例代码,你可以使用 get 方法获取缓存的值。比如,当我们执行 lc.get("a") 时,第一次会比较缓慢,因为它需要到数据源进行获取;第二次就瞬间返回了,也就是缓存命中了。具体时序可以参见下面这张图。
-
+
除了靠 LC 自带的回收策略,我们也可以手动删除某一个元素,这就是 invalidate 方法。当然,数据的这些删除操作,也是可以监听到的,只需要设置一个监听器就可以了,代码如下:
.removalListener(notification -> System.out.println(notification))
@@ -259,7 +259,7 @@ static String slowMethod(String key) throws Exception {
boolean accessOrder)
accessOrder 参数是实现 LRU 的关键。当 accessOrder 的值为 true 时,将按照对象的访问顺序排序;当 accessOrder 的值为 false 时,将按照对象的插入顺序排序。我们上面提到过,按照访问顺序排序,其实就是 LRU。
-
+
如上图,按照缓存的一般设计方式,和 LC 类似,当你向 LinkedHashMap 中添加新对象的时候,就会调用 removeEldestEntry 方法。这个方法默认返回 false,表示永不过期。我们只需要覆盖这个方法,当超出容量的时候返回 true,触发移除动作就可以了。关键代码如下:
public class LRU extends LinkedHashMap {
int capacity;
@@ -276,7 +276,7 @@ static String slowMethod(String key) throws Exception {
相比较 LC,这段代码实现的功能是比较简陋的,它甚至不是线程安全的,但它体现了缓存设计的一般思路,是 Java 中最简单的 LRU 实现方式。
进一步加速
在 Linux 系统中,通过 free 命令,能够看到系统内存的使用状态。其中,有一块叫作 cached 的区域,占用了大量的内存空间。
-
+
如图所示,这个区域,其实就是存放了操作系统的文件缓存,当应用再次用到它的时候,就不用再到磁盘里走一圈,能够从内存里快速载入。
在文件读取的缓存方面,操作系统做得更多。由于磁盘擅长顺序读写,在随机读写的时候,效率很低,所以,操作系统使用了智能的预读算法(readahead),将数据从硬盘中加载到缓存中。
预读算法有三个关键点:
@@ -307,7 +307,7 @@ static String slowMethod(String key) throws Exception {
(3)缓存失效策略
缓存算法也会影响命中率和性能,目前效率最高的算法是 Caffeine 使用的 W-TinyLFU 算法,它的命中率非常高,内存占用也更小。新版本的 spring-cache,已经默认支持 Caffeine。
下图展示了这个算法的性能,从官网的 github 仓库就可以找到 JMH 的测试代码。
-
+
推荐使用 Guava Cache 或者 Caffeine 作为堆内缓存解决方案,然后通过它们提供的一系列监控指标,来调整缓存的大小和内容,一般来说:
- 缓存命中率达到 50% 以上,作用就开始变得显著;
diff --git a/专栏/Java 性能优化实战-完/08 案例分析:Redis 如何助力秒杀业务.md.html b/专栏/Java 性能优化实战-完/08 案例分析:Redis 如何助力秒杀业务.md.html
index 6e52fd69..a4de6504 100644
--- a/专栏/Java 性能优化实战-完/08 案例分析:Redis 如何助力秒杀业务.md.html
+++ b/专栏/Java 性能优化实战-完/08 案例分析:Redis 如何助力秒杀业务.md.html
@@ -164,7 +164,7 @@ function hide_canvas() {
那什么叫分布式缓存呢?它其实是一种集中管理的思想。如果我们的服务有多个节点,堆内缓存在每个节点上都会有一份;而分布式缓存,所有的节点,共用一份缓存,既节约了空间,又减少了管理成本。
在分布式缓存领域,使用最多的就是 Redis。Redis 支持非常丰富的数据类型,包括字符串(string)、列表(list)、集合(set)、有序集合(zset)、哈希表(hash)等常用的数据结构。当然,它也支持一些其他的比如位图(bitmap)一类的数据结构。
说到 Redis,就不得不提一下另外一个分布式缓存 Memcached(以下简称 MC)。MC 现在已经很少用了,但面试的时候经常会问到它们之间的区别,这里简单罗列一下:
-
+
Redis 在互联网中,几乎是标配。我们接下来,先简单看一下 Redis 在 Spring 中是如何使用的,然后,再介绍一下在秒杀业务中,Redis是如何帮助我们承接瞬时流量的。
SpringBoot 如何使用 Redis
使用 SpringBoot 可以很容易地对 Redis 进行操作(完整代码见仓库)。Java 的 Redis的客户端,常用的有三个:jedis、redisson 和 lettuce,Spring 默认使用的是 lettuce。
@@ -176,7 +176,7 @@ function hide_canvas() {
</dependency>
上面这种方式,我们主要是使用 RedisTemplate 这个类。它针对不同的数据类型,抽象了相应的方法组。
-
+
另外一种方式,就是使用 Spring 抽象的缓存包 spring-cache。它使用注解,采用 AOP的方式,对 Cache 层进行了抽象,可以在各种堆内缓存框架和分布式框架之间进行切换。这是它的 maven 坐标:
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -202,7 +202,7 @@ function hide_canvas() {
对于秒杀系统来说,仅仅使用这三个注解是有局限性的,需要使用更加底层的 API,比如 RedisTemplate,来完成逻辑开发,下面就来介绍一些比较重要的功能。
秒杀业务介绍
秒杀,是对正常业务流程的考验。因为它会产生突发流量,平常一天的请求,可能就集中在几秒内就要完成。比如,京东的某些抢购,可能库存就几百个,但是瞬时进入的流量可能是几十上百万。
-
+
如果参与秒杀的人,等待很长时间,体验就非常差,想象一下拥堵的高速公路收费站,就能理解秒杀者的心情。同时,被秒杀的资源会成为热点,发生并发争抢的后果。比如 12306 的抢票,如果单纯使用数据库来接受这些请求,就会产生严重的锁冲突,这也是秒杀业务难的地方。
大家可以回忆一下上一课时的内容,此时,秒杀前端需求与数据库之间的速度是严重不匹配的,而且秒杀的资源是热点资源。这种场景下,采用缓存是非常合适的。
处理秒杀业务有三个绝招:
@@ -219,7 +219,7 @@ function hide_canvas() {
- 抢购阶段,就是我们通常说的秒杀,会产生瞬时的高并发流量,对资源进行集中操作;
- 结束清算,主要完成数据的一致性,处理一些异常情况和回仓操作。
-
+
下面,我将介绍一下最重要的秒杀阶段。
我们可以设计一个 Hash 数据结构,来支持库存的扣减。
seckill:goods:${goodsId}{
@@ -273,13 +273,13 @@ return falseRet
}
执行仓库里的 testSeckill 方法。启动 1000 个线程对 100 个资源进行模拟秒杀,可以看到生成了 100 条记录,同时其他的线程返回的是 0,表示没有秒杀到。
-
+
缓存穿透、击穿和雪崩
抛开秒杀场景,我们再来看一下分布式缓存系统会存在的三大问题: 缓存穿透、缓存击穿和缓存雪崩 。
1.缓存穿透
第一个比较大的问题就是缓存穿透。这个概念比较好理解,和我们上一课时提到的命中率有关。如果命中率很低,那么压力就会集中在数据库持久层。
假如能找到相关数据,我们就可以把它缓存起来。但问题是,本次请求,在缓存和持久层都没有命中,这种情况就叫缓存的穿透。
-
+
举个例子,如上图,在一个登录系统中,有外部攻击,一直尝试使用不存在的用户进行登录,这些用户都是虚拟的,不能有效地被缓存起来,每次都会到数据库中查询一次,最后就会造成服务的性能故障。
解决这个问题有多种方案,我们来简单介绍一下。
第一种就是把空对象缓存起来。不是持久层查不到数据吗?那么我们就可以把本次请求的结果设置为 null,然后放入到缓存中。通过设置合理的过期时间,就可以保证后端数据库的安全。
@@ -292,7 +292,7 @@ return falseRet
3.缓存雪崩
雪崩这个词看着可怕,实际情况也确实比较严重。缓存是用来对系统加速的,后端的数据库只是数据的备份,而不是作为高可用的备选方案。
当缓存系统出现故障,流量会瞬间转移到后端的数据库。过不了多久,数据库将会被大流量压垮挂掉,这种级联式的服务故障,可以形象地称为雪崩。
-
+
缓存的高可用建设是非常重要的。Redis 提供了主从和 Cluster 的模式,其中 Cluster 模式使用简单,每个分片也能单独做主从,可以保证极高的可用性。
另外,我们对数据库的性能瓶颈有一个大体的评估。如果缓存系统当掉,那么流向数据库的请求,就可以使用限流组件,将请求拦截在外面。
缓存一致性
@@ -314,7 +314,7 @@ return falseRet
但这样还是有问题。接下来介绍的场景,也是面试中经常提及的问题。
我们上面提到的缓存删除动作,和数据库的更新动作,明显是不在一个事务里的。如果一个请求删除了缓存,同时有另外一个请求到来,此时发现没有相关的缓存项,就从数据库里加载了一份到缓存系统。接下来,数据库的更新操作也完成了,此时数据库的内容和缓存里的内容,就产生了不一致。
下面这张图,直观地解释了这种不一致的情况,此时,缓存读取 B 操作以及之后的读取操作,都会读到错误的缓存值。
-
+
在面试中,只要你把这个问题给点出来,面试官都会跷起大拇指。
可以使用分布式锁来解决这个问题,将缓存操作和数据库删除操作,与其他的缓存读操作,使用锁进行资源隔离即可。一般来说,读操作是不需要加锁的,它会在遇到锁的时候,重试等待,直到超时。
小结
diff --git a/专栏/Java 性能优化实战-完/09 案例分析:池化对象的应用场景.md.html b/专栏/Java 性能优化实战-完/09 案例分析:池化对象的应用场景.md.html
index f144f2ec..1b098a04 100644
--- a/专栏/Java 性能优化实战-完/09 案例分析:池化对象的应用场景.md.html
+++ b/专栏/Java 性能优化实战-完/09 案例分析:池化对象的应用场景.md.html
@@ -171,9 +171,9 @@ function hide_canvas() {
final GenericObjectPoolConfig<T> config)
Redis 的常用客户端 Jedis,就是使用 Commons Pool 管理连接池的,可以说是一个最佳实践。下图是 Jedis 使用工厂创建对象的主要代码块。对象工厂类最主要的方法就是makeObject,它的返回值是 PooledObject 类型,可以将对象使用 new DefaultPooledObject<>(obj) 进行简单包装返回。
-
+
我们再来介绍一下对象的生成过程,如下图,对象在进行获取时,将首先尝试从对象池里拿出一个,如果对象池中没有空闲的对象,就使用工厂类提供的方法,生成一个新的。
-
+
那对象是存在什么地方的呢?这个存储的职责,就是由一个叫作 LinkedBlockingDeque的结构来承担的,它是一个双向的队列。
接下来看一下 GenericObjectPoolConfig 的主要属性:
private int maxTotal = DEFAULT_MAX_TOTAL;
@@ -196,7 +196,7 @@ private long timeBetweenEvictionRunsMillis = DEFAULT_TIME_BETWEEN_EVICTION_RUNS_
private boolean blockWhenExhausted = DEFAULT_BLOCK_WHEN_EXHAUSTED;
参数很多,要想了解参数的意义,我们首先来看一下一个池化对象在整个池子中的生命周期。如下图所示,池子的操作主要有两个:一个是业务线程,一个是检测线程。
-
+
对象池在进行初始化时,要指定三个主要的参数:
- maxTotal 对象池中管理的对象上限
@@ -206,7 +206,7 @@ private boolean blockWhenExhausted = DEFAULT_BLOCK_WHEN_EXHAUSTED;
其中 maxTotal 和业务线程有关,当业务线程想要获取对象时,会首先检测是否有空闲的对象。如果有,则返回一个;否则进入创建逻辑。此时,如果池中个数已经达到了最大值,就会创建失败,返回空对象。
对象在获取的时候,有一个非常重要的参数,那就是最大等待时间(maxWaitMillis),这个参数对应用方的性能影响是比较大的。该参数默认为 -1,表示永不超时,直到有对象空闲。
如下图,如果对象创建非常缓慢或者使用非常繁忙,业务线程会持续阻塞 (blockWhenExhausted 默认为 true),进而导致正常服务也不能运行。
-
+
一般面试官会问:你会把超时参数设置成多大呢?
我一般都会把最大等待时间,设置成接口可以忍受的最大延迟。比如,一个正常服务响应时间 10ms 左右,达到 1 秒钟就会感觉到卡顿,那么这个参数设置成 500~1000ms 都是可以的。超时之后,会抛出 NoSuchElementException 异常,请求会快速失败,不会影响其他业务线程,这种 Fail Fast 的思想,在互联网应用非常广泛。
带有evcit 字样的参数,主要是处理对象逐出的。池化对象除了初始化和销毁的时候比较昂贵,在运行时也会占用系统资源。比如,连接池会占用多条连接,线程池会增加调度开销等。业务在突发流量下,会申请到超出正常情况的对象资源,放在池子中。等这些对象不再被使用,我们就需要把它清理掉。
@@ -237,11 +237,11 @@ public class JedisPoolVSJedisBenchmark {
...
将测试结果使用 meta-chart 作图,展示结果如下图所示,可以看到使用了连接池的方式,它的吞吐量是未使用连接池方式的 5 倍!
-
+
数据库连接池 HikariCP
HikariCP 源于日语“光”的意思(和光速一样快),它是 SpringBoot 中默认的数据库连接池。数据库是我们工作中经常使用到的组件,针对数据库设计的客户端连接池是非常多的,它的设计原理与我们在本课时开头提到的基本一致,可以有效地减少数据库连接创建、销毁的资源消耗。
同是连接池,它们的性能也是有差别的,下图是 HikariCP 官方的一张测试图,可以看到它优异的性能,官方的 JMH 测试代码见 Github,我也已经拷贝了一份到仓库中。
-
+
一般面试题是这么问的: HikariCP 为什么快呢?主要有三个方面:
- 它使用 FastList 替代 ArrayList,通过初始化的默认值,减少了越界检查的操作;
@@ -255,7 +255,7 @@ public class JedisPoolVSJedisBenchmark {
另外,根据数据库查询和事务类型,一个应用中是可以配置多个数据库连接池的,这个优化技巧很少有人知道,在此简要描述一下。
业务类型通常有两种:一种需要快速的响应时间,把数据尽快返回给用户;另外一种是可以在后台慢慢执行,耗时比较长,对时效性要求不高。如果这两种业务类型,共用一个数据库连接池,就容易发生资源争抢,进而影响接口响应速度。虽然微服务能够解决这种情况,但大多数服务是没有这种条件的,这时就可以对连接池进行拆分。
如图,在同一个业务中,根据业务的属性,我们分了两个连接池,就是来处理这种情况的。
-
+
HikariCP 还提到了另外一个知识点,在 JDBC4 的协议中,通过 Connection.isValid() 就可以检测连接的有效性。这样,我们就不用设置一大堆的 test 参数了,HikariCP 也没有提供这样的参数。
结果缓存池
到了这里你可能会发现池(Pool)与缓存(Cache)有许多相似之处。
@@ -273,7 +273,7 @@ public class JedisPoolVSJedisBenchmark {
将对象池化之后,只是开启了第一步优化。要想达到最优性能,就不得不调整池的一些关键参数,合理的池大小加上合理的超时时间,就可以让池发挥更大的价值。和缓存的命中率类似,对池的监控也是非常重要的。
如下图,可以看到数据库连接池连接数长时间保持在高位不释放,同时等待的线程数急剧增加,这就能帮我们快速定位到数据库的事务问题。
-
+
平常的编码中,有很多类似的场景。比如 Http 连接池,Okhttp 和 Httpclient 就都提供了连接池的概念,你可以类比着去分析一下,关注点也是在连接大小和超时时间上;在底层的中间件,比如 RPC,也通常使用连接池技术加速资源获取,比如 Dubbo 连接池、 Feign 切换成 httppclient 的实现等技术。
你会发现,在不同资源层面的池化设计也是类似的。比如线程池,通过队列对任务进行了二层缓冲,提供了多样的拒绝策略等,线程池我们将在 12 课时进行介绍。线程池的这些特性,你同样可以借鉴到连接池技术中,用来缓解请求溢出,创建一些溢出策略。现实情况中,我们也会这么做。那么具体怎么做?有哪些做法?这部分内容就留给大家思考了,欢迎你在下方留言,与大家一起分享讨论,我也会针对你的思考进行一一点评。
但无论以何种方式处理对象,让对象保持精简,提高它的复用度,都是我们的目标,所以下一课时,我将系统讲解大对象的复用和注意点。
diff --git a/专栏/Java 性能优化实战-完/10 案例分析:大对象复用的目标和注意点.md.html b/专栏/Java 性能优化实战-完/10 案例分析:大对象复用的目标和注意点.md.html
index 32eea30a..a0116344 100644
--- a/专栏/Java 性能优化实战-完/10 案例分析:大对象复用的目标和注意点.md.html
+++ b/专栏/Java 性能优化实战-完/10 案例分析:大对象复用的目标和注意点.md.html
@@ -171,10 +171,10 @@ function hide_canvas() {
String 的 substring 方法
我们都知道,String 在 Java 中是不可变的,如果你改动了其中的内容,它就会生成一个新的字符串。
如果我们想要用到字符串中的一部分数据,就可以使用 substring 方法。
-
+
如上图所示,当我们需要一个子字符串的时候,substring 生成了一个新的字符串,这个字符串通过构造函数的 Arrays.copyOfRange 函数进行构造。
这个函数在 JDK7 之后是没有问题的,但在 JDK6 中,却有着内存泄漏的风险,我们可以学习一下这个案例,来看一下大对象复用可能会产生的问题。
-
+
上图是我从 JDK 官方的一张截图。可以看到,它在创建子字符串的时候,并不只拷贝所需要的对象,而是把整个 value 引用了起来。如果原字符串比较大,即使不再使用,内存也不会释放。
比如,一篇文章内容可能有几兆,我们仅仅是需要其中的摘要信息,也不得不维持整个的大对象。
String content = dao.getArticle(id);
@@ -227,7 +227,7 @@ articles.put(id,summary);
保持合适的对象粒度
给你分享一个实际案例:我们有一个并发量非常高的业务系统,需要频繁使用到用户的基本数据。
如下图所示,由于用户的基本信息,都是存放在另外一个服务中,所以每次用到用户的基本信息,都需要有一次网络交互。更加让人无法接受的是,即使是只需要用户的性别属性,也需要把所有的用户信息查询,拉取一遍。
-
+
为了加快数据的查询速度,根据我们之前 [《08 | 案例分析:Redis 如何助力秒杀业务》]的描述,对数据进行了初步的缓存,放入到了 Redis 中,查询性能有了大的改善,但每次还是要查询很多冗余数据。
原始的 redis key 是这样设计的:
type: string
@@ -271,7 +271,7 @@ String getSex(int userId) {
这些数据,放在堆内内存中,还是过大了。幸运的是,Redis 也支持 Bitmap 结构,如果内存有压力,我们可以把这个结构放到 Redis 中,判断逻辑也是类似的。
再插一道面试算法题:给出一个 1GB 内存的机器,提供 60亿 int 数据,如何快速判断有哪些数据是重复的?
大家可以类比思考一下。Bitmap 是一个比较底层的结构,在它之上还有一个叫作布隆过滤器的结构(Bloom Filter),布隆过滤器可以判断一个值不存在,或者可能存在。
-
+
如图,它相比较 Bitmap,它多了一层 hash 算法。既然是 hash 算法,就会有冲突,所以有可能有多个值落在同一个 bit 上。它不像 HashMap一样,使用链表或者红黑树来处理冲突,而是直接将这个hash槽重复使用。从这个特性我们能够看出,布隆过滤器能够明确表示一个值不在集合中,但无法判断一个值确切的在集合中。
Guava 中有一个 BloomFilter 的类,可以方便地实现相关功能。
上面这种优化方式,本质上也是把大对象变成小对象的方式,在软件设计中有很多类似的思路。比如像一篇新发布的文章,频繁用到的是摘要数据,就不需要把整个文章内容都查询出来;用户的 feed 信息,也只需要保证可见信息的速度,而把完整信息存放在速度较慢的大型存储里。
@@ -280,7 +280,7 @@ String getSex(int userId) {
所谓热数据,就是靠近用户的,被频繁使用的数据;而冷数据是那些访问频率非常低,年代非常久远的数据。
同一句复杂的 SQL,运行在几千万的数据表上,和运行在几百万的数据表上,前者的效果肯定是很差的。所以,虽然你的系统刚开始上线时速度很快,但随着时间的推移,数据量的增加,就会渐渐变得很慢。
冷热分离是把数据分成两份,如下图,一般都会保持一份全量数据,用来做一些耗时的统计操作。
-
+
由于冷热分离在工作中经常遇到,所以面试官会频繁问到数据冷热分离的方案。下面简单介绍三种:
1.数据双写
把对冷热库的插入、更新、删除操作,全部放在一个统一的事务里面。由于热库(比如 MySQL)和冷库(比如 Hbase)的类型不同,这个事务大概率会是分布式事务。在项目初期,这种方式是可行的,但如果是改造一些遗留系统,分布式事务基本上是改不动的,我通常会把这种方案直接废弃掉。
diff --git a/专栏/Java 性能优化实战-完/11 案例分析:如何用设计模式优化性能.md.html b/专栏/Java 性能优化实战-完/11 案例分析:如何用设计模式优化性能.md.html
index fc3bf5a7..d9ce6fd5 100644
--- a/专栏/Java 性能优化实战-完/11 案例分析:如何用设计模式优化性能.md.html
+++ b/专栏/Java 性能优化实战-完/11 案例分析:如何用设计模式优化性能.md.html
@@ -212,11 +212,11 @@ public class AopController {
class com.github.xjjdog.spring.ABean$$EnhancerBySpringCGLIB$$a5d91535 | 1023
下面使用 arthas 分析这个执行过程,找出耗时最高的 AOP 方法。启动 arthas 后,可以从列表中看到我们的应用程序,在这里,输入 2 进入分析界面。
-
+
在终端输入 trace 命令,然后访问 /aop 接口,终端将打印出一些 debug 信息,可以发现耗时操作就是 Spring 的代理类。
trace com.github.xjjdog.spring.ABean method
-
+
代理模式
代理模式(Proxy)可以通过一个代理类,来控制对一个对象的访问。
Java 中实现动态代理主要有两种模式:一种是使用 JDK,另外一种是使用 CGLib。
@@ -242,7 +242,7 @@ ProxyCreateBenchmark.jdk thrpt 10 15612.467 ± 268.362 ops/ms
当指定为单例时(默认行为),在 Spring 容器中,组件有且只有一份,当你注入相关组件的时候,获取的组件实例也是同一份。
如果是普通的单例类,我们通常将单例的构造方法设置成私有的,单例有懒汉加载和饿汉加载模式。
了解 JVM 类加载机制的同学都知道,一个类从加载到初始化,要经历 5 个步骤:加载、验证、准备、解析、初始化。
-
+
其中,static 字段和 static 代码块,是属于类的,在类加载的初始化阶段就已经被执行。它在字节码中对应的是 方法,属于类的(构造方法)。因为类的初始化只有一次,所以它就能够保证这个加载动作是线程安全的。
根据以上原理,只要把单例的初始化动作,放在方法里,就能够实现饿汉模式。
private static Singleton instace = new Singleton();
@@ -250,7 +250,7 @@ ProxyCreateBenchmark.jdk thrpt 10 15612.467 ± 268.362 ops/ms
饿汉模式在代码里用的很少,它会造成资源的浪费,生成很多可能永远不会用到的对象。
而对象初始化就不一样了。通常,我们在 new 一个新对象的时候,都会调用它的构造方法,就是,用来初始化对象的属性。由于在同一时刻,多个线程可以同时调用函数,我们就需要使用 synchronized 关键字对生成过程进行同步。
目前,公认的兼顾线程安全和效率的单例模式,就是 double check。很多面试官,会要求你手写,并分析 double check 的原理。
-
+
如上图,是 double check 的关键代码,我们介绍一下四个关键点:
- 第一次检查,当 instance 为 null 的时候,进入对象实例化逻辑,否则直接返回。
diff --git a/专栏/Java 性能优化实战-完/12 案例分析:并行计算让代码“飞”起来.md.html b/专栏/Java 性能优化实战-完/12 案例分析:并行计算让代码“飞”起来.md.html
index 5197388d..926e9277 100644
--- a/专栏/Java 性能优化实战-完/12 案例分析:并行计算让代码“飞”起来.md.html
+++ b/专栏/Java 性能优化实战-完/12 案例分析:并行计算让代码“飞”起来.md.html
@@ -165,7 +165,7 @@ function hide_canvas() {
并行获取数据
考虑到下面一种场景。有一个用户数据接口,要求在 50ms 内返回数据。它的调用逻辑非常复杂,打交道的接口也非常多,需要从 20 多个接口汇总数据。这些接口,最小的耗时也要 20ms,哪怕全部都是最优状态,算下来也需要 20*20 = 400ms。
如下图,解决的方式只有并行,通过多线程同时去获取计算结果,最后进行结果拼接。
-
+
但这种编程模型太复杂了,如果使用原始的线程 API,或者使用 wait、notify 等函数,代码的复杂度可以想象有多大。但幸运的是,现在 Java 中的大多数并发编程场景,都可以使用 concurrent 包的一些工具类来实现。
在这种场景中,我们就可以使用 CountDownLatch 完成操作。CountDownLatch 本质上是一个计数器,我们把它初始化为与执行任务相同的数量。当一个任务执行完时,就将计数器的值减 1,直到计数器值达到 0 时,表示完成了所有的任务,在 await 上等待的线程就可以继续执行下去。
下面这段代码,是我专门为这个场景封装的一个工具类。它传入了两个参数:一个是要计算的 job 数量,另外一个是整个大任务超时的毫秒数。
@@ -258,7 +258,7 @@ function hide_canvas() {
前几个参数没有什么好说的,相对于普通对象池而言,由于线程资源总是有效,它甚至少了非常多的 Idle 配置参数(与对象池比较),我们主要来看一下 workQueue 和 handler。
关于任务的创建过程,可以说是多线程每次必问的问题了。如下图所示,任务被提交后,首先判断它是否达到了最小线程数(coreSize),如果达到了,就将任务缓存在任务队列里。如果队列也满了,会判断线程数量是否达到了最大线程数(maximumPoolSize),如果也达到了,就会进入任务的拒绝策略(handler)。
-
+
我们来看一下 Executors 工厂类中默认的几个快捷线程池代码。
1.固定大小线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
@@ -290,7 +290,7 @@ function hide_canvas() {
SpringBoot 中可以非常容易地实现异步任务。
首先,我们需要在启动类上加上 @EnableAsync 注解,然后在需要异步执行的方法上加上 @Async 注解。一般情况下,我们的任务直接在后台运行就可以,但有些任务需要返回一些数据,这个时候,就可以使用 Future 返回一个代理,供其他的代码使用。
关键代码如下:
-
+
默认情况下,Spring 将启动一个默认的线程池供异步任务使用。这个线程池也是无限大的,资源使用不可控,所以强烈建议你使用代码设置一个适合自己的。
@Bean
public ThreadPoolTaskExecutor getThreadPoolTaskExecutor() {
@@ -317,7 +317,7 @@ public ThreadPoolTaskExecutor getThreadPoolTaskExecutor() {
下面以一个经常发生问题的案例,来说一下线程安全的重要性。
SimpleDateFormat 是我们经常用到的日期处理类,但它本身不是线程安全的,在多线程运行环境下,会产生很多问题,在以往的工作中,通过 sonar 扫描,我发现这种误用的情况特别的多。在面试中,我也会专门问到 SimpleDateFormat,用来判断面试者是否具有基本的多线程编程意识。
-
+
执行上图的代码,可以看到,时间已经错乱了。
Thu May 01 08:56:40 CST 618104
Thu May 01 08:56:40 CST 618104
@@ -329,7 +329,7 @@ Wed Dec 25 08:56:40 CST 2019
Sun Jul 13 01:55:40 CST 20220200
解决方式就是使用 ThreadLocal 局部变量,代码如下图所示,可以有效地解决线程安全问题。
-
+
2.线程的同步方式
Java 中实现线程同步的方式有很多,大体可以分为以下 8 类。
@@ -343,7 +343,7 @@ Sun Jul 13 01:55:40 CST 20220200
- 使用 Thread 类的 join 方法,可以让多线程按照指定的顺序执行。
下面的截图,是使用 LinkedBlockingQueue 实现的一个简单生产者和消费者实例,在很多互联网的笔试环节,这个题目会经常出现。 可以看到,我们还使用了一个 volatile 修饰的变量,来决定程序是否继续运行,这也是 volatile 变量的常用场景。
-
+
FastThreadLocal
在我们平常的编程中,使用最多的就是 ThreadLocal 类了。拿最常用的 Spring 来说,它事务管理的传播机制,就是使用 ThreadLocal 实现的。因为 ThreadLocal 是线程私有的,所以 Spring 的事务传播机制是不能够跨线程的。在问到 Spring 事务管理是否包含子线程时,要能够想到面试官的真实意图。
/**
@@ -368,10 +368,10 @@ ThreadLocalMap getMap(Thread t) {
}
问题就出在 ThreadLocalMap 类上,它虽然叫 Map,但却没有实现 Map 的接口。如下图,ThreadLocalMap 在 rehash 的时候,并没有采用类似 HashMap 的数组+链表+红黑树的做法,它只使用了一个数组,使用开放寻址(遇到冲突,依次查找,直到空闲位置)的方法,这种方式是非常低效的。
-
+
由于 Netty 对 ThreadLocal 的使用非常频繁,Netty 对它进行了专项的优化。它之所以快,是因为在底层数据结构上做了文章,使用常量下标对元素进行定位,而不是使用JDK 默认的探测性算法。
还记得《03 | 深入剖析:哪些资源,容易成为瓶颈?》提到的伪共享问题吗?底层的 InternalThreadLocalMap对cacheline 也做了相应的优化。
-
+
你在多线程使用中都遇到过哪些问题?
通过上面的知识总结,可以看到多线程相关的编程,是属于比较高阶的技能。面试中,面试官会经常问你在多线程使用中遇到的一些问题,以此来判断你实际的应用情况。
我们先总结一下文中已经给出的示例:
@@ -398,7 +398,7 @@ executor.submit( ()-> {
executor.shutdown();
我们跟踪任务的执行,在 ThreadPoolExecutor 类中可以找到任务发生异常时的方法,它是抛给了 afterExecute 方法进行处理。
-
+
可惜的是,ThreadPoolExecutor 中的 afterExecute 方法是没有任何实现的,它是个空方法。
protected void afterExecute(Runnable r, Throwable t) { }
@@ -409,7 +409,7 @@ executor.shutdown();
其实这是部分同学对“异步作用”的错误理解。异步是一种编程模型,它通过将耗时的操作转移到后台线程运行,从而减少对主业务的堵塞,所以我们说异步让速度变快了。但如果你的系统资源使用已经到了极限,异步就不能产生任何效果了,它主要优化的是那些阻塞性的等待。
在我们前面的课程里,缓冲、缓存、池化等优化方法,都是用到了异步。它能够起到转移冲突,优化请求响应的作用。由于合理地利用了资源,我们的系统响应确实变快了, 之后的《15 | 案例分析:从 BIO 到 NIO,再到 AIO》会对此有更多讲解。
异步还能够对业务进行解耦,如下图所示,它比较像是生产者消费者模型。主线程负责生产任务,并将它存放在待执行列表中;消费线程池负责任务的消费,进行真正的业务逻辑处理。
-
+
小结
多线程的话题很大,本课时的内容稍微多,我们简单总结一下课时重点。
本课时默认你已经有了多线程的基础知识(否则看起来会比较吃力),所以我们从 CountDownLatch 的一个实际应用场景说起,谈到了线程池的两个重点:阻塞队列和拒绝策略。
diff --git a/专栏/Java 性能优化实战-完/13 案例分析:多线程锁的优化.md.html b/专栏/Java 性能优化实战-完/13 案例分析:多线程锁的优化.md.html
index e7c9abed..d000ef11 100644
--- a/专栏/Java 性能优化实战-完/13 案例分析:多线程锁的优化.md.html
+++ b/专栏/Java 性能优化实战-完/13 案例分析:多线程锁的优化.md.html
@@ -161,7 +161,7 @@ function hide_canvas() {
13 案例分析:多线程锁的优化
我们在上一课时,了解到可以使用 ThreadLocal,来避免 SimpleDateFormat 在并发环境下引起的时间错乱问题。其实还有一种解决方式,就是通过对parse 方法进行加锁,也能保证日期处理类的正确运行,代码如下图(可见仓库):
-
+
其实锁对性能的影响,是非常大的。因为对资源加锁以后,资源就被加锁的线程独占,其他的线程就只能排队等待这个锁,此时程序由并行执行,变相地成了顺序执行,执行速度自然就降低了。
下面是开启了 50 个线程,使用 ThreadLocal 和同步锁方式性能的一个对比。
Benchmark Mode Cnt Score Error Units
@@ -229,7 +229,7 @@ void syncBlock();
这两者虽然显示效果不同,但他们都是通过 monitor 来实现同步的。我们可以通过下面这张图,来看一下 monitor 的原理。
注意了,下面是面试题目高发地。比如,你能描述一下 monitor 锁的实现原理吗?
-
+
如上图所示,我们可以把运行时的对象锁抽象地分成三部分。其中,EntrySet 和 WaitSet 是两个队列,中间虚线部分是当前持有锁的线程,我们可以想象一下线程的执行过程。
当第一个线程到来时,发现并没有线程持有对象锁,它会直接成为活动线程,进入 RUNNING 状态。
接着又来了三个线程,要争抢对象锁。此时,这三个线程发现锁已经被占用了,就先进入 EntrySet 缓存起来,进入 BLOCKED 状态。此时,从 jstack 命令,可以看到他们展示的信息都是 waiting for monitor entry。
@@ -271,7 +271,7 @@ void syncBlock();
在 JDK 1.8 中,synchronized 的速度已经有了显著的提升,它都做了哪些优化呢?答案就是分级锁。JVM 会根据使用情况,对 synchronized 的锁,进行升级,它大体可以按照下面的路径进行升级:偏向锁 — 轻量级锁 — 重量级锁。
锁只能升级,不能降级,所以一旦升级为重量级锁,就只能依靠操作系统进行调度。
要想了解锁升级的过程,需要先看一下对象在内存里的结构。
-
+
如上图所示,对象分为 MarkWord、Class Pointer、Instance Data、Padding 四个部分。
和锁升级关系最大的就是 MarkWord,它的长度是 24 位,我们着重介绍一下。它包含Thread ID(23bit)、Age(6bit)、Biased(1bit)、Tag(2bit) 四个部分,锁升级就是靠判断 Thread Id、Biased、Tag 等三个变量值来进行的。
@@ -396,7 +396,7 @@ FairVSNoFairBenchmark.nofair thrpt 10 35195.649 ± 6503.375 ops/ms
2.优化技巧
锁的优化理论其实很简单,那就是减少锁的冲突。无论是锁的读写分离,还是分段锁,本质上都是为了避免多个线程同时获取同一把锁。
所以我们可以总结一下优化的一般思路:减少锁的粒度、减少锁持有的时间、锁分级、锁分离 、锁消除、乐观锁、无锁等。
-
+
- 减少锁粒度
diff --git a/专栏/Java 性能优化实战-完/14 案例分析:乐观锁和无锁.md.html b/专栏/Java 性能优化实战-完/14 案例分析:乐观锁和无锁.md.html
index a0c88b8c..a599954e 100644
--- a/专栏/Java 性能优化实战-完/14 案例分析:乐观锁和无锁.md.html
+++ b/专栏/Java 性能优化实战-完/14 案例分析:乐观锁和无锁.md.html
@@ -166,7 +166,7 @@ function hide_canvas() {
CAS
CAS 是 Compare And Swap 的缩写,意思是比较并替换。
如下图,CAS 机制当中使用了 3 个基本操作数:内存地址V、期望值E、要修改的新值N。更新一个变量的时候,只有当变量的预期值E 和内存地址V 的真正值相同时,才会将内存地址V 对应的值修改为 N。
-
+
如果本次修改不成功,怎么办?很多情况下,它将一直重试,直到修改为期望的值。
拿 AtomicInteger 类来说,相关的代码如下:
public final boolean compareAndSet(int expectedValue, int newValue) {
@@ -201,7 +201,7 @@ inline T Atomic::PlatformCmpxchg<4>::operator()(T exchange_value,
那 CAS 实现的原子类,性能能提升多少呢?我们开启了 20 个线程,对共享变量进行自增操作。
从测试结果得知,针对频繁的写操作,原子类的性能是 synchronized 方式的 3 倍。
-
+
CAS 原理,在近几年面试中的考察率越来越高,主要是由于乐观锁在读多写少的互联网场景中,使用频率愈发频繁。
你可能发现有一些乐观锁的变种,但最基础的思想是一样的,都是基于比较替换并替换的基本操作。
关于 Atomic 类,还有一个小细节,那就是它的主要变量,使用了 volatile 关键字进行修饰。代码如下,你知道它是用来干什么的吗?
@@ -347,7 +347,7 @@ try {
}
使用 redis 的 monitor 命令,可以看到具体的执行步骤,这个过程还是比较复杂的。
-
+
无锁
无锁(Lock-Free),指的是在多线程环境下,在访问共享资源的时候,不会阻塞其他线程的执行。
在 Java 中,最典型的无锁队列实现,就是 ConcurrentLinkedQueue,但它是无界的,不能够指定它的大小。ConcurrentLinkedQueue 使用 CAS 来处理对数据的并发访问,这是无锁算法得以实现的基础。
diff --git a/专栏/Java 性能优化实战-完/15 案例分析:从 BIO 到 NIO,再到 AIO.md.html b/专栏/Java 性能优化实战-完/15 案例分析:从 BIO 到 NIO,再到 AIO.md.html
index f0b517a2..4621c4d0 100644
--- a/专栏/Java 性能优化实战-完/15 案例分析:从 BIO 到 NIO,再到 AIO.md.html
+++ b/专栏/Java 性能优化实战-完/15 案例分析:从 BIO 到 NIO,再到 AIO.md.html
@@ -163,7 +163,7 @@ function hide_canvas() {
Netty 的高性能架构,是基于一个网络编程设计模式 Reactor 进行设计的。现在,大多数与 I/O 相关的组件,都会使用 Reactor 模型,比如 Tomcat、Redis、Nginx 等,可见 Reactor 应用的广泛性。
Reactor 是 NIO 的基础。为什么 NIO 的性能就能够比传统的阻塞 I/O 性能高呢?我们首先来看一下传统阻塞式 I/O 的一些特点。
阻塞 I/O 模型
-
+
如上图,是典型的BIO 模型,每当有一个连接到来,经过协调器的处理,就开启一个对应的线程进行接管。如果连接有 1000 条,那就需要 1000 个线程。
线程资源是非常昂贵的,除了占用大量的内存,还会占用非常多的 CPU 调度时间,所以 BIO 在连接非常多的情况下,效率会变得非常低。
下面的代码是使用 ServerSocket 实现的一个简单 Socket 服务器,监听在 8888 端口。
@@ -207,7 +207,7 @@ nice
PONG:nice
使用 “04 | 工具实践:如何获取代码性能数据?”提到的 JMC 工具,在录制期间发起多个连接,能够发现有多个线程在运行,和连接数是一一对应的。
-
+
可以看到,BIO 的读写操作是阻塞的,线程的整个生命周期和连接的生命周期是一样的,而且不能够被复用。
就单个阻塞 I/O 来说,它的效率并不比 NIO 慢。但是当服务的连接增多,考虑到整个服务器的资源调度和资源利用率等因素,NIO 就有了显著的效果,NIO 非常适合高并发场景。
非阻塞 I/O 模型
@@ -294,7 +294,7 @@ ssc.register(selector, ssc.validOps());
- 写就绪事件(OP_WRITE)。
任何网络和文件操作,都可以抽象成这四个事件。
-
+
接下来,在 while 循环里,使用 select 函数,阻塞在主线程里。所谓阻塞,就是操作系统不再分配 CPU 时间片到当前线程中,所以 select 函数是几乎不占用任何系统资源的。
int num = selector.select();
@@ -333,7 +333,7 @@ int size = sc.read(buf);
Reactor 模式
了解了 BIO 和 NIO 的一些使用方式,Reactor 模式就呼之欲出了。
NIO 是基于事件机制的,有一个叫作 Selector 的选择器,阻塞获取关注的事件列表。获取到事件列表后,可以通过分发器,进行真正的数据操作。
-
+
该图来自 Doug Lea 的《Scalable IO in Java》,该图指明了最简单的 Reactor 模型的基本元素。
@@ -346,7 +346,7 @@ int size = sc.read(buf);
- Reactor将具体的事件分配(dispatch)给 Handler。
我们可以对上面的模型进行进一步细化,如下图所示,将 Reactor 分为 mainReactor 和 subReactor 两部分。
-
+
该图来自 Doug Lea 的 《Scalable IO in Java》
@@ -413,7 +413,7 @@ int size = sc.read(buf);
所以,市面上对 AIO 的实践并不多,在采用技术选型的时候,一定要谨慎。
响应式编程
你可能听说过 Spring 5.0 的 WebFlux,WebFlux 是可以替代 Spring MVC 的一套解决方案,可以编写响应式的应用,两者之间的关系如下图所示:
-
+
Spring WebFlux 的底层使用的是 Netty,所以操作是异步非阻塞的,类似的组件还有 vert.x、akka、rxjava 等。
WebFlux 是运行在 project reactor 之上的一个封装,其根本特性是后者提供的,至于再底层的非阻塞模型,就是由 Netty 保证的了。
非阻塞的特性我们可以理解,那响应式又是什么概念呢?
diff --git a/专栏/Java 性能优化实战-完/16 案例分析:常见 Java 代码优化法则.md.html b/专栏/Java 性能优化实战-完/16 案例分析:常见 Java 代码优化法则.md.html
index 86ff3036..ae90387c 100644
--- a/专栏/Java 性能优化实战-完/16 案例分析:常见 Java 代码优化法则.md.html
+++ b/专栏/Java 性能优化实战-完/16 案例分析:常见 Java 代码优化法则.md.html
@@ -441,7 +441,7 @@ RegexVsRagelBenchmark.regex thrpt 10 201.322 ± 47.056 ops/ms
案例 2:HikariCP 的字节码修改
在 “09 | 案例分析:池化对象的应用场景” 中,我们提到了 HikariCP 对字节码的修改,这个职责是由 JavassistProxyFactory 类来管理的。Javassist 是一个字节码类库,HikariCP 就是用它对字节码进行修改。
如下图所示,这是工厂类的主要方法。
-
+
它通过 generateProxyClass 生成代理类,主要是针对 Connection、Statement、ResultSet、DatabaseMetaData 等 jdbc 的核心接口。
右键运行这个类,可以看到代码生成了一堆 Class 文件。
Generating com.zaxxer.hikari.pool.HikariProxyConnection
@@ -453,7 +453,7 @@ Generating com.zaxxer.hikari.pool.HikariProxyCallableStatement
Generating method bodies for com.zaxxer.hikari.proxy.ProxyFactory
对于这一部分的代码组织,使用了设计模式中的委托模式。我们发现 HikariCP 源码中的代理类,比如 ProxyConnection,都是 abstract 的,它的具体实例就是使用 javassist 生成的 class 文件。反编译这些生成的 class 文件,可以看到它实际上是通过调用父类中的委托对象进行处理的。
-
+
这么做有两个好处:
- 第一,在代码中只需要实现需要修改的 JDBC 接口方法,其他的交给代理类自动生成的代码,极大地减少了编码数量。
diff --git a/专栏/Java 性能优化实战-完/17 高级进阶:JVM 如何完成垃圾回收?.md.html b/专栏/Java 性能优化实战-完/17 高级进阶:JVM 如何完成垃圾回收?.md.html
index 6b6a469a..ecf436a7 100644
--- a/专栏/Java 性能优化实战-完/17 高级进阶:JVM 如何完成垃圾回收?.md.html
+++ b/专栏/Java 性能优化实战-完/17 高级进阶:JVM 如何完成垃圾回收?.md.html
@@ -165,7 +165,7 @@ function hide_canvas() {
另外,本课时的知识点,全部是面试的高频题目,这也从侧面看出 JVM 理论知识的重要性。
JVM 内存区域划分
学习 JVM,内存区域划分是绕不过去的知识点,这几乎是面试必考的题目。如下图所示,内存区域划分主要包括堆、Java 虚拟机栈、程序计数器、本地方法栈、元空间和直接内存这五部分,我将逐一介绍。
-
+
JVM 内存区域划分图
1.堆
如 JVM 内存区域划分图所示,JVM 中占用内存最大的区域,就是堆(Heap),我们平常编码创建的对象,大多数是在这上面分配的,也是垃圾回收器回收的主要目标区域。
@@ -173,7 +173,7 @@ function hide_canvas() {
JVM 的解释过程是基于栈的,程序的执行过程也就是入栈出栈的过程,这也是 Java 虚拟机栈这个名称的由来。
Java 虚拟机栈是和线程相关的。当你启动一个新的线程,Java 就会为它分配一个虚拟机栈,之后所有这个线程的运行,都会在栈里进行。
Java 虚拟机栈,从方法入栈到具体的字节码执行,其实是一个双层的栈结构,也就是栈里面还包含栈。
-
+
Java 虚拟机栈图
如上图,Java 虚拟机栈里的每一个元素,叫作栈帧。每一个栈帧,包含四个区域: 局部变量表 、操作数栈、动态连接和返回地址。
其中,操作数栈就是具体的字节码指令所操作的栈区域,考虑到下面这段代码:
@@ -256,7 +256,7 @@ function hide_canvas() {
这个假设我们称之为弱代假设(weak generational hypothesis)。
如下图,分代垃圾回收器会在逻辑上,把堆空间分为两部分:年轻代(Young generation)和老年代(Old generation)。
-
+
堆空间划分图:年轻代和老年代
1.年轻代
年轻代中又分为一个伊甸园空间(Eden),两个幸存者空间(Survivor)。对象会首先在年轻代中的 Eden 区进行分配,当 Eden 区分配满的时候,就会触发年轻代的 GC。
@@ -282,7 +282,7 @@ function hide_canvas() {
有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如 G1,通过 TargetSurvivorRatio 这个参数,动态更改对象提升的阈值。
老年代的空间一般比较大,回收的时间更长,当老年代的空间被占满了,将发生老年代垃圾回收。
目前,被广泛使用的是 G1 垃圾回收器。G1 的目标是用来干掉 CMS 的,它同样有年轻代和老年代的概念。不过,G1 把整个堆切成了很多份,把每一份当作一个小目标,部分上目标很容易达成。
-
+
如上图,G1 也是有 Eden 区和 Survivor 区的概念的,只不过它们在内存上不是连续的,而是由一小份一小份组成的。G1 在进行垃圾回收的时候,将会根据最大停顿时间(MaxGCPauseMillis)设置的值,动态地选取部分小堆区进行垃圾回收。
G1 的配置非常简单,我们只需要配置三个参数,一般就可以获取优异的性能:
① MaxGCPauseMillis 设置最大停顿的预定目标,G1 垃圾回收器会自动调整,选取特定的小堆区;
diff --git a/专栏/Java 性能优化实战-完/18 高级进阶:JIT 如何影响 JVM 的性能?.md.html b/专栏/Java 性能优化实战-完/18 高级进阶:JIT 如何影响 JVM 的性能?.md.html
index d96747f1..d01f3b20 100644
--- a/专栏/Java 性能优化实战-完/18 高级进阶:JIT 如何影响 JVM 的性能?.md.html
+++ b/专栏/Java 性能优化实战-完/18 高级进阶:JIT 如何影响 JVM 的性能?.md.html
@@ -161,7 +161,7 @@ function hide_canvas() {
18 高级进阶:JIT 如何影响 JVM 的性能?
我们在上一课时,我们了解到 Java 虚拟机栈,其实是一个双层的栈,如下图所示,第一层就是针对 method 的栈帧,第二层是针对字节码指令的操作数栈。
-
+
Java 虚拟机栈图
栈帧的创建是需要耗费资源的,尤其是对于 Java 中常见的 getter、setter 方法来说,这些代码通常只有一行,每次都创建栈帧的话就太浪费了。
另外,Java 虚拟机栈对代码的执行,采用的是字节码解释的方式,考虑到下面这段代码,变量 a 声明之后,就再也不被使用,要是按照字节码指令解释执行的话,就要做很多无用功。
@@ -194,7 +194,7 @@ function hide_canvas() {
另外,我们了解到垃圾回收器回收的目标区域主要是堆,堆上创建的对象越多,GC 的压力就越大。要是能把一些变量,直接在栈上分配,那 GC 的压力就会小一些。
其实,我们说的这几个优化的可能性,JVM 已经通过 JIT 编译器(Just In Time Compiler)去做了,JIT 最主要的目标是把解释执行变成编译执行。
为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,这就是 JIT 编译器的功能。
-
+
如上图,JVM 会将调用次数很高,或者在 for 循环里频繁被使用的代码,编译成机器码,然后缓存在 CodeCache 区域里,下次调用相同方法的时候,就可以直接使用。
那 JIT 编译都有哪些手段呢?接下来我们详细介绍。
方法内联
@@ -230,7 +230,7 @@ JMHSample_16_CompilerControl.dontinline avgt 3 1.934 ± 3.112 ns/op
JMHSample_16_CompilerControl.exclude avgt 3 57.603 ± 4.435 ns/op
JMHSample_16_CompilerControl.inline avgt 3 0.483 ± 1.520 ns/op
-
+
JIT 编译之后的二进制代码,是放在 Code Cache 区域里的。这个区域的大小是固定的,而且一旦启动无法扩容。如果 Code Cache 满了,JVM 并不会报错,但会停止编译。所以编译执行就会退化为解释执行,性能就会降低。不仅如此,JIT 编译器会一直尝试去优化你的代码,造成 CPU 占用上升。
通过参数 -XX:ReservedCodeCacheSize 可以指定 Code Cache 区域的大小,如果你通过监控发现空间达到了上限,就要适当的增加它的大小。
编译层次
@@ -308,7 +308,7 @@ BuilderVsBufferBenchmark.builder thrpt 10 103280.200 ± 76172.538 ops/ms
-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jitdemo.log
使用 jitwatch 工具,可打开这个文件,看到详细的编译结果。
-
+
下面是一段测试代码:
public class SimpleInliningTest {
public SimpleInliningTest() {
@@ -329,7 +329,7 @@ BuilderVsBufferBenchmark.builder thrpt 10 103280.200 ± 76172.538 ops/ms
}
从执行后的结果可以看到,热点 for 循环已经使用 JIT 进行了编译,而里面应用的 add 方法,也已经被内联。
-
+
小结
JIT 是现代 JVM 主要的优化点,能够显著地提升程序的执行效率。从解释执行到最高层次的 C2,一个数量级的性能提升也是有可能的。但即时编译的过程是非常缓慢的,既耗时间也费空间,所以这些优化操作会和解释执行同时进行。
值得注意的是,JIT 在某些情况下还会出现逆优化。比如一些热部署方式触发的 redefineClass,就会造成 JIT 编译结果的失效,相关的内联代码也需要重新生成。
diff --git a/专栏/Java 性能优化实战-完/19 高级进阶:JVM 常见优化参数.md.html b/专栏/Java 性能优化实战-完/19 高级进阶:JVM 常见优化参数.md.html
index 951c30f6..2997f0c7 100644
--- a/专栏/Java 性能优化实战-完/19 高级进阶:JVM 常见优化参数.md.html
+++ b/专栏/Java 性能优化实战-完/19 高级进阶:JVM 常见优化参数.md.html
@@ -188,7 +188,7 @@ OpenJDK 64-Bit Server VM (build 25.40-b25, mixed mode)
其实,通过 Xmx 指定了的堆内存,只有在 JVM 真正使用的时候,才会进行分配。这个参数,在 JVM 启动的时候,就把它所有的内存在操作系统分配了。在堆比较大的时候,会加大启动时间,但它能够减少内存动态分配的性能损耗,提高运行时的速度。
如下图,JVM 的内存,分为堆和堆外内存,其中堆的大小可以通过 Xmx 和 Xms 来配置。
-
+
但我们在配置 ES 的堆内存时,通常把堆的初始化大小,设置成物理内存的一半。这是因为 ES 是存储类型的服务,我们需要预留一半的内存给文件缓存(理论参见 “07 | 案例分析:无处不在的缓存,高并发系统的法宝”),等下次用到相同的文件时,就不用与磁盘进行频繁的交互。这一块区域一般叫作 PageCache,占用的空间很大。
对于计算型节点来说,比如我们普通的 Web 服务,通常会把堆内存设置为物理内存的 2/3,剩下的 1/3 就是给堆外内存使用的。
我们这张图,对堆外内存进行了非常细致的划分,解释如下:
diff --git a/专栏/Java 性能优化实战-完/20 SpringBoot 服务性能优化.md.html b/专栏/Java 性能优化实战-完/20 SpringBoot 服务性能优化.md.html
index 264de25b..b50ac1d5 100644
--- a/专栏/Java 性能优化实战-完/20 SpringBoot 服务性能优化.md.html
+++ b/专栏/Java 性能优化实战-完/20 SpringBoot 服务性能优化.md.html
@@ -184,7 +184,7 @@ management.endpoint.prometheus.enabled=true
management.metrics.export.prometheus.enabled=true
启动之后,我们就可以通过访问监控接口来获取监控数据。
-
+
想要监控业务数据也是比较简单的,你只需要注入一个 MeterRegistry 实例即可,下面是一段示例代码:
@Autowired
MeterRegistry registry;
@@ -202,10 +202,10 @@ public String test() {
test_total{from="127.0.0.1",method="test",} 5.0
这里简单介绍一下流行的Prometheus 监控体系,Prometheus 使用拉的方式获取监控数据,这个暴露数据的过程可以交给功能更加齐全的 telegraf 组件。
-
+
如上图,我们通常使用 Grafana 进行监控数据的展示,使用 AlertManager 组件进行提前预警。这一部分的搭建工作不是我们的重点,感兴趣的同学可自行研究。
下图便是一张典型的监控图,可以看到 Redis 的缓存命中率等情况。
-
+
Java 生成火焰图
火焰图是用来分析程序运行瓶颈的工具。
火焰图也可以用来分析 Java 应用。可以从 github 上下载 async-profiler 的压缩包进行相关操作。比如,我们把它解压到 /root/ 目录,然后以 javaagent 的方式来启动 Java 应用,命令行如下:
@@ -213,11 +213,11 @@ public String test() {
运行一段时间后,停止进程,可以看到在当前目录下,生成了 profile.svg 文件,这个文件是可以用浏览器打开的。
如下图所示,纵向,表示的是调用栈的深度;横向,表明的是消耗的时间。所以格子的宽度越大,越说明它可能是一个瓶颈。一层层向下浏览,即可找到需要优化的目标。
-
+
优化思路
对一个普通的 Web 服务来说,我们来看一下,要访问到具体的数据,都要经历哪些主要的环节?
如下图,在浏览器中输入相应的域名,需要通过 DNS 解析到具体的 IP 地址上,为了保证高可用,我们的服务一般都会部署多份,然后使用 Nginx 做反向代理和负载均衡。
-
+
Nginx 根据资源的特性,会承担一部分动静分离的功能。其中,动态功能部分,会进入我们的SpringBoot 服务。
SpringBoot 默认使用内嵌的 tomcat 作为 Web 容器,使用典型的 MVC 模式,最终访问到我们的数据。
HTTP 优化
@@ -349,7 +349,7 @@ Transfer/sec: 1.51MB
java -javaagent:/opt/skywalking-agent/skywalking-agent.jar -Dskywalking.agent.service_name=the-demo-name -jar /opt/test-service/spring-boot-demo.ja --spring.profiles.active=dev
访问一些服务的链接,打开 Skywalking 的 UI,即可看到下图的界面。这些指标可以类比“01 | 理论分析:性能优化,有哪些衡量指标?需要注意什么?”提到的衡量指标去理解,我们就可以从图中找到响应比较慢 QPS 又比较高的接口,进行专项优化。
-
+
各个层次的优化方向
1.Controller 层
controller 层用于接收前端的查询参数,然后构造查询结果。现在很多项目都采用前后端分离的架构,所以 controller 层的方法,一般会使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回(兼顾效率和可读性)。
@@ -363,10 +363,10 @@ Transfer/sec: 1.51MB
service 层的代码组织,对代码的可读性、性能影响都比较大。我们常说的设计模式,大多数都是针对 service 层来说的。
service 层会频繁使用更底层的资源,通过组合的方式获取我们所需要的数据,大多数可以通过我们前面课时提供的优化思路进行优化。
这里要着重提到的一点,就是分布式事务。
-
+
如上图,四个操作分散在三个不同的资源中。要想达到一致性,需要三个不同的资源 MySQL、MQ、ElasticSearch 进行统一协调。它们底层的协议,以及实现方式,都是不一样的,那就无法通过 Spring 提供的 Transaction 注解来解决,需要借助外部的组件来完成。
很多人都体验过,加入了一些保证一致性的代码,一压测,性能掉的惊掉下巴。分布式事务是性能杀手,因为它要使用额外的步骤去保证一致性,常用的方法有:两阶段提交方案、TCC、本地消息表、MQ 事务消息、分布式事务中间件等。
-
+
如上图,分布式事务要在改造成本、性能、时效等方面进行综合考虑。有一个介于分布式事务和非事务之间的名词,叫作柔性事务。柔性事务的理念是将业务逻辑和互斥操作,从资源层上移至业务层面。
关于传统事务和柔性事务,我们来简单比较一下。
ACID
diff --git a/专栏/Java 性能优化实战-完/21 性能优化的过程方法与求职面经总结.md.html b/专栏/Java 性能优化实战-完/21 性能优化的过程方法与求职面经总结.md.html
index 97ece631..bd4de619 100644
--- a/专栏/Java 性能优化实战-完/21 性能优化的过程方法与求职面经总结.md.html
+++ b/专栏/Java 性能优化实战-完/21 性能优化的过程方法与求职面经总结.md.html
@@ -191,7 +191,7 @@ function hide_canvas() {
5.通用
lsof 命令可以查看当前进程所关联的所有资源;sysctl 命令可以查看当前系统内核的配置参数; dmesg 命令可以显示系统级别的一些信息,比如被操作系统的 oom-killer 杀掉的进程就可以在这里找到。
整理了一幅脑图,可供你参考:
-
+
常用工具集合
为了找到系统的问题,我们会采用类似于神农尝百草的方式,用多个工具、多种手段获取系统的运行状况。
1.信息收集
@@ -208,7 +208,7 @@ function hide_canvas() {
skywalking 可以用来分析分布式环境下的调用链问题,可以详细地看到每一步执行的耗时。但如果你没有这样的环境,就可以使用命令行工具 arthas 对方法进行 trace,最终也能够深挖找到具体的慢逻辑。
jvm-profiling-tools,可以生成火焰图,辅助我们分析问题。另外,更加底层的,针对操作系统的性能测评和调优工具,还有perf和SystemTap,感兴趣的同学可以自行研究一下。
关于工具方面的内容,你可以回顾“04 | 工具实践:如何获取代码性能数据?”和“05|工具实践:基准测试 JMH,精确测量方法性能”进行回忆复习,我整理了一幅脑图,可供你参考。
-
+
基本解决方式
找到了具体的性能瓶颈点,就可以针对性地进行优化。
1.CPU 问题
@@ -241,7 +241,7 @@ function hide_canvas() {
网络 I/O 的另外一个问题就是频繁的网络交互,通过将结果集合并,使用批量的方式,可以显著增加性能,但这种方式的使用场景有限,比较适合异步的任务处理。
使用 netstat 命令,或者 lsof 命令,可以获取进程所关联的,TIME_WAIT 和 CLOSE_WAIT 网络状态的数量,前者可以通过调整内核参数来解决,但后者多是应用程序的 BUG。
我整理了一幅脑图,可供你参考。
-
+
有了上面的信息收集和初步优化,我想你脑海里应该对要优化的系统已经有了非常详细的了解,是时候改变一些现有代码的设计了。
可以说如果上面的基本解决方式面向的是“面”,那么代码层面的优化,面向的就是具体的“性能瓶颈点”。
代码层面
@@ -277,10 +277,10 @@ function hide_canvas() {
并不是说系统的资源利用率越低,我们的代码写得就越好。作为一个编码者,我们要想方设法压榨系统的剩余价值,让所有的资源都轮转起来。尤其在高并发场景下,这种轮转就更加重要——属于在一定压力下系统的最优状态。
资源不能合理的利用,就是一种浪费。比如,业务应用多属于 I/O 密集型业务,如果让请求都阻塞在 I/O 上,就造成了 CPU 资源的浪费。这时候使用并行,就可以在同一时刻承担更多的任务,并发量就能够增加;再比如,我们监控到 JVM 的堆空闲空间,长期处于高位,那就可以考虑加大堆内缓存的容量,或者缓冲区的容量。
我整理了一幅脑图,可供你参考。
-
+
PDCA 循环方法论
性能优化是一个循环的过程,需要根据数据反馈进行实时调整。有时候,测试结果表明,有些优化的效果并不好,就需要回滚到优化前的版本,重新寻找突破点。
-
+
如上图,PDCA 循环的方法论可以支持我们管理性能优化的过程,它有 4 个步骤:
- P(Planning)计划阶段,找出存在的性能问题,收集性能指标信息,确定要改进的目标,准备达到这些目标的具体措施;
@@ -289,7 +289,7 @@ function hide_canvas() {
- A(act)处理阶段,将成功的优化经验进行推广,由点及面进行覆盖,为负面影响提供解决方案,将错误的方法形成经验。
如此周而复始,应用的性能将会逐步提高,如下图,对于性能优化来说,就可以抽象成下面的方式。
-
+
既然叫作循环,就说明这个过程是可以重复执行的。事实上,在我们的努力下,应用性能会螺旋式上升,最终达到我们的期望。
求职面经
1. 关注“性能优化”的副作用问题
diff --git a/专栏/Kubernetes 实践入门指南/01 重新认识 Kubernetes 的核心组件.md.html b/专栏/Kubernetes 实践入门指南/01 重新认识 Kubernetes 的核心组件.md.html
index 3c29ab17..8a633a2f 100644
--- a/专栏/Kubernetes 实践入门指南/01 重新认识 Kubernetes 的核心组件.md.html
+++ b/专栏/Kubernetes 实践入门指南/01 重新认识 Kubernetes 的核心组件.md.html
@@ -179,7 +179,7 @@ function hide_canvas() {
- cri-o,容器运行时管理进程,类似 Docker 管理工具 containerd,国内业界普遍使用 containerd。
我们可以用如下一张架构设计图更能深刻理解和快速掌握 Kubernetes 的核心组件的布局:
-
+
通过以上的介绍,核心组件的基本知识就这么多。从最近几年落地 Kubernetes 云原生技术的用户反馈来看,大家仍然觉得这套系统太复杂,不太好管理,并且随时担心系统给业务带来致命性的影响。
那么 Kubernetes 的组件是为分布式系统设计的,为什么大家还是担心它会影响业务系统的稳定性呢?从笔者接触到的用户来讲,业界并没有统一的可以直接参考的解决方案。大家在落地过程中,只能去摸石头过河,一点一点总结经验并在迭代中不断地改进实施方案。因为业务规模的不同,Kubernetes 实施的架构也完全不同,你很难让基础设施的一致性在全部的商业企业 IT 环境中保持一致性。业务传播的都是最佳实践,在 A 用户这里可以行的通,不代表在 B 用户可以实施下去。
当然,除了客观的限制因素之外,我们应用 Kubernetes 的初衷是尽量的保持企业的 IT 基础设施的一致性,并随着企业业务需求的增长而弹性扩展。毕竟 Kubernetes 是谷歌基于内部 Borg 应用管理系统成功经验的基础之上开源的容器编排系统,它的发展积累了整个业界的经验精华,所以目前企业在做数字转型的阶段,都在无脑的切换到这套新的环境中,生怕技术落后影响了业务的发展。
@@ -210,23 +210,23 @@ function hide_canvas() {
但是往往这种潜在的小问题,就是让你很烦恼并对长时间无法重现感到烦恼。那么对于系统进程,Linux 是有对应的系统维护工具 Systemd 来维护的。它的生态足够完善,并能在多种 Linux 环境中保持行为一致。当出现问题的时候,运维可以直接登录主机快速排查系统日志来定位排错。根据这样的经验积累,笔者推荐生产环境还是采用原生进程的方式来维护 Kubernetes 的组件,让运维可以集中精力在集群架构上多做冗余优化。
接下来我们重新理解 etcd 集群的架构。根据 Kubernetes 官方文档的参考资料介绍,通常按照 etcd 集群的拓扑模型可以分为两类生产级别的 Kubernetes 集群。
栈式 etcd 集群拓扑:
-
+
独立式 etcd 集群拓扑:
-
+
参考上面的架构图,我们可以看到 etcd 集群的部署方式影响着 Kubernetes 集群的规模。在落地实践中因为采购的机器都是高性能大内存的刀片服务器,业务部门的期望是能充分的把这些资源利用上,并不期望用这些机器来跑集群控制管理组件。
当遇到这种情况,很多部署方案会采用第一种方案,是把主机节点、工作节点和 etcd 集群都放在一起复用资源。从高可用架构来讲,高度的应用密度集合并不能给用户带来无限制的好处。试想当出现节点宕机后这种架构的隐患是业务应用会受到极大的影响。所以通常的高可用架构经验是工作节点一定要和主控节点分开部署。在虚拟化混合环境下,主控节点可以使用小型的虚拟机来部署最为合适。当你的基础设施完全采用物理机的时候,直接使用物理机来部署主控节点是很浪费的,建议在现有物理机集群中先使用虚拟化软件虚拟化一批中小型虚拟机来提供管理节点的管理资源。开源的管理系统有 OpenStack,商业的方案是 VMware vSphere,按需求丰俭由人即可。
除了以上标准的部署解决方案,社区还提供了单机模式部署的 K3s 集群模式。把核心组件直接绑定为一个单体二进制文件,这样的好处就是这个系统进程只有一个,非常容易管理和恢复集群。在纯物理机环境,使用这种单点集群架构来部署应用,我们可以通过冗余部署多套集群的方式来支持应用的高可用和容灾。下图就是 K3s 的具体架构图:
-
+
K3s 本来是为嵌入式环境提供的精简 Kubernetes 集群,但是这个不妨碍我们在生产实践中灵活运用。K3s 提供的是原生 Kubernetes 的所有稳定版本 API 接口,在 x86 集群下可以发挥同样的编排容器业务的能力。
工作节点组件的使用策略
在工作节点上默认安装的组件是 kubelet 和 kube-proxy。在实际部署的过程中,kubelet 是有很多配置项需要调优的,这些参数会根据业务需求来调整,并不具备完全一样的配置方案。让我们再次认识一下 kubelet 组件,对于 kubelet,它是用来启动 Pod 的控制管理进程。虽然 kubelet 总体启动容器的工作流程,但是具体的操作它是依赖主机层面的容器引擎来管理的。对于依赖的容器引擎,我们可以选择的组件有 containerd、ori-o 等。Kubernetes 默认配置的组件是 cri-o。但是业界实际落地部署最多的还是 containerd,因为它的部署量巨大,很多潜在的问题都会被第一时间解决。containerd 是从 docker 引擎抽离出来的容器管理工具,用户具备长期的使用经验,这些经验对于运维和管理容器会带来很多潜在的使用信心。
-
+
对于容器实例的维护,我们常用的命令行工具是 Docker,在切换到 containerd 之后,命令行工具就切换为 ctr 和 crictl。很多时候用户无法搞清楚这两个工具的用处,并和 Docker 混为一谈。
Docker 可以理解为单机上运行容器的最全面的开发管理工具,这个不用多介绍,大家都了解。ctr 是 containerd 的客户端级别的命令行工具,主要的能力是管理运行中的容器。crictl 这个工具是管理 CRI 运行时环境的,在上图中是操作 cri-containerd 的组件。它的功能主要聚焦在 Pod 层面的镜像加载和运行。
还请大家注意下 Docker、ctr、crictl 三者细节实现上的差别。举个例子,Docker 和 ctr 确实都是管理主机层面的镜像和容器的,但是他们都有自己独立的管理目录,所以你即使是同样的加载镜像的操作,在主机存储的文件位置也是不同的,他们的镜像层无法复用。而 crictl 是操作 Pod 的,它并不是直接操作镜像的进程,一般把命令发送给对应的镜像管理程序,比如 containerd 进程。
另外一个组件是 kube-proxy,它是面向 Service 对象概念的南北向反向代理服务。通过对接 Endpoint 对象,可以按照均衡策略来负载流量。另外为了实现集群全局的服务发现机制,每一个服务都会定义全局唯一的名字,也就是 Service 的名字。这个名字可以通过附加的组件 coredns 来实现集群内的名字解析,也就是服务发现。对于流量的负载,Kubernetes 是通过 iptables 或 IPVS(IP Virtual Server)来实现。
在正常的集群规模下,Service 并不会超过 500 个,但是华为容器技术团队做了一个极限压测,发现了 iptables 在实现反向代理的时候出现的性能瓶颈。试验验证了,当 Service 增加到足够大的时候,Service 规则增加对于 iptables 是 O(n) 的复杂度,而切换到 IPVS 却是 O(1)。压测结果如下:
-
+
目前 Kubernetes 默认反向代理激活模块为 IPVS 模式,iptables 和 IPVS 都是基于 Linux 的子模块 netfilter,他们的相同点就是做反向代理,但是还是有以下 3 点区别需要知道:
- IPVS 提供大规模集群扩展性和高性能
diff --git a/专栏/Kubernetes 实践入门指南/05 解决 K8s 落地难题的方法论提炼.md.html b/专栏/Kubernetes 实践入门指南/05 解决 K8s 落地难题的方法论提炼.md.html
index f98b9409..2ea614ea 100644
--- a/专栏/Kubernetes 实践入门指南/05 解决 K8s 落地难题的方法论提炼.md.html
+++ b/专栏/Kubernetes 实践入门指南/05 解决 K8s 落地难题的方法论提炼.md.html
@@ -182,46 +182,46 @@ function hide_canvas() {
统一共享集群
-
-
+
+


独立环境多区集群
-
-
-
-
+
+
+
+
应用环境多区集群
-
-
-
-
+
+
+
+
专用小型集群


-
-
+
+
通过以上的对比分析,显然当前最佳的方式是,以环境为中心或以应用为中心部署多集群模式会获得最佳的收益。
构建弹性 CI/CD 流程的策略
构建 CI/CD 流程的工具很多, 但是我们无论使用何种工具,我们都会困 惑如何引入 Kubernetes 系统。通过实践得知,目前业界主要在采用 GitOps 工作流与 Kubernetes 配合使用可以获得很多的收益。这里我们可以参考业界知名的 CI/CD 工具 JenkinsX 架构图作为参考:
-
+
GitOps 配合 Jenkins 的 Pipeline 流水线,可以创建业务场景中需要的流水线,可以让业务应用根据需要在各种环境中切换并持续迭代。这种策略的好处在于充分利用 Git 的版本工作流控制了代码的集成质量,并且依靠流水线的特性又让持续的迭代能力可以得到充分体现。
构建弹性多租户资源管理策略
Kubernetes 内部的账号系统有 User、Group、ServiceAccount,当我们通过 RBAC 授权获得资源权限之后,其实这 3 个资源的权限能力是一样的。因为使用场景的不同,针对人的权限,我们一般会提供 User、Group 对象。当面对 Pod 之间,或者是外部系统服务对 Kubernetes API 的调用时,一般会采用 ServiceAccount。在原生 Kubernetes 环境下,我们可以通过 Namespace 把账号和资源进行绑定,以实现基于 API 级别的多租户。但是原生的多租户配置过于繁琐,一般我们会采用一些辅助的开源多租户工具来帮助我们,例如 Kiosk 多租户扩展套件:
-
+
通过 Kiosk 的设计流程图,我们可以清晰地定义每一个用户的权限,并配置合理的资源环境。让原来繁琐的配置过程简化成默认的租户模板,让多租户的配置过程变得更标准。
构建弹性安全策略
基于 Kubernetes 容器集群的安全考量,它的攻击面很多。所以我们要想做一份完备的安全策略,依然需要借助在系统层面的安全经验作为参考。根据业界知名的 MITRE ATT&CK 全球安全知识库的安全框架设计,我们有如下方面需要考量:
-
+
Initial Access(准入攻击面)
我们需要考虑的面主要是认证授权的审计工作。比如在云端的 Kubernetes,当云端的认证凭证泄露就会导致容器集群暴露在外。比如 Kubeconfig 文件,它是集群管理员的管理授权文件,一旦被攻击者获得授权,整个集群就会暴露在攻击者的眼前。另外基础镜像的潜在 Bug 问题、应用程序的漏洞等问题,稍有不慎,也会对集群带来安全隐患。还有内置的开源面板 Kubernetes Dashboard 也不应该暴露在外网,需要保证其面板的端口安全。
Execution(执行攻击面)
diff --git a/专栏/Kubernetes 实践入门指南/06 练习篇:K8s 核心实践知识掌握.md.html b/专栏/Kubernetes 实践入门指南/06 练习篇:K8s 核心实践知识掌握.md.html
index 9055e40a..19b42e78 100644
--- a/专栏/Kubernetes 实践入门指南/06 练习篇:K8s 核心实践知识掌握.md.html
+++ b/专栏/Kubernetes 实践入门指南/06 练习篇:K8s 核心实践知识掌握.md.html
@@ -326,11 +326,11 @@ spec:
这样的灰度发布效果应用场景是有限的,往往企业内部的应用发布的时候包含十几二十个组件,这些组建直接还有很多网关进行细分,如何有效给细分组件的流量管理才是现在的迫切需求。这种流量策略按照当前的架构方向,是在往微服务网格方向发展,比较出名的开源框架就是 Istio。笔者认为使用服务网格来实现应用的流量观测和引导才更具弹性。
一谈到 Istio,相信大家一定部署过 Bookinfo 项目,其中最体现业务价值的就是业务流量的标记和切换:
-
+
Istio 通过控制 Header 实现蓝绿示例:
-
+
Istio 通过更改 Header 值实现灰度发布示例:
-
+
笔者建议:使用服务网格来实现应用流量的切换是比较自然的设计实现方式,它所依赖的底层技术确实就是 Kubernetes 的经典滚动更新技术。但是比原生 Kubernetes 更好的地方是,服务网格在通过 Header 引导流量的特性中,还加入了熔断、黑名单、限流等更高级的应用保障特性,值得使用。
练习-4:配置合理的网络方案并让流量数据可视化
应用在部署到 Kubernetes 集群后,我们最急手的需求就是业务数据的可视化。当前,仍然有很多用户在纠结使用哪种网络方案是最好的,从笔者来看,这个选型是需要依据你的硬件网络基础设施来决定的。并且当前主流的容器网络插件的损耗非常接近原生网络,早期用户网络的糟糕体验已经一去不复返。
diff --git a/专栏/Kubernetes 实践入门指南/07 容器引擎 containerd 落地实践.md.html b/专栏/Kubernetes 实践入门指南/07 容器引擎 containerd 落地实践.md.html
index 3c5abf61..a1e7b831 100644
--- a/专栏/Kubernetes 实践入门指南/07 容器引擎 containerd 落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/07 容器引擎 containerd 落地实践.md.html
@@ -167,7 +167,7 @@ function hide_canvas() {
07 容器引擎 containerd 落地实践
Docker 公司从 2013 年发布容器引擎 Docker 后,就被全球开发者使用并不断改进它的功能。随着容器标准的建立,Docker 引擎架构也从单体走向微服务结构,剥离出 dontainerd 引擎。它在整个容器技术架构中的位置如下:
-
+
图 6-1 containerd 架构图,版权源自 https://containerd.io/
containerd 使用初体验
从官方仓库可以下载最新的 containerd 可执行文件,因为依赖 runc,所以需要一并下载才能正常使用:
diff --git a/专栏/Kubernetes 实践入门指南/08 K8s 集群安装工具 kubeadm 的落地实践.md.html b/专栏/Kubernetes 实践入门指南/08 K8s 集群安装工具 kubeadm 的落地实践.md.html
index b6d155b2..73d7535e 100644
--- a/专栏/Kubernetes 实践入门指南/08 K8s 集群安装工具 kubeadm 的落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/08 K8s 集群安装工具 kubeadm 的落地实践.md.html
@@ -168,10 +168,10 @@ function hide_canvas() {
08 K8s 集群安装工具 kubeadm 的落地实践
kubeadm 是 Kubernetes 项目官方维护的支持一键部署安装 Kubernetes 集群的命令行工具。使用过它的读者肯定对它仅仅两步操作就能轻松组建集群的方式印象深刻:kubeadm init
以及 kubeadm join
这两个命令可以快速创建 Kubernetes 集群。当然这种便捷的操作并不能在生产环境中直接使用,我们要考虑组件的高可用布局,并且还需要考虑可持续的维护性。这些更实际的业务需求迫切需要我们重新梳理一下 kubeadm 在业界的使用情况,通过借鉴参考前人的成功经验可以帮助我们正确的使用好 kubeadm。
首先,经典的 Kubernetes 高可用集群的架构图在社区官方文档中定义如下:
-
+
从上图架构中可知,Kubernetes 集群的控制面使用 3 台节点把控制组件堆叠起来,形成冗余的高可用系统。其中 etcd 系统作为集群状态数据存储的中心,采用 Raft 一致性算法保证了业务数据读写的一致性。细心的读者肯定会发现,控制面节点中 apiserver 是和当前主机 etcd 组件进行交互的,这种堆叠方式相当于把流量进行了分流,在集群规模固定的情况下可以有效的保证组件的读写性能。
因为 etcd 键值集群存储着整个集群的状态数据,是非常关键的系统组件。官方还提供了外置型 etcd 集群的高可用部署架构:
-
+
kubeadm 同时支持以上两种技术架构的高可用部署,两种架构对比起来,最明显的区别在于外置型 etcd 集群模式需要的 etcd 数据面机器节点数量不需要和控制面机器节点数量一致,可以按照集群规模提供 3 个或者 5 个 etcd 节点来保证业务高可用能力。社区的开发兴趣小组 k8s-sig-cluster-lifecycle 还发布了 etcdadm 开源工具来自动化部署外置 etcd 集群。
安装前的基准检查工作
集群主机首要需要检查的就是硬件信息的唯一性,防止集群信息的冲突。确保每个节点上 MAC 地址和 product_uuid 的唯一性。检查办法如下:
@@ -307,7 +307,7 @@ sysctl --system
使用 kubeadm 安装高可用集群
为 kube-apiserver 创建负载均衡
因为工作节点和控制面节点之间是通过 kube-apiserver 来同步集群状态的,工作节点需要通过一个反向代理来把流量负载均衡到控制面集群中。一般的安装案例中,采用额外的 HAProxy 加 keeplived 来做请求流量的负载均衡。因为最新的 Linux 内核已经支持 IPVS 组件,可以实现内核态的流量代理,业界实践已经有通过动态维护 IPVS 规则来实现负载访问 apiserver。具体配置如图:
-
+
实践总结
Kubernetes 推出了很多安装解决方案,因为环境的差异化,让各种安装工具百花齐放,让用户选择起来很是困惑。kubeadm 算是一个在多种选型中比较突出的一个方案。因为采用了容器化部署方式,其运维难度要比二进制方式要大很多,在安装过程中还是会碰到版本不一致等问题,目前社区也在优化巩固这方面的功能稳定性,可以预见在不久之后,基于 kubeadm 的方式应该会成为主流的安装解决方案。
参考文章:
diff --git a/专栏/Kubernetes 实践入门指南/09 南北向流量组件 IPVS 的落地实践.md.html b/专栏/Kubernetes 实践入门指南/09 南北向流量组件 IPVS 的落地实践.md.html
index fae59141..de781142 100644
--- a/专栏/Kubernetes 实践入门指南/09 南北向流量组件 IPVS 的落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/09 南北向流量组件 IPVS 的落地实践.md.html
@@ -176,7 +176,7 @@ function hide_canvas() {
通过测试数据发现,答案是否定的。在 Pod 实例规模达到上万个实例的时候,iptables 就开始对系统性能产生影响了。我们需要知道哪些原因导致 iptables 不能稳定工作。
首先,IPVS 模式 和 iptables 模式同样基于 Netfilter,在生成负载均衡规则的时候,IPVS 是基于哈希表转发流量,iptables 则采用遍历一条一条规则来转发,因为 iptables 匹配规则需要从上到下一条一条规则的匹配,肯定对 CPU 消耗增大并且转发效率随着规则规模的扩大而降低。反观 IPVS 的哈希查表方案,在生成 Service 负载规则后,查表范围有限,所以转发性能上直接秒杀了 iptables 模式。
其次,这里我们要清楚的是,iptables 毕竟是为防火墙模型配置的工具,和专业的负载均衡组件 IPVS 的实现目标不同,这里并不能说 IPVS 就比 iptables 优秀,因为 IPVS 模式启用之后,仅仅只是取代南北向流量的转发,东西向流量的 NAT 转换仍然需要 iptables 来支撑。为了让大家对它们性能对比的影响有一个比较充分的理解,可以看下图:
-
+
从图中可以看到,当 Service 对象实例超过 1000 的时候,iptables 和 IPVS 对 CPU 的影响才会产生差异,规模越大影响也越明显。很明显这是因为它们的转发规则的查询方式不同导致了性能的差异。
除了规则匹配的检索优势,IPVS 对比 iptables 还提供了一些更灵活的负载均衡算法特性如:
@@ -309,7 +309,7 @@ TCP 100.100.100.100:http rr
Kubernetes 使用 IPVS 模式可以覆盖大部分场景下的流量负载,但是对于长连接 TCP 请求的水平扩展分流是无能为力的。这是因为 IPVS 并没有能力对 keepalive_requests 做一些限制。一旦你遇到这样的场景,临时解决办法是把连接方式从长连接变为短连接。如设定请求值(比如 1000)之后服务端会在 HTTP 的 Header 头标记 Connection:close
,通知客户端处理完当前的请求后关闭连接,新的请求需要重新建立 TCP 连接,所以这个过程中不会出现请求失败,同时又达到了将长连接按需转换为短连接的目的。当然长期的解决之道,你需要在集群前置部署一组 Nginx 或 HAProxy 集群,有效帮助你限制长连接请求的阈值,从而轻松实现流量的弹性扩容。
实践总结
IPVS 模式的引入是社区进行高性能集群测试而引入的优化方案,通过内核已经存在的 IPVS 模块替换掉 iptables 的负载均衡实现,可以说是一次非常成功的最佳实践。因为 IPVS 内置在 Kernel 中,其实 Kernel 的版本对 IPVS 还是有很大影响的,在使用中一定需要注意。当笔者在揣摩着 IPVS 和 iptables 配合使用的纠结中,其实 Linux 社区已经在推进一个新技术 eBPF(柏克莱封包过滤器)技术,准备一举取代 iptables 和 IPVS。如果你没有听说过这个技术,你一定看过这个 Cilium 这个容器网络解决方案,它就是基于 eBPF 实现的:
-
+
通过 eBPF 技术,目前已经在高版本的 Kernel 之上实现了流量转发和容器网络互连,期待有一天可以完美替换 iptables 和 IPVS,为我们提供功能更强、性能更好的流量管理组件。
diff --git a/专栏/Kubernetes 实践入门指南/10 东西向流量组件 Calico 的落地实践.md.html b/专栏/Kubernetes 实践入门指南/10 东西向流量组件 Calico 的落地实践.md.html
index 93c69fce..bc2d684c 100644
--- a/专栏/Kubernetes 实践入门指南/10 东西向流量组件 Calico 的落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/10 东西向流量组件 Calico 的落地实践.md.html
@@ -175,7 +175,7 @@ function hide_canvas() {
- 需要工具来自动解决容器网络地址转换
这里我们通过一个原生网络路由的例子来帮助大家理解容器网络互联互通的基本原理:
-
+
图:Docker 19.03.12 版本直接路由模式图例
分别对主机 1 和主机 2 上的 docker0 进行配置,重启 docker 服务生效
编辑主机 1 上的 /etc/docker/daemon.json
文件,添加内容:"bip" : "ip/netmask"
。
diff --git a/专栏/Kubernetes 实践入门指南/11 服务发现 DNS 的落地实践.md.html b/专栏/Kubernetes 实践入门指南/11 服务发现 DNS 的落地实践.md.html
index e133cb9e..076819d5 100644
--- a/专栏/Kubernetes 实践入门指南/11 服务发现 DNS 的落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/11 服务发现 DNS 的落地实践.md.html
@@ -217,7 +217,7 @@ Address 1: 10.96.0.1 kubernetes.default.svc.cluster.local
- 30 MB 留给缓存,默认缓存大小为 1 万条记录。
- 5 MB 留给应用查询操作使用,默认压测单例 CoreDNS 支持大约 30K QPS。
-
+
集成外部 DNS 服务
我们在使用 Kubernetes 的场景中,企业经常已经默认有了自己的 DNS 服务,在部署容器集群的时候,肯定期望和外置的 DNS 服务做一些集成,方便企业内部的使用。
默认 DNS 查询策略是 ClusterFirst,也就是查询应用名字首先是让集群内部的 CoreDNS 提供名字服务。而我们需要解决的是让指定的别名访问外部的服务,这个时候就需要做如下配置:
diff --git a/专栏/Kubernetes 实践入门指南/14 应用网关 OpenResty 对接 K8s 实践.md.html b/专栏/Kubernetes 实践入门指南/14 应用网关 OpenResty 对接 K8s 实践.md.html
index cebb46c6..7f380d00 100644
--- a/专栏/Kubernetes 实践入门指南/14 应用网关 OpenResty 对接 K8s 实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/14 应用网关 OpenResty 对接 K8s 实践.md.html
@@ -169,13 +169,13 @@ function hide_canvas() {
当前云原生应用网关有很多选择,例如:Nginx/OpenResty、Traefik、Envoy 等,从部署流行度来看 OpenResty 毋容置疑是最流行的反向代理网关。本篇探讨的就是 Kubernetes 为了统一对外的入口网关而引入的 Ingress 对象是如何利用 OpenResty 来优化入口网关的能力的。
为什么需要 OpenResty
原生 Kubernetes Service 提供对外暴露服务的能力,通过唯一的 ClusterIP 接入 Pod 业务负载容器组对外提供服务名(附注:服务发现使用,采用内部 kube-dns 解析服务名称)并提供流量的软负载均衡。缺点是 Service 的 ClusterIP 地址只能在集群内部被访问,如果需要对集群外部用户提供此 Service 的访问能力,Kubernetes 需要通过另外两种方式来实现此类需求,一种是 NodePort,另一种是 LoadBalancer。
-
+
当容器应用采用 NodePort 方式来暴露 Service 并让外部用户访问时会有如下困扰:
- 外部访问服务时需要带 NodePort
- 每次部署服务后,NodePort 端口会改变
-
+
当容器应用采用 LoadBalancer 方式时,主要应用场景还是对接云厂商提供负载均衡上,当然云厂商都提供对应的负载均衡插件方便 Kubernetes 一键集成。
对于大部分场景下,我们仍然需要采用私有的入口应用网关来对外提供服务暴露。这个时候通过暴露七层 Web 端口把外部流量挡在外面访问。同时对于用户来讲屏蔽了 NodePort 的存在,频繁部署应用的时候用户是不需要关心 NodePort 端口占用的。
在早期 Kubernetes 引入的 ingress controller 的方案是采用的 Nginx 作为引擎的,它在使用中有一些比较突出的问题:
@@ -183,10 +183,10 @@ function hide_canvas() {
Kubernetes 原生 Ingress 在设计上,将 YAML 配置文件交由 Ingress Controller 处理,转换为 nginx.conf,再触发 reload nginx.conf 使配置生效。日常运维免不了偶尔动一动 Ingress YAML 配置,每一次配置生效,都会触发一次 reload,这是不能接受的,尤其在入口流量采用⻓连接时更容易导致事故。
扩展能力薄弱
虽然 Ingress 设计之初是为了解决入口网关,但业务对于入口网关的需求一点都不比内部网关少。业务级灰度控制、熔断、流量控制、鉴权、流量管控等需求在 Ingress 上实现的呼声更高。然而原生 Ingress 提供的扩展是捉襟见肘。
-
+
为了解决以上 Nginx 固有的问题,显然基于 Nginx + Lua 的扩展方案 OpenResty 是不二的替换方案。社区方面已经完成的从 Nginx 到 OpenResty 的 Ingress 核心组件替换。(附注:https://github.com/kubernetes/ingress-nginx/pull/4220)
重新认识 NGINX Ingress Controller
-
+
通常情况下,Kubernetes 控制器利用同步循环模式来检查控制器中的所需状态是否被更新或需要更改。为此,我们需要使用集群中的不同对象建立一个模型,特别是 Ingresses、Services、Endpoints、Secrets 和 Configmaps 来生成一个反映集群状态的当前配置文件。
为了从集群中获取这个对象,我们使用 Kubernetes Informers,尤其是 FilteredSharedInformer。这个 Informer 允许在添加、修改或删除新对象时,使用回调对单个变化做出反应。不幸的是,我们无法知道某个特定的变化是否会影响最终的配置文件。因此在每一次变更时,我们都要根据集群的状态从头开始重建一个新的模型,并与当前模型进行比较。如果新模型与当前模型相等,那么我们就避免生成一个新的 Nginx 配置并触发重载。否则,我们检查是否仅是关于 Endpoints 的差异。如果是这样,我们就使用 HTTP POST 请求将新的 Endpoints 列表发送到 Nginx 内部运行的 Lua 处理程序,并再次避免生成新的 Nginx 配置和触发重载。如果运行的模型和新模型之间的区别不仅仅是 Endpoints,我们会根据新模型创建一个新的 Nginx 配置,替换当前模型并触发重载。
为了避免进程重载,我们仍然需要清楚如下情况会导致重载:
diff --git a/专栏/Kubernetes 实践入门指南/15 Service 层引流技术实践.md.html b/专栏/Kubernetes 实践入门指南/15 Service 层引流技术实践.md.html
index 3b7df8fe..0c11ec47 100644
--- a/专栏/Kubernetes 实践入门指南/15 Service 层引流技术实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/15 Service 层引流技术实践.md.html
@@ -217,9 +217,9 @@ spec:
对于以 hostNetwork 方式运行的 Pod 需要显式设置其 DNS 策略 ClusterFirstWithHostNet,只有这样 Pod 才会走集群 DNS 查询服务。这个范例也提醒了我们 Service IP 并不是唯一引流的方案,一定要结合实际场景来应用 Kubernetes 的特性。
接下来我们在来刨析下 Service IP,它是由 iptables 创建的虚拟 IP 地址。根据 iptables 定义规则,统称这类 IP 为 DNAT 模式:
-
+
通过 kube-proxy 生成的 iptables 规则(最新版本采用 IPVS 模块生成代理规则,这里不在赘述,原理类似),每当一个数据包的目的地是服务 IP 时,它就会被 DNAT 化(DNAT = 目的地网络地址转换),也就是说目的地 IP 从服务 IP 变成了 iptables 随机选择的一个端点 Pod IP。这样可以保证负载流量可以在后端 Pod 中均匀分布。当 DNAT 发生时,这些信息会被存储在 conntrack 即 Linux 连接跟踪表中(存储 5 元组数据记录集:协议、srcIP、srcPort、dstIP、dstPort)。当数据回复回来的时候,就可以取消 DNAT,也就是把源 IP 从 Pod IP 改成 Service IP。这样一来,客户端就不需要知道后面的数据包流是如何处理的。
-
+
对于从 Pod 发起并出站的数据流量,也是需要 NAT 转换的。一般来说,节点可以同时拥有私有虚拟 IP 和公有 IP。在节点与外部 IP 的正常通信中,对于出站数据包,源 IP 由节点的私有虚拟 IP 变为其公有 IP,对于入站数据包的回复则反过来。但是当连接到外部 IP 是由 Pod 发起时,源 IP 是 Pod 的 IP,kube-proxy 会多加一些 iptables 规则,做 SNAT(Source Network Address Translation)也就是 IP MASQUERADE。SNAT 规则告诉内核出站数据包需要使用节点的外网 IP 来代替源 Pod 的 IP。系统还需要保留一个 conntrack 条目来解除 SNAT 的回复。
注意这里的性能问题,由于集群容器规模的增加,conntrack 会暴增,后由华为容器团队引入的 IPVS 方案也是在做大规模容器负载的压测中发现这个瓶颈,并提出引入 IPVS 来解决这个问题。在笔者实际应用中发现,iptables 方案在小规模集群场景下性能和 IPVS 持平,所以 IPVS 方案从本质上来讲还只是一个临时方案,它只解决了入站流量数据包的 DNAT 转换,SNAT 转换还是需要 iptables 来维护。去 iptables 化相信在不久的将来会被 eBPF 技术取代,并最终实现最优的流量引流设计方案。
Ingress 的高级策略
diff --git a/专栏/Kubernetes 实践入门指南/16 Cilium 容器网络的落地实践.md.html b/专栏/Kubernetes 实践入门指南/16 Cilium 容器网络的落地实践.md.html
index ab9b2e5d..f3090764 100644
--- a/专栏/Kubernetes 实践入门指南/16 Cilium 容器网络的落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/16 Cilium 容器网络的落地实践.md.html
@@ -168,7 +168,7 @@ function hide_canvas() {
16 Cilium 容器网络的落地实践
随着越来越多的企业采用 Kubernetes,围绕多云、安全、可见性和可扩展性等新要求,可编程数据平面的需求用例范围越来越广。此外,服务网格和无服务器等新技术对 Kubernetes 底层提出了更多的定制化要求。这些新需求都有一些共同点:它们需要一个更可编程的数据平面,能够在不牺牲性能的情况下执行 Kubernetes 感知的网络数据操作。
Cilium 项目通过引入扩展的伯克利数据包过滤器(eBPF)技术,在 Linux 内核内向网络栈暴露了可编程的钩子。使得网格数据包不需要在用户和内核空间之间来回切换就可以通过上下文快速进行数据交换操作。这是一种新型的网络范式,它也是 Cilium 容器网络项目的核心思想。
-
+
为什么需要落地 Cilium 容器网络?
Kubernetes 的容器网络方案发展至今,一直是百家争鸣,各有特色。之前因为 CNI 网络方案不成熟,大家用起来都是战战兢兢,时刻提防容器网络给业务带来不可接受的效果,随即就把容器网络替换成主机网络。随着时间的磨砺,当前主流的容器网络方案如 Calico 等已经经历成百上千次生产环境的应用考验,大部分场景下都可以达到用户可以接受的网络性能指标。因为成功经验开始增多,用户也开始大规模启用容器网络的上线了。随着业务流量的引入越来越大,用户对 Kubernetes 网络的认知也趋于一致。大致分为两大类,一类是 Cluster IP,是一层反向代理的虚拟网络;一类是 Pod IP,是容器间交互数据的网络数据平面。对于反向代理虚拟网络的技术实现,早期 kube-proxy 是采用 iptables,后来引入 IPVS 也解决了大规模容器集群的网络编排的性能问题。这样的实现结构你从顶端俯瞰会明显感知到 Kubernetes 网络数据平台非常零散,并没有实现一套体系的网络策略编排和隔离。显然,这样的技术结构也无法引入数据可视化能力。这也是 Istio 服务网格引入后,通过增加 envoy sidecar 来实现网络流量可视化带来了机会。但是这种附加的边界网关毕竟又对流量增加了一层反向代理,让网络性能更慢了。Cilium 原生通过 eBPF 编排网络数据,让可视化更简单。
Cilium 还有一个强项就是通过 eBPF 是可以自定义隔离策略的,这样就可以在非信任的主机环境编排更多的容器网络隔离成多租户环境,让用户不在担心数据的泄露,可以更专注在数据业务的连通性上。因为 eBPF 的可编程性,我们还能依据业务需求,增加各种定制化插件,让数据平台可以更加灵活安全。
@@ -180,7 +180,7 @@ function hide_canvas() {
- 直接/本地路由模式:在这个配置中,Cilium 会把所有不针对另一个本地端点的数据包交给 linux 内核的路由子系统。这个设置需要一个额外的路由守护程序,如 Bird、Quagga、BGPD、Zebra 等,通过节点的 IP 向所有其他节点公布非本地节点分配的前缀。与 VxLAN 叠加相比,BGP 方案具有更好的性能,更重要的是,它使容器 IP 可路由化,无需任何额外的网状配置。
Cilium 在主机网络空间上创建了三个虚拟接口:ciliumhost、ciliumnet 和 ciliumvxlan。Cilium Agent 在启动时创建一个名为“ciliumhost -> ciliumnet”的 veth 对,并将 CIDR 的第一个 IP 地址设置为 ciliumhost,然后作为 CIDR 的网关。CNI 插件会生成 BPF 规则,编译后注入内核,以解决 veth 对之间的连通问题。数据链路参考如下:
-
+
落地安装实践
因为 Cilium 对内核要求很高,本来我以为需要采用 Ubuntu 才可以安装,后来查阅文档发现,只要是 CentOS 7.x 之后就可以支持。安装步骤如下。
@@ -257,7 +257,7 @@ For any further help, visit https://docs.cilium.io/en/v1.8/gettinghelp
sudo kubectl get svc hubble-ui -n kube-system
然后就可以查看可视化数据平台,如下图:
-
+
经验总结
Cilium 网络方案从实际体验来看,已经可以满足常规容器网络需求。它的可视化控制台 Hubble 是对数据平面可视化的最原生实现,比 Istio 的方案显然要技高一筹。数据可视化这块让笔者有点意外,没有想到 eBPF 的编程能力可以这么强,为之后更多的插件功能带来更多期待。因为 cilium 技术太新,按照实践经验,笔者推荐大家在开发测试环境可以大胆使用起来,生产环境还要再等等,我相信在经过半年的磨砺,Cilium 应该会成为 Kubernetes 社区使用最多的容器网络方案。
diff --git a/专栏/Kubernetes 实践入门指南/17 应用流量的优雅无损切换实践.md.html b/专栏/Kubernetes 实践入门指南/17 应用流量的优雅无损切换实践.md.html
index 83d48355..30943d34 100644
--- a/专栏/Kubernetes 实践入门指南/17 应用流量的优雅无损切换实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/17 应用流量的优雅无损切换实践.md.html
@@ -186,7 +186,7 @@ spec:
maxSurge: 1
maxUnavailable: 0
-
+
此部署对象将一次创建一个带有新版本的 Pod,等待 Pod 启动并准备好后触发其中一个旧 Pod 的终止,并继续进行下一个新 Pod,直到所有的副本都被更新。下面显示了 kubectl get pods
的输出和新旧 Pods 随时间的变化。
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
diff --git a/专栏/Kubernetes 实践入门指南/18 练习篇:应用流量无损切换技术测验.md.html b/专栏/Kubernetes 实践入门指南/18 练习篇:应用流量无损切换技术测验.md.html
index ad25033f..1fd3eb28 100644
--- a/专栏/Kubernetes 实践入门指南/18 练习篇:应用流量无损切换技术测验.md.html
+++ b/专栏/Kubernetes 实践入门指南/18 练习篇:应用流量无损切换技术测验.md.html
@@ -169,7 +169,7 @@ function hide_canvas() {
经过连续 5 篇相关应用流量引流相关的技术探讨,相信大家已经对 Kubernetes 的服务引流架构有了更深入的了解。常言道好记性不如烂笔头,笔者在反复练习这些参数的过程中,也是费劲了很大的一段时间才对 Kubernetes 的集群引流技术有了一些运用。以下的练习案例都是笔者认为可以加固自身知识体系的必要练习,还请大家跟随我的记录一起练习吧。
练习 1:Deployment 下实现无损流量应用更新
我们在更新应用的时候,往往会发现即使发布应用的时候 Kubernetes 采用了滚动更新的策略,应用流量还是会秒断一下。这个困惑在于官方文档资料的介绍中这里都是重点说可以平滑更新的。注意这里,它是平滑更新,并不是无损流量的更新。所以到底问题出在哪里呢。笔者查阅了资料,发现核心问题是 Pod 生命周期中应用的版本更新如下图,关联对象资源如 Pod、Endpoint、IPVS、Ingress/SLB 等资源的更新操作都是异步执行的。往往流量还在处理中,Pod 容器就有可能给如下图:
-
+
依据 Pod 容器进程生命周期流程图中,容器进程的状态变更都是异步的,如果应用部署对象 Deployment 不增加 lifecycle 参数 preStop 的配置,即使南北向流量关闭了,进程仍然还需要几秒钟处理正在执行中的会话数据,才可以优雅退出。以下为应用部署 Deployment 对象的声明式配置:
apiVersion: apps/v1
kind: Deployment
@@ -336,9 +336,9 @@ end
因为 Traefik 可以直接和 Kubernetes Apiserver 进行交互,所以对于流量的切换和部署会比 ingress-nginx 更加便捷。Traefik 在 Kubernetes 中也是一个 Ingress 对象,在第二个练习中我们已经介绍了通过 Service 的 selector 切换实现无损流量的部署方法,第三个例子我们介绍另外三种比较流行的方法,蓝绿部署、金丝雀发布和 A/B 测试。虽然这三种方式都有关联,但也各有不同。
通过 Kubernetes 不可变基础设施的支持,我们可以让同一软件的多个版本实例在同一集群内服务于请求,这种模式会让试验变得非常有趣。像这样混合使用新旧版本,就可以配置路由规则来测试生产环境的最新版本。更重要的是,新版本可以逐步发布——如果出现问题,甚至可以撤回——所有这一切几乎都没有停机时间。
蓝绿发布模式下,"绿色 "指的是应用的当前稳定版本,而“蓝色”指的是引入新功能和修复的即将发布的版本。两个版本的实例同时在同一生产环境中运行。同时,代理服务(如 Traefik)确保只有发送到私有地址的请求才能到达蓝色实例。例子如下图:
-
+
金丝雀发布模式将蓝绿测试又向前推进了一步,用一种谨慎的方式将新功能和补丁部署到活跃的生产环境中。路由配置让当前的稳定版本处理大多数请求,但有限比例的请求会被路由到新的“金丝雀”版本的实例。例子如下:
-
+
A/B 测试技术有时会与前两种技术混淆,但它有自己的目的,即评估即将发布的版本的两个不同的版本,看看哪个版本会更成功。这种策略在 UI 开发中很常见。例如,假设一个新功能很快就会推出到应用程序中,但不清楚如何最好地将其暴露给用户。为了找出答案,包括该功能在内的两个版本的 UI,同时运行 A 版本和 B 版本,代理路由器向每个版本发送有限数量的请求。例子如下:

这些技术对于测试现代的云原生软件架构是非常宝贵的,尤其是与传统的瀑布式部署模型相比。如果使用得当,它们可以帮助发现生产环境中不可预见的回归、集成失败、性能瓶颈和可用性问题,但要在新代码进入稳定的生产版本之前。
diff --git a/专栏/Kubernetes 实践入门指南/19 使用 Rook 构建生产可用存储环境实践.md.html b/专栏/Kubernetes 实践入门指南/19 使用 Rook 构建生产可用存储环境实践.md.html
index c46b3e4e..a274ea6f 100644
--- a/专栏/Kubernetes 实践入门指南/19 使用 Rook 构建生产可用存储环境实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/19 使用 Rook 构建生产可用存储环境实践.md.html
@@ -169,7 +169,7 @@ function hide_canvas() {
Rook 是基于 Kubernetes 之上构建的存储服务框架。它支持 Ceph、NFS 等多种底层存储的创建和管理。帮助系统管理员自动化维护存储的整个生命周期。存储的整个生命周期包括部署、启动、配置、申请、扩展、升级、迁移、灾难恢复、监控和资源管理等,看着就让笔者觉得事情不少,Rook 的目标就是降低运维的难度,让 Kubernetes 和 Rook 来帮你托管解决这些任务。
Rook 管理 Ceph 集群
Ceph 分布式存储是 Rook 支持的第一个标记为 Stable 的编排存储引擎,在笔者验证 Rook 操作 Ceph 的过程中发现,其社区文档、脚本都放在一起,初次新手很难知道如何一步一步体验 Rook 搭建 Ceph 的过程。这从一个侧面反应了分布式存储的技术难度和兼容性是一个长期的迭代过程,Rook 的本意是为了降低部署管理 Ceph 集群的难度,但是事与愿违,初期使用的过程并不友好,有很多不知名的问题存在官方文档中。
-
+
在安装 Ceph 前要注意,目前最新的 Ceph 支持的存储后端 BlueStore 仅支持裸设备,不支持在本地文件系统之上建立存储块。因为 Rook 文档的混乱,一开始我们需要自己找到安装脚本目录,它在
https://github.com/rook/rook/tree/master/cluster/examples/kubernetes/ceph
diff --git a/专栏/Kubernetes 实践入门指南/21 案例:分布式 MySQL 集群工具 Vitess 实践分析.md.html b/专栏/Kubernetes 实践入门指南/21 案例:分布式 MySQL 集群工具 Vitess 实践分析.md.html
index 650a37fa..e5d93e31 100644
--- a/专栏/Kubernetes 实践入门指南/21 案例:分布式 MySQL 集群工具 Vitess 实践分析.md.html
+++ b/专栏/Kubernetes 实践入门指南/21 案例:分布式 MySQL 集群工具 Vitess 实践分析.md.html
@@ -232,7 +232,7 @@ mysql> show databases;
至此,我们的体验和安装一套本地的 MySQL Server 是一样的。这种透明的体验值得我们接下来持续挖掘更高级的特性。
下图说明了 Vitess 的组件架构,我们需要熟悉这些术语:
-
+
Topology
拓扑服务是一个元数据存储对象,包含有关正在运行的服务器、分片方案和复制关系图的信息。拓扑由一致性的数据存储支持,默认支持 etcd2 插件。您可以使用 vtctl(命令行)和 vtctld(web)查看拓扑信息。
VTGate
diff --git a/专栏/Kubernetes 实践入门指南/22 存储对象 PV、PVC、Storage Classes 的管理落地实践.md.html b/专栏/Kubernetes 实践入门指南/22 存储对象 PV、PVC、Storage Classes 的管理落地实践.md.html
index 55ef360e..6cb2fd04 100644
--- a/专栏/Kubernetes 实践入门指南/22 存储对象 PV、PVC、Storage Classes 的管理落地实践.md.html
+++ b/专栏/Kubernetes 实践入门指南/22 存储对象 PV、PVC、Storage Classes 的管理落地实践.md.html
@@ -190,7 +190,7 @@ volumeBindingMode: Immediate
所以总结下来,对于存储资源,我们默认指代的就是铁三角 API 对象:StorageClass、PersistentVolume、PersistentVolumeClaim。
了解 CSI
从 Kubernetes v1.13 开始 CSI 进入稳定可用阶段,所以用户有必要了解这个容器存储接口。CSI 卷类型是一种外部引用驱动的 CSI 卷插件,用于 Pod 与在同一节点上运行的外部 CSI 卷驱动程序交互。部署 CSI 兼容卷驱动后,用户可以使用 CSI 作为卷类型来挂载驱动提供的存储。
-
+
一直以来,存储插件的测试、维护等事宜都由 Kubernetes 社区来完成,即使有贡献者提供协作也不容易合并到主分支发布。另外,存储插件需要随 Kubernetes 一同发布,如果存储插件存在问题有可能会影响 Kubernetes 其他组件的正常运行。
鉴于此,Kubernetes 和 CNCF 决定把容器存储进行抽象,通过标准接口的形式把存储部分移到容器编排系统外部去。CSI 的设计目的是定义一个行业标准,该标准将使存储供应商能够自己实现,维护和部署他们的存储插件。这些存储插件会以 Sidecar Container 形式运行在 Kubernetes 上并为容器平台提供稳定的存储服务。
如上 CSI 设计图:浅绿色表示从 Kubernetes 社区中抽离出来且可复用的组件,负责连接 CSI 插件(右侧)以及和 Kubernetes 集群交互:
diff --git a/专栏/MySQL实战宝典/01 数字类型:避免自增踩坑.md.html b/专栏/MySQL实战宝典/01 数字类型:避免自增踩坑.md.html
index 2166495e..512fda16 100644
--- a/专栏/MySQL实战宝典/01 数字类型:避免自增踩坑.md.html
+++ b/专栏/MySQL实战宝典/01 数字类型:避免自增踩坑.md.html
@@ -185,7 +185,7 @@ function hide_canvas() {
数字类型
整型类型
MySQL 数据库支持 SQL 标准支持的整型类型:INT、SMALLINT。此外,MySQL 数据库也支持诸如 TINYINT、MEDIUMINT 和 BIGINT 整型类型(表 1 显示了各种整型所占用的存储空间及取值范围):
-
+
各 INT 类型的取值范围
在整型类型中,有 signed 和 unsigned 属性,其表示的是整型的取值范围,默认为 signed。在设计时,我不建议你刻意去用 unsigned 属性,因为在做一些数据分析时,SQL 可能返回的结果并不是想要得到的结果。
来看一个“销售表 sale”的例子,其表结构和数据如下。这里要特别注意,列 sale_count 用到的是 unsigned 属性(即设计时希望列存储的数值大于等于 0):
diff --git a/专栏/MySQL实战宝典/02 字符串类型:不能忽略的 COLLATION.md.html b/专栏/MySQL实战宝典/02 字符串类型:不能忽略的 COLLATION.md.html
index 13c48fce..9623980f 100644
--- a/专栏/MySQL实战宝典/02 字符串类型:不能忽略的 COLLATION.md.html
+++ b/专栏/MySQL实战宝典/02 字符串类型:不能忽略的 COLLATION.md.html
@@ -204,7 +204,7 @@ character-set-server = utf8mb4
...
另外,不同的字符集,CHAR(N)、VARCHAR(N) 对应最长的字节也不相同。比如 GBK 字符集,1 个字符最大存储 2 个字节,UTF8MB4 字符集 1 个字符最大存储 4 个字节。所以从底层存储内核看,在多字节字符集下,CHAR 和 VARCHAR 底层的实现完全相同,都是变长存储!
-
+
从上面的例子可以看到,CHAR(1) 既可以存储 1 个 'a' 字节,也可以存储 4 个字节的 emoji 笑脸表情,因此 CHAR 本质也是变长的。
鉴于目前默认字符集推荐设置为 UTF8MB4,所以在表结构设计时,可以把 CHAR 全部用 VARCHAR 替换,底层存储的本质实现一模一样。
排序规则
diff --git a/专栏/MySQL实战宝典/05 表结构设计:忘记范式准则.md.html b/专栏/MySQL实战宝典/05 表结构设计:忘记范式准则.md.html
index 970a710e..86893cfd 100644
--- a/专栏/MySQL实战宝典/05 表结构设计:忘记范式准则.md.html
+++ b/专栏/MySQL实战宝典/05 表结构设计:忘记范式准则.md.html
@@ -305,7 +305,7 @@ CREATE FUNCTION MY_BIN_TO_UUID(_bin BINARY(16))
PK = 时间字段 + 随机码(可选) + 业务信息1 + 业务信息2 ......
电商业务中,订单表是其最为核心的表之一,你可以先打开淘宝 App,查询下自己的订单号,可以查到类似如下的订单信息:
-
+
上图是我自己的淘宝订单信息(第一个订单的订单号为1550672064762308113)。
订单号显然是订单表的主键,但如果你以为订单号是自增整型,那就大错特错了。因为如果你仔细观察的话,可以发现图中所有订单号的后 6 位都是相同的,都为308113:
1550672064762308113
diff --git a/专栏/MySQL实战宝典/07 表的访问设计:你该选择 SQL 还是 NoSQL?.md.html b/专栏/MySQL实战宝典/07 表的访问设计:你该选择 SQL 还是 NoSQL?.md.html
index a831631d..c8da58c2 100644
--- a/专栏/MySQL实战宝典/07 表的访问设计:你该选择 SQL 还是 NoSQL?.md.html
+++ b/专栏/MySQL实战宝典/07 表的访问设计:你该选择 SQL 还是 NoSQL?.md.html
@@ -182,12 +182,12 @@ function hide_canvas() {
SQL 是访问数据库的一个通用接口,虽然数据库有很多种,但数据库中的 SQL 却是类似的,因为 SQL 有标准存在,如 SQL92、SQL2003 等。
虽然有些数据库会扩展支持 SQL 标准外的语法,但 90% 的语法是兼容的,所以,不同数据库在 SQL 层面的学习成本是比较低的。也因为上述原因,从一种关系型数据库迁移到另一种关系型数据库,开发的迁移成本并不高。比如去 IOE,将 Oracle 数据库迁移到 MySQL 数据库,通常 SQL 语法并不是难题。
MySQL 8.0 版本前,有不少同学会吐槽 MySQL 对于 SQL 标准的支持的程度。但是在当前 8.0 版本下,MySQL 对于 SQL 语法的支持度已经越来越好,甚至在某些方面超过了商业数据库 Oracle。
-
+
上图是专家评估的不同数据库对 SQL 的支持程度,可以看到,MySQL 8.0 在这一块非常完善,特别是对 JSON_TABLE 的支持功能。
通常来说,MySQL 数据库用于 OLTP 的在线系统中,不用特别复杂的 SQL 语法支持。但 MySQL 8.0 完备的 SQL 支持意味着 MySQL 未来将逐渐补齐在 OLAP 业务方面的短板,让我们一起拭目以待。
当然,通过 SQL 访问表,你肯定并不陌生,这也不是本讲的重点。接下来我重点带你了解 MySQL 怎么通过 NoSQL 的方式访问表中的数据。
我们先来看看当前 MySQL 版本中支持的不同表的访问方式:
-
+
MySQL 三种表的访问方式
可以看到,除了标准的 SQL 访问,MySQL 5.6 版本开始还支持通过 Memcached 通信协议访问表中的数据,这时 MySQL 可以作为一个 KV 数据库使用。此外,MySQL 5.7 版本开始还支持通过新的 MySQL X 通信协议访问表中的数据,这时 MySQL 可以作为一个文档数据库使用。
但无论哪种 NoSQL 的访问方式,其访问的数据都是以表的方式进行存储。SQL 和 NoSQL 之间通过某种映射关系进行绑定。
@@ -223,7 +223,7 @@ VALUES ('User','test','user_id','user_id|cellphone|last_login','0','0','0','PRIA
上面的映射关系表示通过 Memcached 的 KV 方式访问,其本质是通过 PRIAMRY 索引访问 key 值,key 就是 user_id,value 值返回的是由列 user_id、cellphone、last_login 组合而成,分隔符为"|"的字符串。
最后,通过 SQL 和 KV 的对比性能测试,可以发现通过 KV 的方式访问,性能要好非常多,在我的测试服务器上结果如下所示:
-
+
从测试结果可以看到,基于 Memcached 的 KV 访问方式比传统的 SQL 方式要快54.33%,而且CPU 的开销反而还要低20%。
当然了,上述操作只是将表 User 作为 KV 访问,如果想将其他表通过 KV 的方式访问,可以继续在表 Containers 中进行配置。但是在使用时,务必先通过 GET 命令指定要访问的表:
# Python伪代码
@@ -243,7 +243,7 @@ mc.set('sb1_key1','aa|bbb|ccc')
通过 X Protocol 访问表
MySQL 5.7 版本开始原生支持 JSON 二进制数据类型,同时也提供将表格映射为一个 JSON 文档。同时,MySQL 也提供了 X Protocol 这样的 NoSQL 访问方式,所以,现在我们 MySQL 打造成一个SQL & NoSQL的文档数据库。
对比 MongoDB 文档数据库,将 MySQL 打造为文档数据库与 MongoDB 的对比在于:
-
+
可以看到,除了 MySQL 目前还无法支持数据分片功能外,其他方面 MySQL 的优势会更大一些,特别是 MySQL 是通过二维表格存储 JSON 数据,从而实现文档数据库功能。这样可以通过 SQL 进行很多复杂维度的查询,特别是结合 MySQL 8.0 的 CTE(Common Table Expression)、窗口函数(Window Function)等功能,而这在 MongoDB 中是无法原生实现的。
另外,和 Memcached Plugin 不同的是,MySQL 默认会自动启用 X Plugin 插件,接着就可以通过新的 X Protocol 协议访问 MySQL 中的数据,默认端口 33060,你可以通过下面命令查看有关 X Plugin 的配置:
mysql> SHOW VARIABLES LIEK '%mysqlx%';
@@ -271,7 +271,7 @@ DEFLATE_STREAM,LZ4_MESSAGE,ZSTD_STREAM |
[email protected]:# mysqlsh [email protected]/test
X Protocol 协议支持通过 JS、Python、SQL 的方式管理和访问 MySQL,具体操作你可以参见官方文档。
-
+
开发同学若要通过 X Protocol 协议管理文档数据,也需要下载新的 MySQL Connector,并引入新的 X 驱动库,如 Python 驱动:
import mysqlx
# Connect to server on localhost
diff --git a/专栏/MySQL实战宝典/08 索引:排序的艺术.md.html b/专栏/MySQL实战宝典/08 索引:排序的艺术.md.html
index a339a39c..431ee6c9 100644
--- a/专栏/MySQL实战宝典/08 索引:排序的艺术.md.html
+++ b/专栏/MySQL实战宝典/08 索引:排序的艺术.md.html
@@ -188,7 +188,7 @@ function hide_canvas() {
B+树索引的特点是: 基于磁盘的平衡树,但树非常矮,通常为 3~4 层,能存放千万到上亿的排序数据。树矮意味着访问效率高,从千万或上亿数据里查询一条数据,只用 3、4 次 I/O。
又因为现在的固态硬盘每秒能执行至少 10000 次 I/O ,所以查询一条数据,哪怕全部在磁盘上,也只需要 0.003 ~ 0.004 秒。另外,因为 B+ 树矮,在做排序时,也只需要比较 3~4 次就能定位数据需要插入的位置,排序效率非常不错。
B+ 树索引由根节点(root node)、中间节点(non leaf node)、叶子节点(leaf node)组成,其中叶子节点存放所有排序后的数据。当然也存在一种比较特殊的情况,比如高度为 1 的B+ 树索引:
-
+
上图中,第一个列就是 B+ 树索引排序的列,你可以理解它是表 User 中的列 id,类型为 8 字节的 BIGINT,所以列 userId 就是索引键(key),类似下表:
CREATE TABLE User (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -201,7 +201,7 @@ function hide_canvas() {
所有 B+ 树都是从高度为 1 的树开始,然后根据数据的插入,慢慢增加树的高度。你要牢记:索引是对记录进行排序, 高度为 1 的 B+ 树索引中,存放的记录都已经排序好了,若要在一个叶子节点内再进行查询,只进行二叉查找,就能快速定位数据。
可随着插入 B+ 树索引的记录变多,1个页(16K)无法存放这么多数据,所以会发生 B+ 树的分裂,B+ 树的高度变为 2,当 B+ 树的高度大于等于 2 时,根节点和中间节点存放的是索引键对,由(索引键、指针)组成。
索引键就是排序的列,而指针是指向下一层的地址,在 MySQL 的 InnoDB 存储引擎中占用 6 个字节。下图显示了 B+ 树高度为 2 时,B+ 树索引的样子:
-
+
可以看到,在上面的B+树索引中,若要查询索引键值为 5 的记录,则首先查找根节点,查到键值对(20,地址),这表示小于 20 的记录在地址指向的下一层叶子节点中。接着根据下一层地址就可以找到最左边的叶子节点,在叶子节点中根据二叉查找就能找到索引键值为 5 的记录。
那一个高度为 2 的 B+ 树索引,理论上最多能存放多少行记录呢?
在 MySQL InnoDB 存储引擎中,一个页的大小为 16K,在上面的表 User 中,键值 userId 是BIGINT 类型,则:
@@ -215,13 +215,13 @@ function hide_canvas() {
也就是说,35200 条记录排序后,生成的 B+ 树索引高度为 2。在 35200 条记录中根据索引键查询一条记录只需要查询 2 个页,一个根叶,一个叶子节点,就能定位到记录所在的页。
高度为 3 的 B+ 树索引本质上与高度 2 的索引一致,如下图所示,不再赘述:
-
+
同理,树高度为 3 的 B+ 树索引,最多能存放的记录数为:
总记录数 = 1100(根节点) * 1100(中间节点) * 32 = 38,720,000
讲到这儿,你会发现,高度为 3 的 B+ 树索引竟然能存放 3800W 条记录。在 3800W 条记录中定位一条记录,只需要查询 3 个页。那么 B+ 树索引的优势是否逐步体现出来了呢?
不过,在真实环境中,每个页其实利用率并没有这么高,还会存在一些碎片的情况,我们假设每个页的使用率为60%,则:
-
+
表格显示了 B+ 树的威力,即在 50 多亿的数据中,根据索引键值查询记录,只需要 4 次 I/O,大概仅需 0.004 秒。如果这些查询的页已经被缓存在内存缓冲池中,查询性能会更快。
在数据库中,上述的索引查询请求对应的 SQL 语句为:
SELECT * FROM User WHERE id = ?
@@ -269,7 +269,7 @@ possible_keys: NULL
你不可能要求所有插入的数据都是有序的,因为索引的本身就是用于数据的排序,插入数据都已经是排序的,那么你就不需要 B+ 树索引进行数据查询了。
所以对于 B+ 树索引,在 MySQL 数据库设计中,仅要求主键的索引设计为顺序,比如使用自增,或使用函数 UUID_TO_BIN 排序的 UUID,而不用无序值做主键。
我们再回顾 05 讲的自增、UUID、UUID 排序的插入性能对比:
-
+
可以看到,UUID 由于是无序值,所以在插入时性能比起顺序值自增 ID 和排序 UUID,性能上差距比较明显。
所以,我再次强调: 在表结构设计时,主键的设计一定要尽可能地使用顺序值,这样才能保证在海量并发业务场景下的性能。
以上就是索引查询和插入的知识,接下来我们就分析怎么在 MySQL 数据库中查看 B+ 树索引。
diff --git a/专栏/MySQL实战宝典/09 索引组织表:万物皆索引.md.html b/专栏/MySQL实战宝典/09 索引组织表:万物皆索引.md.html
index 466934d5..a20592d4 100644
--- a/专栏/MySQL实战宝典/09 索引组织表:万物皆索引.md.html
+++ b/专栏/MySQL实战宝典/09 索引组织表:万物皆索引.md.html
@@ -181,13 +181,13 @@ function hide_canvas() {
索引组织表
数据存储有堆表和索引组织表两种方式。
堆表中的数据无序存放, 数据的排序完全依赖于索引(Oracle、Microsoft SQL Server、PostgreSQL 早期默认支持的数据存储都是堆表结构)。
-
+
从图中你能看到,堆表的组织结构中,数据和索引分开存储。索引是排序后的数据,而堆表中的数据是无序的,索引的叶子节点存放了数据在堆表中的地址,当堆表的数据发生改变,且位置发生了变更,所有索引中的地址都要更新,这非常影响性能,特别是对于 OLTP 业务。
而索引组织表,数据根据主键排序存放在索引中,主键索引也叫聚集索引(Clustered Index)。在索引组织表中,数据即索引,索引即数据。
MySQL InnoDB 存储引擎就是这样的数据组织方式;Oracle、Microsoft SQL Server 后期也推出了支持索引组织表的存储方式。
但是,PostgreSQL 数据库因为只支持堆表存储,不适合 OLTP 的访问特性,虽然它后期对堆表有一定的优化,但本质是通过空间换时间,对海量并发的 OLTP 业务支持依然存在局限性。
回看 08 讲中的 User 表,其就是索引组织表的方式:
-
+
表 User 的主键是 id,所以表中的数据根据 id 排序存储,叶子节点存放了表中完整的记录,可以看到表中的数据存放在索引中,即表就是索引,索引就是表。
在了解完 MySQL InnoDB 的主键索引存储方式之后,接下来我们继续了解二级索引。
二级索引
@@ -207,7 +207,7 @@ function hide_canvas() {
SELECT * FROM User WHERE name = 'David',
通过二级索引 idx_name 只能定位主键值,需要额外再通过主键索引进行查询,才能得到最终的结果。这种“二级索引通过主键索引进行再一次查询”的操作叫作“回表”,你可以通过下图理解二级索引的查询:
-
+
索引组织表这样的二级索引设计有一个非常大的好处:若记录发生了修改,则其他索引无须进行维护,除非记录的主键发生了修改。
与堆表的索引实现对比着看,你会发现索引组织表在存在大量变更的场景下,性能优势会非常明显,因为大部分情况下都不需要维护其他二级索引。
前面我强调“索引组织表,数据即索引,索引即数据”。那么为了便于理解二级索引,你可以将二级索引按照一张表来进行理解,比如索引 idx_name 可以理解成一张表,如下所示:
diff --git a/专栏/MySQL实战宝典/10 组合索引:用好,性能提升 10 倍!.md.html b/专栏/MySQL实战宝典/10 组合索引:用好,性能提升 10 倍!.md.html
index 5d5ce10d..4b4f3d1c 100644
--- a/专栏/MySQL实战宝典/10 组合索引:用好,性能提升 10 倍!.md.html
+++ b/专栏/MySQL实战宝典/10 组合索引:用好,性能提升 10 倍!.md.html
@@ -181,10 +181,10 @@ function hide_canvas() {
组合索引
组合索引(Compound Index)是指由多个列所组合而成的 B+树索引,这和我们之前介绍的B+ 树索引的原理完全一样,只是之前是对一个列排序,现在是对多个列排序。
组合索引既可以是主键索引,也可以是二级索引,下图显示的是一个二级组合索引:
-
+
组合索引的 B+ 树结构
从上图可以看到,组合索引只是排序的键值从 1 个变成了多个,本质还是一颗 B+ 树索引。但是你一定要意识到(a,b)和(b,a)这样的组合索引,其排序结果是完全不一样的。而索引的字段变多了,设计上更容易出问题,如:
-
+
对组合索引(a,b)来说,因为其对列 a、b 做了排序,所以它可以对下面两个查询进行优化:
SELECT * FROM table WHERE a = ?
SELECT * FROM table WHERE a = ? AND b = ?
@@ -204,7 +204,7 @@ SELECT * FROM table WHERE a = ? AND b = ?
避免额外排序
在真实的业务场景中,你会遇到根据某个列进行查询,然后按照时间排序的方式逆序展示。
比如在微博业务中,用户的微博展示的就是根据用户 ID 查询出用户订阅的微博,然后根据时间逆序展示;又比如在电商业务中,用户订单详情页就是根据用户 ID 查询出用户的订单数据,然后根据购买时间进行逆序展示。
-
+
上图是 05 节中的淘宝订单详情,根据时间进行了逆序展示。
接着我们用 TPC-H 定义的一组测试表,来展示索引相关示例的展示(TPC-H 定义的库请关注公众号 InsideMySQL,并回复 tpch,获得库表的下载链接)。
TPC-H 是美国交易处理效能委员会( TPC:Transaction Processing Performance Council ) 组织制定的,用来模拟决策支持类应用的一个测试集的规范定义,其模拟的就是一个类似电商业务,看一下其对核心业务表 rders 的设计:
diff --git a/专栏/MySQL实战宝典/11 索引出错:请理解 CBO 的工作原理.md.html b/专栏/MySQL实战宝典/11 索引出错:请理解 CBO 的工作原理.md.html
index 85e76d4a..d2a1a360 100644
--- a/专栏/MySQL实战宝典/11 索引出错:请理解 CBO 的工作原理.md.html
+++ b/专栏/MySQL实战宝典/11 索引出错:请理解 CBO 的工作原理.md.html
@@ -202,7 +202,7 @@ function hide_canvas() {
在查询字段 o_custkey 时,理论上可以使用三个相关的索引:ORDERS_FK1、idx_custkey_orderdate、idx_custkey_orderdate_totalprice。那 MySQL 优化器是怎么从这三个索引中进行选择的呢?
在关系型数据库中,B+ 树索引只是存储的一种数据结构,具体怎么使用,还要依赖数据库的优化器,优化器决定了具体某一索引的选择,也就是常说的执行计划。
而优化器的选择是基于成本(cost),哪个索引的成本越低,优先使用哪个索引。
-
+
MySQL 执行过程
如上图所示,MySQL 数据库由 Server 层和 Engine 层组成:
@@ -217,7 +217,7 @@ function hide_canvas() {
其中,CPU Cost 表示计算的开销,比如索引键值的比较、记录值的比较、结果集的排序……这些操作都在 Server 层完成;
IO Cost 表示引擎层 IO 的开销,MySQL 8.0 可以通过区分一张表的数据是否在内存中,分别计算读取内存 IO 开销以及读取磁盘 IO 的开销。
数据库 mysql 下的表 server_cost、engine_cost 则记录了对于各种成本的计算,如:
-
+
表 server_cost 记录了 Server 层优化器各种操作的成本,这里面包括了所有 CPU Cost,其具体含义如下。
- disk_temptable_create_cost:创建磁盘临时表的成本,默认为20。
diff --git a/专栏/MySQL实战宝典/12 JOIN 连接:到底能不能写 JOIN?.md.html b/专栏/MySQL实战宝典/12 JOIN 连接:到底能不能写 JOIN?.md.html
index 54d8431e..2a77c959 100644
--- a/专栏/MySQL实战宝典/12 JOIN 连接:到底能不能写 JOIN?.md.html
+++ b/专栏/MySQL实战宝典/12 JOIN 连接:到底能不能写 JOIN?.md.html
@@ -207,7 +207,7 @@ ON R.x = S.x
WHERE R.y = ? AND S.z = ?
上面这条 SQL 语句是对表 R 和表 S 进行 INNER JOIN,其中关联的列是 x,WHERE 过滤条件分别过滤表 R 中的列 y 和表 S 中的列 z。那么这种情况下可以有以下两种选择:
-
+
优化器一般认为,通过索引进行查询的效率都一样,所以 Nested Loop Join 算法主要要求驱动表的数量要尽可能少。
所以,如果 WHERE R.y = ?过滤出的数据少,那么这条 SQL 语句会先使用表 R 上列 y 上的索引,筛选出数据,然后再使用表 S 上列 x 的索引进行关联,最后再通过 WHERE S.z = ?过滤出最后数据。
为了深入理解优化器驱动表的选择,咱们先来看下面这条 SQL:
@@ -302,7 +302,7 @@ WHERE
上面这条 SQL 语句是要找出商品类型为 %BRASS,尺寸为 15 的欧洲供应商信息。
因为商品表part 不包含地区信息,所以要从关联表 partsupp 中得到商品供应商信息,然后再从供应商元数据表中得到供应商所在地区信息,最后在外表 region 连接,才能得到最终的结果。
最后的执行计划如下图所示:
-
+
从上图可以发现,其实最早进行连接的是表 supplier 和 nation,接着再和表 partsupp 连接,然后和 part 表连接,再和表 part 连接。上述左右连接算法都是 Nested Loop Join。这时的结果集记录大概有 79,330 条记录
最后和表 region 进行关联,表 region 过滤得到结果5条,这时可以有 2 种选择:
diff --git a/专栏/MySQL实战宝典/13 子查询:放心地使用子查询功能吧!.md.html b/专栏/MySQL实战宝典/13 子查询:放心地使用子查询功能吧!.md.html
index fae18312..3e09b72d 100644
--- a/专栏/MySQL实战宝典/13 子查询:放心地使用子查询功能吧!.md.html
+++ b/专栏/MySQL实战宝典/13 子查询:放心地使用子查询功能吧!.md.html
@@ -214,7 +214,7 @@ WHERE
所以,大部分人都更倾向写子查询,即便是天天与数据库打交道的 DBA 。
不过从优化器的角度看,LEFT JOIN 更易于理解,能进行传统 JOIN 的两表连接,而子查询则要求优化器聪明地将其转换为最优的 JOIN 连接。
我们来看一下,在 MySQL 8.0 版本中,对于上述两条 SQL,最终的执行计划都是:
-
+
可以看到,不论是子查询还是 LEFT JOIN,最终都被转换成了 Nested Loop Join,所以上述两条 SQL 的执行时间是一样的。
即,在 MySQL 8.0 中,优化器会自动地将 IN 子查询优化,优化为最佳的 JOIN 执行计划,这样一来,会显著的提升性能。
子查询 IN 和 EXISTS,哪个性能更好?
@@ -238,7 +238,7 @@ WHERE
你要注意,千万不要盲目地相信网上的一些文章,有的说 IN 的性能更好,有的说 EXISTS 的子查询性能更好。你只关注 SQL 执行计划就可以,如果两者的执行计划一样,性能没有任何差别。
接着说回来,对于上述 NOT EXISTS,它的执行计划如下图所示:
-
+
你可以看到,它和 NOT IN 的子查询执行计划一模一样,所以二者的性能也是一样的。讲完子查询的执行计划之后,接下来我们来看一下一种需要对子查询进行优化的 SQL:依赖子查询。
依赖子查询的优化
在 MySQL 8.0 版本之前,MySQL 对于子查询的优化并不充分。所以在子查询的执行计划中会看到 DEPENDENT SUBQUERY 的提示,这表示是一个依赖子查询,子查询需要依赖外部表的关联。
@@ -258,7 +258,7 @@ WHERE
上述 SQL 语句的子查询部分表示“计算出每个员工最后成交的订单时间”,然后最外层的 SQL表示返回订单的相关信息。
这条 SQL 在最新的 MySQL 8.0 中,其执行计划如下所示:
-
+
通过命令 EXPLAIN FORMAT=tree 输出执行计划,你可以看到,第 3 行有这样的提示:Select #2 (subquery in condition; run only once)。这表示子查询只执行了一次,然后把最终的结果保存起来了。
执行计划的第 6 行Index lookup on <materialized_subquery>,表示对表 orders 和子查询结果所得到的表进行 JOIN 连接,最后返回结果。
所以,当前这个执行计划是对表 orders 做2次扫描,每次扫描约 5587618 条记录:
@@ -267,14 +267,14 @@ WHERE
- 第 2 次表 oders 扫描,查询并返回每个员工的订单信息,即返回每个员工最后一笔成交的订单信息。
最后,直接用命令 EXPLAIN 查看执行计划,如下图所示:
-
+
MySQL 8.0 版本执行过程
如果是老版本的 MySQL 数据库,它的执行计划将会是依赖子查询,执行计划如下所示:
-
+
老版本 MySQL 执行过程
对比 MySQL 8.0,只是在第二行的 select_type 这里有所不同,一个是 SUBQUERY,一个是DEPENDENT SUBQUERY。
接着通过命令 EXPLAIN FORMAT=tree 查看更详细的执行计划过程:
-
+
可以发现,第 3 行的执行技术输出是:Select #2 (subquery in condition; dependent),并不像先前的执行计划,提示只执行一次。另外,通过第 1 行也可以发现,这条 SQL 变成了 exists 子查询,每次和子查询进行关联。
所以,上述执行计划其实表示:先查询每个员工的订单信息,接着对每条记录进行内部的子查询进行依赖判断。也就是说,先进行外表扫描,接着做依赖子查询的判断。所以,子查询执行了5587618,而不是1次!!!
所以,两者的执行计划,扫描次数的对比如下所示:
@@ -295,9 +295,9 @@ WHERE
可以看到,我们将子查询改写为了派生表 o2,然后将表 o2 与外部表 orders 进行关联。关联的条件是:o1.o_clerk = o2.o_clerk AND o1.o_orderdate = o2.orderdate。
通过上面的重写后,派生表 o2 对表 orders 进行了1次扫描,返回约 5587618 条记录。派生表o1 对表 orders 扫描 1 次,返回约 1792612 条记录。这与 8.0 的执行计划就非常相似了,其执行计划如下所示:
-
+
最后,来看下上述 SQL 的执行时间:
-
+
可以看到,经过 SQL 重写后,派生表的执行速度几乎与独立子查询一样。所以,若看到依赖子查询的执行计划,记得先进行 SQL 重写优化哦。
总结
这一讲,我们学习了 MySQL 子查询的优势、新版本 MySQL 8.0 对子查询的优化,以及老版本MySQL 下如何对子查询进行优化。希望你在学完今天的内容之后,可以不再受子查询编写的困惑,而是在各种场景下用好子查询。
diff --git a/专栏/MySQL实战宝典/14 分区表:哪些场景我不建议用分区表?.md.html b/专栏/MySQL实战宝典/14 分区表:哪些场景我不建议用分区表?.md.html
index 60500dd7..59dd3813 100644
--- a/专栏/MySQL实战宝典/14 分区表:哪些场景我不建议用分区表?.md.html
+++ b/专栏/MySQL实战宝典/14 分区表:哪些场景我不建议用分区表?.md.html
@@ -246,7 +246,7 @@ SELECT * FROM t;
很多同学会认为,分区表是把一张大表拆分成了多张小表,所以这样 MySQL 数据库的性能会有大幅提升。这是错误的认识!如果你寄希望于通过分区表提升性能,那么我不建议你使用分区,因为做不到。
分区表技术不是用于提升 MySQL 数据库的性能,而是方便数据的管理。
我们再回顾下 08 讲中提及的“B+树高度与数据存储量之间的关系”:
-
+
从表格中可以看到,B+ 树的高度为 4 能存放数十亿的数据,一次查询只需要占用 4 次 I/O,速度非常快。
但是当你使用分区之后,效果就不一样了,比如上面的表 t,我们根据时间拆成每年一张表,这时,虽然 B+ 树的高度从 4 降为了 3,但是这个提升微乎其微。
除此之外,分区表还会引入新的性能问题,比如非分区列的查询。非分区列的查询,即使分区列上已经创建了索引,但因为索引是每个分区文件对应的本地索引,所以要查询每个分区。
diff --git a/专栏/MySQL实战宝典/15 MySQL 复制:最简单也最容易配置出错.md.html b/专栏/MySQL实战宝典/15 MySQL 复制:最简单也最容易配置出错.md.html
index a3fea97f..e76c6291 100644
--- a/专栏/MySQL实战宝典/15 MySQL 复制:最简单也最容易配置出错.md.html
+++ b/专栏/MySQL实战宝典/15 MySQL 复制:最简单也最容易配置出错.md.html
@@ -189,14 +189,14 @@ WHERE o_orderdate = '1997-12-31';
Query OK, 2482 rows affected (0.07 sec)
可以看到,上面这条 SQL 执行的是删除操作,一共删除了有 2482 行记录。可以在 mysql 命令行下使用命令 SHOW BINLOG EVENTS 查看某个二进制日志文件的内容,比如上述删除操作发生在二进制日志文件 binlog.000004 中,你可以看到:
-
+
通过 MySQL 数据库自带的命令 mysqlbinlog,可以解析二进制日志,观察到更为详细的每条记录的信息,比如:
-
+
从图中,你可以通过二进制日志记录看到被删除记录的完整信息,还有每个列的属性,比如列的类型,是否允许为 NULL 值等。
如果是 UPDATE 操作,二进制日志中还记录了被修改记录完整的前项和后项,比如:
-
+
在有二进制日志的基础上,MySQL 数据库就可以通过数据复制技术实现数据同步了。而数据复制的本质就是把一台 MySQL 数据库上的变更同步到另一台 MySQL 数据库上。下面这张图显示了当前 MySQL 数据库的复制架构:
-
+
可以看到,在 MySQL 复制中,一台是数据库的角色是 Master(也叫 Primary),剩下的服务器角色是 Slave(也叫 Standby):
- Master 服务器会把数据变更产生的二进制日志通过 Dump 线程发送给 Slave 服务器;
@@ -204,7 +204,7 @@ Query OK, 2482 rows affected (0.07 sec)
- SQL/Worker 线程负责并行执行中继日志,即在 Slave 服务器上回放 Master 产生的日志。
得益于二进制日志,MySQL 的复制相比其他数据库,如 Oracle、PostgreSQL 等,非常灵活,用户可以根据自己的需要构建所需要的复制拓扑结构,比如:
-
+
在上图中,Slave1、Slave2、Slave3 都是 Master 的从服务器,而 Slave11 是 Slave1 的从服务器,Slave1 服务器既是 Master 的从机,又是 Slave11 的主机,所以 Slave1 是个级联的从机。同理,Slave3 也是台级联的从机。
在了解完复制的基本概念后,我们继续看如何配置 MySQL 的复制吧。
MySQL 复制配置
@@ -228,7 +228,7 @@ relay_log_info_repository = TABLE
了解完复制的配置后,我们接下来看一下 MySQL 支持的复制类型。
MySQL复制类型及应用选项
MySQL 复制可以分为以下几种类型:
-
+
默认的复制是异步复制,而很多新同学因为不了解 MySQL 除了异步复制还有其他复制的类型,所以错误地在业务中使用了异步复制。为了解决这个问题,我们一起详细了解一下每种复制类型,以及它们在业务中的选型,方便你在业务做正确的选型。
异步复制
在异步复制(async replication)中,Master 不用关心 Slave 是否接收到二进制日志,所以 Master 与 Slave 没有任何的依赖关系。你可以认为 Master 和 Slave 是分别独自工作的两台服务器,数据最终会通过二进制日志达到一致。
@@ -250,17 +250,17 @@ rpl_semi_sync_master_wait_no_slave = 1
在半同步复制中,有损半同步复制是 MySQL 5.7 版本前的半同步复制机制,这种半同步复制在Master 发生宕机时,Slave 会丢失最后一批提交的数据,若这时 Slave 提升(Failover)为Master,可能会发生已经提交的事情不见了,发生了回滚的情况。
有损半同步复制原理如下图所示:
-
+
可以看到,有损半同步是在 Master 事务提交后,即步骤 4 后,等待 Slave 返回 ACK,表示至少有 Slave 接收到了二进制日志,如果这时二进制日志还未发送到 Slave,Master 就发生宕机,则此时 Slave 就会丢失 Master 已经提交的数据。
而 MySQL 5.7 的无损半同步复制解决了这个问题,其原理如下图所示:
-
+
从上图可以看到,无损半同步复制 WAIT ACK 发生在事务提交之前,这样即便 Slave 没有收到二进制日志,但是 Master 宕机了,由于最后一个事务还没有提交,所以本身这个数据对外也不可见,不存在丢失的问题。
所以,对于任何有数据一致性要求的业务,如电商的核心订单业务、银行、保险、证券等与资金密切相关的业务,务必使用无损半同步复制。这样数据才是安全的、有保障的、即使发生宕机,从机也有一份完整的数据。
多源复制
无论是异步复制还是半同步复制,都是 1 个 Master 对应 N 个 Slave。其实 MySQL 也支持 N 个 Master 对应 1 个 Slave,这种架构就称之为多源复制。
多源复制允许在不同 MySQL 实例上的数据同步到 1 台 MySQL 实例上,方便在 1 台 Slave 服务器上进行一些统计查询,如常见的 OLAP 业务查询。
多源复制的架构如下所示:
-
+
上图显示了订单库、库存库、供应商库,通过多源复制同步到了一台 MySQL 实例上,接着就可以通过 MySQL 8.0 提供的复杂 SQL 能力,对业务进行深度的数据分析和挖掘。
延迟复制
前面介绍的复制架构,Slave 在接收二进制日志后会尽可能快地回放日志,这样是为了避免主从之间出现延迟。而延迟复制却允许Slave 延迟回放接收到的二进制日志,为了避免主服务器上的误操作,马上又同步到了从服务器,导致数据完全丢失。
diff --git a/专栏/MySQL实战宝典/16 读写分离设计:复制延迟?其实是你用错了.md.html b/专栏/MySQL实战宝典/16 读写分离设计:复制延迟?其实是你用错了.md.html
index 9eb409b1..ad89b185 100644
--- a/专栏/MySQL实战宝典/16 读写分离设计:复制延迟?其实是你用错了.md.html
+++ b/专栏/MySQL实战宝典/16 读写分离设计:复制延迟?其实是你用错了.md.html
@@ -219,7 +219,7 @@ slave-parallel-workers = 16
主从复制延迟监控
Seconds_Behind_Master
很多同学或许知道通过命令 SHOW SLAVE STATUS,其中的 Seconds_Behind_Master 可以查看复制延迟,如:
-
+
但是,Seconds_Behind_Master 不准确!用于严格判断主从延迟的问题并不合适, 有这样三个原因。
- 它计算规则是(当前回放二进制时间 - 二进制日志中的时间),如果 I/O 线程有延迟,那么 Second_Behind_Master 为 0,这时可能已经落后非常多了,例如存在有大事务的情况下;
@@ -252,13 +252,13 @@ END
读写分离设计
读写分离设计是指:把对数据库的读写请求分布到不同的数据库服务器上。对于写入操作只能请求主服务器,而对读取操作则可以将读取请求分布到不同的从服务器上。
这样能有效降低主服务器的负载,提升从服务器资源利用率,从而进一步提升整体业务的性能。下面这张图显示了一种常见的业务读写分离的架构设计:
-
+
上图引入了 Load Balance 负载均衡的组件,这样 Server 对于数据库的请求不用关心后面有多少个从机,对于业务来说也就是透明的,只需访问 Load Balance 服务器的 IP 或域名就可以。
通过配置 Load Balance 服务,还能将读取请求平均或按照权重平均分布到不同的从服务器。这可以根据架构的需要做灵活的设计。
请记住:读写分离设计的前提是从机不能落后主机很多,最好是能准实时数据同步,务必一定要开始并行复制,并确保线上已经将大事务拆成小事务。
当然,若是一些报表类的查询,只要不影响最终结果,业务是能够容忍一些延迟的。但无论如何,请一定要在线上数据库环境中做好主从复制延迟的监控。
如果真的由于一些不可预知的情况发生,比如一个初级 DBA 在主机上做了一个大事务操作,导致主从延迟发生,那么怎么做好读写分离设计的兜底呢?
-
+
在 Load Balance 服务器,可以配置较小比例的读取请求访问主机,如上图所示的 1%,其余三台从服务器各自承担 33% 的读取请求。
如果发生严重的主从复制情况,可以设置下面从机权重为 0,将主机权重设置为 100%,这样就不会因为数据延迟,导致对于业务的影响了。
总结
diff --git a/专栏/MySQL实战宝典/18 金融级高可用架构:必不可少的数据核对.md.html b/专栏/MySQL实战宝典/18 金融级高可用架构:必不可少的数据核对.md.html
index 496eb914..fd6bc91d 100644
--- a/专栏/MySQL实战宝典/18 金融级高可用架构:必不可少的数据核对.md.html
+++ b/专栏/MySQL实战宝典/18 金融级高可用架构:必不可少的数据核对.md.html
@@ -184,9 +184,9 @@ function hide_canvas() {
在 MySQL 内部就是要把参数 rpl_semi_sync_master_wait_point 设置成 AFTER_SYNC 。
但是在高可用设计时,当数据库 FAILOVER 完后,有时还要对原来的主机做额外的操作,这样才能保证主从数据的完全一致性。
我们来看这样一张图:
-
+
从图中可以看到,即使启用无损半同步复制,依然存在当发生主机宕机时,最后一组事务没有上传到从机的可能。图中宕机的主机已经提交事务到 101,但是从机只接收到事务 100。如果这个时候 Failover,从机提升为主机,那么这时:
-
+
可以看到当主从切换完成后,新的 MySQL 开始写入新的事务102,如果这时老的主服务器从宕机中恢复,则这时事务 101 不会同步到新主服务器,导致主从数据不一致。
但设置 AFTER_SYNC 无损半同步的好处是,虽然事务 101 在原主机已经提交,但是在从机没有收到并返回 ACK 前,这个事务对用户是不可见的,所以,用户感受不到事务已经提交了。
所以,在做高可用设计时,当老主机恢复时,需要做一次额外的处理,把事务101给“回滚”(具体怎么实现我们将在 20 讲,高可用套件中具体分析)。
@@ -205,21 +205,21 @@ function hide_canvas() {
前面我们谈到的高可用设计,都只是机房内的容灾。也就是说,我们的主服务器和从服务器都在一个机房内,现在我们来看一下同城和跨城的容灾设计(我提醒一下,不论是机房内容灾、同城容灾,还是跨城容灾,都是基于 MySQL 的无损半同步复制,只是物理部署方式不同,解决不同的问题)。
对于同城容灾,我看到很多这样的设计:
-
+
这种设计没有考虑到机房网络的抖动。如果机房 1 和机房 2 之间的网络发生抖动,那么因为事务提交需要机房 2 中的从服务器接收日志,所以会出现事务提交被 hang 住的问题。
而机房网络抖动非常常见,所以核心业务同城容灾务要采用三园区的架构,如下图所示:
-
+
该架构称为“三园区的架构”,如果三个机房都在一个城市,则称为“ 一地三中心”,如果在相邻两个城市,那么就叫“两地三中心”。但这种同城/近城容灾,要求机房网络之间的延迟不超过 5ms。
在三园区架构中,一份数据被存放在了 3 个机房,机房之间根据半同步复制。这里将 MySQL 的半同步复制参数 rpl_semi_sync_master_wait_for_slave_count 设置为 1,表示只要有 1 个半同步备机接收到日志,主服务器上的事务就可以提交。
这样的设计,保证除主机房外,数据在其他机房至少一份完整的数据。
另外,即便机房 1 与机房 2 发生网络抖动,因为机房 1 与机房 3 之间的网络很好,不会影响事务在主服务器上的提交。如果机房 1 的出口交换机或光纤发生故障,那么这时高可用套件会 FAILOVER 到机房 2 或机房 3,因为至少有一份数据是完整的。
机房 2、机房 3 的数据用于保障数据一致性,但是如果要实现读写分离,或备份,还需要引入异步复制的备机节点。所以整体架构调整为:
-
+
从图中可以看到,我们加入两个异步复制的节点,用于业务实现读写分离,另外再从机房 3 的备机中,引入一个异步复制的延迟备机,用于做数据误删除操作的恢复。
当设计成类似上述的架构时,你才能认为自己的同城容灾架构是合格的!
另一个重要的点:因为机房 1 中的主服务器要向四个从服务器发送日志,这时网卡有成为瓶颈的可能,所以请务必配置万兆网卡。
在明白三园区架构后,要实现跨城容灾也就非常简单了, 只要把三个机房放在不同城市就行。但这样的设计,当主服务器发生宕机时,数据库就会切到跨城,而跨城之间的网络延迟超过了25 ms。所以,跨城容灾一般设计成“三地五中心”的架构,如下图所示:
-
+
在上图中:机房 1、机房 2 在城市 1 中;机房 3、机房 4 在城市 2 中;机房 5 在城市 3 中,三个城市之间的距离超过 200 公里,延迟超过 25ms。
由于有五个机房,所以 ACK 设置为 2,保证至少一份数据在两个机房有数据。这样当发生城市级故障,则城市 2 或城市 3 中,至少有一份完整的数据。
在真实的互联网业务场景中,“三地五中心”应用并不像“三园区”那样普遍。这是因为 25ms的延迟对业务的影响非常大,一般这种架构应用于读多写少的场景,比如用户中心。
@@ -237,7 +237,7 @@ function hide_canvas() {
业务逻辑核对由业务的同学负责编写, 从整个业务逻辑调度看账平不平。例如“今天库存的消耗”是否等于“订单明细表中的总和”,“在途快递” + “已收快递”是否等于“已下快递总和”。总之,这是个业务逻辑,用于对账。
主从服务器之间的核对,是由数据库团队负责的。 需要额外写一个主从核对服务,用于保障主从数据的一致性。这个核对不依赖复制本身,也是一种逻辑核对。思路是:将最近一段时间内主服务器上变更过的记录与从服务器核对,从逻辑上验证是否一致。其实现如图所示:
-
+
那么现在的难题是:如何判断最近一段时间内主服务器上变更过的记录?这里有两种思路:
- 表结构设计规范中,有讲过每张表有一个 last_modify_date,用于记录每条记录的最后修改时间,按照这个条件过滤就能查出最近更新的记录,然后每条记录比较即可。
diff --git a/专栏/MySQL实战宝典/19 高可用套件:选择这么多,你该如何选?.md.html b/专栏/MySQL实战宝典/19 高可用套件:选择这么多,你该如何选?.md.html
index cbb27170..90f625b7 100644
--- a/专栏/MySQL实战宝典/19 高可用套件:选择这么多,你该如何选?.md.html
+++ b/专栏/MySQL实战宝典/19 高可用套件:选择这么多,你该如何选?.md.html
@@ -184,15 +184,15 @@ function hide_canvas() {
为了不让业务感知到数据库的宕机切换,这里要用到 VIP(Virtual IP)技术。其中,VIP 不是真实的物理 IP,而是可以随意绑定在任何一台服务器上。
业务访问数据库,不是服务器上与网卡绑定的物理 IP,而是这台服务器上的 VIP。当数据库服务器发生宕机时,高可用套件会把 VIP 插拔到新的服务器上。数据库 Failover后,业务依旧访问的还是 VIP,所以使用 VIP 可以做到对业务透明。
下面这张图显示了业务通过 VIP 进行数据库的访问:
-
+
从上图可以看到,MySQL 的主服务器的 IP 地址是 192.168.1.10,两个从服务器的 IP 地址分别为 192.168.1.20、192.168.1.30。
上层服务访问数据库并没有直接通过物理 IP 192.168.1.10,而是访问 VIP,地址为192.168.1.100。这时,如果 MySQL 数据库主服务器发生宕机,会进行如下的处理:
-
+
我们可以看到,当发生 Failover 后,由于上层服务访问的是 VIP 192.168.1.100,所以切换对服务来说是透明的,只是在切换过程中,服务会收到连接数据库失败的提示。但是通过重试机制,当下层数据库完成切换后,服务就可以继续使用了。所以,上层服务一定要做好错误重试的逻辑,否则就算启用 VIP,也无法实现透明的切换。
但是 VIP 也是有局限性的,仅限于同机房同网段的 IP 设定。如果是我们之前设计的三园区同城跨机房容灾架构,VIP 就不可用了。这时就要用名字服务,常见的名字服务就是 DNS(Domain Name Service),如下所示:
-
+
从上图可以看到,这里将域名 m1.insidemysql.com 对应的 IP 指向为了 192.168.1.10,上层业务通过域名进行访问。当发生宕机,进行机房级切换后,结果变为:
-
+
可以看到,当发生 Failover 后,高可用套件会把域名指向为新的 MySQL 主服务器,IP 地址为202.177.54.20,这样也实现了对于上层服务的透明性。
虽然使用域名或其他名字服务可以解决跨机房的切换问题,但是引入了新的组件。新组件的高可用的问题也需要特别注意。在架构设计时,请咨询公司提供名字服务的小组,和他们一起设计高可用的容灾架构。
了解了上述的高可用透明切换机制,我们继续看一下业界 MySQL 常见的几款高可用套件。
@@ -203,19 +203,19 @@ function hide_canvas() {
而 MHA Node 部署在每台 MySQL 服务器上,MHA Manager 通过执行 Node 节点的脚本完成failover 切换操作。
MHA Manager 和 MHA Node 的通信是采用 ssh 的方式,也就是需要在生产环境中打通 MHA Manager 到所有 MySQL 节点的 ssh 策略,那么这里就存在潜在的安全风险。
另外,ssh 通信,效率也不是特别高。所以,MHA 比较适合用于规模不是特别大的公司,所有MySQL 数据库的服务器数量不超过 20 台。
-
!
+
!
Orchestrator
Orchestrator 是另一款开源的 MySQL 高可用套件,除了支持 failover 的切换,还可通过Orchestrator 完成 MySQL 数据库的一些简单的复制管理操作。Orchestrator 的开源地址为:https://github.com/openark/orchestrator
你可以把 Orchestrator 当成 MHA 的升级版,而且提供了 HTTP 接口来进行相关数据库的操作,比起 MHA 需要每次登录 MHA Manager 服务器来说,方便很多。
下图显示了 Orchestrator 的高可用设计架构:
-
+
其基本实现原理与 MHA 是一样的,只是把元数据信息存储在了元数据库中,并且提供了HTTP 接口和命令的访问方式,使用上更为友好。
但是由于管控节点到下面的 MySQL 数据库的管理依然是 ssh 的方式,依然存在 MHA 一样的短板问题,总的来说,关于 Orchestrator 我想提醒你,依然只建议使用在较小规模的数据库集群。
数据库管理平台
当然了,虽然 MHA 和 Orchestrator 都可以完成 MySQL 高可用的 failover 操作,但是,在生产环境中如果需要管理成千乃至上万的数据库服务器,由于它们的通信仅采用 ssh 的方式,并不能满足生产上的安全性和性能的要求。
所以,几乎每家互联网公司都会自研一个数据库的管理平台,用于管理公司所有的数据库集群,以及数据库的容灾切换工作。
接下来,我想带你详细了解数据库管理平台的架构。下图显示了数据库管理平台大致的实现框架:
-
+
上图中的数据库管理平台是用户操作数据库的入口。对数据库的大部分操作,比如数据库的初始化、数据查询、数据备份等操作、后续都能在这个平台完成,不用登录数据库服务器,这样的好处是能大大提升数据库操作的效率。
数据库管理平台提供了 HTTP API 的方式,可用前后端分离的方式支持 Web、手机等多种访问方式。
元数据库用于存储管理 MySQL 数据库所有的节点信息,比如 IP 地址、端口、域名等。
diff --git a/专栏/MySQL实战宝典/21 数据库备份:备份文件也要检查!.md.html b/专栏/MySQL实战宝典/21 数据库备份:备份文件也要检查!.md.html
index a5adc40c..abd1ae12 100644
--- a/专栏/MySQL实战宝典/21 数据库备份:备份文件也要检查!.md.html
+++ b/专栏/MySQL实战宝典/21 数据库备份:备份文件也要检查!.md.html
@@ -246,7 +246,7 @@ Dump progress: 25/37 tables, 1683132/42965650 rows
上面的命令显示了通过 mysqlpump 进行备份。参数 --default-parallelism 表示设置备份的并行线程数。此外,与 mysqldump 不同的是,mysqlpump 在备份过程中可以查看备份的进度。
不过在真正的线上生产环境中,我并不推荐你使用 mysqlpump, 因为当备份并发线程数超过 1 时,它不能构建一个一致性的备份。见 mysqlpump 的提示:
-
+
另外,mysqlpump 的备份多线程是基于多个表的并行备份,如果数据库中存在一个超级大表,那么对于这个表的备份依然还是单线程的。那么有没有一种基于记录级别的并行备份,且支持一致性的逻辑备份工具呢?
有的,那就是开源的 mydumper 工具,地址:https://github.com/maxbube/mydumper。mydumper 的强大之处在于:
diff --git a/专栏/MySQL实战宝典/22 分布式数据库架构:彻底理解什么叫分布式数据库.md.html b/专栏/MySQL实战宝典/22 分布式数据库架构:彻底理解什么叫分布式数据库.md.html
index 8465f07e..846974b1 100644
--- a/专栏/MySQL实战宝典/22 分布式数据库架构:彻底理解什么叫分布式数据库.md.html
+++ b/专栏/MySQL实战宝典/22 分布式数据库架构:彻底理解什么叫分布式数据库.md.html
@@ -187,7 +187,7 @@ function hide_canvas() {
从定义来看,分布式数据库是一种把数据分散存储在不同物理位置的数据库。
对比我们之前学习的数据库,数据都是存放在一个实例对应的物理存储上,而在分布式数据库中,数据将存放在不同的数据库实例上。
-
+
分布式数据库的架构
从图中我们可以看到,在分布式数据库下,分布式数据库本身分为计算层、元数据层和存储层:
@@ -202,10 +202,10 @@ function hide_canvas() {
接下来,我们看一看分布式 MySQL 数据库的整体架构。
分布式MySQL架构
在学习分布式 MySQL 架构前,我们先看一下原先单机 MySQL 架构是怎样的。
-
+
可以看到,原先客户端是通过 MySQL 通信协议访问 MySQL 数据库,MySQL 数据库会通过高可用技术做多副本,当发生宕机进行切换。
那么对于分布式 MySQL 数据库架构,其整体架构如下图所示:
-
+
从上图可以看到,这时数据将打散存储在下方各个 MySQL 实例中,每份数据叫“分片(Shard)”。
在分布式 MySQL 架构下,客户端不再是访问 MySQL 数据库本身,而是访问一个分布式中间件。
这个分布式中间件的通信协议依然采用 MySQL 通信协议(因为原先客户端是如何访问的MySQL 的,现在就如何访问分布式中间件)。分布式中间件会根据元数据信息,自动将用户请求路由到下面的 MySQL 分片中,从而将存储存取到指定的节点。
diff --git a/专栏/MySQL实战宝典/23 分布式数据库表结构设计:如何正确地将数据分片?.md.html b/专栏/MySQL实战宝典/23 分布式数据库表结构设计:如何正确地将数据分片?.md.html
index 0a64e892..bbd2ed26 100644
--- a/专栏/MySQL实战宝典/23 分布式数据库表结构设计:如何正确地将数据分片?.md.html
+++ b/专栏/MySQL实战宝典/23 分布式数据库表结构设计:如何正确地将数据分片?.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
而第一步就是要对表选出一个分片键,然后进行分布式架构的设计。
对于上面的表orders,可以选择的分片键有:o_orderkey、o_orderdate、也可以是o_custkey。在选出分片键后,就要选择分片的算法,比较常见的有 RANGE 和 HASH 算法。
比如,表 orders,选择分片键 o_orderdate,根据函数 YEAR 求出订单年份,然后根据RANGE 算法进行分片,这样就能设计出基于 RANGE 分片算法的分布式数据库架构:
-
+
从图中我们可以看到,采用 RANGE 算法进行分片后,表 orders 中,1992 年的订单数据存放在分片 1 中、1993 年的订单数据存放在分片 2 中、1994 年的订单数据存放在分片 3中,依次类推,如果要存放新年份的订单数据,追加新的分片即可。
不过,RANGE 分片算法在分布式数据库架构中,是一种非常糟糕的算法,因为对于分布式架构,通常希望能解决传统单实例数据库两个痛点:
@@ -216,10 +216,10 @@ function hide_canvas() {
所以在分布式架构中,RANGE 分区算法是一种比较糟糕的算法。但它也有好处:可以方便数据在不同机器间进行迁移(migrate),比如要把分片 2 中 1992 年的数据迁移到分片 1,直接将表进行迁移就行。
而对海量并发的 OLTP 业务来说,一般推荐用 HASH 的分区算法。这样分片的每个节点都可以有实时的访问,每个节点负载都能相对平衡,从而实现性能和存储层的线性可扩展。
我们来看表 orders 根据 o_orderkey 进行 HASH 分片,分片算法如下:
-
+
在上述分片算法中,分片键是 o_orderkey,总的分片数量是 4(即把原来 1 份数据打散到 4 张表中),具体来讲,分片算法是将 o_orderkey 除以 4 进行取模操作。
最终,将表orders 根据 HASH 算法进行分布式设计后的结果如下图所示:
-
+
可以看到,对于订单号除以 4,余数为 0 的数据存放在分片 1 中,余数为 1 的数据存放在分片 2 中,余数为 2 的数据存放在分片 3 中,以此类推。
这种基于 HASH 算法的分片设计才能较好地应用于大型互联网业务,真正做到分布式数据库架构弹性可扩展的设计要求。
但是,表 orders 分区键选择 o_orderkey 是最好地选择吗?并不是。
@@ -227,7 +227,7 @@ function hide_canvas() {
如果用 o_orderkey 作分区键,那么 lineitem 可以用 l_orderkey 作为分区键,但这时会发现表customer 并没有订单的相关信息,即无法使用订单作为分片键。
如果表 customer 选择另一个字段作为分片键,那么业务数据无法做到单元化,也就是对于表customer、orders、lineitem,分片数据在同一数据库实例上。
所以,如果要实现分片数据的单元化,最好的选择是把用户字段作为分区键,在表 customer 中就是将 c_custkey 作为分片键,表orders 中将 o_custkey 作为分片键,表 lineitem 中将 l_custkey 作为分片键:
-
+
这样做的好处是:根据用户维度进行查询时,可以在单个分片上完成所有的操作,不用涉及跨分片的访问,如下面的 SQL:
SELECT * FROM orders
INNER JOIN lineitem ON o_orderkey = l_orderkey
@@ -260,11 +260,11 @@ ORDER BY o_orderdate DESC LIMIT 10
- 同一分片键的表都在同一库下,方便做整体数据的迁移和扩容。
如果根据第 4 种标准的分库分表规范,那么分布式 MySQL 数据库的架构可以是这样:
-
+
有没有发现,按上面这样的分布式设计,数据分片完成后,所有的库表依然是在同一个 MySQL实例上!!!
牢记,分布式数据库并不一定要求有很多个实例,最基本的要求是将数据进行打散分片。接着,用户可以根据自己的需要,进行扩缩容,以此实现数据库性能和容量的伸缩性。这才是分布式数据库真正的魅力所在。
对于上述的分布式数据库架构,一开始我们将 4 个分片数据存储在一个 MySQL 实例上,但是如果遇到一些大促活动,可以对其进行扩容,比如把 4 个分片扩容到 4 个MySQL实例上:
-
+
如果完成了大促活动,又可以对资源进行回收,将分片又都放到一台 MySQL 实例上,这就是对资源进行缩容。
总的来说,对分布式数据库进行扩缩容在互联网公司是一件常见的操作,比如对阿里来说,每年下半年 7 月开始,他们就要进行双 11 活动的容量评估,然后根据评估结果规划数据库的扩容。
一般来说,电商的双 11 活动后,还有双 12、新年、春节,所以一般会持续到过完年再对数据库进行缩容。接下来,我们来看看如何进行扩缩容。
@@ -278,9 +278,9 @@ ORDER BY o_orderdate DESC LIMIT 10
replicate_do_db ="tpch01"
所以在进行扩容时,首先根据下图的方式对扩容的分片进行过滤复制的配置:
-
+
然后再找一个业务低峰期,将业务的请求转向新的分片,完成最终的扩容操作:
-
+
至于缩容操作,本质就是扩容操作的逆操作,这里就不再多说了。
总结
今天这一讲,我们学习了分布式数据库架构设计中的分片设计,也就是我们经常听说的分库分表设计。希望通过本讲,你能牢牢掌握以下内容:
diff --git a/专栏/MySQL实战宝典/24 分布式数据库索引设计:二级索引、全局索引的最佳设计实践.md.html b/专栏/MySQL实战宝典/24 分布式数据库索引设计:二级索引、全局索引的最佳设计实践.md.html
index c812ed57..ded518a5 100644
--- a/专栏/MySQL实战宝典/24 分布式数据库索引设计:二级索引、全局索引的最佳设计实践.md.html
+++ b/专栏/MySQL实战宝典/24 分布式数据库索引设计:二级索引、全局索引的最佳设计实践.md.html
@@ -198,7 +198,7 @@ function hide_canvas() {
) ENGINE=InnoDB
如果把 o_orderkey 设计成上图所示的自增,那么很可能 o_orderkey 同为 1 的记录在不同的分片出现,如下图所示:
-
+
所以,在分布式数据库架构下,尽量不要用自增作为表的主键,这也是我们在第一模块“表结构设计”中强调过的:自增性能很差、安全性不高、不适用于分布式架构。
讲到这儿,我们已经说明白了“自增主键”的所有问题,那么该如何设计主键呢?依然还是用全局唯一的键作为主键,比如 MySQL 自动生成的有序 UUID;业务生成的全局唯一键(比如发号器);或者是开源的 UUID 生成算法,比如雪花算法(但是存在时间回溯的问题)。
总之,用有序的全局唯一替代自增,是这个时代数据库主键的主流设计标准,如果你还停留在用自增做主键,或许代表你已经落后于时代发展了。
@@ -253,7 +253,7 @@ WHERE o_orderate >= ? o_orderdate < ?
因此,再次提醒你,分布式数据库架构设计的要求是业务的绝大部分请求能够根据分片键定位到 1 个分片上。
如果业务大部分请求都需要扫描所有分片信息才能获得最终结果,那么就不适合进行分布式架构的改造或设计。
最后,我们再来回顾下淘宝用户订单表的设计:
-
+
上图是我的淘宝订单信息,可以看到,订单号的最后 6 位都是 308113,所以可以大概率推测出:
- 淘宝订单表的分片键是用户 ID;
@@ -262,7 +262,7 @@ WHERE o_orderate >= ? o_orderdate < ?
全局表
在分布式数据库中,有时会有一些无法提供分片键的表,但这些表又非常小,一般用于保存一些全局信息,平时更新也较少,绝大多数场景仅用于查询操作。
例如 tpch 库中的表 nation,用于存储国家信息,但是在我们前面的 SQL 关联查询中,又经常会使用到这张表,对于这种全局表,可以在每个分片中存储,这样就不用跨分片地进行查询了。如下面的设计:
-
+
唯一索引
最后我们来谈谈唯一索引的设计,与主键一样,如果只是通过数据库表本身唯一约束创建的索引,则无法保证在所有分片中都是唯一的。
所以,在分布式数据库中,唯一索引一样要通过类似主键的 UUID 的机制实现,用全局唯一去替代局部唯一,但实际上,即便是单机的 MySQL 数据库架构,我们也推荐使用全局唯一的设计。因为你不知道,什么时候,你的业务就会升级到全局唯一的要求了。
diff --git a/专栏/MySQL实战宝典/25 分布式数据库架构选型:分库分表 or 中间件 ?.md.html b/专栏/MySQL实战宝典/25 分布式数据库架构选型:分库分表 or 中间件 ?.md.html
index 446b2b65..67b9f33d 100644
--- a/专栏/MySQL实战宝典/25 分布式数据库架构选型:分库分表 or 中间件 ?.md.html
+++ b/专栏/MySQL实战宝典/25 分布式数据库架构选型:分库分表 or 中间件 ?.md.html
@@ -225,7 +225,7 @@ function hide_canvas() {
使用中间件技术
另一种比较流行的分布式数据库访问方式是通过分布式数据库中间件。数据库中间件本身模拟成一个 MySQL 数据库,通信协议也都遵循 MySQL 协议:业务之前怎么访问MySQL数据库的,就如何访问MySQL分布式数据库中间件。
这样做的优点是:业务不用关注分布式数据库中的分片信息,把它默认为一个单机数据库使用就好了。这种模式也是大部分同学认为分布式数据库该有的样子,如下面的图:
-
+
可以看到,通过分布式 MySQL 中间件,用户只需要访问中间件就行,下面的数据路由、分布式事务的实现等操作全部交由中间件完成。所以,分布式数据库中间件变成一个非常关键的核心组件。
业界比较知名的 MySQL 分布式数据库中间件产品有:ShardingShpere、DBLE、TDSQL 等。
ShardingSphere于 2020 年 4 月 16 日成为 Apache 软件基金会的顶级项目、社区熟度、功能支持较多,特别是对于分布式事务的支持,有多种选择(ShardingSphere 官网地址)。
diff --git a/专栏/MySQL实战宝典/27 分布式事务:我们到底要不要使用 2PC?.md.html b/专栏/MySQL实战宝典/27 分布式事务:我们到底要不要使用 2PC?.md.html
index 5abc3e11..19b7878b 100644
--- a/专栏/MySQL实战宝典/27 分布式事务:我们到底要不要使用 2PC?.md.html
+++ b/专栏/MySQL实战宝典/27 分布式事务:我们到底要不要使用 2PC?.md.html
@@ -213,10 +213,10 @@ COMMIT;
用户可以通过命令 XA_RECOVER 查看节点上事务有悬挂事务:

如果有悬挂事务,则这个事务持有的锁资源都是没有释放的。可以通过命令SHOW ENGINE INNODB STATUS 进行查看:
-
+
从上图可以看到,事务 5136 处于 PREPARE状态,已经有 218 秒了,这就是一个悬挂事务,并且这个事务只有了两个行锁对象。
可以通过命令 XA RECOVER 人工的进行提交:
-
+
讲到这,同学们应该都了了分布式事务的 2PC 实现和使用方法。它是一种由数据库层实现强一致事务解决方案。其优点是使用简单,当前大部分的语言都支持 2PC 的实现。若使用中间件,业务完全就不用关心事务是不是分布式的。
然而,他的缺点是,事务的提交开销变大了,从 1 次 COMMIT 变成了两次 PREPARE 和COMMIT。而对于海量的互联网业务来说,2PC 的性能是无法接受。因此,这就有了业务级的分布式事务实现,即柔性事务。
柔性事务
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/00 学好 Netty,是你修炼 Java 内功的必经之路.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/00 学好 Netty,是你修炼 Java 内功的必经之路.md.html
index bdf94c51..9faaf823 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/00 学好 Netty,是你修炼 Java 内功的必经之路.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/00 学好 Netty,是你修炼 Java 内功的必经之路.md.html
@@ -235,7 +235,7 @@ function hide_canvas() {
那么这个课程就是为你量身定做的,课程中我会结合高频的面试题,从源码出发剖析 Netty 的核心技术原理,同时将这么多年使我受益匪浅的一些编程思想和实战经验分享给你,帮助你在工作中学以致用,避免踩坑。
在这里我也总结归纳出一份 Netty 核心知识点的思维导图,希望可以帮助你梳理本专栏的整体知识脉络。我会由浅入深地带你建立起完整的 Netty 知识体系,夯实你的 Netty 基础知识、Netty 进阶技能、实战开发经验。
-
+
- 夯实 Netty 基础知识:第一、二部分介绍 Netty 的全貌,了解 Netty 的发展现状和技术架构,并且逐一讲解了 Netty 的核心组件原理和使用,以及网络通信必不可少的编解码技能,为后面的源码解析和实践环节打下基础。
- Netty 进阶技能:第三部分讲解 Netty 的内存管理,并希望通过对比介绍 Nginx、Redis 两个著名的开源软件,帮你达到举一反三的能力。第四部分结合高频的面试问题,通过多解读剖析 Netty 的核心源码,帮助你快速准确地理解 Netty 高性能的技术原理,对其中的设计思想学以致用。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/01 初识 Netty:为什么 Netty 这么流行?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/01 初识 Netty:为什么 Netty 这么流行?.md.html
index d42bcd4c..e58d7a1e 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/01 初识 Netty:为什么 Netty 这么流行?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/01 初识 Netty:为什么 Netty 这么流行?.md.html
@@ -239,28 +239,28 @@ function hide_canvas() {
- 第二个阶段为I/O 执行阶段。此时,内核等待 I/O 请求处理完成返回。该阶段分为两个过程:首先等待数据就绪,并写入内核缓冲区;随后将内核缓冲区数据拷贝至用户态缓冲区。
为了方便大家理解,可以看一下这张图:
-
+
接下来我们来回顾一下 Linux 的 5 种主要 I/O 模式,并看下各种 I/O 模式的优劣势都在哪里?
1. 同步阻塞 I/O(BIO)
-
+
如上图所表现的那样,应用进程向内核发起 I/O 请求,发起调用的线程一直等待内核返回结果。一次完整的 I/O 请求称为BIO(Blocking IO,阻塞 I/O),所以 BIO 在实现异步操作时,只能使用多线程模型,一个请求对应一个线程。但是,线程的资源是有限且宝贵的,创建过多的线程会增加线程切换的开销。
2. 同步非阻塞 I/O(NIO)
-
+
在刚介绍完 BIO 的网络模型之后,NIO 自然就很好理解了。
如上图所示,应用进程向内核发起 I/O 请求后不再会同步等待结果,而是会立即返回,通过轮询的方式获取请求结果。NIO 相比 BIO 虽然大幅提升了性能,但是轮询过程中大量的系统调用导致上下文切换开销很大。所以,单独使用非阻塞 I/O 时效率并不高,而且随着并发量的提升,非阻塞 I/O 会存在严重的性能浪费。
3. I/O 多路复用
-
+
多路复用实现了一个线程处理多个 I/O 句柄的操作。多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket。select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态。多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
4. 信号驱动 I/O
-
+
信号驱动 I/O 并不常用,它是一种半异步的 I/O 模型。在使用信号驱动 I/O 时,当数据准备就绪后,内核通过发送一个 SIGIO 信号通知应用进程,应用进程就可以开始读取数据了。
5. 异步 I/O
-
+
异步 I/O 最重要的一点是从内核缓冲区拷贝数据到用户态缓冲区的过程也是由系统异步完成,应用进程只需要在指定的数组中引用数据即可。异步 I/O 与信号驱动 I/O 这种半异步模式的主要区别:信号驱动 I/O 由内核通知何时可以开始一个 I/O 操作,而异步 I/O 由内核通知 I/O 操作何时已经完成。
了解了上述五种 I/O,我们再来看 Netty 如何实现自己的 I/O 模型。Netty 的 I/O 模型是基于非阻塞 I/O 实现的,底层依赖的是 JDK NIO 框架的多路复用器 Selector。一个多路复用器 Selector 可以同时轮询多个 Channel,采用 epoll 模式后,只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。
在 I/O 多路复用的场景下,当有数据处于就绪状态后,需要一个事件分发器(Event Dispather),它负责将读写事件分发给对应的读写事件处理器(Event Handler)。事件分发器有两种设计模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用异步 I/O。
Reactor 实现相对简单,适合处理耗时短的场景,对于耗时长的 I/O 操作容易造成阻塞。Proactor 性能更高,但是实现逻辑非常复杂,目前主流的事件驱动模型还是依赖 select 或 epoll 来实现。
-
+
(摘自 Lea D. Scalable IO in Java )
上图所描述的便是 Netty 所采用的主从 Reactor 多线程模型,所有的 I/O 事件都注册到一个 I/O 多路复用器上,当有 I/O 事件准备就绪后,I/O 多路复用器会将该 I/O 事件通过事件分发器分发到对应的事件处理器中。该线程模型避免了同步问题以及多线程切换带来的资源开销,真正做到高性能、低延迟。
完美弥补 Java NIO 的缺陷
@@ -312,7 +312,7 @@ function hide_canvas() {
可见 Netty 4.x 带来了很多提升,性能、健壮性都变得更加强大了。Netty 精益求精的设计精神值得每个人学习。当然,其中还有更多细节变化,感兴趣的同学可以参考以下网址:https://netty.io/wiki/new-and-noteworthy-in-4.0.html。如果你现在对这些概念还不是很清晰,也不必担心,专栏后续的内容中我都会具体讲解。
谁在使用 Netty?
Netty 凭借其强大的社区影响力,越来越多的公司逐渐采用Netty 作为他们的底层通信框架,下图中我列举了一些正在使用 Netty 的公司,一起感受下它的热度吧。
-
+
Netty 经过很多出名产品在线上的大规模验证,其健壮性和稳定性都被业界认可,其中典型的产品有一下几个。
- 服务治理:Apache Dubbo、gRPC。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/02 纵览全局:把握 Netty 整体架构脉络.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/02 纵览全局:把握 Netty 整体架构脉络.md.html
index 096cb24b..37491ddd 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/02 纵览全局:把握 Netty 整体架构脉络.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/02 纵览全局:把握 Netty 整体架构脉络.md.html
@@ -223,7 +223,7 @@ function hide_canvas() {
本节课以 Netty 4.1.42 为基准版本,我将分别从 Netty 整体结构、逻辑架构、源码结构三个方面对其进行介绍。
Netty 整体结构
Netty 是一个设计非常用心的网络基础组件,Netty 官网给出了有关 Netty 的整体功能模块结构,却没有其他更多的解释。从图中,我们可以清晰地看出 Netty 结构一共分为三个模块:
-
+
1. Core 核心层
Core 核心层是 Netty 最精华的内容,它提供了底层网络通信的通用抽象和实现,包括可扩展的事件模型、通用的通信 API、支持零拷贝的 ByteBuf 等。
2. Protocol Support 协议支持层
@@ -234,7 +234,7 @@ function hide_canvas() {
现在,我们对 Netty 的整体结构已经有了一个大概的印象,下面我们一起看下 Netty 的逻辑架构,学习下 Netty 是如何做功能分解的。
Netty 逻辑架构
下图是 Netty 的逻辑处理架构。Netty 的逻辑处理架构为典型网络分层架构设计,共分为网络通信层、事件调度层、服务编排层,每一层各司其职。图中包含了 Netty 每一层所用到的核心组件。我将为你介绍 Netty 的每个逻辑分层中的各个核心组件以及组件之间是如何协调运作的。
-
+
网络通信层
网络通信层的职责是执行网络 I/O 的操作。它支持多种网络协议和 I/O 模型的连接操作。当网络数据读取到内核缓冲区后,会触发各种网络事件,这些网络事件会分发给事件调度层进行处理。
网络通信层的核心组件包含BootStrap、ServerBootStrap、Channel三个组件。
@@ -243,7 +243,7 @@ function hide_canvas() {
Bootstrap 是“引导”的意思,它主要负责整个 Netty 程序的启动、初始化、服务器连接等过程,它相当于一条主线,串联了 Netty 的其他核心组件。
如下图所示,Netty 中的引导器共分为两种类型:一个为用于客户端引导的 Bootstrap,另一个为用于服务端引导的 ServerBootStrap,它们都继承自抽象类 AbstractBootstrap。
-
+
Bootstrap 和 ServerBootStrap 十分相似,两者非常重要的区别在于 Bootstrap 可用于连接远端服务器,只绑定一个 EventLoopGroup。而 ServerBootStrap 则用于服务端启动绑定本地端口,会绑定两个 EventLoopGroup,这两个 EventLoopGroup 通常称为 Boss 和 Worker。
ServerBootStrap 中的 Boss 和 Worker 是什么角色呢?它们之间又是什么关系?这里的 Boss 和 Worker 可以理解为“老板”和“员工”的关系。每个服务器中都会有一个 Boss,也会有一群做事情的 Worker。Boss 会不停地接收新的连接,然后将连接分配给一个个 Worker 处理连接。
有了 Bootstrap 组件,我们可以更加方便地配置和启动 Netty 应用程序,它是整个 Netty 的入口,串接了 Netty 所有核心组件的初始化工作。
@@ -252,7 +252,7 @@ function hide_canvas() {
Channel 的字面意思是“通道”,它是网络通信的载体。Channel提供了基本的 API 用于网络 I/O 操作,如 register、bind、connect、read、write、flush 等。Netty 自己实现的 Channel 是以 JDK NIO Channel 为基础的,相比较于 JDK NIO,Netty 的 Channel 提供了更高层次的抽象,同时屏蔽了底层 Socket 的复杂性,赋予了 Channel 更加强大的功能,你在使用 Netty 时基本不需要再与 Java Socket 类直接打交道。
下图是 Channel 家族的图谱。AbstractChannel 是整个家族的基类,派生出 AbstractNioChannel、AbstractOioChannel、AbstractEpollChannel 等子类,每一种都代表了不同的 I/O 模型和协议类型。常用的 Channel 实现类有:
-
+
- NioServerSocketChannel 异步 TCP 服务端。
- NioSocketChannel 异步 TCP 客户端。
@@ -304,7 +304,7 @@ function hide_canvas() {
- EventLoopGroup & EventLoop
EventLoopGroup 本质是一个线程池,主要负责接收 I/O 请求,并分配线程执行处理请求。在下图中,我为你讲述了 EventLoopGroups、EventLoop 与 Channel 的关系。
-
+
从上图中,我们可以总结出 EventLoopGroup、EventLoop、Channel 的几点关系。
- 一个 EventLoopGroup 往往包含一个或者多个 EventLoop。EventLoop 用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件。
@@ -312,7 +312,7 @@ function hide_canvas() {
- 每新建一个 Channel,EventLoopGroup 会选择一个 EventLoop 与其绑定。该 Channel 在生命周期内都可以对 EventLoop 进行多次绑定和解绑。
下图是 EventLoopGroup 的家族图谱。可以看出 Netty 提供了 EventLoopGroup 的多种实现,而且 EventLoop 则是 EventLoopGroup 的子接口,所以也可以把 EventLoop 理解为 EventLoopGroup,但是它只包含一个 EventLoop 。
-
+
EventLoopGroup 的实现类是 NioEventLoopGroup,NioEventLoopGroup 也是 Netty 中最被推荐使用的线程模型。NioEventLoopGroup 继承于 MultithreadEventLoopGroup,是基于 NIO 模型开发的,可以把 NioEventLoopGroup 理解为一个线程池,每个线程负责处理多个 Channel,而同一个 Channel 只会对应一个线程。
EventLoopGroup 是 Netty 的核心处理引擎,那么 EventLoopGroup 和之前课程所提到的 Reactor 线程模型到底是什么关系呢?其实 EventLoopGroup 是 Netty Reactor 线程模型的具体实现方式,Netty 通过创建不同的 EventLoopGroup 参数配置,就可以支持 Reactor 的三种线程模型:
@@ -330,21 +330,21 @@ function hide_canvas() {
ChannelPipeline 是 Netty 的核心编排组件,负责组装各种 ChannelHandler,实际数据的编解码以及加工处理操作都是由 ChannelHandler 完成的。ChannelPipeline 可以理解为ChannelHandler 的实例列表——内部通过双向链表将不同的 ChannelHandler 链接在一起。当 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。
ChannelPipeline 是线程安全的,因为每一个新的 Channel 都会对应绑定一个新的 ChannelPipeline。一个 ChannelPipeline 关联一个 EventLoop,一个 EventLoop 仅会绑定一个线程。
ChannelPipeline、ChannelHandler 都是高度可定制的组件。开发者可以通过这两个核心组件掌握对 Channel 数据操作的控制权。下面我们看一下 ChannelPipeline 的结构图:
-
+
从上图可以看出,ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念。
-
+
客户端和服务端都有各自的 ChannelPipeline。以客户端为例,数据从客户端发向服务端,该过程称为出站,反之则称为入站。数据入站会由一系列 InBoundHandler 处理,然后再以相反方向的 OutBoundHandler 处理后完成出站。我们经常使用的编码 Encoder 是出站操作,解码 Decoder 是入站操作。服务端接收到客户端数据后,需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。所以客户端和服务端一次完整的请求应答过程可以分为三个步骤:客户端出站(请求数据)、服务端入站(解析数据并执行业务逻辑)、服务端出站(响应结果)。
- ChannelHandler & ChannelHandlerContext
在介绍 ChannelPipeline 的过程中,想必你已经对 ChannelHandler 有了基本的概念,数据的编解码工作以及其他转换工作实际都是通过 ChannelHandler 处理的。站在开发者的角度,最需要关注的就是 ChannelHandler,我们很少会直接操作 Channel,都是通过 ChannelHandler 间接完成。
下图描述了 Channel 与 ChannelPipeline 的关系,从图中可以看出,每创建一个 Channel 都会绑定一个新的 ChannelPipeline,ChannelPipeline 中每加入一个 ChannelHandler 都会绑定一个 ChannelHandlerContext。由此可见,ChannelPipeline、ChannelHandlerContext、ChannelHandler 三个组件的关系是密切相关的,那么你一定会有疑问,每个 ChannelHandler 绑定ChannelHandlerContext 的作用是什么呢?
-
+
ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。此外,你可以试想这样一个场景,如果每个 ChannelHandler 都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?
以上便是 Netty 的逻辑处理架构,可以看出 Netty 的架构分层设计得非常合理,屏蔽了底层 NIO 以及框架层的实现细节,对于业务开发者来说,只需要关注业务逻辑的编排和实现即可。
组件关系梳理
当你了解每个 Netty 核心组件的概念后。你会好奇这些组件之间如何协作?结合客户端和服务端的交互流程,我画了一张图,为你完整地梳理一遍 Netty 内部逻辑的流转。
-
+
- 服务端启动初始化时有 Boss EventLoopGroup 和 Worker EventLoopGroup 两个组件,其中 Boss 负责监听网络连接事件。当有新的网络连接事件到达时,则将 Channel 注册到 Worker EventLoopGroup。
- Worker EventLoopGroup 会被分配一个 EventLoop 负责处理该 Channel 的读写事件。每个 EventLoop 都是单线程的,通过 Selector 进行事件循环。
@@ -355,7 +355,7 @@ function hide_canvas() {
以上便是 Netty 各个组件的整体交互流程,你只需要对每个组件的工作职责有所了解,心中可以串成一条流水线即可,具体每个组件的实现原理后续课程我们会深入介绍。
Netty 源码结构
Netty 源码分为多个模块,模块之间职责划分非常清楚。如同上文整体功能模块一样,Netty 源码模块的划分也是基本契合的。
-
+
我们不仅可以使用 Netty all-in-one 的 Jar 包,也可以单独使用其中某些工具包。下面我根据 Netty 的分层结构以及实际的业务场景具体介绍 Netty 中常用的工具包。
Core 核心层模块
netty-common模块是 Netty 的核心基础包,提供了丰富的工具类,其他模块都需要依赖它。在 common 模块中,常用的包括通用工具类和自定义并发包。
@@ -367,7 +367,7 @@ function hide_canvas() {
netty-resover模块主要提供了一些有关基础设施的解析工具,包括 IP Address、Hostname、DNS 等。
Protocol Support 协议支持层模块
netty-codec模块主要负责编解码工作,通过编解码实现原始字节数据与业务实体对象之间的相互转化。如下图所示,Netty 支持了大多数业界主流协议的编解码器,如 HTTP、HTTP2、Redis、XML 等,为开发者节省了大量的精力。此外该模块提供了抽象的编解码类 ByteToMessageDecoder 和 MessageToByteEncoder,通过继承这两个类我们可以轻松实现自定义的编解码逻辑。
-
+
netty-handler模块主要负责数据处理工作。Netty 中关于数据处理的部分,本质上是一串有序 handler 的集合。netty-handler 模块提供了开箱即用的 ChannelHandler 实现类,例如日志、IP 过滤、流量整形等,如果你需要这些功能,仅需在 pipeline 中加入相应的 ChannelHandler 即可。
Transport Service 传输服务层模块
netty-transport 模块可以说是 Netty 提供数据处理和传输的核心模块。该模块提供了很多非常重要的接口,如 Bootstrap、Channel、ChannelHandler、EventLoop、EventLoopGroup、ChannelPipeline 等。其中 Bootstrap 负责客户端或服务端的启动工作,包括创建、初始化 Channel 等;EventLoop 负责向注册的 Channel 发起 I/O 读写操作;ChannelPipeline 负责 ChannelHandler 的有序编排,这些组件在介绍 Netty 逻辑架构的时候都有所涉及。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/04 事件调度层:为什么 EventLoop 是 Netty 的精髓?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/04 事件调度层:为什么 EventLoop 是 Netty 的精髓?.md.html
index af77968a..e179eedf 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/04 事件调度层:为什么 EventLoop 是 Netty 的精髓?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/04 事件调度层:为什么 EventLoop 是 Netty 的精髓?.md.html
@@ -191,7 +191,7 @@ function hide_canvas() {
再谈 Reactor 线程模型
网络框架的设计离不开 I/O 线程模型,线程模型的优劣直接决定了系统的吞吐量、可扩展性、安全性等。目前主流的网络框架几乎都采用了 I/O 多路复用的方案。Reactor 模式作为其中的事件分发器,负责将读写事件分发给对应的读写事件处理者。大名鼎鼎的 Java 并发包作者 Doug Lea,在 Scalable I/O in Java 一文中阐述了服务端开发中 I/O 模型的演进过程。Netty 中三种 Reactor 线程模型也来源于这篇经典文章。下面我们对这三种 Reactor 线程模型做一个详细的分析。
单线程模型
-
+
(摘自 Lea D. Scalable IO in Java)
上图描述了 Reactor 的单线程模型结构,在 Reactor 单线程模型中,所有 I/O 操作(包括连接建立、数据读写、事件分发等),都是由一个线程完成的。单线程模型逻辑简单,缺陷也十分明显:
@@ -201,16 +201,16 @@ function hide_canvas() {
- 如果 I/O 线程一直处于满负荷状态,很可能造成服务端节点不可用。
多线程模型
-
+
(摘自 Lea D. Scalable IO in Java)
由于单线程模型有性能方面的瓶颈,多线程模型作为解决方案就应运而生了。Reactor 多线程模型将业务逻辑交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,例如读取数据依然保留了串行化的设计。当客户端有数据发送至服务端时,Select 会监听到可读事件,数据读取完毕后提交到业务线程池中并发处理。
主从多线程模型
-
+
(摘自 Lea D. Scalable IO in Java)
主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象。MainReactor 仅负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至 SubReactor。再由 SubReactor 分配线程池中的 I/O 线程与其连接绑定,它将负责连接生命周期内所有的 I/O 事件。
Netty 推荐使用主从多线程模型,这样就可以轻松达到成千上万规模的客户端连接。在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加 SubReactor 线程的数量,从而利用多核能力提升系统的吞吐量。
介绍了上述三种 Reactor 线程模型,再结合它们各自的架构图,我们能大致总结出 Reactor 线程模型运行机制的四个步骤,分别为连接注册、事件轮询、事件分发、任务处理,如下图所示。
-
+
- 连接注册:Channel 建立后,注册至 Reactor 线程中的 Selector 选择器。
- 事件轮询:轮询 Selector 选择器中已注册的所有 Channel 的 I/O 事件。
@@ -222,7 +222,7 @@ function hide_canvas() {
EventLoop 是什么
EventLoop 这个概念其实并不是 Netty 独有的,它是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。例如 Node.js 就采用了 EventLoop 的运行机制,不仅占用资源低,而且能够支撑了大规模的流量访问。
下图展示了 EventLoop 通用的运行模式。每当事件发生时,应用程序都会将产生的事件放入事件队列当中,然后 EventLoop 会轮询从队列中取出事件执行或者将事件分发给相应的事件监听者执行。事件执行的方式通常分为立即执行、延后执行、定期执行几种。
-
+
Netty 如何实现 EventLoop
在 Netty 中 EventLoop 可以理解为 Reactor 线程模型的事件处理引擎,每个 EventLoop 线程都维护一个 Selector 选择器和任务队列 taskQueue。它主要负责处理 I/O 事件、普通任务和定时任务。
Netty 中推荐使用 NioEventLoop 作为实现类,那么 Netty 是如何实现 NioEventLoop 的呢?首先我们来看 NioEventLoop 最核心的 run() 方法源码,本节课我们不会对源码做深入的分析,只是先了解 NioEventLoop 的实现结构。
@@ -282,7 +282,7 @@ function hide_canvas() {
上述源码的结构比较清晰,NioEventLoop 每次循环的处理流程都包含事件轮询 select、事件处理 processSelectedKeys、任务处理 runAllTasks 几个步骤,是典型的 Reactor 线程模型的运行机制。而且 Netty 提供了一个参数 ioRatio,可以调整 I/O 事件处理和任务处理的时间比例。下面我们将着重从事件处理和任务处理两个核心部分出发,详细介绍 Netty EventLoop 的实现原理。
事件处理机制
-
+
结合 Netty 的整体架构,我们一起看下 EventLoop 的事件流转图,以便更好地理解 Netty EventLoop 的设计原理。NioEventLoop 的事件处理机制采用的是无锁串行化的设计思路。
- BossEventLoopGroup 和 WorkerEventLoopGroup 包含一个或者多个 NioEventLoop。BossEventLoopGroup 负责监听客户端的 Accept 事件,当事件触发时,将事件注册至 WorkerEventLoopGroup 中的一个 NioEventLoop 上。每新建一个 Channel, 只选择一个 NioEventLoop 与其绑定。所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/05 服务编排层:Pipeline 如何协调各类 Handler ?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/05 服务编排层:Pipeline 如何协调各类 Handler ?.md.html
index 4478b1cd..908bc876 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/05 服务编排层:Pipeline 如何协调各类 Handler ?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/05 服务编排层:Pipeline 如何协调各类 Handler ?.md.html
@@ -238,13 +238,13 @@ function hide_canvas() {
ChannelPipeline 内部结构
首先我们要理清楚 ChannelPipeline 的内部结构是什么样子,这样才能理解 ChannelPipeline 的处理流程。ChannelPipeline 作为 Netty 的核心编排组件,负责调度各种类型的 ChannelHandler,实际数据的加工处理操作则是由 ChannelHandler 完成的。
ChannelPipeline 可以看作是 ChannelHandler 的容器载体,它是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起,如下图所示。当有 I/O 读写事件触发时,ChannelPipeline 会依次调用 ChannelHandler 列表对 Channel 的数据进行拦截和处理。
-
+
由上图可知,每个 Channel 会绑定一个 ChannelPipeline,每一个 ChannelPipeline 都包含多个 ChannelHandlerContext,所有 ChannelHandlerContext 之间组成了双向链表。又因为每个 ChannelHandler 都对应一个 ChannelHandlerContext,所以实际上 ChannelPipeline 维护的是它与 ChannelHandlerContext 的关系。那么你可能会有疑问,为什么这里会多一层 ChannelHandlerContext 的封装呢?
其实这是一种比较常用的编程思想。ChannelHandlerContext 用于保存 ChannelHandler 上下文;ChannelHandlerContext 则包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。可以试想一下,如果没有 ChannelHandlerContext 的这层封装,那么我们在做 ChannelHandler 之间传递的时候,前置后置的通用逻辑就要在每个 ChannelHandler 里都实现一份。这样虽然能解决问题,但是代码结构的耦合,会非常不优雅。
根据网络数据的流向,ChannelPipeline 分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器。在客户端与服务端通信的过程中,数据从客户端发向服务端的过程叫出站,反之称为入站。数据先由一系列 InboundHandler 处理后入站,然后再由相反方向的 OutboundHandler 处理完成后出站,如下图所示。我们经常使用的解码器 Decoder 就是入站操作,编码器 Encoder 就是出站操作。服务端接收到客户端数据需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。
-
+
接下来我们详细分析下 ChannelPipeline 双向链表的构造,ChannelPipeline 的双向链表分别维护了 HeadContext 和 TailContext 的头尾节点。我们自定义的 ChannelHandler 会插入到 Head 和 Tail 之间,这两个节点在 Netty 中已经默认实现了,它们在 ChannelPipeline 中起到了至关重要的作用。首先我们看下 HeadContext 和 TailContext 的继承关系,如下图所示。
-
+
HeadContext 既是 Inbound 处理器,也是 Outbound 处理器。它分别实现了 ChannelInboundHandler 和 ChannelOutboundHandler。网络数据写入操作的入口就是由 HeadContext 节点完成的。HeadContext 作为 Pipeline 的头结点负责读取数据并开始传递 InBound 事件,当数据处理完成后,数据会反方向经过 Outbound 处理器,最终传递到 HeadContext,所以 HeadContext 又是处理 Outbound 事件的最后一站。此外 HeadContext 在传递事件之前,还会执行一些前置操作。
TailContext 只实现了 ChannelInboundHandler 接口。它会在 ChannelInboundHandler 调用链路的最后一步执行,主要用于终止 Inbound 事件传播,例如释放 Message 数据资源等。TailContext 节点作为 OutBound 事件传播的第一站,仅仅是将 OutBound 事件传递给上一个节点。
从整个 ChannelPipeline 调用链路来看,如果由 Channel 直接触发事件传播,那么调用链路将贯穿整个 ChannelPipeline。然而也可以在其中某一个 ChannelHandlerContext 触发同样的方法,这样只会从当前的 ChannelHandler 开始执行事件传播,该过程不会从头贯穿到尾,在一定场景下,可以提高程序性能。
@@ -295,7 +295,7 @@ function hide_canvas() {
2. ChannelOutboundHandler 的事件回调方法与触发时机。
ChannelOutboundHandler 的事件回调方法非常清晰,直接通过 ChannelOutboundHandler 的接口列表可以看到每种操作所对应的回调方法,如下图所示。这里每个回调方法都是在相应操作执行之前触发,在此就不多做赘述了。此外 ChannelOutboundHandler 中绝大部分接口都包含ChannelPromise 参数,以便于在操作完成时能够及时获得通知。
-
+
事件传播机制
在上文中我们介绍了 ChannelPipeline 可分为入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,与此对应传输的事件类型可以分为Inbound 事件和Outbound 事件。
我们通过一个代码示例,一起体验下 ChannelPipeline 的事件传播机制。
@@ -342,9 +342,9 @@ public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {
}
通过 Pipeline 的 addLast 方法分别添加了三个 InboundHandler 和 OutboundHandler,添加顺序都是 A -> B -> C,下图可以表示初始化后 ChannelPipeline 的内部结构。
-
+
当客户端向服务端发送请求时,会触发 SampleInBoundHandler 调用链的 channelRead 事件。经过 SampleInBoundHandler 调用链处理完成后,在 SampleInBoundHandlerC 中会调用 writeAndFlush 方法向客户端写回数据,此时会触发 SampleOutBoundHandler 调用链的 write 事件。最后我们看下代码示例的控制台输出:
-
+
由此可见,Inbound 事件和 Outbound 事件的传播方向是不一样的。Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head,两者恰恰相反。在 Netty 应用编程中一定要理清楚事件传播的顺序。推荐你在系统设计时模拟客户端和服务端的场景画出 ChannelPipeline 的内部结构图,以避免搞混调用关系。
异常传播机制
ChannelPipeline 事件传播的实现采用了经典的责任链模式,调用链路环环相扣。那么如果有一个节点处理逻辑异常会出现什么现象呢?我们通过修改 SampleInBoundHandler 的实现来模拟业务逻辑异常:
@@ -372,7 +372,7 @@ public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {
}
在 channelRead 事件处理中,第一个 A 节点就会抛出 RuntimeException。同时我们重写了 ChannelInboundHandlerAdapter 中的 exceptionCaught 方法,只是在开头加上了控制台输出,方便观察异常传播的行为。下面看一下代码运行的控制台输出结果:
-
+
由输出结果可以看出 ctx.fireExceptionCaugh 会将异常按顺序从 Head 节点传播到 Tail 节点。如果用户没有对异常进行拦截处理,最后将由 Tail 节点统一处理,在 TailContext 源码中可以找到具体实现:
protected void onUnhandledInboundException(Throwable cause) {
try {
@@ -388,7 +388,7 @@ public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {
虽然 Netty 中 TailContext 提供了兜底的异常处理逻辑,但是在很多场景下,并不能满足我们的需求。假如你需要拦截指定的异常类型,并做出相应的异常处理,应该如何实现呢?我们接着往下看。
异常处理的最佳实践
在 Netty 应用开发的过程中,良好的异常处理机制会让排查问题的过程事半功倍。所以推荐用户对异常进行统一拦截,然后根据实际业务场景实现更加完善的异常处理机制。通过异常传播机制的学习,我们应该可以想到最好的方法是在 ChannelPipeline 自定义处理器的末端添加统一的异常处理器,此时 ChannelPipeline 的内部结构如下图所示。
-
+
用户自定义的异常处理器代码示例如下:
public class ExceptionHandler extends ChannelDuplexHandler {
@Override
@@ -400,7 +400,7 @@ public class SampleOutBoundHandler extends ChannelOutboundHandlerAdapter {
}
加入统一的异常处理器后,可以看到异常已经被优雅地拦截并处理掉了。这也是 Netty 推荐的最佳异常处理实践。
-
+
总结
本节课我们深入分析了 Pipeline 的设计原理与事件传播机制。那么课程最初我提出的几个问题你是否已经都找到答案了?我来做个简单的总结:
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/06 粘包拆包问题:如何获取一个完整的网络包?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/06 粘包拆包问题:如何获取一个完整的网络包?.md.html
index db2a4b1a..fe428da6 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/06 粘包拆包问题:如何获取一个完整的网络包?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/06 粘包拆包问题:如何获取一个完整的网络包?.md.html
@@ -224,7 +224,7 @@ function hide_canvas() {
为什么会出现拆包/粘包现象呢?在网络通信的过程中,每次可以发送的数据包大小是受多种因素限制的,如 MTU 传输单元大小、MSS 最大分段大小、滑动窗口等。如果一次传输的网络包数据大小超过传输单元大小,那么我们的数据可能会拆分为多个数据包发送出去。如果每次请求的网络包数据都很小,一共请求了 10000 次,TCP 并不会分别发送 10000 次。因为 TCP 采用的 Nagle 算法对此作出了优化。如果你是一位网络新手,可能对这些概念并不非常清楚。那我们先了解下计算机网络中 MTU、MSS、Nagle 这些基础概念以及它们为什么会造成拆包/粘包问题。
MTU 最大传输单元和 MSS 最大分段大小
MTU(Maxitum Transmission Unit) 是链路层一次最大传输数据的大小。MTU 一般来说大小为 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大报文段长度,它是传输层一次发送最大数据的大小。如下图所示,MTU 和 MSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部,如果 MSS + TCP 首部 + IP 首部 > MTU,那么数据包将会被拆分为多个发送。这就是拆包现象。
-
+
滑动窗口
滑动窗口是 TCP 传输层用于流量控制的一种有效措施,也被称为通告窗口。滑动窗口是数据接收方设置的窗口大小,随后接收方会把窗口大小告诉发送方,以此限制发送方每次发送数据的大小,从而达到流量控制的目的。这样数据发送方不需要每发送一组数据就阻塞等待接收方确认,允许发送方同时发送多个数据分组,每次发送的数据都会被限制在窗口大小内。由此可见,滑动窗口可以大幅度提升网络吞吐量。
那么 TCP 报文是怎么确保数据包按次序到达且不丢数据呢?首先,所有的数据帧都是有编号的,TCP 并不会为每个报文段都回复 ACK 响应,它会对多个报文段回复一次 ACK。假设有三个报文段 A、B、C,发送方先发送了B、C,接收方则必须等待 A 报文段到达,如果一定时间内仍未等到 A 报文段,那么 B、C 也会被丢弃,发送方会发起重试。如果已接收到 A 报文段,那么将会回复发送方一次 ACK 确认。
@@ -232,7 +232,7 @@ function hide_canvas() {
Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法。它主要用于解决频繁发送小数据包而带来的网络拥塞问题。试想如果每次需要发送的数据只有 1 字节,加上 20 个字节 IP Header 和 20 个字节 TCP Header,每次发送的数据包大小为 41 字节,但是只有 1 字节是有效信息,这就造成了非常大的浪费。Nagle 算法可以理解为批量发送,也是我们平时编程中经常用到的优化思路,它是在数据未得到确认之前先写入缓冲区,等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去。
Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。但如果你的业务场景每次发送的数据都需要获得及时响应,那么 Nagle 算法就不能满足你的需求了,因为 Nagle 算法会有一定的数据延迟。你可以通过 Linux 提供的 TCP_NODELAY 参数禁用 Nagle 算法。Netty 中为了使数据传输延迟最小化,就默认禁用了 Nagle 算法,这一点与 Linux 操作系统的默认行为是相反的。
拆包/粘包的解决方案
-
+
在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如上图所示,拆包/粘包可能会出现以下五种情况:
- 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/07 接头暗语:如何利用 Netty 实现自定义协议通信?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/07 接头暗语:如何利用 Netty 实现自定义协议通信?.md.html
index 00a248fb..03d07251 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/07 接头暗语:如何利用 Netty 实现自定义协议通信?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/07 接头暗语:如何利用 Netty 实现自定义协议通信?.md.html
@@ -273,7 +273,7 @@ function hide_canvas() {
下面我们对 Netty 中常用的抽象编解码类进行详细的介绍。
抽象编码类
-
+
通过抽象编码类的继承图可以看出,编码类是 ChanneOutboundHandler 的抽象类实现,具体操作的是 Outbound 出站数据。
- MessageToByteEncoder
@@ -343,7 +343,7 @@ protected void encode(ChannelHandlerContext ctx, CharSequence msg, List<Objec
抽象解码类
同样,我们先看下抽象解码类的继承关系图。解码类是 ChanneInboundHandler 的抽象类实现,操作的是 Inbound 入站数据。解码器实现的难度要远大于编码器,因为解码器需要考虑拆包/粘包问题。由于接收方有可能没有接收到完整的消息,所以解码框架需要对入站的数据做缓冲操作,直至获取到完整的消息。
-
+
- 抽象解码类 ByteToMessageDecoder。
@@ -364,7 +364,7 @@ protected void encode(ChannelHandlerContext ctx, CharSequence msg, List<Objec
- 抽象解码类 MessageToMessageDecoder。
MessageToMessageDecoder 与 ByteToMessageDecoder 作用类似,都是将一种消息类型的编码成另外一种消息类型。与 ByteToMessageDecoder 不同的是 MessageToMessageDecoder 并不会对数据报文进行缓存,它主要用作转换消息模型。比较推荐的做法是使用 ByteToMessageDecoder 解析 TCP 协议,解决拆包/粘包问题。解析得到有效的 ByteBuf 数据,然后传递给后续的 MessageToMessageDecoder 做数据对象的转换,具体流程如下图所示。
-
+
通信协议实战
在上述通信协议设计的小节内容中,我们提到了协议的基本要素并给出了一个较为通用的协议示例。下面我们通过 Netty 的编辑码框架实现该协议的解码器,加深我们对 Netty 编解码框架的理解。
在实现协议编码器之前,我们首先需要清楚一个问题:如何判断 ByteBuf 是否存在完整的报文?最常用的做法就是通过读取消息长度 dataLength 进行判断。如果 ByteBuf 的可读数据长度小于 dataLength,说明 ByteBuf 还不够获取一个完整的报文。在该协议前面的消息头部分包含了魔数、协议版本号、数据长度等固定字段,共 14 个字节。固定字段长度和数据长度可以作为我们判断消息完整性的依据,具体编码器实现逻辑示例如下:
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/11 另起炉灶:Netty 数据传输载体 ByteBuf 详解.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/11 另起炉灶:Netty 数据传输载体 ByteBuf 详解.md.html
index e0fb4f76..d7e1efa9 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/11 另起炉灶:Netty 数据传输载体 ByteBuf 详解.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/11 另起炉灶:Netty 数据传输载体 ByteBuf 详解.md.html
@@ -190,7 +190,7 @@ function hide_canvas() {
在学习编解码章节的过程中,我们看到 Netty 大量使用了自己实现的 ByteBuf 工具类,ByteBuf 是 Netty 的数据容器,所有网络通信中字节流的传输都是通过 ByteBuf 完成的。然而 JDK NIO 包中已经提供了类似的 ByteBuffer 类,为什么 Netty 还要去重复造轮子呢?本节课我会详细地讲解 ByteBuf。
为什么选择 ByteBuf
我们首先介绍下 JDK NIO 的 ByteBuffer,才能知道 ByteBuffer 有哪些缺陷和痛点。下图展示了 ByteBuffer 的内部结构:
-
+
从图中可知,ByteBuffer 包含以下四个基本属性:
- mark:为某个读取过的关键位置做标记,方便回退到该位置;
@@ -231,7 +231,7 @@ assert buffer.refCnt() == 0;
此外 Netty 可以利用引用计数的特点实现内存泄漏检测工具。JVM 并不知道 Netty 的引用计数是如何实现的,当 ByteBuf 对象不可达时,一样会被 GC 回收掉,但是如果此时 ByteBuf 的引用计数不为 0,那么该对象就不会释放或者被放入对象池,从而发生了内存泄漏。Netty 会对分配的 ByteBuf 进行抽样分析,检测 ByteBuf 是否已经不可达且引用计数大于 0,判定内存泄漏的位置并输出到日志中,你需要关注日志中 LEAK 关键字。
ByteBuf 分类
ByteBuf 有多种实现类,每种都有不同的特性,下图是 ByteBuf 的家族图谱,可以划分为三个不同的维度:Heap/Direct、Pooled/Unpooled和Unsafe/非 Unsafe,我逐一介绍这三个维度的不同特性。
-
+
Heap/Direct 就是堆内和堆外内存。Heap 指的是在 JVM 堆内分配,底层依赖的是字节数据;Direct 则是堆外内存,不受 JVM 限制,分配方式依赖 JDK 底层的 ByteBuffer。
Pooled/Unpooled 表示池化还是非池化内存。Pooled 是从预先分配好的内存中取出,使用完可以放回 ByteBuf 内存池,等待下一次分配。而 Unpooled 是直接调用系统 API 去申请内存,确保能够被 JVM GC 管理回收。
Unsafe/非 Unsafe 的区别在于操作方式是否安全。 Unsafe 表示每次调用 JDK 的 Unsafe 对象操作物理内存,依赖 offset + index 的方式操作数据。非 Unsafe 则不需要依赖 JDK 的 Unsafe 对象,直接通过数组下标的方式操作数据。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/12 他山之石:高性能内存分配器 jemalloc 基本原理.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/12 他山之石:高性能内存分配器 jemalloc 基本原理.md.html
index d597d14a..45c95725 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/12 他山之石:高性能内存分配器 jemalloc 基本原理.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/12 他山之石:高性能内存分配器 jemalloc 基本原理.md.html
@@ -232,9 +232,9 @@ function hide_canvas() {
那么这里又涉及一个概念,什么是内存碎片呢?Linux 中物理内存会被划分成若干个 4K 大小的内存页 Page,物理内存的分配和回收都是基于 Page 完成的,Page 内产生的内存碎片称为内部碎片,Page 之间产生的内存碎片称为外部碎片。
首先讲下内部碎片,因为内存是按 Page 进行分配的,即便我们只需要很小的内存,操作系统至少也会分配 4K 大小的 Page,单个 Page 内只有一部分字节都被使用,剩余的字节形成了内部碎片,如下图所示。
-
+
外部碎片与内部碎片相反,是在分配较大内存块时产生的。我们试想一下,当需要分配大内存块的时候,操作系统只能通过分配连续的 Page 才能满足要求,在程序不断运行的过程中,这些 Page 被频繁的回收并重新分配,Page 之间就会出现小的空闲内存块,这样就形成了外部碎片,如下图所示。
-
+
上述我们介绍了内存分配器的一些背景知识,它们是操作系统以及高性能组件的必备神器,如果你对内存管理有兴趣,jemalloc 和 tcmalloc 都是非常推荐学习的。
常用内存分配器算法
在学习 jemalloc 的实现原理之前,我们先了解下最常用的内存分配器算法:动态内存分配、伙伴算法和Slab 算法,这将对于我们理解 jemalloc 大有裨益。
@@ -250,7 +250,7 @@ function hide_canvas() {
伙伴算法
伙伴算法是一种非常经典的内存分配算法,它采用了分离适配的设计思想,将物理内存按照 2 的次幂进行划分,内存分配时也是按照 2 的次幂大小进行按需分配,例如 4KB、 8KB、16KB 等。假设我们请求分配的内存大小为 10KB,那么会按照 16KB 分配。
伙伴算法相对比较复杂,我们结合下面这张图来讲解它的分配原理。
-
+
伙伴算法把内存划分为 11 组不同的 2 次幂大小的内存块集合,每组内存块集合都用双向链表连接。链表中每个节点的内存块大小分别为 1、2、4、8、16、32、64、128、256、512 和 1024 个连续的 Page,例如第一组链表的节点为 2^0 个连续 Page,第二组链表的节点为 2^1 个连续 Page,以此类推。
假设我们需要分配 10K 大小的内存块,看下伙伴算法的具体分配过程:
@@ -264,14 +264,14 @@ function hide_canvas() {
Slab 算法
因为伙伴算法都是以 Page 为最小管理单位,在小内存的分配场景,伙伴算法并不适用,如果每次都分配一个 Page 岂不是非常浪费内存,因此 Slab 算法应运而生了。Slab 算法在伙伴算法的基础上,对小内存的场景专门做了优化,采用了内存池的方案,解决内部碎片问题。
Linux 内核使用的就是 Slab 算法,因为内核需要频繁地分配小内存,所以 Slab 算法提供了一种高速缓存机制,使用缓存存储内核对象,当内核需要分配内存时,基本上可以通过缓存中获取。此外 Slab 算法还可以支持通用对象的初始化操作,避免对象重复初始化的开销。下图是 Slab 算法的结构图,Slab 算法实现起来非常复杂,本文只做一个简单的了解。
-
+
在 Slab 算法中维护着大小不同的 Slab 集合,在最顶层是 cache_chain,cache_chain 中维护着一组 kmem_cache 引用,kmem_cache 负责管理一块固定大小的对象池。通常会提前分配一块内存,然后将这块内存划分为大小相同的 slot,不会对内存块再进行合并,同时使用位图 bitmap 记录每个 slot 的使用情况。
kmem_cache 中包含三个 Slab 链表:完全分配使用 slab_full、部分分配使用 slab_partial和完全空闲 slabs_empty,这三个链表负责内存的分配和释放。每个链表中维护的 Slab 都是一个或多个连续 Page,每个 Slab 被分配多个对象进行存储。Slab 算法是基于对象进行内存管理的,它把相同类型的对象分为一类。当分配内存时,从 Slab 链表中划分相应的内存单元;当释放内存时,Slab 算法并不会丢弃已经分配的对象,而是将它保存在缓存中,当下次再为对象分配内存时,直接会使用最近释放的内存块。
单个 Slab 可以在不同的链表之间移动,例如当一个 Slab 被分配完,就会从 slab_partial 移动到 slabs_full,当一个 Slab 中有对象被释放后,就会从 slab_full 再次回到 slab_partial,所有对象都被释放完的话,就会从 slab_partial 移动到 slab_empty。
至此,三种最常用的内存分配算法已经介绍完了,优秀的内存分配算法都是在性能和内存利用率之间寻找平衡点,我们今天的主角 jemalloc 就是非常典型的例子。
jemalloc 架构设计
在了解了常用的内存分配算法之后,再理解 jemalloc 的架构设计会相对轻松一些。下图是 jemalloc 的架构图,我们一起学习下它的核心设计理念。
-
+
上图中涉及 jemalloc 的几个核心概念,例如 arena、bin、chunk、run、region、tcache 等,我们下面逐一进行介绍。
arena 是 jemalloc 最重要的部分,内存由一定数量的 arenas 负责管理。每个用户线程都会被绑定到一个 arena 上,线程采用 round-robin 轮询的方式选择可用的 arena 进行内存分配,为了减少线程之间的锁竞争,默认每个 CPU 会分配 4 个 arena。
bin 用于管理不同档位的内存单元,每个 bin 管理的内存大小是按分类依次递增。因为 jemalloc 中小内存的分配是基于 Slab 算法完成的,所以会产生不同类别的内存块。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/13 举一反三:Netty 高性能内存管理设计(上).md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/13 举一反三:Netty 高性能内存管理设计(上).md.html
index f19b9d67..b773b7e4 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/13 举一反三:Netty 高性能内存管理设计(上).md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/13 举一反三:Netty 高性能内存管理设计(上).md.html
@@ -226,7 +226,7 @@ function hide_canvas() {
我们同样带着这两个经典问题开始 Netty 内存管理的课程学习。
内存规格介绍
Netty 保留了内存规格分类的设计理念,不同大小的内存块采用的分配策略是不同的,具体内存规格的分类情况如下图所示。
-
+
上图中 Tiny 代表 0 ~ 512B 之间的内存块,Samll 代表 512B ~ 8K 之间的内存块,Normal 代表 8K ~ 16M 的内存块,Huge 代表大于 16M 的内存块。在 Netty 中定义了一个 SizeClass 类型的枚举,用于描述上图中的内存规格类型,分别为 Tiny、Small 和 Normal。但是图中 Huge 并未在代码中定义,当分配大于 16M 时,可以归类为 Huge 场景,Netty 会直接使用非池化的方式进行内存分配。
Netty 在每个区域内又定义了更细粒度的内存分配单位,分别为 Chunk、Page、Subpage,我们将逐一对其进行介绍。
Chunk 是 Netty 向操作系统申请内存的单位,所有的内存分配操作也是基于 Chunk 完成的,Chunk 可以理解为 Page 的集合,每个 Chunk 默认大小为 16M。
@@ -235,12 +235,12 @@ function hide_canvas() {
了解了 Netty 不同粒度的内存的分配单位后,我们接下来看看 Netty 中的 jemalloc 是如何实现的。
Netty 内存池架构设计
Netty 中的内存池可以看作一个 Java 版本的 jemalloc 实现,并结合 JVM 的诸多特性做了部分优化。如下图所示,我们首先从全局视角看下 Netty 内存池的整体布局,对它有一个宏观的认识。
-
+
基于上图的内存池模型,Netty 抽象出一些核心组件,如 PoolArena、PoolChunk、PoolChunkList、PoolSubpage、PoolThreadCache、MemoryRegionCache 等,可以看出与 jemalloc 中的核心概念有些是类似的,接下来我们逐一进行介绍。
PoolArena
Netty 借鉴了 jemalloc 中 Arena 的设计思想,采用固定数量的多个 Arena 进行内存分配,Arena 的默认数量与 CPU 核数有关,通过创建多个 Arena 来缓解资源竞争问题,从而提高内存分配效率。线程在首次申请分配内存时,会通过 round-robin 的方式轮询 Arena 数组,选择一个固定的 Arena,在线程的生命周期内只与该 Arena 打交道,所以每个线程都保存了 Arena 信息,从而提高访问效率。
根据分配内存的类型,ByteBuf 可以分为 Heap 和 Direct,同样 PoolArena 抽象类提供了 HeapArena 和 DirectArena 两个子类。首先看下 PoolArena 的数据结构,如下图所示。
-
+
PoolArena 的数据结构包含两个 PoolSubpage 数组和六个 PoolChunkList,两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表。
之前我们介绍了 Netty 内存规格的分类,PoolArena 对应实现了 Subpage 和 Chunk 中的内存分配,其 中 PoolSubpage 用于分配小于 8K 的内存,PoolChunkList 用于分配大于 8K 的内存。
PoolSubpage 也是按照 Tiny 和 Small 两种内存规格,设计了tinySubpagePools 和 smallSubpagePools 两个数组,根据关于 Subpage 的介绍,我们知道 Tiny 场景下,内存单位最小为 16B,按 16B 依次递增,共 32 种情况,Small 场景下共分为 512B、1024B、2048B、4096B 四种情况,分别对应两个数组的长度大小,每种粒度的内存单位都由一个 PoolSubpage 进行管理。假如我们分配 20B 大小的内存空间,也会向上取整找到 32B 的 PoolSubpage 节点进行分配。
@@ -254,7 +254,7 @@ function hide_canvas() {
- q100,内存使用率为 100% 的 Chunk。
六种类型的 PoolChunkList 除了 qInit,它们之间都形成了双向链表,如下图所示。
-
+
随着 Chunk 内存使用率的变化,Netty 会重新检查内存的使用率并放入对应的 PoolChunkList,所以 PoolChunk 会在不同的 PoolChunkList 移动。
我在刚开始学习 PoolChunkList 的时候的一个疑问就是,qInit 和 q000 为什么需要设计成两个,是否可以合并成一个?其实它们各有用处。
qInit 用于存储初始分配的 PoolChunk,因为在第一次内存分配时,PoolChunkList 中并没有可用的 PoolChunk,所以需要新创建一个 PoolChunk 并添加到 qInit 列表中。qInit 中的 PoolChunk 即使内存被完全释放也不会被回收,避免 PoolChunk 的重复初始化工作。
@@ -277,10 +277,10 @@ function hide_canvas() {
PoolArena 是 Netty 内存分配中非常重要的部分,我们花了较多篇幅进行讲解,对之后理解内存分配的实现原理会有所帮助。
PoolChunkList
PoolChunkList 负责管理多个 PoolChunk 的生命周期,同一个 PoolChunkList 中存放内存使用率相近的 PoolChunk,这些 PoolChunk 同样以双向链表的形式连接在一起,PoolChunkList 的结构如下图所示。因为 PoolChunk 经常要从 PoolChunkList 中删除,并且需要在不同的 PoolChunkList 中移动,所以双向链表是管理 PoolChunk 时间复杂度较低的数据结构。
-
+
每个 PoolChunkList 都有内存使用率的上下限:minUsage 和 maxUsage,当 PoolChunk 进行内存分配后,如果使用率超过 maxUsage,那么 PoolChunk 会从当前 PoolChunkList 移除,并移动到下一个 PoolChunkList。同理,PoolChunk 中的内存发生释放后,如果使用率小于 minUsage,那么 PoolChunk 会从当前 PoolChunkList 移除,并移动到前一个 PoolChunkList。
回过头再看下 Netty 初始化的六个 PoolChunkList,每个 PoolChunkList 的上下限都有交叉重叠的部分,如下图所示。因为 PoolChunk 需要在 PoolChunkList 不断移动,如果每个 PoolChunkList 的内存使用率的临界值都是恰好衔接的,例如 1 ~ 50%、50% ~ 75%,那么如果 PoolChunk 的使用率一直处于 50% 的临界值,会导致 PoolChunk 在两个 PoolChunkList 不断移动,造成性能损耗。
-
+
PoolChunk
Netty 内存的分配和回收都是基于 PoolChunk 完成的,PoolChunk 是真正存储内存数据的地方,每个 PoolChunk 的默认大小为 16M,首先我们看下 PoolChunk 数据结构的定义:
final class PoolChunk<T> implements PoolChunkMetric {
@@ -297,7 +297,7 @@ function hide_canvas() {
}
PoolChunk 可以理解为 Page 的集合,Page 只是一种抽象的概念,实际在 Netty 中 Page 所指的是 PoolChunk 所管理的子内存块,每个子内存块采用 PoolSubpage 表示。Netty 会使用伙伴算法将 PoolChunk 分配成 2048 个 Page,最终形成一颗满二叉树,二叉树中所有子节点的内存都属于其父节点管理,如下图所示。
-
+
结合 PoolChunk 的结构图,我们介绍一下 PoolChunk 中几个重要的属性:
depthMap 用于存放节点所对应的高度。例如第 2048 个节点 depthMap[1025] = 10。
memoryMap 用于记录二叉树节点的分配信息,memoryMap 初始值与 depthMap 是一样的,随着节点被分配,不仅节点的值会改变,而且会递归遍历更新其父节点的值,父节点的值取两个子节点中最小的值。
@@ -320,11 +320,11 @@ function hide_canvas() {
PoolSubpage 中每个属性的含义都比较清晰易懂,我都以注释的形式标出,在这里就不一一赘述了,只指出其中比较重点的两个知识点:
第一个就是 PoolSubpage 是如何记录内存块的使用状态的呢?PoolSubpage 通过位图 bitmap 记录子内存是否已经被使用,bit 的取值为 0 或者 1,如下图所示。
-
+
第二个就是 PoolSubpage 和 PoolArena 之间是如何联系起来的?
通过之前的介绍,我们知道 PoolArena 在创建是会初始化 tinySubpagePools 和 smallSubpagePools 两个 PoolSubpage 数组,数组的大小分别为 32 和 4。
假如我们现在需要分配 20B 大小的内存,会向上取整为 32B,从满二叉树的第 11 层找到一个 PoolSubpage 节点,并把它等分为 8KB/32B = 256B 个小内存块,然后找到这个 PoolSubpage 节点对应的 PoolArena,将 PoolSubpage 节点与 tinySubpagePools[1] 对应的 head 节点连接成双向链表,形成下图所示的结构。
-
+
下次再有 32B 规格的内存分配时,会直接查找 PoolArena 中 tinySubpagePools[1] 元素的 next 节点是否存在可用的 PoolSubpage,如果存在将直接使用该 PoolSubpage 执行内存分配,从而提高了内存分配效率,其他内存规格的分配原理类似。
PoolThreadCache & MemoryRegionCache
PoolThreadCache 顾名思义,对应的是 jemalloc 中本地线程缓存的意思。那么 PoolThreadCache 是如何被使用的呢?它可以缓存哪些类型的数据呢?
@@ -342,10 +342,10 @@ function hide_canvas() {
}
PoolThreadCache 中有一个重要的数据结构:MemoryRegionCache。MemoryRegionCache 有三个重要的属性,分别为 queue,sizeClass 和 size,下图是不同内存规格所对应的 MemoryRegionCache 属性取值范围。
-
+
MemoryRegionCache 实际就是一个队列,当内存释放时,将内存块加入队列当中,下次再分配同样规格的内存时,直接从队列中取出空闲的内存块。
PoolThreadCache 将不同规格大小的内存都使用单独的 MemoryRegionCache 维护,如下图所示,图中的每个节点都对应一个 MemoryRegionCache,例如 Tiny 场景下对应的 32 种内存规格会使用 32 个 MemoryRegionCache 维护,所以 PoolThreadCache 源码中 Tiny、Small、Normal 类型的 MemoryRegionCache 数组长度分别为 32、4、3。
-
+
到此为止,Netty 中内存管理所涉及的核心组件都介绍完毕,推荐你回头再梳理一遍 jemalloc 的核心概念,与 Netty 做一个简单的对比,思路会更加清晰。
总结
知识都是殊途同归的,当你理解 jemalloc 之后,Netty 的内存管理也就不是那么难了,其中大部分的思路与 jemalloc 是保持一致的,所以打好基础非常重要。下节课我们继续看下 Netty 内存分配与回收的实现原理。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/14 举一反三:Netty 高性能内存管理设计(下).md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/14 举一反三:Netty 高性能内存管理设计(下).md.html
index be20706a..6a99ce3b 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/14 举一反三:Netty 高性能内存管理设计(下).md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/14 举一反三:Netty 高性能内存管理设计(下).md.html
@@ -222,7 +222,7 @@ function hide_canvas() {
本节课会侧重于详细分析不同场景下 Netty 内存分配和回收的实现过程,让你对 Netty 内存池的整体设计有一个更加清晰的认识。
内存分配实现原理
Netty 中负责线程分配的组件有两个:PoolArena和PoolThreadCache。PoolArena 是多个线程共享的,每个线程会固定绑定一个 PoolArena,PoolThreadCache 是每个线程私有的缓存空间,如下图所示。
-
+
在上节课中,我们介绍了 PoolChunk、PoolSubpage、PoolChunkList,它们都是 PoolArena 中所用到的概念。PoolArena 中管理的内存单位为 PoolChunk,每个 PoolChunk 会被划分为 2048 个 8K 的 Page。在申请的内存大于 8K 时,PoolChunk 会以 Page 为单位进行内存分配。当申请的内存大小小于 8K 时,会由 PoolSubpage 管理更小粒度的内存分配。
PoolArena 分配的内存被释放后,不会立即会还给 PoolChunk,而且会缓存在本地私有缓存 PoolThreadCache 中,在下一次进行内存分配时,会优先从 PoolThreadCache 中查找匹配的内存块。
由此可见,Netty 中不同的内存规格采用的分配策略是不同的,我们主要分为以下三个场景逐一进行分析。
@@ -233,7 +233,7 @@ function hide_canvas() {
PoolChunk 中 Page 级别的内存分配
每个 PoolChunk 默认大小为 16M,PoolChunk 是通过伙伴算法管理多个 Page,每个 PoolChunk 被划分为 2048 个 Page,最终通过一颗满二叉树实现,我们再一起回顾下 PoolChunk 的二叉树结构,如下图所示。
-
+
假如用户需要依次申请 8K、16K、8K 的内存,通过这里例子我们详细描述下 PoolChunk 如何分配 Page 级别的内存,方便大家理解伙伴算法的原理。
首先看下分配逻辑 allocateRun 的源码,如下所示。PoolChunk 分配 Page 主要分为三步:首先根据分配内存大小计算二叉树所在节点的高度,然后查找对应高度中是否存在可用节点,如果分配成功则减去已分配的内存大小得到剩余可用空间。
private long allocateRun(int normCapacity) {
@@ -314,7 +314,7 @@ function hide_canvas() {
}
PoolSubpage 通过位图 bitmap 记录每个内存块是否已经被使用。在上述的示例中,8K/32B = 256,因为每个 long 有 64 位,所以需要 256/64 = 4 个 long 类型的即可描述全部的内存块分配状态,因此 bitmap 数组的长度为 4,从 bitmap[0] 开始记录,每分配一个内存块,就会移动到 bitmap[0] 中的下一个二进制位,直至 bitmap[0] 的所有二进制位都赋值为 1,然后继续分配 bitmap[1],以此类推。当我们使用 2049 节点进行内存分配时,bitmap[0] 中的二进制位如下图所示:
-
+
当 bitmap 分成成功后,PoolSubpage 会将可用节点的个数 numAvail 减 1,当 numAvail 降为 0 时,表示 PoolSubpage 已经没有可分配的内存块,此时需要从 PoolArena 中 tinySubpagePools[1] 的双向链表中删除。
至此,整个 PoolChunk 中 Subpage 的内存分配过程已经完成了,可见 PoolChunk 的伙伴算法几乎贯穿了整个流程,位图 bitmap 的设计也是非常巧妙的,不仅节省了内存空间,而且加快了定位内存块的速度。
PoolThreadCache 的内存分配
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/15 轻量级对象回收站:Recycler 对象池技术解析.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/15 轻量级对象回收站:Recycler 对象池技术解析.md.html
index 79c67e83..50b30094 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/15 轻量级对象回收站:Recycler 对象池技术解析.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/15 轻量级对象回收站:Recycler 对象池技术解析.md.html
@@ -269,10 +269,10 @@ true
Recycler 的设计理念
对象池与内存池的都是为了提高 Netty 的并发处理能力,我们知道 Java 中频繁地创建和销毁对象的开销是很大的,所以很多人会将一些通用对象缓存起来,当需要某个对象时,优先从对象池中获取对象实例。通过重用对象,不仅避免频繁地创建和销毁所带来的性能损耗,而且对 JVM GC 是友好的,这就是对象池的作用。
Recycler 是 Netty 提供的自定义实现的轻量级对象回收站,借助 Recycler 可以完成对象的获取和回收。既然 Recycler 是 Netty 自己实现的对象池,那么它是如何设计的呢?首先看下 Recycler 的内部结构,如下图所示:
-
+
通过 Recycler 的 UML 图可以看出,一共包含四个核心组件:Stack、WeakOrderQueue、Link、DefaultHandle,接下来我们逐一进行介绍。
首先我们先看下整个 Recycler 的内部结构中各个组件的关系,可以通过下面这幅图进行描述。
-
+
第一个核心组件是 Stack,Stack 是整个对象池的顶层数据结构,描述了整个对象池的构造,用于存储当前本线程回收的对象。在多线程的场景下,Netty 为了避免锁竞争问题,每个线程都会持有各自的对象池,内部通过 FastThreadLocal 来实现每个线程的私有化。FastThreadLocal 你可以理解为 Java 里的 ThreadLocal,后续会有专门的课程介绍它。
我们有必要先学习下 Stack 的数据结构,先看下 Stack 的源码定义:
static final class Stack<T> {
@@ -394,7 +394,7 @@ boolean scavengeSome() {
}
scavenge 的源码中首先会从 cursor 指针指向的 WeakOrderQueue 节点回收部分对象到 Stack 的 elements 数组中,如果没有回收到数据就会将 cursor 指针移到下一个 WeakOrderQueue,重复执行以上过程直至回到到对象实例为止。具体的流程可以结合下图来理解。
-
+
此外,每次移动 cursor 时,都会检查 WeakOrderQueue 对应的线程是否已经退出了,如果线程已经退出,那么线程中的对象实例都会被回收,然后将 WeakOrderQueue 节点从链表中移除。
还有一个问题,每次 Stack 从 WeakOrderQueue 链表会回收多少数据呢?我们依然结合上图讲解,每个 WeakOrderQueue 中都包含一个 Link 链表,Netty 每次会回收其中的一个 Link 节点所存储的对象。从图中可以看出,Link 内部会包含一个读指针 readIndex,每个 Link 节点默认存储 16 个对象,读指针到链表尾部就是可以用于回收的对象实例,每次回收对象时,readIndex 都会从上一次记录的位置开始回收。
在回收对象实例之前,Netty 会计算出可回收对象的数量,加上 Stack 中已有的对象数量后,如果超过 Stack 的当前容量且小于 Stack 的最大容量,会对 Stack 进行扩容。为了防止回收对象太多导致 Stack 的容量激增,在每次回收时 Netty 会调用 dropHandle 方法控制回收频率,具体源码如下:
@@ -511,7 +511,7 @@ void push(DefaultHandle<?> item) {
到此为止,Recycler 如何回收对象的实现原理就全部分析完了,在多线程的场景下,Netty 考虑的还是非常细致的,Recycler 回收对象时向 WeakOrderQueue 中存放对象,从 Recycler 获取对象时,WeakOrderQueue 中的对象会作为 Stack 的储备,而且有效地解决了跨线程回收的问题,是一个挺新颖别致的设计。
Recycler 在 Netty 中的应用
Recycler 在 Netty 里面使用也是非常频繁的,我们直接看下 Netty 源码中 newObject 相关的引用,如下图所示。
-
+
其中比较常用的有 PooledHeapByteBuf 和 PooledDirectByteBuf,分别对应的堆内存和堆外内存的池化实现。例如我们在使用 PooledDirectByteBuf 的时候,并不是每次都去创建新的对象实例,而是从对象池中获取预先分配好的对象实例,不再使用 PooledDirectByteBuf 时,被回收归还到对象池中。
此外,可以看到内存池的 MemoryRegionCache 也有使用到对象池,MemoryRegionCache 中保存着一个队列,队列中每个 Entry 节点用于保存内存块,Entry 节点在 Netty 中就是以对象池的形式进行分配和释放,在这里我就不展开了,建议你翻阅下源码,学习下 Entry 节点是何时被分配和释放的,从而加深下对 Recycler 对象池的理解。
总结
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/16 IO 加速:与众不同的 Netty 零拷贝技术.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/16 IO 加速:与众不同的 Netty 零拷贝技术.md.html
index e9583f2f..de1afeb3 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/16 IO 加速:与众不同的 Netty 零拷贝技术.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/16 IO 加速:与众不同的 Netty 零拷贝技术.md.html
@@ -222,7 +222,7 @@ function hide_canvas() {
传统 Linux 中的零拷贝技术
在介绍 Netty 零拷贝特性之前,我们有必要学习下传统 Linux 中零拷贝的工作原理。所谓零拷贝,就是在数据操作时,不需要将数据从一个内存位置拷贝到另外一个内存位置,这样可以减少一次内存拷贝的损耗,从而节省了 CPU 时钟周期和内存带宽。
我们模拟一个场景,从文件中读取数据,然后将数据传输到网络上,那么传统的数据拷贝过程会分为哪几个阶段呢?具体如下图所示。
-
+
从上图中可以看出,从数据读取到发送一共经历了四次数据拷贝,具体流程如下:
- 当用户进程发起 read() 调用后,上下文从用户态切换至内核态。DMA 引擎从文件中读取数据,并存储到内核态缓冲区,这里是第一次数据拷贝。
@@ -250,10 +250,10 @@ function hide_canvas() {
}
在使用了 FileChannel#transferTo() 传输数据之后,我们看下数据拷贝流程发生了哪些变化,如下图所示:
-
+
比较大的一个变化是,DMA 引擎从文件中读取数据拷贝到内核态缓冲区之后,由操作系统直接拷贝到 Socket 缓冲区,不再拷贝到用户态缓冲区,所以数据拷贝的次数从之前的 4 次减少到 3 次。
但是上述的优化离达到零拷贝的要求还是有差距的,能否继续减少内核中的数据拷贝次数呢?在 Linux 2.4 版本之后,开发者对 Socket Buffer 追加一些 Descriptor 信息来进一步减少内核数据的复制。如下图所示,DMA 引擎读取文件内容并拷贝到内核缓冲区,然后并没有再拷贝到 Socket 缓冲区,只是将数据的长度以及位置信息被追加到 Socket 缓冲区,然后 DMA 引擎根据这些描述信息,直接从内核缓冲区读取数据并传输到协议引擎中,从而消除最后一次 CPU 拷贝。
-
+
通过上述 Linux 零拷贝技术的介绍,你也许还会存在疑问,最终使用零拷贝之后,不是还存在着数据拷贝操作吗?其实从 Linux 操作系统的角度来说,零拷贝就是为了避免用户态和内存态之间的数据拷贝。无论是传统的数据拷贝还是使用零拷贝技术,其中有 2 次 DMA 的数据拷贝必不可少,只是这 2 次 DMA 拷贝都是依赖硬件来完成,不需要 CPU 参与。所以,在这里我们讨论的零拷贝是个广义的概念,只要能够减少不必要的 CPU 拷贝,都可以被称为零拷贝。
Netty 的零拷贝技术
介绍完传统 Linux 的零拷贝技术之后,我们再来学习下 Netty 中的零拷贝如何实现。Netty 中的零拷贝和传统 Linux 的零拷贝不太一样。Netty 中的零拷贝技术除了操作系统级别的功能封装,更多的是面向用户态的数据操作优化,主要体现在以下 5 个方面:
@@ -279,7 +279,7 @@ httpBuf.writeBytes(body);
httpBuf.addComponents(true, header, body);
CompositeByteBuf 通过调用 addComponents() 方法来添加多个 ByteBuf,但是底层的 byte 数组是复用的,不会发生内存拷贝。但对于用户来说,它可以当作一个整体进行操作。那么 CompositeByteBuf 内部是如何存放这些 ByteBuf,并且如何进行合并的呢?我们先通过一张图看下 CompositeByteBuf 的内部结构:
-
+
从图上可以看出,CompositeByteBuf 内部维护了一个 Components 数组。在每个 Component 中存放着不同的 ByteBuf,各个 ByteBuf 独立维护自己的读写索引,而 CompositeByteBuf 自身也会单独维护一个读写索引。由此可见,Component 是实现 CompositeByteBuf 的关键所在,下面看下 Component 结构定义:
private static final class Component {
final ByteBuf srcBuf; // 原始的 ByteBuf
@@ -292,15 +292,15 @@ httpBuf.addComponents(true, header, body);
}
为了方便理解上述 Component 中的属性含义,我同样以 HTTP 协议中 header 和 body 为示例,通过一张图来描述 CompositeByteBuf 组合后其中 Component 的布局情况,如下所示:
-
+
从图中可以看出,header 和 body 分别对应两个 ByteBuf,假设 ByteBuf 的内容分别为 "header" 和 "body",那么 header ByteBuf 中 offset~endOffset 为 0~6,body ByteBuf 对应的 offset~endOffset 为 0~10。由此可见,Component 中的 offset 和 endOffset 可以表示当前 ByteBuf 可以读取的范围,通过 offset 和 endOffset 可以将每一个 Component 所对应的 ByteBuf 连接起来,形成一个逻辑整体。
此外 Component 中 srcAdjustment 和 adjustment 表示 CompositeByteBuf 起始索引相对于 ByteBuf 读索引的偏移。初始 adjustment = readIndex - offset,这样通过 CompositeByteBuf 的起始索引就可以直接定位到 Component 中 ByteBuf 的读索引位置。当 header ByteBuf 读取 1 个字节,body ByteBuf 读取 2 个字节,此时每个 Component 的属性又会发生什么变化呢?如下图所示。
-
+
至此,CompositeByteBuf 的基本原理我们已经介绍完了,关于具体 CompositeByteBuf 数据操作的细节在这里就不做展开了,有兴趣的同学可以自己深入研究 CompositeByteBuf 的源码。
Unpooled.wrappedBuffer 操作
介绍完 CompositeByteBuf 之后,再来理解 Unpooled.wrappedBuffer 操作就非常容易了,Unpooled.wrappedBuffer 同时也是创建 CompositeByteBuf 对象的另一种推荐做法。
Unpooled 提供了一系列用于包装数据源的 wrappedBuffer 方法,如下所示:
-
+
Unpooled.wrappedBuffer 方法可以将不同的数据源的一个或者多个数据包装成一个大的 ByteBuf 对象,其中数据源的类型包括 byte[]、ByteBuf、ByteBuffer。包装的过程中不会发生数据拷贝操作,包装后生成的 ByteBuf 对象和原始 ByteBuf 对象是共享底层的 byte 数组。
ByteBuf.slice 操作
ByteBuf.slice 和 Unpooled.wrappedBuffer 的逻辑正好相反,ByteBuf.slice 是将一个 ByteBuf 对象切分成多个共享同一个底层存储的 ByteBuf 对象。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/18 源码篇:解密 Netty Reactor 线程模型.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/18 源码篇:解密 Netty Reactor 线程模型.md.html
index 31305e79..e9a0e763 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/18 源码篇:解密 Netty Reactor 线程模型.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/18 源码篇:解密 Netty Reactor 线程模型.md.html
@@ -279,7 +279,7 @@ function hide_canvas() {
}
NioEventLoop 的 run() 方法是一个无限循环,没有任何退出条件,在不间断循环执行以下三件事情,可以用下面这张图形象地表示。
-
+
- 轮询 I/O 事件(select):轮询 Selector 选择器中已经注册的所有 Channel 的 I/O 事件。
- 处理 I/O 事件(processSelectedKeys):处理已经准备就绪的 I/O 事件。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/19 源码篇:一个网络请求在 Netty 中的旅程.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/19 源码篇:一个网络请求在 Netty 中的旅程.md.html
index e0fdcfcf..e25eac5e 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/19 源码篇:一个网络请求在 Netty 中的旅程.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/19 源码篇:一个网络请求在 Netty 中的旅程.md.html
@@ -224,7 +224,7 @@ function hide_canvas() {
事件处理机制回顾
首先我们以服务端接入客户端新连接为例,并结合前两节源码课学习的知识点,一起复习下 Netty 的事件处理流程,如下图所示。
-
+
Netty 服务端启动后,BossEventLoopGroup 会负责监听客户端的 Accept 事件。当有客户端新连接接入时,BossEventLoopGroup 中的 NioEventLoop 首先会新建客户端 Channel,然后在 NioServerSocketChannel 中触发 channelRead 事件传播,NioServerSocketChannel 中包含了一种特殊的处理器 ServerBootstrapAcceptor,最终通过 ServerBootstrapAcceptor 的 channelRead() 方法将新建的客户端 Channel 分配到 WorkerEventLoopGroup 中。WorkerEventLoopGroup 中包含多个 NioEventLoop,它会选择其中一个 NioEventLoop 与新建的客户端 Channel 绑定。
完成客户端连接注册之后,就可以接收客户端的请求数据了。当客户端向服务端发送数据时,NioEventLoop 会监听到 OP_READ 事件,然后分配 ByteBuf 并读取数据,读取完成后将数据传递给 Pipeline 进行处理。一般来说,数据会从 ChannelPipeline 的第一个 ChannelHandler 开始传播,将加工处理后的消息传递给下一个 ChannelHandler,整个过程是串行化执行。
在前面两节课中,我们介绍了服务端如何接收客户端新连接,以及 NioEventLoop 的工作流程,接下来我们重点介绍 ChannelPipeline 是如何实现 Netty 事件驱动的,这样 Netty 整个事件处理流程已经可以串成一条主线。
@@ -253,7 +253,7 @@ protected DefaultChannelPipeline(Channel channel) {
}
当 ChannelPipeline 初始化完成后,会构成一个由 ChannelHandlerContext 对象组成的双向链表,默认 ChannelPipeline 初始化状态的最小结构仅包含 HeadContext 和 TailContext 两个节点,如下图所示。
-
+
HeadContext 和 TailContext 属于 ChannelPipeline 中两个特殊的节点,它们都继承自 AbstractChannelHandlerContext,根据源码看下 AbstractChannelHandlerContext 有哪些实现类,如下图所示。除了 HeadContext 和 TailContext,还有一个默认实现类 DefaultChannelHandlerContext,我们可以猜到 DefaultChannelHandlerContext 封装的是用户在 Netty 启动配置类中添加的自定义业务处理器,DefaultChannelHandlerContext 会插入到 HeadContext 和 TailContext 之间。

接着我们比较一下上述三种 AbstractChannelHandlerContext 实现类的内部结构,发现它们都包含当前 ChannelPipeline 的引用、处理器 ChannelHandler。有一点不同的是 HeadContext 节点还包含了用于操作底层数据读写的 unsafe 对象。对于 Inbound 事件,会先从 HeadContext 节点开始传播,所以 unsafe 可以看作是 Inbound 事件的发起者;对于 Outbound 事件,数据最后又会经过 HeadContext 节点返回给客户端,此时 unsafe 可以看作是 Outbound 事件的处理者。
@@ -448,7 +448,7 @@ private static String generateName0(Class<?> handlerType) {
}
addLast0() 非常简单,就是向 ChannelPipeline 中双向链表的尾部插入新的节点,其中 HeadContext 和 TailContext 一直是链表的头和尾,新的节点被插入到 HeadContext 和 TailContext 之间。例如代码示例中 SampleOutboundA 被添加时,双向链表的结构变化如下所示。
-
+
最后,添加完节点后,就到了回调用户方法,定位到 callHandlerAdded() 的核心源码:
final void callHandlerAdded() throws Exception {
if (setAddComplete()) {
@@ -667,7 +667,7 @@ public ChannelHandlerContext fireChannelRead(final Object msg) {
}
我们发现 HeadContext.channelRead() 并没有做什么特殊操作,而是直接通过 fireChannelRead() 方法继续将读事件继续传播下去。接下来 Netty 会通过 findContextInbound(MASK_CHANNEL_READ), msg) 找到 HeadContext 的下一个节点,然后继续执行我们之前介绍的静态方法 invokeChannelRead(),从而进入一个递归调用的过程,直至某个条件结束。以上 channelRead 的执行过程我们可以梳理成一幅流程图:
-
+
Netty 是如何判断 InboundHandler 是否关心 channelRead 事件呢?这就涉及findContextInbound(MASK_CHANNEL_READ), msg) 中的一个知识点,和上文中我们介绍的 executionMask 掩码运算是息息相关的。首先看下 findContextInbound() 的源码:
private AbstractChannelHandlerContext findContextInbound(int mask) {
AbstractChannelHandlerContext ctx = this;
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/20 技巧篇:Netty 的 FastThreadLocal 究竟比 ThreadLocal 快在哪儿?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/20 技巧篇:Netty 的 FastThreadLocal 究竟比 ThreadLocal 快在哪儿?.md.html
index 15117640..dff2dfbc 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/20 技巧篇:Netty 的 FastThreadLocal 究竟比 ThreadLocal 快在哪儿?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/20 技巧篇:Netty 的 FastThreadLocal 究竟比 ThreadLocal 快在哪儿?.md.html
@@ -262,12 +262,12 @@ tradeOrder info:id=0, status=已支付
可以看出 thread-1 和 thread-2 虽然操作的是同一个 ThreadLocal 对象,但是它们取到了不同的线程名称和订单交易信息。那么一个线程内如何存在多个 ThreadLocal 对象,每个 ThreadLocal 对象是如何存储和检索的呢?
接下来我们看看 ThreadLocal 的实现原理。既然多线程访问 ThreadLocal 变量时都会有自己独立的实例副本,那么很容易想到的方案就是在 ThreadLocal 中维护一个 Map,记录线程与实例之间的映射关系。当新增线程和销毁线程时都需要更新 Map 中的映射关系,因为会存在多线程并发修改,所以需要保证 Map 是线程安全的。那么 JDK 的 ThreadLocal 是这么实现的吗?答案是 NO。因为在高并发的场景并发修改 Map 需要加锁,势必会降低性能。JDK 为了避免加锁,采用了相反的设计思路。以 Thread 入手,在 Thread 中维护一个 Map,记录 ThreadLocal 与实例之间的映射关系,这样在同一个线程内,Map 就不需要加锁了。示例代码中线程 Thread 和 ThreadLocal 的关系可以用以下这幅图表示。
-
+
那么在 Thread 内部,维护映射关系的 Map 是如何实现的呢?从源码中可以发现 Thread 使用的是 ThreadLocal 的内部类 ThreadLocalMap,所以 Thread、ThreadLocal 和 ThreadLocalMap 之间的关系可以用下图表示:
-
+
为了更加深入理解 ThreadLocal,了解 ThreadLocalMap 的内部实现是非常有必要的。ThreadLocalMap 其实与 HashMap 的数据结构类似,但是 ThreadLocalMap 不具备通用性,它是为 ThreadLocal 量身定制的。
ThreadLocalMap 是一种使用线性探测法实现的哈希表,底层采用数组存储数据。如下图所示,ThreadLocalMap 会初始化一个长度为 16 的 Entry 数组,每个 Entry 对象用于保存 key-value 键值对。与 HashMap 不同的是,Entry 的 key 就是 ThreadLocal 对象本身,value 就是用户具体需要存储的值。
-
+
当调用 ThreadLocal.set() 添加 Entry 对象时,是如何解决 Hash 冲突的呢?这就需要我们了解线性探测法的实现原理。每个 ThreadLocal 在初始化时都会有一个 Hash 值为 threadLocalHashCode,每增加一个 ThreadLocal, Hash 值就会固定增加一个魔术 HASH_INCREMENT = 0x61c88647。为什么取 0x61c88647 这个魔数呢?实验证明,通过 0x61c88647 累加生成的 threadLocalHashCode 与 2 的幂取模,得到的结果可以较为均匀地分布在长度为 2 的幂大小的数组中。有了 threadLocalHashCode 的基础,下面我们通过下面的表格来具体讲解线性探测法是如何实现的。

为了便于理解,我们采用一组简单的数据模拟 ThreadLocal.set() 的过程是如何解决 Hash 冲突的。
@@ -325,10 +325,10 @@ class UnpaddedInternalThreadLocalMap {
}
从 InternalThreadLocalMap 内部实现来看,与 ThreadLocalMap 一样都是采用数组的存储方式。但是 InternalThreadLocalMap 并没有使用线性探测法来解决 Hash 冲突,而是在 FastThreadLocal 初始化的时候分配一个数组索引 index,index 的值采用原子类 AtomicInteger 保证顺序递增,通过调用 InternalThreadLocalMap.nextVariableIndex() 方法获得。然后在读写数据的时候通过数组下标 index 直接定位到 FastThreadLocal 的位置,时间复杂度为 O(1)。如果数组下标递增到非常大,那么数组也会比较大,所以 FastThreadLocal 是通过空间换时间的思想提升读写性能。下面通过一幅图描述 InternalThreadLocalMap、index 和 FastThreadLocal 之间的关系。
-
+
通过上面 FastThreadLocal 的内部结构图,我们对比下与 ThreadLocal 有哪些区别呢?FastThreadLocal 使用 Object 数组替代了 Entry 数组,Object[0] 存储的是一个Set<FastThreadLocal<?>> 集合,从数组下标 1 开始都是直接存储的 value 数据,不再采用 ThreadLocal 的键值对形式进行存储。
假设现在我们有一批数据需要添加到数组中,分别为 value1、value2、value3、value4,对应的 FastThreadLocal 在初始化的时候生成的数组索引分别为 1、2、3、4。如下图所示。
-
+
至此,我们已经对 FastThreadLocal 有了一个基本的认识,下面我们结合具体的源码分析 FastThreadLocal 的实现原理。
FastThreadLocal 源码分析
在讲解源码之前,我们回过头看下上文中的 ThreadLocal 示例,如果把示例中 ThreadLocal 替换成 FastThread,应当如何使用呢?
@@ -394,7 +394,7 @@ private static InternalThreadLocalMap slowGet() {
}
InternalThreadLocalMap.get() 逻辑很简单,为了帮助你更好地理解,下面使用一幅图描述 InternalThreadLocalMap 的获取方式。
-
+
如果当前线程是 FastThreadLocalThread 类型,那么直接通过 fastGet() 方法获取 FastThreadLocalThread 的 threadLocalMap 属性即可。如果此时 InternalThreadLocalMap 不存在,直接创建一个返回。关于 InternalThreadLocalMap 的初始化在上文中已经介绍过,它会初始化一个长度为 32 的 Object 数组,数组中填充着 32 个缺省对象 UNSET 的引用。
那么 slowGet() 又是什么作用呢?从代码分支来看,slowGet() 是针对非 FastThreadLocalThread 类型的线程发起调用时的一种兜底方案。如果当前线程不是 FastThreadLocalThread,内部是没有 InternalThreadLocalMap 属性的,Netty 在 UnpaddedInternalThreadLocalMap 中保存了一个 JDK 原生的 ThreadLocal,ThreadLocal 中存放着 InternalThreadLocalMap,此时获取 InternalThreadLocalMap 就退化成 JDK 原生的 ThreadLocal 获取。
获取 InternalThreadLocalMap 的过程已经讲完了,下面看下 setKnownNotUnset() 如何将数据添加到 InternalThreadLocalMap 的。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/22 技巧篇:高性能无锁队列 Mpsc Queue.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/22 技巧篇:高性能无锁队列 Mpsc Queue.md.html
index b52a4ae6..b66622ff 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/22 技巧篇:高性能无锁队列 Mpsc Queue.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/22 技巧篇:高性能无锁队列 Mpsc Queue.md.html
@@ -286,11 +286,11 @@ long p10, p11, p12, p13, p14, p15, p16, p17;
可以看出,MpscXxxPad 类中使用了大量 long 类型的变量,其命名没有什么特殊的含义,只是起到填充的作用。如果你也读过 Disruptor 的源码,会发现 Disruptor 也使用了类似的填充方法。Mpsc Queue 和 Disruptor 之所以填充这些无意义的变量,是为了解决伪共享(false sharing)问题。
什么是伪共享呢?我们有必要补充这方面的基础知识。在计算机组成中,CPU 的运算速度比内存高出几个数量级,为了 CPU 能够更高效地与内存进行交互,在 CPU 和内存之间设计了多层缓存机制,如下图所示。
-
+
一般来说,CPU 会分为三级缓存,分别为L1 一级缓存、L2 二级缓存和L3 三级缓存。越靠近 CPU 的缓存,速度越快,但是缓存的容量也越小。所以从性能上来说,L1 > L2 > L3,容量方面 L1 < L2 < L3。CPU 读取数据时,首先会从 L1 查找,如果未命中则继续查找 L2,如果还未能命中则继续查找 L3,最后还没命中的话只能从内存中查找,读取完成后再将数据逐级放入缓存中。此外,多线程之间共享一份数据的时候,需要其中一个线程将数据写回主存,其他线程访问主存数据。
由此可见,引入多级缓存是为了能够让 CPU 利用率最大化。如果你在做频繁的 CPU 运算时,需要尽可能将数据保持在缓存中。那么 CPU 从内存中加载数据的时候,是如何提高缓存的利用率的呢?这就涉及缓存行(Cache Line)的概念,Cache Line 是 CPU 缓存可操作的最小单位,CPU 缓存由若干个 Cache Line 组成。Cache Line 的大小与 CPU 架构有关,在目前主流的 64 位架构下,Cache Line 的大小通常为 64 Byte。Java 中一个 long 类型是 8 Byte,所以一个 Cache Line 可以存储 8 个 long 类型变量。CPU 在加载内存数据时,会将相邻的数据一同读取到 Cache Line 中,因为相邻的数据未来被访问的可能性最大,这样就可以避免 CPU 频繁与内存进行交互了。
伪共享问题是如何发生的呢?它又会造成什么影响呢?我们使用下面这幅图进行讲解。
-
+
假设变量 A、B、C、D 被加载到同一个 Cache Line,它们会被高频地修改。当线程 1 在 CPU Core1 中中对变量 A 进行修改,修改完成后 CPU Core1 会通知其他 CPU Core 该缓存行已经失效。然后线程 2 在 CPU Core2 中对变量 C 进行修改时,发现 Cache line 已经失效,此时 CPU Core1 会将数据重新写回内存,CPU Core2 再从内存中读取数据加载到当前 Cache line 中。
由此可见,如果同一个 Cache line 被越多的线程修改,那么造成的写竞争就会越激烈,数据会频繁写入内存,导致性能浪费。题外话,多核处理器中,每个核的缓存行内容是如何保证一致的呢?有兴趣的同学可以深入学习下缓存一致性协议 MESI,具体可以参考 https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE。
对于伪共享问题,我们应该如何解决呢?Disruptor 和 Mpsc Queue 都采取了空间换时间的策略,让不同线程共享的对象加载到不同的缓存行即可。下面我们通过一个简单的例子进行说明。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/23 架构设计:如何实现一个高性能分布式 RPC 框架.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/23 架构设计:如何实现一个高性能分布式 RPC 框架.md.html
index 9db0387c..cd82459b 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/23 架构设计:如何实现一个高性能分布式 RPC 框架.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/23 架构设计:如何实现一个高性能分布式 RPC 框架.md.html
@@ -224,7 +224,7 @@ function hide_canvas() {
在正式开始 RPC 实战项目之前,我们先学习一下 RPC 的架构设计,这是项目前期规划非常重要的一步。
RPC 框架架构设计
RPC 又称远程过程调用(Remote Procedure Call),用于解决分布式系统中服务之间的调用问题。通俗地讲,就是开发者能够像调用本地方法一样调用远程的服务。下面我们通过一幅图来说说 RPC 框架的基本架构。
-
+
RPC 框架包含三个最重要的组件,分别是客户端、服务端和注册中心。在一次 RPC 调用流程中,这三个组件是这样交互的:
- 服务端在启动后,会将它提供的服务列表发布到注册中心,客户端向注册中心订阅服务地址;
@@ -246,25 +246,25 @@ function hide_canvas() {
RPC 调用方式
成熟的 RPC 框架一般会提供四种调用方式,分别为同步 Sync、异步 Future、回调 Callback和单向 Oneway。RPC 框架的性能和吞吐量与合理使用调用方式是息息相关的,下面我们逐一介绍下四种调用方式的实现原理。
Sync 同步调用。客户端线程发起 RPC 调用后,当前线程会一直阻塞,直至服务端返回结果或者处理超时异常。Sync 同步调用一般是 RPC 框架默认的调用方式,为了保证系统可用性,客户端设置合理的超时时间是非常重要的。虽说 Sync 是同步调用,但是客户端线程和服务端线程并不是同一个线程,实际在 RPC 框架内部还是异步处理的。Sync 同步调用的过程如下图所示。
-
+
- Future 异步调用。客户端发起调用后不会再阻塞等待,而是拿到 RPC 框架返回的 Future 对象,调用结果会被服务端缓存,客户端自行决定后续何时获取返回结果。当客户端主动获取结果时,该过程是阻塞等待的。Future 异步调用过程如下图所示。
-
+
- Callback 回调调用。如下图所示,客户端发起调用时,将 Callback 对象传递给 RPC 框架,无须同步等待返回结果,直接返回。当获取到服务端响应结果或者超时异常后,再执行用户注册的 Callback 回调。所以 Callback 接口一般包含 onResponse 和 onException 两个方法,分别对应成功返回和异常返回两种情况。
-
+
- Oneway 单向调用。客户端发起请求之后直接返回,忽略返回结果。Oneway 方式是最简单的,具体调用过程如下图所示。
-
+
四种调用方式都各有优缺点,很难说异步方式一定会比同步方式效果好,在不用的业务场景可以按需选取更合适的调用方式。
线程模型
线程模型是 RPC 框架需要重点关注的部分,与我们之前介绍的 Netty Reactor 线程模型有什么区别和联系吗?
首先我们需要明确 I/O 线程和业务线程的区别,以 Dubbo 框架为例,Dubbo 使用 Netty 作为底层的网络通信框架,采用了我们熟悉的主从 Reactor 线程模型,其中 Boss 和 Worker 线程池就可以看作 I/O 线程。I/O 线程可以理解为主要负责处理网络数据,例如事件轮询、编解码、数据传输等。如果业务逻辑能够立即完成,也可以使用 I/O 线程进行处理,这样可以省去线程上下文切换的开销。如果业务逻辑耗时较多,例如包含查询数据库、复杂规则计算等耗时逻辑,那么 I/O 必须将这些请求分发到业务线程池中进行处理,以免阻塞 I/O 线程。
那么哪些请求需要在 I/O 线程中执行,哪些又需要在业务线程池中执行呢?Dubbo 框架的做法值得借鉴,它给用户提供了多种选择,它一共提供了 5 种分发策略,如下表格所示。
-
+
负载均衡
在分布式系统中,服务提供者和服务消费者都会有多台节点,如何保证服务提供者所有节点的负载均衡呢?客户端在发起调用之前,需要感知有多少服务端节点可用,然后从中选取一个进行调用。客户端需要拿到服务端节点的状态信息,并根据不同的策略实现负载均衡算法。负载均衡策略是影响 RPC 框架吞吐量很重要的一个因素,下面我们介绍几种最常用的负载均衡策略。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/24 服务发布与订阅:搭建生产者和消费者的基础框架.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/24 服务发布与订阅:搭建生产者和消费者的基础框架.md.html
index 6b600d78..524a6c92 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/24 服务发布与订阅:搭建生产者和消费者的基础框架.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/24 服务发布与订阅:搭建生产者和消费者的基础框架.md.html
@@ -234,7 +234,7 @@ function hide_canvas() {
项目结构
在动手开发项目之前,我们需要对项目结构有清晰的构思。根据上节课介绍的 RPC 框架设计架构,我们可以将项目结构划分为以下几个模块。
-
+
其中每个模块都是什么角色呢?下面我们一一进行介绍。
- rpc-provider,服务提供者。负责发布 RPC 服务,接收和处理 RPC 请求。
@@ -245,7 +245,7 @@ function hide_canvas() {
- rpc-facade,RPC 服务接口。包含服务提供者需要对外暴露的接口,本模块主要用于模拟真实 RPC 调用的测试。
如下图所示,首先我们需要清楚各个模块之间的依赖关系,才能帮助我们更好地梳理 Maven 的 pom 定义。rpc-core 是最基础的类库,所以大部分模块都依赖它。rpc-consumer 用于发起 RPC 调用。rpc-provider 负责处理 RPC 请求,如果不知道远程服务的地址,那么一切都是空谈了,所以两者都需要依赖 rpc-registry 提供的服务发现和服务注册的能力。
-
+
如何使用
我们不着急开始动手实现代码细节,而是考虑一个问题,最终实现的 RPC 框架应该让用户如何使用呢?这就跟我们学习一门技术一样,你不可能刚开始就直接陷入源码的细节,而是先熟悉它的基本使用方式,然后找到关键的切入点再深入研究实现原理,会起到事半功倍的效果。
首先我们看下 RPC 框架想要实现的效果,如下所示:
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/25 远程通信:通信协议设计以及编解码的实现.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/25 远程通信:通信协议设计以及编解码的实现.md.html
index bd10ae61..f74a4ba4 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/25 远程通信:通信协议设计以及编解码的实现.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/25 远程通信:通信协议设计以及编解码的实现.md.html
@@ -229,13 +229,13 @@ function hide_canvas() {
RPC 通信方案设计
结合本节课的目标,接下来我们对 RPC 请求调用和结果响应两个过程分别进行详细拆解分析。首先看下 RPC 请求调用的过程,如下图所示。
-
+
RPC 请求的过程对于服务消费者来说是出站操作,对于服务提供者来说是入站操作。数据发送前,服务消费者将 RPC 请求信息封装成 MiniRpcProtocol 对象,然后通过编码器 MiniRpcEncoder 进行二进制编码,最后直接向发送至远端即可。服务提供者收到请求数据后,将二进制数据交给解码器 MiniRpcDecoder,解码后再次生成 MiniRpcProtocol 对象,然后传递给 RpcRequestHandler 执行真正的 RPC 请求调用。
我们暂时忽略 RpcRequestHandler 是如何执行 RPC 请求调用的,接下来我们继续分析 RpcRequestHandler 处理成功后是如何向服务消费者返回响应结果的,如下图所示:
-
+
与 RPC 请求过程相反,是由服务提供者将响应结果封装成 MiniRpcProtocol 对象,然后通过 MiniRpcEncoder 编码发送给服务消费者。服务消费者对响应结果进行解码,因为 RPC 请求是高并发的,所以需要 RpcRequestHandler 根据响应结果找到对应的请求,最后将响应结果返回。
综合 RPC 请求调用和结果响应的处理过程来看,编码器 MiniRpcEncoder、解码器 MiniRpcDecoder 以及通信协议对象 MiniRpcProtocol 都可以设计成复用的,最终服务消费者和服务提供者的 ChannelPipeline 结构如下图所示。
-
+
由此可见,在实现 Netty 网络通信模块时,先画图分析 ChannelHandler 的处理流程是非常有帮助的。
自定义 RPC 通信协议
协议是服务消费者和服务提供者之间通信的基础,主流的 RPC 框架都会自定义通信协议,相比于 HTTP、HTTPS、JSON 等通用的协议,自定义协议可以实现更好的性能、扩展性以及安全性。在《接头暗语:利用 Netty 如何实现自定义协议通信》课程中,我们学习了设计一个完备的通信协议需要考虑哪些因素,同时结合 RPC 请求调用与结果响应的场景,我们设计了一个简易版的 RPC 自定义协议,如下所示:
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/26 服务治理:服务发现与负载均衡机制的实现.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/26 服务治理:服务发现与负载均衡机制的实现.md.html
index be931e93..a868a37a 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/26 服务治理:服务发现与负载均衡机制的实现.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/26 服务治理:服务发现与负载均衡机制的实现.md.html
@@ -310,7 +310,7 @@ public void register(ServiceMeta serviceMeta) throws Exception {
}
ServiceInstance 对象代表一个服务实例,它包含名称 name、唯一标识 id、地址 address、端口 port 以及用户自定义的可选属性 payload,我们有必要了解 ServiceInstance 在 Zookeeper 服务器中的存储形式,如下图所示。
-
+
一般来说,我们会将相同版本的 RPC 服务归类在一起,所以可以将 ServiceInstance 的名称 name 根据服务名称和服务版本进行赋值,如下所示。
public class RpcServiceHelper {
public static String buildServiceKey(String serviceName, String serviceVersion) {
@@ -344,12 +344,12 @@ public Object postProcessAfterInitialization(Object bean, String beanName) throw
负载均衡算法基础
服务消费者在发起 RPC 调用之前,需要感知有多少服务端节点可用,然后从中选取一个进行调用。之前我们提到了几种常用的负载均衡策略:Round-Robin 轮询、Weighted Round-Robin 权重轮询、Least Connections 最少连接数、Consistent Hash 一致性 Hash 等。本节课我们讨论的主角是基于一致性 Hash 的负载均衡算法,一致性 Hash 算法可以保证每个服务节点分摊的流量尽可能均匀,而且能够把服务节点扩缩容带来的影响降到最低。下面我们一起看下一致性 Hash 算法的设计思路。
在服务端节点扩缩容时,一致性 Hash 算法会尽可能保证客户端发起的 RPC 调用还是固定分配到相同的服务节点上。一致性 Hash 算法是采用哈希环来实现的,通过 Hash 函数将对象和服务器节点放置在哈希环上,一般来说服务器可以选择 IP + Port 进行 Hash,如下图所示。
-
+
图中 C1、C2、C3、C4 是客户端对象,N1、N2、N3 为服务节点,然后在哈希环中顺时针查找距离客户端对象 Hash 值最近的服务节点,即为客户端对应要调用的服务节点。假设现在服务节点扩容了一台 N4,经过 Hash 函数计算将其放入到哈希环中,哈希环变化如下图所示。
-
+
此时 N2 和 N4 之间的客户端对象需要重新进行分配,可以看出只有 C3 会被分配到新的节点 N4 上,其他的都保持不变。服务节点下线与上线的处理过程是类似的,你可以自行分析下服务节点下线时哈希环是如何变化的。
如果服务节点的数量很少,不管 Hash 算法如何,很大可能存在服务节点负载不均的现象。而且上图中在新增服务节点 N4 时,仅仅分担了 N1 节点的流量,其他节点并没有流量变化。为了解决上述问题,一致性 Hash 算法一般会引入虚拟节点的概念。如下图所示。
-
+
图中相同颜色表示同一组虚拟服务器,它们经过 Hash 函数计算后被均匀放置在哈希环中。如果真实的服务节点越多,那么所需的虚拟节点就越少。在为客户端对象分配节点的时候,需要顺时针从哈希环中找到距离最近的虚拟节点,然后即可确定真实的服务节点。
有了上述一致性 Hash 算法的基础知识,下面我们看看一致性 Hash 算法是如何实现的。
负载均衡算法实现
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/28 实战总结:RPC 实战总结与进阶延伸.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/28 实战总结:RPC 实战总结与进阶延伸.md.html
index be4a035a..c70e8ad2 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/28 实战总结:RPC 实战总结与进阶延伸.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/28 实战总结:RPC 实战总结与进阶延伸.md.html
@@ -231,7 +231,7 @@ function hide_canvas() {
一个完备的网络协议需要具备的基本要素:魔数、协议版本号、序列化算法、报文类型、长度域字段、请求数据、保留字段。在实现协议编解码时经常用到两个重要的抽象类:MessageToByteEncoder 编码器和ByteToMessageDecoder 解码器。Netty 也提供了很多开箱即用的拆包器,推荐最广泛使用的 LengthFieldBasedFrameDecoder,它可以满足实际项目中的大部分场景。如果对 LengthFieldBasedFrameDecoder 的参数不够熟悉,实际直接使用 ByteBuf 反而更加直观,根据个人喜好按需选择。
ByteBuf
ByteBuf 是必须要掌握的核心工具类,并且能够理解 ByteBuf 的内部构造。ByteBuf 包含三个指针:读指针 readerIndex、写指针 writeIndex、最大容量 maxCapacity,根据指针的位置又可以将 ByteBuf 内部结构可以分为四个部分:废弃字节、可读字节、可写字节和可扩容字节。如下图所示。
-
+
Pipeline & ChannelHandler
ChannelPipeline 和 ChannelHandler 也是我们在平时应用开发的过程中打交道最多的组件,这两个组件为用户提供了 I/O 事件的全部控制权。ChannelPipeline 是双向链表结构,包含 ChannelInboundHandler 和 ChannelOutboundHandler 两种处理器。Inbound 事件和 Outbound 事件的传播方向相反,Inbound 事件的传播方向为 Head -> Tail,而 Outbound 事件传播方向是 Tail -> Head。在设计之初一定要梳理清楚 Inbound 和 Outbound 处理的传递顺序,以及数据模型之间是如何转换的。
注册中心
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/29 编程思想:Netty 中应用了哪些设计模式?.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/29 编程思想:Netty 中应用了哪些设计模式?.md.html
index 9a05e618..b96ed205 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/29 编程思想:Netty 中应用了哪些设计模式?.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/29 编程思想:Netty 中应用了哪些设计模式?.md.html
@@ -341,7 +341,7 @@ public class BMWFactory implements CarFactory {
虽然通过反射技术可以有效地减少工厂类的数据量,但是反射相比直接创建工厂类有性能损失,所以对于性能敏感的场景,应当谨慎使用反射。
责任链模式
想必学完本专栏的前面课程后,责任链模式大家应该再熟悉不过了,自然而然联想到 ChannlPipeline 和 ChannelHandler。ChannlPipeline 内部是由一组 ChannelHandler 实例组成的,内部通过双向链表将不同的 ChannelHandler 链接在一起,如下图所示。
-
+
对于 Netty 中责任链模式的实现,也遵循了责任链模式的四个基本要素:
责任处理器接口
ChannelHandler 对应的就是责任处理器接口,ChannelHandler 有两个重要的子接口:ChannelInboundHandler和ChannelOutboundHandler,分别拦截入站和出站的各种 I/O 事件。
@@ -363,12 +363,12 @@ public class BMWFactory implements CarFactory {
}
ChannelPipeline 提供了一系列 add 和 remove 相关接口用于动态添加和删除 ChannelHandler 处理器,如下所示:
-
+
上下文
从 ChannelPipeline 内部结构定义可以看出,ChannelHandlerContext 负责保存责任链节点上下文信息。ChannelHandlerContext 是对 ChannelHandler 的封装,每个 ChannelHandler 都对应一个 ChannelHandlerContext,实际上 ChannelPipeline 维护的是与 ChannelHandlerContext 的关系。
责任传播和终止机制
ChannelHandlerContext 提供了 fire 系列的方法用于事件传播,如下所示:
-
+
以 ChannelInboundHandlerAdapter 的 channelRead 方法为例,ChannelHandlerContext 会默认调用 fireChannelRead 方法将事件默认传递到下一个处理器。如果我们重写了 ChannelInboundHandlerAdapter 的 channelRead 方法,并且没有调用 fireChannelRead 进行事件传播,那么表示此次事件传播已终止。
观察者模式
观察者模式有两个角色:观察者和被观察。被观察者发布消息,观察者订阅消息,没有订阅的观察者是收不到消息的。首先我们通过一个简单的例子看下观察者模式的是如何实现的。
diff --git a/专栏/Netty 核心原理剖析与 RPC 实践-完/30 实践总结:Netty 在项目开发中的一些最佳实践.md.html b/专栏/Netty 核心原理剖析与 RPC 实践-完/30 实践总结:Netty 在项目开发中的一些最佳实践.md.html
index 33d4523a..cffafe92 100644
--- a/专栏/Netty 核心原理剖析与 RPC 实践-完/30 实践总结:Netty 在项目开发中的一些最佳实践.md.html
+++ b/专栏/Netty 核心原理剖析与 RPC 实践-完/30 实践总结:Netty 在项目开发中的一些最佳实践.md.html
@@ -358,7 +358,7 @@ ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerG

流量整形
流量整形(Traffic Shaping)是一种主动控制服务流量输出速率的措施,保证下游服务能够平稳处理。流量整形和流控的区别在于,流量整形不会丢弃和拒绝消息,无论流量洪峰有多大,它都会采用令牌桶算法控制流量以恒定的速率输出,如下图所示。
-
+
Netty 通过实现流量整形的抽象类 AbstractTrafficShapingHandler,提供了三种类型的流量整形策略:GlobalTrafficShapingHandler、ChannelTrafficShapingHandler 和 GlobalChannelTrafficShapingHandler,它们之间的关系如下:
GlobalTrafficShapingHandler = ChannelTrafficShapingHandler + GlobalChannelTrafficShapingHandler
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/01 目标管理发展:OKR 之前,大家都在用什么管理组织目标?.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/01 目标管理发展:OKR 之前,大家都在用什么管理组织目标?.md.html
index a58eff47..a58a88b1 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/01 目标管理发展:OKR 之前,大家都在用什么管理组织目标?.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/01 目标管理发展:OKR 之前,大家都在用什么管理组织目标?.md.html
@@ -155,7 +155,7 @@ function hide_canvas() {
那么,我们在注重目标管理的情况下,该如何把目标进行细化,从哪些维度来制定目标才能更好地沟通和跟进呢?这就涉及 SMART 原则了。
第二阶段:SMART 原则
SMART 是 5 个英文单词首字母的缩写,如下所示:
-
+
SMART 原则是目标设定的基本原则,换句话说,五个原则如果有一个没被遵守,或是没能做到,那么目标的制定和实现就充满了挑战。
比如目标不够具体,就会产生理解歧义;目标不能度量,就没法评价结果好坏;目标制定的不可实现,就是竹篮打水一场空;目标没有相关性,就是偏离了组织目标主航道没法形成合力;目标没有时限,也就没有压力,没有压力成本效率就是问题。
所以,正因为 SMART 方法是目标设定要遵守的最基本的五个维度,所以无论是后来产生的KPI、BSC 还是 OKR 方法也都要求符合目标管理的 SMART 原则,否则目标的制定就没法说清道明,实现也就不了了之。
@@ -182,7 +182,7 @@ function hide_canvas() {
总结
至此,我通过第一课时带你了解了组织目标管理 5 个重要的发展阶段,你可以借助对于这 5 个阶段的理解和应用,让组织的目标管理变得更加有效。比如,在和团队讨论目标的时候,参考 SMART 原则,这样就减少了在制定目标时容易产生的歧义。再如,基于对 BSC 的理解,引导团队去制定关注组织效能和人力资源的指标,因为过程性的这两类指标对于一个团队和组织的长期发展也很重要。
另外,我也围绕组织目标管理经历的阶段、价值和问题给你总结成一个表格,方便你回顾和吸收今天的重点内容。
-
+
学习是“学”和“习”的共生体,所以接下来我想请你也来思考一个问题:
组织目标管理经历了这么多阶段,如今百度、京东、华为、腾讯、字节跳动、Uber、谷歌等国内外头部公司都在使用 OKR,那么 OKR 到底有哪些理念和特点?它又是如何帮助组织更好管理目标的呢?
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/03 OKR 与战略:OKR 如何解决组织增长问题?.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/03 OKR 与战略:OKR 如何解决组织增长问题?.md.html
index c94b5e76..aab8f477 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/03 OKR 与战略:OKR 如何解决组织增长问题?.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/03 OKR 与战略:OKR 如何解决组织增长问题?.md.html
@@ -162,7 +162,7 @@ function hide_canvas() {
最后,供应链是京东的核心能力,正是因为供应链的核心能力,才能让京东履行使命有保障,供应链是京东业务选择所依赖的能力基础,用来指导未来业务的扩展和创新方向。
所以,我们可以发现,组织长期目标对应的就是组织的愿景,而阶段性支撑愿景实现的目标就是基于组织核心能力的选择,我们常把这个选择称作战略。那么,基于组织核心能力选择的战略方向,对于组织的意义又是怎样的呢?
战略就是解决组织增长的问题
-
+
2020 开年,京东零售 CEO 徐雷进行了演讲,其确定了京东零售的主基调就是“有质量的加速增长”,并表示:“不成长便退场,加速增长是我们的必然选择。2020年,京东零售将在交易额、收入、用户、利润这四大核心指标上均实现加速增长。”而围绕增长主基调,京东所选择的三大必赢之战分别是:全渠道、下沉新兴市场和平台生态。在京东,战略选择的方向,内部被称为“必赢之战”。
从京东零售 CEO 徐雷的分享中,我们可以得到三个非常重要的对应关系:
@@ -187,7 +187,7 @@ function hide_canvas() {
接下来,我给你举一个百度李彦宏的 OKR 案例,看看 OKR 是如何与百度战略目标相匹配,又是如何支撑组织目标增长的。
2019 年春节前夕,百度元老崔珊珊在百度内部掀起了一场 OKR 变革风暴,这场风暴席卷了从最高决策层到最基层的几乎所有百度员工。作为百度的掌舵人,李彦宏也在第一时间为自己制定了 OKR:
-
+
通过李彦宏的 OKR,我帮你做以下三点分析:
- 作为百度创始人兼 CEO,李彦宏的目标(O)就是百度的战略方向,一共包括了三个战略:打造移动生态、跑通 AI 商业模式以及提升组织能力。
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/05 O:什么样的 O 得领导赏识?.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/05 O:什么样的 O 得领导赏识?.md.html
index ae5fa77d..45e9e37d 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/05 O:什么样的 O 得领导赏识?.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/05 O:什么样的 O 得领导赏识?.md.html
@@ -184,7 +184,7 @@ Q3 该 O 的描述:京东 OKR 工作法落地和执行,高质量完成业务
O 的选择类型
为了让你能更好地理解这个部分的内容,接下来,我列举业务维度和技术维度的两个案例,来说明选择什么样的 O 对于组织才是有价值的,换句话说,这些 O 的价值性都已经被京东验证通过,你看案例的时候,可以多关注我所举案例 O 的价值类型。
这是京东某业务负责人 Q2 的 O,该业务负责人是负责京东 ISV 开放业务(即通过独立软件开发商为京东商家定制化开发相关产品&服务),在其 Q2 的 OKR 制定中,写了 4 个方向的 O。
-
+
如果更加概括地来分析这 4 个方向,我们可以发现:
- O1 最终目的是提升 GMV,即营收方向;
@@ -194,7 +194,7 @@ Q3 该 O 的描述:京东 OKR 工作法落地和执行,高质量完成业务
总结下来,该业务负责人 O 的方向聚焦在这 3 类:营收型、能力提升型以及用户型。
接下来,我们再来看一个技术负责人的案例。该技术负责人是负责前端开发团队的管理和专业化能力提升,在其 Q2 的 OKR 制定中,写了 3 个方向的 O。
-
+
我们依旧来概括性地分析下这 3 个方向:
- O1 的目的是团队成员的成长,其实就是提升员工能力;
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/07 案例实战:教你写出高质量的 OKR.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/07 案例实战:教你写出高质量的 OKR.md.html
index 2d434841..6925a57f 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/07 案例实战:教你写出高质量的 OKR.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/07 案例实战:教你写出高质量的 OKR.md.html
@@ -194,7 +194,7 @@ KR3:云及 AI2B 业务至少在*个万亿级行业成为第一
我和你分享的这 4 个案例,都特别具有参考性,包括了创始人的 OKR、公司战略型 OKR、研发负责人型 OKR 和项目型 OKR。我在具体的点评过程中,都参照了上两讲 O 和 KR 写法的知识点,对这 4 个 OKR 案例的优缺点进行了说明,解读完整的案例会让你对如何写好 OKR 有更加全面、深刻的理解,避免你在实际应用中踩坑。
总结
最后,为了方便你及时学习、复习,我把在写 O 和 KR 时需要掌握的知识点放在了一张表格中:
-
+
欢迎你在对照上述表格中的 OKR 写法要点,以及对各种 OKR 案例有了好差的学习理解之后,在留言区晒出你自己的完整 OKR,我来帮你圈出写得好的地方,以及不足的地方,助你持续迭代写出“高质量的 OKR”。
讲完了 OKR 的实操,想要在组织中落地 OKR,仅仅靠写是不够的,这个时候你就需要结合流程管理,那么这个落地 OKR 的流程该怎么打造呢?在下一课时,我将介绍“OKR 的制定和流程管理”。
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/08 流程:你应该这样制定、管理 OKR!.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/08 流程:你应该这样制定、管理 OKR!.md.html
index aac58ed5..9a39e716 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/08 流程:你应该这样制定、管理 OKR!.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/08 流程:你应该这样制定、管理 OKR!.md.html
@@ -141,7 +141,7 @@ function hide_canvas() {
其实,大部分组织 OKR 转型,落不了地的最重要原因是组织中缺少建立应用 OKR 的流程机制。流程的意义就是要解决 “人”与“事”是否匹配的问题。
在这里,“事”就是应用 OKR 的基本步骤。团队或组织打造一个基于应用 OKR 的工作流程,并按照这个流程跑起来,这时 OKR 就能嵌入日常工作当中,从而才能为 OKR 落地提供基本保障。那么,这个 OKR 流程要怎么做呢?
OKR 的整体执行节奏
-
+
结合国内大部分组织的绩效管理节奏,我们可以按照季度来整体运营 OKR。
如上图所示,Q2 的整体 OKR 制定和管理节奏就是从 4 月初开始,然后 6 月底结束。如果要能跑顺这个节奏,就需要组织在 3 月中下旬就开始构思校准战略方向,然后 Q2 初,各个部门和团队就可以顺利地进行 Q2 的 OKR 制定和执行。
@@ -162,7 +162,7 @@ function hide_canvas() {
- 归类后,对不同类别的 O 精炼描述,共识出团队的多个业务方向 O。
- 针对每个 O,团队继续共创产出要达成的 KR。
-
+
为了方便你理解,我贴出了当时我们现场的一张照片。可以看到,在工作坊时,我们把京东零售的战略意图明确写出并贴在了看板上,对应着就是图中我标识的“战略方向”,这就是对于方向 O 制定层面上的输入。在集团战略方向的指引下,让团队成员先自己产出对业务条线未来想要做的方向 O,这样就保证了团队中产出 O 的方向是聚焦的,是能支撑战略的。
在团队成员各自产出了 O 后,我们会对每个 O 进行澄清,把属于表达同一个意思的 O 放在一起,也就是我图中的“归类区”。在这个案例中,我们当时一共归类了四个团队业务方向。有了四个业务方向的归类后,继续在每个方向上进行概念化的提炼,提炼的过程就是对团队未来业务方向 O 共识的过程。
有了对于 O 的共识后,接下来就是产出每个 O 的 KR,在 KR 这个部分,需要不断地沟通和确认。这个团队后续在对于 KR 的共识上开了近 3 次的正式沟通会,非正式的沟通次数则更多,直到团队和上下级都对最终 OKR 共识了为止。
@@ -179,7 +179,7 @@ function hide_canvas() {
日:每日站会
每日站会的意义在于跟进从 KR 拆解出来的工作项的进度、问题和风险。
在京东内部,团队会把工作项任务写在便签纸上并贴在物理看板透明出来,物理看板上会有三个命名为 to do | doing | done 纵向泳道(下图),然后站会时团队会围绕着该物理看板,并针对每个工作任务基于以下提问展开每日站会。
-
+
1、昨天的进度是怎样的?
2、今天计划做什么?
@@ -190,7 +190,7 @@ function hide_canvas() {
周:周会/周报
周会/周报的意义在于跟进既定 O 和 KR 的进度和风险,并管理目标的变化情况,看目标是否需要调整,是否有新出现的目标。
我以自己在京东 OKR 的周报为例(周会讨论的内容和周报类似),跟你分享相关的实践。我在周报中,会重点体现这三个维度的内容。
-
+
- 关注每个 O 下面的 KR 进度和风险。 我在周报里会以完成进度的百分比作为进度量化的体现,在这个案例中就是标红部分的 80%;然后再以信心指数(1~10)作为该 KR 所面临问题或者风险的量化体现,也就是说,信心指数越低则说明完成 KR 有风险,需要重点跟进推动问题解决,自信指数越高则无风险,在这个案例中就是标绿部分的 9。
- 说明每个 KR 以周为单位,具体拆分出来的工作任务项完成情况。 这里就对应了上述"每日站会"的内容,可以包括本周完成的工作项,如我周报里的 1)和 2),以及下周待做的工作任务,如我这里的 3),每天的工作紧紧围绕完成 KR 展开,就有力确保了 KR 的实现落地。
@@ -206,7 +206,7 @@ function hide_canvas() {
- 共识新出现的 OKR 推进情况,以及在季末的阶段性 KR。
- 对识别出的重大问题和风险,共识后续专题讨论的时间。
-
+
在经理层和一线团队季中的 OKR 盘点上,我们讨论和盘点内容类似,就像上图我贴出的某团队季中 OKR 评估的会议纪要邮件一样。
“会议目标”关心的是“对 Q3 团队及个人 OKR 进行阶段性 review,主要从更新进展、完成目标是否存在风险以及是否需要对目标调整这几个维度”。
在组织管理中,最怕的就是有人做的事情跟组织目标不相关,我们常把这个问题称为 “人浮于事”。当我们用 OKR 来管理组织目标,然后建立起每天、每周的工作过程都基于 OKR 来展开的机制,这样就能保证组织中所有人都是围绕组织目标在进行工作,不仅确保了战略能高效落地,也能提升组织管理效率。
@@ -215,7 +215,7 @@ function hide_canvas() {
3. 季度末的 OKR 闭环管理
在季度末,我们需要基于 OKR 进行目标阶段性完成情况的闭环管理,这个闭环机制的建立,可以依托对 KR 的评价来完成。
在京东内部,季末我们会对每个 KR 进行评分,评分维度分为了“远超预期:1分”“优秀:0.7分”“一般:0.3分”“无进展:0分”4 种(如下图)。
-
+
具体评分时,包括了自评和他人评价两个部分。
- 自评的方式,就是自己给自己依托 4 个评分维度对每个 KR 进行完成结果的选择。
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/10 激励:如何用 OKR 激活你的团队?.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/10 激励:如何用 OKR 激活你的团队?.md.html
index f010786b..aa3eec34 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/10 激励:如何用 OKR 激活你的团队?.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/10 激励:如何用 OKR 激活你的团队?.md.html
@@ -185,7 +185,7 @@ function hide_canvas() {
OKR 从制定时如何确保支撑战略落地、如何进行流程的过程管理,再到 O 和 KR 的写法,完完全全都是围绕如何高效地制定和实现目标在做。尤其在 O 的选择中,聚焦营收、用户、效率和能力维度,就是在让制定的目标都是以价值导向,因为这些就是组织绩效的构成维度。所以, OKR 所有的理念和相关实践都是为了更好地获得绩效而在努力。
此外,激励在以绩效导向时,还要注重公平。 基于 OKR 的结果评分机制,通过多个相关方的评价,则体现了相对公平性,避免了唯上和一言堂。并且 OKR 系统通晒了所有人的 OKR和 评价结果,则有力保证了绩效评价的公开透明。
然而,我特别担心很多组织内部形成的“没有功劳也有苦劳”的激励思维,兼顾苦劳的激励,就是在看工作量的多少,不仅不是绩效导向,而且会带来不公平,会让真正产生组织绩效的人失望和不满意,这时伤的不仅是绩效,更是组织文化。 这种文化过滤下来的人,尤其擅长“划水”,以及“养老式”的工作,而人才则会选择离开。
-
+
比如,我们在季度打绩效时,不能看一个人天天在加班,上班不迟到也不早退,就给出高绩点。而是要结合 OKR,看他从事的工作所带来的效果,是帮助组织赚了多少钱、提升了多少用户增量、提高了什么组织效率,还是为组织培养了多少个人才,以这个作为绩效的评判标准,才会告别兼顾苦劳的“平均主义”激励。
所以,真正把 OKR 用好后,组织中的管理者参考 OKR 来进行员工的晋升、股权分配、奖金分发、荣誉的获得等,就是完全在以绩效导向来进行激励,通过 OKR 通晒和多方评分的方式,也更好地保证了公平性。只有基于绩效的公平的激励,才能让人真正满意,让人更有持续的工作动力。
小结
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/11 文化:OKR 文化的塑造和沉淀.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/11 文化:OKR 文化的塑造和沉淀.md.html
index 0c2adcb2..7f754640 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/11 文化:OKR 文化的塑造和沉淀.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/11 文化:OKR 文化的塑造和沉淀.md.html
@@ -142,7 +142,7 @@ function hide_canvas() {
当你外出旅游时,最能感受到文化这个词的魅力,你会被代表某个国家的文字、语言或是特殊符号所吸引。你会看到即使同一个国家可能也有着不同的生活习惯,你和当地人交流时,还会发现他们所流传的故事,以及信奉的一些价值观念。我们常把这些统称为文化。
同样,当我们去到企业,会看到刻在墙上的愿景使命价值观,也会注意到不同企业有着各自特定的装饰风格、氛围、语境,还会发现员工的行为举止遵循着特定的仪式和做法,继续和他们深入交流,就会知晓所作所为都有背后特定的理由。我们把这些也统称为企业文化。
那么,文化到底是怎么构成的呢?“企业文化之父”沙因文化的三个层次揭示了文化的本质。
-
+
而要生成新文化,这三个文化层次缺一不可,也就是说,我们想要塑造 OKR 文化,既要抓 OKR 的人工饰物和外显的价值观,也要抓 OKR 所代表的基本假设。
那么,在组织中具体怎么做,才能让 OKR 文化具备这三个层次呢?
塑造 OKR 文化
@@ -167,7 +167,7 @@ function hide_canvas() {
通过调整人的思维来进行文化升级,就让 OKR 文化具备了沙因文化中的基本假设层次,这样的基本假设强调拥抱不确定性、以人为本、增长导向、重视过程,组织才能获得成功。
在组织内部,我们可以采用持续的培训、分享、讲座、思维大赛等方式来传播 OKR 思想。
-
+
(左侧为本人 OKR 培训现场,右侧为 OKR 思维大赛奖杯)
比如,为了让大家更好地理解 OKR 是什么,前期在部门内部导入 OKR 时,我每周二都会给团队中的经理、Leader 做培训,目的就是给大家布道 OKR 的理念及所代表的核心价值观。我还联合 HR 侧举办过“OKR 思维”大赛,通过比赛的形式,拉动大家来学习 OKR 的理念,如此反复向群体中注入 OKR 思维。
只有组织中人的思维能长期基于 OKR 所代表的基本假设来思考和决策,OKR 文化的内核才可以塑造起来。
@@ -184,7 +184,7 @@ function hide_canvas() {
(说明:对于一个局外人,必须要和组织中采用这些管理实践的人沟通后才会理解为什么会这么做,这就是文化的外显价值观和人工饰物的不同之处,文化的人工饰物不需要任何理解,所见、所听、所感都属于人工饰物层。)
管理聚焦的这四个维度的具体 OKR 实践,背后都遵循着 OKR 所代表的拥抱不确定性、以人为本、增长导向、重视过程的理念,是 OKR 理念在管理实践维度的具象化。 比如,制定小目标才有利于我们拥抱不确定性,而员工参与目标制定以及过程中对员工授权(随时更新 OKR)就是以人为本的体现,增长量化指标的定义和考核就是增长导向,物理看板就是对过程的透明和管理。
-
+
(京东内部用于创建 OKR 的系统,员工可以在上面随时更新 OKR)
我们在组织中,管理者的管理方式要能把我上述的这些 OKR 管理实践用起来,也可以生成自己具有特色的管理实践,但是无论是哪种,都必须要能体现 OKR 的基本假设所代表的价值理念。否则导入了 OKR,却保留原始的管理方式,那么 OKR 文化怎么也建立不起来。最严重的就是应用了 OKR,依旧采用 KPI 式的管理(具体内容可以复习 09 课程)。
3. 定规则
@@ -199,7 +199,7 @@ function hide_canvas() {
只有组织中的工作流程、引导群体行为的激励全部基于 OKR 展开和制定,且能刚性执行,OKR 文化才能立起来。
有了基于 OKR 的流程和激励机制的升级保障,就让 OKR 文化具备了沙因文化中的人工饰物层次。这些人工饰物体现在,当你初入一个群体,会看到落在纸面上的关于 OKR 的流程和激励制度,也会看到人们进行 OKR 制定-过程检视&调整-OKR 闭环管理的各种行为,还会听到含有 OKR 的言语(如下图)等。
-
+
(团队按照 OKR 的流程跑起来,就会带来各种制定和讨论 OKR 的行为和言语,外人从这些表面上看到的听到的都是 OKR 文化的人工饰物层)
同时,流程和激励机制会和管理实践交融在一起发挥作用,比如在 OKR 流程的目标设定环节,我们就会采用小目标、优先级的管理实践,在 OKR 的过程检视&调整时采用每日站会结合物理看板的管理实践;基于 OKR 的激励,就会用到通晒、评分、目标合二为一的管理实践。这样,就会让 OKR 文化的人工饰物背后,都能找到与之匹配的 OKR 外显价值观,彼此互相支撑。
然而我常常看到,很多推行 OKR 的组织,仅仅在团队层面基于 OKR 来进行工作的展开,管理者和高层从来不用,组织中从高层开始就无视规则、挑战规则、不遵守规则,团队中的规则执行也就可想而知,这也是国内很多组织 OKR 落地生根不了的重要原因之一。
@@ -209,11 +209,11 @@ function hide_canvas() {
中国文化,源远流长,虽然各个朝代已经跟我们不再是一个时空,但我们依旧可以通过书籍、流传的故事、文物等来了解相应年代的文化特征,而这些就是文化沉淀下来的载体。
沙因文化的三个层次,都可以通过载体来呈现。比如,可以用书籍、文章等来呈现影响人群思维定式的基本假设和外显的价值观,可以用装饰、工具或产品来表达文化的人工饰物。
所以,要沉淀 OKR 文化,也需要这些实实在在、能让人“摸得着”的 OKR 文化载体(如下)。
-
+
OKR 文化有了这些载体,就具备了传播和延续能力,可以持续不断地通过这些具象的文化沉淀去影响更多群体产生应用 OKR 的行为,直至成为一种组织习惯。
而新的组织习惯并非一朝一夕就能练就,还需要通过文化监控的手段来持续塑造 OKR 文化,也就是调思维、做管理、定规则这三步 OKR 文化塑造的方法需要长久地推动下去,对应用 OKR 的群体行为加以保持和巩固,才能以防 OKR 文化走样变形。
我在京东内部,是通过每个季度做评估的方式,进行 OKR 文化的监控和测量。在这里,我把 OKR 文化评估设计的一些问题分享给你,你在后续进行 OKR 文化建设时可以参考使用。
-
+
(评分说明: 0-完全做不到,1-极少做到,2-偶尔做到,3-经常做到,4-高频做到,5-总能做到)
每个测评的问题,按照执行程度,共 0~5 分,问题设计好后,发放给部门所有成员,进行打分。在收集了整个部门的 OKR 文化监控数据后,我会根据评分的高低,来制定后续改进的方案。比如,从得分上,看到团队执行 OKR 的流程上有问题,我就会深入到具体得分较低的团队或部门,进行 OKR 执行流程的复盘,形成具体关于 OKR 流程的改进意见。
在这里,我想提醒你的是,为了保证文化监控和测量的数据真实可靠,你需要亲力亲为,带领文化管理团队下一线,收集一线员工的反馈,进行抽查,分析抽查团队在 OKR 文化三层次的真实执行情况,并把每次文化度量的结果向上汇报,持续不断地得到老板的支持和意见,就可以促进 OKR 文化更好落地。
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/12 变革:OKR 转型难点及解决方案.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/12 变革:OKR 转型难点及解决方案.md.html
index 0fd22fff..d6c6ef2d 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/12 变革:OKR 转型难点及解决方案.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/12 变革:OKR 转型难点及解决方案.md.html
@@ -143,7 +143,7 @@ function hide_canvas() {
那么,我们在 OKR 落地时会遇到哪些难点?又可以通过什么方式来解决这些困难,以促成 OKR 更加顺利的变革呢?
这就是本课时我要帮你解决的问题,我们先来看 OKR 转型的整体推进框架。
OKR 转型的顶层设计
-
+
首先,我们把一个组织抽象成一个系统,在这个系统中,还包括了人以及人与人之间的交互。
那么,当一个变化,如 OKR 转型,进入到这个组织系统,就会对这三个部分带来调整,同时也会面临这三个部分的落地挑战,这些难点在于:
@@ -157,7 +157,7 @@ function hide_canvas() {
在正式组织里做任何事情,我们都是回到目标以及相匹配的职责上。 什么意思呢?
我们很多人都喜欢在豆瓣、微信社群、小红书聚集,这些基于情感、兴趣、爱好组成的社区形式,就是非正式的组织。在非正式组织里,没有所谓的压力和效率,想聊什么就聊,想来就来,想走就走。
但是正式组织则不同,正式组织是为了实现目标而存在,而为了实现目标,继而会设计很多角色来承担实现目标的职责,然后通过分工给到具体的执行人。
-
+
(京东内部 OKR Master 角色的职责定义,供参考)
举个例子。我在京东某部门推动落地 OKR 时,首先定义了一个名为“OKR Master”的角色,并赋予了该角色需要落地 OKR 的职责,然后在该部门以业务条线的维度找承担该职责的人。当时,我一共找到了 20 多个团队的 Leader 来进行分工承接。
接下来,我自己和这 20 多位 OKR Master 成立了OKR 变革小组,通过设立OKR 变革小组的工作目标,来带领部门 200 多人进行 OKR 转型。在这里,我把变革小组的阶段性目标中的关键量化指标分享给你(如下),你可以参考作为 OKR 变革时的核心过程指标。
@@ -168,7 +168,7 @@ function hide_canvas() {
OKR 实现过程管理能力水平,就是 OKR 的文化监控做法《具体参考 11 课时 OKR 文化建设里所讲的如何沉淀 OKR 文化》,这里着重介绍下 OKR 覆盖度和 OKR NPS。
设立 OKR 覆盖度指标的目的,是让 OKR 流程 100% 覆盖所有业务条线,让所有业务条线的工作目标的制定和过程管理都基于 OKR 来展开;此外,覆盖还包括每人都能用 OKR 来制定绩效目标,也就是让每个人基于 O 和 KR 的写法方法论来制定自己的OKR。
-
+
(OKR NPS 调研问卷问题设置及部分结果,供参考)
NPS(Net Promoter Score,净推荐值)原本是用来衡量用户向其他人推荐某个产品或服务可能性程度的指标,该指标可以用来说明用户对于某产品使用的满意度、喜好情况。
OKR 作为一个新的目标管理方法,到底能不能帮助组织解决问题,到底好不好,这真得听听长期在应用这套方法的当事人怎么说才行。就像产品真正解决了用户痛点问题后,用户就会非常喜欢并乐于推荐一样,OKR 若是真的非常有价值,那么运用这套方法的团队就会越来越接受,也会越来越满意,并乐于向其他人推荐这套工作方法。所以,OKR NPS 设立的目的,就是用来收集使用 OKR 这个方法的用户好差反馈,我们可以按照月度或者季度来收集该数据。
@@ -197,11 +197,11 @@ function hide_canvas() {
2020 年,是深圳经济特区成立 40 周年。在深圳特区建立 40 周年庆祝大会上,习主席说到“深圳用 40 年时间走过了国外一些国际化大都市上百年才能走完的历程”,可见深圳特区作为中国经济改革和对外开放的排头兵,获得了举世瞩目的成就。
我们要学习的是,40 年前国家推动改革开放的变革手段,就是用深圳作为试点,进而总结经济特区建设经验,提炼了方法论“实践是检验真理的唯一标准”后,才开始更广泛地推广改革开放,然后开始复制出苏南模式、乡镇企业模式、珠三角模式等等。
所以,在做变革时,为了避免上来就“一刀切”给组织带来的风险,我们需要先做试点,然后总结试点的成功经验,继而再规模化铺开。 同理,在组织中推动 OKR 变革,我们也需要按照这个步骤来实施,而作为 OKR 变革的试点,又该如何选择呢?
-
+
(罗杰斯创新扩模型)
上图的创新扩散模型,由美国埃弗雷特·罗杰斯(E.M.Rogers)提出。从该模型中,我们可以学习到,当一个变化引入一个群体后,群体中的个体拥抱变化的态度上会有明显差别,一般可分为创新者、初期采用者、早期大众、后期大众和落后者五类人。
由于群体中这五类人的存在,对于一个变化,进入一个群体后的扩散速度,就会有先后、快慢之分。 所以,为了降低变革阻力,我们不能上来就找落后者或后期大众来推动 OKR,那将会困难重重,在成本和效率上就会让变革失败。而是应该重点找到并激活整个组织中的创新者和初期采用者,通过他们作为 OKR 变革试点,然后总结试点的成功经验,联合团结他们把这些成功经验推广至其他群体,以此来更快地进行全组织有效的 OKR 变革扩张。
-
+
(个人培训,把京东 OKR 的部门落地成功经验分享铺向其他兄弟部门)
京东从 2019 年年中开始,选择了对 OKR 感兴趣和有热情的京东零售前台 3C 事业部、中台平台生态部等部门进行 OKR 试点。试点推广实践了半年之后,在 2020 年才开始大面积地铺向更多部门。而我部门作为实践 OKR 的排头兵,我就把实际在京东场景下推广 OKR 成功的方法论和经验带给了其他兄弟部门,从而更加规模化地去做京东 OKR 变革。
在这里,我特别想提醒你的是,每个组织的文化不同,制定目标的周期不同,人员水平不同,行业背景不同,所以我们不能完全“照抄”复制其他企业的 OKR 落地方法。一定要基于自己企业试点团队的实际情况,参考本专栏的 OKR 方法论和要素,总结形成适合自己企业落地 OKR 的经验,然后再铺向全组织,这样才是对于你的组织最有效的 OKR 变革扩散。而且,一定要能确保 OKR 在试点团队获得成功,如果连试点都失败,OKR 这种新兴的目标管理方法,想在组织中全面推动几乎是不可能的。
diff --git a/专栏/OKR组织敏捷目标和绩效管理-完/13 加餐 OKR 填写模板及案例.md.html b/专栏/OKR组织敏捷目标和绩效管理-完/13 加餐 OKR 填写模板及案例.md.html
index e20ed526..baf87825 100644
--- a/专栏/OKR组织敏捷目标和绩效管理-完/13 加餐 OKR 填写模板及案例.md.html
+++ b/专栏/OKR组织敏捷目标和绩效管理-完/13 加餐 OKR 填写模板及案例.md.html
@@ -141,7 +141,7 @@ function hide_canvas() {
接下来,我就把在京东内部实践 OKR 的相关模板分享给你,并从应用场景和具体操作流程上帮你熟悉模板的使用。按照模板中的要素来进行 OKR 运行工作的设计和安排,便于我们及时记录 OKR 进程,也能随时查看和修改,还可以作为 OKR 文化的载体进行传播和互相学习。
当然,只有适合自己的才是最好的,你前期在组织内部导入 OKR 时,可以先参考使用我提供给你的模板,如果感觉用得不太顺,可以基于我的模板进行改善和裁剪,找到适合你组织的应用形态。
模板 1:OKR 制定模板
-
+
使用场景:按照组织中绩效制定节奏,在开始制定或是过程中需要新增 OKR 时使用该模板(这里所提供的模板均是基于季度的 OKR 制定节奏来呈现的,下同)。
操作流程:先确定方向 O,再生成每个 O 的 KR,所有 OKR 都需要上下级共识([可参考 08 课时 OKR 制定流程],我列举了详细案例说明)。
注意点:
@@ -153,7 +153,7 @@ function hide_canvas() {
- 每个 KR 需要投入的资源和精力不同,所有需要进行比较,设置权重。
模板 2:OKR 物理看板原型
-
+
使用场景:每天站会时使用该 OKR 物理看板。
操作流程:首先在团队工作现场搭建该物理看板,然后在团队内制定使用该看板的规则,也就是每日固定时间,来看板前基于每日站会三问过每个工作任务的进展。
注意点:
@@ -165,7 +165,7 @@ function hide_canvas() {
- 看板上的工作任务支撑 KR 的完成,都是从 KR 中拆分出来的。
模板 3:OKR 工作周报模板
-
+
使用场景:每周写周报时。
操作流程:O 和 KR 都是制定时生成的,可以直接复制到周报里,但需要更新每个 KR 的进展和信心指数。遇到的问题、阻碍越多,信心指数就标注越低,对应着 KR 下方的问题&风险就要重点描述,反之信心指数越高,说明完成该 KR 没有阻碍,问题&风险可以写无。除此之外,周报中还需要体现完成每个 KR 所做的日常工作,包括本周所做工作(较详细)以及下周工作计划(简写)。
注意点:
@@ -177,7 +177,7 @@ function hide_canvas() {
- 周报要发给与完成 OKR 都有关联的相关方。
模板 4:OKR 季中盘点模板
-
+
使用场景:过程中对 OKR 完成情况盘点时使用该模板,盘点节奏可以每月或者在一个季度的季中来进行。
操作流程:盘点时,个人要更新完 OKR 的整体进度,然后主动约上级时间进行 OKR 盘点,或者上级主动发起,来组织整个团队的过程盘点。
注意点:
@@ -189,7 +189,7 @@ function hide_canvas() {
- 下属要主动叙述绩效完成情况,坦诚交流问题,并努力获得上级支持。
模板 5:OKR 季末闭环评分模板
-
+
使用场景:组织中绩效闭环管理时。
操作流程:一般由 HR 侧发起整个 OKR 绩效闭环评估,团队中个体先进行自评,然后拉起 OKR 实现过程的相关方,对每个 KR 进行评分,O 的得分自动计算(参考[08 课时 OKR 闭环评分中的 O 计算公式])。
注意点:
diff --git a/专栏/Redis 核心原理与实战/01 Redis 是如何执行的.md.html b/专栏/Redis 核心原理与实战/01 Redis 是如何执行的.md.html
index 5e21eee8..97275c92 100644
--- a/专栏/Redis 核心原理与实战/01 Redis 是如何执行的.md.html
+++ b/专栏/Redis 核心原理与实战/01 Redis 是如何执行的.md.html
@@ -224,7 +224,7 @@ function hide_canvas() {
对于任何一门技术,如果你只停留在「会用」的阶段,那就很难有所成就,甚至还有被裁员和找不到工作的风险,我相信能看此篇文章的你,一定是积极上进想有所作为的人,那么借此机会,我们来深入的解一下 Redis 的执行细节。
命令执行流程
一条命令的执行过程有很多细节,但大体可分为:客户端先将用户输入的命令,转化为 Redis 相关的通讯协议,再用 socket 连接的方式将内容发送给服务器端,服务器端在接收到相关内容之后,先将内容转化为具体的执行命令,再判断用户授权信息和其他相关信息,当验证通过之后会执行最终命令,命令执行完之后,会进行相关的信息记录和数据统计,然后再把执行结果发送给客户端,这样一条命令的执行流程就结束了。如果是集群模式的话,主节点还会将命令同步至子节点,下面我们一起来看更加具体的执行流程。
-
+
步骤一:用户输入一条命令
步骤二:客户端先将命令转换成 Redis 协议,然后再通过 socket 连接发送给服务器端
客户端和服务器端是基于 socket 通信的,服务器端在初始化时会创建了一个 socket 监听,用于监测链接客户端的 socket 链接,源码如下:
@@ -251,7 +251,7 @@ function hide_canvas() {
- 将命令转换为 Redis 通讯协议,再将这些协议发送至缓冲区。
步骤三:服务器端接收到命令
-服务器会先去输入缓冲中读取数据,然后判断数据的大小是否超过了系统设置的值(默认是 1GB),如果大于此值就会返回错误信息,并关闭客户端连接。 默认大小如下图所示:
当数据大小验证通过之后,服务器端会对输入缓冲区中的请求命令进行分析,提取命令请求中包含的命令参数,存储在 client 对象(服务器端会为每个链接创建一个 Client 对象)的属性中。
+服务器会先去输入缓冲中读取数据,然后判断数据的大小是否超过了系统设置的值(默认是 1GB),如果大于此值就会返回错误信息,并关闭客户端连接。 默认大小如下图所示:
当数据大小验证通过之后,服务器端会对输入缓冲区中的请求命令进行分析,提取命令请求中包含的命令参数,存储在 client 对象(服务器端会为每个链接创建一个 Client 对象)的属性中。
步骤四:执行前准备
① 判断是否为退出命令,如果是则直接返回;
② 非 null 判断,检查 client 对象是否为 null,如果是返回错误信息;
diff --git a/专栏/Redis 核心原理与实战/02 Redis 快速搭建与使用.md.html b/专栏/Redis 核心原理与实战/02 Redis 快速搭建与使用.md.html
index d5ebc988..ef9544aa 100644
--- a/专栏/Redis 核心原理与实战/02 Redis 快速搭建与使用.md.html
+++ b/专栏/Redis 核心原理与实战/02 Redis 快速搭建与使用.md.html
@@ -230,7 +230,7 @@ function hide_canvas() {
3)高性能
Redis 是一款内存型数据库,因此在性能方面有天生的优势(内存操作比磁盘操作要快很多),并且 Redis 在底层使用了更加高效的算法和数据结构,以最大限度的提高了 Redis 的性能。
4)广泛的编程语言支持
-Redis 客户端有众多的开发者提供了相应的支持,这些客户端可以在 https://redis.io/clients 上找到,支持是编程语言,如下图所示:
可以看出几乎所有的编程语言,都有相应的客户端支持。
+Redis 客户端有众多的开发者提供了相应的支持,这些客户端可以在 https://redis.io/clients 上找到,支持是编程语言,如下图所示:
可以看出几乎所有的编程语言,都有相应的客户端支持。
5)使用简单
Redis 的 API 虽然比较丰富,但操作的方法都非常的简便,并且需要传递的参数也不多,这样开发者就能更快的上手使用,而且 Redis 官方也提供了比较完整的说明文档。
6)活跃性高/版本迭代快
@@ -252,15 +252,15 @@ function hide_canvas() {
Redis 官方提供了 Linux 和 MacOS 服务端安装包,对于 Windows 还有提供正式的支持,之所以不支持 Windows 平台是因为目前 Linux 版本已经很稳定,并且也有大量的用户,如果开发 Windows 版本可能会带来很多的兼容性问题,但 Windows 平台还是有很多种方法可以安装 Redis 的,本文的下半部分会说到,我们先来看 Redis 在 Linux 和 MacOS 平台的安装。
1)源码安装
① 下载源码包
-进入网址:https://redis.io/download 选择需要安装的版本,点击 Download
按钮,如下图所示: 
+进入网址:https://redis.io/download 选择需要安装的版本,点击 Download
按钮,如下图所示: 
② 解压安装包
使用命令:tar zxvf redis-5.0.7.tar.gz
③ 切换到 Redis 目录
使用命令:cd /usr/local/redis-5.0.7/
④ 编译安装
-使用命令:sudo make install 安装完成,如下图所示:
如果没有异常信息输出,向上图所示,则表示 Redis 已经安装成功。
+使用命令:sudo make install 安装完成,如下图所示:
如果没有异常信息输出,向上图所示,则表示 Redis 已经安装成功。
2)Docker 安装
-Docker 的使用前提是必须先有 Docker,如果本机没有安装 Docker,对于 Linux 用户来说,可使用命令 yum -y install docker
在线安装 docker,如果是非 Linux 平台需要在官网下载并安装 Docker Desker,下载地址:https://docs.docker.com/get-started/ 如下图所示:
选择相应的平台,下载安装即可。 有了 Docker 之后,就可以在 Docker 上安装 Redis 服务端了,具体步骤如下:
+Docker 的使用前提是必须先有 Docker,如果本机没有安装 Docker,对于 Linux 用户来说,可使用命令 yum -y install docker
在线安装 docker,如果是非 Linux 平台需要在官网下载并安装 Docker Desker,下载地址:https://docs.docker.com/get-started/ 如下图所示:
选择相应的平台,下载安装即可。 有了 Docker 之后,就可以在 Docker 上安装 Redis 服务端了,具体步骤如下:
① 拉取 Reids 镜像
使用命令:
@@ -278,7 +278,7 @@ function hide_canvas() {
- -p:映射宿主端口到容器端口
- -d:表示后台运行
-执行完成后截图如下:
如图所示,则证明 Redis 已经正常启动了。 如果要查询 Redis 的安装版本,可遵循下图的执行流程,先进入容器,在进入 Redis 的安装目录,执行 redis-server -v
命令,如图如下: 
+执行完成后截图如下:
如图所示,则证明 Redis 已经正常启动了。 如果要查询 Redis 的安装版本,可遵循下图的执行流程,先进入容器,在进入 Redis 的安装目录,执行 redis-server -v
命令,如图如下: 
③ 执行命令
Docker 版的 Redis 命令执行和其他方式安装的 Redis 不太一样,所以这里需要单独讲一下,我们要使用 redis-cli 工具,需要执行以下命令:
@@ -331,14 +331,14 @@ function hide_canvas() {
下面我们就用可执行文件 redis-server
来启动 Redis 服务器,我们在 Redis 的安装目录执行 src/redis-server
命令就可以启动 Redis 服务了,如下图所示:
可以看出 Redis 已经正常启动了,但这种启动方式,会使得 Redis 服务随着控制台的关闭而退出,因为 Redis 服务默认是非后台启动的,我们需要修改配置文件(redis.conf),找到 daemonize no
改为 daemonize yes
,然后重启服务,此时 Redis 就是以后台运行方式启动了,并且不会随着控制台的关闭而退出。
daemonize 配置如下: 
2)使用可视化工具操作 Redis
-Redis 启动之后就可以使用一些客户端工具进行链接和操作,如下图所示:
(注:我们本文使用的是 Redis Desktop Manager 工具链接的,更多 Redis 可视化工具,在本课程的后面有介绍。) 可以看出 Redis 服务器默认有 16 个数据库实例,从 db0 到 db15,但这个数据库实例和传统的关系型数据库实例是不一样的。传统型数据库实例是通过连接字符串配置的,而 Redis 数据库连接字符串只有一个,并不能指定要使用的数据库实例。
+Redis 启动之后就可以使用一些客户端工具进行链接和操作,如下图所示:
(注:我们本文使用的是 Redis Desktop Manager 工具链接的,更多 Redis 可视化工具,在本课程的后面有介绍。) 可以看出 Redis 服务器默认有 16 个数据库实例,从 db0 到 db15,但这个数据库实例和传统的关系型数据库实例是不一样的。传统型数据库实例是通过连接字符串配置的,而 Redis 数据库连接字符串只有一个,并不能指定要使用的数据库实例。
在 Redis 中如果要切换数据库实例,只需要执行 select n
命令即可,例如需要连接 db1 ,使用 select 1
命令选择即可,默认连接的数据库实例是 db0。
小贴士:当使用了 flushall
清空 Redis 数据库时,此数据库下的所有数据都会被清除。
Redis 数据库的实例个数也可以通过配置文件更改,在 redis.conf 中找到 databases 16
,修改后面的数字重启 Redis 服务就会生效。
3)使用 redis-cli 操作 Redis
-redis-cli 是官方自带的客户端链接工具,它可以配合命令行来对 Redis 进行操作,在 Redis 的安装目录使用 src/redis-cli
命令即可链接并操作 Redis,如下图所示: 
+redis-cli 是官方自带的客户端链接工具,它可以配合命令行来对 Redis 进行操作,在 Redis 的安装目录使用 src/redis-cli
命令即可链接并操作 Redis,如下图所示: 
5 小结
本文介绍了 Redis 的特性及其发展历程,以及 Redis 在 Windows、Linux、MacOS 下的安装,其中 Docker 安装方式,对所有平台都是通用的,在 Linux、MacOS 平台下可以在线安装或者使用源码安装,Windows 平台可以使用虚拟机或子系统以及第三方提供的 Redis 安装包进行安装。安装成功之后可以使用 redis-server 来启动 Redis 服务,并使用 redis-cli 来链接和操作 Redis 服务器,redis-server 默认是非后台运行 Redis,需要修改配置 daemonize yes 来设置 Redis 为后台运行模式,这样就可以快速上手使用 Redis 了。
diff --git a/专栏/Redis 核心原理与实战/03 Redis 持久化——RDB.md.html b/专栏/Redis 核心原理与实战/03 Redis 持久化——RDB.md.html
index 1d7678bd..e68cae2b 100644
--- a/专栏/Redis 核心原理与实战/03 Redis 持久化——RDB.md.html
+++ b/专栏/Redis 核心原理与实战/03 Redis 持久化——RDB.md.html
@@ -223,7 +223,7 @@ function hide_canvas() {
Redis 的读写都是在内存中,所以它的性能较高,但在内存中的数据会随着服务器的重启而丢失,为了保证数据不丢失,我们需要将内存中的数据存储到磁盘,以便 Redis 重启时能够从磁盘中恢复原有的数据,而整个过程就叫做 Redis 持久化。
-
Redis 持久化也是 Redis 和 Memcached 的主要区别之一,因为 Memcached 不具备持久化功能。
+
Redis 持久化也是 Redis 和 Memcached 的主要区别之一,因为 Memcached 不具备持久化功能。
1 持久化的几种方式
Redis 持久化拥有以下三种方式:
@@ -240,9 +240,9 @@ function hide_canvas() {
手动触发持久化的操作有两个: save
和 bgsave
,它们主要区别体现在:是否阻塞 Redis 主线程的执行。
① save 命令
在客户端中执行 save
命令,就会触发 Redis 的持久化,但同时也是使 Redis 处于阻塞状态,直到 RDB 持久化完成,才会响应其他客户端发来的命令,所以在生产环境一定要慎用。
-save
命令使用如下:
从图片可以看出,当执行完 save
命令之后,持久化文件 dump.rdb
的修改时间就变了,这就表示 save
成功的触发了 RDB 持久化。 save
命令执行流程,如下图所示: 
+save
命令使用如下:
从图片可以看出,当执行完 save
命令之后,持久化文件 dump.rdb
的修改时间就变了,这就表示 save
成功的触发了 RDB 持久化。 save
命令执行流程,如下图所示: 
② bgsave 命令
-bgsave(background save)既后台保存的意思, 它和 save
命令最大的区别就是 bgsave
会 fork() 一个子进程来执行持久化,整个过程中只有在 fork() 子进程时有短暂的阻塞,当子进程被创建之后,Redis 的主进程就可以响应其他客户端的请求了,相对于整个流程都阻塞的 save
命令来说,显然 bgsave
命令更适合我们使用。 bgsave
命令使用,如下图所示:
bgsave
执行流程,如下图所示: 
+bgsave(background save)既后台保存的意思, 它和 save
命令最大的区别就是 bgsave
会 fork() 一个子进程来执行持久化,整个过程中只有在 fork() 子进程时有短暂的阻塞,当子进程被创建之后,Redis 的主进程就可以响应其他客户端的请求了,相对于整个流程都阻塞的 save
命令来说,显然 bgsave
命令更适合我们使用。 bgsave
命令使用,如下图所示:
bgsave
执行流程,如下图所示: 
2)自动触发
说完了 RDB 的手动触发方式,下面来看如何自动触发 RDB 持久化? RDB 自动持久化主要来源于以下几种情况。
① save m n
@@ -253,7 +253,7 @@ function hide_canvas() {
当 60s 内如果有 10 次 Redis 键值发生改变,就会触发持久化;如果 60s 内 Redis 的键值改变次数少于 10 次,那么 Redis 就会判断 600s 内,Redis 的键值是否至少被修改了一次,如果满足则会触发持久化。
② flushall
-flushall
命令用于清空 Redis 数据库,在生产环境下一定慎用,当 Redis 执行了 flushall
命令之后,则会触发自动持久化,把 RDB 文件清空。 执行结果如下图所示: 
+flushall
命令用于清空 Redis 数据库,在生产环境下一定慎用,当 Redis 执行了 flushall
命令之后,则会触发自动持久化,把 RDB 文件清空。 执行结果如下图所示: 
③ 主从同步触发
在 Redis 主从复制中,当从节点执行全量复制操作时,主节点会执行 bgsave
命令,并将 RDB 文件发送给从节点,该过程会自动触发 Redis 持久化。
4 配置说明
@@ -282,7 +282,7 @@ dir ./
② rdbcompression 参数 它的默认值是 yes
表示开启 RDB 文件压缩,Redis 会采用 LZF 算法进行压缩。如果不想消耗 CPU 性能来进行文件压缩的话,可以设置为关闭此功能,这样的缺点是需要更多的磁盘空间来保存文件。 ③ rdbchecksum 参数 它的默认值为 yes
表示写入文件和读取文件时是否开启 RDB 文件检查,检查是否有无损坏,如果在启动是检查发现损坏,则停止启动。
5 配置查询
-Redis 中可以使用命令查询当前配置参数。查询命令的格式为:config get xxx
,例如,想要获取 RDB 文件的存储名称设置,可以使用 config get dbfilename
,执行效果如下图所示:
查询 RDB 的文件目录,可使用命令 config get dir
,执行效果如下图所示: 
+Redis 中可以使用命令查询当前配置参数。查询命令的格式为:config get xxx
,例如,想要获取 RDB 文件的存储名称设置,可以使用 config get dbfilename
,执行效果如下图所示:
查询 RDB 的文件目录,可使用命令 config get dir
,执行效果如下图所示: 
6 配置设置
设置 RDB 的配置,可以通过以下两种方式:
@@ -294,7 +294,7 @@ dir ./
小贴士:Redis 的配置文件位于 Redis 安装目录的根路径下,默认名称为 redis.conf。
7 RDB 文件恢复
-当 Redis 服务器启动时,如果 Redis 根目录存在 RDB 文件 dump.rdb,Redis 就会自动加载 RDB 文件恢复持久化数据。 如果根目录没有 dump.rdb 文件,请先将 dump.rdb 文件移动到 Redis 的根目录。 验证 RDB 文件是否被加载 Redis 在启动时有日志信息,会显示是否加载了 RDB 文件,我们执行 Redis 启动命令:src/redis-server redis.conf
,如下图所示:
从日志上可以看出, Redis 服务在启动时已经正常加载了 RDB 文件。
+当 Redis 服务器启动时,如果 Redis 根目录存在 RDB 文件 dump.rdb,Redis 就会自动加载 RDB 文件恢复持久化数据。 如果根目录没有 dump.rdb 文件,请先将 dump.rdb 文件移动到 Redis 的根目录。 验证 RDB 文件是否被加载 Redis 在启动时有日志信息,会显示是否加载了 RDB 文件,我们执行 Redis 启动命令:src/redis-server redis.conf
,如下图所示:
从日志上可以看出, Redis 服务在启动时已经正常加载了 RDB 文件。
小贴士:Redis 服务器在载入 RDB 文件期间,会一直处于阻塞状态,直到载入工作完成为止。
@@ -312,7 +312,7 @@ dir ./
- RDB 需要经常 fork() 才能使用子进程将其持久化在磁盘上。如果数据集很大,fork() 可能很耗时,并且如果数据集很大且 CPU 性能不佳,则可能导致 Redis 停止为客户端服务几毫秒甚至一秒钟。
9 禁用持久化
-禁用持久化可以提高 Redis 的执行效率,如果对数据丢失不敏感的情况下,可以在连接客户端的情况下,执行 config set save ""
命令即可禁用 Redis 的持久化,如下图所示: 
+禁用持久化可以提高 Redis 的执行效率,如果对数据丢失不敏感的情况下,可以在连接客户端的情况下,执行 config set save ""
命令即可禁用 Redis 的持久化,如下图所示: 
10 小结
通过本文我们可以得知,RDB 持久化分为手动触发和自动触发两种方式,它的优点是存储文件小,Redis 启动 时恢复数据比较快,缺点是有丢失数据的风险。RDB 文件的恢复也很简单,只需要把 RDB 文件放到 Redis 的根目录,在 Redis 启动时就会自动加载并恢复数据。 最后给大家留一个思考题:如果 Redis 服务器 CPU 占用过高,可能是什么原因导致的?欢迎各位在评论区,写下你们的答案。
参考&鸣谢 https://redis.io/topics/persistence https://blog.csdn.net/qq_36318234/article/details/79994133 https://www.cnblogs.com/ysocean/p/9114268.html https://www.cnblogs.com/wdliu/p/9377278.html
diff --git a/专栏/Redis 核心原理与实战/04 Redis 持久化——AOF.md.html b/专栏/Redis 核心原理与实战/04 Redis 持久化——AOF.md.html
index 36a5d5e2..ae442c9a 100644
--- a/专栏/Redis 核心原理与实战/04 Redis 持久化——AOF.md.html
+++ b/专栏/Redis 核心原理与实战/04 Redis 持久化——AOF.md.html
@@ -232,7 +232,7 @@ function hide_canvas() {
AOF(Append Only File)中文是附加到文件,顾名思义 AOF 可以把 Redis 每个键值对操作都记录到文件(appendonly.aof)中。
2 持久化查询和设置
1)查询 AOF 启动状态
-使用 config get appendonly
命令,如下图所示:
其中,第一行为 AOF 文件的名称,而最后一行表示 AOF 启动的状态,yes 表示已启动,no 表示未启动。
+使用 config get appendonly
命令,如下图所示:
其中,第一行为 AOF 文件的名称,而最后一行表示 AOF 启动的状态,yes 表示已启动,no 表示未启动。
2)开启 AOF 持久化
Redis 默认是关闭 AOF 持久化的,想要开启 AOF 持久化,有以下两种方式:
@@ -241,9 +241,9 @@ function hide_canvas() {
下面分别来看以上两种方式的实现。
① 命令行启动 AOF
-命令行启动 AOF,使用 config set appendonly yes
命令,如下图所示:
命令行启动 AOF 的优缺点:命令行启动优点是无需重启 Redis 服务,缺点是如果 Redis 服务重启,则之前使用命令行设置的配置就会失效。
+命令行启动 AOF,使用 config set appendonly yes
命令,如下图所示:
命令行启动 AOF 的优缺点:命令行启动优点是无需重启 Redis 服务,缺点是如果 Redis 服务重启,则之前使用命令行设置的配置就会失效。
② 配置文件启动 AOF
-Redis 的配置文件在它的根路径下的 redis.conf 文件中,获取 Redis 的根目录可以使用命令 config get dir
获取,如下图所示:
只需要在配置文件中设置 appendonly yes
即可,默认 appendonly no
表示关闭 AOF 持久化。 配置文件启动 AOF 的优缺点:修改配置文件的缺点是每次修改配置文件都要重启 Redis 服务才能生效,优点是无论重启多少次 Redis 服务,配置文件中设置的配置信息都不会失效。
+Redis 的配置文件在它的根路径下的 redis.conf 文件中,获取 Redis 的根目录可以使用命令 config get dir
获取,如下图所示:
只需要在配置文件中设置 appendonly yes
即可,默认 appendonly no
表示关闭 AOF 持久化。 配置文件启动 AOF 的优缺点:修改配置文件的缺点是每次修改配置文件都要重启 Redis 服务才能生效,优点是无论重启多少次 Redis 服务,配置文件中设置的配置信息都不会失效。
3 触发持久化
AOF 持久化开启之后,只要满足一定条件,就会触发 AOF 持久化。AOF 的触发条件分为两种:自动触发和手动触发。
1)自动触发
@@ -260,9 +260,9 @@ appendfsync everysec
小贴士:因为每次写入磁盘都会对 Redis 的性能造成一定的影响,所以要根据用户的实际情况设置相应的策略,一般设置每秒写入一次磁盘的频率就可以满足大部分的使用场景了。
-触发自动持久化的两种情况,如下图所示: 
+触发自动持久化的两种情况,如下图所示: 
2)手动触发
-在客户端执行 bgrewriteaof
命令就可以手动触发 AOF 持久化,如下图所示:
可以看出执行完 bgrewriteaof
命令之后,AOF 持久化就会被触发。
+在客户端执行 bgrewriteaof
命令就可以手动触发 AOF 持久化,如下图所示:
可以看出执行完 bgrewriteaof
命令之后,AOF 持久化就会被触发。
4 AOF 文件重写
AOF 是通过记录 Redis 的执行命令来持久化(保存)数据的,所以随着时间的流逝 AOF 文件会越来越多,这样不仅增加了服务器的存储压力,也会造成 Redis 重启速度变慢,为了解决这个问题 Redis 提供了 AOF 重写的功能。
1)什么是 AOF 重写?
@@ -273,7 +273,7 @@ appendfsync everysec
- auto-aof-rewrite-min-size:允许 AOF 重写的最小文件容量,默认是 64mb 。
- auto-aof-rewrite-percentage:AOF 文件重写的大小比例,默认值是 100,表示 100%,也就是只有当前 AOF 文件,比最后一次(上次)的 AOF 文件大一倍时,才会启动 AOF 文件重写。
-查询 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 的值,可使用 config get xxx
命令,如下图所示: 
+查询 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 的值,可使用 config get xxx
命令,如下图所示: 
小贴士:只有同时满足 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 设置的条件,才会触发 AOF 文件重写。
@@ -302,7 +302,7 @@ aof-load-truncated yes
其中比较重要的是 appendfsync 参数,用它来设置 AOF 的持久化策略,可以选择按时间间隔或者操作次数来存储 AOF 文件,这个参数的三个值在文章开头有说明,这里就不再复述了。
6 数据恢复
1)正常数据恢复
-正常情况下,只要开启了 AOF 持久化,并且提供了正常的 appendonly.aof 文件,在 Redis 启动时就会自定加载 AOF 文件并启动,执行如下图所示:
其中 DB loaded from append only file......
表示 Redis 服务器在启动时,先去加载了 AOF 持久化文件。
+正常情况下,只要开启了 AOF 持久化,并且提供了正常的 appendonly.aof 文件,在 Redis 启动时就会自定加载 AOF 文件并启动,执行如下图所示:
其中 DB loaded from append only file......
表示 Redis 服务器在启动时,先去加载了 AOF 持久化文件。
小贴士:默认情况下 appendonly.aof 文件保存在 Redis 的根目录下。
@@ -312,7 +312,7 @@ aof-load-truncated yes
- 如果只开启了 RDB 持久化,Redis 启动时只会加载 RDB 文件(dump.rdb),进行数据恢复;
- 如果同时开启了 RDB 和 AOF 持久化,Redis 启动时只会加载 AOF 文件(appendonly.aof),进行数据恢复。
-在 AOF 开启的情况下,即使 AOF 文件不存在,只有 RDB 文件,也不会加载 RDB 文件。 AOF 和 RDB 的加载流程如下图所示: 
+在 AOF 开启的情况下,即使 AOF 文件不存在,只有 RDB 文件,也不会加载 RDB 文件。 AOF 和 RDB 的加载流程如下图所示: 
2)简单异常数据恢复
在 AOF 写入文件时如果服务器崩溃,或者是 AOF 存储已满的情况下,AOF 的最后一条命令可能被截断,这就是异常的 AOF 文件。
在 AOF 文件异常的情况下,如果为修改 Redis 的配置文件,也就是使用 aof-load-truncated
等于 yes
的配置,Redis 在启动时会忽略最后一条命令,并顺利启动 Redis,执行结果如下:
diff --git a/专栏/Redis 核心原理与实战/05 Redis 持久化——混合持久化.md.html b/专栏/Redis 核心原理与实战/05 Redis 持久化——混合持久化.md.html
index e5f6ca0d..0d32c00c 100644
--- a/专栏/Redis 核心原理与实战/05 Redis 持久化——混合持久化.md.html
+++ b/专栏/Redis 核心原理与实战/05 Redis 持久化——混合持久化.md.html
@@ -222,24 +222,24 @@ function hide_canvas() {
05 Redis 持久化——混合持久化
RDB 和 AOF 持久化各有利弊,RDB 可能会导致一定时间内的数据丢失,而 AOF 由于文件较大则会影响 Redis 的启动速度,为了能同时使用 RDB 和 AOF 各种的优点,Redis 4.0 之后新增了混合持久化的方式。
在开启混合持久化的情况下,AOF 重写时会把 Redis 的持久化数据,以 RDB 的格式写入到 AOF 文件的开头,之后的数据再以 AOF 的格式化追加的文件的末尾。
-混合持久化的数据存储结构如下图所示: 
+混合持久化的数据存储结构如下图所示: 
1 开启混合持久化
-查询是否开启混合持久化可以使用 config get aof-use-rdb-preamble
命令,执行结果如下图所示:
其中 yes 表示已经开启混合持久化,no 表示关闭,Redis 5.0 默认值为 yes。 如果是其他版本的 Redis 首先需要检查一下,是否已经开启了混合持久化,如果关闭的情况下,可以通过以下两种方式开启:
+查询是否开启混合持久化可以使用 config get aof-use-rdb-preamble
命令,执行结果如下图所示:
其中 yes 表示已经开启混合持久化,no 表示关闭,Redis 5.0 默认值为 yes。 如果是其他版本的 Redis 首先需要检查一下,是否已经开启了混合持久化,如果关闭的情况下,可以通过以下两种方式开启:
- 通过命令行开启
- 通过修改 Redis 配置文件开启
1)通过命令行开启
-使用命令 config set aof-use-rdb-preamble yes
执行结果如下图所示: 
+使用命令 config set aof-use-rdb-preamble yes
执行结果如下图所示: 
小贴士:命令行设置配置的缺点是重启 Redis 服务之后,设置的配置就会失效。
2)通过修改 Redis 配置文件开启
-在 Redis 的根路径下找到 redis.conf 文件,把配置文件中的 aof-use-rdb-preamble no
改为 aof-use-rdb-preamble yes
如下图所示: 
+在 Redis 的根路径下找到 redis.conf 文件,把配置文件中的 aof-use-rdb-preamble no
改为 aof-use-rdb-preamble yes
如下图所示: 
2 实例运行
-当在混合持久化关闭的情况下,使用 bgrewriteaof
触发 AOF 文件重写之后,查看 appendonly.aof 文件的持久化日志,如下图所示:
可以看出,当混合持久化关闭的情况下 AOF 持久化文件存储的为标准的 AOF 格式的文件。 当混合持久化开启的模式下,使用 bgrewriteaof
命令触发 AOF 文件重写,得到 appendonly.aof 的文件内容如下图所示:
可以看出 appendonly.aof 文件存储的内容是 REDIS
开头的 RDB 格式的内容,并非为 AOF 格式的日志。
+当在混合持久化关闭的情况下,使用 bgrewriteaof
触发 AOF 文件重写之后,查看 appendonly.aof 文件的持久化日志,如下图所示:
可以看出,当混合持久化关闭的情况下 AOF 持久化文件存储的为标准的 AOF 格式的文件。 当混合持久化开启的模式下,使用 bgrewriteaof
命令触发 AOF 文件重写,得到 appendonly.aof 的文件内容如下图所示:
可以看出 appendonly.aof 文件存储的内容是 REDIS
开头的 RDB 格式的内容,并非为 AOF 格式的日志。
3 数据恢复和源码解析
-混合持久化的数据恢复和 AOF 持久化过程是一样的,只需要把 appendonly.aof 放到 Redis 的根目录,在 Redis 启动时,只要开启了 AOF 持久化,Redis 就会自动加载并恢复数据。 Redis 启动信息如下图所示:
可以看出 Redis 在服务器初始化的时候加载了 AOF 文件的内容。
+混合持久化的数据恢复和 AOF 持久化过程是一样的,只需要把 appendonly.aof 放到 Redis 的根目录,在 Redis 启动时,只要开启了 AOF 持久化,Redis 就会自动加载并恢复数据。 Redis 启动信息如下图所示:
可以看出 Redis 在服务器初始化的时候加载了 AOF 文件的内容。
1)混合持久化的加载流程
混合持久化的加载流程如下:
@@ -248,7 +248,7 @@ function hide_canvas() {
- 判断 AOF 文件开头是 RDB 的格式, 先加载 RDB 内容再加载剩余的 AOF 内容;
- 判断 AOF 文件开头不是 RDB 的格式,直接以 AOF 格式加载整个文件。
-AOF 加载流程图如下图所示:
2)源码解析
+AOF 加载流程图如下图所示:
2)源码解析
Redis 判断 AOF 文件的开头是否是 RDB 格式的,是通过关键字 REDIS
判断的,RDB 文件的开头一定是 REDIS
关键字开头的,判断源码在 Redis 的 src/aof.c 中,核心代码如下所示:
char sig[5]; /* "REDIS" */
if (fread(sig,1,5,fp) != 5 || memcmp(sig,"REDIS",5) != 0) {
diff --git a/专栏/Redis 核心原理与实战/06 字符串使用与内部实现原理.md.html b/专栏/Redis 核心原理与实战/06 字符串使用与内部实现原理.md.html
index 7e2f3be8..b106d185 100644
--- a/专栏/Redis 核心原理与实战/06 字符串使用与内部实现原理.md.html
+++ b/专栏/Redis 核心原理与实战/06 字符串使用与内部实现原理.md.html
@@ -525,7 +525,7 @@ OK
- *ptr:对象指针用于指向具体的内容,占用 64 bits(8 字节)。
redisObject 总共占用 0.5 bytes + 0.5 bytes + 3 bytes + 4 bytes + 8 bytes = 16 bytes(字节)。
-了解了 redisObject 之后,我们再来看 SDS 自身的数据结构,从 SDS 的源码可以看出,SDS 的存储类型一共有 5 种:SDSTYPE5、SDSTYPE8、SDSTYPE16、SDSTYPE32、SDSTYPE64,在这些类型中最小的存储类型为 SDSTYPE5,但 SDSTYPE5 类型会默认转成 SDSTYPE8,以下源码可以证明,如下图所示: 
+了解了 redisObject 之后,我们再来看 SDS 自身的数据结构,从 SDS 的源码可以看出,SDS 的存储类型一共有 5 种:SDSTYPE5、SDSTYPE8、SDSTYPE16、SDSTYPE32、SDSTYPE64,在这些类型中最小的存储类型为 SDSTYPE5,但 SDSTYPE5 类型会默认转成 SDSTYPE8,以下源码可以证明,如下图所示: 
那我们直接来看 SDSTYPE8 的源码:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 1 byte
diff --git a/专栏/Redis 核心原理与实战/08 字典使用与内部实现原理.md.html b/专栏/Redis 核心原理与实战/08 字典使用与内部实现原理.md.html
index 578a507a..2c31a4c9 100644
--- a/专栏/Redis 核心原理与实战/08 字典使用与内部实现原理.md.html
+++ b/专栏/Redis 核心原理与实战/08 字典使用与内部实现原理.md.html
@@ -306,7 +306,7 @@ public class HashExample {
} dictEntry;
字典类型的数据结构,如下图所示:
-
+
通常情况下字典类型会使用数组的方式来存储相关的数据,但发生哈希冲突时才会使用链表的结构来存储数据。
4.哈希冲突
字典类型的存储流程是先将键值进行 Hash 计算,得到存储键值对应的数组索引,再根据数组索引进行数据存储,但在小概率事件下可能会出完全不相同的键值进行 Hash 计算之后,得到相同的 Hash 值,这种情况我们称之为哈希冲突。
@@ -317,7 +317,7 @@ public class HashExample {
- 判断元素和查找的键值是否相等,相等则成功返回数据,否则需要查看 next 指针是否还有对应其他元素,如果没有,则返回 null,如果有的话,重复此步骤。
键值查询流程,如下图所示:
-
+
5.渐进式rehash
Redis 为了保证应用的高性能运行,提供了一个重要的机制——渐进式 rehash。 渐进式 rehash 是用来保证字典缩放效率的,也就是说在字典进行扩容或者缩容是会采取渐进式 rehash 的机制。
1)扩容
diff --git a/专栏/Redis 核心原理与实战/17 Redis 键值过期操作.md.html b/专栏/Redis 核心原理与实战/17 Redis 键值过期操作.md.html
index 93a5b4cb..2ff029c9 100644
--- a/专栏/Redis 核心原理与实战/17 Redis 键值过期操作.md.html
+++ b/专栏/Redis 核心原理与实战/17 Redis 键值过期操作.md.html
@@ -431,7 +431,7 @@ if (server.masterhost == NULL && expiretime != -1 && expiretime
字符串中可以在添加键值的同时设置过期时间,并可以使用 persist 命令移除过期时间。同时我们也知道了过期键在 RDB 写入和 AOF 重写时都不会被记录。
过期键在主从模式下,从库对过期键的处理要完全依靠主库,主库删除过期键之后会发送 del 命令给所有的从库。
本文的知识点,如下图所示:
-
+
diff --git a/专栏/Redis 核心原理与实战/27 消息队列终极解决方案——Stream(下).md.html b/专栏/Redis 核心原理与实战/27 消息队列终极解决方案——Stream(下).md.html
index 0020b3e6..5e10b093 100644
--- a/专栏/Redis 核心原理与实战/27 消息队列终极解决方案——Stream(下).md.html
+++ b/专栏/Redis 核心原理与实战/27 消息队列终极解决方案——Stream(下).md.html
@@ -288,7 +288,7 @@ OK
xack key group-key ID [ID ...]
消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 ack 确认消息已经被消费完成,整个流程的执行如下图所示:
-
+
查询未确认的消费队列
127.0.0.1:6379> xpending mq group1
1) (integer) 1 #未确认(ack)的消息数量为 1 条
diff --git a/专栏/Redis 核心原理与实战/28 实战:分布式锁详解与代码.md.html b/专栏/Redis 核心原理与实战/28 实战:分布式锁详解与代码.md.html
index 1c93c7c6..b362e046 100644
--- a/专栏/Redis 核心原理与实战/28 实战:分布式锁详解与代码.md.html
+++ b/专栏/Redis 核心原理与实战/28 实战:分布式锁详解与代码.md.html
@@ -228,7 +228,7 @@ function hide_canvas() {
上面说的锁指的是程序级别的锁,例如 Java 语言中的 synchronized 和 ReentrantLock 在单应用中使用不会有任何问题,但如果放到分布式环境下就不适用了,这个时候我们就要使用分布式锁。
分布式锁比较好理解就是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。
分布式锁示意图,如下所示:
-
+
怎么实现分布式锁?
分布式锁比较常见的实现方式有三种:
@@ -265,7 +265,7 @@ function hide_canvas() {
带参数的 Set
因为 setnx 和 expire 存在原子性的问题,所以之后出现了很多类库用于解决此问题的,这样就增加了使用的成本,意味着你不但要添加 Redis 本身的客户端,并且为了解决 setnx 分布式锁的问题,还需要额外第三方类库。
然而,这个问题到 Redis 2.6.12 时得到了解决,因为这个版本可以使用 set 并设置超时和非空判定等参数了。
-
+
这样我们就可以使用 set 命令来设置分布式锁,并设置超时时间了,而且 set 命令可以保证原子性,实现命令如下所示:
127.0.0.1:6379> set lock true ex 30 nx
OK #创建锁成功
diff --git a/专栏/Redis 核心原理与实战/29 实战:布隆过滤器安装与使用及原理分析.md.html b/专栏/Redis 核心原理与实战/29 实战:布隆过滤器安装与使用及原理分析.md.html
index 9b96b28c..6c13297a 100644
--- a/专栏/Redis 核心原理与实战/29 实战:布隆过滤器安装与使用及原理分析.md.html
+++ b/专栏/Redis 核心原理与实战/29 实战:布隆过滤器安装与使用及原理分析.md.html
@@ -241,7 +241,7 @@ docker run -p6379:6379 redislabs/rebloom # 运行容器
启动验证
服务启动之后,我们需要判断布隆过滤器是否正常开启,此时我们只需使用 redis-cli 连接到服务端,输入 bf.add 看有没有命令提示,就可以判断是否正常启动了,如下图所示:
-
+
如果有命令提示则表名 Redis 服务器已经开启了布隆过滤器。
布隆过滤器的使用
布隆过滤器的命令不是很多,主要包含以下几个:
@@ -404,7 +404,7 @@ public class BloomExample {
当进行元素判断时,查询此元素的几个哈希位置上的值是否为 1,如果全部为 1,则表示此值存在,如果有一个值为 0,则表示不存在。因为此位置是通过 hash 计算得来的,所以即使这个位置是 1,并不能确定是那个元素把它标识为 1 的,因此布隆过滤器查询此值存在时,此值不一定存在,但查询此值不存在时,此值一定不存在。
并且当位数组存储值比较稀疏的时候,查询的准确率越高,而当位数组存储的值越来越多时,误差也会增大。
位数组和 key 之间的关系,如下图所示:
-
+
布隆过滤器使用场景
它的经典使用场景包括以下几个:
diff --git a/专栏/Redis 核心原理与实战/32 实战:RediSearch 高性能的全文搜索引擎.md.html b/专栏/Redis 核心原理与实战/32 实战:RediSearch 高性能的全文搜索引擎.md.html
index 96119e48..a449e654 100644
--- a/专栏/Redis 核心原理与实战/32 实战:RediSearch 高性能的全文搜索引擎.md.html
+++ b/专栏/Redis 核心原理与实战/32 实战:RediSearch 高性能的全文搜索引擎.md.html
@@ -394,7 +394,7 @@ OK
其中“num_docs”表示存储的数据数量。
代码实战
RediSearch 支持的客户端有以下这些。
-
+
本文我们使用 JRediSearch 来实现全文搜索的功能,首先在 pom.xml 添加 JRediSearch 引用:
<!-- https://mvnrepository.com/artifact/com.redislabs/jredisearch -->
<dependency>
diff --git a/专栏/Redis 核心原理与实战/37 实战:Redis哨兵模式(上).md.html b/专栏/Redis 核心原理与实战/37 实战:Redis哨兵模式(上).md.html
index 7064e75c..a09610f2 100644
--- a/专栏/Redis 核心原理与实战/37 实战:Redis哨兵模式(上).md.html
+++ b/专栏/Redis 核心原理与实战/37 实战:Redis哨兵模式(上).md.html
@@ -229,7 +229,7 @@ function hide_canvas() {
Redis Sentinel 搭建
Redis 官方提供了 Redis Sentinel 的功能,它的运行程序保存在 src 目录下,如图所示:
-
+
我们需要使用命令 ./src/redis-sentinel sentinel.conf
来启动 Sentinel,可以看出我们在启动它时必须设置一个 sentinel.conf 文件,这个配置文件中必须包含监听的主节点信息:
sentinel monitor master-name ip port quorum
@@ -249,7 +249,7 @@ function hide_canvas() {
sentinel auth-pass mymaster pwd654321
当我们配置好 sentinel.conf 并执行启动命令 ./src/redis-sentinel sentinel.conf
之后,Redis Sentinel 就会被启动,如下图所示:
-
+
从上图可以看出 Sentinel 只需配置监听主节点的信息,它会自动监听对应的从节点。
启动 Sentinel 集群
上面我们演示了单个 Sentinel 的启动,但生产环境我们不会只启动一台 Sentinel,因为如果启动一台 Sentinel 假如它不幸宕机的话,就不能提供自动容灾的服务了,不符合我们高可用的宗旨,所以我们会在不同的物理机上启动多个 Sentinel 来组成 Sentinel 集群,来保证 Redis 服务的高可用。
diff --git a/专栏/Redis 核心原理与实战/39 实战:Redis 集群模式(上).md.html b/专栏/Redis 核心原理与实战/39 实战:Redis 集群模式(上).md.html
index 673721af..512697d7 100644
--- a/专栏/Redis 核心原理与实战/39 实战:Redis 集群模式(上).md.html
+++ b/专栏/Redis 核心原理与实战/39 实战:Redis 集群模式(上).md.html
@@ -224,12 +224,12 @@ function hide_canvas() {
Redis 将所有的数据分为 16384 个 slots(槽),每个节点负责其中的一部分槽位,当有 Redis 客户端连接集群时,会得到一份集群的槽位配置信息,这样它就可以直接把请求命令发送给对应的节点进行处理。
Redis Cluster 是无代理模式去中心化的运行模式,客户端发送的绝大数命令会直接交给相关节点执行,这样大部分情况请求命令无需转发,或仅转发一次的情况下就能完成请求与响应,所以集群单个节点的性能与单机 Redis 服务器的性能是非常接近的,因此在理论情况下,当水平扩展一倍的主节点就相当于请求处理的性能也提高了一倍,所以 Redis Cluster 的性能是非常高的。
Redis Cluster 架构图如下所示:
-
+
搭建 Redis Cluster
Redis Cluster 的搭建方式有两种,一种是使用 Redis 源码中提供的 create-cluster 工具快速的搭建 Redis 集群环境,另一种是配置文件的方式手动创建 Redis 集群环境。
快速搭建 Redis Cluster
create-cluster 工具在 utils/create-cluster 目录下,如下图所示:
-
+
使用命令 ./create-cluster start
就可以急速创建一个 Redis 集群,执行如下:
$ ./create-cluster start # 创建集群
Starting 30001
@@ -319,8 +319,8 @@ $ ./create-cluster clean # 清理集群
手动搭建 Redis Cluster
由于 create-cluster 本身的限制,在实际生产环境中我们需要使用手动添加配置的方式搭建 Redis 集群,为此我们先要把 Redis 安装包复制到 node1 到 node6 文件中,因为我们要安装 6 个节点,3 主 3 从,如下图所示:
-
-
+
+
接下来我们进行配置并启动 Redis 集群。
1. 设置配置文件
我们需要修改每个节点内的 redis.conf 文件,设置 cluster-enabled yes
表示开启集群模式,并且修改各自的端口,我们继续使用 30001 到 30006,通过 port 3000X
设置。
@@ -480,7 +480,7 @@ f5958382af41d4e1f5b0217c1413fe19f390b55f 127.0.0.1:
+
执行命令如下:
127.0.0.1:30001> cluster forget df0190853a53d8e078205d0e2fa56046f20362a7
OK
diff --git a/专栏/Redis 核心原理与实战/41 案例:Redis 问题汇总和相关解决方案.md.html b/专栏/Redis 核心原理与实战/41 案例:Redis 问题汇总和相关解决方案.md.html
index f8d3f131..09492afd 100644
--- a/专栏/Redis 核心原理与实战/41 案例:Redis 问题汇总和相关解决方案.md.html
+++ b/专栏/Redis 核心原理与实战/41 案例:Redis 问题汇总和相关解决方案.md.html
@@ -268,7 +268,7 @@ jedis.setex(cacheKey, exTime+random.nextInt(1000) , value);
二级缓存指的是除了 Redis 本身的缓存,再设置一层缓存,当 Redis 失效之后,先去查询二级缓存。
例如可以设置一个本地缓存,在 Redis 缓存失效的时候先去查询本地缓存而非查询数据库。
加入二级缓存之后程序执行流程,如下图所示:
-
+
缓存穿透
缓存穿透是指查询数据库和缓存都无数据,因为数据库查询无数据,出于容错考虑,不会将结果保存到缓存中,因此每次请求都会去查询数据库,这种情况就叫做缓存穿透。
缓存穿透执行流程如下图所示:
@@ -282,7 +282,7 @@ jedis.setex(cacheKey, exTime+random.nextInt(1000) , value);
缓存击穿
缓存击穿指的是某个热点缓存,在某一时刻恰好失效了,然后此时刚好有大量的并发请求,此时这些请求将会给数据库造成巨大的压力,这种情况就叫做缓存击穿。
缓存击穿的执行流程如下图所示:
-
+
它的解决方案有以下 2 个。
加锁排队
此处理方式和缓存雪崩加锁排队的方法类似,都是在查询数据库时加锁排队,缓冲操作请求以此来减少服务器的运行压力。
@@ -292,7 +292,7 @@ jedis.setex(cacheKey, exTime+random.nextInt(1000) , value);
首先来说,缓存预热并不是一个问题,而是使用缓存时的一个优化方案,它可以提高前台用户的使用体验。
缓存预热指的是在系统启动的时候,先把查询结果预存到缓存中,以便用户后面查询时可以直接从缓存中读取,以节约用户的等待时间。
缓存预热的执行流程,如下图所示:
-
+
缓存预热的实现思路有以下三种:
- 把需要缓存的方法写在系统初始化的方法中,这样系统在启动的时候就会自动的加载数据并缓存数据;
diff --git a/专栏/Redis 核心原理与实战/43 加餐:Redis 的可视化管理工具.md.html b/专栏/Redis 核心原理与实战/43 加餐:Redis 的可视化管理工具.md.html
index 66930f0f..4d5cc69c 100644
--- a/专栏/Redis 核心原理与实战/43 加餐:Redis 的可视化管理工具.md.html
+++ b/专栏/Redis 核心原理与实战/43 加餐:Redis 的可视化管理工具.md.html
@@ -227,29 +227,29 @@ function hide_canvas() {
支持平台:Windows。
项目地址:https://github.com/caoxinyu/RedisClient
使用截图:
-
+
Redis Desktop Manager
是否收费:收费。
项目介绍:一款基于 Qt5 的跨平台 Redis 桌面管理软件。
支持平台:Windows、macOS、Linux。
项目地址:https://github.com/uglide/RedisDesktopManager
使用截图:
-
+
RedisStudio
是否收费:免费。
项目介绍:一款 C++ 编写的 Redis 管理工具,比较老,好久没更新了。
支持平台:Windows。
项目地址:https://github.com/cinience/RedisStudio
使用截图:
-
+
AnotherRedisDesktopManager
是否收费:免费。
项目介绍:一款基于 Node.js 开发的 Redis 桌面管理器,它的特点就是相对来说比较稳定,在数据量比较大的时候不会崩溃。
支持平台:Windows、macOS、Linux。
项目地址:https://github.com/qishibo/AnotherRedisDesktopManager
使用截图:
-
-
+
+
其他 Redis 可视化工具
- Medis:https://github.com/luin/medis
diff --git a/专栏/Serverless 技术公开课(完)/01 架构的演进.md.html b/专栏/Serverless 技术公开课(完)/01 架构的演进.md.html
index 86f85ae9..7556c6d8 100644
--- a/专栏/Serverless 技术公开课(完)/01 架构的演进.md.html
+++ b/专栏/Serverless 技术公开课(完)/01 架构的演进.md.html
@@ -180,11 +180,11 @@ function hide_canvas() {
01 架构的演进
传统单体应用架构
十多年前主流的应用架构都是单体应用,部署形式就是一台服务器加一个数据库,在这种架构下,运维人员会小心翼翼地维护这台服务器,以保证服务的可用性。
-
▲ 单体架构
+
▲ 单体架构
单体应用架构面临的问题
随着业务的增长,这种最简单的单体应用架构很快就面临两个问题。首先,这里只有一台服务器,如果这台服务器出现故障,例如硬件损坏,那么整个服务就会不可用;其次,业务量变大之后,一台服务器的资源很快会无法承载所有流量。
解决这两个问题最直接的方法就是在流量入口加一个负载均衡器,使单体应用同时部署到多台服务器上,这样服务器的单点问题就解决了,与此同时,这个单体应用也具备了水平伸缩的能力。
-
▲ 单体架构(水平伸缩)
+
▲ 单体架构(水平伸缩)
微服务架构
1. 微服务架构演进出通用服务
随着业务的进一步增长,更多的研发人员加入到团队中,共同在单体应用上开发特性。由于单体应用内的代码没有明确的物理边界,大家很快就会遇到各种冲突,需要人工协调,以及大量的 conflict merge 操作,研发效率直线下降。
@@ -192,7 +192,7 @@ function hide_canvas() {
2. 微服务架构给运维带来挑战
应用从单体架构演进到微服务架构,从物理的角度看,分布式就成了默认选项,这时应用架构师就不得不面对分布式带来的新挑战。在这个过程中,大家都会开始使用一些分布式服务和框架,例如缓存服务 Redis,配置服务 ACM,状态协调服务 ZooKeeper,消息服务 Kafka,还有通讯框架如 GRPC 或者 DUBBO,以及分布式追踪系统等。
除分布式环境带来的挑战之外,微服务架构给运维也带来新挑战。研发人员原来只需要运维一个应用,现在可能需要运维十个甚至更多的应用,这意味着安全 patch 升级、容量评估、故障诊断等事务的工作量呈现成倍增长,这时,应用分发标准、生命周期标准、观测标准、自动化弹性等能力的重要性也更加凸显。
-
▲ 微服务架构
+
▲ 微服务架构
云原生
1. 基于云产品架构
一个架构是否是云原生,就看这个架构是否是长在云上的,这是对“云原生”的简单理解。这个“长在云上”不是简单地说用云的 IaaS 层服务,比如简单的 ECS、OSS 这些基本的计算存储;而是应该理解成有没有使用云上的分布式服务,比如 Redis、Kafka 等,这些才是直接影响到业务架构的服务。微服务架构下,分布式服务是必要的,原来大家都是自己研发这样的服务,或者基于开源版本自己运维这样的服务。而到了云原生时代,业务则可以直接使用云服务。
diff --git a/专栏/Serverless 技术公开课(完)/03 常见 Serverless 架构模式.md.html b/专栏/Serverless 技术公开课(完)/03 常见 Serverless 架构模式.md.html
index 77e94722..134b1faa 100644
--- a/专栏/Serverless 技术公开课(完)/03 常见 Serverless 架构模式.md.html
+++ b/专栏/Serverless 技术公开课(完)/03 常见 Serverless 架构模式.md.html
@@ -203,7 +203,7 @@ function hide_canvas() {
- 去云厂商上买台云服务器运行站点,为了解决高可用的问题又买了负载均衡服务和多个服务器;
- 采用静态站点方式,直接由对象存储服务(如 OSS)支持,并使用 CDN 回源 OSS。
-
+
这三种方式由云下到云上,由管理服务器到无需管理服务器,即 Serverless。这一系列的转变给使用者带来了什么变化呢?前两种方案需要预算,需要扩展,需要实现高可用,需要自行监控等,这些都不是马老师当年想要的,他只想去展示信息,让世界了解中国,这是他的业务逻辑。Serverless 正是这样一种理念,最大化地让人去专注业务逻辑。第三种方式就是采用了 Serverless 架构去构建一个静态站点,它有其它方案无法比拟的优势,比如:
- 可运维性:无需管理服务器,比如操作系统的安全补丁升级、故障升级、高可用性,这些云服务(OSS,CDN)都帮着做了;
@@ -225,7 +225,7 @@ function hide_canvas() {
- 是否可以通过函数来实现轻量级微服务,依赖函数计算提供的负载均衡、自动伸缩、按需付费、日志采集、系统监控等能力;
- 基于 Spring Cloud、Dubbo、HSF 等实现的微服务应用是否需要自己购置服务器部署应用,管理服务发现,负载均衡,弹性伸缩,熔断,系统监控等,还是可以将这些工作交给诸如 Serverless 应用引擎服务。
-
+
上图右侧的架构引入了 API 网关、函数计算或者 Serverless 应用引擎来实现计算层,将大量的工作交给了云服务完成,让用户最大程度上专注实现业务逻辑。其中系统内部多个微服务的交互如下图所示,通过提供一个商品聚合服务,将内部的多个微服务统一呈现给外部。这里的微服务可以通过 SAE 或者函数实现。

这样的架构还可以继续扩展,比如如何支持不同客户端的访问,如上图右侧所示。现实中这种需求是常见的,不同的客户端需要的信息可能是不同的,手机可以根据位置信息做相关推荐。如何让手机客户端和不同浏览器都能受益于 Serverless 架构呢?这又牵扯出了另一个词——Backend for fronted(BFF),即为前端定做的后端,这受到了前端开发工程师的推崇,Serverless 技术让这个架构广泛流行,因为前端工程师可以从业务角度出发直接编写 BFF,而无需管理服务器相关的令前端工程师更加头疼的事情。更多实践可以参见:基于函数计算的 BFF 架构。
@@ -233,7 +233,7 @@ function hide_canvas() {
前面提到的动态页面生成是同步请求完成的,还有一类常见场景,其中请求处理通常需要较长时间或者较多资源,比如用户评论中的图片和视频内容管理,涉及到如何上传图片和处理图片(缩略图、水印、审核等)及视频,以适应不同客户端的播放需求。

如何对上传多媒体文件实时处理呢?这个场景的技术架构大体经历了以下演变:
-
+
- 基于服务器的单体架构:多媒体文件被上传到服务器,由服务器处理,对多媒体的显示请求也由服务器完成;
- 基于服务器的微服务架构:多媒体文件被上传到服务器,服务器处理转存到 OSS,然后将文件地址加入消息队列,由另一组服务器处理文件,将处理结果保存到 OSS,对多媒体的显示请求由 OSS 和 CDN 完成;
@@ -262,7 +262,7 @@ function hide_canvas() {
事件触发能力是 FaaS 服务的一个重要特性,这种 Pub-Sub 事件驱动模式不是一个新的概念,但是在 Serverless 流行之前,事件的生产者、消费者以及中间的连接枢纽都是用户负责的,就像前面架构演进中的第二个架构。
Serverless 让生产者发送事件,维护连接枢纽都从用户职责中省略了,而只需关注消费者的逻辑,这就是 Serverless 的价值所在。
函数计算服务还集成其它云服务事件源,让你更方便地在业务中使用一些常见的模式,如 Pub/Sub、事件流模式、Event Sourcing 模式。关于更多的函数组合模式可以参见:函数组合的 N 种方式。
-
+
场景 4: 服务编排
前面的商品页面虽然复杂,但是所有的操作都是读操作,聚合服务 API 是无状态、同步的。我们来看一下电商中的一个核心场景——订单流程。

diff --git a/专栏/Serverless 技术公开课(完)/04 Serverless 技术选型.md.html b/专栏/Serverless 技术公开课(完)/04 Serverless 技术选型.md.html
index edade470..d403812c 100644
--- a/专栏/Serverless 技术公开课(完)/04 Serverless 技术选型.md.html
+++ b/专栏/Serverless 技术公开课(完)/04 Serverless 技术选型.md.html
@@ -181,7 +181,7 @@ function hide_canvas() {
今天来讲,在 Serverless 这个大领域中,不只有函数计算这一种产品形态和应用类型,而是面向不同的用户群体和使用习惯,都有其各自适用的 Serverless 产品。例如面向函数的函数计算、面向应用的 Serverless 应用引擎、面向容器的 Serverless Kubernetes,用户可以根据自己的使用习惯、使用场景或者应用类型,去选择使用什么样的 Serverless 产品。下面通过本文给大家介绍一下,阿里云都有哪些可供大家选择的 Serverless 产品。
Serverless 产品及分层
众所周知,最早提出 Serverless 的是 AWS,其在 Serverless 领域的旗舰产品是 function compute。同样阿里云也有函数计算的产品,帮助用户构建 Serverless 函数。但 Serverless 不仅仅是函数,如下图所示,其实用户会期望在应用、容器等层面也能够享受到 Serverless 的好处,包括按量付费、极致弹性等,这样也更符合用户原有的使用习惯。
-
+
在上图中,大家能够看到,阿里云针对函数、应用和容器都推出了对应的 Serverless 产品,用户可以针对自己的使用场景选择不同的产品。
函数计算
1. 函数计算介绍
@@ -189,7 +189,7 @@ function hide_canvas() {
上图展示了函数计算的使用方式。从用户角度,他需要做的只是编码,然后把代码上传到函数计算中。这个时候还不会产生费用,只有到被调用的时候才有费用。调用的方式可以是产品提供的 API/SDK,也可以通过一些事件源,比如阿里云的 OSS 的事件。比如用户往 OSS 里的某一个 bucket 上传了一个文件,希望这个文件被自动处理;比如上传一个 zip 包,希望能够自动解压到另外一个 bucket,这都是很典型的函数场景。
另外,函数计算能够提供非常好的弹性能力,最终的费用是根据时长和内存数进行计费的,如果调用量小的话,只会有很少的费用。并且它在语言方面也非常丰富,常用的 nodejs、php、python、java 都直接支持。同时提供自定义的运行环境,可以支持任意的可执行的语言。
2. 函数计算典型场景
-
+
从使用场景来说,主要有三类:
- Web 应用。可以是各种语言写的,这种可以使用 Serverless 框架新编写的程序,也可以是已有的应用。比如小程序后端、或者发布到 API 市场的 API 后端应用等。
@@ -197,7 +197,7 @@ function hide_canvas() {
- 事件驱动型的应用。比如通过其他阿里云产品驱动的场景、Web Hook、定时任务等。函数计算已经与很多产品进行了打通,比如对象存储、表格存储、定时器、CDN、日志服务、云监控等,可以非常快速地组装出一些业务逻辑。
3. 函数计算核心竞争力
-
+
函数计算对客户的一个最大的价值,就是能够让用户只关注自己的业务逻辑开发,完全不需要管理运维,诸如计算资源、网络设置等都不需要关心。在隔离性上提供 vm 级别的隔离,保证用户在运行时的数据安全、运行时安全等;在可用性方面默认提供 3az 的高可用架构,保证客户默认就是高可用的最佳实践架构;在弹性方面,可以做到毫秒级的弹性效率,满足客户突发的流量冲击;在计费方面也非常灵活,真正按照用户的请求情况进行收费,也支持对 long run 的应用更友好的预付费模式。
Serverless 应用引擎
1. SAE 概述
@@ -205,7 +205,7 @@ function hide_canvas() {
SAE 是业内首款面向应用的 Serverless Paas 平台。这个产品以面向应用的视角,帮助用户在不做任何修改的前提下把存量应用上到云端。在资源层,用户不再需要自己管理和运维机器及集群,只需要关注自己应用所需要使用的规格以及实例数,不再需要关心底层是虚机还是容器。
SAE 从资源层面提供计算资源、弹性、隔离性等能力,让用户只需要关注自己的应用。在应用层,SAE 提供了监控、日志、微服务治理等能力,帮助用户解决应用可观测性和治理需求。同时提供网络配置、流量控制能力,提供了和 CICD 良好的集成,用户可以使用已有 CICD 部署到 SAE,比如 jenkins、云效等,可以说覆盖了应用上云的完整场景。
2. SAE 典型场景
-
+
SAE 有几个典型的使用场景,一个是存量业务上云,特别是微服务、java 应用,同时也支持其他语言的单体应用,都能够通过 SAE 这个平台运行在阿里云上,并且不需要做任何代码的修改。在行业方面,SAE 特别适合有比较大的流量波动的在线业务,比如电商大促、在线教育等行业的场景。另外 SAE 作为应用平台也可以被上层的行业 Saas 所集成,帮助用户更快地构建行业 Saas。
3. SAE 特性

@@ -218,7 +218,7 @@ function hide_canvas() {
之后有了 K8s 来帮大家解决容器编排的问题。这种标准化的方式确实大大提高了大家的生产力。用户通过使用 deployment、service 等标准的 K8s 的方式进行编排,并进行部署。但 K8s 的运维和管理还是相对比较复杂的,技能要求比较高,用户需要运维 ECS 以及通过 ECS 构建出来的 K8s。另外一个痛点时 K8s 集群里的 ECS 是需要预先购买的,如果客户的负载有比较大的波动,就会出现比较多的资源浪费。虽然技术上也有解决方案,比如 worker node 的弹性,但这对于初级用户来说,还是有比较高的复杂度。
那有没有一种方案可以让用户既能享受到 K8s 提供的容器编排能力,又能够不需要关心 ECS 和 K8s 的运维、管理和弹性问题呢?这就是 Serverless K8s 的方案。对应到阿里云的产品就是 ASK。在 ASK 的方案里,用户创建一个 ASK 集群,但不需要指定任何 ECS 节点,然后通过标准的 K8s 容器编排、deployment 等部署镜像。ASK 会根据用户的负载需求,自动在底层资源池构建需要的 POD 并进行弹性伸缩,用户不再需要关心容量规划、ECS 机器运维、资源限制等 LaaS 层的问题,非常便利。
2. ASK 典型场景
-
+
那 ASK 主要用在哪些场景里呢?首先可以用来跑在线业务,部署模式灵活,可以是 deployment、helm chart 等所有的 K8s 原生模式,特别是能够很好地应对突发流量,极致弹性,可以在 30 秒完成 500 个容器实例的弹性。这样的弹性效率,可以很好地支撑大数据计算类的任务,比如 Spark、Presto 等,也可以在需要的时候即时获取资源,支撑 10000 以上 Pod 的规格,有效降低客户成本。
另外一个非常适合的场景是用来构建随需启动的构建任务,比如在 ASK 中运行 jenkins、Gitlab-Runner 等。在有构建任务的时候,即时启动。没有任务的时候 0 消费,成本做到最低。这里只是列出了一些例子的场景,实际上基于 ASK 的这个特性,用户可以运行很多 K8s 原生的需要极致弹性的工作负载。
3. ASK 特性
diff --git a/专栏/Serverless 技术公开课(完)/05 函数计算简介.md.html b/专栏/Serverless 技术公开课(完)/05 函数计算简介.md.html
index e3140fc2..8bd3557c 100644
--- a/专栏/Serverless 技术公开课(完)/05 函数计算简介.md.html
+++ b/专栏/Serverless 技术公开课(完)/05 函数计算简介.md.html
@@ -208,12 +208,12 @@ function hide_canvas() {
05 函数计算简介
什么是函数计算
大家都了解,Serverless 并不是没有服务器,而是开发者不再需要关心服务器。下图是一个应用从开发到上线的对比图:
-
+
在传统 Serverful 架构下,部署一个应用需要购买服务器,部署操作系统,搭建开发环境,编写代码,构建应用,部署应用,配置负载均衡机制,搭建日志分析与监控系统,应用上线后,继续监控应用的运行情况。而在 Serverless 架构下,开发者只需要关注应用的开发构建和部署,无需关心服务器相关操作与运维,在函数计算架构下,开发者只需要编写业务代码并监控业务运行情况。这将开发者从繁重的运维工作中解放出来,把精力投入到更有意义的业务开发上。
-
+
上图展示了函数计算的使用方式。从用户角度,他需要做的只是编码,然后把代码上传到函数计算中。上传代码就意味着应用部署。当有高并发请求涌入时,开发者也无需手动扩容,函数计算会根据请求量毫秒级自动扩容,弹性可靠地运行任务,并内置日志查询、性能监控、报警等功能帮助开发者发现问题并定位问题。
函数计算核心优势
-
+
敏捷开发
- 使用函数计算时,用户只需聚焦于业务逻辑的开发,编写最重要的 “核心代码”;
@@ -237,7 +237,7 @@ function hide_canvas() {
- 预付费模型根据业务负载估算提前预购计算力,单价更低,组合使用后付费和预付费方式将有效降低成本。
函数计算使用场景
-
+
从使用场景来说,主要有三类:
- Web 应用: 可以是各种语言写的,这种可以是使用 Serverless 框架新编写的程序,也可以是已有的应用。比如可能是小程序后端,也可能是 Web API;
diff --git a/专栏/Serverless 技术公开课(完)/06 函数计算是如何工作的?.md.html b/专栏/Serverless 技术公开课(完)/06 函数计算是如何工作的?.md.html
index 2f8698a7..fb0f726e 100644
--- a/专栏/Serverless 技术公开课(完)/06 函数计算是如何工作的?.md.html
+++ b/专栏/Serverless 技术公开课(完)/06 函数计算是如何工作的?.md.html
@@ -179,25 +179,25 @@ function hide_canvas() {
06 函数计算是如何工作的?
函数计算调用链路
-
+
上图展示了函数计算完整的请求和调用链路。函数计算是事件驱动的无服务器应用,事件驱动是说可以通过事件源自动触发函数执行,比如当有对象上传至 OSS 中时,自动触发函数,对新上传的图片进行处理。函数计算支持丰富的事件源类型,包括日志服务、对象存储、表格存储、消息服务、API 网关、CDN 等。
除了事件触发外,也可以直接通过 API/SDK 直接调用函数。调用可以分为同步调用与异步调用,当请求到达函数计算后,函数计算会为请求分配执行环境,如果是异步调用,函数计算会将请求事件存入队列中,等待消费。
函数计算调用方式
-
+
同步调用的特性是,客户端期待服务端立即返回计算结果。请求到达函数计算时,会立即分配执行环境执行函数。
以 API 网关为例,API 网关同步触发函数计算,客户端会一直等待服务端的执行结果,如果执行过程中遇到错误, 函数计算会将错误直接返回,而不会对错误进行重试。这种情况下,需要客户端添加重试机制来做错误处理。
-
+
异步调用的特性是,客户端不急于立即知道函数结果,函数计算将请求丢入队列中即可返回成功,而不会等待到函数调用结束。
函数计算会逐渐消费队列中的请求,分配执行环境,执行函数。如果执行过程中遇到错误,函数计算会对错误的请求进行重试,对函数错误重试三次,系统错误会以指数退避方式无限重试,直至成功。
异步调用适用于数据的处理,比如 OSS 触发器触发函数处理音视频,日志触发器触发函数清洗日志,都是对延时不敏感,又需要尽可能保证任务执行成功的场景。如果用户需要了解失败的请求并对请求做自定义处理,可以使用 Destination 功能。
函数计算执行过程
函数计算是 Serverless 的,这不是说无服务器,而是开发者无需关心服务器,函数计算会为开发者分配实例执行函数。
-
+
如上图所示,当函数第一次被调用的时候,函数计算需要动态调度实例、下载代码、解压代码、启动实例,得到一个可执行函数的代码环境。然后才开始在系统分配的实例中真正地执行用户的初始化函数,执行函数业务逻辑。这个调度实例启动实例的过程,就是系统的冷启动过程。
函数逻辑执行结束后,不会立即释放掉实例,会等一段时间,如果在这段时间内有新的调用,会复用这个实例,比如上图中的 Request 2,由于执行环境已经分配好了,Request 2 可以直接使用,所以 Request 2 就不会遇到冷启动。
Request 2 执行结束后,等待一段时间,如果这段时间没有新的请求分配到这个实例上,那系统会回收实例,释放执行环境。此实例释放后,新的请求 Request 3 来到函数计算,需要重新调度实例、下载代码、解压代码,启动实例,又会遇到冷启动。
所以,为了减小冷启动带来的影响,要尽可能避免冷启动,降低冷启动带来的延时。
-
+
使用预留实例可以完全避免冷启动,预留实例是在用户预留后就分配实例,准备执行环境;请求结束后系统也不会自动回收实例。
预留实例不由系统自动分配与回收,由用户控制实例的生命周期,可以长驻不销毁,这将彻底消除实例冷启动带来的延时毛刺,提供极致性能,也为在线应用迁移至函数计算扫清障碍。
如果业务场景不适合使用预留实例,那就要设法降低冷启动的延时,比如降低代码包大小,可以降低下载代码包、解压代码包的时间。Initializer 函数是实例的初始化函数,Initializer 在同一实例中执行且只执行一次,所以可以将一些耗时的公共逻辑放到 Initializer 中,比如在 NAS 中加载依赖、建立连接等等。另外要尽量保持请求连续稳定,避免突发的流量,由于系统已启动的实例不足以支撑大量的突发流量,就会带来不可避免的冷启动。
diff --git a/专栏/Serverless 技术公开课(完)/08 函数计算的开发与配置.md.html b/专栏/Serverless 技术公开课(完)/08 函数计算的开发与配置.md.html
index 2855b9f8..6eec7dad 100644
--- a/专栏/Serverless 技术公开课(完)/08 函数计算的开发与配置.md.html
+++ b/专栏/Serverless 技术公开课(完)/08 函数计算的开发与配置.md.html
@@ -181,12 +181,12 @@ function hide_canvas() {
导读: 在本篇文章中“基本概念”部分主要对函数计算最核心的概念进行详细介绍,包括服务、函数、触发器、版本、别名以及相关的配置;“开发流程”部分介绍了基于函数计算开发的完整开发部署流程。
基本概念
1. 服务
-
+
服务是函数计算资源管理的单位,同一个服务下有很多函数,这些函数共享服务的网络配置、权限配置、存储配置、日志配置。
服务可以对应成一个“应用”,这个应用由很多函数共同组成,这些函数具有相同的访问权限、网络配置,日志也记录到相同的 logstore。这些函数本身的配置可以各不相同,比如同一服务下有的函数内存是3G,有的函数内存是 512M,有些函数用 Python 写,有些函数用 Node.js 写。
当然,如果应用比较复杂,同一个应用也可以对应多个服务,这里没有强绑定关系。
1)服务配置
-
+
接下来我们介绍服务的几个核心配置:
日志配置: 开发者的代码在函数计算平台运行,如何查看函数运行产生的日志呢?在Server 化的开发方式中,日志都打到统一的文件中,通过 Logstash/Fluentd 这种日志收集工具收集到 ElasticSearch 中,并通过 Kibana 这种可视化工具查看日志及指标。但是在函数计算中,运行代码的机器由函数计算动态分配,开发者无法自己收集日志,函数计算需要帮助开发者投递日志。日志配置就是起到这个作用,配置 LogConfig 设置日志服务的 Project 和 Logstore,函数计算会将函数运行中产生的日志投递到开发者的 Logstore 里。
但是为了成功投递日志,单单配置 Logtore 还不够,函数计算是没有权限向开发者的 Logstore 投递日志的,需要用户授予函数计算向指定的 Logstore 写数据的权限,有了这个授权后,函数计算就可以名正言顺地向开发者的 Logstore 投递日志了。
@@ -197,7 +197,7 @@ function hide_canvas() {
2. 函数
“函数计算”中函数可谓是核心概念,函数是管理、运行的基本单元,一个函数通常由一系列配置与可运行代码包组成。
1)函数配置
-
+
函数的配置如上图所示:
- Runtime 是函数运行时的环境类型: 函数计算目前支持 Node.js/Python/Java/C#/PHP 等开发环境,同时也支持 Custom Runtime 自定义运行时;
@@ -209,16 +209,16 @@ function hide_canvas() {
- InitializerTimeout 就是 Initializer 函数的最大运行时间。
3. 触发器
-
+
往期课程中介绍了函数计算支持的丰富的事件源类型,在事件驱动的计算模型中,事件源是事件的生产者,函数是事件的处理者,触发器提供了一种集中、统一的方式来管理不同的事件源。当事件发生时,如果满足触发器定义的规则,事件源会自动调用触发器所对应的函数。
典型的使用场景包括对上传至 OSS 中的对象进行处理,比如图像处理、音视频转码、OSS zip 包解压,以及对 SLS 中的日志进行清洗、处理、转存,在指定时间触发函数执行等等。
4. 版本&别名
-
+
上文介绍了服务、函数、触发器,开发者就可以基于函数计算将应用搭建起来了,但又有一个新问题:开发者有了新需求需要更新代码,如何保证线上应用不受影响,平滑迭代上线呢? 为了解决这个问题,函数计算引入了版本和别名。
版本相当于服务的快照,包括服务的配置、服务内的函数代码及函数配置。当您开发和测试完成后,就发布一个版本,版本单调递增,版本发布后,已发布的版本不能更改,您可以继续在 Latest 版本上开发测试,不会影响已发布的版本。调用函数时,只需要指定版本就可以调用指定版本的函数。
那新问题又来了,版本名称是函数计算指定的单调递增的,每次发布版本,都会有一个新的版本,那每次发完版本后,客户端还要改代码执行最新的版本吗? 为了解决这个问题呢,我们引入了别名,别名就是指向特定服务版本的指针,发布后,只需要将别名指向发布的版本,再次发布后,再切换别名指向最新的版本,客户端只需要指定别名就可以保证调用线上最新的代码。同时别名支持灰度发布的功能,即有 10% 的流量指向最新版本,90% 理论指向老版本。回滚也非常简单,只需要将别名指向之前的版本即可快速完成回滚。
开发流程
-
+
如上图所示,开发者首先创建服务,设置日志、权限等配置,然后创建函数,在当前版本(Latest 版本)下编写代码开发函数,测试通过后发布版本,第一次发布的版本为版本 1,创建别名 prod 指向版本 1,就可以对外提供服务了。
客户端调用函数的日志会记录在开发者配置的 Logstore 里,函数计算提供完备的监控图表,应用上线后,开发者可以通过监控图表和日志查看应用的健康状况。
当开发者有新需求时,继续在 Latest 版本更改代码开发函数,测试通过后发布版本,这次发布的版本为版本 2,切换别名流量 10% 到版本 2,即可实现应用的灰度发布,观察一段时间没有问题,就可以切换 100% 的流量到版本 2 了。
diff --git a/专栏/Serverless 技术公开课(完)/10 自动化 CI&CD 与灰度发布.md.html b/专栏/Serverless 技术公开课(完)/10 自动化 CI&CD 与灰度发布.md.html
index 9a248162..4f603706 100644
--- a/专栏/Serverless 技术公开课(完)/10 自动化 CI&CD 与灰度发布.md.html
+++ b/专栏/Serverless 技术公开课(完)/10 自动化 CI&CD 与灰度发布.md.html
@@ -180,7 +180,7 @@ function hide_canvas() {
10 自动化 CI&CD 与灰度发布
环境管理和自动化部署
当我们从传统开发迁移到 Serverless 下,对于环境和部署的管理思路也会有所不同。当用户转到 Serverless ,可以轻松地提供更多的环境,而这个好处常被忽略。
-
+
当我们开发项目时,通常需要一个生产环境,然后需要预发环境,还有一些测试环境。但通常每个环境都需要消耗资源和成本,以保持服务在线。而大多数时候非生产环境上的访问量非常少,为此付出大量的成本很不划算。
但是,在 Serverless 架构中,我们可以为每位开发人员提供一个准生产环境。做 CI/CD 的时候,可以为每个功能分支创建独立的演示环境。
当团队成员在开发功能或者修复 bug 时,想要预览新功能,就可以立即部署,而不需要在自己机器上模拟或者找其他同事协调测试环境的使用时间。
@@ -189,7 +189,7 @@ function hide_canvas() {
后面的课程我们会了解到,借助于函数计算平台提供的 Funcraft 工具,开发人员可以用从前做不到的方式在准生产环境中轻松部署和测试代码。
灰度发布
由于 Serverless 提供的弹性机制,没有访问量的时候能自动缩容到零,极大地节约了部署的多环境的成本。然而在同一套环境内的多个不同的版本也可以受益于这套机制。
-
+
传统应用虽然也支持在一个环境中并存多个版本,但相比于 Serverless 更加困难。首先每个版本都需要相对独立的运行环境,会消耗更多的资源。其次需要解决多个版本之间流量的分配问题。
在 FaaS 上这些问题已经被版本和别名机制完美的解决。由于没有流量就不消耗计算资源,所以发布一个版本的成本极低,每次发布都可以形成一个版本。然后通过别名进行版本的切换和流量分配。
基于 FaaS 的这套抽象,让灰度发布和 A/B 测试变得非常的简单。不再需要像 K8s 那样复杂的基础设置,开发者也能轻松地享受到平滑升级和快速验证的高级特性。
diff --git a/专栏/Serverless 技术公开课(完)/11 函数计算的可观测性.md.html b/专栏/Serverless 技术公开课(完)/11 函数计算的可观测性.md.html
index 3ebc68a0..ccb1f7bb 100644
--- a/专栏/Serverless 技术公开课(完)/11 函数计算的可观测性.md.html
+++ b/专栏/Serverless 技术公开课(完)/11 函数计算的可观测性.md.html
@@ -181,7 +181,7 @@ function hide_canvas() {
概述
可观测性是什么呢?维基百科中这样说:可观测性是通过外部表现判断系统内部状态的衡量方式。
在应用开发中,可观测性帮助我们判断系统内部的健康状况。在系统出现问题时,帮助我们定位问题、排查问题、分析问题;在系统平稳运行时,帮助我们评估风险,预测可能出现的问题。评估风险类似于天气预报,预测到明天下雨,那出门就要带伞。在函数计算的应用开发中,如果观察到函数的并发度持续升高,很可能是业务推广团队的努力工作导致业务规模迅速扩张,为了避免达到并发度限制触发流控,开发者就需要提前提升并发度。
-
+
可观测性包括三个方面:Logging、Metrics、Tracing
- Logging 是日志,日志记录了函数运行中的关键信息,这些信息是离散且具体的,结合错误日志与函数代码可以迅速定位问题。
@@ -211,7 +211,7 @@ function hide_canvas() {
- **配置日志大盘:**日志大盘不仅可以看到函数计算提供的监控指标,而且可以与开发者日志关联,生成自定义的监控指标。
3. 链路追踪
-
(请求在各个链路的延时瀑布图)
+
(请求在各个链路的延时瀑布图)
链路追踪是分布式系统排查问题的重要一环,链路追踪可以分析分布式系统中请求在各个链路的时延。有以下几种情况:
- 函数计算作为整个链路中的一环,可以看到请求在函数计算上的时延,时延包括系统启动的时间和请求真正的执行时间,帮助用户分析性能瓶颈。
diff --git a/专栏/Serverless 技术公开课(完)/13 典型案例 3:十分钟搭建弹性可扩展的 Web API.md.html b/专栏/Serverless 技术公开课(完)/13 典型案例 3:十分钟搭建弹性可扩展的 Web API.md.html
index b725576d..3e5b2612 100644
--- a/专栏/Serverless 技术公开课(完)/13 典型案例 3:十分钟搭建弹性可扩展的 Web API.md.html
+++ b/专栏/Serverless 技术公开课(完)/13 典型案例 3:十分钟搭建弹性可扩展的 Web API.md.html
@@ -197,14 +197,14 @@ function hide_canvas() {
开发流程
1. 登录函数计算控制台,创建应用
-
+
可以通过两种方式来创建应用,如果是已有的 Web 项目,可以选择上图中的第一种方式:“常见 Web 应用”;对于新项目则推荐使用第二种方式:“基于模板创建应用”。我们这里使用模板方式,选择基于 Python 的 Web 应用。
模板可以当做应用脚手架,选择适合的模板,可以自动完成相关依赖资源的创建,如角色、OSS、域名网关等,降低开发成本。
2. 新建函数
-
+
在应用下,创建函数,我们是开发 WebAPI,所以选择“HTTP”函数,这种函数会将指定的 http 请求作为触发器,来调度对应函数的执行。
函数新建好之后,是个返回 helloWorld 的 demo,我们在此基础上来开发我们的业务逻辑。
-
+
首先介绍下上图代码中的 handler 函数,这个函数是入口函数,http 触发器接收到调用后会通过这个入口来启动整个函数。函数有两个入参,environ 和 start_response:
- environ
@@ -215,11 +215,11 @@ function hide_canvas() {
该参数主要用于生成 http 请求的 response。
3. 配置触发器,绑定域名
-
+
在新建函数时会自动创建一个 http 触发器,这个触发器的路径是“aliyun.com”的一个测试路径,只能用于测试,真实的应用需要通过自定义域名将真实域名与函数绑定,这样访问指定域名时,对应函数就会被触发执行。
4. 日志与监控
在每个函数编辑页面,日志和监控服务,函数的每次执行都会生成唯一的 requestId,日志中通过 requestId 进行查询,看到本次函数执行的所有日志。
-
+
操作演示
点击链接即可观看演示视频:https://developer.aliyun.com/lesson202418999
diff --git a/专栏/Serverless 技术公开课(完)/14 Serverless Kubernetes 容器服务介绍.md.html b/专栏/Serverless 技术公开课(完)/14 Serverless Kubernetes 容器服务介绍.md.html
index d2118b30..f8ac17cb 100644
--- a/专栏/Serverless 技术公开课(完)/14 Serverless Kubernetes 容器服务介绍.md.html
+++ b/专栏/Serverless 技术公开课(完)/14 Serverless Kubernetes 容器服务介绍.md.html
@@ -181,19 +181,19 @@ function hide_canvas() {
**导读:**Serverless Kubernetes 是以容器和 kubernetes 为基础的 Serverless 服务,它提供了一种简单易用、极致弹性、最优成本和按需付费的 Kubernetes 容器服务,其无需节点管理和运维,无需容量规划,让用户更关注应用而非基础设施的管理。我们可以把 Serverless Kubernetes 简称为 ASK。
Serverless 容器
首先从 Serverless 开始讲起,相信我们已经熟知 Serverless 理念的核心价值,其中包括无需管理底层基础设施,无需关心底层 OS 的升级和维护,因为 Serverless 可以让我们更加关注应用开发本身,所以应用的上线时间更短。同时 Serverless 架构是天然可扩展的,当业务用户数或者资源消耗增多时,我们只需要创建更多的应用资源即可,其背后的扩展性是用户自己购买机器所无法比拟的。Serverless 应用一般是按需创建,用户无需为闲置的资源付费,可以降低整体的计算成本。
-
+
以上所讲的几种都是 Serverless 理念的核心价值,也是 Serverless 容器与其他 Sererless 形态的相同之处。然而,Serverless 容器和其他 Serverless 形态的差异,在于它是基于容器的交付形态。
-
+
基于容器意味着通用性和标准性,我们可以 Build once and Run anywhere,容器不受语言和库的限制,无论任何应用都可以制作成容器镜像,然后以容器的部署方式启动。基于容器的标准化,开源社区以 Kubernetes 为中心构建了丰富的云原生 Cloud Native 生态,极大地丰富了 Serverless 容器的周边应用框架和工具,比如可以非常方便地部署 Helm Chart 包。基于容器和 Kubernetes 标准化,我们可以轻松地在不同环境中(线上线下环境),甚至在不同云厂商之间进行应用迁移,而不用担心厂商锁定。这些都是 Serverless 容器的核心价值。
-
(Serverless 容器产品 Landscape)
+
(Serverless 容器产品 Landscape)
当下各大云厂商都推出了自己的 Serverless 容器服务,上图为 Gartner 评估机构整理的 Serverless 容器产品 Landscape,其中阿里云有 Serverless Kubernetes ASK 和 ECI;AWS 有 Fargate,基于 Fargate 有 EKS on Fargate 和 ECS on Fargate 两种形态;Azure 有 ACI。另外 Gartner 也预测,到 2023 年,将有 70% 的 AI 应用以容器和 Serverless 方式运行。
ASK/ACK on ECI 容器服务
下面介绍阿里云 Serverless 容器产品家族:ECI、 ACK on ECI 和 Serverless Kubernetes。
1. ECI
-
+
ECI 全称是“Elastic Container Instance 弹性容器实例”,是 Serverless 容器的底层基础设施,实现了容器镜像的启动。ECI 让容器成为和 ECS 一样的云上一等公民。ECI 底层运行环境基于安全容器技术进行强隔离,每个 ECI 拥有一个独立的 OS 运行环境,保证运行时的安全性。ECI 支持 0.25c 到 64c 的 CPU 规格,也支持 GPU,按需创建按秒收费。和 ECS 一样,ECI 也支持 Spot 可抢占式实例,在一些场景中可以节省 90% 的成本。ECI 实例的启动时间目前约是 10s 左右,然后开始拉取容器镜像。我们也提供了镜像快照功能,每次容器启动时从快照中读取镜像,省去远端拉取的时间。值得强调的是,ECI 和 ECS 共用一个弹性计算资源池,这意味着 ECI 的弹性供给能力可以得到最大程度的充分保障,让 ECI 用户享受弹性计算资源池的规模化红利。
ECI 只可以做到单个容器实例的创建,而没有编排的能力,比如让应用多副本扩容,让 SLB 和 Ingress 接入 Pod 流量,所以我们需要在编排系统 Kubernetes 中使用 ECI,我们提供了两种在 Kubernetes 中使用 ECI 的方式。一个是 ACK on ECI,另外一个是 ASK。
-
+
在与 Kubernetes 编排系统的集成中,我们以 Pod 的形式管理每个 ECI 容器实例,每个 Pod 对应一个 ECI 实例, ECI Pod 之间相互隔离,一个 ECI Pod 的启动时间约是 10s。因为是在 Kubernetes 集群中管理 ECI Pod,所以完全连接了 Kubernetes 生态,有以下几点体现:
- 很方便地用 Kubectl 管理 ECI Pod,可以使用标准的 Kubernetes 的 API 操作资源;
@@ -206,42 +206,42 @@ function hide_canvas() {
这些都是使用 Kubernetes 管理容器实例的价值所在。
需要留意的是 Kubernetes 中的 ECI Pod 是 Serverless 容器,所以与普通的 Pod 相比,不支持一些功能(比如 Daemonset),不支持 Prividge 权限,不支持 HostPort 等。除此之外,ECI Pod 与普通 Pod 能力一样,比如支持挂载云盘、NAS 和 OSS 数据卷等。
2. ACK on ECI
-
+
接下来我们看下在 ACK Kubernetes 集群中使用 ECI 的方式。这种方式适合于用户已经有了一个 ACK 集群,集群中已经有了很多 ECS 节点,此时可以基于 ECI 的弹性能力来运行一些短时间 Short-Run 的应用,以解决元集群资源不足的问题,或者使用 ECI 来支撑应用的快速扩容,因为使用 ECI 进行扩容的效率要高于 ECS 节点扩容。
在 ACK on ECI 中,ECS 和 ECI Pod 可以互联互通,ECI Pod 可以访问集群中的 Coredns,也可以访问 ClusterIP Service。
3. Serverless Kubernetes
-
+
与 ACK on ECI 不同的是,ASK Serverless Kubernetes 集群中没有 ECS 节点,这是和传统 Kubernetes 集群最主要的差异,所以在 ASK 集群中无需管理任何节点,实现了彻底的免节点运维环境,是一个纯粹的 Serverless 环境,它让 Kubernetes 的使用门槛大大降低,也丢弃了繁琐的底层节点运维工作,更不会遇到节点 Notready 等问题。在 ASK 集群中,用户只需关注应用本身,而无需关注底层基础设施管理。
ASK 的弹性能力会优于普通 Kubernetes 集群,目前是 30s 创建 500 个 Pod 到 Running 状态。集群中 ECI Pod 默认是按量收费,但也支持 Spot 和预留实例劵来降低成本。在兼容性方面,ASK 中没有真实节点存在,所以不支持 Daemonset 等与节点相关的功能,像 Deployment / Statefulset / Job / Service / Ingress / CRD 等都是无缝支持的。
ASK 中默认的 Ingress 是基于 SLB 7 层转发实现,用户无需部署 Nginx Ingress,维护更加简单。
同时基于 SLB 7 层我们实现了 Knative Serving 能力,其中 Knative Controller 被 ASK 托管,用户无需负担 Controller 的成本。
与 ACK 一样,ASK 和 Arms / SLS 等云产品实现了很好的集成,可以很方便地对 Pod 进行监控,把 Pod 日志收集到 SLS 中。
-
+
这是 ASK 的整体架构,核心部分是 ASK-Schduler,它负责 Watch Pod 的变化,然后创建对应的 ECI 实例,同时把 ECI 实例状态同步到 Pod。集群中没有真实 ECS 节点注册到 Apiserver。这个 Nodeless 架构解耦了 Kubernetes 编排层和 ECI 资源层,让 Kubernetes 彻底摆脱底层节点规模导致的弹性和容量限制,成为面向云的 Nodeless Kubernetes 弹性架构。
ASK 典型功能
下面介绍 ASK 的几个典型功能:
1. GPU 实例
-
+
第一个是 GPU 实例,在 Serverless 集群中使用 GPU 容器实例是一件非常简单的事情,不需要安装 GPU 驱动,只需要指定 GPU Pod 规格,以及容器需要的 GPU 卡数,然后就可以一键部署,这对于机器学习场景可以极大提高开发和测试的效率。
2. Spot 抢占式实例
-
+
第二个是 Spot 抢占式实例。抢占式实例是一种按需实例,可以在数据计算等场景中降低计算成本。抢占式实例创建成功后拥有一小时的保护周期。抢占式实例的市场价格会随供需变化而浮动,我们支持两种 Spot 策略,一种是完全根据市场出价,一种是指定价格上限,我们只需要给 Pod 加上对应的 Annotation 即可,使用方法非常简单。
3. 弹性负载 Elastic Workload
-
+
第三个重要功能是弹性负载 Elastic Workload,弹性负载实现了 Deployment 多个副本调度在不同的单元上,比如 ECS、ECI 和 ECI-Spot 上,通过这种混合调度的模式,可以降低负载的计算成本。在这个示例中,Deployment 是 6 个副本,其中 2 个为正常的 ECI Pod,其他副本为 ECI-Spot 实例。
ASK 使用场景
上面我们已经对 Serverless Kubernetes 做了基本的产品和功能介绍,那么 ASK 适合在哪些场景中使用呢?**
1. 免运维应用托管
-
+
Serverless 集群最大的特点是解决了底层节点资源的运维问题,所以其非常适合对应用的免运维托管,让用户关注在应用开发本身。在传统 K8s 集群中的应用可以无缝部署在 Serverless 集群中,包括各种 Helm Chart。同时结合预留实例劵可以降低 Pod 的长计算成本。
2. ECI 弹性资源池
-
+
第二个场景是 ACK on ECI 的优势,我们可以选择把 ECI 作为弹性资源池,加到已有的 Kubernetes 集群中,当应用业务高峰来临时,通过 ECI 动态灵活地扩容,相比 ECS 节点扩容更有效率,这种比较适合电商或者在线教育这类有着明显波峰波谷的业务场景,用户无需管理一个很大的节点资源池,通过 ECI 弹性能力来降低整体计算成本。
3. 大数据计算
-
+
第三个场景是大数据计算,很多用户使用 Serverless 集群或者 ACK on ECI 来进行 Spark / Presto / AI 等数据计算或者机器学习,利用 ECI 可以轻松解决资源规划和不足的问题。
4. CI/CD 持续集成
-
+
第四个场景是 CI/CD 持续集成,将 Jenkins 和 Gitlab-Runner 对接 ASK 集群,按需创建 CI/CD 构建任务,构建完成后直接部署到 ASK 测试环境进行验证,这样我们无需为 Job 类任务维护一个固定资源池,按需创建极大降低成本,另外如果结合 Spot 实例还能进一步降低成本。
以上就是 Serverless Kubernetes 集群的典型场景,另有快速使用链接、产品文档以及使用示例,供大家学习交流:
diff --git a/专栏/Serverless 技术公开课(完)/15 Serverless Kubernetes 应用部署及扩缩容.md.html b/专栏/Serverless 技术公开课(完)/15 Serverless Kubernetes 应用部署及扩缩容.md.html
index bacb43ba..078bb785 100644
--- a/专栏/Serverless 技术公开课(完)/15 Serverless Kubernetes 应用部署及扩缩容.md.html
+++ b/专栏/Serverless 技术公开课(完)/15 Serverless Kubernetes 应用部署及扩缩容.md.html
@@ -210,7 +210,7 @@ function hide_canvas() {
集群创建及应用部署
1. 集群创建
在对 Serverless Kubernetes 的基础概念有了充分了解之后,我们直接进入容器服务控制台(https://cs.console.aliyun.com/#/authorize)进行集群的创建。
-
+
在创建页面,主要有三类属性需要选择或填写:
- 集群创建的地域和 Kubernetes 的版本信息;
@@ -218,7 +218,7 @@ function hide_canvas() {
- 集群能力和服务:可以按需选择。
属性完成后,点击“创建集群”即可,整个创建过程需要 1~2 分钟的时间。
-
+
2. 应用部署
集群创建完成后,接下来我们部署一个无状态的 nginx 应用,主要分成三步:
@@ -226,14 +226,14 @@ function hide_canvas() {
- 容器配置:镜像、所需资源、容器端口、数据卷等;
- 高级配置:服务、路由、HPA、POD 标签等。
-
+
创建完成后,在路由中就可以看到服务对外暴露的访问方式了。
-
+
如上图所示,在本地 host 绑定 ask-demo.com
到路由端点 123.57.252.131
的解析,然后浏览器访问域名,即可请求到部署的 nginx 应用。
常用功能介绍
我们一般会通过容器服务控制台和 Kubectl 两种方式,来使用 Serverless Kubernetes 的常用功能。
1. 容器服务控制台
-
+
在容器服务控制台上,我们可以进行以下功能的白屏化操作:
- 基本信息:集群 ID 和运行状态、API Server 端点、VPC 和安全性、集群访问凭证的查看和操作;
@@ -248,7 +248,7 @@ function hide_canvas() {
2. Kubectl
除了通过控制台,我们还可以基于 Kubectl 来进行集群操作和管理。
-
+
我们可以在云端通过 CloudShell 来使用 Kubectl,也可以在本地安装 Kubectl,然后通过将集群的访问凭证写入到 kubeconfig 来使用 Serverless Kubernetes 。
应用弹性伸缩
通通过上面的内容讲解,我们已经了解了应用的部署和集群的常用操作,下面为大家介绍一下如何为应用做扩缩容操作。
diff --git a/专栏/Serverless 技术公开课(完)/16 使用 Spot 低成本运行 Job 任务.md.html b/专栏/Serverless 技术公开课(完)/16 使用 Spot 低成本运行 Job 任务.md.html
index 23f203aa..95afdc58 100644
--- a/专栏/Serverless 技术公开课(完)/16 使用 Spot 低成本运行 Job 任务.md.html
+++ b/专栏/Serverless 技术公开课(完)/16 使用 Spot 低成本运行 Job 任务.md.html
@@ -179,14 +179,14 @@ function hide_canvas() {
16 使用 Spot 低成本运行 Job 任务
成本优化
-
+
ECI 除了有秒级弹性、无限容量的优势之外,在一些特定场景下对成本的优化也是非常明显的,通过上图我们可以看到,相同规格的实例,在日运行时间少于 14 小时的时候,使用 ECI 会更加便宜。
-
+
除了日运行时长小于 14 小时的情形,ECI 实例还支持多种计费类型,客户可以根据自身业务选择相应的计费模式:long run 类型的可以采用 RI 实例券;运行时长低于 1 小时可以选用 Spot 竞价实例;针对突发流量部分,采用按量实例。
Spot 实例概述
-
+
抢占式实例是一种按需实例,可以在数据计算等场景中降低计算成本。抢占式实例创建成功后拥有一小时的保护周期。抢占式实例的市场价格会随供需变化而浮动,我们支持两种 spot 策略,一种是完全根据市场出价,一种是指定价格上限,我们只需要给 pod 加上对应的 annotation 即可,使用方法非常简单。
-
+
- SpotAsPriceGo:系统自动出价,跟随当前市场实际价格(通常以折扣的形式体现)
- SpotWithPriceLimit:设置抢占实例价格上限
@@ -195,16 +195,16 @@ function hide_canvas() {
- 用户价格 >= ECI 按量实例价格,使用 ECI 按量实例价格来创建实例。
创建 Spot 实例
-
+
- 根据规格查看实例按量价格,点击查询
首先我们查询出【华北 2(北京)地域 ecs.c5.large 按量(小时)价格:0.62】,然后我们以此规格来创建 Spot 竞价实例。
-
+
采用 Spot 实例来运行 CronJob,分别采用“指定最高限价”、“系统自动出价”的方式。随市场价的场景目前还没有办法直接看到真实的价格,只能根据实例 ID 查询账单信息。
-
+
采用 Spot 实例运行 Deployment,在本次实验中我们采用指定最高限价的策略,并设置一个极低的小时价格,可以看到 2 个 Pod 都创建失败了,使用 kubectl describe 命令可以看到失败的详细原因为价格不匹配:The current price of recommend instanceTypes above user max price。
-
+
如上图所示,当 Spot 实例运行超过 1 小时保护期后,有可能会因为库存不足,或者设置的价格小于市场价而触发实例释放,实例释放前 3 分钟会有事件通知。
应用场景
您可以在抢占式实例上部署以下业务:
diff --git a/专栏/Serverless 技术公开课(完)/17 低成本运行 Spark 数据计算.md.html b/专栏/Serverless 技术公开课(完)/17 低成本运行 Spark 数据计算.md.html
index 61cbf91b..8ce1852f 100644
--- a/专栏/Serverless 技术公开课(完)/17 低成本运行 Spark 数据计算.md.html
+++ b/专栏/Serverless 技术公开课(完)/17 低成本运行 Spark 数据计算.md.html
@@ -181,22 +181,22 @@ function hide_canvas() {
产品介绍
阿里云弹性容器实例 ECI
ECI 提供安全的 Serverless 容器运行服务。无需管理底层服务器,只需要提供打包好的 Docker 镜像,即可运行容器,并仅为容器实际运行消耗的资源付费。
-
+
阿里云容器服务产品族
-
+
不论是托管版的 Kubernetes(ACK)还是 Serverless 版 Kubernetes(ASK),都可以使用 ECI 作为容器资源层,其背后的实现就是借助虚拟节点技术,通过一个叫做 Virtual Node 的虚拟节点对接 ECI。
-
+
Kubernetes + ECI
有了 Virtual Kubelet,标准的 Kubernetes 集群就可以将 ECS 和虚拟节点混部,将 Virtual Node 作为应对突发流量的弹性资源池。
-
+
ASK(Serverless Kubernetes)+ ECI
Serverless 集群中没有任何 ECS worker 节点,也无需预留、规划资源,只有一个 Virtual Node,所有的 Pod 的创建都是在 Virtual Node 上,即基于 ECI 实例。
-
+
Serverless Kubernetes 是以容器和 Kubernetes 为基础的 Serverless 服务,它提供了一种简单易用、极致弹性、最优成本和按需付费的 Kubernetes 容器服务,其中无需节点管理和运维,无需容量规划,让用户更关注应用而非基础设施的管理。
Spark on Kubernetes
Spark 自 2.3.0 开始试验性支持 Standalone、on YARN 以及 on Mesos 之外的新的部署方式:Running Spark on Kubernetes,如今支持已经非常成熟。
Kubernetes 的优势
-
+
Spark on kubernetes 相比于 on Yarn 等传统部署方式的优势:
- 统一的资源管理。不论是什么类型的作业都可以在一个统一的 Kubernetes 集群中运行,不再需要单独为大数据作业维护一个独立的 YARN 集群。
@@ -207,11 +207,11 @@ function hide_canvas() {
- 大数据上云。目前大数据应用上云常见的方式有两种:1)用 ECS 自建 YARN(不限于 YARN)集群;2)购买 EMR 服务,目前所有云厂商都有这类 PaaS,如今多了一个选择——Kubernetes。
Spark 调度
-
+
图中橙色部分是原生的 Spark 应用调度流程,而 Spark on Kubernetes 对此做了一定的扩展(黄色部分),实现了一个 KubernetesClusterManager。其中 **KubernetesClusterSchedulerBackend 扩展了原生的CoarseGrainedSchedulerBackend,**新增了 **ExecutorPodsLifecycleManager、ExecutorPodsAllocator 和 KubernetesClient **等组件,实现了将标准的 Spark Driver 进程转换成 Kubernetes 的 Pod 进行管理。
Spark submit
在 Spark Operator 出现之前,在 Kubernetes 集群提交 Spark 作业只能通过 Spark submit 的方式。创建好 Kubernetes 集群,在本地即可提交作业。
-
+
作业启动的基本流程:
- Spark 先在 K8s 集群中创建 Spark Driver(pod)。
@@ -222,14 +222,14 @@ function hide_canvas() {
直接通过这种 Spark submit 的方式,参数非常不好维护,而且不够直观,尤其是当自定义参数增加的时候;此外,没有 Spark Application 的概念了,都是零散的 Kubernetes Pod 和 Service 这些基本的单元,当应用增多时,维护成本提高,缺少统一管理的机制。
Spark Operator
Spark Operator 就是为了解决在 Kubernetes 集群部署并维护 Spark 应用而开发的,Spark Operator 是经典的 CRD + Controller,即 Kubernetes Operator 的实现。
-
+
下图为 SparkApplication 状态机:
-
+
Serverless Kubernetes + ECI
那么,如果在 Serverless Kubernetes 集群中运行 Spark,其实际上是对原生 Spark 的进一步精简。
-
+
存储选择
-
+
对于批量处理的数据源,由于集群不是基于 HDFS 的,所以数据源会有不同,需要计算与存储分离,Kubernetes 集群只负责提供计算资源。
- 数据源的存储可以采用阿里云对象存储 OSS、阿里云分布式存储 HDFS 等。
diff --git a/专栏/Serverless 技术公开课(完)/18 GPU 机器学习开箱即用.md.html b/专栏/Serverless 技术公开课(完)/18 GPU 机器学习开箱即用.md.html
index 741e3fbc..bff77cf8 100644
--- a/专栏/Serverless 技术公开课(完)/18 GPU 机器学习开箱即用.md.html
+++ b/专栏/Serverless 技术公开课(完)/18 GPU 机器学习开箱即用.md.html
@@ -179,13 +179,13 @@ function hide_canvas() {
18 GPU 机器学习开箱即用
ECI GPU 简介
-
+
相较于普通的 ECI 实例,ECI GPU 实例为用户容器提供了 GPU 资源以加速机器学习等任务的运行,其典型架构如上图所示。ECI GPU 实例预装了 GPU 驱动,免去了用户安装和维护 GPU 驱动的麻烦。同时,ECI GPU 实例同普通的 ECI 实例一样兼容 CRI 接口,Kubernetes 也可以直接对 ECI GPU 实例进行调度和编排。此外,利用官方容器镜像,用户无需关心 CUDA Toolkit/Tensorflow/PyTorch 等工具和框架的搭建部署,只需要专注于具体业务功能的开发和实现。
通过 ECI GPU 实例,用户可以一键式部署和运行经过 GPU 加速的机器学习等业务,简单方便。
ECI GPU 基本实现原理
大家知道,容器一般是通过内核接口访问主机上的资源。但是对于 GPU 资源,容器无法直接通过内核接口访问到,只能通过厂商驱动与 GPU 进行交互。
那么,ECI GPU 实例是如何让用户容器实例访问到 GPU 资源的呢?本质上,ECI GPU 就是在用户容器创建时将 GPU 驱动的一些必要的动态库文件挂载到用户容器中,从而使得用户容器可以通过这些挂载的动态库文件访问到位于 Host 端的 GPU。
-
+
ECI GPU 的基本实现框架如上图所示,图中所有方框代表的组件都运行在 ECI HostOS 侧。其中 ContainerAgent 是自研的一个组件,可以类比与 Kubelet,其接受来自管控的指令;右上角的 nvidia-container-runtime-hook 是 NVIDIA 开源实现的一个符合 OCI 标准的一个 prestart hook,prestart hook 用于在容器执行用户指定的的命令前执行一些自定义配置操作;右侧中间位置的 libnvidia-container 也是一个 NVIDIA 开源的一个组件,用于将 Host 侧 GPU 驱动的动态库挂载到指定容器中。
简单介绍一下 ECI GPU 下的容器启动流程:
@@ -195,17 +195,17 @@ function hide_canvas() {
- 容器创建完成后,用户容器进程通过上述挂载的动态库文件访问并使用 GPU 资源
ECI GPU 使用方式
-
+
目前在 ACK/ASK 集群中使用 GPU,只需要在 YAML 文件中指定两个字段即可,如上图标红处所示。
第一个字段是 k8s.aliyun.com/eci-use-specs,该字段用于指定 ECI GPU 实例规格,当前阿里云上可用的 ECI GPU 实例规格已经列在左图的表格中了。
第二个字段是 nvidia.com/gpu,该字段用于指定该容器所要使用的 GPU 数量。注意,spec 中所有容器指定要使用的 GPU 数量总和不能超过 k8s.aliyun.com/eci-use-specs 字段指定的 ECI GPU 实例规格所提供的 GPU 数量,否则容器会创建失败。
演示
视频演示过程请点击【视频课链接】进行学习。
最后简单演示一下如何在 ACK 集群中使用 GPU 加速执行机器学习任务。我们以在 ASK 集群中进行 MNIST(手写数字识别)训练任务为例:
-
+
该任务由 YAML 文件定义,如上图所示。我们在 YAML 文件中指定了 ECI GPU 实例类型,该实例类型包含一颗 NVIDIA P4 GPU。然后我们指定了容器镜像为 nvcr.io/nvidia/pytorch,该镜像是由 NVIDIA 提供,内部已经封装好了 CUDA/PyTorch 等工具。最后,我们通过 nvidia.com/gpu 指定了要使用的 GPU 数量为 1。
如上图所示,在 ASK 集群中,我们选择使用模板创建应用实例,然后在模板中输入右侧 YAML 文件的内容,最后点击创建即可创建一个使用 GPU 的容器了。
-
+
容器创建完成之后,首先我们通过 kubectl 命令登录到我们创建的容器中,然后执行 nvidia-smi 命令确认 GPU 是否可用。如上图中的左上角截图所示,nvidia-smi 命令成功返回了 GPU 的信息,如 GPU 的型号的 P4,驱动版本号是 418.87.01,CUDA 版本为 10.1 等,这表示了我们创建的容器是可以正常使用 GPU 资源的。
接着,如上图中的右侧截图所示,我们进入 /workspace/examples/mnist 目录下执行 python main.py 开始执行 MNIST 训练任务,MNIST 训练任务会先下载 MNIST 数据集,由于 MNIST 数据集较大可能下载时间会比较长。下载完数据集后,MNIST 训练任务会开始进行数据集的训练。
当 MNIST 任务执行完之后,我们会看到训练结果打印在屏幕上,如上图中左下角截图所示。MNIST 测试集包含 10000 张测试图片,从结果图片我们可以看到其中由 9845 张手写数字图片都被正确识别了,精度已经是相当高。有兴趣的同学可以对比测试一下不使用 GPU 场景下的 MNIST 任务所用的训练时间。
diff --git a/专栏/Serverless 技术公开课(完)/19 基于 Knative 低成本部署在线应用,灵活自动伸缩.md.html b/专栏/Serverless 技术公开课(完)/19 基于 Knative 低成本部署在线应用,灵活自动伸缩.md.html
index a1b5c0f7..b9def081 100644
--- a/专栏/Serverless 技术公开课(完)/19 基于 Knative 低成本部署在线应用,灵活自动伸缩.md.html
+++ b/专栏/Serverless 技术公开课(完)/19 基于 Knative 低成本部署在线应用,灵活自动伸缩.md.html
@@ -179,7 +179,7 @@ function hide_canvas() {
19 基于 Knative 低成本部署在线应用,灵活自动伸缩
为什么需要 Knative
-
+
Serverless 已经是万众期待,未来可期的状态。各种调查报告显示企业及开发者已经在使用 Serverless 构建线上服务,而且这个比例还在不断增加。
在这个大趋势下,我们再来看 IaaS 架构的演进方向。最初企业上云都是基于 VM 的方式在使用云资源,企业线上服务都是通过 Ansible、Saltstack、Puppet 或者 Chef 等工具裸部在 VM 中的。直接在 VM 中启动应用,导致线上服务对 VM 的环境配置有很强的依赖,而后伴随着容器技术的崛起,大家开始通过容器的方式在 VM 中部署应用。
但如果有十几个甚至几十个应用需要部署,就需要在成百上千的 VM 快速部署、升级应用,这是一件非常令人头疼的事情。而 Kubernetes 很好地解决了这些问题,所以现在大家开始通过 Kubernetes 方式使用云资源。随着 Kubernetes 的流行,各大云厂商都开始提供 Serverless Kubernetes 服务,用户无需维护 Kubernetes 集群,即可直接通过 Kubernetes 语义使用云的能力。
@@ -205,12 +205,12 @@ function hide_canvas() {
多个应用部署在同一个集群中,需要一个接入层网关对多个应用以及同一个应用的不同版本进行流量的管理。
随着 Kubernetes 和云原生概念的崛起,第一直觉可能是直接在 Kubernetes 之上部署 Serverless 应用。那么,如果要在原生的 Kubernetes 上部署 Serverless 应用我们可能会怎么做?
-
+
首先需要一个 Deployment 来管理 Workload,还需要通过 Service 对外暴露服务和实现服务发现的能力。应用有重大变更,新版本发布时可能需要暂停观察,待观察确认没问题之后再继续增加灰度的比例。这时就要使用两个 Deployment 才能做到。
v1 Deployment 代表旧版本,灰度的时候逐一减少实例数;v2 Deployment 代表新版本,灰度的时候逐一增加实例数。hpa 代表弹性能力,每一个 Deployment 都有一个 hpa 管理弹性配置。
这其中其实是有冲突的:假设 v1 Deploymen 原本有三个 pod,灰度的时候升级一个 pod 到 v2,此时其实是 1/3 的流量会打到 v2 版本上。但当业务高峰到来后,因为两个版本都配置了 hpa,所以 v2 和 v1 会同时扩容,最终 v1 和 v2 的 pod 数量就不是最初设置的 1/3 的比例了。
所以传统的这种按照 Deployment 实例数发布的灰度策略和弹性配置天然是冲突的。而如果按照流量比例进行灰度就不会有这个问题,这可能就要引入 Istio 的能力。
-
+
引入 Istio 作为 Gateway 组件,Istio 除了管理同一个应用的流量灰度,还能对不同的应用进行流量管理。看起来很好,但是我们再仔细分析一下存在什么问题。先梳理一下在原生 K8s 之上手动管理 Serverless 应用都需要做什么:
- Deployment
@@ -222,43 +222,43 @@ function hide_canvas() {
- Gateway
这些资源是每一个应用维护一份,如果是多个应用就要维护多份。这些资源散落在 K8s 内,根本看不出来应用的概念,另外管理起来也非常繁琐。
-
+
Serverless 应用需要的是面向应用的管理动作,比如应用托管、升级、回滚、灰度发布、流量管理以及弹性等功能。而 Kubernetes 提供的是 IaaS 的使用抽象。所以 Kubernetes 和 Serverless 应用之间少了一层应用编排的抽象。
而 Knative 就是建立在 Kubernetes 之上的 Serverless 应用编排框架。除了 Knative 以外,社区也有好几款 FaaS 类的编排框架,但这些框架编排出来的应用没有统一的标准,每一个框架都有一套自己的规范,而且和 Kubernetes API 完全不兼容。不兼容的 API 就导致使用难度高、可复制性不强。云原生的一个核心标准就是 Kubernetes 的 API 标准,Knative 管理的 Serverless 应用保持 Kubernetes API 语义不变。和 Kubernetes API 具有良好的兼容性,就是 Knative 的云原生特性所在。
Knative 是什么?
-
+
Knative 主要解决的问题就是在 Kubernetes 之上提供通用的 Serverless 编排、调度服务,给上层的 Serverless 应用提供面向应用层的原子操作。并且通过 Kubernetes 原生 API 暴露服务 API,保持和 Kubernetes 生态工具链的完美融合。Knative 有 Eventing 和 Serving 两个核心模块,本文主要介绍 Serving 的核心架构。
Knative Serving 简介
-
+
Serving 核心是 Knative Service,Knative Controller 通过 Service 的配置自动操作 Kubernetes Service 和 Deployment,从而实现简化应用管理的目标。
Knative Service 对应一个叫做 Configuration 的资源,每次 Service 变化如果需要创建新的 Workload 就更新 Configuration,然后每次 Configuration 更新都会创建一个唯一的 Revision。Revision 可以认为是 Configuration 的版本管理机制。理论上 Revision 创建完以后是不会修改的。
Route 主要负责 Knative 的流量管理,Knative Route Controller 通过 Route 的配置自动生成 Knative Ingress 配置,Ingress Controller 基于 Ingress 策略实现路由的管理。
Knative Serving 对应用 Workload 的 Serverless 编排是从流量开始的。流量首先达到 Knative 的 Gateway,Gateway 根据 Route 的配置自动把流量根据百分比拆分到不同的 Revision 上,然后每一个 Revision 都有一个自己独立的弹性策略。当过来的流量请求变多时,当前 Revision 就开始自动扩容。每一个 Revision 的扩容策略都是独立的,相互不影响。
基于流量百分比对不同的 Revision 进行灰度,每一个 Revision 都有一个独立的弹性策略。Knative Serving 通过对流量的控制实现了流量管理、弹性和灰度三者的完美结合。接下来具体介绍一下 Knative Serving API 细节。
-
+
上图展示了 Knative Autoscaler 的工作机制,Route 负责接入流量,Autoscaler 负责做弹性伸缩。当没有业务请求时会缩容到零,缩容到零后 Route 进来的请求会转到 Activator 上。当第一个请求进来之后 Activator 会保持住 http 链接,然后通知 Autoscaler 去做扩容。Autoscaler 把第一个 pod 扩容完成以后 Activator 就把流量转发到 Pod,从而做到了缩容到零也不会损失流量的目的。
到此 Knative Serving 的核心模块和基本原理已经介绍完毕,你应该对 Knative 已经有了初步了解。在介绍原理的过程中你可能也感受到了,要想把 Knative 用起来其实还是需要维护很多 Controller 组件、Gateway 组件(比如 Istio))的,并且要持续地投入 IaaS 成本和运维成本。
-
+
Gateway 组件假设使用 istio 实现的话,Istio 本身就需要十几个 Controller,如果要做高可用可能就需要二十几个 Controller。Knative Serving Controller 全都高可用部署也需要十几个。这些 Controller 的 IaaS 成本和运维成本都比较多。另外冷启动问题也很明显,虽然缩容到零可以降低业务波谷的成本,但是第一批流量也可能会超时。
Knative 和云的完美融合
为了解决上述问题,我们把 Knative 和阿里云做了深度的融合。用户还是按照 Knative 的原生语义使用,但底层的 Controller 、Gateway 都深度嵌入到阿里云体系内。这样既保证了用户可以无厂商锁定风险地以 Knative API 使用云资源,还能享受到阿里云基础设施的已有优势。
-
+
首先是 Gateway 和云的融合,直接使用阿里云 SLB 作为 Gateway,使用云产品 SLB 的好处有:
- 云产品级别的支撑,提供 SLA 保障;
- 按需付费,不需要出 IaaS 资源;
- 用户无需承担运维成本,不用考虑高可用问题,云产品自带高可用能力。
-
+
除了 Gateway 组件以外,Knative Serving Controller 也需要一定的成本,所以我们把 Knative Serving Controller 和阿里云容器服务也进行了融合。用户只需要拥有一个 Serverless Kubernetes 集群并开通 Knative 功能就可以基于 Knative API 使用云的能力,并且用户无需为 Knative Controller 付出任何成本。
-
+
接下来再分析一下冷启动问题。
传统应用在没开启弹性配置的时候实例数是固定的,Knative 管理的 Serverless 应用默认就有弹性策略,在没有流量的时候会缩容到零。传统应用在流量低谷时即便没有业务请求处理,实例数还保持不变,这其实是浪费资源的。但好处就是请求不会超时,什么时候过来的请求都可以会很好地处理。而如果缩容到零,第一个请求到达以后才会触发扩容的过程。
Knative 的模型中从 0 到 1 扩容需要 5 个步骤串行进行,这 5 个步骤都完成以后才能开始处理第一个请求,而此时往往都会超时。所以 Knative 缩容到零虽然降低了常驻资源的成本,但第一批请求的冷启动问题也非常明显。可见弹性其实就是在寻找成本和效率的一个平衡点。
-
+
为了解决第一个实例的冷启动问题,我们推出了保留实例功能。保留实例是阿里云容器服务 Knative 独有的功能。社区的 Knative 默认在没有流量时缩容到零,但是缩容到零之后从 0 到 1 的冷启动问题很难解决。冷启动除了要解决 IaaS 资源的分配、Kubernetes 的调度、拉镜像等问题以外,还涉及到应用的启动时长。应用启动时长从毫秒到分钟级别都有。应用启动时间完全是业务行为,在底层平台层面几乎无法控制。
ASK Knative 对这个问题的解法是通过低价格的保留实例,来平衡成本和冷启动问题。阿里云 ECI 有很多规格,不同规格的计算能力不一样,价格也不一样。如下所示是对 2c4G 配置的计算型实例和突发性能型实例的价格对比。
-
+
通过上图可知突发性能实例比计算型便宜 46%,可见如果在没有流量时,使用突发性能实例提供服务不单单解决了冷启动的问题,还能节省很多成本。
突发性能实例除了价格优势以外,还有一个非常亮眼的功能就是 CPU 积分。突发性能实例可以利用 CPU 积分应对突发性能需求。突发性能实例可以持续获得 CPU 积分,在性能无法满足负载要求时,可以通过消耗积累的 CPU 积分无缝提高计算性能,不会影响部署在实例上的环境和应用。通过 CPU 积分,您可以从整体业务角度分配计算资源,将业务平峰期剩余的计算能力无缝转移到高峰期使用(简单的理解就是油电混动)。突发性能实例的更多细节参见这里。
所以 ASK Knative 的策略是在业务波谷时使用突发性能实例替换标准的计算型实例,当第一个请求来临时再无缝切换到标准的计算型实例。这样可以降低流量低谷的成本,并且在低谷时获得的 CPU 积分,还能在业务高峰到来时消费掉,用户支付的每一分钱都不会浪费。
diff --git a/专栏/Serverless 技术公开课(完)/20 快速构建 JenkinsGitlab 持续集成环境.md.html b/专栏/Serverless 技术公开课(完)/20 快速构建 JenkinsGitlab 持续集成环境.md.html
index f55b48a0..63d0b0bf 100644
--- a/专栏/Serverless 技术公开课(完)/20 快速构建 JenkinsGitlab 持续集成环境.md.html
+++ b/专栏/Serverless 技术公开课(完)/20 快速构建 JenkinsGitlab 持续集成环境.md.html
@@ -179,7 +179,7 @@ function hide_canvas() {
20 快速构建 JenkinsGitlab 持续集成环境
ASK 介绍
-
+
首先,ASK 是什么?ASK 是阿里云推出的无服务器版 Kubernetes 容器服务。与传统的 Kubernetes 服务相比,ASK最大的特点就是通过虚拟节点接入 Kubernetes 集群,而 Kubernetes 的 Master 节点也完全由阿里云容器服务托管。因此,在整个 ASK 集群中,用户无需管理和运维真实节点,只用关心 Pod 资源即可,ASK 中的 Pod 则由阿里云弹性容器实例 ECI 承载。
ASK 的优势主要有以下几点:
@@ -197,7 +197,7 @@ function hide_canvas() {
GitLab CI on ASK 的优势
说到 CI/CD,大家最熟悉的两个工具,一个是 Jenkins,另一个是 GitLab CI,随着 Devops 角色的流行,越来越多的企业采用 GitLab CI 作为持续集成的工具,下面给大家介绍下 GitLab CI on ASK。gitlab-runner 以 Pod 形式注册到 ASK 集群中,每个 CI/CD stage 也对应一个 Pod。
-
+
这么做的优势有以下几点:
- 服务高可用(Deployment+PVC);
@@ -220,15 +220,15 @@ function hide_canvas() {
- 在【容器服务控制台】创建标准 Serverless K8s 集群
-
+
- 集群创建完成后,基本信息中有 API server 公网链接地址
-
+
- 连接信息中有 ASK 集群访问凭证
-
+
2. 准备 PV/PVC
准备两个 nas 盘,一个做 gitlab runner cache,一个做 maven 仓库,请自行替换 nas server 地址和 path
kubectl apply -f mvn-pv.yaml
@@ -268,15 +268,15 @@ kubectl apply -f imagecache.yaml
6. 部署 gitlab runner
kubectl apply -f gitlab-runner-deployment.yaml
-
+
7. 进行一个简单的 CI 任务
-
+
git repo 中的 .gitlab-ci.yml 类似 Jenkinsfile,定义了构建任务的工作流。我们修改 demo 项目中的 src/main/webapp/index.jsp 文件,然后 git commit -m "change index info" 提交。 gitlab 中的流水线任务即被触发,整个流程涉及到编译、打包、部署。
-
-
+
+
成本
使用 ASK 与一台预付费 ECS 的成本对比:
-
+
从上述成本计算可以看出,当您每天的 CI/CD 任务少于 126 个时,使用 ASK+ECI 会比购买一台包年包月的 ECS 更加划算。在享受按需付费的同时,也降低了运维成本,更加重要的是,当业务规模扩大、CI/CD 任务数量陡增时,不再需要担心 Node 节点的扩容。ASK+ECI 的方案,可以被认为是 CI/CD 持续集成场景的量身标配。
diff --git a/专栏/Serverless 技术公开课(完)/21 在线应用的 Serverless 实践.md.html b/专栏/Serverless 技术公开课(完)/21 在线应用的 Serverless 实践.md.html
index 5c88533e..0af5e7b5 100644
--- a/专栏/Serverless 技术公开课(完)/21 在线应用的 Serverless 实践.md.html
+++ b/专栏/Serverless 技术公开课(完)/21 在线应用的 Serverless 实践.md.html
@@ -189,32 +189,32 @@ function hide_canvas() {
SAE 产品介绍
那么摆在 Serverless 技术落地面前的三座大山该如何解决呢?给大家分享一款低门槛,无需任何代码改造就能直接使用的 Serverless PaaS 平台(SAE),是企业在线业务平滑上云的最佳选择。
-
+
SAE 提供了成本更优、效率更高的应用托管方案。底层基于统一的 K8s 技术底座,帮用户屏蔽复杂的 IaaS 层和 K8s 集群运维,提供计算资源、弹性、隔离性等能力,用户只需关心应用实例的规格和实例数。
在应用层,除提供了生命周期管理、多发布策略外,还提供监控、日志、微服务治理能力,解决应用可观测性和治理需求。同时提供一键启停、应用编排等高级能力,进一步提效和降本。核心场景主要面向在线应用:微服务应用、Web 应用、多语言应用等。
在开发者工具方面,和 CI/CD 工具做了良好的集成,无论是 Jenkins 还是云效,都能直接部署应用到 SAE,也可以通过 Cloud Toolkit 插件工具实现本地一键部署应用到云端,可以说 SAE 覆盖了应用上云的完整场景。
-

+

SAE 除了 Serverless 体验本身所带来的极致弹性、免运维、省成本等特性之外,重点在应用层给用户提供了全栈的能力,包括对微服务的增强支持,以及整合了和应用息息相关能力,包括配置、监控、日志、流量控制等。再加上用户零代码的改造,这也是 SAE 区别其它 Serveless 产品的重要优势,平滑迁移企业在线应用。
-
+
SAE 有几个典型的使用场景:一个是存量业务上云,特别是微服务、Java 应用,同时也支持其他语言的单体应用快速上云/搬站,满足极致交付效率和开箱即用的一站式体验。在行业方面,SAE 特别适合有比较大的流量波动的在线业务,比如电商大促、在线教育等行业的场景。另外 SAE 作为应用 PaaS 也可以被上层的行业 SaaS 所集成,帮助用户更快地构建行业 SaaS。
产品核心指标
-
+
SAE 三个核心的指标:容器启动时长 20s(指标定义是从 pull image 到容器启动的耗时,不包括应用启动时间),接下来我们会通过各种技术优化把它优化到 5s 内,保证用户在突发场景下的快速扩容效率。最小规格支持 0.5core 1GiB,满足更细粒度的资源诉求。相比 ECS,SAE 部署一套开发测试环境的成本可以节省 47%~57%。
最佳实践
通过前文介绍, 我们了解了产品的特性、优势、适用场景,最后给大家详细介绍几个 Serverless 落地的最佳实践案例。
1. 低门槛微服务架构转型的解决方案
-

+

随着业务的快速增长,很多企业都面临单体向微服务架构改造转型的难题,或者开源自建的微服务框架(Spring Cloud / Dubbo)能力不再能满足企业稳定性和多样化的需求。通过 SAE 提供开箱即用的微服务能力和稳定性兜底能力,已让这些企业低门槛快速完成微服务架构转型,支撑新业务快速上线,让企业专注于业务本身。
可以说,SAE 是 Serverless 行业最佳的微服务实践,同时也是微服务行业最佳的 Serverless 实践。
2. 免运维、一键启停开发测试环境的降本方案
-

+

中大型企业多套环境,往往开发测试、预发环境都不是 7*24 小时使用,长期保有应用实例,闲置浪费很高,有些企业 CPU 利用率都快接近 0,降本诉求明显。通过 SAE 一键启停能力,让这些企业得以灵活按需释放资源,只开发测试环境就能节省 2/3 的机器成本,非常可观。
3. 精准容量、极致弹性的解决方案
-

+

电商类、安防行业等往往会有一些不可预期的突发流量高峰,之前他们都是提前预估峰值,按照峰值保有 ECS 资源,但经常出现容量预估不准(资源浪费 or 不足),更严重的甚至会影响系统的 SLA。
采用压测工具 + SAE 的方案后,根据压测结果精准设置弹性策略期望值,然后和实时的监控指标比对,系统自动进行扩缩操作,再也无需容量规划,并且弹性效率能做到秒级,轻松应对峰值大考。
4. 构建高效闭环的 DevOps 体系
-
+
SAE 构建了高效闭环的 DevOps 体系,覆盖了应用的开发态、部署态、运维态的整个过程。中大型企业往往都使用企业级 CI/CD 工具 Jenkis / 云效部署 SAE 应用,完成从 Source Code - 构建 - 部署全链路。中小企业/个人开发者往往选择开发者工具 Maven 插件、IDEA 插件一键部署应用到云端,方便本地调试,提升开发者体验。完成部署后,即可进行运维态的治理和诊断,如限流降级、应用诊断,数据化运营分析等。
总结
总结一下,本文主要是围绕在线应用的 Serverless 落地实践展开的。开篇提到的几个落地挑战在 SAE 产品中基本都能得到很好的解决:
diff --git a/专栏/Serverless 技术公开课(完)/22 通过 IDEMaven 部署 Serverless 应用实践.md.html b/专栏/Serverless 技术公开课(完)/22 通过 IDEMaven 部署 Serverless 应用实践.md.html
index a14c9d64..b43d3827 100644
--- a/专栏/Serverless 技术公开课(完)/22 通过 IDEMaven 部署 Serverless 应用实践.md.html
+++ b/专栏/Serverless 技术公开课(完)/22 通过 IDEMaven 部署 Serverless 应用实践.md.html
@@ -183,11 +183,11 @@ function hide_canvas() {

首先,简单介绍一下 SAE。SAE 是一款面向应用的 Serverless PaaS 平台,支持 Spring Cloud、Dubbo、HSF 等主流开发框架,用户可以零代码改造直接将应用部署到 SAE,并且按需使用、按量计费、秒级弹性。SAE 充分发挥 Serverless 的优势,为用户节省闲置资源成本;在体验上,SAE 采用全托管、免运维的方式,用户只需聚焦核心业务的开发,而应用生命周期管理、微服务管理、日志、监控等功能交由 SAE 完成。
2. SAE 应用部署方式
-

+

在使用 SAE 时,您可以在控制台上看到 SAE 支持三种部署方式,即可以通过 WAR 包、JAR 包和镜像的方式进行部署,如果您采用 Spring Cloud、Dubbo、HSF 这类应用,可以直接打包上传,或者填入包的地址便可以部署到 SAE 上;对于非 Java 语言的场景,您也可以使用镜像直接来部署,后续我们也会支持其他语言直接上传包的形式进行部署。
SAE 除上述控制台界面部署的方式之外,还支持通过 Maven 插件或者 IDE 插件的方式进行部署,这样您无需登录控制台,就可以执行自动化部署操作,同时可以集成如云效、Jenkins 等工具实现 CICD 流程。
Maven 插件部署
-
+
如何使用 Maven 插件进行部署?首先需要为应用添加 Maven 依赖 toolkit-maven-plugin,接下来需要编写配置文件来配置插件的具体行为,这里定义了三个配置文件:
- toolkit_profile.yaml 账号配置文件,用来配置阿里云 ak、sk 来标识阿里云用户,这里推荐使用子账号 ak、sk 以降低安全风险。
diff --git a/专栏/Serverless 技术公开课(完)/23 企业级 CICD 工具部署 Serverless 应用的落地实践.md.html b/专栏/Serverless 技术公开课(完)/23 企业级 CICD 工具部署 Serverless 应用的落地实践.md.html
index 5b89735e..ff17c6c7 100644
--- a/专栏/Serverless 技术公开课(完)/23 企业级 CICD 工具部署 Serverless 应用的落地实践.md.html
+++ b/专栏/Serverless 技术公开课(完)/23 企业级 CICD 工具部署 Serverless 应用的落地实践.md.html
@@ -179,12 +179,12 @@ function hide_canvas() {
23 企业级 CICD 工具部署 Serverless 应用的落地实践
背景知识
-
+
通过以往几节课程的学习,相信大家对于 SAE 平台已经有了一定的了解。SAE 为客户免除了很多复杂的运维工作,开箱即用、按用量付费;与此同时 SAE 提供了丰富的 Open API,可以很容易地与其他平台做集成;类似云效以及 Jenkins 的 CI/CD 工具是敏捷软件研发实践中的重要一环,可以自动化地将客户的代码编译、测试、打包并部署至各个环境,从而提升团队的研发效率。
本篇文章分为两个部分,首先介绍使用云效平台实现从源码到 SAE 环境的持续集成,然后介绍使用 Jenkins 的情况下持续集成该如何配置。
使用云效部署到 SAE
云效(rdc.console.aliyun.com),是阿里云推出的企业级一站式 Devops 平台型产品,功能覆盖了从【需求->开发->测试->发布->运维->运营】全流程。对云效感兴趣的同学可以去【阿里云官网】搜索【云效】,本文只介绍与 CI/CD 相关的部分功能。
-
+
如上图所示,图的上半部分是我们的配置流程,下半部分的流程图是我们所要执行的持续集成流程的示例。云效首先会从代码仓库中拉取相应的代码,然后进行代码检查以及单元测试,接着是代码编译构建,这一步会产出相应的生成物:在这里我们用一个 java 应用来举例,如果构建产出物这一步选择是 jar 类型,那么流水线在运行时运行 mvn package 命令产出对应的 jar 包;如果构建产出物类型是 Docker 镜像,那么在构建这一步在产出 jar 包后会继续执行 docker build 命令来构建对应的 Docker 镜像并上传到您所选择的 ACR 镜像仓库;流水线的最后两步是调用 SAE 的 Open API 将构建物(jar 包/Docker 镜像)部署分发到测试环境,根据我们预先的设置,在部署完测试环境这一步后流水线会停下来等待手动触发下一步操作;等待手动验证测试环境的部署一切正常后,手动触发流水线继续运行,这次将调用 Open API 部署到生产环境。
操作步骤:
@@ -195,7 +195,7 @@ function hide_canvas() {
使用 Jenkins 部署 SAE
Jenkins 是被业界广泛使用的开源 CI/CD 平台,使用 Jenkins 可以将源码打包编译后部署至 SAE,其达成的最终效果与“通过云产品云效部署至SAE”类似,通过 Jenkins 将应用源码编译成为 jar 包,然后通过maven plugin 来调用 SAE 的 Open API 部署接口将应用部署至 SAE。
-
+
操作步骤:
- 代码库中有相应的打包配置,在使用 Jenkins 时我们打包的产出构建物是 jar 包,所以此处要求我们项目根目录下有对应的 maven配置文件 pom.xml;
diff --git a/专栏/Serverless 技术公开课(完)/25 Serverless 应用引擎产品的流量负载均衡和路由策略配置实践.md.html b/专栏/Serverless 技术公开课(完)/25 Serverless 应用引擎产品的流量负载均衡和路由策略配置实践.md.html
index 390a03ca..95cf5778 100644
--- a/专栏/Serverless 技术公开课(完)/25 Serverless 应用引擎产品的流量负载均衡和路由策略配置实践.md.html
+++ b/专栏/Serverless 技术公开课(完)/25 Serverless 应用引擎产品的流量负载均衡和路由策略配置实践.md.html
@@ -179,21 +179,21 @@ function hide_canvas() {
25 Serverless 应用引擎产品的流量负载均衡和路由策略配置实践
流量管理从面向实例到面向应用
-
+
在 Serverless 场景下,由于弹性能力以及底层计算实例易变的特性,后端应用实例需要频繁上下线,传统的 ECS 场景下的负载均衡管理方式不再适用。
SAE 产品提供给用户面向应用的流量管理方式,不再需要关心弹性场景以及发布场景的实例上下线,仅仅需要关心监听的配置以及应用实例的健康检查探针,将面向实例的复杂配置工作交给 SAE 产品。
单应用的负载均衡配置
-
+
对于单个应用,SAE 产品支持将应用服务通过公网或私网 SLB 实例监听暴露,目前支持仅支持 TCP 协议。考虑到传统的 HTTP 类型应用存在 HTTPS 改造的需求,SAE 还支持配置 HTTPS 监听,让 HTTP 服务器无需修改就能够对外提供 HTTPS 服务。
公网 SLB 用于互联网客户端访问,会同时产生规格费与流量费用;私网 SLB 用于 VPC 内客户端访问,会产生规格费用。
为了让 SAE 产品能够准确控制实例上下线时机,用户需要在部署时正确地配置探针,避免业务出现损失。
多应用的路由策略配置
-
+
大中型企业在实践中,常常会将业务拆分成不同的应用或者服务,例如将登陆服务、账单服务等关联度较高的部分,单独拆分为应用,独立进行研发以及运维,再对外通过统一的网关服务进行暴露,对用户来说就像使用单体应用一样。
SAE 提供基于 SLB 实例的网关,将流量按照域名以及 HTTP Path 转发到不同的应用的实例上,从功能上对标业界的 Nginx 网关。
公网 SLB 实例实现的网关用于互联网客户端访问,会同时产生规格费与流量费用;私网 SLB 实例实现的网关用于 VPC 内客户端访问,会产生规格费用。
自建微服务网关
-
+
对于微服务场景中常见的微服务网关,SAE 并没有提供产品化的支持,但用户依然可以自由发挥,在 SAE 中部署自建的微服务网关。
实践中,微服务网关也可以作为一个应用,部署到 SAE 中。微服务网关会根据用户自定义的配置,将业务流量转发到提供微服务的实例中。微服务网关作为应用,也是可以通过 SLB 实例对公网以及私网暴露服务。
结语
diff --git a/专栏/Serverless 技术公开课(完)/26 Spring CloudDubbo 应用无缝迁移到 Serverless 架构.md.html b/专栏/Serverless 技术公开课(完)/26 Spring CloudDubbo 应用无缝迁移到 Serverless 架构.md.html
index 198825c7..43b8bfd2 100644
--- a/专栏/Serverless 技术公开课(完)/26 Spring CloudDubbo 应用无缝迁移到 Serverless 架构.md.html
+++ b/专栏/Serverless 技术公开课(完)/26 Spring CloudDubbo 应用无缝迁移到 Serverless 架构.md.html
@@ -207,12 +207,12 @@ function hide_canvas() {
26 Spring CloudDubbo 应用无缝迁移到 Serverless 架构
背景
-
+
通过前面几节课程的学习,相信大家对于 SAE 平台已经有了一定的了解,SAE 基于 IaaS 层资源构建的一款 Serverles 应用托管产品,免除了客户很多复杂的运维工作,开箱即用、按用量付费;并且提供了丰富的 Open API 可以很容易地与其他平台做集成。
本文将为大家介绍 SAE 在微服务方面的一些能力,SAE 产品把 Serverless 技术和微服务做了很好的结合,天然支持 Java 微服务应用的托管和服务治理,对 SpringCloud/Dubbo 微服务应用能够在只修改配置和依赖,不修改代码的情况下迁移到 SAE 上,并提供服务治理能力,比如基于租户的微服务隔离环境、服务列表、无损下线、离群摘除、应用监控以及调用链分析等。
本次课程分为三部分来介绍,分别介绍微服务应用迁移到 SAE 的优势,如何迁移 SpringCloud/Dubbo 应用到 SAE 上,以及针对 SpringCloud 应用迁移的实践演示。
迁移到 SAE 的优势
-
+
在介绍迁移之前,先介绍下 SpringCloud/Dubbo 应用迁移到 SAE 的优势:
- **SAE 内置注册中心:**所有用户共享注册中心组件,SAE 帮助用户运维,这就节省了用户的部署、运维成本;在服务注册和发现的过程中进行链路加密,无需担心被未授权的服务发现。
@@ -222,14 +222,14 @@ function hide_canvas() {
SpringCloud/Dubbo 迁移方案
那如何迁移 SpringCloud/Dubbo 应用到 SAE 呢?我们只需要修改添加依赖和配置,就可以把应用部署到 SAE 上。
-
+
Dubbo 应用需要添加 dubbo-register-nacos 和 nacos-client 依赖;SpringCloud 应用需要添加 spring-cloud-starter-alibaba-nacos-discovery 即可。
SpringCloud/Dubbo 应用迁移实战
Spring Cloud 提供了简化应用开发的一系列标准和规范。
目前业界流行的 Spring Cloud 具体实现有 Spring Cloud Netflix、Spring Cloud Consul、Spring Cloud Gateway 和 Spring Cloud Alibaba 等。
如果您熟悉 Spring Cloud 中的 Eureka、Consul 和 ZooKeeper 等服务注册组件,但未使用过 Spring Cloud Alibaba 的服务注册组件 Nacos Discovery,那么您仅需将服务注册组件的服务依赖关系和服务配置替换成 Spring Cloud Alibaba Nacos Discovery,无需修改任何代码。
Spring Cloud Alibaba Nacos Discovery 同样实现了 Spring Cloud Registry 的标准接口与规范,与您之前使用 Spring Cloud 接入服务注册与发现的方式基本一致。
-
+
接下来针对 SpringCloud 应用迁移过程进行演示,演示过程请点击视频课:https://developer.aliyun.com/lesson202619003 进行观看。
diff --git a/专栏/Serverless 技术公开课(完)/27 SAE 应用分批发布与无损下线的最佳实践.md.html b/专栏/Serverless 技术公开课(完)/27 SAE 应用分批发布与无损下线的最佳实践.md.html
index 5aa90ad8..c68d850f 100644
--- a/专栏/Serverless 技术公开课(完)/27 SAE 应用分批发布与无损下线的最佳实践.md.html
+++ b/专栏/Serverless 技术公开课(完)/27 SAE 应用分批发布与无损下线的最佳实践.md.html
@@ -188,7 +188,7 @@ function hide_canvas() {
有时候,我们把发版安排在凌晨两三点,赶在业务流量比较小的时候,心惊胆颤、睡眠不足、苦不堪言。那如何解决上面的问题,如何保证应用发布过程稳定、高效,保证业务无损呢?首先,我们来梳理下造成这些问题的原因。
场景分析
-
+
上图描述了我们使用微服务架构开发应用的一个常见场景,我们先看下这个场景的服务调用关系:
- 服务 B、C 把服务注册到注册中心,服务 A、B 从注册中心发现需要调用的服务;
@@ -200,14 +200,14 @@ function hide_canvas() {
当服务 A 发布的时候,服务 A1 实例停机后,SLB 根据健康检查探测到服务 A1 下线,然后把实例从 SLB 摘掉。实例 A1 依赖 SLB 的健康检查从 SLB 上摘掉,一般需要几秒到十几秒的时间,在这个过程中,如果 SLB 有持续的流量打入,就会造成一些请求继续路由到实例 A1,导致请求失败;
服务 A 在发布的过程中,如何保证经过 SLB 的流量不报错?我们接着看下 SAE 是如何做的。
南北向流量优雅升级方案
-
+
如上文所提,请求失败的原因在于后端服务实例先停止掉,然后才从 SLB 摘掉,那我们是不是可以先从 SLB 摘掉服务实例,然后再对实例进行升级呢?
按照这个思路,SAE 基于 K8S service 的能力给出了一种方案,当用户在通过 SAE 为应用绑定 SLB 时,SAE 会在集群中创建一个 service 资源,并把应用的实例和 service 关联,CCM 组件会负责 SLB 的购买、SLB 虚拟服务器组的创建,并且把应用实例关联的 ENI 网卡添加到虚拟服务器组中,用户可以通过 SLB 来访问应用实例;当应用发布时,CCM 会先把实例对应的 ENI 从虚拟服务器组中摘除,然后再对实例进行升级,从而保证流量不丢失。
这就是 SAE 对于应用升级过程中关于南北向流量的保障方案。
东西向流量
东西向流量存在问题
在讨论完南北向流量的解决方案后,我们再看下东西向流量,传统的发布流程中,服务提供者停止再启动,服务消费者感知到服务提供者节点停止的流程如下:
-
+
- 服务发布前,消费者根据负载均衡规则调用服务提供者,业务正常。
- 服务提供者 B 需要发布新版本,先对其中的一个节点进行操作,首先是停止 java 进程。
@@ -220,7 +220,7 @@ function hide_canvas() {
从第 2 步到第 6 步的过程中,Eureka 在最差的情况下需要耗时 2 分钟,Nacos 在最差的情况下需要耗时 50 秒。在这段时间内,请求都有可能出现问题,所以发布时会出现各种报错,同时还影响用户的体验,发布后又需要修复执行到一半的脏数据。最后不得不每次发版都安排在凌晨两三点发布,心惊胆颤,睡眠不足,苦不堪言。
东西向流量优雅升级方案
-
+
经过上文的分析,我们看,在传统发布流程中,客户端有一个服务调用报错期,原因就是客户端没有及时感知到服务端下线的实例。在传统发布流程中,主要是借助注册中心通知消费者来更新服务提供者列表,那能不能绕过注册中心,服务提供者直接通知服务消费者呢?答案是肯定的,我们主要做了两件事情:
- 服务提供者应用在发布前后主动向注册中心注销应用,并将应用标记为已下线的状态;将原来的停止进程阶段注销服务变成了 prestop 阶段注销服务。
@@ -230,10 +230,10 @@ function hide_canvas() {
分批发布和灰度发布
上文介绍的是 SAE 在处理优雅下线方面的一些能力,在应用升级的过程中,只有实例的优雅下线是不够的,还需要有一套配套的发布策略,保证我们新业务是可用的,SAE 提供分批发布和灰度发布的能力,可以使得应用的发布过程更加省心省力;
我们先介绍下灰度发布,某应用包含 10 个应用实例,每个应用实例的部署版本为 Ver.1 版本,现需将每个应用实例升级为 Ver.2 版本。
-
+
从图中可以看出,在发布的过程中先灰度 2 台实例,在确认业务正常后,再分批发布剩余的实例,发布的过程中始终有实例处于运行状态,实例升级过程中依照上面的方案,每个实例都有优雅下线的过程,这就保证了业务无损。
再来看下分批发布,分批发布支持手动、自动分批;还是上面的 10 个应用实例,假设将所有应用实例分 3 批进行部署,根据分批发布策略,该发布流程如图所示,就不再具体介绍了。
-
+
最后针对在 SAE 上应用灰度发布的过程进行演示,点击即可观看演示过程:https://developer.aliyun.com/lesson202619009
diff --git a/专栏/Serverless 技术公开课(完)/29 SAE 极致应用部署效率.md.html b/专栏/Serverless 技术公开课(完)/29 SAE 极致应用部署效率.md.html
index 89d290cf..eb1e6648 100644
--- a/专栏/Serverless 技术公开课(完)/29 SAE 极致应用部署效率.md.html
+++ b/专栏/Serverless 技术公开课(完)/29 SAE 极致应用部署效率.md.html
@@ -182,7 +182,7 @@ function hide_canvas() {
接下来将介绍我们在应用创建、部署、重启等过程所做的效率优化工作。
应用创建
首先是应用创建。目前,用户界面可通过镜像或 war、jar 安装包的方式部署应用,最后在平台侧,以统一打包成容器镜像的方式进行分发,然后平台去申请计算、存储、网络等 IAAS 资源,再开始创建容器执行环境和应用实例。
-
+
在这个过程中,涉及到调度、云资源创建和挂载、镜像拉取、容器环境创建、应用进程创建等步骤,应用的创建效率与这些过程紧密相关。
我们很自然而然地能想到,这其中部分过程是否能并行,以减少整个创建的耗时呢?经过对每个过程的耗时分析,我们发现其中的一些瓶颈点,并且部分执行步骤之间是解耦独立的,比如云弹性网卡的创建挂载和应用镜像拉取,就是相互独立的过程。基于此,我们将其中独立的过程做了并行化处理,在不影响创建链路的同时,降低了应用创建的时耗。
应用部署
@@ -199,7 +199,7 @@ function hide_canvas() {
摘流,将运行实例从 SLB 后端摘除 -> 原地升级实例 -> 接入流量
原地升级后,应用实例仍保持原来的 ip。经过测试,对于 2 实例应用,部署效率将提升 4 倍,将部署时长从原来的将近 1 分钟缩短到十几秒。
-
+
应用重启
最后,简单介绍下我们即将推出的原地重启功能。
重启实例在某些运维场合是必要的操作,说到应用重启,我们希望类似于 linux 系统一样,可以只执行一次 reboot,而不是重建实例。具体的做法是,我们在容器环境下,通过容器引擎 API 执行一次启停操作即可。原地重启相比原地升级,省去了镜像更新和执行环境创建的过程,并且相比 ECS,容器的重启更轻量,能达到秒级。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/00 如何正确学习一款分库分表开源框架?.md.html b/专栏/ShardingSphere 核心原理精讲-完/00 如何正确学习一款分库分表开源框架?.md.html
index 97b853f7..15ce3a8e 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/00 如何正确学习一款分库分表开源框架?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/00 如何正确学习一款分库分表开源框架?.md.html
@@ -221,9 +221,9 @@ function hide_canvas() {
这些优秀的特性,让 ShardingSphere 在分库分表中间件领域占据了领先地位,并被越来越多的知名企业(比如京东、当当、电信、中通快递、哔哩哔哩等)用来构建自己强大而健壮的数据平台。如果你苦于找不到一款成熟稳定的分库分表中间件,那么 ShardingSphere 恰能帮助你解决这个痛点。
你为什么需要学习这个课程?
但凡涉及海量数据处理的企业,就一定要用到分库分表。如何进行海量数据的分库分表设计和迁移,有效存储和访问海量业务数据,已经成为很多架构师和开发人员需要规划和落实的一大课题,也成为像拼多多、趣头条、爱库存等很多优质公司高薪诚聘的岗位需求。
-
-
-
+
+
+
但优质人才非常短缺,一是因为从事海量数据处理需要相应的应用场景和较高的技术门槛,二是业界也缺乏成熟的框架来完成实际需求。掌握诸如 ShardingSphere 这样的主流分库分表和分布式数据库中间件框架的技术人员也成了各大公司争抢的对象。
鉴于市面上还没有对 ShardingSphere 进行系统化介绍的内容,我希望能来弥补这个空白。此外,分库分表概念虽然比较简单,但在实际开发过程中要落地却也不容易,也需要一个系统的、由浅入深的学习过程。
课程设计
@@ -245,7 +245,7 @@ function hide_canvas() {
帮你理解 ShardingSphere 的核心功能特性,来满足日常开发工作所需,同时基于源码给出这些功能的设计原理和实现机制。
2. 学习优秀的开源框架,提高技术理解与应用能力
技术原理是具有相通性的。以 ZooKeeper 这个分布式协调框架为例,ShardingSphere 和 Dubbo 中都使用它来完成了注册中心的构建:
-
+
在 ShardingSphere 中,我们可以基于 ZooKeeper 提供的动态监听机制来判断某个数据库实例是否可用、是否需要对某个数据库实例进行数据访问熔断等操作,也可以使用 ZooKeeper 的这一功能特性来实现分布式环境下的配置信息动态管理。
随着对 ShardingSphere 的深入学习,你会发现类似的例子还有很多,包括基于 SPI 机制的微内核架构、基于雪花算法的分布式主键、基于 Apollo 的配置中心、基于 Nacos 的注册中心、基于 Seata 的柔性事务、基于 OpenTracing 规范的链路跟踪等。
而这些技术体系在 Dubbo、Spring Cloud 等主流开发框架中也多有体现。因此这个课程除了可以强化你对这些技术体系的系统化理解,还可以让你掌握这些技术体系的具体应用场景和实现方式,从而实现触类旁通。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/01 从理论到实践:如何让分库分表真正落地?.md.html b/专栏/ShardingSphere 核心原理精讲-完/01 从理论到实践:如何让分库分表真正落地?.md.html
index bd2aec02..fa722ba1 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/01 从理论到实践:如何让分库分表真正落地?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/01 从理论到实践:如何让分库分表真正落地?.md.html
@@ -216,16 +216,16 @@ function hide_canvas() {
分库分表的表现形式也有很多种,一起来看一下。
分库分表的表现形式
分库分表包括分库和分表两个维度,在开发过程中,对于每个维度都可以采用两种拆分思路,即垂直拆分和水平拆分:
-
+
先来讨论垂直拆分的应用方式,相比水平拆分,垂直拆分相对比较容易理解和实现。在电商系统中,用户在打开首页时,往往会加载一些用户性别、地理位置等基础数据。对于用户表而言,这些位于首页的基础数据访问频率显然要比用户头像等数据更高。基于这两种数据的不同访问特性,可以把用户单表进行拆分,将访问频次低的用户头像等信息单独存放在一张表中,把访问频次较高的用户信息单独放在另一张表中:
-
+
从这里可以看到,垂直分表的处理方式就是将一个表按照字段分成多张表,每个表存储其中一部分字段。 在实现上,我们通常会把头像等 blob 类型的大字段数据或热度较低的数据放在一张独立的表中,将经常需要组合查询的列放在一张表中,这也可以认为是分表操作的一种表现形式。
通过垂直分表能得到一定程度的性能提升,但数据毕竟仍然位于同一个数据库中,也就是把操作范围限制在一台服务器上,每个表还是会竞争同一台服务器中的 CPU、内存、网络 IO 等资源。基于这个考虑,在有了垂直分表之后,就可以进一步引入垂直分库。
对于前面介绍的场景,分表之后的用户信息同样还是跟其他的商品、订单信息存放在同一台服务器中。基于垂直分库思想,这时候就可以把用户相关的数据表单独拆分出来,放在一个独立的数据库中。
-
+
这样的效果就是垂直分库。从定义上讲,垂直分库是指按照业务将表进行分类,然后分布到不同的数据库上。然后,每个库可以位于不同的服务器上,其核心理念是专库专用。而从实现上讲,垂直分库很大程度上取决于业务的规划和系统边界的划分。比如说,用户数据的独立拆分就需要考虑到系统用户体系与其他业务模块之间的关联关系,而不是简单地创建一个用户库就可以了。在高并发场景下,垂直分库能够在一定程度上提升 IO 访问效率和数据库连接数,并降低单机硬件资源的瓶颈。
从前面的分析中我们不难明白,垂直拆分尽管实现起来比较简单,但并不能解决单表数据量过大这一核心问题。所以,现实中我们往往需要在垂直拆分的基础上添加水平拆分机制。例如,可以对用户库中的用户信息按照用户 ID 进行取模,然后分别存储在不同的数据库中,这就是水平分库的常见做法:
-
+
可以看到,水平分库是把同一个表的数据按一定规则拆分到不同的数据库中,每个库同样可以位于不同的服务器上。这种方案往往能解决单库存储量及性能瓶颈问题,但由于同一个表被分配在不同的数据库中,数据的访问需要额外的路由工作,因此大大提升了系统复杂度。这里所谓的规则实际上就是一系列的算法,常见的包括:
- 取模算法,取模的方式有很多,比如前面介绍的按照用户 ID 进行取模,当然也可以通过表的一列或多列字段进行 hash 求值来取模;
@@ -233,7 +233,7 @@ function hide_canvas() {
- 预定义算法,是指事先规划好具体库或表的数量,然后直接路由到指定库或表中。
按照水平分库的思路,也可以对用户库中的用户表进行水平拆分,效果如下图所示。也就是说,水平分表是在同一个数据库内,把同一个表的数据按一定规则拆到多个表中。
-
+
显然,系统的数据存储架构演变到现在已经非常复杂了。与拆分前的单库单表相比,现在面临着一系列具有挑战性的问题,比如:
- 如何对多数据库进行高效治理?
@@ -247,10 +247,10 @@ function hide_canvas() {
如果没有很好的工具来支持数据的存储和访问,数据一致性将很难得到保障,这就是以 ShardingSphere 为代表的分库分表中间件的价值所在。
分库分表与读写分离
说到分库分表,我们不得不介绍另一个解决数据访问瓶颈的技术体系:读写分离,这个技术与数据库主从架构有关。我们知道像 MySQL 这样的数据库提供了完善的主从架构,能够确保主数据库与从数据库之间的数据同步。基于主从架构,就可以按照操作要求对读操作和写操作进行分离,从而提升访问效率。读写分离的基本原理是这样的:
-
+
可以看到图中的数据库集群中存在一个主库,也存在一个从库,主库和从库之间通过同步机制实现两者数据的一致性。在互联网系统中,普遍认为对数据库读操作的频率要远远高于写操作,所以瓶颈往往出现在读操作上。通过读写分离,就可以把读操作分离出来,在独立的从库上进行。现实中的主从架构,主库和从库的数量,尤其从库的数量都是可以根据数据量的大小进行扩充的。
读写分离,主要解决的就是高并发下的数据库访问,也是一种常用的解决方案。但是跟提升服务器配置一样,并不是终极解决方案。终极的解决方案还是前面介绍的分库分表,按照用户 ID 等规则来拆分库或拆分表。但是,请注意,分库分表与读写分离之间的关系并不是互斥的,而是可以相辅相成的,完全可以在分库分表的基础上引入读写分离机制:
-
+
事实上,本课程所要介绍的 ShardingSphere 就实现了图中的架构方案,在分库分表的同时支持读写分离,在后续的课程中将会介绍如何实现这一过程。
分库分表解决方案和代表框架
基于前面关于分库分表的讨论,我们可以抽象其背后的一个核心概念,即分片(Sharding)。无论是分库还是分表,都是把数据划分成不同的数据片,并存储在不同的目标对象中。而具体的分片方式涉及实现分库分表的不同解决方案。
@@ -258,20 +258,20 @@ function hide_canvas() {
客户端分片
所谓客户端分片,相当于在数据库的客户端就实现了分片规则。显然,这种方式将分片处理的工作进行前置,客户端管理和维护着所有的分片逻辑,并决定每次 SQL 执行所对应的目标数据库和数据表。
客户端分片这一解决方案也有不同的表现形式,其中最为简单的方式就是应用层分片,也就是说在应用程序中直接维护着分片规则和分片逻辑:
-
+
在具体实现上,我们通常会将分片规则的处理逻辑打包成一个公共 JAR 包,其他业务开发人员只需要在代码工程中引入这个 JAR 包即可。针对这种方案,因为没有独立的服务器组件,所以也不需要专门维护某一个具体的中间件。然而,这种直接在业务代码中嵌入分片组件的方法也有明显的缺点:
- 一方面,由于分片逻辑侵入到了业务代码中,业务开发人员在理解业务的基础上还需要掌握分片规则的处理方式,增加了开发和维护成本;
- 另一方面,一旦出现问题,也只能依赖业务开发人员通过分析代码来找到原因,而无法把这部分工作抽离出来让专门的中间件团队进行完成。
基于以上分析,客户端分片在实现上通常会进一步抽象,把分片规则的管理工作从业务代码中剥离出来,形成单独演进的一套体系。这方面典型的设计思路是重写 JDBC 协议,也就是说在 JDBC 协议层面嵌入分片规则。这样,业务开发人员还是使用与 JDBC 规范完全兼容的一套 API 来操作数据库,但这套 API 的背后却自动完成了分片操作,从而实现了对业务代码的零侵入:
-
+
客户端分片结构:重写JDBC协议
这种解决方案的优势在于,分片操作对于业务而言是完全透明的,从而一定程度上实现业务开发人员与数据库中间件团队在职责上的分离。这样,业务开发人员只需要理解 JDBC 规范就可以完成分库分表,开发难度以及代码维护成本得到降低。
对于客户端分片,典型的中间件包括阿里巴巴的 TDDL 以及本课程将要介绍的 ShardingSphere。因为 TDDL 并没有开源,所以无法判断客户端分片的具体实现方案。而对于 ShardingSphere 而言,它是重写 JDBC 规范以实现客户端分片的典型实现框架。
代理服务器分片
代理服务器分片的解决方案也比较明确,也就是采用了代理机制,在应用层和数据库层之间添加一个代理层。有了代理层之后,就可以把分片规则集中维护在这个代理层中,并对外提供与 JDBC 兼容的 API 给到应用层。这样,应用层的业务开发人员就不用关心具体的分片规则,而只需要完成业务逻辑的实现:
-
+
显然,代理服务器分片的优点在于解放了业务开发人员对分片规则的管理工作,而缺点就是添加了一层代理层,所以天生具有代理机制所带来的一些问题,比方说因为新增了一层网络传输对性能所产生的影响。
对于代理服务器分片,常见的开源框架有阿里的 Cobar 以及民间开源社区的 MyCat。而在 ShardingSphere 3.X 版本中,也添加了 Sharding-Proxy 模块来实现代理服务器分片。
分布式数据库
diff --git a/专栏/ShardingSphere 核心原理精讲-完/02 顶级项目:ShardingSphere 是一款什么样的 Apache 开源软件?.md.html b/专栏/ShardingSphere 核心原理精讲-完/02 顶级项目:ShardingSphere 是一款什么样的 Apache 开源软件?.md.html
index 627cbbf0..2e1939ac 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/02 顶级项目:ShardingSphere 是一款什么样的 Apache 开源软件?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/02 顶级项目:ShardingSphere 是一款什么样的 Apache 开源软件?.md.html
@@ -203,11 +203,11 @@ function hide_canvas() {
在上一课时中,我详细分析了分库分表的表现形式以及分片架构的解决方案和代表性框架。可以看到,ShardingSphere 同时实现了客户端分片和代理服务器组件,并提供了分布式数据库的相关功能特性。作为一款优秀的开源软件,ShardingSphere 能够取得目前的成就也不是一蹴而就,下面我们先来回顾一下 ShardingSphere 的发展历程。
ShardingSphere 的发展历程:从 Sharding-JDBC 到 Apache 顶级项目
说到 ShardingSphere 的起源,我们不得不提 Sharding-JDBC 框架,该框架是一款起源于当当网内部的应用框架,并于 2017 年初正式开源。从 Sharding-JDBC 到 Apache 顶级项目,ShardingSphere 的发展经历了不同的演进阶段。纵观整个 ShardingSphere 的发展历史,我们可以得到时间线与阶段性里程碑的演进过程图:
-
+
从版本发布角度,我们也可以进一步梳理 ShardingSphere 发展历程中主线版本与核心功能之间的演进关系图:
-
+
基于 GitHub 上星数的增长轨迹,也可以从另一个维度很好地反映出 ShardingSphere 的发展历程:
-
+
ShardingSphere 的设计理念:不是颠覆,而是兼容
对于一款开源中间件来说,要得到长足的发展,一方面依赖于社区的贡献,另外在很大程度上还取决于自身的设计和发展理念。
ShardingSphere 的定位非常明确,就是一种关系型数据库中间件,而并非一个全新的关系型数据库。ShardingSphere 认为,在当下,关系型数据库依然占有巨大市场,但凡涉及数据的持久化,关系型数据库仍然是系统的标准配置,也是各个公司核心业务的基石,在可预见的未来中,这点很难撼动。所以,ShardingSphere 在当前阶段更加关注在原有基础上进行兼容和扩展,而非颠覆。那么 ShardingSphere 是如何做到这一点呢?
@@ -215,20 +215,20 @@ function hide_canvas() {
Sharding-JDBC
ShardingSphere 的前身是 Sharding-JDBC,所以这是整个框架中最为成熟的组件。Sharding-JDBC 的定位是一个轻量级 Java 框架,在 JDBC 层提供了扩展性服务。我们知道 JDBC 是一种开发规范,指定了 DataSource、Connection、Statement、PreparedStatement、ResultSet 等一系列接口。而各大数据库供应商通过实现这些接口提供了自身对 JDBC 规范的支持,使得 JDBC 规范成为 Java 领域中被广泛采用的数据库访问标准。
基于这一点,Sharding-JDBC 一开始的设计就完全兼容 JDBC 规范,Sharding-JDBC 对外暴露的一套分片操作接口与 JDBC 规范中所提供的接口完全一致。开发人员只需要了解 JDBC,就可以使用 Sharding-JDBC 来实现分库分表,Sharding-JDBC 内部屏蔽了所有的分片规则和处理逻辑的复杂性。显然,这种方案天生就是一种具有高度兼容性的方案,能够为开发人员提供最简单、最直接的开发支持。关于 Sharding-JDBC 与 JDBC 规范的兼容性话题,我们将会在下一课时中详细讨论。
-
+
Sharding-JDBC 与 JDBC 规范的兼容性示意图
在实际开发过程中,Sharding-JDBC 以 JAR 包的形式提供服务。开发人员可以使用这个 JAR 包直连数据库,无需额外的部署和依赖管理。在应用 Sharding-JDBC 时,需要注意到 Sharding-JDBC 背后依赖的是一套完整而强大的分片引擎:
-
+
由于 Sharding-JDBC 提供了一套与 JDBC 规范完全一致的 API,所以它可以很方便地与遵循 JDBC 规范的各种组件和框架进行无缝集成。例如,用于提供数据库连接的 DBCP、C3P0 等数据库连接池组件,以及用于提供对象-关系映射的 Hibernate、MyBatis 等 ORM 框架。当然,作为一款支持多数据库的开源框架,Sharding-JDBC 支持 MySQL、Oracle、SQLServer 等主流关系型数据库。
Sharding-Proxy
ShardingSphere 中的 Sharding-Proxy 组件定位为一个透明化的数据库代理端,所以它是代理服务器分片方案的一种具体实现方式。在代理方案的设计和实现上,Sharding-Proxy 同样充分考虑了兼容性。
Sharding-Proxy 所提供的兼容性首先体现在对异构语言的支持上,为了完成对异构语言的支持,Sharding-Proxy 专门对数据库二进制协议进行了封装,并提供了一个代理服务端组件。其次,从客户端组件上讲,针对目前市面上流行的 Navicat、MySQL Command Client 等客户端工具,Sharding-Proxy 也能够兼容遵循 MySQL 和 PostgreSQL 协议的各类访问客户端。当然,和 Sharding-JDBC 一样,Sharding-Proxy 也支持 MySQL 和 PostgreSQL 等多种数据库。
接下来,我们看一下 Sharding-Proxy 的整体架构。对于应用程序而言,这种代理机制是完全透明的,可以直接把它当作 MySQL 或 PostgreSQL 进行使用:
-
+
总结一下,我们可以直接把 Sharding-Proxy 视为一个数据库,用来代理后面分库分表的多个数据库,它屏蔽了后端多个数据库的复杂性。同时,也看到 Sharding-Proxy 的运行同样需要依赖于完成分片操作的分片引擎以及用于管理数据库的治理组件。
虽然 Sharding-JDBC 和 Sharding-Proxy 具有不同的关注点,但事实上,我们完全可以将它们整合在一起进行使用,也就是说这两个组件之间也存在兼容性。
前面已经介绍过,我们使用 Sharding-JDBC 的方式是在应用程序中直接嵌入 JAR 包,这种方式适合于业务开发人员。而 Sharding-Proxy 提供静态入口以及异构语言的支持,适用于需要对分片数据库进行管理的中间件开发和运维人员。基于底层共通的分片引擎,以及数据库治理功能,可以混合使用 Sharding-JDBC 和 Sharding-Proxy,以便应对不同的应用场景和不同的开发人员:
-
+
Sharding-Sidecar
Sidecar 设计模式受到了越来越多的关注和采用,这个模式的目标是把系统中各种异构的服务组件串联起来,并进行高效的服务治理。ShardingSphere 也基于该模式设计了 Sharding-Sidecar 组件。截止到目前,ShardingSphere 给出了 Sharding-Sidecar 的规划,但还没有提供具体的实现方案,这里不做具体展开。作为 Sidecar 模式的具体实现,我们可以想象 Sharding-Sidecar** 的作用就是以 Sidecar 的形式代理所有对数据库的访问**。这也是一种兼容性的设计思路,通过无中心、零侵入的方案将分布式的数据访问应用与数据库有机串联起来。
ShardingSphere 的核心功能:从数据分片到编排治理
@@ -239,7 +239,7 @@ Sharding-JDBC 与 JDBC 规范的兼容性示意图
- 微内核架构
ShardingSphere 在设计上采用了微内核(MicroKernel)架构模式,来确保系统具有高度可扩展性。微内核架构包含两部分组件,即内核系统和插件。使用微内核架构对系统进行升级,要做的只是用新插件替换旧插件,而不需要改变整个系统架构:
-
+
在 ShardingSphere 中,抽象了一大批插件接口,包含用实现 SQL 解析的 SQLParserEntry、用于实现配置中心的 ConfigCenter、用于数据脱敏的 ShardingEncryptor,以及用于数据库治理的注册中心接口 RegistryCenter 等。开发人员完全可以根据自己的需要,基于这些插件定义来提供定制化实现,并动态加载到 ShardingSphere 运行时环境中。
- 分布式主键
diff --git a/专栏/ShardingSphere 核心原理精讲-完/03 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?.md.html b/专栏/ShardingSphere 核心原理精讲-完/03 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?.md.html
index 9fcd1e48..60d6d04a 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/03 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/03 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
这个问题非常重要,值得我们专门花一个课时的内容来进行分析和讲解。可以说,理解 JDBC 规范以及 ShardingSphere 对 JDBC 规范的重写方式,是正确使用 ShardingSphere 实现数据分片的前提。今天,我们就深入讨论 JDBC 规范与 ShardingSphere 的这层关系,帮你从底层设计上解开其中的神奇之处。
JDBC 规范简介
ShardingSphere 提供了与 JDBC 规范完全兼容的实现过程,在对这一过程进行详细展开之前,先来回顾一下 JDBC 规范。JDBC(Java Database Connectivity)的设计初衷是提供一套用于各种数据库的统一标准,而不同的数据库厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用。作为统一标准,JDBC 规范具有完整的架构体系,如下图所示:
-
+
JDBC 架构中的 Driver Manager 负责加载各种不同的驱动程序(Driver),并根据不同的请求,向调用者返回相应的数据库连接(Connection)。而应用程序通过调用 JDBC API 来实现对数据库的操作。对于开发人员而言,JDBC API 是我们访问数据库的主要途径,也是 ShardingSphere 重写 JDBC 规范并添加分片功能的入口。如果我们使用 JDBC 开发一个访问数据库的处理流程,常见的代码风格如下所示:
// 创建池化的数据源
PooledDataSource dataSource = new PooledDataSource ();
@@ -239,10 +239,10 @@ connection.close();
}
可以看到,DataSource 接口提供了两个获取 Connection 的重载方法,并继承了 CommonDataSource 接口,该接口是 JDBC 中关于数据源定义的根接口。除了 DataSource 接口之外,它还有两个子接口:
-
+
其中,DataSource 是官方定义的获取 Connection 的基础接口,ConnectionPoolDataSource 是从连接池 ConnectionPool 中获取的 Connection 接口。而 XADataSource 则用来实现在分布式事务环境下获取 Connection,我们在讨论 ShardingSphere 的分布式事务时会接触到这个接口。
请注意,DataSource 接口同时还继承了一个 Wrapper 接口。从接口的命名上看,可以判断该接口应该起到一种包装器的作用,事实上,由于很多数据库供应商提供了超越标准 JDBC API 的扩展功能,所以,Wrapper 接口可以把一个由第三方供应商提供的、非 JDBC 标准的接口包装成标准接口。以 DataSource 接口为例,如果我们想要实现自己的数据源 MyDataSource,就可以提供一个实现了 Wrapper 接口的 MyDataSourceWrapper 类来完成包装和适配:
-
+
在 JDBC 规范中,除了 DataSource 之外,Connection、Statement、ResultSet 等核心对象也都继承了这个接口。显然,ShardingSphere 提供的就是非 JDBC 标准的接口,所以也应该会用到这个 Wrapper 接口,并提供了类似的实现方案。
Connection
DataSource 的目的是获取 Connection 对象,我们可以把 Connection 理解为一种会话(Session)机制。Connection 代表一个数据库连接,负责完成与数据库之间的通信。所有 SQL 的执行都是在某个特定 Connection 环境中进行的,同时它还提供了一组重载方法,分别用于创建 Statement 和 PreparedStatement。另一方面,Connection 也涉及事务相关的操作,为了实现分片操作,ShardingSphere 同样也实现了定制化的 Connection 类 ShardingConnection。
@@ -252,14 +252,14 @@ connection.close();
ResultSet
一旦通过 Statement 或 PreparedStatement 执行了 SQL 语句并获得了 ResultSet 对象后,那么就可以通过调用 Resulset 对象中的 next() 方法遍历整个结果集。如果 next() 方法返回为 true,就意味结果集中存在数据,则可以调用 ResultSet 对象的一系列 getXXX() 方法来取得对应的结果值。对于分库分表操作而言,因为涉及从多个数据库或数据表中获取目标数据,势必需要对获取的结果进行归并。因此,ShardingSphere 中也提供了分片环境下的 ShardingResultSet 对象。
作为总结,我们梳理了基于 JDBC 规范进行数据库访问的开发流程图,如下图所示:
-
+
ShardingSphere 提供了与 JDBC 规范完全兼容的 API。也就是说,开发人员可以基于这个开发流程和 JDBC 中的核心接口完成分片引擎、数据脱敏等操作,我们来看一下。
基于适配器模式的 JDBC 重写实现方案
在 ShardingSphere 中,实现与 JDBC 规范兼容性的基本策略就是采用了设计模式中的适配器模式(Adapter Pattern)。适配器模式通常被用作连接两个不兼容接口之间的桥梁,涉及为某一个接口加入独立的或不兼容的功能。
作为一套适配 JDBC 规范的实现方案,ShardingSphere 需要对上面介绍的 JDBC API 中的 DataSource、Connection、Statement 及 ResultSet 等核心对象都完成重写。虽然这些对象承载着不同功能,但重写机制应该是共通的,否则就需要对不同对象都实现定制化开发,显然,这不符合我们的设计原则。为此,ShardingSphere 抽象并开发了一套基于适配器模式的实现方案,整体结构是这样的,如下图所示:
-
+
首先,我们看到这里有一个 JdbcObject 接口,这个接口泛指 JDBC API 中的 DataSource、Connection、Statement 等核心接口。前面提到,这些接口都继承自包装器 Wrapper 接口。ShardingSphere 为这个 Wrapper 接口提供了一个实现类 WrapperAdapter,这点在图中得到了展示。在 ShardingSphere 代码工程 sharding-jdbc-core 的 org.apache.shardingsphere.shardingjdbc.jdbc.adapter 包中包含了所有与 Adapter 相关的实现类:
-
+
在 ShardingSphere 基于适配器模式的实现方案图的底部,有一个 ShardingJdbcObject 类的定义。这个类也是一种泛指,代表 ShardingSphere 中用于分片的 ShardingDataSource、ShardingConnection、ShardingStatement 等对象。
最后发现 ShardingJdbcObject 继承自一个 AbstractJdbcObjectAdapter,而 AbstractJdbcObjectAdapter 又继承自 AbstractUnsupportedOperationJdbcObject,这两个类都是抽象类,而且也都泛指一组类。两者的区别在于,AbstractJdbcObjectAdapter 只提供了针对 JdbcObject 接口的一部分实现方法,这些方法是我们完成分片操作所需要的。而对于那些我们不需要的方法实现,则全部交由 AbstractUnsupportedOperationJdbcObject 进行实现,这两个类的所有方法的合集,就是原有 JdbcObject 接口的所有方法定义。
这样,我们大致了解了 ShardingSphere 对 JDBC 规范中核心接口的重写机制。这个重写机制非常重要,在 ShardingSphere 中应用也很广泛,我们可以通过示例对这一机制做进一步理解。
@@ -267,10 +267,10 @@ connection.close();
通过前面的介绍,我们知道 ShardingSphere 的分片引擎中提供了一系列 ShardingJdbcObject 来支持分片操作,包括 ShardingDataSource、ShardingConnection、ShardingStatement、ShardingPreparedStament 等。这里以最具代表性的 ShardingConnection 为例,来讲解它的实现过程。请注意,今天我们关注的还是重写机制,不会对 ShardingConnection 中的具体功能以及与其他类之间的交互过程做过多展开讲解。
ShardingConnection 类层结构
ShardingConnection 是对 JDBC 中 Connection 的适配和包装,所以它需要提供 Connection 接口中定义的方法,包括 createConnection、getMetaData、各种重载的 prepareStatement 和 createStatement 以及针对事务的 setAutoCommit、commit 和 rollback 方法等。ShardingConnection 对这些方法都进行了重写,如下图所示:
-
+
ShardingConnection 中的方法列表图
ShardingConnection 类的一条类层结构支线就是适配器模式的具体应用,这部分内容的类层结构与前面介绍的重写机制的类层结构是完全一致的,如下图所示:
-
+
AbstractConnectionAdapter
我们首先来看看 AbstractConnectionAdapter 抽象类,ShardingConnection 直接继承了它。在 AbstractConnectionAdapter 中发现了一个 cachedConnections 属性,它是一个 Map 对象,该对象其实缓存了这个经过封装的 ShardingConnection 背后真实的 Connection 对象。如果我们对一个 AbstractConnectionAdapter 重复使用,那么这些 cachedConnections 也会一直被缓存,直到调用 close 方法。可以从 AbstractConnectionAdapter 的 getConnections 方法中理解具体的操作过程:
public final List<Connection> getConnections(final ConnectionMode connectionMode, final String dataSourceName, final int connectionSize) throws SQLException {
diff --git a/专栏/ShardingSphere 核心原理精讲-完/04 应用集成:在业务系统中使用 ShardingSphere 的方式有哪些?.md.html b/专栏/ShardingSphere 核心原理精讲-完/04 应用集成:在业务系统中使用 ShardingSphere 的方式有哪些?.md.html
index e9962e71..1fe794f1 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/04 应用集成:在业务系统中使用 ShardingSphere 的方式有哪些?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/04 应用集成:在业务系统中使用 ShardingSphere 的方式有哪些?.md.html
@@ -202,7 +202,7 @@ function hide_canvas() {
在上一课时中,我详细介绍了 ShardingSphere 与 JDBC 规范之间的兼容性关系,我们知道 ShardingSphere 对 JDBC 规范进行了重写,并嵌入了分片机制。基于这种兼容性,开发人员使用 ShardingSphere 时就像在使用 JDBC 规范所暴露的各个接口一样。这一课时,我们将讨论如何在业务系统中使用 ShardingSphere 的具体方式。
如何抽象开源框架的应用方式?
当我们自己在设计和实现一款开源框架时,如何规划它的应用方式呢?作为一款与数据库访问相关的开源框架,ShardingSphere 提供了多个维度的应用方式,我们可以对这些应用方式进行抽象,从而提炼出一种模版。这个模版由四个维度组成,分别是底层工具、基础规范、开发框架和领域框架,如下图所示:
-
+
底层工具
底层工具指的是这个开源框架所面向的目标工具或所依赖的第三方工具。这种底层工具往往不是框架本身可以控制和管理的,框架的作用只是在它上面添加一个应用层,用于封装对这些底层工具的使用方式。
对于 ShardingSphere 而言,这里所说的底层工具实际上指的是关系型数据库。目前,ShardingSphere 支持包括 MySQL、Oracle、SQLServer、PostgreSQL 以及任何遵循 SQL92 标准的数据库。
@@ -247,7 +247,7 @@ spring.shardingsphere.datasource.test_datasource.password=root
开发框架集成
从上面所介绍的配置信息中,你实际上已经看到了 ShardingSphere 中集成的两款主流开发框架,即 Spring 和 Spring Boot,它们都对 JDBC 规范做了封装。当然,对于没有使用或无法使用 Spring 家族框架的场景,我们也可以直接在原生 Java 应用程序中使用 ShardingSphere。
在介绍开发框架的具体集成方式之前,我们来设计一个简单的应用场景。假设系统中存在一个用户表 User,这张表的数据量比较大,所以我们将它进行分库分表处理,计划分成两个数据库 ds0 和 ds1,然后每个库中再分成两张表 user0 和 user1:
-
+
接下来,让我们来看一下如何基于 Java 原生、Spring 及 Spring Boot 开发框架针对这一场景实现分库分表。
Java 原生
如果使用 Java 原生的开发方式,相当于我们需要全部通过 Java 代码来创建和管理 ShardingSphere 中与分库分表相关的所有类。如果不做特殊说明,本课程将默认使用 Maven 实现包依赖关系的管理。所以,首先需要引入对 sharding-jdbc-core 组件的 Maven 引用:
diff --git a/专栏/ShardingSphere 核心原理精讲-完/05 配置驱动:ShardingSphere 中的配置体系是如何设计的?.md.html b/专栏/ShardingSphere 核心原理精讲-完/05 配置驱动:ShardingSphere 中的配置体系是如何设计的?.md.html
index c67ca99e..ccce3048 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/05 配置驱动:ShardingSphere 中的配置体系是如何设计的?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/05 配置驱动:ShardingSphere 中的配置体系是如何设计的?.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
在引入配置体系的学习之前,我们先来介绍 ShardingSphere 框架为开发人员提供的一个辅助功能,这个功能就是行表达式。
行表达式是 ShardingSphere 中用于实现简化和统一配置信息的一种工具,在日常开发过程中应用得非常广泛。 它的使用方式非常直观,只需要在配置中使用 ${expression} 或 $->{expression} 表达式即可。
例如上一课时中使用的"ds${0..1}.user${0..1}"就是一个行表达式,用来设置可用的数据源或数据表名称。基于行表达式语法,${begin..end} 表示的是一个从"begin"到"end"的范围区间,而多个 ${expression} 之间可以用"."符号进行连接,代表多个表达式数值之间的一种笛卡尔积关系。这样,如果采用图形化的表现形式,"ds${0..1}.user${0..1}"表达式最终会解析成这样一种结果:
-
+
当然,类似场景也可以使用枚举的方式来列举所有可能值。行表达式也提供了 ${[enum1, enum2,…, enumx]} 语法来表示枚举值,所以"ds${0..1}.user${0..1}"的效果等同于"ds${[0,1]}.user${[0,1]}"。
同样,在上一课时中使用到的 ds${age % 2} 表达式,它表示根据 age 字段进行对 2 取模,从而自动计算目标数据源是 ds0 还是 ds1。所以,除了配置数据源和数据表名称之外,行表达式在 ShardingSphere 中另一个常见的应用场景就是配置各种分片算法,我们会在后续的示例中大量看到这种使用方法。
由于 ${expression} 与 Spring 本身的属性文件占位符冲突,而 Spring 又是目前主流的开发框架,因此在正式环境中建议你使用 $->{expression} 来进行配置。
@@ -213,7 +213,7 @@ function hide_canvas() {
ShardingRuleConfiguration
我们在上一课时中已经了解了如何通过框架之间的集成方法来创建一个 DataSource,这个 DataSource 就是我们使用 ShardingSphere 的入口。我们也看到在创建 DataSource 的过程中使用到了一个 ShardingDataSourceFactory 类,这个工厂类的构造函数中需要传入一个 ShardingRuleConfiguration 对象。显然,从命名上看,这个 ShardingRuleConfiguration 就是用于分片规则的配置入口。
ShardingRuleConfiguration 中所需要配置的规则比较多,我们可以通过一张图例来进行简单说明,在这张图中,我们列举了每个配置项的名称、类型以及个数关系:
-
+
这里引入了一些新的概念,包括绑定表、广播表等,这些概念在下一课时介绍到 ShardingSphere 的分库分表操作时都会详细展开,这里不做具体介绍。事实上,对于 ShardingRuleConfiguration 而言,必须要设置的只有一个配置项,即 TableRuleConfiguration。
TableRuleConfiguration
从命名上看,TableRuleConfiguration 是表分片规则配置,但事实上,这个类同时包含了对分库和分表两种场景的设置。TableRuleConfiguration 包含很多重要的配置项:
@@ -235,7 +235,7 @@ function hide_canvas() {
keyGeneratorConfig 代表分布式环境下的自增列生成器配置,ShardingSphere 中集成了雪花算法等分布式 ID 的生成器实现。
ShardingStrategyConfiguration
我们注意到,databaseShardingStrategyConfig 和 tableShardingStrategyConfig 的类型都是一个 ShardingStrategyConfiguration 对象。在 ShardingSphere 中,ShardingStrategyConfiguration 实际上是一个空接口,存在一系列的实现类,其中的每个实现类都代表一种分片策略:
-
+
ShardingStrategyConfiguration 的类层结构图
在这些具体的分片策略中,通常需要指定一个分片列 shardingColumn 以及一个或多个分片算法 ShardingAlgorithm。当然也有例外,例如 HintShardingStrategyConfiguration 直接使用数据库的 Hint 机制实现强制路由,所以不需要分片列。我们会在《路由引擎:如何在路由过程中集成多种分片策略和分片算法?》中对这些策略的实现过程做详细的剖析。
KeyGeneratorConfiguration
@@ -440,7 +440,7 @@ spring.shardingsphere.masterslave.slave-data-source-names=dsslave0,dsslave1
}
可以看到 createDataSource 方法的输入参数是一个 File 对象,我们通过这个 File 对象构建出 YamlRootShardingConfiguration 对象,然后再通过 YamlRootShardingConfiguration 对象获取了 ShardingRuleConfiguration 对象,并交由 ShardingDataSourceFactory 完成目标 DataSource 的构建。这里的调用关系有点复杂,我们来梳理整个过程的类层结构,如下图所示:
-
+
显然,这里引入了两个新的工具类,YamlEngine 和 YamlSwapper。我们来看一下它们在整个流程中起到的作用。
YamlEngine 和 YamlSwapper
YamlEngine 的作用是将各种形式的输入内容转换成一个 Yaml 对象,这些输入形式包括 File、字符串、byte[] 等。YamlEngine 包含了一批 unmarshal/marshal 方法来完成数据的转换。以 File 输入为例,unmarshal 方法通过加载 FileInputStream 来完成 Yaml 对象的构建:
diff --git a/专栏/ShardingSphere 核心原理精讲-完/09 分布式事务:如何使用强一致性事务与柔性事务?.md.html b/专栏/ShardingSphere 核心原理精讲-完/09 分布式事务:如何使用强一致性事务与柔性事务?.md.html
index 7ae0d858..7efee636 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/09 分布式事务:如何使用强一致性事务与柔性事务?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/09 分布式事务:如何使用强一致性事务与柔性事务?.md.html
@@ -213,7 +213,7 @@ function hide_canvas() {
XA 事务
XA 事务提供基于两阶段提交协议的实现机制。所谓两阶段提交,顾名思义分成两个阶段,一个是准备阶段,一个是执行阶段。在准备阶段中,协调者发起一个提议,分别询问各参与者是否接受。在执行阶段,协调者根据参与者的反馈,提交或终止事务。如果参与者全部同意则提交,只要有一个参与者不同意就终止。
-
+
两阶段提交示意图
目前,业界在实现 XA 事务时也存在一些主流工具库,包括 Atomikos、Narayana 和 Bitronix。ShardingSphere 对这三种工具库都进行了集成,并默认使用 Atomikos 来完成两阶段提交。
BASE 事务
@@ -386,14 +386,14 @@ public void insert(){
}
现在让我们执行这个 processWithXA 方法,看看数据是否已经按照分库的配置写入到目标数据库表中。下面是 ds0 中的 health_record 表和 health_task 表:
-
+
ds0 中的 health_record 表
-
+
ds0 中的 health_task 表
下面则是 ds1 中的 health_record 表和 health_task 表:
-
+
ds1 中的 health_record 表
-
+
ds1 中的 health_task 表
我们也可以通过控制台日志来跟踪具体的 SQL 执行过程:
2020-06-01 20:11:52.043 INFO 10720 --- [ main] ShardingSphere-SQL : Rule Type: sharding
@@ -454,7 +454,7 @@ public void insert(){
}
现在,在 src/main/resources 目录下的文件组织形式应该是这样:
-
+
当然,这里我们还是继续沿用前面介绍的分库配置。
实现 BASE 事务
基于 ShardingSphere 提供的分布式事务的抽象,我们从 XA 事务转到 BASE 事务唯一要做的事情就是重新设置 TransactionType,也就是修改一行代码:
diff --git a/专栏/ShardingSphere 核心原理精讲-完/10 数据脱敏:如何确保敏感数据的安全访问?.md.html b/专栏/ShardingSphere 核心原理精讲-完/10 数据脱敏:如何确保敏感数据的安全访问?.md.html
index b7bea94e..8bbdc945 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/10 数据脱敏:如何确保敏感数据的安全访问?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/10 数据脱敏:如何确保敏感数据的安全访问?.md.html
@@ -202,7 +202,7 @@ function hide_canvas() {
从今天开始,我们又将开始一个全新的主题:介绍 ShardingSphere 中的数据脱敏功能。所谓数据脱敏,是指对某些敏感信息通过脱敏规则进行数据转换,从而实现敏感隐私数据的可靠保护。在日常开发过程中,数据安全一直是一个非常重要和敏感的话题。相较传统的私有化部署方案,互联网应用对数据安全的要求更高,所涉及的范围也更广。根据不同行业和业务场景的属性,不同系统的敏感信息可能有所不同,但诸如身份证号、手机号、卡号、用户姓名、账号密码等个人信息一般都需要进行脱敏处理。
ShardingSphere 如何抽象数据脱敏?
数据脱敏从概念上讲比较容易理解,但在具体实现过程中存在很多方案。在介绍基于数据脱敏的具体开发过程之前,我们有必要先来梳理实现数据脱敏的抽象过程。这里,我将从敏感数据的存储方式、敏感数据的加解密过程以及在业务代码中嵌入加解密的过程这三个维度来抽象数据脱敏。
-
+
针对每一个维度,我也将基于 ShardingSphere 给出这个框架的具体抽象过程,从而方便你理解使用它的方法和技巧,让我们来一起看一下。
敏感数据如何存储?
关于这个问题,要讨论的点在于是否需要将敏感数据以明文形式存储在数据库中。这个问题的答案并不是绝对的。
@@ -210,7 +210,7 @@ function hide_canvas() {
但对于用户姓名、手机号等信息,由于统计分析等方面的需要,显然我们不能直接采用不可逆的加密算法对其进行加密,还需要将明文信息进行处理**。**一种常见的处理方式是将一个字段用两列来进行保存,一列保存明文,一列保存密文,这就是第二种情况。
显然,我们可以将第一种情况看作是第二种情况的特例。也就是说,在第一种情况中没有明文列,只有密文列。
ShardingSphere 同样基于这两种情况进行了抽象,它将这里的明文列命名为 plainColumn,而将密文列命名为 cipherColumn。其中 plainColumn 属于选填,而 cipherColumn 则是必填。同时,ShardingSphere 还提出了一个逻辑列 logicColumn 的概念,该列代表一种虚拟列,只面向开发人员进行编程使用:
-
+
敏感数据如何加解密?
数据脱敏本质上就是一种加解密技术应用场景,自然少不了对各种加解密算法和技术的封装。传统的加解密方式有两种,一种是对称加密,常见的包括 DEA 和 AES;另一种是非对称加密,常见的包括 RSA。
ShardingSphere 内部也抽象了一个 ShardingEncryptor 组件专门封装各种加解密操作:
@@ -227,7 +227,7 @@ function hide_canvas() {
业务代码中如何嵌入数据脱敏?
数据脱敏的最后一个抽象点在于如何在业务代码中嵌入数据脱敏过程,显然这个过程应该尽量做到自动化,并且具备低侵入性,且应该对开发人员足够透明。
我们可以通过一个具体的示例来描述数据脱敏的执行流程。假设系统中存在一张 user 表,其中包含一个 user_name 列。我们认为这个 user_name 列属于敏感数据,需要对其进行数据脱敏。那么按照前面讨论的数据存储方案,可以在 user 表中设置两个字段,一个代表明文的 user_name_plain,一个代表密文的 user_name_cipher。然后应用程序通过 user_name 这个逻辑列与数据库表进行交互:
-
+
针对这个交互过程,我们希望存在一种机制,能够自动将 user_name 逻辑列映射到 user_name_plain 和 user_name_cipher 列。同时,我们希望提供一种配置机制,能够让开发人员根据需要灵活指定脱敏过程中所采用的各种加解密算法。
作为一款优秀的开源框架,ShardingSphere 就提供了这样一种机制。那么它是如何做到这一点呢?
首先,ShardingSphere 通过对从应用程序传入的 SQL 进行解析,并依据开发人员提供的脱敏配置对 SQL 进行改写,从而实现对明文数据的自动加密,并将加密后的密文数据存储到数据库中。当我们查询数据时,它又从数据库中取出密文数据,并自动对其解密,最终将解密后的明文数据返回给用户。ShardingSphere 提供了自动化+透明化的数据脱敏过程,业务开发人员可以像使用普通数据那样使用脱敏数据,而不需要关注数据脱敏的实现细节。
@@ -322,7 +322,7 @@ private final Map<String, EncryptTableRuleConfiguration> tables;
}
作为总结,我们通过一张图罗列出各个配置类之间的关系,以及数据脱敏所需要配置的各项内容:
-
+
现在回到代码,为了实现数据脱敏,我们首先需要定义一个数据源,这里命名为 dsencrypt:
spring.shardingsphere.datasource.names=dsencrypt
spring.shardingsphere.datasource.dsencrypt.type=com.zaxxer.hikari.HikariDataSource
@@ -348,10 +348,10 @@ spring.shardingsphere.encrypt.tables.encrypt_user.columns.pwd.encryptor=pwd_encr
执行数据脱敏
现在,配置工作一切就绪,我们来执行测试用例。首先执行数据插入操作,下图数据表中对应字段存储的就是加密后的密文数据:
-
+
加密后的表数据结果
在这个过程中,ShardingSphere 会把原始的 SQL 语句转换为用于数据脱敏的目标语句:
-
+
SQL 自动转换示意图
然后,我们再来执行查询语句并获取控制台日志:
2020-05-30 15:10:59.174 INFO 31808 --- [ main] ShardingSphere-SQL : Rule Type: encrypt
diff --git a/专栏/ShardingSphere 核心原理精讲-完/11 编排治理:如何实现分布式环境下的动态配置管理?.md.html b/专栏/ShardingSphere 核心原理精讲-完/11 编排治理:如何实现分布式环境下的动态配置管理?.md.html
index acaf23a3..a7654f92 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/11 编排治理:如何实现分布式环境下的动态配置管理?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/11 编排治理:如何实现分布式环境下的动态配置管理?.md.html
@@ -206,7 +206,7 @@ function hide_canvas() {
ShardingSphere 中的配置中心
关于配置信息的管理,常见的做法是把它们存放在配置文件中,我们可以基于 YAML 格式或 XML 格式的配置文件完成配置信息的维护,这在 ShardingSphere 中也都得到了支持。在单块系统中,配置文件能够满足需求,围绕配置文件展开的配置管理工作通常不会有太大挑战。但在分布式系统中,越来越多的运行时实例使得散落的配置难于管理,并且,配置不同步导致的问题十分严重。将配置集中于配置中心,可以更加有效地进行管理。
采用配置中心也就意味着采用集中式配置管理的设计思想。在集中式配置中心内,开发、测试和生产等不同的环境配置信息统一保存在配置中心内,这是一个维度。另一个维度就是需要确保分布式集群中同一类服务的所有服务实例保存同一份配置文件并且能够同步更新。配置中心的示意图如下所示:
-
+
集中式配置管理的设计思想
在 ShardingSphere 中,提供了多种配置中心的实现方案,包括主流的 ZooKeeeper、Etcd、Apollo 和 Nacos。开发人员也可以根据需要实现自己的配置中心并通过 SPI 机制加载到 ShardingSphere 运行时环境中。
另一方面,配置信息不是一成不变的。对修改后的配置信息的统一分发,是配置中心可以提供的另一个重要能力。配置中心中配置信息的任何变化都可以实时同步到各个服务实例中。在 ShardingSphere 中,通过配置中心可以支持数据源、数据表、分片以及读写分离策略的动态切换。
@@ -214,7 +214,7 @@ function hide_canvas() {
ShardingSphere 中的注册中心
在实现方式上,注册中心与配置中心非常类似,ShardingSphere 也提供了基于 ZooKeeeper 和 Etcd 这两款第三方工具的注册中心实现方案,而 ZooKeeeper 和 Etcd 同样也可以被用作配置中心。
注册中心与配置中心的不同之处在于两者保存的数据类型。配置中心管理的显然是配置数据,但注册中心存放的是 ShardingSphere 运行时的各种动态/临时状态数据,最典型的运行时状态数据就是当前的 Datasource 实例。那么,保存这些动态和临时状态数据有什么用呢?我们来看一下这张图:
-
+
注册中心的数据存储和监听机制示意图
注册中心一般都提供了分布式协调机制。在注册中心中,所有 DataSource 在指定路径根目录下创建临时节点,所有访问这些 DataSource 的业务服务都会监听该目录。当有新 DataSource 加入时,注册中心实时通知到所有业务服务,由业务服务做相应路由信息维护;而当某个 DataSource 宕机时,业务服务通过监听机制同样会收到通知。
基于这种机制,我们就可以提供针对 DataSource 的治理能力,包括熔断对某一个 DataSource 的数据访问,或禁用对从库 DataSource 的访问等。
@@ -323,10 +323,10 @@ spring.shardingsphere.orchestration.registry.namespace=orchestration-health_ms
同时,ZooKeeper 服务器端也对来自应用程序的请求作出响应。我们可以使用一些 ZooKeeper 可视化客户端工具来观察目前服务器上的数据。这里,我使用了 ZooInspector 这款工具,由于 ZooKeeper 本质上就是树状结构,~~现在~~所以在根节点中就新增了配置信息:
-
+
ZooKeeper 中的配置节点图
我们关注“config”段内容,其中“rule”节点包含了读写分离的规则设置:
-
+
ZooKeeper 中的“rule”配置项
而“datasource”节点包含的显然是前面所指定的各个数据源信息。
由于我们在本地配置文件中将 spring.shardingsphere.orchestration.overwrite 配置项设置为 true,本地配置的变化就会影响到服务器端配置,进而影响到所有使用这些配置的应用程序。如果不希望产生这种影响,而是统一使用位于配置中心上的配置,应该怎么做呢?
diff --git a/专栏/ShardingSphere 核心原理精讲-完/12 从应用到原理:如何高效阅读 ShardingSphere 源码?.md.html b/专栏/ShardingSphere 核心原理精讲-完/12 从应用到原理:如何高效阅读 ShardingSphere 源码?.md.html
index f356d177..6fb5bde0 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/12 从应用到原理:如何高效阅读 ShardingSphere 源码?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/12 从应用到原理:如何高效阅读 ShardingSphere 源码?.md.html
@@ -202,15 +202,15 @@ function hide_canvas() {
从本课时开始,专栏将进入:“ShardingSphere 源码解析之基础设施”的模块。在介绍完 ShardingSphere 所具备的分库分表、读写分离、分布式事务、数据脱敏等各项核心功能之后,我将带领你全面剖析这些核心功能背后的实现原理和机制。我们将通过深入解析 ShardingSphere 源码这一途径来实现这一目标。
如何系统剖析 ShardingSphere 的代码结构?
在阅读开源框架时,我们碰到的一大问题在于,常常会不由自主地陷入代码的细节而无法把握框架代码的整体结构。市面上主流的、被大家所熟知而广泛应用的代码框架肯定考虑得非常周全,其代码结构不可避免存在一定的复杂性。对 ShardingSphere 而言,情况也是一样,我们发现 ShardingSphere 源码的一级代码结构目录就有 15 个,而这些目录内部包含的具体 Maven 工程则多达 50 余个:
-
+
ShardingSphere 源码一级代码结构目录
如何快速把握 ShardingSphere 的代码结构呢?这是我们剖析源码时需要回答的第一个问题,为此我们需要梳理剖析 ShardingSphere 框架代码结构的系统方法。
本课时我们将对如何系统剖析 ShardingSphere 代码结构这一话题进行抽象,梳理出应对这一问题的六大系统方法(如下图):
-
+
接下来,我们将结合 ShardingSphere 框架对这些方法进行展开。
基于可扩展性设计阅读源码
ShardingSphere 在设计上采用了微内核架构模式来确保系统具有高度的可扩展性,并使用了 JDK 提供的 SPI 机制来具体实现微内核架构。在 ShardingSphere 源代码的根目录下,存在一个独立工程 shardingsphere-spi。显然,从命名上看,这个工程中应该包含了 ShardingSphere 实现 SPI 的相关代码。该工程中存在一个 TypeBasedSPI 接口,它的类层结构比较丰富,课程后面将要讲到的很多核心接口都继承了该接口,包括实现配置中心的 ConfigCenter、注册中心的 RegistryCenter 等,如下所示:
-
+
ShardingSphere 中 TypeBasedSPI 接口的类层结构
这些接口的实现都遵循了 JDK 提供的 SPI 机制。在我们阅读 ShardingSphere 的各个代码工程时,一旦发现在代码工程中的 META-INF/services 目录里创建了一个以服务接口命名的文件,就说明这个代码工程中包含了用于实现扩展性的 SPI 定义。
在 ShardingSphere 中,大量使用了微内核架构和 SPI 机制实现系统的扩展性。只要掌握了微内核架构的基本原理以及 SPI 的实现方式就会发现,原来在 ShardingSphere 中,很多代码结构上的组织方式就是为了满足这些扩展性的需求。ShardingSphere 中实现微内核架构的方式就是直接对 JDK 的 ServiceLoader 类进行一层简单的封装,并添加属性设置等自定义的功能,其本身并没有太多复杂的内容。
@@ -219,7 +219,7 @@ ShardingSphere 中 TypeBasedSPI 接口的类层结构
分包(Package)设计原则可以用来设计和规划开源框架的代码结构。对于一个包结构而言,最核心的设计要点就是高内聚和低耦合。我们刚开始阅读某个框架的源码时,为了避免过多地扎进细节而只关注某一个具体组件,同样可以使用这些原则来管理我们的学习预期。
以 ShardingSphere 为例,我们在分析它的路由引擎时发现了两个代码工程,一个是 sharding-core-route,一个是 sharding-core-entry。从代码结构上讲,尽管这两个代码工程都不是直接面向业务开发人员,但 sharding-core-route 属于路由引擎的底层组件,包含了路由引擎的核心类 ShardingRouter。
而 sharding-core-entry 则位于更高的层次,提供了 PreparedQueryShardingEngine 和 SimpleQueryShardingEngine 类,分包结构如下所示:
-
+
图中我们可以看到两个清晰的代码结构层次关系,这是 ShardingSphere 中普遍采用的分包原则中,具有代表性的一种,即根据类的所属层级来组织包结构。
基于基础开发规范阅读源码
对于 ShardingSphere 而言,在梳理它的代码结构时有一个非常好的切入点,那就是基于 JDBC 规范。我们知道 ShardingSphere 在设计上一开始就完全兼容 JDBC 规范,它对外暴露的一套分片操作接口与 JDBC 规范中所提供的接口完全一致。只要掌握了 JDBC 中关于 DataSource、Connection、Statement 等核心接口的使用方式,就可以非常容易地把握 ShardingSphere 中暴露给开发人员的代码入口,进而把握整个框架的代码结构。
@@ -234,13 +234,13 @@ ShardingSphere 中 TypeBasedSPI 接口的类层结构
通过这个工厂类,我们很容易就找到了创建支持分片机制的 DataSource 入口,从而引出其背后的 ShardingConnection、ShardingStatement 等类。
事实上,在 ShardingSphere 中存在一批 DataSourceFactory 工厂类以及对应的 DataSource 类:
-
+
在阅读 ShardingSphere 源码时,JDBC 规范所提供的核心接口及其实现类,为我们高效梳理代码入口和组织方式提供了一种途径。
基于核心执行流程阅读源码
事实上,还有一个比较容易理解和把握的方法可以帮我们梳理代码结构,这就是代码的执行流程。任何系统行为都可以认为是流程的组合。通过分析,看似复杂的代码结构一般都能梳理出一条贯穿全局的主流程。只要我们抓住这条主流程,就能把握框架的整体代码结构。
那么,对于 ShardingSphere 框架而言,什么才是它的主流程呢?这个问题其实不难回答。事实上,JDBC 规范为我们实现数据存储和访问提供了基本的开发流程。我们可以从 DataSource 入手,逐步引入 Connection、Statement 等对象,并完成 SQL 执行的主流程。这是从框架提供的核心功能角度梳理的一种主流程。
对于框架内部的代码组织结构而言,实际上也存在着核心流程的概念。最典型的就是 ShardingSphere 的分片引擎结构,整个分片引擎执行流程可以非常清晰的分成五个组成部分,分别是解析引擎、路由引擎、改写引擎、执行引擎和归并引擎:
-
+
ShardingSphere 对每个引擎都进行了明确地命名,在代码工程的组织结构上也做了对应的约定,例如 sharding-core-route 工程用于实现路由引擎;sharding-core-execute 工程用于实现执行引擎;sharding-core-merge 工程用于实现归并引擎等。这是从框架内部实现机制角度梳理的一种主流程。
在软件建模领域,可以通过一些工具和手段对代码执行流程进行可视化,例如 UML 中的活动图和时序图。在后续的课时中,我们会基于这些工具帮你梳理 ShardingSphere 中很多有待挖掘的代码执行流程。
基于框架演进过程阅读源码
@@ -265,13 +265,13 @@ ShardingSphere 中 TypeBasedSPI 接口的类层结构
注意,这里基于装饰器模式实现了两个 SQLRewriteContextDecorator,一个是 ShardingSQLRewriteContextDecorator,另一个是 EncryptSQLRewriteContextDecorator,而后者是在前者的基础上完成装饰工作。也就是说,我们首先可以单独使用 ShardingSQLRewriteContextDecorator 来完成对 SQL 的改写操作。
随着架构的演进,我们也可以在原有 EncryptSQLRewriteContextDecorator 的基础上添加新的面向数据脱敏的功能,这就体现了一种架构演进的过程。通过阅读这两个装饰器类,以及 SQL 改写上下文对象 SQLRewriteContext,我们就能更好地把握代码的设计思想和实现原理:
-
+
关于数据脱敏以及装饰器模式的具体实现细节我们会在《数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?》中进行详细展开。
基于通用外部组件阅读源码
在《开篇寄语:如何正确学习一款分库分表开源框架?》中,我们提出了一种观点,即技术原理存在相通性。这点同样可以帮助我们更好地阅读 ShardingSphere 源码。
在 ShardingSphere 中集成了一批优秀的开源框架,包括用于实现配置中心和注册中心的Zookeeper、Apollo、Nacos,用于实现链路跟踪的 SkyWalking,用于实现分布式事务的 Atomikos 和 Seata 等。
我们先以分布式事务为例,ShardingSphere 提供了一个 sharding-transaction-core 代码工程,用于完成对分布式事务的抽象。然后又针对基于两阶段提交的场景,提供了 sharding-transaction-2pc 代码工程,以及针对柔性事务提供了 sharding-transaction-base 代码工程。而在 sharding-transaction-2pc 代码工程内部,又包含了如下所示的 5 个子代码工程。
-
+
sharding-transaction-2pc 代码工程下的子工程
在翻阅这些代码工程时,会发现每个工程中的类都很少,原因就在于,这些类都只是完成与第三方框架的集成而已。所以,只要我们对这些第三方框架有一定了解,阅读这部分代码就会显得非常简单。
再举一个例子,我们知道 ZooKeeper 可以同时用来实现配置中心和注册中心。作为一款主流的分布式协调框架,基本的工作原理就是采用了它所提供的临时节点以及监听机制。基于 ZooKeeper 的这一原理,我们可以把当前 ShardingSphere 所使用的各个 DataSource 注册到 ZooKeeper 中,并根据 DataSource 的运行时状态来动态对数据库实例进行治理,以及实现访问熔断机制。事实上,ShardingSphere 能做到这一点,依赖的就是 ZooKeeper 所提供的基础功能。只要我们掌握了这些功能,理解这块代码就不会很困难,而 ShardingSphere 本身并没有使用 ZooKeeper 中任何复杂的功能。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/13 微内核架构:ShardingSphere 如何实现系统的扩展性?.md.html b/专栏/ShardingSphere 核心原理精讲-完/13 微内核架构:ShardingSphere 如何实现系统的扩展性?.md.html
index 79cd2686..744950cf 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/13 微内核架构:ShardingSphere 如何实现系统的扩展性?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/13 微内核架构:ShardingSphere 如何实现系统的扩展性?.md.html
@@ -206,13 +206,13 @@ function hide_canvas() {
微内核架构本质上是为了提高系统的扩展性 。所谓扩展性,是指系统在经历不可避免的变更时所具有的灵活性,以及针对提供这样的灵活性所需要付出的成本间的平衡能力。也就是说,当在往系统中添加新业务时,不需要改变原有的各个组件,只需把新业务封闭在一个新的组件中就能完成整体业务的升级,我们认为这样的系统具有较好的可扩展性。
就架构设计而言,扩展性是软件设计的永恒话题。而要实现系统扩展性,一种思路是提供可插拔式的机制来应对所发生的变化。当系统中现有的某个组件不满足要求时,我们可以实现一个新的组件来替换它,而整个过程对于系统的运行而言应该是无感知的,我们也可以根据需要随时完成这种新旧组件的替换。
比如在下个课时中我们将要介绍的 ShardingSphere 中提供的分布式主键功能,分布式主键的实现可能有很多种,而扩展性在这个点上的体现就是, 我们可以使用任意一种新的分布式主键实现来替换原有的实现,而不需要依赖分布式主键的业务代码做任何的改变 。
-
+
微内核架构模式为这种实现扩展性的思路提供了架构设计上的支持,ShardingSphere 基于微内核架构实现了高度的扩展性。在介绍如何实现微内核架构之前,我们先对微内核架构的具体组成结构和基本原理做简要的阐述。
什么是微内核架构?
从组成结构上讲, 微内核架构包含两部分组件:内核系统和插件 。这里的内核系统通常提供系统运行所需的最小功能集,而插件是独立的组件,包含自定义的各种业务代码,用来向内核系统增强或扩展额外的业务能力。在 ShardingSphere 中,前面提到的分布式主键就是插件,而 ShardingSphere 的运行时环境构成了内核系统。
-
+
那么这里的插件具体指的是什么呢?这就需要我们明确两个概念,一个概念就是经常在说的 API ,这是系统对外暴露的接口。而另一个概念就是 SPI(Service Provider Interface,服务提供接口),这是插件自身所具备的扩展点。就两者的关系而言,API 面向业务开发人员,而 SPI 面向框架开发人员,两者共同构成了 ShardingSphere 本身。
-
+
可插拔式的实现机制说起来简单,做起来却不容易,我们需要考虑两方面内容。一方面,我们需要梳理系统的变化并把它们抽象成多个 SPI 扩展点。另一方面, 当我们实现了这些 SPI 扩展点之后,就需要构建一个能够支持这种可插拔机制的具体实现,从而提供一种 SPI 运行时环境 。
那么,ShardingSphere 是如何实现微内核架构的呢?让我们来一起看一下。
如何实现微内核架构?
@@ -261,7 +261,7 @@ public class Main {
如果我们调整 META-INF/services/com.tianyilan.KeyGenerator 文件中的内容,去掉 com.tianyilan.UUIDKeyGenerator 的定义,并重新打成 jar 包供 SPI 服务的使用者进行引用。再次执行 Main 函数,则只会得到基于 SnowflakeKeyGenerator 的输出结果。
至此, 完整 的 SPI 提供者和使用者的实现过程演示完毕。我们通过一张图,总结基于 JDK 的 SPI 机制实现微内核架构的开发流程:
-
+
这个示例非常简单,但却是 ShardingSphere 中实现微内核架构的基础。接下来,就让我们把话题转到 ShardingSphere,看看 ShardingSphere 中应用 SPI 机制的具体方法。
ShardingSphere 如何基于微内核架构实现扩展性?
ShardingSphere 中微内核架构的实现过程并不复杂,基本就是对 JDK 中 SPI 机制的封装。让我们一起来看一下。
@@ -365,13 +365,13 @@ public class Main {
可以看到,这里并没有使用前面介绍的 TypeBasedSPIServiceLoader 来加载实例,而是直接使用更为底层的 NewInstanceServiceLoader。
这里引入的 SQLParserEntry 接口就位于 shardingsphere-sql-parser-spi 工程的 org.apache.shardingsphere.sql.parser.spi 包中。显然,从包的命名上看,该接口是一个 SPI 接口。在 SQLParserEntry 类层结构接口中包含一批实现类,分别对应各个具体的数据库:
-
+
SQLParserEntry 实现类图
我们先来看针对 MySQL 的代码工程 shardingsphere-sql-parser-mysql,在 META-INF/services 目录下,我们找到了一个 org.apache.shardingsphere.sql.parser.spi.SQLParserEntry 文件:
-
+
MySQL 代码工程中的 SPI 配置
可以看到这里指向了 org.apache.shardingsphere.sql.parser.MySQLParserEntry 类。再来到 Oracle 的代码工程 shardingsphere-sql-parser-oracle,在 META-INF/services 目录下,同样找到了一个 org.apache.shardingsphere.sql.parser.spi.SQLParserEntry 文件:
-
+
Oracle 代码工程中的 SPI 配置
显然,这里应该指向 org.apache.shardingsphere.sql.parser.OracleParserEntry 类,通过这种方式,系统在运行时就会根据类路径动态加载 SPI。
可以注意到,在 SQLParserEntry 接口的类层结构中,实际并没有使用到 TypeBasedSPI 接口 ,而是完全采用了 JDK 原生的 SPI 机制。
@@ -402,7 +402,7 @@ public class Main {
那么它是如何实现的呢? 首先,ConfigCenterServiceLoader 类通过 NewInstanceServiceLoader.register(ConfigCenter.class) 语句将所有 ConfigCenter 注册到系统中,这一步会通过 JDK 的 ServiceLoader 工具类加载类路径中的所有 ConfigCenter 实例。
我们可以看到在上面的 load 方法中,通过父类 TypeBasedSPIServiceLoader 的 newService 方法,基于类型创建了 SPI 实例。
以 ApolloConfigCenter 为例,我们来看它的使用方法。在 sharding-orchestration-config-apollo 工程的 META-INF/services 目录下,应该存在一个名为 org.apache.shardingsphere.orchestration.config.api.ConfigCenter 的配置文件,指向 ApolloConfigCenter 类:
-
+
Apollo 代码工程中的 SPI 配置
其他的 ConfigCenter 实现也是一样,你可以自行查阅 sharding-orchestration-config-zookeeper-curator 等工程中的 SPI 配置文件。
至此,我们全面了解了 ShardingSphere 中的微内核架构,也就可以基于 ShardingSphere 所提供的各种 SPI 扩展点提供满足自身需求的具体实现。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/14 分布式主键:ShardingSphere 中有哪些分布式主键实现方式?.md.html b/专栏/ShardingSphere 核心原理精讲-完/14 分布式主键:ShardingSphere 中有哪些分布式主键实现方式?.md.html
index f62b5b9d..a6bcd62d 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/14 分布式主键:ShardingSphere 中有哪些分布式主键实现方式?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/14 分布式主键:ShardingSphere 中有哪些分布式主键实现方式?.md.html
@@ -272,7 +272,7 @@ function hide_canvas() {
回顾上一课时的内容,我们不难理解 ShardingKeyGeneratorServiceLoader 类的作用。ShardingKeyGeneratorServiceLoader 继承了 TypeBasedSPIServiceLoader 类,并在静态方法中通过 NewInstanceServiceLoader 注册了类路径中所有的 ShardingKeyGenerator。然后,ShardingKeyGeneratorServiceLoader 的 newService 方法基于类型参数通过 SPI 创建实例,并赋值 Properties 属性。
通过继承 TypeBasedSPIServiceLoader 类来创建一个新的 ServiceLoader 类,然后在其静态方法中注册相应的 SPI 实现,这是 ShardingSphere 中应用微内核模式的常见做法,很多地方都能看到类似的处理方法。
我们在 sharding-core-common 工程的 META-INF/services 目录中看到了具体的 SPI 定义:
-
+
分布式主键 SPI 配置
可以看到,这里有两个 ShardingKeyGenerator,分别是 SnowflakeShardingKeyGenerator 和 UUIDShardingKeyGenerator,它们都位于org.apache.shardingsphere.core.strategy.keygen 包下。
ShardingSphere 中的分布式主键实现方案
@@ -296,7 +296,7 @@ function hide_canvas() {
SnowflakeShardingKeyGenerator
再来看 SnowFlake(雪花)算法,SnowFlake 是 ShardingSphere 默认的分布式主键生成策略。它是 Twitter 开源的分布式 ID 生成算法,其核心思想是使用一个 64bit 的 long 型数字作为全局唯一 ID,且 ID 引入了时间戳,基本上能够保持自增。SnowFlake 算法在分布式系统中的应用十分广泛,SnowFlake 算法中 64bit 的详细结构存在一定的规范:
-
+
64bit 的 ID 结构图
在上图中,我们把 64bit 分成了四个部分:
diff --git a/专栏/ShardingSphere 核心原理精讲-完/15 解析引擎:SQL 解析流程应该包括哪些核心阶段?(上).md.html b/专栏/ShardingSphere 核心原理精讲-完/15 解析引擎:SQL 解析流程应该包括哪些核心阶段?(上).md.html
index 896a8e9d..0760321f 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/15 解析引擎:SQL 解析流程应该包括哪些核心阶段?(上).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/15 解析引擎:SQL 解析流程应该包括哪些核心阶段?(上).md.html
@@ -201,7 +201,7 @@ function hide_canvas() {
15 解析引擎:SQL 解析流程应该包括哪些核心阶段?(上)
你好,欢迎进入第 15 课时的学习,结束了对 ShardingSphere 中微内核架构等基础设施相关实现机制的介绍后,今天我们将正式进入到分片引擎的学习。
对于一款分库分表中间件而言,分片是其最核心的功能。下图展示了整个 ShardingSphere 分片引擎的组成结构,我们已经在[《12 | 从应用到原理:如何高效阅读 ShardingSphere 源码》]这个课时中对分片引擎中所包含的各个组件进行了简单介绍。我们知道,对于分片引擎而言,第一个核心组件就是 SQL 解析引擎。
-
+
对于多数开发人员而言,SQL 解析是一个陌生的话题,但对于一个分库分表中间件来说却是一个基础组件,目前主流的分库分表中间件都包含了对解析组件的实现策略。可以说,SQL 解析引擎所生成的结果贯穿整个 ShardingSphere。如果我们无法很好地把握 SQL 的解析过程,在阅读 ShardingSphere 源码时就会遇到一些障碍。
另一方面,SQL 的解析过程本身也很复杂,你在拿到 ShardingSphere 框架的源代码时,可能首先会问这样一个问题:SQL 的解析过程应该包含哪些核心阶段呢?接下来我将带你深度剖析这个话题。
从 DataSource 到 SQL 解析引擎入口
@@ -225,14 +225,14 @@ shardingRuleConfig.setDefaultTableShardingStrategyConfig(new StandardShardingStr
return ShardingDataSourceFactory.createDataSource(createDataSourceMap(), shardingRuleConfig, new Properties());
可以看到,上述代码构建了几个数据源,加上分库、分表策略以及分片规则,然后通过 ShardingDataSourceFactory 获取了目前数据源 DataSource 。显然,对于应用开发而言,DataSource 就是我们使用 ShardingSphere 框架的入口。事实上,对于 ShardingSphere 内部的运行机制而言,DataSource 同样是引导我们进入分片引擎的入口。围绕 DataSource,通过跟踪代码的调用链路,我们可以得到如下所示的类层结构图:
-
+
上图已经引出了 ShardingSphere 内核中的很多核心对象,但今天我们只关注位于整个链路的最底层对象,即图中的 SQLParseEngine。一方面,在 DataSource 的创建过程中,最终初始化了 SQLParseEngine;另一方面,负责执行路由功能的 ShardingRouter 也依赖于 SQLParseEngine。这个 SQLParseEngine 就是 ShardingSphere 中负责整个 SQL 解析过程的入口。
从 SQL 解析引擎到 SQL 解析内核
在 ShardingSphere 中,存在一批以“Engine”结尾的引擎类。从架构思想上看,这些类在设计和实现上普遍采用了外观模式。外观(Facade)模式的意图可以描述为子系统中的一组接口提供一个一致的界面。外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。该模式的示意图如下图所示:
-
+
从作用上讲,外观模式能够起到客户端与后端服务之间的隔离作用,随着业务需求的变化和时间的演进,外观背后各个子系统的划分和实现可能需要进行相应的调整和升级,这种调整和升级需要做到对客户端透明。在设计诸如 ShardingSphere 这样的中间件框架时,这种隔离性尤为重要。
对于 SQL 解析引擎而言,情况同样类似。不同之处在于,SQLParseEngine 本身并不提供外观作用,而是把这部分功能委托给了另一个核心类 SQLParseKernel。从命名上看,这个类才是 SQL 解析的内核类,也是所谓的外观类。SQLParseKernel 屏蔽了后端服务中复杂的 SQL 抽象语法树对象 SQLAST、SQL 片段对象 SQLSegment ,以及最终的 SQL 语句 SQLStatement 对象的创建和管理过程。上述这些类之间的关系如下所示:
-
+
1.SQLParseEngine
从前面的类层结构图中可以看到,AbstractRuntimeContext 是 SQLParseEngine 的构建入口。顾名思义,RuntimeContext 在 ShardingSphere 中充当一种运行时上下文,保存着与运行时环境下相关的分片规则、分片属性、数据库类型、执行引擎以及 SQL 解析引擎。作为 RuntimeContext 接口的实现类,AbstractRuntimeContex 在其构造函数中完成了对 SQLParseEngine 的构建,构建过程如下所示:
protected AbstractRuntimeContext(final T rule, final Properties props, final DatabaseType databaseType) {
@@ -335,7 +335,7 @@ private final SQLStatementFillerEngine fillerEngine;
- 通过 SQLStatementFiller 填充 SQLStatement
这三个阶段便是 ShardingSphere 新一代 SQL 解析引擎的核心组成部分。其整体架构如下图所示:
-
+
至此,我们看到由解析、提取和填充这三个阶段所构成的整体 SQL 解析流程已经完成。现在能够根据一条 SQL 语句解析出对应的 SQLStatement 对象,供后续的 ShardingRouter 等路由引擎进行使用。
本课时我们首先关注流程中的第一阶段,即如何生成一个 SQLAST(后两个阶段会在后续课时中讲解)。这部分的实现过程位于 SQLParserEngine 的 parse 方法,如下所示:
public SQLAST parse() {
@@ -388,7 +388,7 @@ private final SQLStatementFillerEngine fillerEngine;
从这种实现方式上看,我们可以断定 SQLParserEntry 是一个 SPI 接口。通过查看 SQLParserEntry 所处的代码包结构,更印证了这一观点,因为该类位于 shardingsphere-sql-parser-spi 工程的 org.apache.shardingsphere.sql.parser.spi 包中。
关于 SQLParser 和 SQLParserEntry 这一对接口,还有一点值得探讨。注意到 SQLParser 接口位于 shardingsphere-sql-parser-spi 工程的 org.apache.shardingsphere.sql.parser.api 包中,所示它是一个 API 接口。
从定位上讲,SQLParser 是解析器对外暴露的入口,而 SQLParserEntry 是解析器的底层实现,两者共同构成了 SQL 解析器本身。更宽泛的,从架构设计层次上讲,API 面向高层业务开发人员,而 SPI 面向底层框架开发人员,两者的关系如下图所示。作为一款优秀的中间件框架,这种 API 和 SPI 的对应关系在 ShardingSphere 中非常普遍,也是我们正确理解 ShardingSphere 架构设计上的一个切入点。
-
+
SQLParser 和 SQLParserEntry 这两个接口的定义和实现都与基于 ANTLR4 的 AST 生成机制有关。ANTLR 是 Another Tool for Language Recognition 的简写,是一款能够根据输入自动生成语法树的开源语法分析器。ANTLR 可以将用户编写的 ANTLR 语法规则直接生成 Java、Go 语言的解析器,在 ShardingSphere 中就使用了 ANTLR4 来生成 AST。
我们注意到 SQLParserEngine 的 parse 方法最终返回的是一个 SQLAST,该类的定义如下所示。
public final class SQLAST {
diff --git a/专栏/ShardingSphere 核心原理精讲-完/16 解析引擎:SQL 解析流程应该包括哪些核心阶段?(下).md.html b/专栏/ShardingSphere 核心原理精讲-完/16 解析引擎:SQL 解析流程应该包括哪些核心阶段?(下).md.html
index 56b0b599..bd49569f 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/16 解析引擎:SQL 解析流程应该包括哪些核心阶段?(下).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/16 解析引擎:SQL 解析流程应该包括哪些核心阶段?(下).md.html
@@ -200,7 +200,7 @@ function hide_canvas() {
16 解析引擎:SQL 解析流程应该包括哪些核心阶段?(下)
我们知道整个 SQL 解析引擎可以分成三个阶段(如下图所示),上一课时我们主要介绍了 ShardingSphere 中 SQL 解析引擎的第一个阶段,那么今天我将承接上一课时,继续讲解 ShardingSphere 中 SQL 解析流程中剩余的两个阶段。
-
+
SQL 解析引擎的三大阶段
在 SQL 解析引擎的第一阶段中,我们详细介绍了 ShardingSphere 生成 SQL 抽象语法树的过程,并引出了 SQLStatementRule 规则类。今天我们将基于这个规则类来分析如何提取 SQLSegment 以及如何填充 SQL 语句的实现机制。
1.第二阶段:提取 SQL 片段
@@ -222,7 +222,7 @@ function hide_canvas() {
</sql-statement-rule-definition>
基于 ParseRuleRegistry 类进行规则获取和处理过程,涉及一大批实体对象以及用于解析 XML 配置文件的 JAXB 工具类的定义,内容虽多但并不复杂。核心类之间的关系如下图所示:
-
+
ParseRuleRegistry 类层结构图
当获取规则之后,对于具体某种数据库类型的每条 SQL 而言,都会有一个 SQLStatementRule 对象。我们注意到每个 SQLStatementRule 都定义了一个“context”以及一个“sql-statement-class”。
这里的 context 实际上就是通过 SQL 解析所生成的抽象语法树 SQLAST 中的 ParserRuleContext,包括 CreateTableContext、SelectContext 等各种 StatementContext。而针对每一种 context,都有专门的一个 SQLStatement 对象与之对应,那么这个 SQLStatement 究竟长什么样呢?我们来看一下。
@@ -254,7 +254,7 @@ function hide_canvas() {
SELECT task_id, task_name FROM health_task WHERE user_id = 'user1' AND record_id = 2
通过解析,我们获取了如下所示的抽象语法树:
-
+
抽象语法树示意图
我们发现,对于上述抽象语法树中的某些节点(如 SELECT、FROM 和 WHERE)没有子节点,而对于如 FIELDS、TABLES 和 CONDITIONS 节点而言,本身也是一个树状结构。显然,这两种节点的提取规则应该是不一样的。
因此,ShardingSphere 提供了两种 SQLSegmentExtractor,一种是针对单节点的 OptionalSQLSegmentExtractor;另一种是针对树状节点的 CollectionSQLSegmentExtractor。由于篇幅因素,这里以 TableExtractor 为例,展示如何提取 TableSegment 的过程,TableExtractor 的实现方法如下所示:
@@ -363,11 +363,11 @@ function hide_canvas() {
}
这段代码在实现上采用了回调机制来完成对象的注入。在 ShardingSphere 中,基于回调的处理方式也非常普遍。本质上,回调解决了因为类与类之间的相互调用而造成的循环依赖问题,回调的实现策略通常采用了如下所示的类层结构:
-
+
回调机制示意图
TableFiller 中所依赖的 TableSegmentAvailable 和 TableSegmentsAvailable 接口就类似于上图中的 Callback 接口,具体的 SQLStatement 就是 Callback 的实现类,而 TableFiller 则是 Callback 的调用者。以 TableFiller 为例,我们注意到,如果对应的 SQLStatement 实现了这两个接口中的任意一个,那么就可以通过 TableFiller 注入对应的 TableSegment,从而完成 SQLSegment 的填充。
这里以 TableSegmentAvailable 接口为例,它有一组实现类,如下所示:
-
+
TableSegmentAvailable实现类
以上图中的 CreateTableStatement 为例,该类同时实现了 TableSegmentAvailable 和 IndexSegmentsAvailable 这两个回调接口,所以就可以同时操作 TableSegment 和 IndexSegment 这两个 SQLSegment。CreateTableStatement 类的实现如下所示:
public final class CreateTableStatement extends DDLStatement implements TableSegmentAvailable, IndexSegmentsAvailable {
@@ -380,7 +380,7 @@ function hide_canvas() {
}
至此,我们通过一个示例解释了与填充操作相关的各个类之间的协作关系,如下所示的类图展示了这种协作关系的整体结构。
-
+
SQLStatement类层结构图
有了上图的基础,我们理解填充引擎 SQLStatementFillerEngine 就显得比较简单了,SQLStatementFillerEngine 类的实现如下所示:
public final class SQLStatementFillerEngine {
diff --git a/专栏/ShardingSphere 核心原理精讲-完/17 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?.md.html b/专栏/ShardingSphere 核心原理精讲-完/17 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?.md.html
index 38d0c473..03be5a8e 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/17 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/17 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?.md.html
@@ -203,7 +203,7 @@ function hide_canvas() {
从今天开始,我们将进入 ShardingSphere 的路由(Routing)引擎部分的源码解析。从流程上讲,路由引擎是整个分片引擎执行流程中的第二步,即基于 SQL 解析引擎所生成的 SQLStatement,通过解析执行过程中所携带的上下文信息,来获取匹配数据库和表的分片策略,并生成路由结果。
分层:路由引擎整体架构
与介绍 SQL 解析引擎时一样,我们通过翻阅 ShardingSphere 源码,首先梳理了如下所示的包结构:
-
+
上述包图总结了与路由机制相关的各个核心类,我们可以看到整体呈一种对称结构,即根据是 PreparedStatement 还是普通 Statement 分成两个分支流程。
同时,我们也可以把这张图中的类按照其所属的包结构分成两个层次:位于底层的 sharding-core-route 和位于上层的 sharding-core-entry,这也是 ShardingSphere 中所普遍采用的一种分包原则,即根据类的所属层级来组织包结构。关于 ShardingSphere 的分包原则我们在 [《12 | 从应用到原理:如何高效阅读 ShardingSphere 源码?》]中也已经进行了介绍,接下来我们具体分析这一原则在路由引擎中的应用。
1.sharding-core-route 工程
@@ -246,7 +246,7 @@ private final EncryptRule encryptRule;
ShardingRule 的内容非常丰富,但其定位更多是提供规则信息,而不属于核心流程,因此我们先不对其做详细展开。作为基础规则类,ShardingRule 会贯穿整个分片流程,在后续讲解过程中我们会穿插对它的介绍,这里先对上述变量的名称和含义有简单认识即可。
我们回到 ShardingRouter 类,发现其核心方法只有一个,即 route 方法。这个方法的逻辑比较复杂,我们梳理它的执行步骤,如下图所示:
-
+
ShardingRouter 是路由引擎的核心类,在接下来的内容中,我们将对上图中的 6 个步骤分别一 一 详细展开,帮忙你理解一个路由引擎的设计思想和实现机制。
1.分片合理性验证
我们首先来看 ShardingRouter 的第一个步骤,即验证分片信息的合理性,验证方式如下所示:
@@ -414,7 +414,7 @@ RoutingResult routingResult = routingEngine.route();
}
这些 RoutingEngine 的具体介绍我们放在下一课时《18 | 路由引擎:如何实现数据访问的分片路由和广播路由?》中进行详细介绍,这里只需要了解 ShardingSphere 在包结构的设计上把具体的 RoutingEngine 分成了六大类:即广播(broadcast)路由、混合(complex)路由、默认数据库(defaultdb)路由、无效(ignore)路由、标准(standard)路由以及单播(unicast)路由,如下所示:
-
+
不同类型的 RoutingEngine 实现类
RoutingEngine 的执行结果是 RoutingResult,而 RoutingResult 中包含了一个 RoutingUnit集合,RoutingUnit 中的变量定义如下所示,可以看到有两个关于 DataSource 名称的变量以及一个 TableUnit 列表:
//真实数据源名
@@ -466,10 +466,10 @@ private RoutingResult routingResult;
}
这里的 SQLUnit 中就是最终的一条 SQL 语句以及相应参数的组合。因为路由结果对象 SQLRouteResult 会继续传递到分片引擎的后续流程,且内部结构比较复杂,所以这里通过如下所示的类图对其包含的各种变量进行总结,方便你进行理解。
-
+
至此,我们把 ShardingRouter 类的核心流程做了介绍。在 ShardingSphere 的路由引擎中,ShardingRouter 可以说是一个承上启下的核心类,向下我们可以挖掘各种 RoutingEngine 的具体实现;向上我们可以延展到读写分离等面向应用的具体场景。
下图展示了 ShardingRouter 的这种定位关系。关于各种 RoutingEngine 的介绍是我们下一课时的内容,今天我们先将基于 ShardingRouter 讨论它的上层结构,从而引出了 ShardingEngine。
-
+
从底层 ShardingRouter 到上层 ShardingEngine
我们的思路仍然是从下往上,先来看上图中的 StatementRoutingEngine,其实现如下所示:
public final class StatementRoutingEngine {
@@ -537,7 +537,7 @@ protected abstract SQLRouteResult route(String sql, List<Object> parameter
}
至此,关于 ShardingSphere 路由引擎部分的内容基本都介绍完毕。对于上层结构而言,我们以 SimpleQueryShardingEngine 为例进行了展开,对于 PreparedQueryShardingEngine 的处理方式也是类似。作为总结,我们通过如下所示的时序图来梳理这些路由的主流程。
-
+
从源码解析到日常开发
分包设计原则可以用来设计和规划开源框架的代码结构。在今天的内容中,我们看到了 ShardingSphere 中非常典型的一种分层和分包实现策略。通过 sharding-core-route 和 sharding-core-entry 这两个工程,我们把路由引擎中位于底层的核心类 ShardingRouter 和位于上层的 PreparedQueryShardingEngine 及 SimpleQueryShardingEngine 类进行了合理的分层管理。ShardingSphere 对于分层和分包策略的应用有很多具体的表现形式,随着课程的不断演进,我们还会看到更多的应用场景。
小结与预告
diff --git a/专栏/ShardingSphere 核心原理精讲-完/18 路由引擎:如何实现数据访问的分片路由和广播路由?.md.html b/专栏/ShardingSphere 核心原理精讲-完/18 路由引擎:如何实现数据访问的分片路由和广播路由?.md.html
index 044acf9b..7e809bc5 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/18 路由引擎:如何实现数据访问的分片路由和广播路由?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/18 路由引擎:如何实现数据访问的分片路由和广播路由?.md.html
@@ -201,7 +201,7 @@ function hide_canvas() {
18 路由引擎:如何实现数据访问的分片路由和广播路由?
在上一课时中,我们看到起到承上启下作用的 ShardingRouter 会调用 RoutingEngine 获取路由结果,而在 ShardingSphere 中存在多种不同类型的 RoutingEngine,分别针对不同的应用场景。
我们可以按照是否携带分片键信息将这些路由方式分成两大类,即分片路由和广播路由,而这两类路由中又存在一些常见的 RoutingEngine 实现类型,如下图所示:
-
+
我们无意对所有这些 RoutingEngine 进行详细 的 展开,但在接下来的内容中,我们会分别对分片路由和广播路由中具有代表性的 RoutingEngine 进行讨论。
分片路由
对于分片路由而言,我们将重点介绍标准路由,标准路由是 ShardingSphere 推荐使用的分片方式。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/19 路由引擎:如何在路由过程中集成多种路由策略和路由算法?.md.html b/专栏/ShardingSphere 核心原理精讲-完/19 路由引擎:如何在路由过程中集成多种路由策略和路由算法?.md.html
index 457d56e2..014b6816 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/19 路由引擎:如何在路由过程中集成多种路由策略和路由算法?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/19 路由引擎:如何在路由过程中集成多种路由策略和路由算法?.md.html
@@ -210,7 +210,7 @@ function hide_canvas() {
}
可以看到 ShardingStrategy 包含两个核心方法:一个用于指定分片的 Column,而另一个负责执行分片并返回目标 DataSource 和 Table。ShardingSphere 中为我们提供了一系列的分片策略实例,类层结构如下所示:
-
+
ShardingStrategy 实现类图
如果我们翻阅这些具体 ShardingStrategy 实现类的代码,会发现每个 ShardingStrategy 中都会包含另一个与路由相关的核心概念,即分片算法 ShardingAlgorithm,我们发现 ShardingAlgorithm 是一个空接口,但包含了四个继承接口,即
@@ -220,10 +220,10 @@ function hide_canvas() {
- HintShardingAlgorithm
而这四个接口又分别具有一批实现类,ShardingAlgorithm 的类层结构如下所示:
-
+
ShardingAlgorithm 子接口和实现类图
请注意,ShardingStrategy 与 ShardingAlgorithm 之间并不是一对一的关系。在一个 ShardingStrategy 中,可以同时使用多个 ShardingAlgorithm 来完成具体的路由执行策略。因此,我们具有如下所示的类层结构关系图:
-
+
由于分片算法的独立性,ShardingSphere 将其进行单独抽离。从关系上讲,分片策略中包含了分片算法和分片键,我们可以把分片策略的组成结构简单抽象成如下所示的公式:
分片策略 = 分片算法 + 分片键
ShardingSphere 分片策略详解
@@ -443,10 +443,10 @@ private final Closure<?> closure;
}
最后,作为总结,我们要注意所有的 ShardingStrategy 相关类都位于 sharding-core-common 工程的 org.apache.shardingsphere.core.strategy 包下:
-
+
ShardingStrategy 相关类的包结构
而所有的 ShardingAlgorithm 相关类则位于 sharding-core-api 工程的 org.apache.shardingsphere.api.sharding 包下:
-
+
ShardingAlgorithm 相关类的包结构
我们在前面已经提到过 ShardingStrategy 的创建依赖于 ShardingStrategyConfiguration,ShardingSphere 也提供了一个 ShardingStrategyFactory 工厂类用于创建各种具体的 ShardingStrategy:
public final class ShardingStrategyFactory {
@@ -468,17 +468,17 @@ private final Closure<?> closure;
}
而这里用到的各种 ShardingStrategyConfiguration 也都位于 sharding-core-api 工程的org.apache.shardingsphere.api.sharding.strategy 包下:
-
+
ShardingStrategyConfiguration 相关类的包结构
这样,通过对路由引擎的介绍,我们又接触到了一大批 ShardingSphere 中的源代码。
至此,关于 ShardingSphere 路由引擎部分的内容基本都介绍完毕。作为总结,我们在《17 | 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?》中所给出的时序图中添加了 ShardingStrategy 和 ShardingAlgorithm 部分的内容,如下所示:
-
+
从源码解析到日常开发
在我们设计软件系统的过程中,面对复杂业务场景时,职责分离始终是需要考虑的一个设计点。ShardingSphere 对于分片策略的设计和实现很好地印证了这一观点。
分片策略在 ShardingSphere 中实际上是一个比较复杂的概念,但通过将分片的具体算法分离出去并提炼 ShardingAlgorithm 接口,并构建 ShardingStrategy 和 ShardingAlgorithm 之间一对多的灵活关联关系,我们可以更好地把握整个分片策略体系的类层结构,这种职责分离机制同样可以应用与日常开发过程中。
小结与预告
承接上一课时的内容,今天我们全面介绍了 ShardingSphere 中的五大分片策略和四种分片算法以及它们之间的组合关系。
-
+
ShardingSphere 路由引擎中执行路由的过程正是依赖于这些分片策略和分片算法的功能特性。当然,作为一款具有高扩展性的开源框架,我们也可以基于自身的业务需求,实现特定的分片算法并嵌入到具体的分片策略中。
这里给你留一道思考题:ShardingSphere 中分片策略与分片算法之间是如何协作的? 欢迎你在留言区与大家讨论,我将一一点评解答。
在路由引擎的基础上,下一课时将进入 ShardingSphere 分片引擎的另一个核心阶段,即改写引擎。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/20 改写引擎:如何理解装饰器模式下的 SQL 改写实现机制?.md.html b/专栏/ShardingSphere 核心原理精讲-完/20 改写引擎:如何理解装饰器模式下的 SQL 改写实现机制?.md.html
index 6988da0d..31342570 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/20 改写引擎:如何理解装饰器模式下的 SQL 改写实现机制?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/20 改写引擎:如何理解装饰器模式下的 SQL 改写实现机制?.md.html
@@ -229,7 +229,7 @@ function hide_canvas() {
}
这段代码虽然内容不多,但却完整描述了实现 SQL 改写的整体流程,我们对核心代码都添加了注释,这里面涉及的核心类也很多,值得我们进行深入分析,相关核心类的整体结构如下:
-
+
可以看到在整个类图中,SQLRewriteContext 处于中间位置,改写引擎 SQLRewriteEngine 和装饰器 SQLRewriteContextDecorator 都依赖于它。
所以接下来,让我们先来看一下这个 SQLRewriteContext,并基于自增主键功能引出 SQL 改写引擎的基础组件 SQLToken。
从自增主键功能看改写引擎中的核心类
@@ -533,7 +533,7 @@ public String toString(final Map<String, String> logicAndActualTables) {
}
而 BindingTableRule 又依赖于 TableRule 中保存的 ActualDataNodes 来完成 ActualTableIndex和ActualTable 的计算。回想起我们在案例中配置的分库分表规则,这里再次感受到了以 TableRule 和 BindingTableRule为 代表的各种 Rule 对象在 ShardingSphere 的串联作用:
-
+
当 ShardingSQLBuilder 完成 SQL 的构建之后,我们再回到 ShardingSQLRewriteEngine,这个时候我们对它的 rewrite 方法就比较明确了:
@Override
public SQLRewriteResult rewrite(final SQLRewriteContext sqlRewriteContext) {
diff --git a/专栏/ShardingSphere 核心原理精讲-完/21 执行引擎:分片环境下 SQL 执行的整体流程应该如何进行抽象?.md.html b/专栏/ShardingSphere 核心原理精讲-完/21 执行引擎:分片环境下 SQL 执行的整体流程应该如何进行抽象?.md.html
index 23c6cb4e..eee018be 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/21 执行引擎:分片环境下 SQL 执行的整体流程应该如何进行抽象?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/21 执行引擎:分片环境下 SQL 执行的整体流程应该如何进行抽象?.md.html
@@ -221,7 +221,7 @@ function hide_canvas() {
然后,我们又分别找到了 SQLExecuteTemplate 和 SQLExecutePrepareTemplate 类,这两个是典型的SQL 执行模板类。
根据到目前为止对 ShardingSphere 组件设计和代码分层风格的了解,可以想象,在层次关系上,ShardingExecuteEngine 是底层对象,SQLExecuteTemplate 应该依赖于 ShardingExecuteEngine;而 StatementExecutor、PreparedStatementExecutor 和 BatchPreparedStatementExecutor 属于上层对象,应该依赖于 SQLExecuteTemplate。我们通过简单阅读这些核心类之前的引用关系,印证了这种猜想。
基于以上分析,我们可以给出 SQL 执行引擎的整体结构图(如下图),其中横线以上部分位于 sharding-core-execute 工程,属于底层组件;而直线以下部分位于 sharding-jdbc-core 中,属于上层组件。这种分析源码的能力也是《12 | 从应用到原理:如何高效阅读 ShardingSphere 源码?》中提到的“基于分包设计原则阅读源码”的一种具体表现:
-
+
ShardingSphere 执行引擎核心类的分层结构图
另一方面,我们在上图中还看到 SQLExecuteCallback 和 SQLExecutePrepareCallback,显然,它们的作用是完成 SQL 执行过程中的回调处理,这也是一种非常典型的扩展性处理方式。
ShardingExecuteEngine
diff --git a/专栏/ShardingSphere 核心原理精讲-完/22 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(上).md.html b/专栏/ShardingSphere 核心原理精讲-完/22 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(上).md.html
index 4d724562..53b1f66b 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/22 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(上).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/22 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(上).md.html
@@ -200,7 +200,7 @@ function hide_canvas() {
22 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(上)
在上一课时中,我们对 ShardingGroupExecuteCallback 和 SQLExecuteTemplate 做了介绍。从设计上讲,前者充当 ShardingExecuteEngine 的回调入口;而后者则是一个模板类,完成对 ShardingExecuteEngine 的封装并提供了对外的统一入口,这些类都位于底层的 sharding-core-execute 工程中。
-
+
从今天开始,我们将进入到 sharding-jdbc-core 工程,来看看 ShardingSphere 中执行引擎上层设计中的几个核心类。
AbstractStatementExecutor
如上图所示,根据上一课时中的执行引擎整体结构图,可以看到SQLExecuteTemplate的直接使用者是AbstractStatementExecutor 类,今天我们就从这个类开始展开讨论,该类的变量比较多,我们先来看一下:
@@ -269,7 +269,7 @@ private final Collection<ShardingExecuteGroup<StatementExecuteUnit>>
显然,在这里应该使用 SQLExecuteTemplate 模板类来完成具体回调的执行过程。同时,我可以看到这里还有一个 refreshMetaDataIfNeeded 辅助方法用来刷选元数据。
AbstractStatementExecutor 有两个实现类:一个是普通的 StatementExecutor,一个是 PreparedStatementExecutor,接下来我将分别进行讲解。
-
+
StatementExecutor
我们来到 StatementExecutor,先看它的用于执行初始化操作的 init 方法:
public void init(final SQLRouteResult routeResult) throws SQLException {
@@ -341,7 +341,7 @@ private final Collection<ShardingExecuteGroup<StatementExecuteUnit>>
ConnectionMode connectionMode = maxConnectionsSizePerQuery < sqlUnits.size() ? ConnectionMode.CONNECTION_STRICTLY : ConnectionMode.MEMORY_STRICTLY;
关于这个判断条件,我们可以使用一张简单的示意图来进行说明,如下所示:
-
+
如上图所示,我们可以看到如果每个数据库连接所指向的 SQL 数多于一条时,走的是内存限制模式,反之走的是连接限制模式。
3.StreamQueryResult VS MemoryQueryResult
在了解了 ConnectionMode(连接模式) 的设计理念后,我们再来看 StatementExecutor 的 executeQuery 方法返回的是一个 QueryResult。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/23 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(下).md.html b/专栏/ShardingSphere 核心原理精讲-完/23 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(下).md.html
index 0ac318e5..034454a6 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/23 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(下).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/23 执行引擎:如何把握 ShardingSphere 中的 Executor 执行模型?(下).md.html
@@ -219,7 +219,7 @@ private ResultSet currentResultSet;
在继续介绍 ShardingStatement 之前,我们先梳理一下与它相关的类层结构。我们在 “06 | 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?” 中的 ShardingConnection 提到,ShardingSphere 通过适配器模式包装了自己的实现类,除了已经介绍的 ShardingConnection 类之外,还包含今天要介绍的 ShardingStatement 和 ShardingPreparedStament。
根据这一点,我们可以想象 ShardingStatement 应该具备与 ShardingConnection 类似的类层结构:
-
+
然后我们来到上图中 AbstractStatementAdapter 类,这里的很多方法的风格都与 ShardingConnection 的父类 AbstractConnectionAdapter 一致,例如如下所示的 setPoolable 方法:
public final void setPoolable(final boolean poolable) throws SQLException {
this.poolable = poolable;
@@ -367,7 +367,7 @@ private final List<Object> parameters = new ArrayList<>();
}
关于 AbstractShardingPreparedStatementAdapter 还需要注意的是它的类层结构,如下图所示,可以看到 AbstractShardingPreparedStatementAdapter 继承了 AbstractUnsupportedOperationPreparedStatement 类;而 AbstractUnsupportedOperationPreparedStatement 却又继承了 AbstractStatementAdapter 类并实现了 PreparedStatement:
-
+
形成这种类层结构的原因在于,PreparedStatement 本来就是在 Statement 的基础上添加了各种参数设置功能,换句话说,Statement 的功能 PreparedStatement 都应该有。
所以一方面 AbstractStatementAdapter 提供了所有 Statement 的功能;另一方面,AbstractShardingPreparedStatementAdapter 首先把 AbstractStatementAdapter 所有的功能继承过来,但它自身可能有一些无法实现的关于 PreparedStatement 的功能,所以同样提供了 AbstractUnsupportedOperationPreparedStatement 类,并被最终的 AbstractShardingPreparedStatementAdapter 适配器类所继承。
这样就形成了如上图所示的复杂类层结构。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/24 归并引擎:如何理解数据归并的类型以及简单归并策略的实现过程?.md.html b/专栏/ShardingSphere 核心原理精讲-完/24 归并引擎:如何理解数据归并的类型以及简单归并策略的实现过程?.md.html
index 7ee5b0f1..acea8e13 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/24 归并引擎:如何理解数据归并的类型以及简单归并策略的实现过程?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/24 归并引擎:如何理解数据归并的类型以及简单归并策略的实现过程?.md.html
@@ -214,7 +214,7 @@ result = getResultSet(mergeEngine);
所谓归并,就是将从各个数据节点获取的多数据结果集,通过一定的策略组合成为一个结果集并正确的返回给请求客户端的过程。
按照不同的 SQL 类型以及应用场景划分,归并的类型可以分为遍历、排序、分组、分页和聚合 5 种类型,这 5 种类型是组合而非互斥的关系。
其中遍历归并是最简单的归并,而排序归并是最常用地归并,在下文我会对两者分别详细介绍。
-
+
归并的五大类型
按照归并实现的结构划分,ShardingSphere 中又存在流式归并、内存归并和装饰者归并这三种归并方案。
@@ -224,7 +224,7 @@ result = getResultSet(mergeEngine);
显然,流式归并和内存归并是互斥的,装饰者归并可以在流式归并和内存归并之上做进一步的处理。
归并方案与归并类型之间同样存在一定的关联关系,其中遍历、排序以及流式分组都属于流式归并的一种,内存归并可以作用于统一的分组、排序以及聚合,而装饰者归并有分页归并和聚合归并这 2 种类型,它们之间的对应关系如下图所示:
-
+
归并类型与归并方案之间的对应关系图
2.归并引擎
讲完概念回到代码,我们首先来到 shardingsphere-merge 代码工程中的 MergeEngine 接口:
@@ -234,7 +234,7 @@ result = getResultSet(mergeEngine);
}
可以看到 MergeEngine 接口非常简单,只有一个 merge 方法。在 ShardingSphere 中,该接口存在五个实现类,其类层结构如下所示:
-
+
MergeEngine 类层结构图
从命名上看,可以看到名称中带有“Encrypt”的两个 MergeEngine 与数据脱敏相关,放在后续专题中再做讲解,其余的三个我们会先做一些分析。
在此之前,我们还要来关注一下代表归并结果的 MergedResult 接口:
@@ -337,11 +337,11 @@ public Object getValue(final int columnIndex, final Class<?> type) throws
当在多个数据库中执行某一条 SQL 语句时,我们可以做到在每个库的内部完成排序功能。也就是说,我们的执行结果中保存着内部排好序的多个 QueryResult,然后要做的就是把它们放在一个地方然后进行全局的排序。因为每个 QueryResult 内容已经是有序的,因此只需要将 QueryResult 中当前游标指向的数据值进行排序即可,相当于对多个有序的数组进行排序。
这个过程有点抽象,我们通过如下的示意图进行进一步说明。假设,在我们的健康任务 health_task 表中,存在一个健康点数字段 health_point,用于表示完成这个健康任务能够获取的健康分数。
然后,我们需要根据这个 health_point 进行排序归并,初始的数据效果如下图所示:
-
+
三张 health_task 表中的初始数据
上图中展示了 3 张表返回的数据结果集,每个数据结果集都已经根据 health_point 字段进行了排序,但是 3 个数据结果集之间是无序的。排序归并的做法就是将 3 个数据结果集的当前游标指向的数据值进行排序,并放入到一个排序好的队列中。
在上图中可以看到 health_task0 的第一个 health_point 最小,health_task1 的第一个 health_point 最大,health_task2 的第一个 health_point 次之,因此队列中应该按照 health_task1,health_task2 和 health_task0 的方式排序队列,效果如下:
-
+
队列中已排序的三张 health_task 表
在 OrderByStreamMergedResult 中,我们可以看到如下所示的队列定义,用到了 JDK 中的 Queue 接口:
private final Queue<OrderByValue> orderByValuesQueue;
@@ -422,10 +422,10 @@ public boolean next() throws SQLException {
}
这个过程同样需要用一系列图来进行解释。当进行第一次 next 调用时,排在队列首位的 health_task1 将会被弹出队列,并且将当前游标指向的数据值 50 返回。同时,我们还会将游标下移一位之后,重新把 health_task1 放入优先级队列。而优先级队列也会根据 health_task1 的当前数据结果集指向游标的数据值 45 进行排序,根据当前数值,health_task1 将会被排列在队列的第三位。如下所示:
-
+
第一次 next 之后的优先级队列中的三张 health_task 表
之前队列中排名第二的 health_task2 的数据结果集则自动排在了队列首位。而在进行第二次 next 时,只需要将目前排列在队列首位的 health_task2 弹出队列,并且将其数据结果集游标指向的值返回。当然,对于 health_task2 而言,我们同样下移游标,并继续将它加入优先级队列中,以此类推。
-
+
第二次 next 之后的优先级队列中的三张 health_task 表
可以看到,基于上述的设计和实现方法,对于每个数据结果集内部数据有序、而多数据结果集整体无序的情况下,我们无需将所有的数据都加载至内存即可进行排序。
因此,ShardingSphere 在这里使用的是流式归并的方式,充分提高了归并效率。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/25 归并引擎:如何理解流式归并和内存归并在复杂归并场景下的应用方式?.md.html b/专栏/ShardingSphere 核心原理精讲-完/25 归并引擎:如何理解流式归并和内存归并在复杂归并场景下的应用方式?.md.html
index 2b4b6ac0..b6469cf3 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/25 归并引擎:如何理解流式归并和内存归并在复杂归并场景下的应用方式?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/25 归并引擎:如何理解流式归并和内存归并在复杂归并场景下的应用方式?.md.html
@@ -213,7 +213,7 @@ function hide_canvas() {
显然,上述 SQL 的分组项与排序项完全一致,都是用到了 task_name 列,所以取得的数据是连续的。这样,分组所需的数据全部存在于各个数据结果集的当前游标所指向的数据值,因此可以采用流式归并。
如下图所示,我们在每个 health_task 结果集中,根据 task_name 进行了排序:
-
+
我们先来看一些代码的初始化工作,回到 DQLMergeEngine,找到用于分组归并的 getGroupByMergedResult 方法,如下所示:
private MergedResult getGroupByMergedResult(final Map<String, Integer> columnLabelIndexMap) throws SQLException {
return selectSQLStatementContext.isSameGroupByAndOrderByItems()
@@ -229,13 +229,13 @@ function hide_canvas() {
这样当进行第一次 next 调用时,排在队列首位的 health_task0 将会被弹出队列,并且将分组值同为“task1”其他结果集中的数据一同弹出队列。然后,在获取了所有的 task_name 为“task1”的 health_point 之后,我们进行了累加操作。
所以在第一次 next 调用结束后,取出的结果集是 “task1” 的分数总和,即 46+43+40=129,如下图所示:
-
+
- 第二次 next 调用
与此同时,所有数据结果集中的游标都将下移至“task1”的下一个不同的数据值,并且根据数据结果集当前游标指向的值进行重排序。在上图中,我们看到第二个“task2”同时存在于 health_task0 和 health_task1 中,这样包含名字为“task2”的相关数据结果集则排在的队列的前列。
当再次执行 next 调用时,我们获取了 “task2” 的分数并进行了累加,即 42+50=92,如下图中所示:
-
+
对于接下去的 next 方法,我们也是采用类似的处理机制,分别找到这三种 health_task 表中的“task3”“task4”“task5”等数据记录,并依次类推。
有了对流式分组归并的感性认识之后,让我们回到源代码。我们先来看代表结果的 GroupByStreamMergedResult,我们发现 GroupByStreamMergedResult 实际上是继承了上一课时中介绍的用于排序归并的 OrderByStreamMergedResult,因此也用到了前面介绍的优先级队列 PriorityQueue 和 OrderByValue 对象。
但考虑到需要保存一些中间变量以管理运行时状态,GroupByStreamMergedResult 中添加了如下所示的代表当前结果记录的 currentRow 和代表当前分组值的 currentGroupByValues 变量:
diff --git a/专栏/ShardingSphere 核心原理精讲-完/26 读写分离:普通主从架构和分片主从架构分别是如何实现的?.md.html b/专栏/ShardingSphere 核心原理精讲-完/26 读写分离:普通主从架构和分片主从架构分别是如何实现的?.md.html
index 39b4ad0b..c33c8c24 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/26 读写分离:普通主从架构和分片主从架构分别是如何实现的?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/26 读写分离:普通主从架构和分片主从架构分别是如何实现的?.md.html
@@ -267,7 +267,7 @@ function hide_canvas() {
可以看到,当 loadBalanceStrategyConfiguration 配置不存在时,会直接使用 serviceLoader.newService() 方法完成 SPI 实例的创建。我们回顾 “13 | 微内核架构:ShardingSphere 如何实现系统的扩展性?” 中的介绍,就会知道该方法会获取系统中第一个可用的 SPI 实例。
我们同样在 sharding-core-common 工程中找到了 SPI 的配置信息,如下所示:
-
+
针对 MasterSlaveLoadBalanceAlgorithm 的 SPI 配置
按照这里的配置信息,第一个获取的 SPI 实例应该是 RoundRobinMasterSlaveLoadBalanceAlgorithm,即轮询策略,它的 getDataSource 方法实现如下:
@Override
@@ -395,7 +395,7 @@ public int executeUpdate() throws SQLException {
至此,ShardingSphere 中与读写分离相关的核心类以及主要流程介绍完毕。总体而言,这部分的内容因为不涉及分片操作,所以整体结构还是比较直接和明确的。尤其是我们在了解了分片相关的 ShardingDataSource、ShardingConnection、ShardingStatement 和 ShardingPreparedStatement 之后再来理解今天的内容就显得特别简单,很多底层的适配器模式等内容前面都介绍过。
作为总结,我们还是简单梳理一下读写分离相关的类层结构,如下所示:
-
+
从源码解析到日常开发
在今天的内容中,我们接触到了分布式系统开发过程中非常常见的一个话题,即负载均衡。负载均衡的场景就类似于在多个从库中选择一个目标库进行路由一样,通常需要依赖于一定的负载均衡算法,ShardingSphere 中就提供了随机和轮询这两种常见的实现,我们可以在日常开发过程中参考它的实现方法。
当然,因为 MasterSlaveLoadBalanceAlgorithm 接口是一个 SPI,所以我们也可以定制化新的负载均衡算法并动态加载到 ShardingSphere。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/27 分布式事务:如何理解 ShardingSphere 中对分布式事务的抽象过程?.md.html b/专栏/ShardingSphere 核心原理精讲-完/27 分布式事务:如何理解 ShardingSphere 中对分布式事务的抽象过程?.md.html
index 73aadfc9..1bbe33fd 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/27 分布式事务:如何理解 ShardingSphere 中对分布式事务的抽象过程?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/27 分布式事务:如何理解 ShardingSphere 中对分布式事务的抽象过程?.md.html
@@ -324,7 +324,7 @@ private Collection<ResourceDataSource> getResourceDataSources(final Map<
要理解基于 XA 协议的 ShardingTransactionManager,我们同样需要具备一定的理论知识。XA 是由 X/Open 组织提出的两阶段提交协议,是一种分布式事务的规范,XA 规范主要定义了面向全局的事务管理器 TransactionManager(TM)和面向局部的资源管理器 ResourceManager(RM)之间的接口。
XA 接口是双向的系统接口,在 TransactionManager,以及一个或多个 ResourceManager 之间形成通信桥梁。通过这样的设计,TransactionManager 控制着全局事务,管理事务生命周期,并协调资源,而 ResourceManager 负责控制和管理包括数据库相关的各种实际资源。
XA 的整体结构以及 TransactionManager 和 ResourceManager 之间的交互过程参考下图:
-
+
XA 协议组成结构图
所有关于分布式事务的介绍中都必然会讲到两阶段提交,因为它是实现 XA 分布式事务的关键。我们知道在两阶段提交过程中,存在协调者和参与者两种角色。在上图中,XA 引入的 TransactionManager 充当着全局事务中的“协调者”角色,而图中的 ResourceManager 相当于“参与者”角色,对自身内部的资源进行统一管理。
理解了这些概念之后,我们再来看 Java 中的实现。作为 Java 平台中的事务规范,JTA(Java Transaction API)也定义了对 XA 事务的支持。实际上,JTA 是基于 XA 架构进行建模的,在 JTA 中,事务管理器抽象为 javax.transaction.TransactionManager 接口,并通过底层事务服务进行实现。
@@ -372,16 +372,16 @@ public void commit() {
这里的 XATransactionManager 就是对各种第三方 XA 事务管理器的一种抽象,封装了对
Atomikos、Bitronix 等第三方工具的实现方式。我们会在下一课时中对这个 XATransactionManager 以及 XAShardingTransactionManager 进行具体展开。
作为总结,我们梳理在 ShardingSphere 中与 XA 两阶段提交相关的核心类之间的关系,如下图所示:
-
+
2.SeataATShardingTransactionManager
介绍完 XAShardingTransactionManager 之后,我们来看上图中 ShardingTransactionManager 接口的另一个实现类 SeataATShardingTransactionManager。因为基于不同技术体系和工作原理,所以 SeataATShardingTransactionManager 中的实现方法也完全不同,让我们来看一下。
在介绍 SeataATShardingTransactionManager 之前,我们同样有必要对 Seata 本身做一些展开。与 XA 不同,Seata 框架中一个分布式事务包含三种角色,除了 XA 中同样具备的 TransactionManager(TM)和 ResourceManager(RM) 之外,还存在一个事务协调器 TransactionCoordinator (TC),维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
其中,TM 是一个分布式事务的发起者和终结者,TC 负责维护分布式事务的运行状态,而 RM 则负责本地事务的运行。
Seata 的整体架构图如下所示:
-
+
Seata 分布式事务组成结构图(来自 Seata 官网)
基于Seata 框架,一个分布式事务的执行流程包含如下五个步骤:
-
+
我们同样会在下一课时中对这些步骤,以及其中涉及的核心类进行具体展开。
从源码解析到日常开发
今天的内容我们主要关注于 ShardingSphere 中对分布式事务的抽象过程,本身没有涉及过多的源码分析。我们学习的关注点在于掌握 XA 协议的特点和核心类,以及基于 Seata 框架完成一次分布式事务执行的过程。
diff --git a/专栏/ShardingSphere 核心原理精讲-完/28 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(上).md.html b/专栏/ShardingSphere 核心原理精讲-完/28 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(上).md.html
index f8e12d38..4365880d 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/28 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(上).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/28 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(上).md.html
@@ -288,7 +288,7 @@ function hide_canvas() {
}
在 ShardingSphere 中,继承 DatabaseTypeAwareSPI 接口的就只有 XADataSourceDefinition 接口,而后者存在一批实现类,整体的类层结构如下所示:
-
+
XADataSourceDefinition 的实现类
这里以 MySQLXADataSourceDefinition 为例展开讨论,该类分别实现了 DatabaseTypeAwareSPI 和 XADataSourceDefinition 这两个接口中所定义的三个方法:
public final class MySQLXADataSourceDefinition implements XADataSourceDefinition {
@@ -327,7 +327,7 @@ function hide_canvas() {
}
同样,在 sharding-transaction-xa-core 工程中,我们也发现了如下所示的 SPI 配置信息:
-
+
sharding-transaction-xa-core 工程中的 SPI 配置
当根据数据库类型获取了对应的 XADataSourceDefinition 之后,我们就可以根据 XADriverClassName 来创建具体的 XADataSource:
private static XADataSource loadXADataSource(final String xaDataSourceClassName) {
@@ -360,7 +360,7 @@ function hide_canvas() {
}
DataSourcePropertyProvider 的实现类有两个,一个是 DefaultDataSourcePropertyProvider,另一个是 HikariCPPropertyProvider。ShardingSphere 默认使用的是 HikariCPPropertyProvider,这点可以从如下所示的 SPI 配置文件中得到确认:
-
+
DataSourcePropertyProvider 的 SPI 配置
HikariCPPropertyProvider 实现了 DataSourcePropertyProvider 接口,并包含了对这些基础信息的定义:
public final class HikariCPPropertyProvider implements DataSourcePropertyProvider {
@@ -398,7 +398,7 @@ function hide_canvas() {
}
至此,我们对 XADataSource 的构建过程描述完毕。这个过程不算复杂,但涉及的类比较多,值得我们以 XADataSourceFactory 为中心画一张类图作为总结:
-
+
2.XAConnection
讲完 XADataSource,我们接着来讲 XAConnection,XAConnection 同样是 JDBC 规范中的接口。
负责创建 XAConnection 的工厂类 XAConnectionFactory 如下所示:
@@ -429,7 +429,7 @@ function hide_canvas() {
}
XAConnectionWrapper 接口只有一个方法,即根据传入的 XADataSource 和一个普通 Connection 对象创建出一个新的 XAConnection 对象。XAConnectionWrapper 接口的类层结构如下所示:
-
+
XAConnectionWrapper 接口的实现类
MySQLXAConnectionWrapper 中的 warp 方法如下所示:
@Override
diff --git a/专栏/ShardingSphere 核心原理精讲-完/29 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(下).md.html b/专栏/ShardingSphere 核心原理精讲-完/29 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(下).md.html
index b92aa048..7a8c37a3 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/29 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(下).md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/29 分布式事务:ShardingSphere 中如何集成强一致性事务和柔性事务支持?(下).md.html
@@ -328,7 +328,7 @@ public void rollback() {
对上述代码的理解也依赖与对 bitronix 框架的熟悉程度,整个封装过程简单明了。我们无意对 bitronix 框架做过多展开,而是更多关注于 ShardingSphere 中对 XATransactionManager 的抽象过程。
作为总结,我们在上一课时的基础上,进一步梳理了 XA 两阶段提交相关的核心类之间的关系,如下图所示:
-
+
2.ShardingConnection
上图展示了整个流程的源头是在 ShardingConnection 类。我们在 ShardingConnection 的构造函数中发现了创建 ShardingTransactionManager 的过程,如下所示:
shardingTransactionManager = runtimeContext.getShardingTransactionManagerEngine().getTransactionManager(transactionType);
@@ -403,7 +403,7 @@ private void initSeataRPCClient() {
回想我们在“09 | 分布式事务:如何使用强一致事务与柔性事务?”中关于 Seata 使用方式的介绍,不难理解这里通过 seata.conf 配置文件中所配置的 application.id 和 transaction.service.group 这两个配置项来执行初始化操作。
同时,对于 Seata 而言,它也提供了一套构建在 JDBC 规范之上的实现策略,这点和“03 | 规范兼容:JDBC 规范与 ShardingSphere 是什么关系?”中介绍的 ShardingSphere 与 JDBC 规范之间的兼容性类似。
而在命名上,Seata 更为直接明了,使用 DataSourceProxy 和 ConnectionProxy 这种代理对象。以 DataSourceProxy 为例,我们可以梳理它的类层结构如下:
-
+
可以看到 DataSourceProxy 实现了自己定义的 Resource 接口,然后继承了抽象类 AbstractDataSourceProxy,而后者则实现了 JDBC 中的 DataSource 接口。
所以,在我们初始化 Seata 框架时,同样需要根据输入的 DataSource 对象来构建 DataSourceProxy,并通过 DataSourceProxy 获取 ConnectionProxy。SeataATShardingTransactionManager 类中的相关代码如下所示:
@Override
diff --git a/专栏/ShardingSphere 核心原理精讲-完/30 数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?.md.html b/专栏/ShardingSphere 核心原理精讲-完/30 数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?.md.html
index 2dda132d..867e6db5 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/30 数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/30 数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
与普通的编程模式一样,对于数据脱敏而言,我们同样先获取一个 DataSource 作为整个流程的入口,当然这里获取的不是一个普通的 DataSource,而是一个专门针对数据脱敏的 EncryptDataSource。对于数据脱敏模块,我们的思路还是从上到下,从 EncryptDataSource 开始进入到 ShardingSphere 数据脱敏的世界中。
同时,我们这次讲解数据脱敏模块不是零基础,因为在前面介绍 ShardingDataSource、ShardingConnection、ShardingStatement 等内容时,已经对整个 SQL 执行流程的抽象过程做了全面介绍,所涉及的很多内容对于数据脱敏模块而言也都是适用的。
让我们结合下图来做一些回顾:
-
+
上图中,可以看到与数据脱敏模块相关的类实际上都继承了一个抽象类,而这些抽象类在前面的内容都已经做了介绍。因此,我们对数据脱敏模块将重点关注于几个核心类的讲解,对于已经介绍过的内容我们会做一些回顾,但不会面面俱到。
基于上图,我们从 EncryptDataSource 开始入手,EncryptDataSource 的创建依赖于工厂类 EncryptDataSourceFactory,其实现如下所示:
public final class EncryptDataSourceFactory {
@@ -238,7 +238,7 @@ private EncryptRuleConfiguration ruleConfiguration;
ShardingEncryptor 接口中存在一对用于加密和解密的方法,同时该接口也继承了 TypeBasedSPI 接口,意味着会通过 SPI 的方式进行动态类加载。
ShardingEncryptorServiceLoader 完成了这个工作,同时在 sharding-core-common 工程中,我们也找到了 SPI 的配置文件,如下所示:
-
+
ShardingEncryptor 的 SPI 配置文件
可以看到这里有两个实现类,分别是 MD5ShardingEncryptor 和 AESShardingEncryptor。对于 MD5 算法而言,我们知道它是单向散列的,无法根据密文反推出明文,MD5ShardingEncryptor 的实现类如下所示:
public final class MD5ShardingEncryptor implements ShardingEncryptor {
@@ -501,7 +501,7 @@ public void rewrite(final ParameterBuilder parameterBuilder, final SQLStatementC
}
关于 EncryptAssignmentParameterRewriter 的实现,这里面涉及的类也比较多,我们可以先来画张图作为后续讨论的基础,如下所示:
-
+
3.EncryptAssignmentTokenGenerator
讨论完 EncryptParameterRewriterBuilder 之后,我们再来讨论 EncryptTokenGenerateBuilder。这里,我们也是以 EncryptAssignmentTokenGenerator 为例来进行展开,在这个类中,核心方法是 generateSQLTokens,如下所示:
@Override
diff --git a/专栏/ShardingSphere 核心原理精讲-完/32 注册中心:如何基于注册中心实现数据库访问熔断机制?.md.html b/专栏/ShardingSphere 核心原理精讲-完/32 注册中心:如何基于注册中心实现数据库访问熔断机制?.md.html
index 2cadc2dc..b21e27f4 100644
--- a/专栏/ShardingSphere 核心原理精讲-完/32 注册中心:如何基于注册中心实现数据库访问熔断机制?.md.html
+++ b/专栏/ShardingSphere 核心原理精讲-完/32 注册中心:如何基于注册中心实现数据库访问熔断机制?.md.html
@@ -324,7 +324,7 @@ private static final String PROPS_NODE = "props";
private final String name;
基于 ShardingSphere 中对这些配置项的管理方式,我们可以将这些配置项与具体的存储结构相对应,如下所示:
-
+
有了配置项之后,我们就需要对其进行保存,ConfigurationService 的 persistConfiguration 方法完成了这一目的,如下所示:
public void persistConfiguration(final String shardingSchemaName, final Map<String, DataSourceConfiguration> dataSourceConfigs, final RuleConfiguration ruleConfig,
final Authentication authentication, final Properties props, final boolean isOverwrite) {
@@ -359,7 +359,7 @@ private final String name;
2.StateService
介绍完 ConfigurationService 类之后,我们来关注 ShardingOrchestrationFacade 类中的另一个核心变量 StateService。
从命名上讲,StateService 这个类名有点模糊,更合适的叫法应该是 InstanceStateService,用于管理数据库实例的状态,即创建数据库运行节点并区分不同数据库访问实例。存放在注册中心中的数据结构包括 instances 和 datasources 节点,存储结构如下所示:
-
+
StateService 中保存着 StateNode 对象,StateNode 中的变量与上面的数据结构示例相对应,如下所示:
private static final String ROOT = "state";
private static final String INSTANCES_NODE_PATH = "instances";
diff --git a/专栏/Spring Boot 实战开发/00 开篇词 从零开始:为什么要学习 Spring Boot?.md.html b/专栏/Spring Boot 实战开发/00 开篇词 从零开始:为什么要学习 Spring Boot?.md.html
index 98c22c38..5eb946ed 100644
--- a/专栏/Spring Boot 实战开发/00 开篇词 从零开始:为什么要学习 Spring Boot?.md.html
+++ b/专栏/Spring Boot 实战开发/00 开篇词 从零开始:为什么要学习 Spring Boot?.md.html
@@ -179,8 +179,8 @@ function hide_canvas() {
越来越强大的 Spring Boot 俨然是 Java EE 领域的标准开发框架了。因此,掌握 Spring Boot 成了 Java 工程师的必备技能,而熟练掌握 Spring Boot 中的各项技术组件,并能够在一定程度上掌握其内部运行机制,是你从事 Java 应用程序开发的基本要求,也是你将来学习 Spring Cloud 等微服务开发框架的基础。
我自己也有着多家大型互联网公司的从业经验,日常也经常面试来自不同公司的 Java 工程师,在面试过程中,我对开发人员的要求:掌握 Spring Boot 已经不是一个加分项,而是一个必备技能。
你也可以上拉勾网站查看相关岗位职责,基本都有这条限制,以下是我截取的两份 Java 开发工程师岗的招聘要求:
-
-
+
+
(职位信息来源:拉勾网)
可以说,深入了解并掌握 Spring Boot 是你成功进入大公司、斩获高薪 Offer 的一个重要砝码。
这门课程是如何设计的?
@@ -193,7 +193,7 @@ function hide_canvas() {
虽然 Spring Boot 让你只花 20% 的时间就可解决 80% 的问题,但是剩下 20% 的问题需要我们通过系统性的学习去弄懂,而学习 Spring Boot 是有一定的方法和套路的。
为此,我根据个人多年的架构经验以及对 Spring Boot 的理解,整理出了一套系统化、由浅入深的学习路径,从中你不仅可以掌握 Spring Boot 的全局,更可以从学习三大难题入手一一突破,更加高效地掌握 Spring Boot 的使用方法和实战技巧。
基于如下图所示的 Web 应用程序的拆分维度,我把整个课程设计为 8 大部分,基于目前最主流的 Java EE 领域开发框架 Spring Boot,向你介绍如何从零构建一个 Web 应用程序。
-
+
Web 应用程序的拆分维度
- 第 1 部分,开启 Spring 框架的学习之旅。这部分将介绍 Spring 家族的整个生态系统和技术体系,并提供一个 Spring Customer Service System(简称 SpringCSS) 案例来贯穿整个 Spring Boot 框架的学习过程。
diff --git a/专栏/Spring Boot 实战开发/01 家族生态:如何正确理解 Spring 家族的技术体系?.md.html b/专栏/Spring Boot 实战开发/01 家族生态:如何正确理解 Spring 家族的技术体系?.md.html
index 84e53d56..e838ea8b 100644
--- a/专栏/Spring Boot 实战开发/01 家族生态:如何正确理解 Spring 家族的技术体系?.md.html
+++ b/专栏/Spring Boot 实战开发/01 家族生态:如何正确理解 Spring 家族的技术体系?.md.html
@@ -173,14 +173,14 @@ function hide_canvas() {
今天,我将通过一个课时的时间带领你梳理整个 Spring 家族中的技术体系,以及各种功能齐全的开发框架。让我们先来看一下 Spring 家族技术生态的全景图。
Spring 家族技术生态全景图
我们访问 Spring 的官方网站(https://spring.io/)来对这个框架做宏观的了解。在 Spring 的主页中,展示了下面这张图:
-
+
Spring 家族技术体系(来自 Spring 官网)
从图中可以看到,这里罗列了 Spring 框架的七大核心技术体系,分别是微服务架构、响应式编程、云原生、Web 应用、Serverless 架构、事件驱动以及批处理。
当然,这些技术体系各自独立但也有一定交集,例如微服务架构往往会与基于 Spring Cloud 的云原生技术结合在一起使用,而微服务架构的构建过程也需要依赖于能够提供 RESTful 风格的 Web 应用程序等。
另一方面,在具备特定的技术特点之外,这些技术体系也各有其应用场景。例如,如果我们想要实现日常报表等轻量级的批处理任务,而又不想引入 Hadoop 这套庞大的离线处理平台时,使用基于 Spring Batch 的批处理框架是一个不错的选择。再比方说,如果想要实现与 Kafka、RabbitMQ 等各种主流消息中间件之间的集成,但又希望开发人员不需要了解这些中间件在使用上的差别,那么使用基于 Spring Cloud Stream 的事件驱动架构是你的首选,因为这个框架对外提供了统一的 API,从而屏蔽了内部各个中间件在实现上的差异性。
我们无意对 Spring 中的所有七大技术体系做全面的展开。在日常开发过程中,如果构建单块 Web 服务,可以采用 Spring Boot。如果想要开发微服务架构,那么就需要使用基于 Spring Boot 的 Spring Cloud,而 Spring Cloud 同样内置了基于 Spring Cloud Stream 的事件驱动架构。同时,在这里我想特别强调的是响应式编程技术。响应式编程是 Spring 5 引入的最大创新,代表了一种系统架构设计和实现的技术方向。因此,今天我们也将从 Spring Boot、Spring Cloud 以及 Spring 响应式编程这三个技术体系进行切入,看看 Spring 具体能够为我们解决开发过程中的哪些问题。
当然,所有我们现在能看到的 Spring 家族技术体系都是在 Spring Framework 基础上逐步演进而来的。在介绍上述技术体系之前,我们先简单了解下 Spring Framework 的整体架构,如下图所示:
-
+
Spring Framework 整体架构图
Spring 从诞生之初就被认为是一种容器,上图中的“核心容器”部分就包含了一个容器所应该具备的核心功能,包括容器中基于依赖注入机制的 JavaBean 处理、面向切面 AOP、上下文 Context及 Spring 自身所提供的表达式工具等一些辅助功能。
图中最上面的两个框就是构建应用程序所需要的最核心的两大功能组件,也是我们日常开发中最常用的组件,即数据访问和 Web 服务。这两大部分功能组件中包含的内容非常多,而且充分体现了 Spring Framework 的集成性,也就是说,框架内部整合了业界主流的数据库驱动、消息中间件、ORM 框架等各种工具,开发人员可以根据需要灵活地替换和调整自己想要使用的工具。
@@ -212,20 +212,20 @@ public class DemoApplication {
可以看到,Spring Boot 的上述功能实际上从多个维度简化了 Web 应用程序的开关过程,这些维度包含编码、配置、部署和监控等。在 02 讲中,我们将通过一个具体的案例来对每个维度给出更为详细的描述。
Spring Cloud 与微服务架构
Spring Cloud 构建在 Spring Boot 基础之上,它的整体架构图如下所示:
-
+
Spring Cloud 与微服务整体架构图(来自 Spring 官网)
技术组件的完备性是 Spring Cloud 框架的主要优势,它集成了业界一大批知名的微服务开发组件。Spring Cloud 的核心组件如下图所示:
-
+
Spring Cloud 核心功能组件
可以看到,基于 Spring Boot 的开发便利性,Spring Cloud 巧妙地简化了微服务系统基础设施的开发过程,Spring Cloud 包含上图中所展示的服务发现注册、API 网关、配置中心、消息总线、负载均衡、熔断器、数据监控等。
Spring 5 与响应式编程
目前,Spring 已经演进到 5.X 版本。随着 Spring 5 的正式发布,我们迎来了响应式编程(Reactive Programming)的全新发展时期。Spring 5 中内嵌了与数据管理相关的响应式数据访问、与系统集成相关的响应式消息通信以及与 Web 服务相关的响应式 Web 框架等多种响应式组件,从而极大地简化了响应式应用程序的开发过程和开发难度。
下图展示了响应式编程的技术栈与传统的 Servlet 技术栈之间的对比:
-
+
响应式编程技术栈与 Servlet 技术栈之间的对比图(来自 Spring 官网)
从上图可以看到,上图左侧为基于 Spring WebFlux 的技术栈,右侧为基于 Spring MVC 的技术栈。我们知道传统的 Spring MVC 构建在 Java EE 的 Servlet 标准之上,该标准本身就是阻塞式和同步的,而 Spring WebFlux 基于响应式流,因此可以用来构建异步非阻塞的服务。
在 Spring 5 中,选取了 Project Reactor 作为响应式流的实现库。由于响应式编程的特性,Spring WebFlux 和 Project Reactor 的运行需要依赖于诸如 Netty 和 Undertow 等支持异步机制的容器。同时我们也可以选择使用较新版本的 Tomcat 和 Jetty 作为运行环境,因为它们支持异步 I/O 的 Servlet 3.1。下图更加明显地展示了 Spring MVC 和 Spring WebFlux 之间的区别和联系:
-
+
在基于 Spring Boot 以及 Spring Cloud 的应用程序中,Spring WebFlux 和 Spring MVC 可以混合进行使用。
讲完 Spring 家族的技术体系,让我们回到课程。在 01 讲中,我们主要围绕 Spring Boot 框架展开讨论,分别从配置体系、数据访问、Web 服务、消息通信、系统安全、系统监控、应用测试等维度对该框架进行深入的剖析,所采用的版本为 2.2.X 版。
小结与预告
diff --git a/专栏/Spring Boot 实战开发/02 案例驱动:如何剖析一个 Spring Web 应用程序?.md.html b/专栏/Spring Boot 实战开发/02 案例驱动:如何剖析一个 Spring Web 应用程序?.md.html
index 1cf9d426..727fc7a2 100644
--- a/专栏/Spring Boot 实战开发/02 案例驱动:如何剖析一个 Spring Web 应用程序?.md.html
+++ b/专栏/Spring Boot 实战开发/02 案例驱动:如何剖析一个 Spring Web 应用程序?.md.html
@@ -172,26 +172,26 @@ function hide_canvas() {
在 01 讲中,我们提到 Spring 家族具备很多款开源框架,开发人员可以基于这些开发框架实现各种 Spring 应用程序。在 02 讲中,我们无意对所有这些 Spring 应用程序的类型和开发方式过多展开,而是主要集中在基于 Spring Boot 开发面向 Web 场景的服务,这也是互联网应用程序最常见的表现形式。在介绍基于 Spring Boot 的开发模式之前,让我们先将它与传统的 Spring MVC 进行简单对比。
Spring MVC VS Spring Boot
在典型的 Web 应用程序中,前后端通常采用基于 HTTP 协议完成请求和响应,开发过程中需要完成 URL 地址的映射、HTTP 请求的构建、数据的序列化和反序列化以及实现各个服务自身内部的业务逻辑,如下图所示:
-
+
HTTP 请求响应过程
我们先来看基于 Spring MVC 完成上述开发流程所需要的开发步骤,如下图所示:
-
+
基于 Spring MVC 的 Web 应用程序开发流程
上图中包括使用 web.xml 定义 Spring 的 DispatcherServlet、完成启动 Spring MVC 的配置文件、编写响应 HTTP 请求的 Controller 以及将服务部署到 Tomcat Web 服务器等步骤。事实上,基于传统的 Spring MVC 框架开发 Web 应用逐渐暴露出一些问题,比较典型的就是配置工作过于复杂和繁重,以及缺少必要的应用程序管理和监控机制。
如果想优化这一套开发过程,有几个点值得我们去挖掘,比方说减少不必要的配置工作、启动依赖项的自动管理、简化部署并提供应用监控等。而这些优化点恰巧推动了以 Spring Boot 为代表的新一代开发框架的诞生,基于 Spring Boot 的开发流程见下图:
-
+
基于 Spring Boot 的 Web 应用程序开发流程
从上图中可以看到,它与基于 Spring MVC 的开发流程在配置信息的管理、服务部署和监控等方面有明显不同。作为 Spring 家族新的一员,Spring Boot 提供了令人兴奋的特性,这些特性的核心价值在于确保了开发过程的简单性,具体体现在编码、配置、部署、监控等多个方面。
首先,Spring Boot 使编码更简单。我们只需要在 Maven 中添加一项依赖并实现一个方法就可以提供微服务架构中所推崇的 RESTful 风格接口。
其次,Spring Boot 使配置更简单。它把 Spring 中基于 XML 的功能配置方式转换为 Java Config,同时提供了 .yml 文件来优化原有基于 .properties 和 .xml 文件的配置方案,.yml 文件对配置信息的组织更为直观方便,语义也更为强大。同时,基于 Spring Boot 的自动配置特性,对常见的各种工具和框架均提供了默认的 starter 组件来简化配置。
最后,在部署方案上,Spring Boot 也创造了一键启动的新模式。Spring Boot 部署包结构参考下图:
-
+
Spring Boot部署包结构
从图中我们可以看到,相较于传统模式下的 war 包,Spring Boot 部署包既包含了业务代码和各种第三方类库,同时也内嵌了 HTTP 容器。这种包结构支持 java –jar application.jar 方式的一键启动,不需要部署独立的应用服务器,通过默认内嵌 Tomcat 就可以运行整个应用程序。
最后,基于 Spring Boot 新提供的 Actuator 组件,开发和运维人员可以通过 RESTful 接口获取应用程序的当前运行时状态并对这些状态背后的度量指标进行监控和报警。例如可以通过“/env/{name}”端点获取系统环境变量、通过“/mapping”端点获取所有 RESTful 服务、通过“/dump”端点获取线程工作状态以及通过“/metrics/{name}”端点获取 JVM 性能指标等。
剖析一个 Spring Web 应用程序
针对一个基于 Spring Boot 开发的 Web 应用程序,其代码组织方式需要遵循一定的项目结构。在 02 讲中,如果不做特殊说明,我们都将使用 Maven 来管理项目工程中的结构和包依赖。一个典型的 Web 应用程序的项目结构如下图所示:
-
+
Spring Boot Web 项目结构图
在上图中,有几个地方需要特别注意,我也在图中做了专门的标注,分别是包依赖、启动类、控制器类以及配置,让我们讲此部分内容分别做一些展开。
包依赖
@@ -206,7 +206,7 @@ function hide_canvas() {
可以看到,这里包括了传统 Spring MVC 应用程序中会使用到的 spring-web 和 spring-webmvc 组件,因此 Spring Boot 在底层实现上还是基于这两个组件完成对 Web 请求响应流程的构建。
如果我们使用 Spring Boot 2.2.4 版本,你会发现它所依赖的 Spring 组件都升级到了 5.X 版本,如下图所示:
-
+
Spring Boot 2.2.4 版本的包依赖示意图
在应用程序中引入 spring-boot-starter-web 组件就像引入一个普通的 Maven 依赖一样,如下所示。
<dependency>
@@ -267,7 +267,7 @@ spring:
SpringCSS 整体架构
在 SpringCSS 中,存在一个 customer-service,这是一个 Spring Boot 应用程序,也是整个案例系统中的主体服务。在该服务中,我们可以将采用经典的分层架构,即将服务分成 Web 层、Service 层和 Repository 层。
在客服系统中,我们知道其核心业务是生成客户工单。为此,customer-service 一般会与用户服务 account-service 进行交互,但因为用户账户信息的更新属于低频事件,所以我们设计的实现方式是 account-service 通过消息中间件的方式将用户账户变更信息主动推送给 customer–service,从而完成用户信息的获取操作。而针对 order-service,其定位是订单系统,customer-service 也需要从该服务中查询订单信息。SpringCSS 的整个系统交互过程如下图所示:
-
+
SpringCSS 系统的整体架构图
在上图中,引出了构建 SpringCSS 的多项技术组件,在后续课程中我们会对这些技术组件做专题介绍。
从案例实战到原理剖析
diff --git a/专栏/Spring Boot 实战开发/03 多维配置:如何使用 Spring Boot 中的配置体系?.md.html b/专栏/Spring Boot 实战开发/03 多维配置:如何使用 Spring Boot 中的配置体系?.md.html
index 08525dba..92f5f495 100644
--- a/专栏/Spring Boot 实战开发/03 多维配置:如何使用 Spring Boot 中的配置体系?.md.html
+++ b/专栏/Spring Boot 实战开发/03 多维配置:如何使用 Spring Boot 中的配置体系?.md.html
@@ -173,7 +173,7 @@ function hide_canvas() {
创建第一个 Spring Boot Web 应用程序
基于 Spring Boot 创建 Web 应用程序的方法有很多,但最简单、最直接的方法是使用 Spring 官方提供的 Spring Initializer 初始化模板。
初始化使用操作:直接访问 Spring Initializer 网站(http://start.spring.io/),选择创建一个 Maven 项目并指定相应的 Group 和 Artifact,然后在添加的依赖中选择 Spring Web,点击生成即可。界面效果下图所示:
-
+
使用 Spring Initializer 创建 Web 应用程序示意图
当然,对于有一定开发经验的同学而言,我们完全可以基于 Maven 本身的功能特性和结构,来生成上图中的代码工程。
接下来,我们参考 02 讲中关于 Controller 的创建基本方法,来为这个代码工程添加一些支持 RESTful 风格的 HTTP 端点,在这里我们同样创建一个 CustomerController 类,如下所示:
@@ -201,7 +201,7 @@ public class CustomerController {
那么,如何验证服务是否启动成功,以及 HTTP 请求是否得到正确响应呢?在 03 讲中,我们引入 Postman 来演示如何通过 HTTP 协议暴露的端点进行远程服务访问。
Postman 提供了强大的 Web API 和 HTTP 请求调试功能,界面简洁明晰,操作也比较方便快捷和人性化。Postman 能够发送任何类型的 HTTP 请求(如 GET、HEAD、POST、PUT 等),并能附带任何数量的参数和 HTTP 请求头(Header)。
这时我们通过 Postman 访问“http://localhost:8083/customers/1”端点,可以得到如下图所示的HTTP响应结果,说明整个服务已经启动成功。
-
+
好了,现在我们已经明白如何构建、打包以及运行一个简单的 Web 应用程序了,这是一切开发工作的起点,后续所有的案例代码我们都将通过这种方式展现在你面前,包括接下来要介绍的 Spring Boot 配置体系也是一样。
Spring Boot 中的配置体系
在 Spring Boot 中,其核心设计理念是对配置信息的管理采用约定优于配置。在这一理念下,则意味着开发人员所需要设置的配置信息数量比使用传统 Spring 框架时还大大减少。当然,今天我们关注的主要是如何理解并使用 Spring Boot 中的配置信息组织方式,这里就需要引出一个核心的概念,即 Profile。
@@ -229,7 +229,7 @@ spring.datasource.username=root
spring.datasource.password=root
显然,类似这样的数据源通常会根据环境的不同而存在很多套配置。假设我们存在如下所示的配置文件集合:
-
+
多配置文件示意图
注意,这里有一个全局的 application.yml 配置文件以及多个局部的 profile 配置文件。那么,如何指定当前所使用的那一套配置信息呢?在 Spring Boot 中,我们可以在主 application.properties 中使用如下的配置方式来激活当前所使用的 Profile:
spring.profiles.active = test
diff --git a/专栏/Spring Boot 实战开发/04 定制配置:如何创建和管理自定义的配置信息?.md.html b/专栏/Spring Boot 实战开发/04 定制配置:如何创建和管理自定义的配置信息?.md.html
index 05d8308d..457d9c0c 100644
--- a/专栏/Spring Boot 实战开发/04 定制配置:如何创建和管理自定义的配置信息?.md.html
+++ b/专栏/Spring Boot 实战开发/04 定制配置:如何创建和管理自定义的配置信息?.md.html
@@ -243,10 +243,10 @@ public class SpringCssConfig {
可以看到这里通过创建一个 HashMap 来保存这些 Key-Value 对。类似的,我们也可以实现常见的一些数据结构的自动嵌入。
为自定义配置项添加提示功能
如果你已经使用过 Spring Boot 中的配置文件,并添加了一些内置的配置项,你就会发现,当我们输入某一个配置项的前缀时,诸如 IDEA、Eclipse 这样的,IDE 就会自动弹出该前缀下的所有配置信息供你进行选择,效果如下:
-
+
IDE 自动提示配置项的效果图
上图的效果对于管理自定义的配置信息非常有用。如何实现这种效果呢?当我们在 application.yml 配置文件中添加一个自定义配置项时,会注意到 IDE 会出现一个提示,说明这个配置项无法被 IDE 所识别,如下所示:
-
+
IDE 无法识别配置项时的示意图
遇到这种提示时,我们是可以忽略的,因为它不会影响到任何执行效果。但为了达到自动提示效果,我们就需要生成配置元数据。生成元数据的方法也很简单,直接通过 IDE 的“Create metadata for 'springcss.order.point'”按钮,就可以选择创建配置元数据文件,这个文件的名称为 additional-spring-configuration-metadata.json,文件内容如下所示:
{"properties": [{
@@ -256,7 +256,7 @@ public class SpringCssConfig {
}]}
现在,假如我们在 application.properties 文件中输入“springcss”,IDE 就会自动提示完整的配置项内容,效果如下所示:
-
+
IDE 自动提示 springcss 前缀的效果图
另外,假设我们需要为 springcss.order.point 配置项指定一个默认值,可以通过在元数据中添加一个"defaultValue"项来实现,如下所示:
{"properties": [{
@@ -267,7 +267,7 @@ public class SpringCssConfig {
}]}
这时候,在 IDE 中设置这个配置项时,就会提出该配置项的默认值为 10,效果如下所示:
-
+
IDE 自动提示包含默认值的 springcss 前缀效果图
如何组织和整合配置信息?
在上一课时中,我们提到了 Profile 概念,Profile 可以认为是管理配置信息中的一种有效手段。今天,我们继续介绍另一种组织和整合配置信息的方法,这种方法同样依赖于前面介绍的 @ConfigurationProperties 注解。
@@ -299,7 +299,7 @@ public class SpringCssConfig {
–classpath:/
以下是按照优先级从高到低的顺序,如下所示:
-
+
Spring Boot 会全部扫描上图中的这四个位置,扫描规则是高优先级配置内容会覆盖低优先级配置内容。而如果高优先级的配置文件中存在与低优先级配置文件不冲突的属性,则会形成一种互补配置,也就是说会整合所有不冲突的属性。
如何覆写内置的配置类?
关于 Spring Boot 配置体系,最后值得介绍的就是如何覆写它所提供的配置类。在前面的课程中,我们已经反复强调 Spring Boot 内置了大量的自动配置,如果我们不想使用这些配置,就需要对它们进行覆写。覆写的方法有很多,我们可以使用配置文件、Groovy 脚本以及 Java 代码。这里,我们就以Java代码为例来简单演示覆写配置类的实现方法。
diff --git a/专栏/Spring Boot 实战开发/06 基础规范:如何理解 JDBC 关系型数据库访问规范?.md.html b/专栏/Spring Boot 实战开发/06 基础规范:如何理解 JDBC 关系型数据库访问规范?.md.html
index 2099896e..290872b2 100644
--- a/专栏/Spring Boot 实战开发/06 基础规范:如何理解 JDBC 关系型数据库访问规范?.md.html
+++ b/专栏/Spring Boot 实战开发/06 基础规范:如何理解 JDBC 关系型数据库访问规范?.md.html
@@ -173,7 +173,7 @@ function hide_canvas() {
数据访问层的构建可能会涉及多种不同形式的数据存储媒介,本课程关注的是最基础也是最常用的数据存储媒介,即关系型数据库,针对关系型数据库,Java 中应用最广泛的就是 JDBC 规范,今天我们将对这个经典规范展开讨论。
JDBC 是 Java Database Connectivity 的全称,它的设计初衷是提供一套能够应用于各种数据库的统一标准,这套标准需要不同数据库厂家之间共同遵守,并提供各自的实现方案供 JDBC 应用程序调用。
作为一套统一标准,JDBC 规范具备完整的架构体系,如下图所示:
-
+
JDBC 规范整体架构图
从上图中可以看到,Java 应用程序通过 JDBC 所提供的 API 进行数据访问,而这些 API 中包含了开发人员所需要掌握的各个核心编程对象,下面我们一起来看下。
JDBC 规范中有哪些核心编程对象?
@@ -311,7 +311,7 @@ connection.close();
这段代码中完成了对基于前面介绍的 JDBC API 中的各个核心编程对象的数据访问。上述代码主要面向查询场景,而针对用于插入数据的处理场景,我们只需要在上述代码中替换几行代码,即将“执行查询”和“获取查询结果进行处理”部分的查询操作代码替换为插入操作代码就行。
最后,我们梳理一下基于 JDBC 规范进行数据库访问的整个开发流程,如下图所示:
-
+
基于 JDBC 规范进行数据库访问的开发流程图
针对前面所介绍的代码示例,我们明确地将基于 JDBC 规范访问关系型数据库的操作分成两大部分:一部分是准备和释放资源以及执行 SQL 语句,另一部分则是处理 SQL 执行结果。
而对于任何数据访问而言,前者实际上都是重复的。在上图所示的整个开发流程中,事实上只有“处理 ResultSet ”部分的代码需要开发人员根据具体的业务对象进行定制化处理。这种抽象为整个执行过程提供了优化空间。诸如 Spring 框架中 JdbcTemplate 这样的模板工具类就应运而生了,我们会在 07 讲中会详细介绍这个模板工具类。
diff --git a/专栏/Spring Boot 实战开发/09 数据抽象:Spring Data 如何对数据访问过程进行统一抽象?.md.html b/专栏/Spring Boot 实战开发/09 数据抽象:Spring Data 如何对数据访问过程进行统一抽象?.md.html
index 64967da4..8ba3c5df 100644
--- a/专栏/Spring Boot 实战开发/09 数据抽象:Spring Data 如何对数据访问过程进行统一抽象?.md.html
+++ b/专栏/Spring Boot 实战开发/09 数据抽象:Spring Data 如何对数据访问过程进行统一抽象?.md.html
@@ -179,7 +179,7 @@ function hide_canvas() {
}
在以上代码中,我们看到 Repository 接口只是一个空接口,通过泛型指定了领域实体对象的类型和 ID。在 Spring Data 中,存在一大批 Repository 接口的子接口和实现类,该接口的部分类层结构如下所示:
-
+
Repository 接口的部分类层结构图
可以看到 CrudRepository 接口是对 Repository 接口的最常见扩展,添加了对领域实体的 CRUD 操作功能,具体定义如下代码所示:
public interface CrudRepository<T, ID> extends Repository<T, ID> {
@@ -284,11 +284,11 @@ public @interface Query {
在上面的例子中,通过 findByFirstNameAndLastname 这样符合普通语义的方法名,并在参数列表中按照方法名中参数的顺序和名称(即第一个参数是 fistName,第二个参数 lastName)传入相应的参数,Spring Data 就能自动组装 SQL 语句从而实现衍生查询。是不是很神奇?
而想要使用方法名实现衍生查询,我们需要对 Repository 中定义的方法名进行一定约束。
首先我们需要指定一些查询关键字,常见的关键字如下表所示:
-
+
方法名衍生查询中查询关键字列表
有了这些查询关键字后,在方法命名上我们还需要指定查询字段和一些限制性条件。例如,在前面的示例中,我们只是基于“fistName”和“lastName”这两个字段做查询。
事实上,我们可以查询的内容非常多,下表列出了更多的方法名衍生查询示例,你可以参考下。
-
+
方法名衍生查询示例
在 Spring Data 中,方法名衍生查询的功能非常强大,上表中罗列的这些也只是全部功能中的一小部分而已。
讲到这里,你可能会问一个问题:如果我们在一个 Repository 中同时指定了 @Query 注解和方法名衍生查询,那么 Spring Data 会具体执行哪一个呢?要想回答这个问题,就需要我们对查询策略有一定的了解。
@@ -316,7 +316,7 @@ public @interface Query {
Spring Data 中的组件
Spring Data 支持对多种数据存储媒介进行数据访问,表现为提供了一系列默认的 Repository,包括针对关系型数据库的 JPA/JDBC Repository,针对 MongoDB、Neo4j、Redis 等 NoSQL 对应的 Repository,支持 Hadoop 的大数据访问的 Repository,甚至包括 Spring Batch 和 Spring Integration 在内的系统集成的 Repository。
在 Spring Data 的官方网站https://spring.io/projects/spring-data 中,列出了其提供的所有组件,如下图所示:
-
+
Spring Data 所提供的组件列表(来自 Spring Data 官网)
根据官网介绍,Spring Data 中的组件可以分成四大类:核心模块(Main modules)、社区模块(Community modules)、关联模块(Related modules)和正在孵化的模块(Modules in Incubation)。例如,前面介绍的 Respository 和多样化查询功能就在核心模块 Spring Data Commons 组件中。
这里,我特别想强调下的是正在孵化的模块,它目前只包含一个组件,即 Spring Data R2DBC。 R2DBC 是Reactive Relational Database Connectivity 的简写,代表响应式关系型数据库连接,相当于是响应式数据访问领域的 JDBC 规范。
diff --git a/专栏/Spring Boot 实战开发/11 服务发布:如何构建一个 RESTful 风格的 Web 服务?.md.html b/专栏/Spring Boot 实战开发/11 服务发布:如何构建一个 RESTful 风格的 Web 服务?.md.html
index 75019de4..0a5790d5 100644
--- a/专栏/Spring Boot 实战开发/11 服务发布:如何构建一个 RESTful 风格的 Web 服务?.md.html
+++ b/专栏/Spring Boot 实战开发/11 服务发布:如何构建一个 RESTful 风格的 Web 服务?.md.html
@@ -332,7 +332,7 @@ public class AccountController {
public void updateAccount(@RequestBody Account account) {
如果使用 @RequestBody 注解,我们可以在 Postman 中输入一个 JSON 字符串来构建输入对象,如下代码所示:
-
+
使用 Postman 输入 JSON 字符串发起 HTTP 请求示例图
通过以上内容的讲解,我们发现使用注解的操作很简单,接下来我们有必要探讨下控制请求输入的规则。
关于控制请求输入的规则,关键在于按照 RESTful 风格的设计原则设计 HTTP 端点,对于这点业界也存在一些约定。
diff --git a/专栏/Spring Boot 实战开发/12 服务调用:如何使用 RestTemplate 消费 RESTful 服务?.md.html b/专栏/Spring Boot 实战开发/12 服务调用:如何使用 RestTemplate 消费 RESTful 服务?.md.html
index ba4cb912..702e8d92 100644
--- a/专栏/Spring Boot 实战开发/12 服务调用:如何使用 RestTemplate 消费 RESTful 服务?.md.html
+++ b/专栏/Spring Boot 实战开发/12 服务调用:如何使用 RestTemplate 消费 RESTful 服务?.md.html
@@ -218,7 +218,7 @@ public RestTemplate customRestTemplate(){
这里我们创建了一个 HttpComponentsClientHttpRequestFactory 工厂类,它是 ClientHttpRequestFactory 接口的一个实现类。通过设置连接请求超时时间 ConnectionRequestTimeout、连接超时时间 ConnectTimeout 等属性,我们对 RestTemplate 的默认行为进行了定制化处理。
使用 RestTemplate 访问 Web 服务
在远程服务访问上,RestTemplate 内置了一批常用的工具方法,我们可以根据 HTTP 的语义以及 RESTful 的设计原则对这些工具方法进行分类,如下表所示。
-
+
RestTemplate 中的方法分类表
接下来,我们将基于该表对 RestTemplate 中的工具方法进行详细介绍并给出相关示例。不过在此之前,我们想先来讨论一下请求的 URL。
在一个 Web 请求中,我们可以通过请求路径携带参数。在使用 RestTemplate 时,我们也可以在它的 URL 中嵌入路径变量,示例代码如下所示:
diff --git a/专栏/Spring Boot 实战开发/14 消息驱动:如何使用 KafkaTemplate 集成 Kafka?.md.html b/专栏/Spring Boot 实战开发/14 消息驱动:如何使用 KafkaTemplate 集成 Kafka?.md.html
index b9b891cf..3437da25 100644
--- a/专栏/Spring Boot 实战开发/14 消息驱动:如何使用 KafkaTemplate 集成 Kafka?.md.html
+++ b/专栏/Spring Boot 实战开发/14 消息驱动:如何使用 KafkaTemplate 集成 Kafka?.md.html
@@ -179,12 +179,12 @@ function hide_canvas() {
在用户账户信息变更时,account-service 首先会发送一个消息告知某个用户账户信息已经发生变化,然后通知所有对该消息感兴趣的服务。而在 SpringCSS 案例中,这个服务就是 customer-service,相当于是这个消息的订阅者和消费者。
通过这种方式,customer-service 就可以快速获取用户账户变更消息,从而正确且高效地处理本地的用户账户数据。
整个场景的示意图见下图:
-
+
用户账户更新场景中的消息通信机制
上图中我们发现,消息通信机制使得我们不必花费太大代价即可实现整个交互过程,简单而方便。
消息通信机制简介
消息通信机制的整体工作流程如下图所示:
-
+
消息通信机制示意图
上图中位于流程中间的就是各种消息中间件,消息中间件一般提供了消息的发送客户端和接收客户端组件,这些客户端组件会嵌入业务服务中。
消息的生产者负责产生消息,在实际业务中一般由业务系统充当生产者;而消息的消费者负责消费消息,在实际业务中一般是后台系统负责异步消费。
@@ -196,7 +196,7 @@ function hide_canvas() {
在讨论如何使用 KafkaTemplate 实现与 Kafka 之间的集成方法之前,我们先来简单了解 Kafka 的基本架构,再引出 Kafka 中的几个核心概念。
Kafka 基本架构
Kafka 基本架构参考下图,从中我们可以看到 Broker、Producer、Consumer、Push、Pull 等消息通信系统常见概念在 Kafka 中都有所体现,生产者使用 Push 模式将消息发布到 Broker,而消费者使用 Pull 模式从 Broker 订阅消息。
-
+
Kafka 基本架构图
在上图中我们注意到,Kafka 架构图中还使用了 Zookeeper。
Zookeeper 中存储了 Kafka 的元数据及消费者消费偏移量(Offset),其作用在于实现 Broker 和消费者之间的负载均衡。因此,如果我们想要运行 Kafka,首先需要启动 Zookeeper,再启动 Kafka 服务器。
@@ -271,7 +271,7 @@ public @interface KafkaListener {
设计消费者组的目的是应对集群环境下的多服务实例问题。显然,如果采用发布-订阅模式会导致一个服务的不同实例可能会消费到同一条消息。
为了解决这个问题,Kafka 中提供了消费者组的概念。一旦我们使用了消费组,一条消息只能被同一个组中的某一个服务实例所消费。
消费者组的基本结构如下图所示:
-
+
Kafka 消费者组示意图
使用 @KafkaListener 注解时,我们把它直接添加在处理消息的方法上即可,如下代码所示:
@KafkaListener(topics = “demo.topic”)
diff --git a/专栏/Spring Boot 实战开发/15 消息驱动:如何使用 JmsTemplate 集成 ActiveMQ?.md.html b/专栏/Spring Boot 实战开发/15 消息驱动:如何使用 JmsTemplate 集成 ActiveMQ?.md.html
index 0dff54da..86814a89 100644
--- a/专栏/Spring Boot 实战开发/15 消息驱动:如何使用 JmsTemplate 集成 ActiveMQ?.md.html
+++ b/专栏/Spring Boot 实战开发/15 消息驱动:如何使用 JmsTemplate 集成 ActiveMQ?.md.html
@@ -315,7 +315,7 @@ public void handlerEvent(DemoEvent event) {
首先,我们来回顾下《多维配置:如何使用 Spring Boot 中的配置体系?》的内容介绍,在 Spring Boot 中,我们可以通过 Profile 有效管理针对不同场景和环境的配置信息。
而在 SpringCSS 案例中,Kafka、ActiveMQ 及 16 讲将要介绍的 RabbitMQ 都是消息中间件,在案例系统运行过程中,我们需要选择其中一种中间件演示消息发送和接收到过程,这样我们就需要针对不同的中间件设置不同的 Profile 了。
在 account-service 中,我们可以根据 Profile 构建如下所示的配置文件体系。
-
+
account-service 中的配置文件
从以上图中可以看到:根据三种不同的中间件,我们分别提供了三个配置文件。以其中的 application-activemq.yml 为例,其包含的配置项如下代码所示:
spring:
diff --git a/专栏/Spring Boot 实战开发/17 安全架构:如何理解 Spring 安全体系的整体架构?.md.html b/专栏/Spring Boot 实战开发/17 安全架构:如何理解 Spring 安全体系的整体架构?.md.html
index 97c55e65..c9e948d1 100644
--- a/专栏/Spring Boot 实战开发/17 安全架构:如何理解 Spring 安全体系的整体架构?.md.html
+++ b/专栏/Spring Boot 实战开发/17 安全架构:如何理解 Spring 安全体系的整体架构?.md.html
@@ -178,22 +178,22 @@ function hide_canvas() {
所谓认证,即首先需要明确“你是谁”这个问题,也就是说系统能针对每次访问请求判断出访问者是否具有合法的身份标识。
一旦明确了 “你是谁”,我们就能判断出“你能做什么”,这个步骤就是授权。一般来说,通用的授权模型都是基于权限管理体系,即对资源、权限、角色和用户的进行组合处理的一种方案。
当我们把认证与授权结合起来后,即先判断资源访问者的有效身份,然后确定其对这个资源进行访问的合法权限,整个过程就形成了对系统进行安全性管理的一种常见解决方案,如下图所示:
-
+
基于认证和授权机制的资源访问安全性示意图
上图就是一种通用方案,而在不同的应用场景及技术体系下,系统可以衍生出很多具体的实现策略,比如 Web 应用系统中的认证和授权模型虽然与上图类似,但是在具体设计和实现过程中有其特殊性。
在 Web 应用体系中,因为认证这部分的需求相对比较明确,所以我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理利用。
而授权的情况相对来说复杂些,比如对某个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求具备访问自己的权限。解决完这个第一个问题后,就算这个请求具备访问该应用程序的权限,并不意味着它能够访问其所具有的所有 HTTP 端点,比如业务上的某些核心功能还是需要具备较高的权限才能访问,这就涉及我们需要解决的第二个问题——如何对访问的权限进行精细化管理?如下图所示:
-
+
Web 应用程序访问授权效果示意图
在上图中,假设该请求具备对 Web 应用程序的访问权限,但不具备访问应用程序中端点 1 的权限,如果想实现这种效果,一般我们的做法是引入角色体系:首先对不同的用户设置不同等级的角色(即角色等级不同对应的访问权限也不同),再把每个请求绑定到某个角色(即该请求具备了访问权限)。
接下来我们把认证和授权进行结合,梳理出了 Web 应用程序访问场景下的安全性实现方案,如下图所示:
-
+
认证和授权整合示意图
从上图我们可以看到,用户首先通过请求传递用户凭证完成用户认证,然后根据该用户信息中所具备的角色信息获取访问权限,最终完成对 HTTP 端点的访问授权。
对一个 Web 应用程序进行安全性设计时,我们首先需要考虑认证和授权,因为它们是核心考虑点。在技术实现场景中,只要涉及用户认证,势必会涉及用户密码等敏感信息的加密。针对用户密码的场景,我们主要使用单向散列加密算法对敏感信息进行加密。
关于单向散列加密算法,它常用于生成消息摘要(Message Digest),主要特点为单向不可逆和密文长度固定,同时具备“碰撞”少的优点,即明文的微小差异会导致生成的密文完全不同。其中,常见的单向散列加密实现算法为 MD5(Message Digest 5)和 SHA(Secure Hash Algorithm)。而在 JDK 自带的 MessageDigest 类中,因为它已经包含了这些算法的默认实现,所以我们直接调用方法即可。
在日常开发过程中,对于密码进行加密的典型操作时序图如下所示:
-
+
单向散列加密与加盐机制
上图中,我们引入了加盐(Salt)机制,进一步提升了加密数据的安全性。所谓加盐就是在初始化明文数据时,系统自动往明文中添加一些附加数据,然后再进行散列。
目前,单向散列加密及加盐思想已被广泛用于系统登录过程中的密码生成和校验过程中,比如接下来我们将要引入的 Spring Security 框架。
@@ -203,11 +203,11 @@ function hide_canvas() {
这一讲我们先不对如何使用 Spring Security 框架展开说明,而是先从高层次梳理该框架对前面提到的各项安全性需求提供的架构设计。
Spring Security 中的过滤器链
与业务中大多数处理 Web 请求的框架对比后,我们发现 Spring Security 中采用的是管道-过滤器(Pipe-Filter)架构模式,如下图所示:
-
+
管道-过滤器架构模式示意图
在上图中我们可以看到,处理业务逻辑的组件称为过滤器,而处理结果的相邻过滤器之间的连接件称为管道,它们构成了一组过滤器链,即 Spring Security 的核心。
项目一旦启动,过滤器链将会实现自动配置,如下图所示:
-
+
Spring Security 中的过滤器链
在上图中,我们看到了 BasicAuthenticationFilter、UsernamePasswordAuthenticationFilter 等几个常见的 Filter,这些类可以直接或间接实现 Servlet 类中的 Filter 接口,并完成某一项具体的认证机制。例如,上图中的 BasicAuthenticationFilter 用来认证用户的身份,而 UsernamePasswordAuthenticationFilter 用来检查输入的用户名和密码,并根据认证结果来判断是否将结果传递给下一个过滤器。
这里请注意,整个 Spring Security 过滤器链的末端是一个 FilterSecurityInterceptor,本质上它也是一个 Filter,但它与其他用于完成认证操作的 Filter 不同,因为它的核心功能是用来实现权限控制,即判定该请求是否能够访问目标 HTTP 端点。因为我们可以把 FilterSecurityInterceptor 对权限控制的粒度划分到方法级别,所以它能够满足前面提到的精细化访问控制。
@@ -250,7 +250,7 @@ function hide_canvas() {
}
围绕上述方法,通过翻阅 Spring Security 源代码,我们引出了该框架中一系列核心类,并梳理了它们之间的交互结构,如下图所示:
-
+
Spring Security 核心类图
上图中的很多类,通过名称我们就能明白它的含义和作用。
以位于左下角的 SecurityContextHolder 为例,它是一个典型的 Holder 类,存储了应用的安全上下文对象 SecurityContext,包含系统请求中最近使用的认证信息。这里我们大胆猜想它的内部肯定使用了 ThreadLocal 来确保线程访问的安全性。
diff --git a/专栏/Spring Boot 实战开发/18 用户认证:如何基于 Spring Security 构建用户认证体系?.md.html b/专栏/Spring Boot 实战开发/18 用户认证:如何基于 Spring Security 构建用户认证体系?.md.html
index c776aebe..626d9150 100644
--- a/专栏/Spring Boot 实战开发/18 用户认证:如何基于 Spring Security 构建用户认证体系?.md.html
+++ b/专栏/Spring Boot 实战开发/18 用户认证:如何基于 Spring Security 构建用户认证体系?.md.html
@@ -180,14 +180,14 @@ function hide_canvas() {
请注意,只要我们在代码工程中添加了上述依赖,包含在该工程中的所有 HTTP 端点都将被保护起来。
例如,在 SpringCSS 案例的 account-service 中,我们知道存在一个 AccountController ,且它暴露了一个“accounts/ /{accountId}”端点。现在,我们启动 account-service 服务并访问上述端点,弹出了如下图所示的界面内容:
-
+
添加 Spring Security 之后自动出现的登录界面
同时,在系统的启动控制台日志中,我们发现了如下所示的新的日志信息。
Using generated security password: 17bbf7c4-456a-48f5-a12e-a680066c8f80
在这里可以看到,Spring Security 为我们自动生成了一个密码,我们可以基于“user”这个账号及上述密码登录这个界面,抽空你也可以尝试下。
如果我们使用了 Postman 可视化 HTTP 请求工具,可以设置授权类型为“Basic Auth”并输入对应的用户名和密码完成对 HTTP 端点的访问,设置界面如下图所示:
-
+
使用 Postman 来完成认证信息的设置
事实上,在引入 spring-boot-starter-security 依赖之后,Spring Security 会默认创建一个用户名为“user”的账号。很显然,每次启动应用时,通过 Spring Security 自动生成的密码都会有所变化,因此它不适合作为一种正式的应用方法。
如果我们想设置登录账号和密码,最简单的方式是通过配置文件。例如,我们可以在 account-service 的 application.yml 文件中添加如下代码所示的配置项:
@@ -336,7 +336,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}
Spring Security 中内置了一大批 PasswordEncoder 接口的实现类,如下图所示:
-
+
Spring Security 中的 PasswordEncoder 实现类
上图中,比较常用的算法如 SHA-256 算法的 StandardPasswordEncoder、bcrypt 强哈希算法的 BCryptPasswordEncoder 等。而在实际案例中,我们使用的是 BCryptPasswordEncoder,它的 encode 方法如下代码所示:
public String encode(CharSequence rawPassword) {
diff --git a/专栏/Spring Boot 实战开发/19 服务授权:如何基于 Spring Security 确保请求安全访问?.md.html b/专栏/Spring Boot 实战开发/19 服务授权:如何基于 Spring Security 确保请求安全访问?.md.html
index ae684744..725b1868 100644
--- a/专栏/Spring Boot 实战开发/19 服务授权:如何基于 Spring Security 确保请求安全访问?.md.html
+++ b/专栏/Spring Boot 实战开发/19 服务授权:如何基于 Spring Security 确保请求安全访问?.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
最后, httpBasic() 语句使用 HTTP 协议中的 Basic Authentication 方法完成认证。
18 讲中我们也演示了如何使用 Postman 完成认证的方式,这里就不过多赘述了。
当然,Spring Security 中还提供了很多其他有用的配置方法供开发人员灵活使用,下表中我们进行了列举,一起来看下。
-
+
基于上表中的配置方法,我们就可以通过 HttpSecurity 实现自定义的授权策略。
比方说,我们希望针对“/orders”根路径下的所有端点进行访问控制,且只允许认证通过的用户访问,那么可以创建一个继承了 WebSecurityConfigurerAdapter 类的 SpringCssSecurityConfig,并覆写其中的 configure(HttpSecurity http) 方法来实现,如下代码所示:
@Configuration
diff --git a/专栏/Spring Boot 实战开发/20 服务监控:如何使用 Actuator 组件实现系统监控?.md.html b/专栏/Spring Boot 实战开发/20 服务监控:如何使用 Actuator 组件实现系统监控?.md.html
index 9eeb893a..f3a68a57 100644
--- a/专栏/Spring Boot 实战开发/20 服务监控:如何使用 Actuator 组件实现系统监控?.md.html
+++ b/专栏/Spring Boot 实战开发/20 服务监控:如何使用 Actuator 组件实现系统监控?.md.html
@@ -291,7 +291,7 @@ function hide_canvas() {
- 操作控制类: 在原生端点中只提供了一个关闭应用的端点,即 /shutdown 端点。
根据 Spring Boot Actuator 默认提供的端点列表,我们将部分常见端点的类型、路径和描述梳理在如下表格中,仅供参考。
-
+
通过访问上表中的各个端点,我们就可以获取自己感兴趣的监控信息了。例如访问了http://localhost:8082/actuator/health端点,我们就可以得到如下所示的 account-service 基本状态。
{
"status":"UP"
@@ -328,7 +328,7 @@ function hide_canvas() {
扩展 Info 端点
Info 端点用于暴露 Spring Boot 应用的自身信息。在 Spring Boot 内部,它把这部分工作委托给了一系列 InfoContributor 对象,而 Info 端点会暴露所有 InfoContributor 对象所收集的各种信息。
在Spring Boot 中包含了很多自动配置的 InfoContributor 对象,常见的 InfoContributor 及其描述如下表所示:
-
+
以上表中的 EnvironmentInfoContributor 为例,通过在配置文件中添加格式以“info”作为前缀的配置段,我们就可以定义 Info 端点暴露的数据。添加完成后,我们将看到所有在“info”配置段下的属性都将被自动暴露。
比如你可以将如下所示配置信息添加到配置文件 application.yml 中:
info:
diff --git a/专栏/Spring Boot 实战开发/22 运行管理:如何使用 Admin Server 管理 Spring 应用程序?.md.html b/专栏/Spring Boot 实战开发/22 运行管理:如何使用 Admin Server 管理 Spring 应用程序?.md.html
index ce8d6d96..44c05e70 100644
--- a/专栏/Spring Boot 实战开发/22 运行管理:如何使用 Admin Server 管理 Spring 应用程序?.md.html
+++ b/专栏/Spring Boot 实战开发/22 运行管理:如何使用 Admin Server 管理 Spring 应用程序?.md.html
@@ -202,7 +202,7 @@ public class AdminApplication {
此时,我们会发现使用这种方式构建 Spring Boot Admin Server 就是这么简单。
接下来我们启动这个 Spring Boot 应用程序,并打开 Web 界面,就能看到如下所示的效果:
-
+
Spring Boot Admin Server 启动效果图
从图中我们可以看到,目前还没有一个应用程序与 Admin Server 有关联。如果想将应用程序与 Admin Server 进行关联,我们还需要对原有的 Spring Boot 应用程序做一定的改造。
首先,我们在 Maven 依赖中引入对 Spring Boot Admin Client 组件的依赖,如下代码所示:
@@ -220,7 +220,7 @@ public class AdminApplication {
注意:这里的 9000 就是 Admin Server 的服务器端口。
现在我们启动这个应用程序,就会发现 Admin Server 中已经出现了这个应用的名称和地址,如下图所示:
-
+
Spring Boot Admin Server 添加了应用程序之后的效果图
在图中,我们看到 APPLICATIONS 和 INSTANCES 的数量都是 1,代表 Admin Server 管理着一个应用程序,而该应用程序只有一个运行实例。在界面的下方,我们还能看到这个应用的名称及实例地址。这里你可以尝试使用不同的端口启动应用程序的不同实例,然后观察这个列表的变化。
基于注册中心构建 Admin Server
@@ -249,7 +249,7 @@ public class EurekaServerApplication {
注意:在上面的代码中,我们在启动类上加了一个@EnableEurekaServer 注解。在 SpringCloud 中,包含 @EnableEurekaServer 注解的服务也就是一个 Eureka 服务器组件。这样,Eureka 服务就构建完毕了。
同样,Eureka 服务还为我们提供了一个可视化的 UI 界面,它可以用来观察当前注册到 Eureka 中的应用程序信息,如下图所示:
-
+
Eureka 服务监控页面
接下来,我们需要 Admin Server 也做相应调整。首先,我们在 pom 文件中添加一个对 spring-cloud-starter-netflix-eureka-client 这个 Eureka 客户端组件的依赖:
<dependency>
@@ -279,7 +279,7 @@ public class EurekaServerApplication {
根据 Spring Boot Admin 官方 Github 上的介绍,Admin Server 监控系统提供了一套完整的可视化方案。基于 Admin Server,健康状态、JVM、内存、Micrometer 的度量、线程、HTTP 跟踪等核心功能都可以通过可视化的 UI 界面进行展示。
监控系统运行时关键指标
注意到 Admin Server 菜单中有一个“Wallboard”,点击该菜单,我们就可以看到一面应用墙,如下图所示:
-
+
Admin Server 应用墙
点击应用墙中的某个应用,我们就能进入针对该应用的监控信息主界面。在该界面的左侧,包含了监控功能的各级目录,如下图所示:

@@ -290,11 +290,11 @@ public class EurekaServerApplication {
Admin Server 中的 JVM 监控信息
这些 JVM 数据都是通过可视化的方式进行展现,并随着运行时状态的变化而实时更新。
在 21 讲中,我们详细讨论了 Spring Boot Actuator 中的度量指标。而在 Admin Server 中,同样存在一个“Metrics”菜单,展示效果如下图所示:
-
+
Admin Server 中的 Metrics 信息
在“Metrics”菜单中,开发人员可以通过对各种条件进行筛选,然后添加对应的度量指标。比如上图中,我们针对 HTTP 请求中 /actuator/health 端点进行了过滤,从而得到了度量结果。
接着我们一起看看系统环境方面的属性,因为这方面的属性非常之多,所以 Admin Server 也提供了一个过滤器,如下图所示:
-
+
Admin Server 中的 Environment 信息
在上图中,通过输入“spring.”参数,我们就能获取一系列与该参数相关的环境属性。
日志也是我们监控系统的一个重要途径,在 Admin Server 的“Loggers”菜单中,可以看到该应用程序的所有日志信息,如下图所示:
@@ -303,7 +303,7 @@ public class EurekaServerApplication {
通过”springcss”关键词对这些日志进行过滤,我们就可以获取 SpringCSS 案例中的日志详细了,图中也显示了每个日志记录器对应的日志级别。
最后,我们来看一下 Admin Server 中的“JVM”菜单,该菜单下存在两个子菜单:“Thread Dump”和“Heap Dump”。
以“Thread Dump”为例,尽管 Actuator 提供了 /threaddump 端点,但开发人员只能获取触发该端点时的 Dump 信息,而 Admin Server 则提供了一个连续性的可视化监控界面,如下图所示:
-
+
Admin Server 中的 Thread Dump 信息
点击图中的色条,我们就可以获取每一个线程的详细信息了,这里你可以尝试做一些分析。
控制访问安全性
@@ -323,7 +323,7 @@ public class EurekaServerApplication {
password: "springcss_password"
重启 Admin Server 后,再次访问 Web 界面时,就需要我们输入用户名和密码了,如下图所示:
-
+
Admin Server 的安全登录界面
小结与预告
可视化监控一直是开发和运维人员管理应用程序运行时状态的基础诉求,而 Spring Boot Admin 组件正是这样一款可视化的工具。它基于 Spring Boot Actuator 中各个端点所暴露的监控信息,并加以整合和集成。今天的内容首先介绍了构建 Admin Server 以及 Admin Client 的方法,并剖析了 Admin Server 中所具有的一整套的可视化解决方案。
diff --git a/专栏/Spring Boot 实战开发/23 数据测试:如何使用 Spring 测试数据访问层组件?.md.html b/专栏/Spring Boot 实战开发/23 数据测试:如何使用 Spring 测试数据访问层组件?.md.html
index 8dbf10c1..5622d4ea 100644
--- a/专栏/Spring Boot 实战开发/23 数据测试:如何使用 Spring 测试数据访问层组件?.md.html
+++ b/专栏/Spring Boot 实战开发/23 数据测试:如何使用 Spring 测试数据访问层组件?.md.html
@@ -187,7 +187,7 @@ function hide_canvas() {
其中,最后一个依赖用于导入与 JUnit 相关的功能组件。
然后,通过 Maven 查看 spring-boot-starter-test 组件的依赖关系,我们可以得到如下所示的组件依赖图:
-
+
spring-boot-starter-test 组件的依赖关系图
在《案例驱动:如何剖析一个 Spring Web 应用程序?》中我们提到,Spring Boot 使得编码、配置、部署和监控工作更简单。事实上,Spring Boot 也能让测试工作更加简单。
从上图中可以看到,在代码工程的构建路径中,我们引入了一系列组件初始化测试环境。比如 JUnit、JSON Path、AssertJ、Mockito、Hamcrest 等,这里我们有必要对这些组件进行展开说明。
@@ -216,7 +216,7 @@ public class CustomerApplication {
针对上述 Bootstrap 类,我们可以通过编写测试用例的方式,验证 Spring 容器能否正常启动。
为了添加测试用例,我们有必要梳理一下代码的组织结构,梳理完后就呈现了如下图所示的customer-service 工程中代码的基本目录结构。
-
+
customer-service 工程代码目录结构
基于 Maven 的默认风格,我们将在 src/test/java 和 src/test/resources 包下添加各种测试用例代码和配置文件,正如上图所示。
打开上图中的 ApplicationContextTests.java 文件,我们可以得到如下所示的测试用例代码:
diff --git a/专栏/Spring Boot 实战开发/结束语 以终为始:Spring Boot 总结和展望.md.html b/专栏/Spring Boot 实战开发/结束语 以终为始:Spring Boot 总结和展望.md.html
index 1fb5ca61..04756ead 100644
--- a/专栏/Spring Boot 实战开发/结束语 以终为始:Spring Boot 总结和展望.md.html
+++ b/专栏/Spring Boot 实战开发/结束语 以终为始:Spring Boot 总结和展望.md.html
@@ -201,14 +201,14 @@ function hide_canvas() {
目前 Spring 已经演进到 5.X 版本,随着 Spring 5 的正式发布,我们迎来了响应式编程(Reactive Programming)的全新发展时期。
Spring 5 中内嵌了与数据管理相关的响应式数据访问、与系统集成相关的响应式消息通信,以及与 Web 服务相关的响应式 Web 框架等多种响应式组件,从而极大简化了响应式应用程序的开发过程和难度。
以支持响应式 Web 的 Spring WebFlux 为例,这里我们给出它的架构图,如下图所示:
-
+
Spring WebFlux 架构图(来自 Spring 官网)
在图中我们可以看到,上图左侧为基于 Spring Webflux 的技术栈,右侧为基于 Spring MVC 的技术栈。我们知道传统的 Spring MVC 是在 Java EE 的 Servlet 标准之上进行构建的,该标准本身就是阻塞式和同步式。而 Spring WebFlux 基于响应式流进行构建,因此我们可以使用它来构建异步非阻塞的服务。
随着 WebFlux 等响应式编程技术的兴起,它为构建具有即时响应性和回弹性的应用程序提供了一个很好的技术基础。
我们知道一个分布式系统中,可能存在数十乃至数百个独立的 Web 应用程序,它们之间互相通信以完成复杂的业务流程,而这个过程势必涉及大量的 I/O 操作。
一旦涉及 I/O 操作,尤其是阻塞式 I/O 操作将会整体增加系统的延迟并降低吞吐量。如果我们能够在复杂的流程中集成非阻塞、异步通信机制,就可以高效处理跨服务之间的网络请求。针对这种场景,WebFlux 也是一种非常有效的解决方案。
下面我们再来看一下 Spring Boot 2 的另一张官网架构图,如下图所示:
-
+
Spring Boot 2 架构图(来自 Spring 官网)
从图中我们可以看到,上图底部将 Spring Data 明确划分为两大类型:一类是支持 JDBC、JPA 和部分 NoSQL 的传统 Spring Data Repository,另一类则是支持 Mongo、Cassandra、Redis、Couchbase 等的响应式 Spring Data Reactive Repository。
这张图背后的意义在于,Spring Boot 可以帮助我们构建从 Web 服务层到数据访问层的全栈式响应式编程技术,从而确保系统的各个环节都具备即时响应性。
diff --git a/专栏/Spring Security 详解与实操/00 开篇词 Spring Security,为你的应用安全与职业之路保驾护航.md.html b/专栏/Spring Security 详解与实操/00 开篇词 Spring Security,为你的应用安全与职业之路保驾护航.md.html
index 406ea8f3..236943fd 100644
--- a/专栏/Spring Security 详解与实操/00 开篇词 Spring Security,为你的应用安全与职业之路保驾护航.md.html
+++ b/专栏/Spring Security 详解与实操/00 开篇词 Spring Security,为你的应用安全与职业之路保驾护航.md.html
@@ -161,12 +161,12 @@ function hide_canvas() {
说到安全性,你可能会想到加解密算法、HTTPS 协议等常见的技术体系,但系统安全是一个综合性的主题,并非简单采用一些技术体系就能构建有效的解决方案。
我们以一个分布式环境下的应用场景为例。假设你要开发一个工单系统,而生成工单所依赖的用户订单信息维护在第三方订单系统中。为了生成工单,就必须让工单系统读取订单系统中的用户订单信息。
那么问题来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:
-
+
订单系统用户认证和授权交互示意图
上述方案看起来没有什么问题,但如果你仔细分析一下,就会发现这个流程在安全性上存在一些漏洞。
比如,一旦用户修改了订单管理平台的密码,工单系统就无法正常访问了。为此,我们需要引入诸如 OAuth2 协议完成分布式环境下的认证和授权。
我们通过一张图简单对比一下 OAuth2 协议与传统实现方案:
-
+
OAuth2 协议与传统实现方案的对比图
但是想要实现 OAuth2 协议并没有那么简单。OAuth2 协议涉及的技术体系非常复杂,需要综合考虑用户认证、密码加解密和存储、Token 生成和校验、分布式 Session 和公私钥管理,以及完成各个客户端的权限管理。这时就需要引入专门的安全性开发框架,而Spring Security 就是这样一款开发框架。
Spring Security 专门提供了 UAA(User Account and Authentication,用户账户和认证)服务,封装了 OAuth2 协议,用于管理用户账户、OAuth2 客户端以及用于鉴权的 Token。而 UAA 也只是 Spring Security 众多核心功能中的一部分。
@@ -185,7 +185,7 @@ function hide_canvas() {
- ……
同时,在普遍倡导用户隐私和数据价值的当下,掌握各种安全性相关技术的开发人员和架构师也成了稀缺人才,越发受到行业的认可和重视。
-
+
(职位信息来源:拉勾网)
对于开发人员而言,如何使用各种技术体系解决安全性问题是一大困惑。经验丰富的开发人员需要熟练使用 Spring Security 框架来应对业务发展的需求。例如,全面掌握 Spring Security 框架提供的认证、授权、方法级安全访问、OAuth2、JWT 等核心功能,构建自己对系统安全性设计的知识体系和解决方案。
而对于架构师而言,难点在于如何基于框架提供的功能并结合具体的业务场景,对框架进行扩展和定制化开发。这就需要他们对 Spring Security 中的用户认证和访问授权等核心功能的设计原理有充分的了解,能够从源码级别剖析框架的底层实现机制,进而满足更深层次的需求。
diff --git a/专栏/Spring Security 详解与实操/01 顶级框架:Spring Security 是一款什么样的安全性框架?.md.html b/专栏/Spring Security 详解与实操/01 顶级框架:Spring Security 是一款什么样的安全性框架?.md.html
index 3f47889a..147aad06 100644
--- a/专栏/Spring Security 详解与实操/01 顶级框架:Spring Security 是一款什么样的安全性框架?.md.html
+++ b/专栏/Spring Security 详解与实操/01 顶级框架:Spring Security 是一款什么样的安全性框架?.md.html
@@ -173,7 +173,7 @@ public class DemoController {
}
现在,启动这个 Spring Boot 应用程序,然后通过浏览器访问"/hello"端点。你可能希望得到的是"Hello World!"这个输出结果,但事实上,浏览器会跳转到一个如下所示的登录界面:
-
+
Spring Security 内置的登录界面
那么,为什么会弹出这个登录界面呢?原因就在于我们添加了 spring-boot-starter-security 依赖之后,Spring Security 为应用程序自动嵌入了用户认证机制。
接下来,我们围绕这个登录场景,分析如何获取登录所需的用户名和密码。我们注意到在 Spring Boot 的控制台启动日志中,出现了如下所示的一行日志:
@@ -188,28 +188,28 @@ public class DemoController {
首先我们需要明确,所谓认证,解决的是“你是谁”这一个问题,也就是说对于每一次访问请求,系统都能判断出访问者是否具有合法的身份标识。
一旦明确 “你是谁”,下一步就可以判断“你能做什么”,这个步骤就是授权。通用的授权模型大多基于权限管理体系,即对资源、权限、角色和用户的一种组合处理。
如果我们将认证和授权结合起来,就构成了对系统中的资源进行安全性管理的最常见解决方案,即先判断资源访问者的有效身份,再来确定其是否有对这个资源进行访问的合法权限,如下图所示:
-
+
基于认证和授权机制的资源访问安全性示意图
上图代表的是一种通用方案,而不同的应用场景和技术体系下可以衍生出很多具体的实现策略。Web 应用系统中的认证和授权模型与上图类似,但在具体设计和实现过程中也有其特殊性。
针对认证,这部分的需求相对比较明确。显然我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理的利用。
而针对授权,情况可能会比较复杂。对于某一个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求是否具备访问自己的权限。更进一步,就算这个请求具备访问该应用程序的权限,但并不意味着该请求能够访问应用程序所有的 HTTP 端点。某些核心功能需要具备较高的权限才能访问,而有些则不需要。这就是我们需要解决的第二个问题,如何对访问的权限进行精细化管理?如下图所示:
-
+
Web 应用程序访问授权效果示意图
在上图中,我们假设该请求具备对应用程序中端点 2、3、4 的访问权限,但不具备访问端点 1 的权限。想要达到这种效果,一般的做法是引入角色体系。我们对不同的用户设置不同等级的角色,角色等级不同对应的访问权限也不同。而每一个请求都可以绑定到某一个角色,也就具备了访问权限。
接下来,我们把认证和授权结合起来,梳理出 Web 应用程序访问场景下的安全性实现方案,如下图所示:
-
+
单体服务下的认证和授权整合示意图
结合示意图我们可以看到,通过请求传递用户凭证完成用户认证,然后根据该用户信息中具备的角色信息获取访问权限,并最终完成对 HTTP 端点的访问授权。
围绕认证和授权,我们还需要一系列的额外功能确保整个流程得以实现。这些功能包括用于密码保护的加解密机制、用于实现方法级的安全访问,以及支持跨域等,这些功能在我们专栏的后续内容中都会一一展开讨论。
Spring Security 与微服务架构
微服务架构的情况要比单体应用复杂很多,因为涉及了服务与服务之间的调用关系。我们继续沿用“资源”这个概念,对应到微服务系统中,服务提供者充当的角色就是资源的服务器,而服务消费者就是客户端。所以各个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。
接下来,我们把认证和授权结合起来,梳理出微服务访问场景下的安全性实现方案,如下图所示:
-
+
微服务架构下的认证和授权整合示意图
可以看到,与单体应用相比,在微服务架构中需要把认证和授权的过程进行集中化管理,所以在上图中出现了一个授权中心。 授权中心会获取客户端请求中所带有的身份凭证信息,然后基于凭证信息生成一个 Token,这个 Token 中就包含了权限范围和有效期。
客户端获取 Token 之后就可以基于这个 Token 发起对微服务的访问。这个时候,服务的提供者需要对这个 Token 进行认证,并根据 Token 的权限范围和有效期从授权中心获取该请求能够访问的特定资源。在微服务系统中,对外的资源表现形式同样可以理解为一个个 HTTP 端点。
上图中关键点就在于构建用于生成和验证 Token 的授权中心,为此我们需要引入OAuth2 协议。OAuth2 协议为我们在客户端程序和资源服务器之间设置了一个授权层,并确保 Token 能够在各个微服务中进行有效传递,如下图所示:
-
+
OAuth2 协议在服务访问场景中的应用
OAuth2 是一个相对复杂的协议,综合应用摘要认证、签名认证、HTTPS 等安全性手段,需要提供 Token 生成和校验以及公私钥管理等功能,同时需要开发者入驻并进行权限粒度控制。一般我们应该避免自行实现这类复杂的协议,倾向于借助于特定工具以免重复造轮子。而 Spring Security 为我们提供了实现这一协议的完整解决方案,我们可以使用该框架完成适用于微服务系统中的认证授权机制。
Spring Security 与响应式系统
@@ -218,7 +218,7 @@ public class DemoController {
小结与预告
本讲是整个专栏内容的第一讲,我们通过一个简单的示例引入了 Spring Security 框架,并基于日常开发的安全需求,全面剖析了 Spring Security 框架具备的功能体系。不同的功能对应不同的应用场景,在普通的单体应用、微服务架构、响应式系统中都可以使用 Spring Security 框架提供的各种功能确保系统的安全性。
本讲内容总结如下:
-
+
这里给你留一道思考题:针对单体应用和微服务架构,你能分别描述它们所需要的认证和授权机制吗?
接下来我们将正式进入到 Spring Security 框架各项功能的学习过程中,首先介绍的就是用户认证功能。下一讲,我们将讨论如何基于 Spring Security 对用户进行有效的认证。
diff --git a/专栏/Spring Security 详解与实操/02 用户认证:如何使用 Spring Security 构建用户认证体系?.md.html b/专栏/Spring Security 详解与实操/02 用户认证:如何使用 Spring Security 构建用户认证体系?.md.html
index 065b64b1..f9b70f55 100644
--- a/专栏/Spring Security 详解与实操/02 用户认证:如何使用 Spring Security 构建用户认证体系?.md.html
+++ b/专栏/Spring Security 详解与实操/02 用户认证:如何使用 Spring Security 构建用户认证体系?.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
显然,响应码 401 告诉我们没有访问该地址的权限。同时,在响应中出现了一个“WWW-Authenticate”消息头,其值为“Basic realm="Realm"”,这里的 Realm 表示 Web 服务器中受保护资源的安全域。
现在,让我们来执行 HTTP 基础认证,可以通过设置认证类型为“Basic Auth”并输入对应的用户名和密码来完成对 HTTP 端点的访问,设置界面如下所示:
-
+
使用 Postman 完成 HTTP 基础认证信息的设置
现在查看 HTTP 请求,可以看到 Request Header 中添加了 Authorization 标头,格式为:Authorization: <type>
<credentials
>。这里的 type 就是“Basic”,而 credentials 则是这样一个字符串:
dXNlcjo5YjE5MWMwNC1lNWMzLTQ0YzctOGE3ZS0yNWNkMjY3MmVmMzk=
@@ -218,7 +218,7 @@ function hide_canvas() {
}
formLogin() 方法的执行效果就是提供了一个默认的登录界面,如下所示:
-
+
Spring Security 默认的登录界面
我们已经在上一讲中看到过这个登录界面。对于登录操作而言,这个登录界面通常都是定制化的,同时,我们也需要对登录的过程和结果进行细化控制。此时,我们就可以通过如下所示的配置内容来修改系统的默认配置:
@Override
@@ -293,7 +293,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
小结与预告
这一讲我们详细介绍了如何使用 Spring Security 构建用户认证体系的系统方法。在 Spring Security 中,认证相关的功能都是可以通过配置体系进行定制化开发和管理的。通过简单的配置方法,我们可以组合使用 HTTP 基础认证和表单登录认证,也可以分别基于内存以及基于数据库方案来存储用户信息,这些功能都是 Spring Security 内置的。
本讲内容总结如下:
-
+
最后我想给你留一道思考题:你知道在 Spring Security 中有哪几种存储用户信息的实现方案吗?欢迎在留言区和我分享你的想法。
diff --git a/专栏/Spring Security 详解与实操/03 认证体系:如何深入理解 Spring Security 用户认证机制?.md.html b/专栏/Spring Security 详解与实操/03 认证体系:如何深入理解 Spring Security 用户认证机制?.md.html
index 8e9c44c2..a4c496cf 100644
--- a/专栏/Spring Security 详解与实操/03 认证体系:如何深入理解 Spring Security 用户认证机制?.md.html
+++ b/专栏/Spring Security 详解与实操/03 认证体系:如何深入理解 Spring Security 用户认证机制?.md.html
@@ -166,7 +166,7 @@ function hide_canvas() {
- UserDetailsManager:扩展 UserDetailsService,添加了创建用户、修改用户密码等功能。
这四个对象之间的关联关系如下图所示,显然,对于由 UserDetails 对象所描述的一个用户而言,它应该具有 1 个或多个能够执行的 GrantedAuthority:
-
+
Spring Security 中的四大核心用户对象
结合上图,我们先来看承载用户详细信息的 UserDetails 接口,如下所示:
public interface UserDetails extends Serializable {
@@ -284,7 +284,7 @@ public final C withUser(UserDetails userDetails) {
而 withUser 方法返回的是一个 UserDetailsBuilder 对象,该对象内部使用了前面介绍的 UserBuilder 对象,因此可以实现类似 .withUser("spring_user").password("password1").roles("USER") 这样的链式语法,完成用户信息的设置。这也是上一讲中,我们在介绍基于内存的用户信息存储方案时使用的方法。
作为总结,我们也梳理了 Spring Security 中与用户对象相关的一大批实现类,它们之间的关系如下图所示:
-
+
Spring Security 中用户对象相关类结构图
Spring Security 中的认证对象
有了用户对象,我们就可以讨论具体的认证过程了,首先来看认证对象 Authentication,如下所示:
@@ -353,7 +353,7 @@ public final C withUser(UserDetails userDetails) {
可以看到,这里使用了 AuthenticationManager 而不是 AuthenticationProvider 中的 authenticate() 方法来执行认证。同时,我们也注意到这里出现了 UsernamePasswordAuthenticationToken 类,这就是 Authentication 接口的一个具体实现类,用来存储用户认证所需的用户名和密码信息。
同样作为总结,我们也梳理了 Spring Security 中与认证对象相关的一大批核心类,它们之间的关系如下所示:
-
+
Spring Security 中认证的对象相关类结构图
实现定制化用户认证方案
通过前面的分析,我们明确了用户信息存储的实现过程实际上是可以定制化的。Spring Security 所做的工作只是把常见的、符合一般业务场景的实现方式嵌入到了框架中。如果有特殊的场景,开发人员完全可以实现自定义的用户信息存储方案。
@@ -436,7 +436,7 @@ public class SpringUserDetailsService
我们知道 UserDetailsService 接口只有一个 loadUserByUsername 方法需要实现。因此,我们基于 SpringUserRepository 的 findByUsername 方法,根据用户名从数据库中查询数据。
扩展 AuthenticationProvider
扩展 AuthenticationProvider 的过程就是提供一个自定义的 AuthenticationProvider 实现类。这里我们以最常见的用户名密码认证为例,梳理自定义认证过程所需要实现的步骤,如下所示:
-
+
自定义 AuthenticationProvider 的实现流程图
上图中的流程并不复杂,首先我们需要通过 UserDetailsService 获取一个 UserDetails 对象,然后根据该对象中的密码与认证请求中的密码进行匹配,如果一致则认证成功,反之抛出一个 BadCredentialsException 异常。示例代码如下所示:
@Component
@@ -492,7 +492,7 @@ public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
小结与预告
这一讲我们基于 Spring Security 提供的用户认证功能分析了其背后的实现过程。我们的切入点在于分析与用户和认证相关的各个核心类,并梳理它们之间的交互过程。另一方面,我们还通过扩展 UserDetailsService 和 AuthenticationProvider 接口的方式来实现定制化的用户认证方案。
本讲内容总结如下:
-
+
最后给你留一道思考题:基于 Spring Security,如何根据用户名和密码实现一套定制化的用户认证解决方案?欢迎在留言区和我分享你的想法。
diff --git a/专栏/Spring Security 详解与实操/04 密码安全:Spring Security 中包含哪些加解密技术?.md.html b/专栏/Spring Security 详解与实操/04 密码安全:Spring Security 中包含哪些加解密技术?.md.html
index 092e9c14..6697fc6d 100644
--- a/专栏/Spring Security 详解与实操/04 密码安全:Spring Security 中包含哪些加解密技术?.md.html
+++ b/专栏/Spring Security 详解与实操/04 密码安全:Spring Security 中包含哪些加解密技术?.md.html
@@ -157,7 +157,7 @@ function hide_canvas() {
通过前面两讲内容的学习,相信你已经掌握了 Spring Security 中的用户认证体系。用户认证的过程通常涉及密码的校验,因此密码的安全性也是我们需要考虑的一个核心问题。Spring Security 作为一款功能完备的安全性框架,一方面提供了用于完成认证操作的 PasswordEncoder 组件,另一方面也包含一个独立而完整的加密模块,方便在应用程序中单独使用。
PasswordEncoder
我们先来回顾一下整个用户认证流程。在 AuthenticationProvider 中,我们需要使用 PasswordEncoder 组件验证密码的正确性,如下图所示:
-
+
PasswordEncoder 组件与认证流程之间的关系
在“用户认证:如何使用 Spring Security 构建用户认证体系?”一讲中我们也介绍了基于数据库的用户信息存储方案:
@Override
@@ -184,7 +184,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
}
Spring Security 内置了一大批 PasswordEncoder 接口的实现类,如下所示:
-
+
Spring Security 中的 PasswordEncoder 实现类
我们对上图中比较常见的几个 PasswordEncoder 接口展开叙述。
@@ -262,7 +262,7 @@ PasswordEncoder p = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
在前面的讨论中,我们都基于一个假设,即在对密码进行加解密过程中,只会使用到一个 PasswordEncoder,如果这个 PasswordEncoder 不满足我们的需求,那么就需要替换成另一个 PasswordEncoder。这就引出了一个问题,如何优雅地应对这种变化呢?
在普通的业务系统中,由于业务系统也在不断地变化,替换一个组件可能并没有很高的成本。但对于 Spring Security 这种成熟的开发框架而言,在设计和实现上不能经常发生变化。因此,在新/旧 PasswordEncoder 的兼容性,以及框架自身的稳健性和可变性之间需要保持一种平衡。为了实现这种平衡性,Spring Security 提供了 DelegatingPasswordEncoder。
虽然 DelegatingPasswordEncoder 也实现了 PasswordEncoder 接口,但事实上,它更多扮演了一种代理组件的角色,这点从命名上也可以看出来。DelegatingPasswordEncoder 将具体编码的实现根据要求代理给不同的算法,以此实现不同编码算法之间的兼容并协调变化,如下图所示:
-
+
DelegatingPasswordEncoder 的代理作用示意图
下面我们来看一下 DelegatingPasswordEncoder 类的构造函数,如下所示:
public DelegatingPasswordEncoder(String idForEncode,
@@ -395,7 +395,7 @@ byte [] decrypted = e.decrypt(encrypted);
小结与预告
对于一个 Web 应用程序而言,一旦需要实现用户认证,势必涉及用户密码等敏感信息的加密。为此,Spring Security 专门提供了 PasswordEncoder 组件对密码进行加解密。Spring Security 内置了一批即插即用的 PasswordEncoder,并通过代理机制完成了各个组件的版本兼容和统一管理。这种设计思想也值得我们学习和借鉴。当然,作为一款通用的安全性开发框架,Spring Security 也提供了一个高度独立的加密模块应对日常开发需求。
本讲内容总结如下:
-
+
这里给你留一道思考题:你能描述 DelegatingPasswordEncoder 所起到的代理作用吗?欢迎在留言区和我分享你的思考。
diff --git a/专栏/Spring Security 详解与实操/05 访问授权:如何对请求的安全访问过程进行有效配置?.md.html b/专栏/Spring Security 详解与实操/05 访问授权:如何对请求的安全访问过程进行有效配置?.md.html
index 12bd47b5..910ce420 100644
--- a/专栏/Spring Security 详解与实操/05 访问授权:如何对请求的安全访问过程进行有效配置?.md.html
+++ b/专栏/Spring Security 详解与实操/05 访问授权:如何对请求的安全访问过程进行有效配置?.md.html
@@ -170,10 +170,10 @@ function hide_canvas() {
同样,在 02 讲中我们也已经看到过上述代码,这是 Spring Security 中作用于访问授权的默认实现方法。
基于权限进行访问控制
我们先来回顾一下 03 讲“账户体系:如何深入理解 Spring Security 的认证机制?”中介绍的用户对象以及它们之间的关联关系:
-
+
Spring Security 中的核心用户对象
上图中的 GrantedAuthority 对象代表的就是一种权限对象,而一个 UserDetails 对象具备一个或多个 GrantedAuthority 对象。通过这种关联关系,实际上我们就可以对用户的权限做一些限制,如下所示:
-
+
使用权限实现访问控制示意图
如果用代码来表示这种关联关系,可以采用如下所示的实现方法:
UserDetails user = User.withUsername("jianxiang")
@@ -208,7 +208,7 @@ http.authorizeRequests().anyRequest().access(expression);
上述代码的效果是只有拥有“CREATE”权限且不拥有“Retrieve”权限的用户才能进行访问。
基于角色进行访问控制
讨论完权限,我们再来看角色,你可以把角色看成是拥有多个权限的一种数据载体,如下图所示,这里我们分别定义了两个不同的角色“User”和“Admin”,它们拥有不同的权限:
-
+
使用角色实现访问控制示意图
讲到这里,你可能会认为 Spring Security 应该提供了一个独立的数据结构来承载角色的含义。但事实上,在 Spring Security 中,并没有定义类似“GrantedRole”这种专门用来定义用户角色的对象,而是复用了 GrantedAuthority 对象。事实上,以“ROLE_”为前缀的 GrantedAuthority 就代表了一种角色,因此我们可以使用如下方式初始化用户的角色:
UserDetails user = User.withUsername("jianxiang")
@@ -395,7 +395,7 @@ UserDetails user2 = User.withUsername("jianxiang2")
小结与预告
这一讲我们关注的是对请求访问进行授权,而这个过程需要明确 Spring Security 中的用户、权限和角色之间的关联关系。一旦我们对某个用户设置了对应的权限和角色,那么就可以通过各种配置方法来有效控制访问权限。为此,Spring Security 也提供了 MVC 匹配器、Ant 匹配器以及正则表达式匹配器来实现复杂的访问控制。
本讲内容总结如下:
-
+
最后我想给你留一道思考题:在 Spring Security 中,你知道用户角色与用户权限之间有什么区别和联系吗?欢迎你在留言区和我分享自己的观点。
diff --git a/专栏/Spring Security 详解与实操/06 权限管理:如何剖析 Spring Security 的授权原理?.md.html b/专栏/Spring Security 详解与实操/06 权限管理:如何剖析 Spring Security 的授权原理?.md.html
index 4784d869..f8ac07da 100644
--- a/专栏/Spring Security 详解与实操/06 权限管理:如何剖析 Spring Security 的授权原理?.md.html
+++ b/专栏/Spring Security 详解与实操/06 权限管理:如何剖析 Spring Security 的授权原理?.md.html
@@ -168,7 +168,7 @@ function hide_canvas() {
SecurityMetadataSource 接口定义了一组方法来操作这些权限配置,具体权限配置的表现形式是ConfigAttribute 接口。通过 ExpressionInterceptUrlRegistry 和 AuthorizedUrl,我们能够把配置信息转变为具体的 ConfigAttribute。
当我们获取了权限配置信息后,就可以根据这些配置决定 HTTP 请求是否具有访问权限,也就是执行授权决策。Spring Security 专门提供了一个 AccessDecisionManager 接口完成该操作。而在 AccessDecisionManager 接口中,又把具体的决策过程委托给了 AccessDecisionVoter 接口。AccessDecisionVoter 可以被认为是一种投票器,负责对授权决策进行表决。
以上三个步骤构成了 Spring Security 的授权整体工作流程,可以用如下所示的时序图表示:
-
+
Spring Security 的授权整体工作流程
接下来,我们基于这张类图分别对拦截请求、获取权限配置、执行授权决策三个步骤逐一展开讲解。
拦截请求
@@ -385,12 +385,12 @@ List<AccessDecisionVoter<?>> getDecisionVoters(H http) {
显然,最终的评估过程只是简单使用了 Spring 所提供的 SpEL 表达式语言。
作为总结,我们把这一流程中涉及的核心组件以类图的形式进行了梳理,如下图所示:
-
+
Spring Security 授权相关核心类图
小结与预告
这一讲我们关注的是 Spring Security 授权机制的实现原理,我们把整个授权过程拆分成拦截请求、获取访问策略和执行授权决策这三大步骤。针对每一个步骤,都涉及了一组核心类及其它们之间的交互关系。针对这些核心类的讲解思路是围绕着上一讲介绍的基本配置方法展开讨论的,确保实际应用能与源码分析衔接在一起。
本讲内容总结如下:
-
+
最后给你留一道思考题:在 Spring Security 中,你能简要描述整个授权机制的执行过程吗?
diff --git a/专栏/Spring Security 详解与实操/07 案例实战:使用 Spring Security 基础功能保护 Web 应用.md.html b/专栏/Spring Security 详解与实操/07 案例实战:使用 Spring Security 基础功能保护 Web 应用.md.html
index d69ada9d..cb410610 100644
--- a/专栏/Spring Security 详解与实操/07 案例实战:使用 Spring Security 基础功能保护 Web 应用.md.html
+++ b/专栏/Spring Security 详解与实操/07 案例实战:使用 Spring Security 基础功能保护 Web 应用.md.html
@@ -161,7 +161,7 @@ function hide_canvas() {
这个 Web 应用程序将采用经典的三层架构,即Web 层、服务层和数据访问层,因此我们会存在 HealthRecordController、HealthRecordService 以及 HealthRecordRepository,这是一条独立的代码流程,用来完成系统业务逻辑处理。
另一方面,本案例的核心功能是实现自定义的用户认证流程,所以我们需要构建独立的 UserDetailsService 以及 AuthenticationProvider,这是另一条独立的代码流程。而在这条代码流程中,势必还需要 User 以及 UserRepository 等组件。
我们可以把这两条代码线整合在一起,得到案例的整体设计蓝图,如下图所示:
-
+
案例中的业务代码流程和用户认证流程
系统初始化
要想实现上图中的效果,我们需要先对系统进行初始化。这部分工作涉及领域对象的定义、数据库初始化脚本的整理以及相关依赖组件的引入。
@@ -489,16 +489,16 @@ public class HealthRecordController {
这里我们从 Model 对象中获取了认证用户信息以及健康档案信息,并渲染在页面上。
案例演示
现在,让我们启动 Spring Boot 应用程序,并访问http://localhost:8080端点。因为访问系统的任何端点都需要认证,所以 Spring Security 会自动跳转到如下所示的登录界面:
-
+
用户登录界面
我们分别输入用户名“jianxiang”和密码“12345”,系统就会跳转到健康档案主页:
-
+
健康档案主页
在这个主页中,我们正确获取了登录用户的用户名,并展示了个人健康档案信息。这个结果也证实了自定义用户认证体系的正确性。你可以根据示例代码做一些尝试。
小结与预告
这一讲我们动手实践了“利用 Spring Security 基础功能保护 Web 应用程序”。综合第 2 讲到 6 讲中的核心知识点,我们设计了一个简单而又完整的案例,并通过构建用户管理和认证流程讲解了实现自定义用户认证机制的过程。
本讲内容总结如下:
-
+
最后给你留一道思考题:在 Spring Security 中,实现一套自定义的用户认证体系需要哪些开发步骤?
diff --git a/专栏/Spring Security 详解与实操/08 管道过滤:如何基于 Spring Security 过滤器扩展安全性?.md.html b/专栏/Spring Security 详解与实操/08 管道过滤:如何基于 Spring Security 过滤器扩展安全性?.md.html
index 5eaa4a0e..028dc6b2 100644
--- a/专栏/Spring Security 详解与实操/08 管道过滤:如何基于 Spring Security 过滤器扩展安全性?.md.html
+++ b/专栏/Spring Security 详解与实操/08 管道过滤:如何基于 Spring Security 过滤器扩展安全性?.md.html
@@ -159,7 +159,7 @@ function hide_canvas() {
过滤器是一种通用机制,在处理 Web 请求的过程中发挥了重要作用。可以说,目前市面上所有的 Web 开发框架都或多或少使用了过滤器完成对请求的处理,Spring Security 也不例外。Spring Security 中的过滤器架构是基于 Servlet构建的,所以我们先从 Servlet 中的过滤器开始说起。
Servlet 与管道-过滤器模式
和业界大多数处理 Web 请求的框架一样,Servlet 中采用的最基本的架构就是管道-过滤器(Pipe-Filter)架构模式。管道-过滤器架构模式的示意图如下所示:
-
+
管道-过滤器架构模式示意图
结合上图我们可以看到,处理业务逻辑的组件被称为过滤器,而处理结果通过相邻过滤器之间的管道进行传输,这样就构成了一个过滤器链。
在 Servlet 中,代表过滤器的 Filter 接口定义如下:
@@ -181,7 +181,7 @@ function hide_canvas() {
请注意,过滤器链中的过滤器是有顺序的,这点非常重要,我们在本讲后续内容中会针对这点展开讲解。
Spring Security 中的过滤器链
在 Spring Security 中,其核心流程的执行也是依赖于一组过滤器,这些过滤器在框架启动后会自动进行初始化,如图所示:
-
+
Spring Security 中的过滤器链示意图
在上图中,我们看到了几个常见的 Filter,比如 BasicAuthenticationFilter、UsernamePasswordAuthenticationFilter 等,这些类都直接或间接实现了 Servlet 中的 Filter 接口,并完成某一项具体的认证机制。例如,上图中的 BasicAuthenticationFilter 用来验证用户的身份凭证;而 UsernamePasswordAuthenticationFilter 会检查输入的用户名和密码,并根据认证结果决定是否将这一结果传递给下一个过滤器。
请注意,整个 Spring Security 过滤器链的末端是一个 FilterSecurityInterceptor,它本质上也是一个 Filter。但与其他用于完成认证操作的 Filter 不同,它的核心功能是实现权限控制,也就是用来判定该请求是否能够访问目标 HTTP 端点。FilterSecurityInterceptor 对于权限控制的粒度可以到方法级别,能够满足前面提到的精细化访问控制。我们在 06 讲“权限管理:如何剖析 Spring Security 的授权原理?”中已经对这个拦截器做了详细的介绍,这里就不再展开了。
@@ -210,7 +210,7 @@ function hide_canvas() {
这里我们定义了一个 LoggingFilter,用来记录已经通过用户认证的请求中包含的一个特定的消息头“UniqueRequestId”,通过这个唯一的请求 Id,我们可以对请求进行跟踪、监控和分析。在实现一个自定义的过滤器组件时,我们通常会从 ServletRequest 中获取请求数据,并在 ServletResponse 中设置响应数据,然后通过 filterChain 的 doFilter() 方法将请求继续在过滤器链上进行传递。
接下来,我们想象这样一种场景,业务上我们需要根据客户端请求头中是否包含某一个特定的标志位,来决定请求是否有效。如图所示:
-
+
根据标志位设计过滤器示意图
这在现实开发过程中也是一种常见的应用场景,可以实现定制化的安全性控制。针对这种应用场景,我们可以实现如下所示的 RequestValidationFilter 过滤器:
public class RequestValidationFilter implements Filter {
@@ -234,11 +234,11 @@ function hide_canvas() {
现在,我们已经实现了几个有价值的过滤器了,下一步就是将这些过滤器整合到 Spring Security 的整个过滤器链中。这里,我想特别强调一点,和 Servlet 中的过滤器一样,Spring Security 中的过滤器也是有顺序的。也就是说,将过滤器放置在过滤器链的具体位置需要符合每个过滤器本身的功能特性,不能将这些过滤器随意排列组合。
我们来举例说明合理设置过滤器顺序的重要性。在[“用户认证:如何使用 Spring Security 构建用户认证体系?”]一讲中我们提到了 HTTP 基础认证机制,而在 Spring Security 中,实现这一认证机制的就是 BasicAuthenticationFilter。
如果我们想要实现定制化的安全性控制策略,就可以实现类似前面介绍的 RequestValidationFilter 这样的过滤器,并放置在 BasicAuthenticationFilter 前。这样,在执行用户认证之前,我们就可以排除掉一批无效请求,效果如下所示:
-
+
RequestValidationFilter 的位置示意图
上图中的 RequestValidationFilter 确保那些没有携带有效请求头信息的请求不会执行不必要的用户认证。基于这种场景,把 RequestValidationFilter 放在 BasicAuthenticationFilter 之后就不是很合适了,因为用户已经完成了认证操作。
同样,针对前面已经构建的 LoggingFilter,原则上我们可以把它放在过滤器链的任何位置,因为它只记录了日志。但有没有更合适的位置呢?结合 RequestValidationFilter 来看,同样对于一个无效的请求而言,记录日志是没有什么意义的。所以 LoggingFilter 应该放置在 RequestValidationFilter 之后。另一方面,对于日志操作而言,通常只需要记录那些已经通过认证的请求,所以也推荐将 LoggingFilter 放在 BasicAuthenticationFilter 之后。最终,这三个过滤器之间的关系如下图所示:
-
+
三个过滤器的位置示意图
在 Spring Security 中,提供了一组可以往过滤器链中添加过滤器的工具方法,包括 addFilterBefore()、addFilterAfter()、addFilterAt() 以及 addFilter() 等,它们都定义在 HttpSecurity 类中。这些方法的含义都很明确,使用起来也很简单,例如,想要实现如上图所示的效果,我们可以编写这样的代码:
@Override
@@ -257,7 +257,7 @@ protected void configure(HttpSecurity http) throws Exception {
这里,我们使用了 addFilterBefore() 和 addFilterAfter() 方法在 BasicAuthenticationFilter 之前和之后分别添加了 RequestValidationFilter 和 LoggingFilter。
Spring Security 中的过滤器
下表列举了 Spring Security 中常用的过滤器名称、功能以及它们的顺序关系:
-
+
Spring Security 中的常见过滤器一览表
这里以最基础的 UsernamePasswordAuthenticationFilter 为例,该类的定义及核心方法 attemptAuthentication 如下所示:
public class UsernamePasswordAuthenticationFilter extends
@@ -294,7 +294,7 @@ protected void configure(HttpSecurity http) throws Exception {
}
围绕上述方法,我们结合前面已经介绍的认证和授权相关实现原理,可以引出该框架中一系列核心类并梳理它们之间的交互结构,如下图所示:
-
+
UsernamePasswordAuthenticationFilter 相关核心类图
上图中的很多类,我们通过名称就能明白它的含义和作用。以位于左下角的 SecurityContextHolder 为例,它是一个典型的 Holder 类,存储了应用的安全上下文对象 SecurityContext,而这个上下文对象中就包含了用户的认证信息。
我们也可以大胆猜想,它的内部应该使用 ThreadLocal 确保线程访问的安全性。更具体的,我们已经在“权限管理:如何剖析 Spring Security 的授权原理?”中讲解过 SecurityContext 的使用方法。
@@ -302,7 +302,7 @@ protected void configure(HttpSecurity http) throws Exception {
小结与预告
这一讲我们关注于 Spring Security 中的一个核心组件——过滤器。在请求-响应式处理框架中,过滤器发挥着重要的作用,它用来实现对请求的拦截,并定义认证和授权逻辑。同时,我们也可以根据需要实现各种自定义的过滤器组件,从而实现对 Spring Security 的动态扩展。本讲对 Spring Security 中的过滤器架构和开发方式都做了详细的介绍,你可以反复学习。
本讲内容总结如下:
-
+
最后,给你留一道思考题:在 Spring Security 中,你能简单描述使用过滤器实现用户认证的操作过程吗?欢迎你在留言区和我分享自己的观点。
diff --git a/专栏/Spring Security 详解与实操/09 攻击应对:如何实现 CSRF 保护和跨域 CORS?.md.html b/专栏/Spring Security 详解与实操/09 攻击应对:如何实现 CSRF 保护和跨域 CORS?.md.html
index c60170c5..df7c39fc 100644
--- a/专栏/Spring Security 详解与实操/09 攻击应对:如何实现 CSRF 保护和跨域 CORS?.md.html
+++ b/专栏/Spring Security 详解与实操/09 攻击应对:如何实现 CSRF 保护和跨域 CORS?.md.html
@@ -159,7 +159,7 @@ function hide_canvas() {
我们先来看 CSRF。CSRF 的全称是 Cross-Site Request Forgery,翻译成中文就是跨站请求伪造。那么,究竟什么是跨站请求伪造,面对这个问题我们又该如何应对呢?请继续往下看。
什么是 CSRF?
从安全的角度来讲,你可以将 CSRF 理解为一种攻击手段,即攻击者盗用了你的身份,然后以你的名义向第三方网站发送恶意请求。我们可以使用如下所示的流程图来描述 CSRF:
-
+
CSRF 运行流程图
具体流程如下:
@@ -340,7 +340,7 @@ protected void configure(HttpSecurity http) throws Exception {
作为总结,我们可以用如下所示的示意图来梳理整个定制化 CSRF 所包含的各个组件以及它们之间的关联关系:
-
+
定制化 CSRF 的相关组件示意图
使用 Spring Security 实现 CORS
介绍完 CSRF,我们继续来看 Web 应用程序开发过程中另一个常见的需求——CORS,即跨域资源共享(Cross-Origin Resource Sharing)。那么问题来了,什么叫跨域?
@@ -415,7 +415,7 @@ public class TestController {
这一讲关注的是对 Web 请求安全性的讨论,我们讨论了日常开发过程中常见的两个概念,即 CSRF 和 CORS。这两个概念有时候容易混淆,但应对的是完全不同的两种场景。
CSRF 是一种攻击行为,所以我们需要对系统进行保护,而 CORS 更多的是一种前后端开发模式上的约定。在 Spring Security 中,针对这两个场景都提供了对应的过滤器,我们只需要通过简单的配置方法就能在系统中自动集成想要的功能。
本讲主要内容如下:
-
+
最后我想给你留一道思考题:在 Spring Security 中,如何定制化一套对 CsrfToken 的处理机制?欢迎你在留言区和我分享观点。
diff --git a/专栏/Spring Security 详解与实操/10 全局方法:如何确保方法级别的安全访问?.md.html b/专栏/Spring Security 详解与实操/10 全局方法:如何确保方法级别的安全访问?.md.html
index 4e83209a..d969f933 100644
--- a/专栏/Spring Security 详解与实操/10 全局方法:如何确保方法级别的安全访问?.md.html
+++ b/专栏/Spring Security 详解与实操/10 全局方法:如何确保方法级别的安全访问?.md.html
@@ -171,7 +171,7 @@ public class SecurityConfig
针对方法级别授权,Spring Security 提供了 @PreAuthorize 和 @PostAuthorize 这两个注解,分别用于预授权和后授权。
@PreAuthorize 注解
先来看 @PreAuthorize 注解的使用场景。假设在一个基于 Spring Boot 的 Web 应用程序中,存在一个 Web 层组件 OrderController,该 Controller 会调用 Service 层的组件 OrderService。我们希望对访问 OrderService 层中方法的请求添加权限控制能力,即只有具备“DELETE”权限的请求才能执行 OrderService 中的 deleteOrder() 方法,而没有该权限的请求将直接抛出一个异常,如下图所示:
-
+
Service 层组件预授权示意图
显然,上述流程针对的是预授权的应用场景,因此我们可以使用 @PreAuthorize 注解,
该注解定义如下:
@@ -238,7 +238,7 @@ public List<Order> getOrderByUser(String user) {
这里我们将输入的“user”参数与通过 SpEL 表达式从安全上下文中获取的“authentication.principal.username”进行比对,如果相同就执行正确的方法逻辑,反之将直接抛出异常。
@PostAuthorize 注解
相较 @PreAuthorize 注解,@PostAuthorize 注解的应用场景可能少见一些。有时我们允许调用者正确调用方法,但希望该调用者不接受返回的响应结果。这听起来似乎有点奇怪,但在那些访问第三方外部系统的应用中,我们并不能完全相信返回数据的正确性,也有对调用的响应结果进行限制的需求,@PostAuthorize 注解为我们实现这类需求提供了很好的解决方案,如下所示:
-
+
Service 层组件后授权示意图
为了演示 @PostAuthorize 注解,我们先来设定特定的返回值。假设我们存在如下所示的一个 Author 对象,保存着该作者的姓名和创作的图书作品:
public class Author {
@@ -304,7 +304,7 @@ public List<Product> findProducts() {
小结与预告
这一讲我们关注的重点从 HTTP 端点级别的安全控制转换到了普通方法级别的安全控制。Spring Security 内置了一组非常实用的注解,方便开发人员实现全局方法安全机制,包括用于实现方法级别授权的 @PreAuthorize 和 @PostAuthorize 注解,以及用于实现方法级别过滤的 @PreFilter 注解和 @PostFilter 注解。我们针对这些注解的使用方法也给出了相应的描述和示例代码。
本讲内容总结如下:
-
+
这里给你留一道思考题:针对 Spring Security 提供的全局方法安全机制,你能描述方法级别授权和方法级别过滤的区别以及它们各自的应用场景吗?欢迎在留言区写下你的想法。
多因素认证是一种安全访问控制的方法,基本的设计理念在于用户想要访问最终的资源,至少需要通过两种以上的认证机制。
那么,我们如何实现多种认证机制呢?一种常见的做法是分成两个步骤,第一步通过用户名和密码获取一个认证码(Authentication Code),第二步基于用户名和这个认证码进行安全访问。基于这种多因素认证的基本执行流程如下图所示:
-多因素认证的实现方式示意图
为了实现多因素认证,我们需要构建一个独立的认证服务 Auth-Service,该服务同时提供了基于用户名+密码以及用户名+认证码的认证形式。当然,实现认证的前提是构建用户体系,因此我们需要提供如下所示的 User 实体类:
@@ -201,7 +201,7 @@ CREATE TABLE IF NOT EXISTS `spring_security_demo`.`auth_code` ( PRIMARY KEY (`id`));有了认证服务,接下来我们需要构建一个业务服务 Business-Service,该业务服务通过集成认证服务,完成具体的认证操作,并返回访问令牌(Token)给到客户端系统。因此,从依赖关系上讲,Business-Service 会调用 Auth-Service,如下图所示:
-Business-Service 调用 Auth-Service 关系图
接下来,我们分别从这两个服务入手,实现多因素认证机制。
CustomAuthenticationFilter 的实现过程比较简单,代码也都是自解释的,唯一需要注意的是在基于认证码的认证过程通过之后,我们会在响应中添加一个“Authorization”消息头,并使用 UUID 值作为 Token 进行返回。
针对上述代码,我们可以通过如下所示的类图进行总结:
-多因素认证执行核心类图
最后,我们需要通过 Spring Security 中的配置体系确保各个类之间的有效协作。为此,我们构建了如下所示的 SecurityConfig 类:
@Configuration
@@ -475,17 +475,17 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
关于案例的完整代码你可以在这里进行下载:https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/MultiFactorAuthenticationDemo。
案例演示
现在,让我们分别在本地启动认证服务和业务服务,请注意:认证服务的启动端口是 8080,而业务服务的启动端口是 9090。然后我们打开模拟 HTTP 请求的 Postman 并输入相关参数,如下所示:
-
+
多因素认证的第一步认证示意图:基于用户名+密码
显然,该请求只传入了用户名和密码,所以会基于 UsernamePasswordAuthenticationProvider 执行认证过程,从而为用户“jianxiang”生成认证码。认证码是动态生成的,所以每次请求对应的结果都是不一样的,我通过查询数据库,获取该认证码为“9750”,你也可以自己做一些尝试。
有了认证码,相当于完成了多因素认证机制的第一步。接下来,我们再次基于这个认证码构建请求并获取响应结果,如下所示:
-
+
多因素认证的第二步认证示意图:基于用户名+认证码
可以看到,通过传入正确的认证码,我们基于 AuthCodeAuthenticationProvider 完成了多因素认证机制中的第二步认证,并最终在 HTTP 响应中生成了一个“Authorization”消息头。
小结与预告
这一讲我们基于多因素认证机制展示了如何利用 Spring Security 中的一些高级主题保护 Web 应用程序的实现方法。多因素认证机制的实现需要构建多个自定义的 AuthenticationProvider,并通过拦截器完成对请求的统一处理。相信案例中展示的这些开发技巧会给你的日常开发工作带来帮助。
本讲内容总结如下:
-
+
这里给你留一道思考题:在 Spring Security 中,如何利用过滤器实现对用户请求的定制化认证?
diff --git a/专栏/Spring Security 详解与实操/12 开放协议:OAuth2 协议解决的是什么问题?.md.html b/专栏/Spring Security 详解与实操/12 开放协议:OAuth2 协议解决的是什么问题?.md.html
index 5297a915..5b1fdce9 100644
--- a/专栏/Spring Security 详解与实操/12 开放协议:OAuth2 协议解决的是什么问题?.md.html
+++ b/专栏/Spring Security 详解与实操/12 开放协议:OAuth2 协议解决的是什么问题?.md.html
@@ -160,7 +160,7 @@ function hide_canvas() {
OAuth2 协议的应用场景
在常见的电商系统中,通常会存在类似工单处理的系统,而工单的生成在使用用户基本信息的同时,势必也依赖于用户的订单记录等数据。为了降低开发成本,假设我们的整个商品订单模块并不是自己研发的,而是集成了外部的订单管理平台,此时为了生成工单记录,就必须让工单系统读取用户在订单管理平台上的订单记录。
在这个场景中,难点在于只有得到用户的授权,才能同意工单系统读取用户在订单管理平台上的订单记录。那么问题就来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上的用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:
-
+
案例系统中用户认证和授权交互示意图
上图中的方案虽然可行,但显然存在几个严重的缺点:
@@ -170,11 +170,11 @@ function hide_canvas() {
既然这个方案存在如此多的问题,那么有没有更好的办法呢?答案是肯定的,OAuth2 协议的诞生就是为了解决这些问题。
首先,针对密码的安全性,在 OAuth2 协议中,密码还是由用户自己保管,避免了敏感信息的泄露;其次,OAuth2 协议中提供的授权具有明确的应用范围和有效期,用户可以根据需要限制工单系统所获取授权信息的作用效果;最后,如果用户对自己的密码等身份凭证信息进行了修改,只需通过 OAuth2 协议重新进行一次授权即可,不会影响到相关联的其他第三方应用程序。
-
+
传统认证授权机制与 OAuth2 协议的对比图
OAuth2 协议的角色
OAuth2 协议之所有能够具备这些优势,一个主要的原因在于它把整个系统涉及的各个角色及其职责做了很好地划分。OAuth2 协议中定义了四个核心的角色:资源、客户端、授权服务器和资源服务器。
-
+
OAuth2 协议中的角色定义
我们可以把 OAuth2 中的角色与现实中的应用场景对应起来。
@@ -201,7 +201,7 @@ function hide_canvas() {
- scope:指定了可访问的权限范围,这里指定的是访问 Web 资源的“webclient”。
现在我们已经介绍完令牌,你可能会好奇这样一个令牌究竟有什么用?接下来,我们就来看如何使用令牌完成基于 OAuth2 协议的授权工作流程。整个流程如下图所示:
-
+
基于 OAuth2 协议的授权工作流程图
我们可以把上述流程进一步展开梳理。
@@ -213,26 +213,26 @@ function hide_canvas() {
OAuth2 协议的授权模式
在整个工作流程中,最为关键的是第二步,即获取用户的有效授权。那么如何获取用户授权呢?在 OAuth 2.0 中,定义了四种授权方式,即授权码模式(Authorization Code)、简化模式(Implicit)、密码模式(Password Credentials)和客户端模式(Client Credentials)。
我们先来看最具代表性的授权码模式。当用户同意授权后,授权服务器返回的只是一个授权码,而不是最终的访问令牌。在这种授权模式下,需要客户端携带授权码去换令牌,这就需要客户端自身具备与授权服务器进行直接交互的后台服务。
-
+
授权码模式工作流程图
我们简单梳理一下授权码模式下的执行流程。
首先,用户在访问客户端时会被客户端导向授权服务器,此时用户可以选择是否给予客户端授权。一旦用户同意授权,授权服务器会调用客户端的后台服务提供的一个回调地址,并在调用过程中将一个授权码返回给客户端。客户端收到授权码后进一步向授权服务器申请令牌。最后,授权服务器核对授权码并向客户端发送访问令牌。
这里要注意的是,通过授权码向授权服务器申请令牌的过程是系统自动完成的,不需要用户的参与,用户需要做的就是在流程启动阶段同意授权。
接下来,我们再来看另一种比较常用的密码模式,其授权流程如下图所示:
-
+
密码模式工作流程图
可以看到,密码模式比较简单,也更加容易理解。用户要做的就是提供自己的用户名和密码,然后客户端会基于这些用户信息向授权服务器请求令牌。授权服务器成功执行用户认证操作后将会发放令牌。
OAuth2 中的客户端模式和简化模式因为在日常开发过程中应用得不是很多,这里就不详细介绍了。
你可能注意到了,虽然 OAuth2 协议解决的是授权问题,但它也应用到了认证的概念,这是因为只有验证了用户的身份凭证,我们才能完成对他的授权。所以说,OAuth2 实际上是一款技术体系比较复杂的协议,综合应用了信息摘要、签名认证等安全性手段,并需要提供令牌以及背后的公私钥管理等功能。
OAuth2 协议与微服务架构
对应到微服务系统中,服务提供者充当的角色就是资源服务器,而服务消费者就是客户端。所以每个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。当客户端拿到 Token 之后,该 Token 就能在各个服务之间进行传递。如下图所示:
-
+
OAuth2 协议在服务访问场景中的应用
在整个 OAuth2 协议中,最关键的问题就是如何获取客户端授权。就目前主流的微服架构来说,当我们发起 HTTP 请求时,关注的是如何通过 HTTP 协议透明而高效地传递令牌,此时授权码模式下通过回调地址进行授权管理的方式就不是很实用,密码模式反而更加简洁高效。因此,在本专栏中,我们将使用密码模式作为 OAuth2 协议授权模式的默认实现方式。
小结与预告
今天我们进入微服务安全性领域展开了探讨,在这个领域中,认证和授权仍然是最基本的安全性控制手段。通过系统分析微服务架构中的认证和授权解决方案,我们引入了 OAuth2 协议,这也是微服务架构体系下主流的授权协议。我们对 OAuth2 协议具备的角色、授权模式以及与微服务架构之间的集成关系做了详细展开。
本讲内容总结如下:
-
+
最后给你留一道思考题:你能描述 OAuth2 协议中所具备的四大角色以及四种授权模式吗?欢迎在留言区和我分享你的收获。
diff --git a/专栏/Spring Security 详解与实操/13 授权体系:如何构建 OAuth2 授权服务器?.md.html b/专栏/Spring Security 详解与实操/13 授权体系:如何构建 OAuth2 授权服务器?.md.html
index e79cda00..ca71ed4a 100644
--- a/专栏/Spring Security 详解与实操/13 授权体系:如何构建 OAuth2 授权服务器?.md.html
+++ b/专栏/Spring Security 详解与实操/13 授权体系:如何构建 OAuth2 授权服务器?.md.html
@@ -177,7 +177,7 @@ public class AuthorizationServer {
设置客户端和用户认证信息
上一讲我们提到 OAuth2 协议存在四种授权模式,并提到在微服务架构中,密码模式以其简单性得到了广泛的应用。在接下来的内容中,我们就以密码模式为例展开讲解。
在密码模式下,用户向客户端提供用户名和密码,并将用户名和密码发给授权服务器从而请求 Token。授权服务器首先会对密码凭证信息进行认证,确认无误后,向客户端发放 Token。整个流程如下图所示:
-
+
密码模式授权流程示意图
请注意,授权服务器在这里执行认证操作的目的是验证传入的用户名和密码是否正确。在密码模式下,这一步是必需的,如果采用其他授权模式,不一定会有用户认证这一环节。
确定采用密码模式后,我们来看为了实现这一授权模式,需要对授权服务器做哪些开发工作。首先我们需要设置一些基础数据,包括客户端信息和用户信息。
@@ -270,11 +270,11 @@ public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
生成 Token
现在,OAuth2 授权服务器已经构建完毕,启动这个授权服务器,我们就可以获取 Token。我们在构建 OAuth2 服务器时已经提到授权服务器中会暴露一批端点供 HTTP 请求进行访问,而获取 Token 的端点就是http://localhost:8080/oauth/token。在使用该端点时,我们需要提供前面配置的客户端信息和用户信息。
这里使用 Postman 来模拟 HTTP 请求,客户端信息设置方式如下图所示:
-
+
客户端信息设置示意图
我们在“Authorization”请求头中指定认证类型为“Basic Auth”,然后设置客户端名称和客户端安全码分别为“spring”和“spring_secret”。
接下来我们指定针对授权模式的专用配置信息。首先是用于指定授权模式的 grant_type 属性,以及用于指定客户端访问范围的 scope 属性,这里分别设置为 “password”和“webclient”。既然设置了密码模式,所以也需要指定用户名和密码用于识别用户身份,这里,我们以“spring_user”这个用户为例进行设置,如下图所示:
-
+
用户信息设置示意图
在 Postman 中执行这个请求,会得到如下所示的返回结果:
{
@@ -289,7 +289,7 @@ public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
小结与预告
对微服务访问进行安全性控制的首要条件是生成一个访问 Token。这一讲我们从构建 OAuth2 服务器讲起,基于密码模式给出了如何设置客户端信息、用户认证信息以及最终生成 Token 的实现过程。这个过程中需要开发人员熟悉 OAuth2 协议的相关概念以及 Spring Security 框架中提供的各项配置功能。
本讲内容总结如下:
-
+
最后给你留一道思考题:基于密码模式,你能说明生成 Token 需要哪些具体的开发步骤吗?
diff --git a/专栏/Spring Security 详解与实操/14 资源保护:如何基于 OAuth2 协议配置授权过程?.md.html b/专栏/Spring Security 详解与实操/14 资源保护:如何基于 OAuth2 协议配置授权过程?.md.html
index ebbb0053..e30128d1 100644
--- a/专栏/Spring Security 详解与实操/14 资源保护:如何基于 OAuth2 协议配置授权过程?.md.html
+++ b/专栏/Spring Security 详解与实操/14 资源保护:如何基于 OAuth2 协议配置授权过程?.md.html
@@ -210,7 +210,7 @@ user.getUserAuthentication().getAuthorities()));
我们知道“0efa61be-32ab-4351-9dga-8ab668ababae”这个 Token 是由“spring_user”这个用户生成的,可以看到该结果中包含了用户的用户名、密码以及该用户名所拥有的角色,这些信息与我们在上一讲中初始化的“spring_user”用户信息保持一致。我们也可以尝试使用“spring_admin”这个用户来重复上述过程。
在微服务中嵌入访问授权控制
在一个微服务系统中,每个微服务作为独立的资源服务器,对自身资源的保护粒度并不是固定的,可以根据需求对访问权限进行精细化控制。在 Spring Security 中,对访问的不同控制层级进行了抽象,形成了用户、角色和请求方法这三种粒度,如下图所示:
-
+
用户、角色和请求方法三种控制粒度示意图
基于上图,我们可以对这三种粒度进行排列组合,形成用户、用户+角色以及用户+角色+请求方法这三种层级,这三种层级能够访问的资源范围逐一递减。用户层级是指只要是认证用户就能访问服务内的各种资源;而用户+角色层级在用户层级的基础上,还要求用户属于某一个或多个特定角色;最后的用户+角色+请求方法层级要求最高,能够对某些 HTTP 操作进行访问限制。接下来我们针对这三个层级展开讨论。
用户层级的权限访问控制
@@ -263,7 +263,7 @@ public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter
现在,我们使用普通“USER”角色生成的 Token,并调用"/order/"端点中的 Update 操作,同样会得到“access_denied”的错误信息。而尝试使用“ADMIN”角色生成的 Token 进行访问,就可以得到正常响应。
在微服务中传播 Token
我们知道一个微服务系统势必涉及多个服务之间的调用,并形成一个链路。因为访问所有服务的过程都需要进行访问权限的控制,所以我们需要确保生成的 Token 能够在服务调用链路中进行传播,如下图所示:
-
+
微服务中 Token 传播示意图
那么,如何实现上图中的 Token 传播效果呢?Spring Security 基于 RestTemplate 进行了封装,专门提供了一个用在 HTTP 请求中传播 Token 的 OAuth2RestTemplate 工具类。想要在业务代码中构建一个 OAuth2RestTemplate 对象,可以使用如下所示的示例代码:
@Bean
diff --git a/专栏/Spring Security 详解与实操/15 令牌扩展:如何使用 JWT 实现定制化 Token?.md.html b/专栏/Spring Security 详解与实操/15 令牌扩展:如何使用 JWT 实现定制化 Token?.md.html
index 2287e4af..9b552e58 100644
--- a/专栏/Spring Security 详解与实操/15 令牌扩展:如何使用 JWT 实现定制化 Token?.md.html
+++ b/专栏/Spring Security 详解与实操/15 令牌扩展:如何使用 JWT 实现定制化 Token?.md.html
@@ -276,7 +276,7 @@ public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws E
- 第三步,也是最关键的一步,就是在通过 RestTemplate 发起请求时,能够把这个 Token 自动嵌入到所发起的每一个 HTTP 请求中。
整个实现思路如下图所示:
-
+
在服务调用链中传播 JWT Token 的三个实现步骤
实现这一思路需要你对 HTTP 请求的过程和原理有一定的理解,在代码实现上也需要有一些技巧,下面我一一展开。
首先,在 HTTP 请求过程中,我们可以通过过滤器 Filter 对所有请求进行过滤。Filter 是 Servlet 中的一个核心组件,其基本原理就是构建一个过滤器链并对经过该过滤器链的请求和响应添加定制化的处理机制。Filter 接口的定义如下所示:
diff --git a/专栏/Spring Security 详解与实操/17 案例实战:基于 Spring Security 和 OAuth2 实现单点登录.md.html b/专栏/Spring Security 详解与实操/17 案例实战:基于 Spring Security 和 OAuth2 实现单点登录.md.html
index 4ca9e526..47fa89be 100644
--- a/专栏/Spring Security 详解与实操/17 案例实战:基于 Spring Security 和 OAuth2 实现单点登录.md.html
+++ b/专栏/Spring Security 详解与实操/17 案例实战:基于 Spring Security 和 OAuth2 实现单点登录.md.html
@@ -362,10 +362,10 @@ public @interface EnableOAuth2Sso {
案例演示
最后,让我们演示一下整个单点登录过程。依次启动 SSO 服务器以及 app1 和 app2,然后在浏览器中访问 app1 地址http://localhost:8080/app1/system/profile,这时候浏览器就会重定向到 SSO 服务器登录页面。
请注意,如果我们在访问上述地址时打开了浏览器的“网络”标签并查看其访问路径,就可以看到确实是先跳转到了app1的登录页面(http://localhost:8080/app1/login),然后又重定向到 SSO 服务器。由于用户处于未登录状态,所以最后又重定向到 SSO 服务器的登录界面(http://localhost:8888/login),整个请求的跳转过程如下图所示:
-
+
未登录状态访问 app1 时的网络请求跳转流程图
我们在 SSO 服务器的登录界面输入正确的用户名和密码之后就可以认证成功了,这时候我们再看网络请求的过程,如下所示:
-
+
登录 app1 过程的网络请求跳转流程图
可以看到,在成功登录之后,授权系统重定向到 app1 中配置的回调地址(http://localhost:8080/app1/login)。与此同时,我们在请求地址中还发现了两个新的参数 code 和 state。app1 客户端就会根据这个 code 来访问 SSO 服务器的/oauth/token 接口来申请 token。申请成功后,重定向到 app1 配置的回调地址。
现在,如果你访问 app2,与第一次访问 app1 相同,浏览器先重定向到 app2 的登录页面,然后又重定向到 SSO 服务器的授权链接,最后直接就重新重定向到 app2 的登录页面。不同之处在于,此次访问并不需要再次重定向到 SSO 服务器进行登录,而是成功访问 SSO 服务器的授权接口,并携带着 code 重定向到 app2 的回调路径。然后 app2 根据 code 再次访问 /oauth/token 接口拿到 token,这样就可以正常访问受保护的资源了。
diff --git a/专栏/Spring Security 详解与实操/19 测试驱动:如何基于 Spring Security 测试系统安全性?.md.html b/专栏/Spring Security 详解与实操/19 测试驱动:如何基于 Spring Security 测试系统安全性?.md.html
index cd84ef5a..ca15ef3e 100644
--- a/专栏/Spring Security 详解与实操/19 测试驱动:如何基于 Spring Security 测试系统安全性?.md.html
+++ b/专栏/Spring Security 详解与实操/19 测试驱动:如何基于 Spring Security 测试系统安全性?.md.html
@@ -160,7 +160,7 @@ function hide_canvas() {
安全性测试与 Mock 机制
正如前面提到的,验证安全性功能正确性的难点在于组件与组件之间的依赖关系,为了弄清楚这个关系,这里就需要引出测试领域非常重要的一个概念,即 Mock(模拟)。针对测试组件涉及的外部依赖,我们的关注点在于这些组件之间的调用关系,以及返回的结果或发生的异常等,而不是组件内部的执行过程。因此常见的技巧就是使用 Mock 对象来替代真实的依赖对象,从而模拟真实的调用场景。
我们以一个常见的三层 Web 服务架构为例来进一步解释 Mock 的实施方法。Controller 层会访问 Service 层,而 Service 层又会访问 Repository 层,我们对 Controller 层的端点进行验证时,就需要模拟 Service 层组件的功能。同样,对 Service 层组件进行测试时,也需要假定 Repository 层组件的结果是可以获取的,如下所示:
-
+
Web 服务中各层组件与 Mock 对象示意图
对于 Spring Security 而言,上图所展示的原理同样适用,例如我们可以通过模拟用户的方式来测试用户认证和授权功能的正确性。在本讲后面的内容中,我们会给出相关的代码示例。
Spring Security 中的测试解决方案
diff --git a/专栏/ZooKeeper源码分析与实战-完/00 开篇词:选择 ZooKeeper,一步到位掌握分布式开发.md.html b/专栏/ZooKeeper源码分析与实战-完/00 开篇词:选择 ZooKeeper,一步到位掌握分布式开发.md.html
index ec9fc848..8e388ba3 100644
--- a/专栏/ZooKeeper源码分析与实战-完/00 开篇词:选择 ZooKeeper,一步到位掌握分布式开发.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/00 开篇词:选择 ZooKeeper,一步到位掌握分布式开发.md.html
@@ -199,7 +199,7 @@ function hide_canvas() {
而如果采用集群方式的垂直架构,当业务不断发展,应用和服务会变得越来越多,项目的维护和部署也会变得极为复杂。
因此,越来越多的公司采用分布式架构来开发自己的业务系统,通过将一个系统横向切分成若干个子系统或服务,实现服务性能的动态扩容。这样不但大幅提高了服务的处理能力,而且降低了程序的开发维护以及部署的难度。
掌握分布式系统相关知识的 IT 从业人员因此成为各大公司争抢的对象,从拉勾招聘平台我们也可以看到,分布式系统开发工程师的薪水也相对更高,平均起薪 25k 以上。正因如此,学习和提高分布式系统开发能力,也成为传统软件开发人员转行和寻求高薪职位的必要条件。
-
.png]
+
.png]
学好 ZooKeeper,提升分布式开发与架构能力
分布式技术也因此有了很多拥趸,但在工作以及和朋友的交流中,我也发现了人才供需之间的一些矛盾:
diff --git a/专栏/ZooKeeper源码分析与实战-完/01 ZooKeeper 数据模型:节点的特性与应用.md.html b/专栏/ZooKeeper源码分析与实战-完/01 ZooKeeper 数据模型:节点的特性与应用.md.html
index 2d845ef7..7b172bf0 100644
--- a/专栏/ZooKeeper源码分析与实战-完/01 ZooKeeper 数据模型:节点的特性与应用.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/01 ZooKeeper 数据模型:节点的特性与应用.md.html
@@ -231,9 +231,9 @@ create /servers
create /works
最终在 ZooKeeper 服务器上会得到一个具有层级关系的数据结构,如下图所示,这个数据结构就是 ZooKeeper 中的数据模型。
-
+
ZooKeeper 中的数据模型是一种树形结构,非常像电脑中的文件系统,有一个根文件夹,下面还有很多子文件夹。ZooKeeper 的数据模型也具有一个固定的根节点(/),我们可以在根节点下创建子节点,并在子节点下继续创建下一级节点。ZooKeeper 树中的每一层级用斜杠(/)分隔开,且只能用绝对路径(如“get /work/task1”)的方式查询 ZooKeeper 节点,而不能使用相对路径。具体的结构你可以看看下面这张图:
-
+
znode 节点类型与特性
知道了 ZooKeeper 的数据模型是一种树形结构,就像在 MySQL 中数据是存在于数据表中,ZooKeeper 中的数据是由多个数据节点最终构成的一个层级的树状结构,和我们在创建 MySOL 数据表时会定义不同类型的数据列字段,ZooKeeper 中的数据节点也分为持久节点、临时节点和有序节点三种类型:
1、持久节点
@@ -241,14 +241,14 @@ create /works
2、临时节点
接下来我们来介绍临时节点。从名称上我们可以看出该节点的一个最重要的特性就是临时性。所谓临时性是指,如果将节点创建为临时节点,那么该节点数据不会一直存储在 ZooKeeper 服务器上。当创建该临时节点的客户端会话因超时或发生异常而关闭时,该节点也相应在 ZooKeeper 服务器上被删除。同样,我们可以像删除持久节点一样主动删除临时节点。
在平时的开发中,我们可以利用临时节点的这一特性来做服务器集群内机器运行情况的统计,将集群设置为“/servers”节点,并为集群下的每台服务器创建一个临时节点“/servers/host”,当服务器下线时该节点自动被删除,最后统计临时节点个数就可以知道集群中的运行情况。如下图所示:
-
+
3、有序节点
最后我们再说一下有序节点,其实有序节点并不算是一种单独种类的节点,而是在之前提到的持久节点和临时节点特性的基础上,增加了一个节点有序的性质。所谓节点有序是说在我们创建有序节点的时候,ZooKeeper 服务器会自动使用一个单调递增的数字作为后缀,追加到我们创建节点的后边。例如一个客户端创建了一个路径为 works/task- 的有序节点,那么 ZooKeeper 将会生成一个序号并追加到该节点的路径后,最后该节点的路径为 works/task-1。通过这种方式我们可以直观的查看到节点的创建顺序。
到目前为止我们知道在 ZooKeeper 服务器上存储数据的基本信息,知道了 ZooKeeper 中的数据节点种类有持久节点和临时节点等。上述这几种数据节点虽然类型不同,但 ZooKeeper 中的每个节点都维护有这些内容:一个二进制数组(byte data[]),用来存储节点的数据、ACL 访问控制信息、子节点数据(因为临时节点不允许有子节点,所以其子节点字段为 null),除此之外每个数据节点还有一个记录自身状态信息的字段 stat。
下面我们详细说明节点的状态信息。
节点的状态结构
每个节点都有属于自己的状态信息,这就很像我们每个人的身份信息一样,我们打开之前的客户端,执行 stat /zk_test,可以看到控制台输出了一些信息,这些就是节点状态信息。
-
+
每一个节点都有一个自己的状态属性,记录了节点本身的一些信息,这些属性包括的内容我列在了下面这个表格里:

数据节点的版本
@@ -261,16 +261,16 @@ create /works
悲观锁认为进程对临界区的竞争总是会出现,为了保证进程在操作数据时,该条数据不被其他进程修改。数据会一直处于被锁定的状态。
我们假设一个具有 n 个进程的应用,同时访问临界区资源,我们通过进程创建 ZooKeeper 节点 /locks 的方式获取锁。
线程 a 通过成功创建 ZooKeeper 节点“/locks”的方式获取锁后继续执行,如下图所示:
-
+
这时进程 b 也要访问临界区资源,于是进程 b 也尝试创建“/locks”节点来获取锁,因为之前进程 a 已经创建该节点,所以进程 b 创建节点失败无法获得锁。
-
+
这样就实现了一个简单的悲观锁,不过这也有一个隐含的问题,就是当进程 a 因为异常中断导致 /locks 节点始终存在,其他线程因为无法再次创建节点而无法获取锁,这就产生了一个死锁问题。针对这种情况我们可以通过将节点设置为临时节点的方式避免。并通过在服务器端添加监听事件来通知其他进程重新获取锁。
乐观锁
乐观锁认为,进程对临界区资源的竞争不会总是出现,所以相对悲观锁而言。加锁方式没有那么激烈,不会全程的锁定资源,而是在数据进行提交更新的时候,对数据的冲突与否进行检测,如果发现冲突了,则拒绝操作。
**乐观锁基本可以分为读取、校验、写入三个步骤。**CAS(Compare-And-Swap),即比较并替换,就是一个乐观锁的实现。CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。
在 ZooKeeper 中的 version 属性就是用来实现乐观锁机制中的“校验”的,ZooKeeper 每个节点都有数据版本的概念,在调用更新操作的时候,假如有一个客户端试图进行更新操作,它会携带上次获取到的 version 值进行更新。而如果在这段时间内,ZooKeeper 服务器上该节点的数值恰好已经被其他客户端更新了,那么其数据版本一定也会发生变化,因此肯定与客户端携带的 version 无法匹配,便无法成功更新,因此可以有效地避免一些分布式更新的并发问题。
在 ZooKeeper 的底层实现中,当服务端处理 setDataRequest 请求时,首先会调用 checkAndIncVersion 方法进行数据版本校验。ZooKeeper 会从 setDataRequest 请求中获取当前请求的版本 version,同时通过 getRecordForPath 方法获取服务器数据记录 nodeRecord, 从中得到当前服务器上的版本信息 currentversion。如果 version 为 -1,表示该请求操作不使用乐观锁,可以忽略版本对比;如果 version 不是 -1,那么就对比 version 和 currentversion,如果相等,则进行更新操作,否则就会抛出 BadVersionException 异常中断操作。
-
+
总结
本节课主要介绍了ZooKeeper的基础知识点——数据模型。并深入介绍了节点类型、stat 状态属性等知识,并利用目前学到的知识解决了集群中服务器运行情况统计、悲观锁、乐观锁等问题。这些知识对接下来的课程至关重要,请务必掌握。
了解了 ZooKeeper 数据模型的基本原理后,我们来思考一个问题:为什么 ZooKeeper 不能采用相对路径查找节点呢?
diff --git a/专栏/ZooKeeper源码分析与实战-完/02 发布订阅模式:如何使用 Watch 机制实现分布式通知.md.html b/专栏/ZooKeeper源码分析与实战-完/02 发布订阅模式:如何使用 Watch 机制实现分布式通知.md.html
index 0071e46b..2b3ecea6 100644
--- a/专栏/ZooKeeper源码分析与实战-完/02 发布订阅模式:如何使用 Watch 机制实现分布式通知.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/02 发布订阅模式:如何使用 Watch 机制实现分布式通知.md.html
@@ -210,14 +210,14 @@ Watcher:监控事件
getData(String path, Watcher watcher, Stat stat)
知道了 ZooKeeper 添加服务器监控事件的方式,下面我们来讲解一下触发通知的条件。
-
+
上图中列出了客户端在不同会话状态下,相应的在服务器节点所能支持的事件类型。例如在客户端连接服务端的时候,可以对数据节点的创建、删除、数据变更、子节点的更新等操作进行监控。
现在我们已经从应用层的角度了解了 ZooKeeper 中的 Watch 机制,而学习 ZooKeeper 过程中一个大问题就是入门容易精通难,像上边我们通过几个简单的 API 调用就可以对服务器的节点状态变更进行监控,但是在实际生产环境中我们会遇到很多意想不到的问题,要想解决好这些问题就要深入理解 Watch 的底层实现机制。
Watch 机制的底层原理
现在我们就深入底层了解其背后的实现原理。与上个课时直接通过底层代码的调用过程来分析不同,在 Watch 底层实现的分析阶段,由于 Watch 机制涉及了客户端和服务端的多个函数和操作节点,单单按照程序执行流程分析跳跃性对整体实现机制的理解难度大,这也是我在学习 Watch 这部分底层实现遇到的问题。为了更好地阐述 Watch 机制,我们另辟蹊径,从设计模式角度出发来分析其底层实现:
-
+
最初我在开始学习 Watch 机制的时候,它给我的第一印象是,其结构很像设计模式中的”观察者模式“,一个对象或者数据节点可能会被多个客户端监控,当对应事件被触发时,会通知这些对象或客户端。我们可以将 Watch 机制理解为是分布式环境下的观察者模式。所以接下来我们就以观察者模式的角度点来看看 ZooKeeper 底层 Watch 是如何实现的。
-
+
通常我们在实现观察者模式时,最核心或者说关键的代码就是创建一个列表来存放观察者。
而在 ZooKeeper 中则是在客户端和服务器端分别实现两个存放观察者列表,即:ZKWatchManager 和 WatchManager。其核心操作就是围绕着这两个展开的。
客户端 Watch 注册实现过程
@@ -400,7 +400,7 @@ rsp = new GetDataResponse(b, stat);
提到 ZooKeeper 的应用场景,你可能第一时间会想到最为典型的发布订阅功能。发布订阅功能可以看作是一个一对多的关系,即一个服务或数据的发布者可以被多个不同的消费者调用。一般一个发布订阅模式的数据交互可以分为消费者主动请求生产者信息的拉取模式,和生产者数据变更时主动推送给消费者的推送模式。ZooKeeper 采用了两种模式结合的方式实现订阅发布功能。下面我们来分析一个具体案例:
在系统开发的过程中会用到各种各样的配置信息,如数据库配置项、第三方接口、服务地址等,这些配置操作在我们开发过程中很容易完成,但是放到一个大规模的集群中配置起来就比较麻烦了。通常这种集群中,我们可以用配置管理功能自动完成服务器配置信息的维护,利用ZooKeeper 的发布订阅功能就能解决这个问题。
我们可以把诸如数据库配置项这样的信息存储在 ZooKeeper 数据节点中。如图中的 /confs/data_item1。服务器集群客户端对该节点添加 Watch 事件监控,当集群中的服务启动时,会读取该节点数据获取数据配置信息。而当该节点数据发生变化时,ZooKeeper 服务器会发送 Watch 事件给各个客户端,集群中的客户端在接收到该通知后,重新读取节点的数据库配置信息。
-
+
我们使用 Watch 机制实现了一个分布式环境下的配置管理功能,通过对 ZooKeeper 服务器节点添加数据变更事件,实现当数据库配置项信息变更后,集群中的各个客户端能接收到该变更事件的通知,并获取最新的配置信息。要注意一点是,我们提到 Watch 具有一次性,所以当我们获得服务器通知后要再次添加 Watch 事件。
结束语
本课时我们学习了 ZooKeeper 中非常重要的基础知识——Watch 监控机制。详细分析了 ZooKeeper 在处理 Watch 事件的底层实现,并通过我们掌握的知识实现了一个集群环境下的配置管理功能。
diff --git a/专栏/ZooKeeper源码分析与实战-完/03 ACL 权限控制:如何避免未经授权的访问?.md.html b/专栏/ZooKeeper源码分析与实战-完/03 ACL 权限控制:如何避免未经授权的访问?.md.html
index aefb1e63..da494c3d 100644
--- a/专栏/ZooKeeper源码分析与实战-完/03 ACL 权限控制:如何避免未经授权的访问?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/03 ACL 权限控制:如何避免未经授权的访问?.md.html
@@ -195,7 +195,7 @@ function hide_canvas() {
03 ACL 权限控制:如何避免未经授权的访问?
在前边的几节课程中,我们学习了数据模型节点、Watch 监控机制等知识。并利用这些知识实现了在分布式环境中经常用到的诸如分布式锁、配置管理等功能。这些功能的本质都在于操作数据节点,而如果作为分布式锁或配置项的数据节点被错误删除或修改,那么对整个分布式系统有很大的影响,甚至会造成严重的生产事故。而作为在分布式领域应用最为广泛的一致性解决框架,ZooKeeper 提供一个很好的解决方案那就是 ACL 权限控制。
说到 ACL 可能你会觉得陌生,但是提到权限控制相信你一定很熟悉。比如 Linux 系统将对文件的使用者分为三种身份,即 User、Group、Others。使用者对文件拥有读(read) 写(write)以及执行(execute)3 种方式的控制权。这种权限控制方式相对比较粗糙,在复杂的授权场景下往往并不适用。比如下边一个应用场景。
-
+
上图给出了某个技术开发公司的一个工作项目 /object 。项目中的每个开发人员都可以读取和修改该项目中的文件,作为开发组长也对这个项目文件具有读取和修改的权限。其他技术开发组的员工则不能访问这个项目。如果我们用之前说到的 Linux 权限应该怎么设计呢?
首先作为技术组长使用 User 身份,具有读、写、执行权限。项目组其他成员使用 Group 身份,具有读写权限,其他项目组的人员则没有任何权限。这样就实现了满足要求的权限设定了。
但是,如果技术组新加入一个实习人员,为了能让他熟悉项目,必须具有该项目的读取的权限。但是目前他不具备修改项目的能力,所以并没给他赋予写入的权限。而如果使用现有的权限设置,显然将其分配给 User 用户或者 Group 用户都并不合适。而如果修改 Others 用户的权限,其他项目组的成员也能访问该项目文件。显然普通的三种身份的权限划分是无法满足要求的。而 ZooKeeper 中的 ACl 就能应对这种复杂的权限应用场景。
@@ -226,13 +226,13 @@ addauth digest user:passwd
- 数据节点(delete)删除权限,授予权限的对象可以删除该数据节点的子节点;
- 数据节点(admin)管理者权限,授予权限的对象可以对该数据节点体进行 ACL 权限设置。
-
+
需要注意的一点是,每个节点都有维护自身的 ACL 权限数据,即使是该节点的子节点也是有自己的 ACL 权限而不是直接继承其父节点的权限。如下中“172.168.11.1”服务器有“/Config”节点的读取权限,但是没有其子节点的“/Config/dataBase_Config1”权限。
-
+
实现自己的权限口控制
通过上边的介绍我们了解了 ZooKeeper 中的权限相关知识,虽然 ZooKeeper 自身的权限控制机制已经做得很细,但是它还是提供了一种权限扩展机制来让用户实现自己的权限控制方式。官方文档中对这种机制的定义是 “Pluggable ZooKeeper Authenication”,意思是可插拔的授权机制,从名称上我们可以看出它的灵活性。那么这种机制是如何实现的呢?
首先,要想实现自定义的权限控制机制,最核心的一点是实现 ZooKeeper 提供的权限控制器接口 AuthenticationProvider。下面这张图片展示了接口的内部结构,用户通过该接口实现自定义的权限控制。
-
+
实现了自定义权限后,如何才能让 ZooKeeper 服务端使用自定义的权限验证方式呢?接下来就需要将自定义的权限控制注册到 ZooKeeper 服务器中,而注册的方式通常有两种。
第一种是通过设置系统属性来注册自定义的权限控制器:
-Dzookeeper.authProvider.x=CustomAuthenticationProvider
diff --git a/专栏/ZooKeeper源码分析与实战-完/04 ZooKeeper 如何进行序列化?.md.html b/专栏/ZooKeeper源码分析与实战-完/04 ZooKeeper 如何进行序列化?.md.html
index 710662e6..0749426c 100644
--- a/专栏/ZooKeeper源码分析与实战-完/04 ZooKeeper 如何进行序列化?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/04 ZooKeeper 如何进行序列化?.md.html
@@ -266,10 +266,10 @@ public void deserialize(InputArchive archive, String tag)
Record 接口的内部实现逻辑非常简单,只是定义了一个 序列化方法 serialize 和一个反序列化方法 deserialize 。而在 Record 起到关键作用的则是两个重要的类:OutputArchive 和 InputArchive ,其实这两个类才是真正的序列化和反序列化工具类。
在 OutputArchive 中定义了可进行序列化的参数类型,根据不同的序列化方式调用不同的实现类进行序列化操作。如下图所示,Jute 可以通过 Binary 、 Csv 、Xml 等方式进行序列化操作。
-
+
而对应于序列化操作,在反序列化时也会相应调用不同的实现类,来进行反序列化操作。
如下图所示:
-
+
注意:无论是序列化还是反序列化,都可以对多个对象进行操作,所以当我们在定义序列化和反序列化方法时,需要字符类型参数 tag 表示要序列化或反序列化哪个对象。
总结
本课时介绍了什么是序列化以及为什么要进行序列化,简单来说,就是将对象编译成字节码的形式,方便将对象信息存储到本地或通过网络传输。
diff --git a/专栏/ZooKeeper源码分析与实战-完/07 单机模式:服务器如何从初始化到对外提供服务?.md.html b/专栏/ZooKeeper源码分析与实战-完/07 单机模式:服务器如何从初始化到对外提供服务?.md.html
index dc281e04..a8e96483 100644
--- a/专栏/ZooKeeper源码分析与实战-完/07 单机模式:服务器如何从初始化到对外提供服务?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/07 单机模式:服务器如何从初始化到对外提供服务?.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
通过基础篇的学习我们已经掌握了 ZooKeeper 相关的基础知识,今天我们就开始进阶篇中的第一节课,本节课主要通过对单机版的 ZooKeeper 中的启动与服务的初始化过程进行分析,来学习 ZooKeeper 服务端相关的处理知识。现在我们就开始深入到服务器端看一看 ZooKeeper 是如何从初始化到对外提供服务的。
启动准备实现
在 ZooKeeper 服务的初始化之前,首先要对配置文件等信息进行解析和载入。也就是在真正开始服务的初始化之前需要对服务的相关参数进行准备,而 ZooKeeper 服务的准备阶段大体上可分为启动程序入口、zoo.cfg 配置文件解析、创建历史文件清理器等,如下图所示:
-
+
QuorumPeerMain 类是 ZooKeeper 服务的启动接口,可以理解为 Java 中的 main 函数。 通常我们在控制台启动 ZooKeeper 服务的时候,输入 zkServer.cm 或 zkServer.sh 命令就是用来启动这个 Java 类的。如下代码所示,QuorumPeerMain 类函数只有一个 initializeAndRun 方法,是作用为所有 ZooKeeper 服务启动逻辑的入口。
package org.apache.zookeeper.server.quorum
public class QuorumPeerMain {
@@ -228,7 +228,7 @@ public class QuorumPeerMain {
- FileTxnSnapLog 类,可以用于数据管理。
- 会话管理类,设置服务器 TickTime 和会话超时时间、创建启动会话管理器等操作。
-
+
下面我们就分别分析一下这几个关键步骤在 ZooKeeper 中的底层实现过程。
ServerStats创建
首先,我们来看一下统计工具类 ServerStats。ServerStats 类用于统计 ZooKeeper 服务运行时的状态信息统计。主要统计的数据有服务端向客户端发送的响应包次数、接收到的客户端发送的请求包次数、服务端处理请求的延迟情况以及处理客户端的请求次数。在日常运维工作中,监控服务器的性能以及运行状态等参数很多都是这个类负责收集的。
@@ -272,7 +272,7 @@ final public void setZooKeeperServer(ZooKeeperServer zks) {
Thread 类作为 ServerCnxnFactory 类的启动主线程。之后 ZooKeeper 服务再初始化具体的 NIO 类。这里请你注意的是,虽然初始化完相关的 NIO 类 ,比如已经设置好了服务端的对外端口,客户端也能通过诸如 2181 端口等访问到服务端,但是此时 ZooKeeper 服务器还是无法处理客户端的请求操作。这是因为 ZooKeeper 启动后,还需要从本地的快照数据文件和事务日志文件中恢复数据。这之后才真正完成了 ZooKeeper 服务的启动。
初始化请求处理链
在完成了 ZooKeeper 服务的启动后,ZooKeeper 会初始化一个请求处理逻辑上的相关类。这个操作就是初始化请求处理链。所谓的请求处理链是一种责任链模式的实现方式,根据不同的客户端请求,在 ZooKeeper 服务器上会采用不同的处理逻辑。而为了更好地实现这种业务场景,ZooKeeper 中采用多个请求处理器类一次处理客户端请求中的不同逻辑部分。这种处理请求的逻辑方式就是责任链模式。而本课时主要说的是单机版服务器的处理逻辑,主要分为PrepRequestProcessor、SyncRequestProcessor、FinalRequestProcessor 3 个请求处理器,而在一个请求到达 ZooKeeper 服务端进行处理的过程,则是严格按照这个顺序分别调用这 3 个类处理请求中的对应逻辑,如下图所示。具体的内容,我们会在后面的课程中详细讲解。
-
+
总结
本课时是我们进阶篇阶段的第一课,在整个进阶篇中,我们主要从 ZooKeeper 服务内部的实现逻辑来学习 ZooKeeper 中的相关知识,而本课时从单机版服务器的启动,到对外提供服务的整个过程,逐步分析 ZooKeeper 实现的每个步骤,理解 ZooKeeper 服务器的初始化、配置解析、服务实例化等过程对我们日后在工作中分析排查 ZooKeeper 产生的相关问题以及提高 ZooKeeper 服务器的稳定性或性能都有很大的帮助。
通过本课时的学习我们知道了 ZooKeeper 服务单机版启动的关键步骤,下面我们来思考这个问题:在我们启动单机版服务器的时候,如果 ZooKeeper 服务通过 zoo.cfg 配置文件的相关参数,利用 FileTxnSnapLog 类来实现相关数据的本地化存储。那么我们在日常的开发维护中,如何才能知道当前存储 ZooKeeper 相关数据的磁盘容量应该设置多大的空间才能满足当前业务的发展?如何才能尽量减少磁盘空间的浪费?
diff --git a/专栏/ZooKeeper源码分析与实战-完/09 创建会话:避开日常开发的那些“坑”.md.html b/专栏/ZooKeeper源码分析与实战-完/09 创建会话:避开日常开发的那些“坑”.md.html
index c502f5f8..9c77bc7e 100644
--- a/专栏/ZooKeeper源码分析与实战-完/09 创建会话:避开日常开发的那些“坑”.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/09 创建会话:避开日常开发的那些“坑”.md.html
@@ -196,7 +196,7 @@ function hide_canvas() {
会话是 ZooKeeper 中最核心的概念之一。客户端与服务端的交互操作中都离不开会话的相关的操作。在前几节课中我们学到的临时节点、Watch 通知机制等都和客户端会话有密不可分的关系。比如一次会话关闭时,服务端会自动删除该会话所创建的临时节点,或者当客户端会话退出时,通过 Watch 监控机制就可以向订阅了该事件的客户端发送响应的通知。接下来我们就从会话的应用层使用,到 ZooKeeper 底层的实现原理,一步步学习会话的相关知识。
会话的创建
ZooKeeper 的工作方式一般是通过客户端向服务端发送请求而实现的。而在一个请求的发送过程中,首先,客户端要与服务端进行连接,而一个连接就是一个会话。在 ZooKeeper 中,一个会话可以看作是一个用于表示客户端与服务器端连接的数据结构 Session。而这个数据结构由三个部分组成:分别是会话 ID(sessionID)、会话超时时间(TimeOut)、会话关闭状态(isClosing),如下图所示。
-
+
下面我们来分别介绍一下这三个部分:
- 会话 ID:会话 ID 作为一个会话的标识符,当我们创建一次会话的时候,ZooKeeper 会自动为其分配一个唯一的 ID 编码。
@@ -205,7 +205,7 @@ function hide_canvas() {
会话状态
通过上面的学习,我们知道了 ZooKeeper 中一次会话的内部结构。下面我们就从系统运行的角度去分析,一次会话从创建到关闭的生命周期中都经历了哪些阶段。
-
+
上面是来自 ZooKeeper 官网的一张图片。该图片详细完整地描述了一次会话的完整生命周期。而通过该图片我们可以知道,在 ZooKeeper 服务的运行过程中,会话会经历不同的状态变化。而这些状态包括:正在连接(CONNECTING)、已经连接(CONNECTIED)、正在重新连接(RECONNECTING)、已经重新连接(RECONNECTED)、会话关闭(CLOSE)等。
当客户端开始创建一个与服务端的会话操作时,它的会话状态就会变成 CONNECTING,之后客户端会根据服务器地址列表中的服务器 IP 地址分别尝试进行连接。如果遇到一个 IP 地址可以连接到服务器,那么客户端会话状态将变为 CONNECTIED。
而如果因为网络原因造成已经连接的客户端会话断开时,客户端会重新尝试连接服务端。而对应的客户端会话状态又变成 CONNECTING ,直到该会话连接到服务端最终又变成 CONNECTIED。
diff --git a/专栏/ZooKeeper源码分析与实战-完/10 ClientCnxn:客户端核心工作类工作原理解析.md.html b/专栏/ZooKeeper源码分析与实战-完/10 ClientCnxn:客户端核心工作类工作原理解析.md.html
index 189d720b..b08fdb8f 100644
--- a/专栏/ZooKeeper源码分析与实战-完/10 ClientCnxn:客户端核心工作类工作原理解析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/10 ClientCnxn:客户端核心工作类工作原理解析.md.html
@@ -198,7 +198,7 @@ function hide_canvas() {
客户端核心类
在 ZooKeeper 客户端的底层实现中,ClientCnxn 类是其核心类,所有的客户端操作都是围绕这个类进行的。ClientCnxn 类主要负责维护客户端与服务端的网络连接和信息交互。
在前面的课程中介绍过,向服务端发送创建数据节点或者添加 Watch 监控等操作时,都会先将请求信息封装成 Packet 对象。那么 Packet 是什么呢?其实** Packet 可以看作是一个 ZooKeeper 定义的,用来进行网络通信的数据结构**,其主要作用是封装了网络通信协议层的数据。而 Packet 内部的数据结构如下图所示:
-
+
在 Packet 类中具有一些请求协议的相关属性字段,这些请求字段中分别包括:
- 请求头信息(RequestHeader)
diff --git a/专栏/ZooKeeper源码分析与实战-完/11 分桶策略:如何实现高效的会话管理?.md.html b/专栏/ZooKeeper源码分析与实战-完/11 分桶策略:如何实现高效的会话管理?.md.html
index 38d58735..560ab719 100644
--- a/专栏/ZooKeeper源码分析与实战-完/11 分桶策略:如何实现高效的会话管理?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/11 分桶策略:如何实现高效的会话管理?.md.html
@@ -198,13 +198,13 @@ function hide_canvas() {
会话管理策略
通过前面的学习,我们知道在 ZooKeeper 中为了保证一个会话的存活状态,客户端需要向服务器周期性地发送心跳信息。而客户端所发送的心跳信息可以是一个 ping 请求,也可以是一个普通的业务请求。ZooKeeper 服务端接收请求后,会更新会话的过期时间,来保证会话的存活状态。从中也能看出,在 ZooKeeper 的会话管理中,最主要的工作就是管理会话的过期时间。
ZooKeeper 中采用了独特的会话管理方式来管理会话的过期时间,网络上也给这种方式起了一个比较形象的名字:“分桶策略”。我将结合下图给你讲解“分桶策略”的原理。如下图所示,在 ZooKeeper 中,会话将按照不同的时间间隔进行划分,超时时间相近的会话将被放在同一个间隔区间中,这种方式避免了 ZooKeeper 对每一个会话进行检查,而是采用分批次的方式管理会话。这就降低了会话管理的难度,因为每次小批量的处理会话过期也提高了会话处理的效率。
-
+
通过上面的介绍,我们对 ZooKeeper 中的会话管理策略有了一个比较形象的理解。而为了能够在日常开发中使用好 ZooKeeper,面对高并发的客户端请求能够开发出更加高效稳定的服务,根据服务器日志判断客户端与服务端的会话异常等。下面我们从技术角度去说明 ZooKeeper 会话管理的策略,进一步加强对会话管理的理解。
底层实现
说到 ZooKeeper 底层实现的原理,核心的一点就是过期队列这个数据结构。所有会话过期的相关操作都是围绕这个队列进行的。可以说 ZooKeeper 底层就是采用这个队列结构来管理会话过期的。
而在讲解会话过期队列之前,我们首先要知道什么是 bucket。简单来说,一个会话过期队列是由若干个 bucket 组成的。而 bucket 是一个按照时间划分的区间。在 ZooKeeper 中,通常以 expirationInterval 为单位进行时间区间的划分,它是 ZooKeeper 分桶策略中用于划分时间区间的最小单位。
在 ZooKeeper 中,一个过期队列由不同的 bucket 组成。每个 bucket 中存放了在某一时间内过期的会话。将会话按照不同的过期时间段分别维护到过期队列之后,在 ZooKeeper 服务运行的过程中,具体的执行过程如下图所示。首先,ZooKeeper 服务会开启一个线程专门用来检索过期队列,找出要过期的 bucket,而 ZooKeeper 每次只会让一个 bucket 的会话过期,每当要进行会话过期操作时,ZooKeeper 会唤醒一个处于休眠状态的线程进行会话过期操作,之后会按照上面介绍的操作检索过期队列,取出过期的会话后会执行过期操作。
-
+
下面我们再来看一下 ZooKeeper 底层代码是如何实现会话过期队列的,在 ZooKeeper 底层中,使用 ExpiryQueue 类来实现一个会话过期策略。如下面的代码所示,在 ExpiryQueue 类中具有一个 elemMap 属性字段。它是一个线程安全的 HaspMap 列表,用来根据不同的过期时间区间存储会话。而 ExpiryQueue 类中也实现了诸如 remove 删除、update 更新以及 poll 等队列的常规操作方法。
public class ExpiryQueue<E> {
private final ConcurrentHashMap<E, Long> elemMap;
diff --git a/专栏/ZooKeeper源码分析与实战-完/12 服务端是如何处理一次会话请求的?.md.html b/专栏/ZooKeeper源码分析与实战-完/12 服务端是如何处理一次会话请求的?.md.html
index 8d543cc5..3b0c8bc3 100644
--- a/专栏/ZooKeeper源码分析与实战-完/12 服务端是如何处理一次会话请求的?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/12 服务端是如何处理一次会话请求的?.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
服务端处理过程
在之前的课程中,我们提过会话的创建过程,当客户端需要和 ZooKeeper 服务端进行相互协调通信时,首先要建立该客户端与服务端的连接会话,在会话成功创建后,ZooKeeper 服务端就可以接收来自客户端的请求操作了。
ZooKeeper 服务端在处理一次客户端发起的会话请求时,所采用的处理过程很像是一条工厂中的流水生产线。比如在一个毛绒玩具加工厂中,一条生产线上的工人可能只负责给玩具上色这一个具体的工作。
-
+
ZooKeeper 处理会话请求的方式也像是一条流水线,在这条流水线上,主要参与工作的是三个“工人”,分别是 PrepRequestProcessor 、ProposalRequestProcessor 以及 FinalRequestProcessor。这三个“工人”会协同工作,最终完成一次会话的处理工作,而它的实现方式就是我们之前提到的责任链模式。
下面我将分别对这三个部分进行讲解:作为第一个处理会话请求的“工人”,PrepRequestProcessor 类主要负责请求处理的准备工作,比如判断请求是否是事务性相关的请求操作。在 PrepRequestProcessor 完成工作后,ProposalRequestProcessor 类承接接下来的工作,对会话请求是否执行询问 ZooKeeper 服务中的所有服务器之后,执行相关的会话请求操作,变更 ZooKeeper 数据库数据。最后所有请求就会走到 FinalRequestProcessor 类中完成踢出重复会话的操作。
底层实现
@@ -222,7 +222,7 @@ function hide_canvas() {
在 pRequest 函数的内部,首先根据 OpCode.create 等字段值来判断请求操作的类型是否是事务操作,如果是事务操作,就调用 pRequest2Txn 函数进行预处理,这之后将该条请求交给 nextProcessor 字段指向的处理器进行处理。
事物处理器
PrepRequestProcessor 预处理器执行完工作后,就轮到 ProposalRequestProcessor 事物处理器上场了,ProposalRequestProcessor 是继 PrepRequestProcessor 后,责任链模式上的第二个处理器。其主要作用就是对事务性的请求操作进行处理,而从 ProposalRequestProcessor 处理器的名字中就能大概猜出,其具体的工作就是“提议”。所谓的“提议”是说,当处理一个事务性请求的时候,ZooKeeper 首先会在服务端发起一次投票流程,该投票的主要作用就是通知 ZooKeeper 服务端的各个机器进行事务性的操作了,避免因为某个机器出现问题而造成事物不一致等问题。在 ProposalRequestProcessor 处理器阶段,其内部又分成了三个子流程,分别是:Sync 流程、Proposal 流程、Commit 流程,下面我将分别对这几个流程进行讲解。
-
+
Sync 流程
首先我们看一下 Sync 流程,该流程的底层实现类是 SyncRequestProcess 类。SyncRequestProces 类的作用就是在处理事务性请求时,ZooKeeper 服务中的每台机器都将该条请求的操作日志记录下来,完成这个操作后,每一台机器都会向 ZooKeeper 服务中的 Leader 机器发送事物日志记录完成的通知。
Proposal 流程
diff --git a/专栏/ZooKeeper源码分析与实战-完/13 Curator:如何降低 ZooKeeper 使用的复杂性?.md.html b/专栏/ZooKeeper源码分析与实战-完/13 Curator:如何降低 ZooKeeper 使用的复杂性?.md.html
index a6635eb0..9f3ac653 100644
--- a/专栏/ZooKeeper源码分析与实战-完/13 Curator:如何降低 ZooKeeper 使用的复杂性?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/13 Curator:如何降低 ZooKeeper 使用的复杂性?.md.html
@@ -262,7 +262,7 @@ client.start();
无论使用什么开发语言和框架,程序都会出现错误与异常等情况,而 ZooKeeper 服务使客户端与服务端通过网络进行通信的方式协同工作,由于网络不稳定和不可靠等原因,使得网络中断或高延迟异常情况出现得更加频繁,所以处理好异常是决定 ZooKeeper 服务质量的关键。
在 Curator 中,客户端与服务端建立会话连接后会一直监控该连接的运行情况。在底层实现中通过
ConnectionStateListener 来监控会话的连接状态,当连接状态改变的时候,根据参数设置 ZooKeeper 服务会采取不同的处理方式,而一个会话基本有六种状态,如下图所示:
-
+
下面我来为你详细讲解这六种状态的作用:
- CONNECTED(已连接状态):当客户端发起的会话成功连接到服务端后,该条会话的状态变为 CONNECTED 已连接状态。
@@ -287,7 +287,7 @@ CuratorFrameworkFactory.Builder.canBeReadOnly() 的时候,该会话会一直
Leader 选举
除了异常处理,接下来我们再介绍一个在日常工作中经常要解决的问题,即开发 ZooKeeper 集群的相关功能。
在分布式环境中,ZooKeeper 集群起到了关键作用。在之前的课程中我们讲过,Leader 选举是保证 ZooKeeper 集群可用性的解决方案,可以避免在集群使用中出现单点失效等问题。在 ZooKeeper 服务开始运行的时候,首先会选举出 Leader 节点服务器,之后在服务运行过程中,Leader 节点服务器失效时,又会重新在集群中进行 Leader 节点的选举操作。
-
+
而在日常开发中,使用 ZooKeeper 原生的 API 开发 Leader 选举相关的功能相对比较复杂。Curator 框架中的 recipe 包为我们提供了高效的,方便易用的工具函数,分别是 LeaderSelector 和 LeaderLatch。
LeaderSelector 函数的功能是选举 ZooKeeper 中的 Leader 服务器,其具体实现如下面的代码所示,通过构造函数的方式实例化。在一个构造方法中,第一个参数为会话的客户端实例 CuratorFramework,第二个参数是 LeaderSelectorListener 对象,它是在被选举成 Leader 服务器的事件触发时执行的回调函数。
public LeaderSelector(CuratorFramework client, String mutexPath,LeaderSelectorListener listener)
diff --git a/专栏/ZooKeeper源码分析与实战-完/14 Leader 选举:如何保证分布式数据的一致性?.md.html b/专栏/ZooKeeper源码分析与实战-完/14 Leader 选举:如何保证分布式数据的一致性?.md.html
index 4e5eaa8f..384e71af 100644
--- a/专栏/ZooKeeper源码分析与实战-完/14 Leader 选举:如何保证分布式数据的一致性?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/14 Leader 选举:如何保证分布式数据的一致性?.md.html
@@ -199,12 +199,12 @@ function hide_canvas() {
在分布式系统中有一个著名的 CAP 定理,是说一个分布式系统不能同时满足一致性、可用性,以及分区容错性。今天我们要讲的就是一致性。其实 ZooKeeper 中实现的一致性也不是强一致性,即集群中各个服务器上的数据每时每刻都是保持一致的特性。在 ZooKeeper 中,采用的是最终一致的特性,即经过一段时间后,ZooKeeper 集群服务器上的数据最终保持一致的特性。
在 ZooKeeper 集群中,Leader 服务器主要负责处理事物性的请求,而在接收到一个客户端的事务性请求操作时,Leader 服务器会先向集群中的各个机器针对该条会话发起投票询问。
要想实现 ZooKeeper 集群中的最终一致性,我们先要确定什么情况下会对 ZooKeeper 集群服务产生不一致的情况。如下图所示:
-
+
在集群初始化启动的时候,首先要同步集群中各个服务器上的数据。而在集群中 Leader 服务器崩溃时,需要选举出新的 Leader 而在这一过程中会导致各个服务器上数据的不一致,所以当选举出新的 Leader 服务器后需要进行数据的同步操作。
底层实现
与上面介绍的一样,我们的底层实现讲解主要围绕 ZooKeeper 集群中数据一致性的底层实现。ZooKeeper 在集群中采用的是多数原则方式,即当一个事务性的请求导致服务器上的数据发生改变时,ZooKeeper 只要保证集群上的多数机器的数据都正确变更了,就可以保证系统数据的一致性。 这是因为在一个 ZooKeeper 集群中,每一个 Follower 服务器都可以看作是 Leader 服务器的数据副本,需要保证集群中大多数机器数据是一致的,这样在集群中出现个别机器故障的时候,ZooKeeper 集群依然能够保证稳定运行。
在 ZooKeeper 集群服务的运行过程中,数据同步的过程如下图所示。当执行完数据变更的会话请求时,需要对集群中的服务器进行数据同步。
-
+
广播模式
ZooKeeper 在代码层的实现中定义了一个 HashSet 类型的变量,用来管理在集群中的 Follower 服务器,之后调用
getForwardingFollowers 函数获取在集群中的 Follower 服务器,如下面这段代码所示:
diff --git a/专栏/ZooKeeper源码分析与实战-完/15 ZooKeeper 究竟是怎么选中 Leader 的?.md.html b/专栏/ZooKeeper源码分析与实战-完/15 ZooKeeper 究竟是怎么选中 Leader 的?.md.html
index 0e828d54..b6d5a7cd 100644
--- a/专栏/ZooKeeper源码分析与实战-完/15 ZooKeeper 究竟是怎么选中 Leader 的?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/15 ZooKeeper 究竟是怎么选中 Leader 的?.md.html
@@ -200,7 +200,7 @@ function hide_canvas() {
服务启动时的 Leader 选举
Leader 服务器的选举操作主要发生在两种情况下。第一种就是 ZooKeeper 集群服务启动的时候,第二种就是在 ZooKeeper 集群中旧的 Leader 服务器失效时,这时 ZooKeeper 集群需要选举出新的 Leader 服务器。
我们先来介绍在 ZooKeeper 集群服务最初启动的时候,Leader 服务器是如何选举的。在 ZooKeeper 集群启动时,需要在集群中的服务器之间确定一台 Leader 服务器。当 ZooKeeper 集群中的三台服务器启动之后,首先会进行通信检查,如果集群中的服务器之间能够进行通信。集群中的三台机器开始尝试寻找集群中的 Leader 服务器并进行数据同步等操作。如何这时没有搜索到 Leader 服务器,说明集群中不存在 Leader 服务器。这时 ZooKeeper 集群开始发起 Leader 服务器选举。在整个 ZooKeeper 集群中 Leader 选举主要可以分为三大步骤分别是:发起投票、接收投票、统计投票。
-
+
发起投票
我们先来看一下发起投票的流程,在 ZooKeeper 服务器集群初始化启动的时候,集群中的每一台服务器都会将自己作为 Leader 服务器进行投票。也就是每次投票时,发送的服务器的 myid(服务器标识符)和 ZXID (集群投票信息标识符)等选票信息字段都指向本机服务器。 而一个投票信息就是通过这两个字段组成的。以集群中三个服务器 Serverhost1、Serverhost2、Serverhost3 为例,三个服务器的投票内容分别是:Severhost1 的投票是(1,0)、Serverhost2 服务器的投票是(2,0)、Serverhost3 服务器的投票是(3,0)。
接收投票
@@ -210,7 +210,7 @@ function hide_canvas() {
在接收到投票后,ZooKeeper 集群就该处理和统计投票结果了。对于每条接收到的投票信息,集群中的每一台服务器都会将自己的投票信息与其接收到的 ZooKeeper 集群中的其他投票信息进行对比。主要进行对比的内容是 ZXID,ZXID 数值比较大的投票信息优先作为 Leader 服务器。如果每个投票信息中的 ZXID 相同,就会接着比对投票信息中的 myid 信息字段,选举出 myid 较大的服务器作为 Leader 服务器。
拿上面列举的三个服务器组成的集群例子来说,对于 Serverhost1,服务器的投票信息是(1,0),该服务器接收到的 Serverhost2 服务器的投票信息是(2,0)。在 ZooKeeper 集群服务运行的过程中,首先会对比 ZXID,发现结果相同之后,对比 myid,发现 Serverhost2 服务器的 myid 比较大,于是更新自己的投票信息为(2,0),并重新向 ZooKeeper 集群中的服务器发送新的投票信息。而 Serverhost2 服务器则保留自身的投票信息,并重新向 ZooKeeper 集群服务器中发送投票信息。
而当每轮投票过后,ZooKeeper 服务都会统计集群中服务器的投票结果,判断是否有过半数的机器投出一样的信息。如果存在过半数投票信息指向的服务器,那么该台服务器就被选举为 Leader 服务器。比如上面我们举的例子中,ZooKeeper 集群会选举 Severhost2 服务器作为 Leader 服务器。
-
+
当 ZooKeeper 集群选举出 Leader 服务器后,ZooKeeper 集群中的服务器就开始更新自己的角色信息,除被选举成 Leader 的服务器之外,其他集群中的服务器角色变更为 Following。
服务运行时的 Leader 选举
上面我们介绍了 ZooKeeper 集群启动时 Leader 服务器的选举方法。接下来我们再看一下在 ZooKeeper 集群服务的运行过程中,Leader 服务器是如果进行选举的。
diff --git a/专栏/ZooKeeper源码分析与实战-完/16 ZooKeeper 集群中 Leader 与 Follower 的数据同步策略.md.html b/专栏/ZooKeeper源码分析与实战-完/16 ZooKeeper 集群中 Leader 与 Follower 的数据同步策略.md.html
index a5b7e1bd..7623dd6c 100644
--- a/专栏/ZooKeeper源码分析与实战-完/16 ZooKeeper 集群中 Leader 与 Follower 的数据同步策略.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/16 ZooKeeper 集群中 Leader 与 Follower 的数据同步策略.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
同步条件是指在 ZooKeeper 集群中何时触发数据同步的机制。与上一课时中 Leader 选举首先要判断集群中 Leader 服务器是否存在不同,要想进行集群中的数据同步,首先需要 ZooKeeper 集群中存在用来进行数据同步的 Learning 服务器。 也就是说,当 ZooKeeper 集群中选举出 Leader 节点后,除了被选举为 Leader 的服务器,其他服务器都作为 Learnning 服务器,并向 Leader 服务器注册。之后系统就进入到数据同步的过程中。
同步过程
在数据同步的过程中,ZooKeeper 集群的主要工作就是将那些没有在 Learnning 服务器上执行过的事务性请求同步到 Learning 服务器上。这里请你注意,事务性的会话请求会被同步,而像数据节点的查询等非事务性请求则不在数据同步的操作范围内。 而在具体实现数据同步的时候,ZooKeeper 集群又提供四种同步方式,如下图所示:
-
+
DIFF 同步
DIFF 同步即差异化同步的方式,在 ZooKeeper 集群中,Leader 服务器探测到 Learnning 服务器的存在后,首先会向该 Learnning 服务器发送一个 DIFF 不同指令。在收到该条指令后,Learnning 服务器会进行差异化方式的数据同步操作。在这个过程中,Leader 服务器会将一些 Proposal 发送给 Learnning 服务器。之后 Learnning 服务器在接收到来自 Leader 服务器的 commit 命令后执行数据持久化的操作。
TRUNC+DIFF 同步
diff --git a/专栏/ZooKeeper源码分析与实战-完/17 集群中 Leader 的作用:事务的请求处理与调度分析.md.html b/专栏/ZooKeeper源码分析与实战-完/17 集群中 Leader 的作用:事务的请求处理与调度分析.md.html
index 38a37be8..ee554a55 100644
--- a/专栏/ZooKeeper源码分析与实战-完/17 集群中 Leader 的作用:事务的请求处理与调度分析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/17 集群中 Leader 的作用:事务的请求处理与调度分析.md.html
@@ -203,21 +203,21 @@ function hide_canvas() {
Leader 事务处理分析
上面我们介绍了 ZooKeeper 集群在处理事务性会话请求时的内部原理。接下来我们就以客户端发起的创建节点请求 setData 为例,具体看看 ZooKeeper 集群的底层处理过程。
在 ZooKeeper 集群接收到来自客户端的一个 setData 会话请求后,其内部的处理逻辑基本可以分成四个部分。如下图所示,分别是预处理阶段、事务处理阶段、事务执行阶段、响应客户端。
-
+
预处理阶段:
在预处理阶段,主要工作是通过网络 I/O 接收来自客户端的会话请求。判断该条会话请求的类型是否是事务性的会话请求,之后将该请求提交给
PrepRequestProcessor 处理器进行处理。封装请求事务头并检查会话是否过期,最后反序列化事务请求信息创建 setDataRequest 请求,在 setDataRequest 记录中包含了要创建数据的节点的路径、数据节点的内容信息以及数据节点的版本信息。最后将该请求存放在 outstandingChanges 队列中等待之后的处理。
-
+
事务处理阶段:
在事务处理阶段,ZooKeeper 集群内部会将该条会话请求提交给 ProposalRequestProcessor 处理器进行处理。本阶段内部又分为提交、同步、统计三个步骤。其具体的处理过程我们在之前的课程中已经介绍过了,这里不再赘述。
-
+
事务执行阶段:
在经过预处理阶段和事务会话的投票发起等操作后,一个事务性的会话请求都已经准备好了,接下来就是在 ZooKeeper 的数据库中执行该条会话的数据变更操作。
在处理数据变更的过程中,ZooKeeper 内部会将该请求会话的事务头和事务体信息直接交给内存数据库 ZKDatabase 进行事务性的持久化操作。之后返回 ProcessTxnResult 对象表明操作结果是否成功。
-
+
响应客户端:
在 ZooKeeper 集群处理完客户端 setData 方法发送的数据节点创建请求后,会将处理结果发送给客户端。而在响应客户端的过程中,ZooKeeper 内部首先会创建一个 setDataResponse 响应体类型,该对象主要包括当前会话请求所创建的数据节点,以及其最新状态字段信息 stat。之后创建请求响应头信息,响应头作为客户端请求响应的重要信息,客户端在接收到 ZooKeeper 集群的响应后,通过解析响应头信息中的事务 ZXID 和请求结果标识符 err 来判断该条会话请求是否成功执行。
-
+
事务处理底层实现
介绍完 ZooKeeper 集群处理事务性会话请求的理论方法和内部过程后。接下来我们从代码层面来进一步分析 ZooKeeper 在处理事务性请求时的底层核心代码实现。
首先,ZooKeeper 集群在收到客户端发送的事务性会话请求后,会对该请求进行预处理。在代码层面,ZooKeeper 通过调用 PrepRequestProcessor 类来实现预处理阶段的全部逻辑。可以这样理解:在处理客户端会话请求的时候,首先调用的就是 PrepRequestProcessor 类。而在 PrepRequestProcessor 内部,是通过 pRequest 方法判断客户端发送的会话请求类型。如果是诸如 setData 数据节点创建等事务性的会话请求,就调用 pRequest2Txn 方法进一步处理。
diff --git a/专栏/ZooKeeper源码分析与实战-完/18 集群中 Follow 的作用:非事务请求的处理与 Leader 的选举分析.md.html b/专栏/ZooKeeper源码分析与实战-完/18 集群中 Follow 的作用:非事务请求的处理与 Leader 的选举分析.md.html
index 5216fad8..b1abbd42 100644
--- a/专栏/ZooKeeper源码分析与实战-完/18 集群中 Follow 的作用:非事务请求的处理与 Leader 的选举分析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/18 集群中 Follow 的作用:非事务请求的处理与 Leader 的选举分析.md.html
@@ -205,7 +205,7 @@ function hide_canvas() {
简单介绍完 ZooKeeper 集群中 Follow 服务器在处理非事务性请求的过程后,接下来我们再从代码层面分析一下底层的逻辑实现是怎样的。
从代码实现的角度讲,ZooKeeper 集群在接收到来自客户端的请求后,会将请求交给 Follow 服务器进行处理。而 Follow 服务器内部首先调用的是 FollowerZooKeeperServer 类,该类的作用是封装 Follow 服务器的属性和行为,你可以把该类当作一台 Follow 服务器的代码抽象。
如下图所示,该 FollowerZooKeeperServer 类继承了 LearnerZooKeeperServer 。在一个 FollowerZooKeeperServer 类内部,定义了一个核心的 ConcurrentLinkedQueue 类型的队列字段,用于存放接收到的会话请求。
-
+
在定义了 FollowerZooKeeperServer 类之后,在该类的 setupRequestProcessors 函数中,定义了我们之前一直反复提到的处理责任链,指定了该处理链上的各个处理器。如下面的代码所示,分别按顺序定义了起始处理器 FollowerRequestProcessor 、提交处理器 CommitProcessor、同步处理器 SendAckRequestProcessor 以及最终处理器 FinalProcessor。
protected void setupRequestProcessors() {
RequestProcessor finalProcessor = new FinalRequestProcessor(this);
@@ -221,7 +221,7 @@ function hide_canvas() {
选举过程
介绍完 Follow 服务器处理非事务性请求的过程后,接下来我们再学习一下 Follow 服务器的另一个主要的功能:在 Leader 服务器崩溃的时候,重新选举出 Leader 服务器。
ZooKeeper 集群重新选举 Leader 的过程本质上只有 Follow 服务器参与工作。而在 ZooKeeper 集群重新选举 Leader 节点的过程中,如下图所示。主要可以分为 Leader 失效发现、重新选举 Leader 、Follow 服务器角色变更、集群同步这几个步骤。
-
+
Leader 失效发现
通过之前的介绍我们知道,在 ZooKeeper 集群中,当 Leader 服务器失效时,ZooKeeper 集群会重新选举出新的 Leader 服务器。也就是说,Leader 服务器的失效会触发 ZooKeeper 开始新 Leader 服务器的选举,那么在 ZooKeeper 集群中,又是如何发现 Leader 服务器失效的呢?
这里就要介绍到 Leader 失效发现。和我们之前介绍的保持客户端活跃性的方法,它是通过客户端定期向服务器发送 Ping 请求来实现的。在 ZooKeeper 集群中,探测 Leader 服务器是否存活的方式与保持客户端活跃性的方法非常相似。首先,Follow 服务器会定期向 Leader 服务器发送 网络请求,在接收到请求后,Leader 服务器会返回响应数据包给 Follow 服务器,而在 Follow 服务器接收到 Leader 服务器的响应后,如果判断 Leader 服务器运行正常,则继续进行数据同步和服务转发等工作,反之,则进行 Leader 服务器的重新选举操作。
@@ -251,7 +251,7 @@ default:
continue;
之后,在 ZooKeeper 集群选举 Leader 服务器时,是通过 FastLeaderElection 类实现的。该类实现了 TCP 方式的通信连接,用于在 ZooKeeper 集群中与其他 Follow 服务器进行协调沟通。
-
+
如上图所示,FastLeaderElection 类继承了 Election 接口,定义其是用来进行选举的实现类。而在其内部,又定义了选举通信相关的一些配置参数,比如 finalizeWait 最终等待时间、最大通知间隔时间 maxNotificationInterval 等。
在选举的过程中,首先调用 ToSend 函数向 ZooKeeper 集群中的其他角色服务器发送本机的投票信息,其他服务器在接收投票信息后,会对投票信息进行有效性验证等操作,之后 ZooKeeper 集群统计投票信息,如果过半数的机器投票信息一致,则集群就重新选出新的 Leader 服务器。
static public class ToSend {
diff --git a/专栏/ZooKeeper源码分析与实战-完/19 Observer 的作用与 Follow 有哪些不同?.md.html b/专栏/ZooKeeper源码分析与实战-完/19 Observer 的作用与 Follow 有哪些不同?.md.html
index 4e1758b1..2277cda3 100644
--- a/专栏/ZooKeeper源码分析与实战-完/19 Observer 的作用与 Follow 有哪些不同?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/19 Observer 的作用与 Follow 有哪些不同?.md.html
@@ -199,7 +199,7 @@ function hide_canvas() {
在 ZooKeeper 集群服务运行的过程中,Observer 服务器与 Follow 服务器具有一个相同的功能,那就是负责处理来自客户端的诸如查询数据节点等非事务性的会话请求操作。但与 Follow 服务器不同的是,Observer 不参与 Leader 服务器的选举工作,也不会被选举为 Leader 服务器。
在前面的课程中,我们或多或少有涉及 Observer 服务器,当时我们把 Follow 服务器和 Observer 服务器统称为 Learner 服务器。你可能会觉得疑惑,Observer 服务器做的事情几乎和 Follow 服务器一样,那么为什么 ZooKeeper 还要创建一个 Observer 角色服务器呢?
要想解释这个问题,就要从 ZooKeeper 技术的发展过程说起,最早的 ZooKeeper 框架如下图所示,可以看到,其中是不存在 Observer 服务器的。
-
+
在早期的 ZooKeeper 集群服务运行过程中,只有 Leader 服务器和 Follow 服务器。不过随着 ZooKeeper 在分布式环境下的广泛应用,早期模式的设计缺点也随之产生,主要带来的问题有如下几点:
- 随着集群规模的变大,集群处理写入的性能反而下降。
@@ -209,7 +209,7 @@ function hide_canvas() {
正因如此,随着集群中 Follow 服务器的数量越来越多,一次写入等相关操作的投票也就变得越来越复杂,并且 Follow 服务器之间彼此的网络通信也变得越来越耗时,导致随着 Follow 服务器数量的逐步增加,事务性的处理性能反而变得越来越低。
为了解决这一问题,在 ZooKeeper 3.6 版本后,ZooKeeper 集群中创建了一种新的服务器角色,即 Observer——观察者角色服务器。Observer 可以处理 ZooKeeper 集群中的非事务性请求,并且不参与 Leader 节点等投票相关的操作。这样既保证了 ZooKeeper 集群性能的扩展性,又避免了因为过多的服务器参与投票相关的操作而影响 ZooKeeper 集群处理事务性会话请求的能力。
在引入 Observer 角色服务器后,一个 ZooKeeper 集群服务在部署的拓扑结构,如下图所示:
-
+
在实际部署的时候,因为 Observer 不参与 Leader 节点等操作,并不会像 Follow 服务器那样频繁的与 Leader 服务器进行通信。因此,可以将 Observer 服务器部署在不同的网络区间中,这样也不会影响整个 ZooKeeper 集群的性能,也就是所谓的跨域部署。
底层实现
介绍完 Observer 的作用和原理后,接下来我们再从底层代码的角度去分析一下 ZooKeeper 是如何实现一个 Observer 服务器的。
diff --git a/专栏/ZooKeeper源码分析与实战-完/20 一个运行中的 ZooKeeper 服务会产生哪些数据和文件?.md.html b/专栏/ZooKeeper源码分析与实战-完/20 一个运行中的 ZooKeeper 服务会产生哪些数据和文件?.md.html
index 29eb90d2..1438689a 100644
--- a/专栏/ZooKeeper源码分析与实战-完/20 一个运行中的 ZooKeeper 服务会产生哪些数据和文件?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/20 一个运行中的 ZooKeeper 服务会产生哪些数据和文件?.md.html
@@ -216,7 +216,7 @@ function hide_canvas() {
- VERSION:设置日志文件的版本信息。
- lastZxidSeen:最后一次更新日志得到的 ZXID。
-
+
定义了事务日志操作的相关指标参数后,在 FileTxnLog 类中调用 static 静态代码块,来将这些配置参数进行初始化。比如读取 preAllocSize 参数分配给日志文件的空间大小等操作。
static {
LOG = LoggerFactory.getLogger(FileTxnLog.class);
diff --git a/专栏/ZooKeeper源码分析与实战-完/21 ZooKeeper 分布式锁:实现和原理解析.md.html b/专栏/ZooKeeper源码分析与实战-完/21 ZooKeeper 分布式锁:实现和原理解析.md.html
index eb2d6000..8a154c4d 100644
--- a/专栏/ZooKeeper源码分析与实战-完/21 ZooKeeper 分布式锁:实现和原理解析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/21 ZooKeeper 分布式锁:实现和原理解析.md.html
@@ -216,15 +216,15 @@ function hide_canvas() {
接下来我就通过 ZooKeeper 来实现一个排他锁。
创建锁
首先,我们通过在 ZooKeeper 服务器上创建数据节点的方式来创建一个共享锁。其实无论是共享锁还是排他锁,在锁的实现方式上都是一样的。唯一的区别在于,共享锁为一个数据事务创建两个数据节点,来区分是写入操作还是读取操作。如下图所示,在 ZooKeeper 数据模型上的 Locks_shared 节点下创建临时顺序节点,临时顺序节点的名称中带有请求的操作类型分别是 R 读取操作、W 写入操作。
-
+
获取锁
当某一个事务在访问共享数据时,首先需要获取锁。ZooKeeper 中的所有客户端会在 Locks_shared 节点下创建一个临时顺序节点。根据对数据对象的操作类型创建不同的数据节点,如果是读操作,就创建名称中带有 R 标志的顺序节点,如果是写入操作就创建带有 W 标志的顺序节点。
-
+
释放锁
事务逻辑执行完毕后,需要对事物线程占有的共享锁进行释放。我们可以利用 ZooKeeper 中数据节点的性质来实现主动释放锁和被动释放锁两种方式。
主动释放锁是当客户端的逻辑执行完毕,主动调用 delete 函数删除ZooKeeper 服务上的数据节点。而被动释放锁则利用临时节点的性质,在客户端因异常而退出时,ZooKeeper 服务端会直接删除该临时节点,即释放该共享锁。
这种实现方式正好和上面介绍的死锁的两种处理方式相对应。到目前为止,我们就利用 ZooKeeper 实现了一个比较完整的共享锁。如下图所示,在这个实现逻辑中,首先通过创建数据临时数据节点的方式实现获取锁的操作。创建数据节点分为两种,分别是读操作的数据节点和写操作的数据节点。当锁节点删除时,注册了该 Watch 监控的其他客户端也会收到通知,重新发起创建临时节点尝试获取锁。当事务逻辑执行完成,客户端会主动删除该临时节点释放锁。
-
+
总结
通过本课时的学习,我们掌握了什么是分布式锁,以及分布式锁在实际生产环境中面临的问题和挑战。无论是单机上的加锁还是分布式环境下的分布式锁,都会出现死锁问题。面对死锁问题,如果我们不能很好地处理,会严重影响系统的运行。在本课时中,我为你讲解了两种处理死锁问题的方法,分别是超时设置和死锁监控。然后重点介绍了利用 ZooKeeper 实现一个共享锁。
在具体实现的过程中,我们利用 ZooKeeper 数据模型的临时顺序节点和 Watch 监控机制,在客户端通过创建数据节点的方式来获取锁,通过删除数据节点来释放锁。
diff --git a/专栏/ZooKeeper源码分析与实战-完/22 基于 ZooKeeper 命名服务的应用:分布式 ID 生成器.md.html b/专栏/ZooKeeper源码分析与实战-完/22 基于 ZooKeeper 命名服务的应用:分布式 ID 生成器.md.html
index 7f28ffe0..f5b29913 100644
--- a/专栏/ZooKeeper源码分析与实战-完/22 基于 ZooKeeper 命名服务的应用:分布式 ID 生成器.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/22 基于 ZooKeeper 命名服务的应用:分布式 ID 生成器.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
无论是单机环境还是分布式环境,都有使用唯一标识符标记某一资源的使用场景。比如在淘宝、京东等购物网站下单时,系统会自动生成订单编号,这个订单编号就是一个分布式 ID 的使用。
什么是 ID 生成器
我们先来介绍一下什么是 ID 生成器。分布式 ID 生成器就是通过分布式的方式,实现自动生成分配 ID 编码的程序或服务。在日常开发中,Java 语言中的 UUID 就是生成一个 32 位的 ID 编码生成器。根据日常使用场景,我们生成的 ID 编码一般具有唯一性、递增性、安全性、扩展性这几个特性。
-
+
唯一性:ID 编码作为标记分布式系统重要资源的标识符,在整个分布式系统环境下,生成的 ID 编码应该具有全局唯一的特性。如果产生两个重复的 ID 编码,就无法通过 ID 编码准确找到对应的资源,这也是一个 ID 编码最基本的要求。
递增性:递增性也可以说是 ID 编码的有序特性,它指一般的 ID 编码具有一定的顺序规则。比如 MySQL 数据表主键 ID,一般是一个递增的整数数字,按逐条加一的方式顺序增大。我们现在学习的 ZooKeeper 系统的 zxID 也具有递增的特性,这样在投票阶段就可以根据 zxID 的有序特性,对投票信息进行比对。
安全性:有的业务场景对 ID 的安全性有很高的要求,但这里说的安全性是指,如果按照递增的方式生成 ID 编码,那么这种规律很容易被发现。比如淘宝的订单编码,如果被恶意的生成或使用,会严重影响系统的安全性,所以 ID 编码必须保证其安全性。
@@ -211,20 +211,20 @@ function hide_canvas() {
生成 ID 编码的另一种方式是数据库序列。比如 MySQL 的自增主键就是一种有序的 ID 生成方式。随着数据变得越来越多,为了提升数据库的性能,就要对数据库进行分库分表等操作。在这种情况下,自增主键的方式不能满足系统处理海量数据的要求。
这里我给你介绍另一种性能更好的数据库序列生成方式:TDDL 中的序列化实现。TDDL 是 Taobao Distributed Data Layer 的缩写。是淘宝根据自己的业务特点开发的数据库中间件。主要应用于数据库分库分表的应用场景中。
TDDL 生成 ID 编码的大致过程如下图所示。首先,作为 ID 生成器的机器,数据库中会存在一张sequence 序列化表,用于记录当前已经被占用的 ID 最大值。之后每个需要 ID 编码的客户端在请求 ID 编码生成器后,编码服务器会返回给该客户端一段 ID 地址区间。并更新 sequence 表中的信息。
-
+
在接收一段 ID 编码后,客户端会将该编码存储在内存中。在本机需要使用 ID 编码时,会首先使用内存中的 ID 编码。如果内存中的 ID 编码已经完全被占用,则再重新向编码服务器获取。
在 TDDL 框架的内部实现中,通过分批获取 ID 编码的方式,减少了客户端访问服务器的频率,避免了网络波动所造成的影响,并减轻了服务器的内存压力。不过 TDDL 是高度依赖底层数据库的实现方式,不能作为一个独立的分布式 ID 生成器对外提供服务。
实现方式
上面介绍的几种策略,有的和底层编码耦合比较大,有的又局限在某一具体的使用场景下,并不满足作为分布式环境下一个公共 ID 生成器的要求。接下来我们就利用目前学到的 ZooKeeper 知识,动手实现一个真正的分布式 ID 生成器。
首先,我们通过 ZooKeeper 自身的客户端和服务器运行模式,来实现一个分布式网络环境下的 ID 请求和分发过程。每个需要 ID 编码的业务服务器可以看作是 ZooKeeper 的客户端。ID 编码生成器可以作为 ZooKeeper 的服务端。客户端通过发送请求到 ZooKeeper 服务器,来获取编码信息,服务端接收到请求后,发送 ID 编码给客户端。
-
+
在代码层面的实现中,如上图所示。我们可以利用 ZooKeeper 数据模型中的顺序节点作为 ID 编码。客户端通过调用 create 函数创建顺序节点。服务器成功创建节点后,会响应客户端请求,把创建好的节点信息发送给客户端。客户端用数据节点名称作为 ID 编码,进行之后的本地业务操作。
通过上面的介绍,我们发现,使用 ZooKeeper 实现一个分布式环境下的公用 ID 编码生成器很容易。利用 ZooKeeper 中的顺序节点特性,很容易使我们创建的 ID 编码具有有序的特性。并且我们也可以通过客户端传递节点的名称,根据不同的业务编码区分不同的业务系统,从而使编码的扩展能力更强。
虽然使用 ZooKeeper 的实现方式有这么多优点,但也会有一些潜在的问题。其中最主要的是,在定义编码的规则上还是强烈依赖于程序员自身的能力和对业务的深入理解。很容易出现因为考虑不周,造成设置的规则在运行一段时间后,无法满足业务要求或者安全性不够等问题。为了解决这个问题,我们继续学习一个比较常用的编码算法——snowflake 算法。
snowflake 算法
snowflake 算法是 Twitter 公司开源的一种用来生成分布式 ID 编码的算法。如下图所示,通过 snowflake 算法生成的编码是一个 64 位的长整型值。在 snowflake 算法中,是通过毫秒数、机器 ID
毫秒流水号、符号位这几个元素生成最终的编码。
-
+
在计算编码的过程中,首先获取机器的毫秒数,并存储为 41 位,之后查询机器的工作 ID,存储在后面的 10 位字节中。剩余的 12 字节就用来存储毫秒内的流水号和表示位符号值 0。
从图中可以看出,snowflake 算法最主要的实现手段就是对二进制数位的操作。从性能上说,这个算法理论上每秒可以生成 400 多万个 ID 编码,完全满足分布式环境下,对系统高并发的要求。因此,在平时的开发过程中,也尽量使用诸如 snowflake 这种业界普遍采用的分布式 ID 生成算法,避免自己闭门造车导致的性能或安全风险。
总结
diff --git a/专栏/ZooKeeper源码分析与实战-完/23 使用 ZooKeeper 实现负载均衡服务器功能.md.html b/专栏/ZooKeeper源码分析与实战-完/23 使用 ZooKeeper 实现负载均衡服务器功能.md.html
index 32775224..a0a9ccc5 100644
--- a/专栏/ZooKeeper源码分析与实战-完/23 使用 ZooKeeper 实现负载均衡服务器功能.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/23 使用 ZooKeeper 实现负载均衡服务器功能.md.html
@@ -219,7 +219,7 @@ function hide_canvas() {
状态收集
首先我们来实现网络中服务器运行状态的收集功能,利用 ZooKeeper 中的临时节点作为标记网络中服务器的状态点位。在网络中服务器上线运行的时候,通过在 ZooKeeper 服务器中创建临时节点,向 ZooKeeper 的服务列表进行注册,表示本台服务器已经上线可以正常工作。通过删除临时节点或者在与 ZooKeeper 服务器断开连接后,删除该临时节点。
最后,通过统计临时节点的数量,来了解网络中服务器的运行情况。如下图所示,建立的 ZooKeeper 数据模型中 Severs 节点可以作为存储服务器列表的父节点。用于之后通过负载均衡算法在该列表中选择服务器。在它下面创建 servers_host1、servers_host2、servers_host3等临时节点来存储集群中的服务器运行状态信息。
-
+
在代码层面的实现中,我们首先定义一个 BlanceSever 接口类。该类规定在 ZooKeeper 服务器启动后,向服务器地址列表中,注册或注销信息以及根据接收到的会话请求,动态更新负载均衡情况等功能。如下面的代码所示:
public class BlanceSever{
public void register()
@@ -257,7 +257,7 @@ function hide_canvas() {
负载算法
实现服务器列表后,接下来我们就进入负载均衡最核心的内容:如何选择服务器。这里我们通过采用“最小连接数”算法,来确定究竟如何均衡地分配网络会话请求给后台客户端。
整个实现的过程如下图所示。首先,在接收到客户端的请求后,通过 getData 方法获取服务端 Severs 节点下的服务器列表,其中每个节点信息都存储有当前服务器的连接数。通过判断选择最少的连接数作为当前会话的处理服务器,并通过 setData 方法将该节点连接数加 1。最后,当客户端执行完毕,再调用 setData 方法将该节点信息减 1。
-
+
首先,我们定义当服务器接收到会话请求后。在 ZooKeeper 服务端增加连接数的 addBlance 方法。如下面的代码所示,首先我们通过 readData 方法获取服务器最新的连接数,之后将该连接数加 1,再通过 writeData 方法将新的连接数信息写入到服务端对应节点信息中。
public void addBlance() throws Exception{
InetAddress address = InetAddress.getLocalHost();
diff --git a/专栏/ZooKeeper源码分析与实战-完/24 ZooKeeper 在 Kafka 和 Dubbo 中的工业级实现案例分析.md.html b/专栏/ZooKeeper源码分析与实战-完/24 ZooKeeper 在 Kafka 和 Dubbo 中的工业级实现案例分析.md.html
index 260f1616..9d5ccd39 100644
--- a/专栏/ZooKeeper源码分析与实战-完/24 ZooKeeper 在 Kafka 和 Dubbo 中的工业级实现案例分析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/24 ZooKeeper 在 Kafka 和 Dubbo 中的工业级实现案例分析.md.html
@@ -204,17 +204,17 @@ function hide_canvas() {
其中,远程方法调用是 Dubbo 最为核心的功能点。因为一个分布式系统是由分布在不同网络区间或节点上的计算机或服务,通过彼此之间的信息传递进行协调工作的系统。因此跨机器或网络区间的通信是实现分布式系统的核心。而 Dubbo 框架可以让我们像调用本地方法一样,调用不同机器或网络服务上的线程方法。
下图展示了整个 Dubbo 服务的连通过程。整个服务的调用过程主要分为服务的消费端和服务的提供方。首先,服务的提供方向 Registry 注册中心注册所能提供的服务信息,接着服务的消费端会向 Registry 注册中心订阅该服务,注册中心再将服务提供者地址列表返回给消费者。如果有变更,注册中心将基于长连接将变更数据推送给消费者,从而通过服务的注册机制实现远程过程调用。
-
+
ZooKeeper 注册中心
通过上面的介绍,我们不难发现在整个 Dubbo 框架的实现过程中,注册中心是其中最为关键的一点,它保证了整个 PRC 过程中服务对外的透明性。而 Dubbo 的注册中心也是通过 ZooKeeper 来实现的。
如下图所示,在整个 Dubbo 服务的启动过程中,服务提供者会在启动时向 /dubbo/com.foo.BarService/providers 目录写入自己的 URL 地址,这个操作可以看作是一个 ZooKeeper 客户端在 ZooKeeper 服务器的数据模型上创建一个数据节点。服务消费者在启动时订阅 /dubbo/com.foo.BarService/providers 目录下的提供者 URL 地址,并向 /dubbo/com.foo.BarService/consumers 目录写入自己的 URL 地址。该操作是通过 ZooKeeper 服务器在 /consumers 节点路径下创建一个子数据节点,然后再在请求会话中发起对 /providers 节点的 watch 监控。
-
+
Kafka 与 ZooKeeper
接下来我们再看一下 ZooKeeper 在另一个开源框架 Kafka 中的应用。Kafka 是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者在网站中的所有动作流数据,经常用来解决大量数据日志的实时收集以及 Web 网站上用户 PV 数统计和访问记录等。我们可以把 Kafka 看作是一个数据的高速公路,利用这条公路,数据可以低延迟、高效地从一个地点到达另一个地点。
-
+
Kafka 实现过程
在介绍 ZooKeeper 在 Kafka 中如何使用之前,我们先来简单地了解一下 Kafka 的一些关键概念,以便之后的学习。如下图所示,整个 Kafka 的系统架构主要由 Broker、Topic、Partition、Producer、Consumer、Consumer Group 这几个核心概念组成,下面我们来分别进行介绍。
-
+
Broker
Kafka 也是一个分布式的系统架构,因此在整个系统中存在多台机器,它将每台机器定义为一个 Broker。
Topic
@@ -228,7 +228,7 @@ function hide_canvas() {
在整个 Kafka 服务的运行过程中,信息首先通过 producer 生产者提交给 Kafka 服务器上的 Topics 消息容器。在消息容器的内部,又会根据当前系统磁盘情况选择对应的物理分区进行存储,而每台服务分区可能对应一台或多台 Broker 服务器,之后 Broker 服务器再将信息推送给 Consumer。
Zookeeper 的作用
介绍完 Kafka 的相关概念和服务运行原理后,接下来我们学习 ZooKeeper 在 Kafka 框架下的应用。在 Kafka 中 ZooKeeper 几乎存在于每个方面,如下图所示,Kafka 会将我们上面介绍的流程架构存储为一个 ZooKeeper 上的数据模型。
-
+
由于 Broker 服务器采用分布式集群的方式工作,那么在服务的运行过程中,难免出现某台机器因异常而关闭的状况。为了保证整个 Kafka 集群的可用性,需要在系统中监控整个机器的运行情况。而 Kafka 可以通过 ZooKeeper 中的数据节点,将网络中机器的运行统计存储在数据模型中的 brokers 节点下。
在 Kafka 的 Topic 信息注册中也需要使用到 ZooKeeper ,在 Kafka 中同一个Topic 消息容器可以分成多个不同片,而这些分区既可以存在于一台 Broker 服务器中,也可以存在于不同的 Broker 服务器中。
而在 Kafka 集群中,每台 Broker 服务器又相对独立。为了能够读取这些以分布式方式存储的分区信息,Kafka 会将这些分区信息在 Broker 服务器中的对应关系存储在 ZooKeeper 数据模型的 topic 节点上,每一个 topic 在 ZooKeeper 数据节点上都会以 /brokers/topics/[topic] 的形式存在。当 Broker 服务器启动的时候,会首先在 /brokers/topics 节点下创建自己的 Broker_id 节点,并将该服务器上的分区数量存储在该数据节点的信息中。之后 ,在系统运行的过程中,通过统计 /brokers/topics 下的节点信息,就能知道对应的 Broker 分区情况。
diff --git a/专栏/ZooKeeper源码分析与实战-完/26 JConsole 与四字母命令:如何监控服务器上 ZooKeeper 的运行状态?.md.html b/专栏/ZooKeeper源码分析与实战-完/26 JConsole 与四字母命令:如何监控服务器上 ZooKeeper 的运行状态?.md.html
index ee9cd8cd..8a47986d 100644
--- a/专栏/ZooKeeper源码分析与实战-完/26 JConsole 与四字母命令:如何监控服务器上 ZooKeeper 的运行状态?.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/26 JConsole 与四字母命令:如何监控服务器上 ZooKeeper 的运行状态?.md.html
@@ -196,7 +196,7 @@ function hide_canvas() {
在上节课中我们学习了在生产环境中,如何部署 ZooKeeper 集群服务。为了我们的程序服务能够持续稳定地对外提供服务,除了在部署的时候尽量采用分布式、集群服务等方式提高 ZooKeeper 服务的可靠性外,在服务上线运行的时候,我们还可以通过对 ZooKeeper 服务的运行状态进行监控,如运行 ZooKeeper 服务的生产服务器的 CPU 、内存、磁盘等使用情况来达到目的。在系统性能达到瓶颈的时候,可以增加服务器资源,以保证服务的稳定性。
JConsole 介绍
通常使用 Java 语言进行开发的技术人员对 JConsole 并不陌生。JConsole 是 JDK 自带的工具,用来监控程序运行的状态信息。如下图所示,我们打开系统的控制终端,输入 JConsole 就会弹出一个这样的监控界面。
-
+
JConsole 使用
介绍完 JConsole 的基本信息后,接下来我们来了解如何利用 JConsole 对远程 ZooKeeper 集群服务进行监控。之所以能够通过 JConsole 连接 ZooKeeper 服务进行监控,是因为 ZooKeeper 支持 JMX(Java Management Extensions),即 Java 管理扩展,它是一个为应用程序、设备、系统等植入管理功能的框架。
JMX 可以跨越一系列异构操作系统平台、系统体系结构和网络传输协议,灵活地开发无缝集成的系统、网络和服务管理应用。我们可以通过 JMX 来访问和管理 ZooKeeper 服务集群。接下来我们就来介绍一下监控 ZooKeeper 集群服务的相关配置操作。
@@ -209,7 +209,7 @@ function hide_canvas() {
连接 ZooKeeper
配置完 JMX 的开启功能后,接下来我们通过系统终端启动 JConsole ,再在弹出的对话框中选择远程连接,然后在远程连接的地址中输入要监控的 ZooKeeper 服务器地址,之后就可以通过 JConsole 监控 ZooKeeper 服务器了。
-
+
四字母命令
除了上面介绍的 JConsole 监控控制台之外,ZooKeeper 还提供了一些命令,可使我们更加灵活地统计监控 ZooKeeper 服务的状态信息。 ZooKeeper 提供的这些命令也叫作四字母命令,如它们的名字一样,每一个命令都是由四个字母组成的。如下代码所示,在操作时,我们会打开系统的控制台,并输入相关的命令来查询 ZooKeeper 服务,比如我们可以输入 stat 命令来查看数据节点等信息。
echo {command} | nc 127.0.0.1 2181
diff --git a/专栏/ZooKeeper源码分析与实战-完/27 crontab 与 PurgeTxnLog:线上系统日志清理的最佳时间和方式.md.html b/专栏/ZooKeeper源码分析与实战-完/27 crontab 与 PurgeTxnLog:线上系统日志清理的最佳时间和方式.md.html
index ce71ffca..53b20e49 100644
--- a/专栏/ZooKeeper源码分析与实战-完/27 crontab 与 PurgeTxnLog:线上系统日志清理的最佳时间和方式.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/27 crontab 与 PurgeTxnLog:线上系统日志清理的最佳时间和方式.md.html
@@ -218,7 +218,7 @@ find /home/home/zk/zk_data/logs/ -name "zookeeper.log.*" -mtime +1 | x
crontab [ -u user ] { -l | -r | -e }
接下来我们打开系统的控制台,并输入 crontab -e 命令,开启定时任务的编辑功能。如下图所示,系统会显示出当前已有的定时任务列表。整个 crontab 界面的操作逻辑和 Vim 相同,为了新建一个定时任务,我们首先将光标移动到文件的最后一行,并敲击 i 键来开启编辑模式。
-
+
这个 crontab 定时脚本由两部分组成,第一部分是定时时间,第二部分是要执行的脚本。如下代码所示,脚本的执行时间是按照 f1 分、 f2 小时、f3 日、f4 月、f5 一个星期中的第几天这种固定顺序格式编写的。
f1 f2 f3 f4 f5 program
@@ -227,7 +227,7 @@ find /home/home/zk/zk_data/logs/ -name "zookeeper.log.*" -mtime +1 | x
查看定时任务
当我们设定完定时任务后,就可以打开控制台,并输入 crontab -l 命令查询系统当前的定时任务。
-
+
到目前为止我们就完成了用 crontab 创建定时任务来自动清理和维护 ZooKeeper 服务产生的相关日志和数据的过程。
crontab 定时脚本的方式相对灵活,可以按照我们的业务需求来设置处理日志的维护方式,比如这里我们希望定期清除 ZooKeeper 服务运行的日志,而不想清除数据快照的文件,则可以通过脚本设置,达到只对数据日志文件进行清理的目的。
PurgeTxnLog
diff --git a/专栏/ZooKeeper源码分析与实战-完/28 彻底掌握二阶段提交三阶段提交算法原理.md.html b/专栏/ZooKeeper源码分析与实战-完/28 彻底掌握二阶段提交三阶段提交算法原理.md.html
index 85ac3fa3..345814b2 100644
--- a/专栏/ZooKeeper源码分析与实战-完/28 彻底掌握二阶段提交三阶段提交算法原理.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/28 彻底掌握二阶段提交三阶段提交算法原理.md.html
@@ -202,7 +202,7 @@ function hide_canvas() {
底层实现
正如算法的名字一样,二阶段提交的底层实现主要分成两个阶段,分别是询问阶段和提交阶段。具体过程如下图所示:
整个集群服务器被分成一台协调服务器,集群中的其他服务器是被协调的服务器。在二阶段算法的询问阶段,分布式集群服务在接收到来自客户端的请求的时候,首先会通过协调者服务器,针对本次请求能否正常执行向集群中参与处理的服务器发起询问请求。集群服务器在接收到请求的时候,会在本地机器上执行会话操作,并记录执行的相关日志信息,最后将结果返回给协调服务器。
-
+
在协调服务器接收到来自集群中其他服务器的反馈信息后,会对信息进行统计。如果集群中的全部机器都能正确执行客户端发送的会话请求,那么协调者服务器就会再次向这些服务器发送提交命令。在集群服务器接收到协调服务器的提交指令后,会根据之前处理该条会话操作的日志记录在本地提交操作,并最终完成数据的修改。
虽然二阶段提交可以有效地保证客户端会话在分布式集群中的事务性,但是该算法自身也有很多问题,主要可以归纳为以下几点:效率问题、单点故障、异常中断。
性能问题
@@ -214,7 +214,7 @@ function hide_canvas() {
由于以上种种问题,在实际操作中,我更推荐使用另一种分布式事务的算法——三阶段提交算法。
三阶段提交
三阶段提交(Three-phase commit)简称 3PC , 其实是在二阶段算法的基础上进行了优化和改进。如下图所示,在整个三阶段提交的过程中,相比二阶段提交,增加了预提交阶段。
-
+
底层实现
预提交阶段
为了保证事务性操作的稳定性,同时避免二阶段提交中因为网络原因造成数据不一致等问题,完成提交准备阶段后,集群中的服务器已经为请求操作做好了准备,协调服务器会向参与的服务器发送预提交请求。集群服务器在接收到预提交请求后,在本地执行事务操作,并将执行结果存储到本地事务日志中,并对该条事务日志进行锁定处理。
diff --git a/专栏/ZooKeeper源码分析与实战-完/29 ZAB 协议算法:崩溃恢复和消息广播.md.html b/专栏/ZooKeeper源码分析与实战-完/29 ZAB 协议算法:崩溃恢复和消息广播.md.html
index 36e9594b..67ebd258 100644
--- a/专栏/ZooKeeper源码分析与实战-完/29 ZAB 协议算法:崩溃恢复和消息广播.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/29 ZAB 协议算法:崩溃恢复和消息广播.md.html
@@ -198,7 +198,7 @@ function hide_canvas() {
ZooKeeper 最核心的作用就是保证分布式系统的数据一致性,而无论是处理来自客户端的会话请求时,还是集群 Leader 节点发生重新选举时,都会产生数据不一致的情况。为了解决这个问题,ZooKeeper 采用了 ZAB 协议算法。
ZAB 协议算法(Zookeeper Atomic Broadcast ,Zookeeper 原子广播协议)是 ZooKeeper 专门设计用来解决集群最终一致性问题的算法,它的两个核心功能点是崩溃恢复和原子广播协议。
在整个 ZAB 协议的底层实现中,ZooKeeper 集群主要采用主从模式的系统架构方式来保证 ZooKeeper 集群系统的一致性。整个实现过程如下图所示,当接收到来自客户端的事务性会话请求后,系统集群采用主服务器来处理该条会话请求,经过主服务器处理的结果会通过网络发送给集群中其他从节点服务器进行数据同步操作。
-
+
以 ZooKeeper 集群为例,这个操作过程可以概括为:当 ZooKeeper 集群接收到来自客户端的事务性的会话请求后,集群中的其他 Follow 角色服务器会将该请求转发给 Leader 角色服务器进行处理。当 Leader 节点服务器在处理完该条会话请求后,会将结果通过操作日志的方式同步给集群中的 Follow 角色服务器。然后 Follow 角色服务器根据接收到的操作日志,在本地执行相关的数据处理操作,最终完成整个 ZooKeeper 集群对客户端会话的处理工作。
崩溃恢复
在介绍完 ZAB 协议在架构层面的实现逻辑后,我们不难看出整个 ZooKeeper 集群处理客户端会话的核心点在一台 Leader 服务器上。所有的业务处理和数据同步操作都要靠 Leader 服务器完成。结合我们在“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 中学习到的二阶段提交知识,会发现就目前介绍的 ZooKeeper 架构方式而言,极易产生单点问题,即当集群中的 Leader 发生故障的时候,整个集群就会因为缺少 Leader 服务器而无法处理来自客户端的事务性的会话请求。因此,为了解决这个问题。在 ZAB 协议中也设置了处理该问题的崩溃恢复机制。
@@ -206,7 +206,7 @@ function hide_canvas() {
投票过程如下:当崩溃恢复机制开始的时候,整个 ZooKeeper 集群的每台 Follow 服务器会发起投票,并同步给集群中的其他 Follow 服务器。在接收到来自集群中的其他 Follow 服务器的投票信息后,集群中的每个 Follow 服务器都会与自身的投票信息进行对比,如果判断新的投票信息更合适,则采用新的投票信息作为自己的投票信息。在集群中的投票信息还没有达到超过半数原则的情况下,再进行新一轮的投票,最终当整个 ZooKeeper 集群中的 Follow 服务器超过半数投出的结果相同的时候,就会产生新的 Leader 服务器。
选票结构
介绍完整个选举 Leader 节点的过程后,我们来看一下整个投票阶段中的投票信息具有怎样的结构。以 Fast Leader Election 选举的实现方式来讲,如下图所示,一个选票的整体结果可以分为一下六个部分:
-
+
- logicClock:用来记录服务器的投票轮次。logicClock 会从 1 开始计数,每当该台服务经过一轮投票后,logicClock 的数值就会加 1 。
- state:用来标记当前服务器的状态。在 ZooKeeper 集群中一台服务器具有 LOOKING、FOLLOWING、LEADERING、OBSERVING 这四种状态。
@@ -218,12 +218,12 @@ function hide_canvas() {
当 ZooKeeper 集群需要重新选举出新的 Leader 服务器的时候,就会根据上面介绍的投票信息内容进行对比,以找出最适合的服务器。
选票筛选
接下来我们再来看一下,当一台 Follow 服务器接收到网络中的其他 Follow 服务器的投票信息后,是如何进行对比来更新自己的投票信息的。Follow 服务器进行选票对比的过程,如下图所示。
-
+
首先,会对比 logicClock 服务器的投票轮次,当 logicClock 相同时,表明两张选票处于相同的投票阶段,并进入下一阶段,否则跳过。接下来再对比 vote_zxid 被选举的服务器 ID 信息,若接收到的外部投票信息中的 vote_zxid 字段较大,则将自己的票中的 vote_zxid 与 vote_myid 更新为收到的票中的 vote_zxid 与 vote_myid ,并广播出去。要是对比的结果相同,则继续对比 vote_myid 被选举服务器上所保存的最大事务 ID ,若外部投票的 vote_myid 比较大,则将自己的票中的 vote_myid 更新为收到的票中的 vote_myid 。 经过这些对比和替换后,最终该台 Follow 服务器会产生新的投票信息,并在下一轮的投票中发送到 ZooKeeper 集群中。
消息广播
在 Leader 节点服务器处理请求后,需要通知集群中的其他角色服务器进行数据同步。ZooKeeper 集群采用消息广播的方式发送通知。
ZooKeeper 集群使用原子广播协议进行消息发送,该协议的底层实现过程与我们在“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 的二阶段提交过程非常相似,如下图所示。
-
+
当要在集群中的其他角色服务器进行数据同步的时候,Leader 服务器将该操作过程封装成一个 Proposal 提交事务,并将其发送给集群中其他需要进行数据同步的服务器。当这些服务器接收到 Leader 服务器的数据同步事务后,会将该条事务能否在本地正常执行的结果反馈给 Leader 服务器,Leader 服务器在接收到其他 Follow 服务器的反馈信息后进行统计,判断是否在集群中执行本次事务操作。
这里请大家注意 ,与我们“ 28 | 彻底掌握二阶段提交/三阶段提交算法原理” 中提到的二阶段提交过程不同(即需要集群中所有服务器都反馈可以执行事务操作后,主服务器再次发送 commit 提交请求执行数据变更) ,ZAB 协议算法省去了中断的逻辑,当 ZooKeeper 集群中有超过一般的 Follow 服务器能够正常执行事务操作后,整个 ZooKeeper 集群就可以提交 Proposal 事务了。
总结
diff --git a/专栏/ZooKeeper源码分析与实战-完/30 ZAB 与 Paxos 算法的联系与区别.md.html b/专栏/ZooKeeper源码分析与实战-完/30 ZAB 与 Paxos 算法的联系与区别.md.html
index a4f86317..0534f967 100644
--- a/专栏/ZooKeeper源码分析与实战-完/30 ZAB 与 Paxos 算法的联系与区别.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/30 ZAB 与 Paxos 算法的联系与区别.md.html
@@ -208,10 +208,10 @@ function hide_canvas() {
经过我们之前对 ZooKeeper 的学习,相信对 Paxos 算法的集群角色划分并不陌生。而与 ZAB 协议算法不同的是,在 Paxos 算法中,当处理来自客户端的事务性会话请求的过程时,首先会触发一个或多个服务器进程,就本次会话的处理发起提案。当该提案通过网络发送到集群中的其他角色服务器后,这些服务器会就该会话在本地的执行情况反馈给发起提案的服务器。发起提案的服务器会在接收到这些反馈信息后进行统计,当集群中超过半数的服务器认可该条事务性的客户端会话操作后,认为该客户端会话可以在本地执行操作。
上面介绍的 Paxos 算法针对事务性会话的处理投票过程与 ZAB 协议十分相似,但不同的是,对于采用 ZAB 协议的 ZooKeeper 集群中发起投票的机器,所采用的是在集群中运行的一台 Leader 角色服务器。而 Paxos 算法则采用多副本的处理方式,即存在多个副本,每个副本分别包含提案者、决策者以及学习者。下图演示了三种角色的服务器之间的关系。
-
+
事务处理过程
介绍完 Paxos 算法中的服务器角色和投票的处理过程后,接下来我们再来看一下 Paxos 针对一次提案是如何处理的。如下图所示,整个提案的处理过程可以分为三个阶段,分别是提案准备阶段、事务处理阶段、数据同步阶段。我们分别介绍一下这三个阶段的底层处理逻辑。
-
+
- 提案准备阶段:该阶段是整个 Paxos 算法的最初阶段,所有接收到的来自客户端的事务性会话在执行之前,整个集群中的 Proposer 角色服务器或者节点,需要将会话发送给 Acceptor 决策者服务器。在 Acceptor 服务器接收到该条询问信息后,需要返回 Promise ,承诺可以执行操作信息给 Proposer 角色服务器。
- 事务处理阶段:在经过提案准备阶段,确认该条事务性的会话操作可以在集群中正常执行后,Proposer 提案服务器会再次向 Acceptor 决策者服务器发送 propose 提交请求。Acceptor 决策者服务器在接收到该 propose 请求后,在本地执行该条事务性的会话操作。
diff --git a/专栏/ZooKeeper源码分析与实战-完/31 ZooKeeper 中二阶段提交算法的实现分析.md.html b/专栏/ZooKeeper源码分析与实战-完/31 ZooKeeper 中二阶段提交算法的实现分析.md.html
index 33ed2d99..626cae66 100644
--- a/专栏/ZooKeeper源码分析与实战-完/31 ZooKeeper 中二阶段提交算法的实现分析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/31 ZooKeeper 中二阶段提交算法的实现分析.md.html
@@ -197,7 +197,7 @@ function hide_canvas() {
在学习 ZAB 协议和 Paxos 算法的过程中,我们曾提到在处理来自客户端的事务性请求时,为了保证整个集群的数据一致性,其各自的底层实现与二阶段算法都有相似之处。但我们知道,二阶段提交算法自身有一些缺点,比如容易发生单点故障,比如在并发性能上有一些瓶颈,那么今天就深入 ZooKeeper 的底层,来看一下 ZooKeeper 是如何克服这些问题,并实现自己特有的二阶段提交算法的。希望通过本节课的学习,帮助你进一步提高解决分布式一致性问题的能力。
提交请求
前面我们学到,二阶段提交的本质是协调和处理 ZooKeeper 集群中的服务器,使它们在处理事务性会话请求的过程中能保证数据一致性。如果把执行在 ZooKeeper 集群中各个服务器上的事务会话处理操作分别看作不同的函数,那么整个一致性的处理逻辑就相当于包裹这些函数的事务。而在单机环境中处理事务的逻辑是,包含在事务中的所有函数要么全部成功执行,要么全部都不执行。
-
+
不同的是,在分布式环境中,处理事务请求的各个函数是分布在不同的网络服务器上的线程,无法像在单机环境下一样,做到当事务中的某一个环节发生异常的时候,回滚包裹在整个事务中的操作。因此,分布式环境中处理事务操作的时候,一般的算法不会要求全部集群中的机器都成功执行操作,如果有其中一个函数执行异常,那么整个事务就会把所有函数的执行结果回滚到执行前的状态,也就是无论是正确执行的函数,还是执行异常的函数,各自所做的对数据和程序状态的变更都将被删除。
执行请求
看完提交请求的处理过程后,我们再来看一下在执行请求时 ZooKeeper 的底层实现过程。
diff --git a/专栏/ZooKeeper源码分析与实战-完/32 ZooKeeper 数据存储底层实现解析.md.html b/专栏/ZooKeeper源码分析与实战-完/32 ZooKeeper 数据存储底层实现解析.md.html
index fafff36b..66fff5f3 100644
--- a/专栏/ZooKeeper源码分析与实战-完/32 ZooKeeper 数据存储底层实现解析.md.html
+++ b/专栏/ZooKeeper源码分析与实战-完/32 ZooKeeper 数据存储底层实现解析.md.html
@@ -204,7 +204,7 @@ function hide_canvas() {
我们先来看一下 ,ZooKeeper 是如何搜集程序的运行信息的。在统计操作情况的日志信息中,ZooKeeper 通过第三方开源日志服务框架 SLF4J 来实现的。
SLF4J 是一个采用门面设计模式(Facade) 的日志框架。如下图所示,门面模式也叫作外观模式,采用这种设计模式的主要作用是,对外隐藏系统内部的复杂性,并向外部调用的客户端或程序提供统一的接口。门面模式通常以接口的方式实现,可以被程序中的方法引用。
在下图中,我们用门面模式创建了一个绘制几何图形的小功能。首先,定义了一个 Shape 接口类,并分别创建了三个类 Circle、Square、Rectangle ,以继承 Shape 接口。其次,我们再来创建一个画笔类 ShapeMaker ,在该类中我定义了 shape 形状字段以及绘画函数 drawCircle等。
-
+
之后,当我们在本地项目中需要调用实现的会话功能时,直接调用 ShapeMaker 类,并传入我们要绘制的图形信息,就可以实现图形的绘制功能了。它使用起来非常简单,不必关心其底层是如何实现绘制操作的,只要将我们需要绘制的图形信息传入到接口函数中即可。
而在 ZooKeeper 中使用 SLF4J 日志框架也同样简单,如下面的代码所示,首先在类中通过工厂函数创建日志工具类 LOG,然后在需要搜集的操作流程处引入日志搜集函数 LOG.info 即可。
protected static final Logger LOG = LoggerFactory.getLogger(Learner.class);
@@ -214,7 +214,7 @@ LOG.warn("Couldn't find the leader with id = "
存储日志
接下来我们看一下搜集完的日志是什么样子的。在开头我们已经说过,系统日志的存放位置,在 zoo.cfg 文件中。假设我们的日志路径为dataDir=/var/lib/zookeeper,打开系统命令行,进入到该文件夹,就会看到如下图所示的样子,所有系统日志文件都放在了该文件夹下。
-
+
快照文件
除了上面介绍的记录系统操作日志的文件外,ZooKeeper 中另一种十分重要的文件数据是快照日志文件。快照日志文件主要用来存储 ZooKeeper 服务中的事务性操作日志,并通过数据快照文件实现集群之间服务器的数据同步功能。
快照创建
@@ -232,7 +232,7 @@ LOG.warn("Couldn't find the leader with id = "
快照存储
创建完 ZooKeeper 服务的数据快照文件后,接下来就要对数据文件进行持久化的存储操作了。其实在整个 ZooKeeper 中,随着服务的不同阶段变化,数据快照存放文件的位置也随之变化。存储位置的变化,主要是内存和本地磁盘之间的转变。当 ZooKeeper 集群处理来自客户端的事务性的会话请求的时候,会首先在服务器内存中针对本次会话生成数据快照。当整个集群可以执行该条事务会话请求后,提交该请求操作,就会将数据快照持久化到本地磁盘中,如下图所示。
-
+
存储到本地磁盘中的数据快照文件,是经过 ZooKeeper 序列化后的二进制格式文件,通常我们无法直接查看,但如果想要查看,也可以通过 ZooKeeper 自带的 SnapshotFormatter 类来实现。如下图所示,在 SnapshotFormatter 类的内部用来查看快照文件的几种函数分别是: printDetails 函数,用来打印日志中的数据节点和 Session 会话信息;printZnodeDetails 函数,用来查看日志文件中节点的详细信息,包括节点 id 编码、state 状态信息、version 节点版本信息等。
public class SnapshotFormatter {
private void printDetails(DataTree dataTree, Map<Long, Integer> sessions)
diff --git a/专栏/分布式中间件实践之路(完)/02 走进分布式中间件(课前必读).md.html b/专栏/分布式中间件实践之路(完)/02 走进分布式中间件(课前必读).md.html
index 0f87a784..77baf9ba 100644
--- a/专栏/分布式中间件实践之路(完)/02 走进分布式中间件(课前必读).md.html
+++ b/专栏/分布式中间件实践之路(完)/02 走进分布式中间件(课前必读).md.html
@@ -172,7 +172,7 @@ function hide_canvas() {
Availability = MTBF / (MTBF + MTTR)*100%
其中,MTBF(Mean Time Between Failure)是指相邻两次故障之间的平均工作时间,MTTR(Mean Time To Repair)是指系统由故障状态转为工作状态所需修复时间的平均值。通常,用 N 个9来表征系统可用性,比如99.9%(3-nines Availability),99.999%(5-nines Availability)。
-
+
图片出自:CSDN 博客
2.4 可靠性
与可用性不同,可靠性是指在给定的时间间隔和给定条件下,系统能正确执行其功能的概率。可靠性的量化指标是周期内系统平均无故障运行时间,可用性的量化指标是周期内系统无故障运行的总时间。这种“官方定义”比较晦涩,下面举一个简单的例子。
@@ -299,15 +299,15 @@ str.toUpperCase();//指令3
支持异步通信协议,消息的发送者将消息发送到消息队列后可以立即返回,不用等待接收者的响应。消息会被保存在队列中,直到被接收者取出。消息的发送与处理是完全异步的。下面通过一个例子来说明。
对于大多数应用,在用户注册后,都需要发注册邮件和注册短信。传统的做法有两种:
1. 串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端,如下图示:
-
+
2. 并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的效率。
-
+
接下来,我们引入消息队列,来实现异步处理。
将注册信息写入数据库成功后,将消息写入消息队列,然后立即返回成功;此后,邮件系统和短信系统分别从消息队列中获取注册信息,再发送注册邮件和短信。很明显,借助消息队列的异步处理能力,将极大的提高响应速度。
-
+
应用解耦
以电商 IT 架构为例,在传统紧耦合订单场景里,客户在电商网站下订单,订单系统接收到请求后,立即调用库存系统接口,库存减一,如下图所示:
-
+
上述模式存在巨大风险:
- 假如库存系统无法访问(升级、业务变更、故障等),则订单减库存将失败,从而导致订单失败;
@@ -316,7 +316,7 @@ str.toUpperCase();//指令3
我们引入消息队列,解除强耦合性,处理流程又会怎样呢?
订单系统中,用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功,此时客户可以认为下单成功。消息队列提供异步的通信协议,消息的发送者将消息发送到消息队列后可以立即返回,不用等待接收者的响应。消息会被保存在队列中,直到被接收者取出。
库存系统中,从消息队列中获取下单信息,库存系统根据下单信息进行库存操作。
-
+
流量削锋
像双11秒杀、预约抢购等活动,通常会出现流量暴增,当外部请求超过系统处理能力时,如果系统没有做相应保护,可能因不堪重负而挂掉。
这时,我们可以引入消息队列,缓解短时间内高流量压力:
@@ -324,7 +324,7 @@ str.toUpperCase();//指令3
- 用户的秒杀请求,服务器接收后,首先写入消息队列,然后返回成功。假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到失败页面;
- 秒杀业务根据消息队列中的请求信息,再做后续处理(根据数据库实际的select、insert、update 能力处理注册、预约申请)。
-
+
消息通讯
消息通讯很好理解,以微信群聊为例:
diff --git a/专栏/分布式中间件实践之路(完)/03 主流分布式缓存方案的解读及比较.md.html b/专栏/分布式中间件实践之路(完)/03 主流分布式缓存方案的解读及比较.md.html
index 59e64350..e6f1c6fb 100644
--- a/专栏/分布式中间件实践之路(完)/03 主流分布式缓存方案的解读及比较.md.html
+++ b/专栏/分布式中间件实践之路(完)/03 主流分布式缓存方案的解读及比较.md.html
@@ -188,17 +188,17 @@ function hide_canvas() {
Redis 的内存模型比较复杂,内容也较多,感兴趣的读者可以查阅《深入了解 Redis 的内存模型》博客做更深了解。
Redis 开源客户端
Redis 的开源客户端众多,几乎支持所有编程语言,如下图所示。其中常用的 Java 客户端有 Jedis、Lettuce 以及 Redission。
-
+
Redis 支持事务
Redis 提供了一些在一定程度上支持线程安全和事务的命令,例如 multi/exec、watch、inc 等。由于 Redis 服务器是单线程的,任何单一请求的服务器操作命令都是原子的,但跨客户端的操作并不保证原子性,所以对于同一个连接的多个操作序列也不保证事务。
1.3 Redis 高可用解决方案
Redis 有很多高可用的解决方案,本节只简单介绍其中三种。
方案1:Redis Cluster
从3.0版本开始,Redis 支持集群模式——Redis Cluster,可线性扩展到1000个节点。Redis-Cluster 采用无中心架构,每个节点都保存数据和整个集群状态,每个节点都和其它所有节点连接,客户端直连 Redis 服务,免去了 Proxy 代理的损耗。Redis Cluster 最小集群需要三个主节点,为了保障可用性,每个主节点至少挂一个从节点(当主节点故障后,对应的从节点可以代替它继续工作),三主三从的 Redis Cluster 架构如下图所示:
-
+
方案2:Twemproxy
Twemproxy 是一个使用 C 语言编写、以代理的方式实现的、轻量级的 Redis 代理服务器。它通过引入一个代理层,将应用程序后端的多台 Redis 实例进行统一管理,使应用程序只需要在 Twemproxy 上进行操作,而不用关心后面具体有多少个真实的 Redis 实例,从而实现了基于 Redis 的集群服务。当某个节点宕掉时,Twemproxy 可以自动将它从集群中剔除,而当它恢复服务时,Twemproxy 也会自动连接。由于是代理,Twemproxy 会有微小的性能损失。
-Twemproxy 架构如下图所示: 
+Twemproxy 架构如下图所示: 
方案3:Codis
Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server 没有明显的区别(部分命令不支持), 上层应用可以像使用单机的 Redis 一样使用,Codis 底层会处理请求的转发,不停机的数据迁移等工作。关于 Codis,在第03课中将详细介绍。
2. Memcached 介绍
diff --git a/专栏/分布式中间件实践之路(完)/04 分布式一致性协议 Gossip 和 Redis 集群原理解析.md.html b/专栏/分布式中间件实践之路(完)/04 分布式一致性协议 Gossip 和 Redis 集群原理解析.md.html
index de2fe0f7..60145699 100644
--- a/专栏/分布式中间件实践之路(完)/04 分布式一致性协议 Gossip 和 Redis 集群原理解析.md.html
+++ b/专栏/分布式中间件实践之路(完)/04 分布式一致性协议 Gossip 和 Redis 集群原理解析.md.html
@@ -136,11 +136,11 @@ function hide_canvas() {
Redis 是一个开源的、高性能的 Key-Value 数据库。基于 Redis 的分布式缓存已经有很多成功的商业应用,其中就包括阿里 ApsaraDB,阿里 Tair 中的 RDB 引擎,美团 MOS 以及腾讯云 CRS。本文我将着重介绍 Redis Cluster 原理、类 Codis 分布式方案以及分布式信息一致性协议 Gossip,以帮助大家深入理解 Redis。
1. Redis 单机模式
顾名思义,单机模式指 Redis 主节点以单个节点的形式存在,这个主节点可读可写,上面存储数据全集。在3.0版本之前,Redis 只能支持单机模式,出于可靠性考量,通常单机模式为“1主 N 备”的结构,如下所示:
-
+
需要说明的是,即便有很多个 Redis 主节点,只要这些主节点以单机模式存在,本质上仍为单机模式。单机模式比较简单,足以支撑一般应用场景,但单机模式具有固有的局限性:不支持自动故障转移,扩容能力极为有限(只能 Scale Up,垂直扩容),存在高并发瓶颈。
1.1 不支持自动故障转移
Redis 单机模式下,即便是“1主 N 备”结构,当主节点故障时,备节点也无法自动升主,即无法自动故障转移(Failover)。故障转移需要“哨兵”Sentinel 辅助,Sentinel 是 Redis 高可用的解决方案,由一个或者多个 Sentinel 实例组成的系统可以监视 Redis 主节点及其从节点,当检测到 Redis 主节点下线时,会根据特定的选举规则从该主节点对应的所有从节点中选举出一个“最优”的从节点升主,然后由升主的新主节点处理请求。具有 Sentinel 系统的单机模式示意图如下:
-
+
1.2 扩容能力极为有限
这一点应该很好理解,单机模式下,只有主节点能够写入数据,那么,最大数据容量就取决于主节点所在物理机的内存容量,而物理机的内存扩容(Scale Up)能力目前仍是极为有限的。
1.3 高并发瓶颈
@@ -163,14 +163,14 @@ function hide_canvas() {
2.2 Redis-Cluster 实现基础:分片
Redis 集群实现的基础是分片,即将数据集有机的分割为多个片,并将这些分片指派给多个 Redis 实例,每个实例只保存总数据集的一个子集。利用多台计算机内存和来支持更大的数据库,而避免受限于单机的内存容量;通过多核计算机集群,可有效扩展计算能力;通过多台计算机和网络适配器,允许我们扩展网络带宽。
基于“分片”的思想,Redis 提出了 Hash Slot。Redis Cluster 把所有的物理节点映射到预先分好的16384个 Slot 上,当需要在 Redis 集群中放置一个 Key-Value 时,根据 CRC16(key) Mod 16384的值,决定将一个 Key 放到哪个 Slot 中。
-
+
2.3 Redis Cluster 请求路由方式
客户端直连 Redis 服务,进行读写操作时,Key 对应的 Slot 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点。如下图所示,我们直接登录 127.0.0.1:6379
客户端,进行 Set 操作,当 Key 对应的 Slot 不在当前节点时(如 key-test),客户端会报错并返回正确节点的 IP 和端口。Set 成功则返回 OK。
-
+
以集群模式登录 127.0.0.1:6379
客户端(注意命令的差别:-c
表示集群模式),则可以清楚的看到“重定向”的信息,并且客户端也发生了切换:“6379” -> “6381”。
-
+
以三节点为例,上述操作的路由查询流程示意图如下所示:
-
+
和普通的查询路由相比,Redis Cluster 借助客户端实现的请求路由是一种混合形式的查询路由,它并非从一个 Redis 节点到另外一个 Redis,而是借助客户端转发到正确的节点。
实际应用中,可以在客户端缓存 Slot 与 Redis 节点的映射关系,当接收到 MOVED 响应时修改缓存中的映射关系。如此,基于保存的映射关系,请求时会直接发送到正确的节点上,从而减少一次交互,提升效率。
目前,包括 Lettuce、Jedis、Redission 在内的许多 Redis Client,都已经实现了对 Redis Cluster 的支持,关于客户端的内容,将在第05课中详细介绍。
@@ -287,9 +287,9 @@ function hide_canvas() {
4.2 如何保证消息传播的效率?
前面已经提到,集群的周期性函数 clusterCron()
执行周期是 100ms,为了保证传播效率,每10个周期,也就是 1s,每个节点都会随机选择5个其它节点,并从中选择一个最久没有通信的节点发送 ing消息,源码如下:
-
+
当然,这样还是没法保证效率,毕竟5个节点是随机选出来的,其中最久没有通信的节点不一定是全局“最久”。因此,对哪些长时间没有“被” 随机到的节点进行特殊照顾:每个周期(100ms)内扫描一次本地节点列表,如果发现节点最近一次接受 Pong 消息的时间大于 cluster_node_timeout/2
,则立刻发送 Ping 消息,防止该节点信息太长时间未更新。源码如下:
-
+
4.3 规模效应——无法忽略的成本问题
关键参数 cluster_node_timeout
从上面的分析可以看出,cluster_node_timeout
参数对消息发送的节点数量影响非常大。当带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。但是,过度调大 cluster_node_timeout
会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度,因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
@@ -360,7 +360,7 @@ function hide_canvas() {
hash(key)%N = 目标节点编号, 其中 N 为 Redis 主节点的数量,哈希取余的方式会将不同的 Key 分发到不同的 Redis 主节点上。
-
+
但是,Hash 算法有很多缺陷:
- 不支持动态增加节点:当业务量增加,需要增加服务器节点后,上面的计算公式变为:
hash(key)%(N+1)
,那么,对于同一个 Key-Value,增加节点前后,对应的 Redis 节点可能是完全不同的,可能导致大量之前存储的数据失效;为了解决这个问题,需要将所有数据重新计算 Hash 值,再写入 Redis 服务器。
@@ -372,12 +372,12 @@ function hide_canvas() {
为了克服客户端分片业务逻辑与数据存储逻辑耦合的不足,可以通过 Proxy 将业务逻辑和存储逻辑隔离。客户端发送请求到一个代理,代理解析客户端的数据,将请求转发至正确的节点,然后将结果回复给客户端。这种架构还有一个优点就是可以把 Proxy 当成一个中间件,在这个中间件上可以做很多事情,比如可以把集群和主从的兼容性做到几乎一致,可以做无缝扩减容、安全策略等。
基于代理的分片已经有很多成熟的方案,如开源的 Codis,阿里云的 ApsaraDB for Redis/ApsaraCache,腾讯的 CRS 等。很多大企业也在采用 Proxy+Redis-Server 的架构。
基本原理如下图所示:
-
+
我们来了解下代理分片的缺点。没有完美的架构,由于使用了 Proxy,带宽和 CPU 基本都要加倍,对资源的消耗会大很多。
7.2 Codis 架构
Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server 没有明显的区别 (参考6.1中的代理分片模式),客户端可以像使用单机 Redis 一样使用。
架构图如下:
-
+
Codis 简介
从 Codis 的官方架构图可以看出,Codis 主要由四部分组成:
@@ -399,7 +399,7 @@ function hide_canvas() {
7.3 类 Codis 架构:Proxy + Redis-Server
在上面曾提到,实现 Redis 分布式的基础是分片。目前,主流的分片方案有三种,即 Redis Cluster、客户端分片、代理分片。除了官方推出的 Redis Cluster,大多数 IT 公司采用的都是基于代理的分片模式,即:Proxy + Redis-Server,这与 Codis 的原理类似,因此也称为“类 Codis”架构,其架构图如下:
-
+
该架构有以下特点:
- 分片算法:基于代理的分片原理,将物理节点映射到 Slot(Codis Slot 数为1024,其它方案一般为16384),对 Key-Value 进行读写操作时,采用一致性 Hash 算法或其它算法(如 Redis Cluster采用的 CRC16),计算 Key 对应的 Slot 编号,根据 Slot 编号转发到对应的物理节点;
diff --git a/专栏/分布式中间件实践之路(完)/05 基于 Redis 的分布式缓存实现及加固策略.md.html b/专栏/分布式中间件实践之路(完)/05 基于 Redis 的分布式缓存实现及加固策略.md.html
index 71821395..a037fe06 100644
--- a/专栏/分布式中间件实践之路(完)/05 基于 Redis 的分布式缓存实现及加固策略.md.html
+++ b/专栏/分布式中间件实践之路(完)/05 基于 Redis 的分布式缓存实现及加固策略.md.html
@@ -138,11 +138,11 @@ function hide_canvas() {
本节将介绍基于 Redis 和 Lettuce 搭建一个分布式缓存集群的方法。为了生动地呈现集群创建过程,我没有采用 Redis 集群管理工具 redis-trib,而是基于 Lettuce 编写 Java 代码实现集群的创建,相信,这将有利于读者更加深刻地理解 Redis 集群模式。
1.1 方案简述
Redis 集群模式至少需要三个主节点,作为举例,本文搭建一个3主3备的精简集群,麻雀虽小,五脏俱全。主备关系如下图所示,其中 M 代表 Master 节点,S 代表 Slave 节点,A-M 和 A-S 为一对主备节点。
-
+
按照上图所示的拓扑结构,如果节点 1 故障下线,那么节点 2 上的 A-S 将升主为 A-M,Redis 3 节点集群仍可用,如下图所示:
-
+
特别说明:事实上,Redis 集群节点间是两两互通的,如下图所示,上面作为示意图,进行了适当简化。
-
+
1.2 资源准备
首先,下载 Redis 包。前往 Redis 官网下载 Redis 资源包,本文采用的 Redis 版本为 4.0.8。
接着,将下载的 Redis 资源包 redis-4.0.8.tar.gz
放到自定义目录下,解压,编译便可生成 Redis 服务端和本地客户端 bin 文件 redis-server
和 redis-cli
,具体操作命令如下:
@@ -157,21 +157,21 @@ make
根据端口号分别创建名为 6379、6380、6381、6382、6383、6384 的文件夹。
(2)修改配置文件
在解压文件夹 redis-4.0.8
中有一个 Redis 配置文件 redis.conf
,其中一些默认的配置项需要修改(配置项较多,本文仅为举例,修改一些必要的配置)。以下仅以 6379 端口为例进行配置,6380、6381等端口配置操作类似。将修改后的配置文件分别放入 6379~6384 文件夹中。
-
+
(3)创建必要启停脚本
逐一手动拉起 Redis 进程较为麻烦,在此,我们可以编写简单的启停脚本完成 redis-server
进程的启停(start.sh
和 stop.sh
)。
-
+
(4)简单测试
至此,我们已经完成 Redis 集群创建的前期准备工作,在创建集群之前,我们可以简单测试一下,redis-sever
进程是否可以正常拉起。运行 start.sh
脚本,查看 redis-server
进程如下:
-
+
登录其中一个 Redis 实例的客户端(以 6379 为例),查看集群状态:很明显,以节点 6379 的视角来看,集群处于 Fail 状态,clusterknownnodes:1
表示集群中只有一个节点。
-
+
2. 基于 Lettuce 创建 Redis 集群
关于创建 Redis 集群,官方提供了一个 Ruby 编写的运维软件 redis-trib.rb
,使用简单的命令便可以完成创建集群、添加节点、负载均衡等操作。正因为简单,用户很难通过黑盒表现理解其中细节,鉴于此,本节将基于 Lettuce 编写创建 Redis 集群的代码,让读者对 Redis 集群创建有一个更深入的理解。
Redis 发展至今,其对应的开源客户端几乎涵盖所有语言,详情请见官网,本节采用 Java 语言开发的 Lettuce 作为 Redis 客户端。Lettuce 是一个可伸缩线程安全的 Redis 客户端,多个线程可以共享同一个 RedisConnection。它采用优秀 Netty NIO 框架来高效地管理多个连接。关于 Lettuce 的详情,后面章节中会详细介绍。
-
+
2.1 Redis 集群创建的步骤
(1)相互感知,初步形成集群。
在上文中,我们已经成功拉起了 6 个 redis-server
进程,每个进程视为一个节点,这些节点仍处于孤立状态,它们相互之间无法感知对方的存在,既然要创建集群,首先需要让这些孤立的节点相互感知,形成一个集群。
@@ -393,7 +393,7 @@ class ClusterNode
}
运行上述代码创建集群,再次登录其中一个节点的客户端(以 6379 为例),通过命令:cluster nodes
、cluster info
查看集群状态信息如下,集群已经处于可用状态。
-
+
2.3 测试验证
经过上述步骤,一个可用的 Redis 集群已经创建完毕,接下来,通过一段代码测试验证:
public static void main(String[] args)
@@ -435,12 +435,12 @@ class ClusterNode
3. Redis SSL 双向认证通信实现
3.1 Redis 自带的鉴权访问模式
默认情况下,Redis 服务端是不允许远程访问的,打开其配置文件 redis.conf
,可以看到如下配置:
-
+
根据说明,如果我们要远程访问,可以手动改变 protected-mode
配置,将 yes 状态置为 no 即可,也可在本地客服端 redis-cli
,键入命令:config set protected-mode no
。但是,这明显不是一个好的方法,去除保护机制,意味着严重安全风险。
鉴于此,我们可以采用鉴权机制,通过秘钥来鉴权访问,修改 redis.conf
,添加 requirepass mypassword
,或者键入命令:config set requirepass password
设置鉴权密码。
-
+
设置密码后,Lettuce 客户端访问 redis-server
就需要鉴权,增加一行代码即可,以单机模式为例:
-
+
补充
除了通过密码鉴权访问,出于安全的考量,Redis 还提供了一些其它的策略:
@@ -463,7 +463,7 @@ rename-command EVAL "user-defined"
通过上面的介绍,相信读者已经对 Redis 自带的加固策略有了一定了解。客观地讲,Redis 自带的安全策略很难满足对安全性要求普遍较高的商用场景,鉴于此,有必要优化。就 Client-Server
模式而言,成熟的安全策略有很多,本文仅介绍其一:SSL 双向认证通信。关于 SSL 双向认证通信的原理和具体实现方式,网上有大量的博文可供参考,并非本文重点,因此不做详细介绍。
总体流程
我们首先看下 SSL 双向认证通信的总体流程,如下图所示:
-
+
首先,Client 需要将 Server 的根证书 ca.crt
安装到自己的信任证书库中;同时,Server 也需要将根证书 ca.crt
安装到自己的信任证书库中。
接着,当 SSL 握手时,Server 先将服务器证书 server.p12
发给 Client,Client 收到后,到自己的信任证书库中进行验证,由于 server.p12
是根证书 CA 颁发的,所以验证必然通过。
然后,Client 将客户端证书 client.p12
发给 Server,同理, client.p12
是根证书 CA 颁发的,所以验证也将通过。
@@ -476,17 +476,17 @@ rename-command EVAL "user-defined"
Redis 本身不支持 SSL 双向认证通信,因此,需要修改源码,且涉及修改较多,本文仅列出要点,具体实现层面代码不列。
config.c
SSL 双向认证通信涉及的 keyStore 和 trustStore 密码密文、路径等信息可由 Redis 的配置文件 redis.conf
提供,如此,我们需要修改加载配置文件的源码(config.c->loadServerConfigFromString(char *config)),部分修改如下:
-
+
redis.h
Redis 的客户端(redisClient)和服务端(redisServer)都需要适配,部分代码如下:
-
-
+
+
hiredis.h
修改创建连接的原函数:
-
+
anet.h
定义 SSL 通信涉及的一些函数(实现在 anet.c 中):
-
+
- 客户端
@@ -524,10 +524,10 @@ cmd.get("key");
4. Redis 集群可靠性问题
为了便于理解(同时也为了规避安全违规风险),我将原方案进行了适度简化,以 3 主 3 备 Redis 集群为例阐述方案(redis-cluster
模式最少需要三个主节点),如下图所示,其中 A-M 表示主节点 A,A-S 表示主节点 A 对应的从节点,以此类推。
-
+
4.1 可靠性问题一
Redis 集群并不是将 redis-server
进程启动便可自行建立的。在各个节点启动 redis-server
进程后,形成的只是 6 个“孤立”的 Redis 节点而已,它们相互不知道对方的存在,拓扑结构如下:
-
+
查看每个 Redis 节点的集群配置文件 cluster-config-file
,你将看到类似以下内容:
2eca4324c9ee6ac49734e2c1b1f0ce9e74159796 192.168.1.3:6379 myself,master - 0 0 0 connected
vars currentEpoch 0 lastVoteEpoch 0
@@ -541,21 +541,21 @@ vars currentEpoch 0 lastVoteEpoch 0
使用 redis-trib.rb
建立集群虽然便捷,不过,由于 Ruby 语言本身的一系列安全缺陷,有些时候并不是明智的选择。考虑到 Lettuce 提供了极为丰富的 Redis 高级功能,我们完全可以使用 Lettuce 来创建集群,这一点在上一节已经介绍过。
4.2 节点故障
三个物理节点,分别部署两个 redis-server
,且交叉互为主备,这样做可以提高可靠性:如节点 1 宕机,主节点 A-M 对应的从节点 A-S 将发起投票,作为唯一的备节点,其必然升主成功,与 B-M、C-M 构成新的集群,继续提供服务,如下图所示:
-
+
4.3 故障节点恢复
接续上一节,如果宕机的节点 1 经过修复重新上线,根据 Redis 集群原理,节点 1 上的 A-M 将意识到自己已经被替代,将降级为备,形成的集群拓扑结构如下:
-
+
4.4 可靠性问题二
基于上述拓扑结构,如果节点 3 宕机,Redis 集群将只有一个主节点 C-M 存活,存活的主节点总数少于集群主节点总数的一半 (1<3/2+1
),集群无法自愈,不能继续提供服务。
为了解决这个问题,我们可以设计一个常驻守护进程对 Redis 集群的状态进行监控,当出现主-备状态不合理的情况(如节点 1 重新上线后的拓扑结构),守护进程主动发起主备倒换(clusterFailover),将节点 1 上的 A-S 升为主,节点 3 上的 A-M 降为备,如此,集群拓扑结构恢复正常,并且能够支持单节点故障。
-
+
注: Lettuce 提供了主备倒换的方法,示例代码如下:
// slaveConn为Lettuce与从节点建立的连接
slaveConn.sync().clusterFailover(true)
4.5 可靠性问题三
接续 4.1 节,如果节点 1 故障后无法修复,为了保障可靠性,通常会用一个新的节点来替换掉故障的节点——所谓故障替换。拓扑结构如下:
-
+
新的节点上面部署两个 redis-server
进程,由于是新建节点,redis-server
进程对应的集群配置文件 cluster-config-file
中只包含自身的信息,并没有整个集群的信息,简言之,新建的节点上的两个 redis-server
进程是“孤立”的。
为了重新组成集群,我们需要两个步骤:
@@ -563,7 +563,7 @@ slaveConn.sync().clusterFailover(true)
- 为新加入集群的两个
redis-server
设置主节点:节点 3 上的两个主 A-M 和 B-M 都没有对应的从节点,因此,可将新加入的两个 redis-server
分别设置为它们的从节点。
完成上述两个步骤后,Redis 集群的拓扑结构将演变成如下形态:
-
+
很明显,变成了问题一的形态,继续通过问题一的解决方案便可修复。
4.6 其它
上面仅介绍了几个较为常见的问题,在实际使用 Redis 的过程中可能遇到的问题远不止这些。在第 05 课中,我将介绍一些更为复杂的异常场景。
@@ -571,19 +571,19 @@ slaveConn.sync().clusterFailover(true)
不同的应用场景,关注的问题、可能出现的异常不尽相同,上文中介绍的问题仅仅是一种商业应用场景中遇到的。为了解决上述问题,可基于 Lettuce 设计一个常驻守护进程,实现集群创建、添加节点、平衡主备节点分布、集群运行状态监测、故障自检及故障自愈等功能。
5.1 总体流程图
下面是精简后的流程图:
-
+
流程图中,ETCD 选主部分需要特别说明一下,ETCD 和 ZooKeeper 类似,可提供 Leader 选举功能。Redis 集群模式下,在各个 Redis 进程所在主机上均启动一个常驻守护进程,以提高可靠性,但是,为了避免冲突,只有被 ETCD 选举为 Leader 的节点上的常驻守护进程可以执行 “守护” 流程,其它主机上的守护进程呈 “休眠” 状态。关于 Leader 选举的实现,方式很多,本文仅以 ETCD 为例。
5.2 实现
集群状态检测
读者应该知道,Redis 集群中每个节点都保存有集群所有节点的状态信息,虽然这些信息可能并不准确。通过状态信息,我们可以判断集群是否存在以及集群的运行状态,基于 Lettuce 提供的方法,简要代码如下:
-
+
上面代码只从一个节点的视角进行了检查,完整的代码将遍历所有节点,从所有节点的视角分别检查。
Redis 集群创建
大家可参考第二节“2. 基于 Lettuce 创建 Redis 集群”中的内容。
替换故障节点
(1)加入新节点
替换上来的新节点本质上是“孤立”的,需要先加入现有集群:通过集群命令 RedisAdvancedClusterCommands 对象调用 clusterMeet()
方法,便可实现:
-
+
(2)为新节点设置主备关系
首先需要明确,当前集群中哪些 Master 没有 Slave,然后,新节点通过 clusterReplicate()
方法成为对应 Master 的 Slave:
slaveConn.sync().clusterReplicate(masterNode);
@@ -591,7 +591,7 @@ slaveConn.sync().clusterFailover(true)
平衡主备节点的分布
(1)状态检测
常驻守护进程通过遍历各个节点获取到的集群状态信息,可以确定某些 Host 上 Master 和 Slave 节点数量不平衡,比如,经过多次故障后,某个 Host 上的 Redis 节点角色全部变成了 Master,不仅影响性能,还会危及可靠性。这个环节的关键点是如何区分 Master 和 Slave,通常我们以是否被指派 Slot 为依据:
-
+
(2)平衡
如何平衡呢,在创建 Redis 集群的时候,开发者需要制定一个合理的集群拓扑结构(或者算法)来指导集群的创建,如本文介绍的 3 主 3 备模式。那么,在平衡的时候,同样可以依据制定的拓扑结构进行恢复。具体操作很简单:调用 Lettuce 提供的 clusterFailover()
方法即可。
参考文献与致谢:
diff --git a/专栏/分布式中间件实践之路(完)/06 Redis 实际应用中的异常场景及其根因分析和解决方案.md.html b/专栏/分布式中间件实践之路(完)/06 Redis 实际应用中的异常场景及其根因分析和解决方案.md.html
index 4eb7716f..d83af978 100644
--- a/专栏/分布式中间件实践之路(完)/06 Redis 实际应用中的异常场景及其根因分析和解决方案.md.html
+++ b/专栏/分布式中间件实践之路(完)/06 Redis 实际应用中的异常场景及其根因分析和解决方案.md.html
@@ -455,9 +455,9 @@ if (!del && server.cluster->slots[slot])
故障点找到了,那么,既然存在故障,为何集群状态显示正常,只有部分读写操作失败呢?有必要解释一下,为了便于阐明问题,我以 3 主 3 备集群为例。
在前面的章节中,已经介绍了 Redis 集群混合路由查询的原理,在此,直接引用原理示意图,客户端与主节点 A 直连,进行读写操作时,Key 对应的 Slot 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点,如下图所示:
-
+
如果 A、C 节点之间通信被阻断,上述混合路由查询自然就不能成功了,如下图所示:
-
+
如上图所示,节点 1 与节点 3 互相不可访问,这种情况下,节点 1 和节点 3 相互认为对方下线,因此会将对方标记为 PFAIL 状态,但由于持有这一观点(认为节点 1、3 下线)的主节点数量少于主节点总数的一半,不会发起故障倒换,集群状态正常。
虽然集群显示状态正常,但存在潜在问题,比如节点 1 上的客户端进行读写操作的 Key 位于节点 3 主节点的 Slot 中,这时进行读写操作,由于互不可达,必然失败。读写操作的目标节点是由 Key 决定的,CRC16 算法计算出 Key 对应的 Slot 编号,根据 Slot 编号确定目标节点。同时,不同的 Key 对应的 Slot 不尽相同,从节点 1 的视角来看,那些匹配节点 2 所属 Slot 位的 Key,读写操作都可以正常进行,而匹配节点 3 所属 Slot 位的 Key 则会报错,这样就解释了为何只有部分读写操作失败。
5.3 解决方案
@@ -480,7 +480,7 @@ if (!del && server.cluster->slots[slot])
- 通过
telnet ip port
命令检测节点间通信情况,发现其中一个主节点与备节点无法联通,进一步定位为交换机故障。
上述故障场景示意图如下:
-
+
故障主节点 A-M 的备节点 A-S 升主需要获得超过半数的主节点投票,故障场景下,存活的两个主节点中,C-M 与备节点 A-S 内部通信被阻断,导致备节点 A-S 只能获得 1 张票,没有超过集群规模的半数(3 节点集群,至少需要 2 张票),从而无法升主,进而导致故障主节点故障倒换失败,集群无法恢复。
6.3 解决方案及改进措施
本节所述故障场景,基于 3 主 3 备的架构,Redis 集群不具备自愈的硬性条件,没有解决方案。不过,如果扩大集群的规模,比如 5 主 5 备,出现同样故障则是可以自愈的。
diff --git a/专栏/分布式中间件实践之路(完)/07 Redis-Cluster 故障倒换调优原理分析.md.html b/专栏/分布式中间件实践之路(完)/07 Redis-Cluster 故障倒换调优原理分析.md.html
index 9820aa1e..b02f7400 100644
--- a/专栏/分布式中间件实践之路(完)/07 Redis-Cluster 故障倒换调优原理分析.md.html
+++ b/专栏/分布式中间件实践之路(完)/07 Redis-Cluster 故障倒换调优原理分析.md.html
@@ -139,14 +139,14 @@ function hide_canvas() {
Redis-Cluster 中出现主节点故障后,检测故障需要经历单节点视角检测、检测信息传播、下线判决三个步骤,下文将结合源码分析。
1.1 单点视角检测
在第 03 课中介绍过,集群中的每个节点都会定期通过集群内部通信总线向集群中的其它节点发送 PING 消息,用于检测对方是否在线。如果接收 PING 消息的节点没有在规定的时间内(cluster_node_timeout
)向发送 PING 消息的节点返回 PONG 消息,那么,发送 PING 消息的节点就会将接收 PING 消息的节点标注为疑似下线状态(Probable Fail,PFAIL)。如下源码:
-
+
需要注意的是,判断 PFAIL 的依据也是参数 cluster_node_timeout
。如果 cluster_node_timeout
设置过大,就会造成故障的主节点不能及时被检测到,集群恢复耗时增加,进而造成集群可用性降低。
1.2 检测信息传播
集群中的各个节点会通过相互发送消息的方式来交换自己掌握的集群中各个节点的状态信息,如在线、疑似下线(PFAIL)、下线(FAIL)。例如,当一个主节点 A 通过消息得知主节点 B 认为主节点 C 疑似下线时,主节点 A 会更新自己保存的集群状态信息,将从 B 获得的下线报告保存起来。
1.3 基于检测信息作下线判决
如果在一个集群里,超过半数的主节点都将某个节点 X 报告为疑似下线 (PFAIL),那么,节点 X 将被标记为下线(FAIL),并广播出去。所有收到这条 FAIL 消息的节点都会立即将节点 X 标记为 FAIL。至此,故障检测完成。
下线判决相关的源码位于 cluster.c
的函数 void markNodeAsFailingIfNeeded(clusterNode *node)
中,如下所示:
-
+
通过源码可以清晰地看出,将一个节点标记为 FAIL 状态,需要满足两个条件:
- 有超过半数的主节点将 Node 标记为 PFAIL 状态;
@@ -164,7 +164,7 @@ function hide_canvas() {
2. Redis-Cluster 选举原理及优化分析
2.1 从节点拉票
基于故障检测信息的传播,集群中所有正常节点都将感知到某个主节点下线(Fail)的信息,当然也包括这个下线主节点的所有从节点。当从节点发现自己复制的主节点的状态为已下线时,从节点就会向集群广播一条请求消息,请求所有收到这条消息并且具有投票权的主节点给自己投票。
-
+
2.2 拉票优先级
严格得讲,从节点在发现其主节点下线时,并不是立即发起故障转移流程而进行“拉票”的,而是要等待一段时间,在未来的某个时间点才发起选举,这个时间点的计算有两种方式。
方式一
@@ -174,17 +174,17 @@ function hide_canvas() {
其中,newRank 和 oldRank 分别表示本次和上一次排名。
注意,如果当前系统时间小于需要等待的时刻,则返回,下一个周期再检查。
源码如下:
-
+
方式二
既然是拉票,就有可能因未能获得半数投票而失败,一轮选举失败后,需要等待一段时间(auth_retry_time
)才能清理标志位,准备下一轮拉票。从节点拉票之前也需要等待,等待时间计算方法如下:
mstime() + 500ms + random()%500ms + rank*1000ms
其中,500 ms 为固定延时,主要为了留出时间,使主节点下线的消息能传播到集群中其它节点,这样集群中的主节点才有可能投票;random()%500ms
表示随机延时,为了避免两个从节点同时开始故障转移流程;rank 表示从节点的排名,排名是指当前从节点在下线主节点的所有从节点中的排名,排名主要是根据复制数据量来定,复制数据量越多,排名越靠前,因此,具有较多复制数据量的从节点可以更早发起故障转移流程,从而更可能成为新的主节点。
源码如下:
-
+
可优化点
上面提到的 auth_retry_time
是一个潜在的可优化点,也是一个必要的注意点,其计算方法如下源码所示:
-
+
从中可以看出,auth_retry_time
的取值为 4*cluster_node_timeout (cluster_node_timeout>1s)
。如果一轮选举没有成功,再次发起投票需要等待 4*cluster_node_timeout
,按照 cluster_node_timeout
默认值为 15 s 计算,再次发起投票需要等待至少一分钟,如果故障的主节点只有一个从节点,则难以保证高可用。
在实际应用中,每个主节点通常设置 1-2 个从节点,为了避免首轮选举失败后的长时间等待,可根据需要修改源码,将 auth_retry_time
的值适当减小,如 10 s 左右。
2.3 主节点投票
@@ -197,7 +197,7 @@ function hide_canvas() {
选举新主节点的算法是基于 Raft 算法的 Leader Election 方法来实现的,关于 Raft 算法,在第07课中将有详细介绍,此处了解即可。
3. Redis-Cluster 的 Failover 原理
所有发起投票的从节点中,只有获得超过半数主节点投票的从节点有资格升级为主节点,并接管故障主节点所负责的 Slots,源码如下:
-
+
主要包括以下几个过程。
(1)身份切换
通过选举晋升的从节点会执行一系列的操作,清除曾经为从的信息,改头换面,成为新的主节点。
@@ -208,10 +208,10 @@ function hide_canvas() {
(4)履行义务
在其位谋其政,新的主节点开始处理自己所负责 Slot 对应的请求,至此,故障转移完成。
上述过程由 cluster.c
中的函数 void clusterFailoverReplaceYourMaster(void)
完成,源码如下所示:
-
+
4. 客户端的优化思路
Redis-Cluster 发生故障后,集群的拓扑结构一定会发生改变,如下图所示:
-
+
一个 3 主 3 从的集群,其中一台服务器因故障而宕机,从而导致该服务器上部署的两个 Redis 实例(一个 Master,一个 Slava)下线,集群的拓扑结构变成了 3 主 1 备。
4.1 客户端如何感知 Redis-Cluster 发生故障?
结合上面介绍的故障场景,思考这样一个问题:当 Redis-Cluster 发生故障,集群拓扑结构变化时,如果客户端没有及时感知到,继续试图对已经故障的节点进行“读写操作”,势必会出现异常,那么,如何应对这种场景呢?
@@ -237,7 +237,7 @@ function hide_canvas() {
基于 4.1 节的分析,相信读者已经可以构想出优化思路。在此,我将以 Redis 的高级 Java 客户端 Lettuce 为例,简单介绍一下客户端的耗时优化。
2017 年,国内某电商巨头的仓储系统出现故障(一台服务器宕机),管理页面登录超时(超过一分钟才登录完成),经过评估,判定为系统性能缺陷,需要优化。通过分解登录耗时,发现缓存访问耗时长达 28 秒,进一步排查确认宕机的服务器上部署有两个 Redis 节点,结合日志分析,发现 Redis-Cluster 故障后(两个 Redis 节点下线),客户端感知故障耗 20 秒,为症结所在。
为了优化耗时,我当时阅读了开源客户端 Lettuce 的源码,原来 Lettuce 的连接超时机制采用的超时时间为 10s,部分源码如下:
-
+
当 Redis-Cluster 故障后,客户端(Lettuce)感知到连接不可用后会分别与故障的 Redis 节点进行重试,而重试的超时时间为 10s,两个节点耗时 10*2 s = 20 s
。
至此,优化就显得很简单了,比如,思路 1 缩短超时参数 DEFAULT_CONNECT_TIMEOUT
,思路 2 中 客户端感知到连接不可用之后不进行重试,直接重建新连接,关闭旧连接。
5. 后记
diff --git a/专栏/分布式中间件实践之路(完)/08 基于 Redis 的分布式锁实现及其踩坑案例.md.html b/专栏/分布式中间件实践之路(完)/08 基于 Redis 的分布式锁实现及其踩坑案例.md.html
index fce0fea4..44ae8bef 100644
--- a/专栏/分布式中间件实践之路(完)/08 基于 Redis 的分布式锁实现及其踩坑案例.md.html
+++ b/专栏/分布式中间件实践之路(完)/08 基于 Redis 的分布式锁实现及其踩坑案例.md.html
@@ -153,7 +153,7 @@ function hide_canvas() {
1.2 基于 Redis 实现分布式锁(以 Redis 单机模式为例)
基于 Redis 实现锁服务的思路比较简单。我们把锁数据存储在分布式环境中的一个节点,所有需要获取锁的调用方(客户端),都需访问该节点,如果锁数据(Key-Value 键值对)已经存在,则说明已经有其它客户端持有该锁,可等待其释放(Key-Value 被主动删除或者因过期而被动删除)再尝试获取锁;如果锁数据不存在,则写入锁数据(Key-Value),其中 Value 需要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,以便释放锁的时候进行校验;锁服务使用完毕之后,需要主动释放锁,即删除存储在 Redis 中的 Key-Value 键值对。其架构如下:
-
+
1.3 加解锁流程
根据 Redis 官方的文档,获取锁的操作流程如下。
**步骤1,向 Redis 节点发送命令,请求锁。**代码如下:
diff --git a/专栏/分布式中间件实践之路(完)/09 分布式一致性算法 Raft 和 Etcd 原理解析.md.html b/专栏/分布式中间件实践之路(完)/09 分布式一致性算法 Raft 和 Etcd 原理解析.md.html
index c8a9e27a..f1ebd3a1 100644
--- a/专栏/分布式中间件实践之路(完)/09 分布式一致性算法 Raft 和 Etcd 原理解析.md.html
+++ b/专栏/分布式中间件实践之路(完)/09 分布式一致性算法 Raft 和 Etcd 原理解析.md.html
@@ -159,28 +159,28 @@ function hide_canvas() {
根据 Raft 协议,一个应用 Raft 协议的集群在刚启动时,所有节点的状态都是 Follower。由于没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),因此,Followers 会认为 Leader 已经下线,进而转为 Candidate 状态。然后,Candidate 将向集群中其它节点请求投票,同意自己升级为 Leader。如果 Candidate 收到超过半数节点的投票(N/2 + 1),它将获胜成为 Leader。
第一阶段:所有节点都是 Follower。
上面提到,一个应用 Raft 协议的集群在刚启动(或 Leader 宕机)时,所有节点的状态都是 Follower,初始 Term(任期)为 0。同时启动选举定时器,每个节点的选举定时器超时时间都在 100~500 毫秒之间且并不一致(避免同时发起选举)。
-
+
第二阶段:Follower 转为 Candidate 并发起投票。
没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),节点启动后在一个选举定时器周期内未收到心跳和投票请求,则状态转为候选者 Candidate 状态,且 Term 自增,并向集群中所有节点发送投票请求并且重置选举定时器。
注意,由于每个节点的选举定时器超时时间都在 100-500 毫秒之间,且彼此不一样,以避免所有 Follower 同时转为 Candidate 并同时发起投票请求。换言之,最先转为 Candidate 并发起投票请求的节点将具有成为 Leader 的“先发优势”。
-
+
第三阶段:投票策略。
节点收到投票请求后会根据以下情况决定是否接受投票请求:
- 请求节点的 Term 大于自己的 Term,且自己尚未投票给其它节点,则接受请求,把票投给它;
- 请求节点的 Term 小于自己的 Term,且自己尚未投票,则拒绝请求,将票投给自己。
-
+
第四阶段:Candidate 转为 Leader。
一轮选举过后,正常情况下,会有一个 Candidate 收到超过半数节点(N/2 + 1)的投票,它将胜出并升级为 Leader。然后定时发送心跳给其它的节点,其它节点会转为 Follower 并与 Leader 保持同步,到此,本轮选举结束。
注意:有可能一轮选举中,没有 Candidate 收到超过半数节点投票,那么将进行下一轮选举。
-
+
3. Raft 算法之 Log Replication 原理
在一个 Raft 集群中,只有 Leader 节点能够处理客户端的请求(如果客户端的请求发到了 Follower,Follower 将会把请求重定向到 Leader),客户端的每一个请求都包含一条被复制状态机执行的指令。Leader 把这条指令作为一条新的日志条目(Entry)附加到日志中去,然后并行得将附加条目发送给 Followers,让它们复制这条日志条目。
当这条日志条目被 Followers 安全复制,Leader 会将这条日志条目应用到它的状态机中,然后把执行的结果返回给客户端。如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断得重复尝试附加日志条目(尽管已经回复了客户端)直到所有的 Follower 都最终存储了所有的日志条目,确保强一致性。
第一阶段:客户端请求提交到 Leader。
如下图所示,Leader 收到客户端的请求,比如存储数据 5。Leader 在收到请求后,会将它作为日志条目(Entry)写入本地日志中。需要注意的是,此时该 Entry 的状态是未提交(Uncommitted),Leader 并不会更新本地数据,因此它是不可读的。
-
+
第二阶段:Leader 将 Entry 发送到其它 Follower
Leader 与 Floolwers 之间保持着心跳联系,随心跳 Leader 将追加的 Entry(AppendEntries)并行地发送给其它的 Follower,并让它们复制这条日志条目,这一过程称为复制(Replicate)。
有几点需要注意:
@@ -192,7 +192,7 @@ function hide_canvas() {
在正常情况下,Leader 和 Follower 的日志保持一致,所以追加日志的一致性检查从来不会失败。然而,Leader 和 Follower 一系列崩溃的情况会使它们的日志处于不一致状态。Follower可能会丢失一些在新的 Leader 中有的日志条目,它也可能拥有一些 Leader 没有的日志条目,或者两者都发生。丢失或者多出日志条目可能会持续多个任期。
要使 Follower 的日志与 Leader 恢复一致,Leader 必须找到最后两者达成一致的地方(说白了就是回溯,找到两者最近的一致点),然后删除从那个点之后的所有日志条目,发送自己的日志给 Follower。所有的这些操作都在进行附加日志的一致性检查时完成。
Leader 为每一个 Follower 维护一个 nextIndex,它表示下一个需要发送给 Follower 的日志条目的索引地址。当一个 Leader 刚获得权力的时候,它初始化所有的 nextIndex 值,为自己的最后一条日志的 index 加 1。如果一个 Follower 的日志和 Leader 不一致,那么在下一次附加日志时一致性检查就会失败。在被 Follower 拒绝之后,Leader 就会减小该 Follower 对应的 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得 Leader 和 Follower 的日志达成一致。当这种情况发生,附加日志就会成功,这时就会把 Follower 冲突的日志条目全部删除并且加上 Leader 的日志。一旦附加日志成功,那么 Follower 的日志就会和 Leader 保持一致,并且在接下来的任期继续保持一致。
-
+
第三阶段:Leader 等待 Followers 回应。
Followers 接收到 Leader 发来的复制请求后,有两种可能的回应:
@@ -200,14 +200,14 @@ function hide_canvas() {
- 一致性检查失败,拒绝写入,返回 False,原因和解决办法上面已做了详细说明。
需要注意的是,此时该 Entry 的状态也是未提交(Uncommitted)。完成上述步骤后,Followers 会向 Leader 发出 Success 的回应,当 Leader 收到大多数 Followers 的回应后,会将第一阶段写入的 Entry 标记为提交状态(Committed),并把这条日志条目应用到它的状态机中。
-
+
第四阶段:Leader 回应客户端。
完成前三个阶段后,Leader会向客户端回应 OK,表示写操作成功。
-
+
第五阶段,Leader 通知 Followers Entry 已提交
Leader 回应客户端后,将随着下一个心跳通知 Followers,Followers 收到通知后也会将 Entry 标记为提交状态。至此,Raft 集群超过半数节点已经达到一致状态,可以确保强一致性。
需要注意的是,由于网络、性能、故障等各种原因导致“反应慢”、“不一致”等问题的节点,最终也会与 Leader 达成一致。
-
+
4. Raft 算法之安全性
前面描述了 Raft 算法是如何选举 Leader 和复制日志的。然而,到目前为止描述的机制并不能充分地保证每一个状态机会按照相同的顺序执行相同的指令。例如,一个 Follower 可能处于不可用状态,同时 Leader 已经提交了若干的日志条目;然后这个 Follower 恢复(尚未与 Leader 达成一致)而 Leader 故障;如果该 Follower 被选举为 Leader 并且覆盖这些日志条目,就会出现问题,即不同的状态机执行不同的指令序列。
鉴于此,在 Leader 选举的时候需增加一些限制来完善 Raft 算法。这些限制可保证任何的 Leader 对于给定的任期号(Term),都拥有之前任期的所有被提交的日志条目(所谓 Leader 的完整特性)。关于这一选举时的限制,下文将详细说明。
@@ -225,7 +225,7 @@ function hide_canvas() {
在 Unix 系统中,/etc
目录用于存放系统管理和配置文件。分布式系统(Distributed System)第一个字母是“d”。两者看上去并没有直接联系,但它们加在一起就有点意思了:分布式的关键数据(系统管理和配置文件)存储系统,这便是 Etcd 命名的灵感之源。
5.1 Etcd 架构
Etcd 的架构图如下,从架构图中可以看出,Etcd 主要分为四个部分:HTTP Server、Store、Raft 以及 WAL。
-
+
- HTTP Server:用于处理客户端发送的 API 请求以及其它 Etcd 节点的同步与心跳信息请求。
- Store:用于处理 Etcd 支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是 Etcd 对用户提供的大多数 API 功能的具体实现。
diff --git a/专栏/分布式中间件实践之路(完)/10 基于 Etcd 的分布式锁实现原理及方案.md.html b/专栏/分布式中间件实践之路(完)/10 基于 Etcd 的分布式锁实现原理及方案.md.html
index 268d734b..5f4043a5 100644
--- a/专栏/分布式中间件实践之路(完)/10 基于 Etcd 的分布式锁实现原理及方案.md.html
+++ b/专栏/分布式中间件实践之路(完)/10 基于 Etcd 的分布式锁实现原理及方案.md.html
@@ -293,7 +293,7 @@ maintClient.alarmDisarm(alarmList.get(0));
完成业务流程后,删除对应的 Key 释放锁。
3.2 基于 Etcd 的分布式锁的原理图
根据上一节中介绍的业务流程,基于Etcd的分布式锁示意图如下。
-
+
业务流程图大家可参看这篇文章《Zookeeper 分布式锁实现原理》。
3.3 基于 Etcd 实现分布式锁的客户端 Demo
Demo 代码如下:
diff --git a/专栏/分布式中间件实践之路(完)/11 主流的分布式消息队列方案解读及比较.md.html b/专栏/分布式中间件实践之路(完)/11 主流的分布式消息队列方案解读及比较.md.html
index aca55c6b..08c53bf4 100644
--- a/专栏/分布式中间件实践之路(完)/11 主流的分布式消息队列方案解读及比较.md.html
+++ b/专栏/分布式中间件实践之路(完)/11 主流的分布式消息队列方案解读及比较.md.html
@@ -186,13 +186,13 @@ function hide_canvas() {
- ZooKeeper:Kafka 集群依赖 ZooKeeper,需根据 Kafka 的版本选择安装对应的 ZooKeeper 版本。
1.4 Kafka 架构
-
+
如上图所示,一个典型的 Kafka 体系架构包括若干 Producer(消息生产者),若干 Broker(Kafka 支持水平扩展,一般 Broker 数量越多,集群吞吐率越高),若干 Consumer(Group),以及一个 Zookeeper 集群。Kafka 通过 Zookeeper 管理集群配置,选举 Leader,以及在 Consumer Group 发生变化时进行 Rebalance。Producer 使用 Push(推)模式将消息发布到 Broker,Consumer 使用 Pull(拉)模式从 Broker 订阅并消费消息。
各个名词的解释请见下表:
-
+
1.5 Kafka 高可用方案
Kafka 高可用性的保障来源于其健壮的副本(Replication)策略。为了提高吞吐能力,Kafka 中每一个 Topic 分为若干 Partitions;为了保证可用性,每一个 Partition 又设置若干副本(Replicas);为了保障数据的一致性,Zookeeper 机制得以引入。基于 Zookeeper,Kafka 为每一个 Partition 找一个节点作为 Leader,其余备份作为 Follower,只有 Leader 才能处理客户端请求,而 Follower 仅作为副本同步 Leader 的数据,如下示意图:TopicA 分为两个 Partition,每个 Partition 配置两个副本。
-
+
基于上图的架构,当 Producer Push 的消息写入 Partition(分区) 时,Leader 所在的 Broker(Kafka 节点)会将消息写入自己的分区,同时还会将此消息复制到各个 Follower,实现同步。如果某个 Follower 挂掉,Leader 会再找一个替代并同步消息;如果 Leader 挂了,将会从 Follower 中选举出一个新的 Leader 替代,继续业务,这些都是由 ZooKeeper 完成的。
1.6 Kafka 优缺点
优点主要包括以下几点:
@@ -259,7 +259,7 @@ function hide_canvas() {
2.4 ActiveMQ 架构
ActiveMQ 的主体架构如下图所示。
-
+
传输协议: 消息之间的传递,无疑需要协议进行沟通,启动一个 ActiveMQ 便打开一个监听端口。ActiveMQ 提供了广泛的连接模式,主要包括 SSL、STOMP、XMPP。ActiveMQ 默认的使用协议为 OpenWire,端口号为 61616。
通信方式: ActiveMQ 有两种通信方式,Point-to-Point Model(点对点模式),Publish/Subscribe Model (发布/订阅模式),其中在 Publich/Subscribe 模式下又有持久化订阅和非持久化订阅两种消息处理方式。
消息存储: 在实际应用中,重要的消息通常需要持久化到数据库或文件系统中,确保服务器崩溃时,信息不会丢失。
@@ -277,7 +277,7 @@ function hide_canvas() {
2.5 ActiveMQ 高可用方案
在生产环境中,高可用(High Availability,HA)可谓 “刚需”, ActiveMQ 的高可用性架构基于 Master/Slave 模型。ActiveMQ 总共提供了四种配置方案来配置 HA,其中 Shared Nothing Master/Slave 在 5.8 版本之后不再使用了,并在 ActiveMQ 5.9 版本中引入了基于 Zookeeper 的 Replicated LevelDB Store HA 方案。
-
+
关于几种 HA 方案的详细介绍,读者可查看官网说明,在此,我仅做简单介绍。
方案一:Shared Nothing Master/Slave
这是一种最简单最典型的 Master-Slave 模式,Master 与 Slave 有各自的存储系统,不共享任何数据。“Shared Nothing” 模式有很多局限性,存在丢失消息、“双主”等问题。目前,在要求严格的生产环境中几乎没有应用,是一种趋于淘汰的方案,因此,本文就不作介绍了。
@@ -299,7 +299,7 @@ function hide_canvas() {
特别说明:ActiveMQ 官网警告,LevelDB 不再作为推荐的存储方案,取而代之的是 KahaDB。
2.6 ActiveMQ HA 方案之 Network Bridges 模式
在 2.5 节中介绍的几种 HA 方案,本质上都只有一个 Master 节点,无法满足高并发、大吞吐量的商用场景,因此,ActiveMQ 官方推出了 “网桥”架构模式,即真正的“分布式消息队列”。该模式可应对大规模 Clients、高密度的消息增量的场景;它以集群的模式,承载较大数据量的应用。
-
+
如上图所示,集群由多个子 Groups 构成,每个 Group 为 M-S 模式、共享存储;多个 Groups 之间基于“Network Connector”建立连接(Master-Slave 协议),通常为双向连接,所有的 Groups 之间彼此相连,Groups 之间形成“订阅”关系,比如 G2 在逻辑上为 G1 的订阅者(订阅的策略是根据各个 Broker 上消费者的 Destination 列表进行分类),消息的转发原理也基于此。对于 Client 而言,仍然支持 Failover,Failover 协议中可以包含集群中“多数派”的节点地址。
Topic 订阅者的消息,将会在所有 Group 中复制存储,对于 Queue 的消息,将会在 Brokers 之间转发,并最终到达 Consumer 所在的节点。
Producers 和 Consumers 可以与任何 Group 中的 Master 建立连接并进行消息通信,当 Brokers 集群拓扑变化时,Producers 或 Consumers 的个数变化时,将会动态平衡 Clients 的连接位置。Brokers 之间通过“Advisory”机制来同步 Clients 的连接信息,比如新的 Consumers 加入,Broker 将会发送 Advisory 消息(内部的通道)通知其他 Brokers。
@@ -354,7 +354,7 @@ function hide_canvas() {
3.4 RabbitMQ 架构
根据官方文档说明,RabbitMQ 的架构图如下所示:
-
+
接下来解释几个重要的概念。
- Broker:即消息队列服务器实体。
@@ -448,7 +448,7 @@ function hide_canvas() {
4.4 RocketMQ 架构
RocketMQ 是一个具有高性能、高可靠、低延迟、分布式的万亿级容量,且可伸缩的分布式消息和流平台。它由 Name Servers、Brokers、 Producers 和 Consumers 四个部分组成。其架构如下图所示(取自官网)。
-
+
NameServer 集群
NameServer 是一个功能齐全的服务器,其角色类似 Kafka 中的 ZooKeeper,支持 Broker 的动态注册与发现。主要包括两个功能:
@@ -507,11 +507,11 @@ function hide_canvas() {
5.1 RocketMQ 官方评价
所谓实践是检验真理的唯一标准,实际应用中的表现比文字更具说服力。在 RocketMQ 官方文档中,关于 RocketMQ 的研发背景是这样说的:在我们的研究中,随着使用 Queue 和 Topic 的增加,ActiveMQ IO 模块很快达到了瓶颈。我们试图通过节流、断路器或降级来解决这个问题,但效果不佳。所以我们开始关注当时流行的消息解决方案 Kafka。不幸的是,Kafka 不能满足我们的要求,特别是在低延迟和高可靠性方面。
简而言之,ActiveMQ 和 Kafka 的性能都不能满足阿里的超大规模应用场景。在此背景下,阿里自研了 RocketMQ,并捐赠给了开源社区,目前有超过 100 家企业在使用其开源版本。关于 ActiveMQ 、Kafka 以及 RocketMQ 的比较如下所示(取自 RocketMQ 官网文档):
-
+
5.2 对比四大消息队列
消息队列利用高效可靠的消息传递机制进行平台无关的数据交流,并基于数据通信来进行分布式系统的集成。目前业界有很多的 MQ 产品,例如 RabbitMQ、RocketMQ、ActiveMQ、Kafka、ZeroMQ、MetaMq 等,也有直接使用数据库 Redis 充当消息队列的案例。而这些消息队列产品,各有侧重,在实际选型时,需要结合自身需求及 MQ 产品特征,综合考虑。
以下是四种消息队列的差异对比(图片源地址):
-
+
参考文献
- RocketMQ 、RabbitMQ、Kafka 以及 ActiveMQ 官方文档;
diff --git a/专栏/分布式中间件实践之路(完)/13 深入解读基于 Kafka 和 ZooKeeper 的分布式消息队列原理.md.html b/专栏/分布式中间件实践之路(完)/13 深入解读基于 Kafka 和 ZooKeeper 的分布式消息队列原理.md.html
index e3a2fd0d..d491cf59 100644
--- a/专栏/分布式中间件实践之路(完)/13 深入解读基于 Kafka 和 ZooKeeper 的分布式消息队列原理.md.html
+++ b/专栏/分布式中间件实践之路(完)/13 深入解读基于 Kafka 和 ZooKeeper 的分布式消息队列原理.md.html
@@ -137,11 +137,11 @@ function hide_canvas() {
对于商业级消息中间件来说,可靠性至关重要,那么,Kafka 是如何确保消息生产、传输、存储及消费过程中的可靠性的呢?本文将从 Kafka 的架构切入,解读 Kafka 基本原理,并对其存储机制、复制原理、同步原理、可靠性和持久性等作详细解读。
1. Kafka 总体架构
基于 Kafka、ZooKeeper 的分布式消息队列系统总体架构如下图所示:
-
+
典型的 Kafka 体系架构包括若干 Producer(消息生产者),若干 Broker(作为 Kafka 节点的服务器),若干 Consumer (Group),以及一个 ZooKeeper 集群。
Kafka 通过 ZooKeeper 管理集群配置、选举 Leader,并在 Consumer Group 发生变化时进行 Rebalance(即消费者负载均衡,在下一课介绍)。Producer 使用 Push(推)模式将消息发布到 Broker,Consumer 使用 Pull(拉)模式从 Broker 订阅并消费消息。
上图仅描摹了总体架构,并没有对作为 Kafka 节点的 Broker 进行深入刻画。事实上,它的内部细节相当复杂,如下图所示,Kafka 节点涉及 Topic、Partition 两个重要概念。
-
+
在 Kafka 架构中,有几个术语需要了解下。
- Producer: 生产者,即消息发送者,Push 消息到 Kafka 集群的 Broker(就是 Server)中;
@@ -159,7 +159,7 @@ function hide_canvas() {
1.1 Topic & Partition
为了便于区分消息,Producer 向 Kafka 集群 Push 的消息会被归于某一类别,即 Topic。为了负载均衡、增强可扩展性,Topic 又被分为多个 Partition。从存储层面来看,每一个 Partition 都是一个有序的、不可变的记录序列,通俗点就是一个追加日志(Append Log)文件。每个 Partition 中的记录都会被分配一个称为偏移量(Offset)的序列 ID 号,该序列 ID 号唯一地标识 Partition 内的每个记录。
Kafka 机制中,Producer Push 的消息是追加(Append)到 Partition 中的,这是一种顺序写磁盘的机制,效率远高于随机写内存,如下图所示:
-
+
(来源:Kafka 官网)
1.2 Kafka 为什么要将 Topic 分区?
@@ -221,7 +221,7 @@ log.cleaner.enable=false #是否启用log压缩,一般不用启用,启用的
并没有发现“Segment”文件!事实上,Segment 文件由两部分组成,即 .index
文件和 .log
文件,分别为 Segment 索引文件和数据文件。观察上面的文件名,很容易理解它们的命规则,对于索引文件,Partition 全局的第一个 Segment 从 0 开始,后续每个 Segment 文件名为上一个 Segment 文件最后一条消息的偏移量(Offset)值,Offset 的数值由 20 位数字字符表示,没有数字的位置用 0 填充。对于数据文件,其命名与对应的索引文件保持一致即可。
为了便于读者理解,以上面的其中一“对” Segment 文件为例:00000000000000170410.index
和 00000000000000170410.log
,绘制其关系图,如下所示:
-
+
.index
文件作为索引文件,存储的是元数据;.log
文件作为数据文件,存储的是消息。如何通过索引访问具体的消息呢?事实上,索引文件中的元数据指向的是对应数据文件中消息的物理偏移地址,有了消息的物理地址,自然也就可以访问对应的消息了。
其中以 .index
索引文件中的元数据 [2, 365]
为例,在 .log
数据文件表示第 2 个消息,即在全局 Partition 中表示 170410+2=170412 个消息,该消息的物理偏移地址为 365。
问题3:如何从 Partition 中通过 Offset 查找 Message?
@@ -270,7 +270,7 @@ records: [Record]
为了便于读者更好地理解副本概念,我们看下面这个例子。
一个具有 4 个 Broker 的 Kafka 集群,TopicA 有 3 个 Partition,每个 Partition 有 3 个副本(Leader+Follower)。
-
+
如果 Leader 所在的 Broker 发生故障或宕机,对应 Partition 将因无 Leader 而不能处理客户端请求,这时副本的作用就体现出来了:一个新 Leader 将从 Follower 中被选举出来并继续处理客户端的请求。
如何确保新选举出的 Leader 是最优秀的?
一个 Partition 有多个副本(Replica),为了提高可靠性,这些副本分散在不同的 Broker 上。由于带宽、读写性能、网络延迟等因素,同一时刻,这些副本的状态通常是不一致的,即 Follower 与 Leader 的状态不一致。那么,如何保证新 Leader 是优选出来的呢?
@@ -281,7 +281,7 @@ records: [Record]
前面提到 Kafka 中,Topic 的每个 Partition 可能有多个副本(Replica)用于实现冗余,从而实现高可用。每个副本又有两个重要的属性 LEO 和 HW。
通过前面内容的学习,我们知道在 Kafka 的存储机制中,Partition 可以细分为 Segment,而 Segment 是最终的存储粒度。不过,对于上层应用来说,仍然可以将 Partition 看作最小的存储单元,即 Partition 可以看作是由一系列的 Segment 组成的粒度更粗的存储单元,它由一系列有序的消息组成,这些消息被连续的追加到 Partition 中。
LEO、HW 以及 Offset 的关系图如下:
-
+
- LEO:即日志末端位移(Log End Offset),表示每个副本的 Log 最后一条 Message 的位置。比如 LEO=10、HW=7,则表示该副本保存了 10 条消息,而后面 3 条处于 Uncommitted 状态。
- HW:即水位值(High Watermark)。对于同一个副本而言,其 HW 值不大于 LEO 值。小于等于 HW 值的所有消息都被认为是“已备份”的(Replicated),对于任何一个 Partition,取其对应的 ISR 中最小的 LEO 作为 HW,Consumer 最多只能消费到 HW 所在的位置。
@@ -292,7 +292,7 @@ records: [Record]
下面我们举例说明。
某个 Partition 的 ISR 列表包括 3 个副本(1 个 Leader+2 个 Follower),当 Producer 向其 Leader 写入一条消息后,HW 和 LEO 有如下变化过程:
-
+
由上图可以看出,Kafka 的复制机制既不是完全的同步复制,也不是单纯的异步复制。同步复制要求所有能工作的 Follower 都复制完,这条消息才会被置为 Committed 状态,该复制方式受限于复制最慢的 Follower,会极大地影响吞吐率,因而极少应用于生产环境。而异步复制方式下,Follower 异步地从 Leader 复制数据,数据只要被 Leader 写入 Log 就被认为已经 Committed(类似 Redis,主从异步复制)。如果在 Follower 尚未复制完成的情况下,Leader 宕机,则必然导致数据丢失,很多时候,这是不可接受的。
相较于完全同步复制和异步复制,Kafka 使用 ISR 的策略则是一种较“中庸”的策略,在可靠性和吞吐率方面取得了较好的平衡。某种意义上,ISR 策略与第 8 课中介绍的 Raft 算法所采用的“多数派原则”类似,不过 ISR 更为灵活。
2.4 Kafka 消息生产的可靠性
@@ -345,9 +345,9 @@ records: [Record]
3.2 Topic 在 ZooKeeper 中的注册
在 Kafka 中,所有 Topic 与 Broker 的对应关系都由 ZooKeeper 来维护,在 ZooKeeper 中,通过建立专属的节点来存储这些信息,其路径为 /brokers/topics/{topic_name}
。
前面说过,为了保障数据的可靠性,每个 Topic 的 Partition 实际上是存在备份的,并且备份的数量由 Kafka 机制中的 Replicas 来控制。那么问题来了,如下图所示,假设某个 TopicA 被分为 2 个 Partition,并且存在两个备份,由于这 2 个 Partition(1-2)被分布在不同的 Broker 上,同一个 Partiton 与其备份不能(也不应该)存储于同一个 Broker 上。以 Partition1 为例,假设它被存储于 Broker2,其对应的备份分别存储于 Broker1 和 Broker4,有了备份,可靠性得到保障,但数据一致性却是个问题。
-
+
为了保障数据的一致性,ZooKeeper 机制得以引入。基于 ZooKeeper,Kafka 为每一个 Partition 找一个节点作为 Leader,其余备份作为 Follower;接续上图的例子,就 TopicA 的 Partition1 而言,如果位于 Broker2(Kafka 节点)上的 Partition1 为 Leader,那么位于 Broker1 和 Broker4 上面的 Partition1 就充当 Follower,则有下图:
-
+
基于上图的架构,当 Producer Push 的消息写入 Partition(分区)时,作为 Leader 的 Broker(Kafka 节点)会将消息写入自己的分区,同时还会将此消息复制到各个 Follower,实现同步。如果某个 Follower 挂掉,Leader 会再找一个替代并同步消息;如果 Leader 挂了,Follower 们会选举出一个新的 Leader 替代,继续业务,这些都是由 ZooKeeper 完成的。
3.3 Consumer 在 ZooKeeper 中的注册
Consumer Group 注册
diff --git a/专栏/分布式中间件实践之路(完)/14 深入浅出解读 Kafka 的可靠性机制.md.html b/专栏/分布式中间件实践之路(完)/14 深入浅出解读 Kafka 的可靠性机制.md.html
index dc21dd88..3641e773 100644
--- a/专栏/分布式中间件实践之路(完)/14 深入浅出解读 Kafka 的可靠性机制.md.html
+++ b/专栏/分布式中间件实践之路(完)/14 深入浅出解读 Kafka 的可靠性机制.md.html
@@ -149,7 +149,7 @@ function hide_canvas() {
如果出现 Leader 故障下线的情况,就需要从所有的 Follower 中选举新的 Leader,以便继续提供服务。为了保证一致性,通常只能从 ISR 列表中选取新的 Leader (上面已经介绍,ISR 列表中的 Follower 与原 Leader 保持同步),因此,无论 ISR 中哪个 Follower 被选为新的 Leader,它都知道 HW 之前的数据,可以保证在切换了 Leader 后,Consumer 可以继续“看到”之前已经由 Producer 提交的数据。
如下图所示,如果 Leader 宕机,Follower1 被选为新的 Leader,而新 Leader (原 Follower1 )并没有完全同步之前 Leader 的所有数据(少了一个消息 6),之后,新 Leader 又继续接受了新的数据,此时,原本宕机的 Leader 经修复后重新上线,它将发现新 Leader 中的数据和自己持有的数据不一致,怎么办呢?
为了保证一致性,必须有一方妥协,显然旧的 Leader 优先级较低,因此, 它会将自己的数据截断到宕机之前的 HW 位置(HW 之前的数据,与 Leader 一定是相同的),然后同步新 Leader 的数据。这便是所谓的 “截断机制”。
-
+
3. 消息生产的可靠性
3.1 消息可能重复生产
在第 12 课 2.4 小节中,我们介绍了消息生产过程中保证数据可靠性的策略。该策略虽然可以保障消息不丢失,但无法避免出现重复消息。例如,生产者发送数据给 Leader,Leader 同步数据给 ISR 中的 Follower,同步到一半 Leader 时宕机,此时选出新的 Leader,它可能具有部分此次提交的数据,而生产者收到发送失败响应后将重发数据,新的 Leader 接受数据则数据重复。因此 Kafka 只支持“At Most Once”和“At Least Once”,而不支持“Exactly Once”,消息去重需在具体的业务中实现。
diff --git a/专栏/分布式技术原理与实战45讲-完/06 如何准备一线互联网公司面试?.md.html b/专栏/分布式技术原理与实战45讲-完/06 如何准备一线互联网公司面试?.md.html
index a9f3f7ab..666b2fd0 100644
--- a/专栏/分布式技术原理与实战45讲-完/06 如何准备一线互联网公司面试?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/06 如何准备一线互联网公司面试?.md.html
@@ -254,7 +254,7 @@ function hide_canvas() {
互联网技术面试的特点
互联网公司的技术面试有一些侧重点,国内互联网公司和外企的侧重点又有不同。BAT 互联网公司看重项目能力,重点考察语言深度和项目能力,国外 IT 公司更看重计算机基础,比如微软和 Amazon 的面试,每轮面试都是算法题的在线测评,针对社招还会有 System Design 题目。
一般来说,一线互联网公司面试都有下面的特点:
-
+
1. 看重数据结构和算法等计算机基础知识
一线互联网公司在面试中更加关注计算机基础知识的考察,比如数据结构和算法,操作系统、网络原理,目前,很多国内公司在招聘上也看齐 Google、Facebook 等海外企业,面试重点考察算法,如果没有 ACM 经验,不刷题很难通过。
2. 深入技术栈,考察对原理和源码的掌握程度
@@ -277,7 +277,7 @@ function hide_canvas() {
- 良好的沟通交流能力,具备较强的学习能力和责任心,可以编写良好的代码文档。
感兴趣的可以去招聘网站上看一下,对后端开发的要求,基本就是在这个范围里,从这个通用招聘要求上,我们可以逐条拆解,总结如何高效准备面试。
-
+
1. 对学历和专业的要求,硬性标准
对学历和专业的要求,这一条一般都会注明,不过计算机行业比较包容,不拘一格,非科班以及转专业的技术大牛也有很多,这里不展开。
2. 加强计算机基础,提高算法和数据结构、操作系统等底层能力
diff --git a/专栏/分布式技术原理与实战45讲-完/08 对比两阶段提交,三阶段协议有哪些改进?.md.html b/专栏/分布式技术原理与实战45讲-完/08 对比两阶段提交,三阶段协议有哪些改进?.md.html
index 00fee1a6..6e4c9ebd 100644
--- a/专栏/分布式技术原理与实战45讲-完/08 对比两阶段提交,三阶段协议有哪些改进?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/08 对比两阶段提交,三阶段协议有哪些改进?.md.html
@@ -288,7 +288,7 @@ function hide_canvas() {
三阶段提交协议
为了解决二阶段协议中的同步阻塞等问题,三阶段提交协议在协调者和参与者中都引入了超时机制,并且把两阶段提交协议的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。
三阶段中的 Three Phase 分别为 CanCommit、PreCommit、DoCommit 阶段。
-
+
CanCommit 阶段
3PC 的 CanCommit 阶段其实和 2PC 的准备阶段很像。协调者向参与者发送 Can-Commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。
PreCommit 阶段
diff --git a/专栏/分布式技术原理与实战45讲-完/09 MySQL 数据库如何实现 XA 规范?.md.html b/专栏/分布式技术原理与实战45讲-完/09 MySQL 数据库如何实现 XA 规范?.md.html
index 7047ddf3..a3f8450c 100644
--- a/专栏/分布式技术原理与实战45讲-完/09 MySQL 数据库如何实现 XA 规范?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/09 MySQL 数据库如何实现 XA 规范?.md.html
@@ -303,7 +303,7 @@ show binary logs;
不论是 statement 还是 row 格式,binlog 都会添加一个 XID_EVENT 作为事务的结束,该事件记录了事务的 ID 也就是 Xid,在 MySQL 进行崩溃恢复时根据 binlog 中提交的情况来决定如何恢复。
Binlog 同步过程
下面来看看 Binlog 下的事务提交过程,整体过程是先写 redo log,再写 binlog,并以 binlog 写成功为事务提交成功的标志。
-
+
当有事务提交时:
- 第一步,InnoDB 进入 Prepare 阶段,并且 write/sync redo log,写 redo log,将事务的 XID 写入到 redo 日志中,binlog 不作任何操作;
diff --git a/专栏/分布式技术原理与实战45讲-完/10 如何在业务中体现 TCC 事务模型?.md.html b/专栏/分布式技术原理与实战45讲-完/10 如何在业务中体现 TCC 事务模型?.md.html
index 8602ecec..18c41c7b 100644
--- a/专栏/分布式技术原理与实战45讲-完/10 如何在业务中体现 TCC 事务模型?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/10 如何在业务中体现 TCC 事务模型?.md.html
@@ -256,7 +256,7 @@ function hide_canvas() {
TCC 提出了一种新的事务模型,基于业务层面的事务定义,锁粒度完全由业务自己控制,目的是解决复杂业务中,跨表跨库等大颗粒度资源锁定的问题。TCC 把事务运行过程分成 Try、Confirm / Cancel 两个阶段,每个阶段的逻辑由业务代码控制,避免了长事务,可以获取更高的性能。
TCC 的各个阶段
TCC 的具体流程如下图所示:
-
+
Try 阶段:调用 Try 接口,尝试执行业务,完成所有业务检查,预留业务资源。
Confirm 或 Cancel 阶段:两者是互斥的,只能进入其中一个,并且都满足幂等性,允许失败重试。
@@ -273,7 +273,7 @@ function hide_canvas() {
从真实业务场景分析 TCC
下面以一个电商中的支付业务来演示,用户在支付以后,需要进行更新订单状态、扣减账户余额、增加账户积分和扣减商品操作。
在实际业务中为了防止超卖,有下单减库存和付款减库存的区别,支付除了账户余额,还有各种第三方支付等,这里我们为了描述方便,统一使用扣款减库存,扣款来源是用户账户余额。
-
+
业务逻辑拆解
我们把订单业务拆解为以下几个步骤:
@@ -305,7 +305,7 @@ function hide_canvas() {
TCC 对比 2PC 两阶段提交
TCC 事务模型的思想类似 2PC 提交,下面对比 TCC 和基于 2PC 事务 XA 规范对比。
对比 2PC 提交
-
+
- 第一阶段
@@ -325,7 +325,7 @@ function hide_canvas() {
在业务中引入 TCC 一般是依赖单独的 TCC 事务框架,可以选择自研或者应用开源组件。TCC 框架扮演了资源管理器的角色,常用的 TCC 开源组件有 Tcc-transaction、ByteTCC、Spring-cloud-rest-tcc 等。
前面介绍过的 Seata,可以选择 TCC 事务模式,也支持了 AT 模式及 Saga 模式。
以 Tcc-transaction 为例,源码托管在 Github-tcc-transaction,提供了对 Spring 和 Dubbo 的适配,感兴趣的话可以查看 tcc-transaction-tutorial-sample 学习。
-
+
总结
这一课时介绍了 TCC 分布式事务模型的应用,通过一个实际例子分析了如何应用 TCC 对业务系统进行改造,并且对比了 TCC 和 2PC 两阶段提交,以及 TCC 相关的开源组件。
diff --git a/专栏/分布式技术原理与实战45讲-完/14 如何理解 RPC 远程服务调用?.md.html b/专栏/分布式技术原理与实战45讲-完/14 如何理解 RPC 远程服务调用?.md.html
index 41ac840f..3af7741f 100644
--- a/专栏/分布式技术原理与实战45讲-完/14 如何理解 RPC 远程服务调用?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/14 如何理解 RPC 远程服务调用?.md.html
@@ -286,7 +286,7 @@ function hide_canvas() {
建立通信之后,节点之间数据传输采用什么协议,也就是选择什么样的二进制数据格式组织;传输的数据如何序列化和反序列化,比如在 Dubbo 中,传输协议默认使用 Dubbo 协议,序列化支持选择 Hessian、Kryo、Protobuf 等不同方式。
如何进行服务注册和发现
服务注册和发现,也就是服务寻址,以 Dubbo 为例,下图分布式服务典型的寻址和调用过程:
-
+
服务注册,需要服务提供者启动后主动把服务注册到注册中心,注册中心存储了该服务的 IP、端口、调用方式(协议、序列化方式)等信息。
服务发现,当服务消费者第一次调用服务时,会通过注册中心找到相应的服务提供方地址列表,并缓存到本地,以供后续使用。当消费者再次调用服务时,不会再去请求注册中心,而是直接通过负载均衡算法从 IP 列表中取一个服务提供者调用服务。
上面列举了一些分布式服务框架的实现要点,除了这些,还有很多技术细节,比如如何实现服务调用,RPC 框架如何和服务层交互,Java 中通过代理实现服务调用,那么代理对象如何解析请求参数、如何处理返回值等。
diff --git a/专栏/分布式技术原理与实战45讲-完/15 为什么微服务需要 API 网关?.md.html b/专栏/分布式技术原理与实战45讲-完/15 为什么微服务需要 API 网关?.md.html
index 33f69005..09cd03be 100644
--- a/专栏/分布式技术原理与实战45讲-完/15 为什么微服务需要 API 网关?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/15 为什么微服务需要 API 网关?.md.html
@@ -259,14 +259,14 @@ function hide_canvas() {
假设我们要使用微服务构建一个电商平台,一般来说需要订单服务、商品服务、交易服务、会员服务、评论服务、库存服务等。
移动互联网时代,我们的系统不仅会通过 Web 端提供服务,还有 App 端、小程序端等,那么不同客户端应该如何访问这些服务呢?
如果在单体应用架构下,所有服务都来自一个应用工程,客户端通过向服务端发起网络调用来获取数据,通过 Nginx 等负载均衡策略将请求路由给 N 个相同的应用程序实例中的一个,然后应用程序处理业务逻辑,并将响应返回给客户端。
-
+
在微服务架构下,每个服务都是独立部署,如果直接调用,系统设计可能是这样的:
-
+
各个调用端单独去发起连接,会出现很多问题,比如不容易监控调用流量,出现问题不好确定来源,服务之间调用关系混乱等。
如何解决这个局面呢
针对这些问题,一个常用的解决方案是使用 API 服务网关。在微服务设计中,需要隔离内外部调用,统一进行系统鉴权、业务监控等,API 服务网关是一个非常合适的切入口。
通过引入 API 网关这一角色,可以高效地实现微服务集群的输出,节约后端服务开发成本,减少上线风险,并为服务熔断、灰度发布、线上测试等提供解决方案。
-
+
使用网关,可以优化微服务架构中系统过于分散的弊端,使得架构更加优雅,选择一个适合的 API 网关,可以有效地简化开发并提高运维与管理效率。
应用网关的优劣
API 网关在微服务架构中并不是一个必需项目,而是系统设计的一个解决方案,用来整合各个不同模块的微服务,统一协调服务。
diff --git a/专栏/分布式技术原理与实战45讲-完/16 如何实现服务注册与发现?.md.html b/专栏/分布式技术原理与实战45讲-完/16 如何实现服务注册与发现?.md.html
index a2405315..ef01e1b9 100644
--- a/专栏/分布式技术原理与实战45讲-完/16 如何实现服务注册与发现?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/16 如何实现服务注册与发现?.md.html
@@ -259,7 +259,7 @@ function hide_canvas() {
有了服务注册和发现机制,消费者不需要知道具体服务提供者的真实物理地址就可以进行调用,也无须知道具体有多少个服务者可用;而服务提供者只需要注册到注册中心,就可以对外提供服务,在对外服务时不需要知道具体是哪些服务调用了自己。
服务注册和发现原理
服务注册和发现的基本流程如下图所示:
-
+
首先,在服务启动时,服务提供者会向注册中心注册服务,暴露自己的地址和端口等,注册中心会更新服务列表。服务消费者启动时会向注册中心请求可用的服务地址,并且在本地缓存一份提供者列表,这样在注册中心宕机时仍然可以正常调用服务。
如果提供者集群发生变更,注册中心会将变更推送给服务消费者,更新可用的服务地址列表。
典型服务发现组件的选型
@@ -272,13 +272,13 @@ function hide_canvas() {
Eureka
在 Spring Cloud 中,提供了 Eureka 来实现服务发现功能。Eureka 采用的是 Server 和 Client 的模式进行设计,Eureka Server 扮演了服务注册中心的角色,为 Client 提供服务注册和发现的功能。
Eureka Client 通过客户端注册的方式暴露服务,通过注解等方式嵌入到服务提供者的代码中,当服务启动时,服务发现组件会向注册中心注册自身提供的服务,并周期性地发送心跳来更新服务。
-
+
如果连续多次心跳不能够发现服务,那么 Eureka Server 就会将这个服务节点从服务注册表中移除,各个服务之间会通过注册中心的注册信息来实现调用。
Euerka 在 Spring Cloud 中广泛应用,目前社区中集成的是 1.0 版本,在后续的版本更新中,Netflix 宣布 Euerka 2.0 闭源,于是开源社区中也出现了许多新的服务发现组件,比如 Spring Cloud Alibaba Nacos。
Nacos
Nacos 是阿里巴巴推出来的一个开源项目,提供了服务注册和发现功能,使用 Nacos 可以方便地集成 Spring Cloud 框架。如果正在使用 Eureka 或者 Consul,可以通过少量的代码就能迁移到 Nacos 上。
Nacos 的应用和 Eureka 类似,独立于系统架构,需要部署 Nacos Server。除了服务注册和发现之外,Nacos 还提供了配置管理、元数据管理和流量管理等功能,并且提供了一个可视化的控制台管理界面。
-
+
关于 Nacos 的更多应用,可以在 Nacos 官网找到相关的文档。
一致性对比
在讨论分布式系统时,一致性是一个绕不开的话题,在服务发现中也是一样。CP 模型优先保证一致性,可能导致注册中心可用性降低,AP 模型优先保证可用性,可能出现服务错误。
diff --git a/专栏/分布式技术原理与实战45讲-完/17 如何实现分布式调用跟踪?.md.html b/专栏/分布式技术原理与实战45讲-完/17 如何实现分布式调用跟踪?.md.html
index b27d0ddd..70668de2 100644
--- a/专栏/分布式技术原理与实战45讲-完/17 如何实现分布式调用跟踪?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/17 如何实现分布式调用跟踪?.md.html
@@ -253,7 +253,7 @@ function hide_canvas() {
分布式服务拆分以后,系统变得日趋复杂,业务的调用链也越来越长,如何快速定位线上故障,就需要依赖分布式调用跟踪技术。下面我们一起来看下分布式调用链相关的实现。
为什么需要分布式调用跟踪
随着分布式服务架构的流行,特别是微服务等设计理念在系统中的应用,系统架构变得越来越分散,如下图所示。
-
+
可以看到,随着服务的拆分,系统的模块变得越来越多,不同的模块可能由不同的团队维护,一个请求可能会涉及几十个服务的协同处理, 牵扯到多个团队的业务系统。
假设现在某次服务调用失败,或者出现请求超时,需要定位具体是哪个服务引起的异常,哪个环节导致的超时,就需要去每个服务里查看日志,这样的处理效率是非常低的。
另外,系统拆分以后,缺乏一个自上而下全局的调用 ID,如何有效地进行相关的数据分析工作呢?比如电商的活动转化率、购买率、广告系统的点击链路等。如果没有一个统一的调用 ID 来记录,只依靠业务上的主键等是很难实现的,特别是对于一些大型网站系统,如淘宝、京东等,这些问题尤其突出。
@@ -271,7 +271,7 @@ function hide_canvas() {
分布式链路跟踪的技术实现,主要是参考 Google 的 Dapper 论文,分布式调用跟踪是一种全链路日志,主要的设计基于 Span 日志格式,下面简单介绍这个日志结构。
Dapper 用 Span 来表示一个服务调用开始和结束的时间,也就是时间区间,并记录了 Span 的名称以及每个 Span 的 ID 和父 ID,如果一个 Span 没有父 ID 则被称之为 Root Span。
一个请求到达应用后所调用的所有服务,以及所有服务组成的调用链就像是一个树结构,追踪这个调用链路得到的树结构称之为 Trace,所有的 Span 都挂在一个特定的 Trace 上,共用一个 TraceId。
-
+
在一次 Trace 中,每个服务的每一次调用,就是一个 Span,每一个 Span 都有一个 ID 作为唯一标识。同样,每一次 Trace 都会生成一个 TraceId 在 Span 中作为追踪标识,另外再通过一个 parentSpanId,标明本次调用的发起者。
当 Span 有了上面三个标识后,就可以很清晰地将多个 Span 进行梳理串联,最终归纳出一条完整的跟踪链路。
确定了日志格式以后,接下来日志如何采集和解析,日志的采集和存储有许多开源的工具可以选择。一般来说,会使用离线 + 实时的方式去存储日志,主要是分布式日志采集的方式,典型的解决方案如 Flume 结合 Kafka 等 MQ,日志存储到 HBase 等存储中,接下来就可以根据需要进行相关的展示和分析。
@@ -281,7 +281,7 @@ function hide_canvas() {
Dapper 是 Google 生产环境下的分布式跟踪系统,没有对外开源,但是 Google 发表了“Dapper - a Large-Scale Distributed Systems Tracing Infrastructure”论文,介绍了他们的分布式系统跟踪技术,所以后来的 Zipkin 和鹰眼等都借鉴了 Dapper 的设计思想。
Twitter 的 Zipkin
Zipkin 是一款开源的分布式实时数据追踪系统,基于 Google Dapper 的论文设计而来,由 Twitter 公司开发贡献。其主要功能是聚集来自各个异构系统的实时监控数据,用来追踪微服务架构下的系统延时问题,Zipkin 的用户界面可以呈现一幅关联图表,以显示有多少被追踪的请求通过了每一层应用。
-
+
阿里的 EagleEye
EagleEye 鹰眼系统是 Google 的分布式调用跟踪系统 Dapper 在淘宝的实现,EagleEye 没有开源。下面这段介绍来自 阿里中间件团队:
diff --git a/专栏/分布式技术原理与实战45讲-完/19 容器化升级对服务有哪些影响?.md.html b/专栏/分布式技术原理与实战45讲-完/19 容器化升级对服务有哪些影响?.md.html
index f55d8be9..c9cdd90c 100644
--- a/专栏/分布式技术原理与实战45讲-完/19 容器化升级对服务有哪些影响?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/19 容器化升级对服务有哪些影响?.md.html
@@ -257,7 +257,7 @@ function hide_canvas() {
虚拟化技术
虚拟化技术通过 Hypervisor 实现虚拟机与底层硬件的解耦,虚拟机实现依赖 Hypervisor 层,Hypervisor 是整个虚拟机的核心所在。
Hypervisor 是什么呢 ? 也可以叫作虚拟机监视器 VMM(Virtual Machine Monitor),是一种运行在基础物理服务器和操作系统之间的中间软件层,可允许多个操作系统和应用共享硬件。
-
+
Hypervisor 虚拟机可以模拟机器硬件资源,协调虚拟机对硬件资源的访问,同时在各个虚拟机之间进行隔离。
每一个虚拟机都包括执行的应用,依赖的二进制和库资源,以及一个完整的 OS 操作系统,虚拟机运行以后,预分配给它的资源将全部被占用。
容器化技术
@@ -271,7 +271,7 @@ function hide_canvas() {
实际部署一般是把两种技术结合起来,比如一个虚拟机中运行多个容器,这样既保证了较好的强隔离性和安全性,也有了快速扩展、灵活性和易用性。
容器化的原理
容器技术的核心是如何实现容器内资源的限制,以及不同容器之间的隔离,这些是基于 Linux 的 Namespace 和 CGroups 技术。
-
+
Namespace
Namespace 的目的是通过抽象方法使得 Namespace 中的进程看起来拥有它们自己的隔离的全局系统资源实例。
diff --git a/专栏/分布式技术原理与实战45讲-完/20 ServiceMesh:服务网格有哪些应用?.md.html b/专栏/分布式技术原理与实战45讲-完/20 ServiceMesh:服务网格有哪些应用?.md.html
index f445286e..3fc1c458 100644
--- a/专栏/分布式技术原理与实战45讲-完/20 ServiceMesh:服务网格有哪些应用?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/20 ServiceMesh:服务网格有哪些应用?.md.html
@@ -263,7 +263,7 @@ function hide_canvas() {
什么是 Service Mesh
微服务领域有 CNCF 组织(Cloud Native Computing Foundation),也就是云原生基金会,CNCF 致力于微服务开源技术的推广。Service Mesh 是 CNCF 推广的新一代微服务架构,致力于解决服务间通讯。
Service Mesh 基于边车模式演进,通过在系统中添加边车代理,也就是 Sidecar Proxy 实现。
-
+
Service Mesh 可以认为是边车模式的进一步扩展,提供了以下功能:
- 管理服务注册和发现
diff --git a/专栏/分布式技术原理与实战45讲-完/21 Dubbo vs Spring Cloud:两大技术栈如何选型?.md.html b/专栏/分布式技术原理与实战45讲-完/21 Dubbo vs Spring Cloud:两大技术栈如何选型?.md.html
index 7406a6c2..d82c78f1 100644
--- a/专栏/分布式技术原理与实战45讲-完/21 Dubbo vs Spring Cloud:两大技术栈如何选型?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/21 Dubbo vs Spring Cloud:两大技术栈如何选型?.md.html
@@ -255,7 +255,7 @@ function hide_canvas() {
Dubbo 是阿里开源的一个分布式服务框架,目的是支持高性能的远程服务调用,并且进行相关的服务治理。在 RPC 远程服务这一课时我们也介绍过 Dubbo,从功能上,Dubbo 可以对标 gRPC、Thrift 等典型的 RPC 框架。
总体架构
下面这张图包含了 Dubbo 核心组件和调用流程:
-
+
包括了下面几个角色:
- Provider,也就是服务提供者,通过 Container 容器来承载;
@@ -279,7 +279,7 @@ function hide_canvas() {
Spring Cloud 基于 Spring Boot,是一系列组件的集成,为微服务开发提供一个比较全面的解决方案,包括了服务发现功能、配置管理功能、API 网关、限流熔断组件、调用跟踪等一系列的对应实现。
总体架构
Spring Cloud 的微服务组件都有多种选择,典型的架构图如下图所示:
-
+
整体服务调用流程如下:
- 外部请求通过 API 网关,在网关层进行相关处理;
diff --git a/专栏/分布式技术原理与实战45讲-完/23 读写分离如何在业务中落地?.md.html b/专栏/分布式技术原理与实战45讲-完/23 读写分离如何在业务中落地?.md.html
index d17b32f0..d19b67f7 100644
--- a/专栏/分布式技术原理与实战45讲-完/23 读写分离如何在业务中落地?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/23 读写分离如何在业务中落地?.md.html
@@ -275,7 +275,7 @@ function hide_canvas() {
Mixed 格式,就是 Statement 与 Row 的结合,在这种方式下,不同的 SQL 操作会区别对待。比如一般的数据操作使用 row 格式保存,有些表结构的变更语句,使用 statement 来记录。
主从复制过程
MySQL 主从复制过程如下图所示:
-
+
- 主库将变更写入 binlog 日志,从库连接到主库之后,主库会创建一个log dump 线程,用于发送 bin log 的内容。
- 从库开启同步以后,会创建一个 IO 线程用来连接主库,请求主库中更新的 bin log,I/O 线程接收到主库 binlog dump 进程发来的更新之后,保存在本地 relay 日志中。
diff --git a/专栏/分布式技术原理与实战45讲-完/24 为什么需要分库分表,如何实现?.md.html b/专栏/分布式技术原理与实战45讲-完/24 为什么需要分库分表,如何实现?.md.html
index 0dd4ee26..d930b941 100644
--- a/专栏/分布式技术原理与实战45讲-完/24 为什么需要分库分表,如何实现?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/24 为什么需要分库分表,如何实现?.md.html
@@ -270,13 +270,13 @@ function hide_canvas() {
分库分表,顾名思义,就是将原本存储于单个数据库上的数据拆分到多个数据库,把原来存储在单张数据表的数据拆分到多张数据表中,实现数据切分,从而提升数据库操作性能。分库分表的实现可以分为两种方式:垂直切分和水平切分。
垂直切分
垂直拆分一般是按照业务和功能的维度进行拆分,把数据分别放到不同的数据库中。
-
+
垂直分库针对的是一个系统中对不同的业务进行拆分,根据业务维度进行数据的分离,剥离为多个数据库。比如电商网站早期,商品数据、会员数据、订单数据都是集中在一个数据库中,随着业务的发展,单库处理能力已成为瓶颈,这个时候就需要进行相关的优化,进行业务维度的拆分,分离出会员数据库、商品数据库和订单数据库等。
垂直分表是针对业务上的字段比较多的大表进行的,一般是把业务宽表中比较独立的字段,或者不常用的字段拆分到单独的数据表中。比如早期的商品表中,可能包含了商品信息、价格、库存等,可以拆分出来价格扩展表、库存扩展表等。
水平切分
水平拆分是把相同的表结构分散到不同的数据库和不同的数据表中,避免访问集中的单个数据库或者单张数据表,具体的分库和分表规则,一般是通过业务主键,进行哈希取模操作。
例如,电商业务中的订单信息访问频繁,可以将订单表分散到多个数据库中,实现分库;在每个数据库中,继续进行拆分到多个数据表中,实现分表。路由策略可以使用订单 ID 或者用户 ID,进行取模运算,路由到不同的数据库和数据表中。
-
+
分库分表后引入的问题
下面看一下,引入分库分表后额外增加了哪些系统设计的问题。
diff --git a/专栏/分布式技术原理与实战45讲-完/25 存储拆分后,如何解决唯一主键问题?.md.html b/专栏/分布式技术原理与实战45讲-完/25 存储拆分后,如何解决唯一主键问题?.md.html
index 8f052e66..04d15765 100644
--- a/专栏/分布式技术原理与实战45讲-完/25 存储拆分后,如何解决唯一主键问题?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/25 存储拆分后,如何解决唯一主键问题?.md.html
@@ -275,7 +275,7 @@ function hide_canvas() {
以 MySQL 为例,MySQL 建议使用自增 ID 作为主键,我们知道 MySQL InnoDB 引擎支持索引,底层数据结构是 B+ 树,如果主键为自增 ID 的话,那么 MySQL 可以按照磁盘的顺序去写入;如果主键是非自增 ID,在写入时需要增加很多额外的数据移动,将每次插入的数据放到合适的位置上,导致出现页分裂,降低数据写入的性能。
基于 Snowflake 算法
Snowflake 是 Twitter 开源的分布式 ID 生成算法,由 64 位的二进制数字组成,一共分为 4 部分,下面是示意图:
-
+
其中:
- 第 1 位默认不使用,作为符号位,总是 0,保证数值是正数;
diff --git a/专栏/分布式技术原理与实战45讲-完/26 分库分表以后,如何实现扩容?.md.html b/专栏/分布式技术原理与实战45讲-完/26 分库分表以后,如何实现扩容?.md.html
index 171cbf4f..7c752683 100644
--- a/专栏/分布式技术原理与实战45讲-完/26 分库分表以后,如何实现扩容?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/26 分库分表以后,如何实现扩容?.md.html
@@ -261,7 +261,7 @@ function hide_canvas() {
1. 哈希取模的方式
哈希取模是分库分表中最常见的一种方案,也就是根据不同的业务主键输入,对数据库进行取模,得到插入数据的位置。
6000 万的数据规模,我们按照单表承载百万数量级来拆分,拆分成 64 张表,进一步可以把 64 张表拆分到两个数据库中,每个库中配置 32 张表。当新订单创建时,首先生成订单 ID,对数据库个数取模,计算对应访问的数据库;接下来对数据表取模,计算路由到的数据表,当处理查询操作时,也通过同样的规则处理,这样就实现了通过订单 ID 定位到具体数据表。
-
+
规则示意图
通过哈希取模的方式进行路由,优点是数据拆分比较均匀,但缺点是不利于后面的扩容。假设我们的订单增长速度超出预估,数据规模很快达到了几亿的数量级,原先的数据表已经不满足性能要求,数据库需要继续进行拆分。
数据库拆分以后,订单库和表的数量都需要调整,路由规则也需要调整,为了适配新的分库分表规则,保证数据的读写正常,不可避免地要进行数据迁移,具体的操作,可以分为停机迁移和不停机迁移两种方式。
@@ -285,14 +285,14 @@ function hide_canvas() {
2. 基于数据范围进行拆分
基于数据范围进行路由,通常是根据特定的字段进行划分不同区间,对订单表进行拆分中,如果基于数据范围路由,可以按照订单 ID 进行范围的划分。
同样是拆分成 64 张数据表,可以把订单 ID 在 3000万 以下的数据划分到第一个订单库,3000 万以上的数据划分到第二个订单库,在每个数据库中,继续按照每张表 100万 的范围进行划分。
-
+
规则示意图
可以看到,基于数据范围进行路由的规则,当进行扩容时,可以直接增加新的存储,将新生成的数据区间映射到新添加的存储节点中,不需要进行节点之间的调整,也不需要迁移历史数据。
但是这种方式的缺点就是数据访问不均匀。如果按照这种规则,另外一个数据库在很长一段时间内都得不到应用,导致数据节点负荷不均,在极端情况下,当前热点库可能出现性能瓶颈,无法发挥分库分表带来的性能优势。
3. 结合数据范围和哈希取模
现在考虑,如果结合以上两种方式数据范围和哈希取模,那么是不是可以实现数据均匀分布,也可以更好地进行扩容?
我们设计这样的一个路由规则,首先对订单 ID 进行哈希取模,然后对取模后的数据再次进行范围分区。
-
+
订单数据库进一步拆分
可以看到,通过哈希取模结合数据区间的方式,可以比较好地平衡两种路由方案的优缺点。当数据写入时,首先通过一次取模,计算出一个数据库,然后使用订单 ID 的范围,进行二次计算,将数据分散到不同的数据表中。
这种方式避免了单纯基于数据范围可能出现的热点存储,并且在后期扩展时,可以直接增加对应的扩展表,避免了复杂的数据迁移工作。
diff --git a/专栏/分布式技术原理与实战45讲-完/27 NoSQL 数据库有哪些典型应用?.md.html b/专栏/分布式技术原理与实战45讲-完/27 NoSQL 数据库有哪些典型应用?.md.html
index 28d1c392..0ed1e874 100644
--- a/专栏/分布式技术原理与实战45讲-完/27 NoSQL 数据库有哪些典型应用?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/27 NoSQL 数据库有哪些典型应用?.md.html
@@ -256,7 +256,7 @@ function hide_canvas() {
关系型数据库通过关系模型来组织数据,在关系型数据库当中一个表就是一个模型,一个关系数据库可以包含多个表,不同数据表之间的联系反映了关系约束。
不知道你是否应用过 ER 图?在早期的软件工程中,数据表的创建都会通过 ER 图来定义,ER 图(Entity Relationship Diagram)称为实体-联系图,包括实体、属性和关系三个核心部分。
下面是在电商领域中,一个简化的会员、商品和订单的 ER 图:
-
+
简化版的会员、商品和订单 ER 图
ER图中的实体采用矩形表示,即数据模型中的数据对象,例如电商业务模型中的会员、商品、订单等,每个数据对象具有不同的属性,比如会员有账户名、电话、地址等,商品有商品名称、价格、库存等属性。不同的数据对象之间又对应不同的关系,比如会员购买商品、创建订单。
有了 ER 图等的辅助设计,关系型数据库的数据模型可以非常好的描述物理世界,比较方便地创建各种数据约束。
diff --git a/专栏/分布式技术原理与实战45讲-完/28 ElasticSearch 是如何建立索引的?.md.html b/专栏/分布式技术原理与实战45讲-完/28 ElasticSearch 是如何建立索引的?.md.html
index c0ba2999..c9b9e2a0 100644
--- a/专栏/分布式技术原理与实战45讲-完/28 ElasticSearch 是如何建立索引的?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/28 ElasticSearch 是如何建立索引的?.md.html
@@ -260,7 +260,7 @@ function hide_canvas() {
ElasticSearch 应用
ElasticSearch 对搜索的支持非常好,但是和 NoSQL 数据库一样,对事务、一致性等的支持较低。
下面是一个实际开发中,常见的数据库-索引-缓存系统架构图:
-
+
可以看到,ElasticSearch 一般是作为持久性数据库的辅助存储,是和 SQL & NoSQL 数据库一起使用,对外提供索引查询功能。关系型数据库保证数据更新的准确性,在关系型数据库更新以后,通过 binlog 同步结合消息队列分发的方式,来更新文件索引,提供一致性保证。
ELK stack
ElasticSearch 是由 Elastic 公司创建的,除了 ElasticSearch,Elastic 公司还有另外两款产品,分别是 Logstash 及 Kibana 开源项目,这三个开源项目组合在一起称为 ELK stack。
@@ -326,7 +326,7 @@ Good / / friends / / should / / help / / each / / other / .
具体到数据结构的实现,可以通过实现一个字典树,也就是 Trie 树,对字典树进行扩展,额外存储对应的数据块地址,定位到具体的数据位置。
-
+
对比 B+ 树
MySQL InnoDB 引擎的索引实现是基于 B+ 树,那么同样是索引,倒排索引和 B+ 树索引有哪些区别呢?
严格地说,这两类索引是不能在一起比较的,B+ 树描述的是索引的数据结构,而倒排索引是通过索引的组织形式来命名的。比如我们上面的例子中,倒排指的是关键词和文档列表的结构关系。
diff --git a/专栏/分布式技术原理与实战45讲-完/30 消息队列有哪些应用场景?.md.html b/专栏/分布式技术原理与实战45讲-完/30 消息队列有哪些应用场景?.md.html
index c05eb0f7..4f8f4945 100644
--- a/专栏/分布式技术原理与实战45讲-完/30 消息队列有哪些应用场景?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/30 消息队列有哪些应用场景?.md.html
@@ -254,13 +254,13 @@ function hide_canvas() {
什么是消息队列
消息队列,顾名思义,就是传递消息的队列,学习操作系统中进程通信的时候我们知道,消息队列是进程之间的一种很重要的通信机制。随着分布式系统的发展,消息队列在系统设计中又有了更多的应用。
参与消息传递的双方称为生产者和消费者,生产者和消费者可以只有一个实例,也可以集群部署,典型架构如下图所示:
-
+
其中消息体是参与生产和消费两方传递的数据,消息格式既可以是简单的字符串,也可以是序列化后的复杂文档信息。队列是消息的载体,用于传输和保存消息,它和数据结构中的队列一样,可以支持先进先出、优先级队列等不同的特性。
消息队列有哪些应用
消息队列可以用于系统内部组件之间的通信,也可以用于系统跟其他服务之间的交互,消息队列的使用,增加了系统的可扩展性。下面把消息队列的应用归纳为以下几点。
系统解耦
设计模式中有一个开闭原则,指的是软件实体应该对扩展开放、对修改关闭,尽量保持系统之间的独立,这里面蕴含的是解耦思想。而消息队列的使用,可以认为是在系统中隐含地加入了一个对外的扩展接口,能够方便地对业务进行解耦,调用方只需要发送消息而不用关注下游逻辑如何执行。
-
+
那你可能会有疑问,系统之间的解耦,使用 RPC 服务调用也可以实现,使用消息队列有什么好处吗?使用远程服务调用,需要在其中一个调用方进行显式地编码业务逻辑;如果使用消息队列就不会有这个问题了,系统之间可以更好地实现依赖倒转,这也是设计模式中的一个重要原则。
异步处理
异步化是一个非常重要的机制,在处理高并发、高可用等系统设计时,如果不需要或者限制于系统承载能力,不能立即处理消息,此时就可以应用消息队列,将请求异步化。
diff --git a/专栏/分布式技术原理与实战45讲-完/31 集群消费和广播消费有什么区别?.md.html b/专栏/分布式技术原理与实战45讲-完/31 集群消费和广播消费有什么区别?.md.html
index 1431a336..03a3beed 100644
--- a/专栏/分布式技术原理与实战45讲-完/31 集群消费和广播消费有什么区别?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/31 集群消费和广播消费有什么区别?.md.html
@@ -255,20 +255,20 @@ function hide_canvas() {
先来看一下消息队列的两种基础模型,也就是点对点和发布订阅方式。
这两种模型来源于消息队列的 JMS 实现标准,消息队列有不同的实现标准,比如 AMQP 和 JMS,其中 JMS(Java Message Service)是 Java 语言平台的一个消息队列规范,上一课时中讲过的 ActiveMQ 就是其典型实现。
AMQP 和 JMS 的区别是,AMQP 额外引入了 Exchange 的 Binding 的角色,生产者首先将消息发送给 Exchange,经过 Binding 分发给不同的队列。
-
+
和 JMS 一样,AMQP 也定义了几种不同的消息模型,包括 direct exchange、topic change、headers exchange、system exchange 等。其中 direct exchange 可以类比点对点,其他的模型可以类比发布订阅,这里不做展开介绍了,具体可参考 AMPQ 的其他资料查阅。
点到点模型
在点对点模型下,生产者向一个特定的队列发布消息,消费者从该队列中读取消息,每条消息只会被一个消费者处理。
-
+
发布/订阅模型
大部分人在浏览资讯网站时会订阅喜欢的频道,比如人文社科,或者娱乐新闻,消息队列的发布订阅也是这种机制。在发布订阅模型中,消费者通过一个 Topic 来订阅消息,生产者将消息发布到指定的队列中。如果存在多个消费者,那么一条消息就会被多个消费者都消费一次。
-
+
点对点模型和发布订阅模型,主要区别是消息能否被多次消费,发布订阅模型实现的是广播机制。如果只有一个消费者,则可以认为是点对点模型的一个特例。
现代消息队列基本都支持上面的两种消费模型,但由于消息队列自身的一些特性,以及不同的应用场景,具体实现上还有许多的区别。下面看一下几种代表性的消息队列。
Kafka 的消费模式
先来看一下 Kafka,在分析 Kafka 消费模式之前,先来了解一下 Kafka 的应用设计。
Kafka 系统中的角色可以分为以下几种:
-
+
- Producer:消息生产者,负责发布消息到 broker。
- Consumer:消息消费者,从 broker 中读取消息。
@@ -280,7 +280,7 @@ function hide_canvas() {
从上面的分析中可以看到,Kafka 的消费是基于 Topic 的,属于发布订阅机制,它会持久化消息,消息消费完后不会立即删除,会保留历史消息,可以比较好地支持多消费者订阅。
RocketMQ 的消费模式
RocketMQ 实现的也是典型的发布订阅模型,在细节上和 Kafka 又有一些区别。RocketMQ 的系统设计主要由 NameServer、Broker、Producer 及 Consumer 几部分构成。
-
+
NameServer 在 RocketMQ 集群中作为节点的路由中心,可以管理 Broker 集群,以及节点间的通信,在后面的消息队列高可用课时,我会进一步分析集群下的高可用实现。
具体的消费模式中,RocketMQ 和 Kafka 类似,除了 Producer 和 Consumer,主要分为 Message、Topic、Queue 及 ConsumerGroup 这几部分,同时,RocketMQ 额外支持 Tag 类型的划分。
@@ -292,7 +292,7 @@ function hide_canvas() {
在上一课时中提过, Kafka 使用 Scala 实现、RabbitMQ 使用 Erlang 实现,而 RokcetMQ 是使用 Java 语言实现的。从编程语言的角度,RocketMQ 的源码学习起来比较方便,也推荐你看一下 RokcetMQ 的源码,点击这里查看源码。
RocketMQ 的消费模式分为集群消费和广播消费两种,默认是集群消费。那么,在 RocketMQ 中这两种模式有什么区别呢?
集群消费实现了对点对点模型的扩展,任意一条消息只需要被集群内的任意一个消费者处理即可,同一个消费组下的各个消费端,会使用负载均衡的方式消费。对应 Topic 下的信息,集群消费模式的示意图如下。
-
+
广播消费实现的是发布订阅模式,发送到消费组中的消息,会被多个消费者分别处理一次。在集群消费中,为了将消息分发给消费组中的多个实例,需要实现消息的路由,也就是我们常说的负载均衡,在 RocketMQ 中,支持多种负载均衡的策略,主要包括以下几种:
- 平均分配策略,默认的策略
@@ -302,7 +302,7 @@ function hide_canvas() {
- 一致性哈希分配策略
以上的几种策略,可以在 RocketMQ 的源码中 AllocateMessageQueueStrategy 接口相关的实现中:
-
+
总结
这一课时分析了消息队列中的两种消息模型,以及不同消息模型在 Kafka 和 RocketMQ 等消息队列中的具体实现。
消息模型的概念是分布式消息的基础知识,不同的消息模型会影响消息队列的设计,进而影响消息队列在消息一致性、时序性,以及传输可靠性上的实现方式。了解了这些,才能更好地展开关于消息队列各种特性的讨论。
diff --git a/专栏/分布式技术原理与实战45讲-完/34 高可用:如何实现消息队列的 HA?.md.html b/专栏/分布式技术原理与实战45讲-完/34 高可用:如何实现消息队列的 HA?.md.html
index ab123a52..b8fcef4a 100644
--- a/专栏/分布式技术原理与实战45讲-完/34 高可用:如何实现消息队列的 HA?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/34 高可用:如何实现消息队列的 HA?.md.html
@@ -259,7 +259,7 @@ function hide_canvas() {
Kafka 的高可用实现主要依赖副本机制,我把 Kakfa 的高可用,拆分成几个小问题来讲解,一来是为了更好地理解,二来很多细节问题也可能出现在面试中,方便你更好地掌握。
Broker 和 Partition 的关系
在分析副本机制之前,先来看一下 Broker 和 Partition 之间的关系。Broker 在英文中是代理、经纪人的意思,对应到 Kafka 集群中,是一个 Kafka 服务器节点,Kafka 集群由多个 Broker 组成,也就是对应多个 Kafka 节点。
-
+
Kafka 是典型的发布订阅模式,存在 Topic 的概念,一个 Broker 可以容纳多个 Topic,也就是一台服务器可以传输多个 Topic 数据。
不过 Topic 是一个逻辑概念,和物理上如何存储无关,Kafka 为了实现可扩展性,将一个 Topic 分散到多个 Partition 中,这里的 Partition 就是一个物理概念,对应的是具体某个 Broker 上的磁盘文件。
从 Partition 的角度,Kafka 保证消息在 Partition 内部有序,所以 Partition 是一段连续的存储,不能跨多个 Broker 存在,如果是在同一个 Broker 上,也不能挂载到多个磁盘。从 Broker 的角度,一个 Broker 可以有多个 Topic,对应多个 Partition。
@@ -270,7 +270,7 @@ function hide_canvas() {
假设现在有一个订单的 Topic,配置分区数为 3,如果配置 replication-factor 为 3,那么对应的有三个分区,每个分区都有 3 个副本,在有多个副本的情况下,不同副本之间如何分工呢?
每个分区下配置多个副本,多个副本之间为了协调,就必须有一定的同步机制。Kafka 中同一个分区下的不同副本,有不同的角色关系,分为 Leader Replication 和 Follower Replication。Leader 负责处理所有 Producer、Consumer 的请求,进行读写处理,Follower 作为数据备份,不处理来自客户端的请求。
Follower 不接受读写请求,那么数据来自哪里呢?它会通过 Fetch Request 方式,拉取 Leader 副本的数据进行同步。
-
+
Fetch 这个词一般用于批量拉取场景,比如使用 Git 进行版本管理的 fetch 命令,在 Kafka 中,会为数据同步开辟一个单独的线程,称为 ReplicaFetcherThread,该线程会主动从 Leader 批量拉取数据,这样可以高性能的实现数据同步。
Replication 分配有哪些约定
Kafka 中分区副本数的配置,既要考虑提高系统可用性,又要尽量减少机器资源浪费。
diff --git a/专栏/分布式技术原理与实战45讲-完/36 消息队列选型:RocketMQ 适用哪些场景?.md.html b/专栏/分布式技术原理与实战45讲-完/36 消息队列选型:RocketMQ 适用哪些场景?.md.html
index 0209ab5e..848c3d4d 100644
--- a/专栏/分布式技术原理与实战45讲-完/36 消息队列选型:RocketMQ 适用哪些场景?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/36 消息队列选型:RocketMQ 适用哪些场景?.md.html
@@ -255,7 +255,7 @@ function hide_canvas() {
RocketMQ 应用
RocketMQ 在阿里巴巴被大规模应用,其前身是淘宝的 MetaQ,后来改名为 RocketMQ,并加入了 Apache 基金会。RocketMQ 基于高可用分布式集群技术,提供低延时、高可靠的消息发布与订阅服务。
RocketMQ 整体设计和其他的 MQ 类似,除了 Producer、Consumer,还有 NameServer 和 Broker。
-
+
NameServer 存储了 Topic 和 Broker 的信息,主要功能是管理 Broker,以及进行消费的路由信息管理。
在服务器启动时,节点会注册到 NameServer 上,通过心跳保持连接,并记录各个节点的存活状态;除此之外,NameServer 还记录了生产者和消费者的请求信息,结合消息队列的节点信息,实现消息投递的负载均衡等功能。
RocketMQ 的 Broker 和 Kafka 类似,Broker 是消息存储的承载,作为客户端请求的入口,可以管理生产者和消费者的消费情况。
@@ -272,7 +272,7 @@ function hide_canvas() {
比如电商中的订单信息,订单信息在用户端的展示是通过 ElasticSearch 等文件索引实现的。在订单状态修改后,需要实时同步修改,但一般业务中不会直接操作文件索引,那如何同步数据呢?
业务数据被分散在不同的存储中,就一定要考虑数据一致性,一个典型的解决方案是基于 Binlog 的数据同步。
使用 RocketMQ 实现 Binlog 数据同步,有一个成熟的方案,那就是 RocketMQ 结合阿里的 Canal。Canal 是阿里巴巴开源的数据库组件,可以基于 MySQL 数据库进行增量日志解析,实现增量数据订阅和消费,目前已经在很多大公司中应用。
-
+
Canal 的实现原理特别巧妙。不知道你有没有看过谍战题材的影片,比如 007 系列。Canal 在这里就好像一个伪装的特工,它模拟 MySQL Slave 的交互协议,把自己作为 MySQL 主从同步中的一个从节点,拉取 Binlog 日志信息,然后进行分发。
Canal 和 RokcetMQ 都是阿里巴巴开源的组件,并且都在阿里云上实现了商业化,二者的集成也是顺其自然的。在 Canal 中已经内置了对 RocketMQ 的支持,支持开箱即用的配置方式。
除此之外,Canal 的解决方案还包括一个可视化界面,该界面可以进行动态管理,配置 RocketMQ 集群。如果你在调研 Binlog 数据同步机制,并且自己所在的团队又没有大量的人力进行支持,那可以了解一下这个解决方案。
diff --git a/专栏/分布式技术原理与实战45讲-完/38 不止业务缓存,分布式系统中还有哪些缓存?.md.html b/专栏/分布式技术原理与实战45讲-完/38 不止业务缓存,分布式系统中还有哪些缓存?.md.html
index 7e058551..4113a2b1 100644
--- a/专栏/分布式技术原理与实战45讲-完/38 不止业务缓存,分布式系统中还有哪些缓存?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/38 不止业务缓存,分布式系统中还有哪些缓存?.md.html
@@ -254,7 +254,7 @@ function hide_canvas() {
缓存有哪些分类
如果你是做业务开发的话,提起缓存首先想到的应该是应用 Redis,或者 Memcached 等服务端缓存,其实这些在缓存分类中只是一小部分。然而在整个业务流程中,从前端 Web 请求,到网络传输,再到服务端和数据库服务,各个阶段都有缓存的应用。
以电商业务场景为例,如果你打开淘宝或者京东,查看一个商品详情页,这个过程就涉及多种缓存的协同,我们从页面入口开始梳理一下,如下图所示。
-
+
前端缓存
前端缓存包括页面和浏览器缓存,如果你使用的是 App,那么在 App 端也会有缓存。当你打开商品详情页,除了首次打开以外,后面重复刷新时,页面上加载的信息来自多种缓存。
页面缓存属于客户端缓存的一种,在第一次访问时,页面缓存将浏览器渲染的页面存储在本地,当用户再次访问相同的页面时,可以不发送网络连接,直接展示缓存的内容,以提升整体性能。
diff --git a/专栏/分布式技术原理与实战45讲-完/39 如何避免缓存穿透、缓存击穿、缓存雪崩?.md.html b/专栏/分布式技术原理与实战45讲-完/39 如何避免缓存穿透、缓存击穿、缓存雪崩?.md.html
index 5b9de9ce..61271b23 100644
--- a/专栏/分布式技术原理与实战45讲-完/39 如何避免缓存穿透、缓存击穿、缓存雪崩?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/39 如何避免缓存穿透、缓存击穿、缓存雪崩?.md.html
@@ -253,7 +253,7 @@ function hide_canvas() {
设计缓存系统不得不考虑的问题是缓存穿透、缓存击穿与失效时的雪崩效应,同时,关于这几种问题场景的认识及解决方案,也是面试中的高频考点。今天的内容,可以说是缓存应用的三板斧,下面我们一起来分析一下缓存应用中的这几个热门问题。
缓存穿透
先来看一下缓存穿透,顾名思义,是指业务请求穿过了缓存层,落到持久化存储上。在大多数场景下,我们应用缓存是为了承载前端业务请求,缓存被击穿以后,如果请求量比较大,则会导致数据库出现风险。
-
+
以双十一为例,由于各类促销活动的叠加,整体网站的访问量、商品曝光量会是平时的千倍甚至万倍。巨大的流量暴涨,单靠数据库是不能承载的,如果缓存不能很好的工作,可能会影响数据库的稳定性,继而直接影响整体服务。
那么哪些场景下会发生缓存穿透呢?
@@ -268,7 +268,7 @@ function hide_canvas() {
缓存穿透还可以通过缓存空数据的方式避免。缓存空数据非常好理解,就是针对数据库不存在的数据,在查询为空时,添加一个对应 null 的值到缓存中,这样在下次请求时,可以通过缓存的结果判断数据库中是否存在,避免反复的请求数据库。不过这种方式,需要考虑空数据的 Key 在新增后的处理,感兴趣的同学可以思考一下。
另外一个方案是使用布隆过滤器。布隆过滤器是应用非常广泛的一种数据结构,我们熟悉的 Bitmap,可以看作是一种特殊的布隆过滤器,布隆过滤器的实现细节不是本课时关注的重点,如果你对布隆过滤器还不熟悉,可以抽空查阅数据结构相关的资料学习。
使用布隆过滤器,可在缓存前添加一层过滤,布隆过滤器映射到缓存,在缓存中不存在的数据,会在布隆过滤器这一层拦截,从而保护缓存和数据库的安全。
-
+
缓存击穿
缓存击穿也是缓存应用常见的问题场景,其是一个非常形象的表达。具体表现:前端请求大量的访问某个热点 Key,而这个热点 Key 在某个时刻恰好失效,导致请求全部落到数据库上。
不知道你有没有听过二八定律(80/20 定律、帕累托法则),百度百科中对二八定律的具体描述是这样的:
@@ -284,7 +284,7 @@ function hide_canvas() {
在业务开发中,出现缓存雪崩非常危险,可能会直接导致大规模服务不可用,因为缓存失效时导致的雪崩,一方面是整体的数据存储链路,另一方面是服务调用链路,最终导致微服务整体的对外服务出现问题。
我们知道,微服务本身就存在雪崩效应,在电商场景中,如果商品服务不可用,最终可能会导致依赖的订单服务、购物车服务、用户浏览等级联出现故障。
你考虑一下,如果商品服务出现缓存雪崩,继而商品服务不可用,关联的周边服务都会受影响。
-
+
那么缓存雪崩在业务中如何避免呢?
首先是明确缓存集群的容量峰值,通过合理的限流和降级,防止大量请求直接拖垮缓存;其次是做好缓存集群的高可用,以 Redis 为例,可以通过部署 RedisCluster、Proxy 等不同的缓存集群,来实现缓存集群高可用。
缓存稳定性
diff --git a/专栏/分布式技术原理与实战45讲-完/40 经典问题:先更新数据库,还是先更新缓存?.md.html b/专栏/分布式技术原理与实战45讲-完/40 经典问题:先更新数据库,还是先更新缓存?.md.html
index 47376c88..c1f8c720 100644
--- a/专栏/分布式技术原理与实战45讲-完/40 经典问题:先更新数据库,还是先更新缓存?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/40 经典问题:先更新数据库,还是先更新缓存?.md.html
@@ -253,7 +253,7 @@ function hide_canvas() {
上一课时分享了缓存使用中的几个问题场景:缓存穿透、缓存击穿和缓存雪崩,这几个问题聚焦的是缓存本身的稳定性,包括缓存集群和缓存的数据,除了这些,缓存应用中,缓存和上下游系统的数据同步也很重要。这一课时,我们来学习缓存应用中的另一个高频问题:应用缓存以后,缓存和数据库何时同步。
数据不一致问题
我们知道,除了少部分配置信息类缓存,比如业务中的黑白名单信息、页面展示配置等,大部分缓存应用一般是作为前端请求和持久化存储的中间层,承担前端的海量请求。
-
+
缓存层和数据库存储层是独立的系统,我们在数据更新的时候,最理想的情况当然是缓存和数据库同时更新成功。但是由于缓存和数据库是分开的,无法做到原子性的同时进行数据修改,可能出现缓存更新失败,或者数据库更新失败的情况,这时候会出现数据不一致,影响前端业务。
以电商中的商品服务为例,针对 C 端用户的大部分请求都是通过缓存来承载的,假设某次更新操作将商品详情 A 的价格从 1000 元更新为 1200 元,数据库更新成功,但是缓存更新失败。这时候就会出现 C 端用户在查看商品详情时,看到的还是 1000 元,实际下单时可能是别的价格,最终会影响用户的购买决策,影响平台的购物体验。
可以看到,在使用缓存时,如果不能很好地控制缓存和数据库的一致性,可能会出现非常多的业务问题。
diff --git a/专栏/分布式技术原理与实战45讲-完/43 缓存高可用:缓存如何保证高可用?.md.html b/专栏/分布式技术原理与实战45讲-完/43 缓存高可用:缓存如何保证高可用?.md.html
index c0f7f5d4..6118118e 100644
--- a/专栏/分布式技术原理与实战45讲-完/43 缓存高可用:缓存如何保证高可用?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/43 缓存高可用:缓存如何保证高可用?.md.html
@@ -273,13 +273,13 @@ function hide_canvas() {
Sentinel 在操作故障节点的上下线时,还会通知上游的业务方,整个过程不需要人工干预,可以自动执行。
Redis Cluster 集群
Redis Cluster 是官方的集群方案,是一种无中心的架构,可以整体对外提供服务。
-
+
为什么是无中心呢?因为在 Redis Cluster 集群中,所有 Redis 节点都可以对外提供服务,包括路由分片、负载信息、节点状态维护等所有功能都在 Redis Cluster 中实现。
Redis 各实例间通过 Gossip 通信,这样设计的好处是架构清晰、依赖组件少,方便横向扩展,有资料介绍 Redis Cluster 集群可以扩展到 1000 个以上的节点。
Redis Cluster 另外一个好处是客户端直接连接服务器,避免了各种 Proxy 中的性能损耗,可以最大限度的保证读写性能。
除了 Redis Cluster,另外一个应用比较多的是 Codis 方案,Codis 是国内开源的一个 Redis 集群方案,其作者是个大牛,也是一位技术创业者,不知道你有没有听过最近几年比较火的分布式关系型数据库 TiDB,就来自于作者的公司 PingCAP。
Codis 的实现和 Redis Cluster 不同,是一个“中心化的结构”,同时添加了 Codis Proxy 和 Codis Manager。Codis 设计中,是在 Proxy 中实现路由、数据分片等逻辑,Redis 集群作为底层的存储引擎,另外通过 ZooKeeper 维护节点状态,可以参考下面这张 Codis 的官方架构图:
-
+
之所以提到 Codis,是因为 Codis 和官方的 Redis Cluster 实现思路截然不同,使用 Redis Cluster 方式,数据不经过 Proxy 层,直接访问到对应的节点。
Codis 和 Redis Cluster 的集群细节比较复杂,这里不展开讨论,只要简单了解即可,你也可以在课后分别去官网深入了解。就我自己而言,Codis 的监控和数据迁移更加简便,感觉 Codis 的设计更加合理,不过也是见仁见智,欢迎分享你的思考。
Redis Cluster 划分了 16384 个槽位,每个节点负责其中的一部分数据,都会存储槽位的信息,当客户端链接时,会获得槽位信息。如果需要访问某个具体的数据 Key,就可以根据本地的槽位来确定需要连接的节点。
diff --git a/专栏/分布式技术原理与实战45讲-完/49 线上服务有哪些稳定性指标?.md.html b/专栏/分布式技术原理与实战45讲-完/49 线上服务有哪些稳定性指标?.md.html
index 8afb2346..ec11fcc4 100644
--- a/专栏/分布式技术原理与实战45讲-完/49 线上服务有哪些稳定性指标?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/49 线上服务有哪些稳定性指标?.md.html
@@ -256,7 +256,7 @@ function hide_canvas() {
听了这位朋友的感受,不知道你是否也有过类似的经历,系统监控等稳定性工作,看似离业务开发有点远,但其实是非常重要的,系统监控做得不好,开发人员需要花很多的时间去定位问题,而且很容易出现比较大的系统故障,所以越是在大公司里,对监控的重视程度就越高。
各种监控指标可以帮助我们了解服务运行水平,提前发现线上问题,避免小故障因为处理不及时,变成大故障,从而解放工程师的人力,我在之前的工作中,曾经专门做过一段时间的稳定性工作,现在把自己的一些经验分享给你。
在实际操作中,系统监控可以分为三个方面,分别是监控组件、监控指标、监控处理,在这一课时呢,我先和大家一起梳理下监控指标相关的知识,在接下来的第 44 课时,我将分享常用的监控组件,以及监控报警处理制度。
-
+
稳定性指标有哪些
稳定性指标,这里我按照自己的习惯,把它分为服务器指标、系统运行指标、基础组件指标和业务运行时指标。
每个分类下面我选择了部分比较有代表性的监控项,如果你还希望了解更多的监控指标,可以参考 Open-Falcon 的监控采集,地址为 Linux 运维基础采集项。
diff --git a/专栏/分布式技术原理与实战45讲-完/50 分布式下有哪些好用的监控组件?.md.html b/专栏/分布式技术原理与实战45讲-完/50 分布式下有哪些好用的监控组件?.md.html
index a6c48ee2..c3d997aa 100644
--- a/专栏/分布式技术原理与实战45讲-完/50 分布式下有哪些好用的监控组件?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/50 分布式下有哪些好用的监控组件?.md.html
@@ -262,7 +262,7 @@ function hide_canvas() {
如果希望了解更多具体的应用,还可以去 Zabbix 官网了解相关的内容:ZABBIX 产品手册。
Nagios
Nagios(Nagios Ain’t Goona Insist on Saintood)是一款开源监控组件,和 Zabbix 等相比,Nagios 支持更丰富的监控设备,包括各类网络设备和服务器,并且对不同的操作系统都可以进行良好的兼容,支持 Windows 、Linux、VMware 和 Unix 的主机,另外对各类交换机、路由器等都有很好的支持。
-
+
Nagios 对各类网络协议下的监控支持非常好,我们在第 42 课时提过硬件负载均衡的 F5 设备,就可以应用 Nagios 进行监控。
Nagios 虽然监控报警能力强大,但是配置比较复杂,各种功能都要依靠插件来实现,图形展示效果很差。从这个角度来看,Nagios 的应用更加偏向运维,大部分业务开发同学在工作中简单了解就可以。
Nagios 还可以监控网络服务,包括 SMTP、POP3、HTTP、NNTP、PING 等,支持主机运行状态、自定义服务检查,可以进行系统状态和故障历史的查看,另外,使用 Nagios 可以自定义各种插件实现定制化的功能。感兴趣的同学可点击这里查看官网了解一下。
diff --git a/专栏/分布式技术原理与实战45讲-完/51 分布式下如何实现统一日志系统?.md.html b/专栏/分布式技术原理与实战45讲-完/51 分布式下如何实现统一日志系统?.md.html
index 6fb0990e..65bba389 100644
--- a/专栏/分布式技术原理与实战45讲-完/51 分布式下如何实现统一日志系统?.md.html
+++ b/专栏/分布式技术原理与实战45讲-完/51 分布式下如何实现统一日志系统?.md.html
@@ -299,11 +299,11 @@ tail -fn 1000 test.log | grep 'test'
我在之前的工作中,曾经负责搭建了业务系统的 ELK 日志系统,在[第 25 课时]我们介绍 ElasticSearch 技术栈时中曾经提到过 ELK Stack,就是下面要说的 ELK(ElasticSearch Logstash Kibana)日志收集系统。
ElasticSearch 内核使用 Lucene 实现,实现了一套用于搜索的 API,可以实现各种定制化的检索功能,和大多数搜索系统一样,ElasticSearch 使用了倒排索引实现,我们在第 25 课时中有过介绍,你可以温习一下。
Logstash 同样是 ElasticSearch 公司的产品,用来做数据源的收集,是 ELK 中日志收集的组件。
-
+
Logstash 的数据流图如上图所示,你可以把 Logstash 想象成一个通用的输入和输出接口,定义了多种输入和输出源,可以把日志收集到多种文件存储中,输出源除了 ElasticSearch,还可以是 MySQL、Redis、Kakfa、HDFS、Lucene 等。
Kibana 其实就是一个在 ElasticSearch 之上封装了一个可视化的界面,但是 Kibana 实现的不只是可视化的查询,还针对实际业务场景,提供了多种数据分析功能,支持各种日志数据聚合的操作。
ELK 系统进行日志收集的过程可以分为三个环节,如下图所示:
-
·
+
·
- 使用 Logstash 日志收集,导入 ElasticSearch
diff --git a/专栏/分布式链路追踪实战-完/01 数据观测:数据追踪的基石从哪里来?.md.html b/专栏/分布式链路追踪实战-完/01 数据观测:数据追踪的基石从哪里来?.md.html
index 96adc8f2..861e7519 100644
--- a/专栏/分布式链路追踪实战-完/01 数据观测:数据追踪的基石从哪里来?.md.html
+++ b/专栏/分布式链路追踪实战-完/01 数据观测:数据追踪的基石从哪里来?.md.html
@@ -201,12 +201,12 @@ function hide_canvas() {
日志(Logging)
日志是系统中的常见功能,我们前面说的数据来源的各个部分都有可能产生日志。日志一般的描述是:在特定时间发生的事件,被以结构化的形式记录并产生的文本数据。
日志可以为我们展现系统在任意时间的运行状态,又因为它是结构化的文本,所以我们很容易通过某种格式来进行检索,比如下图就是对 7 月 24 日用户支付下单操作的记录:
-
+
由于日志是最容易生成的,如果它大量地输出,会占据比较大的存储空间,进而影响整个应用程序的性能,比如 Java 中 logback 的日志框架,就算使用了异步线程来执行,也会对磁盘和 I/O 的使用率造成影响。
当然,也有一部分系统是利用日志可追溯、结构化的特点,来实现相关功能的,比如我们最常见的 WAL(Write-Ahead Logging)。WAL 就是在操作之前先进行日志写入,再执行操作;如果没有执行操作,那么在下次启动时就可以通过日志中结构化的,有时间标记的信息恢复操作,其中最典型的就是 MySQL 中的 Redo log。
统计指标(Metrics)
统计指标也是我们经常使用的。它是一种可累加的聚合的数值结果,具有原子性。因此,我们可以通过各种数学计算方式来获取一段时间内的数值。
-
+
统计指标针对数据的存储、处理、压缩和检索进行了优化,所以一般可以长期存储并以很简单的方式(聚合)查询。但由于涉及数据的处理(数学计算方式)和压缩,所以它也会占用一定的 CPU 资源。
统计指标是一个压缩后的数值,因此如果指标出现异常,我们很难得知是什么原因导致的异常。此外,如果我们使用了一个高基数的指标来作为统计的维度,那么统计就很容易给机器带来高性能损耗,比如,在基于用户 ID 的维度去做数据统计时,因为在统计的时候需要一段时间范围,如果数据过多就必然会造成内存上的占用。
讲到这里,你应该对指标有了一定的认识。我们后端经常说的 QPS、TPS、SLA 都是计算后得到的指标;基础设施中的 CPU 使用率、负载情况也可以认为是指标。
@@ -225,7 +225,7 @@ function hide_canvas() {
根据这 2 个级别,我们可以对上面的 3 个内容加以细化,其中链路追踪是请求级别,因为它和每个请求都挂钩;日志和统计指标可以是请求级别,也可以是聚合级别,因为它们可能是真实的请求,也可能是系统在对自身诊断时记录下来的信息。
那么当它们两两组合之后又是什么关系呢?我们可以从下图中看到:
-上一课时我对数据的来源做了简单的讲解,在对可观测性的 3 个核心概念的介绍中,我首先提到的就是日志。我们知道,在应用程序、端上和传输系统中,日志无处不在。因此,这节课我将带你了解,日志为什么会是保障系统稳定性的关键。
日志可以记录系统中硬件、软件和系统的信息,同时还可以观测系统中发生的事件。用户可以通过它来检查错误发生的原因。如下图:
-一般来说,日志具有以下几个功能:
日志级别,这是一个为人熟知的概念。尽管大家都了解它,但我还是发现很多开发人员在用法上存在一些问题。这里我会按照从低到高的顺序,介绍其中比较关键的 4 个日志等级,同时也会指出大家在用法上存在的问题并给出我的理解。
-查询问题的原因时,如果实在找不到,你可以按照一定的顺序对日志逐一排查,说不定就找到原因了。找问题原因的过程其实是一个不断否定、不断排除的过程,排除了所有的不可能,剩下的就是真相。
所以,在这一课时的最后,我会介绍一些常见的日志的来源,以方便你在需要逐层检索的时候,有一个整体的概念。
与我们在 01 课时介绍监控数据的来源时一样,日志的来源也可以按照用户端到服务端来划分。如下图所示:
-这里的终端层我指的是像网页、App、小程序这样的形式。在这一层的所有日志信息都不在我们的服务器端,而是在用户的电脑、手机中。所以我们想要收集的话,一般是通过打点的形式上传到后端服务,再记录下来。
终端层更偏向用户的真实操作行为和一些异常信息的记录,比如用户当前的网络环境、系统状态、手机型号等。通过观察这部分数据,我们可以看出是哪一类用户在操作时容易产生问题,这也更加方便后端和终端的研发人员定位问题。
diff --git a/专栏/分布式链路追踪实战-完/03 日志编写:怎样才能编写“可观测”的系统日志?.md.html b/专栏/分布式链路追踪实战-完/03 日志编写:怎样才能编写“可观测”的系统日志?.md.html index 72ff6ead..7678605d 100644 --- a/专栏/分布式链路追踪实战-完/03 日志编写:怎样才能编写“可观测”的系统日志?.md.html +++ b/专栏/分布式链路追踪实战-完/03 日志编写:怎样才能编写“可观测”的系统日志?.md.html @@ -159,7 +159,7 @@ function hide_canvas() {在 02 课时,我带你重新认识了系统日志,介绍了日志在系统中的重要性。既然日志如此重要,那我们应该如何编写它呢?
这一节,我将带你从编写日志的工具、编写日志的方式,以及日志编写后的管理,就像是购物的售前、售中、售后,这 3 个方面来讲解,怎么样才可以写出更具有“可观测性”的日志内容。
-在编写日志之前,咱们先来了解一下有哪些日志框架可以协助我们编写日志。
在介绍日志框架之前,我需要说明一下,如果你仍在使用 System.out.println、Exception.printStackTrace 或类似的控制台输出日志的方式,我推荐你改用第三方日志框架编写。这种控制台输出的方式,可以从它们的源码了解到它们是线程同步的,大量使用这种方式,会对程序性能造成严重的影响,因为它们同一时间只能有一个线程在进行执行。
@@ -179,7 +179,7 @@ function hide_canvas() {由此就出现了面向接口的日志框架,它提供了统一的 API。开发人员在编写代码的时候,直接使用这套面向接口的日志框架,当业务项目人员在使用时,只需要选择好实现框架,就可以统一日志实现框架。
-目前使用最为广泛的日志接口框架是 SLF4J,出自 logback 的开发者,目前基本已经形成规范。SLF4J 提供了动态占位符的功能,大大提高了程序的性能,无须开发人员再对参数信息进行拼接。
比如默认情况下程序是 info 级别的,在原先的代码方式中想要进行日志输出需要自行拼接字符串:
logger.debug("用户" + userId + "开始下单:" + orderNo + ",请求信息:" + Gson.toJson(req));
@@ -198,7 +198,7 @@ function hide_canvas() {
- 日志开发时(前 5 项):怎么样写出更有效率的日志?
- 日志完成后(后 3 项):上线前后有哪些需要注意的?
-
+
日志编写位置
日志编写的位置可以说是重中之重,好的日志位置可以帮你解决问题,也可以让你更加了解代码的运行情况。我总结了几点比较重要的编写日志的位置,以供参考。
diff --git a/专栏/分布式链路追踪实战-完/04 统计指标:“五个九”对系统稳定的真正意义.md.html b/专栏/分布式链路追踪实战-完/04 统计指标:“五个九”对系统稳定的真正意义.md.html
index 0fa70345..2a387d23 100644
--- a/专栏/分布式链路追踪实战-完/04 统计指标:“五个九”对系统稳定的真正意义.md.html
+++ b/专栏/分布式链路追踪实战-完/04 统计指标:“五个九”对系统稳定的真正意义.md.html
@@ -170,23 +170,23 @@ function hide_canvas() {
介绍了指标的作用后,我们来看一下统计指标都有哪些类型,它们又分别有哪些不同的作用?
计数器(Counter)
计数器是一个数值单调递增的指标,一般这个值为 Double 或者 Long 类型。我们比较常见的有 Java 中的 AtomicLong、DoubleAdder,它们的值就是单调递增的。QPS 的值也是通过计数器的形式,然后配合上一些函数计算得出的。
-
+
图 1:计数器
仪表盘(Gauge)
仪表盘和计数器都可以用来查询某个时间点的固定内容的数值,但和计数器不同,仪表盘的值可以随意变化,可以增加也可以减少。比如在 Java 线程池中活跃的线程数,就可以使用 ThreadPoolExecutor 的 getActiveCount 获取;比较常见的 CPU 使用率和内存占用量也可以通过仪表盘获取。
-
+
图 2:仪表盘
直方图(Histogram)
直方图相对复杂一些,它是将多个数值聚合在一起的数据结构,可以表示数据的分布情况。
如下图,它可以将数据分成多个桶(Bucket),每个桶代表一个范围区间(图下横向数),比如第 1 个桶代表 0~10,第二个桶就代表 10~15,以此类推,最后一个桶代表 100 到正无穷。每个桶之间的数字大小可以是不同的,并没有规定要有规律。每个桶和一个数字挂钩(图左纵向数),代表了这个桶的数值。
-
+
图 3:直方图
以最常见的响应耗时举例,我把响应耗时分为多个桶,比如我认为 0~100 毫秒比较快,就可以把这个范围做一个桶,然后是 100~150 毫秒,以此类推。通过这样的形式,可以直观地看到一个时间段内的请求耗时分布图,这有助于我们理解耗时情况分布。
摘要(Summary)
摘要与直方图类似,同样表示的是一段时间内的数据结果,但是数据反映的内容不太一样。摘要一般用于标识分位值,分位值就是我们常说的 TP90、TP99 等。
假设有 100 个耗时数值,将所有的数值从低到高排列,取第 90% 的位置,这个位置的值就是 TP90 的值,而这个桶的值假设是 80ms,那么就代表小于等于90%位置的请求都 ≤80ms。
用文字不太好理解,我们来看下面这张图。这是一张比较典型的分位值图,我们可以看到图中有 6 个桶,分别是 50、75、80、90、95、99,而桶的值就是相对应的耗时情况。
-
+
图 4:分位值图
通过分位值图,我们可以看到最小值和最大值以外的一些数据,这些数据在系统调优的时候也有重要参考价值。
在这里面我需要补充一个知识点,叫作长尾效应。长尾效应是指少部分类数据在一个数据模型中占了大多数样本,在数据模型中呈现出长长的尾巴的现象。如图所示,最上面的 TP99 相当于这个图表的尾巴,可以看到,1% 用户访问的耗时比其他 5 个桶加起来的都要长。这个时候你如果通过指标查看某个接口的平均响应时间,其实意义不大,因为这 1% 的用户访问已经超出了平均响应时间,所以平均响应时间已经无法反映数据的真实情况了。这时用户会出现严重的量级分化,而量化分级也是我们在进行系统调优时需要着重关注的。
diff --git a/专栏/分布式链路追踪实战-完/05 监控指标:如何通过分析数据快速定位系统隐患?(上).md.html b/专栏/分布式链路追踪实战-完/05 监控指标:如何通过分析数据快速定位系统隐患?(上).md.html
index ea8fcf3e..cf9380c8 100644
--- a/专栏/分布式链路追踪实战-完/05 监控指标:如何通过分析数据快速定位系统隐患?(上).md.html
+++ b/专栏/分布式链路追踪实战-完/05 监控指标:如何通过分析数据快速定位系统隐患?(上).md.html
@@ -182,7 +182,7 @@ function hide_canvas() {
通用指标
端上的资源请求,一般都会经历以下几个步骤:DNS 寻找,建立与服务器的链接,发送请求,请求响应。这几个步骤是可以被监控起来,现在很多主流的拨测软件也会提供这样的统计功能,拨测软件其实就是利用各个不同地方的机器发起请求,来测试网络情况。
-
+
App 和网页,在发送请求和获取数据内容的过程中,除了以上提到的指标,还有以下几个指标需要注意:
- DNS 响应时间:通常用来记录访问地址向 DNS 服务器发起请求,到 DNS 返回服务器 IP 地址信息的时间。
diff --git a/专栏/分布式链路追踪实战-完/07 指标编写:如何编写出更加了解系统的指标?.md.html b/专栏/分布式链路追踪实战-完/07 指标编写:如何编写出更加了解系统的指标?.md.html
index 2474b10f..1cbfbd64 100644
--- a/专栏/分布式链路追踪实战-完/07 指标编写:如何编写出更加了解系统的指标?.md.html
+++ b/专栏/分布式链路追踪实战-完/07 指标编写:如何编写出更加了解系统的指标?.md.html
@@ -170,7 +170,7 @@ function hide_canvas() {
- 营收(revenue):公司是否从用户这里获得了营收,其中最典型的就是用户购买了你的内容,你所获得的成单金额。
- 传播(referral):老用户对潜在用户的病毒式传播及口碑传播,进行“老拉新”,比如拉勾教育的分销就可以认为是传播,并由此算出传播系数。
-
+
在这一流程中,你会发现其中每个部分都可以根据不同的功能,产生不同的数据指标,然后你可以通过这些更细化的指标优化产品,从而让产品更具有商业价值。
性能数据
性能层的数据会更加方便研发人员了解程序的运行情况。通过观测这部分数据,你能快速感知是哪些业务出现了异常,再结合日志或是我在下一课时要讲的链路,来快速定位问题出现的原因。
@@ -252,7 +252,7 @@ for (String time : sortedKeys) {
这样的计算方式,通常与计数器(Counter)一同使用,因为计数器的数据一般是递增的,但有时很难看到增长率。通过速率,你可以看出哪些时候的增长比较多,哪些时候又基本不变,比如拉勾教育的课程购买人数增速占比。在课程上线时我们会开展 1 元购的活动,通过查看活动前后的人数增长率,我们就能很清楚地知道在活动期间购买的人数会大幅增加,以后也会更多地开展类似的活动。
2.irate:同样也叫速率。与 rate 的计算方式不同,irate 只计算最近两次数据之间的增长速率。rate 和 irate 的函数变化如下图:
-这张图中红线的就是 irate 函数,而绿色线的就是 rate 函数。图中可以很明显地看出来,rate 更平缓一些,irate 则能更“实时”地体现出数据。
Rate 会对指定时间段内的所有值做平均计算,导致部分精度丢失。因此,irate 通常比 rate 更加精准。但 rate 的曲线更平滑,能更直接地反映出数据整体的波动。
3.环比:指连续 2 个统计周期的变化率。我们在计算销售量时,就可以使用环比,比如这个月的销售量环比增长 10%,指的就是同上一个月销售量相比,增长了 10%。计算公式如下:
diff --git a/专栏/分布式链路追踪实战-完/09 性能剖析:如何补足分布式追踪短板?.md.html b/专栏/分布式链路追踪实战-完/09 性能剖析:如何补足分布式追踪短板?.md.html index 4db42d7e..14047ac0 100644 --- a/专栏/分布式链路追踪实战-完/09 性能剖析:如何补足分布式追踪短板?.md.html +++ b/专栏/分布式链路追踪实战-完/09 性能剖析:如何补足分布式追踪短板?.md.html @@ -183,7 +183,7 @@ function hide_canvas() {既然都是基于线程的,而线程中基本会伴随着方法栈,即每进入一个方法都会通过压入一个方法栈帧的情况来保存。那我们是不是可以定期查看方法栈的情况来确认问题呢?答案是肯定的。比如我们经常使用到的 jstack,其实就是实时地对所有线程的堆栈进行快照操作,来查看当前线程的执行情况。
利用我上面提到的 2 点,再结合链路中的上下文信息,我们可以通过周期性地对执行中的线程进行快照操作,并聚合所有的快照,来获得应用线程在生命周期中的执行情况,从而估算代码的执行速度,查看出具体的原因。
这样的处理方式,我们就叫作性能剖析(Profile),原理可以参照下图:
-在这张图中,第一行代表线程进行快照的周期情况,每一个周期都可以认为是一段时间,比如 10ms、100ms。周期的时间长短,决定了对程序性能影响的大小。因为在进行线程快照时程序会暂停,当快照完成后才会继续进行操作。
第二行则代表我们需要进行观测的方法的执行时间,线程快照只能做到周期性的快照获取。虽然可能并不能完全匹配,但通过这种方式,相对来说已经很精准了。
性能剖析与埋点相比,有以下几个优势:
@@ -199,11 +199,11 @@ function hide_canvas() {这个时候我们一般可以通过 2 种方式查看线程聚合的结果信息,它们分别是火焰图和树形图。
火焰图,顾名思义,是和火焰一样的图片。火焰图是在 Linux 环境中比较常见的一种性能剖析展现方式。火焰图有很多种展现形式,这里我就以我们会用到的 CPU 火焰图为例:
-CPU 火焰图
在 CPU 火焰图中,每一个方格代表一个方法栈帧,方格的长度则代表它的执行时间,所以方格越长就说明该栈帧执行的时间越长。火焰图中在某一个方格中增高一层,就说明是这个方法栈帧中,又调用了某个方法的栈帧。最顶层的函数,是叶子函数。叶子函数的方格越宽,说明这个方法在这里的执行耗时越长。
如果觉得上面的火焰图太复杂的话,我们可以看一张简化的图,如下:
-图中,a 方法是执行的方法,可以看出来,其中 g 方法是执行时间相对较长的。
无论是火焰图,还是这张简化的图,它们都通过图形的方式,让我们能够快速定位到执行缓慢的原因。但是这种的方式也存在一些问题:
为了解决这 2 个问题,就有了另外一种展现方式,那就是树形图。树形图就是将方法的调用堆栈通过树形图的形式展现出来。这对于开发人员来说相对直观,因为你可以通过树形图的形式快速查看整体的调用情况,并且针对火焰图中的问题,树形图也有很好的解决方法:
我就不介绍 ELK 的安装方式了,ELK 已经使用多年了,整体相对稳定,它的安装方式很容易就能在网上搜到。接下来我会对常见的 Kibana 的使用做简要说明。
Kibana 是一个针对 ElasticSearch 的数据分析与可视化平台,用来搜索、查看存储在 ElasticSearch 中的数据。如果你感兴趣,可以点击这里,前往官网体验提供的 demo。
-图中是一个比较典型的日志检索界面。
它支持通过时间筛选日志内容,我们可以在最上方通过 KQL 或者 Filter 来检索数据,比如我们的系统根据用户 ID 来进行检索,此时就可以输入指定的语句,筛选出符合条件的日志内容。链路追踪的 TraceID 是一个比较常见的查询方式。
左边的竖列就是目前系统中所有已知的字段列表,一般这个列表有 2 个作用:
diff --git a/专栏/分布式链路追踪实战-完/16 指标体系:Prometheus 如何更完美地显示指标体系?.md.html b/专栏/分布式链路追踪实战-完/16 指标体系:Prometheus 如何更完美地显示指标体系?.md.html index 75c13b58..d34e9e3b 100644 --- a/专栏/分布式链路追踪实战-完/16 指标体系:Prometheus 如何更完美地显示指标体系?.md.html +++ b/专栏/分布式链路追踪实战-完/16 指标体系:Prometheus 如何更完美地显示指标体系?.md.html @@ -201,7 +201,7 @@ function hide_canvas() {我会先依据我刚才讲的指标系统的原理,对 Prometheus 的系统架构做详细说明,然后讲解其中的 4 个常见功能。
我们先来看一张 Prometheus 的架构图:
-根据我在原理中讲到的数据收集、指标聚合、指标查询和规则告警,我们也可以通过这 4 个步骤来了解 Prometheus。
我们可以从图中看到,Prometheus 主要是采取拉取模式获取数据的,它提供了完善的服务发现机制,结合 K8s 的 API 可以动态感知服务的创建与销毁。通过可配置的方式,定期拉取服务列表的数据。对于一些短期存在的任务,Prometheus 同样提供了 PushGateway,让业务程序能够推送数据,再由收集系统来收集。
其次是 Prometheus 的指标聚合。Prometheus 服务器接收到数据之后,会通过 TSDB 存储引擎聚合数据,然后存储到磁盘上。TSDB 中的数据结构和我在**13 | 告警质量:如何更好地创建告警规则和质量?**这一课时中,讲到的时序数据库的结构一样。
@@ -256,7 +256,7 @@ description: "{{ $labels.instance }} 实例中的请求错误数超过 20上述代码声明了一个告警规则,接下来就可以通过 Alertmanager 组件进行更详细的通知方式配置。这部分内容十分简单,你可以通过官方提供的文档学习。
最后我带你简单认识一下 Grafana,它是一个通过可视化的形式查看指标数据的展示系统。通过它,你可以快速构建出 dashboard。其数据源就是依赖 Prometheus,通过 PromQL 的查询实现的。如下图所示:
-这张图是官方 demo 所提供的展示内容,可以点击这里访问。
Grafana是依赖于 Prometheus 提供的数据搭建而成的。其中,最上面一行分别展示了当前的展示模板和对应查询时间范围,这个和我介绍的 Kibana 十分类似。下方的每一个模块展示的是通过 PromQL 所查询出数据的结果,这个部分可以选择不同的展示方式,例如柱状图、折线图、列表等。
Grafana 还支持导入和导出展示模板,你可以下载一些已经很成熟的模板信息来使用。·
diff --git a/专栏/分布式链路追踪实战-完/17 链路追踪:Zipkin 如何进行分布式追踪?.md.html b/专栏/分布式链路追踪实战-完/17 链路追踪:Zipkin 如何进行分布式追踪?.md.html index 08eb58d2..1d1e9647 100644 --- a/专栏/分布式链路追踪实战-完/17 链路追踪:Zipkin 如何进行分布式追踪?.md.html +++ b/专栏/分布式链路追踪实战-完/17 链路追踪:Zipkin 如何进行分布式追踪?.md.html @@ -190,7 +190,7 @@ function hide_canvas() {你可以通过下图更直观地看到二者之间的差别。
-这两种链路采集方案没有绝对的好坏之分,还要考虑项目的具体使用场景上。如果是使用开源或者商业方案时,还要考虑到与整个链路追踪系统的集成程度、支持的组件等。
从链路采集到数据之后,我们就可以对这些数据进行解析、分析等工作,并最终存储到相应的存储引擎中,常见的引擎有 ElasticSearch、HBase、MySQL 等。
@@ -213,7 +213,7 @@ function hide_canvas() {Zipkin 是一款开源的链路追踪系统,它是基于我们之前提到的Dapper论文设计的,由 Twitter 公司开发贡献。
我们先来看 Zipkin 的系统架构图,它展现了 Zipkin 的整体工作流程:
-这一部分对应我在原理中讲到的链路采集、数据收集和数据查看的步骤,我们从上往下依次来看。
首先是链路采集。紫色的部分代表业务系统和组件,图中是以一个典型的 RPC 请求作为所需要追踪的链路,其中 client 为请求的发起方,分别请求了两个服务端。其中被观测的客户端和服务端会在启动的实例中增加数据上报的功能,这里的数据上报就是指从本实例中观测到的链路数据,一并上报到 Zipkin 中,传输工具常见的有 Kafka 或者 HTTP 请求。
数据传输到 Zipkin 的收集器后,会经过 Zipkin 的存储模块,存储到数据库中。目前支持的数据库有 MySQL、ElasticSearch、Cassandra 这几种类型,具体的数据库选择可以根据公司内部运维的实力评估出最适合的。
@@ -241,12 +241,12 @@ public OkHttpClient buildOkHttpClient(HttpTracing tracing) {消息传递是在链路追踪中保持上下游服务相同链路的关键,一般会通过消息透传的方式来做到。比如上下游是通过 HTTP 的方式进行数据交换的,此时就可以在上游准备发送时的 HTTP 请求头中增加链路的上下文信息;下游接收到请求后,解析相对应的 HTTP 请求头数据,确认是否有链路上下文信息。
如果存在链路上下文信息则可以继续将链路信息传递,认定是相同链路,从而来实现链路追踪;如果没有,则可以认定为是一个全新的链路。
以刚才的 OkHttp 框架为例,我们尝试发送一个请求,然后通过 WireShark 工具观测数据内容,就可以获取到如下信息:
-在这张图中,我们可以清楚地看到请求时的详细数据。
请求头中除了基础的 Header 信息以外,还会有很多以 "X-B3" 开头的内容,比如TraceId、SpanId 等关键信息,就是经由 Zipkin 产生的链路上下文信息。
我们来看一张相对简单的链路数据展示图。图中主要模拟就是如项目架构图中类似的 client 端发送请求,server 端接收请求的链路逻辑。
-左侧部分展示的是 client 端接收到了上游的请求,然后交由 server 获取数据内容的链路信息。
右侧上半部分分别显示的是客户端发送、服务端接收、服务端处理结束、客户端获取到数据中每一个节点的时间关系。
右侧下半部分展示的是当前我们选中的 Span 的标签信息,和我在“10 链路分析:除了观测链路,还能做什么?”中所讲的自定义数据十分相似,在这里你可以通过自定义属性信息来完成信息的定制化。
diff --git a/专栏/分布式链路追踪实战-完/18 观测分析:SkyWalking 如何把观测和分析结合起来?.md.html b/专栏/分布式链路追踪实战-完/18 观测分析:SkyWalking 如何把观测和分析结合起来?.md.html index c6377d42..c873f63d 100644 --- a/专栏/分布式链路追踪实战-完/18 观测分析:SkyWalking 如何把观测和分析结合起来?.md.html +++ b/专栏/分布式链路追踪实战-完/18 观测分析:SkyWalking 如何把观测和分析结合起来?.md.html @@ -165,7 +165,7 @@ function hide_canvas() {SkyWalking 和 Zipkin 的定位不同,决定了它们不是相同类型的产品。SkyWalking 中提供的组件更加偏向业务应用层面,并没有涉及过多的组件级别的观测;Zipkin 提供了更多组件级别的链路观测,但并没有提供太多的链路分析能力。你可以根据两者的侧重点来选择合适的产品。
下面是官网提供的 SkyWalking 的系统架构图,我们先通过这张图来了解它:
-从中间往上看,首先是 Receiver Cluster,它代表接收器集群,是整个后端服务的接入入口,专门用来收集各个指标,链路信息,相当于我在上一节所讲的链路收集器。
再往后面走是 Aggregator Cluster,代表聚合服务器,它会汇总接收器集群收集到的所有数据,并且最终存储至数据库,并进行对应的告警通知。右侧标明了它支持的多种不同的存储方式,比如常见的 ElasticSearch、MySQL,我们可以根据需要来选择。
图的左上方表示,我们可以使用 CLI 和 GUI,通过 HTTP 的形式向集群服务器发送请求来读取数据。
@@ -195,14 +195,14 @@ function hide_canvas() {最后,基于 OAL 语言,我们自定义了统计指标。我们可以将其运用在自定义的 UI 展示和告警中,将动态统计指标的优势最大化,从而来实现一套高度可定制化的观测平台。
除了统计指标之外,链路分析中的另外一个关键点就是拓扑。拓扑图可以展示服务、端点、实例之间的引用关系,将引用关系与统计指标相结合后,我们能更快地了解到系统整体的运行情况,以及流量主要分布在哪里。下图就展示了在 SkyWalking 中,是怎样展现服务之间的拓扑的。
-在这张图中,从左到右代表服务从接入流量到服务处理中的完整拓扑信息。用户发起访问,首先经由 ProjectA 服务,然后引入 ProjectB、ProjectC 和其他的云服务,ProjectB、ProjectC 又分别调用了其余的组件和服务。服务依赖之间使用线进行连接,可以清楚地描绘出彼此的关系。
传统的拓扑检测,通常是利用时间窗口来推断服务之间的依赖关系。比如 RPC 中消费者发送请求给提供者,提供者会先完成请求,再将链路数据发送到链路收集器端。此时,由于收集器端并不清楚是谁调用了提供者,所以会将数据保留一段到内存中。消费者完成请求处理后,将链路信息再发送到链路收集器中,此时再进行数据匹配,才能得知提供者的消费者是哪一个。得知消费者之后,保存在内存中的数据就会被删掉。
在分布式系统中,RPC 的请求数量可能非常巨大,如果使用传统的拓扑检测,虽然也能完成,但是会导致高延迟和高内存使用。同时由于是基于时间窗口模式,如果提供者的数据上报事件超过了时间窗口规定的时间,就会出现无法匹配的问题。
SkyWalking 为了解决上面提到的延迟和内存问题,引入了一个新的分析方式来进行拓扑检测,这种方式叫作 STAM(Streaming Topology Analysis Method)。
STAM 通过在消息传递的内容中注入更多的链路上下文信息,解决了传统拓扑检测中高延迟和高内存的问题。
Zipkin 和 SkyWalking 在 OkHttp 框架的消息传递时,都会将链路信息放置在请求头中。无论它们的采集器是如何实现的,在进行消息传递时,都会通过某种方式将链路信息设置到请求中。如下图所示:
-我们可以看到其中有三个“sw8”开头的 header 内容,“sw8”也是 SkyWalking 在进行链路上下文传递中的关键信息。这里进行了转码处理,我们可以通过阅读官方对跨线程消息透传协议的 介绍,了解到它进行了信息的传递。我列出一些其中比较关键的部分。
阿里云提供的 ARMS 就是包含上述功能的一套云观测系统,除了以上 4 点,它还提供了很多特有的功能,让你更方便地观测数据。
ARMS 提供的功能主要分为 6 个部分:前端监控、App 监控、应用监控、自定义监控、大盘展示、报警。我们依次来看。
-前端监控 前端监控指的是通过在页面中埋入脚本的形式,让阿里云接管前端的数据上报。其中就包含我们比较常见的脚本错误次数、接口请求次数、PV、UV 等统计数据,也包含页面中脚本错误、API 访问等数据信息。通过统计数据你能快速了解前端用户的访问情况;脚本错误、API 访问等数据,则可以帮助你了解页面出现错误或者接口访问时的详细信息。
App 监控 @@ -205,7 +205,7 @@ ARMS 的应用监控和我之前讲的链路追踪的内容十分相似,其中
在页面中,通过 script 标签引入一个 JavaScript 文件来进行任务处理,然后通过 pid 参数设定的应用 ID,保证数据只会上传到你的服务中。
网页运行时就会自动下载 bl.js 文件,下载完成后,代码会自动执行。当页面处理各种事件时,会通过异步的形式,上报当前的事件信息,从而实现对前端运行环境、执行情况的监控。常见的事件有:页面启动加载、页面加载完成、用户操作行为、页面执行时出现错误、离开页面。
页面加载完成之后,会发送 HEAD 请求来上报数据。其中我们可以清楚的看到,在请求参数中包含 DNS、TCP、SSL、DOM、LOAD 等信息,分别代表 DNS 寻找、TCP 建立连接、SSL 握手这类,我在“05 | 监控指标:如何通过分析数据快速定位系统隐患?(上)”中讲到的通用指标,也包含 DOM 元素加载时间这类网页中的统计指标信息。如下所示:
-数据上报后,ARMS 就会接收到相对应事件中的完整数据信息,从而通过聚合的方式,存储和展示数据。在 ARMS 中,针对应用有访问速度、JS 错误、API 请求这些统计指标和错误信息的数据,ARMS 可以依据不同维度的数据了解到更详细的内容,包含页面、地理、终端、网络这 4 类。通过不同的数据维度,你也可以更有针对性地了解问题。
App 监控 App 的监控方式与前端监控十分类似,都需要通过增加代码的方式进行。以 iOS 为例,如果我们想要接入性能分析功能,除了要引入相关依赖,还需要在代码中进行如下的声明:
@@ -217,24 +217,24 @@ App 的监控方式与前端监控十分类似,都需要通过增加代码的 对于服务端监控来说,ARMS 支持目前主流的 Java、PHP、Go 等语言,这里我以 Java 语言为例说明。在 Java 中,主要通过字节码增强的形式采集数据。项目启动后,会采集机器中 JVM 中的统计指标、链路数据等信息,然后结合链路,分析出统计指标、拓扑图的信息,以及应用与各个组件之间的交互细节,比如数据库查询、消息 MQ 发送量等数据信息。
在服务端监控中,我们可以看到请求链路中的数据,在 ARMS 的显示中都是基于应用的维度,以树形进行展示的。比如我们有 2 个应用程序,上游服务通过“/first”接口地址对外提供服务,同时又调用了下游服务的“/second”接口。如下图所示:
-这张图中展示了对应的上下游服务、发生时间、实例地址、调用方式、服务名称和时间轴信息。并且我们可以通过点击其中单个服务的“方法栈”按钮,查看其链路中关键方法的执行流程。点开之后的页面如下:
-在 ARMS 中服务端监控的功能中,最常用的是应用诊断部分,其中包含了实时诊断、异常分析、线程分析这 3 部分重点功能。
下图中汇总了服务中出现错误的异常信息,我们可以通过点击具体的接口名称,找到对应的接口,更细致地查看接口细则。
-如果程序出现执行缓慢的情况,我们可以通过 CPU 资源消耗来寻找原因。还可以通过点击每个线程中右侧的方法栈,来快速查看指定线程的执行方法栈信息。查询到问题的原因后,我们再结合具体的业务场景处理问题。
-以上,我介绍了云端观测的作用以及在阿里云的 ARMS 系统中的实践。如果你的系统部署在云端,那么云端观测就是你进行系统观测的不二选择。你通过云端观测解决过哪些问题呢?欢迎你在留言区分享。
下一节,我将带你了解如何将可观测系统与 OSS 系统相结合。
diff --git a/专栏/前端工程化精讲-完/00 开篇词 建立上帝视角,全面系统掌握前端效率工程化.md.html b/专栏/前端工程化精讲-完/00 开篇词 建立上帝视角,全面系统掌握前端效率工程化.md.html index 15111a38..d5af6fd5 100644 --- a/专栏/前端工程化精讲-完/00 开篇词 建立上帝视角,全面系统掌握前端效率工程化.md.html +++ b/专栏/前端工程化精讲-完/00 开篇词 建立上帝视角,全面系统掌握前端效率工程化.md.html @@ -169,7 +169,7 @@ function hide_canvas() {通常,一个中高级前端工程师,除了要完成业务功能开发目标外,还要对所开发项目的效率、性能、质量等工程化维度去制定和实施技术优化目标,其中以提升效率为目标的优化技术和工具就属于效率工程化的范畴。
对于公司而言,团队效率可以直接带来人工投入产出比的提升,因此效率提升通常会被作为技术层面的一个重点优化方向。而在面试中,对效率工程化的理解程度和实践中的优化产出情况,也是衡量前端工程师能力高低的常见标准。
例如,在拉勾网搜索前端相关职位,可以看到中高级以上的前端工程师岗位需求中大都会要求熟练掌握 webpack 构建工具、具备开发效率实践经验等。只有具备这方面的能力,你才能应对和优化复杂项目,保证团队高效产出。
-拉勾网搜索“前端效率工程”的岗位情况
然而,大部分时间都投身在业务开发中的前端同学,在效率工程化方面经常面临很多困扰:
那么下面我们先来谈谈脚手架工具究竟是什么。
说到脚手架(Scaffold) 这个词,相信你并不陌生,它原本是建筑工程术语,指为了保证施工过程顺利而搭建的工作平台,它为工人们在各层施工提供了基础的功能保障。
-而在软件开发领域,脚手架是指通过各种工具来生成项目基础代码的技术。通过脚手架工具生成后的代码,通常已包含了项目开发流程中所需的工作目录内的通用基础设施,使开发者可以方便地将注意力集中到业务开发本身。
那么对于日常的前端开发流程来说,项目内究竟有哪些部分属于通用基础设施呢?让我们从项目创建的流程说起。对于一个前端项目来说,一般在进入开发之前我们需要做的准备有:
而通过脚手架工具,我们就能免去人工处理上的环节,轻松地搭建起项目的初始环境,直接进入到业务开发中。接下来我们就先来看一下前端领域的几个典型脚手架工具,了解这几个脚手架所代表的不同设计理念,接着我们会重点分析两个代表性脚手架工具包内的技术细节,以便在工作中更能得心应手地使用和优化。
[图:logo-yeoman]
Yeoman 是前端领域内较早出现的脚手架工具,它由 Google I/O 在 2012 年首次发布。Yeoman 提供了基于特定生成器(Generator)来创建项目基础代码的功能。时至今日,在它的网站中能找到超过 5600 个不同技术栈的代码生成器。
作为早期出现在前端领域的脚手架工具,它没有限定具体的开发技术栈,提供了足够的开放性和自由度,但也因此缺乏某一技术栈的深度集成和技术生态。随着前端技术栈的日趋复杂化,人们更倾向于选择那些以具体技术栈为根本的脚手架工具,而 Yeoman 则更多用于一些开发流程里特定片段代码的生成。
[图:logo-create-react-app]
Create React App(后简称 CRA )是 Facebook 官方提供的 React 开发工具集。它包含了 create-react-app 和 react-scripts 两个基础包。其中 create-react-app 用于选择脚手架创建项目,而 react-scripts 则作为所创建项目中的运行时依赖包,提供了封装后的项目启动、编译、测试等基础工具。
正如官方网站中所说的,CRA 带来的最大的改变,是将一个项目开发运行时的各种配置细节完全封装在了一个 react-scripts 依赖包中,这大大降低了开发者,尤其是对 webpack 等构建工具不太熟悉的开发者上手开发项目的学习成本,也降低了开发者自行管理各配置依赖包的版本所需的额外测试成本。
但事情总有两面性,这种近乎黑盒的封装在初期带来便利的同时,也为后期的用户自定义优化带来了困难。虽然官方也提供了 eject 选项来将全部配置注入回项目,但大部分情况下,为了少量优化需求而放弃官方提供的各依赖包稳定升级的便利性,也仍不是一个好的选择。在这种矛盾之下,在保持原有特性的情况下提供自定义配置能力的工具 react-rewired 和 customize-cra 应运而生。
[图:logo-vue-cli]
正如 Create-React-App 在 React 项目开发中的地位, Vue 项目的开发者也有着自己的基础开发工具。Vue CLI 由 Vue.js 官方维护,其定位是 Vue.js 快速开发的完整系统。完整的 Vue CLI 由三部分组成:作为全局命令的 @vue/cli、作为项目内集成工具的 @vue/cli-service、作为功能插件系统的 @vue/cli-plugin-。
Vue CLI 工具在设计上吸取了 CRA 工具的教训,在保留了创建项目开箱即用的优点的同时,提供了用于覆盖修改原有配置的自定义构建配置文件和其他工具配置文件。
@@ -246,10 +246,10 @@ README.md 8) 默认文档文件还是以上面的 CRA 和 Vue CLI 为例,除了通过脚手架模板生成项目之外,项目内部分别使用 react-scripts 和 vue-cli-service 作为开发流程的集成工具。接下来,我们先来对比下这两个工具在开发与生产环境命令中都使用了哪些配置项,其中一些涉及效率的优化项在后面的课程中还会详细介绍。
从下面表格中我们可以发现,在一般源文件的处理器使用方面,两个脚手架工具大同小异,对于 babel-loader 都采用了缓存优化,Vue 中还增加了多线程的支持。在样式和其他类型文件的处理上 Vue 默认支持更多的文件类型,相应的,在 CRA 模板下如果需要支持对应文件就需要使用 customize-cra 等工具来添加新处理模块。
-在与构建核心功能相关的方面(html、env、hot、css extract、fast ts check),两者使用的插件相同,而在其他一些细节功能上各有侧重,例如 React 的 inline chunk 和 Vue 的 preload。
-(第三方工具)
两者在代码优化配置中相同的部分包括:都使用 TerserPlugin 压缩JavaScript, 都使用 splitChunks 做自动分包 (参数不同)。CSS 的压缩分别采用上面表格中的 OptimizeCssAssetsWebpackPlugin 和 OptimizeCssNanoPlugin 。react-scripts 中还开启了 runtimeChunk 以优化缓存。
@@ -297,7 +297,7 @@ module.exports = class extends Generator {writing 和 install 是 Yeoman 运行时上下文的两个阶段,在例子中,当我们执行下面的创建项目命令时,依次将生成器中模板目录内的所有文件复制到创建目录下,然后执行安装依赖。
在完成生成器的基本功能后,我们就可以通过在生成器目录里 npm link ,将对应生成器包挂载到全局依赖下,然后进入待创建项目的目录中,执行 yo 创建命令即可。 (如需远程安装,则需要先将生成器包发布到 npm 仓库中,支持发布到 @scope/generator-[name] 。)
-至此,制作 Yeoman 的生成器来定制项目模板的基本功能就完成了。除了基本的复制文件和安装依赖外, Yeoman 还提供了很多实用的功能,例如编写用户交互提示框或合成其他生成器等,可供开发者定制功能体验更完善的脚手架生成器。
为 create-react-app 准备的自定义模板在模式上较为简单。作为一个最简化的 CRA 模板,模板中包含如下必要文件:
diff --git a/专栏/前端工程化精讲-完/02 界面调试:热更新技术如何开着飞机修引擎?.md.html b/专栏/前端工程化精讲-完/02 界面调试:热更新技术如何开着飞机修引擎?.md.html index 781ea574..e8df99da 100644 --- a/专栏/前端工程化精讲-完/02 界面调试:热更新技术如何开着飞机修引擎?.md.html +++ b/专栏/前端工程化精讲-完/02 界面调试:热更新技术如何开着飞机修引擎?.md.html @@ -220,7 +220,7 @@ package.json }当我们执行 npm run dev:reload,从日志中可以看到本地服务 http://localhost:8080/ 已启动,然后我们在浏览器中输入网址 http://localhost:8080/index.html (也可以在 devServer 的配置中加入 open 和 openPage 来自动打开网页)并打开控制台网络面板,可以看到在加载完页面和页面中引用的 js 文件后,服务还加载了路径前缀名为 /sockjs-node 的 websocket 链接,如下图:
-通过这个 websocket 链接,就可以使打开的网页和本地服务间建立持久化的通信。当源代码发生变更时,我们就可以通过 Socket 通知到网页端,网页端在接到通知后会自动触发页面刷新。
到了这里,在使用体验上我们似乎已经达到预期的效果了,但是在以下场景中仍然会遇到阻碍:在开发调试过程中,我们可能会在网页中进行一些操作,例如输入了一些表单数据想要调试错误提示的样式、打开了一个弹窗想要调试其中按钮的位置,然后切换回编辑器,修改样式文件进行保存。可是当我们再次返回网页时却发现,网页刷新后,之前输入的内容与打开的弹窗都消失了,网页又回到了初始化的状态。于是,我们不得不再次重复操作才能确认改动后的效果。对于这个问题,又该如何解决呢?
在上面的代码改动中,我们只是在源码部分新增导入了一个简单的 CSS 文件,用于演示热替换的效果。在配置文件中,首先我们在 devServer 配置中新增了 hot:true,其次,新增 module 的配置,使用 style-loader 和 css-loader 来解析导入的 CSS 文件。其中 css-loader 处理的是将导入的 CSS 文件转化为模块供后续 Loader 处理;而 style-loader 则是负责将 CSS 模块的内容在运行时添加到页面的 style 标签中。
当我们执行 npm run dev:hmr 命令,可以看到页面控制台的网络面板与上个示例并无区别,而在审查元素面板中可以看到源码中的 CSS 被添加到了页面头部的新增 style 标签中。
-而当修改源码中 CSS 的样式后,再回到网页端,我们则会发现这样一些变化:
首先在网络面板中,只是新增了两个请求:hot-update.json 和 hot-update.js,而不像上一个立即刷新的示例中那样,会刷新页面重载所有请求。
-其次,在审查元素面板中我们可以看到,在页面的头部新增了 hot-update.js,并替换了原先 style 标签中的样式内容。
-正如我们所见,对于代码中引入的样式文件,可以通过上述设置来开启热替换。但是有同学也许会问,我们为什么不像上一个例子中那样改动 JS 的内容(例如改动显示的文本)来观察热替换的效果呢?原因在于,简单改动 JS 中的显示文本并不能达到热替换的效果。尽管网络端同样新增了 hot-update.json 和 hot-update.js,但紧随其后的是如上一个示例一般的刷新了整个页面。
那么,为什么导入的 CSS 能触发模块热替换,而 JS 文件的内容修改就失效了呢?要回答这个问题,我们还得从 webpack 的热更新原理说起。
也就是说在这三种技术中,我们可以基于 Node.js 中提供的文件模块 fs.watch 来实现对文件和文件夹的监控,同样也可以使用 sockjs-node 或 socket.io 来实现 Websocket 的通信。而在这里,我们重点来看下第三种, webpack 中的模块解析与替换功能。
在讲 webpack 的打包流程之前我们先解释几个 webpack 中的术语:
@@ -286,8 +286,8 @@ package.json在上面的 hmr 示例中,从 entry 中的 './src/index1.js' 到打包产物的 dist/main.js,以模块的角度而言,其基本流程是:
上述流程的结果我们可以在预览页面中控制台的 Sources 面板中看到,这里,我们重点看经过 style-loader 处理的 style.css 模块的代码:
-我们简化一下上述控制台中看到的 style-loader 处理后的模块代码,只看其热替换相关的部分。
//为了清晰期间,我们将模块名称注释以及与热更新无关的逻辑省略,并将 css 内容模块路径赋值为变量 cssContentPath 以便多处引用,实际代码可从示例运行时中查看
diff --git a/专栏/前端工程化精讲-完/03 构建提速:如何正确使用 SourceMap?.md.html b/专栏/前端工程化精讲-完/03 构建提速:如何正确使用 SourceMap?.md.html
index d1a95277..911bec6a 100644
--- a/专栏/前端工程化精讲-完/03 构建提速:如何正确使用 SourceMap?.md.html
+++ b/专栏/前端工程化精讲-完/03 构建提速:如何正确使用 SourceMap?.md.html
@@ -167,7 +167,7 @@ function hide_canvas() {
那么除了热更新以外,项目的开发环境还有哪些在影响着我们的开发效率呢?在过去的工作中,公司同事就曾问过我一个问题:为什么我的项目在开发环境下每次构建还是很卡?每次保存完代码都要过 1~2 秒才能看到效果,这是怎么回事呢?其实这里面的原因主要是这位同事在开发时选择的 Source Map 设定不对。今天我们就来具体讨论下这个问题。首先,什么是 Source Map 呢?
什么是 Source Map
在前端开发过程中,通常我们编写的源代码会经过多重处理(编译、封装、压缩等),最后形成产物代码。于是在浏览器中调试产物代码时,我们往往会发现代码变得面目全非,例如:
-
+
因此,我们需要一种在调试时将产物代码显示回源代码的功能,source map 就是实现这一目标的工具。
source-map 的基本原理是,在编译处理的过程中,在生成产物代码的同时生成产物代码中被转换的部分与源代码中相应部分的映射关系表。有了这样一张完整的映射表,我们就可以通过 Chrome 控制台中的"Enable Javascript source map"来实现调试时的显示与定位源代码功能。
对于同一个源文件,根据不同的目标,可以生成不同效果的 source map。它们在构建速度、质量(反解代码与源代码的接近程度以及调试时行号列号等辅助信息的对应情况)、访问方式(在产物文件中或是单独生成 source map 文件)和文件大小等方面各不相同。在开发环境和生产环境下,我们对于 source map 功能的期望也有所不同:
@@ -230,7 +230,7 @@ if (options.devtool.includes("source-map")) {
通过上面的代码分析,我们了解了不同参数在 Webpack 运行时起到的作用。那么这些不同参数组合下的各种预设对我们的 source map 生成又各自会产生什么样的效果呢?下面我们通过示例来看一下。
不同预设的示例结果对比
下面,以课程示例代码 03_develop_environment 为例,我们来对比下几种常用预设的差异(为了使时间差异更明显,示例中引入了几个大的类库文件):
-
+
*注1:“/”前后分别表示产物 js 大小和对应 .map 大小。
*注2:“/”前后分别表示初次构建时间和开启 watch 模式下 rebuild 时间。对应统计的都是 development 模式下的笔者机器环境下几次构建时间的平均值,只作为相对快慢与量级的比较。
@@ -251,19 +251,19 @@ if (options.devtool.includes("source-map")) {
- 源码且包含列信息
-
+
- 源码不包含列信息
-
+
- Loader转换后代码
-
+
- 生成后的产物代码
-
+
开发环境下 Source Map 推荐预设
在这里我们对开发环境下使用的推荐预设做一个总结(生产环境的预设我们将在之后的构建效率篇中再具体分析):
@@ -295,7 +295,7 @@ if (options.devtool.includes("source-map")) {
...
在上面的示例中,我们将 devtool 设为 false,而直接使用 EvalSourceMapDevToolPlugin,通过传入 module: true 和 column:false,达到和预设 eval-cheap-module-source-map 一样的质量,同时传入 exclude 参数,排除第三方依赖包的 source map 生成。保存设定后通过运行可以看到,在文件体积减小(尽管开发环境并不关注文件大小)的同时,再次构建的速度相比上面表格中的速度提升了将近一倍,达到了最快一级。
-类似这样的优化可以帮助我们在一些大型项目中,通过自定义设置来获取比预设更好的开发体验。
在今天这一课时中,我们主要了解了提升开发效率的另一个重要工具——source map 的用途和使用方法。我们分析了 Webpack 中 devtool 的各种参数预设的组合规则、使用效果及其背后的原理。对于开发环境,我们根据一组示例对比分析来了解通常情况下的最佳选择,也知道了如何直接使用插件来达到更细致的优化。
diff --git a/专栏/前端工程化精讲-完/04 接口调试:Mock 工具如何快速进行接口调试?.md.html b/专栏/前端工程化精讲-完/04 接口调试:Mock 工具如何快速进行接口调试?.md.html index 44e8a90c..881c2151 100644 --- a/专栏/前端工程化精讲-完/04 接口调试:Mock 工具如何快速进行接口调试?.md.html +++ b/专栏/前端工程化精讲-完/04 接口调试:Mock 工具如何快速进行接口调试?.md.html @@ -253,7 +253,7 @@ faker.fake("{{name.lastName}}, {{name.firstName}} {{name.suffix}}")Apifox 是一个桌面应用类的接口管理工具。与 YApi 相比,除了使用方式不同外,其主要特点还包括:
以上两种接口管理工具都包含了提供对应接口的 Mock 服务的能力。相比于单独提供生成 Mock 数据能力的 Mock.js 和 Faker.js,这类工具解决了接口定义与 Mock 数据脱离的问题:
在实现的功能方面:这三种 CSS 的预处理语言都实现了变量(Variables)、嵌套(Nesting)、混合 (Mixins)、运算(Operators)、父选择器引用(Parent Reference)、扩展(Extend)和大量内建函数(Build-in Functions)。但是与另外两种语言相比,Less 缺少自定义函数的功能(可以使用 Mixins 结合 Guard 实现类似效果),而 Stylus 提供了超过 60 个内建函数,更有利于编写复杂的计算函数。
在语法方面:Sass 支持 .scss 与 .sass 两种文件格式。差异点是 .scss 在语法上更接近 CSS,需要括号、分号等标识符,而 Sass 相比之下,在语法上做了简化,去掉了 CSS 规则的括号分号等 (增加对应标识符会导致报错) 。Less 的整体语法更接近 .scss。Stylus 则同时支持类似 .sass 的精简语法和普通 CSS 语法。语法细节上也各不相同,示例如下:
//scss
@@ -232,7 +232,7 @@ html
Snippet
Snippet 是指开发过程中用户在 IDE 内使用的可复用代码片段,大部分主流的 IDE 中都包含了 Snippet 的功能,就像使用脚手架模板生成一个项目的基础代码那样,开发者可以在 IDE 中通过安装扩展来使用预设的片段,也可以自定义代码片段,并在之后的开发中使用它们。
以 VS Code 为例,在扩展商店中搜索 Snippet 可以找到各种语言的代码片段包。例如下图中的Javascript(ES6) code snippets,提供了 JavaScript 常用的 import 、console 等语句的缩写。安装后,输入缩写就能快速生成对应语句。
-
+
除了使用扩展包自带的预设片段外,IDE 还提供了用户自定义代码片段的功能。以 VS Code 为例,通过选择菜单中的"Code-首选项-用户片段",即可弹出选择或新增代码片段的弹窗,选择或创建对应 .code-snippets 文件后即可编辑自定义的片段。就像下面示例代码中我们创建了一个简单的生成 TypeScript 接口代码的片段,保存后在项目代码里输入 tif 后再按回车,就能看到对应生成的片段了:
//sample.code-snippets
{
@@ -255,7 +255,7 @@ interface IFName {
通过上面演示的自定义功能,我们就可以编写自身开发常用的个性预设片段了。相比使用第三方预设,自定义的预设更灵活也更便于记忆。两者相结合,能够大大提升我们编码的效率。同时,针对实际业务场景定制的自定义片段文件,也可以在团队内共享和共同维护,以提升团队整体的效率。
Emmet
Emmet****(前身为 Zen Coding)是一个面向各种编辑器(几乎所有你见过的前端代码编辑器都支持该插件)的 Web 开发插件,用于高速编写和编辑结构化的代码,例如 Html 、 Xml 、 CSS 等。从下面官方的示例图中可以看到,简单的输入 ! 或 html:5 再输入 tab 键,编辑器中就会自动生成完整的 html5 基本标签结构(完整的缩写规则列表可在官方配置中查找):
-
+
它的主要功能包括:
- 缩写代码块:
diff --git a/专栏/前端工程化精讲-完/07 低代码工具:如何用更少的代码实现更灵活的需求.md.html b/专栏/前端工程化精讲-完/07 低代码工具:如何用更少的代码实现更灵活的需求.md.html
index 80f8ea02..1711f948 100644
--- a/专栏/前端工程化精讲-完/07 低代码工具:如何用更少的代码实现更灵活的需求.md.html
+++ b/专栏/前端工程化精讲-完/07 低代码工具:如何用更少的代码实现更灵活的需求.md.html
@@ -194,7 +194,7 @@ function hide_canvas() {
- 通过制定用于编写的JSON 语法图式(JSON Schema),以及封装能够渲染对应 JSON 语法树的运行时工具集,就可以提升开发效率,降低开发技术要求。
下图中的代码就是组件语法树示例(完整的示例代码参见 07_low_code),我们通过编写一个简单的 JSON 语法树以及对应的编译器,来展示低代码开发的模式。
-编写 JSON 语法树开发的高效性体现在:
针对编写 JSON 过程中的输入效率、记忆成本和可维护性等问题,许多低代码工具进一步提供了可视化操作平台的工作方式。下面再让我们来了解下,这种方式是怎么解决上述问题的。
可视化的低代码操作平台把编写 JSON 的过程变成了拖拽组件和调试属性配置,如下图所示,这样的交互方式对用户来说更直观友好,开发效率也会更高。
-绝大部分的可视化操作平台都将界面布局分为三个区域:左侧的组件选择区,中部的预览交互区以及右侧的属性编辑区。这三个区域的排布所对应的,也是用户生成页面的操作流程:
以上便是企业内部无代码开发的一类应用场景。
外部无代码搭建平台
另一类面向非开发人员的无代码开发产品,针对的是缺乏开发资源的企业和部门。对于一些常见的小型项目需求,例如招聘页面、报名页面等,它们往往需要借助外部提供的无代码开发平台。这类无代码开发平台包括百度 H5、MAKA、易企秀等。
-百度 H5 编辑界面
这类产品的特点是:
iVX 编辑器中后端逻辑描述面板
人们可以把代码包发布到 npm 中
2009 年 NodeJS 发布,这对前端开发领域产生了深远的影响。一方面,许多原先基于其他语言开发的工具包如今可以通过 NodeJS 来实现,并通过 npm(Node Package Manager,即 node 包管理器)来安装使用。大量的开发者开始将自己开发的工具包发布到 npm registry 上,包的数量在 2012 年初就达到了 6,000 个,而到 2014 年,数字已经上升到了 50,000 个。
另一方面,安装到本地的依赖包在前端项目中如何引用开始受到关注。Twitter 发布的 Bower 旨在解决前端项目中的依赖安装和引用问题,其中一个问题是,在 npm 安装依赖的过程中会引入大量的子包,在早期版本(npm 3 之前)中会产生相同依赖包的大量重复拷贝,这在前端项目中会导致无谓的请求流量损耗。而 Bower 在安装依赖时则可以避免这类问题。然而随着更多模块化打包工具的诞生,它的优势逐渐被其他工具所取代。直到 2017 年,Bower 官方宣布废弃这个项目。
-著名的 node_modules hell(源自 reddit 用户 xaxaxa_trick)
npm 的另一个饱受诟病的问题是本地依赖管理算法的复杂性以及随之而来的性能、冗余、冲突等问题。而 2016 年发布的 Yarn 正是为解决这些问题而诞生的。和 npm 相比,Yarn 的主要优点有:
优化阶段
优化阶段在 seal 函数中共有 12 个主要的处理过程,如下图所示:
-每个过程都暴露了相应的 Hooks,分别如下:
执行构建后,可以看到在控制台输出了相应的统计时间结果(这里的时间是从构建起始到各阶段 Hook 触发为止的耗时),如下图所示:
-根据这样的输出结果,我们就可以分析项目里各阶段的耗时情况,再进行针对性地优化。这个统计插件将在后面几课的优化实践中运用。
除了这类自己编写的统计插件外,Webpack 社区中也有一些较成熟的统计插件,例如speed-measure-webpack-plugin等,感兴趣的话,你可以进一步了解。
提升编译模块阶段效率的第一个方向就是减少执行编译的模块。显而易见,如果一个项目每次构建都需要编译 1000 个模块,但是通过分析后发现其中有 500 个不需要编译,显而易见,经过优化后,构建效率可以大幅提升。当然,前提是找到原本不需要进行构建的模块,下面我们就来逐一分析。
有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块。典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包,如下面的图片所示:
-但对于大多数情况而言,项目中只需要引入本国语言包即可。而 Webpack 提供的 IgnorePlugin 即可在构建模块时直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积,如下面的图片所示。
-
-
+
除了 moment 包以外,其他一些带有国际化模块的依赖包,例如之前介绍 Mock 工具中提到的 Faker.js 等都可以应用这一优化方式。
第二种典型的减少执行模块的方式是按需引入。这种方式一般适用于工具类库性质的依赖包的优化,典型例子是 lodash 依赖包。通常在项目里我们只用到了少数几个 lodash 的方法,但是构建时却发现构建时引入了整个依赖包,如下图所示:
-要解决这个问题,效果最佳的方式是在导入声明时只导入依赖包内的特定模块,这样就可以大大减少构建时间,以及产物的体积,如下图所示。
-除了在导入时声明特定模块之外,还可以使用 babel-plugin-lodash 或 babel-plugin-import 等插件达到同样的效果。
另外,有同学也许会想到 Tree Shaking,这一特性也能减少产物包的体积,但是这里有两点需要注意:
DllPlugin 是另一类减少构建模块的方式,它的核心思想是将项目依赖的框架等模块单独构建打包,与普通构建流程区分开。例如,原先一个依赖 React 与 react-dom 的文件,在构建时,会如下图般处理:
-
+
而在通过 DllPlugin 和 DllReferencePlugin 分别配置后的构建时间就变成如下图所示,由于构建时减少了最耗时的模块,构建效率瞬间提升十倍。
Webpack 配置中的 externals 和 DllPlugin 解决的是同一类问题:将依赖的框架等模块从构建过程中移除。它们的区别在于:
externals 的示例如下面两张图,可以看到经过 externals 配置后,构建速度有了很大提升。
-
-
+
提升编译阶段效率的第二个方向,是在保持构建模块数量不变的情况下,提升单个模块构建的速度。具体来说,是通过减少构建单个模块时的一些处理逻辑来提升速度。这个方向的优化主要有以下几种:
Webpack 加载器配置中的 include/exclude,是常用的优化特定模块构建速度的方式之一。
include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理。而 exclude 则相反,不对特定条件的模块使用该 Loader(例如不使用 babel-loader 处理 node_modules 中的模块)。如下面两张图片所示。
-
-
+
+
这里有两点需要注意:
Webpack 配置中的 module.noParse 则是在上述 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间,如下面两张图片所示。
-
-
+
Source Map 对于构建时间的影响在第三课中已经展开讨论过,这里再稍做总结:对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map。在开启 Source Map 的情况下,优先选择与源文件分离的类型,例如 "source-map"。有条件也可以配合错误监控系统,将 Source Map 的构建和使用在线下监控后台中进行,以提升普通构建部署流程的速度。
Webpack 中编译 TS 有两种方式:使用 ts-loader 或使用 babel-loader。其中,在使用 ts-loader 时,由于 ts-loader 默认在编译前进行类型检查,因此编译时间往往比较慢,如下面的图片所示。
-
+
通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查,从而大大提升 TS 模块的编译速度,如下面的图片所示。
+
而 babel-loader 则需要单独安装 @babel/preset-typescript 来支持编译 TS(Babel 7 之前的版本则还是需要使用 ts-loader)。babel-loader 的编译效率与上述 ts-loader 优化后的效率相当,如下面的图片所示。
+
不过单独使用这一功能就丧失了 TS 中重要的类型检查功能,因此在许多脚手架中往往配合 ForkTsCheckerWebpackPlugin 一同使用。
Webpack 中的 resolve 配置制定的是在构建时指定查找模块文件的规则,例如:
@@ -249,18 +249,18 @@ function hide_canvas() {这些规则在处理每个模块时都会有所应用,因此尽管对小型项目的构建速度来说影响不大,但对于大型的模块众多的项目而言,这些配置的变化就可能产生客观的构建时长区别。例如下面的示例就展示了使用默认配置和增加了大量无效范围后,构建时长的变化情况:
-
-
+
第三个编译阶段提效的方向是使用并行的方式来提升构建的效率。并行构建的方案早在 Webpack 2 时代已经出现,随着目前最新稳定版本 Webpack 4 的发布,人们发现在一般项目的开发阶段和小型项目的各构建流程中已经用不到这种并发的思路了,因为在这些情况下,并发所需要的多进程管理与通信所带来的额外时间成本可能会超过使用工具带来的收益。但是在大中型项目的生产环境构建时,这类工具仍有发挥作用的空间。这里我们介绍两类并行构建的工具: HappyPack 与 thread-loader,以及 parallel-webpack。
这两种工具的本质作用相同,都作用于模块编译的 Loader 上,用于在特定 Loader 的编译过程中,以开启多进程的方式加速编译。HappyPack 诞生较早,而 thread-loader 参照它的效果实现了更符合 Webpack 中 Loader 的编写方式。下面就以 thread-loader 为例,来看下应用前后的构建时长对比,如下面的两张图所示。
-
-
+
并发构建的第二种场景是针对与多配置构建。Webpack 的配置文件可以是一个包含多个子配置对象的数组,在执行这类多配置构建时,默认串行执行,而通过 parallel-webpack,就能实现相关配置的并行处理。从下图的示例中可以看到,通过不同配置的并行构建,构建时长缩短了 30%:
-
-
+
这节课我们整理了 Webpack 构建中编译模块阶段的构建效率优化方案。对于这一阶段的构建效率优化可以分为三个方向:以减少执行构建的模块数量为目的的方向、以提升单个模块构建速度为目的的方向,以及通过并行构建以提升整体构建效率的方向。每个方向都包含了若干解决工具和配置。
今天课后的思考题是:你的项目中是否都用到了这些解决方案呢?希望你结合课程的内容,和所开发的项目中用到的优化方案进行对比,查漏补缺。如果有这个主题方面其他新的解决方案,也欢迎在留言区讨论分享。
diff --git a/专栏/前端工程化精讲-完/12 打包提效:如何为 Webpack 打包阶段提速?.md.html b/专栏/前端工程化精讲-完/12 打包提效:如何为 Webpack 打包阶段提速?.md.html index 1a5036cc..801d6501 100644 --- a/专栏/前端工程化精讲-完/12 打包提效:如何为 Webpack 打包阶段提速?.md.html +++ b/专栏/前端工程化精讲-完/12 打包提效:如何为 Webpack 打包阶段提速?.md.html @@ -190,11 +190,11 @@ compilation.hooks[end].tap(PluginName, () => { ...使用后的效果如下图所示:
-通过这样的插件,我们可以分析目前项目中的效率瓶颈,从而进一步为选取优化方案及评估方案效果提供依据。
在“第 10 课时|流程分解:Webpack 的完整构建流程”中,我们提到了下面的这张图。如图所示,整个优化阶段可以细分为 12 个子任务,每个任务依次对数据进行一定的处理,并将结果传递给下一任务:
-因此,这一阶段的优化也可以分为两个不同的方向:
Webpack 4 中内置了 TerserWebpackPlugin 作为默认的 JS 压缩工具,之前的版本则需要在项目配置中单独引入,早期主要使用的是 UglifyJSWebpackPlugin。这两个 Webpack 插件内部的压缩功能分别基于 Terser 和 UglifyJS。
从第三方的测试结果看,两者在压缩效率与质量方面差别不大,但 Terser 整体上略胜一筹。
从本节课示例代码的运行结果(npm run build:jscomp)来看,如下面的表格所示,在不带任何优化配置的情况下,3 个测试文件的构建结果都是 Terser 效果更好。
-Terser 和 UglifyJS 插件中的效率优化
Terser 原本是 Fork 自 uglify-es 的项目(Fork 指从开源项目的某一版本分离出来成为独立的项目),其绝大部分的 API 和参数都与 uglify-es 和 [email protected] 兼容。因此,两者对应参数的作用与优化方式也基本相同,这里就以 Terser 为例来分析其中的优化方向。
在作为 Webpack 插件的 TerserWebpackPlugin 中,对执行效率产生影响的配置主要分为 3 个方面:
@@ -238,13 +238,13 @@ function(module,exports){function HelloWorld(){var foo="1234";console.在了解了两个参数对压缩质量的影响之后,我们再来看下它们对效率的影响。以上面表格中的 example-antd 为例,我制作了下面的表格进行对比:
-从结果中可以看到,当compress参数为 false 时,压缩阶段的效率有明显提升,同时对压缩的质量影响较小。在需要对压缩阶段的效率进行优化的情况下,可以优先选择设置该参数。
CSS 同样有几种压缩工具可供选择:OptimizeCSSAssetsPlugin(在 Create-React-App 中使用)、OptimizeCSSNanoPlugin(在 VUE-CLI 中使用),以及CSSMinimizerWebpackPlugin(2020 年 Webpack 社区新发布的 CSS 压缩插件)。
这三个插件在压缩 CSS 代码功能方面,都默认基于 cssnano 实现,因此在压缩质量方面没有什么差别。
在压缩效率方面,首先值得一提的是最新发布的 CSSMinimizerWebpackPlugin,它支持缓存和多进程,这是另外两个工具不具备的。而在非缓存的普通压缩过程方面,整体上 3 个工具相差不大,不同的参数结果略有不同,如下面的表格所示(下面结果为示例代码中 example-css 的执行构建结果)。
-@@ -271,13 +271,13 @@ optimization: { ...注:CSSMinimizerWebpackPlugin 中默认开启多进程选项 parallel,但是在测试示例较小的情况下,多进程的通信时间反而可能导致效率的降低。测试中关闭多进程选项后,构建时间明显缩短。
在这个示例中,有两个入口文件引入了相同的依赖包 lodash,在没有额外设置分包的情况下, lodash 被同时打入到两个产物文件中,在后续的压缩代码阶段耗时 1740ms。而在设置分包规则为 chunks:'all' 的情况下,通过分离公共依赖到单独的 Chunk,使得在后续压缩代码阶段,只需要压缩一次 lodash 的依赖包代码,从而减少了压缩时长,总耗时为 1036ms。通过下面两张图片也可以看出这样的变化。
-
-
+
这里起作用的是 Webpack 4 中内置的 SplitChunksPlugin,该插件在 production 模式下默认启用。其默认的分包规则为 chunks: 'async',作用是分离动态引入的模块 (import('...')),在处理动态引入的模块时能够自动分离其中的公共依赖。
但是对于示例中多入口静态引用相同依赖包的情况,则不会处理分包。而设置为 chunks: 'all',则能够将所有的依赖情况都进行分包处理,从而减少了重复引入相同模块代码的情况。SplitChunksPlugin 的工作阶段是在optimizeChunks阶段(Webpack 4 中是在 optimizeChunksAdvanced,在 Webpack 5 中去掉了 basic 和 advanced,合并为 optimizeChunks),而压缩代码是在 optimizeChunkAssets 阶段,从而起到提升后续环节工作效率的作用。
Tree Shaking(摇树)是指在构建打包过程中,移除那些引入但未被使用的无效代码(Dead-code elimination)。这种优化手段最早应用于在 Rollup 工具中,而在 Webpack 2 之后的版本中, Webpack 开始内置这一功能。下面我们先来看一下 Tree Shaking 的例子,如下面的表格所示:
-可以看到,引入不同的依赖包(lodash vs lodash-es)、不同的引入方式,以及是否使用 babel 等,都会对 Tree Shaking 的效果产生影响。下面我们就来分析具体原因。
./src/example-basic.js
import _ from 'lodash'
-
-
+
可以看到,在没有增加任何优化设置的情况下,初次构建时在 optimizeChunkAssets 阶段的耗时是 1000ms 左右,而再次构建时的耗时直接降到了 18ms,几乎可以忽略不计。
这里的原因就在于,Webpack 4 内置了压缩插件 TerserWebpackPlugin,且默认开启了缓存参数。在初次构建的压缩代码过程中,就将这一阶段的结果写入了缓存目录(node_modules/.cache/terser-webpack-plugin/)中,当再次构建进行到压缩代码阶段时,即可对比读取已有缓存,如下面的代码所示(相关的代码逻辑在插件的源代码中可以看到)。
terser-webpack-plugin/src/index.js:
@@ -210,8 +210,8 @@ if (cache.isEnabled()) {
- cacheCompression:默认为 true,将缓存内容压缩为 gz 包以减小缓存目录的体积。在设为 false 的情况下将跳过压缩和解压的过程,从而提升这一阶段的速度。
开启缓存选项前后的构建时长效果如图所示(示例中运行 npm run build:babel),可以看到,由于开启了 Babel 的缓存,再次构建的速度比初次构建时要快了许多。
-
-
+
在编译过程中利用缓存的第二种方式是使用 Cache-loader。在使用时,需要将 cache-loader 添加到对构建效率影响较大的 Loader(如 babel-loader 等)之前,如下面的代码所示:
./webpack.cache.config.js
@@ -227,8 +227,8 @@ module: {
...
执行两次构建后可以发现,使用 cache-loader 后,比使用 babel-loader 的开启缓存选项后的构建时间更短,如下图所示:
-
-
+
主要原因是 babel-loader 中的缓存信息较少,而 cache-loader 中存储的Buffer 形式的数据处理效率更高。下面的示例代码,是 babel-loader 和 cache-loader 入口模块的缓存信息对比:
//babel-loader中的缓存数据
{"ast":null,"code":"import _ from 'lodash';","map":null,"metadata":{},"sourceType":"module"}
@@ -241,8 +241,8 @@ module: {
代码压缩时的缓存优化
在上一课时中曾提到,在代码压缩阶段,对于 JS 的压缩,TerserWebpackPlugin 和 UglifyJSPlugin 都是支持缓存设置的。而对于 CSS 的压缩,目前最新发布的 CSSMinimizerWebpackPlugin 支持且默认开启缓存,其他的插件如 OptimizeCSSAssetsPlugin 和 OptimizeCSSNanoPlugin 目前还不支持使用缓存。
TerserWebpackPlugin 插件的效果在本节课的开头部分我们已经演示过了,这里再来看一下 CSSMinimizerWebpackPlugin 的缓存效果对比,如下面的图片所示,开启该插件的缓存后,再次构建的时长降低到了初次构建的 1/4。
-
-
+
+
以上就是 Webpack 4 中编译与优化打包阶段可用的几种缓存方案。接下来我们再来看下在构建过程中使用缓存的一些注意点。
缓存的失效
尽管上面示例所显示的再次构建时间要比初次构建时间快很多,但前提是两次构建没有任何代码发生变化,也就是说,最佳效果是在缓存完全命中的情况下。而现实中,通常需要重新构建的原因是代码发生了变化。因此如何最大程度地让缓存命中,成为我们选择缓存方案后首先要考虑的事情。
@@ -253,8 +253,8 @@ module: {
编译阶段的执行时间由每个模块的编译时间相加而成。在开启缓存的情况下,代码发生变化的模块将被重新编译,但不影响它所依赖的及依赖它的其他模块,其他模块将继续使用缓存。因此,这一阶段不需要考虑缓存失效扩大化的问题。
优化打包阶段的缓存失效
优化打包阶段的缓存失效问题则需要引起注意。还是以课程开头的 example-basic 为例,在使用缓存快速构建后,当我们任意修改入口文件的代码后会发现,代码压缩阶段的时间再次变为和初次构建时相近,也就是说,这一 Chunk 的 Terser 插件的缓存完全失效了,如下面的图片所示。
-
-
+
+
之所以会出现这样的结果,是因为,尽管在模块编译阶段每个模块是单独执行编译的,但是当进入到代码压缩环节时,各模块已经被组织到了相关联的 Chunk 中。如上面的示例,4 个模块最后只生成了一个 Chunk,任何一个模块发生变化都会导致整个 Chunk 的内容发生变化,而使之前保存的缓存失效。
在知道了失效原因后,对应的优化思路也就显而易见了:尽可能地把那些不变的处理成本高昂的模块打入单独的 Chunk 中。这就涉及了 Webpack 中的分包配置——splitChunks。
使用 splitChunks 优化缓存利用率
@@ -268,7 +268,7 @@ optimization: {
},
...
-在许多自动化集成的系统中,项目的构建空间会在每次构建执行完毕后,立即回收清理。在这种情况下,默认的项目构建缓存目录(node_mo dules/.cache)将无法留存,导致即使项目中开启了缓存设置,也无法享受缓存的便利性,反而因为需要写入缓存文件而浪费额外的时间。因此,在集成化的平台中构建部署的项目,如果需要使用缓存,则需要根据对应平台的规范,将缓存设置到公共缓存目录下。这类问题我们会在第三模块部署优化中再次展开。
diff --git a/专栏/前端工程化精讲-完/14 增量构建:Webpack 中的增量构建.md.html b/专栏/前端工程化精讲-完/14 增量构建:Webpack 中的增量构建.md.html index 7cb15fd4..cd983b26 100644 --- a/专栏/前端工程化精讲-完/14 增量构建:Webpack 中的增量构建.md.html +++ b/专栏/前端工程化精讲-完/14 增量构建:Webpack 中的增量构建.md.html @@ -170,25 +170,25 @@ function hide_canvas() {但是只编译打包所改动的文件真的不能实现吗?这节课我们就来讨论这个话题(课程里完整的示例代码参见 14_incremental_build)。
上述只构建改动文件的处理过程在 Webpack 中是实际存在的,你可能也很熟悉,那就是在开启 devServer的时候,当我们执行 webpack-dev-server 命令后,Webpack 会进行一次初始化的构建,构建完成后启动服务并进入到等待更新的状态。当本地文件有变更时,Webpack 几乎瞬间将变更的文件进行编译,并将编译后的代码内容推送到浏览器端。你会发现,这个文件变更后的处理过程就符合上面所说的只编译打包改动的文件的操作,这就称为“增量构建”。我们通过示例代码进行验证(npm run dev),如下面的图片:
-
-
+
可以看到,在开发服务模式下,初次构建编译了 47 个模块,完整的构建时间为 3306ms。当我们改动其中一个源码文件后,日志显示 Webpack 只再次构建了这一个模块,因此再次构建的时间非常短(24ms)。那么为什么在开发服务模式下可以实现增量构建的效果,而在生产环境下不行呢?下面我们来分析影响结果的因素。
在上面的增量构建过程中,第一个想到的就是需要监控文件的变化。显然,只有得知变更的是哪个文件后,才能进行后续的针对性处理。要实现这一点也很简单,在“第 2 课时|界面调试:热更新技术如何开着飞机修引擎?”中已经介绍过,在 Webpack 中启用 watch 配置即可,此外在使用 devServer 的情况下,该选项会默认开启。那么,如果在生产模式下开启 watch 配置,是不是再次构建时,就会按增量的方式执行呢?我们仍然通过示例验证(npm run build:watch),如下面的图片所示:
-
-
+
从结果中可以发现,在生产模式下开启 watch 配置后,相比初次构建,再次构建所编译的模块数量并未减少,即使只改动了一个文件,也仍然会对所有模块进行编译。因此可以得出结论,在生产环境下只开启 watch 配置后的再次构建并不能实现增量构建。
仔细查阅 Webpack 的配置项文档,会在菜单最下方的“其他选项”一栏中找到 cache 选项(需要注意的是我们查阅的是 Webpack 4 版本的文档,Webpack 5 中这一选项会有大的改变,会在下一节课中展开讨论)。这一选项的值有两种类型:布尔值和对象类型。一般情况下默认为false,即不使用缓存,但在开发模式开启 watch 配置的情况下,cache 的默认值变更为true。此外,如果 cache 传值为对象类型,则表示使用该对象来作为缓存对象,这往往用于多个编译器 compiler 的调用情况。
下面我们就来看一下,在生产模式下,如果watch 和 cache 都为 true,结果会如何(npm run build:watch-cache)?如下面的图片所示:
-
-
+
正如我们所期望的,再次构建时,在编译模块阶段只对有变化的文件进行了重新编译,实现了增量编译的效果。
但是美中不足的是,在优化阶段压缩代码时仍然耗费了较多的时间。这一点很容易理解:
体积最大的 react、react-dom 等模块和入口模块打入了同一个 Chunk 中,即使修改的模块是单独分离的 bar.js,但它的产物名称的变化仍然需要反映在入口 Chunk 的 runtime 模块中。因此入口 Chunk 也需要跟着重新压缩而无法复用压缩缓存数据。根据前面几节课的知识点,我们对配置再做一些优化,将 vendor 分离后再来看看效果,如下面的图片所示:
-
-
+
可以看到,通过上面这一系列的配置后(watch + cache),在生产模式下,最终呈现出了我们期望的增量构建效果:有文件发生变化时会自动编译变更的模块,并只对该模块影响到的少量 Chunk 进行优化并更新产物文件版本,而其他产物文件则保持之前的版本。如此,整个构建过程的速度大大提升。
为什么在配置项中需要同时启用 watch 和 cache 配置才能获得增量构建的效果呢?接下来我们从源码层面分析。
diff --git a/专栏/前端工程化精讲-完/15 版本特性:Webpack 5 中的优化细节.md.html b/专栏/前端工程化精讲-完/15 版本特性:Webpack 5 中的优化细节.md.html index 1893d70f..0ac050b0 100644 --- a/专栏/前端工程化精讲-完/15 版本特性:Webpack 5 中的优化细节.md.html +++ b/专栏/前端工程化精讲-完/15 版本特性:Webpack 5 中的优化细节.md.html @@ -190,9 +190,9 @@ module.exports = { ... } -
-
-
+
+
可以看到,初次构建完整花费了 3282ms,而在不修改代码进行再次构建的情况下,只花费了不到原先时间的 1/10。在修改代码文件的新情况下也只花费了 628ms,多花费的时间体现在构建被修改的文件的编译上,这就实现了上一课时所寻求的生产环境下的增量构建。
在 Webpack 4 中,cache 只是单个属性的配置,所对应的赋值为 true 或 false,用来代表是否启用缓存,或者赋值为对象来表示在构建中使用的缓存对象。而在 Webpack 5 中,cache 配置除了原本的 true 和 false 外,还增加了许多子配置项,例如:
@@ -282,8 +282,8 @@ console.log(a)可以看到产物代码中只有被引入的属性 a 和 console 语句,而其他两个导出属性 b 和 c 已经在产物中被排除了。
第三个要提到的 Webpack 5 的效率优化点是,它增加了许多内部处理过程的日志,可以通过 stats.logging 来访问。下面两张图是使用相同配置*stats: {logging: "verbose"}*的情况下,Webpack 4 和 Webpack 5 构建输出的日志:
-
-
+
可以看到,Webpack 5 构建输出的日志要丰富完整得多。通过这些日志能够很好地反映构建各阶段的处理过程、耗费时间,以及缓存使用的情况。在大多数情况下,它已经能够代替之前人工编写的统计插件功能了。
除了上面介绍的和构建效率相关的几项变化外,Webpack 5 中还有许多大大小小的功能变化,例如新增了改变微前端构建运行流程的 Module Federation 和对产物代码进行优化处理的 Runtime Modules,优化了处理模块的工作队列,在生命周期 Hooks 中增加了 stage 选项等。感兴趣的话,你可以通过文章顶部的文档链接或官方网站来进一步了解。
diff --git a/专栏/前端工程化精讲-完/16 无包构建:盘点那些 No-bundle 的构建方案.md.html b/专栏/前端工程化精讲-完/16 无包构建:盘点那些 No-bundle 的构建方案.md.html index ffce60ae..33859220 100644 --- a/专栏/前端工程化精讲-完/16 无包构建:盘点那些 No-bundle 的构建方案.md.html +++ b/专栏/前端工程化精讲-完/16 无包构建:盘点那些 No-bundle 的构建方案.md.html @@ -188,11 +188,11 @@ import { appendHTML } from './common.js' ... import('https://cdn.jsdelivr.net/npm/[email protected]/slice.js').then((module) => {...}) -从示例中可以看到,在没有任何构建工具处理的情况下,在页面中引入带有 type="module" 属性的 script,浏览器就会在加载入口模块时依次加载了所有被依赖的模块。下面我们就来深入了解一下这种基于浏览器加载 JS 模块的技术的细节。
从 caniuse 网站中可以看到,目前大部分主流的浏览器都已支持 JavaScript modules 这一特性,如下图所示:
-[图片来源:https://caniuse.com/es6-module]
我们来总结这种加载方式的注意点。
可以看到,运行示例代码后,在浏览器中只引入了 src/main.js 这一个入口模块,但是在网络面板中却依次加载了若干依赖模块,包括外部模块 vue 和 css。依赖图如下:
-可以看到,经过 Vite 处理后,浏览器中加载的模块与源代码中导入的模块相比发生了变化,这些变化包括对外部依赖包的处理,对 vue 文件的处理,对 css 文件的处理等。下面我们就来逐个分析其中的变化。
对 HTML 文件的预处理
diff --git a/专栏/前端工程化精讲-完/18 工具盘点:掌握那些流行的代码部署工具.md.html b/专栏/前端工程化精讲-完/18 工具盘点:掌握那些流行的代码部署工具.md.html index e1373b0a..4c09bc8a 100644 --- a/专栏/前端工程化精讲-完/18 工具盘点:掌握那些流行的代码部署工具.md.html +++ b/专栏/前端工程化精讲-完/18 工具盘点:掌握那些流行的代码部署工具.md.html @@ -166,7 +166,7 @@ function hide_canvas() {上节课我们通过分析“为什么不在本地环境进行部署”这个问题,来对比部署系统的重要性:一个优秀的部署系统,能够自动化地完整部署流程的各环节,无须占用开发人员的时间与精力,同时又能保证环境与过程的一致性,增强流程的稳定性,降低外部因素导致的风险。此外,部署系统还可以提供过程日志、历史版本构建包、通知邮件等各类辅助功能模块,来打造更完善的部署工作流程。
这节课我就来为你介绍在企业项目和开源项目中被广泛使用的几个典型部署工具,包括 Jenkins、CircleCI、Github Actions、Gitlab CI。
Jenkins Logo
Jenkins 是诞生较早且使用广泛的开源持续集成工具。早在 2004 年,Sun 公司就推出了它的前身 Husdon,它在 2011 年更名为 Jenkins。下面介绍它的功能特点。
Jenkins 中 Job 的基本配置界面
CircleCI Logo
CircleCI 是一款基于云端的持续集成服务,下面介绍它的功能特点。
CircleCI 项目流水线示例界面
Github Actions Logo
Github Actions(GHA)是 Github 官方提供的 CI/CD 流程工具,用于为 Github 中的开源项目提供简单易用的持续集成工作流能力。
Github Actions 的工作流模板
-Github Actions 中的矩阵执行示例
Gitlab 是由 Gitlab Inc. 开发的基于 Git 的版本管理与软件开发平台。除了作为代码仓库外,它还具有在线编辑、Wiki、CI/CD 等功能。在费用方面,它提供了免费的社区版本(Community Edition,CE)和免费或收费的商用版本(Enterprise Edition,EE)。其中社区版本和免费的商用版本的区别主要体现在升级到付费商用版本时的操作成本。另一方面,即使是免费的社区版本,其功能也能够满足企业内的一般使用场景,因此常作为企业内部版本管理系统的主要选择之一,下面我们就来了解 Gitlab 内置的 CI/CD 功能。
@@ -220,7 +220,7 @@ function hide_canvas() {当项目根目录中存在.gitlab-ci.yml 文件时,用户提交代码到 Git 仓库时,在 Gitlab 的 CI/CD 面板中即可看到相应的任务记录,当成功设置 gitlab-runner 时这些任务就会在相应的 Runner 中执行并反馈日志和结果。如下图所示:
-Gitlab CI/CD 的任务列表示例界面
最后我们来做一个总结。在今天的课程里,我们一起了解了 4 个典型 CI/CD 工具:Jenkins、CircleCI、Github Actions 和 Gitlab CI。
diff --git a/专栏/前端工程化精讲-完/19 安装提效:部署流程中的依赖安装效率优化.md.html b/专栏/前端工程化精讲-完/19 安装提效:部署流程中的依赖安装效率优化.md.html index b83e129f..942f725d 100644 --- a/专栏/前端工程化精讲-完/19 安装提效:部署流程中的依赖安装效率优化.md.html +++ b/专栏/前端工程化精讲-完/19 安装提效:部署流程中的依赖安装效率优化.md.html @@ -200,7 +200,7 @@ time pnpm i在确定了安装工具和分析方式后,我们还需要对执行过程进行划分,下面我一共区分了 5 种项目执行安装时可能遇到的场景:
-注 1:除了第一种纯净环境外,后面的环境中都存在 Lock 文件。因为 Lock 文件对于提供稳定依赖版本至关重要。出于现实场景考虑,这里不再单独对比没有 Lock 文件但存在历史安装目录的场景。 注 2: 为了屏蔽网络对解析下载依赖包的影响,所有目录下均使用相同注册表网址 registry.npm.taobao.org。 @@ -209,7 +209,7 @@ time pnpm i
不同维度对安装效率的影响分析
纯净环境
首先来对纯净环境进行分析,不同安装方式的执行耗时统计如下:
-+
注 1:总安装时间为执行后显示的时间。而各阶段的细分时间在日志中分析获取。 注 2:在 pnpm 的执行过程中并未对各阶段进行完全分隔,而是针对不同依赖包递归执行各阶段,这种情况在纯净环境中尤其明显,因此阶段时间上不便做单独划分。
@@ -222,7 +222,7 @@ time pnpm iLock 环境
然后我们来考察 Lock 文件对于安装效率的影响。和第一种最纯净的情况相比,带有 Lock 文件的情况通常更符合现实中项目在部署环境中的初始状态(因为 Lock 文件可以在一定程度上保证项目依赖版本的稳定性,因此通常都会把 Lock 文件也保留在代码仓库中)。引入 Lock 文件后,不同安装工具执行安装的耗时情况如下:
-+
@@ -233,7 +233,7 @@ time pnpm i注 1: Yarn 解析依赖阶段日志未显示耗时,因此标记为 0 秒。
缓存环境
缓存环境是在部署服务中可能遇到的一种情形。项目在部署过程中依赖安装时产生了本地缓存,部署结束后项目工作目录被删除,因此再次部署开始时工作目录内有 Lock 文件,也有本地缓存,但是不存在安装目录。这种情形下的耗时统计如下:
-+
对结果的分析如下:
- 从执行时间上看,各类型的安装方式的耗时都明显下降。
@@ -241,7 +241,7 @@ time pnpm i无缓存的重复安装环境
无缓存的重复安装环境在本地环境下部署时可能遇到,即当本地已存在安装目录,但人工清理缓存后再次执行安装时可能遇到。这种情况的耗时如下:
-+
对结果的分析如下:
- 从上面的表格中可以看到,存在安装目录这一条件首先对链接阶段能起到优化的作用。对于下载阶段,除了使用 PnP 的两种安装方式外,当项目中已存在安装目录时,下载阶段耗时也趋近于零。其中 Yarn v1 表现最好,各主要阶段都直接略过,而 npm 和 pnpm 则多少还有一些处理过程。
@@ -249,7 +249,7 @@ time pnpm i有缓存的重复安装环境
最后是安装目录与本地缓存都存在的情况,耗时如下:
-+
对结果的分析如下:
- 无论对于哪种安装方式而言,这种情况都是最理想的。可以看到,各安装工具的耗时都趋近于零。其中尤其对于 Yarn v1 而言效率最高,而 pnpm 次之,npm 相对最慢。
diff --git a/专栏/前端工程化精讲-完/20 流程优化:部署流程中的构建流程策略优化.md.html b/专栏/前端工程化精讲-完/20 流程优化:部署流程中的构建流程策略优化.md.html index 7e705b08..f80972e5 100644 --- a/专栏/前端工程化精讲-完/20 流程优化:部署流程中的构建流程策略优化.md.html +++ b/专栏/前端工程化精讲-完/20 流程优化:部署流程中的构建流程策略优化.md.html @@ -174,7 +174,7 @@ npm config set registry xxxx #yarn设置下载源 yarn config set registry xxxx -+
下载同样的依赖包,使用国内镜像源的速度只有官方源的 1/4。有条件的情况下可以在企业内网部署私有源,下载速度可以得到进一步提升。
2.二进制下载源:对于一些依赖包(例如 node-sass 等),在安装过程中还需下载二进制文件,这类文件的下载不遵循 registry 的地址,因此需要对这类文件单独配置下载路径来提升下载速度。示例配置如下代码(更多配置可以参考国内的镜像网址):
npm config set sass-binary-site https://npm.taobao.org/mirrors/node-sass @@ -212,7 +212,7 @@ npm config set puppeteer_download_host https://npm.taobao.org/mirrors
提升压缩效率的工具
这里介绍两种压缩工具:Pigz 和 Zstd。更多压缩工具的选择以及性能对比可以参见参考文档。
首先我们对这两种工具和 tar 命令中默认的 Gzip 压缩选项的参数进行对比(数据来自上面的参考文档),如下面的表格所示:
-+
从表格中可以发现:
- 对于同一款压缩工具来说,压缩等级越低,压缩速度越快。代价是相应的压缩率越低,压缩体积会相应增大。
diff --git a/专栏/前端工程化精讲-完/23 结束语 前端效率工程化的未来展望.md.html b/专栏/前端工程化精讲-完/23 结束语 前端效率工程化的未来展望.md.html index 420745ad..75002447 100644 --- a/专栏/前端工程化精讲-完/23 结束语 前端效率工程化的未来展望.md.html +++ b/专栏/前端工程化精讲-完/23 结束语 前端效率工程化的未来展望.md.html @@ -165,7 +165,7 @@ function hide_canvas() {23 结束语 前端效率工程化的未来展望
你好,我是李思嘉。
本专栏的内容到这里就结束了。我们先来简单回顾一下整个课程的主要内容,如下图:
-+
在这个专栏中,我主要介绍且梳理了前端工程化中效率提升方向的知识,内容涵盖开发效率、构建效率和部署效率三个方面。希望你通过这个系列课程的学习,能建立起前端效率工程化方面相对完整的知识体系,同时在前端开发日常流程中的效率工程类问题方面,能找到分析和解决的新方向。
当然,这些方向实际涵盖的概念与技术点非常广泛,并不容易完全掌握,除了已有的概念和技术之外,新的技术和方向也在不断涌现。下面我会对前端效率工程化相关的技术做一些展望。
云工作流
diff --git a/专栏/左耳听风/095 高效学习:端正学习态度.md.html b/专栏/左耳听风/095 高效学习:端正学习态度.md.html index 131b0d8a..9a9cd650 100644 --- a/专栏/左耳听风/095 高效学习:端正学习态度.md.html +++ b/专栏/左耳听风/095 高效学习:端正学习态度.md.html @@ -535,7 +535,7 @@ function hide_canvas() {然后,我会在后面给你一些方法和相关的技能,让你可以真正实际操作起来。
主动学习和被动学习
1946 年,美国学者埃德加·戴尔(Edgar Dale)提出了「学习金字塔」(Cone of Learning)的理论。之后,美国缅因州国家训练实验室也做了相同的实验,并发布了「学习金字塔」报告。
-+
人的学习分为「被动学习」和「主动学习」两个层次。
- 被动学习:如听讲、阅读、视听、演示,学习内容的平均留存率为 5%、10%、20% 和 30%。
diff --git a/专栏/左耳听风/099 高效学习:面对枯燥和量大的知识.md.html b/专栏/左耳听风/099 高效学习:面对枯燥和量大的知识.md.html index 8de70277..64f69925 100644 --- a/专栏/左耳听风/099 高效学习:面对枯燥和量大的知识.md.html +++ b/专栏/左耳听风/099 高效学习:面对枯燥和量大的知识.md.html @@ -545,7 +545,7 @@ function hide_canvas() {在这里,我想说,用户手册(User Manual)一定要好好地读一读,很多很多提示都在里面了,这是让你可以少掉很多坑的法宝。比如:Unix 和 Linux 的 man,Docker 和 Kubernetes 的官方文档,Git 的操作文档……你的很多很多问题的答案都在这些文档中。
举个例子,很多年前,我掉了一个坑,我把这个问题记录在了文章《 C/C++ 返回内部静态成员的陷阱 》中。 其中提到了一个函数
char *inet_ntoa(struct in_addr in);
,我还批评了一下这个函数。然而,只要你 man 一下这个函数,就可以看到:“The string is returned in a statically allocated buffer, which subsequent calls will overwrite”。还有,很多中国的文档都会教人把 tcp_tw_recycle 和 tcp_tw_resue 这两个参数打开。然而,只要你 man 一下 TCP(7) ,就可以看到这样的描述:
-+
你就可以看到这两个参数都是不建议被打开的。
认真阅读用户手册不但可以让你少掉很多坑,同时,还能让你学习到很多。
其它几个实用的技巧
diff --git a/专栏/左耳听风/107 结束语 业精于勤,行成于思.md.html b/专栏/左耳听风/107 结束语 业精于勤,行成于思.md.html index 828a20ca..3543417b 100644 --- a/专栏/左耳听风/107 结束语 业精于勤,行成于思.md.html +++ b/专栏/左耳听风/107 结束语 业精于勤,行成于思.md.html @@ -537,7 +537,7 @@ function hide_canvas() {不过,好在现在的人都被微博、微信、知乎、今日头条、抖音等这些 App 消费着(注意:我说的不是人在消费 App,而是人被 App 消费),然后英文还不行,科学上网也不行。所以,你真的不需要努力,只需要正常,你就可以超过绝大多数人。
你真的千万不要以为你订几个专栏,买几本书,听高手讲几次课,你就可以变成高手了。这就好像你以为你买了一个高级的机械键盘,27 吋的 4K 屏、高性能的电脑,高级的人体工程学的桌椅,你就可以写出好的代码来一样。我们要成为一个好的羽毛球高手,不是买几副好的运动装备,到正规的体育场去打球,而是要付出常人不能付出的汗水甚至伤痛。任何行业都是这样的。
这里,我还要把我《高效学习》中那个学习金字塔再帖出来。
-+
再次强调一下,这个世界上的学习只有两种,一种是被动学习,一种是主动学习。听课,看书,看视频,看别人的演讲,这些统统都是被动学习,知识的留存度最多只有 30%,不信你问问自己,今天我的专栏中,你记住了多少?而与别人讨论,实践和传授给别人,是主动学习,其可以让你掌握知识的 50% 到 90% 以上。
所以,我希望我的专栏没有给你带来那种速成的幻觉,而是让你有了可以付出汗水的理由和信心。我没有把我获取知识的手段和我的知识图给隐藏起来,然后,用我理解的东西再贩卖给大家。这样,我可以把我的《程序员练级攻略》一共拆成 20-30 个小专栏,然后一点一点地来收割大家,这样,我可以把大家困在知识的最底层。
然而,我并没有这样做。我觉得大家应该要去自己读最源头的东西,源头的文章都有很多的链接,你也会有第一手的感受,这样你可以顺着找到更好的知识源,并组织出适合你自己的学习路径和地图。订阅我的专栏,如果你不能够按照我专栏里的那些东西去践行的话,那么也毫无意义。
diff --git a/专栏/微服务质量保障 20 讲-完/00 开篇词 既往不恋,当下不杂,未来不迎.md.html b/专栏/微服务质量保障 20 讲-完/00 开篇词 既往不恋,当下不杂,未来不迎.md.html index 4380aadf..bc40dc09 100644 --- a/专栏/微服务质量保障 20 讲-完/00 开篇词 既往不恋,当下不杂,未来不迎.md.html +++ b/专栏/微服务质量保障 20 讲-完/00 开篇词 既往不恋,当下不杂,未来不迎.md.html @@ -164,8 +164,8 @@ function hide_canvas() {也有很多测试从业者认识到了互联网的核心是各种类型的微服务,而且服务端承载了业务的核心逻辑和用户价值,所以他们选择了服务端测试工程师职业方向。思路和切入点很好,但是对于微服务架构下的服务端应该如何测试,网络上大多是关于接口测试自动化及框架之类的资料,很难让他们建立一个整体的认知,并因此容易误会为——服务端测试只能通过接口测试来进行。
其实,服务端测试是一套全方位的测试保障体系,除了保证对外提供的接口符合要求,在业务广度和技术深度方面都需要有良好的覆盖率,并且要求有一系列的流程规范、方法、工具等做支撑。而软件测试人员需要根据技术架构和测试对象的特点,相应地调整自己的测试策略和思路,积累和总结测试方法和技能,进而沉淀出体系化的保障体系。
此外,各大互联网公司也都在积极招募服务端测试高级工程师、服务端测试开发工程师等服务端测试岗位,薪资非常具有竞争优势:
--
+
+
从招聘需求中可以看到,与很多测试从业者对服务端测试的认知和技能还停留在传统的服务端测试阶段不同,大厂已经明确要求服务端测试工程师参与服务端质量保障体系的建设。而即使熟悉服务端质量保障体系的测试人才,也因为微服务的盛行面临新的挑战。他们需要针对微服务的特点、所在项目的环境情况做进一步的分析,对质量保障体系做合理裁剪,才能真正落地应用。
服务端质量(保障)体系的重要性
这里我们有必要先厘清两个概念:测试更多指具体的测试活动,而质量保障是一个全面的体系化的内容,测试只是其中的一个环节或方面。
diff --git a/专栏/微服务质量保障 20 讲-完/01 微服务架构有哪些特点?.md.html b/专栏/微服务质量保障 20 讲-完/01 微服务架构有哪些特点?.md.html index c9c95a45..f7c08890 100644 --- a/专栏/微服务质量保障 20 讲-完/01 微服务架构有哪些特点?.md.html +++ b/专栏/微服务质量保障 20 讲-完/01 微服务架构有哪些特点?.md.html @@ -158,9 +158,9 @@ function hide_canvas() {首先,我以我自身的两份工作经历,来让你感受下什么是微服务,以及微服务架构的优缺点。这样有利于你理解后面的课时内容,同时更加有代入感。
单体应用架构下的服务特性
我第一份工作是网络游戏的测试保障工作,在功能测试之外做了很多服务端相关的工作,如编译后分发、配置、部署、发布等。那时候的服务端应用程序是几个独立的几十兆、上百兆的文件。每个文件是一个可执行文件,包含一个系统的所有功能,这些功能被打包成一体化的文件,几乎没有外部依赖,可以独立部署在装有 Linux 系统的硬件服务器上。 这种应用程序通常被称为单体应用,单体应用的架构方法论,就是单体应用架构(Monolithic Architecture)。单体应用架构下,一个服务中包含了与用户交互的部分、业务逻辑处理层和数据访问层。如果存在数据库交互则与数据库直连,如下图所示。
-+
单体应用架构下,一个服务中,两个业务模块作为该服务的一部分存在同一进程中,它们通过方法调用的方式进行通信,如下图所示。
-+
通过在单体应用架构下,不同阶段的服务端相关工作,可以感知到单体应用的特性。
1. 日常研发测试阶段
@@ -178,10 +178,10 @@ function hide_canvas() {
-现在应用程序日益复杂化,项目对于迭代速度的要求也越来越高,上述的不足会暴露得更加明显,在这种时代背景下,微服务架构开始在企业生根发芽。
微服务架构下的服务特性
后来我转到了互联网公司工作,所在项目的服务架构与过去经历过的单体应用架构下的服务差异巨大。同等规模的研发团队,服务的个数竟然有近百个,虽然数量众多,但每个服务都只负责一小块儿具体的业务功能,能独立地部署到环境中,服务间边界相对清晰,相互间通过轻量级的接口调用或消息队列进行通信,为用户提供最终价值。这样的服务称为微服务(Microservice)。 从本质上来说,微服务是一种架构模式,是面向服务型架构(SOA)的一种变体,如下图所示。
-+
上图所示,微服务架构下,业务逻辑层被分拆成不同的微服务,其中不需要与数据库交互的服务将不再与数据库连接,需要与数据库交互的服务则直接与数据库连接。
微服务架构下,因为两个服务分别在自己的进程中,所以它们不能通过方法调用进行通信,而是通过远程调用的方式进行通信,如下图所示。
-+
同样,通过在微服务架构下,不同阶段的服务端相关工作,可以感知到微服务的特性。
1. 日常研发测试阶段
因为微服务数量众多,研发和测试团队都有诉求构建一个良好的基础建设。如搭建持续交付工具,通过持续交付工具拉取某微服务代码,再进行编译、分发、部署到测试环境的机器上。再加上,微服务应用程序本身并不大,部署耗时短、影响范围小、风险低,整个编译分发部署的过程在几分钟之内就可以搞定,且几乎是自动完成,因此部署频率可以做到很高。
@@ -192,7 +192,7 @@ function hide_canvas() {4. 其他阶段
架构设计方面,微服务可以使用不同的语言,采用不同的架构,部署到不同的环境。同时可以采用适合微服务业务场景的技术,来构建合理的微服务模块。
由此可见,微服务的确解决了单体应用架构下服务的诸多短板。单体应用与微服务对比总结如下。
-+
微服务的缺点
当然,事物都有两面性,任何一项技术都不可能十全十美,在解决一定问题的同时,也会引入新的问题。 那么,微服务架构下服务有哪些缺点呢?
从微服务架构设计角度来看。
diff --git a/专栏/微服务质量保障 20 讲-完/03 微服务架构下的测试策略.md.html b/专栏/微服务质量保障 20 讲-完/03 微服务架构下的测试策略.md.html index 64d1084c..7250b15e 100644 --- a/专栏/微服务质量保障 20 讲-完/03 微服务架构下的测试策略.md.html +++ b/专栏/微服务质量保障 20 讲-完/03 微服务架构下的测试策略.md.html @@ -166,24 +166,24 @@ function hide_canvas() {- 测试需要分层,每一层的测试颗粒度有所不同;
- 不同层次的测试比重有差异,通常来说,层次越高,测试比重应越少。
+
需要说明的是,传统意义下的测试金字塔,在微服务架构下不再完全奏效。因为微服务中最大的复杂性不在于服务本身,而在于微服务之间的交互方式,这一点值得特别注意。
因此,针对微服务架构,常见的测试策略模型有如下几种。
(1) 微服务“测试金字塔”
基于微服务架构的特点和测试金字塔的原理,Toby Clemson 有一篇关于“微服务架构下的测试策略”的文章,其中通过分析阐述了微服务架构下的通用测试策略。
-+
如图,该策略模型依然是金字塔形状,从下到上依次为单元测试、集成测试、组件测试、端到端测试、探索式测试。
(2) 微服务“测试蜂巢”
这种策略模型是蜂巢形状,它强调重点关注服务间的集成测试,而单元测试和端到端测试的占比较少。
-+
(3) 微服务“测试钻石”
这种策略模型是钻石形状的,组件测试和契约测试是重点,单元测试比率减少,另外增加了安全和性能等非功能的测试类型。
-+
我想,有多少个基于微服务架构的测试团队大概就有多少个测试策略模型吧。“测试金字塔”是一种测试策略模型和抽象框架,当技术架构、系统特点、质量痛点、团队阶段不同时,每种测试的比例也不尽相同,而且最关键的,并不一定必须是金字塔结构。
理解了测试策略模型的思考框架,我们看下应如何保障测试活动的全面性和有效性。
全面性
微服务架构下,既需要保障各服务内部每个模块的完整性,又需要关注模块间、服务间的交互。只有这样才能提升测试覆盖率和全面性,因此,可以通过如下的分层测试来保证微服务的全面性。
-+
- 单元测试(Unit Test) :从服务中最小可测试单元视角验证代码行为符合预期,以便测试出方法、类级别的缺陷。
- 集成测试(Integration Test):验证当前服务与外部模块之间的通信方式或者交互符合预期,以便测试出接口缺陷。
@@ -198,7 +198,7 @@ function hide_canvas() {测试策略如同测试技术、技术架构一样,并不是一成不变,它会随着业务或项目所处的阶段,以及基于此的其他影响因素的变化而不断演进。但归根结底,还是要从质量保障的目标出发,制定出适合当时的测试策略,并阶段性地对策略进行评估和度量,进而不断改进和优化测试策略。因此,选取测试策略一定要基于现实情况的痛点出发,结果导向,通过调整测试策略来解决痛点。
比如,在项目早期阶段或某 MVP 项目中,业务的诉求是尽快发布到线上,对功能的质量要求不太高,但对发布的时间节点要求非常严格。那这种情况下快速地用端到端这种能模拟用户真实价值的测试方法保障项目质量也未尝不可;随着项目逐渐趋于平稳后,时间要求渐渐有了节奏,对功能的质量要求会逐渐变高,那么这时候可以再根据实际情况引入其他测试方法,如契约测试或组件测试等。
你要永远记住,适合自身项目阶段和团队的测试策略才是“完美”的策略。
-+
如何建立质量保障体系?
上述分层的测试策略只是尽可能地对微服务进行全面的测试,确保系统的所有层次都被覆盖到,它更多体现在测试活动本身的全面性和有效性方面。要想将质量保障内化为企业的组织能力,就需要通过技术和管理手段形成系统化、标准化和规范化的机制,这就需要建设质量保障体系。
质量保障体系:通过一定的流程规范、测试技术和方法,借助于持续集成/持续交付等技术把质量保障活动有效组合,进而形成系统化、标准化和规范化的保障体系。 同时,还需要相应的度量、运营手段以及组织能力的保障。
diff --git a/专栏/微服务质量保障 20 讲-完/04 单元测试:怎样提升最小可测试单元的质量?.md.html b/专栏/微服务质量保障 20 讲-完/04 单元测试:怎样提升最小可测试单元的质量?.md.html index ea59e86f..40e75ca1 100644 --- a/专栏/微服务质量保障 20 讲-完/04 单元测试:怎样提升最小可测试单元的质量?.md.html +++ b/专栏/微服务质量保障 20 讲-完/04 单元测试:怎样提升最小可测试单元的质量?.md.html @@ -158,7 +158,7 @@ function hide_canvas() {单元测试的价值
单元测试是一种白盒测试技术,通常由开发人员在编码阶段完成,目的是验证软件代码中的每个单元(方法或类等)是否符合预期,即尽早在尽量小的范围内暴露问题。
我们都知道,问题发现得越早,修复的代价越小。毫无疑问,在开发阶段进行正确的单元测试可以极大地节省时间和金钱。如果跳过单元测试,会导致在后续更高级别的测试阶段产生更高的缺陷修复成本。
-+
如图,假如有一个只包含两个单元 A 和 B 的程序,且只执行端到端测试,如果在测试过程中发现了缺陷,则可能有如下多种原因:
- 该缺陷由单元 A 中的缺陷引起;
@@ -185,10 +185,10 @@ function hide_canvas() {就像之前课程所说:微服务中最大的复杂性不在于服务本身,而在于微服务之间的交互方式,服务与服务之间常常互相调用以实现更多更复杂的功能。
举个例子,我们需要测试的是订单类(Order)中的获取总价方法(getTotalPrice()),而在该方法中除了自有的一些代码逻辑外,通常需要去调用其他类的方法。比如这里调用的是用户类(User)的优惠等级方法(reductionLevel ())和商品类(Goods)中的商品价格方法(getUnitPrice())。很显然,优惠等级方法或商品价格方法,只要一方有错误,就会导致订单类获取总价方法的测试失败。基于这种情况,可以有两种单元测试类型。
1. 社交型单元测试(Sociable Unit Testing)
-+
如图,测试订单类的获取总价方法(Order.getTotalPrice())时会真实调用用户类的优惠等级方法(User.reductionLevel())和商品类的商品单价方法(Goods.getUnitPrice())。将被测试单元视为黑盒子,直接对其进行测试,这种单元测试称之为社交型单元测试(Sociable Unit Testing)。
2. 孤立型单元测试(Solitary Unit Testing)
-+
如图,如果测试订单类的获取总价方法(Order.getTotalPrice())时,使用测试替身 (test doubles) 技术来替代用户类的优惠等级方法(User.reductionLevel())和商品类的商品单价方法(Goods.getUnitPrice())的效果。对象及其依赖项之间的交互和协作被测试替身代替,这种单元测试称之为孤立型单元测试(Solitary Unit Testing)。
另外,上述提到的测试替身是一种在测试中使用对象代替实际对象的技术,常用的技术如下。
@@ -196,9 +196,9 @@ function hide_canvas() {
- 模拟代码(Mocks):模拟代码跟桩代码类似,它除了代替真实代码的能力之外,更强调是否使用了特定的参数调用了特定方法,因此,这种对象成为我们测试结果的基础。
根据被测单元是否与其交互者隔离,会产生以上两种单元测试类型,这两种类型的单元测试在微服务测试中都起着重要作用,它们用来解决不同的测试问题。
-+
由上图可知,在微服务架构中,不同组成使用的单元测试类型不同:
-+
特别注意:当微服务的(网关+仓库+资源+服务层)与(域逻辑)之比相对较大时,单元测试可能收益不大。常见的情况有小型服务或某些几乎只包含了网关+仓库+资源+服务层等内容的服务,例如适配服务等。
如何开展单元测试?
在实际项目过程当中,应该怎样开展单元测试呢?通常来说,可以通过如下四个步骤来进行。
@@ -216,7 +216,7 @@ function hide_canvas() {只单纯地看单元测试的执行通过率还比较单一,为了更全面地看到测试的覆盖情况,可以借助代码覆盖率工具和技术。在 Java 语言里,常用覆盖率工具有 Jacoco、Emma 和 Cobertura,个人推荐使用 Jacoco。
4. 接入持续集成工具
接入持续集成工具是为了形成工具链,将单元测试、代码覆盖率统计集成在一起,使得代码有提交时便自动触发单元测试用例的执行,并伴随有代码覆盖率的统计,最后可以看到单元测试报告的数据(用例通过情况和代码层面各个维度的覆盖数据)。接着可以判断是否需要修改代码,这便形成了一个代码质量的反馈环,如下图所示。
-+
后续的文章还会讲解到代码覆盖率工具和持续集成工具。
单元测试最佳实践
了解了如何开展单元测试,那么如何做到最好呢?我们都知道,代码产生错误无非是对一个业务逻辑或代码逻辑没有实现、实现不充分、实现错误或过分实现,所以无论是拆解业务逻辑还是拆解逻辑控制时都要做到 MECE 原则(全称 Mutually Exclusive Collectively Exhaustive,中文意思是“相互独立,完全穷尽”,即日常沟通中常说的“不重不漏”)。
diff --git a/专栏/微服务质量保障 20 讲-完/05 集成测试:如何进行微服务的集成测试?.md.html b/专栏/微服务质量保障 20 讲-完/05 集成测试:如何进行微服务的集成测试?.md.html index b72ec63f..23638d1f 100644 --- a/专栏/微服务质量保障 20 讲-完/05 集成测试:如何进行微服务的集成测试?.md.html +++ b/专栏/微服务质量保障 20 讲-完/05 集成测试:如何进行微服务的集成测试?.md.html @@ -164,7 +164,7 @@ function hide_canvas() {微服务架构下也需要集成测试,需要针对不同服务的不同方法之间的通信情况进行相关测试。 因为在对微服务进行单元测试时,单元测试用例只会验证被测单元的内部逻辑,并不验证其依赖的模块。即使对于服务 A 和服务 B 的单元测试分别通过,并不能说明服务 A 和服务 B 的交互是正常的。
对于微服务架构来说,集成测试通常关注于验证那些与外部组件(例如数据存储或其他微服务)通信的子系统或模块。 目标是验证这些子系统或模块是否可以正确地与外部组件进行通信,而不是测试外部组件是否正常工作。因此,微服务架构下的集成测试,应该验证要集成的子系统之间与外部组件之间的基本通信路径,包括正确路径和错误路径。
微服务架构下的集成测试
-+
微服务结构图与集成测试边界
如上图所示,网关组件层(Gateways+Http Client+External Service)包含了访问外部服务的逻辑,通常包含一个 HTTP/S 的客户端,客户端会连接到系统中另一个微服务或外部服务。数据持久层(Date Mappers/ORM)用于连接外部数据存储。
即,微服务架构下的集成测试主要包括两部分:
@@ -175,7 +175,7 @@ function hide_canvas() {这里请注意,因为需要测试微服务下子系统之间的通信和外部服务的通信是否正确,所以理想情况下不应该对外部组件使用测试替身(Test Double)。
下面我们逐一来看这两部分是如何进行集成测试的:
(1)网关组件层集成测试
-+
假设有个登录服务,该服务需要知道当前时间,而时间是由一个外部的时间服务提供的。当向 /api/json/cet/now 发出 GET 请求时,状态码为 200,并返回如下完整的时间信息。
{ $id: "1", @@ -203,7 +203,7 @@ serviceResponse: null,
- 进行相关的测试;
- 循环上述这个过程。
-+
常见问题及解决策略
然而,有很多时候外部服务不可用(服务尚未开发完成、服务有 block 级别的缺陷未修复),或其异常行为(如外部组件的超时、响应变慢等)很难去验证。外部组件不能使用测试替身,外部服务又不可用或异常场景难构造,看似无解,实际上都是有替代方案的。
服务不可用
diff --git a/专栏/微服务质量保障 20 讲-完/06 组件测试:如何保证单服务的质量?.md.html b/专栏/微服务质量保障 20 讲-完/06 组件测试:如何保证单服务的质量?.md.html index 9c953107..57f446be 100644 --- a/专栏/微服务质量保障 20 讲-完/06 组件测试:如何保证单服务的质量?.md.html +++ b/专栏/微服务质量保障 20 讲-完/06 组件测试:如何保证单服务的质量?.md.html @@ -159,7 +159,7 @@ function hide_canvas() {组件(Component)通常指大型系统中任何封装良好、连贯且可独立替换的中间子系统,在微服务架构中,一般代表单个微服务,因而组件测试(Component Testing)就是对单个服务的测试。
在一个典型的微服务应用程序中,会有许多微服务,且它们之间存在相互调用关系。因此,要想高效地对单个微服务进行测试,需要将其依赖的其他微服务和数据存储模块进行模拟(mock)。
比如,使用测试替身(Test Double)工具隔离掉单个微服务依赖的其他微服务和数据存储,避免测试过程中受到依赖服务或数据存储模块的各类影响(如服务不可用、服务缺陷、数据库连接断开等)而出现阻塞测试过程、测试无效等情况。
-+
从某种意义上来说,组件测试的本质上是将一个微服务与其依赖的所有其他服务和数据存储模块等隔离开,对该服务进行的功能验收测试。
基于组件测试的隔离特性,它有如下优势:
@@ -170,7 +170,7 @@ function hide_canvas() {
根据组件测试调用其依赖模块的方式,以及测试替身位于被测服务所在进程的内部或外部,可以有两种方式:进程内组件测试和进程外组件测试。
进程内组件测试
进程内组件测试是将测试替身注入所测服务所在的进程中,这样对服务的依赖调用通过方法调用的方式实现,不再需要使用网络。
-+
进程内组件测试示意图
如图所示,进程内组件测试有如下变化:
@@ -183,14 +183,14 @@ function hide_canvas() {
进程外组件测试
进程外组件测试则是将测试替身置于被测服务所在进程之外,因而被测服务需要通过实际网络调用与模拟的外部服务进行交互。
如下图所示,只用模拟的外部服务(Stub Service)替代了真实的外部服务(External Service),所以模拟的外部服务和被测服务都以单独的进程运行,而对于数据库、消息代理等基础设施模块则直接使用真实的。因此,被测服务和模拟的外部服务存在于不同的进程中,这就是“进程外(out-of-process)”的具体表现。除了对功能逻辑有所验证外,进程外组件测试还验证了微服务具有正确的网络配置并能够处理网络请求。
-+
进程外组件测试示意图
关于外部服务模拟,也有不同的类型,常见的有使用事先构造好的静态数据、通过传参方式动态调用API、使用录制回放技术(record-replay mechanism),你可以根据自己的需求选取模拟类型,如果依赖的服务仅提供少数几个固定的功能,并且返回结果较为固定,可以使用静态数据来模拟;如果依赖的服务功能较为单一,但是返回结果有一定的规律,可以使用动态调用 API 的方式来模拟;如果依赖的服务功能丰富多样,那么推荐使用录制回放技术来模拟。
在实际微服务项目中,进程外的组件测试非常常见,一般使用服务虚拟化工具对依赖的服务进行模拟。上一课时给出了 Wiremock 模拟服务通信的例子,在进行组件测试时,依然可以用Wiremock,但与集成测试不同的是,组件测试需要更加深入:验证被测服务的功能或行为是否符合预期、返回结果的格式是否符合预期、对服务超时、异常返回等异常行为是否具有容错能力,等等。
用 Wiremock 模拟服务的具体步骤如下:
1.下载 Wiremock 独立版本(wiremock-jre8-standalone-2.27.0.jar); 2.作为独立版本运行,效果如下:
-+
启动后,Wiremock 会在本地启动一个监听指定端口的 web 服务,端口可以用 --port和 --https-port 来分别指定 http 协议和指定 https 协议端口。之后发到指定端口的请求,就会由 WireMock 来完成响应,从而达到接口 Mock 的目的。
这时,在本地运行目录下会看到自动生成 __files 和 mappings 两个目录。这两个目录中存放的是 Mock 模拟的接口匹配内容。其中 __files 存放接口响应中会用到的一些文件资源,mappings 存放接口响应匹配规则。匹配文件以 json 格式存放在 mappings 目录下,WireMock 会在启动后自动加载该目录下所有符合格式的文件作为匹配规则使用。
3.编辑匹配规则文件 tq.json,放到 mappings 目录下,内容如下:
@@ -209,7 +209,7 @@ function hide_canvas() { }注意:body 中的内容为 Json 格式时,需要对其中出现的双引号进行转义,否则启动 Wiremock 时将报错。
-+
4.重新启动 Wiremock,访问模拟服务的对应接口( http://localhost:8080/api/json/est/now),返回如下:
{ $id: "1", @@ -292,7 +292,7 @@ serviceResponse: null,
Wiremock 的模拟能力远远不止这些,足够你用它来模拟被测服务,感兴趣的话可以自行探索和学习。
“进程内” VS “进程外”
如上可知,两种测试方法各有优劣,如下是示意图,方便查看它们的异同:
-+
两种测试方法的示意图对比
两种测试类型的优缺点对比: