1 分布式事务

在学习分布式事务之前,务必要知道,分布式事务不可能100%完美解决问题!只能尽量提高成功概率!让这个成功概率尽量接近99.999%,为了达到这个目的,甚至加入人工。


1.1 问题

star-product,IProductService,添加deductStock方法

public interface IProductService {

Product findByPid(Integer pid);

Product deductStock(Integer pid, Integer number);

}


star-product,ProductServiceImpl,实现deductStock方法

@Service

public class ProductServiceImpl implements IProductService {


...


@Override

public Product deductStock(Integer pid, Integer number) {

Product product = findByPid(pid);

if (product == null) {

商品不存在");

}

if (product.getStock() < number) {

库存不足");

}

product.setStock(product.getStock() - number);

productMapper.updateByPrimaryKey(product);

return product;

}

}


star-product,ProductController,暴露扣减库存的端点

@RestController

public class ProductController {


...


@PutMapping("/products/{pid}/{number}")

public ResultVO deductStock(@PathVariable Integer pid, @PathVariable Integer number) {

try {

Product product = productService.deductStock(pid, number);

扣减库存成功", product);

} catch (Exception e) {

e.printStackTrace();

扣减库存失败");

}

}


}


重启star-product,测试一下

7_协调者



star-order,IProductService接口中,添加扣减库存的方法(记得也同时让后备接口实现该方法)

@FeignClient("star-product")

public interface IProductService {


...


@PutMapping("/products/{pid}/{number}")

ResultVO deductStock(@PathVariable("pid") Integer pid, @PathVariable("number") Integer number);

}


star-order,OrderServiceImpl,改写create方法,加入扣减商品库存的功能:

@Autowired

private IProductService productService;


@Override

public Order create(Integer pid, Integer number) {


ResultVO resultVO = productService.findByPid(pid);


Integer status = resultVO.getCode();

if (status == HttpStatus.INTERNAL_SERVER_ERROR.value()) {

return null;

}


Map map = (Map) resultVO.getData();

Product product = new Product();

try {

BeanUtils.populate(product, map);

} catch (Exception e) {

e.printStackTrace();

}

创建订单

Order order = new Order();

为了测试,用户数据就直接定为常量

order.setUid(1);

邓紫棋");

order.setPid(pid);

order.setPname(product.getPname());

order.setPrice(product.getPrice());

order.setNumber(number);

orderDao.save(order);


扣减库存

resultVO = productService.deductStock(pid, number);

if (resultVO.getCode() == 500) {

return null;

}


return order;

}


重启star-order,测试:

7_数据库_02



引出问题

目前在star-order的create方法中有两个关键步骤:1.订单入库,2.扣减库存。我们必须保证这两个步骤的原子性,而我们已经在OrderServiceImpl类上添加了@Transactional注解。同时在create方法的最尾处,添加一个有1/3几率引起运行时异常的方法:


7_协调者_03



7_数据库_04



重启star-order,再次测试,会发现,订单入库操作被回滚了,但是扣减库存的操作并没有被回滚!


为了方便测试,以下提供SQL

INSERT INTO star_product VALUES(NULL, '句号', 50, 100);

INSERT INTO star_product VALUES(NULL, '倒数', 60, 100);

INSERT INTO star_product VALUES(NULL, '泡沫', 70, 100);


UPDATE product SET stock = 100;

TRUNCATE TABLE orders;



1.2 分布式事务基础

事务

事务指的就是一个操作单元,在这个操作单元中的所有操作最终要保持一致的行为,要么所有操作都成功,要么所有的操作都被撤销。简单地说,事务提供一种“要么什么都不做,要么全部都执行”的机制。


本地事务

本地事务可以认为是数据库提供的事务机制。说到数据库事务,就要提数据库事务的四大特性:

  1. Atomicity
  2. Consistency
  3. Isolation
  4. Durability


分布式事务

分布式事务指事务的参与者、支持事务的服务器、分布式系统的资源服务器以及事务管理器分别位于不同节点之上。简单地说,就是一次大的操作由不同的小操作组成,而这些小的操作又分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。


分布式事务的场景

