Skip to content

逻辑复制限制 ​

逻辑复制的能力清单很诱人:表级粒度、跨大版本、双向可能、订阅端可写。但它的限制清单同样必须刻在脑子里——这些限制大多源于「逻辑复制 = 解码 + 重新执行」这个本质:WAL 里只有物理块变更,DDL、序列推进、大对象句柄这些根本不在解码结果里,自然也就到不了订阅端。与流复制「备库是主库的位级镜像」不同,逻辑复制的订阅端是一个需要人为维持一致性的独立实例,本页列出的每一条限制,都是一致性清单上的一项。

官方文档把这些内容归在「Restrictions」与「Conflicts」两节,本页在其基础上补充了每一条的规避方案与检测方法。学习建议是分两遍读:第一遍记住「什么不复制」,用于架构选型时排除法;第二遍记住「出错了是什么形态」,用于生产排障——逻辑复制的大多数故障(apply 停滞、结构漂移、序列越界、WAL 堆积)都能在本页找到根因。

先给结论性的定位:逻辑复制是优秀的数据分发与迁移工具,但不是开箱即用的双向同步或高可用方案。把它的边界当作设计约束而不是缺陷去接受,方案才稳。

前置知识

建议先阅读:订阅(CREATE SUBSCRIPTION)

基础概念 ​

限制分两类:能力边界(机制上做不到,如 DDL、序列、大对象)与运维约束(能做但要自己负责,如 schema 同步、冲突处理、版本匹配)。先看总览表,每一行在「详细说明」里都有对应的展开与规避方案:

#限制类型规避方向
1DDL 不复制能力边界两端同步执行 DDL;订阅端先做加法式变更
2序列状态不复制能力边界切换前 setval() 同步;订阅端只读则无影响
3大对象(Large Object)不复制能力边界改用 bytea 存进普通表,或另行同步
4视图 / 物化视图 / 外部表无法发布能力边界发布基表,订阅端自建视图
5角色、表空间、权限等数据库级对象不复制能力边界pg_dumpall --roles-only / --tablespaces-only
6无复制标识的表无法复制 UPDATE/DELETE能力边界主键 / USING INDEX / REPLICA IDENTITY FULL
7schema 必须手工保持同步运维约束迁移流程 + 结构 diff 校验
8复制冲突会停住 apply运维约束冲突预案:修数重放或 SKIP
9分区表对订阅端结构有要求运维约束publish_via_partition_root = true 或镜像分区
10订阅端大版本必须 ≥ 发布端能力边界升级方向固定:旧发布 → 新订阅
11初始同步无全局一致快照能力边界停写窗口初始化,或接受短暂偏差
12无内置冲突解决,双向复制无仲裁能力边界单写原则;origin 过滤 + 外部工具
13two_phase 有状态机限制运维约束初始同步完成后才生效;禁用态才能改
14run_as_owner = true 有安全隐患运维约束保持默认 false

这些限制从哪里来 ​

把同一条语句放进两种复制的传输管道,差异一目了然:

sql
UPDATE public.orders SET status = 'paid' WHERE order_id = 1001;
text
【流复制视角】WAL 里是一条物理记录:
  「表 oid=16384 的第 N 号页、页内偏移 X 处的若干字节,替换为这些字节」
  —— 不需要理解“这是哪张表的哪一列”,按块重放即可。
     DDL、序列推进、大对象写入同样是 WAL 记录,所以全部自动同步。

【逻辑复制视角】解码器必须先“理解”这条 WAL 再输出:
  → 变更属于哪张表?在发布里吗?过了行过滤吗?
  → 用哪组列定位旧行?(取决于表的复制标识)
  → 输出哪些列的新值?
  ── 凡是“理解”后无法表达的(DDL 语句、序列状态推进、大对象内容),
     不会出现在解码结果里,订阅端自然收不到。

所以本页的限制不是「官方还没来得及做」,而是「解码—重放」模型的固有边界。做架构设计时,识别哪些需求越界,比背清单更重要。

症状速查:线上最常撞见的五类故障 ​

