跳至内容

5.3 事务边界与失败语义

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

5.3.1 自动提交、显式事务与中止状态

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

BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;

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

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

本章脚本故意执行:

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 当根因。

应用端正确骨架是:

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_timeouttransaction 内无客户端 SQL 的空闲server 终止 session,保护锁与 xmin horizon
客户端 request timeout调用方等待预算不保证 server 已停止,必须有 cancel/幂等/结果确认策略

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

5.3.2 原子性、持久性与 WAL

原子性回答“事务的数据库效果是全部可见或全部不可见”;持久性回答“服务器确认 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在一个事务中改写订单指纹,取得 write XID 后 rollback。一次实测为:

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 混为一谈。

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

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 保存点、重试边界与外部副作用

savepoint 把 transaction 切成 subtransaction 边界:

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_failure40P01 deadlock_detected 的安全策略通常是:

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 不会撤销外部世界

下面的顺序有危险:

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 与可见性 · 返回本章目录 · 下一节:锁与等待 · 查看全书目录 · 查看索引中心

最后更新于