凤凰架构:构建可靠的大型分布式系统-2:分布式

购买链接
在线阅读 https://icyfenix.cn/
实在太多了,只能开辟新篇。

分布式共识算法

前置知识
关于分布式中 CAP 问题,请先阅读“分布式事务”中的介绍,后文中提及的一致性、可用性、网络分区等概念,均有过介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 工业界 -> 学术界
正式开始探讨分布式环境中面临的各种技术问题和解决方案以前,我们先把目光从工业界转到学术界,学习两三种具有代表性的分布式共识算法,为后续分布式环境中操作共享数据准备好理论基础。

# 问题1:数据不丢 -> 数据备份
下面例如从一个最浅显的场景开始,引出本章的主题:
如果你有一份很重要的数据,要确保它长期存储在电脑上不会丢失,你会怎么做?
这不是什么脑筋急转弯的古怪问题,答案就是去买几块硬盘,把数据在不同硬盘上多备份几个副本。
假设一块硬盘每年损坏的概率是 5%,那把文件复制到另一块备份盘上,由于两块硬盘同时损坏而丢失数据的概率就只有 0.25%,
如果使用三块硬盘存储则是 0.0125%,四块是 0.000625%,换而言之,这已经保证了数据在一年内有超过 99.999%的概率是安全可靠的。

# 可靠性 => 数据备份,一致性
在软件系统里,要保障系统的可靠性,采用的办法与那几个备份硬盘并没有什么区别。
单个节点的系统宕机无法访问数据的原因可能有很多,例如程序出错、硬件损坏、网络分区、电源故障,等等,
一年中出现系统宕机的概率也许还要高于 5%,这决定了软件系统也必须有多台机器能够拥有一致的数据副本,才有可能对外提供可靠的服务。

# 可用性 => 多节点动态同步
在软件系统里,要保障系统的可用性,面临的困难与硬盘备份却又有着本质的区别。
硬盘之间是孤立的,不需要互相通信,备份数据是静态的,初始化后状态就不会发生改变,由人工进行的文件复制操作,很容易就保障了数据在各个备份盘中是一致的;
然而分布式系统里面,我们必须考虑动态的数据如何在不可靠的网络通信条件下,依然能在各个节点之间正确复制的问题。

# 问题2:变动数据,多节点同步
将我们要讨论的场景作如下修改:
如果你有一份会随时变动的数据,要确保它正确地存储于网络中的几台不同机器之上,你会怎么做?

# 数据同步:多节点一致性 数据改变 => 节点同步(一个节点拖慢同步速度) => 节点↑,风险↑ (可靠性、可用性矛盾)
相信最容易想到的答案一定是“数据同步”:每当数据有变化,把变化情况在各个节点间的复制视作一种事务性的操作,只有系统里每一台机器都反馈成功地完成硬盘写入后,数据的变化才宣告成功,例如曾经在“全局事务”中介绍过,使用 2PC/3PC 就可以实现这种同步操作。
同步的其中一种真实应用场景是数据库的主从全同步复制(Fully Synchronous Replication),例如 MySQL Cluster,进行全同步复制时,会等待所有 Slave 节点的 Binlog 都完成写入后,Master 节点的事务才进行提交(这个场景中 Binlog 本身就是要同步的状态数据,不应将它看作是指令日志的集合)。
然而这里有一个显而易见的缺陷,尽管可以确保 Master 节点和 Slave 节点中的数据是绝对一致的,但任何一个 Slave 节点因为任何原因未响应均会阻塞整个事务,每增加一个 Slave 节点,都导致造成整个系统可用性风险增加一分。

# 操作转移 => 解决可靠性、可用性矛盾
以同步为代表的数据复制方法,被称为状态转移(State Transfer),这类方法是较符合人类思维的可靠性保障手段,但通常要以牺牲可用性为代价。
我们在建设分布式系统的时候,往往不能承受这样的代价,一些关键系统,必须保障数据正确可靠的前提下,对可用性的要求也非常苛刻,
例如系统要保证数据要达到 99.999999%可靠,同时系统也要达到 99.999%可用的程度,这就引出了我们的第三个问题:
如果你有一份会随时变动的数据,要确保它正确地存储于网络中的几台不同机器之上,并且要尽可能保证数据是随时可用的,你会怎么做?

# 1、直接赋予目标状态 2、通过操作将原本的状态改变为目标状态=状态机
可靠性与可用性的矛盾造成了增加机器数量反而带来可用性的降低,为缓解这个矛盾,在分布式系统里主流的数据复制方法是以操作转移(Operation Transfer)为基础的。
我们想要改变数据的状态,除了直接将目标状态赋予它之外,还有另一种常用的方法是通过某种操作,令源状态转换为目标状态。
能够使用确定的操作,促使状态间产生确定的转移结果的计算模型,在计算机科学中被称为状态机(State Machine)。
额外知识:状态机复制

状态机特性:任何初始状态一样的状态机,如果执行的命令序列一样,则最终达到的状态也一样。
如果将此特性应用在多参与者进行协商共识上,可以理解为系统中存在多个具有完全相同的状态机(参与者),这些状态机能最终保持一致的关键就是起始状态完全一致和执行命令序列完全一致。

# 状态机复制
# 初始一致,序列一致 => 最终一致
# 一条命令:需要同时开始,同时完成(对外不可见=短暂一致)
根据状态机的特性,要让多台机器的最终状态一致,只要确保它们的初始状态是一致的,并且接收到的操作指令序列也是一致的即可,无论这个操作指令是新增、修改、删除抑或是其他任何可能的程序行为,都可以理解为要将一连串的操作日志正确地广播给各个分布式节点。
广播指令与指令执行期间,允许系统内部状态存在不一致的情况,即并不要求所有节点的每一条指令都是同时开始、同步完成的,只要求在此期间的内部状态不能被外部观察到,且当操作指令序列执行完毕时,所有节点的最终的状态是一致的,这种模型就被称为状态机复制(State Machine Replication)。

# Quorum 机制 => 分区少服多
考虑到分布式环境下网络分区现象是不可能消除的,甚至允许不再追求系统内所有节点在任何情况下的数据状态都一致,而是采用“少数服从多数”的原则,
一旦系统中过半数的节点中完成了状态的转换,就认为数据的变化已经被正确地存储在系统当中,这样就可以容忍少数(通常是不超过半数)的节点失联,
使得增加机器数量对系统整体的可用性变成是有益的,这种思想在分布式中被称为“Quorum 机制”。

# 协商共识 => 内部暂时不一致,对外一致,最终一致
根据上述讨论,我们需要设计出一种算法,能够让分布式系统内部暂时容忍存在不同的状态,但最终能够保证大多数节点的状态达成一致;同时,能够让分布式系统在外部看来始终表现出整体一致的结果。
这个让系统各节点不受局部的网络分区、机器崩溃、执行性能或者其他因素影响,都能最终表现出整体一致的过程,就被称为各个节点的协商共识(Consensus)。

# 一致性:数据无差异
# 共识:达成一致性的方法与过程
最后,还要提醒你共识(Consensus)与一致性(Consistency)的区别:一致性是指数据不同副本之间的差异,而共识是指达成一致性的方法与过程。
由于翻译的关系,很多中文资料把 Consensus 同样翻译为一致性,导致网络上大量的“二手中文资料”将这两个概念混淆起来,如果你在网上看到“分布式一致性算法”,应明白其指的其实是“Distributed Consensus Algorithm”。

Paxos

分布式共识算法
世界上只有一种共识协议,就是 Paxos,其他所有共识算法都是 Paxos 的退化版本。
—— Mike Burrows,Inventor of Google Chubby

1
2
3
4
# Paxos => 一种基于消息传递的协商共识算法
Paxos 是由Leslie Lamport(就是大名鼎鼎的LaTeX中的“La”)提出的一种基于消息传递的协商共识算法,现已是当今分布式系统最重要的理论基础,几乎就是“共识”二字的代名词。
这个极高的评价出自于提出 Raft 算法的论文,更是显得分量十足。虽然并没有 Mike Burrows 说的“世界上只有 Paxos 一种分布式共识算法”那么夸张,
但是如果没有 Paxos,那后续的 Raft、ZAB 等算法,ZooKeeper、Etcd 这些分布式协调框架、Hadoop、Consul 这些在此基础上的各类分布式应用都很可能会延后好几年面世(Paxos牛逼!!!)。

Paxos 的诞生

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 希腊城邦 => 民主制
# Paxos算法的目标 => 不一定参加,少服多,最终一致
为了解释清楚 Paxos 算法,Lamport 虚构了一个名为“Paxos”的希腊城邦,这个城邦按照民主制度制定法律,却又不存在一个中心化的专职立法机构,立法靠着“兼职议会”(Part-Time Parliament)来完成,无法保证所有城邦居民都能够及时地了解新的法律提案、也无法保证居民会及时为提案投票。
Paxos 算法的目标就是让城邦能够在每一位居民都不承诺一定会及时参与的情况下,依然可以按照少数服从多数的原则,最终达成一致意见。
但是 Paxos 算法并不考虑拜占庭将军问题(错误传递),即假设信息可能丢失也可能延迟,但不会被错误传递。

# 1990(32年过去了) => 初稿(撤)
Lamport 最初在 1990 年首次发表了 Paxos 算法,选的论文题目就是“The Part-Time Parliament”(兼职议会)。
由于算法本身极为复杂,用希腊城邦作为比喻反而使得描述更为晦涩,论文的三个审稿人一致要求他把希腊城邦的故事删除掉,这令 Lamport 感觉颇为不爽,然后干脆就撤稿不发了,所以 Paxos 刚刚被提出的时候并没有引起什么反响。

# 1998(8年后) => 难懂
八年之后(1998 年),Lamport 再次将此文章重新整理后投到《ACM Transactions on Computer Systems》,这次论文成功发表,Lamport 的名气确实吸引了一些人去研究,结果是并没有多少人能弄懂他在说什么。

# 2001(又三年) => Simple
时间又过去了三年(2001 年),Lamport 认为前两次是同行们无法理解他以“希腊城邦”来讲故事的幽默感。
第三次以“Paxos Made Simple”为题,在《SIGACT News》杂志上发表文章,终于放弃了“希腊城邦”的比喻,尽可能用(他认为)简单直接、(他认为)可读性较强的方式去介绍 Paxos 算法,情况虽然比前两次要好上一些,但以 Paxos 本应获得的重视程度来说,这次依然只能算是应者寥寥。
这一段听起来跟网络段子一般的经历被 Lamport 以自嘲的形式放到了他自己的个人网站上。
尽管我们作为后辈应该尊重 Lamport 老爷子,但当例如翻开“Paxos Made Simple”的论文,见到只有“The Paxos algorithm, when presented in plain English, is very simple.”(简单地说,Paxos算法非常简单。)这一句话的“摘要”时,心里实在是不得不怀疑 Lamport 这样写论文是不是在恶搞审稿人和读者,在嘲讽“你们这些愚蠢的人类!”。
《Paxos Made Simple》 => https://lamport.azurewebsites.net/pubs/paxos-simple.pdf

# 2006 => 谷歌使用
虽然 Lamport 本人连发三篇文章都没能让大多数同行理解 Paxos,但 2006 年,在 Google 的 Chubby、Megastore 以及 Spanner 等分布式系统都使用 Paxos 解决了分布式共识的问题,并将其整理成正式的论文发表之后,得益于 Google 的行业影响力,辅以 Chubby 作者 Mike Burrows 那略显夸张但足够吸引眼球的评价推波助澜,Paxos 算法一夜间成为计算机科学分布式这条分支中最炙手可热的网红概念,开始被学术界众人争相研究。

# 2013 => 图灵奖(功夫不负有心人,牛逼还是牛逼)
2013 年,Lamport 本人因其对分布式系统的杰出理论贡献获得了 2013 年的图灵奖,随后才有了 Paxos 在区块链、分布式系统、云计算等多个领域大放异彩的故事。
足可见技术圈里即使再有本事,也还是需要好好包装一下的道理。

算法流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
讲完段子吃过西瓜,希望你没有被这些对 Paxos 的“困难”做的铺垫所吓倒,反正又不让你马上去实现它。
假如放弃些许严谨性,并简化掉最繁琐的分支细节和特殊情况的话,Paxos 是完全可能去通俗地理解的,Lamport 在论文中也只用两段话就描述“清楚”了它的工作流程,下面,我们正式来学习 Paxos 算法(在本小节中 Paxos 均特指最早的 Basic Paxos 算法)。

# Paxos 三种节点
Paxos 算法将分布式系统中的节点分为三类:
+ 1. 提出要设置值(记日志)
提案节点:称为 Proposer,提出对某个值进行设置操作的节点,设置值这个行为就被称之为提案(Proposal),值一旦设置成功,就是不会丢失也不可变的。
请注意,Paxos 是典型的基于操作转移模型而非状态转移模型来设计的算法,这里的“设置值”不要类比成程序中变量赋值操作,应该类比成日志记录操作,在后面介绍的 Raft 算法中就直接把“提案”叫作“日志”了。
+ 2. 过半接受(记日志) => 拍板(批准:彻底持久化)
决策节点:称为 Acceptor,是应答提案的节点,决定该提案是否可被投票、是否可被接受。
提案一旦得到过半数决策节点的接受,即称该提案被批准(Accept),提案被批准即意味着该值不能再被更改,也不会丢失,且最终所有节点都会接受该它。
+ 3. 新节点:学习共识(少服多)
记录节点:被称为 Learner,不参与提案,也不参与决策,只是单纯地从提案、决策节点中学习已经达成共识的提案,例如少数派节点从网络分区中恢复时,将会进入这种状态。

# 节点平等;一或多角色;节点数:奇(符合过半机制)
使用 Paxos 算法的分布式系统里的,所有的节点都是平等的,它们都可以承担以上某一种或者多种的角色,
不过为了便于确保有明确的过半,决策节点的数量应该被设定为奇数个,且在系统初始化时,网络中每个节点都知道整个网络所有决策节点的数量、地址等信息。

# 设置值(提案)达成一致 => 两个问题
在分布式环境下,如果我们说各个节点“就某个值(提案)达成一致”,指的是“不存在某个时刻有一个值为 A,另一个时刻又为 B 的情景”。
解决这个问题的复杂度主要来源于以下两个方面因素的共同影响:
1. (通信不可靠问题:发出失败、接收失败)系统内部各个节点通信是不可靠的,不论对于系统中企图设置数据的提案节点抑或决定是否批准设置操作的决策节点,其发出、收到的信息可能延迟送达、也可能会丢失,但不去考虑消息有错误传递的情况。
=> 这一点是网络通信中客观存在的现象,也是所有共识算法都要重点解决的问题。
2. (并发修改问题:锁)系统外部各个用户访问是可并发的,如果系统只会有一个用户,或者每次只对系统进行串行访问,那单纯地应用 Quorum 机制,少数节点服从多数节点,就已经足以保证值被正确地读写。
=> 详细解释一下便于你理解:现在我们讨论的是“分布式环境下并发操作的共享数据”的问题,即使先不考虑是不是在分布式的环境下,只考虑并发操作,假设有一个变量 i 当前在系统中存储的数值为 2,同时有外部请求 A、B 分别对系统发送操作指令:“把 i 的值加 1”和“把 i 的值乘 3”,如果不加任何并发控制的话,将可能得到“(2+1)×3=9”与“2×3+1=7”两种可能的结果。
=> (并发修改要加锁)因此,对同一个变量的并发修改必须先加锁后操作,不能让 A、B 的请求被交替处理,这些可以说是程序设计的基本常识了。
=> (避免阻塞,避免死锁)而在分布式的环境下,由于还要同时考虑到分布式系统内可能在任何时刻出现的通信故障,如果一个节点在取得锁之后,在释放锁之前发生崩溃失联,这将导致整个操作被无限期的等待所阻塞,因此算法中的加锁就不完全等同于并发控制中以互斥量来实现的加锁,还必须提供一个其他节点能抢占锁的机制,以避免因通信问题而出现死锁。
=> 为了这个问题,分布式环境中的锁必须是可抢占的。

