标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

Postgres 连接很贵,要复用与池化

温热的连接池能够减少反复建立 PostgreSQL 连接,但空闲连接在池里等待期间也可能消失。服务器结束会话、网络故障或中间设备的空闲策略,都可能让客户端仍握着一个已经不能使用的套接字。复用方案必须包含失效识别和替换;对象还在池里,只说明应用保存了它,不能证明下一条查询一定成功。 坏掉的样子 TCP 保活能够识别原本空闲连接上的部分故障,却不会替 SQL 设置执行截止时间,也不证明数据库能够完成某个具体请求,更不会处理结果未知的提交。客户端连接参数只作用于 TCP,使用 Unix 域套接字时相关选项会被忽略;服务器端保活设置控制的是另一端。具体操作系统是否支持某个选项,也会影响最终行为,不能只看参数字符串存在就宣称故障检测已经发生。 借出前健康检查,可以在把连接交给调用方之前识别已经失效的连接。Psycopg 默认不执行这种检查,但提供回调让应用选择启用。多一次探测意味着额外往返,也仍然无法阻止连接在探测成功后立刻出故障。因此,建立连接、等待租用和执行语句,都需要各自的预算与错误处理,不能用一个“保活开启”覆盖全部阶段。 快速验证 准备 Python 3.10 或更新版本,通过 TCP 连接使用默认零值语句超时的 PostgreSQL 18,并安装原演练使用的版本:python3 -m pip install "psycopg[binary]==3.3.5" "psycopg-pool==3.3.1"。把 PG_DSN 设置为可丢弃数据库,使用允许结束自己后台连接的角色。保存为 liveness-demo.py 后运行。程序先记录每个测试池刚创建的连接标识,然后只结束那条已经归还、处于空闲状态的连接: import os import psycopg from psycopg.errors import QueryCanceled from psycopg_pool import ConnectionPool dsn = os.environ['PG_DSN'] with psycopg.connect(dsn, autocommit=True) as admin: for checked in (False, True): with ConnectionPool( dsn, min_size=1, max_size=1, timeout=3, open=True, check=ConnectionPool.check_connection if checked else None, kwargs={ 'connect_timeout': 2, 'keepalives': 1, 'keepalives_idle': 30, 'keepalives_interval': 10, 'keepalives_count': 3, }, ) as pool: pool.wait(timeout=3) with pool.connection() as conn: assert conn.execute('SELECT inet_server_addr() IS NOT NULL').fetchone()[0] original_pid = conn.info.backend_pid parameters = conn.info.get_parameters() assert [parameters[k] for k in ( 'keepalives', 'keepalives_idle', 'keepalives_interval', 'keepalives_count' )] == ['1', '30', '10', '3'] # Terminate only the idle connection just created by this fixture. assert admin.execute('SELECT pg_terminate_backend(%s,2000)', (original_pid,)).fetchone()[0] if not checked: try: with pool.connection() as conn: conn.execute('SELECT 1') except psycopg.OperationalError as error: print('unchecked_first_use', error.sqlstate) else: raise AssertionError('terminated connection unexpectedly worked') with pool.connection() as conn: replacement_pid = conn.info.backend_pid assert replacement_pid != original_pid assert conn.execute('SELECT 1').fetchone()[0] == 1 print('checked', checked, 'replacement', True) try: with pool.connection() as conn: conn.execute("SET LOCAL statement_timeout='50ms'") conn.execute('SELECT pg_sleep(0.2)') except QueryCanceled as error: assert error.sqlstate == '57014' print('statement_timeout', error.sqlstate) else: raise AssertionError('long statement did not time out') with pool.connection() as conn: assert conn.info.backend_pid == replacement_pid assert conn.execute('SHOW statement_timeout').fetchone()[0] == '0' assert conn.execute('SELECT 1').fetchone()[0] == 1 print('healthy_after_rollback', True) 两种情况都配置了客户端保活参数,并核对有效值,不打印整个连接串。第一种没有借出前检查。空闲后端被服务器结束后,下次读取遭遇操作错误;本次这种明确的服务器关闭返回 57P01。池随后丢弃坏连接,下一次借用得到不同的后端标识,并成功返回一。 ...

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

Postgres 序列用一个缓存发号

