浅析分布式主从架构下数据一致性问题

1,798 阅读27分钟

本文主要为《数据密集型应用系统设计》第二部分的阅读笔记

主从架构

常见的主从架构,即为一主多从,常见的读写策略为写主读从,并由主节点写日志并通过网络传输来维护从节点的数据库状态,但是这个常见的策略会产生一系列的分布式数据一致性问题。

通常我们使用主从架构,主要是处于以下几个目的:

  • 提高服务的容错性,即使一个节点崩溃了,还存在多个节点可以对外提供服务;
  • 提高服务的响应速度与QPS,存在多个冗余节点可以对外提供服务,自然会降低响应的延迟与提高服务的承受能力;常见的使用策略为CDN,通过在全国各地部署服务器,使得用户可以通过请求最靠近自身的服务器而无需跨较远距离访问源站,从而提高了响应速度。

基础概念:

复制状态机:

注意与设计模式中的状态机模式进行区分,核心要点在于各自状态的含义是什么?

  • 复制状态机中的状态一般是指集群节点中数据库的状态,集群节点通过不断接收、应用事件日志使得自身状态与主节点同步;
  • 状态机模式中的状态是指程序对象的状态,程序通过不断响应定义好的状态变更事件使得程序对象的状态在恒定的几个状态间流转

复制状态机基于集群节点初始状态一致,并且导致状态变更的事件在每个节点中应用的顺序一致,那么集群所有节点的状态最终必然是一致的这个理念,并通过事件日志传输维持集群节点的状态同步;

image.png

如上图所示,复制状态机算法下定义的每个节点含有:自身状态机、本地日志;

而如何保证事件日志的应用顺序一致,这个则由具体的分布式一致性算法(共识算法) 实现,如ZABRaft通过实现了全序广播算法对此进行保证,这个下面会具体讲。

分布式系统模型概念:

当我们要研究分布式系统时,首先是要使用形式化的语言描述分布式系统会出现的问题,然后根据这些问题总结出相应的系统模型,最后再基于系统模型提供的前置条件定义算法,并通过算法属性基于系统模型论证算法的有效性。

分布式系统中常见的问题场景为:

  • 节点宕机:节点宕机后可能会一直宕机下去,又或者在经过一段时间后恢复正常运行并重新加入集群;而基于这个问题场景,定义了节点崩溃的两种系统模型-崩溃-中止模型、崩溃-恢复模型,在日常生产中,显然崩溃-恢复模型更加通用;ZABRaft等共识算法基于该系统模型,并解决了该模型下,诸如主节点崩溃后恢复加入集群可能会导致脑裂等问题;
  • 节点间通信无响应或通信信息错位: 分布式系统因为网络问题导致无响应的原因可能包括:网络延迟较高超过程序预设的超时时间、进程暂停无法响应(如正在GC);而出现信息错位是因为集群中每个节点间墙上时钟(本地时间) 存在一定的漂移,在多主架构下依据不同节点的本地时间判断全局的操作次序可能会导致对于操作先后的判断错误,并造成错误的数据覆盖:因此基于该问题场景,定义了节点网络通信的三种系统模型-同步模型、部分同步模型、异步模型,部分同步模型更加适合描述日常生产,网络在大部分时候都是稳定、具有响应时间上限的,但是在一些时候也会出现较长时间无响应的场景;ZABRaft等共识算法亦基于该系统模型,并解决了该计时模型下,如何在不确定网络响应时间上限的条件下判定节点是否失效、墙上时钟会出现震荡等问题。

共识算法:

共识:集群中半数以上节点就某项提议达成一致;提出共识的主要原因是集群中每个节点是无法仅凭借自身信息做出正确决策的;

如多个客户端同时请求分布式系统尝试获取最后一张电影票,此时可以通过共识算法决定哪个客户端为获胜者;

共识算法用于容忍部分同步,崩溃-恢复分布式系统模型下的节点通信、节点失效等问题;

接着我们首先讲复制状态机的实现手段-日志:

日志

