微服务分布式事务

402 阅读26分钟

一. 数据库事务

事务是逻辑上的一组数据库操作,要么都执行,要么都不执行。

事务的特性:原子性,一致性,隔离性,持久性

① 原子性:事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;例如转账的这两个关键操作(将张三的余额减少200元,将李四的余额增加200元)要么全部完成,要么全部失败。

② 一致性: 确保从一个正确的状态转换到另外一个正确的状态,这就是一致性。例如转账业务中,将张三的余额减少200元,中间发生断电情况,李四的余额没有增加200元,这个就是不正确的状态,违反一致性。又比如表更新事务,一部分数据更新了,但一部分数据没有更新,这也是违反一致性的;

③ 隔离性:并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;

④ 持久性:一个事务被提交之后,对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

二. 分布式事务解决方案

CPA定理 :分布式系统三个指标

(1)Consistency(一致性)

(2)Availability(可用性)

(3)Partition tolerance(分区容错性)

BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

(1)Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。

(2)Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。

(3)Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

而分布式事务最大的问题就是各个子事务的一致性问题,因此可用借鉴CAP定理和BASE理论

AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。

CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致,但在事务等待过程中,处于弱可用状态。

在分布式系统中,要实现分布式事务,有以下几种解决方案

2.1. 两阶段提交(2PC)

数据库支持的2PC,又叫XA Transactions,分为以下两个阶段

第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交.

第二阶段:事务协调器要求每个数据库提交数据。、

TransactionScop 默认不能用于异步方法之间事务一致,因为事务上下文是存储于当前线程中的,所以如果是在异步方法,需要显式的传递事务上下文。

优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)

缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景,如果分布式系统跨接口调用,目前 .NET 界还没有实现方案。

2.2 补偿事务(TCC)

TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:

(1)Try 阶段主要是对业务系统做检测及资源预留

(2)Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。

(3)Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

我们有一个本地方法,里面依次调用

1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。

2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。

3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。

2.3本地消息表(异步确保)

本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。

image.png

基本思路就是:

(1)消息生产方:需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

(2)消息消费方:需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

(3)生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。

这种方案遵循BASE理论,采用的是最终一致性,可以说是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。

优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。在 .NET中 有现成的解决方案。

缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。

2.4 MQ事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

以阿里的 RocketMQ 中间件为例,其思路大致为:

第一阶段Prepared消息,会拿到消息的地址。

第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。

优点: 实现了最终一致性,不需要依赖本地数据库事务。

缺点: 实现难度大,主流MQ不支持,没有.NET客户端,RocketMQ事务消息部分代码也未开源。

2.5 Sagas事务模型

Saga事务模型又叫做长时间运行的事务(Long-running-transaction)。

该模型其核心思想就是拆分分布式系统中的长事务为多个短事务,或者叫多个本地事务,然后由 Sagas 工作流引擎负责协调,如果整个流程正常结束,那么就算是业务成功完成,如果在这过程中实现失败,那么Sagas工作流引擎就会以相反的顺序调用补偿操作,重新进行业务回滚。

比如我们一次关于购买旅游套餐业务操作涉及到三个操作,他们分别是预定车辆,预定宾馆,预定机票,他们分别属于三个不同的远程接口。可能从我们程序的角度来说他们不属于一个事务,但是从业务角度来说是属于同一个事务的。

image.png

三. LCN分布式事务解决方案(基于2PC)目前已被淘汰,公司不在,官网已关闭

3.1 实现原理

LCN事务控制原理是由事务模块TxClient下的代理连接池与TxManager的协调配合完成的事务协调控制。

5.0以后由于框架兼容了LCN、TCC、TXC三种事务模式,为了避免区分LCN模式,特此将LCN分布式事务改名为TX-LCN分布式事务框架。

TxClient的代理连接池实现了javax.sql.DataSource接口,并重写了close方法,事务模块在提交关闭以后TxClient连接池将执行"假关闭"操作,等待TxManager协调完成事务以后在关闭连接。

定位:TX-LCN定位于一款事务协调性框架,框架其本身并不操作事务,而是基于对事务的协调从而达到事务一致性的效果。

实现原理:

(1)协调者(TM)和客户端(TC)通过netty保持长链接状态;

(2)调用方进入接口业务之前,会通过AOP技术进到@LcnTransaction注解中去,此时LCN协调者那边生成注册一个全局的事务组Id(groupId);

(3)当调用方通过rpc调用服务提供方的时候,lcn重写了Feign客户端,会从ThreadLocal中拿到该事务组Id(groupId),并将该事务组Id设置到请求头中;

(4)服务提供方在请求头中获取到了这个groupId的时候,lcn会标识该服务为参与方并加入到该事务组,并会被lcn代理数据源,当该服务业务逻辑执行完成后,进行数据源的假关闭,并不会真正的提交或回滚当前服务的事务;

(5)当调用者执行完全部业务逻辑的时候,如果无异常会告知lcn协调者,lcn协调者再分别告诉该请求链上的所有参与方可以提交了,再进行真正的提交。若发起方调用完参与方后报错了,也会告知lcn协调者,lcn协调者再告知所有的参与方进行真正的回滚操作,这样就解决了分布式事务的问题。

