大数据基础(3) - 常见的一致性协议

1. 整体介绍

  本节主要介绍分布式系统中常见的一些一致性协议,了解这些协议对于理解分布式系统的设计思路有很大的帮助。

2. 两阶段提交协议(Two-Phrase Commit, 2PC)

  这是解决分布式事务问题的常用方式,确保分布式事务中,要么所有参与的进程都提交事务,要么都取消事务。要么所有备份数据同时更改数值,要么都不修改,确保数据的强一致性。

  2PC将提交过程分为连续的2个阶段:表决阶段(Voting)和提交阶段(Commit),由下列操作序列构成:

2PC

2.1 表决阶段

协调者视角

  • 协调者向所有参与者发送VOTE_REQUEST消息,进入等待状态;

参与者视角

  参与者收到VOTE_REQUEST消息后

  • 如果准备好了,就向协调者发送VOTE_COMMIT消息,进入提交阶段
  • 如果没有准备好,就向协调者发送VOTE_ABORT消息,告知协调者目前无法提交事务

2.2 提交阶段

协调者视角

  协调者向所有参与者发送一个GLOBAL_COMMIT消息,通知参与者进行本地提交。

  • 如果所有参与者中,任意1个返回VOTE_ABORT,协调者向所有参与者多播GLOBAL_ABORT取消事务
  • 如果所有参与者中,没有返回VOTE_ABORT,协调者向所有参与者发送GLOBAL_COMMIT提交本地事务

参与者视角

  提交了表决信息的参与者等待协调者行为

  • 如果参与者接收到一个GLOBAL_COMMIT消息,参与者提交本地事务
  • 否则接收到GLOBAL_ABORT消息,参与者取消本地事务

2.3 缺点

单点故障

  虽然协调者挂了可以通过选举算法选出新的协调者,但是处于第二个阶段的参与者会锁定资源,导致别人使用这个资源会被阻塞。即使重新换一个协调者,参与者还是阻塞的。

同步阻塞

  参与者是阻塞式的,第一阶段收到请求后就会预先锁定资源,直到COMMIT后才会释放。

数据不一致

  第二阶段COMMIT时如果协调者挂掉,就会出现部分参与者收到了COMMIT请求,部分参与者没有收到COMMIT请求,导致数据不一致的情况。

  由于这些问题,所以引入了三阶段提交协议来解决2PC协议的这些问题。

3. 三阶段提交协议(3PC)

  3PC的核心思想是将2PC的提交阶段细分为2个阶段:预提交阶段和提交阶段。

3.1 canCommit阶段

  3PC的canCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回yes响应,否则返回no响应。

3.2 preCommit阶段

  协调者根据参与者canCommit阶段的响应来决定是否可以继续事务的preCommit操作。

  根据响应情况,有下面两种可能:

  1. 协调者从所有参与者得到的反馈都是yes:那么进行事务的预执行,协调者向所有参与者发送preCommit请求,并进入prepared阶段。参与泽和接收到preCommit请求后会执行事务操作,并将undo和redo信息记录到事务日志中。如果一个参与者成功地执行了事务操作,则返回ACK响应,同时开始等待最终指令。
  2. 协调者从所有参与者得到的反馈有一个是No或是等待超时之后协调者都没收到响应:那么就要中断事务,协调者向所有的参与者发送abort请求。参与者在收到来自协调者的abort请求,或超时后仍未收到协调者请求,执行事务中断。

3.3 doCommit阶段

  协调者根据参与者preCommit阶段的响应来决定是否可以继续事务的doCommit操作。

  根据响应情况,有下面两种可能:

  1. 协调者从参与者得到了ACK的反馈:协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态,并向所有参与者发送doCommit请求。参与者接收到doCommit请求后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源,并向协调者发送haveCommitted的ACK响应。那么协调者收到这个ACK响应之后,完成任务。
  2. 协调者从参与者没有得到ACK的反馈, 也可能是接收者发送的不是ACK响应,也可能是响应超时:执行事务中断。

