likes
comments
collection
share

精通Mysql锁系列之行锁

作者站长头像
站长
· 阅读数 22

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的加锁规则: 精通Mysql锁系列之行锁

表t的建表语句和初始化语句如下:

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);

唯一索引等值查询

值存在

精通Mysql锁系列之行锁

唯一索引命中值next-key退化成记录锁

值不存在

精通Mysql锁系列之行锁

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 Asession Bsession 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. 根据原则1,加锁单位是next-key lock,因此会给(0,5]加上next-key lock
  2. c是普通索引,因此访问c=5这一条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加next-key lock
  3. 根据优化2,等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10)
  4. 根据原则2,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有任何锁,这就是为什么sessionB的update语句可以执行完成

锁是加在索引上的,在这个例子中,lock in share mode只锁覆盖索引,但是如果是for update,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁,这样的话sessionB的update语句会被阻塞住。如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化:在查询字段中加入索引中不存在的字段

值不存在

session Asession Bsession 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 Asession Bsession 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)
  1. 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁
  2. 范围查询就往后继续找,找到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 Asession 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)
  1. 首先这个查询语句的语义是order by id desc,要拿到满足条件的所有行,优化器必须先找 到第一个id<12的值。
  2. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到id=12的这个值,只 是最终没找到,但找到了(10,15)这个间隙, 这里用到了优化2,即索引上的等值 查询,向右遍历的时候id=15不满足条件,所以next-key lock退化为了间隙锁 (10, 15)。
  3. 然后向左遍历,在遍历过程中直到找到id=5才不满足条件,根据next-key规则加锁(0,5]

非唯一索引范围锁

session Asession Bsession 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.

非唯一索引上存在等值

insert into t values(30,10,30);

新插入的这一行c=10,现在表里有两个c=10的行。虽然有两个c=10,但是它们的主键值id是不同的,因此这两个c=10的记录之间也是有间隙的

精通Mysql锁系列之行锁

session Asession Bsession 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)的间隙锁,如下所示:

精通Mysql锁系列之行锁

limit 对加锁的影响

session Asession 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)这个前开后闭区间,如下图所示:

精通Mysql锁系列之行锁

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加limit。这样不仅可以控制删除 数据的条数,让操作更安全,还可以减小加锁的范围。

in

begin;
select id from t where c in(5,20,10) lock in share mode;
  1. 在查找c=5的时候,先锁住了(0,5]。但是因为c不是唯一索引,为了确认还有没有别的记录c=5, 就要向右遍历,找到c=10才确认没有了,这个过程满足优化2,所以加了间隙锁(5,10)。
  2. 同样的,执行c=10这个逻辑的时候,加锁的范围是(5,10] 和 (10,15);
  3. 执行c=20这个逻辑的时候,加锁的范围是(15,20] 和 (20,25)。

这条语句在索引c上加的三个记录锁的顺序是:先加c=5的记录锁,再加c=10的记录锁,最后加c=20的记录锁。这个加锁范围,就是从(5,25)中去掉c=15的行锁吗?但是这些锁是“在执行过程中一个一个加的”,而不是一次性加上去的。

in + order by

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

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);

精通Mysql锁系列之行锁

这个语句的加锁范围,就是表t索引c上的(3,4]和(4,supermum]这两个next-key lock,以及主键索引上id=4这一行, 执行流程是从表t中按照索引c倒序吗,扫描第一行,拿到结果写入到表t2中,因此整条语句的扫描行数是1.

session Asession 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 唯一键冲突

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 Asession 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 死锁

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 Asession Bsession 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几乎同时发现死锁并返回。

  1. 在T1时刻,启动session A,并执行insert语句,此时在索引c的c=5上加了记录锁。注意,这 个索引是唯一索引,因此退化为记录锁。
  2. 在T2时刻,session B要执行相同的insert语句,发现了唯一键冲突,加上读锁;同样 地,session C也在索引c上,c=5这一个记录上,加了读锁。
  3. T3时刻,session A回滚。这时候,session B和session C都试图继续执行插入操作,都要加 上写锁。两个session都要等待对方的行锁,所以就出现了死锁

解决这种死锁问题可以使用 insert into … on duplicate key update。这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。

现在表t里面已经有了(1,1,1)和(2,2,2)这两行,我们再来看看下面这个语句执行的效果:

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。

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 Asession 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

事务中有多个操作,怎么安排操作顺序让并发度更高

如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放

让我们考虑一个在线银行转账的例子,假设你有一个业务流程需要完成以下步骤:

  1. 从用户A的账户扣除转账金额。
  2. 向用户B的账户添加转账金额。
  3. 记录一条转账日志。

如果用户A和用户C同时向用户B转账,那么这两个事务冲突的部分就是步骤2了,因为它们都尝试更新同一个用户B账户的余额,需要修改同一行数据。根据两阶段锁协议,所有的操作需要的行锁都是在事务提交的时候才释放的。

因此,如果我们按照3、1、2的顺序来组织这三个步骤,那么对用户B账户的写入(步骤2)将会尽可能晚地进行,这样可以最大限度地减少因锁冲突导致的等待时间,提升了并发度。同时,还能保证整个交易过程的原子性,即不会出现只完成部分操作的情况。

数据变更对已经加锁的范围有影响吗

delete

session Asession 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 Asession 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), 间隙变大了:

精通Mysql锁系列之行锁

接下来session B要执行 update t set c = 5 where c = 1这个语句了,一样地可以拆成两步:

  1. 插入(c=5, id=5)这个记录;
  2. 删除(c=1, id=5)这个记录。 第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。

只查一条数据有时候很慢

等MDL锁 通过show processlist 查看是否有Waiting for table metadata lock,然后通过

select blocking_pid from sys.schema_table_lock_waits; 

找到pid后在mysql shell执行 kill pid 即可。

等行锁

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_idblocking_trx_idblocking_query)。通过这个信息,你可以确定哪个事务持有了需要的锁。

查询慢

表中的数据量太大且没有建立合适的索引,可以用EXPLAIN命令来查看查询的执行计划,进一步定位问题原因。