一行从 closed 改为 archived,前后都不进入 open 子集,但更改谓词列仍会阻止 PostgreSQL 18 B-tree 示例中的 HOT。

部分索引的写入成本

部分索引筛选的是行,不是裁剪列。给 id 建索引并加上 WHERE status = 'open',保存的是未关闭行的标识符;状态列决定哪些行进入索引,却不因此成为搜索键。估算维护成本时,这个区别很重要:即使某一列的值没有存进索引键,它仍可能影响更新需要付出的代价。 如果订单表大部分是已关闭的历史记录,把这些行排除在索引之外,确实可能省下大量索引空间。但这不代表历史行可以免费写入,也不保证查询一定更快,更不会消除数据表本身的清理工作。判断收益时,要同时看结构缩小了多少,以及维持正确状态仍需承担哪些写入。 成员资格决定哪些行入索引;写入成本还取决于键变化、谓词列依赖和同页 HOT 条件。 区分成员资格与更新成本 新插入的行不满足谓词时,这一个部分索引不需要为它新增条目。谓词判断、表内存储、其他索引以及正常事务处理仍然存在。把一行从子集外改到子集内,需要新增条目;把它改出子集,则会改变当前查询能看见的成员。旧行版本与过时索引条目的物理回收可能发生在之后,不能把逻辑上退出子集理解成立即释放所有存储空间。 成员资格没有变化,也不等于可以跳过索引维护。未关闭订单的标识符改变了,索引键就需要更新。即使只修改一列与索引无关的备注,只要数据库无法采用堆内元组更新,也就是 HOT,新行版本仍可能需要新的索引条目。因此,“谓词结果不变”不足以证明这次更新很便宜。 对本文讨论的 PostgreSQL 18 B-tree 索引,HOT 要求所有索引定义引用的列值保持不变,包括表达式引用的列和部分索引谓词引用的列;原来那张堆页面还必须容得下新行版本。把状态从已关闭改成已归档,两种状态虽然都不满足谓词,仍然改变了索引依赖的状态列,会阻止 HOT。只修改没有被任何索引引用的备注列,则可能满足条件。 ...

二月 15, 2028 · 2 分钟 · XB Field Notes
一千旧版本的条越过新门槛 150,却未达到旧门槛 2050,并注明上限禁用或至少 2050,表示符合条件而非完成清理。

自动清理资格不等于执行完成

繁忙表持续产生旧行版本,维护速度需要与这种产生速度匹配。降低自动回收门槛,可以让表更早具备被处理的资格,但“符合条件”并不等于“已经完成”。工作者仍需轮到这张表、获得所需锁,并在积压继续增长之前完成足够的有效清理。调优应观察这个完整循环,不能把一个参数值当成后台吞吐的保证。 坏掉的样子 普通更新和删除的触发条件,由固定门槛加上表行数估计乘比例因子构成;PostgreSQL 18 在启用上限时,还会应用最大触发门槛。插入触发清理、由事务年龄驱动的冻结,又有各自的运行原因。因此,只看一个死元组比例,会漏掉其他维护需求;每看见一次清理就断言更新删除门槛被触发,也可能误判真正原因。 把门槛调低,不能释放仍被旧快照需要的版本,也不能创造额外的磁盘吞吐。增加工作者或降低成本延迟,还可能加剧与前台查询的资源竞争。让死元组估计在每个瞬间都接近零,既不是通用的现实目标,也不能充分说明业务性能良好。真正需要维持的是可持续的清理能力、可接受的文件增长、请求延迟和维护成本。 2050 到 150 的计算要求有效上限禁用或至少为 2050。资格、自动执行与有效完成仍分开;运行待验证。 快速验证 本轮没有重跑原样保留的后台工作者集成。先前的自动清理观察属于历史记录,手动或单用户清理不能代替它。 使用 PostgreSQL 18 可丢弃实例,确认自动回收与统计收集已启用、有可用工作者,并使用正常的一分钟数据库检查间隔。通过一个全新的 psql -X -v ON_ERROR_STOP=1 连接执行下面的脚本。它最多等待一百三十秒,核对真实自动执行;如果超时,应把这次观察记为失败,不能手动清理一次就冒充自动任务已经运行。 CREATE SCHEMA churn_demo; CREATE TABLE churn_demo.jobs (id integer PRIMARY KEY,n integer) WITH (autovacuum_enabled=false, autovacuum_vacuum_threshold=50, autovacuum_vacuum_scale_factor=0.2); INSERT INTO churn_demo.jobs SELECT g,0 FROM generate_series(1,10000) AS g; SELECT pg_stat_force_next_flush(); VACUUM (ANALYZE) churn_demo.jobs; UPDATE churn_demo.jobs SET n=1 WHERE id<=1000; SELECT pg_stat_force_next_flush(); SELECT n_live_tup,n_dead_tup,vacuum_count,autovacuum_count FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass; SELECT 50+0.2*reltuples AS old_threshold,50+0.01*reltuples AS new_threshold FROM pg_class WHERE oid='churn_demo.jobs'::regclass; ALTER TABLE churn_demo.jobs SET (autovacuum_enabled=true,autovacuum_vacuum_scale_factor=0.01); DO $$ DECLARE deadline timestamptz := clock_timestamp()+interval '130 seconds'; BEGIN LOOP PERFORM pg_stat_clear_snapshot(); EXIT WHEN (SELECT autovacuum_count>0 FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass); IF clock_timestamp()>deadline THEN RAISE EXCEPTION 'no automatic vacuum observed within the experiment budget'; END IF; PERFORM pg_sleep(0.25); END LOOP; END $$; SELECT pg_stat_clear_snapshot(); SELECT n_dead_tup,last_autovacuum IS NOT NULL AS ran,autovacuum_count>0 AS counted FROM pg_stat_user_tables WHERE relid='churn_demo.jobs'::regclass; SELECT count(*),count(*) FILTER (WHERE n=1) FROM churn_demo.jobs; DO $$ BEGIN IF (SELECT count(*) FROM churn_demo.jobs)<>10000 OR (SELECT count(*) FROM churn_demo.jobs WHERE n=1)<>1000 THEN RAISE EXCEPTION 'maintenance changed application data'; END IF; END $$; DROP SCHEMA churn_demo CASCADE; 测试表最初只关闭自己的常规自动回收,让初始化与受控更新先完成。手动清理并分析建立统计,同时清掉初始化插入积压,然后再更新一千行。显式统计刷新调用用于同步这个短实验,避免待发布的写入增量落在手动维护更新估计值之后;它不是建议在线业务频繁调用的调优开关。 ...

