4705 字
24 分钟
MySQL 事务、MVCC 与锁

MySQL InnoDB 的事务机制围绕 ACID 展开,底层通过 Undo Log(原子性)、Redo Log(持久性)、MVCC + 锁(隔离性)三者协同实现。理解 MVCC 的 Read View 可见性规则和 Next-Key Lock 的加锁策略,是掌握 InnoDB 并发控制的核心。


目录#

  1. 事务的基石:ACID
  2. 并发问题与隔离级别
  3. MVCC 底层原理
  4. 锁的艺术
  5. 常见面试题
  6. 总结

1. 事务的基石:ACID#

面试时千万别只背概念,要结合 MySQL 的底层实现来答:

特性含义底层实现
A — Atomicity(原子性)要么全成功,要么全失败Undo Log(回滚日志):记录修改前的旧值,事务回滚时按 Undo Log 恢复数据
C — Consistency(一致性)数据库从一种正确状态迁移到另一种正确状态由 A、I、D + 业务代码共同保证
I — Isolation(隔离性)事务间互不干扰,并发执行的效果等同于串行锁机制 + MVCC(多版本并发控制)
D — Durability(持久性)事务提交后,数据永久保存,宕机不丢Redo Log(重做日志):先写日志再写磁盘,崩溃恢复时通过 Redo Log 重放已提交事务

1.1 Undo Log 与原子性#

事务 T1: UPDATE account SET balance = balance - 100 WHERE id = 1;
① 执行前:将 balance 旧值 (500) 写入 Undo Log
② 修改数据:balance = 400
③ 提交:标记 Undo Log 可回收
④ 回滚:用 Undo Log 中的旧值 (500) 覆盖 → balance 恢复为 500
  • Undo Log 是逻辑日志:记录的是”如何还原”,而非物理页的二进制快照
  • MVCC 也依赖 Undo Log:旧版本数据通过 Undo Log 保留,供其他事务的快照读访问

1.2 Redo Log 与持久性#

事务 T1: UPDATE account SET balance = balance - 100 WHERE id = 1;
① 修改 Buffer Pool 中的数据页(内存)
② 生成 Redo Log 并写入 Redo Log Buffer
③ 事务提交时,Redo Log 刷盘(fsync)
④ 数据页由后台线程异步刷盘(Checkpoint)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 修改内存页 │ → │ 写 Redo Log │ → │ Redo Log 刷盘 │ → 事务提交成功
│ (Buffer Pool) │ │ (顺序写) │ │ (持久化保证) │
└──────────────┘ └──────────────┘ └──────────────┘
│
┌─────────┴─────────┐
│ 后台线程异步刷脏页 │
│ (Checkpoint 机制) │
└───────────────────┘

WAL(Write-Ahead Logging):先写日志,再写数据。Redo Log 是顺序写,速度快;数据页是随机写,速度慢。WAL 将随机写转为顺序写。


2. 并发问题与隔离级别#

2.1 三种并发乱象#

乱象现象侧重点严重程度
脏读 (Dirty Read)读到别人未提交的数据可能读到最终被回滚的脏数据🔴 最严重,绝对不允许
不可重复读 (Non-Repeatable Read)同一事务内两次读同一行,内容不同侧重 UPDATE / DELETE 导致的数据内容变化🟡 部分场景可接受
幻读 (Phantom Read)同一事务内两次范围查询,行数不同侧重 INSERT 导致的数据条数变化🟡 InnoDB 在 RR 级别已解决
脏读场景:
事务 A: UPDATE ... SET age=20 (未提交)
事务 B: SELECT → age=20 ← 读到未提交的脏数据
事务 A: ROLLBACK → age 回到 18
事务 B: 基于 age=20 做了错误决策 ❌
不可重复读场景:
事务 B: SELECT age → 18
事务 A: UPDATE SET age=20 AND COMMIT
事务 B: SELECT age → 20 ← 同一事务内两次读到不同值
幻读场景:
事务 B: SELECT COUNT(*) WHERE age>18 → 3
事务 A: INSERT INTO ... age=25 AND COMMIT
事务 B: SELECT COUNT(*) WHERE age>18 → 4 ← 多了一行"幻影"

