ClickHouseInc

2 小时迁移 1TB!PeerDB 如何把 Postgres 大规模迁移从“数周工程”变成“分钟级切换”?

图片

本文字数:3854;估计阅读时间:10 分钟

作者:Amogh Bharadwaj

Image

Meetup活动

ClickHouse 深圳第3届 Meetup 火热报名中,详见文末海报!

图片

我们在一月份发布了由 ClickHouse 管理的 Postgres。随着项目热度不断提升,一个关键的下一步是:帮助团队在最小停机时间的前提下,快速完成现有 Postgres 数据库的迁移。

在本文中,我们将介绍 PeerDB 如何在大规模场景下实现快速且可靠的 Postgres 迁移。我们会对 1TB 数据迁移在三种工具下的性能进行对比——pg_dump/pg_restore、本地逻辑复制以及 PeerDB。同时,也会深入分析 PeerDB 为 Postgres 迁移专门打造的优化机制和功能特性。

Image

为什么选择 PeerDB 进行 Postgres 迁移?

对于许多团队来说,在线迁移是推动系统升级和平台切换的关键前提。数据库规模往往从数百 GB 到数 TB 不等,而生产环境通常无法接受长时间停机。因此,一个可行的在线迁移方案必须具备以下能力:

  • 具备高初始加载吞吐能力,高效地将现有数据从源 Postgres 复制到目标 Postgres。

  • 支持持续的变更数据捕获 (Change Data Capture, CDC),以保持源库和目标库之间的数据实时同步。

  • 能够兼容多样化的真实生产 PostgreSQL 工作负载,包括复杂数据类型、约束、模式演进、大型 TOAST 列等场景。

尽管 pg_dump/pg_restore、本地逻辑复制以及 AWS DMS 等工具各自适用于特定场景,但在面对大规模在线迁移时,通常需要在性能、可观测性 (observability) 和运维复杂度之间做出权衡。我们将在后续的基准测试部分对这些差异进行量化分析。

PeerDB 的 Postgres 到 Postgres 迁移能力正是为了解决上述约束而设计。它不仅提供高速的初始数据加载能力和持续的 CDC,还支持 TOAST 列而无需启用 REPLICA IDENTITY FULL,支持自动列新增,并且 PeerDB 完全开源。

无论是自托管环境、托管数据库,还是跨云厂商迁移场景,都可以使用 PeerDB 完成 Postgres 到 Postgres 的迁移。

Image

大规模 Postgres 迁移基准测试

在绝大多数大规模迁移场景中,真正的瓶颈在于初始加载阶段,也就是将所有历史数据从源 Postgres 复制到目标 Postgres。

虽然持续的变更数据捕获 (CDC) 可以在正式切换前保持双端数据同步,但历史数据的首次全量复制通常占据迁移周期的大部分时间。对于 TB 级别的数据集,这一阶段可能需要数天,甚至在某些情况下长达数周,具体取决于所使用的工具。

下面我们将对 pg_dump/pg_restore、本地逻辑复制以及 PeerDB 在初始加载阶段的性能进行对比。

环境设置 

本次基准测试的环境配置如下:

  • 源数据库:AWS RDS 上的 Postgres 18 实例,规格为 db.r8g.2xlarge,8 VCPU,64GB 内存,12000 预置 IOPS,gp3 存储。

  • 目标数据库:由 ClickHouse 管理的 Postgres 18,8 VCPU,64GB 内存,基于 NVMe 存储,容量 1875 GB,同样部署在 AWS。

  • EC2:c5d.12xlarge 实例,运行 ubuntu-noble-24.04-amd64。

上述所有组件均部署在 us-west-2c 区域。

数据 

我们使用的数据集为 firenibble 数据库,其中包含一个具备多种数据类型的表。本次基准测试基于单个大型表进行,这更贴近真实世界的数据库结构:虽然系统中可能包含数百张表,但在大规模迁移过程中,往往是一张(或少数几张)超大表成为主要性能瓶颈。

