分布式事务详解

Posted Mr. Dreamer Z

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了分布式事务详解相关的知识,希望对你有一定的参考价值。

最近面临面试,抽空了解了下分布式事务,这篇主要介绍一下分布式事务相关的知识点以及现目前最流行的分布式事务解决工具seata。OK,进入正题

目录

1.本地事务

1.1 什么是本地事务

1.2 本地事务如何保证ACID

1.2.1 undo和redo日志

2. 分布式事务

2.1 跨数据源的分布式锁事务

2.2 跨服务

2.3 分布式系统带来的数据一致性问题

3. 解决分布式事务的思路

3.1 CAP定理

3.1.1 consistency

3.1.2 Availability

3.1.3  partition tolerance

3.1.4 Consistency 和 Availability 的矛盾

3.1.5常见问题

3.2 BASE理论

4. 分阶段提交

4.1  DTP和XA

4.2 二阶段提交

4.3 TCC

4.3.1 基本原理

4.3.2 实例

4.3.3 优势与缺点

4.3.4 使用场景

4.4 可靠消息服务

4.4.1 基本原理

4.4.2 本地消息表

4.5 AT模式

4.5.1 基本原理

4.5.2 详细架构和流程

4.5.3 优缺点

5. seata

5.1 介绍

5.2 seata产品模块

5.3 seata支持的事务模型

5.4 AT模式

5.4.1 准备数据

5.4.2 代码


1.本地事务

在了解分布式事务之前,我们先来了解一下 本地事务。

什么是事务

事务:是数据库操作的最小工作单元,是作为单个逻辑工作单元执行的一系列操作

1.1 什么是本地事务

本地事务,是指传统单机模式下数据库事务,必须满足ACID原则:

  • 原子性:指的是在整个、
  • 要么全部执行成功,要么全部回滚不执行。
  • 一致性:事务的执行必须保证系统的一致性。比如说:A向B打钱100元。那么A必须-100,B必须+100。
  • 隔离性:不同事务之间操作互不影响。数据库保证隔离性的四个隔离级别:
  1. 读未提交:读取了某个事务修改但为提交的数据。(造成脏读)
  2. 读已提交:解决了读未提交造成的影响,由于多个事务同时处理某个数据,导致两次读到的数据不一致。(造成不可重复读)
  3. 可重复读:mysql的默认隔离级别。解决了不可重复读的问题。(但是会造成幻读)
  4. 可串行化:最高的隔离级别。它会对事务进行强制排序,解决之前所说的一切问题。
  •  持久性:事务一旦提交永久保存

因为在传统项目中,项目部署基本是单点的。即单个服务器和单个数据库。这种情况下,数据库本身的事务机制就能保证ACID的原则,这样的事务就叫本地事务。

简单来说的话,其实只要是在单个服务和单个数据库的架构中,产生的事务即都是本地事务。

 

1.2 本地事务如何保证ACID

隔离性来说,数据库本身就提供了4个隔离级别供其原则。简单来说,你选择不同的隔离级别,它会根据不同类型的锁来进行执行,比如说排它锁和共享锁。

一致性来说,我认为其实它其实有点像“伪命题”。保证强一致性势必保存同一个时刻只有一个事务在操作某条数据或者使用锁的形式来操作。

而真正涉及到使用mysql自己的日志来处理的,就只有原子性和持久性。

1.2.1 undo和redo日志

再次回顾一下,

数据库事务的原子性:在整个事务的所有操作中,要么全部成功,要么失败回滚。

持久性:事务一旦提交永久保存。

Mysql有六种日志:

MySQL 中有六种日志文件,分别是:

重做日志(redo log)、回滚日志(undo log)、二进制日志(binlog)、错误日志(errorlog)、慢查询日志(slow query log)、一般查询日志(general log),中继日志(relay log)。

这里只会讲一讲undo和redo日志,其余有兴趣的同学可以下来自行了解下,比如主从复制用到的bin log 和 relay log。

undo日志

undo日志其实很好理解,你可以把它理解成记录原始数据(也就是数据改变之前的样子)。

在任何数据操作之前,都会将其数据备份到undo log。如果出现了错误或者用户开启了rollback语句,那么系统将会利用undo log中的备份进行恢复。