症状根因对应章节
发布端执行 UPDATE 直接报错表在发布 UPDATE/DELETE 的发布里但无可用复制标识无主键表与 REPLICA IDENTITY 的硬约束
订阅端日志反复报同一 ERROR,latest_end_lsn 不动apply 冲突(唯一键 / 权限 / RLS)复制冲突:apply 报错即停
发布端 pg_wal 持续增长、磁盘告警复制槽滞留 WAL(订阅离线或 apply 停摆)WAL 滞留与磁盘风险
新加的表迟迟不同步订阅未刷新,或 worker 池不足DDL 不复制 / 初始同步的临时槽与并发限制
切换后首批写入主键冲突序列未同步序列状态不复制

语法与用法 ​

限制本身没有语法,但检测手段有。上线前把下面这组体检 SQL 跑一遍,能提前消掉本页一半的坑。

sql
-- ① 找出发布中「无主键且复制标识为默认」的表(UPDATE/DELETE 会报错的高危表)
SELECT pt.pubname, pt.schemaname, pt.tablename, c.relreplident
FROM pg_publication_tables pt
JOIN pg_class c
  ON c.relname = pt.tablename
JOIN pg_namespace n
  ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
WHERE NOT EXISTS (
        SELECT 1 FROM pg_index i
        WHERE i.indrelid = c.oid AND i.indisprimary)
  AND c.relreplident = 'd';      -- d=default:无主键时等于没有可用复制标识
sql
-- ② 检查各表的复制标识分布(n=nothing 也会挡住 UPDATE/DELETE)
SELECT n.nspname AS schema, c.relname AS table, c.relreplident
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.relreplident, n.nspname, c.relname;
sql
-- ③ 检查库中是否使用了大对象
SELECT count(*) AS large_object_count FROM pg_largeobject_metadata;

-- ④ 检查是否有列使用了 lo 类型(逻辑复制不搬运其内容)
SELECT table_schema, table_name, column_name, udt_name
FROM information_schema.columns
WHERE udt_name IN ('lo', 'oid');
sql
-- ⑤ 列出需要切换前手工同步的序列
SELECT schemaname, sequencename, last_value
FROM pg_sequences
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2;
bash
# ⑥ 结构 diff:发布端与订阅端 schema-only 导出做对比
pg_dump -h pub-host -p 5432 -U postgres -d app \
    --schema-only --no-owner --no-privileges > /tmp/pub_schema.sql
pg_dump -h sub-host -p 5433 -U postgres -d app \
    --schema-only --no-owner --no-privileges > /tmp/sub_schema.sql
diff -u /tmp/sub_schema.sql /tmp/pub_schema.sql
sql
-- ⑦ 全局对象与扩展核对:订阅端初始化前必须备齐(两端各跑一遍并对比)
SELECT extname, extversion FROM pg_extension ORDER BY extname;

SELECT rolname, rolcanlogin, rolreplication FROM pg_roles
WHERE rolname NOT LIKE 'pg\_%' ORDER BY rolname;

详细说明 ​

DDL 不复制:schema 必须手工同步 ​

官方限制清单的第一条:数据库 schema 与 DDL 命令不被复制。发布端 ALTER TABLE ADD COLUMN 之后,订阅端什么都不会发生;等携带新列的变更到达订阅端而表里没有这一列时,apply worker 开始持续报错——这就是「结构漂移」的标准发作方式。

官方同时给了两条重要缓和信息:

  • 两端结构不需要完全相同。订阅端可以多列(有默认值或可空)、可以有不同的索引与约束;
  • 逻辑复制对「在线改结构」是有韧性的:发布端先改、订阅端后改的窗口期里 apply 会报错,但结构补齐后会自动恢复。在订阅端先执行加法式变更(先加列、再加约束)可以避免绝大多数间歇性报错。

规避方案:

  • 初始结构用 pg_dump --schema-only 复制;
  • 后续 DDL 全部纳入变更流程,约定执行顺序为「订阅端加法 → 发布端变更 → (必要时)订阅端收尾」;
  • 变更窗口后跑一遍结构 diff(见「语法与用法」⑥),确认两端收敛。

序列状态不复制 ​

序列(sequence)的当前值不在逻辑复制范围内。 serial / identity 列里已经写入的「值」作为普通行数据会正常复制,但订阅端序列对象本身停在初始位置——发布端 nextval() 跑到一万,订阅端的序列还停在 1。

影响取决于订阅端的用途:

  • 订阅端纯只读(报表、分析):无影响;
  • 订阅端要切换为主库接管写入(switchover / failover):必须在切换前同步序列,否则第一批新插入就会主键冲突。

规避方案:

sql
-- 切换窗口、停止写入后,在发布端生成、在订阅端执行
SELECT format('SELECT setval(%L, %s);',
              schemaname || '.' || sequencename,
              COALESCE(last_value, start_value))
FROM pg_sequences
WHERE schemaname NOT IN ('pg_catalog', 'information_schema');

也可以按官方建议用 pg_dump 导出序列状态,或直接从各表 max(id) 反推一个足够高的值。序列机制本身见序列一页。

大对象(Large Objects)不复制 ​

官方文档原文的表述很干脆:大对象不被复制,除了把数据存进普通表之外没有绕行方案。逻辑解码的输出里根本没有大对象的内容(pg_largeobject 的变更不走行级解码),因此订阅端的大对象既不会初始化、也不会增量同步。

规避方案:

  • 优先改造:把二进制内容改存 bytea 列——它是普通列,随行正常复制(bytea 与大对象的取舍见 bytea 对比大对象);
  • 不能改造的:订阅端单独用应用层逻辑或脚本同步大对象(官方明确这不是内置能力);
  • 大对象基础概念见大对象概述。

上线前必查大对象

大对象的使用往往藏在老代码里(lo 类型、lo_import() 调用)。用「语法与用法」③④ 的体检 SQL 确认目标库干净,再决定是否采用逻辑复制。

视图、物化视图与外部表无法发布 ​

逻辑复制只支持表(含分区表)。试图把视图、物化视图、外部表加入发布会直接报错;临时表、unlogged 表同样不能进发布。

规避方案:

  • 想分发「视图逻辑」:发布底层基表,订阅端自建同名视图;
  • 想分发物化视图内容:发布其源表,订阅端自建物化视图并定期 REFRESH MATERIALIZED VIEW;
  • 外部表:在订阅端配置同样的 FDW 与外部表定义,数据不经过复制链路。
sql
-- 发布端:只发布基表(视图本身不能进发布)
CREATE PUBLICATION pub_dw FOR TABLE public.orders, public.order_items;

-- 订阅端:数据到位后自建视图,把「视图逻辑」留在下游
CREATE VIEW public.v_orders_current AS
SELECT order_id, user_id, status, amount
FROM public.orders
WHERE status <> 'cancelled';

订阅端自建的视图会不会干扰 apply?不会:视图不是发布目标,apply worker 只碰发布中登记的基表;同理,订阅端为下游报表自建的物化视图 REFRESH 也是纯本地操作,与复制链路互不感知。

数据库级对象不复制:角色、权限与表空间 ​

DDL 不复制的直接推论:角色(users/groups)、表空间、GRANT 授权、扩展安装这些数据库级或集簇级对象都不会出现在订阅端。订阅端连上发布端能收数据,但不代表它能「长得像」发布端——缺角色会导致初始同步后对象 owner 悬空,缺扩展会导致带扩展类型的表无法建出来。

规避方案:

bash
# 集簇级对象用 pg_dumpall 导出(注意这输出的是 SQL,需要人工/脚本在订阅端执行)
pg_dumpall -h pub-host -p 5432 -U postgres --roles-only      > /tmp/roles.sql
pg_dumpall -h pub-host -p 5432 -U postgres --tablespaces-only > /tmp/tablespaces.sql

# 扩展按需在订阅端先安装
# CREATE EXTENSION IF NOT EXISTS pgcrypto;
  • 初始化顺序固定为「角色/表空间 → 扩展 → schema → 建订阅」;
  • GRANT 类授权建议用角色统一管理(订阅端授予角色而非逐用户授权),减少两端对账成本。

初始化顺序展开成检查清单(顺序颠倒会在中途报错):

text
① 角色 / 表空间     pg_dumpall --roles-only / --tablespaces-only,先在订阅端执行
② 扩展              CREATE EXTENSION IF NOT EXISTS ...(含 contrib 与第三方扩展)
③ schema            pg_dump --schema-only 导入订阅端(表、序列、视图、约束)
④ 数据              CREATE SUBSCRIPTION ...(触发初始同步 + 增量应用)
⑤ 校验              结构 diff、核心表行数抽查、pg_stat_subscription_stats 计数归零

缺第①步时,pg_dump --schema-only 导出的对象会把 owner 悬空到当前执行者;缺第②步时,凡是用到扩展类型的表在初始同步阶段直接失败——两类问题都要等建了订阅才发作,回退成本很高。

无主键表与 REPLICA IDENTITY 的硬约束 ​

