MySQL存储引擎内部之:`InnoDB`的`MVCC`:`undo log`和`read view`在`MVCC`中的协同工作。

好的,我们开始今天关于MySQL InnoDB存储引擎中MVCC机制,以及undo log和read view如何协同工作的技术讲座。

引言:并发控制的必要性

在多用户并发访问数据库时,如果没有适当的并发控制机制,可能会出现以下问题:

  • 更新丢失(Lost Update): 两个事务读取同一数据,然后都进行修改并提交,后提交的事务覆盖了前一个事务的修改。
  • 脏读(Dirty Read): 一个事务读取了另一个事务尚未提交的数据,如果未提交的事务回滚,则读取的数据无效。
  • 不可重复读(Non-Repeatable Read): 在同一事务中,多次读取同一数据,由于其他事务的修改,导致每次读取的结果不一致。
  • 幻读(Phantom Read): 在同一事务中,使用相同的查询条件,多次读取记录,由于其他事务的插入/删除操作,导致每次读取的结果集数量不一致。

为了解决这些问题,数据库需要提供并发控制机制,保证事务的隔离性。InnoDB使用了MVCC(Multi-Version Concurrency Control,多版本并发控制)来实现高并发下的数据一致性。

MVCC的核心思想

MVCC的核心思想是:对于每一行数据,保留其多个历史版本,事务在读取数据时,根据一定的规则选择合适的版本,从而避免了读取数据时需要加锁,提高了并发性能。

undo log:数据历史版本的记录者

undo log是InnoDB中非常重要的组成部分,它记录了事务对数据进行修改之前的信息,主要用于以下两个方面:

  1. 事务回滚(Rollback): 当事务执行过程中发生错误或者需要手动回滚时,可以使用undo log将数据恢复到事务开始之前的状态。
  2. MVCC: undo log中保存了数据的历史版本,MVCC机制可以通过undo log来访问数据的旧版本。

undo log可以分为两种类型:

  • insert undo log: 在insert操作时产生的undo log,只在事务回滚时需要,在事务提交后就可以被purge线程清理掉。
  • update undo log: 在update和delete操作时产生的undo log,不仅在事务回滚时需要,在MVCC中也需要,因此不能立即删除,需要等待没有事务需要访问该版本时才能被purge线程清理。

undo log实际上是按照事务发生的时间顺序,将数据的修改历史串联起来。

代码示例(模拟undo log的结构):

class UndoLogEntry:
    def __init__(self, table_name, row_id, old_values, trx_id, prev_undo_log=None):
        self.table_name = table_name  # 表名
        self.row_id = row_id          # 行ID
        self.old_values = old_values  # 修改前的旧值 (字典)
        self.trx_id = trx_id          # 事务ID
        self.prev_undo_log = prev_undo_log # 指向上一个undo log entry

    def apply_undo(self, table):
        """将此 undo log 应用到指定表"""
        table.update_row(self.row_id, self.old_values)

    def __repr__(self):
        return f"UndoLogEntry(table_name={self.table_name}, row_id={self.row_id}, old_values={self.old_values}, trx_id={self.trx_id})"

class Table:
    def __init__(self, name):
        self.name = name
        self.rows = {}  # 模拟表中的数据,key是row_id, value是字典

    def insert_row(self, row_id, values):
        self.rows[row_id] = values

    def update_row(self, row_id, values):
        if row_id in self.rows:
            self.rows[row_id].update(values)
        else:
            print(f"Row with id {row_id} not found in table {self.name}")

    def get_row(self, row_id):
        return self.rows.get(row_id)

# 示例使用
table = Table("users")
table.insert_row(1, {"name": "Alice", "age": 30})

# 模拟一个事务,修改了name
old_values = table.get_row(1).copy() # 保存当前值
trx_id = 100  # 事务ID
table.update_row(1, {"name": "Bob"})
undo_log_entry = UndoLogEntry("users", 1, old_values, trx_id)

print("Table after update:", table.rows)
print("Undo log entry:", undo_log_entry)

# 模拟回滚
undo_log_entry.apply_undo(table)
print("Table after rollback:", table.rows)

这个Python代码只是为了演示undo log的概念,实际InnoDB的undo log实现要复杂得多,包括存储格式、管理方式等等。

