张喜硕学长以前讲过一篇 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;
此时,执行 Query1 中的 1
我们再打开一个查询, 把它称作 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
语句实际上并不会开启一个事务。
对数据进行了增删改查等操作后才会开启一个事务。
版权声明
本文作者:河北工业大学梦云智开发团队 – 李宜衡
参考文章
MYSQL MVCC 实现原理
MVCC 能解决幻读吗?
MySQL 的可重复读级别能解决幻读吗