日志同步方式

  • 同步:主节点的一条日志只有所有子节点都复制成功后才可以进行提交;这种方式很明显可以完全保证主从之间日志的一致性,但是一旦某个子节点出现了网络问题,会导致日志无法提交直到该节点被判定为失效,在此之间集群无法提供写服务;

  • 异步:主节点的一条日志只负责发给所有的子节点后便进行提交;这种方式使得客户端的响应时间得到保证,但很明显主从之间会存在日志复制的延迟,如果一条日志在主节点上提交后主节点便崩溃了,此时这条日志会存在丢失的隐患;

  • 半同步:即对同步与异步的折中,在集群中选择一定数量的从节点为同步更新日志,其他的从节点为异步更新数据;这样既不会过于影响效率,又保证了在主节点崩溃时存在一个可以替换的节点。

同步日志内容

  • 基于语句的复制:如主节点上需要执行updateinsertdelete语句时,便会按照其sql语句生成相应的日志,而从节点应用日志的方式即为在本地执行相应的sql;该方法适用于像Redis这种较简单的键值数据库;如果是复杂的关系型数据库,因为其广泛支持事务,而一个事务中往往包含多条语句,使用该方法用于生成同步日志可能较为复杂;

  • 基于预写日志的复制:诸如Innodb这类数据库存储引擎,为防止数据写入一半时数据库崩溃,因而在真正开始写入数据前会先保存WAL(预先写入日志),从而可以在数据库崩溃恢复后进行redo操作;因此我们可以尝试直接使用该日志进行同步;该方法的好处在于不用花费额外的成本用于生成日志,坏处则在于 因为WAL日志是高度依赖于存储引擎的,所以我们集群日志同步便也会高度依赖特定的存储引擎,即如果WAL日志格式在某个存储引擎版本更新后发生了不向前兼容的升级后,我们不可以进行集群的滚动升级

  • 基于行逻辑日志的复制:因此更进一步,我们需要将集群间同步的日志与具体的存储引擎分隔开,我们可以采用类似Innodb undo日志的格式记录数据库行的逻辑变化,如我们增加了一条记录则标识增加一条记录,如果修改了一条记录则将旧的数据行标识为删除,并增加一条新的记录...并且基于此方式,我们可以在日志中增加当前逻辑日志是否属于事务,事务是否已提交等信息更好的支持事务;

通过定义日志我们可以简单实现复制状态机,但仍然需要分布式一致性协议来保证日志复制的顺序性;

数据一致性定义

数据强一致性(线性化)

image.png

如上图所示:客户端ABCD并发对数据库(存在多个数据副本)进行读写操作,图中矩形的左边表示数据库接收请求的时间,右边表示数据库响应请求的时间,而矩形中的竖线表示请求操作生效的时间;

线性化表示分布式系统中的多个数据副本对于客户端感知而言,相当于一个数据副本,并且对于该数据副本的修改都是原子的;即所有客户端对于数据库的操作是可线性化表示的,即符合全序关系,可以参考上图中最后一条数据库线;

关键在于理解这个原子性

write(x, 1) 即将1赋予x指令生效前,所有客户端从数据库所有读写算法定义的可读副本中读取x的指令返回的结果都为初始值0,(如在写主读从算法下,算法定义的可读副本即为所有主从节点),生效后所有客户端从所有读写算法定义的可读副本中读取x指令返回的结果在下次更改生效前都为修改值1

即不同的客户端对于数据库的感知都是一致的,不会因为数据库存在多个副本,而多个副本需要进行数据同步导致客户端感受到这个操作的中间结果-即读取到新副本得到新值,读取落后副本得到旧值的问题;

多线程下的原子性定义类似,即观察线程不会看到某个线程操作的中间效果,观察线程看到的要么是操作未发生的状态,要么是操作已生效的状态;

接着我们先认识一下全序、因果序的定义与区别:

操作顺序性

  • 全序

如阿拉伯数字属于全序,只要是两个阿拉伯数字比较的范畴,我们就可以很清楚的在全阿拉伯序列上知道数字的先后关系。

