精通Mysql锁系列之行锁

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. 根据原则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 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)
  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 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)
  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 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;
  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

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

  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)这两行,我们再来看看下面这个语句执行的效果:

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

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

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

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

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

如果用户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这个语句了,一样地可以拆成两步:

  1. 插入(c=5, id=5)这个记录;
  2. 删除(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命令来查看查询的执行计划,进一步定位问题原因。

相关推荐
小陈工2 小时前
Python Web开发入门(十七):Vue.js与Python后端集成——让前后端真正“握手言和“
开发语言·前端·javascript·数据库·vue.js·人工智能·python
0xDevNull7 小时前
MySQL数据冷热分离详解
后端·mysql
科技小花7 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸7 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain7 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希8 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神8 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员8 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java8 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿8 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb