Seata分布式事务解决方案详解
分布式事务现存方案
什么是分布式事务,这里就不做解释了,介绍一下下面的常用分布式事务解决方案
Seata分布式事务框架:阿里巴巴2019年开源的分布式事务解决方案。
AT,TCC,SAGA,XA。本文会详细分析AT和TCC原理以及对比,SAGA和XA暂时不在本文讨论中,后续会补上。提一嘴,Saga不存在并发执行问题,因为Saga本质上是一个责任链模式,在同一个线程上有严格的先后执行驱动顺序。
RocketMQ柔性事务,在国内绝大多数的互联网公司里,一般来说,重要的业务系统,一般都是使用mq柔性事务,大多情况下,一般来说都能确保数据是一致的
社区活跃度
Seata在19年初开源,一经发布,社区活跃度一路走高,因为其低侵入性的特性而大受欢迎。从开源地址来看目前seata在市面上的使用及活跃度是非常高的。
Seata分布式方案实现成本分析
这里只对AT和TCC做分析
AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作,使用成本很低,只需要安装seata server,然后在pom文件引用seata-all的包,在yml写上对应的配置即可,AT模式上游服务方法加上注解即可,适用于不希望对业务进行改造的场景,几乎 0 学习成本
但是其实这类统一的分布式事物的框架都不太稳定,所以基本上大家要么选用TCC方式去实现,要么就消息队列去保证最终一致性。但后两者都额外的增加了回归或实现最终一致性的成本,并且使用TCC方案会引出 TCC 的悬挂,幂等,空回滚等极端问题,并且TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增,这个也是TCC难用的地方。
除特定的数据强一致性场景(如金融),可能会针对事务做很多的工作。大部分项目对分布式事务的要求并不是很高,能不用尽量不用,解决分布式事务最好的方案就是不要导致分布式事务的产生。
AT和TCC模式对比
AT | TCC | |
---|---|---|
全局锁 | 需要 | 不需要 |
undo_log回滚日志 | 需要 | 不需要 |
commit/cancel阶段代码实现 | 不需要 | 需要 |
是否需要开发者解决悬挂和空回滚问题 | 不需要 | 需要 |
性能 | 低(高并发时的全局锁) | 高(无锁) |
Seata AT模式
Seata AT模式分布式事务原理
在每个服务对应的库里,都建好undo_log表,比如我们执行一个插入语句,seata会对应的去生成一个逆向操作的回滚日志。
我们的增删改操作和seata回滚日志的生成,会绑定在一个本地事务里提交,要么一起成功,要么一起失败。
上游服务开启一个全局事务,调用下游服务的时候,rpc调用会传递xid給下游服务,下游服务也会去注册分支事务branch_id,下游本地事务提交成功以后,会通知seata server分支事务成功
如果下游事务失败,会通知seata server分支事务失败,然后seata server会通知每个服务的seata客户端进行回滚,根据MySql里的undo log表进行逆向操作。
Seata AT模式分布式事务读写隔离原理
没有全局锁就会出现数据错乱,分布式事务1回滚补偿,但是补偿的数据已经被分布式事务2篡改了。
读隔离:在全局层面,是读未提交,本地事务提交了,但是全局事务还没提交,所以是一个未提交的状态,这时候另一个分布式事务2过来了,可以查询到没有提交的分布式事务1的已经提交的本地事务更新的数据。
Seata本地锁与全局锁的死锁问题以及超时机制
分布式事务1根据之前的undo log 执行逆向操作sql进行补偿,但是需要去获取11001的本地锁,但是此时本地锁被分布式事务2持有,并且它在等待获取全局锁提交事务释放本地锁,但是全局锁又被分布式事务1持有着,典型的死锁。
Seata为了解决死锁,设置了超时机制,超过一定时间还没获取到全局锁,就任务本次分布式事务失败,本地事务回滚。
全局锁的存在,导致AT模式在高并发情况下,会出现吞吐量降低的情况,比如举个例子
生单链路中,用户发起一个生单请求,上游订单服务调用下游库存服务,但是一个sku在库存表里对应一条数据,如果大量的用户同时对同一个sku发起生单请求,那么就会出现某一个库存sku id的全局锁竞争的情况了,导致吞吐量大大降低,这也是AT模式的不足之处。
TCC
TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的全局锁。
try(预留一些资源,实际的业务动作并没有执行)、commit(实际的业务动作执行)、cancel(把预留的资源做一个逆向补偿,取消资源的预留)
Seata AT模式在高并发的情况下,由于全局锁的概念,可能导致吞吐量降低(超时机制保证了不会出现死锁问题)
AT模式有点像自动挡,加个注解,加一张undo_log表就不需要管了,刚性事务,要么全都成功,要么全都失败。
用不要有全局锁的分布式事务方案,TCC,手动挡,不依赖于底层数据资源的事务支持。
TCC模式可能会存在的问题
三者都只是有可能会产生,不是必然事件,但是我们代码中,需要去做防御性编程处理这三种情况,才能使我们的TCC代码足够的健壮
简单来说就是,try可能会出现一些问题,导致卡住了没好好去执行,seata server可能认为这个try没有好好去执行,让你先跑一个cancel回滚操作,这个可能性也是存在的,但是此时你这个try并没有执行,所以这个回滚,是个空回滚,然后等你空回滚完了,try又执行了,然后你的资源就被try悬挂起来了。
还有一种场景,比方说,先跑了一个try,seata server认为try已经成功了,但是其实try并没有成功,然后第二个分支事务失败了,会通知第一个分支事务回滚,这时候就会在没有try成功的情况下出现一个空回滚
悬挂
1.4.2版本之后增加了TCC防悬挂措施,需要数据源支持。
其实就是有一些资源被悬挂起来后续无法处理了
- 发起方通过RPC调用参与者一阶段Try,但是发生网络阻塞导致RPC超时
- RPC超时后,TC会回滚分布式事务(可能是发起方主动通知TC回滚或者是TC发现事务超时后回滚),调用已注册的各个参与方的二阶段Cancel
- 参与方空回滚后,发起方对参与者的一阶段Try才开始执行,进行资源预留从而形成悬挂
空回滚
当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,
Cancel方法需要有办法识别出此时Try有没有执行。如果Try还没执行,
表示这个Cancel操作是无效的,即本次Cancel属于空回滚。
如果Try已经执行,那么执行的是正常的回滚逻辑。
二阶段重试幂等
try成功了以后,seata server会通知分支事务执行commit,但是如果commit失败了,它会不断的进行重试,cancel同理。
只要有重试,那么就要保证commit,cancel幂等。
Seata AT&&TCC实现方案
AT模式
每个服务对应的库里,都要加入一张表undo_log,以提供给seata执行逆向操作
CREATE TABLE `undo_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`branch_id` bigint NOT NULL COMMENT '分支事务id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '全局事务id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '上下文',
`rollback_info` longblob NOT NULL COMMENT '回滚日志',
`log_status` int NOT NULL COMMENT '日志状态',
`log_created` datetime NOT NULL COMMENT '创建时间',
`log_modified` datetime NOT NULL COMMENT '更新时间',
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
上游服务方法加注解
@GlobalTransactional(rollbackFor = Exception.class)
下游被rpc调用的方法,加上本地spring事务注解
@Transactional(rollbackFor = Exception.class)
上游服务会去跟seata server注册全局事务xid,然后rpc调用的时候将xid传递给下游服务,下游服务获取本地锁,执行本地事务,插入undo log,向seata server申请分支事务branchId,然后申请全局锁,提交本地事务释放本地锁,通知seata server分支事务成功/失败。
下游服务分支事务全都执行成功后,上游服务向seata server提交全局事务,全局事务提交成功后,各个分支事务删除xid+branch id的undo logs。
但是假设一个请求 基于seata对一条数据加了全局锁,并进行了本地分支事务提交。此时全局事务没有提交。另一个请求过来(这个请求就是简单的更新这条数据,不是分布式事务)自然就不需要获取全局锁。也就是可以直接更新。那全局事务再进行回滚时就有问题了,这也正是我们开发时需要考虑的地方,当有可能操作同一条数据引起并发问题的时候,给他加上全局事务,去获取全局锁。
分布式事务成功截图
分布式事务回滚截图
TCC模式
TCC不需要undo log表,但是针对空回滚问题,可以搞一张空回滚记录表,下文会仔细讲解。
调用方 方法可以打上全局事务注解,如果是AT+TCC混合模式,TCC这里不要打全局事务注解,在AT上游打上就行了,理解成二者为一个分布式事务。
@GlobalTransactional(rollbackFor = Exception.class)
很多时候,预留资源的try并不好做,try可以直接做成实际业务操作,比如直接锁库存。
在 TCC interface接口层,写一个注解
@LocalTCC
代表这是本地TCC接口
搞三个接口,try(预留一些资源,实际的业务动作并没有执行)、commit(实际的业务动作执行)、cancel(把预留的资源做一个逆向补偿,取消资源的预留)。
@TwoPhaseBusinessAction(name = "tccInterfaceService", commitMethod = "commitMethodName", rollbackMethod = "rollbackMethodName")
try方法,上面打这个注解,name写你接口交给spring容器管理的bean名,commitMethod写上第二个commit方法名字,rollbackMethod写上执行cancel回滚方法的名字。
极端问题解决方案
上面已经知道,TCC方案会有极端情况出现(悬挂、空回滚、二阶段重试幂等),如何解决呢,我这边引用了TccResultHolder,存储TCC第一阶段执行结果,用于解决这些问题。
我们定义一个类 TccResultHolder,存储TCC第一阶段执行结果,用于解决TCC幂等,空回滚,悬挂问题。
类里面搞俩常量,标识TCC try阶段开始执行的标识以及标识TCC try阶段执行成功的标识,再定义一个ConcurrentHashMap用来保存TCC事务执行过程的状态。
/**
* 标识TCC try阶段开始执行的标识
*/
private static final String TRY_START = "TRY_START";
/**
* 标识TCC try阶段执行成功的标识
*/
private static final String TRY_SUCCESS = "TRY_SUCCESS";
/**
* 保存TCC事务执行过程的状态
*/
private static Map<Class<?>, Map<String, String>> map =
new ConcurrentHashMap<Class<?>, Map<String, String>>();
tagTryStart方法和tagTrySuccess方法用于标记try阶段开始执行和try阶段执行成功,把执行tcc的实现类,业务唯一标识(如sku),全局事务xid(通过BusinessActionContext获取xid)传进来即可。
/**
* 标记try阶段开始执行
*
* @param tccClass 执行tcc的实现类
* @param bizKey 业务唯一标识
* @param xid 全局事务xid
*/
public static void tagTryStart(Class<?> tccClass, String bizKey, String xid) {
setResult(tccClass, bizKey, xid, TRY_START);
}
/**
* 标记try阶段执行成功
*
* @param tccClass 执行tcc的实现类
* @param xid 全局事务xid
*/
public static void tagTrySuccess(Class<?> tccClass, String bizKey, String xid) {
setResult(tccClass, bizKey, xid, TRY_SUCCESS);
}
/**
* 判断标识是否为空
*
* @param tccClass
* @param xid
* @return
*/
public static boolean isTagNull(Class<?> tccClass, String bizKey, String xid) {
String v = getResult(tccClass, bizKey, xid);
if (StringUtils.isBlank(v)) {
return true;
}
return false;
}
/**
* 判断try阶段是否执行成功
*
* @param tccClass
* @param xid
* @return
*/
public static boolean isTrySuccess(Class<?> tccClass, String bizKey, String xid) {
String v = getResult(tccClass, bizKey, xid);
if (StringUtils.isNotBlank(v) && TRY_SUCCESS.equals(v)) {
return true;
}
return false;
}
public static void setResult(Class<?> tccClass, String bizKey, String xid, String v) {
Map<String, String> results = map.get(tccClass);
if (results == null) {
synchronized (map) {
if (results == null) {
results = new ConcurrentHashMap<>();
map.put(tccClass, results);
}
}
}
//保存当前分布式事务id
results.put(getTccExecution(xid, bizKey), v);
}
public static String getResult(Class<?> tccClass, String bizKey, String xid) {
Map<String, String> results = map.get(tccClass);
if (results != null) {
return results.get(getTccExecution(xid, bizKey));
}
return null;
}
public static void removeResult(Class<?> tccClass, String bizKey, String xid) {
Map<String, String> results = map.get(tccClass);
if (results != null) {
results.remove(getTccExecution(xid, bizKey));
}
}
private static String getTccExecution(String xid, String bizKey) {
return xid + "::" + bizKey;
}
try
在try阶段方法最开始的时候,执行tagTryStart方法,标识try阶段开始执行,通过map实现记录整个TCC执行的过程状态。
为了解决空回滚,可以在自己的库里再加一张表,tcc Class名称,xid,业务id,当发生空回滚的时候,就往这张表里插入一条记录。
解决空悬挂的思路:即当rollback接口出现空回滚时,需要打一个标识(在数据库中查一条记录),在try这里判断一下
封装一个isEmptyRollback方法,如果查询到有空回滚的记录,就return true。
if (isEmptyRollback()) {
// 移除TCC标记
TccResultHolder.removeResult(getClass(), skuCode, xid);
return false;
}
在try方法结束的时候,标记一下try成功
// 标识try阶段执行成功
TccResultHolder.tagTrySuccess(getClass(), skuCode, xid);
commit
在commit方法中,加入如下代码,判断holder中是否记录try成功记录,如果没有就return
// 当出现网络异常或者TC Server异常时,会出现重复调用commit阶段的情况,所以需要进行幂等操作
if (!TccResultHolder.isTrySuccess(getClass(), skuCode, xid)) {
return;
}
然后在commit方法结束,调用remove方法,移除TCC标记
// 移除标识
TccResultHolder.removeResult(getClass(), skuCode, xid);
rollback
在rollback方法中,判断holder中是否有try记录,如果没有的话,说明此时出现了空回滚,往上面提到的空回滚表中加入一条记录,然后再return,不需要rollback了。
// 空回滚处理
if (TccResultHolder.isTagNull(getClass(), skuCode, xid)) {
log.info("mysql:出现空回滚");
insertEmptyRollbackTag();
return;
}
// try阶段没有完成的情况下,不必执行回滚,因为try阶段有本地事务,事务失败时已经进行了回滚
// 如果try阶段成功,而其他全局事务参与者失败,这里会执行回滚
if (!TccResultHolder.isTrySuccess(getClass(), skuCode, xid)) {
// 移除标识
TccResultHolder.removeResult(getClass(), skuCode, xid);
log.info("mysql:无需回滚");
insertEmptyRollbackTag();
return;
}
然后在rollback方法结束,调用remove方法,移除TCC标记
// 移除标识
TccResultHolder.removeResult(getClass(), skuCode, xid);
Seata使用踩坑注意事项
假设一个请求基于 seata 对一条数据加了全局锁,并进行了本地分支事务提交。此时全局事务没有提交,另一个请求过来(这个请求就是简单的更新这条数据,不是分布式事务)自然就不需要获取全局锁,也就是可以直接更新,那全局事务再进行回滚时就有问题了,这也正是我们开发时需要考虑的地方,当有可能操作同一条数据引起并发问题的时候,给他加上全局事务,去获取全局锁。
还有听说有公司之前用Seata这个框架,生产上出大事故,导致账单数据经常丢失,而且经常出现死锁。负责开发的兄弟加班了几个月,每天晚上加班修复数据,后面弃用了seata,就没出现过这种事故了。因为公司经历过的这个事,导致那哥们儿对 Seata心存疑虑。
Dubbo+ZK整合Seata处理分布式事务
目前现有的落地方案都是spring cloud alibaba+nacos整合的seata,由于公司的技术栈为dubbo+zookeeper,并且seata官网并未给出zk整合seata的具体实现,所以我这里给出下面的方案重新整合。
1. 本地安装seata server
下载 https://github.com/seata/seata/releases/download/v1.3.0/seata-server-1.3.0.zip
这里跟项目中的seata客户端依赖版本保持一致(Seata 1.4.2版本中数据库中时间字段不能使用datetime类型否则会引起序列化错误)
解压安装包得到如下目录
.
├──bin
├──conf
└──lib
修改启动脚本⽂件,调整jvm内存。(机器内存⾜够的也可以不⽤改)
编辑seata-server.sh 或 seata-server.bat
修改120⾏处内容
默认堆内存是2048M,建议修改为512M即可,栈内存,永久代的值都可以不⽤改,原配置截图如下:
建议修改的值为
-Xmx512m -Xms512m -Xmn256m
2. seata与zk整合,以zk为注册中心
这里以zk为例子,nacos网上资源很多,不做过多描述
修改conf文件夹里file.conf和registry.conf
registry.conf配置
registry {
type = "zk"
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
sessionTimeout = 6000
connectTimeout = 2000
username = ""
password = ""
}
}
config {
Type = "zk"
zk {
serverAddr = "127.0.0.1:2181"
sessionTimeout = 6000
connectTimeout = 2000
username = ""
password = ""
}
file {
name = "file.conf"
}
}
file.conf配置
## transaction log store, only used in seata-server
store {
mode = "db"
## file store property
file {
dir = "sessionStore"
# branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
maxBranchSessionSize = 16384
# globe session size , if exceeded throws exceptions
maxGlobalSessionSize = 512
# file buffer size , if exceeded allocate new buffer
fileWriteBufferCacheSize = 16384
# when recover batch read size
sessionReloadReadSize = 100
# async, sync
flushDiskMode = async
}
## database store property
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
datasource = "druid"
## mysql/oracle/postgresql/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
url = "jdbc:mysql://127.0.0.1:3306/seata"
user = "root"
password = "root"
minConn = 5
maxConn = 30
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
}
修改 conf/nacos-config.txt配置为zk-config.properties
service.vgroupMapping.order-service-seata-service-group=default
service.vgroupMapping.account-service-seata-service-group=default
service.vgroupMapping.storage-service-seata-service-group=default
service.vgroupMapping.business-service-seata-service-group=default
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true
store.db.user=root
store.db.password=root
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
3. 执⾏启动脚本(默认是8091端⼝)
sudo chmod -R 777 seata/*
# 执⾏启动脚本⽂件
sudo bin/seata-server.sh
# 如果启动不了,提示Operation not permitted
sudo xattr -r -d com.apple.quarantine seata/*
# 执⾏启动脚本⽂件
sudo bin/seata-server.sh
4. Dubbo项目pom.xml配置
${seata.version}版本要与seata server版本一致
<!-- https://mvnrepository.com/artifact/io.seata/seata-all -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
5. application.yml配置
#seata配置 zk版
seata:
enable-auto-data-source-proxy: false # 如果引⼊的是seata-spring-boot-starter 请关闭⾃动代理
application-id: seata-server # Seata应⽤的名称
tx-service-group: default_tx_group # 事务组
service:
vgroup-mapping:
default_tx_group: default
registry:
type: zk
zk:
server-addr: 127.0.0.1:2181
connect-timeout: 2000
session-timeout: 6000
cluster: default
config:
type: zk
zk:
server-addr: 127.0.0.1:2181
connect-timeout: 2000
session-timeout: 6000
#seata配置 nacos版
seata:
enable-auto-data-source-proxy: false # 如果引⼊的是seata-spring-boot-starter 请关闭⾃动代理
application-id: seata-server # Seata应⽤的名称
tx-service-group: default_tx_group # 事务组
service:
vgroup-mapping:
default_tx_group: default
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848 # Nacos服务地址
namespace: seata # Seata的NameSpace ID
group: SEATA_GROUP
username: nacos
password: nacos
registry:
type: nacos # 基于Nacos实现分布式事务管理
nacos:
server-addr: 127.0.0.1:8848 # Nacos服务地址
namespace: seata # Seata的NameSpace ID
group: SEATA_GROUP
username: nacos
password: nacos
cluster: default
application: seata-server
总结
自动挡跑车,好用但少用,容易出事情,抛砖引玉,欢迎大家讨论
上面提到的悬挂空回滚等问题解决方案,是一套可以落地的通用解决方案,如果大家有想法改进的可以提
不要怼我,我很脆弱