下面是undo日志简化过程:

假设有A、B两个数据,值分别为1,2。

A. 事务开始.

B. 记录A=1到undo log.

C. 修改A=3.

D. 记录B=2到undo log.

E. 修改B=4.

F. 将undo log写到磁盘。

G. 将数据写到磁盘。

H. 事务提交

  • 如何保证持久性?

       事务提交前,会把修改的数据丢到磁盘。也就说只要事务提交了,那么数据肯定持久化了。

  • 如何保证原子性呢?

       每次对数据库修改,都会把之前的数据备份到undo日志,那么需要回滚的时候,即可使用。

  • 这时候有人会问了,如果系统在G和H之间崩溃了咋办呢?

       此时事务还没提交,需要回滚。但是undo日志里面已经有东西了,所以可以直接根据undo日志来恢复数据。

  • 如果在G之前崩溃了咋办

此时的数据没有持久到磁盘,不需要修改,因为它还在之前的状态。

缺陷:每个事务提交前将数据和undo日志写入磁盘,这样会导致大量的磁盘IO,性能会降低。

这时候,我们就想办法能不能将数据缓存一段时间,减少IO呢?但是这样的话又会丧失事务的持久性。这时候,我们就需要redo日志了。

redo日志

它和undo日志完全不一样,它记录的是新数据的备份。它在事务提交前,只需要将redo log持久化,不需要把数据持久化,这样就减少了磁盘的io的次数。

先看看过程:

假设有A、B两个数据,值分别为1,2

 A. 事务开始.
 B. 记录A=1到undo log buffer.
 C. 修改A=3.
 D. 记录A=3到redo log buffer.
 E. 记录B=2到undo log buffer.
 F. 修改B=4.
 G. 记录B=4到redo log buffer.
 H. 将undo log写入磁盘
 I. 将redo log写入磁盘
 J. 事务提交

  • 如何保证原子性?

    如果在事务提交前故障,通过undo log日志恢复数据。如果undo log都还没写入,那么数据就尚未持久化,无需回滚

  • 如何保证持久化?

    大家会发现,这里并没有出现数据的持久化。因为数据已经写入redo log,而redo log持久化到了硬盘,因此只要到了步骤I以后,事务是可以提交的。

  • 内存中的数据库数据何时持久化到磁盘?

    因为redo log已经持久化,因此数据库数据写入磁盘与否影响不大,不过为了避免出现脏数据(内存中与磁盘不一致),事务提交后也会将内存数据刷入磁盘(也可以按照固设定的频率刷新内存数据到磁盘中)。

  • redo log何时写入磁盘

       redo log会在事务提交之前,或者redo log buffer满了的时候写入磁盘

这里存在两个问题:

问题1:之前是写undo和数据库数据到硬盘,现在是写undo和redo到磁盘,似乎没有减少IO次数

  • 数据库数据写入是随机IO,性能很差

  • redo log在初始化时会开辟一段连续的空间,写入是顺序IO,性能很好

  • 实际上undo log并不是直接写入磁盘,而是先写入到redo log buffer中,当redo log持久化时,undo log就同时持久化到硬盘了。

因此事务提交前,只需要对redo log持久化即可。

另外,redo log并不是写入一次就持久化一次,redo log在内存中也有自己的缓冲池:redo log buffer。每次写redo log都是写入到buffer

 

问题2:redo log 数据是写入内存buffer中,当buffer满或者事务提交时,将buffer数据写入磁盘。

redo log中记录的数据,有可能包含尚未提交事务,如果此时数据库崩溃,那么如何完成数据恢复?

数据恢复有两种策略:

  • 恢复时,只重做已经提交了的事务

  • 恢复时,重做所有事务包括未提交的事务和回滚了的事务。然后通过Undo Log回滚那些未提交的事务

Inodb引擎采用的是第二种方案,因此undo log要在 redo log前持久化

,在提交时一次性持久化到磁盘,减少IO次数。

 

最后总结一下:

  • undo log 记录更新前数据,用于保证事务原子性

  • redo log 记录更新后数据,用于保证事务的持久性

  • redo log有自己的内存buffer,先写入到buffer,事务提交时写入磁盘

  • redo log持久化之后,意味着事务是可提交

 

 

