跳到主要内容

01、分布式事务 实战 - 事务的基本概念

一、事务的特性

数据库的事务在实现时,会将一次事务中包含的所有操作全部封装成一个不可分割的执行单元,这个单元中的所有操作要么全部执行成功,要么全部执行失败。只要其中任意一个操作执行失败,整个事务就会执行回滚操作

1.原子性

事务的原子性指的是构成事务的所有操作要么全部执行成功,要么全部执行失败,不可能出现部分执行成功,部分执行失败的情况

比如A 向 B 转账 100 元,数据库需要进行两个操作,A 的账户要减少 100 元,B 的账户要增加 100 元,这两个操作要么全部执行成功,要么全部执行失败

2.一致性

事务的一致性指的是在事务执行之前和执行之后,数据始终处于一致的状态

还是上面转账的例子,如果转账完成后,A 的账户没有减少 100 元或者 B 的账户没有增加 100 元,这就是数据处于不一致状态

3.隔离性

事务的隔离性指的是并发执行的两个事务之间互不干扰。也就是说,一个事务在执行过程中不能看到其他事务运行过程的中间状态

还是上面转账的例子,在转账完成之前,其他事务查询 A 的账户或者 B 的账户,余额应该是转账完成之前的余额,而不会查询到 A 的账户减少了 100 元,而 B 的账户还没有增加 100 元的中间状态

4.持久性

事务的持久性指的是事务提交完成后,此事务对数据的更改操作会被持久化到数据库中,并且不会被回滚

二、事务的类型

1.扁平事务

扁平事务是事务操作中最常见,也是最简单的事务。在数据库中,扁平事务通常由 begin 或者 start transaction 字段开始,由 commit 或者 rollback 字段结束。在这之间的所有操作要么全部执行成功,要么全部执行失败(回滚)

扁平事务的痛点在于无法提交或者回滚整个事务中的部分事务,只能把整个事务全部提交或者回滚。为了解决这个问题,带有保存点的扁平事务出现了

2.带有保存点的扁平事务

带有保存点的扁平事务通过在事务内部的某个位置设置保存点(savepoint),达到将当前事务回滚到此位置的目的,示例如下

在MySQL 数据库中,通过如下命令设置事务的保存点

save [savepoint_name]

通过如下命令将当前事务回滚到定义的保存点位置

rollback to [savepoint_name]

通过如下命令删除保存点

release savepoint [savepoint_name]

3.链式事务

链式事务是在带有保存点的扁平事务的基础上,自动将当前事务的上下文隐式地传递给下一个事务。也就是说,一个事务的提交操作和下一个事务的开始操作具备原子性,上一个事务的处理结果对下一个事务是可见的,事务与事务之间就像链条一样传递下去

链式事务在提交的时候,会释放要提交的事务中的所有锁和保存点,也就是说,链式事务的回滚操作只能回滚到当前所在事务的保存点,而不能回滚到已提交事务的保存点

4.嵌套事务

顾名思义,嵌套事务就是有多个事务处于嵌套状态,共同完成一项任务的处理,整个任务具备原子性。嵌套事务最外层有一个顶层事务,这个顶层事务控制着所有的内部子事务,内部子事务提交完成后,整体事务并不会提交,只有最外层的顶层事务提交完成后,整体事务才算提交完成

关于嵌套事务需要注意以下几点:

1、 回滚嵌套事务内部的子事务时,会将事务回滚到外部顶层事务的开始位置;
2、 嵌套事务的提交是从内部的子事务向外依次进行的,直到最外层的顶层事务提交完成;
3、 回滚嵌套事务最外层的顶层事务时,会回滚嵌套事务包含的所有事务,包括已提交的内部子事务;

5.分布式事务

分布式事务指的是事务的参与者、事务所在的服务器、涉及的资源服务器以及事务管理器分别位于不同分布式系统的不同服务或者数据库节点上。简单来说,分布式事务就是一个在不同环境(比如不同的数据库、不同的服务器)下运行的整体事务。这个整体事务包含一个或多个分支事务,并且整体事务中的所有分支事务要么全部提交成功,要么全部提交失败

例如,在电商系统的下单减库存业务中,订单业务所在的数据库为事务 A 的节点,库存业务所在的数据库为事务 B 的节点

