顺序一致性(Sequential Consistency)
ZooKeeper
一种说法是ZooKeeper是最终一致性,因为由于多副本、以及保证大多数成功的ZAB协议,当一个客户端进程写入一个新值,另外一个客户端进程不能保证马上就能读到这个值,但是能保证最终能读取到这个值。另外一种说法是ZooKeeper的ZAB协议类似于Paxos,提供了强一致性。但这两种说法都不准确,ZooKeeper文档中明确写明它的一致性是Sequential Consitency即顺序一致。ZooKeeper中针对同一个FollowerA提交的写请求request1、request2,某些Follower虽然可能不能在提交成功后立即看到(也就是强一致性),但经过自身与Leader之间的同步后,这些Follower在看到这连个请求时,一定是先看到request1,request2,两个请求之间不会乱序,即顺序一致性。
其实,实现ZooKeeper的一致性更复杂一些,ZooKeeper的读操作是sequential consistency的,ZooKeeper的写操作是linearizability的,关于这个说法,ZooKeeper的官方文档中没有写出来,但是在社区的邮件组有详细的讨论。ZooKeeper的论文《Modular Composition of Coordination Services》中也有提到这个观点。
总结一下,可以这么理解ZooKeeper:从整体(read操作 + write操作)上来说是sequential consistency,写操作实现了Linearizability
线性一致性(Linearizability)
线性一致性又被称为强一致性、严格一致性、原子一致性。是程序能实现的最高的一致性模型,也是分布式系统用户最期望的一致性。CAP中的C一般就指它。顺序一致性中进程只关心大家认同的顺序一样就行,不需要与全局时钟一致,线性就更严格,从这种偏序(partial order)要达到全序(total order)要求是:
- 1.任何一次读都能读到某个数据的最近一次写的数据
- 2.系统中的所有进程,看到的操作顺序,都与全局时钟下的顺序一致。
以前面讲的例3继续讨论:
B1看到X的新值,C1反而看到的是旧值,即对用户来说,x的值发生了回跳
在线性一致的系统中,如果B1看到的x值为1,则C1看到的值也一定为1。任何操作在该系统生效的时刻都对应时间轴上的一个点。如果我们把这些时刻连接起来,如图中紫线所示,则这条线会一致沿时间轴向前,不会反向回跳。所以任何操作都需要互相比较决定,谁发生在前,谁发生在后。例如B1发生在A0之前,C1发生在A0之后,而在前面顺序一致性模型中,我们无法比较诸如B1和A0的先后关系。线性一致性的理论在软件上有哪些体现呢?
etcd与raft
上面提到ZooKeeper的写是线性一致性,读是顺序一致性。而etecd读写都做了线性一致,即etcd是标准的强一致性保证。
etcd是基于raft来实现的,raft是共识算法,虽然共识和一致性的关系很微妙,经常一起讨论,但共识算法只是提供基础,要实现线性一致还需要在算法之上做出更多的努力如库封装,代码实现等。如Raft中对于一致性读给出了两种方案,来保证处理这次读请求的一定是Leader:
- 1.ReadIndex
- 2.LeaseRead
基于Raft的软件有很多,如etcd、tidb、SOFAJRaft等,这些软件在实现一致读时都是基于这两种方式。这里对ReadIndex和Lease Read做下解释,即etcd中线性一致性读的具体实现。由于在Raft算法中,写操作成功仅仅意味着日志达成了一致(已经落盘),而并不能确保当前状态机也已经apply了日志。状态机apply日志的行为在大多数Raft算法的实现中都是异步的,所以此时读取状态机并不能准确反映数据的状态,很可能会读到过期数据。
基于以上这个原因,要想实现线性一致性读,一个交为简单通用的策略就是:每次读操作的时候记录此时集群的committed index,当状态机的apply index大于或等于committed index时才读取数据并返回。由于此时状态机已经把度请求发起时的已提交日志进行了apply动作,所以此时状态机的状态就可以响应度请求发起时的状态,符合线性一致性读的要求。这便是ReadIndex算法。
那如何准确获取集群的committed index?如果获取到的committed index不准确,那么以不准确的committed index为基准的ReadIndex算法讲可能拿到过期数据。为了确保committed index的准确,我们需要: - 1.让leader来处理读请求
- 2.如果follower收到读请求,将请求forward给leader
- 3.确保当前leader仍然是leader
leader会发起一次广播请求,如果还能收到大多数节点的应答,则说明此时leader还是leader.这点非常关键,如果没有这个环节,leader有可能因网络分区等原因已不再是leader,度请求依然由过期的leader处理,那么久将有可能读到过去的数。这样,我们从leader获取的committed index久作为此次读请求的ReadIndex.
以网络分区为例:
- 1.初始状态时集群有5个节点:A、B、C、D和E,其中A是leader;
- 2.发生网络隔离,集群被分割成两部分,一个A和B,另外一个是C、D、E。虽然A会持续向其他介个节点发送headerbeat,但由于网络隔离,C、D、E将无法接收到A的heartbeat。默认地,A不处理向follower节点发送heartbeat失败(此处为网络超时)的情况(协议没有明确说明heartbeat是一个必须收到follower ack的双向过程);
- 3.C、D、E组成的分区在经过一定时间没有收到leader的heartbeat后,触发election timeout,此时C成为leader.此时,原来5节点集群因网络分区分割成两个集群:小集群A和B;大集群C、D、E,C为leader
- 4.此时客户端进行读写操作。在Raft算法中,客户端无法感知集群的leader变化(更无法感知服务端有网络隔离的事件发生)。客户端在向集群发起读写请求时。如果客户端一开始选择C节点,并成功写入数据(C节点集群已经commit操作日志),然后因客户端某些原因(比如断线重连),选择节点A进行读操作。由于A并不知道另外3个节点已经组成当前集群的大多数并写入了新的数据,所以节点A无法返回准确的数据。此时客户端将读到过期数据。不过相应地,如果此时客户端向节点A发起写操作,那么写操作将失败,因为A因网络隔离无法收到大多数节点的写入响应
- 5.针对上述情况,其实节点C、D、E组成的新集群才是当前5节点集群中大多数,读写操作应该发生在这个集群中而不是原来的小集群(节点A和B).如果此时节点A能感知它已经不再是集群的leader,那么节点A将不再处理读写请求。于是,我们可以在leader处理读写请求时,发起一次check quorum环节:
leader向集群的所有节点发起广播。当leader还能收到集群大多数节点的响应,说明leader还是当前集群的有效leader,拥有当前集群完整的数据,否则,读请求失败,将迫使客户端崇训选择新节点进行读写
这样一来,Raft算法久可以保障CAP中的C和P,但无法保障A:网络分区时并不是所有节点都可以响应请求,少数节点的分区将无法进行服务,从而不符合Availablility。因此,Raft算法是CP类型的一致性算法