今天的文章咱们聊的是分布式原理当中的原子性,也称为分布式事务。不知道会不会有人觉得奇怪,分布式系统CAP原则当中并没有原子性,这个原子性是从哪里冒出来的?
其实并不奇怪,之前我们在介绍各种一致性原则的时候,虽然没有明确提出来,但是原子性的相关内容已经隐藏在其中了。让我们回顾一下,分布式系统当中的一致性简单可以分为强一致性和弱一致性。强一致性很好理解,简单可以理解成主节点每次更新都通过同步的方式,同步更新所有副本。而弱一致性则是统称所有不满足强一致性的模型,可以简单理解成通过异步更新的方式实现的一致性模型。
想象一下更新的时候,有节点出错的情况。如果是强一致性,很好办,因为我们采用同步更新,所以更新失败的话,主节点立刻就能感知。要么重试这次的更新,要么回滚放弃,或者是判断该从库是否已经宕机,将它移除资源池。
如果是异步的更新机制就麻烦了,因为没有一个统筹大局的主库了。没有节点知道其他节点更新成功了没有,如果部分成功了,部分失败了,那么数据的一致性就完全没办法保障了,脏数据到处都是,这个系统也就没法用了。所以必须要保证即使是异步更新,也要做到原子性,要么所有节点一起更新成功,要么一起失败回滚,不允许出现一部分成功了,另一部分没有的情况。
那么怎么解决这个问题呢?这就需要用到两阶段提交协议了。
两阶段提交
两阶段提交协议的算法思路其实不难,非常直观,很好理解。前文当中说了,之所以会出现部分节点更新成功,而部分节点更新失败的情况,主要原因就是因为没有一个节点统筹全局,所以没办法做出整体决策导致的。既然如此,那么解决的策略也很简单,我们加上一个整体协调的节点即可。整个协调节点会首先分发任务到各个节点,各个节点收到任务成功之后,由协调节点统一指挥,决定执行任务还是回滚。这个思路和战争非常相似,一个将军坐镇中央,各个小分队散落四方。将军统一指挥,各分队进退有素。
我们整理一下整个过程,可以将它分成两个阶段,分别是表决阶段和提交阶段。
我们来看上面这张图,首先1号节点是协调节点,可以理解成将军节点,其他节点都是小兵节点。首先表决阶段也可以理解成任务分发阶段,由将军节点将本次更新的内容分发给各个小兵节点。小兵节点收到任务之后,如果当前情况允许执行该任务,则回复可以执行,否则回复无法执行。
第二个阶段是提交阶段,也就是执行阶段。由将军节点搜集各个小兵节点的回复消息,只要有一个小兵节点回复无法执行,那么说明这次的任务无法执行,所以将军节点会再次发送消息给各个小兵节点,告知任务取消,此时各个小兵节点删除此次任务。否则,将军节点会发送执行任务的命令,各个小兵节点各自执行任务。
这个就是整个两阶段提交协议的内容,是不是非常直观,非常好理解?
我们接着深入其中的细节,试着画出将军节点和小兵节点状态的状态机。
这是将军节点的状态机,一共只有四种状态。一开始是init状态,表示初始化,也就是分发任务之前的状态。当它给各个小兵节点分发任务之后,转变到等待状态,线程挂起等待,等待各个小兵节点的回复。等所有回复到齐了之后,决定是否执行任务,如果执行则转移到执行状态,否则转移到取消状态。
小兵节点的状态机也类似,小兵节点初始化之后等待将军发放任务的消息。如果小兵判断当前任务无法执行,那么会直接报告将军节点并跳转到取消状态。否则会进入就绪状态,表示当前节点可以执行任务,但是要等将军的指挥。接着,根据将军的命令决定执行任务或者是取消回滚。
上面这两个状态机应该也很好理解,但是这当中有一个小问题。我们观察一下将军节点和小兵节点的状态机,会发现两者当中都存在挂起等待的状态。将军节点做决策之前需要等待所有小兵的回复,而小兵节点执行之前也同样需要等待将军的命令。我们不禁想问一个问题,如果消息传递的过程当中存在丢包应该怎么办?如果更严重一些,某个小兵节点或者是将军节点宕机了呢,应该怎么办?
以现在的模式显然是无法解决的,我们需要加入优化。
这里的优化针对将军节点和小兵节点需要做区分,两者的策略是不同的。我们一个一个来看。
首先是将军节点,如果小兵节点宕机或者是发送的消息丢包,那么将军节点将会无法决策,或者会长久等待。为了解决这个问题,我们需要加上超时机制。如果超时还没有搜集齐应答,那么可以判定是有小兵发生故障或者是网络出现问题,则自动视作任务失败,广播任务回滚的消息。
其次是小兵节点,如果小兵节点在初始状态超时,则自动在本地终止任务,并且发放任务无法执行的消息给将军节点。如果是在等待将军决定的时候超时,这个时候不能简单的终止任务,因为无法判断其他节点有没有执行任务,如果简单地终止,那么就会引起数据不一致。针对这个问题的策略稍稍不同,既不是继续等待,也不是去询问将军节点,而是引入互询机制,也就是说当前小兵去询问其他小兵。
因为小兵的状态只有那么几种,为了方便区分,我们给小兵起个名字。发起询问的小兵称为A,被询问的小兵称为B。我们列举一下,总共只有四种可能。
B的状态是commit,说明B已经收到将军节点的命令执行了任务。那么很简单,不管是什么原因导致A没有收到消息都没有关系,A直接也执行任务即可。
B的状态是abort,说明将军已经取消了任务,但是A没有收到。那么也很上面一样,A直接放弃任务执行即可。
B的状态是init,说明B一直还在等待任务发放,而A已经领到了任务并且在等待将军决策了。一般来说这种情况由两种可能,一种可能是B和将军的通信出现了问题,所以迟迟收不到将军的消息,还有一种可能是将军自己崩溃了。如果将军崩溃了,那当然没的说,任务肯定不能继续了,如果将军还在,那么它肯定还在等待B的消息,由于将军有超时机制,所以最终仍然会超时导致取消任务。所以A把任务置为失败是正确的。
B的状态也是等待,说明B这个节点也不清楚状况,这种情况比较麻烦,只能再换一个节点询问。那问题来了,如果所有节点都处于这个状态呢,应该怎么办?显然在当前的设计里,无法避免这个问题,会导致长久的等待。
所有节点在执行任务的时候,需要实时将状态和消息写入本地log当中,这样如果节点宕机了,根据log日志也可以恢复之前的状态。
三阶段提交
针对上文当中说的二阶段提交的那个问题,大数据专家提出了解决方案,就是在执行阶段再细分成两个阶段,也就是预执行状态和执行状态。因为多了一个阶段,所以也称为三阶段提交。
我们来看三阶段提交中将军节点的状态机:
和之前的相比差别不大,只不过多了一个预执行的节点。我们再看小兵节点:
小兵节点也一样,相当于多接受将军节点一次消息,第一次收到执行消息之后转到预执行状态之后,其实节点就会执行任务了,只是不会提交任务和释放资源,只有进入执行结束的状态之后才会进行这一操作。
用数据库里的事务打个比方,在进入预执行状态之后,节点就会执行事务,但是不会提交事务已经执行完成的状态,也就是说依然可以回滚。所以在预执行之后,如果收到将军的消息需要取消任务,那么小兵节点依然会回滚,进入状态失败。
和两阶段提交相比,在三阶段提交当中,如果小兵在等待的状态超时,那么会直接进入任务取消状态,不会再询问其他节点。如果小兵执行成功会返回将军ACK,如果失败则不会返回。将军如果没有收到所有小兵的成功消息,则会通知所有小兵回滚。如果在预执行状态等待将军消息超时,那么会直接进入执行成功状态,只有收到将军消息取消任务,才会回滚。
显然三阶段提交也不是完美的,虽然降低了阻塞等待的可能性,但是如果小兵节点没有收到将军发来的回滚消息,那么也会产生数据不一致。并且三阶段提交的时间开销要比二阶段提交大得多,加上二阶段提交出现阻塞的概率非常低,因此绝大多数分布式协议当中还是用的二阶段提交。
二阶段提交的协议在分布式系统当中广泛使用,并且它非常直观,推导的过程也很有意思,状态机的应用也很巧妙。因此推荐大家都能深入思考,理解一下其中的精髓。
今天的文章就到这里,扫码关注我的公众号,获取更多文章,你们的支持是我最大的动力。