分布式事务是个业界难题,在看分布式事务方案之前,先从单机数据库事务开始看起。

 

 什么是事务 

事务(Transaction)是数据库系统中一系列操作的一个逻辑单元,所有操作要么全部成功要么全部失败。

 

可以看一个经典的转账事务示例,小哥哥转账100元给×××姐:

begin:

操作1:查询小哥哥账号余额,确保余额充足

操作2:从小哥哥账号扣除100元

操作3:往×××姐账号增加100元

commit;

转账的一系列操作就是一个事务,事务会确保这一系列操作要么全部成功,要么全部失败。

 

 ACID 

谈起事务就不得不谈事务的四大特性ACID

原子性(Atomicity)

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

还拿之前的转账案例来理解,原子性就是要求转账的一系列操作(操作1、操作2、操作3)要么全部完成,要么全部失败。不能出现钱转了一半的情况,比如小哥哥的账号钱扣除成功了,但是×××姐账号加钱的操作失败了,这种属于不满足原子性。

 

一致性(Consistency)

一致性这个词总是一个让人困惑话题,有时不同语境下说的都不是同一个事情。我们先看看*定义:

Consistency ensures that a transaction can only bring the database from one valid state to another, maintaining database invariants: any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof.

我们看看关键点,事务确保数据从一个valid状态转换到另外一个valid状态。什么样才算是valid呢,符合all defined rules。all defined rules包括了constraints、 cascades、triggers等。所以这里的一致性强调的是事务操作使得数据一直处于符合预定规则(约束、触发器等)。This prevents database corruption by an illegal transaction, but does not guarantee that a transaction is correct。但是我们通常讨论的一致性的含义往往比较广,并不局限于ACID的C。

看看转账案例,怎么样才算符合一致性呢,账户余额不能为负数可以算,而两者账户余额相加=200则属于应用语义层面的一致性,由原子性来保证。

 

隔离性(Isolation)

事务通常是并发执行的,同时对数据进行读写和修改的,隔离性强调的多个事务并发执行对数据的影响看起来跟串行一样。通常为了提高并发度,弱化了事务并发时对数据一致性的要求,允许若干种数据异常现象,从而定义了不同的事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。严格来说只有最高隔离的串行化隔离级别才是符合隔离性的。

在转账案例中,如果在转账事务执行过程中,能读取到事务中间状态,比如转了一半然后出错事务进行了回滚,读到了“转一半”的不一致的数据状态,属于脏读。为了提高并发度,在最低的读未提交隔离级别是允许这种脏读,其他几种不会出现此种脏读。

 

持久性(Durability)

事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。持久性其实好理解,事务做完了要保证持久不丢失,比如转账已经转成功了,不能这笔事务丢失掉。

从某种意义来说,ACID都是为了保障数据的一致性,不满足ACID则会有数据的不一致。

 

分布式事务 

互联网时代,业务发展迅猛,数据往往超出单机数据库所能处理的极限,遇到性能的瓶颈。应用层面微服务架构越来越流行,从原来的单体应用拆分成一个个独立的微服务,当应用通过一组微服务来协助完成时,对数据的一致性就需要分布式事务来保证。

对数据库通常采用垂直拆分和水平数据分片,将数据拆分到多个不同的数据节点上。如果一个事务里的操作涉及了多个不同分片节点则产生了分布式事务。

我们来看看业界常见的几种分布式事务实现:

 

基于XA协议的2pc

两阶段提交(2pc)大概属于被提的最多的分布式事务实现方案了。XA协议是 X/Open DTP Group提出的定义的两段提交(2PC - Two-Phase-Commit)协议,主要用于分布式数据库事务管理。XA规范主要定义了(全局)事务管理器(Transaction Manager)和(局部)资源管理器(Resource Manager)之间的接口。

两阶段提交将提交过程分为两个阶段,在第一阶段,协调者询问所有的参与者是否可以提交事务(请参与者投票),所有参与者向协调者投票。在第二阶段,协调者根据所有参与者的投票结果做出是否事务可以全局提交的决定,并通知所有的参与者执行该决定。

 