# (2022-08-17 14:49:22 我又重新梳理了一遍,如果看不懂,把下面“工作实例”看完,再回来读一下这里。)
# 阶段1:提案节点(Prepare请求) => 决策节点(Promise应答)
为了这个问题,分布式环境中的锁必须是可抢占的。Paxos 算法包括两个阶段,其中,第一阶段“准备”(Prepare)就相当于上面抢占锁的过程。
如果某个提案节点准备发起提案,必须先向所有的决策节点广播一个许可申请(称为 Prepare 请求)。
提案节点的 Prepare 请求中会附带一个全局自增且唯一的数字 n 作为提案ID,决策节点收到后,将会给予提案节点两个承诺与一个应答。
两个承诺是指:
+ 承诺1:只接受提案ID 大于 n 的 Prepare 请求(小于 n,忽略)。
+ 承诺2:只接受提案ID 大于 n 的 Accept 请求(小于 n,忽略)。
一个应答是指:
+ 不违背承诺1的前提下,回复(id, acceptedValue) ,其中id=最大提案ID,acceptedValue为第一个处理 Accept请求的值(未处理过Accept请求,此值为null)。
如果违反承诺1,即收到的提案ID 并不是决策节点收到过的最大的,那允许直接对此 Prepare 请求不予理会。

# 阶段2:提案节点(Accept请求) => 决策节点(Accepted应答)
# ===> 收到过半 Promise应答,才可以发送 Accept请求
当提案节点收到了过半决策节点的应答(称为 Promise 应答)后,可以开始第二阶段“批准”(Accept)过程,这时有如下两种可能的结果:
+ 如果提案节点发现所有响应的决策节点此前都没处理过Accept请求(即 acceptedValue值为null),那说明它是第一个设置值的节点,可以将自己选定的值与提案ID,构成一个二元组“(id=当前提案ID, value=当前提案要设置的值)”,再次广播给全部的决策节点(称为 Accept 请求)。
+ 如果提案节点发现响应的决策节点中,已经有至少一个节点的应答中包含有值了,那它就不能够使用自己的值了,必须无条件地从应答中找出最大提案ID的acceptedValue值并接受,构成一个二元组“(id=最大提案ID, value=acceptedValue)”,再次广播给全部的决策节点(称为 Accept 请求)。

# 收到 Accept请求(不违反承诺2) => 持久化
当每一个决策节点收到 Accept 请求时,都会在不违背承诺2的前提下,接收并持久化当前提案ID 和 提案附带的值。
如果违反承诺2,即收到的提案ID 并不是决策节点收到过的最大的,那允许直接对此 Accept 请求不予理会。

# ===> 收到过半 Accepted应答,才可以结束共识
当提案节点收到了过半决策节点的应答(称为 Accepted 应答)后,协商结束,共识决议形成,将形成的决议发送给所有记录节点进行学习。

整个过程的时序图如下图所示:

整个 Paxos 算法的工作流程至此结束,如果你此前并未专门学习过分布式的知识,相信阅读到这里,很有可能的感受是对操作过程中每一步都能看懂,
但还是不能对 Paxos 算法究竟是如何解决协商共识的形成具体的概念。下面就不局限于抽象的算法步骤,以一个更具体例子来讲解 Paxos。

在这里插入图片描述

工作实例

举个栗子

有人读到这里可能又懵了,别急,别急!上栗子,上栗子!!!

假设一个分布式系统有五个节点,分别命名为 S1、S2、S3、S4、S5,这个例子中只讨论正常通信的场景,不涉及网络分区。全部节点都同时扮演着提案节点和决策节点的身份。此时,有两个并发的请求分别希望将同一个值分别设定为 X(由 S1作为提案节点提出)和 Y(由 S5作为提案节点提出),以 P 代表准备阶段,以 A 代表批准阶段,这时候可能发生以下情况:

情况一 => 正常情况:过半Promise应答 和 过半Accepted应答
例如,S1选定的提案ID 是 3.1(全局唯一且自增ID+节点编号),先取得了过半决策节点的 Promise 和 Accepted 应答;此时 S5选定提案ID 是 4.5,发起 Prepare 请求,收到的过半应答中至少会包含 1 个此前应答过 S1的决策节点,假设是 S3,那么 S3提供的 Promise 中必将包含 S1已设定好的值 X,S5就必须无条件地用 X 代替 Y 作为自己提案的值,由此整个系统对“取值为 X”这个事实达成一致,如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 再次解释
按照场景假设,S5是提案节点,S3是决策节点,提案节点会发出Prepare和Accept请求,决策节点会响应Promise和Accepted应答。
=> 可见S3是不可能发出Accept消息的。

只关注S1、S3、S5的之间的交互,此场景的时间线顺序如下:
1. S1向S3提交Prepare(3.5)的请求
2. S3向S1返回Promise(3.5,null)的响应
3. S1收到Promise(3.5,null)后,提交Accept(3.5,X)的请求
4. S3收到Accept(3.5,X)后,向S5返回Accepted(3.5,X)的响应
5. S5向S3提交Prepare(4.5)的请求
6. S3收到后发现已有设置值X,向S5返回Promise(4.5,X)的响应
7. S5收到Promise(4.5,X)后,无条件接受X代替原本的Y作为自己的提案值,提交Accept(4.5,X)的请求
8. S3收到Accept(4.5,X)后,向S5返回Accepted(4.5,X)的响应

在这里插入图片描述


情况二 => 已处理过 Accept请求
事实上,对于情况一,X 被选定为最终值是必然结果,但从情况一的图中可以看出,X 被选定为最终值不是需要过半的共同批准,而是取决于 S5提案时 Promise 应答中是否已包含了批准过 X 的决策节点;如下图所示,S5发起提案的 Prepare 请求时,X 并未获得过半批准,但由于 S3已经批准的关系,最终共识的结果仍然是 X。
在这里插入图片描述


情况三 => 忽略 Accept请求(提案ID小于当前)
当然,另外一种可能的结果是 S5提案时 Promise 应答中并未包含批准过 X 的决策节点,例如应答 S5提案时,节点 S1已经批准了 X,节点 S2、S3未批准但返回了 Promise 应答,此时 S5以更大的提案ID 获得了 S3、S4、S5的 Promise,这三个节点均未批准过任何值,那么 S3将不会再接收来自 S1的 Accept 请求(因为它的提案ID 已经不是最大的了),这三个节点将批准 Y 的取值,整个系统最终会对“取值为 Y”达成一致,如下图所示。
在这里插入图片描述


情况四 => 活锁(Accepted应答一直未过半,不会结束共识)
从情况三可以推导出另一种极端的情况,如果两个提案节点交替使用更大的提案ID 使得准备阶段成功,但是批准阶段失败的话,这个过程理论上可以无限持续下去,形成活锁(Live Lock),如下图所示。=> 在算法实现中会引入随机超时时间来避免活锁的产生。
在这里插入图片描述


1
2
3
4
5
6
7
8
9
# Basic Paxos => 坏处:单个值决议,两次网络请求和应答(准备和批准阶段各一次),极端会活锁
虽然 Paxos 是以复杂著称的算法,但以上介绍都是基于 Basic Paxos、以正常流程(未出现网络分区等异常)、通俗方式讲解的 Paxos 算法,
并未涉及严谨的逻辑和数学原理,也未讨论 Paxos 的推导证明过程,对于普通的不从事算法研究的技术人员来说,理解起来应该也不算太困难。

Basic Paxos 的价值在于开拓了分布式共识算法的发展思路,但它因有如下缺陷,一般不会直接用于实践:Basic Paxos 只能对单个值形成决议,
并且决议的形成至少需要两次网络请求和应答(准备和批准阶段各一次),高并发情况下将产生较大的网络开销,极端情况下甚至可能形成活锁。

总之,Basic Paxos 是一种很学术化但对工业化并不友好的算法,现在几乎只用来做理论研究。
实际的应用都是基于 Multi Paxos 和 Fast Paxos 算法的,接下来我们将会了解 Multi Paxos 与一些它的理论等价的算法(如 Raft、ZAB 等算法)。

【日期标记】2022-08-17 15:15:34 以上同步完成

Multi Paxos

选主:Raft、ZAB

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Basic Paxos改进版 => Multi Paxos(仅主节点提案)
上一节的最后,举例介绍了 Basic Paxos 的活锁问题,两个提案节点互不相让地争相提出自己的提案,抢占同一个值的修改权限,导致整个系统在持续性地“反复横跳”,外部看起来就像被锁住了一样。
此外,还讲述过一个观点,分布式共识的复杂性,主要来源于网络的不可靠与请求的可并发两大因素,活锁问题与许多 Basic Paxos 异常场景中所遭遇的麻烦,都可以看作是源于任何一个提案节点都能够完全平等地、与其他节点并发地提出提案而带来的复杂问题。
为此,Lamport 提出了一种 Paxos 的改进版本“Multi Paxos”算法,希望能够找到一种两全其美的办法,
既不破坏 Paxos 中“众节点平等”的原则,又能在提案节点中实现主次之分,限制每个节点的提案权利,这两个目标听起来似乎是矛盾的,但现实世界中的选举就很符合这种在平等节点中挑选意见领袖的情景。

# 心跳选主(少服多)
Multi Paxos 对 Basic Paxos 的核心改进是增加了“选主”的过程,提案节点会通过定时轮询(心跳),确定当前网络中的所有节点里是否存在有一个主提案节点,
一旦没有发现主节点存在,节点就会在心跳超时后使用 Basic Paxos 中定义的准备、批准的两轮网络交互过程,向所有其他节点广播自己希望竞选主节点的请求,
希望整个分布式系统对“由我作为主节点”这件事情协商达成一致共识,如果得到了决策节点中多数派的批准,便宣告选主成功。


# 仅主提案(除非失联重选)
当选主完成之后,除非主节点失联之后发起重新竞选,否则从此往后,就只有主节点本身才能够提出提案。

# Client(修改操作) -> 从(转发给主,从不能提案) -> 主(无需发送Prepare请求,直接发送 Accept请求)
此时,无论哪个提案节点接收到客户端的操作请求,都会将请求转发给主节点来完成提案,而主节点提案的时候,也就无需再次经过准备过程,
因为可以视作是经过选举时的那一次准备之后,后续的提案都是对相同提案ID 的一连串的批准过程。

# 主顺序广播请求(多数应答 => 通过)
也可以通俗理解为选主过后,就不会再有其他节点与它竞争,相当于是处于无并发的环境当中进行的有序操作,所以此时系统中要对某个值达成一致,只需要进行一次批准的交互即可,如下图所示。

在这里插入图片描述

1
2
3
4
# 二元组 => 三元组(任期:单调递增,用于解决多主问题)
可能有人注意到这时候的二元组(id, value)已经变成了三元组(id, i, value),这是因为需要给主节点增加一个“任期编号”,这个编号必须是严格单调递增的,以应付主节点陷入网络分区后重新恢复,
但另外一部分节点仍然有多数派,且已经完成了重新选主的情况,此时必须以任期编号大的主节点为准。
当节点有了选主机制的支持,在整体来看,就可以进一步简化节点角色,不去区分提案、决策和记录节点了,统统以“节点”来代替,节点只有主(Leader)和从(Follower)的区别,此时协商共识的时序图如图所示:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 一致性 => 三个子问题
在这个理解的基础上,我们换一个角度来重新思考“分布式系统中如何对某个值达成一致”这个问题,可以把该问题划分做三个子问题来考虑,
可以证明当以下三个问题同时被解决时,即等价于达成共识:
=> 具体证明就不列在这里了,感兴趣的可参考结尾给出的论文 《一种可以让人理解的共识算法》 https://web.stanford.edu/~ouster/cgi-bin/papers/raft-atc14
+ 如何选主(Leader Election)。
+ 如何把数据复制到各个节点上(Entity Replication)。
+ 如何保证过程是安全的(Safety)。

# 选主 => 参考 Paxos算法
选主问题尽管还涉及许多工程上的细节,例如心跳、随机超时、并行竞选,等等,但要只论原理的话,如果你已经理解了 Paxos 算法的操作步骤,相信对选主并不会有什么疑惑,
因为这本质上仅仅是分布式系统对“谁来当主节点”这件事情的达成的共识而已,我们在前一节已经花了数千字来讲述分布式系统该如何对一件事情达成共识,这里就不重复赘述了,
下面直接来解决数据(Paxos 中的提案、Raft 中的日志)在网络各节点间的复制问题。

# 数据复制
# 1. 主写日志,不提交
# 2. 主广播所有从写日志(不提交)
# 3. 过半从写日志成功 => 主自己提交日志并广播所有从也提交日志
在正常情况下,当客户端向主节点发起一个操作请求,例如提出“将某个值设置为 X”,此时主节点将 X 写入自己的变更日志,但先不提交,
接着把变更 X 的信息在下一次心跳包中广播给所有的从节点,并要求从节点回复确认收到的消息,从节点收到信息后,将操作写入自己的变更日志,然后给主节点发送确认签收的消息,
主节点收到过半数的签收消息后,提交自己的变更、应答客户端并且给从节点广播可以提交的消息,从节点收到提交消息后提交自己的变更,数据在节点间的复制宣告完成。

# 网络分区 (1=主, 2=从)(3=主, 4=从, 5=从)
在异常情况下,网络出现了分区,部分节点失联,但只要正常工作节点的数量过半,分布式系统就仍可正常工作,这时候数据复制过程如下:
+ 假设有 S1、S2、S3、S4、S5五个节点,S1是主节点,由于网络故障,导致 S1、S2和 S3、S4、S5之间彼此无法通信,形成网络分区。
+ 一段时间后,S3、S4、S5三个节点中的某一个(例如是 S3)最先达到心跳超时的阈值,获知当前分区中已经不存在主节点了,
它向所有节点发出自己要竞选的广播,并收到了 S4、S5节点的批准响应,加上自己一共三票,
即得到了多数派的批准,竞选成功,此时系统中同时存在 S1和 S3两个主节点,但由于网络分区,它们不会知道对方的存在。
+ 这种情况下,客户端发起操作请求:
+ (不过半)如果客户端连接到了 S1、S2之一,都将由 S1处理,但由于操作只能获得最多两个节点的响应,不构成多数派的批准,所以任何变更都无法成功提交。
+ (过半)如果客户端连接到了 S3、S4、S5之一,都将由 S3处理,此时操作可以获得最多三个节点的响应,构成多数派的批准,是有效的,变更可以被提交,即系统可以继续提供服务。
+ (下线)事实上,以上两种“如果”情景很少机会能够并存。网络分区是由于软、硬件或者网络故障而导致的,内部网络出现了分区,但两个分区仍然能分别与外部网络的客户端正常通信的情况甚为少见。
=> 更多的场景是算法能容忍网络里下线了一部分节点,按照这个例子来说,如果下线了两个节点,系统正常工作,下线了三个节点,那剩余的两个节点也不可能继续提供服务了。
+ 假设现在故障恢复,分区解除,五个节点可以重新通信了:
+ (任期大,牛逼)S1和 S3都向所有节点发送心跳包,从各自的心跳中可以得知两个主节点里 S3的任期编号更大,它是最新的,此时五个节点均只承认 S3是唯一的主节点(对不起,阿sir,时代变了)。
+ (回滚未提交的日志)S1、S2回滚它们所有未被提交的变更。
+ (追加变更,赶上前进的步伐)S1、S2从主节点发送的心跳包中获得它们失联期间发生的所有变更,将变更提交写入本地磁盘。
+ 此时分布式系统各节点的状态达成最终一致。

# 安全 => 协定性,终止性
下面我们来看第三个问题:“如何保证过程是安全的”,不知你是否感觉到这个问题与前两个存在一点差异?选主、数据复制都是很具体的行为,但是“安全”就很模糊,什么算是安全或者不安全?

在分布式理论中,Safety(安全性)和Liveness(存活性)两种属性是有预定义的术语,在专业的资料中一般翻译成“协定性”和“终止性”,这两个概念也是由 Lamport 最先提出,当时给出的定义是:

+ 协定性(Safety):“坏”的事情永远不会发生。 => (只能选一个主)
+ 终止性(Liveness):“好事”一定会发生,但我们不知道什么时候。 => (选主会正常结束)
这种就算解释了你也看不明白的定义,是不是很符合 Lamport 老爷子一贯的写作风格?(无奈地摊手苦笑)。
我们不去纠结严谨的定义,仍通过举例来说明它们的具体含义。
例如以选主问题为例,Safety 保证了选主的结果一定是有且只有唯一的一个主节点,不可能同时出现两个主节点;
而 Liveness 则要保证选主过程是一定可以在某个时刻能够结束的。由前面对活锁的介绍可以得知,在 Liveness 这个属性上选主问题是存在理论上的瑕疵的,
可能会由于活锁而导致一直无法选出明确的主节点,所以 Raft 论文中只写了对 Safety 的保证,但由于工程实现上的处理,现实中是几乎不可能会出现终止性的问题。

# 选主,复制,安全
最后,以上这种把共识问题分解为“Leader Election”、“Entity Replication”和“Safety”三个问题来思考、解决的解题思路,即“Raft 算法”,
这篇以《一种可以让人理解的共识算法》为题的论文提出了 Raft 算法,并获得了 USENIX ATC 2014 大会的最佳论文,
后来更是成为 Etcd、LogCabin、Consul 等重要分布式程序的实现基础,ZooKeeper 的 ZAB 算法与 Raft 的思路也非常类似,这些算法都被认为是 Multi Paxos 的等价派生实现。
=> 论文地址 https://web.stanford.edu/~ouster/cgi-bin/papers/raft-atc14

Gossip 协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Gossip 协议
Gossip => 流言蜚语;八卦;闲聊
# 外部短暂可观察(最终一致性) => 整体而非内部

# Paxos、Raft、ZAB 强一致性 => 内部不一致,外部看来一致(整体强一致性)
Paxos、Raft、ZAB 等分布式算法经常会被称作是“强一致性”的分布式共识协议,其实这样的描述抠细节概念的话是很别扭的,会有语病嫌疑,
但我们都明白它的意思其实是在说“尽管系统内部节点可以存在不一致的状态,但从系统外部看来,不一致的情况并不会被观察到,所以整体上看系统是强一致性的”。

# DNS、Gossip => 最终一致性(外部短暂看来不一致)
与它们相对的,还有另一类被冠以“最终一致性”的分布式共识协议,这表明系统中不一致的状态有可能会在一定时间内被外部直接观察到。
一种典型且极为常见的最终一致的分布式系统就是DNS 系统,在各节点缓存的 TTL 到期之前,都有可能与真实的域名翻译结果存在不一致。
在本节中,将介绍在比特币网络和许多重要分布式框架中都有应用的另一种具有代表性的“最终一致性”的分布式共识协议:Gossip 协议。

# Gossip 由来
Gossip 最早由施乐公司 Palo Alto 研究中心在论文《Epidemic Algorithms for Replicated Database Maintenance》中提出的一种用于分布式数据库在多节点间复制同步数据的算法。
从论文题目中可以看出,最初它是被称作“流行病算法”(Epidemic Algorithm)的,只是不太雅观,今天 Gossip 这个名字已经用得更为普遍了,
除此以外,它还有“流言算法”、“八卦算法”、“瘟疫算法”等别名,这些名字都是很形象化的描述,反应了 Gossip 的特点:要同步的信息如同流言一般传播、病毒一般扩散。
=> Xerox,现在可能很多人不了解施乐了,或只把施乐当一家复印产品公司看待,这家公司是计算机许多关键技术的鼻祖,图形界面的发明者、以太网的发明者、激光打印机的发明者、MVC 架构的提出者、RPC 的提出者、BMP 格式的提出者...(牛逼,好多)

# 比特币交换信息:Gossip协议
例如按照习惯也把 Gossip 也称作是“共识协议”,但首先必须强调它所解决的问题并不是直接与 Paxos、Raft 这些共识算法等价的,只是基于 Gossip 之上可以通过某些方法去实现与 Paxos、Raft 相类似的目标而已。
一个最典型的例子是比特币网络中使用到了 Gossip 协议,用它来在各个分布式节点中互相同步区块头和区块体的信息,这是整个网络能够正常交换信息的基础,但并不能称作共识;
比特币使用工作量证明(Proof of Work,PoW)来对“这个区块由谁来记账”这一件事情在全网达成共识,这个目标才可以认为与 Paxos、Raft 的目标是一致的。

# Gossip 工作流程
# 循环
# 1、a节点 -> k个节点
# 2、收到:有x,无 -> k个节点(除了a)
下面,我们来了解 Gossip 的具体工作过程。相比 Paxos、Raft 等算法,Gossip 的过程十分简单,它可以看作是以下两个步骤的简单循环:
+ 如果有某一项信息需要在整个网络中所有节点中传播,那从信息源开始,选择一个固定的传播周期(例如 1 秒),随机选择它相连接的 k 个节点(称为 Fan-Out)来传播消息。
+ 每一个节点收到消息后,如果这个消息是它之前没有收到过的,将在下一个周期内,选择除了发送消息给它的那个节点外的其他相邻 k 个节点发送相同的消息,直到最终网络中所有节点都收到了消息,尽管这个过程需要一定时间,但是理论上最终网络的所有节点都会拥有相同的消息。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Gossip 优点
# 连通性,稳定性 => 无要求(一开始就部分连通,而不是全连通)
# 节点:随意加减,随意鸡掰重启
# 最终一致性
# 节点平等(无中心化)
上图是 Gossip 传播过程的示意图,根据示意图和 Gossip 的过程描述,我们很容易发现 Gossip 对网络节点的连通性和稳定性几乎没有任何要求,
它一开始就将网络某些节点只能与一部分节点部分连通(Partially Connected Network)而不是以全连通网络(Fully Connected Network)作为前提;
能够容忍网络上节点的随意地增加或者减少,随意地宕机或者重启,新增加或者重启的节点的状态最终会与其他节点同步达成一致。Gossip 把网络上所有节点都视为平等而普通的一员,
没有任何中心化节点或者主节点的概念,这些特点使得 Gossip 具有极强的鲁棒性,而且非常适合在公众互联网中应用。

# Gossip 缺点
# 节点状态不一致
# 时间无法预估
# 重复发送(网络压力)
同时我们也很容易找到 Gossip 的缺点,消息最终是通过多个轮次的散播而到达全网的,因此它必然会存在全网各节点状态不一致的情况,
而且由于是随机选取发送消息的节点,所以尽管可以在整体上测算出统计学意义上的传播速率,但对于个体消息来说,无法准确地预计到需要多长时间才能达成全网一致。
另外一个缺点是消息的冗余,同样是由于随机选取发送消息的节点,也就不可避免的存在消息重复发送给同一节点的情况,增加了网络的传输的压力,也给消息节点带来额外的处理负载。

# 耗时,传播量 => 对立面
达到一致性耗费的时间与网络传播中消息冗余量这两个缺点存在一定对立,如果要改善其中一个,就会恶化另外一个,
由此,Gossip 设计了两种可能的消息传播模式:反熵(Anti-Entropy)和传谣(Rumor-Mongering),这两个名字都挺文艺的。
熵(Entropy)是生活中少见但科学中很常用的概念,它代表着事物的混乱程度。
# 反熵:全量
反熵的意思就是反混乱,以提升网络各个节点之间的相似度为目标,所以在反熵模式下,会同步节点的全部数据,以消除各节点之间的差异,目标是整个网络各节点完全的一致。
但是,在节点本身就会发生变动的前提下,这个目标将使得整个网络中消息的数量非常庞大,给网络带来巨大的传输开销。
# 传谣:增量
而传谣模式是以传播消息为目标,仅仅发送新到达节点的数据,即只对外发送变更信息,这样消息数据量将显著缩减,网络开销也相对较小。

【日期标记】2022-08-17 16:51:04 以上同步完成

从类库到服务

通过服务来实现组件
微服务架构也会使用到类库,但构成软件系统组件的主要方式是将其拆分为一个个服务。
—— Martin Fowler / James Lewis, Microservices, 2014

1
2
3
4
5
6
7
8
9
10
11
12
# 微服务设计原则:通过服务来实现独立自治的组件
# 服务√ 类库×
微服务架构其中一个重要设计原则是“通过服务来实现独立自治的组件”(Componentization via Services),强调应采用“服务”(Service)而不再是“类库”(Library)来构建组件化的程序,
这两者的差别在于类库是在编译期静态链接到程序中的,通过调用本地方法来使用其中的功能,而服务是进程外组件,通过调用远程方法来使用其中的功能。

# 微服务的三个问题
采用服务来构建程序,获得的收益是软件系统“整体”与“部分”在物理层面的真正隔离,这对构筑可靠的大型软件系统来说无比珍贵,但另一面,其付出的代价也同样无可忽视,微服务架构在复杂性与执行性能方面做出了极大的让步。
一套由多个微服务相互调用才能正常运作的分布式系统中,每个节点都互相扮演着服务的生产者与消费者的多重角色,形成了一套复杂的网状调用关系,此时,至少有(但不限于)以下三个问题是必须考虑并得到妥善解决的:
+ (注册发现)对消费者来说,外部的服务由谁提供?具体在什么网络位置?
+ (网关路由)对生产者来说,内部哪些服务需要暴露?哪些应当隐藏?应当以何种形式暴露服务?以什么规则在集群中分配请求?
+ (负载均衡)对调用过程来说,如何保证每个远程服务都接收到相对平均的流量,获得尽可能高的服务质量与可靠性?
这三个问题的解决方案,在微服务架构中通常被称为“服务发现”、“服务的网关路由”和“服务的负载均衡”。

服务发现

1
2
3
4
5
6
7
8
9
# 类库
类库封装被大规模使用,令计算机实现了通过位于不同模块的方法调用来组装复用指令序列,打开了软件达到更大规模的一扇大门。

# 链接器:内存引用
无论是编译期链接的 C、C++语言,抑或是运行期链接的 Java 语言,都要通过链接器(Linker)将代码里的符号引用转换为模块入口或进程内存地址的直接引用。

# 服务化
而服务化的普及,令软件系统得以通过分布于网络中不同机器的互相协作来复用功能,这是软件发展规模的第二次飞跃,
此时,如何确定目标方法的确切位置,便是与编译链接有着等同意义的研究课题,解决该问题的过程便被称作“服务发现”(Service Discovery)。

服务发现的意义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 三元组定位服务:全限定名,端口号,服务标识
所有的远程服务调用都是使用全限定名(Fully Qualified Domain Name,FQDN)、端口号与服务标识所构成的三元组来确定一个远程服务的精确坐标的。
+ 全限定名代表了网络中某台主机的精确位置
+ 端口代表了主机上某一个提供了 TCP/UDP 网络服务的程序
+ 服务标识则代表了该程序所提供的某个具体的方法入口。

# 更简化:全限定名+端口号
其中“全限定名、端口号”的含义对所有的远程服务来说都一致,而“服务标识”则与具体的应用层协议相关,不同协议具有不同形式的标识,
例如 REST 的远程服务,标识是 URL 地址;RMI 的远程服务,标识是 Stub 类中的方法;SOAP 的远程服务,标识是 WSDL 中定义方法,等等。
远程服务标识的多样性,决定了“服务发现”也可以有两种不同的理解:
+ 一种是以 UDDI 为代表的“百科全书式”的服务发现,上至提供服务的企业信息(企业实体、联系地址、分类目录等等),下至服务的程序接口细节(方法名称、参数、返回值、技术规范等等)都在服务发现的管辖范围之内;
+ (DNS:全限定名+端口号)另一种是类似于 DNS 这样“门牌号码式”的服务发现,只满足从某个代表服务提供者的全限定名到服务实际主机 IP 地址的翻译转换,并不关心服务具体是哪个厂家提供的,也不关心服务有几个方法,各自由什么参数构成,默认这些细节信息是服务消费者本身已完全了解的,此时服务坐标就可以退化为更简单的“全限定名+端口号”。
当今,后一种服务发现占主流地位,本文后续所说的服务发现,如无说明,均是特指的是后者。

# 1. DNS+负载均衡器 => 无法跟上服务变动的步伐:服务的非正常宕机、重启和正常的上线、下线变得越发频繁
# 2. ZooKeeper 分布式 K/V 框架 => 自己做工作
# 3. 2014 Eureka 简单
# 4. 2018 Eureka 维护 => Consul, Nacos
原本服务发现只依赖 DNS 将一个全限定名翻译为一至多个 IP 地址或者 SRV 等其他类型的记录便可,位于 DNS 之后的负载均衡器也实质上承担了一部分服务发现的职责,完成了外部 IP 地址到各个服务内部实际 IP 的转换,这些内容在“透明多级分流系统”中曾经详细解析过。
这种做法在软件追求不间断长时间运行的时代是很合适的,但随着微服务的逐渐流行,服务的非正常宕机、重启和正常的上线、下线变得越发频繁,仅靠着 DNS 服务器和负载均衡器等基础设施就显得逐渐疲于应对,无法跟上服务变动的步伐了。
人们最初是尝试使用 ZooKeeper 这样的分布式 K/V 框架,通过软件自身来完成服务注册与发现,ZooKeeper 也的确曾短暂统治过远程服务发现,是微服务早期的主流选择,但毕竟 ZooKeeper 是很底层的分布式工具,用户自己还需要做相当多的工作才能满足服务发现的需求。
到了 2014 年,在 Netflix 内部经受过长时间实际考验的、专门用于服务发现的 Eureka 宣布开源,并很快被纳入 Spring Cloud,成为 Spring 默认的远程服务发现的解决方案,从此 Java 程序员再无须再在服务注册这件事情上花费太多的力气。
到 2018 年,Spring Cloud Eureka 进入维护模式以后,HashiCorp 的 Consul 和阿里巴巴的 Nacos 很就快从 Eureka 手上接过传承的衣钵。

# 好处:不仅DNS,HTTP,还支持服务健康检查,集中配置,..
到这个阶段,服务发现框架已经发展得相当成熟,考虑到几乎方方面面的问题,不仅支持通过 DNS 或者 HTTP 请求进行符号与实际地址的转换,
还支持各种各样的服务健康检查方式,支持集中配置、K/V 存储、跨数据中心的数据交换等多种功能,可算是应用自身去解决服务发现的一个顶峰。
# 云原生 => 基础设施:透明化
如今,云原生时代来临,基础设施的灵活性得到大幅度的增强,最初的使用基础设施来透明化地做服务发现的方式又重新被人们所重视,
如何在基础设施和网络协议层面,对应用尽可能无感知、方便地实现服务发现是目前服务发现的一个主要发展方向。

可用与可靠

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 服务发现三个过程:注册,鸡掰(剔除),服务名(坐标)
本章并不打算介绍具体某一种服务发现工具的具体功能与操作,而是会去分析服务发现的通用的共性设计,探讨对比时下服务发现最常见的不同形式。
这里要讨论的第一个问题是“服务发现”具体是指进行过什么操作?这其实包含三个必须的过程。
+ 服务的注册(Service Registration)
当服务启动的时候,它应该通过某些形式(如调用 API、产生事件消息、在 ZooKeeper/Etcd 的指定位置记录、存入数据库,等等)将自己的坐标信息通知到服务注册中心,这个过程可能由应用程序本身来完成,称为自注册模式,
例如 Spring Cloud 的@EnableEurekaClient 注解;也可能由容器编排框架或第三方注册工具来完成,称为第三方注册模式,例如 Kubernetes 和 Registrator。
+ 服务的维护(Service Maintaining)
尽管服务发现框架通常都有提供下线机制,但并没有什么办法保证每次服务都能优雅地下线(Graceful Shutdown)而不是由于宕机、断网等原因突然失联。
所以服务发现框架必须要自己去保证所维护的服务列表的正确性,以避免告知消费者服务的坐标后,得到的服务却不能使用的尴尬情况。
现在的服务发现框架,往往都能支持多种协议(HTTP、TCP 等)、多种方式(长连接、心跳、探针、进程状态等)去监控服务是否健康存活,将不健康的服务自动从服务注册表中剔除。
+ 服务的发现(Service Discovery)
这里的发现是特指狭义上消费者从服务发现框架中,把一个符号(例如 Eureka 中的 ServiceID、Nacos 中的服务名、或者通用的 FQDN)转换为服务实际坐标的过程,
这个过程现在一般是通过 HTTP API 请求或者通过 DNS Lookup 操作来完成,也还有一些相对少用的方式,例如 Kubernetes 也支持注入环境变量来做服务发现。