CREATE TABLE IF NOT EXISTS firenibble(        f0 BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,    f1 BIGINT,    f2 BIGINT,    f3 INTEGER,    f4 DOUBLE PRECISION,    f5 DOUBLE PRECISION,    f6 DOUBLE PRECISION,    f7 DOUBLE PRECISION,    f8 VARCHAR COLLATE pg_catalog."default",    f9 VARCHAR COLLATE pg_catalog."default",    f10 DATE,    f11 DATE,    f12 DATE,    f13 VARCHAR COLLATE pg_catalog."default",    f14 VARCHAR COLLATE pg_catalog."default",    f15 VARCHAR COLLATE pg_catalog."default");

用于创建和填充该表的工具已在 PeerDB 的公共仓库中开源,你可以在这里查看。数据填充时的字符串长度设置为 32。最终生成的表大小为 1TB,共写入 36 亿行数据。

tb_test=> select pg_size_pretty(pg_relation_size('firenibble')); pg_size_pretty ---------------- 1000 GB(1 row)

针对 1TB 表的测试

我们分别使用 PeerDB、pg_dump/pg_restore 以及本地逻辑复制三种工具,对上述 1TB 表执行初始加载,并将其迁移至由 ClickHouse 管理的 Postgres。

pg_dump 和 pg_restore

由于本次测试只涉及单张表,我们在 dump 与 restore 之间采用了流式传输方式,形式如下:

 time pg_dump   -d '<source_postgres_connection_string>'   -Fc   -t firenibble   --data-only   --verbose   | pg_restore     -d '<destination_postgres_connection_string>'   --data-only   --verbose   --no-owner   --no-acl

这种方式可以在无需等待 dump 完成的情况下,同时进行导出和恢复操作。同时,也避免了先将数据落盘所带来的额外 IOPS 开销。整个加载过程使用的是 pg_dump 的默认压缩配置。

另外需要说明的是,pg_dump 和 pg_restore 并不支持对单张表进行并行加载。我们将在本文后续部分介绍 PeerDB 是如何实现这一能力的。

pg_restore: connecting to database for restorepg_restore: processing data for table "public.firenibble"pg_restore: executing SEQUENCE SET firenibble_f0_seqreal    1024m58.739suser    1133m6.474ssys     39m19.008s

完整表在目标端加载完成共耗时 17 小时 5 分钟。

本地逻辑复制

源端 RDS 实例作为发布端,仅为这张表创建了一个 publication。

tb_test=> CREATE PUBLICATION fire_pub FOR TABLE firenibble;CREATE PUBLICATION

随后,在由 ClickHouse 管理的 Postgres 上创建 subscription,指向上述数据库和对应的 publication。

logical_replication_test=# CREATE SUBSCRIPTION rds_subscriptionlogical_replication_test-# CONNECTION '<source_connection_string>'logical_replication_test-# PUBLICATION fire_pub;logical_replication_test=# NOTICE:  created replication slot "rds_subscription" on publisherlogical_replication_test-# CREATE SUBSCRIPTION

创建完成后会立即触发初始加载。需要注意的是,这里同样无法对单张表进行并行加载;整个过程由 Postgres 的单个同步 worker 负责完成。

// grep Postgres subscriber logs for "synchronization"2026-02-17 21:34:27.175 UTC [35026:1] (0,521/2): host=,db=,user=,app=,client= LOG: logical replication table synchronization worker for subscription "rds_subscription", table "firenibble" has started 2026-02-18 06:15:18.842 UTC [35026:2] (0,521/8): host=,db=,user=,app=,client= LOG: logical replication table synchronization worker for subscription "rds_subscription", table "firenibble" has finished

最终,本地逻辑复制在 8 小时 40 分钟内完成了数据加载。

8 线程并行的 PeerDB

PeerDB 的架构由 peer 和 mirror 组成:peer 用于连接数据存储,mirror 则是在 peer 之间建立的数据传输管道。在本次测试中,源端 peer 为 RDS,目标 peer 为由 ClickHouse 管理的 Postgres。