从一次小哥哥与×××姐的转账开始, 浅谈分布式事务从理论到实践

 

2PC的缺点

2PC虽然保证了提交的原子性,但缺点也很明显,先从协议本身来看看两阶段提交的缺点:

1、同步阻塞。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有资源时,其他第三方节点访问资源不得不处于阻塞状态。

2、单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。

从性能来看,2PC中协调者与每个参与者至少有2轮消息交互、多次写日志,过程又是同步阻塞,性能十分低下。

 

2PC是强一致吗

经常听到一些人说2PC是强一致的,真的是吗?我们先从隔离性的角度来看看,比如有两个节点a和b,一个事务已经到第二阶段准备提交,在某个时间点上,a节点已经提交,但b节点还未提交,这时另一个事务就能看到a节点提交后的值以及b节点提交前的值。

还以之前转账为例,转入分支事务已经成功,转出分支事务还未提交成功,这个时候就看到不一致,两个账号总额度是300,属于脏读,能看到不一致还能算强一致么。

从隔离性角度来说,2PC的分布式事务只能算最终一致,算不得强一致。一般人说的强一致只是说的原子性,事务要么全成功要么全失败。所以一致性这个词已经被玩坏了。

 

MySQL对XA支持的坑

另外我们可以看看MySQL对XA的支持,在MySQL5.7之前一直有2个缺陷多年未修复。

1. prepare未写入binlog,若主库宕机切换后则丢失prepare。

2. 客户端退出或者服务宕机,MySQL会自动回滚。

MySQL对外部xa的支持还有其他不少bug,这里不一一赘述。

 

TCC

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

TCC事务机制相对于XA的2PC相比,其特征在于它不依赖资源管理器(RM)对XA的支持,而是通过对(由业务系统提供的)业务逻辑的调度来实现分布式事务。

TCC型事务(Trying/Confirming/Canceling)。

  • TRYING 阶段主要是对业务系统做检测及资源预留。

  • CONFIRMING 阶段主要是对业务系统做确认提交,TRYING阶段执行成功并开始执行CONFIRMING阶段时,默认CONFIRMING阶段是不会出错的。即:只要TRYING成功,CONFIRMING一定成功。

  • CANCELING 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

 

TCC与2PC区别

当讨论2PC时,只专注于事务处理阶段,因而只讨论prepare和commit,往往忽略了业务逻辑执行阶段,或者默认为prepare包括了业务逻辑执行。可以看下MySQL的XA的一个示例就比较好理解。

从一次小哥哥与×××姐的转账开始, 浅谈分布式事务从理论到实践

 

2PC的一个完整的事务生命周期是:begin -> 业务逻辑 -> prepare -> commit。再看TCC的一个完整的事务生命周期是:begin -> 业务逻辑(try业务) -> commit(comfirm业务)。

 虽然TCC的confirm阶段也会包含部分业务逻辑,当然从事务执行角度可以简化来看将commit与confirm类比,所以TCC并不是两阶段提交。

 TCC的Trying/Confirming/Canceling三个接口针对每个事务都需要用户自己来实现,其实对用户不太友好,增加用户开发工作量,另外不能保证所有人实现的接口一定能符合一致性要求,如果接口实现的有漏洞很可能会造成不一致。

 

SAGA

Saga是由普林斯顿大学的H.Garcia-Molina等人提出。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调。每个Saga由一系列本地分支事务组成,每个分支事务有对应一个补偿事务。如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

 SAGA事务模型,是牺牲了一定的隔离性的,但是提高了long-running事务的可用性。

除了隔离性的问题,SAGA跟TCC一样对于补偿的动作也是需要用户自己实现,这点其实对用户不太友好。

 

基于消息队列

这种思路最早来自于ebay,核心思想将分布式事务分成多个本地事务,这里称之为主事务与从事务。主事务本地先行提交,然后通过消息通知从事务,从事务从消息中获取信息进行本地提交。可以看出这是一种异步事务机制、只能保证最终一致性;但可用性非常高,不会因为故障而发生阻塞。

 

从一次小哥哥与×××姐的转账开始, 浅谈分布式事务从理论到实践

