分布式系统中,数据需要在多个节点之间进行同步。由于不可靠的网络传输,难以统一的时间戳等问题,如何保证分布式系统数据的一致性,一直是一个比较复杂的问题。本文重点介绍分布式系统一致性问题产生的原因和解决方案的发展过程,属于概述性文章,需要读者有一定的分布式基础概念的了解。
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为异步复制(实际哪个节点同步复制,哪个节点异步复制是不固定的)。
图1 半同步复制
1.5 复制实现方式
把数据从主节点复制到从节点,有多种数据同步方式,常用的有以下几种:
(1)基于语句的复制
对于主节点每个写请求语句,将其作为日志发送给从节点。对于关系型数据库,这意味着每个INSERT,UPDATE,DELETE语句都会转发给从节点,从节点会按序执行这些写请求。该复制方式存在以下问题:
- 对于非确定语句的执行结果不确定,比如NOW()获取当前时间,RAND()随机数操作,在不同的节点上会获得不同的执行效果,导致数据不一致。
- 如果某条更改语句涉及到现有的多个列,比如 UPDATE ... WHERE <condition>,则所有副本必须按严格相同的顺序执行以上操作,如果有并发事务,会有较大限制。
- 有副作用的语句(例如触发器,存储过程,用户自定义函数),在不同的副本中可能会产生不同的副作用。
(2)基于行的逻辑日志复制
每一个数据修改请求,都会转化为涉及到的具体行。包括:
- 对于行插入,日志包含所有相关列的新值
- 对于行删除,通常用主键标记该行被删除
- 对于行更新,用足够的信息(通常是主键)来标志改行,以及所有更新后的列值
如果某一次修改涉及到多行,那么会按上述规则产生多行的修改,将这些修改信息同步到从节点即可。
(3)基于物理日志复制
物理日志通常跟磁盘类型和规格有强相关性,在实际中使用很少使用。
对于MySQL而言,通过配置复制日志级别,可以选择Statement Level(基于语句复制),Row level(逻辑日志服务)和Mixed三种复制方式。
1.6 复制滞后引发的问题
异步复制,在数据复制滞后的情况下,可能引发各种读写问题。
(1)读自己的写
比如用户发送了一条信息,将这些更改发送到主节点然后即时读取,但是读取路由到了从节点,此时从节点还没同步最新数据,用户看到的结果就是自己的提交丢失了。这种“读自己的写”需要“写后读一致性”(“读写一致性”)来保证。如图2所示:
图2 读自己的写引起的丢失问题
(2)单调读
同一个用户先后从两个不同的数据副本读到了不同时刻的数据,第一次读取读到了最新同步的数据,第二次在另一个副本读到了暂未同步的数据,两次读取结果不一致。这种一致性保障也称可重复读(跟MySQL的可重复读隔离级别不是一个概念)。如图3所示:
图3 复制延迟导致的单调读问题
对于单调读,只要保证同一个用户的读取总是路由到同一个副本即可(例如基于用户ID做hash进行路由)。
(3)前缀一致性
前缀一致性主要涉及到因果关系,由于复制在不同副本上的延迟问题,导致有因果关系的两次数据在观察者看来顺序颠倒了。如图4所示。
在图4中,Poons先生首先提问,将其写入了分区1的主节点,然后Cake夫人的回答紧接着写入了分区2的主节点,然后观察者先读取到了分区2上从节点Cake夫人的回答,然后才读取到分区1从节点Poons先生的提问,看起来因果颠倒了,这就是前缀读一致性(因果一致性)。
1.7 小结
本节主要讨论了三种数据复制模型,复制方式,数据一致性的问题。三种复制模型在异步复制和半同步复制的场景下,均存在数据一致性问题。而多主和无主复制,还存在写入冲突的问题。在实际应用中,主从复制仍然是最广泛应用的模型。以上过程都未考虑节点崩溃所带来的影响和修复过程。实际上,在后面的讨论中,我们会发现,大多数的共识算法,都是在主从复制的基础上,增加了容错机制。
2. 数据一致性及解决过渡方案
2.1 数据一致性级别
可线性化
可线性化的原理非常简单,就是让整个数据系统看起来只有单一副本,一旦一个新的修改对某一个用户可见,那么它必须对所有其他用户都可见。这是最强的一致性保证。线性化的常见应用场景如下:
- 加锁与主节点选举。在主从复制的系统中,如果主节点下线,为了避免脑裂,在主节点选举时,每个节点都会尝试获取锁,一旦一个节点获得锁,它获得锁的信息必须被其他所有节点所公认(即某个节点获得锁的信息能立即被其他节点读取到)。通常都采用zookeeper和etcd等实现分布式锁和主节点选举。
- 唯一性约束,比如文件系统的唯一命名,电子邮箱,注册信息,银行存款余额不能为负等。这些也都需要分布式锁或者有专门的系统实现meta元信息的保存。
在第一节介绍的复制类型中,主从复制可以通过指定读节点的路由(只路由到更新后的从节点)可以实现线性化;多主复制由于写冲突和异步复制过程,是不能实现线性化的;无主复制,在强读写quorum协议下,直觉上是能保证读取最新的修改的,但这忽略了一个重要事实:quorum的描述,让我们觉得读一定是在同步写完成之后才进行的,这样当然能实现线性化;但是实际上读写是可以并发的,在并发读写时,如果写操作还在同步过程中,那么是实现不了线性化的。看下面的例子:
图 5 强quorum协议仍然不能保证可线性化(w=3,r=2)
在图5中,集群共3个节点,w=3, r=2(即同时写入3个节点才算成功;同时读取2个节点的数据才算读取完成)。写客户端 set x=1,读客户端A读取到了最新的数据,而读客户端B没读取到最新的数据。导致这种情况的原因是写入成功之前所做的修改对读客户端可见。实际上,如果能像关系型数据库一样,实现事务的可见性原则,将写客户端三个同步写作为一个事务,在事务成功前(即所有写完成),使事务结果对外不可见,那么就实现了线性化。
因果一致性
可线性化作为最强的一致性保证,必定丧失了高性能,且实现困难。在实际的很多应用场景中,通常不用保障所有操作全序排列(可线性化即一种全序保证),而只需要具有因果关系的操作有序即可。例如图4的问答场景,问题一定是先于回答发生,这种具有因果关系的操作必须保证有序,即因果一致性。因果一致性是一种偏序关系(部分有序)。
2.2 实现一致性的过渡方案
两阶段提交(2PC)
两阶段提交(2 Phase Commit, 2PC)是一种在多节点多系统之间实现分布式事务的方法。流程如下图所示:
2PC引入了一个新的组件:协调者,同时它把整个事务分成了两个阶段。在第一个阶段,协调者询问所有节点能否执行该事务,并在第二阶段决定是否真正的提交该事务(只有所有节点在第一阶段都回答yes,第二阶段才选择执行事务;超时或有节点回答no则中止该事物)。这个过程有两个承诺:
(1)一旦节点在第一阶段做出了yes的回答,则它承诺在任何环境下(包括节点崩溃),事务都能在第二阶段完成提交。
(2)一旦协调者决定提交某个事务,则它保证第二阶段的提交决定一定到达所有节点。
正是上面的两个承诺,保证了2PC的原子性。那么在发生意外时,这两个承诺如何兑现呢?
参与节点故障:如果参与节点在第一阶段故障,则协调者收不到所有节点的肯定回答,会终止该事务;如果参与者在第二阶段故障,协调者会保证一直重试,直到把第二阶段的请求送达到每一个节点。
协调者故障:如果协调者在投票和决议期间故障,则节点第二阶段无法顺利进行,它必须等待直到协调者恢复,发出提交或者中止的指令。由于在这个阶段,未完成的事务持有事务对应的锁,会导致所有接下来的操作都会受到影响。
可见,2PC有严重的单点故障问题。
Lamport时间戳
Lamport时间戳是一个保证因果一致的算法。每一个节点,都有一个自己的标志符和当前处理的请求的最大计数器值(计数器值+节点标志符共同组成时间戳),且所有节点都会更新这个最大计数器值,更新策略如下:每个客户端请求或节点都跟踪迄今为止见过的最大的计数器值,并在每个请求中附带该最大计数器的值,当节点收到某个请求时,如果发现请求的计时器值大于自己的最大计时器的值,则更新自己的计数器为该最大值。示例如下:
图 7 Lamport时间戳
客户端A在节点2上更新时,获取到当前的计数器值为5(大于自己的计数器),因此更新自己的max计数器=5;在接下来的请求中带上自己的计数器值,并发送请求给节点1,节点1看到请求的计数器大于自己的计数器,因此更新自己的计数器为5,加1后(计数器为6)返回给客户端A。这个过程完成了一次节点1和节点2的时间戳同步。用这种方法,能保证因果一致性,详细的解释和证明可以参考原论文。
然而,Lamport时间戳只是记录了一组操作之间的有序性,在实际应用中需要对这种有序性进行广播。比如某个节点收到新增用户ID的请求,它需要首先获得其他节点是否有相同的ID且其Lamport时间戳比自己收到的时间戳小(这个过程,其实已经是等待数据同步的过程),可见其需要时序广播才能应用,而实现时序广播又依赖于一致性。仅仅依靠Lamport时间戳难以实际应用。
3. 共识算法
上面讨论了节点的一致性等级和保证一致性的一些早期尝试。可见实现一致性最重要的是解决两个问题:时间戳有序和单点故障问题。而共识算法就解决了以上问题,实现了分布式系统下的一致性。
共识算法比较常用的包括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。如下图所示:
图 8 Raft term number(在一次新的选举中,term number可能不止加1)
raft term number其实就相当于一个时间戳,只是这个时间戳在一次election和其后续的操作中保持不变,只在新的election时才递增。这个term number有效防止了脑裂问题(即同时存在多个主节点的情况)。比如,term number = 1时,节点A是主节点,然而由于节点A的网络延迟;导致其跟其他节点的心跳丢失,此时节点B在新的选举中成为主节点(term number = 2)。当节点A重新上线后,其不知道新的主节点B已经选举完成,仍然将其写日志同步给其他节点,此时其他节点收到该请求后,发现其term number小于当前整个集群的term number,就会拒绝该请求,而节点A也能在这个拒绝的请求中,发现新的term number,并自动转化为从节点。
3.2 Raft 主节点选举
在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论文的图来做说明:
图 10 Raft日志复制的可能情况
(a)S1是Leader,在日志复制过程中crash,导致只有S2复制成功(此时term number=2,index=2的日志部分复制);
(b)S5被选举为Leader(由S3, S4和自己投票通过,term number=3),并在index=2的位置接受了一条新的日志;
(c)S5 crash,S1重新被选举为Leader(S1, S2, S3, S4均可投票,term number=4), S3继续之前的日志复制过程,并将index=2的日志复制到了S2, S3,然后S1又crash了;
(d)S5又重新上线(由s2,S3,S4或自己投票)成功,将其index=2的日志复制到了所有节点;
(e)S1在(c)之后马上重新上线,并接着后续的的日志复制。
在(d)中,由于S1的index=2的日志已经成功复制到了3个节点上,所以一旦S1提交了index=2的日志才crash,此时S5不可能会被选举为主节点,那么(d)的情况不可能出现;如果S1在提交日志之前crash,由于日志没提交,所以S5能成功选举为leader,那么由S5覆盖没有提价的index=2的日志也合情合理。
通过新的主节点选举,Raft能实现崩溃恢复,解决了单点故障问题。通过日志复制和主节点election restriction,Raft能保证数据不丢失且从节点的日志保证跟主节点一致,解决了数据一致性问题。从而实现了容错的分布式数据一致性算法。
3.4 共识的代价和应用场景
共识算法最大的问题,就是在达成一致性之前,节点间的投票和数据复制是一个同步的过程,相对于异步复制,降低了部分性能。而且,共识算法需要节点中的大部分都能正常工作。如果在一个集群中,由于网络故障,集群分成了两个小的集群,那么只有在大多数节点存在的那个集群才能正常工作,这不满足分区容错性。此外,为了提高响应性能,像Zookeeper或etcd这样的分布式系统一般都将所有数据加载到内存中。所以实现了共识算法的数据库系统,只用在特别重要的场合,包括:
(1)数据分区。对于像数据分区型的数据库,通常都有一个管理节点,用于存储数据分区的meta信息,这个管理节点要实现容错和一致性,需要用到共识算法。这也是很多分区数据库跟Zookeeper绑定的主要原因。
(2)服务发现。现在的大多数服务端,都是部署在容器中,服务IP地址,在应用扩容或重新上线的过程中,经常变化。此时,将服务和地址存储在实现了共识算法的数据库系统中作为服务注册发现系统。每当新的服务上线时,进行服务注册;由proxy访问系统进行服务发现并控制路由规则。
总结
本文从数据复制模型开始,讲解了多副本数据间的数据复制策略,从而引出数据一致性问题。然后讨论了实现一致性过程中的一些中间阶段,最后讨论到分布式算法。每一部分都是概述性的提及了相关技术或术语,没有深入讲解,旨在梳理分布式系统一致性的主要难点,在遇到相关问题时,知道查阅哪些具体的资料。感觉这也是所有学习的核心要点:知识浩如烟海,往往在学完之后,很容易忘记解决问题的具体细节,我们能做的,就是记住相关的问题和解决问题的思路,至于技术细节,留待遇到问题时再纠结。很多语言都是基于个人总结或为本文的讲解服务,缺乏严格的定义和完整性。有需要的读者,可以翻阅参考文献。
Reference
[1] 《Designing Data-Intensive Applications》
[2] 《In Search of an Understandable Consensus Algorithm》(Raft论文原文,强烈推荐)
[3] http://thesecretlivesofdata.com/raft/ (Raft的可视化网站)