分布式系统的事务处理
2018-08-02 07:47:50 阿炯

本站赞助商链接,请多关照。 当我们在生产线上用一台服务器来提供数据服务的时候,我会遇到如下的两个问题:
1)一台服务器的性能不足以提供足够的能力服务于所有的网络请求。

2)我们总是害怕我们的这台服务器停机,造成服务不可用或是数据丢失。

于是我们不得不对我们的服务器进行扩展,加入更多的机器来分担性能上的问题,以及来解决单点故障问题。 通常,我们会通过两种手段来扩展我们的数据服务:
1)数据分区:就是把数据分块放在不同的服务器上(如:uid % 16,一致性哈希等)。

2)数据镜像:让所有的服务器都有相同的数据,提供相当的服务。

对于第一种情况,我们无法解决数据丢失的问题,单台服务器出问题时,会有部分数据丢失。所以,数据服务的高可用性只能通过第二种方法来完成——数据的冗余存储(一般工业界认为比较安全的备份数应该是3份,如:Hadoop和Dynamo)。 但是,加入更多的机器,会让我们的数据服务变得很复杂,尤其是跨服务器的事务处理,也就是跨服务器的数据一致性。这个是一个很难的问题。 让我们用最经典的Use Case:“A帐号向B帐号汇钱”来说明一下,熟悉RDBMS事务的都知道从帐号A到帐号B需要6个操作:
从A帐号中把余额读出来。
对A帐号做减法操作。
把结果写回A帐号中。
从B帐号中把余额读出来。
对B帐号做加法操作。
把结果写回B帐号中。

为了数据的一致性,这6件事,要么都成功做完,要么都不成功,而且这个操作的过程中,对A、B帐号的其它访问必需锁死,所谓锁死就是要排除其它的读写操作,不然会有脏数据的问题,这就是事务。那么,我们在加入了更多的机器后,这个事情会变得复杂起来:
1)在数据分区的方案中:如果A帐号和B帐号的数据不在同一台服务器上怎么办?我们需要一个跨机器的事务处理。也就是说,如果A的扣钱成功了,但B的加钱不成功,我们还要把A的操作给回滚回去。这在跨机器的情况下,就变得比较复杂了。

2)在数据镜像的方案中:A帐号和B帐号间的汇款是可以在一台机器上完成的,但是别忘了我们有多台机器存在A帐号和B帐号的副本。如果对A帐号的汇钱有两个并发操作(要汇给B和C),这两个操作发生在不同的两台服务器上怎么办?也就是说,在数据镜像中,在不同的服务器上对同一个数据的写操作怎么保证其一致性,保证数据不冲突?

同时,我们还要考虑性能的因素,如果不考虑性能的话,事务得到保证并不困难,系统慢一点就行了。除了考虑性能外,我们还要考虑可用性,也就是说,一台机器没了,数据不丢失,服务可由别的机器继续提供。 于是,我们需要重点考虑下面的这么几个情况:
1)容灾:数据不丢、结点的Failover
2)数据的一致性:事务处理
3)性能:吞吐量 、响应时间

前面说过,要解决数据不丢,只能通过数据冗余的方法,就算是数据分区,每个区也需要进行数据冗余处理。这就是数据副本:当出现某个节点的数据丢失时可以从副本读到,数据副本是分布式系统解决数据丢失异常的唯一手段。所以,在这篇文章中,简单起见,我们只讨论在数据冗余情况下考虑数据的一致性和性能的问题。简单说来:
1)要想让数据有高可用性,就得写多份数据
2)写多份的问题会导致数据一致性的问题
3)数据一致性的问题又会引发性能问题

这就是软件开发,按下了葫芦起了瓢。

一致性模型

说起数据一致性来说,简单说有三种类型(当然,如果细分的话,还有很多一致性模型,如:顺序一致性,FIFO一致性,会话一致性,单读一致性,单写一致性,但为了本文的简单易读,只说下面三种):
1)Weak 弱一致性:当你写入一个新值后,读操作在数据副本上可能读出来,也可能读不出来。比如:某些cache系统,网络游戏其它玩家的数据和你没什么关系,VOIP这样的系统,或是百度搜索。
2)Eventually 最终一致性:当你写入一个新值后,有可能读不出来,但在某个时间窗口之后保证最终能读出来。比如:DNS,电子邮件、Amazon S3,Google搜索引擎这样的系统。
3)Strong 强一致性:新的数据一旦写入,在任意副本任意时刻都能读到新值。比如:文件系统,RDBMS,Azure Table都是强一致性的。

从这三种一致型的模型上可以看到,Weak和Eventually一般来说是异步冗余的,而Strong一般来说是同步冗余的,异步的通常意味着更好的性能,但也意味着更复杂的状态控制。同步意味着简单,但也意味着性能下降。好,由浅入深一步一步地来看有哪些技术:

Master-Slave

首先是Master-Slave结构,对于这种架构,Slave一般是Master的备份。在这样的系统中,一般是如下设计的:

1)读写请求都由Master负责。

2)写请求写到Master上后,由Master同步到Slave上。

从Master同步到Slave上,你可以使用异步,也可以使用同步,可以使用Master来push,也可以使用Slave来pull。 通常来说是Slave来周期性的pull,所以,是最终一致性。这个设计的问题是,如果Master在pull周期内垮掉了,那么会导致这个时间片内的数据丢失。如果你不想让数据丢掉,Slave只能成为Read-Only的方式等Master恢复。

当然,如果你可以容忍数据丢掉的话,可以马上让Slave代替Master工作(对于只负责计算的结点来说,没有数据一致性和数据丢失的问题,Master-Slave的方式就可以解决单点问题了);当然Master Slave也可以是强一致性的,比如:当我们写Master的时候,Master负责先写自己,等成功后,再写Slave,两者都成功后返回成功,整个过程是同步的,如果写Slave失败了,那么两种方法,一种是标记Slave不可用报错并继续服务(等Slave恢复后同步Master的数据,可以有多个Slave,这样少一个,还有备份,就像前面说的写三份那样),另一种是回滚自己并返回写失败。(注:一般不先写Slave,因为如果写Master自己失败后,还要回滚Slave,此时如果回滚Slave失败,就得手工订正数据了)。可以看到,如果Master-Slave需要做成强一致性有多复杂。

Master-Master

Master-Master,又叫Multi-master,是指一个系统存在两个或多个Master,每个Master都提供read-write服务。这个模型是Master-Slave的加强版,数据间同步一般是通过Master间的异步完成,所以是最终一致性。 Master-Master的好处是,一台Master挂了,别的Master可以正常做读写服务,他和Master-Slave一样,当数据没有被复制到别的Master上时,数据会丢失。很多数据库都支持Master-Master的Replication的机制。

另外,如果多个Master对同一个数据进行修改的时候,这个模型的恶梦就出现了——对数据间的冲突合并,这并不是一件容易的事情。看看Dynamo的Vector Clock的设计(记录数据的版本号和修改者)就知道这个事并不那么简单,而且Dynamo对数据冲突这个事是交给用户自己搞的。就像我们的SVN源码冲突一样,对于同一行代码的冲突,只能交给开发者自己来处理。

Two/Three Phase Commit

这个协议的缩写又叫2PC,中文叫两阶段提交。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。两阶段提交的算法如下:

第一阶段:
协调者会问所有的参与者结点,是否可以执行提交操作。
各个参与者开始事务执行的准备工作:如:为资源上锁,预留资源,写undo/redo log……
参与者响应协调者,如果事务的准备工作成功,则回应“可以提交”,否则回应“拒绝提交”。

第二阶段:
如果所有的参与者都回应“可以提交”,那么,协调者向所有的参与者发送“正式提交”的命令。参与者完成正式提交,并释放所有资源,然后回应“完成”,协调者收集各结点的“完成”回应后结束这个Global Transaction。

