观察、决策、行动三步示意图,旁边是复用连接、限制借用的英文标题。

连接池摊薄缓慢的建连

连接池把一次物理建连的成本分摊到多次使用中,但不会让建连成本消失,也不保证创建池时就已经打开连接。先分清两个对象:业务代码短期持有的借用句柄,以及底层真正连接数据库的连接。关闭前者,通常只是把后者归还池中,等待下一次复用,而不是立即断开数据库连接。 复用是一段生命周期,不是免费执行的承诺 远程数据库建连可能包含 TCP 握手、TLS、认证和会话初始化。复用能够减少每个请求重复执行这些步骤的次数,但 SQL 本身仍然需要时间。连接过期、断线、显式失效或进程替换之后,建连成本还会重新出现。一个进程内应长期复用其拥有的 SQLAlchemy Engine;如果每次请求都重新创建 Engine,那么每次也在重新创建自己的池,原本可以复用的资源就被拆散了。 SQLAlchemy 的池按需增长。配置 pool_size=2 的 QueuePool 起初是空的,并不意味着已经预热两条连接。先后执行的两次借用可以一直使用同一条物理连接;同时发生的借用才可能促使池增长。本例允许保留两条连接,再临时溢出一条,所以有限的 max_overflow=1 对应最多三条同时借用。全部归还后,最多留下两条空闲连接,而不是一直维持三条。 这些参数语义属于具体实现,不应套用到所有连接池。QueuePool 的 pool_size=0 表示取消大小限制,而不是关闭池化;max_overflow=-1 则允许无限溢出。需要限制数据库连接预算时,必须核对所用库的准确契约,并写出明确、有限的值,不能看到零就自行理解为禁用。 坏掉的样子 池被占满,可能是实际并发超过容量,也可能是长事务或借用后未归还。业务看到的总延迟因此需要拆成获取连接的等待与执行查询的时间。pool_timeout 限制的是等待可用槽位,不是新建物理连接的完整耗时,也不是 SQL 执行时间。实验用了很短的浮点数超时,便于触发失败,但这不是精确到几十毫秒的硬实时承诺,不能据此推导整个请求的截止时间。 归还也不等于抹掉上一个借用者留下的所有状态。SQLAlchemy 的默认归还重置通过回滚清理事务状态,并与 Connection.close() 协同完成;已经提交的临时表等会话状态仍可能保留。不要仅凭“用了上下文管理器,连接已经还回去”就推断租户隔离成立。应用需要哪些会话初始化与清理,应按具体数据库的能力和自身契约明确规定。 断线则是另一个边界。显式 invalidate 会丢弃当前底层 DBAPI 连接,之后使用逻辑 Connection 时,再从池取得替代连接。这个替代品可能是另一条现成空闲连接,不一定刚刚新建。如果失效时已有事务,必须先结束该事务才能重连;重连也不是自动恢复一个可能已经产生效果的事务,不能把连接生命周期管理当成业务重试保证。 快速验证 在 macOS 或 Linux 的空目录执行 python3 -m venv .venv,再执行 .venv/bin/python -m pip install SQLAlchemy==2.0.52。把代码保存为 pool_probe.py,用 .venv/bin/python pool_probe.py 运行。本次验证环境是 Python 3.14.4 与 SQLite 3.53.0。脚本自行创建并清理临时数据库,不连接任何数据库服务器,也不使用生产凭据。 ...

一月 28, 2027 · 2 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

迁移是可版本化的 schema 变更

