对于 MySQL 分布式事务的看法

一致性理论

当我们的单个数据库的性能产生瓶颈的时候,我们可能会对数据库进行水平分区,这里所说的分区指的是物理分区,分区之后可能不同的库就处于不同的服务器上了,这个时候单个数据库的 ACID 已经不能适应这种情况了,而在这种 ACID 的集群环境下,再想保证集群的 ACID 几乎是很难达到,或者即使能达到那么效率和性能会大幅下降,最为关键的是再很难扩展新的分区了,这个时候如果再追求集群的 ACID 会导致我们的系统变得很差,这时我们就需要引入一个新的理论原则来适应这种集群的情况,就是 CAP 原则或者叫 CAP 定理

在分布式系统中,一致性(Consistency)、可用性(Availability)和分区容忍性(Partition Tolerance)3 个要素最多只能同时满足两个,不可兼得。其中,分区容忍性又是不可或缺的。

一致性模型

数据的一致性模型可以分成以下 3 类:
强一致性:数据更新成功后,任意时刻所有副本中的数据都是一致的,一般采用同步的方式实现。
弱一致性:数据更新成功后,系统不承诺立即可以读到最新写入的值,也不承诺具体多久之后可以读到。
最终一致性:弱一致性的一种形式,数据更新成功后,系统不承诺立即可以返回最新写入的值,但是保证最终会返回上一次更新操作的值。

分布式事务的几个解决方案

基本所有的分布式事务都离不开两阶段提交,都是基于两阶段提交的优化。传统意义的2pc在分布式环境下具有非常严重的局限性,体现在:

  1. 使用全局事务,数据被锁住的时间横跨整个事务,直到事务结束才释放,在高并发和涉及业务模块较多的情况下 对数据库的性能影响较大。
  2. 在技术栈比较复杂的分布式应用中,存储组件可能会不支持 XA 协议。

但是两阶段提交的思想十分常见,InnoDB 存储引擎中的 Redo log 与 Binlog 的本地事务提交过程也使用了二阶段提交的思想,让这两个日志的状态保持逻辑上的一致。

解决方案一:TCC补偿模式

TCC 方案是二阶段提交的 另一种实现方式,它涉及 3 个模块,主业务、从业务和 活动管理器(协作者)

第一阶段:主业务服务分别调用所有从业务服务的 Try 操作,并在活动管理器中记录所有从业务服务。当所有从业务服务 Try 成功或者某个从业务服务 Try 失败时,进入第二阶段。

第二阶段:活动管理器根据第一阶段从业务服务的 Try 结果来执行 Confirm 或 Cancel 操作。如果第一阶段所有从业务服务都 Try 成功,则协作者调用所有从业务服务的 Confirm 操作,否则,调用所有从业务服务的 cancel 操作。

在第二阶段中,Confirm 和 Cancel 同样存在失败情况,所以需要对这两种情况做异常处理以保证数据一致性。
Confirm 失败:则回滚所有 Confirm 操作并执行 Cancel 操作。
Cancel 失败:从业务服务需要提供自动重试 Cancel 机制,以保证 Cancel 成功。

这种方案实现的要素在于,调用链需要被记录,且每个服务提供者都需要提供一组业务逻辑相反的操作,互为补偿,同时回滚操作和确认提交操作要保证幂等

举个例子:

Try阶段

《对于 MySQL 分布式事务的看法》

Confirm阶段

《对于 MySQL 分布式事务的看法》

Cancel阶段

《对于 MySQL 分布式事务的看法》

该模式对代码的嵌入性高,要求每个业务需要写三种步骤(Try-Confirm-Cancel)的操作。并且数据一致性的控制几乎完全由开发者控制,对业务开发难度要求高,耦合度高,严重侵入业务代码。但是该模式也有一定的好处,对有无本地事务控制的资源层都可以支持,使用面广。

解决方案二:消息队列可靠消息提交

