MySQL 间隙锁原理深度详解

来源:https://blog.csdn.net/zhangcongyi420/article/details/132415844

一、前言

锁是mysql提供的一种保证不同事务读写隔离的重要措施,通过锁机制可以有效提升决多线程下并发处理事务能力。mysql根据使用场景不同,对锁的分类有很多种,比如按照锁的粒度可以分为表锁与行锁,按照锁状态可分为共享锁与排他锁,按模式可分为乐观锁与悲观锁等。不同的锁划分对应着不同的使用场景,同时锁的使用也与mysql的事务隔离机制息息相关,本文来深入探讨一下mysql的另一种容易被忽视的锁,即间隙锁,以及与之相关的相关问题。

二、mysql之mvcc

在正式开始聊间隙锁之前,还需要了解下mysql的mvcc机制,因为间隙锁的由来与mysql的事务关系密切,同时事务的底层控制是由mysql的mvcc机制来保障。循着这个思路,我们逐渐拨开迷雾,步步为营向前进。

2.1 什么是mvcc

mvc全称多版本并发控制,MVCC 是通过数据行的多个版本管理来实现数据库的并发控制。

通过这项技术,使得在InnoDB的事务隔离级别下执行 一致性读操作有了保证。换言之,就是为了查询一些正在被另一个事务更新的数据行,并且可以看到它们被更新之前的值,这样在做查询的时候就不用等待另一个事务释放锁。

2.2 mvcc组成

mvcc的实现主要依赖下面的3个主要逻辑实现,分别是:

  • 隐藏字段,在上文中有所交待,每个数据行都会存在一个隐藏字段;

  • undolog版本链,上文有所交待,记录了回滚数据行的数据;

  • ReadView(读视图)是快照读SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id,可能是一个数组;

MVCC核心就是 Undo log多版本链 + Read view,“MV”就是通过 Undo log来保存数据的历史版本,实现多版本的管理。“CC”是通过 Read-view来实现管理,通过 Read-view原则来决定数据是否显示。同时针对不同的隔离级别, Read view的生成策略不同,也就实现了不同的隔离级别。

2.2.1 Undo log 多版本链

undo log 也成为回滚日志,用于记录数据被修改前的信息 , 作用包含两个 : 提供回滚 ( 保证事务的原子性 ) 和 MVCC(多版本并发控制 ) 。

举例来说,某一次使用update语句修改一条id为1的数据,如果事务提交失败,那么就需要回滚数据,mysql引擎怎么知道回滚到哪里呢?那就要借助undo log了,undolog中记录了修改之前的数据,所以就可以用于事务回滚。

对于每次操作一条数据的事务来说,每条数据都有两个隐藏字段:

  • trx_id: 事务id,记录最近一次更新这条数据的事务id;

  • roll_pointer: 回滚指针,指向之前生成的undo log;

如下图所示,是关于mysql事务操作时对应的undo log版本链的示意图,记录了多个事务对同一条数据发生修改时undo log的情况;

图片

从上图不难看出,每条数据都可能存在多个版本,不同版本之间,通过undo log链条进行连接,通过这种设计,可保证每个事务提交时,一旦需要回滚,能保证同一个事务只能读取到比当前版本更早提交的值,而不能看到更晚提交的值。

2.2.2 ReadView

Read View是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读已提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

  • Read View简单理解就是对数据在某个时刻的状态拍成照片记录下来。那么之后获取某时刻的数据时就还是原来的照片上的数据,是不会变的;

  • ReadView(读视图)是快照读SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id,可能是一个数组;

Read View中比较重要的字段有4个:

  • m_ids : 用来表示MySQL中哪些事务正在执行,但是没有提交;

  • min_trx_id: 就是m_ids里最小的值;

  • max_trx_id : 下一个要生成的事务id值,也就是最大事务id;

  • creator_trx_id: 就是你这个事务的id;

如下图,记录了Read View中当前事务发生状态时相关的几个字段信息,对照上面的几个字段的解释可以进一步理解,举例来说,某个事务第一次执行查询,生成了一致性视图read-view,里面保存了当前事务相关的信息,再次查询时就会从undo log 中拿最新的一条记录开始跟 read-view 做对比,如果不符合比较规则,就根据回滚指针回滚到上一条记录继续比较,直到得到符合比较条件的查询结果。

图片

Read View如何判断记录的某个版本可见呢?规则大致如下:

1)如果当前记录的事务id落在绿色部分(trx_id < min_id),表示这个版本是已提交的事务生成的,可读;

2)如果当前记录的事务id落在红色部分(trx_id > max_id),表示这个版本是由将来启动的事务生成的,不可读;

