之前作者带我们看了,由于不稳定的网络和时钟带来的问题
那么为了消除这些可能的问题,最好的办法就是构建一个通用的抽象保证,然后进行实现,比如我们说的事务处理。将可能存在的崩溃,冲突抽象为一个事务的概念。
而在分布式系统中,最重要的抽象就是共识,让所有的节点对某件事达成一致。
比如单一领导者节点,比如对某一主键的值达成一致。
那么这一章的主要讲解的点就是共识
首先对于共识,存在着一致性保证
也就是在一个分布式的数据库中,如果在同一时刻查看两个数据库节点,可能看到不同的数据,因为无法确定写请求到达不同数据库的时间
大多数复制的数据库至少提供了最终一致性,反正等待一定时间之后就会得到相同的值,这一点称为收敛
那么这就无法进行任何的保证,也就是在收敛之前,读操作可能返回任何的数据,而且无法确定何时触发收敛。
那么为了提供更强的保证,所以有了更强的一致性模型,并在接下来由作者给我们讲解了其中的线性一致性
对于线性一致性,可以被称为强一致性或者立即一致性,其思想是让一个系统看起来只有一个数据副本,就好比下面的操作
假设有三个客户端分别在读写同一个键x,在抛开时钟问题之外,假设一个正方体的前段为请求开始时间,后端为响应时间
那么A开始的第一个读取,会在写操作之前,那么一定是0
而第二个读取以及B的第一,二个读取,在write之中,可能返回0或者1
但是当C的写入完成了,那么A读取到的一定是1
那么更深入进去,可以描述如下
只要A看到了从0->1 ,那么在其之后开始的B读取就一定是1,哪怕此时C的写入还没结束
再次基础上,我们再引入cas操作,compare and set
上面的分割线代表Write的生效时间点
上面的操作中,比如D客户端,首先写入0到键X中,然后A客户端写入1到X中,最后B读取到的就是1
然后上面操作中并不存在什么事务隔离,另一个客户端可以随便的更改值,比如B和D先后进行了cas操作,只不过D的cas因为获取到的x的值不对从而更改失败了。
最后从上面图中可以看出来,线性一致性代表着一系列的请求和响应可以排列成有效的顺序。
线性一致性和可串行化有点相似,大概意思都有点像可以按照顺序进行排列
但是是两种不同的保证
可串行化是指的事务的隔离属性,每个事物可以读写多个对象,但是在执行的时候按照某种顺序
线性一致性则不会阻止写入偏差,而是为了确保读取和写入的新鲜度保证。
两者可以结合起来,一同提供,同时拥有可串行化和线性一致性,可以基于二阶段锁定
那么我们看看什么场景中需要应用到线性一致性
首先是单主复制的系统,为了确保领导者只有一个,需要在这方面使用线性一致性达成共识。
常见的可以利用ZooKeeper,etcd来实现,但是由于线性一致性可能存在的读取落后问题,比如读写并发执行,这种情况下etcd可以使用NWR 读取法定人数节点来判定,ZooKeeper可以在读取之前调用sync()方法来进行全局同步。
其次是唯一性约束,比如主键,比如要求余额不能为负,比如会议室不能同时被预定两次,对于这种的解决方案,则是需要进行类似CAS的操作。
在讨论并了解了线性一致性地概念之后,如何去实现一个提供线性一致性语义的系统呢?
首先,在一个分布式系统中,最重要的概念是复制,那么不同的复制方式也是实现线性一致性的关键
比如单主复制,由于只有主库具有写的能力,那么只要使用同步复制或者直接去主库读取,那么就具有线性一致性,如果使用异步复制,则不好说了。
还有共识算法,这也是线性一致的
多主复制,因为同时在多个节点上提供读写服务,必然不是线性一致的
无主复制的,即使使用了NWR也不能测地保证线性一致性,如果使用了最终写入胜利,则会破线性一致性。
就比如上面,虽然进行着写入,将x更新为1,但是在读取的时候,可能A读取到的是1和0,B读取到的双0。
这就是违背了线性一致性,但不代表NWR完全无法实现线性一致性
如果NWR后面跟着客户端进行反熵修复,是不是代表着实现了线性一致性,但是也会带来性能的下降。
上面我们看了如果是使用线性一致性,则必须要考虑单主复制或者NWR后接反熵修复。这带来了性能问题,以及CAP理论中CP和AP问题
如果需要线性一致性,且某些副本因为网络问题和其他副本断开链接。那么这些副本掉线的时候不能处理请求,或者这个系统都无法处理请求,如果不是线性一致性,那么则维持一个最终一致性。
不过CAP只是让设计者去考虑出现问题的情况下的处理方式,如果没有问题,那么是否可以提供线性一致性的同时具有可用性。
那么除了线性一致性之外,是否还有着其他级别的一致性保证呢?由于其他级别的一致性保证这个问题,我们引出了一致性中非常重要的一个概念,顺序
顺序是一致性地基石,有了顺序,才能保证因果关系,而因果关系需要应用到很多场景中,比如之前说的提问过程中往往是先看到提问,再看到结果,这是不能改变的,再好比遇到过的,如果多个操作,B操作是更新A操作插入的数据,那么B就要比A靠后
那么这就引出了一个新的一致性级别,因果一致性,但是我们还会说,因果一致性,比线性一致性要弱,至于为什么,这就是接下来要讲的东西。
因为因果顺序不要求是全序的,而线性一致性由于需要对外保证像一个副本一样,那么要求是全序的。利用这个全序,我们总能知道哪个操作在前,哪个操作在后。
但是因果顺序只要求,如果两个事件是并发的,没有关联的,那么不对顺序进行约束,但是如果两个操作是因果相关的,必须要保证一个在前一个在后。
也就是线性一致性的时间线往往是一条主轴,但是对于因果一致性来说,这个主轴中往往有很多分叉点
那么从上面看来,线性一致性是一种更为强大的因果一致性。但是由于线性一致性带来的性能降低,所以可以考虑引入因果一致性。
那么对于因果一致性地系统,为了维护这样的一个因果性,需要知道哪些操作发生在哪些操作之前,也就是happened before原则。
为了维护这样一个happened before原则,可以考虑引入2PL,或者版本向量问题,来跟踪整个数据库中的因果依赖,某个操作读取了X的值,那么就需要考虑其他人对X的更改。
那么抛开因果一致性和线性一致性,我们来探讨下为了维持一致性其他可以使用的手段。
比如引入一个全局的序列号来保证全序和偏序
那么这样的一个序列号,我们如何实现呢?
我们首先假设几个序列生成器的方案来看下
比如每个节点生成自己独属的一组序列号,比如多个节点,每个节点生成序列号的时候带上自己的节点ID
或者利用高分辨率的时钟来进行生成ID,维护一个全局有序
或者预先分配,每次请求ID的时候,直接给一个范围的ID所有权,比如1-1000 1001-2000
上面三种选项都是一种改进方式,但是仍然无法彻底保证全序。
比如节点虽然产生不同的ID,但是无法保证有序,比如高分辨率的时钟也无法保证不会存在完全一致的ID,比如分配区块的情况下,某些操作虽然会被赋予一个1001-2000的ID,但是可能操作比1-1000的操作要早。
肯定还是有能解决的方法,比如使用兰伯特时间戳
每个节点都有一个唯一标识符,和保存自己执行操作的计数器
两个节点有时有相同的计数器值.其中包含节点ID,所以可以保证每个时间戳是一致的。
上面操作中,如果两个节点的操作遇到了计数器质相同的时候,那么节点越大的,时间戳就越大,就可以赢得冲突。而且在自己遇到比自己更大的计数器值得时候,会将自己的计数器设置为这个最大值。
但是仅仅有一个有序的时间戳还不够, 因为全局有序的时间戳更倾向于最后写入胜利,那么我们还需要一些手段来进行写入的时候锁定,比如2PL这类操作,来在写入一个全局唯一的ID的时候进行锁定。
这就是全序广播,一个旨在突破单主复制极限的多节点写入有序。
这一点在ZooKeeper和ETCD中就实现了全序广播,在其中全序广播的重要表现就是顺序在消息送达的时候被固化,如果后续的消息已经被送达,节点也不能将其插入到较早的位置。
就好比一个日志,只允许追加的方式进行写入,从而保证所有节点以相同的顺序传递相同的消息。
比如在其中,我们去写入一个用户名,那么我们去全序广播对应的日志追加一条消息
然后不断读取日志,查看写入的消息
如果写入消息中,你是第一个写入这个用户名的,那么你就拥有了这个用户名,不然就终止。
利用一个全局的可以追加的日志,来保证写入的一致性,虽然写入具有一致性,但并不保证读取也是线性一致的,因为读取往往是异步的,所以结果可能是旧的,不过这也保证了一致性中的顺序一致性,一种凌驾于因果一致性的一致性保证。虽然是异步的,但是像是ZK中也提供了sync() 这样的方法。
在上面主要讲解了不同的顺序性之后,我们最终回到最开始说的共识问题了
共识问题的应用场景就不多说了,这里我们先说下常见的分布式系统中的共识提交,也就是二阶段提交算法,这是解决原子提交问题的常见办法。
那么我们就说下二阶段提交算法(2PC)
二阶段提交类似单机数据库中的原子性提交,避免了陷入半成品结果和半更新状态,
其在很多数据库内部使用,也会以XA事务的形式对应用可用
2pc中分为了提交和中止两个阶段
2pc利用一个新的组件 协调者,这个组件可以在客户端中以库的形式实现,也可以是单独的进程或者服务
正常情况下,2pc事务以应用在多个数据库节点上读取数据开始,这些数据库节点被称为参与者,当应用准备提交的时候,协调者开始阶段1,发送一个准备请求到每个节点,询问其是否能够提交。
如果所有的参与者都回复了是,说明准备好了提交,协调者会在阶段2发出提交请求
如果有一个回复了否,那么协调者会在2阶段发出中止请求,这就是大概的流程。
不过还有些细节值得探讨,比如在启动一个事务的时候,会向协调者请求一个唯一的事务ID
然后无论是做什么都会带上这个全局事务ID,
在二阶段提交中,只要一个节点确定了自己准备好了,那么他在提交的时候,就不能因为出现故障,电源故障等什么问题拒绝提交,也就是放弃了中止事务的权利
而协调者在发出提交的请求之前,会把提交还是中止的决定和全局事务ID一同写入到事务日志中,如果协调者崩溃了,那么也可以知道自己做的决定。
而一旦协调者崩溃了,那么参与者必须无限制等待协调者重启后给出最终决定,而参与者崩溃了,还可以去读取全局的事务日志,获取到最终决定,从而提交。
不过上面的整体流程,可以看出来有一个特别重大的问题,就是当协调者失效,并且恰好处于了准备之后,等待是提交还是中止的时候。参与者该怎么办?
从上面来看,其实在发出了准备好了的响应之后,参与者实际上就没有任何主动权利了。如果协调者崩溃了,那么参与者只能一直等待下去,那么加在参与者上的锁,可能就一直没法解除。
而如果盲目的做出判断,比如执行提交或者中止,那么可能导致一致性被破坏,比如A收到了中止操作,而B迟迟没收到,那么B进行了提交,A和B就产生了数据不一致性。
这种情况下,只能等待协调者恢复,让协调者根据事务日志进行提交或者中止。
虽然业界后来还提出了三阶段提交,在准备之前引入了资源占用更少的”canCommit”阶段,并且还引入了超时的概念,但是还是存在着破坏一致性的可能性,所以2PC仍然被大量使用。
而在具体的分布式事务实现中呢?一般具有两种实现方式,一种是数据库内部的分布式事务,这种分布式数据库支持数据库节点之间的内部事务,这种情况下,所有的参与者节点都运行着相同的数据库软件。
另一种是异构的,在异构事务之中,参与者是由两种以及两种以上不同的技术组成的,比如来自不同供应商的两个数据库,这样的跨系统的分布式事务需要保证原子提交。
如果是异构的,这里我们可以拿Kafka和Flink进行举例,如果要在这两者之间维护一个恰好一次的消息处理,就是需要利用分布式事务的概念,如果消息传递或者数据库事务任意一者失败,两者都会中止,就跟二阶段提交一样,需要两者一样认同。不过这样需要每一个参与者都支持二阶段协议,比如邮件不支持二阶段协议,就没法参与这样的跨系统分布式事务。
那么回到二阶段协议中,无论是异构的还是内部的,如果是协调者崩溃了,二阶段协议就此卡住了,不进行回复的话,所有的参与者都将卡死在这里。
参与者在事务中往往还持有着锁,如果事务协调者不给予一个明确的答复,那么这些锁也无法释放
这种情况下,如果协调者没有对事务的状态进行保存,在崩溃恢复后也无法下达明确的指令,那么就会导致彻底卡死,这种情况下,只能让管理员来决定是提交还是回滚事务。
如果是在异构架构之中,这种分布式事务的限制就会更加出众
如果协调者只是一个单点的,没有复制的应用,那么就容易出现卡死的问题。
那么是否还有办法去实现共识问题呢?
那么必然有,因为共识的实现往往只需要几个定义,一致同意(没有不同意见的节点)
完整性(一个节点不存在两种意见) 有效性(一个节点决定了值是V,那么V就由这个节点提议)
终止(未崩溃的节点来最终决定值),按照这个概念 前三者很好实现,有一个主节点就可以,但是终止这个属性的实现决定了当主节点崩溃后,只要不达到半数的节点崩溃就可以继续运行下去。
那么实现这样的容错共识算法有,视图戳复制 VSR,Raft Paxos,Zab等算法,我们也就讨论下他们的实现主要思想。
在这些共识算法中,也是引入了单主复制的概念,因为一个共识的达成必然需要一个协调者的角色去控制写。
不过在这类的共识算法中,会引入自动执行领导者选举和切换的操作,而且同时为了避免脑裂的问题,对于领导者,引入了版本的概念,还可以被叫做视图编号,任期号码等,每次当有一个新领导者上台的时候,都会被赋予一个新的任期号码,因为任期号码是全序且单调递增的,所以多个时代的领导者出现冲突的时候,更高任期号码的领导者说了算。
利用一个动态的领导者,外加上多数节点投票提交,达成了共识算法的实现。
不过共识算法有一个问题,就是多数节点投票过程中,是一种同步复制。这也是造成了一定的性能损失。而且共识系统引入的动态切换领导者会导致可能因为网络而领导者频繁切换问题。
像上面说的共识算法,最常见的是实现者有Zookeeper和etcd,他们是和存储少量的数据,但是能保证数据的共识性,一致性。
利用ZooKeeper提供的API,其他系统可以将一些机制托管给ZooKeeper,比如领导者选举,利用ZooKeeper来达成领导者的共识。
那么最后总结下本章,首先探讨了不同的一致性模型,首先是线性一致性,让多副本看起来仿佛只有一个副本一样,其次是因果一致性,以及顺序一致性,不过其中强调了线性一致性的实现困难,并说了因果一致性实现的一些方式,比如兰伯特时间戳。并在之后说了全序广播。
最后探讨了分布式事务,首先是2PC
之后是更加具体的共识实现方式,比如Raft这类共识算法。最后强调一点,不是所有的系统都需要共识,比如无领导复制和多主复制通常不就实现共识。但是并不妨碍有人使用,毕竟人人的需求不同。