"为什么我的 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。
避免死锁的实战技巧
- 统一加锁顺序:所有事务按相同顺序访问资源(例如总是 id 小的先锁)。
- 缩小事务范围:事务越短,持锁时间越短,死锁概率越低。
- 避免在事务中等用户输入:用户思考几秒,锁占着,死锁灾难。
- 使用合适的索引:别让锁退化到表锁。
- 降低隔离级别:RC 比 RR 锁少,死锁更少;但要业务能接受幻读。
- 重试机制:死锁是正常现象,业务代码遇到要重试。
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)
分析方法:
- 看每个事务"HOLDS"什么锁、"WAITING FOR"什么锁。
- 对照 SQL 反推每个事务执行到哪一步。
- 找两个事务的"加锁顺序差异"—— 那就是死锁原因。
意向锁的作用
当事务想给表加 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