从 repack.c 深入理解 PostgreSQL REPACK 的底层实现

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() 函数。该函数解析 VERBOSEANALYZECONCURRENTLY 等配置参数,匹配对应锁级别,定位目标数据表或数据表列表,最终调用 cluster_rel() 函数执行核心逻辑。

受历史版本设计影响,源码仍保留 cluster_rel() 这类命名,根源在于 REPACKCLUSTERVACUUM FULL 复用同一套表重写底层逻辑。VACUUM FULLvacuum.c 文件调用该执行链路,CLUSTERREPACK 的差异仅集中在是否按索引排序、是否支持并发处理两大维度。 锁级别判定逻辑统一封装于 RepackLockLevel() 函数:

kotlin 复制代码
if (concurrent)
    return ShareUpdateExclusiveLock;
else
    return AccessExclusiveLock;

并发模式分配共享更新排他锁,非并发模式分配访问排他锁。该逻辑是 REPACK 核心设计要点之一。普通表重写流程简单,可直接屏蔽并发读写操作;并发表重写实现更为复杂,需保障新存储文件构建期间原数据表可正常写入。

表重写流程

表重写流程由 cluster_rel()rebuild_relation() 两大函数承载。

cluster_rel() 负责权限校验、关系合法性检测、索引校验、安全上下文切换、执行进度上报,同时判定数据表是否适配并发 REPACK 模式。针对 REPACK CONCURRENTLYcheck_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 聚类且排序成本更低时,采用顺序扫描加离线排序;
  • 无排序需求则直接普通顺序扫描。

同时会计算激进真空阈值,借表重写的时机清理存量死元组,同步更新 relfrozenxidrelminmxid 等冻结元数据。

TOAST 大字段处理逻辑十分考究。非并发场景下,若新旧堆都关联 TOAST 表,可采用按内容置换 TOAST 关联,保证系统目录类表的 TOAST 指针合法可用。并发模式下该机制直接禁用,原因是回放删除、更新操作时需要改动新堆的 TOAST 数据,沿用旧 TOAST 指针会引发数据安全隐患。

存储切换机制

swap_relation_files() 是 repack.c 中核心底层函数,可在保留数据表逻辑标识的前提下完成物理存储替换。

普通关系表通过更新 pg_class 系统目录字段实现置换,包含以下关键字段:

  • 物理文件节点
  • 表空间
  • 访问方法
  • 持久化属性
  • TOAST 关联标识
  • 尺寸统计信息
  • 冻结元数据

映射型关系无法直接修改 pg_class 物理文件节点字段,需依托关系映射器更新映射配置完成置换。

finish_heap_swap() 封装存储置换全流程收尾清理工作:

  1. 上报存储置换阶段执行进度SWAP_REL_FILES
  2. 调用存储置换核心函数swap_relation_files()
  3. 按需重建索引;
  4. 销毁临时数据表;
  5. 移除临时关系映射配置;
  6. 按需重命名 TOAST 附属表;
  7. 清理非目录表缺失属性元数据。

REPACKVACUUM FULL 能够回收磁盘空间的核心原理即在于此:规整后的新表成为正式存储载体,原膨胀存储关联至临时关系并最终销毁,实现磁盘空间彻底释放。

并发模式

并发 REPACK 是整套机制最有价值的设计分支。实际运行中存在一个核心矛盾:后台在把旧堆数据拷贝到新堆的同时,其他会话会持续插入、更新、删除数据;如果忽略这段时间的业务改动,新堆数据就会和真实业务数据脱节。整套实现依靠逻辑解码解决这一一致性问题。

rebuild_relation()中,并发模式会额外执行一系列专属步骤:

  1. 设为锁组主节点,方便后续后台工作进程加入同一锁组;
  2. 启动逻辑解码后台工作进程;
  3. 等待进程完成逻辑解码初始化,更多说明可参考后文《REPACK 是否需要开启 wal_level = logical》章节;
  4. 获取初始历史快照;
  5. 基于快照拷贝原堆全量数据;
  6. 在新堆上完成索引创建;
  7. 解析并应用已产生的并发变更;
  8. 施加访问排他锁AccessExclusiveLock;
  9. 解析并补全剩余增量变更;
  10. 完成堆文件与索引的存储切换。

后台进程由 start_repack_decoding_worker() 拉起,共享状态定义在 repack_internal.hDecodingWorkerShared结构体中。主进程和后台工作进程通过以下机制实现协同:

  • 动态共享内存
  • 共享文件集
  • 条件变量
  • 自旋锁保护共享字段
  • 用于传递错误与提示信息的共享消息队列

后台进程把解析出的变更写入文件,首份文件保存快照信息,后续文件依次记录增量改动。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

REPACKVACUUM 最核心的区别,是REPACK 会生成全新的物理存储文件。 VACUUM 仅在已有存储内部做清理优化,往往无法把全部膨胀空间归还操作系统文件系统。而 REPACKVACUUM FULL 机制相近,会整表重写数据,从物理层面完成表结构碎片整理与空间压缩。并发版 REPACK 引入逻辑解码能力,大幅缩短了整表重写过程中排他锁的占用时间。

从源码解析视角来看,repack.c 极具 PostgreSQL 设计风格:数据库对象的逻辑标识由系统目录表维护,底层物理存储可在不改动逻辑定义的前提下直接替换;而整个操作的数据正确性,依靠锁机制、快照、WAL 日志、关系缓存失效、系统目录更新等机制精密协同来保障。

相关推荐
Mr. zhihao1 小时前
Agentic 知识库:Agent Wiki不是取代向量数据库,而是让 Agent 学会“多模态思考”
数据库·agent·angetic
爱码小白1 小时前
MySQL索引与SQL优化
大数据·数据库·python
2303_821287381 小时前
MySQL行锁和表锁如何区分_通过explain查看锁等待机制.txt
jvm·数据库·python
是垚不是土1 小时前
PostgreSQL 运维工程师 “一本通“ :安装、配置、备份与监控
linux·运维·数据库·postgresql·运维开发
i220818 Faiz Ul1 小时前
宠物猫之猫咖管理系统|基于java + vue宠物猫之猫咖管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·宠物猫之猫咖管理系统
OceanBase数据库官方博客2 小时前
OceanBase seekdb-cli:专为 AI Agent 设计的数据库接口
数据库·人工智能·oceanbase
i220818 Faiz Ul2 小时前
二手交易系统|基于springboot + vue二手交易系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·二手交易系统
kexnjdcncnxjs2 小时前
如何在Navicat中创建基础数据表_可视化图形界面操作指南
jvm·数据库·python
m0_740796362 小时前
CSS如何兼容新旧方案结合响应式容器查询
jvm·数据库·python