如果有一个参与者回应“拒绝提交”,那么,协调者向所有的参与者发送“回滚操作”,并释放所有资源,然后回应“回滚完成”,协调者收集各结点的“回滚”回应后,取消这个Global Transaction。


我们可以看到,2PC说白了就是第一阶段做Vote,第二阶段做决定的一个算法,也可以看到2PC这个事是强一致性的算法。在前面我们讨论过Master-Slave的强一致性策略,和2PC有点相似,只不过2PC更为保守一些——先尝试再提交。2PC用的是比较多的,在一些系统设计中,会串联一系列的调用,比如:A -> B -> C -> D,每一步都会分配一些资源或改写一些数据。比如我们B2C网上购物的下单操作在后台会有一系列的流程需要做。如果我们一步一步地做,就会出现这样的问题,如果某一步做不下去了,那么前面每一次所分配的资源需要做反向操作把他们都回收掉,所以操作起来比较复杂。现在很多处理流程(Workflow)都会借鉴2PC这个算法,使用 try -> confirm的流程来确保整个流程的能够成功完成。举个通俗的例子,西方教堂结婚的时候,都有这样的桥段:
1)牧师分别问新郎和新娘:你是否愿意……不管生老病死……(询问阶段)
2)当新郎和新娘都回答愿意后(锁定一生的资源),牧师就会说:我宣布你们……(事务提交)

这是多么经典的一个两阶段提交的事务处理。 另外我们也可以看到其中的一些问题, A)其中一个是同步阻塞操作,这个事情必然会非常大地影响性能。
B)另一个主要的问题是在TimeOut上,比如:
(1)如果第一阶段中,参与者没有收到询问请求,或是参与者的回应没有到达协调者。那么,需要协调者做超时处理,一旦超时,可以当作失败,也可以重试。
(2)如果第二阶段中,正式提交发出后,如果有的参与者没有收到,或是参与者提交/回滚后的确认信息没有返回,一旦参与者的回应超时,要么重试,要么把那个参与者标记为问题结点剔除整个集群,这样可以保证服务结点都是数据一致性的。
(3)糟糕的情况是,第二阶段中,如果参与者收不到协调者的commit/fallback指令,参与者将处于“状态未知”阶段,参与者完全不知道要怎么办,比如:如果所有的参与者完成第一阶段的回复后(可能全部yes,可能全部no,可能部分yes部分no),如果协调者在这个时候挂掉了。那么所有的结点完全不知道怎么办(问别的参与者都不行)。为了一致性,要么死等协调者,要么重发第一阶段的yes/no命令。

两段提交最大的问题就是第3)项,如果第一阶段完成后,参与者在第二阶没有收到决策,那么数据结点会进入“不知所措”的状态,这个状态会block住整个事务。也就是说,协调者Coordinator对于事务的完成非常重要,Coordinator的可用性是个关键。因些我们引入三段提交,三段提交在Wikipedia上的描述如下,他把二段提交的第一个段break成了两段:询问,然后再锁资源。最后真正提交。三段提交的示意图如下:


三段提交的核心理念是:在询问的时候并不锁定资源,除非所有人都同意了,才开始锁资源。

理论上来说,如果第一阶段所有的结点返回成功,那么有理由相信成功提交的概率很大。这样一来,可以降低参与者Cohorts的状态未知的概率。也就是说,一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了。这一点很重要。下面我们来看一下3PC的状态迁移图:(注意图中的虚线,那些F,T是Failuer或Timeout,其中的:状态含义是 q – Query,a – Abort,w – Wait,p – PreCommit,c – Commit)


从上图的状态变化图我们可以从虚线(那些F,T是Failuer或Timeout)看到——如果结点处在P状态(PreCommit)的时候发生了F/T的问题,三段提交比两段提交的好处是,三段提交可以继续直接把状态变成C状态(Commit),而两段提交则不知所措。

其实,三段提交是一个很复杂的事情,实现起来相当难,而且也有一些问题。

看到这里,我相信你有很多很多的问题,你一定在思考2PC/3PC中各种各样的失败场景,你会发现Timeout是个非常难处理的事情,因为网络上的Timeout在很多时候让你无所事从,你也不知道对方是做了还是没有做。于是你好好的一个状态机就因为Timeout成了个摆设。

一个网络服务会有三种状态:1)Success,2)Failure,3)Timeout,第三个绝对是恶梦,尤其在你需要维护状态的时候。

Two Generals Problem(两将军问题)

Two Generals Problem 两将军问题是这么一个思维性实验问题:有两支军队,它们分别有一位将军领导,现在准备攻击一座修筑了防御工事的城市。这两支军队都驻扎在那座城市的附近,分占一座山头。一道山谷把两座山分隔开来,并且两位将军唯一的通信方式就是派各自的信使来往于山谷两边。不幸的是,这个山谷已经被那座城市的保卫者占领,并且存在一种可能,那就是任何被派出的信使通过山谷是会被捕。

请注意,虽然两位将军已经就攻击那座城市达成共识,但在他们各自占领山头阵地之前,并没有就进攻时间达成共识。两位将军必须让自己的军队同时进攻城市才能取得成功。因此,他们必须互相沟通,以确定一个时间来攻击,并同意就在那时攻击。如果只有一个将军进行攻击,那么这将是一个灾难性的失败。 这个思维实验就包括考虑他们如何去做这件事情。下面是我们的思考:
1)第一位将军先发送一段消息“让我们在上午9点开始进攻”。然而,一旦信使被派遣,他是否通过了山谷,第一位将军就不得而知了。任何一点的不确定性都会使得第一位将军攻击犹豫,因为如果第二位将军不能在同一时刻发动攻击,那座城市的驻军就会击退他的军队的进攻,导致他的军对被摧毁。
2)知道了这一点,第二位将军就需要发送一个确认回条:“我收到您的邮件,并会在9点的攻击。”但是,如果带着确认消息的信使被抓怎么办?所以第二位将军会犹豫自己的确认消息是否能到达。
3)于是,似乎我们还要让第一位将军再发送一条确认消息——“我收到了你的确认”。然而,如果这位信使被抓怎么办呢?
4)这样一来,是不是我们还要第二位将军发送一个“确认收到你的确认”的信息。

靠,于是你会发现,这事情很快就发展成为不管发送多少个确认消息,都没有办法来保证两位将军有足够的自信自己的信使没有被敌军捕获。


这个问题是无解的。两个将军问题和它的无解证明首先由E.A.Akkoyunlu,K.Ekanadham和R.V.Huber于1975年在《一些限制与折衷的网络通信设计》一文中发表,就在这篇文章的第73页中一段描述两个黑帮之间的通信中被阐明。 1978年,在Jim Gray的《数据库操作系统注意事项》一书中(从第465页开始)被命名为两个将军悖论。作为两个将军问题的定义和无解性的证明的来源,这一参考被广泛提及。

这个实验意在阐明:试图通过建立在一个不可靠的连接上的交流来协调一项行动的隐患和设计上的巨大挑战。

从工程上来说,一个解决两个将军问题的实际方法是使用一个能够承受通信信道不可靠性的方案,并不试图去消除这个不可靠性,但要将不可靠性削减到一个可以接受的程度。比如,第一位将军排出了100位信使并预计他们都被捕的可能性很小。在这种情况下,不管第二位将军是否会攻击或者受到任何消息,第一位将军都会进行攻击。另外,第一位将军可以发送一个消息流,而第二位将军可以对其中的每一条消息发送一个确认消息,这样如果每条消息都被接收到,两位将军会感觉更好。然而我们可以从证明中看出,他们俩都不能肯定这个攻击是可以协调的。他们没有算法可用(比如,收到4条以上的消息就攻击)能够确保防止仅有一方攻击。再者,第一位将军还可以为每条消息编号,说这是1号,2号……直到n号。这种方法能让第二位将军知道通信信道到底有多可靠,并且返回合适的数量的消息来确保最后一条消息被接收到。如果信道是可靠的话,只要一条消息就行了,其余的就帮不上什么忙了。最后一条和第一条消息丢失的概率是相等的。