# 扩展功能:负载均衡、流量管控、键值存储、元数据管理、业务分组,...
以上三点只是列举了服务发现必须提供的功能,在此之余还会有一些可选的扩展功能,例如在服务发现时进行的负载均衡、流量管控、键值存储、元数据管理、业务分组,等等,这部分后续介绍。


# 可用性、一致性 => 矛盾
这里,想借服务发现为样本,展示分布式环境里可用性与一致性的矛盾。
从 CAP 定理开始,到分布式共识算法,我们已在理论上探讨过多次服务的可用和数据的可靠之间需有所取舍,但服务发现却面临着两者都难以舍弃的困境。

服务发现既要高可用,也要高可靠是由它在整个系统中所处的位置所决定的。
在概念模型里,服务发现的位置是如下图所示:
服务提供者在服务注册中心中注册、续约和下线自己的真实坐标,
服务消费者根据某种符号从服务注册中心中获取到真实坐标,
无论是服务注册中心、服务提供者还是服务消费者,它们都是系统服务中的一员,相互间的关系应是对等的(“对等”好词)。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
# 服务发现+配置中心
但在真实的系统里,注册中心的地位是特殊的,不能为完全视其为一个普通的服务。

注册中心不依赖其他服务,但被所有其他服务共同依赖,是系统中最基础的服务,几乎没有可能在业务层面进行容错。
=> 类似地位的大概就数配置中心了,现在服务发现框架也开始同时提供配置中心的功能,以避免配置中心又去专门摆弄出一集群的节点来
这意味着服务注册中心一旦崩溃,整个系统都不再可用,因此,必须尽最大努力保证服务发现的可用性。

# 多节点:高可用
实际用于生产的分布式系统,服务注册中心都是以集群的方式进行部署的,
通常使用三个或者五个节点(通常最多七个,一般也不会更多了,否则日志复制的开销太高)来保证高可用,如下图所示:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# CAP问题
同时,也请注意到上图中各服务注册中心节点之间的“Replicate”字样。
作为用户,我们当然期望服务注册中心一直可用永远健康的同时,也能够在访问每一个节点中都能取到可靠一致的数据,
而不是从注册中心拿到的服务地址可能已经下线,这两个需求就构成了 CAP 矛盾,不可能同时满足。

以最有代表性的 Netflix Eureka 和 Hashicorp Consul 为例:
# ap,异步复制,先可见,超时移除,TTL更新 (Ribbon,Hystrix 兜底)
+ Eureka 的选择是优先保证高可用性,相对牺牲系统中服务状态的一致性。
Eureka 的各个节点间采用异步复制来交换服务注册信息,当有新服务注册进来时,并不需要等待信息在其他节点复制完成,而是马上在该服务发现节点宣告服务可见,只是不保证在其他节点上多长时间后才会可见。
同时,当有旧的服务发生变动,例如下线或者断网,只会由超时机制来控制何时从哪一个服务注册表中移除,变动信息不会实时的同步给所有服务端与客户端。
这样的设计使得不论是 Eureka 的服务端还是客户端,都能够持有自己的服务注册表缓存,并以 TTL(Time to Live)机制来进行更新,哪怕服务注册中心完全崩溃,客户端在仍然可以维持最低限度的可用。
Eureka 的服务发现模型对节点关系相对固定,服务一般不会频繁上下线的系统是很合适的,以较小的同步代价换取了最高的可用性;
Eureka 能够选择这种模型的底气在于万一客户端拿到了已经发生变动的错误地址,也能够通过 Ribbon 和 Hystrix 模块配合来兜底,实现故障转移(Failover)或者快速失败(Failfast)。

# cp,go语言,可靠性(无兜底)
+ Consul 的选择是优先保证高可靠性,相对牺牲系统服务发现的可用性。
Consul 采用Raft 算法,要求多数派节点写入成功后服务的注册或变动才算完成,严格地保证了在集群外部读取到的服务发现结果必定是一致的;
同时采用 Gossip 协议,支持多数据中心之间更大规模的服务同步。
Consul 优先保证高可靠性一定程度上是基于产品现实情况而做的技术决策,它不像 Netflix OSS 那样有着全家桶式的微服务组件,万一从服务发现中取到错误地址,就没有其他组件为它兜底了。

# ap?cp?选择
Eureka 与 Consul 的差异带来的影响主要不在于服务注册的快慢(当然,快慢确实是有差别),而在于你如何看待以下这件事情:
=> “假设系统形成了 A、B 两个网络分区后,A 区的服务只能从区域内的服务发现节点获取到 A 区的服务坐标,B 区的服务只能取到在 B 区的服务坐标,这对你的系统会有什么影响?”

# 不在意 => 选 ap
如果这件事情对你并没有太大的影响,甚至有可能还是有益的,就应该倾向于选择 AP 式的服务发现。
例如假设 A、B 就是不同的机房,是机房间的网络交换机导致服务发现集群出现的分区问题,但每个分区中的服务仍然能独立提供完整且正确的服务能力,此时尽管不是有意而为,但网络分区在事实上避免了跨机房的服务请求,反而还带来了服务调用链路优化的效果。

# 非常在意 => 选 cp
如果这件事情也可能对你影响非常之大,甚至可能带来比整个系统宕机更坏的结果,就应该倾向于选择 CP 式的服务发现。
例如系统中大量依赖了集中式缓存、消息总线、或者其他有状态的服务,一旦这些服务全部或者部分被分隔到某一个分区中,会对整个系统的操作的正确性产生直接影响的话,那与其最后弄出一堆数据错误,还不如直接停机来得痛快。

注册中心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 权衡 => 先选ap/cp,其次才是性能/功能/方便
可用性与一致性的矛盾,是分布式系统永恒的话题,在服务发现这个场景里,权衡的主要关注点:
+ (AP)是相对更能容忍出现服务列表不可用的后果,
+ (CP)还是出现服务数据不准确的后果,
+ (性能,功能,方便)其次才到性能高低,功能是否强大,使用是否方便等因素。
有了选择权衡,很自然就引来了一下个“务实”的话题,现在那么多的服务发现框架,哪一款最好?或者说应该如何挑选适合的?
当下,直接以服务发现、服务注册中心为目标的组件库,或者间接用来实现这个目标的工具主要有以下三类:

# 1. 分布式 K/V 存储框架
# etcd -> raft -> cp
# zk -> ZAB -> cp
# redis 异步复制 -> ap
在分布式 K/V 存储框架上自己开发的服务发现,这类的代表是 ZooKeeper、Doozerd、Etcd。
这些 K/V 框架提供了分布式环境下读写操作的共识算法,Etcd 采用的是我们学习过的 Raft 算法,ZooKeeper 采用的是 ZAB 算法,这也是一种 Multi Paxos 的派生算法,所以采用这种方案,就不必纠结 CP 还是 AP 的问题,它们都是 CP 的(也曾有公司采用 Redis 来做服务发现,这种自然是 AP 的)。
这类框架的宣传语中往往会主动提及“高可用性”,潜台词其实是“在保证一致性和分区容忍性的前提下,尽最大努力实现最高的可用性”,例如 Etcd 的宣传语就是“高可用的集中配置和服务发现”(Highly-Available Key Value Store for Shared Configuration and Service Discovery)。
这些 K/V 框架的一个共同特点是在整体较高复杂度的架构和算法的外部,维持着极为简单的应用接口,只有基本的 CRUD 和 Watch 等少量 API,所以要在上面完成功能齐全的服务发现,很多基础的能力,例如服务如何注册、如何做健康检查,等等都必须自己去实现,如今一般也只有“大厂”才会直接基于这些框架去做服务发现了。

# 2. 基础设施
# 好处:对应于透明,无感知 坏处:实现复杂
# k8s =》 SkyDNS ×, KubeDNS ×, CoreDNS √
以基础设施(主要是指 DNS 服务器)来实现服务发现,这类的代表是 SkyDNS、CoreDNS。
在 Kubernetes 1.3 之前的版本使用 SkyDNS 作为默认的 DNS 服务,其工作原理是从 API Server 中监听集群服务的变化,然后根据服务生成 NS、SRV 等 DNS 记录存放到 Etcd 中,kubelet 会为每个 Pod 设置 DNS 服务的地址为 SkyDNS 的地址,需要调用服务时,只需查询 DNS 把域名转换成 IP 列表便可实现分布式的服务发现。
在 Kubernetes 1.3 之后,SkyDNS 不再是默认的 DNS 服务器,而是由不使用 Etcd,只将 DNS 记录存储在内存中的 KubeDNS 代替,到了 1.11 版,就更推荐采用扩展性很强的 CoreDNS,此时可以通过各种插件来决定是否要采用 Etcd 存储、重定向、定制 DNS 记录、记录日志,等等。
采用这种方案,是 CP 还是 AP 就取决于后端采用何种存储,如果是基于 Etcd 实现的,那自然是 CP 的,如果是基于内存异步复制的方案实现的,那就是 AP 的(仅针对 DNS 服务器本身,不考虑本地 DNS 缓存的 TTL 刷新)。
以基础设施来做服务发现,好处是对应用透明,任何语言、框架、工具都肯定是支持 HTTP、DNS 的,所以完全不受程序技术选型的约束,但坏处是透明的并不一定是简单的,你必须自己考虑如何去做客户端负载均衡、如何调用远程方法等这些问题,而且必须遵循或者说受限于这些基础设施本身所采用的实现机制,例如服务健康检查里,服务的缓存期限就应该由 TTL 来决定,这是 DNS 协议所规定的,如果想改用 KeepAlive 长连接来实时判断服务是否存活就相对麻烦。

# 3. 服务发现的框架
# 应用不透明(声明代替编码)
# Consul cp
# Eureka cp
# Naocs raft cp/distro ap
专门用于服务发现的框架和工具,这类的代表是 Eureka、Consul 和 Nacos。
这一类框架中,你可以自己决定是 CP 还是 AP 的问题,例如 CP 的 Consul、AP 的 Eureka,还有同时支持 CP 和 AP 的 Nacos(Nacos 采用类 Raft 协议做的 CP,采用自研的 Distro 协议做的 AP,这里“同时”是“都支持”的意思,它们必须二取其一,不是说 CAP 全能满足)。
将它们划归一类是由于它们对应用并不是透明的,尽管 Consul 的主体逻辑是在服务进程之外,以边车的形式提供的,尽管 Consul、Nacos 也支持基于 DNS 的服务发现,尽管这些框架都基本上做到了以声明代替编码,例如在 Spring Cloud 中只改动 pom.xml、配置文件和注解即可实现,但它们依然是可以被应用程序感知的。所以或多或少还需要考虑你所用的程序语言、技术框架的集成问题。
但这个特点其实并不见得全是坏处,例如采用 Eureka 做服务注册,那在远程调用服务时你就可以用 OpenFeign 做客户端,它们本身就已做好了集成,写个声明式接口就能跑;在做负载均衡时你就可以采用 Ribbon 做客户端,要换均衡算法改个配置就成,这些“不透明”实际上都为编码开发带来了一定便捷,而前提是你选用的语言和框架必须支持(Java牛逼)。
如果老板提出要在 Rust 上用 Eureka,那就只能无奈叹息了(原本这里我写的是 Node、Go、Python 等,查了一下这些居然都有非官方的 Eureka 客户端,用的人多什么问题都会有解决方案)。

网关路由

1
2
3
# 嘛叫“网关” => 内部的边缘,与外部交互
网关(Gateway)这个词在计算机科学中,尤其是计算机网络中很常见,它用来表示位于内部区域边缘,与外界进行交互的某个物理或逻辑设备,
=> 例如:你家里的路由器就属于家庭内网与互联网之间的网关。

在这里插入图片描述

网关的职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# Gateway
在单体架构下,我们一般不太强调“网关”这个概念,为各个单体系统的副本分发流量的负载均衡器实质上承担了内部服务与外部请求之间的网关角色。

# 为什么需要网关?
在微服务环境中,网关的存在感就极大地增强了,甚至成为了微服务集群中必不可少的设施之一。
其中原因并不难理解:
微服务架构下,每个服务节点都可能由不同团队负责,都有着自己独立的、互不相同的接口,
如果服务集群缺少一个统一对外交互的代理人角色,那外部的服务消费者就必须知道所有微服务节点在集群中的精确坐标(在服务发现中解释过“服务坐标”的概念),
这样,消费者不仅会受到服务集群的网络限制(不能确保集群中每个节点都有外网连接)、安全限制(不仅是服务节点的安全,外部自身也会受到如浏览器同源策略的约束)、
依赖限制(服务坐标这类信息不属于对外接口承诺的内容,随时可能变动,不应该依赖它),就算是调用服务的程序员,自己也不会愿意记住每一个服务的坐标位置(ip+port)来编写代码。

# 职责:路由转发
# 可选:流量过滤
由此可见,微服务中网关的首要职责就是作为统一的出口对外提供服务,将外部访问网关地址的流量,根据适当的规则路由到内部集群中正确的服务节点之上,
因此,微服务中的网关,也常被称为“服务网关”或者“API 网关”,微服务中的网关首先应该是个路由器,
在满足此前提的基础上,网关还可以根据需要作为流量过滤器来使用,提供某些额外的可选的功能,例如安全、认证、授权、限流、监控、缓存,等等(后续介绍)。
网关 = 路由器(基础职能) + 过滤器(可选职能)

# 网关路由的两个关注点:“网络协议层次”和“性能与可用性”
针对“路由”这个基础职能,服务网关主要考量的是能够支持路由的“网络协议层次”和“性能与可用性”两方面的因素。

# 网关路由关注点1:网络协议层次
网络协议层次是指负载均衡中介绍过的四层流量转发与七层流量代理,
+ 仅从技术实现角度来看,对于路由这项工作,负载均衡器与服务网关在实现上是没有什么差别的,很多服务网关本身就是基于老牌的负载均衡器来实现的,例如基于 Nginx、HAProxy 开发的 Ingress Controller,基于 Netty 开发的 Zuul 2.0 等;
+ 但从目的角度看,负载均衡器与服务网关会有一些区别,具体在于前者是为了根据均衡算法对流量进行平均地路由,后者是为了根据流量中的某种特征进行正确地路由。
网关必须能够识别流量中的特征,这意味着网关能够支持的网络通信协议的层次将会直接限制后端服务节点能够选择的服务通信方式。
(TCP 四层网关)如果服务集群只提供像 Etcd 这样直接基于 TCP 的访问的服务,那只部署四层网关便可满足,网关以 IP 报文中源地址、目标地址为特征进行路由;
(HTTP 七层网关)如果服务集群要提供 HTTP 服务的话,那就必须部署一个七层网关,网关根据 HTTP 报文中的 URL、Header 等信息为特征进行路由;
如果服务集群还要提供更上层的 WebSocket、SOAP 等服务,那就必须要求网关同样能够支持这些上层协议,才能从中提取到特征。