2.2 四大隔离级别#

隔离级别脏读不可重复读幻读默认使用
Read Uncommitted (RU)❌ 允许❌ 允许❌ 允许几乎不用
Read Committed (RC)✅ 解决❌ 允许❌ 允许阿里等大厂线上默认(高并发场景)
Repeatable Read (RR)✅ 解决✅ 解决✅ 解决InnoDB 默认级别
Serializable✅ 解决✅ 解决✅ 解决性能极差,靠强制加锁

注意:InnoDB 在 RR 级别下已解决幻读——快照读靠 MVCC,当前读靠 Next-Key Lock。别再说”RR 无法解决幻读”了。


3. MVCC 底层原理#

MVCC(Multi-Version Concurrency Control)是 InnoDB 实现非锁定一致性读的核心机制。它的三个关键组件:

3.1 隐藏列#

InnoDB 表中每行记录都会自动附加三个隐藏字段:

隐藏列大小说明
DB_TRX_ID6 字节最后修改该行的事务 ID(单调递增)
DB_ROLL_PTR7 字节回滚指针,指向 Undo Log 中该行的上一个版本
DB_ROW_ID6 字节隐藏主键(仅当无主键且无唯一非空索引时存在)
一行数据的实际结构:
┌──────────┬──────────┬──────────┬──────────────┬───────────────┐
│ id (PK) │ name │ age │ DB_TRX_ID │ DB_ROLL_PTR │
├──────────┼──────────┼──────────┼──────────────┼───────────────┤
│ 1 │ 张三 │ 25 │ 108 │ → Undo Log │
└──────────┴──────────┴──────────┴──────────────┴───────────────┘
用户定义列 系统隐藏列

3.2 Undo Log 版本链#

每次修改记录时,旧版本不会立刻被覆盖,而是被写入 Undo Log,通过 DB_ROLL_PTR 串联成历史版本链表。

事务 T1 (trx_id=100):INSERT → name='张三', age=20
事务 T2 (trx_id=101):UPDATE → age=25
事务 T3 (trx_id=102):UPDATE → age=30
事务 T4 (trx_id=103):UPDATE → name='张三疯'
Undo Log 版本链:
Undo Log
当前记录 (聚簇索引) ←──旧版本链──
┌─────────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ name='张三疯' │ │ name='张三' │ │ name='张三' │ │ name='张三' │
│ age=30 │ │ age=30 │ │ age=25 │ │ age=20 │
│ trx_id=103 │──→ │ trx_id=102 │──→ │ trx_id=101 │──→ │ trx_id=100 │
│ roll_ptr ───────────┘ │ roll_ptr ────────┘ │ roll_ptr ────────┘ │ roll_ptr = NULL │
└─────────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘
最新版本 (事务103) 历史版本 (事务102) 历史版本 (事务101) 最老版本 (事务100)

关键点:

  • 版本链方向:最新 → 最老(roll_ptr 从新指向旧)
  • Undo Log 中的版本可能被多个事务同时读取(不同快照看到不同版本)
  • Undo Log 不会被立即删除,直到没有事务需要该版本时才由 Purge 线程清理

3.3 Read View 一致性视图#

事务执行查询时生成一个快照(Read View),用于判断版本链中哪些版本对当前事务可见。

Read View 包含的关键属性:

属性含义
m_ids生成快照时,系统中活跃(未提交)的事务 ID 列表
min_trx_idm_ids 中的最小值
max_trx_id系统下一个即将分配的事务 ID(= 已分配的最大 trx_id + 1)
creator_trx_id创建该 Read View 的事务 ID

