TCC是Try、Confirm、Cancel三个词语的缩写,TCC要求每个分支事务实现三个操作:预处理Try、确认Confirm、撤销Cancel。
1、Try 阶段是做业务检查(一致性)及资源预留(隔离),此阶段仅是一个初步操作,它和后续的Confirm一起才能真正构成一个完整的业务逻辑。
2、Confirm 阶段是做确认提交,Try阶段所有分支事务执行成功后开始执行Confirm。通常情况下,TCC认为Confirm阶段是不会出错的,若Confirm阶段真的出错了,则重试或人工处理。
3、Cancel 阶段是在业务执行错误需要回滚的状态下执行分支事务的业务取消,预留资源释放。通常情况下,TCC认为Cancel阶段也是一定成功的,若Cancel阶段真的出错了,则重试或人工处理。
TM(事务管理器)首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所有分支事务的Cancel操作;若try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。
所有try都成功
有一个try失败
特别提醒
所有分支事务的try阶段执行成功,则会执行confirm阶段。TCC认为confirm阶段一定会执行成功,如果confirm执行失败,则会重试或者人工处理错误。
任何一个分支事务的try阶段执行失败,则会执行cancel阶段。TCC认为cancel阶段一定会执行成功,如果cancel执行失败,则会重试或者人工处理错误。
TCC需要注意三种异常处理分别是:空回滚、幂等、悬挂。
空回滚
事务管理器调用服务的try操作,可能会出现因为丢包而导致的网络超时,导致应用的try阶段没执行,事务管理器认为执行try超时,会触发cancel操作。这就导致cancel比try先执行。
悬挂
1、事务协调器在调用 TCC 服务的一阶段 Try 操作时,可能会出现因网络拥堵而导致的超时。
2、此时事务管理器会触发二阶段回滚,调用 TCC 服务的 Cancel 操作,Cancel 执行正常。
3、在此之后,拥堵在网络上的一阶段 Try 数据包被 TCC 服务收到,出现了二阶段 Cancel 请求比一阶段 Try 请求先执行的情况。
4、此 TCC 服务在执行晚到的 Try 之后,将永远不会再收到二阶段的 Confirm 或者 Cancel ,造成 TCC 服务悬挂。
幂等
try、confirm、cancel都会被重复调用,需要做幂等处理。
处理空回滚、悬挂、幂等
可以使用日志表,来解决 空回滚、悬挂、幂等。
新建3张表
try阶段日志表local_try_log | |
字段 | 注释 |
tx_no | 全局事务id |
create_time | 创建时间 |
confirm阶段日志表local_confirm_log | |
字段 | 注释 |
tx_no | 全局事务id |
create_time | 创建时间 |
cancel阶段日志表local_cancel_log | |
字段 | 注释 |
tx_no | 全局事务id |
create_time | 创建时间 |
例子
从银行1转账10元给银行2,使用TCC方案
方案1
银行1
try:
幂等校验,查找try日志(全局事务id是主键)
悬挂处理,查找confirm、cancel日志(全局事务id是主键)
检查余额是否够10元
锁定10元
插入try日志(全局事务id是主键)
confirm:
幂等校验,查找confirm日志(全局事务id是主键)
扣减10元
删除锁定10元
插入confirm日志(全局事务id是主键)
cancel:
cancel幂等校验,查找cancel日志(全局事务id是主键)
空回滚处理,查找try日志(全局事务id是主键)
增加余额10元(回滚)
删除锁定10元
插入cancel日志(全局事务id是主键)
银行2
try:
幂等校验,查找try日志(全局事务id是主键)
悬挂处理,查找confirm、cancel日志(全局事务id是主键)
插入待激活10元
插入try日志(全局事务id是主键)
confirm:
幂等校验,查找confirm日志(全局事务id是主键)
正式增加30元
删除待激活10元
插入confirm日志(全局事务id是主键)
cancel:
空
由于业务很简单,上面的流程还可以取消锁定,解锁的操作,直接在银行1的try中扣减10元,流程如下。
方案2
银行1
try:
幂等校验,查找try日志(全局事务id是主键)
悬挂处理,查找confirm、cancel日志(全局事务id是主键)
检查余额是否够10元
扣减10元
插入try日志(全局事务id是主键)
confirm:
空
cancel:
cancel幂等校验,查找cancel日志(全局事务id是主键)
空回滚处理,查找try日志(全局事务id是主键)
增加余额10元(回滚)
插入cancel日志(全局事务id是主键)
银行2
try:
空
confirm:
幂等校验,查找confirm日志(全局事务id是主键)
正式增加30元
插入confirm日志(全局事务id是主键)
cancel:
空
Hmily实现方案2的代码
服务1
@Service
@Slf4j
public class Bank1ServiceImpl implements Bank1Service {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
HmilyLogDao hmilyLogDao;
@Autowired
Bank2Client bank2Client;
@Override
@Transactional(rollbackFor = Exception.class)
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void updateAccountBalance(String msg, Double amount) {
// 全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 try 开始,transId={}", transId);
// 幂等判断
int existTry = hmilyLogDao.isExistTry(transId);
// 通故全局事务id查找到try日志,表明已经只执行过try
if (existTry > 0) {
log.info("已经执行过try,无需重复执行try,transId={}", transId);
return;
}
// 悬挂处理
int existConfirm = hmilyLogDao.isExistConfirm(transId);
int existCancel = hmilyLogDao.isExistCancel(transId);
// 通故全局事务id查找到confirm、cancel日志,表明已经只执行过confirm、cancel
if (existConfirm > 0 || existCancel > 0) {
log.info("confirm,cancel有一个已经执行过,try不能再次执行,transId={}", transId);
return;
}
// 制造空回滚
if (StringUtils.equals("制造空回滚", msg)) {
throw new RuntimeException("try方法没修改数据库就抛出异常,cancel方法会执行,形成空回滚,transId=" + transId);
}
// blank1减金额
accountInfoDao.subtractAccountBalance("1", amount);
// 添加try日志记录,try日志和扣减余额在同一个本地事务中,要么都成功,要么都失败
// 日志的组件id必须是全局事务id,如果同一个事物重复调用try,到这一步会报主键重复
hmilyLogDao.addTry(transId);
// 远程调用
Boolean result = bank2Client.transfer(msg, amount);
if (!result) {
throw new RuntimeException("调用bank2失败");
}
// bank1调用bank2成功后,发生异常,模拟回滚
if (StringUtils.equals("bank1调用bank2成功后,发生异常,模拟回滚", msg)) {
throw new RuntimeException("bank1调用bank2成功后,发生异常,模拟回滚,transId=" + transId);
}
}
public void confirm(String accountNo, Double amount) {
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 confirm 开始执行,transId={}", transId);
}
@Transactional(rollbackFor = Exception.class)
public void cancel(String msg, Double amount) {
// 全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank1 cancel 开始执行,transId={}", transId);
// 幂等判断
int existCancel = hmilyLogDao.isExistCancel(transId);
if (existCancel > 0) {
log.info("cancel已经执行过,无需重复执行,transId={}", transId);
return;
}
// 处理空回滚
int existTry = hmilyLogDao.isExistTry(transId);
if (existTry == 0) {
log.info("try未执行过,不能执行cancel,transId={}", transId);
return;
}
// bank1回滚,加钱
accountInfoDao.addAccountBalance(msg, amount);
// 添加日志
hmilyLogDao.addCancel(transId);
}
}
@Service
@Slf4j
public class Bank2ServiceImpl implements Bank2Service {
@Autowired
AccountInfoDao accountInfoDao;
@Autowired
HmilyLogDao hmilyLogDao;
@Override
@Hmily(confirmMethod = "confirm", cancelMethod = "cancel")
public void updateAccountBalance(String msg, Double amount) {
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 try 开始执行,transId:{}",transId);
}
@Transactional(rollbackFor = Exception.class)
public void confirm(String msg, Double amount) {
// 全局事务id
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 confirm 开始执行,transId:{}",transId);
int existConfirm = hmilyLogDao.isExistConfirm(transId);
if (existConfirm > 0) {
log.info("bank2 confirm 已经执行过,无需再次执行,transId", transId);
return;
}
// bank2加钱
accountInfoDao.addAccountBalance("2", amount);
// 添加confirm日志
hmilyLogDao.addConfirm(transId);
// bank2 confirm,抛出异常,会重试
if (StringUtils.equals("confirm抛出异常会重试", msg)) {
throw new RuntimeException("confirm抛出异常会重试,transId=" + transId);
}
}
public void cancel(String msg, Double amount) {
String transId = HmilyTransactionContextLocal.getInstance().get().getTransId();
log.info("bank2 cancel 开始执行,transId:{}",transId);
}
}