文章目录
1. 整体介绍
本节主要介绍分布式系统中常见的一些一致性协议,了解这些协议对于理解分布式系统的设计思路有很大的帮助。
2. 两阶段提交协议(Two-Phrase Commit, 2PC)
这是解决分布式事务问题的常用方式,确保分布式事务中,要么所有参与的进程都提交事务,要么都取消事务。要么所有备份数据同时更改数值,要么都不修改,确保数据的强一致性。
2PC将提交过程分为连续的2个阶段:表决阶段(Voting)和提交阶段(Commit),由下列操作序列构成:
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操作。
根据响应情况,有下面两种可能:
- 协调者从所有参与者得到的反馈都是yes:那么进行事务的预执行,协调者向所有参与者发送preCommit请求,并进入prepared阶段。参与泽和接收到preCommit请求后会执行事务操作,并将undo和redo信息记录到事务日志中。如果一个参与者成功地执行了事务操作,则返回ACK响应,同时开始等待最终指令。
- 协调者从所有参与者得到的反馈有一个是No或是等待超时之后协调者都没收到响应:那么就要中断事务,协调者向所有的参与者发送abort请求。参与者在收到来自协调者的abort请求,或超时后仍未收到协调者请求,执行事务中断。
3.3 doCommit阶段
协调者根据参与者preCommit阶段的响应来决定是否可以继续事务的doCommit操作。
根据响应情况,有下面两种可能:
- 协调者从参与者得到了ACK的反馈:协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态,并向所有参与者发送doCommit请求。参与者接收到doCommit请求后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源,并向协调者发送haveCommitted的ACK响应。那么协调者收到这个ACK响应之后,完成任务。
- 协调者从参与者没有得到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之前。
在两种情况下,可以很容易的得到这个关系:
- 如果事件a和事件b是同一个进程中的并且事件a发生在事件b前面,那么
a->b
- 如果进程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 算法
三个机器上各自跑着一个进程,分别为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
- 每次执行一个事件(例如通过网络发送消息,或者将消息交给应用层,或者其它的一些内部事件)之前,将Ci加1
- 当Pi发送消息m给Pj的时候,在消息m上附着上Ci
- 当接收进程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有如下属性:
- VCi[i] 是到目前为止进程Pi上发生的事件的个数
- VCi[k] 是进程Pi知道的进程Pk发生的事件的个数(即Pi对Pj的知识)
每个进程的VC可以通过以下规则进行维护(和Lamport算法类似):
- 进程Pi每次执行一个事件之前,将VCi[i]加1
- 当Pi发送消息m给Pj的时候,在消息m上附着上VCi(进程Pi的向量时钟)
- 当接收进程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协议不能完成一致性保证,需要判断哪些数据是最新的,这就需要结合之前提到的向量时钟来配合了。
参考文章