MySQLNotes

第 19 章:行锁、间隙锁与 Next-Key Lock

zjc 于 2026-01-19 发布

这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 InnoDB 常被称为行锁引擎,但严格来说,它锁的是索引记录和索引记录之间的间隙。理解这一点,才能解释为什么有些 UPDATE 锁住一行,有些查询却阻塞插入。

19.1 实验准备

创建表:

CREATE TABLE lock_demo (
    id INT PRIMARY KEY,
    value INT NOT NULL,
    KEY idx_value (value)
) ENGINE=InnoDB;

INSERT INTO lock_demo VALUES
(10, 100),
(20, 200),
(30, 300);

打开两个 MySQL 客户端,分别称为 Session A 和 Session B。

19.2 Record Lock

按主键等值更新存在的行:

-- Session A
START TRANSACTION;

UPDATE lock_demo
SET value = 101
WHERE id = 10;

查看锁:

SELECT
  INDEX_NAME,
  LOCK_TYPE,
  LOCK_MODE,
  LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'lock_demo';

尝试修改同一行:

-- Session B
START TRANSACTION;

UPDATE lock_demo
SET value = 102
WHERE id = 10;
-- 等待

修改另一行:

UPDATE lock_demo
SET value = 201
WHERE id = 20;
-- 正常执行

提交:

-- Session A
COMMIT;

-- Session B
COMMIT;

这就是典型的记录锁。注意:二级索引列也被更新,因此锁不只涉及主键索引。

19.3 Gap Lock

间隙锁锁定索引记录之间的开区间,主要在 RR 隔离级别下防止幻影插入。

索引 idx_value 当前值:

100, 200, 300

等值查询不存在的值:

-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

SELECT *
FROM lock_demo
WHERE value = 150
FOR UPDATE;

尝试插入落在 (100, 200) 的值:

-- Session B
START TRANSACTION;

INSERT INTO lock_demo VALUES (15, 150);
-- 等待

原因:

value=150 不存在;
InnoDB 锁住 (100, 200) 间隙;
B 的 150 正好落入该间隙;

插入不落在该间隙的值通常不受影响:

INSERT INTO lock_demo VALUES (40, 400);

提交或回滚两个会话后释放锁。

19.4 Next-Key Lock

Next-Key Lock 是记录锁和它前面间隙锁的组合,区间为左开右闭:

(value=100, value=200]

范围锁定:

-- Session A
START TRANSACTION;

SELECT *
FROM lock_demo
WHERE value BETWEEN 100 AND 200
FOR UPDATE;

可能锁定:

(前一个值, 100] 和 (100, 200]

尝试插入:

-- Session B
INSERT INTO lock_demo VALUES (5, 50);    -- 可能不受影响
INSERT INTO lock_demo VALUES (15, 150);  -- 等待
INSERT INTO lock_demo VALUES (25, 250);  -- 取决于实际锁定边界

真实边界与执行计划、索引、条件和隔离级别有关,必须通过 data_locks 查看确认。

19.5 唯一索引等值命中

主键或唯一索引等值命中已存在记录时,InnoDB 可以退化成记录锁,不需要锁间隙来防止同一唯一键再次插入。

-- Session A
START TRANSACTION;

SELECT *
FROM lock_demo
WHERE id = 10
FOR UPDATE;

Session B:

INSERT INTO lock_demo VALUES (15, 150);
-- 通常可以执行,因为没有落入主键 10 的唯一记录锁

但如果插入相同主键:

INSERT INTO lock_demo VALUES (10, 999);
-- 冲突或等待

前提是优化器确实选择唯一索引执行等值匹配。

19.6 非唯一索引等值查找

在非唯一索引上,即使值已存在,也可能需要 Next-Key Lock,因为同一值可能有多条记录,要防止再插入相同二级索引值。

准备:

CREATE TABLE lock_demo2 (
    id INT PRIMARY KEY,
    value INT NOT NULL,
    KEY idx_value (value)
) ENGINE=InnoDB;

INSERT INTO lock_demo2 VALUES
(1, 100), (2, 100), (3, 200);

执行:

-- Session A
START TRANSACTION;

SELECT *
FROM lock_demo2
WHERE value = 100
FOR UPDATE;

Session B:

INSERT INTO lock_demo2 VALUES (4, 100);
-- 可能等待

具体锁范围查看:

SELECT
  INDEX_NAME,
  LOCK_MODE,
  LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'lock_demo2';

19.7 RC 下的间隙锁

