PostgreSQL 16 逻辑复制原理、落地与故障排查

当你只想把 A 库中的几张业务表持续同步到 B 库,而不是搭建一套完整的物理主从时,PostgreSQL 原生逻辑复制通常是最直接的方案。

本文不是一份“复制粘贴命令清单”,而是从问题背景、工作原理、设计边界、实施过程、故障排查和后续运维六个层面,完整讲清楚 PostgreSQL 表级逻辑复制。

文中所有 IP、库名、schema、表名、账号和密码均为脱敏示例。

写在前面:很多时候,我们并不需要一个“从库”

数据库同步需求看起来都很像:

TEXT
A 库有数据

把数据同步到 B 库

但“同步到 B 库”背后可能是完全不同的目标:

  • 为生产库建设只读灾备;
  • 为查询业务提供只读副本;
  • 将部分业务数据同步到报表库;
  • 将某个 schema 的表同步到测试环境;
  • 在数据库迁移期间做增量追平;
  • 将订单表同步给另一个独立系统;
  • 为数据分析平台提供准实时数据源。

如果目标是高可用和整库灾备,通常应该考虑物理流复制。

但如果你的真实需求是:

只同步几张表,B 库仍然是一套独立数据库,并且 B 库中还有自己的其他表和业务。

那么物理主从就显得过重了。

这正是 PostgreSQL 逻辑复制擅长解决的问题。

先给结论

PostgreSQL 原生逻辑复制的核心模型只有一句话:

TEXT
A 库通过 Publication 声明“我要发布哪些表的数据变化”,
B 库通过 Subscription 声明“我要订阅哪个发布”。

数据链路可以简化为:

TEXT
业务写入 A 库

A 库生成 WAL

逻辑解码把 WAL 转换成行级变化

Publication 过滤需要发布的表和操作

Replication Slot 记录 B 库消费位置

B 库 Subscription 接收变化

Apply Worker 将变化应用到同名目标表

不过,真正让逻辑复制稳定运行的并不是两条 CREATE 语句,而是下面这些条件同时成立:

  • A 库 wal_level=logical
  • A/B 网络和认证正常;
  • 复制账号具备正确权限;
  • B 库提前存在兼容的表结构;
  • 发布表具备主键或合适的 Replica Identity;
  • B 库目标表不能被业务随意写入;
  • 复制槽和 WAL 保留量被持续监控;
  • 表结构变更遵守固定顺序;
  • 订阅错误、超时和表同步卡顿有明确排查方法。

本文会围绕这些问题展开。

一、本文使用的环境

A 库:发布端

TEXT
IP:192.168.1.1
端口:5432
数据库:test_db
schema:test_sync
Publication:test_sync_pub
复制用户:repl_test

B 库:订阅端

TEXT
IP:192.168.1.2
端口:5432
数据库:test_db
schema:test_sync
Subscription:test_sync_sub

示例表

TEXT
test_customer
test_order
test_order_item
test_task
test_metric

表名统一使用 test_xxx,而不是 test-xxx

原因是 PostgreSQL 标识符一旦包含连字符,就必须始终使用双引号:

SQL
SELECT *
FROM "test-order";

这会增加 SQL 编写和运维成本,因此示例使用更常见的下划线命名。


二、逻辑复制到底“逻辑”在哪里

理解逻辑复制之前,先要分清两个概念:

  • 物理复制;
  • 逻辑复制。

2.1 物理复制:复制的是数据库底层变化

物理流复制传递的是 WAL 中的物理变化。

可以粗略理解为:

TEXT
A 库某个数据页发生变化

WAL 记录这个底层变化

B 库按相同物理方式重放

它关注的是数据库集群整体一致性,而不是某张业务表。

因此物理复制天然适合:

  • 高可用;
  • 整库灾备;
  • 只读副本;
  • 主备切换。

但它的粒度比较粗。

通常不能说:

TEXT
我只想复制 test_order,
但不复制 test_customer。

2.2 逻辑复制:复制的是行级数据变化

逻辑复制同样建立在 WAL 之上,但它不是把 WAL 当作数据库页修改直接回放,而是通过逻辑解码,将其转换为类似下面的逻辑变化:

TEXT
表:test_sync.test_order
操作:INSERT
主键:10001
字段:
  order_no = TEST-10001
  amount   = 128.50

B 库接收到的不是原始 SQL 文本,也不是底层数据页,而是经过协议编码的行级变化。

因此它可以做到:

  • 只发布指定表;
  • 只发布某些 DML 操作;
  • 一个 Publication 被多个 Subscriber 订阅;
  • B 库保留自己的其他对象;
  • 在兼容条件下用于跨版本迁移;
  • 让目标库继续承担独立查询和分析工作。

2.3 物理复制和逻辑复制怎么选

场景 更适合的方案
PostgreSQL 高可用 物理流复制
整库只读副本 物理流复制
整库灾备 物理流复制
只同步部分表 逻辑复制
将业务表分发给多个系统 逻辑复制
数据库迁移期间增量追平 逻辑复制
为报表库提供部分实时数据 逻辑复制
B 库需要保留独立数据 逻辑复制

没有绝对更好的复制方式,只有是否适合当前目标。


三、从一条 INSERT 看懂完整复制链路

假设业务在 A 库执行:

SQL
INSERT INTO test_sync.test_order(
    id,
    order_no,
    amount,
    created_at
)
VALUES (
    10001,
    'TEST-10001',
    128.50,
    now()
);

这条数据如何到达 B 库?

第一步:A 库写入 WAL

PostgreSQL 遵循 Write-Ahead Logging,也就是预写日志。

在真正修改数据文件之前,数据库先记录 WAL。

WAL 为 PostgreSQL 提供:

  • 崩溃恢复;
  • 物理复制;
  • 时间点恢复;
  • 逻辑解码;
  • 逻辑复制。

第二步:逻辑解码读取 WAL

当:

TEXT
wal_level = logical

PostgreSQL 会在 WAL 中保留足够的信息,使输出插件能够将 WAL 解码为逻辑变化。

原生逻辑复制通常使用内置输出插件:

TEXT
pgoutput

第三步:Publication 判断是否发布

A 库中创建:

SQL
CREATE PUBLICATION test_sync_pub
FOR TABLES IN SCHEMA test_sync
WITH (
    publish = 'insert, update, delete, truncate'
);

Publication 相当于一份发布规则:

TEXT
只关注 test_sync schema 中的表;
发布 INSERT、UPDATE、DELETE、TRUNCATE。

如果业务修改的是另一个未发布的 schema,这条变化不会进入该 Publication。

第四步:Replication Slot 记录消费进度

Subscription 通常会在 A 库创建一个逻辑复制槽。

例如:

TEXT
test_sync_sub

复制槽的作用不是“存放完整数据”,而是记录消费者已经确认到哪个 WAL 位置。

例如:

TEXT
A 库当前 WAL:0/A000000
B 库已确认:  0/8000000

这意味着:

TEXT
0/8000000 之后的 WAL 暂时不能被清理,
因为 B 库还没有消费完成。

第五步:B 库 Apply Worker 应用变化

B 库 Subscription 接收到变化后,由 Apply Worker 将其写入:

TEXT
test_sync.test_order

至此,一次 INSERT 完成了从 A 到 B 的传递。

完整过程:

TEXT
INSERT

WAL

Logical Decoding

Publication

Replication Slot

Subscription

Apply Worker

B 库目标表

四、逻辑复制中的五个关键角色

很多逻辑复制故障,本质上是没有分清不同对象的职责。

4.1 Publisher

Publisher 是发布端数据库。

本文中:

TEXT
192.168.1.1

它负责:

  • 接收业务写入;
  • 生成 WAL;
  • 进行逻辑解码;
  • 提供 Publication;
  • 维护复制槽;
  • 向 Subscriber 发送变化。

4.2 Publication

Publication 是发布规则。

它回答两个问题:

TEXT
发布哪些表?
发布哪些操作?

例如:

SQL
CREATE PUBLICATION test_sync_pub
FOR TABLES IN SCHEMA test_sync
WITH (
    publish = 'insert, update, delete, truncate'
);

Publication 本身不保存数据,也不主动连接 B 库。

它只是描述“哪些变化允许被订阅”。

4.3 Subscriber

Subscriber 是订阅端数据库。

本文中:

TEXT
192.168.1.2

它不是物理意义上的只读备库,而是一套独立 PostgreSQL 实例。

它可以:

  • 拥有自己的用户;
  • 拥有自己的配置;
  • 拥有其他数据库;
  • 拥有其他 schema;
  • 承载查询、报表或分析业务。

但被复制的目标表不应该被其他业务随意写入。

4.4 Subscription

Subscription 定义:

  • A 库地址;
  • A 库端口;
  • 数据库名称;
  • 复制用户;
  • 复制密码;
  • 要订阅的 Publication;
  • 是否复制存量;
  • 是否创建复制槽;
  • 是否立即启用;
  • 出错后是否自动停用。

4.5 Replication Slot

复制槽用于保证 Subscriber 尚未消费的 WAL 不会过早删除。

它提供可靠续传能力,但也带来磁盘风险。

这是一把双刃剑:

TEXT
没有复制槽:
B 库断开后可能丢失所需 WAL。

有复制槽但不监控:
B 库长期离线可能导致 A 库 WAL 撑满磁盘。

因此,逻辑复制上线后必须把复制槽纳入监控。


五、初始同步和实时同步不是一回事

创建 Subscription 时通常会设置:

SQL
copy_data = true

这会触发两个阶段。

5.1 第一阶段:复制已有数据

假设 A 库 test_order 已有 500 万行。

Subscription 创建后,PostgreSQL 会对目标表执行初始同步。

可以理解为:

TEXT
A 库现有数据

COPY

B 库目标表

这个阶段会启动表同步 Worker。

如果配置:

TEXT
max_sync_workers_per_subscription = 2

表示一个 Subscription 同时最多有两张表进行初始 COPY。

其余表会排队。

所以看到:

TEXT
2 张表处于 d
其他表处于 i

是正常现象,不代表遗漏。

5.2 第二阶段:追赶增量

在初始 COPY 期间,A 库可能仍在持续写入。

PostgreSQL必须保证:

TEXT
存量数据复制完成
+
COPY 期间发生的增量变化

都被正确应用。

因此表状态不会简单地从 d 直接消失,而会经历协调过程。

5.3 表状态 i、d、f、s、r 分别是什么

在 B 库查询:

SQL
SELECT
    r.srrelid::regclass AS table_name,
    r.srsubstate
FROM pg_subscription_rel r
JOIN pg_subscription s
  ON s.oid = r.srsubid
WHERE s.subname = 'test_sync_sub'
ORDER BY table_name;

常见状态:

状态 含义
i 初始化或等待调度
d 正在复制存量数据
f 存量 COPY 已完成
s 已同步,等待与主 Apply Worker 协调
r 正常实时复制

真正的最终状态只有:

TEXT
r

如果一共有 5 张表,最终应该看到:

TEXT
r | 5

5.4 为什么初始化完成后只剩一个 Worker

初始化期间可能看到:

TEXT
主 Apply Worker
test_sync.test_order
test_sync.test_order_item

其中:

  • 主 Apply Worker:负责持续应用增量变化;
  • 表同步 Worker:负责某张表的初始 COPY。

当全部表进入 r 后,表同步 Worker 会退出,通常只剩主 Apply Worker。

如果主 Apply Worker 显示:

TEXT
state = idle
wait_event = LogicalApplyMain

这通常是正常空闲状态,表示当前没有新的 WAL 需要处理,不是卡住。


六、逻辑复制的边界:它不会帮你做什么

很多第一次使用逻辑复制的人,会下意识认为:

TEXT
既然数据能同步,表结构应该也会同步。

实际上不会。

6.1 不同步 DDL

逻辑复制不会自动同步:

  • CREATE SCHEMA
  • CREATE TABLE
  • ALTER TABLE
  • DROP TABLE
  • 新增字段
  • 删除字段
  • 修改字段类型
  • 索引
  • 主键
  • 唯一约束
  • 外键
  • 触发器定义

这意味着:

B 库必须提前存在同 schema、同表名、兼容字段结构的目标表。

6.2 不同步序列当前值

假设 A 库使用:

SQL
id bigserial

表中的 id 数据会同步,但序列当前值不会自动跟随。

所以 B 库不适合作为可随时切换写入的节点。

如果在 B 库直接插入数据,可能出现:

  • 序列值落后;
  • 主键冲突;
  • 后续复制中断。

6.3 不同步视图、函数和存储过程

逻辑复制关注的是表数据变化。

不会自动同步:

  • 视图;
  • 物化视图;
  • 函数;
  • 存储过程;
  • 用户;
  • 权限。

6.4 不同步 UNLOGGED 表

UNLOGGED 表不写完整 WAL,因此无法参与正常逻辑复制。

检查:

SQL
SELECT
    n.nspname,
    c.relname,
    c.relpersistence
FROM pg_class c
JOIN pg_namespace n
  ON n.oid = c.relnamespace
WHERE n.nspname = 'test_sync'
  AND c.relkind IN ('r', 'p');

常见值:

TEXT
p = 持久化表
u = UNLOGGED 表
t = 临时表

七、真正开始之前,先做六个设计决策

很多文章直接从 ALTER SYSTEM 开始,但生产实施更重要的是先做设计。

7.1 决定同步范围

需要明确:

  • 同步整个 schema;
  • 还是显式指定几张表;
  • 未来新增表是否自动纳入;
  • 是否发布 TRUNCATE
  • 是否需要全部字段;
  • 是否需要过滤行。

本文采用:

SQL
FOR TABLES IN SCHEMA test_sync

优点:

  • 当前表自动纳入;
  • 未来该 schema 新建表也会进入 Publication 范围。

代价:

  • 很难在同一 schema 中单独排除某张普通表;
  • 新增表后,B 库必须提前准备结构;
  • 新表权限和 Subscription 刷新需要规范化处理。

7.2 决定 B 库是否允许写入

最安全的策略是:

TEXT
B 库复制表只读。

如果允许业务写入,就必须面对:

  • 主键冲突;
  • 更新覆盖;
  • 删除冲突;
  • 数据分叉;
  • Subscription 停止。

原生单向逻辑复制不是双主方案。

7.3 检查主键和 Replica Identity

对于 UPDATEDELETE,B 库必须知道应该修改哪一行。

PostgreSQL 默认使用主键作为 Replica Identity。

检查:

SQL
SELECT
    n.nspname AS schema_name,
    c.relname AS table_name,
    EXISTS (
        SELECT 1
        FROM pg_index i
        WHERE i.indrelid = c.oid
          AND i.indisprimary
    ) AS has_primary_key,
    c.relreplident
FROM pg_class c
JOIN pg_namespace n
  ON n.oid = c.relnamespace
WHERE n.nspname = 'test_sync'
  AND c.relkind IN ('r', 'p')
ORDER BY c.relname;

没有主键的表应优先补主键。

实在无法增加主键时,可以考虑:

SQL
ALTER TABLE test_sync.test_xxx
REPLICA IDENTITY FULL;

但它会增加 WAL、网络和匹配成本。

7.4 决定是否复制存量数据

首次搭建通常使用:

SQL
copy_data = true

适合:

  • B 库目标表为空;
  • 希望 PostgreSQL 自动复制历史数据;
  • 不想自己处理存量导入。

如果 B 库已有一份确认一致的数据,可以使用:

SQL
copy_data = false

但这要求你自己保证 A/B 存量数据一致。

否则逻辑复制只会同步创建 Subscription 之后的新变化,历史差异会永久存在。

