分布式事务与一致性

前言

数据库和业务模块的垂直拆分为我们带来了系统性能、稳定性和开发效率的提升的同时也引入了数据一致性的问题。
在实际业务中,一致性也是分等级的,按照从强到弱,可以划分为

强一致性包括线性一致性和顺序一致性,其他的如最终一致都是弱一致性。针对分布式系统的特点,基于不同的一致性需求产生了不同的分布式事务解决方案。即追求强一致的两阶段提交、追求最终一致性的柔性事务和事务消息等等。

强一致

系统中的某个数据被成功更新后,后续任何对该数据的读取操作都将得到更新后的值;

2pc

2PC全称Two-phaseCommit,中文名是二阶段提交,是XA规范的实现思路.

XA规范是 X/Open DTP 定义的交易中间件与数据库之间的接口规范,交易中间件用它来通知数据库事务的开始、结束以及提交、回滚等。XA 接口函数由数据库厂商提供。

X/Open DTP是X/Open 组织(即现在的 Open Group )1994定义的分布式事务处理模型。XA规模型其中重要的三部分

  1. 应用程序(AP) :
    事务的发启者,通常为微服务自身
  2. 事务管理器(TM):
    控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
  3. 资源管理器(RM):
    控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。一般指数据库

2PC 通常使用到XA中的三个角色(AP,TM)事务协调者,RM事务参与者。
在这里插入图片描述
2PC 分成2个阶段

  • 第一阶段:请求阶段(commit-request phase,或称表决阶段,voting phase)
  1. 事务协调者TM串行给每个参与者(RM)发送Prepare消息
  2. 每个参与者(RM) 本地SQL执行、记录事务日志(Undo、Redo)不提交。如果执行失败则返回abort;执行成功则返回success。
  • 第二阶段:提交阶段(commit phase)。
  1. TM收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息
  2. TM收到了所有参与者的成功消息,则串行提交事务
场景

在这里插入图片描述
用户购买商品的业务逻辑。整个业务逻辑由3个微服务提供支持:

  1. 仓储服务:对给定的商品扣除仓储数量。
  2. 订单服务:根据采购需求创建订单。
  3. 帐户服务:从用户帐户中扣除余额。
Mysql的XA实现

以下是java版TM,mysql版XA的实现demo

//把order库资源,做为RM1
Connection conn1 = DriverManager.getConnection("jdbc:mysql://XXX/order", "sa", "sa");
XAConnection xaConn1 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn1, logXaCommands);
XAResource rm1 = xaConn1.getXAResource();

//把storage库资源,做为RM2
Connection conn2 = DriverManager.getConnection("jdbc:mysql://xxx/storage", "sa", "sa");
XAConnection xaConn2 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn2, logXaCommands);
XAResource rm2 = xaConn2.getXAResource();

//自己当TM,TM生成全局事务id
byte[] gtrid = "g12345".getBytes();

//TM生成rm1上的事务分支id
byte[] bqual1 = "b00001".getBytes();
Xid xid1 = new MysqlXid(gtrid, bqual1, formatId);

// TM生成rm2上的事务分支id
byte[] bqual2 = "b00002".getBytes();
Xid xid2 = new MysqlXid(gtrid, bqual2, formatId);

// 执行rm1上的事务分支,
rm1.start(xid1, XAResource.TMNOFLAGS);/
PreparedStatement ps1 = conn1.prepareStatement("根据采购需求创建订单");
ps1.execute();
rm1.end(xid1, XAResource.TMSUCCESS);

// 执行rm2上的事务分支
rm2.start(xid2, XAResource.TMNOFLAGS);
PreparedStatement ps2 = conn2.prepareStatement("对给定的商品扣除仓储数量");
ps2.execute();
rm2.end(xid2, XAResource.TMSUCCESS);

// ===================两阶段提交================================
// phase1:询问所有的RM 准备提交事务分支
int rm1_prepare = rm1.prepare(xid1);
int rm2_prepare = rm2.prepare(xid2);
// phase2:提交所有事务分支
boolean onePhase = false; //TM判断有2个事务分支,所以不能优化为一阶段提交
if (rm1_prepare == XAResource.XA_OK
       && rm2_prepare == XAResource.XA_OK
    ) {//所有事务分支都prepare成功,提交所有事务分支
          rm1.commit(xid1, onePhase);
          rm2.commit(xid2, onePhase);
} else {//如果有事务分支没有成功,则回滚
          rm1.rollback(xid1);
          rm1.rollback(xid2);
}