在 PeerDB 中创建 mirror 时,可以配置初始加载的并行度,以及其他参数,用于控制逻辑分区大小和跨表并行度。

由于本次测试仅包含 1 张表,我们将跨表并行度设置为 1,每表并行度设置为 8。

最终,PeerDB 在 1 小时 49 分钟内完成了这张 1TB 表的同步。

Image

测试矩阵 

我们针对不同规模的数据表以及 PeerDB 不同的并行配置进行了初始加载测试,结果如下所示。

Image

网络吞吐瓶颈

结果呈现出一个明显的趋势:在给定数据规模下,当并行度提升到一定阈值之后,负载会触及 RDS 实例的网络带宽上限,此时即使继续增加并行线程,性能提升也会趋于平缓。

Image

源端网络吞吐的性能平台期

百分比对比

在整个测试矩阵中,PeerDB 的相对性能表现如下所示。

Image

Image

为什么 PeerDB 比 pg_dump/pg_restore 和本地逻辑复制更快?

我们在基准测试中观察到的性能优势,核心原因在于 PeerDB 执行初始加载的机制。PeerDB 的并行快照 (Parallel Snapshotting) 通过基于 CTID 对单张大表进行逻辑分区,实现单表级别的并行初始加载。在保持一致性快照视图的前提下,并发地对各个分区进行流式传输,相比单线程的全表扫描方式,能够显著缩短加载时间。下面我们来看看其具体工作原理:

一致性 

整个流程首先通过 pg_export_snapshot() 在源数据库上创建一个一致性快照,从而确保所有并行线程都基于同一个时间点读取数据。

使用 CTID 进行逻辑分区

CTID 是每张表自带的系统列,用于表示一行数据在 Postgres 表中的物理存储位置。

我们基于 CTID 将整张表划分为多个逻辑分段。通过将表拆分为若干个 CTID 范围,可以构造出彼此独立的数据分块。

随后,每个 worker 依次读取一个 CTID 范围,通过限定在该范围内的 SELECT 查询获取数据,并将结果流式传输到目标端。

按 CTID 范围过滤之所以高效,是因为查询可以直接基于表中的物理行位置进行扫描。在实际执行过程中,数据库能够按照数据在磁盘上的存储顺序读取行数据。按照物理存储顺序读取不仅可以提升 I/O 性能,还能避免反复扫描表的相同区域。

在较早版本的 Postgres 中,不支持 TID 范围扫描。在这种情况下,PeerDB 还提供另外两种分区策略——MinMax 和 NTILE——但本文不作展开。

流式传输到目标端

上述生成的每个逻辑分区,都会通过 PostgreSQL 的二进制 COPY 协议进行传输:

  • 源端执行 COPY TO STDOUT

  • 目标端执行 COPY FROM STDIN

这种方式可以在无需写入中间文件的情况下,同时完成导出与恢复操作。相比文本格式,二进制格式的开销更低,后文会进一步说明。

为了避免占用过多内存,每个 worker 都会使用游标按批次抓取数据并进行流式传输。

Image

弹性与可观测性 

在使用 pg_dump 或本地逻辑复制进行单线程顺序加载时,一个常见痛点是无法细粒度地查看初始加载进度。而在 PeerDB 的并行初始加载模式下,你可以清晰看到已经同步的分区数量以及剩余分区数量,从而更准确地预估整体加载时间。

Image

单分区顺序导出的另一个风险在于,一旦在中途发生故障,可能会导致数天甚至数周的进度全部丢失。PeerDB 为其执行的每一个操作都提供自动重试机制。在并行快照模式下,即使出现网络中断等间歇性错误,也只会影响某一个分区,该分区会立即重试,对整体初始加载时间的影响几乎可以忽略。

Image

使用 PostgreSQL 二进制格式保障数据保真

为了降低迁移成本,我们不仅关注并行能力,还尽量消除传输过程中或传输之后不必要的数据转换。

