分布式事务

分布式事务


事务介绍

事务(Transaction)是访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。

在关系数据库中,一个事务由一组SQL语句组成。事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。

  • 原子性(atomicity [ˌætəˈmɪsəti] ):一个事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做。

  • 一致性(consistency [kənˈsɪstənsi] ):事务必须是使数据库从一个一致性状态变到另一个一致性状态,事务的中间状态不能被观察到的。

  • 隔离性(isolation[ˌaɪsəˈleɪʃn] ):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

    ​ 隔离性又分为四个级别:读未提交(read uncommitted)、读已提交 (read committed,解决脏读)、可重复读(repeatable read,解决虚读)、串行化(serializable,解决幻读)。

  • 持久性(durability [ˌdjʊərəˈbɪlɪti]):持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该 是永久性的。接下来的其他操作或故障不应该对其有任何影响。

任何事务机制在实现时,都应该考虑事务的ACID特性,包括:本地事务、分布式事务,及时不能都很好的满足,也要考虑支持到什么程度。


本地事务

大多数场景下,我们的应用都只需要操作单一的数据库,这种情况下的事务称之为本地事务(Local Transaction)。本地事务的ACID特性是数据库直接提供支持。本地事务应用架构如下所示:

在这里插入图片描述

分布式事务

分布式事务就是要在分布式系统中的实现事务。在分布式系统中,在保证可用性和不严重牺 牲性能的前提下,光是要实现数据的一致性就已经非常困难了,所以出现了很多“残血版”的一致性,比如顺序一致性、最终一致性等等。显然实现严格的分布式事务是更加不可能完成的任务。所以,目前大家所说的分布式事务, 更多情况下,是在分布式系统中事务的不完整实现。在不同的应用场景中,有不同的实现, 目的都是通过一些妥协来解决实际问题。

实现分布式事务有以下 3 种基本方法:

  • 基于 XA 协议的二阶段提交协议方法;
  • 三阶段提交协议方法;
  • 基于消息的最终一致性方法。

典型的分布式事务场景:

跨库事务

跨库事务指的是,一个应用某个功能需要操作多个库,不同的库中存储不同的业务数据。笔者见过一个相对比较复杂的业务,一个业务中同时操作了9个库。下图演示了一个服务同时操作2个库的情况:
在这里插入图片描述

分库分表

通常一个库数据量比较大或者预期未来的数据量比较大,都会进行水平拆分,也就是分库分表。如下图,将数据库B拆 分成了2个库:

在这里插入图片描述

对于分库分表的情况,一般开发人员都会使用一些数据库中间件来降低sql操作的复杂性。如,对于sql:insert into user(id,name) values (1,“张三”),(2,“李四”)。这条sql是操作单库的语法,单库情况下,可以保证事务的一致性。 但是由于现在进行了分库分表,开发人员希望将1号记录插入分库1,2号记录插入分库2。所以数据库中间件要将其改 写为2条sql,分别插入两个不同的分库,此时要保证两个库要不都成功,要不都失败,因此基本上所有的数据库中间件都面临着分布式事务的问题。

服务化

微服务架构是目前一个比较一个比较火的概念。例如上面笔者提到的一个案例,某个应用同时操作了9个库,这样的应用业务逻辑必然非常复杂,对于开发人员是极大的挑战,应该拆分成不同的独立服务,以简化业务逻辑。拆分后,独立 服务之间通过RPC框架来进行远程调用,实现彼此的通信。下图演示了一个3个服务之间彼此调用的架构:

在这里插入图片描述

Service A完成某个功能需要直接操作数据库,同时需要调用Service B和Service C,而Service B又同时操作了2个数 据库,Service C也操作了一个库。需要保证这些跨服务的对多个数据库的操作要不都成功,要不都失败,实际上这可能 是最典型的分布式事务场景。

总结

上述讨论的分布式事务场景中,无一例外的都直接或者间接的操作了多个数据库。如何保证事务的ACID特性, 对于分布式事务实现方案而言,是非常大的挑战。同时,分布式事务实现方案还必须要考虑性能的问题,如果为了严格保证ACID特性,导致性能严重下降,那么对于一些要求快速响应的业务,是无法接受的。


