公司动态

InnoDB MVCC的实现深度剖析:ReadView、Undo链与Purge线程的协同密码

📅 2026/7/18 21:34:22
InnoDB MVCC的实现深度剖析:ReadView、Undo链与Purge线程的协同密码
InnoDB MVCC的实现深度剖析ReadView、Undo链与Purge线程的协同密码一、一个慢查询拖死了Undo Purge磁盘炸了在一个秒杀场景的MySQL集群中出现了一个看似毫无关联的故障一条持续运行了8小时的报表查询大事务还没有提交此时系统开始告警磁盘使用率飙升。排查发现ibdata1系统表空间在几个小时内膨胀了200GB。根本原因是长时间未提交的读事务阻止了Purge线程回收Undo Log——MVCC需要保留老版本的Undo记录因为还有事务在看这些版本。当Purge无法推进时Undo表空间持续膨胀最终磁盘写满导致数据库拒绝写入。这个案例揭示了MVCC的深层运作机制事务能看到的历史版本越多、Undo链越长需要的存储空间越大Purge线程需要安全地回收不再被任何事务引用的Undo记录但不能回收到被活跃事务引用的版本。ReadView、Undo链和Purge三者构成一个紧密耦合的三角——任何一个的异常都会通过这个三角传导给整个系统。二、ReadView快照、Undo Log链表与Purge的三角关系InnoDB的MVCC基于三个核心组件运作ReadView是事务在启动时刻对哪些事务已提交的即时快照。它包含三个关键值m_low_limit_id下一个即将分配的事务ID大于等于此ID的事务修改不可见、m_up_limit_id当前活跃事务列表中最小的事务ID大于此ID的事务修改可能不可见、m_ids启动时所有活跃事务ID的列表。在可见性判断中一行数据的DB_TRX_ID如果小于m_up_limit_id则可见如果大于等于m_low_limit_id则不可见落入中间范围的则需要检查是否在m_ids列表中。Undo Log保存了每次修改前的旧值形成每个聚簇索引记录的版本链。链头指向最新版本通过DB_ROLL_PTR回滚指针串联各历史版本。读操作需要沿Undo链回溯找到在ReadView下可见的版本。Purge线程负责清理不再被任何活跃ReadView引用的Undo Log记录。它维护一个Purge View系统中最老的活跃ReadView的低水位只清理事务ID小于Purge View的Undo记录。这就是为什么长事务会阻塞Purge——它持有一个老的ReadView拉低了Purge View。sequenceDiagram participant T1 as 事务T1(trx_id100) participant T2 as 事务T2(trx_id101, ReadOnly) participant MVCC as MVCC/Undo系统 participant Purge as Purge线程 T1-MVCC: UPDATE users SET balance200 MVCC-MVCC: 生成Undo: balance100 MVCC--T1: OK T1-MVCC: COMMIT T2-MVCC: BEGIN (创建ReadView) Note over T2: m_up_limit100br/m_low_limit102br/m_ids[T2(101)] T2-MVCC: SELECT balance FROM users MVCC-MVCC: 当前版本: trx_id100, balance200 Note over MVCC: trx_id100 m_up_limit? YESbr/可见: balance200 MVCC--T2: balance200 Purge-Purge: 尝试Purge trx_id100的Undo Note over Purge: T2的ReadView保护br/Purge View100, 回收100 Note over T2: ...长时间未提交... Purge-Purge: 再次尝试Purge Note over Purge: T2仍持有ReadViewbr/Purge仍无法推进三、手动构造ReadView并遍历Undo链的调试脚本#!/usr/bin/env python3 InnoDB MVCC调试工具解析Undo链和可见性判断 import struct import logging from typing import List, Optional, Tuple from dataclasses import dataclass from enum import IntEnum logger logging.getLogger(__name__) class UndoLogType(IntEnum): INSERT 1 UPDATE 2 DELETE 3 dataclass class ReadView: 模拟InnoDB的ReadView m_low_limit_id: int # 活跃的最小事务ID m_up_limit_id: int # 下一个即将分配的事务ID m_creator_trx_id: int # 创建此ReadView的事务ID m_ids: List[int] # 启动时的活跃事务ID列表 def changes_visible(self, trx_id: int) - bool: 判断trx_id的修改是否对此ReadView可见 if trx_id self.m_creator_trx_id: return True # 自己的修改始终可见 if trx_id self.m_up_limit_id: return True # 在ReadView创建前已提交 if trx_id self.m_low_limit_id: return False # 在ReadView创建后修改的 # 在[up_limit, low_limit)之间检查是否在活跃列表中 return trx_id not in self.m_ids dataclass class RowVersion: simulate行记录的一个MVCC版本 trx_id: int roll_ptr: Optional[int] # 指向Undo Log的上一个版本的指针 data: dict # 该版本的数据 del_mark: bool False # 删除标记 class MVCCSimulator: MVCC可见性判断模拟器 def __init__(self): self.undo_log: List[RowVersion] [] self.current_versions: dict {} # key - 当前最新版本索引 self.read_views: List[ReadView] [] def insert_row(self, key: str, data: dict, trx_id: int): 插入新行 version RowVersion(trx_idtrx_id, roll_ptrNone, datadata.copy()) idx len(self.undo_log) self.undo_log.append(version) self.current_versions[key] idx logger.info(fINSERT {key}: trx_id{trx_id}) def update_row(self, key: str, new_data: dict, trx_id: int): 更新行创建Undo记录更新当前版本 if key not in self.current_versions: raise KeyError(fKey {key} not found) old_idx self.current_versions[key] old_version self.undo_log[old_idx] # 在新版本中保存旧版本的roll_ptr new_version RowVersion( trx_idtrx_id, roll_ptrold_idx, # 指向前一个版本 datanew_data.copy(), ) new_idx len(self.undo_log) self.undo_log.append(new_version) self.current_versions[key] new_idx logger.info( fUPDATE {key}: trx_id{trx_id}, fundo_chain{new_idx}-{old_idx} ) def select_row(self, key: str, read_view: ReadView) - Optional[dict]: 在指定ReadView下读取一行数据 if key not in self.current_versions: return None current_idx self.current_versions[key] # 沿Undo链回溯 idx current_idx visited 0 while idx is not None and visited 100: # 防无限循环 version self.undo_log[idx] visited 1 if read_view.changes_visible(version.trx_id): if version.del_mark: logger.debug( fREAD {key}: trx_id{version.trx_id} visible but deleted ) return None logger.debug( fREAD {key}: visible version trx_id{version.trx_id} ) return version.data.copy() idx version.roll_ptr # 这个版本不可见去Undo链上前一个版本 logger.debug(fREAD {key}: no visible version found) return None def build_read_view(self, creator_trx: int, active_trx_list: List[int]) - ReadView: 创建ReadView模拟事务开始时的快照 if not active_trx_list: up_limit creator_trx 1 else: up_limit min(active_trx_list) view ReadView( m_low_limit_idcreator_trx 1, m_up_limit_idup_limit, m_creator_trx_idcreator_trx, m_idslist(active_trx_list), ) logger.info( fReadView: creator{creator_trx}, fup_limit{up_limit}, low_limit{creator_trx1}, factive{active_trx_list} ) return view def can_purge(self, min_active_view_low: int) - List[int]: 模拟Purge回收不再被任何ReadView引用的Undo purgeable [] for i, version in enumerate(self.undo_log): if (version.trx_id min_active_view_low and i not in self.current_versions.values()): # 检查是否有活跃版本通过roll_ptr引用它 referenced False for v in self.undo_log: if v.roll_ptr i and v.trx_id min_active_view_low: referenced True break if not referenced: purgeable.append(i) return purgeable在真实InnoDB中trx_sys-mvcc-view_open()函数创建ReadViewrow_vers_impl_x_locked()函数沿Undo链回溯查找可见版本。Purge线程在主循环srv_purge_coordinator_thread中执行通过trx_purge函数推进Purge。四、大事务、长查询与Purge滞后的死亡螺旋大事务导致的MVCC问题是复合型的。首先是Undo表空间膨胀一个大事务如批量UPDATE 1亿行会生成1亿条Undo记录。这些Undo在事务提交后进入Purge队列。如果Purge速度跟不上Undo生成速度通常在IO瓶颈的机器上发生Undo表空间线性膨胀。Undo膨胀进一步加剧IO负载因为所有读操作都可能需要回溯更长的Undo链——形成负反馈循环。长查询的ReadView滞留是另一个独立但同样危险的问题。一条SELECT查询可能出于合理原因运行很长时间——比如一个重要的月度对账报表。但它持有的ReadView阻止了Purge推进所有在它开始后提交的修改产生的Undo都无法回收。解决办法是设置innodb_undo_log_truncateON和合理的max_execution_time限制将长查询的影响范围最小化。Purge延迟的监控是早期预警的关键。三个核心指标Innodb_purge_trx_id_agePurge落后的事务ID数、Innodb_history_list_lengthUndo历史链长度和Undo表空间大小。当history_list_length持续增长超过100K时需要检查是否有长事务或Purge线程繁忙。五、总结InnoDB的MVCC系统是一个精妙的三角平衡ReadView保证了事务的快照隔离语义Undo链提供了多版本数据的存储和访问路径Purge线程维持了存储空间的可控性。这个三角中任意一角的失衡——长事务降低Purge View、Undo生成速率超过Purge能力、Purge线程被IO阻塞——都会级联影响整个系统。生产运维的三个关键动作是监控history_list_length、限制长事务的持续时间、在IO能力不足的实例上增加Purge线程数。MVCC不是免费午餐——它的存储成本和维护成本需要在系统设计时就纳入考虑。