# MySQL InnoDB 隔离级别与 MVCC 完全解析

一篇读懂 MySQL 的事务隔离、锁机制、MVCC 和间隙锁实现原理

前言

在日常开发中,数据库事务隔离级别是一个绕不开的话题。很多开发人员对 READ COMMITTEDREPEATABLE READ 的区别一知半解,对 MVCC 的原理更是云里雾里。

本文将从实际场景出发,深入浅出地讲解:

  • 四种隔离级别及其解决的问题
  • MVCC 的核心原理与实现
  • 间隙锁是什么,如何实现
  • 生产环境如何选择隔离级别

一、为什么需要隔离级别?

1.1 并发事务的三个问题

当多个事务同时执行时,会出现三种经典问题:

问题 英文 定义 示例
脏读 Dirty Read 读到其他事务未提交的数据 事务A改了余额但未提交,事务B读到了新值;后来A回滚,B读到了脏数据
不可重复读 Non-Repeatable Read 同一事务内,两次读同一条记录结果不同 事务A第一次读到balance=50,事务B改成100并提交,事务A再读变成100
幻读 Phantom Read 同一事务内,两次范围查询返回的行数不同 事务A查到2行,事务B插入1行,事务A再查变成3行

1.2 隔离级别概览

SQL 标准定义了四种隔离级别,MySQL InnoDB 全部支持:

隔离级别 脏读 不可重复读 幻读 并发性能
READ UNCOMMITTED 最高
READ COMMITTED (RC)
REPEATABLE READ (RR) ❌*
SERIALIZABLE 最低

*MySQL InnoDB 的 RR 通过间隙锁解决了幻读问题


二、四种隔离级别详解

2.1 READ UNCOMMITTED(读未提交)

特点:一个事务可以读取到其他事务尚未提交的修改。

sql 复制代码
-- 事务1
START TRANSACTION;
UPDATE users SET balance = 100 WHERE id = 1;
-- 注意:还没有 COMMIT

-- 事务2
SELECT balance FROM users WHERE id = 1;  -- 读到 100(脏读!)

问题 :脏读
场景:几乎不用,数据准确性无法保证


2.2 READ COMMITTED(读已提交)

特点 :只能读取到其他事务已经提交的修改。

sql 复制代码
-- 事务1
START TRANSACTION;
UPDATE users SET balance = 100 WHERE id = 1;
-- 未提交

-- 事务2
SELECT balance FROM users WHERE id = 1;  -- 读到旧值 50

-- 事务1 COMMIT 后
-- 事务2 再次查询
SELECT balance FROM users WHERE id = 1;  -- 读到 100(不可重复读)

问题 :不可重复读
场景:互联网高并发业务(读写锁竞争少,性能好)


2.3 REPEATABLE READ(可重复读)------ MySQL 默认

特点:同一事务内,多次查询结果始终一致(基于第一次查询时的快照)。

sql 复制代码
-- 事务1
START TRANSACTION;
SELECT balance FROM users WHERE id = 1;  -- 读到 50

-- 事务2
UPDATE users SET balance = 100 WHERE id = 1;
COMMIT;

-- 事务1 再次查询
SELECT balance FROM users WHERE id = 1;  -- 还是 50(可重复读)

解决的问题

  • 脏读:❌ 避免
  • 不可重复读:❌ 避免
  • 幻读:❌ 避免(通过间隙锁)

场景:金融、对账等对数据一致性要求高的业务


2.4 SERIALIZABLE(可串行化)

特点:事务完全串行执行,最高的隔离级别。

sql 复制代码
-- 事务1
START TRANSACTION;
SELECT balance FROM users WHERE id = 1;  -- 自动加读锁

-- 事务2
UPDATE users SET balance = 100 WHERE id = 1;  -- ❌ 被阻塞

场景:几乎不用(性能太差)


三、MVCC:多版本并发控制

3.1 为什么需要 MVCC?

没有 MVCC 的时代,读写互斥:

sql 复制代码
-- 事务1:读数据(加读锁)
-- 事务2:写数据(被阻塞,等待事务1释放锁)

有了 MVCC,读写不互斥:

sql 复制代码
-- 事务1:读数据(读旧版本快照)
-- 事务2:写数据(写新版本)✅ 立即成功,不阻塞

3.2 MVCC 的核心组件

1. 隐藏字段(每行都有)
隐藏字段 含义 作用
DB_TRX_ID 最后修改该行的事务ID 知道这行是谁改的
DB_ROLL_PTR 回滚指针,指向 Undo Log 找到历史版本
2. Undo Log(版本链)

每次 UPDATE/DELETE 时,旧数据写入 Undo Log,形成版本链

复制代码
当前数据(balance=100, trx_id=102)
    ↑
    └── DB_ROLL_PTR 指向
    ↓
旧版本(balance=50, trx_id=101)
    ↑
    └── DB_ROLL_PTR 指向
    ↓
更旧版本(balance=30, trx_id=100)
3. Read View(读视图)

事务开始时,生成一个 Read View,记录当前活跃的事务:

复制代码
Read View 包含:
- m_ids:当前所有活跃(未提交)的事务ID列表
- min_trx_id:m_ids 中的最小值
- max_trx_id:系统下一个要分配的事务ID
- creator_trx_id:当前事务自己的ID

3.3 MVCC 如何判断数据是否可见?

条件 可见性 说明
DB_TRX_ID < min_trx_id ✅ 可见 修改该行的事务已提交
DB_TRX_ID >= max_trx_id ❌ 不可见 修改发生在 Read View 之后
DB_TRX_IDm_ids ❌ 不可见 修改该行的事务未提交
DB_TRX_ID == creator_trx_id ✅ 可见 自己修改的

3.4 RC vs RR:Read View 生成时机

隔离级别 Read View 生成时机 效果
RC 每次 SELECT 都生成新的 能看到其他事务已提交的修改
RR 事务第一次 SELECT 时生成,整个事务复用 重复读始终一致

3.5 MVCC 的常见误解澄清

误解:MVCC 就像一个缓存机制,将写操作异步执行。

正确理解 :MVCC 不是 异步写,而是同步写新版本 + 保留旧版本

方面 误解 实际情况
读旧版本 ✅ 像读缓存 读 Undo Log 中的历史版本
写操作 ❌ "异步"或"延迟" 写操作立即执行,只是不阻塞读
本质 缓存写缓冲 写新版本 + 保留旧版本链

更准确的表述:MVCC 让数据库拥有了"时光倒流"能力------每个事务都能看到属于自己的那个时间点的数据快照,读写互不干扰。


四、深入理解 RR:间隙锁

4.1 什么是间隙锁?

间隙锁(Gap Lock) :InnoDB 在 RR 级别下,为了防止幻读,对索引记录之间的间隙加的锁。

假设 users 表的 id 列有索引,现有数据:10, 20, 30, 40, 50

复制代码
间隙包括:
(负无穷, 10), (10, 20), (20, 30), (30, 40), (40, 50), (50, 正无穷)

4.2 间隙锁如何防止幻读?

sql 复制代码
-- 事务1(RR 级别)
START TRANSACTION;
SELECT * FROM users WHERE id BETWEEN 20 AND 30 FOR UPDATE;

-- InnoDB 会锁住:
--   1. id=20 和 id=30 的记录(行锁)
--   2. 间隙 (20, 30)(间隙锁)

-- 事务2(同时执行)
INSERT INTO users (id, name) VALUES (25, 'new');
-- ❌ 被阻塞!因为 25 在间隙 (20,30) 中

COMMIT;  -- 事务1 提交,释放锁

4.3 间隙锁的实现原理

间隙锁不是锁住"空隙"这个抽象概念,而是在内存中创建具体的锁对象

间隙锁如何挂载到 B+树

InnoDB 的索引是 B+树结构,间隙锁实际上挂载在叶子节点之间的指针上:

复制代码
B+树叶子节点(双向链表):

节点1             节点2             节点3
[10,20,30]  ⇄  [40,50,60]  ⇄  [70,80,90]
    ↑               ↑               ↑
    │               │               │
间隙锁A           间隙锁B           间隙锁C
锁住(30,40)      锁住(60,70)      锁住(90,+∞)

关键

  • 每个叶子节点包含多条记录
  • 节点之间有双向指针连接
  • 间隙锁就挂在这些"指针"上,锁住两个节点之间的范围
间隙锁的冲突检测

当另一个事务尝试插入 id=25 时:

sql 复制代码
INSERT INTO users (id) VALUES (25);

检测流程

复制代码
1. 通过 B+树定位 id=25 应该插入的位置(20 和 30 之间)

2. 检查该位置所在的间隙 (20,30) 是否有间隙锁

3. 查询内存中的锁哈希表:
   SELECT * FROM lock_table 
   WHERE index_id = 'idx_id' 
     AND gap_start < 25 
     AND gap_end > 25

4. 如果找到间隙锁 → 阻塞当前事务
5. 如果没有间隙锁 → 允许插入
查看间隙锁(MySQL 8.0)
sql 复制代码
-- 开启事务并加锁
START TRANSACTION;
SELECT * FROM users WHERE id BETWEEN 20 AND 30 FOR UPDATE;

-- 在另一个会话中查看锁信息
SELECT * FROM performance_schema.data_locks;

-- 输出示例(简化):
+--------+----------+-----------+-------------+
| ENGINE | LOCK_TYPE | LOCK_MODE | LOCK_DATA   |
+--------+----------+-----------+-------------+
| InnoDB | TABLE    | IX        | NULL        |
| InnoDB | RECORD   | X         | 20          |
| InnoDB | RECORD   | X         | 30          |
| InnoDB | RECORD   | X,GAP     | 20          |  ← 间隙锁
| InnoDB | RECORD   | X,GAP     | 30          |  ← 间隙锁
+--------+----------+-----------+-------------+

4.4 间隙锁何时释放?

答案:事务结束时(COMMIT 或 ROLLBACK)

锁类型 释放时机
间隙锁 事务提交或回滚时
行锁 事务提交或回滚时
临键锁 事务提交或回滚时

重要:间隙锁没有"锁超时"自动释放,必须等到事务结束。

4.5 间隙锁的危害

sql 复制代码
-- 事务1(糟糕的代码)
START TRANSACTION;
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
-- 锁住了 pending 状态的范围

-- 假设这里调用了外部 API,耗时 30 秒
CALL external_api();

UPDATE orders SET status = 'processing' WHERE status = 'pending';
COMMIT;

在这 30 秒内

  • 其他事务无法插入新的 pending 订单
  • 可能导致业务堆积、超时、死锁

教训:持有间隙锁的事务必须尽量短!


五、快照读 vs 当前读

操作类型 读取方式 加锁 场景
快照读 读 Undo Log 中的旧版本 不加锁 普通 SELECT
当前读 读数据的最新版本 加锁 SELECT FOR UPDATE、UPDATE、DELETE
sql 复制代码
-- 快照读(MVCC)
SELECT * FROM users WHERE id = 1;

-- 当前读(加锁)
SELECT * FROM users WHERE id = 1 FOR UPDATE;
UPDATE users SET balance = 100 WHERE id = 1;

六、MVCC vs 间隙锁:对比总结

方面 MVCC 间隙锁
目的 解决读写互斥 解决幻读(阻止插入)
实现 Undo Log + Read View B+树 + 内存锁对象
存储位置 Undo Log(磁盘 + 内存) 内存中的锁哈希表
生命周期 事务结束 + 无其他事务需要时清理 事务结束立即释放
是否阻塞读 不阻塞 不阻塞快照读,阻塞当前读
是否阻塞写 不阻塞(写新版本) 阻塞(阻止插入/更新)

七、生产环境如何选择隔离级别?

