• Welcome to HiddenMerit - Clyde's Blog
  • Welcome to try the game Torn: Referral Link
  • If you are my relative, friend, or netizen, quickly press Ctrl+D to bookmark Clyde's Blog
  • This site has a like feature. If you read any article, please hit the like button so I know someone has visited
  • Email: hiddenmeritATgmail.com (replace AT with @)

PostgreSQL 复制与高可用系列(四):逻辑复制深度实战——跨版本迁移、数据分发与冲突处理

DBA Clyde Jin 3周前 (04-24) 13次浏览 0个评论

PostgreSQL 复制与高可用系列(四):逻辑复制深度实战——跨版本迁移、数据分发与冲突处理

前三期我们完成了物理流复制从理论到实战的全链路学习,掌握了高可用架构的基石。 但物理复制有一个天然局限:它复制的是整个数据库集群,无法“选择性地同步几张表”,也不能在主备之间做异构同步(比如 12 → 17 跨大版本)。 第四期将聚焦逻辑复制——这张灵活的“手术刀”,为您打开数据同步的全新维度。

一、逻辑复制是什么?

1.1 定义与定位

逻辑复制是一种基于数据对象的复制标识(通常是主键)来复制数据对象及其更改的方法。这里使用“逻辑”一词来与物理复制(基于块地址和逐字节复制)加以区分——物理复制关注的是磁盘上数据块的变化,而逻辑复制关注的是数据的语义变化(插入、更新、删除)。

核心特点:PostgreSQL 同时支持物理复制和逻辑复制两种机制,两者可以并行运行,不互斥。这给了架构设计极大的灵活性。

1.2 架构:一次 WAL 的两面使用

逻辑复制的架构与物理流复制类似,同样由 walsenderapply 进程协作实现:

  1. 发布者walsender 进程启动对 WAL 的逻辑解码,加载标准输出插件 pgoutput。该插件将从 WAL 读取的变更转化为逻辑复制协议,并根据发布(Publication)规范进行筛选。
  2. 传输:数据通过流复制协议持续传输到订阅者的 apply 工作进程。
  3. 订阅者apply 进程将数据映射到本地表,并按照发布者上的提交顺序应用这些变更,确保事务一致性。

一个关键差异在于:逻辑复制 不会复制 DDL 命令——数据库模式和 DDL 命令不被复制。这一点下文会单独讨论。

至于订阅者的触发器行为,默认设置是会话复制角色为 replica,因此默认情况下触发器不触发;但用户可以通过 ALTER TABLE ... ENABLE TRIGGER 手动启用。

1.3 WAL 级别要求:一个关键决策点

逻辑复制要求 wal_level 设置为 logical。这个级别会写入比 replica 更多的 WAL 信息,以满足逻辑解码器的需求。请注意,设置为 logical 会增加 WAL 日志体积,可能对性能产生一定负面影响,因此在选择前需要审视具体的业务负载。如果只是临时启用逻辑复制(如一次性迁移),建议完成后恢复到 replica 以降低 WAL 开销。

二、发布与订阅模型

逻辑复制基于发布/订阅(Publication/Subscription)模型。概念定义如下:

  • 发布者:数据来源方,定义哪些表需要被复制(发布或 Publication)
  • 订阅者:数据目标方,定义连接信息和要订阅的发布(订阅或 Subscription)
  • 复制槽:发布者上持久化的标记,记录每个订阅者的消费进度,防止 WAL 被过早回收

2.1 创建发布(Publication)

-- 发布指定表的 INSERT 和 UPDATE(默认同步所有 DML)
CREATE PUBLICATION my_publication FOR TABLE users, orders;

-- 发布所有表(慎用,对系统表也会产生影响)
CREATE PUBLICATION all_tables FOR ALL TABLES;

-- 控制发布的 DML 类型:只复制 INSERT 和 UPDATE,不复制 DELETE
CREATE PUBLICATION insert_update_only 
FOR TABLE users 
WITH (publish = 'insert, update');