7.5 决定复制槽 WAL 保留策略

如果:

TEXT
max_slot_wal_keep_size = -1

表示复制槽可无限保留 WAL。

好处:

  • B 库离线较久后仍有机会续传。

风险:

  • A 库磁盘可能被 WAL 占满。

生产环境必须在“可靠续传”和“磁盘安全”之间做取舍。

7.6 决定结构变更顺序

最重要的变更规范:

TEXT
新增字段:B 先,A 后。

原因很简单:

如果 A 库先新增字段并开始写入,B 库没有该字段,Subscription 会报:

TEXT
missing replicated columns

八、完整实施:A 库发布端

这一部分不仅给命令,还会解释每一步为什么要做、如何验证以及最常见的错误。

8.1 第一步:确认连接的是正确数据库

Publication 是数据库级对象。

如果 test_sync 位于 test_db,但当前连接的是默认 postgres 数据库,执行创建 Publication 会报:

TEXT
ERROR: schema "test_sync" does not exist

先检查:

SQL
SELECT
    current_database(),
    current_user;

切换:

SQL
\c test_db

验证 schema:

SQL
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name = 'test_sync';

8.2 第二步:检查表类型、主键和持久化属性

SQL
SELECT
    n.nspname AS schema_name,
    c.relname AS table_name,
    CASE c.relkind
        WHEN 'r' THEN '普通表'
        WHEN 'p' THEN '分区表'
    END AS table_type,
    CASE c.relpersistence
        WHEN 'p' THEN '持久化表'
        WHEN 'u' THEN 'UNLOGGED表'
        WHEN 't' THEN '临时表'
    END AS persistence,
    EXISTS (
        SELECT 1
        FROM pg_index i
        WHERE i.indrelid = c.oid
          AND i.indisprimary
    ) AS has_primary_key,
    c.relreplident
FROM pg_class c
JOIN pg_namespace n
  ON n.oid = c.relnamespace
WHERE n.nspname = 'test_sync'
  AND c.relkind IN ('r', 'p')
ORDER BY c.relname;

检查目标:

TEXT
表类型正确
持久化属性为 p
需要 UPDATE/DELETE 的表具备主键
表数量符合预期

8.3 第三步:开启逻辑 WAL

检查当前参数:

SQL
SHOW wal_level;

如果是:

TEXT
replica

设置:

SQL
ALTER SYSTEM SET wal_level = 'logical';

查看参数来源:

SQL
SELECT
    name,
    setting,
    source,
    sourcefile,
    pending_restart
FROM pg_settings
WHERE name = 'wal_level';

这里经常出现一个误区:

SQL
SELECT pg_reload_conf();

并不能让 wal_level 立即生效。

因为 wal_level 是启动级参数,必须重启 PostgreSQL。

BASH
sudo systemctl restart postgresql

重启后:

SQL
SHOW wal_level;

预期:

TEXT
logical

8.4 第四步:检查复制能力参数

SQL
SHOW max_replication_slots;
SHOW max_wal_senders;
SHOW max_slot_wal_keep_size;
SHOW wal_sender_timeout;

这些参数分别控制:

  • 可创建多少复制槽;
  • 可启动多少 WAL Sender;
  • 单个槽最多保留多少 WAL;
  • A 库等待接收端响应多久。

注意:

TEXT
max_replication_slots = 10

表示最多有 10 个复制槽,不是只能同步 10 张表。

8.5 第五步:创建最小权限复制账号

SQL
CREATE ROLE repl_test
WITH
    LOGIN
    REPLICATION
    PASSWORD '<REPLICATION_PASSWORD>';

连接权限:

SQL
GRANT CONNECT
ON DATABASE test_db
TO repl_test;

schema 权限:

SQL
GRANT USAGE
ON SCHEMA test_sync
TO repl_test;

现有表读取权限:

SQL
GRANT SELECT
ON ALL TABLES IN SCHEMA test_sync
TO repl_test;

未来新表默认权限:

SQL
ALTER DEFAULT PRIVILEGES
FOR ROLE test_owner
IN SCHEMA test_sync
GRANT SELECT ON TABLES TO repl_test;

这里有一个容易忽略的细节:

ALTER DEFAULT PRIVILEGES 只影响指定对象所有者未来创建的对象。

如果业务表是由 test_owner 创建,就必须写:

SQL
FOR ROLE test_owner

否则未来新增表可能因为复制账号没有 SELECT 权限而同步失败。

8.6 第六步:配置 pg_hba.conf

查询文件:

SQL
SHOW hba_file;
SHOW listen_addresses;

增加:

CONF
host    test_db    repl_test    192.168.1.2/32    scram-sha-256

重新加载:

SQL
SELECT pg_reload_conf();

还需要确认:

  • A 库监听地址允许远程连接;
  • A 库主机防火墙允许 B 库访问;
  • 网络 ACL 或安全组允许 TCP 5432;
  • B 库实际出口地址就是 192.168.1.2

不要为了省事写:

CONF
0.0.0.0/0

8.7 第七步:创建 Publication

SQL
CREATE PUBLICATION test_sync_pub
FOR TABLES IN SCHEMA test_sync
WITH (
    publish = 'insert, update, delete, truncate'
);

检查 Publication:

