3Database Internals

事务处理

ACID保证

事务MVCCWAL检查点锁机制

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 可见性分析

sql
-- 会话 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 案例二:死锁分析与解决

sql
-- 会话 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、模拟崩溃、重放日志恢复数据。


3/7事务处理
0%