单体系统访问多个数据库。一个服务需要调用多个数据库实例,进而完成数据的CRUD操作。如下,微服务接收一个下单请求后,需要在订单数据库中添加一个订单,并在库存数据库中扣减库存。

7_数据库_05



多个微服务访问同一个数据库。多个服务需要调用一个数据库实例完成数据的CRUD操作

7_分布式事务_06



多个微服务访问多个数据库。多个服务需要调用多个数据库实例来完成数据的CRUD操作

7_协调者_07



分布式事务的问题

一次业务操作需要跨多个数据源或需要跨多个系统进行远程调用,就会产生分布式事务问题。



2 分布式事务解决方案


2.1 全局事务(2PC)

全局事务基于DTP模型实现。DTP是由X/Open组织提出的一种分布式事务模型--X/Open Distributed Transcation Processing Reference Model。它规定,要实现分布式事务的话,就需要三种角色:

  1. AP:Application 应用系统(参与者)
  2. TM:Transaction Manager 事务管理器(全局事务管理者)
  3. RM:Resource Manager 资源管理器(本地事务管理者,即数据库)



整个事务分成两个阶段:

阶段一:表决阶段,所有参与者都预提交(只执行sql,但不提交)事务,并将信息反馈发给协调者。

阶段二:执行阶段,协调者根据所有参与者的反馈,通知所有参与者,步调一致地执行提交或回滚。

7_协调者_08


JTA 是 J2EE 遵循x/open规范,设计并实现了Java里面的分布式事务编程接口规范。


该方案的优点

  1. 提高了数据一致性的概率,实现成本较低

该方案的缺点

  1. 单点问题:事务协调者宕机,参与者会一直阻塞下去
  2. 同步阻塞:延迟了提交时间,加长了资源阻塞时间,影响整体性能(第一阶段不提交事务,锁会一直被占用)
  3. 数据不一致:在第二阶段提交时,依然存在commit结果未知的情况,有可能导致数据不一致



2.2 三阶段提交(3PC)

三阶段提交协议,是二阶段提交协议的改进版本,三阶段提交相较于二阶段提交协议有两个改动点

1. 增加了CanCommit阶段

2. 引入了超时机制


第一阶段:CanCommit阶段。

所有参与者确认自己是否能正常进行事务(包括但不限于网络是否畅通,业务数据是否支持),把结果发送给协调者,以及是否能接到协调者的消息。


第二阶段:PreCommit阶段。

所有参与者执行事务,但是不提交事务,仅仅是把事务执行成功或失败的消息发送给协调者。


第三阶段:DoCommit阶段

协调者向所有参与者发起事务提交或回滚的通知。


3PC和2PC的区别?

1. CanCommit阶段就是3PC相比2PC多出来的一个阶段,在2PC中的第一阶段,参与者会直接执行事务,也就会生成undo日志。而在3PC中,这个CanCommit阶段会进行“试水”操作,降低做出无效的undo日志的几率。


2. 2PC中,只要事务参与者没有收到协调者的commit消息,就会一直阻塞。而在3PC中,一旦事务参与者迟迟没有收到协调者的commit消息,就会自动进行本地commit。这样相对有效地解决了协调者单点故障的问题。


3. 3PC中,在DoCommit阶段,如果参与者没有及时收到来自协调者的commit或者abort消息,参与者就会在等待超时后,直接自行提交事务。这样做除了避免参与者一直占着资源不释放的问题外,还有另外一个原因:参与者能进入第三阶段,说明参与者在第二阶段已经收到了协调者的PreCommit消息,而协调者发送PreCommit消息的前提是,在第二阶段开始之前,协调者收到了所有参与者的CanCommit响应都是Yes。这说明所有的参与者都认为自己有能力正确处理处理事务。所以,当参与者进入第三阶段后,由于网络超时等原因,参与者会在等待超时后自动提交,因为参与者相信,直接提交的话,有很大几率是整个分布式事务是成功的!


注意,3PC仍然没有解决2PC的性能问题和不一致问题(如果协调者确实给所有参数与发送的都是中断事务的消息,而某个参与者没有收到,超时之后,只有它自己直接提交了,而其他参与者却是回滚了,此时就造成数据不一致的情况。不过可以用日志记录在超时之后提交事务的日志,让人工进行检查)。3PC仅仅是增大了分布式事务的成功几率。


