分布式系统一致性与共识概述

   分布式系统中,数据需要在多个节点之间进行同步。由于不可靠的网络传输,难以统一的时间戳等问题,如何保证分布式系统数据的一致性,一直是一个比较复杂的问题。本文重点介绍分布式系统一致性问题产生的原因和解决方案的发展过程,属于概述性文章,需要读者有一定的分布式基础概念的了解。
 

1. 数据复制模型

  分布式系统中,节点之间的数据同步,主要通过网络进行数据复制,常用的复制模型有三种:主从复制,多主复制,无主复制。

1.1 主从复制

  主从复制是实际场景中,使用最多的数据复制模型,往往一个主节点master配置多个从节点slave,主要的工作过程如下:
(1)指定主节点master后,所有的客户端都只能从主节点写入,主节点首先将新的数据写入本地存储。
(2)其他所有副本为从节点。主节点把数据的更改作为日志或数据流发送给从节点,每个从节点收到更新日志后,以与主节点完全相同的顺序写入本地存储
(3)主节点和从节点都可以读取数据。
MySQL的大部分部署都采用这种主从复制模型。对于主从复制,多副本可以提高系统的读QPS,但是单写限制了系统的写入QPS,适合读多写少的场景。在通常情况下,复制延迟很低,但是在网络故障或者从节点故障的情况下,数据延迟会大大增加,此时的读取很可能会获取到过期的数据。
注:分享一个笔者遇到的MySQL主从复制延迟的其他原因。众所周知,MySQL的DDL操作,会获取数据表的metalock,这个metalock会导致后续的DML操作阻塞。如果对MySQL进行了DDL的操作,而从库在执行DDL操作时,遇到了长时间事务而阻塞,这样后续的所有主从复制的DML都被阻塞,从而导致复制延迟。

1.2 多主节点复制

  多主节点复制就是有多个主节点负责数据写入每个主节点配备若干从节点。这主要用在多数据中心,每个数据中心都是一个主从复制的类型。用户可以根据地理位置,就近路由到最近的数据中心进行读写。多数据中心增加了写入节点的数量,容错能力更强,但是多主节点写入势必存在数据冲突问题。另一种常用的数据分区型数据库(比如ES),虽然拥有多个主分区(多主),但是相同的数据,通过hash路由后总是从相同的主分区写入,所以严格来说,不属于多主复制;但是这种模型,增加了写入节点的数量,同时避免了多主复制写冲突的问题,是一种广泛应用于分布式数据库的存储模型(比如HDFS, Kafka)。

1.3 无主节点复制

   前面讨论的两种复制方式,都基于相同的思路:将数据写入到主节点,由主节点决定写入的顺序,然后复制给从节点,从节点按相同的顺序更新数据。无主复制则没有主节点,写入时,通常同时写入多个节点,读取时,从多个节点读取。可以通过quorum协议,保证数据一致性。无主复制也存在多节点写入冲突的问题。同时由于只写入了部分节点,节点间需要数据同步。

1.3.1 读写quorum

   假如总共n个节点,写入时,至少保证写入w个节点才算成功;读取时,至少读取r个节点,那么如果w+r>n,就能保证至少一个读取节点包含了最新的数据。这被称为quorum机制。

1.3.2 读修复和反熵

  对于无主复制节点间数据同步,有两种方式:读修复和反熵读修复当客户端并行读取多个副本时,按照quorum,一定会读到最新的数据,此时也能知道哪些节点的数据是旧数据(通常通过version号来区别新数据和旧数据),此时,由客户端将新值同步到那些没有更新数据的节点。这种方法适合多读的场景反熵由专门的后台进程检查不同副本的差异,并修复这些差异。与主从复制不同,反熵过程写入顺序不确定,且存在明显的数据滞后问题

1.4 同步与异步复制

  在数据同步给从节点时,可以采用同步复制或异步复制的方式。同步复制,在主节点收到写入请求时,同步发送给所有从节点,从节点写入最新的修改后,返回成功消息给主节点,主节点收到各个从节点成功的消息,返回给客户端写入成功的消息(这个过程还涉及到分布式节点事务,稍后会讲到)。而异步复制,只要主节点写入成功之后即刻返回成功消息给客户端,后续的数据复制在后台进行。同步复制保证数据的强一致性,但是写入过程变得复杂,导致写入失败率增大且写入响应时间变长异步复制由于网络故障等原因,一致性相对较弱(最终一致性),在主节点故障时还可能丢失部分数据,但是写入响应时间快。实际对于一致性有要求的场合,往往采用半同步复制,一主多从,每次写入保证一定数量从节点是同步写入。如图1所示,从节点1采用同步复制,从节点2为异步复制(实际哪个节点同步复制,哪个节点异步复制是不固定的)。
 