PostgreSQL 的网络协议支持两种客户端与服务器之间的数据传输格式。第一种是文本格式,数据以人类可读的字符串形式编码。这种方式在接收端需要进行解析,同时由于采用 ASCII 编码,会带来额外的网络开销。

PeerDB 采用的是二进制格式,数据以 Postgres 的原生二进制表示进行编码。这样一来,无需对数据进行解析,同时可以将完整的数据类型信息传递到目标端,从而确保目标表中的每一列都与源端保持完全一致的数据类型定义。

在处理 JSON 数组等复杂数据类型时,这种差异尤为明显。若以文本格式接收此类数据,在对 JSON 值进行反序列化时,可能会出现精度损失、不支持 NaNs 或 INFs 等常量,以及同步性能下降等问题。而在二进制格式下,则无需进行这些额外转换。

以原生二进制形式保留数据,可以最大程度避免类型不匹配或精度问题,从而在应用流量切换至目标端时,实现稳定、可预测且无缝的切换过程。

Image

高效且可靠的 CDC

当初始快照完成后,系统会进入持续的变更数据捕获 (Change Data Capture, CDC) 阶段,在正式切换前持续保持源端与目标端的数据同步。在持续写入负载的场景下,这一阶段对于降低停机时间、保障数据一致性至关重要。

本节将重点介绍两个核心工程问题:如何高效消费 replication slot,以及如何降低复制开销,尤其是在面对大字段或复杂行结构时。

读取 replication slot

在高吞吐数据摄取场景下,持续及时地消费 replication slot 非常关键。如果复制延迟不断累积,slot 中堆积的 WAL 会占用源端 Postgres 实例的存储空间,严重时只能通过删除 slot 并执行全量重新同步来恢复。

PeerDB 在多个同步批次之间复用同一条复制连接,确保 replication slot 始终处于活跃消费状态。同时,它会定期向 Postgres 发送 standby 状态更新,避免触发复制超时。

在架构层面,PeerDB 将“从源端拉取数据”和“向目标端写入数据”设计为彼此独立的流程。因此,即便写入目标端发生故障,也不会影响 replication slot 的持续消费。

此外,PeerDB 开箱即用地支持通过 Slack 和 Email 发送 replication slot 延迟告警,帮助你提前发现风险,避免对业务工作负载造成影响。

支持未变更的 TOAST 列

TOAST 是 PostgreSQL 用于处理大字段值的机制。在使用 Postgres 默认逻辑解码插件 pgoutput 进行 CDC 时,未发生变化的 TOAST 列不会出现在复制流中,而是以 NULL 形式呈现,这在数据迁移过程中会带来问题。这是逻辑解码用户常见的一个陷阱。通常可以通过在源表上设置 REPLICA IDENTITY FULL 来解决,但这意味着需要修改源数据库配置,很多用户对此较为谨慎。

PeerDB 在 CDC 过程中支持未变更 TOAST 列的完整传输,而无需在源表上设置 REPLICA IDENTITY FULL。其核心思路是依赖目标端此前存储的 TOAST 列值,对未变化的列进行重建。

下面我们来看一下内部实现算法。

批次内回填的缓存机制 

在从 Postgres 读取逻辑复制消息时,PeerDB 会为当前批次维护一个 CDC 记录缓存。

借助 Postgres 逻辑复制协议中的元信息,可以识别出包含未变更 TOAST 列的 UPDATE 操作。随后,PeerDB 会在同一批次内查找之前的 INSERT 或 UPDATE 记录,从中补齐缺失的 TOAST 列值。

Image

使用 MERGE 恢复 TOAST 列的历史状态

PeerDB 会将所有拉取到的变更数据写入一张原始表。在这个过程中,我们会将批次内每个表涉及的 TOAST 列唯一组合一并存储在原始表中。

随后,通过 Postgres 的 MERGE 命令,将原始表中的插入、更新和删除操作同步到目标表,同时确保目标表中的未变更 TOAST 列值保持不变。

