mysql如何实现多版本并发控制_解析Undo版本链与ReadView构建
Undo日志以链表结构存储每次修改前的完整行记录(含DB_TRX_ID和DB_ROLL_PTR),INSERT仅记主键,UPDATE/DELETE存整行旧值;ReadView在首个SELECT时生成,含m_up_limit_id、m_low_limit_id、m_ids和m_creator_trx_
Undo日志以链表结构存储每次修改前的完整行记录(含DB_TRX_ID和DB_ROLL_PTR),INSERT仅记主键,UPDATE/DELETE存整行旧值;ReadView在首个SELECT时生成,含m_up_limit_id、m_low_limit_id、m_ids和m_creator_trx_id;可见性判断按固定顺序:先查是否为本事务、再查是否在活跃列表、最后查ID范围。

Undo日志里存的到底是什么结构
说起Undo日志,很多人第一反应是它存了“上一个值”。这么说对,但不够精确。实际上,它是以链表的形式,完整记录了每次修改前的整行数据——包括那些你看不见的隐藏字段,比如DB_TRX_ID和DB_ROLL_PTR。这一串记录,就构成了一条undo log chain。链表的指针就是DB_ROLL_PTR,它指向前一个版本,所以最新版本永远在链头,最早的历史版本则沉在链尾。
这里有个关键细节值得拎出来:INSERT操作生成的undo日志比较“轻量”,它只记录被插入行的主键信息,相当于一个“逻辑删除”标记。而UPDATE和DELETE可就实在多了,它们生成的是“物理快照”——也就是修改前那一刻,整行数据的完整拷贝。这意味着,哪怕你只改动了一个字段,Undo里也会把这一行所有的旧值都存下来。空间换时间,是数据库实现一致性的经典思路。
顺便提一个常见的理解误区:纯SELECT查询是不会触发Undo日志写入的,只有INSERT、UPDATE、DELETE这类DML操作才会。所以,如果你发现系统表空间ibdata1文件不断膨胀,别急着怪查询,大概率是某个长事务一直没提交,导致它能看到的所有旧版本都无法被后台的purge线程清理掉。
ReadView 是什么时候生成的、包含哪些字段
ReadView,你可以把它理解成事务给自己拍的一张“快照”。但请注意,这张快照不是在事务启动时就拍的,而是在它执行第一个SELECT语句(或者显式开启一致性读)的瞬间才生成。这个时机差异,对于理解不同隔离级别的行为至关重要。
这张“快照”里到底记录了什么呢?主要是四个核心的元信息字段:
m_up_limit_id:可以理解为活跃事务ID列表中的最小值,也就是系统中尚未分配出去的下一个事务ID。m_low_limit_id:生成ReadView时,系统里已经出现过的最大事务ID再加1。它和m_up_limit_id共同划出了一个ID范围。m_ids:在生成ReadView的那个时间点,所有正在进行中的、非只读事务的ID列表。这是判断“活跃事务”的直接依据。m_creator_trx_id:创建这个ReadView的事务自身的ID。有了它,事务才能看到自己刚修改但还没提交的数据。
这里有个重要的行为差异:在REPEATABLE READ(可重复读)隔离级别下,一个事务里后续所有的SELECT都会复用第一次生成的ReadView,从而保证“可重复读”。而在READ COMMITTED(读已提交)级别下,每次SELECT都会新建一个ReadView,因此每次都能读到最新提交的数据。
如何判断某条 Undo 版本是否对当前查询可见
有了Undo版本链和ReadView,接下来就是最关键的一步:判断。MySQL有一套固定且顺序严格的可见性判断规则,就像过安检的步骤,一步都不能错:
- 首先,看版本的事务ID(
DB_TRX_ID)是不是等于当前事务自己的ID(m_creator_trx_id)。如果是,说明这版本就是我自己改的,当然可见。 - 如果不是,再看这个ID是否小于
m_up_limit_id。如果是,意味着这个版本对应的事务,在ReadView创建前就已经提交了,对当前事务可见。 - 如果也不满足,就检查它是否大于等于
m_low_limit_id。如果是,说明这个版本对应的事务是在ReadView创建后才启动的,属于“未来事务”,当前不可见。 - 最后,检查这个ID是否落在
m_ids这个活跃事务列表里。如果在,说明生成ReadView时,那个事务还在运行中,其修改自然不可见;如果不在,则可见。
这个判断过程从Undo链的头部(最新版本)开始,逐个版本向下遍历,直到找到第一个符合“可见”条件的版本为止。如果整条链翻完都找不到一个可见版本,那么对于当前事务来说,这行记录就相当于不存在(这正是“幻读”现象背后的原理之一)。
由此引出一个典型的运维陷阱:长事务。一个长时间不提交的事务,会一直“拖住”m_up_limit_id,导致大量早该被清理的旧版本无法被purge,结果就是Undo表空间不断膨胀,极端情况下甚至会阻塞DDL操作。
为什么 SELECT * FROM t WHERE id = 1 可能走不到索引上的最新版本
这个问题很有意思。即使你的查询条件id = 1命中了主键索引,InnoDB的查找过程也分两步走:第一步,通过主键索引快速定位到物理记录;第二步,根据这条记录上的DB_ROLL_PTR指针,回溯整个Undo版本链,运用上面那套规则,找出对当前事务可见的那个版本。所以,“通过索引定位”和“版本可见性过滤”是两个独立的阶段。
那么,哪些情况会让这个过程的性能影响变得明显呢?
- 当表更新非常频繁,同时又存在长事务时,Undo链会变得很长。每次
SELECT都需要遍历这条长链,开销自然就上去了。 - 如果使用
READ COMMITTED隔离级别且并发很高,虽然每次判断的逻辑不变,但频繁地创建和销毁ReadView也会带来额外的CPU开销。 - 当查询条件命中的是二级索引时,路径就更长了:先查二级索引页,取得主键后再“回表”查询聚簇索引,最后再回溯Undo链。三次跳转,延迟可想而知。
其实,最容易被忽略的性能场景反而是那种没有WHERE条件的全表扫描SELECT * FROM t。这种查询会为表中的每一行都执行一遍完整的可见性判断。此时,Undo链的平均长度和系统中的活跃事务数量,将直接决定查询响应时间的波动范围。理解这一点,对诊断数据库的偶发性慢查询很有帮助。
