用一个示例项目演示在分布式系统中使用事务会产生的问题。
示例项目的 SQL:seata_demo.sql
示例项目代码:seata-demo.zip
这个示例项目中的微服务的互相调用依赖于 Nacos,所以还需要提供 Nacos。
整个项目的架构如下:
订单服务有一个创建订单接口,这个接口会在订单表中生成订单信息,同时会依次调用账户服务和库存服务,这两个微服务会分别扣减账户的金额以及扣减库存。
在执行接口的时候,如果库存足够(小于等于10),就可以正常生成订单并完成库存扣减。但如果库存不够,就会出现订单生成、金额扣减,但库存没有成功扣减的问题。
接口调用示例可以参考新建订单接口文档。
出现这个现象的原因是订单创建、金额扣减、库存扣减这三个动作分别属于三个微服务的事务,这三个事务之间没有联系,所以当其中一个事务失败回滚时,另外两个事务不会受到影响,所以会出现数据不一致的问题。
为了解决这个问题,我们需要一个在分布式系统之上协调各个微服务事务的统一事务机制。
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
关于分布式系统的一致性、可用性以及分区容错性的详细说明,可以观看这个视频。
对于任意的分布式系统,最多仅能同时满足这三个目标中的两个:
一般来说,由于分布式系统之间的通信必须通过网络连接,所以分区容错性(P)是不可避免的,所以一般的分布式系统要么会满足可用性和分区容错性(AP),要么会满足一致性和分区容错性(CP)。
BASE 理论是现实中用 CAP 定理实现分布式系统时的一种指导思想,包含三个方面的内容:
BASE 理论可以看做是在具体工程实践中对 CAP 定理的一种妥协,即不需要提供完整系统的可用性,以及确保整个系统在任意时间都具备数据一致性。
从 BASE 理论可以派生出两种分布式事务的解决思路:
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC):
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
官网地址:http://seata.io/
Seata事务管理中有三个重要的角色:
架构图:
Seata基于上述架构提供了四种不同的分布式事务解决方案:
无论哪种方案,都离不开TC,也就是事务的协调者。
部署 seata-tc 服务可以参考这篇文章。
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
XA是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况:
异常情况:
一阶段:
二阶段:
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:
RM一阶段的工作:
① 注册分支事务到TC
② 执行分支业务sql但不提交
③ 报告执行状态到TC
TC二阶段的工作:
TC检测各分支事务执行状态
a.如果都成功,通知所有RM提交事务
b.如果有失败,通知所有RM回滚事务
RM二阶段的工作:
XA模式的优点:
XA模式的缺点:
在微服务配置文件 application.yml 中添加 Seata 的相关配置:
seata:
data-source-proxy-mode: XA # 使用 XA 模式分布式事务
在分布式事务的入口方法上添加@GlobalTransactional
注解:
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
// ...
@Override
@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣用户余额
accountClient.deduct(order.getUserId(), order.getMoney());
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8(), e);
throw new RuntimeException(e.contentUTF8(), e);
}
return order.getId();
}
}
重启微服务并测试,可以观察到如果其中一个微服务执行失败,所有微服务的相关事务都会回滚,日志中会出现类似下面的信息:
io.seata.rm.AbstractRMHandler : Branch Rollbacking: 192.168.0.46:8091:153565015951716362 153565015951716366 jdbc:mysql:///seata_demo
AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
阶段一RM的工作:
阶段二提交时RM的工作:
阶段二回滚时RM的工作:
AT 模式虽然在性能上比 XA 模式更好,但问题是在隔离性上做了牺牲,所以可能会存在脏写问题,因此 AT 模式还引入了全局锁和更新后快照解决这个问题,具体可以观看这个视频。
要实现 AT 模式,需要在 Seata 对应的数据库(seata)中添加一个管理全局锁的表:
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
还需要在具体微服务使用的业务数据库(seata_demo)中添加保存更新前和更新后快照的表:
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` longblob NOT NULL COMMENT 'rollback info',
`log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` datetime(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;
修改微服务的配置文件 application.yml,使用 AT 模式:
seata:
data-source-proxy-mode: AT # 使用 AT 模式分布式事务
AT 模式是 Seata 的默认模式。
在事务的入口方上使用 @GlobalTransactional
注解标记。
实际测试,如果触发分支事务的回滚,会看到如下日志:
... : 扣款成功
... : rm handle branch rollback process:xid=192.168.0.46:8091:153565015951716372,branchId=153565015951716377,branchType=AT,resourceId=jdbc:mysql:///seata_demo,applicationData=null
... : Branch Rollbacking: 192.168.0.46:8091:153565015951716372 153565015951716377 jdbc:mysql:///seata_demo
... : xid 192.168.0.46:8091:153565015951716372 branch 153565015951716377, undo_log deleted with GlobalFinished
... : Branch Rollbacked result: PhaseTwo_Rollbacked
日志中的branchType=AT
说明分支事务使用的是 AT 模式,并且触发了回滚,在回滚后删除了快照信息。
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
举例说明,假设需要用 TCC 模式实现从一个账户扣款的过程,该账户的初始金额是 100:
首先执行 Try,冻结事务执行所需的金额,这里假设需要扣减 30 元:
在事务的二阶段,如果所有的分支事务的 Try 都执行成功,TC 会要求所有分支事务都执行 Confirm 方法,在当前分支事务中,Confirm 方法会扣减掉冻结的金额:
如果二阶段时,有其它事务的 Try 没有成功,TC 会要求所有的分支事务执行 Cancel 方法,即释放冻结的数据。在当前分支事务中,就是释放冻结的金额:
TCC的优点:
TCC的缺点:
在实现 TCC 模式前,还需要讨论两个 TCC 模式中会遇到的两个问题:事务悬挂和空回滚。
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做实际的回滚逻辑(因为还没有 Try),这就是空回滚。
要解决空回滚的问题,可以在数据库中记录分支事务执行的状态,在执行 Cancel 时检查分支事务是否执行过 Try,如果没有,就是空回滚,不执行具体的回滚逻辑。
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。
不存在因为分支事务阻塞但全局事务提交导致的事务悬挂问题,因为某个分支事务的阻塞必然会导致全局事务无法提交。
要解决事务悬挂,可以在 Try 方法中加入判断,如果分支事务已经执行过 Cancel 方法,就不再执行资源锁定等 Try 的正常逻辑。
实现 TCC 模式需要注意的是,TCC 模式是有局限性的,并不能实现所有类型的分支事务,它只能应用于某些资源锁定类型的分支事务。比如在这个示例项目中,创建订单这个操作就无法使用 TCC 模式,因为没有可以锁定的资源,但是扣减账户金额和库存两个操作可以用 TCC 模式实现。因此,在实际使用中通常会将 TCC 模式和其它的两阶段事务提交模式(XA 或 AT 模式)结合使用。
这里通过对示例项目中的账户金额扣减使用 TCC 模式来说明。
首先,为了能够对账户表的金额进行冻结操作,需要创建对应的一张账户金额冻结表:
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` VARCHAR(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` VARCHAR(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` INT(11) UNSIGNED NULL DEFAULT 0,
`state` INT(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = INNODB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
其中:
还需要为对应的金额冻结表创建对应的 MyBatis Mapper 接口和实体类,这里不再赘述。
创建一个采用 TCC 模式实现分支事务的 Service 层接口:
@LocalTCC
public interface AccountTccService {
/**
* 扣减账户金额
*
* @param userId 账户id
* @param money 金额
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
/**
* 分布式事务成功提交时执行的操作
* @param ctx 分支事务的上下文
* @return 分支事务提交是否成功
*/
boolean confirm(BusinessActionContext ctx);
/**
* 分布式事务失败回滚时执行的操作
* @param ctx 分支事务的上下文
* @return 分支事务回滚是否成功
*/
boolean cancel(BusinessActionContext ctx);
}
在这个接口中,deduct
方法是 TCC 模式中的 Try 方法,confirm
方法是 Confirm 方法,cancel
模式上 Cancel 方法。需要用@TwoPhaseBusinessAction
注解标记 TCC 模式的 Try 方法,并且其属性name
对应 Try 方法的方法名,commitMethod
对应 Confirm 方法的方法名,rollbackMethod
对应 Cancel 方法的方法名。
@BusinessActionContextParameter
注解标记 Try 方法参数后,相应的参数值会保存到分支事务的上下文中。相应的,Confirm 方法和 Cancel 方法可以通过形参BusinessActionContext
获取到分支事务的上下文,并从中获取 Try 方法的参数。
实现该接口:
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Service
public class AccountTccServiceImpl implements AccountTccService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 获取当前的全局事务id
String xid = RootContext.getXID();
// 执行 try 逻辑
// 尝试扣减剩余金额,如果扣减失败,数据库会报错
accountMapper.deduct(userId, money);
// 添加冻结金额和 Try 执行记录
AccountFreeze af = new AccountFreeze();
af.setState(AccountFreeze.State.TRY);
af.setFreezeMoney(money);
af.setUserId(userId);
af.setXid(xid);
accountFreezeMapper.insert(af);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 删除冻结金额和 Try 执行记录
int rows = accountFreezeMapper.deleteById(ctx.getXid());
return rows == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 查询冻结数据
String xid = ctx.getXid();
AccountFreeze af = accountFreezeMapper.selectById(xid);
if (af != null) {
// 冻结金额归零,并且分支事务状态修改为 cancel
AccountFreeze newAf = new AccountFreeze();
newAf.setXid(xid);
newAf.setState(AccountFreeze.State.CANCEL);
newAf.setFreezeMoney(0);
accountFreezeMapper.updateById(newAf);
// 恢复可用金额
int rows = accountMapper.refund(af.getUserId(), af.getFreezeMoney());
return rows == 1;
}
return true;
}
}
上面的实现是没有考虑空回滚和事务悬挂的实现,因此还需要做进一步修改。
处理空回滚:
@Override
public boolean cancel(BusinessActionContext ctx) {
// 查询冻结数据
String xid = ctx.getXid();
AccountFreeze af = accountFreezeMapper.selectById(xid);
if (af == null) {
// 没有分支事务执行记录时触发 Cancel,是空回滚
// 只记录 Cancel 执行,不做业务处理
AccountFreeze newAf = new AccountFreeze();
newAf.setXid(xid);
newAf.setUserId(ctx.getActionContext("userId").toString());
newAf.setFreezeMoney(0);
newAf.setState(AccountFreeze.State.CANCEL);
accountFreezeMapper.insert(newAf);
return true;
}
// 冻结金额归零,并且分支事务状态修改为 cancel
AccountFreeze newAf = new AccountFreeze();
newAf.setXid(xid);
newAf.setState(AccountFreeze.State.CANCEL);
newAf.setFreezeMoney(0);
accountFreezeMapper.updateById(newAf);
// 恢复可用金额
int rows = accountMapper.refund(af.getUserId(), af.getFreezeMoney());
return rows == 1;
}
处理事务悬挂:
@Override
@Transactional
public void deduct(String userId, int money) {
// 获取当前的全局事务id
String xid = RootContext.getXID();
// 检查分支事务是否已经执行过 Cancel,如果是,就是业务悬挂,不进行任何处理
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
if (accountFreeze != null && accountFreeze.getState() == AccountFreeze.State.CANCEL) {
return;
}
// 执行 try 逻辑
// 尝试扣减剩余金额,如果扣减失败,数据库会报错
accountMapper.deduct(userId, money);
// 添加冻结金额和 Try 执行记录
AccountFreeze af = new AccountFreeze();
af.setState(AccountFreeze.State.TRY);
af.setFreezeMoney(money);
af.setUserId(userId);
af.setXid(xid);
accountFreezeMapper.insert(af);
}
虽然解决了空回滚和事务悬挂,但我们还需要确保 Confirm 和 Cancel 操作具备幂等性,因为 TC 可能会在调用超时后重复执行调用。
解决幂等性:
@Override
public boolean confirm(BusinessActionContext ctx) {
String xid = ctx.getXid();
// 确保幂等性,如果金额冻结记录已经被删除,直接返回成功
AccountFreeze af = accountFreezeMapper.selectById(xid);
if (af == null) {
return true;
}
// 删除冻结金额和 Try 执行记录
int rows = accountFreezeMapper.deleteById(xid);
return rows == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 查询冻结数据
String xid = ctx.getXid();
AccountFreeze af = accountFreezeMapper.selectById(xid);
if (af == null) {
// 没有分支事务执行记录时触发 Cancel,是空回滚
// 只记录 Cancel 执行,不做业务处理
AccountFreeze newAf = new AccountFreeze();
newAf.setXid(xid);
newAf.setUserId(ctx.getActionContext("userId").toString());
newAf.setFreezeMoney(0);
newAf.setState(AccountFreeze.State.CANCEL);
accountFreezeMapper.insert(newAf);
return true;
}
// 确保幂等性,如果已经执行过 Cancel,不再执行相关逻辑,直接返回成功
if (AccountFreeze.State.CANCEL == af.getState()) {
return true;
}
// 冻结金额归零,并且分支事务状态修改为 cancel
AccountFreeze newAf = new AccountFreeze();
newAf.setXid(xid);
newAf.setState(AccountFreeze.State.CANCEL);
newAf.setFreezeMoney(0);
accountFreezeMapper.updateById(newAf);
// 恢复可用金额
int rows = accountMapper.refund(af.getUserId(), af.getFreezeMoney());
return rows == 1;
}
最后,在 Controller 中使用 TCC 模式实现的 Service:
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccountTccService accountService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money){
accountService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
Saga 模式是 Seata 即将开源的长事务解决方案,将由蚂蚁金服主要贡献。
其理论基础是Hector & Kenneth 在1987年发表的论文Sagas。
Seata官网对于Saga的指南:https://seata.io/zh-cn/docs/user/saga.html
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga也分为两个阶段:
优点:
缺点:
Seata 同样可以通过集群部署以实现高可用,并且还可以结合 Nacos 的配置热更新功能实现异地容灾。
具体可以参考这篇文章 以及这个视频。
本文的完整示例代码可以从这里获取。