2.1 数据一致性级别 

可线性化

  可线性化的原理非常简单,就是让整个数据系统看起来只有单一副本,一旦一个新的修改对某一个用户可见,那么它必须对所有其他用户都可见。这是最强的一致性保证。线性化的常见应用场景如下:

  • 加锁与主节点选举。在主从复制的系统中,如果主节点下线,为了避免脑裂,在主节点选举时,每个节点都会尝试获取锁,一旦一个节点获得锁,它获得锁的信息必须被其他所有节点所公认(即某个节点获得锁的信息能立即被其他节点读取到)。通常都采用zookeeper和etcd等实现分布式锁和主节点选举。
  • 唯一性约束,比如文件系统的唯一命名,电子邮箱,注册信息,银行存款余额不能为负等。这些也都需要分布式锁或者有专门的系统实现meta元信息的保存。
在第一节介绍的复制类型中,主从复制可以通过指定读节点的路由(只路由到更新后的从节点)可以实现线性化;多主复制由于写冲突和异步复制过程,是不能实现线性化的;无主复制,在强读写quorum协议下,直觉上是能保证读取最新的修改的,但这忽略了一个重要事实:quorum的描述,让我们觉得读一定是在同步写完成之后才进行的,这样当然能实现线性化;但是实际上读写是可以并发的,在并发读写时,如果写操作还在同步过程中,那么是实现不了线性化的。看下面的例子:
 

  可线性化作为最强的一致性保证,必定丧失了高性能,且实现困难。在实际的很多应用场景中,通常不用保障所有操作全序排列(可线性化即一种全序保证),而只需要具有因果关系的操作有序即可。例如图4的问答场景,问题一定是先于回答发生,这种具有因果关系的操作必须保证有序,即因果一致性。因果一致性是一种偏序关系(部分有序)。

2.2 实现一致性的过渡方案

 两阶段提交(2PC)

  两阶段提交(2 Phase Commit,  2PC)是一种在多节点多系统之间实现分布式事务的方法。流程如下图所示:

 2PC引入了一个新的组件:协调者,同时它把整个事务分成了两个阶段。在第一个阶段,协调者询问所有节点能否执行该事务,并在第二阶段决定是否真正的提交该事务(只有所有节点在第一阶段都回答yes,第二阶段才选择执行事务;超时或有节点回答no则中止该事物)。这个过程有两个承诺:

(1)一旦节点在第一阶段做出了yes的回答,则它承诺在任何环境下(包括节点崩溃),事务都能在第二阶段完成提交。

(2)一旦协调者决定提交某个事务,则它保证第二阶段的提交决定一定到达所有节点。

 正是上面的两个承诺,保证了2PC的原子性。那么在发生意外时,这两个承诺如何兑现呢?
 参与节点故障:如果参与节点在第一阶段故障,则协调者收不到所有节点的肯定回答,会终止该事务;如果参与者在第二阶段故障,协调者会保证一直重试,直到把第二阶段的请求送达到每一个节点。
 协调者故障:如果协调者在投票和决议期间故障,则节点第二阶段无法顺利进行,它必须等待直到协调者恢复,发出提交或者中止的指令。由于在这个阶段,未完成的事务持有事务对应的锁,会导致所有接下来的操作都会受到影响。
可见,2PC有严重的单点故障问题

 Lamport时间戳

  Lamport时间戳是一个保证因果一致的算法。每一个节点,都有一个自己的标志符和当前处理的请求的最大计数器值(计数器值+节点标志符共同组成时间戳),且所有节点都会更新这个最大计数器值,更新策略如下:每个客户端请求或节点都跟踪迄今为止见过的最大的计数器值,并在每个请求中附带该最大计数器的值,当节点收到某个请求时,如果发现请求的计时器值大于自己的最大计时器的值,则更新自己的计数器为该最大值。示例如下:

 

  上面讨论了节点的一致性等级和保证一致性的一些早期尝试。可见实现一致性最重要的是解决两个问题:时间戳有序和单点故障问题。而共识算法就解决了以上问题,实现了分布式系统下的一致性。

  共识算法比较常用的包括VSR, Paxos, Raft和Zab。理解每一种算法的所有细节不仅花费时间巨大,对于不是专门的分布式系统的研发人员来讲收益也甚低。而且不同的共识算法要解决的核心问题差不多,所以有很多相似之处。在此选举比较容易理解的共识算法Raft,大致讲解一下其实现一致性的几大核心机制。Raft算法本质上就是一个容错的主从复制系统。一主多从的配置最大的好处是解决了时间戳问题。这个时间戳在Raft中通过term来实现(即分布式系统中的epoch)。

