MySQL 中的 MVCC 与事务隔离级别的实现
什么是 MVCC?
MVCC:Multi-Version Concurrency Control,多版本并发控制。MVCC 是一种在数据库管理系统中用来提高并发性能的机制。它主要用于在不加锁或减少加锁的情况下,实现事务的隔离性,从而允许多个事务并发读写数据而不会相互阻塞。
MVCC 的核心机制
场景引入
一张数据库表里面存了很多数据,现在有很多并发事务来访问或者修改数据。在并发事务情况下会出现脏读、不可重复读、幻读问题。
想要解决脏读,就需要读已提交的隔离级别,也就是说要想办法保证一个事务只能读到其他事务已提交的数据;其他事务没提交的数据,不应该被读取到。
想要解决不可重复读,就需要可重复读隔离级别,也就是说一个事务第一次读取之后,后面每次读到的数据都应该和第一次一样。即便后面有其他事务新提交了数据也不应该读取到。
MVCC 的工作机制
要想实现上述的需求,就需要用到 MVCC 了。
MVCC 维护了一份数据的多个版本,每个事务修改一次,就生成一个对应的版本,让不同的事务去读不同的版本。
在读已提交的隔离级别下,让事务去读取已经提交的数据版本,这样就能避免脏读;在可重复读的隔离级别下,让事务每次都读取同一个版本的数据,这样每次读到的就都一样了,就能避免不可重复读的问题。
具体操作
把修改的数据版本记录到 undo log 日志中,然后给表增加一个隐藏字段:回滚指针。
让回滚指针指向 undo log 日志,使用回滚指针将历史版本串联为一个链表。这样的话,想读哪个历史版本,沿着链表 一直找就可以了。

此时就引出了一个问题:一个事务来查数据,怎么知道我要查哪个版本的数据呢?
解决方案就是给每个事务分配一个事务 id,事务 id 自增分配。通过对比事务 id 的大小,就能知道哪个事务创建的早,哪个事务创建的晚。创建比较晚的事务修改的数据不让创建早的事务看到就行了。
所以我们需要给表再增加一个隐藏字段:修改数据的事务 id。谁修改了它,就把对应的事务 id 记录下来。

readview
除了 undo log 日志外,还需要一个东西:readview。readview 中存在 4 个重要的字段:
creator_trx_id
:创建当前 readview 的事务 id。m_ids
:创建 readview 时,当前数据库中存在但未提交的所有事务 id 列表。min_trx_id
:事务 id 列表中最小的事务 id。max_trx_id
:创建 readview 时,应该分配的下一个事务的 id。
这些字段的作用都体现在下图中:

readview 的本质就是描绘了一个创建当前事务时的事务 id 数轴。通过对比数据版本的 trx_id
在数轴的哪个位置就能知道这个数据版本是否对当前事务是可见的。
主要有以下的几种情况:
情况一:当前数据的 trx_id
值小于 readview 中的 min_trx_id
值

该情况下表明当前数据的事务 id 比未提交的最小的事务 id 还小,说明这个数据版本在创建 readview 之前就存在了。数据既然早就有了,那么这个数据版本对当前事务就是 可见的。
情况二:当前数据的 trx_id
值大于或等于 readview 中的 max_trx_id
值

如果记录的 trx_id
值大于等于 readview 中的 max_trx_id
的值,就说明这个数据版本是在 readview 创建后才启动某个事务生成的,该数据是新启动的事务创建的数据,所以该情况下的数据版本是对当前事务 不可见的。
情况三:当前数据的 trx_id
值在 min_trx_id
与 max_trx_id
之间,且存在于 m_ids
列表中

如果记录的 trx_id
值在 min_trx_id
与 max_trx_id
之间,且存在于 m_ids
列表中,则说明生成这个数据版本的事务还没有提交,没有提交的事务当然不能被看到了,所以该情况下的数据版本是对当前事务 不可见的。
情况四:当前数据的 trx_id
值在 min_trx_id
与 max_trx_id
之间,但不存在于 m_ids
列表中

