Skip to content

MySQL 事务与 MVCC

  事务是数据库操作的最小逻辑单元,MVCC(多版本并发控制)是 InnoDB 实现高并发事务的核心机制。理解事务隔离级别和 MVCC 原理,是掌握 MySQL 的关键。


1. 事务基础

1.1 什么是事务

  事务是一组不可分割的数据库操作,要么全部执行成功,要么全部回滚。事务是数据库从一种一致性状态转换到另一种一致性状态的基本单位。

1.2 ACID 特性详解

特性含义InnoDB 实现方式
原子性(Atomicity)事务是不可分割的最小单元,所有操作要么全部成功,要么全部失败undo log(回滚日志)
一致性(Consistency)事务执行前后,数据库都处于一致性状态由其他三个特性共同保证 + 约束(主键、外键、NOT NULL、CHECK 等)
隔离性(Isolation)并发事务之间相互隔离,互不干扰MVCC + 锁机制
持久性(Durability)事务一旦提交,对数据的修改是永久的,即使系统崩溃也不会丢失redo log(重做日志)

1.3 原子性(Atomicity)实现

  原子性通过 undo log 实现:

  1. 事务开始,对数据修改前,先将修改前的数据写入 undo log。
  2. 如果事务需要回滚(ROLLBACK),利用 undo log 将数据恢复到修改前的状态。
  3. 如果事务提交成功,undo log 被标记为可删除(由 Purge Thread 清理)。
sql
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 该操作会在 undo log 中记录 balance 的旧值
ROLLBACK;
-- 通过 undo log 恢复 balance 的旧值

1.4 持久性(Durability)实现

  持久性通过 redo log 实现(WAL 机制):

  1. 事务对数据的修改,先写入 Buffer Pool(内存)。
  2. 同时将修改操作记录到 redo log(顺序写,性能高)。
  3. 事务提交时,redo log 刷盘(innodb_flush_log_at_trx_commit = 1)。
  4. 即使系统崩溃,重启后通过 redo log 恢复已提交的事务。

1.5 隔离性(Isolation)实现

  隔离性通过 MVCC + 锁机制 实现:

  • MVCC:实现非锁定一致性读,读操作不加锁。
  • 锁机制:写操作加锁,保证写操作的隔离性。

1.6 一致性(Consistency)实现

  一致性由原子性、隔离性、持久性共同保证,同时依赖数据库的约束:

  • 主键约束、外键约束、NOT NULL 约束、CHECK 约束。
  • 应用层的业务逻辑一致性。

2. 事务隔离级别

2.1 四种隔离级别

隔离级别脏读不可重复读幻读
READ UNCOMMITTED(读未提交)可能可能可能
READ COMMITTED(读已提交)不可能可能可能
REPEATABLE READ(可重复读)不可能不可能可能(InnoDB 通过 Next-Key Lock 解决)
SERIALIZABLE(串行化)不可能不可能不可能

2.2 三种并发问题

脏读(Dirty Read)

  • 事务 A 读取到了事务 B 尚未提交的数据。
  • 如果事务 B 回滚,事务 A 读取到的数据就是脏数据。
时间线:
T1: 事务 A 修改 balance = 100 -> 200(未提交)
T2: 事务 B 读取 balance = 200(脏读)
T3: 事务 A ROLLBACK, balance = 100
T4: 事务 B 基于 200 做了错误决策

不可重复读(Non-Repeatable Read)

  • 事务 A 两次读取同一行数据,结果不同。
  • 因为事务 B 在两次读取之间修改了该行并提交。
时间线:
T1: 事务 A 读取 balance = 100
T2: 事务 B 修改 balance = 100 -> 200(提交)
T3: 事务 A 再次读取 balance = 200(不可重复读)

幻读(Phantom Read)

  • 事务 A 两次查询同一范围的数据,结果集的行数不同。
  • 因为事务 B 在两次查询之间插入了新行并提交。
时间线:
T1: 事务 A 查询 department_id=1 的员工,得到 10 行
T2: 事务 B 插入一条 department_id=1 的员工(提交)
T3: 事务 A 再次查询 department_id=1 的员工,得到 11 行(幻读)

