这是《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;
原则:
- UPDATE / DELETE 必须走精准索引;
- 避免索引列函数化;
- 类型必须匹配;
- 事务前检查 rows;
- 大范围更新分批;
- 生产执行前在影子库验证锁。
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 争抢同一批任务。
更稳的流程:
- 唯一任务号防重复;
- 抢占和状态更新同事务;
- 设置执行超时;
- 支持重试和死信;
- 任务结果幂等写回;
- 监控积压和失败。
本章小结
InnoDB 行级锁作用在索引记录和间隙上。唯一索引等值命中可以退化为记录锁;非唯一索引和范围查询可能使用 Gap Lock 或 Next-Key Lock;RR 为防止幻读会使用更大范围,RC 通常更少使用间隙锁。生产写操作必须确保执行计划精准,否则“锁一行”可能变成锁一批。
思考题
- Record Lock、Gap Lock、Next-Key Lock 分别锁什么范围?
- 为什么唯一索引等值命中可能只加记录锁?
- 为什么 RR 下不存在的等值查询可能阻塞插入?
- RC 为什么通常能减少间隙锁冲突?
- 用两个会话复现一个插入等待,并用
data_locks解释锁范围。