参考:
小林coding: https://xiaolincoding.com/mysql/lock/deadlock.html
出世&入世:https://blog.csdn.net/weixin_54828627/article/details/129379140
死锁的四个必要条件:
- 互斥
- 占有且等待
- 不可强占用
- 循环等待
一、批量更新/删除数据-同时加锁记录多
InnoDB是边扫描边加锁的。
因为是边扫描边加锁,这里就存在一个顺序问题,假如线程A对a b c d e五条数据边扫描边加X锁,而同时线程B对 e f g h a五条数据也边扫描边加X锁,明显的,这就会存在一个问题,在线程A对e加锁时,线程B已经对e加锁了,所以线程A会等待线程B释放锁,而线程B对a加锁时,线程A也对a加锁了,所以线程B就会等待线程A释放锁,最终结果是,互相循环等待造成死锁。
场景一:
TIP
网上很多资料说,update 没加锁索引会加表锁,这是不对的。
Innodb 源码里面在扫描记录的时候,都是针对索引项这个单位去加锁的, update 不带索引就是全表扫扫描,也就是表里的索引项都加锁(会对所有记录加上 next-key 锁(记录锁 + 间隙锁)),相当于锁了整张表,所以大家误以为加了表锁。
这是我遇到的场景:
有一个会话排队的队列表 queue,与这个案例有关的主要的字段有:
- social_queue_id : 排队 id,主键
- session_id:会话 id,一个会话一般只有一条记录(所以严格不算批量更新,但是全表扫描到的记录多)。但是该字段上没加索引
- queue_id:这次排队所在的队列
在关闭会话时,会把这个会话的排队信息从 queue 表移入 queue_his,也就是说先插入 queue_his ,再从 queue 删除
- delete queue where session_id = 2789
- delete queue where session_id = 2795
- delete queue where session_id = 2777
- ......
当多个会话同时关闭,都需要从 queue 移入 queue_his 时,就会同时执行上面那条 sql。由于是 session_id 上没有索引,那么就会全表扫描(扫描主键索引),对所有记录加上 next-key lock,两个事务同时对全表边扫描边加锁,就会发生同时持有部分数据的锁,同时等待对方那部分数据的锁。
解决方法:
- session_id 上建立二级索引
- 根据 session_id 查出所有满足条件的主键:social_queue_id ,再根据主键删除
场景二:
这也是我遇到的场景:
有一个客服与客户的聊天消息表 :message 表,与这个案例有关的主要的字段有:
- message_id:消息 id ,主键
- session_id:会话 id,一个会话中有多条消息,即一个 session_id 对应多个 message_id。session_id 上有二级索引
- msg_state:消息状态,客服已读这个会话的时候,会根据 session_id 更改这个会话下所有未读消息的 msg_state
在客服关闭会话的时候,会把这个会话下所有的消息移入 消息历史表,也就是说会从 message 表删除,再插入 message_his 表
而关闭会话的时候,在移入历史表之前,会给客户发送关闭会话的提示语,并将提示语插入 message 表。客服点进这个会话后(如果这个客服已经保持在这个聊天窗口,收到消息后就会立马调用),会调用一个接口根据 session_id 更改这个会话下所有消息的 msg_state
- update message set msg_state = 'B' where session_id = 1078;
- delete message where session_id = 1078;
上面这两个操作同一个会话的 sql 是两个线程,也是两个事务,但是是几乎同时执行的。
那么就会发生上面说的,两个事务边扫描,边加锁,对扫描到符合条件的 session_id 二级索引行加 next-key lock(X锁及间隙加GAP锁),然后再根据二级索引里的主键信息去扫描聚簇索引对主键行依次加X锁;
两个事务都持有对方要加锁数据的部分锁,相互等待,发生了死锁。
二、先间隙锁再向间隙插入
场景一:
两事务同时持有同一个间隙的间隙锁
- Time1 阶段:由于 id=25 这条记录不存在,所以事务 A 在主键索引(INDEX_NAME : PRIMARY)上加的是间隙锁,锁范围是
(20, 30)
。 - Time2 阶段:由于 id=36 这条记录不存在,所以事务 B 在主键索引(INDEX_NAME : PRIMARY)上加的也是是间隙锁,锁范围是
(20, 30)
。
事务 A 和 事务 B 的间隙锁范围都是一样的,为什么不会冲突?
对于同一个间隙,两个事务的间隙锁和间隙锁之间是相互兼容的。
在MySQL官网上还有一段非常关键的描述:
Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from Inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.
间隙锁的意义只在于阻止区间被插入,因此是可以共存的。一个事务获取的间隙锁不会阻止另一个事务获取同一个间隙范围的间隙锁,共享(S型)和排他(X型)的间隙锁是没有区别的,他们相互不冲突,且功能相同。
两事务再同时向这个间隙插入数据
- Time3 阶段:事务 A 的状态为等待状态(LOCK_STATUS: WAITING),因为被事务 B 生成的间隙锁(范围
(20, 30)
)阻塞,所以事务 A 的插入操作生成了一个插入意向锁(LOCK_MODE:INSERT_INTENTION
)。 - Time4 阶段:事务 B 的状态为等待状态(LOCK_STATUS: WAITING),因为被事务 A 生成的间隙锁(范围
(20, 30)
)阻塞,所以事务 B 的插入操作生成了一个插入意向锁(LOCK_MODE:INSERT_INTENTION
)。
Insert 语句是怎么加锁的?
Insert 语句在正常执行时是不会生成锁结构的,它是靠聚簇索引记录自带的 trx_id 隐藏列来作为隐式锁来保护记录的。隐式锁是指,当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。
隐式锁就是在 Insert 过程中不加锁,只有在特殊情况下,才会将隐式锁转换为显示锁,这里我们列举两个场景。
- 如果要插入的间隙被别的事务加了间隙锁,为了避免幻读,此时是不能插入记录的;会生成一个插入意向锁,并阻塞等待间隙锁释放。
- 如果 Insert 的记录和已有记录存在唯一键冲突,此时也不能插入记录;插入就会失败,然后对于这条记录加上了 S 型的锁。
- 如果主键索引重复,插入新记录的事务会给已存在的主键值重复的聚簇索引记录添加 S 型记录锁。
- 如果唯一二级索引重复,插入新记录的事务都会给已存在的二级索引列值重复的二级索引记录添加 S 型 next-key 锁。
插入意向锁是什么?
插入意向锁的生成时机:
- 每插入一条新记录,都需要看一下待插入的间隙是否已经被别的事务加上了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁),现象就是 Insert 语句会被阻塞。
在MySQL的官方文档中有以下重要描述:
An Insert intention lock is a type of gap lock set by Insert operations prior to row Insertion. This lock signals the intent to Insert in such a way that multiple transactions Inserting into the same index gap need not wait for each other if they are not Inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to Insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with Insert intention locks prior to obtaining the exclusive lock on the Inserted row, but do not block each other because the rows are nonconflicting.
场景二:
业务主要逻辑就是新增订单、修改订单、查询订单等操作。然后因为订单是不能重复的,所以当时在新增订单的时候做了幂等性校验,做法就是在新增订单记录之前,先通过 select ... for update
语句查询订单是否存在,如果不存在才插入订单记录。
假设这时有两事务,一个事务要插入订单 1007 ,另外一个事务要插入订单 1008,因为需要对订单做幂等性校验,所以两个事务先要查询该订单是否存在,不存在才插入记录,过程如上。
因为 select...for update 和 update 一样,也是当前读&加独占锁(这里也是因为记录不存在退化为间隙锁,间隙锁之间是兼容的)。所以发生死锁的原因和场景一一样,就不继续描述了。