三、本地事务

1.基本概念

在常见的计算机系统和应用系统中,很多事务是通过关系型数据库进行控制的。这种控制事务的方式是利用数据库本身的事务特性来实现,而在这种实现方式中,数据库和应用通常会被放在同一台服务器中,因此,这种基于关系型数据库的事务也可以称作本地事务或者传统事务

本地事务使用常见的执行模式,可以使用如下伪代码来表示:

tansaction begin
insert into (字段名列表) values (值列表)
update 表名 set 字段名 = 字段值 where id = id值
delete from 表名 where id = id 值
transaction commit/rollback

另外,本地事务也具有一些特征。以下列举几个本地事务具有的典型特征

1、 一次事务过程中只能连接一个支持事务的数据库,这里的数据库一般指的是关系型数据库;
2、 事务的执行结果必须满足ACID特性;
3、 事务的执行过程会用到数据库本身的锁机制;

2.本地事务的执行流程

本地事务的执行流程如下图所示:
 

1、 客户端开始事务操作之前,需要开启一个连接会话;
2、 开始会话后,客户端发起开启事务的指令;
3、 事务开启后,客户端发送各种SQL语句处理数据;
4、 正常情况下,客户端会发起提交事务的指令,如果发生异常情况,客户端会发起回滚事务的指令;
5、 上述流程完成后,关闭会话;

本地事务是由资源管理器在本地进行管理的

3.本地事务的优缺点

本地事务的优点总结如下:

1、 支持严格的ACID特性,这也是本地事务得以实现的基础;
2、 事务可靠,一般不会出现异常情况;
3、 本地事务的执行效率比较高;
4、 事务的状态可以只在数据库中进行维护,上层的应用不必理会事务的具体状态;
5、 应用的编程模型比较简单,不会涉及复杂的网络通信;

本地事务的缺点总结如下:

1、 不具备分布式事务的处理能力;
2、 一次事务过程中只能连接一个支持事务的数据库,即不能用于多个事务性数据库;

四、MySQL 事务基础

1.并发事务带来的问题

数据库一般会并发执行多个事务,而多个事务可能会并发地对相同的数据进行增加、删除、修改和查询操作,进而导致并发事务问题。并发事务带来的问题包括更新丢失(脏写)、脏读、不可重复读和幻读

更新丢失(脏写)
当两个或两个以上的事务选择数据库中的同一行数据,并基于最初选定的值更新该行数据时,因为每个事务之间都无法感知彼此的存在,所以会出现最后的更新操作覆盖之前由其他事务完成的更新操作的情况。也就是说,对于同一行数据,一个事务对该行数据的更新操作覆盖了其他事务对该行数据的更新操作

更新丢失(脏写)本质上是写操作的冲突,解决办法是让每个事务按照串行的方式执行,按照一定的顺序一次进行写操作

脏读
一个事务正在对数据库中的一条记录进行修改操作,在这个事务完成并提交之前,当有另一个事务来读取正在修改的这条数据记录时,如果没有对这两个事务进行控制,则第二个事务就会读取到没有被提交的脏数据,并根据这些脏数据做进一步的处理,此时就会产生未提交的数据依赖关系。我们通常把这种现象称为脏读,也就是也给事务读取了另一个事务未提交的数据

脏读本质上是读写操作的冲突,解决办法是先写后读

不可重复读
一个事务读取了某些数据,在一段时间后,这个事务再次读取之前读过的数据,此时发现读取的数据发生了变化,或者其中的某些记录已经被删除,这种现象就叫做不可重复读。即同一个事务,使用相同的查询语句,在不同时刻读取到的结果不一致

不可重复读本质上是读写操作的冲突,解决办法是先读后写

幻读
一个事务按照相同的查询条件重新读取之前读过的数据,此时发现其他事务插入了满足当前事务查询条件的新数据,这种现象叫做幻读。即一个事务两次读取一个范围的数据记录,两次读取到的结果不同

幻读本质上是读写操作的冲突,解决办法是先读后写

不可重复读和幻读的区别:

1、 不可重复读的重点在于更新和删除操作,而幻读的重点在于插入操作;
2、 使用锁机制实现事务隔离级别时,在可重复读隔离级别中,SQL语句第一次读取到数据后,会将相应的数据加锁,使得其他事务无法修改和删除这些数据,此时可以实现可重复读;
3、 幻读无法通过行级锁来避免,需要使用串行化的事务隔离级别,但是这种事务隔离级别会极大降低数据库的并行能力;
4、 从本质上讲,不可重复读和幻读最大的区别在于如何通过锁机制解决问题;

另外,除了可以使用悲观锁来避免不可重复读和幻读的问题外,我们也可以使用乐观锁来处理,例如,MySQL、Oracle 和 PostgreSQL 等数据库为了提高整体性能,就使用了基于乐观锁的 MVCC(多版本并发控制)机制来避免不可重复读和幻读

2.MySQL 事务隔离级别

InMySQL 中的 InnoDB 储存引擎提供 SQL 标准所描述的 4 种事务隔离级别,分别为读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)

可以在命令行用 --transaction-isolation 选项或在 MySQL 的配置文件 my.cnf、my.ini 里,为所有连接设置默认的事务隔离级别。也可以使用 SET TRANSACTION 命令改变单个或者所有新连接的事务隔离级别

3.MySQL 中各种事务的隔离级别的区别

不同事务隔离级别对问题的解决程度对比:
 

1、 读未提交,即可能读取到其他会话未提交事务修改的数据;
2、 读已提交,即只能读取到已经提交的数据;
3、 可重复读就是在同一个事务内,无论何时查询到的数据都与开始查询到的数据一致;
4、 可串行化是指完全串行地读,每次读取数据库中的数据时,都需要获得表级别的共享锁,读和写会相互阻塞;

5.MySQL 中锁的分类

从本质上讲,锁是一种协调多个进程或多个线程对某一资源的访问的机制,MySQL 使用锁和 MVCC 机制实现了事务隔离级别

MySQL 中的锁可以从以下几个方面进行分类,如下图所示:

 

1、 从性能上看,MySQL中的锁可以分为悲观锁和乐观锁,这里的乐观锁是通过版本对比来实现的;
2、 从对数据库的操作类型上看,MySQL中的锁可以分为读锁和写锁,这里的读锁和写锁都是悲观锁;
3、 从操作数据的粒度上看,MySQL中的锁可以分为表锁、行锁和页面锁;
4、 从更细粒度上看,MySQL中的锁分为间隙锁和临建锁;

悲观锁和乐观锁

(1)悲观锁
顾名思义,悲观锁对于数据库中数据的读写持悲观态度,即在整个数据处理的过程中,它会将相应的数据锁定。在数据库中,悲观锁的实现需要依赖数据库提供的锁机制,以保证对数据库加锁后,其他应用系统无法修改数据库中的数据

在悲观锁机制下,读取数据库中的数据时需要加锁,此时不能对这些数据进行修改操作。修改数据库中的数据时也需要加锁,此时不能对这些数据进行读取操作

(2)乐观锁
悲观锁会极大地降低数据库的性能,特别是对长事务而言,性能的损耗往往是无法承受的。乐观锁则在一定程度上解决了这个问题

顾名思义,乐观锁对于数据库中数据的读写持乐观态度,即在整个数据处理的过程中,大多数情况下它是通过数据版本记录机制实现的

实现乐观锁的一种常用做法是为数据增加一个版本标识,如果是通过数据库实现,往往会在数据表中增加一个类似 version 的版本号字段。在查询数据表中的数据时,会将版本号字段的值一起读取出来,当更新数据时,会令版本号字段的值加 1。将提交数据的版本与数据表对应记录的版本进行对比,如果提交的数据版本号大于数据表中当前要修改的数据的版本号,则对数据进行修改操作。否则,不修改数据表中的数据

读锁和写锁

(1)读锁
读锁又称为共享锁或 S 锁(Shared Lock),针对同一份数据,可以加多个读锁而互不影响

(2)写锁
写锁又称为排他锁或 X 锁(Exclusive Lock),如果当前写锁未释放,它会阻塞其他的写锁和读锁

