对于容许数据副本不一致的协议, 我们很难用一个单一的维度来定义或者归类这些协议,对于这些协议来说,更关键的问题在于他们提供的一致性保证,抽象和API是不是对用户有用,尽管这些协议允许副本有某种程度的不一致,但是往往某些业务是可以容忍的。
那么,为什么弱一致性系统还没有一天下呢?
实际上是因为分布式系统主要是处理‘分布’带来的两个后果:
1.信息是以光速传播的,(意味着一定会有一些延迟)
2.互相独立的组件或节点会独立的发生失败
信息传播速度有限说明分布式系统中每个节点的对外界的感知都是独一无二的,在单个节点上计算很简单,因为所有事情都会按照一个可预测的全局全序关系发生。在分布式系统上的计算很困难,因为没有一个全局的全序序列。
显然在分布式系统中实现顺序代价是很高的,特别是对于大型的互联网应用,这种应用往往必须保持时刻可用。强一致性往往不是分布式系统的特点, 强一致性系统的行为就像一个单独的系统,这种系统在网络分割的情况下往往会不可用。
更进一步说,强一致性对于每个操作而言,往往需要和系统中的多数节点进行通信,而且往往不只一次(一个来回),而是至少两次(例如2PC),这对于地理上分布的,但是又要提供足够好性能的系统来说是很困难的。
也许我们想要的是一个并不需要代价很高的协同,但是依然可以返回有用的响应给客户的系统。我们放弃数据唯一真实的条件,转而允许不同的数据副本之间有冲突,并通过某些手段来解决这些数据冲突,同时保证系统的效率和网络容错性。最终一致性表达了这样的理念:不同的节点之间的状态和数据可能会在某一时段不一致,但是最终他们会就某个值达成共识。
提供最终一致性的系统往往有两种不同的设计理念:
1. 概率性最终一致:这种系统能检测到互相冲突的写操作,但是不能保证最终的结果和顺序执行写操作序列的结果保持一致。也就是说,老的写操作的值可能会覆盖较新的写操作的值。在网络分割发生的时候可能会有些异常结果产生。
最近这些年最有影响的提供单一数据正确性的系统是Amazon的Dynamo, 该系统就采用了上述理念。
2. 绝对最终一致,这种系统保证系统的值和顺序执行的结果一致。也就是说,这种系统不会产生任何异常结果;你可以自由的增加某个服务的副本,这些副本之间可以以任何形式通信,他们获得数据更新的顺序可以是任意的,只要他们能够最终获得相同的信息他们就能对最终结果达成一致。
CRDT(复制收敛的数据类型) 是在有网络延迟,分割,或者消息乱序的情况下,仍然能保证数据收敛的数据类型。这些数据类型可以从理论上证明是收敛的,但是能够支持理论的实现很有限。
另一种相似的理论叫做CALM,基于逻辑单一性的一致性:如果我们能够证明某个系统从逻辑上说是单调一致的,那么在没有协同(通信)的情况下,系统任然是正确的。
协调系统执行顺序
如果一个系统不支持绝对数据一致性,那么它的行为会是什么样的呢,我们来看几个例子:
一个显然的特点是数据副本之间的不一致,这意味着系统没有严格定义的通信范式,各个数据副本可以互相隔离,而且同时也能够单独的接受写操作。
假设有三个数据副本,每个副本之间现在互相是隔离的(网路上分割),比如三个副本在三个不同的数据中心,他们之间网络不通。三个数据都是可用的,可以接受读写操作。
[Clients] - > [A]
--- Partition ---
[Clients] - > [B]
--- Partition ---
[Clients] - > [C]
一段时间以后,网络恢复了,每个副本之间开始交换数据,他们都收到不同client端的update操作,所以需要进行冲突合并,我们希望所有的数据副本最终能一致,
[A] \
--> [merge]
[B] / |
|
[C] ----[merge]---> result
另一种看待弱一致性的方式是,假设有一组client向两个数据副本发送消息,因为副本之间没有同步数据的协议,那么消息可能会议完全不同的顺序到达两个副本:
[Clients] --> [A] 1, 2, 3
[Clients] --> [B] 2, 3, 1
这就是为什么我们需要数据同步的协议,例如我们需要拼接一个字符串,分别有如下三个消息;
1: { operation: concat('Hello ') }
2: { operation: concat('World') }
3: { operation: concat('!') }
没有数据同步的情况下,A可能产生"Hello World!",但是B会产生"World!Hello"
A: concat(concat(concat('', 'Hello '), 'World'), '!') = 'Hello World!'
B: concat(concat(concat('', 'World'), '!'), 'Hello ') = 'World!Hello '
这当然是不正确的,我们希望所有副本之间的最终数据保持一致。 有了这两个例子之后,我们来看一下Dynamo,我们可以有一个基线,然后讨论一下构建弱一致
系统的几种方式,例如CRDT和CALM.
Amazon的Dynamo
Dynamo是最有名的弱一致的高可用系统。他的原理是很多其他系统基础,包括Linkedin的Voldermort,Cassandra和Riak.
Dynamo是一个最终一致,高可用的kv store. kv store像一个大的哈希表,client可以通过set(key,value)保存数据,并通过get方法获取数据。
一个Dynamo 集群包含N个对等节点,每个节点负责存储一组key和对应的value.
Dynamo优先保证可用性,(牺牲一致性),他并不保证单一数据一致。 数据副本之间可能会不一致,当读某个数据的时候,在返回给客户端之前有一个步骤会尝试消除多个副本之间的数据不一致。
对于Amazon来说,很多场景下,系统可用性比绝对一致性更重要,因为系统不工作可能会导致业务流失或者信誉受损。 如果某些数据不是特别重要,那么相对传统的RDMBS,弱一致系统可以提供更好的性能和可用性。
下面的图演示了一个写操作是如何route到一个节点,并同时又是怎么写到多个数据副本中的:
[ Client ]
|
( Mapping keys to nodes )
|
V
[ Node A ]
| \
( Synchronous replication task: minimum durability )
| \
[ Node B] [ Node C ]
A
|
( Conflict detection; asynchronous replication task:
ensuring that partitioned / recovered nodes recover )
|
V
[ Node D]
现在我们再看一下如何进行冲突检测和异步数据复制。高可用性的目标要求必须有数据复制,有些复制目标节点可能会由于网络分割而暂时不可用,副本同步任务确保节点在恢复以后能快速更新到最新状态。
一致Hash
无论是读还是写,我们首先要做数据路由,那么一定会有key到某个Node的映射算法。Dynamo里使用一致哈希来做key到节点的映射,这个计算由client端完成,这种方式是的client不需要进行查询来获得目标节点, 这样做的好处是hash运算比rpc的成本要低。
部分仲裁
一旦我们知道key在哪里存储,我们就要持久化这个值,这是一个同步过过程,需要立即将值写入多个节点的原因是保持更高的持久化能力,例如防止刚好在写操作时某个节点失败了。
Dynamo基于一个quroum进行数据复制,但是Dynamo实现的是一个非严格的quroum复制。
一个严格的quroum系统有如下属性:任何两个quroum之间是有交集的。在实际接收一个写操作之前要求得到多数节点的确认,这样能保证对一个数据写操作的历史是全局一致的,
因为在两个多数quroum之间至少有个一个相交的节点(这个节点会记录正确的数据写操作序列)。Paxos协议基于这个规则。
但是部分Quroum就没有这个特性,这意味着写操作不需要多数node参与,那么对同一项数据,不同的节点子集可能有不同的数据版本。对于读写操作,用户可以自己选择需要多少节点参与:
1.用户可以选择W个节点参与写操作
2.用户可以选择R个节点参与读操作
一般建议R+W>N, (N为一个数据的所有副本个数),这意味着read和write至少会hit一个公共的节点,这样范围过期的值得可能会变小。一般的配置都是N=3,那么用户
可以选择:
R = 1, W = 3;
R = 2, W = 2 or
R = 3, W = 1
假如我们有如下配置:
1. R=1,W=N, :读的速度快,写的速度慢
2. R=N,W=1, :写的速度快,读的速度慢
3. R=N/2 W=N/2+1 二者兼顾
N很少会大于3,因为将数据冗余太多副本会带来很高的成本。下面是其他一些产品的R/W配置:
Riak N=3,R=2,W=2
Voldemort N=2 or 3 R=w=1
Cassandra N=3, R=1,W=1
另一个细节是当发出读写请求是,是不是所有N个节点都需要响应,或者说只有一个部分节点需要响应,
如果给所有数据副本节点发请求,那么响应更快,因为他只需要等待最快的R/W个节点返回即可。如果是
仅仅发送给最小个数的R/W个节点那么需要等待所有节点的返回,但是网络发送的消息数量会小一些。
如果读写的Quroum发生重叠,也就是R+W>N的时候,这种情况下的一致性是否就是强一致性?
答案是否定的。 如果一个系统的R+W>N,那么会检测到副本之间的数据冲突,因为任何读写都至少会在一个节点上都发生:
1 2 N/2+1 N/2+2 N
[...] [R] [R + W] [W] [...]
这样就保证了一个写操作的结果会被后面的读操作获取。但是,这个结论仅仅在N个节点不发生变化的情况下是正确的。所以Dynamo并不能算是强一致因为他的集群节点可能会动态变化。(在某些节点失效时)
Dynamo的设计目标是总是保持可写。如果负责某些key结合的服务器宕机了,那么它加入一个新的server到集群中来处理请求,这就意味着quorum数量的server可能不会有交集,甚至R=W=N 这样的配置都不行,因为当quroum数量为N时,所有N台server可能都会因为failure发生改变。当网络分割发生时,如果有足够数量的server不能联网,Dynamo就会自动增加一些server节点,这些节点没有数据但是可以访问。
另外Dynamo处理网络分割的方式和强一致系统的方式也不一样:在两个分割区都可以进行写操作,这以为这至少在一个时间段内系统内的同一数据可能有不同的值,所以R+W>N不等于强一致,这里的强一致仅仅是一个概率的概念。
冲突检测和写修复
容许数据副本有冲突的系统必须有一套最终能消除冲突的方法,一种方式在读数据的时候消除冲突。一般来说这种方法是记录数据改动的因果序列,客户端需要有数据的元数据,然后根据元数据来消除冲突,在写数据的时候系统要返回数据的元数据(因果序列)
之前已经指出了一个实现方法,就是使用向量时钟,一开始Dynamo就是使用向量时钟来消除冲突的。但是也还有其他的方法,有些系统基于元数据实现了其他的方法:
1.没有元数据的方法。系统并不跟踪记录元数据,就直接返回得到的数值,这种系统对并发写无能为力,仅仅就是简单的最后写胜出,(Last-WRITE-WIN),如果有两个并发的写,那么最慢的值会是最终的值。
2.使用时间戳。系统保留有较高时间戳值得写操作的结果。但是,如果时间不同步可能会发生意想不到的情况,可能叫老的写操作发生在时钟有问题的节点上,那么它可能最终会被保留。Cassandra使用了这种方式而不是向量时钟。
3.版本号。版本号避免了时间不同步的问题,但是表达因果顺序的最简单的方式任然是向量时钟。
4.向量时钟。向量时钟可以检测到冲突的写操作,然后进行读修复,但有时候必须让客户端进行选择,如果有数据冲突,但是又没有元数据为依据来解决冲突,那么只能将数据丢弃。
客户端会和R个节点通信进行读取,它拿到所有的响应,然后根据向量时钟丢弃老的数据,如果最后只有一个数据和向量时钟,那么就返回这个数据,如果有多个无法决定先后的向量时钟和数值,那么久返回所有的数值。这种情况下客户端需要根据具体的case选择一个值。
另外实际应用的向量时钟实现不能让时钟无限制的增加,需要有一个垃圾回收过期时钟的方式以避免消耗过多的存储。
数据副本同步 Gossip和Merkle Tree
Dynamo系统既然允许节点失效或者网络分割,那么它会有一个机制来处理重新加入集群的失效节点。当失效节点重新恢复后,需要进行副本复制,并定期执行副本同步。
Gossip是一个副本同步的方法,(概率性的),节点之间的通信方式是随机的,每隔t秒,每隔节点随机选择另外一个节点进行同步,实际上在别的问题上比如quroum写上面也可以用这种方式。
Gossip是扩展的,没有单点失效,但是不能够绝对保证数据同步的正确性。为了保证数据同步的效率,Dynamo使用了merkle tree的技术,他的思想是一个数据存储单元可以整体进行hash,或者每一半个key进行hash,等等。
总结,目前为止讨论了Dynamo的主要设计要点:
1.一致哈希来决定key的存储路由
2.读写是部分quorum
3.通过向量时钟做冲突检测和读修复
4.数据副本同步用Gossip协议