
为什么 SELECT ... FOR UPDATE NOWAIT 还会触发死锁?
很多人以为加了 NOWAIT 就能彻底避开死锁,其实不然。NOWAIT 只是让语句在无法立即获取锁时立刻报错(Lock wait timeout exceeded 或更准确的 Lock wait timeout exceeded; try restarting transaction),而不是跳过锁竞争。真正引发死锁的是事务间对行锁的循环等待,哪怕用了 NOWAIT,只要两个事务按相反顺序更新同一组记录,InnoDB 的死锁检测器仍会介入并主动回滚其中一个事务。
避免死锁的核心:统一加锁顺序 + 最小化事务粒度
死锁不是靠“加 NOWAIT”就能绕开的,关键在于消除循环等待条件。实操中必须做到:
- 所有业务路径中,对多行记录加锁时,必须按相同字段、相同排序规则(如
ORDER BY id ASC)访问,否则极易形成 A→B、B→A 的依赖链 - 把
SELECT ... FOR UPDATE NOWAIT放在事务最开始,且只锁定后续UPDATE真正需要修改的行——避免先锁一堆、再过滤 - 事务内不要混用
SELECT ... FOR UPDATE和UPDATE ... WHERE多次操作同一批主键,InnoDB 可能因索引扫描路径不同而加锁范围不一致 - 确认 WHERE 条件是否走到了唯一索引或主键;若走的是二级非唯一索引,InnoDB 可能锁住索引区间(gap lock),扩大冲突面
FOR UPDATE NOWAIT 的典型安全写法示例
假设要扣减用户余额并记录流水,账户表 accounts 主键为 id,需按用户 ID 批量处理:
BEGIN; -- ✅ 正确:显式排序,只锁将要更新的行 SELECT id, balance FROM accounts WHERE id IN (101, 205, 309) ORDER BY id ASC FOR UPDATE NOWAIT;-- ✅ 后续 UPDATE 严格对应上述结果集,且按 id 顺序执行(逻辑上,SQL 引擎自动保证) UPDATE accounts SET balance = balance - 100 WHERE id = 101; UPDATE accounts SET balance = balance - 200 WHERE id = 205; UPDATE accounts SET balance = balance - 150 WHERE id = 309;
INSERT INTO ledger (...) VALUES (...); COMMIT;
⚠️ 错误示范:WHERE id IN (309, 101, 205) 不加 ORDER BY,不同事务可能按不同顺序加锁;或者先 SELECT ... FOR UPDATE 查出 10 行,再在应用层循环判断哪几行真正要改——多余行锁就是死锁温床。
容易被忽略的隐式锁升级与隔离级别影响
即使写了 FOR UPDATE NOWAIT,以下情况仍可能放大死锁概率:
- 事务隔离级别是
REPEATABLE READ(InnoDB 默认),且 WHERE 条件未命中索引 → 触发全表扫描 + 行锁升级为表级意向锁,冲突面陡增 - UPDATE 语句本身带子查询,而子查询里又去查了另一张被其他事务频繁更新的表,形成跨表锁依赖
- 应用层重试逻辑没区分错误类型:把
Lock wait timeout和真正的Deadlock found when trying to get lock当成一回事,盲目重试反而加剧竞争
真正稳的写法,是把锁顺序、索引覆盖、事务边界都收束到可验证的范围内——死锁不是异常,是设计信号。