其中Mysql的执行流程:
在这里插入图片描述
在这里插入图片描述

  1. 当xa start开启事务后,DML也会在对应的RM上创建undo以及read view。《mysql事务
  2. 当xa prepare 时会将子事务置于PREPARED状态,此时子事务已经完成事务提交前的所有准备工作(获得锁,并将PREPARED状态记录到共享表空间中,会将xa start到xa end之间操作记录在binlog中)。
  3. 当xa commit 时会在binlog中记录xa commit xid, 并将innodb中PREPARED状态转化为COMMITED状态。
  4. 当xa commit one phase 时会同时进行prepare和commit 两种操作,是在TM发现全局的分布式事务只涉及一个RM时进行的(因为不需要等待其他RM的反馈结果)。
  5. 当xa rollback在xa prepare前时,因为没有写binlog和redo,只会释放undo, read view以及lock。
  6. 当xa rollback 在xa prepare之后时,除了需要释放undo, read view以及lock,还需要binlog中记录xa rollback xid(使得从库不会提交该事务)以及innodb中将PREPARED状态转化为ROLLBACK状态。

为什么要多一步prepare操作,将操作写到binlog中?
如果不记,在事务达到PREPARED状态后,Mysql挂机,MySQL将PREPARED的事务丢失了.如果记入,Mysql重启后TM可继续提交。

Spring-JTA

Spring事务根据XA规范,提供的事务管理接口JPA.
在这里插入图片描述
不同的数据源厂商对JPA接口进行实现
在这里插入图片描述
其开源的实现 TM 提供商

  1. Java Open Transaction Manager (JOTM)
  2. JBoss TS
  3. Bitronix Transaction Manager (BTM)
  4. Atomikos
  5. Narayana
  6. seata
seataXA
/**
 * 用户下单
 */
@GlobalTransactional
public void purchase(String userId, String commodityCode, int orderCount) {
        //减库存
        storageService.deduct(commodityCode, orderCount);
        //扣款、生成订单
        orderService.create(userId, commodityCode, orderCount);
}
  1. GlobalTransactional注解会生成全局事务ID
  2. storageService.deduct根据xid去实现减库存的逻辑
  3. orderService.create 根据xid去实现生成订单的逻辑

@GlobalTransactional注解逻辑

public class TransactionalTemplate {
 
    public Object execute(TransactionalExecutor business) throws TransactionalExecutor.ExecutionException {
 
        // 1. 获取当前全局事务实例或创建新的实例
        GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
 
        // 2. 开启全局事务
        try {
            tx.begin(business.timeout(), business.name());
 
        } catch (TransactionException txe) {
            // 2.1 开启失败
            throw new TransactionalExecutor.ExecutionException(tx, txe,
                TransactionalExecutor.Code.BeginFailure);
 
        }
 
        Object rs = null;
        try {
            // 3. 调用业务服务
            rs = business.execute();
 
        } catch (Throwable ex) {
 
            // 业务调用本身的异常
            try {
                // 全局回滚
                tx.rollback();
 
                // 3.1 全局回滚成功:抛出原始业务异常
                throw new TransactionalExecutor.ExecutionException(tx, TransactionalExecutor.Code.RollbackDone, ex);
 
            } catch (TransactionException txe) {
                // 3.2 全局回滚失败:
                throw new TransactionalExecutor.ExecutionException(tx, txe,
                    TransactionalExecutor.Code.RollbackFailure, ex);
 
            }
        }
 
        // 4. 全局提交
        try {
            tx.commit();
 
        } catch (TransactionException txe) {
            // 4.1 全局提交失败:
            throw new TransactionalExecutor.ExecutionException(tx, txe,
                TransactionalExecutor.Code.CommitFailure);
 
        }
        return rs;
    }
}
SeataAT

XA 事务过程中

  1. 数据是被锁定的(锁,对于事务的隔离性是必要的机制)
  2. 连接也是被锁定的(连接资源被占用,限制了并发度)

