【PmHub后端篇】PmHub中Seata分布式事务保障任务审批状态一致性
在分布式系统中,事务管理是保证数据一致性的关键。本文将深入探讨在PmHub系统中,如何利用Seata分布式事务来保证任务审批状态的一致性。分布式事务在面试中是常见的考点,网上教程多偏理论,而实际项目中的应用更为关键。
1 事务基础概念
- 事务的定义:事务是逻辑上的一组操作,要么都执行,要么都不执行。例如在用户A向用户B转账100元的场景中,从用户A账户扣减100元以及向用户B账户增加100元这两个步骤需同时成功或失败。
- 事务的四大特性(ACID)
- 原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败。如银行转账时,扣钱和加钱操作需同时成功或失败。
- 一致性(Consistency):事务执行前后,数据库都必须处于一致的状态。转账后,两个账户的总金额应保持不变。
- 隔离性(Isolation):并发事务之间互不影响,一个事务的中间状态对其他事务不可见。同时进行的转账操作不会互相干扰,彼此看不到中间状态。
- 持久性(Durability):一旦事务提交,其结果是永久性的,即使系统崩溃,事务的结果也不会丢失。转账成功后,系统崩溃重启,账户金额的变动依然存在。
2 本地事务与分布式事务
-
本地事务:在单体应用中的事务属于本地事务,如在springboot中通过
@Transactional
注解实现。单体应用中,一个操作涉及的多张表位于同一个数据库,MySQL中InnoDB引擎支持事务,MyISAM不支持。MySQL 中主要是通过 undo log 和 redo log 来控制事务,undo log 是在事务提交前回滚,保证事务的原子性,redo log 是在事务提交后回滚,保证事务的持久性。
-
分布式事务:在分布式微服务系统中,单体系统拆分成多个微服务,每个微服务可能部署在不同机器且数据库隔离。如PmHub中不同微服务使用不同数据库,此时一个操作可能涉及多个机器、服务和数据库,需保证操作要么全部执行成功,要么全部失败,这就需要分布式事务解决方案。
3 分布式事务解决方案
3.1 XA方案
两阶段提交,通过事务管理器协调多个数据库的事务,适合单块应用里跨多个库的分布式事务,具有强一致性,但效率低,不适合高并发场景。
XA一共分为两阶段:
- 第一阶段(prepare):即所有的参与者RM准备执行事务并锁住需要的资源。参与者ready时,向TM报告已准备就绪。
- 第二阶段 (commit/rollback):当事务管理者(TM)确认所有参与者(RM)都ready后,向所有参与者发送commit命令。
目前主流的数据库基本都支持XA事务,包括mysql、oracle、sqlserver、postgre。
XA 事务由一个或多个资源管理器(RM)、一个事务管理器(TM)和一个应用程序(ApplicationProgram)组成。
这里的RM、TM、AP三个角色是经典的角色划分,会贯穿后续Saga、Tcc等事务模式。
把上面的转账作为例子,一个成功完成的XA事务时序图如下:
3.2 TCC方案
Try-Confirm-Cancel,通过三个阶段管理事务,适合短流程、高并发场景,具有强一致性,但补偿代码复杂,难以维护,适用于与资金相关的支付、交易等场景。
TCC分为3个阶段
- Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
- Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作要求具备幂等设计,Confirm 失败后需要进行重试。
- Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致,要求满足幂等设计。
把上面的转账作为例子,通常会在Try里面冻结金额,但不扣款,Confirm里面扣款,Cancel里面解冻金额,一个成功完成的TCC事务时序图如下:
3.3 SAGA方案
补偿事务,通过本地事务和补偿操作保证最终一致性,当一个事务失败时,反向执行已成功的操作进行补偿,无锁,高性能,参与者可异步执行,但不保证事务的隔离性,适用于业务流程长、参与者包含其它公司或遗留系统服务的场景。
SAGA核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
把上面的转账作为例子,一个成功完成的SAGA事务时序图如下:
3.4 本地消息表
在本地事务中插入消息到消息表,并将消息发送到MQ,接收方在本地事务中处理消息并更新消息状态,如果失败则定时重试,保证最终一致性,适合低并发场景,但严重依赖数据库的消息表,高并发场景扩展性差。
设计核心是将需要分布式处理的任务通过消息的方式来异步确保执行。
大致流程如下:
写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。
容错机制:
- 扣减余额事务失败时,事务直接回滚,无后续步骤
- 轮序生产消息失败, 增加余额事务失败都会进行重试
本地消息表的特点:
- 不支持回滚
- 轮询生产消息难实现,如果定时轮询会延长事务总时长,如果订阅binlog则开发维护困难
适用于可异步执行的业务,且后续操作无需回滚的业务
3.5 可靠消息最终一致性方案
基于MQ实现事务,先发送预备消息,执行本地事务后确认或回滚消息,MQ定时检查预备消息状态并回调确认,适合高并发场景,可靠性高,但处理复杂,系统B的事务失败需要重试或手工处理。
在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上,解决生产端的消息发送与本地事务执行的原子性问题。
事务消息发送及提交:
- 发送消息(half消息)
- 服务端存储消息,并响应消息的写入结果
- 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
- 根据本地事务状态执行Commit或者Rollback(Commit操作发布消息,消息对消费者可见)
正常发送的流程图如下:
补偿流程:
- 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
- Producer收到回查消息,返回消息对应的本地事务的状态,为Commit或者Rollback
事务消息方案与本地消息表机制非常类似,区别主要在于原先相关的本地表操作替换成了一个反查接口
事务消息特点如下:
- 长事务仅需要分拆成多个任务,并提供一个反查接口,使用简单
- 事务消息的回查没有好的方案,极端情况可能出现数据错误
- 适用于可异步执行的业务,且后续操作无需回滚的业务
3.6 最大努力通知方案
系统A执行完本地事务后发送消息到MQ,最大努力通知服务消费消息并调用系统B,如果系统B执行失败则定时重试,最后失败则放弃,简单易实现,适合对一致性要求不高的场景,但不能保证绝对一致性。
发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
- 有一定的消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
- 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
前面介绍的的本地消息表和事务消息都属于可靠消息,与这里介绍的最大努力通知有什么不同?
-
可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
-
最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
解决方案上,最大努力通知需要:
- 提供接口,让接受通知放能够通过接口查询业务处理结果
- 消息队列ACK机制,消息队列按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔 ,直到达到通知要求的时间窗口上限。之后不再通知
最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口
4 Seata介绍
4.1 Seata基本概念
- Seata是什么:Seata是阿里开源的分布式事务解决方案,是一个简单可扩展自治的事务框架。
- Seata的使用:官网地址为https://seata.apache.org/zh-cn/,开源地址为https://github.com/apache/incubator-seata。使用时,在需要使用分布式事务的地方加上
@GlobalTransactional
注解即可。 - Seata的模式:Seata支持AT模式、TCC模式、Saga模式。
- AT模式:自动补偿事务,通过代理自动管理事务的提交和回滚,适合简单场景,易于使用,开发成本低,但依赖于数据库支持,不适合复杂业务逻辑,如PmHub中使用AT模式。
- TCC模式:开发者手动实现业务逻辑的Try、Confirm和Cancel三个阶段,确保事务的一致性,提供强一致性,适用于需要严格事务管理的场景,但实现复杂,开发成本高。
- Saga模式:长事务模式,通过一系列的子事务来完成主事务,子事务之间独立运行,如果某个子事务失败,则通过补偿事务进行回滚,无需全局锁,高性能,适用于长事务场景,但需要开发补偿逻辑,可能无法保证强一致性。
4.2 Seata底层逻辑
- 一阶段:Seata拦截“业务SQL”,解析SQL语义,找到要更新的业务数据,在更新前保存成“before image”,执行“业务SQL”更新业务数据,更新后保存成“after image”,生成行锁,保证一阶段操作的原子性。
- 二阶段
-
正常提交场景:“业务SQL”在一阶段已提交至数据库,Seata框架删掉一阶段保存的快照数据和行锁,完成数据清理。
-
异常提交场景:二阶段回滚时,Seata用“before image”还原业务数据,还原前校验脏写,对比“数据库当前业务数据”和“after image”,若一致则还原业务数据,不一致则转人工处理。
-
5 Seata实战
5.1 下载安装Seata
从https://seata.apache.org/zh-cn/unversioned/download/seata-server/下载最新2.0.0版本
5.2 建库建表
在mysql创建pmhub-seata库并导入数据库脚本:
CREATE DATABASE `pmhub-seata` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;USE `pmhub-seata`;-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`
(`xid` VARCHAR(128) NOT NULL,`transaction_id` BIGINT,`status` TINYINT NOT NULL,`application_id` VARCHAR(32),`transaction_service_group` VARCHAR(32),`transaction_name` VARCHAR(128),`timeout` INT,`begin_time` BIGINT,`application_data` VARCHAR(2000),`gmt_create` DATETIME,`gmt_modified` DATETIME,PRIMARY KEY (`xid`),KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4;-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(`branch_id` BIGINT NOT NULL,`xid` VARCHAR(128) NOT NULL,`transaction_id` BIGINT,`resource_group_id` VARCHAR(32),`resource_id` VARCHAR(256),`branch_type` VARCHAR(8),`status` TINYINT,`client_id` VARCHAR(64),`application_data` VARCHAR(2000),`gmt_create` DATETIME(6),`gmt_modified` DATETIME(6),PRIMARY KEY (`branch_id`),KEY `idx_xid` (`xid`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4;-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(`row_key` VARCHAR(128) NOT NULL,`xid` VARCHAR(128),`transaction_id` BIGINT,`branch_id` BIGINT NOT NULL,`resource_id` VARCHAR(256),`table_name` VARCHAR(32),`pk` VARCHAR(36),`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',`gmt_create` DATETIME,`gmt_modified` DATETIME,PRIMARY KEY (`row_key`),KEY `idx_status` (`status`),KEY `idx_branch_id` (`branch_id`),KEY `idx_xid` (`xid`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4;CREATE TABLE IF NOT EXISTS `distributed_lock`
(`lock_key` CHAR(20) NOT NULL,`lock_value` VARCHAR(20) NOT NULL,`expire` BIGINT,primary key (`lock_key`)
) ENGINE = InnoDBDEFAULT CHARSET = utf8mb4;INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);SET FOREIGN_KEY_CHECKS = 1;
5.3 更改配置
修改端口和nacos相关配置
# Copyright 1999-2019 Seata.io Group.## Licensed under the Apache License, Version 2.0 (the "License");# you may not use this file except in compliance with the License.# You may obtain a copy of the License at## http://www.apache.org/licenses/LICENSE-2.0## Unless required by applicable law or agreed to in writing, software# distributed under the License is distributed on an "AS IS" BASIS,# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.# See the License for the specific language governing permissions and# limitations under the License.server:port: 7091spring:application:name: seata-serverlogging:config: classpath:logback-spring.xmlfile:path: ${log.home:${user.home}/logs/seata}extend:logstash-appender:destination: 127.0.0.1:4560kafka-appender:bootstrap-servers: 127.0.0.1:9092topic: logback_to_logstashconsole:user:username: seatapassword: seataseata:config:type: nacosnacos:server-addr: 127.0.0.1:8848namespace:group: SEATA_GROUP #后续自己在nacos里面新建,不想新建SEATA_GROUP,就写DEFAULT_GROUPusername: nacospassword: nacosregistry:type: nacosnacos:application: seata-serverserver-addr: 127.0.0.1:8848group: SEATA_GROUP #后续自己在nacos里面新建,不想新建SEATA_GROUP,就写DEFAULT_GROUPnamespace:cluster: defaultusername: nacospassword: nacosstore:mode: dbdb:datasource: druiddb-type: mysqldriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/pmhub-seata?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=trueuser: rootpassword: 123456min-conn: 10max-conn: 100global-table: global_tablebranch-table: branch_tablelock-table: lock_tabledistributed-lock-table: distributed_lockquery-limit: 1000max-wait: 5000# server:# service-port: 8091 #If not configured, the default is '${server.port} + 1000'security:secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017tokenValidityInMilliseconds: 1800000ignore:urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/metadata/v1/**
5.4 启动seata
进入seata的bin目录启动seata,windows系统通过双击seata-server.bat
脚本:
mac系统可通过sh seata-server.sh
命令启动
访问http://localhost:7091/
查看nacos确定seata是否成功启动并注册。
6 PmHub实战1:添加任务事务管理
创建项目任务时,添加或更新审批设置需跨库调用且涉及不同数据库,使用Seata的AT模式,在各自业务数据库中新建
undo_log
回滚日志表,在pmhub-project
配置文件中添加seata配置,在接口添加@GlobalTransactional
注解,涉及pmhub_project_task
、pmhub_project_member
、pmhub_project_log
等表。
- 添加任务的具体流程
6.1 业务库添加undo_log 表
因为这里使用的是 Seata 的 AT 模式,故需要在各自的业务数据库中新建 undo_log 回滚日志表,这里主要是 pmhub-project
库和pmhub-workflow
库,建表语句如下:
CREATE TABLE `undo_log` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',`xid` varchar(128) NOT NULL COMMENT 'global transaction id',`context` varchar(128) 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',PRIMARY KEY (`id`),UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`),KEY `ix_log_created` (`log_created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AT transaction mode undo table';
6.2 对应服务加上对应的seata依赖
在pmhub-project
添加依赖:
<!--添加分布式事务-->
<dependency><groupId>com.laigeoffer.pmhub-cloud</groupId><artifactId>pmhub-base-seata</artifactId>
</dependency>
6.3 Nacos 配置文件 pmhub-project-dev.yml 添加 seata 配置
seata:registry:type: nacosnacos:server-addr: 127.0.0.1:8848namespace: ""group: SEATA_GROUPapplication: seata-servertx-service-group: default_tx_group # 事务组,由它获得TC服务的集群名称service:vgroup-mapping: default_tx_group: default # 事务组与TC服务集群的映射关系data-source-proxy-mode: AT
6.4 接口添加 @GlobalTransactional 注解
@Override@GlobalTransactional(name = "pmhub-project-addTask",rollbackFor = Exception.class) //seata分布式事务,AT模式public String add(TaskReqVO taskReqVO) {// xid 全局事务id的检查(方便查看)String xid = RootContext.getXID();log.info("---------------开始新建任务: "+"\t"+"xid: "+xid);if (ProjectStatusEnum.PAUSE.getStatus().equals(projectTaskMapper.queryProjectStatus(taskReqVO.getProjectId()))) {throw new ServiceException("归属项目已暂停,无法新增任务");}// 1、添加任务ProjectTask projectTask = new ProjectTask();if (StringUtils.isNotBlank(taskReqVO.getTaskId())) {projectTask.setTaskPid(taskReqVO.getTaskId());}BeanUtils.copyProperties(taskReqVO, projectTask);projectTask.setCreatedBy(SecurityUtils.getUsername());projectTask.setCreatedTime(new Date());projectTask.setUpdatedBy(SecurityUtils.getUsername());projectTask.setUpdatedTime(new Date());projectTaskMapper.insert(projectTask);// 2、添加任务成员insertMember(projectTask.getId(), 1, SecurityUtils.getUserId());// 3、添加日志saveLog("addTask", projectTask.getId(), taskReqVO.getProjectId(), taskReqVO.getTaskName(), "参与了任务", null);// 将执行人加入if (taskReqVO.getUserId() != null && !Objects.equals(taskReqVO.getUserId(), SecurityUtils.getUserId())) {insertMember(projectTask.getId(), 0, taskReqVO.getUserId());// 添加日志saveLog("invitePartakeTask", projectTask.getId(), taskReqVO.getProjectId(), taskReqVO.getTaskName(), "邀请 " + getSysUserList(Collections.singletonList(taskReqVO.getUserId())).get(0).getNickName() + " 参与任务", taskReqVO.getUserId());}// 4、任务指派消息提醒extracted(taskReqVO.getTaskName(), taskReqVO.getUserId(), SecurityUtils.getUsername(), projectTask.getId());// 5、添加或更新审批设置(远程调用 pmhub-workflow 微服务)ApprovalSetDTO approvalSetDTO = new ApprovalSetDTO(projectTask.getId(), ProjectStatusEnum.TASK.getStatusName(),taskReqVO.getApproved(), taskReqVO.getDefinitionId(), taskReqVO.getDeploymentId());R<Boolean> result = wfDeployService.insertOrUpdateApprovalSet(approvalSetDTO, SecurityConstants.INNER);if (Objects.isNull(result) || Objects.isNull(result.getData())|| R.fail().equals(result.getData())) {throw new ServiceException("远程调用审批服务失败");}log.info("---------------结束新建任务: "+"\t"+"xid: "+xid);return projectTask.getId();}
6.5 涉及数据表
pmhub_project_task
CREATE TABLE `pmhub_project_task` (`id` varchar(64) NOT NULL COMMENT '主键id',`created_by` varchar(64) DEFAULT NULL COMMENT '创建人',`created_time` datetime DEFAULT NULL COMMENT '创建时间',`updated_by` varchar(64) DEFAULT NULL COMMENT '更新人',`updated_time` datetime DEFAULT NULL COMMENT '更新时间',`task_name` varchar(100) DEFAULT NULL COMMENT '任务名称',`project_id` varchar(64) DEFAULT NULL COMMENT '项目id',`task_priority` tinyint(1) NOT NULL DEFAULT '0' COMMENT '任务优先级',`user_id` bigint(20) NOT NULL COMMENT '用户id',`project_stage_id` varchar(64) NOT NULL COMMENT '项目阶段id',`description` varchar(500) DEFAULT NULL COMMENT '任务描述',`begin_time` datetime DEFAULT NULL COMMENT '预计开始时间',`end_time` datetime DEFAULT NULL COMMENT '预计结束时间',`close_time` datetime DEFAULT NULL COMMENT '截止时间',`task_pid` varchar(64) DEFAULT NULL COMMENT '任务父节点',`assign_to` varchar(64) DEFAULT NULL COMMENT '指派给谁',`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '任务状态',`execute_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '执行状态',`task_process` decimal(5,2) NOT NULL DEFAULT '0.00' COMMENT '任务进度',`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除',`deleted_time` datetime DEFAULT NULL,`task_flow` varchar(200) DEFAULT NULL COMMENT '所属流程',`task_type_id` varchar(64) DEFAULT NULL COMMENT '任务类型id',PRIMARY KEY (`id`) USING BTREE,KEY `idx` (`id`,`project_id`,`user_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目-任务表';
pmhub_project_member
CREATE TABLE `pmhub_project_member` (`id` varchar(64) NOT NULL COMMENT '主键id',`pt_id` varchar(64) NOT NULL COMMENT '项目或者任务id',`user_id` bigint(20) NOT NULL COMMENT '用户id',`joined_time` datetime DEFAULT NULL COMMENT '加入时间',`created_by` varchar(100) DEFAULT NULL COMMENT '创建人',`created_time` datetime DEFAULT NULL COMMENT '创建时间',`updated_by` varchar(100) DEFAULT NULL COMMENT '更新人',`updated_time` datetime DEFAULT NULL COMMENT '更新时间',`type` varchar(32) NOT NULL COMMENT '类型是项目还是任务 task project',`creator` tinyint(1) DEFAULT '0' COMMENT '是否创建者',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目-任务成员';
pmhub_project_log
CREATE TABLE `pmhub_project_log` (`id` varchar(64) NOT NULL COMMENT '主键id',`user_id` bigint(20) NOT NULL COMMENT '操作人id',`type` varchar(16) NOT NULL COMMENT '类型 project 或者 task',`operate_type` varchar(32) NOT NULL COMMENT '操作类型',`content` text COMMENT '操作内容',`remark` varchar(500) DEFAULT NULL COMMENT '备注',`pt_id` varchar(64) NOT NULL COMMENT '项目或者任务id',`to_user_id` bigint(20) DEFAULT NULL,`created_by` varchar(64) DEFAULT NULL,`created_time` datetime DEFAULT NULL,`updated_by` varchar(64) DEFAULT NULL,`updated_time` datetime DEFAULT NULL,`log_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1-动态 2-交付物 3-评论',`file_url` varchar(500) DEFAULT NULL COMMENT '文件地址',`icon` varchar(20) DEFAULT NULL,`project_id` varchar(64) NOT NULL COMMENT '项目id',PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目-任务日志';
7 PmHub实战2:审批状态新建/更新
涉及数据库pmhub-workflow及表pmhub_wf_approval_set,在服务pmhub-workflow-dev.yml中进行配置新增。
7.1 Nacos服务 pmhub-workflow-dev.yml 配置新增
# 分布式事务配置
seata:registry:type: nacosnacos:server-addr: 127.0.0.1:8848namespace: ""group: SEATA_GROUPapplication: seata-servertx-service-group: default_tx_group # 事务组,由它获得TC服务的集群名称service:vgroup-mapping: default_tx_group: default # 事务组与TC服务集群的映射关系data-source-proxy-mode: AT
7.2 具体代码实现
- 接口:
com.laigeoffer.pmhub.workflow.controller.WfDeployController#insertOrUpdateApprovalSet
/*** 添加&更新审批设置* @param approvalSetDTO* @return*/
@InnerAuth
@PostMapping("/insertOrUpdateApprovalSet")
public R<Boolean> insertOrUpdateApprovalSet(@RequestBody ApprovalSetDTO approvalSetDTO) {return R.ok(deployService.insertOrUpdateApprovalSet(approvalSetDTO.getExtraId(), approvalSetDTO.getType(), approvalSetDTO.getApproved(), approvalSetDTO.getDefinitionId(), approvalSetDTO.getDeploymentId()));
}
- 具体实现:
com.laigeoffer.pmhub.workflow.service.impl.WfDeployServiceImpl#insertOrUpdateApprovalSet
@Overridepublic boolean insertOrUpdateApprovalSet(String extraId, String type, String approved, String definitionId, String deploymentId) {LambdaQueryWrapper<WfApprovalSet> qw = new LambdaQueryWrapper<>();// 分布式任务异常场景模拟,睡10秒
// try {
// Thread.sleep(10000);
// } catch (InterruptedException e) {
// throw new RuntimeException(e);
// }qw.eq(WfApprovalSet::getExtraId, extraId).eq(WfApprovalSet::getType, type);WfApprovalSet mas = wfApprovalSetMapper.selectOne(qw);if (mas != null) {mas.setApproved(approved);mas.setDefinitionId(definitionId);mas.setDeploymentId(deploymentId);mas.setUpdatedBy(SecurityUtils.getUsername());mas.setUpdatedTime(new Date());wfApprovalSetMapper.updateById(mas);} else {WfApprovalSet wfApprovalSet = new WfApprovalSet();wfApprovalSet.setExtraId(extraId);wfApprovalSet.setType(type);wfApprovalSet.setApproved(approved);wfApprovalSet.setDefinitionId(definitionId);wfApprovalSet.setDeploymentId(deploymentId);wfApprovalSet.setCreatedBy(SecurityUtils.getUsername());wfApprovalSet.setCreatedTime(new Date());wfApprovalSet.setUpdatedBy(SecurityUtils.getUsername());wfApprovalSet.setUpdatedTime(new Date());wfApprovalSetMapper.insert(wfApprovalSet);}return true;}
7.3 涉及数据表
数据库:pmhub-workflow
数据表:pmhub_wf_approval_set
CREATE TABLE `pmhub_wf_approval_set` (`id` varchar(32) NOT NULL,`type` varchar(32) DEFAULT NULL,`approved` varchar(10) DEFAULT NULL,`deployment_id` varchar(64) DEFAULT NULL,`definition_id` varchar(64) DEFAULT NULL,`created_by` varchar(64) DEFAULT NULL,`created_time` datetime DEFAULT NULL,`updated_by` varchar(64) DEFAULT NULL,`updated_time` datetime DEFAULT NULL,`extra_id` varchar(32) DEFAULT NULL,PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
8 Seata实战测试验证
8.1 启动相关服务
pmhub-gateway
pmhub-auth
pmhub-system
pmhub-project
pmhub-workflow
8.2 ( 正常情况 )用前端访问Pmhub, 添加任务测试
- 页面正常添加任务,检查对应表数据正常插入。
8.3 查看数据库数据是否正常新增
-
pmhub_project_task
-
pmhub_project_member
-
pmhub_project_log
8.4 ( 异常情况 )超时, 没有@GlobalTransactional回滚
-
修改代码, 让线程睡眠10秒
-
重新添加任务,页面报“接口请求超时”
8.5 查看数据库表的数据情况
数据库中项目相关表创建数据,流程状态表数据异常,产生脏数据。
-
pmhub_project_task
-
pmhub_project_member
-
pmhub_project_log
-
pmhub_wf_approval_set
8.6 超时异常解决,添加@GlobalTransactional
让更新状态接口多睡10秒并打好断点,重新添加任务,接口超时,任务添加失败,事务回滚成功。
- 再次测试就会发现数据并没有新增到数据库了, 分布式事务回滚成功!
- undo_log 中存下了插入表格的所有信息状态, 是为了方便分布式事务回滚的。
- 当任务执行完,undo_log 中数据会被清空,undo_log 只是暂时记录一下回滚信息
- 出现异常回滚玩,记录自然而然也就不见了。
8.7 seata 底层逻辑验证
为了了解 seata 的 AT 模式是如何工作的,我们采用断点的方式来慢慢看看其底层的原理吧。
- 页面新建任务
- 查看全局事务 id
- pmhub_project_task 已经插入一条记录
- 查看 undo_log
此时 undo_log 表插入了 2 条记录,并有回滚信息。 - 查询 rollback_info 字段,并用 json 解析出来
SELECT CONVERT (rollback_info USING utf8) FROM undo_log;
在线解析:https://www.json.cn/jsononline/
所以可以看到,undo_log 中存下了插入表格的所有信息状态, 是为了方便分布式事务回滚的。
当任务执行完,undo_log 中数据会被清空,undo_log 只是暂时记录一下回滚信息,出现异常回滚玩,记录自然而然也就不见了。
9 总结
本文深入探讨PmHub系统中Seata分布式事务,介绍事务基础概念、分布式事务解决方案,展示Seata使用方法、底层逻辑及在PmHub中的实战测试,包括添加任务和审批状态更新,帮助开发者保证数据一致性。
10 参考链接
- PmHub分布式事务Seata保证任务审批状态一致性
- 分布式事务最经典的七种解决方案
- 分布式事务Seata保证审批状态一致性
- 项目仓库(GitHub)
- 项目仓库(码云):(国内访问速度更快)