写点什么

深入理解 MVCC 与间隙锁

用户头像
参商
关注
发布于: 2021 年 01 月 24 日

MySql 如何实现 MVCC?

先回顾一下 Mysql 的四种隔离级别:


Read Uncommitted(读未提交)

该隔离级别下直接读取数据的最新版本(这里的最新版本我把它理解为别人修改完没提交的数据也可以读到),可能发生脏读、不可重复读和幻读问题


Read Committed(读已提交)

事务执行过程中重复执行相同的普通 select 语句能看到其它事务已经提交的改变。可能发生不可重复读、幻读的情况。

 

Repeatable Read(可重复读)

MySQL 的默认隔离级别,同一事务内多次读取数据时,会看到同样的数据行。不过无法解决幻读的问题,当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。不过 InnoDB 存储引擎通过 MVCC 机制解决了该问题。

 

Serializable(串行化)

该级别要求所有事务都必须串行执行,因此能避免一切因并发引起的问题,但效率很低。


这里的四个隔离级别中 Read Uncommitted 是什么都不做直接读取最新的数据,所以会有各种并发问题,Serializable 是串行化操作保证所有并发问题不会发生,就没什么好讲的了。下面要讲的是 Read Committed 和 Repeatable Read,在讲这两个之前要先讲一下啥叫记录的版本链。


对于使用 InnoDB 存储引擎的表每个行记录中都包含两个必要的隐藏列:

trx_id:修改这条记录的事务 id。

roll_pointer:每次记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

比方说我们现在的 users 表,现在 id 为 1 的记录如下


此时执行两次 update 语句


此时数据库中 id 为 1 的最新记录 name 为张三,假设两次更新的事务 id 分别为 100,200,每次对记录进行改动都会生成一条 undo 日志,经过以上两次操作后,undo 日志串起来就会形成如下的版本链:


由 undo 日志串起来的版本链记录了 id 为 1 的记录历史版本,现在有了记录的历史版本要做到 Read Committed 和 Repeatable Read 隔离级别只读到已提交的数据,核心的问题就变成如何判断版本链中哪个版本是当前事务可见的,只找出已提交的数据就可以了。为此,设计 InnoDB 的大叔提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:


m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。


m_up_limit_id:m_ids 中的最小值,小于这个之前的事务全部提交了。


m_low_limit_id:事务 id 是递增的,表示生成 ReadView 时应该分配给下一个事务的 id 值。(不是 m_ids 最大值,这个值可以不在 m_ids 中)


creator_trx_id:表示生成该事务的事务 id。


判断是否可见的源码如下:


具体见源码见:https://github.com/mysql/mysql-server/blob/ee4455a33b10f1b1886044322e4893f587b319ed/storage/innobase/include/read0types.h


总结起来就是下面这几句话:

1:如果查看版本的 trx_id 属性值小于 ReadView 中的 m_up_limit_id 值或者与 ReadView 中的 m_creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录或者该版本在当前事务生成之前就已经提交,所以可以被查看。

2:如果查看版本的 trx_id 属性值大于或等于 ReadView 中的 m_low_limit_id 值,表明该版本在当前事务之后才生成,不可以被查看。

3:如果查看版本的 trx_id 属性值在 ReadView 的 m_up_limit_id 和 m_low_limit_id 之间,那就看 trx_id 在不在 m_ids 中,如果在,说明是创建 ReadView 时别人还未提交的记录,不能够查看;如果不在,说明创建 ReadView 时事务已经提交,该版本可以被查看。


那既然 Read Committed 和 Repeatable Read 都依靠 ReadView 来判断版本链中哪个版本对当前事务是可见的,那为啥 Repeatable Read 隔离级别能做到可重复读呢,原因是因为它们生成 ReadView 的时机不同,Read Committed 在每一次进行普通 SELECT 操作前都会生成一个 ReadView,而 Repeatable Read 只在第一次进行普通 SELECT 操作前生成一个 ReadView,之后查询操作都复用之前那个 ReadView。

有了 MVCC 就没幻读问题了吗?

假设此时 users 表的数据如下:


此时有两个事务执行如下操作:


那么按上面可见性分析,此时 trx_1 的两次查询结果应该都是为空的,因为 trx_1 中的 m_low_limit_id = 1,当 trx_1 执行第二次 select * from users where id = 2 的时候 id=2 的这条记录 trx_id = 2,因为 trx_id = 2 > m_low_limit_id = 1 所以 id=2 的记录对于 trx_1 不可见。这么看好像是解决了幻读的问题,那我把上面的的语句加一句,看看又会是什么结果:


此时 trx_1 的第二句 select * from users where id = 2 是可以查询到 id=2 的记录的,也就是出现了幻读。按可见性分析就是这条记录的 trx_id=1 = m_creator_trx_id=1,也就是访问的是当前事务修改的版本,所以当前事务可见。所以 MVCC 并不能完全解决幻读的问题,那应该要怎么解决呢,答案就是由程序自己来解决,需要语句进行加锁来保证不出现幻读,也就是 MVCC 和 MySql 的间隙锁进行配合来保证不出现幻读,并不是由 MVCC 天然来保证的。

还能好好使用间隙锁吗?

我想这个大家应该很熟悉,把上面 select * from users where id = 2 改成 select * from users where id = 2 for update 这样就可以保证在 trx_1 执行过程中不会有其它记录插入 id=2 这条记录。这里值得注意的是 select for update 这个语句可不能乱用,用不好是可能造成锁表啥的,具体使用细节以及注意事项我就不在这里讲了,网上文章一大堆,我这里要讲的是由 select for update 可能引起的一个死锁 bug,链接为:https://bugs.mysql.com/bug.php?id=95230复现场景如下:

当前 users 表记录如下:


此时如果有两个事务按如下顺序进行执行


此时就会报死锁异常,这是由于间隙锁(gap lock)是支持多个事务获得同一间隙的锁的,此时 trx_1 在执行 insert into users values (2, '李四');的时候由于 trx_2 获得了相同间隙的间隙锁,阻止其插入导致 trx_1 进行等待,这时候如果 trx_2 执行 insert into users values (3, '张三');就会报死锁异常,因为 trx_1 也阻止 trx_2 插入。


那现在问题来了,如果 gap lock 是不支持多个事务对同一个间隙进行加锁的话,是不是就会解决这个死锁问题呢,毕竟这个可以共享 gap lock 很容易造成死锁。


下面我们考虑另外一种场景,此时 users 表的数据如下:


两个事务 trx_1 和 trx_2 执行顺序如下:


此时 trx_1 和 trx_2 是可以正常提交成功的,因为 trx_1 间隙锁锁定的范围是(1,5),trx_2 间隙锁锁定的范围是(5,10),两个间隙锁的区间不冲突,所以可以正常提交,但是此时如果来了一个事务 trx_3,执行顺序如下:


此时 trx_1 和 trx_2 就不会像之前一样能够正常提交,而是报了死锁冲突,原因如下:

一开始 trx_1 间隙锁锁定的范围是(1,5),trx_2 间隙锁锁定的范围是(5,10),此时 trx_3 删除了 id=5 的数据并提交了这时候 trx_1 和 trx_2 的间隙锁范围发生了扩大,两个全部变成了(1,10)就出现了锁定同一间隙发生死锁冲突的情况。


所以按我的理解,为什么间隙锁能够共享是跟间隙锁的实现有关,它不能凭空锁定某个区间,比如就锁定(2,3)而不锁定(1,10),它的实现按我的理解是需要靠两条存在的记录来实现的,所以当 id=5 的数据被删除的时候,此时记录就只有 id=1 和 id=10,这时候间隙锁自动就扩大为(1,10)了所以就出现了死锁,至于为啥不能让间隙锁不共享的原因可能就是间隙锁无法阻止 delete 操作,导致它也无法避免间隙锁扩大的情况,只要间隙锁会发生扩大,就会重叠在一起,这就导致了两个事务本来锁定不同的区间变成锁定同样的区间了,真要修复这个问题,可能得把实现全部改一遍吧。


所以使用 select for update 这种语法还是有挺多问题的,平常也不是不能用,只是使用的时候要小心一点,具体要考虑好场景,不要发生锁表等各种严重情况。


用户头像

参商

关注

还未添加个人签名 2020.02.13 加入

还未添加个人简介

评论

发布
暂无评论
深入理解MVCC与间隙锁