脏读: 读到了其他事物未提交的数据
不可重复读: 一个事务中,多次读取同一行数据,由于其他事物的修改和 commit,导致每次读到的数据不一致
幻读: 在同一个事务中,多次执行相同的查询,由于其他事务的插入或删除操作,导致每次查询的结果集不同。
| MySQL 定义的隔离级别 | 解决脏读 | 解决不可重复读 | 解决幻读 |
|---|---|---|---|
| 读未提交 Read Uncommitted | ❌ | ❌ | ❌ |
| 读已提交 Read Committed | ✅ | ❌ | ❌ |
| 可重复读 Repeatable Read(MySQL 的默认隔离级别) | ✅ | ✅ | ❌ |
| 串行化 Serializable(代价是性能低) | ✅ | ✅ | ✅ |
MVCC 的核心组件
MVCC 由 UndoLog 和 ReadView 这 2 大核心组件共同实现的
UndoLog 负责版本链的存储,ReadView 负责判断哪些版本对当前事务可见
UndoLog
Undo Log 是 InnoDB 存储引擎中用于事务回滚和 MVCC 的关键组件。它记录了数据修改前的旧值, 使得事务可以回滚, 同时也为 MVCC 提供历史版本数据,通过 UndoLog 使得多个事务可以并发读取同一行数据的不同历史版本。
在 MySQL 中,聚簇索引的每一行记录都会有 3 个隐藏列,这 3 个隐藏列配合 ReadView 还可以用于实现 MVCC:
- trx_id:最后修改该行的事务 id
- roll_ptr:回滚指针,指向该行的 UndoLog 记录
- row_id:行 id(仅在行没有主键的时候存在)
换句话说:只有聚簇索引对应的索引树才会在叶子节点中会存储这 3 个隐藏列
事务修改一行数据的时候,InnoDB 会:
- 将旧值写入 UndoLog,并且记录旧的 trx_id 和 roll_ptr
- 更新 trx_id 为当前事务 id
- 更新roll_ptr 指向最新创建的 UndoLog
- 写入 RedoLog,记录 UndoLog 的写入操作
- 上面都之后才执行实际的数据更新
- 写入 RedoLog,记录数据的修改
在这过程中会形成一条版本链:

ReadView 机制
ReadView 是 InnoDB MVCC 机制的核心组件, 用于判断事务可以看到哪些数据版本。它在事务开始读取数据时创建, 记录当前系统中活跃事务的快照信息。
ReadView 的创建时间
不同隔离级别下 ReadView 的创建时机不同:
READ COMMITTED (RC): 每次执行 SELECT 语句时都创建新的 ReadView,每个 select 都有独立的 ReadView
REPEATABLE READ (RR): 事务中第一次执行 SELECT 时创建 ReadView, 之后整个事务期间复用同一个 ReadView
假设现在是用 RR 隔离级别:
BEGIN;
SELECT * FROM users WHERE id = 1; // 第一次select,创建 ReadView
SELECT * FROM users WHERE id = 2; // 复用之前的 ReadView
COMMIT; // 事务提交, ReadView 被销毁
ReadView 的组成
每次创建 ReadView,都会收集以下信息:
- trx_ids:当前所有活跃的读写事务 (已经开启但是还没提交的事务)
- min_trx_id:活跃事务 (trx_ids) 中最小的事务 id
- max_trx_id:系统中尚未分配的最小事务 id,可以看作是
max (trx_ids)+1 - creator_trx_id:创建 ReadView 的事务自身的 id
ReadView 的可见性判断
当读取一行数据时,通过修改该行的 trx_id(最后修改该行的事务 id)与 ReadView 进行判断:
- 如果 trx_id < ReadView 拿到的活跃事务中的最小事务 id,说明该版本是由已提交的事务创建的,可见
- 如果 trx_id >= ReadView 拿到的系统中尚未分配的最小事务 id,说明该版本由 ReadView 创建后才开启的事务创建的,不可见
- 如果活跃事务中最小的 id <= trx_id < 未分配的最小事务 id:
- 如果 trx_id 在活跃事务 id 中,说明该事务在 ReadView 创建时仍然活跃(未提交),不可见
- 如果 trx_id 不在活跃事务 id 中,说明该事务已提交,可见
Read Committed 隔离级别如何解决脏读问题?
RC 隔离级别是通过 ReadView 机制和半一致性读优化,确保事务只能读取已提交的数据,从而解决脏读问题
在 RC 隔离级别下,每次 select 都会创建新的 ReadView,基于 ReadView 的可见性判断,能确保不读取到未提交的数据。
RC 为什么不能解决不可重复读?
RC 不能解决不可重复读的根本原因是:每次 select 都创建新的 ReadView
if (trx->isolation_level > TRX_ISO_READ_UNCOMMITTED) {
trx_assign_read_view(trx);
}
具体场景如下:
-- 事务 A (RC 隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1; -- 读取到 name = '张三', 第1次创建 ReadView
-- 此时事务 B 修改了 id=1 的记录并提交
SELECT * FROM users WHERE id = 1; -- 读取到 name = '李四' (不可重复读!) ,第2次创建 ReadView
COMMIT;
所以才需要用 RR 去解决这个问题,RR 可以复用 ReadView,确保一个事务中读到的都是同一个版本的数据。
RR 为什么不能解决幻读?
幻读: 在同一个事务中,多次执行相同的查询,由于其他事务的插入或删除操作,导致每次查询的结果集不同。
在 快照读的场景下,RR 可以防止幻读。在 当前读的场景下,RR 无法防止幻读。
InnoDB 支持 2 种读取方式:
- 快照读:普通的 select 语句,基于 mvcc 实现
- 当前读:
select ... for UPDATE,select ... LOCK IN SHARE MODE,UPDATE,DELETE等语句。这些语句不仅仅是读取数据,还会对读取的数据上锁。它总是读取数据的最新版本,而不是历史快照。
在当前读的场景下,不使用 ReadView,而是直接上锁,从聚簇索引读取最新的数据。
-- 事务A:
BEGIN;
-- 假定还没有id为1的数据
SELECT * FROM users WHERE id = 1; -- 创建 ReadView, 读不到数据
-- 此时 事务 B 插入了 id=1的数据, 并且 commit了
-- 事务A继续select
SELECT * FROM users WHERE id = 1; -- 复用ReadView, 仍然读不到
-- 事务A更新数据
UPDATE users SET name='lucas' WHERE id = 1;
SELECT * FROM users WHERE id = 1; -- 此时可以查到数据 name=lucas,id=1
不是说 RR 隔离级别下会复用 ReadView 吗?为什么复用 ReadView 了也能看到 update 后的数据?
其实 ReadView 的作用是 控制可见性,事务 A 执行了 update 后,ReadView 拿到的 trx_id (最后修改该行的事务id)被修改了。而 ReadView 控制可见性又依赖于 trx_id,这就影响了 ReadView 对于可见性的判断。所以在这种情况下是会出现幻读问题的。
参考资料
动画讲解:MySQL的MVCC原理,RR不能完全解决幻读问题的底层原理,Undo版本链
- 这个视频讲解的幻读不是很对