可见性判断规则(遍历版本链,从最新版本开始):

对于版本链中的每个版本,设其 trx_id = V:
① 如果 V == creator_trx_id
→ 当前事务自己修改的版本,可见 ✅
② 如果 V < min_trx_id
→ 修改该版本的事务在快照生成前已提交,可见 ✅
③ 如果 V >= max_trx_id
→ 修改该版本的事务在快照生成后才开始,不可见 ❌ → 沿 roll_ptr 找上一版本
④ 如果 min_trx_id <= V < max_trx_id
→ 检查 V 是否在 m_ids 中:
- 在 m_ids 中:事务在快照时刻仍未提交,不可见 ❌
- 不在 m_ids 中:事务在快照时刻已提交,可见 ✅
可见性判断流程图:
拿到一个版本 (trx_id = V)
│
┌───────────┴───────────┐
│ V == creator_trx_id? │
└───────────┬───────────┘
Yes ↓ ↓ No
✅ 可见 ┌───────────────┐
│ V < min_trx_id?│
└───────┬───────┘
Yes ↓ ↓ No
✅ 可见 ┌───────────────┐
│ V >= max_trx_id│
│ ? │
└───────┬─────────┘
Yes ↓ ↓ No
❌ 不可见 ┌──────────────────┐
(找上一版本)│ V 在 m_ids 中? │
└────┬─────────────┘
Yes ↓ ↓ No
❌ 不可见 ✅ 可见
(未提交) (已提交)

3.4 RC 与 RR 的 Read View 生成策略#

RC 和 RR 在快照读上的唯一区别就是 Read View 的生成时机:

隔离级别Read View 生成策略效果
RC (Read Committed)每次 SELECT 都生成新的 Read View每次都能看到最新已提交的数据 → 不可重复读
RR (Repeatable Read)事务中第一次 SELECT 时生成 Read View,之后复用整个事务期间看到的数据一致 → 可重复读
同一个场景,RC vs RR 看到的不同结果:
时间线:T1 开始 → T1 第一次 SELECT → T2 UPDATE 并 COMMIT → T1 第二次 SELECT
RC 下:
T1 第一次 SELECT → ReadView₁ (m_ids: [T2])
T2 COMMIT → 更新数据
T1 第二次 SELECT → ReadView₂ (m_ids: []) ← 重新生成,T2 已提交 → 可见
T1 两次读到不同值 ❌ (不可重复读)
RR 下:
T1 第一次 SELECT → ReadView₁ (m_ids: [T2]) ← 生成 ReadView
T2 COMMIT → 更新数据
T1 第二次 SELECT → 复用 ReadView₁ (m_ids: [T2]) ← T2 仍在 m_ids 中 → 不可见
T1 两次读到相同值 ✅ (可重复读)

4. 锁的艺术#

InnoDB 在 RR 级别下,彻底解决幻读的两条路径:

4.1 快照读 vs 当前读#

读类型SQL 示例解决幻读的机制
快照读(Snapshot Read)普通 SELECTMVCC:读的是历史版本,别人插了新数据也看不见
当前读(Current Read)SELECT ... FOR UPDATE、UPDATE、DELETE、INSERTNext-Key Lock:锁住记录 + 间隙,阻止插入
同一个 SQL 的不同"读"类型:
SELECT * FROM users WHERE age = 25;
→ 快照读(无锁,基于 MVCC,读到的是事务开始时的快照)
SELECT * FROM users WHERE age = 25 FOR UPDATE;
→ 当前读(加锁,读到的是最新的已提交数据 + 对数据加排他锁)
UPDATE users SET name = '李四' WHERE age = 25;
→ 当前读(UPDATE 内部先执行当前读找到行,再修改)

实战场景:抢购接口中的超卖问题#

假设你正在写一个抢购接口,库存表中当前只有 1 件商品。

❌ 错误写法:快照读导致的灾难

