分布式事务-理论篇
案例
我自己负责的系统中有业务聚合层,暴露了第三方对接的创建接口,功能有两个:创建对应账户(还有一系列配置信息创建),账号和第三方账户绑定。这两个功能其实可以复用底层核心服务域的接口来完成。按照面向过程编程(很多时候就是面向过程),两者前后调用,在没有分布式事务的情况下,如果第二步“绑定第三方账号”失败,无法自动回滚第一步“创建账户”,而要通过手动回滚,逻辑复杂,因为创建附带初始化了很多数据;如果不回滚“创建账户”,第三方再次调用创建会报错“账号已存在”,或者调用免登接口因为无第三方账号映射无法免登。
目前是通过 在核心服务域中提供第三方对接的专有接口,将分布式事务转化为单机单库的事务。如果每个业务都如此,那么服务拆分,服务重用的价值就不大了,由此可见分布式事务的重要性,是一种基础性的组件。
在分布式系统中,经常出现以下几种部署方案:同一个服务搭配多个数据库;不同服务搭配不同数据库;不同服务搭配同一个数据库(前面一般是针对分库分表的情况,最后一种才是普遍情况);而一项业务流程可能要多项服务来完成,那么单机单库的事务就需要演变成横跨多数据源,多服务的分布式事务。分布式事务就是为了解决在同一个事务下,不同节点的数据库操作数据不一致的问题。在一个事务操作请求多个服务或多个数据库节点时,要么所有请求成功,要么所有请求都失败回滚回去。
注:分布式事务不仅仅体现在数据库层面,还体现在服务调用层面,如 在某个场景中要求业务操作和发送消息保证原子性,因为可能有下游服务通过消息订阅进行依赖。当然这里主要讲数据库相关
DTP模型和XA规范
DTP 模型中主要包含了 AP、RM、TM 三个角色,其中 AP 是应用程序,是事务发起和结束的地方;RM 是资源管理器,主要负责管理每个数据库的连接数据源;TM 是事务管理器,负责事务的全局管理,包括事务的生命周期管理和资源的分配协调等
XA 协议是由 X/Open 组织提出的一个分布式事务处理规范,规范了 TM 与 RM 之间的通信接口,在 TM 与多个 RM 之间形成一个双向通信桥梁,从而在多个数据库资源下保证 ACID 四个特性。目前 MySQL 中只有 InnoDB 存储引擎支持 XA 协议。
DTP模型就像7层网络结构,XA规范就像TCP/IP,UDP协议,基于模型提出协议规范,这两者都是通用的分布式事务模型和规范,所以不局限于数据库层面,例如消息中间件JMS有基于XA规范支持分布式事务的实现
数据层解决方案
2PC
2pc就是两阶段提交,两阶段提交其实是一种方法论,很多地方都有具体支持,包括后续的TCC,seata,以及扩展中的zk,innodb等。
对于两阶段,我自己的理解就是将一次数据变更(新增/更新等)转变成两次,增加中间状态。第一次变成中间状态,根据其他操作的结果来变决定最终状态。
具体到XA规范中的实现如下:第一阶段由TM发起全局事务,由各个RM锁定资源进行prepare操作,第二阶段根据prepare的结果进行全局提交或者异常回滚操作。
以下图片来源《极客时间-Java性能调优实战》
缺点
- TM的单点问题
- 分支事务较多,或者事务流程较长,那么很多RM节点存在阻塞情况,影响整体性能
- 仍然存在数据不一致的可能性,例如,在最后通知提交全局事务时,由于网络故障,部分节点有可能收不到通知,由于这部分节点没有提交事务,就会导致数据不一致的情况出现。
3PC
3PC就是三阶段提交,在2PC的基础上改进而来。
3PC 把 2PC 的准备阶段分为了准备阶段和预处理阶段,在第一阶段只是询问各个资源节点是否可以执行事务,而在第二阶段,所有的节点反馈可以执行事务,才开始执行事务操作,最后在第三阶段执行提交或回滚操作。并且在事务管理器和资源管理器中都引入了超时机制,如果在第三阶段,资源节点一直无法收到来自资源管理器的提交或回滚请求,它就会在超时之后,继续提交事务。
我觉得2步拆成3步没啥必要,只有超时机制有点用处,虽然通过超时机制来一定程度上解决2PC的超时问题,但是对于网络问题、或者异常问题导致的数据不一致无法解决。
服务层解决方案
TCC
Try-Confirm-Cancel,事务补偿机制。TCC 正是为了解决2PC,3PC问题而出现的一种分布式事务解决方案。TCC 采用最终一致性的方式实现了一种柔性分布式事务,与 XA 规范实现的二阶事务不同的是,TCC 的实现是基于服务层实现的一种二阶段事务提交。
- Try 阶段:主要尝试执行业务,执行各个服务中的 Try 方法,主要包括预留操作;可以认为是开启事务但未提交,或者是预留部分数据,这个看具体业务
- Confirm 阶段:确认 Try 中的各个方法执行成功,然后通过 TM 调用各个服务的 Confirm 方法,这个阶段是提交阶段;
- Cancel 阶段:当在 Try 阶段发现其中一个 Try 方法失败,例如预留资源失败、代码异常等,则会触发 TM 调用各个服务的 Cancel 方法,对全局事务进行回滚,取消执行业务。
如果在 Confirm 和 Cancel 阶段出现异常情况,此时 TCC 会不停地重试调用失败的 Confirm 或 Cancel 方法,直到成功为止。网上有些说基于事件驱动,基于可靠消息驱动的重试机制,其实都是TCC(事务补偿机制)的变种,就是通过不同的方式进行重试。
以下图片来源《极客时间-Java性能调优实战》
以事件驱动的方式实现TCC来举例,有一张事务表,(XID,order,status,isDone)
- 启动事务:插入多条事务记录,status=init,isDone=N;
- 调用try方法之后,提交/回滚事务,比如 status=success,isDone=N;
- 轮训事务表,取出isDone=N的记录, 不断进行重试confirm或者cancel,成功后isDone=Y;达到一个最终一致性
TCC缺点也很明显:对业务的侵入性非常大,需要业务提供相关的方法,在实现Try-Confirm-Cancel方法时要考虑重试的幂等性、异常处理等情况。
Seata
Seata 是阿里开源的一套分布式事务解决方案,其基础建模和 DTP 模型类似,只不过前者是将事务管理器分得更细了,抽出一个事务协调器(Transaction Coordinator 简称 TC),主要维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。而 TM 则负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。Seata官网
整个事务的流程大致如下:
- TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID;
- XID 在微服务调用链路的上下文中传播;
- RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
- TM 向 TC 发起针对 XID 的全局提交或回滚决议;
- TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求
以下图片来源 《极客时间-Java性能调优实战》 和 github
具体每个分支事务的本地锁,全局锁,读写隔离示例参见官网文档:读写隔离说明
要点总结:
- seata认为大部分事务应该是成功的,所以分支事务会直接提交,减少分支事务持有锁的时间;所以分支事务的隔离级别一般是RC/RR,从全局事务来看是RU
- 每个RM相关的数据源中都需要有UNDO_LOG表,保存记录修改前后的信息,和分支事务一同提交。用于后续的回滚操作
- 写隔离中的全局排他锁是根据resourceId + table + pks实现的,属于一种行锁
待验证点:
在 TC 通知 RM 开始提交事务后,TC 与 RM 的连接断开了,或者 RM 与数据库的连接断开了,都不能保证事务的一致性。不知道是否有超时机制,或者其他重试机制
两阶段提交一致性问题
以上各种方案其实都采用了两阶段提交,在最后的commit/rollback阶段,虽然通过超时,重试等方式来达到最终一致性,但是在前一阶段,通知TM要进行全局提交/回滚的时候,如果这个消息丢失了,怎么办。我了解到的一般是如下的解决方案
- 默认方案,不管/提交/回滚,要看场景,这些操作是否合适
- 由业务系统提供接口,由TM的一个模块来进行业务系统的询问,是否之前的操作成功or失败
拓展
CAP
- Consistency(一致性):所有节点在同一时间的数据完全一致。
- Availability(可用性):服务在正常响应时间内一直可用。
- Partition tolerance(分区容忍性):由于分布式系统通过网络进行通信,网络是不可靠的。当任意数量的消息丢失或延迟到达时,系统仍会继续提供服务,不会挂掉。换句话说,分区容忍性是站在分布式系统的角度,对访问本系统的客户端的再一种承诺:我会一直运行,不管我的内部出现何种数据同步问题,强调的是不挂掉。
BASE
由于CAP理论无法达到同时满足三者的要求,所以衍生出次一级的要求,就是BASE理论。
- 基本可用(Basically Available):假设系统出现不可预知异常,但还是能用,但是相对于正常系统要么响应时间受损失,要么功能上受损失,例如进入降级页面,或者部分其他功能无法使用。
- 软状态(Soft State):相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种“硬状态”。软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
- 最终一致性(Eventually Consistent):上面说软状态,然后不可能一直是软状态,必须有个时间期限。在期限过后,应当保证所有副本保持数据一致性,从而达到数据的最终一致性。这个时间期限取决于网络延时、系统负载、数据复制方案设计等等因素。
ZK中的两阶段提交
ZK的ZAB协议的内容很丰富(参考ZAB协议选主过程详解),包括原子广播,崩溃回复,选主协议等等。其中原子广播表现为两阶段提交,在原子广播中兼顾了CP,在两阶段提交事务的时候,其实是不提供对外服务的,所以A是欠缺的。
在ZAB协议中,当leader发生了一项数据变更,对所有follower进行广播,此时记录状态为proposal,当有超过一半的follower反馈记录成功,leader会发送commit广播,进行提交。
另:每个消息都被赋予了一个zxid,zxid全局唯一。zxid有两部分组成:高32位是epoch,低32位是epoch内的自增id,由0开始。每次选出新的Leader,epoch会递增,同时zxid的低32位清0。消息编号只能由leader赋值
ZAB协议中的选举算法其实是paxos协议的简化版,paxos协议是一种轻量级的共识算法。
InnoDB日志中的两阶段提交
- Undo log是InnoDB MVCC事务特性的重要组成部分,Undo记录中存储的是老版本数据,当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着undo链找到满足其可见性的记录。
- redo 日志是Innodb引擎独有的日志系统,是物理日志,记录的是哪个数据页发生了什么变化,数据更新时,先写到内存和redo日志中,redo日志文件是环形循环写入的,当日志满了的时候会将修改刷到磁盘中
- binlog 日志是server层的日志,是逻辑日志,statement模式下记录的是sql语句,row模式下记录的是更改前后的数据,日志信息是追加的,不像redo日志是覆盖的,binlog可以用来归档(备份+日志可以恢复到任意时刻)。归档/从库 是使用binlog,本机的重启恢复是使用redo log + bin log
日志采用两阶段:binlog和redolog通过XID字段来确认是否是同一变更记录。
- 先写入内存, 在redo中写入日志信息,状态为prepare
- 写binlog
- redo日志改为commit状态。
当在2之前崩溃时
- 重启恢复:后发现没有commit,没有binlog,回滚
- 备份恢复:没有binlog。两种情况的结果时一致的
当在3之前崩溃时
- 重启恢复:虽没有commit,但满足prepare和binlog完整,所以重启后会自动commit
- 备份恢复:有binlog。两种情况的结果时一致的