分布式事物一致性设计思路

 

本地事务ACID大家应该都知道了,统一提交,失败回滚,严格保证了同一事务内数据的一致性!而分布式事务不能实现这种ACID,它只能实现CAP原则里的某两个,CAP也是分布式事务的一个广泛被应用的原型,CAP(Consistency, Availability, Partition Tolerance), 阐述了一个分布式系统的三个主要方面, 只能同时择其二进行实现. 常见的有CP系统, AP系统。

关于分布最终一致性保证始终是分布式框架要考虑的问题。

分布式事物目前解决方案有三种,比较著名的有基于XA协议的方案、TCC方案、消息最终一致性方案。

2.1基于XA协议的方案

该方案最早由oracle提出用于解决跨数据访问的事务问题,是一种强一致性的解决方案,由事务协调器和本地资源管理器共同完成。事务协调器和资源管理器间通过XA协议进行通信。XA协议实现的原理如下图所示,共分为两个阶段,也就是我们常说的两阶段协议。

两阶段方案在解决数据库分布式事务问题方面应用非常广泛,oracle、Mysql等主流关系数据库均支持XA协议,而且ocenbase、DCDB等著名的分布式数据库也都基于两阶段协议。在解决服务事务问题上,其实 XA协议不是只能作用于单个服务内部的多资源场景,跨服务的多资源场景也是可以的,只不过需要额外的事务传递机制。但其都有致命的缺点,性能不理想。由于需要等到各分支事务都就绪后全局事务才开始提交,所以每个事务锁定数据的时间较长,XA方案因此很难满足高并发场景。而且在解决微服务问题时XA方案的性能问题将会被放大。因为应用在访问服务的调用方式、网络环境等要比访问数据库复杂的多。例如,应用和其访问的数据库通常在一个局域网中,而其通过rpc调用的服务则可能属于另一个网络或者在公网上,其时延更长、出故障的概率更高。这将导致数据锁定时间和系统并发度进一步降低。所以XA方案基本不适合解决微服务的事务问题。

2.2TCC方案

TCC方案应用是目前呼声最高,也是落地最多的一个方案。当前也有一些开源的TCC框架实现,如TCC-TransactionByteTCC。TCC方案其实是两阶段方案的一种改进,其将本地资源管理器的功能融入到了业务实现中。其将整个业务逻辑显示的分成了Try、Confirm、Cancel三部分。try部分完成业务的准备工作,confirm部分完成业务的提交,cancel部分完成事务的回滚。基本原理如下图所示。

事务开始时,业务应用会向事务协调器注册启动事务。之后业务应用会调用所有服务的try接口,相当于XA的第一阶段。如果有任何一个服务的try接口调用失败会向事务协调器发送事务回滚请求,否则发送事务提交请求。事务协调器收到事务回滚请求后会依次调用事务的confirm接口,否则调用cancel接口回滚,这相当于XA的第二阶段。如果第二阶段接口调用失败,会进行重试。

TCC方案通过通过三个接口很好的规避了长时间数据加锁的问题,业务表在每个接口调用完毕即可释放,这很大程度上提高了业务的并发度,这也是TCC方案最大的优势。所以在SOA时期,TCC方案被很多金融、电商的业务系统大量使用。
当然TCC方案也有不足之处,集中表现在以下两个方面:

  • 开发工作量大。它将部分资源管理器的功能融入到每个服务的开发中,导致服务的每个接口都需要实现try、confirm、cancle,还需要实现事务协调器,开发量不只翻了一倍。
  • 实现难度大。系统需要记录每个应用的服务调用链路。我前面讲过rpc调用情况比较复杂,由于网络状况、系统故障等调用失败被视为常态,必须按照不同的失败原因实现不同策略的回滚。为了满足一致性的要求,二阶段不管调用confirm还是cancle都必须调用成功,如果一次调用不成功,事务协调器必须尝试重试。这就要求confirm和cancle接口必须实现幂等。

上述原因导致TCC方案大多是被研发实力较强、有迫切需求的大公司所采用。其将分布式事务变成一种所谓的“贵族技术”,中小型企业由于人员有限、技术实力薄弱,很难落地。而且笔者认为微服务倡导的是服务的轻量化、易部署,而TCC方案将很多事务的处理功能融入到业务中,对业务侵入性太高,导致服务逻辑复杂,比较适合比较重的服务。