数据库的 schema 不是一次性的设计;它随使用它的软件一起演进。与其临时编辑 schema,不如用迁移应用一组命名、有序的变更,让任何环境都能重放。 把 schema 当代码 把 schema 当源代码一样对待:每一条迁移都是一个带顺序、带编号、被命名的小步骤,把库从一个状态推进到下一个状态,整个序列都收进仓库。全新的库从空库开始重放这些步骤直到当前版本,这正是测试建库、临时环境克隆、安装新机器的统一口径;没有人再对着线上实例随手敲一条 ALTER TABLE。schema 也因此从「某个实例才知道的事实」变成有历史、可 review、有 diff 的正式产物。 顺序就是契约 迁移按既定顺序执行,于是任何从零开始的环境都会落在同一个已知状态。这种可预测性就是契约:它让测试能创建全新数据库、让暂存能克隆,让运维升级时不用靠猜。随之而来的铁律是——凡是已经被任何环境执行过的迁移,都不得重写或重排,因为它的校验和或文件名本身就是「已经执行过」的证据。篡改历史,环境就开始分叉,再也无法证明一致性。 危险在不可逆 删除列或表的迁移不可逆,必须谨慎对待。给它们配一张清单:先备份,把 drop 拆成单独的后续迁移,这样能先回滚前面的 schema 改动,最后再面对不可逆的破坏。真正的纪律是「只能向前、但留有退路」——绝不要写一条把维护者锁在门外的迁移。上线前一定要在 staging 上跑一遍这类破坏性迁移,让失败模式在真正投入生产前就变成一件平淡无奇的事。 坏掉的样子 最贵的一种坏法,是把不可逆迁移直接送上生产。在 PostgreSQL 里,删除列在新版本中只是廉价的元数据操作,许多类型转换或新增带易变默认值的列会在 ACCESS EXCLUSIVE 锁下重写,但二进制兼容的类型转换可能避免重写,读写请求全部排在部署后面。大型重写可能长期等待或运行。PostgreSQL 的事务性 DDL 失败后可以回滚;是否留下部分结果,取决于先前提交、非事务步骤与执行器事务边界,不能把超时一概理解为半张表已经改好。而 down 迁移并不能撤销数据丢失:删掉邮箱列再加回来,得到的是空列,而不是被删掉的地址。破坏性步骤应当拆成独立的后续迁移,上线前先备份,优先使用扩张再收缩的顺序,先加新结构,再回填数据,最后删除旧结构,让每一次发布都平淡无奇。换句话说,只要迁移涉及删数据,就默认它回不来。 另外两类经典故障是并发双跑和篡改历史。如果两个发布副本在没有任何锁的情况下同时执行迁移,它们可能重复应用同一版本,也可能把版本顺序搅乱。生产级的 runner 因此只允许单个执行者,或者借助 PostgreSQL 咨询锁之类的机制加锁,而 golang-migrate 在失败后会把库标记为 dirty,而不是假装一切正常。重写已经被应用过的迁移,或者合并了两个各自新增版本 7 的分支,都会让历史分叉:环境可能分叉。还要检查执行器到底保存什么:golang-migrate 的 PostgreSQL 账本保存版本与 dirty 状态,并不为每份文件保存内容校验和。先核对实际状态,再设计前向修复,不能假定工具自动发现所有历史编辑。strong_migrations 这类检查工具能在进入评审之前拦下危险操作。 ...

一月 24, 2027 · 4 分钟 · XB Field Notes
标题卡写着“为事务重试设置上限”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

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

重试可以帮助恢复因为并发而被拒绝的事务,但不能保证成功,也不会自动让外部副作用变成幂等。重试单位应包含决定写入内容的所有数据库读取与应用判断。如果新事务仍使用第一次快照算出的金额或目标值,即使执行了新的开始事务命令,也没有真正重新做出适合当前数据的决策。 坏掉的样子 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
表结构先加后改,分阶段走,流量始终不中断。

零停机迁移在旧代码运行时就改表结构

一条语句就重写完繁忙大表的迁移,既耗时长又全程加锁。把它换成一系列小而向后兼容的改动,同一份表结构照样能往前推,而且不需要停机窗口——原因不是技巧,而是每一步都能单独部署、单独回退。 真正被管理的东西是一把锁。每一次结构变更都会取锁;问题只在于锁被拿多久,而答案取决于这条语句要碰多少数据。 先扩展,再回填,后收缩 三个阶段:扩展先加上新列,同时旧结构继续可用;回填负责把数据灌进去,而且必须分批;收缩只在没有任何代码再读旧结构之后,才把它请出去。 夹在扩展和回填之间的是双写阶段:应用会同时写新旧两列,这样回退不丢任何东西,等新列可信了再把读切过去。 旧代码还需要旧结构,于是顺序规则也就定了:加一个可空列是安全的,删列不安全,改名要拆成两步,NOT NULL 永远排在回填之后。在一张 50 万行的 sqlite 表上,ADD COLUMN note TEXT 耗时 2.0 ms,页数仍停在 13926——这是一次只改元数据的编辑。把它改成 NOT NULL 则必须真的重写:115.4 ms,页数从 13926 变成 28532。同一个结构变更,两种代价,区别只在于引擎要不要碰每一行。 分批比阶段顺序更重要。50 万行放在一个事务里回填,写锁被持有 671.2 ms;同样这些行按每批 2 万切分,共 25 个事务,锁持有时间中位数 7.3 ms、p90 是 7.8 ms。总工作量完全一样——不同的只有最长的那一次持锁,而那恰好就是应用真正感受到的数字。 锁才是全部问题 PostgreSQL 的 ACCESS EXCLUSIVE 与所有其他锁模式都冲突,包括一条裸 SELECT 取的 ACCESS SHARE。TRUNCATE、VACUUM FULL、不带 CONCURRENTLY 的 REFRESH MATERIALIZED VIEW,以及很多 ALTER TABLE 形式都会取它。读者和写者一起等。 ...

十一月 3, 2026 · 2 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

SQLite:嵌入应用的文件数据库

SQLite 是链接进应用的数据库引擎,而不是单独运行的数据库服务器。主数据库文件以可移植格式保存模式、表和索引,因此适合本地应用状态、嵌入式设备,以及写入负载符合其并发模型的服务。这个文件依然受事务与锁管理,不能只凭普通文件复制的直觉来操作它,否则容易把方便的存储形式误解成没有数据库运行规则。 坏掉的样子 主文件并不总是当前数据库状态的全部。WAL 模式可以让已经提交的变更仍位于独立 -wal 文件中,而主文件停留在更早的检查点。连接之间的共享内存协调通常还会使用-shm 旁路文件。因此,连接保持活动时只复制主文件,可能漏掉已经提交的数据;依次复制几个正在变化的文件,也不会自动组成一致快照。 WAL 模式改善读写之间的重叠执行,但仍然只允许一个写者。一个长写事务可能让另一个写者收到数据库忙错误。等待更久只是增加耐心,不会增加同时写入者的数量。应缩短事务,并在实际测得的写入并发或远程访问要求超出这种架构的适用范围时,选择有独立服务端的数据库,而不是一味调大超时。 读者也有快照边界。另一个连接持有未提交更新时,读者仍能看见较旧的已提交值。这体现的是事务隔离,并不证明 SQLite 丢失了那次修改。错误之后,应用应检查事务状态,只有操作语义允许时才重试适当范围的事务。不能把所有忙错误、旧值和提交失败都归为同一种可以无限重试的现象。 快速验证 把下面内容保存为 sqlite_lab.py,使用普通 python3 运行并保持断言开启。它只使用标准库和本地临时文件。本次环境是 Python 3.12.14 搭配 SQLite 3.53.1;可以读取模块的引擎版本属性确认,不能用 Python 版本替代实际链接的数据库版本。所有文件都由实验自己创建,不需要连接任何已有数据库。 import shutil import sqlite3 import tempfile from contextlib import closing from pathlib import Path with tempfile.TemporaryDirectory(prefix="xbcatm-sqlite-") as directory: root = Path(directory) database = root / "source.db" with closing(sqlite3.connect(database, isolation_level=None, timeout=0.05)) as first, \ closing(sqlite3.connect(database, isolation_level=None, timeout=0.05)) as second: assert first.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",) first.execute("PRAGMA wal_autocheckpoint=0") second.execute("PRAGMA wal_autocheckpoint=0") first.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY, value INTEGER)") first.execute("INSERT INTO marker VALUES (1,10)") assert first.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() == (0, 0, 0) first.execute("BEGIN IMMEDIATE") first.execute("UPDATE marker SET value=99 WHERE id=1") assert second.execute("SELECT value FROM marker").fetchone() == (10,) try: second.execute("BEGIN IMMEDIATE") except sqlite3.OperationalError as error: assert error.sqlite_errorcode == sqlite3.SQLITE_BUSY assert not second.in_transaction print("second_writer", error.sqlite_errorname) else: raise AssertionError("second writer unexpectedly acquired the lock") first.execute("ROLLBACK") second.execute("BEGIN IMMEDIATE") second.execute("UPDATE marker SET value=20 WHERE id=1") second.execute("COMMIT") assert first.execute("SELECT value FROM marker").fetchone() == (20,) # Deliberately unsafe copy, only as a negative control in this quiet fixture. shutil.copyfile(database, root / "main-only.db") with closing(sqlite3.connect(root / "main-only.db")) as incomplete: assert incomplete.execute("PRAGMA integrity_check").fetchone() == ("ok",) assert incomplete.execute("SELECT value FROM marker").fetchone() == (10,) print("main_only_copy", "integrity ok, stale value 10") with closing(sqlite3.connect(root / "backup.db")) as backup: first.backup(backup) assert backup.execute("PRAGMA integrity_check").fetchone() == ("ok",) assert backup.execute("SELECT value FROM marker").fetchone() == (20,) first.execute("UPDATE marker SET value=30 WHERE id=1") assert backup.execute("SELECT value FROM marker").fetchone() == (20,) print("online_backup", "value 20, source now 30") 夹具先建立已经完成检查点的值十,然后开启写事务,把它改成九十九但不提交。另一个连接仍然读到十;它试图成为写者时,必须得到数据库忙错误,并且仍然处于事务之外。脚本随后回滚第一个写者,让第二个连接取得写入位置,再成功提交二十。这同时观察到写入竞争与回滚后的正常推进。 ...

十月 20, 2026 · 2 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

停滞的复制槽会继续保留 WAL

PostgreSQL 复制槽记录某个消费者需要保留的材料。一旦槽已经预留日志位置,消费者连接消失,并不会自动解除这份要求。主库可能继续生成日志,而槽的保留起点一直不动。实际风险是磁盘保留量增长,以及追赶还是重建越来越难取舍。槽是否可用,取决于必需日志是否仍然存在,以及实际配置的失效规则。 坏掉的样子 新建物理复制槽不一定立即保留 WAL。如果没有请求立即预留,在槽第一次使用之前,重启位置可能始终为空。因此,仅凭不活跃标志不能量化保留量。需要结合槽类型、预留位置、可用状态和消费者归属来理解。对于逻辑槽,已确认刷新位置描述消费者确认的进度,也不能替代对更早重启位置及其保留义务的分析。 删除槽解除的是这个槽的要求,不一定会在同一瞬间删除文件。检查点清理和其他保留条件仍然有效。别的复制槽、归档积压或日志预留,都可能继续保留文件;回收还可能只是重命名供将来复用,并不把全部已分配字节交还文件系统。多个消费者需要的日志区间可以重叠,所以把每个槽的滞后量简单相加也会重复计算。 保留限制是在两种风险之间做交换。槽的最大日志保留量在检查点附近执行,不是瞬间限制磁盘大小的硬配额。落后足够多的槽可能失去必需日志并变得不可用。这样减少无限保留风险,代价是放弃该消费者现有的追赶路径。仅仅静默时间长,不足以证明可以删除一个仍有真实消费者依赖的槽。 快速验证 在 Linux shell 环境里,以非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端和客户端程序。本次实际执行版本是 PostgreSQL 18.6。私有临时集群不打开 TCP 监听,客户端通过私有 Unix 套接字连接,里面只有实验用复制槽。少量有日志写入配合强制切段,使保留现象快速可见;这不是对真实业务日志生成量的性能基准。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-slots.XXXXXX) mkdir "$lab/socket" cluster=$lab/data cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi } trap cleanup EXIT initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica max_replication_slots = 4 max_slot_wal_keep_size = -1 min_wal_size = '32MB' max_wal_size = '64MB' checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.log" -w -t 15 start >/dev/null export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un) export PGCONNECT_TIMEOUT=2 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT * FROM pg_create_physical_replication_slot('unused_slot'); SELECT active,restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot'; DO $$ BEGIN IF (SELECT restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot') IS NOT TRUE THEN RAISE EXCEPTION 'unexpected immediate reservation'; END IF; END $$; SELECT pg_drop_replication_slot('unused_slot'); CREATE TABLE churn (value text); SELECT * FROM pg_create_physical_replication_slot('held_slot',true); SQL segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_walfile_name(restart_lsn) FROM pg_replication_slots WHERE slot_name='held_slot'") psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT active,wal_status,pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot'; DO $$ BEGIN IF (SELECT NOT active AND wal_status='extended' AND pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot') IS NOT TRUE THEN RAISE EXCEPTION 'inactive slot did not retain the expected WAL range'; END IF; END $$; SQL test -f "$cluster/pg_wal/$segment" echo inactive_slot_retains_old_segment psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_drop_replication_slot('held_slot')" >/dev/null test -f "$cluster/pg_wal/$segment" echo drop_did_not_immediately_remove_segment psql -X -v ON_ERROR_STOP=1 -qAt -c CHECKPOINT test ! -f "$cluster/pg_wal/$segment" echo checkpoint_released_old_segment_name psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' ALTER SYSTEM SET max_slot_wal_keep_size='32MB'; SELECT pg_reload_conf(); SQL attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT current_setting('max_slot_wal_keep_size')='32MB'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT * FROM pg_create_physical_replication_slot('limited_slot',true); DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT wal_status,invalidation_reason FROM pg_replication_slots WHERE slot_name='limited_slot'; DO $$ BEGIN IF (SELECT wal_status='lost' AND invalidation_reason='wal_removed' FROM pg_replication_slots WHERE slot_name='limited_slot') IS NOT TRUE THEN RAISE EXCEPTION 'slot limit did not invalidate the test slot'; END IF; END $$; SELECT pg_drop_replication_slot('limited_slot'); SQL printf 'lab_directory %s\n' "$lab" 第一个物理槽创建时不立即预留。输出中的假与真,分别表示槽不活跃、重启位置为空。脚本对此作出断言,再删除这个尚未使用的夹具。下一个槽明确请求预留,而且始终没有消费者连接。经过写入、切段和检查点后,观察到不活跃、保留范围超出最大 WAL 目标,以及按日志位置差计算落后超过六十四兆二进制字节的状态。 ...

十月 19, 2026 · 2 分钟 · XB Field Notes
关于窗口函数与行上下文的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

窗口函数把上下文带到每一行

窗口函数让报表保留每条读数,同时附加相关行的信息。一条传感器读数可以展示前值、变化量和在该传感器总量中的占比,而不必把整个传感器压缩成一行结果。能否正确解释这些附加列,取决于计算前是否明确了输入总体、分区和顺序,不能只看函数名称就认定答案符合业务。 给相邻关系明确的含义 PARTITION BY 用来划分相互独立的序列。按 sensor 分区的 lag,只比较同一传感器的值;漏掉分区条件,可能把某个传感器的第一条读数与另一个传感器的最后一条相比较。OVER 内部的 ORDER BY 决定谁是前一行。可以使用稳定的序列键;时间戳可能重复时,则应使用时间戳加唯一标识,明确同一时刻的先后关系。 lag 找的是这个有序总体中的前一行,不一定是前一分钟、前一天或上一个计划采集时刻。采集有缺口时,数据库不会自动补出缺失行。如果计算要求与前一个自然日比较,应明确建立日历关系,或先构造所需的时间序列,再做窗口计算。表里相邻的两行,并不能证明采样间隔固定。 在分区开头,lag 返回默认值,通常是 SQL NULL,用来表示没有前驱行。把它改成零,会改变首条变化量的含义,应有业务规则支持。PostgreSQL 的 lag、lead 也会保留空值语义:如果前一条读数本身为空,它不会自动越过这条记录,继续向前寻找更早的非空值。不存在前驱与前驱值为空,需要在业务解释中保持区别。 在明细旁保留总额与占比 聚合函数配合 OVER 使用时,会保留输入行,并为每行增加计算结果。sum(value) OVER (PARTITION BY sensor) 没有窗口排序,因此重复显示整个传感器分区的总额。如果未考虑默认帧就加上排序,总额可能变成累计额,随后每一行的占比分母都会改变,这不是无关紧要的展示调整。 需要小数占比时,应在除法之前转换为 numeric。整数相除可能截断小数,让较小贡献看起来全部为零。示例使用 NULLIF 把零分母转换成 SQL NULL,使无法定义的占比保持未定义状态。这个处理是一条明确规则;改为零、拒绝数据集或展示另一个状态,都是不同的业务选择,不能让算式偶然决定。 并非每个窗口函数都使用移动帧。聚合和 last_value 等函数依赖帧,而 lag、lead 在分区行之间移动。仅仅缩小 ROWS 范围,并不会重新定义 lag 所指向的前一条分区记录。应分别为每个问题选择函数和窗口,不能把一条帧公式机械地套在所有计算上。 ...

十月 18, 2026 · 2 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

预备计划必须证明部分索引谓词

一个任务队列可能只有一百条待处理记录,却保留了成千上万条已完成记录。如果主要请求都是领取待处理任务,只为这个子集建立索引就可能更合适。PostgreSQL 把它称为部分索引,索引谓词决定哪些表行拥有条目。收益仍取决于真实查询和状态变化,不能把“所有索引越小越好”当成脱离业务的优化目标。 坏掉的样子 条件之间的蕴含方向不能写反:查询条件必须蕴含索引谓词,也就是查询可能需要的每一行都在索引覆盖范围内。只收录待处理任务的索引,不能完整回答没有状态限制的全表查询。两个条件看上去不矛盾,也不代表规划器能证明它们之间的关系。PostgreSQL 不会识别所有数学上等价的写法,所以让谓词接近应用实际过滤表达式,也有助于人工审阅和计划识别。 哪些参数已经知道,同样由计划阶段决定。预备语句的定制计划,可以使用这次传入的状态值;通用计划却必须对所有可能的参数都成立,不能假定每次调用永远查询待处理状态。因此,手工测试常量查询时看到索引被使用,并不能直接证明应用长期执行的预备语句也会选择同一条访问路径。 快速验证 在 PostgreSQL 18 的可丢弃数据库中,使用一个全新的 psql -X -v ON_ERROR_STOP=1 会话按顺序执行。实验没有建立覆盖全表的其他索引,便于观察资格差异。先创建一万条任务,其中编号为一百整数倍的记录处于待处理状态: CREATE SCHEMA partial_demo; CREATE TABLE partial_demo.jobs (id integer,state text); INSERT INTO partial_demo.jobs SELECT g,CASE WHEN g%100=0 THEN 'ready' ELSE 'done' END FROM generate_series(1,10000) AS g; CREATE INDEX ready_jobs ON partial_demo.jobs(id) WHERE state='ready'; VACUUM (ANALYZE) partial_demo.jobs; SELECT state,count(*) FROM partial_demo.jobs GROUP BY state ORDER BY state; 分组结果应是已完成九千九百条、待处理一百条。这里索引键是任务编号,谓词检查的是状态,两者不要求是同一个字段。先检查带常量条件的计划: ...

十月 17, 2026 · 1 分钟 · XB Field Notes