-- 仅发布部分列(最小化发送开销)
CREATE PUBLICATION limited_columns
FOR TABLE users (id, name, email);

publish 参数的可选值包括:insertupdatedeletetruncate,默认为全部四种。

2.2 创建订阅(Subscription)

-- 基础订阅:连接发布者并开始实时复制
CREATE SUBSCRIPTION my_subscription
CONNECTION 'host=192.168.1.10 port=5432 dbname=mydb user=replicator password=xxx'
PUBLICATION my_publication;

-- 仅复制新增数据,不拷贝已有数据(用于已有数据的场景)
CREATE SUBSCRIPTION delta_only
CONNECTION '...'
PUBLICATION my_publication
WITH (copy_data = false);

-- 禁用复制槽自动创建(专家场景,性能调优用)
CREATE SUBSCRIPTION manual_slot
CONNECTION '...'
PUBLICATION my_publication
WITH (create_slot = false, enabled = false);

一个订阅可以订阅来自同一个发布者的多个发布。当创建订阅时,如果 copy_data = true(默认),将在开始逻辑复制前,自动将发布者上的已有数据快照拷贝到订阅者。

2.3 查看与管理

-- 查看发布列表
SELECT * FROM pg_publication;

-- 查看发布的表
SELECT * FROM pg_publication_tables;

-- 查看订阅列表与状态
SELECT * FROM pg_subscription;

-- 查看复制槽状态
SELECT slot_name, plugin, slot_type, active, restart_lsn 
FROM pg_replication_slots;

三、快速搭建:跨版本迁移场景

假设要将一个 PostgreSQL 12 的 inventory 数据库迁移到 PostgreSQL 17(零停机在线迁移)。逻辑复制的典型用法是:升级数据库大版本、跨操作系统平台(如 Windows → Linux)复制、跨硬件架构(X86 → ARM)迁移等。

步骤 1:确保发布者有主键

逻辑复制依赖 REPLICA IDENTITY 来唯一标识行。如果表没有主键,UPDATEDELETE 操作无法正确同步到订阅者。

-- 检查哪些表没有主键
SELECT n.nspname AS schema, c.relname AS table
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = c.oid AND contype = 'p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema');

如果查询返回了表,请添加主键或设置 REPLICA IDENTITY FULL。后者有性能代价,但可作备选方案。

步骤 2:在 PostgreSQL 12(源库)启用逻辑解码

# postgresql.conf
wal_level = logical
max_replication_slots = 10          # 至少等于发布数量
max_wal_senders = 10                # 至少等于复制槽 + 备份等连接的预留

修改后需要重启。同时,pg_hba.conf 需允许目标库(新版本)的复制连接,并创建复制用户。

步骤 3:在 PostgreSQL 17(目标库)导入 Schema

由于 DDL 不会自动复制,需手动迁移表结构:

# 导出源库 schema(只导出结构,不导出数据)
pg_dump -h 192.168.1.10 -U postgres -d inventory --schema-only -f schema.sql

# 在目标库执行
psql -h 192.168.1.20 -U postgres -d inventory -f schema.sql

步骤 4:创建发布与订阅

-- 源库(12)上创建发布
CREATE PUBLICATION inv_pub FOR ALL TABLES;  -- 或指定表名列表

-- 目标库(17)上创建订阅
CREATE SUBSCRIPTION inv_sub
CONNECTION 'host=192.168.1.10 port=5432 dbname=inventory user=repuser'
PUBLICATION inv_pub
WITH (copy_data = true);

copy_data = true 会先执行一次全量数据拷贝,然后进入增量同步模式。数据量较大时,可考虑使用 PostgreSQL 17 的并行表同步能力(此功能已在 17 中上线,多个工作进程并行拷贝不同表,大幅缩短迁移窗口)。

步骤 5:验证切换

等待复制延迟追平后:

-- 在目标库上检查订阅状态
SELECT subname, subenabled, subslotname, suborigin FROM pg_subscription;

-- 在主库上查看复制进度
SELECT application_name, state, replay_lag 
FROM pg_stat_replication;

延迟追平后,停止对源库的写入,将应用流量指向目标库,源库降级。

四、DDL 不同步问题的深度解析

4.1 为什么 DDL 不会被复制?

这是逻辑复制当前最重要的限制之一。官方文档明确指出:“数据库模式和 DDL 命令不会被复制。”。

这意味着:在表上执行 ALTER TABLE ADD COLUMN 后,订阅者上需要手动执行同样的 DDL,否则复制会因表结构不匹配而报错。

4.2 设计哲学:为什么?

从架构设计来看,逻辑复制关注的是数据行级变化,而非表结构的语法命令。DDL 的复制会引入大量复杂性——不同 PostgreSQL 版本的 DDL 语法可能存在细微差异(如扩展语法、数据类型映射差异)。因此 PostgreSQL 团队的选择是:DDL 由管理员在两端手动维护。

4.3 手动保持 DDL 同步的最佳实践

  • 加法操作领先执行:在订阅者上先执行 ALTER TABLE ... ADD COLUMN,或者 CREATE INDEX,然后再在发布者上执行,避免数据类型不匹配的冲突。
  • 破坏性操作优先在发布者执行:删除列(DROP COLUMN)应在发布者上先执行,然后清理订阅者上对应的列。删除列前,确认订阅者是否还在依赖该列作为复用的判断条件。
  • 使用工具自动化:扩展 pgl_ddl_deploy(基于 pglogical 构建)能够自动将 DDL 语句传递给订阅者,但需要注意其版本兼容性。另一种方案是通过 pg_logical_emit_message 写入 DDL 消息到 WAL,由下游解析并执行。如果手写事件触发器捕获 DDL 并同步到订阅者,也是一种灵活但维护成本较高的方案。

4.4 生产环境示例:自动化 DDL 的代码级方案

-- 示例:创建一个事件触发器,将 DDL 以自定义消息形式写入 WAL
CREATE OR REPLACE FUNCTION capture_ddl()
RETURNS event_trigger AS $$
BEGIN
    PERFORM pg_logical_emit_message(false, 'ddl', current_query());
END;
$$ LANGUAGE plpgsql;

CREATE EVENT TRIGGER capture_ddl_trigger
ON ddl_command_end
EXECUTE FUNCTION capture_ddl();

下游可使用 pg_recvlogical 或应用程序消费 WAL 消息并执行 DDL。此种方式需要实现自己的执行逻辑,但能精确控制同步时机。

五、冲突处理(非常重要)

当订阅者上已经有本地修改,而发布者传来冲突的变更时,逻辑复制会停止并产生一个错误,需要手动解决

5.1 冲突的主要类型

冲突类型 场景描述 行为 处理方式
insert_exists 插入违反唯一约束的行 报错,停止复制 删除或更新冲突行后继续
update_missing UPDATE 找不到待更新行 自动跳过,不报错 无需处理(或做 INSERT 垫补)
update_exists 更新后违反唯一约束 报错,停止复制 手动修改约束违规的行
delete_missing DELETE 找不到待删除行 自动跳过,不报错 无需处理
multiple_unique_conflicts 同一行违反多个唯一约束 报错,停止复制 手动解决

官方明确指出:复制 UPDATEDELETE 操作时,缺少数据(即待更新的行不存在)不产生错误并且会被简单跳过。而违反唯一约束的冲突则会报错停止。

5.2 如何检测和解决冲突

订阅者的服务器日志会详细记录冲突信息。解决冲突的标准流程有两种:

  1. 修改订阅者端数据:删除或更新造成冲突的行,使其与传入的变更不冲突,然后恢复复制。
  2. 跳过冲突事务:使用 ALTER SUBSCRIPTION ... SKIP 跳过引起冲突的特定事务(支持从 PostgreSQL 16 开始)。注意,跳过事务可能导致数据不一致,请谨慎评估业务影响。

为了更深入的冲突检测(如 update_origin_differsdelete_origin_differs 类型),需要在订阅端启用 track_commit_timestamp 参数。

5.3 防止冲突的架构设计

在大多数高可用场景下,逻辑复制的最佳实践是:订阅者保持只读,不会发生双向写入。一旦订阅者参与写入(比如“多主”架构),冲突几乎不可避免。如果业务确实需要双向数据流动,建议考虑以下策略:

  • 数据分片:将不同主键范围映射到不同节点,确保同一个数据行始终只能由一个节点修改。
  • 延伸“最后一晚”模式:在冲突时倾向于保留最新的时间戳版本(可借助业务层或触发器实现)。

六、限制与注意事项

以下限制在实践中极易踩坑,务必提前知晓:

6.1 序列(Sequence)不会被复制

由序列支持的 serialidentity 列中的数据当然会作为表的一部分被复制,但序列本身在订阅者端仍显示起始值。如果计划进行故障切换让订阅者升级为新的主库,必须从发布者复制当前序列值或通过对表中已有数据取 MAX(id)+1 等方式更新序列值。这一问题在需要序列高度一致性(如分布式 ID 生成)的场景中需格外注意。

一个可行的处理方案:在故障切换后,对每个序列执行 SELECT setval('seq_name', (SELECT MAX(id) FROM table_name))

6.2 TRUNCATE 与外键的交互

复制 TRUNCATE 命令是支持的,但截断由外键连接的表组时需要格外小心。复制截断操作时,订阅者将截断发布者上被截断的同一组表(显式指定或通过 CASCADE 隐式收集的),不包括不属于该订阅的表。如果包含外键关系的表与不属于订阅的表存在外键链接,订阅者的截断将失败。

6.3 视图、物化视图、外部表不支持

逻辑复制只支持普通表和分区表。尝试复制视图、物化视图或外部表会报错。如需同步视图数据,一种常见方案是将视图查询结果物化到普通表后再复制。

6.4 大型对象(Large Objects)不会被复制

存储在 pg_largeobject 中的大型对象不会被复制,除了将数据存储在普通表格中外,没有其他解决方法。

6.5 REPLICA IDENTITY FULL 的潜在陷阱

在使用 REPLICA IDENTITY FULL 时,如果表中包含没有 B-tree 或 Hash 默认操作类的数据类型(如 pointbox),UPDATEDELETE 操作将无法应用于订阅者。遇到这种表时,建议设置主键或 REPLICA IDENTITY USING INDEX,避免 FULL 依赖带来的不确定性。

七、性能优化建议

逻辑复制相比物理复制有一定性能开销。以下针对不同场景的优化建议:

7.1 选择正确的出插件

  • pgoutput:PostgreSQL 内置的默认出插件。使用优化的二进制协议,无需额外安装即可使用。对于 PostgreSQL → PostgreSQL 的复制是性能最优的选择。实测在 1k TPS 场景下,端到端延迟通常在 20ms 以内。该插件通过 pgoutput 直接走 WAL 解析 + 流式发送,不经过 SQL 层,延迟较低。但如果是不需要跨版本复制或不需要 DDL 同步的简单拓扑,这也足够。
  • wal2json:将变更输出为 JSON 格式,便于异构系统集成,但性能开销较高。JSON 序列化在高频更新场景下 CPU 占用显著增加,实测单核 5k UPDATE/s 就可能吃满 CPU。优化建议:使用 add-tables 显式指定需要监听的表、设置 include-transaction = false 禁用事务头包装,以及启动 slot 时传入 include-previous = off 禁用旧值,可降低 20-40% CPU。
  • pglogical:在原生逻辑复制之前诞生的扩展,支持表级过滤、列裁剪、跨版本兼容(如 12 → 15)。延迟相对较高(80-150ms),因为多了一层逻辑解析和冲突处理。但它支持 DDL 同步(需显式启用),适合需要稳定同步 DDL 或需要行级冲突检测的场景。

7.2 逻辑复制槽管理

逻辑复制槽是维持 WAL 不被过早回收的关键机制。长期不活跃或被遗忘的槽将导致主库 WAL 堆积,最终磁盘写满。

