MySQL 中的 MVCC 版本控制机制原理

发布于:2025-04-06 ⋅ 阅读:(16) ⋅ 点赞:(0)

1. MVCC(多版本并发控制)概述

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种数据库事务并发控制机制,主要用于提高数据库的读写性能。它通过维护数据的多个版本,使得读操作无需加锁,同时保证一致性,减少了事务之间的阻塞。

在 MySQL 的 InnoDB 存储引擎中,MVCC 主要用于**可重复读(REPEATABLE READ)读已提交(READ COMMITTED)**这两种事务隔离级别。

2. MVCC 的实现原理

MVCC 通过隐藏删除和修改的行,加上一些额外的信息来实现多版本控制。主要依赖于 UNDO日志事务 ID(Transaction ID)

(1) 数据隐藏 & 版本控制

InnoDB 的数据行结构中包含两个隐藏的字段:

  • trx_id(事务 ID):表示最近对该行进行修改的事务 ID。
  • roll_pointer(回滚指针):指向该行的 旧版本(即 undo log 记录),从而支持回滚和版本链。

当数据被修改时:

  • 更新(UPDATE):不会直接修改数据,而是将旧版本保存到 undo log,然后生成新的数据版本,并更新 trx_id
  • 删除(DELETE):不会立即删除,而是生成一个新版本,标记该行已删除,并记录 undo log
  • 插入(INSERT):只插入最新版本的数据,不会产生历史版本(因此插入数据不受 MVCC 影响)。

(2) Undo Log 及回滚指针

Undo Log 主要用于:

  • 回滚事务:当事务失败或回滚时,可以通过 Undo Log 恢复数据。
  • MVCC 读取旧版本数据:Undo Log 形成一个版本链,事务可以基于 trx_id 获取合适的旧版本数据,而不会影响其他事务。

(3) MVCC 的可见性规则

当一个事务读取数据时,它需要判断哪些数据版本对自己可见。InnoDB 通过 Read View(读取视图)来管理可见性。

可见性规则如下:

  1. 当前事务 ID (trx_id) < Read View 的最小活跃事务 ID (min_trx_id)
    • 该数据版本已经提交,对当前事务可见。
  2. 当前事务 ID (trx_id) > Read View 的最大活跃事务 ID (max_trx_id)
    • 该数据版本是在当前事务之后创建的,不可见。
  3. 当前事务 ID (trx_id) 介于 min_trx_idmax_trx_id 之间
    • trx_id 属于活跃事务列表,则表示该事务还未提交,不可见。
    • trx_id 不在活跃事务列表中,则可见。

3. MVCC 在不同事务隔离级别下的表现

隔离级别 MVCC 读取的版本
读已提交(Read Committed) 每次 SELECT 都创建新的 Read View,读取最近提交的数据版本
可重复读(Repeatable Read) 事务开始时创建 Read View,整个事务期间保持一致
串行化(Serializable) 不使用 MVCC,需要加锁

未提交读(Read Uncommitted) 不使用 MVCC,而是直接读取最新的数据版本,因此可能会读取到未提交的数据(脏读)。

4. MVCC 的优缺点

优点

  1. 非阻塞读:读取数据不需要加锁,提高并发性能。
  2. 减少事务冲突:多个事务可以同时操作不同版本的数据,避免不必要的锁竞争。
  3. 提高可重复读性能:避免了 SELECT 过程中加锁的开销。

缺点

  1. 需要额外存储:Undo Log 需要存储多个版本的数据,可能会导致存储空间增长。
  2. 版本回收问题:老版本数据需要定期清理,否则会影响性能。
  3. 不适用于高并发写入:因为写入仍然需要锁定行,多个事务同时写入相同数据时,仍然需要等待。

5. 总结

  • MVCC 主要依赖 trx_idundo log 来维护数据的多个版本,允许事务在不同时间点读取合适的数据版本。
  • 不同事务隔离级别下 MVCC 的行为不同Read Committed 每次查询创建新 Read View,而 Repeatable Read 事务开始时创建 Read View 并保持不变。
  • MVCC 使得大部分读操作无需加锁,提高了数据库的并发能力,但也带来存储开销和版本管理的挑战。

在实际应用中,MVCC 适用于读多写少的场景,对于高并发写入,可能需要结合锁机制或优化索引来提升性能。

理解 Read View 和 事务可见性规则

当事务在 READ COMMITTEDREPEATABLE READ 隔离级别下执行 SELECT 语句时,InnoDB 不会直接读取最新的数据版本,而是通过 Read View 来决定 哪个数据版本对当前事务可见

Read View 的关键字段

