解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)
这里的子系统事务,称为分支事务。有关联的各个分支事务在一起称为全局事务
解决分布式事务的思想和模型
- 全局事务:整个分布式事务
- 分支事务:分布式事务中包含的每个子系统的事务
- 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
- 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚
Seata事务管理中有三个重要的角色:
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚,在 Seata 中,分布式事务的执行流程如下:
TM 开启分布式事务(TM 向 TC 注册全局事务记录);
按业务场景,编排数据库、服务等事务内资源(RM 向 TC 汇报资源准备状态 );
TM 结束分布式事务,事务一阶段结束(TM 通知 TC 提交/回滚分布式事务);
TC 汇总事务信息,决定分布式事务是提交还是回滚;
TC 通知所有 RM 提交/回滚 资源,事务二阶段结束。
TM 和 RM 是作为 Seata 的客户端与业务系统集成在一起,TC 作为 Seata 的服务端独立部署。
Seata提供了四种不同的分布式事务解决方案
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- SAGA模式:长事务模式,有业务侵入
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-seataartifactId>
<exclusions>
<exclusion>
<artifactId>seata-spring-boot-starterartifactId>
<groupId>io.seatagroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>io.seatagroupId>
<artifactId>seata-spring-boot-starterartifactId>
<version>${seata.version}version>
dependency>
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: ""
group: DEFAULT_GROUP
application: seata-server # tc服务在nacos中的服务名称
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: # 事务组与TC服务cluster的映射关系
seata-demo: default #集群地址
这里的事务组是兜了一圈,多个微服务在同一个事务组内。详情后面说
tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称 service: vgroup-mapping: # 事务组与TC服务cluster的映射关系 seata-demo: default
XA规范是X/0pen 组织定义的分布式事务处理(DTP,DistributedTransaction Processing)标准,XA规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对XA规范提供了支持。实现了强一致性的特性
XA工作流程:首先由TM开启全局事务控制,随后调用分支事务,在每个微服务中先由RM注册分支事务,然后开始具体的执行sql,但是不提交告诉TC事务的状态,如果全都执行成功,则告诉RM事务可以提交,如果有一个出错,则通知RM事务回滚
RM一阶段的工作:
- 注册分支事务到TC
- 执行分支业务sql但不提交
- 报告执行状态到TC
TC二阶段的工作:
- TC检测各分支事务执行状态
- 如果都成功,通知所有RM提交事务
- 如果有失败,通知所有RM回滚事务
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则。
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
seata:
data-source-proxy-mode: XA
@GlobalTransactional
注解@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();
}
AT模式工作流程:首先由TM通知TC开启全局事务,随后调用分支,由每个微服务的RM注册分支事务,然后开始执行业务sql,执行的时候会被拦截这个sql,生成一个快照文件,并且提交事务,全部服务提交完后RM向TC报告事务状态,如果有报错,则根据快照文件回滚,然后删除快照
AT跟XA有什么区别?
- AT模式分支事务会直接提交事务,不锁定资源。XA模式分支事务会等全部分支没问题才会提交事务,在这期间会锁定资源
- AT根据快照文件回滚数据,XA根据数据库机制回滚数据
- AT模式保证最终一致性,XA模式保证强一致性
首先事务1先获取DB锁,保存快照,执行sql,提交事务前获取全局锁(这个全局锁是seata管理的),提交事务释放DB锁,此时全局锁还没有释放,还在事务1上。此时事务2进来了,获取到了DB锁,保存快照执行sql,提交事务前获取全局锁,但是获取不到,要等事务1释放全局锁。如果此时事务1需要回滚,那么就需要获取DB锁通过快照文件恢复数据,而DB锁在事务2上,就造成了死锁。seata对这种情况做了处理,如果获取全局锁失败会重试30次,间隔10ms,共计300ms,然后不再获取全局锁。事务2获取全局锁失败任务超时回滚并释放DB锁,事务1获取DB锁回滚数据。如果事务1不需要回滚,则没有后面死锁这一段了。
思考:这种全局锁一样锁定了资源,跟XA有啥区别?即XA不提交事务是DB锁锁定了,AT全局锁锁了资源
AT全局锁是由seata管理的,而XA的DB锁是数据库的。全局锁锁定,不是这个seata管理的事务也可以操作数据库,而DB锁任何事务都不能操作
思考:使用AT的时候,如果非seata管理的事务1跟seata管理的事务2同时操作事务,且事务2回滚,会不会造成脏写,即事务1丢失更新了
情况很少,大多数情况下不会造成二阶段事务回滚
分布式事务并发量低,很少巧合刚好事务提交了释放完锁,此时非seata事务进来获取DB
尽可能避免多个不一样的事务操作一个字段
思考:如果真的发生了,怎么处理?
当事务1一阶段完成后,普通事务2进来了提交事务后,事务1需要回滚数据,此时其实是有两份快照的,一份是事务1开始的快照,一份是一阶段事务提交前sql执行后的快照。回滚的时候seata判断这个一阶段事务提交时的快照90是不是正确的,如果不是说明在提交事务之后,回滚之前是有其他事务进来操作了数据的。就需要人工干预
AT模式的优点:
- 一阶段执行完sql直接提交事务,不用等到TC通知统一全部提交事务,中间减少了对资源的占用,性能好
- 利用全局锁实现读写隔离
- 代码0侵入
AT模式的缺点:
- 两个阶段之间属于软状态,属于最终一致
- 快照功能会影响性能,但是比XA模式要好很多
seata-at.sql:其中lock_table导入到TC服务关联的数据库,undo_log表导入到微服务关联的数据库:
data-source-proxy-mode: AT #默认就是AT
准备两张表在数据库
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
datasource = "druid"
## mysql/oracle/postgresql/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
## if using mysql to store the data, recommend add rewriteBatchedStatements=true in jdbc connection param
url = "jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true"
user = "mysql"
password = "mysql"
minConn = 5
maxConn = 100
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法
- Try:资源的检测和预留
- Confirm:完成资源操作业务;要求Try 成功 Confirm 一定要能成功。
- Cancel:预留资源释放,可以理解为try的反向操作。
TM通知TC开启全局事务,随后调用分支事务,由RM注册分支事务,RM资源预留Try直接提交事务,向TC报告事务状态,如果没问题就提交全局事务,有问题就回滚
TCC模式的每个阶段都是做什么的?
- Try:资源检查和预留
- Confirm:业务的执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 性能最好,不会生成快照文件,不用使用全局锁
- 一阶段完成直接提交事务,跟AT模式的一阶段很像,但是AT要加锁
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务性数据库
TCC的缺点是什么?
- 人工编写三个方法,有代码侵入
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理。有可能失败的时候重试,导致多补偿的情况,要考虑健壮性
其实TCC并不适用于全部事务,比如新增操作,没法预留资源
如果调用分支事务1锁定了资源(Try),此时分支事务2网络阻塞,一直不能锁定资源,超时后TM通知TC全部回滚,那事务1回滚没问题,因为已经try过了,但是事务2还没有try就要回滚了。就需要空回滚,判断事务2有没有try过
上面的业务继续。空回滚后,即全部回滚了,此时事务2网络好了,又要重新去锁定资源try,这个时候整个事务已经结束了全部回滚了就已经没意义了,等于说事务2执行了一半的业务没用了,就是业务悬挂。需要判断当前事务有没有回滚过,如果回滚过说明事务结束了。
来说一下关于业务悬挂的问题吧
在try方法执行的过程中,如果发生了超时,就会容易出现问题。是分情况的,
@LocalTCC //TCC注解
public interface AccountTCCService {
/**
* @TwoPhaseBusinessAction 表明这个是try方法,name是try方法名称,必须一致
* @BusinessActionContextParameter 有这个注解标识的参数会放到BusinessActionContext这个上下文对象里,可以通过这个上下文对象获取到参数
* @param userId
* @param money
*/
@TwoPhaseBusinessAction(name="deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}
改造account-service服务,利用TCC实现分布式事务,需求如下:
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
/**
* try方法
* @param userId
* @param money
*/
@Override
public void deduct(String userId, int money) {
// 获取事务id
String xid = RootContext.getXID();
// 先判断是否业务悬挂,如果freeze有冻结记录,说明一定做过cancel,拒绝业务
if (accountFreezeMapper.selectById(xid)!=null) {
return;
}
// 1.先判断余额够不够
LambdaQueryWrapper<Account> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Account::getUserId,userId);
Account account = accountMapper.selectOne(wrapper);
if (account.getMoney()<money) {
throw new RuntimeException("余额不足");
}
// 2.扣减account表的金额
accountMapper.deduct(userId,money);
// 3.记录冻结金额,事务状态,写入accountFreeze
AccountFreeze accountFreeze = new AccountFreeze();
accountFreeze.setUserId(userId);
accountFreeze.setFreezeMoney(money);
accountFreeze.setXid(xid);
accountFreeze.setState(AccountFreeze.State.TRY);
accountFreezeMapper.insert(accountFreeze);
}
/**
* 提交事务
* @param context
* @return
*/
@Override
public boolean confirm(BusinessActionContext context) {
// 1.先获取事务id
String xid = context.getXid();
// 2.删除冻结记录
int count = accountFreezeMapper.deleteById(xid);
return count == 1;
}
/**
* 回滚
* @param context
* @return
*/
@Override
public boolean cancel(BusinessActionContext context) {
String xid = context.getXid();
int money =(int) context.getActionContext("money");
String userId =context.getActionContext("userId").toString();
AccountFreeze accountFreeze = accountFreezeMapper.selectById(xid);
// 1.判断有没有扣过,即空回滚判断,如果为空,说明没有try,需要空回滚
if (accountFreezeMapper.selectById(xid) == null) {
accountFreeze = new AccountFreeze();
accountFreeze.setUserId(userId);
accountFreeze.setFreezeMoney(0);
accountFreeze.setXid(xid);
accountFreeze.setState(AccountFreeze.State.CANCEL);
return true;
}
// 幂等判断,判断状态是不是cancel,如果是cancel说明已经处理过了
// 如果不做幂等判断,有可能这个方法会一直调用,导致一直恢复金额
if (accountFreeze.getState() == AccountFreeze.State.CANCEL) {
return true;
}
// 2.恢复扣的钱
accountMapper.refund(userId,money);
// 3.将冻结金额清零,状态改成cancel
accountFreeze.setState(AccountFreeze.State.CANCEL);
accountFreeze.setFreezeMoney(0);
int count = accountFreezeMapper.updateById(accountFreeze);
return count==1;
}
}
@LocalTCC //TCC注解
public interface AccountTCCService {
/**
* @TwoPhaseBusinessAction 表明这个是try方法,name是try方法名称,必须一致
* @BusinessActionContextParameter 有这个注解标识的参数会放到BusinessActionContext这个上下文对象里,可以通过这个上下文对象获取到参数
* @param userId
* @param money
*/
@TwoPhaseBusinessAction(name="deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}
saga模式是SEATA提供的长事务解决方案。也分为两个阶段
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
Saga模式优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写