MySQL-06 事务与 MVCC
MySQL 事务与 MVCC
目录
1. ACID 原理
1.1 Atomicity(原子性)
定义:事务中的所有操作,要么全部成功,要么全部回滚,不存在中间状态。
实现:Undo Log
Undo Log 记录了数据修改前的镜像(逻辑日志):
INSERT→ 记录DELETE的 Undo LogDELETE→ 记录INSERT的 Undo LogUPDATE→ 记录旧值的UPDATEUndo 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 危害
- Undo Log 无法清理,占用大量存储空间
- 锁持有时间长,阻塞其他事务(写-写冲突)
- Buffer Pool 污染,大事务的脏页长时间不能刷盘
- 主从延迟: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 核心三要素:
- 隐藏列:DB_TRX_ID + DB_ROLL_PTR
- Undo Log 版本链:保存历史版本
- Read View:判断可见性,RC 每次生成,RR 首次生成后复用
xingliuhua