这是唯一一条会在发布端的业务写入上直接报错的限制,最值得反复强调:

  • 发布含 UPDATE / DELETE 时,表中复制标识为 NOTHING、DEFAULT 但无主键、或 USING INDEX 指向已删除索引的表,执行 UPDATE / DELETE 会在发布端报错(业务写入被拒绝);
  • INSERT 永远不受影响——所以这类表的症状往往是「插入正常、更新炸了」;
  • REPLICA IDENTITY FULL 的连带限制:表中含有 point、box 等没有默认 B-tree / Hash 操作符类的类型时,订阅端无法对这类行做 UPDATE / DELETE 定位;
  • 发布端复制标识不是 FULL 时,订阅端必须定义「相同或更少列」的复制标识;
  • 另一个隐藏点:行过滤器在发布 UPDATE/DELETE 时只能使用复制标识覆盖的列,列清单则必须包含复制标识列。

把各复制标识在发布端的行为整理成矩阵,检查表结构时对号入座:

复制标识发布 INSERT发布 UPDATE / DELETE
DEFAULT + 有主键正常用主键定位旧行,正常
DEFAULT + 无主键正常发布端直接报错,业务写入被拒绝
NOTHING正常发布端直接报错,业务写入被拒绝
USING INDEX(索引存在且满足要求)正常用该索引定位旧行,正常
USING INDEX(索引已被删除)正常发布端直接报错,业务写入被拒绝
FULL正常全行匹配定位;含无默认 B-tree/Hash 操作符类类型的行在订阅端定位失败

规避方案:

  • 优先级从高到低:补主键 → REPLICA IDENTITY USING INDEX(B-tree、唯一、非部分、非表达式索引)→ REPLICA IDENTITY FULL(评估 WAL/网络放大与类型限制);
  • 实在不能改的表:把它从发布 UPDATE/DELETE 的发布中移出去,单独建 publish = 'insert' 的发布。

复制冲突:apply 报错即停 ​

订阅端重新执行变更时违反约束即为冲突(conflict)。官方定义的冲突类型与行为:

冲突类型触发场景行为
insert_exists插入违反非 DEFERRABLE 唯一约束报错停住,需人工处理
update_exists更新后违反唯一约束报错停住,需人工处理
multiple_unique_conflicts一行同时违反多个唯一约束报错停住,需人工处理
update_missing要更新的行不存在记日志与计数,跳过
delete_missing要删除的行不存在记日志与计数,跳过
update_origin_differs / delete_origin_differs目标行曾被其他 origin 修改(需订阅端开启 track_commit_timestamp)记日志与计数,当前版本仍然应用

报错型冲突会让 apply worker 停在原地反复重试,日志形如:

text
ERROR:  conflict detected on relation "public.orders": conflict=insert_exists
DETAIL:  Key already exists in unique index "orders_pkey", which was modified locally in transaction 740 at 2026-09-30 10:47:04.727375+08.
Key (order_id)=(1001); existing local row (1001, 42, 199.00, local); remote row (1001, 42, 199.00, paid).
CONTEXT:  processing remote data for replication origin "pg_16395" during "INSERT" for replication target relation "public.orders" in transaction 725 finished at 0/14C0378

规避方案(处理顺序):

  1. 优先修数重放:按日志 DETAIL 判断保留哪边,把订阅端数据改成可容纳远端事务的形态,apply 自动恢复;
  2. 确认丢弃则用 ALTER SUBSCRIPTION ... SKIP (lsn = '0/14C0378') 跳过整个远端事务(finish LSN 取自日志),注意 SKIP 会丢掉事务内所有变更;
  3. 也可用 pg_replication_origin_advance() 推进原点(需先禁用订阅或已设 disable_on_error),当前进度见 pg_replication_origin_status;
  4. 长期防呆:订阅端开启 track_commit_timestamp(冲突日志会带上来源与提交时间戳)、订阅设 disable_on_error = true、监控 pg_stat_subscription_stats 的 apply_error_count 与 confl_* 计数器;
  5. 另外两类非典型「冲突」:订阅端权限不足、目标表启用了对订阅 owner 生效的行级安全(RLS),同样会让应用失败——表结构与权限也要纳入同步清单。

WAL 滞留与磁盘风险 ​

