mysql的mvcc通过readview、undo log和隐藏字段协同实现非阻塞读,其中readview在事务首次快照读时生成(repeatable read)或每次快照读前新建(read committed),决定版本可见性;update/delete生成版本链,insert不生成;快照读遵循readview,当前读(如select...for update)则直接读最新版本并加锁。

MySQL 的 MVCC 不是靠“加锁读”来避免冲突,而是靠 ReadView + Undo Log + 隐藏字段三者协同,在事务开启时定下“可见性规则”,让读操作自动跳过不该看到的版本。关键不是“怎么读新数据”,而是“怎么判断哪个旧版本该被读到”。
ReadView 什么时候生成?取决于隔离级别
同一个事务里,ReadView 是否复用,直接决定是否可重复读:
-
READ COMMITTED:每次执行SELECT(快照读)前都新建一个ReadView,所以能看到其他事务刚提交的新版本 → 可能出现不可重复读 -
REPEATABLE READ:仅在事务中**第一次快照读**时生成ReadView,后续所有快照读都复用它 → 同一事务内多次读同一行,结果一致 -
READ UNCOMMITTED:不生成ReadView,直接读最新版本 → 可能脏读 -
SERIALIZABLE:退化为加锁读(当前读),ReadView不起作用
ReadView 的四个核心字段怎么参与可见性判断?
当 InnoDB 检查某条记录的版本是否对当前事务可见时,会拿该版本的 DB_TRX_ID(写该版本的事务 ID)和 ReadView 的四个字段比对:
-
creator_trx_id:当前事务自己的 ID。若匹配,说明是自己改的 → 可见 -
trx_ids:生成ReadView时所有活跃(未提交)事务 ID 的数组。若该版本的DB_TRX_ID在这个数组里 → 不可见(因为那个事务还没提交) -
up_limit_id:即trx_ids中最小的事务 ID。若DB_TRX_ID → 表示这个版本由已提交事务写入 → 可见 -
low_limit_id:下一个将分配的事务 ID。若DB_TRX_ID >= low_limit_id→ 表示这个版本由未来事务写入(不可能发生,但逻辑上排除)→ 不可见
不满足任一可见条件,就顺着 DB_ROLL_PTR 往 Undo Log 版本链上回溯,直到找到第一个满足条件的版本或链尾。
为什么 UPDATE/DELETE 会产生版本链,而 INSERT 不?
版本链只对“被修改过”的行存在,本质是 Undo Log 的组织方式:
-
INSERT:新行没有历史版本,DB_ROLL_PTR为空,不形成链 -
UPDATE或DELETE:InnoDB 先把原值写进Undo Log,再更新聚簇索引页,并用DB_ROLL_PTR指向刚写的 undo 记录 → 新旧版本靠指针串成链 - 每次修改都生成新节点,最新版本在链头,最老版本在链尾
- 只有当事务提交且无其他事务依赖该版本时,对应
Undo Log才可能被 purge 线程清理
容易忽略的细节:快照读 vs 当前读的边界在哪?
很多人以为“只要开了事务,所有 SELECT 都走 MVCC”,其实不然:
- 带锁的
SELECT ... LOCK IN SHARE MODE或SELECT ... FOR UPDATE是当前读,不走ReadView,直接读最新版本并加锁 -
UPDATE/DELETE/INSERT本身也是当前读 —— 它们必须基于最新数据做判断(比如检查唯一约束、外键),所以会先做一次当前读,再写新版本 - 这意味着:即使在
REPEATABLE READ下,一个事务内先SELECT(快照读,看到旧值),再UPDATE(当前读,读到别人刚提交的新值),然后提交 —— 就可能发生“幻读”或“更新丢失”
真正难处理的,从来不是“怎么读旧数据”,而是“怎么协调快照读和当前读在同一事务里的语义冲突”。这是 MVCC 的能力边界,也是业务层必须配合加锁或重试的原因。











