张喜硕学长以前讲过一篇MySQL RR 与 锁,在本周又看到了RR的问题,里面提到了RR是通过MVCC实现的,但是自己对此却没什么印象,翻了翻学长的博客也没讲过,就学习一下,做个记录。
MVCC
MVCC 即多版本并发控制技术,简单的理解就是一份数据保存了多份。
用于多事务环境下,对数据读写在不加读写锁的情况下实现互不干扰,从而实现数据库的隔离性,在事务隔离级别为Read Commit 和 Repeatable read中使用到。
在InnoDB中,MVCC其实是通过undo log来实现的,但使用undo log解释起来较为复杂,所以普遍的解释是:每行记录的后面保存了两个隐藏的列,DB_TRX_ID(数据行的版本号)
和DB_ROLL_PT(删除版本号)
,这两列保存的是系统版本号,每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号进行比较。下面看看进行不同的操作时(以下内容取RR隔离级别,当然RC也是同理,只不过select的选定范围不同),InnoDB的行为:
-
SELECT
InnoDB会根据以下两个条件检查每行记录:- InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
- INSERT
InnoDB为新插入的每一行保存当前系统版本号作为数据行版本号。
- DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除版本号。
- UPDATE
InnoDB插入一条新记录,保存当前系统版本号作为数据行版本号,同时保存当前系统版本号到原来的行作为删除版本号。
保存这两个额外系统版本号,使大多数读操作都可以不用加锁。这样设计使得读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。
光看概念肯定还是看的不太明白的,我们用一个例子来展示一下
例子
先创建一个用户表
create table user(
id int primary key auto_increment,
name varchar(20));
打开navicat,新建一个查询,执行以下sql
begin; # 开始一个新的事务, 事务的版本号为1
insert into user values(NULL,'zhangsan');
insert into user values(NULL,'lisi');
commit;
此时数据库中的数据应该是这样,因为新插入的每一行会保存当前系统版本号作为数据行版本号
Id | name | DB_TRX_ID(数据行版本号) | DB_ROLL_PT(删除版本号) |
---|---|---|---|
1 | zhangsan | 1 | null |
2 | lisi | 1 | null |
此时, 我们打开一个新的查询, 把它称作Query1
begin; # 开始一个新的事务,事务版本号为2
select * from user; # 1
select * from user; # 2
commit;
我们再打开一个查询, 把它称作Query2
begin; # 开始一个新的事务,事务版本号为3
update user set name = 'yuzhi' where id = 1;
commit;
执行Query2,之后我们在执行Query1的2
结果和Query1的1查询到的是一样的,这符合我们的预期,因为此时数据库中的数据应该是这样
Id | name | DB_TRX_ID(数据行的版本号) | DB_ROLL_PT(删除版本号) |
---|---|---|---|
1 | zhangsan | 1 | 3 |
1 | yunzhi | 3 | null |
2 | lisi | 1 | null |
Query1只能查询数据行版本号小于等于当前事务版本号或未定义且删除版本号大于当前事务版本号的。
删除操作同理,不再演示,我们对Query进行commit。
MVCC与幻读
上面的例子证明了MVCC能够实现可重复读,但是MVCC是否能够避免幻读呢?我们继续看。
我们新建一个查询,叫做Query3
begin; # 开启一个新的事务,事务版本号4
select * from user; # 1
select * from user; # 2
update user set name='yunzhi'; # 3
select * from user;
commit;
Query3的1,此时数据库中的数据应该是这样(第一条记录因为事务1已关闭,所以被清除了)
Id | name | DB_TRX_ID(数据行的版本号) | DB_ROLL_PT(删除版本号) |
---|---|---|---|
1 | yunzhi | 3 | null |
2 | lisi | 1 | null |
新建一个查询Query4,
begin; # 开启一个新的事务, 事务版本号为5
insert into user values(NULL,'wangwu');
commit;
执行Query4, 此时再执行Query3的2, 查询出来的结果为
符合预期, 因为此时数据库中的数据应该是这样
Id | name | DB_TRX_ID(数据行的版本号) | DB_ROLL_PT(删除版本号) |
---|---|---|---|
1 | yunzhi | 3 | null |
2 | lisi | 1 | null |
3 | wangwu | 5 | null |
而进行查询的事务id为4
我们接着执行Query3的3和4
快照读和当前读
在查阅了一些资料后发现在RR级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,不是数据库最新的数据。这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库最新版本数据的方式,叫当前读 (current read)。
select 快照读
当执行select操作是innodb默认会执行快照读,会记录下这次select后的结果,之后select 的时候就会返回这次快照的数据,即使其他事务提交了不会影响当前select的数据,这就实现了可重复读了。快照的生成当在第一次执行select的时候,也就是说假设当A开启了事务,然后没有执行任何操作,这时候B insert了一条数据然后commit,这时候A执行 select,那么返回的数据中就会有B添加的那条数据。之后无论再有其他事务commit都没有关系,因为快照已经生成了,后面的select都是根据快照来的。
当前读
对于会对数据修改的操作(update、insert、delete)都是采用当前读的模式。在执行这几个操作时会读取最新的记录,即使是别的事务提交的数据也可以查询到。假设要update一条记录,但是在另一个事务中已经delete掉这条数据并且commit了,如果update就会产生冲突,所以在update的时候需要知道最新的数据。也正是因为这样所以才导致上面我们测试的那种情况。
select的当前读需要手动的加锁:
select * from table where ? lock in share mode;
select * from table where ? for update;
同时update以后会把以前的标记为删除,而增加一条数据,所以此时数据库中的数据应该是这样
Id | name | DB_TRX_ID(数据行的版本号) | DB_ROLL_PT(删除版本号) |
---|---|---|---|
1 | yunzhi | 3 | 4 |
1 | yunzhi | 4 | null |
2 | lisi | 1 | 4 |
2 | yunzhi | 4 | null |
3 | wangwu | 5 | 4 |
3 | yunzhi | 4 | null |
这也就解释了为什么后续的select能把所有数据查询出来。
小结
MySQL可重复读的隔离级别中并不是完全解决了幻读的问题,而是解决了读数据情况下的幻读问题。而对于修改的操作依旧存在幻读问题,就是说MVCC对于幻读的解决是不彻底的。
如何解决幻读
有两个办法:
- 使用串行化读的隔离级别
- MVCC+next-key locks:next-key locks由record locks(索引加锁) 和 gap locks(间隙锁,每次锁住的不光是需要使用的数据,还会锁住这些数据附近的数据)
一个注意事项
如果只是执行begin
语句实际上并不会开启一个事务。
对数据进行了增删改查等操作后才会开启一个事务。
版权声明
本文作者: 河北工业大学梦云智开发团队 - 李宜衡