# 事务边界与失败语义

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

---

事务不是给一组 SQL 加上 `BEGIN` 和 `COMMIT` 的排版形式，而是数据库正确性的最小失败与可见单位。应用必须知道事务何时开始、一个 error 会把它变成什么状态、哪类失败可以重试，以及数据库边界之外的动作为什么不会自动回滚。

## 5.3.1 自动提交、显式事务与中止状态 {#item-5-3-1}

PostgreSQL 中每条语句都在事务里。若客户端没有显式 `BEGIN`，服务器会为单条语句建立隐式 transaction 并在成功后提交；psql 把这种行为暴露为 `AUTOCOMMIT=on`。显式 transaction block 则把多条语句放进一个原子边界：

```sql
BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;
```

“autocommit”常由客户端/driver 再包装一层。某些 driver 默认自动提交，某些 ORM 打开 request-scoped transaction，连接池还可能在归还连接时 rollback。排查边界时不能只看业务代码有没有 `BEGIN`，还要检查 driver 配置、middleware 和 server 端的 `xact_start`。

### 一条错误会让显式事务进入 failed state

本章脚本故意执行：

```sql
BEGIN;
SELECT 1 / 0;        -- 22012 division_by_zero
SELECT 'continue';   -- 25P02 in_failed_sql_transaction
ROLLBACK;
```

第一个 error 不是只撤销一个函数调用然后“照常继续”。当前 transaction block 被标记 aborted，后续普通 SQL 收到 `25P02`，直到：

- `ROLLBACK` 结束整个事务；或
- 之前建立过 savepoint，使用 `ROLLBACK TO SAVEPOINT` 回到可用 subtransaction 边界。

`25P02` 通常是二次错误。真正根因是同一连接更早的第一个 SQLSTATE；日志与 tracing 应保留 first error，而不是把最后大量 `current transaction is aborted` 当根因。

应用端正确骨架是：

```text
checkout:
  acquire connection
  BEGIN
  try:
      perform all database work
      COMMIT
  catch:
      ROLLBACK
      classify original SQLSTATE
  finally:
      return a clean connection
```

若 connection 在 failed transaction 状态被放回 pool，下一位请求会接到 `25P02`；若连接停在 `idle in transaction`，它还可能长期持锁、持 snapshot、阻碍 VACUUM。连接池归还前的 rollback 是卫生线，不代替业务代码正确结束事务。

### timeout 也属于失败语义

至少区分：

| 设置 | 限制什么 | 触发后要做什么 |
|---|---|---|
| `statement_timeout` | 单条 statement 总时长 | 当前 statement 被取消；显式事务通常进入 failed state |
| `lock_timeout` | 等待 lock 的时长 | 只在等待锁期间计时；仍需恢复/结束事务 |
| `idle_in_transaction_session_timeout` | transaction 内无客户端 SQL 的空闲 | server 终止 session，保护锁与 xmin horizon |
| 客户端 request timeout | 调用方等待预算 | 不保证 server 已停止，必须有 cancel/幂等/结果确认策略 |

不要在 global 层随意把 `statement_timeout` 设成一个小值并宣称“解决慢 SQL”。它是预算控制，不会告诉你时间消耗在哪，也不会自动让被取消事务恢复可用。

## 5.3.2 原子性、持久性与 WAL {#item-5-3-2}

原子性回答“事务的数据库效果是全部可见或全部不可见”；持久性回答“服务器确认 commit 后，在承诺的故障模型下能否恢复”。MVCC、transaction status 和 WAL 各自承担不同责任，不能缩写成“PostgreSQL 会写日志所以 ACID”。

### WAL 是 redo 前提，不是业务事件流

Write-Ahead Logging 的核心顺序是：描述数据页变更的 WAL 必须先达到要求的持久位置，相关 dirty data page 才能安全落盘。这样 crash recovery 可以从 checkpoint 之后重放 WAL，把未及时写回的数据页恢复到一致状态。

它带来几个边界：

- WAL 记录面向物理/内部恢复，不是稳定的订单事件 API；
- rollback 不是从 WAL 中“删除刚才几条记录”；
- heap/index 写入可以先产生 WAL，最终 transaction 却 aborted；
- LSN 是实例某条 timeline 上的位置，不是 transaction ID 或业务 offset；
- `pg_wal_lsn_diff` 观察的是两个全局位置之差，窗口中可能混入其他 backend 的 WAL。

[`wal-rollback.sql`](/labs/ch05/wal-rollback.sql)在一个事务中改写订单指纹，取得 write XID 后 rollback。一次实测为：

```text
write_xid=961
wal_lsn_before=0/2E96560
wal_lsn_after=0/2E96630
wal_bytes_observed=208
wal_insert_advanced=t
state_restored=t
```

`208` 不是该 UPDATE 的可移植精确尺寸；full-page image、checkpoint、HOT、版本和并发都会改变差值。稳定结论只有：本窗口 WAL insert location 前进，而最终业务值恢复。这反证“LSN 前进 = 事务提交”。

