
做 PostgreSQL 维护我见过太多团队被一张表逼到通宵。业务跑了几个月订单表到了 400GB你查pg_stat_user_tablesn_dead_tup几千万UPDATE 和 DELETE 一直在发生可表的物理文件就是不见小。VACUUM跑到地老天荒也只是把死元组标记成可复用狠下心执行VACUUM FULL表倒是缩了但那条ACCESS EXCLUSIVE锁一上线上写入直接中断几分钟都算运气好几十 GB 的表折腾一两个小时也不稀奇。pg_repack就是为这种场景准备的在线收缩表、重建索引不长时间阻塞读写。这篇文章不是官方文档的翻译是我在多个生产环境里跑pg_repack攒下来的实操经验包含参数选择、资源评估、自动化调度和一堆踩过的坑。适合数据库管理员、运维工程师也适合那些被表膨胀逼到想骂人的后端同学。1. 先把“为什么要用 pg_repack”这件事讲透1.1 表膨胀是怎么来的PostgreSQL 的 MVCC 机制决定了一条UPDATE在物理上等价于DELETE INSERT。旧版本的行不会立刻被抹掉只要还有最老事务的快照可能看到它这行数据就得留在数据页里。DELETE更直接被删的行也是死元组dead tuple原地躺尸。autovacuum能做的事情是把这些不再可见的死元组清理掉把页面内的空间归还给空闲空间映射表FSM方便后续新数据复用。但关键问题是表文件的物理大小不会因为 VACUUM 而缩小。文件的高水位在哪儿操作系统的空间就占着哪儿。除非文件尾部恰好全是空页被 VACUUM 截断否则你看到的pg_relation_size永远只增不减。伴随表膨胀的还有索引膨胀。索引页里同样堆积了大量指向死版本的条目索引文件越来越大扫描路径越来越长。就算表数据本身不算多一个膨胀严重的二级索引也能让查询计划变坏。想快速定位膨胀表先跑这个SELECT schemaname, relname, n_live_tup, n_dead_tup, round(n_dead_tup * 100.0 / nullif(n_live_tup n_dead_tup, 0), 2) AS dead_pct, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 30;要更精确地看物理页里有百分之多少是死空间可以用pgstattupleCREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT relname, table_len, tuple_count, dead_tuple_count, round(100.0 * dead_tuple_len / nullif(table_len, 0), 2) AS dead_ratio, round(100.0 * free_space / nullif(table_len, 0), 2) AS free_ratio FROM pgstattuple(public.orders);dead_ratio超过 20%基本就该列入处理清单了。1.2 VACUUM 和 VACUUM FULL 为什么不够用先把两种常规手段的边界说清楚。VACUUM的工作是清死元组、更新 FSM、维护可见性映射。它能在业务运行的同时跑不会阻塞读写但也确实不归还物理空间。膨胀率不高时靠 VACUUM 维持住复用就够了一旦膨胀已经积重难返VACUUM 就只是杯水车薪。VACUUM FULL是另一条路它重写整张表把数据紧凑地放进新文件然后替换旧文件空间自然就还给了操作系统。代价是它要拿ACCESS EXCLUSIVE锁全表读写全部停摆。而且重写期间还需要相当于表大小一倍的额外磁盘空间。对于几十 GB、几百 GB 的表这个锁的持续时间根本不是“秒级”而是分钟级、小时级。这也是生产环境最尴尬的地方你知道 VACUUM FULL 能解决问题但你不敢在业务时间执行等到了维护窗口又发现窗口不够用。1.3 和 VACUUM FULL、pg_squeeze 的对比同类工具还有一个pg_squeeze它也是在线重建表但依赖后台 worker 进程配置门槛更高社区普及度也远不如pg_repack。我把几个方案放在一起对比维度VACUUMVACUUM FULLpg_repackpg_squeeze是否释放空间给操作系统否是是是是否长时间阻塞读写无全程阻塞仅短暂锁仅短暂锁是否重建索引否是默认重建是表是否需要主键或唯一索引否否是是额外磁盘占用几乎无约 1 倍表大小约 1 倍表索引约 1 倍表索引部署复杂度内置内置低中实际生产里pg_repack基本是这个场景的默认答案。2. pg_repack 到底是怎么做到“无锁”的2.1 核心流程拆解pg_repack不是什么魔法它把 VACUUM FULL 的“停下来重写”改成了“边跑边重写”。我给非 DBA 的同学打个比方老房子不拆在旁边盖一栋同户型的房子。盖房期间住户继续在老房子里正常生活所有日常变化搬进来的、搬走的都有人登记在账本上。新房装修完把账本上的增量对一遍账确保两边数据一致然后一瞬间把门牌换过去老房子拆除。外人从头到尾看到的就是同一栋楼。落到数据库层面流程是这样的在目标表上创建日志表和触发器把并发INSERT/UPDATE/DELETE记录到日志表里。这一步需要短暂拿锁。创建一张结构完全相同的新表放在repackschema 下。分批把老表的数据复制到新表。复制期间业务照常写入全部增量由触发器记入日志表。复制完成后回放日志表里的增量变更到新表解决时间窗口内的不一致。在新表上重建全部索引。用一个短事务完成交换老表改名成备份名新表改名为原表名再删除老表。这一步需要短暂ACCESS EXCLUSIVE锁。整套流程里真正长时间持锁的阶段是没有的。锁在开头和结尾各出现一次正常情况都是秒级。这也是它敢叫“在线”的原因。有个容易忽略的点日志表会从第一步一直记录到交换之前。如果目标表业务写入非常频繁日志表会变得很大磁盘占用比预想的高。后面评估空间时我会再强调。2.2 为什么必须有主键或唯一索引这是pg_repack最常被吐槽的限制表必须要有主键或者唯一的非空约束/唯一索引。原因在回放阶段。日志表里记录的UPDATE和DELETE需要精确映射到新表的某一行没有主键或唯一索引触发器生成的日志根本不知道“改的是哪一行”。你想想假如一张表里两行数据完全一样没有一个可以唯一定位的标识增量回放的时候怎么知道要把哪一行更新掉所以遇到没有主键的表别急着抱怨。可选的方案有三条如果表上有业务上天然唯一的列或列组合比如业务单号用它们建一个唯一索引或唯一约束再跑pg_repack跑完按需决定是否保留。有些表实在找不到唯一键只能选低峰期用VACUUM FULL顶着锁来做。某些版本支持--no-order跳过复制阶段的排序但这个参数并不能绕过主键要求它只是性能选项别搞混。我见过有人给表临时加一个序列列当唯一键说实话不推荐改表结构本身就有风险而且加了列之后默认值、应用层插入逻辑都可能受影响。2.3 前置条件与安装安装分两层工具二进制和数据库扩展。二进制一般从发行版仓库装包名通常会带上 PostgreSQL 大版本号比如postgresql-16-repack或pg_repack16版本必须和数据库大版本匹配。装完验证一下pg_repack --version然后在每个要处理的数据库里创建扩展CREATE EXTENSION pg_repack;如果不创建扩展执行时大概率会直接报错提示找不到相关函数。权限方面最简单的是用超级用户执行如果审计严格想让普通用户跑需要用表的所有者账号并且该用户在目标库有相应权限某些版本还需要加--no-superuser-check绕过默认的超级用户检查。另外pg_repack必须在主库执行连到备库会报cannot execute INSERT in a read-only transaction。这点说过多少次都有人再踩一次。3. 常用参数与实操命令3.1 参数速查表先列一份我用得最顺手的参数表精确语义以你手里版本的pg_repack --help为准参数作用我常用的值-t, --table只处理指定表public.orders-s, --schema只处理某个 schema 下的全部表public-I, --index只处理指定索引public.idx_orders_created_at-x, --only-index只重建索引不重写表-j, --jobs并行 worker 数4--no-order复制数据时不排序--no-analyze结束后不自动执行 ANALYZE-k, --wait-timeout等待锁的超时时间秒600-D, --no-superuser-check跳过超级用户检查-d, --dbname数据库名mydb-h, --host主机地址127.0.0.1默认情况下如果不指定-t、-s、-Ipg_repack会处理当前库里所有符合条件的表。注意-a/--all是处理服务器上的所有数据库不是“当前库所有表”别被网上某些错误示例带偏。3.2 各种业务场景的命令示例场景一单独处理一张膨胀最严重的表限定并发和锁等待时间。pg_repack -d mydb -t public.orders -j 4 --wait-timeout 600场景二处理一个 schema 下所有满足条件的表。我通常在凌晨低峰跑这个。pg_repack -d mydb -s public -j 4 --no-order场景三整库跑一遍。适合膨胀表很多、一张张手写太累的情况注意它会自动跳过没有主键的表。pg_repack -d mydb --jobs 4 --no-order场景四只重建索引不重写表。如果表数据本身没怎么膨胀但二级索引因为频繁更新已经很大这个方案便宜得多、风险也低。pg_repack -d mydb -t public.orders --only-index场景五只处理某一个指定的索引。pg_repack -d mydb -I public.idx_orders_created_at3.3 如何从膨胀判定中找到值得处理的表不可能每天都把所有表 repack 一遍。我的做法是用查询生成一份“待处理清单”按影响面排序SELECT schemaname || . || relname AS tbl, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, n_dead_tup, round(n_dead_tup * 100.0 / nullif(n_live_tup n_dead_tup, 0), 2) AS dead_pct FROM pg_stat_user_tables WHERE pg_total_relation_size(relid) 1024 * 1024 * 1024 AND n_dead_tup 50000 ORDER BY dead_pct DESC;筛选规则很简单表超过 1GB死元组超过 5 万或者死元组占比超过 20%。这批表优先处理。小于 1GB 的表就算膨胀 50%浪费的空间绝对值也有限不值得占用维护窗口。处理顺序我一般按“影响最大的先来”优先处理被高频查询命中的大表其次处理索引膨胀严重的表。如果只是索引的问题先跑--only-index顶着等大促或版本升级前再统一做整表 repack。4. 工程化最佳实践从评估到上线4.1 事前评估磁盘、膨胀率、执行窗口pg_repack不是零成本工具。它最大的硬件代价是临时磁盘空间。整个流程会创建一张新表、重建索引还要累积日志表峰值占用大约是“表 索引原本大小”的 1.5 到 2 倍。我给你的硬建议是执行前确认数据目录所在文件系统至少还有 2 倍于pg_total_relation_size的剩余空间。不然跑到一半No space left on device留下的是一堆repackschema 里的临时表收拾起来比跑一次还烦。如果主表空间不够pg_repack还支持--tablespace参数把重写的新表放到另一个表空间。我一般只在应急时用因为换个表空间意味着表的物理位置迁移后续还要再迁回来多一轮操作就多一轮风险。执行窗口务必选业务低峰。凌晨 1 点到 5 点通常比较安全但也要避开月底结算、秒杀、财报生成这类特殊时段。另外一个容易被忽略的点不要在 repack 前刚跑过大批量数据清理任务。比如你刚 DELETE 了 1 亿行旧数据表膨胀率正高这时候立刻 repack 会撞上 autovacuum 还在清死元组两边的 IO 叠加起来很难看。还有复制环境的检查。如果有物理备库repack 会产生比日常高得多的 WAL 量备库可能出现回放延迟如果有逻辑复制发布订阅那更麻烦表被 swap 之后发布端和订阅端的表 OID 对不上复制会中断需要提前规划重建订阅。4.2 执行阶段参数调优与资源控制先说一个能显著提升效率的小技巧索引重建阶段吃的是maintenance_work_mem。pg_repack本身是个客户端程序没法直接让你在 shell 里SET但可以通过PGOPTIONS环境变量给后端会话传参PGOPTIONS-c maintenance_work_mem2GB pg_repack -d mydb -t public.orders -j 4这个参数设置的是每个 worker 后端的内存不是总内存。你要是开了-j 4就按 4 份去算别把一个 16GB 内存的实例配出一个 8GB 的maintenance_work_mem再开 8 个 worker。我的经验是单 worker 给 1GB 到 2GB总内存占用控制在实例可用内存的 20% 以内再配合-j限制并发。-j的取值看机器。IO 密集型场景2和4差别不大甚至2更稳CPU 余量充足的机器4到8能明显缩短复制和建索引的时间。我从不在高并发生产机器上开超过8。锁等待超时要显式设置。不设置的话某些版本会一直等下去任务挂在那里不报错也不结束非常被动。设一个合理值比如600秒拿不到锁就失败退出再由监控告警通知人工介入。这个超时是“等待锁”的超时不是整个任务的超时放心用。如果目标表非常大我建议分两步走低峰期先只做--only-index解决索引膨胀隔几天业务验证没问题之后再择机做整表 repack。每一步的验证结论都清晰回退也简单。4.3 事中监控与事后验证pg_repack没有官方的进度条但不代表无法监控。我通常在另一个会话里观察这几个东西。看活动会话确认复制和建索引正在跑SELECT pid, state, now() - state_change AS dur, left(query, 120) AS query FROM pg_stat_activity WHERE query ILIKE %repack% OR query ILIKE %CREATE INDEX% ORDER BY dur DESC;看repackschema 下临时表的增长情况这是最直观的进度信号SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid)) AS size FROM pg_class c JOIN pg_namespace n ON n.oid c.relnamespace WHERE n.nspname repack ORDER BY pg_relation_size(c.oid) DESC;新表大小接近目标表大小时基本说明复制接近尾声。完成后验证清单我按这个顺序走-- 1. 确认结构完整 \d public.orders -- 2. 抽验数据行数 SELECT count(*) FROM public.orders; -- 3. 确认物理空间确实缩小 SELECT pg_size_pretty(pg_total_relation_size(public.orders)); -- 4. 确认索引都是 valid 状态 SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE indrelid public.orders::regclass; -- 5. 主动更新统计信息 ANALYZE public.orders;最后再查一遍repackschema 是否清空。如果残留了临时表先确认没有正在运行的 repack 进程然后清理DROP SCHEMA repack CASCADE;执行完这一步临时空间才会完全释放。4.4 自动化调度与告警手动执行始终不是长久之计。我建议把 repack 封装成一个带锁保护和日志输出的脚本用 cron 或调度平台跑。#!/bin/bash set -euo pipefail DBmydb LOG/var/log/pg_repack.log LOCK_KEY88471234 # 防止多个 repack 同时跑先拿一把 advisory lock if [ $(psql -X -d $DB -tAc SELECT pg_try_advisory_lock($LOCK_KEY)) ! t ]; then echo $(date %F %T) another repack is running, exit $LOG exit 0 fi trap psql -X -d \$DB\ -tAc SELECT pg_advisory_unlock($LOCK_KEY) EXIT echo $(date %F %T) repack start $LOG PGOPTIONS-c maintenance_work_mem1GB \ pg_repack -d $DB -s public -j 4 --no-order --wait-timeout 600 $LOG 21 echo $(date %F %T) repack finished rc$? $LOGcron 示例每周日凌晨 3 点跑0 3 * * 0 /usr/local/bin/repack_weekly.sh调度之外最好配合磁盘使用率告警。当数据目录空间使用率超过 75%就要检查是不是又有表膨胀了。膨胀是慢变量你只要把发现膨胀的速度提上来repack 的执行频率反而可以降下来。5. 实战中踩过的坑与排查速查表5.1 常见错误与处理方法我把这些年遇到过的报错整理成一份速查表按出现频率排序现象 / 报错原因处理方法ERROR: relation public.orders must have a primary key or unique index表没有唯一行标识加临时唯一约束/唯一索引或低峰期改用 VACUUM FULLcanceling statement due to lock timeout锁等待超时用--wait-timeout调大排查长事务错峰执行ERROR: permission denied/must be superuser权限不足用超级用户执行或改用表所有者并加--no-superuser-checkERROR: cannot execute INSERT in a read-only transaction连到备库了确认连接的是主库No space left on device临时空间不足清理 WAL 或旧备份腾出约 2 倍表大小空间清理repackschema 残留后再重试repackschema 下残留大量table_xxx上次执行失败中断确认无 repack 进程后执行DROP SCHEMA repack CASCADE逻辑复制中断 / 订阅报错表 OID 被交换发布订阅对不上重建发布或订阅或先停掉该表的订阅再 repack外键相关报错目标表被其他表的外键引用先处理子表外键或临时删除外键repack 完再加回5.2 两个容易忽略的“隐形坑”第一个坑是触发器与审计逻辑。pg_repack复制数据、回放日志的阶段目标表上原有的自定义触发器可能被触发也可能被某些版本的特殊处理绕开。具体行为因版本而异但结果往往出人意料。我曾经在一个有审计触发器的表上跑 repack日志表把迁移期间的增量全部记进了审计表磁盘差点被打满事后对账还费了好大功夫。处理办法很简单repack 前先看这张表上有哪些触发器SELECT tgname, tgtype, pg_get_triggerdef(oid) FROM pg_trigger WHERE tgrelid public.orders::regclass AND NOT tgisinternal;如果存在审计类、业务类的用户触发器先在测试环境摸清行为必要时临时禁用repack 完成后立即恢复。注意禁用触发器本身也是 DDL要在低峰操作。第二个坑是回放阶段对 DDL 的敏感。repack 进行到一半如果有人对原表执行了ALTER TABLE、重建索引这类操作轻则日志回放报错重则留下半截状态。生产环境我强烈建议执行 repack 的窗口内禁止其他运维对同一张表执行任何 DDL。如果实在有需求宁可等 repack 结束再说。还有一个不算坑但容易烦人的细节表名大小写。如果你的表名是混着大小写创建出来的比如public.Orders传给-t的时候必须带引号写成pg_repack -d mydb -t public.Orders裸写public.Orders会被转成小写直接报表不存在。最后说点个人体会。pg_repack不是银弹它解决的是“已经膨胀了”的被动局面。如果你的业务每天都在大量 UPDATE 和 DELETE真正要做的不是隔三差五 repack而是先把应用层的写放大控制住把 autovacuum 参数调到一个适合你业务节奏的值。我把线上实例的autovacuum_vacuum_cost_delay调低、autovacuum_naptime调短之后膨胀周期从一个月延长到了三个月repack 从两周一次变成一个月一次。工具练熟练了更要练的是理解膨胀的来源这才是少加班的正道。