MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种用于实现数据库并发控制的机制,广泛应用于现代关系型数据库系统(如 PostgreSQL、Oracle、MySQL InnoDB 引擎等)。

其核心思想是通过维护数据的多个版本,使得读操作和写操作可以在不互相阻塞的情况下并发执行,从而提高数据库系统的并发性能和一致性。

MVCC = 多版本 + Read View + Undo Log 链

  • 读不阻塞写,写不阻塞读;
  • 通过版本可见性判断实现一致性快照;
  • 实现 RC(Read Committed)RR(Repeatable Read) 隔离级别的基石。

事务管理

数据库事务是构成单一逻辑工作单元的操作集合

1
2
3
4
BEGIN TRANSACTION  //事务开始
SQL1
SQL2
COMMIT/ROLLBACK //事务提交或回滚

当有多个用户同时操作数据库时,数据库能够以事务为单位进行并发控制

1
2
3
4
5
6
7
8
9
-- 查看当前隔离级别
SELECT @@transaction_isolation;

-- 设置隔离级别(会话级)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 查看当前事务 ID(需开启事务后)
SELECT trx_id FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id = CONNECTION_ID();

并发异常

事务并发可能导致数据异常问题,需要通过隔离级别+MVCC+锁保证。以下为并发导致的一些问题:

  • 脏写(锁机制)

    指事务回滚了其他事务对数据项的已提交修改(写写并发)。

    image-20210707175746735

    如图:事务1实际应该回滚到30,而不是10。

  • 丢失更新(锁机制)

    事务覆盖了其他事务对数据的已提交修改,导致其他事务提交的数据丢失(写写并发)。

    image-20210707180059499

    如图:事务1和事务2读取A的值都为10,事务2先将A加上10并提交修改。之后事务2将A减少10并提交修改,A的值最后为0,导致事务2对A的修改丢失

  • 脏读(读未提交)

    指一个事务读取了另一个事务未提交的数据

    image-20210707180243594

    在事务1对A的处理过程中,事务2读取了A的值,但之后事务1回滚,导致事务2读取的A是未提交的脏数据。

  • 不可重复读(读已提交)

    指一个事务对同一数据的读取结果前后不一致。

    image-20210707180413531

    读取的是事务已经提交的数据,只不过因为数据被其他事务修改过导致前后两次读取的结果不一样

  • 幻读(可重复读)

    事务读取某个范围的数据时,因为其他事务的操作导致前后两次读取的结果不一致

    幻读和不可重复读的区别在于,不可重复读是针对确定的某一行数据而言,而幻读是针对不确定的多行数据。因而幻读通常出现在带有查询条件的范围查询中,比如下面这种情况:

    image-20210707184150129

隔离级别

事务具有隔离性,理论上来说事务之间的执行不应该相互产生影响,其对数据库的影响应该和它们串行执行时一样。

然而完全的隔离性会导致系统并发性能很低,降低对资源的利用率,因而实际上对隔离性的要求会有所放宽,这也会一定程度造成对数据库一致性要求降低

img
  • 基于锁的控制

    申请锁的请求被发送给锁管理器。锁管理器根据当前数据项是否已经有锁以及申请的和持有的锁是否冲突决定是否为该请求授予锁

    若锁被授予,则申请锁的事务可以继续执行;若被拒绝,则申请锁的事务将进行等待,直到锁被其他事务释放。

    • 乐观锁: 假定其不会发生冲突,允许并发执行,直到真正发生冲突时才去解决冲突,比如让事务回滚。

    • 悲观锁: 假定其必定发生冲突,通过让事务等待(锁)或者中止(时间戳排序)的方式使并行的操作串行执行。

    • 共享锁(S 读锁): 事务T对数据A加共享锁,其他事务只能对A加共享锁但不能加排他锁。

    • 排他锁(X 写锁): 事务T对数据A加排他锁,其他事务对A既不能加共享锁也不能加排他锁

  • 基于MVCC的控制

    快照隔离是多版本并发控制(mvcc)的一种实现方式。

    数据库为每个数据项维护多个版本(快照),每个事务只对属于自己的私有快照进行更新,在事务真正提交前进行有效性检查,使得事务正常提交更新或者失败回滚。

    由于快照隔离导致事务看不到其他事务对数据项的更新,为了避免出现丢失更新问题,可以采用以下两种方案避免:

    • 先提交者获胜:对于执行该检查的事务T,判断是否有其他事务已经将更新写入数据库,是则T回滚,否则T正常提交。
    • 先更新者获胜:通过锁机制保证第一个获得锁的事务提交其更新,之后试图更新的事务中止。

    事务间的操作通过数据项不同版本的快照相互隔离,到真正要写入数据库时才进行冲突检测。这也是一种乐观并发控制。

