深入理解 MVCC:数据库并发控制的基石

一、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=1COMMIT
t2 BEGIN
t3 BEGINUPDATEBobCOMMIT
t4 BEGINUPDATECharlie未提交
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  |
+----+------+
相关推荐
l1t1 小时前
DeepSeek总结的PostgreSQL 的开源 TDE:pg_tde
数据库·postgresql·开源
欧神附体1231 小时前
MYSQL数据库集群高可用和数据监控平台项目
数据库·mysql
abcy0712131 小时前
python在models定义了一个对象,接口调用时报错对象不存在models.xx.DoesNotExist
数据库·sqlite
無限進步D2 小时前
MySQL 数据处理之增删改
数据库·mysql
我,也来自江湖2 小时前
Redis的持久化有哪些方式
数据库·redis·缓存
凯瑟琳.奥古斯特2 小时前
力扣1235:加权区间调度最优解
java·python·算法·leetcode·职场和发展
兆。2 小时前
LangChain向量数据库集成指南:面向RAG开发者
数据库·langchain
想不到ID了2 小时前
第八篇: 登录注册功能实现
java·javascript
小小工匠2 小时前
Redis - 实现分页 + 多条件模糊查询:一套完整可落地的组合方案
数据库·redis·缓存·分页·模糊查询