该文章举例都是基于 InnoDB 可重复读(RR)隔离级别的,mysql 版本 8.0
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类
全局锁
全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。整个库处于只读状态的时候,其他线程的 DML 和 DDL 语句都会阻塞。
适用场景:全库逻辑备份,就是把整库每个表都 select 出来存成文本。主要是针对 myisam 这种不支持事务一致性读的引擎,InnoDB 引擎可通过 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。
表级锁
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)
表锁
表锁的语法是 lock tables … read/write,通过 unlock tables 主动释放锁,也可以通过客户端断开连接进行释放。
举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表
元数据锁(meta data lock,MDL)
MDL在访问一个表的时候会被自动加上的,增删查改时加 MDL 读锁,DDL 加写锁。其中读读不互斥,读写、写写互斥。
目的:保证读写的正确性。
假如线程 A 查询 C1,C2 两列,线程 B 执行删除列 C1,如果不加锁进行互斥,那么线程 A 查询的结果跟表结构不一致,这是不允许的。
注意事项:给表加个列时,注意程序的长事务(事务没提交时一直占用 MDL 读锁),会长时间阻塞DDL(MDL写锁),进而阻塞后面的增删查改的事务。例子如下:(只有当 sessionA commit 时,其他阻塞线程还会继续执行)
sessionA | sessionB | sessionC | sessionD | sessionE |
---|---|---|---|---|
begin; select * from tb limit 1;//事务没提交,一直持有 MDL 锁 |
||||
select * from tb limit; //不阻塞。注意没有 begin,事务自动提交。 | ||||
alter table tb add c int;//阻塞,因为想加 MDL 写锁,以 sessionA 持有的 MDL 读锁互斥 | ||||
sselect * from tb limit 1;//阻塞,因为前面sessionC MDL 写锁 | update tb set b = 1 where a = 0;//阻塞,因为前面sessionC MDL 写锁 |
自增锁
auto-inc锁是一种特殊的表级锁,由插入带有自动递增列的表中的事务获取。自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。通过参数 innodb_autoinc_lock_mode 不同值控制不同行为,默认值是 1。
0: 即语句执行结束后才释放锁;
-
1:
- 普通 insert 语句,自增锁在申请之后就马上释放;
- 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
2: 所有的申请自增主键的动作都是申请后就释放锁。
特点:自增 id 不是连续递增,开发时不要以自增 id 连续做判断逻辑,原因如下:
- 唯一键冲突。申请到的自增主键弃用
- 事务回滚。申请到的自增主键弃用
行锁
InnoDB支持行锁,MyISAM 不支持。行锁可以减少并发冲突,提高并发读,这个是 InnoDB 代替 MyISAM 原因之一。
InnoDB 行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁。
加锁特点:根据隔离级别需要的时候加上,在事务提交的时候才释放,并不是不需要就立刻释放。所以,在开发时,一个事务需要锁多行(可能多表不同行),把最可能造成锁冲突的语句放在后面,减少事务之间等待时间,提高并发度。锁的是索引本身,而非记录本身。
行锁的两种形式
- 共享锁/读锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁
- 排它锁(X): 允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
SS 可并发、SX 或 XX 不能并发
显示锁定
select ... lock in share mode //共享锁(S)
select ... for update //排他锁(X)
select ... for update
加锁:将查询访问到的索引条目加上排他锁(X),注意如果没有命中索引,走全部扫描时,相当于锁全表。
特点:保证只能一个人去处理数据,其他人不能读也不能写
场景:为了让自己查到的数据确保是最新数据(当前读),并且查到后的数据只允许自己来修改。
select ... lock in share mode
加锁:将查询访问到的索引条目加上共享锁(S),其他事务针对加锁的数据不能进行 DML 操作
特点:保证大家可以一起读,但只能一个人写
场景:为了让自己查到的数据确保是最新数据(当前读),不允许其他人进行修改。(自己不一定能修改,因为其他事务可能持有 S锁)
隐式锁定
不用上面显示锁定方式,隔离级别在需要的时候自动加锁。例如:
update tb set b = b+1 where id = 1;//因为 ID 是主键索引,因此针对 id =1 这行加了行锁
对于insert、update、delete,InnoDB会自动给涉及的数据加排他锁(X);普通 select 不加共享锁和排他锁,会加 MDL锁
意向锁
意向锁的目的就是为了允许行锁和表锁共存,提升判断性能的(高效的互斥判断,加表锁时的判断)
假如没有意向锁,事务 A 给 id=1 加了排他锁,事务 B 想给该表加表排他锁时,需要判断表里面是否有行锁,就需要遍历每条记录,看看是否有行锁,如果有,就意味整个表加了表排他锁。
意向锁有 2 种,都是表锁,具体如下:
- 意向共享锁(IS): 事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁
- 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。
所以的 IX锁、IS 锁之间都不互斥,IX 锁、IS 锁只是为了与表共享锁和表排他锁互斥(不是行锁哦)。
举例说明
- 例子 1: 事务 A 准备给 id= 1 行加行排他锁,获取到该表的 IX 锁,这时事务 B 准备加表锁(例如 DDL),判断 IX 锁已经被其他事务获取,因此说明有其他事务针对某些行数据加行排他锁,因此事务B 阻塞(并不需要遍历表每行数据判断是否有行锁,所以高效)
- 例子 2:事务 A 准备给 id =1 行加行排他锁,给表加了 IX 锁,这时事务 B 准备给 id= 2行加行排他锁,给表加了 IX 锁。这说明了:IX 锁与 IX锁之间不互斥,仅仅为了表锁高效的判断,作为行锁和表锁之间的桥梁,通过意向锁来实现不同粒度(表、行)的锁之间如何做互斥判断
间隙锁(Grap Lock)
间隙锁指锁索引上的一个区间(左右开区间),为了防止读已提交隔离级别出现的幻读
临键锁(Next-Key Lock)
- next-key lock = 间隙锁(grapLock)+ 行锁(record lock),左开右闭区间,例如 (0,10]
- Innodb 使用 next-key lock 来锁定记录,例如 select .... for udpate
关于间隙锁和临键锁,请参考专门文章:幻读解决方式--mysql 间隙锁(grap lock)原理
插入意向锁(Insert Intention Locks)
对已有数据行的修改与删除,必须加互斥锁X锁,那对于数据的插入,怎么实现互斥呢?插入意向锁,孕育而生
插入意向锁是一种 grap lock,专门针对 insert 使用的。当插入时,插入的间隙已经有了 grap 锁,那么就申请插入意向锁。
特点:
- 如果插入到同一索引间隙中的多个事务没有插入到间隙中的同一位置,那么它们就不需要等待对方
- 插入意向锁不会阻止任何锁,对于插入的记录会持有一个记录锁。
例子:tb 表,其中 a 是二级索引
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 40 | 40 | 40 | NULL |
| 50 | 50 | 50 | NULL |
+----+------+------+------+
事务 A | 事务 B | |
---|---|---|
begin; select * from tb where a > 40 for update;//基于索引a加了next-key lock,(40,50] |
||
begin; insert into tb(id,a,b) values(45,45,45);//阻塞,因为命中 next-key lock |
||
commit; | //不阻塞,执行但未提交 | |
begin; insert into tb(id,a,b) values(42,42,42);//不阻塞,执行成功 |
||
insert into tb(id,a,b) values(45,45,45);//阻塞,因为与事务 B的id=45记录锁冲突 | ||
commit; | ||
//报重复主键的错,因为 事务 B 的 id=45已经插入,这时候不阻塞,但是 id=45主键重复了 |
死锁
死锁指的是不同线程都是等待对方释放资源,形成环状的资源依赖,导致几个线程进行无限等待状态。例如下面:
事务 A | 事务 B | |||
---|---|---|---|---|
T1 | begin; update tb set b = b+1 where id = 1; |
|||
T2 | begin; update tb set b=b+1 where id =2; |
|||
T3 | update tb set b= b+1 where id = 2; | |||
T4 | update tb set b = b+1 where id = 1; |
上面的死锁说明:T3时,事务 A 等待事务 B释放 id=2的锁,事务 B 在 T4 时又等待 事务 A 释放 id=1的锁。事务 A 和事务 B 在相互等待对方的资源释放,因此进入死锁状态。
死锁策略
- 等待超时:在 InnoDB 中等待 50s后,出现死锁的第一个线程超时退出,其他线程可以继续执行。由于超时时间太长,或者设置太短(假如 1s),可能会出现误伤(例如不是死锁,普通锁等待)
- 死锁检测:通过检测,主动回滚死锁链中某个事务,InnoDB 默认开启死锁检测(innodb_deadlock_detect=on)
死锁检测性能问题
每条阻塞的线程,都要判断自己的加入是否会导致死锁,时间复杂度 O(n).例如:假如有 2000 条并发线程同时更新同一行,那么死锁检测就是需要判断 2000 * 2000 = 400万次,需要消耗大量 CPU 资源。
一种思路:控制 MySQL 并发度,比如在 MySQL 端或中间件 proxy 层控制同行的更新并发度
减少死锁的主要方向
控制访问相同资源的并发事务量。