外层语句预算矩形包含执行、等锁、执行、等待四块;两个等待块下分别用红括号表示逐次计时。

事务拿锁时别的请求会礼貌等待

等待锁可能是正常协调,也可能让连接和请求持续排队。PostgreSQL 的 lock_timeout 可以让调用方停止等待,但它不会解释持锁者为什么慢,更不会自动决定业务是否可以重试。应从操作能够接受的延迟出发设定预算,同时找到实际阻塞者;单纯延长超时或增加尝试次数,可能只是扩大同一条等待队列。 坏掉的样子 锁超时分别作用于每一次锁获取尝试,包括语句隐式申请的锁。它测量的是等待当前锁的时间,而不是整条语句的总运行时间。查询已经执行了一段时间,随后又申请一把锁,仍然可能触发这个限制。反过来,多次较短的等待也可能累计超过一个配置间隔。把锁超时理解成“只有语句刚开始时检查一次”,或把它当成整次查询的总截止时间,都会漏掉真实的等待范围。 statement_timeout 限制整条语句执行,其中也包括等锁。如果它更短,可能先于锁超时取消语句。空闲事务会话超时处理的则是另一种状态:事务还开着,但服务器正在等待客户端发下一条命令。卡在更新内部等待锁的后端通常仍是活动状态,等待事件属于锁;不能看到连接排队,就直接把所有等待者叫作空闲事务。真正闲着不提交的持锁者和正在等待的受阻者,需要分别定位。 PostgreSQL 十八还提供事务超时,事务持续过久时会终止整个会话。这与锁超时仅取消语句的失败边界不同,连接池必须丢弃已经断开的连接。两种设置都不是跨多次尝试的应用总截止时间;事务开始前的排队,以及重试退避期间的耗时,仍应计入调用方预算。不要把一个数据库内计时器当成完整请求的时间保证。 配置超时与 SQLSTATE 说明作用范围;先回滚再重试。配置毫秒数不是测得的客户端延迟。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。使用同一角色并具备创建模式权限,确认 timeout_demo 尚不存在,显式事务外保持自动提交。实验使用默认的零值锁超时,且没有其他会提前干预的超时设置。下面的毫秒参数只用于快速复现,不是生产建议值。甲先建立独立模式和计数表: CREATE SCHEMA timeout_demo; CREATE TABLE timeout_demo.counter (id integer PRIMARY KEY,n integer); INSERT INTO timeout_demo.counter VALUES (1,0); 甲在显式事务中把数字改成十,暂时不提交,也不回滚,以便继续持有目标行的锁: ...

二月 16, 2028 · 1 分钟 · XB Field Notes
原有库存实验中,竞争方把库存从 2 改成 1,监视冲突使旧队列被拒绝,标记仍不存在。重新监视并读到 1 后,第二次尝试一起提交 0 与标记。仅靠 MULTI/EXEC 无法修复此前的旧读取。

WATCH 保护 Redis 中依赖读取的更新决定

MULTI/EXEC 能阻止别的客户端命令插进队列的执行过程,却不会自动保护应用此前已经读到的值。如果两个购买方先读到相同库存,再各自写回计算结果,即使每次 SET 都放进事务,也仍然可能丢失一次更新。问题出在读取与提交之间的空隙,而不是 SET 执行得是否足够连续。 原有库存实验中,竞争方把库存从 2 改成 1,监视冲突使旧队列被拒绝,标记仍不存在。重新监视并读到 1 后,第二次尝试一起提交 0 与标记。仅靠 MULTI/EXEC 无法修复此前的旧读取。 仅为原有实验示意,本轮未重跑。 先监视,再读取依赖 WATCH 让 EXEC 变成有条件的执行:被监视的键从建立监视之后不能发生修改。因此,必须先 WATCH,再读取参与决策的数据。若键在提交前改变,Redis 拒绝整个队列并返回空响应;这只是不执行自己的队列,不会撤销竞争方已经完成的修改。 监视与事务状态都属于同一条连接。redis-py 流水线在 watch 之后保留连接,multi 之前的 GET 会立即发出,应用可以读取结果并做判断;multi 之后才转为缓冲命令,等待 execute。用上下文管理器包住一次尝试,可以在结束时重置流水线状态并释放专用连接。 ...

七月 22, 2027 · 3 分钟 · XB Field Notes
一个 EXEC 边界内依次返回 OK、WRONGTYPE、OK;两条成功写入保留,执行期间其他客户端不插队。

Redis 事务隔离执行,但不自动回滚

Redis 事务先在一条连接上积累命令队列,再进入一个隔离的执行窗口。MULTI 开始排队,EXEC 按顺序执行已接受的命令。这里的关键保证是其它客户端的命令不能插进执行过程,而不是后面一条失败时,前面所有成功写入都能自动撤销。 入队与执行是两个阶段 QUEUED 表示服务端接受了命令,等待后续执行,并不表示这条命令想完成的写入已经成功。Redis 在接受命令时就会检查命令是否存在、参数数量是否正确;依赖当前数据的错误,例如向字符串键执行列表操作,则可能直到 EXEC 才暴露。不能笼统地把它们都称为语法错误,因为两种失败对应的队列处理方式并不相同。 在队列中发送 GET,同样先收到 QUEUED,真正的值出现在 EXEC 的结果数组里。客户端不能先读取这个排队 GET 的实际结果,再据此决定本次事务后面的命令。需要在应用内判断时,可先用 WATCH 保护读取依赖;必须在读取与写入之间分支时,可考虑有界的服务端脚本。 客户端缓冲又增加了一层。redis-py 的普通事务流水线先把命令放进本地列表,直到 execute 才发送 MULTI、各条命令与 EXEC。set 方法返回流水线对象,并不等于服务端已确认入队。下面使用专用连接读取原始响应,是为了观察协议的实际阶段,而不是把客户端的链式调用误当成服务器执行结果。 隔离从执行时开始 MULTI 不会替调用方提前锁住相关键。一个客户端仍在构造队列时,另一个客户端完全可以更新这些键。队列中的 GET 读取的是它执行时的值,不是 MULTI 开始时保存的快照;后面的 GET 也能看到本事务前面 SET 的写入。把不被插队说成启动时的一致快照,会误导依赖旧值的业务判断。 事务必须始终使用同一条连接,而且这条连接不能混入无关业务。如果连接池为 MULTI 和 EXEC 分别分配连接,它们就不属于同一个事务状态。应用代码应优先使用客户端提供的事务抽象;实验直接访问连接,只用于核验已固定版本的协议行为。 ...

三月 31, 2027 · 3 分钟 · XB Field Notes
已提交读与可重复读两轨均先读零,再由乙提交加一;甲最后分别得到二或错误 40001。

事务可能失败,剩下的就靠你重试

PostgreSQL 中,另一个事务修改了同一行,不代表当前事务必然失败。结果取决于隔离级别、语句怎样使用数据,以及竞争者最终提交了什么。把所有碰撞都归成“等一下就好”,会掩盖无效输入,也可能重复使用已经过时的业务决策。需要定义的重试单位,是包含相关读取、判断与写入的一次完整操作。 坏掉的样子 已提交读下,普通查询使用本条语句的快照。更新遇到竞争写者时,可以先等待,再针对对方提交后的行版本重新检查条件。因此,两个把字段加一的更新完全可能先后成功。另一个常见写法却不同:客户端先读取数字,在内存里算好绝对值,稍后再把那个常量写回。数据库不会自动替客户端重算这段业务逻辑,也不能把这两种写法混为一谈。 可重复读在事务第一条非事务控制语句处建立快照,随后通常沿用。如果目标行在这个快照之后被别人修改并提交,当前事务尝试更新时就可能立即收到序列化失败。可串行化还会识别跨不同行形成的依赖异常。它们都没有承诺“只有提交才报错”,隔离更强也不等于每个负载都会更早、更频繁地失败。执行语句与提交事务这两个位置都必须检查结果。 诊断时应记录第一次错误的 SQLSTATE、隔离级别和操作标识。后续查询出现的失败事务状态只是前面错误的结果;如果日志只留下最后一条查询,就可能把真正导致中止的原因覆盖掉。 保存点可以让某些语句错误恢复后继续较早的工作,但不能代替序列化失败后从新事务快照开始的完整重试。继续使用原来的读取与判断,可能再次作出失效决策。自动重试循环中的外部副作用,也必须有自己的去重契约;数据库回滚不会自动撤回已经发出的邮件或其他系统已接受的操作。 该调度用有序写入对比快照边界,不测量等待;保存点不等于新事务快照。 快速验证 准备 PostgreSQL 18 的可丢弃数据库,用两个全新的 psql -X -v ON_ERROR_STOP=1 连接,分别记作甲和乙。使用同一角色并具备创建模式权限,确认 conflict_demo 尚不存在,且没有超时中断调度。严格按顺序执行,未明确开启事务时,双方均保持自动提交。甲先建立独立实验模式和计数器,不要把示例放进生产库: 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; 乙把数字加一并提交,返回一: ...

二月 1, 2027 · 1 分钟 · XB Field Notes
循环包围重新读取、重新计算、写入和提交。只有已确认的 40001 或 40P01 才经回滚、剩余预算检查和随机退避重试。提交结果未知时先核对结果。

有界重试给事务一次重新执行的机会

重试可以帮助恢复因为并发而被拒绝的事务,但不能保证成功,也不会自动让外部副作用变成幂等。重试单位应包含决定写入内容的所有数据库读取与应用判断。如果新事务仍使用第一次快照算出的金额或目标值,即使执行了新的开始事务命令,也没有真正重新做出适合当前数据的决策。 收到明确并发拒绝后,在统一预算内重做读取、判断与写入。永久错误或预算耗尽应停止,提交结果未知时先核对;数据库回滚不能撤销外部副作用。 坏掉的样子 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;') 程序使用真实的两条数据库连接。每次尝试都开启可重复读事务,先读计数值,再由应用算出要写入的绝对值。竞争连接可以在读取之后、更新之前提交一次增量,迫使原快照下的更新失败。这种调度刻意检查“重试有没有重新读取和计算”,而不只是看程序有没有再次发送更新语句。 ...

十一月 9, 2026 · 2 分钟 · XB Field Notes
两个可重复读事务均读到两名值班医生,并分别让不同医生离岗。两者提交后留岗人数为零,违反至少一人留岗的规则。

按业务不变量选择事务隔离

选择隔离级别,应从操作必须维持的不变量出发。一致快照很有用,但两个事务可能分别作出看似合理的决定,却在一起提交后得到错误结果。PostgreSQL 的可重复读保持事务读取快照稳定,仍允许部分序列化异常。可串行化则增加依赖检测,拒绝无法解释为任何串行执行顺序的并发组合,同时要求应用处理相应重试。关键是知道业务依赖哪项保证,而不是单纯选择一个听起来更强的名称。 可重复读能保持双方快照稳定,却仍破坏跨行规则。受控可串行化顺序拒绝 B;完整重试重新读到一人,不再更新。此图不增加运行验证证据。 稳定视图与正确决策分开判断 假设有两名值班医生,要求至少一名留岗。每名医生只有在看到可用人数大于一时才允许离开。两个可重复读事务可以都看到两人,然后分别把不同医生标记为离岗。它们没有覆盖彼此的同一行,因此都可能提交,最后值班人数变成零,即使每个事务都使用了内部稳定的快照。 这叫写偏差,不是对同一行发生丢失更新。分别检查每个医生布尔字段是否合法,并没有表达“总人数至少为一”的全局规则。只保护自己正在修改的那一行,也会漏掉两个决定之间的联系。适用的序列化协议需要覆盖整个不变量,而不只是其中某个局部字段。 具体数据库实现也很重要。PostgreSQL 把读未提交映射为读已提交;它的可重复读会阻止幻读,尽管 SQL 标准允许这一等级存在幻读。这些特性仍不会消除写偏差。因此,应根据引擎文档解释隔离名称,不能假定所有数据库同名级别的实现完全一样,更不能把某一种异常被排除当成所有并发问题都解决。 坏掉的样子 把可串行化理解为数据库实际每次只执行一个事务,会误导性能判断。PostgreSQL 仍并发运行事务并跟踪依赖,某个事务可能在执行途中或提交时收到 40001。应用需要用新的读取重做完整决定,不能只重新提交,也不能继续使用被拒绝快照中计算出来的值。否则虽然换了一次事务,决策前提仍然是旧的。 可串行化也不能修复一个独自运行时就违反业务规则的事务。所有相关写入路径都必须遵守既定契约;如果另外一条不受限制的更新允许最后一名医生离岗,不变量仍会被破坏。更高隔离提供并发保证,不会自动实现尚未编写的业务逻辑。实验中的条件判断与隔离级别必须一起成立。 快速验证 在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务外保留正常自动提交,并选用尚未使用的模式名称。严格遵守下面顺序;先让两边读取重叠,才能建立要观察的竞争决策。 在甲中创建初始值班表。 CREATE SCHEMA isolation_demo; CREATE TABLE isolation_demo.doctors (id integer PRIMARY KEY,on_call boolean NOT NULL); INSERT INTO isolation_demo.doctors VALUES (1,true),(2,true); 先在甲中开始事务并读取,再在乙中执行对应操作。两边人数都应为二。 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; 在甲中更新第一名医生,再在乙中更新第二名。每条更新也会在自己的快照中检查人数规则。 UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; 先提交甲,再提交乙。两边结束后,在甲中查看已经失效的不变量并重置值班表。 COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=true; 使用可串行化重复这组重叠决策:开始甲、开始乙、更新甲、更新乙、提交甲,最后尝试提交乙。 BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=1 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; \set ON_ERROR_STOP off COMMIT; \echo commit_state :SQLSTATE \set ON_ERROR_STOP on 在甲中检查人数,在乙中以新事务重做完整决定,最后在甲中核对并清理。 SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; UPDATE isolation_demo.doctors SET on_call=false WHERE id=2 AND (SELECT count(*) FROM isolation_demo.doctors WHERE on_call)>1 RETURNING id; COMMIT; SELECT count(*) AS available FROM isolation_demo.doctors WHERE on_call; DROP SCHEMA isolation_demo CASCADE; 保留的 PostgreSQL 18.6 执行报告中,可重复读的两个提交都成功,可用人数变成零。在受控的可串行化顺序下,甲提交成功,乙提交返回 40001,仍有一名医生留岗。代码只在需要观察的提交周围关闭遇错停止,让 psql 能打印 SQLSTATE;如果出现其他错误,不能把它当成预期的序列化拒绝。 ...

