【聊聊MySQL】十一.MySQL-InnoDB的数据库事务锁

作者: Weidan 分类: mysql 发布时间: 2020-04-01

一.InnoDB锁

并发的世界里,就离不开锁这个东西,即使会降低一些执行效率,但是该锁住的还是得锁住。MySQL 也一样,当两个 会话 同时修改一条数据的时候,就需要通过锁机制来保证修改后数据的正确性。锁听起来很牛逼,但是简单的概括一下也就是对某个资源(一个变量)的修改操作撸成一条队列来执行,由于执行之前需要判断一些锁的信息,所以执行效率肯定是要低一点的。

不过由于学 Java 的,这个鬼东西也可以结合起来一起说说,反正原理都一样。

通常来说,对一个资源的操作会出现以下几个情况:

  1. 读读:不会对资源产生副作用,所以不需要加锁;
  2. 写写:两个线程想要动手修改同一个资源,那不用说肯定先到先写;
  3. 读写:一个读一个写的线程,那也是谁先到达现场,谁先锁定。

1.1 写写

假设两个线程都要对同一个记录进行操作,那么就会产生锁的竞争了:

首先 线程A 先到达数据行现场:

接下来,线程B 说我也要更新:

但是发现 线程A 在做操作,这时候只能干巴巴的先等着,不过为了性能,会把线程给睡眠了(类似于 await()

然后,线程A 做完了,提起裤子准备要走,这时候就跟 线程B 说,轮到你了:

1.2 读和写

一个 读线程 和一个 写线程,为了防止 脏读 不可重复读 等一些事务可能出现的问题,我们前边说过 InnoDB 使用 MVCC 来控制读取的东西,而另外一边,写线程 可以继续写的操作。

但是,业务经理 说,我就要读取的事务完成,才可以进行操作(例如银行这种比较高要求一致性的系统),那么 就需要用到锁了:

SELECT * FROM table WHERE id = 1 FOR UPDATE; // 排他锁
SELECT * FROM table WHERE id = 1 LOCK IN SHARE MODE;// 共享锁

上面这句执行后,事务的执行模型 就会变成上面 写写 一样的形式了。

很多时候,使用 MVCC 更加的高效,不过如果业务是很高度准确性要求的话,就需要用到这种方式。

二.锁的类型

2.1 行级锁

这个和 ReentrantReadWriteLock 可以相关联理解:

  1. 排他锁Shared Locks,一般简称 S锁 ,两个操作的排他锁不会阻塞,但是跟下面的 排他锁 使用的话,就会阻塞,等下说;
  2. 独占锁Exclusive Locks,一般简称 X锁,如果发生修改锁定的时候,使用的锁都是 排他锁

那么 排他锁 就是 ReentrantReadWriteLockreadLock(),而 排他锁 就是 writeLock() 了。

画个表看看兼容程度:

兼容性 X S
X 不兼容 不兼容
S 不兼容 兼容

那么只需要记住一个 S锁和S锁是兼容的 就可以了。

2.2 表级锁

表级锁也有 S锁X锁,兼容程度也和 行级锁 一致。不过如果一张表有 ,此时如果需要对表中的某一行做操作,需要判断 表级锁 和将要加的 行级锁 是否兼容,不兼容就需要阻塞。反过来如果想要对表加 表级锁,同样也需要此时表中数据的 行级锁表级锁 是否兼容。

所以有这样一个需求,当我需要对一张表加锁的时候,怎么知道这张表是否有 行级锁?简单想想的话,就是遍历这张表的所有记录了,但是我们生产一张表动不动就上千万个数据,遍历效率实在太低了,而且还要考虑遍历的时候需要对其他事务进行阻塞。所以,InnoDB 为了解决这个问题,又有一种锁 意向锁(Intention Locks)

可以理解成一个标记,当需要对表中某条记录加 S锁 时,会先在 上挂一个 Intention Shared Locks,简称 IS锁,而当需要对表中某条记录加 X锁 时,则相对应的在 上挂一个 Intention Exclusive Lock,简称 IX锁

而这两个锁,并没有相对应的 锁逻辑,只是为了快速判断一个 数据表 中,是否有 S锁 或者 X锁 记录而已,规避需要加 表锁 的时候,不得不循环遍历所有数据的低效。所以 表级锁 的兼容性如下:

兼容性 X IX S IS
X 不兼容 不兼容 不兼容 不兼容
IX 不兼容 兼容 不兼容 兼容
S 不兼容 不兼容 兼容 兼容
IS 不兼容 兼容 兼容 兼容

那什么时候会发生表级锁,当然就是修改 数据表 结构的时候啦,或者在数据库崩溃的时候,恢复数据的时候会用一下 表级锁

2.3 特殊的锁–AUTO-INC锁

当我们一个表的主键设置是 数据库自增 的时候,插入一条数据就需要为 自增变量 加锁,这个就是 AUTO-INC锁

AUTO-INC锁 也有不同的类型,可以通过 innodb_autoinc_lock_mode 来控制不同的类型:

  1. innodb_autoinc_lock_mode = 0:执行插入语句的时候,在表级加一个 AUTO-INC锁,为插入的数据分配递增的值,语句执行完毕即释放 AUTO-INC锁,如果插入语句遇到其他事务在使用这个锁的时候,就需要阻塞等待所释放;
  2. innodb_autoinc_lock_mode = 113 两种方式混合使用,当确定插入数据的数量的时候使用 AUTO-INC锁,不确定插入数量的时候(比如 INSERT...SELECT 等)使用轻量级锁。
  3. innodb_autoinc_lock_mode = 2:使用一个轻量级的锁,生成本次插入需要用到的所有值之后,释放锁,并不需要等待 插入语句 执行完成才释放。

三.深入行级锁

那么行级锁只是简单的对一个数据行加锁吗,肯定不是的,因为需求总是那么多变,可能需要对范围加锁,可能需要对新插入的数据加锁等等的需求,所以行级锁下,又有一些分类:

3.1 Record Locks

这个 Record Locks,就是真正意义上的行级锁了,意为锁住一条真正的数据。我们知道,InnoDB 把数据存储在一颗 B+ 树上,称为 聚簇索引Record Locks 就是加载 聚簇索引 上记录的一个锁:

  • 当一条记录有 S锁,那么其他事务可以继续获取 S锁,而不可以获取 X锁
  • 当一条记录有 X锁,其他事务无论想获取什么类型的 ,都需要阻塞等待。

我们知道一个数据页长这样子的:

不过为了简单点:

那比如说,我现在要操作 prod_id=22 的库存,需要加上一个 X锁

那么其他加锁的操作都会被阻塞。

3.2 GAP锁

GAP锁 是为了防止 幻影记录 而诞生的,我们知道 REPEATABLE READ 级别的隔离下,幻读是可以通过 MVCC 解决的,但是如果需要通过加锁的方式解决,就有个问题,不知道加哪个记录。

所以,我们就需要对数据页中的某个数据(边界)加上一个 GAP锁

在这里,比方说查询库存小于 200prod_id 集合,使用加锁的形式:

SELECT * FROM stock_info WHERE quantity < 200 LOCK IN SHARE MODE;

那么这个 GAP锁 就会被加在 stock_id = 3 的前面,这时候如果插入一条数据 INSERT INTO stock_info values(null, 55, 200);那么这条插入语句将会被阻塞。

那如果想要锁住最小值或者最大值怎么办,别忘了一个数据页中有两个特殊的列:InfimumSupremum。把这两个锁了是不是就完事了。

3.3 Next-Key Locks:

如果想要锁住记录并且也锁住间隙怎么办,就可以使用 Next-Key Locks。他是 Record Locks + GAP锁 的合体,锁住的那条记录即不允许在他前后插入数据,也不允许修改这条 数据行 的数据。

3.4 Insert Intention Locks 插入意向锁

那么在插入数据的时候,需要判断插入的位置是不是被 GAP锁 锁住了,如果有的话就需要阻塞当前事务,等待 GAP锁 的释放。那么 InnoDB 的大佬也规定,如果插入数据的时候进入阻塞状态,也需要生成一个 Insert Intention Locks 插入意向锁 挂在这条记录上边,表明在这个间隙目前有数据想要插入。

注意:意向锁并不会因为前边有个 GAP 锁就等待,而是直接锁住,再进入等待。事实上他也不介意其他锁进来。

3.5 隐形的锁

那么如果一个事务首先插入一条数据,然后另外一个事务马上用到 SELECT ... FOR UPDATE 或者 SELECT ... LOCK IN SHARE MODE 想要读取这条数据咋办,亦或者想要修改这条数据,如果这两种情况都允许的话,那么就会出现 脏读脏写 的问题了。

所以一个隐形的锁横空出世:trx_id(掀桌子):

  • 对于 聚簇索引,该记录的 trx_id 表示当前 事务ID,如果当前事务想要给这一行加锁的时候,就会查看这个 trx_id 的事务在当前是否是活跃状态,如果是的话,就舔狗般的帮他创建一个锁(锁有个属性是 isWaiting=false)然后自己创建一个 isWaiting=true 的锁,然后进入等待状态;
  • 对于 非聚簇索引 来说,因为没有 trx_id 隐藏列,不过 非聚簇索引数据页PageHeader 有个 PAGE_MAX_TRX_ID 列,表示对这个页面做过改动的最大 事务ID,如果这个值小于当前活跃的 事务ID,那说明对这个页面修改的事务都已经提交了,否则就需要定位到记录,回表重复上面一步的流程。

四.锁结构

加锁,那肯定是有事务对 数据行 发生了修改,所以就需要第一个属性记录必要的锁信息。

然后数据行都在 索引树 中,所以我们需要一些索引信息

第三个,表锁 就需要记录 数据表 的一些信息,而 行锁 就需要记录 数据行 的信息

type_mode 则是记录锁的类型,比如 IS锁 或者 IX锁 啊等等信息,也记录了是 表锁 还是 行锁,而上面也说了 GAP锁 等类型,也记录在这里(注意,和 IS锁 的类型记录不在同一个地方)这个字段是由一堆 比特位 组成的,而这个锁是否在等待 is_waiting 也记录在这里(一般第一个加上的锁不需要等待,所以这个值是 false,后面进来的锁都是 true

而类型的最后一个 比特位 则存储了数据的信息,包括 最大值 或者 最小值

摊开说有点复杂也不需要记住,只要记住锁记录了上面的信息就可以了,所以我偷懒不说,等需要用到的时候再聊。

五.手动分割线

上面说的都是 数据库锁 的一些结构,下面就要说 SQL 的加锁情况了。

六.SELECT语句加锁

前面隔离级别和 MVCC 的时候也说过 SELECT 在不同的隔离级别下查询出来以及发生的问题是不同的:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED:读未提交的
READ COMMITTED:读已提交的 ×
REPEATABLE READ:可重复读 × ×
SERIALIZABLE:串行化 × × ×

那么咋整呢,可以使用 MVCC 的方式来避免这些问题,但是为什么使用 MVCC 是因为它能够满足大部分的业务需求,并且执行效率要比 加锁 高很多,所以很多时候,我们 SQL 执行的时候使用的避免问题的方式都是 MVCC

但是有时候我们的业务背景要求,一点错误或者小插曲都不能出现,这时候就需要对我们读取或者操作的数据进行 加锁 执行了。

加锁 加什么锁 则不是必然的,因为 加锁 这个操作涉及到了很多客观元素,比方说 隔离级别 啊,使用什么索引啊 查询条件 等等。

那首先,建个 产品库存表stock_info 来做示例吧:

-- 一个库存表
CREATE TABLE stock_info (
    id BIGINT auto_increment primary key,
    prod_id BIGINT NOT NULL,
    quantity BIGINT NOT NULL
);
ALTER TABLE stock_info ENGINE = InnoDB;
-- 建立产品ID的索引
CREATE INDEX stock_info_prod_idx ON stock_info(prod_id);
-- 插入数据
INSERT INTO stock_info VALUES(NULL, 11, 32),
(NULL, 22, 65), (NULL, 33, 213), (NULL, 44, 77),
(NULL, 55, 20), (NULL, 66, 993), (NULL, 77, 142);
mysql> SELECT * FROM stock_info;
+----+---------+----------+
| id | prod_id | quantity |
+----+---------+----------+
|  1 |      11 |       32 |
|  2 |      22 |       65 |
|  3 |      33 |      213 |
|  4 |      44 |       77 |
|  5 |      55 |       20 |
|  6 |      66 |      993 |
|  7 |      77 |      142 |
+----+---------+----------+
7 rows in set (0.00 sec)

6.1 普通读

我们使用 MySQL 默认的隔离级别是 REPEATABLE READ,前面说过,解决了 脏读不可重复读幻读 的问题。但是吧:

左边 事务A 第一次查询 id = 9 的数据,是空的,但是由于右边的 事务B 插入了 id = 9 的数据并 提交事务A 又更新了 id = 9 的数据(所以此时 数据行trx_id 列又变成了 事务Aid,又因为执行了 UPDATE 语句,所以 ReadView 被刷新了),并且读取,就可以返回数据啦。

所以,REPEATABLE READ 可以说并没有完完全全的解决 幻读 的问题。

那么怎么解决上面的问题,那就剩下 加锁 的方式了。

锁定读的语句:

  • SELECT ... LOCK IN SHARE MODE;
  • SELECT ... FOR UPDATE;
  • UPDATE ...;
  • DELETE ...;

因为后面两种情况需要先读取 数据行,所以也属于 锁定读 的语句。

因为 READ UNCOMMITTEDREAD COMMITTED 的加锁方式是一样的,所以,一起说就好了。

6.xxxxx 分割线,以下属于 READ UNCOMMITTED/READ COMMITTED 加锁方式

6.2 等值锁定读

针对 SELECT 语句,锁一般加在 聚簇索引 的数据行上面。比方说下面两个语句:

-- S锁
SELECT * FROM stock_info WHERE id = 3 LOCK IN SHARE MODE;
-- X锁
SELECT * FROM stock_info WHERE id = 3 FOR UPDATE;

一个加 S锁,一个加 X锁

那么这条数据行就被加锁了。

而如果我更新这条数据:

UPDATE stock_info SET quantity = 200 WHERE id = 3;
DELETE FROM stock_info WHERE id = 3;

如果这条数据没有索引,那加锁的状态和 SELECT * FROM stock_info WHERE id = 3 FOR UPDATE; 是一致的,就不再画出来了。


而如果更新的列是有 二级索引 的话,那么两步走:

  1. 先在 聚簇索引 上为这条记录加上锁;
  2. 再在 二级索引 上为对应的索引加锁。

比如:

UPDATE stock_info SET prod_id = 99 WHERE id = 3;
DELETE FROM stock_info WHERE id = 3;

而如果查询条件是先从 二级索引 出来的话,比如 UPDATE stock_info SET prod_id = 99 WHERE prod_id = 33;,那加锁顺序又要反过来:

而如果,同时有两个事务,一个 UPDATE stock_info SET prod_id = 99 WHERE id = 3; 另外一个 UPDATE stock_info SET prod_id = 99 WHERE prod_id = 33;,那么:

死锁 了,因为 左事务加锁聚簇索引,后 加锁二级索引,而 右事务加锁顺序 反而是反过来的,这就导致了,左事务 在等待 右事务 释放 二级索引锁,而 右事务 在等待 左事务 释放 聚簇索引锁。所以导致死锁的发生。

UPDATEDELETE 是一样的,所以记住一个结论:

如果使用了索引加锁,则先加二级索引的锁再加聚簇索引的锁,如果使用了聚簇索引则先加聚簇索引的锁再加二级索引的锁。就是谁先到达先加谁的!

6.3 范围锁定读

那按照顺序,依然先看看走 聚簇索引 的流程:

SELECT * FROM stock_info WHERE id < 5;

这个看起来有点简单,无非就是把上面等值的所有列加锁就行了呗,其实 不是的,他是这样子走的:

  1. 先拿到满足条件的第一条记录,也就是 id = 1 的记录,然后加锁;
  2. 判断是否符合 索引条件下推 的条件,索引条件下推 是为了减少回表次数,也就是减少 IO 次数,才会发生 索引条件下推 的现象,而 索引条件下推 主要用于 二级索引,所以这里并不需要判断;
  3. 判断是否符合 范围查询 的边界条件,因为 id = 1 < 5 所以返回给 server层 继续处理,又因为 聚簇索引 是一个有序的 ,所以到了 id = 5 的时候,会释放掉当前循环到的 数据行 的锁,然后告诉 server层 已经 查询完毕
  4. server层 继续判断,上一步中如果已经到达边界的话,会收到 查询完毕 的信号,返回数据给客户端。那如果没有收到 查询完毕 的信号的话,就需要继续重新判断数据有没有满足 id = 1 < 5 这个条件(第二次判断,因为没有满足 索引条件下推 的条件,所以这一层又要判断一次),如果符合就加入到发送给客户端数据的 结果集 里边,如果不符合,要释放 数据行
  5. 拿当前 数据行 的下一条数据的指针,走到第二条数据,重复以上步骤,直到 第4步 查询结束为止。

按照我们循环的习惯来说,id < 5 还会走一下 id = 5 的数据进行判断,所以这条 数据行 会在 第1步第3步 被短暂加一下

所以,又双叒叕可能会出现 死锁 锁竞争 等问题。

而如果条件是 id > 5 呢,首先拿到第一条数据也就是 id = 6 走走走走到了 Supremum 记录的时候,就不会再继续加锁了,直接返回给 server层 处理。


那如果是修改到了 索引数据 ,亦或者先通过 索引 找到需要加锁的记录呢,那就跟上面 等值查询 的索引加锁方式一样了,先遍历到的,就先加锁

所以 UPDATE stock_info SET prod_id = xxx WHERE id < 5,就是先找到 id = 1 的进行加锁,然后去索引再加锁,然后 id = 2id = 3 依次循环下去。

UPDATE stock_info SET prod_id = xxx WHERE prod_id < 55 则加锁顺序相反,其他一样。

依然记得 最后一条边界记录要锁一下

6.4 全表扫描读

SELECT * FROM stock_info FOR UPDATE WHERE quantity > 100;

这个加锁就简单粗暴了,每一条数据循环一次,然后在 server层 判断一下(因为没有 条件下推),满足即加入结果集,不满足则 释放锁

6.xxxxx 分割线,以下属于 REPEATABLE READ 加锁方式

加锁之前,在 6.1 普通读 节说到的例子,REPEATABLE READ 可能还是会出现 幻读 的问题。REPEATABLE READ 加锁的方式则会比上面两种隔离级别要多样化,所以 幻读 问题也顺带解决了。

6.5 等值锁定读

如果查询的记录存在:SELECT * FROM stock_info WHERE id = 5 FOR UPDATE,那么加锁的形式跟之前是一样的,这里就不再重复。

那如果记录不存在的话比如:

BEGIN;
DELETE FROM stock_info WHERE id = 5;
COMMIT;

BEGIN;
SELECT * FROM stock_info WHERE id = 5 FOR UPDATE;

那么前面说了那么多的 GAP锁 就来了:

也就是说在 (4, 6) 区间范围内,有个 GAP锁,那其他事务想要把数据插入到这里的话,就要等我 COMMIT 事务以后才能够插入,也就可以防止上面 6.1 普通读 节说到的 幻读 问题。


而如果用到了 二级索引 比方说 SELECT * FROM stock_info WHERE prod_id = 55 FOR UPDATE 的话,那二级索引对应的节点也会跟 聚簇索引 一样,加上 GAP锁。先后顺序也是取决于先到 二级索引 还是先到 聚簇索引

这里还有一点主意的是,如果 这个二级索引是一个唯一索引,那么 聚簇索引 上的数据就不需要加锁了,因为 二级索引 的列是 唯一的,锁住那一个数据也就可以防止插入数据了。

6.6 范围锁定读

上面 READ UNCOMMITTED/READ COMMITTED 我们说,会为满足条件的 数据行 都加上锁,那么在这里为了防止 幻读,所以还要再加上一个锁:next-key锁。因为既要防止 数据行 被修改,也要防止在空隙里面被插入数据。

比方说 SELECT * FROM stock_info WHERE id < 4,我们说了 READ UNCOMMITTED/READ COMMITTEDid = 4 加了一下锁,又会释放掉,但是在 REPEATABLE READ下,他是不会释放的,并且还加上了 3 到 4 之间的 Next-Key 锁

而如果此时,我对 id < 4 的数据都更新了 prod_id 的列,因为此时使用的是 聚簇索引,所以 二级索引id = 4 的列不会被加锁。

那加锁顺序,肯定就是先 聚簇二级 了。


而如果我们的范围查询发生在 二级索引 上面的话,取而代之的是 二级索引 上的每条记录都会被加 Next-key锁聚簇 上的数据不加 GAP锁

因为需要放置修改 二级索引 以及在 二级索引 间隙插入数据,所以 发生在 二级索引 上,不画图了。

6.7 全表扫描

SELECT * FROM stock_info FOR UPDATE WHERE quantity > 100;

依然使用这条 SQL 查询,因为每读取一个记录就会给这个记录加上 Next-Key锁,返回 server层,判断满足条件则返回给客户端。而如果发生全表扫描并且加了 ,因为 REPEATABLE READ 不会释放锁,所以,发生这种情况的时候,整个表都被锁住了!!!。那其他的 事务 对这个表操作的时候均会被 阻塞

七.INSERT的情况

INSERT 语句插入的时候,如果遇到上一条记录加了 NextKey锁 的话,那么会在该记录上加一个 插入意向锁,并且 事务 进入 阻塞 状态。

而如果插入的时候遇见一些状况,也会 加锁

  1. 遇到重复值的时候,如果说在插入 聚簇索引 或者 唯一二级索引 的时候,发现 INSERT 语句中某个值冲突了(也就是说存在了 索引 中了),那么会对造成冲突的 数据行 加锁:
    1. READ UNCOMMITTED/READ COMMITTEDS型行锁
    2. REPEATABLE READ/SERIALIZABLES型Next-Key锁
    3. 唯一二级索引 统一加上 Next-Key锁
  2. 如果使用的是 INSERT...ON DUPLICATE KEY 语法,会在原来冲突的 数据行 上更新,所以,加什么 肯定就一目了然了吧;
  3. 如果插入的数据带有 外键检查,那么关联到的 数据行 会被加上 S行锁,而如果查找不到 外键记录,则 外键记录 的空白位置在 REPEATABLE READ/SERIALIZABLE 会被加上 GAP锁,其他 隔离级别 则不会加锁。

七.死锁

似乎所有程序聊到 的问题,就会有 死锁 的问题:

7.1 普通资源锁

当两个 事务 锁住的数据需要相互等待的时候,就会出现这种 死锁 的情况。

7.2 插入GAP锁

是这样子的:

T1 T2
begin; begin;
delete from ta where a = 4;
delete from ta where a = 4;
insert into ta(a,b,c) values(4, 11, 3),(4, 2, 5);
insert into ta(a,b,c) values(4, 11, 3),(4, 2, 5);// 出现死锁

这大概就是 T1T2 两个执行了 DELETE 的时候都持有了 GAP锁,两个插入,T1 在等 T2GAP锁 释放,T2 在等 T1插入意向锁 释放,所以 死锁 了。

7.3 更新索引锁

这个在上面的 6.2 等值锁定读 已经说过,不在重复了。