☰
PostgreSQL表膨胀怎么破?pg_repack在线重建表实战指南
2026/10/6 9:12:50 网站建设 项目流程

搞 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 那样傻傻地在原表上做重写,而是“另起炉灶,最后偷梁换柱”。

大致分四步:

  1. 它先创建一张和原表结构几乎一样的临时表,并在原表上创建触发器。
  2. 之后开始分批把原表的存量数据复制到临时表。这段时间里业务照常读写原表,所有增量变化都会被触发器写进单独的日志表。
  3. 存量复制完成后,pg_repack 会把日志表里记录的增量变更回放到临时表,让两边数据追上。
  4. 最后在一个极短的事务里,把原表和临时表做交换,并重建索引、约束和触发器。

因为前两步都不需要锁原表,真正加锁的只有最后“交换”的那一小段,业务感受到的锁时间通常只有几百毫秒到几秒。这就是它能做到“在线”的核心原理。

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,每天凌晨跑一次,遇到膨胀超过阈值的表自动执行重建。

脚本逻辑不复杂,核心就三层:

  1. 用 SQL 扫描所有表,算出一个预估膨胀比,超过 20% 就进入候选列表。
  2. 对候选列表做排序,小表先重建,大表按窗口时间分批跑。
  3. 执行 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,建议先挑一张不重要的表练手,把整个输出日志读一遍,再对比一下重建前后的表大小和查询计划。踩过一次坑之后,再上生产心里就有底了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询