5.3 事务边界与失败语义
事务不是给一组 SQL 加上 BEGIN 和 COMMIT 的排版形式,而是数据库正确性的最小失败与可见单位。应用必须知道事务何时开始、一个 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_timeout | transaction 内无客户端 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=t208 不是该 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 后断线,可能发生两种都合理的现实:
- server 尚未 commit,事务因连接消失而 rollback;
- 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_failure 和 40P01 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 与可见性 · 返回本章目录 · 下一节:锁与等待 · 查看全书目录 · 查看索引中心