2. 分布式事务

分布式事务,就是指不是在单个服务或单个数据库架构下,产生的事务:

  • 跨数据源的分布式事务
  • 跨服务的分布式事务
  • 综合情况

2.1 跨数据源的分布式锁事务

随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单表拆分成数据库分片,于是就产生了跨数据库事务问题。

2.2 跨服务

在业务发展初期,单机架构服务就基本能满足业务需求了。但是随着业务的快速发展,系统的访问量和业务的复杂程度都在快速增长,单体架构模式逐渐变成了负担,所以解决业务系统的高耦合、可伸缩问题的需求越来越强烈。如下图,按照面向服务(SOA)的架构的设计原则,将单业务系统拆分成多个业务系统,降低系统间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统容量的伸缩。

 

2.3 分布式系统带来的数据一致性问题

在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。在分布式网络环境下,我们无法保障所有的服务、数据库都百分之百能够正常使用,一定会出现部分业务,数据库执行成功,另外一个部分执行失败的场景。

当出现部分业务操作成功、部分业务失败时,业务数据就会出现不一致。

举个简单地例子,电商行业下单付款:

  • 创建订单
  • 扣减商品库存
  • 扣款

如上图所示,肯定会出现我刚刚提到的问题。按照我们代码的逻辑,如果下订单成功之后,但是扣库存失败了,那么此时应该取消订单的创建/ 或者订单和库存都成功了,扣款失败了。需要回退订单或者库存,但是 由于它们不是本地事务,这个完全是相当于“三件事情”,所以没办法保证ACID。  

 

3. 解决分布式事务的思路

刚才举了例子,有的同学可能会问,为啥呢?

为啥分布式系统下,事务的ACID原则难以满足?

记住,ACID只适用于单机模式下的事务控制。

为了解决分布式事务的处理问题,下面就介绍一下CAP定理和BASE理论

3.1 CAP定理

1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。

  • consistency(一致性)
  • availability(可用性)
  • partition tolerance(分区容错性)

他们的第一个字母分别是 C、A、P。

Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。

3.1.1 consistency

Consistency 中文叫做"一致性"。

写操作之后的读操作,必须返回该值。举个例子:某条记录是V0,用户G1发起了一个写操作,改成了V1

接下来,用户的读操作就会得到V1。这就叫一致性。

但是,问题来了,用户可能向G2发起读操作,由于G2的值没有发生变化,因此返回的是V0。G1和G2读操作的结果不一致了,这就不满足一致性了。

为了让G2也能变成V1,就要在G1写操作的时候,让G1向G2发送一条消息,告诉G2也要改成V1。(这有点像JMM的可见性)。

 

这样的话,用户向G2发送读请求,才能也读到V1。

 

3.1.2 Availability

Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须给出回应(对和错不论)。

用户可以选择向G1或者G2发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是V0还是V1,否则就不满足可用性。

 

3.1.3  partition tolerance

分区容错性

一个分布式系统里面,节点组成的网络本来应该是连通的。然而可能因为一些故障,使得有些节点之间不连通了,整个网络就分成了几块区域。数据就散布在了这些不连通的区域中。这就叫分区。

当你一个数据项只在一个节点中保存,那么分区出现后,和这个节点不连通的部分就访问不到这个数据了。这时分区就是无法容忍的。

提高分区容忍性的办法就是一个数据项复制到多个节点上,那么出现分区之后,这一数据项就可能分布到各个区里。容忍性就提高了。

 

3.1.4 Consistency 和 Availability 的矛盾

一致性和可用性为什么不能同时成立呢?

其实就是可能会通信失败。

如果保证G2的一致性,那么G1必须在写操作时,锁定G2的读操作和写操作。只有数据同步后,才能重新放开锁。 锁定期间,G2不能读,没有可用性。

如果要保证G2的可用性,那么势必不能对它进行锁操作,所以一致性不成立。

so,综上所述,G2无法同时满足一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。

3.1.5常见问题

  • 怎么才能同时满足CA?

        除非是单机机构

  • 何时要满足CP?

       对一致性要求高的场景。例zk,数据同步时,服务对外不可用

  •   何时满足AP

        对可用性要求高的场景,比如说eureka,必须保证注册中心随时可用。

 

