一、概述
MVCC: Multi-Version Concurrency Control 多版本并发控制
本质上是一种行级锁的变种,在MySQL、PosgreSQL、Oracle中都有运用。MVCC可以由乐观锁或悲观锁来实现,事实上,不同的存储引擎的实现是不同的,但目的都是提高数据库在高并发下的吞吐量。
二、知识储备
MySQL的隔离级别:
Read Uncommitted(读未提交)
一个事务可以看到其他未提交事务的执行结果。
会导致脏读(Dirty Read)、不可重复读(Nonrepeatable Read)、幻读(Phantom Read)问题。
本隔离级别很少用于实际应用,因为脏读在大多数业务场景下是不允许的。
Read Committed(读已提交)
一个事务只能看见已经提交事务所做的改变。
这种隔离级别会出现不可重复读和幻读。因为某一事务期间,其他事务会有新的commit,所以同一select可能返回不同结果。
Repeatable Read(可重复读)
确保多个事务在并发读取数据时,会看到同样的数据内容。
这种隔离级别会出现幻读 。
这是MySQL的默认事务隔离级别。
Serializable(串行化)
最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个数据行上加上读写锁。大量的超时现象和锁竞争,因此性能较差。
会出现的几种问题:
更新丢失(Lost Update):当多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题 —— 最后的更新覆盖了其他事务所做的更新。解决方法是一个事务对数据进行更改但还未提交时,其他事务不能访问修改同一个数据。
脏读(Dirty Read):一个事务正在对一条记录做修改,在这个事务未提交前,这条记录的数据就处于不一致状态,若此时另一个事务也来读取同一条记录,读取到了这些尚未提交的脏数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被叫做 “脏读”。
不可重复读(Non-Repeatable Read):一个事务按时序先后多次读取某些数据,在此期间别的事务做了commit,数据已经发生了改变、或某些记录已经被删除了,导致同一事务中多次读取的数据不一样,这种现象叫做“不可重复读”。
幻读(Phantom Read):一个事务按时序多次检索数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为 “幻读”。
!!! ATTENTION !!!
不可重复读和幻读的区别在于,前者是数据内容改变,后者是条目数改变。记录删除在MySQL中是软删除,每条记录的头信息中有一个隐藏的特定位标记bit(deleted_flag)表示是否被删除1。可以使用optimize或alter指令来真正清理磁盘空间2。
三、MVCC的引入
本文聚焦于 MySQL 中的 MVCC 实现,从 《高性能 MySQL》一书中对 MVCC 的介绍可知:
- MySQL 中 InnoDB 引擎支持 MVCC;
- 应对高并发事务, MVCC 比单纯的加行锁更有效, 开销更小;
- MVCC 只在读已提交(Read Committed)和可重复读(Repeatable Read)隔离级别下起作用,其他两个隔离级别和MVCC不兼容;(对于 Serializable 隔离级别,是通过加锁互斥来访问数据,因此不需要 MVCC )
- MVCC 既可以基于乐观(Optimistic)锁又可以基于悲观(Pessimistic )锁来实现。
四、InnoDB MVCC实现
版本控制
每一行记录有三个隐藏键,分别为DATA_TRX_ID、DATA_ROLL_PTR、DB_ROW_ID,其中:
DATA_TRX_ID
记录最近更新这条行记录的事务 ID,大小为 6 个字节
DATA_ROLL_PTR
表示指向该行回滚段(rollback segment)的指针,大小为 7 个字节,InnoDB 便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在 undo 中都通过链表的形式组织。
DB_ROW_ID
行标识(隐藏单调自增 ID),大小为 6 字节,如果表没有主键,InnoDB 会自动生成一个隐藏主键,即此列。
举个🌰
假设当前有一条记录,主键ID为1,插入这条记录的事务ID为100,此时有一个事务Tr1(事务ID为105)想要update这条记录,那么事务Tr1的流程为:
- 对 DB_ROW_ID = 1 的这行记录加排他锁;
- 把此次修改的反向操作记录到 undo log 3中,DB_TRX_ID (100)和 DB_ROLL_PTR 都不动;
- 修改该行的值,此时产生一个新版本,更新 DATA_TRX_ID 为105,将 DATA_ROLL_PTR 指向刚刚拷贝到 undo log 链中的旧版本记录。如果对同一行记录执行多次 Update,Undo Log 会组成一个链表,遍历这个链表执行记录的指令就可以将数据回滚到不同的版本上;
- 记录 redo log,包括 undo log 中的修改。
Insert和Delete操作也是类似的步骤。
ReadView的引入
现在我们有了版本控制,那么如何确定在某个时刻下,每个事务应该看见哪个版本呢?InnoDB使用ReadView,即快照这个概念来实现。
RR 下的 ReadView 生成
在 Repeatable Read 隔离级别下,每个事务执行第一个读请求时生成一份ReadView,后续所有的读请求都是复用这个 ReadView,由此解决了快照读模式下的脏读问题。需要注意的是,修改请求(update, delete, insert) 和ReadView没有关系,因为修改请求使用的是当前读而非快照读模式。
🌰
事务A在两次Select请求之间,事务B修改数据commit了,那么事务A的第二次Select依然读不到事务B修改后的数据。
RC下的ReadView生成
和RR的区别是,Read Commited隔离级别下,每一次的读请求都会生成一份ReadView,由于ReadView的更新,有可能造成前后读到的数据不一致,引入了脏读和幻读的问题。
🌰
在上面的例子中,事务A的第二次Select可以读取到事务B修改后的数据。
ReadView详解
ReadView中保存的 trx_sys 状态主要包括:
rw_trx_ids:当前活跃的事务ID数组,即ReadView初始化时当前未提交的事务列表;
low_limit_id:当前行最大事务ID。事务ID大于等于此值的事务对于view都是不可见的;
up_limit_id:rw_trx_ids 最小值,当前行最小活跃事务ID。事务ID小于此值的事务对于view一定是可见的;
low_limit_no:trx_no小于此值的undo log对于view是可以purge的。
那么我们可以根据当前事务ID判定可读数据版本了,假定当前行的DATA_TRX_ID为trx_id,判定逻辑如下:
- trx_id < up_limit_id:说明生成该版本的事务在 ReadView 生成前就已经提交了,所以该版本可以被当前事务访问。
- trx_id > low_limit_id:说明生成该版本的事务在生成 ReadView 后才生成,所以该版本不可以被当前事务访问。需要根据 Undo Log 链找到前一个版本,然后根据该版本的 DB_TRX_ID 重新判断可见性。
- trx_id 属于[up_limit_id, low_limit_id]:判断 trx_id 是否在 m_ids 列表中。如果在,说明创建 ReadView 时生成该版本所属事务还是活跃的,因此该版本不可以被访问,需要查找 Undo Log 链得到上一个版本,然后根据该版本的 DB_TRX_ID 重新判断可见性;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
此时已经得到了这条记录相对 ReadView 的可见结果。如果这条记录的 delete_flag 为 true,说明这条记录已被删除,返回空,否则返回值即可。
关于当前读
除了ReadView实现的快照读,InnoDB对修改操作使用的是当前读模式,即读取的是数据的最新版本,并且当前读的记录都会加上锁,保证其他事务不会再并发的修改这条记录。具体的语句包括:
- select...lock in share mode (共享读锁)
- select...for update
- update , delete , insert
当前读的实现方式
当前读使用next-key锁(行记录锁+Gap间隙锁)实现。
间隙锁:只有在Read Repeatable、Serializable隔离级别才有,锁定了范围空间的数据。假设id有3,4,5,锁定id>3的数据,是指的4,5及后面的数字都会被锁定,此时如果想要加入新的数据id=6,由于间隙锁的存在无法Insert成功,避免了幻读。
间隙锁规则:
- 对主键或唯一索引,如果当前读时,where条件全部精确命中(=或者in),这种场景本身就不会出现幻读,所以只会加行记录锁。
- 没有索引的列,当前读操作时,会加全表gap锁,生产环境要注意。
- 非唯一索引列,如果where条件部分命中(>、<、like等)或者全未命中,则会加附近Gap间隙锁。