前言
在学习seata时,解决悬挂问题,使用select for update语句进行当前读,插入事务状态语句过程中,出现死锁异常:
Deadlock found when trying to get lock; try restarting transaction
于是出现如下疑问:
insert语句为什么会出现死锁?
本片文章主要分析:
- 锁的基本概念
- 常见语句加锁类型
- 两种死锁产生原因(间隙锁插入意向锁导致死锁、主键冲突导致死锁)
1.锁的基本概念
本次锁分析都是基于innodb数据存储引擎。
1.1 行级锁
共享锁:S锁,允许事务读一行数据。SELECT LOCK IN SHARE MODE
排他锁:X锁,允许事务删除或更新一行数据。SELECT FOR UPDATE | UPDATE | DELETE
||X|S| |-|-|-| |X|不兼容|不兼容| |S|不兼容|兼容| X锁与任何的锁都不兼容,而S锁仅和S锁兼容。
注意:行锁实际上是索引记录锁,对索引记录的锁定。即使表没有建立索引,InnoDB也会创建一个隐藏的聚簇索引,并使用此索引进行记录锁定。
1.2 意向锁
意向锁定是表级锁定,标识事务稍后对表中的行做哪种类型的锁定(共享或独占)
意向共享锁(IS):事务想要获得一张表中某几行的共享锁
意向排他锁(IX):事务想要获得一张表中某几行的排他锁
意图锁遵循如下协议:
在事务获取表中某行的共享锁之前,它必须首先在表上获取IS锁或更强的锁。
在事务获取表中某行的独占锁之前,它必须首先在表上获取IX锁。
注意:意向锁只会阻塞表级别的锁(如LOCK TABLES请求的表锁),并不会阻塞行级锁(如行级X锁)。
1.3 行锁分类
锁名称 | 描述 |
---|---|
Record Lock | 单行记录锁 |
Gap Lock | 间隙锁,锁定一个范围,但不包含记录本身 |
Next-Key Lock | 锁定单行记录以及记录前一个间隙 |
1.4 插入意向锁(Insert Intention Lock)
插入意图锁是在行插入之前通过INSERT操作设置的一种特殊间隙锁。
注意:多个事务插入同一个间隙的不同位置,他们并不会冲突。 假设存在索引记录,其值分别为4和7。单独的事务分别尝试插入值5和6,在获得插入行的排他锁之前,每个事务都使用插入意图锁来锁定4和7之间的间隙, 但他们不会互相阻塞。
同样,不同事务请求同一个间隙的Gap锁并不会阻塞,但如果一个事务请求了Gap锁,另一个事务再请求插入意向锁,则会阻塞。
1.5 间隙锁意义
mysql查询分为快照读和当前读。
快照读(select)通过mvcc解决幻读问题。下图事务1在事务2插入语句前,进行了一次快照读,事务2插入语句提交后,事务1再次进行快照读,同样不能查询到事务2刚插入的记录。
事务1执行当前读,就可以查询到事务2 刚插入的语句。
当前读(select for update)通过间隙锁解决幻读问题。间隙锁通过阻塞插入意向锁解决幻读。
在上面例子的基础上(事务1已经进行当前读,还未完成事务),我们开启事务3,执行插入语句,事务3被事务1的当前读阻塞。
2.常见语句加锁类型
2.1 insert
- 加上表级意向排他锁。
- 检测主键冲突,如果存在冲突,则获取共享锁S进行当前读。
- 冲突检测通过,判断插入数据位置是否有间隙锁,有就等待间隙锁释放。
- 无间隙锁,获取插入意向锁,插入数据。
2.2 update
- 如果修改数据在表里存在,并且where语句存在唯一索引,加行级记录锁,锁定一行。
- 如果修改数据在表里存在,并且where语句不存在唯一索引,对记录加锁,并且对where语句前后的间隙枷锁。
- 如果修改数据在表里存在,并且where语句没有索引,全表加锁。
- 如果数据在表里不存在,where语句存在索引,对命中间隙加锁。
- 如果数据在表里不存在,where语句没有索引,全表加锁。
2.3 delete
和update一样,取决于where语句。
2.4 select
和update一样,取决于where语句。
3.死锁
mysql8可以通过以下语句查询锁状态:
select ENGINE_TRANSACTION_ID,OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_STATUS,LOCK_DATA from performance_schema.data_locks;
3.1 间隙锁和插入意向锁造成的死锁
3.1.1 最终目的
事务1开启当前读,查询age=26的数据是否存在,不存在就插入。
事务2开启当前读,查询age=27的数据是否存在,不存在就插入。
3.1.1 开启事务1和事务2
创表语句:
CREATE TABLE `test1`.`user` (
`id` bigint(0) UNSIGNED NOT NULL AUTO_INCREMENT,
`user_name` varchar(16) NOT NULL,
`age` int(0) NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_user_age`(`age`) USING BTREE
);
user表初始数据如下:
3.1.2 事务1和事务2都执行快照读
事务1执行:
select id, user_name, age from user where age = 26 for update;
事务2执行:
select id, user_name, age from user where age = 27 for update;
来看下两个事务都执行快照读后的锁状态:
事务1(事务编号1863)先获取到表级意向排他锁IX,然后对age=26的数据进行当前读,因为表中没有age=26的数据,所以对age>25之后的数据都加上间隙锁。事务2同理。
3.1.3 事务1执行插入
事务1执行:
insert into user(user_name, age) values('lisi', 26);
因为事务2已经获得age>25的间隙锁,事务1在执行insert时,只能等待事务2释放间隙锁。
可以看到,在对age索引加锁的同时,也对主键索引进行了加锁,防止主键冲突。
3.1.4 事务2执行插入
事务2执行:
insert into user(user_name, age) values('wangwu', 27);
事务2在执行insert语句时,需要等待事务1释放age>25的间隙锁,导致事务2等待。
这时mysql检测到死锁出现,立即回滚事务2。事务1获取到age>25的间隙锁,执行完成insert。
事务2回滚后事务状态如下:
3.1.5 死锁日志
通过以下语句查询mysql最近一次死锁日志:
show engine innodb status;
是否有事务1和事务2都能同时获得排他锁的疑问?
因为InnoDB中的间隙锁的唯一目的是防止其他事务插入间隙。间隙锁是可以共存的,一个事务占用的间隙锁不会阻止另一个事务获取同一个间隙上的间隙锁。
3.2 主键冲突导致死锁
3.2.1 最终目的
事务1插入id=5的数据。
事务2插入id=5的数据。
3.2.2 准备阶段
开始3个事务。
3.2.3 事务3执行insert id=5
事务3获取到插入意向锁,完成插入,事务3还未提交。
3.2.4 事务1和事务2执行insert id=5
事务3获得id=5的行级排他锁(REC_NOT_GAP表示非间隙锁),
事务1和事务2此时由于主键冲突,需要先拿到id=5的共享锁S执行当前读,此时事务3获得id=5的排他锁X,事务1和事务2不能获取到共享锁S,只能等待事务3释放排他锁X。
3.2.5 回滚事务3 出现死锁
由于事务3回滚id=5的排他锁X,事务1和事务2都拿到id=5的共享锁S查询是否存在id=5的数据。
事务3回滚,id=5的数据不存在,事务1和事务2都准备获取查询意向锁插入id=5的数据,由于插入意向锁是排他锁,和共享锁互斥,事务1只能等待事务2释放共享锁S,事务2只能等待事务1释放共享锁S,产生死锁。