Mysql

关注公众号 jb51net

关闭
首页 > 数据库 > Mysql > 分布式事务经典的七种解决方案

关于MySQL与Golan分布式事务经典的七种解决方案

作者:叶东富

本文介绍了分布式事务的一些基础理论,并对常用的分布式事务方案进行了讲解;在文章的后半部分还给出了事务异常的原因、分类以及优雅的解决方案;最后以一个可运行的分布式事务例子,将前面介绍的内容以简短的程序进行演示,需要的朋友可以参考一下文章具体内容

前言:

随着业务的快速发展、业务复杂度越来越高,几乎每个公司的系统都会从单体走向分布式,特别是转向微服务架构。随之而来就必然遇到分布式事务这个难题。
这篇文章首先介绍了相关的基础理论,然后总结了最经典的事务方案,最后给出了子事务乱序执行(幂等、空补偿、悬挂问题)的解决方案,分享给大家。

1、基础理论

在讲解具体方案之前,我们先了解一下分布式事务所涉及到的基础理论知识。

我们拿转账作为例子,A需要转100元给B,那么需要给A的余额-100元,给B的余额+100元,整个转账要保证,A-100和B+100同时成功,或者同时失败。看看在各种场景下,是如何解决这个问题的。

1.1 事务

把多条语句作为一个整体进行操作的功能,被称为数据库事务。数据库事务可以确保该事务范围内的所有操作都可以全部成功或者全部失败。

事务具有 4 个属性:原子性一致性隔离性持久性这四个属性通常称为 ACID 特性。

假如我们的业务系统不复杂,可以在一个数据库、一个服务内对数据进行修改,完成转账,那么,我们可以利用数据库事务,保证转账业务的正确完成。

1.2 分布式事务

银行跨行转账业务是一个典型分布式事务场景,假设A需要跨行转账给B,那么就涉及两个银行的数据,无法通过一个数据库的本地事务保证转账的ACID,只能够通过分布式事务来解决。

分布式事务就是指事务的发起者、资源及资源管理器和事务协调者分别位于分布式系统的不同节点之上。在上述转账的业务中,用户A-100操作和用户B+100操作不是位于同一个节点上。本质上来说,分布式事务就是为了保证在分布式场景下,数据操作的正确执行。

分布式事务在分布式环境下,为了满足可用性、性能与降级服务的需要,降低一致性与隔离性的要求,一方面遵循 BASE 理论(BASE相关理论,涉及内容非常多,感兴趣的同学,可以参考BASE理论):

同样的,分布式事务也部分遵循 ACID 规范:

2、分布式事务的解决方案

由于分布式事务方案,无法做到完全的ACID的保证,没有一种完美的方案,能够解决掉所有业务问题。因此在实际应用中,会根据业务的不同特性,选择最适合的分布式事务方案。

2.1 两阶段提交/XA

XA是由X/Open组织提出的分布式事务的规范,XA规范主要定义了(全局)事务管理器(TM)和(局部)资源管理器(RM)之间的接口。本地的数据库如mysql在XA中扮演的是RM角色

XA一共分为两阶段:

目前主流的数据库基本都支持XA事务,包括mysqloraclesqlserverpostgre

XA 事务由一个或多个资源管理器(RM)、一个事务管理器(TM)和一个应用程序(ApplicationProgram)组成。

这里的RM、TM、AP三个角色是经典的角色划分,会贯穿后续Saga、Tcc等事务模式。

把上面的转账作为例子,一个成功完成的XA事务时序图如下:

如果有任何一个参与者prepare失败,那么TM会通知所有完成prepare的参与者进行回滚。

XA事务的特点是:

如果读者想要进一步研究XAgo语言以及PHPPythonJavaC#Node等都可参考DTM

2.2 SAGA

Saga是这一篇数据库论文sagas提到的一个方案。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

把上面的转账作为例子,一个成功完成的SAGA事务时序图如下:

Saga一旦到了Cancel阶段,那么Cancel在业务逻辑上是不允许失败了。如果因为网络或者其他临时故障,导致没有返回成功,那么TM会不断重试,直到Cancel返回成功。

Saga事务的特点:

论文里面的SAGA内容较多,包括两种恢复策略,包括分支事务并发执行,我们这里的讨论,仅包括最简单的SAGA

SAGA适用的场景较多,长事务适用,对中间结果不敏感的业务场景适用

如果读者想要进一步研究SAGA,可参考DTM,里面包括了SAGA成功、失败回滚的例子,还包括各类网络异常的处理。

2.3 TCC

关于 TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate's Opinion》的论文提出。

TCC分为3个阶段:

把上面的转账作为例子,通常会在Try里面冻结金额,但不扣款,Confirm里面扣款,Cancel里面解冻金额,

一个成功完成的TCC事务时序图如下:

TCCConfirm/Cancel阶段在业务逻辑上是不允许返回失败的,如果因为网络或者其他临时故障,导致不能返回成功,TM会不断的重试,直到Confirm/Cancel返回成功。

TCC特点如下:

如果读者想要进一步研究TCC,可参考DTM

2.4 本地消息表

本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章。设计核心是将需要分布式处理的任务通过消息的方式来异步确保执行。

大致流程如下:

写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。

容错机制:

本地消息表的特点:

适用于可异步执行的业务,且后续操作无需回滚的业务

2.5 事务消息

在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上,解决生产端的消息发送与本地事务执行的原子性问题。

事务消息发送及提交:

发送消息(half消息)
服务端存储消息,并响应消息的写入结果
根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)
根据本地事务状态执行Commit或者RollbackCommit操作发布消息,消息对消费者可见)

正常发送的流程图如下:

补偿流程:

对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
Producer收到回查消息,返回消息对应的本地事务的状态,为Commit或者Rollback
事务消息方案与本地消息表机制非常类似,区别主要在于原先相关的本地表操作替换成了一个反查接口

事务消息特点如下:

适用于可异步执行的业务,且后续操作无需回滚的业务

2.6 最大努力通知

发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:

有一定的消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。
消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。
前面介绍的的本地消息表和事务消息都属于可靠消息,与这里介绍的最大努力通知有什么不同?

可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。

最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。

解决方案上,最大努力通知需要:

最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口

2.7 AT事务模式

这是阿里开源项目seata中的一种事务模式,在蚂蚁金服也被称为FMT。优点是该事务模式使用方式,类似XA模式,业务无需编写各类补偿操作,回滚由框架自动完成,缺点也类似XA,存在较长时间的锁,不满足高并发的场景。从性能的角度看,AT模式会比XA更高一些,但也带来了脏回滚这样的新问题。

3、异常处理

在分布式事务的各个环节都有可能出现网络以及业务故障等问题,这些问题需要分布式事务的业务方做到防空回滚,幂等,防悬挂三个特性。

3.1 异常情况

下面以TCC事务说明这些异常情况:

空回滚:

  在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。

  出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。

幂等:

  由于任何一个请求都可能出现网络异常,出现重复请求,所以所有的分布式事务分支,都需要保证幂等性

悬挂:

  悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。

  出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵,RPC 超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,Try 的 RPC 请求才到达参与者真正执行。

下面看一个网络异常的时序图,更好的理解上述几种问题

面对上述复杂的网络异常情况,目前看到各家建议的方案都是业务方通过唯一键,去查询相关联的操作是否已完成,如果已完成则直接返回成功。相关的判断逻辑较复杂,易出错,业务负担重。

3.2 子事务屏障

在项目https://github.com/yedf/dtm中,出现了一种子事务屏障技术,使用该技术,能够达到这个效果,看示意图:

所有这些请求,到了子事务屏障后:不正常的请求,会被过滤;正常请求,通过屏障。开发者使用子事务屏障之后,前面所说的各种异常全部被妥善处理,业务开发人员只需要关注实际的业务逻辑,负担大大降低。

子事务屏障提供了方法ThroughBarrierCall,方法的原型为:

func ThroughBarrierCall(db *sql.DB, transInfo *TransInfo, busiCall BusiFunc)

业务开发人员,在busiCall里面编写自己的相关逻辑,调用该函数。ThroughBarrierCall保证,在空回滚、悬挂等场景下,busiCall不会被调用;在业务被重复调用时,有幂等控制,保证只被提交一次。

子事务屏障会管理TCC、SAGA、事务消息等,也可以扩展到其他领域

3.3 子事务屏障原理

子事务屏障技术的原理是,在本地数据库,建立分支事务状态表sub_trans_barrier,唯一键为全局事务id-子事务id-子事务分支名称(try|confirm|cancel

在此机制下,解决了网络异常相关的问题

对于SAGA、事务消息等,也是类似的机制。

3.4 子事务屏障小结

子事务屏障技术,为https://github.com/yedf/dtm首创,它的意义在于设计简单易实现的算法,提供了简单易用的接口,在首创,它的意义在于设计简单易实现的算法,提供了简单易用的接口,在这两项的帮助下,开发人员彻底的从网络异常的处理中解放出来。

该技术目前需要搭配yedf/dtm事务管理器,目前SDK已经提供给Go、Python语言的开发者。其他语言的sdk正在规划中。对于其他的分布式事务框架,只要提供了合适的分布式事务信息,能够按照上述原理,快速实现该技术。

4、分布式事务实践

我们以前面介绍的SAGA事务为例,以DTM作为事务框架,来完成一个具体的分布式事务。本例子采用Go语言,如果您对此不感兴趣,可以直接跳到文章最后的小结。

4.1 一个SAGA事务

我们先编写核心业务代码,调整用户的账户余额

func qsAdjustBalance(uid int, amount int) (interface{}, error) {
    _, err := dtmcli.SdbExec(sdbGet(), "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
    return dtmcli.ResultSuccess, err
}


下面我们来编写具体的正向操作/补偿操作的处理函数

    app.POST(qsBusiAPI+"/TransIn", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(2, 30)
    }))
    app.POST(qsBusiAPI+"/TransInCompensate", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(2, -30)
    }))
    app.POST(qsBusiAPI+"/TransOut", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(1, -30)
    }))
    app.POST(qsBusiAPI+"/TransOutCompensate", common.WrapHandler(func(c *gin.Context) (interface{}, error) {
        return qsAdjustBalance(1, 30)
    }))

