目录

MySQL-06 事务与 MVCC

MySQL 事务与 MVCC


目录

  1. ACID 原理
  2. 事务基本操作
  3. 隔离级别与问题
  4. MVCC 多版本并发控制
  5. Read View 机制
  6. 各隔离级别实现细节
  7. 事务 ID 与版本链
  8. 长事务的危害
  9. Go 中的事务最佳实践

1. ACID 原理

1.1 Atomicity(原子性)

定义:事务中的所有操作,要么全部成功,要么全部回滚,不存在中间状态。

实现Undo Log

Undo Log 记录了数据修改前的镜像(逻辑日志):

  • INSERT → 记录 DELETE 的 Undo Log
  • DELETE → 记录 INSERT 的 Undo Log
  • UPDATE → 记录旧值的 UPDATE Undo Log

回滚时,执行 Undo Log 中的反向操作即可恢复。

1.2 Consistency(一致性)

定义:事务执行前后,数据满足所有约束(主键、唯一键、外键、CHECK 约束等)。

实现:由 A、I、D 共同保证,以及应用层逻辑约束。

一致性是目的,原子性、隔离性、持久性是手段。

1.3 Isolation(隔离性)

定义:并发事务之间互不干扰,根据隔离级别提供不同程度的隔离。

实现

  • MVCC(多版本并发控制):读不阻塞写,写不阻塞读
  • 锁机制:写-写冲突时通过锁串行化

1.4 Durability(持久性)

定义:已提交的事务,其修改永久保存,即使系统崩溃也不丢失。

实现Redo Log(WAL)

事务提交时,Redo Log 必须刷盘(fsync)。即使数据页还在 Buffer Pool 中未写磁盘,崩溃后也可通过 Redo Log 重放恢复。

# 控制 Redo Log 刷盘策略
innodb_flush_log_at_trx_commit = 1  # 最安全:每次提交都 fsync
                                    # = 0 每秒刷一次(可能丢 1s 数据)
                                    # = 2 写入 OS buffer,每秒 fsync(可能丢 1s 数据)

2. 事务基本操作

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

-- 隐式事务
-- autocommit=ON 时,每条 SQL 自动是一个事务
-- autocommit=OFF 时,需要手动 COMMIT 或 ROLLBACK

-- 查看当前 autocommit
SELECT @@autocommit;
SHOW VARIABLES LIKE 'autocommit';

-- 关闭 autocommit(会话级别)
SET autocommit = 0;

-- 显式提交
COMMIT;

-- 回滚
ROLLBACK;

-- 保存点
SAVEPOINT sp_name;
ROLLBACK TO SAVEPOINT sp_name;  -- 部分回滚
RELEASE SAVEPOINT sp_name;      -- 删除保存点

-- 查看当前事务信息
SELECT * FROM information_schema.INNODB_TRX\G

3. 隔离级别与问题

3.1 并发问题

问题 描述 示例
脏读 读到其他事务未提交的数据 A 改了余额但未提交,B 读到了错误的余额
不可重复读 同一事务中,两次读到的同一行数据不同 A 读了余额,B 提交了修改,A 再读余额变了
幻读 同一事务中,两次查询返回的行数不同 A 统计订单数,B 新增了订单并提交,A 再统计多了一行

3.2 四种隔离级别

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED ✅ 可能 ✅ 可能 ✅ 可能
READ COMMITTED ✅ 可能 ✅ 可能
REPEATABLE READ ⚠️ InnoDB 通过 MVCC+Gap Lock 基本避免
SERIALIZABLE

MySQL InnoDB 默认:REPEATABLE READ

MySQL 特有:InnoDB 的 REPEATABLE READ 通过 MVCC + 间隙锁(Gap Lock),在大多数情况下避免了幻读,实际上比 SQL 标准要求的更强。

-- 查看/设置隔离级别
SELECT @@transaction_isolation;       -- MySQL 8.0
SELECT @@tx_isolation;                -- MySQL 5.7

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL  TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 或在配置文件中
-- transaction_isolation = REPEATABLE-READ

3.3 各隔离级别演示

-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 返回 1000

-- 会话 B(同时执行)
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;
-- 不 COMMIT

-- 会话 A 再查
SELECT balance FROM accounts WHERE id = 1;
-- READ UNCOMMITTED: 500(脏读!)
-- READ COMMITTED:   1000(B 未提交,看不到)
-- REPEATABLE READ:  1000

