REPACK 是 PostgreSQL 19 版本全新引入的表物理规整特性,通过将数据表重写入全新存储文件实现空间紧凑整理。该特性可清理死元组遗留的冗余空间,与 VACUUM 机制功能相近,但实现逻辑存在本质差异:VACUUM 以原地页面清理为主,REPACK 则直接生成全新的表存储文件。
常规 VACUUM 仅能标记表内空闲空间以供复用,也可截断表末尾部分空页面,往往无法将冗余膨胀空间完整回收至操作系统。REPACK 与 VACUUM FULL 机制类似,通过重建紧凑数据表文件并替换原有存储完成空间整理。二者核心区别在于,REPACK CONCURRENTLY 模式可借助快照拷贝、回放并发变更操作,仅在最终锁与存储替换阶段短暂限制表访问,绝大多数执行时段均可保障数据表正常可用。
REPACK 源码逻辑设计精巧,衔接表重写、索引重建、物理文件节点替换、逻辑解码、后台工作进程、快照管理与锁管理等多个复杂子系统。研究 repack.c 源码,能够清晰理解 PostgreSQL 在保留数据表逻辑标识的前提下,完成物理结构重建的底层实现原理。
从整体架构来看,REPACK 的执行流程为:创建数据表全新物理副本,从原表中填充有效元组数据,重建或替换索引结构,最终在保留原有关系 OID 的基础上完成底层物理存储置换。存储替换后,数据表 OID、权限配置、依赖关系、继承关联及系统目录标识均保持不变,仅底层堆文件更新为全新紧凑结构。
repack.c 文件头部注释明确划分 REPACK 两大运行模式,也是整体代码设计的核心依据:
- 非并发模式:获取访问排他锁,执行表重写、存储替换操作,最终销毁旧存储文件。
- 并发模式:获取共享更新排他锁,数据表持续支持写入操作的同时完成数据拷贝,通过 WAL 日志解码并发变更并同步至新堆文件,短暂升级为访问排他锁后应用剩余变更,最后完成存储替换。
REPACK 的入口函数
REPACK 的 SQL 执行入口为 repack.c 中的 ExecRepack() 函数。该函数解析 VERBOSE、ANALYZE、CONCURRENTLY 等配置参数,匹配对应锁级别,定位目标数据表或数据表列表,最终调用 cluster_rel() 函数执行核心逻辑。
受历史版本设计影响,源码仍保留 cluster_rel() 这类命名,根源在于 REPACK、CLUSTER 与 VACUUM FULL 复用同一套表重写底层逻辑。VACUUM FULL 从 vacuum.c 文件调用该执行链路,CLUSTER 与 REPACK 的差异仅集中在是否按索引排序、是否支持并发处理两大维度。 锁级别判定逻辑统一封装于 RepackLockLevel() 函数:
kotlin
if (concurrent)
return ShareUpdateExclusiveLock;
else
return AccessExclusiveLock;
并发模式分配共享更新排他锁,非并发模式分配访问排他锁。该逻辑是 REPACK 核心设计要点之一。普通表重写流程简单,可直接屏蔽并发读写操作;并发表重写实现更为复杂,需保障新存储文件构建期间原数据表可正常写入。
表重写流程
表重写流程由 cluster_rel() 与 rebuild_relation() 两大函数承载。
cluster_rel() 负责权限校验、关系合法性检测、索引校验、安全上下文切换、执行进度上报,同时判定数据表是否适配并发 REPACK 模式。针对 REPACK CONCURRENTLY,check_concurrent_repack_requirements() 函数设置多重严格限制:
- 不支持对系统目录表执行该操作。
- 不支持 TOAST 附属表执行操作。
- 仅支持永久数据表使用该功能。
- 不支持无副本标识配置的表,是因为 WAL 日志不会记录旧元组数据。
- 数据表必须具备标识索引,可为副本标识索引或非延迟主键索引。
标识索引在后续并发变更回放流程中起到关键作用,更新与删除操作需依托该索引在新堆文件中精准匹配对应元组。
rebuild_relation() 依次调用 make_new_heap() 创建全新堆文件、copy_table_data() 完成数据拷贝,最终根据并发与非并发模式执行差异化收尾逻辑。其中非并发模式执行链路逻辑清晰:
- 对原堆文件施加访问排他锁;
- 初始化全新堆文件;
- 将原表可见有效数据拷贝至新堆文件;
- 关闭新旧关系缓存条目;
- 调用 finish_heap_swap 执行存储置换;
- 对原逻辑关系重建索引;
- 销毁临时数据表。
finish_heap_swap()为核心操作,内部调用 swap_relation_files()函数。PostgreSQL 保留原有逻辑关系 OID,仅替换物理属性,涵盖物理文件节点、表空间、访问方法、持久化属性、TOAST 关联及统计信息等维度。
整个过程并非简单文件重命名,而是通过更新系统目录元数据,让原有关系指向全新物理存储。
数据复制机制
copy_table_data() 把实际扫描和复制动作,交由表访问方法接口执行:
scss
table_relation_copy_for_cluster()
正式拷贝前,会自动适配三种扫描策略:
- 按索引聚类时,采用索引扫描;
- Btree 聚类且排序成本更低时,采用顺序扫描加离线排序;
- 无排序需求则直接普通顺序扫描。
同时会计算激进真空阈值,借表重写的时机清理存量死元组,同步更新 relfrozenxid、relminmxid 等冻结元数据。
TOAST 大字段处理逻辑十分考究。非并发场景下,若新旧堆都关联 TOAST 表,可采用按内容置换 TOAST 关联,保证系统目录类表的 TOAST 指针合法可用。并发模式下该机制直接禁用,原因是回放删除、更新操作时需要改动新堆的 TOAST 数据,沿用旧 TOAST 指针会引发数据安全隐患。
存储切换机制
swap_relation_files() 是 repack.c 中核心底层函数,可在保留数据表逻辑标识的前提下完成物理存储替换。
普通关系表通过更新 pg_class 系统目录字段实现置换,包含以下关键字段:
- 物理文件节点
- 表空间
- 访问方法
- 持久化属性
- TOAST 关联标识
- 尺寸统计信息
- 冻结元数据
映射型关系无法直接修改 pg_class 物理文件节点字段,需依托关系映射器更新映射配置完成置换。
finish_heap_swap() 封装存储置换全流程收尾清理工作:
- 上报存储置换阶段执行进度
SWAP_REL_FILES; - 调用存储置换核心函数
swap_relation_files(); - 按需重建索引;
- 销毁临时数据表;
- 移除临时关系映射配置;
- 按需重命名 TOAST 附属表;
- 清理非目录表缺失属性元数据。
REPACK 与 VACUUM FULL 能够回收磁盘空间的核心原理即在于此:规整后的新表成为正式存储载体,原膨胀存储关联至临时关系并最终销毁,实现磁盘空间彻底释放。
并发模式
并发 REPACK 是整套机制最有价值的设计分支。实际运行中存在一个核心矛盾:后台在把旧堆数据拷贝到新堆的同时,其他会话会持续插入、更新、删除数据;如果忽略这段时间的业务改动,新堆数据就会和真实业务数据脱节。整套实现依靠逻辑解码解决这一一致性问题。
在 rebuild_relation()中,并发模式会额外执行一系列专属步骤:
- 设为锁组主节点,方便后续后台工作进程加入同一锁组;
- 启动逻辑解码后台工作进程;
- 等待进程完成逻辑解码初始化,更多说明可参考后文《REPACK 是否需要开启 wal_level = logical》章节;
- 获取初始历史快照;
- 基于快照拷贝原堆全量数据;
- 在新堆上完成索引创建;
- 解析并应用已产生的并发变更;
- 施加访问排他锁AccessExclusiveLock;
- 解析并补全剩余增量变更;
- 完成堆文件与索引的存储切换。
后台进程由 start_repack_decoding_worker() 拉起,共享状态定义在 repack_internal.h 的 DecodingWorkerShared结构体中。主进程和后台工作进程通过以下机制实现协同:
- 动态共享内存
- 共享文件集
- 条件变量
- 自旋锁保护共享字段
- 用于传递错误与提示信息的共享消息队列
后台进程把解析出的变更写入文件,首份文件保存快照信息,后续文件依次记录增量改动。process_concurrent_changes() 指定解析到特定 LSN 日志位点,更新共享变量阈值shared->lsn_upto,等待后台生成对应数据文件后,调用 apply_concurrent_changes() 完成变更落地。
并发变更回放
变更回放采用底层轻量化设计,apply_concurrent_changes() 函数读取变更记录流,变更类型在repacks_internal.h源码中做明确定义,包含插入、旧元组更新、新元组更新、删除四类操作:
arduino
#define CHANGE_INSERT 'i'
#define CHANGE_UPDATE_OLD 'u'
#define CHANGE_UPDATE_NEW 'U'
#define CHANGE_DELETE 'd'
插入操作将解码元组写入新堆文件并同步更新索引:
scss
table_tuple_insert(..., TABLE_INSERT_NO_LOGICAL, ...)
ExecInsertIndexTuples(...)
删除操作依托标识索引在新堆文件匹配目标元组后执行删除:
scss
find_target_tuple(...)
table_tuple_delete(..., TABLE_DELETE_NO_LOGICAL, ...)
针对更新操作,会同时接收旧元组与新元组数据。优先以旧元组作为检索条件,在新堆表中定位已有元组,按需调整 TOAST 指针,再调用 table_tuple_update() 完成数据更新。
操作标记 TABLE_*_NO_LOGICAL 具备重要作用,可避免回放操作被再次逻辑解码,防止变更数据循环回流。find_target_tuple() 函数依托标识索引构建检索键,通过新堆文件索引扫描匹配解码后的更新、删除操作对应元组。
为什么需要两轮 Catch-Up
并发模式收尾函数 rebuild_relation_finish_concurrent() 会执行两次变更应用。
首先,在堆表数据拷贝完成、新索引构建完毕后,系统会刷写 WAL 日志,并在仅持有弱锁的前提下,应用截至当前位点的所有变更,最大限度减少数据积压。
刷写操作至关重要。解码工作进程不会处理仅存在于 WAL 缓冲区中的未持久化日志,其日志读取范围受已刷写 WAL 位置限制。在数据追赶阶段开始前,主进程会执行以下操作:
scss
XLogFlush(GetXLogInsertEndRecPtr());
end_of_wal = GetFlushRecPtr(NULL);
process_concurrent_changes(end_of_wal, &chgcxt, false);
这就是整套并发机制的核心设计思路:
csharp
long phase:
weak lock
copy heap
build new indexes
catch up with decoded WAL
short phase:
strong lock
final WAL catch-up
swap files
整套设计的核心思路,就是尽量缩短强锁的持有时长。
REPACK 是否依赖 wal_level = logical
很多人容易陷入一个认知误区:只要用到逻辑解码,就意味着 wal_level=logical。而在当前实现中,并发版 REPACK 虽在内部使用逻辑解码,但并不强制要求数据库服务端将 wal_level 这个 GUC 参数配置为 logical。
解码工作进程通过 repack_setup_logical_decoding() 完成初始化。该函数功能与 pg_create_logical_replication_slot() 相近,但创建的是 REPACK 专用复制槽:在整个操作执行期间持续占用,且为临时槽位,不做持久化存储。
持续占用临时槽位,是保障数据正确性的关键。并发 REPACK 需要完整解析从初始快照生成,到最终存储切换之间所有已提交的数据变更。一旦释放槽位,其他后台进程可能占用并推进解码位点,就会导致 REPACK 遗漏需要同步到新堆表的变更数据。
采用临时槽位同样是经过精心设计的。并发 REPACK 不支持崩溃恢复,若在数据拷贝、日志解码、变更应用或文件切换过程中服务器发生崩溃,直接丢弃该槽位并重新执行整个操作,是更简洁、更安全的处理方式。
相关初始化逻辑如下:
scss
CheckLogicalDecodingRequirements(true);
ReplicationSlotCreate(..., RS_TEMPORARY, ...);
EnsureLogicalDecodingEnabled();
CreateInitDecodingContext("pgrepack", ...);
索引处理机制
非并发模式的常规做法是:先完成堆表存储切换,再在原逻辑关系上重建索引。并发模式则不能等到强锁阶段再去建索引,避免耗时过长。因此会提前通过 build_new_indexes(),在加访问排他锁之前,先在新堆表上创建结构完全一致的索引。
后续在最终切换环节,代码会逐对交换新旧索引的物理存储。这样既能保留原有索引的逻辑属性,又替换了底层物理数据。这也是rebuild_relation_finish_concurrent() 需要把新旧索引 OID 按一一对应顺序保存的原因。
进度上报机制
REPACK 机制接入 PostgreSQL 原生进度上报体系,通过pgstat_progress_update_param() 函数推送执行状态。进度阶段定义于 progress.h 文件,主要阶段包括:
- 顺序扫描堆文件
- 索引扫描堆文件
- 元组排序
- 新堆文件写入
- 变更追赶
- 关系文件置换
- 索引重建
- 收尾清理
各阶段与表重写核心步骤一一对应,可作为源码研读的清晰脉络指引。
总结
REPACK 的核心逻辑:
sql
REPACK is a table rewrite that preserves logical identity.
Non-concurrent REPACK:
block writers/readers strongly
copy live data
swap storage
rebuild indexes
drop old storage
Concurrent REPACK:
allow writes during most work
copy data from a historic snapshot
decode changes from WAL
replay changes into the new heap
briefly block writes
replay final changes
swap heap and index storage
REPACK 和 VACUUM 最核心的区别,是REPACK 会生成全新的物理存储文件。 VACUUM 仅在已有存储内部做清理优化,往往无法把全部膨胀空间归还操作系统文件系统。而 REPACK 和 VACUUM FULL 机制相近,会整表重写数据,从物理层面完成表结构碎片整理与空间压缩。并发版 REPACK 引入逻辑解码能力,大幅缩短了整表重写过程中排他锁的占用时间。
从源码解析视角来看,repack.c 极具 PostgreSQL 设计风格:数据库对象的逻辑标识由系统目录表维护,底层物理存储可在不改动逻辑定义的前提下直接替换;而整个操作的数据正确性,依靠锁机制、快照、WAL 日志、关系缓存失效、系统目录更新等机制精密协同来保障。