3.2 代码实现

3.2.1 部署全局协调平台

(1)注册中心:选nacos

(2)部署 github下周源码地址 github.com/codingapi/t…

(3)创建数据库表:先创建MySQL数据库, 名称为: tx-manager,再创建数据库表t_tx_exception

(4)修改配置文件:打开源码,找到txlcn-tm模块,修改其配置文件application.properties,具体修改的内容根据自己业务而定,官方也有相应配置内容,但需要注意首次运行要将配置文件的这段设置为create:spring.jpa.hibernate.ddl-auto=create。最后打包并运行,访问控制台的端口为7919,默认密码codingapi

3.2.2 配置客户端TC环境,也就是在微服务端配置

(1)引入相关依赖:其中一个是lcn核心包,一个是建立长连接的netty包;

    <dependency> 
        <groupId>com.codingapi.txlcn</groupId> 
        <artifactId>txlcn-tc</artifactId> 
        <version>5.0.2.RELEASE</version> </dependency> 
    <dependency> 
        <groupId>com.codingapi.txlcn</groupId> 
        <artifactId>txlcn-txmsg-netty</artifactId> 
        <version>5.0.2.RELEASE</version> 
    </dependency>

(2)配置yml文件;设置服务连接到lcn服务。(注意下lcn控制台是7970,这个8070是lcn通讯端口号)

tx-lcn: 
    client: 
        manager-address: 127.0.0.1:8070 
    logger: enabled: true

(3)微服务启动类添加@EnableDistributedTransaction注解,开启分布式事务

参与方与发起方都要加上该注解

@LcnTransaction //分布式事务注解

@Transactional //本地事务注解

(4)做好这些配置后,开始启动项目,启动成功项目后可以在lcn控制台看到相应的服务列表,能够看到则说明服务已经实现了对lcn事务协调者管理器的注册。

3.3 源码分析

一个请求一个线程

代码执行逻辑:

1、判断方法是否有加上@LcnTransaction, 如果有加上该注解则直接会走 切面类 TransactionAspect

2、判断当前线程缓存中是否有事务分组id,如果没有缓存则是为发起方,如果有缓存则是为参与方

3、随机的创建分组的id,将该分组id注册到协调者中。

4、本地 threadLock 缓存该事务分组id

5、A服务(发起方)调用B(参与方)服务的接口,重写了 RequestInterceptor(该接口是feign框架提供的拦截器,基本上每个rpc框架都会提供类似的拦截器) feign 客户端,将该事务分组id设置到请求中

6、执行到B服务接口,Spring TracingApplier实现,在请求之前拦截,从请求头中获取事务分组id,放入到当前线程缓存中

7、B服务接口走到aop里面代码时,会先判断是发起方还是参与方。

8、从缓存中获取该事务分组id,当前派单服务则是为参与方,在告诉给协调者加入该事务分组。.

问题描述 (1)Lcn 如何判断自己是发起方还是参与方?

根据当前的线程threadlocal 中获取事务分组id, 如果能够成功获取到则是为参与方,没有能够获取到就是为发起方。

(2)参与方如何加入LCN全局协调者?

发起方会把事务id注册到协调者里面去,参与方根据请求头里面的事务分组id加入该事务。

(3)发起方如何通知全局回滚还是提交?

发起方的方法执行完成之后,会修改事务状态,再根据全局协调者通知其他参与者事务执行完成。反之,如果发起方的方法执行方法异常,事务状态改为错误状态,再通过全局协调者发送给其他参与者,参与者再回滚事务即可。

(4)A调用B,B调用C 到底会生产几次事务id?

每次原远程调用接口都会生成一个事务id,但是一条调用链上只有一个事务分组id(全局id)。只有A是发起方,B和C都是参与方。可以从请求头中获取到事务分组id就是参与方,表示加入到这个分组里面去的。

(5)入口:@LcnTransaction,TransactionAspect 切面类。

image.png

(6)feign 重写的拦截器,给请求头添加信息,事务分组id

image.png

四. Seata分布式事务解决方案

4.1 Seata架构

Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式

Seata事务管理中有三个重要的角色:

TC(Transaction Coordinator)-事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。

TM(Transaction Manager)-事务管理器:定义全局事务的范围,开始开局事务、提交或回滚全局事务。

RM(Resource Manager)-资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

4.2 部署TC服务

(1)下周seata-server包,地址:seata.io/zh-cn/blog/…

(2)解压到非中文目录,修改conf下的registry.conf文件

registry { 
    # tc服务的注册中心类,这里选择nacos,也可以是eureka、zookeeper等 
    type = "nacos" 
    nacos { 
        # seata tc 服务注册到 nacos的服务名称,可以自定义 
        application = "seata-tc-server" 
        serverAddr = "127.0.0.1:8848" 
        group = "DEFAULT_GROUP" 
        namespace = "" 
        cluster = "SH" 
        username = "nacos" 
        password = "nacos" 
        } 
 } 
 
 config { 
     # 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置 
     type = "nacos" 
     # 配置nacos地址等信息 
     nacos { 
         serverAddr = "127.0.0.1:8848" 
         namespace = "" 
         group = "SEATA_GROUP" 
         username = "nacos" 
         password = "nacos" 
         dataId = "seataServer.properties" 
     } 
 }