3.2 BASE理论

BASE是三个单词的缩写:

  • Basically Available(基本可用)

  • Soft state(软状态)

  • Eventually consistent(最终一致性)

而我们解决分布式事务,就是依据上述理论来实现的。

  • BA:假设系统,出现了不可预知的故障,但还是能用
  • S:什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种“硬状态”。
  • 软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
  • E:在一定时间内允许数据不一致

还是以下订单操作为例子:

订单服务、库存服务、用户服务及他们对应的数据库就是分布式应用中的三个部分。

  • CP方式:现在如果要对事物要求强一致性,就必须在订单服务数据库锁定的时候,对库存服务,用户服务同时锁定。等待三个服务业务全部处理完成,才可以释放资源,此时如果有其他请求想要操作被锁定的资源就会被阻塞,此满足CP。
  • AP方式:三个服务对应数据库各自执行,执行本地事务不会相互锁定资源。但是中间状态下,我们去访问数据库,可能会遇到不一致性的情况。不过我们需要做一些后补措施,保证在经过一段时间后,数据最终满足一致性。

由上面的两种思想,延伸出了很多分布式解决方案:

  • XA
  • TCC
  • 可靠消息最终一致性
  • AT

4. 分阶段提交

4.1  DTP和XA

分布式事务的解决手段之一,就是两阶段提交协议(2PC:Two-Phase Commit)

那么到底是两阶段提交呢?

1994年,X/Open组织(即现在的Open Group)定义了分布式事务处理的DTP模型,该模型包括这样几个角色:

  • 应用程序(AP):我们的微服务
  • 事务管理器(TM):全局事务管理者
  • 资源管理区(RM):一般都是数据库
  • 通信资源管理器(CRM):是TM和RM间的通信中间件

在该模型中,一个分布式事务(全局事务)可以被拆分成许多个本地事务,运行在不同的AP和RM上。每个本地事务的ACID很好实现,但是全局事务必须保证其中包含的每一个本地事务都能同时成功,若有一个本地事务失败,则所有其它事务都必须回滚。但问题是,本地事务处理过程中,并不知道其它事务的运行状态。因此,就需要通过CRM来通知各个本地事务,同步事务执行的状态。

因此,各个本地事务的通信必须有统一的标准,否则不同数据库间就无法通信。XA就是 X/Open DTP中通信中间件与TM间联系的接口规范,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须实现这些接口。

 

4.2 二阶段提交

二阶段提交协议就是依据这一思想衍生出来的,将全局事务拆分成两个阶段来执行:

  • 阶段一:准备阶段,各个本地事务完成本地事务的准备工作
  • 阶段二:执行阶段,各个本地事务根据上一阶段执行结果,进行提交或者回滚

这个过程中需要一个协调者(coordinator),还有事务的参与者(voter)

情况1:正常执行

如上图所示,

准备阶段:协调者询问每个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后进行反馈。

提交阶段:协调者收到反馈如果发现每个参与者都可以正常执行事务,然后发出指令让他们进行commit操作。

 

情况2:异常情况

准备阶段:协调者询问每个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后进行反馈。but,只要有一个参与者返回disagree,则说明执行失败。

提交阶段:协调者收到反馈如果发现每个参与者都可以正常执行事务,然后发出指令让他们进行commit操作。如果有disagree,认为执行失败。于是发出abort指令,各个事务回滚事务。

 

缺点

  • 单点故障:如果协调者挂了,那么根本无法执行了。
  • 阻塞问题:在准备阶段,提交阶段,每个事务参与者都会锁定本地资源,并等待其他事务的执行结果。阻塞时间较长、资源锁定时间太久,因此执行的效率比较低。

由于2PC的缺点,后来有提出了3PC。但是只是解决了阻塞问题。因此使用场景很少。

  • 引入超时机制。同时在协调者和参与者中都引入超时机制。
  • 在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

使用场景

对事务有强一致性要求(锁资源),对事务执行效率不敏感,并且不希望有太多代码侵入。

 

4.3 TCC

TCC模式可以解决2PC中的资源锁定和阻塞问题,减少资源锁定时间。