严格说这不是「复制能力」的限制,而是逻辑复制链路最著名的副作用:复制槽会阻止发布端回收 WAL。订阅离线、apply 停滞、发布端长事务未提交,都会让 pg_wal 持续增长,最终吃满磁盘——物理槽同样有此风险,但逻辑链路更容易「悄悄停住」:apply 冲突停摆时连接还在、进程还在,不像断线那样显眼。

观察与设防:

sql
-- 滞留量 = 当前 LSN 与槽推进点之间的距离
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_type = 'logical';
conf
# postgresql.conf(发布端):兜底保护
max_slot_wal_keep_size = 100GB        # 超过限额的槽会被标记 lost,订阅需重建
idle_replication_slot_timeout = 4h    # PG18+:空闲槽超时自动失效

规避方案: pg_wal 放独立磁盘分区;给 max_slot_wal_keep_size 设上限(宁可让订阅重建,不可拖垮主库);把 retained_wal 与 active = false 的持续时长纳入告警。机制细节见复制槽。

分区表的额外要求 ​

默认模式(publish_via_partition_root = false)下,变更以叶子分区的身份发布,因此:

  • 发布端的每个分区在订阅端必须存在可用的目标表(叶子分区、进一步分区的表、或独立普通表均可);
  • 新增分区属于 DDL,不会自动同步,订阅端也要跟着建;
  • 把表 ATTACH 进已发布分区树时,其已有内容不会被补发;
  • 改用 publish_via_partition_root = true 后,订阅端只需根表(可以是非分区表),但直接对分区的 TRUNCATE 不会被复制,且中途切换该参数可能造成订阅端数据丢失或重复。

规避方案: 结构同构的场景保持默认;下游不需要分区结构的场景从第一天就开 publish_via_partition_root = true,避免中途切换;分区 DDL 纳入两端变更流程。

跨大版本兼容性 ​

官方支持的版本方向是单向的:订阅端大版本必须 ≥ 发布端。从旧版发布端复制到新版订阅端(升级场景)是官方明确支持的用法;反过来「新版发布、旧版订阅」不被支持——新版本引入的协议能力(流式并行、两阶段、生成列等)旧订阅端无法理解。pgoutput 协议版本协商机制决定了特性是「向下不兼容」的。

版本组合速查:

发布端订阅端是否支持说明
1618✅官方升级方向:近零停机大版本迁移的标准做法
1717✅同版本常规链路
1516✅订阅端略新,兼容
1816❌不支持;旧订阅端无法理解新版协议能力

规避方案: 升级拓扑永远把新版本放在订阅端;发布端与订阅端的小版本都保持跟进最新补丁版本。升级前先在测试环境用真实版本组合演练一轮,streaming、two_phase 等能力按两端较低版本生效。

订阅是一库的契约 ​

发布与订阅都是数据库级对象:发布定义在发布端的某一个库里,订阅的 CONNECTION 只指向一个数据库。这带来几条工程上的推论:

  • 多数据库的复制需求 = 每个库一对「发布 + 订阅」,不存在「实例级发布」;
  • FOR ALL TABLES 的「全部」也只是当前库的全部表;
  • 备份与迁移时,pg_dump 按库导出发布定义,要逐库处理;
  • 多库同步拓扑中,worker、逻辑槽、walsender 连接都按库数线性增长,容量预算要做乘法。
text
同一实例上两个库各自要被订阅时的资源放大(每库独立一份):
    app 库:  1 个订阅 → 1 个逻辑槽 + 1 条 walsender + 1 个 apply worker
    bi  库:  1 个订阅 → 1 个逻辑槽 + 1 条 walsender + 1 个 apply worker
    —— 发布端参数按 2 份预留:max_replication_slots、max_wal_senders
    —— 订阅端参数按 2 份预留:max_logical_replication_workers

初始同步的临时槽与并发限制 ​

初始数据同步除了「慢」,还有一笔资源账:

  • 每个进行中的 table sync worker 都要在发布端创建一个临时逻辑槽,同步高峰期发布端 max_replication_slots 的占用是「订阅常驻槽 + 同步中的表数」,余量不足时表会排队等待;
  • 并行度由订阅端 max_sync_workers_per_subscription(默认 2)控制,想加速全量拷贝要调订阅端配置,而不是发布端;
  • 拷贝阶段对订阅端的 IO 压力与普通 COPY 相当,而 apply worker 同时在追增量,订阅端容量按两者叠加预估;
  • 同步完成后临时槽自动清理;若 REFRESH 移除了同步中的表而遗留槽清理失败,按订阅页的善后流程处理。