MVCC 特点与挑战

  • 高并发性:读操作无需加锁,读写不阻塞。

  • 避免脏读、不可重复读:通过快照隔离,天然避免这些问题(取决于隔离级别)。

  • 支持时间点查询:可以查询历史版本(如 PostgreSQL 的 pg_snapshot 或 Oracle 的 Flashback Query)。

  • 提升系统吞吐量:减少锁竞争,提高并发处理能力。

  • 回滚支持:若事务回滚,可通过 Undo Log 恢复旧版本。

挑战与代价:

  1. 存储开销:需要保存多个版本的数据,占用更多磁盘空间。

  2. 垃圾回收(Vacuum / Purge):旧版本数据不会立即删除,需后台进程清理(如 PostgreSQL 的 VACUUM,InnoDB 的 Purge 线程)。若清理不及时,可能导致“膨胀”(bloat)问题。

  3. 写放大:每次更新都生成新版本,写入量增加。

  4. 长事务问题:长时间运行的事务会阻止旧版本被回收,导致存储膨胀。

  5. 写冲突:写写操作任然会冲突,需要开发者自己加锁实现(乐观、悲观锁)

MVCC 的基本思想

在传统的锁机制中,读写操作通常需要加锁(读锁、写锁),这会导致读写之间互相阻塞,降低并发性能。

而 MVCC 通过以下方式解决这个问题:

  • 写操作不阻塞读操作:当一个事务修改数据时,不会直接覆盖原数据,而是将旧值写入 Undo Log(快照),并生成新版本。

  • 读操依赖“快照”:每个事务在开始时会获得一个“一致性视图”,只能看到在它开始之前已经提交的数据版本,而看不到其他事务未提交或在其开始之后提交的更改。

这样,读操作无需加锁,写操作也不会被读操作阻塞,从而实现了读写不冲突

  • 为了提高数据库并发性能,用更好的方式去处理读-写冲突,即使有读写冲突时,也能做到不加锁,读写可并发。

  • 可以解决脏读幻读Next-Key Lock),不可重复读等事务隔离问题

  • 不能解决更新丢失问题(写-写事务并发),第一类更新丢失问题(A事务撤销时,把已经提交的B事务的更新数据覆盖了)、第二类更新丢失问题(A事务覆盖B事务已经提交的数据,造成B事务所做操作丢失),只能通过开发者自行加锁实现

MVCC 的基础概念

1.redo log 物理日志(数据恢复):记录数据页最后提交的物理修改数据(保证持久性)。

数据库以循环写入的方式记录修改操作,而非语句级别的逻辑修改记录,它用来恢复提交后的物理数据页,且只能恢复到最后一次提交的位置 (保证事务的持久性、减少磁盘IO、数据库恢复和故障恢复)。

2.undo log 逻辑日志(并发控制):记录了事务提交前对旧值的备份(保证原子性)。每行数据修改的记录,可以回滚行记录到某个版本。

Bin log 记录的是逻辑日志,它以追加的方式记录数据库的修改操作,采用二进制格式进行存储(数据恢复、主从复制、数据备份)

3.当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,它会对读取的记录进行加锁(悲观锁)。如:select … lock in share mode (共享锁), select … for update ; update, insert ,delete (排他锁) 的操作

1
2
3
4
5
6
7
8
9
10
11
12
# 共享读锁
select...lock in share mode
# 悲观锁
select...for update  

# 以下都属于当前读,读取记录的最新版本。
# 读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;

4.快照读:不加锁的非阻塞读,select * from t;(隔离级别不是串行级别)

  • MVCC + 悲观锁
    MVCC解决读写冲突,悲观锁解决写写冲突

  • MVCC + 乐观锁
    MVCC解决读写冲突,乐观锁解决写写冲突

img

MVCC 的原理机制

MySQL InnoDB 中 MVCC 的实现机制:undo log版本链 + readview 快照读

隐藏字段(每行包含)

InnoDB 为每行数据隐式添加三个系统列(用户不可见):

  • DB_TRX_ID:创建该行的事务 ID(6 字节)。

  • DB_ROLL_PTR(Undo Pointer):指向 Undo Log 的指针(用于构建历史版本链)。

  • DB_ROW_ID(隐藏主键):如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引(6byte)

Undo Log(回滚日志)

  • 每次 UPDATE/DELETE 时,旧版本数据被写入 Undo Log。

  • 形成 版本链(Version Chain):从最新记录通过 DB_ROLL_PTR 一路回溯到最初版本。

insert undo log:事务在insert时产生的undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
update undo log:事务在update或delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要。

