第11章:事务与 ACID
元数据卡
维度 值 难度 (黑魔法) 前置 第1-10章,熟悉 SQL 基本操作 关键词 事务、ACID、隔离性、可串行化、冲突图、视图等价 代码语言 SQL (PostgreSQL)
你的进度
堡垒里的仓库一直运转良好——直到有一天,两个管理员入库同一批货。一个在登记入库(INSERT),另一个在修改盘点数量(UPDATE)。结果货品数量对不上了。老陈把所有人叫来,在墙上贴了四条铁律:原子性、一致性、隔离性、持久性——ACID。这就是数据堡垒的规矩。
你的任务
理解"事务"为什么不是"一组操作",而是一个保证。学会用 ACID 的视角分析并发问题,掌握冲突可串行化的判定方法,并在 PostgreSQL 中实际使用事务。
破局 · 溯源
事务:一组操作的游戏规则
事务的定义说人话就是:把多个数据库操作打包成一个不可分割的执行单元。
但"不可分割"是什么意思?来看一个问题:
场景:移库
管理员要将炎之剑从上层货架移至下层货架——先出库后入库。在数据库里这是两条 SQL:
UPDATE vault_items SET quantity = quantity - 1 WHERE item_name = 'flame_sword';
UPDATE vault_items SET quantity = quantity + 1 WHERE item_name = 'frost_staff';如果第一条执行完、第二条还没跑的时候,数据库崩溃了——炎之剑已经出库,冰霜法杖却还没入库。这把剑就凭空消失了吗?
事务的方案是把这两条 SQL 包成一个单元:
BEGIN;
UPDATE vault_items SET quantity = quantity - 1 WHERE item_name = 'flame_sword';
UPDATE vault_items SET quantity = quantity + 1 WHERE item_name = 'frost_staff';
COMMIT;要么两条都生效,要么都不生效。这就是事务的第一个保证——原子性。
ACID:四个保证
原子性(Atomicity)
事务内所有操作要么全部提交,要么全部回滚,不存在"只做了一半"的状态。
原子性由 undo log(回滚日志)来实现。如果事务中途失败,数据库用 undo log 把已做的修改撤销。就像管理员在羊皮纸上登记到一半突然蜡烛灭了——原子性保证重新点亮蜡烛时,记录还停在之前干净的状态。
具体实现机制:
PostgreSQL 用 WAL(Write-Ahead Log)记录了每步修改。如果事务提交前崩溃,数据库在恢复时会回滚所有未提交事务的修改。MySQL InnoDB 的 undo log 存的是"修改前的值"——回滚时把数据恢复到修改前的状态。
一致性(Consistency)
事务执行前后,数据库的所有约束(主键、唯一、外键、CHECK、触发器等)都必须满足。
一致性是最容易被误解的。它不是数据库独立保证的——它需要应用 + 数据库一起保证。
数据库保证的是:如果事务开始前数据是"一致"的,那么事务执行后数据也一定是"一致"的。但"什么算一致"是应用层定义的。
例子:你规定"所有物品的库存数量必须 ≥ 0"。约束写了一个 CHECK 在表上:
ALTER TABLE vault_items ADD CONSTRAINT stock_check CHECK (quantity >= 0);如果事务试图把某个物品库存扣到负数,数据库会拒绝提交。但更复杂的业务一致性(比如"移库后两件物品的总量不变")需要应用层自己在事务内保证。
一致性 ≠ 数据库自己搞定的事。 它是应用层约束 + 数据库原子性/隔离性/持久性共同达成的结果。
隔离性(Isolation)
多个并发事务看起来就像在串行执行——一个事务看不到另一个事务未提交的中间状态。
这是这章的核心,也是 Part 4 的四章都在讲的事。隔离性不是一种实现,而是一个目标。不同的实现方式(锁、MVCC)给出不同程度的隔离——这就是下章的隔离级别。这章我们先搞清楚"完全不隔离"会发生什么。
持久性(Durability)
一旦事务提交,它的修改必须永久保存,即使系统崩溃也不会丢失。
PostgreSQL 的实现方式是 WAL:事务提交时,redo log 强制刷盘(fsync),但数据页可能还在 buffer pool 里。崩溃恢复时从 WAL 重放已提交事务的修改。
重要:如果系统配置了异步提交(synchronous_commit = off),事务在返回"提交成功"时可能还没刷盘——在系统级故障下可能丢失数据。这是性能与持久性的权衡。
事务状态机
事务在其生命周期里经历以下状态:
+----------> Committed
|
+-----+-----+
| Partially |
| Committed |
+-----+-----+
|
+------+ +-------+ +--------+
| Active |---->| Failed |----->| Aborted |
+-------+ +-------+ +--------+
↑ |
+-------------------------------+
(重新执行事务)Active:事务正在执行 SQL。这是初始状态。
Partially Committed:事务的最后一条 SQL 已经执行完毕,但还没把修改写入磁盘(或者还没写 WAL)。
Committed:事务已经成功提交,所有修改持久化。这是成功的终点。
Failed:事务执行过程中遇到错误(约束违例、磁盘满、死锁),无法继续。
Aborted:事务已经回滚,所有修改被撤销。之后可以重试或放弃。
PostgreSQL 中,
ROLLBACK或者遇到无法恢复的错误都会将事务置为 Aborted 状态。在此状态下必须执行ROLLBACK后才能开始新事务。
并行执行带来什么问题?
这是最重要的部分。所有并发控制机制都是为了解决以下问题:
问题 1:脏写(Dirty Write)
两个事务修改同一行,后提交的事务覆盖了先提交的修改。
事务 T1: 事务 T2:
UPDATE SET x=1 UPDATE SET x=2
|
COMMIT (T1 提交了)
|
ROLLBACK? —— x 变回什么?如果 T2 回滚时不知道它覆盖了 T1 的提交,那 x 就回溯到了 T1 提交前的值——T1 的修改丢失了。
实际禁止方式:几乎所有数据库都阻止脏写,因为它的后果最严重。实现上,事务未提交前,其他事务不能修改它已经修改的行(通过行级排他锁)。
问题 2:脏读(Dirty Read)
事务 T1 读到事务 T2 未提交的数据。如果 T2 最终回滚,T1 读到的是"不存在"的数据。
T1: BEGIN;
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 10
T2: BEGIN;
T2: UPDATE vault_items SET quantity = 5 WHERE item_name = 'flame_sword';
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 5 (脏读!)
T2: ROLLBACK; -- 炎之剑库存回到10
T1: 基于"库存=5"做决策——但这个值从来没存在过问题 3:不可重复读(Non-repeatable Read)
事务内两次读同一行,得到不同的值。原因是有其他事务在这之间提交了修改。
T1: BEGIN;
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 10
T2: UPDATE vault_items SET quantity = 5 WHERE item_name = 'flame_sword';
T2: COMMIT;
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 5注意这里 T2 是已提交的——所以这不是脏读。但同一个事务内读到了不同的值。
问题 4:幻读(Phantom Read)
事务内两次执行同一查询,第二次返回了额外的行(或少了行)。原因是有其他事务在这之间插入或删除了满足查询条件的行。
T1: BEGIN;
T1: SELECT * FROM vault_items WHERE quantity > 5; → 返回 2 行
T2: INSERT INTO vault_items VALUES ('shadow_cloak' 8);
T2: COMMIT;
T1: SELECT * FROM vault_items WHERE quantity > 5; → 返回 3 行"幻影行"出现了——同样的条件,多了原来不存在的行。
问题 5:更新丢失(Lost Update)
两个事务读取同一行,各自基于读到的值做计算,然后写回。后写的覆盖先写的。
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 10
T2: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 10
T1: UPDATE vault_items SET quantity = 10 + 1 WHERE item_name = 'flame_sword'; → 11
T2: UPDATE vault_items SET quantity = 10 + 1 WHERE item_name = 'flame_sword'; → 11预期结果是 12,实际是 11。T1 的增量"丢失"了。
调度:怎么安排并发事务
你有一堆并发的事务请求,数据库得决定它们的执行顺序。
串行调度:一个接一个执行,不重叠。总有一个确定的执行顺序。
串行调度 S: T1 → T2 → T3
结果 = 按顺序执行所有操作的结果可串行化调度:多个事务并发执行的结果,等同于某个串行调度的结果。
不可串行化 ≠ 一定出错,但可串行化 = 保证不出并发问题。
冲突可串行化(Conflict Serializability)
这是判断可串行化的最常用方法。
冲突:两个操作满足三条:
- 来自不同事务
- 操作同一数据项
- 至少有一个是写操作
有冲突的两个操作在调度中的顺序决定了最终结果。如果两个调度中冲突操作的相对顺序一致,它们就是冲突等价的。
冲突图(Precedence Graph / Serialization Graph):
- 节点 = 事务
- 边 Ti → Tj = Ti 中的某个操作与 Tj 中的某个操作冲突,且 Ti 的操作先执行
定理:如果冲突图无环,调度就是冲突可串行化的。
例子:
调度 S:
T1: R(A) W(A)
T2: R(A) W(B)
T3: R(A) W(A)找出冲突边:
- T1 写 A → T2 读 A (T1.W(A) 先于 T2.R(A)):T1 → T2
- T1 写 A → T3 读 A (T1.W(A) 先于 T3.R(A)):T1 → T3
- T3 写 A → T2 读 A (T3.R(A) 或 T3.W(A) 和 T2.R(A) 的关系要看具体顺序)
画出图。如果有环 → 不可串行化。如果无环 → 可串行化。
T1
/ \
↓ ↓
T2 ← T3有环(T3→T2 且 T2→T3),所以这个调度不可串行化。
视图可串行化(View Serializability)
冲突可串行化是判断可串行化的一种充分条件,但不是必要的。有些调度冲突图有环,但结果仍然等于某个串行调度。
例子:
调度 S:
T1: W(A) W(B)
T2: W(A)
T3: W(B)冲突图:
- T1 写 A → T2 写 A:T1 → T2
- T1 写 B → T3 写 B:T1 → T3
无环,所以冲突可串行化——没问题。
但有些情况冲突图有环,实际上却是视图可串行化的。比如"盲写"(blind write,写操作不依赖读的值)的场景。视图等价比冲突等价更宽松。
视图等价的条件:
- 两个调度中,每个数据项的初始读取者相同
- 每个数据项的最后写入者相同
- 写-读依赖关系相同
定理:每个冲突可串行化的调度都是视图可串行化的。反之不成立。
实际数据库中几乎都使用冲突可串行化作为判定标准,因为它的判定是多项式时间(建图判环),而视图可串行化的判定是 NP 完全问题。
PostgreSQL 实战:事务
启动一个 PostgreSQL 数据库,创建一个表:
CREATE TABLE vault_items (
item_name TEXT PRIMARY KEY
quantity INTEGER NOT NULL
);
INSERT INTO vault_items VALUES ('flame_sword' 10) ('frost_staff' 5);事务 1:移库成功
BEGIN; -- 开始事务
UPDATE vault_items SET quantity = quantity - 1 WHERE item_name = 'flame_sword';
UPDATE vault_items SET quantity = quantity + 1 WHERE item_name = 'frost_staff';
COMMIT; -- 提交验证:
SELECT * FROM vault_items;
item_name | quantity
---------------+----------
flame_sword | 9
frost_staff | 6事务 2:回滚
BEGIN;
UPDATE vault_items SET quantity = quantity - 3 WHERE item_name = 'flame_sword';
-- 发现不对,不应该出库这么多
ROLLBACK;
-- 库存恢复事务 3:模拟脏读
在 PostgreSQL 默认隔离级别(Read Committed)下测试脏读:
-- 终端 1(T1)
BEGIN;
UPDATE vault_items SET quantity = 0 WHERE item_name = 'flame_sword';
-- 终端 2(T2)
BEGIN;
-- 此时 T1 事务未提交
SELECT quantity FROM vault_items WHERE item_name = 'flame_sword';
-- PostgreSQL 的 Read Committed 会阻止脏读,返回 9(修改前的值)事务 4:Savepoint
PostgreSQL 还支持事务内的保存点——比如移库中途发现货架满了,可以回滚到之前的某个步骤:
BEGIN;
UPDATE vault_items SET quantity = quantity - 1 WHERE item_name = 'flame_sword';
SAVEPOINT after_withdraw;
UPDATE vault_items SET quantity = quantity + 1 WHERE item_name = 'frost_staff';
-- 发现冰霜法杖货架已满,回滚到保存点
ROLLBACK TO SAVEPOINT after_withdraw;
-- 炎之剑的 -1 保留,冰霜法杖的 +1 回滚
-- 可以做其他操作
COMMIT;常见陷阱
陷阱 1:把 ACID 的一致性当成"数据库保证一切正确"
一致性需要应用层配合。数据库保证的是"如果事务开始前一致,结束后一定一致",但它不知道什么是"一致"——这是你通过约束和业务逻辑定义的。
陷阱 2:认为 ROLLBACK 可以自动解决所有问题
某些错误(如 SQL 语法错误、类型错误)会导致事务自动进入 Aborted 状态,但不会自动回滚。你必须执行 ROLLBACK 清除它。在 PostgreSQL 中:
BEGIN;
SELECT 1/0; -- 除零错误
SELECT * FROM vault_items; -- 错误:当前事务已放弃,命令被忽略
ROLLBACK; -- 必须显式回滚陷阱 3:混淆事务和块
一个 BEGIN...COMMIT 块不是魔法——里面每条 SQL 的执行时间决定了事务的实际持续时间。在交互式客户端里,让事务在 BEGIN 和 COMMIT 之间长时间等待用户输入,是最糟糕的模式。这期间持有的锁会阻塞其他事务。
陷阱 4:把"可串行化"当成最高隔离一定会避免所有问题
即使是 Serializable 隔离级别,仍然可能有串行化失败(serialization failure)——数据库判定当前调度不可串行化,强制回滚一个事务。这时应用必须重试。
通关挑战
动手试试
- 事务回滚观察
- 启动一个
BEGIN,插入一行数据,执行ROLLBACK。验证数据没有写入。 - 启动
BEGIN,插入一行,COMMIT。验证数据持久化。
- 事务隔离观察
- 两个终端打开。T1
BEGIN且更新一行但不提交。T2 在同一行尝试SELECT和UPDATE。分别记录 T2 的观察。
- 冲突图练习
- 给定以下调度,画出冲突图,判断是否可串行化:
T1: R(A) R(B) W(B)
T2: W(A) R(B) W(B)
T3: R(A)验收标准
- 能用自己的话解释 ACID 四个字母分别代表什么保证
- 能画出事务状态机图
- 能区分脏读、不可重复读、幻读、更新丢失
- 能给任意调度画冲突图并判断是否冲突可串行化
- 能在 PostgreSQL 中使用 BEGIN/COMMIT/ROLLBACK/SAVEPOINT
常见卡点
- 脏读 vs 不可重复读的区别:脏读是读到未提交的数据,不可重复读是读到已提交的更新。脏读出现在 T2 回滚前,不可重复读出现在 T2 提交后。
- 冲突图的边方向:Ti → Tj 表示 Ti 中的操作先于 Tj 中的冲突操作执行。判环用拓扑排序或 DFS。
- Savepoint 后 COMMIT 的行为:回滚到保存点之后,事务仍然活跃,可以继续操作。最终 COMMIT 提交所有未回滚的修改。
现在不需要理解
- PostgreSQL 和 MySQL 在隔离级别上的具体差异——第 12 章
- 两阶段锁和 MVCC 的实现细节——第 12-13 章
- 分布式事务(2PC、TCC、Saga)——分布式系统卷
旅人笔记
事务不是一个操作集合——它是一个保证集合。ACID 给出了原子性、一致性、隔离性、持久性的契约。并发正确性等价于"可串行化",冲突可串行化通过冲突图判环判定——这是并发控制的理论基石。
→ 下一站预告
你知道了事务的基本理论和并发问题。但实际中,"完全串行化"太慢了。下一章你会看到,数据库在"正确性"和"性能"之间给出了几种折中方案——隔离级别。更精彩的是,你还能看到 MVCC 如何用"多版本"的方式做到读不阻塞写。