需要注意的是,对同一份数据,如果加了读锁,则可以继续为其加读锁,且多个读锁之间互不影响,但此时不能为数据增加写锁。一旦加了写锁,则不能再增加写锁和读锁。因为读锁具有共享性,而写锁具有排他性

表锁、行锁和页面锁
(1)表锁
表锁也称为表级锁,就是在整个数据表上对数据进行加锁和释放锁。典型特点是开销比较小,加锁速度快,一般不会出现死锁,锁定的粒度比较大,发生锁冲突的概率最高,并发度最低

在MySQL 中,有两种表级锁模式:一种是表共享锁(Table Shared Lock);另一种是表独占写锁(Table Write Lock)

当一个线程获取到一个表的读锁后,其他线程仍然可以对表进行读操作,但是不能对表进行写操作。当一个线程获取到一个表的写锁后,只有持有锁的线程可以对表进行更新操作,其他线程对数据表的读写操作都会被阻塞,直到写锁被释放位置

可以在MySQL 的命令行通过如下命令手动增加表锁:

lock table 表名称 read(write),表名称2 read(write);

使用如下命令可以查看数据表上增加的锁,如下所示:

show open tables;

使用如下命令可以删除表锁:

unlock tables;

(2)行锁
行锁也成为行级锁,就是在数据行上对数据进行加锁和释放锁。典型特点是开销比较大,加锁速度慢,可能会出现死锁,锁定的粒度最小,发生锁冲突的概率最小,并发度最高

在InnoDB 存储引擎中,有两种类型的行锁:一种是共享锁,另一种是排他锁。共享锁允许一个事务读取一行数据,但不允许一个事务对加了共享锁的当前行增加排他锁。排他锁只允许当前事务对数据进行增删改查操作,不允许其他事务对增加了排他锁的数据行增加共享锁和排他锁

使用行锁时,需要注意以下几点:

1、 行锁主要加在索引上,如果对非索引的字段设置条件进行更新,行锁可能会变成表锁;
2、 InnoDB的行锁是针对索引加锁,不是针对记录加锁,并且加锁的索引不能失效,否则行锁可能会变成表锁;
3、 锁定某一行时,可以使用lockinsharemode命令来指定共享锁,使用forupdate命令来指定排他锁,例如下面的SQL语句:;

select * from account where id = 1 for update;

(3)页面锁
页面锁也称为页级锁,就是在页面级别对数据进行加锁和释放锁。对数据的加锁开销介于表锁和行锁之间,可能会出现死锁,锁定的粒度大小介于表锁和行锁之间,并发度一般

表锁、行锁和页面锁的特点:
 

间隙锁和临键锁
(1)间隙锁
在MySQL 中使用范围查询时,如果请求共享锁或排他锁,InnoDB 会给符合条件的已有数据的索引项加锁。如果键值在条件范围内,而这个范围内并不存在记录,则认为此时出现了 “间隙(也就是 GAP)”。InnoDB 存储引擎会对这个 “间隙” 加锁,而这种加锁机制就是间隙锁(GAP Lock)

说得简单点,间隙锁就是对两个值之间的间隙加锁。MySQL 的默认隔离级别是可重复读,在可重复读隔离级别下会存在幻读的问题,而间隙锁在某种程度下可以解决幻读的问题

例如,account 数据表中存在如下数据:
 

此时,account 数据表中的间隙包括 id 为 (3,15]、(15,20]、(20,正无穷] 的三个区间

如果执行如下命令,将符合条件的用户的账户余额增加 100 元:

update account set balance = balance + 100 where id > 5 and id < 16;

则其他事务无法在 (3,20] 这个区间内插入或者修改任何数据

这里需要注意的是,间隙锁只有在可重复读事务隔离级别下才会生效

(2)临键锁
临键锁(Next-Key Lock)是行锁和间隙锁的组合,例如上面例子中的区间 (3,20] 就可以称为临键锁

6.死锁的产生和预防

虽然锁在一定程度上能够解决并发问题,但稍有不慎,就可能造成死锁。发生死锁的必要条件有 4 个,分别为互斥条件、不可剥夺条件、请求与保持条件和循环等待条件

互斥条件
在一段时间内,计算机中的某个资源只能被一个进程占用。此时,如果其他进程请求该资源,则只能等待