两将军问题可以扩展成更变态的拜占庭将军问题 (Byzantine Generals Problem),其故事背景是这样的:拜占庭位于现在土耳其的伊斯坦布尔,是东罗马帝国的首都。由于当时拜占庭罗马帝国国土辽阔,为了防御目的,因此每个军队都分隔很远,将军与将军之间只能靠信差传消息。在战争的时候,拜占庭军队内所有将军必需达成一致的共识,决定是否有赢的机会才去攻打敌人的阵营。但是,军队可能有叛徒和敌军间谍,这些叛徒将军们会扰乱或左右决策的过程。这时候,在已知有成员谋反的情况下,其余忠诚的将军在不受叛徒的影响下如何达成一致的协议,这就是拜占庭将军问题。

Paxos算法

Wikipedia上的各种Paxos算法的描述非常详细,大家可以去围观一下。

Paxos 算法解决的问题是在一个可能发生上述异常的分布式系统中如何就某个值达成一致,保证不论发生以上任何异常,都不会破坏决议的一致性。一个典型的场景是,在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点都执行相同的操作序列,那么他们最后能得到一个一致的状态。为保证每个节点执行相同的命令序列,需要在每一条指令上执行一个「一致性算法」以保证每个节点看到的指令一致。一个通用的一致性算法可以应用在许多场景中,是分布式计算中的重要问题。从20世纪80年代起对于一致性算法的研究就没有停止过。

Notes:Paxos算法是莱斯利·兰伯特(Leslie Lamport,就是 LaTeX 中的”La”,此人现在在微软研究院)于1990年提出的一种基于消息传递的一致性算法。由于算法难以理解起初并没有引起人们的重视,使Lamport在八年后1998年重新发表到ACM Transactions on Computer Systems上(The Part-Time Parliament)。即便如此paxos算法还是没有得到重视,2001年Lamport 觉得同行无法接受他的幽默感,于是用容易接受的方法重新表述了一遍(Paxos Made Simple)。可见Lamport对Paxos算法情有独钟。近几年Paxos算法的普遍使用也证明它在分布式一致性算法中的重要地位。2006年Google的三篇论文初现“云”的端倪,其中的Chubby Lock服务使用Paxos作为Chubby Cell中的一致性算法,Paxos的人气从此一路狂飙。(Lamport 本人在 他的blog 中描写了他用9年时间发表这个算法的前前后后)

注:Amazon的AWS中,所有的云服务都基于一个ALF(Async Lock Framework)的框架实现的,这个ALF用的就是Paxos算法。我在Amazon的时候,看内部的分享视频时,设计者在内部的Principle Talk里说他参考了ZooKeeper的方法,但他用了另一种比ZooKeeper更易读的方式实现了这个算法。

简单说来,Paxos的目的是让整个集群的结点对某个值的变更达成一致。Paxos算法基本上来说是个民主选举的算法——大多数的决定会成个整个集群的统一决定。任何一个点都可以提出要修改某个数据的提案,是否通过这个提案取决于这个集群中是否有超过半数的结点同意(所以Paxos算法需要集群中的结点是单数)。

这个算法有两个阶段(假设这个有三个结点:A,B,C):

第一阶段:Prepare阶段

A把申请修改的请求Prepare Request发给所有的结点A,B,C。注意,Paxos算法会有一个Sequence Number(你可以认为是一个提案号,这个数不断递增,而且是唯一的,也就是说A和B不可能有相同的提案号),这个提案号会和修改请求一同发出,任何结点在“Prepare阶段”时都会拒绝其值小于当前提案号的请求。所以,结点A在向所有结点申请修改请求的时候,需要带一个提案号,越新的提案,这个提案号就越是是最大的。

如果接收结点收到的提案号n大于其它结点发过来的提案号,这个结点会回应Yes(本结点上最新的被批准提案号),并保证不接收其它<n的提案。这样一来,结点上在Prepare阶段里总是会对最新的提案做承诺。

优化:在上述 prepare 过程中,如果任何一个结点发现存在一个更高编号的提案,则需要通知 提案人,提醒其中断这次提案。

第二阶段:Accept阶段

如果提案者A收到了超过半数的结点返回的Yes,然后他就会向所有的结点发布Accept Request(同样,需要带上提案号n),如果没有超过半数的话,那就返回失败。

当结点们收到了Accept Request后,如果对于接收的结点来说,n是最大的了,那么,它就会修改这个值,如果发现自己有一个更大的提案号,那么,结点就会拒绝修改。

我们可以看以,这似乎就是一个“两段提交”的优化。其实,2PC/3PC都是分布式一致性算法的残次版本,Google Chubby的作者Mike Burrows说过这个世界上只有一种一致性算法,那就是Paxos,其它的算法都是残次品。

我们还可以看到:对于同一个值的在不同结点的修改提案就算是在接收方被乱序收到也是没有问题的。

关于一些实例,你可以看一下Wikipedia中文中的“Paxos样例”一节,我在这里就不再多说了。对于Paxos算法中的一些异常示例,大家可以自己推导一下。你会发现基本上来说只要保证有半数以上的结点存活,就没有什么问题。

多说一下,自从Lamport在1998年发表Paxos算法后,对Paxos的各种改进工作就从未停止,其中动作最大的莫过于2005年发表的Fast Paxos。无论何种改进,其重点依然是在消息延迟与性能、吞吐量之间作出各种权衡。为了容易地从概念上区分二者,称前者Classic Paxos,改进后的后者为Fast Paxos。

总结

下图来自:Google App Engine的co-founder Ryan Barrett在2009年的google i/o上的演讲《Transaction Across DataCenter》(视频: http://www.youtube.com/watch?v=srOgpXECblk)


前面,我们说过,要想让数据有高可用性,就需要冗余数据写多份。写多份的问题会带来一致性的问题,而一致性的问题又会带来性能问题。从上图我们可以看到,我们基本上来说不可以让所有的项都绿起来,这就是著名的CAP理论:一致性,可用性,分区容忍性,你只可能要其中的两个。

NWR模型

最后我还想提一下Amazon Dynamo的NWR模型。这个NWR模型把CAP的选择权交给了用户,让用户自己的选择你的CAP中的哪两个。

所谓NWR模型。N代表N个备份,W代表要写入至少W份才认为成功,R表示至少读取R个备份。配置的时候要求W+R > N。因为W+R > N, 所以 R > N-W 这个是什么意思呢?就是读取的份数一定要比总备份数减去确保写成功的倍数的差值要大。

也就是说,每次读取,都至少读取到一个最新的版本。从而不会读到一份旧数据。当我们需要高可写的环境的时候,我们可以配置W = 1 如果N=3 那么R = 3。这个时候只要写任何节点成功就认为成功,但是读的时候必须从所有的节点都读出数据。如果我们要求读的高效率,我们可以配置 W=N R=1。这个时候任何一个节点读成功就认为成功,但是写的时候必须写所有三个节点成功才认为成功。

NWR模型的一些设置会造成脏数据的问题,因为这很明显不是像Paxos一样是一个强一致的东西,所以,可能每次的读写操作都不在同一个结点上,于是会出现一些结点上的数据并不是最新版本,但却进行了最新的操作。

所以Amazon Dynamo引了数据版本的设计。也就是说,如果你读出来数据的版本是v1,当你计算完成后要回填数据后,却发现数据的版本号已经被人更新成了v2,那么服务器就会拒绝你。版本这个事就像“乐观锁”一样。

但是,对于分布式和NWR模型来说,版本也会有恶梦的时候——就是版本冲的问题,比如:我们设置了N=3 W=1,如果A结点上接受了一个值,版本由v1 -> v2,但还没有来得及同步到结点B上(异步的,应该W=1,写一份就算成功),B结点上还是v1版本,此时,B结点接到写请求,按道理来说,他需要拒绝掉,但是他一方面并不知道别的结点已经被更新到v2,另一方面他也无法拒绝,因为W=1,所以写一分就成功了。于是,出现了严重的版本冲突。

Amazon的Dynamo把版本冲突这个问题巧妙地回避掉了——版本冲这个事交给用户自己来处理。

于是,Dynamo引入了Vector Clock(矢量钟?!)这个设计。这个设计让每个结点各自记录自己的版本信息,也就是说,对于同一个数据,需要记录两个事:
1)谁更新的我
2)我的版本号是什么

