Undo Log版本链通过DB_ROLL_PTR指针将同一行的多个历史版本串联成单向链表,头部为最新已提交版本,遍历时依可见性规则回溯查找。

Undo Log版本链是怎么串起来的
每一行数据在InnoDB里都有两个隐藏字段:DB_TRX_ID(最近修改它的事务ID)和DB_ROLL_PTR(指向旧版本的指针)。每次UPDATE或DELETE,InnoDB不会直接覆盖原数据,而是把旧值写进Undo Log,并用DB_ROLL_PTR把它连到上一个版本,形成一条从新到旧的单向链表。
注意:只有INSERT产生的Undo Log在事务提交后可立刻清理,因为没“上一版本”;而UPDATE/DELETE的Undo Log必须保留到所有可能用到它的Read View都失效之后——否则快照读会找不到历史版本。
- 版本链头部永远是最新已提交版本(不是“当前未提交”的那个),遍历时从
DB_ROLL_PTR出发,逐级回溯 - 如果某次查询需要读取“事务开始时”的快照,但链上最新版的
DB_TRX_ID不可见,就得顺着指针往下找,直到找到第一个满足可见性规则的版本 - 版本链不跨行、不跨表,只针对同一行记录;不同事务对同一行反复更新,链就变长,但不会无限增长——Purge线程会在后台异步清理无用版本
Read View生成时机和判断逻辑
在REPEATABLE READ隔离级别下,Read View只在事务中**第一次执行快照读(即普通SELECT)时生成**;而在READ COMMITTED下,每次SELECT都会新建一个Read View。它不是“静态快照”,而是一套动态可见性规则。
Read View包含四个关键信息:m_ids(当前活跃事务ID集合)、min_trx_id(m_ids中最小ID)、max_trx_id(下一个待分配事务ID,非最大活跃ID)、以及创建该View的事务自身ID。
- 判断某行是否可见:先看它的
DB_TRX_ID—— 若小于min_trx_id,说明修改它的事务早已提交,可见 - 若
DB_TRX_ID在m_ids里,说明那事务还没提交,不可见 - 若
DB_TRX_ID≥max_trx_id,说明该版本由“未来事务”写入(ID分配是严格递增的),也不可见 - 其他情况(比如等于当前事务ID),则可见 —— 这就是为什么你能在自己事务里读到自己刚改的数据
为什么“可重复读”能避免不可重复读,却不一定防幻读
不可重复读的本质是:同一行数据被其他事务反复UPDATE/DELETE并提交,导致你两次SELECT看到不同值。MVCC靠固定Read View解决了这个问题——后续查询都复用第一次生成的m_ids和水位线,旧版本只要还在链上,就能读出来。
但幻读是另一回事:它源于其他事务INSERT了新行,而这些新行的DB_TRX_ID可能落在你的Read View可见范围内(比如比min_trx_id还小),于是第二次SELECT突然多出几条记录。
- MySQL的
REPEATABLE READ通过间隙锁(Gap Lock)+ Next-Key Lock 来堵住插入,不是靠MVCC本身 - 如果你关掉
innodb_locks_unsafe_for_binlog或用了READ COMMITTED,间隙锁会退化,幻读就容易出现 - 单纯依赖MVCC无法拦截
INSERT带来的新行,因为新行没有“旧版本链”,它的DB_TRX_ID就是当前事务ID,天然满足可见条件
常见误判:为什么我查不到刚提交的数据
最常遇到的不是“读到旧数据”,而是“明明提交了,怎么还查不到”。典型场景是:事务A执行UPDATE并COMMIT,事务B却在之后的SELECT中看不到变更。
这往往不是MVCC的问题,而是事务B的隔离级别或启动时机不对:
- 事务B在事务A提交前就已启动,并且是
REPEATABLE READ,那它的Read View已经固化,自然看不见A的新版本 - 事务B用了
SELECT ... LOCK IN SHARE MODE或SELECT ... FOR UPDATE——这是当前读,会跳过MVCC走聚簇索引最新版,但如果A还没提交,B会被阻塞;若A已提交,B应该能看到,看不到多半是锁冲突或死锁回滚了 - 检查是否意外开启了
AUTOCOMMIT=0且忘了COMMIT,或者客户端连接复用导致事务未真正结束
MVCC本身不保证“立即可见”,它只保证“按事务视图一致地可见”。真正影响“能不能读到”的,是Read View生成时刻、事务状态、以及你发的是快照读还是当前读——这点最容易被忽略。










