前置
环境
首先,使用的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中角度进行:
- 按锁的行为进行分类可分为独占锁、共享锁、意向独占锁和意向共享锁
- 按锁的粒度进行分类可分为表所、行锁
对于独占锁和共享锁,它们又被称做读锁和写锁,相比于单纯的独占锁而言能够保证读写一致性的前
提下提高事务的并发能力。
而意向锁(Intention Lock)是在事务获取行锁之前对表的一种锁行为,
表示你想要获取该表中某些行的锁。使用这个锁的目的是为了防止已经有事务锁住了整张表,
并且阻止阻塞后续其他事务锁住整张表的行为。
不同事务对于同一个对象获取锁的模式不同的兼容性如下表所示:
|
IX |
IS |
X |
S |
IX |
兼容 |
兼容 |
冲突 |
冲突 |
IS |
兼容 |
兼容 |
冲突 |
兼容 |
X |
冲突 |
冲突 |
冲突 |
冲突 |
S |
冲突 |
兼容 |
冲突 |
兼容 |
上表中的兼容性很好理解,由于意向锁只会作用于表,因此上面的同一对象指的就是表。
而X
和S
在针对表时可以理解为对表中所有行获取独占/共享锁。所以,总体可分为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
子句所选定的所有记录通过锁的形式进行操作,而当其他事务也使用锁定读时,就
需要通过锁的兼容性去判断是否兼容。当发生不兼容的情况,就需要阻塞事务并等待。
除此之外,使用UPDATE
、DELETE
以及INSERT
都会获取独占锁,因此使用锁定读时能够通过阻塞其他
事务修改数据从而保证一致性。
锁实现
记录锁(Record Lock)
记录锁的是指针对WHERE
所指定的记录进行加锁。对于任意一个锁定读查询语句而言,如果使用到了索引
那么就能够使用行锁。而锁住记录的方式就是锁住索引,但这里存在一个问题,即使用不同索引查询的2个
SELECT
语句可能指向同一行,这种情况下单单锁住使用的索引无法完全锁住行。为了解决这个问题,在
使用非主键索引时,会同时锁住查询的行对应的主键索引。
举个例子,假定有表test
并在a
上有索引idx_a
,则SELECT * FROM test WHERE a = 2 FOR UPDATE
的锁获取顺序:
- 获取
test
表的独占意向锁
- 获取
idx_a
中指定记录的记录锁
- 得到查询记录的主键并通过主键索引锁住该主键
间隙锁(Gap Lock)
首先,我们需要了解仅仅使用记录锁会导致的问题。记录锁只能够锁住已经存在的记录,而这会使得
后进行的事务INSERT
的记录被之前的事务得到,这就是幻读。
幻读是指在事务中执行同一个SQL语句,会出现由于其他事务在该查询范围中插入数据导致执行多次
相同的查询得到结果(行数)不一致的情况。
为了解决这个问题,InnoDB引入了间隙锁。间隙锁会锁住某个索引范围,举个例子,首先我们给出以下表
假定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会在几种情况下产生变化:
- 当使用唯一索引时,会降级成Record Lock
- 当使用非唯一索引的等值比较时,例如1,会锁住(-∞, 1]以及(1, 5)这两个区间,后面的区间为间隙锁
顺便一提,在不同的隔离级别下以及读取方式下,解决幻读的方式并不一致
在Repeatable Read
级别下:
- 使用非锁定读时,通过MVCC能够将当前未提交甚至未开启的事务的执行结果排除
- 使用锁定读时,通过间隙锁能够阻塞其他想要在该范围能插入和删除记录的事务
而在Serializable
级别下,所有查询均会变为锁定读。
因此,在InnoDB中,只要事务的隔离级别是Repeatable Read
和Serializable
,那么幻读就不会出现。
锁验证
通过主键查询
首先,建立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
使用了主键索引,因此使用行锁。获取锁的顺序如下:
- 获取
test
表的意向独占锁,表示想要获取该表中的某些行的独占锁
- 通过
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
可以使用索引,因此使用行锁。而当所用的索引并非主键索引时,
需要额外锁住主键索引。这样做可以防止不同的索引指向同一行导致同一行的并发修改问题。
针对上述执行的语句,整体获取锁的顺序为:
- 获取
test
表的意向独占锁,表示想要获取该表中的某些行的独占锁
- 通过
test_a_IDX
索引锁住被WHERE a = 5
指定的行,REC_NOT_GAP
表明为单纯记录锁,而非Next-Key Lock
- 通过
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
是非唯一索引,
因此还需要获取相应范围内的间隙锁,阻塞该范围内的插入操作并防止幻读。
针对上述执行的语句,整体获取锁的顺序为:
- 获取
test
表的意向独占锁,表示想要获取该表中的某些行的独占锁
- 通过
test_b_IDX
索引锁住被WHERE b = 2
指定的行
- 通过
PRIMARY
索引锁住被WHERE b = 2
指定的行,RECORD_NOT_GAP
表示仅仅是记录锁,而非Next-Key Lock
- 通过
test_b_IDX
锁住范围(2, 6)
的间隙锁,GAP
表示不会锁住6对应的记录,仅仅为间隙锁
总结
对于InnoDB中的锁,能够给出以下结论:
- 锁可分为独占锁、共享锁、意向独占锁和意向共享锁,在获取行锁之前需要获取表的意向锁
- 锁可分为表锁和行锁,行所粒度更细,并发能力更强却也更复杂
- 如果查询中没有使用索引,则使用表锁
- 如果查询中使用了索引,则会先获取表的意向锁,然后通过索引锁住查询的行
- 间隙锁可以阻塞在查询范围中插入其他数据的事务,保证不会产生幻读
Repeatable Read
隔离下使用Next-Key Lock,但会根据实际情况进行降级以提高并发能力
- 当使用主键索引时,获取表意向锁和主键行锁(降级为记录锁)
- 当使用唯一索引时,获取表意向锁、索引行锁(降级为记录锁)和主键行锁(降级为记录锁)
- 当使用非唯一索引时,获取表意向锁、索引行锁、主键行锁和索引间隙锁
Serializable
的本质是将所有的读操作转为锁定读