MySQL 中的锁按不同维度划分,可分为不同的锁类型。
按读写权限划分:
按加锁粒度划分:
InnoDB 是目前较为常用的存储引擎,InnoDB 通过不同粒度的锁和不同读写权限的锁相互组合,共同实现并发控制。除 S 锁和 X 锁外,还主要包含以下类型的锁:
表级锁:
意向锁的好处就是当我们准备加表锁的时候,不需要在表中的每一行去判断是否有行锁,只需要判断一下表上是否有意向锁,节约了时间去遍历整张表。
记录级锁:
InnoDB 在不同隔离模式下加锁方式、加锁类型、加锁范围等都可能不同,本文只讨论 InnoDB 在 RR(可重复读) 隔离级别下,执行 select … for update/share 时的加锁情况。此外,不同的 MySQL 版本加锁规则也可能不同,本文限定版本为 MySQL 8.0.25。
参考《MySQL实战45讲》
这里提出几点疑问:
下面将带着疑问通过实验来探究 InnoDB 在 RR 模式下执行 select … for update/share 的加锁情况。
要分析加锁情况,首先需要确定如何查看当前表的加锁情况。MySQL 8.0.25 版本 performance_schema.data_locks
表保存了当前事务的加锁情况,因此,通过分析这个表中的数据可以获得当前表的加锁情况,表中的主要字段包括:
下面通过例子说明表加锁的具体含义。假设当前有一个表 t,表中有6条数据,具体见下文实验环境准备部分。
LOCK_MODE | LOCK_DATA | INDEX_NAME | 加锁区间 |
---|---|---|---|
X | 5 | PRIMARY | (0, 5] |
X,REC_NOT_GAP | 5 | PRIMARY | [5] |
X,GAP | 5 | PRIMARY | (0,5) |
mysql的blog上才看到一个锁的准确描述. 如下:
https://dev.mysql.com/blog-archive/innodb-data-locking-part-2-locks/
- S,REC_NOT_GAP → shared access to the record itself (行共享锁,或者行读锁)
- X,REC_NOT_GAP → exclusive access to the record itself (行排他锁,或者行写锁)
- S,GAP → right to prevent anyone from inserting anything into the gap before the row (共享gap锁)
- X,GAP → same as above. Yes, “S” and “X” are short for “shared” and “exclusive”, but given that the semantic of this access right is to “prevent insert from happening” several threads can all agree to prevent the same thing without any conflict, thus currently InnoDB treats S,GAP and X,GAP (or *,GAP locks, for short) the same way: as conflicting just with *,INSERT_INTENTION (共享gap锁, 虽然名字叫"排他",实际上和上面的一样.)
- S → is like a combination of S,REC_NOT_GAP and S,GAP at the same time. So it is a shared access right to the row, and prevents insert before it. (共享邻键锁)
- X → is like a combination of X,REC_NOT_GAP and X,GAP at the same time. So it is an exclusive access right to the row, and prevents insert before it. (排他邻键锁)
- X,GAP,INSERT_INTENTION → right to insert a new row into the gap before this row. Despite “X” in its name it is actually compatible with others threads trying to insert at the same time. (插入检查唯一约束会使用这个锁, 但是只要不违反唯一约束, 每个线程都可以获取这个锁. 但是和上面的S,GAP排斥)
- X,INSERT_INTENTION → conceptually same as above, but only happens for the “supremum pseudo-record” which is imaginary record “larger than any other record on the page” so that the gap “before” “it” is actually “gap after the last record”.
MySQL 版本
8.0.25
表结构:
CREATE TABLE `t` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int DEFAULT NULL COMMENT '唯一索引',
`c` int DEFAULT NULL COMMENT '普通索引',
`d` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_a` (`a`),
KEY `idx_c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
insert into t values (0, 0, 0, 0), (5, 5, 5, 5), (10, 10, 10, 10), (15, 15, 15, 15), (20, 20, 20, 20), (25, 25, 25, 25), (30, 30, 30, 30);
简而言之
id主键索引 a唯一索引 c普通索引 d没有索引
表数据:
首先执行几组查询,并查看加锁情况:
mysql> begin; select * from t where id = 10 for update;
mysql> begin; select * from t where a = 10 for update;
mysql> begin; select * from t where c = 10 for update;
mysql> begin; select * from t where d = 10 for update;
结果:
上图仅为了方便作图,在数轴上将加锁区间进行了合并展示(下同),实际是分多段加锁。
分析:
(5, 10]
,但根据优化1,最终只对主键加行锁;(5,10], (10,15]
,但根据优化2,(10,15] next-key lock
退化为 gap lock (10,15)
,最终合并区间为(5,15)
,同时给对应的主键加行锁;结论:
下面考虑覆盖索引的情况,将select * ...
改为select id ...
,执行几组查询并查看加锁情况:
mysql> begin; select id from t where a = 10 for share;
mysql> begin; select id from t where a = 10 for update;
mysql> begin; select id from t where c = 10 for share;
mysql> begin; select id from t where c = 10 for update;
结论:
for share
都不会对主键加锁(MySQL索引覆盖优化),但是 for update
都会对主键加锁;for share/update
都会对主键所有区间加锁(结果未展示);下面测试数据不存在时,加锁范围情况。
mysql> begin; select * from t where id = 11 for update;
mysql> begin; select * from t where a = 11 for update;
mysql> begin; select * from t where c = 11 for update;
分析:
(10,15]
,实际范围为(10, 15)
,符合优化2;对 id=11
查询做验证,在新 session 中执行以下操作:
为防止 update 对后面实验造成影响,事务最后均回滚,记录值实际未更新。
结果显示,插入 id=12
的行是不允许的,而修改 id=15
的行是允许的,这也验证了锁的间隙是(10,15)
。
结论:
(]
区间优化为()
区间;gap lock的兼容性
两个事务执行:
transaction1:
select * from t where id = 11 for update;
transaction2:
select * from t where id = 12 for update;
事务t1先执行第一条,事务t2再执行第二条是不会阻塞的
也应证了一开始说的锁的兼容性
Gap Lock 之间是兼容的,即使范围有重叠,这种情况下很容易造成死锁。
范围查询情况较多,下面分别举例。
mysql> begin; select * from t where id>17 for update;
mysql> begin; select * from t where a>17 for update;
mysql> begin; select * from t where c>17 for update;
q1:主键id(15,+INF)
q2:唯一索引a(15,+INF) 主键id(20,25,30)
q3:普通索引c(15,+INF) 主键(20,25,30)
mysql> begin; select * from t where id<15 for update;
mysql> begin; select * from t where a<15 for update;
mysql> begin; select * from t where c<15 for update;
结论:
Q1:mysql> begin; select * from t where id>=15 for update;
Q2:mysql> begin; select * from t where a>=15 for update;
Q3:mysql> begin; select * from t where c>=15 for update;
分析:
基于主键查询时,理论加锁范围为(10, +INF)
,实际加锁范围为[15, +INF)
,可见MySQL id=15 时的范围优化为了行锁;
基于唯一索引和普通索引查询时,加锁范围为(10, +INF)
,并没有优化。此外还对范围内对应的主键加了行锁;
结论:
mysql> begin; select * from t where id<=15 for update;
mysql> begin; select * from t where a<=15 for update;
mysql> begin; select * from t where c<=15 for update;
结论:
(-INF, 20]
,并没有像主键那样做优化。此外还对范围内对应的主键加了行锁;(-INF, 20]
,此外还对范围内对应的主键加了行锁;结论:
(-INF, 20]
,并没有像主键那样做优化。此外还对范围内对应的主键加了行锁;(-INF, 20]
,此外还对范围内对应的主键加了行锁;前面总结的加锁原则覆盖了绝大多数场景,此外还有行为:
启发