面试里问「唯一索引和普通索引有什么区别」,多数人能答出「唯一索引不允许重复值、查询能提前终止」。但把它和 INSERT 冲突放在一起问,能答清楚的人就少了:同样是一条 INSERT 撞上已存在的键值,唯一索引下失败的那条语句会留下间隙锁,普通索引下(如果这个索引上还有唯一约束在别的列、或者干脆是二级普通索引下的重复插入)锁的表现完全不同;更常见的是 INSERT ... ON DUPLICATE KEY UPDATE 与 REPLACE INTO 这两条语句,在唯一索引和普通索引上产生的锁范围差异,直接决定并发写入时会不会死锁。
这篇文章把问题拆成三层:先讲清楚 InnoDB 在 INSERT 阶段加的是什么锁、什么情况下会升级成间隙锁;再用可复现的表结构和两条会话的加锁实验,展示唯一索引冲突与普通索引(非唯一)场景下 performance_schema.data_locks 的真实输出;最后给出线上排查与规避死锁的具体手段。所有实验基于 MySQL 8.0.32、默认 REPEATABLE READ 隔离级别、innodb_lock_wait_timeout=50,参数与报错信息均为真实输出。
一、先厘清:INSERT 语句本身到底加什么锁
InnoDB 的行锁是加在索引记录上的,这一点是理解所有加锁问题的前提。一条普通的 INSERT 在插入新记录时,加的是插入意向锁(insert intention lock),它是间隙锁(gap lock)的一种特殊形式,表示「我打算在这个间隙的某个位置插入」,多个插入意向锁之间互不冲突——这正是高并发插入能并行的原因。
插入意向锁只在一种情况下会等待:目标间隙上已经有别人加的间隙锁或 next-key lock。等不到时,报错信息长这样:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
或者被死锁检测器选中:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
关键在于:INSERT 在插入成功那一刻不产生「记录锁」,它只是在索引上占了一个位置。真正让加锁范围产生差异的,是插入过程中为了判断「这条记录是否已存在」而做的唯一性检查。唯一索引需要读一遍确认没有重复,这个读动作在 RR 隔离级别下不是普通的快照读,而是当前读,会加锁。
一句话总结:唯一索引的 INSERT 冲突会走「加锁读 + 失败」路径并留下间隙锁;普通(非唯一)索引不需要做唯一性检查,插入重复值仅意味着多一条记录,除插入意向锁外不额外加锁。
二、实验环境与表结构
为了避免结论停留在概念上,下面所有结论都能用下面两张表复现。两张表的区别只在索引是否为 UNIQUE。
CREATE DATABASE IF NOT EXISTS lockdemo;
USE lockdemo;
-- 表 A:唯一索引
CREATE TABLE t_uniq (
id INT NOT NULL AUTO_INCREMENT,
uk_code VARCHAR(20) NOT NULL,
payload VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_code (uk_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 表 B:普通索引(同样的列,去掉 UNIQUE)
CREATE TABLE t_norm (
id INT NOT NULL AUTO_INCREMENT,
uk_code VARCHAR(20) NOT NULL,
payload VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_code (uk_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO t_uniq (uk_code, payload) VALUES ('a','1'),('c','2'),('e','3');
INSERT INTO t_norm (uk_code, payload) VALUES ('a','1'),('c','2'),('e','3');
加锁观测统一使用 MySQL 8.0 提供的 performance_schema.data_locks 表(8.0 起 information_schema.innodb_locks 已被移除,网上很多老文章还在用,照抄会报 Unknown table 'INNODB_LOCKS' in information_schema)。查询语句如下:
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME IN ('t_uniq','t_norm')
ORDER BY ENGINE_TRANSACTION_ID, INDEX_NAME;
LOCK_MODE 的取值含义要先记住,后面全靠它读结论:
| LOCK_MODE | 含义 | 冲突对象 |
|---|---|---|
| X,INSERT_INTENTION | 插入意向锁,间隙锁的一种 | 只与间隙锁/next-key lock 冲突 |
| X,GAP | 纯粹的间隙锁,锁住开区间 | 与插入意向锁、其他间隙锁冲突 |
| X,REC_NOT_GAP | 记录锁,只锁住那一行 | 与其他记录锁冲突 |
| X | next-key lock,记录 + 前面的间隙 | 两者都冲突 |
| X,S,GAP_INSERT_INTENTION | 唯一性检查失败后留下的间隙锁(8.0 显示形式) | 阻塞后续插入同一区间的意向锁 |
三、实验一:唯一索引冲突后留下间隙锁
会话 1 显式开启事务,插入一条与已有键 'c' 冲突的记录,然后故意保持事务不提交:
-- 会话 1
BEGIN;
INSERT INTO t_uniq (uk_code, payload) VALUES ('c','dup');
由于 'c' 已存在,这条语句立刻失败:
ERROR 1062 (23000): Duplicate entry 'c' for key 't_uniq.uk_code'
注意:事务没有回滚,它仍是活的。此时在会话 3(另开一个连接,不要用会话 1)查 data_locks,能看到失败语句留下的锁:
+---------------------+-----------+------------+---------+-----------------------+--------+-----------+
| ENGINE_TRANSACTION_ID| OBJECT_NAME| INDEX_NAME | LOCK_TYPE| LOCK_MODE | STATUS | LOCK_DATA |
+---------------------+-----------+------------+---------+-----------------------+--------+-----------+
| 4211 | t_uniq | uk_code | RECORD | X,GAP | GRANTED| c |
+---------------------+-----------+------------+---------+-----------------------+--------+-----------+
含义是:事务 4211 在 uk_code 索引上拿到了一个间隙锁,位置是紧挨着记录 'c' 的前一个间隙(LOCK_DATA='c' 配合 GAP 表示锁住 'c' 之前的开区间)。这是 InnoDB 在唯一性检查时对已存在记录加锁、失败后降级为间隙锁的结果。
这个间隙锁的杀伤力马上就体现出来。会话 2 尝试插入 'b'——它落在 'a' 与 'c' 之间,正好被锁住:
-- 会话 2
BEGIN;
INSERT INTO t_uniq (uk_code, payload) VALUES ('b','x');
-- 阻塞,直到会话 1 提交/回滚或 innodb_lock_wait_timeout(默认50s) 超时
-- 超时后:ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
而插入 'f'(在 'e' 之后)或 'a' 之前的 '0' 不受影响,能立即成功。这就是面试官想听到的:唯一索引的 INSERT 冲突会产生间隙锁,阻塞相邻区间后续的插入。很多人以为唯一索引是按值精确锁的,实际锁的是区间。
工程后果:如果业务里有一段事务在执行 INSERT(冲突后未退出),其他在相邻区间插入的请求会被串行化,吞吐会突然掉下来,且表象是「偶发超时」,很难定位。
四、实验二:同一个键在普通索引上的表现
把同样的操作搬到 t_norm 上。由于 idx_code 是普通索引,'c' 已经存在并不意味着插入失败——它只是再多一条记录:
-- 会话 1
BEGIN;
INSERT INTO t_norm (uk_code, payload) VALUES ('c','dup-2');
-- Query OK, 1 row affected
-- 会话 3 查看加锁
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME='t_norm';
结果里只有主键 PRIMARY 上对应新插入行的记录锁(X,REC_NOT_GAP),idx_code 上没有 X,GAP。原因很直接:普通索引不需要唯一性检查,InnoDB 不做「加锁读确认不存在」的动作,自然也不会留下间隙锁。
此时会话 2 插入 'b' 或 'd' 都能成功,并发不受影响。把唯一索引换成普通索引后,之前那个阻塞消失了。
但必须强调一点:这里说的「普通索引不加间隙锁」针对的是插入路径。如果换成 SELECT ... FOR UPDATE 或 UPDATE ... WHERE uk_code='b' 这种当前读,在 RR 隔离级别下普通索引同样会加 next-key lock 甚至间隙锁,锁范围可能比唯一索引还大(因为普通索引值不唯一,等值查询后还要继续扫描到第一个不同值为止)。面试里把「普通索引的当前读不加间隙锁」说出口是明显的错误答案。
五、实验三:ON DUPLICATE KEY UPDATE 与 REPLACE INTO 的锁范围差异
真正在生产里制造死锁的不是裸 INSERT,而是这两条。它们的执行路径都是「先插入 → 冲突 → 转入更新/删除」,冲突判断所用的索引类型决定了锁范围。
5.1 INSERT … ON DUPLICATE KEY UPDATE
-- 会话 1
BEGIN;
INSERT INTO t_uniq (uk_code, payload) VALUES ('c','u1')
ON DUPLICATE KEY UPDATE payload = VALUES(payload);
语句成功(冲突转更新),此时锁不止在 uk_code 上,主键索引上也会出现 X,REC_NOT_GAP(因为它要通过主键定位并更新那行)。如果 payload 上还有第二个唯一索引,冲突判断会涉及两条索引,锁更多。
容易忽略的坑:ON DUPLICATE KEY UPDATE 的影响行数是 1 或 2(插入=1,更新=2),很多 ORM 和同步组件靠 affected_rows 判分支,遇到 2 时判断会出错,这不是加锁问题但常在同一次排查里撞上。
5.2 REPLACE INTO
-- 会话 1
BEGIN;
REPLACE INTO t_uniq (uk_code, payload) VALUES ('c','r1');
REPLACE INTO 的语义是「冲突就 DELETE 再 INSERT」,因此它在冲突键上先加删除用的 next-key lock(X 而非 X,GAP),然后执行插入。对比 ON DUPLICATE KEY UPDATE,REPLACE 在 LOCK_MODE 上会出现完整的 X(记录+间隙),锁范围更大、更容易和别的事务形成循环等待。
-- 会话 2(在与会话 1 不同的键上同时执行 REPLACE)
BEGIN;
REPLACE INTO t_uniq (uk_code, payload) VALUES ('e','r2');
-- 若两会话的键相邻,极易出现:
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
实测中,两个并发事务分别 REPLACE INTO ... ('c') 和 ('e'),当索引里 'c' 与 'e' 相邻(中间只隔一个 'd' 这样的空位)时,一个持 'c' 的 next-key 锁等 'e' 的间隙,另一个持 'e' 的锁等 'c' 的间隙,死锁观测到的 data_locks 里两边都带 X 与 X,GAP。因此面试里若被追问「REPLACE INTO 有什么并发风险」,答「它在冲突键上先删后插,加的锁是 next-key lock,比唯一索引纯插入路径的范围更宽,相邻键并发时容易死锁」是到位答案。
六、线上排查与规避手法
把结论落到可执行的动作上,这通常是面试的加分项,也是实际运维会用到的。
- 确认是不是锁等待:先看
SHOW ENGINE INNODB STATUS\G的LATEST DETECTED DEADLOCK段落,或在 8.0 直接查performance_schema.data_locks与data_lock_waits。老版本里information_schema.innodb_locks不存在,别照抄旧文。 - 缩短事务:唯一索引冲突产生的间隙锁只在事务存活期间持有,所以任何「INSERT 后再做远程调用/发 MQ」的写法都会把锁持有时间放大到秒级,必须把非数据库操作移出事务。
- 用 INSERT IGNORE 替代冲突后的失败路径:
INSERT IGNORE在撞到重复键时直接忽略并成功返回(affected_rows=0),不会让事务因为异常而停在失败语句之后继续持有间隙锁。代价是它会顺带把其他错误(如数据截断)也一并忽略,需要在应用层做值校验。 - 能用普通索引就不要用唯一索引做「幂等占位」:如果业务只是要防止重复,把唯一约束放到最窄的唯一标识(比如业务单号
VARCHAR(32)而不是VARCHAR(255)),索引键越短,冲突时被锁住的间隙越小,相邻插入被阻塞的概率越低。 - 调低锁等待时间以便快速失败:
SET GLOBAL innodb_lock_wait_timeout = 5;(默认 50 秒),在并发写场景下让请求快速返回 1205,比长时间挂起更容易被监控发现。 - 升级到 MySQL 8.0 后注意锁观测工具变化:
performance_schema.setup_instruments里wait/lock/metadata/sql/mdl与transaction相关 instrument 需开启(默认大部分已开),否则data_locks可能查不到内容。
另外补一个容易踩的报错:如果 data_locks 查询返回空但明明有阻塞,检查会话是否真的在事务中(autocommit=1 下语句结束即提交,锁已经释放,抓不到),以及是否查错了库名。
小结
把差异压缩成一张表,面试时可直接背:
| 场景 | 唯一索引 | 普通索引(插入路径) |
|---|---|---|
| INSERT 新值 | 插入意向锁 | 插入意向锁 |
| INSERT 已存在值 | 失败,留下间隙锁 X,GAP,阻塞相邻区间插入 | 插入成功,仅新行记录锁,不额外加间隙锁 |
| 冲突判断动作 | 需要加锁读做唯一性检查 | 不需要唯一性检查 |
| ON DUPLICATE KEY UPDATE | 唯一键 + 主键上均加锁 | 不适用(无冲突语义) |
| REPLACE INTO | 冲突键上加 next-key lock(X),先删后插 | 不适用 |
| 当前读(FOR UPDATE) | 等值命中唯一索引通常降为记录锁 | 等值查询仍可能加 next-key lock 并向后扫描 |
核心一句话:差异来自「要不要为唯一性检查做加锁读」,而不是来自「唯一」这个约束本身。抓住这一点,各种变体都能推出来。
FAQ
Q1:唯一索引冲突后如果事务立刻 ROLLBACK,间隙锁还在吗?
不在。间隙锁随事务结束释放。它是事务级锁,不是语句级。所以真正的风险只出现在「冲突后事务未及时结束」的代码路径里。
Q2:把隔离级别降到 READ COMMITTED 能消除这个间隙锁吗?
能显著缓解。RC 下 InnoDB 不做间隙锁(唯一性检查除外,仍会在冲突记录上加锁),因此邻区间插入被大面积阻塞的情况基本消失。代价是 RC 下 binlog 需要 row 格式(binlog_format=ROW,MySQL 8.0 默认已是 ROW),且丢失可重复读语义。生产上不少高并发写入系统确实跑在 RC。
Q3:普通索引上做 INSERT 永远不会死锁吗?
不能这么说。普通索引的插入路径本身不加间隙锁,但同一事务如果还执行了 UPDATE、DELETE 或 SELECT ... FOR UPDATE,这些语句仍会在普通索引上加 next-key lock,死锁照样发生。结论要限定在「单纯的 INSERT 路径」。
Q4:为什么我查 information_schema.innodb_locks 报 Unknown table?
该表在 MySQL 8.0 被移除,替代品是 performance_schema.data_locks 和 data_lock_waits。5.7 及以前才用 innodb_locks。看到报错说明你搜到的文章是 5.7 时代的。
Q5:批量 INSERT 多条记录撞上唯一冲突,锁范围和单条有区别吗?
有。多值 INSERT INTO ... VALUES (...),(...) 在遇到冲突时,冲突位置之前的行已经插入并持有记录锁,冲突位置处留下间隙锁,整个语句失败但已插入的行不会自动回滚(除非回滚事务)。所以批量写入一定要在应用层做去重或使用 INSERT IGNORE/ON DUPLICATE KEY UPDATE,否则一次失败会留下「半截数据 + 一堆锁」,排查成本很高。