1. 分布式事务
用于保证分布式系统中操作的原子性和数据的一致性。参考数据库的ACID,实际实现中Isolation是做了一定程度的牺牲的,Consistency也和实现有关。
2. 基本原理
2.1 CAP理论
-
数据一致性
-
系统可用性
-
分区容错性,是否存在由于网络问题导致的系统分区(partition)
-
在分布式系统中,P是一定存在的,因此本质上CAP的选择在于CP和AP
-
举例来说,ZK就是一个CP系统的设计,当出现P,即网络分区时,为了保证数据的一致性,系统是不可用的;而Eureka等注册中心的实现一般都是AP系统,在P发生时可以牺牲系统暂时的一致性,优先保证系统可用。
2.2 BASE理论
-
BA,Basically Avaiable,基本可用
-
S,Soft state, 软事务,可以理解为分布式事务
-
E,Eventually consistent,数据的最终一致性
2.3 两阶段提交
两阶段提交协议的目标在于为分布式系统保证数据一致性,该协议将一个分布式的事务过程拆分成两个阶段: 投票 和 事务提交。该协议指定了 协调者和参与者的概念。
第一阶段:投票
该阶段确定各参与者是否能够正常的执行事务,具体步骤如下:
-
协调者向所有的参与者发送事务执行请求,并阻塞等待参与者反馈事务执行结果;
-
事务参与者收到请求之后,执行事务但不提交,并记录事务日志;
-
参与者将自己事务执行情况反馈给协调者,同时等待协调者的后续指令。
第二阶段:事务提交
在经过第一阶段协之后,各个参与者会回复自己事务的执行情况,这时候存在 3 种可能性:
-
所有的参与者能够正常执行事务;
-
一个或多个参与者事务执行失败;
-
协调者等待超时。
对于第 1 种情况,协调者将向所有的参与者发出提交事务的通知,具体步骤如下:
-
协调者向各个参与者发送 commit 通知,请求提交事务;
-
参与者收到事务提交通知之后执行 commit 操作,然后释放占有的资源;
-
参与者向协调者返回事务 commit 结果信息。
对于第 2 和第 3 种情况,协调者均认为参与者无法成功执行事务,向各个参与者发送事务回滚通知,具体步骤如下:
-
协调者向各个参与者发送事务 rollback 通知,请求回滚事务;
-
参与者收到事务回滚通知之后执行 rollback 操作,然后释放占有的资源;
-
参与者向协调者返回事务 rollback 结果信息。
两阶段提交缺点:
-
单点问题
协调者在整个两阶段提交过程中扮演着举足轻重的作用,一旦协调者所在服务器宕机,就会影响整个数据库集群的正常运行。比如在第二阶段中,如果协调者因为故障不能正常发送事务提交或回滚通知,那么参与者们将一直处于阻塞状态,整个数据库集群将无法提供服务。
-
同步阻塞
两阶段提交执行过程中,所有的参与者都需要听从协调者的统一调度,期间处于阻塞状态而不能从事其他操作,这样效率极其低下。
-
数据不一致性
两阶段提交协议虽然是分布式数据强一致性所设计,但仍然存在数据不一致性的可能性。比如在第二阶段中,假设协调者发出了事务 commit 通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
2.4 三阶段提交
针对两阶段提交存在的问题,三阶段提交协议通过引入一个 预盘询 阶段,以及超时策略来减少整个集群的阻塞时间,提升系统性能。三阶段提交的三个阶段分别为:预盘询(can_commit)、预提交(pre_commit),以及事务提交(do_commit)。
如果因为协调者或网络问题,导致参与者迟迟不能收到来自协调者的 commit 或 rollback 请求,那么参与者将不会如两阶段提交中那样陷入阻塞,而是等待超时后继续 commit,相对于两阶段提交虽然降低了同步阻塞,但仍然无法完全避免数据的不一致。
3. SEATA的三个组件
Seata 中有三大模块,分别是 TM、RM 和 TC。 其中 TM 和 RM 是作为 Seata 的客户端与业务系统集成在一起,TC 作为 Seata 的服务端独立部署。SEATA是对两阶段提交协议的实现,TC和TM一起承担事务协调者的角色,RM承担参与者的角色。
在 Seata 中,分布式事务的执行流程:
-
TM 开启分布式事务(TM 向 TC 注册全局事务记录);
-
按业务场景,编排数据库、服务等事务内资源(RM 向 TC 汇报资源准备状态 );
-
TM 结束分布式事务,事务一阶段结束(TM 通知 TC 提交/回滚分布式事务);
-
TC 汇总事务信息,决定分布式事务是提交还是回滚;
-
TC 通知所有 RM 提交/回滚 资源,事务二阶段结束;
4. SEATA四种模式
4.1 AT模式
AT 模式是一种无侵入的分布式事务解决方案。在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
4.1.1 如何做到无侵入
-
一阶段:
-
Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更新的业务数据;
-
在业务数据被更新前,将其保存成“before image”,然后执行“业务 SQL”更新业务数据;
-
在业务数据更新之后,再将其保存成“after image”;
-
一个xid &&branchId的before和after image都被保存在undo_log的一条记录中;
-
以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性;
-
最新的实现已没有本地行锁表了。
-
-
二阶段提交:
“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
-
二阶段回滚:
-
Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据;
-
回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。
-
AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。
4.1.2 AT模式的隔离性
-
全局锁
-
AT模式可以对事务中操作的数据加一把全局锁,思想是对操作的每一行数据加一把锁;
-
具体实现是在TC的数据库中创建一个全局锁表,以“resource id:database:table:primarykey”作为主键存储一个数据,加锁的时候写入此表,解锁的时候从中删除。
-
-
本地锁
此处的本地锁是指数据库本身的锁,在开启本地事务后,对数据行的写操作会自动为其上行锁(可能是GAP锁),本地事务提交后释放。
-
写隔离
写隔离是指在一个事务tx1中对一条数据做了修改而未提交之前,其他的事务tx2不能对同一条数据进行修改。
-
一阶段本地事务提交前,需要确保先拿到 全局锁 。
-
拿不到 全局锁 ,不能提交本地事务。
-
拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 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 持有的,所以不会发生 脏写 的问题。
-
-
读隔离
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
4.1.3 AT模式的性能优化
-
二阶段Commit时,TC直接返回成功,然后异步提交各个分支事务(因为此时RM只需要删除undo_log,即使失败也有后续的定时任务去删除)
-
二阶段Commit时,RM直接返回成功,异步的删除undo_log(原因同上)
-
二阶段Rollback,因为涉及到undo_log的redo,不论是TC还是RM都不可异步执行(Rollback占比小,对性能影响较小)
4.1.4 AT模式优缺点
-
优点
-
对业务无侵入,接入AT模式的业务无需修改代码
-
-
缺点
-
需要支持JDBC的数据源,只能用于Java应用
-
要在每个业务库中创建undo_log表
-
rollback之前如果数据被修改,需要人工介入
-
依赖于本地事务,在使用分库分表中间件(Proxy模式)时本地事务失效,导致整个分布式事务不可用
-
没有全局数据的一致性,一阶段和二阶段提交中间,用户可能读取到不一致的数据(脏读,补偿型分布式事务都会存在的问题)
-
4.1.5 其他细节
-
为什么第一阶段提交要真实执行资源的事务提交方法?
首先,子事务的提交也存在失败的可能,应该尽早把失败暴露出来,以决定回滚整个事务;
其次,保证了整个事务提交时一定能成功,二阶段提交时各个子事务事实上已经提交成功,只需要清理一些中间日志数据,如果是回滚,失败率较低,回滚失败记录日志人工处理;
再次,如果一阶段开启子事务而不提交,会导致在子事务提交前,表数据被锁(以mysql为例,GAP锁可能会锁额外很多的行),导致数据库操作被block,影响整体系统吞吐性能;
最后,如果父事务提交的时候才提交子事务,会导致用户不能确定事务是否提交成功(子事务提交失败不会通知TM);相反,一般情况下事务都是成功,二阶段commit失败(例如网络原因)并不影响数据一致性,undo和redo日志等可以通过定时任务清理。
-
全局锁和业务表中的行锁的逻辑?
全局锁存于TC的数据库中,行锁没有表,依赖于数据库本身的行锁机制。
-
全局锁死锁风险
如果获取全局锁的节点“失联”,分支事务的二阶段rollback和commit可以被推送到同一个资源(RM)的任意节点执行,因此不存在“失联”导致死锁的风险。
-
AT模式悬挂
场景:分支事务在注册后、一阶段提交前,发生了全局事务的回滚,如果分支事务继续提交会导致数据不一致。(分支事务的书册和一阶段提交不是原子操作,全局回滚和分支事务注册/提交在不同的线程,可能并发执行。)
解决:全局事务回滚时,会发送回滚指令到已经注册的分支,如发现分分支事务还未进行一阶段提交(没有undo日志),则在undo_log里插入一条log_status==1的日志,后续分支提交的时候会插入日志失败,从而阻止分支事务提交。
4.2 XA模式
4.2.1 什么是 XA
-
XA 规范是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
-
XA 规范描述了全局的事务管理器与局部的资源管理器之间的接口。
-
XA规范的目的是允许多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
-
XA 规范使用两阶段提交(2PC,Two-Phase Commit)来保证所有资源同时提交或回滚任何特定的事务。
-
XA 规范在上世纪 90 年代初就被提出。目前,几乎所有主流的数据库都对 XA 规范 提供了支持。
4.2.2 Seata 的 XA 模式
4.2.2.1 整体运行机制
在 Seata 定义的分布式事务框架内,利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种事务模式。
-
执行阶段:
-
可回滚:业务 SQL 操作放在 XA 分支中进行,由资源对 XA 协议的支持来保证可回滚
-
持久化:XA 分支完成后,执行 XA prepare,同样,由资源对 XA 协议的支持来保证持久化(即,之后任何意外都不会造成无法回滚的情况)
-
-
完成阶段:
-
分支提交:执行 XA 分支的 commit
-
分支回滚:执行 XA 分支的 rollback
-
-
数据源代理
XA 模式需要 XAConnection。
获取 XAConnection 两种方式:
-
方式一:要求开发者配置 XADataSource
-
方式二:根据开发者的普通 DataSource 来创建
第一种方式,给开发者增加了认知负担,需要为 XA 模式专门去学习和使用 XA 数据源,与 透明化 XA 编程模型的设计目标相违背。
第二种方式,对开发者比较友好,和 AT 模式使用一样,开发者完全不必关心 XA 层面的任何问题,保持本地编程模型即可。
SEATA优先设计实现第二种方式:数据源代理根据普通数据源中获取的普通 JDBC 连接创建出相应的 XAConnection。
类比 AT 模式的数据源代理机制,如下:
-
但是,第二种方法有局限:无法保证兼容的正确性。
实际上,这种方法是在做数据库驱动程序要做的事情。不同的厂商、不同版本的数据库驱动实现机制是厂商私有的,我们只能保证在充分测试过的驱动程序上是正确的,开发者使用的驱动程序版本差异很可能造成机制的失效。
这点在 Oracle 上体现非常明显。参见 Druid issue:DruidXADataSource不能正常工作在ORACLE上 · Issue #3707 · alibaba/druid · GitHub
综合考虑,XA 模式的数据源代理设计需要同时支持第一种方式:基于 XA 数据源进行代理。
类比 AT 模式的数据源代理机制,如下:
4.2.2.2 SEATA为什么支持 XA
补偿型分布式事务机制因为不要求事务资源本身(如数据库)的机制参与,所以无法保证从事务框架之外的全局视角的数据一致性。
与补偿型不同,XA协议要求事务资源本身提供对规范和协议的支持。因为事务资源感知并参与分布式事务处理过程,所以事务资源(如数据库)可以保障从任意视角对数据的访问有效隔离,满足全局数据一致性。
4.2.2.3 XA 广泛被质疑的问题
数据锁定:数据在整个事务处理过程结束前,都被锁定,读写都按隔离级别的定义约束起来。
思考:
数据锁定是获得更高隔离性和全局一致性所要付出的代价。
补偿型的事务处理机制,在执行阶段即完成分支(本地)事务的提交,(资源层面)不锁定数据。而这是以牺牲隔离性为代价的。
另外,AT 模式使用 全局锁 保障基本的 写隔离,实际上也是锁定数据的,只不过锁在 TC 侧集中管理,解锁效率高且没有阻塞的问题。
协议阻塞:XA prepare 后,分支事务进入阻塞阶段,收到 XA commit 或 XA rollback 前必须阻塞等待。
思考:
协议的阻塞机制本身并不是问题,关键问题在于协议阻塞遇上数据锁定。
如果一个参与全局事务的资源 “失联” 了(收不到分支事务结束的命令),那么它锁定的数据,将一直被锁定。进而甚至可能因此产生死锁。
这是 XA 协议的核心痛点,也是 Seata 引入 XA 模式要重点解决的问题。
基本思路是两个方面:避免 “失联” 和 增加 “自解锁” 机制。
性能差:性能的损耗主要来自两个方面:一方面,事务协调过程,增加单个事务的 RT;另一方面,并发事务数据的锁冲突,降低吞吐。
思考:
和不使用分布式事务支持的运行场景比较,性能肯定是下降的,这点毫无疑问。
本质上,事务(无论是本地事务还是分布式事务)机制就是拿部分 性能的牺牲 ,换来 编程模型的简单 。
与同为 业务无侵入 的 AT 模式比较:
首先,因为同样运行在 Seata 定义的分布式事务框架下,XA 模式并没有产生更多事务协调的通信开销。
其次,并发事务间,如果数据存在热点,产生锁冲突,这种情况,在 AT 模式(默认使用全局锁)下同样存在的。
所以,在影响性能的两个主要方面,XA 模式并不比 AT 模式有非常明显的劣势。
AT 模式性能优势主要在于:集中管理全局数据锁,锁的释放不需要 RM 参与,释放锁非常快;另外,全局提交的事务,完成阶段 异步化。
4.3. TCC模式
4.3.1 什么是TCC
-
TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;
-
事务发起方在一阶段执行 Try 方式,在二阶段提交执行 Confirm 方法,二阶段回滚执行 Cancel 方法;
-
事务二阶段需要用到的补偿数据通过分支注册上传到TC,TC在二阶段提交/回滚时推送到RM节点。
TCC 三个方法描述:
-
Try:资源的检测和预留;
-
Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功(通过资源监测和预留实现);
-
Cancel:预留资源释放。
4.3.2 需要注意以下问题
a. 允许空回滚
Cancel 接口设计时需要允许空回滚。在 Try 接口因为丢包或其他原因还未执行,事务回滚触发 Cancel 接口,需要返回回滚成功。让事务服务管理器认为已回滚,否则会不断重试,而 Cancel 又没有对应的业务数据可以进行回滚。(可以根据业务数据或者事务的xid&branchId来标识try是否执行过)
b. 防悬挂控制
悬挂的意思是:Cancel 比 Try 接口先执行,出现的原因是 Try 由于网络拥堵还未执行,事务回滚触发 Cancel ,之后执行了Try ,导致了脏数据。在 Cancel 空回滚返回成功之前先记录该条事务 xid&branchId已回滚的记录,Try 执行前先检查此记录。
c. 幂等控制
幂等性的意思是:对同一个系统,使用同样的条件,一次请求和重复的多次请求对系统资源的影响是一致的。因为网络抖动或拥堵可能会超时,事务管理器会对资源进行重试操作,所以很可能一个业务操作会被重复调用,为了不因为重复调用而多次占用资源,需要对服务设计时进行幂等控制,通常我们可以用事务 xid 或业务主键判重来控制。
4.3.3 TCC模式的优缺点
-
优点
-
不依赖于数据源的本地事务,和数据库中间件(Proxy && 客户端模式)可以无缝兼容
-
业务来实现confirm和cancel,无需加锁,性能高
-
-
缺点
-
业务要改造逻辑,并且要注意空回滚、防悬挂、幂等性,成本高
-
4.4 SAGA模式
TODO.
5. 如何实现高可用
-
TC Server保持无状态,数据存到数据源(mysql、redis、file),每次处理请求和定时任务,都是从数据源中拉取最新的数据进行处理。
-
TM和RM与TC之间维持长连接,如果在事务处理过程中,TM和RM节点停机挂掉了,seata如何处理未完成的事务?
-
TM:TC有超时检测,timeout-rollback机制,不会有问题。
-
RM:如果一阶段还未完成,则服务应该返回异常,由TM回滚全局事务;如果一阶段完成,等待全局事务commit/rollback的过程中节点挂掉,二阶段请求可以通过其他节点下发,只要能连通数据库的节点都可以执行消息请求。
-
6. 如何与分库分表中间件结合
-
与Sharding-jdbc结合
可以无缝兼容,Sharding-jdbc兼容了SEATA。
-
与Sharding-proxy结合
-
AT|XA模式与Sharding-proxy结合
-
需要为Proxy搭建一个TC集群和存储集群,proxy解析到事务开启命令时开启一个SEATA全局事务,后续的语句分别当做本地的一个子事务来处理;这种方式proxy的维护方要为所有使用proxy的业务处理数据不一致问题(例如脏写的人工矫正问题);
-
更好的方式是不使用seata,从业务结构上避免事务,或者是保证所有的事务内操作能走到一个物理库;
-
-
TCC模式可以兼容Sharding-proxy
-