十万个why:两个线程更新完全不同的两条商品记录,为什么 MySQL 还是会死锁?
大家好,我是小富~
《十万个why》系列持续更新中,这个系列都是我面试过程中接触过的问题,也是我之前工作踩过的坑。
在写业务代码时,我们通常有这样一个直觉:只要两个线程操作的是完全不同的行,它们在数据库里就不会互相干扰,更不可能发生死锁。
但线上一些奇怪的死锁,往往会打破这个直觉:线程 A 在更新商品 101,线程 B 在更新商品 102,商品 ID 井水不犯河水,但 MySQL 却判定它们死锁了。
今天就聊聊这背后两个最经典的硬核成因。
1. 字段没加索引,更新退化为全表锁
这是开发中最容易踩的坑。
假设有一张商品表 goods,里面目前只有两条记录:id = 1 的商品 A,和 id = 2 的商品 B:
CREATE TABLE goods (
id INT PRIMARY KEY AUTO_INCREMENT,
goods_name VARCHAR(50),
stock INT
);
注意,此时 goods_name 字段没有建任何索引。
现在,两个线程同时去更新这两条不同的记录:
线程 A 去更新商品 A:
UPDATE goods SET stock = 90 WHERE goods_name = '商品A';
线程 B 去更新商品 B:
UPDATE goods SET stock = 80 WHERE goods_name = '商品B';
从业务上看,线程 A 只管商品 A,线程 B 只管商品 B。但因为 goods_name 没加索引,MySQL 根本没办法直接定位到具体是哪一行。
没办法,MySQL 只能被迫去走全表扫描。
而在可重复读(RR)级别下,走全表扫描意味着:InnoDB 会把聚簇索引(主键索引)上的每一条记录都加上排他锁(X锁)。
这很容易导致下面的冲突:
线程 A 从头开始扫描聚簇索引,先锁定了 id = 1,接着去申请锁定 id = 2;
几乎同一时间,线程 B 也在进行全表扫描,它可能先锁定了 id = 2,接着去申请锁定 id = 1。
由于加锁是有先后顺序的,当两边的顺序在内存里发生交错:线程 A 拿着 id = 1 的锁等线程 B 释放 id = 2;而线程 B 拿着 id = 2 的锁等线程 A 释放 id = 1。
这就形成了一个闭环,死锁就此产生。
所以,在线上环境写 UPDATE 或者 DELETE 语句,WHERE 后面一定要确保走索引。否则,哪怕你更新一条根本不存在的数据,它也会去扫全表、把整张表锁个底朝天,极易引发大面积死锁。
2. 间隙锁与插入意向锁打架
除了没索引导致全表扫描,还有一种更隐蔽的死锁。即便你更新的字段有索引,依然会中招。
在日常业务里,我们常写类似“不存在则写入”的防重逻辑:先用 SELECT ... FOR UPDATE 查一下数据在不在,如果不在,再执行 INSERT 写入新数据。
假设商品表里现在只有 id = 1 和 id = 10 的两条记录。
线程 A 准备插入 id = 5 的商品:
SELECT * FROM goods WHERE id = 5 FOR UPDATE;
线程 B 准备插入 id = 6 的商品:
SELECT * FROM goods WHERE id = 6 FOR UPDATE;
因为当前表里根本没有 5 和 6 这两条记录。在可重复读(RR)级别下,InnoDB 为了防止幻读,会在这个不存在的区间 (1, 10) 加一把间隙锁。
但这里有一个极其关键的规则:间隙锁和间隙锁之间是兼容的。
所以,线程 A 占了 (1, 10) 的间隙锁,线程 B 进来也一样能拿到 (1, 10) 的间隙锁,两边相安无事。
但接下来,两个线程都发现数据不存在,开始准备写数据:
线程 A 准备插入商品 5:
INSERT INTO goods(id, goods_name) VALUES(5, '商品C');
线程 B 准备插入商品 6:
INSERT INTO goods(id, goods_name) VALUES(6, '商品D');
在往间隙里插入数据前,InnoDB 要求线程必须先拿到该区间的插入意向锁。
但数据库有一个硬性冲突规则:插入意向锁和间隙锁是互斥的。
这就会引发下面的僵局:
线程 A 申请插入意向锁,发现线程 B 已经占了该区间的间隙锁,只能在旁边等线程 B 释放;
同时,线程 B 也申请插入意向锁,发现线程 A 也占着这个区间的间隙锁,也只能等线程 A 释放。
两个线程都在等对方手里那把相互兼容的间隙锁,谁也不松手,直接卡死触发了死锁。
说在最后
高并发场景下,数据库加锁绝不是简单的“锁定那一行”那么单纯。
如果想在线上避开这些奇怪的死锁,记住两点就行:
1. 严格控制加锁边界。 确保所有的更新和删除操作都走索引,绝对不能给全表扫描和锁升级的机会。
2. 避免大范围的间隙锁。 尽量不要用 SELECT ... FOR UPDATE 去查一条不存在的记录。如果有防重写的需求,建议直接利用数据库的唯一索引(Unique Key),配合 INSERT IGNORE 或者 ON DUPLICATE KEY UPDATE。让锁的竞争和范围约束在单行的维度,千万别牵连无辜的间隙区间。