PostgreSQL 序列缓存为数据库后端预留数值,不属于某个应用对象,也不只属于一笔事务。理解这个边界,才能解释为什么序列保存的位置会领先于真正返回的号码,为什么重新连接也会留下空洞,以及为什么调小 CACHE 不能让标识连续。首先要区分分配器状态,与应用真正成功提交的业务记录,不能把两者当成同一个计数器。 三个不同的数字 一个新建的升序序列使用 CACHE 5 时,第一次 nextval 可以返回一,而 last_value 已记录五。后者是预留边界,不是已插入行数,也不表示一到五都已经交给应用。currval 表示当前会话最近为该序列建立的值,其他连接取号不会替换它。监控时若不说明这些字段的作用域,就很容易把预留数量误当成业务数量。 同一后端后续取号,可以使用已经预留的数值。PostgreSQL 实现中既有后端局部的快速路径,也有补充缓存时的缓冲锁;把每次 nextval 都说成对普通表记录加一把行锁并不准确。缓存减少反复访问序列状态,但不能据此认定某个应用的插入延迟主要来自序列竞争。修改参数前,应先测量真实瓶颈,而不是根据取号机制猜测整体性能。 会话寿命也影响空号 即使正常退出,会话结束时没有用完的预留值也会被放弃。因此,相同取号速率下,频繁建立数据库会话的服务,可能比使用长期连接池的服务放弃更多预留。缓存跟随服务器会话;应用看到一个连接对象,并不足以说明底层数据库会话是否一直存在,连接复用策略也是实验条件的一部分。 不同会话还可以分别持有不同区间。一个先返回一,另一个返回六,第一个随后再返回二。这是分配交错,不代表提交顺序,也不能证明这些值已经丢失。即使 CACHE 为一,事务仍可能先取号后提交,顺序与另一个事务相反。提交之前写入的时间戳同样不能自动提供真实提交顺序,不能只给字段换个名字就获得新的保证。 坏掉的样子 不要把 CACHE 当成崩溃后最大跳号数的公式。多个会话可以同时持有预留,而实现还会把一部分未来序列进度提前写入日志。事务回滚、走冲突处理分支的插入,以及取到但没有使用的值,都会增加空洞。缺号本身既不能证明丢了业务数据,也不能证明没有丢;发现故障时,应核对已提交记录和恢复证据,再判断是否只是分配器行为。 正常运行期间,非循环分配在管理员没有把序列重置回已用范围时,会提供不同数值。但循环、手工重置,以及显式导入编号,都可能改变这个前提。表仍然需要主键或唯一约束保证存储完整性。减小缓存不能代替这些约束,不能找回已经删除的记录,也不会自动让业务编号流程具备可审计性。在数据库外持久使用一个号码之前,应先提交取号事务;崩溃可能使尚未提交的序列变化是否保留变得不确定。 调整序列设置时,还要考虑活跃会话已经拿到的预留值。不要为了补洞,在并发写入期间把序列随意重置到表中最大编号。如果业务确实要求已签发号码连续,应先明确签发、回滚、取消和外部发布的边界,再评估事务计数器。没有经过实际负载测量,就不存在可以普遍承诺的最佳缓存大小或吞吐提升倍数。 快速验证 下面的无套接字练习,使用两个先后运行的单用户后端,访问同一个新初始化的可丢弃集群。以非 root 账户执行脚本,并把 PostgreSQL 18 的 initdb 与 postgres 放入 PATH。绝不能让单用户模式访问正在运行的集群。该模式具有隐式超级用户权限,不能验证并发客户端、真实进程间锁竞争或故障切换;本例只研究缓存预留的生命周期。 #!/bin/sh set -eu umask 077 export LC_ALL=C lab=$(mktemp -d "${TMPDIR:-/tmp}/xbcatm-sequence-cache.XXXXXX") initdb -D "$lab/data" --no-locale --encoding=UTF8 \ --auth-local=trust --auth-host=reject >"$lab/init.log" 2>&1 postgres --single -j -D "$lab/data" -c io_method=sync -c log_min_messages=notice postgres \ >"$lab/session-a.log" 2>&1 <<'SQL' CREATE SEQUENCE cache_probe CACHE 5; SELECT nextval('cache_probe') AS first_value; SELECT last_value AS reserved_through FROM cache_probe; BEGIN; SELECT nextval('cache_probe') AS rolled_back_value; ROLLBACK; SELECT nextval('cache_probe') AS after_rollback; DO $$ BEGIN IF currval('cache_probe') <> 3 OR (SELECT last_value FROM cache_probe) <> 5 THEN RAISE EXCEPTION 'cache reservation or rollback result wrong'; END IF; RAISE NOTICE 'cache_probe_a_ok'; END $$; SQL if grep -E '(ERROR|FATAL|PANIC):' "$lab/session-a.log"; then exit 1; fi grep -q 'cache_probe_a_ok' "$lab/session-a.log" postgres --single -j -D "$lab/data" -c io_method=sync -c log_min_messages=notice postgres \ >"$lab/session-b.log" 2>&1 <<'SQL' SELECT nextval('cache_probe') AS after_clean_session_end; DO $$ BEGIN IF currval('cache_probe') <> 6 OR (SELECT last_value FROM cache_probe) <> 10 THEN RAISE EXCEPTION 'unused cache values were unexpectedly reused'; END IF; RAISE NOTICE 'cache_probe_b_ok'; END $$; DROP SEQUENCE cache_probe; SQL if grep -E '(ERROR|FATAL|PANIC):' "$lab/session-b.log"; then exit 1; fi grep -q 'cache_probe_b_ok' "$lab/session-b.log" cat "$lab/session-a.log" "$lab/session-b.log" printf 'retained_lab %s\n' "$lab" 完整脚本已在 PostgreSQL 18.6 执行。第一个后端返回一,记录预留边界五,在随后回滚的事务中分配二,再返回三。断言核对缓存边界仍为五。单用户模式的 -j 使用空行划分命令,因此脚本保留必要的空行,确保事务边界没有因整段输入被视为同一条命令而改变。观察结果时,也需要区分真正执行的语句与后端输出的提示文字。 ...

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