以转账服务为例,当支付宝账户扣除1万后,我们只要生成一个凭证(消息)即可,这个凭证(消息)上写着“让余额宝账户增加 1万”,只要这个凭证(消息)能可靠保存,我们最终是可以拿着这个凭证(消息)让余额宝账户增加1万的,即我们能依靠这个凭证(消息)完成最终一致性。最为核心的问题便是如何可靠的保证消息会被消费以及如何解决消息重复投递的问题。

如何可靠保存凭证(消息):
  1)支付宝在扣款事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不真正发送,只有消息发送成功后才会提交事务;
  2)当支付宝扣款事务被提交成功后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才真正发送该消息;
  3)当支付宝扣款事务提交失败回滚后,向实时消息服务取消发送。在得到取消发送指令后,该消息将不会被发送;
  4)对于那些未确认的消息或者取消的消息,需要有一个消息状态确认系统定时去支付宝系统查询这个消息的状态并进行更新。为什么需要这一步骤,举个例子:假设在第2步支付宝扣款事务被成功提交后,系统挂了,此时消息状态并未被更新为“确认发送”,从而导致消息不能被发送。
  优点:消息数据独立存储,降低业务系统与消息系统间的耦合;
  缺点:一次消息发送需要两次请求;业务处理服务需要实现消息状态回查接口。

如何解决消息重复投递的问题:
  还有一个很严重的问题就是消息重复投递,以我们支付宝转账到余额宝为例,如果相同的消息被重复投递两次,那么我们余额宝账户将会增加2万而不是1万了。
  为什么相同的消息会被重复投递?比如余额宝处理完消息msg后,发送了处理成功的消息给实时消息服务,正常情况下实时消息服务应该要删除消息msg,但如果实时消息服务这时候悲剧的挂了,重启后一看消息msg还在,就会继续发送消息msg。
  解决方法很简单,消费消息做到幂等性,在余额宝这边增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。

解决方案三:最大努力通知

核心业务上有 很多附加业务,比如当用户支付完成后,需要通过短信通知用户支付成功。这一类业务的成功或者失败不会影响核心业务,甚至很多大型互联网平台在并高并发的情况下会主动关闭这一类业务以保证核心业务的顺利执行。最大努力通知方案就很适合这类业务场景。

最大努力通知方案涉及三个模块:
上游应用,发消息到 MQ 队列。
下游应用(例如短信服务、邮件服务),接受请求,并返回通知结果。
最大努力通知服务,监听消息队列,将消息按照通知规则调用下游应用的发送通知接口。

具体流程为下:

  1. 上游应用发送 MQ 消息到 MQ 组件内,消息内包含通知规则和通知地址
  2. 最大努力通知服务监听到 MQ 内的消息,解析通知规则并放入延时队列等待触发通知
  3. 最大努力通知服务调用下游的通知地址,如果调用成功,则该消息标记为通知成功,如果失败则在满足通知规则(例如 5 分钟发一次,共发送 10 次)的情况下重新放入延时队列等待下次触发。

最大努力通知服务表示在 不影响主业务的情况下,尽可能地确保数据的一致性。它需要开发人员根据业务来指定通知规则,在满足通知规则的前提下,尽可能的确保数据的一致,以尽到最大努力的目的。

解决方案四:阿里开源分布式中间件 Seata —— 标准分布式模型 TXC

对业务无入侵,业务层上无需关心分布式事务机制的约束,Seata 正是往这个方向发展的。Seata 的设计思路是将一个分布式事务可以理解成一个全局事务,下面挂了若干个分支事务,而一个分支事务是一个满足 ACID 的本地事务,因此我们可以操作分布式事务像操作本地事务一样。