二月 4, 2028 · 1 分钟 · XB Field Notes
会话甲持锁,连接会话乙的线被挡住,乙等待;观察者丙指向等待侧。

查询变慢时同时检查计划与等待

一条过去很快的查询,可以在数据、统计和访问计划都没有变化时变慢。另一个事务可能持有它需要的锁,存储缓存可能比上次更冷,应用也可能在语句进入 PostgreSQL 之前就开始等待。重新检查计划很有用,但它应当放在完整的耗时比较中:先区分时间是花在执行工作上,还是花在等待获得执行机会。不能从“现在更慢”直接推导出“数据与统计一定都变了”。 保留可比较的基线 记录语句形状、有代表性的参数值、返回行数、相关会话设置,以及应用是否使用预备语句。不同参数可能命中完全不同规模的数据。手工使用常量的查询,不一定复现预备语句采用的通用计划;不同角色或搜索路径,也可能改变实际对象与权限。因此,拿一条看起来相似的语句作为对照,并不保证比较条件相同。 尽量在变慢正在发生时观察活动。pg_stat_activity 可以显示一个状态为运行中的查询正在等待锁;运行中并不等于持续消耗处理器。pg_blocking_pids 列出当前阻塞这个后端的会话。查看其他角色活动需要适当权限,因此字段不可见不等于没有工作发生。交互观察最好使用新的自动提交查询,而不是在同一个长事务中不断读取已经缓存的监控快照。 坏掉的样子 不加判断就运行 ANALYZE,相当于把每次变慢都当成统计陈旧。它可能增加维护工作,却完全没有解除真正的阻塞。一张只有一行的表,即使新增索引,也不能释放其他事务持有的行锁。准确的行数估计和很小的顺序扫描,可以与很长的应用等待同时存在;这两种观察并不矛盾。 EXPLAIN ANALYZE 会真正执行语句,写入也不例外,而且测量本身同样可能被阻塞。从生产环境复制一条大范围更新,不是无害的检查。应先看等待情况和锁的拥有者,再选择安全夹具或副作用已经明确的语句来测量。看到某个进程编号出现在阻塞列表中,也不能立即终止不熟悉的事务,因为它可能正在完成另一个有效业务操作。 没有锁等待的计划样本,也不等于端到端延迟。EXPLAIN ANALYZE 不会像普通查询那样把结果行发送给客户端;未指定 SERIALIZE 时,还不包含输出转换。TIMING OFF 减少逐节点计时开销,但仍保留总执行时间。大量结果传输、应用解码和连接池排队,需要在计划之外单独观察,不能从这里的执行时间中直接读出。 多会话过程归属于历史报告,未找回原始日志;打印观察需人工检查,超时是独立失败结果。 快速验证 打开三个新的 psql 会话,分别记作甲、乙、丙,连接同一个可丢弃的 PostgreSQL 18 数据库并使用相同角色,保留通常的自动提交行为。名为 query_recheck_demo 的模式必须尚不存在,集群中也不能有其他会话使用实验应用名称。角色须有权创建并移除模式,活动跟踪应已启用,且没有更短的锁超时。每个会话遇到非预期错误都应停止。务必区分会话:把所有代码块顺序粘贴到同一个连接,无法证明事务之间的阻塞。 在甲中建立夹具,然后开启事务,修改唯一一条记录但暂不提交。 \set ON_ERROR_STOP on CREATE SCHEMA query_recheck_demo; CREATE TABLE query_recheck_demo.items (id integer PRIMARY KEY,value integer); INSERT INTO query_recheck_demo.items VALUES (1,0); ANALYZE query_recheck_demo.items; BEGIN; UPDATE query_recheck_demo.items SET value=10 WHERE id=1; SELECT pg_backend_pid() AS blocker_pid; 在乙中发起带执行测量的更新。它会等待甲结束事务,或者等到六十秒的语句超时。 SET application_name='xbcatm_recheck_waiter'; SET statement_timeout='60s'; EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) UPDATE query_recheck_demo.items SET value=value+1 WHERE id=1; 乙仍在等待且尚未超时时,在丙中执行观察查询。实验专用应用名称用于定位乙,不需要展示整条查询文本。 SELECT pid,state,wait_event_type,wait_event,pg_blocking_pids(pid) AS blockers FROM pg_stat_activity WHERE application_name='xbcatm_recheck_waiter'; 回到甲,撤销尚未提交的变更并释放锁,然后收集乙执行完毕后的计划。 ROLLBACK; 确认乙已经成功结束后,在丙中检查最终数据并清理夹具。 SELECT value FROM query_recheck_demo.items WHERE id=1; DROP SCHEMA query_recheck_demo CASCADE; 原文报告的 PostgreSQL 18.6 观察未在本轮重新执行,也未在保留的验证文件中找到原始多会话日志;其中描述乙的状态为运行中,等待类型是 Lock,具体等待事件是 transactionid,阻塞列表包含甲的进程编号。具体事件可能随争用过程变化,核心证据是乙确实被甲阻塞。该报告称甲回滚后,乙把零加成一,最后查询返回一,清理移除了模式。必须人工确认丙恰好识别出乙、阻塞列表包含甲报告的 pid,且最终值为一;SQL 只是打印这些观察,没有自动断言。 ...