(3)在nacos中添加配置

让tc服务的集群可以共享配置,选择了nacos作为统一配置中心。因此服务端配置文件seataServer.properties文件需要在nacos中配好。

image.png

# 数据存储方式,db代表数据库 
store.mode=db store.db.datasource=druid 
store.db.dbType=mysql 
store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true&serverTimezone=Asia/Shanghai 
store.db.user=root 
store.db.password=123456 
store.db.minConn=5 
store.db.maxConn=30 
store.db.globalTable=global_table 
store.db.branchTable=branch_table 
store.db.queryLimit=100 
store.db.lockTable=lock_table 
store.db.maxWait=5000 
# 事务、日志等配置 
server.recovery.committingRetryPeriod=1000 
server.recovery.asynCommittingRetryPeriod=1000 
server.recovery.rollbackingRetryPeriod=1000 
server.recovery.timeoutRetryPeriod=1000 
server.maxCommitRetryTimeout=-1 
server.maxRollbackRetryTimeout=-1 
server.rollbackRetryTimeoutUnlockEnable=false 
server.undo.logSaveDays=7 
server.undo.logDeletePeriod=86400000 
# 客户端与服务端传输方式 
transport.serialization=seata 
transport.compressor=none 
# 关闭metrics功能,提高性能 
metrics.enabled=false 
metrics.registryType=compact 
metrics.exporterList=prometheus 
metrics.exporterPrometheusPort=9898

(4)创建数据库表:创建数据库seata,配置如下:

SET NAMES utf8mb4; 
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------- -- 分支事务表 -- ---------------------------- 
DROP TABLE IF EXISTS `branch_table`; 
CREATE TABLE `branch_table` (
    `branch_id` bigint(20) NOT NULL, 
    `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
    `transaction_id` bigint(20) NULL DEFAULT NULL, 
    `resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `status` tinyint(4) NULL DEFAULT NULL, 
    `client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `gmt_create` datetime(6) NULL DEFAULT NULL, `gmt_modified` datetime(6) NULL DEFAULT NULL, 
    PRIMARY KEY (`branch_id`) USING BTREE,
    INDEX `idx_xid`(`xid`) USING BTREE 
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact; 

-- ---------------------------- -- 全局事务表 -- ---------------------------- 
DROP TABLE IF EXISTS `global_table`; 
CREATE TABLE `global_table` (
    `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
    `transaction_id` bigint(20) NULL DEFAULT NULL, `status` tinyint(4) NOT NULL, 
    `application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `timeout` int(11) NULL DEFAULT NULL, `begin_time` bigint(20) NULL DEFAULT NULL, 
    `application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, 
    `gmt_create` datetime NULL DEFAULT NULL, 
    `gmt_modified` datetime NULL DEFAULT NULL, PRIMARY KEY (`xid`) USING BTREE,
    INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE, 
    INDEX `idx_transaction_id`(`transaction_id`) USING BTREE 
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact; 
SET FOREIGN_KEY_CHECKS = 1;

(5)启动TC服务 进入到seata文件夹下的bin目录,运行seata-server.bat即可。

4.3 微服务集成Seata

(1)引入Seata相关依赖

<dependency> 
    <groupId>com.alibaba.cloud</groupId> 
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId> 
        <exclusions> 
            <!--版本较低,1.3.0,因此排除--> 
            <exclusion> 
                <artifactId>seata-spring-boot-starter</artifactId>
                <groupId>io.seata</groupId>
            </exclusion> 
       </exclusions> 
</dependency> 
<!--seata starter 采用1.4.2版本--> 
<dependency> 
    <groupId>io.seata</groupId> 
    <artifactId>seata-spring-boot-starter</artifactId> 
    <version>${seata.version}</version> 
</dependency>

(2)配置application.yml,让微服务通过注册中心找到seata-tc-server。配置完毕

seata: 
    registry: 
    # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址 
    # 参考tc服务自己的registry.conf中的配置 
    type: nacos 
    nacos: # tc 
        server-addr: 127.0.0.1:8848 
        namespace: "" 
        group: DEFAULT_GROUP 
        application: seata-tc-server # tc服务在nacos中的服务名称 
        username: nacos 
        password: nacos 
    tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称 
    service: 
        vgroup-mapping: # 事务组与TC服务cluster的映射关系 
            seata-demo: LZ

五. Seata四种模式

image.png

1. Seata实践XA模式

实现原理

XA规范描述了全局的TM(事务管理器)与局部的RM(资源管理器)之间的接口,几乎所有主流的数据库都对XA规范提供了支持。

RM一阶段工作

  1. 注册分支事务到TC;
  2. 执行分支业务sql但不提交
  3. 报告执行状态到TC;

TC二阶段的工作:TC检测各分支事务执行状态(如果都成功,通知所有RM提交事务;如果都失败,则通知回滚)

RM二阶段的工作:接收TC指令,提交活回滚事务

image.png

优点:

  1. 事务的强一致性,满足ACID原则。
  2. 常用数据库都支持,实现简单,并且没有代码侵入。

缺点:

  1. 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差。
  2. 依赖关系型数据库实现事务。

具体实现

(1)修改application.yml(每个参与事务的微服务),开启XA模式

seata: data-source-proxy-mode: XA

(2)给发起全局事务的入口方法添加@GlobalTransaction注解,

2. Seata实践AT模式(采用全局锁的方式)

实现原理

AT模式同样是分阶段提交的事务模型,不过弥补了XA模型中资源锁定周期过长的缺陷。

阶段一RM工作:

  1. 注册分支事务;
  2. 记录业务sql并提交
  3. 报告事务状态

阶段二RM工作:删除undo-log

阶段二回滚RM的工作:根据undo-log恢复数据到更新前

例如,一个分支业务的SQL是update tb_account set money = money - 10 where id = 1

image.png

AT模式的脏写问题

(1)当事务1获取DB锁后,执行完业务sql并提交后,释放了DB锁。(此时money为90)

(2)此时事务2获取DB锁,执行完业务sql并提交后,释放了DB锁。(此时money为80)

(3)此时事务1获取了DB锁,在阶段2时根据快照恢复数据,将money恢复至100,数据就出现了问题。

针对这个问题,AT模式采用了全局锁的方式来解决。

AT模式的写隔离

全局锁由TC记录当前正在操作的某行数据的事务,此时只有该事务持有全局锁,其他事务不能对这行数据进行写操作。

(1)当事务1获取DB锁,保存快照,执行业务sql后尝试获取全局锁,此时TC记录了这条数据由事务1具备执行权。紧接着提交事务,释放DB锁。(money值为90)

(2)当事务2获取DB锁,保存快照,执行业务sql后尝试获取全局锁,由于TC已记录了事务1具备该数据的执行权,因此拒绝给事务2全局锁。为了事务2一直获取全局锁而导致死锁,加入了重试机制,默认30次,间隔10ms。当超时后回滚数据并释放DB锁。

(3)紧接着事务1获取DB锁,根据快照恢复数据后释放全局锁。数据也正确的进行了回滚。

在这种情况下,保存两个快照,一个是修改前的快照,一个是修改后的快照。

当要进行回滚时会对当前数据的状态和修改后的快照的数据状态进行比对,若一致则回滚到修改前的快照;若不一致则记录异常,发送警告进行人工介入。

优点:

  • 一阶段完成直接提交事务,释放数据库资源,性能比较好。
  • 利用全局锁实现读写隔离。
  • 没有代码侵入,框架自动完成回滚和提交。

缺点:

  • 两阶段之间属于软状态,属于最终一致。
  • 框架的快照功能会影响性能,但比XA模式要好很多。

具体实现

(1)创建两张表,lock_table导入到TC服务关联的数据库,undo_log表导入到微服务关联的数据库。

(2)修改yml文件,将事务模式修改为AT即可

seata: 
    data-source-proxy-mode: AT

3. Seata实践TCC模式

实现原理

TCC模式与AT模式非常相似,每个阶段都是独立的事务,不同的是TCC通过人工编码来实现数据恢复,需要实现三个方法:

  • Try:资源的检测和预留
  • Confirm:完成资源操作业务,要求Try成功Confirm一定要能成功。
  • Cancel:预留资源释放,可理解为try的反向操作。

优点:

(1)一阶段完成直接提交事务,释放数据库资源,性能好。

(2)相比AT模型,无需生成快照,无需使用全局锁,性能最强。

(3)不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库。

缺点:

(1)有代码侵入,需要人为编写try、Confirm和Cancel接口,过于麻烦。

(2)软状态,事务最终一致。

(3)需要考虑Confirm和Cancel的失败情况,需要做好幂等处理。

TCC的空回滚和业务悬挂

(1)当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作。此时cancel不能做回滚,这就是空回滚。

(2)对于已经空回滚的业务,如果以后继续执行try,就永远不可能confirm或cancel,这就是业务悬挂。应当阻止执行空回滚后的try操作,避免悬挂。

具体实现:声明TCC接口

TCC的Try、Confirm、Cancel方法都需要在接口中基于注解声明,语法如下:

@LocalTCC public interface AccountTCCService { 
    /** 
    * Try逻辑,@TwoPhaseBusinessAction中的name属性名要与当前方法名一致,用于指定try逻辑对应的方法 
    */ 
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel") 
    void deduct(@BusinessActionContextParameter(paramName = "userId") String userId, @BusinessActionContextParameter(paramName = "money") int money); 
    
    /** 
    * 二阶段confirm确认方法,可以另命名,但要保证与commitMethod一致 
    * @param ctx 上下文,可以传递try方法的参数 
    * @return 执行是否成功 
    */ 
    boolean confirm(BusinessActionContext ctx); 
    
    /** 
    * 二阶段回滚方法,要保证与rollbackMethod一致 
    * @param ctx 
    * @return 
    */ 
    boolean cancel(BusinessActionContext ctx); 
 }

4. Seata实践SAGA模式

SAGA模式是Seata提供的长事务解决方案,分为两个阶段:

一阶段:直接提交本地事务

二阶段:成功则什么都不做,失败则通过编写补偿业务来回滚。

优点:

事务参与者可以基于事件驱动实现异步调用,吞吐高。

一阶段直接提交事务,无锁,性能好。

不用编写TCC中的三个阶段,实现简单。

缺点:

软状态持续时间不确定,时效性差

没有锁,没有事务隔离,会导致脏写

比较适用于跨银行转账的长事务等。

六JTA分布式事务解决方案(JTA+Atomic+多数据源)

JTA,即Java Transaction API,JTA允许应用程序执行分布式事务处理——在两个或多个网络计算机资源上访问并且更新数据。JDBC驱动程序的JTA支持极大地增强了数据访问能力。

JTA是基于XA标准制定的,采用两阶段提交的方式来管理分布式事务。即是一个事务管理器和多个资源管理器协作完成,第一阶段各个资源管理器提交,第二个阶段事务管理器需要查看资源管理器是否全部提交成功再提交。

1.项目依赖

<dependency> 
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-jta-atomikos</artifactId> 
</dependency>
  1. application.yml配置
spring: 
    datasource: 
        type: com.alibaba.druid.pool.xa.DruidXADataSource 
        druid: 
            systemDB: 
                name: systemDB 
                url: jdbc:mysql://localhost:3306/springboot-mybatis?useUnicode=true&characterEncoding=utf-8 
                username: root 
                password: root 
            businessDB: 
                name: businessDB 
                url: jdbc:mysql://localhost:3306/springboot-mybatis2?useUnicode=true&characterEncoding=utf-8 
                username: root 
                password: root
                
#jta相关参数配置 
jta: 
    log-dir: classpath:tx-logs 
    transaction-manager-id: txManager
  1. 在DruidConfig.java中实现多个数据源的注册;分布式事务管理器的注册;druid的注册;
package com.zjt.config; 

import com.alibaba.druid.filter.stat.StatFilter;
import com.alibaba.druid.support.http.StatViewServlet; 
import com.alibaba.druid.support.http.WebStatFilter; 
import com.alibaba.druid.wall.WallConfig; 
import com.alibaba.druid.wall.WallFilter; 
import com.atomikos.icatch.jta.UserTransactionImp; 
import com.atomikos.icatch.jta.UserTransactionManager; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jta.atomikos.AtomikosDataSourceBean; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; import org.springframework.transaction.jta.JtaTransactionManager; 
import javax.sql.DataSource; 
import javax.transaction.UserTransaction; 
import java.util.Properties; 
/** * Druid配置 * * */ 
@Configuration public class DruidConfig { 
    @Bean(name = "systemDataSource") 
    @Primary 
    @Autowired 
    public DataSource systemDataSource(Environment env) { 
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean(); 
        Properties prop = build(env, "spring.datasource.druid.systemDB."); 
        ds.setXaDataSourceClassName("com.alibaba.druid.pool.xa.DruidXADataSource"); 
        ds.setUniqueResourceName("systemDB"); 
        ds.setPoolSize(5); 
        ds.setXaProperties(prop);
        return ds; 
    } 
    
    @Autowired 
    @Bean(name = "businessDataSource") 
    public AtomikosDataSourceBean businessDataSource(Environment env) { 
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean(); 
        Properties prop = build(env, "spring.datasource.druid.businessDB."); 
        ds.setXaDataSourceClassName("com.alibaba.druid.pool.xa.DruidXADataSource"); 
        ds.setUniqueResourceName("businessDB"); 
        ds.setPoolSize(5);
        ds.setXaProperties(prop); 
        return ds; 
    } 
    /** 
    * 注入事物管理器 
    * @return 
    */ 
    @Bean(name = "xatx") 
    public JtaTransactionManager regTransactionManager () { 
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        UserTransaction userTransaction = new UserTransactionImp(); 
        return new JtaTransactionManager(userTransaction, userTransactionManager); 
    } 
    
    private Properties build(Environment env, String prefix) {
        Properties prop = new Properties(); 
        prop.put("url", env.getProperty(prefix + "url"));
        prop.put("username", env.getProperty(prefix + "username"));
        prop.put("password", env.getProperty(prefix + "password")); 
        prop.put("driverClassName", env.getProperty(prefix + "driverClassName", "")); 
        prop.put("initialSize", env.getProperty(prefix + "initialSize", Integer.class)); 
        prop.put("maxActive", env.getProperty(prefix + "maxActive", Integer.class)); 
        prop.put("minIdle", env.getProperty(prefix + "minIdle", Integer.class)); 
        prop.put("maxWait", env.getProperty(prefix + "maxWait", Integer.class)); 
        prop.put("poolPreparedStatements", env.getProperty(prefix + "poolPreparedStatements", Boolean.class)); 
        prop.put("maxPoolPreparedStatementPerConnectionSize", env.getProperty(prefix + "maxPoolPreparedStatementPerConnectionSize", Integer.class));
        prop.put("maxPoolPreparedStatementPerConnectionSize", env.getProperty(prefix + "maxPoolPreparedStatementPerConnectionSize", Integer.class)); 
        prop.put("validationQuery", env.getProperty(prefix + "validationQuery")); 
        prop.put("validationQueryTimeout", env.getProperty(prefix + "validationQueryTimeout", Integer.class)); 
        prop.put("testOnBorrow", env.getProperty(prefix + "testOnBorrow", Boolean.class)); 
        prop.put("testOnReturn", env.getProperty(prefix + "testOnReturn", Boolean.class)); prop.put("testWhileIdle", env.getProperty(prefix + "testWhileIdle", Boolean.class)); 
        prop.put("timeBetweenEvictionRunsMillis", env.getProperty(prefix + "timeBetweenEvictionRunsMillis", Integer.class)); 
        prop.put("minEvictableIdleTimeMillis", env.getProperty(prefix + "minEvictableIdleTimeMillis", Integer.class)); 
        prop.put("filters", env.getProperty(prefix + "filters"));
        return prop; 
    } 
    
    @Bean 
    public ServletRegistrationBean druidServlet() {
        ServletRegistrationBean servletRegistrationBean = new ServletRegistrationBean(new StatViewServlet(), "/druid/*"); /
        /控制台管理用户,加入下面2行 进入druid后台就需要登录 
        //servletRegistrationBean.addInitParameter("loginUsername", "admin"); 
        //servletRegistrationBean.addInitParameter("loginPassword", "admin"); 
        return servletRegistrationBean; 
    }
    @Bean 
    public FilterRegistrationBean filterRegistrationBean() { 
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
        filterRegistrationBean.setFilter(new WebStatFilter()); 
        filterRegistrationBean.addUrlPatterns("/*"); 
        filterRegistrationBean.addInitParameter("exclusions", "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"); 
        filterRegistrationBean.addInitParameter("profileEnable", "true"); 
        return filterRegistrationBean; 
    } 
    
    @Bean 
    public StatFilter statFilter(){ 
        StatFilter statFilter = new StatFilter();
        statFilter.setLogSlowSql(true); //slowSqlMillis用来配置SQL慢的标准,执行时间超过slowSqlMillis的就是慢。 
        statFilter.setMergeSql(true); //SQL合并配置 
        statFilter.setSlowSqlMillis(1000);//slowSqlMillis的缺省值为3000,也就是3秒。 
        return statFilter; 
    } 
    @Bean 
    public WallFilter wallFilter(){ 
        WallFilter wallFilter = new WallFilter(); //允许执行多条SQL 
        WallConfig config = new WallConfig(); config.setMultiStatementAllow(true); 
        wallFilter.setConfig(config); 
        return wallFilter; 
    }
 }
  1. 分别配置每个数据源对应的sqlSessionFactory,以及MapperScan扫描的包:

MybatisDatasourceConfig.java

package com.zjt.config; import com.zjt.util.MyMapper;

import org.apache.ibatis.session.SqlSessionFactory; 
import org.mybatis.spring.SqlSessionFactoryBean; 
import org.mybatis.spring.SqlSessionTemplate; 
import org.mybatis.spring.annotation.MapperScan; 
import org.springframework.beans.factory.annotation.Autowired; 
import org.springframework.beans.factory.annotation.Qualifier; 
import org.springframework.context.annotation.Bean; 
import org.springframework.context.annotation.Configuration; 
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver; 
import javax.sql.DataSource; 

 @Configuration 
 // 精确到 mapper 目录,以便跟其他数据源隔离 
 @MapperScan(basePackages = "com.zjt.mapper", markerInterface = MyMapper.class, sqlSessionFactoryRef = "sqlSessionFactory") 
 public class MybatisDatasourceConfig { 
 
     @Autowired 
     @Qualifier("systemDataSource") 
     private DataSource ds; 
     
     @Bean 
     public SqlSessionFactory sqlSessionFactory() throws Exception { 
         SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); 
         factoryBean.setDataSource(ds); 
         //指定mapper xml目录 
         ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); 
         factoryBean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml")); 
         return factoryBean.getObject();
    }
    
    @Bean
    public SqlSessionTemplate sqlSessionTemplate() throws Exception {
        SqlSessionTemplate template = new SqlSessionTemplate(sqlSessionFactory()); // 使用上面配置的Factory 
        return template; 
    } 
    
    //关于事务管理器,不管是JPA还是JDBC等都实现自接口 PlatformTransactionManager 
    // 如果你添加的是 spring-boot-starter-jdbc 依赖,框架会默认注入 DataSourceTransactionManager 实例。 
    //在Spring容器中,我们手工注解@Bean 将被优先加载,框架不会重新实例化其他的 PlatformTransactionManager 实现类。 

}

