# 如实看待 ACID

> Postgres 中 ACID 每个字母保证什么、背后的机制、代价、如何关闭，以及它究竟止于哪里。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-9"></span>

我们提交了三行，三次都收到 `COMMIT` 返回，然后杀掉服务器，再把它启动起来，结果那些行不见了。Postgres 的工作方式完全符合配置。一个会话设置 `synchronous_commit = off` 用这些行换来了速度，而这项取舍从头到尾都写在文档里。

ACID 常被当成一枚徽章：数据库“是 ACID 的”，所以数据就是安全的。其实它是四项彼此独立的承诺，每项都由一种具体机制兑现，都有代价，都有可以关闭它的开关，也都有它止步的边界。Postgres 支持的系统中，大多数数据丢失发生在这些边界上，而不是四项承诺的内部。

## 四项承诺，四种机制 {#four-promises-four-mechanisms}

下面这张表概括了整章。后文会逐行证明它。

| 字母 | 由什么保证 | 代价 | 由什么关闭 | 止于 |
|---|---|---|---|---|
| 原子性 | 行版本、`pg_xact` 状态 | 每次中止留下死元组 | 自动提交 | 你的事务边界 |
| 一致性 | 你的约束 | 每次写入都要检查 | 不声明约束 | 跨行规则 |
| 隔离性 | 快照、锁、[SSI](/43-glossary/#ssi) | 等待、重试 | 读已提交 | 你选择的级别 |
| 持久性 | 提交时刷新的 WAL | 每次提交都要刷新 | `synchronous_commit`、`fsync`、会撒谎的磁盘 | 这台机器的磁盘 |

<span id="tbl-9-1"></span>

**Table 9-1.** 四项承诺，四种机制


它们彼此独立地失效，所以我们一次讲一项。

## 原子性：从来没有真正撤销什么 {#atomicity-nothing-is-ever-undone}

原子性意味着事务要么完整发生，要么完全不发生。你可能会想象一份撤销日志：回滚时，数据库把旧值放回去。

Postgres 没有撤销。回滚根本不碰表。

### 回滚会让行留在页上 {#a-rollback-leaves-its-rows-on-the-page}

每个行版本都带有创建它的事务 ID（`xmin`），而它是否计入结果，取决于提交日志 `pg_xact` 对该事务的记录（[更新就是插入](/06-mvcc/#basics-chapter-6)）。它就像支票登记簿：一张被取消的支票不会被擦掉，而是盖上 VOID 印章。

向一个保存账户余额的小型 `credits` 表插入三行，然后回滚。`pageinspect` 仍然能在页上找到这三个元组，逐字节都和插入时写入的一样，而 `pg_xact_status()` 会说它们的事务是 `aborted`。`pg_xact` 中的两个位是唯一把它们藏起来的东西。每个读取它们的查询都会检查这个状态并跳过它们；在清理移除它们之前，它们会一直留在那里。

回滚就是这么回事：翻转一个状态，把残局留给以后处理。这就是为什么回滚一行和回滚一千万行耗时相同。

### 失败的语句什么也没改变，却仍然要付出代价 {#a-failed-statement-changes-nothing-and-still-costs}

原子性也适用于每条语句。给每个账户扣 10 个积分，而最后一行的账户 1000 只有 5 个积分：

```sql
update credits set balance = balance - 10;
```

```text
ERROR:  new row for relation "credits" violates check constraint
        "credits_balance_check"
DETAIL:  Failing row contains (1000, -5).
```

什么都没有改变：999 个余额仍然是 100。但这条更新在碰到第 1000 行之前，已经写入了 999 个新行版本，而它们全都还在表里，成了死元组：

```text
 pages | tuple_count | dead_tuple_count
-------+-------------+------------------
    11 |        1000 |              999
```

表从 6 页增长到 11 页，一条什么也没改变的语句还写入了超过 100,000 字节的 WAL。一次失败的批量更新，其 I/O 成本大致和成功更新相同，并留下相当于整张表数据量的死元组，等待 [清理](/10-vacuum/#basics-chapter-10)。一个在最后一行失败、每五分钟重试一次的批处理任务，就是一台制造膨胀的机器。

很可能失败时，应在写入前先验证（例如使用 `where balance >= 10`，或先执行检查查询）；大规模变更要分批执行，这样失败浪费的是一个批次，而不是整张表。在事务内部，保存点可以在更细的粒度上提供同样的全有或全无效果（[保存点可以把损失控制在局部](/25-concurrency/#savepoints-contain-the-damage)）。

<span id="what-a-rollback-and-a-failed-update-leave-on-the-page" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-what-a-rollback-and-a-failed-update-leave-on-the-page" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：回滚和失败的更新会在页上留下什么
>
> ```sql
> begin;
> insert into credits values (5001, 50), (5002, 50), (5003, 50);
> select pg_current_xact_id();   -- 854733
> rollback;
>
> select lp, t_xmin from heap_page_items(get_raw_page('credits', 5))
> where t_xmin = '854733';
> ```
>
> ```text
>  lp | t_xmin
> ----+--------
>  77 | 854733
>  78 | 854733
>  79 | 854733
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid`
> >
> > ```sql
> > begin;
> > insert into credits values (5001, 50), (5002, 50), (5003, 50);
> > select pg_current_xact_id() as aborted_xid \gset
> > rollback;
> >
> > select lab.prove(
> >   'after rollback, all three row versions are still on the page',
> >   (select count(*) = 3
> >    from heap_page_items(get_raw_page('credits', (pg_relation_size('credits') / 8192 - 1)::int))
> >    where t_xmin::text = :'aborted_xid'));
> >
> > select lab.prove(
> >   'pg_xact says their transaction aborted',
> >   pg_xact_status(:'aborted_xid') = 'aborted');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)
>
> 手工运行失败的更新时，死元组数量可能略低于 999：之后读取一个几乎填满的页时，可能会提前剪枝掉一些已中止的行版本（[页内剪枝要等到页接近填满](/07-hot/#pruning-waits-for-a-nearly-full-page)）。实验会保持一个更早的快照打开，从而让数量精确。第一次读取已中止事务的元组的查询还会把判定缓存为提示位 `HEAP_XMIN_INVALID`，后续读取者就会跳过 `pg_xact` 查询（[读取也可能写入](/06-mvcc/#a-read-can-write)）。
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid`
> >
> > ```sql
> > -- s1 holds a snapshot older than the failed UPDATE, so nothing prunes early.
> > call lab.on('s1', 'begin isolation level repeatable read');
> > select x as _ from dblink('s1', 'select 1') as t(x int) \gset
> >
> > select lab.prove(
> >   'the failed UPDATE left 999 dead row versions behind',
> >   (select dead_tuple_count = 999 from pgstattuple('credits')));
> >
> > select lab.prove(
> >   'the failed UPDATE grew the table from 6 to 11 pages',
> >   :pages_before = 6 and pg_relation_size('credits') / 8192 = 11);
> >
> > select lab.prove(
> >   'the failed UPDATE still wrote over 100,000 bytes of WAL',
> >   lab.my_wal() - :wal_before > 100000);
> >
> > select lab.prove(
> >   'no query sees them',
> >   (select count(*) = 0 from credits where account_id > 5000));
> > select lab.prove(
> >   'the charge failed on its last row, and all 999 other rows are unchanged',
> >   (select count(*) filter (where balance = 100) = 999 from credits));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)

### 提交只是一个很小的记录 {#commit-is-one-small-record}

那么，是什么让一个事务变成已提交？是 [WAL 记录很小，页镜像却不是](/08-wal/#a-wal-record-is-small-a-page-image-is-not)中的 34 字节 `COMMIT` 记录：写入它，让 WAL 刷过它，在 `pg_xact` 中把事务标记为已提交，然后让它变得可见。刷新返回的那一刻，就是提交发生的时刻。刷新后一微秒服务器崩溃，恢复会重放这条记录，提交仍然成立；早一微秒，事务就从未发生过（[恢复只做重做](/08-wal/#recovery-only-redoes)）。回滚的 `ABORT` 记录甚至不会刷新：没有提交记录的事务本来就会被视为已中止。

## 一致性是由你写出的部分 {#consistency-is-the-part-you-write}

C 是四个字母中最特殊的一个。原子性、隔离性和持久性是数据库负责提供的东西。一致性意味着“事务把数据从一个有效状态带到另一个有效状态”，而“有效”由你定义：`not null`、`check`、`unique`、外键和排他约束（[有目的地选择约束](/14-relationships/#basics-chapter-14)是它们的归宿）。Postgres 只会严格执行你声明的规则，不会多执行一条。

### 约束按语句触发，或在提交时触发 {#constraints-fire-per-statement-or-at-commit}

Postgres 在写入每一行时检查大多数约束，并在每条语句结束时检查外键。声明为 `deferrable initially deferred` 的唯一约束、主键、外键或排他约束会改为等到 `COMMIT` 时才检查，因此一个事务可以先插入发票明细，再插入发票。如果发票始终没有出现，`COMMIT` 就会失败。（`check` 和 `not null` 不能延迟检查。）

所以 **`COMMIT` 可能失败**。延迟约束、串行化失败和连接丢失都可能在这里暴露；而在之前已经出现过任何错误后，`COMMIT` 会悄悄返回 `ROLLBACK`（[一个错误会中止整个事务](/25-concurrency/#one-error-aborts-the-whole-transaction)）。忽略 `commit` 返回结果的代码会告诉用户数据已经保存，实际却没有。

<span id="a-commit-that-fails" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-a-commit-that-fails" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：一次失败的 COMMIT
>
> ```sql
> create table invoice_lines (
>   invoice_id bigint not null
>     references invoices (id) deferrable initially deferred,
>   amount     int not null
> );
>
> begin;
> insert into invoice_lines values (777, 10);   -- no invoice 777: fine, for now
> commit;
> ```
>
> ```text
> INSERT 0 1
> ERROR:  insert or update on table "invoice_lines" violates foreign key
>         constraint "invoice_lines_invoice_id_fkey"
> DETAIL:  Key (invoice_id)=(777) is not present in table "invoices".
> ```
>
> 插入成功了；`COMMIT` 失败，什么也没有保留下来。在提交前插入发票 777，同一个事务就能顺利提交。

### 跨行规则对约束不可见 {#rules-across-rows-are-invisible-to-constraints}

大多数真实业务规则都跨越多行：“免费账户最多有 3 个席位”“借方等于贷方”。`check` 约束只能看到正在写入的那一行，所以通常下一步是写一个触发器，统计该账户的席位数，在达到 3 个时抛出异常。

一次只处理一个请求时，它确实有效。现在，两个请求同时给一个已有 2 个席位的账户添加席位。因为另一个插入还没有提交，两个触发器各自看到的都是 2。两边都提交后，账户有了 4 个席位，而触发器从未抛出异常。

代码没有错。**一个会读取其他行的规则，其保证强度不会超过所用隔离级别的保证。** 有三种修复方式，按强度从高到低排列：

1. **把它变成约束。**“不能有两行冲突”可以用排他约束表达（[排他约束让重叠变得不可能](/14-relationships/#an-exclusion-constraint-makes-overlaps-impossible)）；数量上限也可以改成约束：用 `seat_number` 表示席位编号，用 `check (seat_number between 1 and 3)` 约束它，再用 `unique (account_id, seat_number)`，就能把席位数封顶为 3。
2. **让写入者在可串行化级别下运行**，这样其中一个会因为可重试错误而中止。
3. **在父行上串行化**：在触发器开头执行 `perform 1 from accounts where id = new.account_id for no key update`。使用 `for no key update`，不要使用 `for update`：`for update` 还会阻塞向任何表插入且引用该账户的行时所进行的外键检查，而且会一直阻塞到触发器所在的事务结束。

<span id="the-racing-trigger-and-why-a-function-in-a-check-constraint-lies" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-the-racing-trigger-and-why-a-function-in-a-check-constraint-lies" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：竞态触发器，以及为什么 CHECK 约束中的函数会欺骗你
>
> 触发器，以及实验用来与它制造竞态的代码：
>
> ```sql
> create function seats_limit() returns trigger language plpgsql as $$
> begin
>   if (select count(*) from seats where account_id = new.account_id) >= 3 then
>     raise exception 'account % already has 3 seats', new.account_id;
>   end if;
>   return new;
> end $$;
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid`
> >
> > ```sql
> > insert into seats values (1, 1), (1, 2);
> >
> > call lab.on('s1', 'begin');
> > call lab.on('s1', 'insert into seats values (1, 3)');
> > call lab.on('s2', 'begin');
> > call lab.on('s2', 'insert into seats values (1, 4)');
> > call lab.on('s1', 'commit');
> > call lab.on('s2', 'commit');
> >
> > select lab.prove(
> >   'two concurrent inserts each counted 2 seats, so the trigger let both in: 4 seats',
> >   (select count(*) = 4 from seats where account_id = 1));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)
>
> CHECK 中的子查询会直接被拒绝（`cannot use subquery in check constraint`）。把它包进函数可以绕过解析器，但随后它会欺骗你。Postgres 假定一行的检查结果不会改变，因此不会在其他行发生变化时重新检查；之后的转储和恢复可能会因为某些行不再通过检查而失败。
>
> 在分布式系统里，“一致性”还指另一件事。在 CAP 和复制语境中，它说的是每个副本是否都返回最新写入；ACID 中的 C 说的是你的规则是否成立。一个副本完全符合 ACID 的一致性，却仍然可能落后数秒（[写入之后，副本可能显示旧值](/38-replication/#after-a-write-a-replica-can-show-the-old-value)）。

## 隔离性是一个旋钮，不是一项保证 {#isolation-is-a-dial-not-a-guarantee}

隔离性决定你能看到并发事务的哪些影响。理想情况是，事务表现得仿佛它们是一个接一个运行的。Postgres 能提供这种效果，但要付出代价，而且默认设置更弱。下面是这个模型；[有目的地选择隔离级别](/25-concurrency/#pick-an-isolation-level-on-purpose)会从实践角度展开。

### 只有可串行化能排除所有异常 {#only-serializable-rules-out-every-anomaly}

**异常**是指某个结果不可能由这些事务的任何串行顺序产生。SQL 标准用其中三种异常定义了各个级别；[Berenson 等人](https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/)在 1995 年指出，这份清单并不完整。Postgres 在各级别下的行为如下：

| 异常 | 读已提交 | 可重复读 | 可串行化 |
|---|---|---|---|
| 脏读：看到未提交的数据 | 从不 | 从不 | 从不 |
| 不可重复读：两次读取之间一行发生变化 | 会 | 从不 | 从不 |
| 幻读：出现新的匹配行 | 会 | 从不 | 从不 |
| 丢失更新：读取、计算、覆盖 | 会，且悄悄发生 | 错误（`40001`） | 错误（`40001`） |
| [写偏差](/43-glossary/#write-skew)：两边读取，各自写入不同的行 | 会 | 会 | 错误（`40001`） |
| 只读异常：读取者看到不可能的状态 | 会 | 会 | 错误（`40001`） |

<span id="tbl-9-2"></span>

**Table 9-2.** 只有可串行化能排除所有异常


- **任何级别都没有脏读。** Postgres 接受 `read uncommitted`，但给你的实际是读已提交。
- **读已提交每条语句取一个快照，可重复读每个事务取一个快照**（[快照会选定一个版本](/06-mvcc/#a-snapshot-picks-one-version)）。请求处理器需要前者。运行五条查询再把结果相加的报表，则需要后者，否则各项合计无法对上。
- **只有可串行化能捕获表格最后两行所说的异常**，做法是让你重试。丢失更新和写偏差在[读已提交会丢失在应用中计算的更新](/25-concurrency/#read-committed-loses-updates-computed-in-the-app)和[可串行化能捕获可重复读漏掉的写偏差](/25-concurrency/#serializable-catches-the-write-skew-repeatable-read-misses)中演示。最后一种异常放在这里讲。

### 只读事务也可能看到不可能的状态 {#even-a-read-only-transaction-can-see-the-impossible}

这是最让人意外的一种异常，因为受害者从头到尾没有写入任何东西。计费按周期运行。一行控制记录保存当前周期；费用都记录在这个周期上；月底有人通过递增周期号来关闭当前周期，报表则汇总已关闭的周期。这里有三个事务：

```text
T1: add a charge         T2: close period 1       T3: report
-----------------------  -----------------------  -----------------------
begin;
insert into charges
  select current_period
  ... (reads period 1)
                         begin;
                         update billing_period
                           set current_period
                             = 2;
                         commit;
                                                  begin;
                                                  select current_period;
                                                    -> 2: 1 is closed
                                                  select count(*),
                                                    sum(amount)
                                                    from charges
                                                    where period = 1;
                                                    -> 3, 600
                                                  commit;
commit;
```

在可重复读级别下，一切都会成功。T3 报告说周期 1 最终有 3 笔费用，总额为 600。随后 T1 把自己的费用提交到周期 1；此时周期 1 已经有 4 笔费用，总额为 700。没有任何串行顺序能产生这个结果：如果 T1 在 T2 之前运行，报表就会包含它的费用；如果在 T2 之后运行，这笔费用就会落到周期 2。

在 `serializable` 下，报表仍然得到相同的 3 笔费用，但 T1 的提交会失败，并提示 `could not serialize access due to read/write dependencies among transactions`。T1 重试，读取到周期 2，费用也就落在了应该在的地方。没有任意两个事务写入同一行，所以只有会追踪读取的隔离级别才能捕获它。

<span id="how-serializable-catches-it-without-blocking-anyone" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-how-serializable-catches-it-without-blocking-anyone" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：可串行化如何在不阻塞任何人的情况下捕获它
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid`
> >
> > ```sql
> > call lab.run_billing('repeatable read');
> > select lab.prove(
> >   'then T1 commits fine',
> >   lab.try_on('s1', 'commit') = 'ok');
> > select lab.prove(
> >   'and the closed period 1 now has 4 charges, 700',
> >   (select count(*) = 4 and sum(amount) = 700 from charges where period = 1));
> >
> > call lab.run_billing('serializable');
> > select lab.prove(
> >   'but now T1''s commit fails with a serialization error',
> >   lab.try_on('s1', 'commit') like 'could not serialize access%');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)
>
> Postgres 用**可串行化快照隔离**（serializable snapshot isolation）实现可串行化级别（[Ports 和 Grittner，2012](https://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)）。每个事务都在一个快照上运行，与可重复读完全相同，同时还会把它读取的内容记录为不阻塞的 `SIReadLock` 条目。当一个事务写入并发事务读取过的东西时，就形成了读写依赖。每个串行化异常都需要一个“枢轴”：它既有传入依赖，又有传出依赖的事务。Postgres 发现这种情况时，会以 SQLSTATE `40001` 中止一个事务。没有人被阻塞，但有人必须重试。这里 T1 就是枢轴：报表在 T1 添加费用前读取了周期 1 的费用，这是进入 T1 的依赖；T1 读取了之后被 T2 改动的周期，这是离开 T1 的依赖。被取消的是 T1，错误消息也明确这样说：`Reason code: Canceled on identification as a pivot, during commit attempt.`
>
> 这套记账比较粗：它可能跟踪整页或整个关系，而不是具体行，因此会有误报；代价确实存在，但通常不算大。对于长时间运行的报表，`begin isolation level serializable read only deferrable` 会等待，直到它能取得一个不可能参与任何异常的快照，然后在不进行追踪的情况下运行，也就不再会因这类串行化异常而失败。

## 持久性就是一次 fsync {#durability-is-an-fsync}

持久性意味着已提交的事务能在崩溃后存活：提交记录在 `COMMIT` 返回前已经到达磁盘。[提交意味着刷新](/08-wal/#commit-means-flush)已经测量过这项代价：每次提交都要一次 fsync；在刷新耗时 7.7 ms 的磁盘上，单个客户端每秒大约只能提交 130 次。现在我们故意把它破坏掉。

### synchronous_commit = off 会丢失一个窗口 {#synchronous_commit--off-loses-a-window}

设置 `synchronous_commit = off` 后，`COMMIT` 会在记录刷新前返回，因此一次崩溃最多可以带走大约 600 ms 内已经确认的提交（[synchronous_commit = off 可能丢失约 600 ms 内的提交](/08-wal/#synchronous_commit--off-can-lose-about-600-ms-of-commits)）。风险是丢失，绝不是损坏。微妙之处在于：事务在持久化之前就已经**可见**。其他会话可以读取它的行，根据这些行采取行动，甚至发送相关邮件，然后崩溃会把这些行抹掉。

### 一次真实崩溃带走了三笔已确认的提交 {#a-real-crash-took-three-acknowledged-commits}

本章实验会启动自己的服务器，这样就能杀掉它。实验把 `wal_writer_delay` 设为 10 秒（最大值），以扩大这个窗口，在 `synchronous_commit = off` 下提交三行，并确认它们的 WAL 仍然只在共享内存中。然后向容器发送 `SIGKILL`：不会有关闭检查点，也不会有最后一次刷新。重启时，恢复会重放到有效 WAL 的末尾，执行 `select count(*) from crash_test` 得到 0。

三笔已经确认的提交，就这样消失了。用默认的 `synchronous_commit = on` 做同样的插入，再执行同样的杀进程操作，重启后会看到三行都回来。把 `wal_writer_delay` 设为 1 秒时，在杀进程前超过 3 秒就已经提交的行也能存活。这个窗口确实存在，而且宽度正如文档所说。

所以，只在事务级别使用它，而且只用于可以丢失的写入。

<span id="a-killed-container-is-not-a-power-cut" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-a-killed-container-is-not-a-power-cut" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：杀掉容器不等于断电
>
> 重启日志说明了一切：
>
> ```text
> LOG:  database system was not properly shut down; automatic recovery in progress
> LOG:  redo starts at 0/1796688
> LOG:  invalid record length at 0/1796758: expected at least 24, got 0
> LOG:  redo done at 0/17966E0 ...
> ```
>
> "got 0" 表示有效 WAL 的末尾，不是错误。实验知道提交仍在内存中，因为 `pg_current_wal_lsn()`（WAL 已写出的距离）落后于 `pg_current_wal_insert_lsn()`（WAL 已生成的距离）。
>
> 杀掉容器杀掉了 Postgres，而宿主机的内核仍在运行。Postgres 已经通过 `write()` 交给操作系统、但还没有用 `fsync()` 强制刷到磁盘的内容，仍然停留在内核的页缓存中，并因此存活下来。这就是为什么实验要检查 WAL 在杀进程前甚至还没有被*写出*。
>
> 真正的断电还会带走页缓存，随后也会带走磁盘自己的缓存。要测试这一点，需要拔电源，或使用可以硬重置的虚拟机，以及为此设计的工具（文档指向 `diskchecker.pl`）。我们的测试展示的是 `COMMIT` 返回与 WAL 离开 Postgres 内存之间的窗口。低于这条线的部分属于操作系统和硬件的职责，而它们也可能在这里失效。
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid.sh`
> >
> > ```bash
> > prove "three async commits returned, and their WAL was still only in shared memory" $lost_ok
> > prove "after SIGKILL and restart, all three async-committed rows are gone" \
> >   holds "select count(*) = 0 from crash_test"
> > prove "after SIGKILL and restart, all three sync-committed rows are there" \
> >   holds "select count(*) = 3 from crash_test where how = 'sync'"
> > prove "after SIGKILL, async commits older than 3 x wal_writer_delay survive" \
> >   holds "select count(*) = 2 from crash_test where id in (4, 5)"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sh)

### fsync = off 和 full_page_writes = off 只能用于 CI {#fsync--off-and-full_page_writes--off-belong-only-in-ci}

两个设置会移除整台服务器的持久性。**`fsync = off`** 会停止让 Postgres 强制把任何东西写入磁盘，电源故障可能让数据文件和 WAL 彼此不一致，造成文档所说的“unrecoverable data corruption”（不可恢复的数据损坏）。**`full_page_writes = off`** 会丢弃用于修复撕裂页的页镜像（[检查点后的第一次变化会记录整页](/08-wal/#the-first-change-after-a-checkpoint-logs-the-whole-page)），只有在不会撕裂 8 KB 写入的存储上才安全，例如 ZFS。两者都会让基准测试看起来很漂亮。把它们用于一次性的 CI 数据库，除此之外不要使用。

### fsync 只有在磁盘诚实的时候才可靠 {#fsync-is-only-as-honest-as-the-drive}

`fsync` 只有在磁盘说“完成”时确实说了实话才有用。许多消费级磁盘在写入自己的易失缓存后就确认写入，而断电会把该缓存抹掉。使用带**掉电保护**的存储，在该卷上运行 `pg_test_fsync`，并对快得超过硬件物理能力的刷新保持怀疑（[时间都花在哪里](/03-latency/#basics-chapter-3)）。

还要保持**数据校验和**开启（从 PostgreSQL 18 起，这是 `initdb` 的默认设置）。磁盘或控制器翻转的一位会变成错误，而不是静默地产生错误的行。校验和能检测损坏；备份负责修复它（[有校验和时，连读取也会写 WAL](/08-wal/#with-checksums-even-a-read-writes-wal)解释了它们的 WAL 成本）。

<span id="when-the-operating-system-lied-and-the-checksum-upgrade-trap" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-when-the-operating-system-lied-and-the-checksum-upgrade-trap" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：操作系统撒了谎，以及校验和升级陷阱
>
> 2018 年，Postgres 开发者发现，在 Linux 上失败的 `fsync()` 可能只报告一次错误，随后却把脏页标记为干净；于是重试会在数据尚未到达磁盘时“成功”。从那以后，Postgres 会把数据文件上的 fsync 失败当作崩溃（`PANIC`），并从 WAL 恢复，而不是相信重试；这一改动随 PostgreSQL 12 发布，并回移到了当时仍受支持的版本（[Fsync Errors](https://wiki.postgresql.org/wiki/Fsync_Errors)）。
>
> `pg_upgrade` 要求校验和设置一致，因此，把一个没有校验和的旧集簇迁移到全新的 18 集簇，需要使用 `initdb --no-data-checksums`，或者先在旧集簇离线时用 `pg_checksums` 启用校验和。校验和只保护数据页，不保护内存或内部结构。

### 这里持久，不等于处处持久 {#durable-here-is-not-durable-everywhere}

前面讲的所有内容都只涉及一台机器的磁盘。使用默认的异步复制时，提交会在主库刷新完成后返回，副本稍后才收到它。在这段时间内[故障切换](/43-glossary/#failover)，用户已经看到成功的提交就会消失，而新的主库仍然完全一致（[异步是默认设置，而且可能丢失提交](/38-replication/#asynchronous-is-the-default-and-it-can-lose-commits)）。同步复制会用每次提交一次网络往返的代价关闭这个窗口（[同步提交的代价是一次往返](/38-replication/#synchronous-commit-costs-a-round-trip)）。

在事故替你决定之前，先用一个数字决定：你最多能承受丢失多少笔已经确认的提交。

## ACID 止于一个数据库中的一个事务 {#acid-stops-at-one-transaction-in-one-database}

上面的每项保证都有同一个边界：一个数据库中的一个事务。你的业务操作却很少尊重这个边界。

### 自动提交会把一个操作拆成两个 {#autocommit-splits-one-operation-into-two}

从账户 1 向账户 2 转移 30 个积分，是一个业务操作。如果不写 `begin`，而是直接写两条语句，那就是两个事务；许多 ORM 默认就是这样工作的：每个 `save()` 都是一次独立提交。若应用在两条语句之间崩溃：

```sql
update credits set balance = balance - 30 where account_id = 1;
-- the process dies here
update credits set balance = balance + 30 where account_id = 2;
```

`sum(balance)` 恰好减少了 30。30 个积分消失了，但没有任何数据库保证被破坏：每条语句都具备原子性、一致性、隔离性和持久性。出问题的是这个*操作*，因为没人告诉数据库它们属于同一件事。把它们包在 `begin` 和 `commit` 中，同样的崩溃会让全部操作回滚。

每个业务操作都要放进一个显式事务，并弄清你的 ORM 默认做什么。事务要保持短小：不要在其中执行 HTTP 调用，也不要在其中等待用户，因为打开的事务会持有锁，并且[拖住清理](/06-mvcc/#long-transactions-hold-everything-back)。

> [!EXAMPLE]- 动手验证 · lab `08-acid`
>
> ```sql
> call lab.on('s1', 'update credits set balance = balance - 30 where account_id = 1');
> -- The app crashes here, before its second statement runs.
> select dblink_disconnect('s1') = 'OK' as gone \gset
> select lab.prove(
>   'two autocommit statements, a crash between them: 30 credits vanished',
>   (select sum(balance) = :total_before - 30 from credits));
>
> -- ... restore, then the same with an explicit transaction:
> call lab.on('s1', 'begin');
> call lab.on('s1', 'update credits set balance = balance - 30 where account_id = 1');
> select dblink_disconnect('s1') = 'OK' as gone \gset
> select lab.prove(
>   'one explicit transaction, the same crash: nothing changed',
>   (select sum(balance) = :total_before from credits)
>   and (select balance = 100 from credits where account_id = 1));
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)

### 两阶段提交很少是答案 {#two-phase-commit-is-rarely-the-answer}

当一个操作跨越两个数据库时，教科书式的答案是两阶段提交。Postgres 支持其中属于自己的一部分（`prepare transaction`，然后 `commit prepared`），但一个已准备事务不属于任何会话：如果协调器死亡，它会在重启后继续存在，持有锁，也拖住清理。这就是 `max_prepared_transactions` 默认值为 0 的原因。而消息代理、支付 API 或邮件服务根本不支持两阶段提交。

<span id="a-prepared-transaction-survives-a-crash-and-so-do-its-locks" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-a-prepared-transaction-survives-a-crash-and-so-do-its-locks" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：已准备事务能在崩溃后存活，它的锁也一样
>
> ```sql
> begin;
> insert into crash_test values (6, 'prepared');
> update crash_test set how = 'locked by a prepared transaction' where id = 1;
> prepare transaction 'transfer-42';
> ```
>
> `SIGKILL` 并重启后，`transfer-42` 仍然位于 `pg_prepared_xacts` 中，它插入的行仍然不可见，对第 1 行的更新仍然会等待它持有的锁。如果启用这一功能，就要对 `pg_prepared_xacts` 中存在超过一分钟的任何行设置告警（[清理无法移除旧快照可能需要的内容](/10-vacuum/#vacuum-cant-remove-what-an-old-snapshot-might-need)）。
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid.sh`
> >
> > ```bash
> > crash
> > prove "after SIGKILL and restart, the prepared transaction is still in pg_prepared_xacts" \
> >   holds "select count(*) = 1 from pg_prepared_xacts where gid = 'transfer-42'"
> > prove "it still holds its row lock: an update of row 1 gives up waiting" \
> >   fails_with "lock timeout" -c "set lock_timeout = '500ms'" -c "update crash_test set how = 'x' where id = 1"
> > prove "it holds back vacuum: a row deleted after it was prepared stays dead, not removed" \
> >   holds "select dead_tuple_count >= 1 from pgstattuple('crash_test')"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sh)

### 把消息和订单放进同一个事务 {#put-the-message-in-the-same-transaction-as-the-order}

这里有一个几乎每个应用都会踩到的情况。下订单会写入订单，并为其他服务发布一条“订单已下达”消息。因为涉及两个系统，无论先做哪一个，两个动作之间的崩溃都会让它们互相矛盾：订单没有任何人知道，或出现了一条关于不存在订单的消息。

解决办法是停止向两个系统写入。把消息写进一张表，并让它和订单处于同一个事务中，再由另一个进程负责投递：

```sql
with o as (
  insert into orders (account_id, total_cents) values (42, 1999)
  returning id, account_id, total_cents
)
insert into outbox (topic, payload)
select 'order.placed',
       jsonb_build_object('order_id', id, 'account_id', account_id,
                          'total_cents', total_cents)
from o;
```

现在，原子性同时覆盖了订单和消息：两者要么一起提交，要么都不提交。中继进程在同一个事务中，用[`for update skip locked`](/43-glossary/#skip-locked)认领一批消息，发布它们，再删除已经发送的消息。这个事务是“不要在事务中进行网络调用”这条原则唯一有意保留的例外：批次要小，并为中继角色设置 `idle_in_transaction_session_timeout`，这样卡住的代理就不会让事务一直打开。

如果中继在发布消息后、`delete` 提交前死亡，下一个中继会再次发送这些消息。这就是**至少一次**投递，代价是消费者要按消息 ID 去重。它总好过“消息有时会丢”。邮件和扣款也可以使用同样的形状：在事务中记录意图，提交后执行动作，并让动作可以安全地重复（[重试整个事务](/25-concurrency/#retry-the-whole-transaction)）。

<span id="two-relays-never-claim-the-same-message" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-9-two-relays-never-claim-the-same-message" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：两个中继不会认领同一条消息
>
> ```sql
> begin;
> select id, topic, payload from outbox
> order by id limit 100
> for update skip locked;
> -- publish each message, and wait for the broker's acknowledgment
> delete from outbox where id = any ($1);   -- the ids you published
> commit;
> ```
>
> `skip locked` 让多个中继可以并行运行，而不会重复认领（[Postgres 队列和业务写入一起提交](/25-concurrency/#a-postgres-queue-commits-with-the-business-write)）。实验中，两个中继同时各请求 5 条消息，会得到 10 条不同的消息。在非常高的吞吐量下，逻辑解码可以直接从 WAL 读取 outbox 插入，而不是轮询；但对大多数应用来说，轮询中继已经足够走很远。
>
> > [!EXAMPLE]- 动手验证 · lab `08-acid`
> >
> > ```sql
> > select lab.prove(
> >   'every order has its message, and a rolled-back order has neither',
> >   (select count(*) = 2 from orders) and (select count(*) = 2 from outbox)
> >   and not exists (select 1 from orders o
> >                   where not exists (select 1 from outbox m
> >                                     where (m.payload ->> 'order_id')::bigint = o.id)));
> >
> > select lab.prove(
> >   'two relays with FOR UPDATE SKIP LOCKED claim disjoint batches of 5',
> >   (select count(*) = 10 and count(distinct id) = 10 from claimed));
> >
> > select lab.prove(
> >   'the relay that died after publishing left its batch in the outbox: it gets sent again',
> >   (select count(*) = 5 from outbox where id in (select id from claimed where relay = 's2')));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/08-acid.sql)

## 本章要点 {#the-short-version}

- 回滚什么也不会撤销：已中止事务的行版本会留在页上，直到清理。失败的语句仍然会写入它处理过的行和 WAL，并留下膨胀。
- 事务在提交记录刷新完成的那一刻提交。检查每一次 `COMMIT` 的结果。
- 一致性只包含你声明的规则。读取其他行的检查存在竞态；应使用约束、可串行化级别，或锁住父行。
- 读未提交实际上是读已提交。只有可串行化能捕获写偏差和只读异常，而且需要重试。
- `synchronous_commit = off` 可能丢失约 600 ms 内已经可见且已确认的提交。按事务使用它，而且只用于可以丢失的数据。
- 生产环境永远不要使用 `fsync = off` 或 `full_page_writes = off`。使用带掉电保护的存储，并保持校验和开启。
- 一次提交只在一台机器上持久。使用异步副本时，故障切换可能丢失已确认的提交；在事故替你做决定前，先选定可以丢多少。
- 每个业务操作都放进一个显式事务。要通知另一个系统，就在同一个事务中写入 outbox 行，再由中继投递。

## 延伸阅读 {#further-reading}

- [Transaction isolation](https://www.postgresql.org/docs/18/transaction-iso.html)，PostgreSQL 文档。各隔离级别的确切保证，包括可串行化级别的只读可延迟模式。
- [Reliability](https://www.postgresql.org/docs/18/wal-reliability.html)，PostgreSQL 文档。持久提交对磁盘、缓存和控制器的要求，以及测试方法。
- [Asynchronous commit](https://www.postgresql.org/docs/18/wal-async-commit.html)，PostgreSQL 文档。`synchronous_commit = off` 的风险窗口，使用开发者自己的表述说明。
- [A Critique of ANSI SQL Isolation Levels](https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/)，Berenson、Bernstein、Gray、Melton、O'Neil 和 O'Neil，SIGMOD 1995。指出标准异常清单不完整，并定义快照隔离的论文。
- [Serializable Snapshot Isolation in PostgreSQL](https://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)，Ports 和 Grittner，PVLDB 2012。由实现者说明 Postgres 的可串行化级别如何工作。[SSI wiki 页面](https://wiki.postgresql.org/wiki/SSI)中有本章改写的批量关账示例。