Postgres JSONB 搭配 GIN 做灵活查询

PostgreSQL 的 JSONB 列可以容纳形态不同的文档,但高效查询仍要求条件与索引有准确的对应关系。对整篇文档建立 GIN 索引,只能支持特定的 JSONB 算子,不能自动加速所有从文档提取字段的表达式。先确定数据类型和查询语义,再选择索引,才能避免建完索引却没有收益。 让算子与索引对应 包含条件 payload @> '{"status":"pending"}' 检查文档是否包含该键及对应的 JSON 字符串值。采用默认 jsonb_ops 操作符类时,整列 GIN 可以支持包含、顶层键存在判断,以及能够提取索引条件的 JSON 路径查询。索引保存可检索的组成部分;位图堆扫描仍可能访问表,并重新检查候选行,不能把它理解为完全不用回表。 payload->>'status' = 'pending' 则是在比较提取后的文本。payload 上的 GIN 并不是这个文本表达式的索引。对 (payload->>'status') 建立 B-tree 表达式索引,才与标量等值查询对应。如果很多查询需要对 status 排序、关联或施加约束,独立的有类型列可能更清楚,也能减少不断从灵活文档中推导字段的成本。 不能未经类型检查,就把所有文本条件改写成包含条件。JSON 数字和 JSON 字符串经过文本提取后可能得到相同文本,但包含算子会区分它们的 JSON 类型。只有数据约束保证相关前提时,改写才等价。应用应通过数据库驱动绑定参数,而不是把不可信输入拼接进 SQL 或 JSON 字符串;索引选择和输入处理需要分别正确。 ...

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

Postgres 索引的存在意义就是回答问题

