死锁源于并发事务加锁顺序不一致,非唯一索引多行扫描导致加锁顺序不可控;唯一/联合唯一索引可降低风险,应用层必须按索引顺序显式排序ID以对齐加锁路径。

死锁不是随机发生的,是加锁顺序不一致导致的
mysql 的 select ... for UPDATE 或 UPDATE 在非唯一索引上扫描多行时,会按索引顺序逐行加锁;但如果两个事务以不同顺序访问相同的数据行(比如一个先查 id=5 再查 id=2,另一个反过来),就可能互相等待,触发死锁。
根本原因不在 SQL 写得“错”,而在并发下加锁路径不可控。常见错误现象包括:应用层重试后突然报 Deadlock found when trying to get lock,且日志里看不出明显逻辑冲突。
- 唯一索引能大幅降低风险——因为 MySQL 知道只有一行匹配,会直接定位并加锁,跳过范围扫描和行锁升级
- 非唯一索引 +
WHERE条件模糊(如status = 'pending')极易引发多行扫描,加锁顺序依赖 B+ 树物理结构,不可预测 - 如果必须用非唯一索引,强制让所有事务按同一字段排序访问(比如统一加
ORDER BY id ASC),可收敛加锁顺序
唯一索引不是万能的,但它是防并发写冲突的第一道防线
唯一索引不仅加速查询,更重要的是让 MySQL 能在插入/更新时做“唯一性校验 + 行级锁”原子操作。比如用 INSERT ... ON DUPLICATE KEY UPDATE 替代先 SELECT 再 INSERT,就能避开“检查-插入”间的竞态窗口。
但要注意:唯一索引只对“键值完全相同”的冲突有效;如果业务逻辑依赖组合唯一(如 (user_id, order_type)),必须建联合唯一索引,单列索引无效。
- 联合唯一索引的列顺序影响查询走索引的能力,也影响加锁范围——
WHERE user_id = ? AND order_type = ?能走全索引,而只查order_type可能退化为全表扫描加锁 - NULL 值在唯一索引中不参与冲突判定(MySQL 允许多个
NULL),若业务允许空值,需额外校验 -
REPLACE INTO本质是delete + INSERT,会触发两轮加锁,在高并发下反而更容易死锁,不推荐
ORDER BY + LIMIT 不一定能减少锁,要看执行计划
很多人以为加了 LIMIT 1 就只锁一行,但 MySQL 实际加锁行为取决于是否能用索引避免排序和回表。如果 ORDER BY created_at 没有对应索引,优化器可能选择全表扫描+文件排序,此时即使 LIMIT 1,也可能对所有扫描过的行加间隙锁(gap lock)。
典型错误现象:明明只查一条记录,却阻塞了其他事务对相邻主键区间的插入。
- 用
EXPLAIN确认type是range或ref,且Extra里没有using filesort或Using temporary - 如果排序字段和查询条件字段不同,优先建覆盖索引,例如
WHERE status = 'ready' ORDER BY id→ 建索引(status, id) - 间隙锁(gap lock)在
READ COMMITTED隔离级别下会被禁用,但代价是可能产生幻读;权衡点在于你更怕死锁还是数据一致性
应用层加锁顺序必须和数据库索引顺序严格对齐
最隐蔽的死锁来源,是应用代码里多个 ID 的处理顺序和数据库实际加锁顺序不一致。比如批量更新订单状态,代码按 [101, 99, 105] 循环,但 MySQL 因为索引是 B+ 树,实际按主键升序加锁(即 99 → 101 → 105)。当另一服务按降序处理时,就埋下死锁伏笔。
这不是数据库问题,是协作契约没对齐。
- 所有涉及多行修改的场景,应用层必须显式排序(如
ids.sort()),且排序依据必须和索引最左前缀一致 - 不要依赖 ORM 自动生成的批量语句顺序——django 的
bulk_update、mybatis 的<foreach>都不保证与索引顺序一致 - 如果业务逻辑天然无法排序(比如按用户提交时间乱序处理),那就拆成单条语句串行执行,用吞吐换确定性
真正难的不是写对 SQL,而是让所有服务、所有线程、所有事务,在同一套索引规则下“排队”。一旦漏掉某个分支没排序,或者某次部署绕过了索引,死锁就会在低概率下稳定复现。