MySQL学习之MVCC多版本并发控制
在数据库并发场景中,如何平衡一致性与性能始终是核心难题。当多个事务同时读写数据时,可能出现脏读、不可重复读、幻读等问题。MySQL 的 InnoDB 存储引擎通过 MVCC(多版本并发控制)机制,在不加锁的情况下巧妙解决了这些问题,成为其高性能并发的关键。
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现事务隔离级别的核心机制。它通过为数据记录保存多个版本,允许读写操作不互相阻塞,从而在高并发场景下提升数据库性能。简单来说:读操作可以访问数据的历史版本,无需等待写操作释放锁;写操作也无需阻塞读操作,二者通过 “版本” 实现隔离。
在 MVCC 出现前,数据库解决并发问题主要依赖锁机制:读加共享锁(S 锁),写加排他锁(X 锁)。但这种方式会导致 “读阻塞写、写阻塞读”,严重影响并发性能。MVCC 的出现解决了这一痛点,其核心作用包括:
(1)读写不冲突:读操作无需加锁,直接读取历史版本;写操作仅锁定当前版本,不阻塞读。
(2)支持多隔离级别:通过控制 “可见版本” 的规则,实现 REPEATABLE READ(可重复读)、READ COMMITTED(读已提交)等隔离级别(InnoDB 默认 REPEATABLE READ)。
(3)解决并发问题:避免脏读(读取未提交的数据)、不可重复读(同一事务内多次读取结果不一致),配合间隙锁可解决幻读。
InnoDB 的 MVCC 通过隐藏列、undo 日志和Read View三大组件协同实现,缺一不可。它们的作用分别是:
(一)隐藏列:
InnoDB 为每个数据行添加了 3 个隐藏列(非用户定义),用于标记版本信息:
(1)DB_TRX_ID:6 字节,记录最后一次修改该记录的事务 ID(事务开始时由 InnoDB 分配的唯一 ID)。
(2)DB_ROLL_PTR:7 字节,回滚指针,指向该记录的上一个版本(存储在 undo 日志中)。
(3)DB_ROW_ID:6 字节,若表无主键或唯一索引,InnoDB 会用该列生成聚簇索引(可选)。
例如,一行数据的实际存储结构如下(简化):
id(用户定义) | name | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|
1 | 张三 | 100 | 指向 undo 日志版本 1 |
(二)undo 日志:
undo 日志(回滚日志)是存储数据历史版本的地方。当事务修改数据时,InnoDB 会先将旧版本数据写入 undo 日志,再更新当前记录的 DB_TRX_ID 和 DB_ROLL_PTR(指向 undo 日志中的旧版本)。undo 日志分为两类:
(1)insert undo:记录插入操作的旧版本,事务提交后可直接删除(插入的数据在事务外不可见)。
(2)update undo:记录更新 / 删除操作的旧版本,需保留至没有事务需要访问这些版本(由 purge 线程清理)。
通过 undo 日志,InnoDB 可形成一条 “版本链”:当前记录 → DB_ROLL_PTR → 上一版本(undo 日志) → ... → 最初版本。
(三)Read View:
Read View(读视图)是一个动态生成的 “快照”,用于判断当前事务能看到哪个版本的数据。它包含 4 个核心字段:
(1)m_ids:当前活跃(未提交)的事务 ID 列表。
(2)min_trx_id:m_ids 中的最小事务 ID。
(3)max_trx_id:下一个将被分配的事务 ID(并非 m_ids 中的最大值)。
(4)creator_trx_id:当前事务的 ID。
其有一套可见性判断规则,例如(假设某版本的 DB_TRX_ID 为 trx_id):
(1)若trx_id == creator_trx_id
:当前事务修改的版本,可见。
(2)若trx_id < min_trx_id
:该版本由已提交事务生成,可见。
(3)若trx_id > max_trx_id
:该版本由未来事务生成,不可见。
(4)若min_trx_id ≤ trx_id ≤ max_trx_id,且
trx_id在m_ids中,则
事务未提交,版本不可见。反之,若trx_id不在m_ids中,则代表事务已提交,版本可见。
通过这套规则,Read View 能精准筛选出当前事务可访问的版本。
MVCC的主要优点有:
(1)读写并发性能高:读不加锁,写仅锁当前版本,避免 “读写互斥”,适合读多写少场景。
(2)事务隔离性好:通过版本控制天然支持读已提交和可重复读隔离级别,无需复杂锁机制。
(3)减少锁竞争:避免了共享锁(S 锁)的使用,降低死锁概率。
但其也有缺点,例如:
(1)存储开销大:undo 日志需保存多个历史版本,可能占用较多磁盘空间。
(2)性能损耗:版本链遍历和 Read View 判断会增加 CPU 开销;undo 日志的清理(purge 线程)也需额外资源。
(3)幻读问题:MVCC 在可重复读隔离级别下无法完全解决幻读(需配合间隙锁,InnoDB 默认开启)。
下面,我们通过实际 SQL 操作,观察 MVCC 在不同隔离级别下的表现(以 InnoDB 为例):
-- 创建一个用户表,并插入数据
CREATE TABLE `user` (`id` int PRIMARY KEY,`name` varchar(10) NOT NULL
) ENGINE=InnoDB;INSERT INTO `user` VALUES (1, '张三');
在可重复读隔离级别(默认级别)下,其运行如下:
时间 | 事务 A(ID=100) | 事务 B(ID=200) |
---|---|---|
T1 | BEGIN; | BEGIN; |
T2 | (未操作) | SELECT * FROM user WHERE id=1; -- 结果:name=' 张三 '(DB_TRX_ID=0,初始版本) |
T3 | UPDATE user SET name=' 李四 ' WHERE id=1; -- DB_TRX_ID=100,DB_ROLL_PTR 指向旧版本 | (未操作) |
T4 | (未提交) | SELECT * FROM user WHERE id=1; -- 结果:仍为 ' 张三 '(因可重复读隔离级别的 Read View 在 T1 生成,看不到事务 A 的未提交版本) |
T5 | COMMIT; | (未操作) |
T6 | (已提交) | SELECT * FROM user WHERE id=1; -- 结果:仍为 ' 张三 '(可重复读隔离级别的 Read View 不变,看不到事务 A 的提交版本) |
可以看到,在可重复读隔离级别下,事务 B 全程看到的是 T1 时刻的版本,符合要求。而下面将隔离级别换成读已提交级别:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
然后运行如下:
时间 | 事务 A(ID=100) | 事务 B(ID=200) |
---|---|---|
T1 | BEGIN; | BEGIN; |
T2 | (未操作) | SELECT * FROM user WHERE id=1; -- 结果:' 张三' |
T3 | UPDATE user SET name=' 李四 ' WHERE id=1; | (未操作) |
T4 | (未提交) | SELECT * FROM user WHERE id=1; -- 结果:' 张三 '(事务 A 未提交,版本不可见) |
T5 | COMMIT; | (未操作) |
T6 | (已提交) | SELECT * FROM user WHERE id=1; -- 结果:' 李四 '(RC 的 Read View 在 T6 重新生成,看到已提交版本) |
可以看出,在读已提交级别下,事务 B 在 T6 看到了事务 A 提交的新版本,符合其 “读已提交” 特性。
可以看出在读已提交和可重复读2个级别中,MVCC的主要差异在于在可重复读级别中,事务开始时生成一次 Read View,事务内查询结果一致;而读已提交级别中,每次查询时生成新的 Read View,能看到其他事务已提交的更新。
MVCC 是 InnoDB 并发控制的灵魂,通过隐藏列、undo 日志和 Read View 的协同,实现了 “读写不阻塞” 的高效并发,理解 MVCC 能帮助我们写出更符合隔离级别的 SQL。