如果记录的 trx_id
值在 min_trx_id
与 max_trx_id
之间,但不存在于 m_ids
列表中,则说明生成这个数据版本的事务已经提交了,已经提交的事务当然能被看到,所以该情况下的数据版本是对当前事务 可见的。
一句话总结 MVCC
每次修改数据就记录修改的事务 id 到隐藏字段,然后生成一个版本记录到 undo log 版本链中,通过回滚指针指向版本链,然后再通过对比 readview 和数据版本的事务 id,就能知道某个版本的数据对当前事务是否是可见的。
undo log 记录数据版本,readview 来判断数据版本对当前事务的可见性,这就是 MVCC。
事务隔离级别怎么实现?
读未提交
读未提交不用实现,多个并发事务同时执行就是天然的读未提交,这是说读的时候不用实现,但是写的时候还是需要加锁的,不能同时写一个数据。
串行化
串行化就是保证事务的单线程执行,直接加锁即可。只有拿到锁的事务才能够去执行,这样就实现了串行化。
可重复读
每次读取数据都会读取到第一次的数据,即使后面有新的事务进行了数据的修改,还是会读取到第一次相同的数据,这就是可重复读。
怎么保证可重复读每次都读取到相同的数据呢?
可重复读会在第一次 select
的时候生成一个 readview。readview 是用来判断数据版本是否对当前事务可见的,而可重复读是会 复用第一次生成的 readview 的。当生成这个 readview 的时候,哪些版本的数据对当前事务可见就已经固定下来了,后续每次通过同一个 readview 判断数据的可见性,所以每次都读的是同一个数据版本。
总结来说就是可重复读是通过 MVCC 加复用第一次生成的 readview 来实现的。该隔离级别就是 MySQL 默认的事务隔离级别,解决了不可重复读的问题,不过仍然会出现幻读的现象。
读已提交
读取数据的时候,如果后面有新的事务进行了数据的修改并提交,那么就会读取到最新提交的数据,这就是读已提交。
读已提交的本质就是每次 select
的时候就生成一个新的 readview,每次都读取新生成的 readview,这样就能保证每次都可以读取到新提交的数据版本。
可重复读是怎么解决幻读的?
可重复读是 MySQL 默认的事务隔离级别,解决了脏读和不可重复读的问题,但是仍然会出现幻读的现象。
我们该怎么解决幻读?
最直接的方案就是使用串行化隔离级别,不过串行化性能太差,几乎不会使用。所以我们就需要考虑在可重复读的隔离级别下解决幻读,这就涉及到了快照读和当前读。
快照读
在可重复读的隔离级别下,当执行普通的 select
语句,则会通过 MVCC 去读,每次都会读到同一个数据版本,这就是快照读。普通的 select
语句通过快照读去避免加锁,每次都读快照,别的事务干了什么是读不到的,因此就不会产生幻读。
但是也不是永远读不到的。比如你先开启事务,别人后开启事务,但是别人先插入了 id 为 1 的数据,你读不到,然后你以为 id 为 1 的数据不存在,你就插入了 id 为 1 的数据,此时是插入失败的。所以对于插入这种操作必须要读到最新数据才能判断能不能让你插入,这就是当前读。
当前读
诸如 select ... lock in share mode
的共享锁或 select ... for update
、update
、insert
、delete
的排他锁等的操作就是当前读。当前读读取的是最新版本的数据。
执行当前读 MySQL 会使用临键锁去锁住数据和数据之间的间隙。加了锁之后就不能执行插入操作了,这样就不会出现幻读问题。
举例来说,select * from user where id >= 100 for update
,查询 id 大于等于 100 的数据,此时使用 ... for update
执行当前读。MySQL 会先锁住 id = 100
的数据,让别的事务无法修改;再锁住 (100, ∞)
的区间,让别的事务无法插入 id 大于 100 的数据,这样就避免产生了幻读。
总结
整体来说就是:在可重复读的隔离级别下,是通过 MVCC 快照读和当前读加锁 的方式来解决幻读的。
先快照读后当前读产生的幻读
上述的方案一定程度上解决了可重复读的隔离级别下出现的幻读问题,但是在某些情况下还是会出现幻读的问题,比如 先快照读再当前读。
- 案例一
- 事务 A 执行普通的
select
语句查询id = 1
的数据,此时表中不存在该数据,所以查不出来。 - 然后事务 B 插入
id = 1
的数据,并提交事务。 - 事务 A 再次使用普通的
select
语句查询id = 1
的数据,还是查不出来,因为这是快照读的方式。 - 但是事务 A 使用
select ... for update
当前读去查,就能查到id = 1
的数据。
先快照读,再当前读就出现了幻读。
- 案例二
假设系统中用户名是不允许重复的,那我们可以给用户名添加唯一索引。
- 事务 A 使用普通的
select
语句查询是否存在用户名为zhangsan
的用户,此时表中不存在该数据,所以查不出来。 - 然后事务 B 插入了用户名为
zhangsan
的用户,并提交了事务。 - 此时事务 A 插入了
username = zhangsan
的数据发现插入不了,提示用户名为zhangsan
的已存在。 - 事务 A 再次使用普通
select
语句查询用户名为zhangsan
的用户,该方式是快照读,所以还是查不到数据。 - 此时就出现了问题:明明查询到没有用户名为
zhangsan
的数据,但是一插入就提示已存在,这就是幻读现象。 - 但是更骚的来了,事务 A 查不到
username = zhangsan
,但是直接使用update
语句却能够修改用户名为zhangsan
的数据。 - 就是这么诡异,你查不到无法插入,但是可以修改,修改完后再去查就能查到了。
这也是一种先快照读,再当前读出现的幻读现象。
那该怎么解决这种现象下出现的幻读?
解决思路就是:在一个事务操作某张表的时候,不允许其他事务也操作这张表标即可。也就是说,要么给表加表锁,要么给表中的数据加行锁,使用 select ... for update
即可。
- 加表锁
1 | -- 事务 1 |
- 加行锁
1 | -- 事务 1 |