一月 31, 2028 · 1 分钟 · XB Field Notes
稀疏的原文件大格通过强锁符号指向容纳存活内容的较小替代文件。

VACUUM FULL 重建一张表而不是修剪

大量删除之后,PostgreSQL 表可能只剩少量记录,却仍保留很大的文件。普通清理让旧空间可以在表内复用,完全清理则把存活内容写入新文件,同时重建索引。更彻底的空间回收,需要另一套锁与容量预算,应作为有计划的维护操作,而不是一看到文件大小偏高就立刻执行的日常反应。 坏掉的样子 重写需要的是 ACCESS EXCLUSIVE 表锁,普通读取和写入都与它冲突。某个查询虽然已经执行完,但事务仍然打开、继续持有表锁,也会挡住重写。把它叫成独占行锁,或者以为只有写请求受影响,会低估实际中断范围。排队等待重写的强锁请求,还可能影响随后进入的其他取锁请求,不能只看当前命令有没有开始复制数据。 替代文件成功建立之前,原文件仍然存在,所以操作需要额外磁盘空间。等文件系统已经写满才着手完全清理,可能反而没有容纳新文件的余量。应提前评估替代堆表与索引、磁盘读写和日志影响,以及能够承受的持锁时长。常规自动回收不会自动替你执行这类完整重写。 VACUUM FULL 需要冲突表锁和替代文件余量。SQLSTATE 断言提案仍未在双会话中执行;未声明生产耗时结果。 快速验证 早先审阅记录只报告在 PostgreSQL 18.6 单用户模式中执行了准备与成功压缩的 SQL 部分,双会话锁实验仍未执行;本轮源文审阅没有运行任何 SQL。 只有在获准的真实实验中,才在同一个 PostgreSQL 18 可丢弃数据库中开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。把乙的每个代码块存成文件,通过 \i 加载;任何脚本错误都必须停止整个练习。清理命令必须在显式事务块之外执行。乙先创建五千行、删除前四千五百行,然后进行普通清理: CREATE SCHEMA compact_demo; CREATE TABLE compact_demo.items (id integer PRIMARY KEY,payload text) WITH (autovacuum_enabled=false); INSERT INTO compact_demo.items SELECT g,repeat(md5(g::text),10) FROM generate_series(1,5000) AS g; CREATE TABLE compact_demo.baseline AS SELECT pg_relation_size('compact_demo.items') AS heap_bytes, pg_relation_filenode('compact_demo.items') AS file_id; DELETE FROM compact_demo.items WHERE id<=4500; VACUUM (ANALYZE) compact_demo.items; SELECT pg_relation_size('compact_demo.items')=heap_bytes AS same_heap_size, pg_relation_filenode('compact_demo.items')=file_id AS same_file FROM compact_demo.baseline; 两个比较都返回真:堆文件大小与文件标识没有改变。存活行位于原文件尾部,末尾不存在可以截断的连续空页,所以普通清理不能通过截断来缩小这个特定文件。这不代表普通清理永远不会缩文件;如果末尾确实存在可释放的空页,就可能归还相应空间。这里要区分样本布局和命令的一般能力。 ...

一月 3, 2028 · 1 分钟 · XB Field Notes