理论篇

XA规范

XA是X/Open,即现在的open group,它是一个独立的组织,主要负责制定各种行业技术标准所指定的规范。 就分布式事务处理 (Distributed Transaction Processing,简称DTP)而言,X/Open提供了DTP模型与XA规范。

DTP模型

构成DTP模型的5个基本元素:

  • 应用程序(Application Program ,简称AP):用于定义事务边界(即定义事务的开始和结束),并且在事务边界内对资源进行操作。
  • 资源管理器(Resource Manager,简称RM):如数据库、文件系统等,并提供访问资源的方式。
  • 事务管理器(Transaction Manager ,简称TM):负责分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚等。
  • 通信资源管理器(Communication Resource Manager,简称CRM):控制一个TM域(TM domain)内或者跨TM域 的分布式应用之间的通信。
  • 通信协议(Communication Protocol,简称CP):提供CRM提供的分布式应用节点之间的底层通信服务。
隔离级别的实现

对于隔离级别的实现,如何避免读写冲突的解决方案,其中很重要的概念就是 MVCC 和快照。MVCC 是单体数据库普遍使用的一种技术,通过记录数据项历史版本的方式,提升系统应对多事务访问的并发处理能力。

在 MVCC 出现前读写操作是相互阻塞的,并行能力受到很大影响。而使用 MVCC 可以实现读写无阻塞,并能够达到 RC(读已提交)隔离级别。基于 MVCC 还可以构建快照,使用快照则能够更容易地实现 RR(可重复读)和 SI(快照隔离)两个隔离级别。

XA规范的模型

XA规范的模型由AP、RMs和TM组成,不需要其他元素。AP、RM和TM之间,彼此都需要进行交互,如下图

在这里插入图片描述

这张图中(1)表示AP-RM的交互接口,(2)表示AP-TM的交互接口,(3)表示RM-TM的交互接口。

XA规范的最主要的作用是,就是定义了RM-TM的交互接口,XA规范除了定义的RM-TM交互的接口(XA Interface)之外,还对两阶段提交协议进行了优化。

两阶段协议(2PC)

两阶段提交协议(Two Phase Commit)不是在XA规范中提出,但是XA规范对其进行了优化。而从字面意思来理解, Two Phase Commit,就是将提交(commit)过程划分为2个阶段(Phase):

  • 阶段1: TM通知各个RM准备提交它们的事务分支。如果RM判断自己进行的工作可以被提交,那就就对工作内容进行持久化,再给TM肯定答复;要是发生了其他情况,那给TM的都是否定答复。在发送了否定答复并回滚了已经的工作后,RM 就可以丢弃这个事务分支信息。 以mysql数据库为例,在第一阶段,事务管理器向所有涉及到的数据库服务器发出prepare"准备提交"请求,数据库收到请求后执行数据修改和日志记录等处理,处理完成后只是把事务的状态改成"可以提交",然后把结果返回给事务管理器。
  • 阶段2 :TM根据阶段1各个RM prepare的结果,决定是提交还是回滚事务。如果所有的RM都prepare成功,那么TM通知所有 的RM进行提交;如果有RM prepare失败的话,则TM通知所有RM回滚自己的事务分支。

在这里插入图片描述

可以看出,2PC 是一种强一致的设计,它可以保证原子性和隔离性。只要 2PC 事务完成,订单库和促 销库中的数据一定是一致的状态,也就是我们总说的,要么都成功,要么都失败。所以 2PC 比较适合那些对数据一致性要求比较高的场景,比如我们这个订单优惠券的场 景,如果一致性保证不好,有可能会被黑产利用,一张优惠券反复使用,那样我们的损失就大了。

TCC VS XA

XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。

TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。

使用 TCC 协议时,由应用系统负责协议的实现,数据库没有额外工作。所以,TCC 更加灵活,但对业务的侵入性更高。反之,使用 2PC 协议时,主要靠数据库来实现,应用层工作很少,所以业务侵入少,但是存在同步阻塞、单点故障和数据不一致这三个问题。

二者对比如下:

在这里插入图片描述

改进版

