返回

揭秘MySQL 8.0奇特死锁:空表SELECT FOR UPDATE的陷阱与对策

mysql

MySQL 8.0奇特死锁探秘:空表 SELECT ... FOR UPDATE 引发的『自己锁自己』怪象

跑一个 Java 定时任务,结果它报了个 MySQL 死锁异常,这不算太稀奇,对吧?

Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:124)
    // ... 省略部分堆栈 ...
    at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:197)
    ... 25 more

一开始,我们以为就是常规操作下,并发冲突导致的普通死锁。但仔细扒拉了一下日志,发现事情好像有点不简单,死锁似乎和 SELECT ... FOR UPDATE 在某个特定场景下的行为有关。

问题与初步分析

为了搞清楚状况,我们祭出了 SHOW ENGINE INNODB STATUS 大法,看看死锁日志里到底记录了什么。

=====================================
2024-12-27 02:51:05 140067749193472 INNODB MONITOR OUTPUT
=====================================
...
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-12-27 02:24:16 140068337477376
*** (1) TRANSACTION:
TRANSACTION 3165095, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 13899, OS thread handle 140066175198976, query id 19098873 192.168.10.195 root update
INSERT INTO `daily_statistic_data_2021` ... values ('861213052219265',...) -- (为了简洁省略了部分列)

*** (1) HOLDS THE LOCK(S): -- 事务1持有的锁
RECORD LOCKS space id 376 page no 5 n bits 72 index daily_statistic_data_unique of table `es`.`daily_statistic_data_2021` trx id 3165095 lock_mode X locks gap before rec
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 15; hex 383633383636303439373932383135; asc 863866049792815;; -- 注意这个记录标识符 (这是 *概念上* 的,下面会解释)
 1: len 4; hex 32303231; asc 2021;;
 2: len 2; hex 3038; asc 08;;
 3: len 2; hex 3235; asc 25;;
 4: len 8; hex 8000000000000001; asc         ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED: -- 事务1等待的锁
RECORD LOCKS space id 376 page no 5 n bits 72 index daily_statistic_data_unique of table `es`.`daily_statistic_data_2021` trx id 3165095 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 15; hex 383633383636303439373932383135; asc 863866049792815;; -- 注意看,和上面持有的锁指向的*似乎*是同一个东西
 1: len 4; hex 32303231; asc 2021;;
 2: len 2; hex 3038; asc 08;;
 3: len 2; hex 3235; asc 25;;
 4: len 8; hex 8000000000000001; asc         ;;


*** (2) TRANSACTION:
TRANSACTION 3165096, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 13904, OS thread handle 140066184709888, query id 19098879 192.168.10.193 root update
INSERT INTO `daily_statistic_data_2021` ... values ('861213050685368',...) -- (插入不同的 imei)

*** (2) HOLDS THE LOCK(S): -- 事务2持有的锁 (注意,也是指向同一个概念上的记录/间隙)
RECORD LOCKS space id 376 page no 5 n bits 72 index daily_statistic_data_unique of table `es`.`daily_statistic_data_2021` trx id 3165096 lock_mode X locks gap before rec
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 15; hex 383633383636303439373932383135; asc 863866049792815;;
 1: len 4; hex 32303231; asc 2021;;
 2: len 2; hex 3038; asc 08;;
 3: len 2; hex 3235; asc 25;;
 4: len 8; hex 8000000000000001; asc         ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED: -- 事务2等待的锁 (同理,也指向同一个概念上的记录/间隙)
RECORD LOCKS space id 376 page no 5 n bits 72 index daily_statistic_data_unique of table `es`.`daily_statistic_data_2021` trx id 3165096 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 15; hex 383633383636303439373932383135; asc 863866049792815;;
 1: len 4; hex 32303231; asc 2021;;
 2: len 2; hex 3038; asc 08;;
 3: len 2; hex 3235; asc 25;;
 4: len 8; hex 8000000000000001; asc         ;;

*** WE ROLL BACK TRANSACTION (2) -- InnoDB 选择回滚事务2来打破死锁
...

怪事来了!仔细看事务 1 (TRANSACTION 3165095) 的部分:它持有 lock_mode X locks gap before rec,同时它又在等待 lock_mode X locks gap before rec insert intention waiting。而且,持有和等待的锁信息里,的 PHYSICAL RECORD 看上去一模一样!这不就是传说中的“我锁我自己”或者说“我等我自己释放锁”吗? 这怎么可能?

我们的 Java 任务里的逻辑大概是这样的(典型的 "select-then-insert/update" 模式):

