MySQL的MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种高效的并发控制机制,通过维护数据的多个版本实现读写操作的并行执行,显著提升数据库的并发性能和数据一致性。
MVCC
的实现依赖于:隐藏字段、Read View、undo log。
隐藏字段
DB_TRX_ID(6字节)
:表示最后一次插入或更新该行的事务 id。此外,delete
操作在内部被视为更新,只不过会在记录头Record header
中的deleted_flag
字段将其标记为已删除DB_ROLL_PTR(7字节):
回滚指针,指向该行的undo log
。如果该行未被更新,则为空DB_ROW_ID(6字节)
:如果没有设置主键且该表没有唯一非空索引时,InnoDB
会使用该 id 来生成聚簇索引
Read View
ReadView(读视图)是 InnoDB 为了实现一致性读(Consistent Read)而创建的数据结构,它用于确定在特定事务中哪些版本的行记录是可见的。
当事务开始执行时,InnoDB 会为该事务创建一个 ReadView,这个 ReadView 会记录 4 个重要的信息:
creator_trx_id:创建该 ReadView 的事务 ID。
m_ids:所有活跃事务的 ID 列表,活跃事务是指那些已经开始但尚未提交的事务。
min_trx_id:所有活跃事务中最小的事务 ID。它是 m_ids 数组中最小的事务 ID。
max_trx_id:事务 ID 的最大值加一。换句话说,它是下一个将要生成的事务 ID。
Undo log
undo log(回滚日志)
主要有两个作用:
- 当事务回滚时用于将数据恢复到修改前的样子
- 另一个作用是
MVCC
,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过undo log
读取之前的版本数据,以此实现非锁定读
在 InnoDB
存储引擎中 undo log
分为两种:insert undo log
和 update undo log
:
insert undo log
:指在insert
操作中产生的undo log
。因为insert
操作的记录只对事务本身可见,对其他事务不可见,故该undo log
可以在事务提交后直接删除。不需要进行purge
操作update undo log
:update
或delete
操作中产生的undo log
。该undo log
可能需要提供MVCC
机制,因此不能在事务提交时就进行删除。提交时放入undo log
链表,等待purge线程
进行最后的删除
可见性判断
当读取一行数据时,会通过以下规则判断该版本是否可见:
- 如果该版本的DB_TRX_ID小于min_trx_id,说明该版本在创建ReadView时已经提交,可见
- 如果该版本的DB_TRX_ID大于等于max_trx_id,说明该版本在创建ReadView时还未开始,不可见
- 如果该版本的DB_TRX_ID在m_ids中,说明该版本在创建ReadView时还未提交,不可见
- 如果该版本的DB_TRX_ID等于creator_trx_id,说明该版本是当前事务修改的,可见
可见性判断举例
前景知识
事务开始和ReadView创建的时序关系:
- 事务开始
- 当执行 BEGIN 或 START TRANSACTION 时,事务开始
- 此时会分配一个事务ID(trx_id)
- 但此时并不会创建ReadView
- ReadView创建
- 在第一次执行SELECT语句时才会创建ReadView
- 不同隔离级别下,ReadView的创建时机不同:
- READ COMMITTED:每次SELECT都会创建新的ReadView
- REPEATABLE READ:第一次SELECT时创建ReadView,后续复用
假设我们有一个用户表,包含以下数据:
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
);
INSERT INTO users VALUES (1, '张三', 20);
场景1:事务ID小于min_trx_id(已提交事务)
事务1 (trx_id=100): 开始事务
事务2 (trx_id=101): 开始事务
事务1: 更新 users 表,将张三的年龄改为21
事务1: 提交事务
事务2: 创建ReadView (min_trx_id=101, max_trx_id=102, m_ids=[101])
事务2: 读取 users 表
结果:事务2可以看到更新后的数据(年龄=21)
原因:因为事务1的trx_id(100) < min_trx_id(101),说明该版本在创建ReadView时已经提交
场景2:事务ID大于等于max_trx_id(未开始事务)
事务1 (trx_id=100): 开始事务
事务1: 创建ReadView (min_trx_id=100, max_trx_id=101, m_ids=[100])
事务2 (trx_id=101): 开始事务
事务2: 更新 users 表,将张三的年龄改为22
事务1: 读取 users 表
结果:事务1看不到事务2的更新(仍然看到年龄=20)
原因:因为事务2的trx_id(101) >= max_trx_id(101),说明该版本在创建ReadView时还未开始
场景3:事务ID在m_ids中(未提交事务)
事务1 (trx_id=100): 开始事务
事务2 (trx_id=101): 开始事务
事务1: 更新 users 表,将张三的年龄改为21
事务2: 创建ReadView (min_trx_id=100, max_trx_id=102, m_ids=[100,101])
事务2: 读取 users 表
结果:事务2看不到事务1的更新(仍然看到年龄=20)
原因:因为事务1的trx_id(100)在m_ids中,说明该版本在创建ReadView时还未提交
场景4:事务ID等于creator_trx_id(当前事务修改)
事务1 (trx_id=100): 开始事务
事务1: 创建ReadView (min_trx_id=100, max_trx_id=101, m_ids=[100])
事务1: 更新 users 表,将张三的年龄改为21
事务1: 读取 users 表
结果:事务1可以看到自己的更新(看到年龄=21)
原因:因为该版本的trx_id(100)等于creator_trx_id(100),说明该版本是当前事务修改的
隔离级别与MVCC
1. 读未提交(Read Uncommitted)
- MVCC 行为:不使用 MVCC,直接读取数据的最新版本(包括未提交的数据)。
- 数据可见性:事务可看到其他事务未提交的修改(脏读)。
- 典型问题:脏读、不可重复读、幻读均可能发生。
- 适用场景:对数据一致性要求极低的场景(如日志记录)。
2. 读已提交(Read Committed)
- MVCC 行为:
- 每次 SELECT 生成新 ReadView:每次查询时创建新的快照,仅读取已提交的数据版本。
- 写操作使用当前读:UPDATE/DELETE 操作会读取最新已提交数据并加锁。
- 数据可见性:事务内多次查询可能看到不同结果(因其他事务提交导致数据变更)。
- 解决的问题:脏读(因只读已提交数据)。
- 未解决的问题:不可重复读、幻读(因无间隙锁)。
- 适用场景:需避免脏读,但允许不可重复读的场景(如实时统计)。
3. 可重复读(Repeatable Read,MySQL 默认级别)
- MVCC 行为:
- 事务开始时生成固定 ReadView:整个事务复用同一快照,确保多次查询结果一致。
- 通过版本链访问历史数据:通过
DB_ROLL_PTR
回溯旧版本。
- 锁机制补充:间隙锁(Gap Lock) 阻止范围内新数据插入,解决幻读。
- 解决的问题:脏读、不可重复读、幻读(InnoDB 通过 MVCC + 间隙锁实现)。
- 适用场景:需保证事务内数据一致性的场景(如账户余额查询)。
4. 串行化(Serializable)
- MVCC 行为:基本不使用 MVCC,主要依赖严格的锁机制(读写均加锁)。
- 数据可见性:事务串行执行,完全隔离并发操作。
- 解决的问题:所有并发问题(脏读、不可重复读、幻读)。
- 缺点:性能最低,高并发下易引发锁等待和死锁。
- 适用场景:对数据一致性要求极高且并发量低的场景(如金融清算)。
- 读未提交(Read Uncommitted):不使用MVCC,直接读取数据的最新版本(包括未提交的数据),脏读、不可重复读、幻读均可能发生。
- 读已提交(Read Committed):使用MVCC
- 可重复读(Repeatable Read,MySQL 默认级别)
- 串行化(Serializable)