技术架构(5)-分布一致性
我认为,分布一致性包含两种不同的场景。一种是像微服务这样,由于分布式事务的引入,导致不同数据之间的逻辑关系不一致。另一种是分布式存储中,由于数据分片和多副本导致的相同数据不同副本不一致。
分布式事务一致性
分布式事务是在分布式系统中实现事务,它是由多个本地事务组合而成,对于分布式事务而言几乎满足不了 ACID。
2PC
2PC(Two-phase commit protocol),二阶段提交。 二阶段提交是强一致性协议,2PC 引入一个事务协调者的角色来协调各参与者的提交和回滚,二阶段分别指的是准备和提交两个阶段,具体落地会有差异。
- 准备阶段:协调者向各参与者发送准备命令,锁定资源、执行操作,但不执行事务提交。
- 提交阶段:同步等待所有参与者的响应。如果全部参与者都返回成功,则事务协调者通知执行事务提交;如果有一个参与者返回失败,则整体回滚事务。
如果第二阶段有参与者失败了怎么办?只能头铁不断重试,必要的时候需要人工介入处理,否则数据就不一致了,毕竟程序没那么聪明。
2PC是一个同步阻塞协议,协调者会等待所有参与者响应才会进行下一步操作。第一阶段的协调者有超时机制,如果没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。
协调者故障
- 协调者存在单点故障问题
- 协调者宕机会导致阻塞。如果协调者在发送准备命令之后挂了,或者在发送提交(回滚)事务之前挂了,资源会锁定,不会释放,一直阻塞。
2PC是一种尽量保证强一致性的分布式事务,总体而言效率低,在极端条件下存在数据不一致的风险。2PC只适用于数据库层面的分布式事务场景,而我们业务需求有时候不仅仅关乎数据库,也有一些服务调用的操作,这个时候2PC就无能为力了。
3PC
3PC的出现是为了解决2PC阻塞的问题,相比于2PC,它在参与者中引入了超时机制,并且新增了一个询问阶段,使得参与者可以利用这一个阶段统一各自的状态。如果发生超时,默认提交事务。
- 询问阶段:协调者向参与者询问能否完成指令,参与者只需要回答是或否,无需做其他的操作。
- 准备阶段:协调者向参与者发起指令,锁定资源、执行操作但是不提交。如果有参与者在询问阶段回答否,则协调者向参与者发送中止请求。
- 提交阶段:如果每个参与者都明确的返回成功,也就是意味着资源锁定、执行操作成功,则协调者向各个参与者发起提交指令,参与者提交操作、释放锁定资源;如果有参与者在上述的两个步骤中有明确返回失败,也就是说资源锁定或者执行操作失败,则协调者向各个参与者发布中止指令,参与者执行undo日志,释放锁定的资源。
我们来看下参与者超时能带来什么样的影响。
我们知道 2PC 是同步阻塞的,协调者在提交请求之前宕机,所有参与者会锁定资源并且阻塞等待。引入超时机制后,参与者就不会傻等了。如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的;如果是等待准备命令超时,那该干啥就干啥,反正本来啥也没干。然而超时机制也会带来数据不一致的问题。比如在等待提交命令时超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。新协调者来的时候发现有一个参与者处于准备或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。
3PC 相对于 2PC 做了一定的改进,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务。分布式事务不仅仅包括数据库的操作,还包括远程服务调用等等,这时候 TCC 就派上用场了。
TCC 指的是Try - Confirm - Cancel
。
- Try 预处理,参与者完成业务检查,预留业务资源。所有参与者都预留成功,try阶段才算成功。此阶段仅是一个初步操作,它和后续的Confirm 一起才能真正构成一个完整的业务逻辑。
- Confirm 确认操作,不做任何业务检查,只使用Try阶段预留的业务资源。通常情况下,认为 Confirm阶段是不会出错的。即只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。
- Cancel 撤销操作,可以理解为把预留阶段的动作撤销了。如果某个业务资源没有预留成功,则取消所有业务资源预留请求。通常情况下,认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。
从思想上看和 2PC 差不多,都是先试探性的执行,如果可以那就真正的执行,如果不行就回滚。不同的是,2PC是一种强一致性事务,而TCC在Confirm、Cancel阶段允许重试,这就意味着数据在一段时间内一致性被破坏,TCC是一种柔性事务。
可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作都需要定义三个动作分别对应Try - Confirm - Cancel,
对业务的侵入较大,业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。
还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等。
相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。
本地消息表
本地消息表是利用了各系统本地的事务来实现分布式事务。核心思想就是将分布式事务拆分成本地事务进行处理,用数据库的事务特性保证数据一致性。
本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候,将业务的执行和消息表中的操作放在同一个事务中,这样就能保证消息放入本地表中业务肯定是执行成功的。
然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。如果调用失败也没事,会有后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。
可以看到本地消息表实现的是最终一致性,容忍了数据暂时不一致的情况。
消息事务
RocketMQ 就很好的支持了消息事务,让我们来看一下如何通过消息实现事务。
首先给 Broker 发送事务消息即半消息,半消息不是说一半消息,而是这个消息对消费者来说不可见,然后发送成功后发送方再执行本地事务。根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。
RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内半消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
可以看到通过 RocketMQ 还是比较容易实现的,RocketMQ 提供了事务消息的功能,我们只需要定义好事务反查接口即可。消息事务实现的也是最终一致性。业务流程方向都是不可逆的,上游系统事务成功提交,下游系统的事务则也一定要成功,业务不能回滚,如果要是出现消息消费失败,则只能进行不断的重试,直到成功为止。
最大努力通知
本地消息表也可以算最大努力,事务消息也可以算最大努力。
就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽最大的努力想达成事务的最终一致了。适用于对时间不敏感的业务,例如短信通知。
分布式存储一致性
多主协议
写操作可以由不同节点发起,同步给其他副本,它不能保证操作的有序性,只能做到弱一致性。
Gossip算法
Gossip又被称为流行病算法,它与流行病毒在人群中传播的性质类似,由初始的几个节点向周围互相传播,到后期的大规模互相传播,最终达到一致性。Gossip协议被广泛应用于P2P网络,同时一些分布式的数据库,如Redis集群的消息同步使用的也是Gossip协议。Gossip算法每个节点都是对等的,即没有角色之分,每个节点都会将数据改动告诉其他节点。
Gossip协议的整体流程非常简单,传输示意图见上图。初始由几个节点发起消息,这几个节点会将消息的更新内容告诉自己周围的节点,收到消息的节点再将这些信息告诉周围的节点。依照这种方式,获得消息的节点会越来越多,总体消息的传输规模会越来越大,消息的传播速度也越来越快。虽然不是每个节点都能在相同的时间达成一致,但是最终集群中所有节点的信息必然是一致的。Gossip协议确保的是分布式集群的最终一致性。
预先设定好消息更新的周期时间T,以及每个节点每个周期能够传播的周围节点数n,我们可以得到大致的消息更新流程如下:
- 节点A收到新消息并更新
- 节点A将收到的消息传递给与之直接相连的B1、B2...Bn
- B1-Bn各自将新消息传给与之直接相连的n个节点,这些节点不包含A
- 最终集群达成一致
在Gossip算法中,Gossip每次新感染的节点都会至少再感染一个节点,展开来看,这就是一个多叉树的结构,那么依据这个结构,最大的时间复杂度即使一个二叉树的形式,这时整体上达到一致性的速度是log(n)。可见Gossip传播性能还是相当惊人的,著名的Redis数据库便是使用Gossip协议保持一致性,Redis最多可支持百万级别的节点,Gossip协议在其中起到了重要作用。
Proof-of-work(Pow)算法
Proof-of-work算法,工作量证明算法,从这个算法的名称中我们能对它实现的功能窥见一二。那是否意味着工作量较大的某一个节点能够获得主动权呢?事实也是类似这个原理,大量的节点参与竞争,通过自身的工作量大小来证明自己的能力,最终能力最大的节点获得优胜,其他节点的信息需要与该节点统一。Pow最为人所熟知的应用是比特币。
我们知道,比特币塑造的是一个去中心化的交易平台,最重要的一点就是该平台的可信度。要达到高可信度,要求整个系统中没有固定的Leader,且为了防止外界篡改,必然要设定一些特殊的机制,比如让图谋不轨的一方无法篡改或者必须付出与收获完全不对称的代价才有修改的可能,以这样的方式打消其修改的念头。这时候比特币引入了Pow算法,在Pow机制下,所有参与者共同求解数学问题,这些数学问题往往需要经过大量枚举才能求解,因此需要参与者消耗大量的硬件算力。成功求解数学问题的参与者将获得记账权,并获得比特币作为奖励。其余所有参与者需要与获得记账权节点的区块一致,由此达到最终的一致性。
依靠Pow算法,比特币很大程度保证了交易平台的安全性。因为如果要对该平台的数据进行篡改或者毁坏,篡改者至少需要获得比特币全网一半以上的算力,这是非常难以达到的。但是同样Pow存在很多缺点,Pow达成一致性的速度很慢,应用在比特币中每秒钟只能做成7笔交易,这在大部分的商业应用中都是达不到要求的。其次Pow造成了很大的资源浪费,所有的竞争者夺取记账权需要付出巨大的硬件算力,这在背后是大量的硬件成本、电力损耗,而一旦记账权确定,其余没有获得记账权的节点的算力就白白浪费了。最后是出现了一些大规模的专业矿场,这些矿场的算力非常强大,它们的存在增大了平台被篡改的可能性。
Gossip算法和Pow算法对比
同为去中心化的算法,Gossip算法和Pow算法都能实现超大集群的一致性,但是它们的特性可谓有天壤之别。
Gossip算法往往应用于超大集群快速达成一致性的目的。它的特性是如流感一般超强的传播速度,以及自身能够管理的数量可观的节点数。但是对于传播的消息没有特别的管控,无法辨别其中的虚假信息,并且只关注最终的一致性,不关心消息的顺序性。
Pow算法则完全专注于尽可能地解决"拜占庭将军"问题,防止消息被篡改。它可以不计代价地去要求各个节点参与竞选,以付出巨大算力为代价保证平台的安全性。
在比特币的应用中,使用Pow算法确定竞争者的记账权,尽可能地解决"拜占庭将军"问题,再将可信的结果由传播速度极强,节点数目量大的Gossip协议去进行传输,最终达成全网一致,可谓很好地利用这两大算法的特点,将二者优劣互补并巧妙地用于区块链领域。
单主协议
写操作只能由主节点处理并同步给其他副本。
Paxos协议
Paxos一致性算法由分布式领域专家Lamport提出。作者论文中对于该算法的阐述非常理论化,因而整体概念的理解较为抽象。
一致性算法的最终目的是让各个节点的数据内容达成一致,那么什么情况下可以认为各节点已经成功达成一致了呢?Paxos中的判定方法是如果存在大部分节点共同接收了该更新内容,可以认为本次更新已经达成一致。
这里列举希腊城邦选举的例子,帮助我们理解Paxos算法。城中的一些位高权重的人们("发起者")会提出新的"法案",这些法案需要立法委员("接收者")达成一致即多数同意才能通过。于是权贵们会预先给立法委员一些金钱,让他们通过自己的法案,这对应的就是"预请求",如果立法委员已经收到过更高贿赂的"预请求",他们会拒绝,否则会同意。权贵们贿赂成功后,会告诉立法委员新的法案,在收到新法案之前,如果立法委员没有收到更高的贿赂,他们会选择接受这个法案,否则会拒绝。很关键的一点是,不要忘了我们是一致性协议,不是真正的立法,因此很关键的一点是如果立法委员在接收到更高的贿赂时已经接受了某个法案,那他会告诉贿赂的权贵这个法案的内容,权贵会将自己发起的法案改成该法案的内容,这样才能够迅速达成一致性。
Paxos是非常经典的一致性协议,但是因为过于理论化,因此工业界出现了诸多基于Paxos思想出发的变种。虽然这些变种最终很多都和原始的Paxos有比较大的差距,甚至完全演变成了新的协议,但是作为奠基者的Paxos在分布式一致性协议中依然持有不可撼动的地位。
Raft协议
Raft协议是斯坦福的Diego Ongaro、John Ousterhout两人于2013年提出,作者表示流行的Paxos算法难以理解,且其过于理论化致使直接应用于工程实现时出现很多困难,因此作者希望提出一个能被大众比较容易理解接受,且易于工程实现的协议。Raft由此应运而生。不得不说,Raft作为一种易于理解,且工程上能够快速实现一个较完整的原型的算法,受到业界的广泛追捧。大量基于Raft的一致性框架层出不穷。
Raft的算法逻辑主要可以分成两个部分,一个是选举部分,另一个是log传输部分。和Paxos部分不同的是,这里的log是连续并且按序传输的。Raft中定义了一个叫term概念,一个term实际上相当于一个时间片。这个时间片被分成两个部分,第一部分是选举部分,第二部分是传输部分。
- 选举算法逻辑。在Raft中可以存在多个server,其中每一个server都有机会成为leader,但是真实有效的leader只有一个,于是这个leader的产生需要所有的server去竞争。在每个term开始的阶段,众多server需要进行竞选,选出一个有效的leader。每个server被设置了一个300-400ms随机的timeout,在如果在timeout内没有收到某一个leader发来的心跳信息,那么这个server就会发起竞选,将自己的term值加一,成为candidate,并寻求其他server的投票。当且仅当某一个candidate收到超过一半的server的票数时,它就成功当选了leader,并开始向每个server发送心跳信息。仅仅这么做其实是存在问题的,如果有多个candidate同时参与竞选,很可能出现选票分散的情况,最终无法选出有效的leader。因而除此之外,Raft还要求如果candidate收到term比自己大的投票请求时将自己的状态修改成follower,这么一来就成了谁的term增加得快的问题,因为timeout是随机的,总会出现更快的server,因此算法最终是收敛的。
- log传输逻辑。client会给leader发送需要传输的log,leader收到后在log上附加log的位置索引值和当前term值。这么一来每个log的索引与term值都是独一无二的。leader中会记录所有待传输给server的log索引值,针对于某个server,如果leader现存的索引数目大于待传输值,leader就会向该server传输新的logs。server收到logs后验证第一个log的term是否与自己同索引log的term一致,如果不一致告知leader匹配失败,leader会将传输的索引值减一,再重新传送。如果server验证后发现一致,则删除冲突索引后的所有log,并将新收到的log续借在该索引后面。
总的来看,Raft算法清晰明了,相比于Paxos抽象的理论阐述更易于理解。但是看文字,还是不容易理解,如果有动图,效果会好很多。
Paxos和Raft的对比
Paxos算法和Raft算法有显而易见的相同点和不同点。二者的共同点在于,它们本质上都是单主的一致性算法,且都以不存在拜占庭将军问题作为前提条件。二者的不同点在于,Paxos算法相对于Raft,更加理论化,原理上理解比较抽象,仅仅提供了一套理论原型,这导致很多人在工业上实现Paxos时,不得已需要做很多针对性的优化和改进,但是改进完却发现算法整体和Paxos相去甚远,无法从原理上保证新算法的正确性,这一点是Paxos难以工程化的一个很大原因。相比之下Raft描述清晰,作者将算法原型的实现步骤完整地列在论文里,极大地方便了业界的工程师实现该算法,因而能够受到更广泛的应用。同时Paxos日志的传输过程中允许有空洞,而Raft传输的日志却一定是需要有连续性的,这个区别致使它们确认日志传输的过程产生差异。