介绍下MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制) 是为了解决数据库 读写冲突,提高并发性能 的机制。
特点:
- 读写冲突时,读操作不加锁
- 每个事务看到的数据是某个时间点的“快照”
- InnoDB 专用,MyISAM 不支持事务
当前读与快照读
前置知识
为什么需要 undo log
我们在进行增、删、改、查操作的时候,虽然没有自己手动开启 BEGIN 事务,也没有显式执行 COMMIT 提交事务,但 MySQL 会在后台隐式地帮我们开启一个事务,操作执行完成后会自动提交。
那么问题来了:如果在执行这条事务的过程中,数据库意外崩溃了,已经修改的数据该怎么办?如果没有任何机制,部分操作已经生效,数据就会处于不一致的状态,可能导致严重错误。
这时,undo log 就发挥作用了。undo log 会在数据修改之前,先记录下原来的值,相当于做了一份“事务提交前的数据备份”。当事务需要回滚的时候,数据库可以通过 undo log 将数据恢复到原来的状态的一致性。
每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,都会把回滚时需要的信息记录到 undo log 里。
- 修改操作:会记录修改前的字段值,这样回滚时可以恢复到原来的状态。
- 删除操作:会记录被删除记录的完整数据,包括主键和其他字段,这样回滚时可以重新插入这条记录。
- 新增操作:会记录这条记录的主键信息,回滚时只需要根据主键删除这条未提交的记录即可。
一条记录每次被更新时,InnoDB 都会生成对应的 undo log,其中包含 roll_pointer(回滚指针)和 trx_id(事务 ID),用于支持事务回滚和多版本并发控制(MVCC)。
通过 roll_pointer 指针可以将这些 undo log 串成一个链表,这个链表就被称为版本链
| 字段 | 大小 | 作用 |
|---|---|---|
| DB_TRX_ID | 6 byte | 记录修改该行的事务 ID,用于事务可见性判断 |
| DB_ROLL_PTR | 7 byte | 回滚指针,指向该行被修改前的旧版本(undo log 中的记录) |
| DB_ROW_ID | 6 byte | 隐含的自增 ID,如果表无主键,InnoDB 会自动生成,用于聚簇索引 |
| 删除标记(delete flag) | 1 byte | 标记记录是否被删除或更新,实际数据并未立即物理删除 |
版本链图如下:
当前读
读取当前最新版本的数据,对读取的数据加锁,防止其他事务修改,属于 悲观锁
如下操作都是当前读:
- 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_id | 6 byte | 创建该 Read View 的事务 ID,即当前事务自身 ID |
| min_trx_id | 6 byte | 数据库中所有未提交事务中最小的事务 ID,用于判断哪些已提交事务对快照可见 |
| max_trx_id | 6 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数据页最新值变成 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 的影响 |
|---|---|---|
| t0 | T1 (trx_id=100) 开始,修改数据行(将其 DB_TRX_ID 设为 100),但未提交。 | 数据的最新版本由活跃事务 100 创建。 |
| t1 | T2 (trx_id=101) 开始。 | T2 的事务ID被分配为 101。 |
| t2 | T2 执行第一次 SELECT。 | 这是最关键的一步! InnoDB 为 T2 创建了一个 Read View: - m_ids: [100] (当前活跃事务,只有T1) - min_trx_id: 100 - max_trx_id: 102 (假设下一个事务ID是102) - creator_trx_id: 101 (T2自己) |
| t3 | T1 (100) 提交事务。 | T2 的 Read View 没有任何变化! 它还是在 t2 时刻创建的那个。对于 T2 来说,m_ids 里依然记录着 [100]。 |
| t4 | T2 再次执行 SELECT。 | T2 复用在 t2 时刻创建的 Read View。 |
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。