1

介绍下MVCC

MVCC(Multi-Version Concurrency Control,多版本并发控制) 是为了解决数据库 读写冲突,提高并发性能 的机制。

特点:

  • 读写冲突时,读操作不加锁
  • 每个事务看到的数据是某个时间点的“快照”
  • InnoDB 专用,MyISAM 不支持事务

当前读与快照读

前置知识

为什么需要 undo log

我们在进行增、删、改、查操作的时候,虽然没有自己手动开启 BEGIN 事务,也没有显式执行 COMMIT 提交事务,但 MySQL 会在后台隐式地帮我们开启一个事务,操作执行完成后会自动提交。

那么问题来了:如果在执行这条事务的过程中,数据库意外崩溃了,已经修改的数据该怎么办?如果没有任何机制,部分操作已经生效,数据就会处于不一致的状态,可能导致严重错误。

这时,undo log 就发挥作用了。undo log 会在数据修改之前,先记录下原来的值,相当于做了一份“事务提交前的数据备份”。当事务需要回滚的时候,数据库可以通过 undo log 将数据恢复到原来的状态的一致性。

image.png

每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,都会把回滚时需要的信息记录到 undo log 里。

  • 修改操作:会记录修改前的字段值,这样回滚时可以恢复到原来的状态。
  • 删除操作:会记录被删除记录的完整数据,包括主键和其他字段,这样回滚时可以重新插入这条记录。
  • 新增操作:会记录这条记录的主键信息,回滚时只需要根据主键删除这条未提交的记录即可。

一条记录每次被更新时,InnoDB 都会生成对应的 undo log,其中包含 roll_pointer(回滚指针)和 trx_id(事务 ID),用于支持事务回滚和多版本并发控制(MVCC)。

通过 roll_pointer 指针可以将这些 undo log 串成一个链表,这个链表就被称为版本链

字段大小作用
DB_TRX_ID6 byte记录修改该行的事务 ID,用于事务可见性判断
DB_ROLL_PTR7 byte回滚指针,指向该行被修改前的旧版本(undo log 中的记录)
DB_ROW_ID6 byte隐含的自增 ID,如果表无主键,InnoDB 会自动生成,用于聚簇索引
删除标记(delete flag)1 byte标记记录是否被删除或更新,实际数据并未立即物理删除

版本链图如下:

image.png

当前读

读取当前最新版本的数据,对读取的数据加锁,防止其他事务修改,属于 悲观锁

如下操作都是当前读:

  • SELECT ... FOR UPDATE(排他锁)
  • SELECT ... LOCK IN SHARE MODE(共享锁)
  • UPDATE / DELETE / INSERT
  • 串行化事务隔离级别

原理:

  • InnoDB 会直接从最新版本的数据页中读取。
  • 如果有事务正在修改该数据,当前事务会被阻塞,直到锁释放(悲观锁机制)。

示例:

假设有一个表 account:

id | balance
---+--------
1  | 100

事务 T1:

START TRANSACTION;

-- 查询余额并加锁
SELECT balance FROM account WHERE id=1 FOR UPDATE;

-- 假设扣款操作
UPDATE account SET balance = balance - 100 WHERE id=1;

COMMIT;

T1 会读取最新的 balance(100),并对这行加排他锁。
其他事务想修改这行,会被阻塞,直到 T1 提交或回滚。

快照读

读取事务开始时的历史快照,不加锁

  • 读取的是某一时刻事务一致性的快照数据(MVCC版本)。不加锁,
  • 不阻塞其他事务。
  • 常用于普通查询(SELECT),保证事务隔离性(可重复读 REPEATABLE READ)。
  • 基于 undo log(回滚日志)实现。

原理:

  • InnoDB 通过 undo log 保存每条记录的历史版本。
  • 每个事务在开始时会获取一个 事务ID(transaction ID)。
  • 查询时,会根据事务ID判断哪些版本对该事务可见。

示例:

假设表 account 初始数据:

id | balance
---+--------
1  | 100

事务 T1 启动(事务ID=50)

T1 读取:
不加锁的select操作(注:事务级别不是串行化)

SELECT balance FROM account WHERE id=1;

事务 T2 修改 balance:

START TRANSACTION;
UPDATE account SET balance=200 WHERE id=1;

T1 读取:
不加锁的select操作(注:事务级别不是串行化)

SELECT balance FROM account WHERE id=1;

T1 看到的仍然是 100(快照读),即使 T2 已经提交了事务

快照读与mvcc的关系

利用 MVCC(多版本并发控制)来实现事务隔离而不加锁

