DeepSeek总结的PostgreSQL 19 的新功能REPACK
原文地址https://www.highgo.ca/2026/04/20/understanding-postgresql-repack-through-repack-c/通过 repack.c 理解 PostgreSQL REPACK2026年4月20日 | 作者Chao LiREPACK 是 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()中if(concurrent)returnShareUpdateExclusiveLock;elsereturnAccessExclusiveLock;这是第一个主要的设计要点。普通重写很简单因为它排除了重要的并发读/写操作。并发重写更难因为在构建新副本时旧表仍然可写。核心重写核心工作发生在cluster_rel()和rebuild_relation()中。cluster_rel()执行权限检查、关系检查、索引检查、安全上下文切换和进度报告。它还处理并发模式的资格检查。对于REPACK CONCURRENTLYcheck_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()将实际的扫描/复制委托给表访问方法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中定义#defineCHANGE_INSERTi#defineCHANGE_UPDATE_OLDu#defineCHANGE_UPDATE_NEWU#defineCHANGE_DELETEd对于插入它将解码后的元组插入新堆并更新索引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 位置。在追赶传递之前主后端调用XLogFlush(GetXLogInsertEndRecPtr());end_of_walGetFlushRecPtr(NULL);process_concurrent_changes(end_of_wal,chgcxt,false);这是核心的并发策略长阶段弱锁复制堆构建新索引用解码后的 WAL 追赶短阶段强锁最终的 WAL 追赶交换文件整个设计是为了使强锁阶段尽可能小。REPACK 是否需要wal_level logical一个容易掉入的陷阱是将“使用逻辑解码”等同于“需要设置服务器 GUCwal_level logical”。在此实现中并发 REPACK 在内部使用逻辑解码但它不一定要求服务器 GUCwal_level设置为logical。解码工作进程在repack_setup_logical_decoding()中初始化此路径。该函数类似于pg_create_logical_replication_slot()但它创建的槽是 REPACK 私有的它在操作期间保持获取状态并且是临时的而不是持久的。保持槽被获取对于正确性很重要。并发 REPACK 必须解码在其初始快照之后、最终存储交换之前发生的每个已提交的行更改。如果槽被释放另一个后端可能会从中消费并推进解码位置。REPACK 随后可能会错过需要应用到新堆的更改。使槽成为临时也是有意的。并发 repack 不是一个可崩溃恢复的操作。如果服务器在复制、解码、应用更改或交换文件的过程中崩溃丢弃该槽并稍后重新启动整个操作更简单、更安全。相关设置如下所示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 失效和目录更新。