InnoDB 锁机制完全指南:Next-Key Lock、间隙锁与死锁排查

"为什么我的 UPDATE 把整张表锁了?""死锁日志里的 Next-Key Lock 是什么?""间隙锁怎么避免?" —— InnoDB 锁机制是 MySQL DBA 的入门必考题。这篇文章把 InnoDB 各种锁讲透:行锁、间隙锁、Next-Key Lock、意向锁、记录锁,以及它们在不同 SQL 下的实际行为。所有结论都配可复现的 SQL。

锁的两个维度

1. 锁模式:S 锁 vs X 锁

  • 共享锁(S Lock):允许其他事务也加 S 锁,不允许加 X 锁。多读共存。
  • 排他锁(X Lock):不允许其他事务加任何锁。独占。
SELECT ... LOCK IN SHARE MODE;    -- S 锁
SELECT ... FOR UPDATE;             -- X 锁
INSERT / UPDATE / DELETE;          -- 内部加 X 锁

2. 锁粒度:行锁 vs 表锁

InnoDB 主推行锁,但某些场景必须表锁(DDL、LOCK TABLES、AUTO-INC 锁)。意向锁(IS / IX)是表级别的"有意要加行锁"的标记,避免事务 A 拿了行锁,事务 B 来加表锁时还要逐行检查。

三种行锁类型

1. 记录锁(Record Lock)

锁一条具体的索引记录:

SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 锁住 id=1 这一行

2. 间隙锁(Gap Lock)

锁住索引记录之间的"空隙",阻止其他事务在该空隙插入新行:

-- users 表 age 字段有索引,当前数据:age = 20, 30, 40
SELECT * FROM users WHERE age > 25 AND age < 35 FOR UPDATE;
-- 锁住 (20, 30) 和 (30, 40) 这两个间隙(简化看 -> (25,35) 区间)
-- 此时其他事务 INSERT INTO users (age) VALUES (28) 会被阻塞

间隙锁只在 REPEATABLE READ 级别下出现,READ COMMITTED 下没有(因为 RC 允许幻读)。它专门用来阻止幻读。

3. Next-Key Lock

记录锁 + 间隙锁的组合,InnoDB 在 RR 下范围查询的默认锁。锁住"一个区间(左开右闭)":

当前 age 索引:..., 20, 30, 40, ...
SELECT * FROM users WHERE age = 30 FOR UPDATE;
-- Next-Key Lock 锁住区间 (20, 30] —— 不只是 age=30
-- 还包含 20 到 30 之间的间隙,阻止 INSERT age=25

实战:锁分析常用 SQL

-- 看当前锁等待
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- 看哪些事务在跑
SELECT * FROM information_schema.innodb_trx;

-- 看锁等待详情(MySQL 8+)
SELECT * FROM sys.innodb_lock_waits;

-- 死锁日志
SHOW ENGINE INNODB STATUS\G   -- LATEST DETECTED DEADLOCK 段
-- 或:
SET GLOBAL innodb_print_all_deadlocks = ON;   -- 写到 error log

典型加锁场景

InnoDB 加锁规则极其复杂,实战经验:

1. 主键等值 + 唯一索引

SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 命中:只加记录锁
-- 未命中(假设 id=1 不存在):加间隙锁 (?, ?) —— 阻止插入 id=1

2. 普通索引等值

-- age 是非唯一索引,有多条 age=30
SELECT * FROM users WHERE age = 30 FOR UPDATE;
-- 锁住所有 age=30 的行(记录锁)
-- 同时锁前后 gap,阻止插入 age=30 的新行(Next-Key Lock)

3. 范围查询

SELECT * FROM users WHERE age BETWEEN 25 AND 35 FOR UPDATE;
-- 锁住 [25, 35] 范围内的所有记录和间隙

4. 没有索引

SELECT * FROM users WHERE name = 'Alice' FOR UPDATE;   -- name 无索引
-- 退化为全表扫描 + 全表逐行加 X 锁 -> 几乎等于锁表
-- 必须避免!

