Skip to content

MySQL事务相关

笔者说明

关于事务(尤其是隔离级别的实现方式)

隔离级别解决的并发事务导致的问题已经比较熟练,这里就不多赘述

一个注意点

MySQL里除了普通查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。这很好理解,假设你要 update 一个记录,另一个事务已经 delete 这条记录并且提交事务了,这样会产生冲突,所以 update 的时候肯定要知道最新的数据。

另外, select ... for update这种査询语句也是当前读,每次执行的时候都是读取最新的数据。

InnoDB引擎是用什么技术保证事务的四大特性?

  • 持久性是通过 redo_log(重做日志)来保证的
  • 原子性是通过 undo_log(回滚日志)来保证的
  • 隔离性是通过 MVCC(多版本并发控制)或锁机制来保证的
  • 一致性则是通过持久性 + 原子性 + 隔离性来保证

并发事务可能导致脏读 > 不可重复读 > 幻读的问题

其中:

  • 脏读:读取到其他事务未提交的数据
  • 不可重复读:一个事务内前后读取的数据不一致
  • 幻读:一个事务内前后读取的数据数量不一致

于是针对这些并发问题SQL标准(注意这是标准而不是实现数据库的厂商具体的实现都完全对此标准做实现,如MySQL还是有些出入的)提出了四大隔离级别,隔离级别越高性能越低

  • 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到
  • 读已提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到(解决脏读
  • 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,是MySQL InnoDB引擎的默认隔离级别解决不可重复读
  • 串行化(serializable):会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行(解决幻读

MySQL对于「幻读」问题的解决

MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生(注意是很大程度避免,并不是彻底避免),所以 MySQL并不会使用「串行化」隔离级别来避免幻读现象的发生,因为使用「串行化」隔离级别会影响性能。解决的方案有两种:

  • 针对快照读(普通select语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
  • 针对当前读(select ... for update 等语句),是通过next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select .. for update 语句的时候,会加上next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

关于「幻读」的详细解释

首先来看看 MySQL 文档是怎么定义幻读(Phantom Read)的:

The so-called phantom problem occurs within a transaction when the same query produces different sets ofrowsat different times. For example, if a SELECT is executed twice, but returns a row the second time that was notreturned the first time, the row is a "phantom"row

翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是“幻像”行。

举个例子,假设一个事务在 T1 时刻和 T2 时刻分别执行了下面查询语句,途中没有执行其他任何语句:

SELECT *FROM t_test WHERE id >100;

只要 T1 和 T2 时刻执行产生的结果集是不相同的,那就发生了幻读的问题,比如:

T1 时间执行的结果是有5条行记录,而 T2 时间执行的结果是有6 条行记录,那就发生了幻读的问题;

T1时间执行的结果是有5条行记录,而 T2 时间执行的结果是有4条行记录,也是发生了幻读的问题。

四种隔离级别是如何实现的

  • 「读未提交」:可以读到未提交事务修改的数据,所以直接读取最新的数据
  • 「串行化」:通过加读写锁的方式来避免并行访问
  • 「读提交」和「可重复读」:通过Read view(数据快照)来实现,区别在于创建Read View的时机不同。「读提交」是在「每个语句执行前」都会重新生成(因为每个语句执行前都有可能有其他事务提交),而「可重复读」是「启动事务时」生成,然后整个事务期间都用这个Read View

注意执行「开始事务」命令,并不意味着启动了事务。在 MSQL有两种开启事务的命令,分别是

  • begin/start transaction
  • start transaction with consistent snapshot

这两种开启事务的命令,事务的启动时机是不同的,执行了begin/start transaction命令后,并不代表事务启动了。只有在执行这个命令后,执行了第一条select语句,才是事务真正启动的时机;执行了start transaction with consistent snapshot命令,就会马上启动事务。

关于Read View 在 MVCC中的工作原理

MVCC允许多个事务同时读取同一行数据,而不会彼此阻塞,每个事务看到的数据版本是该事务开始时的数据版本。这意味着,如果其他事务在此期间修改了数据,正在运行的事务仍然看到的是它开始时的数据状态,从而实现了非阻塞读操作

Read View 有四个重要的字段:

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。

聚簇索引的两个隐藏列

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

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

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

下面这些情况是判断的依据

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

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

  3. 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx id 之间,需要判断 trx_id 是否在m_ids 列表中

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

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

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

笔者声明

利用上面提到的关键信息,举例可重复读和读已提交的工作原理

只要看一张图即可理解

假设事务A(事务id为51)启动后,紧接着另一个事务B(事务id为52)也启动,在这之前某一条数据行最新的数据中隐藏列的trx_id为50,表示当时最新的数据。接着事务A和事务B做如下的操作:

  1. 事务 B 读取 york 的账户余额记录
  2. 事务 A 将 york 的账户余额记录修改成 200000,并没有提交事务
  3. 事务 B 读取 york 的账户余额记录
  4. 事务 A 提交事务
  5. 事务 B 读取 york 的账户余额记录

对这同样的步骤,对「可重复读」和「读已提交」两种隔离级别的不同结果及实现方式进行讨论即可

对于下图的快照Read View创建的字段意义很好理解,不懂的话再细看上面对Read View的四个字段的详解即可理解

可重复读

可重复读隔离级别是启动事务时生成一个 Read view,然后整个事务期间都在用这个 Read view。在事务期间读到的记录都是事务启动前的记录。

这里也强调一下,正因为「可重复读」这种设计,即一直使用这个 Read View,所以即使中途有其他事务插入了新数据也查不到这条数据,所以很好地避免了幻读的问题(但也只是很好,不是完全)

对于我们统一的步骤,「可重复读」的结果应该是:

  1. 事务 B 读取 york 的账户余额记录(余额为100000)
  2. 事务 A 将 york 的账户余额记录修改成 200000,并没有提交事务
  3. 事务 B 读取 york 的账户余额记录(余额为100000)
  4. 事务 A 提交事务
  5. 事务 B 读取 york 的账户余额记录(余额为100000)

结合上面的图来看,事务 B 第一次读 york 的账户余额记录,在找到记录后,它会先看这条记录的trx_id,此时发现 trx_id为 50(刚开始读到最新的事务),比 RV_B 中的min_trx_id值(51)还小,这意味着修改这条记录的事务早就在事务B启动前提交过了(也就是这是B事务启动前最新的数据,是可见的),所以该版本的记录对事务 B 可见的。

接着,事务 A 通过 update 语句将这条记录修改了(这时还未提交),将 york 的余额改成 200000,这时 MySQL 会记录相应的 undo_log,并以链表的方式串联起来,形成版本链(如上图中用箭头连接的信息),并且最新的记录是由事务 A 引起的,所以对应数据的trx_id记录是51(表示是事务 A),然后新旧版本之间连接着。

然后事务 B 第二次去读取该记录,此时记录的 trx_id 值为 51,在 RV_B 的min_trx_idmax_trx_id之间,则需要判断 trx_id值是否在m_ids范围内(即是否属于活跃列表,也就是未提交的事务做的事情),显然确实属于活跃列表,也就是说这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录,而是沿着undo_log链条(图中的指针)往下找旧版本的记录,直到找到trx_id 「小于」RV_B 中的 min_trx_id 值的第一条记录(也就是找到事务 B 之前最新的数据),所以事务 B 能读取到的是 trx_id 为50 的记录,也就是 york 余额是100000的这条记录。

最后,当事务 A 提交后,由于隔离级别是「可重复读」,事务 B 再次读取记录时仍然基于启动事务时创建的 Read view(还是那个 RV_B) 来判断当前版本的记录是否可见。所以还是旧记录。

读已提交

读已提交隔离级别是每次读取数据时都生成一个新的 Read view。

对于我们统一的步骤,「读已提交」的结果应该是:

  1. 事务 B 读取 york 的账户余额记录(余额为100000)
  2. 事务 A 将 york 的账户余额记录修改成 200000,并没有提交事务
  3. 事务 B 读取 york 的账户余额记录(余额为100000)
  4. 事务 A 提交事务
  5. 事务 B 读取 york 的账户余额记录(余额为200000)

结合上面的图来看,事务 B 第一次读 york 的账户余额记录,在找到记录后,它会先看这条记录的trx_id,此时发现 trx_id为 50(刚开始读到最新的事务),比 RV_B 中的min_trx_id值(51)还小,这意味着修改这条记录的事务早就在事务B启动前提交过了(也就是这是B事务启动前最新的数据,是可见的),所以该版本的记录对事务 B 可见的。(这一步是一样的)

接着,事务 A 通过 update 语句将这条记录修改了(这时还未提交),将 york 的余额改成 200000,这时 MySQL 会记录相应的 undo_log,并以链表的方式串联起来,形成版本链(如上图中用箭头连接的信息),并且最新的记录是由事务 A 引起的,所以对应数据的trx_id记录是51(表示是事务 A),然后新旧版本之间连接着。(这一步也一样)

然后事务 B 第二次去读取该记录,此时记录的 trx_id 值为 51,在 RV_B 的min_trx_idmax_trx_id之间,则需要判断 trx_id值是否在m_ids范围内(即是否属于活跃列表,也就是未提交的事务做的事情),显然确实属于活跃列表,也就是说这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录,而是沿着undo_log链条(图中的指针)往下找旧版本的记录,直到找到trx_id 「小于」RV_B 中的 min_trx_id 值的第一条记录(也就是找到事务 B 之前最新的数据),所以事务 B 能读取到的是 trx_id 为50 的记录,也就是 york 余额是100000的这条记录。(这一步还是一样,关键就是事务 A 并没有提交,此时的活跃事务列表仍然包含 51(即事务 A),所以 B 就读不到)

最后,当事务 A 提交后,由于隔离级别是「读已提交」,事务 B 再次读取记录时创建此时最新的RV_B,最大的区别在于此时活跃列表中已经没有了51(代表事务 A 已经是已提交的事务)。具体地说:此时毒的是数据的trx_id51,而51在最新的 RV_B 中是小于min_trx_id的最大值,表示当前数据是当前事务能够读取到的最新的数据。

小结

这里已经对「读已提交」和「可重复读」解释,剩下的「读未提交」就很好理解了。

技术漫游

本站访客数 人次 本站总访问量