共识算法想要实现可线性化的定义,则日志序列关系要支持全序,即意味着任意两个从主节点同步至从节点的日志,我们都需要知道其先后关系;

在常见的全序关系广播算法-ZABRaft中,一般通过<epoch(term)号, 自增数(index)>的组合实现全局唯一且递增的序列号,在zookeeper中即为zxid

  • 因果序(偏序)

如果两个操作之间不存在因果关系,即可以并发,则这两个操作不受因果序的限制;

如果两个操作之间存在因果关系,即不可以并发,必须先后执行时,则这两个操作受因果序的限制;

在主从同步的场景中维持全序一般是为了维持因果序,因为对于可以并发的操作,绝大部分场景下从节点同步并不存在日志应用顺序的问题;

弱数据一致性

因为完全在数据库系统中实现ACID中的隔离性成本很高,因此提出了提交读、快照读、串行化等事务的弱隔离级别;

与事务的弱隔离级别提出的原因类似,在分布式系统中完全保证数据的一致性的代价比较大:在CAP理论中揭示了在网络分区产生时,对于分布式系统而言,要么保证系统数据一致性,要么保证系统可用性,因此如果要使得分布式系统在不稳定的网络环境下保证数据的一致性是要以牺牲服务可用性为代价的,而这个代价往往是不可以接受的;

因而提出了一些弱数据一致性级别(保证级别由低到高):

  • 最终一致性:该一致性级别保证在分布式系统中主节点与从节点的数据可能在某个时间点上从节点的数据版本会落后于主节点,但是最终是一致的;但是不能保证在某个时间点->最终同步完成的时间点之间主从数据是一致的,因而在写主读从策略下会导致客户端读取到旧数据的问题,是比较弱的一致性级别;

  • 读取副本一致性:该一致性强于最终一致性,该一致性保证了读写分离架构下,用户在修改数据后不会因为最终一致性保证下主节点与从节点的日志同步延迟而导致读取到旧的数据;

  • 因果顺序一致性:该一致性强于最终一致性,但是与读取副本一致性的关注点不同,该一致性关注的是日志的因果顺序一致性;

其中读取副本一致性与因果顺序一致性,会在下面谈一致性问题时进行详细解释,现在只需要有个印象即可;

数据一致性问题

复制滞后

问题 1-读取到落后节点

image.png

客户端请求主节点进行数据的写入,主节点根据一般的共识算法获得半数以上支持后会进行写commit(如ZABRaft),而在写主读从策略下,如果此时客户端读取了未完成数据同步的从节点,会导致用户观察到的现象为更新丢失,即Read After Write一致性问题;

从节点数据同步一般包含两个步骤,持久化日志与提交日志并应用自身状态机:在ZAB协议中,主节点接收到客户端请求后会为该请求生成一个提案(proposal)以及对应的序列号并自身持久化提案日志,接着会将该提案通过网络传递到每个从节点;

  • 从节点会将该提案日志持久化后回复ACK至主节点;(从节点持久化日志)
  • 当主节点收到半数以上节点的确认消息后会为该提案生成对应的commit消息并通过网络传递给每个从节点,接着主节点应用提案修改状态机状态并答复客户端;(从节点提交日志并应用自身状态机)

因此尚未同步完成包括两个层面:

  • 部分Follower尚未完成提案的持久化;

  • 全部Follower实现了提案持久化,但部分Follower尚未完成状态机的同步;

针对主从同步存在延迟的问题,我们可以通过使得客户端读自己的写来解决:

问题 2-多次读写数据不一致

image.png

主节点回复客户端请求完成后,从节点尚未全部同步完成,而客户端第一次请求了同步完成的节点获取到了新的数据,而第二次却请求到了旧的节点;

在操作系统中存在类似的问题,如果在一个线程中通过CPU更新变量至WriteBuffer or Cache尚未进行冲刷写缓冲区(缓存同步) 时,线程进行了CPU的切换,那么此时线程重新读取该变量值时会读取到更新前的值;我们可以使用操作系统的相关内存屏障机制,或者直接使用语言级机制-如volatile强制刷新缓存解决该问题。