规则:WHERE 条件必须能用上索引,否则锁会失控

死锁

两个事务互相等对方持有的锁,形成循环等待:

-- 事务 A:
UPDATE users SET ... WHERE id = 1;    -- 锁 id=1
-- 事务 B(并发):
UPDATE users SET ... WHERE id = 2;    -- 锁 id=2
-- 事务 A:
UPDATE users SET ... WHERE id = 2;    -- 等 B 释放
-- 事务 B:
UPDATE users SET ... WHERE id = 1;    -- 等 A 释放 -> 死锁!

InnoDB 检测到死锁后主动回滚 cost 较小的事务(rollback 的工作量小的),报 Deadlock found when trying to get lock

避免死锁的实战技巧

  1. 统一加锁顺序:所有事务按相同顺序访问资源(例如总是 id 小的先锁)。
  2. 缩小事务范围:事务越短,持锁时间越短,死锁概率越低。
  3. 避免在事务中等用户输入:用户思考几秒,锁占着,死锁灾难。
  4. 使用合适的索引:别让锁退化到表锁。
  5. 降低隔离级别:RC 比 RR 锁少,死锁更少;但要业务能接受幻读。
  6. 重试机制:死锁是正常现象,业务代码遇到要重试。

SHOW INNODB STATUS 解读死锁日志

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec, status: LOCK WAIT
HOLDS THE LOCK(S):
RECORD LOCKS space id 5 page no 4 n bits 80 index PRIMARY of table `t` ...
WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 5 page no 5 n bits 80 index PRIMARY of table `t` ...
*** (2) TRANSACTION:
...
*** WE ROLL BACK TRANSACTION (2)

分析方法:

  1. 看每个事务"HOLDS"什么锁、"WAITING FOR"什么锁。
  2. 对照 SQL 反推每个事务执行到哪一步。
  3. 找两个事务的"加锁顺序差异"—— 那就是死锁原因。

意向锁的作用

当事务想给表加 LOCK TABLES 时,它需要知道"表里有没有人持有行锁"。逐行扫描太慢,所以 InnoDB 用意向锁:

  • 事务对行加 S 锁前,先在表上加 IS(意向共享)锁。
  • 事务对行加 X 锁前,先在表上加 IX(意向排他)锁。
  • 表级 S 锁和 IX 不兼容,X 锁和 IS / IX 都不兼容。

所以 LOCK TABLES 来加表 X 锁时,只要看表上有没有 IS / IX 就知道。普通行操作不会感知到意向锁。

插入意向锁(Insert Intention Lock)

特殊的间隙锁,INSERT 时使用。两个 INSERT 在同一间隙不冲突 —— 否则一张表的并发插入会非常糟糕:

事务 A:INSERT age=25     (在间隙 (20,30) 加插入意向锁)
事务 B:INSERT age=27     (同一间隙,加插入意向锁,不冲突,可以同时插)

但:事务 C:SELECT ... WHERE age BETWEEN 21 AND 29 FOR UPDATE
    在该范围加间隙锁 -> A 和 B 都会被阻塞

AUTO-INC 锁

自增主键的特殊锁,保证 ID 不冲突。innodb_autoinc_lock_mode 控制:

  • 0:传统模式,每条 INSERT 都加表级 AUTO-INC 锁,直到结束。批量插入慢
  • 1:连续模式(MySQL 5.7 默认),简单 INSERT 用轻量锁,批量 INSERT 用表锁。
  • 2:交错模式(MySQL 8 默认),所有 INSERT 都用轻量锁,但 ID 可能不连续。要求 binlog_format=ROW。

常见误解

误解 1:"UPDATE 只锁更新的行"。错,WHERE 条件命中的所有行都加 X 锁,且如果走索引扫描,扫到的间隙也可能加锁。

误解 2:"RC 级别没有间隙锁"。正确。RC 几乎只有记录锁,所以死锁少很多 —— 这也是高并发业务常调到 RC 的原因。

