跳到内容

第 5 卷 / 第 11 章 / 第 1 课

11.1 事务边界、ACID 与失败语义 ​


元数据卡

  • 前置:第 4 章 WAL 与恢复、第 8 章执行器
  • 关键词:事务边界、ACID、约束、WAL、提交、回滚、Savepoint
  • 代码语言:SQL(PostgreSQL)

事务规定一段状态变换如何提交、失败和恢复。账户转账中,两条 UPDATE 都执行过还不够:扣款与入账必须一起生效,总额才不会凭空变化。数据库可以检查余额,至于转给谁,仍得应用自己记清楚。

以下实验适用于 PostgreSQL 16+,在空的练习数据库中按顺序执行。只有保存点示例故意触发一次错误;交互式 psql 在报错后继续输入即可,使用 ON_ERROR_STOP 的脚本需把这一步单独处理。先建立非负余额约束:

sql
CREATE TABLE account (
  account_id bigint PRIMARY KEY,
  balance numeric(18, 2) NOT NULL,
  CONSTRAINT account_balance_nonnegative CHECK (balance >= 0)
);

INSERT INTO account (account_id, balance)
VALUES (1, 500.00), (2, 200.00);

事务边界决定什么一起成功 ​

一次转账包含扣款和入账。若两条语句分别自动提交,第一条成功、第二条失败时,系统便停在业务不允许的中间状态。

sql
BEGIN;

UPDATE account
SET balance = balance - 100.00
WHERE account_id = 1;

UPDATE account
SET balance = balance + 100.00
WHERE account_id = 2;

COMMIT;

此时余额应为 (1, 400.00)、(2, 300.00)。这个最小示例假设两账户都存在,后面会把缺失账户与余额不足的检查补到执行代码中。

真正的事务边界还应覆盖转账记录、幂等键等必须同步生效的数据,但不宜包住等待用户输入、网络调用或长时间计算。事务越长,快照、锁、旧版本和连接占用得越久。

ACID 是四类不同的责任 ​

Atomicity:只提交完整状态变换 ​

事务中止后,其修改不能作为已提交事实保留下来;显式 ROLLBACK 也应得到同样结果。某些语句错误可以通过 savepoint 恢复,并不意味着整个事务都必须中止。实现并不统一:InnoDB 使用 undo 记录支持回滚;PostgreSQL 依靠元组版本和事务提交状态判断可见性,崩溃恢复主要重放 WAL,并不会像传统 undo/redo 教科书模型那样逐条“撤销磁盘上的未提交值”。

原子性并不负责撤回数据库之外的副作用。若事务中已经发出邮件、扣了第三方余额或写入另一个独立系统,本地 ROLLBACK 无法使它们消失。跨边界一致性需要 outbox、幂等消费、补偿或分布式事务协议。

Consistency:先定义不变量,再谈保持 ​

ACID 中的一致性不是“副本一致性”,也不是数据库能够猜出全部业务规则。它表示事务应把一个满足不变量的状态变成另一个满足不变量的状态。

数据库擅长强制执行可声明的规则:

  • PRIMARY KEY、UNIQUE:身份与唯一性;
  • NOT NULL、CHECK:单行或可表达的域约束;
  • FOREIGN KEY:引用关系;
  • 合适的隔离级别或显式锁:跨行、跨查询的不变量。

应用仍要负责写出正确的状态变换,并检查影响行数。例如扣款应把余额条件放进同一条原子更新,而不是先读后写:

sql
BEGIN;
UPDATE account
SET balance = balance - 100.00
WHERE account_id = 1
  AND balance >= 100.00;
ROLLBACK;

这里用 ROLLBACK 保留练习余额;实际转账必须检查 UPDATE 的影响行数。影响 0 行意味着账户不存在或余额不足,应用必须中止后续入账。数据库不会自动理解这两种业务含义。

Isolation:约束并发执行可以暴露什么 ​

隔离性回答的是:多个事务交错执行时,哪些中间状态和并发异常可以出现。它不是“所有事务完全串行”,也不是一个开关。SQL 隔离级别给出不同保证,数据库还可能用锁、MVCC、SSI 等不同机制实现它们。