在 MVCC 机制下,每个事务在读取数据时都会维护一个 Read View,它主要包含:

  • trx_id:每个事务都有一个唯一递增的事务 ID,越新的事务 ID 值越大。
  • m_ids(活跃事务列表):当 Read View 生成时,当前正在执行但未提交的事务 ID 列表。
  • min_trx_id(最小活跃事务 ID):m_ids 中最小的事务 ID。
  • max_trx_id(下一个将要分配的事务 ID):比当前所有活跃事务 ID 都大的值,代表未来新事务的起始 ID。

数据可见性规则

当事务 T 读取一行数据时,该数据的 trx_id(创建它的事务 ID)将与 T 的 Read View 进行比较,以决定该版本是否可见。

数据版本的 trx_id 与 Read View 比较 可见性 解释
trx_id < min_trx_id ✅ 可见 该数据版本是比 Read View 生成时更早的事务创建的,并且该事务已经提交。
trx_id > max_trx_id ❌ 不可见 该数据版本是比 Read View 生成时更晚的事务创建的,因此不可见。
min_trx_id ≤ trx_id < max_trx_idtrx_id ∈ m_ids ❌ 不可见 该数据版本是由某个活跃未提交的事务创建的,不可见。
min_trx_id ≤ trx_id < max_trx_idtrx_id ∉ m_ids ✅ 可见 该数据版本的事务已提交,但其事务 ID 仍然在 Read View 生成时的范围内,因此可见。

直观示例

假设有如下事务操作:

  1. T1 开启事务,写入数据 A,但未提交 (trx_id = 10)。
  2. T2 开启事务,创建 Read View (min_trx_id = 10, max_trx_id = 15)
  3. T3 开启事务,并更新数据 A (trx_id = 12),但也未提交
  4. T4 开启事务 (trx_id = 15),并提交更新数据 A 的事务

此时:

  • T2 读取数据 A 时,trx_id = 10m_ids 内,未提交,不可见。
  • T2 也看不到 trx_id = 12(未提交)。
  • T2 只能看到 trx_id < 10 的数据,即 T1 之前的版本。
  • T4 提交后,T2 依然看不到 trx_id = 15 创建的数据,因为 Read View 在创建时已经固定了事务状态。

如何理解 Read View 的作用

  1. 保证一致性
    • REPEATABLE READ 级别下,同一事务内多次 SELECT 看到的数据是一致的,因为 Read View 在事务开始时创建,不会变化。
    • READ COMMITTED 级别下,每次 SELECT 都会生成新的 Read View,所以可以看到最新已提交的数据。
  2. 避免幻读
    • REPEATABLE READ 级别的 Read View 确保了事务期间看到的行数据不会随其他事务的提交而变化,但对 INSERT 仍然可能出现幻读(需借助 Next-Key Lock 解决)。
  3. 提升并发性能
    • 通过 Read View 让事务读取旧版本数据,而不需要加锁,避免阻塞其他事务。

创建 Read View

T1 开启事务时是否创建 Read View 取决于 事务的隔离级别执行的 SQL 语句

什么时候创建 Read View?

操作 Read View 何时创建?
READ COMMITTED 每次执行 SELECT 语句时都会创建一个新的 Read View。
REPEATABLE READ 第一次执行 SELECT 语句时 创建 Read View,并在整个事务生命周期内保持不变。
SERIALIZABLE 由于需要加锁,不依赖 MVCC Read View,而是直接使用锁进行事务隔离。

T1 开启事务时是否创建 Read View?

  • 如果 T1 只是执行 START TRANSACTION;,此时并不会创建 Read View,只是开启一个事务,还没有执行任何查询。
  • 只有当 T1 执行 SELECT 语句时,才会创建 Read View(前提是隔离级别需要 Read View,如 READ COMMITTEDREPEATABLE READ)。
  • READ COMMITTED 级别下,每次查询都会创建一个新的 Read View。
  • REPEATABLE READ 级别下,T1 第一次查询时 会创建 Read View,后续查询都会使用这个 Read View,即使其他事务提交了新数据,T1 也不会看到(除非显式提交)。

除非显示提交的含义

这里的 “除非显式提交” 是指 T1 事务提交 (COMMIT) 后,再次开启新的事务并执行查询,此时会生成一个新的 Read View,从而看到其他事务提交的数据。

1. 提交事务的方式

在 MySQL 中,提交事务的方式主要有两种:

① 显式提交(Explicit Commit)

手动执行 COMMIT 语句 来提交事务:

BEGIN;
SELECT * FROM users;  -- 事务 T1 创建 Read View
-- 此时 Read View 固定,无法看到其他事务提交的数据

COMMIT;  -- 显式提交事务

