PostgreSQL 19 把在线表重写带进了内核:REPACK (CONCURRENTLY) 可以在业务继续读写时压缩膨胀表、重建索引,并在末尾切换到新文件。它不依赖扩展,也不要求配置 shared_preload_libraries,因此托管 PostgreSQL 服务也更容易采用。
不过,“并发执行”不等于“没有阻塞”。它会制造大量 I/O 和 WAL,持有旧快照、拖住 VACUUM,还需要在最后获取 ACCESS EXCLUSIVE 锁。更重要的是,PostgreSQL 19 beta4 中还有 MVCC 可见性和变更数量上限等明确边界。生产环境真正需要评估的,不只是命令能不能执行,而是它能否在业务允许的时间、磁盘、内存和锁等待预算内完成。
本文涉及的具体行为和数据来自 PostgreSQL 19 beta4。正式版以及后续版本可能改变实现细节,尤其应在升级前核对当前版本文档和已知问题。
它如何在读写不停的情况下重写表
普通 VACUUM FULL 会获取 ACCESS EXCLUSIVE 锁,复制存活行、重建索引,然后替换文件。整个过程里,其他会话无法正常访问该表。
REPACK (CONCURRENTLY) 的核心流程仍然是“复制并替换”,但把长时间独占锁缩短到了最后阶段:
- 创建临时复制槽并取得一致性快照。
- 后台工作进程通过逻辑解码收集执行期间的更新和删除。
- 按初始快照把存活行复制到新文件,跳过死元组。
- 为新文件重建全部索引。
- 回放执行期间积累的变更并追赶当前状态。
- 获取
ACCESS EXCLUSIVE锁,回放最后一批变更,交换文件并提交。
从取得快照到最终交换,整个操作处在一个事务中。它不需要像 pg_repack 那样在原表安装触发器,也不会让每次业务写入额外插入一条日志表记录。
这直接反映在 WAL 上。对一张包含 9000 万行、删除三分之一后仍有 6000 万存活行、总大小约 27.8 GB 且有三个索引的测试表,空闲负载下的结果如下:
| 工具 | 用时 | WAL | 峰值额外磁盘 |
|---|---|---|---|
VACUUM FULL |
109 秒 | 17.3 GB | 18.7 GB |
REPACK (CONCURRENTLY) |
107 秒 | 18.8 GB | 18.7 GB |
pg_repack |
162 秒 | 33.5 GB | 18.5 GB |
pg_squeeze |
165 秒 | 18.8 GB | 21.2 GB |
在持续每秒 3000 次单行更新时,REPACK (CONCURRENTLY) 用时 130 秒、产生 24.5 GB WAL;pg_repack 则用了 198 秒并产生 43.4 GB WAL。后者的触发器日志和逐行记录复制会把更多数据推入 WAL、归档、备份和每一个流复制节点。
容量规划时,至少应预留:
- 表和全部索引的第二份副本;
- 执行期间产生的 WAL;
- 收集并等待回放的变更;
- 文件系统和托管平台要求的安全余量。
不要只看 heap 大小。评估对象应是 pg_total_relation_size(),因为索引也会完整重建。
真正危险的地方在 VACUUM、旧快照和最终锁
旧快照会让死元组继续堆积
在线重写必须保留开始时看到的数据版本。临时逻辑复制槽及相关后端会阻止 VACUUM 清除这些旧版本。VACUUM 仍然能够运行,但它可能无法真正回收死元组;ANALYZE 通常仍可更新统计信息。
复制槽是整个 PostgreSQL 实例级别的对象,而不只属于某个数据库。因此,REPACK (CONCURRENTLY) 运行期间,不只是目标表所在数据库,实例中其他数据库的 VACUUM 也可能受到旧 xmin 的影响。长时间重写与另一个数据库的大批量更新任务叠加时,膨胀可能迅速扩散。
可以在运行期间持续观察目标库中的趋势:
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
由于普通 SQL 连接只能查看当前数据库,这条查询应在实例中各个繁忙数据库分别执行。
最终交换仍然需要 ACCESS EXCLUSIVE
当 REPACK 请求最终锁时,它必须等待所有已经持有目标表相关锁的事务,包括长报表、ETL、未提交事务,以及读取过该表后处于 idle in transaction 的会话。
PostgreSQL 锁队列还会放大影响:REPACK 排在长查询后面等待时,后续新来的查询可能又排在 REPACK 后面。结果不是单个维护命令在等,而是整张表突然停止服务。
设置 lock_timeout 可以限制事故半径:
SET lock_timeout = '3s';
SET statement_timeout = '2h';
REPACK (CONCURRENTLY) public.orders;
执行前请把 public.orders 改成目标表,并根据实测重写时间调整 statement_timeout。
这里存在一个艰难取舍:
- 不设置
lock_timeout,应用可能一直等待最慢的旧事务结束; - 设置得较短,最终拿锁失败会丢弃前面所有复制、索引构建和追赶工作;
- PostgreSQL 19 beta4 的 REPACK 最终锁只尝试一次,不像
pg_repack那样可以短暂放弃后重试。
测试中,持续写入场景下 REPACK (CONCURRENTLY) 的写入者在最终阶段完全停止了约 7 秒。但这不是固定成本。在受限云盘上,28 GB 表以每秒 500 次更新运行时停写约 13 秒;100 GB 表的停写时间达到 231 秒。磁盘被写满负载时,28 GB 表甚至出现了约 432 秒的停写。
因此,“绝大部分时间在线”不能代替对最终停顿时间的压测。
PostgreSQL 19 的旧快照可见性问题
在 PostgreSQL 19 beta4 中,REPACK (CONCURRENTLY) 被明确标注为非 MVCC-safe。一个较早开始的只读 REPEATABLE READ 事务,如果先从别的表取得快照,并在 REPACK 交换完成后才读取目标表,可能看到空表。
原因是新文件中的行都由 REPACK 事务写入;对旧快照而言,这些行来自“未来事务”,因此不可见。新连接却能正常看到所有数据。
这对以下工作负载尤其危险:
- 长时间一致性导出;
- 报表任务先建立快照、稍后再扫描目标表;
- 使用
REPEATABLE READ保证跨表一致性的批处理。
执行前可以查找长事务:
SELECT
pid,
datname,
usename,
state,
xact_start,
now() - xact_start AS transaction_age,
wait_event_type,
wait_event,
left(query, 120) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND pid <> pg_backend_pid()
ORDER BY xact_start;
不要仅仅看到 state = 'idle in transaction' 就直接终止会话;先确认应用所有者和事务用途。PostgreSQL 20 计划改进 MVCC 安全性,但 PostgreSQL 19 上应把调度隔离作为必要措施。
先计算每秒变更率,再决定能不能跑
PostgreSQL 19 beta4 的另一个硬边界来自单事务内保存的 combo command ID。目标表中每一行更新或删除,REPACK 后端大约需要保留 50 字节状态直到提交;普通插入不计入这个限制。
当累计更新与删除超过约 104,857,600 行时,内部数组下一次扩容会超过单次内存分配限制。增加服务器内存也无法绕过这个固定上限。更早的时候,容器或托管实例还可能先触发 OOM:测试中,1 GB 和 4 GB 内存限制都导致后端被 OOM killer 杀死,并触发整个 PostgreSQL 实例 crash restart。
粗略预算公式是:
允许的每秒更新与删除行数 ≈ 29,000 / REPACK 预计持续小时数
注意这里计算的是行,不是 SQL 语句。一条更新 50 万行的 UPDATE 会消费 50 万个变更额度。
下面的 SQL 可以在同一个会话中采样变更率。先执行第一段,等待一个有代表性的时间窗口——最好覆盖批处理——再执行第二段:
CREATE TEMP TABLE chg_sample AS
SELECT
relid,
n_tup_upd + n_tup_del AS changes,
now() AS sampled_at
FROM pg_stat_user_tables;
-- 等待 10 分钟或更长,并保持当前会话不退出。
SELECT
s.schemaname,
s.relname,
pg_size_pretty(pg_total_relation_size(s.relid)) AS total_size,
round(
(s.n_tup_upd + s.n_tup_del - c.changes)
/ extract(epoch FROM now() - c.sampled_at)
) AS changes_per_sec,
round(
104857600.0
/ nullif(
(s.n_tup_upd + s.n_tup_del - c.changes)
/ extract(epoch FROM now() - c.sampled_at),
0
)
/ 3600,
1
) AS hours_until_ceiling
FROM pg_stat_user_tables AS s
JOIN chg_sample AS c USING (relid)
ORDER BY changes_per_sec DESC NULLS LAST
LIMIT 20;
hours_until_ceiling 是按采样速率推算的理论最长执行时间。生产决策不应贴着上限运行:日均值可能很低,但一个夜间任务就可能更新 1.2 亿行。建议把估算变更总量控制在硬上限的一小部分,并额外核对所有批处理计划。
一套可执行的上线前检查
1. 确认表具备可用的 replica identity
目标表必须有主键,或使用非部分、非可空的唯一索引作为 REPLICA IDENTITY USING INDEX。可延迟主键不满足要求,REPLICA IDENTITY FULL 和 NOTHING 也不受支持。
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
c.relreplident,
i.indexrelid::regclass AS replica_identity_index
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
LEFT JOIN pg_index AS i
ON i.indrelid = c.oid
AND i.indisreplident
WHERE c.oid = 'public.orders'::regclass;
relreplident 为 d 通常表示使用主键,i 表示显式 replica identity 索引。分区表父表不能直接执行 REPACK,需要逐个处理叶子分区。
2. 估算时间、磁盘和 WAL
在相似硬件上对副本或同规模测试表做普通重写计时。云盘吞吐上限往往比 CPU 更早成为瓶颈。来源测试中,本地 NVMe 的重写速度约为每小时 0.9 TB,而 4 vCPU 云主机配持久化 SSD 只有约每小时 0.25 TB。
检查对象大小与磁盘余量:
SELECT
pg_size_pretty(pg_relation_size('public.orders')) AS heap,
pg_size_pretty(pg_indexes_size('public.orders')) AS indexes,
pg_size_pretty(pg_total_relation_size('public.orders')) AS total;
还应检查 max_repack_replication_slots 是否有空位、max_worker_processes 是否留有后台工作进程名额。wal_level = replica 即可,但临时逻辑槽存在时,PostgreSQL 19 会让整个实例采用逻辑解码所需的有效 WAL 级别,直到最后一个逻辑槽删除后的下一个 checkpoint。
3. 清理锁风险,而不是盲目杀会话
重点排查:
- 长时间运行且访问目标表的查询;
idle in transaction会话;- ETL、备份和报表窗口;
- 另一个数据库中的大规模更新或删除;
- 长时间
REPEATABLE READ导出任务。
运行期间同时监控复制槽、WAL 和活动会话:
SELECT slot_name, slot_type, active, xmin, catalog_xmin,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;
SELECT pid, state, wait_event_type, wait_event,
now() - query_start AS query_age,
left(query, 100) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start NULLS LAST;
该选 REPACK、pg_repack 还是分区
进入 PostgreSQL 19 后,REPACK (CONCURRENTLY) 对大多数场景是更合理的默认选择:它不需要扩展,测试中速度最快,在持续写入时产生的 WAL 少于 pg_repack,失败后的清理也更可靠。它尤其适合无法安装扩展或修改 shared_preload_libraries 的托管服务。
但它不是无条件替代品:
- 最终锁可能造成明显停写,慢存储和高写入率会放大停顿;
- 临时复制槽会跨数据库拖住 VACUUM;
- PostgreSQL 19 存在旧快照读取空表的 MVCC 风险;
- 更新和删除累计约 1.05 亿行是硬上限;
- 高变更量可能消耗数 GB 后端内存,容器中甚至可能导致实例重启。
对于数 TB 的高写入单表,最可靠的方案通常不是寻找更强的重写工具,而是分区。REPACK 可以逐个处理叶子分区:历史分区几乎没有写入,单次操作更短,锁、内存和变更预算也更容易控制。
上线前可以用这份简短清单做最终确认:
- [ ] 用真实存储吞吐估算了执行时长;
- [ ] 更新与删除速率乘以时长,显著低于 1.05 亿行;
- [ ] 为表、索引、WAL 和变更数据预留了磁盘;
- [ ] 核对了主键或 replica identity;
- [ ] 排除了长事务、长查询和一致性导出;
- [ ] 明确设置了
lock_timeout,并接受超时后整次工作回滚; - [ ] 准备了跨数据库的
n_dead_tup、WAL、内存和锁等待监控; - [ ] 在同版本、相似存储的环境里做过一次带写入压力的演练。
CONCURRENTLY 的准确含义是“把大部分独占时间移走”,而不是“对业务无感”。只要把变更预算、最终锁和旧快照当成上线门槛,PostgreSQL 19 的内建 REPACK 才能真正成为可控的日常维护工具。