### commit acknowledgement 有配置前提

默认 `synchronous_commit=on` 时，普通本地提交等待 commit WAL flush 到 durable storage 后再向客户端成功；若配置了 synchronous standbys，具体等待还受 `synchronous_commit` 模式和 `synchronous_standby_names` 影响。

`synchronous_commit=off` 允许服务器在 WAL durable flush 之前返回成功。crash 窗口内最近事务可能丢失，但数据库通过已 flush WAL 恢复到一致状态；它改变 durability guarantee，不把已成功返回的事务“半提交”成损坏行。`fsync=off` 风险更大，可能导致 crash 后不可恢复的不一致/损坏，不能与 async commit 混为一谈。

因此一次关键业务提交至少要记录：

```sql
SHOW synchronous_commit;
SHOW fsync;
SHOW full_page_writes;
```

并把“本地 durable”“同步副本 durable/已应用”“归档可用于 PITR”分成三个承诺。第 19、20 章再把这些承诺落实到 Pigsty HA 和 backup topology。

### commit 不等于调用方一定知道结果

客户端在发送 COMMIT 后断线，可能发生两种都合理的现实：

1. server 尚未 commit，事务因连接消失而 rollback；
2. server 已 commit，但成功响应没到客户端。

调用方只知道 outcome ambiguous，不能盲目重放非幂等订单。ch03/ch04 已为 order/payment 建立 request/idempotency key，就是为这种边界提供查询与重试依据。事务原子性保护数据库内部状态，不保证网络把最终答案可靠送达一次且仅一次。

## 5.3.3 保存点、重试边界与外部副作用 {#item-5-3-3}

savepoint 把 transaction 切成 subtransaction 边界：

```sql
BEGIN;
SAVEPOINT risky_statement;

-- 可能触发一个允许恢复的数据库错误
UPDATE ...;

ROLLBACK TO SAVEPOINT risky_statement;
-- transaction 再次可用
...
COMMIT;
```

本章实验先用非法 `currency_code='USD'` 触发 `23514`，再 `ROLLBACK TO`，随后成功执行另一条 UPDATE，最后整体 rollback。它证明的是“局部数据库失败可在预设边界恢复”，不是鼓励捕获所有错误后继续提交。

### 只恢复预期、局部、已理解的失败

适合 savepoint 的例子是：一个明确可选的子操作、已知 constraint exception、恢复后主事务不变量仍成立。以下情况通常应 rollback 整体 transaction：

- 连接丢失、admin shutdown、crash recovery；
- deadlock victim (`40P01`)；
- serialization failure (`40001`)；
- statement/query cancel 后应用不清楚执行进度；
- 违反关键业务约束，后续操作依赖失败结果；
- 任意未知 SQLSTATE。

大量逐行 savepoint 还会制造 subtransaction 管理开销；bulk ingest 更适合 staging、set-based validation、`ON CONFLICT` 的明确策略或分批 transaction。

锁也遵循 savepoint 边界：在 savepoint 之后取得的 table/row lock，回滚到该 savepoint 时释放。savepoint 之前取得的锁仍持有到 outer transaction 结束。看到一次 `ROLLBACK TO` 不能假定“这个会话已经不持锁”。

### 重试必须覆盖整个正确性单元

`40001 serialization_failure` 和 `40P01 deadlock_detected` 的安全策略通常是：

```text
rollback whole transaction
discard values read inside it
apply bounded backoff + jitter
start from BEGIN with fresh snapshot
```

只重放最后一条 UPDATE 会复用旧决策和旧读取，不再是同一个正确性证明。重试还必须有总 deadline、最大次数、metrics，并使用 idempotency key 处理 ambiguous commit。constraint violation、syntax error、权限错误通常是确定性失败，盲目 retry 只会制造负载。

### 数据库 rollback 不会撤销外部世界

下面的顺序有危险：

```text
BEGIN
charge payment provider       ← 外部副作用已发生
INSERT payment row
COMMIT fails
```

数据库无法“回滚 HTTP”。反过来先 COMMIT 再发消息，也可能 commit 成功而进程在发送前崩溃。常见闭环是：

- 外部接口使用稳定 idempotency key；
- 数据库事务同时写业务事实与 outbox row；
- 独立 publisher 至少一次发送 outbox；
- consumer 用 inbox/dedup key 幂等处理；
- 对 ambiguous result 先查询权威状态，不直接重复副作用。

本书在 ch12 把这套合同接入后端服务，在 ch13 讨论哪些逻辑适合留在数据库。当前最重要的分界是：savepoint 和 transaction 只控制同一 PostgreSQL transaction 内的效果；文件、邮件、支付、Kafka 和另一个数据库都需要额外协议。

---

[上一节：MVCC 与可见性](../02/) · [返回本章目录](../) · [下一节：锁与等待](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
