目录
一、Seata 分布式解决方案
1.1、TCC 模式
1.1.1、TCC 模式理论
对比 TCC 和 AT 模式的一致性和隔离性
TC 的工作模型
1.2.2、TCC 模式优缺点
1.2.3、TCC 模式注意事项:空回滚
1.2.4、TCC 模式注意事项:业务悬挂
1.2.5、实现 TCC 模式
案例:在用户余额扣减服务中,实现 TCC 模式.
实现案例
a)TCC 的 try、confirm、cancel 方法都需要在接口中基于注解来声明
b)建表
c)对应刚刚上述所描述的实现思路,可以基本实现(未考虑空回滚 和 业务悬挂)
d)考虑空回滚
e)幂等问题
f)业务悬挂问题
g)到此,整个业务完成.
1.2、Saga 模式
1.2.1、Saga 模式理论
1.2.2、saga 模式优缺点
TCC 模式和 AT 模式很相似,第一阶段都是独立事务,执行完了直接提交,不同的是 TCC 模式不用去加锁,也不用生成快照,因此性能上会更好.
TCC 模式的第二阶段是基于人工编码的方式来实现数据恢复的,不像 AT 是自动实现的.
人工编码的方式需要实现三个方法,分别是 try、confirm、cancel.
例如现在我的账户余额是 100 元,现在要扣掉 30 元. 如果分成 try、cancel、confirm 这三个阶段.
对比 TCC 和 AT 模式的一致性和隔离性
一致性:首先第一阶段两个模式都是各自提交各自的事务,因此两种模式都有可能出现提交成功和失败的情况,导致状态不一致,需要通过第二阶段来调整. 也就是说这两种模式都是最终一致性.
隔离性:AT 模式是需要通过加锁实现隔离(在第一、第二阶段持有全局锁),而 TCC 模式下不需要加锁隔离,因为在第一阶段是通过冻结来实现隔离(冻结了一部分金额),就算此时有另一个事务也要冻结金额,那就直接从可用余额中取一部分冻结,所以事务之间都没有任何影响,不需要加锁,那么 TCC 模式的性能就要比 AT 模式好很多了.
TC 的工作模型
第一阶段:
这里大部分都和 AT 很像,一开始都是由 TM 去开启全局事务并注册到 TC 上面,然后 TM 去通知每一个分支事务去执行,然后请求被 RM 拦截,RM 就会先去注册分之十五,然后去执行 try 预留资源,执行完后直接提交,随后向 TC 报告事务的状态(资源预留执行成功了?还是失败了).
第二阶段:
TM 通知 TC 事务结束了,那么 TC 就要对事务的状态做判断了. 如果分支预留资源成功了,就直接执行 confirm 提交即可;如果发现其中任意一个有问题,就要执行 cancel 逻辑.
优点:
性能高:第一阶段执行完直接提交事务,并且既不用生成快照,也不用使用全局锁. 可以认为是所有分布式事务模型中性能最好的.
不依赖数据库:不需要依赖于事务性的数据库,因为是靠预留资源来做代偿的. 也就是说不仅可以使用 mysql 这种关系型数据库,也可以使用 redis 这种非关系型数据库去实现 TCC 模式.
缺点:
代码侵入高:try、confirm、cancel 这三个方法需要人工编写.
软状态,最终一致:第一阶段执行完后,直接提交事务.
考虑幂等:将来 confirm 和 cancel 可能会执行失败,Seata 看到失败了就会重试,就可能造成死循环. 因此要考虑各种健壮性.
问题:
在将执行某个分支事务的时候,发现执行分支事务的请求因为某种原因(网络抖动)阻塞住了,一旦阻塞的时间超过了超时时间,就会将超时的错误报告给 TC,然后 TC 就会告诉这个分支事务的 RM:“那你去回滚吧”,此时 RM 就会去执行 cancel 的业务.
这就导致本身你没有执行 try 预留资源,现在却要执行 cancel 去释放预留资源. 比方说 try 的业务就是去冻结 30 元的余额,但是在没有进行 try 之前却要进行释放 30 元冻结余额的业务,这不就出事了吗?
解决方案:
因此这里需要做一个空回滚.
在 try 执行请求因为某种原因阻塞时,可能会导致全局事务超时,从而先触发了 cancel 逻辑,此时根本就没有做资源预留,就不能回滚,并且也不能报错(不然 Seata 会以为 cancel 出问题了,会重试,最后导致死循环). 那么空回滚只需要我们返回一个正常结束即可.
问题:
在执行完空回滚之后,try 逻辑的请求阻塞突然通畅,就会去执行资源预留业务,但是资源预留了之后就没有后续了(已经执行过 cancel 中的空回滚了),既没有 cancel,也没有 confirm,业务只执行了一半. 这就是业务悬挂.
比如说我本来有 100 元余额,执行完空回滚后,try逻辑突然通常,冻结了我 30 元的可用余额,然后也没有后续业务了,就导致我这 30 元有是有,但是却一直用不了.
解决办法:
在执行 try 的时候,先判断一下是否回滚过,如果回滚过了 try 就不能执行了. 同样在执行 cancel 的时候,需要判断一下,try 是不是已经执行了,如果 try 没有执行,就去做一个空回滚.
怎么知道 try 到底有没有执行过呢?这就需要在数据库中在创建一个表,用来记录事务的状态(记录上一步是执行了 try 呢?还是cancel?还是confirm?).
那么实现的思路如下:
语法如下:
@LocalTCC
public interface TCCService {
/**
* Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
* commitMethod 用来指定 confirm 逻辑,值必须对应自己实现的方法名. rollbackMethod 表示 cancel 逻辑,值必须对应自己实现的方法名.
*/
@TwoPhaseBusinessAction(name = "prepare", commitMethod = "confirm", rollbackMethod = "cancel")
void prepare(@BusinessActionContextParameter(paramName = "param") String param);
/**
* 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致
*
* @param context 上下文,可以传递try方法的参数
* @return boolean 执行是否成功
*/
boolean confirm (BusinessActionContext context);
/**
* 二阶段回滚方法,要保证与rollbackMethod一致
*/
boolean cancel (BusinessActionContext context);
}
根据上述语法,就可以编写用户余额冻结服务的接口 AccountTCCService ,如下
@LocalTCC
public interface AccountTCCService {
/**
* try:冻结指定余热
* @param userId
* @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);
}
这里我们已经有了用户金额表,如下:
这里我们还需要创建 用户冻结金额表 ,如下:
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;
对应实体类如下:
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
private Integer state;
public static abstract class State {
public final static int TRY = 0;
public final static int CONFIRM = 1;
public final static int CANCEL = 2;
}
}
AccountTCCService 接口,如下:
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
//1.获取事务 id
String xid = RootContext.getXID();
//2.扣减可用余额
accountMapper.deduct(userId, money);
//3.增加冻结金额,并记录当前事务的状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
//1.添加事务 id
String xid = RootContext.getXID();
//2.根据 id 删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
//1.查询冻结记录
String xid = RootContext.getXID();
AccountFreeze freeze = freezeMapper.selectById(xid);
//2.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//3.清理冻结余额,状态修改为 cancel
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
考虑在执行 try 逻辑阻塞超时,执行了 cancel 逻辑,那么就需要考虑空回滚. 主要记录 cancel 状态即可.
@Override
public boolean cancel(BusinessActionContext ctx) {
//1.查询冻结记录
String xid = RootContext.getXID();
AccountFreeze freeze = freezeMapper.selectById(xid);
//a. 空回滚判断
if (freeze == null) {
//这里主要记录当前的 cancel 状态
freeze = new AccountFreeze();
//这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
String userId = ctx.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
//2.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//3.清理冻结余额,状态修改为 cancel
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
第一次超时了,进行空回滚(添加 freeze,设置状态为 cancel),第二次又超时了,freeze 不为空,就会进行恢复金额逻辑. 这就出问题了,不能进行恢复金额操作,因此,这里需要进行判断,如果处理过了,直接返回 true 即可.
@Override
public boolean cancel(BusinessActionContext ctx) {
//1.查询冻结记录
String xid = RootContext.getXID();
AccountFreeze freeze = freezeMapper.selectById(xid);
//a. 空回滚判断
if (freeze == null) {
//这里主要记录当前的 cancel 状态
freeze = new AccountFreeze();
//这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
String userId = ctx.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
//b.幂等问题:第一次超时了,进行空回滚,第二次又超时了,freeze 不为空,就会进行恢复金额逻辑(这就出问题了).
if(freeze.getState() == AccountFreeze.State.CANCEL) {
//已经处理过依次 cancel 了,无需重复处理
return true;
}
//2.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//3.清理冻结余额,状态修改为 cancel
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
confirm 为什么不考虑幂等了?
因为 confirm 逻辑是删除冻结记录,底层就是 sql 调用 delete. 因此即使操作多次,也无妨.
处理过 cancel 之后,就没必要再处理 try 了,因此这里只需要判断 freeze 是否存在冻结记录,如果有,拒绝即可.
@Override
@Transactional
public void deduct(String userId, int money) {
//1.获取事务 id
String xid = RootContext.getXID();
//a. 业务悬挂问题处理:判断 freeze 中是否有冻结记录,如果有,一定是 cancel 执行过,要拒绝业务
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if(oldFreeze != null) {
return;
}
//2.扣减可用余额
accountMapper.deduct(userId, money);
//3.增加冻结金额,并记录当前事务的状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
全代码如下:
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
//1.获取事务 id
String xid = RootContext.getXID();
//a. 业务悬挂问题处理:判断 freeze 中是否有冻结记录,如果有,一定是 cancel 执行过,要拒绝业务
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if(oldFreeze != null) {
return;
}
//2.扣减可用余额
accountMapper.deduct(userId, money);
//3.增加冻结金额,并记录当前事务的状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
//1.添加事务 id
String xid = RootContext.getXID();
//2.根据 id 删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
//1.查询冻结记录
String xid = RootContext.getXID();
AccountFreeze freeze = freezeMapper.selectById(xid);
//a. 空回滚判断
if (freeze == null) {
//这里主要记录当前的 cancel 状态
freeze = new AccountFreeze();
//这里能拿到 userId 和 money 是因为在 AccountTCCService 接口中通过 BusinessActionContextParameter 注解注册了
String userId = ctx.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setXid(xid);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.TRY);
freezeMapper.insert(freeze);
}
//b.幂等问题:第一次超时了,进行空回滚,第二次又超时了,freeze 不为空,就会进行恢复金额逻辑(这就出问题了).
if(freeze.getState() == AccountFreeze.State.CANCEL) {
//已经处理过依次 cancel 了,无需重复处理
return true;
}
//2.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//3.清理冻结余额,状态修改为 cancel
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
Saga模式是SEATA提供的长事务解决方案。也分为两个阶段:
第一阶段:
与 AT 一样,直接提交本地事务.
第二阶段:
如果第一阶段大家都成功了,就什么也不做.
如果第一阶段有失败的,那么他会反向做一个补偿逻辑去回滚. 这里确实和 tcc 优点像,但不完全一样,因为 tcc 再第一阶段中不是处理事务,只是做资源预留.
比如 扣余额业务,TCC 就直接冻结了,而 saga 是直接把余额扣掉了,如果 saga 第一阶段出现问题,第二阶段就是把扣掉的余额增加回来,实现回滚逻辑的.
缺点:
没有隔离性:因为一二阶段既没有全局锁,也没有预留资源,所有事务与事务之间可能存在脏写问题.
软状态持续时间不确定:saga 模式是按顺序执行每一个事务,如果有任何一个出现问题,就会立刻反向补偿. 因此这个不一致的时间不确定.
优点:
吞吐能力高:基于事件驱动实现异步调用,也就是一个事务完成了,自己执行下一个事务,无需阻塞等待.
性能高:第一阶段无需上锁,性能高.
实现简单:不用像 TCC 那样编写三个阶段,实现简单.
1.2.3、补充说明
Ps:由于这种模式的使用场景极少,因此就不演示了.