事务隔离级别与 MVCC

举报
yd_227606267 发表于 2026/11/02 13:42:11 2026/11/02
【摘要】 从 ReadView 与 undo 版本链讲透 MVCC,并用两个会话复现隔离级别差异

同一个事务里两次查询结果不一致,或者"查不到却插不进去"——这些现象背后是隔离级别与 MVCC 在起作用。很多文章只给出"RR 解决了不可重复读"的结论,但说不清为什么 RR 下幻读只是"部分解决"。这篇文章从 undo 版本链和 ReadView 的源码级结构讲起,最后用两个会话把四种隔离级别的行为逐一复现。基准 MySQL 8.0 + InnoDB,5.7 的差异单独标注。

一、四种隔离级别与三种异常

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

| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现 |

| --- | --- | --- | --- | --- |

| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本 |

| READ COMMITTED | 不可能 | 可能 | 可能 | 每次快照读生成 ReadView |

| REPEATABLE READ(默认) | 不可能 | 不可能 | 可能(快照读下不可能) | 首次快照读生成 ReadView |

| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 快照读加共享 next-key lock |

三个概念要分清:

  • 脏读:读到别的事务未提交的数据。
  • 不可重复读:同一事务内两次读同一行,值变了(别人 UPDATE 并提交)。
  • 幻读:同一事务内两次按同一条件查,行数变了(别人 INSERT 并提交)。

注意:MySQL 8.0 与 5.7 的默认隔离级别都是 REPEATABLE READ,这一点没有变化。常被混淆的是 8.0 默认字符集从 latin1 变成 utf8mb4、默认认证插件从 mysql_native_password 变成 caching_sha2_password。

二、当前读与快照读

InnoDB 的读分两种,这是理解 MVCC 的前提:

  • 快照读(一致性非锁定读):普通 SELECT,读 undo 版本链上的某个历史版本,不加锁。
  • 当前读(锁定读):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT,读最新已提交版本并加锁。

MVCC 只作用于快照读。当前读永远读最新数据,这也是"RR 下幻读只被部分解决"的根源。

三、undo log 版本链

每行有三个隐藏列:

  • DB_TRX_ID:最后修改该行的事务 ID。
  • DB_ROLL_PTR:指向 undo log 中该行的上一个版本。
  • DB_ROW_ID:无主键时生成的行 ID。

每次 UPDATE 都会把旧值写进 undo log,并用 DB_ROLL_PTR 串成链表。假设一行被事务 100、200、300 依次修改,版本链如下:

当前行 (DB_TRX_ID=300)
   ↓ roll_ptr
undo: 版本 200 (值 B)
   ↓
undo: 版本 100 (值 A)
   ↓
undo: 插入前的初始版本

快照读要读哪个版本,由 ReadView 决定。purge 线程负责删除"没有 ReadView 需要"的旧版本,长事务会阻止 purge。

四、ReadView 的结构

ReadView 是事务在做快照读时生成的一个可见性判断依据,核心字段四个:

  • m_ids:生成 ReadView 时,当前活跃(未提交)的事务 ID 列表。
  • min_trx_id:m_ids 中的最小值。
  • max_trx_id:生成 ReadView 时系统应分配的下一个事务 ID(即当前最大事务 ID + 1)。
  • creator_trx_id:创建该 ReadView 的事务 ID。

判断某版本是否可见,按顺序比较:

if trx_id == creator_trx_id:        可见(自己的修改)
elif trx_id < min_trx_id:           可见(提交早于所有活跃事务)
elif trx_id >= max_trx_id:          不可见(ReadView 之后才开启的事务)
elif trx_id in m_ids:               不可见(生成 ReadView 时还活跃)
else:                               可见(已提交)

不可见就顺着 DB_ROLL_PTR 找上一个版本,直到找到可见版本或链表结束。

RC 与 RR 的唯一差别

两者判断逻辑完全一样,差别只在生成 ReadView 的时机:

  • RC:每次快照读都生成新的 ReadView,所以能看到别人新提交的数据。
  • RR:只在事务的第一次快照读时生成,之后复用,所以整个事务看到的是同一份快照。

这也解释了一个细节:RR 下如果事务先执行 UPDATE(当前读)再 SELECT,ReadView 是在 UPDATE 之后才生成的,此时能看到自己和他人的最新提交。

五、可复现实验:隔离级别差异

准备数据:

CREATE DATABASE IF NOT EXISTS mvcc_demo DEFAULT CHARACTER SET utf8mb4;
USE mvcc_demo;

