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 语言开发环境,需要先下载 Go 语言开发包。你可以从官网 https://golang.org/dl/https://golang.google.cn/dl/ 下载(第一个链接是国外的官网,第二个是国内的官网,如果第一个访问不了,可以从第二个下载)。

下载时可以根据自己的操作系统选择相应的开发包,比如 Window、MacOS 或是 Linux 等,如下图所示:

-

go_sdk_download.png

+

png

Windows MSI 下安装

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"}
 

数组在内存中都是连续存放的,下面通过一幅图片形象地展示数组在内存中如何存放:

-

Drawing 1.png

+

png

可以看到,数组的每个元素都是连续存放的,每一个元素都有一个下标(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 接口。这就证明了以指针类型接收者实现接口的时候,只有对应的指针类型才被认为实现了该接口。

我用如下表格为你总结这两种接收者类型的接口实现规则:

-

Drawing 0.png

+

png

可以这样解读:

-

Drawing 1.png

+

png

讲师寄语

本课程的设计目标是,尽最大程度解决你的实际问题,让你在不同的工程实践中,对分布式场景下的数据库存储有更加专业的认知,并对技术趋势建立深入的洞察。

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 个差异点。

-

Drawing 0.png

+

png

从表中,我们可以总结出分布式数据库的核心——数据分片、数据同步

1. 数据分片

该特性是分布式数据库的技术创新。它可以突破中心化数据库单机的容量限制,从而将数据分散到多节点,以更灵活、高效的方式来处理数据。这是分布式理论带给数据库的一份礼物。

@@ -226,7 +226,7 @@ function hide_canvas() {

当然分布式数据库还有其他特点,但把握住以上两点,已经足够我们理解它了。下面我将从这两个特性出发,探求技术史上分布式数据库的发展脉络。我会以互联网、云计算等较新的时间节点来进行断代划分,毕竟我们的核心还是着眼现在、面向未来。

商业数据库

互联网浪潮之前的数据库,特别是前大数据时代。谈到分布式数据库绕不开的就是 Oracle RAC。

-

Drawing 2.png

+

png

Oracle RAC 是典型的大型商业解决方案,且为软硬件一体化解决方案。我在早年入职国内顶级电信行业解决方案公司的时候,就被其强大的性能所震撼,又为它高昂的价格所深深折服。它是那个时代数据库性能的标杆和极限,是完美方案与商业成就的体现。

我们试着用上面谈到的两个特性来简单分析一下 RAC:它确实是做到了数据分片与同步。每一层都是离散化的,特别在底层存储使用了 ASM 镜像存储技术,使其看起来像一块完整的大磁盘。

这样做的好处是实现了极致的使用体验,即使用单例数据库与 RAC 集群数据库,在使用上没有明显的区别。它的分布式存储层提供了完整的磁盘功能,使其对应用透明,从而达到扩展性与其他性能之间的平衡。甚至在应对特定规模的数据下,其经济性又有不错的表现。

@@ -234,17 +234,17 @@ function hide_canvas() {

该规模在当时的环境下是完全够用的,但是随着互联网的崛起,一场轰轰烈烈的“运动”将会打破 Oracle RAC 的不败金身。

大数据

我们知道 Oracle、DB2 等商业数据库均为 OLTP 与 OLAP 融合数据库。而首先在分布式道路上寻求突破的是 OLAP 领域。在 2000 年伊始,以 Hadoop 为代表的大数据库技术凭借其“无共享”(share nothing)的技术体系,开始向以 Oracle 为代表的关系型数据库发起冲击。

-

Drawing 4.png

+

png

这是一次水平扩展与垂直扩展,通用经济设备与专用昂贵服务,开源与商业这几组概念的首次大规模碰撞。拉开了真正意义上分布式数据库的帷幕

当然从一般的观点出发,Hadoop 一类的大数据处理平台不应称为数据库。但是从前面我们归纳的两点特性看,它们又确实非常满足。因此我们可以将它们归纳为早期面向商业分析场景的分布式数据库。从此 OLAP 型数据库开始了自己独立演化的道路

除了 Hadoop,另一种被称为 MPP(大规模并行处理)类型的数据库在此段时间也经历了高速的发展。MPP 数据库的架构图如下:

-

Drawing 6.png

+

png

我们可以看到这种数据库与大数据常用的 Hadoop 在架构层面上非常类似,但理念不同。简而言之,它是对 SMP(对称多处理器结构)、NUMA(非一致性存储访问结构)这类硬件体系的创新,采用 shared-nothing 架构,通过网络将多个 SMP 节点互联,使它们协同工作。

MPP 数据库的特点是首先支持 PB 级的数据处理,同时支持比较丰富的 SQL 分析查询语句。同时,该领域是商业产品的战场,其中不仅仅包含独立厂商,如 Teradata,还包含一些巨头玩家,如 HP 的 Vertica、EMC 的 Greenplum 等。

大数据技术的发展使 OLAP 分析型数据库,从原来的关系型数据库之中独立出来,形成了完整的发展分支路径。而随着互联网浪潮的发展,OLTP 领域迎来了发展的机遇。

互联网化

国内数据库领域进入互联网时代第一个重大事件就是“去 IOE”。

-

Drawing 8.png

+

png

其中尤以“去 Oracle 数据库”产生的影响深远。十年前,阿里巴巴喊出的这个口号深深影响了国内数据库领域,这里我们不去探讨其中细节,也不去评价它正面或负面的影响。但从对于分布式数据库的影响来说,它至少带来两种观念的转变。

  1. 应用成为核心:去 O 后,开源数据库需要配合数据库中间件(proxy)去使用,但这种组合无法实现传统商业库提供的一些关键功能,如丰富的 SQL 支持和 ACID 级别的事务。因此应用软件需要进行精心设计,从而保障与新数据库平台的配合。应用架构设计变得非常关键,整个技术架构开始脱离那种具有调侃意味的“面向数据库” 编程,转而变为以应用系统为核心。
  2. @@ -267,11 +267,11 @@ function hide_canvas() {

    首先,由于云服务天生的“超卖”特性,造成其采购成本较低,从而使终端用户尝试分布式数据库的门槛大大降低。

    其次,来自云服务厂商的支撑人员可以与用户可以进行深度的合作,形成了高效的反馈机制。这种反馈机制促使云原生的分布式数据库有机会进行快速的迭代,从而可以积极响应客户的需求。

    这就是云原生带给分布式数据库的变化,它是通过生态系统的优化完成了对传统商业数据库的超越。以下来自 DB-Engines 的分析数据说明了未来的数据库市场属于分布式数据库,属于云原生数据库。

    -

    Drawing 9.png

    +

    png

    随着分布式数据库的发展,我们又迎来了新的一次融合:那就是 OLTP 与 OLAP 将再一次合并为 HTAP(融合交易分析处理)数据库。

    该趋势的产生主要来源于云原生 OLTP 型分布式数据库的日趋成熟。同时由于整个行业的发展,客户与厂商对于实时分析型数据库的需求越来越旺盛,但传统上大数据技术包括开源与 MPP 类数据库,强调的是离线分析。

    如果要进行秒级的数据处理,那么必须将交易数据与分析数据尽可能地贴近,并减少非实时 ELT 的引入,这就促使了 OLTP 与 OLAP 融合为 HTAP。下图就是阿里云 PolarDB 的 HTAP 架构。

    -

    Drawing 11.png

    +

    png

    总结

    用《三国演义》的第一句话来说:“天下大势,分久必合,合久必分。”而我们观察到的分布式数据库,乃至数据库本身的发展正暗合了这句话。

    分布式数据库发展就是一个由合到分,再到合的过程

    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 数据库的区别

    -

    image

    +

    png

    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() {
  3. 垂直分片:在不同的数据库节点中存储表不同的表列。

如下图所示,水平和垂直这两个概念来自原关系型数据库表模式的可视化直观视图。

-

Drawing 0.png

+

png

图 1 可视化直观视图

分片理念其实来源于经济学的边际收益理论:如果投资持续增加,但收益的增幅开始下降时,被称为边际收益递减状态。而刚好要开始下降的那个点被称为边际平衡点。

该理论应用在数据库计算能力上往往被表述为:如果数据库处理能力遇到瓶颈,最简单的方式是持续提高系统性能,如更换更强劲的 CPU、更大内存等,这种模式被称为垂直扩展。当持续增加资源以提升数据库能力时,垂直扩展有其自身的限制,最终达到边际平衡,收益开始递减。

@@ -224,11 +224,11 @@ function hide_canvas() {

哈希分片

哈希分片,首先需要获取分片键,然后根据特定的哈希算法计算它的哈希值,最后使用哈希值确定数据应被放置在哪个分片中。数据库一般对所有数据使用统一的哈希算法(例如 ketama),以促成哈希函数在服务器之间均匀地分配数据,从而降低了数据不均衡所带来的热点风险。通过这种方法,数据不太可能放在同一分片上,从而使数据被随机分散开。

这种算法非常适合随机读写的场景,能够很好地分散系统负载,但弊端是不利于范围扫描查询操作。下图是这一算法的工作原理。

-

Drawing 1.png

+

png

图 2 哈希分片

范围分片

范围分片根据数据值或键空间的范围对数据进行划分,相邻的分片键更有可能落入相同的分片上。每行数据不像哈希分片那样需要进行转换,实际上它们只是简单地被分类到不同的分片上。下图是范围分片的工作原理。

-

Drawing 2.png

+

png

图 3 范围分片

范围分片需要选择合适的分片键,这些分片键需要尽量不包含重复数值,也就是其候选数值尽可能地离散。同时数据不要单调递增或递减,否则,数据不能很好地在集群中离散,从而造成热点。

范围分片非常适合进行范围查找,但是其随机读写性能偏弱。

@@ -261,7 +261,7 @@ function hide_canvas() {

ShardingShpere 首先提供了分布式的主键生成,这是生成分片键的关键。由于分布式数据库内一般由多个数据库节点参与,因此基于数据库实例的主键生成并不适合分布式场景。

常用的算法有 UUID 和 Snowfalke 两种无状态生成算法。

UUID 是最简单的方式,但是生成效率不高,且数据离散度一般。因此目前生产环境中会采用后一种算法。下图就是用该算法生成的分片键的结构。

-

Drawing 3.png

+

png

图 4 分片键结构

其中有效部分有三个。

    @@ -280,21 +280,21 @@ function hide_canvas() {

    用户通过以上多种分片工具,可以灵活和统一地制定数据库分片策略。

    自动分片

    ShardingShpere 提供了 Sharding-Scale 来支持数据库节点弹性伸缩,该功能就是其对自动分片的支持。下图是自动分片功能展示图,可以看到经过 Sharding-Scale 的特性伸缩,原有的两个数据库扩充为三个。

    -

    Drawing 4.png

    +

    png

    图 5 自动分片功能展示

    自动分片包含下图所示的四个过程。

    -

    Drawing 5.png

    +

    png

    图 6 自动分片过程

    从图 6 中可以看到,通过该工作量,ShardingShpere 可以支持复杂的基于哈希的自动分片。同时我们也应该看到,没有专业和自动化的弹性扩缩容工具,想要实现自动化分片是非常困难的。

    以上就是分片算法的实际案例,使用的是经典的水平分片模式。而目前水平和垂直分片有进一步合并的趋势,下面要介绍的 TiDB 正代表着这种融合趋势。

    垂直与水平分片融合案例

    TiDB 就是一个垂直与水平分片融合的典型案例,同时该方案也是 HATP 融合方案。

    其中水平扩展依赖于底层的 TiKV,如下图所示。

    -

    Drawing 6.png

    +

    png

    图 7 TiKV

    TiKV 使用范围分片的模式,数据被分配到 Region 组里面。一个分组保持三个副本,这保证了高可用性(相关内容会在“05 | 一致性与 CAP 模型:为什么需要分布式一致性?”中详细介绍)。当 Region 变大后,会被拆分,新分裂的 Region 也会产生多个副本。

    TiDB 的水平扩展依赖于 TiFlash,如下图所示。

    -

    Drawing 7.png

    +

    png

    图 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() {

    现在让我们开始学习单主复制,其中不仅介绍了该技术本身,也涉及了一些复制领域的话题,如复制延迟、高可用和复制方式等。

    单主复制

    单主复制,也称主从复制。写入主节点的数据都需要复制到从节点,即存储数据库副本的节点。当客户要写入数据库时,他们必须将请求发送给主节点,而后主节点将这些数据转换为复制日志或修改数据流发送给其所有从节点。从使用者的角度来看,从节点都是只读的。下图就是经典的主从复制架构。

    -

    Drawing 0.png

    +

    png

    这种模式是最早发展起来的复制模式,不仅被广泛应用在传统数据库中,如 PostgreSQL、MySQL、Oracle、SQL Server;它也被广泛应用在一些分布式数据库中,如 MongoDB、RethinkDB 和 Redis 等。

    那么接下来,我们就从复制同步模式、复制延迟、复制与高可用性以及复制方式几个方面来具体说说这个概念。

    复制同步模式

    @@ -292,7 +292,7 @@ function hide_canvas() {

    下面我就从第一代复制技术开始说起。

    MHA 复制控制

    下图是 MHA 架构图。

    -

    Drawing 1.png

    +

    png

    MHA 作为第一代复制架构,有如下适用场景:

    1. MySQL 的版本≤5.5,这一点说明它很古老;
    2. @@ -317,7 +317,7 @@ function hide_canvas() {
    3. 这一代开始需要支持跨 IDC 复制。需要引入监控 Monitor,配合 consul 注册中心。多个 IDC 中 Monitor 组成分布式监控,把健康的 MySQL 注册到 consul 中,同时将从库复制延迟情况也同步到 consul 中。

    下图就是带有 consul 注册中心与监控模块的半同步复制架构图。

    -

    Drawing 2.png

    +

    png

    第二代复制技术也有自身的一些缺陷。

    1. 存在幻读的情况。当事务同步到从库但没有 ACK 时,主库发生宕机;此时主库没有该事务,而从库有。
    2. @@ -329,7 +329,7 @@ function hide_canvas() {

      这一代复制技术采用的是增强半同步。首先主从的复制都是用独立的线程来运行;其次主库采用 binlog group commit,也就是组提交来提供数据库的写入性能;而从库采用并行复制,它是基于事务的,通过数据参数调整线程数量来提高性能。这样主库可以并行,从库也可以并行。

      这一代技术体系强依赖于增强半同步,利用半同步保证 RPO,对于 RTO,则取决于复制延迟。

      下面我们用 Xenon 来举例说明,请看下图(图片来自官网)。

      -

      Drawing 3.png

      +

      png

      从图中可以看到。每个节点上都有一个独立的 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 类系统是不存在的,这里你需要特别注意。

      -

      Drawing 0.png

      +

      png

      图 1 CAP 理论

      CAP 中的可用性也不同于上述的高可用性,CAP 定义对请求的延迟没有任何限制。此外,与 CAP 相反,数据库的高可用性并不需要每个在线节点都可以提供服务。

      CAP 里面的 C 代表线性一致,除了它以外,还有其他的一致模式,我们现在来具体介绍一下。

      @@ -232,7 +232,7 @@ function hide_canvas() {

      从用户的角度看,分布式数据库就像具有共享存储的单机数据库一样,节点间的通信和消息传递被隐藏到了数据库内部,这会使用户产生“分布式数据库是一种共享内存”的错觉。一个支持读取和写入操作的单个存储单元通常称为寄存器,我们可以把代表分布式数据库的共享存储看作是一组这样的寄存器。

      每个读写寄存器的操作被抽象为“调用”和“完成”两个动作。如果“调用”发生后,但在“完成”之前该操作崩溃了,我们将操作定义为失败。如果一个操作的调用和完成事件都在另一个操作被调用之前发生,我们说这个操作在另一个操作之前,并且这两个操作是顺序的;否则,我们说它们是并发的。

      如下图所示,a)是顺序操作,b)和 c)是并发操作。

      -

      Drawing 1.png

      +

      png

      图 2 顺序操作&并发操作

      多个读取或写入操作可以同时访问一个寄存器。对寄存器的读写操作不是瞬间完成的,需要一些时间,即调用和完成两个动作之间的时间。由不同进程执行的并发读/写操作不是串行的,根据寄存器在操作重叠时的行为,它们的顺序可能不同,并且可能产生不同的结果。

      当我们讨论数据库一致性时,可以从两个维度来区别。

      @@ -265,7 +265,7 @@ function hide_canvas() {
    3. 第三次读只能返回 2,因为第二次写是在第一次写之后进行的。

    下图正是现象一致性的直观展示。

    -

    Drawing 2.png

    +

    png

    图 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。

    -

    Drawing 3.png

    +

    png

    图 4 顺序一致性

    我们使用下图来进一步区分线性一致和顺序一致。

    -

    Drawing 4.png

    +

    png

    图 5 区分线性一致和顺序一致

    其中,图 a 满足了顺序一致性,但是不满足线性一致性。原因在于,从全局时钟的观点来看,P2 进程对变量 x 的读操作在 P1 进程对变量 x 的写操作之后,然而读出来的却是旧的数据。但是这个图却是满足顺序一致性,因为两个进程 P1 和 P2 的一致性并没有冲突。

    图 b 满足线性一致性,因为每个读操作都读到了该变量的最新写的结果,同时两个进程看到的操作顺序与全局时钟的顺序一样。

    @@ -292,10 +292,10 @@ function hide_canvas() {
  1. 闭包传递:和时钟向量里面定义的一样,如果 a->b、b->c,那么肯定也有 a->c。

那么,为什么需要因果关系,以及没有因果关系的写法如何传播?下图中,进程 P1 和 P2 进行的写操作没有因果关系,也就是最终一致性。这些操作的结果可能会在不同时间,以乱序方式传播到读取端。进程 P3 在看到 2 之前将看到值 1,而 P4 将先看到 2,然后看到 1。

-

Drawing 5.png

+

png

图 6 因果一致性

而下图显示进程 P1 和 P2 进行因果相关的写操作并按其逻辑顺序传播到 P3 和 P4。因果写入除了写入数据外,还需要附加一个逻辑时钟,用这个时钟保证两个写入是有因果关系的。这可以防止我们遇到上面那张图所示的情况。你可以在两个图中比较一下 P3 和 P4 的历史记录。

-

Drawing 6.png

+

png

图 7 逻辑时钟

而实现这个逻辑时钟的一种主要方式就是向量时钟。向量时钟算法利用了向量这种数据结构,将全局各个进程的逻辑时间戳广播给所有进程,每个进程发送事件时都会将当前进程已知的所有进程时间写入到一个向量中,而后进行传播。

因果一致性典型案例就是 COPS 系统,它是基于 causal+一致性模型的 KV 数据库。它定义了 dependencies,操作了实现因果一致性。这对业务实现分布式数据因果关系很有帮助。另外在亚马逊 Dynamo 基于向量时钟,也实现了因果一致性。

@@ -308,7 +308,7 @@ function hide_canvas() {

那么它们之间的联系如何呢?其实就是事务的隔离性与一致模型有关联。

如果把上面线性一致的例子看作多个并行事务,你会发现它们是没有隔离性的。因为在开始和完成之间任意一点都会读取到这份数据,原因是一致性模型关心的是单一操作,而事务是由一组操作组成的。

现在我们看另外一个例子,这是展示事务缺乏一致性后所导致的问题。

-

Drawing 7.png

+

png

图 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 系统。其架构图可以在官网找到,如下所示。

-

image

+

png

可以看到其中的 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)。

跳表的优势在于其实现难度比简单的链表高不了多少,但是其时间复杂度可以接近负载平衡的搜索树结构。

跳表在插入和更新时避免对节点做旋转或替换,而是使用了随机平衡的概念来使整个表平衡。跳表由一系列节点组成,它们又由不同的高度组成。连续访问高度较高的节点可以跳过高度较低的节点,有点像蜘蛛侠利用高楼在城市内快速移动一样,这也就是跳表名称的来源。现在我们用一个例子来说明跳表的算法细节。请看下面的图片。

-

Drawing 0.png

+

png

如果我们以寻找 15 为例来说明跳表的查找顺序。

  1. 首先查找跳表中高度最高的节点,从图中可以看到是10。
  2. diff --git a/专栏/24讲吃透分布式数据库-完/09 日志型存储:为什么选择它作为底层存储?.md.html b/专栏/24讲吃透分布式数据库-完/09 日志型存储:为什么选择它作为底层存储?.md.html index a0fd2185..b5e3318b 100644 --- a/专栏/24讲吃透分布式数据库-完/09 日志型存储:为什么选择它作为底层存储?.md.html +++ b/专栏/24讲吃透分布式数据库-完/09 日志型存储:为什么选择它作为底层存储?.md.html @@ -215,10 +215,10 @@ function hide_canvas() {

    可以看到双树操作是比较简单明了的,而且可以作为一种 B 树类的索引结构而存在。但实际上几乎没有存储引擎去使用它,主要原因是它的合并操作是同步的,也就是刷盘的时候要同步进行合并。而刷盘本身是个相对频繁的操作,这样会造成写放大,也就是会影响写入效率且会占用非常大的磁盘空间。

    多树结构是在双树的基础上提出的,内存数据刷盘时不进行合并操作,而是完全把内存数据写入到单独的文件中。那这个时候另外的问题就出现了:随着刷盘的持续进行,磁盘上的文件会快速增加。这时,读取操作就需要在很多文件中去寻找记录,这样读取数据的效率会直线下降。

    为了解决这个问题,此种结构会引入合并操作(Compaction)。该操作是异步执行的,它从这众多文件中选择一部分出来,读取里面的内容而后进行合并,最后写入一个新文件中,而后老文件就被删除掉了。如下图所示,这就是典型的多树结构合并操作。而这种结构也是本讲介绍的主要结构。

    -

    1.png

    +

    png

    最后,我再为你详细介绍一下刷盘的流程。

    首先定义几种角色,如下表所示。

    -

    2.png

    +

    png

    数据首先写入当前内存表,当数据量到达阈值后,当前数据表把自身状态转换为刷盘中,并停止接受写入请求。此时会新建另一个内存表来接受写请求。刷盘完成后,由于数据在磁盘上,除了废弃内存表的数据外,还对提交日志进行截取操作。而后将新数据表设置为可以读取状态。

    在合并操作开始时,将被合并的表设置为合并中状态,此时它们还可以接受读取操作。完成合并后,原表作废,新表开始启用提供读取服务。

    以上就是经典的 LSM 树的结构和一些操作细节。下面我们开始介绍如何对其进行查询、更新和删除等操作。

    @@ -236,14 +236,14 @@ function hide_canvas() {

    常见的合并策略有 Size-Tiered Compaction 和 Leveled Compaction。

    Size-Tiered Compaction

    下图就是这种策略的合并过程。

    -

    3.png

    +

    png

    其中,数据表按照大小进行合并,较小的数据表逐步合并为较大的数据表。第一层保存的是系统内最小的数据表,它们是刚刚从内存表中刷新出来的。合并过程就是将低层较小的数据表合并为高层较大的数据表的过程。Apache Cassandra 使用过这种合并策略。

    该策略的优点是比较简单,容易实现。但是它的空间放大性很差,合并时层级越高该问题越严重。比如有两个 5GB 的文件需要合并,那么磁盘至少要保留 10GB 的空间来完成这次操作,可想而知此种容量压力是巨大的,必然会造成系统不稳定。

    那么有没有什么策略能缓解空间放大呢?答案就是 Leveled Compaction。

    Leveled Compaction

    如名称所示,该策略是将数据表进行分层,按照编号排成 L0 到 Ln 这样的多层结构。L0 层是从内存表刷盘产生的数据表,该层数据表中间的 key 是可以相交的;L1 层及以上的数据,将 Size-Tiered Compaction 中原本的大数据表拆开,成为多个 key 互不相交的小数据表,每层都有一个最大数据量阈值,当到达该值时,就出发合并操作。每层的阈值是按照指数排布的,例如 RocksDB 文档中介绍了一种排布:L1 是 300MB、L2 是 3GB、L3 是 30GB、L4 为 300GB。

    该策略如下图所示。

    -

    4.png

    +

    png

    上图概要性地展示了从 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() {

    总结

    这一讲是模块三的引导课,我首先为你介绍了失败模型的概念,它是描述分布式数据库内各种可能行为的一个准则;而后根据失败模型为你梳理了本模块的讲解思路。

    分布式算法根据目标不同可能分为下面几种行为模式,这些模式与对应的课时如下表所示。

    -

    image.png

    +

    png

    diff --git a/专栏/24讲吃透分布式数据库-完/14 错误侦测:如何保证分布式系统稳定?.md.html b/专栏/24讲吃透分布式数据库-完/14 错误侦测:如何保证分布式系统稳定?.md.html index eac4ed43..2e46b158 100644 --- a/专栏/24讲吃透分布式数据库-完/14 错误侦测:如何保证分布式系统稳定?.md.html +++ b/专栏/24讲吃透分布式数据库-完/14 错误侦测:如何保证分布式系统稳定?.md.html @@ -224,16 +224,16 @@ function hide_canvas() {
  3. 一个节点向周围节点以一个固定的频率发送特定的数据包(称为心跳包),周围节点根据接收的频率判断该节点的健康状态。如果超出规定时间,未收到数据包,则认为该节点已经离线。

可以看到这两种方法虽然实现细节不同,但都包含了一个所谓“规定时间”的概念,那就是超时机制。我们现在以第一种模式来详细介绍这种算法,请看下面这张图片。

-

Drawing 0.png

+

png

图 1 模拟两个连续心跳访问

上面的图模拟了两个连续心跳访问,节点 1 发送 ping 包,在规定的时间内节点 2 返回了 pong 包。从而节点 1 判断节点 2 是存活的。但在现实场景中经常会发生图 2 所示的情况。

-

Drawing 2.png

+

png

图 2 现实场景下的心跳访问

可以看到节点 1 发送 ping 后,节点没有在规定时间内返回 pong,此时节点 1 又发送了另外的 ping。此种情况表明,节点 2 存在延迟情况。偶尔的延迟在分布式场景中是极其常见的,故基于超时的心跳检测算法需要设置一个超时总数阈值。当超时次数超过该阈值后,才判断远程节点是离线状态,从而避免偶尔产生的延迟影响算法的准确性。

由上面的描述可知,基于超时的心跳检测法会为了调高算法的准确度,从而牺牲算法的效率。那有没有什么办法能改善算法的效率呢?下面我就要介绍一种不基于超时的心跳检测算法。

不基于超时

不基于超时的心跳检测算法是基于异步系统理论的。它保存一个全局节点的心跳列表,上面记录了每一个节点的心跳状态,从而可以直观地看到系统中节点的健康度。由此可知,该算法除了可以提高检测的效率外,还可以非常容易地获得所有节点的健康状态。那么这个全局列表是如何生成的呢?下图展示了该列表在节点之间的流转过程。

-

Drawing 4.png

+

png

图 3 全局列表在节点之间的流转过程

由图可知,该算法需要生成一个节点间的主要路径,该路径就是数据流在节点间最常经过的一条路径,该路径同时要包含集群内的所有节点。如上图所示,这条路径就是从节点 1 经过节点 2,最后到达节点 3。

算法开始的时候,节点首先将自己记录到表格中,然后将表格发送给节点 2;节点 2 首先将表格中的节点 1 的计数器加 1,然后将自己记录在表格中,而后发送给节点 3;节点 3 如节点 2 一样,将其中的所有节点计数器加 1,再把自己记录进去。一旦节点 3 发现所有节点全部被记录了,就停止这个表格的传播。

@@ -242,7 +242,7 @@ function hide_canvas() {

那么有没有方法能提高对于单一节点的判断呢?现在我就来介绍一种间接的检测方法。

间接检测

间接检测法可以有效提高算法的稳定性。它是将整个网络进行分组,我们不需要知道网络中所有节点的健康度,而只需要在子网中选取部分节点,它们会告知其相邻节点的健康状态。

-

Drawing 6.png

+

png

图 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() {

现在我就和你一起,把一致性模型的知识体系补充完整。

完整的一致性模型

完整的一致性模型如下图所示。

-

Drawing 0.png

+

png

图中不同的颜色代表了可用性的程度,下面我来具体说说。

  1. 粉色代表网络分区后完全不可用。也就是 CP 类的数据库。
  2. @@ -265,7 +265,7 @@ function hide_canvas() {

    由于目前 CRDT 算法仍然处于高速发展的阶段,为了方便你理解,我这里选取携程网内部 Redis 集群一致性方案,它的技术选型相对实用。如果你对 CRDT 有兴趣,可以进一步研究,这里就不对诸如 PN-Counter、G-Set 等做进一步说明了。

    由于 Redis 最常用的处理手段是设置字符串数据,故需要使用 CRDT 中的 register 进行处理。携程团队选择了经典的 LWW Regsiter,也就是最后写入胜利的冲突处理方案。

    这种方案,最重要的是数据上需要携带时间戳。我们用下图来说明它的流程。

    -

    Drawing 1.png

    +

    png

    从图中可以看到,每个节点的数据是一个二元组,分别是 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() {

    随着熵逐步增加,系统进入越来越混乱的状态。但是如果没有读取操作,这种混乱其实是不会暴露出去的。那么人们就有了一个思路,我们可以在读取操作发生的时候再来修复不一致的数据。

    具体操作是,请求由一个总的协调节点来处理,这个协调节点会从一组节点中查询数据,如果这组节点中某些节点有数据缺失,该协调节点就会把缺失的数据发送给这些节点,从而修复这些节点中的数据,达到反熵的目的。

    有的同学可能会发现,这个思路与上一讲的可调节一致性有一些关联。因为在可调节一致性下,读取操作为了满足一致性要求,会从多个节点读取数据从而发现最新的数据结果。而读修复会更进一步,在此以后,会将落后节点数据进行同步修复,最后将最新结果发送回客户端。这一过程如下图所示。

    -

    Drawing 0.png

    +

    png

    当修复数据时,读修复可以使用阻塞模式与异步模式两种。阻塞模式如上图所示,在修复完成数据后,再将最终结果返还给客户端;而异步模式会启动一个异步任务去修复数据,而不必等待修复完成的结果,即可返回到客户端。

    你可以回忆一下,阻塞的读修复模式其实满足了上一讲中客户端一致性提到的读单增。因为一个值被读取后,下一次读取数据一定是基于上一次读取的。也就是说,同步修复的数据可以保证在下一次读取之前就被传播到目标节点;而异步修复就没有如此保证。但是阻塞修复同时丧失了一定的可用性,因为它需要等待远程节点修复数据,而异步修复就没有此问题。

    在进行消息比较的时候,我们有一个优化的手段是使用散列来比较数据。比如协调节点收到客户端请求后,只向一个节点发送读取请求,而向其他节点发送散列请求。而后将完全请求的返回值进行散列计算,与其他节点返回的散列值进行比较。如果它们是相等的,就直接返回响应;如果不相等,将进行上文所描述的修复过程。

    @@ -218,7 +218,7 @@ function hide_canvas() {

    以上就是在读取操作中进行的反熵操作,那么在写入阶段我们如何进行修复呢?下面我来介绍暗示切换。

    暗示切换

    暗示切换名字听起来很玄幻。其实原理非常明了,让我们看看它的过程,如下图所示。

    -

    Drawing 1.png

    +

    png

    客户端首先写入协调节点。而后协调节点将数据分发到两个节点中,这个过程与可调节一致性中的写入是类似的。正常情况下,可以保证写入的两个节点数据是一致的。如果其中的一个节点失败了,系统会启动一个新节点来接收失败节点之后的数据,这个结构一般会被实现为一个队列(Queue),即暗示切换队列(HHQ)。

    一旦失败的节点恢复了回来,HHQ 会把该节点离线这一个时间段内的数据同步到该节点中,从而修复该节点由于离线而丢失的数据。这就是在写入节点进行反熵的操作。

    以上介绍的前台同步操作其实都有一个限制,就是需要假设此种熵增过程发生的概率不高且范围有限。如果熵增大范围产生,那么修复读会造成读取延迟增高,即使使用异步修复也会产生很高的冲突。而暗示切换队列的问题是其容量是有限的,这意味着对于一个长期离线的节点,HHQ 可能无法保存其全部的消息。

    @@ -228,7 +228,7 @@ function hide_canvas() {

    而后台方案与前台方案的关注点是不同的。前台方案重点放在修复数据,而后台方案由于需要比较和处理大量的非活跃数据,故需要重点解决如何使用更少的资源来进行数据比对。我将要为你介绍两种比对技术:Merkle 树和位图版本向量。

    Merkle 树

    如果想要检查数据的差异,我们一般能想到最直观的方式是进行全量比较。但这种思路效率是很低的,在实际生产中不可能实行。而通过 Merkle 树我们可以快速找到两份数据之间的差异,下图就是一棵典型的 Merkle 树。

    -

    Drawing 2.png

    +

    png

    树构造的过程是:

    1. 将数据划分为多个连续的段。而后计算每个段的哈希值,得到 hash1 到 hash4 这四个值;
    2. @@ -240,7 +240,7 @@ function hide_canvas() {

      位图版本向量

      最近的研究发现,大部分数据差异还是发生在距离当前时间不远的时间段。那么我们就可以针对此种场景进行优化,从而避免像 Merkle 树那样计算全量的数据。而位图版本向量就是根据这个想法发展起来的。

      这种算法利用了位图这一种对内存非常友好的高密度数据格式,将节点近期的数据同步状态记录下来;而后通过比较各个节点间的位图数据,从而发现差异,修复数据。下面我用一个例子为你展示这种算法的执行过程,请看下图。

      -

      Drawing 3.png

      +

      png

      如果有三个节点,每个节点包含了一组与其他节点数据同步的向量。上图表示节点 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() {
    3. bal:lock 存 start_ts=>(primary cell),Primary cell 是 Rowkey 和列名的组合,它在提交容错处理和事务冲突时使用,用来清理由于协调器失败导致的事务失败留下的锁信息。

    我们现在用一个例子来介绍一下整个过程,请看下图。

    -

    Drawing 0.png

    +

    png

    一个账户表中,Bob 有 10 美元,Joe 有 2 美元。我们可以看到 Bob 的记录在 write 字段中最新的数据是 [email protected],它表示当前最新的数据是 ts=5 那个版本的数据,ts=5 版本中的数据是 10 美元,这样读操作就会读到这个 10 美元。同理,Joe 的账号是 2 美元。

    -

    Drawing 1.png

    +

    png

    现在我们要做一个转账操作,从 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。

    -

    Drawing 2.png

    +

    png

    然后对 Joe 账户加锁,同样是 ts=7,在 Joe 账户的加锁信息中包含了指向 Primary lock 的引用,如此这般处于同一个事务的行就关联起来了。Joe 的数据列写入 9(2+7=9),write 列为空,至此完成 Prewrite 阶段。

    -

    Drawing 3.png

    +

    png

    接下来事务就要 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 的数据了。

    -

    Drawing 4.png

    +

    png

    接下来就是 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() {
  3. 快照读:顾名思义,Spanner 实现了 MVCC 和快照隔离,故读取操作在整个事务内部是一致的。同时这也暗示了,Spanner 可以保存同一份数据的多个版本。

了解了事务模型后,我们深入其内部,看看 Spanner 的核心组件都有哪些。下面是一张 Spanner 的架构图。

-

Drawing 0.png

+

png

其中我们看到,每个 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 事务的处理组件如下图所示。

-

Drawing 1.png

+

png

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:使用乐观事务打造悲观事务

在分布式事务那一讲,我提到 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 的架构。

-

Drawing 0.png

+

png

对于事务部分,TiDB 实现悲观事务的方式是非常简洁的。其团队在仔细研究了 Percolator 的模型后发现,其实只要将在客户端调用 Commit 时候进行两阶段提交这个行为稍微改造一下,将第一阶段上锁和等锁提前到事务中执行 DML 的过程中,就可以简单高效地支持悲观事务场景。

TiDB 的悲观锁实现的原理是,在一个事务执行 DML(UPDATE/DELETE)的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,等到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程和原来保持一致即可。

这个方案在很大程度上兼容了原有的事务实现,其扩展性、高可用和灵活性都有保证。同时该方案尽最大可能复用了原有 Percolator 的乐观事务方案,减少了事务模型整体的复杂度。

@@ -239,11 +239,11 @@ function hide_canvas() {

Cassandra 的可调节一致性如我在本模块一致性那一讲介绍的一样,分为写一致性与读一致性。

写一致性

写一致性声明了需要写入多少个节点才算一次成功的写入。Cassandra 的写一致性是可以在强一致到弱一致之间进行调整的。我总结了下面的表格来为你说明。

-

Drawing 1.png

+

png

我们可以看到 ANY 级别实际上对应了最终一致性。Cassandra 使用了反熵那一讲提到的暗示切换技术来保障写入的数据的可靠,也就是写入节点一旦失败,数据会暂存在暗示切换队列中,等到节点恢复后数据可以被还原出来。

读一致性

对于读操作,一致性级别指定了返回数据之前必须有多少个副本节点响应这个读查询。这里同样给你整理了一个表格。

-

Drawing 2.png

+

png

Cassandra 在读取的时候使用了读修复来修复副本上的过期数据,该修复过程是一个后台线程,故不会阻塞读取。

以上就是 Apache Cassandra 实现可调节一致性的一些细节。AWS 的 DynamoDB、Azure 的 CosmosDB 都有类似的可调节一致性供用户进行选择。你可以比照 Cassandra 的模式和这些数据库的文档进行学习。

总结

diff --git a/专栏/24讲吃透分布式数据库-完/22 发展与局限:传统数据库在分布式领域的探索.md.html b/专栏/24讲吃透分布式数据库-完/22 发展与局限:传统数据库在分布式领域的探索.md.html index 7e2eb1f1..48689e3c 100644 --- a/专栏/24讲吃透分布式数据库-完/22 发展与局限:传统数据库在分布式领域的探索.md.html +++ b/专栏/24讲吃透分布式数据库-完/22 发展与局限:传统数据库在分布式领域的探索.md.html @@ -248,10 +248,10 @@ function hide_canvas() {

单体开源数据要向分布式数据库演进,就要解决写入性能不足的问题。

最简单直接的办法就是分库分表。分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库。代理节点分为“客户端 + 单体数据库”和“中间件 + 单体数据库”两个模式。

客户端组件 + 单体数据库通过独立的逻辑层建立数据分片和路由规则,实现单体数据库的初步管理,使应用能够对接多个单体数据库,实现并发、存储能力的扩展。其作为应用系统的一部分,对业务侵入比较深。这种客户端组件的典型产品是 Apache ShardingShpere 的 JDBC 客户端模式,下图就是该模式的架构图。

-

Drawing 0.png

+

png

Apache ShardingShpere 的 JDBC 客户端模式架构图

代理中间件 + 单体数据库以独立中间件的方式,管理数据规则和路由规则,以独立进程存在,与业务应用层和单体数据库相隔离,减少了对应用的影响。随着代理中间件的发展,还会衍生出部分分布式事务处理能力。这种中间件的典型产品是 MyCat、Apache ShardingShpere 的 Proxy 模式。

-

Drawing 1.png

+

png

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 数据库为例。

-

image.png

+

png

可以看到其中的创新点有以下几个。

  1. 存储引擎没有使用传统数据库。而使用的是新型基于 LSM 的 KV 分布式存储引擎,有些数据库使用了完全内存形式的存储引擎,比如 NuoDB。
  2. diff --git a/专栏/24讲吃透分布式数据库-完/加餐2 数据库选型:我们该用什么分布式数据库?.md.html b/专栏/24讲吃透分布式数据库-完/加餐2 数据库选型:我们该用什么分布式数据库?.md.html index d56a1021..c8496526 100644 --- a/专栏/24讲吃透分布式数据库-完/加餐2 数据库选型:我们该用什么分布式数据库?.md.html +++ b/专栏/24讲吃透分布式数据库-完/加餐2 数据库选型:我们该用什么分布式数据库?.md.html @@ -211,7 +211,7 @@ function hide_canvas() {
  3. 高效的异地数据同步。

如下面的架构图所示,应用层通过 Cobar 访问数据库。

-

image.png

+

png

其对数据库的访问分为读操作(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() {

第 3 幕:谁来拯救微服务

2015 年,互联网技术的飞速发展带给了我们无限发展的空间。越来越多的行业在思考:如何转型互联网?如何开展互联网业务?这时,一个互联网转型的利器——微服务,它恰恰能够帮助很多行业很好地应对互联网业务。于是乎,我们加入了微服务转型的滚滚洪流之中。

但是,微服务也不是银弹,它也有很多的“坑”

-

ddd.png

+

png

当按照模块拆分微服务以后才发现,每次变更都需要修改多个微服务,不但多个团队都要变更,还要同时打包、同时升级,不仅没有降低维护成本,还使得系统的发布比过去更麻烦,真不如不用微服务。是微服务不好吗?我又陷入了沉思。

这时我才注意到 Martin Flower 在定义微服务时提到的“小而专”,很多人理解了“小”却忽略了“专”,就会带来微服务系统难于维护的糟糕境地。这里的“专”,就是要“小团队独立维护”,也就是尽量让每次的需求变更交给某个小团队独立完成,让需求变更落到某个微服务上进行变更,唯有这样才能发挥微服务的优势。

-

1.png

+

png

通过这样的一番解析,才发现微服务的设计真的不仅仅是一个技术架构更迭的事情,而是对原有的设计提出了更高的要求,即“微服务内高内聚,微服务间低耦合”。如何才能更好地做到这一点呢?答案还是 DDD。

我们转型微服务的重要根源之一就是系统的复杂性,即系统规模越来越大,维护越来越困难,才需要拆分微服务。然而,拆分成微服务以后,并不意味着每个微服务都是各自独立地运行,而是彼此协作地组织在一起。这就好像一个团队,规模越大越需要一些方法来组织,而 DDD 恰恰就是那个帮助我们组织微服务的实践方法。

第 4 幕:DDD,想说爱你不容易

@@ -176,7 +176,7 @@ function hide_canvas() {

有了这个技术中台的支持,开发团队就可以把更多的精力放到对用户业务的理解,对业务痛点的理解,快速开发用户满意的功能并快速交付上。这样,不仅编写代码减少了,技术门槛降低了,还使得日后的变更更加容易,技术更迭也更加方便。因此,我又开始苦苦求索。

很快,Bob 大叔的整洁架构(Clean Architecture)给了我全新的思路。整洁架构最核心的是业务(图中的黄色与红色部分),即我们通过领域模型分析,最后形成的那些 Service、Entity 与 Value Object。

然而,整洁架构最关键的设计思想是通过一系列的适配器(图中的绿色部分),将业务代码与技术框架解耦。通过这样的解耦,上层业务开发人员更专注地去开发他们的业务代码,技术门槛得到降低;底层平台架构师则更低成本地进行架构演化,不断地跟上市场与技术的更迭。唯有这样,才能跟上日益激烈的市场竞争。

-

2.png

+

png

图片来自 Robert C. Martin 的《架构整洁之道》

不仅如此,我在实践摸索过程中,还创新性地提出了单 Controller、通用仓库、通用工厂,以及完美支持 DDD + 微服务的技术中台架构设计。通过这些设计,开发团队能够更好地将 DDD 落地到项目开发中,真正地打造出一支支理解业务、高质量开发与快速交付的团队。

这门课能让你学到什么?

diff --git a/专栏/DDD 微服务落地实战/01 DDD :杜绝软件退化的利器.md.html b/专栏/DDD 微服务落地实战/01 DDD :杜绝软件退化的利器.md.html index 47db58ef..1e05f989 100644 --- a/专栏/DDD 微服务落地实战/01 DDD :杜绝软件退化的利器.md.html +++ b/专栏/DDD 微服务落地实战/01 DDD :杜绝软件退化的利器.md.html @@ -158,7 +158,7 @@ function hide_canvas() {

然而,在面对全新业务、全新增长点的时候,我们能不能把握住这样的机遇呢?我们期望能把握住,但每次回到现实,回到正在维护的系统时,却令人沮丧。我们的软件总是经历着这样的轮回,软件设计质量最高的时候是第一次设计的那个版本,当第一个版本设计上线以后就开始各种需求变更,这常常又会打乱原有的设计。

因此,需求变更一次,软件就修改一次,软件修改一次,质量就下降一次。不论第一次的设计质量有多高,软件经历不了几次变更,就进入一种低质量、难以维护的状态。进而,团队就不得不在这样的状态下,以高成本的方式不断地维护下去,维护很多年。

这时候,维护好原有的业务都非常不易,又如何再去期望未来更多的全新业务呢?比如,这是一段电商网站支付功能的设计,最初的版本设计质量还是不错的:

-

Drawing 0.png

+

png

当第一个版本上线以后,很快就迎来了第一次变更,变更的需求是增加商品折扣功能,并且这个折扣功能还要分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣。当我们拿到这个需求时怎么做呢?很简单,增加一个 if 语句,if 限时折扣就怎么怎么样,if 限量折扣就怎么怎么样……代码开始膨胀了。

接着,第二次变更需要增加 VIP 会员,除了增加各种金卡、银卡的折扣,还要为会员发放各种福利,让会员享受各种特权。为了实现这些需求,我们又要在 payoff() 方法中加入更多的代码。

第三次变更增加的是支付方式,除了支付宝支付,还要增加微信支付、各种银行卡支付、各种支付平台支付,此时又要塞入一大堆代码。经过这三次变更,你可以想象现在的 payoff() 方法是什么样子了吧,变更是不是就可以结束了呢?其实不能,接着还要增加更多的秒杀、预订、闪购、众筹,以及各种返券。程序变得越来越乱而难以阅读,每次变更也变得越来越困难。

@@ -171,7 +171,7 @@ function hide_canvas() {

在我们不断地修复 Bug,实现新需求的过程中,软件的业务逻辑也会越来越接近真实世界,使得我们的软件越来越专业,让用户感觉越来越好用。但是,在软件越来越接近真实世界的过程中,业务逻辑就会变得越来越复杂,软件规模也越来越庞大

你一定有这样一个认识:简单软件有简单软件的设计,复杂软件有复杂软件的设计。

比如,现在的需求就是将用户订单按照“单价 × 数量”公式来计算应付金额,那么在一个 PaymentBus 类中增加一个 payoff() 方法即可,这样的设计没有问题。不过,如果现在的需求需要在付款的过程中计算各种折扣、各种优惠、各种返券,那么我们必然会做成一个复杂的程序结构。

-

Lark20201116-102936.png

+

png

但是,真实情况却不是这样的。真实情况是,起初我们拿到的需求是那个简单需求,然后在简单需求的基础上进行了设计开发。但随着软件的不断变更,软件业务逻辑变得越来越复杂,软件规模不断扩大,逐渐由一个简单软件转变成一个复杂软件。

这时,如果要保持软件设计质量不退化,就应当逐步调整软件的程序结构,逐渐由简单的程序结构转变为复杂的程序结构。如果我们总是这样做,就能始终保持软件的设计质量,不过非常遗憾的是,我们以往在维护软件的过程中却不是这样做的,而是不断地在原有简单软件的程序结构下,往 payoff() 方法中塞代码,这样做必然会造成软件的退化。

也就是说,软件退化的根源不是软件变更,软件变更只是一个诱因。如果每次软件变更时,适时地进行解耦,进行功能扩展,再实现新的功能,就能保持高质量的软件设计。但如果在每次软件变更时没有调整程序结构,而是在原有的程序结构上不断地塞代码,软件就会退化。这就是软件发展的规律,软件退化的根源。

@@ -187,7 +187,7 @@ function hide_canvas() {
  • 不折扣
  • 以往我们拿到这个需求,就很不冷静地开始改代码,修改成了如下一段代码:

    -

    Drawing 3.png

    +

    png

    这里增加了一段 if 语句,并不是一种好的变更方式。如果每次都这样变更,那么软件必然就会退化,进入难以维护的状态。这种变更为什么就不好呢?因为它违反了“开放-封闭原则”。

    开放-封闭原则(OCP) 分为开放原则与封闭原则两部分。

    按以上案例为例,为了实现新的功能,我们在原有代码的基础上,在不添加新功能的前提下调整原有程序结构,我们抽取出了 Strategy 这样一个接口和“不折扣”这个实现类。这时,原有程序变了吗?没有。但是程序结构却变了,增加了这样一个接口,称为“可扩展点”。在这个可扩展点的基础上再实现各种折扣,既能满足“开放-封闭原则”来保证程序质量,又能够满足新的需求。当日后发生新的变更时,什么类型的折扣就修改哪个实现类,添加新的折扣类型就增加新的实现类,维护成本得到降低。

    -

    Drawing 4.png

    +

    png

    “两顶帽子”的设计方式意义重大。过去,我们每次在设计软件时总是担心日后的变更,就很不冷静地设计了很多所谓的“灵活设计”。然而,每一种“灵活设计”只能应对一种需求变更,而我们又不是先知,不知道日后会发生什么样的变更。最后的结果就是,我们期望的变更并没有发生,所做的设计都变成了摆设,它既不起什么作用,还增加了程序复杂度;我们没有期望的变更发生了,原有的程序依然不能解决新的需求,程序又被打回了原形。因此,这样的设计不能真正解决未来变更的问题,被称为“过度设计”。

    有了“两顶帽子”,我们不再需要焦虑,不再需要过度设计,正确的思路应当是“活在今天的格子里做今天的事儿”,也就是为当前的需求进行设计,使其刚刚满足当前的需求。所谓的“高质量的软件设计”就是要掌握一个平衡,一方面要满足当前的需求,另一方面要让设计刚刚满足需求,从而使设计最简化、代码最少。这样做,不仅软件设计质量提高了,设计难点也得到了大幅度降低。

    简而言之,保持软件设计不退化的关键在于每次需求变更的设计,只有保证每次需求变更时做出正确的设计,才能保证软件以一种良性循环的方式不断维护下去。这种正确的设计方式就是“两顶帽子”。

    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() {
  • 一个用户可以有多个用户地址,但每个订单只能有一个用户地址;
  • 此外,一个订单对应多个订单明细,每个订单明细对应一个商品,每个商品对应一个供应商。
  • -

    Drawing 0.png

    +

    png

    最后,我们对订单可以进行“下单”“付款”“查看订单状态”等操作。因此形成了以下领域模型图:

    -

    Drawing 2.png

    +

    png

    有了这样的领域模型,就可以通过该模型进行以下程序设计:

    -

    Drawing 4.png

    +

    png

    通过领域模型的指导,将“订单”分为订单 Service 与值对象,将“用户”分为用户 Service 与值对象,将“商品”分为商品 Service 与值对象……然后,在此基础上实现各自的方法。

    商品折扣的需求变更

    当电商网站的付款功能按照领域模型完成了第一个版本的设计后,很快就迎来了第一次需求变更,即增加折扣功能,并且该折扣功能分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣与不折扣。当我们拿到这个需求时应当怎样设计呢?很显然,在 payoff() 方法中去插入 if 语句是不 OK 的。这时,按照领域驱动设计的思想,应当将需求变更还原到领域模型中进行分析,进而根据领域模型背后的真实世界进行变更。

    -

    Drawing 6.png

    +

    png

    这是上一个版本的领域模型,现在我们要在这个模型的基础上增加折扣功能,并且还要分为限时折扣、限量折扣、某类商品的折扣等不同类型。这时,我们应当怎么分析设计呢?

    首先要分析付款与折扣的关系。

    付款与折扣是什么关系呢?你可能会认为折扣是在付款的过程中进行的折扣,因此就应当将折扣写到付款中。这样思考对吗?我们应当基于什么样的思想与原则来设计呢?这时,另外一个重量级的设计原则应该出场了,那就是“单一职责原则”。

    @@ -195,10 +195,10 @@ function hide_canvas() {
  • ……
  • 最后发现,不同类型的折扣也是软件变化不同的原因。将它们放在同一个类、同一个方法中,合适吗?通过以上分析,我们做出了如下设计:

    -

    Drawing 8.png

    +

    png

    在该设计中,将折扣功能从付款功能中独立出去,做出了一个接口,然后以此为基础设计了各种类型的折扣实现类。这样的设计,当付款功能发生变更时不会影响折扣,而折扣发生变更的时候不会影响付款。同样,当“限时折扣”发生变更时只与“限时折扣”有关,“限量折扣”发生变更时也只与“限量折扣”有关,与其他折扣类型无关。变更的范围缩小了,维护成本就降低了,设计质量提高了。这样的设计就是“单一职责原则”的真谛。

    接着,在这个版本的领域模型的基础上进行程序设计,在设计时还可以加入一些设计模式的内容,因此我们进行了如下的设计:

    -

    Drawing 10.png

    +

    png

    显然,在该设计中加入了“策略模式”的内容,将折扣功能做成了一个折扣策略接口与各种折扣策略的实现类。当哪个折扣类型发生变更时就修改哪个折扣策略实现类;当要增加新的类型的折扣时就再写一个折扣策略实现类,设计质量得到了提高。

    VIP 会员的需求变更

    在第一次变更的基础上,很快迎来了第二次变更,这次是要增加 VIP 会员,业务需求如下。

    @@ -220,13 +220,13 @@ function hide_canvas() {
  • 而“付款”与“VIP 会员”的关系是在付款的过程中去调用会员折扣、会员福利与会员特权。
  • 通过以上的分析,我们做出了以下版本的领域模型:

    -

    Drawing 12.png

    +

    png

    有了这些领域模型的变更,然后就可以以此作为基础,指导后面程序代码的变更了。

    支付方式的需求变更

    同样,第三次变更是增加更多的支付方式,我们在领域模型中分析“付款”与“支付方式”之间的关系,发现它们也是软件变化不同的原因。因此,我们果断做出了这样的设计:

    -

    Drawing 14.png

    +

    png

    而在设计实现时,因为要与各个第三方的支付系统对接,也就是要与外部系统对接。为了使第三方的外部系统的变更对我们的影响最小化,在它们中间果断加入了“适配器模式”,设计如下:

    -

    Drawing 16.png

    +

    png

    通过加入适配器模式,订单 Service 在进行支付时调用的不再是外部的支付接口,而是“支付方式”接口,与外部系统解耦。只要保证“支付方式”接口是稳定的,那么订单 Service 就是稳定的。比如:

    俗话说:小船好掉头,泰坦尼克号看见冰山了为什么要撞上去?因为它实在太大了,根本来不及掉头。写代码也是一样的,一段 10 来行的代码变更会很容易,但一段数百上千行的代码变更就非常复杂。因此,我们设计软件应当秉承这样的态度:宁愿花更多的时间去分析设计让软件设计精简到极致从而花更少的时间去编码。俗话说:磨刀不误砍柴工。用这样的态度编写出来的代码,既快又易于维护。

    -

    Drawing 6.png

    +

    png

    接着,看一看在以往软件研发过程中存在的问题。以往的软件项目在研发的过程中需要编写太多的代码了,每个功能都要编写自己的 UI、Controller、Service 和 DAO。并且,在每一个层次中都有不同格式的数据,因此我们编写的大量代码都是在进行各个层次之间的数据格式转换。如下图所示:

    -

    Drawing 8.png

    +

    png

    譬如,前端以 Form 的形式传输到后台,这时后台由 MVC 层从 Model 或者 Request 中获得,然后将其转换成值对象,接着去调用 Service。然而,从 Model 或者 Request 中获得数据以后,由于我们在 MVC 层的 Controller 中写了太多的判断与操作,再将其塞入值对象中,所以这里耗费了太多的代码。

    接着,在 Service 中经过各种业务操作,最后要存盘的时候,又要将 VO 转换为 PO,将数据持久化存储到数据库中。这时,又要为每一个功能编写一个 DAO。我们写的代码越多,日后维护与变更就越困难。那么,能不能将这些转换统一成公用代码下沉到技术中台中呢?基于这样的思想,系统架构调整为这样:

    -

    Drawing 10.png

    +

    png

    在这个架构中,将各个层次的数据都统一成值对象,这是怎样统一的呢?首先,在前端的数据,现在越来越多的前端框架都是以 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 语句进行查询。
  • 遵循该设计模式,是我们在许多软件项目中总结出来的最佳实践。因此,技术中台在建设时,对业务系统的支持也分为增删改查询两个部分。

    -

    Drawing 0.png

    +

    png

    增删改的架构设计

    -

    Drawing 1.png

    +

    png

    增删改部分的技术中台架构设计

    在增删改部分中,采用了前面提到的单 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 语句执行数据库操作。

    查询功能的架构设计

    接着,是查询功能的技术中台设计,如图所示:

    -

    Drawing 2.png

    +

    png

    查询功能的技术中台架构设计

    与增删改部分一样的是,查询功能中,每个功能的前端 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 的架构设计

    -

    Drawing 1.png

    +

    png

    通常,在支持领域驱动的软件项目中,架构设计如上图所示。

    这些都是将领域驱动落地到软件设计时所采用的方式。从架构分层上说,DDD 的仓库和工厂的设计介于业务领域层与基础设施层之间,即接口在业务领域层,而实现在基础设施层。DDD 的基础设施层相当于支撑 DDD 的基础技术架构,通过各种技术框架支持软件系统完成除了领域驱动以外的各种功能。

    -

    1.png

    +

    png

    然而,传统的软件系统采用 DDD 进行架构设计时,需要在各个层次之间进行各种数据结构的转换:

    在这个过程中,需要编写大量代码进行数据的转换,无疑将加大软件开发的工作量与日后变更的维护成本。因此,我们可不可以考虑上一讲所提到的设计,将各个层次的数据结构统一起来呢?

    -

    Drawing 5.png

    +

    png

    另外,传统的软件系统在采用 DDD 进行架构设计时,需要为每一个功能模块编写各自的仓库与工厂,如订单模块有订单仓库与订单工厂、库存模块有库存仓库与库存工厂。各个模块在编写仓库与工厂时,虽然实现了各自不同的业务,却形成了大量重复的代码。这样的问题与前面探讨的 Dao 的问题一样,是否可以通过配置与建模,设计成一个统一的仓库与工厂。如果是这样,那么仓库与工厂又与 Dao 是什么关系呢?基于对以上问题的思考,我提出了统一数据建模、内置聚合的实现、通用仓库和工厂,来简化 DDD 业务开发。因此,进行了如下的架构设计。

    通用仓库与通用工厂的设计

    -

    Drawing 7.png

    +

    png

    该设计与上一讲的架构设计相比,差别仅是将单 Dao 替换为了通用仓库与通用工厂。也就是说,与 Dao 相比,DDD 的仓库就是在 Dao 的基础上扩展了一些新的功能

    这就是 DDD 的仓库与 Dao 的关系。

    基于这种扩展关系,该如何设计这个通用仓库呢?如果熟悉设计模式,则会想到“装饰者模式”。“装饰者模式”的目的,就是在原有功能的基础上进行“透明功能扩展”。这种“透明功能扩展”,既可以扩展原有功能,又不影响原有的客户程序,使客户程序不用修改任何代码就能实现新功能,从而降低变更的维护成本。因此,将“通用仓库”设计成了这样。

    -

    Drawing 9.png

    +

    png

    即在原有的 BasicDao 与 BasicDaoImpl 的基础上,增加了通用仓库 Repository。将 Repository 设计成装饰者,它也是接口 BasicDao 的实现类,是通过一个属性变量引用的 BasicDao。使用时,在 BasicDaoImpl 的基础上包一个 Repository,就可以扩展出那些 DDD 的功能。因此,所有的 Service 在注入 Dao 的时候:

    解决技术不确定性的问题

    如今的微服务架构,基本已经形成了 Spring Cloud 一统天下的局势。然而,在 Spring Cloud 框架下的各种技术组件依然存在诸多不确定性,如:注册中心是否采用 Eureka、服务网关是采用 Zuul 还是 Gateway,等等。同时,服务网格 Service Mesh 方兴未艾,不排除今后所有的微服务都要切换到 Service Mesh 的可能。在这种情况下如何决策微服务的技术架构?代码尽量不要与 Spring Cloud 耦合,才能在将来更容易地切换到 Service Mesh。那么,具体又该如何做到呢?

    -

    Drawing 0.png

    +

    png

    单 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,否则就会报错。

    -

    Drawing 1.png

    +

    png

    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′,使维护成本降到了最低。

    -

    Drawing 2.png

    +

    png

    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 替换的同时,还扩展了许多的功能,如数据的补填、领域对象的映射与装配、聚合的处理,等等。当我们把这些关系思考清楚了,通用仓库与工厂的设计就出来了。

    -

    Lark20210108-153942.png

    +

    png

    如上图所示,仓库就是一个 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() {

    基于消息的领域事件发布

    前面讲解了领域溯源的设计思路,最后要落地到项目实践中,依然需要技术中台的相应支持。譬如,业务系统的发布者只负责事件的发布,订阅者只负责事件的后续操作。但这个过程该如何发布事件呢?发布事件到底要做什么呢?又如何实现事件的订阅呢?这就需要下沉到技术中台去设计。

    首先,事件的发布方在发布事件的同时,需要在数据库中予以记录。数据库可以进行如下设计:

    -

    Drawing 0.png

    +

    png

    接着,领域事件还需要通过消息队列进行发布,这里可以采用 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() {
     

    软件架构模式的演进

    在进入今天的主题之前,我们先来了解下背景。

    我们知道,这些年来随着设备和新技术的发展,软件的架构模式发生了很大的变化。软件架构模式大体来说经历了从单机、集中式到分布式微服务架构三个阶段的演进。随着分布式技术的快速兴起,我们已经进入到了微服务架构时代。

    -

    1628872362791

    +

    png

    我们可以用三步来划定领域模型和微服务的边界。

    第一步:在事件风暴中梳理业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出领域实体等领域对象。

    第二步:根据领域实体之间的业务关联性,将业务紧密相关的实体进行组合形成聚合,同时确定聚合中的聚合根、值对象和实体。在这个图里,聚合之间的边界是第一层边界,它们在同一个微服务实例中运行,这个边界是逻辑边界,所以用虚线表示。

    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 的研究方法与自然科学的研究方法类似。当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题一步一步地细分,再针对细分出来的问题域,逐个深入研究,探索和建立所有子域的知识体系。当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。

    -

    1628872456555

    +

    png

    我们来看一下上面这张图。这个例子是在讲如何给桃树建立一个完整的生物学知识体系。初中生物课其实早就告诉我们研究方法了。它的研究过程是这样的。

    第一步:确定研究对象,即研究领域,这里是一棵桃树。

    第二步:对研究对象进行细分,将桃树细分为器官,器官又分为营养器官和生殖器官两种。其中营养器官包括根、茎和叶,生殖器官包括花、果实和种子。桃树的知识体系是我们已经确定要研究的问题域,对应 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、订单量、数据量的不断增长,用来支撑业务的系统复杂度也会不断提高,模块之间的依赖关系也会日益复杂。这时候我们一般会从单体架构进入集群架构(如下图所示),在集群架构中通过负载均衡技术,将流量尽可能均摊到集群中的每台机器上,以此克服单台机器硬件资源的限制,做到横向扩展。

    -

    Drawing 0.png

    +

    png

    单体架构 VS 集群架构

    之后,又由于业务系统本身的实现较为复杂、扩展性较差、性能也有上限,代码和功能的复用能力较弱,我们会将一个巨型业务系统拆分成多个微服务,根据不同服务对资源的不同要求,选择更合理的硬件资源。例如,有些流量较小的服务只需要几台机器构成的集群即可,而核心业务则需要成百上千的机器来支持,这样就可以最大化系统资源的利用率。

    另外一个好处是,可以在服务维度进行重用,在需要某个服务的时候,直接接入即可,从而提高开发效率。拆分成独立的服务之后(如下图所示),整个服务可以最大化地实现重用,也可以更加灵活地扩展。

    -

    Drawing 1.png

    +

    png

    微服务架构图

    但是在微服务架构落地的过程中,我们需要解决的问题有很多,如:

      @@ -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 框架的原理和核心实现。

      -

      Drawing 2.png

      -

      Drawing 3.png

      -

      Drawing 4.png

      +

      png

      +

      png

      +

      png

      (职位信息来源:拉勾网)

      而 Dubbo 就是首选。Dubbo 和 Spring Cloud 是目前主流的微服务框架,阿里、京东、小米、携程、去哪儿网等互联网公司的基础设施早已落成,并且后续的很多项目还是以 Dubbo 为主。Dubbo 重启之后,已经开始规划 3.0 版本,相信后面还会有更加惊艳的表现。

      另外,RPC 框架的核心原理和设计都是相通的,阅读过 Dubbo 源码之后,你再去了解其他 RPC 框架的代码,就是一件非常简单的事情了。

      阅读 Dubbo 源码的痛点

      学习和掌握一项技能的时候,一般都是按照“是什么”“怎么用”“为什么”(原理)逐层深入的:

      -

      Drawing 5.png

      +

      png

      同样,你可以通过阅读官方文档或是几篇介绍性的文章,迅速了解 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 核心架构:

      -

      Drawing 0.png

      +

      png

      Dubbo 核心架构图

      • Registry:注册中心。 负责服务地址的注册与查找,服务的 Provider 和 Consumer 只在启动时与注册中心交互。注册中心通过长连接感知 Provider 的存在,在 Provider 出现宕机的时候,注册中心会立即推送相关事件通知 Consumer。
      • @@ -324,38 +324,38 @@ function hide_canvas() {

        然后,在 IDEA 中导入源码,因为这个导入过程中会下载所需的依赖包,所以会耗费点时间。

        Dubbo源码核心模块

        在 IDEA 成功导入 Dubbo 源码之后,你看到的项目结构如下图所示:

        -

        Drawing 2.png

        +

        png

        下面我们就来简单介绍一下这些核心模块的功能,至于详细分析,在后面的课时中我们还会继续讲解。

        • dubbo-common 模块: Dubbo 的一个公共模块,其中有很多工具类以及公共逻辑,例如课程后面紧接着要介绍的 Dubbo SPI 实现、时间轮实现、动态编译器等。
        -

        Drawing 4.png

        +

        png

        • dubbo-remoting 模块: Dubbo 的远程通信模块,其中的子模块依赖各种开源组件实现远程通信。在 dubbo-remoting-api 子模块中定义该模块的抽象概念,在其他子模块中依赖其他开源组件进行实现,例如,dubbo-remoting-netty4 子模块依赖 Netty 4 实现远程通信,dubbo-remoting-zookeeper 通过 Apache Curator 实现与 ZooKeeper 集群的交互。
        -

        Drawing 5.png

        +

        png

        • dubbo-rpc 模块: Dubbo 中对远程调用协议进行抽象的模块,其中抽象了各种协议,依赖于 dubbo-remoting 模块的远程调用功能。dubbo-rpc-api 子模块是核心抽象,其他子模块是针对具体协议的实现,例如,dubbo-rpc-dubbo 子模块是对 Dubbo 协议的实现,依赖了 dubbo-remoting-netty4 等 dubbo-remoting 子模块。 dubbo-rpc 模块的实现中只包含一对一的调用,不关心集群的相关内容。
        -

        Drawing 6.png

        +

        png

        • dubbo-cluster 模块: Dubbo 中负责管理集群的模块,提供了负载均衡、容错、路由等一系列集群相关的功能,最终的目的是将多个 Provider 伪装为一个 Provider,这样 Consumer 就可以像调用一个 Provider 那样调用 Provider 集群了。
        • dubbo-registry 模块: Dubbo 中负责与多种开源注册中心进行交互的模块,提供注册中心的能力。其中, dubbo-registry-api 子模块是顶层抽象,其他子模块是针对具体开源注册中心组件的具体实现,例如,dubbo-registry-zookeeper 子模块是 Dubbo 接入 ZooKeeper 的具体实现。
        -

        Drawing 7.png

        +

        png

        • dubbo-monitor 模块: Dubbo 的监控模块,主要用于统计服务调用次数、调用时间以及实现调用链跟踪的服务。
        • dubbo-config 模块: Dubbo 对外暴露的配置都是由该模块进行解析的。例如,dubbo-config-api 子模块负责处理 API 方式使用时的相关配置,dubbo-config-spring 子模块负责处理与 Spring 集成使用时的相关配置方式。有了 dubbo-config 模块,用户只需要了解 Dubbo 配置的规则即可,无须了解 Dubbo 内部的细节。
        -

        Drawing 8.png

        +

        png

        • dubbo-metadata 模块: Dubbo 的元数据模块(本课程后续会详细介绍元数据的内容)。dubbo-metadata 模块的实现套路也是有一个 api 子模块进行抽象,然后其他子模块进行具体实现。
        -

        Drawing 9.png

        +

        png

        • dubbo-configcenter 模块: Dubbo 的动态配置模块,主要负责外部化配置以及服务治理规则的存储与通知,提供了多个子模块用来接入多种开源的服务发现组件。
        -

        Drawing 10.png

        +

        png

        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 模块就是定义业务接口的地方,如下图所示:

      -

      Drawing 11.png

      +

      png

      其中,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 模块,其结构如下图所示:

      -

      Drawing 12.png

      +

      png

      在其 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 模块,结构如下图所示:

      -

      Drawing 13.png

      +

      png

      在 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,得到如下图所示的内容:

      -

      Drawing 0.png

      +

      png

      这里传入的 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,会得到下图:

      -

      Drawing 1.png

      +

      png

      传入的 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,会得到下图所示的内容:

      -

      Lark20200731-183202.png

      +

      png

      我们看到传入的 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 的基本使用方式:

      -

      image.png]

      +

      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() 方法,调用关系如下图所示:

      -

      image

      +

      png

      在 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() 方法,调用关系如下图所示:

      -

      image

      +

      png

      首先来看 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 接口就是一个扩展接口:

      -

      Drawing 0.png

      +

      png

      @SPI 注解的 value 值指定了默认的扩展名称,例如,在通过 Dubbo SPI 加载 Protocol 接口实现时,如果没有明确指定扩展名,则默认会将 @SPI 注解的 value 值作为扩展名,即加载 dubbo 这个扩展名对应的 org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,相关的 SPI 配置文件在 dubbo-rpc-dubbo 模块中,如下图所示:

      -

      Drawing 1.png

      +

      png

      那 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
     
    -

    Drawing 2.png

    +

    png

    • 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 注解。

    -

    Drawing 3.png

    +

    png

    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 配置文件中指定的类,如下图所示:

    -

    Drawing 4.png

    +

    png

    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 集合缓存的扩展实现如下表所示:

    -

    11.png

    +

    png

    在 Provider 端调用 getActivateExtension() 方法时传入的 values 配置为 "demoFilter3、-demoFilter2、default、demoFilter1",那么根据上面的逻辑:

    1. 得到默认激活的扩展实实现集合中有 [ demoFilter4, demoFilter6 ];
    2. 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),一般会使用时间轮的方式。

      时间轮是一种高效的、批量管理定时任务的调度模型。时间轮一般会实现成一个环形结构,类似一个时钟,分为很多槽,一个槽代表一个时间间隔,每个槽使用双向链表存储定时任务;指针周期性地跳动,跳动到一个槽位,就执行该槽位的定时任务。

      -

      1.png

      +

      png

      时间轮环形结构示意图

      需要注意的是,单层时间轮的容量和精度都是有限的,对于精度要求特别高、时间跨度特别大或是海量定时任务需要调度的场景,通常会使用多级时间轮以及持久化存储与时间轮结合的方案。

      那在 Dubbo 中,时间轮的具体实现方式是怎样的呢?本课时我们就重点探讨下。Dubbo 的时间轮实现位于 dubbo-common 模块的 org.apache.dubbo.common.timer 包中,下面我们就来分析时间轮涉及的核心接口和实现。

      核心接口

      在 Dubbo 中,所有的定时任务都要继承 TimerTask 接口。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象,我们不仅可以查看定时任务的状态,还可以操作定时任务(例如取消关联的定时任务)。Timeout 接口中的方法如下图所示:

      -

      image.png

      +

      png.png

      Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法:提交一个定时任务(TimerTask)并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。

      -

      image

      +

      png

      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 源码也有相应的接入模块,如下图所示:

        -

        Drawing 0.png

        +

        png

        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 集群的核心架构。

        -

        2.png

        +

        png

        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 树中的每个节点都可以拥有子节点,这与文件系统的目录树类似。

        -

        1.png

        +

        png

        ZooKeeper 树型存储结构

        ZNode 节点类型有如下四种:

          @@ -317,7 +317,7 @@ function hide_canvas() {
        • 临时顺序节点。 基本特性与临时节点一致,创建节点的过程中,ZooKeeper 会在其名字后自动追加一个单调增长的数字后缀,作为新的节点名。

        在每个 ZNode 中都维护着一个 stat 结构,记录了该 ZNode 的元数据,其中包括版本号、操作控制列表(ACL)、时间戳和数据长度等信息,如下表所示:

        -

        Drawing 3.png

        +

        png

        我们除了可以通过 ZooKeeper Client 对 ZNode 进行增删改查等基本操作,还可以注册 Watcher 监听 ZNode 节点、其中的数据以及子节点的变化。一旦监听到变化,则相应的 Watcher 即被触发,相应的 ZooKeeper Client 会立即得到通知。Watcher 有如下特点:

        • 主动推送。 Watcher 被触发时,由 ZooKeeper 集群主动将更新推送给客户端,而不需要客户端轮询。
        • @@ -337,7 +337,7 @@ function hide_canvas() {
        • 最后,Follower 节点会返回 Client 写请求相应的响应。

    下图展示了写操作的核心流程:

    -

    Drawing 4.png

    +

    png

    写操作核心流程图

    崩溃恢复

    上面写请求处理流程中,如果发生 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 包:

    -

    1.png

    +

    png

    下面我们从最基础的使用展开,逐一介绍 Apache Curator 在实践中常用的核心功能,开始我们的 Apache Curator 之旅。

    1. 基本操作

    简单了解了 Apache Curator 各个组件的定位之后,下面我们立刻通过一个示例上手使用 Curator。首先,我们创建一个 Maven 项目,并添加 Apache Curator 的依赖:

    @@ -519,7 +519,7 @@ function hide_canvas() { }

    接下来,我们打开 ZooKeeper 的命令行客户端,在 /user 节点下先后添加两个子节点,如下所示:

    -

    Drawing 0.png

    +

    png

    此时我们只得到一行输出:

    NodeChildrenChanged,/user
     
    @@ -616,13 +616,13 @@ TreeCache,type=NODE_ADDED path=/user/test2 TreeCache,type=INITIALIZED

    接下来,我们在 ZooKeeper 命令行客户端中更新 /user 节点中的数据

    -

    Drawing 1.png

    +

    png

    得到如下输出:

    TreeCache,type=NODE_UPDATED path=/user
     NodeCache节点路径:/user,节点数据为:userData
     

    创建 /user/test3 节点

    -

    Drawing 2.png

    +

    png

    得到输出:

    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 节点的数据

    -

    Drawing 3.png

    +

    png

    得到输出:

    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 节点

    -

    Drawing 4.png

    +

    png

    得到输出:

    TreeCache,type=NODE_REMOVED path=/user/test3
     2020-06-26T08:44:06.329  CHILD_REMOVED 
    @@ -650,7 +650,7 @@ PathChildrenCache删除子节点:/user/test3
     
    -

    Drawing 5.png

    +

    png

    这两个组件的使用与 JDK 动态代理中的 Proxy 和 InvocationHandler 相似。

    下面我们通过一个示例简单介绍 CGLib 的使用。在使用 CGLib 创建动态代理类时,首先需要定义一个 Callback 接口的实现, CGLib 中也提供了多个Callback接口的子接口,如下图所示:

    -

    image

    +

    png

    这里以 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)中,如下图所示,每个请求都需要独立的线程完成读数据、业务处理以及写回数据的完整操作。

    -

    2.png

    +

    png

    一个线程在同一时刻只能与一个连接绑定,如下图所示,当请求的并发量较大时,就需要创建大量线程来处理连接,这就会导致系统浪费大量的资源进行线程切换,降低程序的性能。我们知道,网络数据的传输速度是远远慢于 CPU 的处理速度,连接建立后,并不总是有数据可读,连接也并不总是可写,那么线程就只能阻塞等待,CPU 的计算能力不能得到充分发挥,同时还会导致大量线程的切换,浪费资源。

    -

    3.png

    +

    png

    2. I/O 多路复用模型

    针对传统的阻塞 I/O 模型的缺点,I/O 复用的模型在性能方面有不小的提升。I/O 复用模型中的多个连接会共用一个 Selector 对象,由 Selector 感知连接的读写事件,而此时的线程数并不需要和连接数一致,只需要很少的线程定期从 Selector 上查询连接的读写状态即可,无须大量线程阻塞等待连接。当某个连接有新的数据可以处理时,操作系统会通知线程,线程从阻塞状态返回,开始进行读写操作以及后续的业务逻辑处理。I/O 复用的模型如下图所示:

    -

    4.png

    +

    png

    Netty 就是采用了上述 I/O 复用的模型。由于多路复用器 Selector 的存在,可以同时并发处理成百上千个网络连接,大大增加了服务器的处理能力。另外,Selector 并不会阻塞线程,也就是说当一个连接不可读或不可写的时候,线程可以去处理其他可读或可写的连接,这就充分提升了 I/O 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程切换。如下图所示:

    -

    6.png

    +

    png

    从数据处理的角度来看,传统的阻塞 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 的逻辑,而不是阻塞等待。具体情况如下图所示:

    -

    7.png

    +

    png

    单 Reactor 单线程的优点就是:线程模型简单,没有引入多线程,自然也就没有多线程并发和竞争的问题。

    但其缺点也非常明显,那就是性能瓶颈问题,一个线程只能跑在一个 CPU 上,能处理的连接数是有限的,无法完全发挥多核 CPU 的优势。一旦某个业务逻辑耗时较长,这唯一的线程就会卡在上面,无法处理其他连接的请求,程序进入假死的状态,可用性也就降低了。正是由于这种限制,一般只会在客户端使用这种线程模型。

    2. 单 Reactor 多线程

    在单 Reactor 多线程的架构中,Reactor 监控到客户端请求之后,如果连接建立的请求,则由Acceptor 通过 accept 处理,然后创建一个 Handler 对象处理连接建立之后的业务请求。如果不是连接建立请求,则 Reactor 会将事件分发给调用连接对应的 Handler 来处理。到此为止,该流程与单 Reactor 单线程的模型基本一致,唯一的区别就是执行 Handler 逻辑的线程隶属于一个线程池

    -

    8.png

    +

    png

    单 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)才能发送数据。

    -

    9.png

    +

    png

    主从 Reactor 多线程模型

    主从 Reactor 多线程的设计模式解决了单一 Reactor 的瓶颈。主从 Reactor 职责明确,主 Reactor 只负责监听连接建立事件,SubReactor只负责监听读写事件。整个主从 Reactor 多线程架构充分利用了多核 CPU 的优势,可以支持扩展,而且与具体的业务逻辑充分解耦,复用性高。但不足的地方是,在交互上略显复杂,需要一定的编程门槛。

    4. Netty 线程模型

    Netty 同时支持上述几种线程模式,Netty 针对服务器端的设计是在主从 Reactor 多线程模型的基础上进行的修改,如下图所示:

    -

    1.png

    +

    png

    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 事件的顺序,这就是典型的责任链模式

    -

    Drawing 0.png

    +

    png

    从图中我们还可以看到,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 拦截并处理出站事件。

    -

    Drawing 1.png

    +

    png

    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。 如下图所示:

    -

    1.png

    +

    png

    最后,需要注意的是,如果要在 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 线程中串行执行。
    -

    2.png

    +

    png

    • 定时任务队列。当用户在非 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 三者的关联关系,如下图所示:

    -

    Drawing 4.png

    +

    png

    前面我们提到过,在 Netty 服务器端中,会有 BossEventLoopGroup 和 WorkerEventLoopGroup 两个 NioEventLoopGroup。通常一个服务端口只需要一个ServerSocketChannel,对应一个 Selector 和一个 NioEventLoop 线程。

    BossEventLoop 负责接收客户端的连接事件,即 OP_ACCEPT 事件,然后将创建的 NioSocketChannel 交给 WorkerEventLoopGroup; WorkerEventLoopGroup 会由 next() 方法选择其中一个 NioEventLoopGroup,并将这个 NioSocketChannel 注册到其维护的 Selector 并对其后续的I/O事件进行处理。

    -

    image

    +

    png

    如上图,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
     
    -

    Drawing 6.png

    +

    png

    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 个组;依次类推,直至最顶层。

      -

      Drawing 7.png

      +

      png

      1. 内存分配&释放

      当服务向内存池请求内存时,Netty 会将请求分配的内存数向上取整到最接近的分组大小,然后在该分组的相应层级中从左至右寻找空闲分组。例如,服务请求分配 3 * PageSize 的内存,向上取整得到的分组大小为 4 * PageSize,在该层分组中找到完全空闲的一组内存进行分配即可,如下图:

      -

      Drawing 8.png

      +

      png

      当分组大小 4 * PageSize 的内存分配出去后,为了方便下次内存分配,分组被标记为全部已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)。

      Netty 使用完全平衡树的结构实现了上述算法,这个完全平衡树底层是基于一个 byte 数组构建的,如下图所示:

      -

      Drawing 9.png

      +

      png

      具体的实现逻辑这里就不再展开讲述了,你若感兴趣的话,可以参考 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 的方式记录各个小内存的使用情况(如下图所示)。虽然这种方案不能完美消灭内存碎片,但是很大程度上还是减少了内存浪费。

    -

    Drawing 10.png

    +

    png

    为了解决单个 PoolChunk 容量有限的问题,Netty 将多个 PoolChunk 组成链表一起管理,然后用 PoolChunkList 对象持有链表的 head。

    Netty 通过 PoolArena 管理 PoolChunkList 以及 PoolSubpage

    PoolArena 内部持有 6 个 PoolChunkList,各个 PoolChunkList 持有的 PoolChunk 的使用率区间有所不同,如下图所示:

    -

    Drawing 11.png

    +

    png

    6 个 PoolChunkList 对象组成双向链表,当 PoolChunk 内存分配、释放,导致使用率变化,需要判断 PoolChunk 是否超过所在 PoolChunkList 的限定使用率范围,如果超出了,需要沿着 6 个 PoolChunkList 的双向链表找到新的合适的 PoolChunkList ,成为新的 head。同样,当新建 PoolChunk 分配内存或释放空间时,PoolChunk 也需要按照上面逻辑放入合适的PoolChunkList 中。

    -

    Drawing 12.png

    +

    png

    从上图可以看出,这 6 个 PoolChunkList 额定使用率区间存在交叉,这样设计的原因是:如果使用单个临界值的话,当一个 PoolChunk 被来回申请和释放,内存使用率会在临界值上下徘徊,这就会导致它在两个 PoolChunkList 链表中来回移动。

    PoolArena 内部持有 2 个 PoolSubpage 数组,分别存储微型 Buffer 和小型 Buffer 的PoolSubpage。相同大小的 PoolSubpage 组成链表,不同大小的 PoolSubpage 链表的 head 节点保存在 tinySubpagePools 或者 smallSubpagePools 数组中,如下图:

    -

    Drawing 13.png

    +

    png

    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 协议等,如下图所示:

    -

    1.png

    +

    png

    简易 RPC 框架的架构图

    本课时我们主要实现RPC 框架的基石部分——远程调用,简易版 RPC 框架一次远程调用的核心流程是这样的:

      @@ -306,7 +306,7 @@ function hide_canvas() {

      这个远程调用的过程,就是我们简易版本 RPC 框架的核心实现,只有理解了这个流程,才能进行后续的开发。

      项目结构

      了解了简易版 RPC 框架的工作流程和实现目标之后,我们再来看下项目的结构,为了方便起见,这里我们将整个项目放到了一个 Module 中了,如下图所示,你可以按照自己的需求进行模块划分。

      -

      image

      +

      png

      那这各个包的功能是怎样的呢?我们就来一一说明。

      • 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 协议,如下图所示:

        -

        image

        +

        png

        在 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 之间的转换,如下图所示:

        -

        image

        +

        png

        Netty 提供的 Decoder 和 Encoder 实现

        在 Netty 的源码中,我们可以看到对很多已有协议的序列化和反序列化都是基于上述抽象类实现的,例如,HttpServerCodec 中通过依赖 HttpServerRequestDecoder 和 HttpServerResponseEncoder 来实现 HTTP 请求的解码和 HTTP 响应的编码。如下图所示,HttpServerRequestDecoder 继承自 ByteToMessageDecoder,实现了 ByteBuf 到 HTTP 请求之间的转换;HttpServerResponseEncoder 继承自 MessageToMessageEncoder,实现 HTTP 响应到其他消息的转换(其中包括转换成 ByteBuf 的能力)。

        HttpServerCodec结构图.png

        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,如下图所示:

        -

        Drawing 0.png

        +

        png

        DemoRpcClientHandler 和 DemoRpcServerHandler 的继承关系图

        下面我们就来看一下这两个自定义的 ChannelHandler 实现:

        public class DemoRpcServerHandler extends
        @@ -431,7 +431,7 @@ public class DemoRpcClientHandler extends
         }
         

        通过 DemoRpcClient 的代码我们可以看到其 ChannelHandler 的执行顺序如下:

        -

        Lark20200904-143159.png

        +

        png

        客户端 ChannelHandler 结构图

        另外,在创建EventLoopGroup时并没有直接使用NioEventLoopGroup,而是在 NettyEventLoopFactory 中根据当前操作系统进行选择,对于 Linux 系统,会使用 EpollEventLoopGroup,其他系统则使用 NioEventLoopGroup。

        接下来我们再看DemoRpcServer 的具体实现

        @@ -475,13 +475,13 @@ public class DemoRpcClientHandler extends }

        通过对 DemoRpcServer 实现的分析,我们可以知道每个 Channel 上的 ChannelHandler 顺序如下:

        -

        Lark20200904-143204.png

        +

        png

        服务端 ChannelHandler 结构图

        registry 相关实现

        介绍完客户端和服务端的通信之后,我们再来看简易 RPC 框架的另一个基础能力——服务注册与服务发现能力,对应 demo-rpc 项目源码中的 registry 包。

        registry 包主要是依赖 Apache Curator 实现了一个简易版本的 ZooKeeper 客户端,并基于 ZooKeeper 实现了注册中心最基本的两个功能:Provider 注册以及 Consumer 订阅。

        这里我们先定义一个 Registry 接口,其中提供了注册以及查询服务实例的方法,如下图所示:

        -

        Drawing 3.png

        +

        png

        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 业务接口。

        -

        Drawing 4.png

        +

        png

        使用接入的相关类

        首先,我们定义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 架构图中可知:

        -

        Drawing 0.png

        +

        png

        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 红框之外的内容。

          -

          Drawing 1.png

          +

          png

          整个 Dubbo 体系图

          核心接口

          作为“注册中心”部分的第一课时,我们有必要介绍下 dubbo-registry-api 模块中的核心抽象接口,如下图所示:

          -

          Drawing 2.png

          +

          png

          在 Dubbo 中,一般使用 Node 这个接口来抽象节点的概念。Node不仅可以表示 Provider 和 Consumer 节点,还可以表示注册中心节点。Node 接口中定义了三个非常基础的方法(如下图所示):

          -

          Drawing 3.png

          +

          png

          • getUrl() 方法返回表示当前节点的 URL;
          • isAvailable() 检测当前节点是否可用;
          • destroy() 方法负责销毁当前节点并释放底层资源。

          RegistryService 接口抽象了注册服务的基本行为,如下图所示:

          -

          Drawing 4.png

          +

          png

          • register() 方法和 unregister() 方法分别表示注册取消注册一个 URL。
          • subscribe() 方法和 unsubscribe() 方法分别表示订阅取消订阅一个 URL。订阅成功之后,当订阅的数据发生变化时,注册中心会主动通知第二个参数指定的 NotifyListener 对象,NotifyListener 接口中定义的 notify() 方法就是用来接收该通知的。
          • lookup() 方法能够查询符合条件的注册数据,它与 subscribe() 方法有一定的区别,subscribe() 方法采用的是 push 模式,lookup() 方法采用的是 pull 模式。

          Registry 接口继承了 RegistryService 接口和 Node 接口,如下图所示,它表示的就是一个拥有注册中心能力的节点,其中的 reExportRegister() 和 reExportUnregister() 方法都是委托给 RegistryService 中的相应方法。

          -

          Drawing 5.png

          +

          png

          RegistryFactory 接口是 Registry 的工厂接口,负责创建 Registry 对象,具体定义如下所示,其中 @SPI 注解指定了默认的扩展名为 dubbo,@Adaptive 注解表示会生成适配器类并根据 URL 参数中的 protocol 参数值选择相应的实现。

          @SPI("dubbo")
           public interface RegistryFactory {
          @@ -330,9 +330,9 @@ public interface RegistryFactory {
           }
           

          通过下面两张继承关系图可以看出,每个 Registry 实现类都有对应的 RegistryFactory 工厂实现,每个 RegistryFactory 工厂实现只负责创建对应的 Registry 对象。

          -

          Drawing 6.png

          +

          png

          RegistryFactory 继承关系图

          -

          Drawing 7.png

          +

          png

          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 的子类会覆盖上述四个基础的注册、订阅方法进行增强。

          -

          Drawing 8.png

          +

          png

          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 中的节点层级结构:

          -

          Drawing 0.png

          +

          png

          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

          -

          Drawing 1.png

          +

          png

          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 注解指定的默认扩展名)。

        -

        Drawing 2.png

        +

        png

        按照前面对 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。
        -

        Drawing 3.png

        +

        png

        • DataListener:主要监听某个节点存储的数据变化。
        -

        Drawing 4.png

        +

        png

        • **ChildListener:**主要监听某个 ZNode 节点下的子节点变化。
        -

        Drawing 5.png

        +

        png

        在 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 节点层级图中的一层。

        -

        Drawing 6.png

        +

        png

        doSubscribe() 方法的核心是通过 ZookeeperClient 在指定的 path 上添加 ChildListener 监听器,当订阅的节点发现变化的时候,会通过 ChildListener 监听器触发 notify() 方法,在 notify() 方法中会触发传入的 NotifyListener 监听器。

        从 doSubscribe() 方法的代码结构可看出,doSubscribe() 方法的逻辑分为了两个大的分支。

        一个分支是处理:订阅 URL 中明确指定了 Service 层接口的订阅请求。该分支会从 URL 拿到 Consumer 关注的 category 节点集合,然后在每个 category 节点上添加 ChildListener 监听器。下面是 Demo 示例中 Consumer 订阅的三个 path,图中展示了构造 path 各个部分的相关方法:

        -

        Lark20200915-155646.png

        +

        png

        下面是这个分支的核心源码分析:

        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 模块的结构如下图所示:

        -

        Drawing 0.png

        +

        png

        dubbo-serialization-api 模块中定义了 Dubbo 序列化层的核心接口,其中最核心的是 Serialization 这个接口,它是一个扩展接口,被 @SPI 接口修饰,默认扩展实现是 Hessian2Serialization。Serialization 接口的具体实现如下:

        @SPI("hessian2") // 被@SPI注解修饰,默认是使用hessian2序列化算法
         public interface Serialization {
        @@ -335,7 +335,7 @@ public interface Serialization {
         }
         

        Dubbo 提供了多个 Serialization 接口实现,用于接入各种各样的序列化算法,如下图所示:

        -

        Drawing 1.png

        +

        png

        这里我们以默认的 hessian2 序列化方式为例,介绍 Serialization 接口的实现以及其他相关实现。 Hessian2Serialization 实现如下所示:

        public class Hessian2Serialization implements Serialization {
             public byte getContentTypeId() {
        @@ -353,11 +353,11 @@ public interface Serialization {
         }
         

        Hessian2Serialization 中的 serialize() 方法创建的 ObjectOutput 接口实现为 Hessian2ObjectOutput,继承关系如下图所示:

        -

        Drawing 2.png

        +

        png

        在 DataOutput 接口中定义了序列化 Java 中各种数据类型的相应方法,如下图所示,其中有序列化 boolean、short、int、long 等基础类型的方法,也有序列化 String、byte[] 的方法。

        -

        Drawing 3.png

        +

        png

        ObjectOutput 接口继承了 DataOutput 接口,并在其基础之上,添加了序列化对象的功能,具体定义如下图所示,其中的 writeThrowable()、writeEvent() 和 writeAttachments() 方法都是调用 writeObject() 方法实现的。

        -

        Drawing 4.png

        +

        png

        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,继承关系如下所示:

        -

        Drawing 5.png

        +

        png

        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 两层。

        -

        Drawing 0.png

        +

        png

        Dubbo 整体架构设计图

        Dubbo 并没有自己实现一套完整的网络库,而是使用现有的、相对成熟的第三方网络库,例如,Netty、Mina 或是 Grizzly 等 NIO 框架。我们可以根据自己的实际场景和需求修改配置,选择底层使用的 NIO 框架。

        下图展示了 dubbo-remoting 模块的结构,其中每个子模块对应一个第三方 NIO 框架,例如,dubbo-remoting-netty4 子模块使用 Netty4 实现 Dubbo 的远程通信,dubbo-remoting-grizzly 子模块使用 Grizzly 实现 Dubbo 的远程通信。

        -

        Drawing 1.png

        +

        png

        其中的 dubbo-remoting-zookeeper,我们在前面第 15 课时介绍基于 Zookeeper 的注册中心实现时已经讲解过了,它使用 Apache Curator 实现了与 Zookeeper 的交互。

        dubbo-remoting-api 模块

        需要注意的是,Dubbo 的 dubbo-remoting-api 是其他 dubbo-remoting-* 模块的顶层抽象,其他 dubbo-remoting 子模块都是依赖第三方 NIO 库实现 dubbo-remoting-api 模块的,依赖关系如下图所示:

        -

        Drawing 2.png

        +

        png

        我们先来看一下 dubbo-remoting-api 中对整个 Remoting 层的抽象,dubbo-remoting-api 模块的结构如下图所示:

        -

        Drawing 3.png

        +

        png

        一般情况下,我们会将功能类似或是相关联的类放到一个包中,所以我们需要先来了解 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 接口的定义如下:

          -

          Drawing 4.png

          +

          png

          如上图所示,这里的 get*() 方法是获得 Endpoint 本身的一些属性,其中包括获取 Endpoint 的本地地址、关联的 URL 信息以及底层 Channel 关联的 ChannelHandler。send() 方法负责数据发送,两个重载的区别在后面介绍 Endpoint 实现的时候我们再详细说明。最后两个 close() 方法的重载以及 startClose() 方法用于关闭底层 Channel ,isClosed() 方法用于检测底层 Channel 是否已关闭。

          Channel 是对两个 Endpoint 连接的抽象,好比连接两个位置的传送带,两个 Endpoint 传输的消息就好比传送带上的货物,消息发送端会往 Channel 写入消息,而接收端会从 Channel 读取消息。这与第 10 课时介绍的 Netty 中的 Channel 基本一致。

          -

          Lark20200922-162359.png

          +

          png

          下面是Channel 接口的定义,我们可以看出两点:一个是 Channel 接口继承了 Endpoint 接口,也具备开关状态以及发送数据的能力;另一个是可以在 Channel 上附加 KV 属性。

          -

          Drawing 5.png

          +

          png

          ChannelHandler 是注册在 Channel 上的消息处理器,在 Netty 中也有类似的抽象,相信你对此应该不会陌生。下图展示了 ChannelHandler 接口的定义,在 ChannelHandler 中可以处理 Channel 的连接建立以及连接断开事件,还可以处理读取到的数据、发送的数据以及捕获到的异常。从这些方法的命名可以看到,它们都是动词的过去式,说明相应事件已经发生过了。

          -

          Drawing 6.png

          +

          png

          需要注意的是: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 等接口,也就是说两者都具备了读写数据能力。

          -

          Drawing 7.png

          +

          png

          Client 和 Server 本身都是 Endpoint,只不过在语义上区分了请求和响应的职责,两者都具备发送的能力,所以都继承了 Endpoint 接口。Client 和 Server 的主要区别是 Client 只能关联一个 Channel,而 Server 可以接收多个 Client 发起的 Channel 连接。所以在 RemotingServer 接口中定义了查询 Channel 的相关方法,如下图所示:

          -

          Drawing 8.png

          +

          png

          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-* 实现模块中。

          -

          Drawing 9.png

          +

          png

          这些 Transporter 接口实现返回的 Client 和 RemotingServer 具体是什么呢?如下图所示,返回的是 NIO 库对应的 RemotingServer 实现和 Client 实现。

          -

          Drawing 10.png -Drawing 11.png

          +

          png +png

          相信看到这里,你应该已经发现 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 的架构非常类似。

        -

        Lark20200922-162354.png

        +

        png

        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 设计进行统一,其子类如下图所示:

        -

        Drawing 0.png

        +

        png

        ChannelBuffer 继承关系图

        下面我们就按照 ChannelBuffer 的继承结构,从顶层的 ChannelBuffer 接口开始,逐个向下介绍,直至最底层的各个实现类。

        ChannelBuffer 接口

        @@ -303,7 +303,7 @@ function hide_canvas() {
      • capacity()、clear()、copy() 等辅助方法用来获取 ChannelBuffer 容量以及实现清理、拷贝数据的功能,这里不再赘述。
      • factory() 方法:该方法返回创建 ChannelBuffer 的工厂对象,ChannelBufferFactory 中定义了多个 getBuffer() 方法重载来创建 ChannelBuffer,如下图所示,这些 ChannelBufferFactory的实现都是单例的。
      -

      Drawing 1.png

      +

      png

      ChannelBufferFactory 继承关系图

      AbstractChannelBuffer 抽象类实现了 ChannelBuffer 接口的大部分方法,其核心是维护了以下四个索引。

        @@ -358,7 +358,7 @@ public ChannelBuffer getBuffer(byte[] array, int offset, int length) {
      • factory(ChannelBufferFactory 类型),用于创建被修饰的 HeapChannelBuffer 对象的 ChannelBufferFactory 工厂,默认为 HeapChannelBufferFactory。

      DynamicChannelBuffer 需要关注的是 ensureWritableBytes() 方法,该方法实现了动态扩容的功能,在每次写入数据之前,都需要调用该方法确定当前可用空间是否足够,调用位置如下图所示:

      -

      Drawing 2.png

      +

      png

      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 提供了一套输入输出流,如下图所示:

      -

      Drawing 3.png

      +

      png

      ChannelBufferInputStream 底层封装了一个 ChannelBuffer,其实现 InputStream 接口的 read*() 方法全部都是从 ChannelBuffer 中读取数据。ChannelBufferInputStream 中还维护了一个 startIndex 和一个endIndex 索引,作为读取数据的起止位置。ChannelBufferOutputStream 与 ChannelBufferInputStream 类似,会向底层的 ChannelBuffer 写入数据,这里就不再展开,你若感兴趣的话可以参考源码进行分析。

      最后要介绍 ChannelBuffers 这个门面类,下图展示了 ChannelBuffers 这个门面类的所有方法:

      -

      Drawing 4.png

      +

      png

      对这些方法进行分类,可归纳出如下这些方法。

      • 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 抽象类的父类。

        -

        Drawing 0.png

        +

        png

        AbstractPeer 继承关系

        Netty 中也有 ChannelHandler、Channel 等接口,但无特殊说明的情况下,这里的接口指的都是 Dubbo 中定义的接口。如果涉及 Netty 中的接口,会进行特殊说明。

        @@ -338,7 +338,7 @@ function hide_canvas() {

      Server 继承路线分析

      AbstractServer 和 AbstractClient 都实现了 AbstractEndpoint 抽象类,我们先来看 AbstractServer 的实现。AbstractServer 在继承了 AbstractEndpoint 的同时,还实现了 RemotingServer 接口,如下图所示:

      -

      Drawing 1.png

      +

      png

      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 接口的实现类如下图所示:

    -

    Drawing 2.png

    +

    png

    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 端结构如下:

    -

    5.png

    +

    png

    NettyServer 模型

    核心 ChannelHandler

    下面我们来逐个看看这四个 ChannelHandler 的核心功能。

    首先是decoder 和 encoder,它们都是 NettyCodecAdapter 的内部类,如下图所示,分别继承了 Netty 中的 ByteToMessageDecoder 和 MessageToByteEncoder:

    -

    Drawing 4.png

    +

    png

    还记得 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,从下面的继承图就能看出来。

    -

    Drawing 5.png

    +

    png

    NettyServerHandler 继承关系图

    在 NettyServerHandler 中有 channels 和 handler 两个核心字段。

    • channels(Map<String,Channel>集合):记录了当前 Server 创建的所有 Channel,从下图中可以看到,连接创建(触发 channelActive() 方法)、连接断开(触发 channelInactive()方法)会操作 channels 集合进行相应的增删。
    -

    Drawing 6.png

    +

    png

    • handler(ChannelHandler 类型):NettyServerHandler 内几乎所有方法都会触发该 Dubbo ChannelHandler 对象(如下图)。
    -

    Drawing 7.png

    +

    png

    这里以 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 对象,如下图所示:

    -

    Drawing 8.png

    +

    png

    也就是说,NettyServerHandler 会将数据委托给这个 ChannelHandler。

    到此为止,Server 这条继承线就介绍完了。你可以回顾一下,从 AbstractPeer 开始往下,一路继承下来,NettyServer 拥有了 Endpoint、ChannelHandler 以及RemotingServer多个接口的能力,关联了一个 ChannelHandler 对象以及 Codec2 对象,并最终将数据委托给这两个对象进行处理。所以,上层调用方只需要实现 ChannelHandler 和 Codec2 这两个接口就可以了。

    -

    6.png

    +

    png

    总结

    本课时重点介绍了 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 结构如下图所示:

    -

    Lark20200930-161759.png

    +

    png

    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 集合的方法:

    -

    Drawing 1.png

    +

    png

    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 的其他实现类,涉及的实现类如下所示:

    -

    Drawing 2.png

    +

    png

    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 有紧密的联系。

    -

    Drawing 3.png

    +

    png

    WrappedChannelHandler 继承关系图

    从上图中我们可以看到,每个 WrappedChannelHandler 实现类的对象都由一个相应的 Dispatcher 实现类创建,下面是 Dispatcher 接口的定义:

    @SPI(AllDispatcher.NAME) // 默认扩展名是all
    @@ -520,7 +520,7 @@ public interface Dispatcher {
     

    老版本中没有 ExecutorRepository 的概念,不会根据 URL 复用同一个线程池,而是通过 SPI 找到 ThreadPool 实现创建新线程池。

    此时,Dubbo Consumer 同步请求的线程模型如下图所示:

    -

    Drawing 4.png

    +

    png

    Dubbo Consumer 同步请求线程模型

    从图中我们可以看到下面的请求-响应流程:

      @@ -531,7 +531,7 @@ public interface Dispatcher {

    在这个设计里面,Consumer 端会维护一个线程池,而且线程池是按照连接隔离的,即每个连接独享一个线程池。这样,当面临需要消费大量服务且并发数比较大的场景时,例如,典型网关类场景,可能会导致 Consumer 端线程个数不断增加,导致线程调度消耗过多 CPU ,也可能因为线程创建过多而导致 OOM。

    为了解决上述问题,Dubbo 在 2.7.5 版本之后,引入了 ThreadlessExecutor,将线程模型修改成了下图的样子:

    -

    Drawing 5.png

    +

    png

    引入 ThreadlessExecutor 后的结构图

    1. 业务线程发出请求之后,拿到一个 Future 对象。
    2. @@ -586,10 +586,10 @@ public interface Dispatcher { }

    结合前面的分析,我们可以得到下面这张图:

    -

    Drawing 6.png

    +

    png

    Server 端 ChannelHandler 结构图

    我们可以在创建 NettyServerHandler 的地方添加断点 Debug 得到下图,也印证了上图的内容:

    -

    Drawing 7.png

    +

    png

    总结

    本课时我们重点介绍了 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 接口的定义如下:

    -

    Drawing 0.png

    +

    png

    ExchangeChannel 接口

    其中,request() 方法负责发送请求,从图中可以看到这里有两个重载,其中一个重载可以指定请求的超时时间,返回值都是 Future 对象。

    -

    Drawing 1.png

    +

    png

    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 层的能力。如下图所示:

    -

    Lark20201013-153600.png

    +

    png

    ChannelHandler 继承关系总览图

    HeaderExchangeHandler 作为一个装饰器,其 connected()、disconnected()、sent()、received()、caught() 方法最终都会转发给上层提供的 ExchangeHandler 进行处理。这里我们需要聚焦的是 HeaderExchangeHandler 本身对 Request 和 Response 的处理逻辑。

    -

    Lark20201013-153557.png

    +

    png

    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 接口是个空接口,没有定义任何方法。

    -

    Drawing 0.png

    +

    png

    HeaderExchangeClient 继承关系图

    HeaderExchangeClient 中有以下两个核心字段。

      @@ -327,7 +327,7 @@ function hide_canvas() {

      其实,startReconnectTask() 方法的具体实现与前面展示的 startHeartBeatTask() 方法类似,这里就不再赘述。

      下面我们继续回到心跳定时任务进行分析,你可以回顾第 20 课时介绍的 NettyClient 实现,其 canHandleIdle() 方法返回 true,表示该实现可以自己发送心跳请求,无须 HeaderExchangeClient 再启动一个定时任务。NettyClient 主要依靠 IdleStateHandler 中的定时任务来触发心跳事件,依靠 NettyClientHandler 来发送心跳请求。

      对于无法自己发送心跳请求的 Client 实现,HeaderExchangeClient 会为其启动 HeartbeatTimerTask 心跳定时任务,其继承关系如下图所示:

      -

      Drawing 1.png

      +

      png

      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 这三个接口我们在前面已经详细介绍过了,这里不再重复。

        -

        Drawing 2.png

        +

        png

        HeaderExchangeServer 的继承关系图

        与前面介绍的 HeaderExchangeClient 一样,HeaderExchangeServer 是 RemotingServer 的装饰器,实现自 RemotingServer 接口的大部分方法都委托给了所修饰的 RemotingServer 对象。

        在 HeaderExchangeServer 的构造方法中,会启动一个 CloseTimerTask 定时任务,定期关闭长时间空闲的连接,具体的实现方式与 HeaderExchangeClient 中的两个定时任务类似,这里不再展开分析。

        @@ -423,7 +423,7 @@ public interface Exchanger { }

    Dubbo 只为 Exchanger 接口提供了 HeaderExchanger 这一个实现,其中 connect() 方法创建的是 HeaderExchangeClient 对象,bind() 方法创建的是 HeaderExchangeServer 对象,如下图所示:

    -

    Drawing 3.png

    +

    png

    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 接口的实现,涉及的类如下图所示:

    -

    Drawing 4.png

    +

    png

    AbstractCodec抽象类并没有实现 Codec2 中定义的接口方法,而是提供了几个给子类用的基础方法,下面简单说明这些方法的功能。

    • getSerialization() 方法:通过 SPI 获取当前使用的序列化方式。
    • @@ -451,7 +451,7 @@ public interface Exchanger {

      接下来看TransportCodec,我们可以看到这类上被标记了 @Deprecated 注解,表示已经废弃。TransportCodec 的实现非常简单,其中根据 getSerialization() 方法选择的序列化方法对传入消息或 ChannelBuffer 进行序列化或反序列化,这里就不再介绍 TransportCodec 实现了。

      TelnetCodec继承了 TransportCodec 序列化和反序列化的基本能力,同时还提供了对 Telnet 命令处理的能力。

      最后来看ExchangeCodec,它在 TelnetCodec 的基础之上,添加了处理协议头的能力。下面是 Dubbo 协议的格式,能够清晰地看出协议中各个数据所占的位数:

      -

      Drawing 5.png

      +

      png

      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 层进行装饰。

        -

        Drawing 0.png

        +

        png

        Dubbo 架构中 Protocol 层的位置图

        Protocol 层在 Dubbo 源码中对应的是 dubbo-rpc 模块,该模块的结构如下图所示:

        -

        Drawing 1.png

        +

        png

        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 模块的包结构,如下图所示:

        -

        Drawing 2.png

        +

        png

        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。

          -

          Lark20201013-153553.png

          +

          png

          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 接口的继承关系:

          -

          Drawing 0.png

          +

          png

          Protocol 接口继承关系图

          其中,AbstractProtocol提供了一些 Protocol 实现需要的公共能力以及公共字段,它的核心字段有如下三个。

          • exporterMap(Map<String, Exporter<?>>类型):用于存储出去的服务集合,其中的 Key 通过 ProtocolUtils.serviceKey() 方法创建的服务标识,在 ProtocolUtils 中维护了多层的 Map 结构(如下图所示)。首先按照 group 分组,在实践中我们可以根据需求设置 group,例如,按照机房、地域等进行 group 划分,做到就近调用;在 GroupServiceKeyCache 中,依次按照 serviceName、serviceVersion、port 进行分类,最终缓存的 serviceKey 是前面三者拼接而成的。
          -

          Lark20201016-164613.png

          +

          png

          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 的继承关系如下图所示:

          -

          Drawing 2.png

          +

          png

          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 来接收客户端的请求。

          -

          Drawing 3.png

          +

          png

          export() 方法调用栈

          下面我们将逐个介绍 export() 方法栈中的每个被调用的方法。

          首先,在 openServer() 方法中会根据 URL 判断当前是否为服务端,只有服务端才能创建 ProtocolServer 并对外服务。如果是来自服务端的调用,会依靠 serverMap 集合检查是否已有 ProtocolServer 在监听 URL 指定的地址;如果没有,会调用 createServer() 方法进行创建。openServer() 方法的具体实现如下:

          @@ -398,7 +398,7 @@ function hide_canvas() { }

    在 createServer() 方法中还有几个细节需要展开分析一下。第一个是创建 ExchangeServer 时,使用的 Codec2 接口实现实际上是 DubboCountCodec,对应的 SPI 配置文件如下:

    -

    Drawing 4.png

    +

    png

    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 的子类。

    -

    Drawing 5.png

    +

    png

    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 接口,如下图所示:

    -

    Drawing 6.png

    +

    png

    RpcInvocation 继承关系图

    下面是 RpcInvocation 中的核心字段,通过读写这些字段即可实现 Invocation 接口的全部方法。

      @@ -478,7 +478,7 @@ function hide_canvas() {
    • invokeMode(InvokeMode类型):此次调用的模式,分为 SYNC、ASYNC 和 FUTURE 三类。

    我们在上面的继承图中看到 RpcInvocation 的一个子类—— DecodeableRpcInvocation,它是用来支持解码的,其实现的 decode() 方法正好是 DubboCodec.encodeRequestData() 方法对应的解码操作,在 DubboCodec.decodeBody() 方法中就调用了这个方法,调用关系如下图所示:

    -

    Drawing 7.png

    +

    png

    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 等序列化算法时,会读取该集合中的类,完成注册操作,相关的调用关系如下图所示:

    -

    Drawing 8.png

    +

    png

    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 都启动了两个独享连接:

    -

    Lark20201020-171207.png

    +

    png

    Service 独享连接示意图

    当使用共享连接的时候,会区分不同的网络地址(host:port),一个地址只建立固定数量的共享连接。如下图所示,Provider 1 暴露了多个服务,Consumer 引用了 Provider 1 中的多个服务,共享连接是说 Consumer 调用 Provider 1 中的多个服务时,是通过固定数量的共享 TCP 长连接进行数据传输,这样就可以达到减少服务端连接数的目的。

    -

    Lark20201020-171159.png

    +

    png

    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() 方法中则会减少引用次数。

    -

    Drawing 2.png

    +

    png

    referenceCount 修改调用栈

    这样,对于同一个地址的共享连接,就可以满足两个基本需求:

      @@ -417,7 +417,7 @@ private void replaceWithLazyClient() { }

    LazyConnectExchangeClient 也是 ExchangeClient 的装饰器,它会在原有 ExchangeClient 对象的基础上添加懒加载的功能。LazyConnectExchangeClient 在构造方法中不会创建底层持有连接的 Client,而是在需要发送请求的时候,才会调用 initClient() 方法进行 Client 的创建,如下图调用关系所示:

    -

    Drawing 3.png

    +

    png

    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 接口,继承关系如下图所示:

    -

    Drawing 0.png

    +

    png

    AbstractInvoker 继承关系示意图

    从图中可以看到,最核心的 DubboInvoker 继承自AbstractInvoker 抽象类,AbstractInvoker 的核心字段有如下几个。

      @@ -470,7 +470,7 @@ private static final InternalThreadLocal<RpcContext> SERVER_LOCAL = ... }

    oneway 指的是客户端发送消息后,不需要得到响应。所以,对于那些不关心服务端响应的请求,就比较适合使用 oneway 通信,如下图所示:

    -

    Lark20201023-161312.png

    +

    png

    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 的默认调用模式,具体含义如下图所示,客户端发送请求之后,客户端线程会阻塞等待服务端返回响应。

    -

    Lark20201027-180625.png

    +

    png

    SYNC 调用模式图

    在拿到线程池之后,DubboInvoker 就会调用 ExchangeClient.request() 方法,将 Invocation 包装成 Request 请求发送出去,同时会创建相应的 DefaultFuture 返回。注意,这里还加了一个回调,取出其中的 AppResponse 对象。AppResponse 表示的是服务端返回的具体响应,其中有三个字段。

    为了解决上述问题,Dubbo 独立出了一个实现集群功能的模块—— dubbo-cluster

    -

    Drawing 0.png

    +

    png

    dubbo-cluster 结构图

    作为 dubbo-cluster 模块分析的第一课时,我们就首先来了解一下 dubbo-cluster 模块的架构以及最核心的 Cluster 接口。

    Cluster 架构

    dubbo-cluster 模块的主要功能是将多个 Provider 伪装成一个 Provider 供 Consumer 调用,其中涉及集群的容错处理、路由规则的处理以及负载均衡。下图展示了 dubbo-cluster 的核心组件:

    -

    Lark20201110-175555.png

    +

    png

    Cluster 核心接口图

    由图我们可以看出,dubbo-cluster 主要包括以下四个核心接口:

    LoadBalance 接口

    上述 Dubbo 提供的负载均衡实现,都是 LoadBalance 接口的实现类,如下图所示:

    -

    Lark20201124-174750.png

    +

    png

    LoadBalance 继承关系图

    LoadBalance 是一个扩展接口,默认使用的扩展实现是 RandomLoadBalance,其定义如下所示,其中的 @Adaptive 注解参数为 loadbalance,即动态生成的适配器会按照 URL 中的 loadbalance 参数值选择扩展实现类。

    @SPI(RandomLoadBalance.NAME)
    @@ -378,16 +378,16 @@ public interface LoadBalance {
     hash(请求参数) % 2^32
     

    Provider 地址和请求经过对 2^32 取模得到的结果值,都会落到一个 Hash 环上,如下图所示:

    -

    Lark20201124-174752.png

    +

    png

    一致性 Hash 节点均匀分布图

    我们按顺时针的方向,依次将请求分发到对应的 Provider。这样,当某台 Provider 节点宕机或增加新的 Provider 节点时,只会影响这个 Provider 节点对应的请求。

    在理想情况下,一致性 Hash 算法会将这三个 Provider 节点均匀地分布到 Hash 环上,请求也可以均匀地分发给这三个 Provider 节点。但在实际情况中,这三个 Provider 节点地址取模之后的值,可能差距不大,这样会导致大量的请求落到一个 Provider 节点上,如下图所示:

    -

    Lark20201124-174755.png

    +

    png

    一致性 Hash 节点非均匀分布图

    这就出现了数据倾斜的问题。所谓数据倾斜是指由于节点不够分散,导致大量请求落到了同一个节点上,而其他节点只会接收到少量请求的情况

    为了解决一致性 Hash 算法中出现的数据倾斜问题,又演化出了 Hash 槽的概念。

    Hash 槽解决数据倾斜的思路是:既然问题是由 Provider 节点在 Hash 环上分布不均匀造成的,那么可以虚拟出 n 组 P1、P2、P3 的 Provider 节点 ,让多组 Provider 节点相对均匀地分布在 Hash 环上。如下图所示,相同阴影的节点均为同一个 Provider 节点,比如 P1-1、P1-2……P1-99 表示的都是 P1 这个 Provider 节点。引入 Provider 虚拟节点之后,让 Provider 在圆环上分散开来,以避免数据倾斜问题。

    -

    Lark20201124-174743.png

    +

    png

    数据倾斜解决示意图

    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,如下图所示:

    -

    Drawing 5.png

    +

    png

    权重坐标轴示意图

    下面我们通过随机数生成器在 [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。

    -

    Lark20201127-153527.png

    +

    png

    1. 处理第一个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [0, 0, 0] 变为 [5, 1, 1]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-2, 1, 1]。
    2. 处理第二个请求,currentWeight 数组中的权重与配置的 weight 相加,即从 [-2, 1, 1] 变为 [3, 2, 2]。接下来,从中选择权重最大的 Invoker 作为结果,即节点 A。最后,将节点 A 的 currentWeight 值减去 totalWeight 值,最终得到 currentWeight 数组为 [-4, 2, 2]。
    3. 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 对象)。

      -

      Lark20201201-164714.png

      +

      png

      Cluster 核心流程图

      Cluster Invoker 获取 Invoker 的流程大致可描述为如下:

        @@ -327,10 +327,10 @@ public interface Cluster { }

        Cluster 接口的实现类如下图所示,分别对应前面提到的多种容错策略:

        -

        Lark20201201-164718.png

        +

        png

        Cluster 接口继承关系

        在每个 Cluster 接口实现中,都会创建对应的 Invoker 对象,这些都继承自 AbstractClusterInvoker 抽象类,如下图所示:

        -

        Lark20201201-164728.png

        +

        png

        AbstractClusterInvoker 继承关系图

        通过上面两张继承关系图我们可以看出,Cluster 接口和 Invoker 接口都会有相应的抽象实现类,这些抽象实现类都实现了一些公共能力。下面我们就来深入介绍 AbstractClusterInvoker 和 AbstractCluster 这两个抽象类。

        AbstractClusterInvoker

        @@ -543,7 +543,7 @@ public <T> Invoker<T> join(Directory<T> directory) throws RpcE }

        Dubbo 提供了两个 ClusterInterceptor 实现类,分别是 ConsumerContextClusterInterceptor 和 ZoneAwareClusterInterceptor,如下图所示:

        -

        Lark20201201-164721.png

        +

        png

        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 中使用多个注册中心的架构如下图所示:

        -

        Lark20201203-183149.png

        +

        png

        双注册中心结构图

        Consumer 可以使用 ZoneAwareClusterInvoker 先在多个注册中心之间进行选择,选定注册中心之后,再选择 Provider 节点,如下图所示:

        -

        Lark20201203-183145.png

        +

        png

        ZoneAwareClusterInvoker 在多注册中心之间进行选择的策略有以下四种。

        1. 找到preferred 属性为 true 的注册中心,它是优先级最高的注册中心,只有该中心无可用 Provider 节点时,才会回落到其他注册中心。
        2. 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 实现,具体继承关系如下图所示:

          -

          Lark20201208-135542.png

          +

          png

          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 这条线。

          -

          Drawing 0.png

          +

          png

          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,它的继承关系如下图所示:

          -

          Drawing 1.png

          +

          png

          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 进行发布。服务发布流程中涉及的核心对象转换,如下图所示:

          -

          Lark20201215-163844.png

          +

          png

          服务发布核心流程图

          exportServices() 方法的具体实现如下:

          private void exportServices() {
          @@ -667,7 +667,7 @@ anyhost=true
           
        3. 触发 RegistryProtocolListener 监听器。

        远程发布的详细流程如下图所示:

        -

        Drawing 1.png

        +

        png

        服务发布详细流程图

        总结

        本课时我们重点介绍了 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 传统架构以及这个架构中最核心的组件:

        -

        Drawing 0.png

        +

        png

        Dubbo 核心架构图

        结合上面这张架构图,我们可以一起回顾一下这些核心组件的功能。

          @@ -315,7 +315,7 @@ function hide_canvas() {

          Dubbo 的改进方案

          Dubbo 从 2.7.0 版本开始增加了简化 URL的特性,从 URL 中抽出的数据会被存放至元数据中心。但是这次优化只是缩短了 URL 的长度,从内存使用量以及降低通知频繁度的角度降低了注册中心的压力,并没有减少注册中心 URL 的数量,所以注册中心所承受的压力还是比较明显的。

          Dubbo 2.7.5 版本引入了服务自省架构,进一步降低了注册中心的压力。在此次优化中,Dubbo 修改成应用为粒度的服务注册与发现模型,最大化地减少了 Dubbo 服务元信息注册数量,其核心流程如下图所示:

          -

          Lark20201222-120323.png

          +

          png

          服务自省架构图

          上图展示了引入服务自省之后的 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 服务修订的核心逻辑:

          -

          Lark20201222-120318.png

          +

          png

          引入 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() {
          1. methods(List 类型):接口中定义的全部方法描述信息。在 MethodDefinition 中记录了方法的名称、参数类型、返回值类型以及方法参数涉及的所有 TypeDefinition。
          2. types(List 类型):接口定义中涉及的全部类型描述信息,包括方法的参数和字段,如果遇到复杂类型,TypeDefinition 会递归获取复杂类型内部的字段。在 dubbo-metadata-api 模块中,提供了多种类型对应的 TypeBuilder 用于创建对应的 TypeDefinition,对于没有特定 TypeBuilder 实现的类型,会使用 DefaultTypeBuilder。
        -

        6.png

        +

        png

        TypeBuilder 接口实现关系图

        在服务发布的时候,会将服务的 URL 中的部分数据封装为 FullServiceDefinition 对象,然后作为元数据存储起来。FullServiceDefinition 继承了 ServiceDefinition,并在 ServiceDefinition 基础之上扩展了 params 集合(Map<String, String> 类型),用来存储 URL 上的参数。

        MetadataService

        接下来看 MetadataService 接口,在上一讲我们提到Dubbo 中的每个 ServiceInstance 都会发布 MetadataService 接口供 Consumer 端查询元数据,下图展示了 MetadataService 接口的继承关系:

        -

        1.png

        +

        png

        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 节点与元数据中心交互的桥梁,其继承关系如下图所示:

        -

        2.png

        +

        png

        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 子类中实现的。

        -

        Drawing 3.png

        +

        png

        2. BaseMetadataIdentifier

        在 AbstractMetadataReport 上报元数据的时候,元数据对应的 Key 都是BaseMetadataIdentifier 类型的对象,其继承关系如下图所示:

        -

        3.png

        +

        png

        BaseMetadataIdentifier 继承关系图

        • MetadataIdentifier 中包含了服务接口、version、group、side 和 application 五个核心字段。
        • @@ -695,7 +695,7 @@ public interface MetadataReportFactory {

        MetadataReportFactory 是个扩展接口,从 @SPI 注解的默认值可以看出Dubbo 默认使用 Redis 实现元数据中心。 Dubbo 提供了针对 ZooKeeper、Redis、Consul 等作为元数据中心的 MetadataReportFactory 实现,如下图所示:

        -

        4.png

        +

        png

        MetadataReportFactory 继承关系图

        这些 MetadataReportFactory 实现都继承了 AbstractMetadataReportFactory,在 AbstractMetadataReportFactory 提供了缓存 MetadataReport 实现的功能,并定义了一个 createMetadataReport() 抽象方法供子类实现。另外,AbstractMetadataReportFactory 实现了 MetadataReportFactory 接口的 getMetadataReport() 方法,下面我们就来简单看一下该方法的实现:

        public MetadataReport getMetadataReport(URL url) {
        @@ -763,7 +763,7 @@ String getNodePath(BaseMetadataIdentifier metadataIdentifier) {
         }
         

        MetadataServiceExporter 只有 ConfigurableMetadataServiceExporter 这一个实现,如下图所示:

        -

        Drawing 6.png

        +

        png

        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 实现,如下图所示:

        -

        Lark20201229-160604.png

        +

        png

        ServiceDiscovery 继承关系图

        在 Dubbo 创建 ServiceDiscovery 对象的时候,会通过 ServiceDiscoveryFactory 工厂类进行创建。ServiceDiscoveryFactory 接口也是一个扩展接口,Dubbo 只提供了一个默认实现—— DefaultServiceDiscoveryFactory,其继承关系如下图所示:

        -

        Lark20201229-160606.png

        +

        png

        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 接口,如下图所示:

        -

        Drawing 2.png

        +

        png

        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 接口的实现,如下图所示:

        -

        Drawing 3.png

        +

        png

        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,如下图所示:

        -

        Drawing 4.png

        +

        png

        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 实现,继承关系如下所示:

          -

          Drawing 0.png

          +

          png

          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 接口的所有实现类:

        -

        Drawing 1.png

        +

        png

        ServiceInstanceCustomizer 继承关系图

        我们首先来看 ServiceInstanceMetadataCustomizer 这个抽象类,它主要是对 ServiceInstance 中 metadata 这个 KV 集合进行自定义修改,这部分逻辑在 customize() 方法中,如下所示:

        public final void customize(ServiceInstance serviceInstance) {
        @@ -624,7 +624,7 @@ public interface ServiceInstanceCustomizer extends Prioritized {
         }
         

        这里涉及一个新的接口——MetadataServiceProxyFactory,它是用来创建 MetadataService 本地代理的工厂类,继承关系如下所示:

        -

        Drawing 2.png

        +

        png

        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 接口的实现:

        -

        Drawing 3.png

        +

        png

        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 在继承体系中的位置:

        -

        Drawing 4.png

        +

        png

        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 获取对应配置值的相关方法,如下图所示:

      -

      Drawing 0.png

      +

      png

      Configuration 接口核心方法

      从上图中我们可以看到,Configuration 针对不同的 boolean、int、String 返回值都有对应的 get*() 方法,同时还提供了带有默认值的 get*() 方法。这些 get

      *() 方法底层首先调用 getInternalProperty() 方法获取配置值,然后调用 convert() 方法将获取到的配置值转换成返回值的类型之后返回。getInternalProperty() 是一个抽象方法,由 Configuration 接口的子类具体实现。

      下图展示了 Dubbo 中提供的 Configuration 接口实现,包括:SystemConfiguration、EnvironmentConfiguration、InmemoryConfiguration、PropertiesConfiguration、CompositeConfiguration、ConfigConfigurationAdapter 和 DynamicConfiguration。下面我们将结合具体代码逐个介绍其实现。

      -

      Drawing 1.png

      +

      png

      Configuration 继承关系图

      SystemConfiguration & EnvironmentConfiguration

      SystemConfiguration 是从 Java Properties 配置(也就是 -D 配置参数)中获取相应的配置项,EnvironmentConfiguration 是从使用环境变量中获取相应的配置。两者的 getInternalProperty() 方法实现如下:

      @@ -435,7 +435,7 @@ public interface OrderedPropertiesProvider {

      ConfigConfigurationAdapter

      Dubbo 通过 AbstractConfig 类来抽象实例对应的配置,如下图所示:

      -

      Drawing 2.png

      +

      png

      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 接口对应的继承关系如下:

      -

      11.png

      +

      png

      DynamicConfigurationFactory 继承关系图

      -

      Drawing 4.png

      +

      png

      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 的配置变化。

      -

      222.png

      +

      png

      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 抽象类,如下图所示:

      -

      Drawing 1.png

      +

      png

      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 日志。

      -

      Drawing 2.png

      +

      png

      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 的流行程度也是稳居第一。

      -

      1c91b731-e86d-4b59-85b6-8b7ec53e87d6.jpg

      -

      4fead80b-dc2e-4c40-852f-c4bd22bab207.jpg

      +

      png

      +

      png

      拉勾网 2019 年 9 月统计的招聘岗位比例,也可以看到 Java 和 JavaScript 是最高的,不过 Java 的求职难度只有 JavaScript 的 1/7。

      -

      f5b072d7-2235-4814-ac63-3e90f0633629.jpg

      +

      png

      Java 平均一个岗位有 4 个人竞争,而 JavaScript 则是 28 个,Perl 最夸张,超过 30 个。

      -

      d70b22b6-177c-443e-8ef1-957531028c60.jpg

      +

      png

      而通过职友网的数据统计,北京、上海、杭州、深圳的 Java 程序员平均薪酬在 16-21K 之间,在广州、成都、苏州、南京等城市也有 11K-13K 的平均收入,远超一般行业的收入水平。

      -

      fd19dbb9-87e6-40bd-9d67-4455f1ee2513.jpg

      +

      png

      所以学习 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() {
    4. JDK = JRE + 开发工具
    5. JRE = JVM + 类库
    6. -

      0.18346271077222331.png

      +

      png

      三者在开发运行 Java 程序时的交互关系:

      简单的说,就是通过 JDK 开发的程序,编译以后,可以打包分发给其他装有 JRE 的机器上去运行。而运行的程序,则是通过 Java 命令启动的一个 JVM 实例,代码逻辑的执行都运行在这个 JVM 实例上。

      -

      0.9484384203409852.png

      +

      png

      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 版本即可。 891e2fe6-e872-4aa9-b00d-d176e947f11f.jpg

      -

      建议安装比较新的 JDK8 版本, 如 JDK8u2313bbdc5e9-149c-407d-b757-69a061581aae.png

      +

      JDK 通常是从 Oracle 官网下载, 打开页面翻到底部,找 Java for Developers 或者 Developers, 进入 Java 相应的页面 或者 Java SE 相应的页面, 查找 Download, 接受许可协议,下载对应的 x64 版本即可。 png

      +

      建议安装比较新的 JDK8 版本, 如 JDK8u231png

      注意:从 Oracle 官方安装 JDK 需要注册和登录 Oracle 账号。现在流行将下载链接放到页面底部,很多工具都这样。当前推荐下载 JDK8。 今后 JDK11 可能成为主流版本,因为 Java11 是 LTS 长期支持版本,但可能还需要一些时间才会普及,而且 JDK11 的文件目录结构与之前不同, 很多工具可能不兼容其 JDK 文件的目录结构。

      @@ -361,7 +361,7 @@ function hide_canvas() {

      brew cask install java

      -

      如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): 035a0b3e-de33-4e97-946c-c9adb8b68ae7.png

      +

      如果电脑上有 360 软件管家或者腾讯软件管家,也可以直接搜索和下载安装 JDK(版本不是最新的,但不用注册登录 Oracle 账号): png

      如果网络不好,可以从我的百度网盘共享获取:

      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 上:

      -

      54940291.png 在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 JVM 情况。

      +

      png 在后面的章节里,我们会详细解决其中一些工具的用法,以及怎么用它们来分析 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 的内容之前,我们先了解一下性能相关的一些基本概念和原则。

      -

      0.260488235671565.png

      +

      png

      如果要问目前最火热的 JVM 知识是什么? 很多同学的答案可能是 “JVM 调优” 或者 “JVM 性能优化”。但是具体需要从哪儿入手,怎么去做呢?

      其实“调优”是一个诊断和处理手段,我们最终的目标是让系统的处理能力,也就是“性能”达到最优化,这个过程我们就像是一个医生,诊断和治疗“应用系统”这位病人。我们以作为医生给系统看病作为对比,“性能优化”就是实现“把身体的大小毛病治好,身体达到最佳健康状态”的目标。

      那么去医院看病,医生会是怎么一个处理流程呢?先简单的询问和了解基本情况,发烧了没有,咳嗽几天了,最近吃了什么,有没有拉肚子一类的,然后给患者开了一系列的检查化验单子:去查个血、拍个胸透、验个尿之类的。然后就会有医生使用各项仪器工具,依次把去做这些项目的检查,检查的结果就是很多标准化的具体指标(这里就是我们对 JVM 进行信息收集,变成各项指标)。

      然后拿过来给医生诊断用,医生根据这些指标数据判断哪些是异常的,哪些是正常的,这些异常指标说明了什么问题(对系统问题进行分析排查),比如是白细胞增多(系统延迟和抖动增加,偶尔宕机),说明可能有炎症(比如 JVM 配置不合理)。最后要“对症下药”,开出一些阿莫西林或者头孢(对 JVM 配置进行调整),叮嘱怎么频率,什么时间点服药,如果问题比较严重,是不是要住院做手术(系统重构和调整),同时告知一些注意事项(对日常运维的要求和建议),最后经过一段时间治疗,逐渐好转,最终痊愈(系统延迟降低,不在抖动,不再宕机)。通过了解 JVM 去让我们具有分析和诊断能力,是本课程的核心主题。

      2.1 量化性能相关指标

      -

      0.7784482211178771.png

      +

      png

      "没有量化就没有改进",所以我们需要先了解和度量性能指标,就像在医院检查以后得到的检验报告单一样。因为人的主观感觉是不靠谱的,个人经验本身也是无法复制的,而定义了量化的指标,就意味着我们有了一个客观度量体系。哪怕我们最开始定义的指标不是特别精确,我们也可以在使用过程中,随着真实的场景去验证指标有效性,进而替换或者调整指标,逐渐的完善这个量化的指标体系,成为一个可以复制和复用的有效工具。就像是上图的血常规检查报告单,一旦成为这种标准化的指标,那么使用它得到的结果,也就是这个报告单,给任何一个医生看,都是有效的,一般也能得到一致的判断结果。

      那么系统性能的诊断要做些什么指标呢?我们先来考虑,进行要做诊断,那么程序或 JVM 可能出现了问题,而我们排查程序运行中出现的问题,比如排查程序 BUG 的时候,要优先保证正确性,这时候就不仅仅是 JVM 本身的问题,例如死锁等等,程序跑在 JVM 里,现象出现在 JVM 上,很多时候还要深入分析业务代码和逻辑确定 Java 程序哪里有问题。

        @@ -229,7 +229,7 @@ function hide_canvas() {
      1. 业务需求指标:如吞吐量(QPS、TPS)、响应时间(RT)、并发数、业务成功率等。
      2. 资源约束指标:如 CPU、内存、I/O 等资源的消耗情况。
      3. -

        0.3186824516633562.png

        +

        png

        详情可参考: 性能测试中服务器关键性能指标浅析

        @@ -246,7 +246,7 @@ function hide_canvas() {
      4. 调整 JVM 启动参数,GC 策略等等
      5. 2.3 性能调优总结

        -

        9b861ce8-8350-4943-ac1f-d6fb4fa2f127.png

        +

        png

        性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 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)编程语言分类

        首先,我们可以把形形色色的编程从底向上划分为最基本的三大类:机器语言、汇编语言、高级语言。

        -

        66340662.png

        +

        png

        按《计算机编程语言的发展与应用》一文里的定义:计算机编程语言能够实现人与机器之间的交流和沟通,而计算机编程语言主要包括汇编语言、机器语言以及高级语言,具体内容如下:

        • 机器语言:这种语言主要是利用二进制编码进行指令的发送,能够被计算机快速地识别,其灵活性相对较高,且执行速度较为可观,机器语言与汇编语言之间的相似性较高,但由于具有局限性,所以在使用上存在一定的约束性。
        • @@ -238,8 +238,8 @@ function hide_canvas() {

          现在我们聊聊跨平台,为什么要跨平台,因为我们希望所编写的代码和程序,在源代码级别或者编译后,可以运行在多种不同的系统平台上,而不需要为了各个平台的不同点而去实现两套代码。典型地,我们编写一个 web 程序,自然希望可以把它部署到 Windows 平台上,也可以部署到 Linux 平台上,甚至是 MacOS 系统上。

          这就是跨平台的能力,极大地节省了开发和维护成本,赢得了商业市场上的一致好评。

          这样来看,一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。

          -

          1、典型的源码跨平台(C++): 71212109.png

          -

          2、典型的二进制跨平台(Java 字节码): 71237637.png

          +

          1、典型的源码跨平台(C++): png

          +

          2、典型的二进制跨平台(Java 字节码): png

          可以看到,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)。

          我们在前面反编译的代码中已经看到过这些内容。

          -

          c0463778-bb4c-43ab-9660-558d2897b364.jpg

          +

          png

          局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量+形参的个数有关,还要看每个变量/参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。

          有一些操作码/指令可以将值压入“操作数栈”; 还有一些操作码/指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。

          4.7 方法体中的字节码解读

          @@ -408,11 +408,11 @@ SourceFile: "HelloByteCode.java"

          例如, new 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。

          因此,下一条指令 dup 的索引从 3 开始。

          如果将这个方法体变成可视化数组,那么看起来应该是这样的:

          -

          2087a5ff-61b1-49ab-889e-698a73ceb41e.jpg

          +

          png

          每个操作码/指令都有对应的十六进制(HEX)表示形式, 如果换成十六进制来表示,则方法体可表示为HEX字符串。例如上面的方法体百世成十六进制如下所示:

          -

          b75bd86b-45c4-4b05-9266-1b7151c7038f.jpg

          +

          png

          甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:

          -

          9f8bf31f-e936-47c6-a3d1-f0c0de0fc898.jpg (此图由开源文本编辑软件Atom的hex-view插件生成)

          +

          png (此图由开源文本编辑软件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)。
        -

        9d1a9509-c0ca-4320-983c-141257b0ddf5.jpg

        +

        png

        dup_x1dup2_x1 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?

        请看一个实际案例:怎样交换 2 个 double 类型的值?

        需要注意的是,一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。

        要执行交换,你可能想到了 swap 指令,但问题是 swap 只适用于单字(one-word, 单字一般指 32 位 4 个字节,64 位则是双字),所以不能处理 double 类型,但 Java 中又没有 swap2 指令。

        怎么办呢? 解决方法就是使用 dup2_x2 指令,将操作数栈顶部的 double 值,复制到栈底 double 值的下方, 然后再使用 pop2 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。 示意图如下图所示:

        -

        17ee9537-a42f-4a49-bb87-9a03735ab83a.jpg

        +

        png

        dupdup_x1dup2_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); 1e17af1a-6b6b-4992-a75c-9eac959bc467.jpg

        +

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

        理解这些字节码的诀窍在于:

        给局部变量赋值时,需要使用相应的指令来进行 store,如 astore_1store 类的指令都会删除栈顶值。 相应的 load 指令则会将值从局部变量表压入操作数栈,但并不会删除局部变量中的值。

        @@ -748,11 +748,11 @@ javap -c -verbose demo/jvm0104/ForLoopTest

        Java 字节码中有许多指令可以执行算术运算。实际上,指令集中有很大一部分表示都是关于数学运算的。对于所有数值类型(int, long, double, float),都有加,减,乘,除,取反的指令。

        那么 bytechar, boolean 呢? JVM 是当做 int 来处理的。另外还有部分指令用于数据类型之间的转换。

        -

        算术操作码和类型 30666bbb-50a0-4114-9675-b0626fd0167b.jpg

        +

        算术操作码和类型 png

        当我们想将 int 类型的值赋值给 long 类型的变量时,就会发生类型转换。

        -

        类型转换操作码 e8c82cb5-6e86-4d52-90cc-40cde0fabaa0.jpg

        +

        类型转换操作码 png

        在前面的示例中, 将 int 值作为参数传递给实际上接收 doublesubmit() 方法时,可以看到, 在实际调用该方法之前,使用了类型转换的操作码:

                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 类的生命周期和加载过程

        -

        3de64ff2-77de-4468-af3a-c61bbb8cd944.png

        +

        png

        一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。

        其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就分别来说一下这五个过程。

        1)加载 加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的“class 文件”。 如果找不到二进制表示形式,则会抛出 NoClassDefFound 错误。

        @@ -284,14 +284,14 @@ function hide_canvas() {
      6. 应用类加载器(AppClassLoader)
      7. 一般启动类加载器是由 JVM 内部实现的,在 Java 的 API 里无法拿到,但是我们可以侧面看到和影响它(后面的内容会演示)。后 2 种类加载器在 Oracle Hotspot JVM 里,都是在中sun.misc.Launcher定义的,扩展类加载器和应用类加载器一般都继承自URLClassLoader类,这个类也默认实现了从各种不同来源加载 class 字节码转换成 Class 的方法。

        -

        c32f4986-0e72-4268-a90a-7451e1931161.png

        +

        png

        1. 启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class)。它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个 null。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。
        2. 扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。
        3. 应用类加载器(app class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。

        此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。

        -

        8a806e88-cd41-4a28-b552-76efb0a1fdba.png

        +

        png

        类加载机制有三个特点:

        1. 双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。
        2. 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)两个部分。 如下图所示:

          -

          6f0f8921-0768-4d1d-8811-f27a8a6608a8.jpg

          +

          png

          JVM 中,每个正在运行的线程,都有自己的线程栈。 线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息。

          所以线程栈又被称为“方法栈”或“调用栈”(call stack)。线程在执行代码时,调用栈中的信息会一直在变化。

          线程栈里面保存了调用链上正在执行的所有方法中的局部变量。

          @@ -216,7 +216,7 @@ function hide_canvas() {
        3. 不管是创建一个对象并将其赋值给局部变量, 还是赋值给另一个对象的成员变量, 创建的对象都会被保存到堆内存中。
        4. 下图演示了线程栈上的调用栈和局部变量,以及存储在堆内存中的对象:

          -

          91015fe2-53dc-477d-ba6d-fd0fe5e864e0.jpg

          +

          png

          • 如果是原生数据类型的局部变量,那么它的内容就全部保留在线程栈上。
          • 如果是对象引用,则栈中的局部变量槽位中保存着对象的引用地址,而实际的对象内容保存在堆中。
          • @@ -230,21 +230,21 @@ function hide_canvas() {
          • 如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。

          示意图如下所示:

          -

          5eb89250-e803-44bb-8553-a2ae74fd01ba.jpg

          +

          png

          总结一下:虽然各个线程自己使用的局部变量都在自己的栈上,但是大家可以共享堆上的对象,特别地各个不同线程访问同一个对象实例的基础类型的成员变量,会给每个线程一个变量的副本。

          6.2 栈内存的结构

          根据以上内容和对 JVM 内存划分的理解,制作了几张逻辑概念图供大家参考。

          先看看栈内存(Stack)的大体结构:

          -

          dd71b714-e026-4679-b589-52c8b9226b6f.jpg

          +

          png

          每启动一个线程,JVM 就会在栈空间栈分配对应的线程栈, 比如 1MB 的空间(-Xss1m)。

          线程栈也叫做 Java 方法栈。 如果使用了 JNI 方法,则会分配一个单独的本地方法栈(Native Stack)。

          线程执行过程中,一般会有多个方法组成调用栈(Stack Trace), 比如 A 调用 B,B 调用 C……每执行到一个方法,就会创建对应的栈帧(Frame)。

          -

          6f9940a3-486f-4137-9420-123c9ae0826c.jpg

          +

          png

          栈帧是一个逻辑上的概念,具体的大小在一个方法编写完成后基本上就能确定。

          比如 返回值 需要有一个空间存放吧,每个局部变量都需要对应的地址空间,此外还有给指令使用的 操作数栈,以及 class 指针(标识这个栈帧对应的是哪个类的方法, 指向非堆里面的 Class 对象)。

          6.3 堆内存的结构

          Java 程序除了栈内存之外,最主要的内存区域就是堆内存了。

          -

          706185c0-d264-4a7c-b0c3-e23184ab20b7.jpg

          +

          png

          堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。

          但 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 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。

          -

          af56a365-b03b-46f6-94d0-2983ec2259d8.jpg

          +

          png

          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 选项和程序参数两个可以输入参数的地方,直接输入即可。

          -

          73146375.png

          +

          png

          上图输入了两个 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 权限,或是需要在提示下输入当前用户的密码。

        -

        56345767.png

        +

        png

        然后就可以看到如下信息:

        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,然后回车,可以看到如下界面:

        -

        63078367.png

        +

        png

        本地进程列表列出了本机的所有 Java 进程(远程进程我们在 JMX 课程进行讲解),选择一个要连接的 Java 进程,点击连接,然后可以看到如下界面:

        -

        63206281.png

        +

        png

        注意,点击右上角的绿色连接图标,即可连接或断开这个 Java 进程。

        上图中显示了总共 6 个标签页,每个标签页对应一个监控面板,分别为:

          @@ -221,10 +221,10 @@ function hide_canvas() {

      当我们想关注最近 1 小时或者 1 分钟的数据,就可以选择对应的档。旁边的 3 个标签页(内存、线程、类),也都支持选择时间范围。

      内存

      -

      63726065.png

      +

      png

      内存监控,是 JConsole 中最常用的面板。内存面板的主区域中展示了内存占用量随时间变化的图像,可以通过这个图表,非常直观地判断内存的使用量和变化趋势。

      同时在左上方,我们可以在图表后面的下拉框中选择不同的内存区:

      -

      65575723.png

      +

      png

      本例中,我们使用的是 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 -gcjstat -gcutil 命令的图形化展示,它们的本质是一样的,都是通过采样的方式拿到JVM各个内存池的数据进行统计,并展示出来。

        其实图形界面存在一个问题,如果 GC 特别频繁,每秒钟执行了很多次 GC,实际上图表方式就很难反应出每一次的变化信息。

        -

        64607499.png

        +

        png

        线程

        线程面板展示了线程数变化信息,以及监测到的线程列表。

        • 我们可以常根据名称直接查看线程的状态(运行还是等待中)和调用栈(正在执行什么操作)。
        • 特别地,我们还可以直接点击“检测死锁”按钮来检测死锁,如果没有死锁则会提示“未检测到死锁”。
        -

        64167338.png

        +

        png

        类监控面板,可以直接看到 JVM 加载和卸载的类数量汇总信息。

        -

        64205914.png

        +

        png

        VM 概要

        -

        64231816.png

        +

        png

        VM 概要的数据也很有用,可以看到总共有五个部分:

        • 第一部分是虚拟机的信息;
        • @@ -268,47 +268,47 @@ function hide_canvas() {

          $ jvisualvm

          JVisualVM 启动后的界面大致如下:

          -

          58401878.png

          +

          png

          在其中可以看到本地的 JVM 实例。

          通过双击本地进程或者右键打开,就可以连接到某个 JVM,此时显示的基本信息如下图所示:

          -

          20be819f-99e1-4f28-bfc6-6bc564777966.png

          +

          png

          可以看到,在概述页签中有 PID、启动参数、系统属性等信息。

          切换到监视页签:

          -

          fe7bf60f-1e1a-4fae-81a4-49e854c73fed.png

          +

          png

          在监视页签中可以看到 JVM 整体的运行情况。比如 CPU、堆内存、类、线程等信息。还可以执行一些操作,比如“强制执行垃圾回收”、“堆 Dump”等。

          "线程"页签则展示了 JVM 中的线程列表。再一次看出在程序中对线程(池)命名的好处。

          -

          70799622-072c-45fd-b5df-7c3ac1433061.png

          +

          png

          与 JConsole 只能看线程的调用栈和状态信息相比,这里可以直观看到所有线程的状态颜色和运行时间,从而帮助我们分析过去一段时间哪些线程使用了较多的 CPU 资源。

          抽样器与 Profiler

          JVisualVM 默认情况下,比 JConsole 多了抽样器和 Profiler 这两个工具。

          例如抽样,可以配合我们在性能压测的时候,看压测过程中,各个线程发生了什么、或者是分配了多少内存,每个类直接占用了多少内存等等。

          -

          58663465.png

          -

          58766362.png

          +

          png

          +

          png

          使用 Profiler 时,需要先校准分析器。

          -

          58910878.png

          +

          png

          然后可以像抽样器一样使用了。

          -

          59113954.png

          -

          59294077.png

          +

          png

          +

          png

          从这个面板直接能看到热点方法与执行时间、占用内存以及比例,还可以设置过滤条件。

          同时我们可以直接把当前的数据和分析,作为快照保存,或者将数据导出,以后可以继续加载和分析。

          插件

          JVisualVM 最强大的地方在于插件。

          JDK 8 需要安装较高版本(如 Java SE 8u211),才能从官方服务器安装/更新 JVisualVM 的插件(否则只能凭运气找对应的历史版本)。

          -

          8c352918-6e46-44c3-9081-0f0c7e57c581.png

          +

          png

          JVisualVM 安装 MBeans 插件的步骤:

          通过工具(T)–插件(G)–可用插件–勾选具体的插件–安装–下一步–等待安装完成。

          -

          b65b122e-53ea-4241-88bb-844a5cad65af.png

          +

          png

          最常用的插件是 VisualGC 和 MBeans。

          如果看不到可用插件,请安装最新版本,或者下载插件到本地安装。 先排除网络问题,或者检查更新,重新启动试试。

          -

          4b391dfa-1074-4084-9d37-b7f48779695c.png

          +

          png

          安装完成后,重新连接某个 JVM,即可看到新安装的插件。

          切换到 VisualGC 页签:

          -

          260031cf-d2f0-4ca2-904e-298d4fe3f7b1.png

          +

          png

          在其中可以看到各个内存池的使用情况,以及类加载时间、GC 总次数、GC 总耗时等信息。比起命令行工具要简单得多。

          切换到 MBeans 标签:

          -

          27e732cf-75f8-405e-8686-c2389948fc35.png

          +

          png

          一般人可能不怎么关注 MBean,但 MBean 对于理解 GC的原理倒是挺有用的。

          主要看 java.lang 包下面的 MBean。比如内存池或者垃圾收集器等。

          从图中可以看到,Metaspace 内存池的 Type 是 NON_HEAP。

          @@ -325,7 +325,7 @@ function hide_canvas() {

        根据经验,这些信息对分析GC性能来说,不能得出什么结论。只有编写程序,获取GC相关的 JMX 信息来进行统计和分析。

        下面看怎么执行远程实时监控。

        -

        56b59ee5-2885-425d-a174-b5ea279f9bf6.png

        +

        png

        如上图所示,从文件菜单中,我们可以选择“添加远程主机”,以及“添加 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 后,启动后的界面如下:

        -

        ad3d63a9-6050-4f7d-af0c-5e6fab8e81a1.jpg

        +

        png

        点击相关的按钮或者菜单即可启用对应的功能,JMC 提供的功能和 JVisualVM 差不多。

        飞行记录器

        除了 JConsole 和 JVisualVM 的常见功能(包括 JMX 和插件)以外,JMC 最大的亮点是飞行记录器。

        在进程上点击“飞行记录器”以后,第一次使用时需要确认一下取消锁定商业功能的选项:

        -

        59819531.png

        +

        png

        然后就可以看到飞行记录向导:

        -

        59881001.png

        +

        png

        点击下一步可以看到更多的配置:

        -

        59960019.png

        +

        png

        这里我们可以把堆内存分析、类加载两个选型也勾选上。点击完成,等待一分钟,就可以看到飞行记录。

        -

        60125860.png

        +

        png

        概况里可以使用仪表盘方式查看堆内存、CPU 占用率、GC 暂停时间等数据。

        内存面板则可以看到 GC 的详细分析:

        -

        60966956.png

        -

        60997473.png

        +

        png

        +

        png

        代码面板则可以看到热点方法的执行情况:

        -

        60878569.png

        +

        png

        线程面板则可以看到线程的锁争用情况等:

        -

        61168308.png

        +

        png

        跟 JConsole 和 JVisualVM 相比,这里已经有了很多分析数据了,内存分配速率、GC 的平均时间等等。

        最后,我们也可以通过保存飞行记录为 jfr 文件,以后随时查看和分析,或者发给其他人员来进行分析。

        -

        60801271.png

        +

        png

        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 配置类似,进入编辑:

        -

        749ef972-a71a-475a-a395-ab8e78db5fdf.png

        +

        png

        添加 Remote(不是 Tomcat 下面的那个 Remote Server):

        -

        f6a45f68-6c1c-4c55-90ae-eae35c2dafc3.png

        +

        png

        然后配置端口号,比如 8888。

        -

        82bb5db4-9dc4-443d-9bb9-00864167c52f.png

        +

        png

        然后点击应用(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 的一些信息:

        -

        enter image description here

        +

        png

        也可以直接调用方法,例如查看 VM 参数:

        -

        enter image description here

        +

        png

        如果启动的进程是 Tomcat 或者是 Spring Boot 启动的嵌入式 Tomcat,那么我们还可以看到很多 Tomcat 的信息:

        -

        enter image description here

        +

        png

        JMX 的 MBean 创建和远程访问

        前面讲了在同一个 JVM 里获取 MBean,现在我们再来写一个更完整的例子:创建一个 MBean,然后远程访问它。

        先定义一个 UserMBean 接口(必须以 MBean 作为后缀):

        @@ -386,11 +386,11 @@ public class UserJmxServer {

        打开 JConsole,在远程输入:

        service:jmx:rmi:///jndi/rmi://localhost:1099/user
         
        -

        enter image description here

        +

        png

        查看 User 的属性:

        -

        enter image description here

        +

        png

        直接修改 UserName 的值:

        -

        enter image description here

        +

        png

        使用 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 算法。

        -

        f37a9c99-3c14-4c8c-b285-0130a598c756.jpg

        +

        png

        闲话 GC

        假如我们做生意,需要仓库来存放物资。如果所有仓库都需要公司自建,那成本就太高了,一般人玩不转,而且效率也不高,成本控制不好就很难赚到钱。所以现代社会就有了一种共享精神和租赁意识,大幅度提高了整个社会的资源利用率。

        比如说一条供应链,A 公司转给 B 公司,B 公司转给 C 公司,那么每个公司自己的加工车间和私有仓库,就类似于线程空间,工厂内部会有相应的流水线。因为每个公司/业务员的精力有限,这个私有空间不可能无限大。

        @@ -213,9 +213,9 @@ function hide_canvas() {

        GC 垃圾收集器就像这个仓库部门,负责分配内存,负责追踪这些内存的使用情况,并在适当的时候进行释放。

        于是仓库部门就建立起来,专门管理这些仓库。怎么管理呢?

        先是想了一个办法,叫做“引用计数法”。有人办业务需要来申请仓库,就找个计数器记下次数 1,后续哪个业务用到呢都需要登记一下,继续加 1,每个业务办完计数器就减一。如果一个仓库(对象使用的内存)的计数到降了 0,就说明可以人使用这个仓库了,我们就可以随时在方便的时候去归还/释放这个仓库。(需要注意:一般不是一个仓库到 0 了就立即释放,出于效率考虑,系统总是会等一批仓库一起处理,这样更加高效。)

        -

        8442223.png

        +

        png

        但是呢,如果业务变得更复杂。仓库之间需要协同工作,有了依赖关系之后。

        -

        8648060.png

        +

        png

        这时候单纯的引用计数就会出问题,循环依赖的仓库/对象没办法回收,就像数据库的死锁一样让人讨厌,你没法让它自己变成 0。

        这种情况在计算机中叫做“内存泄漏”,该释放的没释放,该回收的没回收。

        如果依赖关系更复杂,计算机的内存资源很可能用满,或者说不够用,内存不够用则称为“内存溢出”。

        @@ -253,7 +253,7 @@ function hide_canvas() {
      • 在创建新对象时,JVM 在连续的块中分配内存。如果碎片问题很严重,直至没有空闲片段能存放下新创建的对象,就会发生内存分配错误(allocation error)。

      要避免这类问题,JVM 必须确保碎片问题不失控。因此在垃圾收集过程中,不仅仅是标记和清除,还需要执行“内存碎片整理”过程。这个过程让所有可达对象(reachable objects)依次排列,以消除(或减少)碎片。就像是我们把棋盘上剩余的棋子都聚集到一起,留出来足够大的空余区域。示意图如下所示:

      -

      5160496.png

      +

      png

      说明

      JVM 中的引用是一个抽象的概念,如果 GC 移动某个对象,就会修改(栈和堆中)所有指向该对象的引用。

      移动/拷贝/提升/压缩一般来说是一个 STW 的过程,所以修改对象引用是一个安全的行为。但要更新所有的引用,可能会影响应用程序的性能。

      @@ -264,13 +264,13 @@ function hide_canvas() {
    7. 还有一部分不会立即无用,但也不会持续太长时间。
    8. 这些观测形成了 弱代假设(Weak Generational Hypothesis),即我们可以根据对象的不同特点,把对象进行分类。基于这一假设,VM 中的内存被分为年轻代(Young Generation)和老年代(Old Generation)。老年代有时候也称为年老区(Tenured)。

      -

      5808335.png

      +

      png

      拆分为这样两个可清理的单独区域,我们就可以根据对象的不同特点,允许采用不同的算法来大幅提高 GC 的性能。

      天下没有免费的午餐,所以这种方法也不是没有任何问题。例如,在不同分代中的对象可能会互相引用,在收集某一个分代时就会成为“事实上的”GC root。

      当然,要着重强调的是,分代假设并不适用于所有程序。因为分代 GC 算法专门针对“要么死得快”、“否则活得长”这类特征的对象来进行优化,此时 JVM 管理那种存活时间半长不长的对象就显得非常尴尬了。

      内存池划分

      堆内存中的内存池划分也是类似的,不太容易理解的地方在于各个内存池中的垃圾收集是如何运行的。请注意:不同的 GC 算法在实现细节上可能会有所不同,但和本章所介绍的相关概念都是一致的。

      -

      5921643.png

      +

      png

      新生代(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。

      -

      6202084.png

      +

      png

      存活的对象会在两个存活区之间复制多次,直到某些对象的存活 时间达到一定的阀值。分代理论假设,存活超过一定时间的对象很可能会继续存活更长时间。

      这类“年老”的对象因此被提升(promoted)到老年代。提升的时候,存活区的对象不再是复制到另一个存活区,而是迁移到老年代,并在老年代一直驻留,直到变为不可达对象。

      为了确定一个对象是否“足够老”,可以被提升(Promotion)到老年代,GC 模块跟踪记录每个存活区对象存活的次数。每次分代 GC 完成后,存活对象的年龄就会增长。当年龄超过提升阈值(tenuring threshold),就会被提升到老年代区域。

      @@ -318,7 +318,7 @@ function hide_canvas() {

      第一步,记录(census)所有的存活对象,在垃圾收集中有一个叫做 标记(Marking) 的过程专门干这件事。

      标记可达对象(Marking Reachable Objects)

      现代 JVM 中所有的 GC 算法,第一步都是找出所有存活的对象。下面的示意图对此做了最好的诠释:

      -

      6696297.png

      +

      png

      首先,有一些特定的对象被指定为 Garbage Collection Roots(GC 根元素)。包括:

      • 当前正在执行的方法里的局部变量和输入参数
      • @@ -336,15 +336,15 @@ function hide_canvas() {

        清除(Sweeping)

        **Mark and Sweep(标记—清除)**算法的概念非常简单:直接忽略所有的垃圾。也就是说在标记阶段完成后,所有不可达对象占用的内存空间,都被认为是空闲的,因此可以用来分配新对象。

        这种算法需要使用空闲表(free-list),来记录所有的空闲区域,以及每个区域的大小。维护空闲表增加了对象分配时的开销。此外还存在另一个弱点 —— 明明还有很多空闲内存,却可能没有一个区域的大小能够存放需要分配的对象,从而导致分配失败(在 Java 中就是 OutOfMemoryError)。

        -

        6898662.png

        +

        png

        整理(Compacting)

        标记—清除—整理算法(Mark-Sweep-Compact),将所有被标记的对象(存活对象),迁移到内存空间的起始处,消除了“标记—清除算法”的缺点。

        相应的缺点就是 GC 暂停时间会增加,因为需要将所有对象复制到另一个地方,然后修改指向这些对象的引用。

        此算法的优势也很明显,碎片整理之后,分配新对象就很简单,只需要通过指针碰撞(pointer bumping)即可。使用这种算法,内存空间剩余的容量一直是清楚的,不会再导致内存碎片问题。

        -

        7068361.png

        +

        png

        复制(Copying)

        **标记—复制算法(Mark and Copy)**和“标记—整理算法”(Mark and Compact)十分相似:两者都会移动所有存活的对象。区别在于,“标记—复制算法”是将内存移动到另外一个空间:存活区。“标记—复制方法”的优点在于:标记和复制可以同时进行。缺点则是需要一个额外的内存区间,来存放所有的存活对象。

        -

        7149973.png

        +

        png

        下一小节,我们将介绍 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 嘛,再去收集年轻代估计也没什么效果。

        看看示意图:

        -

        54201932.png

        +

        png

        阶段 2:Concurrent Mark(并发标记)

        在此阶段,CMS GC 遍历老年代,标记所有的存活对象,从前一阶段“Initial Mark”找到的根对象开始算起。“并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。请注意,并非所有老年代中存活的对象都在此阶段被标记,因为在标记过程中对象的引用关系还在发生变化。

        -

        80365661.png

        +

        png

        在上面的示意图中,“当前处理的对象”的一个引用就被应用线程给断开了,即这个部分的对象关系发生了变化(下面会讲如何处理)。

        阶段 3:Concurrent Preclean(并发预清理)

        此阶段同样是与应用线程并发执行的,不需要停止应用线程。

        因为前一阶段“并发标记”与程序并发运行,可能有一些引用关系已经发生了改变。如果在并发标记过程中引用关系发生了变化,JVM 会通过“Card(卡片)”的方式将发生了改变的区域标记为“脏”区,这就是所谓的“卡片标记(Card Marking)”。

        -

        82347169.png

        +

        png

        在预清理阶段,这些脏对象会被统计出来,它们所引用的对象也会被标记。此阶段完成后,用以标记的 card 也就会被清空。

        -

        82835555.png

        +

        png

        此外,本阶段也会进行一些必要的细节处理,还会为 Final Remark 阶段做一些准备工作。

        阶段 4:Concurrent Abortable Preclean(可取消的并发预清理)

        此阶段也不停止应用线程。本阶段尝试在 STW 的 Final Remark 阶段 之前尽可能地多做一些工作。本阶段的具体时间取决于多种因素,因为它循环做同样的事情,直到满足某个退出条件(如迭代次数,有用工作量,消耗的系统时间等等)。

        @@ -256,7 +256,7 @@ function hide_canvas() {

        在 5 个标记阶段完成之后,老年代中所有的存活对象都被标记了,然后 GC 将清除所有不使用的对象来回收老年代空间。

        阶段 6:Concurrent Sweep(并发清除)

        此阶段与应用程序并发执行,不需要 STW 停顿。JVM 在此阶段删除不再使用的对象,并回收它们占用的内存空间。

        -

        85886580.png

        +

        png

        阶段 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 区拼在一起那就是老年代,如下图所示:

        -

        4477357.png

        +

        png

        这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块,见下图带对号的部分:

        -

        36113613.png

        +

        png

        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 根。注意,在并发标记过程中,老年代中被确定为垃圾的对象会被忽略,即使有外部引用指向它们:因为在这种情况下引用者也是垃圾(如垃圾对象之间的引用或者循环引用)。

        -

        79450295.png

        +

        png

        接下来的行为,和其他垃圾收集器一样:多个 GC 线程并行地找出哪些是存活对象,确定哪些是垃圾:

        -

        79469787.png

        +

        png

        最后,存活对象被转移到存活区(survivor regions),在必要时会创建新的小堆块。现在,空的小堆块被释放,可用于存放新的对象了。

        -

        79615062.png

        +

        png

        GC 选择的经验总结

        -

        72433648.png

        +

        png

        通过本节内容的学习,你应该对 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 算法比起来相比,改进非常明显。

        请看下图:

        -

        9324cf7d-ab45-4620-9661-2035e3f1b3d2.png

        +

        png

        左边的图是线性坐标,右边是指数坐标。

        可以看到,不管是平均值、95 线、99 线还是最大暂停时间,ZGC 都优胜于 G1 和并行 GC 算法。

        根据我们在生产环境的监控数据来看(16G~64G 堆内存),每次暂停都不超过 3ms。

        比如下图是一个低延迟网关系统的监控信息,几十 GB 的堆内存环境中,ZGC 表现得毫无压力,暂停时间非常稳定。

        -

        68469069.png

        +

        png

        像 G1 和 ZGC 之类的现代 GC 算法,只要空闲的堆内存足够多,基本上不触发 FullGC。

        所以很多时候,只要条件允许,加内存才是最有效的解决办法。

        既然低延迟是 ZGC 的核心看点,而 JVM 低延迟的关键是 GC 暂停时间,那么我们来看看有哪些方法可以减少 GC 暂停时间:

        @@ -284,7 +284,7 @@ Option -XX:+UseZGC not supported

        ZGC 的原理

        ZCG 的 GC 周期如图所示:

        -

        37037772.png

        +

        png

        每个 GC 周期分为 6 个小阶段:

        1. 暂停—标记开始阶段:第一次暂停,标记根对象集合指向的对象;
        2. @@ -305,11 +305,11 @@ Option -XX:+UseZGC not supported

          ZGC 使用着色指针来标记所处的 GC 阶段。

          着色指针是从 64 位的指针中,挪用了几位出来标识表示 Marked0、Marked1、Remapped、Finalizable。所以不支持 32 位系统,也不支持指针压缩技术,堆内存的上限是 4TB。

          从这些标记上就可以知道对象目前的状态,判断是不是可以执行清理压缩之类的操作。

          -

          0.21570593705169117.png

          +

          png

          读屏障

          对于 GC 线程与用户线程并发执行时,业务线程修改对象的操作可能带来的不一致问题,ZGC 使用的是读屏障,这点与其他 GC 使用写屏障不同。

          有读屏障在,就可以留待之后的其他阶段,根据指针颜色快速的处理。并且不是所有的读操作都需要屏障,例如下面只有第一种语句(加载指针时)需要读屏障,后面三种都不需要,又或者是操作原生类型的时候也不需要。

          -

          73cd6f97-8730-4aff-ae48-b8a30f3eaae0.jpg

          +

          png

          著名的 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 可用的参数见下表:

          -

          62790527.png

          +

          png

          一些常用的参数介绍:

          • -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 非常类似。

            -

            28583d44-89ad-4196-b96c-dd747dc43c42.png

            +

            png

            部分日志内容如下:

            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 工作负载对比:

            -

            b319f998-e955-4a1e-8091-9371866633e1.jpg

            +

            png

            可以看到,相对于 CMS、G1、Parallel GC,Shenandoah 在系统负载增加的情况下,延迟时间稳定在非常低的水平,而其他几种 GC 都会迅速上升。

            常用参数介绍

            推荐几个配置或调试 Shenandoah 的 JVM 参数:

            @@ -437,7 +437,7 @@ GC(3) Concurrent cleanup 76244M->56620M(102400M) 12.242ms

            同时针对于内存分配失败时的策略,可以通过调节 ShenandoahPacingShenandoahDegeneratedGC 参数,对线程进行一定的调节控制。如果还是没有足够的内存,最坏的情况下可能会产生 Full GC,以使得系统有足够的内存不至于发生 OOM。

            更多有关如何配置、调试 Shenandoah 的参数信息,请参阅 Shenandoah 官方 Wiki 页面。

            各版本 JDK 对 Shenandoah 的集成情况

            -

            9b62765c-d0ac-436e-999c-53d964e1ca33.png

            +

            png

            这张图展示了 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 system diagram

              +

              png

              GraalVM 支持大量的语言,包括:

              • 基于 JVM 的语言(例如 Java、Scala、Groovy、Kotlin、Clojure 等);
              • @@ -230,9 +230,9 @@ function hide_canvas() {
              • 占用内存更低

              启动时间对比:

              -

              microservices

              +

              png

              占用内存对比:

              -

              microservices

              +

              png

              解决了哪些痛点

              GraalVM 提供了一个全面的生态系统,消除编程语言之间的隔离,打通了不同语言之间的鸿沟,在共享的运行时中实现了互操作性,让我们可以进行混合式多语言编程。

              用 Graal 执行的语言可以互相调用,允许使用来自其他语言的库,提供了语言的互操作性。同时结合了对编译器技术的最新研究,在高负载场景下 GraalVM 的性能比传统 JVM 要好得多。

              @@ -306,7 +306,7 @@ function hide_canvas() {

            GitHub 下载页面 中找到下载链接。

            如下图所示:

            -

            70802368.png

            +

            png

            这里区分操作系统(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 事件的示意图如下所示:

            -

            57974076.png

            +

            png

            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 事件的内存变化情况,可以表示为下面的示意图:

            -

            839273.png

            +

            png

            年轻代看起来数据几乎没变化,怎么办?因为上下文其实还有其他的 GC 日志记录,我们照着这个格式去解读即可。

            Parallel GC 日志解读

            并行垃圾收集器对年轻代使用“标记—复制(mark-copy)”算法,对老年代使用“标记—清除—整理(mark-sweep-compact)”算法。

            @@ -383,7 +383,7 @@ demo.jvm0204.GCLogAnalysis

            年轻代 GC,我们可以关注暂停时间,以及 GC 后的内存使用率是否正常,但不用特别关注 GC 前的使用量,而且只要业务在运行,年轻代的对象分配就少不了,回收量也就不会少。

            此次 GC 的内存变化示意图为:

            -

            8353526.png

            +

            png

            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 的内存变化示意图为:

            -

            85130696.png

            +

            png

            细心的同学可能会发现,此次 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 的内存变化示意图为:

            -

            4438116.png

            +

            png

            哇塞,这个数字不得了,老年代使用量 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 前后的内存使用情况示意图:

            -

            3110993.png

            +

            png

            总之,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 对应的示意图如下所示:

        -

        58726143.png

        +

        png

        Concurrent Marking(并发标记)

        当堆内存的总体使用比例达到一定数值时,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。

        阶段 1:Initial Mark(初始标记)

        @@ -410,7 +410,7 @@ Heap

        标记周期一般只在碰到 region 中一个存活对象都没有的时候,才会顺手处理一把,大多数情况下都不释放内存。

        示意图如下所示:

        -

        52452256.png

        +

        png

        Evacuation Pause(mixed)(转移暂停:混合模式)

        并发标记完成之后,G1 将执行一次混合收集(mixed collection),不只清理年轻代,还将一部分老年代区域也加入到 collection set 中。

        混合模式的转移暂停(Evacuation Pause)不一定紧跟并发标记阶段。

        @@ -462,9 +462,9 @@ Heap

        因为我们的堆内存空间很小,存活对象的数量也不多,所以这里看到的 Full GC 暂停时间很短。

        此次 Full GC 的示意图如下所示:

        -

        59111406.png

        +

        png

        在堆内存较大的情况下(8G+),如果 G1 发生了 Full GC,暂停时间可能会退化,达到几十秒甚至更多。如下面这张图片所示:

        -

        5b03ee3d-1e0a-4375-a5f6-aab17f4d1184.jpg

        +

        png

        从其中的 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 线程模型的理解,我们制作了下面这下示意图:

        -

        62445939.png

        +

        png

        从图中可以看到,调用 Thread 对象的 start() 方法后,JVM 会在内部执行一系列的操作。

        因为 Hotspot JVM 是使用 C++ 语言编写的,所以在 JVM 层面会有很多和线程相关的 C++ 对象。

          @@ -635,9 +635,9 @@ Found 1 deadlock.

          可以看到,这些工具会自动发现死锁,并将相关线程的调用栈打印出来。

          使用可视化工具发现死锁

          当然我们也可以使用前面介绍过的可视化工具 jconsole,示例如下:

          -

          79277126.png

          +

          png

          也可以使用 JVisualVM:

          -

          79394987.png

          +

          png

          各种工具导出的线程转储内容都差不多,参考前面的内容。

          有没有自动分析线程的工具呢?请参考后面的章节《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 怎么表示一个对象:

          -

          742441.png

          +

          png

          说明

          • alignment(外部对齐):比如 8 字节的数据类型 long,在内存中的起始地址必须是 8 字节的整数倍。
          • @@ -484,16 +484,16 @@ public class ClearRequestCacheFilter implements Filter{

            双击打开 MemoryAnalyzer.exe,打开 MAT 分析工具,选择菜单 File –> Open File… 选择对应的 dump 文件。

            选择 Leak Suspects Report 并确定,分析内存泄露方面的报告。

            -

            bd3d81d4-d928-4081-a2f7-96c11de76178.png

            +

            png

            3. 内存报告

            然后等待,分析完成后,汇总信息如下:

            -

            07acbdb7-0c09-40a5-b2c3-e7621a36870f.png

            +

            png

            分析报告显示,占用内存最大的问题根源 1:

            -

            345818b9-9323-4025-b23a-8f279a99eb84.png

            +

            png

            占用内存最大的问题根源 2:

            -

            07bbe993-5139-416a-9e6d-980131b649bf.png

            +

            png

            占用内存最大的问题根源 3:

            -

            7308f1b5-35aa-43e0-bbb4-05cb2e3131be.png

            +

            png

            可以看到,总的内存占用才 2GB 左右。问题根源 1 和根源 2,每个占用 800MB,问题很可能就在他们身上。

            当然,根源 3 也有一定的参考价值,表明这时候有很多 JDBC 操作。

            查看问题根源 1,其说明信息如下:

            @@ -532,12 +532,12 @@ http-nio-8086-exec-8

            当然,还可以分析这个根源下持有的各个类的对象数量。

            点击根源 1 说明信息下面的 Details » 链接,进入详情页面。

            查看其中的 “Accumulated Objects in Dominator Tree”:

            -

            b5ff6319-a5d9-426f-99ef-19bd100fd80a.png

            +

            png

            可以看到占用内存最多的是 2 个 ArrayList 对象。

            鼠标左键点击第一个 ArrayList 对象,在弹出的菜单中选择 Show objects by class –> by outgoing references。

            -

            6dbbb72d-ec2b-485f-bc8e-9de044b21b7d.png

            +

            png

            打开 class_references 标签页:

            -

            28fe37ed-36df-482a-bc58-231c9552638d.png

            +

            png

            展开后发现 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)等等,如下图所示:

            -

            2459010.png

            +

            png

            最常见的 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 也没啥效果。此时系统就像是到了癌症晚期,身体的营养都被癌细胞占据了,真正用于身体使用的非常少了,而且就算是调用所有营养去杀灭癌细胞也晚了,因为杀的效果很差了,还远远没有癌细胞复制的速度快。

            -

            06cff4f1-b6a6-4cda-b7f5-8e8837f55c3a.png

            +

            png

            注意,“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 中的线程也需要内存空间来执行自己的任务。如果线程数量太多,就会引入新的问题:

            -

            7ff0ad95-3c2e-4246-badc-e007abf84978.png

            +

            png

            “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 使用的内存总量超过可用的物理内存,操作系统就会用到虚拟内存(一般基于磁盘文件)。

            -

            40dddc4d-f192-40dc-9a1f-ab4df65d6f12.png

            +

            png

            错误信息“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 亿?)

            -

            8cc643df-164f-4ee9-9142-7a27032418ec.png

            +

            png

            如果程序抛出“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 代理,而是系统内核内置的一种安全保护措施。

            -

            13abc504-c840-4264-a905-e9011159484f.png

            +

            png

            如果可用内存(含 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 方式上传文本字符串,然后点击分析按钮。

            分析结果页面

            等待片刻,自动跳转到分析结果页面。

            -

            6843295.png

            +

            png

            这里可以看到基本信息,以及右边的一些链接:

            • 分享报告,可以很方便地把报告结果发送给其他小伙伴。

            线程数汇总

            把页面往下拉,可以看到线程数量汇总报告。

            -

            6864312.png

            +

            png

            从这个报告中可以很直观地看到,线程总数为 26,其中 19 个运行状态线程,5 个等待状态的线程,2 个阻塞状态线程。

            右边还给了一个饼图,展示各种状态所占的比例。

            线程组分析

            接着是将线程按照名称自动分组。

            -

            6898070.png

            +

            png

            这里就看到线程命名的好处了吧!如果我们的线程池统一命名,那么相关资源池的使用情况就很直观。

            所以在代码里使用线程池的时候,统一添加线程名称就是一个好的习惯!

            守护线程分析

            接下来是守护线程分析:

            -

            6923926.png

            +

            png

            这里可以看到守护线程与前台线程的统计信息。

            死锁情况检测

            当然,也少不了死锁分析:

            -

            6948610.png

            +

            png

            可以看到,各个工具得出的死锁检测结果都差不多。并不难分析,其中给出了线程名称,以及方法调用栈信息,等待的是哪个锁。

            线程调用栈情况

            以及线程调用情况:

            -

            7008839.png

            +

            png

            后面是这些线程的详情:

            -

            7058206.png

            +

            png

            这块信息只是将相关的方法调用栈展示出来。

            热点方法统计

            热点方法是一个需要注意的重点,调用的越多,说明这一块可能是系统的性能瓶颈。

            -

            7104053.png

            +

            png

            这里展示了此次快照中正在执行的方法。如果只看热点方法抽样的话,更精确的工具是 JDK 内置的 hprof。

            但如果有很多方法阻塞或等待,则线程快照中展示的热点方法位置可以快速确定问题出现的代码行。

            CPU 消耗信息

            @@ -426,18 +426,18 @@ Found 1 deadlock.

            这里看到 GC 线程数是 8 个,这个值跟具体的 CPU 内核数量相差不大就算是正常的。

            GC 线程数如果太多或者太少,会造成很多问题,我们在后面的章节中通过案例进行讲解。

            线程栈深度

            -

            7277060.png

            +

            png

            这里都小于10,说明堆栈都不深。

            复杂死锁检测

            接下来是复杂死锁检测和 Finalizer 线程的信息。

            -

            7295147.png

            +

            png

            简单死锁是指两个线程之间互相死等资源锁。那么什么复杂死锁呢? 这个问题留给同学们自己搜索。

            火焰图

            -

            7336167.png

            +

            png

            火焰图挺有趣,将所有线程调用栈汇总到一张图片中。

            调用栈树

            如果我们把所有的调用栈合并到一起,整体来看呢?

            -

            7358293.png

            +

            png

            树形结构在有些时候也很有用,比如大量线程都在执行类似的调用栈路径时。

            以上这些信息,都有助于我们去分析和排查 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

          下载完成后解压即可使用:

          -

          1613271.png

          +

          png

          可以看到,bin 目录下是可执行文件,samples 目录下是脚本示例。

          示例程序

          我们先编写一个有入参有返回值的方法,示例如下:

          @@ -325,25 +325,25 @@ public class RandomSample {

          细心的同学可能已经发现,在安装 JVisualVM 的插件时,有一款插件叫做“BTrace Workbench”。安装这款插件之后,在对应的 JVM 实例上点右键,就可以进入 BTrace 的操作界面。

          1. BTrace 插件安装

          打开 VisualVM,选择菜单“工具–插件(G)”:

          -

          82699966.png

          +

          png

          然后在插件安装界面中,找到“可用插件”:

          -

          82770532.png

          +

          png

          勾选“BTrace Workbench”之后,点击“安装(I)”按钮。

          如果插件不显示,请更新 JDK 到最新版。

          -

          82937996.png

          +

          png

          按照引导和提示,继续安装即可。

          -

          82991766.png

          +

          png

          接受协议,并点击安装。

          -

          83219940.png

          +

          png

          等待安装完成:

          -

          83257210.png

          +

          png

          点击“完成”按钮即可。

          BTrace 插件使用

          -

          85267702.png

          +

          png

          打开后默认的界面如下:

          -

          85419826.png

          +

          png

          可以看到这是一个 Java 文件的样子。然后我们参考官方文档,加一些脚本进去。

          BTrace 脚本示例

          我们下载的 BTrace 项目中,samples 目录下有一些脚本示例。 参照这些示例,编写一个简单的 BTrace 脚本:

          @@ -380,7 +380,7 @@ public class TracingScript {

          执行结果

          可以看到,输出了简单的执行结果:

          -

          6182718.png

          +

          png

          可以和示例程序的控制台输出比对一下。

          更多示例

          BTrace 提供了很多示例,照着改一改就能执行简单的监控。

          @@ -443,9 +443,9 @@ java -jar arthas-boot.jar

          使用示例

          启动之后显示的信息大致如下图所示:

          -

          8128798.png

          +

          png

          然后我们输入需要连接(Attach)的 JVM 进程,例如 1,然后回车。

          -

          8296362.png

          +

          png

          如果需要退出,输入 exit 即可。

          接着我们输入 help 命令查看帮助,返回的信息大致如下。

          [[email protected]]$ help
          @@ -492,11 +492,11 @@ java -jar arthas-boot.jar
           
          help thread
           

          如果查看 JVM 信息,输入命令 jvm 即可。

          -

          8871310.png

          +

          png

          环境变量 sysenv:

          -

          9257854.png

          +

          png

          查看线程信息,输入命令 thread:

          -

          8831103.png

          +

          png

          查看某个线程的信息:

          [[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:

          -

          9104344.png

          +

          png

          某些选项可以设置,这里给出了示例 vmoption PrintGCDetails true

          查找类 sc:

          -

          9474443.png

          +

          png

          反编译代码 jad:

          -

          9593919.png

          +

          png

          堆内存转储 heapdump:

          -

          9834789.png

          +

          png

          跟踪方法执行时间 trace:

          -

          9997095.png

          +

          png

          观察方法执行 watch:

          -

          10270279.png

          +

          png

          可以看到,支持条件表达式,类似于代码调试中的条件断点。 功能非常强大,并且作为一个 JVM 分析的集成环境,使用起来也比一般工具方便。更多功能请参考 Arthas 用户文档

          抽样分析器(Profilers)

          下面介绍分析器(profilers,Oracle 官方翻译是“抽样器”)。

          @@ -570,11 +570,11 @@ java -jar arthas-boot.jar
        • 让程序运行一段时间,以收集关于对象分配的足够信息。
        • 单击下方的“Snapshot”(快照)按钮,可以获取收集到的快照信息。
    -

    85731ea7-1c8c-4f9a-8869-bd25f61e3300.png

    +

    png

    完成上面的步骤后,可以得到类似这样的信息:

    -

    f6c5d90d-104f-474e-b9fe-fa4d351871da.png

    +

    png

    上图按照每个类被创建的对象数量多少来排序。看第一行可以知道,创建的最多的对象是 int[] 数组。鼠标右键单击这行,就可以看到这些对象都在哪些地方创建的:

    -

    e9583002-b04a-4a2c-8e94-3c9e0e57557a.png

    +

    png

    与 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() {
  • 以猜测来驱动,凭历史经验进行排查。
  • 如果您倾向于选择后一种方式,那么可能会浪费大量的时间,效果得看运气。更糟糕的是,因为基本靠蒙,所以这个过程是完全不可预测的,如果时间很紧张,就会在团队内部造成压力,甚至升级为甩锅和互相指责。

    -

    66772038.png

    +

    png

    系统出现性能问题或者故障,究竟是不是 JVM 的问题,得从各个层面依次进行排查。

    为什么问题排查这么困难?

    生产环境中进行故障排查的困难

    @@ -305,7 +305,7 @@ function hide_canvas() {

    做好监控,定位问题,验证结果,总结归纳。

    下面我们看看 JVM 领域有哪些问题.

    -

    47867364.png

    +

    png

    从上图可以看到,JVM 可以划分为这些部分:

    前面我们谈到的高可用设计,都只是机房内的容灾。也就是说,我们的主服务器和从服务器都在一个机房内,现在我们来看一下同城和跨城的容灾设计(我提醒一下,不论是机房内容灾、同城容灾,还是跨城容灾,都是基于 MySQL 的无损半同步复制,只是物理部署方式不同,解决不同的问题)。

    对于同城容灾,我看到很多这样的设计:

    -

    3.png

    +

    png

    这种设计没有考虑到机房网络的抖动。如果机房 1 和机房 2 之间的网络发生抖动,那么因为事务提交需要机房 2 中的从服务器接收日志,所以会出现事务提交被 hang 住的问题。

    而机房网络抖动非常常见,所以核心业务同城容灾务要采用三园区的架构,如下图所示:

    -

    4.png

    +

    png

    该架构称为“三园区的架构”,如果三个机房都在一个城市,则称为“ 一地三中心”,如果在相邻两个城市,那么就叫“两地三中心”。但这种同城/近城容灾,要求机房网络之间的延迟不超过 5ms。

    在三园区架构中,一份数据被存放在了 3 个机房,机房之间根据半同步复制。这里将 MySQL 的半同步复制参数 rpl_semi_sync_master_wait_for_slave_count 设置为 1,表示只要有 1 个半同步备机接收到日志,主服务器上的事务就可以提交。

    这样的设计,保证除主机房外,数据在其他机房至少一份完整的数据。

    另外,即便机房 1 与机房 2 发生网络抖动,因为机房 1 与机房 3 之间的网络很好,不会影响事务在主服务器上的提交。如果机房 1 的出口交换机或光纤发生故障,那么这时高可用套件会 FAILOVER 到机房 2 或机房 3,因为至少有一份数据是完整的。

    机房 2、机房 3 的数据用于保障数据一致性,但是如果要实现读写分离,或备份,还需要引入异步复制的备机节点。所以整体架构调整为:

    -

    5.png

    +

    png

    从图中可以看到,我们加入两个异步复制的节点,用于业务实现读写分离,另外再从机房 3 的备机中,引入一个异步复制的延迟备机,用于做数据误删除操作的恢复。

    当设计成类似上述的架构时,你才能认为自己的同城容灾架构是合格的!

    另一个重要的点:因为机房 1 中的主服务器要向四个从服务器发送日志,这时网卡有成为瓶颈的可能,所以请务必配置万兆网卡。

    在明白三园区架构后,要实现跨城容灾也就非常简单了, 只要把三个机房放在不同城市就行。但这样的设计,当主服务器发生宕机时,数据库就会切到跨城,而跨城之间的网络延迟超过了25 ms。所以,跨城容灾一般设计成“三地五中心”的架构,如下图所示:

    -

    6.png

    +

    png

    在上图中:机房 1、机房 2 在城市 1 中;机房 3、机房 4 在城市 2 中;机房 5 在城市 3 中,三个城市之间的距离超过 200 公里,延迟超过 25ms。

    由于有五个机房,所以 ACK 设置为 2,保证至少一份数据在两个机房有数据。这样当发生城市级故障,则城市 2 或城市 3 中,至少有一份完整的数据。

    在真实的互联网业务场景中,“三地五中心”应用并不像“三园区”那样普遍。这是因为 25ms的延迟对业务的影响非常大,一般这种架构应用于读多写少的场景,比如用户中心。

    @@ -237,7 +237,7 @@ function hide_canvas() {

    业务逻辑核对由业务的同学负责编写, 从整个业务逻辑调度看账平不平。例如“今天库存的消耗”是否等于“订单明细表中的总和”,“在途快递” + “已收快递”是否等于“已下快递总和”。总之,这是个业务逻辑,用于对账。

    主从服务器之间的核对,是由数据库团队负责的。 需要额外写一个主从核对服务,用于保障主从数据的一致性。这个核对不依赖复制本身,也是一种逻辑核对。思路是:将最近一段时间内主服务器上变更过的记录与从服务器核对,从逻辑上验证是否一致。其实现如图所示:

    -

    7.png

    +

    png

    那么现在的难题是:如何判断最近一段时间内主服务器上变更过的记录?这里有两种思路:

    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 实现类有:

    -

    Drawing 3.png

    +

    png

    EventLoopGroup 本质是一个线程池,主要负责接收 I/O 请求,并分配线程执行处理请求。在下图中,我为你讲述了 EventLoopGroups、EventLoop 与 Channel 的关系。

    -

    Drawing 4.png

    +

    png

    从上图中,我们可以总结出 EventLoopGroup、EventLoop、Channel 的几点关系。

    1. 一个 EventLoopGroup 往往包含一个或者多个 EventLoop。EventLoop 用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件。
    2. @@ -312,7 +312,7 @@ function hide_canvas() {
    3. 每新建一个 Channel,EventLoopGroup 会选择一个 EventLoop 与其绑定。该 Channel 在生命周期内都可以对 EventLoop 进行多次绑定和解绑。

    下图是 EventLoopGroup 的家族图谱。可以看出 Netty 提供了 EventLoopGroup 的多种实现,而且 EventLoop 则是 EventLoopGroup 的子接口,所以也可以把 EventLoop 理解为 EventLoopGroup,但是它只包含一个 EventLoop 。

    -

    Drawing 5.png

    +

    png

    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 的结构图:

      -

      Drawing 6.png

      +

      png

      从上图可以看出,ChannelPipeline 中包含入站 ChannelInboundHandler 和出站 ChannelOutboundHandler 两种处理器,我们结合客户端和服务端的数据收发流程来理解 Netty 的这两个概念。

      -

      Drawing 7.png

      +

      png

      客户端和服务端都有各自的 ChannelPipeline。以客户端为例,数据从客户端发向服务端,该过程称为出站,反之则称为入站。数据入站会由一系列 InBoundHandler 处理,然后再以相反方向的 OutBoundHandler 处理后完成出站。我们经常使用的编码 Encoder 是出站操作,解码 Decoder 是入站操作。服务端接收到客户端数据后,需要先经过 Decoder 入站处理后,再通过 Encoder 出站通知客户端。所以客户端和服务端一次完整的请求应答过程可以分为三个步骤:客户端出站(请求数据)、服务端入站(解析数据并执行业务逻辑)、服务端出站(响应结果)。

      在介绍 ChannelPipeline 的过程中,想必你已经对 ChannelHandler 有了基本的概念,数据的编解码工作以及其他转换工作实际都是通过 ChannelHandler 处理的。站在开发者的角度,最需要关注的就是 ChannelHandler,我们很少会直接操作 Channel,都是通过 ChannelHandler 间接完成。

      下图描述了 Channel 与 ChannelPipeline 的关系,从图中可以看出,每创建一个 Channel 都会绑定一个新的 ChannelPipeline,ChannelPipeline 中每加入一个 ChannelHandler 都会绑定一个 ChannelHandlerContext。由此可见,ChannelPipeline、ChannelHandlerContext、ChannelHandler 三个组件的关系是密切相关的,那么你一定会有疑问,每个 ChannelHandler 绑定ChannelHandlerContext 的作用是什么呢?

      -

      Drawing 8.png

      +

      png

      ChannelHandlerContext 用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。ChannelHandlerContext 可以实现 ChannelHandler 之间的交互,ChannelHandlerContext 包含了 ChannelHandler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。此外,你可以试想这样一个场景,如果每个 ChannelHandler 都有一些通用的逻辑需要实现,没有 ChannelHandlerContext 这层模型抽象,你是不是需要写很多相同的代码呢?

      以上便是 Netty 的逻辑处理架构,可以看出 Netty 的架构分层设计得非常合理,屏蔽了底层 NIO 以及框架层的实现细节,对于业务开发者来说,只需要关注业务逻辑的编排和实现即可。

      组件关系梳理

      当你了解每个 Netty 核心组件的概念后。你会好奇这些组件之间如何协作?结合客户端和服务端的交互流程,我画了一张图,为你完整地梳理一遍 Netty 内部逻辑的流转。

      -

      Drawing 9.png

      +

      png

      PoolChunk 中 Page 级别的内存分配

      每个 PoolChunk 默认大小为 16M,PoolChunk 是通过伙伴算法管理多个 Page,每个 PoolChunk 被划分为 2048 个 Page,最终通过一颗满二叉树实现,我们再一起回顾下 PoolChunk 的二叉树结构,如下图所示。

      -

      Drawing 1.png

      +

      png

      假如用户需要依次申请 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] 中的二进制位如下图所示:

      -

      Drawing 5.png

      +

      png

      当 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 的内部结构,如下图所示:

      -

      333.png

      +

      png

      通过 Recycler 的 UML 图可以看出,一共包含四个核心组件:StackWeakOrderQueueLinkDefaultHandle,接下来我们逐一进行介绍。

      首先我们先看下整个 Recycler 的内部结构中各个组件的关系,可以通过下面这幅图进行描述。

      -

      111.png

      +

      png

      第一个核心组件是 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,重复执行以上过程直至回到到对象实例为止。具体的流程可以结合下图来理解。

      -

      222.png +

      png 此外,每次移动 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 相关的引用,如下图所示。

      -

      444.png

      +

      png

      其中比较常用的有 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 时钟周期和内存带宽。

      我们模拟一个场景,从文件中读取数据,然后将数据传输到网络上,那么传统的数据拷贝过程会分为哪几个阶段呢?具体如下图所示。

      -

      Drawing 0.png

      +

      png

      从上图中可以看出,从数据读取到发送一共经历了四次数据拷贝,具体流程如下:

      1. 当用户进程发起 read() 调用后,上下文从用户态切换至内核态。DMA 引擎从文件中读取数据,并存储到内核态缓冲区,这里是第一次数据拷贝
      2. @@ -250,10 +250,10 @@ function hide_canvas() { }

        在使用了 FileChannel#transferTo() 传输数据之后,我们看下数据拷贝流程发生了哪些变化,如下图所示:

        -

        Drawing 1.png

        +

        png

        比较大的一个变化是,DMA 引擎从文件中读取数据拷贝到内核态缓冲区之后,由操作系统直接拷贝到 Socket 缓冲区,不再拷贝到用户态缓冲区,所以数据拷贝的次数从之前的 4 次减少到 3 次。

        但是上述的优化离达到零拷贝的要求还是有差距的,能否继续减少内核中的数据拷贝次数呢?在 Linux 2.4 版本之后,开发者对 Socket Buffer 追加一些 Descriptor 信息来进一步减少内核数据的复制。如下图所示,DMA 引擎读取文件内容并拷贝到内核缓冲区,然后并没有再拷贝到 Socket 缓冲区,只是将数据的长度以及位置信息被追加到 Socket 缓冲区,然后 DMA 引擎根据这些描述信息,直接从内核缓冲区读取数据并传输到协议引擎中,从而消除最后一次 CPU 拷贝。

        -

        Drawing 2.png

        +

        png

        通过上述 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 的内部结构:

        -

        Drawing 3.png

        +

        png

        从图上可以看出,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 的布局情况,如下所示:

        -

        Drawing 4.png

        +

        png

        从图中可以看出,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 的属性又会发生什么变化呢?如下图所示。

        -

        Drawing 5.png

        +

        png

        至此,CompositeByteBuf 的基本原理我们已经介绍完了,关于具体 CompositeByteBuf 数据操作的细节在这里就不做展开了,有兴趣的同学可以自己深入研究 CompositeByteBuf 的源码。

        Unpooled.wrappedBuffer 操作

        介绍完 CompositeByteBuf 之后,再来理解 Unpooled.wrappedBuffer 操作就非常容易了,Unpooled.wrappedBuffer 同时也是创建 CompositeByteBuf 对象的另一种推荐做法。

        Unpooled 提供了一系列用于包装数据源的 wrappedBuffer 方法,如下所示:

        -

        Drawing 6.png

        +

        png

        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() 方法是一个无限循环,没有任何退出条件,在不间断循环执行以下三件事情,可以用下面这张图形象地表示。

        -

        Lark20201216-164824.png

        +

        png

        只有组织中的工作流程、引导群体行为的激励全部基于 OKR 展开和制定,且能刚性执行,OKR 文化才能立起来。

        有了基于 OKR 的流程和激励机制的升级保障,就让 OKR 文化具备了沙因文化中的人工饰物层次。这些人工饰物体现在,当你初入一个群体,会看到落在纸面上的关于 OKR 的流程和激励制度,也会看到人们进行 OKR 制定-过程检视&调整-OKR 闭环管理的各种行为,还会听到含有 OKR 的言语(如下图)等

        -

        Drawing 3.png

        +

        png

        (团队按照 OKR 的流程跑起来,就会带来各种制定和讨论 OKR 的行为和言语,外人从这些表面上看到的听到的都是 OKR 文化的人工饰物层)

        同时,流程和激励机制会和管理实践交融在一起发挥作用,比如在 OKR 流程的目标设定环节,我们就会采用小目标、优先级的管理实践,在 OKR 的过程检视&调整时采用每日站会结合物理看板的管理实践;基于 OKR 的激励,就会用到通晒、评分、目标合二为一的管理实践。这样,就会让 OKR 文化的人工饰物背后,都能找到与之匹配的 OKR 外显价值观,彼此互相支撑

        然而我常常看到,很多推行 OKR 的组织,仅仅在团队层面基于 OKR 来进行工作的展开,管理者和高层从来不用,组织中从高层开始就无视规则、挑战规则、不遵守规则,团队中的规则执行也就可想而知,这也是国内很多组织 OKR 落地生根不了的重要原因之一。

        @@ -209,11 +209,11 @@ function hide_canvas() {

        中国文化,源远流长,虽然各个朝代已经跟我们不再是一个时空,但我们依旧可以通过书籍、流传的故事、文物等来了解相应年代的文化特征,而这些就是文化沉淀下来的载体。

        沙因文化的三个层次,都可以通过载体来呈现。比如,可以用书籍、文章等来呈现影响人群思维定式的基本假设和外显的价值观,可以用装饰、工具或产品来表达文化的人工饰物。

        所以,要沉淀 OKR 文化,也需要这些实实在在、能让人“摸得着”的 OKR 文化载体(如下)。

        -

        Lark20201127-174124.png

        +

        png

        OKR 文化有了这些载体,就具备了传播和延续能力,可以持续不断地通过这些具象的文化沉淀去影响更多群体产生应用 OKR 的行为,直至成为一种组织习惯。

        而新的组织习惯并非一朝一夕就能练就,还需要通过文化监控的手段来持续塑造 OKR 文化,也就是调思维、做管理、定规则这三步 OKR 文化塑造的方法需要长久地推动下去,对应用 OKR 的群体行为加以保持和巩固,才能以防 OKR 文化走样变形。

        我在京东内部,是通过每个季度做评估的方式,进行 OKR 文化的监控和测量。在这里,我把 OKR 文化评估设计的一些问题分享给你,你在后续进行 OKR 文化建设时可以参考使用。

        -

        Drawing 4.png

        +

        png

        (评分说明: 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 转型的顶层设计

        -

        Drawing 0.png

        +

        png

        首先,我们把一个组织抽象成一个系统,在这个系统中,还包括了人以及人与人之间的交互

        那么,当一个变化,如 OKR 转型,进入到这个组织系统,就会对这三个部分带来调整,同时也会面临这三个部分的落地挑战,这些难点在于:

        OKR 实现过程管理能力水平,就是 OKR 的文化监控做法《具体参考 11 课时 OKR 文化建设里所讲的如何沉淀 OKR 文化》,这里着重介绍下 OKR 覆盖度和 OKR NPS。

        设立 OKR 覆盖度指标的目的,是让 OKR 流程 100% 覆盖所有业务条线,让所有业务条线的工作目标的制定和过程管理都基于 OKR 来展开;此外,覆盖还包括每人都能用 OKR 来制定绩效目标,也就是让每个人基于 O 和 KR 的写法方法论来制定自己的OKR。

        -

        Drawing 2.png

        +

        png

        (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 变革的试点,又该如何选择呢?

        -

        Drawing 3.png

        +

        png

        (罗杰斯创新扩模型)

        上图的创新扩散模型,由美国埃弗雷特·罗杰斯(E.M.Rogers)提出。从该模型中,我们可以学习到,当一个变化引入一个群体后,群体中的个体拥抱变化的态度上会有明显差别,一般可分为创新者、初期采用者、早期大众、后期大众和落后者五类人。

        由于群体中这五类人的存在,对于一个变化,进入一个群体后的扩散速度,就会有先后、快慢之分。 所以,为了降低变革阻力,我们不能上来就找落后者或后期大众来推动 OKR,那将会困难重重,在成本和效率上就会让变革失败。而是应该重点找到并激活整个组织中的创新者和初期采用者,通过他们作为 OKR 变革试点,然后总结试点的成功经验,联合团结他们把这些成功经验推广至其他群体,以此来更快地进行全组织有效的 OKR 变革扩张。

        -

        Drawing 4.png

        +

        png

        (个人培训,把京东 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 制定模板

        -

        Drawing 0.png

        +

        png

        使用场景:按照组织中绩效制定节奏,在开始制定或是过程中需要新增 OKR 时使用该模板(这里所提供的模板均是基于季度的 OKR 制定节奏来呈现的,下同)。

        操作流程:先确定方向 O,再生成每个 O 的 KR,所有 OKR 都需要上下级共识([可参考 08 课时 OKR 制定流程],我列举了详细案例说明)。

        注意点

        @@ -153,7 +153,7 @@ function hide_canvas() {
      3. 每个 KR 需要投入的资源和精力不同,所有需要进行比较,设置权重。
      4. 模板 2:OKR 物理看板原型

        -

        Drawing 1.png

        +

        png

        使用场景:每天站会时使用该 OKR 物理看板。

        操作流程:首先在团队工作现场搭建该物理看板,然后在团队内制定使用该看板的规则,也就是每日固定时间,来看板前基于每日站会三问过每个工作任务的进展。

        注意点

        @@ -165,7 +165,7 @@ function hide_canvas() {
      5. 看板上的工作任务支撑 KR 的完成,都是从 KR 中拆分出来的。
      6. 模板 3:OKR 工作周报模板

        -

        Drawing 2.png

        +

        png

        使用场景:每周写周报时。

        操作流程:O 和 KR 都是制定时生成的,可以直接复制到周报里,但需要更新每个 KR 的进展和信心指数。遇到的问题、阻碍越多,信心指数就标注越低,对应着 KR 下方的问题&风险就要重点描述,反之信心指数越高,说明完成该 KR 没有阻碍,问题&风险可以写无。除此之外,周报中还需要体现完成每个 KR 所做的日常工作,包括本周所做工作(较详细)以及下周工作计划(简写)。

        注意点

        @@ -177,7 +177,7 @@ function hide_canvas() {
      7. 周报要发给与完成 OKR 都有关联的相关方。
      8. 模板 4:OKR 季中盘点模板

        -

        Drawing 3.png

        +

        png

        使用场景:过程中对 OKR 完成情况盘点时使用该模板,盘点节奏可以每月或者在一个季度的季中来进行。

        操作流程:盘点时,个人要更新完 OKR 的整体进度,然后主动约上级时间进行 OKR 盘点,或者上级主动发起,来组织整个团队的过程盘点。

        注意点

        @@ -189,7 +189,7 @@ function hide_canvas() {
      9. 下属要主动叙述绩效完成情况,坦诚交流问题,并努力获得上级支持。
      10. 模板 5:OKR 季末闭环评分模板

        -

        Drawing 4.png

        +

        png

        使用场景:组织中绩效闭环管理时。

        操作流程:一般由 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 连接的方式将内容发送给服务器端,服务器端在接收到相关内容之后,先将内容转化为具体的执行命令,再判断用户授权信息和其他相关信息,当验证通过之后会执行最终命令,命令执行完之后,会进行相关的信息记录和数据统计,然后再把执行结果发送给客户端,这样一条命令的执行流程就结束了。如果是集群模式的话,主节点还会将命令同步至子节点,下面我们一起来看更加具体的执行流程。

        -

        image.png

        +

        png

        步骤一:用户输入一条命令

        步骤二:客户端先将命令转换成 Redis 协议,然后再通过 socket 连接发送给服务器端

        客户端和服务器端是基于 socket 通信的,服务器端在初始化时会创建了一个 socket 监听,用于监测链接客户端的 socket 链接,源码如下:

        @@ -251,7 +251,7 @@ function hide_canvas() {
      11. 将命令转换为 Redis 通讯协议,再将这些协议发送至缓冲区。
      12. 步骤三:服务器端接收到命令

        -

        服务器会先去输入缓冲中读取数据,然后判断数据的大小是否超过了系统设置的值(默认是 1GB),如果大于此值就会返回错误信息,并关闭客户端连接。 默认大小如下图所示: redis-run-max_query_buffer.png 当数据大小验证通过之后,服务器端会对输入缓冲区中的请求命令进行分析,提取命令请求中包含的命令参数,存储在 client 对象(服务器端会为每个链接创建一个 Client 对象)的属性中。

        +

        服务器会先去输入缓冲中读取数据,然后判断数据的大小是否超过了系统设置的值(默认是 1GB),如果大于此值就会返回错误信息,并关闭客户端连接。 默认大小如下图所示: png 当数据大小验证通过之后,服务器端会对输入缓冲区中的请求命令进行分析,提取命令请求中包含的命令参数,存储在 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-client.png 可以看出几乎所有的编程语言,都有相应的客户端支持。

        +

        Redis 客户端有众多的开发者提供了相应的支持,这些客户端可以在 https://redis.io/clients 上找到,支持是编程语言,如下图所示: png 可以看出几乎所有的编程语言,都有相应的客户端支持。

        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 按钮,如下图所示: redis-download.png

        +

        进入网址:https://redis.io/download 选择需要安装的版本,点击 Download 按钮,如下图所示: png

        ② 解压安装包

        使用命令:tar zxvf redis-5.0.7.tar.gz

        ③ 切换到 Redis 目录

        使用命令:cd /usr/local/redis-5.0.7/

        ④ 编译安装
        -

        使用命令:sudo make install 安装完成,如下图所示: mac-insert1.png 如果没有异常信息输出,向上图所示,则表示 Redis 已经安装成功。

        +

        使用命令:sudo make install 安装完成,如下图所示: png 如果没有异常信息输出,向上图所示,则表示 Redis 已经安装成功。

        2)Docker 安装

        -

        Docker 的使用前提是必须先有 Docker,如果本机没有安装 Docker,对于 Linux 用户来说,可使用命令 yum -y install docker 在线安装 docker,如果是非 Linux 平台需要在官网下载并安装 Docker Desker,下载地址:https://docs.docker.com/get-started/ 如下图所示: image.png 选择相应的平台,下载安装即可。 有了 Docker 之后,就可以在 Docker 上安装 Redis 服务端了,具体步骤如下:

        +

        Docker 的使用前提是必须先有 Docker,如果本机没有安装 Docker,对于 Linux 用户来说,可使用命令 yum -y install docker 在线安装 docker,如果是非 Linux 平台需要在官网下载并安装 Docker Desker,下载地址:https://docs.docker.com/get-started/ 如下图所示: png 选择相应的平台,下载安装即可。 有了 Docker 之后,就可以在 Docker 上安装 Redis 服务端了,具体步骤如下:

        ① 拉取 Reids 镜像

        使用命令:

        @@ -278,7 +278,7 @@ function hide_canvas() {
      13. -p:映射宿主端口到容器端口
      14. -d:表示后台运行
      15. -

        执行完成后截图如下: redis-setup-docker-ok.png 如图所示,则证明 Redis 已经正常启动了。 如果要查询 Redis 的安装版本,可遵循下图的执行流程,先进入容器,在进入 Redis 的安装目录,执行 redis-server -v 命令,如图如下: redis-docker-version.png

        +

        执行完成后截图如下: png 如图所示,则证明 Redis 已经正常启动了。 如果要查询 Redis 的安装版本,可遵循下图的执行流程,先进入容器,在进入 Redis 的安装目录,执行 redis-server -v 命令,如图如下: png

        ③ 执行命令

        Docker 版的 Redis 命令执行和其他方式安装的 Redis 不太一样,所以这里需要单独讲一下,我们要使用 redis-cli 工具,需要执行以下命令:

        @@ -331,14 +331,14 @@ function hide_canvas() {

        下面我们就用可执行文件 redis-server 来启动 Redis 服务器,我们在 Redis 的安装目录执行 src/redis-server 命令就可以启动 Redis 服务了,如下图所示: 启动运行.png 可以看出 Redis 已经正常启动了,但这种启动方式,会使得 Redis 服务随着控制台的关闭而退出,因为 Redis 服务默认是非后台启动的,我们需要修改配置文件(redis.conf),找到 daemonize no 改为 daemonize yes ,然后重启服务,此时 Redis 就是以后台运行方式启动了,并且不会随着控制台的关闭而退出。

        daemonize 配置如下: 改为后台运行.png

        2)使用可视化工具操作 Redis

        -

        Redis 启动之后就可以使用一些客户端工具进行链接和操作,如下图所示: mac-client-link.png (注:我们本文使用的是 Redis Desktop Manager 工具链接的,更多 Redis 可视化工具,在本课程的后面有介绍。) 可以看出 Redis 服务器默认有 16 个数据库实例,从 db0 到 db15,但这个数据库实例和传统的关系型数据库实例是不一样的。传统型数据库实例是通过连接字符串配置的,而 Redis 数据库连接字符串只有一个,并不能指定要使用的数据库实例。

        +

        Redis 启动之后就可以使用一些客户端工具进行链接和操作,如下图所示: png (注:我们本文使用的是 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-link2.png

        +

        redis-cli 是官方自带的客户端链接工具,它可以配合命令行来对 Redis 进行操作,在 Redis 的安装目录使用 src/redis-cli 命令即可链接并操作 Redis,如下图所示: png

        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 持久化。

        -

        image.png Redis 持久化也是 Redis 和 Memcached 的主要区别之一,因为 Memcached 不具备持久化功能。

        +

        png Redis 持久化也是 Redis 和 Memcached 的主要区别之一,因为 Memcached 不具备持久化功能。

        1 持久化的几种方式

        Redis 持久化拥有以下三种方式:

          @@ -240,9 +240,9 @@ function hide_canvas() {

          手动触发持久化的操作有两个: savebgsave ,它们主要区别体现在:是否阻塞 Redis 主线程的执行。

          ① save 命令

          在客户端中执行 save 命令,就会触发 Redis 的持久化,但同时也是使 Redis 处于阻塞状态,直到 RDB 持久化完成,才会响应其他客户端发来的命令,所以在生产环境一定要慎用

          -

          save 命令使用如下: image.png 从图片可以看出,当执行完 save 命令之后,持久化文件 dump.rdb 的修改时间就变了,这就表示 save 成功的触发了 RDB 持久化。 save 命令执行流程,如下图所示: image.png

          +

          save 命令使用如下: png 从图片可以看出,当执行完 save 命令之后,持久化文件 dump.rdb 的修改时间就变了,这就表示 save 成功的触发了 RDB 持久化。 save 命令执行流程,如下图所示: png

          ② bgsave 命令
          -

          bgsave(background save)既后台保存的意思, 它和 save 命令最大的区别就是 bgsave 会 fork() 一个子进程来执行持久化,整个过程中只有在 fork() 子进程时有短暂的阻塞,当子进程被创建之后,Redis 的主进程就可以响应其他客户端的请求了,相对于整个流程都阻塞的 save 命令来说,显然 bgsave 命令更适合我们使用。 bgsave 命令使用,如下图所示: image.png bgsave 执行流程,如下图所示: image.png

          +

          bgsave(background save)既后台保存的意思, 它和 save 命令最大的区别就是 bgsave 会 fork() 一个子进程来执行持久化,整个过程中只有在 fork() 子进程时有短暂的阻塞,当子进程被创建之后,Redis 的主进程就可以响应其他客户端的请求了,相对于整个流程都阻塞的 save 命令来说,显然 bgsave 命令更适合我们使用。 bgsave 命令使用,如下图所示: png bgsave 执行流程,如下图所示: png

          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 文件清空。 执行结果如下图所示: image.png

        +

        flushall 命令用于清空 Redis 数据库,在生产环境下一定慎用,当 Redis 执行了 flushall 命令之后,则会触发自动持久化,把 RDB 文件清空。 执行结果如下图所示: png

        ③ 主从同步触发

        在 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 ,执行效果如下图所示: image.png 查询 RDB 的文件目录,可使用命令 config get dir ,执行效果如下图所示: image.png

        +

        Redis 中可以使用命令查询当前配置参数。查询命令的格式为:config get xxx ,例如,想要获取 RDB 文件的存储名称设置,可以使用 config get dbfilename ,执行效果如下图所示: png 查询 RDB 的文件目录,可使用命令 config get dir ,执行效果如下图所示: png

        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 ,如下图所示: image.png 从日志上可以看出, Redis 服务在启动时已经正常加载了 RDB 文件。

        +

        当 Redis 服务器启动时,如果 Redis 根目录存在 RDB 文件 dump.rdb,Redis 就会自动加载 RDB 文件恢复持久化数据。 如果根目录没有 dump.rdb 文件,请先将 dump.rdb 文件移动到 Redis 的根目录。 验证 RDB 文件是否被加载 Redis 在启动时有日志信息,会显示是否加载了 RDB 文件,我们执行 Redis 启动命令:src/redis-server redis.conf ,如下图所示: png 从日志上可以看出, Redis 服务在启动时已经正常加载了 RDB 文件。

        小贴士:Redis 服务器在载入 RDB 文件期间,会一直处于阻塞状态,直到载入工作完成为止。

        @@ -312,7 +312,7 @@ dir ./
      16. RDB 需要经常 fork() 才能使用子进程将其持久化在磁盘上。如果数据集很大,fork() 可能很耗时,并且如果数据集很大且 CPU 性能不佳,则可能导致 Redis 停止为客户端服务几毫秒甚至一秒钟。
      17. 9 禁用持久化

        -

        禁用持久化可以提高 Redis 的执行效率,如果对数据丢失不敏感的情况下,可以在连接客户端的情况下,执行 config set save "" 命令即可禁用 Redis 的持久化,如下图所示: image.png

        +

        禁用持久化可以提高 Redis 的执行效率,如果对数据丢失不敏感的情况下,可以在连接客户端的情况下,执行 config set save "" 命令即可禁用 Redis 的持久化,如下图所示: png

        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 命令,如下图所示: image.png 其中,第一行为 AOF 文件的名称,而最后一行表示 AOF 启动的状态,yes 表示已启动,no 表示未启动。

        +

        使用 config get appendonly 命令,如下图所示: png 其中,第一行为 AOF 文件的名称,而最后一行表示 AOF 启动的状态,yes 表示已启动,no 表示未启动。

        2)开启 AOF 持久化

        Redis 默认是关闭 AOF 持久化的,想要开启 AOF 持久化,有以下两种方式:

          @@ -241,9 +241,9 @@ function hide_canvas() {

        下面分别来看以上两种方式的实现。

        ① 命令行启动 AOF
        -

        命令行启动 AOF,使用 config set appendonly yes 命令,如下图所示: image.png 命令行启动 AOF 的优缺点:命令行启动优点是无需重启 Redis 服务,缺点是如果 Redis 服务重启,则之前使用命令行设置的配置就会失效。

        +

        命令行启动 AOF,使用 config set appendonly yes 命令,如下图所示: png 命令行启动 AOF 的优缺点:命令行启动优点是无需重启 Redis 服务,缺点是如果 Redis 服务重启,则之前使用命令行设置的配置就会失效。

        ② 配置文件启动 AOF
        -

        Redis 的配置文件在它的根路径下的 redis.conf 文件中,获取 Redis 的根目录可以使用命令 config get dir 获取,如下图所示: image.png 只需要在配置文件中设置 appendonly yes 即可,默认 appendonly no 表示关闭 AOF 持久化。 配置文件启动 AOF 的优缺点:修改配置文件的缺点是每次修改配置文件都要重启 Redis 服务才能生效,优点是无论重启多少次 Redis 服务,配置文件中设置的配置信息都不会失效。

        +

        Redis 的配置文件在它的根路径下的 redis.conf 文件中,获取 Redis 的根目录可以使用命令 config get dir 获取,如下图所示: png 只需要在配置文件中设置 appendonly yes 即可,默认 appendonly no 表示关闭 AOF 持久化。 配置文件启动 AOF 的优缺点:修改配置文件的缺点是每次修改配置文件都要重启 Redis 服务才能生效,优点是无论重启多少次 Redis 服务,配置文件中设置的配置信息都不会失效。

        3 触发持久化

        AOF 持久化开启之后,只要满足一定条件,就会触发 AOF 持久化。AOF 的触发条件分为两种:自动触发和手动触发。

        1)自动触发

        @@ -260,9 +260,9 @@ appendfsync everysec

        小贴士:因为每次写入磁盘都会对 Redis 的性能造成一定的影响,所以要根据用户的实际情况设置相应的策略,一般设置每秒写入一次磁盘的频率就可以满足大部分的使用场景了。

        -

        触发自动持久化的两种情况,如下图所示: image.png

        +

        触发自动持久化的两种情况,如下图所示: png

        2)手动触发

        -

        在客户端执行 bgrewriteaof 命令就可以手动触发 AOF 持久化,如下图所示: image.png 可以看出执行完 bgrewriteaof 命令之后,AOF 持久化就会被触发。

        +

        在客户端执行 bgrewriteaof 命令就可以手动触发 AOF 持久化,如下图所示: png 可以看出执行完 bgrewriteaof 命令之后,AOF 持久化就会被触发。

        4 AOF 文件重写

        AOF 是通过记录 Redis 的执行命令来持久化(保存)数据的,所以随着时间的流逝 AOF 文件会越来越多,这样不仅增加了服务器的存储压力,也会造成 Redis 重启速度变慢,为了解决这个问题 Redis 提供了 AOF 重写的功能。

        1)什么是 AOF 重写?

        @@ -273,7 +273,7 @@ appendfsync everysec
      18. auto-aof-rewrite-min-size:允许 AOF 重写的最小文件容量,默认是 64mb 。
      19. auto-aof-rewrite-percentage:AOF 文件重写的大小比例,默认值是 100,表示 100%,也就是只有当前 AOF 文件,比最后一次(上次)的 AOF 文件大一倍时,才会启动 AOF 文件重写。
      20. -

        查询 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 的值,可使用 config get xxx 命令,如下图所示: image.png

        +

        查询 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 的值,可使用 config get xxx 命令,如下图所示: png

        小贴士:只有同时满足 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 文件并启动,执行如下图所示: image.png 其中 DB loaded from append only file...... 表示 Redis 服务器在启动时,先去加载了 AOF 持久化文件。

        +

        正常情况下,只要开启了 AOF 持久化,并且提供了正常的 appendonly.aof 文件,在 Redis 启动时就会自定加载 AOF 文件并启动,执行如下图所示: png 其中 DB loaded from append only file...... 表示 Redis 服务器在启动时,先去加载了 AOF 持久化文件。

        小贴士:默认情况下 appendonly.aof 文件保存在 Redis 的根目录下。

        @@ -312,7 +312,7 @@ aof-load-truncated yes
      21. 如果只开启了 RDB 持久化,Redis 启动时只会加载 RDB 文件(dump.rdb),进行数据恢复;
      22. 如果同时开启了 RDB 和 AOF 持久化,Redis 启动时只会加载 AOF 文件(appendonly.aof),进行数据恢复。
      23. -

        在 AOF 开启的情况下,即使 AOF 文件不存在,只有 RDB 文件,也不会加载 RDB 文件。 AOF 和 RDB 的加载流程如下图所示: image.png

        +

        在 AOF 开启的情况下,即使 AOF 文件不存在,只有 RDB 文件,也不会加载 RDB 文件。 AOF 和 RDB 的加载流程如下图所示: png

        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 的格式化追加的文件的末尾。

        -

        混合持久化的数据存储结构如下图所示: image.png

        +

        混合持久化的数据存储结构如下图所示: png

        1 开启混合持久化

        -

        查询是否开启混合持久化可以使用 config get aof-use-rdb-preamble 命令,执行结果如下图所示: image.png 其中 yes 表示已经开启混合持久化,no 表示关闭,Redis 5.0 默认值为 yes。 如果是其他版本的 Redis 首先需要检查一下,是否已经开启了混合持久化,如果关闭的情况下,可以通过以下两种方式开启:

        +

        查询是否开启混合持久化可以使用 config get aof-use-rdb-preamble 命令,执行结果如下图所示: png 其中 yes 表示已经开启混合持久化,no 表示关闭,Redis 5.0 默认值为 yes。 如果是其他版本的 Redis 首先需要检查一下,是否已经开启了混合持久化,如果关闭的情况下,可以通过以下两种方式开启:

        • 通过命令行开启
        • 通过修改 Redis 配置文件开启

        1)通过命令行开启

        -

        使用命令 config set aof-use-rdb-preamble yes 执行结果如下图所示: image.png

        +

        使用命令 config set aof-use-rdb-preamble yes 执行结果如下图所示: png

        小贴士:命令行设置配置的缺点是重启 Redis 服务之后,设置的配置就会失效。

        2)通过修改 Redis 配置文件开启

        -

        在 Redis 的根路径下找到 redis.conf 文件,把配置文件中的 aof-use-rdb-preamble no 改为 aof-use-rdb-preamble yes 如下图所示: image.png

        +

        在 Redis 的根路径下找到 redis.conf 文件,把配置文件中的 aof-use-rdb-preamble no 改为 aof-use-rdb-preamble yes 如下图所示: png

        2 实例运行

        -

        当在混合持久化关闭的情况下,使用 bgrewriteaof 触发 AOF 文件重写之后,查看 appendonly.aof 文件的持久化日志,如下图所示: image.png 可以看出,当混合持久化关闭的情况下 AOF 持久化文件存储的为标准的 AOF 格式的文件。 当混合持久化开启的模式下,使用 bgrewriteaof 命令触发 AOF 文件重写,得到 appendonly.aof 的文件内容如下图所示: image.png 可以看出 appendonly.aof 文件存储的内容是 REDIS 开头的 RDB 格式的内容,并非为 AOF 格式的日志。

        +

        当在混合持久化关闭的情况下,使用 bgrewriteaof 触发 AOF 文件重写之后,查看 appendonly.aof 文件的持久化日志,如下图所示: png 可以看出,当混合持久化关闭的情况下 AOF 持久化文件存储的为标准的 AOF 格式的文件。 当混合持久化开启的模式下,使用 bgrewriteaof 命令触发 AOF 文件重写,得到 appendonly.aof 的文件内容如下图所示: png 可以看出 appendonly.aof 文件存储的内容是 REDIS 开头的 RDB 格式的内容,并非为 AOF 格式的日志。

        3 数据恢复和源码解析

        -

        混合持久化的数据恢复和 AOF 持久化过程是一样的,只需要把 appendonly.aof 放到 Redis 的根目录,在 Redis 启动时,只要开启了 AOF 持久化,Redis 就会自动加载并恢复数据。 Redis 启动信息如下图所示: image.png 可以看出 Redis 在服务器初始化的时候加载了 AOF 文件的内容。

        +

        混合持久化的数据恢复和 AOF 持久化过程是一样的,只需要把 appendonly.aof 放到 Redis 的根目录,在 Redis 启动时,只要开启了 AOF 持久化,Redis 就会自动加载并恢复数据。 Redis 启动信息如下图所示: png 可以看出 Redis 在服务器初始化的时候加载了 AOF 文件的内容。

        1)混合持久化的加载流程

        混合持久化的加载流程如下:

          @@ -248,7 +248,7 @@ function hide_canvas() {
        1. 判断 AOF 文件开头是 RDB 的格式, 先加载 RDB 内容再加载剩余的 AOF 内容;
        2. 判断 AOF 文件开头不是 RDB 的格式,直接以 AOF 格式加载整个文件。
        -

        AOF 加载流程图如下图所示: image.png 2)源码解析

        +

        AOF 加载流程图如下图所示: png 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
         
      24. *ptr:对象指针用于指向具体的内容,占用 64 bits(8 字节)。
      25. 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,以下源码可以证明,如下图所示: SDS-0116-1.png

        +

        了解了 redisObject 之后,我们再来看 SDS 自身的数据结构,从 SDS 的源码可以看出,SDS 的存储类型一共有 5 种:SDSTYPE5、SDSTYPE8、SDSTYPE16、SDSTYPE32、SDSTYPE64,在这些类型中最小的存储类型为 SDSTYPE5,但 SDSTYPE5 类型会默认转成 SDSTYPE8,以下源码可以证明,如下图所示: png

        那我们直接来看 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;
         

        字典类型的数据结构,如下图所示:

        -

        Redis-HashType-02.png

        +

        png

        通常情况下字典类型会使用数组的方式来存储相关的数据,但发生哈希冲突时才会使用链表的结构来存储数据。

        4.哈希冲突

        字典类型的存储流程是先将键值进行 Hash 计算,得到存储键值对应的数组索引,再根据数组索引进行数据存储,但在小概率事件下可能会出完全不相同的键值进行 Hash 计算之后,得到相同的 Hash 值,这种情况我们称之为哈希冲突

        @@ -317,7 +317,7 @@ public class HashExample {
      26. 判断元素和查找的键值是否相等,相等则成功返回数据,否则需要查看 next 指针是否还有对应其他元素,如果没有,则返回 null,如果有的话,重复此步骤。
      27. 键值查询流程,如下图所示:

        -

        Redis-HashType-03.png

        +

        png

        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 命令给所有的从库。

        本文的知识点,如下图所示:

        -

        image.png

        +

        png

        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 确认消息已经被消费完成,整个流程的执行如下图所示:

        -

        image.png

        +

        png

        查询未确认的消费队列

        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 在单应用中使用不会有任何问题,但如果放到分布式环境下就不适用了,这个时候我们就要使用分布式锁。

        分布式锁比较好理解就是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。

        分布式锁示意图,如下所示:

        -

        image.png

        +

        png

        怎么实现分布式锁?

        分布式锁比较常见的实现方式有三种:

          @@ -265,7 +265,7 @@ function hide_canvas() {

          带参数的 Set

          因为 setnx 和 expire 存在原子性的问题,所以之后出现了很多类库用于解决此问题的,这样就增加了使用的成本,意味着你不但要添加 Redis 本身的客户端,并且为了解决 setnx 分布式锁的问题,还需要额外第三方类库。

          然而,这个问题到 Redis 2.6.12 时得到了解决,因为这个版本可以使用 set 并设置超时和非空判定等参数了。

          -

          image.png

          +

          png

          这样我们就可以使用 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 &nbsp;# 运行容器
           

          启动验证

          服务启动之后,我们需要判断布隆过滤器是否正常开启,此时我们只需使用 redis-cli 连接到服务端,输入 bf.add 看有没有命令提示,就可以判断是否正常启动了,如下图所示:

          -

          image.png

          +

          png

          如果有命令提示则表名 Redis 服务器已经开启了布隆过滤器。

          布隆过滤器的使用

          布隆过滤器的命令不是很多,主要包含以下几个:

          @@ -404,7 +404,7 @@ public class BloomExample {

          当进行元素判断时,查询此元素的几个哈希位置上的值是否为 1,如果全部为 1,则表示此值存在,如果有一个值为 0,则表示不存在。因为此位置是通过 hash 计算得来的,所以即使这个位置是 1,并不能确定是那个元素把它标识为 1 的,因此布隆过滤器查询此值存在时,此值不一定存在,但查询此值不存在时,此值一定不存在

          并且当位数组存储值比较稀疏的时候,查询的准确率越高,而当位数组存储的值越来越多时,误差也会增大。

          位数组和 key 之间的关系,如下图所示:

          -

          image.png

          +

          png

          布隆过滤器使用场景

          它的经典使用场景包括以下几个:

            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 支持的客户端有以下这些。

            -

            image.png

            +

            png

            本文我们使用 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 目录下,如图所示:

        -

        image.png

        +

        png

        我们需要使用命令 ./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 就会被启动,如下图所示:

        -

        image.png

        +

        png

        从上图可以看出 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 架构图如下所示:

        -

        image.png

        +

        png

        搭建 Redis Cluster

        Redis Cluster 的搭建方式有两种,一种是使用 Redis 源码中提供的 create-cluster 工具快速的搭建 Redis 集群环境,另一种是配置文件的方式手动创建 Redis 集群环境。

        快速搭建 Redis Cluster

        create-cluster 工具在 utils/create-cluster 目录下,如下图所示:

        -

        image.png

        +

        png

        使用命令 ./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 从,如下图所示:

        -

        image.png

        -

        image.png

        +

        png

        +

        png

        接下来我们进行配置并启动 Redis 集群。

        1. 设置配置文件

        我们需要修改每个节点内的 redis.conf 文件,设置 cluster-enabled yes 表示开启集群模式,并且修改各自的端口,我们继续使用 30001 到 30006,通过 port 3000X 设置。

        @@ -480,7 +480,7 @@ f5958382af41d4e1f5b0217c1413fe19f390b55f 127.0.0.1:

        +

        png

        执行命令如下:

        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 缓存失效的时候先去查询本地缓存而非查询数据库。

        加入二级缓存之后程序执行流程,如下图所示:

        -

        image.png

        +

        png

        缓存穿透

        缓存穿透是指查询数据库和缓存都无数据,因为数据库查询无数据,出于容错考虑,不会将结果保存到缓存中,因此每次请求都会去查询数据库,这种情况就叫做缓存穿透。

        缓存穿透执行流程如下图所示:

        @@ -282,7 +282,7 @@ jedis.setex(cacheKey, exTime+random.nextInt(1000) , value);

        缓存击穿

        缓存击穿指的是某个热点缓存,在某一时刻恰好失效了,然后此时刚好有大量的并发请求,此时这些请求将会给数据库造成巨大的压力,这种情况就叫做缓存击穿。

        缓存击穿的执行流程如下图所示:

        -

        image.png

        +

        png

        它的解决方案有以下 2 个。

        加锁排队

        此处理方式和缓存雪崩加锁排队的方法类似,都是在查询数据库时加锁排队,缓冲操作请求以此来减少服务器的运行压力。

        @@ -292,7 +292,7 @@ jedis.setex(cacheKey, exTime+random.nextInt(1000) , value);

        首先来说,缓存预热并不是一个问题,而是使用缓存时的一个优化方案,它可以提高前台用户的使用体验。

        缓存预热指的是在系统启动的时候,先把查询结果预存到缓存中,以便用户后面查询时可以直接从缓存中读取,以节约用户的等待时间。

        缓存预热的执行流程,如下图所示:

        -

        image.png

        +

        png

        缓存预热的实现思路有以下三种:

        1. 把需要缓存的方法写在系统初始化的方法中,这样系统在启动的时候就会自动的加载数据并缓存数据;
        2. 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

          使用截图:

          -

          image.png

          +

          png

          Redis Desktop Manager

          是否收费:收费。

          项目介绍:一款基于 Qt5 的跨平台 Redis 桌面管理软件。

          支持平台:Windows、macOS、Linux。

          项目地址https://github.com/uglide/RedisDesktopManager

          使用截图:

          -

          image.png

          +

          png

          RedisStudio

          是否收费:免费。

          项目介绍:一款 C++ 编写的 Redis 管理工具,比较老,好久没更新了。

          支持平台:Windows。

          项目地址https://github.com/cinience/RedisStudio

          使用截图:

          -

          image.png

          +

          png

          AnotherRedisDesktopManager

          是否收费:免费。

          项目介绍:一款基于 Node.js 开发的 Redis 桌面管理器,它的特点就是相对来说比较稳定,在数据量比较大的时候不会崩溃。

          支持平台:Windows、macOS、Linux。

          项目地址https://github.com/qishibo/AnotherRedisDesktopManager

          使用截图:

          -

          image.png

          -

          image.png

          +

          png

          +

          png

          其他 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.png ▲ 单体架构

            +

            png ▲ 单体架构

            单体应用架构面临的问题

            随着业务的增长,这种最简单的单体应用架构很快就面临两个问题。首先,这里只有一台服务器,如果这台服务器出现故障,例如硬件损坏,那么整个服务就会不可用;其次,业务量变大之后,一台服务器的资源很快会无法承载所有流量。

            解决这两个问题最直接的方法就是在流量入口加一个负载均衡器,使单体应用同时部署到多台服务器上,这样服务器的单点问题就解决了,与此同时,这个单体应用也具备了水平伸缩的能力。

            -

            2.png ▲ 单体架构(水平伸缩)

            +

            png ▲ 单体架构(水平伸缩)

            微服务架构

            1. 微服务架构演进出通用服务

            随着业务的进一步增长,更多的研发人员加入到团队中,共同在单体应用上开发特性。由于单体应用内的代码没有明确的物理边界,大家很快就会遇到各种冲突,需要人工协调,以及大量的 conflict merge 操作,研发效率直线下降。

            @@ -192,7 +192,7 @@ function hide_canvas() {

            2. 微服务架构给运维带来挑战

            应用从单体架构演进到微服务架构,从物理的角度看,分布式就成了默认选项,这时应用架构师就不得不面对分布式带来的新挑战。在这个过程中,大家都会开始使用一些分布式服务和框架,例如缓存服务 Redis,配置服务 ACM,状态协调服务 ZooKeeper,消息服务 Kafka,还有通讯框架如 GRPC 或者 DUBBO,以及分布式追踪系统等。

            除分布式环境带来的挑战之外,微服务架构给运维也带来新挑战。研发人员原来只需要运维一个应用,现在可能需要运维十个甚至更多的应用,这意味着安全 patch 升级、容量评估、故障诊断等事务的工作量呈现成倍增长,这时,应用分发标准、生命周期标准、观测标准、自动化弹性等能力的重要性也更加凸显。

            -

            3.png ▲ 微服务架构

            +

            png ▲ 微服务架构

            云原生

            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。
          -

          2.JPG

          +

          png

          这三种方式由云下到云上,由管理服务器到无需管理服务器,即 Serverless。这一系列的转变给使用者带来了什么变化呢?前两种方案需要预算,需要扩展,需要实现高可用,需要自行监控等,这些都不是马老师当年想要的,他只想去展示信息,让世界了解中国,这是他的业务逻辑。Serverless 正是这样一种理念,最大化地让人去专注业务逻辑。第三种方式就是采用了 Serverless 架构去构建一个静态站点,它有其它方案无法比拟的优势,比如:

          • 可运维性:无需管理服务器,比如操作系统的安全补丁升级、故障升级、高可用性,这些云服务(OSS,CDN)都帮着做了;
          • @@ -225,7 +225,7 @@ function hide_canvas() {
          • 是否可以通过函数来实现轻量级微服务,依赖函数计算提供的负载均衡、自动伸缩、按需付费、日志采集、系统监控等能力;
          • 基于 Spring Cloud、Dubbo、HSF 等实现的微服务应用是否需要自己购置服务器部署应用,管理服务发现,负载均衡,弹性伸缩,熔断,系统监控等,还是可以将这些工作交给诸如 Serverless 应用引擎服务。
          -

          4.JPG

          +

          png

          上图右侧的架构引入了 API 网关、函数计算或者 Serverless 应用引擎来实现计算层,将大量的工作交给了云服务完成,让用户最大程度上专注实现业务逻辑。其中系统内部多个微服务的交互如下图所示,通过提供一个商品聚合服务,将内部的多个微服务统一呈现给外部。这里的微服务可以通过 SAE 或者函数实现。

          img

          这样的架构还可以继续扩展,比如如何支持不同客户端的访问,如上图右侧所示。现实中这种需求是常见的,不同的客户端需要的信息可能是不同的,手机可以根据位置信息做相关推荐。如何让手机客户端和不同浏览器都能受益于 Serverless 架构呢?这又牵扯出了另一个词——Backend for fronted(BFF),即为前端定做的后端,这受到了前端开发工程师的推崇,Serverless 技术让这个架构广泛流行,因为前端工程师可以从业务角度出发直接编写 BFF,而无需管理服务器相关的令前端工程师更加头疼的事情。更多实践可以参见:基于函数计算的 BFF 架构

          @@ -233,7 +233,7 @@ function hide_canvas() {

          前面提到的动态页面生成是同步请求完成的,还有一类常见场景,其中请求处理通常需要较长时间或者较多资源,比如用户评论中的图片和视频内容管理,涉及到如何上传图片和处理图片(缩略图、水印、审核等)及视频,以适应不同客户端的播放需求。

          img

          如何对上传多媒体文件实时处理呢?这个场景的技术架构大体经历了以下演变:

          -

          7.JPG

          +

          png

          • 基于服务器的单体架构:多媒体文件被上传到服务器,由服务器处理,对多媒体的显示请求也由服务器完成;
          • 基于服务器的微服务架构:多媒体文件被上传到服务器,服务器处理转存到 OSS,然后将文件地址加入消息队列,由另一组服务器处理文件,将处理结果保存到 OSS,对多媒体的显示请求由 OSS 和 CDN 完成;
          • @@ -262,7 +262,7 @@ function hide_canvas() {

            事件触发能力是 FaaS 服务的一个重要特性,这种 Pub-Sub 事件驱动模式不是一个新的概念,但是在 Serverless 流行之前,事件的生产者、消费者以及中间的连接枢纽都是用户负责的,就像前面架构演进中的第二个架构。

            Serverless 让生产者发送事件,维护连接枢纽都从用户职责中省略了,而只需关注消费者的逻辑,这就是 Serverless 的价值所在。

            函数计算服务还集成其它云服务事件源,让你更方便地在业务中使用一些常见的模式,如 Pub/Sub、事件流模式、Event Sourcing 模式。关于更多的函数组合模式可以参见:函数组合的 N 种方式

            -

            8.JPG

            +

            png

            场景 4: 服务编排

            前面的商品页面虽然复杂,但是所有的操作都是读操作,聚合服务 API 是无状态、同步的。我们来看一下电商中的一个核心场景——订单流程。

            img

            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 的好处,包括按量付费、极致弹性等,这样也更符合用户原有的使用习惯。

            -

            image.gif

            +

            png

            在上图中,大家能够看到,阿里云针对函数、应用和容器都推出了对应的 Serverless 产品,用户可以针对自己的使用场景选择不同的产品。

            函数计算

            1. 函数计算介绍

            @@ -189,7 +189,7 @@ function hide_canvas() {

            上图展示了函数计算的使用方式。从用户角度,他需要做的只是编码,然后把代码上传到函数计算中。这个时候还不会产生费用,只有到被调用的时候才有费用。调用的方式可以是产品提供的 API/SDK,也可以通过一些事件源,比如阿里云的 OSS 的事件。比如用户往 OSS 里的某一个 bucket 上传了一个文件,希望这个文件被自动处理;比如上传一个 zip 包,希望能够自动解压到另外一个 bucket,这都是很典型的函数场景。

            另外,函数计算能够提供非常好的弹性能力,最终的费用是根据时长和内存数进行计费的,如果调用量小的话,只会有很少的费用。并且它在语言方面也非常丰富,常用的 nodejs、php、python、java 都直接支持。同时提供自定义的运行环境,可以支持任意的可执行的语言。

            2. 函数计算典型场景

            -

            image.gif

            +

            png

            从使用场景来说,主要有三类:

            • Web 应用。可以是各种语言写的,这种可以使用 Serverless 框架新编写的程序,也可以是已有的应用。比如小程序后端、或者发布到 API 市场的 API 后端应用等。
            • @@ -197,7 +197,7 @@ function hide_canvas() {
            • 事件驱动型的应用。比如通过其他阿里云产品驱动的场景、Web Hook、定时任务等。函数计算已经与很多产品进行了打通,比如对象存储、表格存储、定时器、CDN、日志服务、云监控等,可以非常快速地组装出一些业务逻辑。

            3. 函数计算核心竞争力

            -

            image.gif

            +

            png

            函数计算对客户的一个最大的价值,就是能够让用户只关注自己的业务逻辑开发,完全不需要管理运维,诸如计算资源、网络设置等都不需要关心。在隔离性上提供 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 典型场景

            -

            image.gif

            +

            png

            SAE 有几个典型的使用场景,一个是存量业务上云,特别是微服务、java 应用,同时也支持其他语言的单体应用,都能够通过 SAE 这个平台运行在阿里云上,并且不需要做任何代码的修改。在行业方面,SAE 特别适合有比较大的流量波动的在线业务,比如电商大促、在线教育等行业的场景。另外 SAE 作为应用平台也可以被上层的行业 Saas 所集成,帮助用户更快地构建行业 Saas。

            3. SAE 特性

            img

            @@ -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 典型场景

            -

            image.gif

            +

            png

            那 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 并不是没有服务器,而是开发者不再需要关心服务器。下图是一个应用从开发到上线的对比图:

            -

            1.jpg

            +

            png

            在传统 Serverful 架构下,部署一个应用需要购买服务器,部署操作系统,搭建开发环境,编写代码,构建应用,部署应用,配置负载均衡机制,搭建日志分析与监控系统,应用上线后,继续监控应用的运行情况。而在 Serverless 架构下,开发者只需要关注应用的开发构建和部署,无需关心服务器相关操作与运维,在函数计算架构下,开发者只需要编写业务代码并监控业务运行情况。这将开发者从繁重的运维工作中解放出来,把精力投入到更有意义的业务开发上。

            -

            2.png

            +

            png

            上图展示了函数计算的使用方式。从用户角度,他需要做的只是编码,然后把代码上传到函数计算中。上传代码就意味着应用部署。当有高并发请求涌入时,开发者也无需手动扩容,函数计算会根据请求量毫秒级自动扩容,弹性可靠地运行任务,并内置日志查询、性能监控、报警等功能帮助开发者发现问题并定位问题。

            函数计算核心优势

            -

            3.png

            +

            png

            敏捷开发

            • 使用函数计算时,用户只需聚焦于业务逻辑的开发,编写最重要的 “核心代码”;
            • @@ -237,7 +237,7 @@ function hide_canvas() {
            • 预付费模型根据业务负载估算提前预购计算力,单价更低,组合使用后付费和预付费方式将有效降低成本。

            函数计算使用场景

            -

            4.jpg

            +

            png

            从使用场景来说,主要有三类:

            • 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 函数计算是如何工作的?

              函数计算调用链路

              -

              5.PNG

              +

              png

              上图展示了函数计算完整的请求和调用链路。函数计算是事件驱动的无服务器应用,事件驱动是说可以通过事件源自动触发函数执行,比如当有对象上传至 OSS 中时,自动触发函数,对新上传的图片进行处理。函数计算支持丰富的事件源类型,包括日志服务、对象存储、表格存储、消息服务、API 网关、CDN 等。

              除了事件触发外,也可以直接通过 API/SDK 直接调用函数。调用可以分为同步调用与异步调用,当请求到达函数计算后,函数计算会为请求分配执行环境,如果是异步调用,函数计算会将请求事件存入队列中,等待消费。

              函数计算调用方式

              -

              6.jpg

              +

              png

              同步调用的特性是,客户端期待服务端立即返回计算结果。请求到达函数计算时,会立即分配执行环境执行函数。

              以 API 网关为例,API 网关同步触发函数计算,客户端会一直等待服务端的执行结果,如果执行过程中遇到错误, 函数计算会将错误直接返回,而不会对错误进行重试。这种情况下,需要客户端添加重试机制来做错误处理。

              -

              7.jpg

              +

              png

              异步调用的特性是,客户端不急于立即知道函数结果,函数计算将请求丢入队列中即可返回成功,而不会等待到函数调用结束。

              函数计算会逐渐消费队列中的请求,分配执行环境,执行函数。如果执行过程中遇到错误,函数计算会对错误的请求进行重试,对函数错误重试三次,系统错误会以指数退避方式无限重试,直至成功。

              异步调用适用于数据的处理,比如 OSS 触发器触发函数处理音视频,日志触发器触发函数清洗日志,都是对延时不敏感,又需要尽可能保证任务执行成功的场景。如果用户需要了解失败的请求并对请求做自定义处理,可以使用 Destination 功能。

              函数计算执行过程

              函数计算是 Serverless 的,这不是说无服务器,而是开发者无需关心服务器,函数计算会为开发者分配实例执行函数。

              -

              8.jpg

              +

              png

              如上图所示,当函数第一次被调用的时候,函数计算需要动态调度实例、下载代码、解压代码、启动实例,得到一个可执行函数的代码环境。然后才开始在系统分配的实例中真正地执行用户的初始化函数,执行函数业务逻辑。这个调度实例启动实例的过程,就是系统的冷启动过程。

              函数逻辑执行结束后,不会立即释放掉实例,会等一段时间,如果在这段时间内有新的调用,会复用这个实例,比如上图中的 Request 2,由于执行环境已经分配好了,Request 2 可以直接使用,所以 Request 2 就不会遇到冷启动。

              Request 2 执行结束后,等待一段时间,如果这段时间没有新的请求分配到这个实例上,那系统会回收实例,释放执行环境。此实例释放后,新的请求 Request 3 来到函数计算,需要重新调度实例、下载代码、解压代码,启动实例,又会遇到冷启动。

              所以,为了减小冷启动带来的影响,要尽可能避免冷启动,降低冷启动带来的延时。

              -

              9.jpg

              +

              png

              使用预留实例可以完全避免冷启动,预留实例是在用户预留后就分配实例,准备执行环境;请求结束后系统也不会自动回收实例。

              预留实例不由系统自动分配与回收,由用户控制实例的生命周期,可以长驻不销毁,这将彻底消除实例冷启动带来的延时毛刺,提供极致性能,也为在线应用迁移至函数计算扫清障碍。

              如果业务场景不适合使用预留实例,那就要设法降低冷启动的延时,比如降低代码包大小,可以降低下载代码包、解压代码包的时间。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. 服务

              -

              image.png

              +

              png

              服务是函数计算资源管理的单位,同一个服务下有很多函数,这些函数共享服务的网络配置、权限配置、存储配置、日志配置。

              服务可以对应成一个“应用”,这个应用由很多函数共同组成,这些函数具有相同的访问权限、网络配置,日志也记录到相同的 logstore。这些函数本身的配置可以各不相同,比如同一服务下有的函数内存是3G,有的函数内存是 512M,有些函数用 Python 写,有些函数用 Node.js 写。

              当然,如果应用比较复杂,同一个应用也可以对应多个服务,这里没有强绑定关系。

              1)服务配置

              -

              image.png

              +

              png

              接下来我们介绍服务的几个核心配置:

              日志配置: 开发者的代码在函数计算平台运行,如何查看函数运行产生的日志呢?在Server 化的开发方式中,日志都打到统一的文件中,通过 Logstash/Fluentd 这种日志收集工具收集到 ElasticSearch 中,并通过 Kibana 这种可视化工具查看日志及指标。但是在函数计算中,运行代码的机器由函数计算动态分配,开发者无法自己收集日志,函数计算需要帮助开发者投递日志。日志配置就是起到这个作用,配置 LogConfig 设置日志服务的 Project 和 Logstore,函数计算会将函数运行中产生的日志投递到开发者的 Logstore 里。

              但是为了成功投递日志,单单配置 Logtore 还不够,函数计算是没有权限向开发者的 Logstore 投递日志的,需要用户授予函数计算向指定的 Logstore 写数据的权限,有了这个授权后,函数计算就可以名正言顺地向开发者的 Logstore 投递日志了。

              @@ -197,7 +197,7 @@ function hide_canvas() {

              2. 函数

              “函数计算”中函数可谓是核心概念,函数是管理、运行的基本单元,一个函数通常由一系列配置与可运行代码包组成。

              1)函数配置

              -

              image.png

              +

              png

              函数的配置如上图所示:

              • Runtime 是函数运行时的环境类型: 函数计算目前支持 Node.js/Python/Java/C#/PHP 等开发环境,同时也支持 Custom Runtime 自定义运行时;
              • @@ -209,16 +209,16 @@ function hide_canvas() {
              • InitializerTimeout 就是 Initializer 函数的最大运行时间。

              3. 触发器

              -

              image.png

              +

              png

              往期课程中介绍了函数计算支持的丰富的事件源类型,在事件驱动的计算模型中,事件源是事件的生产者,函数是事件的处理者,触发器提供了一种集中、统一的方式来管理不同的事件源。当事件发生时,如果满足触发器定义的规则,事件源会自动调用触发器所对应的函数。

              典型的使用场景包括对上传至 OSS 中的对象进行处理,比如图像处理、音视频转码、OSS zip 包解压,以及对 SLS 中的日志进行清洗、处理、转存,在指定时间触发函数执行等等。

              4. 版本&别名

              -

              image.png

              +

              png

              上文介绍了服务、函数、触发器,开发者就可以基于函数计算将应用搭建起来了,但又有一个新问题:开发者有了新需求需要更新代码,如何保证线上应用不受影响,平滑迭代上线呢? 为了解决这个问题,函数计算引入了版本和别名。

              版本相当于服务的快照,包括服务的配置、服务内的函数代码及函数配置。当您开发和测试完成后,就发布一个版本,版本单调递增,版本发布后,已发布的版本不能更改,您可以继续在 Latest 版本上开发测试,不会影响已发布的版本。调用函数时,只需要指定版本就可以调用指定版本的函数。

              那新问题又来了,版本名称是函数计算指定的单调递增的,每次发布版本,都会有一个新的版本,那每次发完版本后,客户端还要改代码执行最新的版本吗? 为了解决这个问题呢,我们引入了别名,别名就是指向特定服务版本的指针,发布后,只需要将别名指向发布的版本,再次发布后,再切换别名指向最新的版本,客户端只需要指定别名就可以保证调用线上最新的代码。同时别名支持灰度发布的功能,即有 10% 的流量指向最新版本,90% 理论指向老版本。回滚也非常简单,只需要将别名指向之前的版本即可快速完成回滚。

              开发流程

              -

              image.png

              +

              png

              如上图所示,开发者首先创建服务,设置日志、权限等配置,然后创建函数,在当前版本(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 ,可以轻松地提供更多的环境,而这个好处常被忽略。

              -

              1.jpg

              +

              png

              当我们开发项目时,通常需要一个生产环境,然后需要预发环境,还有一些测试环境。但通常每个环境都需要消耗资源和成本,以保持服务在线。而大多数时候非生产环境上的访问量非常少,为此付出大量的成本很不划算。

              但是,在 Serverless 架构中,我们可以为每位开发人员提供一个准生产环境。做 CI/CD 的时候,可以为每个功能分支创建独立的演示环境。

              当团队成员在开发功能或者修复 bug 时,想要预览新功能,就可以立即部署,而不需要在自己机器上模拟或者找其他同事协调测试环境的使用时间。

              @@ -189,7 +189,7 @@ function hide_canvas() {

              后面的课程我们会了解到,借助于函数计算平台提供的 Funcraft 工具,开发人员可以用从前做不到的方式在准生产环境中轻松部署和测试代码。

              灰度发布

              由于 Serverless 提供的弹性机制,没有访问量的时候能自动缩容到零,极大地节约了部署的多环境的成本。然而在同一套环境内的多个不同的版本也可以受益于这套机制。

              -

              2.jpg

              +

              png

              传统应用虽然也支持在一个环境中并存多个版本,但相比于 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() {

              概述

              可观测性是什么呢?维基百科中这样说:可观测性是通过外部表现判断系统内部状态的衡量方式。

              在应用开发中,可观测性帮助我们判断系统内部的健康状况。在系统出现问题时,帮助我们定位问题、排查问题、分析问题;在系统平稳运行时,帮助我们评估风险,预测可能出现的问题。评估风险类似于天气预报,预测到明天下雨,那出门就要带伞。在函数计算的应用开发中,如果观察到函数的并发度持续升高,很可能是业务推广团队的努力工作导致业务规模迅速扩张,为了避免达到并发度限制触发流控,开发者就需要提前提升并发度。

              -

              1.PNG

              +

              png

              可观测性包括三个方面:Logging、Metrics、Tracing

              • Logging 是日志,日志记录了函数运行中的关键信息,这些信息是离散且具体的,结合错误日志与函数代码可以迅速定位问题。
              • @@ -211,7 +211,7 @@ function hide_canvas() {
              • **配置日志大盘:**日志大盘不仅可以看到函数计算提供的监控指标,而且可以与开发者日志关联,生成自定义的监控指标。

              3. 链路追踪

              -

              2.png (请求在各个链路的延时瀑布图)

              +

              png (请求在各个链路的延时瀑布图)

              链路追踪是分布式系统排查问题的重要一环,链路追踪可以分析分布式系统中请求在各个链路的时延。有以下几种情况:

              • 函数计算作为整个链路中的一环,可以看到请求在函数计算上的时延,时延包括系统启动的时间和请求真正的执行时间,帮助用户分析性能瓶颈。
              • 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. 登录函数计算控制台,创建应用

              -

              3.png

              +

              png

              可以通过两种方式来创建应用,如果是已有的 Web 项目,可以选择上图中的第一种方式:“常见 Web 应用”;对于新项目则推荐使用第二种方式:“基于模板创建应用”。我们这里使用模板方式,选择基于 Python 的 Web 应用。

              模板可以当做应用脚手架,选择适合的模板,可以自动完成相关依赖资源的创建,如角色、OSS、域名网关等,降低开发成本。

              2. 新建函数

              -

              4.png

              +

              png

              在应用下,创建函数,我们是开发 WebAPI,所以选择“HTTP”函数,这种函数会将指定的 http 请求作为触发器,来调度对应函数的执行。

              函数新建好之后,是个返回 helloWorld 的 demo,我们在此基础上来开发我们的业务逻辑。

              -

              image.png

              +

              png

              首先介绍下上图代码中的 handler 函数,这个函数是入口函数,http 触发器接收到调用后会通过这个入口来启动整个函数。函数有两个入参,environ 和 start_response:

              • environ
              • @@ -215,11 +215,11 @@ function hide_canvas() {

              该参数主要用于生成 http 请求的 response。

              3. 配置触发器,绑定域名

              -

              image.png

              +

              png

              在新建函数时会自动创建一个 http 触发器,这个触发器的路径是“aliyun.com”的一个测试路径,只能用于测试,真实的应用需要通过自定义域名将真实域名与函数绑定,这样访问指定域名时,对应函数就会被触发执行。

              4. 日志与监控

              在每个函数编辑页面,日志和监控服务,函数的每次执行都会生成唯一的 requestId,日志中通过 requestId 进行查询,看到本次函数执行的所有日志。

              -

              image.png

              +

              png

              操作演示

              点击链接即可观看演示视频: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 应用一般是按需创建,用户无需为闲置的资源付费,可以降低整体的计算成本。

              -

              image.png

              +

              png

              以上所讲的几种都是 Serverless 理念的核心价值,也是 Serverless 容器与其他 Sererless 形态的相同之处。然而,Serverless 容器和其他 Serverless 形态的差异,在于它是基于容器的交付形态。

              -

              image.png

              +

              png

              基于容器意味着通用性和标准性,我们可以 Build once and Run anywhere,容器不受语言和库的限制,无论任何应用都可以制作成容器镜像,然后以容器的部署方式启动。基于容器的标准化,开源社区以 Kubernetes 为中心构建了丰富的云原生 Cloud Native 生态,极大地丰富了 Serverless 容器的周边应用框架和工具,比如可以非常方便地部署 Helm Chart 包。基于容器和 Kubernetes 标准化,我们可以轻松地在不同环境中(线上线下环境),甚至在不同云厂商之间进行应用迁移,而不用担心厂商锁定。这些都是 Serverless 容器的核心价值。

              -

              image.png (Serverless 容器产品 Landscape)

              +

              png (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

              -

              image.png

              +

              png

              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。

              -

              image.png

              +

              png

              在与 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

                -

                image.png

                +

                png

                接下来我们看下在 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

                -

                image.png

                +

                png

                与 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 中。

                -

                image.png

                +

                png

                这是 ASK 的整体架构,核心部分是 ASK-Schduler,它负责 Watch Pod 的变化,然后创建对应的 ECI 实例,同时把 ECI 实例状态同步到 Pod。集群中没有真实 ECS 节点注册到 Apiserver。这个 Nodeless 架构解耦了 Kubernetes 编排层和 ECI 资源层,让 Kubernetes 彻底摆脱底层节点规模导致的弹性和容量限制,成为面向云的 Nodeless Kubernetes 弹性架构。

                ASK 典型功能

                下面介绍 ASK 的几个典型功能:

                1. GPU 实例

                -

                image.png

                +

                png

                第一个是 GPU 实例,在 Serverless 集群中使用 GPU 容器实例是一件非常简单的事情,不需要安装 GPU 驱动,只需要指定 GPU Pod 规格,以及容器需要的 GPU 卡数,然后就可以一键部署,这对于机器学习场景可以极大提高开发和测试的效率。

                2. Spot 抢占式实例

                -

                image.png

                +

                png

                第二个是 Spot 抢占式实例。抢占式实例是一种按需实例,可以在数据计算等场景中降低计算成本。抢占式实例创建成功后拥有一小时的保护周期。抢占式实例的市场价格会随供需变化而浮动,我们支持两种 Spot 策略,一种是完全根据市场出价,一种是指定价格上限,我们只需要给 Pod 加上对应的 Annotation 即可,使用方法非常简单。

                3. 弹性负载 Elastic Workload

                -

                image.png

                +

                png

                第三个重要功能是弹性负载 Elastic Workload,弹性负载实现了 Deployment 多个副本调度在不同的单元上,比如 ECS、ECI 和 ECI-Spot 上,通过这种混合调度的模式,可以降低负载的计算成本。在这个示例中,Deployment 是 6 个副本,其中 2 个为正常的 ECI Pod,其他副本为 ECI-Spot 实例。

                ASK 使用场景

                上面我们已经对 Serverless Kubernetes 做了基本的产品和功能介绍,那么 ASK 适合在哪些场景中使用呢?**

                1. 免运维应用托管

                -

                image.png

                +

                png

                Serverless 集群最大的特点是解决了底层节点资源的运维问题,所以其非常适合对应用的免运维托管,让用户关注在应用开发本身。在传统 K8s 集群中的应用可以无缝部署在 Serverless 集群中,包括各种 Helm Chart。同时结合预留实例劵可以降低 Pod 的长计算成本。

                2. ECI 弹性资源池

                -

                image.png

                +

                png

                第二个场景是 ACK on ECI 的优势,我们可以选择把 ECI 作为弹性资源池,加到已有的 Kubernetes 集群中,当应用业务高峰来临时,通过 ECI 动态灵活地扩容,相比 ECS 节点扩容更有效率,这种比较适合电商或者在线教育这类有着明显波峰波谷的业务场景,用户无需管理一个很大的节点资源池,通过 ECI 弹性能力来降低整体计算成本。

                3. 大数据计算

                -

                image.png

                +

                png

                第三个场景是大数据计算,很多用户使用 Serverless 集群或者 ACK on ECI 来进行 Spark / Presto / AI 等数据计算或者机器学习,利用 ECI 可以轻松解决资源规划和不足的问题。

                4. CI/CD 持续集成

                -

                image.png

                +

                png

                第四个场景是 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)进行集群的创建。

                  -

                  image.png

                  +

                  png

                  在创建页面,主要有三类属性需要选择或填写:

                  • 集群创建的地域和 Kubernetes 的版本信息;
                  • @@ -218,7 +218,7 @@ function hide_canvas() {
                  • 集群能力和服务:可以按需选择。

                  属性完成后,点击“创建集群”即可,整个创建过程需要 1~2 分钟的时间。

                  -

                  image.png

                  +

                  png

                  2. 应用部署

                  集群创建完成后,接下来我们部署一个无状态的 nginx 应用,主要分成三步:

                    @@ -226,14 +226,14 @@ function hide_canvas() {
                  • 容器配置:镜像、所需资源、容器端口、数据卷等;
                  • 高级配置:服务、路由、HPA、POD 标签等。
                  -

                  image.png

                  +

                  png

                  创建完成后,在路由中就可以看到服务对外暴露的访问方式了。

                  -

                  image.png

                  +

                  png

                  如上图所示,在本地 host 绑定 ask-demo.com 到路由端点 123.57.252.131 的解析,然后浏览器访问域名,即可请求到部署的 nginx 应用。

                  常用功能介绍

                  我们一般会通过容器服务控制台和 Kubectl 两种方式,来使用 Serverless Kubernetes 的常用功能。

                  1. 容器服务控制台

                  -

                  image.png

                  +

                  png

                  在容器服务控制台上,我们可以进行以下功能的白屏化操作:

                  • 基本信息:集群 ID 和运行状态、API Server 端点、VPC 和安全性、集群访问凭证的查看和操作;
                  • @@ -248,7 +248,7 @@ function hide_canvas() {

                  2. Kubectl

                  除了通过控制台,我们还可以基于 Kubectl 来进行集群操作和管理。

                  -

                  image.png

                  +

                  png

                  我们可以在云端通过 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 任务

                  成本优化

                  -

                  1.jpg

                  +

                  png

                  ECI 除了有秒级弹性、无限容量的优势之外,在一些特定场景下对成本的优化也是非常明显的,通过上图我们可以看到,相同规格的实例,在日运行时间少于 14 小时的时候,使用 ECI 会更加便宜。

                  -

                  2.jpg

                  +

                  png

                  除了日运行时长小于 14 小时的情形,ECI 实例还支持多种计费类型,客户可以根据自身业务选择相应的计费模式:long run 类型的可以采用 RI 实例券;运行时长低于 1 小时可以选用 Spot 竞价实例;针对突发流量部分,采用按量实例。

                  Spot 实例概述

                  -

                  3.jpg

                  +

                  png

                  抢占式实例是一种按需实例,可以在数据计算等场景中降低计算成本。抢占式实例创建成功后拥有一小时的保护周期。抢占式实例的市场价格会随供需变化而浮动,我们支持两种 spot 策略,一种是完全根据市场出价,一种是指定价格上限,我们只需要给 pod 加上对应的 annotation 即可,使用方法非常简单。

                  -

                  4.jpg

                  +

                  png

                  • SpotAsPriceGo:系统自动出价,跟随当前市场实际价格(通常以折扣的形式体现)
                  • SpotWithPriceLimit:设置抢占实例价格上限
                  • @@ -195,16 +195,16 @@ function hide_canvas() {
                  • 用户价格 >= ECI 按量实例价格,使用 ECI 按量实例价格来创建实例。

                  创建 Spot 实例

                  -

                  5.jpg

                  +

                  png

                  首先我们查询出【华北 2(北京)地域 ecs.c5.large 按量(小时)价格:0.62】,然后我们以此规格来创建 Spot 竞价实例。

                  -

                  6.jpg

                  +

                  png

                  采用 Spot 实例来运行 CronJob,分别采用“指定最高限价”、“系统自动出价”的方式。随市场价的场景目前还没有办法直接看到真实的价格,只能根据实例 ID 查询账单信息。

                  -

                  7.jpg

                  +

                  png

                  采用 Spot 实例运行 Deployment,在本次实验中我们采用指定最高限价的策略,并设置一个极低的小时价格,可以看到 2 个 Pod 都创建失败了,使用 kubectl describe 命令可以看到失败的详细原因为价格不匹配:The current price of recommend instanceTypes above user max price。

                  -

                  8.jpg

                  +

                  png

                  如上图所示,当 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 镜像,即可运行容器,并仅为容器实际运行消耗的资源付费。

                  -

                  image.png

                  +

                  png

                  阿里云容器服务产品族

                  -

                  image.png

                  +

                  png

                  不论是托管版的 Kubernetes(ACK)还是 Serverless 版 Kubernetes(ASK),都可以使用 ECI 作为容器资源层,其背后的实现就是借助虚拟节点技术,通过一个叫做 Virtual Node 的虚拟节点对接 ECI。

                  -

                  image.png

                  +

                  png

                  Kubernetes + ECI

                  有了 Virtual Kubelet,标准的 Kubernetes 集群就可以将 ECS 和虚拟节点混部,将 Virtual Node 作为应对突发流量的弹性资源池。

                  -

                  image.png

                  +

                  png

                  ASK(Serverless Kubernetes)+ ECI

                  Serverless 集群中没有任何 ECS worker 节点,也无需预留、规划资源,只有一个 Virtual Node,所有的 Pod 的创建都是在 Virtual Node 上,即基于 ECI 实例。

                  -

                  image.png

                  +

                  png

                  Serverless Kubernetes 是以容器和 Kubernetes 为基础的 Serverless 服务,它提供了一种简单易用、极致弹性、最优成本和按需付费的 Kubernetes 容器服务,其中无需节点管理和运维,无需容量规划,让用户更关注应用而非基础设施的管理。

                  Spark on Kubernetes

                  Spark 自 2.3.0 开始试验性支持 Standalone、on YARN 以及 on Mesos 之外的新的部署方式:Running Spark on Kubernetes,如今支持已经非常成熟。

                  Kubernetes 的优势

                  -

                  image.png

                  +

                  png

                  Spark on kubernetes 相比于 on Yarn 等传统部署方式的优势:

                  • 统一的资源管理。不论是什么类型的作业都可以在一个统一的 Kubernetes 集群中运行,不再需要单独为大数据作业维护一个独立的 YARN 集群。
                  • @@ -207,11 +207,11 @@ function hide_canvas() {
                  • 大数据上云。目前大数据应用上云常见的方式有两种:1)用 ECS 自建 YARN(不限于 YARN)集群;2)购买 EMR 服务,目前所有云厂商都有这类 PaaS,如今多了一个选择——Kubernetes。

                  Spark 调度

                  -

                  image.png

                  +

                  png

                  图中橙色部分是原生的 Spark 应用调度流程,而 Spark on Kubernetes 对此做了一定的扩展(黄色部分),实现了一个 KubernetesClusterManager。其中 **KubernetesClusterSchedulerBackend 扩展了原生的CoarseGrainedSchedulerBackend,**新增了 **ExecutorPodsLifecycleManager、ExecutorPodsAllocator 和 KubernetesClient **等组件,实现了将标准的 Spark Driver 进程转换成 Kubernetes 的 Pod 进行管理。

                  Spark submit

                  在 Spark Operator 出现之前,在 Kubernetes 集群提交 Spark 作业只能通过 Spark submit 的方式。创建好 Kubernetes 集群,在本地即可提交作业。

                  -

                  image.png

                  +

                  png

                  作业启动的基本流程:

                  • 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 的实现。

                    -

                    image.png

                    +

                    png

                    下图为 SparkApplication 状态机:

                    -

                    image.png

                    +

                    png

                    Serverless Kubernetes + ECI

                    那么,如果在 Serverless Kubernetes 集群中运行 Spark,其实际上是对原生 Spark 的进一步精简。

                    -

                    image.png

                    +

                    png

                    存储选择

                    -

                    image.png

                    +

                    png

                    对于批量处理的数据源,由于集群不是基于 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 简介

                      -

                      image.png

                      +

                      png

                      相较于普通的 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。

                      -

                      image.png

                      +

                      png

                      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() {
                      1. 容器创建完成后,用户容器进程通过上述挂载的动态库文件访问并使用 GPU 资源

                      ECI GPU 使用方式

                      -

                      image.png

                      +

                      png

                      目前在 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(手写数字识别)训练任务为例:

                      -

                      image.png

                      +

                      png

                      该任务由 YAML 文件定义,如上图所示。我们在 YAML 文件中指定了 ECI GPU 实例类型,该实例类型包含一颗 NVIDIA P4 GPU。然后我们指定了容器镜像为 nvcr.io/nvidia/pytorch,该镜像是由 NVIDIA 提供,内部已经封装好了 CUDA/PyTorch 等工具。最后,我们通过 nvidia.com/gpu 指定了要使用的 GPU 数量为 1。

                      如上图所示,在 ASK 集群中,我们选择使用模板创建应用实例,然后在模板中输入右侧 YAML 文件的内容,最后点击创建即可创建一个使用 GPU 的容器了。

                      -

                      image.png

                      +

                      png

                      容器创建完成之后,首先我们通过 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

                      -

                      image.png

                      +

                      png

                      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 应用我们可能会怎么做?

                    -

                    image.png

                    +

                    png

                    首先需要一个 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 的能力。

                    -

                    image.png

                    +

                    png

                    引入 Istio 作为 Gateway 组件,Istio 除了管理同一个应用的流量灰度,还能对不同的应用进行流量管理。看起来很好,但是我们再仔细分析一下存在什么问题。先梳理一下在原生 K8s 之上手动管理 Serverless 应用都需要做什么:

                    • Deployment
                    • @@ -222,43 +222,43 @@ function hide_canvas() {
                    • Gateway

                    这些资源是每一个应用维护一份,如果是多个应用就要维护多份。这些资源散落在 K8s 内,根本看不出来应用的概念,另外管理起来也非常繁琐。

                    -

                    image.png

                    +

                    png

                    Serverless 应用需要的是面向应用的管理动作,比如应用托管、升级、回滚、灰度发布、流量管理以及弹性等功能。而 Kubernetes 提供的是 IaaS 的使用抽象。所以 Kubernetes 和 Serverless 应用之间少了一层应用编排的抽象。

                    而 Knative 就是建立在 Kubernetes 之上的 Serverless 应用编排框架。除了 Knative 以外,社区也有好几款 FaaS 类的编排框架,但这些框架编排出来的应用没有统一的标准,每一个框架都有一套自己的规范,而且和 Kubernetes API 完全不兼容。不兼容的 API 就导致使用难度高、可复制性不强。云原生的一个核心标准就是 Kubernetes 的 API 标准,Knative 管理的 Serverless 应用保持 Kubernetes API 语义不变。和 Kubernetes API 具有良好的兼容性,就是 Knative 的云原生特性所在。

                    Knative 是什么?

                    -

                    image.png

                    +

                    png

                    Knative 主要解决的问题就是在 Kubernetes 之上提供通用的 Serverless 编排、调度服务,给上层的 Serverless 应用提供面向应用层的原子操作。并且通过 Kubernetes 原生 API 暴露服务 API,保持和 Kubernetes 生态工具链的完美融合。Knative 有 Eventing 和 Serving 两个核心模块,本文主要介绍 Serving 的核心架构。

                    Knative Serving 简介

                    -

                    image.png

                    +

                    png

                    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 细节。

                    -

                    image.png

                    +

                    png

                    上图展示了 Knative Autoscaler 的工作机制,Route 负责接入流量,Autoscaler 负责做弹性伸缩。当没有业务请求时会缩容到零,缩容到零后 Route 进来的请求会转到 Activator 上。当第一个请求进来之后 Activator 会保持住 http 链接,然后通知 Autoscaler 去做扩容。Autoscaler 把第一个 pod 扩容完成以后 Activator 就把流量转发到 Pod,从而做到了缩容到零也不会损失流量的目的。

                    到此 Knative Serving 的核心模块和基本原理已经介绍完毕,你应该对 Knative 已经有了初步了解。在介绍原理的过程中你可能也感受到了,要想把 Knative 用起来其实还是需要维护很多 Controller 组件、Gateway 组件(比如 Istio))的,并且要持续地投入 IaaS 成本和运维成本。

                    -

                    image.png

                    +

                    png

                    Gateway 组件假设使用 istio 实现的话,Istio 本身就需要十几个 Controller,如果要做高可用可能就需要二十几个 Controller。Knative Serving Controller 全都高可用部署也需要十几个。这些 Controller 的 IaaS 成本和运维成本都比较多。另外冷启动问题也很明显,虽然缩容到零可以降低业务波谷的成本,但是第一批流量也可能会超时。

                    Knative 和云的完美融合

                    为了解决上述问题,我们把 Knative 和阿里云做了深度的融合。用户还是按照 Knative 的原生语义使用,但底层的 Controller 、Gateway 都深度嵌入到阿里云体系内。这样既保证了用户可以无厂商锁定风险地以 Knative API 使用云资源,还能享受到阿里云基础设施的已有优势。

                    -

                    image.png

                    +

                    png

                    首先是 Gateway 和云的融合,直接使用阿里云 SLB 作为 Gateway,使用云产品 SLB 的好处有:

                    • 云产品级别的支撑,提供 SLA 保障;
                    • 按需付费,不需要出 IaaS 资源;
                    • 用户无需承担运维成本,不用考虑高可用问题,云产品自带高可用能力。
                    -

                    image.png

                    +

                    png

                    除了 Gateway 组件以外,Knative Serving Controller 也需要一定的成本,所以我们把 Knative Serving Controller 和阿里云容器服务也进行了融合。用户只需要拥有一个 Serverless Kubernetes 集群并开通 Knative 功能就可以基于 Knative API 使用云的能力,并且用户无需为 Knative Controller 付出任何成本。

                    -

                    image.png

                    +

                    png

                    接下来再分析一下冷启动问题。

                    传统应用在没开启弹性配置的时候实例数是固定的,Knative 管理的 Serverless 应用默认就有弹性策略,在没有流量的时候会缩容到零。传统应用在流量低谷时即便没有业务请求处理,实例数还保持不变,这其实是浪费资源的。但好处就是请求不会超时,什么时候过来的请求都可以会很好地处理。而如果缩容到零,第一个请求到达以后才会触发扩容的过程。

                    Knative 的模型中从 0 到 1 扩容需要 5 个步骤串行进行,这 5 个步骤都完成以后才能开始处理第一个请求,而此时往往都会超时。所以 Knative 缩容到零虽然降低了常驻资源的成本,但第一批请求的冷启动问题也非常明显。可见弹性其实就是在寻找成本和效率的一个平衡点。

                    -

                    image.png

                    +

                    png

                    为了解决第一个实例的冷启动问题,我们推出了保留实例功能。保留实例是阿里云容器服务 Knative 独有的功能。社区的 Knative 默认在没有流量时缩容到零,但是缩容到零之后从 0 到 1 的冷启动问题很难解决。冷启动除了要解决 IaaS 资源的分配、Kubernetes 的调度、拉镜像等问题以外,还涉及到应用的启动时长。应用启动时长从毫秒到分钟级别都有。应用启动时间完全是业务行为,在底层平台层面几乎无法控制。

                    ASK Knative 对这个问题的解法是通过低价格的保留实例,来平衡成本和冷启动问题。阿里云 ECI 有很多规格,不同规格的计算能力不一样,价格也不一样。如下所示是对 2c4G 配置的计算型实例和突发性能型实例的价格对比。

                    -

                    image.png

                    +

                    png

                    通过上图可知突发性能实例比计算型便宜 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 介绍

                    -

                    1.PNG

                    +

                    png

                    首先,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。

                    -

                    2.png

                    +

                    png

                    这么做的优势有以下几点:

                    • 服务高可用(Deployment+PVC);
                    • @@ -220,15 +220,15 @@ function hide_canvas() { -

                      image.png

                      +

                      png

                      • 集群创建完成后,基本信息中有 API server 公网链接地址
                      -

                      image.png

                      +

                      png

                      • 连接信息中有 ASK 集群访问凭证
                      -

                      image.png

                      +

                      png

                      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
                       
                      -

                      image.png

                      +

                      png

                      7. 进行一个简单的 CI 任务

                      -

                      image.png

                      +

                      png

                      git repo 中的 .gitlab-ci.yml 类似 Jenkinsfile,定义了构建任务的工作流。我们修改 demo 项目中的 src/main/webapp/index.jsp 文件,然后 git commit -m "change index info" 提交。 gitlab 中的流水线任务即被触发,整个流程涉及到编译、打包、部署。

                      -

                      image.png

                      -

                      image.png

                      +

                      png

                      +

                      png

                      成本

                      使用 ASK 与一台预付费 ECS 的成本对比:

                      -

                      image.png

                      +

                      png

                      从上述成本计算可以看出,当您每天的 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),是企业在线业务平滑上云的最佳选择。

                -

                2.PNG

                +

                png

                SAE 提供了成本更优、效率更高的应用托管方案。底层基于统一的 K8s 技术底座,帮用户屏蔽复杂的 IaaS 层和 K8s 集群运维,提供计算资源、弹性、隔离性等能力,用户只需关心应用实例的规格和实例数。

                在应用层,除提供了生命周期管理、多发布策略外,还提供监控、日志、微服务治理能力,解决应用可观测性和治理需求。同时提供一键启停、应用编排等高级能力,进一步提效和降本。核心场景主要面向在线应用:微服务应用、Web 应用、多语言应用等。

                在开发者工具方面,和 CI/CD 工具做了良好的集成,无论是 Jenkins 还是云效,都能直接部署应用到 SAE,也可以通过 Cloud Toolkit 插件工具实现本地一键部署应用到云端,可以说 SAE 覆盖了应用上云的完整场景。

                -

                3.PNG image.gif

                +

                png png

                SAE 除了 Serverless 体验本身所带来的极致弹性、免运维、省成本等特性之外,重点在应用层给用户提供了全栈的能力,包括对微服务的增强支持,以及整合了和应用息息相关能力,包括配置、监控、日志、流量控制等。再加上用户零代码的改造,这也是 SAE 区别其它 Serveless 产品的重要优势,平滑迁移企业在线应用。

                -

                4.PNG

                +

                png

                SAE 有几个典型的使用场景:一个是存量业务上云,特别是微服务、Java 应用,同时也支持其他语言的单体应用快速上云/搬站,满足极致交付效率和开箱即用的一站式体验。在行业方面,SAE 特别适合有比较大的流量波动的在线业务,比如电商大促、在线教育等行业的场景。另外 SAE 作为应用 PaaS 也可以被上层的行业 SaaS 所集成,帮助用户更快地构建行业 SaaS。

                产品核心指标

                -

                5.PNG

                +

                png

                SAE 三个核心的指标:容器启动时长 20s(指标定义是从 pull image 到容器启动的耗时,不包括应用启动时间),接下来我们会通过各种技术优化把它优化到 5s 内,保证用户在突发场景下的快速扩容效率。最小规格支持 0.5core 1GiB,满足更细粒度的资源诉求。相比 ECS,SAE 部署一套开发测试环境的成本可以节省 47%~57%。

                最佳实践

                通过前文介绍, 我们了解了产品的特性、优势、适用场景,最后给大家详细介绍几个 Serverless 落地的最佳实践案例。

                1. 低门槛微服务架构转型的解决方案

                -

                image.gif 6.PNG

                +

                png png

                随着业务的快速增长,很多企业都面临单体向微服务架构改造转型的难题,或者开源自建的微服务框架(Spring Cloud / Dubbo)能力不再能满足企业稳定性和多样化的需求。通过 SAE 提供开箱即用的微服务能力和稳定性兜底能力,已让这些企业低门槛快速完成微服务架构转型,支撑新业务快速上线,让企业专注于业务本身。

                可以说,SAE 是 Serverless 行业最佳的微服务实践,同时也是微服务行业最佳的 Serverless 实践。

                2. 免运维、一键启停开发测试环境的降本方案

                -

                7.PNGimage.gif

                +

                pngpng

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

                3. 精准容量、极致弹性的解决方案

                -

                image.gif8.PNG

                +

                pngpng

                电商类、安防行业等往往会有一些不可预期的突发流量高峰,之前他们都是提前预估峰值,按照峰值保有 ECS 资源,但经常出现容量预估不准(资源浪费 or 不足),更严重的甚至会影响系统的 SLA。

                采用压测工具 + SAE 的方案后,根据压测结果精准设置弹性策略期望值,然后和实时的监控指标比对,系统自动进行扩缩操作,再也无需容量规划,并且弹性效率能做到秒级,轻松应对峰值大考。

                4. 构建高效闭环的 DevOps 体系

                -

                9.PNG

                +

                png

                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() {

                img

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

                2. SAE 应用部署方式

                -

                image.gif2.jpg

                +

                pngpng

                在使用 SAE 时,您可以在控制台上看到 SAE 支持三种部署方式,即可以通过 WAR 包、JAR 包和镜像的方式进行部署,如果您采用 Spring Cloud、Dubbo、HSF 这类应用,可以直接打包上传,或者填入包的地址便可以部署到 SAE 上;对于非 Java 语言的场景,您也可以使用镜像直接来部署,后续我们也会支持其他语言直接上传包的形式进行部署。

                SAE 除上述控制台界面部署的方式之外,还支持通过 Maven 插件或者 IDE 插件的方式进行部署,这样您无需登录控制台,就可以执行自动化部署操作,同时可以集成如云效、Jenkins 等工具实现 CICD 流程。

                Maven 插件部署

                -

                3.jpg

                +

                png

                如何使用 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 应用的落地实践

                  背景知识

                  -

                  1.png

                  +

                  png

                  通过以往几节课程的学习,相信大家对于 SAE 平台已经有了一定的了解。SAE 为客户免除了很多复杂的运维工作,开箱即用、按用量付费;与此同时 SAE 提供了丰富的 Open API,可以很容易地与其他平台做集成;类似云效以及 Jenkins 的 CI/CD 工具是敏捷软件研发实践中的重要一环,可以自动化地将客户的代码编译、测试、打包并部署至各个环境,从而提升团队的研发效率。

                  本篇文章分为两个部分,首先介绍使用云效平台实现从源码到 SAE 环境的持续集成,然后介绍使用 Jenkins 的情况下持续集成该如何配置。

                  使用云效部署到 SAE

                  云效(rdc.console.aliyun.com),是阿里云推出的企业级一站式 Devops 平台型产品,功能覆盖了从【需求->开发->测试->发布->运维->运营】全流程。对云效感兴趣的同学可以去【阿里云官网】搜索【云效】,本文只介绍与 CI/CD 相关的部分功能。

                  -

                  2.PNG

                  +

                  png

                  如上图所示,图的上半部分是我们的配置流程,下半部分的流程图是我们所要执行的持续集成流程的示例。云效首先会从代码仓库中拉取相应的代码,然后进行代码检查以及单元测试,接着是代码编译构建,这一步会产出相应的生成物:在这里我们用一个 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。

                  -

                  3.PNG

                  +

                  png

                  操作步骤:

                  • 代码库中有相应的打包配置,在使用 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 应用引擎产品的流量负载均衡和路由策略配置实践

                    流量管理从面向实例到面向应用

                    -

                    image.png

                    +

                    png

                    在 Serverless 场景下,由于弹性能力以及底层计算实例易变的特性,后端应用实例需要频繁上下线,传统的 ECS 场景下的负载均衡管理方式不再适用。

                    SAE 产品提供给用户面向应用的流量管理方式,不再需要关心弹性场景以及发布场景的实例上下线,仅仅需要关心监听的配置以及应用实例的健康检查探针,将面向实例的复杂配置工作交给 SAE 产品。

                    单应用的负载均衡配置

                    -

                    image.png

                    +

                    png

                    对于单个应用,SAE 产品支持将应用服务通过公网或私网 SLB 实例监听暴露,目前支持仅支持 TCP 协议。考虑到传统的 HTTP 类型应用存在 HTTPS 改造的需求,SAE 还支持配置 HTTPS 监听,让 HTTP 服务器无需修改就能够对外提供 HTTPS 服务。

                    公网 SLB 用于互联网客户端访问,会同时产生规格费与流量费用;私网 SLB 用于 VPC 内客户端访问,会产生规格费用。

                    为了让 SAE 产品能够准确控制实例上下线时机,用户需要在部署时正确地配置探针,避免业务出现损失。

                    多应用的路由策略配置

                    -

                    image.png

                    +

                    png

                    大中型企业在实践中,常常会将业务拆分成不同的应用或者服务,例如将登陆服务、账单服务等关联度较高的部分,单独拆分为应用,独立进行研发以及运维,再对外通过统一的网关服务进行暴露,对用户来说就像使用单体应用一样。

                    SAE 提供基于 SLB 实例的网关,将流量按照域名以及 HTTP Path 转发到不同的应用的实例上,从功能上对标业界的 Nginx 网关。

                    公网 SLB 实例实现的网关用于互联网客户端访问,会同时产生规格费与流量费用;私网 SLB 实例实现的网关用于 VPC 内客户端访问,会产生规格费用。

                    自建微服务网关

                    -

                    image.png

                    +

                    png

                    对于微服务场景中常见的微服务网关,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 架构

                    背景

                    -

                    image.png

                    +

                    png

                    通过前面几节课程的学习,相信大家对于 SAE 平台已经有了一定的了解,SAE 基于 IaaS 层资源构建的一款 Serverles 应用托管产品,免除了客户很多复杂的运维工作,开箱即用、按用量付费;并且提供了丰富的 Open API 可以很容易地与其他平台做集成。

                    本文将为大家介绍 SAE 在微服务方面的一些能力,SAE 产品把 Serverless 技术和微服务做了很好的结合,天然支持 Java 微服务应用的托管和服务治理,对 SpringCloud/Dubbo 微服务应用能够在只修改配置和依赖,不修改代码的情况下迁移到 SAE 上,并提供服务治理能力,比如基于租户的微服务隔离环境、服务列表、无损下线、离群摘除、应用监控以及调用链分析等。

                    本次课程分为三部分来介绍,分别介绍微服务应用迁移到 SAE 的优势,如何迁移 SpringCloud/Dubbo 应用到 SAE 上,以及针对 SpringCloud 应用迁移的实践演示。

                    迁移到 SAE 的优势

                    -

                    image.png

                    +

                    png

                    在介绍迁移之前,先介绍下 SpringCloud/Dubbo 应用迁移到 SAE 的优势:

                    • **SAE 内置注册中心:**所有用户共享注册中心组件,SAE 帮助用户运维,这就节省了用户的部署、运维成本;在服务注册和发现的过程中进行链路加密,无需担心被未授权的服务发现。
                    • @@ -222,14 +222,14 @@ function hide_canvas() {

                    SpringCloud/Dubbo 迁移方案

                    那如何迁移 SpringCloud/Dubbo 应用到 SAE 呢?我们只需要修改添加依赖和配置,就可以把应用部署到 SAE 上。

                    -

                    image.png

                    +

                    png

                    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 接入服务注册与发现的方式基本一致。

                    -

                    image.png

                    +

                    png

                    接下来针对 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() {

                  有时候,我们把发版安排在凌晨两三点,赶在业务流量比较小的时候,心惊胆颤、睡眠不足、苦不堪言。那如何解决上面的问题,如何保证应用发布过程稳定、高效,保证业务无损呢?首先,我们来梳理下造成这些问题的原因。

                  场景分析

                  -

                  image.png

                  +

                  png

                  上图描述了我们使用微服务架构开发应用的一个常见场景,我们先看下这个场景的服务调用关系:

                  • 服务 B、C 把服务注册到注册中心,服务 A、B 从注册中心发现需要调用的服务;
                  • @@ -200,14 +200,14 @@ function hide_canvas() {

                    当服务 A 发布的时候,服务 A1 实例停机后,SLB 根据健康检查探测到服务 A1 下线,然后把实例从 SLB 摘掉。实例 A1 依赖 SLB 的健康检查从 SLB 上摘掉,一般需要几秒到十几秒的时间,在这个过程中,如果 SLB 有持续的流量打入,就会造成一些请求继续路由到实例 A1,导致请求失败;

                    服务 A 在发布的过程中,如何保证经过 SLB 的流量不报错?我们接着看下 SAE 是如何做的。

                    南北向流量优雅升级方案

                    -

                    image.png

                    +

                    png

                    如上文所提,请求失败的原因在于后端服务实例先停止掉,然后才从 SLB 摘掉,那我们是不是可以先从 SLB 摘掉服务实例,然后再对实例进行升级呢?

                    按照这个思路,SAE 基于 K8S service 的能力给出了一种方案,当用户在通过 SAE 为应用绑定 SLB 时,SAE 会在集群中创建一个 service 资源,并把应用的实例和 service 关联,CCM 组件会负责 SLB 的购买、SLB 虚拟服务器组的创建,并且把应用实例关联的 ENI 网卡添加到虚拟服务器组中,用户可以通过 SLB 来访问应用实例;当应用发布时,CCM 会先把实例对应的 ENI 从虚拟服务器组中摘除,然后再对实例进行升级,从而保证流量不丢失。

                    这就是 SAE 对于应用升级过程中关于南北向流量的保障方案。

                    东西向流量

                    东西向流量存在问题

                    在讨论完南北向流量的解决方案后,我们再看下东西向流量,传统的发布流程中,服务提供者停止再启动,服务消费者感知到服务提供者节点停止的流程如下:

                    -

                    image.png

                    +

                    png

                    1. 服务发布前,消费者根据负载均衡规则调用服务提供者,业务正常。
                    2. 服务提供者 B 需要发布新版本,先对其中的一个节点进行操作,首先是停止 java 进程。
                    3. @@ -220,7 +220,7 @@ function hide_canvas() {

                    从第 2 步到第 6 步的过程中,Eureka 在最差的情况下需要耗时 2 分钟,Nacos 在最差的情况下需要耗时 50 秒。在这段时间内,请求都有可能出现问题,所以发布时会出现各种报错,同时还影响用户的体验,发布后又需要修复执行到一半的脏数据。最后不得不每次发版都安排在凌晨两三点发布,心惊胆颤,睡眠不足,苦不堪言。

                    东西向流量优雅升级方案

                    -

                    image.png

                    +

                    png

                    经过上文的分析,我们看,在传统发布流程中,客户端有一个服务调用报错期,原因就是客户端没有及时感知到服务端下线的实例。在传统发布流程中,主要是借助注册中心通知消费者来更新服务提供者列表,那能不能绕过注册中心,服务提供者直接通知服务消费者呢?答案是肯定的,我们主要做了两件事情:

                    1. 服务提供者应用在发布前后主动向注册中心注销应用,并将应用标记为已下线的状态;将原来的停止进程阶段注销服务变成了 prestop 阶段注销服务。
                    2. @@ -230,10 +230,10 @@ function hide_canvas() {

                      分批发布和灰度发布

                      上文介绍的是 SAE 在处理优雅下线方面的一些能力,在应用升级的过程中,只有实例的优雅下线是不够的,还需要有一套配套的发布策略,保证我们新业务是可用的,SAE 提供分批发布和灰度发布的能力,可以使得应用的发布过程更加省心省力;

                      我们先介绍下灰度发布,某应用包含 10 个应用实例,每个应用实例的部署版本为 Ver.1 版本,现需将每个应用实例升级为 Ver.2 版本。

                      -

                      image.png

                      +

                      png

                      从图中可以看出,在发布的过程中先灰度 2 台实例,在确认业务正常后,再分批发布剩余的实例,发布的过程中始终有实例处于运行状态,实例升级过程中依照上面的方案,每个实例都有优雅下线的过程,这就保证了业务无损。

                      再来看下分批发布,分批发布支持手动、自动分批;还是上面的 10 个应用实例,假设将所有应用实例分 3 批进行部署,根据分批发布策略,该发布流程如图所示,就不再具体介绍了。

                      -

                      image.png

                      +

                      png

                      最后针对在 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 资源,再开始创建容器执行环境和应用实例。

              -

              image.png

              +

              png

              在这个过程中,涉及到调度、云资源创建和挂载、镜像拉取、容器环境创建、应用进程创建等步骤,应用的创建效率与这些过程紧密相关。

              我们很自然而然地能想到,这其中部分过程是否能并行,以减少整个创建的耗时呢?经过对每个过程的耗时分析,我们发现其中的一些瓶颈点,并且部分执行步骤之间是解耦独立的,比如云弹性网卡的创建挂载和应用镜像拉取,就是相互独立的过程。基于此,我们将其中独立的过程做了并行化处理,在不影响创建链路的同时,降低了应用创建的时耗。

              应用部署

              @@ -199,7 +199,7 @@ function hide_canvas() {

              摘流,将运行实例从 SLB 后端摘除 -> 原地升级实例 -> 接入流量

              原地升级后,应用实例仍保持原来的 ip。经过测试,对于 2 实例应用,部署效率将提升 4 倍,将部署时长从原来的将近 1 分钟缩短到十几秒。

              -

              image.png

              +

              png

              应用重启

              最后,简单介绍下我们即将推出的原地重启功能

              重启实例在某些运维场合是必要的操作,说到应用重启,我们希望类似于 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 恰能帮助你解决这个痛点。

              你为什么需要学习这个课程?

              但凡涉及海量数据处理的企业,就一定要用到分库分表。如何进行海量数据的分库分表设计和迁移,有效存储和访问海量业务数据,已经成为很多架构师和开发人员需要规划和落实的一大课题,也成为像拼多多、趣头条、爱库存等很多优质公司高薪诚聘的岗位需求。

              -

              image

              -

              image

              -

              image

              +

              png

              +

              png

              +

              png

              但优质人才非常短缺,一是因为从事海量数据处理需要相应的应用场景和较高的技术门槛,二是业界也缺乏成熟的框架来完成实际需求。掌握诸如 ShardingSphere 这样的主流分库分表和分布式数据库中间件框架的技术人员也成了各大公司争抢的对象。

              鉴于市面上还没有对 ShardingSphere 进行系统化介绍的内容,我希望能来弥补这个空白。此外,分库分表概念虽然比较简单,但在实际开发过程中要落地却也不容易,也需要一个系统的、由浅入深的学习过程。

              课程设计

              @@ -245,7 +245,7 @@ function hide_canvas() {

              帮你理解 ShardingSphere 的核心功能特性,来满足日常开发工作所需,同时基于源码给出这些功能的设计原理和实现机制。

              2. 学习优秀的开源框架,提高技术理解与应用能力

              技术原理是具有相通性的。以 ZooKeeper 这个分布式协调框架为例,ShardingSphere 和 Dubbo 中都使用它来完成了注册中心的构建:

              -

              image

              +

              png

              在 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() {

              分库分表的表现形式也有很多种,一起来看一下。

              分库分表的表现形式

              分库分表包括分库和分表两个维度,在开发过程中,对于每个维度都可以采用两种拆分思路,即垂直拆分水平拆分

              -

              image

              +

              png

              先来讨论垂直拆分的应用方式,相比水平拆分,垂直拆分相对比较容易理解和实现。在电商系统中,用户在打开首页时,往往会加载一些用户性别、地理位置等基础数据。对于用户表而言,这些位于首页的基础数据访问频率显然要比用户头像等数据更高。基于这两种数据的不同访问特性,可以把用户单表进行拆分,将访问频次低的用户头像等信息单独存放在一张表中,把访问频次较高的用户信息单独放在另一张表中:

              -

              image

              +

              png

              从这里可以看到,垂直分表的处理方式就是将一个表按照字段分成多张表,每个表存储其中一部分字段。 在实现上,我们通常会把头像等 blob 类型的大字段数据或热度较低的数据放在一张独立的表中,将经常需要组合查询的列放在一张表中,这也可以认为是分表操作的一种表现形式。

              通过垂直分表能得到一定程度的性能提升,但数据毕竟仍然位于同一个数据库中,也就是把操作范围限制在一台服务器上,每个表还是会竞争同一台服务器中的 CPU、内存、网络 IO 等资源。基于这个考虑,在有了垂直分表之后,就可以进一步引入垂直分库。

              对于前面介绍的场景,分表之后的用户信息同样还是跟其他的商品、订单信息存放在同一台服务器中。基于垂直分库思想,这时候就可以把用户相关的数据表单独拆分出来,放在一个独立的数据库中。

              -

              image

              +

              png

              这样的效果就是垂直分库。从定义上讲,垂直分库是指按照业务将表进行分类,然后分布到不同的数据库上。然后,每个库可以位于不同的服务器上,其核心理念是专库专用。而从实现上讲,垂直分库很大程度上取决于业务的规划和系统边界的划分。比如说,用户数据的独立拆分就需要考虑到系统用户体系与其他业务模块之间的关联关系,而不是简单地创建一个用户库就可以了。在高并发场景下,垂直分库能够在一定程度上提升 IO 访问效率和数据库连接数,并降低单机硬件资源的瓶颈。

              从前面的分析中我们不难明白,垂直拆分尽管实现起来比较简单,但并不能解决单表数据量过大这一核心问题。所以,现实中我们往往需要在垂直拆分的基础上添加水平拆分机制。例如,可以对用户库中的用户信息按照用户 ID 进行取模,然后分别存储在不同的数据库中,这就是水平分库的常见做法:

              -

              image

              +

              png

              可以看到,水平分库是把同一个表的数据按一定规则拆分到不同的数据库中,每个库同样可以位于不同的服务器上。这种方案往往能解决单库存储量及性能瓶颈问题,但由于同一个表被分配在不同的数据库中,数据的访问需要额外的路由工作,因此大大提升了系统复杂度。这里所谓的规则实际上就是一系列的算法,常见的包括:

              • 取模算法,取模的方式有很多,比如前面介绍的按照用户 ID 进行取模,当然也可以通过表的一列或多列字段进行 hash 求值来取模;
              • @@ -233,7 +233,7 @@ function hide_canvas() {
              • 预定义算法,是指事先规划好具体库或表的数量,然后直接路由到指定库或表中。

              按照水平分库的思路,也可以对用户库中的用户表进行水平拆分,效果如下图所示。也就是说,水平分表是在同一个数据库内,把同一个表的数据按一定规则拆到多个表中

              -

              image

              +

              png

              显然,系统的数据存储架构演变到现在已经非常复杂了。与拆分前的单库单表相比,现在面临着一系列具有挑战性的问题,比如:

              • 如何对多数据库进行高效治理?
              • @@ -247,10 +247,10 @@ function hide_canvas() {

                如果没有很好的工具来支持数据的存储和访问,数据一致性将很难得到保障,这就是以 ShardingSphere 为代表的分库分表中间件的价值所在。

                分库分表与读写分离

                说到分库分表,我们不得不介绍另一个解决数据访问瓶颈的技术体系:读写分离,这个技术与数据库主从架构有关。我们知道像 MySQL 这样的数据库提供了完善的主从架构,能够确保主数据库与从数据库之间的数据同步。基于主从架构,就可以按照操作要求对读操作和写操作进行分离,从而提升访问效率。读写分离的基本原理是这样的:

                -

                image

                +

                png

                可以看到图中的数据库集群中存在一个主库,也存在一个从库,主库和从库之间通过同步机制实现两者数据的一致性。在互联网系统中,普遍认为对数据库读操作的频率要远远高于写操作,所以瓶颈往往出现在读操作上。通过读写分离,就可以把读操作分离出来,在独立的从库上进行。现实中的主从架构,主库和从库的数量,尤其从库的数量都是可以根据数据量的大小进行扩充的。

                读写分离,主要解决的就是高并发下的数据库访问,也是一种常用的解决方案。但是跟提升服务器配置一样,并不是终极解决方案。终极的解决方案还是前面介绍的分库分表,按照用户 ID 等规则来拆分库或拆分表。但是,请注意,分库分表与读写分离之间的关系并不是互斥的,而是可以相辅相成的,完全可以在分库分表的基础上引入读写分离机制:

                -

                image

                +

                png

                事实上,本课程所要介绍的 ShardingSphere 就实现了图中的架构方案,在分库分表的同时支持读写分离,在后续的课程中将会介绍如何实现这一过程。

                分库分表解决方案和代表框架

                基于前面关于分库分表的讨论,我们可以抽象其背后的一个核心概念,即分片(Sharding)。无论是分库还是分表,都是把数据划分成不同的数据片,并存储在不同的目标对象中。而具体的分片方式涉及实现分库分表的不同解决方案。

                @@ -258,20 +258,20 @@ function hide_canvas() {

                客户端分片

                所谓客户端分片,相当于在数据库的客户端就实现了分片规则。显然,这种方式将分片处理的工作进行前置,客户端管理和维护着所有的分片逻辑,并决定每次 SQL 执行所对应的目标数据库和数据表。

                客户端分片这一解决方案也有不同的表现形式,其中最为简单的方式就是应用层分片,也就是说在应用程序中直接维护着分片规则和分片逻辑:

                -

                image

                +

                png

                在具体实现上,我们通常会将分片规则的处理逻辑打包成一个公共 JAR 包,其他业务开发人员只需要在代码工程中引入这个 JAR 包即可。针对这种方案,因为没有独立的服务器组件,所以也不需要专门维护某一个具体的中间件。然而,这种直接在业务代码中嵌入分片组件的方法也有明显的缺点:

                • 一方面,由于分片逻辑侵入到了业务代码中,业务开发人员在理解业务的基础上还需要掌握分片规则的处理方式,增加了开发和维护成本;
                • 另一方面,一旦出现问题,也只能依赖业务开发人员通过分析代码来找到原因,而无法把这部分工作抽离出来让专门的中间件团队进行完成。

                基于以上分析,客户端分片在实现上通常会进一步抽象,把分片规则的管理工作从业务代码中剥离出来,形成单独演进的一套体系。这方面典型的设计思路是重写 JDBC 协议,也就是说在 JDBC 协议层面嵌入分片规则。这样,业务开发人员还是使用与 JDBC 规范完全兼容的一套 API 来操作数据库,但这套 API 的背后却自动完成了分片操作,从而实现了对业务代码的零侵入:

                -

                image +

                png 客户端分片结构:重写JDBC协议

                这种解决方案的优势在于,分片操作对于业务而言是完全透明的,从而一定程度上实现业务开发人员与数据库中间件团队在职责上的分离。这样,业务开发人员只需要理解 JDBC 规范就可以完成分库分表,开发难度以及代码维护成本得到降低。

                对于客户端分片,典型的中间件包括阿里巴巴的 TDDL 以及本课程将要介绍的 ShardingSphere。因为 TDDL 并没有开源,所以无法判断客户端分片的具体实现方案。而对于 ShardingSphere 而言,它是重写 JDBC 规范以实现客户端分片的典型实现框架。

                代理服务器分片

                代理服务器分片的解决方案也比较明确,也就是采用了代理机制,在应用层和数据库层之间添加一个代理层。有了代理层之后,就可以把分片规则集中维护在这个代理层中,并对外提供与 JDBC 兼容的 API 给到应用层。这样,应用层的业务开发人员就不用关心具体的分片规则,而只需要完成业务逻辑的实现:

                -

                image

                +

                png

                显然,代理服务器分片的优点在于解放了业务开发人员对分片规则的管理工作,而缺点就是添加了一层代理层,所以天生具有代理机制所带来的一些问题,比方说因为新增了一层网络传输对性能所产生的影响。

                对于代理服务器分片,常见的开源框架有阿里的 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 的发展历史,我们可以得到时间线与阶段性里程碑的演进过程图:

                -

                1.png

                +

                png

                从版本发布角度,我们也可以进一步梳理 ShardingSphere 发展历程中主线版本与核心功能之间的演进关系图:

                -

                2.png

                +

                png

                基于 GitHub 上星数的增长轨迹,也可以从另一个维度很好地反映出 ShardingSphere 的发展历程:

                -

                3.png

                +

                png

                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 规范的兼容性话题,我们将会在下一课时中详细讨论。

                -

                4.png +

                png Sharding-JDBC 与 JDBC 规范的兼容性示意图

                在实际开发过程中,Sharding-JDBC 以 JAR 包的形式提供服务。开发人员可以使用这个 JAR 包直连数据库,无需额外的部署和依赖管理。在应用 Sharding-JDBC 时,需要注意到 Sharding-JDBC 背后依赖的是一套完整而强大的分片引擎:

                -

                5.png

                +

                png

                由于 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 进行使用:

                -

                6.png

                +

                png

                总结一下,我们可以直接把 Sharding-Proxy 视为一个数据库,用来代理后面分库分表的多个数据库,它屏蔽了后端多个数据库的复杂性。同时,也看到 Sharding-Proxy 的运行同样需要依赖于完成分片操作的分片引擎以及用于管理数据库的治理组件。

                虽然 Sharding-JDBC 和 Sharding-Proxy 具有不同的关注点,但事实上,我们完全可以将它们整合在一起进行使用,也就是说这两个组件之间也存在兼容性。

                前面已经介绍过,我们使用 Sharding-JDBC 的方式是在应用程序中直接嵌入 JAR 包,这种方式适合于业务开发人员。而 Sharding-Proxy 提供静态入口以及异构语言的支持,适用于需要对分片数据库进行管理的中间件开发和运维人员。基于底层共通的分片引擎,以及数据库治理功能,可以混合使用 Sharding-JDBC 和 Sharding-Proxy,以便应对不同的应用场景和不同的开发人员:

                -

                7.png

                +

                png

                Sharding-Sidecar

                Sidecar 设计模式受到了越来越多的关注和采用,这个模式的目标是把系统中各种异构的服务组件串联起来,并进行高效的服务治理。ShardingSphere 也基于该模式设计了 Sharding-Sidecar 组件。截止到目前,ShardingSphere 给出了 Sharding-Sidecar 的规划,但还没有提供具体的实现方案,这里不做具体展开。作为 Sidecar 模式的具体实现,我们可以想象 Sharding-Sidecar** 的作用就是以 Sidecar 的形式代理所有对数据库的访问**。这也是一种兼容性的设计思路,通过无中心、零侵入的方案将分布式的数据访问应用与数据库有机串联起来。

                ShardingSphere 的核心功能:从数据分片到编排治理

                @@ -239,7 +239,7 @@ Sharding-JDBC 与 JDBC 规范的兼容性示意图

              • 微内核架构

              ShardingSphere 在设计上采用了微内核(MicroKernel)架构模式,来确保系统具有高度可扩展性。微内核架构包含两部分组件,即内核系统和插件。使用微内核架构对系统进行升级,要做的只是用新插件替换旧插件,而不需要改变整个系统架构:

              -

              8.png

              +

              png

              在 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 规范具有完整的架构体系,如下图所示:

                -

                Drawing 0.png

                +

                png

                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 接口之外,它还有两个子接口:

                -

                Drawing 1.png

                +

                png

                其中,DataSource 是官方定义的获取 Connection 的基础接口,ConnectionPoolDataSource 是从连接池 ConnectionPool 中获取的 Connection 接口。而 XADataSource 则用来实现在分布式事务环境下获取 Connection,我们在讨论 ShardingSphere 的分布式事务时会接触到这个接口。

                请注意,DataSource 接口同时还继承了一个 Wrapper 接口。从接口的命名上看,可以判断该接口应该起到一种包装器的作用,事实上,由于很多数据库供应商提供了超越标准 JDBC API 的扩展功能,所以,Wrapper 接口可以把一个由第三方供应商提供的、非 JDBC 标准的接口包装成标准接口。以 DataSource 接口为例,如果我们想要实现自己的数据源 MyDataSource,就可以提供一个实现了 Wrapper 接口的 MyDataSourceWrapper 类来完成包装和适配:

                -

                Drawing 2.png

                +

                png

                在 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 规范进行数据库访问的开发流程图,如下图所示:

                -

                Drawing 3.png

                +

                png

                ShardingSphere 提供了与 JDBC 规范完全兼容的 API。也就是说,开发人员可以基于这个开发流程和 JDBC 中的核心接口完成分片引擎、数据脱敏等操作,我们来看一下。

                基于适配器模式的 JDBC 重写实现方案

                在 ShardingSphere 中,实现与 JDBC 规范兼容性的基本策略就是采用了设计模式中的适配器模式(Adapter Pattern)。适配器模式通常被用作连接两个不兼容接口之间的桥梁,涉及为某一个接口加入独立的或不兼容的功能。

                作为一套适配 JDBC 规范的实现方案,ShardingSphere 需要对上面介绍的 JDBC API 中的 DataSource、Connection、Statement 及 ResultSet 等核心对象都完成重写。虽然这些对象承载着不同功能,但重写机制应该是共通的,否则就需要对不同对象都实现定制化开发,显然,这不符合我们的设计原则。为此,ShardingSphere 抽象并开发了一套基于适配器模式的实现方案,整体结构是这样的,如下图所示:

                -

                Drawing 4.png

                +

                png

                首先,我们看到这里有一个 JdbcObject 接口,这个接口泛指 JDBC API 中的 DataSource、Connection、Statement 等核心接口。前面提到,这些接口都继承自包装器 Wrapper 接口。ShardingSphere 为这个 Wrapper 接口提供了一个实现类 WrapperAdapter,这点在图中得到了展示。在 ShardingSphere 代码工程 sharding-jdbc-core 的 org.apache.shardingsphere.shardingjdbc.jdbc.adapter 包中包含了所有与 Adapter 相关的实现类:

                -

                Drawing 5.png

                +

                png

                在 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 对这些方法都进行了重写,如下图所示:

                -

                Drawing 6.png +

                png ShardingConnection 中的方法列表图

                ShardingConnection 类的一条类层结构支线就是适配器模式的具体应用,这部分内容的类层结构与前面介绍的重写机制的类层结构是完全一致的,如下图所示:

                -

                111.jpeg

                +

                png

                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 提供了多个维度的应用方式,我们可以对这些应用方式进行抽象,从而提炼出一种模版。这个模版由四个维度组成,分别是底层工具、基础规范、开发框架和领域框架,如下图所示:

                -

                2.png

                +

                png

                底层工具

                底层工具指的是这个开源框架所面向的目标工具或所依赖的第三方工具。这种底层工具往往不是框架本身可以控制和管理的,框架的作用只是在它上面添加一个应用层,用于封装对这些底层工具的使用方式。

                对于 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:

                -

                1.png

                +

                png

                接下来,让我们来看一下如何基于 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}"表达式最终会解析成这样一种结果:

                -

                image

                +

                png

                当然,类似场景也可以使用枚举的方式来列举所有可能值。行表达式也提供了 ${[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 中所需要配置的规则比较多,我们可以通过一张图例来进行简单说明,在这张图中,我们列举了每个配置项的名称、类型以及个数关系:

                -

                image

                +

                png

                这里引入了一些新的概念,包括绑定表、广播表等,这些概念在下一课时介绍到 ShardingSphere 的分库分表操作时都会详细展开,这里不做具体介绍。事实上,对于 ShardingRuleConfiguration 而言,必须要设置的只有一个配置项,即 TableRuleConfiguration。

                TableRuleConfiguration

                从命名上看,TableRuleConfiguration 是表分片规则配置,但事实上,这个类同时包含了对分库和分表两种场景的设置。TableRuleConfiguration 包含很多重要的配置项:

                @@ -235,7 +235,7 @@ function hide_canvas() {

                keyGeneratorConfig 代表分布式环境下的自增列生成器配置,ShardingSphere 中集成了雪花算法等分布式 ID 的生成器实现。

                ShardingStrategyConfiguration

                我们注意到,databaseShardingStrategyConfig 和 tableShardingStrategyConfig 的类型都是一个 ShardingStrategyConfiguration 对象。在 ShardingSphere 中,ShardingStrategyConfiguration 实际上是一个空接口,存在一系列的实现类,其中的每个实现类都代表一种分片策略:

                -

                3.png +

                png  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 的构建。这里的调用关系有点复杂,我们来梳理整个过程的类层结构,如下图所示:

                -

                image

                +

                png

                显然,这里引入了两个新的工具类,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 事务提供基于两阶段提交协议的实现机制。所谓两阶段提交,顾名思义分成两个阶段,一个是准备阶段,一个是执行阶段。在准备阶段中,协调者发起一个提议,分别询问各参与者是否接受。在执行阶段,协调者根据参与者的反馈,提交或终止事务。如果参与者全部同意则提交,只要有一个参与者不同意就终止。

        -

        Drawing 0.png

        +

        png

        两阶段提交示意图

        目前,业界在实现 XA 事务时也存在一些主流工具库,包括 Atomikos、Narayana 和 Bitronix。ShardingSphere 对这三种工具库都进行了集成,并默认使用 Atomikos 来完成两阶段提交。

        BASE 事务

        @@ -386,14 +386,14 @@ public void insert(){ }

        现在让我们执行这个 processWithXA 方法,看看数据是否已经按照分库的配置写入到目标数据库表中。下面是 ds0 中的 health_record 表和 health_task 表:

        -

        Drawing 2.png

        +

        png

        ds0 中的 health_record 表

        -

        Drawing 3.png

        +

        png

        ds0 中的 health_task 表

        下面则是 ds1 中的 health_record 表和 health_task 表: -Drawing 4.png

        +png

        ds1 中的 health_record 表

        -

        Drawing 5.png

        +

        png

        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 目录下的文件组织形式应该是这样:

        -

        Drawing 6.png

        +

        png

        当然,这里我们还是继续沿用前面介绍的分库配置。

        实现 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 如何抽象数据脱敏?

        数据脱敏从概念上讲比较容易理解,但在具体实现过程中存在很多方案。在介绍基于数据脱敏的具体开发过程之前,我们有必要先来梳理实现数据脱敏的抽象过程。这里,我将从敏感数据的存储方式、敏感数据的加解密过程以及在业务代码中嵌入加解密的过程这三个维度来抽象数据脱敏。

        -

        Drawing 0.png

        +

        png

        针对每一个维度,我也将基于 ShardingSphere 给出这个框架的具体抽象过程,从而方便你理解使用它的方法和技巧,让我们来一起看一下。

        敏感数据如何存储?

        关于这个问题,要讨论的点在于是否需要将敏感数据以明文形式存储在数据库中。这个问题的答案并不是绝对的。

        @@ -210,7 +210,7 @@ function hide_canvas() {

        但对于用户姓名、手机号等信息,由于统计分析等方面的需要,显然我们不能直接采用不可逆的加密算法对其进行加密,还需要将明文信息进行处理**。**一种常见的处理方式是将一个字段用两列来进行保存,一列保存明文,一列保存密文,这就是第二种情况。

        显然,我们可以将第一种情况看作是第二种情况的特例。也就是说,在第一种情况中没有明文列,只有密文列。

        ShardingSphere 同样基于这两种情况进行了抽象,它将这里的明文列命名为 plainColumn,而将密文列命名为 cipherColumn。其中 plainColumn 属于选填,而 cipherColumn 则是必填。同时,ShardingSphere 还提出了一个逻辑列 logicColumn 的概念,该列代表一种虚拟列,只面向开发人员进行编程使用:

        -

        Drawing 2.png

        +

        png

        敏感数据如何加解密?

        数据脱敏本质上就是一种加解密技术应用场景,自然少不了对各种加解密算法和技术的封装。传统的加解密方式有两种,一种是对称加密,常见的包括 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 这个逻辑列与数据库表进行交互:

        -

        Drawing 4.png

        +

        png

        针对这个交互过程,我们希望存在一种机制,能够自动将 user_name 逻辑列映射到 user_name_plain 和 user_name_cipher 列。同时,我们希望提供一种配置机制,能够让开发人员根据需要灵活指定脱敏过程中所采用的各种加解密算法。

        作为一款优秀的开源框架,ShardingSphere 就提供了这样一种机制。那么它是如何做到这一点呢?

        首先,ShardingSphere 通过对从应用程序传入的 SQL 进行解析,并依据开发人员提供的脱敏配置对 SQL 进行改写,从而实现对明文数据的自动加密,并将加密后的密文数据存储到数据库中。当我们查询数据时,它又从数据库中取出密文数据,并自动对其解密,最终将解密后的明文数据返回给用户。ShardingSphere 提供了自动化+透明化的数据脱敏过程,业务开发人员可以像使用普通数据那样使用脱敏数据,而不需要关注数据脱敏的实现细节。

        @@ -322,7 +322,7 @@ private final Map<String, EncryptTableRuleConfiguration> tables; }

        作为总结,我们通过一张图罗列出各个配置类之间的关系,以及数据脱敏所需要配置的各项内容:

        -

        Drawing 6.png

        +

        png

        现在回到代码,为了实现数据脱敏,我们首先需要定义一个数据源,这里命名为 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
         

        执行数据脱敏

        现在,配置工作一切就绪,我们来执行测试用例。首先执行数据插入操作,下图数据表中对应字段存储的就是加密后的密文数据:

        -

        Drawing 8.png +

        png 加密后的表数据结果

        在这个过程中,ShardingSphere 会把原始的 SQL 语句转换为用于数据脱敏的目标语句:

        -

        Drawing 9.png +

        png 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 中也都得到了支持。在单块系统中,配置文件能够满足需求,围绕配置文件展开的配置管理工作通常不会有太大挑战。但在分布式系统中,越来越多的运行时实例使得散落的配置难于管理,并且,配置不同步导致的问题十分严重。将配置集中于配置中心,可以更加有效地进行管理。

        采用配置中心也就意味着采用集中式配置管理的设计思想。在集中式配置中心内,开发、测试和生产等不同的环境配置信息统一保存在配置中心内,这是一个维度。另一个维度就是需要确保分布式集群中同一类服务的所有服务实例保存同一份配置文件并且能够同步更新。配置中心的示意图如下所示:

        -

        1.png +

        png 集中式配置管理的设计思想

        在 ShardingSphere 中,提供了多种配置中心的实现方案,包括主流的 ZooKeeeper、Etcd、Apollo 和 Nacos。开发人员也可以根据需要实现自己的配置中心并通过 SPI 机制加载到 ShardingSphere 运行时环境中。

        另一方面,配置信息不是一成不变的。对修改后的配置信息的统一分发,是配置中心可以提供的另一个重要能力。配置中心中配置信息的任何变化都可以实时同步到各个服务实例中。在 ShardingSphere 中,通过配置中心可以支持数据源、数据表、分片以及读写分离策略的动态切换。

        @@ -214,7 +214,7 @@ function hide_canvas() {

        ShardingSphere 中的注册中心

        在实现方式上,注册中心与配置中心非常类似,ShardingSphere 也提供了基于 ZooKeeeper 和 Etcd 这两款第三方工具的注册中心实现方案,而 ZooKeeeper 和 Etcd 同样也可以被用作配置中心。

        注册中心与配置中心的不同之处在于两者保存的数据类型。配置中心管理的显然是配置数据,但注册中心存放的是 ShardingSphere 运行时的各种动态/临时状态数据,最典型的运行时状态数据就是当前的 Datasource 实例。那么,保存这些动态和临时状态数据有什么用呢?我们来看一下这张图:

        -

        2.png +

        png 注册中心的数据存储和监听机制示意图

        注册中心一般都提供了分布式协调机制。在注册中心中,所有 DataSource 在指定路径根目录下创建临时节点,所有访问这些 DataSource 的业务服务都会监听该目录。当有新 DataSource 加入时,注册中心实时通知到所有业务服务,由业务服务做相应路由信息维护;而当某个 DataSource 宕机时,业务服务通过监听机制同样会收到通知。

        基于这种机制,我们就可以提供针对 DataSource 的治理能力,包括熔断对某一个 DataSource 的数据访问,或禁用对从库 DataSource 的访问等。

        @@ -323,10 +323,10 @@ spring.shardingsphere.orchestration.registry.namespace=orchestration-health_ms
        
         

        同时,ZooKeeper 服务器端也对来自应用程序的请求作出响应。我们可以使用一些 ZooKeeper 可视化客户端工具来观察目前服务器上的数据。这里,我使用了 ZooInspector 这款工具,由于 ZooKeeper 本质上就是树状结构,~~现在~~所以在根节点中就新增了配置信息:

        -

        3.png +

        png ZooKeeper 中的配置节点图

        我们关注“config”段内容,其中“rule”节点包含了读写分离的规则设置:

        -

        4.png +

        png 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 余个:

        -

        Drawing 0.png +

        png ShardingSphere 源码一级代码结构目录

        如何快速把握 ShardingSphere 的代码结构呢?这是我们剖析源码时需要回答的第一个问题,为此我们需要梳理剖析 ShardingSphere 框架代码结构的系统方法。

        本课时我们将对如何系统剖析 ShardingSphere 代码结构这一话题进行抽象,梳理出应对这一问题的六大系统方法(如下图):

        -

        Drawing 1.png

        +

        png

        接下来,我们将结合 ShardingSphere 框架对这些方法进行展开。

        基于可扩展性设计阅读源码

        ShardingSphere 在设计上采用了微内核架构模式来确保系统具有高度的可扩展性,并使用了 JDK 提供的 SPI 机制来具体实现微内核架构。在 ShardingSphere 源代码的根目录下,存在一个独立工程 shardingsphere-spi。显然,从命名上看,这个工程中应该包含了 ShardingSphere 实现 SPI 的相关代码。该工程中存在一个 TypeBasedSPI 接口,它的类层结构比较丰富,课程后面将要讲到的很多核心接口都继承了该接口,包括实现配置中心的 ConfigCenter、注册中心的 RegistryCenter 等,如下所示:

        -

        Drawing 3.png +

        png 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 类,分包结构如下所示:

        -

        Drawing 4.png

        +

        png

        图中我们可以看到两个清晰的代码结构层次关系,这是 ShardingSphere 中普遍采用的分包原则中,具有代表性的一种,即根据类的所属层级来组织包结构。

        基于基础开发规范阅读源码

        对于 ShardingSphere 而言,在梳理它的代码结构时有一个非常好的切入点,那就是基于 JDBC 规范。我们知道 ShardingSphere 在设计上一开始就完全兼容 JDBC 规范,它对外暴露的一套分片操作接口与 JDBC 规范中所提供的接口完全一致。只要掌握了 JDBC 中关于 DataSource、Connection、Statement 等核心接口的使用方式,就可以非常容易地把握 ShardingSphere 中暴露给开发人员的代码入口,进而把握整个框架的代码结构。

        @@ -234,13 +234,13 @@ ShardingSphere 中 TypeBasedSPI 接口的类层结构

        通过这个工厂类,我们很容易就找到了创建支持分片机制的 DataSource 入口,从而引出其背后的 ShardingConnection、ShardingStatement 等类。

        事实上,在 ShardingSphere 中存在一批 DataSourceFactory 工厂类以及对应的 DataSource 类:

        -

        Drawing 6.png

        +

        png

        在阅读 ShardingSphere 源码时,JDBC 规范所提供的核心接口及其实现类,为我们高效梳理代码入口和组织方式提供了一种途径。

        基于核心执行流程阅读源码

        事实上,还有一个比较容易理解和把握的方法可以帮我们梳理代码结构,这就是代码的执行流程。任何系统行为都可以认为是流程的组合。通过分析,看似复杂的代码结构一般都能梳理出一条贯穿全局的主流程。只要我们抓住这条主流程,就能把握框架的整体代码结构。

        那么,对于 ShardingSphere 框架而言,什么才是它的主流程呢?这个问题其实不难回答。事实上,JDBC 规范为我们实现数据存储和访问提供了基本的开发流程。我们可以从 DataSource 入手,逐步引入 Connection、Statement 等对象,并完成 SQL 执行的主流程。这是从框架提供的核心功能角度梳理的一种主流程。

        对于框架内部的代码组织结构而言,实际上也存在着核心流程的概念。最典型的就是 ShardingSphere 的分片引擎结构,整个分片引擎执行流程可以非常清晰的分成五个组成部分,分别是解析引擎、路由引擎、改写引擎、执行引擎和归并引擎

        -

        Drawing 8.png

        +

        png

        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,我们就能更好地把握代码的设计思想和实现原理:

        -

        Drawing 10.png

        +

        png

        关于数据脱敏以及装饰器模式的具体实现细节我们会在《数据脱敏:如何基于改写引擎实现低侵入性数据脱敏方案?》中进行详细展开。

        基于通用外部组件阅读源码

        在《开篇寄语:如何正确学习一款分库分表开源框架?》中,我们提出了一种观点,即技术原理存在相通性。这点同样可以帮助我们更好地阅读 ShardingSphere 源码。

        在 ShardingSphere 中集成了一批优秀的开源框架,包括用于实现配置中心和注册中心的Zookeeper、Apollo、Nacos,用于实现链路跟踪的 SkyWalking,用于实现分布式事务的 Atomikos 和 Seata 等。

        我们先以分布式事务为例,ShardingSphere 提供了一个 sharding-transaction-core 代码工程,用于完成对分布式事务的抽象。然后又针对基于两阶段提交的场景,提供了 sharding-transaction-2pc 代码工程,以及针对柔性事务提供了 sharding-transaction-base 代码工程。而在 sharding-transaction-2pc 代码工程内部,又包含了如下所示的 5 个子代码工程。

        -

        Drawing 12.png +

        png 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 中提供的分布式主键功能,分布式主键的实现可能有很多种,而扩展性在这个点上的体现就是, 我们可以使用任意一种新的分布式主键实现来替换原有的实现,而不需要依赖分布式主键的业务代码做任何的改变

        -

        image.png

        +

        png

        微内核架构模式为这种实现扩展性的思路提供了架构设计上的支持,ShardingSphere 基于微内核架构实现了高度的扩展性。在介绍如何实现微内核架构之前,我们先对微内核架构的具体组成结构和基本原理做简要的阐述。

        什么是微内核架构?

        从组成结构上讲, 微内核架构包含两部分组件:内核系统和插件 。这里的内核系统通常提供系统运行所需的最小功能集,而插件是独立的组件,包含自定义的各种业务代码,用来向内核系统增强或扩展额外的业务能力。在 ShardingSphere 中,前面提到的分布式主键就是插件,而 ShardingSphere 的运行时环境构成了内核系统。

        -

        image

        +

        png

        那么这里的插件具体指的是什么呢?这就需要我们明确两个概念,一个概念就是经常在说的 API ,这是系统对外暴露的接口。而另一个概念就是 SPI(Service Provider Interface,服务提供接口),这是插件自身所具备的扩展点。就两者的关系而言,API 面向业务开发人员,而 SPI 面向框架开发人员,两者共同构成了 ShardingSphere 本身。

        -

        image

        +

        png

        可插拔式的实现机制说起来简单,做起来却不容易,我们需要考虑两方面内容。一方面,我们需要梳理系统的变化并把它们抽象成多个 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 机制实现微内核架构的开发流程:

        -

        image

        +

        png

        这个示例非常简单,但却是 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 类层结构接口中包含一批实现类,分别对应各个具体的数据库:

        -

        Drawing 4.png

        +

        png

        SQLParserEntry 实现类图

        我们先来看针对 MySQL 的代码工程 shardingsphere-sql-parser-mysql,在 META-INF/services 目录下,我们找到了一个 org.apache.shardingsphere.sql.parser.spi.SQLParserEntry 文件:

        -

        Drawing 5.png

        +

        png

        MySQL 代码工程中的 SPI 配置

        可以看到这里指向了 org.apache.shardingsphere.sql.parser.MySQLParserEntry 类。再来到 Oracle 的代码工程 shardingsphere-sql-parser-oracle,在 META-INF/services 目录下,同样找到了一个 org.apache.shardingsphere.sql.parser.spi.SQLParserEntry 文件:

        -

        Drawing 6.png

        +

        png

        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 类:

        -

        Drawing 7.png

        +

        png

        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 定义:

        -

        1.png

        +

        png

        分布式主键 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 的详细结构存在一定的规范:

        -

        2.png

        +

        png

        64bit 的 ID 结构图

        在上图中,我们把 64bit 分成了四个部分:

        这三个阶段便是 ShardingSphere 新一代 SQL 解析引擎的核心组成部分。其整体架构如下图所示:

        -

        Drawing 8.png

        +

        png

        至此,我们看到由解析、提取和填充这三个阶段所构成的整体 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 架构设计上的一个切入点。

        -

        Drawing 10.png

        +

        png

        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 解析流程中剩余的两个阶段。

        -

        Drawing 0.png

        +

        png

        SQL 解析引擎的三大阶段

        在 SQL 解析引擎的第一阶段中,我们详细介绍了 ShardingSphere 生成 SQL 抽象语法树的过程,并引出了 SQLStatementRule 规则类。今天我们将基于这个规则类来分析如何提取 SQLSegment 以及如何填充 SQL 语句的实现机制。

        1.第二阶段:提取 SQL 片段

        @@ -222,7 +222,7 @@ function hide_canvas() { </sql-statement-rule-definition>

        基于 ParseRuleRegistry 类进行规则获取和处理过程,涉及一大批实体对象以及用于解析 XML 配置文件的 JAXB 工具类的定义,内容虽多但并不复杂。核心类之间的关系如下图所示:

        -

        Drawing 2.png

        +

        png

        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
         

        通过解析,我们获取了如下所示的抽象语法树:

        -

        Drawing 4.png

        +

        png

        抽象语法树示意图

        我们发现,对于上述抽象语法树中的某些节点(如 SELECT、FROM 和 WHERE)没有子节点,而对于如 FIELDS、TABLES 和 CONDITIONS 节点而言,本身也是一个树状结构。显然,这两种节点的提取规则应该是不一样的。

        因此,ShardingSphere 提供了两种 SQLSegmentExtractor,一种是针对单节点的 OptionalSQLSegmentExtractor;另一种是针对树状节点的 CollectionSQLSegmentExtractor。由于篇幅因素,这里以 TableExtractor 为例,展示如何提取 TableSegment 的过程,TableExtractor 的实现方法如下所示:

        @@ -363,11 +363,11 @@ function hide_canvas() { }

        这段代码在实现上采用了回调机制来完成对象的注入。在 ShardingSphere 中,基于回调的处理方式也非常普遍。本质上,回调解决了因为类与类之间的相互调用而造成的循环依赖问题,回调的实现策略通常采用了如下所示的类层结构:

        -

        Drawing 6.png

        +

        png

        回调机制示意图

        TableFiller 中所依赖的 TableSegmentAvailable 和 TableSegmentsAvailable 接口就类似于上图中的 Callback 接口,具体的 SQLStatement 就是 Callback 的实现类,而 TableFiller 则是 Callback 的调用者。以 TableFiller 为例,我们注意到,如果对应的 SQLStatement 实现了这两个接口中的任意一个,那么就可以通过 TableFiller 注入对应的 TableSegment,从而完成 SQLSegment 的填充。

        这里以 TableSegmentAvailable 接口为例,它有一组实现类,如下所示:

        -

        Drawing 8.png

        +

        png

        TableSegmentAvailable实现类

        以上图中的 CreateTableStatement 为例,该类同时实现了 TableSegmentAvailable 和 IndexSegmentsAvailable 这两个回调接口,所以就可以同时操作 TableSegment 和 IndexSegment 这两个 SQLSegment。CreateTableStatement 类的实现如下所示:

        public final class CreateTableStatement extends DDLStatement implements TableSegmentAvailable, IndexSegmentsAvailable {
        @@ -380,7 +380,7 @@ function hide_canvas() {
         }
         

        至此,我们通过一个示例解释了与填充操作相关的各个类之间的协作关系,如下所示的类图展示了这种协作关系的整体结构。

        -

        Drawing 9.png

        +

        png

        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 源码,首先梳理了如下所示的包结构:

        -

        Drawing 0.png

        +

        png

        上述包图总结了与路由机制相关的各个核心类,我们可以看到整体呈一种对称结构,即根据是 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 方法。这个方法的逻辑比较复杂,我们梳理它的执行步骤,如下图所示:

        -

        image

        +

        png

        ShardingRouter 是路由引擎的核心类,在接下来的内容中,我们将对上图中的 6 个步骤分别一 一 详细展开,帮忙你理解一个路由引擎的设计思想和实现机制。

        1.分片合理性验证

        我们首先来看 ShardingRouter 的第一个步骤,即验证分片信息的合理性,验证方式如下所示:

        @@ -414,7 +414,7 @@ RoutingResult routingResult = routingEngine.route(); }

        这些 RoutingEngine 的具体介绍我们放在下一课时《18 | 路由引擎:如何实现数据访问的分片路由和广播路由?》中进行详细介绍,这里只需要了解 ShardingSphere 在包结构的设计上把具体的 RoutingEngine 分成了六大类:即广播(broadcast)路由、混合(complex)路由、默认数据库(defaultdb)路由、无效(ignore)路由、标准(standard)路由以及单播(unicast)路由,如下所示:

        -

        Drawing 3.png

        +

        png

        不同类型的 RoutingEngine 实现类

        RoutingEngine 的执行结果是 RoutingResult,而 RoutingResult 中包含了一个 RoutingUnit集合,RoutingUnit 中的变量定义如下所示,可以看到有两个关于 DataSource 名称的变量以及一个 TableUnit 列表:

        //真实数据源名
        @@ -466,10 +466,10 @@ private RoutingResult routingResult;
         }
         

        这里的 SQLUnit 中就是最终的一条 SQL 语句以及相应参数的组合。因为路由结果对象 SQLRouteResult 会继续传递到分片引擎的后续流程,且内部结构比较复杂,所以这里通过如下所示的类图对其包含的各种变量进行总结,方便你进行理解。

        -

        Drawing 4.png

        +

        png

        至此,我们把 ShardingRouter 类的核心流程做了介绍。在 ShardingSphere 的路由引擎中,ShardingRouter 可以说是一个承上启下的核心类,向下我们可以挖掘各种 RoutingEngine 的具体实现;向上我们可以延展到读写分离等面向应用的具体场景。

        下图展示了 ShardingRouter 的这种定位关系。关于各种 RoutingEngine 的介绍是我们下一课时的内容,今天我们先将基于 ShardingRouter 讨论它的上层结构,从而引出了 ShardingEngine。

        -

        Drawing 6.png

        +

        png

        从底层 ShardingRouter 到上层 ShardingEngine

        我们的思路仍然是从下往上,先来看上图中的 StatementRoutingEngine,其实现如下所示:

        public final class StatementRoutingEngine {
        @@ -537,7 +537,7 @@ protected abstract SQLRouteResult route(String sql, List<Object> parameter
         }
         

        至此,关于 ShardingSphere 路由引擎部分的内容基本都介绍完毕。对于上层结构而言,我们以 SimpleQueryShardingEngine 为例进行了展开,对于 PreparedQueryShardingEngine 的处理方式也是类似。作为总结,我们通过如下所示的时序图来梳理这些路由的主流程。

        -

        Drawing 8.png

        +

        png

        从源码解析到日常开发

        分包设计原则可以用来设计和规划开源框架的代码结构。在今天的内容中,我们看到了 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 实现类型,如下图所示:

        -

        image

        +

        png

        我们无意对所有这些 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 中为我们提供了一系列的分片策略实例,类层结构如下所示:

        -

        Drawing 0.png

        +

        png

        ShardingStrategy 实现类图

        如果我们翻阅这些具体 ShardingStrategy 实现类的代码,会发现每个 ShardingStrategy 中都会包含另一个与路由相关的核心概念,即分片算法 ShardingAlgorithm,我们发现 ShardingAlgorithm 是一个空接口,但包含了四个继承接口,即

          @@ -220,10 +220,10 @@ function hide_canvas() {
        • HintShardingAlgorithm

        而这四个接口又分别具有一批实现类,ShardingAlgorithm 的类层结构如下所示:

        -

        Drawing 1.png

        +

        png

        ShardingAlgorithm 子接口和实现类图

        请注意,ShardingStrategy 与 ShardingAlgorithm 之间并不是一对一的关系。在一个 ShardingStrategy 中,可以同时使用多个 ShardingAlgorithm 来完成具体的路由执行策略。因此,我们具有如下所示的类层结构关系图:

        -

        Drawing 2.png

        +

        png

        由于分片算法的独立性,ShardingSphere 将其进行单独抽离。从关系上讲,分片策略中包含了分片算法和分片键,我们可以把分片策略的组成结构简单抽象成如下所示的公式:

        分片策略 = 分片算法 + 分片键

        ShardingSphere 分片策略详解

        @@ -443,10 +443,10 @@ private final Closure<?> closure; }

        最后,作为总结,我们要注意所有的 ShardingStrategy 相关类都位于 sharding-core-common 工程的 org.apache.shardingsphere.core.strategy 包下:

        -

        Drawing 4.png

        +

        png

        ShardingStrategy 相关类的包结构

        而所有的 ShardingAlgorithm 相关类则位于 sharding-core-api 工程的 org.apache.shardingsphere.api.sharding 包下:

        -

        Drawing 5.png

        +

        png

        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 包下:

        -

        Drawing 6.png

        +

        png

        ShardingStrategyConfiguration 相关类的包结构

        这样,通过对路由引擎的介绍,我们又接触到了一大批 ShardingSphere 中的源代码。

        至此,关于 ShardingSphere 路由引擎部分的内容基本都介绍完毕。作为总结,我们在《17 | 路由引擎:如何理解分片路由核心类 ShardingRouter 的运作机制?》中所给出的时序图中添加了 ShardingStrategy 和 ShardingAlgorithm 部分的内容,如下所示:

        -

        Drawing 7.png

        +

        png

        从源码解析到日常开发

        在我们设计软件系统的过程中,面对复杂业务场景时,职责分离始终是需要考虑的一个设计点。ShardingSphere 对于分片策略的设计和实现很好地印证了这一观点。

        分片策略在 ShardingSphere 中实际上是一个比较复杂的概念,但通过将分片的具体算法分离出去并提炼 ShardingAlgorithm 接口,并构建 ShardingStrategy 和 ShardingAlgorithm 之间一对多的灵活关联关系,我们可以更好地把握整个分片策略体系的类层结构,这种职责分离机制同样可以应用与日常开发过程中。

        小结与预告

        承接上一课时的内容,今天我们全面介绍了 ShardingSphere 中的五大分片策略和四种分片算法以及它们之间的组合关系。

        -

        Drawing 9.png

        +

        png

        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 改写的整体流程,我们对核心代码都添加了注释,这里面涉及的核心类也很多,值得我们进行深入分析,相关核心类的整体结构如下:

        -

        image.png

        +

        png

        可以看到在整个类图中,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 的串联作用:

        -

        image

        +

        png

        当 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 源码?》中提到的“基于分包设计原则阅读源码”的一种具体表现:

        -

        Drawing 0.png

        +

        png

        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 工程中。

        -

        image.png

        +

        png

        从今天开始,我们将进入到 sharding-jdbc-core 工程,来看看 ShardingSphere 中执行引擎上层设计中的几个核心类。

        AbstractStatementExecutor

        如上图所示,根据上一课时中的执行引擎整体结构图,可以看到SQLExecuteTemplate的直接使用者是AbstractStatementExecutor 类,今天我们就从这个类开始展开讨论,该类的变量比较多,我们先来看一下:

        @@ -269,7 +269,7 @@ private final Collection<ShardingExecuteGroup<StatementExecuteUnit>>

        显然,在这里应该使用 SQLExecuteTemplate 模板类来完成具体回调的执行过程。同时,我可以看到这里还有一个 refreshMetaDataIfNeeded 辅助方法用来刷选元数据。

        AbstractStatementExecutor 有两个实现类:一个是普通的 StatementExecutor,一个是 PreparedStatementExecutor,接下来我将分别进行讲解。

        -

        image

        +

        png

        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;
         

        关于这个判断条件,我们可以使用一张简单的示意图来进行说明,如下所示:

        -

        image

        +

        png

        如上图所示,我们可以看到如果每个数据库连接所指向的 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 类似的类层结构:

        -

        Drawing 0.png

        +

        png

        然后我们来到上图中 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:

        -

        Drawing 2.png

        +

        png

        形成这种类层结构的原因在于,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 种类型是组合而非互斥的关系。

        其中遍历归并是最简单的归并,而排序归并是最常用地归并,在下文我会对两者分别详细介绍。

        -

        Lark20200903-185718.png

        +

        png

        归并的五大类型

        按照归并实现的结构划分,ShardingSphere 中又存在流式归并、内存归并和装饰者归并这三种归并方案。

          @@ -224,7 +224,7 @@ result = getResultSet(mergeEngine);

        显然,流式归并和内存归并是互斥的,装饰者归并可以在流式归并和内存归并之上做进一步的处理。

        归并方案与归并类型之间同样存在一定的关联关系,其中遍历、排序以及流式分组都属于流式归并的一种,内存归并可以作用于统一的分组、排序以及聚合,而装饰者归并有分页归并和聚合归并这 2 种类型,它们之间的对应关系如下图所示:

        -

        Lark20200903-185710.png

        +

        png

        归并类型与归并方案之间的对应关系图

        2.归并引擎

        讲完概念回到代码,我们首先来到 shardingsphere-merge 代码工程中的 MergeEngine 接口:

        @@ -234,7 +234,7 @@ result = getResultSet(mergeEngine); }

        可以看到 MergeEngine 接口非常简单,只有一个 merge 方法。在 ShardingSphere 中,该接口存在五个实现类,其类层结构如下所示:

        -

        Drawing 2.png

        +

        png

        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 进行排序归并,初始的数据效果如下图所示:

        -

        Lark20200903-190058.png

        +

        png

        三张 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 的方式排序队列,效果如下:

        -

        Lark20200903-185846.png +

        png 队列中已排序的三张 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 将会被排列在队列的第三位。如下所示:

        -

        Lark20200903-185915.png

        +

        png

        第一次 next 之后的优先级队列中的三张 health_task 表

        之前队列中排名第二的 health_task2 的数据结果集则自动排在了队列首位。而在进行第二次 next 时,只需要将目前排列在队列首位的 health_task2 弹出队列,并且将其数据结果集游标指向的值返回。当然,对于 health_task2 而言,我们同样下移游标,并继续将它加入优先级队列中,以此类推。

        -

        Lark20200903-185920.png

        +

        png

        第二次 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 进行了排序:

        -

        Drawing 0.png

        +

        png

        我们先来看一些代码的初始化工作,回到 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,如下图所示:

        -

        Drawing 2.png

        +

        png

        • 第二次 next 调用

        与此同时,所有数据结果集中的游标都将下移至“task1”的下一个不同的数据值,并且根据数据结果集当前游标指向的值进行重排序。在上图中,我们看到第二个“task2”同时存在于 health_task0 和 health_task1 中,这样包含名字为“task2”的相关数据结果集则排在的队列的前列。

        当再次执行 next 调用时,我们获取了 “task2” 的分数并进行了累加,即 42+50=92,如下图中所示:

        -

        Lark20200907-164326.png

        +

        png

        对于接下去的 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 的配置信息,如下所示:

        -

        1.png

        +

        png

        针对 MasterSlaveLoadBalanceAlgorithm 的 SPI 配置

        按照这里的配置信息,第一个获取的 SPI 实例应该是 RoundRobinMasterSlaveLoadBalanceAlgorithm,即轮询策略,它的 getDataSource 方法实现如下:

        @Override
        @@ -395,7 +395,7 @@ public int executeUpdate() throws SQLException {
         

        至此,ShardingSphere 中与读写分离相关的核心类以及主要流程介绍完毕。总体而言,这部分的内容因为不涉及分片操作,所以整体结构还是比较直接和明确的。尤其是我们在了解了分片相关的 ShardingDataSource、ShardingConnection、ShardingStatement 和 ShardingPreparedStatement 之后再来理解今天的内容就显得特别简单,很多底层的适配器模式等内容前面都介绍过。

        作为总结,我们还是简单梳理一下读写分离相关的类层结构,如下所示:

        -

        image.png

        +

        png

        从源码解析到日常开发

        在今天的内容中,我们接触到了分布式系统开发过程中非常常见的一个话题,即负载均衡。负载均衡的场景就类似于在多个从库中选择一个目标库进行路由一样,通常需要依赖于一定的负载均衡算法,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 之间的交互过程参考下图:

        -

        Drawing 0.png

        +

        png

        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 两阶段提交相关的核心类之间的关系,如下图所示:

        -

        Drawing 2.png

        +

        png

        2.SeataATShardingTransactionManager

        介绍完 XAShardingTransactionManager 之后,我们来看上图中 ShardingTransactionManager 接口的另一个实现类 SeataATShardingTransactionManager。因为基于不同技术体系和工作原理,所以 SeataATShardingTransactionManager 中的实现方法也完全不同,让我们来看一下。

        在介绍 SeataATShardingTransactionManager 之前,我们同样有必要对 Seata 本身做一些展开。与 XA 不同,Seata 框架中一个分布式事务包含三种角色,除了 XA 中同样具备的 TransactionManager(TM)和 ResourceManager(RM) 之外,还存在一个事务协调器 TransactionCoordinator (TC),维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。

        其中,TM 是一个分布式事务的发起者和终结者,TC 负责维护分布式事务的运行状态,而 RM 则负责本地事务的运行。

        Seata 的整体架构图如下所示:

        -

        Drawing 4.png

        +

        png

        Seata 分布式事务组成结构图(来自 Seata 官网)

        基于Seata 框架,一个分布式事务的执行流程包含如下五个步骤:

        -

        Drawing 5.png

        +

        png

        我们同样会在下一课时中对这些步骤,以及其中涉及的核心类进行具体展开。

        从源码解析到日常开发

        今天的内容我们主要关注于 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 接口,而后者存在一批实现类,整体的类层结构如下所示:

        -

        Drawing 0.png

        +

        png

        XADataSourceDefinition 的实现类

        这里以 MySQLXADataSourceDefinition 为例展开讨论,该类分别实现了 DatabaseTypeAwareSPI 和 XADataSourceDefinition 这两个接口中所定义的三个方法:

        public final class MySQLXADataSourceDefinition implements XADataSourceDefinition {
        @@ -327,7 +327,7 @@ function hide_canvas() {
         }
         

        同样,在 sharding-transaction-xa-core 工程中,我们也发现了如下所示的 SPI 配置信息:

        -

        Drawing 1.png

        +

        png

        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 配置文件中得到确认:

        -

        Drawing 2.png

        +

        png

        DataSourcePropertyProvider 的 SPI 配置

        HikariCPPropertyProvider 实现了 DataSourcePropertyProvider 接口,并包含了对这些基础信息的定义:

        public final class HikariCPPropertyProvider implements DataSourcePropertyProvider {
        @@ -398,7 +398,7 @@ function hide_canvas() {
         }
         

        至此,我们对 XADataSource 的构建过程描述完毕。这个过程不算复杂,但涉及的类比较多,值得我们以 XADataSourceFactory 为中心画一张类图作为总结:

        -

        image.png

        +

        png

        2.XAConnection

        讲完 XADataSource,我们接着来讲 XAConnection,XAConnection 同样是 JDBC 规范中的接口。

        负责创建 XAConnection 的工厂类 XAConnectionFactory 如下所示:

        @@ -429,7 +429,7 @@ function hide_canvas() { }

        XAConnectionWrapper 接口只有一个方法,即根据传入的 XADataSource 和一个普通 Connection 对象创建出一个新的 XAConnection 对象。XAConnectionWrapper 接口的类层结构如下所示:

        -

        Drawing 4.png

        +

        png

        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 两阶段提交相关的核心类之间的关系,如下图所示:

        -

        image.png

        +

        png

        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 为例,我们可以梳理它的类层结构如下:

        -

        image

        +

        png

        可以看到 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 执行流程的抽象过程做了全面介绍,所涉及的很多内容对于数据脱敏模块而言也都是适用的。

        让我们结合下图来做一些回顾:

        -

        image

        +

        png

        上图中,可以看到与数据脱敏模块相关的类实际上都继承了一个抽象类,而这些抽象类在前面的内容都已经做了介绍。因此,我们对数据脱敏模块将重点关注于几个核心类的讲解,对于已经介绍过的内容我们会做一些回顾,但不会面面俱到。

        基于上图,我们从 EncryptDataSource 开始入手,EncryptDataSource 的创建依赖于工厂类 EncryptDataSourceFactory,其实现如下所示:

        public final class EncryptDataSourceFactory {
        @@ -238,7 +238,7 @@ private EncryptRuleConfiguration ruleConfiguration;
         

        ShardingEncryptor 接口中存在一对用于加密和解密的方法,同时该接口也继承了 TypeBasedSPI 接口,意味着会通过 SPI 的方式进行动态类加载。

        ShardingEncryptorServiceLoader 完成了这个工作,同时在 sharding-core-common 工程中,我们也找到了 SPI 的配置文件,如下所示:

        -

        Drawing 1.png

        +

        png

        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 的实现,这里面涉及的类也比较多,我们可以先来画张图作为后续讨论的基础,如下所示:

        -

        imag

        +

        png

        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 中对这些配置项的管理方式,我们可以将这些配置项与具体的存储结构相对应,如下所示:

        -

        image

        +

        png

        有了配置项之后,我们就需要对其进行保存,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 节点,存储结构如下所示:

        -

        image

        +

        png

        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 开发工程师岗的招聘要求:

        -

        Drawing 0.png -Drawing 1.png

        +

        png +png

        (职位信息来源:拉勾网)

        可以说,深入了解并掌握 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 应用程序。

        -

        Lark20201123-142325.png

        +

        png

        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 的主页中,展示了下面这张图:

          -

          Drawing 0.png

          +

          png

          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 的整体架构,如下图所示:

          -

          1.png

          +

          png

          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 基础之上,它的整体架构图如下所示:

          -

          Drawing 3.png

          +

          png

          Spring Cloud 与微服务整体架构图(来自 Spring 官网)

          技术组件的完备性是 Spring Cloud 框架的主要优势,它集成了业界一大批知名的微服务开发组件。Spring Cloud 的核心组件如下图所示:

          -

          Drawing 4.png

          +

          png

          Spring Cloud 核心功能组件

          可以看到,基于 Spring Boot 的开发便利性,Spring Cloud 巧妙地简化了微服务系统基础设施的开发过程,Spring Cloud 包含上图中所展示的服务发现注册、API 网关、配置中心、消息总线、负载均衡、熔断器、数据监控等。

          Spring 5 与响应式编程

          目前,Spring 已经演进到 5.X 版本。随着 Spring 5 的正式发布,我们迎来了响应式编程(Reactive Programming)的全新发展时期。Spring 5 中内嵌了与数据管理相关的响应式数据访问、与系统集成相关的响应式消息通信以及与 Web 服务相关的响应式 Web 框架等多种响应式组件,从而极大地简化了响应式应用程序的开发过程和开发难度。

          下图展示了响应式编程的技术栈与传统的 Servlet 技术栈之间的对比:

          -

          Drawing 5.png

          +

          png

          响应式编程技术栈与 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 之间的区别和联系:

          -

          3.png

          +

          png

          在基于 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 请求的构建、数据的序列化和反序列化以及实现各个服务自身内部的业务逻辑,如下图所示:

          -

          Drawing 0.png

          +

          png

          HTTP 请求响应过程

          我们先来看基于 Spring MVC 完成上述开发流程所需要的开发步骤,如下图所示:

          -

          Drawing 1.png

          +

          png

          基于 Spring MVC 的 Web 应用程序开发流程

          上图中包括使用 web.xml 定义 Spring 的 DispatcherServlet、完成启动 Spring MVC 的配置文件、编写响应 HTTP 请求的 Controller 以及将服务部署到 Tomcat Web 服务器等步骤。事实上,基于传统的 Spring MVC 框架开发 Web 应用逐渐暴露出一些问题,比较典型的就是配置工作过于复杂和繁重,以及缺少必要的应用程序管理和监控机制。

          如果想优化这一套开发过程,有几个点值得我们去挖掘,比方说减少不必要的配置工作、启动依赖项的自动管理、简化部署并提供应用监控等。而这些优化点恰巧推动了以 Spring Boot 为代表的新一代开发框架的诞生,基于 Spring Boot 的开发流程见下图:

          -

          Drawing 2.png

          +

          png

          基于 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 部署包结构参考下图:

          -

          Drawing 3.png

          +

          png

          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 应用程序的项目结构如下图所示:

          -

          Drawing 4.png

          +

          png

          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 版本,如下图所示:

        -

        Drawing 5.png

        +

        png

        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 的整个系统交互过程如下图所示:

        -

        Drawing 6.png

        +

        png

        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,点击生成即可。界面效果下图所示:

        -

        Drawing 0.png

        +

        png

        使用 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响应结果,说明整个服务已经启动成功。

        -

        Drawing 1.png

        +

        png

        好了,现在我们已经明白如何构建、打包以及运行一个简单的 Web 应用程序了,这是一切开发工作的起点,后续所有的案例代码我们都将通过这种方式展现在你面前,包括接下来要介绍的 Spring Boot 配置体系也是一样。

        Spring Boot 中的配置体系

        在 Spring Boot 中,其核心设计理念是对配置信息的管理采用约定优于配置。在这一理念下,则意味着开发人员所需要设置的配置信息数量比使用传统 Spring 框架时还大大减少。当然,今天我们关注的主要是如何理解并使用 Spring Boot 中的配置信息组织方式,这里就需要引出一个核心的概念,即 Profile。

        @@ -229,7 +229,7 @@ spring.datasource.username=root spring.datasource.password=root

        显然,类似这样的数据源通常会根据环境的不同而存在很多套配置。假设我们存在如下所示的配置文件集合:

        -

        Drawing 2.png

        +

        png

        多配置文件示意图

        注意,这里有一个全局的 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 就会自动弹出该前缀下的所有配置信息供你进行选择,效果如下:

        -

        Drawing 0.png

        +

        png

        IDE 自动提示配置项的效果图

        上图的效果对于管理自定义的配置信息非常有用。如何实现这种效果呢?当我们在 application.yml 配置文件中添加一个自定义配置项时,会注意到 IDE 会出现一个提示,说明这个配置项无法被 IDE 所识别,如下所示:

        -

        Drawing 1.png

        +

        png

        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 就会自动提示完整的配置项内容,效果如下所示:

        -

        Drawing 2.png

        +

        png

        IDE 自动提示 springcss 前缀的效果图

        另外,假设我们需要为 springcss.order.point 配置项指定一个默认值,可以通过在元数据中添加一个"defaultValue"项来实现,如下所示:

        {"properties": [{
        @@ -267,7 +267,7 @@ public class SpringCssConfig {
         }]}
         

        这时候,在 IDE 中设置这个配置项时,就会提出该配置项的默认值为 10,效果如下所示:

        -

        Drawing 3.png

        +

        png

        IDE 自动提示包含默认值的 springcss 前缀效果图

        如何组织和整合配置信息?

        在上一课时中,我们提到了 Profile 概念,Profile 可以认为是管理配置信息中的一种有效手段。今天,我们继续介绍另一种组织和整合配置信息的方法,这种方法同样依赖于前面介绍的 @ConfigurationProperties 注解。

        @@ -299,7 +299,7 @@ public class SpringCssConfig { –classpath:/

        以下是按照优先级从高到低的顺序,如下所示:

        -

        IiC6LiP3KXM49YyV__thumbnail.png

        +

        png

        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 规范具备完整的架构体系,如下图所示:

        -

        Drawing 2.png

        +

        png

        JDBC 规范整体架构图

        从上图中可以看到,Java 应用程序通过 JDBC 所提供的 API 进行数据访问,而这些 API 中包含了开发人员所需要掌握的各个核心编程对象,下面我们一起来看下。

        JDBC 规范中有哪些核心编程对象?

        @@ -311,7 +311,7 @@ connection.close();

        这段代码中完成了对基于前面介绍的 JDBC API 中的各个核心编程对象的数据访问。上述代码主要面向查询场景,而针对用于插入数据的处理场景,我们只需要在上述代码中替换几行代码,即将“执行查询”和“获取查询结果进行处理”部分的查询操作代码替换为插入操作代码就行。

        最后,我们梳理一下基于 JDBC 规范进行数据库访问的整个开发流程,如下图所示:

        -

        Drawing 10.png

        +

        png

        基于 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 接口的子接口和实现类,该接口的部分类层结构如下所示:

        -

        image

        +

        png

        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 中定义的方法名进行一定约束。

        首先我们需要指定一些查询关键字,常见的关键字如下表所示:

        -

        Lark20201215-174017.png

        +

        png

        方法名衍生查询中查询关键字列表

        有了这些查询关键字后,在方法命名上我们还需要指定查询字段和一些限制性条件。例如,在前面的示例中,我们只是基于“fistName”和“lastName”这两个字段做查询。

        事实上,我们可以查询的内容非常多,下表列出了更多的方法名衍生查询示例,你可以参考下。

        -

        Lark20201215-174023.png

        +

        png

        方法名衍生查询示例

        在 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 中,列出了其提供的所有组件,如下图所示:

        -

        image

        +

        png

        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 字符串来构建输入对象,如下代码所示:

        -

        Drawing 1.png

        +

        png

        使用 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 的设计原则对这些工具方法进行分类,如下表所示。

        -

        Lark20201225-135202.png

        +

        png

        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 就可以快速获取用户账户变更消息,从而正确且高效地处理本地的用户账户数据。

        整个场景的示意图见下图:

        -

        Drawing 0.png

        +

        png

        用户账户更新场景中的消息通信机制

        上图中我们发现,消息通信机制使得我们不必花费太大代价即可实现整个交互过程,简单而方便。

        消息通信机制简介

        消息通信机制的整体工作流程如下图所示:

        -

        Drawing 1.png

        +

        png

        消息通信机制示意图

        上图中位于流程中间的就是各种消息中间件,消息中间件一般提供了消息的发送客户端和接收客户端组件,这些客户端组件会嵌入业务服务中。

        消息的生产者负责产生消息,在实际业务中一般由业务系统充当生产者;而消息的消费者负责消费消息,在实际业务中一般是后台系统负责异步消费。

        @@ -196,7 +196,7 @@ function hide_canvas() {

        在讨论如何使用 KafkaTemplate 实现与 Kafka 之间的集成方法之前,我们先来简单了解 Kafka 的基本架构,再引出 Kafka 中的几个核心概念。

        Kafka 基本架构

        Kafka 基本架构参考下图,从中我们可以看到 Broker、Producer、Consumer、Push、Pull 等消息通信系统常见概念在 Kafka 中都有所体现,生产者使用 Push 模式将消息发布到 Broker,而消费者使用 Pull 模式从 Broker 订阅消息。

        -

        Drawing 2.png

        +

        png

        Kafka 基本架构图

        在上图中我们注意到,Kafka 架构图中还使用了 Zookeeper。

        Zookeeper 中存储了 Kafka 的元数据及消费者消费偏移量(Offset),其作用在于实现 Broker 和消费者之间的负载均衡。因此,如果我们想要运行 Kafka,首先需要启动 Zookeeper,再启动 Kafka 服务器。

        @@ -271,7 +271,7 @@ public @interface KafkaListener {

        设计消费者组的目的是应对集群环境下的多服务实例问题。显然,如果采用发布-订阅模式会导致一个服务的不同实例可能会消费到同一条消息。

        为了解决这个问题,Kafka 中提供了消费者组的概念。一旦我们使用了消费组,一条消息只能被同一个组中的某一个服务实例所消费。

        消费者组的基本结构如下图所示:

        -

        Drawing 3.png

        +

        png

        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 构建如下所示的配置文件体系。

        -

        Drawing 1.png

        +

        png

        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() {
         

        所谓认证,即首先需要明确“你是谁”这个问题,也就是说系统能针对每次访问请求判断出访问者是否具有合法的身份标识。

        一旦明确了 “你是谁”,我们就能判断出“你能做什么”,这个步骤就是授权。一般来说,通用的授权模型都是基于权限管理体系,即对资源、权限、角色和用户的进行组合处理的一种方案。

        当我们把认证与授权结合起来后,即先判断资源访问者的有效身份,然后确定其对这个资源进行访问的合法权限,整个过程就形成了对系统进行安全性管理的一种常见解决方案,如下图所示:

        -

        Drawing 1.png

        +

        png

        基于认证和授权机制的资源访问安全性示意图

        上图就是一种通用方案,而在不同的应用场景及技术体系下,系统可以衍生出很多具体的实现策略,比如 Web 应用系统中的认证和授权模型虽然与上图类似,但是在具体设计和实现过程中有其特殊性。

        在 Web 应用体系中,因为认证这部分的需求相对比较明确,所以我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理利用。

        而授权的情况相对来说复杂些,比如对某个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求具备访问自己的权限。解决完这个第一个问题后,就算这个请求具备访问该应用程序的权限,并不意味着它能够访问其所具有的所有 HTTP 端点,比如业务上的某些核心功能还是需要具备较高的权限才能访问,这就涉及我们需要解决的第二个问题——如何对访问的权限进行精细化管理?如下图所示:

        -

        Drawing 3.png

        +

        png

        Web 应用程序访问授权效果示意图

        在上图中,假设该请求具备对 Web 应用程序的访问权限,但不具备访问应用程序中端点 1 的权限,如果想实现这种效果,一般我们的做法是引入角色体系:首先对不同的用户设置不同等级的角色(即角色等级不同对应的访问权限也不同),再把每个请求绑定到某个角色(即该请求具备了访问权限)。

        接下来我们把认证和授权进行结合,梳理出了 Web 应用程序访问场景下的安全性实现方案,如下图所示:

        -

        Drawing 5.png

        +

        png

        认证和授权整合示意图

        从上图我们可以看到,用户首先通过请求传递用户凭证完成用户认证,然后根据该用户信息中所具备的角色信息获取访问权限,最终完成对 HTTP 端点的访问授权。

        对一个 Web 应用程序进行安全性设计时,我们首先需要考虑认证和授权,因为它们是核心考虑点。在技术实现场景中,只要涉及用户认证,势必会涉及用户密码等敏感信息的加密。针对用户密码的场景,我们主要使用单向散列加密算法对敏感信息进行加密。

        关于单向散列加密算法,它常用于生成消息摘要(Message Digest),主要特点为单向不可逆和密文长度固定,同时具备“碰撞”少的优点,即明文的微小差异会导致生成的密文完全不同。其中,常见的单向散列加密实现算法为 MD5(Message Digest 5)和 SHA(Secure Hash Algorithm)。而在 JDK 自带的 MessageDigest 类中,因为它已经包含了这些算法的默认实现,所以我们直接调用方法即可。

        在日常开发过程中,对于密码进行加密的典型操作时序图如下所示:

        -

        Drawing 3.png

        +

        png

        单向散列加密与加盐机制

        上图中,我们引入了加盐(Salt)机制,进一步提升了加密数据的安全性。所谓加盐就是在初始化明文数据时,系统自动往明文中添加一些附加数据,然后再进行散列。

        目前,单向散列加密及加盐思想已被广泛用于系统登录过程中的密码生成和校验过程中,比如接下来我们将要引入的 Spring Security 框架。

        @@ -203,11 +203,11 @@ function hide_canvas() {

        这一讲我们先不对如何使用 Spring Security 框架展开说明,而是先从高层次梳理该框架对前面提到的各项安全性需求提供的架构设计。

        Spring Security 中的过滤器链

        与业务中大多数处理 Web 请求的框架对比后,我们发现 Spring Security 中采用的是管道-过滤器(Pipe-Filter)架构模式,如下图所示:

        -

        Drawing 8.png

        +

        png

        管道-过滤器架构模式示意图

        在上图中我们可以看到,处理业务逻辑的组件称为过滤器,而处理结果的相邻过滤器之间的连接件称为管道,它们构成了一组过滤器链,即 Spring Security 的核心。

        项目一旦启动,过滤器链将会实现自动配置,如下图所示:

        -

        Drawing 10.png

        +

        png

        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 源代码,我们引出了该框架中一系列核心类,并梳理了它们之间的交互结构,如下图所示:

        -

        Lark20210112-182830.png

        +

        png

        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 服务并访问上述端点,弹出了如下图所示的界面内容:

        -

        Drawing 0.png

        +

        png

        添加 Spring Security 之后自动出现的登录界面

        同时,在系统的启动控制台日志中,我们发现了如下所示的新的日志信息。

        Using generated security password: 17bbf7c4-456a-48f5-a12e-a680066c8f80
         

        在这里可以看到,Spring Security 为我们自动生成了一个密码,我们可以基于“user”这个账号及上述密码登录这个界面,抽空你也可以尝试下。

        如果我们使用了 Postman 可视化 HTTP 请求工具,可以设置授权类型为“Basic Auth”并输入对应的用户名和密码完成对 HTTP 端点的访问,设置界面如下图所示:

        -

        Drawing 1.png

        +

        png

        使用 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 接口的实现类,如下图所示:

        -

        Drawing 3.png

        +

        png

        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 中还提供了很多其他有用的配置方法供开发人员灵活使用,下表中我们进行了列举,一起来看下。

        -

        Lark20210119-172757.png

        +

        png

        基于上表中的配置方法,我们就可以通过 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() {
         
      28. 操作控制类: 在原生端点中只提供了一个关闭应用的端点,即 /shutdown 端点。
      29. 根据 Spring Boot Actuator 默认提供的端点列表,我们将部分常见端点的类型、路径和描述梳理在如下表格中,仅供参考。

        -

        Drawing 0.png

        +

        png

        通过访问上表中的各个端点,我们就可以获取自己感兴趣的监控信息了。例如访问了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 及其描述如下表所示:

        -

        Drawing 1.png

        +

        png

        以上表中的 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 界面,就能看到如下所示的效果:

        -

        Drawing 1.png

        +

        png

        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 中已经出现了这个应用的名称和地址,如下图所示:

        -

        Drawing 2.png

        +

        png

        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 中的应用程序信息,如下图所示:

        -

        Drawing 4.png

        +

        png

        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”,点击该菜单,我们就可以看到一面应用墙,如下图所示:

        -

        Drawing 5.png

        +

        png

        Admin Server 应用墙

        点击应用墙中的某个应用,我们就能进入针对该应用的监控信息主界面。在该界面的左侧,包含了监控功能的各级目录,如下图所示:

        图片6.png

        @@ -290,11 +290,11 @@ public class EurekaServerApplication {

        Admin Server 中的 JVM 监控信息

        这些 JVM 数据都是通过可视化的方式进行展现,并随着运行时状态的变化而实时更新。

        在 21 讲中,我们详细讨论了 Spring Boot Actuator 中的度量指标。而在 Admin Server 中,同样存在一个“Metrics”菜单,展示效果如下图所示:

        -

        Drawing 8.png

        +

        png

        Admin Server 中的 Metrics 信息

        在“Metrics”菜单中,开发人员可以通过对各种条件进行筛选,然后添加对应的度量指标。比如上图中,我们针对 HTTP 请求中 /actuator/health 端点进行了过滤,从而得到了度量结果。

        接着我们一起看看系统环境方面的属性,因为这方面的属性非常之多,所以 Admin Server 也提供了一个过滤器,如下图所示:

        -

        Drawing 9.png

        +

        png

        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 则提供了一个连续性的可视化监控界面,如下图所示:

        -

        Drawing 11.png

        +

        png

        Admin Server 中的 Thread Dump 信息

        点击图中的色条,我们就可以获取每一个线程的详细信息了,这里你可以尝试做一些分析。

        控制访问安全性

        @@ -323,7 +323,7 @@ public class EurekaServerApplication { password: "springcss_password"

        重启 Admin Server 后,再次访问 Web 界面时,就需要我们输入用户名和密码了,如下图所示:

        -

        Drawing 12.png

        +

        png

        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 组件的依赖关系,我们可以得到如下所示的组件依赖图:

        -

        Drawing 1.png

        +

        png

        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 工程中代码的基本目录结构。

        -

        Drawing 3.png

        +

        png

        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 为例,这里我们给出它的架构图,如下图所示:

        -

        Drawing 0.png

        +

        png

        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 的另一张官网架构图,如下图所示:

        -

        Drawing 1.png

        +

        png

        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 协议等常见的技术体系,但系统安全是一个综合性的主题,并非简单采用一些技术体系就能构建有效的解决方案

        我们以一个分布式环境下的应用场景为例。假设你要开发一个工单系统,而生成工单所依赖的用户订单信息维护在第三方订单系统中。为了生成工单,就必须让工单系统读取订单系统中的用户订单信息。

        那么问题来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:

        -

        Drawing 0.png

        +

        png

        订单系统用户认证和授权交互示意图

        上述方案看起来没有什么问题,但如果你仔细分析一下,就会发现这个流程在安全性上存在一些漏洞。

        比如,一旦用户修改了订单管理平台的密码,工单系统就无法正常访问了。为此,我们需要引入诸如 OAuth2 协议完成分布式环境下的认证和授权

        我们通过一张图简单对比一下 OAuth2 协议与传统实现方案:

        -

        Drawing 1.png

        +

        png

        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() {
      30. ……
      31. 同时,在普遍倡导用户隐私和数据价值的当下,掌握各种安全性相关技术的开发人员和架构师也成了稀缺人才,越发受到行业的认可和重视

        -

        Drawing 2.png

        +

        png

        (职位信息来源:拉勾网)

        对于开发人员而言,如何使用各种技术体系解决安全性问题是一大困惑。经验丰富的开发人员需要熟练使用 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!"这个输出结果,但事实上,浏览器会跳转到一个如下所示的登录界面:

        -

        Drawing 0.png

        +

        png

        Spring Security 内置的登录界面

        那么,为什么会弹出这个登录界面呢?原因就在于我们添加了 spring-boot-starter-security 依赖之后,Spring Security 为应用程序自动嵌入了用户认证机制

        接下来,我们围绕这个登录场景,分析如何获取登录所需的用户名和密码。我们注意到在 Spring Boot 的控制台启动日志中,出现了如下所示的一行日志:

        @@ -188,28 +188,28 @@ public class DemoController {

        首先我们需要明确,所谓认证,解决的是“你是谁”这一个问题,也就是说对于每一次访问请求,系统都能判断出访问者是否具有合法的身份标识。

        一旦明确 “你是谁”,下一步就可以判断“你能做什么”,这个步骤就是授权。通用的授权模型大多基于权限管理体系,即对资源、权限、角色和用户的一种组合处理。

        如果我们将认证和授权结合起来,就构成了对系统中的资源进行安全性管理的最常见解决方案,即先判断资源访问者的有效身份,再来确定其是否有对这个资源进行访问的合法权限,如下图所示:

        -

        Drawing 2.png

        +

        png

        基于认证和授权机制的资源访问安全性示意图

        上图代表的是一种通用方案,而不同的应用场景和技术体系下可以衍生出很多具体的实现策略。Web 应用系统中的认证和授权模型与上图类似,但在具体设计和实现过程中也有其特殊性。

        针对认证,这部分的需求相对比较明确。显然我们需要构建一套完整的存储体系来保存和维护用户信息,并且确保这些用户信息在处理请求的过程中能够得到合理的利用。

        而针对授权,情况可能会比较复杂。对于某一个特定的 Web 应用程序而言,我们面临的第一个问题是如何判断一个 HTTP 请求是否具备访问自己的权限。更进一步,就算这个请求具备访问该应用程序的权限,但并不意味着该请求能够访问应用程序所有的 HTTP 端点。某些核心功能需要具备较高的权限才能访问,而有些则不需要。这就是我们需要解决的第二个问题,如何对访问的权限进行精细化管理?如下图所示:

        -

        Drawing 4.png

        +

        png

        Web 应用程序访问授权效果示意图

        在上图中,我们假设该请求具备对应用程序中端点 2、3、4 的访问权限,但不具备访问端点 1 的权限。想要达到这种效果,一般的做法是引入角色体系。我们对不同的用户设置不同等级的角色,角色等级不同对应的访问权限也不同。而每一个请求都可以绑定到某一个角色,也就具备了访问权限。

        接下来,我们把认证和授权结合起来,梳理出 Web 应用程序访问场景下的安全性实现方案,如下图所示:

        -

        Drawing 6.png

        +

        png

        单体服务下的认证和授权整合示意图

        结合示意图我们可以看到,通过请求传递用户凭证完成用户认证,然后根据该用户信息中具备的角色信息获取访问权限,并最终完成对 HTTP 端点的访问授权。

        围绕认证和授权,我们还需要一系列的额外功能确保整个流程得以实现。这些功能包括用于密码保护的加解密机制、用于实现方法级的安全访问,以及支持跨域等,这些功能在我们专栏的后续内容中都会一一展开讨论。

        Spring Security 与微服务架构

        微服务架构的情况要比单体应用复杂很多,因为涉及了服务与服务之间的调用关系。我们继续沿用“资源”这个概念,对应到微服务系统中,服务提供者充当的角色就是资源的服务器,而服务消费者就是客户端。所以各个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。

        接下来,我们把认证和授权结合起来,梳理出微服务访问场景下的安全性实现方案,如下图所示:

        -

        Drawing 8.png

        +

        png

        微服务架构下的认证和授权整合示意图

        可以看到,与单体应用相比,在微服务架构中需要把认证和授权的过程进行集中化管理,所以在上图中出现了一个授权中心。 授权中心会获取客户端请求中所带有的身份凭证信息,然后基于凭证信息生成一个 Token,这个 Token 中就包含了权限范围和有效期

        客户端获取 Token 之后就可以基于这个 Token 发起对微服务的访问。这个时候,服务的提供者需要对这个 Token 进行认证,并根据 Token 的权限范围和有效期从授权中心获取该请求能够访问的特定资源。在微服务系统中,对外的资源表现形式同样可以理解为一个个 HTTP 端点。

        上图中关键点就在于构建用于生成和验证 Token 的授权中心,为此我们需要引入OAuth2 协议。OAuth2 协议为我们在客户端程序和资源服务器之间设置了一个授权层,并确保 Token 能够在各个微服务中进行有效传递,如下图所示:

        -

        Drawing 10.png

        +

        png

        OAuth2 协议在服务访问场景中的应用

        OAuth2 是一个相对复杂的协议,综合应用摘要认证、签名认证、HTTPS 等安全性手段,需要提供 Token 生成和校验以及公私钥管理等功能,同时需要开发者入驻并进行权限粒度控制。一般我们应该避免自行实现这类复杂的协议,倾向于借助于特定工具以免重复造轮子。而 Spring Security 为我们提供了实现这一协议的完整解决方案,我们可以使用该框架完成适用于微服务系统中的认证授权机制。

        Spring Security 与响应式系统

        @@ -218,7 +218,7 @@ public class DemoController {

        小结与预告

        本讲是整个专栏内容的第一讲,我们通过一个简单的示例引入了 Spring Security 框架,并基于日常开发的安全需求,全面剖析了 Spring Security 框架具备的功能体系。不同的功能对应不同的应用场景,在普通的单体应用、微服务架构、响应式系统中都可以使用 Spring Security 框架提供的各种功能确保系统的安全性。

        本讲内容总结如下:

        -

        Drawing 12.png

        +

        png

        这里给你留一道思考题:针对单体应用和微服务架构,你能分别描述它们所需要的认证和授权机制吗?

        接下来我们将正式进入到 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 端点的访问,设置界面如下所示:

        -

        Drawing 0.png

        +

        png

        使用 Postman 完成 HTTP 基础认证信息的设置

        现在查看 HTTP 请求,可以看到 Request Header 中添加了 Authorization 标头,格式为:Authorization: <type> <credentials>。这里的 type 就是“Basic”,而 credentials 则是这样一个字符串:

        dXNlcjo5YjE5MWMwNC1lNWMzLTQ0YzctOGE3ZS0yNWNkMjY3MmVmMzk=
        @@ -218,7 +218,7 @@ function hide_canvas() {
         }
         

        formLogin() 方法的执行效果就是提供了一个默认的登录界面,如下所示:

        -

        Drawing 1.png

        +

        png

        Spring Security 默认的登录界面

        我们已经在上一讲中看到过这个登录界面。对于登录操作而言,这个登录界面通常都是定制化的,同时,我们也需要对登录的过程和结果进行细化控制。此时,我们就可以通过如下所示的配置内容来修改系统的默认配置:

        @Override
        @@ -293,7 +293,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         

        小结与预告

        这一讲我们详细介绍了如何使用 Spring Security 构建用户认证体系的系统方法。在 Spring Security 中,认证相关的功能都是可以通过配置体系进行定制化开发和管理的。通过简单的配置方法,我们可以组合使用 HTTP 基础认证和表单登录认证,也可以分别基于内存以及基于数据库方案来存储用户信息,这些功能都是 Spring Security 内置的。

        本讲内容总结如下:

        -

        Drawing 2.png

        +

        png

        最后我想给你留一道思考题:你知道在 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() {
      32. UserDetailsManager:扩展 UserDetailsService,添加了创建用户、修改用户密码等功能。
      33. 这四个对象之间的关联关系如下图所示,显然,对于由 UserDetails 对象所描述的一个用户而言,它应该具有 1 个或多个能够执行的 GrantedAuthority:

        -

        Drawing 0.png

        +

        png

        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 中与用户对象相关的一大批实现类,它们之间的关系如下图所示:

        -

        Drawing 1.png

        +

        png

        Spring Security 中用户对象相关类结构图

        Spring Security 中的认证对象

        有了用户对象,我们就可以讨论具体的认证过程了,首先来看认证对象 Authentication,如下所示:

        @@ -353,7 +353,7 @@ public final C withUser(UserDetails userDetails) {

        可以看到,这里使用了 AuthenticationManager 而不是 AuthenticationProvider 中的 authenticate() 方法来执行认证。同时,我们也注意到这里出现了 UsernamePasswordAuthenticationToken 类,这就是 Authentication 接口的一个具体实现类,用来存储用户认证所需的用户名和密码信息

        同样作为总结,我们也梳理了 Spring Security 中与认证对象相关的一大批核心类,它们之间的关系如下所示:

        -

        Drawing 2.png

        +

        png

        Spring Security 中认证的对象相关类结构图

        实现定制化用户认证方案

        通过前面的分析,我们明确了用户信息存储的实现过程实际上是可以定制化的。Spring Security 所做的工作只是把常见的、符合一般业务场景的实现方式嵌入到了框架中。如果有特殊的场景,开发人员完全可以实现自定义的用户信息存储方案。

        @@ -436,7 +436,7 @@ public class SpringUserDetailsService

        我们知道 UserDetailsService 接口只有一个 loadUserByUsername 方法需要实现。因此,我们基于 SpringUserRepository 的 findByUsername 方法,根据用户名从数据库中查询数据。

        扩展 AuthenticationProvider

        扩展 AuthenticationProvider 的过程就是提供一个自定义的 AuthenticationProvider 实现类。这里我们以最常见的用户名密码认证为例,梳理自定义认证过程所需要实现的步骤,如下所示:

        -

        Drawing 3.png

        +

        png

        自定义 AuthenticationProvider 的实现流程图

        上图中的流程并不复杂,首先我们需要通过 UserDetailsService 获取一个 UserDetails 对象,然后根据该对象中的密码与认证请求中的密码进行匹配,如果一致则认证成功,反之抛出一个 BadCredentialsException 异常。示例代码如下所示:

        @Component
        @@ -492,7 +492,7 @@ public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
         

        小结与预告

        这一讲我们基于 Spring Security 提供的用户认证功能分析了其背后的实现过程。我们的切入点在于分析与用户和认证相关的各个核心类,并梳理它们之间的交互过程。另一方面,我们还通过扩展 UserDetailsService 和 AuthenticationProvider 接口的方式来实现定制化的用户认证方案。

        本讲内容总结如下:

        -

        Drawing 4.png

        +

        png

        最后给你留一道思考题:基于 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 组件验证密码的正确性,如下图所示:

        -

        Drawing 0.png

        +

        png

        PasswordEncoder 组件与认证流程之间的关系

        在“用户认证:如何使用 Spring Security 构建用户认证体系?”一讲中我们也介绍了基于数据库的用户信息存储方案:

        @Override
        @@ -184,7 +184,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         }
         

        Spring Security 内置了一大批 PasswordEncoder 接口的实现类,如下所示:

        -

        Drawing 1.png

        +

        png

        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 将具体编码的实现根据要求代理给不同的算法,以此实现不同编码算法之间的兼容并协调变化,如下图所示:

          -

          Drawing 2.png

          +

          png

          DelegatingPasswordEncoder 的代理作用示意图

          下面我们来看一下 DelegatingPasswordEncoder 类的构造函数,如下所示:

          public DelegatingPasswordEncoder(String idForEncode,
          @@ -395,7 +395,7 @@ byte [] decrypted = e.decrypt(encrypted);
           

          小结与预告

          对于一个 Web 应用程序而言,一旦需要实现用户认证,势必涉及用户密码等敏感信息的加密。为此,Spring Security 专门提供了 PasswordEncoder 组件对密码进行加解密。Spring Security 内置了一批即插即用的 PasswordEncoder,并通过代理机制完成了各个组件的版本兼容和统一管理。这种设计思想也值得我们学习和借鉴。当然,作为一款通用的安全性开发框架,Spring Security 也提供了一个高度独立的加密模块应对日常开发需求。

          本讲内容总结如下:

          -

          Drawing 3.png

          +

          png

          这里给你留一道思考题:你能描述 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 的认证机制?”中介绍的用户对象以及它们之间的关联关系:

          -

          Drawing 0.png

          +

          png

          Spring Security 中的核心用户对象

          上图中的 GrantedAuthority 对象代表的就是一种权限对象,而一个 UserDetails 对象具备一个或多个 GrantedAuthority 对象。通过这种关联关系,实际上我们就可以对用户的权限做一些限制,如下所示:

          -

          Drawing 1.png

          +

          png

          使用权限实现访问控制示意图

          如果用代码来表示这种关联关系,可以采用如下所示的实现方法:

          UserDetails user = User.withUsername("jianxiang")
          @@ -208,7 +208,7 @@ http.authorizeRequests().anyRequest().access(expression);
           

          上述代码的效果是只有拥有“CREATE”权限且不拥有“Retrieve”权限的用户才能进行访问。

          基于角色进行访问控制

          讨论完权限,我们再来看角色,你可以把角色看成是拥有多个权限的一种数据载体,如下图所示,这里我们分别定义了两个不同的角色“User”和“Admin”,它们拥有不同的权限:

          -

          Drawing 2.png

          +

          png

          使用角色实现访问控制示意图

          讲到这里,你可能会认为 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 匹配器以及正则表达式匹配器来实现复杂的访问控制。

          本讲内容总结如下:

          -

          Drawing 3.png

          +

          png

          最后我想给你留一道思考题:在 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 的授权整体工作流程,可以用如下所示的时序图表示:

          -

          Drawing 0.png

          +

          png

          Spring Security 的授权整体工作流程

          接下来,我们基于这张类图分别对拦截请求、获取权限配置、执行授权决策三个步骤逐一展开讲解。

          拦截请求

          @@ -385,12 +385,12 @@ List<AccessDecisionVoter<?>> getDecisionVoters(H http) {

          显然,最终的评估过程只是简单使用了 Spring 所提供的 SpEL 表达式语言。

          作为总结,我们把这一流程中涉及的核心组件以类图的形式进行了梳理,如下图所示:

          -

          Drawing 1.png

          +

          png

          Spring Security 授权相关核心类图

          小结与预告

          这一讲我们关注的是 Spring Security 授权机制的实现原理,我们把整个授权过程拆分成拦截请求、获取访问策略和执行授权决策这三大步骤。针对每一个步骤,都涉及了一组核心类及其它们之间的交互关系。针对这些核心类的讲解思路是围绕着上一讲介绍的基本配置方法展开讨论的,确保实际应用能与源码分析衔接在一起。

          本讲内容总结如下:

          -

          Drawing 2.png

          +

          png

          最后给你留一道思考题:在 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 等组件。

          我们可以把这两条代码线整合在一起,得到案例的整体设计蓝图,如下图所示:

          -

          Drawing 0.png

          +

          png

          案例中的业务代码流程和用户认证流程

          系统初始化

          要想实现上图中的效果,我们需要先对系统进行初始化。这部分工作涉及领域对象的定义、数据库初始化脚本的整理以及相关依赖组件的引入

          @@ -489,16 +489,16 @@ public class HealthRecordController {

          这里我们从 Model 对象中获取了认证用户信息以及健康档案信息,并渲染在页面上。

          案例演示

          现在,让我们启动 Spring Boot 应用程序,并访问http://localhost:8080端点。因为访问系统的任何端点都需要认证,所以 Spring Security 会自动跳转到如下所示的登录界面:

          -

          Drawing 1.png

          +

          png

          用户登录界面

          我们分别输入用户名“jianxiang”和密码“12345”,系统就会跳转到健康档案主页:

          -

          Drawing 2.png

          +

          png

          健康档案主页

          在这个主页中,我们正确获取了登录用户的用户名,并展示了个人健康档案信息。这个结果也证实了自定义用户认证体系的正确性。你可以根据示例代码做一些尝试。

          小结与预告

          这一讲我们动手实践了“利用 Spring Security 基础功能保护 Web 应用程序”。综合第 2 讲到 6 讲中的核心知识点,我们设计了一个简单而又完整的案例,并通过构建用户管理和认证流程讲解了实现自定义用户认证机制的过程。

          本讲内容总结如下:

          -

          Drawing 3.png

          +

          png

          最后给你留一道思考题:在 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)架构模式。管道-过滤器架构模式的示意图如下所示:

          -

          Drawing 0.png

          +

          png

          管道-过滤器架构模式示意图

          结合上图我们可以看到,处理业务逻辑的组件被称为过滤器,而处理结果通过相邻过滤器之间的管道进行传输,这样就构成了一个过滤器链。

          在 Servlet 中,代表过滤器的 Filter 接口定义如下:

          @@ -181,7 +181,7 @@ function hide_canvas() {

          请注意,过滤器链中的过滤器是有顺序的,这点非常重要,我们在本讲后续内容中会针对这点展开讲解。

          Spring Security 中的过滤器链

          在 Spring Security 中,其核心流程的执行也是依赖于一组过滤器,这些过滤器在框架启动后会自动进行初始化,如图所示:

          -

          Drawing 1.png

          +

          png

          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() 方法将请求继续在过滤器链上进行传递。

          接下来,我们想象这样一种场景,业务上我们需要根据客户端请求头中是否包含某一个特定的标志位,来决定请求是否有效。如图所示:

          -

          Drawing 2.png

          +

          png

          根据标志位设计过滤器示意图

          这在现实开发过程中也是一种常见的应用场景,可以实现定制化的安全性控制。针对这种应用场景,我们可以实现如下所示的 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 前。这样,在执行用户认证之前,我们就可以排除掉一批无效请求,效果如下所示:

          -

          Drawing 3.png

          +

          png

          RequestValidationFilter 的位置示意图

          上图中的 RequestValidationFilter 确保那些没有携带有效请求头信息的请求不会执行不必要的用户认证。基于这种场景,把 RequestValidationFilter 放在 BasicAuthenticationFilter 之后就不是很合适了,因为用户已经完成了认证操作。

          同样,针对前面已经构建的 LoggingFilter,原则上我们可以把它放在过滤器链的任何位置,因为它只记录了日志。但有没有更合适的位置呢?结合 RequestValidationFilter 来看,同样对于一个无效的请求而言,记录日志是没有什么意义的。所以 LoggingFilter 应该放置在 RequestValidationFilter 之后。另一方面,对于日志操作而言,通常只需要记录那些已经通过认证的请求,所以也推荐将 LoggingFilter 放在 BasicAuthenticationFilter 之后。最终,这三个过滤器之间的关系如下图所示:

          -

          Drawing 4.png

          +

          png

          三个过滤器的位置示意图

          在 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 中常用的过滤器名称、功能以及它们的顺序关系:

          -

          image.png

          +

          png

          Spring Security 中的常见过滤器一览表

          这里以最基础的 UsernamePasswordAuthenticationFilter 为例,该类的定义及核心方法 attemptAuthentication 如下所示:

          public class UsernamePasswordAuthenticationFilter extends
          @@ -294,7 +294,7 @@ protected void configure(HttpSecurity http) throws Exception {
           }
           

          围绕上述方法,我们结合前面已经介绍的认证和授权相关实现原理,可以引出该框架中一系列核心类并梳理它们之间的交互结构,如下图所示:

          -

          image

          +

          png

          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 中的过滤器架构和开发方式都做了详细的介绍,你可以反复学习。

          本讲内容总结如下:

          -

          Drawing 6.png

          +

          png

          最后,给你留一道思考题:在 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:

          -

          Drawing 0.png

          +

          png

          CSRF 运行流程图

          具体流程如下:

            @@ -340,7 +340,7 @@ protected void configure(HttpSecurity http) throws Exception {

          作为总结,我们可以用如下所示的示意图来梳理整个定制化 CSRF 所包含的各个组件以及它们之间的关联关系:

          -

          Drawing 1.png

          +

          png

          定制化 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 中,针对这两个场景都提供了对应的过滤器,我们只需要通过简单的配置方法就能在系统中自动集成想要的功能。

          本讲主要内容如下:

          -

          Drawing 2.png

          +

          png

          最后我想给你留一道思考题:在 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() 方法,而没有该权限的请求将直接抛出一个异常,如下图所示:

          -

          Drawing 0.png

          +

          png

          Service 层组件预授权示意图

          显然,上述流程针对的是预授权的应用场景,因此我们可以使用 @PreAuthorize 注解,

          该注解定义如下:

          @@ -238,7 +238,7 @@ public List<Order> getOrderByUser(String user) {

          这里我们将输入的“user”参数与通过 SpEL 表达式从安全上下文中获取的“authentication.principal.username”进行比对,如果相同就执行正确的方法逻辑,反之将直接抛出异常。

          @PostAuthorize 注解

          相较 @PreAuthorize 注解,@PostAuthorize 注解的应用场景可能少见一些。有时我们允许调用者正确调用方法,但希望该调用者不接受返回的响应结果。这听起来似乎有点奇怪,但在那些访问第三方外部系统的应用中,我们并不能完全相信返回数据的正确性,也有对调用的响应结果进行限制的需求,@PostAuthorize 注解为我们实现这类需求提供了很好的解决方案,如下所示:

          -

          Drawing 1.png

          +

          png

          Service 层组件后授权示意图

          为了演示 @PostAuthorize 注解,我们先来设定特定的返回值。假设我们存在如下所示的一个 Author 对象,保存着该作者的姓名和创作的图书作品:

          public class Author {
          @@ -304,7 +304,7 @@ public List<Product> findProducts() {
           

          小结与预告

          这一讲我们关注的重点从 HTTP 端点级别的安全控制转换到了普通方法级别的安全控制。Spring Security 内置了一组非常实用的注解,方便开发人员实现全局方法安全机制,包括用于实现方法级别授权的 @PreAuthorize 和 @PostAuthorize 注解,以及用于实现方法级别过滤的 @PreFilter 注解和 @PostFilter 注解。我们针对这些注解的使用方法也给出了相应的描述和示例代码。

          本讲内容总结如下:

          -

          Drawing 2.png

          +

          png

          这里给你留一道思考题:针对 Spring Security 提供的全局方法安全机制,你能描述方法级别授权和方法级别过滤的区别以及它们各自的应用场景吗?欢迎在留言区写下你的想法。

          diff --git a/专栏/Spring Security 详解与实操/11 案例实战:使用 Spring Security 高级主题保护 Web 应用.md.html b/专栏/Spring Security 详解与实操/11 案例实战:使用 Spring Security 高级主题保护 Web 应用.md.html index dffbd5ef..f9640480 100644 --- a/专栏/Spring Security 详解与实操/11 案例实战:使用 Spring Security 高级主题保护 Web 应用.md.html +++ b/专栏/Spring Security 详解与实操/11 案例实战:使用 Spring Security 高级主题保护 Web 应用.md.html @@ -160,7 +160,7 @@ function hide_canvas() {

          多因素认证设计

          多因素认证是一种安全访问控制的方法,基本的设计理念在于用户想要访问最终的资源,至少需要通过两种以上的认证机制

          那么,我们如何实现多种认证机制呢?一种常见的做法是分成两个步骤,第一步通过用户名和密码获取一个认证码(Authentication Code),第二步基于用户名和这个认证码进行安全访问。基于这种多因素认证的基本执行流程如下图所示:

          -

          Drawing 0.png

          +

          png

          多因素认证的实现方式示意图

          系统初始化

          为了实现多因素认证,我们需要构建一个独立的认证服务 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,如下图所示:

          -

          Drawing 2.png

          +

          png

          Business-Service 调用 Auth-Service 关系图

          接下来,我们分别从这两个服务入手,实现多因素认证机制。

          实现多因素认证机制

          @@ -431,7 +431,7 @@ public class CustomAuthenticationFilter extends OncePerRequestFilter {

          CustomAuthenticationFilter 的实现过程比较简单,代码也都是自解释的,唯一需要注意的是在基于认证码的认证过程通过之后,我们会在响应中添加一个“Authorization”消息头,并使用 UUID 值作为 Token 进行返回

          针对上述代码,我们可以通过如下所示的类图进行总结:

          -

          Drawing 4.png

          +

          png

          多因素认证执行核心类图

          最后,我们需要通过 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 并输入相关参数,如下所示:

          -

          Drawing 5.png

          +

          png

          多因素认证的第一步认证示意图:基于用户名+密码

          显然,该请求只传入了用户名和密码,所以会基于 UsernamePasswordAuthenticationProvider 执行认证过程,从而为用户“jianxiang”生成认证码。认证码是动态生成的,所以每次请求对应的结果都是不一样的,我通过查询数据库,获取该认证码为“9750”,你也可以自己做一些尝试。

          有了认证码,相当于完成了多因素认证机制的第一步。接下来,我们再次基于这个认证码构建请求并获取响应结果,如下所示:

          -

          Drawing 7.png

          +

          png

          多因素认证的第二步认证示意图:基于用户名+认证码

          可以看到,通过传入正确的认证码,我们基于 AuthCodeAuthenticationProvider 完成了多因素认证机制中的第二步认证,并最终在 HTTP 响应中生成了一个“Authorization”消息头。

          小结与预告

          这一讲我们基于多因素认证机制展示了如何利用 Spring Security 中的一些高级主题保护 Web 应用程序的实现方法。多因素认证机制的实现需要构建多个自定义的 AuthenticationProvider,并通过拦截器完成对请求的统一处理。相信案例中展示的这些开发技巧会给你的日常开发工作带来帮助。

          本讲内容总结如下:

          -

          Drawing 9.png

          +

          png

          这里给你留一道思考题:在 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 协议的应用场景

          在常见的电商系统中,通常会存在类似工单处理的系统,而工单的生成在使用用户基本信息的同时,势必也依赖于用户的订单记录等数据。为了降低开发成本,假设我们的整个商品订单模块并不是自己研发的,而是集成了外部的订单管理平台,此时为了生成工单记录,就必须让工单系统读取用户在订单管理平台上的订单记录。

          在这个场景中,难点在于只有得到用户的授权,才能同意工单系统读取用户在订单管理平台上的订单记录。那么问题就来了,工单系统如何获得用户的授权呢?一般我们想到的方法是用户将自己在订单管理平台上的用户名和密码告诉工单系统,然后工单系统通过用户名和密码登录到订单管理平台并读取用户的订单记录,整个过程如下图所示:

          -

          Drawing 0.png

          +

          png

          案例系统中用户认证和授权交互示意图

          上图中的方案虽然可行,但显然存在几个严重的缺点:

            @@ -170,11 +170,11 @@ function hide_canvas() {

          既然这个方案存在如此多的问题,那么有没有更好的办法呢?答案是肯定的,OAuth2 协议的诞生就是为了解决这些问题。

          首先,针对密码的安全性,在 OAuth2 协议中,密码还是由用户自己保管,避免了敏感信息的泄露;其次,OAuth2 协议中提供的授权具有明确的应用范围和有效期,用户可以根据需要限制工单系统所获取授权信息的作用效果;最后,如果用户对自己的密码等身份凭证信息进行了修改,只需通过 OAuth2 协议重新进行一次授权即可,不会影响到相关联的其他第三方应用程序。

          -

          Drawing 1.png

          +

          png

          传统认证授权机制与 OAuth2 协议的对比图

          OAuth2 协议的角色

          OAuth2 协议之所有能够具备这些优势,一个主要的原因在于它把整个系统涉及的各个角色及其职责做了很好地划分。OAuth2 协议中定义了四个核心的角色:资源、客户端、授权服务器和资源服务器

          -

          Drawing 2.png

          +

          png

          OAuth2 协议中的角色定义

          我们可以把 OAuth2 中的角色与现实中的应用场景对应起来。

            @@ -201,7 +201,7 @@ function hide_canvas() {
          • scope:指定了可访问的权限范围,这里指定的是访问 Web 资源的“webclient”。

          现在我们已经介绍完令牌,你可能会好奇这样一个令牌究竟有什么用?接下来,我们就来看如何使用令牌完成基于 OAuth2 协议的授权工作流程。整个流程如下图所示: -Drawing 3.png

          +png

          基于 OAuth2 协议的授权工作流程图

          我们可以把上述流程进一步展开梳理。

            @@ -213,26 +213,26 @@ function hide_canvas() {

            OAuth2 协议的授权模式

            在整个工作流程中,最为关键的是第二步,即获取用户的有效授权。那么如何获取用户授权呢?在 OAuth 2.0 中,定义了四种授权方式,即授权码模式(Authorization Code)、简化模式(Implicit)、密码模式(Password Credentials)和客户端模式(Client Credentials)

            我们先来看最具代表性的授权码模式。当用户同意授权后,授权服务器返回的只是一个授权码,而不是最终的访问令牌。在这种授权模式下,需要客户端携带授权码去换令牌,这就需要客户端自身具备与授权服务器进行直接交互的后台服务。

            -

            Drawing 4.png

            +

            png

            授权码模式工作流程图

            我们简单梳理一下授权码模式下的执行流程。

            首先,用户在访问客户端时会被客户端导向授权服务器,此时用户可以选择是否给予客户端授权。一旦用户同意授权,授权服务器会调用客户端的后台服务提供的一个回调地址,并在调用过程中将一个授权码返回给客户端。客户端收到授权码后进一步向授权服务器申请令牌。最后,授权服务器核对授权码并向客户端发送访问令牌。

            这里要注意的是,通过授权码向授权服务器申请令牌的过程是系统自动完成的,不需要用户的参与,用户需要做的就是在流程启动阶段同意授权

            接下来,我们再来看另一种比较常用的密码模式,其授权流程如下图所示:

            -

            Drawing 5.png

            +

            png

            密码模式工作流程图

            可以看到,密码模式比较简单,也更加容易理解。用户要做的就是提供自己的用户名和密码,然后客户端会基于这些用户信息向授权服务器请求令牌。授权服务器成功执行用户认证操作后将会发放令牌。

            OAuth2 中的客户端模式和简化模式因为在日常开发过程中应用得不是很多,这里就不详细介绍了。

            你可能注意到了,虽然 OAuth2 协议解决的是授权问题,但它也应用到了认证的概念,这是因为只有验证了用户的身份凭证,我们才能完成对他的授权。所以说,OAuth2 实际上是一款技术体系比较复杂的协议,综合应用了信息摘要、签名认证等安全性手段,并需要提供令牌以及背后的公私钥管理等功能

            OAuth2 协议与微服务架构

            对应到微服务系统中,服务提供者充当的角色就是资源服务器,而服务消费者就是客户端。所以每个服务本身既可以是客户端,也可以作为资源服务器,或者两者兼之。当客户端拿到 Token 之后,该 Token 就能在各个服务之间进行传递。如下图所示: -Drawing 6.png

            +png

            OAuth2 协议在服务访问场景中的应用

            在整个 OAuth2 协议中,最关键的问题就是如何获取客户端授权。就目前主流的微服架构来说,当我们发起 HTTP 请求时,关注的是如何通过 HTTP 协议透明而高效地传递令牌,此时授权码模式下通过回调地址进行授权管理的方式就不是很实用,密码模式反而更加简洁高效。因此,在本专栏中,我们将使用密码模式作为 OAuth2 协议授权模式的默认实现方式。

            小结与预告

            今天我们进入微服务安全性领域展开了探讨,在这个领域中,认证和授权仍然是最基本的安全性控制手段。通过系统分析微服务架构中的认证和授权解决方案,我们引入了 OAuth2 协议,这也是微服务架构体系下主流的授权协议。我们对 OAuth2 协议具备的角色、授权模式以及与微服务架构之间的集成关系做了详细展开。

            本讲内容总结如下:

            -

            Drawing 7.png

            +

            png

            最后给你留一道思考题:你能描述 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。整个流程如下图所示:

            -

            Drawing 0.png

            +

            png

            密码模式授权流程示意图

            请注意,授权服务器在这里执行认证操作的目的是验证传入的用户名和密码是否正确。在密码模式下,这一步是必需的,如果采用其他授权模式,不一定会有用户认证这一环节。

            确定采用密码模式后,我们来看为了实现这一授权模式,需要对授权服务器做哪些开发工作。首先我们需要设置一些基础数据,包括客户端信息和用户信息。

            @@ -270,11 +270,11 @@ public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {

            生成 Token

            现在,OAuth2 授权服务器已经构建完毕,启动这个授权服务器,我们就可以获取 Token。我们在构建 OAuth2 服务器时已经提到授权服务器中会暴露一批端点供 HTTP 请求进行访问,而获取 Token 的端点就是http://localhost:8080/oauth/token。在使用该端点时,我们需要提供前面配置的客户端信息和用户信息。

            这里使用 Postman 来模拟 HTTP 请求,客户端信息设置方式如下图所示:

            -

            Drawing 1.png

            +

            png

            客户端信息设置示意图

            我们在“Authorization”请求头中指定认证类型为“Basic Auth”,然后设置客户端名称和客户端安全码分别为“spring”和“spring_secret”。

            接下来我们指定针对授权模式的专用配置信息。首先是用于指定授权模式的 grant_type 属性,以及用于指定客户端访问范围的 scope 属性,这里分别设置为 “password”和“webclient”。既然设置了密码模式,所以也需要指定用户名和密码用于识别用户身份,这里,我们以“spring_user”这个用户为例进行设置,如下图所示:

            -

            Drawing 2.png

            +

            png

            用户信息设置示意图

            在 Postman 中执行这个请求,会得到如下所示的返回结果:

            {
            @@ -289,7 +289,7 @@ public class SpringWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
             

            小结与预告

            对微服务访问进行安全性控制的首要条件是生成一个访问 Token。这一讲我们从构建 OAuth2 服务器讲起,基于密码模式给出了如何设置客户端信息、用户认证信息以及最终生成 Token 的实现过程。这个过程中需要开发人员熟悉 OAuth2 协议的相关概念以及 Spring Security 框架中提供的各项配置功能。

            本讲内容总结如下:

            -

            13-2.jpg

            +

            png

            最后给你留一道思考题:基于密码模式,你能说明生成 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 中,对访问的不同控制层级进行了抽象,形成了用户、角色和请求方法这三种粒度,如下图所示:

            -

            image-2.png

            +

            png

            用户、角色和请求方法三种控制粒度示意图

            基于上图,我们可以对这三种粒度进行排列组合,形成用户、用户+角色以及用户+角色+请求方法这三种层级,这三种层级能够访问的资源范围逐一递减。用户层级是指只要是认证用户就能访问服务内的各种资源;而用户+角色层级在用户层级的基础上,还要求用户属于某一个或多个特定角色;最后的用户+角色+请求方法层级要求最高,能够对某些 HTTP 操作进行访问限制。接下来我们针对这三个层级展开讨论。

            用户层级的权限访问控制

            @@ -263,7 +263,7 @@ public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter

            现在,我们使用普通“USER”角色生成的 Token,并调用"/order/"端点中的 Update 操作,同样会得到“access_denied”的错误信息。而尝试使用“ADMIN”角色生成的 Token 进行访问,就可以得到正常响应。

            在微服务中传播 Token

            我们知道一个微服务系统势必涉及多个服务之间的调用,并形成一个链路。因为访问所有服务的过程都需要进行访问权限的控制,所以我们需要确保生成的 Token 能够在服务调用链路中进行传播,如下图所示:

            -

            image-3.png

            +

            png

            微服务中 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 请求中。

          整个实现思路如下图所示:

          -

          1.png +

          png 在服务调用链中传播 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),整个请求的跳转过程如下图所示:

          -

          17-2.png

          +

          png

          未登录状态访问 app1 时的网络请求跳转流程图

          我们在 SSO 服务器的登录界面输入正确的用户名和密码之后就可以认证成功了,这时候我们再看网络请求的过程,如下所示:

          -

          17-3png.png

          +

          png

          登录 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 层组件的结果是可以获取的,如下所示:

          -

          2.jpg

          +

          png

          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 以上。正因如此,学习和提高分布式系统开发能力,也成为传统软件开发人员转行和寻求高薪职位的必要条件

          -

          image.png]

          +

          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 中的数据模型。

          -

          image

          +

          png

          ZooKeeper 中的数据模型是一种树形结构,非常像电脑中的文件系统,有一个根文件夹,下面还有很多子文件夹。ZooKeeper 的数据模型也具有一个固定的根节点(/),我们可以在根节点下创建子节点,并在子节点下继续创建下一级节点。ZooKeeper 树中的每一层级用斜杠(/)分隔开,且只能用绝对路径(如“get /work/task1”)的方式查询 ZooKeeper 节点,而不能使用相对路径。具体的结构你可以看看下面这张图:

          -

          image

          +

          png

          znode 节点类型与特性

          知道了 ZooKeeper 的数据模型是一种树形结构,就像在 MySQL 中数据是存在于数据表中,ZooKeeper 中的数据是由多个数据节点最终构成的一个层级的树状结构,和我们在创建 MySOL 数据表时会定义不同类型的数据列字段,ZooKeeper 中的数据节点也分为持久节点、临时节点和有序节点三种类型:

          1、持久节点

          @@ -241,14 +241,14 @@ create /works

          2、临时节点

          接下来我们来介绍临时节点。从名称上我们可以看出该节点的一个最重要的特性就是临时性。所谓临时性是指,如果将节点创建为临时节点,那么该节点数据不会一直存储在 ZooKeeper 服务器上。当创建该临时节点的客户端会话因超时或发生异常而关闭时,该节点也相应在 ZooKeeper 服务器上被删除。同样,我们可以像删除持久节点一样主动删除临时节点。

          在平时的开发中,我们可以利用临时节点的这一特性来做服务器集群内机器运行情况的统计,将集群设置为“/servers”节点,并为集群下的每台服务器创建一个临时节点“/servers/host”,当服务器下线时该节点自动被删除,最后统计临时节点个数就可以知道集群中的运行情况。如下图所示:

          -

          image

          +

          png

          3、有序节点

          最后我们再说一下有序节点,其实有序节点并不算是一种单独种类的节点,而是在之前提到的持久节点和临时节点特性的基础上,增加了一个节点有序的性质。所谓节点有序是说在我们创建有序节点的时候,ZooKeeper 服务器会自动使用一个单调递增的数字作为后缀,追加到我们创建节点的后边。例如一个客户端创建了一个路径为 works/task- 的有序节点,那么 ZooKeeper 将会生成一个序号并追加到该节点的路径后,最后该节点的路径为 works/task-1。通过这种方式我们可以直观的查看到节点的创建顺序。

          到目前为止我们知道在 ZooKeeper 服务器上存储数据的基本信息,知道了 ZooKeeper 中的数据节点种类有持久节点和临时节点等。上述这几种数据节点虽然类型不同,但 ZooKeeper 中的每个节点都维护有这些内容:一个二进制数组(byte data[]),用来存储节点的数据、ACL 访问控制信息、子节点数据(因为临时节点不允许有子节点,所以其子节点字段为 null),除此之外每个数据节点还有一个记录自身状态信息的字段 stat。

          下面我们详细说明节点的状态信息。

          节点的状态结构

          每个节点都有属于自己的状态信息,这就很像我们每个人的身份信息一样,我们打开之前的客户端,执行 stat /zk_test,可以看到控制台输出了一些信息,这些就是节点状态信息。

          -

          image

          +

          png

          每一个节点都有一个自己的状态属性,记录了节点本身的一些信息,这些属性包括的内容我列在了下面这个表格里:

          表.png

          数据节点的版本

          @@ -261,16 +261,16 @@ create /works 悲观锁认为进程对临界区的竞争总是会出现,为了保证进程在操作数据时,该条数据不被其他进程修改。数据会一直处于被锁定的状态。

          我们假设一个具有 n 个进程的应用,同时访问临界区资源,我们通过进程创建 ZooKeeper 节点 /locks 的方式获取锁。

          线程 a 通过成功创建 ZooKeeper 节点“/locks”的方式获取锁后继续执行,如下图所示:

          -

          image

          +

          png

          这时进程 b 也要访问临界区资源,于是进程 b 也尝试创建“/locks”节点来获取锁,因为之前进程 a 已经创建该节点,所以进程 b 创建节点失败无法获得锁。

          -

          image

          +

          png

          这样就实现了一个简单的悲观锁,不过这也有一个隐含的问题,就是当进程 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 异常中断操作。

          -

          image

          +

          png

          总结

          本节课主要介绍了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 添加服务器监控事件的方式,下面我们来讲解一下触发通知的条件。

          -

          image.png

          +

          png

          上图中列出了客户端在不同会话状态下,相应的在服务器节点所能支持的事件类型。例如在客户端连接服务端的时候,可以对数据节点的创建、删除、数据变更、子节点的更新等操作进行监控。

          现在我们已经从应用层的角度了解了 ZooKeeper 中的 Watch 机制,而学习 ZooKeeper 过程中一个大问题就是入门容易精通难,像上边我们通过几个简单的 API 调用就可以对服务器的节点状态变更进行监控,但是在实际生产环境中我们会遇到很多意想不到的问题,要想解决好这些问题就要深入理解 Watch 的底层实现机制。

          Watch 机制的底层原理

          现在我们就深入底层了解其背后的实现原理。与上个课时直接通过底层代码的调用过程来分析不同,在 Watch 底层实现的分析阶段,由于 Watch 机制涉及了客户端和服务端的多个函数和操作节点,单单按照程序执行流程分析跳跃性对整体实现机制的理解难度大,这也是我在学习 Watch 这部分底层实现遇到的问题。为了更好地阐述 Watch 机制,我们另辟蹊径,从设计模式角度出发来分析其底层实现:

          -

          image

          +

          png

          最初我在开始学习 Watch 机制的时候,它给我的第一印象是,其结构很像设计模式中的”观察者模式“,一个对象或者数据节点可能会被多个客户端监控,当对应事件被触发时,会通知这些对象或客户端。我们可以将 Watch 机制理解为是分布式环境下的观察者模式。所以接下来我们就以观察者模式的角度点来看看 ZooKeeper 底层 Watch 是如何实现的。

          -

          image

          +

          png

          通常我们在实现观察者模式时,最核心或者说关键的代码就是创建一个列表来存放观察者。 而在 ZooKeeper 中则是在客户端和服务器端分别实现两个存放观察者列表,即:ZKWatchManager 和 WatchManager。其核心操作就是围绕着这两个展开的。

          客户端 Watch 注册实现过程

          @@ -400,7 +400,7 @@ rsp = new GetDataResponse(b, stat);

          提到 ZooKeeper 的应用场景,你可能第一时间会想到最为典型的发布订阅功能。发布订阅功能可以看作是一个一对多的关系,即一个服务或数据的发布者可以被多个不同的消费者调用。一般一个发布订阅模式的数据交互可以分为消费者主动请求生产者信息的拉取模式,和生产者数据变更时主动推送给消费者的推送模式。ZooKeeper 采用了两种模式结合的方式实现订阅发布功能。下面我们来分析一个具体案例:

          在系统开发的过程中会用到各种各样的配置信息,如数据库配置项、第三方接口、服务地址等,这些配置操作在我们开发过程中很容易完成,但是放到一个大规模的集群中配置起来就比较麻烦了。通常这种集群中,我们可以用配置管理功能自动完成服务器配置信息的维护,利用ZooKeeper 的发布订阅功能就能解决这个问题。

          我们可以把诸如数据库配置项这样的信息存储在 ZooKeeper 数据节点中。如图中的 /confs/data_item1。服务器集群客户端对该节点添加 Watch 事件监控,当集群中的服务启动时,会读取该节点数据获取数据配置信息。而当该节点数据发生变化时,ZooKeeper 服务器会发送 Watch 事件给各个客户端,集群中的客户端在接收到该通知后,重新读取节点的数据库配置信息。

          -

          image

          +

          png

          我们使用 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 种方式的控制权。这种权限控制方式相对比较粗糙,在复杂的授权场景下往往并不适用。比如下边一个应用场景。

          -

          1.png

          +

          png

          上图给出了某个技术开发公司的一个工作项目 /object 。项目中的每个开发人员都可以读取和修改该项目中的文件,作为开发组长也对这个项目文件具有读取和修改的权限。其他技术开发组的员工则不能访问这个项目。如果我们用之前说到的 Linux 权限应该怎么设计呢?

          首先作为技术组长使用 User 身份,具有读、写、执行权限。项目组其他成员使用 Group 身份,具有读写权限,其他项目组的人员则没有任何权限。这样就实现了满足要求的权限设定了。

          但是,如果技术组新加入一个实习人员,为了能让他熟悉项目,必须具有该项目的读取的权限。但是目前他不具备修改项目的能力,所以并没给他赋予写入的权限。而如果使用现有的权限设置,显然将其分配给 User 用户或者 Group 用户都并不合适。而如果修改 Others 用户的权限,其他项目组的成员也能访问该项目文件。显然普通的三种身份的权限划分是无法满足要求的。而 ZooKeeper 中的 ACl 就能应对这种复杂的权限应用场景。

          @@ -226,13 +226,13 @@ addauth digest user:passwd
        • 数据节点(delete)删除权限,授予权限的对象可以删除该数据节点的子节点;
        • 数据节点(admin)管理者权限,授予权限的对象可以对该数据节点体进行 ACL 权限设置。
        -

        image

        +

        png

        需要注意的一点是,每个节点都有维护自身的 ACL 权限数据,即使是该节点的子节点也是有自己的 ACL 权限而不是直接继承其父节点的权限。如下中“172.168.11.1”服务器有“/Config”节点的读取权限,但是没有其子节点的“/Config/dataBase_Config1”权限。

        -

        image

        +

        png

        实现自己的权限口控制

        通过上边的介绍我们了解了 ZooKeeper 中的权限相关知识,虽然 ZooKeeper 自身的权限控制机制已经做得很细,但是它还是提供了一种权限扩展机制来让用户实现自己的权限控制方式。官方文档中对这种机制的定义是 “Pluggable ZooKeeper Authenication”,意思是可插拔的授权机制,从名称上我们可以看出它的灵活性。那么这种机制是如何实现的呢?

        首先,要想实现自定义的权限控制机制,最核心的一点是实现 ZooKeeper 提供的权限控制器接口 AuthenticationProvider。下面这张图片展示了接口的内部结构,用户通过该接口实现自定义的权限控制。

        -

        image

        +

        png

        实现了自定义权限后,如何才能让 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 等方式进行序列化操作。

        -

        image

        +

        png

        而对应于序列化操作,在反序列化时也会相应调用不同的实现类,来进行反序列化操作。 如下图所示:

        -

        image

        +

        png

        注意:无论是序列化还是反序列化,都可以对多个对象进行操作,所以当我们在定义序列化和反序列化方法时,需要字符类型参数 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 配置文件解析、创建历史文件清理器等,如下图所示:

        -

        image

        +

        png

        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 {
         
      34. FileTxnSnapLog 类,可以用于数据管理。
      35. 会话管理类,设置服务器 TickTime 和会话超时时间、创建启动会话管理器等操作。
      -

      image

      +

      png

      下面我们就分别分析一下这几个关键步骤在 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 个类处理请求中的对应逻辑,如下图所示。具体的内容,我们会在后面的课程中详细讲解。

      -

      image

      +

      png

      总结

      本课时是我们进阶篇阶段的第一课,在整个进阶篇中,我们主要从 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),如下图所示。

      -

      image

      +

      png

      下面我们来分别介绍一下这三个部分:

      • 会话 ID:会话 ID 作为一个会话的标识符,当我们创建一次会话的时候,ZooKeeper 会自动为其分配一个唯一的 ID 编码。
      • @@ -205,7 +205,7 @@ function hide_canvas() {

      会话状态

      通过上面的学习,我们知道了 ZooKeeper 中一次会话的内部结构。下面我们就从系统运行的角度去分析,一次会话从创建到关闭的生命周期中都经历了哪些阶段。

      -

      11.png

      +

      png

      上面是来自 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 内部的数据结构如下图所示:

      -

      image.png

      +

      png

      在 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 对每一个会话进行检查,而是采用分批次的方式管理会话。这就降低了会话管理的难度,因为每次小批量的处理会话过期也提高了会话处理的效率。

        -

        image

        +

        png

        通过上面的介绍,我们对 ZooKeeper 中的会话管理策略有了一个比较形象的理解。而为了能够在日常开发中使用好 ZooKeeper,面对高并发的客户端请求能够开发出更加高效稳定的服务,根据服务器日志判断客户端与服务端的会话异常等。下面我们从技术角度去说明 ZooKeeper 会话管理的策略,进一步加强对会话管理的理解。

        底层实现

        说到 ZooKeeper 底层实现的原理,核心的一点就是过期队列这个数据结构。所有会话过期的相关操作都是围绕这个队列进行的。可以说 ZooKeeper 底层就是采用这个队列结构来管理会话过期的。

        而在讲解会话过期队列之前,我们首先要知道什么是 bucket。简单来说,一个会话过期队列是由若干个 bucket 组成的。而 bucket 是一个按照时间划分的区间。在 ZooKeeper 中,通常以 expirationInterval 为单位进行时间区间的划分,它是 ZooKeeper 分桶策略中用于划分时间区间的最小单位。

        在 ZooKeeper 中,一个过期队列由不同的 bucket 组成。每个 bucket 中存放了在某一时间内过期的会话。将会话按照不同的过期时间段分别维护到过期队列之后,在 ZooKeeper 服务运行的过程中,具体的执行过程如下图所示。首先,ZooKeeper 服务会开启一个线程专门用来检索过期队列,找出要过期的 bucket,而 ZooKeeper 每次只会让一个 bucket 的会话过期,每当要进行会话过期操作时,ZooKeeper 会唤醒一个处于休眠状态的线程进行会话过期操作,之后会按照上面介绍的操作检索过期队列,取出过期的会话后会执行过期操作。

        -

        image

        +

        png

        下面我们再来看一下 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 服务端在处理一次客户端发起的会话请求时,所采用的处理过程很像是一条工厂中的流水生产线。比如在一个毛绒玩具加工厂中,一条生产线上的工人可能只负责给玩具上色这一个具体的工作。

        -

        image

        +

        png

        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 流程,下面我将分别对这几个流程进行讲解。

        -

        image

        +

        png

        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 服务会采取不同的处理方式,而一个会话基本有六种状态,如下图所示:

        -

        image

        +

        png

        下面我来为你详细讲解这六种状态的作用:

        • CONNECTED(已连接状态):当客户端发起的会话成功连接到服务端后,该条会话的状态变为 CONNECTED 已连接状态。
        • @@ -287,7 +287,7 @@ CuratorFrameworkFactory.Builder.canBeReadOnly() 的时候,该会话会一直

          Leader 选举

          除了异常处理,接下来我们再介绍一个在日常工作中经常要解决的问题,即开发 ZooKeeper 集群的相关功能。

          在分布式环境中,ZooKeeper 集群起到了关键作用。在之前的课程中我们讲过,Leader 选举是保证 ZooKeeper 集群可用性的解决方案,可以避免在集群使用中出现单点失效等问题。在 ZooKeeper 服务开始运行的时候,首先会选举出 Leader 节点服务器,之后在服务运行过程中,Leader 节点服务器失效时,又会重新在集群中进行 Leader 节点的选举操作。

          -

          image

          +

          png

          而在日常开发中,使用 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 集群服务产生不一致的情况。如下图所示:

          -

          image.png

          +

          png

          在集群初始化启动的时候,首先要同步集群中各个服务器上的数据。而在集群中 Leader 服务器崩溃时,需要选举出新的 Leader 而在这一过程中会导致各个服务器上数据的不一致,所以当选举出新的 Leader 服务器后需要进行数据的同步操作。

          底层实现

          与上面介绍的一样,我们的底层实现讲解主要围绕 ZooKeeper 集群中数据一致性的底层实现。ZooKeeper 在集群中采用的是多数原则方式,即当一个事务性的请求导致服务器上的数据发生改变时,ZooKeeper 只要保证集群上的多数机器的数据都正确变更了,就可以保证系统数据的一致性。 这是因为在一个 ZooKeeper 集群中,每一个 Follower 服务器都可以看作是 Leader 服务器的数据副本,需要保证集群中大多数机器数据是一致的,这样在集群中出现个别机器故障的时候,ZooKeeper 集群依然能够保证稳定运行。

          在 ZooKeeper 集群服务的运行过程中,数据同步的过程如下图所示。当执行完数据变更的会话请求时,需要对集群中的服务器进行数据同步。

          -

          image

          +

          png

          广播模式

          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 选举主要可以分为三大步骤分别是:发起投票、接收投票、统计投票。

          -

          2.png

          +

          png

          发起投票

          我们先来看一下发起投票的流程,在 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 服务器。

          -

          1.png

          +

          png

          当 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 集群又提供四种同步方式,如下图所示:

          -

          image

          +

          png

          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 会话请求后,其内部的处理逻辑基本可以分成四个部分。如下图所示,分别是预处理阶段、事务处理阶段、事务执行阶段、响应客户端。

          -

          image.png

          +

          png

          预处理阶段:

          在预处理阶段,主要工作是通过网络 I/O 接收来自客户端的会话请求。判断该条会话请求的类型是否是事务性的会话请求,之后将该请求提交给

          PrepRequestProcessor 处理器进行处理。封装请求事务头并检查会话是否过期,最后反序列化事务请求信息创建 setDataRequest 请求,在 setDataRequest 记录中包含了要创建数据的节点的路径、数据节点的内容信息以及数据节点的版本信息。最后将该请求存放在 outstandingChanges 队列中等待之后的处理。

          -

          1.png

          +

          png

          事务处理阶段:

          在事务处理阶段,ZooKeeper 集群内部会将该条会话请求提交给 ProposalRequestProcessor 处理器进行处理。本阶段内部又分为提交、同步、统计三个步骤。其具体的处理过程我们在之前的课程中已经介绍过了,这里不再赘述。

          -

          2.png

          +

          png

          事务执行阶段:

          在经过预处理阶段和事务会话的投票发起等操作后,一个事务性的会话请求都已经准备好了,接下来就是在 ZooKeeper 的数据库中执行该条会话的数据变更操作。

          在处理数据变更的过程中,ZooKeeper 内部会将该请求会话的事务头和事务体信息直接交给内存数据库 ZKDatabase 进行事务性的持久化操作。之后返回 ProcessTxnResult 对象表明操作结果是否成功。

          -

          3.png

          +

          png

          响应客户端:

          在 ZooKeeper 集群处理完客户端 setData 方法发送的数据节点创建请求后,会将处理结果发送给客户端。而在响应客户端的过程中,ZooKeeper 内部首先会创建一个 setDataResponse 响应体类型,该对象主要包括当前会话请求所创建的数据节点,以及其最新状态字段信息 stat。之后创建请求响应头信息,响应头作为客户端请求响应的重要信息,客户端在接收到 ZooKeeper 集群的响应后,通过解析响应头信息中的事务 ZXID 和请求结果标识符 err 来判断该条会话请求是否成功执行。

          -

          4.png

          +

          png

          事务处理底层实现

          介绍完 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 类型的队列字段,用于存放接收到的会话请求。

          -

          image

          +

          png

          在定义了 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 服务器角色变更、集群同步这几个步骤。

          -

          image

          +

          png

          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 服务器进行协调沟通。

          -

          image

          +

          png

          如上图所示,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 服务器的。

          -

          image

          +

          png

          在早期的 ZooKeeper 集群服务运行过程中,只有 Leader 服务器和 Follow 服务器。不过随着 ZooKeeper 在分布式环境下的广泛应用,早期模式的设计缺点也随之产生,主要带来的问题有如下几点:

          1. 随着集群规模的变大,集群处理写入的性能反而下降。
          2. @@ -209,7 +209,7 @@ function hide_canvas() {

            正因如此,随着集群中 Follow 服务器的数量越来越多,一次写入等相关操作的投票也就变得越来越复杂,并且 Follow 服务器之间彼此的网络通信也变得越来越耗时,导致随着 Follow 服务器数量的逐步增加,事务性的处理性能反而变得越来越低。

            为了解决这一问题,在 ZooKeeper 3.6 版本后,ZooKeeper 集群中创建了一种新的服务器角色,即 Observer——观察者角色服务器。Observer 可以处理 ZooKeeper 集群中的非事务性请求,并且不参与 Leader 节点等投票相关的操作。这样既保证了 ZooKeeper 集群性能的扩展性,又避免了因为过多的服务器参与投票相关的操作而影响 ZooKeeper 集群处理事务性会话请求的能力。

            在引入 Observer 角色服务器后,一个 ZooKeeper 集群服务在部署的拓扑结构,如下图所示:

            -

            image

            +

            png

            在实际部署的时候,因为 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() {
          3. VERSION:设置日志文件的版本信息。
          4. lastZxidSeen:最后一次更新日志得到的 ZXID。
        -

        image

        +

        png

        定义了事务日志操作的相关指标参数后,在 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 写入操作。

        -

        image

        +

        png

        获取锁

        当某一个事务在访问共享数据时,首先需要获取锁。ZooKeeper 中的所有客户端会在 Locks_shared 节点下创建一个临时顺序节点。根据对数据对象的操作类型创建不同的数据节点,如果是读操作,就创建名称中带有 R 标志的顺序节点,如果是写入操作就创建带有 W 标志的顺序节点。

        -

        image

        +

        png

        释放锁

        事务逻辑执行完毕后,需要对事物线程占有的共享锁进行释放。我们可以利用 ZooKeeper 中数据节点的性质来实现主动释放锁和被动释放锁两种方式。

        主动释放锁是当客户端的逻辑执行完毕,主动调用 delete 函数删除ZooKeeper 服务上的数据节点。而被动释放锁则利用临时节点的性质,在客户端因异常而退出时,ZooKeeper 服务端会直接删除该临时节点,即释放该共享锁。

        这种实现方式正好和上面介绍的死锁的两种处理方式相对应。到目前为止,我们就利用 ZooKeeper 实现了一个比较完整的共享锁。如下图所示,在这个实现逻辑中,首先通过创建数据临时数据节点的方式实现获取锁的操作。创建数据节点分为两种,分别是读操作的数据节点和写操作的数据节点。当锁节点删除时,注册了该 Watch 监控的其他客户端也会收到通知,重新发起创建临时节点尝试获取锁。当事务逻辑执行完成,客户端会主动删除该临时节点释放锁。

        -

        X.png

        +

        png

        总结

        通过本课时的学习,我们掌握了什么是分布式锁,以及分布式锁在实际生产环境中面临的问题和挑战。无论是单机上的加锁还是分布式环境下的分布式锁,都会出现死锁问题。面对死锁问题,如果我们不能很好地处理,会严重影响系统的运行。在本课时中,我为你讲解了两种处理死锁问题的方法,分别是超时设置和死锁监控。然后重点介绍了利用 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 编码一般具有唯一性、递增性、安全性、扩展性这几个特性

        -

        1.png

        +

        png

        唯一性: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 表中的信息。

        -

        2.png

        +

        png

        在接收一段 ID 编码后,客户端会将该编码存储在内存中。在本机需要使用 ID 编码时,会首先使用内存中的 ID 编码。如果内存中的 ID 编码已经完全被占用,则再重新向编码服务器获取。

        在 TDDL 框架的内部实现中,通过分批获取 ID 编码的方式,减少了客户端访问服务器的频率,避免了网络波动所造成的影响,并减轻了服务器的内存压力。不过 TDDL 是高度依赖底层数据库的实现方式,不能作为一个独立的分布式 ID 生成器对外提供服务。

        实现方式

        上面介绍的几种策略,有的和底层编码耦合比较大,有的又局限在某一具体的使用场景下,并不满足作为分布式环境下一个公共 ID 生成器的要求。接下来我们就利用目前学到的 ZooKeeper 知识,动手实现一个真正的分布式 ID 生成器。

        首先,我们通过 ZooKeeper 自身的客户端和服务器运行模式,来实现一个分布式网络环境下的 ID 请求和分发过程。每个需要 ID 编码的业务服务器可以看作是 ZooKeeper 的客户端。ID 编码生成器可以作为 ZooKeeper 的服务端。客户端通过发送请求到 ZooKeeper 服务器,来获取编码信息,服务端接收到请求后,发送 ID 编码给客户端。

        -

        Drawing 2.png

        +

        png

        在代码层面的实现中,如上图所示。我们可以利用 ZooKeeper 数据模型中的顺序节点作为 ID 编码。客户端通过调用 create 函数创建顺序节点。服务器成功创建节点后,会响应客户端请求,把创建好的节点信息发送给客户端。客户端用数据节点名称作为 ID 编码,进行之后的本地业务操作。

        通过上面的介绍,我们发现,使用 ZooKeeper 实现一个分布式环境下的公用 ID 编码生成器很容易。利用 ZooKeeper 中的顺序节点特性,很容易使我们创建的 ID 编码具有有序的特性。并且我们也可以通过客户端传递节点的名称,根据不同的业务编码区分不同的业务系统,从而使编码的扩展能力更强。

        虽然使用 ZooKeeper 的实现方式有这么多优点,但也会有一些潜在的问题。其中最主要的是,在定义编码的规则上还是强烈依赖于程序员自身的能力和对业务的深入理解。很容易出现因为考虑不周,造成设置的规则在运行一段时间后,无法满足业务要求或者安全性不够等问题。为了解决这个问题,我们继续学习一个比较常用的编码算法——snowflake 算法。

        snowflake 算法

        snowflake 算法是 Twitter 公司开源的一种用来生成分布式 ID 编码的算法。如下图所示,通过 snowflake 算法生成的编码是一个 64 位的长整型值。在 snowflake 算法中,是通过毫秒数、机器 ID

        毫秒流水号、符号位这几个元素生成最终的编码。

        -

        3.png

        +

        png

        在计算编码的过程中,首先获取机器的毫秒数,并存储为 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等临时节点来存储集群中的服务器运行状态信息。

        -

        Drawing 0.png

        +

        png

        在代码层面的实现中,我们首先定义一个 BlanceSever 接口类。该类规定在 ZooKeeper 服务器启动后,向服务器地址列表中,注册或注销信息以及根据接收到的会话请求,动态更新负载均衡情况等功能。如下面的代码所示:

        public class BlanceSever{
           public void register()
        @@ -257,7 +257,7 @@ function hide_canvas() {
         

        负载算法

        实现服务器列表后,接下来我们就进入负载均衡最核心的内容:如何选择服务器。这里我们通过采用“最小连接数”算法,来确定究竟如何均衡地分配网络会话请求给后台客户端。

        整个实现的过程如下图所示。首先,在接收到客户端的请求后,通过 getData 方法获取服务端 Severs 节点下的服务器列表,其中每个节点信息都存储有当前服务器的连接数。通过判断选择最少的连接数作为当前会话的处理服务器,并通过 setData 方法将该节点连接数加 1。最后,当客户端执行完毕,再调用 setData 方法将该节点信息减 1。

        -

        Drawing 1.png

        +

        png

        首先,我们定义当服务器接收到会话请求后。在 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 注册中心订阅该服务,注册中心再将服务提供者地址列表返回给消费者。如果有变更,注册中心将基于长连接将变更数据推送给消费者,从而通过服务的注册机制实现远程过程调用。

      -

      1.png

      +

      png

      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 监控。

      -

      2.png

      +

      png

      Kafka 与 ZooKeeper

      接下来我们再看一下 ZooKeeper 在另一个开源框架 Kafka 中的应用。Kafka 是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者在网站中的所有动作流数据,经常用来解决大量数据日志的实时收集以及 Web 网站上用户 PV 数统计和访问记录等。我们可以把 Kafka 看作是一个数据的高速公路,利用这条公路,数据可以低延迟、高效地从一个地点到达另一个地点。

      -

      3.png

      +

      png

      Kafka 实现过程

      在介绍 ZooKeeper 在 Kafka 中如何使用之前,我们先来简单地了解一下 Kafka 的一些关键概念,以便之后的学习。如下图所示,整个 Kafka 的系统架构主要由 Broker、Topic、Partition、Producer、Consumer、Consumer Group 这几个核心概念组成,下面我们来分别进行介绍。

      -

      4.png

      +

      png

      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 上的数据模型。

      -

      5.png

      +

      png

      由于 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 就会弹出一个这样的监控界面。

      -

      image.png

      +

      png

      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 服务器了。

      -

      image

      +

      png

      四字母命令

      除了上面介绍的 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 键来开启编辑模式。

      -

      Drawing 0.png

      +

      png

      这个 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 命令查询系统当前的定时任务。

      -

      Drawing 1.png

      +

      png

      到目前为止我们就完成了用 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() {

      底层实现

      正如算法的名字一样,二阶段提交的底层实现主要分成两个阶段,分别是询问阶段提交阶段。具体过程如下图所示:

      整个集群服务器被分成一台协调服务器,集群中的其他服务器是被协调的服务器。在二阶段算法的询问阶段,分布式集群服务在接收到来自客户端的请求的时候,首先会通过协调者服务器,针对本次请求能否正常执行向集群中参与处理的服务器发起询问请求。集群服务器在接收到请求的时候,会在本地机器上执行会话操作,并记录执行的相关日志信息,最后将结果返回给协调服务器。

      -

      image.png

      +

      png

      在协调服务器接收到来自集群中其他服务器的反馈信息后,会对信息进行统计。如果集群中的全部机器都能正确执行客户端发送的会话请求,那么协调者服务器就会再次向这些服务器发送提交命令。在集群服务器接收到协调服务器的提交指令后,会根据之前处理该条会话操作的日志记录在本地提交操作,并最终完成数据的修改。

      虽然二阶段提交可以有效地保证客户端会话在分布式集群中的事务性,但是该算法自身也有很多问题,主要可以归纳为以下几点:效率问题、单点故障、异常中断。

      性能问题

      @@ -214,7 +214,7 @@ function hide_canvas() {

      由于以上种种问题,在实际操作中,我更推荐使用另一种分布式事务的算法——三阶段提交算法。

      三阶段提交

      三阶段提交(Three-phase commit)简称 3PC , 其实是在二阶段算法的基础上进行了优化和改进。如下图所示,在整个三阶段提交的过程中,相比二阶段提交,增加了预提交阶段

      -

      image

      +

      png

      底层实现

      预提交阶段

      为了保证事务性操作的稳定性,同时避免二阶段提交中因为网络原因造成数据不一致等问题,完成提交准备阶段后,集群中的服务器已经为请求操作做好了准备,协调服务器会向参与的服务器发送预提交请求。集群服务器在接收到预提交请求后,在本地执行事务操作,并将执行结果存储到本地事务日志中,并对该条事务日志进行锁定处理。

      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 集群系统的一致性。整个实现过程如下图所示,当接收到来自客户端的事务性会话请求后,系统集群采用主服务器来处理该条会话请求,经过主服务器处理的结果会通过网络发送给集群中其他从节点服务器进行数据同步操作。

      -

      Drawing 0.png

      +

      png

      以 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 选举的实现方式来讲,如下图所示,一个选票的整体结果可以分为一下六个部分:

      -

      Drawing 1.png

      +

      png

      • logicClock:用来记录服务器的投票轮次。logicClock 会从 1 开始计数,每当该台服务经过一轮投票后,logicClock 的数值就会加 1 。
      • state:用来标记当前服务器的状态。在 ZooKeeper 集群中一台服务器具有 LOOKING、FOLLOWING、LEADERING、OBSERVING 这四种状态。
      • @@ -218,12 +218,12 @@ function hide_canvas() {

        当 ZooKeeper 集群需要重新选举出新的 Leader 服务器的时候,就会根据上面介绍的投票信息内容进行对比,以找出最适合的服务器。

        选票筛选

        接下来我们再来看一下,当一台 Follow 服务器接收到网络中的其他 Follow 服务器的投票信息后,是如何进行对比来更新自己的投票信息的。Follow 服务器进行选票对比的过程,如下图所示。

        -

        2.png

        +

        png

        首先,会对比 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 | 彻底掌握二阶段提交/三阶段提交算法原理” 的二阶段提交过程非常相似,如下图所示。

        -

        Drawing 3.png

        +

        png

        当要在集群中的其他角色服务器进行数据同步的时候,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 算法则采用多副本的处理方式,即存在多个副本,每个副本分别包含提案者、决策者以及学习者。下图演示了三种角色的服务器之间的关系。

      -

      11.png

      +

      png

      事务处理过程

      介绍完 Paxos 算法中的服务器角色和投票的处理过程后,接下来我们再来看一下 Paxos 针对一次提案是如何处理的。如下图所示,整个提案的处理过程可以分为三个阶段,分别是提案准备阶段、事务处理阶段、数据同步阶段。我们分别介绍一下这三个阶段的底层处理逻辑。

      -

      Drawing 1.png

      +

      png

      • 提案准备阶段:该阶段是整个 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 集群中各个服务器上的事务会话处理操作分别看作不同的函数,那么整个一致性的处理逻辑就相当于包裹这些函数的事务。而在单机环境中处理事务的逻辑是,包含在事务中的所有函数要么全部成功执行,要么全部都不执行。

        -

        image.png

        +

        png

        不同的是,在分布式环境中,处理事务请求的各个函数是分布在不同的网络服务器上的线程,无法像在单机环境下一样,做到当事务中的某一个环节发生异常的时候,回滚包裹在整个事务中的操作。因此,分布式环境中处理事务操作的时候,一般的算法不会要求全部集群中的机器都成功执行操作,如果有其中一个函数执行异常,那么整个事务就会把所有函数的执行结果回滚到执行前的状态,也就是无论是正确执行的函数,还是执行异常的函数,各自所做的对数据和程序状态的变更都将被删除。

        执行请求

        看完提交请求的处理过程后,我们再来看一下在执行请求时 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等。

        -

        Drawing 0.png

        +

        png

        之后,当我们在本地项目中需要调用实现的会话功能时,直接调用 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,打开系统命令行,进入到该文件夹,就会看到如下图所示的样子,所有系统日志文件都放在了该文件夹下。

        -

        Drawing 1.png

        +

        png

        快照文件

        除了上面介绍的记录系统操作日志的文件外,ZooKeeper 中另一种十分重要的文件数据是快照日志文件。快照日志文件主要用来存储 ZooKeeper 服务中的事务性操作日志,并通过数据快照文件实现集群之间服务器的数据同步功能。

        快照创建

        @@ -232,7 +232,7 @@ LOG.warn("Couldn't find the leader with id = "

        快照存储

        创建完 ZooKeeper 服务的数据快照文件后,接下来就要对数据文件进行持久化的存储操作了。其实在整个 ZooKeeper 中,随着服务的不同阶段变化,数据快照存放文件的位置也随之变化。存储位置的变化,主要是内存和本地磁盘之间的转变。当 ZooKeeper 集群处理来自客户端的事务性的会话请求的时候,会首先在服务器内存中针对本次会话生成数据快照。当整个集群可以执行该条事务会话请求后,提交该请求操作,就会将数据快照持久化到本地磁盘中,如下图所示。

        -

        zk.png

        +

        png

        存储到本地磁盘中的数据快照文件,是经过 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)。

        -

        enter image description here

        +

        png

        图片出自:CSDN 博客

        2.4 可靠性

        与可用性不同,可靠性是指在给定的时间间隔和给定条件下,系统能正确执行其功能的概率。可靠性的量化指标是周期内系统平均无故障运行时间,可用性的量化指标是周期内系统无故障运行的总时间。这种“官方定义”比较晦涩,下面举一个简单的例子。

        @@ -299,15 +299,15 @@ str.toUpperCase();//指令3

        支持异步通信协议,消息的发送者将消息发送到消息队列后可以立即返回,不用等待接收者的响应。消息会被保存在队列中,直到被接收者取出。消息的发送与处理是完全异步的。下面通过一个例子来说明。

        对于大多数应用,在用户注册后,都需要发注册邮件和注册短信。传统的做法有两种:

        1. 串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端,如下图示:

        -

        enter image description here

        +

        png

        2. 并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的效率。

        -

        enter image description here

        +

        png

        接下来,我们引入消息队列,来实现异步处理。

        将注册信息写入数据库成功后,将消息写入消息队列,然后立即返回成功;此后,邮件系统和短信系统分别从消息队列中获取注册信息,再发送注册邮件和短信。很明显,借助消息队列的异步处理能力,将极大的提高响应速度。

        -

        enter image description here

        +

        png

        应用解耦

        以电商 IT 架构为例,在传统紧耦合订单场景里,客户在电商网站下订单,订单系统接收到请求后,立即调用库存系统接口,库存减一,如下图所示:

        -

        enter image description here

        +

        png

        上述模式存在巨大风险:

        1. 假如库存系统无法访问(升级、业务变更、故障等),则订单减库存将失败,从而导致订单失败;
        2. @@ -316,7 +316,7 @@ str.toUpperCase();//指令3

          我们引入消息队列,解除强耦合性,处理流程又会怎样呢?

          订单系统中,用户下单后,订单系统完成持久化处理,将消息写入消息队列,返回用户订单下单成功,此时客户可以认为下单成功。消息队列提供异步的通信协议,消息的发送者将消息发送到消息队列后可以立即返回,不用等待接收者的响应。消息会被保存在队列中,直到被接收者取出。

          库存系统中,从消息队列中获取下单信息,库存系统根据下单信息进行库存操作。

          -

          enter image description here

          +

          png

          流量削锋

          像双11秒杀、预约抢购等活动,通常会出现流量暴增,当外部请求超过系统处理能力时,如果系统没有做相应保护,可能因不堪重负而挂掉。

          这时,我们可以引入消息队列,缓解短时间内高流量压力:

          @@ -324,7 +324,7 @@ str.toUpperCase();//指令3
        3. 用户的秒杀请求,服务器接收后,首先写入消息队列,然后返回成功。假如消息队列长度超过最大数量,则直接抛弃用户请求或跳转到失败页面;
        4. 秒杀业务根据消息队列中的请求信息,再做后续处理(根据数据库实际的select、insert、update 能力处理注册、预约申请)。
        -

        enter image description here

        +

        png

        消息通讯

        消息通讯很好理解,以微信群聊为例:

          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。

          -

          enter image description here

          +

          png

          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 架构如下图所示:

          -

          enter image description here

          +

          png

          方案2:Twemproxy

          Twemproxy 是一个使用 C 语言编写、以代理的方式实现的、轻量级的 Redis 代理服务器。它通过引入一个代理层,将应用程序后端的多台 Redis 实例进行统一管理,使应用程序只需要在 Twemproxy 上进行操作,而不用关心后面具体有多少个真实的 Redis 实例,从而实现了基于 Redis 的集群服务。当某个节点宕掉时,Twemproxy 可以自动将它从集群中剔除,而当它恢复服务时,Twemproxy 也会自动连接。由于是代理,Twemproxy 会有微小的性能损失。

          -

          Twemproxy 架构如下图所示: enter image description here

          +

          Twemproxy 架构如下图所示: png

          方案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 备”的结构,如下所示:

          -

          enter image description here

          +

          png

          需要说明的是,即便有很多个 Redis 主节点,只要这些主节点以单机模式存在,本质上仍为单机模式。单机模式比较简单,足以支撑一般应用场景,但单机模式具有固有的局限性:不支持自动故障转移,扩容能力极为有限(只能 Scale Up,垂直扩容),存在高并发瓶颈。

          1.1 不支持自动故障转移

          Redis 单机模式下,即便是“1主 N 备”结构,当主节点故障时,备节点也无法自动升主,即无法自动故障转移(Failover)。故障转移需要“哨兵”Sentinel 辅助,Sentinel 是 Redis 高可用的解决方案,由一个或者多个 Sentinel 实例组成的系统可以监视 Redis 主节点及其从节点,当检测到 Redis 主节点下线时,会根据特定的选举规则从该主节点对应的所有从节点中选举出一个“最优”的从节点升主,然后由升主的新主节点处理请求。具有 Sentinel 系统的单机模式示意图如下:

          -

          enter image description here

          +

          png

          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 中。

          -

          enter image description here

          +

          png

          2.3 Redis Cluster 请求路由方式

          客户端直连 Redis 服务,进行读写操作时,Key 对应的 Slot 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点。如下图所示,我们直接登录 127.0.0.1:6379 客户端,进行 Set 操作,当 Key 对应的 Slot 不在当前节点时(如 key-test),客户端会报错并返回正确节点的 IP 和端口。Set 成功则返回 OK。

          -

          enter image description here

          +

          png

          以集群模式登录 127.0.0.1:6379 客户端(注意命令的差别:-c 表示集群模式),则可以清楚的看到“重定向”的信息,并且客户端也发生了切换:“6379” -> “6381”。

          -

          enter image description here

          +

          png

          以三节点为例,上述操作的路由查询流程示意图如下所示:

          -

          enter image description here

          +

          png

          和普通的查询路由相比,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消息,源码如下:

        -

        enter image description here

        +

        png

        当然,这样还是没法保证效率,毕竟5个节点是随机选出来的,其中最久没有通信的节点不一定是全局“最久”。因此,对哪些长时间没有“被” 随机到的节点进行特殊照顾:每个周期(100ms)内扫描一次本地节点列表,如果发现节点最近一次接受 Pong 消息的时间大于 cluster_node_timeout/2,则立刻发送 Ping 消息,防止该节点信息太长时间未更新。源码如下:

        -

        enter image description here

        +

        png

        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 主节点上。

        -

        enter image description here

        +

        png

        但是,Hash 算法有很多缺陷:

        1. 不支持动态增加节点:当业务量增加,需要增加服务器节点后,上面的计算公式变为:hash(key)%(N+1),那么,对于同一个 Key-Value,增加节点前后,对应的 Redis 节点可能是完全不同的,可能导致大量之前存储的数据失效;为了解决这个问题,需要将所有数据重新计算 Hash 值,再写入 Redis 服务器。
        2. @@ -372,12 +372,12 @@ function hide_canvas() {

          为了克服客户端分片业务逻辑与数据存储逻辑耦合的不足,可以通过 Proxy 将业务逻辑和存储逻辑隔离。客户端发送请求到一个代理,代理解析客户端的数据,将请求转发至正确的节点,然后将结果回复给客户端。这种架构还有一个优点就是可以把 Proxy 当成一个中间件,在这个中间件上可以做很多事情,比如可以把集群和主从的兼容性做到几乎一致,可以做无缝扩减容、安全策略等。

          基于代理的分片已经有很多成熟的方案,如开源的 Codis,阿里云的 ApsaraDB for Redis/ApsaraCache,腾讯的 CRS 等。很多大企业也在采用 Proxy+Redis-Server 的架构。

          基本原理如下图所示:

          -

          enter image description here

          +

          png

          我们来了解下代理分片的缺点。没有完美的架构,由于使用了 Proxy,带宽和 CPU 基本都要加倍,对资源的消耗会大很多。

          7.2 Codis 架构

          Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server 没有明显的区别 (参考6.1中的代理分片模式),客户端可以像使用单机 Redis 一样使用。

          架构图如下:

          -

          enter image description here

          +

          png

          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”架构,其架构图如下:

          -

          enter image description here

          +

          png

          该架构有以下特点:

          • 分片算法:基于代理的分片原理,将物理节点映射到 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 为一对主备节点。

            -

            enter image description here

            +

            png

            按照上图所示的拓扑结构,如果节点 1 故障下线,那么节点 2 上的 A-S 将升主为 A-M,Redis 3 节点集群仍可用,如下图所示:

            -

            enter image description here

            +

            png

            特别说明:事实上,Redis 集群节点间是两两互通的,如下图所示,上面作为示意图,进行了适当简化。

            -

            enter image description here

            +

            png

            1.2 资源准备

            首先,下载 Redis 包。前往 Redis 官网下载 Redis 资源包,本文采用的 Redis 版本为 4.0.8。

            接着,将下载的 Redis 资源包 redis-4.0.8.tar.gz 放到自定义目录下,解压,编译便可生成 Redis 服务端和本地客户端 bin 文件 redis-serverredis-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 文件夹中。

            -

            enter image description here

            +

            png

            (3)创建必要启停脚本

            逐一手动拉起 Redis 进程较为麻烦,在此,我们可以编写简单的启停脚本完成 redis-server 进程的启停(start.shstop.sh)。

            -

            enter image description here

            +

            png

            (4)简单测试

            至此,我们已经完成 Redis 集群创建的前期准备工作,在创建集群之前,我们可以简单测试一下,redis-sever 进程是否可以正常拉起。运行 start.sh 脚本,查看 redis-server 进程如下:

            -

            enter image description here

            +

            png

            登录其中一个 Redis 实例的客户端(以 6379 为例),查看集群状态:很明显,以节点 6379 的视角来看,集群处于 Fail 状态,clusterknownnodes:1 表示集群中只有一个节点。

            -

            enter image description here

            +

            png

            2. 基于 Lettuce 创建 Redis 集群

            关于创建 Redis 集群,官方提供了一个 Ruby 编写的运维软件 redis-trib.rb,使用简单的命令便可以完成创建集群、添加节点、负载均衡等操作。正因为简单,用户很难通过黑盒表现理解其中细节,鉴于此,本节将基于 Lettuce 编写创建 Redis 集群的代码,让读者对 Redis 集群创建有一个更深入的理解。

            Redis 发展至今,其对应的开源客户端几乎涵盖所有语言,详情请见官网,本节采用 Java 语言开发的 Lettuce 作为 Redis 客户端。Lettuce 是一个可伸缩线程安全的 Redis 客户端,多个线程可以共享同一个 RedisConnection。它采用优秀 Netty NIO 框架来高效地管理多个连接。关于 Lettuce 的详情,后面章节中会详细介绍。

            -

            enter image description here

            +

            png

            2.1 Redis 集群创建的步骤

            (1)相互感知,初步形成集群。

            在上文中,我们已经成功拉起了 6 个 redis-server 进程,每个进程视为一个节点,这些节点仍处于孤立状态,它们相互之间无法感知对方的存在,既然要创建集群,首先需要让这些孤立的节点相互感知,形成一个集群。

            @@ -393,7 +393,7 @@ class ClusterNode }

        运行上述代码创建集群,再次登录其中一个节点的客户端(以 6379 为例),通过命令:cluster nodescluster info 查看集群状态信息如下,集群已经处于可用状态。

        -

        enter image description here

        +

        png

        2.3 测试验证

        经过上述步骤,一个可用的 Redis 集群已经创建完毕,接下来,通过一段代码测试验证:

        public static void main(String[] args)
        @@ -435,12 +435,12 @@ class ClusterNode
         

        3. Redis SSL 双向认证通信实现

        3.1 Redis 自带的鉴权访问模式

        默认情况下,Redis 服务端是不允许远程访问的,打开其配置文件 redis.conf,可以看到如下配置:

        -

        enter image description here

        +

        png

        根据说明,如果我们要远程访问,可以手动改变 protected-mode 配置,将 yes 状态置为 no 即可,也可在本地客服端 redis-cli,键入命令:config set protected-mode no。但是,这明显不是一个好的方法,去除保护机制,意味着严重安全风险。

        鉴于此,我们可以采用鉴权机制,通过秘钥来鉴权访问,修改 redis.conf,添加 requirepass mypassword ,或者键入命令:config set requirepass password 设置鉴权密码。

        -

        enter image description here

        +

        png

        设置密码后,Lettuce 客户端访问 redis-server 就需要鉴权,增加一行代码即可,以单机模式为例:

        -

        enter image description here

        +

        png

        补充

        除了通过密码鉴权访问,出于安全的考量,Redis 还提供了一些其它的策略:

          @@ -463,7 +463,7 @@ rename-command EVAL "user-defined"

          通过上面的介绍,相信读者已经对 Redis 自带的加固策略有了一定了解。客观地讲,Redis 自带的安全策略很难满足对安全性要求普遍较高的商用场景,鉴于此,有必要优化。就 Client-Server 模式而言,成熟的安全策略有很多,本文仅介绍其一:SSL 双向认证通信。关于 SSL 双向认证通信的原理和具体实现方式,网上有大量的博文可供参考,并非本文重点,因此不做详细介绍。

          总体流程

          我们首先看下 SSL 双向认证通信的总体流程,如下图所示:

          -

          enter image description here

          +

          png

          首先,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)),部分修改如下:

          -

          enter image description here

          +

          png

          redis.h

          Redis 的客户端(redisClient)和服务端(redisServer)都需要适配,部分代码如下:

          -

          enter image description here

          -

          enter image description here

          +

          png

          +

          png

          hiredis.h

          修改创建连接的原函数:

          -

          enter image description here

          +

          png

          anet.h

          定义 SSL 通信涉及的一些函数(实现在 anet.c 中):

          -

          enter image description here

          +

          png

          • 客户端
          @@ -524,10 +524,10 @@ cmd.get("key");

        4. Redis 集群可靠性问题

        为了便于理解(同时也为了规避安全违规风险),我将原方案进行了适度简化,以 3 主 3 备 Redis 集群为例阐述方案(redis-cluster 模式最少需要三个主节点),如下图所示,其中 A-M 表示主节点 A,A-S 表示主节点 A 对应的从节点,以此类推。

        -

        enter image description here

        +

        png

        4.1 可靠性问题一

        Redis 集群并不是将 redis-server 进程启动便可自行建立的。在各个节点启动 redis-server 进程后,形成的只是 6 个“孤立”的 Redis 节点而已,它们相互不知道对方的存在,拓扑结构如下:

        -

        enter image description here

        +

        png

        查看每个 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 构成新的集群,继续提供服务,如下图所示:

        -

        enter image description here

        +

        png

        4.3 故障节点恢复

        接续上一节,如果宕机的节点 1 经过修复重新上线,根据 Redis 集群原理,节点 1 上的 A-M 将意识到自己已经被替代,将降级为备,形成的集群拓扑结构如下:

        -

        enter image description here

        +

        png

        4.4 可靠性问题二

        基于上述拓扑结构,如果节点 3 宕机,Redis 集群将只有一个主节点 C-M 存活,存活的主节点总数少于集群主节点总数的一半 (1<3/2+1),集群无法自愈,不能继续提供服务。

        为了解决这个问题,我们可以设计一个常驻守护进程对 Redis 集群的状态进行监控,当出现主-备状态不合理的情况(如节点 1 重新上线后的拓扑结构),守护进程主动发起主备倒换(clusterFailover),将节点 1 上的 A-S 升为主,节点 3 上的 A-M 降为备,如此,集群拓扑结构恢复正常,并且能够支持单节点故障。

        -

        enter image description here

        +

        png

        注: Lettuce 提供了主备倒换的方法,示例代码如下:

        // slaveConn为Lettuce与从节点建立的连接
         slaveConn.sync().clusterFailover(true)
         

        4.5 可靠性问题三

        接续 4.1 节,如果节点 1 故障后无法修复,为了保障可靠性,通常会用一个新的节点来替换掉故障的节点——所谓故障替换。拓扑结构如下:

        -

        enter image description here

        +

        png

        新的节点上面部署两个 redis-server 进程,由于是新建节点,redis-server 进程对应的集群配置文件 cluster-config-file 中只包含自身的信息,并没有整个集群的信息,简言之,新建的节点上的两个 redis-server 进程是“孤立”的。

        为了重新组成集群,我们需要两个步骤:

          @@ -563,7 +563,7 @@ slaveConn.sync().clusterFailover(true)
        1. 为新加入集群的两个 redis-server 设置主节点:节点 3 上的两个主 A-M 和 B-M 都没有对应的从节点,因此,可将新加入的两个 redis-server 分别设置为它们的从节点。

        完成上述两个步骤后,Redis 集群的拓扑结构将演变成如下形态:

        -

        enter image description here

        +

        png

        很明显,变成了问题一的形态,继续通过问题一的解决方案便可修复。

        4.6 其它

        上面仅介绍了几个较为常见的问题,在实际使用 Redis 的过程中可能遇到的问题远不止这些。在第 05 课中,我将介绍一些更为复杂的异常场景。

        @@ -571,19 +571,19 @@ slaveConn.sync().clusterFailover(true)

        不同的应用场景,关注的问题、可能出现的异常不尽相同,上文中介绍的问题仅仅是一种商业应用场景中遇到的。为了解决上述问题,可基于 Lettuce 设计一个常驻守护进程,实现集群创建、添加节点、平衡主备节点分布、集群运行状态监测、故障自检及故障自愈等功能。

        5.1 总体流程图

        下面是精简后的流程图:

        -

        enter image description here

        +

        png

        流程图中,ETCD 选主部分需要特别说明一下,ETCD 和 ZooKeeper 类似,可提供 Leader 选举功能。Redis 集群模式下,在各个 Redis 进程所在主机上均启动一个常驻守护进程,以提高可靠性,但是,为了避免冲突,只有被 ETCD 选举为 Leader 的节点上的常驻守护进程可以执行 “守护” 流程,其它主机上的守护进程呈 “休眠” 状态。关于 Leader 选举的实现,方式很多,本文仅以 ETCD 为例。

        5.2 实现

        集群状态检测

        读者应该知道,Redis 集群中每个节点都保存有集群所有节点的状态信息,虽然这些信息可能并不准确。通过状态信息,我们可以判断集群是否存在以及集群的运行状态,基于 Lettuce 提供的方法,简要代码如下:

        -

        enter image description here

        +

        png

        上面代码只从一个节点的视角进行了检查,完整的代码将遍历所有节点,从所有节点的视角分别检查。

        Redis 集群创建

        大家可参考第二节“2. 基于 Lettuce 创建 Redis 集群”中的内容。

        替换故障节点

        (1)加入新节点

        替换上来的新节点本质上是“孤立”的,需要先加入现有集群:通过集群命令 RedisAdvancedClusterCommands 对象调用 clusterMeet() 方法,便可实现:

        -

        enter image description here

        +

        png

        (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 为依据:

        -

        enter image description here

        +

        png

        (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 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点,如下图所示:

    -

    enter image description here

    +

    png

    如果 A、C 节点之间通信被阻断,上述混合路由查询自然就不能成功了,如下图所示:

    -

    enter image description here

    +

    png

    如上图所示,节点 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 命令检测节点间通信情况,发现其中一个主节点与备节点无法联通,进一步定位为交换机故障。
  • 上述故障场景示意图如下:

    -

    enter image description here

    +

    png

    故障主节点 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)。如下源码:

    -

    enter image description here

    +

    png

    需要注意的是,判断 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) 中,如下所示:

    -

    enter image description here

    +

    png

    通过源码可以清晰地看出,将一个节点标记为 FAIL 状态,需要满足两个条件:

    • 有超过半数的主节点将 Node 标记为 PFAIL 状态;
    • @@ -164,7 +164,7 @@ function hide_canvas() {

      2. Redis-Cluster 选举原理及优化分析

      2.1 从节点拉票

      基于故障检测信息的传播,集群中所有正常节点都将感知到某个主节点下线(Fail)的信息,当然也包括这个下线主节点的所有从节点。当从节点发现自己复制的主节点的状态为已下线时,从节点就会向集群广播一条请求消息,请求所有收到这条消息并且具有投票权的主节点给自己投票。

      -

      enter image description here

      +

      png

      2.2 拉票优先级

      严格得讲,从节点在发现其主节点下线时,并不是立即发起故障转移流程而进行“拉票”的,而是要等待一段时间,在未来的某个时间点才发起选举,这个时间点的计算有两种方式。

      方式一
      @@ -174,17 +174,17 @@ function hide_canvas() {

      其中,newRank 和 oldRank 分别表示本次和上一次排名。

      注意,如果当前系统时间小于需要等待的时刻,则返回,下一个周期再检查。

      源码如下:

      -

      enter image description here

      +

      png

      方式二

      既然是拉票,就有可能因未能获得半数投票而失败,一轮选举失败后,需要等待一段时间(auth_retry_time)才能清理标志位,准备下一轮拉票。从节点拉票之前也需要等待,等待时间计算方法如下:

      mstime() + 500ms + random()%500ms + rank*1000ms
       

      其中,500 ms 为固定延时,主要为了留出时间,使主节点下线的消息能传播到集群中其它节点,这样集群中的主节点才有可能投票;random()%500ms 表示随机延时,为了避免两个从节点同时开始故障转移流程;rank 表示从节点的排名,排名是指当前从节点在下线主节点的所有从节点中的排名,排名主要是根据复制数据量来定,复制数据量越多,排名越靠前,因此,具有较多复制数据量的从节点可以更早发起故障转移流程,从而更可能成为新的主节点。

      源码如下:

      -

      enter image description here

      +

      png

      可优化点

      上面提到的 auth_retry_time 是一个潜在的可优化点,也是一个必要的注意点,其计算方法如下源码所示:

      -

      enter image description here

      +

      png

      从中可以看出,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,源码如下:

      -

      enter image description here

      +

      png

      主要包括以下几个过程。

      (1)身份切换

      通过选举晋升的从节点会执行一系列的操作,清除曾经为从的信息,改头换面,成为新的主节点。

      @@ -208,10 +208,10 @@ function hide_canvas() {

      (4)履行义务

      在其位谋其政,新的主节点开始处理自己所负责 Slot 对应的请求,至此,故障转移完成。

      上述过程由 cluster.c 中的函数 void clusterFailoverReplaceYourMaster(void) 完成,源码如下所示:

      -

      enter image description here

      +

      png

      4. 客户端的优化思路

      Redis-Cluster 发生故障后,集群的拓扑结构一定会发生改变,如下图所示:

      -

      enter image description here

      +

      png

      一个 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,部分源码如下:

      -

      enter image description here

      +

      png

      当 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 键值对。其架构如下:

      -

      enter image description here

      +

      png

      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 毫秒之间且并不一致(避免同时发起选举)。

      -

      enter image description here

      +

      png

      第二阶段:Follower 转为 Candidate 并发起投票。

      没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),节点启动后在一个选举定时器周期内未收到心跳和投票请求,则状态转为候选者 Candidate 状态,且 Term 自增,并向集群中所有节点发送投票请求并且重置选举定时器。

      注意,由于每个节点的选举定时器超时时间都在 100-500 毫秒之间,且彼此不一样,以避免所有 Follower 同时转为 Candidate 并同时发起投票请求。换言之,最先转为 Candidate 并发起投票请求的节点将具有成为 Leader 的“先发优势”。

      -

      enter image description here

      +

      png

      第三阶段:投票策略。

      节点收到投票请求后会根据以下情况决定是否接受投票请求:

      1. 请求节点的 Term 大于自己的 Term,且自己尚未投票给其它节点,则接受请求,把票投给它;
      2. 请求节点的 Term 小于自己的 Term,且自己尚未投票,则拒绝请求,将票投给自己。
      -

      enter image description here

      +

      png

      第四阶段:Candidate 转为 Leader。

      一轮选举过后,正常情况下,会有一个 Candidate 收到超过半数节点(N/2 + 1)的投票,它将胜出并升级为 Leader。然后定时发送心跳给其它的节点,其它节点会转为 Follower 并与 Leader 保持同步,到此,本轮选举结束。

      注意:有可能一轮选举中,没有 Candidate 收到超过半数节点投票,那么将进行下一轮选举。

      -

      enter image description here

      +

      png

      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 并不会更新本地数据,因此它是不可读的。

      -

      enter image description here

      +

      png

      第二阶段: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 保持一致,并且在接下来的任期继续保持一致。

      -

      enter image description here

      +

      png

      第三阶段:Leader 等待 Followers 回应。

      Followers 接收到 Leader 发来的复制请求后,有两种可能的回应:

        @@ -200,14 +200,14 @@ function hide_canvas() {
      1. 一致性检查失败,拒绝写入,返回 False,原因和解决办法上面已做了详细说明。

      需要注意的是,此时该 Entry 的状态也是未提交(Uncommitted)。完成上述步骤后,Followers 会向 Leader 发出 Success 的回应,当 Leader 收到大多数 Followers 的回应后,会将第一阶段写入的 Entry 标记为提交状态(Committed),并把这条日志条目应用到它的状态机中。

      -

      enter image description here

      +

      png

      第四阶段:Leader 回应客户端。

      完成前三个阶段后,Leader会向客户端回应 OK,表示写操作成功。

      -

      enter image description here

      +

      png

      第五阶段,Leader 通知 Followers Entry 已提交

      Leader 回应客户端后,将随着下一个心跳通知 Followers,Followers 收到通知后也会将 Entry 标记为提交状态。至此,Raft 集群超过半数节点已经达到一致状态,可以确保强一致性。

      需要注意的是,由于网络、性能、故障等各种原因导致“反应慢”、“不一致”等问题的节点,最终也会与 Leader 达成一致。

      -

      enter image description here

      +

      png

      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。

      -

      enter image description here

      +

      png

      • 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的分布式锁示意图如下。

        -

        enter image description here

        +

        png

        业务流程图大家可参看这篇文章《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 架构

      -

      enter image description here

      +

      png

      如上图所示,一个典型的 Kafka 体系架构包括若干 Producer(消息生产者),若干 Broker(Kafka 支持水平扩展,一般 Broker 数量越多,集群吞吐率越高),若干 Consumer(Group),以及一个 Zookeeper 集群。Kafka 通过 Zookeeper 管理集群配置,选举 Leader,以及在 Consumer Group 发生变化时进行 Rebalance。Producer 使用 Push(推)模式将消息发布到 Broker,Consumer 使用 Pull(拉)模式从 Broker 订阅并消费消息。

      各个名词的解释请见下表:

      -

      enter image description here

      +

      png

      1.5 Kafka 高可用方案

      Kafka 高可用性的保障来源于其健壮的副本(Replication)策略。为了提高吞吐能力,Kafka 中每一个 Topic 分为若干 Partitions;为了保证可用性,每一个 Partition 又设置若干副本(Replicas);为了保障数据的一致性,Zookeeper 机制得以引入。基于 Zookeeper,Kafka 为每一个 Partition 找一个节点作为 Leader,其余备份作为 Follower,只有 Leader 才能处理客户端请求,而 Follower 仅作为副本同步 Leader 的数据,如下示意图:TopicA 分为两个 Partition,每个 Partition 配置两个副本。

      -

      enter image description here

      +

      png

      基于上图的架构,当 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 的主体架构如下图所示。

    -

    enter image description here

    +

    png

    传输协议: 消息之间的传递,无疑需要协议进行沟通,启动一个 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 方案。

    -

    enter image description here

    +

    png

    关于几种 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、高密度的消息增量的场景;它以集群的模式,承载较大数据量的应用。

    -

    enter image description here

    +

    png

    如上图所示,集群由多个子 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 的架构图如下所示:

    -

    enter image description here

    +

    png

    接下来解释几个重要的概念。

    • Broker:即消息队列服务器实体。
    • @@ -448,7 +448,7 @@ function hide_canvas() {

    4.4 RocketMQ 架构

    RocketMQ 是一个具有高性能、高可靠、低延迟、分布式的万亿级容量,且可伸缩的分布式消息和流平台。它由 Name Servers、Brokers、 Producers 和 Consumers 四个部分组成。其架构如下图所示(取自官网)。

    -

    enter image description here

    +

    png

    NameServer 集群

    NameServer 是一个功能齐全的服务器,其角色类似 Kafka 中的 ZooKeeper,支持 Broker 的动态注册与发现。主要包括两个功能:

    -

    4.png

    +

    png

    日志编写位置

    日志编写的位置可以说是重中之重,好的日志位置可以帮你解决问题,也可以让你更加了解代码的运行情况。我总结了几点比较重要的编写日志的位置,以供参考。

      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 的值也是通过计数器的形式,然后配合上一些函数计算得出的。

      -

      image

      +

      png

      图 1:计数器

      仪表盘(Gauge)

      仪表盘和计数器都可以用来查询某个时间点的固定内容的数值,但和计数器不同,仪表盘的值可以随意变化,可以增加也可以减少。比如在 Java 线程池中活跃的线程数,就可以使用 ThreadPoolExecutor 的 getActiveCount 获取;比较常见的 CPU 使用率和内存占用量也可以通过仪表盘获取。

      -

      image

      +

      png

      图 2:仪表盘

      直方图(Histogram)

      直方图相对复杂一些,它是将多个数值聚合在一起的数据结构,可以表示数据的分布情况。

      如下图,它可以将数据分成多个桶(Bucket),每个桶代表一个范围区间(图下横向数),比如第 1 个桶代表 0~10,第二个桶就代表 10~15,以此类推,最后一个桶代表 100 到正无穷。每个桶之间的数字大小可以是不同的,并没有规定要有规律。每个桶和一个数字挂钩(图左纵向数),代表了这个桶的数值。

      -

      2.png

      +

      png

      图 3:直方图

      以最常见的响应耗时举例,我把响应耗时分为多个桶,比如我认为 0~100 毫秒比较快,就可以把这个范围做一个桶,然后是 100~150 毫秒,以此类推。通过这样的形式,可以直观地看到一个时间段内的请求耗时分布图,这有助于我们理解耗时情况分布。

      摘要(Summary)

      摘要与直方图类似,同样表示的是一段时间内的数据结果,但是数据反映的内容不太一样。摘要一般用于标识分位值,分位值就是我们常说的 TP90、TP99 等。

      假设有 100 个耗时数值,将所有的数值从低到高排列,取第 90% 的位置,这个位置的值就是 TP90 的值,而这个桶的值假设是 80ms,那么就代表小于等于90%位置的请求都 ≤80ms。

      用文字不太好理解,我们来看下面这张图。这是一张比较典型的分位值图,我们可以看到图中有 6 个桶,分别是 50、75、80、90、95、99,而桶的值就是相对应的耗时情况。

      -

      3.png

      +

      png

      图 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 寻找,建立与服务器的链接,发送请求,请求响应。这几个步骤是可以被监控起来,现在很多主流的拨测软件也会提供这样的统计功能,拨测软件其实就是利用各个不同地方的机器发起请求,来测试网络情况。

    -

    11.png

    +

    png

    App 和网页,在发送请求和获取数据内容的过程中,除了以上提到的指标,还有以下几个指标需要注意:

    1. DNS 响应时间:通常用来记录访问地址向 DNS 服务器发起请求,到 DNS 返回服务器 IP 地址信息的时间。
    2. 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() {
    3. 营收(revenue):公司是否从用户这里获得了营收,其中最典型的就是用户购买了你的内容,你所获得的成单金额。
    4. 传播(referral):老用户对潜在用户的病毒式传播及口碑传播,进行“老拉新”,比如拉勾教育的分销就可以认为是传播,并由此算出传播系数。
    -

    Drawing 1.png

    +

    png

    在这一流程中,你会发现其中每个部分都可以根据不同的功能,产生不同的数据指标,然后你可以通过这些更细化的指标优化产品,从而让产品更具有商业价值。

    性能数据

    性能层的数据会更加方便研发人员了解程序的运行情况。通过观测这部分数据,你能快速感知是哪些业务出现了异常,再结合日志或是我在下一课时要讲的链路,来快速定位问题出现的原因。

    @@ -252,7 +252,7 @@ for (String time : sortedKeys) {

    这样的计算方式,通常与计数器(Counter)一同使用,因为计数器的数据一般是递增的,但有时很难看到增长率。通过速率,你可以看出哪些时候的增长比较多,哪些时候又基本不变,比如拉勾教育的课程购买人数增速占比。在课程上线时我们会开展 1 元购的活动,通过查看活动前后的人数增长率,我们就能很清楚地知道在活动期间购买的人数会大幅增加,以后也会更多地开展类似的活动。

    2.irate:同样也叫速率。与 rate 的计算方式不同,irate 只计算最近两次数据之间的增长速率。rate 和 irate 的函数变化如下图:

    -

    Drawing 2.png

    +

    png

    这张图中红线的就是 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),原理可以参照下图:

    -

    Drawing 1.png

    +

    png

    在这张图中,第一行代表线程进行快照的周期情况,每一个周期都可以认为是一段时间,比如 10ms、100ms。周期的时间长短,决定了对程序性能影响的大小。因为在进行线程快照时程序会暂停,当快照完成后才会继续进行操作。

    第二行则代表我们需要进行观测的方法的执行时间,线程快照只能做到周期性的快照获取。虽然可能并不能完全匹配,但通过这种方式,相对来说已经很精准了。

    性能剖析与埋点相比,有以下几个优势:

    @@ -199,11 +199,11 @@ function hide_canvas() {

    这个时候我们一般可以通过 2 种方式查看线程聚合的结果信息,它们分别是火焰图树形图

    火焰图

    火焰图,顾名思义,是和火焰一样的图片。火焰图是在 Linux 环境中比较常见的一种性能剖析展现方式。火焰图有很多种展现形式,这里我就以我们会用到的 CPU 火焰图为例:

    -

    Drawing 2.png

    +

    png

    CPU 火焰图

    在 CPU 火焰图中,每一个方格代表一个方法栈帧,方格的长度则代表它的执行时间,所以方格越长就说明该栈帧执行的时间越长。火焰图中在某一个方格中增高一层,就说明是这个方法栈帧中,又调用了某个方法的栈帧。最顶层的函数,是叶子函数。叶子函数的方格越宽,说明这个方法在这里的执行耗时越长。

    如果觉得上面的火焰图太复杂的话,我们可以看一张简化的图,如下:

    -

    Drawing 3.png

    +

    png

    图中,a 方法是执行的方法,可以看出来,其中 g 方法是执行时间相对较长的。

    无论是火焰图,还是这张简化的图,它们都通过图形的方式,让我们能够快速定位到执行缓慢的原因。但是这种的方式也存在一些问题:

      @@ -211,7 +211,7 @@ function hide_canvas() {
    1. 很难发现非叶子节点的问题。我们在简化图中可以发现,我们在 d 方法中除了 e 和 f 方法的调用以外,其实 d 方法还有一段的时间是自己消耗的,并且没有被处理掉,这一问题在火焰图中会更加明显。

    树形图

    -

    Drawing 5.png

    +

    png

    为了解决这 2 个问题,就有了另外一种展现方式,那就是树形图。树形图就是将方法的调用堆栈通过树形图的形式展现出来。这对于开发人员来说相对直观,因为你可以通过树形图的形式快速查看整体的调用情况,并且针对火焰图中的问题,树形图也有很好的解决方法:

    -

    image.png

    +

    png

    实现的功能方面:这三种 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 等语句的缩写。安装后,输入缩写就能快速生成对应语句。

    -

    image

    +

    png

    除了使用扩展包自带的预设片段外,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 基本标签结构(完整的缩写规则列表可在官方配置中查找):

    -

    emmet-sample.gif

    +

    png

    它的主要功能包括:

    • 缩写代码块: 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 语法树以及对应的编译器,来展示低代码开发的模式。

      -

      Drawing 0.png

      +

      png

      编写 JSON 开发的高效性

      编写 JSON 语法树开发的高效性体现在:

        @@ -213,7 +213,7 @@ function hide_canvas() {

        针对编写 JSON 过程中的输入效率、记忆成本和可维护性等问题,许多低代码工具进一步提供了可视化操作平台的工作方式。下面再让我们来了解下,这种方式是怎么解决上述问题的。

        基于可视化操作平台的低代码开发

        可视化的低代码操作平台把编写 JSON 的过程变成了拖拽组件和调试属性配置,如下图所示,这样的交互方式对用户来说更直观友好,开发效率也会更高。

        -

        Drawing 1.png

        +

        png

        可视化操作平台的基本使用方式

        绝大部分的可视化操作平台都将界面布局分为三个区域:左侧的组件选择区,中部的预览交互区以及右侧的属性编辑区。这三个区域的排布所对应的,也是用户生成页面的操作流程

          diff --git a/专栏/前端工程化精讲-完/08 无代码工具:如何做到不写代码就能高效交付?.md.html b/专栏/前端工程化精讲-完/08 无代码工具:如何做到不写代码就能高效交付?.md.html index 6edc1aba..85725a7d 100644 --- a/专栏/前端工程化精讲-完/08 无代码工具:如何做到不写代码就能高效交付?.md.html +++ b/专栏/前端工程化精讲-完/08 无代码工具:如何做到不写代码就能高效交付?.md.html @@ -262,7 +262,7 @@ function hide_canvas() {

          以上便是企业内部无代码开发的一类应用场景。

          外部无代码搭建平台

          另一类面向非开发人员的无代码开发产品,针对的是缺乏开发资源的企业和部门。对于一些常见的小型项目需求,例如招聘页面、报名页面等,它们往往需要借助外部提供的无代码开发平台。这类无代码开发平台包括百度 H5、MAKA、易企秀等。

          -

          image

          +

          png

          百度 H5 编辑界面

          这类产品的特点是:

            @@ -278,7 +278,7 @@ function hide_canvas() {
            • 更为多样化的应用场景:同上述面向非开发人员的产品相比,这类产品最主要的功能是提供了描述性的后端的数据与功能模块,因此能够实现的应用场景也更为多样化和通用化。以 iVX 为例,可实现的应用场景从上面的 C 端产品扩展到了 B 端产品,包括:小程序、小游戏、H5、营销活动,BPM、OA、CRM、ERP,企业中台,BI、大屏幕等。
            -

            image

            +

            png

            iVX 编辑器中后端逻辑描述面板

            • 目标人群的变化:应用场景扩展对应的是 IDE 功能的复杂化和操作学习成本的增加,于是目标人群也多少有些不同: diff --git a/专栏/前端工程化精讲-完/09 构建总览:前端构建工具的演进.md.html b/专栏/前端工程化精讲-完/09 构建总览:前端构建工具的演进.md.html index ad6c93a6..269080c6 100644 --- a/专栏/前端工程化精讲-完/09 构建总览:前端构建工具的演进.md.html +++ b/专栏/前端工程化精讲-完/09 构建总览:前端构建工具的演进.md.html @@ -197,11 +197,11 @@ function hide_canvas() { 2012 年,Twitter 发布了名为Bower前端依赖包管理工具。 2016 年,Facebook 发布了 npm registry 的兼容客户端Yarn

              -

              nodejs-npm-publish-730x340.png

              +

              png

              人们可以把代码包发布到 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.jpg

              +

              png

              著名的 node_modules hell(源自 reddit 用户 xaxaxa_trick)

              npm 的另一个饱受诟病的问题是本地依赖管理算法的复杂性以及随之而来的性能、冗余、冲突等问题。而 2016 年发布的 Yarn 正是为解决这些问题而诞生的。和 npm 相比,Yarn 的主要优点有:

                diff --git a/专栏/前端工程化精讲-完/10 流程分解:Webpack 的完整构建流程.md.html b/专栏/前端工程化精讲-完/10 流程分解:Webpack 的完整构建流程.md.html index bebe868a..bb900cba 100644 --- a/专栏/前端工程化精讲-完/10 流程分解:Webpack 的完整构建流程.md.html +++ b/专栏/前端工程化精讲-完/10 流程分解:Webpack 的完整构建流程.md.html @@ -299,7 +299,7 @@ new CommonJsPlugin(options.module).apply(compiler);

            优化阶段

            优化阶段在 seal 函数中共有 12 个主要的处理过程,如下图所示:

            -

            image

            +

            png

            每个过程都暴露了相应的 Hooks,分别如下:

            • seal、needAdditionalSeal、unseal、afterSeal:分别在 seal 函数的起始和结束的位置触发。
            • @@ -344,7 +344,7 @@ new CommonJsPlugin(options.module).apply(compiler); module.exports = SamplePlugin;

    执行构建后,可以看到在控制台输出了相应的统计时间结果(这里的时间是从构建起始到各阶段 Hook 触发为止的耗时),如下图所示:

    -

    image

    +

    png

    根据这样的输出结果,我们就可以分析项目里各阶段的耗时情况,再进行针对性地优化。这个统计插件将在后面几课的优化实践中运用。

    除了这类自己编写的统计插件外,Webpack 社区中也有一些较成熟的统计插件,例如speed-measure-webpack-plugin等,感兴趣的话,你可以进一步了解。

    总结

    diff --git a/专栏/前端工程化精讲-完/11 编译提效:如何为 Webpack 编译阶段提速?.md.html b/专栏/前端工程化精讲-完/11 编译提效:如何为 Webpack 编译阶段提速?.md.html index 8fd89e09..5da3bf7f 100644 --- a/专栏/前端工程化精讲-完/11 编译提效:如何为 Webpack 编译阶段提速?.md.html +++ b/专栏/前端工程化精讲-完/11 编译提效:如何为 Webpack 编译阶段提速?.md.html @@ -182,16 +182,16 @@ function hide_canvas() {

    提升编译模块阶段效率的第一个方向就是减少执行编译的模块。显而易见,如果一个项目每次构建都需要编译 1000 个模块,但是通过分析后发现其中有 500 个不需要编译,显而易见,经过优化后,构建效率可以大幅提升。当然,前提是找到原本不需要进行构建的模块,下面我们就来逐一分析。

    IgnorePlugin

    有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块。典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包,如下面的图片所示:

    -

    Drawing 0.png

    +

    png

    但对于大多数情况而言,项目中只需要引入本国语言包即可。而 Webpack 提供的 IgnorePlugin 即可在构建模块时直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积,如下面的图片所示。

    -

    Drawing 1.png -Drawing 2.png

    +

    png +png

    除了 moment 包以外,其他一些带有国际化模块的依赖包,例如之前介绍 Mock 工具中提到的 Faker.js 等都可以应用这一优化方式。

    按需引入类库模块

    第二种典型的减少执行模块的方式是按需引入。这种方式一般适用于工具类库性质的依赖包的优化,典型例子是 lodash 依赖包。通常在项目里我们只用到了少数几个 lodash 的方法,但是构建时却发现构建时引入了整个依赖包,如下图所示:

    -

    Drawing 3.png

    +

    png

    要解决这个问题,效果最佳的方式是在导入声明时只导入依赖包内的特定模块,这样就可以大大减少构建时间,以及产物的体积,如下图所示。

    -

    Drawing 4.png

    +

    png

    除了在导入时声明特定模块之外,还可以使用 babel-plugin-lodash 或 babel-plugin-import 等插件达到同样的效果。

    另外,有同学也许会想到 Tree Shaking,这一特性也能减少产物包的体积,但是这里有两点需要注意:

      @@ -200,9 +200,9 @@ function hide_canvas() {

    DllPlugin

    DllPlugin 是另一类减少构建模块的方式,它的核心思想是将项目依赖的框架等模块单独构建打包,与普通构建流程区分开。例如,原先一个依赖 React 与 react-dom 的文件,在构建时,会如下图般处理:

    -

    Drawing 5.png +

    png 而在通过 DllPlugin 和 DllReferencePlugin 分别配置后的构建时间就变成如下图所示,由于构建时减少了最耗时的模块,构建效率瞬间提升十倍。

    -

    Drawing 6.png

    +

    png

    Externals

    Webpack 配置中的 externals 和 DllPlugin 解决的是同一类问题:将依赖的框架等模块从构建过程中移除。它们的区别在于:

      @@ -212,15 +212,15 @@ function hide_canvas() {
    1. 在引用依赖包的子模块时,DllPlugin 无须更改,而 externals 则会将子模块打入项目包中。

    externals 的示例如下面两张图,可以看到经过 externals 配置后,构建速度有了很大提升。

    -

    Drawing 7.png -Drawing 8.png

    +

    png +png

    提升单个模块构建的速度

    提升编译阶段效率的第二个方向,是在保持构建模块数量不变的情况下,提升单个模块构建的速度。具体来说,是通过减少构建单个模块时的一些处理逻辑来提升速度。这个方向的优化主要有以下几种:

    include/exclude

    Webpack 加载器配置中的 include/exclude,是常用的优化特定模块构建速度的方式之一。

    include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理。而 exclude 则相反,不对特定条件的模块使用该 Loader(例如不使用 babel-loader 处理 node_modules 中的模块)。如下面两张图片所示。

    -

    Drawing 9.png -Drawing 10.png +

    png +png 这里有两点需要注意:

    1. 从上面的第二张图中可以看到,jquery 和 lodash 的编译过程仍然花费了数百毫秒,说明通过 include/exclude 排除的模块,并非不进行编译,而是使用 Webpack 默认的 js 模块编译器进行编译(例如推断依赖包的模块类型,加上装饰代码等)。
    2. @@ -228,17 +228,17 @@ function hide_canvas() {

    noParse

    Webpack 配置中的 module.noParse 则是在上述 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间,如下面两张图片所示。

    -

    Drawing 11.png -Drawing 12.png

    +

    png +png

    Source Map

    Source Map 对于构建时间的影响在第三课中已经展开讨论过,这里再稍做总结:对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map。在开启 Source Map 的情况下,优先选择与源文件分离的类型,例如 "source-map"。有条件也可以配合错误监控系统,将 Source Map 的构建和使用在线下监控后台中进行,以提升普通构建部署流程的速度。

    TypeScript 编译优化

    Webpack 中编译 TS 有两种方式:使用 ts-loader 或使用 babel-loader。其中,在使用 ts-loader 时,由于 ts-loader 默认在编译前进行类型检查,因此编译时间往往比较慢,如下面的图片所示。

    -

    Drawing 13.png +

    png 通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查,从而大大提升 TS 模块的编译速度,如下面的图片所示。

    -

    Drawing 14.png +

    png 而 babel-loader 则需要单独安装 @babel/preset-typescript 来支持编译 TS(Babel 7 之前的版本则还是需要使用 ts-loader)。babel-loader 的编译效率与上述 ts-loader 优化后的效率相当,如下面的图片所示。

    -

    Drawing 15.png +

    png 不过单独使用这一功能就丧失了 TS 中重要的类型检查功能,因此在许多脚手架中往往配合 ForkTsCheckerWebpackPlugin 一同使用。

    Resolve

    Webpack 中的 resolve 配置制定的是在构建时指定查找模块文件的规则,例如:

    @@ -249,18 +249,18 @@ function hide_canvas() {
  • resolve.symlinks:指定在查找模块时是否处理软连接。
  • 这些规则在处理每个模块时都会有所应用,因此尽管对小型项目的构建速度来说影响不大,但对于大型的模块众多的项目而言,这些配置的变化就可能产生客观的构建时长区别。例如下面的示例就展示了使用默认配置和增加了大量无效范围后,构建时长的变化情况:

    -

    Drawing 16.png -Drawing 17.png

    +

    png +png

    并行构建以提升总体效率

    第三个编译阶段提效的方向是使用并行的方式来提升构建的效率。并行构建的方案早在 Webpack 2 时代已经出现,随着目前最新稳定版本 Webpack 4 的发布,人们发现在一般项目的开发阶段和小型项目的各构建流程中已经用不到这种并发的思路了,因为在这些情况下,并发所需要的多进程管理与通信所带来的额外时间成本可能会超过使用工具带来的收益。但是在大中型项目的生产环境构建时,这类工具仍有发挥作用的空间。这里我们介绍两类并行构建的工具: HappyPack 与 thread-loader,以及 parallel-webpack。

    HappyPack 与 thread-loader

    这两种工具的本质作用相同,都作用于模块编译的 Loader 上,用于在特定 Loader 的编译过程中,以开启多进程的方式加速编译。HappyPack 诞生较早,而 thread-loader 参照它的效果实现了更符合 Webpack 中 Loader 的编写方式。下面就以 thread-loader 为例,来看下应用前后的构建时长对比,如下面的两张图所示。

    -

    Drawing 18.png -Drawing 19.png

    +

    png +png

    parallel-webpack

    并发构建的第二种场景是针对与多配置构建。Webpack 的配置文件可以是一个包含多个子配置对象的数组,在执行这类多配置构建时,默认串行执行,而通过 parallel-webpack,就能实现相关配置的并行处理。从下图的示例中可以看到,通过不同配置的并行构建,构建时长缩短了 30%:

    -

    Drawing 20.png -Drawing 21.png

    +

    png +png

    总结

    这节课我们整理了 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, () => { ...

    使用后的效果如下图所示:

    -

    Drawing 0.png

    +

    png

    通过这样的插件,我们可以分析目前项目中的效率瓶颈,从而进一步为选取优化方案及评估方案效果提供依据。

    优化阶段效率提升的整体分析

    在“第 10 课时|流程分解:Webpack 的完整构建流程”中,我们提到了下面的这张图。如图所示,整个优化阶段可以细分为 12 个子任务,每个任务依次对数据进行一定的处理,并将结果传递给下一任务:

    -

    Drawing 2.png

    +

    png

    因此,这一阶段的优化也可以分为两个不同的方向:

    1. 针对某些任务,使用效率更高的工具或配置项,从而提升当前任务的工作效率
    2. @@ -208,7 +208,7 @@ compilation.hooks[end].tap(PluginName, () => {

      Webpack 4 中内置了 TerserWebpackPlugin 作为默认的 JS 压缩工具,之前的版本则需要在项目配置中单独引入,早期主要使用的是 UglifyJSWebpackPlugin。这两个 Webpack 插件内部的压缩功能分别基于 TerserUglifyJS

      第三方的测试结果看,两者在压缩效率与质量方面差别不大,但 Terser 整体上略胜一筹。

      从本节课示例代码的运行结果(npm run build:jscomp)来看,如下面的表格所示,在不带任何优化配置的情况下,3 个测试文件的构建结果都是 Terser 效果更好。

      -

      Lark20200918-161929.png

      +

      png

      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.
    3. mangle 参数的作用是对源代码中的变量与函数名称进行压缩,当参数为 false 时,示例代码压缩后的体积从 1.16KB 增加到 1.84KB,对代码压缩的效果影响非常大。

    在了解了两个参数对压缩质量的影响之后,我们再来看下它们对效率的影响。以上面表格中的 example-antd 为例,我制作了下面的表格进行对比:

    -

    Lark20200918-161934.png

    +

    png

    从结果中可以看到,当compress参数为 false 时,压缩阶段的效率有明显提升,同时对压缩的质量影响较小。在需要对压缩阶段的效率进行优化的情况下,可以优先选择设置该参数

    面向 CSS 的压缩工具

    CSS 同样有几种压缩工具可供选择:OptimizeCSSAssetsPlugin(在 Create-React-App 中使用)、OptimizeCSSNanoPlugin(在 VUE-CLI 中使用),以及CSSMinimizerWebpackPlugin(2020 年 Webpack 社区新发布的 CSS 压缩插件)。

    这三个插件在压缩 CSS 代码功能方面,都默认基于 cssnano 实现,因此在压缩质量方面没有什么差别。

    在压缩效率方面,首先值得一提的是最新发布的 CSSMinimizerWebpackPlugin,它支持缓存和多进程,这是另外两个工具不具备的。而在非缓存的普通压缩过程方面,整体上 3 个工具相差不大,不同的参数结果略有不同,如下面的表格所示(下面结果为示例代码中 example-css 的执行构建结果)。

    -

    Lark20200918-161938.png

    +

    png

    注:CSSMinimizerWebpackPlugin 中默认开启多进程选项 parallel,但是在测试示例较小的情况下,多进程的通信时间反而可能导致效率的降低。测试中关闭多进程选项后,构建时间明显缩短。

    @@ -271,13 +271,13 @@ optimization: { ...

    在这个示例中,有两个入口文件引入了相同的依赖包 lodash,在没有额外设置分包的情况下, lodash 被同时打入到两个产物文件中,在后续的压缩代码阶段耗时 1740ms。而在设置分包规则为 chunks:'all' 的情况下,通过分离公共依赖到单独的 Chunk,使得在后续压缩代码阶段,只需要压缩一次 lodash 的依赖包代码,从而减少了压缩时长,总耗时为 1036ms。通过下面两张图片也可以看出这样的变化。

    -

    Drawing 3.png -Drawing 4.png

    +

    png +png

    这里起作用的是 Webpack 4 中内置的 SplitChunksPlugin,该插件在 production 模式下默认启用。其默认的分包规则为 chunks: 'async',作用是分离动态引入的模块 (import('...')),在处理动态引入的模块时能够自动分离其中的公共依赖。

    但是对于示例中多入口静态引用相同依赖包的情况,则不会处理分包。而设置为 chunks: 'all',则能够将所有的依赖情况都进行分包处理,从而减少了重复引入相同模块代码的情况。SplitChunksPlugin 的工作阶段是在optimizeChunks阶段(Webpack 4 中是在 optimizeChunksAdvanced,在 Webpack 5 中去掉了 basic 和 advanced,合并为 optimizeChunks),而压缩代码是在 optimizeChunkAssets 阶段,从而起到提升后续环节工作效率的作用。

    Tree Shaking

    Tree Shaking(摇树)是指在构建打包过程中,移除那些引入但未被使用的无效代码(Dead-code elimination)。这种优化手段最早应用于在 Rollup 工具中,而在 Webpack 2 之后的版本中, Webpack 开始内置这一功能。下面我们先来看一下 Tree Shaking 的例子,如下面的表格所示:

    -

    Lark20200918-161943.png

    +

    png

    可以看到,引入不同的依赖包(lodash vs lodash-es)、不同的引入方式,以及是否使用 babel 等,都会对 Tree Shaking 的效果产生影响。下面我们就来分析具体原因。

    1. ES6 模块: 首先,只有 ES6 类型的模块才能进行Tree Shaking。因为 ES6 模块的依赖关系是确定的,因此可以进行不依赖运行时的静态分析,而 CommonJS 类型的模块则不能。因此,CommonJS 类型的模块 lodash,在无论哪种引入方式下都不能实现 Tree Shaking,而需要依赖第三方提供的插件(例如 babel-plugin-lodash 等)才能实现动态删除无效代码。而 ES6 风格的模块 lodash-es,则可以进行 Tree Shaking 优化。
    2. diff --git a/专栏/前端工程化精讲-完/13 缓存优化:那些基于缓存的优化方案.md.html b/专栏/前端工程化精讲-完/13 缓存优化:那些基于缓存的优化方案.md.html index bb303d23..3ba60b6c 100644 --- a/专栏/前端工程化精讲-完/13 缓存优化:那些基于缓存的优化方案.md.html +++ b/专栏/前端工程化精讲-完/13 缓存优化:那些基于缓存的优化方案.md.html @@ -176,8 +176,8 @@ function hide_canvas() {
      ./src/example-basic.js
       import _ from 'lodash'
       
      -

      Drawing 0.png -Drawing 1.png

      +

      png +png

      可以看到,在没有增加任何优化设置的情况下,初次构建时在 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()) {
       
    3. cacheCompression:默认为 true,将缓存内容压缩为 gz 包以减小缓存目录的体积。在设为 false 的情况下将跳过压缩和解压的过程,从而提升这一阶段的速度。
    4. 开启缓存选项前后的构建时长效果如图所示(示例中运行 npm run build:babel),可以看到,由于开启了 Babel 的缓存,再次构建的速度比初次构建时要快了许多。

      -

      Drawing 2.png -Drawing 3.png

      +

      png +png

      Cache-loader

      在编译过程中利用缓存的第二种方式是使用 Cache-loader。在使用时,需要将 cache-loader 添加到对构建效率影响较大的 Loader(如 babel-loader 等)之前,如下面的代码所示:

      ./webpack.cache.config.js
      @@ -227,8 +227,8 @@ module: {
       ...
       

      执行两次构建后可以发现,使用 cache-loader 后,比使用 babel-loader 的开启缓存选项后的构建时间更短,如下图所示:

      -

      Drawing 4.png -Drawing 5.png

      +

      png +png

      主要原因是 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。

      -

      Drawing 6.png -Drawing 7.png

      +

      png +png

      以上就是 Webpack 4 中编译与优化打包阶段可用的几种缓存方案。接下来我们再来看下在构建过程中使用缓存的一些注意点。

      缓存的失效

      尽管上面示例所显示的再次构建时间要比初次构建时间快很多,但前提是两次构建没有任何代码发生变化,也就是说,最佳效果是在缓存完全命中的情况下。而现实中,通常需要重新构建的原因是代码发生了变化。因此如何最大程度地让缓存命中,成为我们选择缓存方案后首先要考虑的事情。

      @@ -253,8 +253,8 @@ module: {

      编译阶段的执行时间由每个模块的编译时间相加而成。在开启缓存的情况下,代码发生变化的模块将被重新编译,但不影响它所依赖的及依赖它的其他模块,其他模块将继续使用缓存。因此,这一阶段不需要考虑缓存失效扩大化的问题。

      优化打包阶段的缓存失效

      优化打包阶段的缓存失效问题则需要引起注意。还是以课程开头的 example-basic 为例,在使用缓存快速构建后,当我们任意修改入口文件的代码后会发现,代码压缩阶段的时间再次变为和初次构建时相近,也就是说,这一 Chunk 的 Terser 插件的缓存完全失效了,如下面的图片所示。

      -

      Drawing 8.png -Drawing 9.png

      +

      png +png

      之所以会出现这样的结果,是因为,尽管在模块编译阶段每个模块是单独执行编译的,但是当进入到代码压缩环节时,各模块已经被组织到了相关联的 Chunk 中。如上面的示例,4 个模块最后只生成了一个 Chunk,任何一个模块发生变化都会导致整个 Chunk 的内容发生变化,而使之前保存的缓存失效。

      在知道了失效原因后,对应的优化思路也就显而易见了:尽可能地把那些不变的处理成本高昂的模块打入单独的 Chunk 中。这就涉及了 Webpack 中的分包配置——splitChunks

      使用 splitChunks 优化缓存利用率

      @@ -268,7 +268,7 @@ optimization: { }, ...
      -

      Drawing 10.png

      +

      png

      其他使用缓存的注意事项

      CI/CD 中的缓存目录问题

      在许多自动化集成的系统中,项目的构建空间会在每次构建执行完毕后,立即回收清理。在这种情况下,默认的项目构建缓存目录(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 中的增量构建

      上述只构建改动文件的处理过程在 Webpack 中是实际存在的,你可能也很熟悉,那就是在开启 devServer的时候,当我们执行 webpack-dev-server 命令后,Webpack 会进行一次初始化的构建,构建完成后启动服务并进入到等待更新的状态。当本地文件有变更时,Webpack 几乎瞬间将变更的文件进行编译,并将编译后的代码内容推送到浏览器端。你会发现,这个文件变更后的处理过程就符合上面所说的只编译打包改动的文件的操作,这就称为“增量构建”。我们通过示例代码进行验证(npm run dev),如下面的图片:

      -

      Drawing 0.png -Drawing 1.png

      +

      png +png

      可以看到,在开发服务模式下,初次构建编译了 47 个模块,完整的构建时间为 3306ms。当我们改动其中一个源码文件后,日志显示 Webpack 只再次构建了这一个模块,因此再次构建的时间非常短(24ms)。那么为什么在开发服务模式下可以实现增量构建的效果,而在生产环境下不行呢?下面我们来分析影响结果的因素。

      增量构建的影响因素

      watch 配置

      在上面的增量构建过程中,第一个想到的就是需要监控文件的变化。显然,只有得知变更的是哪个文件后,才能进行后续的针对性处理。要实现这一点也很简单,在“第 2 课时|界面调试:热更新技术如何开着飞机修引擎?”中已经介绍过,在 Webpack 中启用 watch 配置即可,此外在使用 devServer 的情况下,该选项会默认开启。那么,如果在生产模式下开启 watch 配置,是不是再次构建时,就会按增量的方式执行呢?我们仍然通过示例验证(npm run build:watch),如下面的图片所示:

      -

      Drawing 2.png -Drawing 3.png

      +

      png +png

      从结果中可以发现,在生产模式下开启 watch 配置后,相比初次构建,再次构建所编译的模块数量并未减少,即使只改动了一个文件,也仍然会对所有模块进行编译。因此可以得出结论,在生产环境下只开启 watch 配置后的再次构建并不能实现增量构建。

      cache 配置

      仔细查阅 Webpack 的配置项文档,会在菜单最下方的“其他选项”一栏中找到 cache 选项(需要注意的是我们查阅的是 Webpack 4 版本的文档,Webpack 5 中这一选项会有大的改变,会在下一节课中展开讨论)。这一选项的值有两种类型:布尔值和对象类型。一般情况下默认为false,即不使用缓存,但在开发模式开启 watch 配置的情况下,cache 的默认值变更为true。此外,如果 cache 传值为对象类型,则表示使用该对象来作为缓存对象,这往往用于多个编译器 compiler 的调用情况。

      下面我们就来看一下,在生产模式下,如果watch 和 cache 都为 true,结果会如何(npm run build:watch-cache)?如下面的图片所示:

      -

      Drawing 4.png -Drawing 5.png

      +

      png +png

      正如我们所期望的,再次构建时,在编译模块阶段只对有变化的文件进行了重新编译,实现了增量编译的效果。

      但是美中不足的是,在优化阶段压缩代码时仍然耗费了较多的时间。这一点很容易理解:

      体积最大的 react、react-dom 等模块和入口模块打入了同一个 Chunk 中,即使修改的模块是单独分离的 bar.js,但它的产物名称的变化仍然需要反映在入口 Chunk 的 runtime 模块中。因此入口 Chunk 也需要跟着重新压缩而无法复用压缩缓存数据。根据前面几节课的知识点,我们对配置再做一些优化,将 vendor 分离后再来看看效果,如下面的图片所示:

      -

      Drawing 6.png -Drawing 7.png

      +

      png +png

      可以看到,通过上面这一系列的配置后(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 = { ... }
      -

      Drawing 0.png -Drawing 1.png -Drawing 2.png

      +

      png +png +png

      可以看到,初次构建完整花费了 3282ms,而在不修改代码进行再次构建的情况下,只花费了不到原先时间的 1/10。在修改代码文件的新情况下也只花费了 628ms,多花费的时间体现在构建被修改的文件的编译上,这就实现了上一课时所寻求的生产环境下的增量构建

      Cache 基本配置

      在 Webpack 4 中,cache 只是单个属性的配置,所对应的赋值为 true 或 false,用来代表是否启用缓存,或者赋值为对象来表示在构建中使用的缓存对象。而在 Webpack 5 中,cache 配置除了原本的 true 和 false 外,还增加了许多子配置项,例如:

      @@ -282,8 +282,8 @@ console.log(a)

      可以看到产物代码中只有被引入的属性 a 和 console 语句,而其他两个导出属性 b 和 c 已经在产物中被排除了。

      Logs

      第三个要提到的 Webpack 5 的效率优化点是,它增加了许多内部处理过程的日志,可以通过 stats.logging 来访问。下面两张图是使用相同配置*stats: {logging: "verbose"}*的情况下,Webpack 4 和 Webpack 5 构建输出的日志:

      -

      Drawing 3.png -Drawing 4.png

      +

      png +png

      可以看到,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) => {...}) -

      Drawing 0.png

      +

      png

      从示例中可以看到,在没有任何构建工具处理的情况下,在页面中引入带有 type="module" 属性的 script,浏览器就会在加载入口模块时依次加载了所有被依赖的模块。下面我们就来深入了解一下这种基于浏览器加载 JS 模块的技术的细节。

      基于浏览器的 JS 模块加载功能

      从 caniuse 网站中可以看到,目前大部分主流的浏览器都已支持 JavaScript modules 这一特性,如下图所示:

      -

      Drawing 1.png

      +

      png

      [图片来源:https://caniuse.com/es6-module]

      我们来总结这种加载方式的注意点。

      HTML 中的 Script 引用

      @@ -223,9 +223,9 @@ cd example-vite npm install npm run dev -

      Drawing 2.png

      +

      png

      可以看到,运行示例代码后,在浏览器中只引入了 src/main.js 这一个入口模块,但是在网络面板中却依次加载了若干依赖模块,包括外部模块 vue 和 css。依赖图如下:

      -

      Drawing 4.png

      +

      png

      可以看到,经过 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

      -

      Drawing 0.png

      +

      png

      Jenkins Logo

      Jenkins 是诞生较早且使用广泛的开源持续集成工具。早在 2004 年,Sun 公司就推出了它的前身 Husdon,它在 2011 年更名为 Jenkins。下面介绍它的功能特点。

      功能特点

      @@ -178,10 +178,10 @@ function hide_canvas() {
    5. Job 配置:得益于其插件系统,在 Jenkins 的 Job 配置中可以灵活定制各种复杂的构建与部署选项,例如构建远程触发、构建参数化选项、关联 Jira、执行 Windows 批处理、邮件通知等。
    6. API 调用:Jenkins 提供了 Restful 的 API 接口,可用于外部调用控制节点、任务、配置、构建等处理过程。
    7. -

      Drawing 1.png

      +

      png

      Jenkins 中 Job 的基本配置界面

      CircleCI

      -

      Drawing 2.png

      +

      png

      CircleCI Logo

      CircleCI 是一款基于云端的持续集成服务,下面介绍它的功能特点。

      功能特点

      @@ -193,10 +193,10 @@ function hide_canvas() {
    8. 配置简化:在 CircleCI 中提供了开箱即用的用户体验,只需要少量配置即可快速开始构建项目。
    9. API 调用:CircleCI 中也提供了 Restfull 的 API 接口,可用于访问项目、构建和产物。
    10. -

      Drawing 3.png

      +

      png

      CircleCI 项目流水线示例界面

      Github Actions

      -

      Drawing 4.png

      +

      png

      Github Actions Logo

      Github Actions(GHA)是 Github 官方提供的 CI/CD 流程工具,用于为 Github 中的开源项目提供简单易用的持续集成工作流能力。

      功能特点

      @@ -208,9 +208,9 @@ function hide_canvas() {
    11. 社区支持:Github 社区中提供了众多工作流的模板可供选择使用,例如构建并发布 npm 包、构建并提交到 Docker Hub 等。
    12. 费用情况:Github Action 对于公开的仓库,以及在自运维执行器的情况下是免费的。而对于私有仓库则提供一定额度的免费执行时间和免费存储空间,超出部分则需要收费。
    13. -

      Drawing 5.png

      +

      png

      Github Actions 的工作流模板

      -

      Drawing 6.png

      +

      png

      Github Actions 中的矩阵执行示例

      Gitlab CI

      Gitlab 是由 Gitlab Inc. 开发的基于 Git 的版本管理与软件开发平台。除了作为代码仓库外,它还具有在线编辑、Wiki、CI/CD 等功能。在费用方面,它提供了免费的社区版本(Community Edition,CE)和免费或收费的商用版本(Enterprise Edition,EE)。其中社区版本和免费的商用版本的区别主要体现在升级到付费商用版本时的操作成本。另一方面,即使是免费的社区版本,其功能也能够满足企业内的一般使用场景,因此常作为企业内部版本管理系统的主要选择之一,下面我们就来了解 Gitlab 内置的 CI/CD 功能。

      @@ -220,7 +220,7 @@ function hide_canvas() {
    14. 独立安装执行器:与前面两款产品不同的是,Gitlab 中需要单独安装执行器。Gitlab 中的执行器 Gitlab Runner 是一个独立运行的开源程序,它的作用是执行任务,并将结果反馈到 Gitlab 中。开发者可以在独立的服务器上安装Gitlab Runner 工具,然后依次执行gitlab-runner register注册特定配置的 Runner,最后执行gitlab-runner start启动相应服务。此外,项目中除了注册独立的 Runner 外,也可以使用共享的或组内通用的 Runner。
    15. 当项目根目录中存在.gitlab-ci.yml 文件时,用户提交代码到 Git 仓库时,在 Gitlab 的 CI/CD 面板中即可看到相应的任务记录,当成功设置 gitlab-runner 时这些任务就会在相应的 Runner 中执行并反馈日志和结果。如下图所示:

      -

      Drawing 7.png

      +

      png

      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 种项目执行安装时可能遇到的场景:

      -

      Drawing 0.png

      +

      png

      注 1:除了第一种纯净环境外,后面的环境中都存在 Lock 文件。因为 Lock 文件对于提供稳定依赖版本至关重要。出于现实场景考虑,这里不再单独对比没有 Lock 文件但存在历史安装目录的场景。 注 2: 为了屏蔽网络对解析下载依赖包的影响,所有目录下均使用相同注册表网址 registry.npm.taobao.org。 @@ -209,7 +209,7 @@ time pnpm i

      不同维度对安装效率的影响分析

      纯净环境

      首先来对纯净环境进行分析,不同安装方式的执行耗时统计如下:

      -

      Drawing 1.png

      +

      png

      注 1:总安装时间为执行后显示的时间。而各阶段的细分时间在日志中分析获取。 注 2:在 pnpm 的执行过程中并未对各阶段进行完全分隔,而是针对不同依赖包递归执行各阶段,这种情况在纯净环境中尤其明显,因此阶段时间上不便做单独划分。

      @@ -222,7 +222,7 @@ time pnpm i

      Lock 环境

      然后我们来考察 Lock 文件对于安装效率的影响。和第一种最纯净的情况相比,带有 Lock 文件的情况通常更符合现实中项目在部署环境中的初始状态(因为 Lock 文件可以在一定程度上保证项目依赖版本的稳定性,因此通常都会把 Lock 文件也保留在代码仓库中)。引入 Lock 文件后,不同安装工具执行安装的耗时情况如下:

      -

      Drawing 2.png

      +

      png

      注 1: Yarn 解析依赖阶段日志未显示耗时,因此标记为 0 秒。

      @@ -233,7 +233,7 @@ time pnpm i

      缓存环境

      缓存环境是在部署服务中可能遇到的一种情形。项目在部署过程中依赖安装时产生了本地缓存,部署结束后项目工作目录被删除,因此再次部署开始时工作目录内有 Lock 文件,也有本地缓存,但是不存在安装目录。这种情形下的耗时统计如下:

      -

      Drawing 3.png

      +

      png

      对结果的分析如下:

      • 从执行时间上看,各类型的安装方式的耗时都明显下降。
      • @@ -241,7 +241,7 @@ time pnpm i

      无缓存的重复安装环境

      无缓存的重复安装环境在本地环境下部署时可能遇到,即当本地已存在安装目录,但人工清理缓存后再次执行安装时可能遇到。这种情况的耗时如下:

      -

      Drawing 4.png

      +

      png

      对结果的分析如下:

      • 从上面的表格中可以看到,存在安装目录这一条件首先对链接阶段能起到优化的作用。对于下载阶段,除了使用 PnP 的两种安装方式外,当项目中已存在安装目录时,下载阶段耗时也趋近于零。其中 Yarn v1 表现最好,各主要阶段都直接略过,而 npm 和 pnpm 则多少还有一些处理过程。
      • @@ -249,7 +249,7 @@ time pnpm i

      有缓存的重复安装环境

      最后是安装目录与本地缓存都存在的情况,耗时如下:

      -

      Drawing 5.png

      +

      png

      对结果的分析如下:

      • 无论对于哪种安装方式而言,这种情况都是最理想的。可以看到,各安装工具的耗时都趋近于零。其中尤其对于 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 -

        image.png

        +

        png

        下载同样的依赖包,使用国内镜像源的速度只有官方源的 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 压缩选项的参数进行对比(数据来自上面的参考文档),如下面的表格所示:

        -

        image

        +

        png

        从表格中可以发现:

        • 对于同一款压缩工具来说,压缩等级越低,压缩速度越快。代价是相应的压缩率越低,压缩体积会相应增大。
        • 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 结束语 前端效率工程化的未来展望

          你好,我是李思嘉。

          本专栏的内容到这里就结束了。我们先来简单回顾一下整个课程的主要内容,如下图:

          -

          Lark20201026-160921.png

          +

          png

          在这个专栏中,我主要介绍且梳理了前端工程化中效率提升方向的知识,内容涵盖开发效率、构建效率和部署效率三个方面。希望你通过这个系列课程的学习,能建立起前端效率工程化方面相对完整的知识体系,同时在前端开发日常流程中的效率工程类问题方面,能找到分析和解决的新方向。

          当然,这些方向实际涵盖的概念与技术点非常广泛,并不容易完全掌握,除了已有的概念和技术之外,新的技术和方向也在不断涌现。下面我会对前端效率工程化相关的技术做一些展望。

          云工作流

          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)的理论。之后,美国缅因州国家训练实验室也做了相同的实验,并发布了「学习金字塔」报告。

          -

          1624128302678

          +

          png

          人的学习分为「被动学习」和「主动学习」两个层次。

          • 被动学习:如听讲、阅读、视听、演示,学习内容的平均留存率为 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) ,就可以看到这样的描述:

            -

            1624128447955

            +

            png

            你就可以看到这两个参数都是不建议被打开的。

            认真阅读用户手册不但可以让你少掉很多坑,同时,还能让你学习到很多。

            其它几个实用的技巧

            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 屏、高性能的电脑,高级的人体工程学的桌椅,你就可以写出好的代码来一样。我们要成为一个好的羽毛球高手,不是买几副好的运动装备,到正规的体育场去打球,而是要付出常人不能付出的汗水甚至伤痛。任何行业都是这样的。

            这里,我还要把我《高效学习》中那个学习金字塔再帖出来。

            -

            1624128622904

            +

            png

            再次强调一下,这个世界上的学习只有两种,一种是被动学习,一种是主动学习。听课,看书,看视频,看别人的演讲,这些统统都是被动学习,知识的留存度最多只有 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() {

            也有很多测试从业者认识到了互联网的核心是各种类型的微服务,而且服务端承载了业务的核心逻辑和用户价值,所以他们选择了服务端测试工程师职业方向。思路和切入点很好,但是对于微服务架构下的服务端应该如何测试,网络上大多是关于接口测试自动化及框架之类的资料,很难让他们建立一个整体的认知,并因此容易误会为——服务端测试只能通过接口测试来进行。

            其实,服务端测试是一套全方位的测试保障体系,除了保证对外提供的接口符合要求,在业务广度和技术深度方面都需要有良好的覆盖率,并且要求有一系列的流程规范、方法、工具等做支撑。而软件测试人员需要根据技术架构和测试对象的特点,相应地调整自己的测试策略和思路,积累和总结测试方法和技能,进而沉淀出体系化的保障体系。

            此外,各大互联网公司也都在积极招募服务端测试高级工程师、服务端测试开发工程师等服务端测试岗位,薪资非常具有竞争优势:

            -

            image

            -

            image

            +

            png

            +

            png

            从招聘需求中可以看到,与很多测试从业者对服务端测试的认知和技能还停留在传统的服务端测试阶段不同,大厂已经明确要求服务端测试工程师参与服务端质量保障体系的建设。而即使熟悉服务端质量保障体系的测试人才,也因为微服务的盛行面临新的挑战。他们需要针对微服务的特点、所在项目的环境情况做进一步的分析,对质量保障体系做合理裁剪,才能真正落地应用。

            服务端质量(保障)体系的重要性

            这里我们有必要先厘清两个概念:测试更多指具体的测试活动,而质量保障是一个全面的体系化的内容,测试只是其中的一个环节或方面。

            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)。单体应用架构下,一个服务中包含了与用户交互的部分、业务逻辑处理层和数据访问层。如果存在数据库交互则与数据库直连,如下图所示。

            -

            Drawing 0.png

            +

            png

            单体应用架构下,一个服务中,两个业务模块作为该服务的一部分存在同一进程中,它们通过方法调用的方式进行通信,如下图所示。

            -

            Drawing 2.png

            +

            png

            通过在单体应用架构下,不同阶段的服务端相关工作,可以感知到单体应用的特性。

            1. 日常研发测试阶段

              @@ -178,10 +178,10 @@ function hide_canvas() {

              现在应用程序日益复杂化,项目对于迭代速度的要求也越来越高,上述的不足会暴露得更加明显,在这种时代背景下,微服务架构开始在企业生根发芽。

              微服务架构下的服务特性

              后来我转到了互联网公司工作,所在项目的服务架构与过去经历过的单体应用架构下的服务差异巨大。同等规模的研发团队,服务的个数竟然有近百个,虽然数量众多,但每个服务都只负责一小块儿具体的业务功能,能独立地部署到环境中,服务间边界相对清晰,相互间通过轻量级的接口调用或消息队列进行通信,为用户提供最终价值。这样的服务称为微服务(Microservice)。 从本质上来说,微服务是一种架构模式,是面向服务型架构(SOA)的一种变体,如下图所示。

              -

              Drawing 4.png

              +

              png

              上图所示,微服务架构下,业务逻辑层被分拆成不同的微服务,其中不需要与数据库交互的服务将不再与数据库连接,需要与数据库交互的服务则直接与数据库连接。

              微服务架构下,因为两个服务分别在自己的进程中,所以它们不能通过方法调用进行通信,而是通过远程调用的方式进行通信,如下图所示。

              -

              Drawing 6.png

              +

              png

              同样,通过在微服务架构下,不同阶段的服务端相关工作,可以感知到微服务的特性。

              1. 日常研发测试阶段

              因为微服务数量众多,研发和测试团队都有诉求构建一个良好的基础建设。如搭建持续交付工具,通过持续交付工具拉取某微服务代码,再进行编译、分发、部署到测试环境的机器上。再加上,微服务应用程序本身并不大,部署耗时短、影响范围小、风险低,整个编译分发部署的过程在几分钟之内就可以搞定,且几乎是自动完成,因此部署频率可以做到很高。

              @@ -192,7 +192,7 @@ function hide_canvas() {

              4. 其他阶段

              架构设计方面,微服务可以使用不同的语言,采用不同的架构,部署到不同的环境。同时可以采用适合微服务业务场景的技术,来构建合理的微服务模块。

              由此可见,微服务的确解决了单体应用架构下服务的诸多短板。单体应用与微服务对比总结如下。

              -

              Drawing 8.png

              +

              png

              微服务的缺点

              当然,事物都有两面性,任何一项技术都不可能十全十美,在解决一定问题的同时,也会引入新的问题。 那么,微服务架构下服务有哪些缺点呢?

              从微服务架构设计角度来看。

              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() {
            • 测试需要分层,每一层的测试颗粒度有所不同;
            • 不同层次的测试比重有差异,通常来说,层次越高,测试比重应越少。
            -

            Drawing 0.png

            +

            png

            需要说明的是,传统意义下的测试金字塔,在微服务架构下不再完全奏效。因为微服务中最大的复杂性不在于服务本身,而在于微服务之间的交互方式,这一点值得特别注意。

            因此,针对微服务架构,常见的测试策略模型有如下几种。

            (1) 微服务“测试金字塔”

            基于微服务架构的特点和测试金字塔的原理,Toby Clemson 有一篇关于“微服务架构下的测试策略”的文章,其中通过分析阐述了微服务架构下的通用测试策略。

            -

            Drawing 1.png

            +

            png

            如图,该策略模型依然是金字塔形状,从下到上依次为单元测试、集成测试、组件测试、端到端测试、探索式测试。

            (2) 微服务“测试蜂巢”

            这种策略模型是蜂巢形状,它强调重点关注服务间的集成测试,而单元测试和端到端测试的占比较少。

            -

            Drawing 3.png

            +

            png

            (3) 微服务“测试钻石”

            这种策略模型是钻石形状的,组件测试和契约测试是重点,单元测试比率减少,另外增加了安全和性能等非功能的测试类型。

            -

            Drawing 5.png

            +

            png

            我想,有多少个基于微服务架构的测试团队大概就有多少个测试策略模型吧。“测试金字塔”是一种测试策略模型和抽象框架,当技术架构、系统特点、质量痛点、团队阶段不同时,每种测试的比例也不尽相同,而且最关键的,并不一定必须是金字塔结构。

            理解了测试策略模型的思考框架,我们看下应如何保障测试活动的全面性和有效性。

            全面性

            微服务架构下,既需要保障各服务内部每个模块的完整性,又需要关注模块间、服务间的交互。只有这样才能提升测试覆盖率和全面性,因此,可以通过如下的分层测试来保证微服务的全面性。

            -

            Drawing 7.png

            +

            png

            • 单元测试(Unit Test) :从服务中最小可测试单元视角验证代码行为符合预期,以便测试出方法、类级别的缺陷。
            • 集成测试(Integration Test):验证当前服务与外部模块之间的通信方式或者交互符合预期,以便测试出接口缺陷。
            • @@ -198,7 +198,7 @@ function hide_canvas() {

              测试策略如同测试技术、技术架构一样,并不是一成不变,它会随着业务或项目所处的阶段,以及基于此的其他影响因素的变化而不断演进。但归根结底,还是要从质量保障的目标出发,制定出适合当时的测试策略,并阶段性地对策略进行评估和度量,进而不断改进和优化测试策略。因此,选取测试策略一定要基于现实情况的痛点出发,结果导向,通过调整测试策略来解决痛点。

              比如,在项目早期阶段或某 MVP 项目中,业务的诉求是尽快发布到线上,对功能的质量要求不太高,但对发布的时间节点要求非常严格。那这种情况下快速地用端到端这种能模拟用户真实价值的测试方法保障项目质量也未尝不可;随着项目逐渐趋于平稳后,时间要求渐渐有了节奏,对功能的质量要求会逐渐变高,那么这时候可以再根据实际情况引入其他测试方法,如契约测试或组件测试等。

              你要永远记住,适合自身项目阶段和团队的测试策略才是“完美”的策略。

              -

              Drawing 7.png

              +

              png

              如何建立质量保障体系?

              上述分层的测试策略只是尽可能地对微服务进行全面的测试,确保系统的所有层次都被覆盖到,它更多体现在测试活动本身的全面性和有效性方面。要想将质量保障内化为企业的组织能力,就需要通过技术和管理手段形成系统化、标准化和规范化的机制,这就需要建设质量保障体系。

              质量保障体系:通过一定的流程规范、测试技术和方法,借助于持续集成/持续交付等技术把质量保障活动有效组合,进而形成系统化、标准化和规范化的保障体系。 同时,还需要相应的度量、运营手段以及组织能力的保障。

              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() {

              单元测试的价值

              单元测试是一种白盒测试技术,通常由开发人员在编码阶段完成,目的是验证软件代码中的每个单元(方法或类等)是否符合预期,即尽早尽量小的范围内暴露问题。

              我们都知道,问题发现得越早,修复的代价越小。毫无疑问,在开发阶段进行正确的单元测试可以极大地节省时间和金钱。如果跳过单元测试,会导致在后续更高级别的测试阶段产生更高的缺陷修复成本。

              -

              Drawing 0.png

              +

              png

              如图,假如有一个只包含两个单元 A 和 B 的程序,且只执行端到端测试,如果在测试过程中发现了缺陷,则可能有如下多种原因:

              • 该缺陷由单元 A 中的缺陷引起;
              • @@ -185,10 +185,10 @@ function hide_canvas() {

                就像之前课程所说:微服务中最大的复杂性不在于服务本身,而在于微服务之间的交互方式,服务与服务之间常常互相调用以实现更多更复杂的功能。

                举个例子,我们需要测试的是订单类(Order)中的获取总价方法(getTotalPrice()),而在该方法中除了自有的一些代码逻辑外,通常需要去调用其他类的方法。比如这里调用的是用户类(User)的优惠等级方法(reductionLevel ())和商品类(Goods)中的商品价格方法(getUnitPrice())。很显然,优惠等级方法或商品价格方法,只要一方有错误,就会导致订单类获取总价方法的测试失败。基于这种情况,可以有两种单元测试类型。

                1. 社交型单元测试(Sociable Unit Testing)

                -

                Drawing 2.png

                +

                png

                如图,测试订单类的获取总价方法(Order.getTotalPrice())时会真实调用用户类的优惠等级方法(User.reductionLevel())和商品类的商品单价方法(Goods.getUnitPrice())。将被测试单元视为黑盒子,直接对其进行测试,这种单元测试称之为社交型单元测试(Sociable Unit Testing)

                2. 孤立型单元测试(Solitary Unit Testing)

                -

                Lark20200728-165448.png

                +

                png

                如图,如果测试订单类的获取总价方法(Order.getTotalPrice())时,使用测试替身 (test doubles) 技术来替代用户类的优惠等级方法(User.reductionLevel())和商品类的商品单价方法(Goods.getUnitPrice())的效果。对象及其依赖项之间的交互和协作被测试替身代替,这种单元测试称之为孤立型单元测试(Solitary Unit Testing)

                另外,上述提到的测试替身是一种在测试中使用对象代替实际对象的技术,常用的技术如下。

                  @@ -196,9 +196,9 @@ function hide_canvas() {
                • 模拟代码(Mocks):模拟代码跟桩代码类似,它除了代替真实代码的能力之外,更强调是否使用了特定的参数调用了特定方法,因此,这种对象成为我们测试结果的基础。

                根据被测单元是否与其交互者隔离,会产生以上两种单元测试类型,这两种类型的单元测试在微服务测试中都起着重要作用,它们用来解决不同的测试问题。

                -

                Drawing 5.png

                +

                png

                由上图可知,在微服务架构中,不同组成使用的单元测试类型不同:

                -

                Drawing 6.png

                +

                png

                特别注意:当微服务的(网关+仓库+资源+服务层)与(域逻辑)之比相对较大时,单元测试可能收益不大。常见的情况有小型服务或某些几乎只包含了网关+仓库+资源+服务层等内容的服务,例如适配服务等。

                如何开展单元测试?

                在实际项目过程当中,应该怎样开展单元测试呢?通常来说,可以通过如下四个步骤来进行。

                @@ -216,7 +216,7 @@ function hide_canvas() {

                只单纯地看单元测试的执行通过率还比较单一,为了更全面地看到测试的覆盖情况,可以借助代码覆盖率工具和技术。在 Java 语言里,常用覆盖率工具有 Jacoco、Emma 和 Cobertura,个人推荐使用 Jacoco。

                4. 接入持续集成工具

                接入持续集成工具是为了形成工具链,将单元测试、代码覆盖率统计集成在一起,使得代码有提交时便自动触发单元测试用例的执行,并伴随有代码覆盖率的统计,最后可以看到单元测试报告的数据(用例通过情况和代码层面各个维度的覆盖数据)。接着可以判断是否需要修改代码,这便形成了一个代码质量的反馈环,如下图所示。

                -

                Drawing 7.png

                +

                png

                后续的文章还会讲解到代码覆盖率工具和持续集成工具。

                单元测试最佳实践

                了解了如何开展单元测试,那么如何做到最好呢?我们都知道,代码产生错误无非是对一个业务逻辑或代码逻辑没有实现、实现不充分、实现错误或过分实现,所以无论是拆解业务逻辑还是拆解逻辑控制时都要做到 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 的交互是正常的。

                对于微服务架构来说,集成测试通常关注于验证那些与外部组件(例如数据存储或其他微服务)通信的子系统或模块。 目标是验证这些子系统或模块是否可以正确地与外部组件进行通信,而不是测试外部组件是否正常工作。因此,微服务架构下的集成测试,应该验证要集成的子系统之间与外部组件之间的基本通信路径,包括正确路径和错误路径。

                微服务架构下的集成测试

                -

                image

                +

                png

                微服务结构图与集成测试边界

                如上图所示,网关组件层(Gateways+Http Client+External Service)包含了访问外部服务的逻辑,通常包含一个 HTTP/S 的客户端,客户端会连接到系统中另一个微服务或外部服务。数据持久层(Date Mappers/ORM)用于连接外部数据存储。

                即,微服务架构下的集成测试主要包括两部分:

                @@ -175,7 +175,7 @@ function hide_canvas() {

                这里请注意,因为需要测试微服务下子系统之间的通信和外部服务的通信是否正确,所以理想情况下不应该对外部组件使用测试替身(Test Double)。

                下面我们逐一来看这两部分是如何进行集成测试的:

                (1)网关组件层集成测试

                -

                image

                +

                png

                假设有个登录服务,该服务需要知道当前时间,而时间是由一个外部的时间服务提供的。当向 /api/json/cet/now 发出 GET 请求时,状态码为 200,并返回如下完整的时间信息。

                {
                 $id: "1",
                @@ -203,7 +203,7 @@ serviceResponse: null,
                 
              • 进行相关的测试;
              • 循环上述这个过程。
    -

    image

    +

    png

    常见问题及解决策略

    然而,有很多时候外部服务不可用(服务尚未开发完成、服务有 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)工具隔离掉单个微服务依赖的其他微服务和数据存储,避免测试过程中受到依赖服务或数据存储模块的各类影响(如服务不可用、服务缺陷、数据库连接断开等)而出现阻塞测试过程、测试无效等情况。

    -

    Drawing 0.png

    +

    png

    从某种意义上来说,组件测试的本质上是将一个微服务与其依赖的所有其他服务和数据存储模块等隔离开,对该服务进行的功能验收测试。

    基于组件测试的隔离特性,它有如下优势:

    如下图所示: -image.png

    +png

    在这里需要着重说明下事务id,当我们开启一个事务,并不会马上获得事务id,哪怕我们在事务中执行select语句,也是没有事务id的(事务id为0),只有执行insert/update/delete语句才能获得事务id,这一点尤为重要。

    其中和MVCC紧密相关的是transaction_id和roll_pointer两个字段,在开发过程中,我们无需关心,但是要研究MVCC,我们必须关心。

    如果有类似这样的一行数据: -image.png +png 代表这行数据是由transaction_id为9的事务创建出来的,roll_pointer是空的,因为这是一条新纪录。

    实际上,roll_pointer并不是空的,如果真要解释,需要绕一大圈,理解成空的,问题也不大。

    当我们开启事务,对这条数据进行修改,会变成这样: -image.png

    +png

    有点感觉了吧,这就像一个单向链表,称之为“版本链”,最上面的数据是这个数据的最新版本,roll_pointer指向这个数据的旧版本,给人的感觉就是一行数据有多个版本,是不是符合“多版本并发控制”中的“多版本”这个概念, 那么“并发控制”又是怎么做到的呢,别急,继续往下看。

    ReadView

    @@ -609,7 +609,7 @@ MySQL默认隔离级别,但是在MySQL中,此隔离级别解决了“幻读

    如果某个数据的最新版本不可以被读出来,就顺着roll_pointer找到该数据的上一个版本,继续做如上的判断,以此类推,如果第一个版本也不可见的话,代表该数据对当前事务完全不可见,查询结果就不包含这条记录了。

    看完上面的描述,是不是觉得“云里雾里”,“不知所云”,甚至“脑阔疼,整个人都不好了”。

    我们换个方法来解释,看会不会更容易理解点: -image.png +png 在事务启动的一瞬间(执行CURD操作),会创建出ReadView,对于一个数据版本的trx_id来说,有以下三种情况:

    • 如果落在低水位,表示生成这个版本的事务已经提交了,或者是当前事务自己生成的,这个版本可见。
    • @@ -621,7 +621,7 @@ b. 如果当前版本的trx_id不在活跃事务列表中,代表这个版本

      上面我比较简单的解释了下ReadView,用了两种方式来说明如何判断当前数据版本是否可见,不知道各位看官是不是有了一个比较模糊的概念,有了ReadView的基本概念,我们就可以具体看下READ COMMITTED、REPEATABLE READ这两个事务隔离级别为什么读到的数据是不同的,以及上述规则是如何应用的。

      READ COMMITTED——每次读取数据都会创建ReadView

      假设,现在系统只有一个活跃的事务T,事务id是100,事务中修改了数据,但是还没有提交,形成的版本链是这样的: -image.png

      +png

      现在A事务启动,并且执行了select语句,此时会创建出一个ReadView,m_ids是【100】,min_trx_id是100, max_trx_id是101,creator_trx_id是0。

      为什么m_ids只有一个,为什么creator_trx_id是0?这里再次强调下,只有在事务中执行insert/update/delete语句才能获得事务id。

      那么A事务执行的select语句会读到什么数据呢?

      @@ -639,7 +639,7 @@ b. 如果当前版本的trx_id不在活跃事务列表中,代表这个版本

      所以读到的数据的name是“梦境地底王”。

      REPEATABLE READ ——首次读取数据会创建ReadView

      假设,现在系统只有一个活跃的事务T,事务id是100,事务中修改了数据,但是还没有提交,形成的版本链是这样的: -image.png

      +png

      现在A事务启动,并且执行了select语句,此时会创建出一个ReadView,m_ids是【100】,min_trx_id是100, max_trx_id是101,creator_trx_id是0。

      那么A事务执行的select语句会读到什么数据呢?

        diff --git a/文章/QingStor 对象存储架构设计及最佳实践.md.html b/文章/QingStor 对象存储架构设计及最佳实践.md.html index 9af9f1af..ddaa352a 100644 --- a/文章/QingStor 对象存储架构设计及最佳实践.md.html +++ b/文章/QingStor 对象存储架构设计及最佳实践.md.html @@ -497,7 +497,7 @@ function hide_canvas() {

    QingStor®️对象存储核心优势

    -

    0_1591683403636_1.png

    +

    png

    上面讲的几点是对象存储产品所具备的通用特征,接下来介绍一下青云QingCloud 自研的存储产品 QingStor®️对象存储独有的核心优势,主要包括三方面:

    第一,对海量小文件这个场景,我们在存储及 IO 上都做了针对性的优化优化。

    第二,QingStor®️对象存储的系统具有无限扩展的能力,当数据量、访问量增加时,可以通过增加节点的方式提升计算和存储能力。

    diff --git a/文章/Spring Boot 教程:如何开发一个 starter.md.html b/文章/Spring Boot 教程:如何开发一个 starter.md.html index 0f4df27f..8e2831b6 100644 --- a/文章/Spring Boot 教程:如何开发一个 starter.md.html +++ b/文章/Spring Boot 教程:如何开发一个 starter.md.html @@ -496,7 +496,7 @@ function hide_canvas() {

    所以 Java SPI 实际上是“基于接口的编程+策略模式+配置文件” 组合实现的动态加载机制

    一个 SPI 的典型案例就是 JDBC 的驱动,Java JDBC 定义接口规范(java.sql.Driver),各个数据库厂商(MySQL/Oracle/MS SQLServer 等)去完成具体的实现,然后通过 SPI 配置文件引入具体的实现类,如下图:

    -

    jdbc spi

    +

    png

    Java SPI 机制示例

    一个简单的 Java SPI 开发步骤:

      diff --git a/文章/Spring MyBatis和Spring整合的奥秘.md.html b/文章/Spring MyBatis和Spring整合的奥秘.md.html index 29a6ffb9..f9c97dda 100644 --- a/文章/Spring MyBatis和Spring整合的奥秘.md.html +++ b/文章/Spring MyBatis和Spring整合的奥秘.md.html @@ -486,7 +486,7 @@ function hide_canvas() {

      mybatis-spring使用

      因为现在有了SpringBoot,所以Mybatis和Spring的整合变得非常简单,但是如果没有SpringBoot,该怎么整合呢?我翻阅了百度的前几页,不知道是不是搜索关键词问题,几乎全是用XML的方式去整合Mybatis和Spring的,零XML配置,它不香吗?

      代码结构: -image.png

      +png

      具体实现:

       <dependencies>
               <dependency>
      @@ -626,7 +626,7 @@ this.importBeanDefinitionRegistrars.put(registrar, importingClassMetadata);
       private final Map<ImportBeanDefinitionRegistrar, AnnotationMetadata> importBeanDefinitionRegistrars = new LinkedHashMap<>();
       

      让我们就监视下configClass: -image.png +png 可以看到我们写的MyBeanDefinitionRegistrar被放入了importBeanDefinitionRegistrars ,我们需要记住这个集合,至于还有一个什么,这里不用关心,当然,聪明的小伙伴肯定知道这是什么了。

      我们写的MyBeanDefinitionRegistrar只是被放入了一个Map,并没有执行,下面我们要找找它是在哪里执行的。

      我们需要回到ConfigurationClassPostProcessor的processConfigBeanDefinitions方法:

      diff --git a/文章/Spring 帮助你更好的理解Spring循环依赖.md.html b/文章/Spring 帮助你更好的理解Spring循环依赖.md.html index 683f8c4d..c8846af1 100644 --- a/文章/Spring 帮助你更好的理解Spring循环依赖.md.html +++ b/文章/Spring 帮助你更好的理解Spring循环依赖.md.html @@ -530,7 +530,7 @@ public class AuthorService { }

      启动后,令人恐惧的红色字体在控制台出现了: -image.png

      +png

      如果是构造参数注入的循环依赖,Spring无法解决:

      @Service
       public class AuthorService {
      @@ -548,7 +548,7 @@ public class BookService {
       }
       

      还是讨厌的红色字体: -image.png

      +png

      循环依赖可以关闭吗

      可以,Spring提供了这个功能,我们需要这么写:

      public class Main {
      @@ -561,7 +561,7 @@ public class BookService {
       }
       

      再次运行,就报错了: -image.png

      +png

      需要注意的是,我们不能这么写:

              AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
               applicationContext.setAllowCircularReferences(false);
      diff --git a/文章/Spring中眼花缭乱的BeanDefinition.md.html b/文章/Spring中眼花缭乱的BeanDefinition.md.html
      index eacf17b7..e1fe4418 100644
      --- a/文章/Spring中眼花缭乱的BeanDefinition.md.html
      +++ b/文章/Spring中眼花缭乱的BeanDefinition.md.html
      @@ -481,7 +481,7 @@ function hide_canvas() {
       

      引入主题

      为什么要读Spring源码,有的人为了学习Spring中的先进思想,也有的人是为了更好的理解设计模式,当然也有很大一部分小伙伴是为了应付面试,Spring Bean的生命周期啦,Spring AOP的原理啦,Spring IoC的原理啦,应付面试,看几篇博客,对照着看看源码,应该就没什么问题了,但是如果想真正的玩懂Spring,需要花的时间真的很多,需要你沉下心,从最基础的看起,今天我们就来看看Spring中的基础——BeanDefinition。

      什么是BeanDefinition

      -

      image.png +

      png Spring官网中有详细的说明,我们来翻译下: SpringIoc容器管理一个Bean或多个Bean,这些Bean通过我们提供给容器的配置元数据被创建出来(例如,在xml中的定义) 在容器中,这些Bean的定义用BeanDefinition对象来表示,包含以下元数据:

      @@ -493,14 +493,14 @@ SpringIoc容器管理一个Bean或多个Bean,这些Bean通过我们提供给

    Spring官网中对BeanDefinition的解释还是很详细的,但是不是那么通俗易懂,其实BeanDefinition是比较容易解释的:BeanDefinition就是用来描述一个Bean或者BeanDefinition就是Bean的定义。

    创建一个Java Bean,大概是下面这个酱紫: -image.png +png 我们写的Java文件,会编译为Class文件,运行程序,类加载器会加载Class文件,放入JVM的方法区,我们就可以愉快的new对象了。

    创建一个Spring Bean,大概是下面这个酱紫: -image.png +png 我们写的Java文件,会编译为Class文件,运行程序,类加载器会加载Class文件,放入JVM的方法区,这一步还是保持不变(当然这个也没办法变。。。) 下面就是Spring的事情了,Spring会解析我们的配置类(配置文件),假设现在只配置了A,解析后,Spring会把A的BeanDefinition放到一个map中去,随后,由一个一个的BeanPostProcessor进行加工,最终把经历了完整的Spring生命周期的Bean放入了singleObjects。

    BeanDefinition类图鸟瞰

    -

    image.png +

    png 大家可以看到,Spring中BeanDefinition的类图还是相当复杂的,我刚开始读Spring源码的时候,觉得BeanDefinition应该是一个特别简单的东西,但是后面发觉并不是那么回事。

    下面我将对涉及到的类逐个进行解读。

    AttributeAccessor

    @@ -691,9 +691,9 @@ Root bean: class [com.codebear.springcycle.AuthorService]; scope=prototype; abst

    在介绍GenericBeanDefinition的时候,写了两段代码。

    给第一个代码打上断点,观察下mergedBeanDefinitions,会发现parentBeanDefinition和 childBeanDefinition在mergedBeanDefinitions都变为了RootBeanDefinition: -image.png

    +png

    给第二个代码打上断点,也观察下mergedBeanDefinitions,会发现authorService在mergedBeanDefinitions也变为了RootBeanDefinition: -image.png

    +png

    可以看到在mergedBeanDefinitions存放的都是RootBeanDefinition。

    RootBeanDefinition也可以用来充当父BeanDefinition,就像下面的酱紫:

     public static void main(String[] args) {
    diff --git a/文章/Vert.x 基础入门.md.html b/文章/Vert.x 基础入门.md.html
    index fae77fe9..4fbc3b47 100644
    --- a/文章/Vert.x 基础入门.md.html	
    +++ b/文章/Vert.x 基础入门.md.html	
    @@ -680,7 +680,7 @@ public class MinimalHttpServer {
     }
     

    访问后获得结果:

    -

    http response

    +

    png

    如果想同时构建 HTTP 和 WebSocket 服务,处理起来也很简单,代码如下:

    package com.roytrack.http;
     import io.vertx.core.Vertx;
    @@ -717,7 +717,7 @@ public class HttpAndWsServer {
     }
     

    通过访问 WebSocket 返回如下:

    -

    http response 同时,访问 HTTP 也不受影响,因为 WebSocket 协议的升级包也是 HTTP 协议。

    +

    png 同时,访问 HTTP 也不受影响,因为 WebSocket 协议的升级包也是 HTTP 协议。

    另外如果使用 Verticle 多实例部署,也可以共用一个端口,这样一个 Verticle 停用或者卸载了,也不影响其他 Verticle 的服务,体现了反应式宣言中的回弹性。

    多语言编程 ( Polyglot )

    Vert.x 不止支持 Java,还官方支持 Ceylon 、 Grovvy 、 JavaScript 、Ruby 、 Scala 、 Kotlin ,贡献者支持 Python 、 TypeScript 。

    @@ -742,7 +742,7 @@ server.listen(8080);

    项目实战 :构建一个资源管理器

    经过以上的学习,我们对 Vert.x 的主要功能有一些了解了,下面我们来实战一个项目。

    需求:调用 MXBean 来获取 cpu 和内存,然后通过 eventbus 发送。另外一个接收 eventbus ,发送到页面进行展现。 最终效果如下图:

    -

    dashboard 最终完成的项目地址为:https://github.com/roytrack/vertx-gitchat

    +

    png 最终完成的项目地址为:https://github.com/roytrack/vertx-gitchat

    构建 Agent

    com.roytrack.dashboard.Agent 核心代码如下:

      @Override
    diff --git a/文章/从SpringCloud开始,聊微服务架构.md.html b/文章/从SpringCloud开始,聊微服务架构.md.html
    index 52f4ab65..4bab3281 100644
    --- a/文章/从SpringCloud开始,聊微服务架构.md.html
    +++ b/文章/从SpringCloud开始,聊微服务架构.md.html
    @@ -480,11 +480,11 @@ function hide_canvas() {
                             

    从SpringCloud开始,聊微服务架构

    背景

    随着公司业务量的飞速发展,平台面临的挑战已经远远大于业务,需求量不断增加,技术人员数量增加,面临的复杂度也大大增加。在这个背景下,平台的技术架构也完成了从传统的单体应用到微服务化的演进。

    -

    enter image description here

    +

    png

    系统架构的演进过程

    单一应用架构(第一代架构)

    这是平台最开始的情况,当时流量小,为了节约成本,并将所有应用都打包放到一个应用里面,采用的架构为.net+sqlserver:

    -

    enter image description here

    +

    png

    表示层 位于最外层(最上层),最接近用户。用于显示数据和接收用户输入的数 据,为用户提供一种交互式操作的界面,平台所使用的是基于.net的web形式。

    业务逻辑层 业务逻辑层(Business Logic Layer)无疑是系统架构中体现核心价值的部分。它的关注点主要集中在业务规则的制定、业务流程的实现等与业务需求有关的系统设计,也即是说它是与系统所应对的领域(Domain)逻辑有关,很多时候,也将业务逻辑层称为领域层。 业务逻辑层在体系架构中的位置很关键,它处于数据访问层与表示层中间,起到了数据交换中承上启下的作用。由于层是一种弱耦合结构,层与层之间的依赖是向下的,底层对于上层而言是“无知”的,改变上层的设计对于其调用的底层而言没有任何影响。如果在分层设计时,遵循了面向接口设计的思想,那么这种向下的依赖也应该是一种弱依赖关系。对于数据访问层而言,它是调用者;对于表示层而言,它却是被调用者。

    @@ -499,7 +499,7 @@ function hide_canvas() {

    垂直应用架构(第二代架构)

    为了解决第一代架构面临的问题,团队制定了如下的策略,并形成了第二代应用架构(垂直应用架构)

    -

    enter image description here

    +

    png

    • 应用拆成独立的应用模块。
    • 各个应用模块独立部署,并在负载均衡通过session保持解决应用模块的水平扩展问题。
    • @@ -539,7 +539,7 @@ function hide_canvas() {
    • 去.net化,开发语言使用java来实现。

    并以此为基础进行了平台的第三代架构的重构工作。

    -

    enter image description here

    +

    png

    看第三代架构里面的组成,主要分为八个部分:

    • @@ -585,15 +585,15 @@ function hide_canvas() {
      • 按照业务边界进行了划分,在一个团队内全栈,让团队自治,按照这样的方式组建,将沟通的成本维持在系统内部,每个子系统就会更加内聚,彼此的依赖耦合能变弱,跨系统的沟通成本也就能降低
      -

      enter image description here

      +

      png

      • 专门建立了一个架构师部门来负责第三代架构的推行工作。通常对于一个的架构师团队有系统架构、应用架构、运维、DBA、敏捷专家五个角色组成是一个比较合理的结构。那么又如何控制好架构组的产出,保证架构工作的顺利推行呢?
      • 首先:打造持续改进的自组织文化是实施微服务的关键基石。只有持续改进,持续学习和反馈,持续打造这样一个文化氛围和团队,微服务架构才能持续发展下去,保持新鲜的生命力,从而实现我们的初衷。
      • 其次:架构组的产品要经过严格的流程,因为架构组推行的是通用的解决方案,为了保证方案的质量,我们从方案调研到评审再到实施都有一个严格的闭环。
      -

      enter image description here

      +

      png

      再谈谈整个团队的交付流程与开发模式,如果没有预先定义好,则很难让微服务架构发挥出真正的价值,下面我们先来看看微服务架构的交付流程。

      -

      enter image description here

      +

      png

      使用微服务架构开发应用程序,我们实际上是针对一个个微服务进行设计、开发、测试、部署,因为每个服务之间是没有彼此依赖的,大概的交付流程就像上图这样。

      设计阶段:

      架构组将产品功能拆分为若干微服务,为每个微服务设计 API 接口(例如 REST API),需要给出 API 文档,包括 API 的名称、版本、请求参数、响应结果、错误代码等信息。

      @@ -605,12 +605,12 @@ function hide_canvas() {
      • 实践"绞杀者模式":
      -

      enter image description here

      +

      png

      由于第三代架构跨度较大,并且面临了无法修改的.net遗留系统,我们采用绞杀者模式,在遗留系统外面增加新的Proxy代理微服务,并且在LB控制upstream的方式,而不是直接修改原有系统,逐步的实现对老系统的替换。

      • 开发规范
      -

      enter image description here

      +

      png

      经验表明,我们需要善用代码版本控制系统,我曾经遇到一个开发团队,由于分支没有规范,最后一个小版本上线合代码居然化了几个小时,最后开发人员自己都不知道合到哪个分支。拿 Gitlab 来说,它很好地支持了多分支代码版本,我们需要利用这个特性来提高开发效率,上图就是我们目前的分支管理规范。

      最稳定的代码放在 master 分支上,我们不要直接在 master 分支上提交代码,只能在该分支上进行代码合并操作,例如将其它分支的代码合并到 master 分支上。

      我们日常开发中的代码需要从 master 分支拉一条 develop 分支出来,该分支所有人都能访问,但一般情况下,我们也不会直接在该分支上提交代码,代码同样是从其它分支合并到 develop 分支上去。

      @@ -635,7 +635,7 @@ function hide_canvas() {

    如何搭建微服务架构

    为了搭建好微服务架构,技术选型是一个非常重要的阶段,只有选择合适的"演员",才能把这台戏演好。

    -

    enter image description here

    +

    png

    我们使用 Spring Cloud 作为微服务开发框架,Spring Boot 拥有嵌入式 Tomcat,可直接运行一个 jar 包来发布微服务,此外它还提供了一系列“开箱即用”的插件,例如:配置中心,服务注册与发现,熔断器,路由,代理,控制总线,一次性令牌,全局锁,leader选举,分布式 会话,集群状态等,可大量提高我们的开发效率。

    @@ -688,7 +688,7 @@ function hide_canvas() {

    工程结构规范

    -

    enter image description here

    +

    png

    上图是我们实践中每个服务应该具有的项目组成结构。

    其中:

      @@ -702,7 +702,7 @@ function hide_canvas() {

    API 网关实践

    -

    enter image description here

    +

    png

    API网关作为后端所有微服务和API的访问入口, 对微服务和API进行审计,流控, 监控,计费等。常用的API网关解决方案有:

    • @@ -720,7 +720,7 @@ function hide_canvas() {

      良好的HTTP API支持, 可以动态管理upstreams, 这也意味着我们可以通过发布平台或者胶水系统无缝的实现服务注册和发现, 对服务的访问方透明。

    -

    enter image description here

    +

    png

    在以上的方案里:

    consul作为状态存储或者说配置中心(主要使用consul的KV存储功能);nginx作为API网关, 根据consul中upstreams的相关配置,动态分发流量到配置的upstreams结点;

    nginx根据配置项, 连接到consul集群;

    @@ -768,12 +768,12 @@ function hide_canvas() {

    针对于微服务,我们在spring cloud基础上,对微服务架构进行了扩展,基于Google Dapper的概念,设计了一套基于微服务架构的分布式跟踪系统(WeAPM)。

    -

    enter image description here

    +

    png

    如上图所示,我们可以通过服务名、时间、日志类型、方法名、异常级别、接口耗时等参数查询响应的日志。在得到的TrackID可以查询到该请求的整个链路日志,为重现问题、分析日志提供了极大方便。

    -

    enter image description here

    +

    png

    断路器实践

    在微服务架构中,我们将系统拆分成了一个个的微服务,这样就有可能因为网络原因或是依赖服务自身问题出现调用故障或延迟,而这些问题会直接导致调用方的对外服务也出现延迟,若此时调用方的请求不断增加,最后就会出现因等待出现故障的依赖方响应而形成任务积压,最终导致自身服务的瘫痪。为了解决这样的问题,因此产生了断路器模式

    -

    enter image description here

    +

    png

    我们在实践中使用了Hystrix 来实现断路器的功能。Hystrix是Netflix开源的微服务框架套件之一,该框架目标在于通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。Hystrix具备拥有回退机制和断路器功能的线程和信号隔离,请求缓存和请求打包,以及监控和配置等功能。

    断路器的使用流程如下:

    启用断路器

    @@ -798,7 +798,7 @@ function hide_canvas() { }

    配置文件

    -

    enter image description here

    +

    png

    资源控制实践

    聊到资源控制,估计很多小伙伴会联系到docker,docker确实是一个实现资源控制很不错的解决方案,我们前期做调研时也对是否使用docker进行了评审,但是最终选择放弃,而使用linux 的libcgroup脚本控制,原因如下:

      @@ -817,7 +817,7 @@ function hide_canvas() {

      配置文件模板(以memory为例):

          cat /etc/cgconfig.conf
       
      -

      enter image description here

      +

      png

      看到memory子系统是挂载在目录/sys/fs/cgroup/memory下,进入这个目录创建一个文件夹,就创建了一个control group了。

          mkdir test
           echo "服务进程号">>  tasks(tasks是test目录下的一个文件)
      diff --git a/文章/深入浅出Cache.md.html b/文章/深入浅出Cache.md.html
      index 39c46187..3e775608 100644
      --- a/文章/深入浅出Cache.md.html
      +++ b/文章/深入浅出Cache.md.html
      @@ -480,7 +480,7 @@ function hide_canvas() {
                               

      深入浅出Cache

      ① 什么是Cache? Cache的目标?

        -
      • 在说这个之前我们先看下典型Web 2.0的一些架构演变(这里不用”演进”). 从简单的到复杂的通用架构.Arch1Arch2Arch3Arch4
      • +
      • 在说这个之前我们先看下典型Web 2.0的一些架构演变(这里不用”演进”). 从简单的到复杂的通用架构.pngpngpngpng
      • 首先, 诚然说Cache在互联网公司里,是一个好东西. Cache化,可以显著地提高应用程序的性能和便于提供应用程序的伸缩性(可以消除不必要请求落到外在的不频繁改变数据的DataSource上). 那么Cache化目的非常明显, 就是有且只有一个: 提高应用程序的性能.
      • 再者, Cache化, 以in-memory为组织形式, 作为外部的持久化系统的数据的副本(可能数据结构不同), 仅仅为了提高性能. 那么Cache化的数据应当是短暂停留在Distributed Cache中 — 它们可能(可以)随时的消失(即使断电不保证立马就有数据-这一点类似CPU的L1/L2 Cache), 那么应用在用到Cache时候仅当Cache系统可用时候使用不应当完全依赖于Cache数据 — 就是说在Distributed Cache中个别的Cache实例失效,那么DataSource(持久化)可以临时性完成数据被访问的工作.
      • 最后, 我们可以假定如果各种DataSource自有的系统性能非常高, 那么Cache所能解决的领域就变得非常的少.
      • diff --git a/文章/深入理解 MySQL 底层实现.md.html b/文章/深入理解 MySQL 底层实现.md.html index 590b757d..a6e1b39d 100644 --- a/文章/深入理解 MySQL 底层实现.md.html +++ b/文章/深入理解 MySQL 底层实现.md.html @@ -577,7 +577,7 @@ function hide_canvas() {
      • 如果某个指针在节点node的左右相邻key分别是keyi和keyi+1且不为null,则其指向节点的所有key小于v(keyi+1)且大于v(keyi)。
      • B-Tree中的每个节点根据实际情况可以包含大量的关键字信息和分支,例:

        -

        enter image description here

        +

        png

        每个节点占用一个盘块的磁盘空间,一个节点上有两个升序排序的关键字和三个指向子树根节点的指针,指针存储的是子节点所在磁盘块的地址。两个关键词划分成的三个范围域对应三个指针指向的子树的数据的范围域。以根节点为例,关键字为17和35,P1指针指向的子树的数据范围为小于17,P2指针指向的子树的数据范围为17~35,P3指针指向的子树的数据范围为大于35。

        模拟查找关键字29的过程:

          @@ -598,17 +598,17 @@ function hide_canvas() {
        1. 数据节点之间是有指针指向的

        由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:

        -

        enter image description here

        +

        png

        通常在B+Tree上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。

        8. Myisam中的B+Tree

        Myisam引擎也是采用的B+Tree结构来作为索引结构。

        由于Myisam中的索引和数据分别存放在不同的文件,所以在索引树中的叶子节点中存的数据是该索引对应的数据记录的地址,由于数据与索引不在一起,所以Myisam是非聚簇索引。

        -

        enter image description here

        +

        png

        9. InnoDB中的B+Tree

        InnoDB是以ID为索引的数据存储。

        采用InnoDB引擎的数据存储文件有两个,一个定义文件,一个是数据文件。

        InnoDB通过B+Tree结构对ID建索引,然后在叶子节点中存储记录。

        -

        enter image description here

        +

        png

        若建索引的字段不是主键ID,则对该字段建索引,然后在叶子节点中存储的是该记录的主键,然后通过主键索引找到对应的记录。

        MySQL的相关优化

        1. MySQL性能优化:组成、表的设计

        diff --git a/文章/网易严选怎么做全链路监控的?.md.html b/文章/网易严选怎么做全链路监控的?.md.html index 7d390c3e..49b90531 100644 --- a/文章/网易严选怎么做全链路监控的?.md.html +++ b/文章/网易严选怎么做全链路监控的?.md.html @@ -524,7 +524,7 @@ function hide_canvas() {

        2.2 性能数据

        说到性能数据,不得不提 W3C 给我们的 performance.timing 了,这玩意是个好东西。官方图拿来凑字数。

        -

        W3CPerformance.Timing

        +

        png

        这张图并不是我们实际的每一次请求的耗时,而是一个页面在加载完成前的不同阶段,而且并不是所有的阶段都会发生。通过我们项目总结,将得到的这些数据分成两大类:

        第一类,就是网页的“基础耗时类指标”,简单说,这类指标没啥用,大概看看有这么个东西就行了,这玩意主要用来做可视化的瀑布图,看起来比较酷炫而已。

        @@ -642,7 +642,7 @@ function hide_canvas() {

        在资源响应头中添加 Timing-Allow-Origin 配置,例如:Timing-Allow-Origin:*

        2.3 网络请求数据

        网络请求指的是用户从当前系统中发出去的 Fetch/XHR 请求,如何无侵入的获取到这些请求的数据呢?那就需要 HACK 一下这两个方法了。

        -

        Hack

        +

        png

        Hack 的步骤大概有以下几步:

        1. 把 Windows 中的原生方法另存;
        2. diff --git a/文章/解读《阿里巴巴 Java 开发手册》背后的思考.md.html b/文章/解读《阿里巴巴 Java 开发手册》背后的思考.md.html index aed8306a..df11b2ac 100644 --- a/文章/解读《阿里巴巴 Java 开发手册》背后的思考.md.html +++ b/文章/解读《阿里巴巴 Java 开发手册》背后的思考.md.html @@ -485,7 +485,7 @@ function hide_canvas() {

          豆瓣介绍:https://book.douban.com/subject/30333948/ 京东地址:https://item.jd.com/31288905323.html 当当地址:http://product.dangdang.com/25346848.html

          下面开始展开正文。

          为什么禁止工程师直接使用日志系统(Log4j、Logback)中的 API

          -

          enter image description here

          +

          png

          作为 Java 程序员,我想很多人都知道日志对于一个程序的重要性,尤其是 Web 应用。很多时候,日志可能是我们了解应用程序如何执行的唯一方式。

          所以,日志在 Java Web 应用中至关重要,但是,很多人却以为日志输出只是一件简单的事情,所以会经常忽略和日志相关的问题。在接下来的几篇文章中,我会来介绍介绍这个容易被大家忽视,但同时也容易导致故障的知识点。

          Java 语言之所以强大,就是因为他很成熟的生态体系。包括日志这一功能,就有很多成熟的开源框架可以被直接使用。

          @@ -506,7 +506,7 @@ function hide_canvas() {

          关于 Log4j2 解决了 Log4j 的哪些问题,Log4j2 相比较于 Log4j、j.u.l 和 logback 有哪些优势,我们在后续的文章中介绍。

          前面介绍了四种日志框架,也就是说,我们想要在应用中打印日志的时候,可以使用以上四种类库中的任意一种。比如想要使用 Log4j,那么只要依赖 Log4j 的 jar 包,配置好配置文件并且在代码中使用其 API 打印日志就可以了。

          不知道有多少人看过《阿里巴巴 Java 开发手册》,其中有一条规范做了『强制』要求:

          -

          -w922

          +

          png

          说好了以上四种常用的日志框架是给 Java 应用提供的方便进行记录日志的,那为什么又不让在应用中直接使用其 API 呢?这里面推崇使用的 SLF4J 是什么呢?所谓的门面模式又是什么东西呢?

          什么是日志门面

          日志门面,是门面模式的一个典型的应用。

          @@ -562,7 +562,7 @@ function hide_canvas() {

          这样做的最大好处,就是业务层的开发不需要关心底层日志框架的实现及细节,在编码的时候也不需要考虑日后更换框架所带来的成本。这也是门面模式所带来的好处。

          综上,请不要在你的 Java 代码中出现任何 Log4j 等日志框架的 API 的使用,而是应该直接使用 SLF4J 这种日志门面。

          为什么禁止开发人员使用 isSuccess 作为变量名

          -

          -w656

          +

          png

          在日常开发中,我们会经常要在类中定义布尔类型的变量,比如在给外部系统提供一个 RPC 接口的时候,我们一般会定义一个字段表示本次请求是否成功的。

          关于这个"本次请求是否成功"的字段的定义,其实是有很多种讲究和坑的,稍有不慎就会掉入坑里,作者在很久之前就遇到过类似的问题,本文就来围绕这个简单分析一下。到底该如何定一个布尔类型的成员变量。

          一般情况下,我们可以有以下四种方式来定义一个布尔类型的成员变量:

          @@ -578,7 +578,7 @@ Boolean isSuccess

          success 还是 isSuccess

          到底应该是用 success 还是 isSuccess 来给变量命名呢?从语义上面来讲,两种命名方式都可以讲的通,并且也都没有歧义。那么还有什么原则可以参考来让我们做选择呢。

          在阿里巴巴 Java 开发手册中关于这一点,有过一个『强制性』规定:

          -

          -w656

          +

          png

          那么,为什么会有这样的规定呢?我们看一下 POJO 中布尔类型变量不同的命名有什么区别吧。

              class Model1  {
                   private Boolean isSuccess;
          @@ -633,7 +633,7 @@ public void set<PropertyName>(<PropertyType> a);
           
          public boolean is<PropertyName>();
           public void set<PropertyName>(boolean m);
           
          -

          -w687

          +

          png

          通过对照这份 JavaBeans 规范,我们发现,在 Model4 中,变量名为 isSuccess,如果严格按照规范定义的话,他的 getter 方法应该叫 isIsSuccess。但是很多 IDE 都会默认生成为 isSuccess。

          那这样做会带来什么问题呢。

          在一般情况下,其实是没有影响的。但是有一种特殊情况就会有问题,那就是发生序列化的时候。

          @@ -717,7 +717,7 @@ Serializable Result With jackson :{"success":true}

          但是,一旦以上代码发生在生产环境,这绝对是一个致命的问题。

          所以,作为开发者,我们应该想办法尽量避免这种问题的发生,对于 POJO 的设计者来说,只需要做简单的一件事就可以解决这个问题了,那就是把 isSuccess 改为 success。这样,该类里面的成员变量时 success, getter 方法是 isSuccess,这是完全符合 JavaBeans 规范的。无论哪种序列化框架,执行结果都一样。就从源头避免了这个问题。

          引用以下 R 大关于阿里巴巴 Java 开发手册这条规定的评价(https://www.zhihu.com/question/55642203):

          -

          -w665

          +

          png

          所以,在定义 POJO 中的布尔类型的变量时,不要使用 isSuccess 这种形式,而要直接使用 success!

          Boolean 还是 boolean?

          前面我们介绍完了在 success 和 isSuccess 之间如何选择,那么排除错误答案后,备选项还剩下:

          @@ -787,13 +787,13 @@ Boolean success

          小结

          本文围绕布尔类型的变量定义的类型和命名展开了介绍,最终我们可以得出结论,在定义一个布尔类型的变量,尤其是一个给外部提供的接口返回值时,要使用 success 来命名,阿里巴巴 Java 开发手册建议使用封装类来定义 POJO 和 RPC 返回值中的变量。但是这不意味着可以随意的使用 null,我们还是要尽量避免出现对 null 的处理的。

          为什么禁止开发人员修改 serialVersionUID 字段的值

          -

          -w934

          +

          png

          序列化是一种对象持久化的手段。普遍应用在网络传输、RMI 等场景中。类通过实现 java.io.Serializable 接口以启用其序列化功能。

          在我的博客中,其实已经有多篇文章介绍过序列化了,对序列化的基础知识不够了解的朋友可以参考以下几篇文章:

          Java 对象的序列化与反序列化 深入分析 Java 的序列化与反序列化 单例与序列化的那些事儿

          在这几篇文章中,我分别介绍过了序列化涉及到的类和接口、如何自定义序列化策略、transient 关键字和序列化的关系等,还通过学习 ArrayList 对序列化的实现源码深入学习了序列化。并且还拓展分析了一下序列化对单例的影响等。

          但是,还有一个知识点并未展开介绍,那就是关于serialVersionUID 。这个字段到底有什么用?如果不设置会怎么样?为什么《阿里巴巴 Java 开发手册》中有以下规定:

          -

          -w934

          +

          png

          背景知识

          Serializable 和 Externalizable

          类通过实现 java.io.Serializable 接口以启用其序列化功能。**未实现此接口的类将无法进行序列化或反序列化。**可序列化类的所有子类型本身都是可序列化的。

          @@ -1210,7 +1210,7 @@ System.out.println((new StringBuilder()).append("+ cost:").append(t2 -
        3. 如果在并发场景中进行字符串拼接的话,要使用StringBuffer来代替StringBuilder

        为什么禁止在 foreach 循环里进行元素的 remove/add 操作

        -

        -w1191

        +

        png

        foreach 循环

        Foreach 循环(Foreach loop)是计算机编程语言中的一种控制流程语句,通常用来循环遍历数组或集合中的元素。

        Java 语言从 JDK 1.5.0 开始引入 foreach 循环。在遍历数组、集合方面, foreach 为开发人员提供了极大的方便。

        @@ -1326,7 +1326,7 @@ H }

        然后运行以上代码,同样会抛出异常。我们来看一下 ConcurrentModificationException 的完整堆栈:

        -

        -w738

        +

        png

        通过异常堆栈我们可以到,异常发生的调用链 ForEachDemo 的第 23 行,Iterator.next 调用了 Iterator.checkForComodification方法 ,而异常就是 checkForComodification 方法中抛出的。

        其实,经过 debug 后,我们可以发现,如果 remove 代码没有被执行过,iterator.next 这一行是一直没报错的。抛异常的时机也正是 remove 执行之后的的那一次 next 方法的调用。

        我们直接看下 checkForComodification 方法的代码,看下抛出异常的原因:

        @@ -1360,7 +1360,7 @@ H

        其实,看到这里,大概很多人都能猜到为什么 remove/add 操作之后,会导致 expectedModCount 和 modCount 不想等了。

        通过翻阅代码,我们也可以发现,remove 方法核心逻辑如下:

        -

        -w764

        +

        png

        可以看到,它只修改了 modCount,并没有对 expectedModCount 做任何操作。

        简单总结一下,之所以会抛出 ConcurrentModificationException 异常,是因为我们的代码中使用了增强 for 循环,而在增强 for 循环中,集合遍历是通过 iterator 进行的,但是元素的 add/remove 却是直接使用的集合类自己的方法。这就导致 iterator 在遍历的时候,会发现有一个元素在自己不知不觉的情况下就被删除/添加了,就会抛出一个异常,用来提示用户,可能发生了并发修改!

        正确姿势

        @@ -1399,7 +1399,7 @@ H System.out.println(userNames);

        如果直接使用 Iterator 提供的 remove 方法,那么就可以修改到 expectedModCount 的值。那么就不会再抛出异常了。其实现代码如下:

        -

        -w375

        +

        png

        3、使用 Java 8 中提供的 filter 过滤

        Java 8 中可以把集合转换成流,对于流有一种 filter 操作, 可以对原始 Stream 进行某项测试,通过测试的元素被留下来生成一个新 Stream。

                List<String> userNames = new ArrayList<String>() {{
        @@ -1516,7 +1516,7 @@ H
         

        上面的代码挺有意思的,一个简单的容量初始化,Java 的工程师也有很多考虑在里面。

        上面的算法目的挺简单,就是:根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的 2 的幂并返回。

        聪明的读者们,如果让你设计这个算法你准备如何计算?如果你想到二进制的话,那就很简单了。举几个例子看一下:

        -

        QQ20180527-173743

        +

        png

        请关注上面的几个例子中,蓝色字体部分的变化情况,或许你会发现些规律。5->8、9->16、19->32、37->64 都是主要经过了两个阶段。

        Step 1,5->7

        @@ -1572,7 +1572,7 @@ n |= n >>> 16;

        HashMap 中初始容量的合理值

        当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk 会默认帮我们计算一个相对合理的值当做初始容量。那么,是不是我们只需要把已知的 HashMap 中即将存放的元素个数直接传给 initialCapacity 就可以了呢?

        关于这个值的设置,在《阿里巴巴 Java 开发手册》有以下建议:

        -

        Demo

        +

        png

        这个值,并不是阿里巴巴的工程师原创的,在 guava(21.0 版本)中也使用的是这个值。

            public static <K, V> HashMap<K, V> newHashMapWithExpectedSize(int expectedSize) {
                 return new HashMap<K, V>(capacity(expectedSize));
        diff --git a/文章/进阶:Dockerfile 高阶使用指南及镜像优化.md.html b/文章/进阶:Dockerfile 高阶使用指南及镜像优化.md.html
        index 8fce6baa..c4b1ade2 100644
        --- a/文章/进阶:Dockerfile 高阶使用指南及镜像优化.md.html	
        +++ b/文章/进阶:Dockerfile 高阶使用指南及镜像优化.md.html	
        @@ -491,7 +491,7 @@ function hide_canvas() {
         

        builder

        这里我们需要引入一个概念 builder .

        builder 就是上面提到的特定模块,也就是说构建内容 context 是由 Docker CLI 发送给 dockerd;并最终由 builder 完成构建。

        -

        enter image description here

        +

        png

        docker 的顶级命令中,我们可以看到有一个 builder 的命令组。它有一个子命令 prune 用于清理所有构建过程中的缓存。

        以下是 Docker 18.09 的输出信息。

        / # docker builder
        @@ -876,7 +876,7 @@ ff4a9e18658e        moby/buildkit:master   "buildkitd"         About a
         

        发现

        首先推荐一个工具 dive ; 通过上次的 Chat 我们已经知道了镜像的组成和结构,dive 是一个命令行工具,使用它可以浏览 Docker 镜像每层的内容,以此来发现我们镜像中是否有什么不需要的东西存在。

        关于 dive 这里不做过多介绍了,该项目的文档中介绍还是比较详细的,我们可以用它来分析下刚才我们构建成功的镜像:

        -

        enter image description here

        +

        png

        第二种方法,则是比较一般的,通过之前介绍的 docker image history 来查看构建记录和每层的大小,以此来观察是否有非必要的操作之类的。

        优化

        我们对前面所举例中的 Spring Boot 项目的 Dockerfile 做点小改动: