1.事务相关概念
1.1.为什么要分布式事务
在单体应用中通常情况下只有一个数据库(单数据源),集成事务是一个非常容易的工作。Spring对事务做了很好的管理,我们只需要通过简单的注解@Transactional就可以完成本地事务管理。
但是在微服务项目中事务的管理变得困难,因为微服务项目往往有很多的数据库组成,如果在一个业务中涉及到了对多个微服务以及多个数据库的写操作(跨多个数据源),那么要如何才能保证多个数据库组件的读写一致呢?数据库A写操作成功过,数据库B写操作失败要怎么样让数据库A的写操作回滚?很显然用本地事务管理是不能实现了。
我们知道,虽然Spring对事务做了很好的管理和封装,但是最终都是调用数据库的事务命令完成事务管理,那在微服务中是跨多个数据库的写操作,如果想要实现事务管理(要么都成功,要么到失败)就需要协调多个数据库的提交,回滚等操作,这就需要使用分布式事务框架来实现。
本文章意在对分布式事务的基础理论以及常见的分布式事务解决方案做一个探讨和总结。
1.2.强一致性、弱一致性、最终一致性
这里的一致性我们可以简单的理解为数据的同步时机,如关系型数据库要求当数据写入数据库之后,马上就能读到写入的数据,这是强一致性,对数据的实时性要求非常高 。
在某些场景下对数据的实时性要求不那么高,比如能够容忍在写操作完成之后不要求所有数据都能马上读到或者能够容忍写数据和读到数据之间有一定的时间延迟,那这个就是弱一致性。
而最终一致性也是弱一致性的一种,即数据写完之后,不保证什么时候能够读到数据,但是最终数据都会同步成功,最终都可以读到数据,这个就是最终一致性。
强一致性一般用户关系型数据库,而弱一致性或者最终一致性一般使用分布式领域,到底如何选择需要根据具体的业务场景来决定。
1.3.刚性事务ACID
关系型数据库使用的就是刚性事务是强一致性的典型例子,它严格遵循事务的ACID特性。
原子性(Atomicity)
把一个事务看做一个整体,不可分割(原子是最小单位不能再分),一个事务中的多个事务操作(对数据库的写操作)要么都成功,要么都失败,如果失败,所有的操作回滚,相当于啥都没做。
一致性(Consistency)
在一个事务中,不管发生什么样的操作,有多少的并发,事务必须保证数据的一致性改变,比如有账户A 100 元和账户B 0元,总和是 100 元,那么在一个事务中有两个操作,A 减少 100 元 ,B 增加 100 元 ,事务提交后A变成了 0 元 ,B就需要变成了 100 元,不管两个账户之间发生什么样的操作,做了多少次转账,但是两个账户的总额最终都是 100 元 。
隔离性(Isolation)
事务相互隔离,一个事务的执行不能被其他事务干扰,比如两个事务对同一个数据进行修改,那么当开始事务的那一刻,两个事务都有自己完整的数据空间,相互不能干扰和影响。在SQL规范有4个事务隔离机制,不同的隔离机对事务的处理不同,事务隔离机制分为:读未提交、读已提交 、可重复读 、串行化四种。
Read uncommitted 读未提交
:就是一个事务可以读取另一个未提交事务的数据,这个隔离机制会造成脏读。举个例子:老板老赵给员工小刚发工资2000元,但是老赵误把2000输成了20000 ,这个时候事务来没提交 ,然后小刚查询自己的工资发现发了20000元,但是老赵及时发现金额输错了,把20000修改成 2000 ,然后提交了事务,这时候小刚再次查询自己的工资发现工资变成了 2000元。
为什么小刚最开始看到的是20000元,就是因为它读取到了老赵未提交的数据(读未提交),小刚读取到的是脏数据,即:读未提交会造成数据的脏读,可以使用 “读提交”来解决
Read committed 读提交
:一个事务只能读取到另一个事务提交后的数据,这个隔离机制会造成不可重复读。举个例子:小刚拿着2000块工资开开心心去洗脚城洗脚,检查了一下卡上的余额是2000元没错,就在这时,小刚的媳妇儿把钱转到了自己的卡里面,当小刚洗完脚准备结账时发现卡上余额为0 (小刚第二次读必须等到小刚媳妇儿把钱转走然后提交事务后的数据)
神奇的一幕发生了,明明卡上有钱的,怎么就变成了 0 ,这个就是读提交 ,同一个数据,事务一在读取,正好事务二在修改这个数据,那么读取的事务必须等到修改的事务操作完成才能读取数据,这种隔离机制可以解决脏读,但是会造成不可重复读,因为在一个事务中对一个数据做两次读取读到了不一样的结果。可以使用 “Repeatable read ”可重复读机制来解决。
Repeatable read 可重复读取
:在一个事务中对一个数据做多次读取,获取到的结果都是一样的,即一个事务开启,对一个数据进行读取,另外的事务不允许修改。还是使用上面的例子:小刚卡里有 2000 元,去洗脚城洗脚 ,小刚给她媳妇儿打了一个电话,说不准转走我卡里的钱,我要去洗脚用,那么在小刚结账的时候卡里依然有 2000元,开开心心结账走人。
这种隔离机制解决了不可能重复读的问题,即一个事务中多次读取相同数据都是相同的结果,这是因为事务读的时候不允许事务修改(针对UPDATE),但是有可能会出现幻读,举例:小刚洗完脚后,小刚的媳妇儿查询小刚的消费记录是 1000元(insert一个消费记录),这时候小刚又去搓了一个背花掉 500 元,然后小刚的媳妇儿去打印消费清单结果发现了两条消费记录总共是1500 元,小刚的媳妇儿以为自己出现了幻觉。
这种情况就是幻读,同一个事务中读取相同的表,前后两次读取的条数不一样, 可重复读取能够决绝读取的时候不允许修改,但是不能保证不能添加数据(insert),要解决脏读可以使用“串行化”隔离机制。
Serializable 串行化
:这种隔离机制是让事务串行化执行,事务一个一个执行就避免了脏读,不可重复读和幻读的问题,但是这种方式对数据库性能的影响极高,一般不用。
其实对于Mysql数据库使用的是 Repeatable read 可重复读 ,Sql Server 和 Oracle 使用的是Read committed 读提交。
持久性(Durability)
一旦事务完成提交,该事务对数据的写操作便已经完成,并不会被回滚。
1.4.CAP理论
在分布式领域有一个著名的CAP定义,它主张在分布式应用中要保证数据的强一致性是很难做到的,只能委曲求全保证数据的最终一致性。下面是百度百科的定义:
CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
- 一致性(Consistency):可以理解为分布式中存在多个数据副本,当其中某个数据副本发生数据更新需要实时同步到其他节点上。多个节点数据总是保持一致。例如:同一个数据同时存在于A服务器和B服务器,那么当A服务器的数据发生改变,B服务器的数据始终保持同步。
- 可用性(Availability): 客户端在任何时候访问集群,请求都可以正常返回结果,可以允许有一定的延迟。
- 分区容错性(Partition tolerance):能够容忍当服务之间通信发生故障,整个集群别分割为多个无法相互通信的分区的情况。即当集群因为网络故障被划分为多个分区,集群仍然可用。
需要特别说明的是,分区容错是分布式系统最基本的要求,因为我们的机器都是部署在不同的网络节点(分布式嘛),因为网络不可控问题,分区容错总是存在的,所以系统必须具备分区容错性,即需要在AP和CP做选择,因此系统架构师需要根据具体的业务场景在C(一致性)和A(可用性)之间寻求平衡。
其实在《Spring Cloud 极简入门》中在介绍Eureak集群的时候我们有探讨过 CAP理论,Eureka选择了CAP理论中的AP,保证了可用性和分区容错,放弃了数据一致性。
1.5.BASE理论与柔性事务
下面是百度百科对BASE理论的定义
BASE 理论是对 CAP 中的一致性和可用性进行一个权衡的结果,理论的核心思想就是:我们无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性。
其实BASE理论是根据CAP理演化而来的,就是对CAP理论中的一致性和可用性的权衡结果,是对大规模互联网系统分布式实践的总结。
BASE理论当然是牺牲了数据的强一致性,没办法做到像关系数据库那样的读写实时一致,但是它牺牲了一致性得到了可用性,BASE理论允许数据在一段时间内是不一致的,但最终达到一致状态。这种使用场景在生活中着我们随处可见,如银行转账 “我们将在24小时为您转账成功” 就可以看做是类似的场景 。
柔性事务
柔性事务的概念就是是在分布式场景下基于BASE理论定义出来的 ,它跟传统的事务(刚性事务)不一样,它不一定严格遵循事务的ACID四大特性。
2.分布式事务的解决方案
2.1.两阶段提交(2pc)
先来所以说 XA规范
XA协议由Tuxedo首先提出的,并交给X/Open组织,作为资源管理器(数据库)与事务管理器的接口标准(即接口函数)。目前,Oracle、Informix、DB2和Sybase等各大数据库厂家都提供对XA的支持。XA协议采用两阶段提交方式来管理分布式事务。
简单理解就是XA规范协议是一种事务协议,通过这种协议来通知数据库事务的开始、结束、提交、以及回滚,XA 接口函数由数据库厂商提供。XA协议使用二阶段提交来处理分布式事务,说的更明白一点就是XA协议保证了分布式事务的原子性,要么都成功,要么都失败。
XA协议采用两阶段提交方式来管理分布式事务采用两阶段提交 2PC(Two Phase Commitment Protocol)来管理分布式事务,所谓二阶段是有两个阶段组成,一阶段投票阶段和二阶段提交阶段。同时它是由“事务协调器”和若干“事务执行者”两个角色组成。
第一阶段Prepare:准备阶段(投票阶段),
- 事务协调器向所有事务参与者发请求,询问是否可以执行提交操作(你们都可以执行事务操作吗?),并开始等待各参与者节点的响应。
- 事务参与者收到协调者的指令开始执行事务操作但是不会提交事务,同时写Undo log(写操作之前首先将数据备份log,如果要回滚就从这个log进行数据还原) 和 Redo log(修改数据在buffer pool缓冲池中修改,Redo log是对这个缓冲池的内容做持久,避免修改的数据丢失) 。
- 如果参与者事务操作都执行成功(注意哦,没提交事务哦),那么就会回复 事务协调器 “准备OK” ,如果事务操作失败,那么就会回复执行者“准备不OK”。
第二阶段Commit:
正常流程
- 事务协调器会收到参与者的回复,如果所有的参与者都回复“准备ok”,意味着所有的参与者都可以完成事务操作,那么事务协调器会向每个事务参与者发送一个“commit” 提交事务指令(既然大家都可以进行事务操作,那大家都提交事务把)
- 事务参与者收到指令就开始提交事务,然后会向事务协调器回复“完成”,事务协调器收到所有参与者都回复完成,事务完成。
回滚流程
- 如果再第一阶段事务协调器收到了某个事务参与者回复“准备不ok”即事务操作执行失败,那么事务协调器会向所有的事务参与者发送“rollback”回滚执行(有一个成员不ok,那大家都散了吧,今天的事情搞不成了)
- 事务参与者收到指令,回滚之前的事务操作,即:将数据还原到“Undo log”的数据,然后先事务协调者回复“回滚成功”,当事务协调器收到所有的参与者回复“回滚成功”后,取消事务。
发送回滚指令
二阶段提交的问题
二阶段能保证分布式事务的原子性,但是也有一些明显的缺陷。比如:
在第一阶段,如果参与者迟迟不回复协调者,就会造成事务的阻塞,性能不好。
单节点故障,如果协调器挂了,参与者会阻塞,比如在第二阶段,如果事务协调器宕机,参与者没办法回复信息,长时间处于事务资源锁定,造成阻塞(事务操作是要加锁的)。
在第二阶段,如果在事务协调器发出"commit"执行后宕机,一部和参与者收到了消息提交了事务,而一部分没有消息没法做出事务提交操作,这样就出现了数据不一致。
在第二阶段,如果事务事务协调器发出“commit”指令后宕机,收到“commmit”指令的参与者也宕机了,那么事务最终变成了什么效果,提交了还是没提交?没有谁知道。
2.2.三阶段提交(3pc)
三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本,
3PC在2PC的功能上做了两个改动,一是在协调者和事务参与者之间引入了超时机制,在第一阶段和第二阶段中插入一个准备阶段 , 保证了在最后提交阶段之前各参与节点的状态是一致的,那么现在的三阶段分为了:
- “canCommit“询问是否能提交,
- “papreCommit”准备提交阶段 ,
- “doCommit”提交阶段。
第一阶段“canCommit” :
- 事务协调者向事务参与者发送 canCommit 请求询问是否能提交事务,然后等待所有事务参与者的返回
- 事务参与者接收到事务些调整的canCommit指令,然后自身认为能够提交事务则返回 “yes”否则返回“no”
第二阶段“papreCommit”
事务协调者收到所有的事务参与者的canCmmit指令的反馈结果,这里有两种情况,一是所有的反馈都是yes,二是有部分的事 务参与者返回No,后者反馈超时。
正常流程
- 如果事务协调者收到所有的事务参与者的canCmmit指令反馈结果都为YES,那么就进入papreCommit阶段。事务协调者向事务参与者发送 “papreCommit”指令。
事务参与者收到“papreCommit”指令,开始进行事务操作,并将undo和redo信息记录到事务日志中,如果顺利执行事务操作,则反馈ACK确认信息,然后等待下一步指令。
中断事务
如果事务协调者收到所有的事务参与者的canCmmit指令反馈结果出现了NO,或者等待超时,那么就执行事务中断,向所有的事务参与者发送“abort”中断指令。
- 事务参与者接收到“abort”指令,中断事务,当然如果事务参与者迟迟未收到事务协调者的指令等待超时也会中断事务。
第三阶段“doCommit阶段”
这里准备提交事务了,这里有两种情况,如果事务协调者收到所有的事务参与者的papreCommit指令反馈结果都是ACK,那么进入doCommit阶段,否则会中断事务。
正常流程
- 事务协调者收到所有的事务参与者的papreCommit指令反馈结果都是ACK,然后向事务参与者发送“doCommit”指令,通知提交事务。
- 事务参与者收到“doCommit”指令,正式执行事务提交,并且释放所有事务资源,返回向事务协调者返回事务结果状态“ACK”完成
事务协调者收到所有的事务参与者都返回ACK成功,完成事务。
中断事务
- 事务协调者收到的事务参与者的papreCommit指令反馈结果有的不是ACK,那么事务协调者然后向事务参与者发送“abort”事务中断指密令。
- 事务参与者收到“abort”事务指令,会根据unlog日志文件还原数据,然后释放事务资源,然后向事务协调者发送回滚“ACK”消息
- 事务协调者收到所有的事务参与者都返回ACK消息,取消事务。
2.3.2pc和3pc总结
3pc在2pc的基础上增加了超时,比如事务参与者没有收到事务协调者的提交指令,等待一定时间后会自动提交事务,减少了事务的阻塞。事务协调者咩有收到事务参与者的回复而超时,会触发事务回滚。但是3pc的问题是数据不一致,比如全局事务取消了,但是某一个事务参与者么有收到事务取消的指令,等待一段时间后会选择自动commit,这样就会造成数据不一致的情况。
2.3.TCC事物补偿
TCC(Try Confirm Cancel) 事务补偿机制,即每一个操作都要做相应的补偿机制,即如何确认操作成功,如果操作失败如何撤销事务。它分为三个阶段
- Try : try的意思是尝试,其实这个步骤是用来做业务的预处理,可以理解为是做一些准备工作,等到Confirm之后这些操作才算成功。
- Confirm :确认,如果所有的事务参与者都try成功,执行commit对业务做提交操作,或者可以理解成对try的工作作出确认。
- Cancel:取消,如果try失败需要回滚,即取消try的预处理操作
事务协调器是用来协调全局的事务操作,可以是独立的服务,也可以让事务发起方来承担事务协调器。
正常流程
回滚流程
举个例子:
假设 A 账户 向 B 账户转账,大致流程如下:
1、开启全局事务,调用A 和 B的try接口,try的逻辑就是先把 A账户和B账户的金额冻结起来
2、然后调用A和B的 Confirm 接口,调用转账逻辑,转账成功进行解冻
3、如果A和B的Confirm接口执行成功,那么转账成功,如果A或B的Confirm执行失败,则调A和B的Cancel接口回滚之前的操作,账户还原,相当于啥都咩有做。
注意事项
为了防止Try、Confirm或者Cancel操作重复执行事务参与者需要需要考虑幂等控制,即Try、Confirm、Cancel 执行次和执行多次的业务结果是一样的
TCC和 2PC的区别
看到这里你是不是感觉TCC和2PC很像,虽然他们都有类似 “准备”,“提交”,"回滚"的操作,但TCC的try阶段是和全局事务无关的,只是一些业务逻辑的前奏而已,TCC它对事务的提交/回滚是通过执行一段confirm/cancel业务逻辑来实现,二者还是有很大的差别。
2.4.MQ消息最终一致性
利用MQ消息队列实现分布式事务,比如RocketMQ可以支持事务消息,这种事务处理方式类似于二阶段提交。
大致流程是:
1.主业务方向发送一个“Prepared”消息到MQ,这个消息还未被确认,然后主业务方执行自己的业务,并提交本地事务,成功后向MQ确认之前的“Prepared”消息。
2.消息接受者方收到消息后执行业务逻辑,提交本地事务,然后做一个消息签收,如果消费者消息接受失败会进行重试,保证消息最终被消费。
总结一下
这里我们探讨了一下分布式事务的一些基础思想和理论,以及分布式事务的几种解决方案思想,希望大家可以对分布式事务由一些了解 ,同时也是为后面Spring Cloud Seata 做铺垫。