1. 分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务,例如:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。例如电商行业中比较常见的下单付款案例,包括下面几个行为:
- 创建新订单
- 扣减商品库存
- 从用户账户余额扣除金额
完成上面的操作需要访问三个不同的微服务和三个不同的数据库。
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
但是当我们把三件事情看做一个"业务",要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。此时有可能出现此种情况:当库存不足时,订单创建失败;但账户余额已经扣减,并不会回滚,此时就出现了分布式事务问题。
解决分布式事务问题,需要以下一些分布式系统的基础知识作为理论指导。
2. 理论
2.1. CAP原理
- Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致
- Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝
- Partition tolerance (分区容错性):
- Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
- Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务
在分布式系统中,系统间的网络不能100%保证健康,一定会有故障的时候,而服务有必须对外保证服务。因此Partition Tolerance不可避免。
当节点接收到新的数据变更时,就会出现问题了:
如果此时要保证一致性,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。
如果此时要保证可用性,就不能等待网络恢复,那node01、node02与node03之间就会出现数据不一致。也就是说,在P一定会出现的情况下,A和C之间只能实现一个。
2.2. Base理论
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
2.3. 解决分布式事务的思路
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论,有两种解决思路:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。(软状态 + 最终一致)
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。(基本可用)
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC):
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
2.4. 总结
简述CAP定理内容?
分布式系统节点通过网络连接,一定会出现分区问题(P)
当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
elasticsearch集群是CP还是AP?
ES集群出现分区时,故障节点会被剔除集群,数据分片会重新分配到其它节点,保证数据一致。因此是低可用性,高一致性,属于CP
Nacos作为注册中心和配置中心分别是什么模式?
作为配置中心时是CP,保证一致性
作为注册中心时是AP,保证可用性
3. Seata入门
3.1. 概述
Seata事务管理中有三个重要的角色:
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata基于上述架构提供了四种不同的分布式事务解决方案:
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- SAGA模式:长事务模式,有业务侵入
模式 |
XA |
AT |
TCC |
SAGA |
一致性 |
强一致 |
弱一致 |
弱一致 |
最终一致 |
隔离性 |
完全隔离 |
基于全局锁隔离 |
基于资源预留隔离 |
无隔离 |
代码侵入 |
有 |
无 |
有,要编写三个接口 |
无 |
性能 |
无 |
非常好 |
非常好 |
非常好 |
场景 |
对一致性、隔离性有高要求的业务 |
基于关系型数据库的大多数分布式事务场景都可以 |
|
|
3.2. Seata部署TC
- 修改seata.conf
- nacos加配置
- 引依赖
- 本地加配置
registry { # tc服务的注册中心类,这里选择nacos,也可以是eureka、zookeeper等 type = "nacos" nacos { # seata tc 服务注册到 nacos的服务名称,可以自定义 application = "seata-tc-server" serverAddr = "127.0.0.1:8848" group = "DEFAULT_GROUP" namespace = "" cluster = "SH" username = "nacos" password = "nacos" } } config { # 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置 type = "nacos" # 配置nacos地址等信息 nacos { serverAddr = "127.0.0.1:8848" namespace = "" group = "SEATA_GROUP" username = "nacos" password = "nacos" dataId = "seataServer.properties" } }
<!--seata--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <exclusions> <!--版本较低,1.3.0,因此排除--> <exclusion> <artifactId>seata-spring-boot-starter</artifactId> <groupId>io.seata</groupId> </exclusion> </exclusions> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-spring-boot-starter</artifactId> <!--seata starter 采用1.4.2版本--> <version>${seata.version}</version> </dependency>
# 数据存储方式,db代表数据库 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true store.db.user=root store.db.password=123 store.db.minConn=5 store.db.maxConn=30 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.queryLimit=100 store.db.lockTable=lock_table store.db.maxWait=5000 # 事务、日志等配置 server.recovery.committingRetryPeriod=1000 server.recovery.asynCommittingRetryPeriod=1000 server.recovery.rollbackingRetryPeriod=1000 server.recovery.timeoutRetryPeriod=1000 server.maxCommitRetryTimeout=-1 server.maxRollbackRetryTimeout=-1 server.rollbackRetryTimeoutUnlockEnable=false server.undo.logSaveDays=7 server.undo.logDeletePeriod=86400000 # 客户端与服务端传输方式 transport.serialization=seata transport.compressor=none # 关闭metrics功能,提高性能 metrics.enabled=false metrics.registryType=compact metrics.exporterList=prometheus metrics.exporterPrometheusPort=9898
seata: registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址 type: nacos # 注册中心类型 nacos nacos: server-addr: 127.0.0.1:8848 # nacos地址 namespace: "" # namespace,默认为空 group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP application: seata-tc-server # seata服务名称 username: nacos password: nacos tx-service-group: seata-demo # 事务组名称 service: vgroup-mapping: # 事务组与cluster的映射关系 seata-demo: SH
3.3. XA模式
流程
RM一阶段的工作:
① 注册分支事务到TC
② 执行分支业务sql但不提交
③ 报告执行状态到TC
TC二阶段的工作:
- TC检测各分支事务执行状态a.如果都成功,通知所有RM提交事务b.如果有失败,通知所有RM回滚事务
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
特点
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则。
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
实现方式
- 方法加@GobalTransactional
- 修改配置
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
3.4. AT模式
流程
阶段一RM的工作:
- 注册分支事务
- 记录undo-log(数据快照)
- 执行业务sql并提交
- 报告事务状态
阶段二提交时RM的工作:
- 删除undo-log即可
阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前
特点
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚和提交
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但比XA模式要好很多
实现方式
- 方法加@GobalTransactional
- 修改配置
seata:
data-source-proxy-mode: XA # 开启数据源代理的AT模式
seata事务与seata事务
一阶段
- 事务A获取DB锁,进行更新前快照;执行SQL;获取全局锁,并在TC插入一条记录(事务id,操作表table,主键pk),提交事务,并进行更新后快照,释放DB锁
- 事务B获取DB锁,进行更新前快照;执行SQL;获取全局锁,但因为TC存在记录,获取失败;采用乐观锁思想,进行30次重试,间隔10ms;获取全局锁失败,超时释放DB锁
二阶段
- 事务A获取DB锁,将更新后快照与当前数据库记录比对,若一致,则进行事务提交,删除快照;若不一致,则进行事务回滚,根据更新前快照恢复数据,并删除快照。
注意点:
当非seata事务与seata事务同时进行时,由于非seata事务没有获取全局锁;二阶段seata事务进行更新后快照比对失败后会进行记录异常,由人工介入。
3.5. TCC模式(难点)
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
特点
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
3.5.1. 空回滚与业务悬挂
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚。
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。
执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂
3.5.2. 实现
package cn.itcast.account.service.impl; import cn.itcast.account.entity.AccountFreeze; import cn.itcast.account.mapper.AccountFreezeMapper; import cn.itcast.account.mapper.AccountMapper; import cn.itcast.account.service.AccountTCCService; import io.seata.core.context.RootContext; import io.seata.rm.tcc.api.BusinessActionContext; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Objects; import static cn.itcast.account.entity.AccountFreeze.State.CANCEL; @Slf4j @Service public class AccountTCCServiceImpl implements AccountTCCService { @Autowired private AccountMapper accountMapper; @Resource private AccountFreezeMapper accountFreezeMapper; /** * TCC模式第一阶段Try *(1)扣减正常的account表的余额 *(2)冻结资源:freeze——tbl * (3) 业务悬挂: try阶段cancel已经做了, * 我查找一次本地DB如果有一条状态为cancel的数据,就做业务悬挂 * @param userId * @param money */ @Override public void deduct(String userId, int money) { //获取事务id String xid = RootContext.getXID(); //处理业务悬挂 AccountFreeze freeze = accountFreezeMapper.selectById(xid); if (Objects.nonNull(freeze)) { // 说明并发场景下,别的线程提前执行完了try if (freeze.getState() == AccountFreeze.State.TRY || // 真正的业务悬挂:尾巴比头先做 freeze.getState() == CANCEL) { return; } } //1.扣减可用金额 accountMapper.deduct(userId,money); //2.新增冻结资源 AccountFreeze entity = new AccountFreeze(); entity.setXid(xid); entity.setUserId(userId); entity.setFreezeMoney(money); entity.setState(AccountFreeze.State.TRY); accountFreezeMapper.insert(entity); } /** * 第二阶段confirm函数 * (1)删除冻结资源-xid * (2) 幂等:多次相同操作返回相同的结果 * 第一次:返回true,数据成功删除,符合预期 * 第二次:返回false,数据没有删除成功说明已经被人删除了,所以我本身就可以不删除,符合预期 * N次:SQL执行异常,应该删除但是没删除所以返回false,刚好需要重试 * @param context * @return */ @Override public boolean confirm(BusinessActionContext context) { //1.删除冻结资源 int rows = accountFreezeMapper.deleteById(context.getXid()); //2.返回结果 return rows == 1; } /** * 第二阶段cancel函数 * (1)根据冻结资源恢复account表数据 * (2)释放冻结资源 * (3)空回滚: cancel阶段try没做 * (4)幂等 * @param context * @return */ @Override public boolean cancel(BusinessActionContext context) { // 1-根据冻结资源恢复account表数据 // 1.1 查找当前冻结资源信息 AccountFreeze freeze = accountFreezeMapper.selectById(context.getXid()); //空回滚 if (Objects.isNull(freeze)) { AccountFreeze entity = new AccountFreeze(); entity.setXid(context.getXid()); entity.setUserId(context.getActionContext("userId").toString()); entity.setFreezeMoney(0); entity.setState(CANCEL); accountFreezeMapper.insert(entity); return true; } //幂等 if (freeze.getState() == CANCEL) { return true; } //1.2恢复数据 String userId = freeze.getUserId(); Integer money = freeze.getFreezeMoney(); accountMapper.refund(userId,money); //2.释放冻结资源 AccountFreeze updateEntity = new AccountFreeze(); updateEntity.setXid(context.getXid()); updateEntity.setState(CANCEL); updateEntity.setFreezeMoney(0); int rows = accountFreezeMapper.updateById(updateEntity); return rows == 1; } }
资源怎么预留?
将传入的资源(userId,money)存在资源冻结表里,并标记执行状态(try,confirm,cannel)
怎么处理空回滚,业务悬挂以及幂等?
处理业务悬挂,先根据事务id查询资源冻结表,若查询结果不为空且状态为try,说明并发场景下其他线程先进行了try操作;若不为空且状态为cannel,说明发生了业务悬挂,两种情况直接return;
3.6. SAGA模式
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
3.6.1. 特点
优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写