上述解决方案看似完美,实际上还没有解决分布式问题。为了使第一个事务不涉及分布式操作,消息队列必须与主事务使用同一套存储资源,但为了使第二个事务是本地的,消息队列存储又必须与第二事务的存储在一起。这两者是不可能同时满足的。本质上并没有规避分布式事务。

 

如果消息具有操作幂等性,也就是一个消息被应用多次与应用一次产生的效果是一样的话,上述问题是很好解决的。但实际情况下,有些消息很难具有幂等性,比如转账中的扣款操作,执行一次和执行多次的结束显然是不一样的,因此需要做很多额外处理,一般通过状态表或者事务消息来解决。

 

最大努力提交

最大努力提交(Best Efforts)的关键点在于:

1. 与2PC相比,省去了prepare,本质上属于1PC

2. commit推迟到最后一起执行

 

最大努力提交最早在spring中事务管理中广泛流传,感兴趣的可以参考:

https://www.javaworld.com/article/2077963/open-source-tools/distributed-transactions-in-spring--with-and-without-xa.html

 

在分布式数据库中间件的场景也广泛应用,我们来看看MyCAT的事务模型,有时也被称为弱XA。

 

从一次小哥哥与×××姐的转账开始, 浅谈分布式事务从理论到实践

 

最大努力提交优点是性能非常好且对用户透明,缺点是可能存在部分提交成功部分失败的场景(Partial commits),而对于已经commit成功的场景无法rollback。但是由于将容易出错的sql执行阶段先执行,commit推迟到最后一起执行,相当于可能出错的危险窗口期缩短到只有最后的commit阶段,实际出错概率很低。而commit开始之前出错时可以正常回滚,不会有不一致。

 

如果是在应用层采用该事务模型可以将分支事务设计成幂等性,这样在commit出错时可以对出错分支进行重试。在分布式数据库中间件的场景,则很难具备幂等性。

 

 DDM分布式事务解决方案 

 各种分布式事务的方案都各有优缺点,而业务场景又是复杂多样的,对一致性的要求也各不一样,很难有一种方案包打天下。所以DDM在设计分布式事务方案时,充分考虑和权衡了各种方案的优缺点,提供了四种分布式事务模型,可以由用户*选择。TCC等模型使用起来需要用户自己实现相应的接口,对用户非常不友好。因此DDM提供了全透明模型的分布式事务,使用接口与原来单机一致。

单机

适合业务拆分比较合理,在应用层有自己的完善的事务处理框架,到DDM的事务都是单分片事务,单分片事务由底层数据库提供强一致性的保证。单机事务模型下,如果出现跨分片的事务,会报错进行提示,避免达不到预期目的。

 

最大努力提交

该事务模型前文有描述,在此不再赘述。该模型适合绝大部分不涉及金钱往来的业务,在性能和一致性之间比较好的一个平衡。事务中commit时因为是往多个节点发送执行,有部分commit成功部分commit失败的可能性,但是可能性比较低,只有在commit的时间窗内出现异常才有可能出现此种情况。

 

最终一致性

最大努力提交模型的问题(Partial commits)本质是不满足ACID中的A原子性  ,针对该问题 做了改进,针对Partial commits中已经提交成功的分支事务进行自动的事务补偿,保证了原子性,由于从Partial commits到补偿成功会有比较短的时间窗口,该时间窗口内数据处于不一致状态但最终会达到一致状态,所以称之为最终一致性。

 当出现Partial commits异常情况是,是允许应用支持读取,所以可能会有脏读,如果业务场景对脏读比较敏感,比如之前转账事务中的查询余额,可以通过对该select加for update或者lock in share mode来解决,相当于针对该语句了保证了读已提交。

从Partial commits到补偿成功时间窗内,业界有选择不加锁的则会出现回滚覆盖,造成数据错误回补不成功,而DDM采用了高效的加锁避免了该问题。

 

强一致性

强一致性模型既解决了分布式事务的原子性,又可避免脏读,确保了读取到的都是commit成功的数据,从隔离级别上来属于读已提交。适合对一致性有极端要求的场景。但是一致性级别越高,付出性能代价会越大,所以请根据业务需要选择合适的模型。