MybatisDatasource2Config.java

package com.zjt.config; 

import com.zjt.util.MyMapper; 
import org.apache.ibatis.session.SqlSessionFactory; 
import org.mybatis.spring.SqlSessionFactoryBean; 
import org.mybatis.spring.SqlSessionTemplate; 
import org.mybatis.spring.annotation.MapperScan; 
import org.springframework.beans.factory.annotation.Autowired; 
import org.springframework.beans.factory.annotation.Qualifier; 
import org.springframework.context.annotation.Bean; 
import org.springframework.context.annotation.Configuration; 
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; 
import org.springframework.core.io.support.ResourcePatternResolver; 
import javax.sql.DataSource; 

/** * * @description */
@Configuration 
// 精确到 mapper 目录,以便跟其他数据源隔离 
@MapperScan(basePackages = "com.zjt.mapper2", markerInterface = MyMapper.class, sqlSessionFactoryRef = "sqlSessionFactory2") 
public class MybatisDatasource2Config { 
    
    @Autowired
    @Qualifier("businessDataSource") 
    private DataSource ds; 
    
    @Bean 
    public SqlSessionFactory sqlSessionFactory2() throws Exception { 
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); 
        factoryBean.setDataSource(ds); //指定mapper xml目录 
        ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); 
        factoryBean.setMapperLocations(resolver.getResources("classpath:mapper2/*.xml")); 
        return factoryBean.getObject();
    } 
    
    @Bean
    public SqlSessionTemplate sqlSessionTemplate2() throws Exception { 
        SqlSessionTemplate template = new SqlSessionTemplate(sqlSessionFactory2()); // 使用上面配置的Factory 
        return template; 
    } 
}

