MySQL—事务和MVCC

2023年 7月 28日 74.1k 0

0. 简介

并不是所有MySQL的引擎都支持事务,现阶段只有InnoDBNDB支持事务。

事务必须满足以下四点:

  • 原子性:通过undo log(回滚日志)来保证的;
  • 一致性:通过原子性、隔离性、持久性保证;
  • 隔离性:通过MVCC(多版本并发控制) 或锁机制来保证的;
  • 持久性:通过redo log(重做日志)来保证的;

接下来,我们就重点介绍事务隔离性的保障机制之一——MVCC(多版本并发控制)。

1. 事务的隔离级别

1.1 事务并发时遇到的问题

可参考:大白话讲解脏写、脏读、不可重复读和幻读,事务并发执行时,可能会遇到一些问题,按照严重性从高到低排序:

  • 脏写(Dirty Write):一个事务修改了另一个没有提交的事务的值;
  • 脏读(Dirty Read):一个事务读到了另一个未提交的事务的值;
  • 不可重复读(Non-Repeatable Read):一个事务内多次读取同一个数据,出现前后两次数据不一致的情况;
  • 幻读(Phantom):在一个事务内多次查询到符合条件的记录数量不一致。

1.2 SQL定义的4种隔离级别

SQL标准中,定义了4种隔离级别,隔离级别越低,越严重的问题就可能发生,隔离级别从低到高如下所示:

隔离级别 脏写 脏读 不可重复读 幻读
READ UNCOMMITTED(未提交读) No 可能 可能 可能
READ COMMITTED(已提交读) No No 可能 可能
REPEATABLE READ(可重复读) No No No 可能
SERIALIZABLE(串行化) No No No No

从上表可以看出:

  • 所有的级别都不能忍受脏写;
  • 串行化安全级别最高,所有的问题都不会出现,但是串行化的效率太低,拉低了系统性能。

1.3 MySQL中支持的四种隔离级别

MySQL虽然支持以上的四种隔离级别,但是和SQL标准中的区别在于:MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象:

  • 针对快照读(普通select语句),通过MVCC方式解决幻读;
  • 针对当前读(select ... for update 等语句),通过加next-key lock解决幻读。

MySQL中的默认隔离级别是REPEATABLE-READ

mysql> show variables like 'tx_isolation';
+---------------+-----------------+
| Variable_name | Value           |
+---------------+-----------------+
| tx_isolation  | REPEATABLE-READ |
+---------------+-----------------+
1 row in set (0.01 sec)

2. 多版本并发控制 MVCC

MVCC(Multi-Version Concurrency Control),即「多版本并发控制」,指的是在使用 READ COMMITTDREPEATABLE READ 这两种隔离级别的事务在执行普通的 SELECT 操作时,访问记录的版本链的过程。

通过 MVCC,可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

2.1 版本链

对于InnoDB存储引擎来说,其记录中都含有两个必须得隐藏列:trx_idroll_pointer

  • trx_id:每次一个事务对某条记录进行改动时,都会把该事物的id赋值给trx_id列;
  • roll_pointer:每次对某条记录进行改动时,都会把旧版本的记录写入到undo日志中,然后roll_pointer列就相当于一个指针,指向undo页中原来的记录,如果有多次修改,那么就会形成一个版本链。

2.2 ReadView

对于READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都必须保证读到的已经提交了的事务修改多的记录,也就是说,假如另一个事务修改了记录,但是尚未提交,那么是不能读取到其版本的。核心问题就是:需要判断一下版本链中哪个版本是当前事务可见的。

为此,引入了一个ReadView的概念,其组成如上图所示:

  • m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表

  • min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是 m_ids中的最小值。

  • max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值,注意这就不是m_ids中的最大id了。

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

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

  • 如果记录的 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(多版本并发控制)。

在MySQL默认的隔离级别REPEATABLE READ下,在事务启动后第一次读取数据时生成一个ReadView,这就保证在此级别下,整个事务期间读到的记录都是第一次读之前的记录,保证不会存在不可重复读问题。

在隔离级别READ COMMITTD下,每次读数据之前,都会生成一次ReadView,这就导致其并不能避免不可重复读问题,但是可以避免脏读。

2.3 如何尽量避免幻读 / 是否完全避免幻读了?

MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:

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

这两个解决方案是很大程度上解决了幻读现象,但是还是有个别的情况造成的幻读现象是无法解决的。

例子A

  • 对于快照读, MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。

T1:

-- 事务A
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM `table_a` WHERE aid = 4;
Empty set (0.00 sec)

T2:

-- 事务B
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO `table_a` (`aid`,`a_name`,`age`) VALUES (4,'name_b',18);
Query OK, 1 row affected (0.01 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

T3:

-- 事务A
mysql> UPDATE `table_a` SET `a_name` = 'test_a' WHERE `aid` = 4;
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4  Changed: 4  Warnings: 0

mysql> SELECT * FROM `table_a` WHERE aid = 4;
+-----+--------+-----+
| aid | a_name | age |
+-----+--------+-----+
|   4 | test_a |  18 |
+-----+--------+-----+
1 row in set (0.00 sec)

你看,以上就产生了幻读,虽然这种场景不太可能存在,事务A不太可能会出现,毕竟很少会有事务去更新一条不存在的记录。

例子B

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

T1:

-- 事务A
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM `table_a` WHERE `aid` > 2;
+-----+--------+-----+
| aid | a_name | age |
+-----+--------+-----+
|   3 | test_a |   3 |
|   4 | test_a |  18 |
|   5 | name_a |  18 |
+-----+--------+-----+
3 rows in set (0.00 sec)

T2:

-- 事务B,单条语句也默认为事务
mysql> INSERT INTO `table_a` (`aid`,`a_name`,`age`) VALUES (6,'name_b',33);
Query OK, 1 row affected (0.01 sec)

T3:

-- 事务A
mysql> SELECT * FROM `table_a` WHERE `aid` > 2 for update;
+-----+--------+-----+
| aid | a_name | age |
+-----+--------+-----+
|   3 | test_a |   3 |
|   4 | test_a |  18 |
|   5 | name_a |  18 |
|   6 | name_b |  33 |
+-----+--------+-----+
4 rows in set (0.00 sec)

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

所以说,MySQL的REPEATABLE READ隔离级别只是尽最大可能地避免了幻读,但是没有完全避免!

3. 小结

其实本质上,MVCC的版本链控制,是一个乐观锁设计,保证数据库的高效性。

4. 参考文档

MySQL事务和MVCC

小林coding-事务隔离级别是怎么实现的?

《MySQL是怎样运行的》从根儿上理解 MySQL

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论