MySQL锁详解及案例分析
- 一、一条update语句
- 二、MySQL锁介绍
- 三、全局锁
- 全局锁演示
- 1.环境准备
- 2.全局锁演示
- 四、MySQL表级锁(都是Server层实现)
- 1、表级锁介绍
- 2、表读S、写锁X
- 1)表锁相关命令
- 2)表锁演示
- 1、表级的共享锁(读锁)
- 2、表级的排他锁
- 3、元数据锁
- 1)元数据锁介绍
- 2)元数据锁演示
- 4、自增锁(AUTO-INC Locks)
- 五、MySQL行级锁(粒度细)
- 1、行级锁介绍
- 2、 记录锁(Record Locks)
- 共享记录锁演示
- 排他记录锁演示
- 3、间隙锁(Gap Locks)
- 4、临键锁(Next-Key Locks)
- 案例演示
- 5、意向锁 (Intention Locks)(表级锁)
- 1)意向锁介绍
- 2)意向锁的作用
- 3)意向锁和共享锁、排他锁的兼容关系
- 6、行锁加锁规则
- 7、插入意向锁(Insert Intention Locks)
- 8、锁相关参数
- 六、行锁分析实战
- 1)组合一:id主键+RC
- 分析
- 结论
- 2)组合二:id唯一索引+RC
- 分析
- 结论
- 3)组合三:id非唯一索引+RC
- 分析
- 结论:
- 4)组合四:id无索引+RC
- 分析
- 结论:
- 5)组合五:id主键+RR
- 6)组合六:id唯一索引+RR
- 7)组合七:id非唯一索引+RR
- 分析
- 结论:
- 8)组合八:id无索引+RR
- 分析
- 结论:
- 9)组合九:Serializable
- 分析
- 结论:
- 七、一条复杂SQL的加锁分析
- 分析
- 结论:
- 八、死锁原理与分析
- 结论:
- 如何避免死锁呢?
一、一条update语句
二、MySQL锁介绍
按照锁的粒度来说,MySQL主要包含三种类型(级别)的锁定机制:
全局锁
:锁的是整个database。由MySQL的SQL layer层实现的
表级锁
:锁的是某个table。由MySQL的SQL layer层实现的
行级锁
:锁的是某行数据,也可能锁定行之间的间隙。由某些存储引擎实现,比如InnoDB。按照锁的功能来说分为:共享锁和排他锁。
共享锁Shared Locks(S锁):
1、兼容性:加了S锁的记录,允许其他事务再加S锁,不允许其他事务再加X锁
2、加锁方式:select…lock in share mode排他锁Exclusive Locks(X锁):
1、兼容性:加了X锁的记录,不允许其他事务再加S锁或者X锁
2、加锁方式:select…for update
三、全局锁
全局锁就对整个数据库实例加锁,加锁后整个实例就处于只读状态,后续的MDL的写语句,DDL语句, 已经更新操作的事务提交语句都将被阻塞。其典型的使用场景是做全库的逻辑备份,对所有的表进行锁 定,从而获取一致性视图,保证数据的完整性。
加全局锁的命令为:
flush tables with read lock
释放全局锁的命令为:
unlock tables;
解锁方式二
查看锁住的进程
如果MySQL数据库出现锁住的情况,可以使用以下命令查看锁住的进程:
SHOW FULL PROCESSLIST;
该命令将会显示当前所有的数据库连接及其状态,包括正在执行的SQL语句、执行时间等信息。通过查看这些信息,可以找到锁住的进程及其相关信息。
杀死锁住的进程
找到锁住的进程后,可以使用以下命令来杀死该进程:
KILL;
其中,进程ID是SHOW FULL PROCESSLIST命令中显示的进程ID。通过杀死锁住的进程,可以解除MySQL数据库的锁住状态。
全局锁演示
1.环境准备
CREATE TABLE mylock ( id int(11) NOT NULL AUTO_INCREMENT, NAME varchar(20) DEFAULT NULL, PRIMARY KEY (id) ); INSERT INTO mylock (id,NAME) VALUES (1, 'a'); INSERT INTO mylock (id,NAME) VALUES (2, 'b');INSERT INTO mylock (id,NAME) VALUES (3, 'c');INSERT INTO mylock (id,NAME) VALUES (4, 'd');
2.全局锁演示
回话1 | 回话2 |
---|---|
1.对数据库加锁 | 2.新建回话执行查询操作 |
无 | 3.执行删除语句 |
4.释放锁 | 5.再次执行删除语句 |
四、MySQL表级锁(都是Server层实现)
1、表级锁介绍
MySQL的表级锁有四种:
1、表读、写锁。
2、元数据锁(meta data lock,MDL)。
3、意向锁 Intention Locks(InnoDB)
4、自增锁(AUTO-INC Locks)
2、表读S、写锁X
1)表锁相关命令
- MySQL 实现的表级锁定的争用状态变量:
show status like 'table%';
- table_locks_immediate:产生表级锁定的次数;
- table_locks_waited:出现表级锁定争用而发生等待的次数
表锁有两种表现形式
表共享读锁(Table Read Lock)
表独占写锁(Table Write Lock)手动增加表锁
lock table 表名称 read(write),表名称2 read(write),其他;
1、表级的共享锁
lock table 表名 read;
可以共享读操作,不能做些操作。
2、表级的排他锁
lock table 表名 write;查看表锁情况
show open tables;
删除表锁:
unlock tables;
2)表锁演示
1、表级的共享锁(读锁)
可以共享读,不能做写操作
一旦给这个表加上共享锁之后,其他的会话也是可以给这个表加上共享锁;共享锁时可以共存的,你可以加锁,我也可以加锁。因为是共享读锁,加锁之后,可以读表,当前事务是不能够进行写操作,其他事务也不能进行写操作,需要读锁都释放才可以。
lock table 表名 read;
回话1 | 回话2 |
---|---|
1.添加共享锁 | 无 |
2.查询数据 | 无 |
3.新增数据:提示已有读锁,不能更新 | 4.新建回话,添加锁 |
无 | 5.查询数据 |
无 | 6.新增数据:提示已有读锁,不能更新 |
无 | 7.释放读锁 |
无 | 8.再次执行新增数据,一直处于等待状态,因为回话一中有读锁 |
9.回话一释放锁 | 10.回话二新增数据:成功 |
2、表级的排他锁
一个回话/事务当中给表添加了排他锁,当前回话可以对表做读写操作;其他回话读表做操作时会处于阻塞状态,需等待到加锁的事务中进行锁释放。
lock table 表名 write;
对表处于一个独享状态。
写锁演示
回话1 | 回话2 |
---|---|
1.回话一中给mylock表添加write锁 | 无 |
2.当前回话对锁定的表的查询+更新+新增操作都可以 | |
3.回话二进行查询(修改/新增)时被阻塞,需要等待锁的释放 | |
4.释放锁 | 5.回话二获得到锁查询成功 |
3、元数据锁
1)元数据锁介绍
在一个事务中对一个表进行查询操作,不允许其他回话对表结构进行修改,就在表上加元数据锁。
MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了 一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。
因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL读锁;当要对表做结构变更操作的时候,加 MDL 写锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
2)元数据锁演示
1、session1: begin;--开启事务 select * from mylock;--加MDL读锁 2、session2: alter table mylock add f int; -- 修改阻塞 3、session1:commit; --提交事务或者rollback 释放读锁 4、session2:Query OK, 0 rows affected (38.67 sec) --修改完成 Records: 0Duplicates: 0Warnings: 0
回话1 | 回话2 |
---|---|
1、session1: begin;–开启事务 | 无 |
1.1 select * from mylock; – 加MDL读锁 | 2、session2: alter table mylock add age int; – 修改阻塞 |
3、session1:commit; – 提交事务或 rollback 释放读锁 | 4.执行成功 |
4、自增锁(AUTO-INC Locks)
AUTO-INC锁是一种特殊的表级锁,发生涉及AUTO_INCREMENT列的事务性插入操作时产生。
五、MySQL行级锁(粒度细)
要求必须使用InnoDB引擎
1、行级锁介绍
MySQL的行级锁,是由存储引擎来实现的,这里我们主要讲解InnoDB的行级锁。
InnoDB行锁是通过给索引上的索引项加锁来实现的,因此InnoDB这种行锁实现特点意味着:只有通过索引条件检索的数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁 !
InnoDB的行级锁,按照锁定范围来说,分为四种:
记录锁(Record Locks):
锁定索引中一条记录。
间隙锁(Gap Locks):
要么锁住索引记录中间的值,要么锁住第一个索引记录前面的值或者最后一个索引记录后面的值。
临键锁(Next-Key Locks):
是索引记录上的记录锁和在索引记录之前的间隙锁的组合(间隙锁+记录锁)。
插入意向锁(Insert Intention Locks):
做insert操作时添加的对记录id的锁。InnoDB的行级锁,按照功能来说,分为两种:
共享锁(S):
允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
排他锁(X):
允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁
对于UPDATE
、DELETE
和INSERT
语句,InnoDB
会自动给涉及数据集加排他锁(X);对于普通SELECT
语句,InnoDB
不会加任何锁,事务可以通过以下语句显示给记录集加共享锁或排他锁。
手动添加共享锁(S):
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
手动添加排他锁(x):
SELECT * FROM table_name WHERE ... FOR UPDATE
2、 记录锁(Record Locks)
根据主键等值更新时使用记录锁。
(1)记录锁, 仅仅锁住索引记录的一行,在单条索引记录上加锁。
(2)record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。
所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。
-- 共享记录锁select * from table where id = 1 lock in share mode; -- 排他记录锁select * from tablewhere id = 1 for udpate;
共享记录锁演示
对某一条记录加共享记录锁,其他的回话也可以对这条记录加上共享记录锁,也可以对这条记录进行读操作;但是不能进行写,也不能对这条记录加排它锁(会处于阻塞状态)。
演示时我们开启了事务,方便看效果。没有开启事务的话这条语句执行之后锁会自动释放,就看不见效果了。
回话一
-- 1.开启事务BEGIN;-- 2.查询操作SELECT * FROM `mylock`;-- 3.给id=2的记录添加共享记录锁select * from mylock where id = 2 lock in share mode; -- 4.本地回话可以进行写操作UPDATE mylock SET name='李四' WHERE id=2-- 4.1.查询结果,看见更新成功SELECT * FROM `mylock`;-- 11.再次查询结果,看见查询的数据和步骤5一致,--而不是回话2中修改后的,这是正确的保证一个事务中数据不可重复读SELECT * FROM `mylock`;-- 12提交事务COMMIT;-- 13 再次查询,数据最新的SELECT * FROM `mylock`;
回话二
-- 回话2-- 5.查询id=2数据成功select * from mylock where id = 2-- 6.加记录共享锁成功select * from mylock where id = 2 lock in share mode;-- 7.加记录排他锁阻塞select * from mylock where id = 2 for update; -- 8.更新id=2的数据阻塞UPDATE mylock set name='李武' WHERE id =2; -- 9.更新其他记录成功UPDATE mylock set name='李白' WHERE id =3; -- 10.查询记录,由于回话一中并没有提交id=2的修改记录,查询到的仍是旧值SELECT * FROM mylock;
回话1 | 回话2 |
---|---|
BEGIN; – 1.开启事务 | |
SELECT * FROM mylock ; – 2.查询操作 | |
select * from mylock where id = 2 lock in share mode; – 3.给id=2的记录添加共享记录锁 | |
UPDATE mylock SET name=‘李四’ WHERE id=2 – 4.本地回话可以进行写操作 | |
SELECT * FROM mylock ;– 4.1.查询结果,看见更新成功 | select * from mylock where id = 2 – 5.查询id=2数据成功 |
无 | select * from mylock where id = 2 lock in share mode; – 6.加记录共享锁成功 |
无 | select * from mylock where id = 2 for update; – 7.加记录排他锁失败 |
无 | UPDATE mylock set name=‘李武’ WHERE id =2; – 8.更新id=2的数据失败 |
无 | UPDATE mylock set name=‘李白’ WHERE id =3; – 9.更新其他记录成功 |
无 | SELECT * FROM mylock; – 10.查询记录,由于回话一中并没有提交id=2的修改记录,查询到的仍是旧值 |
SELECT * FROM mylock ; – 11.再次查询结果,看见查询的数据和步骤4.1一致,而不是回话2中修改后的,这是正确的保证一个事务中数据不可重复读 | 无 |
COMMIT;– 12提交事务 SELECT * FROM mylock ; – 13 再次查询发现结果已是最新 |
排他记录锁演示
对某一条记录添加了排它锁,同一回话中可以写操作。在其他回话中可以读这个条记录,但是不能进行写,不能对这条记录加排它锁或者是共享锁(会处于阻塞状态)
回话一
-- 1.开启事务BEGIN;-- 2.查询操作SELECT * FROM `mylock`;-- 3.给id=2的记录添加排它锁select * from mylock where id = 2 for UPDATE; -- 4.id=2的记录更新操作成功UPDATE mylock SET name='张丹' WHERE id=2;-- 9 提交事务,当前排它锁释放COMMIT;
回话二
-- 5.加共享锁处于阻塞select * from mylock where id = 2lock in share mode;-- 6.加排它锁锁处于阻塞select * from mylock where id = 2 for UPDATE; -- 7.更新id=2记录处于阻塞UPDATE mylock SET name='白冰' WHERE id=2;-- 8.查询id=2的记录成功SELECT * FROM `mylock` WHERE id=2;
回话1 | 回话2 |
---|---|
– 1.开启事务 BEGIN;– 2.查询操作 SELECT * FROM mylock ; | |
– 3.给id=2的记录添加排它锁 select * from mylock where id = 2 for UPDATE; | |
– 4.id=2的记录更新操作成功 UPDATE mylock SET name=‘张丹’ WHERE id=2; | |
无 | – 5.加共享锁处于阻塞select * from mylock where id = 2 lock in share mode; |
无 | – 6.加排它锁锁处于阻塞select * from mylock where id = 2 for UPDATE; |
无 | – 7.更新id=2记录处于阻塞UPDATE mylock SET name=‘白冰’ WHERE id=2; |
无 | – 8.查询id=2的记录成功SELECT * FROM mylock WHERE id=2; |
– 9 提交事务,当前排它锁释放 COMMIT; |
3、间隙锁(Gap Locks)
在记录和记录之间的范围区间就是间隙,间隙锁就是加区间之上的,防止插入数据。目的就是防止幻读。
(1)区间锁, 仅仅锁住一个索引区间(开区间,不包括双端端点)。
(2)在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,并不包括该索引记录 本身。
(3)间隙锁可用于防止幻读,保证索引间的不会被插入数据
session1: begin; select * from t1_simple where id > 4 for update; --------------------------------------------------------- session2: insert into t1_simple values (7,100); --阻塞 insert into t1_simple values (3,100); --成功
4、临键锁(Next-Key Locks)
就是记录锁+间隙锁
是一个左开右闭的区间。
(1,3],(3,5]…
在InnoDB中默认使用临键锁,当满足某些条件时,临键锁会退化。
(1)record lock + gap lock
, 左开右闭区间,例如(5,8]。
(2)默认情况下,innodb
使用next-key locks
来锁定记录。select … for update
(3)但当查询的索引含有唯一属性
的时候,Next-Key Lock
会进行优化,将其降级为Record Lock
,即仅锁住索引本身,不是范围。
(4)Next-Key Lock
在不同的场景中会退化:
案例演示
CREATE TABLE `t1_simple`( `id` int(11) NOT NULL, `pubtime` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE, INDEX `idx_pu`(`pubtime`) USING BTREE ) ENGINE = InnoDB; INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (1, 10);INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (3, 3);INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (5, 100);INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (10, 1);INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (20, 5);INSERT INTO `t1_simple`(`id`, `pubtime`) VALUES (100, 20);
(1) 排他记录锁:使用主键索引精确匹配(=),且记录存在。
session1执行:
begin; -- 临键锁区间(10,20],(20,100]select * from t1_simple where id = 20 for update;
session2执行:
-- 16和25在临键锁区间(10,20],(20,100]内,因为有临建锁会阻塞insert into t1_simple values (16, 19); --阻塞 insert into t1_simple values (25, 50); --阻塞 -- 退化成排它锁,在其他回话中不能对这条记录加排它锁select * from t1_simple where id = 20 for update; --阻塞insert into t1_simple values (4, 101); --成功
(2)间隙锁: 使用主键索引精确匹配(=),且记录不存在。
session1执行:
BEGIN;-- 6在(5,10)之间,锁为间隙锁SELECT * FROM t1_simple WHERE id =6 FOR UPDATE;
session2执行:
--因为回话一给(5,10)添加了间隙锁。回话二中插入id=6数据时会阻塞INSERT INTO `t1_simple` VALUES (6, 33);--成功INSERT INTO `t1_simple` VALUES (28, 32);
(3)临键锁: 使用主键索引精确匹配()
加了临建锁,在范围值内,插入数据会阻塞。
session1执行:
BEGIN;-- 添加临键锁SELECT * FROM `t1_simple` WHERE id<=5 FOR UPDATE;
session2执行:
INSERT INTO `t1_simple` VALUES (2, 33); -- 阻塞INSERT INTO `t1_simple` VALUES (5, 92); -- 阻塞INSERT INTO `t1_simple` VALUES (18, 72); -- 成功
5、意向锁 (Intention Locks)(表级锁)
意向锁IX、IS,当数据库中某条记录上有行锁时,需要在表上增加一个标志位,代表当前表中某行记录上有行锁。
通俗的来说:
假如某行记录上有共享锁(S)那么表上标志位会变成意向共享锁(IS)
假如某行记录上有排它锁(X)那么表上标志位会变成意向排它锁(IX)
假如表中不同行记录上有共享锁(S)和排它锁(X)那么表上标志位会变成意向共享锁(IS)和意向排它锁(IX)
总结:
标记当前表中是否有行锁的存在。
行锁:S 意向锁:IS
行锁:X 意向锁:IX
1)意向锁介绍
InnoDB也实现了表级锁,也就是意向锁
,意向锁是mysql内部使用的,不需要用户干预。意向锁和行锁可以共存,意向锁的主要作用是为了【全表更新数据】时的性能提升。否则在全表更新数据时,需要先检索该范是否某些记录上面有行锁。
- 表明“某个事务正在某些行持有了锁、或该事务准备去持有锁”
- 意向锁的存在是为了协调行锁和表锁的关系,支持多粒度(表锁与行锁)的锁并存。
- 例子:事务A修改user表的记录r,会给记录r上一把行级的排他锁(X),同时会给user表上一把意向排他锁(IX),这时事务B要给user表上一个表级的排他锁就会被阻塞。意向锁通过这种方式实现了行锁和表锁共存且满足事务隔离性的要求。
- 1)意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
2)意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁
2)意向锁的作用
当我们需要给表加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁);
(1)如果意向锁是行锁,则需要遍历每一行数据去确认;
(2)如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。
3)意向锁和共享锁、排他锁的兼容关系
下图表示意向锁和表共享锁、表排他锁的兼容关系
意向锁相互兼容,因为IX、IS只是表明申请更低层次级别元素(比如 page、记录)的X、S操作。
因为上了表级S锁后,不允许其他事务再加X锁,所以表级S锁和X、IX锁不兼容。
上了表级X锁后,会修改数据,所以表级X锁和 IS、IX、S、X(即使是行排他锁,因为表级锁定的行肯定包括行级锁定的行,所以表级X和IX、行级X)不兼容。
注意: 上了行级X锁后,行级X锁不会因为有别的事务上了IX而堵塞,一个mysql是允许多个行级X锁同时存在的,只要他们不是针对相同的数据行。
6、行锁加锁规则
1)主键索引
等值查询
(1)命中记录,加记录锁。
(2)未命中记录,加间隙锁。范围查询
(1)没有命中任何一条记录时,加间隙锁。
(2)命中1条或者多条,包含where条件的临键区间,加临键锁
2)辅助索引
等值查询
(1)命中记录,命中记录的辅助索引项+主键索引项加记录锁,辅助索引项两侧加间隙锁。
(2)未命中记录,加间隙锁范围查询
(1)没有命中任何一条记录时,加间隙锁。
(2)命中1条或者多条,包含where条件的临键区间加临键锁。命中记录的id索引项加记录锁。
7、插入意向锁(Insert Intention Locks)
在开启事务之后插入数据之前需要开启插入意向锁(把id锁上),防止其他的回话也插入相同的记录。
(1)插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。
(2)在多事务同时写入不同数据至同一索引间隙的时候,并不需要等待其他事务完成,不会发生锁等待。
(3)假设有一个记录索引包含键值4和7,不同的事务分别插入5和6,每个事务都会产生一个加在4-7之
间的插入意向锁,获取在插入行上的排它锁,但是不会被互相锁住,因为数据行并不冲突。
(4)插入意向锁不会阻止任何锁,对于插入的记录会持有一个记录锁。
8、锁相关参数
Innodb所使用的行级锁定争用状态查看:
show status like 'innodb_row_lock%';
Innodb_row_lock_current_waits
:当前正在等待锁定的数量;Innodb_row_lock_time
:从系统启动到现在锁定总时间长度;Innodb_row_lock_time_avg
:每次等待所花平均时间;Innodb_row_lock_time_max
:从系统启动到现在等待最常的一次所花的时间;Innodb_row_lock_waits
:系统启动后到现在总共等待的次数;
对于这5个状态变量,比较重要的主要是:
Innodb_row_lock_time_avg
(等待平均时长)Innodb_row_lock_waits
(等待总次数)Innodb_row_lock_time
(等待总时长)这三项。
尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手指定优化计划。
查看事务、锁的sql:
select * from information_schema.innodb_locks;select * from information_schema.innodb_lock_waits;select * from information_schema.innodb_trx;
六、行锁分析实战
在介绍完一些背景知识之后,接下来将选择几个有代表性的例子,来详细分析MySQL的加锁处理。当然,还是从最简单的例子说起。经常有朋友发给我一个SQL,然后问我,这个SQL加什么锁?就如同下面两条简单的SQL,他们加什么锁?
- SQL1:
select * from t1 where id = 10;
- SQL2:
delete from t1 where id = 10;
针对这个问题,该怎么回答?能想象到的一个答案是:
- SQL1:不加锁。因为MySQL是使用多版本并发控制的,读不加锁。
- SQL2:对id = 10的记录加写锁 (走主键索引)。
这个答案对吗?说不上来。即可能是正确的,也有可能是错误的,已知条件不足,这个问题没有答案。
必须还要知道以下的一些前提,前提不同,能给出的答案也就不同。要回答这个问题,还缺少哪些前提条件?
前提一: id列是不是主键?
前提二:当前系统的隔离级别是什么?
前提三:id列如果不是主键,那么id列上有索引吗?
前提四:id列上如果有二级索引,那么这个索引是唯一索引吗?
前提五:两个SQL的执行计划是什么?索引扫描?全表扫描?
没有这些前提,直接就给定一条SQL,然后问这个SQL会加什么锁,都是很业余的表现。而当这些问题有了明确的答案之后,给定的SQL会加什么锁,也就一目了然。下面,我们将这些问题的答案进行组合,然后按照从易到难的顺序,逐个分析每种组合下,对应的SQL会加哪些锁?
注:下面的这些组合,需要做一个前提假设,也就是有索引时,执行计划一定会选择使用索引进行过滤 (索引扫描)。但实际情况会复杂很多,真正的执行计划,还是需要根据MySQL输出的为准。
组合一:id列是主键,RC隔离级别组合二:id列是二级唯一索引,RC隔离级别组合三:id列是二级非唯一索引,RC隔离级别组合四:id列上没有索引,RC隔离级别组合五:id列是主键,RR隔离级别组合六:id列是二级唯一索引,RR隔离级别组合七:id列是二级非唯一索引,RR隔离级别组合八:id列上没有索引,RR隔离级别组合九:Serializable隔离级别
排列组合还没有列举完全,但是看起来,已经很多了。真的有必要这么复杂吗?事实上,要分析加锁,就是需要这么复杂。但是从另一个角度来说,只要你选定了一种组合,SQL需要加哪些锁,其实也就确定了。接下来,就让我们来逐个分析这9种组合下的SQL加锁策略。
注:在前面八种组合下,也就是RC,RR隔离级别下SQL1:select操作均不加锁,采用的是快照读,因此在下面的讨论中就忽略了,主要讨论SQL2:delete操作的加锁。
1)组合一:id主键+RC
分析
这个组合,是最简单,最容易分析的组合。id是主键
,Read Committed隔离级别
,给定SQL:delete from t1 where id = 10
; 只需要将主键上id = 10的记录加上X锁即可。如下图所示:
结论
id是主键时,此SQL只需要在id=10这条记录上加X锁即可。
2)组合二:id唯一索引+RC
分析
这个组合,id不是主键,而是一个Unique的二级索引键值
。那么在RC隔离级别
下,delete from t1 where id = 10;
需要加什么锁呢?见下图:
此组合中,id是unique索引
,而主键是name列
。此时,加锁的情况由于组合一有所不同。由于id是 unique索引
,因此delete语句
会选择走id列的索引
进行where条件的过滤
,在找到id=10
的记录后:
首先 会将unique索引
上的id=10索引
记录加上X锁,同时,会根据读取到的name列
,回主键索引(聚簇索引);
然后将聚簇索引上的name = ‘d’
对应的主键索引项加X锁。
为什么聚簇索引上的记录也要加锁?
试想一下,如果并发的一个SQL,是通过主键索引来更新:update t1 set id = 100 where name = ‘d’;
此时,如果delete语句
没有将主键索引
上的记录加锁,那么并发的update
就会感知不到delete语句
的存在,违背了同一记录上的更新/删除需要串行执行的约束。
结论
若id列是unique列,其上有unique索引。那么SQL需要加两个X锁,一个对应于id unique索引上的id = 10的记录,另一把锁对应于聚簇索引上的【name=’d’,id=10】的记录。
3)组合三:id非唯一索引+RC
分析
相对于组合一、二,组合三又发生了变化,隔离级别仍旧是RC不变,但是id列上的约束又降低了,id列不再唯一,只有一个普通的索引。假设delete from t1 where id = 10;
语句,仍旧选择id列上的索引进行
过滤where条件,那么此时会持有哪些锁?同样见下图:
根据此图,可以看到,首先,id列索引上,满足id = 10查询条件的记录,均已加锁。同时,这些记录对
应的主键索引上的记录也都加上了锁。与组合二唯一的区别在于,组合二最多只有一个满足等值查询的
记录,而组合三会将所有满足查询条件的记录都加锁。
结论:
若id列上有非唯一索引,那么对应的所有满足SQL查询条件的记录,都会被加锁。同时,这些记录在主键
索引上的记录,也会被加锁。
4)组合四:id无索引+RC
分析
相对于前面三个组合,这是一个比较特殊的情况。id列上没有索引,where id = 10; 这个过滤条件,没法通过索引进行过滤,那么只能走全表扫描做过滤。
对应于这个组合,SQL会加什么锁?或者是换句话说,全表扫描时,会加什么锁?
这个答案也有很多: 有人说会在表上加X锁;有人说会将聚簇索引上,选择出来的id = 10;的记录加上X锁。
那么实际情况呢? 请看下图:
由于id列上没有索引,因此只能走聚簇索引,进行全部扫描。从图中可以看到,满足删除条件的记录有 两条,但是,聚簇索引上所有的记录,都被加上了X锁。无论记录是否满足条件,全部被加上X锁。既不 是加表锁,也不是在满足条件的记录上加行锁。
有人可能会问?为什么不是只在满足条件的记录上加锁呢?
这是由于MySQL的实现决定的。如果一个条 件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行 过滤。因此也就把所有的记录,都锁上了。
注:在实际的实现中,MySQL有一些改进,在MySQL Server过滤条件,发现不满足后,会调用 unlock_row方法,把不满足条件的记录放锁 (违背了2PL的约束)。这样做,保证了最后只会持有满 足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。
结论:
若id列上没有索引
,SQL会走聚簇索引
的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁
。但是,为了效率考量,MySQL做了优化
,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放 锁动作不会省略。同时,优化也违背了2PL的约束。
5)组合五:id主键+RR
上面的四个组合,都是在Read Committed隔离级别
下的加锁行为,接下来的四个组合,是在 Repeatable Read隔离级别
下的加锁行为。
组合五,id列是主键列
,Repeatable Read隔离级别
,针对delete from t1 where id = 10;
这条SQL,加锁与组合一:[id主键,Read Committed] 一致。
6)组合六:id唯一索引+RR
与组合五类似,组合六的加锁,与组合二:[id唯一索引,Read Committed] 一致。两个X锁,id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个。
7)组合七:id非唯一索引+RR
分析
还记得前面提到的MySQL的四种隔离级别的区别吗?RC隔离级别允许幻读,而RR隔离级别,不允许存在幻读。但是在组合五、组合六中,加锁行为又是与RC下的加锁行为完全一致。那么RR隔离级别下,如 何防止幻读呢?问题的答案,就在组合七中揭晓。
组合七,Repeatable Read隔离级别
,id上有一个非唯一索引
,执行delete from t1 where id = 10;
假设选择id列上的索引进行条件过滤,最后的加锁行为,是怎么样的呢?同样看下面这幅图:
此图,相对于组合三:[id列上非唯一锁,Read Committed]
看似相同,其实却有很大的区别。最大的区别在于,这幅图中多了一个GAP锁,而且GAP锁看起来也不是加在记录上的,倒像是加载两条记录之间 的位置,GAP锁有何用?
其实这个多出来的GAP锁,就是RR隔离级别,相对于RC隔离级别,不会出现幻读的关键
。确实,GAP锁锁住的位置,也不是记录本身,而是两条记录之间的GAP。所谓幻读,就是同一个事务,连续做两次当 前读 (例如:select * from t1 where id = 10 for update;
),那么这两次当前读返回的是完全相同的记录 (记录数量一致,记录本身也一致),第二次的当前读,不会比第一次返回更多的记录 (幻象)。
如何保证两次当前读返回一致的记录,那就需要在第一次当前读与第二次当前读之间,其他的事务不会 插入新的满足条件的记录并提交。为了实现这个功能,GAP锁应运而生。
如图中所示,有哪些位置可以插入新的满足条件的项 (id = 10),考虑到B+树索引的有序性,满足条件的项一定是连续存放的。记录[6,c]之前,不会插入id=10的记录;[6,c]与[10,b]间可以插入[10, aa];[10,b] 与[10,d]间,可以插入新的[10,bb],[10,c]等;[10,d]与[11,f]间可以插入满足条件的[10,e],[10,z]等;而 [11,f]之后也不会插入满足条件的记录。因此,为了保证[6,c]与[10,b]间,[10,b]与[10,d]间,[10,d]与 [11,f]不会插入新的满足条件的记录,MySQL选择了用GAP锁,将这三个GAP给锁起来。
Insert操作,如insert [10,aa],首先会定位到[6,c]与[10,b]间,然后在插入前,会检查这个GAP是否已经 被锁上,如果被锁上,则Insert不能插入记录。因此,通过第一遍的当前读,不仅将满足条件的记录锁 上 (X锁),与组合三类似。同时还是增加3把GAP锁,将可能插入满足条件记录的3个GAP给锁上,保证后 续的Insert不能插入新的id=10的记录,也就杜绝了同一事务的第二次当前读,出现幻象的情况。
有心的朋友看到这儿,可以会问:既然防止幻读,需要靠GAP锁的保护,为什么组合五、组合六,也是 RR隔离级别,却不需要加GAP锁呢?
首先,这是一个好问题。其次,回答这个问题,也很简单。GAP锁的目的,是为了防止同一事务的两次 当前读,出现幻读的情况。而组合五,id是主键;组合六,id是unique键,都能够保证唯一性。一个等值查询,最多只能返回一条记录,而且新的相同取值的记录,一定不会在新插入进来,因此也就避免了 GAP锁的使用。其实,针对此问题,还有一个更深入的问题:如果组合五、组合六下,针对SQL:select * from t1 where id = 10 for update;
第一次查询,没有找到满足查询条件的记录,那么GAP锁是否还能 够省略?此问题留给大家思考。
结论:
Repeatable Read隔离级别
下,id列上有一个非唯一索引
,对应SQL:delete from t1 where id = 10;
首先,通过id索引定位到第一条满足查询条件的记录,加记录上的X锁
,加GAP上的GAP锁
,然后加主键聚簇索引
上的记录X锁
,然后返回;然后读取下一条,重复进行。直至进行到第一条不满足条件的记录[11,f],此时,不需要加记录X锁
,但是仍旧需要加GAP锁
,最后返回结束。
8)组合八:id无索引+RR
分析
组合八,Repeatable Read隔离级别
下的最后一种情况,id列上没有索引
。此时SQL:delete from t1 where id = 10;
没有其他的路径可以选择,只能进行全表扫描。最终的加锁情况,如下图所示:
如图,这是一个很恐怖的现象。首先,聚簇索引
上的所有记录,都被加上了X锁
。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁
。这个示例表,只有6条记录,一共需要6个记录锁,7个GAP 锁。试想,如果表上有1000万条记录呢?
在这种情况下,这个表上,除了不加锁的快照度,其他任何加锁的并发SQL,均不能执行,不能更新, 不能删除,不能插入,全表被锁死。
当然,跟组合四:[id无索引, Read Committed]
类似,这个情况下,MySQL也做了一些优化,就是所谓的semi-consistent read
。semi-consistent read开启
的情况下,对于不满足查询条件的记录,MySQL 会提前放锁。针对上面的这个用例,就是除了记录[d,10],[g,10]之外,所有的记录锁都会被释放,同时 不加GAP锁。semi-consistent read
如何触发:要么是read committed隔离级别
;要么是Repeatable Read隔离级别
,同时设置了 innodb_locks_unsafe_for_binlog
参数。
结论:
在Repeatable Read隔离级别
下,如果进行全表扫描的当前读,那么会锁上表中的所有记录,同时会锁上聚簇索引内的所有GAP,杜绝所有的并发 更新/删除/插入 操作。当然,也可以通过触发semi- consistent read
,来缓解加锁开销与并发影响,但是semi-consistent read
本身也会带来其他问 题,不建议使用。
9)组合九:Serializable
分析
针对前面提到的简单的SQL,最后一个情况:Serializable隔离级别
。对于SQL2来说,Serializable隔离
级别与Repeatable Read隔离级别
完全一致,因此不做介绍。
delete from t1 where id = 10
Serializable隔离级别,影响的是SQL1这条SQL
select * from t1 where id = 10
在RC,RR隔离级别
下,都是快照读,不加锁。但是在Serializable隔离级别
,SQL1会加读锁,也就是说 快照读不复存在,MVCC并发控制降级为Lock-Based CC。
结论:
在MySQL/InnoDB
中,所谓的读不加锁,并不适用于所有的情况,而是隔离级别相关的。Serializable 隔离级别
,读不加锁就不再成立,所有的读操作,都是当前读。
七、一条复杂SQL的加锁分析
分析
写到这里,其实MySQL的加锁实现也已经介绍的八八九九。只要将本文上面的分析思路,大部分的 SQL,都能分析出其会加哪些锁。而这里,再来看一个稍微复杂点的SQL,用于说明MySQL加锁的另外 一个逻辑。SQL用例如下:
如图中的SQL,会加什么锁?假定在Repeatable Read隔离级别下 (Read Committed隔离级别下的加锁 情况,留给学生们分析),同时,假设SQL走的是idx_t1_pu索引。
在详细分析这条SQL的加锁情况前,还需要有一个知识储备,那就是一个SQL中的where条件如何拆分? 在这里,我直接给出分析后的结果:
Index key:
pubtime > 1 and puptime < 20。此条件,用于确定SQL在idx_t1_pu索引上的查询范围。Index Filter:
userid = ‘hdc’ 。此条件,可以在idx_t1_pu索引
上进行过滤,但不属于Index Key
。Table Filter:
comment is not NULL。此条件,在idx_t1_pu索引
上无法过滤,只能在聚簇索引上过滤。
在分析出SQL where条件的构成之后,再来看看这条SQL的加锁情况 (RR隔离级别),如下图所示:
从图中可以看出,在Repeatable Read隔离级别下,由Index Key所确定的范围,被加上了GAP锁; Index Filter锁给定的条件 **(userid = ‘hdc’)**何时过滤,视MySQL的版本而定,在MySQL 5.6版本之前,不 支持Index Condition Pushdown(ICP),因此Index Filter在MySQL Server层过滤,在5.6后支持了Index Condition Pushdown,则在index上过滤。若不支持ICP,不满足Index Filter的记录,也需要加上记录X 锁;若支持ICP,则不满足Index Filter的记录,无需加记录X锁 (图中,用红色箭头标出的X锁,是否要加,视是否支持ICP而定);而Table Filter对应的过滤条件,则在聚簇索引中读取后,在MySQL Server层 面过滤,因此聚簇索引上也需要X锁。最后,选取出了一条满足条件的记录[8,hdc,d,5,good],但是加锁的数量,要远远大于满足条件的记录数量。
结论:
在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件。
- Index Key确定的范围,需要加上GAP锁;
- Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记 录,不加X锁,否则需要X锁;
- Table Filter过滤条件,无论是否满足,都需要加X锁。
八、死锁原理与分析
本文前面的部分,基本上已经涵盖了MySQL/InnoDB所有的加锁规则。深入理解MySQL如何加锁,有两 个比较重要的作用:
- 可以根据MySQL的加锁规则,写出不会发生死锁的SQL;
- 可以根据MySQL的加锁规则,定位出线上产生死锁的原因;
下面,来看看两个死锁的例子 (一个是两个Session的两条SQL产生死锁;另一个是两个Session的一条 SQL,产生死锁):
上面的两个死锁用例。第一个非常好理解,也是最常见的死锁,每个事务执行两条SQL,分别持有了一 把锁,然后加另一把锁,产生死锁。
第二个用例,虽然每个Session都只有一条语句,仍旧会产生死锁。要分析这个死锁,首先必须用到本文 前面提到的MySQL加锁的规则。针对Session 1,从name索引出发,读到的[hdc, 1],[hdc, 6]均满足条 件,不仅会加name索引上的记录X锁,而且会加聚簇索引上的记录X锁,加锁顺序为先[1,hdc,100],后 [6,hdc,10]。而Session 2,从pubtime索引出发,[10,6],[100,1]均满足过滤条件,同样也会加聚簇索引 上的记录X锁,加锁顺序为[6,hdc,10],后[1,hdc,100]。发现没有,跟Session 1的加锁顺序正好相反, 如果两个Session恰好都持有了第一把锁,请求加第二把锁,死锁就发生了。
结论:
死锁的发生与否,并不在于事务中有多少条SQL语句,【死锁的关键在于】:两个(或以上)的 Session【加锁的顺序】不一致。而使用本文上面提到的,分析MySQL每条SQL语句的加锁规则,分析出 每条语句的加锁顺序,然后检查多个并发SQL间是否存在以相反的顺序加锁的情况,就可以分析出各种潜 在的死锁情况,也可以分析出线上死锁发生的原因。
如何避免死锁呢?
MySQL默认会主动探知死锁,并回滚某一个影响最小的事务。等另一事务执行完成之后,再重新执行该事务。
如何避免死锁
1、注意程序的逻辑
根本的原因是程序逻辑的顺序,最常见的是交差更新
Transaction 1: 更新表A -> 更新表B
Transaction 2: 更新表B -> 更新表A
Transaction获得两个资源
2、保持事务的轻量
越是轻量的事务,占有越少的锁资源,这样发生死锁的几率就越小
3、提高运行的速度
避免使用子查询,尽量使用主键等等
4、尽量快提交事务,减少持有锁的时间
越早提交事务,锁就越早释放