下面,我们来看一个操作序列:
1)一个写请求,第一次被节点A处理了。节点A会增加一个版本信息(A,1)。我们把这个时候的数据记做D1(A,1)。 然后另外一个对同样key的请求还是被A处理了于是有D2(A,2)。这个时候,D2是可以覆盖D1的,不会有冲突产生。
2)现在我们假设D2传播到了所有节点(B和C),B和C收到的数据不是从客户产生的,而是别人复制给他们的,所以他们不产生新的版本信息,所以现在B和C所持有的数据还是D2(A,2)。于是A,B,C上的数据及其版本号都是一样的。
3)如果我们有一个新的写请求到了B结点上,于是B结点生成数据D3(A,2;B,1),意思是:数据D全局版本号为3,A升了两新,B升了一次。这不就是所谓的代码版本的log么?
4)如果D3没有传播到C的时候又一个请求被C处理了,于是,以C结点上的数据是D4(A,2; C,1)。
5)好,最精彩的事情来了:如果这个时候来了一个读请求,我们要记得,我们的W=1 那么R=N=3,所以R会从所有三个节点上读,此时,他会读到三个版本:
A结点:D2(A,2)
B结点:D3(A,2;  B,1);
C结点:D4(A,2;  C,1)
6)这个时候可以判断出,D2已经是旧版本(已经包含在D3/D4中),可以舍弃。
7)但是D3和D4是明显的版本冲突。于是,交给调用方自己去做版本冲突处理。就像源代码版本管理一样。

很明显,上述的Dynamo的配置用的是CAP里的A和P。

非常推大家都去看看这篇论文:《Dynamo:Amazon’s Highly Available Key-Value Store》,如果英文痛苦,你可以看看译文(译者不详),或直接打开:[doc]译文Dynamo.Amazon的高可用性的键值存储系统

本文转自:分布式系统的事务处理


分布式事务七种经典的解决方案

随着业务的快速发展、业务复杂度越来越高,几乎每个公司的系统都会从单体走向分布式,特别是转向微服务架构。随之而来就必然遇到分布式事务这个难题,本节总结了分布式事务最经典的解决方案,分享给大家。

基础理论

在讲解具体方案之前,先了解一下分布式事务所涉及到的基础理论知识。拿转账作为例子,A需要转100元给B,那么需要给A的余额-100元,给B的余额+100元,整个转账要保证,A-100和B+100同时成功,或者同时失败。 看看在各种场景下,是如何解决这个问题的。

事务

把多条语句作为一个整体进行操作的功能,被称为数据库事务。数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败。事务具有 4 个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID 特性。

Atomicity(原子性):一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复到事务开始前的状态,就像这个事务从来没有执行过一样。

Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。完整性包括外键约束、应用定义的等约束不会被破坏。

Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

分布式事务

银行跨行转账业务是一个典型分布式事务场景,假设A需要跨行转账给B,那么就涉及两个银行的数据,无法通过一个数据库的本地事务保证转账的ACID,只能够通过分布式事务来解决。

分布式事务就是指事务的发起者、资源及资源管理器和事务协调者分别位于分布式系统的不同节点之上。在上述转账的业务中,用户A-100操作和用户B+100操作不是位于同一个节点上。本质上来说,分布式事务就是为了保证在分布式场景下,数据操作的正确执行。

分布式事务在分布式环境下,为了满足可用性、性能与降级服务的需要,降低一致性与隔离性的要求,一方面遵循 BASE 理论(BASE相关理论,涉及内容非常多,感兴趣的同学,可以参考BASE理论):

基本业务可用性(Basic Availability)

柔性状态(Soft state)

最终一致性(Eventual consistency)

同样的,分布式事务也部分遵循 ACID 规范:

原子性:严格遵循

一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽

隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽

持久性:严格遵循

分布式事务的解决方案

1、两阶段提交/XA

XA是由X/Open组织提出的分布式事务的规范,XA规范主要定义了(全局)事务管理器(TM)和(局部)资源管理器(RM)之间的接口。本地的数据库如MySQL在XA中扮演的是RM角色。

XA一共分为两阶段:

第一阶段(prepare):即所有的参与者RM准备执行事务并锁住需要的资源。参与者ready时,向TM报告已准备就绪。

第二阶段(commit/rollback):当事务管理者(TM)确认所有参与者(RM)都ready后,向所有参与者发送commit命令。

目前主流的数据库基本都支持XA事务,包括MySQL、Oracle、SQL Server、PostgreSQL。

XA事务由一个或多个资源管理器(RM)、一个事务管理器(TM)和一个应用程序(ApplicationProgram)组成。

把上面的转账作为例子,一个成功完成的XA事务时序图如下:


如果有任何一个参与者prepare失败,那么TM会通知所有完成prepare的参与者进行回滚。

XA事务的特点是:
简单易理解,开发较容易
对资源进行了长时间的锁定,并发度低

如果读者想要进一步研究XA,Go语言可参考github.com/yedf/dtm,Java语言可参考github.com/seata/seata。

2、SAGA

Saga是这一篇数据库论文Saga提到的一个方案。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

把上面的转账作为例子,一个成功完成的Saga事务时序图如下:


Saga事务的特点:

并发度高,不用像XA事务那样长期锁定资源

需要定义正常操作以及补偿操作,开发量比XA大

一致性较弱,对于转账,可能发生A用户已扣款,最后转账又失败的情况

论文里面的Saga内容较多,包括两种恢复策略,包括分支事务并发执行,我们这里的讨论,仅包括最简单的Saga。

Saga适用的场景较多,长事务适用,对中间结果不敏感的业务场景适用。

如果读者想要进一步研究Saga,Go语言可参考DTM,Java语言可参考seata。

3、TCC

关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。

TCC分为3个阶段:

Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)

Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作要求具备幂等设计,Confirm 失败后需要进行重试。

Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致,要求满足幂等设计。

把上面的转账作为例子,通常会在Try里面冻结金额,但不扣款,Confirm里面扣款,Cancel里面解冻金额,一个成功完成的TCC事务时序图如下:


TCC特点如下:

并发度较高,无长期资源锁定

开发量较大,需要提供Try/Confirm/Cancel接口

一致性较好,不会发生SAGA已扣款最后又转账失败的情况

TCC适用于订单类业务,对中间状态有约束的业务

如果读者想要进一步研究TCC,Go语言可参考DTM,Java语言可参考seata。

4、本地消息表

本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章。设计核心是将需要分布式处理的任务通过消息的方式来异步确保执行。

大致流程如下:


写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。

容错机制:

扣减余额事务 失败时,事务直接回滚,无后续步骤

轮序生产消息失败, 增加余额事务失败都会进行重试