针对 2PC 协议的这些问题,还有两种改进型协议。

  • 一种是 Percolator 模型,Percolator 将 2PC 第二阶段 工作简化到极致,减少了与参与者的通讯,完美解决了一致性问题,同时通过日志和异步线程弱化了单点故障问题,在 TiDB 和 CockroachDB 中有具体落地。

  • 另一种是 GoldenDB 中实现的“一阶段提交”协议,改良了 2PC 第一阶段的资源协调过程,将协调者与多个参与者的交互转换为协调者与全局事务管理器的交互,同样达到了减少通讯的效果,弱化了一致性和单点故障的问题。

    它们都较好地解决了单点故障和数据不一致的问题。

两阶段提交协议(2PC)存在的问题
1、同步阻塞问题

两阶段提交方案下全局事务的ACID特性,是依赖于RM的。一个全局事务内部包含了多个独立的事务分支,这一组事务分支要不都成功,要不都失败。各个事务分支的ACID特性共同构成了全局事务的ACID特性。也就是将单个事务分支的 支持的ACID特性提升一个层次到分布式事务的范畴。 即使在本地事务中,如果对操作读很敏感,我们也需要将事务隔离级别设置为SERIALIZABLE。而对于分布式事务来说,更是如此,可重复读隔离级别不足以保证分布式事务一致性。如果 我们使用mysql来支持XA分布式事务的话,那么最好将事务隔离级别设置为SERIALIZABLE,然而SERIALIZABLE(串行 化)是四个事务隔离级别中最高的一个级别,也是执行效率最低的一个级别。

2、单点故障

由于协调者的重要性,一旦协调者TM发生故障,参与者RM会一直阻塞下去。尤其在第二阶段,协调者发生故障,那 么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个 协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

3、数据不一致

在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求 过程中协调者发生了故障,这会导致只有一部分参与者接受到了commit请求,而在这部分参与者接到commit请求之后 就会执行commit操作,但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数 据不一致性的现象。

三阶段提交协议(Three-phase commit)

三阶段提交(3PC),是二阶段提交(2PC)的改进版本。 与两阶段提交不同的是,三阶段提交有两个改动点:

  • 引入超时机制。同时在协调者和参与者中都引入超时机制。
  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。也就是说, 除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、 DoCommit三个阶段。

2PC与3PC的区别

相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会 默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

了解了2PC和3PC之后,我们可以发现,无论是二阶段提交还是三阶段提交都无法彻底解决分布式的一致性问题。


实现篇——不同数据库对分布式事务的支持

Mysql

Mysql单机事务

mysql有三种log:binlog、redolog、undolog。

binlog、redolog
  • binlog记录了数据库表结构和表数据变更,存储着每条变更的SQL语句(不止SQL,还有XID「事务Id」等等),主要有两个作用:复制和恢复数据
  • redo log的存在为了防止当我们修改的时候,写完内存了,但数据还没真正写到磁盘的时候,此时我们的数据库挂了,我们可以根据redo log来对数据进行恢复。redo log记载的是物理修改的内容(xxxx页修改了xxx)。redo log的作用是为持久化,是MySQL的InnoDB引擎特有的。
binlog、redolog事务记录的过程

redo log事务开始的时候,就开始记录每次的变更信息,而binlog是在事务提交的时候才记录。

于是新有的问题又出现了:我写其中的某一个log,失败了,那会怎么办?现在我们的前提是先写redo log,再写binlog,我们来看看:

  • 如果写redo log失败了,那我们就认为这次事务有问题,回滚,不再写binlog
  • 如果写redo log成功了,写binlog,写binlog写一半了,但失败了怎么办?我们还是会对这次的事务回滚,将无效的binlog给删除(因为binlog会影响从库的数据,所以需要做删除操作)
  • 如果写redo logbinlog都成功了,那这次算是事务才会真正成功。

简单来说:MySQL需要保证redo logbinlog数据是一致的,如果不一致,那就乱套了。

  • 如果redo log写失败了,而binlog写成功了。那假设内存的数据还没来得及落磁盘,机器就挂掉了。那主从服务器的数据就不一致了。(从服务器通过binlog得到最新的数据,而主服务器由于redo log没有记载,没法恢复数据)
  • 如果redo log写成功了,而binlog写失败了。那从服务器就拿不到最新的数据了。

