2 小时迁移 1TB!PeerDB 如何把 Postgres 大规模迁移从“数周工程”变成“分钟级切换”?
本文字数:3854;估计阅读时间:10 分钟
作者:Amogh Bharadwaj
Meetup活动
ClickHouse 深圳第3届 Meetup 火热报名中,详见文末海报!
我们在一月份发布了由 ClickHouse 管理的 Postgres。随着项目热度不断提升,一个关键的下一步是:帮助团队在最小停机时间的前提下,快速完成现有 Postgres 数据库的迁移。
在本文中,我们将介绍 PeerDB 如何在大规模场景下实现快速且可靠的 Postgres 迁移。我们会对 1TB 数据迁移在三种工具下的性能进行对比——pg_dump/pg_restore、本地逻辑复制以及 PeerDB。同时,也会深入分析 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 的迁移。
在绝大多数大规模迁移场景中,真正的瓶颈在于初始加载阶段,也就是将所有历史数据从源 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 started2026-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 表的同步。
测试矩阵
我们针对不同规模的数据表以及 PeerDB 不同的并行配置进行了初始加载测试,结果如下所示。
网络吞吐瓶颈
结果呈现出一个明显的趋势:在给定数据规模下,当并行度提升到一定阈值之后,负载会触及 RDS 实例的网络带宽上限,此时即使继续增加并行线程,性能提升也会趋于平缓。
源端网络吞吐的性能平台期
百分比对比
在整个测试矩阵中,PeerDB 的相对性能表现如下所示。
我们在基准测试中观察到的性能优势,核心原因在于 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 都会使用游标按批次抓取数据并进行流式传输。
弹性与可观测性
在使用 pg_dump 或本地逻辑复制进行单线程顺序加载时,一个常见痛点是无法细粒度地查看初始加载进度。而在 PeerDB 的并行初始加载模式下,你可以清晰看到已经同步的分区数量以及剩余分区数量,从而更准确地预估整体加载时间。
单分区顺序导出的另一个风险在于,一旦在中途发生故障,可能会导致数天甚至数周的进度全部丢失。PeerDB 为其执行的每一个操作都提供自动重试机制。在并行快照模式下,即使出现网络中断等间歇性错误,也只会影响某一个分区,该分区会立即重试,对整体初始加载时间的影响几乎可以忽略。
为了降低迁移成本,我们不仅关注并行能力,还尽量消除传输过程中或传输之后不必要的数据转换。
PostgreSQL 的网络协议支持两种客户端与服务器之间的数据传输格式。第一种是文本格式,数据以人类可读的字符串形式编码。这种方式在接收端需要进行解析,同时由于采用 ASCII 编码,会带来额外的网络开销。
PeerDB 采用的是二进制格式,数据以 Postgres 的原生二进制表示进行编码。这样一来,无需对数据进行解析,同时可以将完整的数据类型信息传递到目标端,从而确保目标表中的每一列都与源端保持完全一致的数据类型定义。
在处理 JSON 数组等复杂数据类型时,这种差异尤为明显。若以文本格式接收此类数据,在对 JSON 值进行反序列化时,可能会出现精度损失、不支持 NaNs 或 INFs 等常量,以及同步性能下降等问题。而在二进制格式下,则无需进行这些额外转换。
以原生二进制形式保留数据,可以最大程度避免类型不匹配或精度问题,从而在应用流量切换至目标端时,实现稳定、可预测且无缝的切换过程。
当初始快照完成后,系统会进入持续的变更数据捕获 (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 列值。
使用 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 columnsRANK() OVER (PARTITION BY (_peerdb_data ->> 'id') :: integer -- group by primary keyORDER BY_peerdb_timestamp DESC -- rank by latest) AS _peerdb_rankFROMpeerdb_temp._peerdb_raw_my_mirror -- contains all change-data of the mirrorWHERE_peerdb_batch_id = $ 1AND _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_columnsFROMsrc_rankWHERE_peerdb_rank = 1) src ON src."id" = dst."id"
3. 插入场景的处理相对直接。
WHEN NOT MATCHED THEN -- row is not on target, so it is an INSERTINSERT("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 targetAND src._peerdb_record_type != 2 -- this means it isn't a delete, so it's an updateAND _peerdb_unchanged_toast_columns = '' -- no unchanged toast columns, update everythingTHENUPDATESET"id" = src."id","blob" = src."blob","status" = src."status","_peerdb_synced_at" = CURRENT_TIMESTAMP
5. 在上述示例中,例如 blob 列在更新中未发生变化,则会按照如下方式进行处理:
WHEN MATCHED -- row exists on targetAND src._peerdb_record_type != 2 -- this means it isn't a delete, so it's an updateAND _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 PGTHENUPDATESET -- blob not updated here"id" = src."id","status" = src."status","_peerdb_synced_at" = CURRENT_TIMESTAMPWHEN MATCHEDAND src._peerdb_record_type = 2 THEN DELETE
在 ClickHouse,我们正在持续推进将 Postgres 迁移打造为“一键完成”的体验,而这正是迈出的第一步。欢迎持续关注我们即将发布的更多更新。
PeerDB 只需一条命令即可完成部署。你可以前往 GitHub 上的开源仓库快速开始。准备就绪后,按照文档指引操作,通过几次点击即可创建一个由 ClickHouse 管理的 Postgres 到 Postgres 的 mirror。
如果你希望亲自体验,可以注册由 ClickHouse 管理的 Postgres 私有预览版本,并借助我们的快速入门指南,在几分钟内启动一个高性能的 OLTP 技术栈。
好消息:ClickHouse Shenzhen User Group第 3 届 Meetup 火热报名中,将于2026年3月28日在深圳市南山区科技一路桑达大厦1楼 蓝马咖啡举行,扫码免费报名
/END/
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]