MySQL 中的锁

MySQL 中的锁

按照类型分:读锁(S)、写锁(X)

按照粒度分:表锁、页锁、行锁、全局锁

行锁由存储引擎(如:InnoDB)实现。

读写锁排斥情况

SX
S兼容排斥
X排斥排斥

表锁

表级 S 锁、X 锁

加表级读写锁方式:

  • LOCK TABLES t READ:对表 t 加表级别的 S 锁。
  • LOCK TABLES t WRITE:对表 t 加表级别的 X 锁。
锁类型自己可读自己可写自己可操作其他表他人可读他人可写
读锁否,等
写锁否,等否,等

对于 InnoDB 有更加细粒度的行级锁,一般不使用表锁。

实验一:加表级读锁

# 下面所有实验均用这样表
CREATE TABLE `user` ( 
  `ID` int(11) NOT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB;
INSERT INTO user(ID) VALUES (1);

CREATE TABLE `user_1` ( 
  `ID` int(11) NOT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=InnoDB;
INSERT INTO user_1(ID) VALUES (1);

本文所有实验都是基于 MySQL 8,MySQL 5.7 可能有些不同,具体差异可以百度一下。

会话 1

mysql> LOCK TABLES user READ; # 加表级读锁
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM user; # 当前会话可读
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

mysql> INSERT INTO user(ID) VALUES (2); # 不可修改上读锁的表
ERROR 1099 (HY000): Table 'user' was locked with a READ lock and can't be updated

mysql> SELECT * FROM user_1; # 当前会话不可操作未上锁的表
ERROR 1100 (HY000): Table 'user_1' was not locked with LOCK TABLES

mysql> INSERT INTO user_1(ID) VALUES (2); # 当前会话不可操作未上锁的表
ERROR 1100 (HY000): Table 'user_1' was not locked with LOCK TABLES

会话 2

mysql> SELECT * FROM user; # 可以读被其他会话上读锁的表
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

mysql> INSERT INTO user(ID) VALUES (2);
 # 不可修改上读锁的表,阻塞等待。

实验二:加表级写锁

会话 1

mysql> LOCK TABLES user WRITE; # 加表级写锁
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM user; # 当前会话可读
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

mysql> INSERT INTO user(ID) VALUES (2); # 当前会话可写
Query OK, 1 row affected (0.01 sec)

mysql> SELECT * FROM user_1; # 当前会话不可操作未上锁的表
ERROR 1100 (HY000): Table 'user_1' was not locked with LOCK TABLES

mysql> INSERT INTO user_1(ID) VALUES (2); # 当前会话不可操作未上锁的表
ERROR 1100 (HY000): Table 'user_1' was not locked with LOCK TABLES

会话 2

mysql> SELECT * FROM user;
 # 不可读上写锁的表,阻塞等待。

意向锁(Intention Lock)

对一行记录上行锁时会对该表添加对应类型的意向锁。意向锁由 InnoDB 自己维护,无法人为添加。

  • 意向读锁(IS)

    -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
    SELECT xx FROM table WHERE ... FOR SHARE;
    
  • 意向写锁(IX)

    -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。 
    SELECT xx FROM table WHERE ... FOR UPDATE;
    

当存在意向锁时,对该表上表锁先检查是否存在排斥的意向锁,如果存在则加表锁失败。

ISIX
IS兼容兼容
IX兼容兼容
表 S兼容排斥
表 X排斥排斥

为什么会存在意向锁?先假设意向锁不存在时对表上锁的情况。

  1. 对一行记录上读锁,再对整个表上写锁。行读与表写互斥,所以要先遍历全表是否有一行上了行读锁,如果上了那么加表写锁失败。
  2. 对一行记录上写锁,再对整个表上读锁。行写于表读互斥,所以要先遍历全表是否有一行上了行写锁,如果上了那么加表读锁失败。
  3. 对一行记录上写锁,再对整个表上写锁。行写于表写互斥,所以要先遍历全表是否有一行上了行写锁,如果上了那么加表写锁失败。

总之,是否能对表上锁都取决于表中的行的上锁情况。如果不存在意向锁就需要对表中所有记录的上锁情况进行遍历,徒增开销。而意向锁的存在就可以免除遍历。

实验一:先加 IS,再加表 S

user 表数据

+----+
| ID |
+----+
|  1 |
|  2 |
+----+

会话 1

mysql> SELECT * FROM user WHERE id = 1 FOR SHARE; # 上行 S 锁同时上 IS 锁
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

会话 2

mysql> LOCK TABLES user READ; # 对表上 S 锁,成功
Query OK, 0 rows affected (0.00 sec)

实验二:先加 IS,再加表 X

会话 1

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM user WHERE id = 1 FOR SHARE; # 上行 S 锁同时上 IS 锁
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

会话 2

mysql> LOCK TABLES user WRITE; 
 # 对表上 X 锁,阻塞

实验三:先加 IX,再加表 S

会话 1

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM user WHERE id = 1 FOR UPDATE; # 上行 X 锁同时上 IX 锁
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

会话 2

mysql> LOCK TABLES user READ;
 # 对表上 S 锁,阻塞

实验四:先加 IX,再加表 X

会话 1

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM user WHERE id = 1 FOR UPDATE; # 上行 X 锁同时上 IX 锁
+----+
| ID |
+----+
|  1 |
+----+
1 row in set (0.00 sec)

会话 2

mysql> LOCK TABLES user WRITE; 
 # 对表上 X 锁,阻塞

元数据锁(MDL)

MDL 无需显式使用,在访问表时会自动加上。MDL 的作用是保证读写的正确性。

  • MDL 读锁:当对一个表做增删改查操作的时候
  • MDL 写锁:当要对表做结构变更操作的时候

如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

MDL 读锁MDL 写锁
MDL 读锁兼容排斥
MDL 写锁排斥排斥

实验一:先加 MDL 读锁,再加 MDL 读锁

事务时序

时序会话 1会话 2
1BEGIN;BEGIN;
2SELECT * FROM user; 加 MDL 读锁
3SELECT * FROM user; 加 MDL 读锁

不产生阻塞。

实验二:先加 MDL 读锁,再加 MDL 写锁

事务时序

时序会话 1会话 2
1BEGIN;
2SELECT * FROM user; 加 MDL 读锁
3ALTER TABLE user ADD age int NOT NULL; 加 MDL 写锁(阻塞)

直到会话 1 中的事务终止才会释放所持有的锁。

实验三:先加 MDL 读锁,再加 MDL 写锁,再加 MDL 读锁

事务时序

时序会话 1会话 2会话 3
1BEGIN;
2SELECT * FROM user; 加 MDL 读锁
3ALTER TABLE user DROP age; 加 MDL 写锁(阻塞)
4SELECT * FROM user; 加 MDL 读锁(阻塞)

会话 2 的申请不到 MDL 写锁阻塞,会导致后续会话中的 MDL 读锁阻塞。

如果会话 1 中的事务很长,且客户端有重试机制,超时后会再起一个新 session 再请求,这个库的线程很快就会爆满。

自增锁(Autoinc Lock)

可以为表的某个列添加 AUTO_INCREMENT 属性。

自增长字段无需赋值,但要保证在并发场景下单增且不重复,自然需要上锁。

自增长字段指定为 0、null 或未指定值时,就会将表中 AUTO_INCREMENT 赋给自增长字段。

MySQL 5.7 及之前版本,AUTO_INCREMENT 存储在内存中,当 MySQL 重启后通过 max(自增长字段) 来恢复 AUTO_INCREMENT

MySQL 8 开始,AUTO_INCREMENT 存储在 redo log 中,重启时依靠 redo log 恢复。

自增锁定模式有三种:

  • 传统锁定:innodb_autoinc_lock_mode = 0。向表中插入数据时获取自增锁,增加 AUTO_INCREMENT,插入完后再释放。
  • 连续锁定(MySQL 8 之前默认):innodb_autoinc_lock_mode = 1
    • 在插入数据时如果能事先知道插入多少数据,申请后增加 AUTO_INCREMENT 直接释放。
    • 当无法确定插入数据条数时,才需等待语句执行完毕后释放自增锁。
  • 交叉锁定(MySQL 8 开始默认):innodb_autoinc_lock_mode = 2。所有插入语句,申请后直接释放。(导致增加 AUTO_INCREMENT 会比分配的要多)

锁定模式只能通过 my.cnf 设置

对于连续锁定和交叉锁定模式,批量插入数据的语句,批量申请自增的策略:

  1. 语句执行过程中,第一次申请自增 id,会分配 1 个;
  2. 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
  3. 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
  4. 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍

实验一:传统锁定与连续锁定的自增对比

t1 表结构

CREATE TABLE `t1` (
	`id` INT NOT NULL AUTO_INCREMENT,
	`name` VARCHAR ( 255 ) NOT NULL,
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

会话时序

时序会话 1会话 2
1CREATE TABLE t1 ...
INSERT INTO t1(name) VALUES ('a');
INSERT INTO t1(name) VALUES ('b');
INSERT INTO t1(name) VALUES ('c');
INSERT INTO t1(name) VALUES ('d');
2CREATE TABLE t2 LIKE t1;
INSERT INTO t2(name) SELECT name FROM t1;
3INSERT INTO t2(name) VALUES ('f');

传统锁定

mysql> select * from t2;
+----+------+
| id | name |
+----+------+
|  1 | a    |
|  2 | b    |
|  3 | c    |
|  4 | d    |
|  5 | f    |
+----+------+
5 rows in set (0.00 sec)

连续锁定

mysql> select * from t1;
+----+------+
| id | name |
+----+------+
|  1 | a    |
|  2 | b    |
|  3 | c    |
|  4 | d    |
|  8 | f    |
+----+------+
4 rows in set (0.00 sec)

实验二:连续锁定与交叉锁定的自增对比

数据太小模拟不出效果,不妨使用存储过程扩大插入数据量再做测试。

-- 创建存储过程
DELIMITER //
CREATE PROCEDURE insert_t1_data (IN n INTEGER) BEGIN
	DECLARE i INT;	
	SET i = 0;
	WHILE i < n DO
		INSERT INTO t1(NAME) VALUES (i);
		SET i = i + 1;
	END WHILE;
END // 
DELIMITER ;

会话时序

时序会话 1会话 2
1CREATE TABLE t1 ...
CALL insert_t1_data(100000);
2CREATE TABLE t2 LIKE t1;
INSERT INTO t2(name) SELECT name FROM t1;
3INSERT INTO t2(name) VALUES ('f');

INSERT INTO t2(name) SELECT name FROM t1; 后马上跟 INSERT INTO t2(name) VALUES ('f');,不然可能测不出效果,如果仍然出不来效果可以扩大到百万条数据

连续锁定

select * from t2 where name='f';
+--------+------+
| id     | name |
+--------+------+
| 131071 | f    |
+--------+------+

交叉锁定

mysql> select * from t2 where name='f';
+-------+------+
| id    | name |
+-------+------+
| 32768 | f    |
+-------+------+
1 row in set (0.03 sec)

实验一与实验二总结

传统锁定:保证自增、连续和先后次序

连续锁定:保证自增,会出现空洞,保证先后次序

交叉锁定:保证自增,会出现空洞,不保证先后次序

对于交叉锁定,若 binlog_format=statement 时,对于从机会出现自增长字段数据不一致,所以使用交叉锁定时要使用 row 格式保证数据一致。

行锁

记录锁(Record Lock)

对表中记录上锁,同样分为 S 锁和 X 锁。默认对 UPDATEDELETE 加写锁,SELECT 不加锁(采用 MVCC 避免加锁)。

也可手动加锁:

  • ... WHERE ID = ? ... FOR SHARE
  • ... WHERE ID = ? ... FOR UPDATE

记录锁是对锁扫描到的记录加锁。

在这里插入图片描述

实验一:先加行读锁,再加行读锁

数据准备

CREATE TABLE `student` (
	`id` INT NOT NULL,
	`name` VARCHAR ( 255 ) NOT NULL,
	PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

INSERT INTO student(id, name) VALUES (1, "张三"), (3, "李四"), (8, "王五"), (15, "赵六"), (20, "钱七");

事务时序

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id = 3 FOR SHARE; 加行读锁
3SELECT * FROM student WHERE id = 3 FOR SHARE; 加行读锁

实验二:先加行读锁,再加行写锁

事务时序

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id = 3 FOR SHARE;id = 3 的记录加读锁
3SELECT * FROM student WHERE id = 3 FOR UPDATE;id = 3 的记录加写锁(阻塞)

实验三:先加行写锁,再加行读锁

事务时序

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id = 3 FOR UPDATE;id = 3 的记录加写锁
3SELECT * FROM student WHERE id = 3 FOR SHARE;id = 3 的记录加写锁(阻塞)

实验四:先加行写锁,再加行写锁

事务时序

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id = 3 FOR UPDATE;id = 3 的记录加写锁
3SELECT * FROM student WHERE id = 3 FOR UPDATE; 加行写锁(阻塞)

实验五:死锁

事务时序

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id = 3 FOR SHARE;id = 3 的记录加读锁
3SELECT * FROM student WHERE id = 8 FOR SHARE;id = 8 的记录加写锁
4SELECT * FROM student WHERE id = 3 FOR UPDATE;id = 3 的记录加写锁(阻塞)
5SELECT * FROM student WHERE id = 8 FOR UPDATE;id = 8 的记录加写锁(死锁)

间隙锁(Gap Lock)

InnoDB 在 RR 级别下利用 MVCC 解决幻读,也可使用间隙锁解决。间隙锁就是把间隙加上锁,禁止在间隙中的插入操作。

对一个不存在的记录上锁就是在他的区间内加上间隙锁。

如下图

  • SELECT * FROM student WHERE id = 5 FOR UPDATEid = 5 的记录不存在,就是在 (3, 8) 这个区间内上了间隙锁
  • SELECT * FROM student WHERE id = 28 FOR UPDATEid = 28 的记录不存在,就是在 (20, +∞) 这个区间内上了间隙锁

间隙锁不管加的是 S 还是 X 都会阻塞对间隙的插入操作

在这里插入图片描述

实验一:加间隙 S 锁

事务时序

时序会话 1会话 2
1BEGIN
2SELECT * FROM student WHERE id = 5 FOR SHARE;(3, 8) 区间加间隙锁
3INSERT INTO student(ID, name) VALUES (6, 'GG'); (阻塞)

实验二:加间隙 X 锁

事务时序

时序会话 1会话 2
1BEGIN
2SELECT * FROM student WHERE id = 5 FOR UPDATE;(3, 8) 区间加间隙锁
3INSERT INTO student(ID, name) VALUES (6, 'GG'); (阻塞)

临键锁(Next-key Lock)

结合了记录锁和间隙锁的锁,对扫描的记录加记录锁,对扫描到的空隙加间隙锁。

实验一:加临键 S 锁

对记录加读锁

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id < 8 FOR SHARE;
id = 1id = 3 加读锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3SELECT * FROM student WHERE id = 3 FOR SHARE;
id = 3 的记录加读锁

对记录写锁

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id < 8 FOR SHARE;
id = 1id = 3 加读锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3SELECT * FROM student WHERE id = 3 FOR UPDATE;
id = 3 的记录加写锁(阻塞)

对表插入操作

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id < 8 FOR SHARE;
id = 1id = 3 加读锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3INSERT INTO student(ID, name) VALUES (-10, 'GG') (阻塞)

实验二:加间隙 X 锁

对记录加读锁

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id < 8 FOR UPDATE;
id = 1id = 3 加写锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3SELECT * FROM student WHERE id = 3 FOR SHARE;
id = 3 的记录加读锁(阻塞)

对记录写锁

时序会话 1会话 2
1BEGINBEGIN
2SELECT * FROM student WHERE id < 8 FOR UPDATE;
id = 1id = 3 加写锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3SELECT * FROM student WHERE id = 3 FOR UPDATE;
id = 3 的记录加写锁(阻塞)

对表插入操作

时序会话 1会话 2
1BEGIN
2SELECT * FROM student WHERE id < 8 FOR UPDATE;
id = 1id = 3 加写锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3INSERT INTO student(ID, name) VALUES (-10, 'GG') (阻塞)

插入意向锁

插入意向锁是由 INSERT 产生的间隙锁。若要对间隙插入记录则会生成插入意向锁。

插入意向锁允许在 (上一条记录, 本记录) 的区间内插入数据。

间隙锁临键锁插入意向锁记录锁
插入意向锁排斥排斥兼容兼容

插入意向锁的作用是为了提高并发插入的性能, 多个事务同时写入不同数据至同一区间内,并不需要等待其他事务完成,不会发生锁等待

实验一:先加插入意向锁,再加间隙锁

事务时序

时序会话 1会话 2
1BEGIN;BEGIN;
2INSERT INTO student(ID, name) VALUES (5, 'GG');
(3, 5) 加插入意向锁
3SELECT * FROM student WHERE id < 8 FOR SHARE;
(3, 8) 加的间隙锁和 (3, 5) 的插入意向锁排斥(阻塞)

实验二:先加间隙锁,再加插入意向锁

事务时序

时序会话 1会话 2
1BEGIN;BEGIN;
2SELECT * FROM student WHERE id < 8 FOR SHARE;
id = 1id = 3 加读锁,对 (-∞,1)(1, 3)(3, 8) 加间隙锁
3INSERT INTO student(ID, name) VALUES (5, 'GG');
(3, 5) 加的插入意向锁和 (3, 8) 上的间隙锁排斥(阻塞)

实验三:对重叠区间加两次插入意向锁

事务时序

时序会话 1会话 2
1BEGIN;BEGIN;
2INSERT INTO student(ID, name) VALUES (5, 'GG');
(3, 5) 加插入意向锁
3INSERT INTO student(ID, name) VALUES (7, 'MM');
(3, 7) 加插入意向锁

页锁

页锁的粒度介于行锁和表锁之间,应用于 BDB 引擎,并发度一般,开销和加锁速度也介于行锁和表锁之间。

了解即可,主要是 MyISAM 和 InnoDB

全局锁

全局锁就是对整个数据库实例加锁。MySQL 只提供了一个加全局读锁的方法

flush tables with read lock;

整个库处于只读状态,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。

参考资料

  • 5
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值