【MySQL】我理解的多版本并发控制(Multi-Version Concurrency Control,MVCC)

1. 什么是 MVCC ?( what )

首先对于数据的并发读写,需要承认的基本事实是:读-读不会冲突,写-写和读-写会冲突。读-读不冲突,因此无需做任何互斥处理;写-写冲突会引起丢失更新问题,读-写冲突可能会造成事务的隔离性问题,可能遇到脏读、幻读、不可重复读,因此对于写-写冲突和读-写冲突需要进行互斥处理。 MVCC 是 MySQL InnoDB 存储引擎在读已提交( RC )和可重复读( RR )隔离级别下解决读-写冲突的方式。为了方便,下文都以 MySQL 来代替 MySQL InnoDB 的表述。

2. 为何使用 MVCC ?( why )

解决冲突的过程实际上是对共享资源进行互斥访问的过程,要实现对共享资源的互斥访问通常需要借助锁。概念维度可以将锁分为悲观锁和乐观锁,区别如下:

悲观锁:在操作共享资源时悲观,认为操作会产生并发问题(会有其他线程对共享资源进行修改),因此会对共享资源上锁,在操作完成后解锁。
适用场景:适用于并发冲突多、多写的场景,但效率比较低。

乐观锁:在操作共享资源时乐观,认为操作不会产生并发问题(不会有其他线程对共享资源进行修改),因此不会对共享资源上锁。在修改时会判断其他线程在这之前是否对资源进行了修改,根据判断结果决定继续或放弃本次修改,一般会使用版本号机制或 CAS 实现。
适用场景:适用于并发冲突比较少、多读的情况,可以省去加锁和释放锁的开销,加大了系统的吞吐量。但如果经常发生冲突,乐观锁会占用 CPU 不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

注:乐观锁与悲观锁之间并非有此无彼的关系,很多场景下会将二者进行搭配使用,如 JDK 中基于 AQS 并发同步框架实现的 ReentrantLock 互斥锁,是一种典型的悲观锁, AQS 底层会利用 CAS 操作方式进行锁抢占和释放、对抢占锁的线程进行管理等。

使用悲观锁解决读-写冲突是完全可行的,对于数据库单条数据记录而言,读操作的频率一般要高于写操作的频率,这样读操作和写操作均加悲观锁的方式会带来系统吞吐问题:因少量写操作而影响了多数读操作效率。 MVCC 即乐观锁在 MySQL 数据库中的应用,如文章开头所述,是 MySQL 在读已提交( RC )和可重复读( RR )隔离级别下解决读-写冲突的方式,当读-写操作同时进行时,读不加锁,采取读快照的方式实现读-写分离,提高系统的吞吐。

3. 如何实现 MVCC ?( How )

MVCC 让读-写操作分离,以不加锁读快照的方式解决读-写冲突,那么直观上实现 MVCC 的关键就在于生成准确无误的数据快照。生成准确无误的数据快照,依赖于隐藏的列、 undo log 和 ReadView 。

undo log

多版本并发控制中的多版本指的是每条数据记录存在多个不同的版本,之所以存在多个不同的版本是为了让不同事务根据隔离级别看到不一样的数据记录,这些不同版本的数据记录存储于 undo log 中。这里需要承认的一个事实是:只有新增、修改、删除会引起版本新增,查询不会引起版本新增。

隐藏的列

MySQL 存储引擎 InnoDB 会在每行数据记录后面增加三个隐藏字段:

  • DB_TRX_ID:插入或修改该记录的事务ID,删除可以等同于修改处理。区别在于 undo log 中会通过标识位来表明是删除还是修改,删除时会给行记录的 info_bits 打上删除标识,由专门的 purge 线程来执行真正的删除操作。

  • DB_ROLL_PTR:回滚指针,指向 undo log 记录,undo log 中保存每条记录每次的改动记录。undo log 中的每个数据记录版本又指向上一个数据记录版本,通过 DB_ROLL_PTR 链接形成数据记录的多版本链。

  • DB_ROW_ID:随着插入记录而递增,没有定义主键时,该列充当隐藏的主键。(该字段实际上与 MVCC 关系并不大)

undolog.jpg

ReadView