SQL
SELECT
    pubname,
    pubinsert,
    pubupdate,
    pubdelete,
    pubtruncate
FROM pg_publication
WHERE pubname = 'test_sync_pub';

检查表清单:

SQL
SELECT
    pubname,
    schemaname,
    tablename
FROM pg_publication_tables
WHERE pubname = 'test_sync_pub'
ORDER BY schemaname, tablename;

不要只看:

TEXT
CREATE PUBLICATION

必须核对表数量和名称。


九、B 库连接 A 库:先验证链路,再创建订阅

创建 Subscription 之前,应先从 B 服务器直接测试 A 库。

BASH
psql \
  -h 192.168.1.1 \
  -p 5432 \
  -U repl_test \
  -d test_db

连接后:

SQL
SELECT
    current_database(),
    current_user;

SELECT count(*)
FROM test_sync.test_order;

这一小步可以提前发现:

  • 防火墙不通;
  • 端口不通;
  • pg_hba.conf 不匹配;
  • 密码错误;
  • 数据库无 CONNECT 权限;
  • schema 无 USAGE 权限;
  • 表无 SELECT 权限。

不要等到 CREATE SUBSCRIPTION 失败后再排查基础网络。


十、完整实施:B 库订阅端

10.1 第一步:检查 Worker 参数

SQL
SHOW max_replication_slots;
SHOW max_logical_replication_workers;
SHOW max_worker_processes;
SHOW max_sync_workers_per_subscription;
SHOW wal_receiver_timeout;

最容易被误解的是:

TEXT
max_sync_workers_per_subscription

它控制的是初始同步阶段,同时有多少张表进行 COPY。

例如:

TEXT
max_sync_workers_per_subscription = 2

即使有 100 张表,也只是每次并发两张,其余表排队。

10.2 第二步:准备同名表结构

逻辑复制不会自动创建目标表。

因此需要从 A 库导出 schema-only。

推荐使用自定义格式:

BASH
export PGPASSWORD='<A_DATABASE_PASSWORD>'

pg_dump \
  -h 192.168.1.1 \
  -p 5432 \
  -U postgres \
  -d test_db \
  --format=custom \
  --schema-only \
  --schema=test_sync \
  --no-owner \
  --no-privileges \
  --file=/tmp/test_sync_schema.dump

为什么推荐 .dump 而不是普通 .sql

因为普通 SQL 文本很容易出现:

  • >> 追加多次;
  • 文件中间被截断;
  • 手工编辑损坏;
  • 多个 dump 被拼接;
  • 导入错误后继续执行。

自定义格式可以先验证:

BASH
pg_restore \
  --list \
  /tmp/test_sync_schema.dump \
  >/tmp/test_sync_schema.list

echo $?

退出码应为:

TEXT
0

10.3 第三步:检查 schema 外部依赖

如果发布表的外键指向另一个 schema:

TEXT
test_sync.test_order.created_by
    → public.test_user.id

仅导出 test_sync 时,不会自动导出 public.test_user

恢复前检查:

SQL
SELECT to_regclass('public.test_user');

否则恢复可能在创建外键时失败。

10.4 第四步:清理旧结构

首次部署前,目标表应为空。

如果曾经失败并需要重建,应按照下面顺序清理:

TEXT
停业务写入

停用或删除旧 Subscription

确认复制 Worker 退出

终止长事务和阻塞连接

删除旧 schema

恢复最新结构

删除:

SQL
DROP SCHEMA IF EXISTS test_sync CASCADE;

为避免无限等待:

SQL
SET lock_timeout = '15s';

10.5 第五步:原子化恢复

BASH
export PGPASSWORD='<B_DATABASE_PASSWORD>'

pg_restore \
  -h 192.168.1.2 \
  -p 5432 \
  -U postgres \
  --role=test_owner \
  -d test_db \
  --no-owner \
  --no-privileges \
  --exit-on-error \
  --single-transaction \
  --verbose \
  /tmp/test_sync_schema.dump

关键参数:

TEXT
--role=test_owner

让恢复后的对象归业务角色。

TEXT
--exit-on-error

遇到错误立即退出。

TEXT
--single-transaction

整个恢复失败时统一回滚,避免留下半套结构。

10.6 第六步:验证结构和空表

表清单:

SQL
SELECT
    tablename,
    tableowner
FROM pg_tables
WHERE schemaname = 'test_sync'
ORDER BY tablename;

字段清单:

SQL
SELECT
    table_name,
    ordinal_position,
    column_name,
    data_type,
    udt_name,
    is_nullable,
    column_default
FROM information_schema.columns
WHERE table_schema = 'test_sync'
ORDER BY table_name, ordinal_position;

检查每张表行数:

SQL
SELECT format(
    'SELECT %L AS table_name, count(*) AS row_count FROM %I.%I;',
    schemaname || '.' || tablename,
    schemaname,
    tablename
)
FROM pg_tables
WHERE schemaname = 'test_sync'
ORDER BY tablename
\gexec

首次使用 copy_data=true 时,目标表应为空。

10.7 第七步:创建 Subscription

SQL
CREATE SUBSCRIPTION test_sync_sub
CONNECTION '
    host=192.168.1.1
    port=5432
    dbname=test_db
    user=repl_test
    password=<REPLICATION_PASSWORD>
    application_name=test_sync_sub
    connect_timeout=10
