MySQL锁与加锁规则

234 阅读10分钟

MySQL锁

介绍MySQL中的锁,并通过performance_schema分析它具体加锁情况。

前言

基于MySQL-8.0.32,事务隔离级别为REPEATABLE-READ

REPEATABLE-READ下才会使间隙锁和next-key lock(临键锁)生效。MySQLREPEATABLE-READ下通过间隙锁和next-key lock来解决当前读幻读问题

测试数据

CREATE TABLE `test_lock` (
  `id` int NOT NULL,
  `a` int DEFAULT NULL,
  `b` int DEFAULT NULL,
  `c` int NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `b` (`b`),
  KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `test_lock` (`id`, `a`, `b`, `c`) VALUES (1, 1, 1, 1);
INSERT INTO `test_lock` (`id`, `a`, `b`, `c`) VALUES (4, 4, 4, 4);
INSERT INTO `test_lock` (`id`, `a`, `b`, `c`) VALUES (8, 8, 8, 8);
INSERT INTO `test_lock` (`id`, `a`, `b`, `c`) VALUES (12, 12, 12, 12);
id(主键)a(无索引)b(唯一索引)c(普通索引)
1111
4444
8888
12121212

performance_schema

performance schema提供了MySQL服务器内部运行操作上的底层指标,和数据锁相关表如下:

data_locks-数据锁信息

主要关注LOCK_TYPE-锁类型,LOCK_MODE-锁模式、LOCK_STATUS-锁状态、LOCK_DATA-锁住的数据

字段类型是否允许nullKey默认值说明
ENGINEvarchar(32)NOPRINULL存储引擎
ENGINE_LOCK_IDvarchar(128)NOPRINULL存储引擎内部的锁ID,该值会发生动态变化,外部系统不应该依赖该值
ENGINE_TRANSACTION_IDbigint unsignedYESMULNULL事务id
THREAD_IDbigint unsignedYESMULNULL请求线程id
EVENT_IDbigint unsignedYESNULL事件ID
OBJECT_SCHEMAvarchar(64)YESMULNULL目标库
OBJECT_NAMEvarchar(64)YESNULL目标表
PARTITION_NAMEvarchar(64)YESNULL表分区
SUBPARTITION_NAMEvarchar(64)YESNULL子分区
INDEX_NAMEvarchar(64)YESNULL索引名
OBJECT_INSTANCE_BEGINbigint unsignedNONULL锁的内存空间起始地址
LOCK_TYPEvarchar(32)NONULL锁类型(TABLE/RECORD),TABLE-表锁、RECORD-行锁
LOCK_MODEvarchar(32)NONULL锁模式
LOCK_STATUSvarchar(32)NONULL锁状态
LOCK_DATAvarchar(8192)YESNULL锁住的数据

重点关注字段内容如下:

  • LOCK_MODE

    锁模式,具体值有如下:

    内容意义
    `S[,GAPREC_NOT_GAP]`共享
    `X[,GAPREC_NOT_GAP]`独占
    `IS[,GAPREC_NOT_GAP]`意向共享
    `IX[,GAPREC_NOT_GAP]`意向独占
    AUTO_INC自增锁
    UNKNOWN未知

    [,GAP]存在表示是间隙锁,为,REC_NOT_GAP时表示记录锁、否则就是next-key lock(即间隙锁+记录锁)

  • LOCK_STATUS

    锁状态:是持有-GRANTED 还是等待WAITING

  • LOCK_DATA

    锁的数据,当LOCK_TYPE为RECORD时才会有值,如果是聚族索引则直接显示主键,如果是非聚族索引则是,当前数据以及主键数据

锁类型

MySQL有两种读,对于快照读使用MVCC+Read View实现,在快照读下是不需要加锁。对于当前读则需要加锁,例如DeleteUpdate以及SELECT ....FOR or SELECT ...... LOCK IN XXX MOD属于当前读。

共享锁和独占锁

InnoDB实现了标准的行级锁定,其中有两种类型的锁,共享(S)锁和独占(X)锁。

  • 共享(S)锁允许持有该锁的事务读取一行。

  • 独占(X)锁允许持有该锁的事务更新或删除一行。

当然也提供表级别的锁定

意向锁

为了支持多粒度级别的锁定,InnoDB使用了意向锁。意向锁是表级锁,用于指示事务稍后需要表中某行使用哪种类型的锁(共享或独占)。意向锁有两种类型:

  • 意向共享锁(IS)表示事务打算在表中的各个行上设置共享锁。

  • 意图独占锁(IX)表示事务打算对表中的各个行设置独占锁。

意向锁定规则如下:

  • 在事务可以获取表中某行的共享锁之前,它必须首先获取表上的IS锁或更强的锁。

  • 在事务可以获取表中某行的独占锁之前,它必须首先获取表上的IX锁。

可以看到意向锁可以使得在使用表锁时不需要遍历表中每一个行加锁情况。例如存在一个意向独占锁就不能加表级别的独占锁

XIXSIS
XConflictConflictConflictConflict
IXConflictCompatibleConflictCompatible
SConflictConflictCompatibleCompatible
ISConflictCompatibleCompatibleCompatible

由于意向锁是表级别的,所以不会阻塞除全表扫描外的行锁。

记录锁

原理是匹配过程中锁住索引值索引失效则直接锁表

间隙锁

注意是锁住当前索引记录之间的间隙间隙锁不会因更小的范围查询而缩小(例如 a>3 and a<5 假设当前表中记录为 1,7 即间隙(1,7)那么之前的查询锁定的是(1,7)而不是(3,5))。

注意,间隙锁之间不会阻塞,间隙锁只会阻塞那些在间隙间插入数据。如下:

BEGIN;
SELECT * FROM `test_lock` WHERE id=3 for UPDATE;

-----开启另一个会话--
BEGIN;
SELECT * FROM `test_lock` WHERE id=2 for UPDATE;
------ 发现第二个会话不住宿-----
-----由于2,3记录不存在,且唯一索引 所以锁住的都是(1,4)这个间隙锁

查询performance schema.data_locks如下:

THREAD_IDOBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
54testtest_lockTABLEIXGRANTED
54testtest_lockPRIMARYRECORDX,GAPGRANTED4
53testtest_lockTABLEIXGRANTED
53testtest_lockPRIMARYRECORDX,GAPGRANTED4

可以确定都对4这个索引记录前的间隙加锁,证明间隙锁之间相互不阻塞。

其次gap是个动态的概念,例如删除间隙前一天记录则gap的范围变大。

next-key lock

next-key lock相当于记录锁+间隙锁,锁住的范围是记录以及记录前的间隙,即一个前开后闭的区间

InnoDB执行行级锁定的方式是,当它搜索或扫描表索引时,它会对遇到的索引记录设置共享或独占锁定。因此,行级锁实际上是索引记录锁。索引记录上的下一个键锁定也会影响该索引记录之前的“间隙”。也就是说,next-key lock是索引记录锁加上索引记录之前间隙上的间隙锁。例如一个会话对索引中的记录R具有共享或独占锁定(next-key lock),则另一个会话无法在索引顺序中R之前的间隙中插入新的索引记录。

以测试数据为例,next-key lock可能有以下加锁范围

(negative infinity, 1]
(1, 4]
(4, 8]
(8, 12]
(12, positive infinity)

插入意向锁

插入意向锁是一种间隙锁形式的意向锁,在真正执行 INSERT 操作之前设置。该锁定以这样一种方式发出插入意图的信号,即如果插入到同一索引间隙中的多个事务不在间隙内的同一位置插入,则它们不需要彼此等待。假设存在值为4和7的索引记录。尝试分别插入值为5和6的单独事务,在获得插入行的独占锁之前,每个事务都使用插入意图锁来锁定4和7之间的间隙,但不会相互阻止,因为行是不冲突的。

插入意向锁会和间隙锁冲突。

自增锁

AUTO-INC锁是一种特殊的表级锁(该锁会在插入语句完成后立即释放,而不是插入语句所在事务提交时释放),由插入到具有AUTO_INCREMENT列的表中的事务使用。在最简单的情况下,如果一个事务正在向表中插入值,那么任何其他事务都必须等待自己向该表中插入,以便第一个事务插入的行接收连续的主键值。

自增长方式可分为下面四类:

  • INSERT-LIKE:指所有的插入语句,比如 INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT,LOAD DATA等。
  • Simple insert:指在插入前就能确定插入行数的语句,包括INSERT、REPLACE,不包含INSERT…ON DUPLICATE KEY UPDATE这类语句。
  • Bulk inserts:指在插入前不能确定得到插入行的语句。如INSERT…SELECT,REPLACE…SELECT,LOAD DATA.
  • Mixed-mode inserts:指其中一部分是子增长的,有一部分是确定的。

使用innodb_autoinc_lock_mode参数控制,参数取值如下:

  1. innodb_autoinc_lock_mode=0 传统锁定模式, 5.1.22之前的方式,也就是所有INSERT-LIKE操作都用AUTO-inc locking(表级锁)。
  2. innodb_autoinc_lock_mode=1 连续锁定模式,这个参数是5.1.22之后出现的也是之后的默认值,对于SIMPLE INSERT使用轻量级互斥锁,对于BULK INSERT,使用AUTO-inc locking
  3. innodb_autoinc_lock_mode=2 交错锁定模式,指不管什么情况都使用轻量级互斥的锁,效率最高,但是复制只能使用row-basereplication,因为statement-base replication会出现问题。且对于Bulk inserts可能导致插入不连续的问题。

在使用互斥量情况下, 对于在插入前就能确定插入的行数,那么可以一次加锁直接分配一段。如果不能确定行数那么就会导致不连续的问题。

加锁分析

测试数据如下:

id(主键)a(无索引)b(唯一索引)c(普通索引)
1111
4444
8888
12121212

先说规则,带着规则取验证

  1. 加锁时,会先给表添加意向锁,IX 或 IS;

  2. 加锁的基本单位next-key lock,即在扫描或者查询索引时锁住访问的next-key lock-前开后闭的区间;

  3. 如果是辅助索引,则还需对命中的行记录的主键加上记录锁;

    ---------优化-------

  4. 唯一性索引等值查询,命中时,next-key lock会降级为记录锁 X,REC_NOT_GAP

  5. 唯一性等值查询,未命中时,next-key lock会降级为间隙锁 X,GAP

  6. 非唯一性索引等值查询,未命中时,next-key lock会降级为间隙锁 X,GAP

非唯一索引

测试数据中 c为非唯一索引字段。

等值查询

命中
SELECT * FROM `test_lock` t  WHERE  t.c =1 for SHARE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEISGRANTED
testtest_lockcRECORDSGRANTED1, 1
testtest_lockPRIMARYRECORDS,REC_NOT_GAPGRANTED1
testtest_lockcRECORDS,GAPGRANTED4, 4

首先对表加了意向锁-IS;

然后对c索引中1(1命中)加了临键锁-(negative infinity, 1],同时对对应的主键值1加一个记录锁;

由于是非唯一索引在命中1之后还需往后查询,锁住下一个临键锁,下一记录是4未命中(等值查询不相等)所以退化成间隙锁,即锁住了(1,4)

所以c索引锁住的范围是(negative infinity, 4),主键锁住的是记录1。

未命中
SELECT * FROM `test_lock` t  WHERE  t.c =2 for SHARE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEISGRANTED
testtest_lockcRECORDS,GAPGRANTED4, 4

可以看到等值查询未命中退化成间隙锁。

范围查询


SELECT * FROM `test_lock` t  WHERE  t.c >1 and t.c<7 for SHARE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEISGRANTED
testtest_lockcRECORDSGRANTED4, 4
testtest_lockcRECORDSGRANTED8, 8
testtest_lockPRIMARYRECORDS,REC_NOT_GAPGRANTED4

首先对表-test_lock加了意向锁-IS;

对与c索引4命中,所以对4加临键锁(1, 4];

继续往后扫描(前一个在范围内且非唯一),下一个索引值为8,对8加临键锁(4, 8],范围查询不降级;

这里没有按c索引逆向排序所以往后扫。如果 order by c desc则就是逆向扫描,此时8就是间隙锁,而1是next-key lock了

符合条件只有c索引值为4,将其对应的主键锁值(这里是4)加上记录锁;

所以c索引锁住的范围是(1, 8],主键锁住记录4。

二级唯一索引

b为唯一性索引

等值查询

命中

SELECT * FROM `test_lock` t  WHERE  t.b =1 FOR UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockbRECORDX,REC_NOT_GAPGRANTED1, 1
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED1

首先对表加了意向锁-IX;

然后对b索引中1(1命中)加了临键锁-(negative infinity, 1],等值查询且命中索所以退化成记录锁X,REC_NOT_GAP

由于是唯一索引在命中1之后不需往后查询;

b为二级索引,需要的命中的记录上主键加上记录锁X,REC_NOT_GAP;

所以b索引锁住的范围是[1,1],主键锁住的是记录1。

未命中
SELECT * FROM `test_lock` t  WHERE  t.b =2 FOR UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockbRECORDX,GAPGRANTED4, 4

可以看唯一性索引等值查询未命中的时候,退化为间隙锁。

范围查询

SELECT * FROM `test_lock` t  WHERE  t.b >1 and t.b<7 for UPDATE;
或者是下面
SELECT * FROM `test_lock` t  WHERE  t.b >1 and t.b<=4 for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockbRECORDXGRANTED4, 4
testtest_lockbRECORDXGRANTED8, 8
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED4

首先对表加了意向锁-IX;

然后对b索引中4(4第一个满足条件)加了临键锁-(1, 4]

4在查询范围内,继续往后扫描,下一个索引值是8,对8加临键锁-(4, 8](加锁的基本单位),范围查询不降级;

注意扫描的顺序,即索引正向还是逆向

b为二级索引,需要的命中的记录上主键加上记录锁X,REC_NOT_GAP,命中的记录只有4;

所以b索引锁住的范围是(1,8],主键锁住的是记录4。

边界测试
SELECT * FROM `test_lock` t  WHERE  t.b >=4 and t.b<=8 for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockbRECORDXGRANTED4, 4
testtest_lockbRECORDXGRANTED8, 8
testtest_lockbRECORDXGRANTED12,12
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED4
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED8

这个和使用非唯一索引(这里是c)SELECT * FROM test_lock t WHERE t.c >=4 and t.c<=8 for UPDATE;的加锁情况是一样的。

从第一个满足的记录到最后一个不满足的记录加next key lock;

逆向查询

同样是SQL但是按b逆序。

SELECT * FROM `test_lock` t  WHERE  t.b >=4 and t.b<=8 order by t.b desc for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockbRECORDX,GAPGRANTED12, 12
testtest_lockbRECORDXGRANTED1, 1
testtest_lockbRECORDXGRANTED4, 4
testtest_lockbRECORDXGRANTED8, 8
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED4
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED8

可以看到从右往左扫描第一个不等的是1,所以对1也加了next key lock;然后第一个命中的是8,从右往左所以需要对(8,12)这间隙加锁。

主键索引

等值查询

命中
SELECT * FROM `test_lock` t  WHERE  t.id =1 FOR UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED1

首先对表加了意向锁-IX;

然后对主键索引中1(1命中)加了临键锁-(negative infinity, 1],等值查询且命中索所以退化成记录锁X,REC_NOT_GAP

由于是主键索引在命中1之后不需往后查询;

所以主键锁住的是记录1。

未命中
SELECT * FROM `test_lock` t  WHERE  t.id =2 FOR UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDX,GAPGRANTED4

等值查询未命中退化成间隙锁。

范围查询

SELECT * FROM `test_lock` t  WHERE  t.id >=4 and t.id<=8 for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED4
testtest_lockPRIMARYRECORDXGRANTED8

首先对表加了意向锁-IX;

然后对主键索引中4满足条件加了临键锁-(1, 4],这里退化乘车记录锁,对主键索引优化

4在查询范围内,继续往后扫描,下一个索引值是8,对8加临键锁-(4, 8];

按照之前的逻辑到得查询到第一不满足的继续往后,但是这里没有往后。

下面把查询范围稍微扩大一点

SELECT * FROM `test_lock` t  WHERE  t.id >=4 and t.id<=9 for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDX,REC_NOT_GAPGRANTED4
testtest_lockPRIMARYRECORDXGRANTED8
testtest_lockPRIMARYRECORDX,GAPGRANTED12

主键索引对范围查询边界左右边界做了优化。边界值退化间隙锁或者是记录锁。

id逆序
SELECT * FROM `test_lock` t  WHERE  t.id >=4 and t.id<=8 order by t.id desc for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDXGRANTED1
testtest_lockPRIMARYRECORDXGRANTED4
testtest_lockPRIMARYRECORDXGRANTED8
testtest_lockPRIMARYRECORDX,GAPGRANTED12

发现逆序的时候边界又没有优化了

不使用索引

a 上没有索引


SELECT * FROM `test_lock` t  WHERE  t.a =1 for UPDATE;

select * from performance_schema.data_locks查询锁信息如下:

OBJECT_SCHEMAOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
testtest_lockTABLEIXGRANTED
testtest_lockPRIMARYRECORDXGRANTEDsupremum pseudo-record
testtest_lockPRIMARYRECORDXGRANTED1
testtest_lockPRIMARYRECORDXGRANTED4
testtest_lockPRIMARYRECORDXGRANTED8
testtest_lockPRIMARYRECORDXGRANTED12

可以看到在没有使用索引时对所有的记录的主键值加了临键锁。相当于锁住全表。supremum pseudo-record表示锁住上界的间隙。

注意事项

注意上面所有的的data_locks只列出了一部分所需关键内容,并不是全部内容。且隔离等级要是可重复读的

结论

加锁规则

基于MySQL-8.0.32可重复读。

  1. 加锁时,会先给表添加意向锁,IX 或 IS;

  2. 加锁的基本单位next-key lock,即在扫描或者查询索引时锁住访问next-key lock(前开后闭的区间);

    按照索引的顺序扫描,如果逆向排序则扫描范围相反。

  3. 如果是辅助索引,则还需对命中的行记录的主键索引加上记录锁

    在MySQL-8.0.32上是这样的,mysql45讲中只有排它锁时才对对应的主键加锁

  4. 唯一性索引等值查询命中时,next-key lock会降级为记录锁 X,REC_NOT_GAP

  5. 索引等值查询未命中时,next-key lock会降级为间隙锁 X,GAP

  6. 范围查询是加锁第一个满足条件的记录的next-key lock到第一个不满足条件的记录的next-key lock

    范围查询第一个值就是先按边界值等值查询,然后再继续遍历直到不满足条件。

  7. 主键索引的范围查询的边界会根据等值查询一样进行优化,退化成记录锁或者行锁。

    按道理,唯一性索引也可对边界进行优化,但是却没有

这里命中的意思索引值等于查询值,在等值查询的情况临键锁才有可能降级。

等值查询即=

索引等值查询未命中则退化为间隙锁,无论是唯一性索引还是非唯一索引。

注意在可重复读下才有间隙锁。

注意事项

间隙锁之间相互不阻塞且为动态概念!!!

间隙锁只会阻塞往间隙里插入数据。其次gap是个动态的概念,例如删除间隙前一天记录则gap的范围变大

利用索引排序影响加锁!!!

加锁和扫描索引的顺序有关

参考

nextkey-lock