背景与症状

在一次常规操作中,一条 INSERT 语句(目标 id=664)被长时间阻塞,最后在 Go 应用层报错 invalid connection

1
2
3
INSERT INTO `chip_info`(`info`,`display_order`,`id`)
VALUES ('{...}', 519, 664)
ON DUPLICATE KEY UPDATE `info`=VALUES(`info`), `display_order`=VALUES(`display_order`);

最终排查定位,阻塞的根源是 MySQL 的 Gap 锁(间隙锁)。通过终止持有该锁的悬挂事务,操作立即恢复正常。

Gap 锁与 Next-Key 锁的定义

  • Gap 锁 (Gap Lock):这是一种锁机制,它锁定的不是具体的某一行记录,而是索引记录之间的"间隙"。其唯一目的是防止其他事务在这个间隙中执行 INSERT 操作。
  • Next-Key 锁 (Next-Key Lock):这是 InnoDB 在 REPEATABLE READ 隔离级别下的默认锁策略。它本质上是行锁 (Record Lock) 与该行记录之前间隙的 Gap 锁的组合。Next-Key 锁是解决幻读问题的核心机制。

第一性原理:为什么需要 Gap 锁?

核心目标:实现可重复读 (Repeatable Read)

REPEATABLE READ 隔离级别下,数据库承诺在一个事务内,对同一条件的多次查询将返回完全相同的结果集。如果不存在 Gap 锁,并发的 INSERT 操作会破坏这一承诺,导致幻读 (Phantom Read)。

幻读场景示例 (无 Gap 锁的情况下)

  • T1: SELECT * FROM t WHERE id < 25; 返回 5 条记录。
  • T2: INSERT INTO t(id) VALUES (15); 并提交。
  • T1: 再次执行 SELECT * FROM t WHERE id <25;,此时将返回 6 条记录。事务 T1 内的查询结果集发生了变化,违反了可重复读的原则。

Gap 锁的解决方案

为了防止幻读,InnoDB 引入了 Gap/Next-Key 锁。当事务 T1 执行范围查询时,InnoDB 不仅会锁住满足条件的已有行,还会锁住查询范围内的所有"间隙"。这样,事务 T2 的 INSERT 操作因无法在锁定的间隙中插入数据而被阻塞,直到 T1 提交或回滚,从而保证了 T1 的查询结果一致性。

理论与实践的平衡

Gap 锁可以看作是理论上谓词锁 (Predicate Lock) 的一种工程化、高性能的近似实现。它通过锁定索引区间来间接实现对查询谓词的保护。同时,这种机制也保证了基于语句的复制(SBR)在主从环境下执行结果的确定性。

Gap 锁的触发场景

Gap 锁的产生与隔离级别索引使用查询类型密切相关。

REPEATABLE READ 隔离级别下

  • 范围查询:执行 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETE 时,若 WHERE 条件是范围扫描(如 ><BETWEEN),会锁定扫描过的索引区间。
  • 唯一索引等值查询未命中:当使用唯一索引(包括主键)进行等值查询,但该记录不存在时,为防止并发插入该值,InnoDB 会在对应位置加上 Gap 锁。

READ COMMITTED 隔离级别下

  • 该级别下默认禁用 Gap 锁,因此大大减少了阻塞概率。
  • 但在外键约束检查和唯一性检查这两种特殊场景下,为了保证数据一致性,仍然可能会产生 Gap 锁。

诊断与定位方法 (MySQL 8.0+)

当怀疑发生 Gap 锁阻塞时,可以通过以下视图进行诊断:

  1. 查询锁等待关系

    1
    SELECT * FROM performance_schema.data_lock_waits;

    该表直接展示了哪个事务正在等待哪个事务所持有的锁。

  2. 查询活跃事务

    1
    SELECT * FROM information_schema.INNODB_TRX;

    该表列出了所有当前正在运行的事务及其状态、执行的 SQL 等信息。

  3. 关联查询(推荐):

    通过以下查询可以将事务信息与锁信息关联,快速定位持有锁的事务 ID (trx_id) 和其对应的数据库连接 ID (trx_mysql_thread_id)。

    1
    2
    3
    4
    5
    6
    7
    SELECT
    t.trx_id, t.trx_state, t.trx_started, t.trx_mysql_thread_id, t.trx_query
    FROM information_schema.INNODB_TRX t
    JOIN performance_schema.data_locks dl
    ON t.trx_id = dl.ENGINE_TRANSACTION_ID
    WHERE dl.LOCK_STATUS = 'GRANTED' -- 找到持有锁的事务
    ORDER BY t.trx_started;

应急解决方案

定位到持有锁的事务后,最直接的解决方法是终止其数据库连接。

  1. 获取连接 ID (thread_id):

    通过上述诊断查询,找到 trx_mysql_thread_id

  2. 终止连接

    1
    KILL [trx_mysql_thread_id]; -- 将 ID 替换为实际值

    执行 KILL 命令后,该事务会立即回滚,释放其持有的所有锁,从而解决阻塞问题。

如何规避 Gap 锁问题

  • 缩短事务生命周期:保持事务简短,尽快 COMMITROLLBACK,减少锁的持有时间。
  • 选择合适的隔离级别:如果业务逻辑允许,将隔离级别设置为 READ COMMITTED 是最有效的规避方法。
  • 优化查询,精准锁定
    • 尽量使用唯一索引进行等值查询和更新,避免范围扫描。
    • 确保查询条件能够命中高效的索引,避免因索引不当导致锁范围扩大。
  • 谨慎使用锁定读:仅在必要时使用 SELECT ... FOR UPDATE,并确保 WHERE 条件尽可能精确。
  • 设置锁等待超时:合理配置 innodb_lock_wait_timeout,避免应用因长时间等待锁而无响应。