分布式事务科普(初识篇)
微信=>朱小厮的博客
《分布式事务科普》是我在YQ期间整理的一篇科普型文章,内容共计两万五千字左右,应该算是涵盖了这个领域的大多数知识点。篇幅较长,遂分为上下两篇发出。上篇为《分布式事务科普——初识篇》:ACID、事务隔离级别、MySQL事务实现原理、CAP、BASE、2PC、3PC等。下篇为《分布式事务科普——终结篇》,详细讲解分布式事务的解决方案:XA、AT、TCC、Saga、本地消息表、消息事务、最大努力通知等(明日放出)。
分布式事务科普
随着业务的快速发展、业务复杂度越来越高,传统单体应用逐渐暴露出了一些问题,例如开发效率低、可维护性差、架构扩展性差、部署不灵活、健壮性差等等。而微服务架构是将单个服务拆分成一系列小服务,且这些小服务都拥有独立的进程,彼此独立,很好地解决了传统单体应用的上述问题,但是在微服务架构下如何保证事务的一致性呢?本文首先从事务的概念出来,带大家先回顾一下ACID、事务隔离级别、CAP、BASE、2PC、3PC等基本理论,然后再详细讲解分布式事务的解决方案:XA、AT、TCC、Saga、本地消息表、消息事务、最大努力通知等。
什么是事务
事务提供一种机制,可以将一个活动涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。简单地说,事务提供一种“要么什么都不做,要么做全套(All or Nothing)”机制。
事务最经典也经常被拿出来说例子就是转账了。假如A要给B转账1000元,这个转账会涉及到两个关键操作就是:将A的余额减少1000元,将B的余额增加1000元。万一在这两个操作之间突然出现错误比如银行系统崩溃,导致A余额减少而B的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。
事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。任何事务机制在实现时,都应该考虑事务的ACID特性,包括:本地事务、分布式事务,即使不能都很好的满足,也要考虑支持到什么程度。
ACID
ACID 理论是对事务特性的抽象和总结,方便我们实现事务。你可以理解成:如果实现了操作的 ACID 特性,那么就实现了事务。ACID的具体含义详述如下。
原子性(Atomicity):原子性是指单个事务本身涉及到的数据库操作,要么全部成功,要么全部失败,不存在完成事务中一部分操作的可能。以上文说的转账为例,就是要么操作全部成功,A的钱少了,B的钱多了;要么就是全部失败,AB保持和原来一直的数目。
一致性(Consistency):事务必须是使数据库从一个一致性状态变到另一个一致性状态,事务的中间状态不能被观察到的。还是以转账为例,原来AB账户的钱加一起是1000,相互转账完成时候彼此还是1000,所以一致性理解起来就是事务执行前后的数据状态是稳定的,对于转账就是金额稳定不变,对于其他的事务操作就是事务执行完成之后,数据库的状态是正确的,没有脏数据。
隔离性(isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。隔离性侧重于多个事务之间的特性,也就是说多个事务之间是没有相互影响的比如A给B转账和B给C转账这两个事务是没有影响的(这里B给C转账如果和A给B转账的事务同时进行的时候,B的金额正确性问题保证就要看隔离级别了)。
持久性(durability):持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
事务的隔离级别
在多个事务并发操作数据库(多线程、网络并发等)的时候,如果没有有效的避免机制,就会出现脏读、不可重复读和幻读这3种问题。
脏读(Dirty Read)
A事务读取B事务尚未提交的数据,此时如果B事务由于某些原因执行了回滚操作,那么A事务读取到的数据就是脏数据。
参考下图,事务A读取到了事务B未提交的记录。
不可重复读(Nonrepeatable Read)
一个事务内前后多次读取,数据内容不一致。在这个事务还没有结束时,另外一个事务也访问该同一数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的的数据可能是不一样的。这样在一个事务内两次读到的数据是不一样的,因此称为是不可重复读。
参考下图,事务A读取到的name可能为“张三”,也可能为“李四”。
幻读(Phantom Read)
一个事务内前后多次读取,数据总量不一致。参考下图,事务A在执行读取操作,需要两次统计数据的总量,前一次查询数据总量后,此时事务B执行了新增数据的操作并提交后,这个时候事务A读取的数据总量和之前统计的不一样,就像产生了幻觉一样,平白无故的多了几条数据,成为幻读。
不可重复读和幻读有些相似,两者的区别在于:不可重复读的重点在于修改,同样的条件, 你读取过的数据,再次读取出来发现值不一样了;而幻读的重点在于新增或者删除(参考MySQL官网https://dev.mysql.com/doc/refman/5.7/en/innodb-next-key-locking.html
对幻读的定义,记录的减少也应该算是幻读),同样的条件, 第 1 次和第 2 次读出来的记录数不一样。
隔离级别
事务的隔离性是指多个并发的事务同时访问一个数据库时,一个事务不应该被另一个事务所干扰,每个并发的事务间要相互进行隔离。SQL 标准定义了以下四种隔离级别:
-
读未提交(Read Uncommitted):一个事务可以读取到另一个事务未提交的修改。这种隔离级别是最弱的,可能会产生脏读,幻读,不可重复读的问题问题。
-
读已提交(Read Committed):一个事务只能读取另一个事务已经提交的修改。其避免了脏读,仍然存在不可以重复读和幻读的问题。SQL Server和Oracle的默认隔离级别就是这个。
-
可重复读(Repeated Read):同一个事务中多次读取相同的数据返回的结果是一样的。其避免了脏读和不可重复读问题,但是幻读依然存在。MySQL中的默认隔离级别就是这个,不过MySQL通过多版本并发控制(MVCC)、Next-key Lock等技术解决了幻读问题。
-
串行化(Serializable):这是数据库最高的隔离级别,这种级别下,事务“串行化顺序执行”,也就是一个一个排队执行。在这种级别下,脏读、不可重复读、幻读都可以被避免,但是执行效率奇差,性能开销也最大。
事务的隔离级别和脏读、不可重复读、幻读的关系总结如下表所示:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读 | 可能 | 可能 | 可能 |
已提交读 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
可串行化 | 不可能 | 不可能 | 不可能 |
MySQL事务实现原理
这里所说的MySQL事务是指使用InnoDB引擎时的事务。MySQL在5.5版本之前默认的数据库引擎时MyISAM,虽然性能极佳,而且提供了大量的特性,包括全文索引、压缩、空间函数等,但MyISAM不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。5.5版本之后,MySQL引入了InnoDB(事务性数据库引擎),MySQL 5.5版本后默认的存储引擎为InnoDB。
redo log和undo log来保证事务的原子性、一致性和持久性,同时采用预写日志(WAL)方式将随机写入变成顺序追加写入,提升事务性能。而隔离性是通过锁技术来保证的。
这里我们不放先来了解一下redo log和undo log。redo log是重做日志,提供前滚操作,undo log是回滚日志,提供回滚操作。undo log不是redo log的逆向过程,其实它们都算是用来恢复的日志:
-
redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。
-
undo用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行记录。
redo log
redo log 又称为重做日志,它包含两部分:一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的。
当需要修改事务中的数据时,先将对应的redo log写入到redo log buffer中,然后才在内存中执行相关的数据修改操作。InnoDB通过“force log at commit”机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有redo log都写入到磁盘上的redo log file中,然后待事务的commit操作完成才算整个事务操作完成。
在每次将redo log buffer中的内容写入redo log file时,都需要调用一次fsync操作,以此确保redo log成功写入到磁盘上(参考下图,内容的流向为:用户态的内存->操作系统的页缓存->物理磁盘)。因此,磁盘的性能在一定程度上也决定了事务提交的性能。这里还可以通过innodb_flush_log_at_trx_commit来控制redo log刷磁盘的策略,这里就不做赘述了。
undo log
undo log有2个功能:实现回滚和多版本并发控制(MVCC, Multi-Version Concurrency Control)。
在数据修改的时候,不仅记录了redo log,还记录了相对应的undo log,如果因为某些原因导致事务失败或回滚了,可以借助该undo log进行回滚。
undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。
当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。
MVCC
说到undo log,就不得不顺带提一下MVCC了,因为MVCC的实现依赖了undo log。当然,MVCC的实现还依赖了隐藏字段(DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID)、Read View等。
MVCC的全称是多版本并发控制,它使得在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务下执行一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值。这是一个可以用来增强并发性的强大技术,因为这样的一来的话查询就不用等待另一个事务释放锁,使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
这里的读指的是“快照读”。普通的SELECT操作就是快照读,有的地方也称之为“一致性读”或者“一致性无锁读”。它不会对表中的任何记录做加锁动作,即不加锁的非阻塞读。快照读的前提是隔离级别不是串行化级别,串行化级别下的快照读会退化成当前读。之所以出现快照读的情况,是基于提高并发性能的考虑,这里可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销。当然,既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
对应的还有“当前读”。类似UPDATE、DELETE、INSERT、SELECT...LOCK IN SHARE MODE、SELECT...FOR UPDATE这些操作就是当前读。为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
锁技术
并发事务的读-读情况并不会引起什么问题(读取操作本身不会对记录有任何影响,并不会引起什么问题,所以允许这种情况的发生),不过对于写-写、读-写或写-读这些情况可能会引起一些问题,需要使用MVCC或者加锁的方式来解决它们。
在使用加锁的方式解决问题时,既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞。这里引入了两种行级锁:
-
共享锁:英文名为Shared Locks,简称S锁。允许事务读一行数据。
-
排它锁:也常称独占锁,英文名为Exclusive Locks,简称X锁。允许事务删除或更新一行数据。
假如事务A首先获取了一条记录的S锁之后,事务B接着也要访问这条记录:1) 如果事务B想要再获取一个记录的S锁,那么事务B也会获得该锁,也就意味着事务A和B在该记录上同时持有S锁;2) 如果事务B想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务A提交之后将S锁释放掉。
如果事务A首先获取了一条记录的X锁之后,那么不管事务B接着想获取该记录的S锁还是X锁都会被阻塞,直到事务A提交。
除了 S锁 和 S 锁兼容,其他都不兼容。
InnoDB存储引擎还支持多粒度锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为此,InnoDB存储引擎引入了意向锁(表级别锁):
-
意向共享锁(IS 锁):事务想要获取一张表的几行数据的共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
-
意向排他锁(IX 锁):事务想要获取一张表中几行数据的排它锁,事务在给一个数据行加排它锁前必须先取得该表的 IX 锁。
当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
下表展示了X、IX、S、IS锁的兼容性:
兼容性 | X | IX | S | IS |
---|---|---|---|---|
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
S | 不兼容 | 不兼容 | 兼容 | 兼容 |
IS | 不兼容 | 兼容 | 兼容 | 兼容 |
这里还要了解一下的是,在InnoDB中有 3 种行锁的算法:
-
Record Locks(记录锁):单个行记录上的锁。
-
Gap Locks(间隙锁):在记录之间加锁,或者在第一个记录之前加锁,亦或者在最后一个记录之后加锁,即锁定一个范围,而非记录本身。
-
Next-Key Locks:结合 Gap Locks 和 Record Locks,锁定一个范围,并且锁定记录本身。主要解决的是 REPEATABLE READ 隔离级别下的幻读问题。
对于Next-Key Locks,如果我们锁定了一个行,且查询的索引含有唯一属性时(即有唯一索引),那么这个时候InnoDB会将Next-Key Locks优化成Record Locks,也就是锁定当前行,而不是锁定当前行加一个范围;如果我们使用的不是唯一索引锁定一行数据,那么此时InnoDB就会按照本来的规则锁定一个范围和记录。还有需要注意的点是,当唯一索引由多个列组成时,如果查询仅是查找其中的一个列,这时候是不会降级的。InnoDB存储引擎还会对辅助索引的下一个键值区间加Gap Locks(这么做也是为了防止幻读)。
总结
MySQL实现事务ACID特性的方式总结如下:
-
原子性:使用 undo log来实现,如果事务执行过程中出错或者用户执行了rollback,系统通过undo log日志返回事务开始的状态。
-
持久性:使用 redo log来实现,只要redo log日志持久化了,当系统崩溃,即可通过redo log把数据恢复。
-
隔离性:通过锁以及MVCC来实现。
-
一致性:通过回滚、恢复以及并发情况下的隔离性,从而实现一致性。
理论基石
对于事务,想必大家也或多或少地听到过类似本地事务、数据库事务、传统事务、刚性事务、柔性事务、分布式事务等多种称呼(还有如单机事务、全局事务等称呼),那么这些多种类的事务分别指的是什么呢?
本地事务(Local Transaction),通常也被称之为数据库事务、传统事务(相对于分布式事务而言)。它仅限于对单一数据库资源的访问控制,如下图(左)所示。
不过,现在随着系统架构的服务化,事务的概念也延伸到了服务中,倘若将一个单一的服务操作作为一个事务,那么整个服务操作只能涉及一个单一的数据库资源。由此,本地事务的定义可以扩展为基于单个服务单一数据库资源访问的事务,如上图(右)所示。
本地事务通常由资源管理器进行管理,如下图所示。
本地事务的优点就是支持严格的ACID特性,高效,可靠,状态可以只在资源管理器中维护,而且应用编程模型简单。但是本地事务不具备分布式事务的处理能力,隔离的最小单位受限于资源管理器。
与传统的本地事务所对应的是分布式事务,它指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。
刚性事务是指完全遵循ACID规范的事务。最常见的刚性事务就是数据库事务(本地事务),比如MySQL事务就是一种典型的刚性事务。
在电商领域等互联网场景下,传统的事务在数据库性能和处理能力上都暴露出了瓶颈。在分布式领域基于CAP理论以及BASE理论,有人就提出了柔性事务的概念。柔性事务为了满足可用性、性能与降级服务的需要,降低了一致性(Consistency)与隔离性(Isolation)的要求。
CAP
CAP是指的是在一个分布式系统中、Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
-
一致性(C):每次读取要么获得最近写入的数据,要么获得一个错误。
-
可用性(A):每次请求都能获得一个(非错误)响应,但不保证返回的是最新写入的数据。
-
分区容忍性(P):尽管任意数量的消息被节点间的网络丢失(或延迟),系统仍继续运行。
ACID理论和CAP理论都有一个C,也都叫一致性, 所以很多人都会把这两个概念当做是一个概念。不过,这两个C是有区别的:
-
ACID的C指的是事务中的一致性,在一系列对数据修改的操作中,保证数据的正确性。即数据在事务期间的多个操作中,数据不会凭空的消失或增加,数据的每一个增删改操作都是有因果关系的。比如用户A向用户B转了200块钱,不会出现用户A扣了款,而用户B没有收到的情况。
-
在分布式环境中,多服务之间的复制是异步,需要一定耗时,不会瞬间完成。在某个服务节点的数据修改之后,到同步到其它服务节点之间存在一定的时间间隔,如果在这个间隔内有并发读请求过来,而这些请求又负载均衡到多个节点,可能会出现从多个节点数据不一致的情况,因为请求有可能会落到还没完成数据同步的节点上。CAP中的C就是为了做到在分布式环境中读取的数据是一致的。
总的来说,ACID的C着重强调单数据库事务操作时,要保证数据的完整和正确性,而CAP理论中的C强调的是对一个数据多个备份的读写一致性。
有关CAP的更多解读,可以看看这篇《越说越迷糊的CAP》。记得关注公众号:朱小厮的博客。
BASE
在 CAP 理论中,三者不可同时满足,而服务化中,更多的是提升 A 以及 P,在这个过程中不可避免的会降低对 C 的要求,因此,BASE 理论随之而来。
BASE理论来源于 ebay 在 2008 年 ACM 中发表的论文(下载地址:https://dl.acm.org/doi/10.1145/1394127.1394128),BASE 理论的基本原则有三个:Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent(最终一致性),主要目的是为了提升分布式系统的可伸缩性,论文同样阐述了如何对业务进行调整以及折中的手段,BASE 理论的提出为分布式事务的发展指出了一个方向。
BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的。BASE理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。BASE 理论本质上是对 CAP 理论的延伸,是对 CAP 中 AP 方案的一个补充。
基本可用
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。不过,这绝不等价于系统不可用。比如:
-
响应时间上的损失。正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒。
-
系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
软状态
软状态,也被称之为柔性状态,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
最终一致性
最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
分布式事务的由来
当下互联网发展如火如荼,绝大部分公司都进行了数据库拆分和服务化(SOA)。在这种情况下,完成某一个业务功能可能需要横跨多个服务,操作多个数据库。这就涉及到了分布式事务,用需要操作的资源位于多个资源服务器上,而应用需要保证对于多个资源服务器的数据的操作,要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同资源服务器的数据一致性。
最早的分布式事务应用架构很简单,不涉及服务间的访问调用,仅仅是服务内操作涉及到对多个数据库资源的访问,如下图所示。
当一个服务操作访问不同的数据库资源,又希望对它们的访问具有事务特性时,就需要采用分布式事务来协调所有的事务参与者。
典型的应用场景如分表分库中的事务。通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低SQL操作的复杂性。如:INSERT INTO user(id, name) VALUES (1,"张三"),(2,"李四");
这条SQL是操作单库的语法,单库情况下,可以保证事务的一致性。但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改写为2条SQL,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。
对于上面介绍的分布式事务应用架构,尽管一个服务操作会访问多个数据库资源,但是毕竟整个事务还是控制在单一服务的内部。如果一个服务操作需要调用另外一个服务,这时的事务就需要跨越多个服务了。在这种情况下,起始于某个服务的事务在调用另外一个服务的时候,需要以某种机制流转到另外一个服务,从而使被调用的服务访问的资源也自动加入到该事务当中来。下图反映了这样一个跨越多个服务的分布式事务。
举个简单的例子,一个公司之内,用户的资产可能分为好多个部分,比如余额,积分,优惠券等等,如下图所示。
在公司内部有可能积分功能由一个微服务团队维护,优惠券又是另外的团队维护,这样的话就无法保证积分扣减了之后,优惠券能否扣减成功。所以这里也需要使用分布式事务来控制。
如果将上面这两种场景(一个服务可以调用多个数据库资源,也可以调用其他服务)结合在一起,对此进行延伸,整个分布式事务的参与者将会组成如下图所示的树形拓扑结构。在一个跨服务的分布式事务中,事务的发起者和提交均系同一个,它可以是整个调用的客户端,也可以是客户端最先调用的那个服务。
上述讨论的分布式事务场景中,无一例外的都直接或者间接的操作了多个数据库。如何保证事务的ACID特性,对于分布式事务实现方案而言,是非常大的挑战。同时,分布式事务实现方案还必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。
分布式事务一致性协议
如果一个操作涉及多个分布式节点,为了保证事务的ACID特性,需要引入一个“协调者”组件来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为“参与者”。协调者负责调度参与者的行为,并最终决定这些参与者是否真正地提交事务。
分布式事务通常采用二阶段提交协议(2PC),它是几乎所有分布式事务算法的基础,后续的分布式事务算法几乎都由此改进而来。
2PC
二阶段提交(Two-phase Commit,简称2PC),是指为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常2PC也被称为是一种协议(Protocol)。
在此协议中,一个事务管理器(Transaction Manager,简称 TM,也被称之为“协调者”)协调 1 个或多个资源管理器(Resource Manager,简称 RM,也被称之为“参与者”)的活动,所有资源管理器(参与者)向事务管理器(协调者)汇报自身活动状态,由事务管理器(协调者)根据各资源管理器(协调者)汇报的状态(完成准备或准备失败)来决定各资源管理器(协调者)是“提交”事务还是进行“回滚”操作。
因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
所谓的两个阶段是指:第一阶段:准备阶段(投票阶段)和第二阶段:提交阶段(执行阶段)。
第一阶段
协调者通知各个参与者准备提交它们的事务分支。如果参与者判断自己进行的工作可以被提交,那就对工作内容进行持久化,再给协调者肯定答复;要是发生了其他情况,那给协调者的都是否定答复。在发送了否定答复并回滚了已经的工作后,参与者就可以丢弃这个事务分支信息。
以MySQL数据库为例,在第一阶段,事务管理器(协调者)向所有涉及到的数据库服务器(参与者)发出Prepare "准备提交"请求,数据库(参与者)收到请求后执行数据修改和日志记录等处理,处理完成后只是把事务的状态改成"可以提交",然后把结果返回给事务管理器(协调者)。
第二阶段
协调者根据第一阶段中各个参与者 Prepare的结果,决定是提交还是回滚事务。如果所有的参与者都Prepare成功,那么协调者通知所有的参与者进行提交;如果有参与者Prepare失败的话,则协调者通知所有参与者回滚自己的事务分支。
还是以MySQL数据库为例,如果第一阶段中所有数据库(参与者)都Prepare成功,那么事务管理器(协调者)向数据库服务器(参与者)发出"确认提交"请求,数据库服务器(参与者)把事务的"可以提交"状态改为"提交完成"状态,然后返回应答。如果在第一阶段内有任何一个数据库(参与者)的操作发生了错误,或者事务管理器(协调者)收不到某个数据库(参与者)的回应,则认为事务失败,回撤所有数据库(参与者)的事务。数据库服务器(参与者)收不到第二阶段的确认提交请求,也会把"可以提交"的事务回撤。
2PC提交的优点是尽量保证了数据的强一致,但不是 100% 一致。但是2PC也有明显的缺点:
-
单点故障:由于协调者的重要性,一旦协调者发生故障,参与者会一直阻塞,尤其是在第二阶段,协调者发生故障,那么所有的参与者都处于锁定事务资源的状态中,而无法继续完成事务操作。
-
同步阻塞:由于所有节点在执行操作时都是同步阻塞的,当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
-
数据不一致:在第二阶段中,当协调者向参与者发送提交事务请求之后,发生了局部网络异常或者在发送提交事务请求过程中协调者发生了故障,这会导致只有一部分参与者接收到了提交事务请求。而在这部分参与者接到提交事务请求之后就会执行提交事务操作。但是其他部分未接收到提交事务请求的参与者则无法提交事务。从而导致分布式系统中的数据不一致。
二阶段提交的问题:如果协调者在第二阶段发送提交请求之后挂掉,而唯一接受到这条消息的参与者执行之后也挂掉了,即使协调者通过选举协议产生了新的协调者并通知其他参与者进行提交或回滚操作的话,都可能会与这个已经执行的参与者执行的操作不一样。
3PC
三阶段提交(Three-phase Commit,简称3PC),是为解决2PC中的缺点而设计的。参考维基百科:https://en.wikipedia.org/wiki/Three-phasecommitprotocol。与两阶段提交不同的是,三阶段提交是“非阻塞”协议。
对应于2PC,3PC有两个改动点:
-
引入超时机制。同时在协调者和参与者中都引入超时机制。
-
在两阶段提交的第一阶段与第二阶段之间插入了一个准备阶段,使得原先在两阶段提交中,参与者在投票之后,由于协调者发生崩溃或错误而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
第一阶段CanCommit
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送Commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
-
事务询问:协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
-
响应反馈:参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No。
第二阶段PreCommit
协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有以下两种可能。
-
假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。
a) 发送预提交请求:协调者向参与者发送PreCommit请求,并进入Prepared阶段。
b) 事务预提交:参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
c) 响应反馈:如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
-
假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
a) 发送中断请求:协调者向所有参与者发送Abort请求。
b) 中断事务:参与者收到来自协调者的Abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
第三阶段DoCommit
该阶段进行真正的事务提交,也可以分为以下两种情况。
-
Case 1:执行提交。
a) 发送提交请求:协调者接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送DoCommit请求。
b) 事务提交:参与者接收到DoCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
c) 响应反馈:事务提交完之后,向协调者发送ACK响应。
d) 完成事务:协调者接收到所有参与者的ACK响应之后,完成事务。
-
Case 2:中断事务。协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。
a) 发送中断请求:协调者向所有参与者发送Abort请求。
b) 事务回滚:参与者接收到Abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
c) 反馈结果:参与者完成事务回滚之后,向协调者发送ACK消息。
d) 中断事务:协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
在三阶段提交中,如果在第三阶段协调者发送提交请求之后挂掉,并且唯一的接受的参与者执行提交操作之后也挂掉了,这时协调者通过选举协议产生了新的协调者。在二阶段提交时存在的问题就是新的协调者不确定已经执行过事务的参与者是执行的提交事务还是中断事务。但是在三阶段提交时,肯定得到了第二阶段的再次确认,那么第二阶段必然是已经正确的执行了事务操作,只等待提交事务了。所以新的协调者可以从第二阶段中分析出应该执行的操作,进行提交或者中断事务操作,这样即使挂掉的参与者恢复过来,数据也是一致的。
所以,三阶段提交解决了二阶段提交中存在的由于协调者和参与者同时挂掉可能导致的数据一致性问题和单点故障问题,并减少阻塞。因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行提交事务,而不会一直持有事务资源并处于阻塞状态。
不过3PC也存在自身的问题:在提交阶段如果发送的是中断事务请求,但是由于网络问题,导致部分参与者没有接到请求。那么参与者会在等待超时之后执行提交事务操作,这样这些由于网络问题导致提交事务的参与者的数据就与接受到中断事务请求的参与者存在数据不一致的问题。所以无论是 2PC 还是 3PC 都不能保证分布式系统中的数据 100% 一致。