# Zuul HTTP 网关
举个栗子!!以下是用到的 Netflix Zuul 网关的配置,Zuul 是 HTTP 网关,
/restful/accounts/**和/restful/pay/**是 HTTP 中 URL 的特征,而配置中的serviceId就是路由的目标服务。
------
routes:
account:
path: /restful/accounts/**
serviceId: account
stripPrefix: false
sensitiveHeaders: "*"

payment:
path: /restful/pay/**
serviceId: payment
stripPrefix: false
sensitiveHeaders: "*"
------
今天围绕微服务的各种技术仍处于快速发展期,不提倡针对每一种工具、框架本身去记忆配置细节,就是无须纠结上面代码清单中配置的确切写法、每个指令的含义。
如果你从根本上理解了网关的原理,参考一下技术手册,很容易就能够将上面的信息改写成 Kubernetes Ingress Controller、Istio VirtualServer 或者其他服务网关所需的配置形式。

# 网关路由关注点2:性能与可用性
网关的另一个主要关注点是它的性能与可用性。
# 网关1ms,所有服务+1ms
由于网关是所有服务对外的总出口,是流量必经之地,所以网关的路由性能将导致全局的、系统性的影响,如果经过网关路由会有 1 毫秒的性能损失,就意味着整个系统所有服务的响应延迟都会增加 1 毫秒。

# 服务网关:七层代理
网关的性能与它的工作模式和自身实现算法都有关系,但毫无疑问工作模式是最关键的因素,如果能够采用 DSR 三角传输模式,原理上就决定了性能一定会比代理模式来的强(DSR、IP Tunnel、NAT、代理等这些都是网络基础知识,曾在介绍“负载均衡器”时详细讲解过)。
不过,因为今天 REST 和 JSON-RPC 等基于 HTTP 协议的服务接口在对外部提供的服务中占绝对主流的地位,所以我们所讨论的服务网关默认都必须支持七层路由,通常就默认无法直接进行流量转发,只能采用代理模式。

# 性能取决于:网络I/O模型
在这个前提约束下,网关的性能主要取决于它们如何代理网络请求,也即它们的网络 I/O 模型,下面正好借这个场景介绍一下网络 I/O 的基础知识。

网络 I/O 模型

看这篇文章之前,可以先看看之前的一篇:I/O 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# 网络I/O => Socket读写
在套接字接口抽象下,网络 I/O 的出入口就是 Socket 的读和写,Socket 在操作系统接口中被抽象为数据流,网络 I/O 可以理解为对流的操作。
每一次网络访问,从远程主机返回的数据会先存放到操作系统内核的缓冲区中,然后内核的缓冲区复制到应用程序的地址空间,
所以当发生一次网络请求发生后,将会按顺序经历 “等待数据从远程主机到达缓冲区” 和 “将数据从缓冲区拷贝到应用程序地址空间” 两个阶段(IO -> 内核 -> 用户),

# 两类,五种
根据实现这两个阶段的不同方法,人们把网络 I/O 模型总结为两类、五种模型:
+ 两类是指同步 I/O与异步 I/O
+ 五种是指在同步 IO 中又分有划分出阻塞 I/O、非阻塞 I/O、多路复用 I/O和信号驱动 I/O四种细分模型。

# 同步/异步、阻塞/非阻塞
# 阻塞/非阻塞:中间是否线程挂起 收到IO -> 中间 -> 返回结果
这里先解释一下同步和异步、阻塞和非阻塞的概念。
+ 同步是指调用端发出请求之后,得到结果之前必须一直等待,
+ 与之相对的就是异步,发出调用请求之后将立即返回,不会马上得到处理结果,结果将通过状态变化和回调来通知调用者。
+ 阻塞和非阻塞是针对请求处理过程,指收到调用请求之后,返回结果之前,当前处理线程是否会被挂起。

这种概念上的叙述估计还是不太好理解的,下面以“你如何领到盒饭”为情景,将之类比解释如下:

+ 异步 I/O(Asynchronous I/O)
好比你在美团外卖订了个盒饭,付款之后你自己该干嘛还干嘛去,饭做好了骑手自然会到门口打电话通知你。
异步 I/O 中数据到达缓冲区后,不需要由调用进程主动进行从缓冲区复制数据的操作,而是复制完成后由操作系统向线程发送信号,所以它一定是非阻塞的。
+ 同步 I/O(Synchronous I/O)
好比你自己去饭堂打饭,这时可能有如下情形发生:
+ 阻塞 I/O(Blocking I/O)
你去到饭堂,发现饭还没做好,你也干不了别的,只能打个瞌睡(线程休眠),直到饭做好,这就是被阻塞了。
阻塞 I/O 是最直观的 I/O 模型,逻辑清晰,也比较节省 CPU 资源,但缺点就是线程休眠所带来的上下文切换,这是一种需要切换到内核态的重负载操作,不应当频繁进行。
+ 非阻塞 I/O(Non-Blocking I/O)
你去到饭堂,发现饭还没做好,你就回去了,然后每隔 3 分钟来一次饭堂看饭做好了没,直到饭做好。
非阻塞 I/O 能够避免线程休眠,对于一些很快就能返回结果的请求,非阻塞 I/O 可以节省切换上下文切换的消耗,但是对于较长时间才能返回的请求,非阻塞 I/O 反而白白浪费了 CPU 资源,所以目前并不常用。
+ 多路复用 I/O(Multiplexing I/O)
多路复用 I/O 本质上是阻塞 I/O 的一种,但是它的好处是可以在同一条阻塞线程上处理多个不同端口的监听。
类比的情景是你名字叫雷锋,代表整个宿舍去饭堂打饭,去到饭堂,发现饭还没做好,还是继续打瞌睡,但哪个舍友的饭好了,你就马上把那份饭送回去,然后继续打着瞌睡哼着歌等待其他的饭做好。
多路复用 I/O 是目前的高并发网络应用的主流,它下面还可以细分 select、epoll、kqueue 等不同实现,这里就不作展开了。
+ 信号驱动 I/O(Signal-Driven I/O)
你去到饭堂,发现饭还没做好,但你跟厨师熟,跟他说饭做好了叫你,然后回去该干嘛干嘛,等收到厨师通知后,你把饭从饭堂拿回宿舍。
这里厨师的通知就是那个“信号”,信号驱动 I/O 与异步 I/O 的区别是“从缓冲区获取数据”这个步骤的处理,
前者收到的通知是可以开始进行复制操作了,即要你自己从饭堂拿回宿舍,在复制完成之前线程处于阻塞状态,所以它仍属于同步 I/O 操作,而后者收到的通知是复制操作已经完成,即外卖小哥已经把饭送到了。

# 异步I/O 不完善,多路复用I/O 常用
显而易见,异步 I/O 模型是最方便的,毕竟能叫外卖谁愿意跑饭堂啊,但前提是你学校里有开展外卖业务。
同样,异步 I/O 受限于操作系统,Windows NT 内核早在 3.5 以后,就通过IOCP实现了真正的异步 I/O 模型。
而 Linux 系统下,是在 Linux Kernel 2.6 才首次引入,目前也还并不算很完善,因此在 Linux 下实现高并发网络编程时仍是以多路复用 I/O 模型模式为主。

# 网络I/O => 外部应答,内部请求
回到服务网关的话题上,有了网络 I/O 模型的知识,我们就可以在理论上定性分析不同七层网关的性能差异了。
七层服务网关处理一次请求代理时,包含了两组网络操作,分别是作为服务端对外部请求的应答,和作为客户端对内部服务的请求,理论上这两组网络操作可以采用不同的模型去完成,但一般来说并没有必要这样做。

# Zuul 1.0 => 阻塞 I/O 模型 “一条线程对应一个连接”
# CPU 密集型 => 服务节点耗时长,I/O完成快CPU还在计算,CPU压力
# I/O 密集型 => 服务节点耗时端,频繁上下文切换(请求一次,立马返回),I/O压力
以 Zuul 网关为例,在 Zuul 1.0 时,它采用的是阻塞 I/O 模型来进行最经典的“一条线程对应一个连接”(Thread-per-Connection)的方式来代理流量,
采用阻塞 I/O 意味着它会有线程休眠,就有上下文切换的成本,
所以如果后端服务普遍属于 CPU 密集型(CPU Bound,可以通俗理解为服务耗时比较长,主要消耗在 CPU 上)时,这种模式能够相对节省网关的 CPU 资源,
但如果后端服务普遍都是 I/O 密集型(I/O Bound,可以理解服务都很快返回,主要消耗在 I/O 上),它就会由于频繁的上下文切换而降低性能。

# Zuul 2.0 => 异步 I/O 模型
在 Zuul 的 2.0 版本,最大的改进就是基于 Netty Server 实现了异步 I/O 模型来处理请求,大幅度减少了线程数,获得了更高的性能和更低的延迟。
根据 Netflix 官方自己给出的数据,Zuul 2.0 大约要比 Zuul 1.0 快上 20%左右。
甚至还有一些网关,支持自行配置,或者根据环境选择不同的网络 I/O 模型,典型的就是 Nginx,可以支持在配置文件中指定 select、poll、epoll、kqueue 等并发模型。

网关的性能高低一般只去定性分析,要定量地说哪一种网关性能最高、高多少是很困难的,就像我们都认可 Chrome 要比 IE 快,但脱离了具体场景,快上多少就很难说的清楚。
尽管例如上面引用了 Netflix 官方对 Zuul 两个版本的量化对比,网络上也有不少关于各种网关的性能对比数据,但要是脱离具体应用场景去定量地比较不同网关的性能差异还是难以令人信服,不同的测试环境和后端服务都会直接影响结果。

# 可用性:单点问题
网关还有最后一点必须关注的是它的可用性问题。
任何系统的网络调用过程中都至少会有一个单点存在,这是由用户只通过唯一的一个地址去访问系统所决定的。
即使是淘宝、亚马逊这样全球多数据中心部署的大型系统也不例外。
对于更普遍的小型系统(小型是相对淘宝这些而言)来说,作为后端对外服务代理人角色的网关经常被视为整个系统的入口,往往很容易成为网络访问中的单点,这时候它的可用性就尤为重要。

# 网关的选型:轻量、成熟、扩展
由于网关的地址具有唯一性,就不像之前服务发现那些注册中心那样直接做个集群,随便访问哪一台都可以解决问题。
为此,对网关的可用性方面,我们应该考虑到以下几点:
+ (轻量)网关应尽可能轻量,尽管网关作为服务集群统一的出入口,可以很方便地做安全、认证、授权、限流、监控,等等的功能,
但给网关附加这些能力时还是要仔细权衡,取得功能性与可用性之间的平衡,过度增加网关的职责是危险的。
+ (成熟)网关选型时,应该尽可能选择较成熟的产品实现,例如 Nginx Ingress Controller、KONG、Zuul 这些经受过长期考验的产品,
而不能一味只考虑性能选择最新的产品,性能与可用性之间的平衡也需要权衡。
+ (扩展)在需要高可用的生产环境中,应当考虑在网关之前部署负载均衡器或者等价路由器(ECMP),
让那些更成熟健壮的设施(往往是硬件物理设备)去充当整个系统的入口地址,这样网关也可以进行扩展了。
=> 网关之前:负载均衡器、路由器 (网关可扩展)

BFF 网关

1
2
3
4
5
6
7
8
提到网关的唯一性、高可用与扩展,例如顺带也说一下近年来随着微服务一起火起来的概念“BFF”(Backends for Frontends)。【哈哈哈,迪卡侬】

这个概念目前还没有权威的中文翻译,在我们讨论的上下文里,它的意思是,网关不必为所有的前端提供无差别的服务,而是应该针对不同的前端,聚合不同的服务,提供不同的接口和网络访问协议支持。

# 裁剪、适配、聚合 => 利于后端稳定
例如,运行于浏览器的 Web 程序,由于浏览器一般只支持 HTTP 协议,服务网关就应提供 REST 等基于 HTTP 协议的服务,
但同时我们亦可以针对运行于桌面系统的程序部署另外一套网关,它能与 Web 网关有完全不同的技术选型,能提供出基于更高性能协议(如 gRPC)的接口来获得更好的体验。
在网关这种边缘节点上,针对同一样的后端集群,裁剪、适配、聚合出适应不一样的前端的服务,有助于后端的稳定,也有助于前端的赋能。

在这里插入图片描述

客户端负载均衡

LB
前置知识:可以先参考之前讲的 “负载均衡”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# LB => “从服务集群中寻找到一个合适的服务来调用”
在正式开始讨论之前,我们先来明确区分清楚几个容易混淆的相似概念,分别是本章节中频繁提到的服务发现、网关路由、负载均衡以及在服务流量治理章节中将会介绍的服务容错。
这几个技术名词都带有着“从服务集群中寻找到一个合适的服务来调用”的含义,例如通过以下具体场景来说明它们之间的差别:

举个栗子!
假设你身处广东,要上 Fenix's Bookstore 购买一本书,在程序业务逻辑里,购书其中一个关键步骤是调用商品出库服务来完成货物准备,在代码中该服务的调用请求为:
PATCH https://warehouse:8080/restful/stockpile/3

{amount: -1}
又假设 Fenix's Bookstore 是个大书店,在北京、武汉、广州的机房均部署有服务集群(多服务集群),你的购物请求从浏览器发出后,服务端按顺序发生了如下事件:

# 服务发现(流量分发)
1. 首先是将warehouse这个服务名称转换为恰当的服务地址,“恰当”是个宽泛的描述,一种典型的“恰当”便是因调用请求来自广东,优先分配给传输距离最短的广州机房来应答。
其实按常理来说这次出库服务的调用应该是集群内的流量,而不是用户浏览器直接发出的请求,
所以尽管结果没有不同,但更接近实际的的情况是用户访问首页时已经被 DNS 服务器分配到了广州机房,请求出库服务时,应优先选择同机房的服务进行调用,此时请求变为:
PATCH https://guangzhou-ip-wan:8080/restful/stockpile/3
# 网关路由
2. 广州机房的服务网关将该请求与配置中的特征进行比对,由 URL 中的/restful/stockpile/**得知该请求访问的是商品出库服务,
因此,将请求的 IP 地址转换为内网中 warehouse 服务集群的入口地址:
PATCH https://warehouse-gz-lan:8080/restful/stockpile/3
# 负载均衡
3. 集群中部署有多个 warehouse 服务,收到调用请求后,负载均衡器要在多个服务中根据某种标准——可能是随机挑选,
也可能是按顺序轮询,抑或是选择此前调用次数最少那个,等等。根据均衡策略找出要响应本次调用的服务,称其为warehouse-gz-lan-node1。
PATCH https://warehouse-gz-lan-node1:8080/restful/stockpile/3
4. 如果访问warehouse-gz-lan-node1服务,没有返回需要的结果,而是抛出 500 错。
HTTP/1.1 500 Internal Server Error
# 服务容错
5. 根据预置的故障转移(Failover)策略,重试将调用分配给能够提供该服务的其他节点,称其为warehouse-gz-lan-node2。
PATCH https://warehouse-gz-lan-node2:8080/restful/stockpile/3
6. warehouse-gz-lan-node2服务返回商品出库成功。
HTTP/1.1 200 OK
以上过程从整体上看,步骤 1、2、3、5,分别对应了服务发现、网关路由、负载均衡和服务容错,
在细节上看,其中部分职责又是有交叉的,并不是服务注册中心就只关心服务发现,网关只关心路由,均衡器只关心流量负载均衡。
例如,步骤 1 服务发现的过程中,“根据请求来源的物理位置来分配机房”这个操作本质上是根据请求中的特征(地理位置)进行流量分发,这实际是一种路由行为。
实际系统中,在 DNS 服务器(DNS 智能线路)、服务注册中心(如 Eureka 等框架中的 Region、Zone 概念)或者负载均衡器(可用区负载均衡,如 AWS 的 NLB,或 Envoy 的 Region、Zone、Sub-zone)中都有可能实现。

# 不仅消耗了带宽,降低了性能,也增加了链路上的风险和运维的复杂度 => 可以简化吗?
此外,你是否感觉到以上网络调用过程似乎过于烦琐了,一个从广州机房内网发出的服务请求,绕到了网络边缘的网关、负载均衡器这些设施上,
再被分配回内网中另外一个服务去响应,不仅消耗了带宽,降低了性能,也增加了链路上的风险和运维的复杂度。
可是,如果流量不经过这些设施,它们相应的职责就无法发挥作用。
例如:不经过负载均衡器的话,连请求应该具体交给哪一个服务去处理都无法确定,这有办法简化吗?

客户端负载均衡器

Netflix Ribbon
Spring Cloud Load Balancer

1
2
3
4
5
6
7
8
9
10
对于任何一个大型系统,负载均衡器都是必不可少的设施。

# 外部流量 => 集中式的负载均衡(流量还未进入)
以前,负载均衡器大多只部署在整个服务集群的前端,将用户的请求分流到各个服务进行处理,这种经典的部署形式现在被称为集中式的负载均衡。

# 内部流量 => 客户端负载均衡器(流量已进入)
随着微服务日渐流行,服务集群的收到的请求来源不再局限于外部,越来越多的访问请求是由集群内部的某个服务发起,由集群内部的另一个服务进行响应的,
对于这类流量的负载均衡,既有的方案依然是可行的,但针对内部流量的特点,直接在服务集群内部消化掉,肯定是更合理更受开发者青睐的办法。

由此一种全新的、独立位于每个服务前端的、分散式的负载均衡方式正逐渐变得流行起来,这就是我们要讨论的主角:客户端负载均衡器(Client-Side Load Balancer),如下图:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 好处
客户端负载均衡器的理念提出以后,此前的集中式负载均衡器也有了一个方便与它对比的名字“服务端负载均衡器”(Server-Side Load Balancer)。从图中能够清晰地看到客户端负载均衡器的特点,也是它与服务端负载均衡器的关键差别所在:客户端均衡器是和服务实例一一对应的,而且与服务实例并存于同一个进程之内。这个特点能为它带来很多好处,如:
# 无额外开销
+ 均衡器与服务之间信息交换是进程内的方法调用,不存在任何额外的网络开销。
# 不依赖外部
+ 不依赖集群边缘的设施,所有内部流量都仅在服务集群的内部循环,避免了出现前文那样,集群内部流量要“绕场一周”的尴尬局面。
# 不单点
+ 分散式的均衡器意味着天然避免了集中式的单点问题,它的带宽资源将不会像集中式均衡器那样敏感,这在以七层均衡器为主流、不能通过 IP 隧道和三角传输这样方式节省带宽的微服务环境中显得更具优势。
# 灵活性
+ 客户端均衡器要更加灵活,能够针对每一个服务实例单独设置均衡策略等参数,访问某个服务,是不是需要具备亲和性,选择服务的策略是随机、轮询、加权还是最小连接等等,都可以单独设置而不影响其它服务。
+ ...

# 坏处 No Silver Bullet
但是,客户端均衡器也不是银弹,它得到上述诸多好处的同时,缺点同样也是不少的:
# 语言限制(违背微服务原则)
+ 它与服务运行于同一个进程之内,意味着它的选型受到服务所使用的编程语言的限制,譬如用 Golang 开发的微服务就不太可能搭配 Spring Cloud Load Balancer 来使用,要为每种语言都实现对应的能够支持复杂网络情况的均衡器是非常难的。客户端均衡器的这个缺陷有违于微服务中技术异构不应受到限制的原则。
# 影响自己(耗资源)
+ 从个体服务来看,由于是共用一个进程,均衡器的稳定性会直接影响整个服务进程的稳定性,消耗的 CPU、内存等资源也同样影响到服务的可用资源。从集群整体来看,在服务数量达成千乃至上万规模时,客户端均衡器消耗的资源总量是相当可观的。
# 安全(一鼠坏锅汤)
+ 由于请求的来源可能是来自集群中任意一个服务节点,而不再是统一来自集中式均衡器,这就使得内部网络安全和信任关系变得复杂,当攻破任何一个服务时,更容易通过该服务突破集群中的其他部分。
# 服务维护(有代价)
+ 服务集群的拓扑关系是动态的,每一个客户端均衡器必须持续跟踪其他服务的健康状况,以实现上线新服务、下线旧服务、自动剔除失败的服务、自动重连恢复的服务等均衡器必须具备的功能。由于这些操作都需要通过访问服务注册中心来完成,数量庞大的客户端均衡器一直持续轮询服务注册中心,也会为它带来不小的负担。
+ ...

代理负载均衡器

Service Mesh 服务网格

1
2
3
4
5
6
在 Java 领域,客户端均衡器中最具代表性的产品是 Netflix Ribbon 和 Spring Cloud Load Balancer,随着微服务的流行,它们在 Java 微服务中已积聚了相当可观的使用者。

# 客户端均衡器 => 升级 => 代理负载均衡器
# 便车代理 => 独立于服务进程
直到最近两三年,服务网格(Service Mesh)开始逐渐盛行,另外一种被称为“代理客户端负载均衡器”(Proxy Client-Side Load Balancer,后文简称“代理均衡器”)的客户端均衡器变体形式开始引起不同编程语言的微服务开发者共同关注,它解决了此前客户端均衡器的大多数缺陷。
代理均衡器对此前的客户端负载均衡器的改进是将原本嵌入在服务进程中的均衡器提取出来,作为一个进程之外,同一 Pod 之内的特殊服务,放到边车代理中去实现,它的流量关系如下图:

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 不同进程
虽然代理均衡器与服务实例不再是进程内通信,而是通过网络协议栈进行数据交换的,数据要经过操作系统的协议栈,要进行打包拆包、计算校验和、维护序列号等网络数据的收发步骤,流量比起之前的客户端均衡器确实多增加了一系列处理步骤。
# 实质:本机回环 lo => localhost, 127.0.0.1
不过,Kubernetes 严格保证了同一个 Pod 中的容器不会跨越不同的节点,这些容器共享着同一个网络名称空间,因此代理均衡器与服务实例的交互,实质上是对本机回环设备的访问,仍然要比真正的网络交互高效且稳定得多。
# 代价小,收益高
代理均衡器付出的代价较小,但从服务进程中分离出来所获得的收益却是非常显著的:
# 无语言限制
+ 代理均衡器不再受编程语言的限制。发展一个支持 Java、Golang、Python 等所有微服务应用服务的通用的代理均衡器具有很高的性价比。集中不同编程语言的使用者的力量,更容易打造出能面对复杂网络情况的、高效健壮的均衡器。即使退一步说,独立于服务进程的均衡器也不会由于自身的稳定性影响到服务进程的稳定。
# 无服务维护
+ 在服务拓扑感知方面代理均衡器也要更有优势。由于边车代理接受控制平面的统一管理,当服务节点拓扑关系发生变化时,控制平面就会主动向边车代理发送更新服务清单的控制指令,这避免了此前客户端均衡器必须长期主动轮询服务注册中心所造成的浪费。
# 双向TLS(安全,可观测)
+ 在安全性、可观测性上,由于边车代理都是一致的实现,有利于在服务间建立双向 TLS 通信,也有利于对整个调用链路给出更详细的统计信息。
+ ...

# 还不成熟
总体而言,边车代理这种通过同一个 Pod 的独立容器实现的负载均衡器是目前处理微服务集群内部流量最理想的方式,
只是服务网格本身仍是初生事物,还不足够成熟,对操作系统、网络和运维方面的知识要求也较高,但有理由相信随着时间的推移,未来这将会是微服务的主流通信方式。

地域与区域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
最后,借助前文已经铺设好的上下文场景,想再谈一个与负载均衡相关,但又不仅仅应用于负载均衡的概念:地域与区域。
你是否有注意到在微服务相关的许多设施中,都带有着 Region、Zone 参数,如前文中提到过的服务注册中心 Eureka 的 Region、Zone、边车代理 Envoy 中的 Region、Zone、Sub-zone,
如果你有云计算 IaaS 的使用经历,也会发现几乎所有云计算设备都有类似的概念。
Region 和 Zone 是公有云计算先驱亚马逊 AWS提出的概念,它们的含义是指:

# 集群内部,服务发现,负载均衡(不跨地域 region)
+ Region 是地域的意思,譬如华北、东北、华东、华南,这些都是地域范围。
面向全球或全国的大型系统的服务集群往往会部署在多个不同地域,
譬如本节开头列举的案例场景,大型系统就是通过不同地域的机房来缩短用户与服务器之间的物理距离,提升响应速度,对于小型系统,地域一般就只在异地容灾时才会涉及到。
需要注意,不同地域之间是没有内网连接的,所有流量都只能经过公众互联网相连,如果微服务的流量跨越了地域,实际就跟调用外部服务商提供的互联网服务没有任何差别了。
所以集群内部流量是不会跨地域的,服务发现、负载均衡器默认也是不会支持跨地域的服务发现和负载均衡。

+ Zone 是区域的意思,它是可用区域(Availability Zones)的简称,区域指在地理上位于同一地域内,但电力和网络是互相独立的物理区域,
=> 例如:在华东的上海、杭州、苏州的不同机房就是同一个地域的几个可用区域。
同一个地域的可用区域之间具有内网连接,流量不占用公网带宽,因此区域是微服务集群内流量能够触及的最大范围。
但你的应用是只部署在同一区域内,还是部署到几个不同可用区域中,要取决于你是否有做异地双活的需求,以及对网络延时的容忍程度(又是权衡 Trade Off,架构到处都是权衡 Trade Off)。
# 高可用:两地三中心 => 生产中心(主),同城容灾中心,异地容灾中心
+ 如果你追求高可用,譬如希望系统即使在某个地区发生电力或者骨干网络中断时仍然可用,那可以考虑将系统部署在多个区域中。
注意异地容灾和异地双活的差别:容灾是非实时的同步,而双活是实时或者准实时的,跨地域或者跨区域做容灾都可以,但一般只能跨区域做双活,当然也可以将它们结合起来同时使用,即“两地三中心”模式。
# 低延迟:同子域
+ 如果你追求低延迟,譬如对时间有高要求的SLA 应用(监控管理),或者网络游戏服务器等,那就应该考虑将系统的所有服务都只部署在同一个区域中,
因为尽管内网连接不受限于公网带宽,但毕竟机房之间的专线容量也是有限的,难以跟机房内部的交换机相比,延时也受物理距离、网络跳点数量等因素的影响。

# 子域不可用 => 可用区域
+ 可用区域对应于城市级别的区域的范围,一些场景中仍是过大了一些,即使是同一个区域中的机房,也可能存在具有差异的不同子网络,
所以在部分微服务框架也提供了 Group、Sub-zone 等做进一步的细分控制,这些参数的意思通常是加权或优先访问同一个子区域的服务,
但如果子区域中没有合适的,仍然会访问到可用区域中的其他服务。

+ 地域和区域原本是云计算中的概念,对于一些中小型的微服务系统,尤其是非互联网的企业信息系统,很多仍然没有使用云计算设施,只部署在某个专有机房内部,只为特定人群提供服务,这就不需要涉及地理上地域、区域的概念了。
此时完全可以自己灵活延拓 Region、Zone 参数的含义,达到优化虚拟化基础设施流量的目的。譬如,将服务发现的区域设置与 Kubernetes 的标签、选择器配合,实现内部服务请求其他服务时,优先使用同一个 Node 中提供的服务进行应答,以降低真实的网络消耗。

在这里插入图片描述

流量治理

服务容错

容错策略

在这里插入图片描述

容错设计模式

1
2
3
4
5
1. 断路器模式

2. 舱壁隔离模式

3. 重试模式

流量控制

流量统计指标

1
2
3
1. 每秒事务数(Transactions per Second,TPS):TPS 是衡量信息系统吞吐量的最终标准。
2. 每秒请求数(Hits per Second,HPS):HPS 是指每秒从客户端发向服务端的请求数。
3. 每秒查询数(Queries per Second,QPS):QPS 是指一台服务器能够响应的查询次数。

限流设计模式

1
2
3
4
1. 流量计数器模式
2. 滑动时间窗模式
3. 漏桶模式
4. 令牌桶模式

分布式限流

1
2
3
4
5
6
7
8
9
10
# 例如:Redis限流
只要集中式存储统计信息,就不可避免地会产生网络开销,为了缓解这里产生的性能损耗,一种可以考虑的办法是在令牌桶限流模式基础上进行“货币化改造”,即不把令牌看作是只有准入和不准入的“通行证”,而看作数值形式的“货币额度”。当请求进入集群时,首先在 API 网关处领取到一定数额的“货币”,为了体现不同等级用户重要性的差别,他们的额度可以有所差异,譬如让 VIP 用户的额度更高甚至是无限的。
我们将用户 A 的额度表示为 QuanityA。由于任何一个服务在响应请求时都需要消耗集群一定量的处理资源,所以访问每个服务时都要求消耗一定量的“货币”,假设服务 X 要消耗的额度表示为 CostX,那当用户 A 访问了 N 个服务以后,他剩余的额度 LimitN即表示为:
LimitN = QuanityA - ∑NCostX
此时,我们可以把剩余额度 LimitN作为内部限流的指标,规定在任何时候,只要一旦剩余额度 LimitN小于等于 0 时,就不再允许访问其他服务了。此时必须先发生一次网络请求,重新向令牌桶申请一次额度,成功后才能继续访问,不成功则进入降级逻辑。除此之外的任何时刻,即 LimitN不为零时,都无须额外的网络访问,因为计算 LimitN是完全可以在本地完成的。

# 并发性能和限流效果上 => 折衷 No Silver Bullet
基于额度的限流方案对限流的精确度有一定的影响,可能存在业务操作已经进行了一部分服务调用,却无法从令牌桶中再获取到新额度,因“资金链断裂”而导致业务操作失败。这种失败的代价是比较高昂的,它白白浪费了部分已经完成了的服务资源,但总体来说,它仍是一种并发性能和限流效果上都相对折衷可行的分布式限流方案。
上一节提到过,对于分布式系统容错是必须要有、无法妥协的措施。
但限流与容错不一样,做分布式限流从不追求“越彻底越好”,往往需要权衡方案的代价与收益。

可靠通讯

服务安全

建立信任

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 私密 ×
# 权威 √ => PKI
零信任网络里不存在默认的信任关系,一切服务调用、资源访问成功与否,均需以调用者与提供者间已建立的信任关系为前提。
此前我们曾讨论过,
+ 真实世界里,能够达成信任的基本途径不外乎基于共同私密信息的信任和基于权威公证人的信任两种;
+ 网络世界里,因为客户端和服务端之间一般没有什么共同私密信息,所以真正能采用的就只能是基于权威公证人的信任,它有个标准的名字:公开密钥基础设施(Public Key Infrastructure,PKI)。

PKI 是构建传输安全层(Transport Layer Security,TLS)的必要基础。

# TLS 防监听,防篡改
在任何网络设施都不可信任的假设前提下,无论是 DNS 服务器、代理服务器、负载均衡器还是路由器,传输路径上的每一个节点都有可能监听或者篡改通信双方传输的信息。
要保证通信过程不受到中间人攻击的威胁,启用 TLS 对传输通道本身进行加密,让发送者发出的内容只有接受者可以解密是唯一具备可行性的方案。
建立 TLS 传输,说起来似乎不复杂,只要在部署服务器时预置好CA 根证书,以后用该 CA 为部署的服务签发 TLS 证书便是。
但落到实际操作上,这事情就属于典型的“必须集中在基础设施中自动进行的安全策略实施点”,面对数量庞大且能够自动扩缩的服务节点,依赖运维人员手工去部署和轮换根证书必定是难以为继的。
除了随服务节点动态扩缩而来的运维压力外,微服务中 TLS 认证的频次也显著高于传统的应用,比起公众互联网中主流单向的 TLS 认证,
在零信任网络中,往往要启用双向 TLS 认证(Mutual TLS Authentication,常简写为 mTLS),即不仅要确认服务端的身份,还需要确认调用者的身份。
# 防止伪Server
+ 单向 TLS 认证:只需要服务端提供证书,客户端通过服务端证书验证服务器的身份,但服务器并不验证客户端的身份。单向 TLS 用于公开的服务,即任何客户端都被允许连接到服务进行访问,它保护的重点是客户端免遭冒牌服务器的欺骗。
# 防止伪Server、伪Client
+ 双向 TLS 认证(单向增强版):客户端、服务端双方都要提供证书,双方各自通过对方提供的证书来验证对方的身份。双向 TLS 用于私密的服务,即服务只允许特定身份的客户端访问,它除了保护客户端不连接到冒牌服务器外,也保护服务端不遭到非法用户的越权访问。

对于以上提到的围绕 TLS 而展开的密钥生成、证书分发、签名请求(Certificate Signing Request,CSR)、更新轮换等是一套操作起来非常繁琐的流程,稍有疏忽就会产生安全漏洞,所以尽管理论上可行,但实践中如果没有自动化的基础设施的支持,仅靠应用程序和运维人员的努力,是很难成功实施零信任安全模型的。下面我们结合 Fenix's Bookstore 的代码,聚焦于“认证”和“授权”两个最基本的安全需求,看它们在微服务架构下,有或者没有基础设施支持时,各是如何实现的。

可观测性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 职责:记录离散事件
# 输出容易,收集分析复杂
日志(Logging):日志的职责是记录离散事件,通过这些记录事后分析出程序的行为,譬如曾经调用过什么方法,曾经操作过哪些数据,等等。打印日志被认为是程序中最简单的工作之一,调试问题时常有人会说“当初这里记得打点日志就好了”,可见这就是一项举手之劳的任务。输出日志的确很容易,但收集和分析日志却可能会很复杂,面对成千上万的集群节点,面对迅速滚动的事件信息,面对数以 TB 计算的文本,传输与归集都并不简单。对大多数程序员来说,分析日志也许就是最常遇见也最有实践可行性的“大数据系统”了。

# 目的:排除故障
# 单体=>栈追踪,分布式=>全链路追踪
追踪(Tracing):单体系统时代追踪的范畴基本只局限于栈追踪(Stack Tracing),调试程序时,在 IDE 打个断点,看到的 Call Stack 视图上的内容便是追踪;编写代码时,处理异常调用了 Exception::printStackTrace()方法,它输出的堆栈信息也是追踪。微服务时代,追踪就不只局限于调用栈了,一个外部请求需要内部若干服务的联动响应,这时候完整的调用轨迹将跨越多个服务,同时包括服务间的网络传输信息与各个服务内部的调用堆栈信息,因此,分布式系统中的追踪在国内常被称为“全链路追踪”(后文就直接称“链路追踪”了),许多资料中也称它为“分布式追踪”(Distributed Tracing)。追踪的主要目的是排查故障,如分析调用链的哪一部分、哪个方法出现错误或阻塞,输入输出是否符合预期,等等。

# 目的:监控和预警
# Java -> JMX
度量(Metrics):度量是指对系统中某一类信息的统计聚合。譬如,证券市场的每一只股票都会定期公布财务报表,通过财报上的营收、净利、毛利、资产、负债等等一系列数据来体现过去一个财务周期中公司的经营状况,这便是一种信息聚合。Java 天生自带有一种基本的度量,就是由虚拟机直接提供的 JMX(Java Management eXtensions)度量,诸如内存大小、各分代的用量、峰值的线程数、垃圾收集的吞吐量、频率,等等都可以从 JMX 中获得。度量的主要目的是监控(Monitoring)和预警(Alert),如某些度量指标达到风险阈值时触发事件,以便自动处理或者提醒管理员介入。


# 日志收集和分析:ELK -> EFK
在工业界,目前针对可观测性的产品已经是一片红海,经过多年的角逐,日志、度量两个领域的胜利者算是基本尘埃落定。
日志收集和分析大多被统一到 Elastic Stack(ELK)技术栈上,如果说未来还能出现什么变化的话,也就是其中的 Logstash 能看到有被 Fluentd 取代的趋势,让 ELK 变成 EFK,但整套 Elastic Stack 技术栈的地位已是相当稳固。

# 度量 Zabbix -> Prometheus
度量方面,跟随着 Kubernetes 统一容器编排的步伐,Prometheus 也击败了度量领域里以 Zabbix 为代表的众多前辈,即将成为云原生时代度量监控的事实标准,虽然从市场角度来说 Prometheus 还没有达到 Kubernetes 那种“拔剑四顾,举世无敌”的程度,但是从社区活跃度上看,Prometheus 已占有绝对的优势,在 Google 和 CNCF 的推动下,未来前途可期。
额外知识:Kubernetes 与 Prometheus 的关系
Kubernetes 是 CNCF 第一个孵化成功的项目,Prometheus 是 CNCF 第二个孵化成功的项目。(哇!)
Kubernetes 起源于 Google 的编排系统 Borg,Prometheus 起源于 Google 为 Borg 做的度量监控系统 BorgMon。(原本就有啊)

# 追踪:网络协议,程序语言相关
追踪方面的情况与日志、度量有所不同,追踪是与具体网络协议、程序语言密切相关的,收集日志不必关心这段日志是由 Java 程序输出的还是由 Golang 程序输出的,对程序来说它们就只是一段非结构化文本而已,同理,度量对程序来说也只是一个个聚合的数据指标而已。
但链路追踪就不一样,各个服务之间是使用 HTTP 还是 gRPC 来进行通信会直接影响追踪的实现,各个服务是使用 Java、Golang 还是 Node.js 来编写,也会直接影响到进程内调用栈的追踪方式。
这决定了追踪工具本身有较强的侵入性,通常是以插件式的探针来实现;也决定了追踪领域很难出现一家独大的情况,通常要有多种产品来针对不同的语言和网络。
近年来各种链路追踪产品层出不穷,市面上主流的工具既有像 Datadog 这样的一揽子商业方案,也有 AWS X-Ray 和 Google Stackdriver Trace 这样的云计算厂商产品,还有像 SkyWalking、Zipkin、Jaeger 这样来自开源社区的优秀产品。

如下图,CNCF Interactive Landscape中列出的日志、追踪、度量领域的著名产品,其实这里很多不同领域的产品是跨界的,
譬如 ELK 可以通过 Metricbeat 来实现度量的功能,Apache SkyWalking 的探针就有同时支持度量和追踪两方面的数据来源,由OpenTracing进化而来OpenTelemetry更是融合了日志、追踪、度量三者所长,有望成为三者兼备的统一可观测性解决方案。
本章后面的讲解,也会扣紧每个领域中最具有统治性产品来进行介绍。

在这里插入图片描述

事件日志

1
2
3
4
5
6
7
日志用来记录系统运行期间发生过的离散事件。相信没有哪一个生产系统能够缺少日志功能,然而也很少人会把日志作为多么关键功能来看待。
日志就像阳光与空气,无可或缺却不太被重视。程序员们会说日志简单,其实这是在说“打印日志”这个操作简单,打印日志的目的是为了日后从中得到有价值的信息,
而今天只要稍微复杂点的系统,尤其是复杂的分布式系统,就很难只依靠 tail、grep、awk 来从日志中挖掘信息了,往往还要有专门的全局查询和可视化功能。

此时,从打印日志到分析查询之间,还隔着收集、缓冲、聚合、加工、索引、存储等若干个步骤,如图
=> 这一整个链条中涉及大量值得注意的细节,复杂性并不亚于任何一项技术或业务功能的实现。
=> 接下来将以此为线索,以最成熟的 Elastic Stack 技术栈为例子,介绍该链条每个步骤的目的与方法。

在这里插入图片描述

输出

收集与缓冲

1
2
3
4
5
6
7
8
9
Logstash Java 1G(负重) -> Filebeat Golang(轻)


# 淘宝 10PB/天
日志收集器不仅要保证能覆盖全部数据来源,还要尽力保证日志数据的连续性,这其实并不容易做到。譬如淘宝这类大型的互联网系统,每天的日志量超过了 10,000TB(10PB)量级,日志收集器的部署实例数能到达百万量级(数据来源),此时归集到系统中的日志要与实际产生的日志保持绝对的一致性是非常困难的,也不应该为此付出过高成本。
换而言之,日志不追求绝对的完整精确,只追求在代价可承受的范围内保证尽可能地保证较高的数据质量。

# 缓冲
一种最常用的缓解压力的做法是将日志接收者从 Logstash 和 Elasticsearch 转移至抗压能力更强的队列缓存,譬如在 Logstash 之前架设一个 Kafka 或者 Redis 作为缓冲层,面对突发流量,Logstash 或 Elasticsearch 处理能力出现瓶颈时自动削峰填谷,甚至当它们短时间停顿,也不会丢失日志数据。

加工与聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 日志 -> 非结构化数据(需要做结构化处理)
将日志集中收集之后,存入 Elasticsearch 之前,一般还要对它们进行加工转换和聚合处理。
这是因为日志是非结构化数据,一行日志中通常会包含多项信息,如果不做处理,那在 Elasticsearch 就只能以全文检索的原始方式去使用日志,既不利于统计对比,也不利于条件过滤。

# 加工:非结构化 => 结构化
Logstash 的基本职能是把日志行中的非结构化数据,通过 Grok 表达式语法转换为上面表格那样的结构化数据,进行结构化的同时,
还可能会根据需要,调用其他插件来完成时间处理(统一时间格式)、类型转换(如字符串、数值的转换)、查询归类(譬如将 IP 地址根据地理信息库按省市归类)等额外处理工作,
然后以 JSON 格式输出到 Elasticsearch 中(这是最普遍的输出形式,Logstash 输出也有很多插件可以具体定制不同的格式)。
有了这些经过 Logstash 转换,已经结构化的日志,Elasticsearch 便可针对不同的数据项来建立索引,进行条件查询、统计、聚合等操作的了。

# 聚合:离散 => 统计
提到聚合,这也是 Logstash 的另一个常见职能。
日志中存储的是离散事件,离散的意思是每个事件都是相互独立的,譬如有 10 个用户访问服务,他们操作所产生的事件都在日志中会分别记录。
如果想从离散的日志中获得统计信息,譬如想知道这些用户中正常返回(200 OK)的有多少、出现异常的(500 Internal Server Error)的有多少,再生成个可视化统计图表,
+ (即时查询)一种解决方案是通过 Elasticsearch 本身的处理能力做实时的聚合统计,这很便捷,不过要消耗 Elasticsearch 服务器的运算资源。
+ (固定查询)另一种解决方案是在收集日志后自动生成某些常用的、固定的聚合指标,这种聚合就会在 Logstash 中通过聚合插件来完成。

存储与查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Elasticsearch 日志分析 -> 无可替代
经过收集、缓冲、加工、聚合的日志数据,终于可以放入 Elasticsearch 中索引存储了。
Elasticsearch 是整个 Elastic Stack 技术栈的核心,其他步骤的工具,如 Filebeat、Logstash、Kibana 都有替代品,有自由选择的余地,唯独 Elasticsearch 在日志分析这方面完全没有什么值得一提的竞争者,几乎就是解决此问题的唯一答案。
这样的结果肯定与 Elasticsearch 本身是一款优秀产品有关,然而更关键的是 Elasticsearch 的优势正好与日志分析的需求完美契合:

# 不变的时间流数据
+ 从数据特征的角度看
日志是典型的基于时间的数据流,但它与其他时间数据流,譬如你的新浪微博、微信朋友圈这种社交网络数据又稍有区别:日志虽然增长速度很快,但已写入的数据几乎没有再发生变动的可能。日志的数据特征决定了所有用于日志分析的 Elasticsearch 都会使用时间范围作为索引,根据实际数据量的大小可能是按月、按周或者按日、按时。以按日索引为例,由于你能准确地预知明天、后天的日期,因此全部索引都可以预先创建,这免去了动态创建的寻找节点、创建分片、在集群中广播变动信息等开销。又由于所有新的日志都是“今天”的日志,所以只要建立“logs_current”这样的索引别名来指向当前索引,就能避免代码因日期而变动。

# 冷热数据 => 数据挖掘
+ 从数据价值的角度看
日志基本上只会以最近的数据为检索目标,随着时间推移,早期的数据将逐渐失去价值。这点决定了可以很容易区分出冷数据和热数据,进而对不同数据采用不一样的硬件策略。譬如为热数据配备 SSD 磁盘和更好的处理器,为冷数据配备 HDD 磁盘和较弱的处理器,甚至可以放到更为廉价的对象存储(如阿里云的 OSS,腾讯云的 COS,AWS 的 S3)中归档。
注意,本节的主题是日志在可观测性方面的作用,另外还有一些基于日志的其他类型应用,譬如从日志记录的事件中去挖掘业务热点,分析用户习惯等等,这属于真正大数据挖掘的范畴,并不在我们讨论“价值”的范围之内,事实上它们更可能采用的技术栈是 HBase 与 Spark 的组合,而不是 Elastic Stack。

# ES强项:检索能力和近实时性
+ 从数据使用的角度看
分析日志很依赖全文检索和即席查询,对实时性的要求是处于实时与离线两者之间的“近实时”,即不强求日志产生后立刻能查到,但也不能接受日志产生之后按小时甚至按天的频率来更新,这些检索能力和近实时性,也正好都是 Elasticsearch 的强项。

# ES 可API查询
# Kibana 图形化 => 能力:“探索数据并可视化” 宣传语:“一张图片胜过千万行日志”
Elasticsearch 只提供了 API 层面的查询能力,它通常搭配同样出自 Elastic.co 公司的 Kibana 一起使用,可以将 Kibana 视为 Elastic Stack 的可视化部分。
Kibana 尽管只负责图形界面和展示,但它提供的能力远不止让你能在界面上执行 Elasticsearch 的查询那么简单。
Kibana 宣传的核心能力是“探索数据并可视化”,即把存储在 Elasticsearch 中的数据被检索、聚合、统计后,定制形成各种图形、表格、指标、统计,以此观察系统的运行状态,找出日志事件中潜藏的规律和隐患。
按 Kibana 官方的宣传语来说就是“一张图片胜过千万行日志”。

在这里插入图片描述

链路追踪

追踪与跨度

1
2
3
4
5
6
# 追踪 Trace、跨度 Span
为了有效地进行分布式追踪,Dapper 提出了“追踪”与“跨度”两个概念。
从客户端发起请求抵达系统的边界开始,记录请求流经的每一个服务,直到到向客户端返回响应为止,这整个过程就称为一次“追踪”(Trace,为了不产生混淆,后文就直接使用英文 Trace 来指代了)。
由于每次 Trace 都可能会调用数量不定、坐标不定的多个服务,为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前都要先埋入一个调用记录,这个记录称为一个“跨度”(Span)。
Span 的数据结构应该足够简单,以便于能放在日志或者网络协议的报文头里;也应该足够完备,起码应含有时间戳、起止时间、Trace 的 ID、当前 Span 的 ID、父 Span 的 ID 等能够满足追踪需要的信息。
每一次 Trace 实际上都是由若干个有顺序、有层级关系的 Span 所组成一颗“追踪树”(Trace Tree)

数据收集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 基于日志的追踪(Log-Based Tracing)
思路是将 Trace、Span 等信息直接输出到应用日志中,然后随着所有节点的日志归集过程汇聚到一起,再从全局日志信息中反推出完整的调用链拓扑关系。
日志追踪对网络消息完全没有侵入性,对应用程序只有很少量的侵入性,对性能影响也非常低。
但其缺点是直接依赖于日志归集过程,日志本身不追求绝对的连续与一致,这也使得基于日志的追踪往往不如其他两种追踪实现来的精准。
另外,业务服务的调用与日志的归集并不是同时完成的,也通常不由同一个进程完成,有可能发生业务调用已经顺利结束了,但由于日志归集不及时或者精度丢失,导致日志出现延迟或缺失记录,进而产生追踪失真。
这也是前面介绍 Elastic Stack 时提到的观点,ELK 在日志、追踪和度量方面都可以发挥作用,这对中小型应用确实有一定便利,但是大型系统最好还是由专业的工具做专业的事。
日志追踪的代表产品是 Spring Cloud Sleuth,在 Sleuth 调用时自动生成的日志记录中,可以观察到 TraceID、SpanID、父 SpanID 等追踪信息。

# 基于服务的追踪(Service-Based Tracing)
# 探针 Probe => Java Agent
基于服务的追踪是目前最为常见的追踪实现方式,被 Zipkin、SkyWalking、Pinpoint 等主流追踪系统广泛采用。
服务追踪的实现思路是通过某些手段给目标应用注入追踪探针(Probe),针对 Java 应用一般就是通过 Java Agent 注入的。
探针在结构上可视为一个寄生在目标服务身上的小型微服务系统,它一般会有自己专用的服务注册、心跳检测等功能,有专门的数据收集协议,把从目标系统中监控得到的服务调用信息,通过另一次独立的 HTTP 或者 RPC 请求发送给追踪系统。
因此,基于服务的追踪会比基于日志的追踪消耗更多的资源,也有更强的侵入性,换来的收益是追踪的精确性与稳定性都有所保证,不必再依靠日志归集来传输追踪数据。

# 基于边车代理的追踪(Sidecar-Based Tracing)
基于边车代理的追踪是服务网格的专属方案,也是最理想的分布式追踪模型,它对应用完全透明,无论是日志还是服务本身都不会有任何变化;
它与程序语言无关,无论应用采用什么编程语言实现,只要它还是通过网络(HTTP 或者 gRPC)来访问服务就可以被追踪到;
它有自己独立的数据通道,追踪数据通过控制平面进行上报,避免了追踪对程序通信或者日志归集的依赖和干扰,保证了最佳的精确性。
如果要说这种追踪实现方式还有什么缺点的话,那就是服务网格现在还不够普及,未来随着云原生的发展,相信它会成为追踪系统的主流实现方式之一。
还有就是边车代理本身的对应用透明的工作原理决定了它只能实现服务调用层面的追踪,像上面基于服务追踪的那样本地方法调用级别的追踪诊断是做不到的。

现在市场占有率最高的边车代理Envoy就提供了相对完善的追踪功能,但没有提供自己的界面端和存储端,所以 Envoy 和 Sleuth 一样都属于狭义的追踪系统,
需要配合专门的 UI 与存储来使用,现在 SkyWalking、Zipkin、Jaeger、LightStep Tracing等系统都可以接受来自于 Envoy 的追踪数据,充当它的界面端。

聚合度量

1
2
3
4
5
6
7
8
9
10
11
12
13
度量(Metrics)的目的是揭示系统的总体运行状态。

相信大家应该见过这样的场景:舰船的驾驶舱或者卫星发射中心的控制室,在整个房间最显眼的位置,布满整面墙壁的巨型屏幕里显示着一个个指示器、仪表板与统计图表,沉稳端坐中央的指挥官看着屏幕上闪烁变化的指标,果断决策,下达命令...如果以上场景被改成指挥官双手在键盘上飞舞,双眼紧盯着日志或者追踪系统,试图判断出系统工作是否正常。这光想像一下,都能感觉到一股身份与行为不一致的违和气息。

# 度量 => 聚合后的数据
由此可见度量与日志、追踪的差别,度量是用经过聚合统计后的高维度信息,以最简单直观的形式来总结复杂的过程,为监控、预警提供决策支持。

如果你人生经历比较平淡,没有驾驶航母的经验,甚至连一颗卫星或者导弹都没有发射过,那就只好打开电脑,按CTRL+ALT+DEL呼出任务管理器,看看下图这个熟悉的界面,它也是一个非常具有代表性的度量系统。

# 度量三过程 => 客户端的指标收集、服务端的存储查询、终端的监控预警
度量总体上可分为客户端的指标收集、服务端的存储查询以及终端的监控预警三个相对独立的过程,每个过程在系统中一般也会设置对应的组件来实现,你不妨现在先翻到下面,看一眼 Prometheus 的组件流程图作为例子,图中在 Prometheus Server 左边的部分都属于客户端过程,右边的部分就属于终端过程。

Prometheus在度量领域的统治力虽然还暂时不如日志领域中 Elastic Stack 的统治地位那么稳固,但在云原生时代里,基本也已经能算是事实标准了,接下来,笔者将主要以 Prometheus 为例,介绍这三部分组件的总体思路、大致内容与理论标准。

在这里插入图片描述

指标收集

1
2
3
4
5
6
7
# 推 push、拉 pull
对于“如何将这些指标告诉服务端”这个问题,通常有两种解决方案:拉取式采集(Pull-Based Metrics Collection)和推送式采集(Push-Based Metrics Collection)。
+ 所谓 Pull 是指度量系统主动从目标系统中拉取指标,
+ Push 就是由目标系统主动向度量系统推送指标。
这两种方式并没有绝对的好坏优劣,以前很多老牌的度量系统,如Ganglia、Graphite、StatsD等是基于 Push 的,
而以 Prometheus、Datadog、Collectd为代表的另一派度量系统则青睐 Pull 式采集(Prometheus 官方解释选择 Pull 的原因)。
Push 还是 Pull 的权衡,不仅仅在度量中才有,所有涉及客户端和服务端通讯的场景,都会涉及该谁主动的问题,之前讲的追踪系统也是如此。

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
一般来说,度量系统只会支持其中一种指标采集方式,因为度量系统的网络连接数量,以及对应的线程或者协程数可能非常庞大,如何采集指标将直接影响到整个度量系统的架构设计。

# 不但能拉,还兼推(Push Gateway)
Prometheus 基于 Pull 架构的同时还能够有限度地兼容 Push 式采集,是因为它有 Push Gateway 的存在。
如上图所示,这是一个位于 Prometheus Server 外部的相对独立的中介模块,将外部推送来的指标放到 Push Gateway 中暂存,然后再等候 Prometheus Server 从 Push Gateway 中去拉取。

# Push Gateway => 解决 pull 缺陷
Prometheus 设计 Push Gateway 的本意是为了解决 Pull 的一些固有缺陷:
# 不能访问目标服务
+ 目标系统位于内网,通过 NAT 访问外网,外网的 Prometheus 是无法主动连接目标系统的,这就只能由目标系统主动推送数据;
# 生命周期短
+ 某些小型短生命周期服务,可能还等不及 Prometheus 来拉取,服务就已经结束运行了,因此也只能由服务自己 Push 来保证度量的及时和准确。

由推和拉决定该谁主动以后,另一个问题是指标应该以怎样的网络访问协议、取数接口、数据结构来获取?如同计算机科学中其他这类的问题类似,一贯的解决方向是“定义规范”,应该由行业组织和主流厂商一起协商出专门用于度量的协议,目标系统按照协议与度量系统交互。
譬如,网络管理中的SNMP、Windows 硬件的WMI、以及此前提到的 Java 的JMX都属于这种思路的产物。
但是,定义标准这个办法在度量领域中就不是那么有效,上述列举的度量协议,只在特定的一块小块领域上流行过。原因一方面是业务系统要使用这些协议并不容易,你可以想像一下,让订单金额存到 SNMP 中,让基于 Golang 实现的系统把指标放到 JMX Bean 里,即便技术上可行,这也不像是正常程序员会干的事;
另一方面,度量系统又不会甘心局限于某个领域,成为某项业务的附属品。
度量面向的是广义上的信息系统,横跨存储(日志、文件、数据库)、通讯(消息、网络)、中间件(HTTP 服务、API 服务),直到系统本身的业务指标,甚至还会包括度量系统本身(部署两个独立的 Prometheus 互监是很常见的)。
所以,上面这些度量协议其实都没有成为最正确答案的希望。

# Zabbix 多协议
# Prometheus 只用 HTTP(要么 Exporter)
+ 老牌的 Zabbix 就选择同时支持了 SNMP、JMX、IPMI 等多种不同的度量协议
+ 以 Prometheus 为代表就相对强硬,选择任何一种协议都不去支持,只允许通过 HTTP 访问度量端点这一种访问方式。
如果目标提供了 HTTP 的度量端点(如 Kubernetes、Etcd 等本身就带有 Prometheus 的 Client Library)就直接访问,否则就需要一个专门的 Exporter 来充当媒介。

Exporter 是 Prometheus 提出的概念,它是目标应用的代表,既可以独立运行,也可以与应用运行在同一个进程中,只要集成 Prometheus 的 Client Library 便可。
Exporter 以 HTTP 协议(Prometheus 在 2.0 版本之前支持过 Protocol Buffer,目前已不再支持)返回符合 Prometheus 格式要求的文本数据给 Prometheus 服务器。
得益于 Prometheus 的良好社区生态,现在已经有大量各种用途的 Exporter,让 Prometheus 的监控范围几乎能涵盖所有用户所关心的目标。
绝大多数用户都只需要针对自己系统业务方面的度量指标编写 Exporter 即可。

存储查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 关系型数据库 => 不适合度量
指标从目标系统采集过来之后,应存储在度量系统中,以便被后续的分析界面、监控预警所使用。
存储数据对于计算机软件来说是司空见惯的操作,但如果用传统关系数据库的思路来解决度量系统的存储,效果可能不会太理想。

举个例子,假设你建设一个中等规模的、有着 200 个节点的微服务系统,每个节点要采集的存储、网络、中间件和业务等各种指标加一起,也按 200 个来计算,监控的频率如果按秒为单位的话,一天时间内就会产生超过 34 亿条记录,这很大概率会出乎你的意料之外:
200(节点)× 200(指标)× 86400(秒)= 3,456,000,000(记录)
大多数这种 200 节点规模的系统,本身一天的业务发生数据都远到不了 34 亿条,建设度量系统,肯定不能让度量反倒成了业务系统的负担,可见,度量的存储是需要专门研究解决的问题。
至于如何解决,让我们先来观察一段 Prometheus 的真实度量数据,如下所示:
{
// 时间戳
"timestamp": 1660819529,
// 指标名称
"metric": "total_website_visitors",
// 标签组
"tags": {
"host": "xxx.com",
"job": "prometheus"
},
// 指标值
"value": 10086
}

# 时序数据库
观察这段度量数据的特征:每一个度量指标由时间戳、名称、值和一组标签构成,除了时间之外,指标不与任何其他因素相关。
指标的数据总量固然是不小的,但它没有嵌套、没有关联、没有主外键,不必关心范式和事务,这些都是可以针对性优化的地方。
事实上,业界早就已经存在了专门针对该类型数据的数据库了,即“时序数据库”(Time Series Database)。
------
额外知识:时序数据库

# 时间建立索引
时序数据库用于存储跟随时间而变化的数据,并且以时间(时间点或者时间区间)来建立索引的数据库。

# 时序库由来
时序数据库最早是应用于工业(电力行业、化工行业)应用的各类型实时监测、检查与分析设备所采集、产生的数据,
这些工业数据的典型特点是产生频率快(每一个监测点一秒钟内可产生多条数据)、严重依赖于采集时间(每一条数据均要求对应唯一的时间)、
测点多信息量大(常规的实时监测系统均可达到成千上万的监测点,监测点每秒钟都在产生数据)。

# 时序库特性
时间序列数据是历史烙印,具有不变性,、唯一性、有序性。
时序数据库同时具有数据结构简单,数据量大的特点。
------

# 只有插入/查询,不可删除/修改
写操作,时序数据通常只是追加,很少删改或者根本不允许删改。
针对数据热点只集中在近期数据、多写少读、几乎不删改、数据只顺序追加这些特点,时序数据库被允许做出很激进的存储、访问和保留策略(Retention Policies):

# 1. 存储:LSM-Tree (适合写多读少,几乎不删改)
以日志结构的合并树(Log Structured Merge Tree,LSM-Tree)代替传统关系型数据库中的B+Tree作为存储结构,LSM 适合的应用场景就是写多读少,且几乎不删改的数据。

# TTL 自动删除
设置激进的数据保留策略,譬如根据过期时间(TTL)自动删除相关数据以节省存储空间,同时提高查询性能。对于普通数据库来说,数据会存储一段时间后就会被自动删除这种事情是不可想象的。

# 再采样:节省空间
对数据进行再采样(Resampling)以节省空间,譬如最近几天的数据可能需要精确到秒,而查询一个月前的数据时,只需要精确到天,查询一年前的数据时,只要精确到周就够了,这样将数据重新采样汇总就可以极大节省存储空间。

# 特殊的时序库:环形缓冲 => 固定数量的新数据
时序数据库中甚至还有一种并不罕见却更加极端的形式,叫作轮替型数据库(Round Robin Database,RRD),
以环形缓冲(在“服务端缓存”一节介绍过)的思路实现,只能存储固定数量的最新数据,超期或超过容量的数据就会被轮替覆盖,因此也有着固定的数据库容量,却能接受无限量的数据输入。

# 2. 查询:PromQL
Prometheus 服务端自己就内置了一个强大时序数据库实现,“强大”并非客气,近几年它在DB-Engines的排名中不断提升,目前已经跃居时序数据库排行榜的前三。
该时序数据库提供了名为 PromQL 的数据查询语言,能对时序数据进行丰富的查询、聚合以及逻辑运算。
某些时序库(如排名第一的InfluxDB)也会提供类 SQL 风格查询,但 PromQL 不是,它是一套完全由 Prometheus 自己定制的数据查询DSL,写起来风格有点像带运算与函数支持的 CSS 选择器。
譬如要查找网站 xxx.com 访问人数,会是如下写法:
// 查询命令:
total_website_visitors{host=“xxx.com”}

// 返回结果:
total_website_visitors{host=“xxx.com”,job="prometheus"}=(10086)
通过 PromQL 可以轻易实现指标之间的运算、聚合、统计等操作,在查询界面中往往需要通过 PromQL 计算多种指标的统计结果才能满足监控的需要,语法方面的细节就不详细展开了,具体可以参考Prometheus 的文档手册。

# 时序数据库 Prometheus,关系型数据库 Zabbix
最后补充说明一下,时序数据库对度量系统来说是很合适的选择,但并不是说绝对只有用时序数据库才能解决度量指标的存储问题,Prometheus 流行之前最老牌的度量系统 Zabbix 用的就是传统关系数据库来存储指标。

监控预警

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
指标度量是手段,最终目的是做分析和预警。

界面分析和监控预警是与用户更加贴近的功能模块,但对度量系统本身而言,它们都属于相对外围的功能。与追踪系统的情况类似,
# 广义
+ 广义上的度量系统由面向目标系统进行指标采集的客户端(Client,与目标系统进程在一起的 Agent,或者代表目标系统的 Exporter 等都可归为客户端),
负责调度、存储和提供查询能力的服务端(Server,Prometheus 的服务端是带存储的,但也有很多度量服务端需要配合独立的存储来使用的),以及面向最终用户的终端(Backend,UI 界面、监控预警功能等都归为终端)组成。
# 狭义
+ 狭义上的度量系统就只包括客户端和服务端,不包含终端。

# Prometheus 官方推荐 => Prometheus + Grafana
按照定义,Prometheus 应算是处于狭义和广义的度量系统之间,尽管它确实内置了一个界面解决方案“Console Template”,以模版和 JavaScript 接口的形式提供了一系列预设的组件(菜单、图表等),让用户编写一段简单的脚本就可以实现可用的监控功能。
不过这种可用程度,往往不足以支撑正规的生产部署,只能说是为把度量功能嵌入到系统的某个子系统中提供了一定便利。
在生产环境下,大多是 Prometheus 配合 Grafana 来进行展示的,这是 Prometheus 官方推荐的组合方案,但该组合也并非唯一选择,如果要搭配 Kibana 甚至 SkyWalking(8.x 版之后的 SkyWalking 支持从 Prometheus 获取度量数据)来使用也都是完全可行的。

# 何时扩容,新旧对照,日志追踪
良好的可视化能力对于提升度量系统的产品力十分重要,长期趋势分析(譬如根据对磁盘增长趋势的观察判断什么时候需要扩容)、对照分析(譬如版本升级后对比新旧版本的性能、资源消耗等方面的差异)、故障分析(不仅从日志、追踪自底向上可以分析故障,高维度的度量指标也可能自顶向下寻找到问题的端倪)等分析工作,既需要度量指标的持续收集、统计,往往还需要对数据进行可视化,才能让人更容易地从数据中挖掘规律,毕竟数据最终还是要为人类服务的。

# 预警
除了为分析、决策、故障定位等提供支持的用户界面外,度量信息的另一种主要的消费途径是用来做预警。
譬如你希望当磁盘消耗超过 90%时给你发送一封邮件或者是一条微信消息,通知管理员过来处理,这就是一种预警。
Prometheus 提供了专门用于预警的 Alert Manager,将 Alert Manager 与 Prometheus 关联后,可以设置某个指标在多长时间内达到何种条件就会触发预警状态,触发预警后,根据路由中配置的接收器,譬如邮件接收器、Slack 接收器、微信接收器、或者更通用的WebHook接收器等来自动通知用户。

【日期标记】2022-08-18 19:00:01 以上同步完成