COMMIT 之后,事务结束,Read View 被销毁
如果 T1 之后再执行 SELECT,需要开启新事务,此时会创建新的 Read View,可以看到最新数据。

② 隐式提交(Implicit Commit)

MySQL 在某些情况下会自动提交事务,比如执行以下 SQL 语句时:

  • DDL 语句CREATE, ALTER, DROP, TRUNCATE
  • SET AUTOCOMMIT = 1(默认开启自动提交)
  • LOCK TABLES(会自动提交当前事务)

示例:

BEGIN;
SELECT * FROM users;  -- 创建 Read View
ALTER TABLE users ADD COLUMN age INT;  -- DDL 语句触发隐式提交
SELECT * FROM users;  -- 这时 Read View 重新创建

执行 ALTER TABLE 之后,事务被 MySQL 隐式提交,Read View 也重新生成。

2. “除非显式提交” 的含义

REPEATABLE READ 级别,T1 第一次查询时创建的 Read View 不会变化,即使其他事务 (T2) 提交了新数据,T1 也看不到。
只有当 T1 执行 COMMIT 之后,再次查询时才会创建新的 Read View,并看到最新数据。

(1) 事务 T1 只读取,不提交
-- 事务 T1 开启
BEGIN;
SELECT * FROM users;  -- 创建 Read View (RV1)

此时 Read View (RV1) 记录了活跃事务列表。

-- 事务 T2 执行更新
BEGIN;
INSERT INTO users VALUES (2, 'Bob');
COMMIT;

事务 T2 提交了新数据。

-- T1 继续查询
SELECT * FROM users;  -- 仍然使用 Read View (RV1),看不到 Bob

由于 T1 没有提交,它的 Read View 没变,所以看不到 T2 提交的数据。

(2) T1 提交后,再次查询
COMMIT;  -- 显式提交事务
BEGIN;
SELECT * FROM users;  -- 重新创建 Read View (RV2)

T1 提交事务后,Read View (RV1) 销毁。
新事务创建新的 Read View (RV2),此时能看到 Bob!

3. 事务提交的不同方式总结

提交方式 触发时机 是否创建新 Read View
显式提交 (COMMIT) 事务手动提交 ✅ 是,提交后开启新事务会创建新的 Read View
隐式提交(DDL、LOCK TABLES 等) 特定 SQL 语句执行时自动提交 ✅ 是,事务自动提交,Read View 重新创建
未提交 (ROLLBACK 或未执行 COMMIT) 事务未结束 ❌ 否,事务继续使用旧的 Read View

4. 结论

🔹 “除非显式提交” 的意思是:

  • REPEATABLE READ 级别下,T1 不会看到其他事务的新提交数据,除非 T1 自己先 COMMIT 事务。
  • COMMIT 之后,Read View 被销毁,新的查询会创建新的 Read View,这时就能看到最新数据了!

示例

假设数据库初始状态:

CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50));
INSERT INTO users VALUES (1, 'Alice');

场景 1:REPEATABLE READ

-- T1 事务
BEGIN;
SELECT * FROM users;  -- (此时创建 Read View)

这时 Read View 记录了当前活跃事务列表和 min_trx_id

-- T2 事务
BEGIN;
INSERT INTO users VALUES (2, 'Bob');
COMMIT;
-- T1 继续查询
SELECT * FROM users;  -- 仍然只能看到 Alice,因为 Read View 不变

T1 的 Read View 在事务开始时确定,不会看到 Bob

场景 2:READ COMMITTED

-- T1 事务
BEGIN;
SELECT * FROM users;  -- (此时创建 Read View)

Read View 记录了当前事务状态。

-- T2 事务
BEGIN;
INSERT INTO users VALUES (2, 'Bob');
COMMIT;
-- T1 再次查询
SELECT * FROM users;  -- 这次能看到 Bob,因为 Read View 在每次查询时重新生成

READ COMMITTED 级别每次查询都会创建新的 Read View,因此 T1 在第二次查询时能看到 T2 提交的数据。

Read View 绑定性

1. 事务只能看到自己创建的 Read View

  • Read View 是事务内部的快照,用于决定当前事务能看到哪些数据版本。
  • 不同事务的 Read View 互不影响,事务 A 无法访问事务 B 的 Read View。
  • 每个事务只能使用自己创建的 Read View 来查询数据。

2. 示例:不同事务的 Read View 是独立的

-- 事务 A 开始
BEGIN;
SELECT * FROM users;  -- 创建 Read View A

此时 Read View A 记录了当前活跃事务列表。

-- 事务 B 开始
BEGIN;
SELECT * FROM users;  -- 创建 Read View B

