脏读: 读到了其他事物未提交的数据

不可重复读: 一个事务中,多次读取同一行数据,由于其他事物的修改和 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,记录数据的修改

在这过程中会形成一条版本链:

An image to describe post

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到底解决了幻读没有

动画讲解:MySQL的MVCC原理,RR不能完全解决幻读问题的底层原理,Undo版本链

  • 这个视频讲解的幻读不是很对