PostgreSQL 19 在线重写表:REPACK CONCURRENTLY 的性能成本与上线边界

2026-09-28 32 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:16 分钟

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) 的核心流程仍然是“复制并替换”,但把长时间独占锁缩短到了最后阶段:

  1. 创建临时复制槽并取得一致性快照。
  2. 后台工作进程通过逻辑解码收集执行期间的更新和删除。
  3. 按初始快照把存活行复制到新文件,跳过死元组。
  4. 为新文件重建全部索引。
  5. 回放执行期间积累的变更并追赶当前状态。
  6. 获取 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 才能真正成为可控的日常维护工具。


相关推荐