事务顺序与初始快照一致性 ​

两点官方语义需要精确理解:

  • 单个订阅内,事务按发布端的提交顺序应用,事务级一致性有保证(前提是订阅端没有本地写入搅局);
  • 但初始同步没有全局一致快照:table sync worker 按表逐个拷贝,各表快照时点不同。订阅端「全部表就绪」的那一刻,它并不是发布端某一瞬间的完整镜像,表与表之间存在短暂的不一致窗口。

规避方案: 对「多表关联一致性」敏感的场景,初始化安排在停写窗口内;或者接受增量阶段的短暂偏差、以追平后的稳态为准;绝不要在初始同步未完成时就对订阅端数据做跨表对账。

无内置冲突解决,双向复制需自制 ​

PostgreSQL 不提供任何内置的冲突解决机制:insert_exists 停下来等人,update_missing 静默跳过,谁赢谁输全靠人工或外部逻辑。同样,双向复制没有开箱即用的方案——两个节点互为发布/订阅时,同主键的并发修改没有任何仲裁。

规避方案:

  • 架构上坚持「单写原则」:每张表在一个时刻只有一个可写节点,其余下游只读;
  • 确需双向链路:订阅 origin = none 过滤上游来源防回环,配合 track_commit_timestamp 做 update_origin_differs / delete_origin_differs 检测,但仲裁逻辑要自己写(触发器、应用层、或借助第三方多主方案);
  • 把「冲突计数非 0」当作最高级别告警,而不是日常噪音。

双向复制的最小防回环骨架(A、B 两库互为发布与订阅,均 PG15+):

sql
-- 节点 A:订阅来自 B 的变更,只要「B 本地产生」的变更(origin = none 滤掉转发链路)
CREATE SUBSCRIPTION sub_a_from_b
    CONNECTION 'host=b-host dbname=app application_name=sub_a_from_b'
    PUBLICATION pub_b
    WITH (origin = none, copy_data = false);

-- 节点 B:对称地订阅来自 A 的变更
CREATE SUBSCRIPTION sub_b_from_a
    CONNECTION 'host=a-host dbname=app application_name=sub_b_from_a'
    PUBLICATION pub_a
    WITH (origin = none, copy_data = false);

origin = none 只解决「回环」:发布端本地写入的变更解码时无 origin,能正常通过;经复制链路转发来的变更带着来源 origin,会被滤掉。它不解决仲裁——同一行在两端同时修改,两个方向都会各自应用,update_origin_differs / delete_origin_differs(需订阅端开启 track_commit_timestamp)只留下证据,不会替你裁决。另外初始拷贝不受 origin 过滤约束,双向建链的初始化要放在停写窗口里规划,避免同一批数据来回搬运。

two_phase 的限制 ​

two_phase = true(PG15+)让 PREPARE TRANSACTION 以两阶段形式复制,但它自带一套约束:

  • 初始同步完成后才真正生效:pg_subscription.subtwophasestate 先经历 pending(p)再进入 enabled(e),设计上不能指望建订阅的瞬间就有两阶段能力;
  • two_phase 与 failover 只能在订阅禁用时修改;REFRESH PUBLICATION、SET (two_phase = false) 不能在事务块内执行;
  • 开启状态下做刷新必须 copy_data = false;
  • 从 true 改回 false 时,若订阅端残留逻辑复制 worker PREPARE 的事务(GID 形如 pg_gid_<订阅oid>_<远端xid>)会报错,需先在发布端或订阅端 resolve;
  • ALTER SUBSCRIPTION ... SKIP 对已被 PREPARE 的两阶段事务无效。
sql
-- 排查订阅端残留的两阶段事务(GID 形如 pg_gid_<订阅oid>_<远端xid>)
SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
WHERE gid LIKE 'pg_gid_%';

规避方案: 只在确实使用 PREPARE TRANSACTION 的业务上开启;开启前确认初始同步策略(建议 copy_data = true 走完 pending 阶段);变更 two_phase 的流程写成 runbook(禁用 → 改参数 → 启用)。

权限与安全(run_as_owner) ​

订阅端权限模型的默认形态是安全的:apply worker 会话级以订阅 owner 运行,但对每张表执行 DML 时切换到表 owner,权限各归其位。代价是订阅 owner 必须能 SET ROLE 到所有目标表的 owner。

