InnoDB存储引擎---事务与MVCC

77 阅读8分钟

事务的基本特性(ACID)

事务具有四个基本特性,通常称为ACID特性:

  1. 原子性(Atomicity)​

    • 事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败回滚。
    • 例如:银行转账,A转给B100元,A账户扣减100元和B账户增加100元必须同时成功或同时失败。
  2. 一致性(Consistency)​

    • 事务执行前后,数据库从一个一致状态变到另一个一致状态。
    • 例如:转账前后,A和B的账户总额保持不变。
  3. 隔离性(Isolation)​

    • 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
    • 数据库通过锁机制、多版本并发控制(MVCC)等技术实现隔离性。
  4. 持久性(Durability)​

    • 事务一旦提交,它对数据库的改变就是永久性的,即使系统崩溃也不会丢失。

事务的隔离级别

由于完全的隔离性会影响性能,数据库提供了不同的隔离级别来平衡一致性和性能:

  1. 读未提交(Read Uncommitted)​

    • 最低的隔离级别,允许读取未提交的数据变更。
    • 可能导致脏读、不可重复读和幻读问题。
  2. 读已提交(Read Committed)​

    • 只能读取已提交的数据。
    • 防止脏读,但可能出现不可重复读和幻读。
  3. 可重复读(Repeatable Read)​

    • 在同一个事务中多次读取同一数据会得到相同的结果。
    • 防止脏读和不可重复读,但可能出现幻读。
    • MySQL的默认隔离级别。
  4. 串行化(Serializable)​

    • 最高的隔离级别,完全串行执行事务。
    • 防止脏读、不可重复读和幻读,但性能最低。

事务的常见问题

  1. 脏读(Dirty Read)​

    • 一个事务读取了另一个未提交事务修改的数据。
    • 在读已提交及以上隔离级别可以避免。
  2. 不可重复读(Non-repeatable Read)​

    • 在同一个事务中,两次读取同一数据,由于另一个事务的修改导致两次读取结果不同。
    • 在可重复读及以上隔离级别可以避免。
  3. 幻读(Phantom Read)​

    • 在同一个事务中,两次查询返回的记录数不同,因为另一个事务插入或删除了记录。
    • 在串行化隔离级别可以避免。

MySQL InnoDB 引擎中 MVCC

MVCC 解决的核心问题

读写冲突​:
传统锁机制下,读操作(共享锁)和写操作(排他锁)相互阻塞。MVCC 通过数据多版本实现:

  • 读操作​:访问事务开始时的一致性快照(历史版本)。
  • 写操作​:创建新版本,不影响旧快照的读取。
    结果​:读操作不会被写操作阻塞(非锁定读),大幅提升并发性能。

二、InnoDB MVCC 实现的核心组件

1. 隐藏字段 (每行记录)​
字段名长度作用
DB_TRX_ID6字节最后修改此记录的事务ID​(插入/更新时写入当前事务ID)
DB_ROLL_PTR7字节回滚指针,指向Undo Log中该记录的上一个版本地址,构成版本链。
DB_ROW_ID6字节隐式自增行ID(当表无主键时自动生成)
2. Undo Log(回滚日志)​
  • 存储位置​:位于共享表空间(ibdata1)或独立Undo表空间。

  • 数据结构​:

    • INSERT Undo Log​:存放插入操作的反向操作(删除)。
    • UPDATE Undo Log​:存放更新前的旧值(前镜像)。
  • 核心作用​:

    • 事务回滚​:恢复数据到修改前的状态。
    • 构建版本链​:DB_ROLL_PTR指向Undo Log中的历史版本数据。
  • 生命周期​:事务提交后不会立即删除,需确保无活跃事务依赖该版本。

3. ReadView(一致性视图)​

在事务首次执行快照读时创建,决定该事务能看到哪些版本的数据:

属性描述
m_ids生成ReadView时,系统中所有活跃事务ID的列表(未提交的事务)。
min_trx_idm_ids中的最小值(即最早的活跃事务ID)。
max_trx_id生成ReadView时,系统应分配给下一个事务的ID​(即当前最大事务ID + 1)。
creator_trx_id创建此ReadView的事务ID。

📌 ​关键规则​:
通过ReadView判断数据版本的可见性,确定是否可被当前事务访问。


三、MVCC 可见性判断规则

事务根据 ​ReadView + 版本链​ 决定看到哪个数据版本:

  1. 从最新数据版本(DB_TRX_ID最大)开始遍历版本链。

  2. 检查每行数据的 DB_TRX_ID(记为 trx_id):

    • 情况1​:trx_id < min_trx_id
      → ​可见​(该版本在ReadView创建前已提交)。
    • 情况2​:trx_id >= max_trx_id
      → ​不可见​(该版本在ReadView创建后才修改)。
    • 情况3​:trx_id 在 m_ids 中
      → ​不可见​(修改该版本的事务在ReadView创建时未提交)。
    • 情况4​:trx_id 不在 m_ids 中 且 trx_id ∈ [min_trx_id, max_trx_id)
      → ​可见​(该版本在ReadView创建时已提交)。
  3. 沿版本链(DB_ROLL_PTR)找到第一个可见的版本。

