转自:http://nosql-wiki.org/foswiki/bin/view/Main/TwoPhaseCommit
2PC是工程上广泛使用的分布式一致性协议,它主要解决的问题是:一个事务,要么所有参与者都commit;要么所有参与者都abort。 在没有异常的情况下,2PC是很容易理解的。理解2PC的难点在于出现异常的情况下协议如何保证事务的正确执行执行。
2PC协议中有两种身份:协调者(coordinator)和参与制(participant)。2PC包括两个阶段,每个阶段各自包含两个步骤。下面请跟着 笔者的思路逐渐加深对2PC协议的理解。
理想时代:没有异常
此时,我们假设所有参与者、网络都不会出现异常,这种情况下2PC没有任何难度。
- 协调者向所有参与者发出VOTE_REQUEST请求,然后协调者阻塞等待所有参与者的响应
- 参与者在收到VOTE_REQUEST的时候,执行事务预处理,根据预处理的结果响应coordinator:VOTE_COMMIT或者VOTE_ABORT; 然后参与者等待协调者的最后决定(global_decision)
- 协调者等待所有的参与者的响应,如果所有参与者都响应VOTE_COMMIT,那么协调者就向所有参与者发出GLOBAL_COMMIT; 如果至少有一个参与者响应VOTE_ABORT,那么协调者就向所有参与者发出GLOBAL_ABORT
- 参与者根据协调者的决定(global_decision)在本地进行事务操作
在理想的时代,一切都是完美的,一切都是简单的。协调者的状态转移图如下:
参与者的状态转移图如下:
次理想时代:节点、网络异常会最终恢复
本节的算法摘自《Distributed Systems: Principles and Paradigms》。
Actions of Coordinator
01 |
write( "START_2PC to local log" ); |
02 |
multicast( "VOTE_REQUEST to all participants" ); |
03 |
while (not all votes have been collected) |
05 |
waitfor( "any incoming vote" ); |
08 |
write( "GLOBAL_ABORT to local host" ); |
09 |
multicast( "GLOBAL_ABORT to all participants" ); |
14 |
if (all participants send VOTE_COMMIT and coordinator votes COMMIT) |
16 |
write( "GLOBAL_COMMIT to all participants" ); |
17 |
multicast( "GLOBAL_ABORT to all participants" ); |
21 |
write( "GLOBAL_ABORT to local log" ); |
22 |
multicast( "GLOBAL_ABORT to all participants" ); |
Actions of Participantsdata/Main/TwoPhaseCommit.txt
01 |
write( "INIT to local log" ); |
02 |
waitfor( "VOTE_REQUEST from coordinator" ); |
05 |
write( "VOTE_ABORT to local log" ); |
08 |
if ( "participant votes COMMIT" ) |
10 |
write( "VOTE_COMMIT to local log" ); |
11 |
send( "VOTE_COMMIT to coordinator" ); |
12 |
waitfor( "DESCISION from coordinator" ); |
15 |
multicast( "DECISION_REQUEST to other participants" ); |
16 |
waituntil( "DECISION is received" ); |
17 |
write( "DECISION to local log" ); |
19 |
if (DECISION == "GLOBAL_COMMIT" ) |
21 |
write( "GLOBAL_COMMIT to local log" ); |
23 |
else if (DECISION == "GLOBAL_ABORT" ) |
25 |
write( "GLOBAL_ABORT to local log" ); |
30 |
write( "GLOBAL_ABORT to local log" ); |
31 |
send( "GLOBAL_ABORT to coordinator" ); |
最糟糕的时代:协调者和参与者在死亡后无法恢复
2PC很无辜的看着大家,其实这个与我无关。听我详细道来。
算法解析
2PC这个协议本身其实本不难,难的是很多人(包括我自己)在学习算法本身的时候会思考如何把他应用在实际系统上。是想, 如果我们假设任何阶段coordinator或者participant出现异常,那么整个算法就停止在那个地方一直循环等待,直到退出的节点 恢复,算法才继续往前走,这个算法其实一点难度都没有。但是每个人都会思考,这样的算法在实际过程中还有用吗?实际过程中 的工程师们是如何来处理这个问题的?只要一思考这些,读者就会觉得怎么都不对。其实就2PC而言,他本来就是一个阻塞的算法, 在所有participant都响应VOTE_REQUEST之后,在收到DECISION之前,coordinator宕机,那么算法就会一直阻塞,因为没有人 知道最后的decision是什么。既然它天生就是阻塞的,那么我们直接再弱化一下它好了,任何步骤主要出现异常,算法都阻塞。 这样理解到的才是算法的实质。
可能有人会问,上面算法中有的地方在超时后会进行一些操作,然后算法可以继续;有些地方在超时后算法无法继续;这是为什么? 什么时候决定算法可以继续,什么时候应该阻塞?以我对算法本身的理解,继续还是阻塞的标准是:
- 是否会导致事务的结果处于一种不一致的状态(一部分参与者commit,一部分参与者abort);如果不会出现不一致的情况, 那么算法可以继续;否则就必须阻塞。
可以这么理解:非阻塞的部分是算法的优化。算法继续,唯一会出现不一致状态的情况是,所有的参与者都响应了VOTE_REQUEST,在 任何参与者收到decision之前coordinator宕机死亡,此时所有参与者都必须等待coordinator恢复。
有个同事的观点:所有参与者(包括协调者)都必须通过多副本的方式保证自己的高可用性, 因为单副本不可用的问题不是2PC这个协议的 目的,如果没有2PC这个协议,单副本的不可用性也是存在的,因此这种问题与2PC无关。可以说2PC本身不解决高可用性问题,它仅仅 解决的是atomic group commit的问题,这是2PC的假设,也是理解2PC的关键。一句话:每个协议解决自己的问题,不要带着你面临的 n个问题来理解2PC(包括其他分布式协议),这样只能使你自己陷入死角。
大家会说,那么每个协议如果这样去了解,岂不是都很简单,我作为架构师的最终目的是实现高可用的系统,而不是分开理解每个协议。 呵呵,可以理解,我和大家一样由于这个想法走了很多的弯路。我会后续慢慢的告诉大家2PC如何在高可用的系统中使用。在分布式 一致性这一系列文章中,我会为大家逐一解开谜底。
分析对工程实践的指导
还是从同事那里讨论得到的:如果在分布式系统中,协议包括这种逻辑:A发起一个请求给所有人; 等待所有人响应之后A继续进行处理。这样的东西一看就太复杂,不靠谱,因为这相当于实现了一个2PC,有些偏复杂,如果必须这么实现, 那么同学,你一定要按照2PC的理解方式去理解,去分析这个问题。
其实在分布式系统中,需要使用2pc思想指导设计的地方很多。一个很简单的例子,中心节点控制从一个数据节点拷贝一个分片到另外一个数据 节点就需要这样的协议。以gfs增加block副本为例,当gfs metaserver的后台线程发现某个block的副本数量小于配置的阈值的时候,就会发起 副本拷贝的任务:将block从一个chunkserver拷贝到另外一个chunkserver。这样的场景会产生如下问题:
- metaserver如何监控拷贝进度?
- 如果拷贝的源失败如何处理?
- 如果拷贝的目的失败如何处理?
一个比较挫的设计方法:meta不断的去询问源或者目的,任务是否结束,根据复制的结果决定如何进行后续的操作。想一想,这个实现起来有 多困难,metaserver上有上十万的block,如何处理?
看看伟大的google是如何处理的,metaserver为所有复制任务维护一个任务队列,任务队列中的任务有超时时间; 后台线程发现副本数量小于配置的阈值,首先查看任务队列中是否有任务正在进行该bock的复制操作,如果有任务 则不做任何事情;如果没有相应的任务,则发起任务。metaserver的工作到此为止。那么如何判断任务队列中的任务 完成与否呢?这是chunkserver的事情,复制的目的会在复制任务完成后向metaserver汇报新复制的block, metaserver在收到复制完成的汇报后会把相应的任务从任务队列中删除。这样,整个协议很简单,很清晰,不易出bug。 之前那种挫的设计,状态太难维护。在我们实际的工程实践中,一定要尽量少的使用一个进程去等待另外两个进程 完成某项任务的协议,这样的协议太难维护了。