run_as_owner = true(PG16+)取消了角色切换,全部以订阅 owner 执行。官方安全文档明确指出风险:拥有目标表的角色可以借此以订阅 owner 的权限执行任意代码(例如给表挂一个触发器)。因此除非库内用户安全无关紧要,否则保持默认 false。

规避方案: 保持 run_as_owner = false;订阅 owner 与表 owner 的 SET ROLE 链路用角色层级固化;发布端连接角色权限最小化(官方提醒:发布端权限只在建连时检查一次,之后不复查)。

实战示例 ​

示例一:上线前体检脚本 ​

把下面三段跑成定时任务或 CI 步骤,覆盖本页前六条限制的检测:

sql
-- 体检一:高危表清单(发布中、无主键、复制标识为默认)
SELECT pt.pubname, pt.schemaname, pt.tablename,
       CASE c.relreplident
            WHEN 'd' THEN 'DEFAULT(无主键=无标识)'
            WHEN 'n' THEN 'NOTHING'
            WHEN 'f' THEN 'FULL'
            WHEN 'i' THEN 'USING INDEX'
       END AS replica_identity
FROM pg_publication_tables pt
JOIN pg_class c ON c.relname = pt.tablename
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
WHERE NOT EXISTS (SELECT 1 FROM pg_index i
                  WHERE i.indrelid = c.oid AND i.indisprimary)
  AND c.relreplident IN ('d', 'n');
text
   pubname    | schemaname | tablename |   replica_identity
--------------+------------+-----------+-----------------------
 pub_orders_dw| public     | no_pk     | DEFAULT(无主键=无标识)
(1 row)
sql
-- 体检二:大对象与 lo 类型列
SELECT count(*) AS large_objects FROM pg_largeobject_metadata;

SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE udt_name IN ('lo', 'oid')
  AND table_schema NOT IN ('pg_catalog', 'information_schema');
sql
-- 体检三:订阅健康(错误计数非 0 即有问题)
SELECT subname, apply_error_count, sync_error_count
FROM pg_stat_subscription_stats
WHERE apply_error_count > 0 OR sync_error_count > 0;

示例二:切换前的序列同步 ​

sql
-- 发布端(停写后)执行,生成全部序列的 setval 语句
SELECT format('SELECT setval(%L, %s);',
              schemaname || '.' || sequencename,
              COALESCE(last_value, start_value)) AS sync_stmt
FROM pg_sequences
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
  AND last_value IS NOT NULL;
text
                 sync_stmt
---------------------------------------------
 SELECT setval(public.orders_order_id_seq, 10086);
 SELECT setval(public.items_item_id_seq, 50233);
(2 rows)
bash
# 把输出灌到订阅端执行
psql -h sub-host -p 5433 -U postgres -d app -f sequence_sync.sql

setval 之后再加安全余量

为防在生成与执行之间有漏网写入,可在 setval 的值上再加大一个余量(例如 +1000),或在切换前再次校验两端 max(id)。

示例三:结构漂移的定位与修复 ​

订阅端日志持续报错:

text
ERROR:  replication target table "public.orders" is missing replicated column: "discount_code"

处理流程:

bash
# 1. 两端做 schema diff 定位差异
diff -u <(pg_dump -h sub-host -p 5433 -U postgres -d app --schema-only --no-owner --no-privileges) \
        <(pg_dump -h pub-host -p 5432 -U postgres -d app --schema-only --no-owner --no-privileges)
sql
-- 2. 订阅端补齐加法式变更
ALTER TABLE public.orders ADD COLUMN discount_code text;
sql
-- 3. 观察 apply 恢复推进(滞后 LSN 开始缩小即为恢复)
SELECT subname, received_lsn, latest_end_lsn FROM pg_stat_subscription
WHERE worker_type = 'apply';

示例四:加列变更的三段式演练 ​

需求:orders 表新增 discount_code text 列。按「订阅端加法 → 发布端变更 → diff 收尾」执行:

sql
-- 第一段:订阅端先加列(加法式变更,现有数据流不受影响)
ALTER TABLE public.orders ADD COLUMN discount_code text;
sql
-- 第二段:发布端变更(此后到达订阅端的行已携带新列)
ALTER TABLE public.orders ADD COLUMN discount_code text NOT NULL DEFAULT '';
sql
-- 第三段:验证 apply 恢复并追平
SELECT subname, received_lsn, latest_end_lsn FROM pg_stat_subscription
WHERE worker_type = 'apply';

