搞 PostgreSQL 的老玩家,基本都有这种经历:业务表 UPDATE 太频繁,表文件一路涨到几十 GB,VACUUM 再怎么勤快,磁盘告警还是如约而至。更让人头疼的是,SELECT 的执行计划也跟着变差,因为扫描范围被死元组和无用空间拉长了。这就是典型的表膨胀(bloat)。pg_repack这个名字,在很多 DBA 的工具箱里是“在线重建表”的代名词。它的核心价值是在不长期锁业务写的前提下,把表和索引重写一遍,把表膨胀问题真正解决掉。
这篇文章我会从问题本身讲起,再把 pg_repack 的原理、安装、参数、实操步骤和排障经验串起来讲清楚。最后会聊一聊怎么把它从“手动救火工具”变成“定期巡检的工程化方案”。无论你是专职 PostgreSQL DBA,还是兼职管数据库的后端工程师,这篇文章里的东西都值得收藏后照着做一遍。
1. 为什么要用 pg_repack:膨胀和锁的双重困局
1.1 表膨胀是怎么一点一点堆出来的
PostgreSQL 的 MVCC 机制和 MySQL 的 undo 日志有一个本质差异:在 PostgreSQL 里,一条被 UPDATE 的旧版本数据不会立刻从物理文件里清除,而是标记为“死元组”,等 VACUUM 来清理。只要 VACUUM 能跟上,死元组会被及时回收,页面空间也能被后续插入复用。可一旦表特别大、写入特别密集,autovacuum 就可能跑不过来。
跑不过来的结果就是表文件越来越大,而真正“活着”的数据行占比越来越低。我见过一张订单表,业务上只有 8000 万行,但表文件已经占了 96 GB。用pgstattuple看,将近 60% 是 free space 和 dead tuple。这个场景下,普通 VACUUM 只能标记空间可复用,却不能把文件“压缩”回去,磁盘水位依旧高高挂着。
膨胀还会传染给索引。索引页里的死指针同样不会自动归还操作系统,尤其是经常更新索引列的场合。索引膨胀不像表膨胀那么容易被发现,但会让扫描成本明显上升,查询计划可能从 index scan 退化到 bitmap heap scan,甚至 seq scan,这就是你感觉“数据没多多少,但库越来越慢”的根源。
1.2 VACUUM FULL 为什么不是银弹
很多人遇到膨胀的第一反应是VACUUM FULL。这个命令确实能重写整张表并把空间压缩回去,但它有两个在生产环境里几乎无法接受的问题。
第一,它会拿ACCESS EXCLUSIVE锁。这个锁级别意味着从它执行开始到结束,任何读和写都被挡住。一张 50 GB 的表,重建可能要十几分钟到几十分钟,前端业务在这期间基本就处于“等待数据库连接”的雪崩状态。第二,VACUUM FULL 的重写过程是单进程作业,期间产生的临时占用几乎和原表一样大,磁盘紧张的时候跑一半磁盘满了,直接整库阻塞。
所以 VACUUM FULL 只能算“最后手段”,适合在应用停机窗口里处理小表。对那种 24 小时都有流量的大表,唯一的出路就是找一个能“在线操作”的重建工具,这也是 pg_repack 存在的意义。
1.3 pg_repack 适合谁、什么时候上
pg_repack 适合下面几类场景:
- 大表膨胀明显,但业务不能接受长时间锁表
- 索引膨胀导致查询性能下降,又不能在维护窗口里 drop index 再 recreate
- 磁盘空间不足,需要回收碎片,但不能停机
- 想把表按指定索引重新物理排序,
CLUSTER会锁表而 pg_repack 可以避免
不适合用它的时候也有。比如表本身没有主键或唯一约束,pg_repack 会直接拒绝执行;又比如你是想一次性重建整个库的所有对象,那不如干脆规划一段维护窗口,直接用 pg_dump/restore 或者物理迁移,效率和可靠性都更高。
2. 核心机制:弄清它是怎么做到不阻塞的
2.1 触发器加临时表加日志回放
要理解 pg_repack 的最佳实践,先得明白它的完整套路。它并不是像 VACUUM FULL 那样傻傻地在原表上做重写,而是“另起炉灶,最后偷梁换柱”。
大致分四步:
- 它先创建一张和原表结构几乎一样的临时表,并在原表上创建触发器。
- 之后开始分批把原表的存量数据复制到临时表。这段时间里业务照常读写原表,所有增量变化都会被触发器写进单独的日志表。
- 存量复制完成后,pg_repack 会把日志表里记录的增量变更回放到临时表,让两边数据追上。
- 最后在一个极短的事务里,把原表和临时表做交换,并重建索引、约束和触发器。
因为前两步都不需要锁原表,真正加锁的只有最后“交换”的那一小段,业务感受到的锁时间通常只有几百毫秒到几秒。这就是它能做到“在线”的核心原理。
2.2 为什么要求表有主键或唯一索引
很多第一次用 pg_repack 的人会遇到一个报错:cannot repack table ... without a primary key or unique constraint。这不是工具故意刁难,而是它的同步机制离不开唯一标识。
在日志回放阶段,中间表要应用源表上发生的 UPDATE 和 DELETE,它必须能定位到对应的行。没有主键或唯一索引,就没法把增量变更准确映射到已经复制的数据上。你可以理解成:它需要一把“行的身份证”,否则增量对不上账。
如果表确实没有主键怎么办?我的建议是别硬上,先找业务确认能否加一列id bigserial之类的唯一键;实在不行,只能考虑分组分批 VACUUM FULL 或者停机窗口。这不是工具的缺陷,而是在线重建这个思路本身的硬约束。
2.3 和 CLUSTER、pg_squeeze 的选型对比
有不少人会问:CLUSTER 也能重建表,pg_squeeze 也能在线重建,到底怎么选?我习惯用一张表来对比:
| 方案 | 是否阻塞写入 | 是否需要手工触发 | 是否要求唯一键 | 适合场景 |
|---|---|---|---|---|
| VACUUM FULL | 全程阻塞 | 是 | 否 | 小表、停机窗口 |
| CLUSTER | 全程阻塞 | 是 | 否 | 想按索引物理排序的维护场景 |
| pg_repack | 基本不阻塞,交换时有瞬断 | 是 | 需要 | 日常在线重建大表 |
| pg_squeeze | 不阻塞,可后台自动执行 | 可配置自动 | 需要 | 想自动化、有额外学习成本 |
pg_squeeze 看起来更“自动”,但它多了一步后台 worker 持续监控的成本,出问题时定位更难。对于绝大多数团队,pg_repack 的“手工触发、过程可控、回放清晰”反而更适合工程化落地。工具不是越自动越好,而是越可控越好。
3. 安装、权限与参数选择
3.1 两个东西都要装:扩展和命令行
pg_repack 的部署比一般扩展啰嗦一点,它需要两个部分同时存在:
- 数据库内的扩展对象:
CREATE EXTENSION pg_repack; - 操作系统上的可执行文件:
pg_repack命令
数据库扩展负责创建日志表、临时表,以及相关辅助函数;命令行工具则负责编排整个重排过程,两者版本最好一致。
拿 Debian/Ubuntu 举例,从 PostgreSQL 官方 apt 仓库装完 PostgreSQL 15 之后:
apt-get install postgresql-15-repack psql -d appdb -c "CREATE EXTENSION pg_repack;"也可以从源码编译,但我不太推荐在生产环境用源码方式维护,除非你的操作系统太老、仓库里没有对应包。源码编译时要记得make install后去每个需要的库里执行CREATE EXTENSION,少一步都会在运行时报“找不到函数”之类的怪错。
3.2 跑之前必须核对的权限和约束条件
权限是第一道坎。pg_repack 通常要求以超级用户或者目标表的所有者身份执行。如果权限不够,你会看到权限报错,有时还是不太友好的内部错误。原因在于它要在目标表上建触发器、创建临时表、最后还要执行 rename/schema 级别的操作,普通用户天然没有这些权限。
第二个要核对的是目标表是否有主键。如果没有主键但有一个非空唯一约束,也可以;如果两者都没有,直接放弃。第三个要核对的是数据库能不能连上“空闲连接”。pg_repack 默认遇到锁冲突时可能会终止后端连接,对于通过连接池维护的会话要格外小心,我后面会展开说。
还有一个容易被忽略的限制:pg_repack 不能在只读从库上执行,因为从库不允许 DDL 和写操作。你只能在主库或者可写的从库上跑,跑完再让复制把变更同步过去。
3.3 常用参数和工程化配置
pg_repack 的参数不少,但核心的其实就几个。我把常用参数整理成一张速查表:
| 参数 | 作用 | 我的习惯 |
|---|---|---|
-d/--dbname | 指定数据库名 | 每个命令都显式写,避免误操作 |
-t/--table | 指定要重建的表 | 可以多次使用,也可以指定 schema |
-T/--table-list | 从文件读取表列表 | 批量重建时用,用文本文件维护候选表 |
-j/--jobs | 并行 worker 数量 | 同时重建多个表时按 CPU 核数设置 |
-w/--wait-time | 等待锁的超时秒数 | 默认可能太快,我一般给到 60 以上 |
-n/--no-kickout | 不主动终止阻塞会话 | 生产环境我强烈建议加 |
--lz4 | 压缩临时表数据 | 磁盘紧张时使用 |
--index | 只重建索引 | 适合索引膨胀明显、堆数据还好的场景 |
--no-kickout和--wait-time是配合使用的。加了--no-kickout后,pg_repack 遇到阻塞会话不会去 terminate_backend,而是老实等待;--wait-time用来控制等待多久后放弃。这个组合在多人共用的生产库里非常重要,否则它可能会把你同事跑得正欢的报表查询直接干掉。
4. 一次标准的在线重建实操
4.1 第一步:预检
我在生产库上操作前,从来不会直接敲pg_repack -d appdb -t public.orders。先做一轮预检,能省掉后面 80% 的麻烦。
第一个检查是磁盘空间。确认数据目录所在文件系统至少有目标表 1.5 倍以上的剩余空间。pg_repack 的临时表会吃空间,索引重建又是一份空间,日志表还要增量追加。磁盘不够的时候,与其让任务跑到一半失败,不如先不发车。
第二个检查是看表的膨胀度。最直观的方式是用 pgstattuple 扩展:
SELECT * FROM pgstattuple('public.orders');主要看dead_tuple_percent和free_percent两个字段。如果 dead tuple 占比超过 20%,或者 free space 占比超过 30%,就有重建的价值。占比很低的时候跑 pg_repack 属于白费力气。
第三个检查是确认没有正在运行的长事务和长查询。可以用这条 SQL 快速扫一眼:
SELECT pid, state, now() - xact_start AS xact_age, now() - query_start AS query_age, left(query, 80) AS query FROM pg_stat_activity WHERE state <> 'idle' AND now() - xact_start > interval '5 minutes';长事务会影响 pg_repack 获取快照和回收日志,如果在事务里执行过 DDL,还可能直接卡住后面的换表阶段。发现有长事务,先沟通再操作。
4.2 第二步:执行重建
预检通过后,最稳妥的单表重建命令长这样:
pg_repack -h 127.0.0.1 -p 5432 -U repacker -d appdb \ --table public.orders \ --no-kickout \ --wait-time 120这里的repacker用户需要是超级用户或表 owner。加上--no-kickout,是为了避免它把正在跑的业务查询杀掉;--wait-time 120的意思是如果最后交换阶段拿不到锁,最多等 120 秒后放弃,而不是无限挂在那里。
如果是多张表批量重建,我会先把表清单写到文件里:
cat > /tmp/repack_tables.txt <<'EOF' public.orders public.order_items public.pay_records EOF pg_repack -h 127.0.0.1 -p 5432 -U repacker -d appdb \ --table-list /tmp/repack_tables.txt \ --jobs 2 \ --no-kickout \ --wait-time 120--jobs 2可以让两张表并行重建,但对系统 IO 和 CPU 的冲击也会翻倍。如果是 SSD,开到 2 到 4 通常没问题;如果是机械盘,建议老老实实串行。
4.3 第三步:过程监控和收尾
pg_repack 跑起来之后,不要干等。我在另一个会话里会持续观察它的进度:
SELECT pid, state, now() - query_start AS runtime, left(query, 120) AS query FROM pg_stat_activity WHERE query LIKE '%repack%' OR query LIKE '%pg_repack%';另外,pg_repack 会在数据库里创建一套repackschema,里面记录了正在处理的表和日志表信息。想看增量日志有多少、回放到哪一步,可以查:
SELECT * FROM repack.tables;任务正常结束的日志大致是:creating temporary table、copying rows、replaying changes、locking table、swapping relation、recreating indexes。看到INFO: repack table "public.orders" completed就代表成功了。
最后别急着走。重建完成后立刻确认三件事:原表数据量和索引可用性、磁盘剩余空间是否回落、以及目标表上的触发器和约束是否都恢复了。这三个确认没问题,才算真正收尾。
4.4 把 pg_repack 做成定时任务
如果每次膨胀都要等告警响了才手动跑,那还谈不上“工程化”。我现在的做法是,把预检脚本放进 crontab,每天凌晨跑一次,遇到膨胀超过阈值的表自动执行重建。
脚本逻辑不复杂,核心就三层:
- 用 SQL 扫描所有表,算出一个预估膨胀比,超过 20% 就进入候选列表。
- 对候选列表做排序,小表先重建,大表按窗口时间分批跑。
- 执行 pg_repack 的命令里统一加
--no-kickout --wait-time 180,并在日志里记录 start/end/status。
之所以把阈值设成 20%,是因为膨胀比例太低时重建性价比很差。3% 的膨胀跑一次全表重建,纯粹浪费磁盘 IO。宁可让 autovacuum 慢慢消化,也不要频繁重写几十 GB 的表。
5. 常见问题与排障
5.1 高频报错对照表
我把实际运维中遇到最多的几个报错整理成一张对照表,扫一眼就能定位问题:
| 报错信息 | 常见原因 | 处理方法 |
|---|---|---|
permission denied | 用户不是超级用户或表 owner | 换用有权限的用户执行 |
cannot repack table without primary key | 表没有主键/唯一约束 | 先补唯一约束,或用其他方案 |
another pg_repack job is running | 有重复的 pg_repack 进程 | 先 ps 查一下残留进程,再决定是否清理 |
temporary table already exists | 上一次运行中断残留了临时对象 | 连接对应库,清理 repack schema 遗留对象 |
lock on table ... conflicts | 别的会话持锁时间过长 | 确认会话来源,等待或协调后重试 |
ERROR: query was canceled | 锁等待超时被触发 | 调大--wait-time,或加--no-kickout |
这里我想专门强调中间两个。很多人以为“另一个 pg_repack 在跑”就一定是并发任务太多,其实很可能是上一个任务因为网络断开、误按 Ctrl-C 而残留了子进程。遇到先看ps -ef | grep repack,确认没有孤儿进程后再做处理。
清理残留对象的时候要非常小心,只清理明确属于 pg_repack 的临时表和序列,别手滑把业务表删了。不确定就先查repack.tables和pg_class,看清楚再动手。
5.2 一次值得分享的翻车现场
我有一次在客户那边跑 pg_repack,表也就 20 GB,本以为 10 分钟搞定。结果跑了一个多小时还没结束,业务侧开始报大量锁等待。我登录上去一看,pg_repack 卡在 replay 阶段,日志表里积累了上百万条变更,一直在来回同步。
原因查到最后很尴尬:当时业务在做一段时间内的数据回填,那张表上的 UPDATE 和 INSERT 密集到每分钟几万条,增量变更速度比 pg_repack 回放速度还快。换句话说,它永远追不上增量。
那次的处理办法是停下任务、清掉残留,把窗口调整到业务低峰期再跑。后来我养成了一个习惯:执行 pg_repack 前先问一句“这张表现在是不是有批量任务”。如果业务侧马上要做大范围 update/delete,那就不值得硬扛,换个时间窗口更划算。
这个案例也说明,pg_repack 不是“任何时候都能在线”,它是“在业务写入可控的前提下能在线”。增量风暴会把它的同步窗口拉得很长,锁时间未必会明显变长,但任务失败的概率会显著上升。
5.3 容易忽视的运维细节
最后分享几个不写进官方文档但我踩过坑的细节点。
第一,注意连接池。pg_repack 默认行为里有终止阻塞会话的逻辑。如果业务通过 pgbouncer 之类的连接池连接数据库,它可能终止的并不是某一条业务查询,而是一个被连接池复用的空闲会话,这会让业务侧看到莫名其妙的连接断开。加了--no-kickout后虽然更安全,但也要理解:它可能会因为一个持有锁的异常会话而一直等待,所以配合告警超时是必须的。
第二,重建完成后,检查 autovacuum 设置。pg_repack 复制数据到临时表后,新表的统计信息和 vacuum 状态是全新的。如果不开--no-analyze或者重建后没有及时做 ANALYZE,优化器可能按旧统计信息估算,导致执行计划偏差。我通常在收尾阶段手动执行一次ANALYZE,代价很低,收益却很直接。
第三,分区表要逐分区处理。pg_repack 对“父表”执行时经常得不到预期效果,正确做法是找出所有子分区,然后对每个分区单独执行-t schema.partition_name。没有自动递归处理分区,这是很多初用者的认知盲区。
第四,版本要跟 PostgreSQL 大版本匹配。PostgreSQL 15 的 pg_repack 不一定能在 PostgreSQL 17 上正常工作,报错也可能是诡异的函数缺失或内部错误。升级 PostgreSQL 大版本后,记得同步升级 pg_repack 的包和扩展。
结尾:一点个人经验
我把 pg_repack 用成生产环境标配之后,最大的感触是:这个工具不难,难的是建立一套“什么时候该跑、怎么跑、跑完怎么确认”的规矩。我个人目前的默认策略是,所有超过 10 GB 且膨胀率高于 20% 的表都纳入月度巡检清单,重建时间固定放在凌晨低峰,命令统一加上--no-kickout和--wait-time,每台机器的磁盘水位单独做监控。
如果你刚接触 pg_repack,建议先挑一张不重要的表练手,把整个输出日志读一遍,再对比一下重建前后的表大小和查询计划。踩过一次坑之后,再上生产心里就有底了。