随着业务的增长,需要进行分表、分库,甚至拆分应用演化成微服务。
因此一次交易需要跨库、跨服务保证每个系统中的交易要么全部成功,否则全部回滚。这里就涉及到分布式事务。

XA协议时一个基于数据库的分布式事务协议,其分为两部分:事务管理器和本地资源管理器。
事务管理器作为一个全局调度着,负责对各个本地资源管理器统一发送提交或者回滚命令。
二阶段提交和三阶段提交都是根据此协议衍生而来,Oracle和Mysql均已实现了XA接口。
除了二阶段提交和三阶段提交外还有Try Confirm Cancel (TCC)、本地消息队列等分布式事务解决方案。

二阶段提交 2PC

二阶段提交需要进行两个阶段的操作,准备阶段和提交阶段。

准备阶段就是事务管理器(协调者)分别给不同的系统发送“准备”命令,这些系统出了提交数据库事务之外的所有操作,都要在准备阶段操作完成。

提交阶段就是事务管理器(协调者)给不同的系统发送“提交”命令,每个系统提交自己的数据库事务,然后给协调者返回“提交成功”, 协调者收到所有响应以后,返回给客户端成功响应。
如果遇到异常情况提交不成功,需要做一些补偿机制来保证成功。

二阶段提交

对于二阶段提交:
如果准备阶段全部返回成功, 那么进入提交阶段,该必须保证成功。
如果准备阶段有一个失败,那么协调者通知每个系统回滚。

二阶段提交保证了原子性与隔离性。所以2PC适合对数据一致性高的场景。

缺陷:
性能低:整个事务的执行过程需要阻塞服务端线程和数据库会话。
协调者单点故障:一旦协调者宕机,就会导致事务回话一致处于等待提交阶段,直到事务超时自动回滚。
超时导致同步阻塞:当某个参与者节点通信处于超时,其余参与者都会被懂阻塞导致占用的资源不能释放。

适合场景:只有病发量不大且需要强一致的情况下才考虑使用2PC。

三阶段提交 3PC

三阶段提交是对二阶段提交的一种升级优化,它在二阶段提交的中间增加了precommit阶段。保证了在最后提交阶段之前,各个参与者节点状态都一致。
同时在协调者与参与者中都引入超时机制,当参与者由于各种原因未收到协调者的commit请求后,会对本地事务进行commit,不会一直阻塞等待,解决了2pc的单点故障问题,但是3pc还是没有能从根本上解决数据一致性的问题。

三个阶段分别是can commit、Pre Commit 、Do Commit;

三阶段提交

CanCommit

类似于二阶段提交的准备阶段,协调者向所有参与者发送CanCommit命令,询问是否可以执行事务提交操作,如果可以提交就返回YES,否则返回NO。如果全部响应YES则进入下一个阶段。

PreCommit

协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有两种可能

所有参与者的反馈都是YES那么执行事务的预执行:
1)发送于提交请求,协调者向参与者发送Precommit请求,并进入Prepared阶段。
2)事务预提交,参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
3)响应反馈:如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

假如任何一个参与者向协调者发送了NO响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。
1)发送终端请求: 协调者向所有参与者发送abort请求。
2)中断事务:参与者收到来自协调者的abort请求之后(或者超时之后,仍未收到协调者的请求)执行事务的中断。

DoCommit

该阶段进行真正的事务提交,也分为两种情况。

执行提交

发送提交请求: 协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态。
并向所有参与者发送doCommit请求。
事务提交:参与者收到docommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有的事务资源。
响应反馈:参与者事务提交之后,向协调者发送ACK请求
完成事务: 协调者接收到所有参与者的ACK响应之后,完成事务。

中断事务

协调者没有接收到参与者发送的ACK响应(可能是参与者发送的不是ACK响应,也可能是响应超时),那么就会执行中断事务。

发送中断请求:协调者向所有参与者发送abort请求。
事务回滚:参与者接收到abort请求后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
反馈结果:参与者完成事务回滚之后,向协调者发送ACK信息
中断事务:协调者接收到参与者反馈的ACK消息之后,执行事务的中断操作。

三阶段提交状态图

如果已经完成了Precommit进入到Docommit阶段,有的参与者由于超时没有收到Docommit请求时,会自动提交本地事务,并且释放资源。

三阶段提交解决了二阶段提交无法释放资源的问题。
也保证了在提交事务之前所有参与者的状态都一致

补偿事务(TCC)

针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)

它分为三个操作:

Try阶段

主要针对业务系统做检测以及资源预留。

Confirm阶段

确认执行业务操作。

Cancel阶段

取消执行业务操作。

TCC处理流程与2PC类似,不过2PC通常是在跨库的DB层面,TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,是的降低锁冲突、提高吞吐量成为可能。

不足之处在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。
此外其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。
为了满足一致性的要求,confirm和cancel必须实现幂等。

TCC(图片来源于网络)

本地消息队列

如果只需要保证数据的最终一致性,那么可以使用消息队列来解决。

什么是事务Transaction

Transaction一词在英语的翻译中还有一次交易、业务、事务、办理、处理;

对于一次账户充值100元的操作,其中就有多个环节。
1)检查账户的有效性,查询账户余额;
2)记录充值流水;
3)为账户余额增加100元。

对于这三个操作来讲,他是一个Transaction, 要么都成功,只要有一个失败,就必须整个Transaction失败,不允许记录了充值流水成功,但是余额增加失败。这三个是一个整体,是不可分割的工作单元。

事务的特性

原子性 Atomic:要么都成功,只要有一个步骤失败,整个步骤必须回滚(失败)。
一致性 Consistency:事务保证读取到的数据总是一致的,如(不存在100元的充值流水时金额为100元, 存在100元的充值流水时金额为200元)。
但是对于这个充值过程来讲,肯定是先记录了充值记录,后增加100元余额。可以查到充值记录,但余额是100是客观存在的。
隔离性Isolation:为了保证一致性,事务执行过程中的中间状态不对外部可见,事务需要对整个过程进行隔离。
持久性 Durability:只要事务一经提交,就一定会被持久化到磁盘中。

Transaction 阶段

Transaction 需要有开始和结束,标记一个Transaction的开始和截止;
当所有的操作都成功的时候,需要Commit;
当其中某个操作失败的时候,之前的操作需要Rollback;

Transaction Start;
Transaction Commit Or Rollback;
Transaction End;

脏读

假如业务员给账户A增加100元的操作的同时(记录了充值流水100元,流水号为666),对账系统也在同时运行同时读取到了这一笔流水号为666的流水记录。但是后续的为余额增加100元的操作失败了,整个Transaction RollBack。 导致账户余额比对账系统少了100元。

在这里,对账系统看到了业务系统Transaction未提交时的数据,叫脏读。
也就是说当前事务的中间状态,对其他事务时可见的。

事务的最低隔离级别是未提交读(Read Uncommitted),因此会发生脏读的现象。
为了解决脏读的现象,需要将事务隔离起来,只允许读到已提交的数据。 Read Committed.

不可重复读

假如业务员A给用户增加100元。

开启事务;
查询账户余额 100元;
有其他事情去忙……

此时业务员B也给用户增加100元。
开启事务;
查询账户余额 100元
增加流水记录;
增加账户余额100元,变为200元;
结束事务;

业务员A回来为A增加100元:
查询账户余额 200元;

不可重复读就是业务员A在同一个事务内,先后两次读取同一条数据的结果可能不一样。
可重复读就是业务员A在同一个事务内,先后两次读取同一条数据的结果总是相同的,无论其他会话是否已经更新了这条数据。

为了保障在同一事务下,先后两次读取到的同一条数据结果一致,需要将事务的隔离级别调整为可重复读(Repeatable Read)。

幻读

假如业务员A给用户增加100元。

开启事务;
查询流水中是否有ID为1000的流水记录,不存在;

业务员B给用户增加100元;
开启事务;
查询流水中是否有ID为1000的流水记录,不存在;
插入流水记录;
更新账户余额为200;
提交事务;

业务员A开始执行;
插入流水记录,此时已经有ID为1000的流水记录,系统异常;
可以开启重试机制;
由于隔离性,查询是否有ID为1000的流水记录时还是不存在,重试插入时还是异常。

为了解决幻读的问题,需要将所有的事务和操作进行串行化。这也是Database的最高隔离级别,性能也最差。

事务的隔离级别以及解决的问题

对于账户充值来讲,交易的原子性以及持久性是最重要的。可以适当牺牲一些一致性和隔离性。

以下操作在 ReadCommitted 和 Repeatable Read下是安全的。
1)给账户余额表增加一个log_id属性,记录最后一笔交易的流水号。
2)首先开启事务,查询并记录当前账户的余额和最后一笔交易的流水号。
3)写入流水记录。
4)更新账户余额以及流水记录ID,需要在更新语句的Where条件中限定,只有流水好等于之前查询出的流水号时才能更新。
update account_balance set amount = amount + 100 , log_id = 2 where user_id = 0 and log_id = 1;
5)检查更新余额的返回值,如果为1 提交事务,否则回滚。