误解 3:"SELECT 不加锁"。正确 —— 普通 SELECT 走 MVCC,不加锁。但 FOR UPDATE / IN SHARE MODE 是当前读,加锁。

误解 4:"InnoDB 行锁锁的是行"。错,锁的是索引记录。所以没有索引时退化为表扫描 + 全表锁。

实战:解决一个常见死锁

-- 业务代码并发执行:
-- 事务 A: UPDATE items SET stock = stock - 1 WHERE id = 1;
--        UPDATE items SET stock = stock - 1 WHERE id = 2;
-- 事务 B: UPDATE items SET stock = stock - 1 WHERE id = 2;
--        UPDATE items SET stock = stock - 1 WHERE id = 1;
-- 容易死锁

-- 修复:统一顺序(按 id 升序)
-- 业务层把要更新的 id 排序后再 UPDATE
ids = sorted([1, 2])
for id in ids:
    UPDATE items SET stock = stock - 1 WHERE id = ?
-- 死锁概率大幅下降

常见死锁场景图解

1. 经典两资源死锁

事务 A:
  UPDATE orders SET ... WHERE id=1;  -- 拿到 id=1 的 X 锁
  UPDATE orders SET ... WHERE id=2;  -- 等 id=2 释放

事务 B:
  UPDATE orders SET ... WHERE id=2;  -- 拿到 id=2 的 X 锁
  UPDATE orders SET ... WHERE id=1;  -- 等 id=1 释放 -> DEADLOCK

2. 索引冲突死锁

表 users 有索引 (status)。两个事务都做 UPDATE WHERE status='active'。
事务 A 用索引扫描,锁了 status='active' 行 1, 2, 3
事务 B 反向扫描(取决于 optimizer),锁了 status='active' 行 3, 2, 1
-> 死锁

修复:在 UPDATE 中显式按主键 ID 范围,而不是按 status

3. INSERT 与唯一索引冲突

users 表 email 唯一索引

事务 A:INSERT 行(email='x@x.com')  -- 拿到 email='x@x.com' 的插入意向锁
                                       -- 但在等"间隙锁"(因为 unique 索引要查重)

事务 B:也 INSERT email='x@x.com'   -- 等 A 的间隙锁

事务 C:INSERT email='x@x.com'      -- 也在等

如果 A 这时 ROLLBACK,B 和 C 抢谁先插入?可能死锁。

这是 INSERT 唯一索引场景下最容易出的死锁。
解决:用 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE。

锁超时与死锁检测

-- 锁等待超时(默认 50 秒)
SET innodb_lock_wait_timeout = 5;   -- 调短,业务能更快失败而非长时间等

-- 死锁检测(默认开)
SET innodb_deadlock_detect = ON;

-- 极高并发场景关闭死锁检测,改用超时
-- 因为死锁检测本身在持锁数大时是 O(N²)
SET innodb_deadlock_detect = OFF;
SET innodb_lock_wait_timeout = 1;   -- 1 秒超时

关闭死锁检测的代价:真死锁要等超时才放,期间锁占着。但在万级并发热点更新的场景,死锁检测开销可能比死锁本身更大。

实战调优:减少锁影响

1. 分散热点行

-- 全网计数器是单行,所有人 UPDATE counters SET cnt = cnt + 1 WHERE id=1
-- 大并发下竞争极严重

-- 改造:分桶
CREATE TABLE counters_buckets (
    id INT, bucket INT, cnt BIGINT, PRIMARY KEY (id, bucket)
);
INSERT INTO counters_buckets VALUES (1, 0, 0), (1, 1, 0), ..., (1, 99, 0);

-- 写入时随机选 bucket
UPDATE counters_buckets SET cnt = cnt + 1 WHERE id=1 AND bucket=RAND()*100;

-- 读时聚合
SELECT SUM(cnt) FROM counters_buckets WHERE id=1;

2. 缩短事务

事务里只做必要的数据库操作,业务计算 / 远程调用 / 用户交互全部放事务外。

3. 避免长扫描

WHERE 条件能用索引就用索引,避免全表扫描带来的整表加锁。

