InnoDB - 锁

前置

环境

首先,使用的MySQL版本8.0.18,事务的隔离级别为Repeatable Read

下面给出验证时使用的表结构:

1
2
3
4
5
6
7
8
CREATE TABLE `test` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `a` int(11) NOT NULL,
  `b` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `test_a_IDX` (`a`) USING BTREE,
  KEY `test_b_IDX` (`b`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

验证使用的数据为:

1
INSERT INTO study.test (id,a,b) VALUES (1,1,2), (2,5,6), (3,9,10), (10,11,2);

验证方式

本文中的锁相关知识均通过使用MySQL 8提供的performance_schema.data_locks表进行验证。 该表是MySQL提供的一张内置只读表,通过查询该表能够得到当前所有事务获取锁的状态。

举个例子,执行语句:

1
SELECT * FROM performance_schema.data_locks\G;

语句末尾的\G表示垂直显示查询结果,我们可以获得执行结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753360976:1335:140251771546136
ENGINE_TRANSACTION_ID: 27100351
            THREAD_ID: 1537
             EVENT_ID: 12
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140251771546136
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL

下面对部分字段给出简要的解释:

字段名 描述
OBJECT_SCHEMA 数据库名称
OBJECT_NAME 表名称
LOCK_TYPE 锁类型,本文中出现的有表锁(TABLE)、行锁(RECORD)与间隙锁(GAP)
LOCK_MODE 锁的方式,本文中出现的有独占锁(X)、共享锁(S)、意向独占锁(IX)和意向共享锁(IS),而RECORD_NOT_GAP表示降级为记录锁,GAP表示为间隙锁
LOCK_STATUS 获取锁的状态,本文中出现有已获取(GRANTED)和等待(WAITING)
LOCK_DATA 锁住的数据,用,分割,前面表示本索引中的数据,后面的表示主键(主键索引没有第二部分)

锁分类

MySQL中的锁分类可以通过2中角度进行:

  1. 按锁的行为进行分类可分为独占锁、共享锁、意向独占锁和意向共享锁
  2. 按锁的粒度进行分类可分为表所、行锁

对于独占锁和共享锁,它们又被称做读锁和写锁,相比于单纯的独占锁而言能够保证读写一致性的前 提下提高事务的并发能力。

意向锁(Intention Lock)是在事务获取行锁之前对表的一种锁行为, 表示你想要获取该表中某些行的锁。使用这个锁的目的是为了防止已经有事务锁住了整张表, 并且阻止阻塞后续其他事务锁住整张表的行为。

不同事务对于同一个对象获取锁的模式不同的兼容性如下表所示:

IX IS X S
IX 兼容 兼容 冲突 冲突
IS 兼容 兼容 冲突 兼容
X 冲突 冲突 冲突 冲突
S 冲突 兼容 冲突 兼容

上表中的兼容性很好理解,由于意向锁只会作用于表,因此上面的同一对象指的就是表。 而XS在针对表时可以理解为对表中所有行获取独占/共享锁。所以,总体可分为3种情况:

  1. 必然会产生冲突,不兼容
  2. 而可能产生冲突,兼容,在获取行锁时才可能冲突
  3. 必然不会冲突,兼容

读分类

非锁定读

非锁定读的意思实在进行数据库查询时不获取任何锁进行查询,查询方式只有1种:

1
SELECT * FROM test WHERE a = 2;

上述的语句就是我们平时在进行开发时最常使用的SQL语句类型,也就是说我们平时的开发其实 使用的就是非锁定读进行的。而这种读取方式得到的结果和事务的隔离级别相关,关联如下:

隔离级别 行为
Read Uncommitted 不获取锁,直接读取当前最新的记录
Read Committed 不获取锁,通过MVCC读取最新的快照数据
Repeatable Read 不获取锁,通过MVCC保证事务只能读取到事务开启时已被其他事务提交的内容
Serializable 通过获取共享锁进行查询,相当于语句后添加LOCK IN SHARE MODE

锁定读

锁定读的方式显式的有2种,分别是获取独占锁读取和获取共享锁读取:

1
2
SELECT * FROM test WHERE a = 2 LOCK IN SHARE MODE;
SELECT * FROM test WHERE a = 2 FOR UPDATE;

锁定读即是对WHERE子句所选定的所有记录通过锁的形式进行操作,而当其他事务也使用锁定读时,就 需要通过锁的兼容性去判断是否兼容。当发生不兼容的情况,就需要阻塞事务并等待。

除此之外,使用UPDATEDELETE以及INSERT都会获取独占锁,因此使用锁定读时能够通过阻塞其他 事务修改数据从而保证一致性。

锁实现

记录锁(Record Lock)

记录锁的是指针对WHERE所指定的记录进行加锁。对于任意一个锁定读查询语句而言,如果使用到了索引 那么就能够使用行锁。而锁住记录的方式就是锁住索引,但这里存在一个问题,即使用不同索引查询的2个 SELECT语句可能指向同一行,这种情况下单单锁住使用的索引无法完全锁住行。为了解决这个问题,在 使用非主键索引时,会同时锁住查询的行对应的主键索引。

举个例子,假定有表test并在a上有索引idx_a,则SELECT * FROM test WHERE a = 2 FOR UPDATE 的锁获取顺序:

  1. 获取test表的独占意向锁
  2. 获取idx_a中指定记录的记录锁
  3. 得到查询记录的主键并通过主键索引锁住该主键

间隙锁(Gap Lock)

首先,我们需要了解仅仅使用记录锁会导致的问题。记录锁只能够锁住已经存在的记录,而这会使得 后进行的事务INSERT的记录被之前的事务得到,这就是幻读

幻读是指在事务中执行同一个SQL语句,会出现由于其他事务在该查询范围中插入数据导致执行多次 相同的查询得到结果(行数)不一致的情况。

为了解决这个问题,InnoDB引入了间隙锁。间隙锁会锁住某个索引范围,举个例子,首先我们给出以下表

id a
1 1
2 5
3 9
10 11

假定a存在索引a_idx,那么针对索引就存在5个间隙锁的区间,分别为 (-∞, 1)、(1, 5)、(5, 9)、(9, 11)、(11, +∞)。如果锁住的(-∞, 1),则表示在这个区间内不允许插入 新的记录。注意到,这些区间没有闭合,这是由于取等号的都存在对应的记录,也就是存在记录锁。

Next-Key Lock

Repeatable Read的隔离级别下,InnoDB会结合上述两个锁算法。变化在于针对上面的表格,会存在 5个区间,分别为(-∞, 1]、(1, 5]、(5, 9]、(9, 11]、(11, +∞),即同时锁住记录和区间。

Next-Key Lock会在几种情况下产生变化:

  1. 当使用唯一索引时,会降级成Record Lock
  2. 当使用非唯一索引的等值比较时,例如1,会锁住(-∞, 1]以及(1, 5)这两个区间,后面的区间为间隙锁

顺便一提,在不同的隔离级别下以及读取方式下,解决幻读的方式并不一致

Repeatable Read级别下:

  1. 使用非锁定读时,通过MVCC能够将当前未提交甚至未开启的事务的执行结果排除
  2. 使用锁定读时,通过间隙锁能够阻塞其他想要在该范围能插入和删除记录的事务

而在Serializable级别下,所有查询均会变为锁定读。 因此,在InnoDB中,只要事务的隔离级别是Repeatable ReadSerializable,那么幻读就不会出现。

锁验证

通过主键查询

首先,建立2个数据库连接A和B,在A中我们开启一个事务,并通过建立索引的行对表中的数据锁定读查询。

1
2
BEGIN;
SELECT * FROM test WHERE id = 1 FOR UPDATE

然后在B中观察performance_schema.data_locks

1
SELECT * FROM performance_schema.data_locks\G;

得到结果去如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753360976:1335:140251771546136
ENGINE_TRANSACTION_ID: 27102484
            THREAD_ID: 1537
             EVENT_ID: 27
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140251771546136
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753360976:272:4:2:140251771573272
ENGINE_TRANSACTION_ID: 27102484
            THREAD_ID: 1537
             EVENT_ID: 27
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140251771573272
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 1
2 rows in set (0.01 sec)

由于WHERE id = 1使用了主键索引,因此使用行锁。获取锁的顺序如下:

  1. 获取test表的意向独占锁,表示想要获取该表中的某些行的独占锁
  2. 通过PRIMARY主键索引锁住WHERE id = 1指定的行,REC_NOT_GAP表明为单纯的记录锁,而非Next-Key Lock

通过主键查询是最具效率的查询方式,不仅仅因为主键索引是聚簇索引,也因为获取的锁是最少的。

通过唯一索引查询

首先,建立2个数据库连接A和B,在A中我们开启一个事务,并通过建立索引的行对表中的数据锁定读查询。

1
2
BEGIN;
SELECT * FROM test WHERE a = 5 FOR UPDATE;

然后在B中观察performance_schema.data_locks

1
SELECT * FROM performance_schema.data_locks\G;

得到结果去如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:1341:140251771544600
ENGINE_TRANSACTION_ID: 27728943
            THREAD_ID: 23236
             EVENT_ID: 34
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140251771544600
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:5:3:140251771567128
ENGINE_TRANSACTION_ID: 27728943
            THREAD_ID: 23236
             EVENT_ID: 34
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: test_a_IDX
OBJECT_INSTANCE_BEGIN: 140251771567128
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 5, 2
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:4:3:140251771567472
ENGINE_TRANSACTION_ID: 27728943
            THREAD_ID: 23236
             EVENT_ID: 34
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140251771567472
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2
3 rows in set (0.02 sec)

由于子句WHERE a = 5可以使用索引,因此使用行锁。而当所用的索引并非主键索引时, 需要额外锁住主键索引。这样做可以防止不同的索引指向同一行导致同一行的并发修改问题。 针对上述执行的语句,整体获取锁的顺序为:

  1. 获取test表的意向独占锁,表示想要获取该表中的某些行的独占锁
  2. 通过test_a_IDX索引锁住被WHERE a = 5指定的行,REC_NOT_GAP表明为单纯记录锁,而非Next-Key Lock
  3. 通过PRIMARY索引锁住被WHERE a = 5指定的行,REC_NOT_GAP表明为单纯记录锁,而非Next-Key Lock

通过非唯一索引查询

首先,建立2个数据库连接A和B,在A中我们开启一个事务,并通过建立索引的行对表中的数据锁定读查询。

1
2
BEGIN;
SELECT * FROM test WHERE b = 2;

然后在B中观察performance_schema.data_locks

1
SELECT * FROM performance_schema.data_locks\G;

得到结果去如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
*************************** 1. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:1341:140251771544600
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 140251771544600
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:6:2:140251771567128
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: test_b_IDX
OBJECT_INSTANCE_BEGIN: 140251771567128
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2, 1
*************************** 3. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:6:5:140251771567128
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: test_b_IDX
OBJECT_INSTANCE_BEGIN: 140251771567128
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2, 10
*************************** 4. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:4:2:140251771567472
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140251771567472
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 1
*************************** 5. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:4:5:140251771567472
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 140251771567472
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 10
*************************** 6. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 140251753359232:278:6:3:140251771567816
ENGINE_TRANSACTION_ID: 27728912
            THREAD_ID: 23236
             EVENT_ID: 30
        OBJECT_SCHEMA: study
          OBJECT_NAME: test
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: test_b_IDX
OBJECT_INSTANCE_BEGIN: 140251771567816
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 6, 2
6 rows in set (0.02 sec)

由于子句WHERE b = 2可以使用索引,因此使用行锁。而test_b_IDX是非唯一索引, 因此还需要获取相应范围内的间隙锁,阻塞该范围内的插入操作并防止幻读。 针对上述执行的语句,整体获取锁的顺序为:

  1. 获取test表的意向独占锁,表示想要获取该表中的某些行的独占锁
  2. 通过test_b_IDX索引锁住被WHERE b = 2指定的行
  3. 通过PRIMARY索引锁住被WHERE b = 2指定的行,RECORD_NOT_GAP表示仅仅是记录锁,而非Next-Key Lock
  4. 通过test_b_IDX锁住范围(2, 6)的间隙锁,GAP表示不会锁住6对应的记录,仅仅为间隙锁

总结

对于InnoDB中的锁,能够给出以下结论:

  • 锁可分为独占锁、共享锁、意向独占锁和意向共享锁,在获取行锁之前需要获取表的意向锁
  • 锁可分为表锁和行锁,行所粒度更细,并发能力更强却也更复杂
  • 如果查询中没有使用索引,则使用表锁
  • 如果查询中使用了索引,则会先获取表的意向锁,然后通过索引锁住查询的行
  • 间隙锁可以阻塞在查询范围中插入其他数据的事务,保证不会产生幻读
  • Repeatable Read隔离下使用Next-Key Lock,但会根据实际情况进行降级以提高并发能力
  • 当使用主键索引时,获取表意向锁和主键行锁(降级为记录锁)
  • 当使用唯一索引时,获取表意向锁、索引行锁(降级为记录锁)和主键行锁(降级为记录锁)
  • 当使用非唯一索引时,获取表意向锁、索引行锁、主键行锁和索引间隙锁
  • Serializable的本质是将所有的读操作转为锁定读
Built with Hugo
主题 StackJimmy 设计