MySQL通过两阶段提交来保证redo logbinlog的数据是一致的。

过程:

  • 阶段1:InnoDBredo log 写盘,InnoDB 事务进入 prepare 状态
  • 阶段2:binlog 写盘,InooDB 事务进入 commit 状态
  • 每个事务binlog的末尾,会记录一个 XID event,标志着事务是否提交成功,也就是说,恢复过程中,binlog 最后一个 XID event 之后的内容都应该被 purge。
undo log

undo log主要有两个作用:回滚和多版本控制(MVCC)。在数据修改的时候,不仅记录了redo log,还记录undo log,如果因为某些原因导致事务失败或回滚了,可以用undo log进行回滚。

undo log主要存储的也是逻辑日志,比如我们要insert一条数据了,那undo log会记录的一条对应的delete日志。我们要update一条记录时,它会记录一条对应相反的update记录。

undo log存储着修改之前的数据,相当于一个前版本,MVCC实现的是读写不阻塞,读的时候只要返回前一个版本的数据就行了。

MVCC(Multiversion Concurrency Control)

从名字上理解,它是一个基于多版本技术实现的一种并发控制机制。它乐观地认为数据不会发生冲突,但是当事务提交时,具备检测数据是否冲突的能力。Mysql通过 undo log + read view视图来实现RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别。不过它们都是InnoDB实现的。

视图

视图,这是事务隔离实现的根本,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。

  • 在可重复读隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了,所以一个事务的查询结果每次都是一样的。
  • 读提交隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的,在这个隔离级别下,事务在每次查询开始时都会生成一个独立的ReadView。
版本链

可重复读隔离级别下,事务在启动的时候就“拍了个快照”,注意,这个快照是基于整库的。Innodb用版本链来实现这个”快照“:

InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id,它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。每行数据也都是有多个版本的,每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id

这是一个隐藏列,还有另外一个roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

undo log的回滚机制也是依靠这个版本链,每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表。

InnoDB存储引擎保存的MVCC的数据

InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的。

对于使用 InnoDB 存储引擎的数据库表,它的聚族索引记录中都包含下面两个隐藏列:

  • 事务ID(DB_TRX_ID):trx_id,当一个事务对某条聚族索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里
  • 回滚指针(DB_ROLL_PT):roll_pointer,每次对某条聚族索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,于是就可以通过它找到修改前的记录。

每开始一个新的事务,都会自动递增产 生一个新的事务id。事务开始时刻的会把事务id放到当前事务影响的行事务id中,当查询时需要用当前事务id和每行记录的事务id进行比较。

下面看一下在REPEATABLE READ隔离级别下,MVCC具体是如何操作的。

SELECT

InnoDB 会根据以下两个条件检查每行记录:

  1. InnoDB只查找版本早于当前事务版本的数据行(也就是,行的事务编号小于或等于当前事务的事务编号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
  2. 删除的行要事务ID判断,读取到事务开始之前状态的版本,只有符合上述两个条件的记录,才能返回作为查询结果。

INSERT

InnoDB为新插入的每一行保存当前事务编号作为行版本号。

DELETE

InnoDB为删除的每一行保存当前事务编号作为行删除标识。

UPDATE

InnoDB为插入一行新记录,保存当前事务编号作为行版本号,同时保存当前事务编号到原来的行作为行删除标识。

保存这两个额外事务编号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。

分布式XA事务

MYSQL5.0以后的版本支持XA事务。上面讲述,XA事务需要有一个 事务协调器来保证所有事务的参与者都完成了准备工作,MYSQL在事务过程中,扮演一个参与者的角色。事实上,MYSQL有两种XA事务:一种是参与外部的分布式事务;另一种是可以通过XA事务来协调存储引擎与二进制日志。

内部XA事务

MYSQL本身的插件式架构导致内部需要XA事务,MYSQL中各个存储引擎都是完全独立的,彼此不知道对方的存在,所以一个跨存储引擎的事务就需要一个外部的协调者。

但是XA事务会对MYSQL带来巨大的性能下降,它破坏了内部的批量提交。2013 年的 MySQL 技术大会上(Percona Live MySQL C&E 2013),Randy Wigginton 等人在一场名为“Distributed Transactions in MySQL”的 演讲中公布了一组 XA 事务与单机事务的对比数据:

在这里插入图片描述

其中,横坐标是并发线程数量,纵坐标是事务延迟,以毫秒为单位;蓝色的折线表示单机 事务,红色的折线式表示跨两个节点的 XA 事务。我们可以清晰地看到,无论并发数量如 何,XA 事务的延迟时间总是在单机事务的 10 倍以上。所以在技术大会上,他们给出的建议是最好不要使用分布式事务:)