本例中只使用一个事务管理器:xatx,故就不在使用TxAdviceInterceptor.java和TxAdvice2Interceptor.java中配置的事务管理器了;

  1. 新建分布式业务测试接口JtaTestService.java和实现类JtaTestServiceImpl.java

JtaTestService 

package com.zjt.service3; 

import java.util.Map; 
public interface JtaTestService {
    public Map<String,Object> test01();
}

JtaTestServiceImpl 

package com.zjt.service3.impl; 

import com.zjt.entity.TClass; 
import com.zjt.entity.Teacher; 
import com.zjt.service.TClassService; 
import com.zjt.service2.TeacherService; 
import com.zjt.service3.JtaTestService; 
import org.springframework.beans.factory.annotation.Autowired; 
import org.springframework.beans.factory.annotation.Qualifier; 
import org.springframework.stereotype.Service; 
import org.springframework.transaction.annotation.Propagation; 
import org.springframework.transaction.annotation.Transactional; 
import java.util.LinkedHashMap; 
import java.util.Map; 

@Service("jtaTestServiceImpl") 
public class JtaTestServiceImpl implements JtaTestService{ 
    
    @Autowired 
    @Qualifier("teacherServiceImpl") 
    private TeacherService teacherService; 
    
    @Autowired 
    @Qualifier("tclassServiceImpl") 
    private TClassService tclassService; 
    
