本篇文章整理自《MySQL技术内幕》第6章 锁,调整了文章中的顺序使其更加符合逻辑,也对文章中令我感到疑惑的地方通过查阅资料进行了补充和说明
1. MySQL中的锁
在MySQL中,lock的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般lock的对象仅在commit或rollback后进行释放,也就是说锁的持续时间是整个事务过程。
锁的出现是用来解决数据的一致性问题的,例如脏读、不可重复读和幻读。对于如何解决以上问题有两种方案:
- 读操作利用多版本并发控制(MVCC),写操作进行加锁
- 读操作进行加锁,写操作进行加锁
MVCC在之前有讲过,这里我们着重讲MySQL中的各种锁操作。
2. 锁类型
在InnoDB的READ COMMITTED以及REPEATABLE READ事务隔离级别下,都是使用MVCC的方式进行读取,也就是无锁读,不过我们也可以显示的执行有锁读操作。
MySQL锁的类型分为共享锁S锁
和排他锁X锁
,如果两种类型的锁是可以同时存在的则我们称之为兼容,否则称之为不兼容;
对读取的记录加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锁是兼容的。下表展示了表级锁的兼容性
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,如下图所示
如图中为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锁
,画个图就是这样:
Next-Key Lock:结合了Gap Lock和Record Lock的一种锁定算法。
7. 死锁
死锁是指两个或两个以上的事务在执行过程中,因争夺锁资源而造成的一种互相等待的现象。
在InnoDB中通过两种方法解决死锁问题,一种方法是设置一个超时阈值,若阻塞时间超过这个阈值后一个事物进行回滚,另一个事务继续运行。另一种方法就是使用wait-for graph(等待图)的方式来进行死锁检测,这是一种更主动的方法,如下图t1和t2之间存在环路,故产生了死锁,通常来说InnoDB存储引擎选择回滚undo量最小的事务。