MySQL知识点总结(四)——MVCC
- 三个隐式字段
- row_id
- trx_id
- roll_pointer
- undo log
- read view
- MVCC与隔离级别的关系
- 快照读和当前读
MVCC全称是Multi Version Concurrency Control,也就是多版本并发控制。它的作用是提高事务的并发度,通过MVCC机制,数据库可以不通过加锁,也能保证事务的隔离性。MySQL的InnoDB存储引擎也有自己的MVCC机制的实现,通过MVCC机制保证了“读已提交”和“可重复读”两个隔离级别下的隔离性。
要理解MVCC,就要理解InnoDB里面的三样东西:“数据行记录中的三个隐式字段”、“undo log”、“read view”。理解了这三个东西,也就是理解了MVCC。
三个隐式字段
当我们使用InnoDB作为我们某个库表的存储引擎时,我们添加到该库表中的数据行记录,InnoDB都会默认给该行记录添加三个隐式字段,这三个隐式字段分别是“row_id”、“trx_id”、“roll_pointer”。
row_id
这个是InnoDB为每一个行记录生成的一个唯一的id,它和主键一样,都可以唯一标识一条行记录。当我们没有给我们的库表定义主键、也没有定义唯一索引时,InnoDB就会拿它来作为库表的主键。
于是,InnoDB中库表主键的选取,首先看库表是否有显式定义主键列,如果有,那么当前库表的主键就是我们显式定义的主键列;如果没有定义主键,但是我们定义了唯一索引,那么当前库表的主键就是我们定义的唯一索引(多个唯一索引,选其中一个);如果我们没有定义主键列,也没有定义唯一索引,那么InnoDB就会把row_id作为当前库表的主键。
trx_id
“trx_id”是事务id,记录的是当前行记录是被哪个事务修改提交的。
InnoDB会为每个开启的事务分配一个递增的id,用于唯一标识一个事务,当某个事务修改了某个行记录时,就会在这个行记录的trx_id隐式字段中记录当前事务的id。
roll_pointer
roll_pointer是回滚指针,当一个事务对某库表的一条行记录进行修改时,会把该行记录先拷贝到undo log日志中作为一个历史版本,然后再对该行记录进行修改,并且使用一个roll_pointer指针指向undo log中该行记录的历史版本。
undo log
undo log是InnoDB的回滚日志,用于事务回滚。每次对库表进行增删改,InnoDB都会把涉及到的行记录的历史版本拷贝到undo log中,然后使用行记录中的隐式字段“roll_pointer”指向undo log中该行记录的最近的版本,这样,undo log就形成了一个链表,沿着roll_pointer回滚指针,我们可以不断的往前追溯某个行记录的历史版本。
有了undo log,就可以实现事务的可重复读,只要当前事务通过某种机制记住该行记录哪个历史版本对于自己是可见的,然后每次读取该行记录时都读取这个历史版本,就可以实现可重复读,其他事务也可以照常进行更新操作,不会被阻塞。
而这个机制就是“read view”,read view记录了当前活跃的事务id,也就是已开启但还未提交的事务对于的事务id。有了“read view”,当前事务读取某库表的行记录时,就可以沿着roll_pointer指针往前进行遍历,拿到该行记录的每个版本对应的trx_id字段,在read view中进行比对,如果发现trx_id在read view中是存在的,那说明这个版本是当前某个活跃事务修改(但未提交)的,因此对于当前事务尚不可见,直到遍历到某个版本,发现在read_view中不存在,那么就可以读取这个版本。
read view
“read view”是一种快照,它不仅记录了当前有哪些事务正处于活跃状态,还记录了当前活跃事务中最小的id,以及下一个开启的事务将会被分配的事务id。
比如当前活跃的事务对应的事务id是5、4、3,那么read view就会通过一个列表trx_ids记录[3,4,5]。通过trx_ids可以得知,当最小的活跃事务id是3,InnoDB用一个变量up_limit_id记录这个3,然后下一个开启的事务被分配的事务id是6,InnoDB用一个变量low_limit_id记录这个6。
这样,通过read view不但可以得知当前活跃事务,也可以知道在当前事务开启前已经提交的最新事务是多少,比如这里的read view记录的时[3,4,5],那就可以知道在当前事务开启以前,最新提交的事务是2,那么trx_id小于等于2(trx_id < up_limit_id)的行记录对于当前事务都是可见的。
除此以外,也可以知道下一个要开启的事务被分配的事务id是多少,比如这里比如这里的read view记录的时[3,4,5],那就可以知道下一个要开启的事务会被分配事务id为6。如果在当前事务开启之后,又开启了其他事务,并且提交了修改,那么被修改的行记录的trx_id就是大于等于6的(trx_id >= low_limit_id),对于当前事务来说是不可见,只能沿着roll_pointer往前追溯,读取历史版本。
因此,当一个事务读取某库表的某行记录时,这个可见性的判断逻辑就是这样:
- 判断当前记录的trx_id是否小于up_limit_id:如果是,那么当前行记录对于当前事务是可见的;如果不是,执行下一步的判断逻辑(步骤2)。
- 判断trx_id是否大于等于low_limit_id:如果是,那么当前行记录对于当前事务是不可见的,只能沿着roll_pointer指针往前遍历,读取当前行记录的上一个历史版本,并且回到步骤1重新比对;如果不是,那么执行下一步的判断逻辑(步骤3)。
- 判断trx_id是否在trx_ids列表中:如果是,那么表示当前行记录是当前某个活跃的事务修改的,执行下一步判断逻辑(步骤4);如果不是,那么表示当前行记录是在当前事务开启并生成read view前就已经提交了的,那么对于当前事务来说可见,读取该版本的行记录。
- 判断trx_id是否是当前事务自己的id:如果是,那么表示是自己做的修改,还是可见的;否则对于当前事务来说也是不可见,只能沿着roll_pointer指针往前遍历,读取当前行记录的上一个历史版本,并且回到步骤1重新比对。
MVCC与隔离级别的关系
我们知道隔离级别从低到高有四个:读未提交、读已提交、可重复读、串行化。在InnoDB中,读已提交和可重复读这两个隔离级别就是通过MVCC保证的。
在读已提交隔离级别下,事务的每次SQL都会生成一个新的read view,这是它可以读到其他事务最新提交的修改的原因。比如现在有一个事务A读取某库表id为5的这一条记录,读到【name】字段的值“zhagnsan”。此时,另一个事务B修改了该库表id为5的这一行记录的【name】字段为“lisi”,并提交了。此时事务A再次读取该库表id为5的这一行记录,在读取前,会重新生成read view,由于事务B已经提交了,活跃事务列表trx_ids中就不包含事务B的事务id了,因此事务B修改id为5的行记录的【name】字段为“lisi”,是可以被事务A读到的,这就是不可重复读问题出现的原因。
而在可重复读隔离级别下,read view只在当前事务开启后,第一次发起读操作时生成一遍,后续就不再生成。也就是说,在可重复读隔离级别下,一个事务的read view一旦生成,后续就不再改变,即使中间有某个活跃的事务修改数据并提交了,当前事务的read view也不会发生变化,再次发起读操作时也不会再重新生成read view,因此read view的trx_ids中还是记录着这个已提交的事务的事务id,因此该修改对于当前事务就不可见,不会被当前事务读取到。
快照读和当前读
上面这种通过MVCC读取一个行记录的历史版本的做法,就是快照读,也就是说我们读到的是数据的历史版本。
而当前读则是保证读到的一定是当前行记录的最新版本,那什么时候会触发快照读,什么时候会触发当前读呢?
当一个事务开启以后,一般的select语句都是快照读,只有显式地添加“lock in share mode”或者“for update”的select语句才会进行当前读,因为这两个语句是加锁的语句,都已经要加锁了,就没有必要走MVCC的逻辑了。
除此以外,“update”、“insert”、“delete”等SQL也会触发当前读,因为增删改这种写操作必然是要基于最新的行记录的,如果对一个历史版本的数据进行修改是毫无意义的,并且写操作是需要保证绝对的隔离性的,否则就会发送更新丢失,因此写操作也会加锁,那么就会触发当前读。