InnoDB锁的实现

MySQL学习笔记

Posted by     "Eric" on Tuesday, March 24, 2020

本篇文章整理自《MySQL技术内幕》第6章 锁,调整了文章中的顺序使其更加符合逻辑,也对文章中令我感到疑惑的地方通过查阅资料进行了补充和说明

1. MySQL中的锁

在MySQL中,lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在commit或rollback后进行释放,也就是说锁的持续时间是整个事务过程。

锁的出现是用来解决数据的一致性问题的,例如脏读、不可重复读和幻读。对于如何解决以上问题有两种方案:

  1. 读操作利用多版本并发控制(MVCC),写操作进行加锁
  2. 读操作进行加锁,写操作进行加锁

MVCC在之前有讲过,这里我们着重讲MySQL中的各种锁操作。

2. 锁类型

在InnoDB的READ COMMITTED以及REPEATABLE READ事务隔离级别下,都是使用MVCC的方式进行读取,也就是无锁读,不过我们也可以显示的执行有锁读操作。

MySQL锁的类型分为共享锁S锁和排他锁X锁,如果两种类型的锁是可以同时存在的则我们称之为兼容,否则称之为不兼容;

image-20200812213206010

对读取的记录加S锁的显示操作:

SELECT ... LOCK IN SHARE MODE;

对读取的记录加X锁的显示操作:

SELECT ... FOR UPDATE;

在InnoDB中锁是有不同粒度的,行锁是粒度最细的锁,除此之外还有表锁。表锁同样也是分S锁和X锁,不过我在对表上S锁的时候首先要判断是否在该表的记录上有X锁;我们在对表上X锁的时候要判断是否在该表的记录上有S锁;我们当然可以通过遍历整个表的所有记录来查看是否能对表上锁,但这显然是十分低效的。为了解决这一问题,InnoDB的设计者们提出了意向锁这一概念。

  • 意向共享锁,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁
  • 意向独占锁,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁

IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。下表展示了表级锁的兼容性

image-20200812215338604

4. 外键和锁

外键主要用于引用完整性的约束检查,在InnoDB中,对于一个外键列,如果没有显式地对这个列加索引,InnoDB存储引擎自动对其加一个索引,这样可以避免针对该列的锁定退化为表锁,进而可能会引发死锁

对于外键值的插入或更新,首先需要查询父表中的记录(判断该外键是否在父表中有值),即SELECT父表。对父表SELECT时,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是SELECT … LOCK IN SHARE MODE方式,即对父表加一个S锁。

时间 会话A 会话B
1 BEGIN
2 DELETE FROM parent WHERE id=3;
3 BEGIN
4 INSERT INTO child SELECT 2,3 #第二列是外键,执行该句时会被阻塞

在以上案例中,如果使用非锁定读,则父表中已经不存在主键3了,但是子表中的外键中会出现3,存在不一致的情况。

5. 幻读

如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。笔者之前总是把幻读和不可重复读搞混,幻读更侧重于读取到了之前没有读取的记录,而不可重复读侧重于读相同的记录但结果不同。

时间 会话A 会话B
1 SET SESSION tx_isolation=‘READ COMMITED’;
2 BEGIN;
3 SELECT * FROM t WHERE a>2 FOR UPDATE;
4 BEGIN;
5 INSERT INTO t SELECT 4;
6 COMMIT;
7 SELECT * FROM t WHERE a>2 FOR UPDATE;

如何解决幻读,这里用到了下面马上讲到的next-key lock

6. InnoDB中的行锁

InnoDB存储引擎有3中行锁的算法,分别是:

  • Record Lock:单个行记录上的锁
  • Gap Lock:间隙锁,锁定一个范围,但不包含本行
  • Next-Key Lock(临键锁):Gap Lock+Record Lock,锁定一个范围,并锁定本行

Record Lock:总是会锁住索引所指向的行。

Gap Locks:gap lock是为了解决幻读而诞生的。在事务第一次执行读取操作的时候,幻影记录是不存在的,我们无法给幻影记录加上记录锁。因此我们在两个记录之间加上Gap Lock,如下图所示

image-20200812221649474

如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前边的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number值为4的新记录,它定位到该条新记录的下一条记录的number值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,number列的值在区间(3, 8)中的新记录才可以被插入。

不知道大家发现了一个问题没,给一条记录加了gap锁只是不允许其他事务往这条记录前边的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero表中number值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入number值在(20, +∞)这个区间的新记录呢?这时候应该想起我们在前边唠叨数据页时介绍的两条伪记录了:

  • Infimum记录,表示该页面中最小的记录。
  • Supremum记录,表示该页面中最大的记录。

为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁,画个图就是这样:

image-20200812221846305

Next-Key Lock:结合了Gap Lock和Record Lock的一种锁定算法。

image-20200812222219811

7. 死锁

死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。

在InnoDB中通过两种方法解决死锁问题,一种方法是设置一个超时阈值,若阻塞时间超过这个阈值后一个事物进行回滚,另一个事务继续运行。另一种方法就是使用wait-for graph(等待图)的方式来进行死锁检测,这是一种更主动的方法,如下图t1和t2之间存在环路,故产生了死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。

image-20200324200301192

image-20200324200735196