-- 会话 B COMMIT 后,会话 A 再查
-- READ COMMITTED:   500(不可重复读)
-- REPEATABLE READ:  1000(快照不变)

4. MVCC 多版本并发控制

MVCC 使得读操作不需要加锁,实现读写并发

4.1 InnoDB 行数据的隐藏列

每行数据包含 3 个隐藏字段:

┌────────────────────────────────────────────────────────┐
│  row_data:                                              │
│  ┌──────────┬────────────┬─────────────┬────────────┐  │
│  │ DB_TRX_ID │ DB_ROLL_PTR │ DB_ROW_ID   │  用户数据  │  │
│  │ 6字节     │ 7字节       │ 6字节(可选)  │            │  │
│  └──────────┴────────────┴─────────────┴────────────┘  │
│                                                         │
│  DB_TRX_ID  : 最近修改该行的事务 ID                      │
│  DB_ROLL_PTR: 回滚指针,指向 Undo Log 中的上一个版本      │
│  DB_ROW_ID  : 隐藏主键(只有无主键时才有)                │
└────────────────────────────────────────────────────────┘

4.2 版本链(Undo Log 链)

每次修改行数据,都会在 Undo Log 中保留旧版本,并通过 DB_ROLL_PTR 形成链表:

当前数据(最新版本)
  DB_TRX_ID  = 100
  DB_ROLL_PTR ──────────────────────────────────────────►
  name = 'Alice'                                         │
                                                         ▼
                                                 Undo Log(版本 2)
                                                   DB_TRX_ID = 50
                                                   DB_ROLL_PTR ──►
                                                   name = 'Bob'  │
                                                                  ▼
                                                         Undo Log(版本 1)
                                                           DB_TRX_ID = 10
                                                           DB_ROLL_PTR = NULL
                                                           name = 'Carol'

4.3 Undo Log 的两种类型

类型 触发操作 用途
insert undo log INSERT 仅用于回滚,事务提交后可立即删除
update undo log UPDATE/DELETE 用于回滚 + MVCC,需要等到没有事务引用后才能清理(purge)

UPDATE 实际上是"标记旧版本为删除 + 新增一个版本",而不是真正修改原位。


5. Read View 机制

Read View 是 MVCC 的核心,用于判断某个版本的数据是否对当前事务可见。

5.1 Read View 的结构

// 伪代码表示 Read View 结构
type ReadView struct {
    // 当前活跃的事务 ID 列表(开始 Read View 时,还未提交的事务)
    m_ids []TrxID

    // 活跃事务中最小的事务 ID
    m_up_limit_id TrxID

    // 下一个将要分配的事务 ID(当前最大事务 ID + 1)
    m_low_limit_id TrxID

    // 创建 Read View 的事务 ID
    m_creator_trx_id TrxID
}

5.2 可见性判断规则

对于某行数据的版本,其 DB_TRX_ID = trx_id,判断流程:

1. trx_id == m_creator_trx_id
   → 自己修改的,可见

2. trx_id < m_up_limit_id
   → 在 Read View 创建前已提交,可见

3. trx_id >= m_low_limit_id
   → 在 Read View 创建后才开启的事务,不可见

4. m_up_limit_id <= trx_id < m_low_limit_id
   → 在活跃列表 m_ids 中?
     在:不可见(还未提交)
     不在:可见(已提交)

5. 不可见时,沿 DB_ROLL_PTR 找上一个版本,重复判断

5.3 Read View 的生成时机(关键!)

隔离级别 Read View 生成时机
READ COMMITTED 每次 SELECT 都生成新的 Read View
REPEATABLE READ 事务第一次 SELECT 时生成,之后复用同一个

这就是为什么:

  • RC 能读到已提交的最新数据(每次查看最新快照)
  • RR 在整个事务期间看到的数据一致(快照固定)

6. 各隔离级别实现细节

6.1 READ COMMITTED 实现

事务 A (trx_id=10)            事务 B (trx_id=20)
│                              │
├─ BEGIN                       ├─ BEGIN
│                              │
│                              ├─ UPDATE users SET name='Bob' WHERE id=1
│                              │  (name 从 'Alice' 改为 'Bob',未提交)
│                              │
├─ SELECT name WHERE id=1      │
│  生成 Read View:              │
│  m_ids=[20], m_low_limit=21   │
│  → trx_id=20 在活跃列表,不可见│
│  → 沿版本链找上一版本          │
│  → trx_id=5(已提交),可见    │
│  → 返回 'Alice'               │
│                              │
│                              ├─ COMMIT(trx_id=20 提交)
│                              │
├─ SELECT name WHERE id=1      │
│  重新生成 Read View:           │
│  m_ids=[], m_low_limit=21     │
│  → trx_id=20 < m_low_limit,且不在活跃列表 → 可见
│  → 返回 'Bob'(读到了 B 提交的值!)
│