'
PUBLICATION test_sync_pub
WITH (
    copy_data = true,
    create_slot = true,
    enabled = true,
    disable_on_error = true
);

参数解释:

  • copy_data=true:先复制历史数据;
  • create_slot=true:在 A 库创建逻辑复制槽;
  • enabled=true:创建后立即启动;
  • disable_on_error=true:出现复制错误时自动停用;
  • application_name:便于 A 库从 pg_stat_replication 识别连接。

十一、如何判断“真的同步完成了”

看到 Subscription 创建成功,不代表所有表已经同步完成。

11.1 检查 Subscription 是否启用

SQL
SELECT
    subname,
    subenabled,
    subslotname,
    subdisableonerr,
    subpublications
FROM pg_subscription
WHERE subname = 'test_sync_sub';

目标:

TEXT
subenabled = t

11.2 查看所有表状态

SQL
SELECT
    r.srrelid::regclass AS table_name,
    r.srsubstate,
    CASE r.srsubstate
        WHEN 'i' THEN '初始化排队'
        WHEN 'd' THEN '复制存量数据'
        WHEN 'f' THEN '存量复制完成'
        WHEN 's' THEN '已同步,等待切换'
        WHEN 'r' THEN '正常实时复制'
        ELSE '未知状态'
    END AS sync_state
FROM pg_subscription_rel r
JOIN pg_subscription s
  ON s.oid = r.srsubid
WHERE s.subname = 'test_sync_sub'
ORDER BY table_name;

汇总:

SQL
SELECT
    r.srsubstate,
    count(*) AS table_count
FROM pg_subscription_rel r
JOIN pg_subscription s
  ON s.oid = r.srsubid
WHERE s.subname = 'test_sync_sub'
GROUP BY r.srsubstate
ORDER BY r.srsubstate;

最终标准:

TEXT
r | 5

假设一共 5 张表。

11.3 查看 Worker

SQL
SELECT
    ps.pid,
    CASE
        WHEN ps.relid IS NULL THEN '主 Apply Worker'
        ELSE ps.relid::regclass::text
    END AS worker,
    a.state,
    a.wait_event_type,
    a.wait_event,
    ps.latest_end_time
FROM pg_stat_subscription ps
LEFT JOIN pg_stat_activity a
  ON a.pid = ps.pid
WHERE ps.subname = 'test_sync_sub'
ORDER BY ps.relid NULLS FIRST;

全部完成后通常只剩主 Apply Worker。

11.4 查看 COPY 是否推进

SQL
SELECT
    pid,
    relid::regclass AS table_name,
    command,
    type,
    pg_size_pretty(bytes_processed) AS processed,
    tuples_processed
FROM pg_stat_progress_copy;

持续观察:

SQL
SELECT
    clock_timestamp(),
    pid,
    relid::regclass AS table_name,
    bytes_processed,
    tuples_processed
FROM pg_stat_progress_copy
\watch 10

如果数值持续增长,说明正在正常 COPY。


十二、一次完整验证:INSERT、UPDATE、DELETE

只看系统视图还不够,最终必须做业务级验证。

12.1 INSERT

A 库:

SQL
INSERT INTO test_sync.test_order(
    id,
    order_no,
    amount,
    created_at
)
VALUES (
    900001,
    'TEST-ORDER-900001',
    100.00,
    now()
);

B 库:

SQL
SELECT *
FROM test_sync.test_order
WHERE id = 900001;

12.2 UPDATE

A 库:

SQL
UPDATE test_sync.test_order
SET amount = 200.00
WHERE id = 900001;

B 库:

SQL
SELECT
    id,
    order_no,
    amount
FROM test_sync.test_order
WHERE id = 900001;

12.3 DELETE

A 库:

SQL
DELETE FROM test_sync.test_order
WHERE id = 900001;

B 库:

SQL
SELECT *
FROM test_sync.test_order
WHERE id = 900001;

预期:

TEXT
0 rows

十三、真实实施中最容易踩的八个坑

下面这些问题并不是理论推演,而是逻辑复制实施中非常典型的故障。

坑一:在错误数据库创建 Publication

现象:

TEXT
ERROR: schema "test_sync" does not exist

先检查:

SQL
SELECT current_database();

Publication 是数据库级对象,必须连接包含目标 schema 的数据库。

坑二:设置了 wal_level,但没有重启

执行:

SQL
ALTER SYSTEM SET wal_level = 'logical';

之后只做:

SQL
SELECT pg_reload_conf();

仍然不会生效。

检查:

SQL
SELECT
    setting,
    pending_restart
FROM pg_settings
WHERE name = 'wal_level';

只要需要重启,就必须安排数据库重启。

坑三:普通 SQL dump 被追加或截断

典型表现:

TEXT
一个文件里出现多个 dump complete
SQL 从半行开始
约束定义被截断

解决办法不是手工修几行,而是重新导出自定义格式:

TEXT
pg_dump --format=custom

并使用:

TEXT
pg_restore --list

验证归档完整性。

坑四:B 库缺少 A 库新字段

错误:

TEXT
logical replication target relation
is missing replicated columns

根因:

TEXT
逻辑复制不同步 DDL。

修复:

  • 在 A 库确认准确字段定义;
  • 在 B 库补齐;
  • 再恢复 Subscription。

