事务(Transaction)是计算机科学中的一个重要概念,主要是指一个完整的、不可分割的操作序列。在关系型数据库中,事务通常用于描述对数据库进行的一系列操作的执行单元。
事务的ACID特性:
根据不同的隔离级别,事务可以分为多种类型。常见的隔离级别包括:
分布式事务:指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
分布式事务的产生是由于分库分表,服务的拆分等产生的,一个业务逻辑要操纵不同节点的多个数据库。
CAP定理
CAP理论是指在分布式系统中,当出现网络故障或节点故障时,系统无法同时满足以下三个特性:
根据CAP理论,当分布式系统出现网络故障或节点故障时(分区容错性),系统只能同时满足两个特性,要么满足可用性,舍弃一致性,要么满足一致性,放弃可用性。因此,在设计分布式系统时,需要根据具体的应用场景和需求来选择适当的特性进行权衡和取舍。
BASE理论
BASE理论也是分布式事务理论的一个重要理论。BASE理论是指在一个分布式系统中,当出现网络故障或节点故障时,系统应该满足以下三个特性:
与CAP理论不同的是,BASE理论强调了系统的可用性和软状态的重要性,并允许系统在一定时间内存在不一致性的情况。这种理论更加适合于处理大规模的、高可用的分布式系统。
解决分布式事务,各个分支事务必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个分支事务。有关联的各个分支事务在一起称为全局事务。
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论:
最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
Seata官网,其中的文档、播客中提供了大量的使用说明、源码分析。
Seata 提供了四种不同的分布式事务解决方案:
Seata术语
在Seata的架构中,一共有三个角色:
修改conf 目录下的 registry.conf 文件,设置注册中心和配置中心
registry.conf
registry {
# 注册中心 file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "default"
username = "nacos"
password = "nacos"
}
}
config {
#配置中心 file、nacos 、apollo、zk、consul、etcd3
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
启动 nacos,在配置中心新建配置文件。dataId 与 Group 要和 registry.conf 中的名称一致。
seataServer.properties 内容
其中的数据库地址、用户名、密码都需要修改成你自己的数据库信息。
# 数据存储方式,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=123456
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
创建数据库表
TC 服务在管理分布式事务时,需要记录事务相关数据到数据库中,需要提前创建好这些表。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for branch_table
-- ----------------------------
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint(20) NOT NULL,
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`status` tinyint(4) NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_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`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of branch_table
-- ----------------------------
-- ----------------------------
-- Table structure for global_table
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`timeout` int(11) NULL DEFAULT NULL,
`begin_time` bigint(20) NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of global_table
-- ----------------------------
-- ----------------------------
-- Records of lock_table
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;
点击 运行 Seata TC Server
在 nacos 中服务列表可以看到 Seata TC Server 服务已经成功注册
在微服务中引入 Seata 相关依赖,需要使用 Seata 的每一个微服务都要进行配置。
<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>1.4.2version>
dependency>
配置 application.yml,让微服务通过注册中心找到 seata-tc-server。
seata:
# TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置
registry:
type: nacos
nacos:
# 地址、namespace、group、application-name、cluster
server-addr: 127.0.0.1:8848
namespace: "" # 默认值为 public
group: DEFAULT_GROUP
application: seata-server
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: default
SEATA 的 XA 模式是一种基于 XA 协议的事务处理模式。XA 协议是一种由 X/Open 和 ISO 定义的分布式事务协议,它规定了事务管理器(Transaction Manager,TM)和资源管理器(Resource Manager,RM)之间的交互方式。
在 SEATA 的 XA 模式下,事务管理器会将事务划分为两个阶段:事务开始(Begin Phase)和 事务提交(Commit Phase)。
在事务开始阶段,事务管理器会锁定所有需要操作的资源,并在资源管理器中注册事务。
在事务提交阶段,事务管理器会通知所有资源管理器共同提交事务。
第一阶段执行各个分支事务执行成功,在第二阶段所有分支事务进行提交。
在第一阶段如果有分支事务执行失败,则第二阶段所有分支事务回滚。
XA 模式执行过程
RM 一阶段工作:
TC 二阶段工作:
RM 二阶段工作:
XA 模式的优点:
XA 模式的缺点:
XA 模式的实现
Seata 的 starter 已经完成了 XA 模式的自动装配,实现非常简单,步骤如下:
1.修改application.yml文件(每个参与事务的微服务),开启 XA模式:
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
2.给发起全局事务的入口方法添加 @GlobalTransactional 注解
@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();
}
3.重启服务测试
当库存不足时,扣除的余额会回滚。
AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
在AT模式下,用户只需关注自己的业务SQL,SEATA框架会自动生成事务的二阶段提交和回滚操作。
在一阶段,SEATA会拦截业务SQL,首先解析SQL语义,找到业务SQL要更新的业务数据,在更新前后保存数据快照,以便在二阶段回滚时使用。
在回滚阶段,SEATA需要回滚一阶段已经执行的业务SQL,还原业务数据。
AT模式执行过程:
RM 一阶段的工作:
TC 二阶段的工作:
RM二阶段的工作:
AT模式的优点:
AT模式的缺点:
脏写问题:指的是在并发控制中,多个事务同时更新同一行数据,其中一个事务在更新数据后,还没有提交或回滚之前,另一个事务又对该行数据进行更新,导致之前的数据被覆盖或更改,从而导致数据的不一致。
事务1在一阶段提交后,释放了DB锁,事务2 在事务1后获得了DB锁,对数据进行了更改,并提交,释放了DB锁。事务1 在二阶段根据数据快照进行回滚,将事务2 的修改进行了覆盖。
全局锁:由 TC 记录当前正在操作某行数据的事务,该事务持有全局锁,具备执行权。
全局锁解决脏写问题:
示例:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待全局锁 。
tx1 二阶段全局提交,释放全局锁 。tx2 拿到全局锁提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程全局锁在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
在数据库本地事务隔离级别读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是读未提交(Read Uncommitted)** 。
如果应用在特定场景下,必需要求全局的读已提交,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请全局锁 ,如果全局锁被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁拿到,即读取的相关数据是已提交的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
lock_table表导入到 Seata TC Server 服务相关联的数据库,lock_table 用来保存全局锁的信息。
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
undo_log表导入微服务相关联的数据库,undo_log表用来保存数据快照信息。
-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` longblob NOT NULL COMMENT 'rollback info',
`log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` datetime(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;
1.修改application.yml文件,将事务模式修改为AT模式即可:
seata:
data-source-proxy-mode: AT # 开启数据源代理的AT模式
2.给发起全局事务的入口方法添加 @GlobalTransactional 注解
3.重启服务测试
Seata TCC模式的核心思想是基于二阶段提交协议(Try-Confirm-Cancel),将分布式事务拆分为两个阶段:Try阶段 和 Confirm/Cancel 阶段。
第一阶段(Try阶段):尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性);
第二阶段(Confirm/Cancel阶段):确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作满足幂等性,Cancel操作满足幂等性。
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
TCC的缺点
TCC的空回滚和业务悬挂
空回滚:当某分支事务的 Try 阶段阻塞时,可能导致全局事务超时而触发二阶段的 Cancel 操作。在未执行 Try 操作时先执行了 Cancel 操作。
业务悬挂:对于已经空回滚的业务,如果以后继续执行 Try,就永远不可能 Confirm 或 Cancel,这就是业务悬挂。
业务描述:在账户表扣减余额,然后去库存表扣减商品,完成业务,如果扣减库存失败,则将账户表余额进行回滚。
在数据库中增加账户冻结表,用于记录冻结的金额。
账户冻结表结构
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL,
`user_id` varchar(255) 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;
Try业务:添加冻结金额,扣减可用金额
Confirm业务:删除冻结金额
Cancel业务:删除冻结金额,恢复可用金额
在实现中需要保证Confirm、Cancel 接口的幂等性,允许空回滚,拒绝业务悬挂。
TCC的 Try、Confirm、Cancel 方法都需要在接口中基于注解来声明,语法如下:
@LocalTCC
public interface TCCService {
/**
*
* Try逻辑
* @TwoPhaseBusinessAction 中的name属性要与当前方法名一致,用于指定 Try 逻辑对应的方法
* @BusinessActionContextParameter 设置上下文参数,可以在confirm,cancel方法中使用
*/
@TwoPhaseBusinessAction(name = "prepare", commitMethod = "confirm", rollbackMethod = "cancel")
void prepare(@BusinessActionContextParameter(paramName = "param") String param);
/**
*
* 二阶段confirm确认方法、可以另命名,但要保证与commitMethod一致
* @param context 上下文,可以传递Try方法的参数
* @return boolean 执行是否成功
*/
boolean confirm (BusinessActionContext context);
/**
*
* 二阶段回滚方法,要保证与rollbackMethod一致
*/
boolean cancel (BusinessActionContext context);
}
声明接口
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm",
rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
实现类
@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 0.获取事务id
String xid = RootContext.getXID();
// 判断 freeze 中是否有冻结记录,如果有,一定是 CANCEL执行过,要拒绝执行 Try 业务
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze != null) {
// CANCEL执行过,拒绝执行 Try 业务,避免业务悬挂
return;
}
// 1.扣减可用余额
accountMapper.deduct(userId, money);
// 2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
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
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
String userId = ctx.getActionContext("userId").toString();
AccountFreeze freeze = freezeMapper.selectById(xid);
// 查询冻结表,存在说明已经执行过 try 逻辑,不存在做空回滚
AccountFreeze freeze = freezeMapper.selectById(xid);
if (freeze == null) {
// 证明 try 没执行,需要空回滚
freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
// 幂等判断
if (freeze.getState() == AccountFreeze.State.CANCEL) {
//状态已经为 CANCEL 了,无需重复处理
return true;
}
// 1.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 2.将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
Saga模式是SEATA提供的长事务解决方案。
分为两个阶段:
Saga模式缺点:
Saga 模式不是很常用,使用示例省略