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,测试一下
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,测试:
引出问题
目前在star-order的create方法中有两个关键步骤:1.订单入库,2.扣减库存。我们必须保证这两个步骤的原子性,而我们已经在OrderServiceImpl类上添加了@Transactional注解。同时在create方法的最尾处,添加一个有1/3几率引起运行时异常的方法:
重启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 分布式事务基础
事务
事务指的就是一个操作单元,在这个操作单元中的所有操作最终要保持一致的行为,要么所有操作都成功,要么所有的操作都被撤销。简单地说,事务提供一种“要么什么都不做,要么全部都执行”的机制。
本地事务
本地事务可以认为是数据库提供的事务机制。说到数据库事务,就要提数据库事务的四大特性:
- Atomicity
- Consistency
- Isolation
- Durability
分布式事务
分布式事务指事务的参与者、支持事务的服务器、分布式系统的资源服务器以及事务管理器分别位于不同节点之上。简单地说,就是一次大的操作由不同的小操作组成,而这些小的操作又分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。
分布式事务的场景
单体系统访问多个数据库。一个服务需要调用多个数据库实例,进而完成数据的CRUD操作。如下,微服务接收一个下单请求后,需要在订单数据库中添加一个订单,并在库存数据库中扣减库存。
多个微服务访问同一个数据库。多个服务需要调用一个数据库实例完成数据的CRUD操作
多个微服务访问多个数据库。多个服务需要调用多个数据库实例来完成数据的CRUD操作
分布式事务的问题
一次业务操作需要跨多个数据源或需要跨多个系统进行远程调用,就会产生分布式事务问题。
2 分布式事务解决方案
2.1 全局事务(2PC)
全局事务基于DTP模型实现。DTP是由X/Open组织提出的一种分布式事务模型--X/Open Distributed Transcation Processing Reference Model。它规定,要实现分布式事务的话,就需要三种角色:
- AP:Application 应用系统(参与者)
- TM:Transaction Manager 事务管理器(全局事务管理者)
- RM:Resource Manager 资源管理器(本地事务管理者,即数据库)
整个事务分成两个阶段:
阶段一:表决阶段,所有参与者都预提交(只执行sql,但不提交)事务,并将信息反馈发给协调者。
阶段二:执行阶段,协调者根据所有参与者的反馈,通知所有参与者,步调一致地执行提交或回滚。
JTA 是 J2EE 遵循x/open规范,设计并实现了Java里面的分布式事务编程接口规范。
该方案的优点
- 提高了数据一致性的概率,实现成本较低
该方案的缺点
- 单点问题:事务协调者宕机,参与者会一直阻塞下去
- 同步阻塞:延迟了提交时间,加长了资源阻塞时间,影响整体性能(第一阶段不提交事务,锁会一直被占用)
- 数据不一致:在第二阶段提交时,依然存在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阶段真的出错了,需引入重试机制或人工处理。
TCC两阶段提交与XA两阶段提交的区别是
1. XA是资源(数据库)层面的分布式事务,强一致性,在两阶段提交的过程中,一直会持有资源的锁
2. TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁
TCC事务的
优点:执行完每个阶段就commit,不会长时间占有资源。提高了整个系统的并发量。
缺点:TCC的Try、Confirm和Cancel操作功能需业务提供,开发成本高。
3 Seata
3.1 Seata是什么
Seata是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
Seata 为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。我们将以AT模式作为学习重点。
3.2 传统事务
在传统的,巨大的项目中,如下,事务由3个模块构成,开发人员只使用单一的数据源,自然地,数据的一致性就被本地事务所保证。
在微服务架构中,事情变得复杂了,以上的3个模块分别在不同的数据源上,每个数据源各自只能保证各自模块的数据一致性。
针对于以上问题,Seata是如何处理这种问题的呢?
Seata把一个全局事务,看做是由一组分支事务组成的一个大的事务(分支事务可以直接认为就是本地事务)
3.3 Seata中的三个组件
Transaction Coodinator(TC)
事务协调者,维护全局事务和分支事务的状态,驱动全局事务的提交或回滚
Transaction Manager(TM)
事务管理器,定义全局事务的范围:何时开始全局事务,何时提交或回滚全局事务
Resource Manager(RM)
资源管理器(数据库),负责向TC注册分支事务、向TC汇报状态,并接收事务协调器的指令。驱动分支事务的提交或回滚
3.4 Seata管理分布式事务的典型流程
- TM告知TC开启一个全局事务,TC生成一个全局唯一的XID。
- XID在微服务调用链中传播。
- RM向TC注册分支事务,将其纳入XID对应的全局事务的管辖之内。
- TM告知TC发起针对XID的全局提交或回滚决议。
- TC调度XID下管辖的全部分支事务完成提交或回滚请求。
3.5 Seata TC搭建
下载seata中的TC服务端:https://github.com/seata/seata/releases/tag/v1.3.0。下载seata-1.3.0版本
解压:
进入conf目录
在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目录下:
双击nacos-config.sh,该脚本会把seata根目录下的config.txt中的配置全部推送到nacos配置中心:
打开store.db.url配置以检查:
进入seata home下的bin目录,进入cmd命令行,键入以下命令以启动seata的TC服务
seata-server.bat
查看注册中心,发现seata的TC服务已经注册成功:
为了避免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,再次测试下订单的端点,会发现全局事务起效果了。
3.7 事务分组与高可用:TC的异地多机房容灾
https://seata.io/zh-cn/docs/user/txgroup/transaction-group-and-ha.html
- 假定TC集群部署在两个机房:guangzhou机房(主)和shanghai机房(备)各两个实例
- 一整套微服务架构项目:projectA
- 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
假如此时guangzhou集群分组整个down掉,或者因为网络原因projectA暂时无法与Guangzhou机房通讯,那么我们将配置中心中的Guangzhou集群分组改为Shanghai,并推送到各个微服务,便完成了对整个projectA项目的TC集群动态切换。如下:
seata.service.vgroup-mapping.projectA=Shanghai
4. CAP理论
先回顾一下传统的ACID分别是什么 Atomicity Consistency Isolation
Durability
CAP是什么一致性 Availability 可用性 Partition tolerance 分区容错性
分布式系统(distributed system)正变得越来越重要,大型网站几乎都是分布式的。分布式系统的最大难点,就是各个节点的状态如何同步。CAP 定理是这方面的基本定理,也是理解分布式系统的起点。1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。先看 Partition tolerance,中文叫做"分区容错"。大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。
上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。 一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。
Consistency 中文叫做"一致性"。意思是,写操作之后的读操作,必须读到刚刚写的值。举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。
接下来,用户的读操作就会得到 v1。这就叫一致性。
问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。
这样的话,用户向 G2 发起读操作,也能得到 v1。
为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。
Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须在用户可以接受的范围内给出响应(响应速度越快,就说可用性越高,响应速度越慢,就说可用性越低)。 对比,CAP概念中的“可用性”并不同于集群概念中的“高可用”!用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。
Consistency 和 Availability 的矛盾一致性和可用性,为什么会形成矛盾关系?答案很简单:
如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性!如果锁定时间太长,则会导致用户等待响应的时间太长,从而导致“可用性”的降低
如果保证 G2 的可用性,那么势必不能锁定 G2时间太长,如果设置一个锁定时间的上限,则有可能G2还没有获取到G1的最新数据,就获得了锁,此时给客户响应的数据就是G1上不一致的数据!
综上所述,G2 无法同时保证一致性和可用性,它们是此消彼长的关系!系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。