// 伪代码
startTransaction();
try {
    // 1. 查询并锁定记录
    Record existingRecord = select ... where condition=X for update;

    if (existingRecord == null) {
        // 2a. 如果记录不存在,插入新记录
        insert into table ... values ...;
    } else {
        // 2b. 如果记录存在,更新记录
        update table set ... where condition=X;
    }

    // 3. 提交事务
    commit();
} catch (Exception e) {
    rollback();
    // 处理异常,比如死锁重试
}

按照通常的理解,如果 Session-1 执行了 SELECT ... WHERE condition=1 FOR UPDATE 并且还没释放锁,那么 Session-2 再执行同样的 SELECT ... FOR UPDATE (未使用 SKIP LOCKEDNOWAIT 的情况下),应该会阻塞等待,直到 Session-1 提交或回滚。

但是,实际测试发现:

  1. 当目标表 daily_statistic_data_2021 为空时:

    • Session-1 执行 SELECT ... FOR UPDATE 语句,立刻返回 ,结果集为空。
    • Session-2 执行同样的 SELECT ... FOR UPDATE 语句,也立刻返回 ,结果集为空。
    • 两个会话都认为记录不存在,于是都尝试执行 INSERT 操作。
    • 结果:发生了死锁!通常是一个会话插入成功,另一个会话失败并被回滚。而且,即使两个会话要插入的记录不同(比如 imei 不同),也会死锁。这和上面的 innodb status 日志吻合。

    空表时 SELECT ... FOR UPDATE 立即返回
    空表时 INSERT 导致死锁

  2. 当目标表不为空,且 SELECT ... FOR UPDATE 的条件能匹配到现有记录时:

    • Session-1 执行 SELECT ... FOR UPDATE 成功,锁住匹配的行。
    • Session-2 执行同样的 SELECT ... FOR UPDATE阻塞 ,等待 Session-1 释放锁。
    • 这种情况下,行为符合预期,不会发生上述那种奇特的死锁。

    非空表时行为正常

这现象表明,SELECT ... FOR UPDATE 在目标表为空(或者更准确地说,WHERE 条件没有匹配到任何行)时的加锁行为,似乎和匹配到行时有所不同。我们翻阅了 MySQL 官方文档关于 Locking Reads 的部分 (注意:这是 8.0 的链接,问题中提到了 8.4,行为可能略有不同,但基本原理类似),并没有找到直接解释这种空表场景下特定行为的说明。

深挖根源:为何空表行为如此诡异?

要理解这个现象,得先了解 InnoDB 的锁机制,特别是和 SELECT ... FOR UPDATE 相关的:

  1. 记录锁 (Record Locks): 锁定索引记录本身。如果 SELECT ... FOR UPDATEWHERE 条件精准匹配到了唯一索引或主键上的某一行,通常会加记录锁。
  2. 间隙锁 (Gap Locks): 锁定索引记录之间的“间隙”。比如你查询 WHERE id > 10 FOR UPDATE,即使 id=11 的记录不存在,InnoDB 也可能会锁定 (10, +∞) 这个范围内的间隙,防止其他事务在这个间隙里插入 id=11 的记录,从而避免“幻读”。Gap 锁之间通常是兼容的,即多个事务可以持有同一个间隙的 Gap 锁。
  3. Next-Key Locks: 记录锁 + 间隙锁的组合。锁定索引记录本身以及该记录之前的那个间隙。这是 InnoDB 在 REPEATABLE READ 隔离级别下的默认行锁类型。
  4. 插入意向锁 (Insert Intention Locks): 这是一种特殊的 Gap 锁。在插入一条记录之前,事务需要获取目标插入位置所在间隙的插入意向锁。如果这个间隙已经被其他事务加了 Gap 锁或 Next-Key 锁(非插入意向锁类型的),那么插入操作就得等待。多个事务如果要在同一个间隙插入 不同 的记录,它们各自获取插入意向锁时通常是不会互相阻塞的。但如果它们要插入的位置冲突,或者等待的间隙锁类型冲突,就会阻塞。

