PostgreSQL 的表膨胀(bloat)问题一直让运维人员头疼——频繁的更新和删除留下大量死元组,即使 autovacuum 勤勉工作,表文件本身也不会缩小。要真正回收空间,就得重建整张表。但 VACUUM FULL 会拿 ACCESS EXCLUSIVE 锁,整张表读写全堵;pg_repack 和 pg_squeeze 作为第三方扩展解决了锁的问题,却带来额外的安装和维护负担。PostgreSQL 19 把 pg_squeeze 的核心思路收编进内核,推出了 REPACK CONCURRENTLY,终于让无锁重建表成为原生能力。
表膨胀是怎么来的
PostgreSQL 的 MVCC 机制意味着 UPDATE 和 DELETE 并不立即移除旧版本行,而是标记为死元组,等待 vacuum 清理。vacuum 能回收死元组占的空间供后续插入复用,但不会把表文件本身截短——除非死元组恰好在文件末尾且满足特定条件。
长期高更新率的表会逐渐膨胀:磁盘占用远大于有效数据量,顺序扫描变慢,索引深度增加。VACUUM FULL 是官方提供的重建手段,但它需要 ACCESS EXCLUSIVE 锁:
-- 这条命令运行期间,整张表无法读写
VACUUM FULL orders;
对于不能停服务的生产库,这基本不可用。
pg_repack / pg_squeeze 的思路与代价
社区先后出现了 pg_repack 和 pg_squeeze 两个扩展,核心策略相同:
- 创建一张新的临时表,结构与原表一致。
- 用触发器或逻辑解码捕获原表上的增量变更,同步到临时表。
- 把原表的数据批量拷贝到临时表。
- 追平增量后,用一个极短的事务交换两张表的 OID 和 relfilenode。
- 删除旧表。
整个过程原表只在一个极短的窗口内被锁(毫秒级),读写几乎不受影响。代价是:需要安装扩展、维护额外依赖、升级 PostgreSQL 时扩展可能不兼容,且 pg_repack 还要求目标表必须有主键或唯一索引。
REPACK CONCURRENTLY:内核级实现
PostgreSQL 19 的 REPACK CONCURRENTLY 把上述流程做进了内核,语法直观:
-- 无锁重建表,读写不中断
REPACK CONCURRENTLY orders;
-- 也可以只重建索引
REPACK CONCURRENTLY INDEX orders_pkey;
与扩展方案相比,原生实现有几个结构性优势:
- 无外部依赖:不需要编译安装扩展,升级 PostgreSQL 时不会因扩展版本不匹配而卡住。
- 内核级增量同步:不再依赖触发器或逻辑解码插件捕获变更,而是利用内核的事务日志机制,效率更高、一致性更可靠。
- 更短的锁窗口:交换表的瞬间操作在内核内部完成,锁持有时间比扩展方案更可控。
- 不强制要求主键:pg_repack 依赖主键或唯一索引来做行级增量同步,原生实现可以使用系统列或其他内部机制,适用范围更广。
实操示例:从 pg_repack 迁移到原生方案
假设你有一张高频更新的 user_events 表,当前膨胀率很高,过去用 pg_repack 定期重建:
-- 旧方案:需要先安装扩展,且表必须有主键
CREATE EXTENSION pg_repack;
-- 每次重建需要显式调用命令行工具
-- pg_repack -d mydb -t user_events
迁移到 PostgreSQL 19 后,直接用 SQL 命令即可:
-- 查看膨胀情况(估算死元组比例)
SELECT schemaname, relname,
n_dead_tup,
n_live_tup,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS bloat_pct
FROM pg_stat_all_tables
WHERE relname = 'user_events';
-- 当 bloat_pct 较高时,执行无锁重建
REPACK CONCURRENTLY user_events;
-- 重建后验证表大小
SELECT pg_size_pretty(pg_total_relation_size('user_events'));
如果你想对整个库中膨胀严重的表批量重建,可以组合查询:
-- 找出膨胀超过 20% 的用户表
DO $$
DECLARE
tbl RECORD;
BEGIN
FOR tbl IN
SELECT schemaname, relname
FROM pg_stat_user_tables
WHERE ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) > 20
AND n_live_tup > 10000 -- 避免对小表过度操作
LOOP
EXECUTE format('REPACK CONCURRENTLY %I.%I', tbl.schemaname, tbl.relname);
RAISE NOTICE 'Repacked: %.%', tbl.schemaname, tbl.relname;
END LOOP;
END $$;
注意:批量操作应在低峰期执行。虽然 REPACK CONCURRENTLY 不阻塞读写,但重建过程本身消耗 I/O 和 CPU,可能影响查询性能。
索引重建也能无锁
索引膨胀同样常见。过去用 REINDEX CONCURRENTLY(PostgreSQL 12 引入)可以无锁重建索引,现在 REPACK CONCURRENTLY INDEX 提供了另一种选择,语义上更贴近"压缩重建"而非单纯重建:
-- 无锁重建指定索引
REPACK CONCURRENTLY INDEX idx_user_events_created_at;
-- 或重建某张表上的所有索引
REPACK CONCURRENTLY TABLE user_events INDEX;
具体行为差异(REINDEX CONCURRENTLY vs REPACK CONCURRENTLY INDEX)需以 PostgreSQL 19 正式文档为准——当前信息来自早期提案,细节可能在发布前调整。
需要注意的边界
-
磁盘空间:REPACK CONCURRENTLY 需要同时容纳旧表和新临时表的完整数据,磁盘可用空间至少要等于目标表大小。如果空间紧张,这比
VACUUM FULL更危险——后者是原地重建,峰值空间占用更小。 -
长事务干扰:如果重建期间存在持有快照的长事务,增量追平阶段可能延迟,导致临时表积压大量未同步数据,拉长整体时间。
-
大表耗时:几百 GB 的表重建可能持续数小时,期间额外的 I/O 压力不可忽视。建议在监控中跟踪进度(具体进度视图以 19 版正式发布为准)。
-
扩展退役时机:pg_repack 和 pg_squeeze 在 19 版上仍然可用,但如果你全面采用原生方案,可以逐步卸载扩展,减少维护面:
-- 确认不再依赖后卸载
DROP EXTENSION pg_repack;
上线前的检查清单
- 确认磁盘剩余空间 ≥ 目标表 + 目标索引总大小。
- 在测试库上用
REPACK CONCURRENTLY对大表做一次完整演练,记录耗时和 I/O 影响。 - 检查是否有依赖 pg_repack 的自动化脚本或 cron 任务,提前改为
REPACK CONCURRENTLY。 - 对关键表先在低峰期单表试跑,观察读写延迟变化。
- 留意 PostgreSQL 19 正式发布时 REPACK 的最终语法和选项,早期版本可能仍有调整。
REPACK CONCURRENTLY 的加入,意味着 PostgreSQL 终于在内核层面补上了"无锁表重建"这块短板。对于任何维护高更新率生产库的团队,这都是 19 版最值得优先验证的特性之一。