    @Override
    @Transactional(transactionManager = "xatx", propagation = Propagation.REQUIRED, rollbackFor = { java.lang.RuntimeException.class })
    public Map<String, Object> test01() { 
        LinkedHashMap<String,Object> resultMap=new LinkedHashMap<String,Object>(); 
        TClass tClass=new TClass(); tClass.setName("8888");
        tclassService.saveOrUpdateTClass(tClass); Teacher teacher=new Teacher(); 
        teacher.setName("8888"); 
        teacherService.saveOrUpdateTeacher(teacher); 
        System.out.println(1/0); 
        resultMap.put("state","success");
        resultMap.put("message","分布式事务同步成功"); 
        return resultMap;
    } 
}

七. RocketMQ分布式事务实现

实现原理

RocketMQ的事务消息,主要是通过消息的异步处理,可以保证本地事务和消息发送同时成功执行或失败,从而保证数据的最终一致性,

image.png

事务消息共有三种状态,提交状态、回滚状态、中间状态:

(1)RocketMQLocalTransactionState.COMMIT: 提交事务,它允许消费者消费此消息。

(2)RocketMQLocalTransactionState.ROLLBACK: 回滚事务,它代表该消息将被删除,不允许被消费。

(3)RocketMQLocalTransactionState.UNKNOWN: 中间状态,它代表需要检查消息队列来确定状态。

事务过程:

通过sendMessageInTransaction方法将消息发送到broker,并回调事务监听器的方法,此时消息为半消息状态,需要进行二次确认才能发送到队列并由消费者进行消费。

如果MQ收到的事务状态一直是UNKNOWN,那么将不断的向MQ发送方发起回调检查本地事务状态,直到收Commit/Rollback状态的消息或者人工干预删除UNKNOWN状态的消息;

具体实现

核心类为TxConsumer、TxProducer、TxProducerListener实现流程。TxProducer调用sendMessageInTransaction方法后进入到TxProducerListener中

注意本地SQL事务一定要在MQ监听器的回调方法中执行

需要注意的是: 一个RocketMQTemplate只能注册一个事务监听器,如果存在多个事务监听器监听不同的Producer,需要通过注解@ExtRocketMQTemplateConfiguration定义不同的RocketMQTemplate

完整代码

TxConsumer

import lombok.extern.slf4j.Slf4j; 
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; 
import org.apache.rocketmq.spring.core.RocketMQListener; 
import org.springframework.stereotype.Component; 

@Component
@RocketMQMessageListener(topic = "tx_topic", consumerGroup = "tx_group") 
@Slf4j
public class TxConsumer implements RocketMQListener<String> { 
    /** * * @param message */ 
    @Override public void onMessage(String message) { 
        log.info("消息事务-接受到消息:" + message); 
    } 
}

TxRocketMQTemplate

// 一个RocketMQTemplate只能注册一个事务监听器,如果存在多个事务监听器监听不同的`Producer` 
// 需要通过注解`@ExtRocketMQTemplateConfiguration`定义不同的RocketMQTemplate 
@ExtRocketMQTemplateConfiguration 
public class TxRocketMQTemplate extends RocketMQTemplate {

}