两个细节:第一段订阅端加列不写 NOT NULL,避免与第二段的默认值策略不一致;第三段除看 LSN 外,抽查一行新变更核对新列值。若顺序颠倒(发布端先加 NOT NULL 且无默认值的列),订阅端在补列之前会持续报错——这正是「加法先行」要消灭的窗口。

注意事项 ​

「不复制」不等于「不用管」。 DDL、序列、大对象这三类内容的维护责任完整地落到了运维流程上:没有变更流程的团队不适合使用逻辑复制做长期链路,只适合做一次性迁移。

update_missing / delete_missing 是静默的。 它们不停复制、只计数,最容易造成「两端悄悄不一致」。把 pg_stat_subscription_stats 的冲突计数纳入告警,非 0 必查。

订阅端可写是双刃剑。 本地写入会让冲突概率陡增,而逻辑复制没有仲裁。让订阅表的写入权只属于 apply worker,是成本最低的稳定剂。

版本升级方向是单向的。 规划升级路径时永远让新版本做订阅端;反向链路(新发布 → 旧订阅)不在官方支持范围内。

two_phase 状态机要整段理解。 pending → enabled 的过渡、禁用态才能改、SKIP 失效——这三条不记住,两阶段复制一定会在最不合适的时刻给你一个意外。

RLS 与权限不足会伪装成「复制坏了」。 它们报错的位置在订阅端 apply,与网络、槽无关。排查 apply 报错时把「订阅 owner 对目标表的权限」和「RLS 策略」放进前三个检查项。

「多库同步」的成本是线性叠加的。 每个库一对发布/订阅、各占 apply worker 与逻辑槽,初始同步的临时槽也按库各自创建。容量评估按库数做乘法,别按「一个订阅」做加法。

临时槽也是槽。 初始同步高峰期发布端的槽占用会瞬时超过「订阅数」,max_replication_slots 余量按「常驻槽 + 峰值同步表数」规划,否则同步会静默排队。

SELECT 权限是初始同步的隐形依赖。 除了 REPLICATION 属性与 pg_hba.conf,复制连接角色还必须能读取发布中的表(表 owner 或被授予 SELECT),初始同步按表逐一校验——「连接成功了但每张表同步都报权限错」的案例,九成是漏了这一层授权。

最佳实践 ​

  1. 把本页总览表变成团队的上线检查单:每一次新建订阅,逐行确认「这条限制对我的链路是否成立、规避方案是否落地」。
  2. 无主键表治理常态化:体检 SQL(无主键 + relreplident = 'd'/'n')放进 CI,新增一张高危表就挡一次发布。
  3. DDL 走统一变更平台:约定「订阅端先做加法、发布端随后变更、窗口后 diff 验证」的三段式顺序,禁止绕过平台手工改结构。
  4. 序列同步脚本进切换手册:与「停写、追平、校验」同属切换序列,执行后抽查两三个核心序列的 last_value。
  5. 存量库先做大对象体检再上逻辑复制:pg_largeobject_metadata 非零或存在 lo/oid 列的库,先改造再复制。
  6. 冲突预案演练化:在测试环境制造一次 insert_exists,完整走一遍「看日志 → 修数重放 → 必要时 SKIP → 验证恢复」,把 finish LSN、原点名、命令写成 runbook。
  7. 订阅端开启 track_commit_timestamp:冲突日志会带上来源与提交时间戳,仲裁「保本地还是保远端」时这就是证据。
  8. 订阅一律 disable_on_error = true:熔断 + 告警比无限重试可控;恢复前先看 pg_stat_subscription_stats 的计数变化。
  9. 监控把 confl_* 与 apply_error_count 当核心指标:它们从 0 变非 0,意味着两端数据已经开始分叉,要按数据事故处理。
  10. 双向/多主需求先做架构否决:能用单写 + 分发的就不要上双写;确需双写,接受「仲裁逻辑自研」的成本并预留一致对账工具。
  11. 升级链路固定「旧发布 → 新订阅」:跨版本迁移、大版本升级统一用逻辑复制,且把订阅端版本管理进升级计划。
  12. 保持 run_as_owner = false 默认值:订阅 owner 的 SET ROLE 链路用角色层级表达,不要为了省一步授权打开安全缺口。

下一节

继续学习:逻辑复制与流复制对比

参考链接 ​