不可剥夺条件
某个进程获得的资源在使用完毕之前,不能被其他进程强行夺走,只能由获得资源的进程主动释放

请求与保持条件
进程已经获得了至少一个资源,又要请求其他资源,但请求的资源已经被其他进程占有,此时请求的进程就会被阻塞,并且不会释放自己已获得的资源

循环等待条件
系统中的进程之间相互等待,同时各自占用的资源又会被下一个进程所请求。例如有进程 A、进程 B 和进程 C 三个进程,进程 A 请求的资源被进程 B 占用,进程 B 请求的资源被进程 C 占用,进程 C 请求的资源被进程 A 占用,于是形成了循环等待条件

需要注意的是,只有 4 个必要条件都满足时,才会发生死锁

处理死锁有 4 种方法,分别为预防死锁、避免死锁、检测死锁和解除死锁:

  • 预防死锁:处理死锁最直接的方法就是破坏造成死锁的 4 个必要条件中的一个或多个,以防止死锁的产生
  • 避免死锁:在系统资源的分配过程中,使用某种策略或者方法防止系统进入不安全状态,从而避免死锁的发生
  • 检测死锁:这种方法允许系统在运行过程中发生死锁,但是能够检测死锁的发生,并采取适当的措施清除死锁
  • 接触死锁:当检测出死锁后,采用适当的策略和方法将进程从死锁状态解脱出来

在实际工作中,通常采用有序资源分配法和银行家算法这两种方式来避免死锁

7.MySQL 中的死锁问题

在MySQL 5.5.5 及以上版本中,MySQL 的默认存储引擎是 InnoDB。该存储引擎使用的是行级锁,在某种情况下会产生死锁问题,所以 InnoDB 存储引擎采用了一种叫做等待图(wait-for graph)的方法来自动检测死锁,如果发现死锁,就会自动回滚一个事务

接下来,我们看一个 MySQL 中的死锁案例

第一步:打开终端 A,登录 MySQL,将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 1 的数据添加排他锁,如下所示:
 

第二步: 打开终端 B,登录 MySQL,将事务隔离级别设置为可重复读,开启事务后为 account 数据表中 id 为 2 的数据添加排他锁,如下所示:
 

第三步:在终端 A 为 account 数据表中 id 为 2 的数据添加排他锁,如下所示:

mysql>select * from account where id = 2 for update;

此时,线程会一直卡住,因为在等待终端 B 中 id 为 2 的数据释放排他锁

第四步:在终端 B 中为 account 数据表中 id 为 1 的数据添加排他锁,如下所示:

mysql> select * from account where id =1 for update;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

此时发生了死锁。通过如下命令可以查看死锁的日志信息:

show engine innodb status\G

通过命令行查看 LATEST DETECTED DEADLOCK 选项相关的信息,可以发现死锁的相关信息,或者通过配置 innodb_print_all_deadlocks(MySQL 5.6.2 版本开始提供)参数为 ON,将死锁相关信息打印到 MySQL 错误日志中

在MySQL 中,通常通过以下几种方式来避免死锁:

1、 尽量让数据表中的数据检索都通过索引来完成,避免无效索引导致行锁升级为表锁;
2、 合理设计索引,尽量缩小锁的范围;
3、 尽量减少查询条件的范围,尽量避免间隙锁或缩小间隙锁的范围;
4、 尽量控制事务的大小,减少一次事务锁定的资源数量,缩短锁定资源的时间;
5、 如果一条SQL语句涉及事务加锁操作,则尽量将其放在整个事务的最后执行;
6、 尽可能使用低级别的事务隔离机制;

8.InnoDB 中的 MVCC 原理

在MVCC 机制中,每个连接到数据库的读操作,在某个瞬间看到的都是数据库中数据的一个快照,而写操作的事务提交之前,读操作是看不到这些数据的变化的

MVCC 机制能够大大提升数据库的读写性能,很多数据库厂商的事务性存储引擎都实现了 MVCC 机制,包含 MySQL、Oracle、PostgreSQL 等。虽然不同数据库实现 MVCC 机制的细节不同,但大多实现了非阻塞的读操作,写操作也只会锁定必要的数据行

