文章目录
1. 分布式事务介绍
事务分为两种
- 本地事务
- 分布式事务
①:本地事务
大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(Local Transaction)。本地事务的ACID
特性是数据库直接提供支持。
本地事务要求只有一个数据库连接Connection
,通过这个连接Connection
对数据库事务进行开启begin
、提交commit
来保证整个代码的事务!伪代码如下:
Connection conn = getConnection(); //获取数据库连接
conn.setAutoCommit(false); //开启事务
try {
//...执行增删改查sql
conn.commit(); //提交事务
} catch (Exception e) {
conn.rollback();//事务回滚
} finally {
conn.close();//关闭链接
}
Spring通过AOP
的方式对数据库事务进行了整合,使我们平时在解决本地事务时,只需要加上@Transactional
注解即可很方便控制本地事务!
②:分布式事务
对于微服务架构,完成某一个业务功能可能需要横跨多个服务,操作多个数据库。这就涉及到到了分布式事务。分布式事务需要保证在多个数据库连接下,代码要么么全部成功,要么全部失败。
本质上来说,分布式事务就是操作多个数据库连接时,也能保证数据要么一起修改成功,要么一起失败!为了保证不同资源服务器的数据一致性。
分布式事务的出现场景:
-
跨库事务:一个应用某个功能需要操作多个库
-
分库分表:一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。如,对于sql:
insert into user(id,name) values (1,"张三"),(2,"李四")
。这条sql是操作单库的语法,单库情况下,可以保证事务的一致性。但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2
。所以数据库中间件要将其改写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此就面临着分布式事务的问题
-
微服务:微服务自然不多说,一个接口会调用很多服务,每个服务都有独立的数据库连接!
③:常见的分布式事务解决方案
1. 2pc
2PC
有XA/JTA
、Seata
的AT
模式等等,主要表现为:有全局锁,保证强一致性,更适合金融领域
TCC
也可以看做是两阶段提交,不过不需要全局锁,保证最终一致性。比XA/JTA
,Seata
的AT
模式效率高,但需要手动实现try、confirm、cancel
接口,实现起来比较难!在微服务架构下实现TCC
时,很有可能出现网络超时、重发,机器宕机等一系列的异常,出现空回滚、幂等、悬挂的问题。
常用的TCC开源框架有:Tcc-Transaction、 Hmily、 ByteTCC、 EasyTransaction、 Seata TCC等
2. TCC设计规范
-
①:允许空回滚
- 空回滚出现的原因:
Try
超时(丢包),分布式事务回滚触发Cancel
,出现未收到Try
,收到Cancel
的情况,Cancel
中对数据进行新增或者减少,造成数据不一致!因此Cancel
方法需要识别出这是一个空回滚,然后直接返回成功。 解决方案
:由一个 事务ID 贯穿全局,并保存当前 事务ID 下的try、confirm、cancel
记录到数据库,在执行cancel
时先检查数据库中是否有try
的记录,如果没有操作过try
,cancel
就不执行或者返回null!
- 空回滚出现的原因:
-
②:防悬挂控制
悬挂出现的原因
:Try
超时(拥堵),分布式事务回滚触发Cancel
,执行了空回滚。之后拥堵的Try
到达,又对数据进行了修改,造成数据不一致!Cancel
比Try
先执行!要运行空回滚,但要拒绝空回滚之后的Try
操作解决方案
:由一个 事务ID 贯穿全局,并保存当前 事务ID 下的try、confirm、cancel
记录到数据库。在执行try
操作时,先判断当前事务id的cancel、confirm
是否执行过,只要执行过,就不再执行下面的try
操作(try
操作就是正常业务操作:扣库存、扣余额等等),防悬挂
-
③:幂等控制
Try
,Confirm
,Cancel
都需要保证幂等性。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,可以使用业务id进行判重操作!
3. 可靠消息(最终一致性)
强一致性的XA、AT
可能更适合金融领域,但对于一些高并发场景时,比如电商,其实更多的是采用补偿的措施去解决分布式问题,比如mq发消息等,虽然可能存在消息丢失,但可以人工补偿,保证最终一致性即可,避免使用全局锁拖慢整个系统性能!实现方式有以下几种
- 本地消息表,发消息时把消息存储在本地,通过定时任务轮询该消息的发送结果,如果一定时间没有接收到
ACK
响应,则认为发送失败,然后重复发送,再失败一定次数后,转人工处理 RocketMQ
的事务消息,其实就是把本地消息轮询放在Broker
端,如果Broker
没有正确接收到消息,则采用事务回查机制,根据事务回查结果来决定是否给消费端投递消息。
4. 最大努力通知(最终一致性)
最大努力通知型( Best-effort delivery)是最简单的一种柔性事务,是分布式事务中对一致性要求最低的一种,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。典型的使用场景:如银行通知、商户通知等。
最大努力通知型的实现方案,一般符合以下特点:
不可靠消息
:业务活动主动方,在完成业务处理之后,向业务活动的被动方发送消息,直到通知N次后不再通知,允许消息丢失(不可靠消息)。定期校对
:业务活动的被动方,根据定时策略,向业务活动主动方查询(主动方提供查询接口),恢复丢失的业务消息。
所以最大努力通知方案需要实现如下功能:
- 消息重复通知机制。
- 消息查询校对功能。
以发短信业务为例,除了要回调通知发送端外,还要允许发送端查询发送状态。保证最终一致
最大努力通知与可靠消息最终一致性有什么区别?
- 思想不同
- 可靠消息最终一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。
- 最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
- 两者的业务应用场景不同
- 可靠消息最终一致性关注的是交易过程的事务一致,以异步的方式完成交易。
- 最大努力通知关注的是交易后的通知事务,即将交易结果可靠的通知出去。
- 技术解决方向不同
- 可靠消息最终一致性要解决消息从发出到接收的一致性,即消息发出并且被接收到。
- 最大努力通知无法保证消息从发出到接收的一致性,只提供消息接收的可靠性机制。可靠机制是,最大努力的将消息通知给接收方,当消息无法被接收方接收时,由接收方主动查询消息(业务处理结果)
3. 2PC与3PC
2PC:
2PC
两阶段提交协议(Two Phase Commit
)从字面意思来理解, Two Phase Commit,就是将提交(commit)过程划分为2个阶段(Phase):如下图
其中2PC
的实现有为XA
和TCC
-
XA
是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。 -
TCC
是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。
-
XA
是资源(数据库)的分布式事务,强一致性,在整个过程中,数据一直锁住状态;即从prepare
到commit
、rollback
的整个过程中,事务管理器TM
一直把持着数据库的锁,如果有其他人要修改数据库的该条数据,就必须等待锁的释放。(简单一句话就是长事务风险)。另外在开发过程中,开发人员无感知XA的代码入侵 -
TCC
是业务的分布式事务,最终一致性,不会出现长事务的锁风险。为什么TCC
不会出现长事务的锁风险呢?因为try
是本地事务,第一阶段tyy
后就commit
提交事务,confirm
和cancel
也是本地事务,可以直接提交事务;故多个短事务不会出现长事务的风险。在开发过程中,开发人员感受到两阶段提交的存在;即开发人员必须实现try
、confirm
和cancel
方法。第一阶段事务正常提交,不过会有一个字段记录着本次提交的数据变量(比如减10
,则会把数据10
记录下来),如果业务正常执行confirm,清除掉记录的值10
,如果业务异常,则根据记录下来的10
对原数据反向补偿(执行加10
)操作!
2PC存在的问题:
- 同步阻塞问题:两阶段提交执行过程中,所有的参与者都需要听从协调者的统一调度,期间处于阻塞状态而不能从事其他操作,这样效率及其低下。
- 单点故障:协调者在整个两阶段提交过程中扮演着举足轻重的作用,一旦协调者所在服务器宕机,那么就会影响整个数据库集群的正常运行,比如在第二阶段中,如果协调者因为故障不能正常发送事务提交或回滚通知,那么参与者们将一直处于阻塞状态,整个数据库集群将无法提供服务。
- 数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务
commit
的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit
操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
3PC
三阶段提交(3PC),是二阶段提交(2PC)的改进版本。为弱化2PC
存在的问题,三阶段提交有两个改动点:
- 引入超时机制。同时在协调者和参与者中都引入超时机制,防止断网无法接受通知而阻塞!
- 新增一个提交准备
CanCommit
阶段。作用是在访问TM
之前先检验一下网络是否通畅。也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit
、PreCommit
、DoCommit
三个阶段。
①:2PC与3PC的区别
相对于2PC
,3PC
主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit
。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort
响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit
操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
所以,无论是二阶段提交还是三阶段提交都无法彻底解决分布式的一致性问题。3PC
虽然解决了部分问题,做了部分优化,但是也增加了一次RM
与TM
的交互,并没有想象的那么强大,所以用3PC
的也不多,因为这些优化,其实也可以自己在2pc
中去做!
3. Seata介绍
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata
将为用户提供了 AT
、TCC
、SAGA
和 XA
事务模式,为用户打造一站式的分布式解决方案。AT模式是阿里首推的模式,阿里云上有商用版本的GTS(Global Transaction Service 全局事务服务)
分布式事务模式 | 介绍 | 技术栈 |
---|---|---|
AT 模式 | 无侵入的分布式事务解决方案,适用于不希望对业务进行改造的场景 ,几乎0学习成本(sql都由框架托管统一执行,会存在脏写问题) | seata、shardingsphere |
TCC 模式 | 对业务有侵入性,需要实现三个接口。适用于核心系统等对性能有很高要求的场景(第一阶段会产生行锁,事务执行太久会锁行很久) | seata、service-comb |
Saga 模式 | 长事务解决方案,适用于业务流程长且需要保证事务最终一致性的业务系统(第一阶段就操作DB,会存在脏读问题) | seata、shardingsphere、service-comb |
XA模式 | 分布式强一致性的解决方案,但性能低而使用较少。 | seata、shardingsphere |
相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模 式高很多。但需要改 造成try
、confirm
、canel
3个接口,开发成本高
官网:https://seata.io/zh-cn/index.html
源码: https://github.com/seata/seata
官方Demo: https://github.com/seata/seata-samples
①:Seata的三种角色
在 Seata 的架构中,一共有三个角色:
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC
交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
其中,TC
为单独部署的 Server
服务端,TM
和 RM
为嵌入到应用中的 Client
客户端。
在 Seata
中,一个分布式事务的生命周期如下:
- 事务管理器
TM
请求 事务协调者TC
开启一个全局事务。TC
会生成一个XID
作为该全局事务的编号。XID
会在微服务的调用链路中传播,保证将多个微服务的子事务关联在一起。TM
是一个事务的发起者,可以是事务方法的入口 - 客户端的资源管理器
RM
请求TC
将本地事务注册为全局事务的分支事务,通过全局事务的XID
进行关联。 TM
请求TC
告诉XID
对应的全局事务是进行提交还是回滚。TC
驱动RM
们将XID
对应的自己的本地事务进行提交还是回滚。
②:Seata的设计思路
Seata
的AT
模式的核心是对业务无侵入,是一种改进后的两阶段提交
,其设计思路如下:
- 第一阶段:
RM
端提交本地事务,生成undo
日志记录,释放本地锁和连接资源。并向TC
注册分支事务,通过全局事务的XID
进行关联。 - 第二阶段:完全异步
- 分布式事务操作成功,则
TC
通知RM
异步删除undolog
- 分布式事务操作失败,则
TM
向TC
发送回滚请求,RM
收到协调器TC
发来的回滚请求,通过XID
和Branch ID
找到相应的回滚日志记录,通过回滚记录生成反向的更新SQL
并执行,以完成分支的回滚。
- 分布式事务操作成功,则
③:设计亮点以及存在的问题
设计亮点:
- 应用层基于
SQL
解析实现了自动补偿,从而最大程度的降低业务侵入性; - 将分布式事务中
TC
(事务协调者)独立部署,负责事务的注册、回滚;避免单点故障 - 通过全局锁实现了写隔离与读隔离。
存在的问题:
- 性能损耗:由于
Seata
在解决分布式事务时,需要多次与TC
通讯,每次都需要一次远程通讯RPC,而且是同步的。还要写undoLog
日志,每条写SQL都会增加这么多开销,粗略估计会增加5
倍响应时间。 - 性价比:如果仅有极少的请求会失败,需要触发回滚。在使用了
Seata
解决分布式事务后,为了极少的交易回滚,需要将大部分的成功交易的响应时间增加5倍,这样的代价有待考量。 - 死锁问题:
Seata
的引入全局锁会额外增加死锁的风险,但如果出现死锁,会不断进行重试,最后靠等待全局锁超时,这种方式并不优雅,也延长了对数据库锁的占有时间。
4. Seata的DB模式配置
Seata分TC、TM
和RM
三个角色,TC
(Server端)为单独服务端部署,TM
和RM
(Client端)由业务系统集成。由于Seata
的事务协调者TC
是单独配置的,所以在使用Seata
时需要先配置TC
,然后再配置客户端RM
和 TM
TC
环境配置- 客户端
RM
和TM
配置
TC
(Server端)存储模式(store.mode)现有file、db、redis三种(后续将引入raft,mongodb)
- file模式:无需改动,直接启动即可
- db模式:高可用模式,全局事务会话信息通过
db
共享,相应性能差些 - redis模式:Seata-Server 1.3及以上版本支持,性能较高,存在事务信息丢失风险,请提前配置合适当前场景的
redis
持久化配置.
下面来看一下Seata
的高可用DB
模式在不同项目中的配置:
① :Spring boot单体项目集成Seata
事务协调者TC环境配置
-
步骤一:下载seata服务器和源码的安装包 地址:https://github.com/seata/seata/releases
需要写 服务器文件解释 和 源码文件解释 -
步骤二:建表(仅DB模式下需要建三张表)
建表的sql,可以从seata的源码中获取
建成后如下所示:
-
步骤三:修改registry.conf 的配置
registry.conf
配置文件在seata-server
启动时会被加载,通过registry.conf
找到seata-server
对应的配置信息
registry.conf
配置如下:
registry {
# seata Server端启动时会先加载type,根据type 去找对应的配置
#file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "file"
nacos {
application = "seata-server"
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
namespace = ""
cluster = "default"
username = ""
password = ""
}
eureka {
serviceUrl = "http://localhost:8761/eureka"
application = "default"
weight = "1"
}
redis {
serverAddr = "localhost:6379"
db = 0
password = ""
cluster = "default"
timeout = 0
}
zk {
cluster = "default"
serverAddr = "127.0.0.1:2181"
sessionTimeout = 6000
connectTimeout = 2000
username = ""
password = ""
}
consul {
cluster = "default"
serverAddr = "127.0.0.1:8500"
aclToken = ""
}
etcd3 {
cluster = "default"
serverAddr = "http://localhost:2379"
}
sofa {
serverAddr = "127.0.0.1:9603"
application = "default"
region = "DEFAULT_ZONE"
datacenter = "DefaultDataCenter"
cluster = "default"
group = "SEATA_GROUP"
addressWaitTime = "3000"
}
# 会根据 type = file 找到这里,然后再去读取"file.conf"
file {
name = "file.conf"
}
}
config {
# file、nacos 、apollo、zk、consul、etcd3
type = "file"
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = ""
password = ""
dataId = "seataServer.properties"
}
consul {
serverAddr = "127.0.0.1:8500"
aclToken = ""
}
apollo {
appId = "seata-server"
## apolloConfigService will cover apolloMeta
apolloMeta = "http://192.168.1.204:8801"
apolloConfigService = "http://192.168.1.204:8080"
namespace = "application"
apolloAccesskeySecret = ""
cluster = "seata"
}
zk {
serverAddr = "127.0.0.1:2181"
sessionTimeout = 6000
connectTimeout = 2000
username = ""
password = ""
nodePath = "/seata/seata.properties"
}
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
name = "file.conf"
}
}
- 步骤四:修改file.config中的配置
如果使用DB模式,file.config
需要修改两点
- 修改存储模式:
store.mode = "db"
- 修改数据库连接
file.conf
配置如下:
## transaction log store, only used in seata-server
store {
## seata-server的各种存储模式,当前选择db模式
## store mode: file、db、redis
mode = "db"
## rsa decryption public key
publicKey = ""
## file store property
file {
## store location dir
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
}
## 根据上面选择的db模式,配置数据库连接信息
## 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"
## if using mysql to store the data, recommend add rewriteBatchedStatements=true in jdbc connection param
url = "jdbc:mysql://127.0.0.1:3306/seata"
user = "root"
password = "123456"
minConn = 5
maxConn = 100
# 数据库中刚才我们建立的三张表
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
## redis store property
redis {
## redis mode: single、sentinel
mode = "single"
## single mode property
single {
host = "127.0.0.1"
port = "6379"
}
## sentinel mode property
sentinel {
masterName = ""
## such as "10.28.235.65:26379,10.28.235.65:26380,10.28.235.65:26381"
sentinelHosts = ""
}
password = ""
database = "0"
minConn = 1
maxConn = 10
maxTotal = 100
queryLimit = 100
}
}
- 步骤五:启动Seata Server
- windows启动:点击
seata-server.bat
- linux启动:点击
seata-server.sh
- 源码启动: 执行
server
模块下io.seata.server.Server.java
的main
方法 - 命令启动:
bin/seata-server.sh -h 127.0.0.1 -p 8091 -m db -n 1 -e test
- windows启动:点击
启动成功可以看到如下所示:
spring boot项目配置
业务场景:用户下单,整个业务逻辑由三个微服务构成:
- 订单服务:根据采购需求创建订单。
- 仓储服务:对给定的商品扣除库存数量。
- 帐户服务:从用户帐户中扣除余额。
由于是Spring Boot
单体项目,不同数据库的调用需要使用多数据源,在此基础上才能产生分布式事务。当出现业务异常时,需要undo_log
表来进行事务回滚,所以每一个数据库都要建一个undo_log
表
注意:
- 这个
undo_log
表并不是mysql
自带的undo_log
日志版本链,不要搞混了, - 这个
undo_log
表是Seata
的AT
模式自带的表,用于数据回滚,AT
模式不使用undo_log
日志版本链进行回滚, - 而原生的
XA
模式(不是seata
的XA
模式)的回滚就用的是mysql
自带的undo_log
日志版本链
# AT模式下的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;
- 步骤一:引入seata依赖
<!--seata对springboot的支持 -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
- 步骤二:添加registry.conf文件,指定客户端配置文件地址
registry.conf
配置内容如下
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "file"
file {
name = "file.conf"
}
}
config {
# file、nacos 、apollo、zk、consul、etcd3、springCloudConfig
type = "file"
file {
name = "file.conf"
}
}
- 步骤三:添加file.config文件,填写客户端请求stata服务的配置
file.config
配置内容如下
transport {
# tcp udt unix-domain-socket
type = "TCP"
#NIO NATIVE
server = "NIO"
#enable heartbeat
heartbeat = true
# the client batch send request enable
enableClientBatchSendRequest = true
#thread factory for netty
threadFactory {
bossThreadPrefix = "NettyBoss"
workerThreadPrefix = "NettyServerNIOWorker"
serverExecutorThread-prefix = "NettyServerBizHandler"
shareBossWorker = false
clientSelectorThreadPrefix = "NettyClientSelector"
clientSelectorThreadSize = 1
clientWorkerThreadPrefix = "NettyClientWorkerThread"
# netty boss thread size,will not be used for UDT
bossThreadSize = 1
#auto default pin or 8
workerThreadSize = "default"
}
shutdown {
# when destroy server, wait seconds
wait = 3
}
serialization = "seata"
compressor = "none"
}
# seata服务器配置
service {
#transaction service group mapping
# 配置组名称my_test_tx_group,用于找到在seata中属于自己的服务
vgroupMapping.my_test_tx_group = "default"
vgroupMapping.multi-datasource-service-group = "default"
#only support when registry.type=file, please don't set multiple addresses
default.grouplist = "127.0.0.1:8091"
#degrade, current not support
enableDegrade = false
#disable seata
disableGlobalTransaction = false
}
client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}
- 步骤四:指定seata事务分组,用于从TC获取seata server服务实例
由于 TC是独立部署的,可能这个TC集群服务了很多项目,它内部通过group做了服务隔离, 所以需要配置一个group
用于确定seata server的服务实例
# Seata事务分组
spring.cloud.alibaba.seata.tx-service-group=my_test_tx_group
- 步骤五:配置@GlobalTransactional注解,发起全局事务
springboot
项目下的分布式事务,需要切换多数据源,而使用seata保证全局事务,需要对每个数据源进行一次代理,通过代理往TC
注册分支事务信息!
另外Seata
的AT
模式依赖于本地事务,在下单 - 减库存 - 扣账户余额的过程中,每一个操作都需要加@Transtional
注解,只需要在最外层加上@GlobalTransactional
注解即可开启分布式事务,这也是seata
对业务零侵入的体现。
-
5.1 多数据源配置及其代理配置如下:
@Configuration @MapperScan("com.tuling.mutiple.datasource.mapper") public class DataSourceProxyConfig { @Bean("originOrder") @ConfigurationProperties(prefix = "spring.datasource.order") public DataSource dataSourceMaster() { return new DruidDataSource(); } @Bean("originStorage") @ConfigurationProperties(prefix = "spring.datasource.storage") public DataSource dataSourceStorage() { return new DruidDataSource(); } @Bean("originAccount") @ConfigurationProperties(prefix = "spring.datasource.account") public DataSource dataSourceAccount() { return new DruidDataSource(); } //指定数据源代理: 作用是数据源的AOP ,保存子事务信息到 TC事务协调者 @Bean(name = "order") public DataSourceProxy masterDataSourceProxy(@Qualifier("originOrder") DataSource dataSource) { return new DataSourceProxy(dataSource); } @Bean(name = "storage") public DataSourceProxy storageDataSourceProxy(@Qualifier("originStorage") DataSource dataSource) { return new DataSourceProxy(dataSource); } @Bean(name = "account") public DataSourceProxy payDataSourceProxy(@Qualifier("originAccount") DataSource dataSource) { return new DataSourceProxy(dataSource); } @Bean("dynamicDataSource") public DataSource dynamicDataSource(@Qualifier("order") DataSource dataSourceOrder, @Qualifier("storage") DataSource dataSourceStorage, @Qualifier("account") DataSource dataSourcePay) { DynamicRoutingDataSource dynamicRoutingDataSource = new DynamicRoutingDataSource(); // 数据源的集合 Map<Object, Object> dataSourceMap = new HashMap<>(3); dataSourceMap.put(DataSourceKey.ORDER.name(), dataSourceOrder); dataSourceMap.put(DataSourceKey.STORAGE.name(), dataSourceStorage); dataSourceMap.put(DataSourceKey.ACCOUNT.name(), dataSourcePay); dynamicRoutingDataSource.setDefaultTargetDataSource(dataSourceOrder); dynamicRoutingDataSource.setTargetDataSources(dataSourceMap); DynamicDataSourceContextHolder.getDataSourceKeys().addAll(dataSourceMap.keySet()); return dynamicRoutingDataSource; } //保存动态数据源可以在调用时实现数据源切换! @Bean @ConfigurationProperties(prefix = "mybatis") public SqlSessionFactoryBean sqlSessionFactoryBean(@Qualifier("dynamicDataSource") DataSource dataSource) { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(dataSource); org.apache.ibatis.session.Configuration configuration=new org.apache.ibatis.session.Configuration(); //使用jdbc的getGeneratedKeys获取数据库自增主键值 configuration.setUseGeneratedKeys(true); //使用列别名替换列名 configuration.setUseColumnLabel(true); //自动使用驼峰命名属性映射字段,如userId ---> user_id configuration.setMapUnderscoreToCamelCase(true); sqlSessionFactoryBean.setConfiguration(configuration); return sqlSessionFactoryBean; } }
-
5.2 通过ThreadLocal绑定当前线程与某一个数据源的关系
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = ThreadLocal.withInitial(DataSourceKey.ORDER::name); private static List<Object> dataSourceKeys = new ArrayList<>(); public static void setDataSourceKey(DataSourceKey key) { CONTEXT_HOLDER.set(key.name()); } public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); } public static void clearDataSourceKey() { CONTEXT_HOLDER.remove(); } public static List<Object> getDataSourceKeys() { return dataSourceKeys; } }
-
5.3 基于AbstractRoutingDataSource的多数据源动态切换
@Slf4j public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { log.info("当前数据源 [{}]", DynamicDataSourceContextHolder.getDataSourceKey()); return DynamicDataSourceContextHolder.getDataSourceKey(); } }
-
5.4 发起全局事务调用
@Override //全局事务注解 @GlobalTransactional(name="createOrder") public Order saveOrder(OrderVo orderVo){ log.info("=============用户下单================="); //切换数据源 DynamicDataSourceContextHolder.setDataSourceKey(DataSourceKey.ORDER); log.info("当前 XID: {}", RootContext.getXID()); // 保存订单 Order order = new Order(); order.setUserId(orderVo.getUserId()); order.setCommodityCode(orderVo.getCommodityCode()); order.setCount(orderVo.getCount()); order.setMoney(orderVo.getMoney()); order.setStatus(OrderStatus.INIT.getValue()); Integer saveOrderRecord = orderMapper.insert(order); log.info("保存订单{}", saveOrderRecord > 0 ? "成功" : "失败"); //扣减库存,需要切换数据源 log.info("=============扣减库存================="); storageService.deduct(orderVo.getCommodityCode(),orderVo.getCount()); //扣减余额 需要切换数据源 log.info("=============扣减余额================="); accountService.debit(orderVo.getUserId(),orderVo.getMoney()); //切换数据源 log.info("=============更新订单状态================="); DynamicDataSourceContextHolder.setDataSourceKey(DataSourceKey.ORDER); //更新订单 Integer updateOrderRecord = orderMapper.updateOrderStatus(order.getId(),OrderStatus.SUCCESS.getValue()); log.info("更新订单id:{} {}", order.getId(), updateOrderRecord > 0 ? "成功" : "失败"); return order; }
配置完成即可发起调用!下图是调用成功,无异常!
如果执行成功,如上所示,seata-server的日志如下:
② :Spring cloud微服务项目集成Seata
由于使用了微服务,我们可以把Seata Server注册到nacos做配置统一管理,具体步骤如下:
事务协调者TC环境配置
- 步骤一:修改registry.conf文件注册中心、配置中心为nacos
此处与Spring Boot
的配置不一样了,springboot
项目需要指定file.conf
从里边读取信息,需要修改registry.conf
和file.conf
文件。
在springcloud
项目中,我们使用nacos
作为配置中心和注册中心管理配置,只需修改registry.conf
文件即可!
在使用nacos
配置中心时,最好新开一个namespace
,与其他配置分离,防止数据混乱
- 步骤二:获取/seata源码/script/config-center/config.txt,修改config.txt配置信息
主要修以下内容
配置事务分组, 要与客户端配置的事务分组一致
(客户端properties配置:spring.cloud.alibaba.seata.tx‐service‐group=my_test_tx_group
)
- 步骤三:把第二步的配置信息同步到nacos
config.txt
文件的配置信息修改完毕后需要放入nacos配置中心,seata源码中已经提供了同步的脚本
在cmd
窗口中执行shell
脚本,会自动把config.txt
文件的配置信息推送到nacos对应的namespace
下
sh ${SEATAPATH}/script/config-center/nacos/nacos-config.sh -h localhost -p 8848 -g SEATA_GROUP -t 5a3c7d6c-f497-4d68-a71a-2e5e3340b3ca
参数说明:
-h: IP地址:默认值 localhost
-p: 端口号:默认值 8848
-g: 配置分组:默认值为 ‘SEATA_GROUP’
-t: 租户信息:对应 Nacos 的命名空间ID字段, 默认值为空
- 步骤四:启动nacos
- 步骤五:启动Seata Server
都启动完毕后,打开nacos,发现seata服务已注册,配置信息已上传即可!
spring cloud项目配置
spring cloud
项目中,使用feign
的远程调用取代了spring boot
单机项目的多数据源,这也是分布式事务的另一个场景。
- 步骤一:引入seata依赖
环境准备:seata:v1.4.0
- spring cloud
&
spring cloud alibaba: - 在引入依赖时需要注意
spring cloud alibaba
与seata
的版本问题:spring cloud alibaba2.1.2
及其以上版本使用seata1.4.0
会出现如下异常 (但支持seata 1.3.0
)
所以,如果想要使用seata:v1.4.0
,需要对spring cloud alibaba:
的版本进行降级
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.13.RELEASE</version>
</parent>
<dependencies>
<!-- seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<version>2.1.1.RELEASE</version>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- seata-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-all</artifactId>
<version>1.4.0</version>
</dependency>
<!--nacos 注册中心-->
<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>
</dependencies>
-
步骤二:为每一个服务的数据库创建undo_log表
与springboot
单体项目一样,发生业务异常时,也需要一个undo_log
表来进行回滚!所以需要为每一个数据库建一个undo_log
表,建表sql和上文一致!
-
步骤三:使用DataSourceProxy代理数据源
微服务项目可以只在common
包里只写一份代理,其他服务引用common
包即可!/** * 需要用到分布式事务的微服务都需要使用seata DataSourceProxy代理自己的数据源 */ @Configuration @MapperScan("com.tuling.datasource.mapper") public class MybatisConfig { /** * 从配置文件获取属性构造datasource,注意前缀,这里用的是druid,根据自己情况配置, * 原生datasource前缀取"spring.datasource" * 其他服务引用后,会自动根据配置文件进行赋值! */ @Bean @ConfigurationProperties(prefix = "spring.datasource.druid") public DataSource druidDataSource() { DruidDataSource druidDataSource = new DruidDataSource(); return druidDataSource; } /** * 构造datasource代理对象,替换原来的datasource * @param druidDataSource * @return */ @Primary @Bean("dataSource") public DataSourceProxy dataSourceProxy(DataSource druidDataSource) { return new DataSourceProxy(druidDataSource); } @Bean(name = "sqlSessionFactory") public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); //设置代理数据源 factoryBean.setDataSource(dataSourceProxy); ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); factoryBean.setMapperLocations(resolver.getResources("classpath*:mybatis/**/*-mapper.xml")); org.apache.ibatis.session.Configuration configuration=new org.apache.ibatis.session.Configuration(); //使用jdbc的getGeneratedKeys获取数据库自增主键值 configuration.setUseGeneratedKeys(true); //使用列别名替换列名 configuration.setUseColumnLabel(true); //自动使用驼峰命名属性映射字段,如userId ---> user_id configuration.setMapUnderscoreToCamelCase(true); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } }
其他服务引用后,会自动根据当前服务的配置文件对数据源赋值!例如账户服务:
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seata_account?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
initial-size: 10
max-active: 100
min-idle: 10
max-wait: 60000
注意: 启动类上需要排除DataSourceAutoConfiguration
,否则会出现循环依赖的问题
@SpringBootApplication(scanBasePackages = "com.tuling",exclude = DataSourceAutoConfiguration.class)
public class AccountServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AccountServiceApplication.class, args);
}
}
-
步骤四:在每一个微服务上添加registry.conf配置
-
①:将
registry.conf
文件拷贝到resources
目录下,指定注册中心和配置中心都是nacos
,此处与springboot
项目不同,由于配置已经上到nacos
上了,所以不用再添加file.conf
配置信息了!registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "" cluster = "default" group = "SEATA_GROUP" } } config { # file、nacos 、apollo、zk、consul、etcd3、springCloudConfig type = "nacos" nacos { serverAddr = "127.0.0.1:8848" namespace = "226cc392-3523-4ac1-86a5-304fb9871048" group = "SEATA_GROUP" } }
-
②:在
yml
中指定事务分组(和配置中心的service.vgroup_mapping
配置一一对应)spring: application: name: order-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 alibaba: seata: tx-service-group: my_test_tx_group # seata 服务事务分组
-
-
步骤四:在事务发起者中添加@GlobalTransactional注解,发起调用
@GlobalTransactional(name="createOrder") public Order saveOrder(OrderVo orderVo) { log.info("=============用户下单================="); log.info("当前 XID: {}", RootContext.getXID()); // 保存订单 Order order = new Order(); order.setUserId(orderVo.getUserId()); order.setCommodityCode(orderVo.getCommodityCode()); order.setCount(orderVo.getCount()); order.setMoney(orderVo.getMoney()); order.setStatus(OrderStatus.INIT.getValue()); Integer saveOrderRecord = orderMapper.insert(order); log.info("保存订单{}", saveOrderRecord > 0 ? "成功" : "失败"); //扣减库存 storageFeignService.deduct(orderVo.getCommodityCode(), orderVo.getCount()); //扣减余额 服务降级 throw Boolean debit= accountFeignService.debit(orderVo.getUserId(), orderVo.getMoney()); // if(!debit){ // // 解决 feign整合sentinel降级导致SeaTa失效的处理 // throw new RuntimeException("账户服务异常降级了"); // } //更新订单 Integer updateOrderRecord = orderMapper.updateOrderStatus(order.getId(),OrderStatus.SUCCESS.getValue()); log.info("更新订单id:{} {}", order.getId(), updateOrderRecord > 0 ? "成功" : "失败"); return order; }
测试结果正常: 如果出现异常,各微服务一起成功或失败,符合分布式事务期望!
5. 服务降级对分布式事务的影响
在微服务项目中,通常会配置服务降级策略,电商业务:下订单 - 减库存 - 扣账户余额,如果扣除账户余额失败后会执行下边的降级代码!
@Component
@Slf4j
public class FallbackAccountFeignServiceFactory implements FallbackFactory<AccountFeignService> {
@Override
public AccountFeignService create(Throwable throwable) {
return new AccountFeignService() {
@Override
public Boolean debit(String userId, int money) {
log.info("账户服务异常降级了");
// 解决 feign整合sentinel降级导致Seata失效的处理 此方案不可取
if(!StringUtils.isEmpty(RootContext.getXID())){
//通过xid获取GlobalTransaction调用rollback回滚
// 可以让库存服务回滚 能解决问题吗? 绝对不能用
GlobalTransactionContext.reload(RootContext.getXID()).rollback();
}
}
};
}
}
上述代码通过xid
获取GlobalTransaction
调用rollback
进行回滚,可以让库存服务回滚,但是订单还是生成了,分布式事务失效了!因为分布式事务没有感受到异常!那么在服务发生降级时如何使用分布式事务呢?
思路:使用一个boolean
类型标记是否发生降级,在主业务代码中根据boolean
值,如果发生了降级,则抛出一个自定义异常,此异常可被全局事务捕捉到,进而触发分布式事务的回滚! 重写降级代码如下:
@Component
@Slf4j
public class FallbackAccountFeignServiceFactory implements FallbackFactory<AccountFeignService> {
@Override
public AccountFeignService create(Throwable throwable) {
return new AccountFeignService() {
@Override
public Boolean debit(String userId, int money) {
log.info("账户服务异常降级了");
// 解决 feign整合sentinel降级导致Seata失效的处理 此方案不可取
//
// if(!StringUtils.isEmpty(RootContext.getXID())){
// //通过xid获取GlobalTransaction调用rollback回滚
// 可以让库存服务回滚 能解决问题吗? 绝对不能用
// GlobalTransactionContext.reload(RootContext.getXID()).rollback();
// }
return true;
}
};
}
}
主业务代码中根据boolean
值来决定是否抛出自定义异常,进而触发分布式事务回滚
@Override
//@Transactional
@GlobalTransactional(name="createOrder")
public Order saveOrder(OrderVo orderVo) {
log.info("=============用户下单=================");
log.info("当前 XID: {}", RootContext.getXID());
// 保存订单
Order order = new Order();
order.setUserId(orderVo.getUserId());
order.setCommodityCode(orderVo.getCommodityCode());
order.setCount(orderVo.getCount());
order.setMoney(orderVo.getMoney());
order.setStatus(OrderStatus.INIT.getValue());
Integer saveOrderRecord = orderMapper.insert(order);
log.info("保存订单{}", saveOrderRecord > 0 ? "成功" : "失败");
//扣减库存
storageFeignService.deduct(orderVo.getCommodityCode(), orderVo.getCount());
//扣减余额 服务降级 throw
Boolean debit= accountFeignService.debit(orderVo.getUserId(), orderVo.getMoney());
//根据debit值来决定是否抛出自定义异常,进而触发分布式事务回滚
//如果触发了降级,debit == true
if(debit){
// 解决 feign整合sentinel降级导致SeaTa失效的处理
throw new RuntimeException("账户服务异常降级了");
}
//更新订单
Integer updateOrderRecord = orderMapper.updateOrderStatus(order.getId(),OrderStatus.SUCCESS.getValue());
log.info("更新订单id:{} {}", order.getId(), updateOrderRecord > 0 ? "成功" : "失败");
return order;
}