前言
一、分布式事务问题
分布式之前:
系统单机单库,不会出现分布式问题,随着业务的发展,系统越来越复杂,数据库也开始变化由一对一逐渐演变一对多,多对多,此时一个单一的系统无法支撑起整个应用,此时,系统逐渐被拆分成一个一个服务,每一个应用组成一个服务,微服务诞生。
分布式之后:
单体应用被拆分成微服务应用,原来的三个模块被拆分成三个独立的应用,分别使用三个独立的数据源。业务操作需要调用三个服务来完成,此时每个服务内部的数据一致性由本地本地事务来保证,但是全局的数据一致性问题没法保证
示意图如下
总之一句话:一次业务操作需要垮多个数据源或垮多个系统进行远程调用,就会产生分布式事务问题
二、Seata简介
1,是什么
官网地址https://seata.io/zh-cn/
Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
2,能干嘛
1,术语
一个典型的分布式事务过程:分布式事务处理过程的 一ID+三组件模型(简称1+3模型)
-
XID (Transaction ID) : 全局唯一的事务ID
-
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。 -
TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。 -
RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
2,处理过程
-
TM 向TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID
-
XID 在微服务调用链路的上下文中传播
-
RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖
-
TM 向TC 发起针对 XID 的全局提交或回滚决议
-
TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求
3,去哪下
下载地址https://seata.io/zh-cn/blog/download.html
4,怎么玩
单个应用我们只需要使用一个注解 @Transactional
分布式我们也只需要一个注解 @GlobalTransactional
用起来就这么简单,搞定!!!!
ps 往往使用起来越简单,往往越不简单
三、Seata-Server安装
1,官网地址
地址 https://seata.io/zh-cn/index.html
2,下载版本
下载地址 https://seata.io/zh-cn/blog/download.html
3,解压修改file.conf文件
先备份初始文件再修改
我们这里使用数据库(版本不一致,file.conf也可能不一样)
store修改如下
4,数据库新建seata库
新版本的seata1.0以后没有sql文件需要自行去查找
链接地址 https://github.com/seata/seata/tree/develop/script
5,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_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
-- 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 = InnoDB
DEFAULT CHARSET = utf8;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;
6,修改seata-server的registry.conf配置文件
后面我们会将配置上传到nacos,所以配置中心也需要修改如下
7,启动nacos端口8848
8,启动seata-server
启动时可能报错
(1)会报日志文件找不到的错误
解决办法创建一个logs文件,在文件目录下新建一个seata_gc.log
(2)报内存不够(报文未截图,后续补充下)
解决办法 修改启动参数
将其设置为1024,1024,1024,512即可
9,将配置导入到nacos
(1)修改 config.tx将配置注册到nacos中
下载下来是没有这个文件的所以要自己去下载https://github.com/seata/seata/tree/1.3.0/script/config-center
启动脚本以及config.txt都有,注意目录结构不要放错了,否则启动不起来,或者自行修改启动脚本
(2)执行将config.txt推送到nacos中
sh nacos-config.sh -h 182.92.219.202 -p 8848 -g SEATA_GROUP -u nacos -w nacos -t 51915a62-d2d6-43d4-8f45-86b159eb90f5
参数配置说明如下
sh ${SEATAPATH}/script/config-center/nacos/nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t 5a3c7d6c-f497-4d68-a71a-2e5e3340b3ca -u username -w password
-h: host, the default value is localhost.
-p: port, the default value is 8848.
-g: Configure grouping, the default value is 'SEATA_GROUP'.
-t: Tenant information, corresponding to the namespace ID field of Nacos, the default value is ''.
-u: username, nacos 1.2.0+ on permission control, the default value is ''.
-w: password, nacos 1.2.0+ on permission control, the default value is ''.
四、业务数据库准备
订单-库存-账户 业务数据库准备
1,前提条件
需要先启动Nacos后再启动Seata,两个服务必须先启动起来
2,分布式事务业务说明
参考地址 https://github.com/seata/seata-samples/tree/master/seata-spring-boot-starter-samples
流程如下:
下订单–>扣库存–>减账户余额
创建三个服务:订单服务、库存服务、账户服务
具体流程如下:
当用户下叮当时,会在订单服务中创建一个订单,然后通过远程调用库存服务开扣减下单商品的库存,再通过远程调用账户服务来扣减用户账户里面的余额,最后再订单服务中修改订单状态为已完成
流程说明:跨越三个数据库,有两次远程调用,会存在分布式事务问题
3,创建业务数据库
创建三个数据库
CREATE DATABASE 数据库名
存储订单的数据库 seata_order
存储库存的数据库 seata_storage
存储账户信息的数据库 seata_account
4,按照业务数据库创建业务表
存储订单的数据库 seata_order中
CREATE TABLE `t_order` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint DEFAULT NULL COMMENT '用户id',
`product_id` bigint DEFAULT NULL COMMENT '产品id',
`count` int DEFAULT NULL COMMENT '数量',
`money` decimal(11,0) DEFAULT NULL COMMENT '金额',
`status` int DEFAULT NULL COMMENT '订单状态:0:创建中;1:已完结',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表';
存储库存的数据库 seata_storage中
CREATE TABLE `t_storage` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint DEFAULT NULL COMMENT '产品id',
`total` int DEFAULT NULL COMMENT '库存',
`used` int DEFAULT NULL COMMENT '已用库存',
`residue` int DEFAULT NULL COMMENT '剩余库存',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存表';
存储账户信息的数据库 seata_account中
CREATE TABLE `t_account` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint DEFAULT NULL COMMENT '用户id',
`total` decimal(10,0) DEFAULT NULL COMMENT '总额度',
`used` decimal(10,0) DEFAULT NULL COMMENT '已用额度',
`residue` decimal(10,0) DEFAULT NULL COMMENT '剩余可用额度',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='账户表';
5,按照业务数据库创建对应的回滚日志表
每个库建一个
订单、库存、账户下都需要建各自的回滚日志表
CREATE TABLE `undo_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`branch_id` bigint NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int 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=55 DEFAULT CHARSET=utf8;
6,最终效果
五、业务微服务准备
订单-库存-账户 业务微服务准备
1,业务需求
下订单–>减库存–>扣余额–>改(订单)状态
2,新建订单Order-Module
源码地址 https://gitee.com/jn-acheng/spring-cloud-study/tree/master/spring-cloud-seata-order-8101
(1)新建module
spring-cloud-seata-order-8101
版本 (父pom)
(2)POM
只列出核心pom依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
(3)YML
server:
port: 8101
spring:
application:
name: seata-order
datasource:
url: jdbc:mysql://192.168.119.50:3306/seata_order?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
cloud:
nacos:
discovery:
server-addr: 192.168.119.50:8848
username: nacos
password: nacos
group: SEATA_GROUP # 分组
# namespace: 193f80d6-57e9-4718-87ff-179335a50ac5 # 命名空间
# sh nacos-config.sh -h 192.168.119.50 -p 8848 -g SEATA_GROUP -u nacos -w nacos -t 292c3d9d-b037-49e5-bfc6-1f14d648c743
seata:
enabled: true
application-id: ${spring.application.name}
tx-service-group: my_test_tx_group #这里要特别注意和nacos中配置的要保持一直
registry:
type: nacos
nacos:
serverAddr: ${spring.cloud.nacos.discovery.server-addr}
username: ${spring.cloud.nacos.discovery.username}
password: ${spring.cloud.nacos.discovery.password}
group: ${spring.cloud.nacos.discovery.group}
config:
type: nacos
nacos:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
username: ${spring.cloud.nacos.discovery.username}
password: ${spring.cloud.nacos.discovery.password}
group: ${spring.cloud.nacos.discovery.group}
service:
vgroup-mapping:
my_test_tx_group: default
management:
endpoints:
web:
exposure:
include: '*'
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
(4)代码
1,主启动类
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class SpringBootSeataOrder_8101 {
public static void main(String[] args) {
SpringApplication.run(SpringBootSeataOrder_8101.class, args);
}
}
2,订单pojo类
@Data
public class Order {
private Long id;
/**
* 用户id
*/
private Long userId ;
/**
* 产品id
*/
private Long productId;
/**
* 数量
*/
private int count;
/**
* '金额'
*/
private BigDecimal money;
/**
* 订单状态 0:创建中;1:已完结
*/
private int status;
}
3,OrderMapper
@Mapper
public interface OrderMapper {
/**
* 创建订单
* @param order
*/
void create(Order order);
/**
* 修改订单状态
* @param id
* @param status
*/
void update(@Param("id") Long id,@Param("status") int status);
}
4,OrderMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.acheng.mapper.OrderMapper">
<resultMap id="BaseResultMap" type="com.acheng.pojo.Order">
<id column="id" property="id" jdbcType="BIGINT"></id>
<result column="user_id" property="userId" jdbcType="BIGINT" />
<result column="product_id" property="productId" jdbcType="BIGINT" />
<result column="count" property="count" jdbcType="BIGINT" />
<result column="money" property="money" jdbcType="DECIMAL" />
<result column="status" property="status" jdbcType="INTEGER" />
</resultMap>
<insert id="create" useGeneratedKeys="true" keyProperty="id">
insert into t_order (user_id,product_id,count,money,status) values
(#{userId},#{productId},#{count},#{money},#{status})
</insert>
<update id="update">
update t_order set status=#{status} where id=#{id}
</update>
</mapper>
5,service接口,有三个接口
OrderService
public interface OrderService {
void create(Order order);
}
StorageService
@FeignClient("seata-storage")
public interface StorageService {
/**
* 扣减库存
*/
@PostMapping("/storage/decrease")
public void decrease(@RequestParam("productId") Long productId, @RequestParam("count")int count);
}
AccountService
@FeignClient(value ="seata-account" )
public interface AccountService {
/**
* 扣减金钱
*/
@PostMapping("/account/decrease")
public void decrease(@RequestParam("userId") Long userId,@RequestParam("used") BigDecimal used);
}
6,实现类
package com.acheng.service.impl;
import com.acheng.mapper.OrderMapper;
import com.acheng.pojo.Order;
import com.acheng.service.AccountService;
import com.acheng.service.OrderService;
import com.acheng.service.StorageService;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* @author LiuCheng
* @data 2021/5/13 15:53
*/
@Service
@Slf4j
@GlobalTransactional(name = "my_test_tx_group" ,rollbackFor = Exception.class)
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private StorageService storageService;
@Autowired
private AccountService accountService;
@Override
public void create(Order order) {
// 创建订单
log.info("======= 创建订单 start");
orderMapper.create(order);
// 扣减库存
log.info("======= 扣减库存 start");
storageService.decrease(order.getProductId(),order.getCount());
log.info("======= 扣减库存 end");
// 扣减账户余额
log.info("======= 扣减账户余额 start");
accountService.decrease(order.getUserId(),order.getMoney());
log.info("======= 扣减账户余额 end");
// 修改订单状态
log.info("======= 修改订单状态 start");
int status=1;
orderMapper.update(order.getId(),1);
log.info("======= 修改订单状态 end");
log.info("======= 创建订单 end");
}
}
7,控制层
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
OrderService orderService;
@GetMapping("/create")
public CommonResult create(Order order){
orderService.create(order);
return new CommonResult(200,"订单创建成功");
}
}
3,新建库存Storage-Module
偷个懒,不贴代码了 o(╯□╰)o
源码地址 https://gitee.com/jn-acheng/spring-cloud-study/tree/master/spring-cloud-seata-storage-8201
4,新建账户Account-Module
再偷个懒,不贴代码了 o(╯□╰)o
源码地址 https://gitee.com/jn-acheng/spring-cloud-study/tree/master/spring-cloud-seata-account-8301
六、测试
下订单–>减库存–>扣余额–>改(订单)状态
1,数据库初始情况
2,正常下单
3,超时异常没加@GloblaTransactional
订单状态时0 未支付
库存被扣减了
账户余额扣减了
当库存和账户金额扣减后,订单状态并没有设置为已完成,没有从0改为1,而且feign的重试机制,账户余额还可能多次扣减
4,超时异常添加加@GloblaTransactional
订单未插入
库存未减少
金额未扣减
七、补充Seata原理
1,Seata
Seata是Fescar的升级版本,2019年1月份蚂蚁金服和阿里巴巴共同开元的分布式解决方案 Seata
全称 Simple Extensible Autonomous Transaction Architecture。
2,再看TC/TM/RM三大组件
分布式事务的执行流程
- TM 开启分布式事务(TM向TC注册全局事务记录)
- 按业务场景,编排数据库、服务等事务内资源(RM向TC汇报资源准备状态)
- TM结束分布式事务,事务一阶段结束(TM通知TC提交/回滚分布式事务)
- TC汇总事务信息,决定分布式事务是提交还是回滚
- TC通知所有RM 提交/回滚 资源,事务二阶段结束
3,AT模式如何做到对业务的无侵入
(1)是什么
- 基于支持本地 ACID 事务的关系型数据库
- Java 应用,通过 JDBC 访问数据库。
整体机制
两阶段提交协议的演变:
-
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
-
二阶段:
1,提交异步化,非常快速地完成。
2,回滚通过一阶段的回滚日志进行反向补偿,
(2)一阶段加载
在一阶段,Seata会拦截 “业务SQL”
- 解析SQL语义,找到 "业务SQL"要更新的业务数据,在业务数据被更新前,将其保存成 “before image” 前置镜像
- 执行 “业务SQL” 更新业务数据,在业务数据更新之后
- 将保存成 “after image” 后置镜像,最后生成行锁
以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性
1,解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
2,查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';
3,执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
4,查询后镜像:根据前镜像的结果,通过 主键 定位数据。
select id, name, since from product where id = 1;
5,插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
"branchId": 641789253,
"undoItems": [{
"afterImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "GTS"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"beforeImage": {
"rows": [{
"fields": [{
"name": "id",
"type": 4,
"value": 1
}, {
"name": "name",
"type": 12,
"value": "TXC"
}, {
"name": "since",
"type": 12,
"value": "2014"
}]
}],
"tableName": "product"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx"
}
6,提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
7,本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
8,将本地事务提交的结果上报给 TC。
(3)二阶段提交
1,收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
2,异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
(4)二阶段回滚
1,收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
2,通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
3,数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
4,根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
update product set name = 'TXC' where id = 1;
5,提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
4,debug调试查看
debug放行后,数据删除
5,补充总结