READ COMMITTED 通常不使用 Gap Lock / Next-Key Lock 来防止幻读,只锁匹配记录。

切换:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

实验:

-- Session A
START TRANSACTION;

SELECT *
FROM lock_demo
WHERE value = 150
FOR UPDATE;
-- Session B
INSERT INTO lock_demo VALUES (15, 150);
-- 通常可以执行

这是很多高并发系统选择 RC 的原因之一:锁范围更小,插入冲突和死锁更少。

19.8 Insert Intention Lock

插入意向锁是插入前的一种间隙锁请求。若目标间隙已被其他事务锁定,插入等待。

Session A 锁住 (100, 200);
Session B 要插入 150;
B 设置 insert intention lock 并等待;

多个事务向不同间隙或同间隙不同值插入时,如果彼此不冲突,可以并发;但目标间隙已被 Gap Lock 占用时会被阻塞。

19.9 锁与执行计划

如果 WHERE 条件没有索引:

UPDATE lock_demo
SET value = 999
WHERE id + 0 = 10;

优化器可能全表扫描,导致扫描到的记录和相关间隙被锁定,锁范围远大于“一行”。

查看计划:

EXPLAIN
SELECT *
FROM lock_demo
WHERE id + 0 = 10;

原则:

  1. UPDATE / DELETE 必须走精准索引;
  2. 避免索引列函数化;
  3. 类型必须匹配;
  4. 事务前检查 rows;
  5. 大范围更新分批;
  6. 生产执行前在影子库验证锁。

19.10 查看锁详情

查看锁:

SELECT
  ENGINE_TRANSACTION_ID,
  OBJECT_SCHEMA,
  OBJECT_NAME,
  INDEX_NAME,
  LOCK_TYPE,
  LOCK_MODE,
  LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = 'lock_demo'
ORDER BY ENGINE_TRANSACTION_ID, INDEX_NAME;

字段解释:

字段 说明
LOCK_TYPE TABLE 或 RECORD
LOCK_MODE X、S、X,GAP、X,REC_NOT_GAP 等
INDEX_NAME 锁住的索引
LOCK_DATA 索引记录或 supremum 值

常见 LOCK_MODE:

模式 含义
X,REC_NOT_GAP 仅记录锁
X,GAP 仅间隙锁
X Next-Key Lock
X,INSERT_INTENTION 插入意向
AUTO_INC 自增锁

查看等待关系:

SELECT
  r.ENGINE_TRANSACTION_ID AS waiting_trx,
  b.ENGINE_TRANSACTION_ID AS blocking_trx,
  r.OBJECT_NAME AS table_name,
  r.INDEX_NAME,
  r.LOCK_MODE,
  r.LOCK_DATA
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
  ON r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
JOIN performance_schema.data_locks b
  ON b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID;

19.11 任务队列实践

创建队列表:

CREATE TABLE task_queue (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    task_no VARCHAR(64) NOT NULL,
    status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
    owner VARCHAR(64) NULL,
    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    UNIQUE KEY uk_task_no (task_no),
    KEY idx_status_id (status, id)
) ENGINE=InnoDB;

多个 worker 抢任务:

START TRANSACTION;

SELECT id, task_no
FROM task_queue
WHERE status = 'PENDING'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

UPDATE task_queue
SET status = 'RUNNING',
    owner = 'worker-1'
WHERE id IN (...);

COMMIT;

应用需要记录 SELECT 到的 ID 并替换 IN (...)SKIP LOCKED 避免多个 worker 争抢同一批任务。

更稳的流程:

  1. 唯一任务号防重复;
  2. 抢占和状态更新同事务;
  3. 设置执行超时;
  4. 支持重试和死信;
  5. 任务结果幂等写回;
  6. 监控积压和失败。

本章小结

InnoDB 行级锁作用在索引记录和间隙上。唯一索引等值命中可以退化为记录锁;非唯一索引和范围查询可能使用 Gap Lock 或 Next-Key Lock;RR 为防止幻读会使用更大范围,RC 通常更少使用间隙锁。生产写操作必须确保执行计划精准,否则“锁一行”可能变成锁一批。

思考题

  1. Record Lock、Gap Lock、Next-Key Lock 分别锁什么范围?
  2. 为什么唯一索引等值命中可能只加记录锁?
  3. 为什么 RR 下不存在的等值查询可能阻塞插入?
  4. RC 为什么通常能减少间隙锁冲突?
  5. 用两个会话复现一个插入等待,并用 data_locks 解释锁范围。