对于并发系统,使用MySQL锁来对数据进行保护是非常必要的,下面我们来看一下MySQL都有哪些锁可以供我们使用,锁的介绍顺序是根据锁的粒度来进行划分的。
全局锁
首先介绍的是MySQL中粒度最大的锁 -- 全局锁,它的加锁语句如下:
flush tables with read lock
使用全局锁会锁住整个数据库,这时其他的线程如果再想进行insert、update、delete等对数据的更改操作和alter、drop等对表结构进行更改的操作都会被锁住。
如果要释放全局锁,可以使用如下语句:
unlock tables
那不用我说大家应该都知道锁的粒度越小,系统的并发度就会越高,那么使用全局锁就是根本没有并发了,只有一个线程可以对数据库进行写操作,其他线程只能读数据库,那全局锁的目的是什么呢?
用于做全库的逻辑备份,这样不会在进行数据备份的时期因为对数据或者表结构的更改而出现备份数据与实际数据不一致的情况。
那么我们上面已经写了给数据库加全局锁的缺点,那么可不可以使用什么方法来提升数据备份的性能:有的兄弟,有的。
我们知道MySQL的事物是支持可重复读级别的,在这个级别中我们在读数据的时候会对表加原数据(MDL)锁(后面会讲),这样是不能有线程对数据的表结构进行更改的,并且在可重复读这个级别下我们是看不到其他线程对于数据的修改的,所以可以用来进行数据备份。
在我们使用mysqldump进行数据备份的时候可以加上-single-transaction来开启事物,但是这种备份操作只能用于支持可重复读的存储引擎,对MyISAM这样的存储引擎还是只能使用全局锁来进行数据备份操作。
表级锁
将完全局锁之后就可以将一下表级锁了,因为除了数据库下一个粒度就是表了,我们来看一下表级锁都包含哪些:
- 表锁
- 原数据锁(MDL)
- 意向锁
- AUTO-INC锁
表锁
我们先来看一下表锁,如果想对一个表加锁可以使用如下语句
# 表级别的共享锁,允许本线程和其他线程对表进行读操作
# 但是不允许本线程和其他线程进行写操作
lock tables xxx read
# 表级别的写锁,允许本线程读写该表
# 但是不允许其他线程读写该表
lock tables xxx write
这里需要注意的就是,表锁不仅仅会影响其他线程的读写操作,也会影响自己的读写操作,比如加了读的表锁本线程也是不可以写的,而且是不能访问其他表的,比如lock tables t1 read, t2 write;这个语句,这个线程就只能读t1,读写t2,对于其他表也是不可以操作的。
# 释放表级锁
unlock tables
UNLOCK TABLES; 这个命令同时是全局锁和显式表锁的解锁命令,但它是一个“原子性”操作,会释放当前会话持有的所有锁(包括全局读锁和所有表锁),你无法用它来只释放其中一个锁。
原数据(MDL)锁
原数据(MDL)锁的目的是为了防止在用户进行CRUD操作的时候其他线程对于数据表的结构进行更改。
- 对一张表进行CRUD操作的时候会给这张表加一张MDL读锁。
- 对一张表做结构变更的时候会加上MDL写锁。
也就是说我们如果想对一个表进行表结构更改要先保证此时没有线程在进行CRUD操作,同样的我们要对一个表进行CRUD操作前要保证没有线程在修改表结构
还有一个问题就是我们不需要现实的调用MDL锁这个锁是会根据我们的操作自动加上读锁或者写锁的,那么它是什么时间释放的呢?
在事物commit的时候会释放这个锁,那么如果有一个长事物在进行读操作时有一个线程要修改表结构可能会导致后续的所有读操作都被阻塞
- A事物执行select语句之后不进行提交,此时它占有这个表的MDL读锁
- B事物要对这个表结构进行修改,它要去拿到这个表结构的MDL写锁,但是由于A事物没有提交所以它拿不到,这时候它会阻塞。
- 这时后续要对这个表进行CRUD的操作都会被阻塞住(这是因为申请MDL锁是一个队列,写锁的优先级更高,所以有一个写锁被申请之后即使没有得到也不能再申请读锁了)。
所以我们一来要尽量的避免长事物,而且如果要进行表结构变更应该先看长事物是否占有MDL读锁,如果有就把他kill掉。
意向锁
上面说的MDL锁和表锁还有全局锁都是server层面上的锁,而意向锁是Innodb独有的锁。
那意向锁被创造的目的是什么呢,我们来看一下这种情况:首先要对表加写锁,这时表中是不可以有行锁的(不管是写锁还是读锁),那么我们要判断是否有行锁就要一个个的遍历,这是很不方便的,所以我们在加行级的写锁的时候会加一个意向写锁,在家行级的读锁的时候会加一个意向读锁,如下所示。
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
-- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
SELECT column FROM table ... FOR UPDATE;
知道怎么加锁和锁的用处之后我们来看一下这个锁的兼容性
| 意向共享锁(IS) | 意向排他锁(IX) | |
|---|---|---|
| 意向共享锁(IS) | 兼容 | 兼容 |
| 意向排他锁(IX) | 兼容 | 兼容 |
首先意向锁之间都是兼容的,其实也好理解,就是我对一条数据加读锁会给这个表加意向共享锁,我对另一条数据加写锁会加意向拍他锁,这些本来就不应该冲突。
那看完这个再看一下意向锁和表锁之间的兼容性
| 意向共享锁(IS) | 意向排他锁(IX) | |
|---|---|---|
| 共享锁(S) | 兼容 | 互斥 |
| 排他锁(X) | 互斥 | 互斥 |
AUTO-INC锁
这个也是Innodb独有的一种锁。
AUTO-INC就是我们实现主键自增的一种方式,在早期的MySQL中实现方式是给全表加一个锁,然后在自增之后将锁释放(不是事物提交才释放),这时如果来其他语句会阻塞住,这显然性能上是不行的,然后MySQL就进行了升级,只是对这个字段加上锁,然后就释放。
这里有一个问题就是在使用这个轻量级锁的同时binlog的字段为statement是有问题的,我们如果设置轻量级锁应该使用raw作为binlog的格式,至于为什么想了解的同学可以上网查一下,就是会出现主从不一致的问题。
行级锁
行级锁是Innodb独自支持的,MyISAM是不支持行级锁的,我们来看一下行级锁都有哪些?
首先我们要知道的是不同的select的读取是不会加锁的,这种读称之为快照读,它是通过MVCC实现的,具体实现可以上网查询,简单点说就是通过redoLog维护版本链,然后通过创建ReadView来进行版本选择。
那加锁的读又叫当前读,即它读取的都是当前最新的数据。
# 对读取的记录加共享锁
select ... lock in share mode
# 对读取的记录加独占锁
select ... lock for update
同样的,这些锁在事物提交的时候会自动释放,那mysql的锁就到此结束了吗,当然不是,在innodb中行级锁包含:
- Record Lock:记录锁,也就是仅仅把一条记录锁上
- Gap Lock:锁定一个范围,但是不包括记录本身
- Next-Key Lock:Record Lock + Gap Lock,锁定范围的同时锁定记录
这些锁分别是怎么加的我们在最后讲解
插入意向锁
插入意向锁是如果一个事物持有间隙锁,比如(3, 10),此时另一个事物相插入5,另一个事物就会获得一个插入意向锁,那么这个锁是干什么用的呢?
插入意向锁是一种特殊的间隙锁 (Gap Lock)。它是在执行 INSERT 操作之前,由 InnoDB 引擎主动设置的一种“意向”锁。
它的目的不是像普通的间隙锁那样“禁止”其他事务在某个区间内插入数据,恰恰相反,它的存在是为了允许不同的事务在同一个间隙中同时插入不冲突的数据,从而大大提高插入操作的并发度。
你可以把它理解为一个“友好的信号”或“预约标记”。一个事务通过设置插入意向锁来宣告:“我打算在这个间隙的某个特定位置插入一条数据,但我不会独占整个间隙”。
在没有插入意向锁的情况下,如果两个事务都要在同一个间隙(例如,在索引记录 10 和 20 之间)插入数据,后发起的事务会因为前面的事务持有该间隙的普通间隙锁 (Gap Lock) 而被完全阻塞,必须串行执行。
插入意向锁的引入,使得这两个事务只要插入的数据的主键或唯一键不冲突(即它们插入的不是同一个位置),就可以同时进行,从而实现了更细粒度的并发控制。
核心目标:在保证不破坏“可重复读”隔离级别防止幻读能力的前提下,最大限度地提高并发插入的效率。
我们来看一下它在场景中的作用
没有事务持有“普通间隙锁”(大多数情况)
- 状态:间隙 (10, 20) 是“开放的”,没有任何事务用它来做范围查询或更新。
- 事务A 要插入
id=11。 - 它向间隙 (10, 20) 申请一个在 点11 上的插入意向锁。
- 因为没有冲突的锁,申请成功,执行插入。
- 几乎同时,事务B 要插入
id=12。 - 它向间隙 (10, 20) 申请一个在 点12 上的插入意向锁。
- InnoDB 检查发现:只有一个在点11上的插入意向锁。插入意向锁之间是兼容的,所以申请成功,执行插入。
- 结果:高并发达成! 两个插入操作同时完成,性能极高。这种情况下,根本没有防止幻读的需求,因为没有任何事务在持续关注这个间隙。
Record、Gap、Next-Key解析
MySQL的间隙表示的是两个索引键之间的空间,间隙锁用于防止范围查询期间的幻读,确保查询结果的一致性和并发安全性。
我们依次来介绍几个锁
record lock
记录锁(record lock)又被称为行锁,顾名思义它就是用于锁定MySQL的行数据的一种锁,比如
``mysql SELECT * FROMuserWHEREid`=1 FOR UPDATE;
这条语句就会在id = 1这一行加入一个record lock目的是放弃其他事物插入、更新、删除这一行。
**gap lock**
间隙锁锁定的是索引之间的间隙,以避免其他事物在这个范围内插入新的数据。间隙锁是可读锁,目的是防止其他事物在间隙中插入满足条件的值。它只在可重复读的条件下生效。这里还要知道的是我们的间隙锁只是阻塞住了insert,而record lock阻塞的是任意其他的锁。
**next-key lock**
临键锁由记录锁和间隙锁组合而成,它在索引范围内的记录上加上记录锁,并在索引范围之间的间隙上加上间隙锁。这样可以避免幻读(Phantom Read)的问题,确保事务的隔离性。
切记:间隙锁的区间是左开右开的,临键锁的区间是左开右闭的。
#### 间隙锁解析
上面是对这三个锁的概念进行讲解,下面我们结合例子来理解一下这些锁到底在干什么。
假设有一个名为`products`的表,其中有一个整型列`product_id`作为主键索引。现在有两个并发事务:事务A和事务B。
事务A执行以下语句:
mysql
BEGIN;
SELECT * FROM products WHERE product_id BETWEEN 100 and 200 FOR UPDATE;
事务B执行以下语句:
mysql
BEGIN;
INSERT INTO products (product_id, name) VALUES (150, 'Product 150');
在这种情况下,事务A会在`products`表中`product_id`值在 100 和 200 之间的范围上设置间隙锁。因此,在事务A运行期间,其他事务无法在这个范围内插入新的数据,在事务B尝试插入`product_id`为150的记录时,由于该记录位于事务A锁定的间隙范围内,事务B将被阻塞,直到事务A释放间隙锁为止。
##### **间隙锁触发条件**
在**可重复读(Repeatable Read)事务隔离级别下**,以下情况会产生间隙锁:
- 使用**普通索引**锁定:当一个事务使用普通索引进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。
- 使用**多列唯一索引**:如果一个表存在多列组成的唯一索引,并且事务对这些列进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。
- 使用**唯一索引锁定多行记录**:当一个事务使用唯一索引来锁定多行记录时,MySQL会在这些记录之间的间隙上生成间隙锁,以确保其他事务无法在这个范围内插入新的数据。
需要注意的是,上述情况仅在**可重复读**隔离级别下才会产生间隙锁。在其他隔离级别下,如读提交(Read Committed)隔离级别,MySQL可能会使用临时的意向锁来避免并发问题,而不是生成真正的间隙锁。
为什么这里强调的是普通索引呢?**因为对唯一索引锁定单行查询并不会触发间隙锁**,请看下面这个例子:
假设我们有一个名为`students`的表,其中有两个字段:id 和 name。id是主键,现在有两个事务同时进行操作:
事务A执行以下语句:
mysql
SELECT * FROM students WHERE id = 1 FOR UPDATE;
事务B执行以下语句:
mysql
INSERT INTO students (id, name) VALUES (2, 'John');
由于事务A使用了唯一索引锁定,**它会锁定id为1的记录,不会触发间隙锁**。同时,在事务B中插入id为2的记录也不会受到影响。这是因为唯一索引只会锁定匹配条件的具体记录,而不会锁定不存在的记录(如间隙)。
**当使用唯一索引锁定一条存在的记录时,会使用记录锁,而不是间隙锁**
但是当搜索条件仅涉及到**多列唯一索引的一部分列**时,可能会产生间隙锁。以下是一个例子:
假设`students`表,包含三个列:id、name和age。我们在(name, age)上创建了一个唯一索引。
现在有两个事务同时进行操作:
事务A执行以下语句:
mysql
SELECT * FROM students WHERE name = 'John' FOR UPDATE;
事务B执行以下语句:
mysql
INSERT INTO students (id, name, age) VALUES (2, 'John', 25);
在这种情况下,事务A搜索的条件只涉及到了唯一索引的一部分列(name),而没有涉及到完整的索引列(name, age)。因此,MySQL会对匹配的记录加上行锁,并且还会对与该条件范围相邻的间隙加上间隙锁。
#### Next-Key(临健锁)详解
我们之前看了间隙锁的触发条件,其实就是在可重复读这个隔离级别之下,使用普通索引,或者唯一索引的多列,或者多行唯一索引这种情况可能会使用间隙锁,那什么是临健锁呢,为什么要有这个锁呢,以及在什么情况下会使用这个锁呢?
首先我们介绍一下临健锁:**临键锁是 InnoDB 在可重复读(REPEATABLE-READ)隔离级别下默认使用的行锁算法。它是记录锁(Record Lock)和间隙锁(Gap Lock)的组合。**
然后我们来看一下Next-Key多加锁规则
规则1:加锁的基本单位是 Next-Key Lock,左开右闭区间。
规则2:查找过程中访问到的对象才会加锁。
规则3:唯一索引上的范围查询会上锁到不满足条件的第一个值为止。
规则4:唯一索引等值查询,并且记录存在,Next-Key Lock 退化为行锁。
规则5:索引上的等值查询,会将距离最近的左边界和右边界作为锁定范围,如果索引不是唯一索引还会继续向右匹配,直到遇见第一个不满足条件的值,如果最后一个值不等于查询条件,Next-Key Lock 退化为间隙锁。
下面使用例子来帮助我们进行理解
mysql
CREATE TABLE user (id bigint NOT NULL AUTO_INCREMENT,age int DEFAULT NULL,name varchar(32) DEFAULT NULL,
PRIMARY KEY (id)
KEY age (age)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
数据:
| id | age | name |
|---|---|---|
| 1 | 1 | 小明 |
| 5 | 5 | 小王 |
| 7 | 7 | 小张 |
| 11 | 11 | 小陈 |
在进行测试之前,我们先来看看 user 表中存在的隐藏间隙:
- (-∞, 1]
- (1, 5]
- (5, 7]
- (7, 11]
- (11, +∞]
案例一:唯一索引等值锁定存在的数据
如下是事务A和事务B执行的顺序:
| 时刻 | 事务A | 事务B |
|---|---|---|
| T1 | begin | begin |
| T2 | select * from user where id = 5 for update | |
| T3 | insert into user value(3,3,"小黑") ---不阻塞 | |
| T4 | insert into user value(6,6,"小蓝") ---不阻塞 | |
| T5 | commit | commit |
根据规则4,加的是记录锁,不会使用间隙锁,所以只会锁定 5 这一行记录,这是因为对于唯一索引使用等值查询,临健锁会退化为行锁。
案例二:索引等值锁定
| 时刻 | 事务A | 事务B |
|---|---|---|
| T1 | begin | begin |
| T2 | select * from user where id = 3 for update --- 不存在的数据 | |
| T3 | insert into user value(6,6,"小蓝") --- 不阻塞 | |
| T4 | insert into user value(2,2,"小黄") --- 阻塞 | |
| T5 | commit |
这是一个索引等值查询,根据规则1和规则5,加锁范围是( 1,5 ] ,又由于向右遍历时最后一个值 5 不满足查询需求,Next-Key Lock 退化为间隙锁。也就是最终锁定范围区间是 ( 1,5 )。
案例三:唯一索引范围锁定
| 时刻 | 事务A | 事务B |
|---|---|---|
| T1 | begin | begin |
| T2 | select * from user where id >= 5 and id<6 for update | |
| T3 | insert into user value(7,7,"小赵") --- 阻塞 | |
| T4 | commit |
根据规则3,会上锁到不满足条件的第一个值为止,也就是7,所以最终加锁范围是 [ 5,7 ]。
其实这里可以分为两个步骤,第一次用 id=5 定位记录的时候,其实加上了间隙锁 ( 1,5 ],又因为是唯一索引等值查询,所以退化为了行锁,只锁定 5。
第二次用 id<6 定位记录的时候,其实加上了间隙锁( 5,7 ],所以最终合起来锁定区间是 [ 5,7 ]。
案例四:非唯一索引范围锁定
| 时刻 | 事务A | 事务B |
|---|---|---|
| T1 | begin | begin |
| T2 | select * from user where age >= 5 and age<6 for update | |
| T3 | insert into user value(8,8,"小青") --- 不阻塞 | |
| T4 | insert into user value(2,2,"小黄") --- 阻塞 | |
| T5 | commit |
参考上面那个例子。
第一次用 age =5 定位记录的时候,加上了间隙锁 ( 1,5 ],不是唯一索引,所以不会退化为行锁,根据规则5,会继续向右匹配,所以最终合起来锁定区间是 ( 1,7 ]。
案例五:间隙锁死锁
| 时刻 | 事务A | 事务B |
|---|---|---|
| T1 | begin | begin |
| T2 | select * from user where id = 3 for update | |
| T3 | select * from user where id = 4 for update | |
| T4 | insert into user value(2,2,"小黄") --- 阻塞 | |
| T5 | insert into user value(4,4,"小紫") --- 阻塞 |
间隙锁之间不是互斥的,如果一个事务A获取到了( 1,5 ] 之间的间隙锁,另一个事务B仍然可以获取到( 1,5 ] 之间的间隙锁。这时就可能会发生死锁问题。
在事务A事务提交,间隙锁释放之前,事务B也获取到了间隙锁( 1,5 ] ,这时两个事务就处于死锁状态。
这里还有要注意的是我们(1, 5]在5这里相当于加的是record key所以我们读取id = 5 for update的操作是会阻塞的。
还有就是如果没有使用索引进行查询的话,这里的的锁可能会锁住整个表
间隙锁(Gap Lock)和临键锁(Next-Key Lock)是可重复读(Repeatable Read, RR)隔离级别所独有的。
Comments NOTHING