1. 问题现象与背景
上周排查一个线上MySQL死锁问题时,发现了一个很有意思的现象:两个简单的INSERT语句竟然引发了死锁。这让我意识到,很多开发者对MySQL锁机制的理解可能还停留在"读锁共享、写锁互斥"的层面,实际上InnoDB的锁机制要复杂得多。
这个案例发生在电商平台的订单分表场景中。我们按用户ID哈希分成了16个订单表,每个表的主键是自增ID,同时有用户ID和订单时间的联合索引。死锁发生时,两个并发的INSERT操作试图在同一张分表插入不同用户的数据,理论上应该互不影响,但最终却形成了死锁。
2. MySQL锁机制基础回顾
2.1 InnoDB的锁类型
在分析具体案例前,我们需要明确InnoDB的几种关键锁类型:
- 记录锁(Record Lock):锁定索引记录
- 间隙锁(Gap Lock):锁定索引记录之间的间隙
- 临键锁(Next-Key Lock):记录锁+间隙锁的组合
- 插入意向锁(Insert Intention Lock):INSERT操作特有的间隙锁
2.2 不同隔离级别下的锁行为
锁的行为会随着隔离级别的变化而变化:
- READ UNCOMMITTED:不加锁
- READ COMMITTED:只加记录锁
- REPEATABLE READ(默认):加临键锁
- SERIALIZABLE:所有查询自动加共享锁
我们的案例发生在默认的REPEATABLE READ隔离级别下。
3. 死锁场景还原与分析
3.1 表结构与索引情况
假设订单分表结构如下:
sql复制CREATE TABLE `order_1` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`order_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_time` (`user_id`,`order_time`)
) ENGINE=InnoDB;
3.2 死锁发生的SQL序列
事务1:
sql复制BEGIN;
INSERT INTO order_1(user_id, order_time) VALUES(1001, '2023-06-01 10:00:00');
事务2:
sql复制BEGIN;
INSERT INTO order_1(user_id, order_time) VALUES(1002, '2023-06-01 10:00:01');
3.3 锁等待关系
通过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 order_1(user_id, order_time) VALUES(1001, '2023-06-01 10:00:00')
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 333 page no 3 n bits 72 index `PRIMARY` of table `test`.`order_1` trx id 123456 lock_mode X insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 112, OS thread handle 0x7f8b1c0b6800, query id 223 127.0.0.1 root update
INSERT INTO order_1(user_id, order_time) VALUES(1002, '2023-06-01 10:00:01')
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 333 page no 3 n bits 72 index `PRIMARY` of table `test`.`order_1` trx id 123457 lock_mode X insert intention waiting
*** WE ROLL BACK TRANSACTION (2)
3.4 死锁原因解析
这个死锁的核心在于自增主键的锁竞争和间隙锁的相互作用:
- 两个事务都尝试获取主键索引上的插入意向锁
- InnoDB在分配自增ID时需要锁定自增计数器
- 由于两个事务几乎同时执行,形成了互相等待:
- 事务1持有自增计数器的锁,等待获取主键索引上的插入意向锁
- 事务2持有主键索引上的某个间隙锁,等待获取自增计数器的锁
4. 解决方案与优化建议
4.1 短期解决方案
对于已经出现的死锁问题,可以采取以下临时措施:
- 重试机制:应用层捕获死锁异常后自动重试
- 降低隔离级别:改为READ COMMITTED(需评估业务影响)
- 调整innodb_autoinc_lock_mode参数:
- 0:传统模式(语句级锁)
- 1:连续模式(默认值)
- 2:交错模式(最高并发)
4.2 长期优化方案
从架构层面避免此类问题:
- 避免热点表:进一步拆分订单表,或考虑使用UUID等非自增主键
- 使用批量插入:合并多个INSERT为单个批量INSERT
- 应用层ID生成:改用雪花算法等分布式ID生成方案
- 监控与预警:建立死锁监控机制,及时发现并处理
5. 深入理解INSERT加锁过程
5.1 自增主键的锁机制
InnoDB处理自增主键时,会先获取一个特殊的AUTO-INC锁。这个锁的行为受innodb_autoinc_lock_mode参数控制:
- 模式0:语句执行期间一直持有锁
- 模式1:批量插入时特殊处理(默认)
- 模式2:获取后立即释放
5.2 插入意向锁的特性
插入意向锁是一种特殊的间隙锁,具有以下特点:
- 多个事务可以在同一间隙上申请不冲突的插入意向锁
- 插入意向锁会等待已有的间隙锁释放
- 不会阻止其他事务获取插入意向锁
5.3 不同索引的锁影响
在我们的案例中,虽然用户查询主要使用idx_user_time索引,但死锁却发生在主键索引上。这说明:
- 即使不直接使用主键索引,INSERT操作仍会修改主键索引
- 二级索引的更新也会间接影响主键索引的锁情况
- 表上有多少索引,INSERT就可能需要维护多少索引
6. 实战排查技巧
6.1 死锁日志分析要点
查看死锁日志时重点关注:
- 涉及的事务和SQL语句
- 每个事务持有的锁类型和等待的锁类型
- 锁所在的索引和记录
6.2 常用诊断命令
SHOW ENGINE INNODB STATUS:查看最新死锁信息SELECT * FROM performance_schema.events_statements_history:查看历史SQLSELECT * FROM sys.innodb_lock_waits:查看当前锁等待
6.3 模拟复现方法
要复现类似死锁,可以:
- 在两个会话中分别BEGIN事务
- 使用
SELECT ... FOR UPDATE人为制造锁冲突 - 然后执行INSERT语句
- 通过sleep控制时序
7. 预防死锁的最佳实践
7.1 应用层设计
- 事务粒度:尽量减小事务范围和持续时间
- 访问顺序:统一对多个表的访问顺序
- 重试机制:实现幂等的重试逻辑
7.2 数据库配置
- 锁超时:设置合理的
innodb_lock_wait_timeout - 死锁检测:确保
innodb_deadlock_detect为ON - 并发控制:合理设置
innodb_thread_concurrency
7.3 监控指标
需要监控的关键指标包括:
- 死锁次数(
innodb_deadlocks) - 锁等待时间(
innodb_row_lock_time_avg) - 等待事务数(
innodb_row_lock_waits)
8. 类似场景扩展
这种INSERT死锁不仅出现在自增主键场景,还可能发生在以下情况:
- 唯一键冲突:两个事务尝试插入相同的唯一键值
- 外键约束:父表和子表的并发插入
- 空间索引:GIS数据插入时的特殊锁行为
- 全文索引:涉及倒排索引更新的并发问题
在实际工作中,我们需要根据具体业务场景分析锁冲突的根本原因,不能简单套用通用解决方案。理解InnoDB的锁机制原理,结合业务特点设计合理的数据库访问模式,才能从根本上减少死锁发生。