3.1 Raft term

   很多分布式系统都有类似epoch的概念,在一个epoch中,leader是唯一的,当leader failover之后,会开启新的epoch(epoch单调递增)选举新的leader。这个epoch对应Raft中的term number,Paxos的ballot number。如下图所示:
 

  在Raft的主从通信过程中,有两种类型的RPC通信:Vote RPC(主节点选举投票)和AppendEntry RPC(操作数据同步和heartbeat)。一个节点有三种状态:Leader, Candidate, Follower。当初始化或者Leader 超时时,发生新的Leader选举。此时,发现超时的Follower马上变成Candidate,增加自身的term number并发送 vote RPC,发送之后有以下三种可能:

  • 收到大多数其他节点(超过半数)的同意请求(1个Follower在一个term number内只能投票一次),选举成功,成为Leader;
  • 收到少量同意请求(说明同时有其他节点成为了Candidate并发送了vote RPC),选举失败;
  • 两个Candidate的投票相同,这种情况下,选取新的随机election timeout,增加term number,发起下一次投票,这也就是图8中所展示的一个election中term number增加了多次。

注:Raft算法在每轮投票时,不同的Candidate会选取不同的election timeout,这就保证了在连续几轮投票中,同时有两个以上Candidate的概率非常低。

读者可以自行思考以下问题,加深对主节点选举过程的理解:

(1)如果在Candidate发送vote RPC的时候,旧的主节点收到了这个vote,它会怎么处理?

(2)所谓的大多数投票,是不是意味着Raft集群需要记住系统当前有多少个有效节点(比如当前5个节点,那么Leader failover之后,意味着一次投票至少收到两个同意才能成为主节点。如果Leader连同2个甚至更多Follower崩溃,导致不可能收到两个同意怎么办?当然,5个节点的集群最多只能容忍两个失效节点)。

3.3 Raft 日志复制

在阐述Raft的日志复制机制前,明确以下几个要点:

(1)日志复制是单向的,从主节点到从节点。主节点的日志只能提交不会被覆盖;从节点的日志可能会被主节点覆盖。

(2)Raft的每一条log entry都带上了term number和其位置偏移index,如图9所示:

 

图 9 每一条log entry都带上了term number和index

(3)Raft通过 election restriction 保证被选举为leader的节点的所有log entry都是提交的并且是最新的。

  首先,leader的一条AppendEntry(一条操作记录)只有在被多数(半数以上)follower复制之后才会被提交生效。每个leader在每一个AppendEntry RPC中,都会携带最新commit的最大的index以便让对应的follower完成提交。Leader的每一次AppendEntry RPC,都会携带上一条Log Entry的term number和index,如果follower发现它的log的term number和index下的数据跟Leader的不一致,就会拒绝保存该Log Entry,此时会完成一次日志修复和覆盖(通过多次AppendEntry RPC,每次携带更早的term number和index,直到往前回溯到Leader和Follower的index的数据和term number完全相同,然后在此之后的Follower的日志都会被重写以跟Leader保持一致)。这就是通用的日志复制过程。即上述要点(1)(2)。
   然后election restrction保证了被选举的leader一定是包含了最新提交的日志。election restrction实际上就是quorum协议的实现。一个日志被提交,必须保证超过一半的节点(num_replica)复制了该条AppendEntry log;而一个节点被选举为主节点,必须有超过一半的节点投票同意(num_vote)。投票同意选举的前提是Candidate的日志必须是最新的(即term number大的数据更新;term number相同时,index大的数据更新)。如果一个Follwer收到了Candidate的RequestVote RPC,但是Candidate的日志落后于自己的日志,就会拒绝该投票请求。由于num_replica+num_vote> num_server,保证了在投票选举的过程中,一定至少有一个节点同时参与了投票并包含了最新的日志。由于被选举为Leader的节点的日志比所有投票节点的日志都新(否则该投票就会被拒绝),所以Leader的日志一定是包含了所有提交的日志,且是最新的。用一个Raft论文的图来做说明:
 
posted @ 2021-12-19 19:54  晨枫1  阅读(447)  评论(0编辑  收藏  举报