seata是阿里开源的一个分布式事务框架,能够让大家在操作分布式事务时,像操作本地事务一样简单。一个注解搞定分布式事务。
有些地方官网文档写的可能比较难以理解,这里用较为简单的方式来描述一下。
快速入门
譬如你有两个微服务,一个是库存模块StorageService,一个是订单模块OrderService,主业务是用户下单,然后需要分别调用上面的两个服务,完成减库存、用户扣款和下单操作。由于两个服务是不同的服务,并且是不同的数据库,那么这就是一个典型的分布式事务场景。我们希望要么全部成功,要么全部失败。
/**
* 用户下单
*/
public void purchase(String userId, String commodityCode, int orderCount) {
//减库存
storageService.deduct(commodityCode, orderCount);
//扣款、生成订单
orderService.create(userId, commodityCode, orderCount);
}
其中OrderService做了如下操作,扣减钱款、生成订单。库存Service就是减了一下库存。
那么要完成这次分布式事务,只需要在purchase方法上加个注解即可。看起来确实是一个注解,解决所有。
@GlobalTransactional
public void purchase(String userId, String commodityCode, int orderCount) {
......
}
@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;
}
}
被注解包围的方法,其实就是第三步,可以看一下流程:
第一步:初始化一个全局事务的实例,该实例拥有一个唯一的id,称之为XID,这个XID会在整个分布式事务的各个服务间流转。作为这一次分布式事务的唯一标识。
第二步:开启全局事务,设置超时时间等属性,以避免拿不到锁时,无限的等待。
第三步:执行逻辑,(具体的各个服务,执行各自的本地事务)。如果任何一个服务的本地事务出现异常,则进入回滚全局事务。如果回滚成功,就抛出那个本地事务失败的异常。如果回滚全局事务失败,则抛出异常原因。
第四步:标记该次全局事务状态为完成,通知各单体服务该次事务已经完成,请继续下一步。各单体服务收到该请求后,会立刻返回成功,然后异步删除之前本地事务前保存的回滚信息。
这里面需要注意的,和其他的常见的分布式事务不同的地方在于第三步的单体服务的执行逻辑。在生成全局事务XID并打出begin的发令枪后,各个单体服务会立刻开始自己的本地事务,而不会去关心别的服务的情况。在本地事务执行成功、失败后,会通知TC中心,说我成功、失败了。TC会记录下来每个单体服务的状态,如果全部成功了,那么就进行下一步(第四步)。如果有任何一个失败了,就通知各个单体服务,进行各自的回滚。
很明显,这种处理方式减少了XA两阶段提交的锁的时间,而且并不依赖于数据库本身的回滚机制,靠的是TC这个server端维护这一次XID中各个单体服务的执行状态,回滚时靠着自己保存的回滚语句进行回滚。可以明显提高各事务的并发执行。
详细流程
来看一下单体服务具体的执行流程。
第一步:解析sql语句,得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
第二步:查询老数据,根据上面的where语句sql,去数据库查询原始的数据。
如 select * from product where name = 'TXC';得到原始的数据,如该行id=1,然后记录下来。
第三步:执行第一步的sql语句,即执行update,修改数据库的该记录的值。
第四步:查询修改后的值,select * from product where id =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"
}
第六步:向TC server注册分支,申请product表,id=1的行的全局锁。注意,这个全局锁是相对于所有可能的同时在执行的分布式事务而言的。一旦某个分支,获取了该记录的全局锁,在解锁之前,任何其他的分布式事务,不能修改该数据。
第七步:本地事务提交,将自己的本地事务、和前面的UNDO LOG一起提交。
第八步:将本地事务提交的结果上报给TC server。如成功、失败。
此时TC会陆续收到各个分支的执行结果,在各分支全部提交完毕后,TC会下发最终结果给各分支。
开始第二阶段。
成功的情况:分支收到了TC下发的成功请求,立马返回我已OK的结果给TC,然后异步执行删除UNDO LOG的操作。因为成功了,所以用来回滚的UNDO LOG就没意义了,异步删除掉就好。
失败的情况:
1 分支收到了TC下发的失败请求,开始执行回滚逻辑。
2 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
3 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
4 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句。
update product set name = 'TXC' where id = 1;
5 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
结论:
可以看到,整体来说,这个分布式事务是比较迅速的,在不等待全局锁的情况下,基本和本地事务没什么区别。回滚时,也不依赖数据库本身的回滚能力,都由自己业务来实现回滚操作。
这里还是有个问题点的,就是第3步。在回滚时,发现老数据已经被其他的事务修改了,该怎么处理。
全局锁和本地锁
上面提到了,在操作同一个数据时,涉及了全局锁的概念。这是为了控制多个分布式事务,同时操作同一条数据时,造成的数据不一致。
定义整个分布式事务为两个阶段:
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
那么锁的限制就是:
一阶段本地事务提交前,需要确保先拿到 全局锁 。
拿不到 全局锁 ,不能提交本地事务。
拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
举例:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。
tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
参考:
https://www.jianshu.com/p/77a95a0cf850
https://github.com/seata/seata/wiki/Quick-Start