一、需求背景
目前项目整体架构都是基于微服务的基础上开发,同时逐渐把第三方的功能回迁,此次采用其中一个功能(报销流程)来举例,整个大功能后端大致上可以分成三个模块,财务模块,流程模块,发票模块,文章后序均已简称描述,A模块(财务模块),B模块(流程模块),C模块(发票模块),调用的逻辑链路是 :A -> B -> A -> C -> A,三个模块有功能上相互依赖的关系。
报销的流程业务描述:
- 在页面发起报销流程,请求进入到A
- 调用B,判断发票是否已被使用(业务上限制一张发票只能被使用一次)
- 在A保存基础的表单信息(报销的金额,报销人等基础信息)
- 调用B保存发票的相关信息
- 调用C发起报销的流程,流程成功发起后返回相关的流程信息(如流程唯一标识等)
- 在A保存C返回的信息
- 调用B,让B保存C返回的流程唯一标识
在公司人员在飞速增长下,内部系统访问量渐增,分布式事务问题日渐凸显,在一次巧合下了解到了Seata,同时架构师说公司用户运营中台有一个Seata的Committer,文章后续简称‘伟少’,在初期自己集成的Seata的时候遇到各种各样的问题,在网络上流传的文章基本都是千篇一律,不是你C我的就是我V你的,都是一些标准的CV工程师,几乎没有自己想要的答案,后面实在不行了就去找伟少了,结局肯定都是完美解决。
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
二、整合过程
服务端
Seata Server: 1.1.0
客户端
Spring Cloud Alibaba 1.5.1 + Nacos 1.1(初期使用Eureka) + Seata 1.1.0
相信有不少人在整合中间件的过程中,都遇到了不少问题,本着记录和分享的精神,将过程中遇到的每一个问题,以便帮助遇到同样问题的朋友。
先来看看以下表格,可以很清晰的看到,SCA 1.5.1依赖的Seata版本为0.7.1,但是当时Seata最新版本已经到了1.1.0,经过内部讨论后,依然决定使用低版本的SCA去整合高版本的Seata
Spring Cloud Alibaba Version | Sentinel Version | Nacos Version | RocketMQ Version | Dubbo Version | Seata Version |
---|---|---|---|---|---|
2.2.3.RELEASE or 2.1.3.RELEASE or 2.0.3.RELEASE | 1.8.0 | 1.3.3 | 4.4.0 | 2.7.8 | 1.3.0 |
2.2.1.RELEASE or 2.1.2.RELEASE or 2.0.2.RELEASE | 1.7.1 | 1.2.1 | 4.4.0 | 2.7.6 | 1.2.0 |
2.2.0.RELEASE | 1.7.1 | 1.1.4 | 4.4.0 | 2.7.4.1 | 1.0.0 |
2.1.1.RELEASE or 2.0.1.RELEASE or 1.5.1.RELEASE | 1.7.0 | 1.1.4 | 4.4.0 | 2.7.3 | 0.9.0 |
2.1.0.RELEASE or 2.0.0.RELEASE or 1.5.0.RELEASE | 1.6.3 | 1.1.1 | 4.4.0 | 2.7.3 | 0.7.1 |
SCA版本说明:https://github.com/alibaba/spring-cloud-alibaba/wiki/%E7%89%88%E6%9C%AC%E8%AF%B4%E6%98%8E
相信使用过Seata的朋友,已经知道怎么去启动Seata Server,此处就不详细展开讲解,只作简单描述
修改registry.conf
registry {
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
}
}
修改file.conf为db模式(Seata 1.1支持的模式可选有file和db)
store {
mode = "db"
db {
url = "jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true"
user = "mysql"
password = "mysql"
}
}
运行官方提供的sql脚本
CREATE TABLE IF NOT EXISTS `global_table`
...
CREATE TABLE IF NOT EXISTS `branch_table`
...
CREATE TABLE IF NOT EXISTS `lock_table`
...
脚本地址:https://github.com/seata/seata/tree/1.1.0/script/server/db
启动成功并检查nacos
接下来是客户端
先粗略讲解一下seata AT模式的大概过程:通过代理数据库的数据源去接管本地事务,然后在请求链路的入口处,生成一个全局的xid,然后把这个xid带到下游服务里面去,服务端就通过这个xid确定这次请求的范围一共涉及了多少个事务。
既然要试用低版本的SCA去整合高版本的Seata,以下操作是必不可少的,然后重新依赖Seata的最新版本1.1.0
<spring-cloud-alibaba-seata.version>1.5.1.RELEASE</spring-cloud-alibaba-seata.version>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-seata</artifactId>
<version>${spring-cloud-alibaba-seata.version}</version>
<exclusions>
<exclusion>
<artifactId>seata-all</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
使用官方提供的脚本新建undo_log表
CREATE TABLE IF NOT EXISTS `undo_log`
...
脚本地址:https://github.com/seata/seata/tree/1.1.0/script/client/at/db
客户端配置
spring:
cloud:
alibaba:
seata:
tx-service-group: oa-process-service
seata:
registry:
type: nacos
nacos:
namespace: nacos空间名
server-addr: nacos的地址
service:
vgroup-mapping:
oa-process-service: default
client:
undo:
logSerialization: kryo
logSerialization 这个配置可根据实际情况去配置,这个配置是用于客户端保存undolog记录的时候序列化和反序列化数据,项目上有使用了flowable框架,flowable上时间的字段大部分都是精确到毫秒的,seata默认使用的是jackson,经调研发现kyro和protobuf在对时间精度上有更加好的支持,最终选定kyro。
三、问题初现
当使用Spring Cloud Alibaba 1.5.1集成高版本Seata,有问题也是在预料之中,当集成完毕后,启动会出现如下的报错
由于Spring Cloud Alibaba 1.5.1和对应的Spring Cloud Edgware官方已经停止维护,所以不建议使用此版本
经过调试和请教后发现,是初始化GlobalTransactionScanner时出现了问题,由于在Spring Cloud Alibaba 1.5.1存在的GlobalTransactionAutoConfiguration和Seata 1.1存在的SeataAutoConfiguration,他们的目的都是用于初始化GlobalTransactionScanner,导致了重复初始化引发的错误。
GlobalTransactionScanner是用于为使用了@GlobalTransactional和@GlobalLock生成代理类,使得该方法被调用时能够进入Seata的拦截器
继续尝试启动,会出现第二个问题如下图
找不到Netty相关的方法,因为Spring Cloud Edgware所依赖的eureka版本使用的Netty较旧,而Seata所使用的Netty较新,而导致问题,只需要简单把老版本Netty依赖排除掉即可
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<exclusions>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
</exclusion>
</exclusions>
</dependency>
最后一个问题也是上面所提到过的logSerialization变更为kyro的主要原因之一,由于Spring boot 1.5.x 会出现 jackson 相关 NoClassDefFoundException
Caused by: java.lang.NoClassDefFoundError: Could not initialize class com.fasterxml.jackson.databind.ObjectMapper
原因是发现在 Spring Boot 1.5.x 版本中原始引入的 jackson 版本过低,会导致 Seata 依赖 jackson 的新特性找不到,Seata 要求 jackson 版本2.9.9+,但是使用 jackson 2.9.9+ 版本会导致Spring Boot中使用的jackson API找不到,也就是jackson本身的向前兼容性存在问题
四、全面升级
听说Seata从1.1开始,到1.4.0,每一个版本都有非常大的变动,每个版本合并pr高达过百个,可见社区活跃度非常不错。经过部门内部讨论,也考虑到该中间件的性能和完善程度。决定邀请伟少前来协助,对内部系统所有微服务进行全面的升级
五、问题再现
当微服务版本依赖变更完成后尝试启动项目,出现了比较奇怪的错误,错误原因出现在Seata绑定XID到上下文时,提示的XID不能为空。
当带有@GlobalTransactional注解的方法被调用时,如果Seata上下文不存在XID,则会请求TC开启一个新的全局事务并生成XID返回到客户端,客户端会将XID绑定到上下文中并传递到下游的链路中。
但可疑的是,这是应用启动的时候报错,意味着并没有开启全局事务。理论上XID作为全局事务ID,我写了@GlobalTransactional注解之后才会生成并且传递,可是我就一个查询接口我也没写@GlobalTransactional居然也报错了,后来折腾了几天感觉要入土了就只能去请教伟少的帮忙了,结果伟少看了一眼说了句1.4是会有这个问题,前几天刚发布了1.4.1修复了这个问题,你升级一下版本就好,幸福来得这么突然,果然升级了版本之后就好。
后续运行期间,在TC会有较低的概率出现branch_table和lock_table出现残留数据,但是global_table缺没记录,导致后续其他全局事务提交失败,需要手动清理branch_table和lock_table的数据。
由于分支事务一阶段提交时,需要往TC注册分支事务,注册分支时候需要获取全局锁,由于lock_table已经存在残留数据,所以会导致获取锁失败,从而导致分支事务注册失败。
这个问题在1.1版本就开始出现,但是一直没有找到原因。又经过了几天的排查,在伟少的帮助下,终于发现了这个问题的真相,原因是TC端连的数据库做了主从,当全局事务决议进行二阶段的时候,去找分支事务信息和锁信息删除的时候,查询请求分发到了从库,此时分支数据还没完全同步到从库上,只能获取到部分的分支事务,导致二阶段提交时,只提交了一部分分支事务,而另外一部分继续残留在数据库。最后关闭掉数据库主从,该问题得以解决。
六、总结
Seata AT模式简单的执行过程
- 开启全局事务(通过@GlobalTransactional,生成xid)
- 执行分布式分支的方法,在提交本地事务的时候,数据更新之前,先对需要修改的数据生成一个前置镜像,然后执行sql,然后再对修改后的数据生成一个后置的镜像
- 向TC发起分支注册,并且保存本地的undolog,最后提交本地事务
- TC会给每个微服务发起提交请求,如果出现异常,分支会根据xid和branchId(每一个分支都会对应一个自己的branchId)去查询undolog,会将之前生成的后置镜像和数据库当前的数据对比,看是否有脏写,如果没有发生脏写,则会根据前置镜像去回滚数据
在集成和使用seata的过程中虽然遇到不少的问题,但分析这些问题并且解决之后可以提升自身的能力,对自身能力有很大的锻炼,总的来说seata是一个很棒棒的开源框架。