重试可以帮助恢复因为并发而被拒绝的事务,但不能保证成功,也不会自动让外部副作用变成幂等。重试单位应包含决定写入内容的所有数据库读取与应用判断。如果新事务仍使用第一次快照算出的金额或目标值,即使执行了新的开始事务命令,也没有真正重新做出适合当前数据的决策。
坏掉的样子
PostgreSQL 用 40001 表示序列化失败,用 40P01 表示死锁。收到这些明确拒绝后,可以在回滚并检查剩余预算的前提下再次尝试完整操作。唯一约束错误却可能是永久的:同一个无效标识不会因为多等一会儿就变成合法输入。某些键值确实由此前查询结果决定,需要特定业务的重算策略,不能据此将所有异常都塞进统一重试循环。
错误既可能发生在语句执行期间,也可能出现在提交阶段。服务器明确拒绝事务,与客户端等待提交确认时连接断开,是两种不同情况。后一种可能已经提交,只是确认没有传回来;盲目重放就可能重复操作。在数据库事务之外已经完成的支付、发信等动作,也不会随着数据库回滚自动撤销。循环本身既不能回收这些副作用,也不能凭空识别重复。
快速验证
准备 Linux 或 macOS 上的 Python 3,以及可通过命令路径运行的 PostgreSQL 18 客户端 psql。设置 PGDATABASE、PGHOST 及必要连接参数,明确指向允许创建模式的可丢弃数据库。提前配置好认证,示例禁止交互式密码提示。把下面的程序保存为 retry-demo.py,再执行 python3 retry-demo.py。它创建并删除自己的实验模式;如果同名模式已经存在,初始化会失败,不能拿已有业务模式充当实验数据。
import os
import random
import re
import selectors
import subprocess
import time
PSQL = ['psql', '-X', '-w', '-qAt', '-v', 'ON_ERROR_STOP=1', '-v', 'VERBOSITY=sqlstate']
ENV = dict(os.environ, PGCONNECT_TIMEOUT='2')
def run(sql):
return subprocess.run(PSQL, input=sql, text=True, capture_output=True,
check=True, timeout=5, env=ENV).stdout.strip()
def read_value(worker):
deadline = time.monotonic() + 5
data = bytearray()
with selectors.DefaultSelector() as ready:
ready.register(worker.stdout, selectors.EVENT_READ)
while b'\n' not in data:
remaining = deadline - time.monotonic()
if remaining <= 0 or not ready.select(remaining):
raise TimeoutError('Initial read was not confirmed')
chunk = os.read(worker.stdout.fileno(), 4096)
if not chunk:
raise RuntimeError('Connection ended before initial read')
data.extend(chunk)
return int(data.strip())
def attempt(mode, number):
worker = subprocess.Popen(PSQL, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=ENV)
try:
worker.stdin.write("SET statement_timeout='1s';\n"
'BEGIN ISOLATION LEVEL REPEATABLE READ;\n'
'SELECT n FROM retry_demo.counter WHERE id=1;\n')
worker.stdin.flush()
observed = read_value(worker)
# A real second connection commits after this attempt's snapshot.
if mode == 'always' or (mode == 'once' and number == 1):
run('UPDATE retry_demo.counter SET n=n+1 WHERE id=1;')
statement = ('INSERT INTO retry_demo.counter VALUES (1,99);' if mode == 'permanent'
else f'UPDATE retry_demo.counter SET n={observed + 1} WHERE id=1;')
finish = r'''
\set ON_ERROR_STOP off
__STATEMENT__
\if :ERROR
\echo ATTEMPT :SQLSTATE
ROLLBACK;
\else
COMMIT;
\if :ERROR
\echo ATTEMPT :SQLSTATE
\else
\echo ATTEMPT 00000
\endif
\endif
'''.replace('__STATEMENT__', statement)
output, _ = worker.communicate(finish, timeout=5)
match = re.fullmatch(r'ATTEMPT ([0-9A-Z]{5})', output.strip())
if worker.returncode != 0 or match is None:
raise RuntimeError('No confirmed transaction outcome; do not replay blindly')
return match[1], observed
finally:
if worker.poll() is None:
worker.kill()
worker.communicate()
def increment(mode):
observations = []
for number in range(1, 4):
state, observed = attempt(mode, number)
observations.append(observed)
if state not in {'40001', '40P01'} or number == 3:
return {'attempts': number, 'state': state, 'reads': observations}
time.sleep(random.uniform(0, 0.01 * 2 ** (number - 1)))
run('CREATE SCHEMA retry_demo; '
'CREATE TABLE retry_demo.counter (id integer PRIMARY KEY,n integer); '
'INSERT INTO retry_demo.counter VALUES (1,0);')
try:
cases = [
('once', {'attempts': 2, 'state': '00000', 'reads': [0, 1]}, 2),
('permanent', {'attempts': 1, 'state': '23505', 'reads': [0]}, 0),
('always', {'attempts': 3, 'state': '40001', 'reads': [0, 1, 2]}, 3),
]
for mode, expected, expected_value in cases:
run('UPDATE retry_demo.counter SET n=0 WHERE id=1;')
result = increment(mode)
value = int(run('SELECT n FROM retry_demo.counter WHERE id=1;'))
assert result == expected and value == expected_value, (mode, result, value)
print(mode, result, 'stored', value)
finally:
run('DROP SCHEMA retry_demo CASCADE;')
程序使用真实的两条数据库连接。每次尝试都开启可重复读事务,先读计数值,再由应用算出要写入的绝对值。竞争连接可以在读取之后、更新之前提交一次增量,迫使原快照下的更新失败。这种调度刻意检查“重试有没有重新读取和计算”,而不只是看程序有没有再次发送更新语句。
第一种情况只制造一次冲突。预期尝试两次,最终状态成功,两次读取依次为零和一,数据库最终存储二。其中一次增量来自竞争连接,另一次来自应用成功的重试。被拒绝的第一次尝试没有贡献任何提交。如果第二次只是复用原先算好的值,就不能保留两次合法增量的结果,因此最终数据也是判断重试边界的重要证据。
第二种情况制造永久错误:向已有主键再次插入相同标识。预期只尝试一次,状态为 23505,读取零,存储仍为零。分类器应立即停止,而不是把三次机会全部浪费在未改变的无效输入上。此时不能报告应用完成了加一,也不能仅因为客户端没有崩溃就把业务状态改成成功。
第三种情况在每次尝试中都安排竞争者先提交。预期尝试三次后退出,最终仍是 40001,读取依次为零、一、二,存储值为三。这里的三次增量全部属于竞争连接,应用自己的操作一次也没有提交。这是达到预算上限的失败结果,不是“重试终于成功”。断言同时检查控制流程报告与持久化数据,因为单看表里的数字无法知道是哪一方完成了操作。
让错误处理覆盖完整决策
示例从客户端取得结构化错误码,再输出专用结果标记,没有根据本地化错误文字猜测类型。预期语句错误后执行回滚,提交阶段则单独检查。若缺少确认、进程异常退出或传输等待超时,异常会离开重试分类器,不能被随意当作一次可安全重放的并发拒绝。首次读取与进程完成分别设置了等待边界,避免实验无期限挂在一个阶段。
真实应用应使用数据库驱动的事务接口和结构化异常接口,而不是直接复制这个进程调度工具。请求总预算还应包含获取连接、全部尝试以及退避时间;调用方取消后要停止继续重放,并记录尝试次数和最终错误。随机退避有助于打散同时重试的请求,却不能修复永久占用、保证公平,或让有限次数必然成功。
对外部可见操作,应使用稳定操作标识和可查询的持久化结果。提交结果不确定时,先核对结果,再决定是否重新发起非幂等动作。不可逆副作用不应直接放进会被多次执行的回调,除非相应的交付协议已经明确处理重复。让一次失败尝试可以正确退出、让重复决策基于新状态、让未知结果得到核实,才构成可靠恢复;循环次数和短暂睡眠只是其中负责安排时间的部分。
来源
- PostgreSQL 18 序列化失败处理,重试错误分类和完整事务重算,已核验 2026-09-10。
- PostgreSQL 18 事务隔离,实验使用的快照冲突规则,已核验 2026-09-10。
- PostgreSQL 18 psql,错误状态变量、条件执行和客户端错误处理,已核验 2026-09-10。