而对于多次读写数据不一致问题,我们是否也可以通过类似于在集群间进行强制日志同步的方式来解决呢?分布式系统是否可以提供一种机制使得客户端在通过从节点读取数据时,数据对应的线性时间点一定是在其对应的写操作同步至该从节点后;

我们可以通过Follwer Read或者读走Quorum机制解决该问题;

问题 3-因果顺序不一致

上述的两个问题本质是因为复制存在滞后,但是日志顺序并没有发生改变;

如果因为网络的原因导致了两个存在因果关系的同步日志的顺序发生了改变,则导致因果关系颠倒的问题

image.png

如上图这个场景:

——提问:“Cake夫人,您能看到多远的未来” ——回答:“通常约10s”

其中提问是回答的因,回答是提问的果,但是下图在分区1中存储因,在分区2中存储果,接着客户端(观察者)分别读取分区1的从节点、分区2的从节点,因为两个分区的主从同步的延迟不一致,当客户端仅按照两个分区上该记录的同步时间用于判断先后时会导致因果关系颠倒;

为什么只有分区会产生因果颠倒的问题?

因为主从节点同步一般是基于Tcp协议,而Tcp协议可以通过Seq(Tcp头部序列号)来保证接收方接收网络包的顺序性,所以如果集群各节点间只存在简单复制的平行扩展,并不会有因果顺序不一致的问题;

而在主从节点引入了分区后,则会产生该问题,因为一个数据库的多个分区可能存储在不同的从节点上,而每个从节点只能保证自身分区的日志顺序,但在并不存在某种机制判断分区之间的日志顺序,上图中只简单的按照记录在分区中生成的时间进行时间戳的排序导致;

我们可以通过前缀一致读使得存在因果关系的记录存储在同一个分区中,或者通过实现全序消息广播算法提供全局的逻辑时钟用于判断日志顺序来解决该问题;

解决方案 1-读自己的写(读取副本一致性)

在一主多从下,即为读主,既然客户端是在主节点上进行的更新,那么客户端从主节点上进行读取就不会有复制滞后的问题-因为根本不涉及到复制;

但该方案会存在主节点提交日志主节点应用日志修改状态机状态响应客户端这三个时间点的先后时机问题:

主节点提交日志

在主从同步过程中,主节点通过心跳包为每个从节点维护其matchIndexnextIndex值:

matchIndex:该子节点已经同步的最大日志序列号(即回复给主节点ACK消息的日志序列号);

nextIndex:主节点要向从节点同步的下一条日志序列号;

image.png

而主节点通过判断所有从节点的matchIndex的大小,判断当前index的日志是否已经被半数节点复制,是否可以被主节点commit

主节点修改状态机状态: 应用(apply)日志,并修改状态机状态;

诸如Raft算法并没有强制规定主节点响应客户端的时机是在日志提交后,还是主节点修改完状态机后;

如果主节点是获取到半数从节点支持后将日志到状态机再返回,那么写主读主不会存在问题;而如果是先commit然后apply,那么客户端收到主节点回复后立即读取记录时可能状态机状态尚未修改,因此还是存在数据一致性的问题,此时只能保证数据的最终一致性-较弱的一致性级别;

因此我们要避免这个问题,我们首先要了解,为了区分处于不同状态的日志,每个主节点都会维护四种index

firstLogIndex:主节点中尚存的最小日志序列号;

applyIndex:主节点中已经应用到状态机的最大日志序列号;

commitIndex:主节点中已经得到集群半数以上节点确认后可提交的最大日志序列号;

lastLogIndex:主节点中接收到当前最新请求后生成的最大日志序列号;

image.png

了解这四个Index后解决这个问题比较简单,在主节点对于写命令已经得到半数确认时我们只需记录下当前的请求序列号(commit index),当 apply index追上该 commit index时,即可将状态机中的内容响应给客户端。

但是对于因为网络分区产生两或多个主节点时-脑裂,客户端可能会连接到错误的主节点从而读取到旧的记录;

因此我们需要引入下一个解决方案:读主节点也需要走Quorum共识机制;

