MySQL的行锁是在引擎层由各个引擎自己实现的。但不是所有的引擎都支持行锁,比如MyISAM引擎就不支持行锁。
加锁/释放锁时机:同MDL锁在事务中一样,行锁是在需要的时候才加上的,是要等到事务结束时才释放。
行锁有三种算法: 记录锁(Record Lock), 间隙锁(Gap Lock)和Next-Key Lock, mysql采用的是Next-Key Lock,mysql在RR的隔离级别之所以能做到防止幻读, 正是Next-Key起的作用。
记录锁就是某个索引记录的锁,间隙锁就是两个索引记录之间的空隙锁,Next-Key 则是前面两者的结合。
间隙锁可以共存,也就是说对同一块间隙可以加多次锁,间隙锁主要是为了防止间隙内插入数据的。
不同隔离级别的锁机制不同
对于MySQL的InnoDB存储引擎,当隔离级别设为READ COMMITTED时,它不会使用Next-Key锁,而是只使用记录锁。在更新或删除记录时,它也只会锁定需要直接修改的那些记录,而不会锁定整个范围。这种行为有助于提高并发性能。
在REPEATABLE READ隔离级别下,InnoDB通常会使用Next-Key锁,这包括一个记录锁和一个间隙锁,这可以帮助防止幻读。但在READ COMMITTED隔离级别下,由于每次查询都会看到最新已提交的数据,所以不使用Next-Key锁,因此无法完全防止幻读问题。
next-key
对于索引查找,InnoDB使用一种称为"Next-Key Locking"的方法,这种方法在搜索到的索引记录及其左边的间隙上设置锁,详细的加锁规则如下:
- 原则1:加锁的基本单位是next-key lock,next-key lock是前开后闭区间
- 原则2:查找过程中访问到的对象才会加锁
- 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为记录锁
- 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁
结合下面的图可以更好理解next-key的加锁规则:
表t的建表语句和初始化语句如下:
scss
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
唯一索引等值查询
值存在
唯一索引命中值next-key退化成记录锁
值不存在
1.由于表t中没有id=7的记录,根据原则1,加锁单位是next-key lock,sessionA加锁范围就是(5,10] 2.根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock退化成间隙锁,因此最终加锁的范围是(5,10) 3. sessionB要往这个间隙里面插入id=8的记录会被锁住,但是sessionB要是修改id=10这行是可以的
普通索引等值查询
值存在
session A | session B | session C |
---|---|---|
begin; | ||
select id from t where c = 5 lock in share mode; | ||
--- | update t set d=d+1 where id=5; (Query OK) | --- |
--- | --- | insert into t values(7,7,7); (Blocked) |
- 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock
- c是普通索引,因此访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock
- 根据优化2,等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)
- 根据原则2,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有任何锁,这就是为什么sessionB的update语句可以执行完成
锁是加在索引上的,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁,这样的话sessionB的update语句会被阻塞住。如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化:在查询字段中加入索引中不存在的字段
值不存在
session A | session B | session C |
---|---|---|
begin; | ||
select * from t where c = 7 lock in share mode; | ||
--- | insert into t values(4,4,4); (Query OK) | --- |
--- | --- | insert into t values(7,7,7); (Blocked) |
因为表t
中没有c=7
的行,所以不会有记录被锁定,但是会在符合查询条件c=7
的间隙上加上间隙锁。
唯一索引范围锁
session A | session B | session C |
---|---|---|
begin; | ||
select * from t where id >= 10 and id < 11 lock in share mode; | ||
--- | insert into t values(8,8,8); (Query OK) | --- |
--- | insert into t values(13,13,13); (Blocked) | --- |
--- | --- | update t set d=d+1 where id=15; (Query OK) |
- 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁
- 范围查询就往后继续找,找到id=15这一行停下来,虽然这个间隙到达了
id=15
,但实际上并没有锁定id=15
这条记录本身。间隙锁是为了防止在此范围内插入新记录,而不是阻止对区间结束点即id=15
的修改。
所以,sessionA这时候锁的范围就是主键索引上,行锁id=10和间隙锁(10,15)
InnoDB将会对
id=10
的记录加上共享锁,并且对从id=10
到下一个索引记录(即id=15
)之间的间隙加锁。这意味着,虽然查询的条件是id < 11
,但由于Next-Key Lock的机制,实际上锁定的范围扩展到了id=15
倒序
session A | session B |
---|---|
begin; | |
select * from t where id>9 and id<12 order by id desc for update; | |
--- | insert into t values(4,4,4); (Blocked) |
- 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找 到第一个id<12的值。
- 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只 是最终没找到,但找到了(10,15)这个间隙, 这里用到了优化2,即索引上的等值 查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁 (10, 15)。
- 然后向左遍历,在遍历过程中直到找到id=5才不满足条件,根据next-key规则加锁(0,5]
非唯一索引范围锁
session A | session B | session C |
---|---|---|
begin; | ||
select * from t where c >= 10 and c < 11 lock in share mode; | ||
--- | insert into t values(8,8,8); (Blocked) | --- |
--- | insert into t values(13,13,13); (Blocked) | --- |
--- | --- | update t set d=d+1 where id=15; (Query OK) |
这次sessionA用字段c来判断,加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加上(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,因此最终sessionA加的锁是索引c上的(5,10]和(10,15)这两个lock.
非唯一索引上存在等值
sql
insert into t values(30,10,30);
新插入的这一行c=10,现在表里有两个c=10的行。虽然有两个c=10,但是它们的主键值id是不同的,因此这两个c=10的记录之间也是有间隙的
session A | session B | session C |
---|---|---|
begin; | ||
delete from t where c=10; | ||
--- | insert into t values(12,12,12); (Blocked) | --- |
--- | --- | update t set d=d+1 where c=15; (Query OK) |
sessionA在遍历的时候,先访问第一个c=10的记录。根据原则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。然后sessionA向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10)到(c=15,id=15)的间隙锁,如下所示:
limit 对加锁的影响
session A | session B |
---|---|
begin; | |
delete from t where c=10 limit 2; | |
--- | insert into t values(12,12,12); (Query OK) |
加了limit 2的限制,因此在遍历到(c=10,id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间,如下图所示:
这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。这样不仅可以控制删除 数据的条数,让操作更安全,还可以减小加锁的范围。
in
csharp
begin;
select id from t where c in(5,20,10) lock in share mode;
- 在查找c=5的时候,先锁住了(0,5]。但是因为c不是唯一索引,为了确认还有没有别的记录c=5, 就要向右遍历,找到c=10才确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。
- 同样的,执行c=10这个逻辑的时候,加锁的范围是(5,10] 和 (10,15);
- 执行c=20这个逻辑的时候,加锁的范围是(15,20] 和 (20,25)。
这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁。这个加锁范围,就是从(5,25)中去掉c=15的行锁吗?但是这些锁是"在执行过程中一个一个加的",而不是一次性加上去的。
in + order by
sql
select id from t where c in(5,20,10) order by c desc for update;
由于语句里面是order by c desc, 这三个记录锁的加锁顺序,是先锁c=20,然后c=10,最后是c=5。也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候, 就可能出现死锁。
insert 语句加锁方式
insert + select
sql
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
insert into t2(c,d) select c,d from t;
insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1);
这个语句的加锁范围,就是表t索引c上的(3,4]和(4,supermum]这两个next-key lock,以及主键索引上id=4这一行, 执行流程是从表t中按照索引c倒序吗,扫描第一行,拿到结果写入到表t2中,因此整条语句的扫描行数是1.
session A | session B |
---|---|
begin; | |
insert into t2(c,d) (select c+1, d from t force index(c) order by c desc limit 1); | |
--- | update t set c=c+1 where id=4; (Blocked) |
--- | insert into t values(5,5,5); (Blocked) |
show status like '%Innodb_rows_read%';
insert 唯一键冲突
scss
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
session A | session B |
---|---|
begin; | |
insert into t values(11,10,10); ERROR 1062 (23000): Duplicate entry '10' for key 't.c' | |
--- | insert into t values(12,9,9); (Blocked) |
,session A执行的insert语句,发生唯一键冲突的时候,并不只是简单地报错返回,还 在冲突的索引上加了锁。一个next-key lock就是由它右边界的值定义的。这时 候,session A持有索引c上的(5,10] 共享next-key lock(读锁)。
insert 死锁
sql
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
session A | session B | session C |
---|---|---|
begin; | ||
insert into t values(null, 5,5); | ||
--- | insert into t values(null, 5,5); (Blocked) | insert into t values(null, 5,5); |
rollback; | (Query OK) | ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
在session A执行rollback语句回滚的时候,session C几乎同时发现死锁并返回。
- 在T1时刻,启动session A,并执行insert语句,此时在索引c的c=5上加了记录锁。注意,这 个索引是唯一索引,因此退化为记录锁。
- 在T2时刻,session B要执行相同的insert语句,发现了唯一键冲突,加上读锁;同样 地,session C也在索引c上,c=5这一个记录上,加了读锁。
- T3时刻,session A回滚。这时候,session B和session C都试图继续执行插入操作,都要加 上写锁。两个session都要等待对方的行锁,所以就出现了死锁
解决这种死锁问题可以使用 insert into ... on duplicate key update。这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。
现在表t里面已经有了(1,1,1)和(2,2,2)这两行,我们再来看看下面这个语句执行的效果:
sql
mysql> insert into t values(2,1,100) on duplicate key update d=100;
Query OK, 2 rows affected (0.00 sec)
mysql> select * from t where id <= 2;
+----+------+------+
| id | c | d |
+----+------+------+
| 1 | 1 | 1 |
| 2 | 2 | 100 |
+----+------+------+
2 rows in set (0.00 sec)
主键id是先判断的,MySQL认为这个语句跟id=2这一行冲突,所以修改的是id=2的行。需要注意的是,执行这条语句的affected rows返回的是2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert和update都认为自己成功了,update计数加了1, insert计数也加了1。
scss
truncate table t;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
insert into t values(11,10,10) on duplicate key update d=100;
session A | session B |
---|---|
begin; | |
insert into t values(11,10,10) on duplicate key update d=100; | |
--- | insert into t values(8,8,8); (Blocked) |
其中c=10重复,会给索引c上(5,10]加一个排他的next-key lock(写锁)、
QA
事务中有多个操作,怎么安排操作顺序让并发度更高
如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放
让我们考虑一个在线银行转账的例子,假设你有一个业务流程需要完成以下步骤:
- 从用户A的账户扣除转账金额。
- 向用户B的账户添加转账金额。
- 记录一条转账日志。
如果用户A和用户C同时向用户B转账,那么这两个事务冲突的部分就是步骤2了,因为它们都尝试更新同一个用户B账户的余额,需要修改同一行数据。根据两阶段锁协议,所有的操作需要的行锁都是在事务提交的时候才释放的。
因此,如果我们按照3、1、2的顺序来组织这三个步骤,那么对用户B账户的写入(步骤2)将会尽可能晚地进行,这样可以最大限度地减少因锁冲突导致的等待时间,提升了并发度。同时,还能保证整个交易过程的原子性,即不会出现只完成部分操作的情况。
数据变更对已经加锁的范围有影响吗
delete
session A | session B |
---|---|
begin; | |
select * from t where id>10 and id<=15 for update; | |
--- | insert into t values(8,8,8); (Query OK) |
--- | delete from t where id=10; (Query OK) |
--- | insert into t values(10,10,10); (Blocked) |
sessionA首先要找到id>10的记录,没找到但是只找到(10, 15) 这个间隙,没有锁住id=10这个记录,所以session B删除id=10这一行是可以的。但是之后,session B再想insert id=10这一行回去就不行了。由于delete操作把id=10这一行删掉了,原来的两个间隙(5,10)、(10,15)变成了一个(5,15)。也就是说session A执行完select语句后,什么都没做,但它加锁的范围突然"变大"了
update
session A | session B |
---|---|
begin; | |
select * from t where id>10 and id<=15 for update; | |
--- | update t set c=1 where c=5; (Query OK) |
--- | update t set c=5 where c=1; (Blocked) |
根据c>5查到的第一个记录是c=10, 所以不会加(0,5]这个next-key lock, 最终session A的加锁范围是索引c上的(5,10]、(10,15]、(15,20]、(20,25]和(25,supremum]。
之后session B的第一个update语句,要把c=5改成c=1,此时c=10的左边的间隙变成了(1,10), 间隙变大了:
接下来session B要执行 update t set c = 5 where c = 1这个语句了,一样地可以拆成两步:
- 插入(c=5, id=5)这个记录;
- 删除(c=1, id=5)这个记录。 第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。
只查一条数据有时候很慢
等MDL锁 通过show processlist 查看是否有Waiting for table metadata lock,然后通过
csharp
select blocking_pid from sys.schema_table_lock_waits;
找到pid后在mysql shell执行 kill pid 即可。
等行锁
csharp
select * from t where id=1 lock in share mode;
由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,select语句就会被堵住。
通过SELECT * FROM sys.innodb_lock_waits\G; 可以查询所有当前正在等待锁的事务信息,包括等待的事务ID (waiting_trx_id
)、等待的查询 (waiting_query
),以及被阻塞事务持有的锁信息(blocking_lock_id
, blocking_trx_id
, blocking_query
)。通过这个信息,你可以确定哪个事务持有了需要的锁。
查询慢
表中的数据量太大且没有建立合适的索引,可以用EXPLAIN
命令来查看查询的执行计划,进一步定位问题原因。