2.3 消息事务一致性方案

消息一致性方案是通过消息中间件保证上、下游应用数据操作的一致性。基本思路是将本地操作和发送消息放在一个事务中,保证本地操作和消息发送要么两者都成功或者都失败。下游应用向消息系统订阅该消息,收到消息后执行相应操作。
以下单业务为例进行说明,下单基本流程是先存储订单信息,然后扣相应商品的库存,两个操作必须在一个事务中。如下图,业务应用首先调用订单服务,订单存储成功后,订单服务会通过消息处理服务投递订单消息到MQ。库存服务从MQ收到消息后进行扣库存操作,如果执行成功会向消息处理服务发送通知。消息处理服务会实时监测订单消息是否超时,如果超时会重新投递到MQ中,以驱动库存服务进行扣库存操作。如果扣库存操作执行失败后,库存服务后续还会从MQ接收到相同的订单消息,需要多次重复执行,直到成功或者进行人工干预。库存服务需要实现幂等。 

消息方案从本质上讲是将分布式事务转换为两个本地事务,然后依靠下游业务的重试机制达到最终一致性。相对TCC方案来讲,消息方案技术难度相对低,落地较容易,如果对一致性不敏感的应用也是一个不错的选择。美国著名电商e-bay以及国内的蘑菇街都做过尝试。消息一致性方案的不足之处是其对应用侵入性较高,应用需要基于消息接口进行改造,而且需要建设专门的消息系统,成本较高。

 

目前已有基于TCC设计方案可参考:

https://github.com/changmingxie/tcc-transaction

 

下面是转自大鹏设计师基于TCC实现的设计思路,考虑的更加全面:详见:https://github.com/dapeng-soa/dapeng-soa/wiki/TCC-support

1、基本概念

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

2、数据表结构

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。

3、案例描述

这里以订单创建为例。

用户创建订单,同时扣除库存。

其中订单、库存分别为两个不同的服务。同时, TM也是一个单独的服务。

本流程有2个业务服务参与,分别是订单服务的创建订单接口以及库存服务的库存扣减接口。

业务主流程如下:

1、客户端调用orderService.createOrder, 发起订单创建流程
2、orderService调用stockService.decreaseStock, 扣减库存
3、orderService创建订单,并返回客户端。

对应的订单创建序列图如下: 

 

3.1. 客户端发起订单创建的操作

对应时序图的No.1调用

参数

3.2、全局事务的Try阶段

订单服务的全局事务拦截器(TI)收到请求后, 识别到目标方法带有TCC标识,即进入Trying阶段。

3.2.1、订单服务开启全局事务

TI向事务管理服务请求开启全局事务,对应时序图的No.2。 tm.beginGTX(gtxId, params)

txId可用sessionTid(long的形式),params可直接用bytes

3.2.2、事务管理器处理订单服务请求

对应时序图的No.3/4/5

事务管理器根据txId去决定调用方是事务发起者还是事务参与者。 这里,orderService是事务发起方, 那么: 1、TM首先通过createTGX(txId)方法创建一个全局事务(插入一条全局事务记录到t_gtx表中,状态为新建) 2、通过createStep(txId, params)方法创建一个子事务日志(插入一条子事务记录到t_gtx_step表中, 状态为新建)

全局事务开启, 操作成功后返回stepId继续下一步,否则失败后直接返回调用方,由调用方决定是继续还是回滚(在这个案例中, 这里的调用方是client)。

3.2.3、订单服务的TI转发请求到具体的业务服务方法

对应时序图中的No.6/7 全局事务开启成功后, TI转发请求到业务服务。这里为orderService.createOrder

在这个方法中, 首先调用库存服务的扣减库存接口:stockService.decreaseStock

如果全局事务开启失败,那么TI会直接报错返回给调用方(Err-Gtx-001: begin gtx error)

3.2.4、库存服务开启全局事务

对应时序图的No.8