3.4 缺点

  如果进入PreCommit后,协调者发出的是abort请求,假设只有一个Cohort收到并进行了abort操作,而其他对于系统状态未知的Cohort会根据3PC选择继续Commit,此时系统状态发生不一致性。

  还有一种重要的算法就是Paxos算法,Zookeeper采用的就是Paxos算法的改进,后面的章节会进行介绍。

4. 时钟

  分布式系统中,在写数据时,由于数据不存储在单点,例如DB1和DB2都可以同时提供写服务,并且都存有全量数据,client不管是写哪一个DB都不用担心数据写乱问题,但现实场景中往往会碰到并行同时修改的情况,导致数据不一致,为了解决该问题,我们引入了时钟的概念。

4.1 逻辑时钟(Lamport’s Logical Clocks)

  • happens-before

  为了同步logical clocks,Lamport 定义了一个关系叫做happens-before记作->

  • a->b意味着所有的进程都同意事件a发生在事件b之前。

  在两种情况下,可以很容易的得到这个关系:

  1. 如果事件a和事件b是同一个进程中的并且事件a发生在事件b前面,那么a->b
  2. 如果进程A发送一条消息m给进程B,a代表进程A发送消息m的事件,b代表进程B接收消息m的事件,那么a->b(由于消息的传递需要时间)

  happens-before 关系满足传递性:即(a->b && b->c)->(a->c)

  如果事件a和事件b发生在不同的进程,并且这两个进程没有传递消息,那么既不能推到a->b也不能推到b->a,这样的两个事件叫做并发事件。

  现在需要定义一个事件的函数C,使得[a->b]->[C(a)<C(b)],并且由于是作为一种对时间的衡量,所以C也必须是只增不减的。

  • Lamport 算法

Lamport 算法

  三个机器上各自跑着一个进程,分别为P1,P2,P3,由于不同的机器上的quartz crystal不一样,所以不同的机器上的时钟速率可能是不同的,例如当P1所在的机器tick了6次,P2所在的机器tick了8次。

  图中,P1给P2发送了消息m1,m1上附带了发送m1时的时钟6,随后P2收到了m1,根据P2接收到m1时的时钟,认为传输消息花了16-6=10个tick。

  随后,P3给P2发送消息m3,m3附带的发送时钟是60,由于P2的时钟走的比P3的慢,所以接收到m3时,本机的时钟56比发送时钟60小。这是不合理的,需要调整时钟,如图中,将P2的56调整为61,即m3的发送时钟加1。

  • Lamport logical clocks的实现

  每个进程Pi维护一个本地计数器Ci,相当于logical clocks,按照以下的规则更新Ci

  1. 每次执行一个事件(例如通过网络发送消息,或者将消息交给应用层,或者其它的一些内部事件)之前,将Ci加1
  2. 当Pi发送消息m给Pj的时候,在消息m上附着上Ci
  3. 当接收进程Pj接收到Pi的发送的消息时,更新自己的Cj = max{Cj,Ci}

4.2 向量时钟(Vector Clock)

  逻辑时钟可以保证(a->b)->( C(a)<C(b) ),但是不能保证( C(a)<C(b) )->(a->b)

  逻辑时钟的问题是:事件a和事件b实际发生的先后顺序不能仅仅通过比较C(a)和C(b)来决定,因为逻辑时钟没有因果关系,于是引入了向量时钟。

  向量时钟实际是一组版本号(版本号=逻辑时钟),假设数据需要存放3份,需要3台db存储(用A,B,C表示),那么向量维度就是3,每个db有一个版本号,从0开始,这样就形成了一个向量版本[A:0, B:0, C:0]

  • DB_A——> [A:0, B:0, C:0]
  • DB_B——> [A:0, B:0, C:0]
  • DB_C——> [A:0, B:0, C:0]

  这样既可以保证(a->b)->( C(a)<C(b) ),又能保证( C(a)<C(b) )->(a->b)

  • 算法逻辑

  用VC(a)来表示事件a的向量时钟,有如下性质:VC(a) < VC(b)可以推出事件a causally 发生在事件b之前(也就是事件a发生在事件b之前)。

  为每个进程Pi维护一个向量VC,也就是Pi的向量时钟,这个向量VC有如下属性:

  1. VCi[i] 是到目前为止进程Pi上发生的事件的个数
  2. VCi[k] 是进程Pi知道的进程Pk发生的事件的个数(即Pi对Pj的知识)

  每个进程的VC可以通过以下规则进行维护(和Lamport算法类似):

  1. 进程Pi每次执行一个事件之前,将VCi[i]加1
  2. 当Pi发送消息m给Pj的时候,在消息m上附着上VCi(进程Pi的向量时钟)
  3. 当接收进程Pj接收到Pi的发送的消息时,更新自己的VCj[k] = max{VCj[k],VCi[k]} ,对于所有的k
  • 示例说明

  正常情况:

  • Step 1: 初始状态下,所有机器都是 [A:0, B:0, C:0];
