了解 MySQL 数据库中的各种锁
前言
上篇文章学习了事务的隔离级别,其中隔离性是通过锁来实现的,篇幅原因将锁单独分开介绍,下面让我们一起学习 MySQL 中各种锁。
环境:MySQL 8.0.32 ,InnoDB 存储引擎。
丢失更新
在说锁之前我们先来看这样一个业务场景,一个用户账户中有 10000 元人民币,他用两个客户端分别进行转账,第一次转账 9000,因为某些原因需要等待。这时候用户操作另一个网上银行客户端转账 1 ,然后第一个客户端提交修改,正好第二个客户端此时也提交修改,由于第二个客户端修改的时候还认为余额是第一个客户端修改前的初始值 10000,所以减 1 之后,余额变成了 9999 。但是另一端的账户还是收到了 9000 的这笔转账。
也就是下面的流程,
- 事务
T1查询一行数据,放入本地内存,并显示给终端Client1 - 事务
T2也查询该行数据,并将取得的数据显示给终端Client2 Client1修改这行记录,更新数据库并提交Client2修改这行记录,更新数据库并提交
| 时刻 | T1 | T2 |
|---|---|---|
| 0 | begin | begin |
| 1 | 查询数据放入内存 | 查询数据放入内存 |
| 2 | 将余额修改成 1000 | |
| 3 | 提交事务 | |
| 4 | 将余额更新成 9999 | |
| 5 | 提交事务 |
在非常短暂的时间下上述四个步骤执行完毕,我们可以发现 T1 事务的修改变相的丢失了。为了解决这些不安全的问题,MySQL 提供了一些锁,也内置了一些锁给我们使用。
锁的分类
- 设计思想分类:乐观锁、悲观锁。
- 按锁定粒度分类:全局锁、表锁、页锁、行锁。
- 按属性分类:共享锁、排他锁。
- 按行锁再细分:记录锁、间隙锁、临键锁
- 按表级锁再细分:自增锁、意向锁、元数据锁
先混个眼熟,下面会一一介绍这些锁名词。
共享锁(S Lock)、排他锁(X Lock)
MySQL 提供了两种类型的行锁标准。
- 共享锁
允许事务读行数据,如果一个事务 T1 已经获得了行 row 的共享锁(下面简称 S 锁),那么另外的事务 T2 可以立即获取 row 的 S 锁,因为都是读取,不会互相产生影响,这种情况叫做 锁兼容。
注意我们平时写的简单 SELECT * FROM table WHERE ID = 6; 这种语句并不会加共享锁。显示使用共享锁的方法是在 SQL 语句 末尾加上 LOCK IN SHARE MODE
SELECT * FROM t_loan WHERE ID = 56987 LOCK IN SHARE MODE;
- 排他锁
排他锁允许事务删除或更新行数据,借用上面的案例,如果其他事务 T3 想要获取 row 的排他锁(下面简称 X 锁),则必须等待事务 T1、T2 释放行 row 上的共享锁,这种情况叫做锁不兼容。X、S 的兼容关系如下表。
| 锁 | X(排他锁) | S(共享锁) |
|---|---|---|
| X(排他锁) | 不兼容 | 不兼容 |
| S(共享锁) | 不兼容 | 兼容 |
显示使用排它锁的方式是在 SELECT 语句后面加上 FOR UPDATE。
SELECT balance FROM ACCOUNT WHERE user_id = 315789 FOR UPDATE
因此对于上面丢失更新的问题,我们在执行 SELECT 语句的时候可以对该行加上 X 锁
| 时刻 | 会话A | 会话B |
|---|---|---|
| 1 | BEGIN | BEGIN |
| 2 | SELECT balance FROM ACCOUNT WHERE user_id = 315789 FOR UPDATE #该行被加了 X 锁 | |
| 3 | SELECT balance FROM ACCOUNT WHERE user_id = 315789 FOR UPDATE #阻塞 | |
| ... | ... | ... |
| m | UPDATE ACCOUNT SET balance = balance - 9000 WHERE user_id = 315789 | |
| m+1 | COMMIT | |
| m+2 | -- SELECT balance FROM ACCOUNT WHERE user_id = 315789 FOR UPDATE #第 3时刻的查询获取到锁 | |
| m+3 | UPDATE ACCOUNT SET balance = balance - 1 WHERE user_id = 315789 #拿到锁开始更新 | |
| m+4 | COMMIT |
这样上述案例就没有问题了。值得注意的是 X锁和 S锁 是两种锁类型,它的锁粒度不单是一行数据,也可以是多行数据。
锁定读与非锁定读
上面这种在 SELECT 语句后面使用 FOR UPDATE、Lock In Share 加 X、S 锁 的方式就叫做锁定读,其他事务的写必须要等待当前 X、S 锁 释放。
SELECT 不加后缀的普通查询语句都是非锁定读,即使目标行正在被持有 X 锁的事务更新也不影响读取,非锁定读会即时读取目标行的快照,也就是历史版本数据。对于 RC 和 RR 两个事务隔离级别来说。
RC级别下,非锁定读总是读取目标行最新版本的数据。RR级别下,非锁定读总是读取当前事务开始时目标行最初版本的数据。
表级别锁
使用下面的命令可以显示的锁住整张表
LOCK TABLES table_name [READ | WRITE];
作为研发人员来说,通常我们不会显示的使用表级别锁。因此接下来我们主要介绍隐示的几个表级锁。
自增锁 (AUTO-INC Lock)
我们知道 MySQL 主键可以使用 AUTO_INCREMENT ,并且插入的时候是可以不赋值的,让数据库自动生成,那么在并发下进行数据库插入而又要确保自增主键不会重复,显然需要一个锁机制来实现,MySQL 内置了一个自增锁。为了保证一个主键全表唯一,所以自增锁是一个表级锁。
但是每一条 insert 语句都要加自增锁,这无疑是性能低下的,所以 MySQL 又提供了三种自增锁的模式来控制是否需要加自增锁来提高并发插入性能。它由变量 innodb_autoinc_lock_mode 控制,innodb_autoinc_lock_mode 不同取值会决定在不同的插入类型下是否使用自增锁。MySQL 将插入类型分为以下几种
插入类型
Simple inserts简单插入
可以提前确定要插入的行数的语句。这包括没有嵌套子查询的单行和多行 INSERT 和 REPLACE 语句。例如:
INSERT INTO table_demo VALUES(NULL,column1,column2);
Bulk inserts批量插入
事先不知道要插入的行数的语句。例如
INSERT INTO table2 (column1, column2) SELECT column1, column2 FROM table1 WHERE condition;
Mixed-mode inserts混合插入
插入语句中存在确定的主键值,也存在不确定的主键值。例如: INSERT INTO t1 (c1,c2) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,'d');
插入模式设定
innodb_autoinc_lock_mode 不同的取值含义如下:
innodb_autoinc_lock_mode = 0传统模式,每一条INSERT语句都会加自增锁innodb_autoinc_lock_mode = 1连续锁定模式,对于Simple inserts类型的插入不会加自增锁innodb_autoinc_lock_mode = 2交叉模式 这是MySQL 8.0之后默认的值。所有的INSERT语句都不会加自增锁,可能存在的问题是Mixed-mode inserts类型的插入得到的主键id可能不是连续自增的。但是这大大的提高了业务并发性能
注意自增锁的加锁的作用过程是每一条 insert 语句结束,和当前事务无关。
关于自增锁具体可以参考官网对于自增锁的描述 InnoDB AUTO_INCREMENT Lock
元数据锁(MDL)
元数据锁是用来控制进行 DML(数据的增删改查) 操作、DDL(表结构更改操作) 操作互斥的一种隐示表级别锁。
如果没有元数据锁控制,可能会出现的场景是,一个查询语句正在遍历几条行数据,其中一列字段叫做 column1,遍历过程中另一个客户端请求对表结构更改,删除了列 column1,遍历上一条数据的时候还有这个字段,下一条数据突然就没了,可能会引发数据库异常。所以元数据锁是必要的。
当进行 DML 操作时会对表加上 元数据读锁,当进行 DDL 时会对表加上 元数据写锁。两者互斥。被加了表级别的 DML 锁之后,无法查询也无法更新行数据。
意向锁(Intention Lock)
意向锁相比于行锁来说,其实是更大维度上的一个锁标识,标识当前表是否存在持有锁的行索引。
假设有两个事务 T1,T2 ,其中 T2 试图在表级别上使用 X 锁,如果没有意向锁存在,T2 就需要去检查各个页或者行是否已经存在锁,这个做法显然实不可取的,因为要一直遍历所有行检查锁标识,因此引入了意向锁。
比如下图我们要对 表1 某一行记录上使用 X 锁。

