1. 死锁现象与问题定位
上周排查一个线上数据库问题时,发现一个典型的INSERT死锁场景:两个事务同时向同一张表插入数据,结果发生死锁导致事务回滚。这种死锁在并发量高的系统中并不罕见,但很多开发者对其形成机制并不清楚。本文将基于这个实际案例,拆解MySQL在并发插入时产生死锁的内在逻辑。
我们遇到的业务场景是订单分库表,采用user_id作为分片键。死锁发生时,两个不同用户的订单创建请求同时到达,事务内容都是先查询用户信息,然后向order表插入记录。看似毫无关联的两个事务,却在INSERT阶段发生死锁,错误日志显示"Deadlock found when trying to get lock"。
2. MySQL锁机制基础
2.1 InnoDB的锁类型
要理解INSERT死锁,需要先了解InnoDB的锁机制。InnoDB实现了以下几种关键锁:
- 记录锁(Record Lock):锁定索引记录
- 间隙锁(Gap Lock):锁定索引记录之间的间隙
- Next-Key Lock:记录锁+间隙锁的组合
- 插入意向锁(Insert Intention Lock):INSERT操作设置的间隙锁
在RR(可重复读)隔离级别下,InnoDB通过Next-Key Lock解决幻读问题。这对理解INSERT死锁至关重要。
2.2 唯一索引的加锁逻辑
当表存在唯一索引时,INSERT操作会经历特殊的加锁过程:
- 首先对要插入的索引位置加插入意向锁
- 检查唯一键冲突时,会短暂获取S锁(共享锁)
- 确认无冲突后,最终获取X锁(排他锁)完成插入
这个过程中,不同事务的锁请求可能形成循环等待,进而导致死锁。
3. 死锁场景深度解析
3.1 死锁现场还原
假设有订单表orders,主键为自增id,唯一索引uk_order_no(order_no)。两个并发事务执行如下:
事务A:
sql复制BEGIN;
INSERT INTO orders(order_no, user_id) VALUES('NO1001', 101);
-- 正在执行...
事务B:
sql复制BEGIN;
INSERT INTO orders(order_no, user_id) VALUES('NO1002', 102);
-- 正在执行...
这两个事务最终形成死锁。通过SHOW ENGINE INNODB STATUS查看死锁日志,关键信息如下:
code复制LATEST DETECTED DEADLOCK
...
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 111, OS thread handle 0x7f8b1c0b6700, query id 222 127.0.0.1 root update
INSERT INTO orders(order_no, user_id) VALUES('NO1001', 101)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 333 page no 3 n bits 72 index `uk_order_no` of table `test`.`orders` trx id 123456 lock_mode X insert intention waiting
...
3.2 死锁形成路径
- 事务A和事务B同时开始INSERT操作
- 两者都先获取了插入意向锁(IX锁)
- 在检查唯一约束时,都需要获取S锁
- 事务A持有IX锁等待S锁,事务B持有IX锁等待S锁
- 形成循环等待,触发死锁检测机制
3.3 关键因素分析
这种死锁需要满足几个条件:
- RR隔离级别(RC级别间隙锁行为不同)
- 存在唯一索引
- 并发插入操作
- 插入的记录在索引结构上位置相近
4. 解决方案与优化建议
4.1 事务拆分方案
将大事务拆分为小事务是最有效的预防措施:
sql复制-- 不推荐
BEGIN;
SELECT... -- 业务查询
UPDATE... -- 业务更新
INSERT... -- 可能产生死锁的操作
COMMIT;
-- 推荐方案
-- 第一阶段:查询业务数据
SELECT... -- 业务查询
-- 第二阶段:执行插入
BEGIN;
INSERT... -- 独立短事务
COMMIT;
4.2 索引设计优化
调整索引结构也能降低死锁概率:
- 避免过度使用唯一索引
- 将多列唯一索引改为普通索引+应用层校验
- 对于流水号类字段,考虑使用前缀索引
4.3 数据库参数调优
调整以下参数可减少死锁影响:
ini复制innodb_deadlock_detect = ON # 死锁检测(默认开启)
innodb_lock_wait_timeout = 50 # 锁等待超时(秒)
innodb_rollback_on_timeout = ON # 超时回滚
4.4 应用层处理
在代码中增加重试机制:
python复制max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
execute_transaction()
break
except DeadlockError:
retry_count += 1
sleep(random.uniform(0.1, 0.5)) # 随机退避
5. 监控与排查技巧
5.1 死锁日志分析
定期检查死锁日志:
sql复制SHOW ENGINE INNODB STATUS\G
重点关注:
- 涉及的事务SQL语句
- 等待的锁类型和索引
- 持有的锁资源
5.2 性能监控指标
配置监控以下指标:
- 死锁次数(Innodb_deadlocks)
- 锁等待时间(Innodb_row_lock_time_avg)
- 锁等待次数(Innodb_row_lock_waits)
5.3 现场复现方法
在测试环境复现死锁的步骤:
- 准备两个数据库会话
- 同时开启事务
- 在第一个会话执行部分SQL后暂停
- 在第二个会话执行冲突操作
- 恢复第一个会话继续执行
6. 进阶原理探讨
6.1 B+树索引与锁的关系
InnoDB的锁是加在索引记录上的。当插入新记录时:
- 定位到要插入的B+树位置
- 对前一条记录加gap lock
- 对新记录加insert intention lock
- 检查唯一约束时加S锁
这种复杂的加锁机制是死锁的根源。
6.2 不同隔离级别的影响
- RC级别:通常不会加gap lock,死锁概率降低
- RR级别:默认加gap lock和next-key lock,死锁概率高
- Serializable:加锁范围最大,死锁风险最高
6.3 批量插入的锁优化
批量插入时使用单条多值INSERT语句:
sql复制-- 差:多个单行插入
INSERT INTO t VALUES(1);
INSERT INTO t VALUES(2);
-- 优:单条多值插入
INSERT INTO t VALUES(1),(2);
后者持有锁的时间更短,减少并发冲突。