业务场景 推荐级别 原因
互联网高并发(电商、社交) RC 无间隙锁,死锁少,性能好
金融、对账 RR 需要可重复读,数据一致性要求高
数据仓库、报表 RC 大查询多,RR 的间隙锁容易死锁
默认(不确定) RR MySQL 默认,兼容性最好

查看和设置隔离级别

sql 复制代码
-- 查看当前隔离级别(MySQL 8.0)
SELECT @@transaction_isolation;

-- 设置当前会话级别
SET SESSION transaction_isolation = 'READ-COMMITTED';

-- 设置全局级别(影响新连接)
SET GLOBAL transaction_isolation = 'REPEATABLE-READ';

八、总结

8.1 隔离级别对比

隔离级别 脏读 不可重复读 幻读 间隙锁 并发性能
RU 最高
RC
RR
Serializable 最低

8.2 MVCC 核心公式

MVCC = 隐藏字段 + Undo Log + Read View

8.3 间隙锁核心公式

间隙锁 = B+树叶子节点指针 + 内存锁对象 + 哈希表冲突检测

8.4 快速记忆

复制代码
RU:啥都不防,性能最高
RC:只防脏读,不防重复(互联网最爱)
RR:防脏防重,幻读也防(MySQL默认)
Serializable:全都防住,性能最低

8.5 一句话总结

RC 用 MVCC 每次生成新快照,RR 用 MVCC 复用快照 + 间隙锁防止幻读。MVCC 是"写新留旧"实现读写不互斥,间隙锁是内存锁对象挂载在 B+树间隙上阻止插入。理解了这个,你就掌握了 MySQL 并发控制的精髓。


附录:常见问题 FAQ

Q1:RR 级别下,普通 SELECT 会有间隙锁吗?

A:不会。普通 SELECT 是快照读,基于 MVCC,不加任何锁。

Q2:间隙锁什么时候释放?

A:事务提交或回滚时。间隙锁没有"语句执行完就释放"的机制。

Q3:为什么互联网公司偏爱 RC?

A:RC 没有间隙锁,死锁概率低,高并发下性能更好。很多业务(如计数、点赞)并不需要严格的 RR。

Q4:MVCC 能解决写写冲突吗?

A:不能。MVCC 只解决读写冲突,写写冲突仍然需要行锁。

Q5:MVCC 是缓存吗?写操作是异步的吗?

A:不是。MVCC 是同步写新版本 + 保留旧版本,不是异步或延迟执行。

Q6:间隙锁是真实存在的锁吗?

A:是的。间隙锁是在内存中创建的真实锁对象,挂载在 B+树的叶子节点指针上,通过哈希表进行冲突检测。

相关推荐
weisian1512 小时前
进阶篇-LangChain篇-10--向量数据库选型指南:本地FAISS, Chroma与云原生方案
数据库·langchain·faiss·向量数据库·chroma
草莓熊Lotso3 小时前
MySQL 从入门到实战:视图特性 + 用户权限管理全解
linux·运维·服务器·数据库·c++·mysql
Navicat中国4 小时前
如何使用 Ollama 配置 AI 助手 | Navicat 教程
数据库·人工智能·ai·navicat·ollama
小猿姐8 小时前
实测对比:哪款开源 Kubernetes MySQL Operator 最值得用?(2026 深度评测)
数据库·mysql·云原生
倔强的石头_10 小时前
从 “存得下” 到 “算得快”:工业物联网需要新一代时序数据平台
数据库
TDengine (老段)11 小时前
TDengine IDMP 可视化 —— 分享
大数据·数据库·人工智能·时序数据库·tdengine·涛思数据·时序数据
GottdesKrieges12 小时前
OceanBase数据库备份配置
数据库·oceanbase
冬奇Lab12 小时前
MediaPlayer 播放器架构:NuPlayer 的 Source/Decoder/Renderer 三驾马车
android·音视频开发·源码阅读
SPC的存折13 小时前
MySQL 8组复制完全指南
linux·运维·服务器·数据库·mysql