事务的ACID原则:
分布式服务的事务问题:在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有分支事务最终状态一致,这样的事务就是分布式事务。由于各个微服务之间是独立的,即各个微服务之间的状态是不可互知的,这里举个例子,在购买商品时需要涉及三个微服务,分别是创建订单微服务、扣除用户用户账户余额微服务以及扣除商品库存微服务。现在假如用户购买了一个商品,但商品库存不足了现在扣除库存微服务失败,但创建订单微服务和扣除用户余额微服务还是成功执行了,这就导致了微服务之间事务状态不一致,这就是比较常见的分布式服务的事务问题。
1998年,加州大学的计算机科学家Eric Brewer提出,分布式系统有三个指标:
CAP定理-Consistency
用户访问分布式系统中的任意节点,得到的数据必须一致
CAP定理-Availablility:用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝
CAP定理-Partition tolerance
Partition:因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去联系,形成独立分区
tolerance:在集群出现分区时,整个系统也要持续对外提供服务
又CAP定理我们知道,由于分区问题的存在,我们只能在CA或者CP中做出我们的选择,但如果我们不想放弃CAP中的任何一个,BASE理论就可以解决这个问题。
BASE理论是对CAP的一种解决思路,包括三个意思:
利用BASE理论和CAP定理解决分布式事务问题
可以发现,解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务),这里的子系统事务称为分支事务,有关联的各个分支事务在一起称为全局事务
Seata是2019年1月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。官网地址
Seata事务管理有三种重要的角色:
在 Java 微服务架构中,业务侵入是指微服务之间的业务逻辑耦合。这种耦合通常是由于微服务之间共享了某些业务逻辑而导致的。举个例子,假设有两个微服务 A 和 B,它们都需要对订单进行处理。如果 A 微服务需要调用 B 微服务来查询订单信息,那么它们之间就存在业务侵入。这是因为 A 微服务需要知道 B 微服务的具体实现细节,才能完成它自己的业务逻辑。业务侵入会导致微服务之间的依赖性增加,降低了系统的可扩展性和可维护性。为了避免业务侵入,通常采用以下两种方法:采用服务注册与发现机制,让微服务之间通过接口进行通信,而不是直接调用。将公共的业务逻辑封装到一个独立的微服务中,让其他微服务通过接口来调用,而不是直接依赖具体的实现。
该配置文件主要分为两个部分
registry
:注册中心配置(TC都要注册到注册中心,这样RM才方便找到TC)
config
:TC服务的配置内容
修改后的配置文件内容如下:
server:
port: 7091
spring:
application:
name: seata-server
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
extend:
logstash-appender:
destination: 127.0.0.1:4560
kafka-appender:
bootstrap-servers: 127.0.0.1:9092
topic: logback_to_logstash
console:
user:
username: seata
password: seata
seata:
config:
# support: nacos, consul, apollo, zk, etcd3
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace:
group: SEATA_GROUP
username: nacos
password: nacos
data-id: seataServer.properties
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace:
cluster: default
username: nacos
password: nacos
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://localhost:3306/seata?useSSL=false
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
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 分支事务表
-- ----------------------------
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;
-- ----------------------------
-- 全局事务表
-- ----------------------------
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;
SET FOREIGN_KEY_CHECKS = 1;
错误记录:Unrecognized VM option 'CMSParallelRemarkEnabled' Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit.
解决方案:jdk版本过高,降低版本即可
错误记录:The stack size specified is too small, Specify at least 640k
解决方案:在启动的sh中配置Jvm的启动参数-Xss即可
可以看到Seata主页面(端口是你yaml文件中配置的端口,账号和密码都是yaml
中配置的)
Nacos中可以看到TC服务部署成功
导入项目seata-demo,项目百度网盘 提取码: ubc7
<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.5.2version>
dependency>
seata:
registry:
#TC服务注册中心的配置,微服务根据这些信息区注册中心获取tc服务地址
#参考tc服务自己的registry.conf中的配置
#包括:地址、namespace、group、application-name、cluster
type: nacos
nacos: #tc
server-addr: 127.0.0.1:8848
namespace: ""
group: SEATA_GROUP
application: seata-server #tc服务载nacos中的服务名称
username: nacos
password: nacos
tx-service-group: seata-demo#事务组,根据这个获取tc服务的cluster名称
service:
vgroup-mapping: #事务组与tc服务cluster的映射关系
seata-demo: default
XA规范是X/Open组织定义的分布式事务处理(DTP)标准,XA规范描述了全局的TM与局部的RM之间的接口,几乎所有主流数据库都对XA规范提供了支持。在分布式系统中,XA是一种经典的事务管理协议,它可以协调多个独立的事务管理器,实现分布式事务的ACID属性。XA协议定义了分布式事务的两阶段提交(Two-Phase Commit,2PC)算法,用于协调分布式事务的提交过程。
在XA模式下,分布式事务包括两个阶段:
XA协议的2PC算法可以保证在任何情况下都能够保持ACID属性,但是它也有一些缺点。例如,在第一阶段,如果某个参与者失败了,则协调者必须等待该参与者恢复后才能继续进行事务提交,这可能会导致事务的执行时间过长。此外,XA模式在某些情况下也可能存在性能瓶颈,因为它需要在多个参与者之间进行频繁的网络通信和状态同步。
Seata中的XA模式:
TC二阶段工作:
RM二阶段工作:
总结:
XA模式的优点:
XA模式的缺点:
实现XA模式
data-source-proxy-mode: XA
@Override
@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();
}
测试之前先看数据库的情况
然后使用Postman进行测试
事务发生了会滚
AT模式同样是分阶段提交事务模型,不过弥补了XA模型中资源锁定周期过长的缺陷。
阶段二提交时RM工作:
阶段二回滚时RM的工作:
总结:
AT模式和XA模式的最大区别是什么?
AT模式脏写问题:假如我们现在有一个事务A修改了数据库的某个值(修改值时要获得DB锁,修改完后释放DB锁),而另一个事务B在前面事务A修改事务后也对这个值进行了修改,现在第一个事务A要进行回滚操作,由于AT模式下是用快照来回滚的导致第二个事务设置的值直接覆盖掉了,也就是所谓的丢失更新的问题,导致这个问题的原因是,事务没有满足隔离性。为了解决这个问题,Seata提供了全局锁机制,由TC记录当前正在操作某行数据的事务,该事务在提交事务前持有全局锁,具备执行权。TC记录全局锁的方式如下:
xid | table | pk |
---|---|---|
事务1 | accout | 1 |
AT模式死锁问题:但加入全局锁机制后,又引入了新的问题,例如上面事务A获取DB锁后,并获取全局锁提交事务释放DB锁,而此时事务B获得DB锁也对数据库进行修改,并申请获得全局锁提交自己的事务,但此时全局锁在事务A手里,所以事务B需要等待,假定现在事务A现在要进行数据库会滚操作,但操作数据库之前需要获得DB锁,而DB锁现在在事务B手里,两者都在等待对方释放锁,所以陷入了死锁状态,而Seata解决该问题的方法是,设定事务申请获取全局锁不能超过30次,且每次间隔10ms,若没有申请到自己的资源,则释放自己所持有的资源(主动释放)
非Seata管理事务写隔离:前面介绍的事务A以及事务B都是在Seata管理的背景下,如果现在来了个非Seata管理的事务C 对数据库进行写操作,且它不需要获得全局锁,现在还是有可能出现上面的更新丢失的问题。Seata的解决方法是,它准备了两份快照,即数据更新前的快照和数据更新后的快照,如果两个快照不一致就说明在二阶段中有人做了手脚,会触发异常警报,这时候就需要人工处理。
实现AT模式
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;
创建第二张表,放在seata服务访问的数据库lock_table中,我这里是seata(用来记录全局锁)
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;
data-source-proxy-mode: AT
测试前先看一下数据库状态
开始测试,解雇和XA模式一样,会发生回滚,这里就不详细展示了
前面介绍的AT锁和XA锁实际上都加入了锁的机制,如XA的DB锁和AT的全局锁,由于锁的加入多少会对性能造成损耗。而TCC模式和AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码的方式来实现数据恢复,需要实现三个方法:
案例:一个扣除用户余额的业务,假定业务A原来的余额是100,需要余额扣减30
阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元(是从余额100中冻结30元),可用余额扣除30
阶段二(Confirm):假如要提交(Confirm),则冻结金额扣减30
阶段二(Confirm):如果要回滚(Cancel),则冻结金额扣减30,可用余额增加30
总结:
TCC的优点是什么:
TCC的缺点是什么:
所谓的幂等性,是分布式环境下的一个常见问题,一般是指我们在进行多次操作时,所得到的结果是一样的,即多次运算结果是一致的。
也就是说,用户对于同一操作,无论是发起一次请求还是多次请求,最终的执行结果是一致的,不会因为多次点击而产生副作用。
案例 改造account-service服务,利用TTC实现分布式事务:
需求如下:
空回滚:当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作,在未执行try操作时执行力cancel操作,这时cancel不能做回滚,也就是空回滚
业务悬挂;对于已经空回滚的业务,如果以后继续执行try(阻塞的业务可以执行了),就永远不可能confirm或cancel了,这就是业务悬挂,应当阻止执行空回滚后的try操作,避免悬挂
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` int(11) UNSIGNED NULL DEFAULT 0,
`state` int(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
SET FOREIGN_KEY_CHECKS = 1;
- 实现try业务
- 记录冻结金额和事务状态到account_freeze表
- 扣除account表可用金额
- 实现confirm业务
- 根据xid删除account_freeze表的冻结记录
- 实现concel业务
- 修改account_freeze表,冻结金额为0,state为2
- 修改account表,恢复可用金额
- 如何判断空回滚
- cancel业务中,根据xid查询account_freeze表,如果为null说明try还没做,需要空会滚
- 如何避免业务悬挂
- try业务中,需要xid查询account_freeze,如果已经存在则证明cancel已经执行,拒绝执行try业务
@LocalTCC //表示是TCC业务
public interface AccoutTCCService {
/**
* 这个方法上面加了一个TwoPhaseBusinessAction注解表示这是Try方法,commitMethod表示confirm方法,rollbackMethod表示cancel方法
* @BusinessActionContextParameter:该注解标记出来的参数都会放到上下文对象中
* @param UserId
* @param money
*/
@TwoPhaseBusinessAction(name="deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String UserId, @BusinessActionContextParameter(paramName = "money") int money);
/**
* confirm方法
* @param ctx 这是一个事务上下文对象,该对象可以获得事务相关信息,以及事务的一些参数信息
* @return
*/
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
@Service
@Slf4j
public class AccoutTCCServiceImpl implements AccoutTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
@Transactional
public void deduct(String UserId, int money) {
//获取事务id
String xid = RootContext.getXID();
//判断是否空业务悬挂
AccountFreeze accountFreeze=accountFreezeMapper.selectById(xid);
if (accountFreeze !=null) { //判断是否有冻结记录,如果有,一定是Cancel执行了,我要拒绝业务
return;
}
//1. 尝试扣减可用余额
accountMapper.deduct(UserId,money);
//2. 记录冻结金额和事务状态
AccountFreeze myaccountFreeze = new AccountFreeze();
myaccountFreeze.setUserId(UserId);
myaccountFreeze.setFreezeMoney(money);
myaccountFreeze.setState(AccountFreeze.State.TRY);
myaccountFreeze.setXid(xid);
accountFreezeMapper.insert(myaccountFreeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
//获取事务ID
String xid = ctx.getXid();
//根据ID删除记录
int cout = accountFreezeMapper.deleteById(xid);
return cout==1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
//获取事务id
String xid = ctx.getXid();
//判断是否做空回滚
AccountFreeze accountFreeze=accountFreezeMapper.selectById(xid);
if (accountFreeze==null) { //为null表示try现在还没有做,需要空回滚
//空回滚也需要插入记录
AccountFreeze myaccountFreeze = new AccountFreeze();
myaccountFreeze.setUserId(ctx.getActionContext("userId").toString());
myaccountFreeze.setFreezeMoney(0);
myaccountFreeze.setState(AccountFreeze.State.CANCEL);
myaccountFreeze.setXid(xid);
accountFreezeMapper.insert(myaccountFreeze);
return true;
}
//判断幂等性
if(accountFreeze.getState()==AccountFreeze.State.CANCEL)
{
//表示依据经过Cancel处理了
return true;
}
//查询冻结记录
//恢复可用余额
accountMapper.refund(accountFreeze.getUserId(),accountFreeze.getFreezeMoney());
//将冻结金额清0
accountFreeze.setFreezeMoney(0);
accountFreeze.setState(AccountFreeze.State.CANCEL);
int count=accountFreezeMapper.updateById(accountFreeze);
//状态改为cancel
return count==1;
}
}
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccoutTCCService accoutTCCService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money){
accoutTCCService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
Saga模式(没有隔离性)是seata提供的长事务解决方案。也分为两个阶段:
所谓长事务,就是需要长时间执行的事务,这类事务往往需要访问大量的数据对象,其执行周期甚至能达到几周或几月。但传统的事务执行时需要锁定占用资源,如果在这样的一个场景下,资源被长期锁定,带来的性能消耗可想而知。因此我们引入了SAGA模式来解决长事务。
Sega模式优点:
Sega模式缺点:
XA | AT | TCC | SAGA | |
---|---|---|---|---|
一致性 | 强一致 | 弱一致 | 弱一致 | 最终一致 |
隔离性 | 完全隔离 | 基于全局锁隔离 | 基于资源预留隔离 | 无隔离 |
代码侵入 | 无 | 无 | 有,需要编写三个接口 | 有,要编写状态机和补偿业务 |
性能 | 差 | 好 | 非常好 | 非常好 |
场景 | 对一执性、隔离性有高要求的业务 | 基于关系型数据库的大多数分布式事务场景都可以 | 对性能要求较高的事务、有非关系型数据库要参与的事务 | 业务流程长、业务流程多以及参与者包含其它公式或遗留系统服务,无法提供TCC模式要求的三个接口 |