-- 初始状态:goods 表,id=1,stock=1
事务 A 事务 B
──────────────────────────────────────────────────────────
BEGIN;
SELECT stock FROM goods BEGIN;
WHERE id = 1; SELECT stock FROM goods
→ 快照读,拿到 stock = 1 WHERE id = 1;
→ 快照读,也拿到 stock = 1
-- A 觉得库存够,扣减:
UPDATE goods SET stock =
stock - 1 WHERE id = 1;
-- B 也觉得库存够,扣减:
UPDATE goods SET stock =
stock - 1 WHERE id = 1;
COMMIT; COMMIT;
-- 结果:stock 变成了 -1,超卖发生! 💥

问题根因:普通 SELECT 是快照读,基于 MVCC 读历史版本,无锁。两个事务同时读到 stock=1,都认为自己可以扣减。UPDATE 内部的当前读虽然会加锁,但为时已晚——逻辑判断已经做完了。

✅ 正确写法:当前读的威力

-- 初始状态:goods 表,id=1,stock=1
事务 A 事务 B
──────────────────────────────────────────────────────────
BEGIN;
SELECT stock FROM goods BEGIN;
WHERE id = 1 FOR UPDATE; SELECT stock FROM goods
→ 当前读,拿到 stock = 1 WHERE id = 1 FOR UPDATE;
→ 对 id=1 加 X 锁 🔒 → 🚫 被阻塞!等待锁...
-- A 放心扣减:
UPDATE goods SET stock =
stock - 1 WHERE id = 1;
COMMIT; -- 释放锁 🔓
→ 🔓 终于拿到锁
→ 当前读,拿到最新的 stock = 0
→ 判断库存不足,抛异常!
-- 结果:stock = 0,完美防住超卖 ✅

核心要点:

对比维度快照读 SELECT当前读 SELECT ... FOR UPDATE
是否加锁❌ 无锁✅ 加 X 锁(排他锁)
读到什么MVCC 历史快照最新已提交数据
并发安全不安全,会超卖安全,串行化访问
性能影响高并发、无阻塞并发下降,但数据正确

一句话总结:在需要”先读后写”且读写之间有逻辑判断的场景(如库存扣减、余额变更),必须用当前读加锁,普通快照读只是”看个热闹”。

4.2 行锁 (Record Lock)#

锁住单条索引记录,阻止其他事务对该行的修改。

表:users (id PRIMARY KEY, name, age)
数据:id ∈ {5, 10, 15, 20}
事务 A: SELECT * FROM users WHERE id = 10 FOR UPDATE;
→ 对 id=10 这条记录加 X 锁
→ 事务 B 的 UPDATE users SET ... WHERE id = 10 被阻塞
[5] [10]🔒 [15] [20]
↑
Record Lock:只锁 id=10 这一条

4.3 间隙锁 (Gap Lock)#

锁住索引记录之间的间隙,阻止其他事务往这个间隙里 INSERT。

表:id ∈ {5, 10, 15, 20}
间隙:( -∞, 5 )、( 5, 10 )、( 10, 15 )、( 15, 20 )、( 20, +∞ )
事务 A: SELECT * FROM users WHERE id BETWEEN 10 AND 15 FOR UPDATE;
→ 对间隙 (5, 10)、(10, 15)、(15, 20) 加 Gap Lock
[5] ──[gap]── [10]🔒 ──[gap]── [15]🔒 ──[gap]── [20]
↑ ↑ ↑ ↑
间隙锁不锁行,只锁区间。事务 B 不能在这些间隙中 INSERT
但可以 UPDATE id=5 或 id=20(这些行没被行锁锁住)

关键特性:

  • 间隙锁不互斥:两个事务可以同时持有同一个间隙的 Gap Lock
  • 间隙锁只阻止 INSERT,不阻止 SELECT 或其他间隙上的 Gap Lock
  • 目的是防止幻读——新行只能插入到不被锁住的间隙中