6.2 REPEATABLE READ 实现

事务 A (trx_id=10)            事务 B (trx_id=20)
│                              │
├─ BEGIN                       ├─ BEGIN
│                              │
├─ SELECT name WHERE id=1      │
│  生成 Read View(第一次查询时):│
│  m_ids=[20], m_low_limit=21   │
│  → 返回 'Alice'               │
│                              │
│                              ├─ UPDATE ... SET name='Bob'
│                              ├─ COMMIT
│                              │
├─ SELECT name WHERE id=1      │
│  复用之前的 Read View!        │
│  m_ids=[20], m_low_limit=21   │
│  → trx_id=20 在 m_ids 中,不可见
│  → 沿版本链,找到 'Alice'     │
│  → 返回 'Alice'(可重复读!)  │

6.3 RR 下的幻读问题

-- 事务 A(RR 隔离级别)
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 返回 5

-- 事务 B 此时插入一条新记录并提交
INSERT INTO orders (user_id, amount) VALUES (1, 100);
COMMIT;

-- 事务 A 再次查询
SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 还是 5(MVCC 快照)

-- 但!如果事务 A 执行 UPDATE
UPDATE orders SET status = 1 WHERE user_id = 1;
-- affected rows = 6!(更新了 B 新插入的那行)

-- 再次查询
SELECT COUNT(*) FROM orders WHERE user_id = 1;  -- 6!(幻读出现了)
-- 因为被更新的行,其 DB_TRX_ID 变为当前事务 ID,Read View 判断为可见

结论:RR 下,纯快照读不会幻读;但混合了快照读和当前读(UPDATE/SELECT FOR UPDATE)时,可能出现幻读。用 SELECT ... FOR UPDATE 加间隙锁可彻底防止。


7. 事务 ID 与版本链

7.1 事务 ID 分配

InnoDB 全局维护一个单调递增的事务 ID 计数器:

  • 只读事务:不分配真实事务 ID(优化,减少开销)
  • 读写事务:在第一次修改数据时分配事务 ID

7.2 Undo Log 清理(Purge)

当没有任何活跃事务需要访问某版本的历史数据时,该版本的 Undo Log 可以被清理(purge)。

-- 查看 Undo Log 状态
SHOW ENGINE INNODB STATUS\G
-- 找 TRANSACTION 部分,关注 History list length(应该不大)

-- 长事务会阻止 Undo Log 清理,导致 undo 表空间膨胀
SELECT * FROM information_schema.INNODB_TRX
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60  -- 超过 60 秒的事务
ORDER BY trx_started;

8. 长事务的危害

8.1 危害

  1. Undo Log 无法清理,占用大量存储空间
  2. 锁持有时间长,阻塞其他事务(写-写冲突)
  3. Buffer Pool 污染,大事务的脏页长时间不能刷盘
  4. 主从延迟:binlog 在事务提交时才写,大事务导致从库延迟

8.2 检测长事务

-- 查看运行超过 5 分钟的事务
SELECT
  trx_id,
  trx_started,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
  trx_query,
  trx_rows_locked,
  trx_rows_modified
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300
ORDER BY trx_started;

-- 通过 performance_schema 找到对应连接
SELECT t.*, p.HOST, p.USER, p.INFO
FROM information_schema.INNODB_TRX t
JOIN information_schema.PROCESSLIST p ON t.trx_mysql_thread_id = p.ID;

8.3 预防长事务

-- 设置事务超时(阻塞等待的超时,不是事务总时长)
SET innodb_lock_wait_timeout = 10;  -- 默认 50s

-- MySQL 8.0 可以设置 transaction_timeout(事务总时长限制)
-- 目前 MySQL 没有直接的事务总时长限制,需要应用层控制

9. Go 中的事务最佳实践

9.1 上下文感知事务

