🍊 分布式事务
🎉 Seata 的架构
在 Seata 的架构中,一共有三个角色:
TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,驱动全局事务提交或回滚。TC 为单独部署的 Server 服务端。
TM (Transaction Manager) - 事务管理器:定义全局事务的范围:开始全局事务、提交或回滚全局事务。TM为嵌入到应用中的 Client 客户端。
RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。RM 为嵌入到应用中的 Client 客户端。
🎉 存在的问题
📝 性能损耗
一条Update的SQL,则需要全局事务xid获取(与TC通讯)、before image(解析SQL,查询一次数据库)、after image(查询一次数据库)、insert undo log(写一次数据库)、before commit(与TC通讯,判断锁冲突),这些操作都需要一次远程通讯RPC,而且是同步的。另外undo log写入时blob字段的插入性能也是不高的。每条写SQL都会增加这么多开销,粗略估计会增加5倍响应时间。
📝 性价比
为了进行自动补偿,需要对所有交易生成前后镜像并持久化,可是在实际业务场景下,这个是成功率有多高,或者说分布式事务失败需要回滚的有多少比率?按照二八原则预估,为了20%的交易回滚,需要将80%的成功交易的响应时间增加5倍,这样的代价相比于让应用开发一个补偿交易是否是值得?
📝 全局锁
🔥 热点数据
相比XA,Seata 虽然在一阶段成功后会释放数据库锁,但一阶段在commit前全局锁的判定也拉长了对数据锁的占有时间,这个开销比XA的prepare低多少需要根据实际业务场景进行测试。全局锁的引入实现了隔离性,但带来的问题就是阻塞,降低并发性,尤其是热点数据,这个问题会更加严重。
🔥 回滚锁释放时间
Seata在回滚时,需要先删除各节点的undo log,然后才能释放TC内存中的锁,所以如果第二阶段是回滚,释放锁的时间会更长。
🔥 死锁问题
Seata的引入全局锁会额外增加死锁的风险,但如果出现死锁,会不断进行重试,最后靠等待全局锁超时,这种方式并不优雅,也延长了对数据库锁的占有时间。
🎉 事务模式
Seata 将为用户提供了 AT、TCC、SAGA 和XA 事务模式,为用户打造一站式的分布式解决方案。AT模式是阿里首推的模式,阿里云上有商用版本的GTS。
📝 XA 事务模式
基于XA协议的两阶段提交
XA是一个分布式事务协议,由Tuxedo提出。XA中大致分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。但是,XA也有致命的缺点,那就是性能不理想,特别是在交易下单链路,往往并发量很高,XA无法满足高并发场景。XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。许多nosql也没有支持XA,这让XA的应用场景变得非常狭隘。
XA 模式其实就是 Seata 底层利用了 XA 接口,在一阶段二阶段时自动处理。如一阶段时,XA 的 RM 通过代理用户数据源,创建 XAConnection,进行开启 XA 事务(XA start)和 XA-prepare(此时 XA 的任何操作都会被持久化,即便宕机也能恢复),在二阶段时,TC 通知 RM 进行 XA 分支的 Commit/Rollback 操作。
📝 AT事务模式
AT模式的核心是对业务无侵入,是一种改进后的两阶段提交。
🔥 第一阶段
业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。核心在于对业务sql进行解析,转换成undolog,并同时入库。
业务 sql:update product set name = ‘GTS’ where name = ‘TXC’。
一阶段的执行过程对用户是无感知的,用户侧的业务 sql 保持不变,而 AT 模式下一阶段具体发生了什么?接下来,简单说下。
解析 sql 并查询得到前镜像:select id, name, since from product where name = ‘TXC’。
执行业务 sql。
查询执行后的数据作为后镜像:select id, name, since from product where id = 1。
🔥 第二阶段
分布式事务操作成功,则TC通知RM异步删除undolog。
分布式事务操作失败,TM向TC发送回滚请求,RM 收到协调器TC发来的回滚请求,通过 XID 和 Branch ID 找到相应的回滚日志记录,通过回滚记录生成反向的更新 SQL 并执行,以完成分支的回滚。
提交:仅需把事务相关信息删除即可(理论上不删除也没问题)。
回滚:取出前镜像进行回滚。
🔥 优点
Seata架构的亮点主要有几个:
- 应用层基于SQL解析实现了自动补偿,从而最大程度的降低业务侵入性;
- 将分布式事务中TC(事务协调者)独立部署,负责事务的注册、回滚;
- 通过全局锁实现了写隔离与读隔离。
通过上述简单的例子,其实可以发现,AT 模式就是自动补偿式事务,那 AT 具体都做了哪些呢?下文将会讲述。
🔥 AT 如何保证分布式事务一致性?
可能很多人刚看到上图会有疑问,其实这个就是无侵入式 AT 模式的做法示意图。首先用户还是从接口进入,到达事务发起方,此时对业务开发者来说,这个发起方入口就是一个业务接口罢了,一样地执行业务 sql,一样地 return 响应信息给客户端并没有什么改变。而背后就是用户的 sql 被 Seata 代理所托管,Seata-AT 模式能感知到用户的所有 sql,并对之进行操作,来保证一致性。
🔥 Seata-AT 是怎么做到无侵入的呢?
应用启动时 Seata 会自动把用户的 DataSource 代理,对 JDBC 操作熟悉的用户其实对 DataSource 还是比较熟悉的,拿到了 DataSource,就等于掌握了数据源连接,也就能在背后做些“小动作”,此时对用户来讲也是无感知无入侵。
之后业务有请求进来,执行业务 sql 时,Seata 会解析用户的 sql,提取出表元数据,生成前镜像,再通过执行业务 sql,保存执行 sql 后的后镜像(至于后镜像的介绍之后会讲到),生成行锁之后在注册分支时携带到 Seata-Server,也就是 TC 端。
到此为止,在 Client 端的一阶段操作就已经完成了,无感知、无入侵。此时如果思考下,会发现这里其实有一个行锁,这个行锁是干什么用的呢?这就是要接着讲到 Seata-AT 是如何保证分布式下的事务隔离性,这里直接拿官网的示例来说。
写隔离
一阶段本地事务提交前,需要确保先拿到全局锁 。
拿不到全局锁,不能提交本地事务。
拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的全局锁,本地提交释放本地锁。tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待**全局锁 **。
tx1 二阶段全局提交,释放全局锁 。tx2 拿到全局锁提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时如果 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程全局锁在 tx1 结束前一直是被 tx1 持有的,所以不会发生脏写的问题。
这个时候隔离性想必大家已经比较明白了,此时一阶段的大部分操作相信大家也比较明白了,接下来我们继续往下一阶段解析。
AT 模式二阶段处理
在二阶段提交时,TC 仅是下发一个通知 :把之前一阶段做记录的 undoLog 删除,并把相关事务信息如:行锁删除,之后让因为在竞争锁被阻塞的事务顺利进行。
而二阶段是回滚时,则要多做一些处理。
首先在 Client 端收到 TC 告知的二阶段是回滚时,会去查到对应的事务的 undolog,取出后镜像,对比当前的数据(因为 SeataAT 是从业务应用层面进行保护分布式事务,如果此时在数据库层面直接修改了库内信息,这个时候 SeataAT 的行锁不起隔离性作用),如果出现了在全局事务以外的数据修改,此时判定为脏写,而 Seata 因为无法感知这个脏写如何发生,此时只能打印日志和触发异常通知,告知用户需要人工介入(规范修改数据入口可避免脏写)。
而如果没有发生脏写就比较简单了,拿出前镜像,众所皆知事务是需要有原子性的,要么一起发生,要么都不发生,此时前镜像记录了发生之前的数据,进行回滚后,就达到了类似本地事务那样的原子性效果。回滚后,再把事务相关信息,如 undolog,行锁进行删除。二阶段回滚算是告一段落了。
既然介绍完了 AT 模式的一阶段及二阶段的原理思想方式,那么 AT 在 Seata 的分布式事务框架下是怎么样的呢?
可以看到,AT 与其它事务模式在 Seata 事务框架中,会多出一个 undolog 的表(相对其它模式的入侵点),但是除此之外,对业务来说,几乎是零入侵性,这也就是为什么 AT 模式在 Seata 中受众广泛的原因。
AT 模式与 Seata 支持的其它二阶段模式区别
首先应该明白,目前为止,不存在有任何一种分布式事务的可以满足所有场景。
无论 AT 模式、TCC 模式还是 Saga 模式,这些模式的提出,本质上都源自 XA 规范对某些场景需求的无法满足。
目前分为 3 点来做出对比:
数据锁定
AT 模式使用全局锁保障基本的写隔离,实际上也是锁定数据的,只不过锁在 TC 侧集中管理,解锁效率高且没有阻塞的问题。
TCC 模式无锁,利用本地事务排他锁特性,可预留资源,在全局事务决议后执行相应操作。
XA 模式在整个事务处理过程结束前,涉及数据都被锁定,读写都按隔离级别的定义约束起来。
死锁(协议阻塞)
XA 模式 prepare 后(老版本的数据库中,需要 XA END 后,再下发 prepare <三阶段由来>),分支事务进入阻塞阶段,收到 XA commit 或 XA rollback 前必须阻塞等待。
AT 可支持降级,因为锁存储在 TC 侧,如果 Seata 出现 bug 或者其它问题,可直接降级,对后续业务调用链无任何影响。
TCC 无此问题。
性能
性能的损耗主要来自两个方面:一方面,事务相关处理和协调过程,增加单个事务的 RT;另一方面,并发事务数据的锁冲突,降低吞吐。其实主要原因就是上面的协议阻塞跟数据锁定造成。
XA 模式它的一阶段不提交,在大并发场景由于锁存储在多个资源方(数据库等),加剧了性能耗损。
AT 模式锁粒度细至行级(需要主键),且所有事务锁存储在 TC 侧,解锁高效迅速。
TCC 模式性能最优,仅需些许 RPC 开销,及 2 次本地事务的性能开销,但是需要符合资源预留场景,且是对业务侵入性较大(需要业务开发者每个接口分为 3 个,一个 try,2 个二阶段使用的 confirm 和 cancel )。
可能很多同学对 XA 和 AT 的锁 & 协议阻塞不是特别理解,那么直接来看下图:
可以试着猜一下是哪个是 XA?其实下图的是 XA,因为它带来的锁粒度更大,且锁定时间更久,导致了并发性能相对 AT 事务模型来说,差的比较多,所以至今XA模式的普及度都不很太高。
总结:关于seata是可以做到对项目代码无入侵,代价是需要部署和维护一个中间件,关于at和xa模式对比从概念上看很难区别,我的理解差异点在于AT模式的隔离就是靠全局锁来保证,粒度细至行级,锁信息存储在Seata-Server一侧。
XA模式的隔离性就是由本地数据库保证,锁存储在各个本地数据库中。由于XA模式一旦执行了prepare后,再也无法重入这个XA事务,也无法跟其他XA事务共享锁。因为XA协议,仅是通过XID来start一个xa事务,本身它不存在所谓的分支事务说法,它本事就是一个XA事务而已,也就是说它只管它自己。at模式的undolog就是把本地事务作用中的undolog,利用他的原理,做到了分布式事务中,来保证了分布式事务下的事务一致性。
目前使用:目前是结合sharding在使用,如xxljob跑任务会用到一些订单实时报价并修改用户订单概览等信息,需要与第三方系统交互,系统就需要保证数据的最终一致性。
🍊 流量控制
Sentinel 是面向分布式服务架构的高可用流量防护组件,主要以流量为切入点,从限流、流量整形、熔断降级、系统负载保护、热点防护等多个维度来帮助开发者保障微服务的稳定性。
🎉 实现原理
📝 启动并且初始化Sentinel
Sentinel本质上只是一个运行在特殊模式下的Redis服务器,当一个Sentinel启动时,它需要执行以下步骤:
1)初始化服务器。
2)将普通Redis服务器使用的代码替换成Sentinel专用代码。
3)初始化Sentinel状态。
4)根据给定的配置文件,初始化Sentinel的监视主服务器列表。
5)创建连向主服务器的网络连接。
对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:
❑一个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
❑另一个是订阅连接,这个连接专门用于订阅主服务器的__sentinel__:hello频道。
📝 获取主服务器信息
通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两方面的信息:
❑一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色;
❑另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个"slave"字符串开头的行记录,每行的ip=域记录了从服务器的IP地址,而port=域则记录了从服务器的端口号。根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。
📝 获取从服务器信息
❑从服务器的运行ID run_id。
❑从服务器的角色role。
❑主服务器的IP地址master_host,以及主服务器的端口号master_port。
❑主从服务器的连接状态master_link_status。
❑从服务器的优先级slave_priority。
❑从服务器的复制偏移量slave_repl_offset。
📝 向主服务器和从服务器发送信息,接收来自主服务器和从服务器的频道信息
对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知。
当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络:Sentinel A有连向Sentinel B的命令连接,而Sentinel B也有连向Sentinel A的命令连接。
📝 检测主观下线状态
Sentinel配置文件中的down-after-milliseconds选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在down-after-milliseconds毫秒内,连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的flags属性中打开SRI_S_DOWN标识,以此来表示这个实例已经进入主观下线状态。多个Sentinel设置的主观下线时长可能不同
📝 检查客观下线状态
当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。当认为主服务器已经进入下线状态的Sentinel的数量,超过Sentinel配置中设置的quorum参数的值,那么该Sentinel就会认为主服务器已经进入客观下线状态。不同Sentinel判断客观下线的条件可能不同。
📝 选举领头的Sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。
以下是Redis选举领头Sentinel的规则和方法:
❑所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel。
❑每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元(configuration epoch)的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的。
❑在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改。
❑每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
❑当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送SENTINEL is-master-down-by-addr命令,并且命令中的runid参数不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel。
❑Sentinel设置局部领头Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝。
❑目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
❑源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel。
❑如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。举个例子,在一个由10个Sentinel组成的Sentinel系统里面,只要有大于等于10/2+1=6个Sentinel将某个Sentinel设置为局部领头Sentinel,那么被设置的那个Sentinel就会成为领头Sentinel。
❑因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现一个领头Sentinel。
❑如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止。
📝 故障转移
1)在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
2)让已下线主服务器属下的所有从服务器改为复制新的主服务器。
3)将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
新的服务器是怎样挑选出来的?
领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:
1)删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
2)删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
3)删除所有与已下线主服务器连接断开超过down-after-milliseconds10毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。
之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。
如果有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。
最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的从服务器。
🌟 致歉
伙伴们,本来想将所有知识点都写到一篇文章里面,后面发现无法实现,单篇文章字数过多会导致丢失情况,咨询过官方客服,得到的答复是目前也没有其他解决办法,建议分为多篇发,不符合一开始总结成一篇文章的规划。
解决方法:
请移步到GitEE代码仓库查看,完整版地址:
🌟 作者求关注
用心写文章,希望大家多多关注我哈。
个人技术博客主页:欢迎大家来关注我哟:https://blog.csdn.net/java_wxid
个人开源项目主页:欢迎大家来评价(Star) :https://gitee.com/java_wxid/java_wxid