4. 用 RC 替代 RR(在能接受幻读的业务)

RC 没有间隙锁,死锁概率大幅降低。互联网高并发业务很多用 RC。

SELECT FOR UPDATE 的实战模式

1. 库存扣减的安全写法

-- 错的写法(并发下超卖):
SELECT stock FROM items WHERE id = 1;   -- 应用拿到 stock=5
-- 应用判断 stock > 0 -> OK
UPDATE items SET stock = stock - 1 WHERE id = 1;

-- 两个用户同时 stock=5 -> 都判断 OK -> 都 -1 -> 最终 stock=3 但只下了 1 单
-- 卖了 1 件实际扣了 2,要么超卖要么少卖

-- 对的写法:加锁
START TRANSACTION;
SELECT stock FROM items WHERE id = 1 FOR UPDATE;  -- 加 X 锁
-- 应用判断
UPDATE items SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 更简洁:用 SQL 直接判断扣减
UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 检查返回行数,0 行说明库存不足

2. 分布式 ID 生成的号段模式

-- 一次取一段 ID,避免每次 ID 都加锁
START TRANSACTION;
SELECT current FROM id_sequence WHERE biz_type = 'order' FOR UPDATE;
UPDATE id_sequence SET current = current + 1000 WHERE biz_type = 'order';
COMMIT;

-- 应用内存里维护 [start, start+1000),用完再取下一段

意向锁的并发性

多个事务可以同时持有 IS 或 IX(只要表上没有别人的 S / X):

事务 A:对行 1 加 X 锁 -> 表上 IX
事务 B:对行 2 加 X 锁 -> 表上 IX(和 A 的 IX 兼容)
事务 C:LOCK TABLES t WRITE -> 想加表 X 锁 -> 看到 IX 存在,等待

意向锁不冲突彼此,只是给"想加表级锁的事务"看的标识。这种设计让"多行级并发 + 偶尔表级操作"既正确又高效。

性能优化:lock granularity

1. 用更细的锁粒度

把"一行锁所有用户"的设计改成"每个用户一行"。例如全局计数器拆成 100 个 bucket。

2. 减少锁持有时间

-- 不好:事务里做远程调用
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1;
sendNotification(...)   -- 网络调用,几百 ms,锁一直占着
COMMIT;

-- 好:把异步操作放事务外
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1;
INSERT INTO outbox (event_type) VALUES ('OrderPaid');
COMMIT;
-- 之后后台进程处理 outbox 发通知

3. 用乐观锁替代悲观锁

对竞争不严重的场景,乐观锁(版本号)比 FOR UPDATE 高效:

UPDATE items SET stock = ?, version = version + 1
WHERE id = ? AND version = ?
-- 返回 0 行 -> 别人改过了,重试

排查工具:performance_schema

-- 启用更多锁监控
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME LIKE 'wait/lock/%';

-- 查实时锁等待
SELECT * FROM sys.innodb_lock_waits;

-- 查某事务持有的锁
SELECT * FROM performance_schema.data_locks
WHERE ENGINE_TRANSACTION_ID = ?;

sys schema 是 MySQL 5.7+ 内置的"高级视图",把 performance_schema 的复杂数据封装成易读形式。生产 DBA 必备。

写在最后

InnoDB 锁是 MySQL 高并发表现的核心,也是踩坑最密集的领域。规则极其精细,且不同 SQL 模式下不一致。不要试图把所有规则背下来,而是遇到具体场景查文档 + 验证。生产经验:写关键 SQL 后用 EXPLAIN 看执行计划,用 performance_schema 看实际加锁,确认行为符合预期。死锁出现时,认真读 InnoDB Status 而不是简单重试 —— 大部分死锁可以通过调整加锁顺序根治。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MVCC 完全指南:从 ReadView 到 InnoDB 多版本并发控制

2026-5-15 16:26:44

技术教程

Redis 完全指南:从 5 种数据结构到 Cluster 部署实战

2026-5-15 16:26:44

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索