ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

只有INSERT也能死锁?MySQL InnoDB插入死锁的成因与排查

只有INSERT也能死锁?MySQL InnoDB插入死锁的成因与排查 周一早上还没开始写代码告警群先响了数据库死锁。我当时的第一个反应是查最近有没有批量 UPDATE 或大事务上线结果翻完变更记录定位到的根源让所有人都有点意外——死锁发生在一张只做 INSERT 的表上事务里就是几条单表插入。团队里新来的同事脱口而出只插入怎么也会死锁这个问题其实很有代表性。太多人对 InnoDB 锁的认知停留在UPDATE/DELETE 才会抢行锁觉得 INSERT 就是往表里加一行哪来的冲突。但我在生产环境里排查过的死锁案例相当一部分根因恰恰落在 INSERT 上。这篇文章就用实际经验把这件事讲透INSERT 到底拿了哪些锁、为什么纯插入事务会互相等成环、遇到这种死锁该怎么一步步定位、最后怎么从根上规避。适合被死锁困扰的 DBA、后端开发也适合准备 MySQL 面试的人——死锁这个考点面试官尤其喜欢拿 INSERT 场景出题。1. 一个反直觉的起点只有 INSERT 的事务也能死锁1.1 我在生产环境遇到的 INSERT 死锁现场先说那次具体的故障。表结构是个典型的订单扩展表主键是自增 id另外有一个业务订单号的唯一索引uk_order_no。业务逻辑是在一个事务里先插主订单再插一张子表映射关系事务代码大概长这样BEGIN; INSERT INTO orders (order_no, user_id, amount) VALUES (A0001, 100, 25.00); INSERT INTO order_ext (order_no, remark, tag) VALUES (A0001, 测试, hot); COMMIT;因为主订单和扩展表是一一对应的按照订单号天然会存在重复插入的可能。当时并发量一上来两个线程同时处理同一个业务订单号事务交错执行InnoDB 直接抛了Deadlock found when trying to get lock; try restarting transaction。问题在于这两个事务都只有 INSERT 语句没有任何显式加锁操作。很多开发第一次看到这种报错会怀疑代码写错了或者是数据库抽风其实底层锁机制是能解释清楚的。深入查之前得先纠正一个认知——INSERT 要拿的锁远不止给新行加一把排他锁这么简单。1.2 先破个认知INSERT 申请的不只是一行锁InnoDB 里和 INSERT 相关的锁类型至少有四类我把它们按触发时机列出来锁类型锁模式触发场景说明记录锁Record LockX 锁插入成功后锁定新记录阻止其他事务修改或删除该记录间隙锁Gap LockS/X 锁条件范围扫描、外键检查、INSERT...SELECT锁定索引记录之间的空隙阻止其他事务在间隙内插入临键锁Next-Key Lock记录锁间隙锁在 RR 隔离级别下范围查询、唯一键检查左开右闭区间兼顾记录和间隙插入意向锁Insert Intention Lock特殊的间隙锁向某个间隙插入记录前多个事务可同时持有插入意向锁但和任何间隙锁互斥自增锁AUTO-INC Lock表级锁向含自增列的表插入时与innodb_autoinc_lock_mode参数相关批量插入时影响最大最容易被忽略的是插入意向锁。它的语义是我打算往这个间隙里插记录。两个事务同时往同一个间隙插入不同的记录这个操作本身是互不阻塞的——插入意向锁之间兼容。但一旦这个间隙已经被某个事务用 X 锁的间隙锁锁住了新来的插入意向锁就必须等。这个机制是后面所有 INSERT 死锁故事的核心前提。1.3 死锁的四个必要条件放在 INSERT 场景意味着什么教科书上写死锁四条件是互斥、持有并等待、不可剥夺、循环等待。放到 INSERT 场景里前三样 InnoDB 天然满足——行锁互斥事务不结束不会释放锁锁也不能被强占。所以真正决定死锁是否发生的是第四条循环等待。这意味着一个事务单枪匹马是死不了锁的。你看到死锁告警必然有至少两个事务各自持有一部分锁同时又都在等对方手里的另一部分锁。而 INSERT 参与死锁的方式往往是某个事务先持有了一批插入成功记录的锁然后去等另一个事务持有的、此时自己需要的那把锁。理解了只有 INSERT 也能形成循环等待接下来的问题就是究竟哪些具体场景最容易触发这种循环我在生产环境里归纳出三大类高频场景下面逐个拆。2. InnoDB 里 INSERT 到底怎么拿锁隐式锁、唯一键检查与插入意向锁2.1 隐式锁未提交的 INSERT 怎么霸占记录先说一个反直觉的事实刚插入且未提交的记录在记录头上其实没有显式的锁标记。InnoDB 不会为每行新插入的数据立刻构造一个锁结构那太占内存了。它用的是隐式锁机制——通过记录上的隐藏列DB_TRX_ID来标记最后一次修改该记录的事务 ID。当另一个事务来访问这条记录时InnoDB 一读DB_TRX_ID发现这个事务还处于活跃状态就知道这条记录已经被别人隐性持有了于是一方进入等待。这个状态下锁还没有实体化只是通过事务 ID 做的判断。隐式锁会在什么情况下转成显式锁当发生锁等待时构造锁结构才能进入死锁检测链路。比如事务 B 试图修改 A 插入的未提交记录B 阻塞此时 InnoDB 需要把 A 的隐式锁转成显式 X 锁才能建立起谁持有、谁等待的图。这个转换过程本身开销不小而且 5.7 之前死锁检测性能在高并发下容易被放大。理解这一点你就知道为什么很多 INSERT 死锁发生在插入成功后立刻又被其他事务触碰唯一键的场景——隐式锁和显式锁在几个事务之间交错转换最容易织出循环。2.2 唯一键冲突时 S 锁与 X 锁的转换链条死锁高发点这是 INSERT 死锁里分量最重的一块值得单独讲细。假设表里有一个唯一索引uk_order_no事务 T1 插入order_noA0001后未提交。此时事务 T2 也插入order_noA0001会发生什么第一步InnoDB 要检查唯一性约束。这个过程需要先对目标索引记录加一个S 锁共享锁用于读取判断不能直接上来就加 X 锁——万一唯一性冲突你要返回的是违反唯一约束错误而不是破坏数据。但此时 T1 的隐式锁挡住了 T2T2 只能等 T1 提交或回滚。这里有个极其关键的细节如果 T1 回滚了T2 会拿着这把 S 锁继续完成唯一性检查然后发现自己没有冲突了需要把锁升级成 X 锁来完成插入。从 S 锁升级到 X 锁和另一个事务的锁形成互斥是死锁最容易出现的位置。再进一步如果这个事务不止一条 INSERT而是像 1.1 里那样在同一个事务里连续插入父记录和子记录两个事务按相反顺序插入相同订单号就会形成完整的环形等待T1 持有父记录的 X 锁等待 T2 已插入子记录的 S 锁升级T2 持有子记录的 X 锁等待 T1 已插入父记录的 S 锁升级死锁检测器一扫描直接回滚其中一个事务。2.3 插入意向锁与 Gap 锁的碰撞逻辑第二个容易成环的机制是插入意向锁和间隙锁的冲突。前面说了插入意向锁之间是兼容的但它和任意间隙锁互斥。在早期顺序扫描的INSERT ... SELECT或REPLACE INTO场景下第一个事务把某个索引区间的 Gap Lock 锁住第二个事务往同一个区间插入数据就要排队。排队本身不是死锁但如果两个事务都通过类似INSERT ... SELECT的方式锁定了彼此需要扫描的区间循环就出现了。举个具体例子T1 执行INSERT INTO target SELECT * FROM source WHERE id 100不仅锁定了 target 表要插入的记录还在 source 表的扫描范围内加了临键锁T2 执行同样的 SQL但筛选条件稍微不同锁定了另一部分 source 记录两边都在完成 INSERT 时需要回头等一下对方持有的间隙锁这种死锁在 REPEATABLE READRR默认隔离级别下尤其常见。因为 RR 级别下普通 SELECT 的加锁读和范围扫描会把间隙锁玩到极致连一个根本不存在的空洞都可能成为死锁的支点。3. 三类高频死锁场景与根因别说你代码里没有3.1 场景 A同一事务内多次 INSERT 形成环形等待这是我在业务系统里见到最多的一类典型特征就是事务里不止一条 INSERT通常是一个主表加多个子表或者批量插入多条记录。我在排障时还原过这样一个案子三个线程并发插入同一批订单号事务内部逻辑如下T1 插入订单号A0001成功持有该行的 X 锁随后尝试插入订单号B0002但B0002已被 T2 插入T1 等待T2 插入B0002成功随后尝试插入C0003但C0003已被 T3 插入T2 等待T3 插入C0003成功随后尝试插入A0001但A0001已被 T1 插入T3 等待于是 T1 等 T2T2 等 T3T3 等 T1一个完整的死锁三角形。这个场景里每条 INSERT 单拎出来都不冲突但多个 INSERT 的顺序不一致加上三个事务并发交错执行就把各自持有的锁编织成了环。这种死锁最坑的地方在于低并发时完全测不出来压测时偶发一两次你以为是你代码里的某个边界 bug其实是多个事务对同一批 key 做了不同顺序的插入。解决办法不是调数据库参数而是改事务里的插入顺序——所有事务都按A0001 - B0002 - C0003的固定顺序执行环就不存在了。3.2 场景 BINSERT ... SELECT 与并发插入在 Gap 上的对抗第二种高频场景是INSERT ... SELECT语句引发的 gap 锁对抗多发生在报表同步、数据归档跑批的时候。INSERT INTO target_table SELECT ... FROM source_table WHERE ...在执行过程中会对 source_table 扫描范围加临键锁同时对 target_table 的记录加插入锁。如果两个类似的大事务并发跑各自锁定的 source 区间有交叉然后又都往 target_table 的相近区域插入Gap 锁和插入意向锁就会互相排队。配合 RR 隔离级别这个排队一旦交错死锁率非常高。另外还有一种变体REPLACE INTO。它的语义是如果有唯一键冲突就先 DELETE 再 INSERTDELETE 会拿 X 锁INSERT 又会重新走唯一键检查等于把一个简单的插入放大成删插两步。并发 REPLACE INTO 同一批 key 时涉及先删除的很快后删除的等锁而前一个事务又要回头插入新值形成等待循环。3.3 场景 C外键约束、自增锁的隐藏成本外键约束造成的 INSERT 死锁是个经典盲区。插入子表记录时InnoDB 需要检查父表对应记录是否存在这会在父表记录上加 S 锁。如果多个事务同时插入不同的子表记录而父表相同S 锁互相兼容本来没事。但一旦随后这些事务又对父表记录做 UPDATE 拿 X 锁S 锁和 X 锁不兼容环就出来了。这场面其实已经不纯粹是 INSERT 死锁而是INSERT 的外键检查锁 业务里的 UPDATE/X 锁在打架。再说自增锁。使用innodb_autoinc_lock_mode0或1时批量插入比如一次插多条或INSERT ... SELECT会获取表级 AUTO-INC 锁插入期间其他事务的 INSERT 会被阻塞哪怕它们的自增值完全不相干。这个本身不形成死锁但会显著拉长大事务持有锁的时间间接提高死锁概率。能把这个表级锁的范围缩小通常换成模式 2interleaved高并发下的锁竞争会肉眼可见地改善。顺带提醒一下MySQL 8.0 默认是模式 2但如果你从老版本迁过来的配置里显式写了模式 1这个问题会一直存在。4. 死锁现场还原从日志到根因的完整排查链路4.1 先让现场留下来innodb_print_all_deadlocks排查死锁的第一原则是没有第一现场日志后面都是猜。很多人遇到死锁第一反应是查代码但代码只能告诉你哪里可能死锁不能告诉你这次到底谁和谁撞了。真正的铁证在 InnoDB 自己的死锁日志里。MySQL 5.7 开始有一个参数建议第一时间打开SET GLOBAL innodb_print_all_deadlocks ON;这个参数的作用是把每一次死锁的详细信息全部写入 MySQL 错误日志而不仅仅是记录最近一次。默认情况下 SHOW ENGINE INNODB STATUS 只能看到最后一回死锁生产环境里死锁经常是一瞬间的等你看到状态现场已经没了。所以我会建议把它加上持久化配置[mysqld] innodb_print_all_deadlocks ON log_error_verbosity 3顺便说一句log_error_verbosity3让错误日志记录更多详细信息排障时很有用。改配置要重启实例如果不想重启SET GLOBAL是即时生效的先开着顶住现场再安排后续持久化。4.2 SHOW ENGINE INNODB STATUS 死锁日志逐段解读有了日志下一步就是会读。我截取一段典型的 INSERT 死锁日志做过脱敏简化带你把关键信息一行行拆开。------------------------ LATEST DETECTED DEADLOCK ------------------------ 2026-01-15 10:32:17 0x7f123456 *** (1) TRANSACTION: TRANSACTION 452318, ACTIVE 12 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 390455, query id 881234 ... insert into orders (order_no, user_id, amount) values (A0001, 100, 25.00) *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 5 n bits 80 index uk_order_no of table shop.orders trx id 452318 lock_mode X locks rec but not gap *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 6 n bits 80 index PRIMARY of table shop.orders trx id 452318 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 452320, ACTIVE 10 sec inserting LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 390456, query id 881235 ... insert into order_ext (order_no, remark, tag) values (A0001, 测试, hot) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 7 n bits 80 index uk_order_no of table shop.order_ext trx id 452320 lock_mode X locks rec but not gap *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 5 n bits 80 index uk_order_no of table shop.orders trx id 452320 lock_mode X locks rec but not gap waiting *** WE ROLL BACK TRANSACTION (2)逐段看第一段(1) TRANSACTION里ACTIVE 12 sec inserting说明这个事务持续了 12 秒当前正在执行 INSERT。LOCK WAIT 3 lock struct(s)表示它已经持有 3 个锁结构其中 2 个 row lock正在等待第 4 个。HOLDS THE LOCK(S)段告诉你事务 1 手里握着哪把锁index uk_order_no of tableshop.orderslock_mode X locks rec but not gap也就是在订单表唯一索引上的一把 X 记录锁。WAITING FOR THIS LOCK TO BE GRANTED段告诉你它在等哪把锁index PRIMARY of tableshop.orders 上的 X 记录锁。也就是说事务 1 需要拿到某个主键记录的锁才能继续而这个主键记录的锁大概率被另一个事务握着。后半段事务 2 对称出现它握着order_ext表的 X 记录锁在等orders表唯一索引上的锁。把两边的持有和等待连起来就得到了一条清晰的环事务 1 持有 orders.uk_order_no 的锁、等待 PRIMARY 的锁事务 2 持有 PRIMARY 的锁、等待 uk_order_no 的锁。日志末尾还写了WE ROLL BACK TRANSACTION (2)InnoDB 会按代价选择回滚其中一个受害者事务让另一个继续执行。这里要对开发同学多说一句死锁日志里的 SQL 只是临门一脚时正在执行的语句不代表事务里只有这一条 SQL。一定要结合业务代码把完整事务捞出来看。很多 INSERT 死锁的日志表面是两个 INSERT 撞一起了实际根因是其中某个事务里先有 UPDATE 拿了另一把锁再回头 INSERT 才发现撞车。4.3 用 performance_schema.data_locks 把谁等谁画出来死锁日志能看现场但现场只覆盖最近一次。如果死锁频发光靠日志逐条翻效率太低。MySQL 8.0 提供了直接把锁等待关系实时查出来的视图这是我最常用的辅助手段。先看当前有哪些事务正在跑SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx;结果里能找到长时间处于LOCK WAIT状态的事务以及它们当前执行的 SQL。然后查锁等待矩阵SELECT r.trx_id AS waiting_trx_id, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_query AS blocking_query, l.object_name AS table_name, l.index_name, l.lock_type, l.lock_mode FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON w.REQUESTING_ENGINE_LOCK_ID l.ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON w.REQUESTING_TRX_ID r.trx_id JOIN information_schema.innodb_trx b ON w.BLOCKING_TRX_ID b.trx_id;这条 SQL 会把当前每一个等待中的事务和被谁阻塞直接列出来。生产环境遇到只报死锁但不清楚源头的情况我一般是先跑上面的查询找到正在互相等待的事务再回代码里定位对应的事务内容。这比翻应用日志或者是满屏的 general log 快得多。MySQL 5.7 没有data_lock_waits不过可以通过information_schema.innodb_lock_waits的blocking_trx_id字段做类似分析查询结构大同小异。排除死锁还有一种长期战手段开启通用日志或 performance_schema 的events_statements_history把死锁时间点前后每个线程执行过的语句捞出来排序然后人工还原交错顺序。这个方法费时但定位率最准特别适合那种动不动闪一下、你根本蹲不到现场的死锁。5. 治本组合拳索引、事务与隔离级别的实战调整5.1 索引侧让 Gap Lock 的影响范围小一点如果我们回头分析所有和 Gap 锁相关的 INSERT 死锁会发现一个共性间隙锁锁得太宽。范围条件越宽被锁住的间隙越大其他事务可以插入的位置就越少等待概率越高。最直接的调整是让走到的索引精确命中。比如WHERE order_no ?这类点查如果 order_no 上有唯一索引InnoDB 在 RR 下加的是单条记录锁不会再额外锁一个大间隙但如果走的是普通索引或者没索引那就会退化成范围扫描加一堆临键锁。所以排查死锁时先用EXPLAIN看 INSERT 语句和事务内其他语句走的执行计划确认是否出现了range扫描、有没有用到唯一索引。另一个容易被忽略的点是复合索引的顺序设计。比如你的业务高频按(user_id, order_no)查询并插入索引建立在(user_id, order_no)上如果某个事务的 WHERE 条件只给了user_id、没给order_no那插入和查询的锁覆盖范围和另一个事务就不一致极容易在缝隙处产生插入意向锁的相互等待。这个需要结合业务场景专门设计没有万能配方但记住一条让所有高频事务的锁范围尽可能对齐而不是各自锁不同的区间。5.2 事务侧锁顺序一致性、事务时长与单语句化解决死锁的七成功夫在事务设计上而不是数据库参数。第一个铁律多事务操作多条记录时保持相同的锁顺序。这个思想其实可以对照旅游抢票场景大家拿号排队如果你先排 A 队再排 B 队我非要先排 B 队再排 A 队一旦两边进度交错就会互相卡住。放在数据库里统一先插父表、再插子表或者先插大单、再插小单的顺序环形等待就不存在了。第二个要点压缩事务时间。事务里只放必要的逻辑SELECT 查询、RPC 调用、业务计算统统放到事务外。我处理过一个 INSERT 死锁反复出现的业务定位后发现事务里居然调了一次外部接口最长事务持续了七八秒八秒内其他并发 INSERT 全在排队排队久了必然撞车。把外部调用移出事务后问题直接消失。第三个手段尽量把多条 INSERT 合并成一条多值插入。INSERT INTO t (...) VALUES (...), (...), (...)比循环单条 INSERT 好在哪里它让 InnoDB 在插入这批记录时按索引顺序统一处理减少了锁结构和隐式锁转换次数同时单个事务的整体持锁时间也更短。对于大批量导入进一步可以拆成固定行数的批次避免单个事务锁行过多。5.3 隔离级别侧READ COMMITTED 带来的取舍默认的 REPEATABLE READ 是 gap lock 的重灾区。很多 INSERT 死锁其实在 READ COMMITTEDRC下根本不会发生因为 RC 级别下 InnoDB 禁用了大部分间隙锁只保留记录锁和外键检查需要的锁。我没少和团队争论过降隔离级别的事。从实践来看如果你的业务场景里不依赖 RR 的可重复读特性典型场景是 binlog 需要设置为 row 格式配合那么切到 RC 通常利大于弊死锁少了并发插入的能力上去了甚至还可以配合binlog_formatROW很多原本担心的问题都不存在。但是降级不是免费的。RC 下可能出现幻读这意味着你的事务如果在执行中要多次按条件读取同一批数据可能会读到不同的结果。对于统计类、对账类的业务要谨慎。我的建议是死锁频发时先在测试环境把隔离级别切到 RC 跑一遍核心用例确认业务逻辑不受影响再上生产。不要看到一个死锁减少就盲目切换。5.4 兜底侧死锁重试与业务幂等最后说一个现实观点在高并发场景下把死锁率降到零是不现实的更理性的思路是让偶发死锁成为系统的一个可恢复状态。死锁发生后InnoDB 会自动回滚其中一个事务应用层拿到Deadlock found的错误码1213MySQL 5.7 是 40001 的 SQLSTATE。代码里要做的是捕获这个错误并重试整个事务而不是直接把这个异常抛给用户。重试次数一般建议 2~3 次每次之间加一个极短随机退避比如 10~50ms防止多个客户端节奏一致地反复撞车。但重试的前提是业务操作要幂等。如果事务里有 INSERT 是纯新数据重试时会多插一条如果有 UPDATE 不是幂等的重试两次结果错乱。所以我在项目里落地死锁重试时一定会同时要求处理这几个点INSERT 是否带唯一键做业务幂等比如order_no、request_idUPDATE 是否用的是相对更新SET cnt cnt 1而不是绝对赋值SET cnt 5提交前的数据校验逻辑是不是可重复执行有了幂等兜底死锁重试才敢放手做。这其实是把死锁从必须根治的故障重新定义成了偶发重试即可的自愈事件运维压力小很多。在实际跟过的这些 INSERT 死锁案例里我发现一个规律绝大多数死锁都不是数据库 bug而是事务设计和索引设计为低并发写优化得太舒服高并发一冲就露馅。遇到死锁先别急着调参数先把你的事务完整还原出来数一数一个事务里有几条语句、每条语句走哪些索引、锁的顺序是不是全局一致——答案八成就在这些地方。上面这套排查路径我已经在多个项目里反复验证过照着走通常跑不出这个圈。
返回列表