2.3 TCC两阶段补偿性方案

TCC,Try Confirm Cancel,它属于补偿型事务。TCC实现分布式事务一共有三个步骤:


Try:尝试待执行的业务

这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源


Confirm:确认执行业务

确认执行业务操作,不做任何业务检查,只使用Try阶段预留的业务资源。通常情况下,采用TCC则认为Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。


Cancel:取消待执行的业务

取消Try阶段预留的业务资源。通常情况下,采用TCC则认为Cancel阶段也是一定会成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。


7_协调者_09




TCC两阶段提交与XA两阶段提交的区别是

1. XA是资源(数据库)层面的分布式事务,强一致性,在两阶段提交的过程中,一直会持有资源的锁

2. TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁


TCC事务的

优点:执行完每个阶段就commit,不会长时间占有资源。提高了整个系统的并发量。

缺点:TCC的Try、Confirm和Cancel操作功能需业务提供,开发成本高。



3 Seata

官网地址:http://seata.io/zh-cn/


3.1 Seata是什么

Seata是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。

Seata 为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。我们将以AT模式作为学习重点。


3.2 传统事务

在传统的,巨大的项目中,如下,事务由3个模块构成,开发人员只使用单一的数据源,自然地,数据的一致性就被本地事务所保证。

7_协调者_10



在微服务架构中,事情变得复杂了,以上的3个模块分别在不同的数据源上,每个数据源各自只能保证各自模块的数据一致性。

7_协调者_11



针对于以上问题,Seata是如何处理这种问题的呢?

7_协调者_12



Seata把一个全局事务,看做是由一组分支事务组成的一个大的事务(分支事务可以直接认为就是本地事务)

7_协调者_13



3.3 Seata中的三个组件

Transaction Coodinator(TC)

事务协调者,维护全局事务和分支事务的状态,驱动全局事务的提交或回滚

Transaction Manager(TM)

事务管理器,定义全局事务的范围:何时开始全局事务,何时提交或回滚全局事务

Resource Manager(RM)

资源管理器(数据库),负责向TC注册分支事务、向TC汇报状态,并接收事务协调器的指令。驱动分支事务的提交或回滚


3.4 Seata管理分布式事务的典型流程

  1. TM告知TC开启一个全局事务,TC生成一个全局唯一的XID。
  2. XID在微服务调用链中传播。
  3. RM向TC注册分支事务,将其纳入XID对应的全局事务的管辖之内。
  4. TM告知TC发起针对XID的全局提交或回滚决议。
  5. TC调度XID下管辖的全部分支事务完成提交或回滚请求。


7_数据库_14



3.5 Seata TC搭建

下载seata中的TC服务端https://github.com/seata/seata/releases/tag/v1.3.0。下载seata-1.3.0版本

7_分布式事务_15



解压:

7_协调者_16



进入conf目录

7_分布式事务_17



在star_seata数据库中,创建下表:

USE star_seata;


DROP TABLE IF EXISTS `global_table`;


CREATE TABLE `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`)

);


DROP TABLE IF EXISTS `branch_table`;


CREATE TABLE `branch_table`

(

`branch_id` BIGINT NOT NULL,

`xid` VARCHAR(128) NOT NULL,

`transaction_id` BIGINT,

`resource_group_id` VARCHAR(32),

`resource_id` VARCHAR(256),

`local_key` VARCHAR(128),

`branch_type` VARCHAR(8),

`status` TINYINT,

`client_id` VARCHAR(64),

`application_data` VARCHAR(2000),

`gmt_create` DATETIME,

`gmt_modified` DATETIME,

PRIMARY KEY(`branch_id`),

KEY `idx_xid` (`xid`)

);


DROP TABLE IF EXISTS `lock_table`;


CREATE TABLE `lock_table`

(

`row_key` VARCHAR(600) NOT NULL,

`xid` VARCHAR(96),

`transaction_id` LONG,

`branch_id` LONG,

`resource_id` VARCHAR(256),

`table_name` VARCHAR(32),

`pk` VARCHAR(1000),

`gmt_create` DATETIME,

`gmt_modified` DATETIME,

PRIMARY KEY(`row_key`)

);