4.3.1 基本原理

它本质是一种补偿机制的思路。事务运行包括是三个方法,

Try:资源的检测和预留;

Confirm:执行的业务操作提交;要求Try成功Confirm一定要能成功;

Cancel:预留资源释放。

执行分为两个阶段:

准备阶段(try):资源的检测和预留;

执行阶段(confirm/cancel ):根据上一步结果,判断下面的执行方法。如果上一步所有事务参与者都成功,那么confirm,否则执行cancel。

粗看似乎与两阶段提交没什么区别,其实差别很大:

  • try、confirm、cancel都是独立的事务,不受其他参与者的影响,不会阻塞等待它人。
  • try、confirm、cancel由程序猿在业务层编写,锁粒度由代码控制。

4.3.2 实例

我们之前的下单业务中的扣减余额为例。假设账户A的余额原来是100,需要余额扣减30元。如图:

  • 一阶段(Try):余额检查,并冻结用户部分金额,此阶段执行完毕,事务已经提交

    • 检查用户余额是否充足,如果充足,冻结部分余额

    • 在账户表中添加冻结金额字段,值为30,余额不变

  • 二阶段

    • 提交(Confirm):真正的扣款,把冻结金额从余额中扣除,冻结金额清空

      • 修改冻结金额为0,修改余额为100-30 = 70元

    • 补偿(Cancel):释放之前冻结的金额,并非回滚

      • 余额不变,修改账户冻结金额为0

4.3.3 优势与缺点

  • 优势

    TCC执行的每一个阶段都会提交本地事务并释放锁,并不需要等待其它事务的执行结果。而如果其它事务执行失败,最后不是回滚,而是执行补偿操作。这样就避免了资源的长期锁定和阻塞等待,执行效率比较高,属于性能比较好的分布式事务方式。

  • 缺点

    • 代码侵入:需要人为编写代码实现try、confirm、cancel,代码侵入较多

    • 开发成本高:一个业务需要拆分成3个步骤,分别编写业务实现,业务编写比较复杂

    • 安全性考虑:cancel动作如果执行失败,资源就无法释放,需要引入重试机制,而重试可能导致重复执行,还要考虑重试时的幂等问题

4.3.4 使用场景

  • 对事务有一定的一致性要求(最终一致)

  • 对性能要求较高

  • 开发人员具备较高的编码能力和幂等处理经验

 

4.4 可靠消息服务

这种实现方式的思路,其实是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。

4.4.1 基本原理

一般分为事务的发起者A和事务的其它参与者B:

  • 事务发起者A执行本地事务

  • 事务发起者A通过MQ将需要执行的事务信息发送给事务参与者B

  • 事务参与者B接收到消息后执行本地事务

这个过程有点像你去学校食堂吃饭:

  • 拿着钱去收银处,点一份红烧牛肉面,付钱

  • 收银处给你发一个小票,还有一个号牌,你别把票弄丢!

  • 你凭小票和号牌一定能领到一份红烧牛肉面,不管需要多久

 

几个注意事项:

  • 事务发起者A必须确保本地事务成功后,消息一定发送成功

  • MQ必须保证消息正确投递和持久化保存

  • 事务参与者B必须确保消息最终一定能消费,如果失败需要多次重试

  • 事务B执行失败,会重试,但不会导致事务A回滚

那么问题来了,我们如何保证消息发送一定成功?如何保证消费者一定能收到消息?

 

4.4.2 本地消息表

我了避免消息发送失败或者丢失,我们可以把消息持久化到数据库中。实现时有简化版本和解耦合版本两种方式。

1.简化版本

事务发起者:

  • 开启本地事务

  • 执行事务相关业务

  • 发送消息到MQ

  • 把消息持久化到数据库,标记为已发送

  • 提交本地事务

事务接收者:

  • 接收消息

  • 开启本地事务

  • 处理事务相关业务

  • 修改数据库消息状态为已消费

  • 提交本地事务

额外的定时任务

  • 定时扫描表中超时未消费消息,重新发送

优点:

  • 与tcc相比,实现方式较为简单,开发成本低。