此时 Read View B 也创建了,可能与 Read View A 不同。

-- 事务 A 继续查询
SELECT * FROM users;  -- 仍然使用 Read View A

事务 A 只能使用自己的 Read View A,而不会使用事务 B 的 Read View。

-- 事务 B 继续查询
SELECT * FROM users;  -- 仍然使用 Read View B

事务 B 只能使用自己的 Read View B,而不会使用事务 A 的 Read View。

所以,每个事务的 Read View 就像是自己专属的“数据时间快照”,它决定了事务能看到的数据版本 🎯。

默认事务

MySQL 默认是自动提交 (AUTOCOMMIT = 1),即每条 SQL 语句都会作为一个独立的事务执行,并在执行后立即提交。

  • 在默认情况下,每条 SQL 语句(如 INSERTUPDATEDELETE)都会立即提交,不会等待 COMMIT
  • 只有显式关闭 AUTOCOMMIT 或使用 BEGIN; / START TRANSACTION; 才能开启手动提交模式。

示例 1:默认情况下,每条语句都会自动提交

SELECT @@AUTOCOMMIT;  -- 查询当前自动提交模式
-- 结果:1 (表示自动提交开启)

INSERT INTO users VALUES (1, 'Alice');  
-- 这条 INSERT 语句在执行后立即提交,不需要手动 COMMIT。

如果想要手动管理事务(即不让 SQL 语句自动提交),可以通过以下两种方式:

方法 1:使用 SET AUTOCOMMIT = 0

SET AUTOCOMMIT = 0;  -- 关闭自动提交
BEGIN;               -- 开启事务
UPDATE users SET name = 'Bob' WHERE id = 1;
COMMIT;              -- 手动提交事务

注意:

  • SET AUTOCOMMIT = 0 的作用是对当前会话生效,也就是说,当前连接的所有操作都会使用手动提交,直到手动 COMMITROLLBACK
  • 但是 如果连接断开,AUTOCOMMIT 也会恢复为默认值 1

方法 2:使用 START TRANSACTION; / BEGIN;

START TRANSACTION;  -- 开启事务(不会立即提交)
UPDATE users SET name = 'Charlie' WHERE id = 1;
COMMIT;             -- 手动提交事务

或者:

BEGIN;  -- 也是开启事务
UPDATE users SET name = 'David' WHERE id = 1;
ROLLBACK;  -- 撤销事务(如果不想提交)

区别:

  • START TRANSACTION;BEGIN; 只是针对当前事务关闭自动提交,并不会影响整个会话。
  • 当事务提交 (COMMIT) 或回滚 (ROLLBACK) 后,自动提交模式 (AUTOCOMMIT=1) 仍然有效。

事务提交模式总结

方式 作用 影响范围 恢复方式
AUTOCOMMIT = 1(默认) 每条 SQL 语句都会立即提交 整个会话(每个 SQL 语句) 无需恢复
SET AUTOCOMMIT = 0 关闭自动提交,手动 COMMIT 事务 仅当前会话 连接断开后恢复为 1
START TRANSACTION; / BEGIN; 仅当前事务手动提交 只影响当前事务 COMMIT / ROLLBACK 后恢复自动提交

结论

MySQL 默认是自动提交 (AUTOCOMMIT=1),每条 SQL 语句都会自动提交。
如果要手动管理事务,可以用 SET AUTOCOMMIT = 0START TRANSACTION; / BEGIN;
手动管理事务时,需要显式 COMMITROLLBACK,否则事务可能会被锁住,影响并发性能。

如果想在事务中批量执行 SQL 并手动控制提交,建议使用 START TRANSACTION;,这样不会影响整个会话的自动提交行为! 🚀

MyBatis 默认行为

如果在 Java 代码中使用 MyBatis 执行 SQL 语句,而没有显式管理事务(比如没有调用 commit()rollback()),那么默认情况下,MySQL 的 AUTOCOMMIT=1 会生效,每条 SQL 语句都会自动提交。

  • MyBatis 不主动管理事务,而是依赖数据库的默认行为(自动提交)。
  • 这意味着即使执行 INSERTUPDATEDELETE 语句,也不需要显式 COMMIT,数据会立即生效。

不手动管理事务(默认自动提交)

SqlSession sqlSession = sqlSessionFactory.openSession(); // 默认不手动控制事务
UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
userMapper.insertUser(new User(1, "Alice"));  // 这条 SQL 立即提交
sqlSession.close();  // 关闭连接

这里的 insertUser() 方法执行后,SQL 语句会立即提交,因为 MySQL 默认是 AUTOCOMMIT=1