在star_order、star_product数据库中创建undolog表:

DROP TABLE IF EXISTS `undo_log`;


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,

PRIMARY KEY (`id`),

UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)

) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;


编辑registry.conf,注册中心使用nacos,配置也使用“nacos”

registry {

type = "nacos"


nacos {

application = "seata-server"

serverAddr = "127.0.0.1:8848"

group = "SEATA_GROUP"

namespace = ""

cluster = "default"

username = "nacos"

password = "nacos"

}


}


config {

type = "nacos"


nacos {

serverAddr = "127.0.0.1:8848"

namespace = ""

group = "SEATA_GROUP"

username = "nacos"

password = "nacos"

}

}


在seata根目录下,创建config.txt,内容如下:

transport.type=TCP transport.server=NIO transport.heartbeat=true transport.enableClientBatchSendRequest=false transport.threadFactory.bossThreadPrefix=NettyBoss transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler transport.threadFactory.shareBossWorker=false transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector transport.threadFactory.clientSelectorThreadSize=1 transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread transport.threadFactory.bossThreadSize=1 transport.threadFactory.workerThreadSize=default transport.shutdown.wait=3 service.vgroupMapping.my_test_tx_group=default service.default.grouplist=127.0.0.1:8091 service.enableDegrade=false service.disableGlobalTransactinotallow=false client.rm.asyncCommitBufferLimit=10000 client.rm.lock.retryInterval=10 client.rm.lock.retryTimes=30 client.rm.lock.retryPolicyBranchRollbackOnCnotallow=true client.rm.reportRetryCount=5 client.rm.tableMetaCheckEnable=false client.rm.sqlParserType=druid client.rm.reportSuccessEnable=false client.rm.sagaBranchRegisterEnable=false client.tm.commitRetryCount=5 client.tm.rollbackRetryCount=5 client.tm.degradeCheck=false client.tm.degradeCheckAllowTimes=10 client.tm.degradeCheckPeriod=2000 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driverstore.db.url=jdbc:mysql://127.0.0.1:3306/star_seata?serverTimeznotallow=Asia/Shanghaistore.db.user=rootstore.db.password=123 store.db.minCnotallow=5 store.db.maxCnotallow=30 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.queryLimit=100 store.db.lockTable=lock_table store.db.maxWait=5000 store.redis.host=127.0.0.1 store.redis.port=6379 store.redis.maxCnotallow=10 store.redis.minCnotallow=1 store.redis.database=0 store.redis.password=null store.redis.queryLimit=100 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 client.undo.dataValidatinotallow=true client.undo.logSerializatinotallow=jackson client.undo.notallow=true server.undo.logSaveDays=7 server.undo.logDeletePeriod=86400000 client.undo.logTable=undo_log client.log.exceptinotallow=100 transport.serializatinotallow=seata transport.compressor=none metrics.enabled=false metrics.registryType=compact metrics.exporterList=prometheus metrics.exporterPrometheusPort=9898


将seata-1.3.0源码包中的script/config-center/nacos/nacos-config.sh拷贝到seata/conf目录下:

7_协调者_18


双击nacos-config.sh,该脚本会把seata根目录下的config.txt中的配置全部推送到nacos配置中心:

7_协调者_19


打开store.db.url配置以检查:

7_分布式事务_20



进入seata home下的bin目录,进入cmd命令行,键入以下命令以启动seata的TC服务

seata-server.bat


查看注册中心,发现seata的TC服务已经注册成功:

7_分布式事务_21


为了避免star-order与TC服务端口冲突,改变star-order的端口为:8092。


3.6 Seata TM RM搭建

在star-order、star-product中,加入seata依赖,依赖中封装了TM和RM的逻辑。注意不要直接把seata依赖添加到star-common中,因为加入了seata依赖就必须添加seata的相关配置。

<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>

<version>1.3.0</version>

</dependency>

注意: spring-cloud-starter-alibaba-seata自带的io.seata版本比较旧,所以我们手动排除io.seata,再手动引入1.3.0版本的io.seata依赖。