缺点:

  • 数据一致性完全依赖于消息服务,因此消息服务必须是可靠的。

  • 需要处理被动业务方的幂等问题

  • 被动业务失败不会导致主动业务的回滚,而是重试被动的业务

  • 事务业务与消息发送业务耦合、业务数据与消息表要在一起

2.独立消息服务

为了解决上述问题,我们会引入一个独立的消息服务,来完成对消息的持久化、发送、确认、失败重试等一系列行为,大概的模型如下

事务发起者A的基本执行步骤:

  • 开启本地事务

  • 通知消息服务,准备发送消息(消息服务将消息持久化,标记为准备发送)

  • 执行本地业务,

    • 执行失败则终止,通知消息服务,取消发送(消息服务修改订单状态)

    • 执行成功则继续,通知消息服务,确认发送(消息服务发送消息、修改订单状态)

  • 提交本地事务

消息服务本身提供下面的接口:

  • 准备发送:把消息持久化到数据库,并标记状态为准备发送

  • 取消发送:把数据库消息状态修改为取消

  • 确认发送:把数据库消息状态修改为确认发送。尝试发送消息,成功后修改状态为已发送

  • 确认消费:消费者已经接收并处理消息,把数据库消息状态修改为已消费

  • 定时任务:定时扫描数据库中状态为确认发送的消息,然后询问对应的事务发起者,事务业务执行是否成功,结果:

    • 业务执行成功:尝试发送消息,成功后修改状态为已发送

    • 业务执行失败:把数据库消息状态修改为取消

事务参与者B的基本步骤:

  • 接收消息

  • 开启本地事务

  • 执行业务

  • 通知消息服务,消息已经接收和处理

  • 提交事务

 

优点:

  • 解除了事务业务与消息相关业务的耦合

缺点:

  • 实现起来比较复杂

 

4.5 AT模式

2019年 1 月份,Seata 开源了 AT 模式。AT 模式是一种无侵入的分布式事务解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入、编码复杂等问题。

在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。

4.5.1 基本原理

感觉和TCC的执行很像,都是分两个阶段:

  • 一阶段:执行本地事务,并返回执行结果
  • 二阶段:根据一阶段的结果,判断二阶段的做法:提交或者回滚

但是AT模式不一样之处在于,第二阶段不需要我们编写。全部由seata自己实现了。也就是说:我们写的代码与本地事务代码一样,无需手动处理分布式锁事务了。

so,AT模式是如何实现无代码入侵呢?

一阶段

在一阶段,Seata会拦截“业务sql”,首先解析SQL语义,找到“业务SQL要更新的业务数据”,在业务数据被更新前,将其保存成“before images”(这里面就是需要修改的值修改之前的值),然后执行SQL,更新数据。在业务数据更新之后,再将其保存成“after image”,最后获取全局行锁,提交事务。以上操作全部在一个数据事务内完成,这样保证了原子性。

其实这里的“before image”和“after image”类似于数据库的undo和redo日志,但其实是用数据库模拟的。

二阶段提交

二阶段如果只是提交,因为“业务SQL”在一阶段已经提交至数据库,所以seata框架只需要将一阶段保存的快照数据和行锁删掉,完成数据清理即可。

二阶段回滚

二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。

不过因为有全局锁机制,所以可以降低出现脏写的概率。

AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。

 

4.5.2 详细架构和流程

Seata中的几个基本概念:

  • TC(Transaction Coordinator) - 事务协调者

    维护全局和分支事务的状态,驱动全局事务提交或回滚(TM之间的协调者)。

  • TM(Transaction Manager) - 事务管理器

    定义全局事务的范围:开始全局事务、提交或回滚全局事务。

  • RM(Resource Manager) - 资源管理器

    管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。可以理解为数据库

分布式事务的执行流程

  • TM开启分布式事务(TM向TC注册全局事务记录) ;
  • 按业务场景,编排数据库、服务等事务内资源(RM向TC汇报资源准备状态) ;
  • TM结束分布式事务,事务一阶段结束(TM通知TC提交/回滚分布式事务) ;
  • TC汇总事务信息,决定分布式事务是提交还是回滚;
  • TC通知所有RM提交/回滚资源,事务二阶段结束。
     