同3.2.1,库存服务的TI收到扣减库存请求后,开启全局事务: `tm.beginGTX'

3.2.5、事务管理器处理库存服务请求

对应时序图的No.9/10

事务管理器通过gtxId发现全局事务已经开启,那么该请求来自事务参与方而不是发起方。 这时候,直接通过createStep插入一条子事务日志到t_gtx_step表中即可,并返回stepId。

3.2.6、库存服务本地逻辑处理

对应时序图的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

3.2.7、库存服务的TI更新全局事务

对应时序图的No.14/15/16

TI根据3.2.6的结果,调用tm.updateGTX更新全局事务。

TM根据gtxId以及stepId判断该请求来自事务参与方,那么仅更新子事务日志表updateStep, 状态为成功/失败。

这一步有可能失败,导致本地子事务提交后,结果没反映到TM的子事务表的状态中。

还有一个可能就是本地子事务成功,TI更新全局事务也成功了, 但是由于网络中断或者其他原因,导致服务调用方(这里是orderService)的对扣减库存调用失败。

不管如何,服务调用方调用失败后,由服务调用方自行决定是继续前行还是回滚全局事务。

3.2.8、订单服务本地业务逻辑处理

对应时序图的No.18/19

订单服务根据库存扣减的结果,决定是继续往前走还是失败回退。

如果继续往前走的话,就完成本地事务后返回结果给订单服务的TI; 如果失败回退的话,就把失败信息返回给订单服务的TI。

3.2.9、订单服务的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阶段(失败)

3.3、confirm阶段

对应序列图的No.24~33 理论上, Trying阶段成功的话,confirm阶段一定能成功(最终一致).

Confirm操作由TI发起,而具体的逻辑由TM控制。

3.3.1 事务管理器的confirm操作

首先事务管理器根据gtxId得到全局事务记录以及子事务记录集合(gtx_steps)。

按照子事务的seq从小到大的顺序,依次调用子事务的confirm方法。(这个过程可以使用异步的方式并发去confirm?)

最后根据结果更新全局事务以及子事务的状态。

只有全部子事务的状态为完成,全局事务状态才能更新为完成。

TI发起confirm操作后,不管本次confirm操作是否成功, 都返回成功给client。

3.4、cancel阶段

对应序列图的No.24~43 本阶段跟confirm阶段逻辑类似,但是子事务的执行顺序相反。

TI发起cancel操作后,不管本次cancel操作是否成功, 都返回失败给client。

3.5、confirm/cancel阶段的异常处理

TM通过定时器,定时扫描全局事务日志表中状态为非完成的记录(1分钟前),再次执行confirm/cancel操作。

4. 业务场景

TCC场景:

4.1. 客户端调用单独的TCC服务

image.png

4.1.1 正常流程

try成功,confirm成功

  1. try阶段: 1.1 t_gtx, t_gtx_step插入事务日志成功, 状态皆为新建 1.2 tccServiceA本地事务成功 1.3 t_gtx, t_gtx_step更新事务日志成功,状态皆为成功
  2. confirm阶段 2.1 TM调用tccServiceA成功,更新t_gtx, t_gtx_step成功,状态为完成。

try失败,cancel成功

  1. try阶段: 1.1 t_gtx, t_gtx_step插入事务日志成功, 状态皆为新建 1.2 tccServiceA本地事务失败 1.3 t_gtx, t_gtx_step更新事务日志成功,状态皆为失败
  2. cancel阶段 2.1 TM调用tccServiceA成功,更新t_gtx, t_gtx_step成功,状态为完成。

4.1.2 异常流程

try成功,confirm阶段或者cancel阶段失败 那么后续由TM定时任务继续重试。

4.1.3 异常流程

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操作。

4.2. 客户端先后调用2个TCC服务

image.png

这时候, 这两次服务调用分别构成一个全局事务, 是两个互不相关的全局事务

4.3. 客户端调用TCC服务a,服务a再调用TCC服务b

image.png

4.4. 客户端调用TCC服务a,服务a再分别调用TCC服务b以及TCC服务c

image.png

4.5. 客户端调用TCC服务a,服务a调用TCC服务b,服务b再调用TCC服务c

image.png

 

posted @ 2019-01-11 20:43  BarryW  阅读(891)  评论(0编辑  收藏  举报