Seata分布式事务-Raft模式(2023)全面解析
Seata 是一款开源的分布式事务解决方案,star高达23000+,社区活跃度极高,致力于在微服务架构下提供高性能和简单易用的分布式事务服务.
本篇文章将解析Seata-Server Raft模式的源码,带你领略从调研对比,设计,到具体实现,再到知识沉淀的过程.
分享人:陈健斌(funkye) github id: a364176773
介绍:
同盾科技资深开发工程师 、Seata Committer、Sofa-Jraft & Spring cloud alibaba & Mybatis-Plus (by dynamic-datasource) & Apache-Dubbo & Apache-Kafka & Byte- buddy & Druid Contributor
Seata 存储模式Raft&Redis 作者
注: 特此感谢Seata社区所有参与code review,pr讨论的小伙伴及Sofa-JRaft社区的家纯兄指导.
目录
- raft模式是什么?
- 为什么需要raft模式?
- seata-raft模式是如何设计的.
- 设计上的一些问题探讨
- 总结;
1.Raft模式是什么?
首先我们要明白什么是raft分布式一致性算法,这里直接摘抄sofa-jraft官网的相关介绍:
RAFT 是一种新型易于理解的分布式一致性复制协议,由斯坦福大学的 Diego Ongaro 和 John Ousterhout 提出,作为 RAMCloud 项目中的中心协调组件。Raft 是一种 Leader-Based 的 Multi-Paxos 变种,相比 Paxos、Zab、View Stamped Replication 等协议提供了更完整更清晰的协议描述,并提供了清晰的节点增删描述。 Raft 作为复制状态机,是分布式系统中最核心最基础的组件,提供命令在多个节点之间有序复制和执行,当多个节点初始状态一致的时候,保证节点之间状态一致。
简而言之Seata的Raft模式就是基于Sofa-Jraft组件实现可保证Seata-Server自身的数据一致性和服务高可用.
2.为什么需要raft模式
看完上述的Seata-Raft模式是什么的定义后,是否就有疑问,难道现在Seata-Server就无法保证一致性和高可用了吗?那么我们下面从一致性和高可用来看看目前Seata-Server是如何做的.
2.1.一致性:
在目前Seata的设计中,Server端的作用就是用来保证事务的二阶段被正确的执行,那么我们应该知道被正确的执行的前提条件是什么?
没错,就是事务记录的正确存储和记录,要确保事务记录不丢失,状态不错误下,二阶段驱动所有的Seata-RM时一定是正确的行为,而Seata目前对事务状态和记录是如何存储的?
首先要介绍Seata支持事务存储模式有: file, db, redis 三种模式,根据一致性的排名来db(事务保证)>file(默认异步刷盘)>redis(aof,rdb)
顾名思义:
- file就是Seata自实现的事务存储,默认以异步形式进行存储事务信息到本地磁盘上,为了兼顾性能,默认是异步化,且事务信息存储在内存中,保证内存和磁盘上的一致性,在Seata-Server(以下简称TC)意外宕机时,会在启动时从磁盘读取事务信息并存储到内存中,以便恢复事务上下文继续运转.
- db是Seata抽象出的AbstractTransactionStoreManager另一个实现,背后由数据库如pgsql,mysql,oracle来实现事务存储,这个比较好理解,就是对数据库的增删改查,一致性由数据库的本地事务保证,数据也由数据库的保证落盘.
- redis同上,背后就是利用jedis+lua脚本(部分如竞争锁时)来进行事务的增删改查,数据也是由存储方保证,与db一样在seata中处于计算和存储分离架构设计.
2.2.高可用:
高可用其实简单点理解,就是对集群的容灾,是否在TC宕机后,服务能正常运行,常见的方式就是Provider集群部署(可以说TC就是Seata-Client端的一个Provider),再通过注册中心实时感知TC的上线下,及时的切换到可用的TC.
看起来好像加几台机器,部署即可,但是背后存在了一个问题,如何让多个TC像一个整体一样,即便其中一个宕机后,另一个TC能接手宕机时TC的数据完美善后呢?答案其实很简单,在计算与存储分离的情况下,只要把数据丢到存储方,任意一个TC通过这个公共的存储区域读取即可得到所有TC操作的事务信息,也就保证了高可用的能力.
但是我们也提到了一个前提条件,也就是计算与存储分离下,那么计算与存储一体化设计下为什么不行呢?
这就要说说我们的File实现了,file在一致性那块的描述已经说了,他只是讲数据存储在本地磁盘和TC内存中,这个数据写操作是没有任何同步,也就代表了目前的File模式无法高可用,仅支持单机部署,作为初级的快速入门的简单使用,这也让高性能的file(基于内存)基本被告别生产环境的使用.
3.seata-raft模式是如何设计的
3.1.设计原理
Seata-Raft模式的设计思路是来自于当前无法高可用的file模式的包装,将其的所有增删改操作利用raft算法进行同步,这也就保证了多个TC使用file模式时,数据是一致的,且摒弃了file模式的异步刷盘操作,改为通过raftlog+snapshot恢复数据
3.2.流程设计
设计示意如上图,client端在启动时,会通过配置中心读取当前client的事务分组,以及相关的raft集群其中一个节点的ip(当然你不嫌麻烦的话可以把整个集群的节点都写上,参考zookeeper那样),通过将获取元数据的request发向172.17.0.1:7091节点后,tc将会把改default raft集群下的元数据返回,即leader&follower&learner,随后将进行watch非leader节点
如图是启动时拉取metadata的过程,及请求过程
假设tm begin一个事务,那么由于本地的metadata指向了tc1的地址,那么他只会与tc1进行交互,而tc1在增加一个全局事务信息时,会通过raft协议,也就是图上标注的步骤1,发送日志,步骤2follower应答日志接收情况,当超过半数节点,如tc2接受成功并相应,那么tc1上的状态机(fsm)将执行添加全局事务动作(详细下面会说到).
那么如果tc1宕机了,或者重选举了会发生什么呢?由于第一次启动拉取到了metadata,client便会执行watch follower节点的watch接口(因为只有非leader才可能成为leader,watch leader是无意义的,且集群发生重选举或增删节点,每个tc节点都会感知)
当tc1宕机后,tc2选举成为leader,而tm通过tc3或者tc2(只要是follower)的watch成功更新本地的metadata信息,后续的事务将请求至tc2,而tc1的数据本身就被同步到了tc2和tc3,数据一致性没影响,只不过在选举发生的一瞬间,且如果事务恰好处于决议发送request至旧leader时,该事务会被主动回滚(因为rpc的节点已经宕机或者重选举了,目前没做该rpc重试,tm侧默认有5次,但由于选举大约需要1s-2s时间,大概率处于这个阶段的事务会回滚)
3.2.1.client初始化源码解析
首先client端通过额外实现一个SeataRegistryServiceImpl为Seata自身作为注册中心
1 | public class SeataRegistryServiceImpl implements RegistryService<ConfigChangeListener> { |
通过Seata自身作为client的注册中心后,metadata便成为核心点,具体代码和类如下:
Metadata 其刷新metadata设计线程全为单线程无线程安全问题,且刷新方式为直接put覆盖,思想为copyonwrite,rpc线程获取的时候不受其刷新干扰且无锁
1 | public class Metadata { |
1 | public class Node { |
3.2.2.server端初始化源码解析
接下来就是server如何处理watch和刷新元数据接口的请求
1 |
|
接下来我们看ClusterWatcherManager是如何管理观察者们的
1 |
|
ok,client和server侧的元数据获取算是告一段落了,而看到这里大家会发现ClusterWatcherManager是一个ClusterChangeListener,那么到底是由哪里进行通知呢?
不急,我们先看看raft模式的初始化流程
而存储核心的流程开头我们先看SessionHolder这个类,此类保存了TC端的所有sessionmanager的实例,并管理其初始化,在初始化后我们就可以来构建raft的相关服务,以下是源码解析
1 | // 预留给multi-raft使用,一个group,一个sessionmanager隔离 |
既然我们看到了在RaftServerFactory中进行了初始化,那么接下来就带大家来继续解析
1 | public class RaftServerFactory { |
接着我们看看RaftServer的具体实现类的初始化都做了哪些事RaftServerImpl
1 | public class RaftServer implements ConfigurationChangeListener, Disposable, Closeable { |
ok,直此TC端的raft初始化相关内容已经解析完毕,核心的状态机将在下述存储模式篇章解析
3.2.3.server端raft存储模式源码解析
首先通过上述的server端初始化源码,已经可以初步知晓raft存储模式就是包装了file存储模式,并摒弃他原本的事务存储方式,增加raft的日志和快照存储及数据同步,那么我们现在就来看看相关的类吧
RaftLockManager-Raft的全局锁实现,再老方案中一味的只考虑到follower因与leader执行相同的动作得到相同的结果,故忘记了动作可能存在无意义性,故在新版设计中,将raft的lockmanager直接继承file lock manager即可,并只需要在解锁时进行同步即可,在seata-server中file模式的锁是存在在内存中,也在branch身上,如果branch添加成功,那么锁一定存在,竞争锁的过程就不必同步了,竞争失败,branch一定不会添加成功,这是原子的.
1 | "raft") (name = |
这样lock的实现就很简单了,而是在添加分支事务时同步分支时一并处理即可,再后续讲到状态机章节会再次提及
而session这块的实现就是将filesessionmanager进行一个继承,使globalsession和branchsession的增删改行为以raft算法进行同步
1 | "raft", scope = Scope.PROTOTYPE) (name = |
1 | public class RaftTaskUtil { |
1 | public class RaftSyncMsgSerializer { |
如上所述,raftsessionmanager做的任务就是把每次的写操作进行同步到follower节点,通过raft一致性协议保证了多节点的数据一致性,那么这个时候我们就应该去了解状态机的设计,因为这才是同步的核心地方
RaftStateMachine-TC的raft状态机类,此处着重讲述seata利用sofa-jraft状态机做了什么,具体可以访问sofa官网
1 | public class RaftStateMachine extends StateMachineAdapter { |
故障恢复示意图
我们来重点看下执行器做了什么,首先看下AddGlobalSessionExecute
1 |
|
AddBranchSessionExecute,此时我们可以解答一下与老版设计对比,为什么不需要同步锁了,因为在seata-server中当分支被添加的时候,也就是走到sessionmanager中的onaddbranch时,只有获取了锁成功的分支才会走到此处,所以当分支被同步的时候,也就意味着锁一定是争抢成功,这样就避免了很多无意义的锁同步行为,将锁同步和分支事务添加合并为一次同步任务即可.
1 |
|
按照顺序,一个正常的事务流程 addglobalsession->addbranchsession->releaseGlobalSessionLock->,updateglobalsession->updatebranchsession(不一定有,成功执行二阶段后会直接删除branch)->branchReleaseLock->removebranchsession->updateglobalsession(更改事务为end状态)->removeglobalsession,所以我们按顺序看响应的执行器
1 | public class UpdateGlobalSessionExecute extends AbstractRaftMsgExecute { |
1 | public class UpdateBranchSessionExecute extends AbstractRaftMsgExecute { |
1 | public class RemoveBranchSessionExecute extends AbstractRaftMsgExecute { |
1 | public class RemoveGlobalSessionExecute extends AbstractRaftMsgExecute { |
1 | public class BranchReleaseLockExecute extends AbstractRaftMsgExecute { |
1 | public class GlobalReleaseLockExecute extends AbstractRaftMsgExecute { |
以上Seata-Server(TC)端的相关涉及源码解读已经完毕,在阅读以上代码后,相信大家已经对client-server两端的实现已经有比较清晰的认知了,接下来我们来看看server与client端在raft下的通信相关设计
3.2.4.Client&Server端通信解析
如上图所示,client请求至server,再从serveronrequestprocessor进而找到对应的处理器,最终通过raftsessionmanager存储事务信息,而事务信息通过raft协议,从各个seataserver的状态机中执行事务的存储动作,最终再响应至client.
看过了watch和querymetadata的同学相信会有一个疑问,就是如果client侧获取最新metadata时,恰好有业务线程在begin或者commit或者registry的动作会发生什么?它们所请求的leader很可能在这一刻已经不存在或者不是leader了,那么前者比较简单rpc就会失败,后者在tc侧做了一个当前是否是leader的检测,如果不是leader将拒绝该请求,如果是leader但在中途失败,那么会由于当前已经不是leader,createTask提交到状态机的动作会失败,那么client侧也会接收到响应异常,而该旧leader的提交任务也会失败,也避免了事务信息不一致的问题
1 |
|
可以看到与原先的rpc设计上区别就是,如果是raft存储模式,那么必须在状态机完全处理完这个请求的所有操作后再做响应,所以大家会看到在createTask的时候利用了completableFuture进行阻塞等待事务相关增删改的结果,这样就可以让client获得响应的时候是一个正确状态,而非异步不确定状态,很多同学不了解raft一致性算法的可能会有疑问,我通过sofa-jraft官网提供的设计图来解释
首先我们看到client端的请求通过一致性算法,将操作,比如我们上述所说的添加/修改/删除事务的操作通过日志形式传递,并在超过半数follower应用了该日志后,leader的状态机才会去做处理,如leader是最后才写数据(相对半数follower),而状态机的执行相对client端是异步(状态机是串行,但是跟client请求不是一个线程),也就是对client端的响应必须在写操作都做完了再响应,所以这里的设计利用了状态机是串行有序的,如果server能响应给client,说明先前的增删改操作是同步完成,数据一致的情况,也代表了数据准确落盘(在raft模式下写入内存),那么此时去响应client端是正确行为,如果直接响应,那么由于状态机和业务线程不是同一个,相对业务线程,写操作完全异步化掉了,业务线程响应给client时,未必数据写完了,这个响应的结果可能就有出入.
3.2.5 raft选主解决分布式任务调度解析
DefaultCoordinator#init 该类是异步提交,回滚重试,重试提交,检查事务超时,undolog 防悬挂记录删除的定时任务初始化函数,我们来看下再集成raft之前,是怎么处理的
1 | /** |
可以看到以上的定时任务,就是这么简单的审计,定时线程来跑任务即可,我们试想一下,在db,redis模式中,因为存储是用的外部存储来保证多tc的资源共享,那么在定时任务的时候,没有做任何排他操作,很可能会出现,同一个事物需要提交补偿的时候,多个tc的定时任务同时发动,同时再数据库读出了同一批需要重试提交的事务,进行下发,那么会带来什么问题呢?
没错,就是幂等性问题(XA与AT及SAGA模式无影响,TCC需要业务自己保证幂等),且多个tc对同一批的事务进行处理也是极大的资源浪费,这是很不可取的.Seata在先前本来想打算引入分布式任务调度组件来解决此问题,而Raft的集成顺带也就解决了此问题,我们来看一下raft下定时任务是如何处理的.
1 | /** |
一幕了然,再未来的版本中,TC端预留了对每个模式的分布式锁实现的接口,后续单独的db,redis模式会实现各自的分布式锁机制,使定时任务不再冲突,而raft模式下,对预留的锁接口是如何实现的呢?其实很简单,如果发现当前是leader节点,就等于拿到锁了
我们来看看raft里对相关定时任务的分布式锁是如何做default处理的
1 | "raft") (name = |
然后我们来看一下RaftServerFactory中的isleader又是如何判断,且兼容不用raft进行选举的场景,比如纯db模式
1 | public Boolean isLeader() { |
而如果是db或redis下不用raft又该怎么处理呢?很简单,直接实现相应的acquireLock,不就把default给重写了吗?这就保证了纯db/redis下由对应的store实现分布式锁的设计,而db/redis+raft选举下,直接由leader执行定时任务即可解决集群下冲突的问题.
至此Seata-Server与Sofa-JRfat的结合源码已经解读完毕,接下来我们来讲讲设计上的一些问题并给出个人的理解.
4.设计上的一些问题探讨
1.为什么要同步file模式的数据
答: 首先file不支持高可用的原因就是数据不同步,导致宕机后事务数据无法共享,部分数据可能被脏写,如AT模式需要全局锁来保证隔离性,如果多个tc使用file模式各自为营,那么client端通过LB策略请求的tc截然不同,必定造成数据混乱,从而影响分布式事务的一致性
2.那为什么不直接同步LOCK数据即可?
答: 无法只同步lock数据,其一是一致性问题,如果通过同步lock的话,其余tc在接收到写请求时,lock数据还未同步到位,此时是从何查起呢?不知源头,不知何时同步,不知该不该阻塞等待,所以基本上主流的分布式一致性算法都是由leader来写入数据,因为leader才是真正数据最准确的存在,且也避免了上述所说的不知道什么时候lock数据才同步到位的尴尬局面,所以无法保证分布式事务的隔离性,必须由leader来处理,并不只是同步数据那么简单.
3.那为什么不独立lock到公共存储如db或者redis呢?
答: 未来会支持这种方式,通过lb策略,使begin时路由到的TC的raft-group作为标识,后续的所有rm都会直接请求begin的那个group集群的leader,这样事务信息保证了在此raft-group内是一致准确的.这样在tcc xa saga模式下无需全局锁互斥时,可大幅提升吞吐量,而at需要全局锁互斥必定需要一个公共的地方存放锁,在单raft集群模式下直接存储在raft集群中即可,multi-raft下存储在外置存储器上虽然带来了一定吞吐下降,但相对之前的计算与存储完全分离下理论上有可靠的提升.
4.为什么目前由client端主动请求leader节点,而不像zookeeper之类的进行写请求转发呢?
答: 1.性能考虑: 分布式事务的二阶段下,明显带来的就是多次rpc确认事务决议状态,并下发响应结果造成的性能下降,如果直接由server端进行转发,那么其实也存在转发过程中leader的变更,这种由server端转发的方案会带来一定的性能下降,所以目前没有此考虑. 2.中间件职责不同: zookeeper这类的工具并不会像seata那样会有大量的读写请求(其实有,但是性能非常不理想,这也是为什么那么多开源中间件开始放弃zk的原因之一),即便使用的tps低一些也是能接受.再者在2022年年底宣布KRaft集群模式生产可用(GA)的Kafka而言,其也是通过metadata方式刷新client本地的路由,client的消息也是从metadata中找到分区leader进行发送,如果分区leader重选举会刷新元数据重试,及rocketmq也有类似的功能,定时从namingserver中拉取元数据.
5.与redis-cluster/sentinel及db模式主备模式比,raft模式的优劣
答:
redis模式对比:
一.cluster跟sentinel一样,存在数据丢失的可能,cluster是一主一备6台redis服务组成,master同步到slave时无法保证强一致性,且cluster适合超大数据量需要数据分片的场景,当前TC的设计在事务结束后就删除,其实是不太适合的.
二.sentinel也是一样无法保证强一致,比如aof的持久化,极大影响redis吞吐,sentinel可以设置让master得到slave写入响应,但是这样一来也就跟raft无太大差距,如raft也需要让follower写入,自身再写,redis又是外部存储多了一次网络io的开销,再吞吐上理论上是比不过raft模式
db模式对比:
一.db模式在seata中目前尚不支持主备切换,首先虽然根据数据库的redolog&binlog二阶段提交来同步到slave,但是说到底master并不会感知slave是否写入了数据,只是关注是否发出了广播,再极限情况下比如master写入了全局锁后宕机,server端对client响应分支注册成功,此时slave接替master后,又来了一个分支进行update操作去争取全局锁,而此时slave还没收到master的全局锁写入广播(可能由于网络问题),所以另一个分支的update操作就被准许,此时前一个事务如果需要回滚会因为后续的update事务没有被全局锁隔离,导致脏写无法回滚的局面,这是不可取的.
二.目前基于db的模式对磁盘和cpu的要求较高,一些用户使用8c16g配置,ssd硬盘可轻松上1200+tps,而使用同等配置下使用机械硬盘可能只有800+左右的tps.究其原因便是seata-server在事务结束后立马就删除造成的数据库频繁的写擦操作,在并发量大的时候,很可能导致大量的页分裂,页合并等行为有严重影响性能的可能性,而raft模式基于对file模式的包装,基于内存的操作,效率理论上远高于db模式,且也能保证数据的一致性.
6.在raft这类事务结束时就将事务信息删除的模式,如何追溯历史数据?
答:
一. 社区目前已有完成的将事务全部流转状态(增删改)全部发送至kafka中的pr,业务侧可消费此topic将事务存储至hive或Cassandra等数据中间件中持久化存储,可做报表或查询平台功能.
二. 社区未来可能针对此问题引入rocksdb或h2这类本地存储中,将事务的删除转为删除+存储至rocksdb或h2中方便事务的回溯查询
5.总结
在Seata未来的发展中,性能是至关重要了,我们要对二阶段协议带来的性能下降做尽可能的优化,如存储方面,资源利用率方面,通信方面.这也就代表了我们需要将更多可掌握的拿捏在手,file模式就是一个自实现的事务存储模式,基于此,我们可在自己能力范畴中,以及社区的力量加强对其的优化,把存储性能掌握在自己手中,而外部存储的方案当然也是有好处的,但是对其调优能力因人而异,维护seata组件的相关同学既要维护server端的相关优化指标,也要关注数据库的相关指标,导致有关注点分散,性能问题不好定位的情况.raft模式的到来,代表了类似于redis模式的基于内存的写操作的性能提升,足够的可操控空间,除去了外部的磁盘&网络io开销,达到一个自主可控的阶段.
并针对业界发展趋势,如clickhouse和kafka都开始抛弃zk,转为自研的如clickkeeper和KRaft,将元数据等信息交由自身保证存储,减少第三方依赖减少运维成本和学习成本,这也是非常成熟和可借鉴效仿的特性.
当然现阶段可能raft模式还不太成熟,未必有上述描述的那般美好,但更因为理论如此,我们应该让事实如此,在此我们欢迎任何对Seata有兴趣的同学加入社区,共同维护,共同进步,一起为Seata分布式事务添砖加瓦!