4.4 临键锁 (Next-Key Lock)#

Next-Key Lock = Record Lock + Gap Lock(前开后闭区间)

表:id ∈ {5, 10, 15}
Next-Key Lock 锁住的区间:
( -∞, 5 ] ( 5, 10 ] ( 10, 15 ] ( 15, +∞ )
左开右闭
事务 A: SELECT * FROM users WHERE id > 8 AND id < 15 FOR UPDATE;
→ Next-Key Lock 锁住 (5, 10] + (10, 15]
→ 阻止:
- 修改 id=10、id=15 的记录(Record Lock)
- 在 (5,10)、(10,15) 区间插入新记录(Gap Lock)
-∞ ··· [5] ──(5,10]── [10]🔒 ──(10,15]── [15]🔒 ──(15,+∞)── ...
↑ ↑ ↑
锁住 id=5 锁住 (5,10] 锁住 (10,15]
(前开后闭: (前开后闭: (前开后闭:
间隙+记录) 间隙+记录) 间隙+记录)

4.5 加锁规则实战#

等值查询和范围查询的加锁行为不同:

查询类型命中情况加的锁
等值查询命中命中存在的记录命中记录的 Record Lock + 两边间隙各一个 Gap Lock(退化为两个 Gap Lock)
等值查询未命中没找到记录所在间隙的 Gap Lock
范围查询—范围内的 Next-Key Lock
-- 表数据:id ∈ {5, 10, 15, 20},主键索引
-- ① 等值查询命中
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 加锁:(5, 10] Next-Key → 退化为 (5,10) Gap + id=10 Record + (10,15) Gap
-- ② 等值查询未命中
SELECT * FROM t WHERE id = 12 FOR UPDATE;
-- 加锁:(10, 15) Gap Lock ← 阻止插入 11~14
-- ③ 范围查询
SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
-- 加锁:(5, 10] Next-Key + (10, 15] Next-Key

小提示:唯一索引等值查询命中时,Next-Key Lock 会退化为 Record Lock。但间隙锁部分保留,仍然防止幻读。


5. 常见面试题#

Q1:MVCC 是如何解决不可重复读的?#

A1:在 RR 隔离级别下,事务第一次 SELECT 时生成 Read View,之后复用同一个 Read View。版本链中只有 trx_id < min_trx_id(快照前已提交)或 trx_id == creator_trx_id(自己修改)的版本可见。后续其他事务即使提交了修改,其 trx_id 要么 >= max_trx_id,要么在 m_ids 中——对当前事务不可见。因此同一事务内多次读取看到的数据始终一致。

Q2:RR 级别下如何彻底解决幻读?#

A2:分场景作答:

  1. 快照读(普通 SELECT):靠 MVCC——读的是快照生成时的数据版本,别人 INSERT 的新行对当前事务不可见
  2. 当前读(SELECT FOR UPDATE / UPDATE / DELETE):靠 Next-Key Lock——锁住记录 + 间隙,阻止其他事务在间隙中 INSERT

两者结合,RR 级别下的幻读被彻底解决。

Q3:RC 和 RR 的区别,以及各自适用场景?#

A3:

维度RCRR
Read View 生成每次 SELECT 都生成新的事务内首次 SELECT 生成,之后复用
不可重复读存在解决
幻读存在解决
间隙锁不使用使用(可能增大死锁概率)
并发性能更高(锁少)较低(间隙锁影响并发插入)
适用场景高并发 OLTP,读写频繁需要事务内数据一致性(如对账、报表)
大厂实践阿里等将默认隔离级别设为 RCMySQL InnoDB 默认

Q4:什么是快照读?什么是当前读?#

A4:

-- 快照读:基于 MVCC,无锁,读历史快照
SELECT * FROM users WHERE age = 25;
-- 当前读:加锁,读最新已提交版本
SELECT * FROM users WHERE age = 25 FOR UPDATE; -- 加 X 锁
SELECT * FROM users WHERE age = 25 LOCK IN SHARE MODE; -- 加 S 锁
UPDATE users SET name = 'x' WHERE age = 25; -- 内部用当前读
DELETE FROM users WHERE age = 25; -- 内部用当前读

Q5:Undo Log 和 Redo Log 的区别是什么?#

A5:

维度Undo LogRedo Log
目的回滚事务 + MVCC 版本链崩溃恢复,确保持久性
记录内容修改前的旧值(逻辑日志)修改后的新值(物理日志)
写入时机修改数据前修改数据后(事务提交时刷盘)
是否可删除Purge 线程在无事务需要时清理Checkpoint 后覆盖
对应 ACIDA(原子性)+ I(MVCC 基础)D(持久性)

6. 总结#

6.1 事务机制全景#

┌─────────────────────────────────────────────────────────────┐
│ InnoDB 事务机制全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ACID │
│ ├── 原子性 (A) ←── Undo Log │
│ ├── 一致性 (C) ←── A + I + D + 业务约束 │
│ ├── 隔离性 (I) ←── MVCC (快照读) + 锁 (当前读) │
│ └── 持久性 (D) ←── Redo Log (WAL) │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ MVCC 架构 │ │
│ │ │ │
│ │ 隐藏列 (trx_id, roll_ptr) │ │
│ │ ↓ │ │
│ │ Undo Log 版本链 (旧版本链表) │ │
│ │ ↓ │ │
│ │ Read View (一致性视图快照) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ m_ids, min/max_trx_id │ │ │
│ │ │ 可见性规则判断 │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 锁体系 │ │
│ │ │ │
│ │ Record Lock ── 锁单行 │ │
│ │ Gap Lock ── 锁间隙,阻止 INSERT │ │
│ │ Next-Key Lock ── Record + Gap │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

6.2 关键设计原则#

原则说明
MVCC 无锁读快照读基于版本链 + Read View,完全无锁,写不阻塞读
Read View 时机决定隔离级别RC 每次重新生成,RR 复用同一个——同一个 MVCC 机制,不同策略表现不同行为
当前读靠锁FOR UPDATE、UPDATE、DELETE 必须加锁,读到最新数据
Next-Key Lock = 行锁 + 间隙锁当前读防止幻读的终极武器
WAL 先写日志Redo Log 顺序写,将随机写转为顺序写,保证持久性 + 高性能
Undo Log 多重使命既支持回滚(原子性),又支撑 MVCC(隔离性)

6.3 快速排查清单#

排查点看什么工具/命令
当前事务隔离级别transaction_isolation 变量SHOW VARIABLES LIKE '%isolation%'
是否有长时间未提交的事务trx_state、trx_startedSELECT * FROM information_schema.INNODB_TRX
锁等待/死锁lock_mode、lock_typeSHOW ENGINE INNODB STATUS、INNODB_LOCKS、INNODB_LOCK_WAITS
Undo Log 膨胀history_list_lengthSHOW ENGINE INNODB STATUS(长事务导致 Undo 无法 Purge)
Redo Log 刷盘策略innodb_flush_log_at_trx_commitSHOW VARIABLES LIKE '%flush_log%'

6.4 一句口诀#

Undo 回滚 MVCC,Redo 持久 WAL 提;快照无锁读旧版,当前加锁防幻欺。


相关链接#


本文基于 MySQL 8.0 / InnoDB 引擎编写,核心概念(MVCC 版本链、Read View 可见性规则、Next-Key Lock)在所有 5.6+ 版本中通用。

MySQL 事务、MVCC 与锁
https://shirone.mysqil.com/posts/mysql/mysql-事务-mvcc-与锁/
作者
HikariTish
发布于
2026-05-24
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

最后更新于 ,距今已过 144 天

部分内容可能已过时