坑五:B 库已有数据导致主键冲突

错误:

TEXT
duplicate key value violates unique constraint

常见原因:

  • B 库目标表未清空;
  • 重建 Subscription 时再次 copy_data=true
  • B 库被其他业务写入。

最稳妥方式:

TEXT
停止写入
→ 清理目标表
→ 重建结构
→ 重新初始同步

坑六:Subscription 自动变成 f

查询:

SQL
SELECT
    subenabled,
    subdisableonerr
FROM pg_subscription
WHERE subname = 'test_sync_sub';

如果:

TEXT
subenabled = f

说明订阅已停用。

若:

TEXT
subdisableonerr = t

则一次复制错误就可能自动禁用订阅。

不要直接反复:

SQL
ALTER SUBSCRIPTION test_sync_sub ENABLE;

应该先查看最新错误日志和统计。

坑七:初始同步最后一张表长期停在 d

先看进度:

SQL
SELECT
    pid,
    relid::regclass AS table_name,
    bytes_processed,
    tuples_processed
FROM pg_stat_progress_copy;

隔一段时间再次查询。

如果长时间完全不增长,再检查 Worker:

SQL
SELECT
    ps.pid,
    ps.relid::regclass AS table_name,
    a.wait_event_type,
    a.wait_event
FROM pg_stat_subscription ps
LEFT JOIN pg_stat_activity a
  ON a.pid = ps.pid
WHERE ps.subname = 'test_sync_sub';

确认某个表同步 Worker 已异常卡死后,可以只终止该 Worker:

SQL
SELECT pg_terminate_backend(<TABLE_SYNC_WORKER_PID>);

不要随意终止主 Apply Worker。

坑八:复制 Worker 因超时退出

日志:

TEXT
terminating logical replication worker due to timeout

参数位置必须分清:

TEXT
A 库:wal_sender_timeout
B 库:wal_receiver_timeout

跨网络链路可以适当增大:

SQL
-- A 库
ALTER SYSTEM SET wal_sender_timeout = '15min';
SELECT pg_reload_conf();

-- B 库
ALTER SYSTEM SET wal_receiver_timeout = '15min';
SELECT pg_reload_conf();

如果仍然超时,不应无限增大参数,而应进一步检查:

  • 网络丢包;
  • NAT 会话回收;
  • 防火墙连接超时;
  • TCP keepalive;
  • A/B 数据库 I/O;
  • 容器网络;
  • 大表 COPY;
  • 连接是否持续收到 keepalive。

十四、表结构变更应该怎么做

逻辑复制长期稳定运行的关键,不是第一次搭建,而是后续 DDL 管理。

14.1 新增字段:B 先,A 后

SQL
-- B 库先执行
ALTER TABLE test_sync.test_order
ADD COLUMN test_remark varchar(500);

-- A 库后执行
ALTER TABLE test_sync.test_order
ADD COLUMN test_remark varchar(500);

然后再发布使用该字段的业务代码。

顺序:

TEXT
B 库结构兼容

A 库新增字段

业务开始写新字段

14.2 删除字段:先停止业务使用

推荐:

TEXT
业务停止读写字段

确认 A 库不再使用

A 库删除字段

验证复制正常

B 库删除字段

不要先在 B 库删除仍由 A 库发送的字段。

14.3 新增表

使用:

SQL
FOR TABLES IN SCHEMA test_sync

时,A 库以后在该 schema 中新建的普通表会自动进入 Publication 范围。

但 B 库不会自动建表。

正确流程:

TEXT
B 库先创建同名表

设置正确 owner

A 库创建表

给复制用户 SELECT

B 库 REFRESH PUBLICATION

观察新表进入 r

刷新:

SQL
ALTER SUBSCRIPTION test_sync_sub
REFRESH PUBLICATION
WITH (
    copy_data = true
);

14.4 修改字段类型

修改字段类型必须确保两边兼容。

稳妥策略:

  • B 库先扩展为兼容类型;
  • A 库再修改;
  • 验证复制;
  • 最后上线新业务代码。

复杂类型转换应进入维护窗口,不应在高峰期直接操作。


十五、如何建立真正有效的监控

逻辑复制监控可以分成三个层面。

15.1 控制面:Subscription 是否正常

B 库:

SQL
SELECT
    s.subname,
    s.subenabled,
    s.subdisableonerr,
    st.apply_error_count,
    st.sync_error_count
FROM pg_subscription s
LEFT JOIN pg_stat_subscription_stats st
  ON st.subid = s.oid
WHERE s.subname = 'test_sync_sub';

应该告警:

  • subenabled=false
  • apply_error_count 增长;
  • sync_error_count 增长;
  • 主 Apply Worker 不存在。

15.2 数据面:Worker 是否推进

SQL
SELECT
    ps.pid,
    ps.relid::regclass AS syncing_table,
    a.state,
    a.wait_event_type,
    a.wait_event,
    ps.received_lsn,
    ps.latest_end_lsn,
    ps.latest_end_time
FROM pg_stat_subscription ps
LEFT JOIN pg_stat_activity a
  ON a.pid = ps.pid
WHERE ps.subname = 'test_sync_sub';

应该关注:

  • Worker 是否存在;
  • latest_end_time 是否长时间不更新;
  • 表同步进度是否停滞;
  • 正常阶段是否出现非 r 表。