我们可以结合上图中的表结构,通过一个简化的 MERGE 示例来说明这一过程。

1. 首先,根据主键对记录进行分组,并按照从源端拉取的时间顺序进行排序。

WITH src_rank AS (    SELECT        _peerdb_data, -- contains the change-data record (insert, update or delete)        _peerdb_record_type, -- says if it's insert(0), update(1) or delete(2)        _peerdb_unchanged_toast_columns, -- comma separated string of columns        RANK() OVER (            PARTITION BY (_peerdb_data ->> 'id') :: integer -- group by primary key            ORDER BY                _peerdb_timestamp DESC -- rank by latest        ) AS _peerdb_rank    FROM        peerdb_temp._peerdb_raw_my_mirror -- contains all change-data of the mirror    WHERE        _peerdb_batch_id = $ 1        AND _peerdb_destination_table_name = $ 2)

2. 接着,执行 MERGE 命令,将每条变更数据应用到最终表中。

MERGE INTO "public"."my_table" dst USING (    SELECT        (_peerdb_data ->> 'id') AS "id",        (_peerdb_data ->> 'blob') AS "blob",        (_peerdb_data ->> 'status') AS "status",        _peerdb_record_type,        _peerdb_unchanged_toast_columns    FROM        src_rank    WHERE        _peerdb_rank = 1) src ON src."id" = dst."id"

3. 插入场景的处理相对直接。

   WHEN NOT MATCHED THEN -- row is not on target, so it is an INSERT        INSERT            ("id", "blob", "status", "_peerdb_synced_at")        VALUES            (                src."id",                src."blob",                src."status",                CURRENT_TIMESTAMP            )

4. 随后是更新场景下的冲突处理逻辑。在更新记录中,_peerdb_unchanged_toast_columns 是一个以逗号分隔的字符串列表,表示值未发生变化的列名。如果不存在未变更列,则该字段为空字符串。

   WHEN MATCHED -- row exists on target    AND src._peerdb_record_type != 2  -- this means it isn't a delete, so it's an update    AND _peerdb_unchanged_toast_columns = '' -- no unchanged toast columns, update everything    THEN        UPDATE        SET            "id" = src."id",            "blob" = src."blob",            "status" = src."status",            "_peerdb_synced_at" = CURRENT_TIMESTAMP

5. 在上述示例中,例如 blob 列在更新中未发生变化,则会按照如下方式进行处理:

   WHEN MATCHED -- row exists on target    AND src._peerdb_record_type != 2 -- this means it isn't a delete, so it's an update    AND _peerdb_unchanged_toast_columns = 'blob' -- unchanged toast column ! we cannot update this guy, because it would wipe out its value to empty string sent by PG THEN         UPDATE        SET -- blob not updated here            "id" = src."id",            "status" = src."status",            "_peerdb_synced_at" = CURRENT_TIMESTAMP            WHEN MATCHED            AND src._peerdb_record_type = 2 THEN DELETE

Image

展望与快速开始

在 ClickHouse,我们正在持续推进将 Postgres 迁移打造为“一键完成”的体验,而这正是迈出的第一步。欢迎持续关注我们即将发布的更多更新。

PeerDB 只需一条命令即可完成部署。你可以前往 GitHub 上的开源仓库快速开始。准备就绪后,按照文档指引操作,通过几次点击即可创建一个由 ClickHouse 管理的 Postgres 到 Postgres 的 mirror。

如果你希望亲自体验,可以注册由 ClickHouse 管理的 Postgres 私有预览版本,并借助我们的快速入门指南,在几分钟内启动一个高性能的 OLTP 技术栈。

图片
Meetup 活动报名通知

好消息:ClickHouse Shenzhen User Group第 3 届 Meetup 火热报名中,将于2026年3月28日在深圳市南山区科技一路桑达大厦1楼 蓝马咖啡举行,扫码免费报名图片图片

Image

/END/

试用阿里云 ClickHouse企业版

轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

图片
图片

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]

图片图片