一阶段:

  • TM开启全局事务,并向TC声明全局事务,包括全局事务XID信息

  • TM所在服务调用其它微服务

  • 微服务,主要有RM来执行

    • 查询before_image

    • 执行本地事务

    • 查询after_image

    • 生成undo_log并写入数据库

    • 向TC注册分支事务,告知事务执行结果

    • 获取全局锁(阻止其它全局事务并发修改当前数据)

    • 释放本地锁(不影响其它业务对数据的操作)

  • 待所有业务执行完毕,事务发起者(TM)会尝试向TC提交全局事务

二阶段:

  • TC统计分支事务执行情况,根据结果判断下一步行为

    • 分支都成功:通知分支事务,提交事务

    • 有分支执行失败:通知执行成功的分支事务,回滚数据

  • 分支事务的RM

    • 提交事务:直接清空before_imageafter_image信息,释放全局锁

    • 回滚事务:

      • 校验after_image,判断是否有脏写

      • 如果没有脏写,回滚数据到before_image,清除before_imageafter_image

      • 如果有脏写,请求人工介入

4.5.3 优缺点

优点:

  • 与2PC相比:每个分支事务都是独立提交,不互相等待,减少了资源锁定和阻塞时间

  • 与TCC相比:二阶段的执行操作全部自动化生成,无代码侵入,开发成本低

缺点:

  • 与TCC相比,需要动态生成二阶段的反向补偿操作,执行性能略低于TCC

 

5. seata

5.1 介绍

Seata(Simple Extensible Autonomous Transaction Architecture,简单可扩展自治事务框架)是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。Seata 开源半年左右,目前已经有接近一万 star,社区非常活跃。我们热忱欢迎大家参与到 Seata 社区建设中,一同将 Seata 打造成开源分布式事务标杆产品。

Seata:https://github.com/seata/seata

 

5.2 seata产品模块

如下图,seata中有三大模块,分别是TM,RM和TC。其中TM和RM是作为seata的客户端与业务端系统集成在一起的,TC作为seata的服务端独立部署。

 

5.3 seata支持的事务模型

seata会有4种分布式解决方案,分别是AT,TCC,Saga,和XA模式。

5.4 AT模式

seata中比较常见的是AT模式,在本篇文章中我们以AT模式作为讲解。想要了解其他模式的可以通过seata官网的文档去了解。

我们设定一个场景,一个用户购买商品的业务逻辑,整个业务逻辑由3个微服务提供支持:

  • 仓储服务:对给定的商品扣除仓储数量。

  • 订单服务:根据采购需求创建订单。

  • 帐户服务:从用户帐户中扣除余额。

订单服务在下单时,同时调用库存服务和用户服务,此时就会发生跨服务和跨数据源的分布式事务问题。

5.4.1 准备数据

执行资料中提供的seata_demo.sql文件,导入数据。

其中包含4张表。

Order表:

DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order`  (
  `id` bigint(11) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(11) NULL DEFAULT NULL COMMENT '用户id',
  `product_id` bigint(11) NULL DEFAULT NULL COMMENT '产品id',
  `count` int(11) NULL DEFAULT NULL COMMENT '数量',
  `money` decimal(11, 0) NULL DEFAULT NULL COMMENT '金额',
  `status` int(1) NULL DEFAULT NULL COMMENT '订单状态:0:创建中;1:已完结',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4;

商品库存表:

DROP TABLE IF EXISTS `seata_storage`;
CREATE TABLE `t_storage`  (
  `id` bigint(11) NOT NULL AUTO_INCREMENT,
  `product_id` bigint(11) NULL DEFAULT NULL COMMENT '产品id',
  `total` int(11) NULL DEFAULT NULL COMMENT '库存',
  `used` int(11) NULL DEFAULT NULL COMMENT '已用库存',
  `residue` int(11) NULL DEFAULT NULL COMMENT '剩余库存',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4;

用户账户表:

DROP TABLE IF EXISTS `t_account`;
CREATE TABLE `t_account`  (
  `id` bigint(11) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(11) NULL DEFAULT NULL COMMENT '用户id',
  `total` decimal(10, 0) NULL DEFAULT NULL COMMENT '总额度',
  `used` decimal(10, 0) NULL DEFAULT NULL COMMENT '已用额度',
  `residue` decimal(10, 0) NULL DEFAULT NULL COMMENT '剩余可用额度',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4;

还有用来记录Seata中的事务日志表undo_log,其中会包含after_imagebefore_image数据,用于数据回滚:

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

三个库

create database seata_order;
create database seata_storage;
create database seata_account;

依次在对应的库中创建表

5.4.2 代码

seata-order-service

在order服务中,我们主要需要做三个操作。创建订单,扣减库存,扣款,修改订单状态

@Service
@Slf4j
public class OrderServiceImpl implements OrderService 

    @Resource
    private OrderDao orderDao;
    @Resource
    private AccountService accountService;
    @Resource
    private StorageService storageService;

    /**
     * name随意取,只要全局唯一
     * @param order
     */
    @Override
    @GlobalTransactional(name = "fsp-create-order",rollbackFor = Exception.class)
    public void create(Order order) 
        log.info("--------->开始新建订单");
        //1 新建订单
        orderDao.create(order);
        //2 扣减库存
        log.info("------------->订单微服务开始调用库存,做扣减Count");
        storageService.decrease(order.getProductId(), order.getCount());
        log.info("------------->订单微服务开始调用库存,做扣减end");
        //3 扣减账户
        log.info("------------->订单微服务开始调用账户,做扣减Money");
        accountService.decrease(order.getUserId(), order.getMoney());
        log.info("------------->订单微服务开始调用账户,做扣减end");

        //4 修改订单状态
        log.info("------------->修改订单状态开始");
        orderDao.update(order.getUserId(),0);
        log.info("------------->修改订单状态结束");

        log.info("------------->下订单结束了");
    

刚刚我们提到了,由于是分布式项目,我们模拟了不同的数据源。所以我们需要去控制对应的数据源操作,提交/回滚。

源码地址:

通常来说,我们不做任务处理。如果某一环出了问题。

如上图,如果在订单下单和库存扣减都成功的情况下,但是账户扣件失败了。但是由于不是单体架构模式,所以各个服务之间无法感知到失败与否。如果非要通知,那么其实时很麻烦的事情。比如:使用消息队列发送消息,然后手动开启/关闭事务。。等等这一系类的操作

 

此时让我们进入今天的重头戏,引入seata的AT.

环境

这里我们使用nacos作为注册中心。

下载nacos,解压缩。然后进入bin目录下。由于用的windows环境,我们直接启动startup.cmd即可。在此之前我们将其修改成非集群模式的

启动成功之后,我们可以看到

接着下载seata,这里我们使用的是seata1.1.0.

注意了,我们在开发的时候尽量去使用1.0版本以上

下载成功之后,我们进入conf目录

首先我们先修改file.conf文件

由于我们使用的数据库来管理修改mode为“db”

之后修改我们的数据库配置

注意:这里我们给出1.1的sql

DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table`  (
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `transaction_id` bigint(20) NULL DEFAULT NULL,
  `resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `status` tinyint(4) NULL DEFAULT NULL,
  `client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `gmt_create` datetime(0) NULL DEFAULT NULL,
  `gmt_modified` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`branch_id`) USING BTREE,
  INDEX `idx_xid`(`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
 
-- ----------------------------
-- Table structure for global_table
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table`  (
  `xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `transaction_id` bigint(20) NULL DEFAULT NULL,
  `status` tinyint(4) NOT NULL,
  `application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `timeout` int(11) NULL DEFAULT NULL,
  `begin_time` bigint(20) NULL DEFAULT NULL,
  `application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `gmt_create` datetime(0) NULL DEFAULT NULL,
  `gmt_modified` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`xid`) USING BTREE,
  INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
  INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
 
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table`  (
  `row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `transaction_id` bigint(20) NULL DEFAULT NULL,
  `branch_id` bigint(20) NOT NULL,
  `resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `gmt_create` datetime(0) NULL DEFAULT NULL,
  `gmt_modified` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`row_key`) USING BTREE,
  INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

 

接着我们来修改register.conf

注册中心修改为nacos

使用文件file来管理

分布式事务详解

分布式事务详解

分布式事务详解

分布式事务详解

分布式事务详解

分布式事务之 RocketMQ 事务消息详解