15.3 存储面:复制槽保留多少 WAL

A 库:

SQL
SELECT
    slot_name,
    active,
    wal_status,
    pg_size_pretty(
        pg_wal_lsn_diff(
            pg_current_wal_lsn(),
            restart_lsn
        )
    ) AS retained_wal
FROM pg_replication_slots
WHERE slot_name = 'test_sync_sub';

必须告警:

  • 复制槽长时间 inactive;
  • retained_wal 持续增长;
  • wal_status 异常;
  • A 库磁盘使用率过高;
  • pg_wal 目录异常增长。

十六、日常运维命令

暂停

SQL
ALTER SUBSCRIPTION test_sync_sub DISABLE;

注意:Worker 可能不会瞬间退出。

SQL
SELECT
    pid,
    relid::regclass AS syncing_table
FROM pg_stat_subscription
WHERE subname = 'test_sync_sub';

恢复

SQL
ALTER SUBSCRIPTION test_sync_sub ENABLE;

删除

SQL
DROP SUBSCRIPTION test_sync_sub;

正常情况下会尝试删除 A 库对应复制槽。

A 库不可达时删除

SQL
ALTER SUBSCRIPTION test_sync_sub DISABLE;

ALTER SUBSCRIPTION test_sync_sub
SET (
    slot_name = NONE
);

DROP SUBSCRIPTION test_sync_sub;

之后在 A 库手工检查遗留槽。

修改复制密码

SQL
ALTER SUBSCRIPTION test_sync_sub
CONNECTION '
    host=192.168.1.1
    port=5432
    dbname=test_db
    user=repl_test
    password=<NEW_REPLICATION_PASSWORD>
    application_name=test_sync_sub
    connect_timeout=10
';

十七、FAQ:常见认知误区

1. max_replication_slots=10 是否只能同步 10 张表

不是。

它限制的是复制槽数量,不是表数量。

一个 Subscription 可以包含很多张表。

2. B 库可以继续写同步表吗

技术上可能允许,但不建议。

单向逻辑复制没有自动冲突解决能力。

3. B 库表字段顺序必须完全一致吗

目标表需要具备兼容列。

最稳妥的方式是直接从 A 库导出 schema-only 恢复,而不是人工重建。

4. 逻辑复制能代替高可用吗

不能直接等同。

它适合表级数据分发和迁移,不等于完整主备切换体系。

5. Subscription 创建成功是否代表完成

不代表。

必须确认所有表:

TEXT
srsubstate = r

6. B 库断开后会不会丢数据

只要复制槽仍可用且所需 WAL 没有丢失,恢复连接后可以继续追赶。

但 B 库长时间离线会导致 A 库 WAL 堆积。

7. 为什么 B 库没有自动出现新表

因为逻辑复制不复制 DDL。

新表必须先在 B 库创建,再刷新 Publication。


十八、上线前检查清单

A 库

TEXT
[ ] 当前数据库正确
[ ] wal_level = logical
[ ] max_replication_slots 足够
[ ] max_wal_senders 足够
[ ] 复制用户具备 REPLICATION
[ ] 复制用户具备 CONNECT、USAGE、SELECT
[ ] pg_hba.conf 仅允许 B 库来源
[ ] Publication 表清单正确
[ ] 发布表有主键或合理 Replica Identity
[ ] 复制槽纳入 WAL 和磁盘监控

B 库

TEXT
[ ] 目标 schema 已创建
[ ] 目标表结构与 A 库兼容
[ ] 目标表 owner 正确
[ ] 初次 copy_data=true 时目标表为空
[ ] Worker 参数足够
[ ] wal_receiver_timeout 合理
[ ] Subscription 已创建
[ ] subenabled = true
[ ] 所有表 srsubstate = r
[ ] 主 Apply Worker 正常
[ ] B 库业务账号无同步表写权限

验证

TEXT
[ ] INSERT 已验证
[ ] UPDATE 已验证
[ ] DELETE 已验证
[ ] A/B 抽样数据一致
[ ] 错误统计不再增长
[ ] 复制槽 active=true
[ ] retained_wal 在合理范围
[ ] 告警已配置

十九、总结:逻辑复制真正难的不是创建,而是治理

PostgreSQL 逻辑复制的创建过程并不复杂:

TEXT
A 库创建 Publication
B 库创建 Subscription

但真正的生产难点集中在:

  • 表结构兼容;
  • 权限管理;
  • 初始同步;
  • DDL 变更;
  • 复制槽 WAL 堆积;
  • Worker 超时;
  • 目标库写入冲突;
  • 长事务和锁;
  • 错误自动停用;
  • 监控告警。

因此,一套成熟的逻辑复制方案不应该只包含部署命令,还应该包含:

TEXT
复制范围设计
+
结构变更规范
+
故障排查手册
+
监控告警
+
安全权限
+
验收标准

如果只记住一个原则,可以记住下面这句话:

逻辑复制负责持续同步数据变化,但表结构一致性、目标端写入控制和复制槽安全,仍然需要由运维体系负责。

对于“只同步部分业务表,同时让 B 库保持独立”的场景,PostgreSQL 原生逻辑复制是一种足够轻量、透明且可控的方案。

前提是:不要把它当作两条 SQL,而要把它当作一条需要持续治理的数据链路。


参考资料

评论