MVCC(Multiversion Concurrency Control),多版本并发控制。它和undo log中的版本链息息相关,MVVC通过数据行的多个版本来实现数据库的并发控制。
简单的说就是当前事务查询另一个事务正在更改的行(如果此时读取就会发生脏读),不用加锁等待,而是读取该数据的历史版本,降低响应时间。
MVVC是通过undo log和Read View两种技术实现的。
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读 ,而这个读指的就是快照读 , 而非当前读。当前读实际上是一种加锁的操作。
当前读读取的记录一定是最新的数据,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
加锁的读被称为当前读,还有数据的增删改都是要先读取数据的,这一读取过程也是当前读。
SELECT * FROM t LOCK IN SHARE MODE; # 共享锁
SELECT * FROM t FOR UPDATE; # 排他锁
UPDATE SET t..
快照读又叫一致性读,读取的是数据行的快照版本。在MySQL中,普通的select语句(不加for update或lock in share mode的select语句)默认就是使用的快照读,不加锁。
SELECT * FROM table WHERE ...
之所以这样,是因为快照读可以避免加锁操作,降低开销。
当事务的隔离级别是串行时,快照读就没有用了,会退化为当前读。
隔离级别:
在MySQL中默认的隔离级别就是可重复读RR,可以解决不可重复读问题,在MySQL中,特别的还额外支持解决幻读问题。
它是如何解决幻读问题的呢?有两种方式:
undo log版本链:
对应InnoDB来说,聚簇索引中的每个记录都包含了两个必要的隐藏字段:
举例:
有一个id为8的事务创建了一条数据,那么该记录的示意图大概如下:
假设之后两个id分别为10、20的事务对这条记录进行update操作,流程如下:
事务10 | 事务20 |
---|---|
BEGIN; | |
BEGIN; | |
UPDATE student SET name='李四' WHERE id=1; | |
UPDATE student SET name='王五' WHERE id=1; | |
COMMIT; | |
UPDATE student SET name='赵六' WHERE id=1; | |
UPDATE student SET name='钱七' WHERE id=1; | |
COMMIT; |
每次修改都会生成一个undo log日志,每个日志都相互链接,构成版本链,此时该条数据的示意图如下:
每个版本中还包含生成该版本时对应的事务id 。
有了undo log就可以读取到记录的历史版本,那么在什么情况下,读取哪个版本的记录呢?这就用到了Read View,它帮我们解决了行的可见性问题。
Read View就是当某个事务在使用MVVC机制进行快照读操作时产生的读视图。该视图是数据库当前所有活跃事务id(还未提交的事务)组成的列表的一个快照。
四种隔离级别里,读未提交和串行化是不会使用MVVC的,因为读未提交直接读取某个数据的最新数据即可,串行化是通过加锁来读的。
读已提交和可重复读都必须保证读到的数据都是其他事务提交了的,所以,其他事务修改了数据但是还未提交,我们不能够访问该数据,但可以通过MVVC机制读取该记录的历史版本,核心问题就是需要判断版本链中的哪条历史版本是当前事务可见的,这也是ReadView要解决的问题。
Read View包含4个比较重要的内容:
只有事务对表中的记录做修改时才会为事务分配事务id,否则一个事务中只有读操作,该事务的id默认为0。
注意:low_limit_id并不是trx_ids中的最大值,事务id是递增分配的。比如,现在有id为1, 2,5这三个事务,之后id为5的事务提交了。那么一个新的读事务在生成ReadView时, trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是6。
当某个事务有了Read View,访问某条记录时,需要按照下面的步骤判断该记录的哪个版本可见:
了解了这些概念之后,我们来看下当查询一条记录的时候,系统如何通过MVCC找到它:
在隔离级别为读已提交时,一个事务中的每一次SELECT查询都会重新获取一次Read View,而可重复读是第一SELECT操作才会生成Read View,之后的查询操作复用这一个。
导致这两种的差距是因为:可重复读要保证一个事务中相同的SELECT读取的内容是相同的。
现在有两个事务id分别为10、20的事务在执行:
-- id为10的事务
begin;
update t set name='李四' where id=1;
update t set name='王五' where id=1;
-- id为20的事务
更新其他行的数据
此刻,表中id为1的记录得到的版本链表如下所示:
此时新来一个事务执行如下操作:
begin;
select * from t where id=1;
-- 事务10、20未提交
查询到的结果为张三。
具体的过程如下:
接下来,再将id为10的事务进行commit提交。然后id为20的事务来更新记录:
begin;
-- id为20的事务
update t set name='赵六' where id=1;
update t set name='钱七' where id=1;
此时版本链更新为:
再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id 为1的记录,得到的结果为name=王五的那条记录。执行过程如下:
注意:READ COMMITTED,每次读取数据前都生成一个新的ReadView。
假如此时id为10的事务和id为20的事务正在修改,都未提交,修改内容和前面的一样,但是还未提交,此时当前事务做一个查询。
步骤为:
此时,id为10的记录提交事务。
当前事务又需要select id为1的记录,步骤为:
注意:REPEATABLE READ,每次读取都复用第一次生成的Read View
假设现在有一条数据, id为1
当前活跃的事务有10和20。
此时当前事务启动了,执行如下SQL语句:
begin;
select * from student where id>=1;
在开始前生成Read View,内容如下:creator_trx_id=0,trx_ids= [10,20] , up_limit_id=10, low_limit_id=21。
由于id大于等于1的数据只有一个,且该数据的trx_id为8,小于up_limit_id,所以可以读取到。
在这之后id为10的事务新增了一行数据,增加了id为2的数据,且提交了。
此时当前线程继续查找id>=1的数据,因为是可重复读,复用刚刚的Read View。
得到两行数据,但是因为id为2的数据trx_id为10,该值在Read View的trx_ids中存在,所以该记录对当前事务不可见,所以最后查询到的数据只有一条记录。
如果当前事务再插入id为2的数据就插不进去,所以说MVVC只解决了一半的幻读问题。