第12章:事务管理(Transaction Management)
一、导读
1.1 本章学习目标
事务是数据库操作的逻辑单位。本章讲解事务的概念、ACID 属性、并发执行的问题。
本章的核心学习目标包括:
- 理解事务的概念:数据库操作的逻辑单位
- 掌握 ACID 属性:原子性、一致性、隔离性、持久性
- 理解并发执行的问题:丢失更新、脏读、不可重复读、幻读
- 掌握可串行化:冲突可串行化、视图可串行化
- 了解事务隔离级别
- 理解并发控制的实现机制:封锁、MVCC、时间戳排序
1.2 为什么事务管理如此重要
事务管理是数据库系统的核心功能。它保证:
- 原子性:事务要么全部执行,要么全部不执行
- 一致性:事务执行后数据库处于一致状态
- 隔离性:并发事务互不干扰
- 持久性:事务提交后数据永久保存
理解事务管理,对于开发可靠的数据库应用至关重要。无论是银行转账、机票预订还是电商下单,事务管理都是保证数据正确性的基石。
1.3 核心问题引导
在学习本章时,请思考以下问题:
如果没有事务机制,银行转账可能出现什么问题?
并发执行为什么需要控制?不控制会导致哪些异常?
可串行化为什么是并发控制的正确性标准?
隔离级别越高越好吗?如何选择合适的隔离级别?
二、核心概念详解
2.1 事务的概念
事务(Transaction)是数据库操作的逻辑单位,由一组操作组成。
BEGIN TRANSACTION;
UPDATE Account SET balance = balance - 100 WHERE id = 'A';
UPDATE Account SET balance = balance + 100 WHERE id = 'B';
COMMIT;事务的边界由 BEGIN 和 COMMIT/ROLLBACK 确定。COMMIT 表示事务成功完成,ROLLBACK 表示事务失败并撤销所有修改。
2.2 ACID 属性
2.2.1 原子性(Atomicity)
事务中的操作要么全部执行,要么全部不执行。
转账事务:
1. 从 A 账户扣除 100 元
2. 向 B 账户增加 100 元
如果步骤 1 成功但步骤 2 失败,必须回滚步骤 1。实现机制:日志(Undo Log)。数据库在执行每个修改操作前,先将修改前的旧值写入日志。当事务需要回滚时,通过日志恢复旧值。
2.2.2 一致性(Consistency)
事务执行后,数据库从一个一致状态转换到另一个一致状态。
一致性约束:
- A 账户余额 + B 账户余额 = 总金额
- 转账前后总金额不变实现机制:完整性约束、触发器、应用代码。一致性是事务的最终目标,其他三个属性(原子性、隔离性、持久性)都是为了保证一致性。
2.2.3 隔离性(Isolation)
并发事务的执行效果与串行执行相同。
事务 T1:读取 A 的余额
事务 T2:修改 A 的余额
T1 和 T2 并发执行时,T1 应该看到 T2 修改前或修改后的值,
不应该看到中间状态。实现机制:锁、多版本控制。隔离性是并发控制的核心目标,需要在正确性和性能之间权衡。
2.2.4 持久性(Durability)
事务提交后,其对数据库的修改永久保存,即使系统崩溃也不会丢失。
实现机制:日志(Redo Log)、检查点。Redo Log 记录了所有修改操作,系统崩溃后可以通过重做日志恢复已提交事务的修改。检查点机制定期将内存中的数据写入磁盘,减少恢复时需要重做的日志量。
2.3 并发执行的问题
并发事务可能导致以下问题:
2.3.1 丢失更新(Lost Update)
两个事务同时修改同一数据,一个事务的修改被覆盖。
T1: 读取 A = 100
T2: 读取 A = 100
T1: A = A + 10, 写入 A = 110
T2: A = A + 20, 写入 A = 120 ← T1 的更新丢失丢失更新是最基本的并发问题,任何隔离级别(除 READ UNCOMMITTED 外)都应该避免。
2.3.2 脏读(Dirty Read)
一个事务读取了另一个未提交事务的修改。
T1: 修改 A = 100
T2: 读取 A = 100 ← 脏读
T1: 回滚,A 恢复为 90
T2: 基于错误的 A = 100 继续执行脏读的危险在于 T2 基于一个"不存在"的数据值做出了决策。
2.3.3 不可重复读(Non-Repeatable Read)
一个事务两次读取同一数据,结果不同。
T1: 读取 A = 100
T2: 修改 A = 200, 提交
T1: 再次读取 A = 200 ← 不可重复读不可重复读影响需要多次读取同一数据的应用逻辑,如报表生成。
2.3.4 幻读(Phantom Read)
一个事务两次查询,第二次查询看到了第一次查询没有的行。
T1: 查询所有余额 > 100 的账户,得到 10 行
T2: 插入一个新账户,余额 150, 提交
T1: 再次查询所有余额 > 100 的账户,得到 11 行 ← 幻读幻读与不可重复读的区别在于:不可重复读是已有行的修改,幻读是新行的插入。
2.4 可串行化
可串行化是并发控制的正确性标准。
2.4.1 串行调度
事务一个接一个执行,没有交错。
调度 1:T1 → T2
调度 2:T2 → T1串行调度一定是正确的,但并发度为零,性能很差。
2.4.2 冲突可串行化
如果调度可以通过交换相邻的非冲突操作转换为串行调度,则它是冲突可串行化的。
冲突操作:
- 同一数据项的读写(R-W 冲突)
- 同一数据项的写读(W-R 冲突)
- 同一数据项的写写(W-W 冲突)
两个读操作(R-R)不冲突,可以交换。
2.4.3 优先图(Precedence Graph)
优先图用于判断调度是否冲突可串行化:
- 节点:事务
- 边:如果 Ti 的某个操作在 Tj 的某个操作之前,且它们冲突,则有一条从 Ti 到 Tj 的边
如果优先图无环,则调度冲突可串行化。环的存在意味着事务之间存在循环依赖,无法排成线性顺序。
2.5 事务隔离级别
SQL 标准定义了四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;不同的数据库系统默认隔离级别不同:MySQL 默认 REPEATABLE READ,PostgreSQL 和 Oracle 默认 READ COMMITTED。
三、深入分析
3.1 事务状态转换
活跃 → 部分提交 → 提交
↓
失败 → 中止事务的状态转换反映了事务的生命周期:
- 活跃状态:事务正在执行操作
- 部分提交状态:事务的最后一个操作已执行,但修改还在缓冲区中
- 提交状态:事务的所有修改已持久化
- 失败状态:事务检测到错误或系统故障
- 中止状态:事务被回滚,数据库恢复到事务开始前的状态
3.2 并发控制的实现机制
- 封锁(Locking):共享锁(S 锁)允许并发读,排他锁(X 锁)独占写。两阶段封锁协议(2PL)保证可串行化:事务分为扩展阶段(只加锁不释放)和收缩阶段(只释放不加锁)。
- 时间戳排序:每个事务有唯一的时间戳,按时间戳顺序执行。冲突时通过回滚较新事务来解决。
- 多版本并发控制(MVCC):维护数据的多个版本,读操作读取旧版本,写操作创建新版本。读写不冲突,大大提高了并发度。PostgreSQL 和 MySQL 的 InnoDB 引擎都使用 MVCC。
3.3 死锁处理
- 死锁预防:等待-死亡(Wound-Wait)方案中,老事务可以抢占新事务的资源;受伤-等待(Wait-Die)方案中,新事务等待老事务,否则被中止。两种方案都通过限制等待关系来防止死锁。
- 死锁检测:等待图(Wait-for Graph)中,如果存在环则发生死锁。数据库定期检测等待图,发现死锁后选择一个"牺牲者"回滚。
- 死锁恢复:回滚代价最小的事务,释放其持有的锁,打破死锁环。
四、实践案例
4.1 案例一:银行转账事务
START TRANSACTION;
UPDATE Account SET balance = balance - 100 WHERE id = 'A';
UPDATE Account SET balance = balance + 100 WHERE id = 'B';
COMMIT;如果在两个 UPDATE 之间系统崩溃,原子性保证第一个 UPDATE 会被回滚,不会出现扣款成功但存款失败的情况。
4.2 案例二:使用 JDBC 控制事务
Connection conn = DriverManager.getConnection(url, user, password);
conn.setAutoCommit(false);
try {
Statement stmt = conn.createStatement();
stmt.executeUpdate("UPDATE Account SET balance = balance - 100 WHERE id = 'A'");
stmt.executeUpdate("UPDATE Account SET balance = balance + 100 WHERE id = 'B'");
conn.commit();
} catch (SQLException e) {
conn.rollback();
}在应用程序中控制事务时,务必在 catch 块中执行 rollback,并在 finally 块中关闭连接。
4.3 案例三:隔离级别的选择策略
- READ COMMITTED:适合大多数 OLTP 场景,如 Web 应用。性能好,能避免脏读。
- REPEATABLE READ:适合需要一致性读的场景,如报表生成。MySQL 默认级别。
- SERIALIZABLE:适合对一致性要求极高的场景,如金融交易。性能最差。
选择隔离级别的原则:在满足业务需求的前提下,选择最低的隔离级别以获得最好的并发性能。
五、常见误区
5.1 误区一:"隔离级别越高越好"
隔离级别越高,并发度越低,性能越差。需要根据业务需求选择合适的隔离级别。大多数 Web 应用使用 READ COMMITTED 就足够了。
5.2 误区二:"事务越长越好"
长事务持有锁的时间长,降低并发度,增加死锁风险。应该尽量缩短事务的执行时间,将非数据库操作(如网络调用、文件操作)移出事务。
5.3 误区三:"REPEATABLE READ 完全消除了幻读"
MySQL 的 InnoDB 在 REPEATABLE READ 级别下通过间隙锁(Gap Lock)大幅减少了幻读,但并非完全消除。在某些特殊场景下(如先更新后插入),仍可能出现幻读现象。
5.4 难点:理解可串行化
可串行化是抽象的概念,需要通过优先图来理解。关键是要区分"冲突可串行化"和"视图可串行化":前者基于冲突操作的交换,后者基于读写视图的等价性。冲突可串行化是视图可串行化的充分条件。
5.5 难点:理解 MVCC
MVCC 通过维护数据的多个版本实现并发控制。每个事务看到的是事务开始时的数据快照,不会被其他事务的修改干扰。实现细节包括版本链、Read View 和 Undo Log 的配合使用。
六、本章小结
第12章讲解了事务管理:
事务 是数据库操作的逻辑单位
ACID 属性 保证事务的可靠性
并发执行 可能导致丢失更新、脏读、不可重复读、幻读
可串行化 是并发控制的正确性标准
隔离级别 提供不同级别的并发控制
后续章节将讲解并发控制的具体实现。
七、思考题
请用银行转账的例子,分别说明原子性、一致性、隔离性和持久性的含义。
丢失更新和不可重复读有什么区别?分别通过什么隔离级别可以避免?
什么是冲突可串行化?如何使用优先图判断一个调度是否冲突可串行化?
比较两阶段封锁协议(2PL)和 MVCC 的优缺点。各适合什么场景?
为什么长事务会降低数据库的并发性能?如何优化长事务?
MySQL 的 REPEATABLE READ 隔离级别是否完全消除了幻读?请说明原因。
设计一个场景,说明在 SERIALIZABLE 隔离级别下仍然可能出现的问题(如死锁)。
解释死锁的两种预防策略(等待-死亡和受伤-等待)的工作原理和区别。