PS:如果是多主,则可以通过Hash等方式保证客户端访问的服务端节点一致。

解决方案 2-读走Quorum机制

Quorum机制用来实现共识与容错,即对于集群的每个操作(Proposal),都需要获得半数以上节点的同意才可以提交;

解决上述问题的本质在于客户端在读取主节点时,主节点要通过心跳机制确认其获得了超过Quorum机制要求的从节点数的支持,来保证其主节点的有效性

如果此时读取到旧的主节点,其心跳机制确认的从节点数必然小于半数,则需要重新获取集群主节点的连接;

但是如果完全进行读主写主,那么从节点便只有冗余提高系统的容错性了;所以我们可以使用Follower Read的策略解决更好的利用从节点。

解决方案 3-Follwer Read

上述解决方案之所以从写主读从演变为写主读主本质上是因为从节点的状态机状态可能落后于主节点的状态,因而客户端在向从节点发起读请求时记录下当前的read index,从节点直到状态机apply至主节点同步过来的到日志序列号超过当前read index后再进行返回,即可保证不会读取到落后的数据;

虽然该方案仍然依赖于主节点,但一定程度上缓解了主节点的压力。

解决方案 4-前缀一致读 (保证因果顺序一致性)

问题的关键在于数据库存在多个分区,而多个分区间不存在全局写入顺序,但每个分区自身存在顺序,因而我们可以对于存在因果关系的操作放入同一个分区中便可以解决这个问题;

比如在Kafka中,我们可以自定义按Key分区策略,从而进行实现;

解决方案 5-实现全序算法

上面讲述的Cake夫人的问题,当然我们可以在先后写入数据至分区1主节点、分区2主节点时就在主节点上记录下主节点的墙上时钟(本地时钟),然后在同步日志时顺带同步该时间;但是按照部分同步系统模型的定义,节点间墙上时钟会存在漂移现象,而时钟漂移仍然会导致日志全局顺序判断错误:

image.png

如上图所示,因为节点1的墙上时钟相较节点3快,因而set x = 1的操作全局顺序上应该早于x += 1,但通过各自节点上的时间戳观察时顺序却颠倒了;

因而物理时钟无法实现可靠的全序,因此全序算法需要基于逻辑时钟,通过生成器按照相应的全序算法生成的全局唯一、自增序列号做为逻辑时钟;

如在ZAB协议中,我们通过比较zxid的大小即可确定该请求在全局顺序的相对位置。

上述即为主从架构集群节点间正常网络通信时问题与解决方案,下面我们探讨另一个关注点-节点失效。

节点失效

对于节点失效有两种常见的系统模型-崩溃-中止崩溃-恢复;崩溃-中止即指失效的节点失效后就不会再加入集群;崩溃-恢复是指节点失效后会自动恢复并尝试重新加入集群;崩溃-恢复模型更符合现实情况。

1 节点失效的判定

节点之间如果无法按时维持相互间的心跳包,因为网络的不稳定性,彼此并不能够判断是网络延迟还是通信的节点已经挂了,但目前还是以超时判断为主;

如在Raft中,对于从节点而言,如果在一个区间内随机的超时时间中无法与主节点进行心跳联系,则从节点会认为该主节点已经失效,并发起新一轮的选举;

2 崩溃-恢复

崩溃-恢复过程中,需要提供两个保证:

  • 已经被主节点提交的日志不可以丢失;
  • 只在主节点中被提交的节点需要被丢弃;

从节点失效

从节点重新加入集群后,只需要找到当前集群中的主节点,然后开始同步日志即可;

集群新增节点的同步方式一致,首先是进行主节点的整体快照复制,然后再是在该快照版本后新增变更的日志序列的复制;

因为完全基于日志序列的复制,则从节点需要较长时间逐句同步,而首先基于主节点的整体进行快照复制则快很多;类似的,Redis一般也是用RDB + AOF的从节点状态同步方式;

主节点失效

如果判定主节点失效,则需要进行新一轮的主节点选举,选举出新的主节点;