read view:事务的快照

read view是InnoDB实现MVCC的关键,它定义了事务可以访问哪些版本的数据。每个事务在启动时都会创建一个read view,它主要包含以下信息:

  • trx_id: 创建该read view的事务ID。
  • m_ids: 当前活跃的事务ID集合(即还没有提交的事务ID)。
  • min_trx_id: m_ids中最小的事务ID。
  • max_trx_id: 下一个要生成的事务ID,表示大于等于该ID的事务均不可见。

read view 的可见性判断规则

当事务需要读取一行数据时,InnoDB会根据数据的版本号(即修改该行数据的事务ID)和read view的信息,判断该版本的数据对当前事务是否可见。可见性判断规则如下:

  1. 如果数据的版本号小于min_trx_id,则该版本的数据可见。 这意味着该版本的数据是在当前事务启动之前就已经提交的。
  2. 如果数据的版本号大于等于max_trx_id,则该版本的数据不可见。 这意味着该版本的数据是在当前事务启动之后才被修改的。
  3. 如果数据的版本号在min_trx_id和max_trx_id之间,则需要判断该版本号是否在m_ids集合中:
    • 如果在m_ids集合中,则该版本的数据不可见。 这意味着该版本的数据是由当前活跃的事务修改的,尚未提交。
    • 如果不在m_ids集合中,则该版本的数据可见。 这意味着该版本的数据是由已经提交的事务修改的。

代码示例(模拟read view的可见性判断):

class ReadView:
    def __init__(self, trx_id, m_ids):
        self.trx_id = trx_id  # 当前事务ID
        self.m_ids = m_ids    # 当前活跃的事务ID集合
        self.min_trx_id = min(m_ids) if m_ids else float('inf') # 活跃事务最小ID
        self.max_trx_id = max(m_ids) + 1 if m_ids else 1  # 下一个事务ID

    def is_visible(self, version_trx_id):
        """判断指定版本号的数据是否对当前read view可见"""
        if version_trx_id < self.min_trx_id:
            return True  # 版本号小于最小活跃事务ID,可见
        elif version_trx_id >= self.max_trx_id:
            return False # 版本号大于等于最大事务ID,不可见
        elif version_trx_id in self.m_ids:
            return False # 版本号在活跃事务ID集合中,不可见
        else:
            return True  # 版本号不在活跃事务ID集合中,可见

# 示例使用
# 假设当前活跃事务ID集合为[101, 105, 110]
read_view = ReadView(108, {101, 105, 110})  # 当前事务ID为108

print(f"Read View: trx_id={read_view.trx_id}, m_ids={read_view.m_ids}, min_trx_id={read_view.min_trx_id}, max_trx_id={read_view.max_trx_id}")

# 测试不同版本号的可见性
print(f"Version 100 is visible: {read_view.is_visible(100)}")  # 可见 (小于min_trx_id)
print(f"Version 101 is visible: {read_view.is_visible(101)}")  # 不可见 (在m_ids中)
print(f"Version 105 is visible: {read_view.is_visible(105)}")  # 不可见 (在m_ids中)
print(f"Version 108 is visible: {read_view.is_visible(108)}")  # 不可见 (虽然不在m_ids,但大于等于当前事务的ID并且小于max_trx_id,说明当前事务启动前别的事务修改了,但是还没有提交)
print(f"Version 110 is visible: {read_view.is_visible(110)}")  # 不可见 (在m_ids中)
print(f"Version 111 is visible: {read_view.is_visible(111)}")  # 不可见 (大于等于max_trx_id)