ReadView 是某一时刻事务系统的快照。包括以下几个主要的成员:

  • trx_ids:当前事务系统中活跃的事务 Id 列表,即还未 commit 的事务 Id 列表。

  • up_limit_id:trx_ids 中的最小值,版本链中 DB_TX_ID 小于该值的数据记录对当前事务都可见。

  • low_limit_id:生成 ReadView 时系统将要分配给下一个事务的 Id 值,当前事务不能看到版本链中 DB_TX_ID 大于等于该值的数据记录。

  • creator_trx_id:生成 ReadView 时,当前事务的 Id。

从版本链中读数据实际上是沿着 DB_ROLL_PTR 指针遍历查找第一条符合条件的版本记录, ReadView 是用于判断当前版本记录是否符合条件的依据,具体条件如下:

  1. 被访问版本的 DB_TX_ID 与 ReadView 中的 creator_trx_id 相同,即当前事务在访问它自己修改过的记录,该版本可以被当前事务读取。
  2. 被访问版本的 DB_TX_ID 小于 ReadView 中的 up_limit_id 值,即生成该版本的事务在当前事务生成 ReadView 前已经 commit 了,该版本可以被当前事务读取。
  3. 被访问版本的 DB_TX_ID 大于等于 ReadView 中的 low_limit_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,该版本不可以被当前事务读取。
  4. 被访问版本的 DB_TX_ID 在 ReadView 的 up_limit_id 和 low_limit_id之间,那就需要判断一下 DB_TX_ID 是不是在 trx_ids 列表中。如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被读取;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被读取。

RC 隔离级别下的 MVCC

RC 隔离级别解决了脏读问题,存在不可重复读、幻读问题。RC 隔离级别级别下,同一事务里每次 Select 查询操作都会生成新的 ReadView ,其他事务在 commit 后,系统事务 Id 的变化会体现在最新的 ReadView 中,这样就保证了当前事务可以读取到其他事务已经修改并 commit 的数据记录。

RR 隔离级别下的 MVCC

RR 隔离级别解决了不可重复读问题,存在幻读问题。与 RC 隔离级别下 MVCC 的区别仅仅在于生成 ReadView 的时机不同,RR 隔离级别下 ReadView 只在事务开始时生成,后续都根据这份 ReadView 进行数据读取。另外幻读是对于某个范围内的数据而言,以上所谈 MVCC 仅仅是针对单行记录的读取操作。MySQL 在 RR 隔离级别下,利用 MVCC 可以解决快照读时的幻读,对于当前读时的幻读,需要使用 Next-key 锁 (Rcord 锁 + Gap 锁) 解决( lock in share mode / for update 加锁 )。但是 RR 隔离级别无法完全解决幻读,在同一事务中,快照发生变更时还会发生幻读,以下是一个典型的 RR 隔离级别下幻读的例子:

             事务 A                                            事务 B
              
> start transaction;                            > start transaction;
Query OK, 0 rows affected (0.0654 sec)          Query OK, 0 rows affected (0.0645 sec)
Empty set (0.0648 sec)
(END)

> select * from t where id = 1;
Empty set (0.0659 sec)
(END)

                                                > insert into t (id, name) values (1, name_1);
                                                Query OK, 1 row affected (0.0654 sec)
                                         
                                                > commit;
                                                Query OK, 0 rows affected (0.0742 sec)
                                         
> update t set name = 'new-name' where id = 1;
Query OK, 1 row affected (0.0645 sec)

> select * from t where id = 1;
+----+----------+
| id | name     |
+----+----------+
|  1 | new-name |
+----+----------+
1 row in set (0.0648 sec)                                      
复制代码

上面的例子中,事务 A 最后 select 读到了 id = 1 的新数据,而第一次没有读到,这符合幻读的定义。A 在最后一次 select 之前进行了 update ,update 将记录的版本号更新成了当前事务的 Id ,符合快照读中根据 ReadView 进行判断的条件,读到了事务 B 插入的数据。但是业务层面来说事务 A 进行 update 操作的前提是默许存在 id = 1 的记录,这样是否可以认为这是一种预料之中可以忽略的幻读呢?

4. 总结

  • MVCC 在 RC 和 RR 工作,让读写分离,以读不加锁的方式提高了 MySQL 吞吐。
  • RR 隔离级别下的 MVCC 可以解决快照读的幻读问题,当前读下的幻读需要采用加锁的方式解决。
  • RR 隔离级别无法完全解决幻读,同一事务在前后两次快照读中间包含了修改幻读行时仍然会发生幻读。
  • 文章只谈了对于 MVCC 的一点粗浅理解,如有不足之处,欢迎指点。
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享