SeataAT模式就是解决连接数锁定的问题。其主要方式通过代理dataSource,实现undo功能

Transaction Coordinator (TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,//全局ID
  `xid` varchar(100) NOT NULL,//分支xid
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,//回滚需要的信息
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

例:当一业务SQL进来

update product set name = 'GTS' where name = 'TXC';

一阶段

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据
select id, name, since from product where name = 'TXC';
//得数据为
id	name	since
1	TXC	    2014
  1. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
select id, name, since from product where id = 1;
//得数据为
id	name	since
1	GTS	    2014
  1. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}
  1. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
  2. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  3. 将本地事务提交的结果上报给 TC。

二阶段-回滚

  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理。
  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
update product set name = 'TXC' where id = 1;
  1. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

二阶段-提交

  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
  2. 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

写隔离-分析
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
在这里插入图片描述

  1. tx1 开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。
  2. 本地事务提交前,先拿到该记录的全局锁 ,本地提交释放本地锁。
  3. tx2 开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。
  4. 本地事务提交前,尝试拿该记录的全局锁 (一直尝试)
  5. tx1提交释放全局锁
  6. tx2 拿到 全局锁 提交本地事务
    在这里插入图片描述

如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。


读隔离-分析
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
在这里插入图片描述
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

3pc

2PC存在以下问题

  1. TM单点故障:由于全流程依赖TM的协调,一旦TM发生故障。参与者会一直阻塞下去。尤其在第二阶段,TM发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。所有参与者必须等待TM重新上线(TM重新选举)后才能继续工作。
  2. TM脑裂引起数据不一致:在第二阶段中,当TM向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中TM发生了故障,这会导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。

3PC 分成3个阶段:CanCommit(准备阶段)、PreCommit(对齐阶段)、DoCommit(提交阶段);

  1. 准备阶段:跟2PC的表决阶段很类似,TM向参与者发送SQL请求,参与者如果可以提交就返回Yes,否则返回No,询问超时默认参与者为No。唯一差别在于SQL层面:准备阶段只做了SQL处理,并未记录事务日志(Undo 和Redo)

  2. 对齐阶段:TM 和各个参与者对齐事务状态,TM 通知各个参与者事务决策后状态。如果参与者未收到事务对齐通知,会在超时后(准备阶段结束后开始计时)通知TM向参与者发起对齐请求。在SQL层面:事务状态对齐后,记录事务日志(Undo 和Redo)。如果TM一直没收集到齐各节点对齐的返回状态,超时(开始对齐阶段计时),则标注对齐结果为失败。

  3. 提交阶段:该阶段进行真正的事务提交。根据第二阶段得到的事务状态结果,各参与者根据TM的通知命令进行提交/回滚或者超时后自动提交(收到对齐命令的时候开始算起)。
    在这里插入图片描述

最终一致性

是弱一致性的特殊形式,不保证在任意时刻任意节点上的同一份数据都是相同的,但是随着时间的迁移,不同节点上的同一份数据总是在向趋同的方向变化。

TCC

TCC 是一种补偿型事务,该模型要求应用的每个服务提供 try、confirm、cancel 三个接口,它的核心思想是通过对资源的预留(提供中间态,如账户状态、冻结金额等),尽早释放对资源的加锁,如果事务可以提交,则完成对预留资源的确认,如果事务要回滚,则释放预留的资源。

TCC模型完全交由业务实现,每个子业务都需要实现Try-Confirm-Cancel三个接口,对业务侵入大,资源锁定交由业务方。

  1. Try:尝试执行业务,完成所有业务检查(一致性),预留必要的业务资源(准隔离性)。
  2. Confirm:确认执行业务,不再做业务检查。只使用Try阶段预留的业务资源,Confirm操作满足幂等性。
  3. Cancel:取消执行业务释放Try阶段预留业务资源。
场景

在这里插入图片描述
订单支付之后,我们需要做下面的步骤:

  • 更改订单的状态为“已支付”
  • 扣减商品库存
  • 给会员增加积分
  • 创建销售出库单通知仓库发货

**TCC 实现阶段一:Try**

在这里插入图片描述

  • 更改订单的状态为“支付中”
  • 扣减商品库存 (可售为98,冻结为2)
  • 给会员增加积分 (积分为1190,预增加为10)
  • 创建销售出库单通知仓库发货(状态为Unknown)