2.3 查看和设置隔离级别

sql
-- 查看全局隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
SELECT @@GLOBAL.transaction_isolation;

-- 查看会话隔离级别
SELECT @@SESSION.transaction_isolation;

-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;

2.4 各隔离级别行为示例

READ UNCOMMITTED

sql
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 100

-- 会话 B
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;  -- 未提交

-- 会话 A
SELECT balance FROM accounts WHERE id = 1;  -- 200(脏读)

READ COMMITTED

sql
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 100

-- 会话 B
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;

-- 会话 A
SELECT balance FROM accounts WHERE id = 1;  -- 200(不可重复读,但避免了脏读)

REPEATABLE READ

sql
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 100

-- 会话 B
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;

-- 会话 A
SELECT balance FROM accounts WHERE id = 1;  -- 100(可重复读,MVCC 快照读)

SERIALIZABLE

sql
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM accounts WHERE balance > 100;
-- 所有满足条件的行被加 S 锁(共享锁),其他事务无法修改

3. MVCC 原理

3.1 什么是 MVCC

  MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非锁定一致性读的机制。它通过保存数据的历史版本,让读操作不加锁,从而提高并发性能。

核心思想

  • 每行数据维护多个版本,每个事务看到的是数据的一个快照。
  • 读操作不加锁,写操作不阻塞读操作。
  • 只在 READ COMMITTED 和 REPEATABLE READ 隔离级别下生效。

3.2 隐藏列

  InnoDB 为每行数据添加了三个隐藏列:

隐藏列名称大小说明
DB_TRX_ID事务 ID6 字节最近修改该行的事务 ID(增删改都会分配)
DB_ROLL_PTR回滚指针7 字节指向 undo log 中该行的上一个版本
DB_ROW_ID行 ID6 字节如果没有主键和唯一非空索引,用此行 ID 作为聚集索引
行数据结构示意:
┌────────────┬───────────┬─────────────┬──────────────┬──────────────┐
│ DB_ROW_ID  │ DB_TRX_ID │ DB_ROLL_PTR │   列1, 列2   │   列3, ...   │
│  (6 bytes) │ (6 bytes) │  (7 bytes)  │  (用户数据)   │              │
└────────────┴───────────┴─────────────┴──────────────┴──────────────┘

3.3 Undo Log 版本链

  每次对行进行修改,都会在 undo log 中记录修改前的版本,并通过 DB_ROLL_PTR 形成版本链。

版本链结构:

当前数据(最新版本):
┌──────────────────────────────────────────────┐
│  id=1, name='Alice', age=26                  │
│  DB_TRX_ID=103, DB_ROLL_PTR ──────────────┐  │
└──────────────────────────────────────────┬─┘  │
                                           │    │
                                           ▼    │
Undo Log 版本 1(age=25 被修改为 26):          │
┌──────────────────────────────────────────────┐│
│  id=1, name='Alice', age=25                  ││
│  DB_TRX_ID=102, DB_ROLL_PTR ──────────────┐ ││
└──────────────────────────────────────────┬─┘││
                                           │  ││
                                           ▼  ││
Undo Log 版本 2(name='Alice' 被插入):        ││
┌──────────────────────────────────────────────┐││
│  id=1, name='Alice'(INSERT 原始记录)        │││
│  DB_TRX_ID=101, DB_ROLL_PTR = NULL(结束)   │││
└──────────────────────────────────────────────┘││
                                                ││
                                                ▼▼

3.4 ReadView(读视图)

  ReadView 是事务进行快照读时产生的读视图,用于判断当前事务能看到哪个版本的数据。

ReadView 包含的关键信息

字段说明
m_ids创建 ReadView 时,当前系统中活跃的(未提交的)事务 ID 列表
min_trx_idm_ids 中的最小值
max_trx_id创建 ReadView 时,系统下一个要分配的事务 ID(即当前最大事务 ID + 1)
creator_trx_id创建该 ReadView 的事务 ID

3.5 可见性判断算法

  当需要读取某一行数据时,沿着版本链遍历,用 ReadView 判断每个版本是否可见:

判断数据版本 trx_id 是否可见:

1. 如果 trx_id == creator_trx_id
   → 可见(当前事务自己修改的)

2. 如果 trx_id < min_trx_id
   → 可见(修改该版本的事务在 ReadView 创建前已提交)

3. 如果 trx_id >= max_trx_id
   → 不可见(修改该版本的事务在 ReadView 创建后开始)

4. 如果 min_trx_id <= trx_id < max_trx_id
   → 如果 trx_id 在 m_ids 中:
      → 不可见(该版本由未提交的事务修改)
   → 如果 trx_id 不在 m_ids 中:
      → 可见(该版本由已提交的事务修改)

5. 如果不可见,沿着 DB_ROLL_PTR 找到上一个版本,重复判断

3.6 RC 与 RR 的 ReadView 生成差异

  这是理解 MVCC 的关键点:

隔离级别ReadView 生成时机效果
READ COMMITTED每次 SELECT 都生成新的 ReadView能看到其他事务已提交的修改(不可重复读)
REPEATABLE READ事务中第一次 SELECT 时生成 ReadView,后续复用整个事务看到的数据一致(可重复读)

RC 示例

事务 A(id=100):
T1: BEGIN;
T2: SELECT name FROM users WHERE id=1;  -- 生成 ReadView_1,看到 name='Alice'
T3: -- 事务 B 修改 name='Bob' 并提交
T4: SELECT name FROM users WHERE id=1;  -- 生成 ReadView_2,看到 name='Bob'

RR 示例

事务 A(id=100):
T1: BEGIN;
T2: SELECT name FROM users WHERE id=1;  -- 生成 ReadView_1,看到 name='Alice'
T3: -- 事务 B 修改 name='Bob' 并提交
T4: SELECT name FROM users WHERE id=1;  -- 复用 ReadView_1,仍看到 name='Alice'

3.7 快照读与当前读

快照读(Snapshot Read)

  • 普通的 SELECT 语句。
  • 通过 MVCC 读取数据的快照版本,不加锁。
  • 在 RC 和 RR 隔离级别下生效。

当前读(Current Read)

  • 读取数据的最新版本,并且加锁。
  • 包括:SELECT ... FOR UPDATESELECT ... FOR SHAREUPDATEDELETEINSERT
  • 当前读会读取最新版本,并在最新版本上加锁。
sql
-- 快照读(无锁)
SELECT * FROM users WHERE id = 1;

-- 当前读(加锁)
SELECT * FROM users WHERE id = 1 FOR UPDATE;
SELECT * FROM users WHERE id = 1 FOR SHARE;
UPDATE users SET name = 'Bob' WHERE id = 1;
DELETE FROM users WHERE id = 1;

4. RR 如何解决幻读

4.1 MVCC 解决部分幻读

  在 RR 级别下,MVCC 的快照读可以解决"读"的幻读(SELECT 不会看到其他事务插入的新行)。

4.2 Next-Key Lock 解决剩余幻读

  但对于当前读(SELECT ... FOR UPDATEUPDATEDELETE),MVCC 无法解决幻读。此时 InnoDB 使用 Next-Key Lock(临键锁)来防止其他事务插入新行。

sql
-- 事务 A(RR 级别)
BEGIN;
SELECT * FROM users WHERE age > 25 FOR UPDATE;
-- 在 age > 25 的所有行和间隙上加 Next-Key Lock

-- 事务 B
INSERT INTO users (name, age) VALUES ('Bob', 30);
-- 阻塞!因为 age=30 所在间隙被事务 A 锁住了

RR 级别下幻读解决总结

操作类型解决方式
普通 SELECTMVCC 快照读
SELECT ... FOR UPDATENext-Key Lock
SELECT ... FOR SHARENext-Key Lock
UPDATE / DELETENext-Key Lock

5. 事务控制命令

5.1 事务基本操作

sql
-- 开启事务
START TRANSACTION;
-- 或
BEGIN;

-- 提交事务
COMMIT;

-- 回滚事务
ROLLBACK;

