会话持有通过两次解锁从二降至一再到零;回滚仍保留两次持有。

建议锁寿命属于连接或事务

建议锁让应用使用一个标识来协调工作,不要求这个标识对应数据库里某一行。比如,多名工作者可以约定,同一时间只能有一个连接刷新某份报表。PostgreSQL 负责管理锁的持有与竞争,应用则负责定义标识的业务含义,以及哪些操作必须先拿锁才能执行。这个分工决定了它能提供的保障,也决定了绕过协议时会发生什么。 坏掉的样子 建议锁的键只能是一个大整数,或者两个普通整数,没有接受文本名字的函数重载。这两种键空间彼此独立。如果应用通过哈希把名称映射为数字,必须明确一致的编码规则,也要考虑碰撞;同一资源被不同算法映射到不同数字,同样会破坏协调。直接维护有文档的整数命名空间,通常比隐藏在各个调用方里的临时哈希规则更容易核对。 建议锁是参与者共同遵守的协议。忽略它的连接,仍然可以更新所谓受保护的表行;它不能代替唯一约束、事务或权限检查。会话锁在回滚后仍然保留,直到对应次数的解锁完成,或服务器结束该会话。事务锁则随事务结束释放。如果处于自动提交模式,一条语句取得的事务锁,在后续语句开始之前就已经释放,无法保护接下来才执行的工作。 会话持有次数在回滚后保留;事务持有随事务结束。数值为人工预期,不是本轮竞争观察。 快速验证 本轮没有重跑原样保留的双会话示例。保留的审阅记录给出源文核对后的预期,没有原始多会话输出;单用户后端不能证明会话间竞争。 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。使用同一角色并具备创建模式权限,确认 advisory_demo 尚不存在;显式事务外保持自动提交,且没有会中断调度的超时。为本实验预留下面的整数键,不要与已有业务约定混用。按照块的顺序切换终端。甲先创建计数器: CREATE SCHEMA advisory_demo; CREATE TABLE advisory_demo.counter (id integer PRIMARY KEY,n integer); INSERT INTO advisory_demo.counter VALUES (1,0); 甲传入显式文本类型的键,预期返回 42883,即不存在匹配的函数。故意报错这一段结束后恢复遇错停止设置: \set ON_ERROR_STOP off SELECT pg_advisory_lock('job-name'::text); \echo text_key_state :SQLSTATE \set ON_ERROR_STOP on 甲在事务里把同一把会话锁取两次,然后回滚。注意函数名使用的是会话级形式: ...

十一月 24, 2027 · 1 分钟 · XB Field Notes
账户 A 扣款、账户 B 入账和流水写入共享事务边界。COMMIT 一起保留三项事务性写入,ROLLBACK 一起撤销;应用负责纳入必需的写入。

事务让提交是原子的

事务让相关数据库变更一起提交,或者一起放弃。如果一次操作要求扣减一个账户、增加另一个账户,并写入流水,那么这三步就应属于同一个决定。PostgreSQL 通过 BEGIN、COMMIT 和 ROLLBACK 提供这个边界,但应用仍须把正确的语句放进去。原子执行不会自动发现漏掉的入账,也不会替业务补出一个没有编写的校验。事务保护的是被明确纳入其中的工作,而不是开发者脑中尚未落实的意图。 第二次转账失败后,应恢复已提交余额与流水行数。COMMIT 回复丢失则属于另一种不确定状态,须核对实际结果。此概念图不增加运行验证证据。 决定一个工作单元包含什么 普通 psql 在没有显式事务块时使用自动提交,每条语句各自处于一个事务里。因此,扣款可能已经成功提交,之后的流水插入才失败。显式分组改变了失败边界:其他事务看不到这组操作尚未提交的表数据中间状态;回滚直接丢弃其事务性变更,而不是依赖应用事后再发几条补偿更新来还原。 原子性与隔离性、持久性并不是同一件事。如果隔离级别或加锁协议不足,一组完整提交的操作仍可能基于错误的并发判断。数据库确认提交后的持久保障,也受到实际持久化配置影响。仅在代码里找到 BEGIN,还不能回答这些问题。同样,PostgreSQL 的每条语句本来就运行在事务中,读取也不例外;显式读事务可以用于重复读取快照或事务局部设置,不能一概视为无用开销。 坏掉的样子 PostgreSQL 中未处理的 SQL 错误通常会让显式事务块进入失败状态。客户端必须整体回滚,或者回滚到适用的早期保存点,才能恢复正常执行。关闭客户端的遇错停止功能,并不会修复服务器端已经失败的事务。如果把这种连接直接还给连接池,下一个请求可能接着遇到失败状态,而不是从干净边界开始。 服务器明确拒绝提交,与提交确认在网络中丢失,是不同证据。如果连接在等待 COMMIT 回复时断开,客户端可能不知道数据库是否已先完成提交。盲目重发支付可能导致重复操作。持久的操作标识和结果核对用于处理这种不确定性;普通回滚语义不能证明一个暂时无法联系的服务器究竟完成了什么。不能把所有客户端报错都解释成“肯定什么也没发生”。 快速验证 在可丢弃的 PostgreSQL 18 数据库中打开新的 psql -X -v ON_ERROR_STOP=1 会话,运行下面代码。临时表让这些账户编号只属于本会话。代码仅在两个刻意制造的失败周围关闭遇错停止,以便观察 SQLSTATE,随后立即重新开启。 ...

五月 12, 2027 · 1 分钟 · 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
A 持有 stable.lock,B 等待同一把锁;未参与协议的普通写入绕过锁,仍能访问数据。

文件锁保护共同协议中的特定对象

文件锁协调的是一组参与者:大家约定锁哪个对象、何时获取锁,以及哪些操作必须放在临界区内。Linux 提供多种锁接口,它们的所有权和释放规则并不相同。选一个文件名,再调用名称里带 lock 的函数,并不足以构成正确协议;还必须明确底层对象、锁的种类,以及相关每份引用的寿命,否则看起来都成功加锁的程序仍可能同时修改同一份逻辑数据。 图示限于本地 Linux 语义;参与者须使用同种锁与稳定对象。建议锁不阻止普通写入,重建锁文件可能分离所有权;互斥不等于持久化。 坏掉的样子 在本实验使用的 Linux 本地文件系统上,flock 提供建议式互斥。另一个请求冲突锁的进程会等待或失败,但完全没有请求锁的普通写入仍然可以进行。因此,文件锁不会赋予内容隐私,也不会自动限制所有能够打开文件的程序。只有参与者实际遵循共同协议,互斥才能保护需要保持一致的数据操作。 如果更新通过新 inode 替换原路径,直接锁数据文件就会出现对象分离。已有锁仍关联旧的打开文件描述,后续打开者却访问替换后的文件,并可能成功获取另一把锁。两个写者都以为自己独占同一份逻辑文档,实际锁住的却是不同对象。路径名称稳定,并不意味着路径背后的文件身份稳定。 传统 POSIX 记录锁还具有容易忽略的寿命规则:锁属于进程,该进程关闭任何一个指向该文件的描述符,都会释放它在该文件上持有的记录锁。因此,某个库独立打开并关闭同一 inode,也可能使程序其他位置持有的锁失效。这与通过打开文件描述拥有锁的 flock 不同,不能把两套释放规则混用。还要注意,通过任意复制引用显式解锁,也会释放同一把 flock;其他复制描述符仍然打开,并不能抵消显式解锁。 快速验证 把脚本保存为 locking.py,在一次性 Linux 环境中运行 python3 locking.py。实测环境为 Linux 6.12.76、Python 3.11.2 和本地 tmpfs。每个竞争锁的请求都在独立子进程中执行,并使用非阻塞获取,因此预期冲突会产生可检查结果,不会让演示一直挂起: ...

十二月 5, 2026 · 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
A 的令牌 1 租约到期后,B 获得令牌 2。A 恢复并发出迟到写入;已经见过令牌 2 的资源拒绝令牌 1。

锁与租约驯服并发写入

锁与租约用于协调共享资源访问,但具体故障行为取决于实现。本文关注互斥写入,锁本身也可以有共享模式;租约则给有效权限增加期限。熟悉的名字或一个超时值,都不能单独证明延迟写入者在交接之后不会破坏数据。设计前应明确保护什么资源、由谁执行拒绝,以及失败之后如何结束所有权。 到期允许交接,却不会取消旧效果。独立资源看到新令牌后才识别旧代次;原文协调者与资源共置的单时钟模型,还会在写入时原子检查期限。 锁有明确的生命周期 持有者崩溃后,锁并不一定永远留下。PostgreSQL 会话级咨询锁会在会话结束时释放,事务级咨询锁会在事务结束时释放。远程连接已经坏掉,服务器却尚未发现,可能导致等待持续一段时间,但这与完全没有清理机制不同。等待获取锁的超时,也不同于已经取得的所有权自行到期,不能把两种时间限制混为一谈。 单进程内互斥锁可能很合适,但死锁和线程停顿仍然存在。跨进程操作也可能更适合操作系统锁或数据库锁,而不是应用租约。应根据资源、所有权范围与恢复语义选择,不能套用“只要分布式就必须有租约”的规则,也不能因为线程属于同一进程就假定持有者永远不会失去进展。 咨询锁只约束遵守协议的参与者。忽略锁的代码仍可能访问它本来有权限触碰的资源。数据库对数据操作强制执行的锁,与外部工作者约定的主身份,具有不同的执行点。需要明确究竟哪个组件阻止冲突操作,不能仅依赖每个调用方在出错、暂停和重启之后都继续自觉遵守规则。 租约允许稍后交接 协调者可以授予截止时间之前的权限、接受及时续约,并在过期后允许另一轮获取。这样能够在持有者消失后恢复,而不必等它主动释放。然而,过期不会停止已经暂停的进程、撤回已经发送的请求,也不会取消另一项服务正在执行的写入。权限失效与旧工作实际结束之间,仍然可能存在很长的间隔。 每次写入前检查所有权,仍留下检查与效果之间的窗口。围栏协议让资源端参与,拒绝过时的获取代次。如果独立资源只记住自己已经见过的最大令牌,那么旧令牌是在更新令牌到达该资源后才被识别为过时;这个机制本身不会在所有租约刚过期的瞬间就知道它已经无效。不能把收到新代次后的拒绝,夸大为实时租约校验。 续约和释放也需要携带每次获取独有的身份。拥有者名称可能复用:工作者 B 上一次获取留下的延迟释放,不能释放 B 后来取得的新租约。Redis 按值比较后删除的模式展示了这种获取身份检查,但随机所有权值并不自动具有大小顺序,也不能直接当成严格递增的围栏号。 续约存在不确定性 续约余量应根据调度延迟、网络延迟、暂停以及协议的时钟假设选择。每个 TTL 的三分之一续一次,可以是一项运维选择,却不是正确性定理。十秒租约也不保证服务一定在十秒内完全恢复;协调者不可用、重试、调度和资源恢复都会增加额外时间,应分别测量这些等待,而不是把 TTL 直接写成故障恢复上限。 续约超时意味着结果可能未知,不一定证明协调者拒绝续约。一旦无法建立安全权限,持有者应停止发起新的工作;但尝试取消,不证明已经在途的效果也停下了。重新获取必须取得新身份,资源端仍需执行相应的旧请求拒绝规则。否则客户端看起来已经恢复正常,却可能仍有旧工作迟到并覆盖结果。 坏掉的样子 旧任务可以在另一持有者开始后恢复执行,如果存储接受旧请求,它仍然可能覆盖新数据。客户端检查尤其怕发生在检查刚通过之后的暂停。实验应在真正执行效果的位置重现迟到写入,而不只是观察客户端计时器报过期,否则最重要的写入竞争仍未被覆盖。 时钟比较需要明确模型。墙钟跳变、走时速率差异与不同时间原点,都可能破坏截止时间计算。本地时钟偏快不必然延长租约;若双方比较同一个绝对截止值,偏快反而可能使它提前过期。单调经过时间可以避免墙钟跳变,却不自动消除所有协议时序假设。缩短 TTL 也不是普遍补救办法,还可能增加误判与反复交接。 ...

十月 4, 2026 · 2 分钟 · XB Field Notes
甲持有第一行并等待第二行,乙持有第二行并等待第一行。两个有向等待形成循环,PostgreSQL 可能中止任意一方并返回 40P01。

死锁:两个写者在互相等待

死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。PostgreSQL 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。 死锁需要循环等待,任意一方都可能失败,全部更新随之回滚。只重试失败的完整操作;所有相关资源都应遵守一致顺序,并为重试设定有限预算。 坏掉的样子 即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。 数据库检测到死锁时返回 40P01。应用不能依赖“后开始者必输”或“工作量少的一方会被中止”之类规则。锁等待超时、语句超时也可能先于死锁检测触发,返回另一种错误。因此必须查看实际错误码和服务器诊断,再判断发生了什么。没有看到死锁码,并不等于没有形成过循环;重试了一次,也不代表原来的取锁顺序已经修好。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色、独占这个数据库,并确保实验模式名尚未使用。初始化失败就停止;清理只针对本次成功建立的夹具。甲创建两行数据,并显示死锁检测等待参数: CREATE SCHEMA deadlock_demo; CREATE TABLE deadlock_demo.items (id integer PRIMARY KEY,n integer); INSERT INTO deadlock_demo.items VALUES (1,0),(2,0); SHOW deadlock_timeout; 保留的历史记录显示一秒;本次评审没有重跑双会话实验。下面给语句十秒预算,让检测有时间发生,并确保没有更短的锁超时抢先干预。如果你的检测间隔更长,应相应调整实验预算。甲记录自己要增加的十,并先更新第一行,暂不提交: \set delta 10 SET statement_timeout='10s'; BEGIN; UPDATE deadlock_demo.items SET n=n+:delta WHERE id=1; 乙记录自己要增加的一百,先更新第二行,也保持事务打开: ...

八月 11, 2026 · 1 分钟 · 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