PostgreSQL 中,另一个事务修改了同一行,不代表当前事务必然失败。结果取决于隔离级别、语句怎样使用数据,以及竞争者最终提交了什么。把所有碰撞都归成“等一下就好”,会掩盖无效输入,也可能重复使用已经过时的业务决策。需要定义的重试单位,是包含相关读取、判断与写入的一次完整操作。
坏掉的样子
已提交读下,普通查询使用本条语句的快照。更新遇到竞争写者时,可以先等待,再针对对方提交后的行版本重新检查条件。因此,两个把字段加一的更新完全可能先后成功。另一个常见写法却不同:客户端先读取数字,在内存里算好绝对值,稍后再把那个常量写回。数据库不会自动替客户端重算这段业务逻辑,也不能把这两种写法混为一谈。
可重复读在事务第一条非事务控制语句处建立快照,随后通常沿用。如果目标行在这个快照之后被别人修改并提交,当前事务尝试更新时就可能立即收到序列化失败。可串行化还会识别跨不同行形成的依赖异常。它们都没有承诺“只有提交才报错”,隔离更强也不等于每个负载都会更早、更频繁地失败。执行语句与提交事务这两个位置都必须检查结果。
诊断时应记录第一次错误的 SQLSTATE、隔离级别和操作标识。后续查询出现的失败事务状态只是前面错误的结果;如果日志只留下最后一条查询,就可能把真正导致中止的原因覆盖掉。
保存点可以让某些语句错误恢复后继续较早的工作,但不能代替序列化失败后从新事务快照开始的完整重试。继续使用原来的读取与判断,可能再次作出失效决策。自动重试循环中的外部副作用,也必须有自己的去重契约;数据库回滚不会自动撤回已经发出的邮件或其他系统已接受的操作。
快速验证
准备 PostgreSQL 18 的可丢弃数据库,用两个全新的 psql -X -v ON_ERROR_STOP=1 连接,分别记作甲和乙。严格按下面的先后顺序执行,未明确开启事务时,乙保持自动提交。甲先建立独立实验模式和计数器,不要把示例放进生产库:
CREATE SCHEMA conflict_demo;
CREATE TABLE conflict_demo.counter (id integer PRIMARY KEY,n integer);
INSERT INTO conflict_demo.counter VALUES (1,0);
甲开启已提交读事务并读取零。读取完成后先不要提交,让乙获得插入调度的机会:
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT n FROM conflict_demo.counter WHERE id=1;
乙把数字加一并提交,返回一:
UPDATE conflict_demo.counter SET n=n+1 WHERE id=1 RETURNING n;
甲再执行字段加一,返回二,然后提交。这个结果说明甲较早的查询没有冻结后续更新所使用的行值:
UPDATE conflict_demo.counter SET n=n+1 WHERE id=1 RETURNING n;
COMMIT;
等甲提交后,乙把数据归零,以便下一轮只改变隔离级别,不混入第一轮的累计值:
UPDATE conflict_demo.counter SET n=0 WHERE id=1;
甲改用可重复读,并通过读取零建立事务快照。只执行开始事务而没有读数据,不能代替这里明确的快照边界:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT n FROM conflict_demo.counter WHERE id=1;
乙再次加一并提交,返回一:
UPDATE conflict_demo.counter SET n=n+1 WHERE id=1 RETURNING n;
甲执行同样的更新。为观察预期错误和随后失败状态,暂时关闭客户端遇错停止选项;演示完成立即恢复,再回滚:
\set ON_ERROR_STOP off
UPDATE conflict_demo.counter SET n=n+1 WHERE id=1;
\echo update_state :SQLSTATE
SELECT n FROM conflict_demo.counter WHERE id=1;
\echo transaction_state :SQLSTATE
\set ON_ERROR_STOP on
ROLLBACK;
本轮核对了原有双会话调度与文档,未重新执行;预期更新返回 40001,后续查询返回 25P02,表示事务已经失败。此时根本还没有执行提交,所以错误不只会在提交阶段出现。恢复客户端选项不会修复服务器中的失败事务;继续发送查询也不能获得新的业务快照。这里需要的是回滚,而不是把失败的那一句反复塞进原事务。
乙的竞争写入已经结束。甲现在从事务起点重试,重新读取数字、重新更新并提交:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT n FROM conflict_demo.counter WHERE id=1;
UPDATE conflict_demo.counter SET n=n+1 WHERE id=1 RETURNING n;
COMMIT;
新读取返回一,更新返回二。乙最后核对已提交结果并删除本次实验模式:
SELECT n FROM conflict_demo.counter WHERE id=1;
DROP SCHEMA conflict_demo CASCADE;
最终的二分别包含双方各一次成功提交的增量;失败的尝试没有额外贡献一次加法。如果得到其他结果,先核对两个终端是否连接同一数据库、隔离级别是否正确、重置是否发生在甲提交之后,以及是否误用了第三个连接。这里的判据是完整调度和最终数据,不能只凭终端最后显示了一个数字就认定冲突处理正确。
定义完整的重试边界
收到序列化失败后,应开启新事务,连同决定键值、金额或目标行的读取与判断一起重做。死锁也可能适合整笔重试,但仍要设置次数和耗时预算。重放可能重新扫描大量数据、占用连接并再次碰撞,并非天然便宜,更不能保证最终成功。连接归还池之前应结束失败事务;预算用完时应向上层保留最终错误,不能把重试耗尽伪装成已经完成。
唯一约束错误不能一律当作瞬态事件。用户重复提交一个永久标识时,等待不会让它变成合法数据;若键值确实来自之前的查询,才可能需要针对该操作重新计算。错误码分类必须结合真实业务,而不是将所有数据库异常放进同一个循环。
还有一种情况需要单独处理:提交时连接断开,客户端没有收到确认。服务器可能已经完成提交,也可能没有完成,客户端此刻不知道结果。这不同于明确收到服务器拒绝事务的错误。支付、发信等外部可见动作,应结合操作标识与持久化结果消除歧义,再决定是否继续执行,不能仅凭“没有收到成功”就重复整笔副作用。
来源
- PostgreSQL 18 事务隔离,语句快照及并发更新行为,已核验 2026-10-15。
- PostgreSQL 18 序列化失败处理,完整事务重试及错误分类,已核验 2026-10-15。