现在,我们来尝试解释空表时的怪现象:

  • 步骤 1 & 2: 当 Session-1 和 Session-2 执行 SELECT ... FOR UPDATE 时,由于表是空的(或者 WHERE 条件没有匹配到任何行),InnoDB 找不到可以加记录锁 (Record Lock) 的具体行。
  • 关键点: 在这种情况下,为了满足 FOR UPDATE 的锁定语义(特别是防止幻读),InnoDB 仍然需要加锁。它通常会在相关索引上加一个或多个 Gap 锁Next-Key 锁 。从 innodb status 的输出 lock_mode X locks gap before rec 来看,它们都在索引 daily_statistic_data_unique 上获得了一个 X 模式(排他模式)的 Gap 锁 。这个 Gap 锁可能覆盖了表中所有可能插入新记录的范围,或者至少是根据 WHERE 条件推断出的第一个潜在记录之前的那个“超级大间隙”。因为是 Gap 锁,并且可能两个事务一开始获取的锁(或者锁检查的阶段)相互兼容(或者说,因为没有实际记录冲突,检查通过),所以两个 SELECT 语句都 立即返回 了,没有互相阻塞。这解释了为什么两个事务能同时往下执行。
  • 步骤 3: 两个事务现在都发现没有记录存在,于是都决定执行 INSERT
  • 步骤 4: 冲突点!
    • 事务 1 (trx 3165095) 尝试插入 imei = '861213052219265'。这需要在 daily_statistic_data_unique 索引上对应的间隙获取一个 插入意向锁 (Insert Intention Lock) 。我们暂且称这个所需的锁为 II_Lock_1
    • 事务 2 (trx 3165096) 尝试插入 imei = '861213050685368'。这同样需要在 daily_statistic_data_unique 索引上(可能是同一个间隙,因为表是空的,两个 imei 相对靠近)获取一个 插入意向锁 。称之为 II_Lock_2
  • 死锁形成:
    • 还记得吗?在 SELECT ... FOR UPDATE 阶段,事务 1 和事务 2 都已经各自持有了覆盖目标插入区域的 X 模式 Gap 锁 (我们称之为 Gap_Lock_1Gap_Lock_2,虽然 innodb status 里看起来它们指向同一个 "record",但它们是两个事务持有的不同锁实例)。
    • 现在,事务 1 想要获取 II_Lock_1,但是 II_Lock_1 与事务 2 持有的 Gap_Lock_2 存在冲突(插入意向锁需要等待间隙上的 X Gap 锁)。所以,事务 1 开始等待事务 2 释放 Gap_Lock_2
    • 同时,事务 2 想要获取 II_Lock_2,这与事务 1 持有的 Gap_Lock_1 存在冲突。所以,事务 2 开始等待事务 1 释放 Gap_Lock_1
    • 这样就形成了经典的死锁:事务 1 等待事务 2,事务 2 等待事务 1。
  • 解读 innodb status: innodb status 的输出有点绕。当它说事务 1 "HOLDS THE LOCK(S)" 指向某个 "record" 并且 "WAITING FOR THIS LOCK TO BE GRANTED" 也指向同一个 "record" (with insert intention waiting) 时,它实际表达的是:
    • Holds: 事务 1 持有由 SELECT ... FOR UPDATE 在空表(或无匹配行)上产生的那个 X Gap 锁 (Gap_Lock_1),这个锁覆盖了即将插入记录的位置,日志里用那个 "PHYSICAL RECORD" 的描述来代表这个锁定的间隙或边界。
    • Waiting: 事务 1 在尝试获取插入意向锁 (II_Lock_1) 时,发现该意向与事务 2 持有的 X Gap 锁 (Gap_Lock_2) 冲突了。因为 Gap_Lock_2 逻辑上也覆盖了同一个区域(由同一个 "PHYSICAL RECORD" 描述代表),所以看起来就像事务 1 在等待“自己”持有的锁,但实际上它等待的是另一个事务 持有的、覆盖相同范围 的 Gap 锁。对事务 2 来说也是同理。

这个现象的核心在于:SELECT ... FOR UPDATE 在没有匹配到行时加的 Gap 锁,与后续 INSERT 操作需要的插入意向锁之间,在并发场景下形成了锁等待的循环。

对症下药:如何化解僵局?

既然知道了问题根源,解决起来就有的放矢了。以下是一些常见的解决方案,各有优劣:

方案一:利用唯一约束 + INSERT IGNORE / ON DUPLICATE KEY UPDATE (推荐)