八月 23, 2026 · 2 分钟 · XB Field Notes
A 保留的可重复读快照包含旧的第 1 行和自己新插入的第 3 行。A 提交后的新读取能看到新的第 1 行、B 的第 2 行和自己的第 3 行。

MVCC 根据可见性规则选择行版本

多版本并发控制让 PostgreSQL 保留不同的行版本,再判断查询可以看到哪些版本。普通读者可以继续读取较早已提交的版本,同时另一个事务写出新版本。这减少了读写之间的冲突,但不表示每个事务都永久拥有一幅固定画面。快照寿命取决于隔离级别,而且事务能看到自己此前的修改,即使其他事务尚看不到。把这些边界说清楚,才能理解读取结果为何既稳定又可能变化。 A 的可重复读快照保留他人数据的较早状态,也允许读取自己的新插入。提交后的新读取才能看到当前三行。原文历史并发测试的未重跑状态保持不变;此图不增加运行证据。 可见性跟随快照 在读已提交级别,普通读取通常每条语句取得一个新快照。PostgreSQL 的可重复读则复用事务第一条非事务控制语句建立的快照。仅仅发送 BEGIN,还没有建立实际读取使用的边界。因此,一个较早开始、较晚才第一次读取的事务,可能看到这两个时刻之间已经完成的提交。“事务启动时间”与“读取快照已经建立”不能随意混用。 更新会创建一个新行版本。可见性规则决定旧快照是否仍读取之前的版本,而新快照是否看到更新。元组物理上仍然存在,并不足以说明当前查询能看到它;SQL 也不是直接返回数据页上最新写下的字节。只有结合事务状态与快照,才能判断哪一版记录属于这次读取的结果。 事务自己的修改,是简单“历史照片”比喻的重要例外。即使快照早已建立,事务仍然可以读到之后由自己插入的记录。它看到的数据,可能同时包含其他事务的较早状态与自己的新工作。这种行为让事务能够实际完成读写操作,但不能把它误解为外部世界某一个已提交时刻的完整照片。理解这个例外,也能避免错误地认为自己的更新必须提交以后才可读。 坏掉的样子 普通读取取得的常规表锁不会阻塞正常行更新,但锁并没有消失。两个写者仍可能争用同一行;SELECT FOR UPDATE 会明确请求行锁;ACCESS EXCLUSIVE 表锁则与普通读者冲突。因此,MVCC 不承诺所有带有 SELECT 的命令都永远不等待。是否阻塞还需要看语句请求的锁以及当前其他操作。 ...

八月 8, 2026 · 1 分钟 · XB Field Notes