graph LR
    Current[当前记录
value=B
DB_TRX_ID=100] -->|undo_ptr| V1[Undo Log 1
value=A
DB_TRX_ID=50] V1 -->|undo_ptr| V2[Undo Log 2
value=X
DB_TRX_ID=30] V2 -->|...| NULL[NULL]

Read View(读视图)

事务在首次执行读操作时(或根据隔离级别在事务开始时)创建 Read View,包含:

  • m_ids:当前活跃(未提交)的事务 ID 列表。

  • min_trx_idm_ids 中最小的事务 ID。

  • max_trx_id:下一个将要分配的事务 ID(系统当前最大事务 ID + 1)。

  • creator_trx_id:创建该 Read View 的事务 ID(若为只读事务则为 0)。

读视图,可以控制事务能看到哪些版本。读取的是版本链中的数据,而非数据库的实时数据。

可见性判断规则

对于某条记录的版本(其 DB_TRX_ID = trx_id),当前事务是否可见?通常基于以下规则:

条件 是否可见 说明
trx_id == creator_trx_id 可见 自己修改的,当然可见
trx_id < min_trx_id 可见 创建该版本的事务在 Read View 创建前已提交
trx_id >= max_trx_id 不可见 该版本在 Read View 创建后才创建
trx_id ∈ m_ids 不可见 创建该版本的事务尚未提交(活跃中)
trx_id ∉ m_idsmin_trx_id ≤ trx_id < max_trx_id 可见 该事务已提交(不在活跃列表中)

简单总结:只能看到在自己开始前已提交的数据

sequenceDiagram
    participant T1 as 事务 T1 (trx_id=100)
    participant T2 as 事务 T2 (trx_id=101)
    participant DB as 数据库 (InnoDB)
    participant UndoLog as Undo Log 链

    Note over T1, DB: 初始:记录 R 的值为 "A",DB_TRX_ID = 50

    T1->>DB: UPDATE R SET value = "B"
    DB->>UndoLog: 写入旧值 "A" 到 Undo Log
(形成版本链) DB->>DB: 当前记录 R: value="B", DB_TRX_ID=100
指向 Undo Log (version "A") T2->>DB: SELECT R (开启一致性读) DB->>T2: 创建 Read View:
- m_ids = [100, 101](活跃事务)
- min_trx_id = 100
- max_trx_id = 102 T2->>DB: 读取记录 R (value="B", trx_id=100) alt trx_id=100 在 m_ids 中(未提交) DB->>UndoLog: 沿 Undo Log 链查找可见版本 UndoLog-->>DB: 找到 version "A" (trx_id=50) alt trx_id=50 < min_trx_id → 已提交且对 T2 可见 DB-->>T2: 返回 "A" end else trx_id 已提交且在 Read View 可见 DB-->>T2: 直接返回当前值 end

关键组件说明

组件 作用
DB_TRX_ID 记录最后一次修改该行的事务 ID
Undo Log 链 存储历史版本,形成“版本链”(从新到旧)
Read View 事务开启时创建的快照视图,包含: - m_ids:当前活跃事务 ID 列表 - min_trx_id:最小活跃事务 ID - max_trx_id:下一个将分配的事务 ID
可见性规则 一个版本对当前事务可见,当且仅当: 1. trx_id < min_trx_id(已提交) 2. trx_id 不在 m_ids 中(非未提交事务)

不同数据库中的 MVCC 实现:

数据库 MVCC 实现机制
PostgreSQL 每行记录带xmin/xmax;使用 MVCC + Heap 表;依赖VACUUM清理死元组。
MySQL (InnoDB) 使用 Undo Log 存储旧版本;Read View 决定可见性;后台 Purge 线程清理。
Oracle 使用 Undo Segments;支持 Flashback Query;自动管理版本。
SQL Server 默认不启用 MVCC,但可通过“行版本控制隔离级别”(如 RCSI)启用。

MVCC 与隔离级别

MVCC 主要服务于 隔离性,同时通过版本机制辅助实现 一致性原子性(如回滚)。

MVCC 是实现快照隔离(Snapshot Isolation)可重复读(Repeatable Read) 等高级隔离级别的基础。

  • 读已提交(Read Committed):每次语句开始时获取新快照。

  • 可重复读(Repeatable Read):事务开始时获取一次快照,整个事务中复用。

  • 串行化(Serializable):某些数据库(如 PostgreSQL)在 MVCC 基础上增加冲突检测,实现真正的串行化。

注意:MySQL InnoDB 的“可重复读”实际上提供了快照隔离语义,能避免幻读(在特定条件下)。

MySQL 支持四种隔离级别,但 只有两种会使用 MVCC