最佳实践:

  • 设置 max_slot_wal_keep_size 参数,限制单个槽长期离线时保留的 WAL 总量。
  • 使用 pg_drop_replication_slot 删除不再使用的废弃槽。
  • 监控 pg_replication_slots 中的 activerestart_lsn 字段,设置告警。

7.3 批量设参优化

ALTER SYSTEM SET max_wal_senders = 20;
ALTER SYSTEM SET max_replication_slots = 20;

以上参数需要重启生效。此外,针对大数据量的复制集,可适当调大 work_memmaintenance_work_mem

7.4 增加并行优化

PostgreSQL 17 对逻辑复制做了重大改进:pg_upgrade 现可保留逻辑复制槽和订阅的完整状态,大版本升级后无需重新同步数据即可继续复制。如需在复制过程中搭建新节点,也可使用 pg_createsubscriber 工具,该工具可从物理备库创建逻辑副本。

八、PostgreSQL 17 新特性:值得关注的增强

自 PostgreSQL 17 起,逻辑复制获得多项重要更新:

  1. 故障槽(Failover Slots):允许逻辑复制在发生主库故障切换后继续工作,避免重新创建槽和重新同步的大开销。这是将逻辑复制纳入高可用体系的关键突破点。
  2. 升级保留pg_upgrade 现在保留发布者的逻辑复制槽和订阅者的完整订阅状态,使得大版本升级后逻辑复制能够快速继续,无需重新同步数据。这也意味着逻辑复制集群的平滑升级终于成为现实——但只适用于从 17.0 或更高版本升级的场景。
  3. 序列复制支持(PostgreSQL 18) :序列复制功能的支持已在 PostgreSQL 18 中引入,不同步序列的问题将逐步得到解决。

九、物理复制 vs 逻辑复制:场景总结

维度 物理流复制 逻辑复制
复制粒度 整个集群(实例级) 表级选择
复制内容 WAL 字节流(块级变化) 逻辑变更(行级 DML)
DDL 复制 ✅ 自动 ❌ 不支持(需手动同步)
跨大版本 ❌ 要求版本一致 ✅ 支持
选择性复制 ❌ 无 ✅ 有
冲突处理 不涉及(主备完全不写) 冲突可能发生,需要处理
主库写入性能影响 中等
典型用例 高可用主从、同城容灾 跨版本迁移、数据分发、业务解耦、CDC

一个常见组合:使用物理流复制维护高可用集群,在备库上再配置逻辑复制将特定表数据同步至数据仓库或分析系统。这是一种两全其美的架构。

十、一个完整的企业级逻辑复制架构

现代企业中的逻辑复制往往是多层次的:

  • 发布层:多个业务库分别定义自己的发布
  • 传输层:Kafka 或类似消息总线,通过 wal2json 等插件将 WAL 变更导入消息队列(CDC 范式)
  • 消费层:多个下游应用订阅各自的变更流(如实时数仓、搜索引擎、缓存刷新)

这种架构实现的优势:数据生产者与消费者的松耦合,下游数据产品可按需接入,上游数据库不受影响。

十一、写在最后

逻辑复制是 PostgreSQL 生态中最耀眼的功能之一。它从概念上把 WAL 从“只供数据库内部使用的物理日志”演变为“可被应用程序消费的变更流”——这一转变具有深远的架构意义:数据库系统不再是孤岛,而是可观测、可集成、可事件驱动的数据中枢。

本期系统梳理了:

  • 发布的创建与订阅管理
  • 跨版本零停机迁移的实操步骤
  • DDL 不同步的深层原因与自动化方案
  • 冲突类型、检测与解决策略
  • 出插件选型(pgoutput vs wal2json vs pglogical)
  • 性能优化与 PostgreSQL 17 新特性

第五期将深入 WAL 内核与 PITR 备份恢复,剖析 pg_basebackuppgBackRest 和 WAL 的完整生命周期管理。欢迎继续关注。


绩隐金 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:PostgreSQL 复制与高可用系列(四):逻辑复制深度实战——跨版本迁移、数据分发与冲突处理
喜欢 (0)
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址