一、MVCC是什么
MVCC(multi-version concurrency control),多版本并发控制,是一种数据库并发控制技术。
核心思想:每次写操作(DELETE/UPDATE),都不直接覆盖旧数据,而是生成一个新的数据版本;读操作根据事务的时间视角,选择一个合适的版本读取。不同事务,在同一时刻,看到的数据状态可能不同,但彼此互不干扰。
二、为了解决什么问题
1.读写冲突(核心问题)
在没有mvcc的传统数据库中,读操作通常需要加共享锁(S锁),写操作通常需要加排它锁(X锁),这会导致:
- 读阻塞写:一个事务在读数据时,其他事务不能修改;
- 写阻塞读:一个事务在修改数据时,其他事务不能读取
MVCC版本控制通过保存数据的历史版本,让读操作不加锁就能读到一致性数据,实现了读写不互斥 。注意,MVCC解决的是读写不互斥不需要加锁的问题,但写写还是要加锁的。
2.提高并发性能
高并发场景下,锁竞争是性能瓶颈。MVCC将锁的粒度从整行数据降低到版本选择,大幅减少了锁等待。
三、基于什么实现的
不同数据库实现方式不一样,但原理都大同小异,为每行记录生成一个版本链,每行记录保留了每次被更新的记录,以MySQL innodb为例,mvcc的实现使用了①隐藏字段、②undo Log、③Read View(一致性快照)
1. 隐藏字段(版本标识)
innodb每行数据隐藏了三个字段:
| 字段 | 作用 |
|---|---|
DB_TRX_ID(6字节) |
最后修改该行的事务ID |
DB_ROLL_PTR(7字节) |
回滚指针,指向 undo log 中的上一个版本 |
DB_ROW_ID(6字节) |
隐藏主键(若无显式主键) |
在 MVCC(多版本并发控制)中,事务执行 UPDATE/DELETE 时立即生成 undo log 版本,不需要等待提交,COMMIT 只影响可见性,不影响版本生成时机。undo log 链上的是旧版本,数据页上的是最新版本
2.undo log(版本链)
- DELETE/UPDATE时,innodb将旧数据复制到undo log;
- 通过DB_ROLL_PTR字段记录上一个版本的地址,将多个版本串联起来,形成版本链
- 读取时,沿着版本链回溯,找到对当前事务可见的版本。
3. Read View(一致性快照)
select操作时,即开启事务,事务开始时,生成一个数据结构,包括:
- creator_trx_id:当前读事务的事务id;
- m_ids:该时刻,所有活跃(未提交)的事务id列表/集合;
- min_trx_id:生成read view时,最小的未提交的事务id(即m_ids中的最小值);
本质:它是一个"分界线":所有 ID 小于它的事务,在 Read View 生成那一刻必定已经提交了(否则它们会在 m_ids 里,最小值就会更低) - max_trx_id:生成read view时,innodb即将分配的下一个事务id
本质:它是一个"未来线",所有大于该事物的id,都是在read view生成之后启动的,属于"未来的"修改,肯定都还未提交
四、可见性的判断
可见性的判断的含义是:事务在读取数据时,决定应该看到哪个版本的规则机制
可见性的判断流程
当事务读取某一行时,使用该行的DB_TRX_ID与生成的read view对比,按照以下顺序判断:
①DB_TRX_ID==CREATOR_TRX_ID:可见(自己修改的),当然可见;
②DB_TRX_ID< min_trx_id:可见(read view 生成时已提交);
③DB_TRX_ID>= max_trx_id:不可见(read view 生成后的事务操作,不可见)
④min_trx_id <= DB_TRX_ID <max_trx_id:
DB_TRX_ID在m_ids中:不可见(m_ids是在生成read view时未提交的事务,所以不可见);
DB_TRX_ID不在m_ids中:可见(生成read view时已提交,所以可见)。
⑤如果判断为不可见,就沿着DB_ROLL_PTR找到undo log中上一个版本,重复上述流程,直至找到可见版本或返回空。
五、记录的版本链什么时候删除?一直保留吗?
不会一直保留
MySQL innodb的清理机制
版本链的清理有purge线程异步完成,触发条件:
| 时机 | 说明 |
|---|---|
| 没有事务再需要该版本 | 当某个 undo log 版本对所有活跃 Read View 都不可见时 |
| 事务提交后 | 不是立即删除,而是等待 Purge 线程判断"无引用"后清理 |
| 系统空闲时 | Purge 线程在后台周期性运行 |
六、RC vs RR:Read View 生成时机不同
1.不同隔离级别生成read view的时机
| 隔离级别 | Read View 生成时机 | 效果 |
|---|---|---|
| Read Committed (RC) | 每次 SELECT 都新建 | 能看到其他事务已提交的最新修改 |
| Repeatable Read (RR) | 事务第一次 SELECT 时创建,后续复用 | 整个事务期间看到的数据是一致的快照 |
2.快照读vs 当前读
当前读
| 特点 | 说明 |
|---|---|
| 加锁 | 读取最新版本并加锁 |
| 读最新版本 | 不读历史版本,必须读到最新已提交数据 |
| 实现方式 | 直接读数据页,加 S 锁或 X 锁 |
| SQL 示例 | SELECT ... FOR UPDATE/SHARE |
| 能否防止幻读 | 单独当前读不能防止幻读;配合间隙锁(next-key lock)可以避免幻读。oracle RR级别默认开启间隙锁 |
sql
-- 当前读
SELECT * FROM t WHERE id = 1 FOR UPDATE; -- 加 X 锁,读最新
SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE; -- 加 S 锁,读最新
快照读
| 特点 | 说明 |
|---|---|
| 不加锁 | 不阻塞任何操作 |
| 读历史版本 | 通过 MVCC 读 undo log |
| 实现方式 | 基于 ReadView 判断可见性 |
| SQL 示例 | 普通 SELECT |
| 能否防止幻读 | 可以 MVCC + Read View 保证可重复读 |
sql
-- 快照读
SELECT * FROM t WHERE id = 1; -- 不加锁,读历史版本
一句话总结:MySQL 快照读(普通 SELECT)基于 MVCC 和 Read View,天然不会幻读;当前读(FOR UPDATE / UPDATE / DELETE)需要依赖临键锁(Next-Key Lock)才能防止幻读------RR 级别默认启用临键锁,但特定场景(如唯一索引等值命中)可能降级为记录锁,导致幻读漏洞。
3. read view 、快照读、当前读三者之间的关系
| 维度 | 快照读 (Snapshot Read) | 当前读 (Current Read) |
|---|---|---|
| 读取目标 | 历史版本(可能不是最新的) | 最新已提交版本 |
| 是否用 Read View | ✅ 必须用 | ❌ 完全不用 |
| 是否加锁 | ❌ 不加锁 | ✅ 加锁(S/X/Next-Key) |
| 一致性 | 事务级一致性(可重复) | 实时一致性 |
| 典型 SQL | 普通 SELECT |
SELECT ... FOR UPDATE / UPDATE / DELETE / INSERT |
4.read view的作用范围
事务生命周期
│
├─ BEGIN;
│ │
│ ├─ 快照读 (SELECT)
│ │ │
│ │ └─ 依赖 Read View
│ │ ├─ RR: 复用首次生成的 Read View
│ │ └─ RC: 每次 SELECT 新建 Read View
│ │
│ └─ 当前读 (UPDATE/DELETE/SELECT FOR UPDATE)
│ │
│ └─ 不依赖 Read View
│ ├─ 直接读取最新已提交版本
│ └─ 加锁 (X锁 / S锁 / Next-Key锁)
│
└─ COMMIT;
│
└─ Read View 销毁
└─ 锁释放
七、mvcc能解决幻读吗
标准 MVCC 不能彻底解决幻读。
- 幻读定义:同一事务两次查询,第二次查到了第一次没有的新行(由其他事务插入)
- mvcc的局限:MVCC 保护的是已存在行的版本可见性,但新插入的行对当前事务的 Read View 边界判断可能存在问题
MySQL InnoDB 的解决方案:在 RR 级别下,MVCC + 间隙锁(Gap Lock) + 临键锁(Next-Key Lock) 共同防止幻读。
八、可见性示例判断
1. 场景设定
场景设定 :表 users 中有一行数据:id=1, name='Alice'
历史操作 :事务 80:插入这行数据,并已提交
当前并发事务时间线:
| 时间 | 事务 80 | 事务 100 | 事务 101 | 事务 102 | 事务 103 |
|---|---|---|---|---|---|
| t1 | 插入 id=1,COMMIT |
||||
| t2 | BEGIN |
||||
| t3 | BEGIN;UPDATE 为 Bob;COMMIT |
||||
| t4 | BEGIN;UPDATE 为 Charlie(未提交) |
||||
| t5 | BEGIN;执行 SELECT |
t5 时刻 :事务 103 生成 Read View
事务 103 第一次执行 SELECT,生成 Read View:
- creator_trx_id = 103
- m_ids = 100, 102
(事务 100 仍在活跃;事务 102 活跃且未提交;事务 101 已提交,不在列表中)
- min_trx_id = 100
- max_trx_id = 104(系统下一个要分配的事务 ID)
此时数据库中的版本链(undo log):最新数据行:name='Charlie',DB_TRX_ID = 102
2. 版本链
版本1(最新): trx_id=102, name='Charlie', DB_ROLL_PTR ──→
版本2 : trx_id=101, name='Bob', DB_ROLL_PTR ──→
版本3(最旧) : trx_id=80, name='Alice', DB_ROLL_PTR ──→ NULL
3.事务 103 的可见性判断过程
3.1 第 1 轮:版本 1(trx_id = 102)
| 判断步骤 | 计算 | 结果 |
|---|---|---|
| 是自己改的? | 102 == 103? | ❌ 否 |
| 在 min 之前已提交? | 102 < 100? | ❌ 否 |
| 是未来事务? | 102 >= 104? | ❌ 否 |
| 在 [min, max) 区间内? | 100 ≤ 102 < 104? | ✅ 是 |
| 在活跃列表 m_ids 中? | 102 ∈ 100, 102? | ✅ 是,未提交 |
结论:不可见!
→ 沿着 DB_ROLL_PTR 回溯到版本 2。
3.2 第 2 轮:版本 2(trx_id = 101)
| 判断步骤 | 计算 | 结果 |
|---|---|---|
| 是自己改的? | 101 == 103? | ❌ 否 |
| 在 min 之前已提交? | 101 < 100? | ❌ 否 |
| 是未来事务? | 101 >= 104? | ❌ 否 |
| 在 [min, max) 区间内? | 100 ≤ 101 < 104? | ✅ 是 |
| 在活跃列表 m_ids 中? | 101 ∈ 100, 102? | ❌ 不在,已提交 |
结论:可见!
→ 返回 name = 'Bob'。
3.3 事务 103 执行 SELECT最终读到的结果是:
sql
+----+------+
| id | name |
+----+------+
| 1 | Bob |
+----+------+