本地消息表的特点:

长事务仅需要分拆成多个任务,使用简单

生产者需要额外的创建消息表

每个本地消息表都需要进行轮询

消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作

适用于可异步执行的业务,且后续操作无需回滚的业务。

事务消息:在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上,解决生产端的消息发送与本地事务执行的原子性问题。

事务消息发送及提交:

发送消息(half消息)

服务端存储消息,并响应消息的写入结果

根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)

根据本地事务状态执行Commit或者Rollback(Commit操作发布消息,消息对消费者可见)

正常发送的流程图如下:


补偿流程:
对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”

Producer收到回查消息,返回消息对应的本地事务的状态,为Commit或者Rollback

事务消息方案与本地消息表机制非常类似,区别主要在于原先相关的本地表操作替换成了一个反查接口

事务消息特点如下:
长事务仅需要分拆成多个任务,并提供一个反查接口,使用简单

消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作

适用于可异步执行的业务,且后续操作无需回滚的业务。

如果读者想要进一步研究事务消息,可参考github.com/apache/rocketmq,为了方便一站式解决分布式事务问题,github.com/yedf/dtm也提供了实现。

5、最大努力通知

发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
有一定的消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。

消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。

前面介绍的的本地消息表和事务消息都属于可靠消息,与这里介绍的最大努力通知有什么不同?

可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。

最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。

解决方案上,最大努力通知需要:
提供接口,让接受通知放能够通过接口查询业务处理结果

消息队列ACK机制,消息队列按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔 ,直到达到通知要求的时间窗口上限。之后不再通知。

最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口。

6、AT事务模式

这是阿里开源项目seata中的一种事务模式,在蚂蚁金服也被称为FMT。优点是该事务模式使用方式,类似XA模式,业务无需编写各类补偿操作,回滚由框架自动完成,缺点也类似AT,存在较长时间的锁,不满足高并发的场景。有兴趣的同学可以参考seata-AT。

异常处理

在分布式事务的各个环节都有可能出现网络以及业务故障等问题,这些问题需要分布式事务的业务方做到防空回滚,幂等,防悬挂三个特性。

异常情况

下面以TCC事务说明这些异常情况:

空回滚:

在没有调用TCC资源Try方法的情况下,调用了二阶段的Cancel方法,Cancel方法需要识别出这是一个空回滚,然后直接返回成功。出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。

幂等:由于任何一个请求都可能出现网络异常,出现重复请求,所以所有的分布式事务分支,都需要保证幂等性。

悬挂:

悬挂就是对于一个分布式事务,其二阶段Cancel接口比Try接口先执行。

出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵,RPC超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC请求才到达参与者真正执行。

下面看一个网络异常的时序图,更好的理解上述几种问题 。

业务处理请求4的时候,Cancel在Try之前执行,需要处理空回滚

业务处理请求6的时候,Cancel重复执行,需要幂等

业务处理请求8的时候,Try在Cancel后执行,需要处理悬挂

面对上述复杂的网络异常情况,目前看到各家建议的方案都是业务方通过唯一键,去查询相关联的操作是否已完成,如果已完成则直接返回成功。相关的判断逻辑较复杂,易出错,业务负担重。

7、子事务屏障

在项目DTM中,首创了一种子事务屏障技术,使用该技术,能够达到这个效果,看示意图:


所有这些请求,到了子事务屏障后:不正常的请求,会被过滤;正常请求,通过屏障。开发者使用子事务屏障之后,前面所说的各种异常全部被妥善处理,业务开发人员只需要关注实际的业务逻辑,负担大大降低。

子事务屏障提供了方法ThroughBarrierCall,方法的原型为:
func ThroughBarrierCall(db *sql.DB, transInfo *TransInfo, busiCall BusiFunc)

业务开发人员,在busiCall里面编写自己的相关逻辑,调用该函数。ThroughBarrierCall保证,在空回滚、悬挂等场景下,busiCall不会被调用;在业务被重复调用时,有幂等控制,保证只被提交一次。

子事务屏障会管理TCC、SAGA、XA、事务消息等,也可以扩展到其他领域。

原理:子事务屏障技术的原理是,在本地数据库,建立分支事务状态表sub_trans_barrier,唯一键为全局事务id-子事务id-子事务分支名称(try|confirm|cancel)。

开启事务

如果是Try分支,则那么insert ignore插入gid-branchid-try,如果成功插入,则调用屏障内逻辑

如果是Confirm分支,那么insert ignore插入gid-branchid-confirm,如果成功插入,则调用屏障内逻辑

如果是Cancel分支,那么insert ignore插入gid-branchid-try,再插入gid-branchid-cancel,如果try未插入并且cancel插入成功,则调用屏障内逻辑

屏障内逻辑返回成功,提交事务,返回成功

屏障内逻辑返回错误,回滚事务,返回错误

在此机制下,解决了网络异常相关的问题:
空补偿控制--如果Try没有执行,直接执行了Cancel,那么Cancel插入gid-branchid-try会成功,不走屏障内的逻辑,保证了空补偿控制

幂等控制--任何一个分支都无法重复插入唯一键,保证了不会重复执行

防悬挂控制--Try在Cancel之后执行,那么插入的gid-branchid-try不成功,就不执行,保证了防悬挂控制

对于SAGA事务,也是类似的机制。

子事务屏障小结

子事务屏障技术,为DTM首创,它的意义在于设计简单易实现的算法,提供了简单易用的接口,在首创,它的意义在于设计简单易实现的算法,提供了简单易用的接口,在这两项的帮助下,开发人员彻底的从网络异常的处理中解放出来。

该技术目前需要搭配yedf/dtm事务管理器,目前SDK已经提供给Go语言的开发者。其他语言的SDK正在规划中。对于其他的分布式事务框架,只要提供了合适的分布式事务信息,能够按照上述原理,快速实现该技术。

小结

本文介绍了分布式事务的一些基础理论,并对常用的分布式事务方案进行了讲解,在文章的后半部分还给出了事务异常的原因、分类以及优雅的解决方案。yedf/dtm支持了TCC、XA、SAGA、事务消息、最大努力通知(使用事务消息实现),提供了简洁易用的接入。

本节转自:IT架构师联盟


原生分布式数据库与分库分表中间件、云原生数据库有何区别

2022年1月消息,我们正处于数据库从互联网基础软件转变为社会数字化基础软件的时代,在传统集中式数据库已不能满足大规模数据承载需求与高并发处理需求的形势下,基于海量数据场景应用而生的分布式数据库迎来应用热潮。据IDC调研,目前约26.8%的企业级市场用户部署了分布式数据库,超过90%的企业认可分布式数据库部署后的效果,在分布式数据库中,主要有三类解决方案:
第一类是以中间件+单机数据库为主的分布式数据库,下层的单机数据库提供存储和执行能力,在多个单机数据库上封装一层中间层,以统一分片规则管理及处理分布在不同数据库节点的数据,并提供SQL解析,请求转发和结果合并的能力;

第二类是原生分布式数据库,在架构设计之初,便根据分布式一致性协议做底层设计,因此数据的存储、查询、处理都天然具备分布式特性,各数据节点提供对等的读写服务,组成统一的集群对外提供服务;

第三类是通过构建分布式共享存储实现扩展,采用非对称计算节点,大部分公有云数据库都属于此类。


在本节中重点说说前两类。

无论是分库分表中间件还是原生分布式数据库,目的都是解决数据容量问题,但实际上,二者的实践路径有本质区别。那么具体而言,分库分表中间件与原生分布式数据库的区别在哪,各自有哪些优劣势?原生分布式数据库与如今热炒的云原生数据库又是什么关系。CSDN采访了OceanBase的CTO杨传辉,以下为他的解读。

