mysql 在REPEATEABLE-READ隔离级别下,如果两个线程同时对相同条件记录用SELECT...ROR UPDATE加排他锁,在没有符合该记录情况下,两个线程都会加锁成功。程序发现记录尚不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。这种情况下,将隔离级别改成READ COMMITTED,就可以避免问题。
解释以上发生死锁的原因 及解决死锁的原理?
1 死锁的原因
InnoDB间隙锁(Gap Lock)的一个核心特性:间隙锁之间是不互斥的,允许多个事务在同一间隙上持有锁。
你描述的完整流程,会形成一个"双方各自持有了对方需要的间隙锁,然后又试图获取对方的间隙锁"的经典死锁循环。我们分步拆解一下。
🔍 问题根源:RR隔离级别下的间隙锁与死锁
在 REPEATABLE-READ(RR)隔离级别下,为了彻底防止幻读,InnoDB引入了间隙锁机制。当 SELECT ... FOR UPDATE 查询一条不存在的记录时,它不会空手而归,而是会锁定这条记录"本应存在"的区间。
-
第一步:查询并加间隙锁
- 事务T1执行
SELECT ... WHERE id = 5 FOR UPDATE,假设id=5的记录不存在。T1会在id=5所属的间隙(例如(3, 8)区间)上加上间隙锁(Gap Lock)。 - 事务T2执行相同的查询。因为间隙锁可以共存 ,它不会阻塞T2,T2也会成功地在同一个间隙
(3, 8)上加上间隙锁。
- 事务T1执行
-
第二步:尝试插入并死锁
- 现在T1尝试
INSERT INTO ... VALUES (5)。在插入前,它需要申请一个与间隙锁互斥的插入意向锁(Insert Intention Lock)。 - 由于间隙
(3,8)上已有T2的间隙锁,T1的插入意向锁请求会被阻塞,T1进入等待T2的状态。 - 同样,T2尝试插入时,其插入意向锁也会被T1的间隙锁阻塞,T2进入等待T1的状态。
- 至此,死锁(Deadlock) 形成,系统将回滚其中一个事务以解除僵局。
- 现在T1尝试
这个经典死锁场景在MySQL官方Bug库中也被多次记录和讨论。
2 为什么 READ-COMMITTED (RC) 能解决问题?
将隔离级别降为 READ COMMITTED 能从根本上避免此问题,因为它完全改变了加锁策略:
- 禁用间隙锁 :在RC隔离级别下,InnoDB不使用间隙锁来防止幻读(除了极少数涉及唯一索引检查的特殊情况)。
- 仅锁定存在的行 :
SELECT ... FOR UPDATE只会对实际存在 并符合条件的记录加上行锁(Record Lock)。对于不存在的记录,它不会锁定任何间隙。
因此,在你描述的场景中,T1和T2的SELECT ... FOR UPDATE都不会获得任何锁,更不会产生间隙锁的冲突,死锁的循环链条就从源头上被打破了。
💎 总结:死锁如何形成?
下表清晰对比了两种隔离级别下的行为差异:
| 隔离级别 | SELECT ... FOR UPDATE (记录不存在时) | 能否避免死锁 | 原因 |
|---|---|---|---|
| REPEATABLE READ (RR) | 加间隙锁 (Gap Lock) | 否 | 间隙锁不互斥,允许多个事务同时持有,但它们都与插入意向锁互斥,从而形成等待环路导致死锁。 |
| READ COMMITTED (RC) | 不加锁 | 是 | 仅对存在的行加记录锁,从根本上杜绝了针对不存在记录的间隙锁冲突。 |
对于这种SELECT ... FOR UPDATE后再INSERT的业务模式(通常称为get_or_create),一个更推荐的解决方案是直接使用INSERT ... ON DUPLICATE KEY UPDATE语句,这通常能在一条SQL内原子化地完成操作,既保证了并发安全,也无需在应用层编写复杂的加锁逻辑。
另外,由于间隙锁是针对索引记录的,如果你查询的列上没有建立索引,SELECT ... FOR UPDATE可能会锁定更大的范围甚至退化成表锁,因此为查询列建立合适的索引也十分重要。
你遇到的这个问题在并发编程中确实很典型,要复现它需要一个完美的时机。如果想亲自体验一下,我也可以为你提供具体的代码示例和验证步骤,帮你更直观地理解这个过程~