对于RaftZAB算法来说,它们的目的都是选举出具有最大全序序列号的节点做为主节点,因为按照之前对于全序的定义,拥有最大序列号的节点也意味着拥有最新的状态;

节点选举可能出现在集群刚刚启动以及集群主节点失效这两种场景下,下面我们对上述两种协议的选举进行比较分析:

ZAB协议

ZAB协议定义了64位全局序列号(epoch + 32位递增数)做为逻辑时钟,其中epoch的值在每次切换主节点时都会递增,用于标识不同主节点任期间日志的全序关系,而32位递增数即为该主节点任期间用于标识在该任期中日志的全序关系;

ZXID:(1, 2)表示第一个主节点任期内的第二条日志,其比ZXID:(1, 1)要新; 如ZXID:(2, 1)表示第一个主节点失效后,选举出的第二个主节点任期内的第一条日志,其比ZXID:(1, 2)要新;

ZAB协议定义了节点的几种状态:

Leading:当前节点为主节点;Following:当前节点为从节点;

Locking:当前节点处于参与选举状态;

Observing:当前节点处于观察状态(不参与选举);

当集群刚刚启动时,所有的节点处于Locking状态参与选举,每个节点都会像集群中的其他节点发送选举信息:该节点最大序列号、该节点的主机id,在集群刚刚启动时最大序列号都为0,而节点的主机id即为定义在zookeeper每个broker配置文件中的myid值;

进行比较大小的方法即为,序列号大的更大,序列号相同则myid大的更大;

(主机号:2,ZXID: 9) > (主机号:1,ZXID:9) > (主机号3,ZXID: 8)

因此对于当集群刚刚启动时,必然是主机号最大的节点称为主节点;同理对于集群运行过程中主节点失效时,我们亦按照相同的规则进行主节点的选举:

开始选举时,由于每个节点都不知道其他节点的状况,因而首轮选举投票都会向集群广播投给自己的选票;

选举过程中, 每个节点会存在几种收包情况:

  • 收到的(主机号,ZXID号)的选票信息小于节点自身则忽略该信息;
  • 收到的(主机号,ZXID号)的选票信息大于节点自身,则该节点会更改自身选票为这个比自己大的节点;
  • 收到了新的Leader节点的心跳包,并且心跳包中的(主机号,ZXID号)组合大于自身,则自身转变为Followering
  • 节点自身获取了半数以上节点(Quorum)的选票后将自身节点状态修改为Leading,并向其他节点发送心跳包表示主节点已经产生,并结束选举;
  • 集群在当前选举中未产生合适的主节点,因而没有收到主节点的心跳信息,需要进行新一轮的选举。

Raft协议

ZAB等全序广播协议类似,其通过term号 + index组成序列号做为集群的逻辑时钟,其中term号随着每增加一个任期便会进行递增从而维持不同主节点任期间日志的全序关系;而index则标识某条日志为该主节点任期内的第几条日志;

类似的,Raft也定义了节点角色:

Leader:当前集群的主节点;

Follower:当前集群的从节点;

Candidate:当前集群中正在参与选举的节点;

节点通过RequestVote RPCs 格式的消息来投票选举集群中的主节点;

RaftZAB大同小异,这里着重讲述Raft的一些优点:

集群刚刚启动时:与ZAB将每个节点都初始化为LOCKING不同,Raft节点启动时节点状态为Follower,并为每个节点随机初始化选举超时时间,增加选举超时时间的单位>>心跳包同步时间的单位的限制,导致集群中某个节点率先变为Candidate时,节点自增term号表示选举的任期,该节点向其他节点发送自身最大日志序列号LastLogIndex用于参与选举,参与投票给该节点并使其成为主节点后,该节点即可通过向子节点同步心跳包开始任期;

可见相比于ZAB,Raft引入随机化初始时间后在集群刚启动时可以避免繁琐的更改选票过程,因为可能在同一个时间只有一个节点状态变为Candidate

参考

别再怀疑自己的智商了,Raft协议本来就不好理解

深度解析 Raft 分布式一致性协议

分布式一致性算法:Raft 算法(Raft 论文翻译)