在常用的关系型数据库,都是具备事务特性的。
那什么是事务呢?事务是数据库运行的一个逻辑工作单元,在这个工作单元内的一系列SQL命令具有原子性操作的特点,也就是说这一系列SQL指令要么全部执行成功,要么全部回滚不执行。
如果是不执行,那么对于数据库中的数据来说,数据没有发生任何改变。
数据库事务要满足四个需求:
原子性(Atomic): 事务必须是原子工作单元,对数据进行修改,要么全部执行,要么全部都不执行
一致性(Consistent): 事务在完成时,必须使所有数据都保持一致状态,事务结束时所有的内部数据结构都必须是正确的;如果事务是并发多个,系统也必须如同串行事务一样操作。其主要特征是保护性和不变性。(Preserving an Invariant)
隔离性(Isolation): 由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。
持久性(duration): 事务完成之后,对系统的影响是永久性的,不会被回滚。
通常情况下,传统的关系型数据库只能保证单个数据库中多个数据表的事务特性。
一旦多个SQL操作涉及到多个数据库,这类的事务无法解决跨库事务问题。
在传统架构下,这种问题出现的情况非常少,但是在分布式微服务架构中,分布式事务的问题变得更加突出。
以电商项目为例,假设我们要实现电商系统中的支付功能,它的实现流程如下:
在微服务架构中,应用被拆分成以业务模块为单元的服务,并且每个服务有自己的数据库系统。
当用户发起支付时,会涉及到以下几个事务操作:
创建支付订单
从资金服务中扣除余额
从红包服务中扣除余额
更新支付结果
这是四个典型的事务操作,而且这些操作分别属于不同的数据库,最终期望的结果是希望这三个服务所对应的数据是一致的,很显然传统的事务无法解决这个问题!
这就引出了分布式事务问题,所谓分布式事务,就是事务具有分布式特性,简单理解就是如何保证多个小事务组成的大事务的ACID特性。
说到分布式事务,我们不得不提的就是 X/OPENDTP事务模型,它是X/Open这个组织定义的一套分布式事务的标准,也就是定义了规范和API接口,由各个厂商进行具体的实现。
这个标准提出了使用二阶段提交(2PC)来保证分布式事务的完整性。
在了解 DTP 事务模型之前,我们先来了解一下什么是2PC协议。
两阶段提交又称2PC,2PC是一个非常经典的强一致、中心化的原子提交协议 。
如下图所示,我们知道,在分布式事务中,多个小事务的提交与回滚,只有当前进程知道,其他进程是不清楚的。
而为了实现多个数据库的事务一致性,就必然需要引入第三方节点来进行事务协调,如下图所示。
从图中可以看出,通过一个全局的分布式事务协调工具,来实现多个数据库事务的提交和回滚,在这样的架构下,事务的管理方式就变成了两个步骤。
第一个步骤,开启事务并向各个数据库节点写入事务日志。
第二个步骤,根据第一个步骤中各个节点的执行结果,来决定对事务进行提交或者回滚。
这就是所谓的2PC提交协议。
2PC提交流程如下:
既然提到了2PC,那我们就顺便说一下三阶段提交模型 3PC 。
三阶段提交是在两阶段提交的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。
但是性能问题和不一致问题仍然没有根本解决。下面我们还是一起看下三阶段流程的是什么样的:
1PC
这个阶段类似于2PC中的第二个阶段中的Ready阶段,是一种事务询问操作。事务的协调者向所有参与者询问“你们是否可以完成本次事务?”,如果参与者节点认为自身可以完成事务就返回“YES”,否则“NO”。而在实际的场景中参与者节点会对自身逻辑进行事务尝试,简单来说就是检查下自身状态的健康性,看有没有能力进行事务操作。
2PC
在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit
阶段进行事务预提交。此时分布式事务协调者会向所有的参与者节点发送PreCommit
请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。
否则,如果阶段一中有任何一个参与者节点返回的结果是No响应,或者协调者在等待参与者节点反馈的过程中超时(2PC中只有协调者可以超时,参与者没有超时机制)。整个分布式事务就会中断,协调者就会向所有的参与者发送**“abort”**请求。
3PC
在阶段二中如果所有的参与者节点都可以进行PreCommit
提交,那么协调者就会从预提交状态》提交状态。然后向所有的参与者节点发送"doCommit"
请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈**“Ack”**消息,协调者收到所有参与者的Ack消息后完成事务。
相反,如果有一个参与者节点未完成PreCommit
的反馈或者反馈超时,那么协调者都会向所有的参与者 节点发送abort请求,从而中断事务。
相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。
另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。
以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。
基于上述分析,了解2PC之后,我们再来理解下 DTP 事务模型。
X/Open了定义了规范和API接口,这个标准提出了使用二阶段提交来保证分布式事务的完整性,如下图所示。
X/Open DTP模型定义了三个角色和两个协议,其中三个角色分别如下:
AP(Application Program),表示应用程序,也可以理解成使用DTP模型的程序。
RM(Resource Manager),资源管理器,这个资源可以是数据库, 应用程序通过资源管理器对资源进行控制,资源管理器必须实现XA定义的接口。
TM(Transaction Manager),表示事务管理器,负责协调和管理全局事务,事务管理器控制整个全局事务,管理事务的生命周期,并且协调资源。
两个协议分别是:
XA协议: XA 是X/Open DTP定义的资源管理器和事务管理器之间的接口规范,TM用它来通知和协调相关RM事务的开始、结束、提交或回滚。目前Oracle、Mysql、DB2都提供了对XA的支持; XA接口是双向的系统接口,在事务管理器™以及多个资源管理器之间形成通信的桥梁(XA不能自动提交)
https://dev.mysql.com/doc/refman/8.0/en/xa.html
https://dev.mysql.com/doc/refman/8.0/en/xa-statements.html
XA协议的语法,主流的数据库都支持 XA协议,从而能够实现跨数据库事务。
XA {START|BEGIN} xid [JOIN|RESUME] # 负责开启或者恢复一个事务分支,并且管理XID到调用线程
XA END xid [SUSPEND [FOR MIGRATE]] # 负责取消当前线程与事务分支的关联
XA PREPARE xid # 负责询问RM 是否准备好了提交事务分支
XA COMMIT xid [ONE PHASE] # 通知RM提交事务分支
XA ROLLBACK xid # 通知RM回滚事务分支
XA RECOVER [CONVERT XID]
TX协议: 全局事务管理器与资源管理器之间通信的接口
在分布式系统中,每一个机器节点虽然都能够明确知道自己在进行事务操作过程中的结果是成功还是失败,但却无法直接获取到其他分布式节点的操作结果。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交到(RM)。
在X/OpenDTP模型中,一个分布式事务所涉及的SQL逻辑都执行完成,并到了(RM)要最后提交事务的关键时刻,为了避免分布式系统所固有的不可靠性导致提交事务意外失败,TM 果断决定实施两步走的方案,这个就称为二阶提交,如图所示。
我们按照如上2PC的提交流程,在MySQL中模拟一下。
# 启动一个XA事务 (xid 必须是一个唯一值; [JOIN|RESUME] 字句不被支持)
xa start 'xatest';
insert into user(username,password) values('cc','cc');
# 结束一个XA事务 ( [SUSPEND [FOR MIGRATE]] 字句不被支持)
xa end 'xatest';
# 准备
# 此动作会把这个事务的redo日志写入innodb redo log,只要这一阶段是成功的,那么后续XACommit一定会成功
xa prepare 'xatest';
XA COMMIT 'xatest'; //提交事务
# 或者回滚代码,如果xa prepare这个环节出现错误,事务协调者就把这个事务回滚。
xa rollback 'xatest'; //回滚
主流的数据库如oracle、mysql都支持XA协议,因此都可以基于xa协议规范,通过二阶段提交来实现数据的一致性。
而J2EE也遵循了X/OpenDTP规范,设计并实现了Java里面的分布式事务编程接口规范-JTA。因此我们可以利用这些JTA中提供的API来完成多个数据库的事务一致性处理。
但是,在XA事务中,根据前面我们的操作过程可以发现,另外,在XA COMMIT阶段,如果其中一个RM因为网络超时没有收到数据提交的指令,就会导致数据不一致。
所以如果我们要基于这些API来实现一个比较成熟的分布式事务解决方案,还需要考虑到这些问题并提出解决方案。比如针对这个问题,我们可以采用重试的机制来完成数据一致性。
因此,针对这类分布式事务解决方案的开源框架也很多,比如:
Atomikos
Atomikos是为Java平台提供的开源的事务管理工具,它包含收费和开源两个版本,开源版本基本能满足我们的需求。
Bitronix
Bitronix是一个流行的开源JTA事务管理器实现。
Seata
阿里巴巴开源的事务解决方案。
我们要知道即便是基于2pc协议提交,它也是有两类落地形式的。
基于zookeeper的, 改进版本的2pc提交(投票方式,少数服从多数)
基于Atomikos、Bitronix等,使用XA协议实现的强一致性提交,也就是所有参与者都成功,才能提交。
对于这些不同的落地方案,核心还是由场景来决定,但同时又引出来一个问题,为什么要有不同的落地方案?
大家都听过CAP理论,所谓CAP理论,说的是在分布式架构下的数据一致性问题和性能问题的平衡方案, 它代表三个关键词:
Consistency 一致性:同一数据的多个副本是否实时相同。
Availability 可用性 :一定时间内 & 系统返回一个明确的结果则称为该系统可用。
Partition tolerance 分区容错性 将同一服务分布在多个系统中,从而保证某一个系统宕机,仍然有其他系统提供相同的服务。
CAP理论告诉我们,在分布式系统中,C、A、P三个条件中我们最多只能选择两个。那么问题来了,究竟选择哪两个条件较为合适呢?
对于一个业务系统来说,可用性和分区容错性是必须要满足的两个条件,并且这两者是相辅相成的。业务系统之所以使用分布式系统,主要原因有两个:
提升整体性能
当业务量猛增,单个服务器已经无法满足我们的业务需求的时候,就需要使用分布式系统,使用多个节点提供相同的功能,从而整体上提升系统的性能,这就是使用分布式系统的第一个原因。
实现分区容错性
单一节点 或 多个节点处于相同的网络环境下,那么会存在一定的风险,万一该机房断电、该地区发生自然灾害,那么业务系统就全面瘫痪了。为了防止这一问题,采用分布式系统,将多个子系统分布在不同的地域、不同的机房中,从而保证系统高可用性。
这说明分区容错性是分布式系统的根本,如果分区容错性不能满足,那使用分布式系统将失去意义。
此外,可用性对业务系统也尤为重要。在大谈用户体验的今天,如果业务系统时常出现“系统异常”、响应时间过长等情况,这使得用户对系统的好感度大打折扣,在互联网行业竞争激烈的今天,相同领域的竞争者不甚枚举,系统的间歇性不可用会立马导致用户流向竞争对手。因此,我们只能通过牺牲一致性来换取系统的可用性和分区容错。
所以这才有了Zookeeper中的基于少数服从多数的2pc落地方案(Zab协议)。
因此,也引出了另外一个理论,叫Base理论。
CAP理论告诉我们一个悲惨但不得不接受的事实——我们只能在C、A、P中选择两个条件。而对于业务系统而言,我们往往选择牺牲一致性来换取系统的可用性和分区容错性。不过这里要指出的是,所谓的“牺牲一致性”并不是完全放弃数据一致性,而是牺牲强一致性换取弱一致性。
BA : Basic Available 基本可用
整个系统在某些不可抗力的情况下,仍然能够保证“可用性”,即一定时间内仍然能够返回一个明确的结果。只不过“基本可用”和“高可用”的区别是:
S:Soft State:柔性状态
同一数据的不同副本的状态,可以不需要实时一致。
E:Eventual Consisstency:最终一致性
同一数据的不同副本的状态,可以不需要实时一致,但一定要保证经过一定时间后仍然是一致的。
所以对于服务来说,我们有很多的方案去选择:
提供查询服务确认数据状态
幂等操作对于重发保证数据的安全性
TCC事务操作
补偿操作
定期校对
下面分别对不同的解决方案做一个详细的说明。
最终一致性方案,也称为弱一致性方案。
它是基于BASE理论的落地,也就是说,多个数据库节点的数据运行存在中间状态,但是在未来的某个时间内,这些数据会达成一致。
可靠性消息最终一致性方案,是指在多个事务中,当前事务发起方执行完本地事务后,发送一条数据同步消息给到事务参与方。
基于可靠性消息队列,需要保证这个消息一定能够被其他事务参与方接收并执行成功,从而实现多个事务参与者之间的数据最终一致性。
实现原理如下图所示:
假设在用户注册的场景中,用户注册成功后需要给用户增加积分。在这里就涉及到两个事务操作:
一个是用户服务中的用户记录新增
另一个是积分服务中的积分增加
在这个场景中,我们可以采用最终一致性方案,当用户服务的本地事务执行成功后,再把增加积分的消息通过可靠性消息传递到积分服务中,积分服务消费该消息后执行积分新增操作。
这个方案看起来好像没问题,但是在整个流程中,还是会存在数据不一致的情况。
不难发现,上面这个方案仍然存在原子性问题,我们需要保证本地事务执行成功,且消息发送必须成功。
假设我们先发送消息,再操作数据库
这种方案有可能会出现消息发送成功,但是数据库操作失败的问题,从而导致数据不一致。
先操作数据库,再发送消息
这种情况下看起来没问题,因为如果MQ发送失败,就会抛出异常,导致数据库事务回滚。
但是如果是MQ超时异常,使得数据库回滚,但MQ可能已经正常发送了,同样也会导致数据不一致问题。
事务参与方也就是MQ的消费端,必须要能够从消息队列接收到消息,也就是无论什么情况下,消费者必须至少收到一个消息。
例如在 rabbitmq 中开启confirm模式,消费者消费成功后,会给生产者返回确认信息,告知生产者成功消费信息了。
为了保证“消费者至少收到一个消息”的情况,那么MQ这边需要实现消息的可靠投递,为了保证这一点,必然会采用重试机制。
同样,假设存在这样一种情况: 消费者已经收到了这个消息,但是在发送消息确认通知时,MQ因为超时得到一个超时异常。
此时,消息中间件会重复投递这个消息,就会导致消费者重复接收消息的问题。如下图所示:
因此,通过上述分析,要实现完全的可靠性解决方案,我们还需要在整个流程中,分别解决这三个问题。
本地消息表这个方案最初是 eBay 提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
下面以注册送积分为例来说明:下例共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。
整体流程如下:
用户注册 :用户服务在本地事务新增用户和增加 “积分消息日志”。(用户表和消息表通过本地事务保证一致)这种情况下,本地数据库操作与存储积分消息日志处于同一个事务中,本地数据库操作与记录消息日志操作具备原子性。
定时任务扫描日志,在第一步中,我们把需要发送到消息队列的事务消息保存到了消息日志表,为了保证消息能够百分之百的发送给消息队列,这里可以启动一个定时任务不断扫描这个消息表中的消息发送到消
息队列。当消息队列反馈发送成功后,删除该消息日志,否则等到下一个任务周期重试。
消息的可靠性消费,主流的MQ都带了消息确认机制(ack),消费者监听MQ的消息。
当消费者收到“新增积分”消息后,根据该消息的逻辑规则完成指定用户的积分更新,再基于MQ的ACK机制,从而可以实现可靠的消息投递功能。
在可靠性消息投递过程中,由于MQ的重试机制,有可能会出现消费者重复收到同一个消息的情况。
因此,我们需要保证消息投递的幂等性,所谓的幂等性,就是MQ重复调用多次产生的业务结果与调用一次产生的业务结果相同;
在分布式架构中,我们调用一个远程服务去完成一个操作,除了成功和失败以外,还有未知状态,那么针对这个未知状态,我们会采取一些重试的行为;
或者在消息中间件的使用场景中,消费者可能会重复收到消息。对于这两种情况,消费端或者服务端需要采取一定的手段,也就是考虑到重发的情况下保证数据的安全性。一般我们常用的手段:
状态机实现幂等
有限状态机(Finite-state machine FSM),又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。用于处理复杂的状态转换。
比如在支付的例子中,为了简单理解,我们不考虑退款、取消订单等复杂的状态,只考虑未支付和已支付两种状态之间的转换。
由上面的状态转换图可以看到,相同支付订单ID从未支付状态,要不就是支付不成功停留在未支付状态,要不就是支付成功,状态转移为已支付。
此状态转移过程不可逆。
所以相同支付ID的请求,支付状态只能进行一次从未支付到已支付的转换。通过这种方式从而保证了其幂等性。
数据库唯一约束实现幂等
使用 redis 提供的 setNx 命令
基于可靠性消息的最终一致性方案,我们可以采用消息中间件(MQ)实现事务最终一致。
这里我们采用RocketMQ
来实现。 RocketMQ
自4.3版本之后,开始支持事务消息,事务消息为分布式事务提供了非常方便的解决方案,整体实现流程如下图所示:
MQ生产者发送事务消息到MQ Broker中,此时MQ Broker把消息状态标记为(Prepared),此时这条消息,MQ消费者是无法消费的。
MQ Broker回应MQ生产者,消息发送成功,表示MQ Broker已经接收到了消息并且保存成功。
MQ 生产者此时开始执行本地事务。
MQ 生产者本地事务执行成功后自动向MQ Broker发送一个Commit消息,MQ Broker收到该消息后把第一步发送的那条消息标记为“可消费”,此时MQ 消费者可以正常收到这条消息进行处理。
MQ消费者消费完这条消息后,向MQ Broker发送一个ACK表示成功消费,否则MQ Broker会不断重试。
如果MQ 生产者执行本地事务的过程中,MQ生产者宕机或者一直没有发送commit给到MQ Broker,MQ Server将会不停的询问同组的其他Producer来获取事务执行状态。MQ Server会根据事务回查结果来决定是否投递消息。
RocketMQ事务消息的设计,主要是解决Producer端发送消息和本地事务执行结果的原子性问题,因此RocketMQ的设计中Broker和Producer端提供了双向通信的能力,使得Broker天生可以作为一个事务协调者。
而RocketMQ本身提供的存储机制,未事务消息提供了持久化的能力;再加上RocketMQ的高可用机制以及可靠性消息设计能力,为事务消息在系统发生异常时仍然能够保证消息的成功投递。
因此它的实现思想其实就是本地消息表的实现思路,只不过本地消息表移动到了MQ的内部,最终解决Producer端的消息发送和本地事务的原子性问题。
实际上,基于可靠性消息的最终一致性,在实际应用,还有很多地方可以借鉴并使用这些案例,比如支付宝的支付状态同步,或者我们做短信发送时的设计方案,如下图所示:
最后,除了我们常说的 2PC 方案,TCC 也是一种分布式事务的实现方案。
TCC的方案,在电商、金融领域落地也比较多,他是一种两阶段提交的基于应用层的改进方案。TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:“针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)”。它将整个业务逻辑的每个分支分成了Try、Confirm、Cancel三个操作。
Try阶段:主要是对业务系统做检测及资源预留。
Confirm阶段:确认执行业务操作。
Cancel阶段:取消执行业务操作。
TCC事务解决方案本质上是一种补偿的思路,它把事务运行过程分成Try、Confirm/cancel 两个阶段,每个阶段由业务代码控制,这样事务的锁力度可以完全自由控制。
需要注意的是,TCC事务和2pc的思想类似,但并不是2pc的实现,TCC不再是两阶段提交,而只是它对事务的提交/回滚是通过执行一段confirm/cancel业务逻辑来实现,并且也并没有全局事务来把控整个事务逻辑。
假设在一个支付场景中,用户购买商品的业务逻辑如下
支付服务:根据采购需求创建订单,发起支付。
资金服务:从用户资金帐户中扣除余额。
红包服务:从用户红包账户中扣除余额。
在TCC事务中,整体的工作流程如下:
首先在支付服务中创建支付订单,在在TRY阶段,把支付订单状态修改为PAYING。同时远程调用红包服务和资金服务,把付款方的余额进行扣减(预扣款)
如果在TRY阶段,任何一个服务失败,TCC事务管理器自动调用这些服务的CANCEL方法,支付订单状态变为PAY_FAILED,同时远程调用红包服务和资金服务把扣减的余额增加回去。
如果TRY阶段正常完成,TCC事务管理器自动调用CONFIRM方法,在该方法中,把支付订单状态变更为CONFIRMED,同时远程调用红包服务和资金服务对应的CONFIRM方法,增加收款方的余额。
以上便是TCC 的工作流程,但是也有不足之处。
不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。