所在文集:数据库
本文的内容参考了:
- 架构师之路 - 挖坑,InnoDB的七种锁
- 架构师之路 - 插入InnoDB自增列,居然是表锁?
- 架构师之路 - InnoDB并发插入,居然使用意向锁?
- 架构师之路 - InnoDB,select为啥会阻塞insert?
- 架构师之路 - 别废话,各种SQL到底加了什么锁?
- 架构师之路 - 超赞,InnoDB调试死锁的方法!
下面会涉及到 MySQL 数据库隔离级别和索引,请先参见:
自增锁
MySQL InnoDB 默认的隔离级别为 RR,假设有数据表:
t(id AUTO_INCREMENT, name);
数据表中有数据:
1, shenjian
2, zhangsan
3, lisi
事务 A 先执行,还未提交:insert into t(name) values(xxx);
事务 B 后执行:insert into t(name) values(ooo);
问:事务B会不会被阻塞? 答案是会阻塞,分析如下:
- 事务 A 先执行
insert
,会得到一条(4, xxx)
的记录,由于是自增列,InnoDB 会自动增长,注意此时事务并未提交; - 事务 B 后执行
insert
,假设不会被阻塞,那会得到一条(5, ooo)
的记录;
此时,并未有什么不妥,但如果,事务 A 继续 insert
:insert into t(name) values(xxoo);
会得到一条 (6, xxoo)
的记录。
事务 A 再 select
:select * from t where id>3;
得到的结果是:
4, xxx
6, xxoo
注意:不可能查询到 (5, ooo)
的记录,因为在 RR 的隔离级别下,不可能读取到还未提交事务生成的数据。
这对于事务 A 来说,就很奇怪了,对于 AUTO_INCREMENT
的列,连续插入了两条记录,一条是 4,接下来一条变成了 6,就像莫名其妙的幻影。
自增锁是一种特殊的表级别锁(table-level lock),专门针对事务插入 AUTO_INCREMENT
类型的列。最简单的情况,如果一个事务正在往表中插入记录,所有其他事务的插入必须等待,以便第一个事务插入的行,是连续的主键值。
共享/排他锁(Shared and Exclusive Locks)
共享锁(S锁)和排他锁(X锁)是行级别的锁(row-level locking):
- 事务拿到某一行记录的共享S锁,才可以读取这一行;
- 多个事务可以拿到一把共享S锁,读读可以并行
- 事务拿到某一行记录的排它X锁,才可以修改或者删除这一行;
- 只有一个事务可以拿到排它X锁,写写/读写必须互斥;
共享/排它锁的潜在问题是,不能充分的并行,解决思路是数据多版本。参见:MySQL InnoDB 并发控制,事务的实现 学习笔记
意向锁(Intention Locks)
意向锁是指,未来的某个时刻,事务可能要加共享/排它锁了,先提前声明一个意向。意向锁有这样一些特点:
- 首先,意向锁,是一个表级别的锁(table-level locking);
- 意向锁分为:
- 意向共享锁(intention shared lock, IS)例如:
select ... lock in share mode
要设置意向共享锁; - 意向排它锁(intention exclusive lock, IX)例如:
select ... for update
要设置意向排它锁;
- 意向共享锁(intention shared lock, IS)例如:
- 意向锁协议(intention locking protocol)并不复杂:
- 事务要获得某些行的 S 锁,必须先获得表的 IS 锁
- 事务要获得某些行的 X 锁,必须先获得表的 IX 锁
- 由于意向锁仅仅表明意向,它其实是比较弱的锁,意向锁之间并不相互互斥,而是可以并行
插入意向锁(Insert Intention Locks)
对已有数据行的修改 update
与删除 delete
,必须加排他锁(X锁),那对于数据的插入 insert
,是否还需要加这么强的锁,来实施互斥呢?
插入意向锁,是间隙锁(Gap Locks)的一种(所以,也是实施在索引上的),它是专门针对 insert
操作的:
多个事务,在同一个索引,同一个范围区间插入记录时,如果插入的位置不冲突,不会阻塞彼此。
回到上面自增锁时所用的插入的例子。如果主键不是自增的 t(id unique PK, name);
数据表中有数据:
10, shenjian
20, zhangsan
30, lisi
事务 A 先执行,在 10 与 20 两条记录中插入了一行,还未提交:insert into t values(11, xxx);
事务 B 后执行,也在 10 与 20 两条记录中插入了一行:insert into t values(12, ooo);
问:会使用什么锁?使用的是插入意向锁。
问:事务B会不会被阻塞呢?虽然事务隔离级别是 RR,虽然是同一个索引,虽然是同一个区间,但插入的记录并不冲突,故这里并不会阻塞事务 B。
思路总结:
- InnoDB 使用共享锁,可以提高读读并发;
- 为了保证数据强一致,InnoDB 使用强互斥锁,保证同一行记录修改与删除的串行性;
- InnoDB 使用插入意向锁,可以提高插入并发;
记录锁(Record Locks)
记录锁封锁索引记录,例如:
select * from t where id=1 for update;
它会在 id=1
的索引记录上加锁,以阻止其他事务插入,更新,删除 id=1
的这一行。
需要说明的是:
select * from t where id=1;
则是快照读(SnapShot Read),它并不加锁。
间隙锁(Gap Locks)
间隙锁封锁索引记录中的间隔,或者第一条索引记录之前的范围,又或者最后一条索引记录之后的范围。
t(id PK, name KEY);
表中有四条记录:
1, shenjian
3, zhangsan
5, lisi
9, wangwu
SQL 语句:select * from t where id between 8 and 15 for update;
会封锁区间,以阻止其他事务 id=10
的记录插入。
为什么要阻止 id=10
的记录插入?如果能够插入成功,头一个事务执行相同的 SQL 语句,会发现结果集多出了一条记录,即幻影数据。(即我们采用 MySQL InnoDB 的 可重复读 RR 隔离级别)
如果把事务的隔离级别降级为读提交(Read Committed, RC),间隙锁则会自动失效。
临键锁(Next-Key Locks)
临键锁,是记录锁与间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间。
更具体的,临键锁会封锁索引记录本身,以及索引记录之前的区间。
t(id PK, name KEY);
表中有四条记录:
1, shenjian
3, zhangsan
5, lisi
9, wangwu
主键上潜在的临键锁为:
(-infinity, 1]
(1, 3]
(3, 5]
(5, 9]
(9, +infinity]
临键锁的主要目的,也是为了避免幻读(Phantom Read)。如果把事务的隔离级别降级为RC,临键锁则也会失效。
各种SQL到底加了什么锁
普通 select
- 在读未提交(Read Uncommitted),读提交(Read Committed, RC),可重复读(Repeated Read, RR)这三种事务隔离级别下,普通
selec
使用快照读(snpashot read),不加锁,并发非常高; - 在串行化(Serializable)这种事务的隔离级别下,普通
select
会升级为select ... in share mode;
加锁 select
select ... for update
select ... in share mode
如果,在唯一索引(unique index)上使用唯一的查询条件(unique search condition),会使用记录锁(record lock),而不会封锁记录之间的间隔,即不会使用间隙锁(gap lock)与临键锁(next-key lock);
假设有 InnoDB 表:t(id PK, name);
1, shenjian
2, zhangsan
3, lisi
SQL 语句:select * from t where id=1 for update;
只会封锁记录,而不会封锁区间。
其他的查询条件和索引条件,InnoDB 会封锁被扫描的索引范围,并使用间隙锁与临键锁,避免索引范围区间插入记录。
update 与 delete
和加锁 select
类似,如果在唯一索引上使用唯一的查询条件来 update/delete
,例如:
update t set name=xxx where id=1;
也只加记录锁;
否则,符合查询条件的索引记录之前,都会加排他临键锁(exclusive next-key lock),来封锁索引记录与之前的区间;
尤其需要特殊说明的是,如果 update
的是聚集索引(clustered index)记录,则对应的普通索引(secondary index)记录也会被隐式加锁,这是由 InnoDB 索引的实现机制决定的:普通索引存储 PK 的值,检索普通索引本质上要二次扫描聚集索引。
insert
同样是写操作,insert
和 update
与 delete
不同,它会用排它锁封锁被插入的索引记录,而不会封锁记录之前的范围。
同时,会在插入区间加插入意向锁(insert intention lock),但这个并不会真正封锁区间,也不会阻止相同区间的不同 KEY 插入。
InnoDB 调试死锁
InnoDB 的行锁都是实现在索引上的,实验可以使用主键,建表时设定为 InnoDB 引擎:
create table t (
id int(10) primary key
)engine=innodb;
插入一些实验数据:
start transaction;
insert into t values(1);
insert into t values(3);
insert into t values(10);
commit;
实验一,间隙锁互斥
开启区间锁,RR 的隔离级别下,上例会有四个区间:
(-infinity, 1)
(1, 3)
(3, 10)
(10, infinity)
事务 A 删除某个区间内的一条不存在记录,获取到共享间隙锁,会阻止其他事务 B 在相应的区间插入数据,因为插入需要获取排他间隙锁。
session A:
set session autocommit=0;
start transaction;
delete from t where id=5;
session B:
set session autocommit=0;
start transaction;
insert into t values(0);
insert into t values(2);
insert into t values(12);
insert into t values(7);
事务 B 插入的值:0
, 2
, 12
都不在 (3, 10)
区间内,能够成功插入,而 7
在 (3, 10)
这个区间内,会阻塞。
可以使用:show engine innodb status;
来查看锁的情况。
如果事务 A 提交或者回滚,事务 B 就能够获得相应的锁,以继续执行。
如果事务 A 一直不提交,事务 B 会一直等待,直到超时。
实验二,共享排他锁死锁
事务 A 先执行:
set session autocommit=0;
start transaction;
insert into t values(7);
事务 B 后执行:
set session autocommit=0;
start transaction;
insert into t values(7);
事务 C 最后执行:
set session autocommit=0;
start transaction;
insert into t values(7);
三个事务都试图往表中插入一条为 7
的记录:
- A 先执行,插入成功,并获取
id=7
的排他锁; - B 后执行,需要进行PK校验,故需要先获取
id=7
的共享锁,阻塞; - C 后执行,也需要进行PK校验,也要先获取
id=7
的共享锁,也阻塞;
如果此时,事务 A 执行:rollback;
释放 id=7
排他锁。
则 B,C 会继续进行主键校验:
- B 会获取到
id=7
共享锁,主键未互斥; - C 也会获取到
id=7
共享锁,主键未互斥;
B 和 C 要想插入成功,必须获得 id=7
的排他锁,但由于双方都已经获取到 id=7
的共享锁,它们都无法获取到彼此的排他锁,死锁就出现了。
当然,InnoDB有死锁检测机制,B 和 C 中的一个事务会插入成功,另一个事务会自动放弃。
共享排他锁,在并发量插入相同记录的情况下会出现,相应的案例比较容易分析。
实验三,并发间隙锁的死锁
SQL 执行序列如下:
A:set session autocommit=0;
A:start transaction;
A:delete from t where id=6;
B:set session autocommit=0;
B:start transaction;
B:delete from t where id=7;
A:insert into t values(5);
B:insert into t values(8);
- A 执行
delete
后,会获得(3, 10)
的共享间隙锁。 - B 执行
delete
后,也会获得(3, 10)
的共享间隙锁。 - A 执行
insert
后,希望获得(3, 10)
的排他间隙锁,于是会阻塞。 - B 执行
insert
后,也希望获得(3, 10)
的排他间隙锁,于是死锁出现。
检测到死锁后,事务 B 自动回滚了,事务 A 将会执行成功。