关于分布最终一致性保证始终是分布式框架要考虑的问题。html
分布式事物目前解决方案有三种,比较著名的有基于XA协议的方案、TCC方案、消息最终一致性方案。前端
该方案最先由oracle提出用于解决跨数据访问的事务问题,是一种强一致性的解决方案,由事务协调器和本地资源管理器共同完成。事务协调器和资源管理器间经过XA协议进行通讯。XA协议实现的原理以下图所示,共分为两个阶段,也就是咱们常说的两阶段协议。git
两阶段方案在解决数据库分布式事务问题方面应用很是普遍,oracle、Mysql等主流关系数据库均支持XA协议,并且ocenbase、DCDB等著名的分布式数据库也都基于两阶段协议。在解决服务事务问题上,其实 XA协议不是只能做用于单个服务内部的多资源场景,跨服务的多资源场景也是能够的,只不过须要额外的事务传递机制。但其都有致命的缺点,性能不理想。因为须要等到各分支事务都就绪后全局事务才开始提交,因此每一个事务锁定数据的时间较长,XA方案所以很难知足高并发场景。并且在解决微服务问题时XA方案的性能问题将会被放大。由于应用在访问服务的调用方式、网络环境等要比访问数据库复杂的多。例如,应用和其访问的数据库一般在一个局域网中,而其经过rpc调用的服务则可能属于另外一个网络或者在公网上,其时延更长、出故障的几率更高。这将致使数据锁定时间和系统并发度进一步下降。因此XA方案基本不适合解决微服务的事务问题。github
TCC方案应用是目前呼声最高,也是落地最多的一个方案。当前也有一些开源的TCC框架实现,如TCC-Transaction、ByteTCC。TCC方案实际上是两阶段方案的一种改进,其将本地资源管理器的功能融入到了业务实现中。其将整个业务逻辑显示的分红了Try、Confirm、Cancel三部分。try部分完成业务的准备工做,confirm部分完成业务的提交,cancel部分完成事务的回滚。基本原理以下图所示。sql
事务开始时,业务应用会向事务协调器注册启动事务。以后业务应用会调用全部服务的try接口,至关于XA的第一阶段。若是有任何一个服务的try接口调用失败会向事务协调器发送事务回滚请求,不然发送事务提交请求。事务协调器收到事务回滚请求后会依次调用事务的confirm接口,不然调用cancel接口回滚,这至关于XA的第二阶段。若是第二阶段接口调用失败,会进行重试。数据库
TCC方案经过经过三个接口很好的规避了长时间数据加锁的问题,业务表在每一个接口调用完毕便可释放,这很大程度上提升了业务的并发度,这也是TCC方案最大的优点。因此在SOA时期,TCC方案被不少金融、电商的业务系统大量使用。
固然TCC方案也有不足之处,集中表如今如下两个方面:segmentfault
上述缘由致使TCC方案大可能是被研发实力较强、有迫切需求的大公司所采用。其将分布式事务变成一种所谓的“贵族技术”,中小型企业因为人员有限、技术实力薄弱,很难落地。并且笔者认为微服务倡导的是服务的轻量化、易部署,而TCC方案将不少事务的处理功能融入到业务中,对业务侵入性过高,致使服务逻辑复杂,比较适合比较重的服务。markdown
消息一致性方案是经过消息中间件保证上、下游应用数据操做的一致性。基本思路是将本地操做和发送消息放在一个事务中,保证本地操做和消息发送要么二者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操做。
如下单业务为例进行说明,下单基本流程是先存储订单信息,而后扣相应商品的库存,两个操做必须在一个事务中。以下图,业务应用首先调用订单服务,订单存储成功后,订单服务会经过消息处理服务投递订单消息到MQ。库存服务从MQ收到消息后进行扣库存操做,若是执行成功会向消息处理服务发送通知。消息处理服务会实时监测订单消息是否超时,若是超时会从新投递到MQ中,以驱动库存服务进行扣库存操做。若是扣库存操做执行失败后,库存服务后续还会从MQ接收到相同的订单消息,须要屡次重复执行,直到成功或者进行人工干预。库存服务须要实现幂等。 cookie
消息方案从本质上讲是将分布式事务转换为两个本地事务,而后依靠下游业务的重试机制达到最终一致性。相对TCC方案来说,消息方案技术难度相对低,落地较容易,若是对一致性不敏感的应用也是一个不错的选择。美国著名电商e-bay以及国内的蘑菇街都作过尝试。消息一致性方案的不足之处是其对应用侵入性较高,应用须要基于消息接口进行改造,并且须要建设专门的消息系统,成本较高。网络
目前已有基于TCC设计方案可参考:
https://github.com/changmingxie/tcc-transaction
下面是转自大鹏设计师基于TCC实现的设计思路,考虑的更加全面:详见:https://github.com/dapeng-soa/dapeng-soa/wiki/TCC-support
TI:Transaction Interceptor,事务拦截器,位于dapeng容器的filterChain链中。
因为TI的逻辑会比较复杂, 不太适合在IO线程中操做
TM:Transaction Manager, 事务管理器,做为一个独立的服务存在。
事务发起方: 服务调用链或者说请求会话中第一个加入全局事务的接口方法,称为事务发起方。
事务参与方: 服务调用链或者说请求会话中除事务发起方的其它加入了全局事务的接口方法,称为事务参与方。
例如,对于服务a,b,c, d: client调用a.m1, a.m1调用b.m2以及c.m3, b.m2调用d.m4. 其中,a.m1以及b.m2,d.m4都声明为TCC事务, 那么在此次服务调用中, a.m1为事务发起方,b.m2,d.m4为事务参与方。
由事务参与方发起confirm或者cancel操做。
事务管理器负责confirm或者cancel失败后的重试。
在定义接口的时候, 须要加上如下注解,以代表该接口须要加入全局事务。@TCC(confirm="",cancel="")
该注解有2个可选参数, 其中, confirm表明该接口的confirm方法名字,cancel表明该接口的cancel方法名字。
默认状况下,methodA的confirm方法名为methodA_confirm, cancel方法名为methodA_cancel
t_gtx
CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx` ( `id` INT(11) NOT NULL, `gtx_id` INT(11) NOT NULL COMMENT '全局事务id,通常使用服务的会话id(sesstionTid)', `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '全局事务状态, 1:新建(CREATED);2:成功(SUCCEED);3:失败(FAILED);4:完成(DONE)', `expired_time` DATETIME(0) NOT NULL COMMENT '超时时间。事务管理器的定时任务会根据全局事务表的状态以及超时时间去过滤未完成且超时的事务。默认为事务建立时间后1分钟。', `created_time` DATETIME(0) NOT NULL COMMENT '建立时间', `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', `remark` VARCHAR(255) NULL COMMENT '备注, 每次状态变动都须要追加到remark字段。', PRIMARY KEY (`id`), INDEX `index_gtx_id` (`gtx_id` ASC)) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COMMENT = '全局事务表'
t_gtx_step
CREATE TABLE IF NOT EXISTS `gtx_db`.`t_gtx_step` ( `id` INT NOT NULL, `gtx_id` INT(11) NOT NULL COMMENT '全局事务id,通常使用服务的会话id(sesstionTid)', `step_seq` SMALLINT(2) NOT NULL COMMENT '子事务序号', `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '子事务状态, 1:新建(CREATED);2:成功(SUCCEED);3:失败(FAILED);4:完成(DONE)', `service_name` VARCHAR(128) NOT NULL COMMENT '服务名', `version` VARCHAR(32) NOT NULL DEFAULT '1.0.0' COMMENT '服务版本号', `method_name` VARCHAR(32) NOT NULL, `request` BLOB NULL, `confirm_method_name` VARCHAR(32) NULL, `cancel_method_name` VARCHAR(32) NULL, `redo_times` INT(11) NOT NULL DEFAULT 0, `created_time` DATETIME(0) NOT NULL COMMENT '建立时间', `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', `remark` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '备注, 每次状态变动都须要追加到remark字段。', PRIMARY KEY (`id`)), INDEX `index_gtx_id` (`gtx_id` ASC)) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COMMENT = '全局事务流程表'
t_gtx_journal 对于参与分布式事务的服务接口,须要在本地有个事务流水表(例如orderDb):
CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx_journal` ( `id` INT(11) NOT NULL, `gtx_id` INT(11) NOT NULL COMMENT '全局事务id', `step_id` INT(11) NOT NULL COMMENT '子事务id', `biz_tag` VARCHAR(45) NOT NULL COMMENT '本次全局事务操做的本地业务表名字', `biz_id` INT(11) NOT NULL COMMENT '本次全局事务操做的本地业务记录id', `status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '本地子事务状态, 可在confirm/cancel阶段用于判断try阶段是否成功 1:新建(CREATED);4:完成(DONE)', `old_values` VARCHAR(255) NULL COMMENT '修改前的值。可选,用于在cancel阶段恢复原始值。例如修改字符串的操做。格式为:fieldName:fieldValue fieldName:fieldValue', `created_time` DATETIME(0) NOT NULL COMMENT '建立时间', `updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间', `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注, 每次状态变动都须要追加到remark字段。', PRIMARY KEY (`id`)) ENGINE = InnoDB DEFAULT CHARACTER SET = utf8mb4 COMMENT = '子事务的本地流' /* comment truncated */ /*水表。 当本地事务成功时, 由本地业务*/
本流水表可用于幂等(例如confirm或者cancel的重试,若是状态是完成,那么就不须要执行confirm/cancel逻辑, 或者可用于判断try阶段是否成功。
本地事务流水是否须要建立,须要建立多少,是否记录oldValues,根据业务性质去定。 例如, 建立订单的时候,会建立一个主单若干个子单。 这时候, 只须要插入一条本地事务流水(跟主单挂钩)便可。 由于在confirm或者cancel中, 根据主单id能够招到全部的子单id。
这里以订单建立为例。
用户建立订单,同时扣除库存。
其中订单、库存分别为两个不一样的服务。同时, TM也是一个单独的服务。
本流程有2个业务服务参与,分别是订单服务的建立订单接口以及库存服务的库存扣减接口。
业务主流程以下:
一、客户端调用orderService.createOrder, 发起订单建立流程 二、orderService调用stockService.decreaseStock, 扣减库存 三、orderService建立订单,并返回客户端。
对应的订单建立序列图以下:
对应时序图的No.1调用
参数
订单服务的全局事务拦截器(TI)收到请求后, 识别到目标方法带有TCC标识,即进入Trying
阶段。
TI向事务管理服务请求开启全局事务,对应时序图的No.2。 tm.beginGTX(gtxId, params)
txId可用sessionTid(long的形式),params可直接用bytes
对应时序图的No.3/4/5
事务管理器根据txId去决定调用方是事务发起者仍是事务参与者。 这里,orderService是事务发起方, 那么: 一、TM首先经过createTGX(txId)方法建立一个全局事务(插入一条全局事务记录到t_gtx表中,状态为新建) 二、经过createStep(txId, params)方法建立一个子事务日志(插入一条子事务记录到t_gtx_step表中, 状态为新建)
全局事务开启, 操做成功后返回stepId继续下一步,不然失败后直接返回调用方,由调用方决定是继续仍是回滚(在这个案例中, 这里的调用方是client)。
对应时序图中的No.6/7 全局事务开启成功后, TI转发请求到业务服务。这里为orderService.createOrder
。
在这个方法中, 首先调用库存服务的扣减库存接口:stockService.decreaseStock
若是全局事务开启失败,那么TI会直接报错返回给调用方(Err-Gtx-001: begin gtx error)
对应时序图的No.8
同3.2.1,库存服务的TI收到扣减库存请求后,开启全局事务: `tm.beginGTX'
对应时序图的No.9/10
事务管理器经过gtxId发现全局事务已经开启,那么该请求来自事务参与方而不是发起方。 这时候,直接经过createStep
插入一条子事务日志到t_gtx_step表中便可,并返回stepId。
对应时序图的No.11/12/13
TI开始全局事务成功后, 转发扣减库存请求给具体的业务方法。 库存服务执行本地事务(库存余额扣减,冻结库存增长)后返回到TI
同时,须要插入一条本地事务流水表到t_gtx_journal中,
INSERT INTO `t_gtx_journal` (`id`, `gtx_id`, `step_id`, `biz_tag`, `biz_id`, `status`, `old_values`) VALUES (id, gtxId, stepId, 't_stock', stockId, 1, NULL);
本案例不须要记录oldValues, 由于根据接口的入参能够推算出oldValues
对应时序图的No.14/15/16
TI根据3.2.6的结果,调用tm.updateGTX
更新全局事务。
TM根据gtxId以及stepId判断该请求来自事务参与方,那么仅更新子事务日志表updateStep
, 状态为成功/失败。
这一步有可能失败,致使本地子事务提交后,结果没反映到TM的子事务表的状态中。
还有一个可能就是本地子事务成功,TI更新全局事务也成功了, 可是因为网络中断或者其余缘由,致使服务调用方(这里是orderService)的对扣减库存调用失败。
无论如何,服务调用方调用失败后,由服务调用方自行决定是继续前行仍是回滚全局事务。
对应时序图的No.18/19
订单服务根据库存扣减的结果,决定是继续往前走仍是失败回退。
若是继续往前走的话,就完成本地事务后返回结果给订单服务的TI; 若是失败回退的话,就把失败信息返回给订单服务的TI。
对应序列图的No.20/21/22/23
若是订单服务本地事务成功,那么TI经过tm.updateGTX
把结果反馈给TM。
TM根据gtxId
判断该请求来自事务发起方,那么根据status把全局事务状态更新为成功/失败; 同时, 更新子事务状态为成功/失败
全局事务的最终状态跟事务发起方对应的子事务的最终状态一致。
No.20中若是事务发起方更新全局事务状态失败, 那么应经过实时告警的方式提醒人工介入,同时放弃confirm或者cancel操做, 直接返回前端(根据 根据事务发起方的本地事务流水状态,更新全局事务状态为成功/失败(也须要更新事务发起方的子事务状态)。 后续,TM定时器会处理后续的confirm或者cancel操做。
至此,Trying阶段完成。
根据本阶段的结果, TI将会进入TCC的confirm
(成功)或者cancel
阶段(失败)
对应序列图的No.24~33 理论上, Trying阶段成功的话,confirm阶段必定能成功(最终一致).
Confirm操做由TI发起,而具体的逻辑由TM控制。
首先事务管理器根据gtxId
获得全局事务记录以及子事务记录集合(gtx_steps
)。
按照子事务的seq从小到大的顺序,依次调用子事务的confirm方法。(这个过程可使用异步的方式并发去confirm?)
最后根据结果更新全局事务以及子事务的状态。
只有所有子事务的状态为完成,全局事务状态才能更新为完成。
TI发起confirm操做后,无论本次confirm操做是否成功, 都返回成功给client。
对应序列图的No.24~43 本阶段跟confirm阶段逻辑相似,可是子事务的执行顺序相反。
TI发起cancel操做后,无论本次cancel操做是否成功, 都返回失败给client。
TM经过定时器,定时扫描全局事务日志表中状态为非完成的记录(1分钟前),再次执行confirm/cancel操做。
TCC场景:
try成功,confirm成功
try失败,cancel成功
try成功,confirm阶段或者cancel阶段失败 那么后续由TM定时任务继续重试。
try阶段TI插入事务日志失败(Err-Gtx-001: begin gtx error) 若是是事务发起方(本案例), 那么TI直接返回Err-Gtx-001,本次服务调用失败。 若是是事务参与方, 那么TI直接返回Err-Gtx-001,并最终回到事务发起方,本次全局事务失败,并对已经有记录的子事务作cancel操做。
由于这里缺失了分布式事务的某个子事务日志记录,TM没法进行confirm或者cancel操做。
try阶段本地事务成功,可是TI更新事务日志失败(Err-Gtx-002: update gtx error),子事务的状态停留在新建的状态 这时候若是是事务发起方(本案例),那么TI会继续走confirm或者cancel的流程。 若是是事务参与方,把Err-Gtx-002返回, 事务发起方会忽略该错误,其对应的TI会继续走confirm或者cancel的流程。
在confirm或者cancel的逻辑里,TM会把gtxId以及该子事务id、状态经过cookie传过来。 若是子事务状态为成功或者失败,那么直接执行confirm或者cancel逻辑;
若是子事务状态为新建,那么目前尚不清楚到底try阶段的本地事务执行了没。
若是执行了, 那么必然能够经过gtxId,stepId找到在try阶段的本地事务操做过的本地事务流水记录,从而确认try阶段的本地事务提交状况,再进而决定本次confirm或者cancel该作的操做。
举个例子, 库存服务的扣减库存接口。 在try阶段,本地事务成功,而后TI在更新子事务状态的时候失败了,那么该子事务状态为新建。 而后事务发起方依然决定作confirm操做,同时库存服务扣减库存接口的confirm方法,经过gtxId以及stepId,找到了本地事务流水记录,从而能够执行confirm操做。
若是在try阶段,本地事务失败,而后TI在更新子事务状态的时候也失败了,那么该子事务状态为新建。 而后事务发起方依然决定作confirm操做,同时库存服务扣减库存接口的confirm方法,经过gtxId以及stepId,这时候是找不到本地事务流水记录的,说明try阶段本地事务失败。 那么业务能够调用一下把try以及confirm的逻辑合并起来,完成本次confirm操做。
这时候, 这两次服务调用分别构成一个全局事务, 是两个互不相关的全局事务