事务让相关数据库变更一起提交,或者一起放弃。如果一次操作要求扣减一个账户、增加另一个账户,并写入流水,那么这三步就应属于同一个决定。PostgreSQL 通过 BEGIN、COMMIT 和 ROLLBACK 提供这个边界,但应用仍须把正确的语句放进去。原子执行不会自动发现漏掉的入账,也不会替业务补出一个没有编写的校验。事务保护的是被明确纳入其中的工作,而不是开发者脑中尚未落实的意图。
决定一个工作单元包含什么
普通 psql 在没有显式事务块时使用自动提交,每条语句各自处于一个事务里。因此,扣款可能已经成功提交,之后的流水插入才失败。显式分组改变了失败边界:其他事务看不到这组操作尚未提交的表数据中间状态;回滚直接丢弃其事务性变更,而不是依赖应用事后再发几条补偿更新来还原。
原子性与隔离性、持久性并不是同一件事。如果隔离级别或加锁协议不足,一组完整提交的操作仍可能基于错误的并发判断。数据库确认提交后的持久保障,也受到实际持久化配置影响。仅在代码里找到 BEGIN,还不能回答这些问题。同样,PostgreSQL 的每条语句本来就运行在事务中,读取也不例外;显式读事务可以用于重复读取快照或事务局部设置,不能一概视为无用开销。
坏掉的样子
PostgreSQL 中未处理的 SQL 错误通常会让显式事务块进入失败状态。客户端必须整体回滚,或者回滚到适用的早期保存点,才能恢复正常执行。关闭客户端的遇错停止功能,并不会修复服务器端已经失败的事务。如果把这种连接直接还给连接池,下一个请求可能接着遇到失败状态,而不是从干净边界开始。
服务器明确拒绝提交,与提交确认在网络中丢失,是不同证据。如果连接在等待 COMMIT 回复时断开,客户端可能不知道数据库是否已先完成提交。盲目重发支付可能导致重复操作。持久的操作标识和结果核对用于处理这种不确定性;普通回滚语义不能证明一个暂时无法联系的服务器究竟完成了什么。不能把所有客户端报错都解释成“肯定什么也没发生”。
快速验证
在可丢弃的 PostgreSQL 18 数据库中打开新的 psql -X -v ON_ERROR_STOP=1 会话,运行下面代码。临时表让这些账户编号只属于本会话。代码仅在两个刻意制造的失败周围关闭遇错停止,以便观察 SQLSTATE,随后立即重新开启。
\set ON_ERROR_STOP on
CREATE TEMP TABLE accounts (id integer PRIMARY KEY,balance integer CHECK(balance>=0));
CREATE TEMP TABLE ledger (id integer PRIMARY KEY,amount integer CHECK(amount>0));
INSERT INTO accounts VALUES (1,100),(2,100);
BEGIN;
UPDATE accounts SET balance=balance-20 WHERE id=1;
UPDATE accounts SET balance=balance+20 WHERE id=2;
INSERT INTO ledger VALUES (1,20);
COMMIT;
SELECT id,balance FROM accounts ORDER BY id;
BEGIN;
UPDATE accounts SET balance=balance-30 WHERE id=1;
\set ON_ERROR_STOP off
INSERT INTO ledger VALUES (1,30);
\echo duplicate_error :SQLSTATE
SELECT balance FROM accounts WHERE id=1;
\echo aborted_error :SQLSTATE
\set ON_ERROR_STOP on
ROLLBACK;
SELECT id,balance FROM accounts ORDER BY id;
SELECT count(*) AS ledger_rows FROM ledger;
DO $$ BEGIN
IF (SELECT array_agg(balance ORDER BY id) FROM accounts) IS DISTINCT FROM ARRAY[80,120]
OR (SELECT count(*) FROM ledger) <> 1
OR (SELECT amount FROM ledger WHERE id=1) IS DISTINCT FROM 20 THEN
RAISE EXCEPTION 'partial transfer survived';
END IF;
END $$;
DROP TABLE ledger,accounts;
第一个事务提交后,余额是八十和一百二十,流水表有一行。第二个事务又扣减三十,接着尝试复用流水编号一。PostgreSQL 18.6 返回唯一性错误 23505;紧跟着的普通读取返回 25P02,证明即使客户端继续发送命令,服务器中的事务也已经处于失败状态。这个阶段看不到正常读取结果,不能误以为失败只影响那一条插入。
回滚后,余额仍然是八十和一百二十,流水仍只有一行,第二次扣款没有保留下来。断言检查整个相关状态,而不是仅凭看见错误就认定回滚一定正确。最后显式删除临时夹具;关闭会话也会移除这些临时表。实验把成功提交、数据库拒绝和清理后的结果分开展示,便于判断每一步实际发生了什么。
这是一项受控的原子性实验,不是完整转账服务。真实操作还应验证目标账户存在、影响行数符合预期、金额满足业务要求,并保护并发决策。否则事务完全可能把一组本来就错误的变更原子提交。原子性不能替代输入校验、行数检查或隔离协议,但可以让这些已经写清楚的步骤共享一个可靠提交边界。
让恢复遵守同一份契约
保存点允许撤销指定范围的工作,同时保留更早的事务性变更。只有部分成功本来就是允许结果时,这才符合业务意图。对于本例,吞掉必需的流水失败后继续提交扣款,就违反了操作契约。数据库过程块捕获异常时,也可能形成更小的回滚范围,因此需要沿真正的异常处理路径理解结果,不能只看最外层有没有事务。
序列已经发出的号码,以及发送邮件等外部动作,不会随着表写入回滚而撤销。可能重试的尝试应避免直接携带不可逆外部效果,或者使用持久协议进行协调。短而明确的事务让提交决定更容易理解;经过检查的结果和清楚的恢复路径,则确保这个决定仍然表达应用真正需要的工作。
来源
- PostgreSQL 18:事务,原子分组与回滚边界,已核验 2026-09-10。
- PostgreSQL 18:事务隔离,可见性与序列的非事务性行为,已核验 2026-09-10。
- PostgreSQL 18:SAVEPOINT,事务内有意保留部分工作的回滚,已核验 2026-09-10。