DB_A——> [A:0, B:0, C:0]
DB_B——> [A:0, B:0, C:0]
DB_C——> [A:0, B:0, C:0]
  • Step 2: 假设现在应用是一个商场,现在录入一个iphone13的价格 iphone13 price 6888,客户端随机选择一个db机器写入,现假设选择了A,数据如下:
{key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}
  • Step 3: 接下来A会把数据同步给B和C;于是最终同步结果如下
DB_A——> {key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}
DB_B——> {key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}
DB_C——> {key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}

Step 4:过了2分钟,价格出现波动,降到6588,于是业务员更新价格,这时候系统随机选择了B做为写入存储,于是结果看起来是这样:

DB_A——> {key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}
DB_B——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_C——> {key=iphone_price; value=6888; vclk=[A:1,B:0,C:0]}
  • Step 5:于是B就把更新同步给其他几个存储
DB_A——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_B——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_C——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}

  以上同步都为正常状态,下面示例异常情况:

  • Step 6:价格再次发生波动,变成4000,这次选择C写入:
DB_A——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_B——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_C——> {key=iphone_price; value=4000; vclk=[A:1,B:1,C:1]}
  • Step 7:C把更新同步给A和B,因为某些问题,只同步到A,结果如下:
DB_A——> {key=iphone_price; value=4000; vclk=[A:1,B:1,C:1]}
DB_B——> {key=iphone_price; value=6588; vclk=[A:1,B:1,C:0]}
DB_C——> {key=iphone_price; value=4000; vclk=[A:1,B:1,C:1]}
  • Step 8:价格再次波动,变成6000元,系统选择B写入
DB_A——> {key=iphone_price; value=6888; vclk=[A:1,B:1,C:1]}
DB_B——> {key=iphone_price; value=6000; vclk=[A:1,B:2,C:0]}
DB_C——> {key=iphone_price; value=4000; vclk=[A:1,B:1,C:1]}
  • Step 9: 当B同步更新给A和C时候就出现问题了,A自己的向量时钟是[A:1,B:1,C:1], 而收到更新消息携带过来的向量时钟是[A:1,B:2,C:0], B:2 比 B:1新,但是C:0却比C1旧。这时候发生不一致冲突,向量时钟只是告诉你目前数据存在冲突,还是需要自己进行处理的。

5. RWN 协议

  RWN协议是亚马逊公司在实现Dynamo KV存储系统时提出的。通过对分布式系统下多备份数据读写配置,确保数据一致性的分析和约束设置。

  • R:代表一次成功读数据操作要求至少有R份成功读取;
  • W:代表一次成功更新操作要求至少有W份数据写入成功;
  • N:分布式存储系统中,有多少份备份的数据;

  如果满足公式R+W>N,则满足数据一致性协议。

  在具体实现系统时,只依靠RWN协议不能完成一致性保证,需要判断哪些数据是最新的,这就需要结合之前提到的向量时钟来配合了。


  参考文章

猜你喜欢

转载自blog.csdn.net/initiallht/article/details/123983299