第3章:事务处理(Transaction Processing)
一、导读
1.1 本章学习目标
第3章深入讲解数据库事务处理的内部实现机制。事务是数据库保证数据一致性的核心抽象——它将一组操作打包为一个原子单元,要么全部成功,要么全部失败。理解事务的内部实现,是掌握数据库可靠性保证的关键。
本章的核心学习目标包括:
- 掌握 MVCC 的实现原理:多版本并发控制如何让读写不冲突?
- 理解 WAL 与事务持久性的关系:redo log 如何保证崩溃恢复?
- 掌握锁机制的设计:共享锁、排他锁、意向锁如何协同工作?
- 理解死锁检测与预防:等待图算法和超时机制如何实现?
- 掌握检查点的实现:模糊检查点如何在不停止事务的情况下保证一致性?
- 理解事务隔离级别的实现:Read Committed、Repeatable Read、Serializable 各如何实现?
1.2 为什么本章如此重要
事务是数据库区别于简单 KV 存储的核心特性。ACID(原子性、一致性、隔离性、持久性)四个属性保证了即使在并发访问和系统崩溃的情况下,数据仍然保持一致。
理解事务的内部实现,对于以下场景至关重要:
- 排查并发问题:死锁、锁等待、幻读等问题的根因分析
- 性能调优:选择合适的隔离级别、优化长事务、减少锁竞争
- 架构设计:分布式事务、最终一致性、Saga 模式的选择
1.3 核心问题引导
MVCC 如何在不加锁的情况下实现事务隔离?
一个事务提交时,redo log 的写入顺序是什么?
为什么 InnoDB 的 Repeatable Read 级别可以防止幻读?
死锁检测的等待图算法的时间复杂度是多少?有什么优化方案?
模糊检查点如何处理检查点期间正在修改数据的事务?
二、核心概念详解
2.1 MVCC(多版本并发控制)
2.1.1 核心思想
MVCC 的核心思想是:每个事务看到的不是数据的最新值,而是数据在某个时间点的一致性快照。通过维护数据的多个版本,读写操作可以并发执行而不冲突。
MVCC 的基本模型:
Row: [key, value, version_chain]
version_chain:
v3 → v2 → v1 → v0
↑ ↑ ↑ ↑
tx3 tx2 tx1 tx0 (创建该版本的事务)
事务 tx4 (Read Committed):
→ 看到 v3 (最新的已提交版本)
事务 tx2 (Repeatable Read, 开始于 v2 之后):
→ 看到 v2 (事务开始时的快照)2.1.2 InnoDB 的 MVCC 实现
InnoDB 的每一行数据都包含两个隐藏列:
- DB_TRX_ID(6 字节):最后修改该行的事务 ID
- DB_ROLL_PTR(7 字节):指向 undo log 中该行旧版本的指针
InnoDB 行格式:
┌──────────────────────────────────────────┐
│ DB_TRX_ID (6B) | DB_ROLL_PTR (7B) | ... │ ← 当前版本
├──────────────────────────────────────────┤
│ User Columns │
└──────────────────────────────────────────┘
Undo Log (版本链):
Current Row (trx_id=100)
↓ roll_ptr
Undo Record (trx_id=80)
↓ roll_ptr
Undo Record (trx_id=50)
↓ roll_ptr
...ReadView(读视图) 是 MVCC 的关键数据结构,它在事务开始时创建,记录了当前活跃事务的列表:
ReadView 结构:
┌─────────────────────────────────────────┐
│ creator_trx_id: 创建此 ReadView 的事务ID │
│ low_limit_id: 高水位线 (下一个将分配的事务ID) │
│ up_limit_id: 低水位线 (最小的活跃事务ID) │
│ active_trx_ids: [活跃事务ID列表] │
└─────────────────────────────────────────┘
可见性判断规则:
对于版本 v (trx_id = T):
1. T == creator_trx_id → 可见 (自己的修改)
2. T < up_limit_id → 可见 (事务已提交)
3. T >= low_limit_id → 不可见 (事务在 ReadView 创建后才开始)
4. T in active_trx_ids → 不可见 (事务还在活跃中)
5. 其他 → 可见 (事务已提交)Read Committed vs Repeatable Read 的区别:
- Read Committed:每次 SELECT 都创建新的 ReadView → 能看到其他事务的最新提交
- Repeatable Read:只在第一次 SELECT 时创建 ReadView → 整个事务期间看到一致的快照
2.2 锁机制
2.2.1 锁的类型
InnoDB 支持多种锁类型:
记录锁(Record Lock):
- 锁定索引中的一条记录
- SELECT ... WHERE id = 1 FOR UPDATE → 锁住 id=1 这一行
间隙锁(Gap Lock):
- 锁定索引记录之间的间隙
- 防止其他事务在间隙中插入新记录
- 用于防止幻读(Repeatable Read 级别)
临键锁(Next-Key Lock):
- 记录锁 + 间隙锁的组合
- 锁定记录本身 + 记录之前的间隙
- InnoDB 在 Repeatable Read 级别下的默认锁策略
意向锁(Intention Lock):
- 表级锁,表示事务打算对表中的记录加锁
- IS (Intention Shared): 打算加 S 锁
- IX (Intention Exclusive): 打算加 X 锁
- 用于快速检测表级锁冲突2.2.2 锁的兼容性矩阵
IS IX S X
IS ✓ ✓ ✓ ✗
IX ✓ ✓ ✗ ✗
S ✓ ✗ ✓ ✗
IX ✗ ✗ ✗ ✗
✓ = 兼容, ✗ = 冲突2.2.3 死锁检测
InnoDB 使用等待图(Wait-For Graph)算法检测死锁:
等待图:
T1 → 持有锁A, 等待锁B
T2 → 持有锁B, 等待锁A
等待图:
T1 ──(等待B)──→ T2
T2 ──(等待A)──→ T1
检测到环 → 死锁!
解决方案: 回滚持有最少行锁的事务死锁检测的时间复杂度为 O(n²),n 为事务数量。对于高并发场景,可以设置 innodb_deadlock_detect=OFF,改用锁等待超时(innodb_lock_wait_timeout)来处理。
2.3 WAL 与崩溃恢复
2.3.1 事务提交流程
事务提交流程 (InnoDB):
1. 将事务的 redo log 写入 log buffer
2. 调用 fsync 将 log buffer 刷入磁盘
3. 标记事务为已提交
4. 返回客户端
关键: 步骤 2 完成后事务才算真正提交
即使数据页还没有刷入磁盘,崩溃后也可以通过 redo log 恢复2.3.2 崩溃恢复过程
ARIES 恢复算法 (三阶段):
1. 分析阶段 (Analysis):
- 从最近的检查点开始扫描 redo log
- 确定哪些事务在崩溃时是活跃的
- 构建 Dirty Page Table
2. 重做阶段 (Redo):
- 从分析阶段确定的起点开始
- 重放所有 redo log 记录
- 恢复所有已修改的页面(包括未提交事务的修改)
3. 撤销阶段 (Undo):
- 对活跃事务(崩溃时未提交的)执行 undo 操作
- 回滚未完成的修改
- 保证原子性三、实战案例
3.1 案例一:MVCC 可见性分析
-- 会话 1
BEGIN;
SELECT * FROM accounts WHERE id = 1; -- balance = 100
-- ReadView: up_limit_id = 10, active = [5]
-- 会话 2
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
-- trx_id = 15, 修改 balance 为 200
-- 旧版本 (balance=100, trx_id=10) 存入 undo log
-- 会话 1 (Repeatable Read)
SELECT * FROM accounts WHERE id = 1;
-- 仍然看到 balance = 100
-- 因为 trx_id=15 在 active_trx_ids 中 → 不可见
-- 会话 1 (Read Committed)
SELECT * FROM accounts WHERE id = 1;
-- 如果会话 2 已提交 → 看到 balance = 200
-- 因为每次 SELECT 创建新 ReadView, trx_id=15 已不在 active 中3.2 案例二:死锁分析与解决
-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- 锁住 id=1
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- 等待 id=2
-- 会话 2
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 锁住 id=2
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 等待 id=1 → 死锁!
-- InnoDB 检测到死锁, 回滚会话 2:
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
-- 解决方案: 保证所有事务以相同的顺序访问资源
-- 会话 1 和 2 都先锁 id=1, 再锁 id=2 → 不会死锁四、本章小结
本章深入讲解了数据库事务处理的核心机制:
MVCC 通过维护数据的多个版本,实现读写不冲突的并发控制。InnoDB 通过 DB_TRX_ID 和 DB_ROLL_PTR 实现版本链,通过 ReadView 控制可见性。
锁机制 包括记录锁、间隙锁、临键锁和意向锁,不同隔离级别使用不同的锁策略。
WAL 通过先写日志再写数据的方式保证事务持久性,崩溃恢复使用 ARIES 三阶段算法。
死锁检测 使用等待图算法,检测到环时回滚代价最小的事务。
五、思考与练习
思考题
为什么 InnoDB 的 Repeatable Read 级别使用 Next-Key Lock 而不是单纯的 Record Lock?
MVCC 的 undo log 版本链过长会导致什么问题?如何优化?
在高并发场景下,死锁检测的性能瓶颈在哪里?有什么替代方案?
编程练习
实现一个简单的 MVCC 系统,支持多版本读取和可见性判断。
实现一个基于等待图的死锁检测器。
模拟 WAL 的崩溃恢复过程:写入 redo log、模拟崩溃、重放日志恢复数据。