索引的价值在于改变真实查询需要完成的工作,索引了多少列并不是有效的成功指标。一个能直接找到某位客户最新三笔订单的窄 B-tree,可能胜过无法提供所需顺序的宽索引;读取几乎全部行的查询,则可能正确地忽略两者。评估时要把访问方式、排序和返回数据量放在一起,而不是看到索引名称就宣布优化完成。 从需要的结果出发 假设应用要查询某位客户最新三笔订单。customer 的等值条件缩小候选范围,placed_at 降序选择该客户最新记录,再用 id 作为最后决胜键,处理时间戳相同的情况。(customer, placed_at DESC, id DESC) 就表达了这条查询需要的键顺序。每个键都应对应可解释的条件或排序要求,而不是根据列名凭直觉添加。 收益不只是避免全表扫描。有序索引路径可以取得所需的有限结果后就停止,不必先收集该客户全部订单再排序。去掉 LIMIT,或改成返回大部分行后,成本平衡可能变化。因此同一个索引必须结合完整 SQL 和常用参数评价,不能只盯 WHERE 的一个片段,更不能把少量结果的计划当成大规模导出的保证。 WHERE 条件的文本顺序不会定义索引顺序。PostgreSQL 规划逻辑表达式,把 customer 等值写在 AND 前面或后面,不会重排 B-tree。真正相关的是索引方向、空值顺序、前导键限制和最终 ORDER BY。修改查询时应明确核对这些结构属性,而不是套用“列写在前面就能用索引”的笼统规则。PostgreSQL 18 还能在缺失前导键的不同值较少时,对某些后续键条件采用 B-tree skip scan;没有前导等值条件,并非绝对禁止使用索引。 如实读取执行计划 EXPLAIN 展示计划工作,EXPLAIN ANALYZE 会真正执行并报告实际行数和循环次数。查询返回表中大部分数据,尤其需要索引中没有的宽列时,顺序扫描可能是正确选择。这不证明列顺序错误,也不证明索引创建失败。应把它看成优化器在当前条件下选择的访问成本,而不是必须消除的红色标记。 反过来,出现索引名也不证明查询便宜。索引扫描仍可能访问大量条目和堆元组,bitmap 路径可能还要排序,index-only 计划也可能检查堆可见性。结合 actual rows、适用时的 Heap Fetches、缓冲区活动和 Sort 阅读。顶层有 LIMIT 只有在下层也避免了大量多余工作时才真正省事,不能忽略 LIMIT 下面的完整树。 ...

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

EXPLAIN 让你看清 Postgres 会怎么做

EXPLAIN 打印规划器准备采用的执行计划与代价;EXPLAIN ANALYZE 则真的跑一遍语句,把估算变成测到的行数与时间。它们回答的是两个不同问题:规划器认为应该怎样做,以及数据库实际怎样做。排障时先看前者理解意图,再用后者测量现实,别把 cost 当成秒数,也别在生产库上无准备地执行一条会写数据的 ANALYZE。 计划提供需要核对的估计与观测,而不是单凭行数差异就能完成的诊断。循环次数和提前停止也会制造表面差距;估计准确时,查询仍可能受到锁等待、溢写或大量必要工作的影响。它告诉你读了哪些表、用了什么索引、预计经过多少行,以及每个节点把多少工作交给下一个节点。把计划保存进工单,和请求参数、统计信息更新时间、数据库版本一起记录,几周后的回归才有可比基线。只贴一句“慢”没有证据;保存计划能帮助定位值得检验的假设,但不能自动解释所有请求延迟。 自上而下读形状 文本计划先打印父节点,再缩进打印它的子节点。向下读可以理解节点依赖哪些输入,向上看可以理解结果怎样汇合。执行可能按需发生:上层的 LIMIT 得到足够多的行便会停止索取,下层排序却可能需要读完全部输入才能交出第一行。缩进描述依赖关系,并不是所有节点通用的执行时间顺序。 cost 使用规划器的工作量单位,不是毫秒。父节点总成本包含子节点,不能把整棵树的数字直接求和。启动成本表示返回第一行前预计投入多少工作,总成本表示完成节点的估算。实际时间和实际行数在 loops 大于一时按每次执行取平均;判断重复工作要结合循环次数,同时避免把父节点包含的子节点时间再加一次。 阅读时先问三个问题。第一,计划是否走了预期的访问路径,例如选择性很高的条件是否用了索引。第二,估算行数和实际行数是否在同一个数量级,数量级错了通常比单个节点的 cost 更值得处理。第三,数据流在哪里膨胀或等待,宽大的中间结果会把后面的排序、连接和聚合一起拖慢。沿着这三个问题读,计划就从一堆术语变成了可验证的因果链。 追踪工作量与放大点 行数最多只是线索,不能直接证明瓶颈。连续读取的大表扫描可能比随机访问索引更便宜,一个每次很快却反复执行数万次的节点也可能主导延迟。先找估算与实际第一次发生明显偏离的地方,再追踪它怎样影响后续连接、排序与内存。嵌套循环并非天然错误:外表很小、内表有匹配索引时它可能最快;危险的是外表被严重低估后仍反复访问内表。 把计划格式化为 JSON 可以让工具比较节点,手工排障先用普通 EXPLAIN 确认访问路径,再按需执行带 ANALYZE、BUFFERS 的测量。缓冲命中、缓冲读取、临时文件读写能帮助定位数据访问与排序落盘。缓冲读取不等于真实磁盘读取,因为操作系统缓存也可能提供数据;看到 read 就宣布磁盘慢,证据还不够。VERBOSE 会显示更多输出列和别名,适合定位复杂视图。 用 ANALYZE 让行数和代价对齐 EXPLAIN ANALYZE 会真实执行查询,同时给出估算与实际行数、节点耗时和循环次数。两者出入很大,可能来自统计过时、分布倾斜或列间相关性;重新收集统计有时能改善,但不能保证修好所有偏差。写入实验放到隔离数据库,并设置语句超时。事务回滚只能撤销可回滚的数据库改动,不能撤销已经分配的序列值、函数或触发器对外部系统造成的不可回滚副作用。SELECT 也可能调用带副作用的函数,不能只根据语句开头判断安全。 可以按需提高相关列的统计目标,或者建立描述同一张表中相关列的扩展统计,但这些动作也要由测量验证。扩展统计不能修复所有估计,目前也不是跨表连接选择性的通用信息来源;应先确认它改变了真正有问题的估计,再归因于统计改进。索引收益仍要根据整个查询的工作量评估。若同一语句对小租户很快、对大租户很慢,要用应用实际的预备语句路径比较通用计划和定制计划。PostgreSQL 核心并没有通用的 SQL 提示语法,不要凭空加入提示来强行选择节点。每次只改变一个变量,把结果集、计划和延迟并排保存。 ...

