MySQL(2):事务

1 事务

数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行 。

1.1 ACID

原子性、隔离性、持久性(手段) => 一致性(目的)

原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。

  • 原子性Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;

  • 一致性Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。数据库总是从一个一致性的状态转换到另外一个一致性的状态。例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;

  • 隔离性Isolation):一个事务所做的修改在最终提交以前,对其他事务是不可见的。保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。

  • 持久性Durability):一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。

[!note] InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?

  • 持久性是通过 redo log (重做日志)来保证的;

  • 原子性是通过 undo log(回滚日志) 来保证的;

  • 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;

  • 一致性则是通过持久性+原子性+隔离性来保证;

1.2 并发事务控制方式

MySQL 中并发事务的控制方式无非就两种:MVCC。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。

控制方式下会通过锁来显式控制共享资源而不是通过调度手段,MySQL 中主要是通过 读写锁 来实现并发控制。

  • 共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。

  • 排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。

MVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本。通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。

MVCC 在 MySQL 中实现所依赖的手段主要是: 隐藏字段、read view、undo log

  • undo log : undo log 用于记录某行数据的多个版本的数据。

  • read view 和 隐藏字段 : 用来判断当前版本数据的可见性。

1.3 并发事务挑战

  • 脏读(Dirty read):事务1对数据进行了修改,事务2读取到了修改后的数据(事务未提交),但事务1随后回滚

  • 丢失修改(Lost to modify):两个事务访问同一个数据,随后都对其进行修改,第一个事务内的修改被丢失

  • 不可重复读(Unrepeatable read):事务2多次读取一个数据,但事务1在两次读之间修改了该数据,导致事务2读的结果不一致

  • 幻读(Phantom read):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据。在事务2两次读数据之间,事务1插入了一些数据,事务2读取到了一些本来不存在的数据。

1.3.1 不可重复读 vs 幻读

  • 不可重复读的重点是内容修改,比如多次读取一条记录发现其中某些记录的值被修改;

  • 幻读的重点在于记录新增或删除,比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。

幻读其实可以看作是不可重复读的一种特殊情况,单独把区分幻读的原因主要是解决幻读和不可重复读的方案不一样

举个例子:执行 deleteupdate 操作的时候,可以直接对记录加锁,保证事务安全。而执行 insert 操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行 insert 操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock) 进行加锁来保证不出现幻读。

1.3.2 解决更新丢失

  • 方法1:使用事务+锁定读,也就是for update,

  • 方法2:不使用事务,用CAS自旋来操作。

1.3.3 解决幻读

一个事务在操作某张表数据的时候,另外一个事务不允许新增或者删除这张表中的数据了。解决幻读的方式主要有以下几种:

  1. 将事务隔离级别调整为 SERIALIZABLE

  2. 在可重复读的事务级别下,给事务操作的这张表添加表锁。

  3. 在可重复读的事务级别下,给事务操作的这张表添加 Next-key Lock(Record Lock+Gap Lock)

image.png

1.3.4 并发事务控制

  • 共享锁(读锁,S锁)

  • 排他锁(写锁,X锁)

1.4 事务隔离级别

MVCC:实现不同事务隔离级别
MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。
SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。

SET TRANSACTION ISOLATION LEVEL X
  • READ UNCOMMITTED(读未提交数据):事务中的修改,即使没有提交,对其他事务也都是可见的。事务可以读取未提交的数据,这也被称为脏读(Dirty Read)。

  • READ COMMITTED(读提交数据):允许事务读已提交的数据,但不要求“可重复读”。

  • REPEATABLE READ(可重复读):只许事务读已提交的数据,且两次读之间不许其他事务修改此数据。事务可以不可串行化。

  • SERIALIZABLE(可串行化):允许事务并发执行,但须保证并发调度可串行化。会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

隔离级别 脏读 不可重复读 幻读
读未提交
读取已提交
可重复读
可串行化 × × ×

MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。

SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。

  • 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;

  • 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问;

  • 对于「读提交」和「可重复读」隔离级别的事务来说,通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同, Read View 理解成一个数据快照。「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View。「读提交」隔离级别是在「每个select语句执行前」都会重新生成一个 Read View; 「可重复读」隔离级别是执行第一条select时,生成一个 Read View,然后整个事务期间都在用这个 Read View。

1.4.1 读提交和可重复读

对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同:

  • 「读提交」隔离级别是在每个 select 都会生成一个新的 Read View,也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

  • 「可重复读」隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View,这样就保证了在事务期间读到的数据都是事务启动前的记录。

这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列」的比对,来控制并发事务访问同一个记录时的行为,这就叫 MVCC(多版本并发控制)。