示例

数据库初始状态


CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance DECIMAL(10, 2)
) ENGINE=InnoDB;

INSERT INTO accounts VALUES (1, 1000.00);  -- 初始余额 1000

事务操作序列

时间事务A (Trx10)事务B (Trx20)事务C (Trx30)
T1START TRANSACTION;
T2UPDATE accounts SET balance=900 WHERE id=1;
T3START TRANSACTION;
T4SELECT balance FROM accounts WHERE id=1; (生成ReadView)
T5COMMIT;
T6START TRANSACTION;
T7SELECT balance FROM accounts WHERE id=1; (使用原ReadView)
T8UPDATE accounts SET balance=800 WHERE id=1;
T9SELECT balance FROM accounts WHERE id=1; (生成ReadView)
T10COMMIT;
T11COMMIT;

详细过程解析 (REPEATABLE READ 隔离级别)​

T1-T2: 事务A启动并修改数据
-- Trx10
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id=1;
  • 数据变化​:

    • 创建新版本数据:(id=1, balance=900, DB_TRX_ID=10, DB_ROLL_PTR → 指向旧版本)
    • Undo Log 记录旧值:(id=1, balance=1000)
  • 状态​:事务A未提交

T3-T4: 事务B启动并执行查询
-- Trx20
START TRANSACTION;
SELECT balance FROM accounts WHERE id=1; -- 第一次查询
  • 生成 ReadView​:

    • m_ids = [10](活跃事务:只有Trx10)
    • min_trx_id = 10
    • max_trx_id = 21(假设下一个事务ID分配20+1)
    • creator_trx_id = 20
  • 可见性判断​:

    • 最新数据版本:DB_TRX_ID=10(在 m_ids 中 → ​不可见
    • 沿版本链找到旧版本:DB_TRX_ID=null(初始插入,视为0)
    • 0 < min_trx_id(10) → ​可见
  • 结果​:事务B ​读取到旧值 1000

T5: 事务A提交
  • Redo Log 刷盘
  • Undo Log 保留(因为事务B的ReadView仍依赖它)
T6-T7: 事务B再次查询
-- Trx20 (继续)
SELECT balance FROM accounts WHERE id=1; -- 第二次查询
  • 复用之前的 ReadView​(RR隔离级别特性)

  • 可见性判断​:

    • 最新版本:DB_TRX_ID=10(仍在 m_ids=[10]?注意:Trx10已提交!但ReadView是T4时刻生成的,当时Trx10未提交)

    • 根据原ReadView规则:

      • trx_id=10 ∈ [min_trx_id=10, max_trx_id=21)
      • 且 trx_id=10 在 m_ids=[10] 中 → ​不可见
    • 继续找旧版本:trx_id=null(0) < min_trx_id(10) → ​可见

  • 结果​:事务B ​仍读取到 1000​(避免不可重复读)

T8-T9: 事务C启动并修改数据
-- Trx30
START TRANSACTION;
UPDATE accounts SET balance=800 WHERE id=1; -- 修改数据
SELECT balance FROM accounts WHERE id=1;     -- 查询
  • 数据变化​:

    • 创建新版本:(id=1, balance=800, DB_TRX_ID=30, DB_ROLL_PTR → 指向balance=900的版本)
  • 生成 ReadView​:

    • m_ids = [20](活跃事务:Trx20)
    • min_trx_id = 20
    • max_trx_id = 31
    • creator_trx_id = 30
  • 可见性判断​:

    • 最新版本:DB_TRX_ID=30(等于creator_trx_id → ​可见
  • 结果​:事务C ​读取到自己的修改 800

T10-T11: 事务B和C提交
  • 事务B提交:ReadView 失效
  • 事务C提交:新数据版本持久化

最终版本链状态

image.png


关键点总结

  1. 版本链构建​:

    • 每次修改生成新版本
    • DB_ROLL_PTR 指向前一个版本
    • Undo Log 存储旧数据
  2. ReadView 工作机制​:

    • REPEATABLE READ​:第一次读时创建,后续复用
    -- 事务B的两次查询使用同一个ReadView
    SELECT 1: 创建ReadView(活跃事务=[10])
    SELECT 2: 复用同一个ReadView
    
  3. 可见性规则实践​:

    • 事务B看到 DB_TRX_ID=10 不可见(创建ReadView时活跃)
    • 事务C看到 DB_TRX_ID=30 可见(当前事务修改)
  4. 不同隔离级别对比​:

    操作REPEATABLE READ (示例)READ COMMITTED
    事务B的T7查询1000 (复用旧ReadView)900 (新ReadView)
    避免不可重复读
  5. 清理机制​:

    • 当所有事务(包括Trx20)提交后
    • Purge线程清理 DB_TRX_ID=10 的旧版本(balance=900)