2.1. 什么是分布式事务
2.1.1 概念
首先我们看看项目中的下单业务整体流程:
交互流程如下:
用户创建订单,客户端请求交易服务创建订单
创建订单成功,交易服务请求购物车服务清理购物车,请求库存服务扣减库存
由于订单、购物车、商品分别是三个不同的微服务,而每个微服务都有自己独立的数据库,一次下单事务需要订单、购物车、商品服务分别执行自己的本地事务,是跨多个数据库完成这次下单的事务,像这种,在分布式系统环境下由多个服务通过网络通信协作去完成一次事务,这称之为分布式事务。可简单理解为一个分布式事务等于多个本地事务。
2.1.2 测试分布式事务
分布式事务是无法通过单个数据库事务去控制的,每个微服务都有自己的数据库,一次下单事务需要订单、购物车、商品服务分别执行自己的本地事务,其中一个执行失败其它本地事务是无法回滚的,比如:扣减库存失败无法回滚清理购物车及创建订单的事务。
下边我们测试下单扣减库存,首先部署交易微服务,交易微服务是第一天布置的作业,这里有几点需要注意:
1.需要在ItemClient接口中增加扣减库存的方法,以供交易服务远程调用,还需要在ItemClientFallbackFactory增加扣减库存的降级逻辑,即现在已有的代码【已实现】
@PutMapping("/stock/deduct") public void deductStock(@RequestBody List<OrderDetailDTO> items);
2.商品服务中的OrderDetailDTO移到了hm-api工程,凡是引用该类的微服务都需要统一引用hm-api下的OrderDetailDTO【已实现】
3.交易服务启动类 trade-service 注意添加扫描hm-api下的feign接口
@MapperScan("com.hmall.trade.mapper") @EnableFeignClients(basePackages = {"com.hmall.api"}) @SpringBootApplication(scanBasePackages = {"com.hmall.trade","com.hmall.api"})
4.暂时将“UserContext.getUser()”获取用户id代码固定为“1”。
我们在下单方法代码的最后位置制造异常,如下【注意要有下面的:@Transactional】:
@Override @Transactional public Long createOrder(OrderFormDTO orderFormDTO) { .... if(1==1){ throw new RuntimeException("测试异常"); } return order.getId(); }
预期结果是:当扣减库存成功,下单失败,最终扣减库存事务进行回滚。
此时商品库存我们截个图
下边启动:item-service、trade-service,打开交易服务的swagger文档找到下单接口进行测试:
从商品数据库找一个商品id填入请求参数
最终抛出异常,查看商品的库存正常扣除,但是订单数据没有创建成功,最终导致数据不一致。
测试结论:
通过本地事务控制注解 @Transactional是无法控制分布式事务的。
2.1.3 认识分布式事务
下边为了简化分析过程 我们仍然以下单扣减库存为例说明:
在单体架构下实现下单减库存,如下图:
用户请求订单服务,订单服务请求数据库完成创建订单扣减库存,通过本地事务实现,代码如下:
begin transaction; //1.本地数据库操作:创建订单 //2.本地数据库操作:减去库存 commit transation;
如果是在微服务架构下,如下图:
用户请求订单服务下单,订单服务请求库存服务扣减库存。
此时代码变为下边这样:
begin transaction; //1.本地数据库操作:创建订单 //2.远程调用:减去库存 commit transation;
设想: 当远程调用扣减库存成功了,由于网络问题远程调用并没有返回,此时本地事务提交失败就回滚了创建订单的操作,此时订单没有创建成功而库存却扣减了,最终就导致了下单扣减库存整个事务的数据不一致。
因此在分布式架构下,基于数据库的事务控制无法满足要求,下单操作是一次本地事务,扣减库存是一次本地事务,两次本地事务组成一个完整的事务即下单扣减库存,数据库的本地事务只能控制一次本地事务即下单操作控制下单的本地事务,扣减库存操作控制扣减库存的本地事务,无法保证下单和扣减库存整体事务的原子性和一致性。
造成分布式事务无法控制的根本原因是不同业务的数据通常不在一个数据库中或者不在一个系统中,一次事务需要由多个服务或多个系统远程调用协作完成,远程协作依赖网络,由于网络问题会导致整体事务不能正常完成。
分布式事务的典型场景是:业务的数据分布在多个数据库,一次事务操作需要跨多个数据库去完成,
需要由多个服务远程调用协作去完成,远程调用依赖网络,由于网络问题会导致整体事务不能正常完成。
如下图所示:
还有非典型的分布式事务场景也需要了解下。
1)单服务请求多数据库完成一次事务
下图中虽然没有跨服务远程调用但一次事务请求两个不同的数据库也属于分布式事务的场景,创建订单会和订单数据库创建连接通过一次本地事务提交数据,减库存会和商品数据库创建连接通过一次本地事务提交数据,因为下单扣减库存是通过两个数据库连接完成,仍然是多次本地事务共同完成一个完整的事务。
2)多服务请求单数据库完成一次事务
下图中虽然用的一个数据库但是通过跨服务远程调用去完成一次事务,也属于分布式事务的场景。
思考下这种场景为什么也属于分布式事务?
2.1.4 小结
什么是本地事务?
基于应用自己的关系型数据库的事务称为本地事务,在service方法通过添加@Transactional注解进行本地事务控制。
什么是分布式事务?
在分布式系统环境下由多个服务通过网络通信协作去完成一次事务,这称之为分布式事务。
分布式事务的场景有哪些?
多个微服务之间通过远程调用完成一次分布式事务,即:跨服务完成一次事务
单服务请求多数据库完成一次事务,即:跨数据源完成一次事务
多服务请求单数据库完成一次事务,即:跨服务完成一次事务
2.2. CAP原理
遇到了分布式事务的场景我们该如何去进行事务控制呢,本节学习如何选型分布式事务的控制方案。
什么是CAP原理
首先需要理解什么是CAP原理,明白了CAP原理有助于我们去选型分布式事务的控制方案。
CAP是 Consistency、Availability、Partition tolerance三个词语的缩写,分别表示一致性、可用性、分区容忍性。使用下图去理解CAP:下图表示客户端经过网关访问订单服务,库存服务
一致性: 向系统写一个新数据再次读取到的也一定是这个新数据。拿上图举例,请求订单服务下单,订单服务请求库存服务扣减库存,只要下单成功则库存扣减成功。
可用性:任何时间都可以访问订单服务和库存服务,系统保证可用。
分区容忍性:也叫分区容错性,分布式系统在部署时服务器可能部署在不同的网络分区,比如上图中订单服务在北京,库存服务在上海,由于处于不同的网络分区如果网络通信异常就会导致节点 之间无法通信,当出现由于网络问题导致节点 之间无法通信,此时仍然是对外提供服务的这叫做满足分区容忍性。
CAP理论要强调在分布式系统中C、A、P这三点不能全部满足。
由于是分布式系统就要满足分区容忍性,因为分布式系统难免存在网络分区,不能因为网络异常导致整个系统不可用,所以P是一定要满足的。满足P,那么C和A不能同时满足。拿上图举例说明:
当订单服务与库存服务出现网络通信异常,订单服务无法访问库存服务,此时如果要保证数据一致性则下单接口必须不可用,如果要保证可用性数据将出现不一致。
学习了CAP理论我们知道进行分布式事务控制要在C和A中作出取舍,进行分布式事务控制要么保证CP要么保证AP。具体要根据应用场景进行判断,下边举例说明CP和AP业务场景的例子。
符合CP的场景:满足C舍弃A,强调一致性。
金融系统:一般需要在多个账户之间进行交易或资金转移的操作通常需要满足CP,这是因为在这种场景下,数据的一致性是至关重要的,确保不会发生资金丢失、重复扣款或其他意外情况,源账号和目标账号的转账结果要么都成功要么都失败,不会存在一个成功一个失败的情况。
库存系统:在多个仓库之间进行库存转移或销售操作时,需要确保库存的一致性,防止商品超卖或库存混乱。
订票系统:需要确保预订信息的一致性,避免出现同一个资源被多次预订的问题。
Zookeeper:可作为注册中心,支持CP,拿主节点选举举例,当主节点异常进行选举,选举期间所有节点不可用,保证数据的一致性。
Redis:Redis主从模式是CP模式,当主从通信异常此时无法向主节点写数据。
Nacos:Nacos也支持CP,不过默认是AP模式,当客户端注册为非临时节点时为CP模式,注册为非临时节点就需要实时上报心跳,即使在一段时间内未收到心跳信息,该实例仍然会保留在服务列表中,适用于配置中心。
符合AP的场景:满足A舍弃C,强调可用性。
AP强调的是可用性,允许短暂的不一致但是要保证最终一致性,在实际应用中符合AP的场景较多。
订单退款:退款后状态为退款中,24小时后退款金额到帐。
积分系统:注册送积分,注册成功积分在24小时后到账。
跨行转账:一般转账支持CP,还有的支持AP,源账号扣减金额后需要等一段时间目标账户才到账,或者源账号扣款后由于目标账号有问题过一段时间将转账金额退回到源账户。
MySQL主从复制:支持AP,向主节点写数据,异步同步到的从节点。
Nacos:默认支持AP,即临时节点的情况,会实时上报心跳,如果一段时间内未收到心跳信息,Nacos 会将该实例标记为不可用并从服务列表中移除。
在生产中AP场景应用的更多,强调的是可用性,允许出现短暂不一致,最终达到数据一致性。
2.4. 安装Seata
2.4.1 认识Seata
解决分布式事务的方案有很多,但实现起来都比较复杂,因此我们一般会使用开源的框架来解决分布式事务问题。在众多的开源分布式事务框架中,功能最完善、使用最多的就是阿里巴巴在2019年开源的 Seata 了。
https://seata.io/zh-cn/docs/overview/what-is-seata.html
其实分布式事务产生的一个重要原因,就是参与事务的多个分支事务互相无感知,不知道彼此的执行状态。因此解决分布式事务的思想非常简单:
就是找一个统一的事务协调者,与多个分支事务通信,检测每个分支事务的执行状态,保证全局事务下的每一个分支事务同时成功或失败即可。大多数的分布式事务框架都是基于这个理论来实现的。
Seata也不例外,在Seata的事务管理中有三个重要的角色:
- TC (Transaction Coordinator) -事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚,相当于监控中心。
- TM (Transaction Manager) -事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) -资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata的工作架构如图所示:
其中,TM和RM可以理解为Seata的客户端部分,引入到参与事务的微服务依赖中即可。将来TM和RM就会协助微服务,实现本地分支事务与TC之间交互,实现事务的提交或回滚。
而TC服务则是事务协调中心,是一个独立的微服务,需要单独部署。
Seata支持四种不同的分布式事务解决方案:
- XA:强一致性,唯一一个强一致,无业务侵入,注解即可
- AT:最终一致性,无业务侵入,注解即可,是默认的模式
- TCC:最终一致性,有业务侵入,适合复杂定制化业务
- SAGA:最终一致性,有业务侵入,适合长事务模式,较少使用
2.4.2 准备数据库表
Seata支持多种存储模式,但考虑到持久化的需要,我们一般选择基于数据库存储。执行课前资料提供的
《seata-tc.sql》,导入数据库表【默认已实现】:
2.4.3 准备配置文件
课前资料准备了一个seata目录,其中包含了seata运行时所需要的配置文件:
注意:需要修改自己虚拟机的ip地址和MySQL数据库的账户和密码。
我们将整个seata文件夹拷贝到虚拟机的/root目录【已实现】:
2.4.4 Docker部署
以下Docker部署均已实现,大家了解安装、运行步骤即可
如果镜像下载困难,也可以把课前资料提供的镜像上传到虚拟机并加载:
然后,将课前资料中的seata-1.5.2.tar上传至虚拟机的/root目录。
首先导入镜像文件:
docker load -i seata-1.5.2.tar
进入虚拟机的/root目录执行下面的命令:
注意修改虚拟机的ip地址
docker run --name seata \ -p 8099:8099 \ -p 7099:7099 \ -e SEATA_IP=192.168.101.68 \ -v /root/seata:/seata-server/resources \ -d \ seataio/seata-server:1.5.2
查询容器
Seata控制台:http://192.168.101.68:7099/,账号/密码:admin/admin
2.5. 微服务集成Seata
接下来我们实现下单扣减库存的需求,参与分布式事务的每一个微服务都需要集成Seata,下单扣减库存涉及两个微服务,即交易微服务、商品微服务,下边我们以交易微服务为例在trade-service中集成Seata。
2.5.1.引入依赖
分别在交易服务、商品服务引入seata依赖【引入标准:每一个参与调用链路的微服务都需要】。
<!--seata--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>
2.5.2.Seata配置
分别在交易服务、商品服务引入seata配置。
内容如下:
seata: registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址 type: nacos # 注册中心类型 nacos nacos: server-addr: 192.168.101.68:8848 # nacos地址 namespace: "" # namespace,默认为空 group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP application: seata-server # seata服务名称 tx-service-group: hmall # 事务组名称 service: vgroup-mapping: # 事务组与tc集群的映射关系 hmall: "default"
tx-service-group: 事务组是seata进行全局事务管理的逻辑单元,通常按项目为单位进行定义
vgroup-mapping:事务组与tc集群的映射,配置事务组名称与seata TC集群的映射关系
"defualt":seata集群名称,seata服务实例上传至nacos,在nacos中查看seata集群如下:
2.5.3.seata数据库表
seata的客户端在解决分布式事务的时候需要记录一些中间数据,保存在数据库中。因此我们要先准备一个这样的表。将课前资料的seata-at.sql分别文件导入hm-trade、hm-item两个数据库中【已实现】:
OK,至此为止,微服务整合的工作就完成了。
2.5.4.实现分布式事务
前边在没有使用Seata是无法控制分布式事务的,接下来我们用seata控制分布式事务。
我们找到trade-service模块下的com.hmall.trade.service.impl.OrderServiceImpl类中的createOrder方法,也就是下单业务方法。
将其上的@Transactional注解改为Seata提供的@GlobalTransactional:
@GlobalTransactional注解就是在标记事务的起点,将来TM就会基于这个方法判断全局事务范围,初始化全局事务。
我们重启trade-service、item-service服务,再次测试分布式事务:
1.仍然在下单方法最后添加异常,扣减库存成功下单失败观察最终扣减库存是否会回滚。
2.下单正常但扣减库存失败观察最终下单数据是否回滚。
那么,Seata是如何解决分布式事务的呢?
2.6. Seata 工作模式
Seata支持四种不同的分布式事务解决方案,Seata默认使用的是AT模式。
- XA:强一致性,唯一一个强一致,无业务侵入,注解即可
- AT:最终一致性,无业务侵入,注解即可,是默认的模式
- TCC:最终一致性,有业务侵入,适合复杂定制化业务
- SAGA:最终一致性,有业务侵入,适合长事务模式,较少使用
这里我们以XA模式和AT模式来给大家讲解其实现原理。
2.6.1 AT模式
AT模式的基本流程图:
阶段一RM的工作:
- 注册分支事务
- 记录undo-log(数据快照)
- 执行业务sql并提交
- 报告事务状态
阶段二提交时RM的工作:
- 删除undo-log即可
阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前
下边我们用一个真实的业务来梳理下AT模式的原理。
比如,现在有一个数据库表,记录用户余额:
| id | money |
1 |
100 |
其中一个分支业务要执行的SQL为:
update tb_account set money = money - 10 where id = 1
AT模式下,当前分支事务执行流程如下:
一阶段:
TM发起并注册全局事务到TCTM调用分支事务- 分支事务准备执行业务SQL
RM拦截业务SQL,根据where条件查询原始数据,形成快照。
{ "id": 1, "money": 100 }
RM执行业务SQL,提交本地事务,释放数据库锁。此时 money = 90RM报告本地事务状态给TC
二阶段:
TM通知TC事务结束TC检查分支事务状态
- 如果都成功,则立即删除快照
- 如果有分支事务失败,需要回滚。读取快照数据({"id": 1, "money": 100}),使用快照恢复数据库,此时数据库再次恢复为100。
流程图:
演示AT模式
下边通过断点调试演示AT模式的整体流程:
首先在提交订单方法中模拟异常并打断点
当代码执行到断点处每个分支事务已经执行完成,
通过观察seata控制台,已经开启一个全局事务
分支事务如下
上图中其中一个分支事务27595734695333932即商品服务已经成功扣减库存,我们可以观察hm-item数据库的item表的stock字段值
当抛出异常后全局事务回滚,每个分支事务全部回滚
再次查询hm-item数据库的item表,发现stock库存值已恢复。
2.6.2 XA模式
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
XA是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况:
异常情况:
一阶段:
- 事务协调者通知每个事务参与者执行本地事务
- 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
- 事务协调者基于一阶段的报告来判断下一步操作
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
Seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图:
RM一阶段的工作:
- 注册分支事务到
TC - 执行分支业务sql但不提交
- 报告执行状态到
TC
TC二阶段的工作:
TC检测各分支事务执行状态
- 如果都成功,通知所有RM提交事务
- 如果有失败,通知所有RM回滚事务
RM二阶段的工作:
- 接收
TC指令,提交或回滚事务
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
简述AT模式与XA模式最大的区别是什么?
XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。XA模式强一致;AT模式最终一致
可见,AT模式使用起来更加简单,无业务侵入,性能更好。因此企业90%的分布式事务都可以用AT模式来解决。
2.6.3 TCC模式
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
- Try:资源的检测和预留;
- Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。
- Cancel:预留资源释放,可以理解为try的反向操作。
举例,一个扣减用户余额的业务。假设账户A原来余额是100,需要余额扣减30元。
- 阶段一( Try ):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30
初识余额:
余额充足,可以冻结:
此时,总金额 = 冻结金额 + 可用金额,数量依然是100不变。事务直接提交无需等待其它事务。
- 阶段二(Confirm):假如要提交(Confirm),则冻结金额扣减30
确认可以提交,不过之前可用金额已经扣减过了,这里只要清除冻结金额就好了:
此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元
- 阶段二(Canncel):如果要回滚(Cancel)则冻结金额扣减30,可用余额增加30
需要回滚,那么就要释放冻结金额,恢复可用金额:
Seata中的TCC模型依然延续之前的事务架构,如图:
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
2.6.4 面试题
seata是怎么进行分布式事务控制的?/Seata的工作原理?
作业
对下单方法分布式事务控制
实现完整的分布式事务控制并进行测试,流程如下:
提示:
根据上图分析可知下单方法的分布式事务控制范围包括:下单、清理购物车、扣减库存,分别隶属交易服务、购物车服务、商品服务。
要想使用seata控制下单方法的分布式事务,需要将相关微服务集成 seata。
实现完成参考老师课堂上的方法进行分布式事务控制的测试。
对支付方法分布式事务控制
除下单业务外,用户如果选择余额支付,前端会将请求发送到pay-service模块。而这个模块要做三件事情:
- 直接从user-service模块调用接口,扣除余额付款
- 更新本地(pay-service)交易流水表状态
- 通知交易服务(trade-service)更新其中的业务订单状态
流程如图:
显然,这里也存在分布式事务问题。
前端会提交支付请求,业务接口的入口在com.hmall.pay.controller.PayController类的tryPayOrderByBalance方法:
对应的service方法如下:
@Override @Transactional public void tryPayOrderByBalance(PayOrderDTO payOrderDTO) { // 1.查询支付单 PayOrder po = getById(payOrderDTO.getId()); // 2.判断状态 if(!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())){ // 订单不是未支付,状态异常 throw new BizIllegalException("交易已支付或关闭!"); } // 3.尝试扣减余额 userClient.deductMoney(payOrderDTO.getPw(), po.getAmount()); // 4.修改支付单状态 boolean success = markPayOrderSuccess(payOrderDTO.getId(), LocalDateTime.now()); if (!success) { throw new BizIllegalException("交易已支付或关闭!"); } // 5.修改订单状态 tradeClient.markOrderPaySuccess(po.getBizOrderNo()); }
将上边的方法改为使用seata控制分布式事务并进行测试。
测试方法:
首先通过交易服务提交订单,拿到业务订单号
然后通过支付服务创建支付单,如下:
再通过支付服务完成余额支付,如下:
测试完成注意观察相关数据库表的数据变化是否符合预期。