到此各个子事务的处理函数已经OK了,然后是开启SAGA事务,进行分支调用

    req := &gin.H{"amount": 30} // 微服务的载荷
    // DtmServer为DTM服务的地址
    saga := dtmcli.NewSaga(DtmServer, dtmcli.MustGenGid(DtmServer)).
        // 添加一个TransOut的子事务,正向操作为url: qsBusi+"/TransOut", 逆向操作为url: qsBusi+"/TransOutCompensate"
        Add(qsBusi+"/TransOut", qsBusi+"/TransOutCompensate", req).
        // 添加一个TransIn的子事务,正向操作为url: qsBusi+"/TransOut", 逆向操作为url: qsBusi+"/TransInCompensate"
        Add(qsBusi+"/TransIn", qsBusi+"/TransInCompensate", req)
    // 提交saga事务,dtm会完成所有的子事务/回滚所有的子事务
    err := saga.Submit()

至此,一个完整的SAGA分布式事务编写完成。

如果您想要完整运行一个成功的示例,那么按照yedf/dtm项目的说明搭建好环境之后,通过下面命令运行saga的例子即可:

go run app/main.go quick_start

4.2 处理网络异常

假设提交给dtm的事务中,调用转入操作时,出现短暂的故障怎么办?按照SAGA事务的协议,dtm会重试未完成的操作,这时我们要如何处理?故障有可能是转入操作完成后出网络故障,也有可能是转入操作完成中出现机器宕机。如何处理才能够保障账户余额的调整是正确无问题的?

我们使用了子事务屏障功能,保证多次重试,只会有一次成功提交。

我们把处理函数调整为:

func sagaBarrierAdjustBalance(sdb *sql.Tx, uid int, amount int) (interface{}, error) {
    _, err := dtmcli.StxExec(sdb, "update dtm_busi.user_account set balance = balance + ? where user_id = ?", amount, uid)
    return dtmcli.ResultSuccess, err

}

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    return dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, reqFrom(c).Amount)
    })
}

func sagaBarrierTransInCompensate(c *gin.Context) (interface{}, error) {
    return dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, -reqFrom(c).Amount)
    })
}

这里的dtmcli.TroughBarrierCall调用会使用子事务屏障技术,保证第三个参数里的回调函数仅被处理一次。

您可以尝试多次调用这个TransIn服务,仅有一次余额调整。您可以运行以下命令,运行新的处理方式:

go run app/main.go saga_barrier

4.3 处理回滚

假如银行将金额准备转入用户2时,发现用户2的账户异常,返回失败,会怎么样?我们调整处理函数,让转入操作返回失败

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    return dtmcli.ResultFailure, nil
}

我们给出事务失败交互的时序图

这里有一点,TransIn的正向操作什么都没有做,就返回了失败,此时调用TransIn的补偿操作,会不会导致反向调整出错了呢?

不用担心,前面的子事务屏障技术,能够保证TransIn的错误如果发生在提交之前,则补偿为空操作;TransIn的错误如果发生在提交之后,则补偿操作会将数据提交一次;如果TransIn还在进行中,则补偿操作会等待TransIn最终提交/回滚,然后再提交补偿/空回滚。

您可以将返回错误的TransIn改成:

func sagaBarrierTransIn(c *gin.Context) (interface{}, error) {
    dtmcli.ThroughBarrierCall(sdbGet(), MustGetTrans(c), func(sdb *sql.Tx) (interface{}, error) {
        return sagaBarrierAdjustBalance(sdb, 1, 30)
    })
    return dtmcli.ResultFailure, nil
}

最后的结果余额依旧没有问题

5、总结

本文介绍了分布式事务的一些基础理论,并对常用的分布式事务方案进行了讲解;在文章的后半部分还给出了事务异常的原因、分类以及优雅的解决方案;最后以一个可运行的分布式事务例子,将前面介绍的内容以简短的程序进行演示。

到此这篇关于关于MySQL与Golan分布式事务经典的七种解决方案的文章就介绍到这了,更多相关分布式事务经典的七种解决方案内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文