外部XA事务

MYSQL能作为参与者完成外部的分布式事务。但是因为通信延迟和参与者本身可能失败,外部XA事务比内部消耗会更大,可能会因为不可预测的网络性能导致事务失败,而且有太多的不可控因素。

ShardingSphere

ShardingSphere定位为关系型数据库中间件,提供数据分片、分布式事务、数据库治理、多模式链接、友好的管控界面。

它提供了三种分布式事务:标准化事务接口、XA强一致事务、柔性事务。

分布式事务解决方案Seata

Seata 介绍

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。AT模式是阿里首推的模式,阿里云上有商用版本的GTS(Global Transaction Service 全局事务服务)。

Seata 架构

在 Seata 的架构中,一共有三个角色:

  • TC (Transaction Coordinator) - 事务协调者 维护全局和分支事务的状态,驱动全局事务提交或回滚。
  • TM (Transaction Manager) - 事务管理器 定义全局事务的范围:开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - 资源管理器 管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

其中,TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端。

Seata 设计思路

在 Seata 中,一个分布式事务的生命周期如下:

在这里插入图片描述

  • TM 请求 TC 开启一个全局事务。TC 会生成一个 XID 作为该全局事务的编号。XID,会在微服务的调用链路中传播,保证将多个微服务 的子事务关联在一起。
  • RM 请求 TC 将本地事务注册为全局事务的分支事务,通过全局事务的 XID 进行关联。
  • TM 请求 TC 告诉 XID 对应的全局事务是进行提交还是回滚。
  • TC 驱动 RM 们将 XID 对应的自己的本地事务进行提交还是回滚。
Seata亮点

相比与其它分布式事务框架,Seata架构的亮点主要有几个:

  1. 应用层基于SQL解析实现了自动补偿,从而最大程度的降低业务侵入性;
  2. 将分布式事务中TC(事务协调者)独立部署,负责事务的注册、回滚;
  3. 通过全局锁实现了写隔离与读隔离。
Seata缺点
性能损耗

一条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的引入全局锁会额外增加死锁的风险,但如果出现死锁,会不断进行重试,最后靠等待全局锁超时,这种方式并不优雅,也延 长了对数据库锁的占有时间。

总结

​ 不同的数据库都或多或少对分布式事务提供了一定的支持,尽管有很多分布式事务框架也可以实现分布式事务,但是在分布式事务中,高延迟一直是分布式事务的痛点。但是总的来说,分布式事务开销还是太大了,在不要求强一致性,允许短时间内的数据不一致情况下,最好还是绕开分布式事务,选用基于消息的最终一致性分布式事务方案。


最终一致性

事务消息

事务消息适用的场景主要是那些需要异步更新数据,并且对数据实时性要求不太高的场景。事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。我们通常采用本地消息表+消息队列提供的事务相关功能来保证最终一致性。

场景描述

在购物流程中,用户在购物车界面选好商品后,点击“去结算”按钮进入订单页面创建 一个新订单。这个过程我们的系统其实做了两件事儿。

  • 第一,订单系统需要创建一个新订单,订单关联的商品就是购物车中选择的那些商品。
  • 第二,创建订单成功后,购物车系统需要把订单中的这些商品从购物车里删掉。