undo log和read view的协同工作流程

  1. 事务启动: 事务启动时,InnoDB会为该事务分配一个唯一的事务ID,并创建一个read view。
  2. 读取数据: 当事务需要读取一行数据时,InnoDB会按照以下步骤进行:
    • 首先,尝试读取数据的最新版本(即当前版本)。
    • 如果当前版本的数据对当前事务可见(根据read view的可见性判断规则),则直接返回该版本的数据。
    • 如果当前版本的数据对当前事务不可见,则需要从undo log中查找更早的版本。
    • InnoDB会沿着undo log链,逐个检查历史版本,直到找到一个对当前事务可见的版本,然后返回该版本的数据。
  3. 修改数据: 当事务需要修改一行数据时,InnoDB会按照以下步骤进行:
    • 首先,将修改前的旧值写入undo log。
    • 然后,将数据的当前版本号更新为当前事务的事务ID。
    • 最后,执行修改操作。
  4. 事务提交: 事务提交时,InnoDB会将该事务的redo log刷入磁盘,并释放该事务占用的锁。
  5. 事务回滚: 事务回滚时,InnoDB会使用undo log将数据恢复到事务开始之前的状态。
  6. purge线程: purge线程负责清理不再需要的undo log,回收存储空间。purge线程会定期扫描undo log,判断是否有不再需要的undo log,如果undo log对应的版本已经没有事务需要访问,则可以被安全地删除。

举例说明

假设有一张名为users的表,包含idname两个字段。初始数据如下:

id name
1 Alice

现在有两个事务A和B并发执行:

  1. 事务A启动,事务ID为100,创建read view,m_ids为空,min_trx_id为无穷大,max_trx_id为1。
  2. 事务B启动,事务ID为101,创建read view,m_ids为{100},min_trx_id为100,max_trx_id为101。
  3. *事务A执行查询:`SELECT FROM users WHERE id = 1;** 事务A读取到users表中id=1的最新数据,nameAlice,版本号小于min_trx_id,对事务A可见,因此事务A读取到的数据为Alice`。
  4. 事务B执行更新:UPDATE users SET name = 'Bob' WHERE id = 1; 事务B将name修改为Bob,并将旧值Alice写入undo log,将users表中id=1的数据版本号更新为101
  5. *事务A再次执行查询:`SELECT FROM users WHERE id = 1;** 事务A读取到users表中id=1的最新数据,nameBob,版本号为101,大于等于max_trx_id,对事务A不可见。然后,事务A从undo log中找到id=1的上一个版本,nameAlice,版本号小于min_trx_id,对事务A可见,因此事务A读取到的数据仍然为Alice`。这就是MVCC实现可重复读的原理。
  6. 事务B提交。
  7. 事务A提交。

隔离级别与MVCC

InnoDB的MVCC在不同的隔离级别下表现有所不同:

  • 读未提交(Read Uncommitted): 事务可以看到其他未提交事务的修改。 此隔离级别不使用MVCC。
  • 读已提交(Read Committed): 事务只能看到已经提交的事务的修改。 每次读取数据时,都会创建一个新的read view。
  • 可重复读(Repeatable Read): 事务在整个事务期间只能看到事务开始时的数据快照。 这是InnoDB的默认隔离级别,事务只在第一次读取数据时创建read view,之后的读取都使用同一个read view。
  • 串行化(Serializable): 强制事务串行执行,可以避免幻读。 通过加锁实现,不依赖MVCC。
隔离级别 Read View创建时机 是否解决脏读 是否解决不可重复读 是否解决幻读
读未提交(RU)
读已提交(RC) 每次读取数据时
可重复读(RR) 事务开始时
串行化(Serializable) 无(使用锁)

MVCC的优势与代价

优势:

  • 提高并发性能: 读写操作之间不需要加锁,减少了锁的竞争,提高了并发性能。
  • 实现可重复读: 通过read view和undo log,保证事务在整个事务期间读取到的数据是一致的。

代价:

  • 存储空间开销: 需要存储多个历史版本的数据,增加了存储空间的开销。
  • purge线程的开销: 需要purge线程定期清理不再需要的undo log,增加了系统的开销。

MVCC、undo log 和read view 的关系

MVCC 是一种并发控制的方法,undo log 是实现数据多版本的手段,read view 是决定事务可以看到哪个版本的策略。undo log 提供了历史版本的数据,read view 决定了可见性,两者协同工作,最终实现了 MVCC。

总结:MVCC让并发成为可能,undo log和read view是基石

InnoDB的MVCC机制通过undo log记录数据的历史版本,并使用read view来判断事务可以访问哪些版本的数据,从而实现了在不加锁的情况下,保证事务的隔离性,提高了并发性能。理解undo log和read view的协同工作原理,对于深入理解InnoDB的并发控制机制至关重要。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注