它的流程是先对 数据库A 下面的 表1、页 上分别加 意向排他锁 IX,最后才对行记录加 X 锁,如果任何一个环节进行等待,那么该操作都需要等待粗粒度锁的完成。意向锁有两种:
- 意向共享锁(IS):事务想要获取一张表某几行的共享锁
- 意向排他锁(IX):事务想要获取一张表某几行的排他锁
在意向锁的作用下,上面的案例 T2 试图在表级别上使用 X锁 就不需要遍历所有行查找是否存在互斥的行锁,只需要看当前表上是否有互斥的意向锁即可。
举例来说,假如在对行记录加 X 锁之前,已经有事务对 表1 进行了 表级别的 S/X锁,那么表1 上已经存在 S 锁,之后事务需要对行记录在 表1 上加 IX 锁,由于不兼容,所以该事务需要等待表级别的 S锁 的释放。
意向锁的互斥关系
- 意向锁与意向锁
意向锁和意向锁之间是不会阻塞的,比如对 表1 某个行 r1 加 X 锁,此时 表1 上面会被加 IX 锁,另一个事务对 表1 的另一个行 r2 加 X 锁,此时 表1 上又来一个意向锁 IX,这两个 IX 是兼容的,因为如果这时候要是不兼容,那么就麻烦了,意味着我们在数据库中操作两个不同的行还会互相阻塞,这显然是不正确的。
- 意向锁与行锁
注意意向锁和行锁也是不会互斥的。例如 事务A 对行 r1 加了 X锁 。按照刚刚分析的,加锁之前会先在表上加 IX 锁。如果意向锁和行锁互斥,那么我们现在 事务B 也不能对行 r2 加 X锁,这显示也是不对的。r1 和 r2 的X锁 互不影响。
注意 《MySQL技术内幕: InnoDB存储引擎》里面有一句话是:”关于意向锁与行锁的兼容性如下表“。这里应该是笔误了。把 ”表“ 写成了 ”行“,意向锁和行锁是不会互斥的。
- 意向锁与表锁
关于意向锁与表锁的兼容性
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 不兼容 |
| IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
| S | 兼容 | 不兼容 | 兼容 | 不兼容 |
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
行级别锁
记录锁(Record Locks)
顾名思义,就是某一行记录上加的锁,InnoDB 行锁是通过对 索引加锁实现的
- 当一个事务获取了一条记录的
S锁后,其他事务可以继续获取该记录的S锁,但不能获取X锁。 - 当一个事务获取了一条记录的
X锁后,其他事务既不可以获取该记录的S锁,也不能获取X锁。
这属于共享锁和排他锁的范围。update 语句会自动给行记录加 X锁,例如
update t_account set balance = balance - 20 where user_id = 1
事务未提交时,其他更新同一行记录的 update 语句要等当前事务释放 X锁,才能更新。
间隙锁(Gap Locks)
间隙锁是为了防止幻读现象产生的,它的作用是锁定一个索引范围(开区间)。以让这个范围内不可以插入记录。总共可以归纳为三种区间类型。
- 两个索引值之间
- 第一个索引值之前
- 最后一个索引值之后的范围

