好的,我们开始今天关于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中非常重要的组成部分,它记录了事务对数据进行修改之前的信息,主要用于以下两个方面:
- 事务回滚(Rollback): 当事务执行过程中发生错误或者需要手动回滚时,可以使用undo log将数据恢复到事务开始之前的状态。
- 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的信息,判断该版本的数据对当前事务是否可见。可见性判断规则如下:
- 如果数据的版本号小于min_trx_id,则该版本的数据可见。 这意味着该版本的数据是在当前事务启动之前就已经提交的。
- 如果数据的版本号大于等于max_trx_id,则该版本的数据不可见。 这意味着该版本的数据是在当前事务启动之后才被修改的。
- 如果数据的版本号在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的协同工作流程
- 事务启动: 事务启动时,InnoDB会为该事务分配一个唯一的事务ID,并创建一个read view。
- 读取数据: 当事务需要读取一行数据时,InnoDB会按照以下步骤进行:
- 首先,尝试读取数据的最新版本(即当前版本)。
- 如果当前版本的数据对当前事务可见(根据read view的可见性判断规则),则直接返回该版本的数据。
- 如果当前版本的数据对当前事务不可见,则需要从undo log中查找更早的版本。
- InnoDB会沿着undo log链,逐个检查历史版本,直到找到一个对当前事务可见的版本,然后返回该版本的数据。
- 修改数据: 当事务需要修改一行数据时,InnoDB会按照以下步骤进行:
- 首先,将修改前的旧值写入undo log。
- 然后,将数据的当前版本号更新为当前事务的事务ID。
- 最后,执行修改操作。
- 事务提交: 事务提交时,InnoDB会将该事务的redo log刷入磁盘,并释放该事务占用的锁。
- 事务回滚: 事务回滚时,InnoDB会使用undo log将数据恢复到事务开始之前的状态。
- purge线程: purge线程负责清理不再需要的undo log,回收存储空间。purge线程会定期扫描undo log,判断是否有不再需要的undo log,如果undo log对应的版本已经没有事务需要访问,则可以被安全地删除。
举例说明
假设有一张名为users
的表,包含id
和name
两个字段。初始数据如下:
id | name |
---|---|
1 | Alice |
现在有两个事务A和B并发执行:
- 事务A启动,事务ID为100,创建read view,m_ids为空,min_trx_id为无穷大,max_trx_id为1。
- 事务B启动,事务ID为101,创建read view,m_ids为{100},min_trx_id为100,max_trx_id为101。
- *事务A执行查询:`SELECT FROM users WHERE id = 1;
** 事务A读取到
users表中
id=1的最新数据,
name为
Alice,版本号小于
min_trx_id,对事务A可见,因此事务A读取到的数据为
Alice`。 - 事务B执行更新:
UPDATE users SET name = 'Bob' WHERE id = 1;
事务B将name
修改为Bob
,并将旧值Alice
写入undo log,将users
表中id=1
的数据版本号更新为101
。 - *事务A再次执行查询:`SELECT FROM users WHERE id = 1;
** 事务A读取到
users表中
id=1的最新数据,
name为
Bob,版本号为
101,大于等于
max_trx_id,对事务A不可见。然后,事务A从undo log中找到
id=1的上一个版本,
name为
Alice,版本号小于
min_trx_id,对事务A可见,因此事务A读取到的数据仍然为
Alice`。这就是MVCC实现可重复读的原理。 - 事务B提交。
- 事务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的并发控制机制至关重要。