原生分布式数据库与分库分表中间件的区别

区别一:是否依赖中间件

分库分表中间件大多采用中间件来补充分布式的能力,也就是说在各数据库节点上,架构一层事务处理和查询优化的中间层。在这套系统中,各个数据库可能是同构型,也可能是异构型,将各个数据库组合在一起,是一种松耦合的方式,会暴露不少问题,如事务处理能力、高可用受限等。

原生分布式数据库,由各个同构型的数据库组成,每个数据库节点天然具备分布式的能力,无需借助额外的中间件,也无需用户关注集群实现细节,是一个紧耦合的系统。由此可见,原生分布式数据库的能力有很大发展空间,但其开发难度也较大。

区别二:是否依赖分库分表

弹性扩容能力是考验分布式数据库面对流量高峰或极端场景时能否持续稳定运行的重要因素。比如在交易场景中,高峰时每秒可产生一百万笔订单,每笔订单对于数据库而言都是一个业务,需要数据库做扩容处理,其中的关键点就在于是否采用分库分表的方式,这对扩容能力有很大制约。

分库分表中间件的分片规则基于算法提供,下层计算节点之间并不会进行数据交互,扩展下层计算节点的时候就无法按需扩展。通过分库分表将原有机器划分为一百份,每台机器处理一笔业务,而如果想进一步拆分为一千份、一万份就很难做到了。这个过程还需要停机,由开发人员手动拆库,效率低,且弹性有限,但好在对数据库的依赖也低。

原生分布式数据库中设有分区表的功能,可以将中间件所拆分的一百份,每份再拆一百份,总共一万份,且每一份都至少能在一台机器被处理。在此过程中,系统自动扩容、按需扩容,不受数量和规模的限制,且无需人为干预。

分库分表中间件的核心理念是让多台服务器协同工作,完成单台服务器无法处理的任务,尤其是高并发或者大数据量的任务。而原生分布式高可用设计能够在普通服务器上实现无限水平扩展,通过添加低成本服务器即可扩展算力,提升数据库集群的整体性能。

区别三:是否实现数据强一致

分库分表中间件由于其架构特性,本质是将把单机数据库进行二次处理,在数据一致性、全局事务能力、全局MVCC、副本控制、高可用等方面存在短板,需要有针对性增强。

大多数原生分布式数据库是在分布式KV的基础上发展出SQL计算引擎,将分布式存储、事务、计算有机的结合在一起,数据由系统自动打散并存储多个副本,通过一致性协议保证多个副本和事务日志的一致性,对分布式事务、全局MVCC等支持更为彻底。

区别四:能否实现业务平滑迁移

众所周知,数据库对企业与机构来说是心脏所在,尤其是金融、能源、社保、政务等行业。当数据库已达到能力天花板或不足以满足企业需求时,企业也会考虑“换心”来维持活力。此时数据能否平滑迁移至新的数据库,保证数据的正确性且不丢失、保证业务不受影响是重要考虑因素。

分库分表中间件由于本身不具备分布式的能力,进行数据迁移时需要对业务做改造。才能使数据库正常运行。而且,由于每张表只能有一个分片规则,业务建模需要重新规划,业务代码也要相应修改,改造成本高。

而原生分布式数据库因其高兼容和透明性的优势,可以在不改业务代码的情况下,支持数据迁移,并保证数据库正常运行,不损害性能。因为整个分布式结构是包裹在集群内部的,应用对此无感知,对应用来说,与使用集中式数据库没有区别,所以不需要分布式改造。此外,由于原生分布式数据库对硬件依赖少,在云时代,可以灵活进行混合云和多云部署,以及跨多云的数据管理,为企业提供了更多且更方便的服务选择。

总的来说,分库分表中间件通常是在单机数据库之上构建数据分区的特性,支持扩展能力。因为基于传统的单机数据库,相对来说比较稳定,用户更容易上手。但缺点也很明显,其底层不具备分布式能力,只是在宏观架构上加补丁,不断增加机器冗余和系统复杂度,能力天花板较低。原生分布式数据库虽然综合表现更佳,而且支持调整底层模型,优化各方面的能力。但由于技术发展时间相对短,一些产品解决方案成熟度待提升。从眼下的行业发展趋势来看,随着技术的不断成熟,原生分布式数据库正在成为主流,大量企业与组织开始着手对传统数据库进行升级。

原生分布式数据库和云原生数据库的区别

云原生数据库天然生长在云环境中,而且都是分布式的。云原生数据库有两种分布式的方式,一种方式是计算存储分离,例如,计算部分采用集中式数据库,存储采用远程的、分布式的文件系统。另一种方式是,计算与存储都采用分布式。而原生分布式数据库既可以部署在本地或私有云,也可以部署在公有云上。比如OceanBase,除了支持计算存储分离的分布式之外,它还支持计算系统的分布式扩展,提升系统的处理能力。当它被部署到云上,就成为了云原生分布式数据库,提供与本地相同的用户体验。

此外,原生分布式数据库部署灵活的特性意味着不被特定硬件和服务绑定,能够做到机房部署、任意公有云部署,甚至集群内跨多基础设施的混合云或多云部署,被称为“多云原生”。

分库分表中间件在十多年前顺应互联网时代而生,相比传统集中式数据库,它确实帮助很多企业解决了降本增效的根本问题,顶住了更多的服务压力。如今十几年过去,新的数据库处理需求使传统分布式数据库的不足与限制暴露无遗,适应新环境需求的解决方案,即原生分布式数据库应时而生,逐渐变得炙手可热。未来,原生分布式数据库结合云原生,将发挥更大的能力。而企业在选择数据库时,不管是分库分表中间件还是原生分布式数据库,亦或云原生分布式数据库,首要考虑数据的正确性。怎么判定数据库是否能够保证数据正确性呢?

第一,这款分布式数据库经过了核心业务场景的检验,比如在金融、大促等海量、实时的数据处理场景中,持续稳定。

第二,该分布式数据库已经在行业中证明自己,如TPC-C(主要测试联机交易处理能力)、TPC-H(检测系统处理复杂查询分析能力的多个方面)等数据库行业公认的测试,以及行业企业的良好口碑。

此外,每个企业需要根据业务特性选择数据库的功能特性,可以视情况做具体取舍。如今,分库分表中间件面临的挑战愈发巨大,具备一体化架构的原生分布式数据库正成为不少企业试用并应用的对象。其兼顾集中式与分布式的双重技术优势,将多种负载能力融合于一套数据库中。一方面具备强兼容性,能够兼容传统数据库如MySQL、Oracle的功能与单机性能;一方面具备强扩展性,当机器容量不足时,只需扩展机器,就能够解决大规模的需求。重要的是,一套系统便可处理OLTP(联机事务处理)与OLAP(联机分析处理)的需求,并保证事务的ACID(原子性、一致性、隔离性和持久性),加之本地部署与多云部署的灵活特性,也更适用于社会数字化时代。


小结

1. 分布式事务基础理论

1.1 本地事务

本地事务是指将多条语句作为一个整体进行操作的功能,通过数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败,如果事务失败,那么效果就和没有执行这些 SQL 一样,不会对数据库数据有任何改动。也就是事务具有原子性,一个事务中的一系列操作要么全部成功,要么全部失败。一般来说,事务具有 4 个属性:
Atomic:原子性,将一个事务中的所有 SQL 作为原子工作单元执行,要么全部执行,要么全部不执行;
Consistent:一致性,事务完成后,所有数据的状态都是一致的,以银行转帐为例,如果 A 账户减去了 100,则 B 账户则必定加上了 100;
Isolation:隔离性,如果有多个事务并发执行,每个事务作出的修改必须与其他事务隔离;
Duration:持久性,即事务完成后,对数据库数据的修改被持久化存储。