这也是一个分布式事务问题,创建订单和清空购物车这两个数据更新操作需要保证,要么都成功,要么都失败。但是,清空购物车这个操作,它对一致性要求就没有扣减优惠券那么高,订单创建成功后,晚几秒钟再清空购物车,完全是可以接受的。只要保证经过一个小的 延迟时间后,最终订单数据和购物车数据保持一致就可以了。 参与分布式事务的进程更少,故障点也就更少,稳定性更好; 减少了一些远程调用,性能也更好一些。

本地消息表+消息队列实现方案

在这里插入图片描述

​ 订单服务在收到下单请求后,正常使用订单库的事务去更新订单的数据,并且,在执行这个数据库事务过程中,在本地记录一条消息。这个消息就是一个日志,内容就是“清空购物车”这个操作。因为这个日志是记录在本地的,这里面没有分布式的问题,那这就是一个普通的单机事务,那我们就可以让订单库的事务,来保证记录本地消息和订单库的一致性。完成这一步之后,就可以给客户端返回成功响应了,然后由异步扫描本地消息表,把消息发送给消息队列,购物车系统订阅对应的topic处理即可。

​ 可以看出,本地消息表+消息队列实现最终一致性的安全性分为三个部分:

上游系统

​ 发送记录一致性:由本地事务保证下单+记录本地消息表这两个操作的一致性;

​ 一定发送:这一步指的是本地消息表的数据一定会被发送给消息队列,这一步可以通过异步定时扫描实现,通过状态记录+失败次数+人工干预来保证。

​ 状态记录:异步从本地消息表读取出需要发送的消息,发送给消息队列,接受到消息队列的发送成功回调去更改本地消息表的状态,改为已发送;

​ 失败次数:如果在发送给消息队列的时候出了差错,发送失败,那么会统计失败次数,如果超过一定的次数就发出邮件或者短信告警,进行人工干预,保证本地消息表的消息一定能发送给消息队列;

消息存储

由消息队列提供的高可靠性保证数据一定不会丢失;

  • Kafka复制相关参数acks+它的副本机制保证消息至少会存在于一个Kafka的Topic上,并且通过ISR副本机制+配置参数unclean.leader.election.enable可以保证就算leader宕机了,新的leader消息也会存在;
  • RocketMQ引入Dledger实现复制来保证消息不会丢失(还有传统的主从模式:Broker 的主从关系是通过配置固定的,不支持动态切换。如果主节点 宕机,生产者就不能再生产消息了,消费者可以自动切换到从节点继续进行消费,也就是牺牲了可用性来保证数据一致性):Dledger 在写入消息的时候,要求至少消息复制到半数以上的节点之后,才给客户端返回写入成功,并且它是支持通过选举来动态切换主节点的。
下游系统

​ 消息一定被消费:也是由消息队列自身提供的功能保证的

​ Kafka是通过ack机制,当你消费了这条消息需要给Kafka回传消费成功的offset(默认Kafka会自动提交位移,不过最好关闭自动提交,在拿到消息处理完只后再手动提交位移,防止在处理消息的时候失败但是kafka收到自动提交的位移就会认为你已经消费成功了,这条消息就丢失了);

​ RocketMQ提供了死信队列+ack机制,来确保消息一定会被消费,消费失败就扔到死信队列,然后可以对死信队列做监控来进行人工干预处理。

​ 消息比对:可以异步对本地消息表与消费方的消费记录通过一个唯一ID来进行比对,发现哪一条漏掉了,就重新补发

消息队列提供的事务相关功能

(这里指的是生产者,也就是消息发送方)

kafka 和 RocketMQ 都提供了事务相关功能,采用的其实是2PC二阶段提交,不过它们具体的细节实现不一样。

在上面说的购物车与订单的栗子中,如果在第4步提交事务消息时失败了如何处理?

RocketMQ

在 RocketMQ 中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果 Producer 也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。

为了支持这个事务反查机制,我们的业务代码需要实现一个事务反查状态的接口,告诉RocketMQ本地事务所成功还是失败。

这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ 依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。

使用 RocketMQ 事务消息功能实现分布式事务的流程如下图:

在这里插入图片描述

Kafka

而Kafka的处理就非常的简单粗暴了:直接给你抛个异常,您自己看着办吧嗷,小爷我不伺候了。

三种实现方式对比

在这里插入图片描述

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值