Seata 内部定义了 3个模块来处理全局事务和分支事务的关系和处理过程,这三个组件分别是:
Transaction Coordinator (TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
Transaction Manager (TM):事务的发起者,控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
Resource Manager (RM):负责控制每个服务的分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。

简要说说整个全局事务的执行步骤:
TM 向 TC 申请开启一个全局事务,TC 创建全局事务后返回全局唯一的 XID,XID 会在全局事务的上下文中传播;
RM 向 TC 注册分支事务,该分支事务归属于拥有相同 XID 的全局事务;
TM 向 TC 发起全局提交或回滚;
TC 调度 XID 下的分支事务完成提交或者回滚。

TC,TM,RM三种的概念是否看起来与 XA 是否相像,但是它与 XA 的区别在于,设计了一套不同与 XA 的两阶段协议,在保持对业务不侵入的前提下,保证良好的性能,也避免了对底层数据库协议支持的要求。可以看作是一套轻量级的 XA 机制。具体的架构层次差别如下:

《对于 MySQL 分布式事务的看法》

XA方案的 RM 实际上是在数据库层,RM本质上就是数据库自身(通过提供支持 XA 协议的驱动程序来供应用使用)。

而 Seata 的 RM 是以二方包的形式作为中间件层部署在应用程序这一侧的,不依赖与数据库本身对协议的支持,当然也不需要数据库支持 XA 协议。这点对于微服务化的架构来说是非常重要的,应用层不需要为本地事务和分布式事务两类不同场景来适配两套不同的数据库驱动。

对比两个中间层形式的两阶段提交的不同,先来看一下 XA 的2PC 过程。

《对于 MySQL 分布式事务的看法》

无论 Phase2 的决议是 commit 还是 rollback,事务性资源的锁都要保持到 Phase2 完成才释放。

再看 Seata 的2PC 过程。

《对于 MySQL 分布式事务的看法》

整个流程中,最为重要就是 RM,RM 主要是到 TC 控制器端查询操作的本地数据这一行是否被全局锁定了,如果被锁定了,就重新尝试,如果没被锁定,则加全局锁后开始解析 SQL,把业务数据在更新前后的数据镜像组织成回滚日志,并将 undo log 日志插入 undo_log 表中,保证每条更新数据的业务 sql 都有对应的回滚日志存在。这样做的好处就是,本地事务执行完可以立即释放本地事务锁定的资源,然后向 TC 上报分支状态。当 TM 决议全局提交时,就不需要同步协调处理了,TC 会异步调度各个 RM 分支事务删除对应的 undo log 日志和全局锁,这个步骤非常快速地可以完成;当 TM 决议全局回滚时,RM 收到 TC 发送的回滚请求,RM 通过 XID 找到对应的 undo log 回滚日志,然后执行回滚日志完成回滚操作。

分支事务中数据的 本地锁 由本地事务管理,在分支事务 Phase1 结束时释放。同时,随着本地事务结束,连接也得以释放。
分支事务中数据的 全局锁 在事务协调器侧 TC 管理,在决议 Phase2 全局提交时,全局锁马上可以释放。只有在决议全局回滚的情况下,全局锁才被持有至分支的 Phase2 结束。
这个设计,极大地减少了分支事务对资源(数据和连接)的锁定时间,给整体并发和吞吐的提升提供了基础。

辅以下图可以更快的理解全局锁以及应用层的 RM 是如何实现的

《对于 MySQL 分布式事务的看法》

总而言之,XA 与 Seata 都存在全局锁定的过程,但是 Seata 优化了锁定的机制,Seata 直接本地先提交,减少了分支事务对资源(数据和连接)的锁定时间,释放了连接,数据库并发和吞吐不会因为全局锁定而受到影响,因为 Seata 是在应用层去判断锁定和等待锁,而传统的 XA 则为数据库层的全局锁定。

对分布式事务的看法

在面临数据一致性问题的时候,首先要从业务需求的角度出发,确定我们对于一致性模型的接受程度,再通过具体场景来决定解决方案。从应用角度看,分布式事务的现实场景常常无法规避。在现代技术发展和优化下,结合阿里给出的中间件测试数据,高并发下的分布式事务也并不是没有可能。

    原文作者:Philomon
    原文地址: https://segmentfault.com/a/1190000020266194
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