唯一索引更新 NULL 值时加两把锁,是因为 WHERE business_no = NULL 无法走索引等值查找,InnoDB 按“记录不存在”逻辑处理:先在唯一索引上加间隙锁(GAP 锁),再回查主键确认行存在与否,从而额外对主键加锁;根本原因是 NULL 破坏唯一索引的定位确定性,导致加锁从精准记录锁退化为不可预测的范围锁。

唯一索引更新 NULL 值时为什么加两把锁?
因为 InnoDB 在唯一索引上执行 UPDATE 时,无论 WHERE 条件是否命中行,都会先定位索引项——而 NULL 值在 B+ 树中不参与排序比较(NULL <= NULL 无定义),导致优化器无法直接跳转到确定位置,必须扫描索引范围。一旦 WHERE 条件含唯一索引列且值为 NULL,InnoDB 实际按「等值查询但记录不存在」逻辑处理:加 GAP 锁而非记录锁,同时仍需回查主键确认是否存在对应行,从而触发对主键的额外加锁尝试。
WHERE business_no = NULL 不走唯一索引?
是的,WHERE business_no = NULL 永远不会命中唯一索引的等值查找路径。MySQL 规定 NULL = NULL 为 UNKNOWN,所以该条件被优化器判定为「无法使用索引进行精确查找」,退化为全索引扫描或全表扫描(取决于统计信息)。此时加锁范围扩大,可能锁住整个索引区间,甚至触发 Next-Key Lock,显著增加死锁概率。
- 正确写法应为
WHERE business_no IS NULL,这才可能利用唯一索引的IS NULL优化路径(仅限单列唯一索引) - 联合唯一索引中任意一列是
NULL,则整组值无法被高效定位,IS NULL也大概率失效 - ORM 如 Laravel Eloquent 默认生成
= NULL,需显式改用->whereNull('business_no')
NULL 值更新引发死锁的真实链路
典型死锁场景不是「两个事务同时更新 NULL 行」,而是:一个事务用唯一索引列 IS NULL 更新,另一个事务用主键 id 更新同一行。前者先锁唯一索引段(因 NULL 定位模糊,锁范围大),再试图锁主键;后者先锁主键,再试图锁唯一索引——双方持有一把锁、等待另一把,形成循环等待。
- 现象:8 次死锁中
UPDATE ... WHERE business_no = 'ABC'全部失败,不是因为 ABC 是 NULL,而是因为 ABC 对应行的business_no列允许 NULL,导致索引结构中该值附近存在大量NULL预留间隙,放大了锁竞争面 - 验证方式:查
INFORMATION_SCHEMA.STATISTICS确认该列是否为NULLABLE,再用SELECT * FROM performance_schema.data_locks观察 LOCK_DATA 是否包含NULL区间 - 根本原因不在 NULL 本身,而在「允许 NULL 的唯一索引」破坏了等值查询的确定性,使加锁行为从「精准记录锁」退化为「不可预测的范围锁」
如何避免 NULL 引发的锁异常?
最稳妥的做法不是绕开 NULL,而是从建表和查询两端切断不确定性:
- 建表时,对业务上「必须有值」的唯一字段,显式声明
NOT NULL(哪怕默认填空字符串) - 查询时,永远用
IS NULL/IS NOT NULL替代= NULL,并确认执行计划中type是ref或const,而非index - 高并发写场景下,若业务逻辑真需区分「未设置」和「明确为空」,建议拆成两个字段:一个
NOT NULL唯一业务键 + 一个可空状态标记 - 不要依赖 ORM 自动生成的
= NULL,所有涉及唯一索引列的 WHERE 条件,手动校验生成的 SQL
NULL 本身不是问题,问题在于它让唯一索引失去了「唯一性可预测」这个前提——而 InnoDB 的锁机制,恰恰建立在这个前提之上。


















