逻辑复制原理
逻辑复制(logical replication)是 PostgreSQL 10 引入的一项内置能力,它让一个 PostgreSQL 实例(或多个实例)可以以「表」为粒度、以「行变更」为内容,把数据变更实时地同步到另一个 PostgreSQL 实例。它的官方定位是「复制数据对象及其变更」,而不是「复制整个数据库集簇的物理块」。这一点决定了它与前面学过的流复制(physical / streaming replication)在原理、能力边界与适用场景上都完全不同。
很多人第一次接触逻辑复制时会有一个误解:以为它只是「流复制的一个开关」。实际上,逻辑复制的实现链路与流复制是两套东西共用一段管道。流复制把 WAL 当成字节流原样转发,备库把收到的字节流重放到自己的数据文件上,因此备库必须是主库的「位级镜像」,主版本、架构、页大小、段大小都必须一致。逻辑复制则要先在发布端把 WAL 解码成一条条「某个表的某一行发生了什么变化」的逻辑记录,再通过网络发给订阅端,由订阅端把这些变更当成普通的 INSERT / UPDATE / DELETE / TRUNCATE 重新执行一遍。
正是「解码 + 重新执行」这两个动作,带来了逻辑复制最重要的两个特征:灵活(可以跨大版本、可以只复制部分表、可以做双向同步和合并)与昂贵(需要额外的 CPU 做解码、需要订阅端真正写数据、需要为冲突与不一致承担更多责任)。本页先把整条链路和关键概念讲清楚,后续几页再分别展开发布、订阅、限制与选型。
前置知识
建议先阅读:quorum 同步提交
基础概念
逻辑复制涉及的术语不多,但每一个都容易与流复制的同名概念混淆。先把它们厘清。
| 概念 | 英文 / 记法 | 说明 |
|---|---|---|
| 逻辑复制 | logical replication | 以表为单位复制数据变更,而非复制物理数据块 |
| 发布 | publication | 发布端的一组「要复制什么」的声明,可包含若干表或 schema |
| 订阅 | subscription | 订阅端对某个发布端的连接与消费声明 |
| 发布端 | publisher | 产生变更并被订阅的一方,通常是从库升级而来的主库或业务主库 |
| 订阅端 | subscriber | 接收并重新应用变更的一方,可同时订阅多个发布 |
| 逻辑解码 | logical decoding | 把 WAL 中的物理记录翻译成「表 + 行的逻辑变更」的过程 |
| 复制槽 | replication slot | 逻辑复制同样依赖复制槽来锚定解码起点、防止 WAL 被过早回收 |
| 输出插件 | output plugin | 负责把解码结果序列化成某种格式,内置为 pgoutput |
| 复制标识 | replica identity | 决定 UPDATE / DELETE 时如何定位订阅端的那一行 |
| 复制原点 | replication origin | 订阅端用来标记「这一行变更来自哪个上游」的标记 |
| 初始同步 | initial data copy / table sync | 订阅建立时把已有数据整体搬过去的过程 |
| 流式事务 | streaming of in-progress transactions | 大事务在提交前就分批发送的能力,避免订阅端堆积内存 |
| 逻辑复制槽 | logical replication slot | 专门持有逻辑解码状态的槽,与物理槽性质不同 |
逻辑复制与物理复制的根本区别
理解逻辑复制,最有效的办法是把它和流复制并排比较。下表是两者在机制层面的对照:
| 维度 | 物理复制(流复制) | 逻辑复制 |
|---|---|---|
| 复制单位 | 整个集簇的 WAL 字节 | 表级、行级变更 |
| 备库/订阅端可写性 | 只读(hot standby) | 可读写,是独立实例 |
| 跨大版本 | 必须同主版本 | 可以跨大版本 |
| 粒度过滤 | 无(全量) | 可按表、按 schema、按列、按行过滤 |
| 解码开销 | 无 | 有(发布端 CPU) |
| 应用开销 | 重放 WAL(快) | 重新执行 SQL 级操作(慢) |
| DDL 复制 | 随 WAL 自动同步 | 完全不复制 |
| 序列 / 大对象 | 随物理文件同步 | 序列不复制、大对象不复制 |
| 拓扑灵活性 | 简单(一条主备链) | 多主、环形、双向、多对一均可 |
| 典型用途 | 高可用、读写分离、灾备 | 数据汇聚、灰度迁移、异构分发 |
一句话总结:物理复制是「拷贝磁盘」,逻辑复制是「重放变更」。前者追求与主库完全一致,后者追求把「有用的那部分变更」送到需要的地方。
发布与订阅模型
逻辑复制的模型极其简单,只有两个角色:
- 发布(publication) 定义在发布端,回答的问题是「哪些表的哪些变更要被发出去」。它本身不产生任何订阅关系,也不消耗资源,只是一个声明。
- 订阅(subscription) 定义在订阅端,回答的问题是「从哪里、拉哪个发布的变更、怎么应用」。订阅一旦创建,就会在发布端建立复制槽,并启动 worker 进程持续拉取。
一个发布可以被多个订阅消费;一个订阅也可以同时订阅多个发布。发布与订阅之间通过 TCP 连接(走的是标准复制协议)通信,因此两者可以位于不同机器、不同主版本、甚至不同时空(订阅端可以先用 pg_dump 初始化,再建立订阅只消费增量)。
┌──────────────────────┐ ┌──────────────────────┐
│ 发布端 │ │ 订阅端 │
│ (publisher) │ │ (subscriber) │
│ │ replication │ │
│ table A ──┐ │ protocol (TCP) │ ┌── table A │
│ table B ──┼─► WAL │ ◄────────────────► │ ├── table B │
│ table C ──┘ │ │ │ (可写) │
│ │ │ └── 其他本地表 │
│ publication: │ │ subscription: │
│ pub_ab (A, B) │ │ sub_x ──► pub_ab │
└──────────────────────┘ └──────────────────────┘注意一个容易踩坑的点:发布端与订阅端的「角色名」是相对的。同一台实例既可以是某个发布的发布端,又可以是另一个上游的订阅端(例如环形复制、级联汇聚)。角色由「相对于某条链路」来定义。
逻辑解码
逻辑解码是逻辑复制的技术核心。PostgreSQL 的 WAL 里记录的是「在某个页面的某个偏移处,把若干字节改成什么样」这类物理信息。这种记录对物理复制足够,但无法回答「表 A 的第 3 行被改成什么了」。逻辑解码要做的就是从这些物理记录出发,结合系统目录(表结构、列类型、主键定义)和系统缓存,推导出逻辑层面的变更。
解码过程中,PostgreSQL 需要知道「表 A 的这一行在变更前长什么样」。这依赖于:
- WAL 中是否记录了足够的信息(由
REPLICA IDENTITY决定需要记录哪些列); - 解码时表结构是否与产生该 WAL 时一致(这也是「DDL 不复制」这条限制的根源——结构变化会让解码失去参照)。
逻辑解码是一个独立于复制的通用能力。PostgreSQL 提供了 pg_recvlogical 客户端与 SQL 接口(pg_logical_slot_get_changes() 等)让我们直接消费解码结果,官方也自带 test_decoding 输出插件用于学习与调试。逻辑复制只是「把解码结果通过复制协议发给另一个实例并重新执行」这套组合里的一个应用。
复制槽在逻辑复制中的角色
逻辑复制必须使用复制槽。原因有两层:
- 锚定解码起点:订阅端必须从某个确定的 LSN 开始解码,否则重启后就不知道「上次解码到哪里了」。逻辑槽的
confirmed_flush_lsn记录了这个位置。 - 防止 WAL 被回收:如果订阅端长时间离线,而发布端把 WAL 回收了,那么订阅端重新上线时就再也拿不到那段时间的变更,只能重建订阅。复制槽会把这些 WAL 一直保留到被消费为止。
逻辑槽与物理槽最重要的区别在于:逻辑槽只在发布端存在,且承载了解码状态。关于槽的通用机制(restart_lsn、confirmed_flush_lsn、wal_status、safe_wal_size、失效原因等),请看前面《复制槽》一页。逻辑槽与物理槽在 pg_replication_slots 里通过 slot_type 列区分(physical / logical),逻辑槽还会额外显示 database、plugin、temporary 等列。
复制槽会撑爆磁盘
逻辑槽会无限制地保留 WAL,直到订阅端消费。一旦订阅端长期停机且没有监控,pg_wal 会持续膨胀直至磁盘写满。生产环境必须监控 pg_replication_slots 中逻辑槽的 pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn),或者设置 max_slot_wal_keep_size 给槽保留的 WAL 设一个上限(超限时槽会失效并需要重建订阅)。
输出插件 pgoutput
WAL 解码出来的结果要经过输出插件才能变成可发送的格式。逻辑复制使用内置的 pgoutput 插件,它输出的是「二进制逻辑复制协议」格式,由 walsender 直接放进复制协议流中。
pgoutput 只做「格式转换与过滤」,不做任何自定义逻辑。它接受来自发布的过滤条件(哪些表、哪些列、哪些行),并在编码时应用这些条件,从而减少网络传输量。除 pgoutput 外,PostgreSQL 还自带 test_decoding(文本调试格式),社区还有 wal2json、decoderbufs 等插件,但这些都用于「自己消费解码结果」的场景,不用于内置逻辑复制。
pgoutput 也是版本相关的:发布端与订阅端之间会就 pgoutput 的协议版本进行协商,这解释了为什么「订阅端版本低于发布端」时新特性无法生效。
walsender 与 apply worker
逻辑复制的两端各有一套进程:
发布端:每个订阅连接对应一个 walsender 进程。它负责启动逻辑解码、加载 pgoutput 插件、应用发布的过滤条件,然后把变更通过复制协议发送出去。可以用 SELECT * FROM pg_stat_replication 观察这些 walsender,state 通常显示 streaming,application_name 是订阅名。
订阅端:由一个后台的 logical replication launcher 统一管理,它为每个订阅启动一个 apply worker。apply worker 负责接收变更并按事务顺序应用到本地表。如果订阅在建立时要做初始同步,还会额外启动若干个 table synchronization worker,每个负责一张表的初始拷贝。如果订阅启用了 streaming = parallel,还会按需启动 parallel apply worker。
| 进程 | 位置 | 职责 |
|---|---|---|
| walsender | 发布端 | 运行逻辑解码、按发布过滤、发送变更 |
| logical replication launcher | 订阅端 | 监督并为每个订阅拉起 apply worker |
| apply worker | 订阅端 | 接收并应用单个订阅的全部变更 |
| table synchronization worker | 订阅端 | 初始同步阶段,每张表一个,负责全量拷贝 |
| parallel apply worker | 订阅端 | streaming = parallel 时并行应用大事务的多个子事务 |
这些进程的数量受一组参数控制(max_logical_replication_workers、max_sync_workers_per_subscription、max_parallel_apply_workers_per_subscription、max_worker_processes 等),并且所有这些 worker 都从 max_worker_processes 这个池子里分配。
复制标识(REPLICA IDENTITY)
逻辑复制的 UPDATE 与 DELETE 需要在订阅端定位到具体的那一行。发布端的 WAL 要记录「被改动的行的旧值」,订阅端才能拼出 UPDATE ... WHERE <旧值> 或 DELETE ... WHERE <旧值>。记录多少旧值由发布端每张表的 REPLICA IDENTITY 决定:
| 取值 | 记录的内容 | 对逻辑复制的影响 |
|---|---|---|
DEFAULT | 主键列(若表无主键,则不记录任何可用于定位的旧值) | 有主键即可正常同步 UPDATE / DELETE |
FULL | 行的所有列(旧值) | 无主键也能同步,但 WAL 体积与网络传输显著增大 |
NOTHING | 不记录旧值 | 该表的 UPDATE / DELETE 无法复制(会被忽略) |
USING INDEX index_name | 指定唯一索引的列 | 用唯一索引代替主键来定位 |
因此一条非常重要的实践规则是:要参与逻辑复制的表必须有主键,或者显式设置 REPLICA IDENTITY FULL / USING INDEX。没有主键又不设 REPLICA IDENTITY 的表,INSERT 能过去,但 UPDATE / DELETE 会被静默丢弃或在订阅端报错。
REPLICA IDENTITY FULL 不是万能药
FULL 会让每一行的旧值全部写入 WAL 并全量发送,对大宽表来说是数倍的 I/O、WAL 与网络开销。更糟的是,如果表中含有 point、box 等没有默认 B-tree / Hash 操作符类的类型,REPLICA IDENTITY FULL 下的 UPDATE / DELETE 可能无法在订阅端匹配到行。正确做法仍然是为表定义主键或唯一索引并使用 USING INDEX。
逻辑复制中的 LSN
逻辑复制同样以 LSN 作为进度坐标,但语义与物理复制有差别:
| 视图 / 函数 | 含义 |
|---|---|
pg_current_wal_lsn() | 发布端当前写入的 WAL 位置 |
pg_replication_slots.confirmed_flush_lsn | 订阅端已确认消费(可安全丢弃)的 LSN |
pg_stat_subscription.received_lsn | 订阅端 apply worker 已收到的 LSN |
pg_stat_subscription.latest_end_lsn | 订阅端已确认发回给发布端的 LSN |
pg_replication_origin_progress() | 订阅端某个复制原点已应用到的位置 |
confirmed_flush_lsn 与 restart_lsn 的差值,就是「订阅端滞后多少 WAL」,它是监控逻辑复制延迟的主要指标之一。
复制原点(replication origin)
复制原点(replication origin)是订阅端的一个内部机制,用来给「从上游同步过来的变更」打上来源标记。它的作用有两个:
- 防止循环复制:如果变更已经带着某个 origin 标记,就不会再被本实例发布出去,避免 A→B→A 的死循环。
- 冲突检测:启用
track_commit_timestamp后,可以区分「这一行的最新修改来自本地」还是「来自上游」,从而检测update_origin_differs/delete_origin_differs类冲突。
每个订阅在订阅端对应一个复制原点,名字形如 pg_16395(pg_ 加订阅的 OID)。订阅数量受 max_active_replication_origins 限制,默认 10。
流式事务的三种模式
PostgreSQL 在发送逻辑变更时,对大事务的处理方式由订阅的 streaming 选项控制:
| 取值 | 行为 | 引入版本 |
|---|---|---|
off | 完全不流式:事务在发布端被完整解码并缓存在内存,直到提交后才一次性发送 | PG14 |
on | 事务进行中就把变更流式发送到订阅端,订阅端写入临时文件,等事务提交后再一次性应用 | PG14 |
parallel | 在 on 的基础上,订阅端可以并行应用大事务的各个子事务 | PG16 |
默认值随版本变化:PG16 中 streaming 默认为 off,而 PG18 的默认值已改为 parallel。off 对发布端内存压力最大(大事务要全部缓存在 logical_decoding_work_mem 里并可能落盘),parallel 对订阅端并行度要求最高(受 max_parallel_apply_workers_per_subscription 限制)。
语法与用法
逻辑复制没有「一条命令搞定」的语法,它由一组配置参数、若干 DDL 和若干个监控查询组成。下面是搭建一条最小链路时需要用到的东西。
发布端的必要配置(postgresql.conf):
# 逻辑解码的必要前提:wal_level 必须是 logical
wal_level = logical
# 至少要留出「订阅数 + 初始同步预留」个槽
max_replication_slots = 10
# 至少要能容纳 max_replication_slots 个 walsender 加上物理副本的连接
max_wal_senders = 10订阅端的关键配置(postgresql.conf):
# 订阅数 + 初始同步预留
max_active_replication_origins = 10
# 每个订阅一个 leader apply worker,加上同步/并行 worker 的预留
max_logical_replication_workers = 10
# 必须大于 max_logical_replication_workers + 1
max_worker_processes = 16
# 单个订阅初始同步的并行度
max_sync_workers_per_subscription = 2
# streaming = parallel 时的并行度
max_parallel_apply_workers_per_subscription = 2发布端建立发布:
-- 建一个空发布,随后再添加表
CREATE PUBLICATION pub_orders;
-- 把若干表加进发布
ALTER PUBLICATION pub_orders ADD TABLE public.orders, public.order_items;
-- 也可以一次性建好
CREATE PUBLICATION pub_orders
FOR TABLE public.orders, public.order_items
WITH (publish = 'insert, update, delete, truncate');
-- 查看发布
SELECT * FROM pg_publication;
SELECT * FROM pg_publication_tables;订阅端建立订阅:
-- 需要具备 pg_create_subscription 角色权限,以及当前数据库的 CREATE 权限
CREATE SUBSCRIPTION sub_orders
CONNECTION 'host=publisher-host port=5432 dbname=app user=repl password=secret'
PUBLICATION pub_orders
WITH (copy_data = true, streaming = on);NOTICE: created replication slot "sub_orders" on publisher
CREATE SUBSCRIPTION观察两端状态:
-- 订阅端:订阅列表与启用状态
SELECT subname, subenabled, subslotname, subpublications FROM pg_subscription;
-- 订阅端:worker 进程与进度
SELECT subname, worker_type, pid, received_lsn, latest_end_lsn
FROM pg_stat_subscription;
-- 发布端:walsender 连接
SELECT application_name, state, sync_state, sent_lsn, replay_lsn
FROM pg_stat_replication;详细说明
一条变更从产生到落地的完整路径
把整条链路串起来看,一次 UPDATE 要经过下面这些环节才会出现在订阅端:
- 发布端产生 WAL:事务执行
UPDATE,PostgreSQL 按REPLICA IDENTITY决定记录多少旧值,写入 WAL。 - walsender 启动解码:为每个订阅连接创建的 walsender 从逻辑槽的
confirmed_flush_lsn开始,调用逻辑解码。 - 解码器重建逻辑变更:解码器结合系统目录,把物理记录翻译成「表 OID + 元组 + 操作类型」。
- pgoutput 过滤与编码:按发布定义过滤掉不相关的表、列、行,再编码为复制协议消息。
- 网络传输:通过复制协议流发送(本质是 libpq 连接上的专用消息类型)。
- apply worker 接收:订阅端的 apply worker 解析消息,维护事务边界。
- 应用变更:以
session_replication_role = replica重新执行INSERT/UPDATE/DELETE/TRUNCATE。 - 回传进度:apply worker 定期把已应用的 LSN 回传给发布端,更新槽的
confirmed_flush_lsn。
任何一个环节出错(解码报错、冲突、网络中断、磁盘满),链条就在那里停下,后续变更全部积压在发布端的 WAL 里。
为什么 wal_level 必须是 logical
wal_level 有三个级别:minimal、replica、logical。级别越高,WAL 里记录的信息越多。
minimal:只记录崩溃恢复所需的最小信息,很多操作甚至可以用「整页重写」的方式省略细节,逻辑解码完全不可用。replica:记录足够支撑物理复制与 PITR 的信息,但仍不包含逻辑解码所需的部分(例如某些标记信息)。logical:在replica基础上额外记录逻辑解码需要的信息,例如为REPLICA IDENTITY FULL记录全部旧值、为复制槽记录必要的元数据。
修改 wal_level 需要重启实例,且会增加 WAL 体积与写入开销。因此生产上要权衡:如果确定要做逻辑复制(或需要 pg_recvlogical),就必须把 wal_level 提升到 logical 并接受这点额外成本。
初始同步是怎么完成的
订阅建立时如果 copy_data = true(默认),订阅端会先做一次全量初始同步,再进入增量阶段。这个过程常被误解为「一次性 COPY」,实际上它比 COPY 复杂:
- 创建复制槽(发布端):
CREATE SUBSCRIPTION会在发布端创建逻辑槽,槽的起点是当前 WAL 位置。 - 为每张表启动一个 table sync worker(订阅端):并行度由
max_sync_workers_per_subscription控制(注意每个表只有一个同步 worker)。 - 每张表在发布端创建一个自己的临时槽:这是很多人忽略的细节——table sync worker 会为它负责的那张表在发布端建立一个临时复制槽,用来做「一致性快照 + 增量补齐」。
- COPY 快照:table sync worker 从一个一致快照里把表的现有数据整表拷贝过去。这一步会触发订阅端的行级与语句级
INSERT触发器(与普通COPY行为一致)。 - 追赶增量:快照之后、COPY 期间的增量变更由临时槽补齐,直到追平主 apply worker 的进度。
- 交接:table sync worker 完成后退出,该表的事务应用交回主 apply worker 统一处理。
- 槽清理:表同步用的临时槽在完成后被删除。
如果 table sync worker 在拷贝过程中意外终止,主 apply worker 会在一段时间后(受 wal_retrieve_retry_interval 影响)重新拉起它。这也是为什么初始同步阶段需要给 max_replication_slots 留出额外空间——同步期间每个正在拷贝的表都会占用一个槽。
apply worker 的执行上下文
apply worker 应用变更时有一系列容易被忽略的行为:
session_replication_role = replica:apply worker 始终以这个角色执行。默认情况下,这意味着用户触发器(trigger)与规则(rule)不会被触发。如果确实希望订阅端也执行某些触发器,必须对表执行ALTER TABLE ... ENABLE TRIGGER/ENABLE ALWAYS TRIGGER(规则类似)。- 只触发行级触发器,不触发语句级触发器:增量应用阶段,只有行级触发器会执行,语句级触发器不会。
- 初始同步阶段例外:初始同步的 COPY 阶段会同时触发行级与语句级
INSERT触发器,行为与用户自己执行COPY一致。这个差异需要在设计幂等逻辑时特别注意。 - 以订阅 owner 的权限执行:apply worker 的权限等价于订阅的 owner,而不是订阅端某个超级用户。因此目标表与序列的权限必须正确授予订阅 owner。
三种流式模式的实际差异
streaming 的三种取值,影响的其实是「发布端内存」与「订阅端并行度」这两笔账:
| 模式 | 发布端 | 订阅端 | 适用 |
|---|---|---|---|
off | 事务完整解码后才发送,大事务会占用大量内存并可能落盘 | 简单,串行应用 | 事务普遍较小,或发布端内存充裕 |
on | 事务进行中即发送,发布端内存压力小 | 变更先写临时文件,提交后一次性应用 | 存在大事务,但订阅端不想并行 |
parallel | 同 on | 可为大事务启动 parallel apply worker 并行应用(受 max_parallel_apply_workers_per_subscription 限制) | 大事务多、订阅端有多核余量 |
需要提醒的是:使用 streaming = parallel 时,如果某事务在订阅端应用失败,其 finish LSN 可能没有被记录,ALTER SUBSCRIPTION ... SKIP 需要先切换成 on 或 off 复现并定位。这个细节在排障时非常关键。
复制原点与循环复制的防护
设想一个环形拓扑:A 复制到 B,B 复制到 C,C 又复制到 A。如果没有任何防护,A 上产生的一行变更会被 B 应用、再被 C 应用、再回到 A,形成无限放大。
复制原点就是防止这种情况的机制:当订阅端把变更应用到本地时,会把这些变更标记为「来自某个 origin」。当本实例再作为发布端向外发送时,如果一条变更已经带了 origin 标记(说明它是从上游来的),默认行为是不再向外转发。
订阅的 origin 选项控制这个行为的严格程度,取值为 any(默认)或 none。any 表示「任何来源的变更都会被发布」,none 表示「带 origin 的变更(即从上游同步来的)不再发布」。做级联或多级拓扑时,origin 的选择直接决定是否会形成回环。
冲突是怎么产生的
订阅端重新应用变更时,本地表的状态可能与发布端的历史不一致,于是产生冲突(conflict)。常见冲突类型(详见《逻辑复制限制》一页):
| 冲突类型 | 触发场景 |
|---|---|
insert_exists | 要插入的行在订阅端已存在(例如本地写了相同主键) |
update_exists | UPDATE 导致唯一约束冲突 |
update_missing | UPDATE 找不到目标行(会被静默跳过) |
delete_missing | DELETE 找不到目标行(会被静默跳过) |
update_origin_differs | 本地行与上游行的来源不同(需 track_commit_timestamp) |
delete_origin_differs | 同上,用于删除 |
multiple_unique_conflicts | 一行违反多个唯一约束 |
处理冲突是逻辑复制运维中最需要人工介入的部分:多数冲突会停止复制并写日志,需要人工 ALTER SUBSCRIPTION ... SKIP 跳过,或 pg_replication_origin_advance() 手动推进原点。
两阶段提交(two_phase)
默认情况下,发布端的两阶段提交(PREPARE TRANSACTION / COMMIT PREPARED)不会以两阶段的形式传给订阅端:PREPARE 的变更会一直等到 COMMIT PREPARED 才发送。这会导致订阅端在这一段时间里「看不到」这个已准备好的事务。
订阅的 two_phase 选项(默认 false)可以开启两阶段复制:发布端会把 PREPARE 与 COMMIT PREPARED / ROLLBACK PREPARED 都同步到订阅端。它的代价是只有所有订阅都完成两阶段初始化后,发布端的两阶段提交才被允许——在这之前发布端执行 PREPARE TRANSACTION 会报错。因此开启 two_phase 前必须确认所有订阅都准备好。
故障转移槽(failover)
逻辑复制的复制槽长在发布端,这带来一个经典问题:如果发布端本身宕机并需要切换到它的备库,新主库上没有这个槽(槽不随物理复制同步),订阅端就会断流。
PostgreSQL 17 引入了订阅的 failover 选项,配合复制槽的 failover 属性,可以让逻辑槽的状态随物理复制一起同步到备库,从而在切主后由新主库接管该槽继续供订阅消费。这解决的是「发布端也做了高可用」这一层的问题,是生产级逻辑复制拓扑必须考虑的环节。
逻辑复制不复制什么
这是理解逻辑复制边界的关键,也是后续《逻辑复制限制》一页的主题。简要列举:
- DDL:
CREATE TABLE、ALTER TABLE等结构变更完全不复制,必须两端手工保持一致。 - 序列(sequence):序列数据不复制。
serial/identity列的「值」会作为普通列数据复制过去,但订阅端的序列对象仍停在初始值,切换时需要手工setval()。 - 大对象(large object):官方明确不复制,且没有内置的绕行方案。
- 视图、物化视图、外部表:不能作为发布的成员。
- TRUNCATE:从 PostgreSQL 11 起是支持的(但受
publish_via_partition_root与外键分组细节影响)。 - 生成列(generated column):PG18 之前完全不发布;PG18 起需要显式开启
publish_generated_columns = stored。
典型拓扑与应用场景
逻辑复制的灵活性集中体现在拓扑上:
| 拓扑 | 描述 | 典型用途 |
|---|---|---|
| 一对多(分发) | 一个发布,多个订阅 | 把主库数据分发给多个下游系统 |
| 多对一(汇聚) | 多个发布端,一个订阅端 | 分库数据汇总到数据仓库 |
| 级联 | 订阅端同时是下游的发布端 | 减轻主库网络压力 |
| 双向 / 环形 | 两端互为发布与订阅 | 双活、多区域写入(需防冲突) |
| 按表分发 | 只发布部分表 | 灰度迁移、只同步核心业务表 |
需要强调:逻辑复制不提供高可用。它没有自动故障转移、没有一致性保证、订阅端可以随意写入,因此它不能替代流复制来承担「主库挂了立刻接管」的责任。它更适合「数据分发」「迁移」「异构集成」这些场景。
实战示例
示例一:同机双实例搭建最小逻辑复制
前提:两个实例(端口 5432 发布端、5433 订阅端)都已设置 wal_level = logical。
# 发布端:创建复制用户
psql -p 5432 -U postgres -c "CREATE ROLE repl LOGIN REPLICATION PASSWORD 'secret';"
# 发布端:准备数据与发布
psql -p 5432 -U postgres <<'SQL'
CREATE TABLE public.t1 (id int PRIMARY KEY, val text);
INSERT INTO public.t1 VALUES (1, 'a'), (2, 'b');
CREATE PUBLICATION pub_t1 FOR TABLE public.t1;
SQL
# 订阅端:准备一张同结构的表
psql -p 5433 -U postgres -c "CREATE TABLE public.t1 (id int PRIMARY KEY, val text);"
# 订阅端:创建订阅
psql -p 5433 -U postgres <<'SQL'
CREATE SUBSCRIPTION sub_t1
CONNECTION 'host=127.0.0.1 port=5432 dbname=postgres user=repl password=secret'
PUBLICATION pub_t1
WITH (copy_data = true, streaming = on);
SQLNOTICE: created replication slot "sub_t1" on publisher
CREATE SUBSCRIPTION验证:
psql -p 5432 -U postgres -c "INSERT INTO public.t1 VALUES (3, 'c');"
psql -p 5433 -U postgres -c "SELECT * FROM public.t1 ORDER BY id;" id | val
----+-----
1 | a
2 | b
3 | c
(3 rows)示例二:观察 worker 进程与复制进度
-- 订阅端
SELECT subname,
worker_type,
pid,
received_lsn,
latest_end_lsn,
latest_end_time
FROM pg_stat_subscription
ORDER BY subname; subname | worker_type | pid | received_lsn | latest_end_lsn | latest_end_time
---------+---------------+-------+--------------+----------------+---------------------------
sub_t1 | apply | 25143 | 0/16B3A80 | 0/16B3A80 | 2026-09-30 10:42:11.9+08-- 发布端:槽的保留与消费进度
SELECT slot_name,
slot_type,
database,
plugin,
active,
restart_lsn,
confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
WHERE slot_type = 'logical'; slot_name | slot_type | database | plugin | active | restart_lsn | confirmed_flush_lsn | retained
-----------+-----------+----------+----------+--------+-------------+---------------------+----------
sub_t1 | logical | postgres | pgoutput | t | 0/16B3A48 | 0/16B3A80 | 256 bytes示例三:验证 REPLICA IDENTITY 对 UPDATE 的影响
先建一张没有主键的表,观察 UPDATE 是否被复制:
-- 发布端
CREATE TABLE public.no_pk (id int, val text);
INSERT INTO public.no_pk VALUES (1, 'x');
ALTER PUBLICATION pub_t1 ADD TABLE public.no_pk;
-- 订阅端
CREATE TABLE public.no_pk (id int, val text);-- 发布端执行
UPDATE public.no_pk SET val = 'y' WHERE id = 1;
-- 订阅端查询
SELECT * FROM public.no_pk; id | val
----+-----
1 | x
(1 row)因为表没有主键、REPLICA IDENTITY 是 DEFAULT,发布端没有记录可用于定位的旧值,这条 UPDATE 不会(也无法)被复制。修复方式:
-- 发布端
ALTER TABLE public.no_pk REPLICA IDENTITY FULL;-- 发布端再次修改
UPDATE public.no_pk SET val = 'z' WHERE id = 1;
-- 订阅端
SELECT * FROM public.no_pk; id | val
----+-----
1 | z
(1 row)示例四:用 pg_recvlogical 直接观察解码结果
逻辑解码可以脱离复制单独使用,这有助于理解「解码到底解出了什么」。
# 发布端:创建一个临时逻辑槽,使用 test_decoding 插件
psql -p 5432 -U postgres -c \
"SELECT * FROM pg_create_logical_replication_slot('demo_slot', 'test_decoding');"
# 另开一个终端消费解码结果
pg_recvlogical -p 5432 -U postgres -d postgres \
--slot demo_slot --start -f -# 第三个终端:产生一些变更
psql -p 5432 -U postgres <<'SQL'
BEGIN;
INSERT INTO public.t1 VALUES (100, 'hello');
UPDATE public.t1 SET val = 'world' WHERE id = 100;
DELETE FROM public.t1 WHERE id = 100;
COMMIT;
SQLpg_recvlogical 的终端会输出类似内容:
BEGIN 741
table public.t1: INSERT: id[integer]:100 val[text]:'hello'
table public.t1: UPDATE: id[integer]:100 val[text]:'world'
table public.t1: DELETE: id[integer]:100
COMMIT 741清理演示槽:
SELECT pg_drop_replication_slot('demo_slot');注意事项
逻辑复制不会自动同步表结构。 这是最常见的「上线即失败」原因。发布端新加一列后,如果订阅端还没加,apply worker 会报错并停止。所有 DDL 必须由变更流程(如 Flyway、Liquibase 或自制脚本)在两端按同样顺序执行,且尽量使用「加法式」变更(先加列再加约束)以保证订阅端始终兼容。
发布端与订阅端的表结构不要求完全相同,但要求兼容。 订阅端可以有额外的列(只要有默认值或可空),可以有不同的索引、约束、触发器。这既是灵活性也是风险:不兼容的约束(例如订阅端多了一个 NOT NULL 或 CHECK)会把本来能应用的变更变成冲突。
删除发布端的复制槽后,订阅端必须重建。 复制槽承载了解码起点,一旦在发布端被删除,订阅端就失去了它的进度锚点。此时 ALTER SUBSCRIPTION ... REFRESH 或重建订阅是唯一出路。
订阅端写入的数据不受保护。 逻辑复制仲裁不了「本地写」与「上游写」的先后顺序。如果业务同时在订阅端写同样的表,冲突会不可避免地发生。要么让订阅端的这些表保持只读,要么使用 origin 与 track_commit_timestamp 做更精细的冲突判断。
CREATE SUBSCRIPTION 不能在事务块内执行。 因为它要在发布端创建复制槽、并可能在订阅端做初始同步,这些动作需要独立的事务边界。同理,ALTER SUBSCRIPTION ... REFRESH PUBLICATION、SET (failover = ...)、SET (two_phase = false) 也不能在事务块里执行。
同一集簇内做复制必须 create_slot = false。 如果订阅端就是要订阅「本实例自己」(例如同集簇内做表级复制),直接 CREATE SUBSCRIPTION 会因为要连自己建槽而挂起。必须手动先建槽(pg_create_logical_replication_slot),再在订阅里指定 create_slot = false 与已有的 slot_name。
复制槽的 WAL 保留是单点故障。 一个长期失联的订阅会让发布端的 pg_wal 无限增长。必须监控逻辑槽的保留量,并考虑用 max_slot_wal_keep_size 设置上限(超限时槽失效、订阅中断)。同时 PostgreSQL 17+ 提供了 idle_replication_slot_timeout,可以在槽长期空闲时主动使其失效,避免无限堆积。
大对象不会被复制。 如果业务使用 lo / oid 类型存储二进制内容,逻辑复制不会搬运它们——这部分内容必须在订阅端另行处理,或改写为 bytea 列(bytea 属于普通列,可以正常复制)。
最佳实践
- 明确逻辑复制的定位:它是数据分发工具,不是高可用方案。 高可用与故障转移请继续交给流复制 + 自动切换方案;逻辑复制负责跨版本迁移、灰度分流、数据汇聚、异构集成这类「复制数据对象」的需求。
- 为所有参与复制的表定义主键。 主键既是最好的冲突检测依据,也让
REPLICA IDENTITY保持默认即可高效复制UPDATE/DELETE。只有确实无法定义主键时才退而使用REPLICA IDENTITY FULL或USING INDEX。 - 把 DDL 纳入发布流程并通过审批。 建立一个「先发布端、后订阅端、只用加法式变更」的约定,并在 CI 中校验两端结构差异。DDL 不会自动跨端传播,靠人工记忆一定会出事。
- 监控复制槽的 WAL 保留量,而不是只看延迟。 逻辑复制的真正风险是发布端磁盘被槽撑爆。对逻辑槽计算
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)并设阈值告警,同时兜底设置max_slot_wal_keep_size。 - 监控订阅端的
pg_stat_subscription与pg_stat_subscription_stats。 前者看 worker 是否存活、进度是否推进;后者看apply_error_count、sync_error_count与各confl_*冲突计数。冲突计数从 0 变成非 0 往往意味着数据已经出现分歧。 - 冲突必须有明确的处置预案。 提前想清楚:手动修复订阅端数据后
SKIP跳过、还是重建订阅。建议为每个订阅记录它的复制原点名(pg_<oid>),以便在需要时用pg_replication_origin_advance()精确推进。 - 初始同步安排在业务低峰,并临时提高
max_sync_workers_per_subscription。 初始同步会消耗发布端的读 I/O 与槽资源,且同步期间的每个表都占用一个槽。监控发布端pg_replication_slots的槽数量,确保留有余量。 - 发布用「显式表清单」而不是
FOR ALL TABLES。 显式清单让复制范围可控、可审计;FOR ALL TABLES需要超级用户、会把无关表也纳入、且后续新建表会自动进入发布,容易造成意外流量。 - 为
streaming选择合适的取值并理解代价。 事务普遍较小可用off;有大事务且希望降低发布端内存压力可用on;订阅端有多核余量且大事务多可用parallel。注意parallel在排障时对SKIP有影响。 - 在发布端为逻辑复制单独规划连接与资源。 walsender 会占用
max_wal_senders、解码 CPU 与内存(logical_decoding_work_mem),并增加 WAL 体积。把逻辑复制的负载与物理复制、业务负载一起纳入容量规划。 - 跨版本迁移优先用逻辑复制而不是
pg_dump+恢复。 逻辑复制可以在迁移窗口内持续追平增量,最终切换时只需短暂的停机或只读窗口,比「全量导出 + 停机导入」的 RTO 小得多。但迁移完成后务必手工同步序列值。 - 为订阅启用
slot_name = NONE之外的显式命名,并纳入配置管理。 订阅名会决定发布端槽的名字,改名或重建都涉及槽的删除与重建。把订阅定义(CONNECTION、PUBLICATION、WITH参数)纳入版本管理,避免「线上有、仓库里没有」的漂移。
下一节
参考链接
- PostgreSQL 官方文档:Logical Replication
- PostgreSQL 官方文档:Logical Replication Publication
- PostgreSQL 官方文档:Logical Replication Subscription
- PostgreSQL 官方文档:Architecture of Logical Replication
- PostgreSQL 官方文档:Logical Replication Configuration
- PostgreSQL 官方文档:Logical Replication Conflicts
- PostgreSQL 官方文档:Logical Decoding Concepts
- PostgreSQL 官方文档:Monitoring Database Activity
- PostgreSQL 官方文档:Copying Data Between Databases (pg_recvlogical)
- PostgreSQL 官方文档:Replication Configuration