从本质上讲,MVCC 机制保存了数据库中数据在某个时间点上的数据快照,这意味着同一个读操作的事务,按照相同的条件查询数据,无论查询多少次,结果都是一样的。从另一个角度来讲,这也意味着不同的事务在同一时刻看到的同一张表的数据可能不同

在InnoDB 存储引擎中,MVCC 机制是通过在每行数据表记录后面保存两个隐藏的列来实现的,一列用来保存行的创建版本号(create_version),另一列用来保存行的过期版本号(delete_version)。每当有一个新的事务执行时,版本号就会自动递增。事务开始时刻的版本号作为事务的版本号,用于和查询到的每行记录的版本号做对比

查询操作

在查询操作中,InnoDB 存储引擎会根据下面两个条件检查每行记录

1、 InnoDB存储引擎只会查找不晚于当前事务版本的数据行,也就是说,InnoDB存储引擎只会查找版本号小于或者等于当前事务版本的数据行这些数据行要么在事务开始前就已经存在,要么就是事务本身插入或者更新的数据行;
2、 数据行删除的版本要么还没有被定义,要么大于当前事务的版本号,只有这样才能确保事务读取到的行,在事务开始之前没有被删除;

例如,存在事务 A 和事务 B 两个事务,事务 A 中存在两条相同的 select 语句,事务 B 中存在一条 update 语句。事务 A 中的第一条 select 语句在事务 B 提交之前执行,第二条 select 语句在事务 B 提交之后执行。事务 A 如下所示:

start transaction;
select * from account where id = 1;		//在事务 B 提交之前执行
select * from account where id = 1;		//在事务 B 提交之后执行
commit;

事务B 如下所示:

start transaction;
update account set balance = balance + 100 where id = 1;
commit;

如果不使用 MVCC 机制,则事务 A 中的第一条 select 语句读取的数据是修改前的数据,而第二条 select 语句读取的是修改后的数据,两次读取的数据不一致。如果使用了 MVCC 机制,则无论事务 B 如何修改数据,事务 A 中的两条 select 语句查询出来的结果始终是一致的

插入操作

在插入操作中,InnoDB 存储引擎会将新插入的每一行记录的当前系统版本号保存为行版本号

例如向account 数据表中插入一条数据,同时假设 MVCC 的两个版本号分别为 create_version 和 delete_version:create_version 代表创建该行记录的版本号;delete_version 代表删除该行记录的版本号。为了更好地展示效果,再再增加一个描述事务版本号的字段 trans_id。向 account 数据表插入数据的 SQL 语句如下所示:

insert into account(id, name, balance) values (1001, 'sisyphus', 100);

对应的版本号信息如下表所示:
 

从上表可以看出,当向数据表中新增记录时,需要设置保存该行记录的版本号,而删除改行记录的版本号未定义

更新操作

在更新操作中,InnoDB 存储引擎会插入一行新记录,并保存当前系统的版本号作为新记录行的版本号,同时保存当前系统的版本号到原来的数据行作为删除标识

例如,将 account 数据表中 id 为 1001 的用户的账户余额增加 100 元,SQL 语句如下所示:

update account set balance = balance + 100 where id = 1001;

执行SQL 语句成功后,再次查询 account 数据表中的数据,存在版本号和事务编号不同的两条记录,如下表所示:

 

从上表可以看出,执行更新操作时,MVCC 机制是先将原来的数据复制一份,将 balance 字段的值增加 100 后,再将 create_version 字段的值设置为当前系统的版本号,而 delete_version 字段的值未定义。除此之外,MVCC 机制还会将原来行的 delete_version 字段的值设置为当前的系统版本号,以标识原来的行记录被删除

这里需要注意的是,原来的行会被复制到 Undo Log 中

删除操作

在删除操作中,InnoDB 存储引擎会保存删除的每一个行记录当前的系统版本号,作为行删除标识

例如,删除 account 数据表中 id 为 1001 的数据,SQL 语句如下所示:

delete from account where id = 1001;

对应的版本号信息如下表所示:

 

从上表中可以看出,当删除数据表中的数据行时,MVCC 机制会将当前系统的版本号写入被删除数据行的删除版本字段 delete_version 中,以此来标识当前数据行已经被删除