3)如果当前记录的事务id落在黄色部分(min_id <= trx_id <= max_id),则又可以分为两种情况:

  • 若当前记录的事务id在未提交事务的数组中,则此条记录不可读;

  • 若当前记录的事务id不在未提交事务的数组中,则此条记录可读;

图片

在mysql的事务隔离级别中,RC(读已提交) 和 RR(可重复读) 隔离级别都是基于 MVCC 实现,区别在于:

  • RC 隔离级别时,read-view 是每次执行 select 语句时都生成一个;

  • RR 隔离级别时,read-view 是在第一次执行 select 语句时生成一个,同一事务中后面的所有 select 语句都复用这个 read-view ;

2.2.3 快照读与当前读

快照读

快照读又叫一致性读,读取的是快照数据。不加锁简单的 SELECT 都属于快照读,即不加锁的非阻塞读,比如这样:SELECT * FROM user WHERE …

之所以出现快照读,是基于提高并发性能考虑,快照读的实现是基于MVCC,它在很多情况下,避免了加锁操作,降低了开销。

当前读

读取的是记录最新版本(最新数据,而不是历史版本的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。加锁的 SELECT,或者对数据进行增删改都会进行当前读。比如:

SELECT * FROM student LOCK IN SHARE MODE; # 共享锁

SELECT * FROM student FOR UPDATE; # 排他锁

三、RR级别下的事务问题

RR即可重复读,即一个事务执行过程中看到的数据,总是跟这个事务在第一次执行时看到的数据是一致的。在学习mysql的事务隔离级别以及各隔离级别所能解决的问题时,是否还记得在这种隔离级别下能够解决什么问题?以及仍存在什么问呢?

3.1 RR隔离级别解决的问题

下面这张表,详细列举了各事务隔离级别下能够解决的问题,以及未能解决的问题,对照RR隔离级别来说,默认情况下,RR级别可以解决脏读和不可重复读问题,但是仍未解决幻读问题。

图片

3.1.1 幻读问题

简单来说,幻读是指当用户读取某一范围的数据行时,另一个事务又在该范围插入了新行,当用户在读取该范围的数据时会发现有新的幻影行。

注意,在可重复读隔离级别时,默认情况下,普通的查询是快照读(后面的查询一直用的是初次保存的快照数据),因此是不会看到别的事务插入的数据的。因此, 幻读在“当前读”下才会出现(查询语句添加for update,表示当前读),很多人在这里容易糊涂,也是容易混淆一刀切的地方(经常会有面试官问:RR隔离级别下,一定会出现幻读问题吗?所以需要区分是快照读还是当前读,后面会通过案例演示说明);

MVCC多版本并发控制中,读操作可以分为两类: 快照读(Snapshot Read)与当前读 (Current Read)。上述对快照读和当前读有过介绍,它们解决的问题主要如下:

快照读

快照读可以使普通的SELECT 读取数据时不用对表数据进行加锁,从而解决了因为对数据库表的加锁而导致的两个如下问题:

1)解决因加锁导致的修改数据时无法对数据读取问题;

2)解决因加锁导致读取数据时无法对数据进行修改的问题

当前读

当前读是读取的数据库最新的数据,当前读和快照读不同,因为要读取最新的数据而且要保证事务的隔离性,所以当前读是需要对数据进行加锁的(插入/更新/删除操作,属于当前读,需要加锁 , select for update 为当前读)

3.2 幻读效果演示

下面演示基于读已提交事务隔离级别下的幻读效果演示

3.2.1 准备测试表和数据

创建如下表,并插入几条数据;

