事务
1.1 本地事务
本地事务,也就是传统的单机事务。在传统数据库事务中,必须要满足四个原则:
A:原子性(Atomicity),一个事务中的所有操作,要么全部完成,要么全部不完成
C:一致性(Consistency),在一个事务执行之前和执行之后数据库都必须处于一致性状态
I:隔离性(Isolation),在并发环境中,当不同的事务同时操作相同的数据时,事务之间互不影响
D:持久性(Durability),指的是只要事务成功结束,它对数据库所做的更新就必须永久的保存下来
数据库事务在实现时会将一次事务涉及的所有操作全部纳入到一个不可分割的执行单元,该执行单 元中的所有操作要么都成功,要么都失败,只要其中任一操作执行失败,都将导致整个事务的回滚
1.2 分布式事务
分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务,例如:
- 跨数据源的分布式事务
- 跨服务的分布式事务
以及上面两种情况的综合
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。例如电商行业中比较常见的下单付款案例,包括下面几个行为:
zs 买了一瓶玻璃水
- 创建新订单
- 扣减商品库存
- 从用户账户余额扣除金额
成上面的操作可能需要访问三个不同的微服务和三个不同的数据库
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
但是当我们把三件事情看做一个"业务",要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务了。
此时ACID难以满足,这是分布式事务要解决的问题
1.3 分布式事务操作
数据库:
tab_user: id username money
tab_product:id ,name,price
tab_storage:id pid kucun
tab_order:id pid num totalmoney
DROP TABLE IF EXISTS `tab_order`;
CREATE TABLE `tab_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`pid` int(11) DEFAULT NULL,
`num` int(11) DEFAULT NULL,
`totalmoney` double DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of tab_order
-- ----------------------------
INSERT INTO `tab_order` VALUES ('1', '1', '12', '12');
INSERT INTO `tab_order` VALUES ('2', '2', '12', '24');
-- ----------------------------
-- Table structure for tab_product
-- ----------------------------
DROP TABLE IF EXISTS `tab_product`;
CREATE TABLE `tab_product` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`price` double(10,0) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of tab_product
-- ----------------------------
INSERT INTO `tab_product` VALUES ('1', 'test', '1');
INSERT INTO `tab_product` VALUES ('2', 'test2', '2');
-- ----------------------------
-- Table structure for tab_storage
-- ----------------------------
DROP TABLE IF EXISTS `tab_storage`;
CREATE TABLE `tab_storage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`pid` int(11) DEFAULT NULL,
`kucun` int(255) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of tab_storage
-- ----------------------------
INSERT INTO `tab_storage` VALUES ('1', '1', '100');
INSERT INTO `tab_storage` VALUES ('2', '2', '200');
-- ----------------------------
-- Table structure for tab_user
-- ----------------------------
DROP TABLE IF EXISTS `tab_user`;
CREATE TABLE `tab_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`money` int(255) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
-- ----------------------------
-- Records of tab_user
-- ----------------------------
INSERT INTO `tab_user` VALUES ('1', 'zs', '100');
INSERT INTO `tab_user` VALUES ('2', 'ls', '200');
seata-demo:父工程,负责管理项目依赖
account-service:账户服务,负责管理用户的资金账户。提供扣减余额的接口
storage-service:库存服务,负责管理商品库存。提供扣减库存的接口
order-service:订单服务,负责管理订单。创建订单时,需要调用account-service和storage-service
理论
2.1 CAP
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
> - Consistency(一致性)
> - Availability(可用性)
> - Partition tolerance (分区容错性)
这三个指标不可能同时做到
一致性:
Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。
Availability (可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
在分布式系统中,系统间的网络不能100%保证健康,一定会有故障的时候,而服务有必须对外保证服务。因此Partition Tolerance不可避免。
当节点接收到新的数据变更时,就会出现问题了:
如果此时要保证**一致性**,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。
如果此时要保证**可用性**,就不能等待网络恢复,那node01、node02与node03之间就会出现数据不一致。
也就是说,在P一定会出现的情况下,A和C之间只能实现一个。
2.2 BASE理论
BASE理论是对CAP的一种解决思路,包含三个思想:
- Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
2.3 解决分布式事务的思路
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论,有两种解决思路:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC):
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
初识Seata
Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:Apache Seata,其中的文档、播客中提供了大量的使用说明、源码分析。
3.1 Seata的架构
Seata事务管理中有三个重要的角色:
- TC (Transaction Coordinator) -事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) -事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) -资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
整体的架构如图:
Seata基于上述架构提供了几种不同的分布式事务解决方案:
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC模式:最终一致的分阶段事务模式,有业务侵入
TRY 业务
CONFIRM 提交
CANCEL 回滚
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- SAGA模式:长事务模式,有业务侵入
无论哪种方案,都离不开TC,也就是事务的协调者
3.2 部署TC
seata的部署和集成_seata的部署和集成.md-CSDN博客
3.2.1 下载seata-server包
3.2.2 解压
3.2.3 修改配置文件
修改conf目录下的registry.conf文件:
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
nacos {
application = "seata-server"
serverAddr = "localhost:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
config {
# file、nacos 、apollo、zk、consul、etcd3
type = "nacos"
# 配置nacos地址等信息
nacos {
serverAddr = "localhost:8848"
namespace = ""
group ="DEFAULT_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
3.2.4 在nacos添加配置
特别注意,为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心。因此服务端配置文件seataServer.properties文件需要在nacos中配好
配置内容如下:
# 数据存储方式,db代表数据库
store.mode=db
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=root
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
3.2.5 创建数据库以及使用的表
这些表主要记录全局事务、分支事务、全局锁信息:
-- ----------------------------
-- Table structure for branch_table
-- ----------------------------
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint NOT NULL,
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`transaction_id` bigint NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`status` tinyint NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`gmt_create` datetime(6) NULL DEFAULT NULL,
`gmt_modified` datetime(6) NULL DEFAULT NULL,
PRIMARY KEY (`branch_id`) USING BTREE,
INDEX `idx_xid`(`xid` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of branch_table
-- ----------------------------
-- ----------------------------
-- Table structure for distributed_lock
-- ----------------------------
DROP TABLE IF EXISTS `distributed_lock`;
CREATE TABLE `distributed_lock` (
`lock_key` char(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`lock_value` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`expire` bigint NULL DEFAULT NULL,
PRIMARY KEY (`lock_key`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of distributed_lock
-- ----------------------------
INSERT INTO `distributed_lock` VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` VALUES ('TxTimeoutCheck', ' ', 0);
-- ----------------------------
-- Table structure for global_table
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`transaction_id` bigint NULL DEFAULT NULL,
`status` tinyint NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`timeout` int NULL DEFAULT NULL,
`begin_time` bigint NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_status_gmt_modified`(`status` ASC, `gmt_modified` ASC) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of global_table
-- ----------------------------
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`transaction_id` bigint NULL DEFAULT NULL,
`branch_id` bigint NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0:locked ,1:rollbacking',
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE,
INDEX `idx_branch_id`(`branch_id` ASC) USING BTREE,
INDEX `idx_xid_and_branch_id`(`xid` ASC, `branch_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of lock_table
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8;
3.2.5 启动tc
此时tc就之策成功了
3.3 微服务集成seata
以order-service为例来演示。
3.3.1.引入依赖
首先,在order-service中引入依赖:
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!--seata starter 采用1.4.2版本-->
<version>${seata.version}</version>
</dependency>
3.3.2 配置TC地址
在order-service中的application.yml中,配置TC服务信息,通过注册中心nacos,结合服务名称获取TC地址:
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-server # seata服务名称
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: SH
微服务如何根据这些配置寻找TC的地址呢?
我们知道注册到Nacos中的微服务,确定一个具体实例需要四个信息:
- namespace:命名空间
- group:分组
- application:服务名
- cluster:集群名
以上四个信息,在刚才的yaml文件中都能找到
namespace为空,就是默认的public
结合起来,TC服务的信息就是:public@DEFAULT_GROUP@seata-server@SH,这样就能确定TC服务集群了。然后就可以去Nacos拉取对应的实例信息了。
3.3.3 其它服务
其它两个微服务也都参考order-service的步骤来做,完全一样。
实践
4.1.两阶段提交/XA
两阶段提交,顾名思义就是要分两步提交。存在一个负责协调各个本地资源管理器的事务管理器,本地资源管理器一般是由数据库实现,事务管理器在第一阶段的时候询问各个资源管理器是否都就绪?如果收到每个资源的回复都是 yes,则在第二阶段提交事务,如果其中任意一个资源的回复是 no, 则回滚事务。
第一阶段(prepare):事务管理器向所有本地资源管理器发起请求,询问是否是 ready
状态,所有参与者都将本事务能否成功的信息反馈发给协调者;
第二阶段(commit/rollback):事务管理器根据所有本地资源管理器的反馈,通知所有本地资源管理器,步调一致地在所有分支上提交或者回滚。
存在的问题:
同步阻塞:当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。
单点故障:一旦事务管理器出现故障,整个系统不可用 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
不确定性:当协事务管理器发送 commit 之后,并且此时只有一个参与者收到了
commit,那么当该参与者与事务管理器同时宕机之后,重新选举的事务管理器无法确定该条消息是否提交成功。
📌
一阶段:
- 事务协调者通知每个事物参与者执行本地事务
- 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
- 事务协调者基于一阶段的报告来判断下一步操作
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
4.1.1 两阶段解决方案
2PC传统方案是在数据库层面实现分布式事务,为了统一处理模型和接口标准XA,国际组织定义了分布式事务处理模型DTP
4.1.2 DTP角色
AP(Application Program):即应用程序,可以理解为使用DTP分布式事务的程序。
RM(Resource Manager):即资源管理器,可以理解为事务的参与者,一般情况下是指一个数据库实例,执行实际业务操作,通过 资源管理器对该数据库进行控制,资源管理器控制着分支事务;
TM(Transaction Manager):事务管理器,控制全局事务,通过XA接口通知RM事务开始、结束、回滚、提交,提交阶段结束释放资源锁
DTP规定TM和RM之间的通讯接口规范为XA
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
TM,RM,AP之间的交互方式:
- TM向AP提供 应用程序编程接口,AP通过TM提交及回滚事务。
- TM交易中间件通过XA接口来通知RM数据库事务的开始、结束以及提交、回滚等
4.1.3 Seata的XA模型
seata对原始的XA模式做了简单的封装和改造,以适应自己的事务模型,基本架构如图
RM一阶段的工作:
① 注册分支事务到TC
② 执行分支业务sql但不提交
③ 报告执行状态到TC
TC二阶段的工作:
- TC检测各分支事务执行状态
a.如果都成功,通知所有RM提交事务
b.如果有失败,通知所有RM回滚事务
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
4.1.4 XA优缺点
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则。
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
4.1.5 实现XA
Seata的starter已经完成了XA模式的自动装配,实现非常简单,步骤如下
seata:
data-source-proxy-mode: XA
给发起全局事务的入口方法添加@GlobalTransactional注解:
package com.example.service.impl;
import com.aaa.util.Result;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.comm.entity.TabOrder;
import com.comm.entity.TabProduct;
import com.example.feign.AccountService;
import com.example.feign.ProductService;
import com.example.mapper.TabOrderMapper;
import com.example.service.ITabOrderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* <p>
* 服务实现类
* </p>
*
* @author 于永利
* @since 2023-08-23
*/
@Service
public class TabOrderServiceImpl extends ServiceImpl<TabOrderMapper, TabOrder> implements ITabOrderService {
@Resource
private AccountService accountService;
@Resource
private ProductService productService;
@Resource
private ITabOrderService orderService;
@Override
@GlobalTransactional
public Object addOrder(Integer uid, Integer pid, Integer num) {
Result proByIdAndNum = productService.getProByIdAndNum(num, pid);
Object data = proByIdAndNum.getData();
// 转化为对象
ObjectMapper objectMapper = new ObjectMapper();
TabProduct product = objectMapper.convertValue(data, TabProduct.class);
// 金额减少
Integer price = product.getPrice();
String money=(price*num)+"";
//accountService.buyPro(Integer.decode(money),uid);
accountService.zz(uid,Integer.decode(money));
// 添加订单的信息
TabOrder order = new TabOrder();
order.setUser(uid);
order.setPid(pid);
order.setPrice(price);
order.setNum(num);
orderService.save(order);
return Result.success(true);
}
}
重启服务并测试
重启order-service,再次测试,发现无论怎样,三个微服务都能成功回滚。
4.2. TCC
4.2.1 概述
关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC 事务机制相比于上面介绍的 XA,解决了其几个缺点:
解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
数据一致性,有了补偿机制之后,由业务活动管理器控制一致性 TCC(Try Confirm Cancel)
Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm
操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。
Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。
在 Try 阶段,是对业务系统进行检查及资源预览,比如订单和存储操作,需要检查库存剩余数量是否够用,并进行预留,预留操作的话就是新建一个可用库存数量字段,Try 阶段操作是对这个可用库存数量进行操作。基于 TCC 实现分布式事务,会将原来只需要一个接口就可以实现的逻辑拆分为 Try、Confirm、Cancel 三个接口,所以代码实现复杂度相对较高
分布式事务执行成功:
执行失败:
4.2.2 Seata的TCC
4.2.3 TCC优缺点
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
4.2.4 TCC解决方案
tcc-transaction、Hmily、ByteTCC、EasyTransaction等
处理三种异常(均可在数据库中增加相应表格来记录,记录全局事务ID以及相关操作是否已经执行),需要自己手写这三个方法,所以代码侵入性很强
需要考虑业务悬挂情况:
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。解决办法:执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂
需要考虑幂等性的情况:
微服务因为某种原因失联了,客户端发送n次信息,解决方案:只计算一次
需要考虑空回滚的情况:
在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚。给TC发命令告诉他已经完成了,要不然会一直回滚。
解决办法:执行cancel操作时,应当判断try是否已经执行,如果尚未执行,则应该空回滚。
4.2.5 实现TCC
A向B转账例子
优化前:
账号A:(如果Try没有执行,如果执行了cancel,便增加了30元)
账号B:(如果在Try中增加了30元,便可以花掉,如果再执行cancel,没有钱扣掉了咋整)
TCC优化后
账号A:
账号B:(将增加前放置在Confirm中,最后执行,不再需要cancel,其实我觉得不够完美,总感觉有些问题,B账户confirm执行失败咋整呢,哦,多次执行,再有问题人工介入)
解决空回滚和业务悬挂问题,必须要记录当前事务状态,是在try、还是cancel?
1)思路分析
这里我们定义一张表:
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL,
`user_id` int(11) DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) unsigned DEFAULT '0' COMMENT '冻结金额',
`state` int(1) DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
其中:
💡
- xid:是全局事务id
- freeze_money:用来记录用户冻结金额
- state:用来记录事务状态
那此时,我们的业务开怎么做呢?
💡
- Try业务:
- 记录冻结金额和事务状态到account_freeze表
- 扣减account表可用金额
💡
- Confirm业务
- 根据xid删除account_freeze表的冻结记录
📌
- Cancel业务
- 修改account_freeze表,冻结金额为0,state为2
- 修改account表,恢复可用金额
👋
- 如何判断是否空回滚?
- cancel业务中,根据xid查询account_freeze,如果为null则说明try还没做,需要空回滚
💡
- 如何避免业务悬挂?
- try业务中,根据xid查询account_freeze ,如果已经存在则证明Cancel已经执行,拒绝执行try业务
接下来,我们改造account-service,利用TCC实现余额扣减功能。
2)声明TCC接口
TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,
我们在account-service项目中的`com.account.service`包中新建一个接口,声明TCC三个接口:
package com.example.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") Integer userId,
@BusinessActionContextParameter(paramName = "money")int money);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
3)编写实现类
在account-service服务中的`service.impl`包下新建一个类,实现TCC业务:
package com.example.service.impl;
import com.comm.entity.TabAccount;
import com.example.entity.AccountFreezeTbl;
import com.example.mapper.AccountFreezeTblMapper;
import com.example.mapper.TabAccountMapper;
import com.example.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
/**
* <p>
* 服务实现类
* </p>
*
* @author 于永利
* @since 2023-08-23
*/
@Service
public class AccountFreezeTblServiceImpl implements AccountTCCService {
@Resource
private TabAccountMapper accountMapper;
@Resource
private AccountFreezeTblMapper freezeMapper;
@Override
@Transactional
public void deduct(Integer userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
// 解决业务悬挂
AccountFreezeTbl oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze != null) {
return;
}
// 1.扣减可用余额
// 根据用户的id 查询用户的信息 并且 扣除金额
TabAccount account = accountMapper.selectById(userId);
// 开始减钱
account.setMoney(account.getMoney()-money);
accountMapper.updateById(account);
//accountMapper.deduct(userId, money);
// 2.记录冻结金额,事务状态
AccountFreezeTbl freeze = new AccountFreezeTbl();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(0);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务id
String xid = ctx.getXid();
// 2.根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
@Transactional
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
AccountFreezeTbl freeze = freezeMapper.selectById(xid);
// 解决空回滚
if (freeze == null) {
// 获取一下参数中的uid的值
int userId = Integer.parseInt(ctx.getActionContext("userId").toString());
freeze = new AccountFreezeTbl();
freeze.setXid(xid);
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(2);
freezeMapper.insert(freeze);
return true;
}
// 解决幂等性
if (freeze.getState() == 2) {
return true;
}
// 1.恢复可用余额
//accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
TabAccount account = new TabAccount();
account.setId(freeze.getUserId());
account.setMoney(freeze.getFreezeMoney());
accountMapper.updateById(account);
// 2.将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(2);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
4.3 可靠消息最终一致性
- 本地事务与消息发送的原子性问题:事务发起方在本地执行成功后必须将消息发送出去,否则便要丢弃消息,要么都成功,要么都失败
- 事务参与方接收消息的可靠性:事务参与方必须从消息中间件中接收到消息,如果接收失败可多次接收
- 消息重复性消费问题:事务参与方接受消息失败,多次发送,可能导致重复性消费,需要满足幂等性
4.3.1 Rocket MQ事务消息方案
4.3.2、执行流程
MQ发送方将事务消息发送至MQServer,MQ将事务标记为Prepared状态(不可消费)
MQServer回复MQ发送方,消息已经发送成功
MQ发送方通知执行本地事务
若本地事务执行成功,MQ发送方像MQServer发送提交信息,MQServer收到后将事务标记为可消费状态;如果本地事务执行失败,MQ发送方像MQServer发送回滚信息,MQServer收到后将删除事务消息
MQ订阅方消费成功后,像MQServer回应ACK,否则MQServer重复发送消息
事务回查:如果执行本地事务过程中,执行出错或者超时,那么MQServer会不断询问同组其它生产者来获取事务执行状态
4.3.3 RocketMQ总结
解决了本地事务与消息发送的原子性问题;保证了参与方接受消息的可靠性
适用于执行周期长,对实时性要求不高的场景
4.4 分布式事务解决方案之最大努力通知
4.4.1最大努力通知
即发起方通过一定机制尽最大努力将业务处理结果通知接收方
措施包括
具有消息重复通知机制:多次向接收方发送通知
消息校对机制:尽最大努力后发送方也没有收到消息,或者接收方消费消息之后需要重复消费,那么此时接收方可以主动向通知放查询消息
4.4.2 最大努力通知和可靠消息一致性区别
解决思想
可靠消息一致性保证将消息发送出去,并且将消息发送给接收方,由发起方保证
最大努力通知,发起方尽力将消息发送给通知方,如果收不到,那么接收方可以自己来查,由接收方来保证
业务场景不同
可靠消息一致性关注交易过程事务一致,以异步方式完成
最大努力通知关注交易后的事务通知,将交易结果可靠地通知出去
技术解决方案不同
可靠消息一致性解决消息从发送到接收的一致性,整个过程要一致
最大努力通知无法保证从发送到接收的一致性,仅保证接收消息的可靠性,即当收不到消息时,接收方自己主动去查
最大努力通知解决方案,利用ACK机制
方案一流程
发起方将通知发送给MQ(普通消息机制),如果消息没有发送出去,那么可以由接收通知方主动请求通知方查询
接收通知方监听MQ
接收通知方接收消息,业务处理完成后回应ACK
接收通知方如果没有回应ACK机制,那么MQ会重复通知(要求收到通知方的ACK),会按照1min、5min、10min...等增大时间间隔进行重复通知
接收通知方可以通过消息校对接口直接请求发起通知方,验证消息一致性
方案二流程
发起通知方将通知发送给MQ,使用可靠消息一致性方案中的事务消息保证本地事务与消息的原子性
通知程序监听MQ,接收MQ消息(方案一中接收通知方直接监听MQ,方案二中使用通知陈旭监听MQ。方案二中,如果通知程序没有回应ACK程序给MQ,那么MQ会重复通知)
通知程序通过互联网接口协议(HTTP)调用接收通知方案
通知接收方可以通过消息校对接口与发起通知方进行消息一致性校对
两方案对比
方案一中,接收方监听MQ,可用于内部应用之间的通知
方案二中,通知程序监听MQ,然后通知程序利用HTTP对外进行消息通知,可用于外部应用之间的通知(不会允许外部系统监听MQ,如微信支付)
最大努力通知总结
对一致性的要求最低,适用于处理最终一致性时间不敏感的业务
需要实现消息重复通知机制、消息校对机制
5 方案总结
一般能不用就不用