根本原因是for update在非唯一条件或范围查询下触发间隙锁或临键锁;若status无索引,全表扫描会锁所有行及间隙,导致阻塞与死锁。

为什么FOR UPDATE会意外锁住一大片行
根本原因不是SELECT ... FOR UPDATE本身危险,而是它在非唯一条件或范围查询下会触发间隙锁(gap lock)或临键锁(next-key lock)。比如SELECT * FROM order WHERE status = 'pending' FOR UPDATE,若status没索引,InnoDB 可能全表扫描并锁住所有匹配行——甚至包括还没插入的“间隙”。这时另一个事务想插一条status = 'pending'的新订单,就会被阻塞;若它同时也在等你刚锁住的某条具体记录,死锁就成立了。
- 用
EXPLAIN确认WHERE是否走索引:type 必须是const、eq_ref或ref,不能是ALL或index - 联合查询条件要建覆盖索引,顺序必须匹配谓词,例如
(status, user_id, id)而不是(user_id, status) - 避免
LIKE '%xxx'、OR、函数包裹字段等导致索引失效的操作
怎么让FOR UPDATE只锁你要的那几行
核心是把锁粒度从“区间”收窄到“单点”。只要WHERE能命中主键或唯一索引的等值查找,InnoDB 就只会加记录锁(record lock),不会碰间隙。
- 优先用主键或唯一键做条件:
SELECT * FROM order WHERE order_no = 'ORD-20260901' FOR UPDATE,而非WHERE user_id = 123 - 批量操作别用
IN (3,1,5)这种无序列表,改用ORDER BY id ASC显式排序:SELECT id FROM order WHERE id IN (3,1,5) ORDER BY id FOR UPDATE - 真要按业务字段查,先查ID再二次锁定:比如
SELECT id FROM order WHERE status = 'pending' AND created_at ,拿到ID列表后再<code>SELECT ... FOR UPDATE WHERE id IN (...)
替代方案:什么时候该放弃FOR UPDATE
很多场景下,SELECT ... FOR UPDATE只是为防并发修改,但其实用带条件的UPDATE更轻量、更安全,且天然规避间隙锁问题。
- 用原子更新代替“查—判—更”:
UPDATE order SET status = 'processing' WHERE id = 100 AND status = 'ready',返回影响行数为 0 就说明已被抢 - 高频争抢单业务键(如
order_no)时,直接上INSERT INTO ... ON DUPLICATE KEY UPDATE,MySQL 内部加锁路径更可控 - 纯校验类逻辑(如“余额是否足够”)尽量前置到应用层缓存或预检查,别拖进事务里锁数据
隔离级别和配置的隐性影响
很多人忽略:即使 SQL 写对了,READ-COMMITTED 和 REPEATABLE-READ 对间隙锁的处理完全不同。默认的 RR 级别下,范围查询必然带间隙锁;而 RC 下间隙锁被禁用——但这不意味着可以随便切隔离级别,得看业务一致性要求。
- 确认当前会话隔离级别:
SELECT @@transaction_isolation - 如果业务允许(比如不要求可重复读),设为
READ-COMMITTED可直接消除大部分间隙锁死锁 - 不建议动
innodb_locks_unsafe_for_binlog,它已废弃,且开启后会影响主从一致性
死锁最麻烦的地方,往往不是锁没加对,而是你以为只锁了一行,实际锁了一整个索引段;你以为顺序一致,结果应用层分页没ORDER BY,两次取数顺序不同,加锁顺序就天然错位。