CREATE TABLE account (
  id      BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  name    VARCHAR(32)     NOT NULL,
  balance INT             NOT NULL,
  PRIMARY KEY (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

INSERT INTO account (name, balance) VALUES ('alice', 1000), ('bob', 1000);

-- 确认隔离级别
SELECT @@transaction_isolation;  -- 8.0 与 5.7.20+ 均为 transaction_isolation

注意:5.7.20 之前变量名是 tx_isolation,8.0 已移除旧名,脚本里混用会报错。

实验一:RC 下的不可重复读

会话 A:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE id = 1;  -- 1000

会话 B:

START TRANSACTION;
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;

会话 A 再查:

SELECT balance FROM account WHERE id = 1;  -- RC: 900,出现不可重复读
COMMIT;

实验二:RR 下同一场景

把会话 A 的隔离级别换成 REPEATABLE READ 重做,第二次查询仍是 1000。原因是 ReadView 在第一次 SELECT 时生成,事务 B 的 trx_id 大于等于 max_trx_id,落在"不可见"分支,于是沿版本链读到了旧值。

实验三:RR 下的幻读

会话 A:

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE id > 100;  -- 0

会话 B:

INSERT INTO account (name, balance) VALUES ('carol', 500);
COMMIT;

会话 A 再查,仍是 0(快照读)。但此时执行:

SELECT COUNT(*) FROM account WHERE id > 100 FOR UPDATE;  -- 1,当前读看到了新行

这就是"RR 部分解决幻读":快照读不幻读,当前读仍会幻读。要彻底避免,需要在事务开始时用 SELECT ... FOR UPDATE 或 LOCK IN SHARE MODE 对范围加 next-key lock,或者升级到 SERIALIZABLE。

六、RR 为什么"看起来"解决了幻读

关键在于间隙锁。在 RR 下,UPDATE、DELETE 以及加锁读会对扫描到的索引区间加 next-key lock(记录锁 + 间隙锁),阻止其他事务在区间内插入。所以"先查后插"的业务在 RR 下用 SELECT ... FOR UPDATE 是安全的。

但普通快照读不加锁,别人仍然可以插入,只是你的快照读看不到。如果后续基于快照做了 UPDATE,就会出问题:

-- 会话 A(RR)
START TRANSACTION;
SELECT COUNT(*) FROM account WHERE id > 100;  -- 0,认为没有
UPDATE account SET balance = balance + 1 WHERE id > 100;  -- 影响 1 行!

UPDATE 是当前读,看到了别人插入的行。这是"快照读 + 当前读混用"导致的经典不一致。

七、当前读的加锁验证

-- 会话 A
START TRANSACTION;
SELECT * FROM account WHERE id = 1 FOR UPDATE;

-- 会话 B,会阻塞
UPDATE account SET balance = 0 WHERE id = 1;

在会话 B 中执行 SHOW ENGINE INNODB STATUS\G,TRANSACTIONS 段能看到 lock_mode X locks rec but not gap 和等待信息。这是验证加锁行为最直接的手段。

八、常见坑 / 最佳实践

  1. 不要为了"性能"把隔离级别改成 RC 却不评估业务。RC 下没有间隙锁,死锁更少、并发更高,但会出现不可重复读和幻读。如果业务依赖"事务内多次查询结果一致",必须留在 RR。
  2. 事务里不要混用快照读和当前读做判断。先用普通 SELECT 判断存在性、再 UPDATE,中间可能被别人插入。正确做法是直接用 SELECT ... FOR UPDATE 或依赖唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE。
  3. 事务尽量短。RR 的 ReadView 从第一次快照读一直持有到事务结束,长事务会让 purge 无法清理,undo 表空间膨胀,同时 history list length 持续上升。
  4. 监控 innodb_trx 的 trx_isolation_level。连接池、中间件、ORM 都可能改掉会话隔离级别,排查时先确认实际值,别假设是默认 RR。
  5. SELECT ... FOR UPDATE 必须命中索引。没命中索引会锁全表所有记录加间隙,等于把并发打回串行,还可能引发大面积死锁。

九、小结

  • MVCC 通过 undo 版本链 + ReadView 实现快照读,读操作不加锁。
  • ReadView 四个字段:m_ids、min_trx_id、max_trx_id、creator_trx_id,按事务 ID 区间判断可见性。
  • RC 与 RR 的判断逻辑相同,唯一差别是 ReadView 生成时机:每次读 vs 首次读。
  • RR 下快照读不会幻读,但当前读仍会;间隙锁只对加锁读和写操作生效。
  • 长事务是 MVCC 最大的敌人,直接表现为 undo 膨胀与 purge 滞后。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。