这是解决 "select-then-insert/update" 模式下并发问题的经典方案,也是通常推荐的做法。

  • 原理: 不再依赖 SELECT ... FOR UPDATE 来做并发控制。而是直接尝试 INSERT,利用数据库本身的唯一约束 来保证数据的一致性。如果插入成功,皆大欢喜;如果违反了唯一约束(说明记录已存在),则根据业务需要选择忽略插入 (IGNORE) 或者执行更新操作 (ON DUPLICATE KEY UPDATE)。数据库在检查约束和执行插入/更新时,其内部机制能够原子性地处理并发冲突。
  • 操作步骤:
    1. 确保 daily_statistic_data_2021 表上存在一个合适的唯一索引(或主键),能够覆盖你需要检查重复的字段组合(比如 (imei, year, month, day))。从 innodb status 中的 index daily_statistic_data_unique 看,似乎已经存在这样的唯一索引。如果不存在,需要添加:
      -- 假设需要基于 imei, year, month, day 保证唯一
      ALTER TABLE daily_statistic_data_2021
      ADD UNIQUE KEY `idx_unique_daily_stats` (`imei`, `year`, `month`, `day`);
      
    2. 修改 Java 代码逻辑,去掉 SELECT ... FOR UPDATE,直接执行 INSERT
      • 方式 A: 使用 INSERT IGNORE (如果记录已存在就忽略,不报错也不更新)
        -- SQL 语句示例 (注意:是 INSERT IGNORE)
        INSERT IGNORE INTO `daily_statistic_data_2021`
            (`imei`,`last_calculate_time`, /*...其他列...*/ )
        VALUES
            (?, ?, /*...其他值...*/ );
        
        -- 如果插入成功 (返回的 affected rows > 0),说明是新记录。
        -- 如果插入被忽略 (affected rows = 0),说明记录已存在。
        -- 如果需要“如果存在则更新”,这种方式就不合适了,看方式 B。
        
        注意: INSERT IGNORE 会将违反唯一约束的错误转化为一个警告,并且不会插入数据。后续你需要判断受影响行数来确定是否插入成功。这种方式比较简单,但可能隐藏其他插入问题。
      • 方式 B: 使用 INSERT ... ON DUPLICATE KEY UPDATE (推荐,如果记录已存在则执行更新)
        -- SQL 语句示例
        INSERT INTO `daily_statistic_data_2021`
            (`imei`, `last_calculate_time`, `year`, `month`, `day`, `di1`, /*...其他列...*/ )
        VALUES
            (?, ?, ?, ?, ?, ?, /*...其他值...*/ )
        ON DUPLICATE KEY UPDATE
            `last_calculate_time` = VALUES(`last_calculate_time`), -- 使用 VALUES() 获取新传入的值
            `di1` = `di1` + VALUES(`di1`), -- 例子:累加某个值
            -- ... 其他需要更新的列 ...
            `some_other_column` = ?; -- 也可以直接提供更新的值
        
        这种方式最为常用,能在一个语句里原子性地完成“插入或更新”的操作。MySQL 会处理好底层的锁,避免我们之前遇到的死锁问题。
  • 安全建议: 确保唯一索引的选择是恰当的,真正能反映业务上的唯一性要求。
  • 进阶使用技巧: ON DUPLICATE KEY UPDATE 子句中可以使用 VALUES(column_name) 来引用 INSERT 部分提供的值,也可以直接赋常量或表达式。对于需要累加或复杂更新逻辑的场景,这非常有用。

方案二:降低事务隔离级别

当前的死锁与 Gap 锁密切相关,而 Gap 锁主要是 InnoDB 在 REPEATABLE READ(可重复读)隔离级别下为了防止幻读引入的。

  • 原理: 将事务隔离级别降低到 READ COMMITTED(读已提交)。在此隔离级别下,InnoDB 通常不会使用 Gap 锁(除了外键约束检查和唯一性检查等少数情况),SELECT ... FOR UPDATE 只会锁定实际匹配到的行(加记录锁),找不到行就不加锁(或只加非常短暂的锁)。这样,空表 SELECT ... FOR UPDATE 就不会加持久的 Gap 锁,也就破坏了死锁形成的条件。
  • 操作步骤:
    1. 在数据库连接层面或者事务开始前设置隔离级别:
      -- 针对当前会话设置
      SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
      
      -- 或者在事务开始时设置 (注意语法,如果需要)
      -- SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
      START TRANSACTION;
      -- ... 执行你的 select for update, insert/update 逻辑 ...
      COMMIT;
      
    2. Java 代码层面,可以在获取数据库连接后设置,或者通过配置数据源(如 HikariCP, Druid)来设定默认的事务隔离级别。
  • 风险与考量:
    • 可能出现幻读: 这是降低隔离级别的主要代价。在一个事务内,两次执行相同的范围查询,可能会得到不同的结果集(因为其他事务可能在你两次查询之间插入了新的、符合范围的记录)。需要评估你的业务逻辑是否能接受幻读。对于我们这个 "select-then-insert/update" 场景,如果后续操作只依赖于查询到的那条记录是否存在,幻读影响可能不大。但如果事务内还有其他依赖范围稳定性的查询,就需要小心了。
    • 二进制日志格式: 如果使用了基于语句的复制 (Statement-Based Replication, SBR),READ COMMITTED 可能会导致主从不一致。通常建议使用基于行的复制 (Row-Based Replication, RBR),或者至少是 MIXED 模式。MySQL 默认已经是 RBR 了,一般问题不大,但最好确认一下。
  • 安全建议: 充分评估业务场景是否能容忍幻读。确认 MySQL 的 binlog_format 设置为 ROWMIXED