即使普通 SELECT 使用快照而不阻塞写入,两个写事务仍可能争夺同一行;可串行化事务也可能被数据库主动中止。正确的应用必须同时处理等待、死锁和可重试错误。

Durability:提交确认与故障模型绑定 ​

持久性承诺的是:在数据库所声明的故障模型中,已经确认提交的事务不会因崩溃而消失。常见路径是先把足够的日志持久化,再允许脏数据页延后写回。

这个承诺取决于配置和基础设施:

  • PostgreSQL 将 synchronous_commit 设为 off 时,客户端可能先收到成功,随后发生数据库或操作系统崩溃则可能丢失尚未刷盘的最近事务;数据库一致性仍可恢复,但“确认过的提交绝不丢”被放宽了。
  • 复制系统中的“持久”还要说明是仅主库落盘,还是同步副本也已确认。
  • 存储设备若谎报刷盘完成,数据库无法凭软件配置补足物理保证。

所以不要只写“开启 WAL 就有持久性”,应记录提交策略、复制确认条件和可接受的数据丢失窗口。

失败不是只有 COMMIT 与 ROLLBACK ​

PostgreSQL 中,事务块内一条语句报错后,事务通常进入 failed 状态;后续语句不会继续执行,直到 ROLLBACK 或回滚到错误之前建立的保存点。

sql
BEGIN;

SAVEPOINT before_optional_step;
INSERT INTO account (account_id, balance) VALUES (1, 10.00);
-- 主键冲突,事务进入错误状态

ROLLBACK TO SAVEPOINT before_optional_step;
-- 事务重新可用

UPDATE account
SET balance = balance + 10.00
WHERE account_id = 2;

COMMIT;

本段提交后账户 2 为 310.00,账户 1 仍为 400.00。保存点只在原事务内有效。连接结束时,服务端会回滚仍未提交的普通事务;但若客户端在等待 COMMIT 响应时失联,提交可能已经完成,应按业务请求号查证,不能直接视为失败重做。

还要留意不完全服从事务回滚的对象。PostgreSQL sequence 的取号不会因调用方事务回滚而回收,因此自增编号出现空洞是正常现象,不能拿连续性判断是否丢了数据。

一段可检查的转账 ​

下面用 PostgreSQL 的 PL/pgSQL 匿名块实际检查影响行数;任一条件失败都会抛出异常,使扣款不能单独提交。它仍使用固定账户与金额,尚未加入业务请求去重。

sql
BEGIN;
DO $$
DECLARE
  affected bigint;
BEGIN
  PERFORM account_id FROM account
  WHERE account_id IN (1, 2)
  ORDER BY account_id FOR UPDATE;

  UPDATE account SET balance = balance - 100.00
  WHERE account_id = 1 AND balance >= 100.00;
  GET DIAGNOSTICS affected = ROW_COUNT;
  IF affected <> 1 THEN
    RAISE EXCEPTION 'source missing or insufficient balance';
  END IF;

  UPDATE account SET balance = balance + 100.00
  WHERE account_id = 2;
  GET DIAGNOSTICS affected = ROW_COUNT;
  IF affected <> 1 THEN
    RAISE EXCEPTION 'destination missing';
  END IF;
END $$;
COMMIT;
SELECT account_id, balance FROM account ORDER BY account_id;

按本页顺序执行后,余额应为 (1, 300.00)、(2, 410.00)。在独立练习事务中去掉收款账户再运行,可观察异常后扣款也不生效;报错后先执行 ROLLBACK,再检查结果。

固定锁顺序降低死锁概率,但不能把重试逻辑删掉。更完整的实现还应有唯一业务请求号,防止客户端超时后重复提交同一笔转账。

本节检查点 ​

  • 能区分数据库内原子性与外部副作用补偿。
  • 能把业务不变量分配给约束、SQL 条件和隔离机制。
  • 能解释 WAL、刷盘策略与持久性承诺之间的关系。
  • 能处理事务错误状态、保存点和影响行数。

参考 ​

下一节把多个事务拆成读写操作,判断一次并发执行究竟等价于哪个串行顺序。

Built with VitePress | Software Systems Atlas