这四个属性通常称为 ACID 特性。


1.2 分布式事务

当项目用上了微服务之后,分布式事务就是一个比较常见的问题了,也会遇到很多相关的场景。就拿商品下单的分布式事务的案例来说,像下面这样,一共有五个服务,架构如下图:


eureka:这是服务注册中心。
account:这是账户服务,可以查询 / 修改用户的账户信息(主要是账户余额)。
order:这是订单服务,可以下订单。
storage:这是一个仓储服务,可以查询 / 修改商品的库存数量。
bussiness:这是业务,用户下单操作将在这里完成。

当用户想要下单的时候,调用了 bussiness 中的接口,bussiness 中的接口又调用了它自己的 service,在 service 中,通过 feign 调用 storage 中的接口去扣库存,然后再通过 feign 调用 order 中的接口去创建订单(order 在创建订单的时候,不仅会创建订单,还会扣除用户账户的余额)。

这三个操作,希望它们能够同时成功或者同时失败。然而如上图所示,三个微服务都有自己的 DB,这是三个完全不同的 DB,相当于三个不同的本地事务,按照传统的本地事务规则,显然是无法实现三个操作同时成功或者同时失败的。

想要实现 storage、order 以及 account 中的操作同时成功或者同时失败,就得考虑分布式事务了。

最后再来看看分布式事务的概念:分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于的不同节点之上,数据库的操作执行成功与否,不仅取决于本地 DB 的执行结果,也取决于第三方系统的执行结果。而分布式事务就保证这些操作要么全部成功,要么全部失败。本质上,分布式事务就是为了保证不同数据库的数据一致性。

1.3 CAP

CAP 定理(CAP theorem),有时候又被称作布鲁尔定理(Brewer's theorem),它指出对于一个分布式计算系统来说,不可能同时满足以下三点:
1)一致性(Consistency):在分布式系统中的所有数据备份,在同一时刻是否具备同样的值。(等同于所有节点访问同一份最新的数据副本)。
2)可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)。
3)分区容错性(Partition tolerance):这个觉得可能对有的小伙伴来说有点难以理解,就简单说一下,先来说分区:因为是分布式系统,分布式系统中不同的微服务位于不同的网络节点上,当发生网络故障或者节点故障的时候,不同的服务之间就无法通信了,也就是说发生了分区;再来看分区容错性:这是说,当系统中出现分区的时候,系统还要能运行,不能罢工!一般来说,在一个分布式系统中,分区发生的概率还是比较大的,不会发生分区的系统,那就不是分布式系统了,而是单体应用了。

CAP 原则的精髓就是要么 AP,要么 CP,要么 AC,但是不存在 CAP。因为在分布式系统内,P 是必然的发生的,不选 P,一旦发生分区,整个分布式系统就完全无法使用了,这样的系统就太脆弱了。所以对于分布式系统,只能能考虑当发生分区错误时,如何选择一致性和可用性(选择一致性,意味着服务在某段时间内不可用,选择了可用性,意味着服务虽然一直可用但是返回的数据却不一致)。

而根据一致性和可用性的选择不同,开源的分布式系统往往又被分为 CP 系统和 AP 系统。

当一套系统在发生分区故障后,客户端的任何请求都被卡死或者超时,但是系统的每个节点总是会返回一致的数据,则这套系统就是 CP 系统,经典的比如 Zookeeper。

如果一套系统发生分区故障后,客户端依然可以访问系统,但是获取的数据有的是新的数据,有的还是老数据,那么这套系统就是 AP 系统,经典的比如 Eureka。

1.4 BASE

因为无法同时满足 CAP,所以又有了 BASE 理论,BASE 理论指的是:
1)基本可用 Basically Available:分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。
2)软状态 Soft State:允许系统存在中间状态,而该中间状态不会影响系统整体可用性。
3)终一致性 Eventual Consistency:系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。

BASE 理论的核心思想是即便无法做到强一致性,但应该采用适合的方式保证最终一致性。其本质上是对 CAP 理论的延伸,是对 CAP 中 AP 方案的一个补充。

1.5 刚柔并济

事务有刚性事务和柔性事务之分。

刚性事务(如单数据库中的本地事务)完全遵循 ACID 规范,即数据库事务正确执行的四个基本要素:
原子性(Atomicity)
一致性(Consistency)
隔离性(Isolation)
持久性(Durability)

柔性事务,主要就是只分布式事务了,柔性事务为了满足可用性、性能与降级服务的需要,降低一致性(Consistency)与隔离性(Isolation)的要求,遵守 BASE 理论:
基本业务可用性(Basic Availability)
柔性状态(Soft state)
最终一致性(Eventual consistency)

当然,柔性事务也部分遵循 ACID 规范:
原子性:严格遵循
一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽
隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽
持久性:严格遵循

柔性事务有不同的分类,不过基本上都可以看作是分布式事务的解决方案:
两阶段型:分布式事务二阶段提交,对应技术上的 XA、JTA/JTS,这是分布式环境下事务处理的典型模式。
补偿型:我们之前文章介绍的 TCC,就算是一种补偿型事务,在 Try 成功的情况下,如果事务要回滚,Cancel 将作为一个补偿机制,回滚 Try 操作;TCC 各操作事务本地化,且尽早提交(没有两阶段约束);当全局事务要求回滚时,通过另一个本地事务实现 “补偿” 行为。 TCC 是将资源层的二阶段提交协议转换到业务层,成为业务模型中的一部分。
异步确保型:将一些有同步冲突的事务操作变为异步操作,避免对数据库事务的争用,如消息事务机制。
最大努力通知型:通过通知服务器(消息通知)进行,允许失败,有补充机制。

2. 分布式事务实践

2.1 XA

先来说说 XA,它是一种典型的两阶段提交(2PC,Two-phase commit protocol),而两阶段提交是一种强一致性设计,在两阶段提交中,一般会引入一个事务协调者的角色来协调管理各个事务参与者,所谓的两阶段分别指的是准备和提交两个阶段。

XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。

XA 规范描述了全局的事务管理器与局部的资源管理器之间的接口。 XA 规范的目的是允许多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。

XA 规范使用两阶段提交来保证所有资源同时提交或回滚任何特定的事务。

XA 规范在上世纪 90 年代初就被提出。目前几乎所有主流的数据库如 MySQL、Oracle、MSSQL 等都对 XA 规范提供了支持。

XA 事务的基础是两阶段提交协议。需要有一个事务协调者来保证所有的事务参与者都完成了准备工作(第一阶段)。如果协调者收到所有参与者都准备好的消息,就会通知所有的事务都可以提交了(第二阶段)。MySQL 在这个 XA 事务中扮演的是参与者的角色,而不是协调者 (事务管理器)。

MySQL 的 XA 事务分为内部 XA 和外部 XA。外部 XA 可以参与到外部的分布式事务中,需要应用层介入作为协调者;内部 XA 事务用于同一实例下跨多引擎事务,由 Binlog 作为协调者,比如在一个存储引擎提交时,需要将提交信息写入二进制日志,这就是一个分布式内部 XA 事务,只不过二进制日志的参与者是 MySQL 本身。 MySQL 在 XA 事务中扮演的是一个参与者的角色,而不是协调者。

XA 事务的特点是:
简单易理解,开发较容易。
对资源进行了长时间的锁定,并发度低。

2.2 3PC

3PC 主要是为了弥补 2PC 的不足而产生的,2PC 有哪些不足呢?