方案三:显式锁定,但粒度要准

如果确实无法避免 SELECT ... FOR UPDATE,并且方案一、二不适用,可以尝试更精细地控制锁。

  • 原理: 避免在 SELECT ... FOR UPDATE 查询不到数据时产生过大的 Gap 锁。但这比较困难,因为 Gap 锁的行为不易精确控制。
  • 一个可能的思路 (不一定有效或通用): 尝试让 SELECT ... FOR UPDATE 的条件更具体,或者利用某些索引特性。比如,如果可以锁定一个必然存在的“哨兵”记录,再进行后续判断和操作。但这会增加复杂性,且不一定能解决根本问题。
  • 或者尝试 SELECT ... FOR UPDATE SKIP LOCKEDNOWAIT
    • 原理: 如果多个任务是处理不同 imei 的,并且不关心是否跳过已被其他事务锁定的记录,可以用 SKIP LOCKEDSELECT ... FOR UPDATE SKIP LOCKED 会跳过已经被其他事务锁定的行,而不是等待。NOWAIT 则是不等待,如果遇到锁就立刻报错。
    • 操作:
      SELECT ... WHERE condition=X FOR UPDATE SKIP LOCKED;
      
      或者
      SELECT ... WHERE condition=X FOR UPDATE NOWAIT;
      
    • 适用性: 这种方式改变了原有逻辑。SKIP LOCKED 适用于任务可以独立处理,跳过冲突记录也没关系的情况。NOWAIT 适用于需要快速失败并可能由应用层重试的场景。它们可能可以避免阻塞,但不一定能完全解决空表插入的死锁问题,因为两个事务可能依然能同时通过 SELECT 阶段(都没跳过对方,因为对方也没实际锁定行,只加了 Gap 锁),然后在 INSERT 时再次遭遇我们之前分析的 Gap 锁冲突。
  • 结论: 这个方向的解决方案通常比较复杂,且效果不确定,一般不作为首选。

方案四:悲观锁改乐观锁 (特定场景)

如果业务允许短暂的数据不一致,或者可以通过版本号控制并发。

  • 原理: 不使用数据库的 FOR UPDATE 锁。而是在表中增加一个 version 字段(通常是整数或时间戳)。
    1. SELECT 数据时不加锁,同时获取 version 字段。
    2. 应用层判断记录是否存在。
    3. 如果是更新操作,UPDATE 语句的 WHERE 子句要包含之前的 version 值:UPDATE ... SET ..., version = version + 1 WHERE id = ? AND version = old_version。如果 version 不匹配(说明在你 SELECT 后数据已被其他事务修改),则更新失败 (affected rows = 0),应用层需要处理冲突(如重试)。
    4. 如果是插入操作,直接 INSERT。可以用唯一约束来处理插入冲突(类似方案一)。
  • 适用性: 对于更新场景更有效。对于 "select-then-insert" 场景,主要还是依赖唯一约束来防止重复插入。这种方式可以减少数据库锁竞争,提高并发,但增加了应用层的复杂性。对于我们这个问题,主要矛盾是空表 INSERT 的并发冲突,乐观锁对这个场景帮助有限,最终还是需要唯一约束兜底。

选择哪种方案?

综合来看:

  • 首选推荐:方案一 (唯一约束 + INSERT ... ON DUPLICATE KEY UPDATE) 。这是最符合 SQL 思想、最简洁、也通常是性能最好的方案,专门用来解决这类“插入或更新”的并发问题。它把并发控制的难题交给了数据库本身,让应用层代码更干净。
  • 次选考虑:方案二 (降低隔离级别到 READ COMMITTED) 。如果能接受幻读的可能性,并且确认 binlog 格式没问题,这也是一个有效的方案,能直接避免 Gap 锁带来的问题。
  • 谨慎使用:方案三 (显式锁定优化) 。通常比较tricky,不一定能完美解决,且可能引入新的复杂性。
  • 场景限制:方案四 (乐观锁) 。主要用于更新冲突检测,对解决空表插入并发冲突的帮助不大,最终还是要结合唯一约束。

对于问题中描述的场景,看起来方案一是最直接、最稳妥的解决之道。通过 INSERT ... ON DUPLICATE KEY UPDATE 可以原子地完成“如果不存在就插入,如果存在就更新”的逻辑,有效避免了因 SELECT ... FOR UPDATE 在空表上加 Gap 锁而引发的并发插入死锁。