0. 简介
并不是所有MySQL的引擎都支持事务,现阶段只有InnoDB
和NDB
支持事务。
事务必须满足以下四点:
- 原子性:通过
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 COMMITTD
和 REPEATABLE READ
这两种隔离级别的事务在执行普通的 SELECT
操作时,访问记录的版本链的过程。
通过 MVCC,可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
2.1 版本链
对于InnoDB存储引擎来说,其记录中都含有两个必须得隐藏列:trx_id
和roll_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
列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务可见。
- 如果记录的 trx_id 在
当判定该版本记录不可见时,会沿着版本链一直读下去,直到遇到可见的版本,或者没有记录。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 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