前言
在写如何设计一个分布式KV存储文章的时候,写到数据复制和集群选主章节的时候,感觉没有办法完全的讲清楚逻辑,所以恶补了一番各类分布式协议和算法的,那么在这里把学习的总结贴出来,进一步总结归纳这些知识。
1 基础理论
1.1 拜占庭将军问题
该问题主要是为了解决分布式的共识问题,围绕着当集群中存在恶意节点时,应该如何解决共识问题,展开的论证和解决方法。
问题内容:
假如X个将军准备攻打一个地方,相互之间应该如何通信,来统一计划,决定进攻还是撤退。如果这X位将军中存在了Y个卧底反贼,通过发送错误的信息来扰乱计划,应该如何解决。
1.1.1 二忠一叛问题
假如有A、B、C三位将军,各位将军都做出自己的作战指令,最终的作战计划遵从少数服从多数的决定。
需要将军数量为奇数,不然会出现票数一样的情况
正常情况下,三位将军发送出去的决定消息都是一致的,进攻或撤退,例如
- A->B进攻 A->C进攻
- B->C进攻 B->A进攻
- C->A撤退 C->B撤退
这样遵从少数服从多数的原则,三人的最终决定是进攻,作战结果是一致的。
而当其中B是反贼时,B给A、C发送的消息不一样
- B->C撤退 B->A进攻
那么
- A的三个决定为[A:进攻,B:进攻,C:撤退],A最终决定进攻,
- C的三个决定为[A:进攻,B:撤退,C:撤退],C最终决定撤退;
最终C撤退,A单刀去找敌军solo去了,B完成了反贼任务,作战计划完全乱套。
那么应该怎么解决我们之中有一个叛徒这个问题呢
1.1.2 口信消息型拜占庭容错算法
当反贼数量为1时,这个算法的流程分为两轮
- 设定一个leader指挥官,由他发起作战决定,广播给所有将军
- 将军们再互相通信,将leader给来的作战决定相互发送。
下面来进行实际场景示例:
假设leader是A,剩余B、C、D三位将军、
当leader是反贼时,第一轮他给多位将军发送不同的作战决定
- A->B 进攻
- A->C 撤退
- A->D 进攻
第二轮
- D->B进攻 D->C进攻
- B->C进攻 B->D进攻
- C->D撤退 C->B撤退
最终少数服从多数,BCD三位将军决定进攻。
反过来,当反贼不是leader,而在剩余几位将军中时,一样也可以完成一致的决定,这里不再赘述。
但是这个算法的局限性在于,假如反贼数量为M,将军人数至少要3M+1,才能解决有人作恶的问题。
缺点是
- 算法的递归次数取决反贼人数,次数=反贼人数+1,需要大量的消息传递。
- 需要增加人数才能解决上述二忠一叛的问题
1.1.3 签名消息型拜占庭容错算法
该算法是为了解决口信消息型的增加将军成员问题,保证在现有的节点集合下,各忠臣将军依然能够达成作战一致。
假设依然是二忠一叛三位将军,流程不变,依然是
- 设定一个leader指挥官,由他发起作战决定,广播给所有将军
- 将军们再互相通信,将leader给来的作战决定相互发送。
通过例如非对称加密和校验和等手段保证消息发送人的真实性,以及消息没有被篡改,来消除反贼传递错误消息的行为。
然后假设对各指令都有对应的编码: 0:进攻,1:撤退
那么假如发起作战的节点A是反贼,他会发出这样的指令
- A->B进攻 A->C撤退
然后BC互相交换作战指令
- B->C进攻
- C->B撤退
BC收到的指令集合如下:
- B [A:进攻,C:撤退]
- C [A:撤退,B:进攻]
进行指令排序后
- B [0:进攻,1:撤退]
- C [0:进攻,1:撤退]
以一定规则选取一个指定:假如选取最后一个指令,撤退;选取第一个指令,进攻;这样就能够保证忠臣的作战计划一致了。
当反贼非leader节点时,它没有办法篡改作战指令,只能进行转发,依然能够保证让各位忠臣将军达成作战计划一致。
1.1.4 小节
以上,就描述完了拜占庭将军问题,以及相应的拜占庭容错算法逻辑。
不过有拜占庭容错算法,也就有非拜占庭容错算法,两者的区别是什么呢?
- 拜占庭容错算法:指允许存在作恶节点,并能够通过一些措施来处理作恶节点的算法,例如在区块链场景中;
- 非拜占庭容错算法:没有办法处理处理存在作恶节点的情况,必须都是“自己人”,例如公司或个人的内部集群;
1.2 Basic-Paxos
目的是,多节点如何就某个值来达成共识。
节点角色有三种
- 提议者
- 接受者
- 学习者
提议者是发出提案的leader,也就是接受客户端请求的节点。接受者相当于负责投票并存储提议值的副本节点。学习者仅同步最终投票的结果,没有投票权,属于备份。
共识的达成分为两个阶段
- 准备阶段
- 接受阶段
依然假设有ABC三个节点,A节点是提议者
在准备阶段的时候,A向BC发起提案3,仅携带编号、不携带值,先与各个节点达成编号的共识。
- 若BC节点此时未接受任何提案,则返回尚无提案,并设置当前准备中的提案号为3,并且不会接受小于1的提案
- 若BC节点此前接受过其他编号的提案,例如之前编号为5,5 > 3则丢弃该请求;若之前编号为1,1<3则执行第一步的逻辑,更新提案号为3。
接受阶段,A若能收到大多数据节点的响应,则发起请求,将提案编号和实际的值发送给BC
- 若BC在响应提案3后,又接收到了更大编号的提案,则丢弃请求
- 若BC当前提案编号仍为3,则接受该请求,并将请求中的值应用到自身
这样多节点之间就完成了对某一个值的共识。
1.3 Multi-Paxos
在basic paxos基础上,Multi-Paxos关心是一系列的值达成共识,可以理解为执行多次basic paxos。
它不关心这些值具体是什么,也不考虑顺序性。
如果只是单纯的执行多次basic paxos那么会存在如下问题
- 同一时间内可能多个节点都发起了提案,导致一直都没有办法达成共识;
- 两阶段提交需要和每个节点都通讯两次,对资源的消耗问题;
第一个问题可以通过引入leader节点来解决,保证同一时间只有一个节点进行提案。
引入leader节点同时也能优化第二个问题。由于提案者只有一个,不再需要准备阶段来进行编号提案,直接进行<编号,值>的接受阶段,若大多数节点完成了接受,则表示共识达成。
缺点是所有写请求都要由leader来处理,存在单机的瓶颈问题。
2 常用协议
2.1 ZAB
ZAB协议起源于ZooKeeper中,ZK在2007年被开发出来。
ZAB协议是一个能保证操作顺序性的,以leader为准的,基于主备模式的原子广播协议。它与zk强耦合,无法单独使用。
2.1.2 如何保证顺序性
由于Multi-Paxos不能保证操作的顺序性,ZAB协议为了解决这个问题, 它使用了TCP协议进行和副本通讯提案,串行了提案操作,前一个操作未commit,后一个操作需要排队。
2.1.3 数据一致性
提供的是最终一致性,读操作在任意节点行执行,可能会读到旧数据。
2.1.4 领导选举
各节点的角色共四种
- 选举者
- 跟随者
- 领导者
- 观察者
观察者状态相当于学习者,仅仅用作数据复制备份,不参与投票。
正常运行的集群最多有跟随者、领导者、观察者三种状态。
只有当leader发生问题,各节点需要重新竞选leader时,跟随者会将自身的状态变更为选举状态。
过程如下:
首先,各个节点分别以RPC请求的方式发起提议,提议自己为leader,
请求中携带着自己的选票,选票中写着选举PK需要的数据项,分别为:
- 节点id(假如集群时的节点ID)
- 日志seq(递增的一个日志编号)
- 任期编号(第几任领导)
- 逻辑时钟(选举的第几轮)
- 节点名称(A、B、C这种名称)
选举的目的是在集群中选举出来最适合当leader的节点,所以条件PK的顺序为:
- 先判断逻辑时钟(确认当前收到的投票,是否为最新的轮次)
- 再检查任期编号,大的作为领导者
- 前面条件相同时,判断最新的事务日志seq编号
- 前面条件相同时,比较集群ID,大的作为领导者
若某节点收到的选举请求比自己更适合当leader,它会更新自己的选票为对方的选票,也就是将票投给了对方。然后将选票结果重新发送出去。
然后,各个节点判断自己是否收集够了超过半数节点的投票,若已达标,则该节点成为leader状态;若当前轮次,没有节点成功成为leader,则将逻辑时钟+1,开始下一轮的投票。
当leader成功选举出来之后,不是直接开始处理请求工作,而是要先进入成员发现阶段和数据同步阶段。
这里还要提一下ZAB中还定义了四种状态
- 选举状态:代表节点正在进行选举
- 成员发现状态
- 数据同步状态
- 广播状态:代表进入了正常工作状态
中间的两种状态就是刚刚被选举出来的leader的中间过渡状态,它的目的主要是消除各个节点的数据不一致,确保数据完全一致后,才允许leader正式工作接收处理请求。
新leader选举成功后,先进入成员发现阶段,与各个节点建立连接、二次确认投票状态
具体流程如下:
- follower主动发起FOLLOEWINFO消息,消息中包含前任领导的任期编号;
- leader返回LEADERINFO,主要包含当前的任期编号;
- follower确认新leader的任期编号是否为,全新的最大的任期编号;
- 若不是,则会发起新的一轮选举;
- 若是,则返回ACKEPOCH,进入数据同步状态;
- 如果大多数成员已经进入到了数据同步状态,则leader也将自己设为数据同步状态;
数据同步阶段是就开始了实际的数据同步操作,过程中会以leader的数据为准进行数据覆盖,来保证各个节点的数据完全一致。
当将数据修复完成后,跟随者节点会向leader发送NEWLEADER消息,当leader收到大多数成员的NEWLEADER消息后,进入广播状态,并发送UPTODATE通知成员数据同步阶段完成,成员也设置为广播状态。
至此,选举彻底完成,集群进入正常工作状态。
2.2 Raft
Raft是分布式系统开发的首选的共识算法,它在multi-Paxos思想的基础上,做了一些简化。和ZAB协议类似,也是一切以leader为准的共识算法,又leader单点写入,再向各副本进行同步,借此来保证各节点的日志一致。
2.2.1 领导选举
各成员有三种身份:
- leader 领导者
- follower 跟随者
- candidate 候选人
正常运行的集群下,只有leader和follower两种角色,当follower超过超时时间,仍未收到leader的心跳包时,它的身份会转化为候选人进行发起投票选举,选举出一个新的leader。
各节点的超时时间随机,大小在150ms ~ 300ms之间。
节点间通过RPC方式进行通讯,有如下两种RPC请求
- 投票RPC
- 日志复制RPC(仅leader节点可发起)
选票RPC请求包含如下内容
- 任期编号(第X任leader)
- 最大日志seq号
规则先判断日志完整度,即判断日志seq号,再判断任期编号,逻辑如下:
-
RCP请求中的seq < 当前节点的最大seq,则直接拒绝这个消息
-
若seq大于当前节点的seq号,再进行任期编号判断
-
RCP请求中的任期< 当前节点的任期编号,则直接拒绝这个消息
-
当前节点的任期编号 < 收到的RPC请求中的任期编号,立即恢复为follower,并更新自己的任期编号
-
在一个随机的时间窗口内,获得大多数选票的候选人,成为新的leader
-
超过窗口仍未获得半票数据,选举无效,发起新的一轮选举
每个节点仅有一次投票权,投出去之后,不再进行投票给其他节点。成为新的leader后,除非leader自身发生问题,导致follower节点没有收到心跳包,否则将一直是leader。
相比较与上面提到的ZAB协议,raft协议省去了成员发现阶段和数据同步阶段,选举出来的leader直接开始处理请求,减少了服务的不可用时间。它通过日志复制的RPC请求,来发现follower和leader日志不一样的节点,然后以leader的日志为准进行覆盖,最终实现各节点日志的一致。在下面的日志复制小节,详细展开。
2.2.2 日志复制
raft日志中的每条日志主要包括
- 指令
- 索引值(seq号)
- 任期编号(term)
正常的日志复制是一个二阶段提交的过程,即
- 向各节点同步日志(提案)
- 收到大多数节点的成功消息,将日志应用到自身的状态机
- 向各节点同步日志commit消息
- 各节点将日志应用到自身的状态机
而raft将两阶段提交优化成为了一阶段提交,第3步的commit通知改为了通过日志复制RPC、心跳消息来同步leader的commit进度,从而减少了一次和各节点的网络通信。
当产生了新leader或者follower发生了宕机,需要重新进行日志同步的时候,所有follower都以leader的日志为准。 即找到和leader相同的日志位置(term和seq都一样),然后将leader中的日志覆盖给follower,来保证leader和follower的日志一致。
如果在leader将日志应用到自身状态机后,马上宕机,无法向follower节点同步日志commit位置,这时这条日志是否失效了呢?
答案是否定的,它是有效的。
因为在选票RPC请求中携带的最大日志seq号是包含了未提交的,所以选举出来的leader一定是包含日志最全的一个节点,即一定包含这条日志。
那么新选举出来的leader如果发现某个seq号的日志已经被同步到大多数节点上,则默认将此seq号日志应用到自身状态机,并向其他节点同步提交消息。
2.2.3 成员变更
新增节点的流程如下:
- 部署新节点
- 新节点与leader建立链接
- leader向新节点同步数据
- leader向所有节点广播新的成员集合
为了避免新加入的N个节点时发生了网络分区,产生了两个大多数获取选票的leader,发生脑裂问题。故在加入成员时需要逐个串行加入,确定该节点加入成功后在进行加入下一个。
2.2.4 落地应用
- ETCD
- redis主备选主
- ...
2.3 Quorum NWR
通常用于AP型的分布式系统中,使系统具备灵活自定义一致性级别的能力。
关键在于NWR这三个字母的定义
- N是代表集群中总副本的个数
- W是一次写入的副本个数
- R是读取时,要读取的节点个数
举例说明,假如N为3,W为2,R为2
写入时,一次写入要同时完成2个副本的更新才算成功; 读取时,要同时读取2个副本并选择其中最新的值。
当W + R > N时,能够保证读取一定能够读到最新数据的节点,也就保证了强一致性
当W + R <= N时,不能够保证读取时,一定能读到最新数据的节点,也就是保证了最终一致性
若想保证读性能,则需要尽量让读请求读取较少的节点就能够读到最新的数据,也就是W=N,这样R=1即可获取到最新的数据。
若想保证写性能,则让W=1,R=N,仅写入一个节点,读请求时读取所有节点来获取最新的数据。
若想保证容错性,则W和R都要超过半数节点,例如N为3,则W和R都为2,这样一个副本节点宕机,仍然能保证数据不丢失,能读到最新的数据。
W和R可以通过在系统的配置中定义,让业务方结合业务场景来设定具体的值,将决定权交给了业务方。
落地应用
- InfluxDB企业版
- ...
2.4 Gossip
别名流言蜚语协议,所有节点都接收和处理请求,通过节点间的信息相互传播,在一定的时间后使各节点的数据达到一致。
和zk、raft集群一样,依然存在leader节点,但是这个leader节点仅负责一些决策能力,例如加入和剔除节点。
2.4.1 数据一致性级别
也是一个最终一致性的协议,当多个节点交换数据时,发现同一份数据的value不一样时,以最新的为准。
2.4.2 如何实现数据一致
该协议主要依靠了他的三个能力
- 直接邮寄
- 反熵
- 谣言传播
直接邮寄:当集群中的某个节点执行了数据更新操作后,直接将数据更新通知给其他节点。如果通知失败,则在本地缓存起来,然后执行重传。但是由于缓冲队列大小有限,当超出阈值后,数据可能丢失。
反熵:节点每隔一定的时间就和其他节点进行通讯,互相交换数据,来消除彼此的数据差异达到数据一致。 通讯的方式有三种:
- 推:推自己的数据给对方
- 拉:拉对方的数据过来
- 推拉结合:推拉的同步结合
在工程上的实现,可能不是随机的通讯,而是节点间连成一个闭环,一次性修复所有节点的数据不一致。对于快速对比数据是否一致,可以使用如奇偶校验、CRC校验和格雷码校验等方式。但是纠错依然需要数据的传递。
谣言传播:为了解决集群节点数过多以及节点动态变化的问题,当某个节点执行了数据更新操作后(被邮寄),它会变为活跃状态,周期性的向其他节点发送新数据,让集群里的每个节点都收到这个新数据。
后两种行为,个人理解相当于为直接邮寄的实施的补充措施。
2.4.3 落地应用
- Akka
- redis cluster模式的分片信息同步
- Cassandra的状态同步
- ...
2.5 PBFT
全称为Practical Byzantine-Fault-Tolerant,即实用拜占庭容错算法。
在口信消息型拜占庭容错算法中,有一些局限的地方
- 多节点可以就提议的值达成共识,但是它不关心最终的共识结果是否是正确的。
- 消息传递随叛军数量指数级递增,假设叛军数量为X,消息复杂度为O(n^(X+1))
而PBFT则能够保证最终达成共识的值是正确的,在区块链中被广泛应用。
2.5.1 如何达成共识
达成共识采用了三阶段协议
- 预准备阶段
- 准备阶段
- 提交请求阶段
具体流程如下:
- 客户端发送请求,主节点接收到请求后,将其广播给集群中所有节点;
- 集群中的每个节点收到了请求后,都将自己收到的请求广播给所有其他节点;
- 各节点根据收到的大多数决定,决定自己是否执行,并将最终决定广播给其他节点;
- 大多数节点到此阶段已经达成共识,将结果返回给客户端;
- 客户端根据响应的大多数结果,来判定该请求的最终结果;
使用了数字签名和消息验证码来保证不被恶意节点伪造消息,约束了恶意节点的行为。
其中视图变更消息和新视图消息使用签名消息,其他消息则采用了消息验证码。
在确保消息是真实的前提下,再根据大多数原则判定执行的最终结果。
它的消息复杂度是O(n^2),虽然相比之下降低了不少,但是当集群规模增长时,消息的增长量对网络的冲击依然不容小觑。 所以PBFT算法更适合于中小型分布式系统。
2.5.2 领导节点更换
当领导节点作恶的时候,通过两种行为触发
- 超时未收到消息
- 相同的视图值和序号,但是内容摘要不同
即作恶节点要么不转发消息,要么转发错误的消息,都会触发领导节点变更。
发生上述两种行为时,即认为主节点是作恶节点,情况可能如下
- 客户端请求主节点,主节点不转发,客户端直接请求备份节点,备份节点将消息抓发给主节点。如果主节点依然不转发,则认为主节点已经叛变,需要进行视图变更,选举新的主节点。
- 在准备阶段中,备份节点发送了准备消息后,超时未接收到2X个相同的准备消息
- 在提交阶段中,备份节点发送了提交消息后,超时未接收到2X个相同的提交消息
- 备份节点收到了相同的视图值和序号,但是内容摘要不同的消息
2.5.3 落地应用
用于区块链中实现共识。
2.6 POW
Proof of work 工作量证明算法。和PBFT算法一样,也属于拜占庭容错算法,能够处理存在作恶节点的问题,通常用于区块链新增节点的验证。
核心目的是通过提升恶意节点的攻击成本,来防止作恶。
提升成本的方式就是每个加入的节点要提供自己的工作量证明,例如通过为某个固定的字符串添加后坠,然后进行哈希计算,计算出来某种规则的最终哈希值(例如以0000开头的哈希值),来证明自己做的工作。
另外一个要求就是,加入方需要很高的成本自己完成了一定的工作量,验证方则需要很容易的校验就能检查工作的真实与否。
这样每个想要加入的节点,都要通过算力的消耗得到工作量的证明,然后才能加入,这样也就提升了想要加入的作恶节点的成本。
落地应用
用于区块链中识别作恶节点。
总结
以上,就是在分布式中常用的分布式协议了。
在设计分布式集群的时候,通过考量业务场景的需求,来酌情选择使用哪些协议。