MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)通过维护数据的多个版本,让读写不冲突:读走快照、写加锁,从而实现非阻塞读。
一、当前读与快照读
| 类型 | 读取内容 | 是否加锁 | 典型语句 |
|---|---|---|---|
| 当前读 | 记录的最新版本,并保证其他并发事务不能修改当前记录 | 加锁 | SELECT ... LOCK IN SHARE MODE(S 锁)、SELECT ... FOR UPDATE、UPDATE、INSERT、DELETE(X 锁) |
| 快照读 | 记录的可见版本,可能是历史数据 | 不加锁、非阻塞 | 简单的 SELECT(不加锁) |
快照读是 MySQL 实现 MVCC 提供的非阻塞读功能;当前读则依赖锁。
各隔离级别下的快照读
| 隔离级别 | 快照读行为 |
|---|---|
| Read Committed | 每次 SELECT 都生成一个快照读(新的 ReadView) |
| Repeatable Read | 开启事务后第一个 SELECT 才是快照读的地方,后续复用该 ReadView |
| Serializable | 快照读退化为当前读 |
二、实现三要素
MVCC 的具体实现依赖:记录中的隐藏字段、undo log 日志、ReadView。
2.1 隐藏字段
| 隐藏字段 | 含义 |
|---|---|
DB_TRX_ID | 最近修改事务 ID:记录插入这条记录或最后一次修改该记录的事务 ID |
DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,配合 undo log 使用 |
DB_ROW_ID | 隐藏主键:表结构没有指定主键时,会生成该隐藏字段 |
2.2 undo log 版本链
不同事务(或同一事务)对同一条记录进行修改,会让该记录的 undo log 形成一条版本链表:链表头部是最新的旧记录,尾部是最早的旧记录。
示例:id = 30 的记录被事务 2、3、4 依次修改:
| 版本 | id | age | name | DB_TRX_ID | DB_ROLL_PTR |
|---|---|---|---|---|---|
| 当前记录 | 30 | 10 | A3 | 4 | 0x00003 |
| undo log | 30 | 3 | A3 | 3 | 0x00002 |
| undo log | 30 | 3 | A30 | 2 | 0x00001 |
| undo log | 30 | 30 | A30 | 1 | null |
顺着
DB_ROLL_PTR就能从当前版本一路回溯到最早版本,快照读正是靠它找到「自己该看到的那一版」。
2.3 ReadView(读视图)
ReadView 是快照读 SQL 执行时 MVCC 提取数据的依据,记录并维护系统当前活跃事务(未提交)的 ID。
| 字段 | 含义 |
|---|---|
m_ids | 当前活跃的事务 ID 集合 |
min_trx_id | 最小活跃事务 ID |
max_trx_id | 预分配事务 ID,即当前最大事务 ID + 1(事务 ID 自增) |
creator_trx_id | ReadView 创建者的事务 ID |
三、版本链数据访问规则
设 trx_id 为当前记录某个版本的事务 ID,按顺序判断:
| # | 判断 | 能否访问 | 说明 |
|---|---|---|---|
| ① | trx_id == creator_trx_id | 可以 | 数据是当前这个事务更改的 |
| ② | trx_id < min_trx_id | 可以 | 数据已经提交了 |
| ③ | trx_id > max_trx_id | 不可以 | 该事务是在 ReadView 生成后才开启的 |
| ④ | min_trx_id <= trx_id <= max_trx_id | 若 trx_id 不在 m_ids 中则可以 | 数据已经提交 |
若当前版本不可访问,就顺着
DB_ROLL_PTR找上一个版本,重复上述判断,直到找到可见版本。
四、RC 与 RR 的区别
| 隔离级别 | ReadView 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 事务中每一次执行快照读时都生成 | 每次都能读到别人最新提交的数据 → 不可重复读 |
| REPEATABLE READ | 仅在事务中第一次执行快照读时生成,后续复用 | 整个事务看到的快照不变 → 可重复读 |
RC 示例:事务 2 / 3 / 4 / 5 并发,事务 5 两次查询之间事务 3、4 先后提交。
| 时刻 | 事务 2 | 事务 3 | 事务 4 | 事务 5 |
|---|---|---|---|---|
| ① | 开始事务 | 开始事务 | 开始事务 | 开始事务 |
| ② | 修改 id=30,age 改为 3 | 查询 id=30 | ||
| ③ | 提交事务 | |||
| ④ | 修改 id=30,name 改为 A3 | 查询 id=30 | ||
| ⑤ | 提交事务 | |||
| ⑥ | 修改 id=30,age 改为 10;查询 id=30 | |||
| ⑦ | 查询 id=30 | |||
| ⑧ | 提交事务 |
- 事务 5 第 ④ 步生成 ReadView:
m_ids = {3,4,5}、min_trx_id = 3、max_trx_id = 6、creator_trx_id = 5。 - 事务 5 第 ⑦ 步重新生成 ReadView:
m_ids = {4,5}、min_trx_id = 4、max_trx_id = 6。 - 所以 RC 下两次查询结果不同(读到事务 3 提交后的数据),表现为不可重复读。
RR 示例:同样的时序,事务 5 第二次查询复用第一次的 ReadView,所以两次结果一致,表现为可重复读。
五、整体关系
MVCC = 隐藏字段 + undo log 版本链 + ReadView
隔离性 = MVCC + 锁
一致性 = undo log + redo log
速查
-- 查看当前隔离级别(RR 为默认)
SELECT @@transaction_isolation;
-- 修改隔离级别为 RC:每次快照读都生成新 ReadView
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 快照读:不加锁,读可见版本
SELECT * FROM stu WHERE id = 30;
-- 当前读:加共享锁 / 排他锁,读最新版本
SELECT * FROM stu WHERE id = 30 LOCK IN SHARE MODE;
SELECT * FROM stu WHERE id = 30 FOR UPDATE;
| 要做什么 | 结论 |
|---|---|
| 读不加锁、不阻塞写 | 快照读(普通 SELECT) |
| 读到最新数据并锁住 | 当前读(FOR UPDATE / LOCK IN SHARE MODE) |
| RR 下两次查询结果一致 | 复用第一次快照读生成的 ReadView |
| RC 下能读到新提交的数据 | 每次快照读都重新生成 ReadView |
相关
- 事务原理:undo log 如何支撑 MVCC
- InnoDB 逻辑存储结构:
Trx_id、Roll_pointer隐藏字段 - 并发事务问题与隔离级别:脏读 / 不可重复读 / 幻读
- 行级锁:当前读所加的锁