346 lines
19 KiB
Markdown
346 lines
19 KiB
Markdown
# 锁
|
||
## lock 和 latch
|
||
在mysql数据库中,lock和latch都可以被称之为锁,但是两者锁包含意义不同。
|
||
### latch
|
||
latch一般为轻量级的锁,要求锁定时间通常非常短,否则会影响性能。在innodb中,latch可以分为mutex和rwlock。
|
||
|
||
`latch通常被用于防止代码中临界资源的并发访问`,保证并发操作的安全性。latch操作通常没有死锁检测机制。
|
||
|
||
### lock
|
||
lock和latch则稍有区别,lock一般针对的是事务,用于锁定数据库中的表、页、行等数据。并且,`lock一般在数据库事务进行commit或rollback后才释放`。
|
||
|
||
在innodb中,lock拥有死锁检测机制。
|
||
|
||
latch和lock的比较如下:
|
||
| | lock | latch |
|
||
| :-: | :-: | :-: |
|
||
| 对象 | 事务 | 线程 |
|
||
| 保护 | 数据库内容 | 代码中的临界资源 |
|
||
| 持续时间 | 整个事务过程 | 临界资源 |
|
||
| 模式 | 行锁、表锁、意向锁 | 读写锁、互斥量 |
|
||
| 死锁 | 通过waits-for graph、timeout等机制检测死锁 | 无死锁检测机制 |
|
||
|
||
在innodb中,可以通过`show engine innodb status`以及`information_schema`下的`innodb_trx`来查看锁的情况。
|
||
|
||
## innodb中的锁
|
||
innodb中针对行级锁有如下两种实现:
|
||
- S lock(共享锁):允许事务针对一行数据进行读取
|
||
- X lock(排他锁):允许事务针对一行数据进行更新和删除
|
||
|
||
S lock和S lock之间可以互相兼容,但是S lock和X lock、 X lock和 X lock之间都是不兼容的。当一个事务t持有某行数据的X lock时,其他事务必须等待事务t提交或回滚之后才能获取该行数据的S lock或X lock。
|
||
|
||
除了行锁之外,innodb还支持多粒度的锁定,允许行级和表级的锁同时存在。为了支持该操作,innodb引入了一种额外的加锁方式`意向锁`。
|
||
|
||
### 意向锁
|
||
意向锁支持多粒度加锁,允许行锁和表锁共存。例如,`lock tables ... write`会在指定表上加X锁。为了在多个粒度上进行加锁,innodb使用了意向锁,意向锁是表级锁,代表事务稍后需要为表中的数据加上哪种锁。
|
||
|
||
意向锁分为两种类型:
|
||
- IS: 事务旨在为table中独立的行添加共享锁
|
||
- IX: 事务旨在为table中的行添加排他锁
|
||
|
||
例如,`select ... for share`设置IS锁,而`select ... for update`则设置了IX锁。
|
||
|
||
意向锁协议如下:
|
||
- 在事务获取table中某行记录的s锁之前,其必须获取table的IS或更高级别锁
|
||
- 在事务获取table中某行记录的x锁之前,其必须获取table的IX锁
|
||
|
||
table level锁的兼容性如下所示:
|
||
| | x | ix | s | is |
|
||
| :-: | :-: | :-: | :-: | :-: |
|
||
| x | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
|
||
| ix | 不兼容 | 兼容 | 不兼容 | 兼容 |
|
||
| s | 不兼容 | 不兼容 | 兼容 | 兼容 |
|
||
| is | 不兼容 | 兼容 | 兼容 | 兼容 |
|
||
|
||
如果要加的锁和已经存在的锁兼容,那么它将被授予请求加锁的事务;要加的锁和已经存在的锁冲突时,则不会授予。请求加锁的事务将会阻塞,一直等待到存在冲突的锁被释放。
|
||
|
||
如果事务中的锁请求因为与现有锁冲突而无法被授予,并且造成死锁,那么会抛出异常。
|
||
|
||
> 意向锁和意向锁之间是兼容的,故而意向锁只会和全表扫描相冲突。
|
||
|
||
引入意向锁的意图是展示有人锁定了表中的某一行,或是将要锁定表中的某一行。
|
||
|
||
事务和意向锁相关的信息可以通过`show engine innodb status`来展示,其输出如下:
|
||
```
|
||
TABLE LOCK table `test`.`t` trx id 10080 lock mode IX
|
||
```
|
||
|
||
### Record Lock
|
||
记录锁是针对一条index record的锁。例如,`SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE`语句会阻塞任何其他事务针对`c1 = 10`记录的插入、更新或删除。
|
||
|
||
records总是会针对index record加锁,即使当前table没有定义索引。对于table未定义索引的情况,innodb会创建一个隐藏的聚簇索引,并使用该聚簇索引来进行record lock。
|
||
|
||
事务相关record lock的信息,可以通过`SHOW ENGINE INNODB STATUS`来展示,其输出格式如下:
|
||
```
|
||
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
|
||
trx id 10078 lock_mode X locks rec but not gap
|
||
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
|
||
0: len 4; hex 8000000a; asc ;;
|
||
1: len 6; hex 00000000274f; asc 'O;;
|
||
2: len 7; hex b60000019d0110; asc ;;
|
||
```
|
||
|
||
### 一致性读
|
||
#### 一致性非锁定读
|
||
`一致性非锁定读`是指innodb通过mvcc(多版本并发控制)来读取行数据。如果当前待读取的行正在执行update或delete操作,那么此时`读取行的操作并不会被阻塞,而是会去读取当前被修改行的快照历史版本`。这种行为被成为`非锁定读`。
|
||
|
||
在非锁定读的场景下,事务读取行数据时并不需要等待其他事务持有的X锁释放,而是之前读取行数据的历史快照版本。`历史快照版本通过`undo log`来生成`。
|
||
|
||
非锁定读极大的提高了数据库的并发性,一个事务对行数据的写操作并不会阻塞其他的事务对该行进行读取。innodb在默认的隔离级别下,默认通过非锁定读来读取数据。
|
||
|
||
`在不同事务隔离级别下,读取数据的方式可能会不同,并非所有隔离级别都采用非锁定读的读取方式`。
|
||
|
||
> #### 多版本并发控制(mvcc)
|
||
> 在非锁定读场景下,一行数据通常不会只有一个历史版本,其数据快照并不只有一个,这被称为多版本并发控制。
|
||
>
|
||
> innodb对read committed和repeatable read隔离级别使用非锁定读,但是,两种隔离级别`快照定义并不相同`。
|
||
>
|
||
> #### read committed
|
||
>
|
||
> 在read committed隔离级别下,非锁定读总是会读取行数据`最新的快照`。
|
||
>
|
||
> #### repeatable read
|
||
> 在repeatable read隔离级别下,非锁定读则是会读取`事务开始时的行数据版本`。
|
||
|
||
|
||
|
||
|
||
#### 一致性锁定读
|
||
在默认隔离级别下,read committed和repeatable read都是用非锁定读,但是,可以通过语法显式的支持锁定读。`用户可以通过加锁来保证读取数据的一致性`。
|
||
|
||
在通过select语句对数据加锁时,支持加两种类型的锁:
|
||
- select ... for update
|
||
- select ... lock in share mode
|
||
|
||
`select ... for update`实际是对读取的行记录加上X锁,其他事务均不能对该数据添加任何的锁。
|
||
|
||
`select ... lock in share mode`则是对读取的记录加上S锁,其他事务可以向被锁定记录加S锁,但是不能加X锁。
|
||
|
||
`对于非锁定读,即使数据被添加了X锁,也可以进行读取。`只有通过`for update`或`lock in share mode`在读取时添加X锁或S锁时,读取操作才会被阻塞。
|
||
|
||
并且,`for update`或`lock in share mode`添加的行锁,在事务commit或rollback时会被释放。
|
||
|
||
|
||
### Gap Lock
|
||
gap lock是针对index record之间的间隙来进行加锁的(或是针对第一条index record之前或最后一条index record之后的间隙进行加锁)。例如,`SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE; `语句会阻止其他事务在c1值的`10~20`范围之间插入数据,不管这条数据存不存在,在`10~20`的间隙之前所有的值都被锁定了。
|
||
|
||
间隙范围可能跨单个索引值,多个索引值甚至为空。
|
||
|
||
间隙锁在部分的事务隔离级别中有使用,在其他隔离级别中则不会被使用。
|
||
|
||
在通过unique index区查询唯一行时,并不需要使用间隙锁(并不包含unique复合索引的情况)。例如,在id为唯一索引的情况下,如下所示的语句只会使用index record lock:
|
||
```sql
|
||
SELECT * FROM child WHERE id = 100;
|
||
```
|
||
|
||
但是,如果id并不是索引,或者id的缩影并不unique,那么上述语句会对之前的间隙进行加锁。
|
||
|
||
值得注意的是,不同事务针对同一间隙,可以持有相互冲突的锁。例如,A事务针对间隙持有S锁,而B事务针对间隙持有X锁,当index record从索引中被清除时,不同事务所持有的间隙锁将会被合并。
|
||
|
||
在innodb中,间隙锁只是为了防止其他事务在该间隙中插入锁,间隙锁是可以共存的。如果事务A针对间隙加锁,这并不会阻止事务B获取同一间隙的锁。`间隙的S锁和X锁都遵循该规则,且S锁和X锁之间并不会相互冲突,间隙的S锁和X锁功能相同`。
|
||
|
||
间隙锁可以被显示的禁用,如果将事务的隔离级别改为`read committed`,在查找和索引扫描时间隙锁将会被禁用,间隙锁只会被用于外键检查和重复key检查。
|
||
|
||
### 行锁算法
|
||
innodb中有三种行锁的算法,如下:
|
||
- Record Lock:单个行记录上的锁
|
||
- Gap Lock:间隙锁,锁定一个范围,但是不包含记录本身
|
||
- Next-Key Lock:Gap Lock + Record Lock,锁定一个范围,并且锁定记录本身
|
||
|
||
Record Lock是针对索引加的锁,`如果在建表时没有为表设置索引,那么innodb会采用隐式的主键来进行锁定。`
|
||
|
||
#### Next-Key Lock
|
||
Next-Key Lock结合了record lock和gap lock,如果一个索引中有10,11,13,20这四条记录,那么next-key lock的可锁定区间如下:
|
||
- (-∞, 10]
|
||
- (10, 11]
|
||
- (11, 13]
|
||
- (13, 20]
|
||
- (20, +∞)
|
||
|
||
Next-Key Lock是为了解决幻读问题而引入的,如果事务T1已经锁定了`(10, 11]`和`(11, 13]`区间,那么在T1插入值为12的记录时,锁定的范围会变为:
|
||
`(10, 11] (11, 12] (12, 13]`。
|
||
|
||
但是,`如果查询的索引为unique索引,那么innodb则是会针对next-key lock进行优化,将其降级为record lock,紧锁住索引本身,而不对范围进行加锁。`
|
||
|
||
|
||
#### insert-intention lock
|
||
插入意向锁是在插入行数据前,由`插入操作`设置的间隙锁。多个事务在针对同一间隙进行插入操作时,`如果他们并不在同一位置进行插入,那么各个事务之间并不需要彼此等待`。
|
||
|
||
例如,多个事务需要在`(4, 7)`的间隙之间插入值,但是A事务插入5,B事务插入6,那么在获取待插入行的X锁之前,都需要通过insert intention lock来锁住`(4, 7)`的间隙。插入意向锁之间并不会相互阻塞。
|
||
|
||
|
||
### 加锁示例
|
||
首先,创建数据库并预制数据
|
||
```sql
|
||
-- ddl
|
||
CREATE TABLE `t_learn_lock` (
|
||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||
`content` varchar(32) NOT NULL DEFAULT '',
|
||
`lv` int NOT NULL DEFAULT '-1',
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_lv` (`lv`)
|
||
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||
|
||
-- dml
|
||
insert into t_lear_lock(id, content, lv) values
|
||
(1, 'a', 3),
|
||
(2, 'd', 7),
|
||
(3, 'f', 9),
|
||
(4, 'o', 13);
|
||
```
|
||
|
||
会话的事务默认隔离级别为repeatable read
|
||
|
||
#### performance_schema.data_locks
|
||
`performace_schema.data_locks`中存储了加锁情况,其表结构如下:
|
||
- engine:该锁代表的存储引擎
|
||
- engine_transaction_id: 存储引擎中标识事务的id
|
||
- thread_id: `创建该锁的会话`的线程id
|
||
- object_schema: 包含该表的schema
|
||
- object_name:表名称
|
||
- index_name: 被加锁的索引名称
|
||
- lock_type: 加锁的类型,对于innodb,其值为record或table,代表行锁或表锁
|
||
- lock_mode: 请求锁的方式,对于innodb,其格式为`S[,GAP], X[,GAP], IS[,GAP], IX[,GAP], AUTO_INC, and UNKNOWN`。
|
||
- lock_data:锁相关的数据
|
||
- lock_status: 请求锁的状态,在innodb中,可以为`GRANTED或WAITING`
|
||
|
||
> #### lock mode
|
||
> 在lock mode字段中,除了auto_inc和unknown之外的值都代表gap lock
|
||
>
|
||
> 其取值如下:
|
||
> - X: next_key_locking,`代表x和x之前的间隙`
|
||
> - X,GAP:间隙锁,`代表x之前的间隙,不包含x记录本身`
|
||
> - X, REC_NOT_GAP:`代表X记录本身,不包含之前的间隙`
|
||
> - X,GAP,INSERT_INTENTION:插入意向锁,彼此之间不互斥
|
||
|
||
#### select ... 加锁情况
|
||
在开启事务后,执行`select * from t_lear_lock`语句,默认`并不会为index record或表加上任何锁`,在可重复读的隔离级别下,默认会采用无锁的一致性读方式,来读取数据的历史版本快照,期间并不需要进行任何加锁操作。
|
||
|
||
#### select ... for update 加锁情况
|
||
在开启事务后,如果执行`select ... for update`语句,那么分为如下几种场景:
|
||
|
||
##### select语句未命中索引
|
||
若执行`select * from t_learn_lock where content < 'h' and content > 'e' for update for update;`语句,由于content字段上并没有添加索引,故而该查询语句并不会命中索引,possible_keys为空。
|
||
|
||
在未命中索引的情况下,`select ... for update`语句`会针对主键索引来进行加锁`,其加锁情况如下:
|
||
|
||
|INDEX_NAME|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|----------|---------|---------|-----------|---------|
|
||
||TABLE|IX|GRANTED||
|
||
|PRIMARY|RECORD|X|GRANTED|supremum pseudo-record|
|
||
|PRIMARY|RECORD|X|GRANTED|1|
|
||
|PRIMARY|RECORD|X|GRANTED|2|
|
||
|PRIMARY|RECORD|X|GRANTED|3|
|
||
|PRIMARY|RECORD|X|GRANTED|4|
|
||
|
||
其中,`select * from t_learn_lock where content < 'h' and content > 'e' for update for update;`会查询出id为`3`的记录,但是由于未命中索引,`该语句会针对主键中所有的index record进行加锁(包括 supremum prseudo-record)`。
|
||
|
||
并且,在可重复读(repeatable read)的隔离级别下,会针对所有的记录添加`next-key`锁,锁住index record和位于该记录之前的间隙。
|
||
|
||
而在读已提交(read committed)的隔离级别下,则只会针对index record来进行加锁,并不会添加间隙锁。
|
||
|
||
> ##### supremum preseudo-record
|
||
> 该index record并不是一条真实存在的索引记录,其代表了`比索引中所有记录的值都大的一条虚拟记录`,类似有序链表中的尾节点。
|
||
|
||
##### select语句命中非unique索引
|
||
当执行的select语句,其where条件命中非unique索引,`那么,其会针对命中索引以及主键索引都进行加锁`。
|
||
|
||
例如,`select * from t_learn_lock where lv between 4 and 8 for update;`,该语句会针对`idx_lv`索引记录和主键索引记录进行加锁,加锁状况如下:
|
||
|
||
|INDEX_NAME|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|----------|---------|---------|-----------|---------|
|
||
||TABLE|IX|GRANTED||
|
||
|idx_lv|RECORD|X|GRANTED|7, 2|
|
||
|idx_lv|RECORD|X|GRANTED|9, 3|
|
||
|PRIMARY|RECORD|X,REC_NOT_GAP|GRANTED|2|
|
||
|
||
上述查询语句会命中`(2, 'd', 7)`这条记录,但是,其针对索引加锁的操作如下:
|
||
- 对于`idx_lv`索引记录,不仅对语句查询出的`(7,2)`这条索引记录加了`next-key`锁,还针对了下一条索引记录`(9,3)`加了`next-key`锁
|
||
- 对于`primary`主键索引记录,则只针对查询出的id为2的记录进行了加锁`(2)`
|
||
|
||
对于`idx_lv`中`(9,3)`这条记录进行加锁,主要是为了防止幻读,当对`(9, 3)`这条记录加锁后,如果后续其他事务想要向lv值为`(7,9)`的范围内进行加锁时,会被间隙锁阻塞,故而保证多次读取结果都一致。
|
||
|
||
##### select语句命中主键索引
|
||
当unique命中主键索引时,则是会针对主键的index record添加记录锁(非间隙锁),示例如下`select * from t_learn_lock where id = 3 for update`,其加锁状况如下:
|
||
|
||
|ENGINE_TRANSACTION_ID|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|---------------------|---------|---------|-----------|---------|
|
||
|4381865|TABLE|IX|GRANTED||
|
||
|4381865|RECORD|X,REC_NOT_GAP|GRANTED|3|
|
||
|
||
|
||
#### update ... 加锁情况
|
||
##### update语句未命中索引的加锁情况
|
||
类似于`select ... for update`未命中索引的情况,update操作在未命中索引的情况下,`也会针对主键索引的记录进行加锁,并且会获取主键索引中所有记录的锁`。
|
||
|
||
其加锁情况和`select ... for update`一样,例如`update t_learn_lock set content = 'fuck' where content = 'm';`,该语句并不会实际修改任何行记录,但是还是会锁住主键中所有的index record。
|
||
|
||
|INDEX_NAME|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|----------|---------|---------|-----------|---------|
|
||
||TABLE|IX|GRANTED||
|
||
|PRIMARY|RECORD|X|GRANTED|supremum pseudo-record|
|
||
|PRIMARY|RECORD|X|GRANTED|1|
|
||
|PRIMARY|RECORD|X|GRANTED|2|
|
||
|PRIMARY|RECORD|X|GRANTED|3|
|
||
|PRIMARY|RECORD|X|GRANTED|4|
|
||
|
||
##### update语句命中非unique索引
|
||
类似于`select ... for update`命中非unique索引一样,update语句在命中非unique索引之后,`也会同时针对命中索引和主键索引进行加锁`。
|
||
|
||
例如,在`update t_learn_lock set content = 'fuck' where lv between 4 and 8;`该更新语句命中`idx_lv`索引之后,既针对`idx_lv`索引进行了加锁,又针对`primary`主键索引进行了加锁。
|
||
|
||
同样和select相似的是,该update语句只会修改`(2, 'd', 7)`记录,但是update语句仍然获取了`(3, 'f', 9)`记录的锁。
|
||
|
||
update语句获取锁的情况如下:
|
||
|
||
|INDEX_NAME|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|----------|---------|---------|-----------|---------|
|
||
||TABLE|IX|GRANTED||
|
||
|idx_lv|RECORD|X|GRANTED|7, 2|
|
||
|idx_lv|RECORD|X|GRANTED|9, 3|
|
||
|PRIMARY|RECORD|X,REC_NOT_GAP|GRANTED|3|
|
||
|PRIMARY|RECORD|X,REC_NOT_GAP|GRANTED|2|
|
||
|
||
类似`select ... for update`,update语句针对非unique索引进行了`next-key`加锁。
|
||
|
||
##### update语句命中主键索引
|
||
如果`update ...`语句命中主键索引,那么类似select语句,也同样只会针对索引记录添加记录所(非间隙)
|
||
|
||
`update t_learn_lock set content = 'fuck' where id= 3;`语句加锁情况如下:
|
||
|
||
|INDEX_NAME|LOCK_TYPE|LOCK_MODE|LOCK_STATUS|LOCK_DATA|
|
||
|----------|---------|---------|-----------|---------|
|
||
||TABLE|IX|GRANTED||
|
||
|PRIMARY|RECORD|X,REC_NOT_GAP|GRANTED|3|
|
||
|
||
### innodb死锁
|
||
当innodb中多个事务持有各自的资源并同时请求对方所持有的资源时,就会发生死锁。当发生死锁时,事务获取锁的请求会被阻塞,阻塞超时后,会根据诗剧苦设置决定是否回滚(`innodb_rollback_on_timeout`)。
|
||
|
||
#### 超时
|
||
在innodb中,可以通过如下变量来控制innodb的超时时间和超时后行为
|
||
- innodb_lock_wait_timeout: 超时等待时间,默认为`50`,即超时时间限制为50s
|
||
- innodb_rollback_on_timeout: 在等待超时后,对事务是否进行回滚,默认为`OFF`,代表不回滚
|
||
|
||
#### wait-for graph
|
||
相较于被动等待持有锁的事务超时,wait-for graph是一种更加主动的死锁检测方式。
|
||
|
||
在wait-for graph中,`节点`代表`事务`,而节点指向另一个节点的有向边则代表事务事务之间的等待关系。
|
||
|
||
wait-for graph中,事务`t1`指向事务`t2`的有向边代表如下两种可能的场景:
|
||
- 事务t1所等待的资源正在被事务t2占用
|
||
- 事务t1和事务t2都在等待相同的资源,但是在等待队列中,t2的排序在t1的前面,只有t2获取等待的资源并释放后,t1才能获取
|
||
|
||
故而,`在wait-for grah中存在回路时,则代表当前存在死锁。`
|
||
|
||
### innodb锁升级
|
||
在其他数据库中,可能会将多个细粒度锁合并为一个粗粒度锁,从而减少锁占用的资源,例如将1000个行锁合并为一个页锁。
|
||
|
||
但是,`innodb`中并不存在锁升级问题。innodb中,锁是按页进行管理的,通过位图(bitmap)来对锁进行管理,页中不论有多少条记录进行了加锁,锁占用的资源都是相同的。
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|