原文地址:https://www.highgo.ca/2026/04/20/understanding-postgresql-repack-through-repack-c/
通过 repack.c 理解 PostgreSQL REPACK
2026年4月20日 | 作者:Chao Li
REPACK 是 PostgreSQL 19 的一个新功能,用于通过将表重写到新的存储空间来物理压缩表。与 VACUUM 类似,它也处理死元组留下的空间,但它通过构建一个全新的表文件来实现,而不是主要在原地清理页面。普通的 VACUUM 可以标记表内可重用的空间,并可能截断末尾的一些空页,但通常无法将膨胀的空间完全归还给操作系统。REPACK 与 VACUUM FULL 一样,将表重写到一个紧凑的文件中,然后将该存储空间交换到位。与 VACUUM FULL 的重要区别在于,REPACK CONCURRENTLY 在大部分操作期间保持表可用,它通过复制快照并在短暂的最终锁和交换阶段之前重放并发更改来实现这一点。
REPACK 代码很有趣,因为它位于几个困难的子系统之间:表重写、索引重建、relfilenode 交换、逻辑解码、后台工作进程、快照和锁管理。阅读 repack.c 是理解 PostgreSQL 如何在保留表逻辑标识的同时物理重建表的一个好方法。
在高层,REPACK 创建表的一个新的物理副本,用来自旧表的存活元组填充它,重建或交换索引,然后在原始关系 OID 下交换物理存储。用户仍然看到相同的表 OID、权限、依赖关系、继承关系和目录标识,但堆文件是新的且紧凑的。
repack.c 顶部的文件注释总结了两种模式:
- 非并发模式:获取 AccessExclusiveLock,重写表,交换存储,丢弃旧存储。
- 并发模式:获取 ShareUpdateExclusiveLock,在写入继续的同时复制表,从 WAL 解码并发更改,将它们重放到新堆中,短暂升级到 AccessExclusiveLock,应用剩余的更改,然后交换。
这种分割驱动了文件中几乎所有的设计选择。
入口点
主要的 SQL 入口点是 repack.c 中的 ExecRepack()。它解析 VERBOSE、ANALYZE 和 CONCURRENTLY 等选项,选择锁级别,解析关系或关系列表,最终调用 cluster_rel()。
一个稍微令人困惑的历史细节是:这个文件仍然使用像 cluster_rel() 这样的名字,因为 REPACK、CLUSTER 和 VACUUM FULL 共享表重写机制。VACUUM FULL 从 vacuum.c 调用此路径,而 CLUSTER 和 REPACK 的主要区别在于是否请求索引顺序以及是否允许并发处理。
锁级别集中在 RepackLockLevel() 中:
c
if (concurrent)
return ShareUpdateExclusiveLock;
else
return AccessExclusiveLock;
这是第一个主要的设计要点。普通重写很简单,因为它排除了重要的并发读/写操作。并发重写更难,因为在构建新副本时旧表仍然可写。
核心重写
核心工作发生在 cluster_rel() 和 rebuild_relation() 中。
cluster_rel() 执行权限检查、关系检查、索引检查、安全上下文切换和进度报告。它还处理并发模式的资格检查。对于 REPACK CONCURRENTLY,check_concurrent_repack_requirements() 强制执行几个重要的限制:
- 拒绝系统目录
- 拒绝 TOAST 关系
- 只允许永久关系
- 拒绝
REPLICA IDENTITY NOTHING,因为 WAL 不包含旧元组 - 表必须有一个标识索引,要么是
REPLICA IDENTITY索引,要么是不可延迟的主键
这个标识索引在后面很重要。在并发重放期间,更新和删除必须在新堆中找到相应的元组。代码通过标识索引查找行来实现这一点。
然后 rebuild_relation() 使用 make_new_heap() 创建新堆,使用 copy_table_data() 复制数据,并根据并发与非并发模式以不同方式完成。
在非并发模式下,路径很简单:
- 旧堆锁定为 AccessExclusive
- 创建新堆
- 将可见/存活数据复制到新堆
- 关闭旧/新 relcache 条目
- 调用
finish_heap_swap(...) - 重建旧的逻辑关系上的索引
- 丢弃临时关系
关键操作是 finish_heap_swap(),它调用 swap_relation_files()。PostgreSQL 保留旧的逻辑关系 OID,但交换了物理标识:relfilenode、表空间、访问方法、持久性、TOAST 链接和统计信息。
表不是像重命名文件那样"重命名到位"。PostgreSQL 更新目录元数据,使原始关系指向新的存储。
复制数据
copy_table_data() 将实际的扫描/复制委托给表访问方法:
c
table_relation_copy_for_cluster(...)
在此之前,它决定是否使用:
- 索引扫描(当通过索引进行聚簇时)
- 顺序扫描加排序(当这对于 btree 聚簇更便宜时)
- 普通顺序扫描(当未请求排序时)
它还计算激进的清理截止点。由于表无论如何都会被重写,这是移除死元组并设置更新的 relfrozenxid / relminmxid 的好机会。
TOAST 处理很微妙。在非并发模式下,如果新旧堆都有 TOAST 表,代码可能会使用"按内容交换 TOAST"。这为系统目录等情况保留了 TOAST 指针的有效性。在并发模式下,此功能被禁用,因为重放的删除/更新可能需要操作新堆中的 TOAST 数据,而旧的 TOAST 指针技巧将变得不安全。
存储交换
swap_relation_files() 是文件中最重要的函数之一。它在保留逻辑标识的同时交换物理存储。
对于普通关系,它交换 pg_class 中的字段:
relfilenodereltablespacerelamrelpersistence- 可选的
reltoastrelid - 大小统计信息
- 冻结元数据
对于映射关系,它不能简单地更新 pg_class.relfilenode,因为映射关系使用 relmapper。在这种情况下,它会更新关系映射。
finish_heap_swap() 将其与其余的清理工作包装在一起:
- 报告进度阶段
SWAP_REL_FILES - 调用
swap_relation_files() - 如果请求,重建索引
- 丢弃临时表
- 移除临时关系映射
- 如果需要,重命名 TOAST 关系
- 为非目录表清除缺失的属性元数据
这就是为什么 REPACK 和 VACUUM FULL 能回收磁盘空间的原因:紧凑的表成为真正的存储,而旧的膨胀的存储被附加到临时关系上,然后被丢弃。
并发模式
并发 repack 是更有趣的路径。
问题很简单:当 PostgreSQL 将旧堆复制到新堆时,其他会话可能会在旧堆中插入、更新或删除行。如果最终的交换忽略了这些更改,新堆将是过时的。
该实现使用逻辑解码来解决这个问题。
在 rebuild_relation() 中,并发模式做了几件额外的事情:
- 成为一个锁组领导者,因为稍后后台工作进程需要加入该组
- 启动一个解码后台工作进程
- 等待工作进程初始化逻辑解码(更多细节请参见后面的"REPACK 是否需要
wal_level = logical?"部分) - 获取一个初始的历史快照
- 使用该快照复制旧堆
- 在新堆上构建新索引
- 解码并应用并发更改
- 获取
AccessExclusiveLock - 解码并应用最终更改
- 交换堆和索引存储
工作进程由 start_repack_decoding_worker() 启动。共享状态存在于 repack_internal.h 中定义的 DecodingWorkerShared 中。后端和工作进程通过以下方式协调:
- 动态共享内存
SharedFileSet- 条件变量
- 自旋锁保护的共享字段
- 用于工作进程错误/通知的共享消息队列
工作进程将解码后的更改写入文件。第一个导出的文件包含快照。后续文件包含更改。
process_concurrent_changes() 要求工作进程解码到指定的 LSN。它设置 shared->lsn_upto,等待工作进程导出预期的文件编号,打开该文件,并调用 apply_concurrent_changes()。
应用并发更改
重放端是故意低层次的。
apply_concurrent_changes() 读取更改记录流。更改类型在 repack_internal.h 中定义:
c
#define CHANGE_INSERT 'i'
#define CHANGE_UPDATE_OLD 'u'
#define CHANGE_UPDATE_NEW 'U'
#define CHANGE_DELETE 'd'
-
对于插入,它将解码后的元组插入新堆并更新索引:
table_tuple_insert(..., TABLE_INSERT_NO_LOGICAL, ...)ExecInsertIndexTuples(...)
-
对于删除,它使用标识索引在新堆中找到匹配的元组并删除它:
find_target_tuple(...)table_tuple_delete(..., TABLE_DELETE_NO_LOGICAL, ...)
-
对于更新 ,它可能会接收一个旧元组和一个新元组。它尽可能使用旧元组作为查找键,定位新堆中现有的元组,如果需要调整 TOAST 指针,并执行
table_tuple_update()。
TABLE_*_NO_LOGICAL 标志很重要。这些重放操作本身不应被解码为新的逻辑更改,否则系统可能会将自己的更改反馈回流中。
find_target_tuple() 是标识索引需求发挥作用的地方。它从标识索引列构建扫描键,并使用新堆上的索引扫描来查找与解码后的更新/删除相对应的元组。
为什么有两次追赶传递
并发完成路径 rebuild_relation_finish_concurrent() 应用了两次更改。
- 第一次:在复制堆并构建新索引之后,它刷新 WAL 并应用到此为止的更改,同时仍然只持有较弱的锁。这最大限度地减少了积压。
刷新很重要。解码工作进程不会消费仅存在于 WAL 缓冲区中的任意 WAL。它的 WAL 读取器受限于已刷新的 WAL 位置。在追赶传递之前,主后端调用:
c
XLogFlush(GetXLogInsertEndRecPtr());
end_of_wal = GetFlushRecPtr(NULL);
process_concurrent_changes(end_of_wal, &chgcxt, false);
这是核心的并发策略:
- 长阶段:弱锁,复制堆,构建新索引,用解码后的 WAL 追赶
- 短阶段:强锁,最终的 WAL 追赶,交换文件
整个设计是为了使强锁阶段尽可能小。
REPACK 是否需要 wal_level = logical?
一个容易掉入的陷阱是将"使用逻辑解码"等同于"需要设置服务器 GUC wal_level = logical"。在此实现中,并发 REPACK 在内部使用逻辑解码,但它不一定要求服务器 GUC wal_level 设置为 logical。
解码工作进程在 repack_setup_logical_decoding() 中初始化此路径。该函数类似于 pg_create_logical_replication_slot(),但它创建的槽是 REPACK 私有的:它在操作期间保持获取状态,并且是临时的而不是持久的。
保持槽被获取对于正确性很重要。并发 REPACK 必须解码在其初始快照之后、最终存储交换之前发生的每个已提交的行更改。如果槽被释放,另一个后端可能会从中消费并推进解码位置。REPACK 随后可能会错过需要应用到新堆的更改。
使槽成为临时也是有意的。并发 repack 不是一个可崩溃恢复的操作。如果服务器在复制、解码、应用更改或交换文件的过程中崩溃,丢弃该槽并稍后重新启动整个操作更简单、更安全。
相关设置如下所示:
c
CheckLogicalDecodingRequirements(true);
ReplicationSlotCreate(..., RS_TEMPORARY, ...);
EnsureLogicalDecodingEnabled();
CreateInitDecodingContext("pgrepack", ...);
索引处理
- 非并发模式通常交换堆,然后在原始逻辑关系上重建索引。
- 并发模式 不能等到强锁阶段才构建索引,因为这可能需要很长时间。相反,
build_new_indexes()在获取AccessExclusiveLock之前,就在新堆上创建匹配的索引。
然后,在最终的交换过程中,代码为每一对旧/新索引交换存储。这在替换其物理内容的同时,保留了原始索引的逻辑标识。
这就是为什么 rebuild_relation_finish_concurrent() 保持 ind_oids_old 和 ind_oids_new 顺序匹配的原因。
进度报告
该文件还通过 pgstat_progress_update_param() 连接到 PostgreSQL 的进度报告。阶段在 progress.h 中定义:
- 顺序扫描堆
- 索引扫描堆
- 排序元组
- 写入新堆
- 追赶
- 交换关系文件
- 重建索引
- 最终清理
这是阅读代码的有用地图。每个阶段对应重写中的一个主要结构步骤。
总结
理解 REPACK 的一个简洁方式是:
- REPACK 是一种保留逻辑标识的表重写。
- 非并发 REPACK:强烈阻塞写者/读者,复制存活数据,交换存储,重建索引,丢弃旧存储。
- 并发 REPACK:在大部分工作中允许写入,从历史快照复制数据,从 WAL 解码更改,将更改重放到新堆,短暂阻塞写入,重放最终更改,交换堆和索引存储。
与普通 VACUUM 最重要的区别是,REPACK 会创建新的存储。VACUUM 主要在现有存储内进行清理,并且通常无法将所有膨胀空间归还给文件系统。REPACK 与 VACUUM FULL 一样,重写表并且可以物理压缩它。并发版本添加了逻辑解码,以使这种重写以更短的独占锁窗口发生。
从代码分析的角度来看,repack.c 是 PostgreSQL 风格的一个很好的例子:逻辑数据库标识存在于目录中,物理存储可以在其下交换,而正确性来自于仔细组合锁、快照、WAL、relcache 失效和目录更新。