MySQL 锁与事务——InnoDB 锁类型、MVCC、隔离级别、死锁排查
更新时间:2026-09-02。本文是数据库域第二篇,接 MySQL 索引原理。索引解决的问题是怎么查得快,锁与事务解决的问题是怎么查得对、不冲突。理解锁和 MVCC,才能写好并发事务、避免死锁和性能问题。
本文要回答的问题
- InnoDB 有哪些锁类型?行锁、间隙锁、临键锁、意向锁分别做什么?
- 什么情况下锁"升级"成表锁?什么情况下只锁一行?
- MVCC 怎么实现"读不阻塞写,写不阻塞读"?
- 四种隔离级别分别解决什么问题?MySQL 默认是什么?
- 死锁怎么产生的?怎么排查和避免?
- 实际场景:行锁变表锁、间隙锁锁住范围、MVCC 读不到最新数据——怎么分析?
一、InnoDB 锁类型
1. 锁的粒度
InnoDB 锁的粒度(从粗到细):
┌──────────────────────────────────────────────┐
│ 表锁(Table Lock) │
│ ┌────────────────────────────────────────┐ │
│ │ 意向锁(Intention Lock) │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ 行锁(Row Lock) │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ 共享锁(S) │ │ 排他锁(X) │ │ │ │
│ │ │ └────────────┘ └────────────┘ │ │ │
│ │ │ ┌────────────┐ ┌────────────┐ │ │ │
│ │ │ │ 间隙锁(GAP)│ │ 临键锁(NK) │ │ │ │
│ │ │ └────────────┘ └────────────┘ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘InnoDB 默认行锁:只在 WHERE 条件走了索引时才用行锁;如果没走索引,退化为表锁。
2. 共享锁(S Lock)与排他锁(X Lock)
| 锁类型 | 简称 | 含义 | 兼容性 |
|---|---|---|---|
| 共享锁 | S Lock | 读锁,允许其他事务读,禁止写 | S 兼容 S,S 不兼容 X |
| 排他锁 | X Lock | 写锁,禁止其他事务读和写 | X 不兼容 S,X 不兼容 X |
-- 共享锁:SELECT ... LOCK IN SHARE MODE(MySQL 8.0 前语法)
-- MySQL 8.0+ 推荐用:
SELECT * FROM users WHERE id = 1 FOR SHARE;
-- 排他锁:
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 排他锁(DML 语句自动加):
UPDATE users SET name = 'new' WHERE id = 1;
DELETE FROM users WHERE id = 1;
INSERT INTO users VALUES (1, 'new');关键区别:
FOR SHARE:我只读,但怕别人改,所以加锁,别人不能写FOR UPDATE:我要写,所以加锁,别人不能读也不能写- 普通
SELECT(不加锁):走 MVCC,不阻塞任何人
3. 意向锁(Intention Lock)
意向锁是表级锁,表示"事务准备在某个行上加锁",目的是快速判断表锁是否冲突。
| 意向锁 | 含义 |
|---|---|
| 意向共享锁(IS) | 事务准备在行上加共享锁 |
| 意向排他锁(IX) | 事务准备在行上加排他锁 |
-- 事务加行锁前,自动加意向锁:
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 自动给 users 表加 IX 锁,给 id=1 的行加 X 锁
COMMIT;为什么需要意向锁? 如果没有意向锁,另一个事务要锁全表(LOCK TABLES users WRITE),需要逐行检查是否有行锁——效率极低。有意向锁后,直接看表上的 IX/IS 锁就知道有没有行锁冲突。
4. 行锁的三种实现
InnoDB 的行锁不是只锁一行,而是锁在索引记录上。根据锁的范围,分为三种:
| 锁类型 | 作用范围 | 作用 |
|---|---|---|
| 记录锁(Record Lock) | 锁住单条索引记录 | 只锁这一行 |
| 间隙锁(Gap Lock) | 锁住两条记录之间的间隙 | 防止幻读,防止插入 |
| 临键锁(Next-Key Lock) | 记录锁 + 间隙锁 | InnoDB 默认行锁,防止幻读 |
临键锁是 InnoDB 的默认行锁,它锁的是"索引记录 + 前面的间隙"。
示例:索引列 id 的值 [1, 3, 5, 10]
临键锁锁的范围(负无穷,1]、(1, 3]、(3, 5]、(5, 10]、(10, 正无穷)
如果执行:
SELECT * FROM t WHERE id = 5 FOR UPDATE;
→ 锁住 (3, 5] 这个区间,即 id=5 的记录 + 前面 3~5 的间隙
→ 其他事务不能插入 id=4 的新记录(防止幻读)5. 什么时候用间隙锁
间隙锁只在可重复读(REPEATABLE READ) 级别生效。在读已提交(READ COMMITTED)级别,间隙锁被禁用,只有记录锁。
-- 可重复读下,WHERE 条件没命中记录时,锁住间隙
-- 假设表 t 有 id: [1, 3, 5, 10]
-- 情况 1:等值查询命中记录
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- 锁住 (3, 5] 区间(临键锁)
-- 情况 2:等值查询没命中
SELECT * FROM t WHERE id = 7 FOR UPDATE;
-- 锁住 (5, 10] 区间(间隙锁 + 临键锁)
-- 防止其他事务插入 id=7
-- 情况 3:范围查询
SELECT * FROM t WHERE id > 5 FOR UPDATE;
-- 锁住 (5, 10]、(10, 正无穷)二、MVCC——多版本并发控制
MVCC(Multi-Version Concurrency Control)是 InnoDB 实现非阻塞读的核心机制。
1. 核心思路
MVCC 的核心思想:
"写操作创建新版本,读操作读旧版本——读不阻塞写,写不阻塞读。"
具体实现:
- 每行记录有多个隐藏字段,记录版本信息
- 每个事务启动时,记录当前活跃事务列表
- 读操作根据事务启动时的快照,决定读哪个版本2. 隐藏字段
每行记录在 InnoDB 中有三个隐藏字段:
| 字段 | 含义 |
|---|---|
| DB_TRX_ID | 最近修改这行记录的事务 ID |
| DB_ROLL_PTR | 回滚指针,指向 undo log 中该行的旧版本 |
| DB_ROW_ID | 行 ID(聚簇索引自增,无主键时使用) |
3. undo log 版本链
每行记录的版本链(undo log 链):
记录当前值:id=1, name='c' (事务 ID=30)
↑ DB_ROLL_PTR
记录旧版本:id=1, name='b' (事务 ID=20)
↑ DB_ROLL_PTR
记录旧版本:id=1, name='a' (事务 ID=10)
↑ DB_ROLL_PTR
初始版本:id=1, name='init' (事务 ID=5)4. ReadView——一致性视图
ReadView 是事务启动时创建的"快照",记录当前活跃事务列表。
ReadView 包含四个关键信息:
ReadView = {
m_ids: 当前活跃事务 ID 列表(未提交的事务)
min_trx_id: 活跃事务中最小的事务 ID
max_trx_id: 下一个要分配的事务 ID(当前最大事务 ID + 1)
creator_trx_id: 创建这个 ReadView 的事务 ID
}版本可见性判断规则:
判断一行记录的 DB_TRX_ID 是否可见:
1. DB_TRX_ID < min_trx_id → 已提交,可见
2. DB_TRX_ID >= max_trx_id → 未来事务,不可见
3. DB_TRX_ID == creator_trx_id → 自己改的,可见
4. DB_TRX_ID ∈ m_ids → 活跃事务,不可见(未提交)
5. 其他情况 → 已提交,可见5. RC 与 RR 的 ReadView 区别
| 隔离级别 | ReadView 创建时机 | 效果 |
|---|---|---|
| READ COMMITTED(RC) | 每次 SELECT 都创建新的 ReadView | 每次读到最新已提交数据 |
| REPEATABLE READ(RR) | 事务中第一次 SELECT 创建 ReadView,之后复用 | 整个事务读到一致的数据 |
这就是为什么 RR 级别下,同样的 SELECT 每次结果一样——因为在 RR 下,ReadView 只创建一次,后续复用,看不到其他事务的提交。
三、事务隔离级别
1. 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 默认 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 无 |
| READ COMMITTED | 无 | 可能 | 可能 | Oracle/PG 默认 |
| REPEATABLE READ | 无 | 无 | 可能(InnoDB 通过间隙锁解决) | MySQL 默认 |
| SERIALIZABLE | 无 | 无 | 无 | 无 |
MySQL RR 级别实际上解决了幻读(通过间隙锁 + MVCC),所以 MySQL 的 RR 级别比标准 SQL 的 RR 更强。
2. 隔离级别解决的问题
三种并发问题:
脏读:事务 A 读到事务 B 未提交的数据
B 改了数据但没提交,A 读到了,然后 B 回滚了
→ A 读到的是脏数据
不可重复读:事务 A 内两次读同一行,结果不同
A 第一次读 id=1 得 name='a'
B 改 id=1 为 name='b' 并提交
A 第二次读 id=1 得 name='b'
→ A 两次读的结果不一致
幻读:事务 A 内两次范围查询,行数不同
A 第一次 SELECT count(*) FROM t WHERE id > 5 → 3 行
B 插入 id=6 并提交
A 第二次 SELECT count(*) FROM t WHERE id > 5 → 4 行
→ A 两次查到的行数不一样("幻影行")3. 隔离级别操作示例
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- MySQL 8.0 默认:REPEATABLE READ
-- 设置隔离级别(当前会话)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置隔离级别(全局)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;实际场景选择:
| 场景 | 推荐隔离级别 | 原因 |
|---|---|---|
| 大部分业务 | READ COMMITTED | 减少间隙锁竞争,提高并发 |
| 金融/对账 | REPEATABLE READ | 需要一致性读,避免幻读 |
| 数据统计 | REPEATABLE READ | 整个报表取数要一致快照 |
| 高并发简单操作 | READ COMMITTED | 锁冲突少,吞吐高 |
四、死锁排查
1. 死锁产生的条件
死锁四个必要条件(缺一不可):
1. 互斥:资源不能被多个事务同时独占
2. 持有并等待:事务持有锁,又等待其他事务的锁
3. 不可剥夺:事务不能强行夺取其他事务的锁
4. 循环等待:事务之间形成等待环路2. 经典死锁场景
场景 A:两个事务互相等待
-- 事务 1
BEGIN;
UPDATE users SET name = 'a' WHERE id = 1; -- 锁 id=1
UPDATE users SET name = 'b' WHERE id = 2; -- 等待事务 2 释放 id=2
-- 事务 2
BEGIN;
UPDATE users SET name = 'c' WHERE id = 2; -- 锁 id=2
UPDATE users SET name = 'd' WHERE id = 1; -- 等待事务 1 释放 id=1
-- 死锁!解决方案: 所有事务按相同顺序加锁(先 id=1 再 id=2)。
场景 B:间隙锁导致的死锁
-- 假设表 t 有 id: [1, 5, 10]
-- 事务 1
BEGIN;
SELECT * FROM t WHERE id = 7 FOR UPDATE; -- 锁住 (5, 10] 间隙
-- 事务 2
BEGIN;
SELECT * FROM t WHERE id = 8 FOR UPDATE; -- 也锁住 (5, 10] 间隙(间隙锁兼容)
-- 事务 1
INSERT INTO t VALUES (7, 'new'); -- 等待事务 2 释放间隙锁
-- 事务 2
INSERT INTO t VALUES (8, 'new'); -- 等待事务 1 释放间隙锁
-- 死锁!解决方案: 降低隔离级别为 RC(禁用间隙锁),或用 SELECT ... FOR UPDATE NOWAIT。
场景 C:行锁 + 外键/二级索引
-- 表 t1(主表)有 id=1, id=2
-- 表 t2(从表)有外键指向 t1.id
-- 事务 1
DELETE FROM t1 WHERE id = 1; -- 锁 t1.id=1,同时锁 t2 中引用 t1.id=1 的行
-- 事务 2
DELETE FROM t1 WHERE id = 2; -- 锁 t1.id=2,同时锁 t2 中引用 t1.id=2 的行
-- 如果 t2 中有行同时引用 t1.id=1 和 t1.id=2,可能死锁3. 死锁排查方法
-- 1. 查看最近死锁信息
SHOW ENGINE INNODB STATUS\G
-- 在输出中查找 "LATEST DETECTED DEADLOCK" 部分
-- 会显示:
-- - 死锁事务的 SQL 语句
-- - 每个事务持有的锁
-- - 每个事务等待的锁
-- 2. 查看当前事务锁等待
SELECT * FROM performance_schema.data_lock_waits\G
-- 3. 查看当前锁信息
SELECT * FROM performance_schema.data_locks\G
-- 4. 查看 InnoDB 事务状态
SELECT * FROM information_schema.INNODB_TRX\G4. 死锁避免策略
| 策略 | 做法 |
|---|---|
| 固定加锁顺序 | 所有事务按相同顺序操作表/行 |
| 缩小事务范围 | 尽量短的事务,减少锁持有时间 |
| 降低隔离级别 | RC 比 RR 少间隙锁,死锁概率低 |
| 使用 NOWAIT | SELECT ... FOR UPDATE NOWAIT 不等待直接返回 |
| 合理使用索引 | 让行锁走索引,避免行锁变表锁 |
| 分时段处理 | 批量操作放在低峰期 |
-- NOWAIT 示例:不等待,直接返回锁冲突
SELECT * FROM users WHERE id = 1 FOR UPDATE NOWAIT;
-- 如果 id=1 已被锁,立即报错,不等待
-- SKIP LOCKED 示例:跳过已锁的行
SELECT * FROM users WHERE status = 'pending' FOR UPDATE SKIP LOCKED;
-- 跳过已锁的行,只处理未锁的行(适合任务队列)五、常见坑对照
| 坑 | 现象 | 原因 | 对策 |
|---|---|---|---|
| 行锁变表锁 | 更新一条记录,其他行也锁住 | WHERE 条件没走索引 | 检查 EXPLAIN,确保索引 |
| 死锁频繁 | 应用报 Deadlock found | 事务顺序不一致或间隙锁 | 固定加锁顺序,降低隔离级别 |
| 可重复读读不到 | 事务内读不到其他事务已提交的数据 | RR 级别复用 ReadView | 要么用 RC,要么重新开启事务 |
| 间隙锁锁住插入 | 插入一条不存在的记录被阻塞 | RR 级别间隙锁防止幻读 | 降低隔离级别或优化 SQL |
| 大事务锁太多 | 锁等待超时 | 事务处理太多行或太久 | 拆分事务,分批处理 |
| 唯一键冲突死锁 | 并发 INSERT ... ON DUPLICATE KEY UPDATE 死锁 | 唯一键检查顺序不一致 | 用 INSERT IGNORE 或先查询 |
六、实战:锁分析
场景 1:排查锁等待
-- 当前锁等待超时设置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认 50 秒
-- 排查锁等待
SELECT
waiting_trx_id,
waiting_thread,
blocking_trx_id,
blocking_thread,
waiting_query,
blocking_query
FROM sys.innodb_lock_waits;
-- 找到阻塞的事务后,可以 kill 它
-- KILL <thread_id>;场景 2:分析死锁
-- 启用死锁日志(记录到 MySQL 错误日志)
SET GLOBAL innodb_print_all_deadlocks = 1;
-- 查看死锁日志
SHOW ENGINE INNODB STATUS\G
-- 查找以下内容:
-- ------------------------
-- LATEST DETECTED DEADLOCK
-- ------------------------
-- 2026-09-02 10:00:00 0x7f1234
-- *** (1) TRANSACTION: ...
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
-- *** (2) TRANSACTION: ...
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
-- *** WE ROLL BACK TRANSACTION (2)场景 3:锁等待超时优化
-- 查看当前锁等待超时
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 修改为 30 秒(避免长时间等待)
SET GLOBAL innodb_lock_wait_timeout = 30;
-- 注意:不要设得太小,否则正常等待也会超时
-- 也不要设得太大,否则故障恢复慢相关与延伸
下一篇:MySQL 查询优化——EXPLAIN、慢查询、优化器;索引基础见 MySQL 索引原理;InnoDB 存储引擎见 InnoDB 存储引擎——页结构、Buffer Pool、redo/undo log;锁与事务的底层实现,涉及 CPU 缓存一致性(原子操作与锁的实现)和 NUMA 架构(多核竞争)。
一句话总结
MySQL 锁与事务:InnoDB 行锁走索引(否则升表锁),默认 RR 级别用临键锁(记录锁+间隙锁)防止幻读;MVCC 通过 undo log 版本链 + ReadView 实现非阻塞读(RC 每次 SELECT 新建 ReadView,RR 复用第一次的 ReadView);死锁排查用 SHOW ENGINE INNODB STATUS 找循环等待,通过固定加锁顺序、降低隔离级别、缩小事务范围来避免。