七月 8, 2027 · 1 分钟 · XB Field Notes
持续归档 WAL,实现精确到某一时刻的恢复。

Postgres WAL 归档实现时间点恢复

时间点恢复从一份有效的物理基础备份开始,重放到指定恢复目标所需的 WAL。目标必须位于这份备份及其可用时间线能够覆盖的历史中。开启归档并不会自动获得恢复到任意更早日期的能力,也不会修复基础备份与期望终点之间已经丢失的一段日志。恢复范围由真正保存并可读取的材料决定。 坏掉的样子 复制出来的数据目录不会自动成为有效的在线备份。应采用受支持的备份方式,保存必要日志与时间线历史,并检查备份清单。清单校验程序能发现许多完整性问题,但它运行成功,不能证明更晚的归档日志存在,也不能证明业务期望的恢复目标对应正确状态。后两者需要实际启动恢复,再检查恢复出来的数据。 选择目标也需要明确边界。时间、事务标识、日志位置以及命名恢复点表达的含义不同;对适用的目标类型,是否包含目标边界由对应参数控制。本实验使用命名恢复点,避免依赖人工判断某个墙上时钟时间,但这个标记必须由管理操作事先写入日志。复用同一个名字可能使恢复停在首个同名位置,而不是操作人员心中较新的那个位置。 快速验证 在 Linux 环境中使用非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端、客户端以及支持保留属性复制、临时文件、硬链接和按文件同步的工具。本次在 PostgreSQL 18.6 上实际执行。集群位于私有临时目录,使用独立 Unix 套接字并关闭网络监听。本地归档用于演练恢复机制,不代表能够抵抗整台宿主机丢失。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-pitr.XXXXXX) mkdir "$lab/socket" "$lab/archive" primary=$lab/primary restored=$lab/restored missing=$lab/missing cleanup() { for cluster in "$missing" "$restored" "$primary"; do if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi done } trap cleanup EXIT initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$primary/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica archive_mode = on archive_command = '$lab/archive-copy.sh "%p" "%f" "$lab/archive"' CONF cat > "$lab/archive-copy.sh" <<'ARCHIVE_HELPER' #!/bin/sh set -eu wal_source=$1 wal_name=$2 archive_directory=$3 archive_target=$archive_directory/$wal_name if test -e "$archive_target"; then cmp -s "$wal_source" "$archive_target" sync "$archive_target" "$archive_directory" exit 0 fi archive_temporary=$(mktemp "$archive_directory/.incoming.XXXXXX") trap 'rm -f "$archive_temporary"' EXIT HUP INT TERM cp "$wal_source" "$archive_temporary" sync "$archive_temporary" ln "$archive_temporary" "$archive_target" || cmp -s "$wal_source" "$archive_target" sync "$archive_target" "$archive_directory" ARCHIVE_HELPER chmod 700 "$lab/archive-copy.sh" pg_ctl -D "$primary" -l "$lab/primary.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' CREATE TABLE marker (id integer PRIMARY KEY,value text); INSERT INTO marker VALUES (1,'base'); SQL pg_basebackup -D "$lab/base" -X stream -c fast -w pg_verifybackup -q "$lab/base" psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' INSERT INTO marker VALUES (2,'keep'); SELECT pg_create_restore_point('before_bad_change'); UPDATE marker SET value='bad' WHERE id=1; DELETE FROM marker WHERE id=2; SQL segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_walfile_name(pg_current_wal_lsn())') psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_switch_wal()' >/dev/null attempt=0 while ! test -f "$lab/archive/$segment"; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'archive segment did not arrive' >&2; exit 1; } sleep 0.1 done printf 'primary_now ' psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker" mkdir "$restored" cp -a "$lab/base/." "$restored/" cat >> "$restored/postgresql.conf" <<CONF port = 56433 archive_mode = off restore_command = 'cp "$lab/archive/%f" "%p"' recovery_target_name = 'before_bad_change' recovery_target_action = 'pause' CONF touch "$restored/recovery.signal" pg_ctl -D "$restored" -l "$lab/restored.log" -w -t 15 start >/dev/null attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'restore target was not reached' >&2; exit 1; } sleep 0.1 done PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT pg_is_in_recovery(),pg_get_wal_replay_pause_state(); SELECT id,value FROM marker ORDER BY id; DO $$ BEGIN IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker) IS DISTINCT FROM '1:base,2:keep' THEN RAISE EXCEPTION 'point-in-time result mismatch'; END IF; END $$; SQL test ! -e "$lab/base/pg_wal/$segment" mkdir "$lab/archive-missing" "$missing" for archived_file in "$lab/archive/"*; do if test "${archived_file##*/}" != "$segment"; then cp "$archived_file" "$lab/archive-missing/" fi done cp -a "$lab/base/." "$missing/" cat >> "$missing/postgresql.conf" <<CONF port = 56434 archive_mode = off restore_command = 'cp "$lab/archive-missing/%f" "%p"' recovery_target_name = 'before_bad_change' recovery_target_action = 'pause' CONF touch "$missing/recovery.signal" if pg_ctl -D "$missing" -l "$lab/missing.log" -w -t 15 start >/dev/null 2>&1; then echo 'missing-segment server was briefly available' fi attempt=0 while pg_ctl -D "$missing" status >/dev/null 2>&1; do attempt=$((attempt+1)) test "$attempt" -lt 100 || { echo 'missing-segment server did not stop' >&2; exit 1; } sleep 0.1 done grep -F 'recovery ended before configured recovery target was reached' "$lab/missing.log" test -f "$lab/archive/$segment" echo missing_segment_target_unreachable printf 'lab_directory %s\nrequired_segment %s\n' "$lab" "$segment" 主库最初只有第一行,值为 base。取得并验证基础备份后,脚本切换日志段,再插入值为 keep 的第二行,并创建错误修改之前的命名恢复点。只有在标记之后,才把第一行改成 bad、删除第二行。输出中的主库当前状态应只剩 1:bad,证明错误修改确实已经发生,不能把没有真正写入的错误当作成功排除的对象。 ...

六月 25, 2027 · 3 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

hstore:在一格里装键值映射

hstore 扩展把文本键与文本值组成的扁平映射存进一个 PostgreSQL 值,其中值还可以为空。它适合不同记录拥有不同可选描述属性的情况,但灵活性不会免除重要键定义、值校验和按查询设计索引的工作。权限开关、价格和凭据都需要认真建模,不能因为装在一列里,就被当成没有后果的普通备注。 分清缺失、空值与文本 键不存在,与键存在但值为空,是两种状态。-> 对这两种情况都会返回 SQL NULL;? 用来确认键是否出现,defined 则检查它是否对应非空值。带引号的字符串“NULL”只是普通文本。整列本身还可以是 SQL NULL;如果不需要这个额外状态,应把映射列声明为 NOT NULL,再明确单个属性如何表示未知或缺失。 键不能是空值,键值对的顺序也没有语义。重复写同一个键不会生成列表,而只会留下其中一个值,且没有保证留下哪一个。需要确定性处理时,应在存储前拒绝重复键。应用应使用驱动支持的适配方式或参数化构造函数,不能把不可信字符串直接拼接成 hstore 字面量。驱动是否自动读写字典取决于实际支持和配置,不是任何语言环境都成立的承诺。 映射仍然可以有约束 CHECK 可以检查映射并限制指定属性,所以“数据库无法限制 hstore 内容”的说法不正确。但 SQL CHECK 在表达式结果为未知时也允许通过,单纯比较一个可能为空的提取结果,可能意外放行缺失键或空值。应明确出现条件、空值政策和允许值,再分别测试这些边界。需要时,提取键上的唯一表达式索引也可以约束重复值,并非只有普通列才能声明这种规则。 经常参与关联或业务约束的重要字段,仍然通常更适合独立的有类型列。理由是结构更清楚、操作更简单,而不是映射完全不能校验。增加一个可选键也许不需要改表,但改变键的含义仍可能需要历史数据检查、回填、客户端协调和索引调整。灵活性把这些决策移到另一处,并没有让迁移成本自然消失,更不能保证所有已有记录已经满足新含义。 对齐操作符与转换规则 GIN 和 GiST 支持 hstore 的包含与键存在操作符。提取文本再比较则属于另一种表达式,可能需要单独的 B-tree 表达式索引。给整个映射建 GIN,不意味着所有键值计算都自动有索引。应比较应用最终发出的 SQL、条件选择性和实际计划,再决定是否值得承担存储与写入开销;小表选择顺序扫描,也不能单独证明索引无效。 标准 hstore 到 jsonb 的转换把非空值保留为 JSON 字符串,把空值变成 JSON null,不会猜测文本“12”应该成为数字。反方向没有内置、对称的 jsonb 到 hstore 强制转换;任意嵌套 JSON 需要先定义允许的形状以及具体转换政策。不能把两个方向都描述成一条语句就能无损来回的免费迁移。存储大小也应由真实数据测量,不能承诺 hstore 总是更小。 ...

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

清理在可见性边界允许后回收旧版本

PostgreSQL 更新一行时会产生新版本,删除则让旧版本对后来的快照不可见。但这不意味着所有较早开始读取的事务都已经用完旧版本。普通清理,包括自动回收工作者发起的清理,都必须遵守这个可见性边界,之后才能复用空间。把清理任务安排得更频繁,不能越过仍然有效的读取快照。 坏掉的样子 一个持续很久的快照,可能在删除事务提交以后仍然需要旧行。因此,清理命令可以正常执行,却没有回收预期数量的版本。单纯降低自动回收触发门槛,也不会让这些版本突然变成可删除。应先检查长事务和其他持有回收边界的对象,例如复制槽保留的 xmin,再判断是不是工作者吞吐不足。 回收元组空间,通常意味着同一张表以后可以复用这些位置,并不必然让文件长度下降。普通清理可以截断文件末尾连续的空页,却不会把中间所有存活行搬到一起、整理掉每个孔洞。因此,清理之后磁盘文件大小不变,可能是正常的内部空间复用,不能仅凭这个现象宣布任务没有发挥作用。 快速验证 本轮没有重跑原样保留的双会话快照与清理实验。早先的 PostgreSQL 观察属于历史记录,单用户后端无法复现保留读者的边界。 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验只在自己的测试表上暂时关闭自动回收,并手动执行普通清理以控制时序。它验证的是自动与手动清理共同遵守的可见性规则,不把手动命令当作自动调度已经运行的证据。乙先创建五千行数据: CREATE SCHEMA vacuum_demo; CREATE TABLE vacuum_demo.items (id integer PRIMARY KEY,payload text) WITH (autovacuum_enabled=false); INSERT INTO vacuum_demo.items SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g; SELECT pg_stat_force_next_flush(); VACUUM (ANALYZE) vacuum_demo.items; CREATE TABLE vacuum_demo.baseline AS SELECT pg_relation_size('vacuum_demo.items') AS heap_bytes; 统计刷新辅助函数让待发布计数在下一个机会写出,这里用于减少短实验中的观察滞后。基线记录的是堆文件字节数,不包括索引和其他分支文件。甲开启可重复读事务,读取五千行后保持事务打开: ...

六月 4, 2027 · 1 分钟 · XB Field Notes