在star-order、star-product的config包中,添加以下配置类,用来创建代理数据源,而创建代理数据源的目的是为了创建代理连接,而创建代理连接的目的是为创建代理Statement,这个代理Statement是可以产生前置镜像和后置镜像的!(代理数据源默认也会自动配置好,这里手动配置是为了加深大家的印象)

@Configuration

public class DataSourceProxyConfig {

@Primary

@Bean

@ConfigurationProperties(prefix = "spring.datasource")

public DruidDataSource druidDataSource() {

return new DruidDataSource();

}


@Bean

public DataSourceProxy dataSource(DruidDataSource druidDataSource) {

return new DataSourceProxy(druidDataSource);

}

}


在star-order,star-product,application.yml中,添加以下配置,旨在“找TC”(就目前进度,现在使用的是远程配置,在nacos配置中心中),旨在在找到TC服务后,进一步找到TC中的某个集群,通过my_test_tx_group可以找到TC中的默认集群:default集群。

spring:

cloud:

alibaba:

seata:

tx-service-group: my_test_tx_group


最后,在star-order,OrderServiceImpl,create方法上添加@GlobalTransactional注解。重启star-order、star-product,再次测试下订单的端点,会发现全局事务起效果了。

7_分布式事务_22



3.7 事务分组与高可用:TC的异地多机房容灾

https://seata.io/zh-cn/docs/user/txgroup/transaction-group-and-ha.html

  1. 假定TC集群部署在两个机房:guangzhou机房(主)和shanghai机房(备)各两个实例
  2. 一整套微服务架构项目:projectA
  3. projectA内有微服务:serviceA、serviceB、serviceC 和 serviceD


其中,projectA所有微服务的事务分组tx-service-group设置为:projectA,projectA正常情况下使用guangzhou的TC集群(主)。


那么正常情况下,client端的配置如下:

seata.tx-service-group=projectA


TC server端的配置如下:

seata.service.vgroup-mapping.projectA=Guangzhou


7_协调者_23



假如此时guangzhou集群分组整个down掉,或者因为网络原因projectA暂时无法与Guangzhou机房通讯,那么我们将配置中心中的Guangzhou集群分组改为Shanghai,并推送到各个微服务,便完成了对整个projectA项目的TC集群动态切换。如下:

seata.service.vgroup-mapping.projectA=Shanghai


7_数据库_24





4. CAP理论

先回顾一下传统的ACID分别是什么 Atomicity Consistency Isolation

Durability


CAP是什么一致性 Availability 可用性 Partition tolerance 分区容错性


分布式系统(distributed system)正变得越来越重要,大型网站几乎都是分布式的。分布式系统的最大难点,就是各个节点的状态如何同步。CAP 定理是这方面的基本定理,也是理解分布式系统的起点。1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。

7_协调者_25


Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。先看 Partition tolerance,中文叫做"分区容错"。大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。

7_协调者_26


上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。 一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。

Consistency 中文叫做"一致性"。意思是,写操作之后的读操作,必须读到刚刚写的值。举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。

7_数据库_27


接下来,用户的读操作就会得到 v1。这就叫一致性。

7_协调者_28


问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。

7_协调者_29


这样的话,用户向 G2 发起读操作,也能得到 v1。

7_数据库_30


为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。

7_数据库_31


Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须在用户可以接受的范围内给出响应(响应速度越快,就说可用性越高,响应速度越慢,就说可用性越低)。 对比,CAP概念中的“可用性”并不同于集群概念中的“高可用”!用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。


Consistency 和 Availability 的矛盾一致性和可用性,为什么会形成矛盾关系?答案很简单:

如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性!如果锁定时间太长,则会导致用户等待响应的时间太长,从而导致“可用性”的降低

如果保证 G2 的可用性,那么势必不能锁定 G2时间太长,如果设置一个锁定时间的上限,则有可能G2还没有获取到G1的最新数据,就获得了锁,此时给客户响应的数据就是G1上不一致的数据!

综上所述,G2 无法同时保证一致性和可用性,它们是此消彼长的关系!系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。