如果想手动控制事务(比如执行多个 SQL 语句后统一提交),需要:

  1. 关闭自动提交openSession(false))。
  2. 手动 commit()rollback() 事务

(2)手动管理事务

SqlSession sqlSession = sqlSessionFactory.openSession(false); // 关闭自动提交
try {
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
    userMapper.insertUser(new User(2, "Bob"));
    userMapper.updateUser(new User(2, "Bob Updated"));

    sqlSession.commit(); // 统一提交事务
} catch (Exception e) {
    sqlSession.rollback(); // 发生异常则回滚
} finally {
    sqlSession.close(); // 关闭连接
}

这里 openSession(false) 关闭了自动提交,所以:

  1. insertUser()updateUser() 不会立即提交。
  2. 只有 sqlSession.commit() 执行后,数据才会真正写入数据库。
  3. 如果发生异常,会执行 rollback(),撤销事务中的所有操作。

Spring + MyBatis 事务管理

在实际的 Spring 项目中,事务通常是由 Spring 事务管理器@Transactional)来控制的,而不是手动调用 commit()rollback()

(3)使用 Spring 事务管理

@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    @Transactional  // 由 Spring 统一管理事务
    public void createUser() {
        userMapper.insertUser(new User(3, "Charlie"));
        userMapper.updateUser(new User(3, "Charlie Updated"));
        // 发生异常时,Spring 会自动回滚事务
    }
}

Spring 事务管理的特点:

  • @Transactional 让 Spring 统一管理事务,默认是 非自动提交 的(AUTOCOMMIT=0)。
  • 方法执行成功时,Spring 自动提交 事务。
  • 发生异常时,Spring 自动回滚 事务。

结论

MyBatis 默认使用 MySQL 的 AUTOCOMMIT=1,每条 SQL 语句执行后都会自动提交。
如果需要手动管理事务,可以使用 openSession(false) 关闭自动提交,并手动 commit()rollback()
在 Spring 项目中,通常使用 @Transactional 让 Spring 统一管理事务,而不会直接调用 MyBatis 的 commit()

所以,如果 MyBatis 代码里没有 commit(),而数据仍然成功写入数据库,那就是 MySQL 的自动提交功能在起作用! 🚀

SpringBoot 中的事务提交

Spring Boot 中,事务的提交方式主要取决于 是否使用了 Spring 事务管理@Transactional),并不是单纯依赖 MySQL 的 AUTOCOMMIT=1

1. Spring Boot 默认的事务行为

Spring Boot 默认不会自动提交事务,它的事务管理主要依赖于 Spring 事务管理器,而 Spring 事务管理默认是 AUTOCOMMIT=0(手动提交)

在 Spring Boot 项目中,通常有两种情况:

  1. 没有 @Transactional(默认自动提交,每个 SQL 语句执行后都会立即提交,依赖 MySQL AUTOCOMMIT=1)。
  2. 使用 @Transactional(Spring 事务管理,事务方法结束后才会提交,默认 AUTOCOMMIT=0)。

2. 如果没有 @Transactional,会使用 MySQL 自动提交

如果 没有 @Transactional,那么 MyBatis 或 JPA 在执行 SQL 语句时,都会立即提交事务,这时依赖 MySQL 的 AUTOCOMMIT=1

3. 如果使用了 @Transactional,Spring 事务管理接管提交

🚀 @Transactional 作用下:

  • Spring 事务管理会关闭 MySQL 自动提交 (AUTOCOMMIT=0)。
  • 所有 SQL 语句都会等到方法执行结束后才提交。
  • 如果方法中抛出异常,Spring 会自动回滚事务,之前的 SQL 操作也不会生效。

4. 如何验证 Spring Boot 是否在使用事务?

可以在 application.properties 中查看 spring.datasource 配置:

spring.datasource.hikari.auto-commit=false  # Hikari 数据源默认关闭自动提交
  • 如果 auto-commit=false,那么即使没有 @Transactional,数据库操作也不会立即提交,而是等待 commit()
  • 如果 auto-commit=true,则默认会使用 MySQL 的 AUTOCOMMIT=1,每条 SQL 执行后立即提交。

此外,还可以手动检查数据库的 AUTOCOMMIT 状态:

SELECT @@autocommit;

如果返回 1,说明 MySQL 处于自动提交模式,SQL 执行后会立即提交。

在 Spring Boot 代码中看不到 commit(),是因为 Spring 事务管理器帮我们自动管理了事务提交,而不是依赖 MySQL 自动提交。

Spring Boot 代码中不需要手动 commit(),并不一定是因为 MySQL 自动提交,而是 Spring 事务管理器负责了事务的提交和回滚! 🚀