-- 设置自动提交
SET autocommit = 0;  -- 关闭自动提交
SET autocommit = 1;  -- 开启自动提交

5.2 保存点(SAVEPOINT)

sql
BEGIN;
INSERT INTO users (name) VALUES ('Alice');
SAVEPOINT sp1;

INSERT INTO users (name) VALUES ('Bob');
SAVEPOINT sp2;

INSERT INTO users (name) VALUES ('Charlie');
ROLLBACK TO sp2;  -- 回滚到 sp2,Alice 和 Bob 保留,Charlie 被回滚

ROLLBACK TO sp1;  -- 回滚到 sp1,只有 Alice 保留

COMMIT;  -- 最终提交 Alice

5.3 隐式提交

  以下操作会隐式提交当前事务:

  • 执行 DDL 语句(CREATE、ALTER、DROP 等)。
  • 执行 LOCK TABLESUNLOCK TABLES
  • 执行 BEGINSTART TRANSACTION(先提交上一个事务)。
  • 修改 MySQL 系统变量。
  • 从复制的从库执行 STOP SLAVE

5.4 事务隔离级别与锁行为

sql
-- 查看当前事务是否自动提交
SELECT @@autocommit;

-- 查看事务隔离级别
SELECT @@transaction_isolation;

-- 查看事务状态
SELECT * FROM information_schema.INNODB_TRX;

-- 查看长事务
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
       trx_query, trx_mysql_thread_id
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

6. 生产环境事务最佳实践

6.1 事务设计原则

  1. 事务尽量短小:事务内只包含必要的数据库操作,避免长时间持有锁。
  2. 避免事务中的非数据库操作:不要在事务中执行远程调用、文件 I/O、用户交互。
  3. 合理选择隔离级别:大多数场景使用 RR(MySQL 默认),高并发场景可考虑 RC。
  4. 避免长事务:监控长事务,超过一定时间告警。
  5. 正确处理回滚:捕获异常时显式回滚。

6.2 Spring 事务示例

java
@Service
public class OrderService {
    
    @Transactional(
        isolation = Isolation.REPEATABLE_READ,
        propagation = Propagation.REQUIRED,
        timeout = 30,
        rollbackFor = Exception.class
    )
    public void createOrder(OrderDTO dto) {
        // 1. 扣减库存
        productService.decreaseStock(dto.getProductId(), dto.getQuantity());
        // 2. 创建订单
        orderDao.insert(dto.toOrder());
        // 3. 扣减余额
        accountService.decreaseBalance(dto.getUserId(), dto.getAmount());
    }
}

6.3 长事务监控

sql
-- 使用 sys 库查看长事务
SELECT * FROM sys.innodb_lock_waits;

-- 查看超过 60 秒的事务
SELECT trx_id, trx_started, 
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
       trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started;

6.4 大事务拆分

sql
-- 不推荐:一次性删除大量数据
DELETE FROM logs WHERE create_time < '2023-01-01';
-- 可能锁住大量行,产生大事务,undo log 膨胀

-- 推荐:分批删除
SET @batch_size = 1000;
REPEAT
    DELETE FROM logs WHERE create_time < '2023-01-01' LIMIT @batch_size;
    -- 每批之间 sleep 一小段时间
    DO SLEEP(0.1);
UNTIL ROW_COUNT() = 0 END REPEAT;

7. 总结

  • ACID 是事务的四大特性,InnoDB 通过 undo log 实现原子性,redo log 实现持久性,MVCC + 锁实现隔离性,约束保证一致性。
  • 四种隔离级别逐级加强,MySQL 默认 RR 级别,InnoDB 通过 Next-Key Lock 解决了幻读。
  • MVCC 通过隐藏列、undo log 版本链和 ReadView 实现非锁定一致性读。
  • RC 每次 SELECT 生成新 ReadView,RR 在事务开始时生成一次 ReadView 并复用。
  • 快照读(普通 SELECT)走 MVCC,当前读(FOR UPDATE、UPDATE、DELETE)走锁机制。
  • 事务应尽量短小,避免长事务,合理选择隔离级别。