由应用加锁、与表级锁隔离的一个命名标识。

PostgreSQL 咨询锁是应用起的名字

建议锁让应用使用一个标识来协调工作,不要求这个标识对应数据库里某一行。比如,多名工作者可以约定,同一时间只能有一个连接刷新某份报表。PostgreSQL 负责管理锁的持有与竞争,应用则负责定义标识的业务含义,以及哪些操作必须先拿锁才能执行。这个分工决定了它能提供的保障,也决定了绕过协议时会发生什么。 坏掉的样子 建议锁的键只能是一个大整数,或者两个普通整数,没有接受文本名字的函数重载。这两种键空间彼此独立。如果应用通过哈希把名称映射为数字,必须明确一致的编码规则,也要考虑碰撞;同一资源被不同算法映射到不同数字,同样会破坏协调。直接维护有文档的整数命名空间,通常比隐藏在各个调用方里的临时哈希规则更容易核对。 建议锁是参与者共同遵守的协议。忽略它的连接,仍然可以更新所谓受保护的表行;它不能代替唯一约束、事务或权限检查。会话锁在回滚后仍然保留,直到对应次数的解锁完成,或服务器结束该会话。事务锁则随事务结束释放。如果处于自动提交模式,一条语句取得的事务锁,在后续语句开始之前就已经释放,无法保护接下来才执行的工作。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。为本实验预留下面的整数键,不要与已有业务约定混用。按照块的顺序切换终端。甲先创建计数器: 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
几次写入要么一起提交,要么作为一体回滚。

事务让提交是原子的

事务让相关数据库变更一起提交,或者一起放弃。如果一次操作要求扣减一个账户、增加另一个账户,并写入流水,那么这三步就应属于同一个决定。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,证明即使客户端继续发送命令,服务器中的事务也已经处于失败状态。这个阶段看不到正常读取结果,不能误以为失败只影响那一条插入。 ...

五月 12, 2027 · 1 分钟 · XB Field Notes
一种让事务从头开始的并发信号。

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

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; 乙把数字加一并提交,返回一: ...

二月 1, 2027 · 1 分钟 · XB Field Notes
独立且身份稳定的锁文件,可以协调替换数据路径的写入协议。

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

文件锁协调的是一组参与者:大家约定锁哪个对象、何时获取锁,以及哪些操作必须放在临界区内。Linux 提供多种锁接口,它们的所有权和释放规则并不相同。选一个文件名,再调用名称里带 lock 的函数,并不足以构成正确协议;还必须明确底层对象、锁的种类,以及相关每份引用的寿命,否则看起来都成功加锁的程序仍可能同时修改同一份逻辑数据。 坏掉的样子 在本实验使用的 Linux 本地文件系统上,flock 提供建议式互斥。另一个请求冲突锁的进程会等待或失败,但完全没有请求锁的普通写入仍然可以进行。因此,文件锁不会赋予内容隐私,也不会自动限制所有能够打开文件的程序。只有参与者实际遵循共同协议,互斥才能保护需要保持一致的数据操作。 如果更新通过新 inode 替换原路径,直接锁数据文件就会出现对象分离。已有锁仍关联旧的打开文件描述,后续打开者却访问替换后的文件,并可能成功获取另一把锁。两个写者都以为自己独占同一份逻辑文档,实际锁住的却是不同对象。路径名称稳定,并不意味着路径背后的文件身份稳定。 传统 POSIX 记录锁还具有容易忽略的寿命规则:锁属于进程,该进程关闭任何一个指向该文件的描述符,都会释放它在该文件上持有的记录锁。因此,某个库独立打开并关闭同一 inode,也可能使程序其他位置持有的锁失效。这与通过打开文件描述拥有锁的 flock 不同,不能把两套释放规则混用。 快速验证 把脚本保存为 locking.py,在一次性 Linux 环境中运行 python3 locking.py。实测环境为 Linux 6.12.76、Python 3.11.2 和本地 tmpfs。每个竞争锁的请求都在独立子进程中执行,并使用非阻塞获取,因此预期冲突会产生可检查结果,不会让演示一直挂起: from pathlib import Path from tempfile import TemporaryDirectory import fcntl,os,subprocess,sys probe_code=r''' import fcntl,sys with open(sys.argv[1],'r+') as handle: try: if sys.argv[2]=='flock':fcntl.flock(handle,fcntl.LOCK_EX|fcntl.LOCK_NB) else:fcntl.lockf(handle,fcntl.LOCK_EX|fcntl.LOCK_NB,1,int(sys.argv[3]),0) except BlockingIOError:print('blocked') else:print('acquired') ''' def probe(path,kind='flock',start=0): result=subprocess.run([sys.executable,'-c',probe_code,str(path),kind,str(start)],capture_output=True,text=True,timeout=5) assert result.returncode==0,result.stderr return result.stdout.strip() with TemporaryDirectory(prefix='xbcatm-locks-') as directory: root=Path(directory);target=root/'data';target.write_text('original') handle=target.open('r+');fcntl.flock(handle,fcntl.LOCK_EX) assert probe(target)=='blocked' subprocess.run([sys.executable,'-c',"from pathlib import Path;import sys;Path(sys.argv[1]).write_text('uncooperative')",str(target)],capture_output=True,text=True,check=True) handle.seek(0);assert handle.read()=='uncooperative' duplicate=os.dup(handle.fileno());handle.close() try:assert probe(target)=='blocked' finally:os.close(duplicate) assert probe(target)=='acquired' print('flock blocks cooperating contenders but not ordinary writes; dup retains the same lock') with target.open('r+') as handle: fcntl.flock(handle,fcntl.LOCK_EX) replacement=root/'replacement';replacement.write_text('new inode');replacement.replace(target) assert os.fstat(handle.fileno()).st_ino!=target.stat().st_ino assert probe(target)=='acquired' lockfile=root/'stable.lock';lockfile.touch() with lockfile.open('r+') as handle: fcntl.flock(handle,fcntl.LOCK_EX) replacement.write_text('next inode');replacement.replace(target) assert probe(lockfile)=='blocked' assert probe(lockfile)=='acquired' print('replacing locked data changes the inode; a separate stable lock file protects that replacement protocol') first=os.open(target,os.O_RDWR);second=os.open(target,os.O_RDWR) try: fcntl.lockf(first,fcntl.LOCK_EX|fcntl.LOCK_NB,1,0,0) assert probe(target,'record',0)=='blocked' and probe(target,'record',1)=='acquired' os.close(second);second=None assert probe(target,'record',0)=='acquired' print('POSIX record locks permit disjoint bytes; closing another descriptor to the inode releases process-owned locks') finally: if second is not None:os.close(second) os.close(first) 首先,父进程持有排他 flock,遵守协议的子进程报告 blocked。另一个子进程完全不请求锁就写入文件,父进程随后读到修改后的内容。同一个文件、同一组权限下的两个结果,分别证明互斥确实存在,以及它的建议式边界。不能只展示一个竞争者失败,就推断所有普通读写也会被锁阻止。 ...

十二月 5, 2026 · 1 分钟 · XB Field Notes
一个事务先中止,再在上限内重新开始。

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

重试可以帮助恢复因为并发而被拒绝的事务,但不能保证成功,也不会自动让外部副作用变成幂等。重试单位应包含决定写入内容的所有数据库读取与应用判断。如果新事务仍使用第一次快照算出的金额或目标值,即使执行了新的开始事务命令,也没有真正重新做出适合当前数据的决策。 坏掉的样子 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
两个写入者争抢一条记录,一次只允许一个进去。

锁与租约驯服并发写入

单个写入者很简单;多个写入者才是 bug 的温床。锁与租约是两个经典答案,而分清它们,决定了你的系统能否在持有者崩溃后恢复。 锁:一个你放手才失去的权利 锁授予某一进程独占的写权,一直持有到它主动释放为止。在单个进程内这既简单又确定,但一旦跨越多机,就会出现一个致命的失效模式:持有者崩溃或卡死时,解锁代码永远不会执行,所有等待者只能永远等下去。普通锁连超时都没有,「失败之后由谁解开」必须由外部机制补上,否则一台死节点就能把共享资源拖下线。锁只能回答「现在谁能写」,却回答不了「可以写多久」。 租约:带时限的权利 租约授予同样的写权,但这次权利带一个时间上限:到期不续就会被回收,崩溃的持有者因此无法永远堵住别人。租约由协调者给出一个截止时间,持有者在过期前续期,一旦过期,其他写方即可接手。代价是一个极窄的竞态窗口——慢速或已被分区的持有者,会误以为租约还在自己手上,而另一个写方已经开始写入。这正是续签与 fencing(围栏)存在的意义:租约代码在每次写入之前,都要重新核实自己是否仍然有权。 哪个合适 单进程里用进程内锁就够,而且最正确——旁边没有另一个独立的持有者问你撞车。跨进程就要用租约,因为它把最坏等待时间限定住了。你真正要决定的,是「一台死节点来了怎么办」: 用普通锁就得自己加超时,否则永远卡死;用租约,这个上限是内置的。所以,当你可能遇到死节点、又希望自动恢复时,选租约,并把续签与 fencing 当作协议的第一类组件来设计。 续约决定真正的保证 租约不是取一次的时间戳,而是一个要不断自证的循环。存活时间决定恢复上限:十秒租约把一次崩溃的影响封顶在十秒左右,十分钟租约则把每次崩溃都变成十分钟等待。持有者必须在到期前续约,通常以三分之一存活时间为周期,丢一次心跳也还有重试机会。铁律只有一条:续约失败就停写。先暂停任务、取消在途写入,重新拿到租约再碰资源。每次写入都要同时复核持有者身份、截止时间和围栏号,因为上次续约之后租约可能已经易主。本文与 fencing-token 那篇分工不同:那边讲存储侧如何在交接后拒绝旧写者,这里讲交接何时可以发生,以及旧持有者到期后还欠系统什么动作。 坏掉的样子 过期任务仍在跑是最经典的租约故障。一次垃圾回收停顿,或者一个运行时间超过租约的任务,会让协调者把租约发给写者乙,而写者甲还以为自己持有租约。双方同时写入,最后写入者悄悄覆盖前者。对外表现为间歇性覆盖,却看不到任何锁争用指标。 时钟偏斜会在没有任何卡顿的情况下制造同一个双持有。当甲的时钟偏快,甲会在协调者已经过期并转授给乙之后,仍然认为自己的租约有效。授时台阶、虚拟机迁移、被冻结后恢复的容器都是常见诱因。对策是缩短租约并限定整个系统的时钟不确定度,而不是信任任何一方的本地时钟。 最安静的故障是悄悄死掉的续约循环。心跳线程崩溃,或者网络抖动让每次续约都失败,而主线程照常处理请求。日志看起来一切健康,但过期之后每一次写入都落在了别人的任期里。把续约失败都当作持有者的致命事件:停下任务,重新获取。 释放与时长取值收尾。不释放会把每次优雅交接拖成完整存活时间的等待,释放两次则可能把已经不属于自己的租约放掉。存活时间远小于续约延迟时,所有权会在做完有用工作之前来回抖动。 快速验证 把下面存成 lease.py,执行 python3 lease.py。它只用标准库,以整数模拟时钟,不安装任何东西,时间也不会抖动。一个 LeaseStore 实现 acquire、renew 与 release,write 同时校验过期与获取时下发的围栏号: class LeaseLostError(RuntimeError): pass class LeaseStore: """极简租约协调者,时钟为手动推进的整数秒。""" def __init__(self): self.holder = None self.expiry = 0 self.fence = 0 self.data = None def acquire(self, owner, ttl, now): if self.holder is None or now >= self.expiry: self.holder = owner self.expiry = now + ttl self.fence += 1 return self.fence return None def renew(self, owner, ttl, now): if self.holder == owner and now < self.expiry: self.expiry = now + ttl return True return False def release(self, owner): if self.holder == owner: self.holder = None return True return False def write(self, owner, token, value, now): if self.holder != owner or now >= self.expiry: raise LeaseLostError(f"lease expired for {owner} at t={now}") if token != self.fence: raise LeaseLostError(f"stale token {token} != {self.fence}") self.data = value return True store = LeaseStore() token_a = store.acquire("A", ttl=5, now=0) assert token_a == 1, token_a assert store.write("A", token_a, "a-1", now=2) is True assert store.renew("A", ttl=5, now=4) is True # 截止时间从 5 移到 9 assert store.write("A", token_a, "a-2", now=8) is True try: store.write("A", token_a, "a-stale", now=10) except LeaseLostError as error: print(f"rejected expired write: {error}") else: raise AssertionError("expired lease write was accepted") assert store.renew("A", ttl=5, now=10) is False # 过期后再续约必然失败 token_b = store.acquire("B", ttl=5, now=10) assert token_b == 2, token_b assert store.write("B", token_b, "b-1", now=11) is True try: store.write("B", token_a, "b-wrong-token", now=12) except LeaseLostError as error: print(f"rejected wrong fencing token: {error}") else: raise AssertionError("wrong-token write was accepted") try: store.write("A", token_a, "a-older-term", now=12) except LeaseLostError as error: print(f"rejected stale holder: {error}") else: raise AssertionError("stale holder write was accepted") assert store.acquire("C", ttl=5, now=12) is None # 乙仍持有,丙拿不到 assert store.release("A") is False # 只有持有者能释放 assert store.release("B") is True token_c = store.acquire("C", ttl=5, now=13) assert token_c == 3, token_c assert store.write("C", token_c, "c-1", now=14) is True assert store.data == "c-1" print("PASS: renew extended the lease; expired, wrong-token, and stale writes rejected") 首轮写入持一号成功,续约把截止时间从五移到九,时刻十的写入因过期被拒。乙以二号接管并写入成功,错号与甲的迟到写入各被拒绝一次,乙释放后丙以三号干净接管。预期输出如下: ...

十月 4, 2026 · 2 分钟 · XB Field Notes
两个事务各持一锁并等待对方,直到其中一个被中止。

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

死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。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
一个事务读旧快照,另一个事务并行写出新版本。

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

多版本并发控制让 PostgreSQL 保留不同的行版本,再判断查询可以看到哪些版本。普通读者可以继续读取较早已提交的版本,同时另一个事务写出新版本。这减少了读写之间的冲突,但不表示每个事务都永久拥有一幅固定画面。快照寿命取决于隔离级别,而且事务能看到自己此前的修改,即使其他事务尚看不到。把这些边界说清楚,才能理解读取结果为何既稳定又可能变化。 可见性跟随快照 在读已提交级别,普通读取通常每条语句取得一个新快照。PostgreSQL 的可重复读则复用事务第一条非事务控制语句建立的快照。仅仅发送 BEGIN,还没有建立实际读取使用的边界。因此,一个较早开始、较晚才第一次读取的事务,可能看到这两个时刻之间已经完成的提交。“事务启动时间”与“读取快照已经建立”不能随意混用。 更新会创建一个新行版本。可见性规则决定旧快照是否仍读取之前的版本,而新快照是否看到更新。元组物理上仍然存在,并不足以说明当前查询能看到它;SQL 也不是直接返回数据页上最新写下的字节。只有结合事务状态与快照,才能判断哪一版记录属于这次读取的结果。 事务自己的修改,是简单“历史照片”比喻的重要例外。即使快照早已建立,事务仍然可以读到之后由自己插入的记录。它看到的数据,可能同时包含其他事务的较早状态与自己的新工作。这种行为让事务能够实际完成读写操作,但不能把它误解为外部世界某一个已提交时刻的完整照片。理解这个例外,也能避免错误地认为自己的更新必须提交以后才可读。 坏掉的样子 普通读取取得的常规表锁不会阻塞正常行更新,但锁并没有消失。两个写者仍可能争用同一行;SELECT FOR UPDATE 会明确请求行锁;ACCESS EXCLUSIVE 表锁则与普通读者冲突。因此,MVCC 不承诺所有带有 SELECT 的命令都永远不等待。是否阻塞还需要看语句请求的锁以及当前其他操作。 旧快照还可能让读者需要的旧行版本继续保留。只要某个相关快照仍可能看到该版本,清理就不能直接移除它。空间压力取决于更新量、保留版本和清理进度,不只是打开了多少个事务。再启动一次清理,并不会让仍然活跃的快照忘掉自己的可见性要求;需要沿保留原因调查,而不是只盯住维护命令有没有运行。 快速验证 在同一个可丢弃的 PostgreSQL 18 数据库中,打开甲、乙两个新的 psql -X -v ON_ERROR_STOP=1 会话,使用相同角色,显式事务之外保持正常自动提交。模式名称必须尚未被使用。按下面顺序执行,让乙在甲的读事务仍然打开时完成提交。 在乙中建立一条已经提交的原始记录。 CREATE SCHEMA mvcc_visibility_demo; CREATE TABLE mvcc_visibility_demo.items (id integer PRIMARY KEY,note text); INSERT INTO mvcc_visibility_demo.items VALUES (1,'old'); 在甲中通过读取建立可重复读快照。 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; 在乙中更新原记录、插入第二条记录,并查看已经提交的结果。 UPDATE mvcc_visibility_demo.items SET note='new' WHERE id=1; INSERT INTO mvcc_visibility_demo.items VALUES (2,'other'); SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; 在甲中重读,插入属于自己的第三条记录,再读一次并提交。 SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; INSERT INTO mvcc_visibility_demo.items VALUES (3,'mine'); SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; COMMIT; 甲提交后使用新语句再次读取,随后在乙中清理。 SELECT id,note FROM mvcc_visibility_demo.items ORDER BY id; DROP SCHEMA mvcc_visibility_demo CASCADE; PostgreSQL 18.6 实测中,甲第一次读取返回 (1,old)。乙不需要等待这个普通读者结束,就完成了更新和插入,并看到 (1,new) 与 (2,other)。甲再次读取时仍然只有 (1,old)。当甲插入自己的记录后,它能看到 (1,old) 和 (3,mine),但乙插入的第二行仍不属于甲的快照。这同时展示了他人提交的隔离与自身修改的可见性。 ...

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