// 推荐:使用 BeginTx 传入 context,支持超时取消
func TransferWithContext(ctx context.Context, db *sql.DB, fromID, toID int64, amount float64) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelReadCommitted,
        ReadOnly:  false,
    })
    if err != nil {
        return err
    }
    defer tx.Rollback()  // 如果 Commit 了,Rollback 是 no-op

    // 使用 SELECT ... FOR UPDATE 防止并发超扣
    var balance float64
    err = tx.QueryRowContext(ctx,
        "SELECT balance FROM accounts WHERE id = ? FOR UPDATE",
        fromID,
    ).Scan(&balance)
    if err != nil {
        return err
    }
    if balance < amount {
        return errors.New("insufficient balance")
    }

    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance - ? WHERE id = ?",
        amount, fromID,
    )
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance + ? WHERE id = ?",
        amount, toID,
    )
    if err != nil {
        return err
    }

    return tx.Commit()
}

9.2 事务助手函数

// 封装事务的通用模式
func WithTransaction(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p)  // 重新抛出 panic
        }
    }()

    if err = fn(tx); err != nil {
        if rbErr := tx.Rollback(); rbErr != nil {
            return fmt.Errorf("tx error: %v, rollback error: %v", err, rbErr)
        }
        return err
    }
    return tx.Commit()
}

// 使用
err = WithTransaction(ctx, db, func(tx *sql.Tx) error {
    if _, err := tx.ExecContext(ctx, "UPDATE ...", ...); err != nil {
        return err
    }
    if _, err := tx.ExecContext(ctx, "INSERT ...", ...); err != nil {
        return err
    }
    return nil
})

9.3 GORM 事务

// GORM 内置事务支持
err = db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&order).Error; err != nil {
        return err  // 返回 error 会自动 rollback
    }
    if err := tx.Create(&orderItems).Error; err != nil {
        return err
    }
    // 返回 nil 会自动 commit
    return nil
})

// GORM 嵌套事务(使用保存点)
db.Transaction(func(tx *gorm.DB) error {
    tx.Create(&user)

    // 嵌套事务(内部使用 SAVEPOINT)
    tx.Transaction(func(tx2 *gorm.DB) error {
        tx2.Create(&pet)
        return errors.New("rollback pet")  // 只回滚内部事务
    })

    return nil  // 外部事务正常提交
})

9.4 避免事务中的常见错误

// ❌ 错误1:忘记 defer Rollback
tx, _ := db.Begin()
tx.Exec("UPDATE ...")
tx.Commit()
// 如果 Exec 失败,没有 Rollback

// ❌ 错误2:在 for 循环中开启大事务
tx, _ := db.Begin()
for _, item := range items {  // 10000 条!
    tx.Exec("INSERT ...", item)
}
tx.Commit()  // 这是一个超大事务,持有大量锁

// ✅ 正确:分批提交
batchSize := 100
for i := 0; i < len(items); i += batchSize {
    batch := items[i:min(i+batchSize, len(items))]
    err := WithTransaction(ctx, db, func(tx *sql.Tx) error {
        for _, item := range batch {
            if _, err := tx.Exec("INSERT ...", item); err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        return err
    }
}

// ❌ 错误3:事务中有网络调用(长事务!)
tx, _ := db.Begin()
tx.Exec("SELECT ... FOR UPDATE")  // 持锁
result := callExternalAPI()       // 外部 API 可能很慢!
tx.Exec("UPDATE ...")
tx.Commit()

// ✅ 正确:先做外部调用,再开事务
result := callExternalAPI()
err = WithTransaction(ctx, db, func(tx *sql.Tx) error {
    // 快速操作
    return nil
})

9.5 读已提交在 Go 中的使用场景

// 场景:高并发读场景,业务允许读到最新已提交数据
// 比如:首页热门商品列表(不需要可重复读)
rows, err := db.QueryContext(ctx, `
    /* READ COMMITTED */
    SELECT id, name, price FROM products WHERE status = 1 LIMIT 20
`)

// 或者连接级别设置
_, err = db.ExecContext(ctx, "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED")

小结

隔离级别 Read View 时机 解决 未解决
RC 每次 SELECT 脏读 不可重复读、幻读
RR(默认) 事务首次 SELECT 脏读、不可重复读、快照读幻读 当前读幻读(需 Gap Lock)
SERIALIZABLE 全程加读锁 所有问题 并发性能差

MVCC 核心三要素:

  1. 隐藏列:DB_TRX_ID + DB_ROLL_PTR
  2. Undo Log 版本链:保存历史版本
  3. Read View:判断可见性,RC 每次生成,RR 首次生成后复用