隔离级别 是否使用MVCC READ VIEW 创建时机 特点
READ UNCOMMITTED 直接读最新数据(包括未提交的),可能脏读
READ COMMITTED (RC)**** 每次 SELECT 语句开始时创建新 Read View 可避免脏读,但不可重复读,可能出现幻读
REPEATABLE READ (RR) 事务首次 SELECT 时创建 Read View,并复用整个事务 可重复读,InnoDB 下基本避免幻读。
InnoDB通过间隙锁(Gap Lock)和Next-Key Lock进一步防止幻读。
SERIALIZABLE 否(加锁) 所有 SELECT 自动转为SELECT ... FOR SHARE,加共享锁,完全串行化

MySQL InnoDB 默认隔离级别是 REPEATABLE READ,其他数据库(如 Oracle、PostgreSQL 默认为 READ COMMITTED)不同。

  • 读已提交(RC)可重复读(RR)主要依赖MVCC来实现其特性的隔离级别。
  • 它们的核心区别在于快照的生成时机
    • RC:语句级快照(每次SELECT都新生成)。
    • RR:事务级快照(事务开始时生成,用到底)。
  • 读未提交 不用MVCC,它直接读最新数据。
  • 串行化 主要依赖锁机制,或者使用比MVCC更复杂的算法来保证绝对的一致性。

MVCC 解决 脏读

事务 A 读取了事务 B 尚未提交的修改数据,若 B 回滚,则 A 读到了“脏”数据。

MVCC 通过快照读(Snapshot Read)实现一致性读。每个事务在启动时会获得一个一致性视图(Read View),该视图决定了该事务能看到哪些版本的数据。

  • 事务只能看到在其开始之前已提交的数据版本。

  • 未提交的修改对其他事务不可见,因此不可能读到未提交的数据

MVCC 解决 幻读

在同一事务中,两次执行相同范围查询,第二次查询返回了第一次没有的“新行”(由其他事务插入并提交)。

REPEATABLE READ 下,MVCC 的快照读能保证同一事务内多次读取已有行的值不变。但对于新插入的行(即“幻行”),仅靠 MVCC 无法完全阻止幻读,因为快照读只控制可见性,不阻止新行的物理插入

MVCC 本身不能完全解决幻读,需要配合锁机制。

举例:事务 A 执行 SELECT * FROM t WHERE id > 10,事务 B 插入 id=15 并提交。若 A 再次执行相同查询,在纯 MVCC 下可能仍看不到新行(因为快照读),但若 A 执行 UPDATE 或 DELETE 范围操作,则可能影响到 B 插入的行,此时就出现了“幻读”的语义问题。

InnoDB 的解决方案:Next-Key Lock

  • InnoDB 在 REPEATABLE READ 隔离级别下,结合 MVCC + Next-Key Lock 来防止幻读。

  • Next-Key Lock = 记录锁(Record Lock) + 间隙锁(Gap Lock),锁定索引记录及其前面的间隙。

  • 当执行当前读(如 SELECT ... FOR UPDATEUPDATEDELETE)时,InnoDB 会使用 Next-Key Lock 阻止其他事务在范围内插入新记录。

  • 快照读(普通 SELECT):靠 MVCC 避免看到新插入的行(在 RR 下)。

  • 当前读(带锁的读):靠 Next-Key Lock 阻止插入,从而彻底防止幻读

MVCC 解决 不可重复读

在同一事务中,两次读取同一行数据,结果不一致(因为另一事务在中间提交了对该行的修改)。

REPEATABLE READ 隔离级别下,MVCC 通过固定 Read View 避免了不可重复读

  • READ COMMITTED(RC):每次 SELECT 都生成新的 Read View,因此可能看到其他事务新提交的修改,存在不可重复读

  • REPEATABLE READ(RR):事务在首次读取时创建 Read View,并在整个事务期间复用该视图,后续读取始终看到相同的数据版本(即使其他事务已提交新版本)。

MVCC 与写写并发

MVCC不能解决写-写事务并发导致的更新丢失问题,需要借助锁机制(悲观锁或乐观锁)

在MVCC中写-写并发会导致其中一个失败

1、乐观锁解决写-写并发

  • 通过增加版本号机制,利用CAS的思想来对比版本号更新。

  • 如果更新失败报错回滚,或者自旋,新开启事务重新查询当前最新版本号,再重复利用CAS的思想来对比版本号更新。

自旋记得要开启新事务,因为MVCC读视图一旦成功,同一个事务就不会变,读取的数据永远是一样的

2、悲观锁解决写-写并发

  • 使用select for update悲观锁,其他事务操作阻塞。