1)同步阻塞:2PC 在执行过程中,所有参与节点(也就是一个分支事务)都是事务阻塞型的,当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态,也就是在 2PC 执行的过程中,资源是被锁住的。
2)单点故障:在 2PC 中,事务协调者扮演了举足轻重的作用,由于事务协调者的重要性,一旦事务协调者发生故障,事务的参与者就会一直阻塞下去。尤其是在第二阶段,如果协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。还有一个问题,就是当事务协调者发出 commit 指令之前,如果宕机了,此时虽然可以重新选举一个新的协调者出来,但是还是无法解决因为事务协调者宕机导致的事务参与者处于阻塞状态的问题。

3PC 则尝试解决 2PC 的这些问题。3PC 主要是把 2PC 中的第一阶段再次一分为二,这样 3PC 就有 CanCommit、PreCommit 以及 DoCommit 三个不同的阶段。不过 3PC 并不能解决 2PC 的所有问题,3PC 主要解决了单点故障问题,并且减少了阻塞。一旦事务参与者(分支事务)无法及时收到来自事务协调者的信息,那么分支事务会默认执行 commit,而不会一直持有事务资源并处于阻塞状态,不过这种机制也带来了新的问题,假设事务协调者发送了 abort 指令给各个分支事务,然而由于网络问题导致分支事务没有及时接收到该指令,那么分支事务在等待超时之后执行了 commit 操作,这样就和其他接到 abort 命令并执行回滚的分支事务之间存在数据不一致的情况。

来看看 3PC 的流程:
1)CanCommit 阶段:这个阶段所做的事很简单,就是事务协调者询问各个分支事务,你是否有能力完成此次事务?如果都返回 yes,则进入第二阶段;有一个返回 no 或等待响应超时,则中断事务,并向所有分支事务发送 abort 请求。
2)PreCommit 阶段:此时事务协调者会向所有的分支事务发送 PreCommit 请求,分支事务收到后开始执行事务操作,并将 Undo 和 Redo 信息记录到事务日志中。分支执行完事务操作后 (此时属于未提交事务的状态),就会向事务协调者反馈 “Ack” 表示我已经准备好提交了,并等待协调者的下一步指令。
3)DoCommit 阶段:在阶段二中如果所有的分支事务节点都可以进行 PreCommit 提交,那么事务协调者就会从 “预提交状态” 转变为 “提交状态”,然后向所有的分支事务节点发送 "doCommit" 请求,分支事务节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈 “Ack” 消息,协调者收到所有参与者的 Ack 消息后完成事务。

相反,如果有一个分支事务节点未完成 PreCommit 的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送 abort 请求,从而中断事务。

2.3 TCC

关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate's Opinion》的论文提出。其模式主要有如下一些优缺点:

优点:
性能提升:通过具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

缺点:
对微服务的侵入性强,微服务的每个事务都必须实现 try,confirm,cancel 等 3 个方法,开发成本高,今后维护改造的成本也高。
为了达到事务的一致性要求,try,confirm、cancel 接口必须实现等幂性操作,这在一定程度上增加了开发工作量。

TCC 主要是两个阶段,步骤如下:
Try 阶段(一阶段):尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)。
Confirm 阶段(二阶段):确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足需要满足幂等性,Confirm 执行失败后需要进行重试。
Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源,Cancel 操作也需要满足幂等性。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

在我们之前的文章中,松哥也给大家举了 TCC 的例子了,这里就不再赘述了。

2.4 SAGA

SAGA 最初出现在 1987 年 Hector Garcaa-Molrna & Kenneth Salem 发表的论文 SAGAS 里。这篇论文的核心思想是将长事务拆分为多个短事务,由 Saga 事务协调器协调,如果每个短事务都成功提交完成,那么全局事务就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。其事务的特点是:
1)并发度高,不用像 XA 事务那样长期锁定资源。
2)需要定义正常操作以及补偿操作(回滚),开发量工作量比 XA 大。
3)一致性较弱,对于转账,可能发生 A 用户已扣款,最后转账又失败的情况。

SAGA 适用的场景较多,适用于长事务或者对中间结果不敏感的业务场景。

2.5 本地消息表

本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章中提出。顾名思义,本地消息表就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表以及业务肯定是一起执行成功的。

当一个操作执行成功之后,再去执行下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改为已成功;如果下一个任务调用失败也没关系,会有后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务(重试),服务更新成功了再变更消息的状态。

重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。

根据上面的描述其实可以看到,本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。

本地消息表的特点:
长事务仅需要分拆成多个任务,使用简单。
生产者需要额外的创建消息表。
每个本地消息表都需要进行轮询(如果有失败的要重试)。
消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作。

根据本地消息表的特点我们可以发现,本地消息表适用于可异步执行且后续操作无需回滚的业务。

2.6 消息事务

这种方案的核心思路,其实就是通过消息中间件来将全局事务转为本地事务,通过消息中间件来确保各个分支事务最终都能调用成功。不过后来发现利用 Alibaba 的 RocketMQ(4.3 之后)可以更好的实现分布式事务。

RocketMQ 是一种最终一致性的分布式事务,就是说它保证的是消息最终一致性,而不是像 2PC、3PC、TCC 那样强一致分布式事务,在 RocketMQ 中有一种消息叫做 Half Message,Half Message 是指暂不能被 Consumer 消费的消息,虽然 Producer 已经把消息成功发送到了 Broker 端,但此消息被标记为暂不能投递状态,处于该种状态下的消息称为半消息,此时需要 Producer 对消息进行二次确认后,Consumer 才能去消费它。

RocketMQ 就是基于 Half Message 来实现的分布式事务,举一个转账的例子:

1)A 服务先发送个 Half Message 给 Brock 端,消息中携带 B 服务即将要 +100 元的信息。
2)当 A 服务知道 Half Message 发送成功后,那么开始本地事务。
3)执行本地事务 (会有三种情况 1、执行成功;2、执行失败;3、网络等原因导致没有响应)
3.1 如果本地事务成功,那么 A 向 Broker 服务器发送 Commit, 这样 B 服务就可以消费该 message。
3.2 如果本地事务失败,那么 A 向 Broker 服务器发送 Rollback,那么就会直接删除上面这条半消息。
3.3 如果由于网络或者生产者应用重启等原因。导致 A 一直没有对 Half Message 进行二次确认,此时 Broker 服务器会定时扫描长期处于半消息的消息,会主动询问 A 端该消息的最终状态 (Commit 或者 Rollback),这个操作也就是所谓的消息回查。

可能有人会说,那要是 B 最终执行失败怎么办?对于这种情况,几乎可以断定就是代码有问题所以才引起异常,因为消费端 RocketMQ 有重试机制,如果不是代码问题一般重试几次就能成功。如果是代码的原因引起多次重试失败后,也没有关系,将该异常记录下来,由人工处理,人工兜底处理后,就可以让事务达到最终的一致性。

2.7 最大努力通知

发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
1)有一定的消息重试机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息进行重试。
2)消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。

在前面两个小节介绍的的本地消息表和事务消息都属于可靠消息,这与我们这里介绍的最大努力通知有什么不同?

可靠消息一致性:消息发起方需要保证将消息发出去,并且将消息发到接收方,消息的可靠性关键由发起方来保证。
最大努力通知:消息发起方尽最大努力将业务处理结果通知给接收方,但是可能消息接收不到,此时需要接收方主动调用发起方的接口查询业务处理结果,此时消息的可靠性关键在接收方。

仅此而已。

在具体的解决方案上,最大努力通知需要消息发起方提供接口,让被通知方能够通过接口查询业务处理结果。

最大努力通知适用于业务通知类型,最常见的场景就是支付回调,支付服务收到第三方服务支付成功通知后,先更新自己库中订单支付状态,然后同步通知订单服务支付成功。如果此次同步通知失败,会通过异步脚步不断重试地调用订单服务的接口。

最大努力通知更多是业务上的设计,在基础设施层,可以直接使用二阶段消息,或者事务消息、本地消息表等来实现。