MVCC 提供多版本存储

  • InnoDB 为每条数据维护 当前版本 和 历史版本(保存在 undo log 中)。
  • 每个事务启动时,会记录一个 事务快照,快照中包含哪些事务已提交、哪些未提交。

快照读基于 MVCC

  • 当事务执行普通 SELECT 时,InnoDB 会根据事务 ID 和快照信息,从 undo log 或数据页中找到事务可见的版本。
  • 即便其他事务修改了数据,快照读也只会看到事务启动时的历史版本,保证 可重复读(REPEATABLE READ)或事务隔离性。

简单来说,我们不直接去锁数据,而是维护数据的多个版本,这样读和写就不会直接冲突。

MVCC的实现原理

MVCC 主要是通过 版本链 (Version Chain)、Undo 日志(Undo Log)和事务快照(Read View) 来实现多版本并发控制

事务快照(Read View)

字段大小作用
creator_trx_id6 byte创建该 Read View 的事务 ID,即当前事务自身 ID
min_trx_id6 byte数据库中所有未提交事务中最小的事务 ID,用于判断哪些已提交事务对快照可见
max_trx_id6 byte数据库中所有事务的最大事务 ID,主要用于内部管理,不直接影响可见性判断
active_trx_ids (m_ids)可变长度事务列表,记录快照生成时所有活跃(未提交)事务的 ID,用于判断哪些事务对快照不可见

实例讲解

假设表 account,初始表状态(T0)

id | balance
---+--------
1  | 100

隐藏字段:

id=1, balance=100
DB_TRX_ID=50       # 最初创建的事务ID
DB_ROLL_PTR=NULL   # 没有历史版本
DB_ROW_ID=1
delete_flag=0      # 记录未删除

事务 T1 启动并查询:

START TRANSACTION; -- T1, trx_id=100
SELECT * FROM account;

T1 生成 Read View:

min_trx_id =        # 没有比 T1 更小的未提交事务
max_trx_id = 100            # 当前数据库中最大事务 ID
m_ids = []                  # 活跃事务列表为空,没有其他未提交事务
creator_trx_id = 100        # 当前事务 T1 的 ID

查询结果:

balance = 100

快照读读取已提交的记录(DB_TRX_ID=50),因此对 T1 可见,读取到 balance=100

T1 修改记录(当前读 / 更新)

UPDATE account SET balance = 200 WHERE id=1;

内部变化:

旧版本写入 Undo Log:

id=1, balance=100
DB_TRX_ID=50  # 原始事务ID
DB_ROLL_PTR=NULL
delete_flag=0

新版本更新数据页:

id=1, balance=200
DB_TRX_ID=100        # 当前事务T1
DB_ROLL_PTR=指向旧版本
delete_flag=0

image.png

数据页最新值变成 200,旧版本 100 在 Undo Log 中保留,以供其他事务的快照读使用。

T1 再次查询(快照读)

SELECT * FROM account WHERE id=1;

自己事务的修改总是可见

balance = 200

事务 T2(另一个事务,演示快照读)

START TRANSACTION; -- T2, trx_id=101
SELECT * FROM account;  -- 快照读

T2 的 Read View(生成时 T1 还未提交):

min_trx_id = 100
max_trx_id = 102
m_ids = [100]  # 活跃事务列表
creator_trx_id = 101

查询结果:

balance = 100

解释:T2 看不到 T1 的未提交修改(balance=200),只能看到旧版本(balance=100),通过 Undo Log 提供。

T1 提交

COMMIT; -- T1 提交

T2 再次查询(快照读)

SELECT * FROM account;  -- 快照读

查询结果:

balance = 100

快照读的判断逻辑

时间点事件对 T2 的影响
t0T1 (trx_id=100) 开始,修改数据行(将其 DB_TRX_ID 设为 100),但未提交。数据的最新版本由活跃事务 100 创建。
t1T2 (trx_id=101) 开始。T2 的事务ID被分配为 101。
t2T2 执行第一次 SELECT这是最关键的一步!
InnoDB 为 T2 创建了一个 Read View
- m_ids: [100] (当前活跃事务,只有T1)
- min_trx_id: 100
- max_trx_id: 102 (假设下一个事务ID是102)
- creator_trx_id: 101 (T2自己)
t3T1 (100) 提交事务T2 的 Read View 没有任何变化! 它还是在 t2 时刻创建的那个。对于 T2 来说,m_ids 里依然记录着 [100]
t4T2 再次执行 SELECTT2 复用在 t2 时刻创建的 Read View。

kexb
646 声望38 粉丝

引用和评论

0 条评论