TCC 实现阶段二:Confirm

在这里插入图片描述


TCC 实现阶段三:Cancel

在这里插入图片描述
TCC常用的框架有ByteTCC,TCC-transaction,Himly,Seata.

TCC的Seata实现

在这里插入图片描述
TCC 模式,不依赖于底层数据资源。指支持把 自定义 的分支事务纳入到全局事务的管理中。然后等待全局事务触发提交(commit)和 回滚(Cancel)的事件

SAGA

Saga 是 30 年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
在这里插入图片描述
每个 Saga 由一系列 sub-transaction Ti 组成,每个 Ti 都有对应的补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。这里的每个 T,都是一个本地事务。

Saga的Seata实现

在这里插入图片描述

目前SEATA提供的Saga模式是基于状态机引擎来实现的,机制是:

  1. 通过状态图来定义服务调用的流程并生成 json 状态语言定义文件

通过状态图来描述子事务的执行顺序,即T1,T2,T3…TN

  1. 状态图中一个节点可以是调用一个服务,节点可以配置它的补偿节点

为每个T配置一个C进行补尝

  1. 状态图 json由状态机引擎驱动执行,当出现异常时状态引擎反向执行已成功节点对应的补偿节点将事务回滚

注意: 异常发生时是否进行补偿也可由用户自定义决定
SEATA 只负责通知哪点节点是否要进行补尝操作,不负责具体实现

  1. 可以实现服务编排需求,支持单项选择、并发、子流程、参数转换、参数映射、服务执行状态判断、异常捕获等功能

SEATA提供了丰富的API,获取各咱信息

状态机如果实现的,这边暂不介绍

事务消息

通知型事务的主流实现机制是通过MQ来通知其他事务参与者自己事务的执行状态。MQ组件的引入有效的讲事务参与者解耦开,各个参与者都可以异步执行,所以通知型事务又称为异步事务.其主要实现方式有以下向种:


基于MQ的事务功能方式
在这里插入图片描述

  1. 事务发起方首先发送prepare消息到MQ;
  2. 在发送prepare消息成功后执行本地事务;
  3. 根据本地事务执行结果返回commit或者是rollback;
  4. MQ Server 向 MQ producer询问提交状态
  1. 如果消息是rollback, MQ将删除该prepare消息不进行下发,如果是commit消息,MQ将会消息发送给consumer端;
  2. 如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
  1. Consumer端的消费成功机制有MQ保证
  • MQ的数据持久化机制及MQ的重发机制

基于消费端回调方案

  • 基于MQ的事务功能,必须机于MQ的事务机制,影响技术选型
  • MQ服务器端将不停的询问producer来获取事务状态,不好控制

基于消费端回调方案则 “ 把 MQ Server 向 MQ producer询问提交状态” 改用 “MQ consumer 向 MQ producer询问提交状态”


基于BD的本地消息表方案

  • 基于消费端回调方案,必须要提供回查方法

本地事务消息表的优势在于方案的通用性,无需提供回查方法,进一步减少的业务的侵入。

在这里插入图片描述

  1. 直接利用本地事务,将业务数据和事务消息直接写入数据库。
  2. 启用投递线程:专门投递事务消息到MQ,根据投递ACK去删除事务消息表记录(也造成了浪费)

总结

  • 强一致性的2pc,3pc很刚,它需要浪费更多服务器性能和资源。但对业务的入侵少。

  • 弱一致性的tcc,saga,mq很柔。可以根据不同的业务场景灵活编写,可以节省大量的资源,但也意味着业务入侵更多,考虑的点更多。

    1. 幂等性:重复调用的问题
    2. 资源悬挂:网络异常导致两个事件无法保证严格的顺序执行的问题

主要参考

分布式场景之刚性事务-2PC详解
分布式架构,刚性事务-2PC必须注意的问题及3PC详细解
分布式柔性事务的TCC方案
分布式柔性事务之Saga详解
柔性事务之事务消息详解
mysql xa事务
JTA分布式事务处理
Seata 是什么
分布式事务中间件Seata的设计原理
终于有人把“TCC分布式事务”实现原理讲明白了!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值