在开始推演之前我们先认识一下 MySQL InnoDB 锁家族的“火枪手三兄弟”。记住一个核心大前提MySQL 的行锁锁的永远是“索引”而不是真实的物理数据行 一、兵器谱三种锁的真实面目假设我们有一张user表主键是id。表里目前有三行数据id 5, 10, 15。1. Record Lock (记录锁) —— “狙击手”定义仅仅锁住索引记录本身。效果如果对id10加了记录锁其他事务就不能修改或删除id10这行数据。精准打击锁的范围极小。2. Gap Lock (间隙锁) —— “地雷阵”定义锁住两个索引记录之间的“间隙”不包括记录本身。效果数据5, 10, 15把空间划分成了四个间隙(-∞, 5)、(5, 10)、(10, 15)、(15, ∞)。如果在(5, 10)这个间隙加了 Gap Lock其他事务绝对无法在这个间隙里插入新数据比如id6, 7, 8都插不进去。防御属性它的唯一目的就是防止幻读防止别人偷偷塞数据进来。3. Next-Key Lock (临键锁) —— “重装坦克”定义Record Lock Gap Lock 的结合体。它锁住记录本身并且锁住该记录前面的那个间隙。效果它是一个左开右闭的区间。比如对id10加 Next-Key Lock它锁住的范围就是(5, 10]。不仅id10不能改6, 7, 8, 9也全都不准插 二、加锁的“最高纲领” (加锁规则)在 RR 隔离级别下InnoDB 加锁有一个万能的“退化/演变”规则。只要背熟这几条99% 的死锁分析都不在话下。最高纲领InnoDB 默认加锁的基本单位永远是 Next-Key Lock左开右闭区间。但是为了提高系统的并发度MySQL 在某些特定条件下会大发慈悲地把“重装坦克”降级退化为“狙击手”或“地雷阵”。让我们通过一个实战案例来推演。案发现场建表准备CREATETABLEt_user(idintNOTNULL,-- 主键索引ageintDEFAULTNULL,-- 普通非唯一索引namevarchar(10),PRIMARYKEY(id),KEYidx_age(age))ENGINEInnoDB;-- 插入三条测试数据INSERTINTOt_userVALUES(5,10,A);INSERTINTOt_userVALUES(10,20,B);INSERTINTOt_userVALUES(15,30,C);*此时id产生的间隙有(-∞, 5), (5, 10), (10, 15), (15, ∞)**age产生的间隙有(-∞, 10), (10, 20), (20, 30), (30, ∞)*场景 1唯一索引主键等值查询且记录存在SQL 执行UPDATE t_user SET name X WHERE id 10;加锁推演默认加上 Next-Key Lock锁住(5, 10]。MySQL 优化器一看嘿id是主键唯一索引且你精准命中了存在的id10。别人不可能再插入另一个id10了没必要防着间隙。退化法则Next-Key Lock 退化为 Record Lock记录锁。最终结果只锁住了id10这一行。插入id8完全不受影响。并发度极高场景 2唯一索引等值查询但记录不存在 (极易踩坑)SQL 执行UPDATE t_user SET name X WHERE id 12;加锁推演默认找第一个大于 12 的记录也就是 15。加上 Next-Key Lock区间是(10, 15]。MySQL 优化器一看你查的id12根本不存在啊。既然你查不存在的数据我得防着别人在你眼皮子底下把 12 插进来防止幻读。退化法则既然 15 并没有被命中锁住 15 没意义。Next-Key Lock 退化为 Gap Lock间隙锁。最终结果锁住了区间(10, 15)。此时如果你想INSERT id11或id14全部会被阻塞场景 3非唯一索引普通索引等值查询 (死锁的重灾区)SQL 执行UPDATE t_user SET name X WHERE age 20;加锁推演先走idx_age索引树。找到age20默认加上 Next-Key Lock锁住区间(10, 20]。重点来了因为age是普通索引不是唯一的这意味着表里完全可以存在多个age20的人。为了防止幻读别人再插一个age20进来MySQL 不敢退化。向右延伸不仅不能退化MySQL 还会继续顺着索引树向右找找到下一个不满足条件的记录age30然后在它们中间加一个 Gap Lock区间是(20, 30)。最终结果锁住了整整一大片包含一个 Next-Key Lock(10, 20]和一个 Gap Lock(20, 30)。如果此时线程 B 想要INSERT一个age15落在前面的间隙阻塞如果此时线程 C 想要INSERT一个age25落在后面的间隙阻塞 三、死锁是怎么产生的(破案时刻)理解了上面的规则我们就能解开开头那个“明明操作不同行却死锁”的悬案了。死锁场景再现线程 A执行DELETE FROM t_user WHERE id 12;(注意12 不存在)。根据场景 2线程 A 获得了(10, 15)的Gap Lock。线程 B执行DELETE FROM t_user WHERE id 13;(13 也不存在)。根据场景 2线程 B 也申请获得了(10, 15)的Gap Lock。敲黑板间隙锁和间隙锁之间是不冲突的它们的目的都是“防止别人插入”所以 A 和 B 愉快地共同持有了这段间隙的锁。高潮来了线程 A想插入数据INSERT INTO t_user VALUES (12, 25, D);A 准备插入 12发现 12 所在的间隙(10, 15)被线程 B的间隙锁踩着了A 只能等待 B 释放。线程 B也想插入数据INSERT INTO t_user VALUES (13, 26, E);B 准备插入 13发现 13 所在的间隙(10, 15)被线程 A的间隙锁踩着了B 只能等待 A 释放。A 等 BB 等 A。互相僵持死锁形成 MySQL 的死锁检测器瞬间被触发选择牺牲其中一个事务抛出经典的Deadlock found when trying to get lock异常。 四、总结如何避免被 MySQL 锁死MySQL 的锁设计极其精妙但如果不了解底层的退化规则业务代码就像在雷区里跳舞。作为高级开发/架构师日常写 SQL 请牢记以下保命指南尽量用主键或唯一索引去更新数据。这样锁的范围最小降级为 Record Lock并发度最高。尽量避免通过普通非唯一索引去做范围更新或批量删除。它会产生大面积的 Next-Key Lock 和 Gap Lock极易引发死锁和系统卡顿。如果业务允许可以把隔离级别降低为 Read Committed (RC)。在 RC 级别下除了外键和唯一键检查Gap Lock 几乎完全失效死锁概率断崖式下降这也是为什么大厂阿里、腾讯在很多核心业务上强制使用 RC 级别的原因。