在可重复读隔离级别中,普通的 select 语句就是基于 MVCC 实现的快照读,也就是不会加锁的。而 select … for update 语句就不是快照读了,而是当前读了,也就是每次读都是拿到最新版本的数据,但是它会对读到的记录加上 next-key lock 锁。

1.4.2 可重复度

在 MySQL 的可重复读隔离级别下,针对当前读的语句会对索引加记录锁+间隙锁,这样可以避免其他事务执行增、删、改时导致幻读的问题。

有一点要注意的是,在执行 update、delete、select … for update 等具有加锁性质的语句,检查语句是否走了索引;未走索引是全表扫描,会对每一个索引加 next-key 锁,相当于把整个表锁住了,这是挺严重的问题。

1.5 锁

表级锁 行级锁
MyISAM
InnoDB
InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。

1.5.1 InnoDB行锁种类

  • 记录锁(Record Lock):也被称为记录锁,属于单个行记录上的锁。

  • 间隙锁(Gap Lock):锁定一个范围,不包括记录本身。

  • 临键锁(Next-Key Lock):Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。

1.6 InnoDB默认隔离级别

InnoDB存储引擎的默认事务隔离级别是“可重复读”(REPEATABLE READ)。在这个级别下,事务在开始时创建一个快照,事务内的查询都是基于这个快照进行的,因此可以确保在整个事务过程中读取到的数据是一致的,即使其他事务在此期间修改了数据。

image.png

  • m_ids :指的是在创建 Read View 时,当前数据库中「活跃事务」的事务 id 列表,注意是一个列表,“活跃事务”指的就是,启动了但还没提交的事务

  • min_trx_id :指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。

  • max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;

  • creator_trx_id :指的是创建该 Read View 的事务的事务 id

image.png

  • trx_id,当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里

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

image.png
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:

  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 已经提交的事务生成的,所以该版本的记录对当前事务可见

  • 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 才启动的事务生成的,所以该版本的记录对当前事务不可见

  • 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:

    • 如果记录的 trx_id  m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务不可见
    • 如果记录的 trx_id 不在 m_ids列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见

这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。

1.7 InnoDB解决幻读

幻读是指在同一个事务中多次执行相同的查询,但由于其他事务的插入操作,导致结果集不一致的现象。InnoDB引擎通过多版本并发控制(MVCC)和间隙锁(Gap Locks)来解决幻读问题。在可重复读隔离级别下,InnoDB使用MVCC来保持事务内数据的一致性视图。同时,InnoDB的间隙锁可以防止其他事务在已锁定范围的间隙中插入新的数据,从而确保在事务范围内不会出现新的、未被锁定的行,这样就解决了幻读问题。

  • 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。

  • 针对当前读(select … for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

select for update的含义是在查询数据的同时对所选的数据行进行锁定,以保证数据的一致性和并发控制。

image.png

1.8 当前读无法避免幻读情景

MySQL 里除了普通查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。

1.8.1 场景1

当事务 A 更新了一条事务 B 插入的记录image.png
在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。

1.8.2 场景2

对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。

  • T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。

  • T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;

  • T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。

要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select … for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录

MySQL 可重复读隔离级别,完全解决幻读了吗? | 小林coding (xiaolincoding.com)
深入理解select for update的含义和锁机制-CSDN博客

1.9 当前读 vs 快照读

MVCC机制实现了快照读,普通select 查询就是快照读,快照读到数据有可能不是最新的数据,它主要是为了实现可重复读的事务隔离级别。

当前读:读取的是最新的数据版本,当我们使用UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE 是当前读。当前读就是加了锁的增删改查语句。

[!note] MVCC 有没有解决幻读的问题?

在快照读的情况下,InnoDB通过MVCC机制解决了幻读现象;但是在当前读的情况下,InnoDB是无法通过MVCC解决幻读的现象,因为它每次读取的都是最新的数据。

数据库面试题:mysql当前读和快照读(MVCC)_当前读和快照读 面试-CSDN博客
MySQL 高频面试题解析 第02期:当前读和快照读的区别-腾讯云开发者社区-腾讯云 (tencent.com)

2 参考

MySQL 事务并发带来的问题以及其解决方案分析-腾讯云开发者社区-腾讯云 (tencent.com)
(七)MySQL事务篇:ACID原则、事务隔离级别及事务机制原理剖析 - 掘金 (juejin.cn)
mysql当前读和快照读(MVCC)_当前读和快照读 面试-CSDN博客
MySQL三大日志(binlog、redo log和undo log)详解 | JavaGuide
MySQL事务隔离级别详解 | JavaGuide