``

  1. CREATE TABLE test (

  2. id int(12) NOT NULL,

  3. x int(12) DEFAULT NULL,

  4. PRIMARY KEY (id)

  5. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

  6. insert into test values(1,3);

  7. insert into test values(2,3);

  8. insert into test values(3,3);

  9. insert into test values(5,3);

  10. insert into test values(17,3);

``

完整操作步骤

顺序 事务A 事务B
1 begin;
2 select * from test where x=3 for update;
3
insert into test values(19,3);
4 select * from test where x=3 for update;
5 commit;

3.2.2 修改事务级别

检查当前数据库事务隔离级别,默认情况下,事务隔离级别为可重复读;

SELECT @@tx_isolation;

图片

为了模拟幻读效果,先手动调整一下会话的事务隔离级别,使用下面的命令调整

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

设置完成后,再次查询时,看到事务隔离级别就变成了读已提交;

图片

3.2.3 开启两个session会话并执行事务操作

在第一个mysql的session会话窗口执行如下命令

`

  1. begin;

  2. select * from test where x=3 for update;

`

图片

此时在第二个会话窗口insert一条数据

图片

再在第一个会话窗口查询x=3的数据,检查数据,发现能够查询到上面插入的这条数据;

图片

3.3 间隙锁解决幻读问题

3.3.1 间隙锁概述

幻读是如何产生的呢?产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了 next-key 锁,就是记录锁和间隙锁的组合。

  • RecordLock锁:锁定单个行记录的锁。(记录锁,RC、RR隔离级别都支持);

  • GapLock锁:间隙锁,锁定索引记录间隙(不包括记录本身),确保索引记录的间隙不变。(范围锁,RR隔离级别支持);

  • Next-key Lock 锁:记录锁和间隙锁组合,同时锁住数据,并且锁住数据前后范围。(记录锁+范围锁,RR隔离级别支持);

可以对照下面这张图深入理解上面几种锁的含义

图片

3.3.2 基于快照读解决幻读问题

完整的操作步骤和顺序如下表

顺序 事务1 事务2
1 begin;
2 select * from test where id>1; begin;
3
insert into test values(20,3);
4
commit;
5 select * from test where id>1;
6 commit;

仍然使用上面的表,在开始之前,先将事务隔离级别调整为可重复读;

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT @@tx_isolation;

图片

开启第一个会话,查询id>1的数据

`

  1. begin;

  2. select * from test where id > 1;

`

图片

开启第二个会话并插入一条数据

`

  1. begin;

  2. insert into test values(20,3);

  3. commit;

`

图片

第一个会话再次查询id>1的数据,可以发现第二个会话插入的数据在当前的会话事务中并没有查到;

图片

提交第一个会话的事务,再次查询,此时就能查到数据了

图片

总结:

可重复读隔离级别下是通过MVCC来避免幻读的,具体的实现方式在事务开启后的第一条select语句生成一张Read View(数据库系统当前的一个快照),之后的每一次快照读都会读取这个Read View。

在上面的操作流程中,在第2步生成一张Read View,所以在第5步时读取到数据和第2步相同,避免了幻读。

3.3.3 当前读基于间隙锁解决幻读问题

select lock in share mode(共享锁), select for update ; update, insert ,delete这些操作都是一种当前读,读取的是记录的最新版本。在当前读情况下是通过next-key lock(间隙锁)来避免幻读,即加锁阻塞其他事务的当前读。

操作步骤如下:

顺序 事务A 事务B
1 begin;
2 select * from test where id>1 for update; begin;
3
insert into test values(20,3);

第一个会话事务执行如下操作

`

  1. begin;

  2. select * from test where id>1 for update;

`

图片

第二个会话事务开启事务,insert一条数据

`

  1. begin;

  2. insert into test values(20,3);

`

图片

通过上面的现象可以看到,第二个会话事务将会阻塞而不能插入成功;

事务A在第2步执行了select for update当前读,会对id>1的数据行记录加锁,同时对(2,+∞)这个区间加间隙锁,两个都是排它锁,会阻塞其他事务的当前读,所以在第2个事务insert新数据时阻塞,从而避免了当前读情况下的幻读。

3.4 可重复读一定解决了幻读问题吗

mysql默认的事务隔离级(可重复读)下可解决大多数场景下的幻读问题,但某些场景下仍然无法完全解决,看下面的这个操作;

顺序 事务A 事务B
1 begin;
2 select * from test where id>1; begin;
3
insert into test values(21,3);
4
commit;
5 select * from test where id>1 for update;
6 commit;

有兴趣的同学可以按照这个步骤操作一下看下效果,针对上面的操作来做一下分析:

  • 事务A在第2步使用的是快照读,此时生成了Read View查询出来的数据是id>1这个区间的所有数据;

  • 事务B在第3步插入了一条id为21的数据,因为事务A没有对数据加锁,所以事务B可以正常插入;

  • 第5步事务A查询时查出了事务B插入的数据,因此产生幻读;

3.4.1 原因分析

第5步的时候使用了for update,即使用的是当前读,不会再读取Read View,而读取的是当前最新的数据,所以读出了事务B插入的数据。

3.4.2 总结

结合上面的分析结果,做最后如下小结

  • MySQL默认隔离级别可重复读很大程度上解决了幻读问题,在快照读情况下是通过MVCC解决,在第一次执行查询时生成一张Read View,后续每次快照读都是读这张Read View;

  • 在当前读情况下是加锁来解决,枷锁会阻塞其他事务的当前读,从而避免幻读;

  • 然而可重复读并不能完全解决幻读,比如当一个事务里面使用快照读之后又使用当前读的话就还是可能会出现幻读。