TxProducerListener

import lombok.extern.slf4j.Slf4j; 
import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener; 
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener; 
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState; 
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.messaging.Message; 
import java.util.concurrent.ConcurrentHashMap; 

@Slf4j 
@RocketMQTransactionListener(rocketMQTemplateBeanName = "txRocketMQTemplate") 
public class TxProducerListener implements RocketMQLocalTransactionListener { 
    
    /** 
    * 记录各个事务Id的状态:1-正在执行,2-执行成功,3-执行失败 
    */ 
    private ConcurrentHashMap<String, Integer> transMap = new ConcurrentHashMap<>();
    
    /** 
    * 执行本地事务 
    * 
    * @param msg 
    * @param arg 
    * @return 
    */ 
    @Override 
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 执行本地事务 
        String transId = msg.getHeaders().get(RocketMQHeaders.TRANSACTION_ID).toString(); 
        log.info("消息事务id为:" + transId); 
        // 状态为正在执行 
        transMap.put(transId, 1); 
        try { 
            // 本地SQL事务一定要在MQ监听器的回调方法中执行 
            log.info("正在执行本地事务"); 
            // 模拟耗时操作估计出发mq回查操作:当RocketMQ长时间(1分钟)没有收到本地事务的返回结果 
            // TimeUnit.SECONDS.sleep(80); 
            // 模拟业代码执行,比如模拟插入user数据到数据库中,并且失败的情况 
            // System.out.println(1 / 0); 
            log.info("事务执行完成."); 
        } catch (Exception e) {
            // 状态为执行失败 transMap.put(transId, 3);
            log.error("事务执行异常."); 
            // 出现异常 
            // 如果不需要重试 则设置为:ROLLBACK 
            // 如果需要检查事务重试,1分钟后发起检查 则设置为:UNKNOWN 
            return RocketMQLocalTransactionState.UNKNOWN; 
        } 
        // 状态为执行成功 
        transMap.put(transId, 2); 
        return RocketMQLocalTransactionState.COMMIT; 
    } 
    
    /** 
    * 事务超时,回查方法 
    * 检查本地事务,如果RocketMQ长时间(1分钟左右)没有收到本地事务的返回结果,则会定时主动执行改方法,查询本地事务执行情况。 
     * @param msg 
     * @return 
     */ 
     @Override 
     public RocketMQLocalTransactionState checkLocalTransaction(Message msg) { 
         //根据transaction的id回查该事务的状态,并返回给消息队列 
         //未知状态:查询事务状态,但始终无结果,或者由于网络原因发送不成功,对mq来说都是未知状态 
         //正确提交返回LocalTransactionState.COMMIT_MESSAGE 
         //事务执行失败返回LocalTransactionState.ROLLBACK_MESSAGE 
         String transId = (String) msg.getHeaders().get(RocketMQHeaders.TRANSACTION_ID); 
         Integer status = transMap.get(transId); 
         // 执行状态 1-正在执行,2-执行成功,3-执行失败 
         log.info("回查的事务id为:" + transId + ",当前的状态为:" + status); 
         //正在执行 
         if (status == 1) { 
             log.info("回查结果为:正在执行状态");
             return RocketMQLocalTransactionState.UNKNOWN; 
        } else if (status == 2) {
            //执行成功,返回commit 
            log.info("回查结果为:成功状态"); 
            transMap.remove(transId); 
            return RocketMQLocalTransactionState.COMMIT; 
        } else if (status == 3) { 
            //执行失败,返回rollback 
            log.info("回查结果为:失败状态"); 
            return RocketMQLocalTransactionState.ROLLBACK; 
            // 通过伪代码表示 检查本地事务执行情况 
         }
         // 其他未知情况,统一返回不重试,删除消息
         transMap.remove(transId); 
         return RocketMQLocalTransactionState.ROLLBACK; 
    }
}

TxProducer

import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate; 
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.messaging.Message; 
import org.springframework.messaging.support.MessageBuilder; 
import org.springframework.stereotype.Service;
import javax.annotation.Resource; 
import java.util.UUID; 

@Service 
@Slf4j 
public class TxProducer { 
    
    /** 
    * 一个RocketMQTemplate只能注册一个事务监听器,
    * 如果存在多个事务监听器监听不同的`Producer`, 
    * 需要通过注解`@ExtRocketMQTemplateConfiguration`定义不同的RocketMQTemplate
    */ 
    @Resource(name = "txRocketMQTemplate") 
    RocketMQTemplate rocketMQTemplate; 
    
    public void tx() {
        String text = "消息事务发送" + System.currentTimeMillis(); 
        log.info(text); 
        UUID transactionId = UUID.randomUUID(); 
        log.info("事务ID:" + transactionId); 
        Message<String> message = MessageBuilder.withPayload(text) 
        // 设置事务Id 
        .setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId) 
        .build();
        // 调研sendMessageInTransaction后进行到监听器中
        rocketMQTemplate.sendMessageInTransaction("tx_topic", message, null); 
        log.info("已发送..."); 
    } 
}