例如下面的语句就会对数据行加上间隙锁
select * from cash_repay_apply where id > 1 and id < 7 for update
Gap Locks 只在 REPEATABLE READ 隔离级别下有效。下面我们来验证一下间隙锁,借用之前 shardingJDBC 分表文章使用的表,初始化几条数据
CREATE TABLE `cash_repay_apply` (
`id` int NOT NULL AUTO_INCREMENT,
`member_id` int NOT NULL,
`repay_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `member_id` (`member_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 初始化数据
INSERT INTO `tcbiz_ins`.`cash_repay_apply` (`id`, `member_id`, `repay_no`) VALUES (1, 10000, 'TQYHKN202405302909821');
INSERT INTO `tcbiz_ins`.`cash_repay_apply` (`id`, `member_id`, `repay_no`) VALUES (4, 10001, 'TQYHKN202405302909822');
INSERT INTO `tcbiz_ins`.`cash_repay_apply` (`id`, `member_id`, `repay_no`) VALUES (8, 10002, 'TQYHKN202405302909823');
可以查看 performance_schema.data_locks 表。例如上面的 SQL 查询语句具体的加锁信息如下表
| ENGINE_TRANSACTION(事务id) | LOCK_TYPE(锁类型) | LOCK_MODE(锁模式) | LOCK_STATUS(锁状态) | LOCK_DATA(锁定的数据) | 其他字段 |
|---|---|---|---|---|---|
| 201258 | TABLE | IX | GRANTED | NULL | ... |
| 201258 | RECORD | X | GRANTED | 4 | ... |
| 201258 | RECORD | X,GAP | GRANTED | 8 | ... |
LOCK_MODE = X,GAP 就代表间隙锁。具体细节下篇文章会专门介绍。
临键锁(Next-Key Locks)
Next-Key Locks 是索引记录上的记录锁和索引记录之前的间隙上的间隙锁的组合。除了锁定间隙,还锁定当前索引记录本身。它锁定的范围是一块左开右闭的区间,例如下面的语句。
select * from cash_repay_apply where id > 1 and id <= 4 for update
关于临键锁其实真的非常简单,千万不要想复杂了,就是在查询结果的索引值存在闭区间 "[" 或 "]" 的情况下,将 "=" 和间隙锁联合,换了个名字。
注意 Next-Key Locks 也是 RR 隔离级别下才有的。下篇文章会用 SQL 语句来实践什么情况下会加临键锁。
插入意向锁(Insert Intention Locks)
插入数据的时候如果数据索引值所处的范围被加了间隙锁/临键锁,那么需要等待间隙锁/临键锁的释放才能完成插入。这时会生成一个插入意向锁 Insert Intention Locks。后面我们介绍间隙锁的时候会见到 data_locks 表中 LOCK_MODE 字段的值存在 INSERT_INTENTION 。这个就是代表当前事务正在等待加插入意向锁。
注意多个插入意向锁之间会不会互斥取决于唯一索引和主键,只要唯一索引和主键不冲突,那么就不会互斥,
注意这个插入意向锁是行级别的锁,和前面我们介绍的表级别的意向锁没有关系。从逻辑上来说,插入意向锁可以归属到间隙锁范围内。
总结
关于行锁里面的 Record Locks、Gap Locks、Next-Key Locks 具体什么情况下会加哪种类型的锁,下篇文章会用实际的 SQL 语句来详细介绍,从实践中得出真知。
乐观锁、悲观锁
乐观锁和悲观锁是两种锁的设计思想,并不是真正的锁。它们其实也不属于 MySQL 数据库的范畴,由于我们开发过程中常常和数据库结合使用,所以这里也提一下。
悲观锁
悲观锁的思想是,当线程访问资源时总会认为有其他线程来竞争资源,所以每个线程访问资源时总是会上排他锁。MySQL 中 行锁,Java 中的 synchronized 关键字、ReentrantLock 都是悲观锁的思想。
乐观锁
乐观锁的思想是,当线程访问资源是总会认为没有其他线程来竞争资源,不会给资源加上排他锁,但是在更新资源的时候总是会判断是否有其他线程更新过资源。
乐观锁是我们在代码层面用程序结合数据库版本号字段来实现的。在我们操作的数据库表中增加一个版本号 version 字段,初始值为 1 ,每修改一次 version = version + 1 。
update t_user set balance = balance - 20 ,version = version + 1 where user_id = #{userId} and version=#{version}
更新前先查询当前数据的 version ,更新时将 version 传递过去作为条件。由于 MySQL update 语句自带 X锁,所以 version 字段的更新不会被覆盖,在执行当前 update 语句时如果发现 version 条件不成立,即说明数据已经被其他线程修改过,当前语句条件不成立,不会执行。
适用场景
从两种锁的设计思想不难看出:
-
乐观锁:适合查询多,修改少的业务场景。如果修改很多,将会出现很多无效的
update语句。乐观锁的优点是不会出现死锁问题。 -
悲观锁:适合查询少,修改多的场景。如果查询很多,就浪费了锁资源。悲观锁可以有效控制并发安全。
结语
本篇文章简单介绍了 MySQL 中各种锁的定义,使用场景。关于行级别锁下面的三种细粒度锁需要具体 SQL 来实践,下篇文章会单独详细介绍。
如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!
转载自:https://juejin.cn/post/7374965362196119604