逻辑复制限制
逻辑复制的能力清单很诱人:表级粒度、跨大版本、双向可能、订阅端可写。但它的限制清单同样必须刻在脑子里——这些限制大多源于「逻辑复制 = 解码 + 重新执行」这个本质:WAL 里只有物理块变更,DDL、序列推进、大对象句柄这些根本不在解码结果里,自然也就到不了订阅端。与流复制「备库是主库的位级镜像」不同,逻辑复制的订阅端是一个需要人为维持一致性的独立实例,本页列出的每一条限制,都是一致性清单上的一项。
官方文档把这些内容归在「Restrictions」与「Conflicts」两节,本页在其基础上补充了每一条的规避方案与检测方法。学习建议是分两遍读:第一遍记住「什么不复制」,用于架构选型时排除法;第二遍记住「出错了是什么形态」,用于生产排障——逻辑复制的大多数故障(apply 停滞、结构漂移、序列越界、WAL 堆积)都能在本页找到根因。
先给结论性的定位:逻辑复制是优秀的数据分发与迁移工具,但不是开箱即用的双向同步或高可用方案。把它的边界当作设计约束而不是缺陷去接受,方案才稳。
前置知识
建议先阅读:订阅(CREATE SUBSCRIPTION)
基础概念
限制分两类:能力边界(机制上做不到,如 DDL、序列、大对象)与运维约束(能做但要自己负责,如 schema 同步、冲突处理、版本匹配)。先看总览表,每一行在「详细说明」里都有对应的展开与规避方案:
| # | 限制 | 类型 | 规避方向 |
|---|---|---|---|
| 1 | DDL 不复制 | 能力边界 | 两端同步执行 DDL;订阅端先做加法式变更 |
| 2 | 序列状态不复制 | 能力边界 | 切换前 setval() 同步;订阅端只读则无影响 |
| 3 | 大对象(Large Object)不复制 | 能力边界 | 改用 bytea 存进普通表,或另行同步 |
| 4 | 视图 / 物化视图 / 外部表无法发布 | 能力边界 | 发布基表,订阅端自建视图 |
| 5 | 角色、表空间、权限等数据库级对象不复制 | 能力边界 | pg_dumpall --roles-only / --tablespaces-only |
| 6 | 无复制标识的表无法复制 UPDATE/DELETE | 能力边界 | 主键 / USING INDEX / REPLICA IDENTITY FULL |
| 7 | schema 必须手工保持同步 | 运维约束 | 迁移流程 + 结构 diff 校验 |
| 8 | 复制冲突会停住 apply | 运维约束 | 冲突预案:修数重放或 SKIP |
| 9 | 分区表对订阅端结构有要求 | 运维约束 | publish_via_partition_root = true 或镜像分区 |
| 10 | 订阅端大版本必须 ≥ 发布端 | 能力边界 | 升级方向固定:旧发布 → 新订阅 |
| 11 | 初始同步无全局一致快照 | 能力边界 | 停写窗口初始化,或接受短暂偏差 |
| 12 | 无内置冲突解决,双向复制无仲裁 | 能力边界 | 单写原则;origin 过滤 + 外部工具 |
| 13 | two_phase 有状态机限制 | 运维约束 | 初始同步完成后才生效;禁用态才能改 |
| 14 | run_as_owner = true 有安全隐患 | 运维约束 | 保持默认 false |
这些限制从哪里来
把同一条语句放进两种复制的传输管道,差异一目了然:
UPDATE public.orders SET status = 'paid' WHERE order_id = 1001;【流复制视角】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 跑一遍,能提前消掉本页一半的坑。
-- ① 找出发布中「无主键且复制标识为默认」的表(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:无主键时等于没有可用复制标识-- ② 检查各表的复制标识分布(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;-- ③ 检查库中是否使用了大对象
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');-- ⑤ 列出需要切换前手工同步的序列
SELECT schemaname, sequencename, last_value
FROM pg_sequences
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2;# ⑥ 结构 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-- ⑦ 全局对象与扩展核对:订阅端初始化前必须备齐(两端各跑一遍并对比)
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):必须在切换前同步序列,否则第一批新插入就会主键冲突。
规避方案:
-- 切换窗口、停止写入后,在发布端生成、在订阅端执行
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 与外部表定义,数据不经过复制链路。
-- 发布端:只发布基表(视图本身不能进发布)
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 悬空,缺扩展会导致带扩展类型的表无法建出来。
规避方案:
# 集簇级对象用 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 类授权建议用角色统一管理(订阅端授予角色而非逐用户授权),减少两端对账成本。
初始化顺序展开成检查清单(顺序颠倒会在中途报错):
① 角色 / 表空间 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 停在原地反复重试,日志形如:
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规避方案(处理顺序):
- 优先修数重放:按日志 DETAIL 判断保留哪边,把订阅端数据改成可容纳远端事务的形态,apply 自动恢复;
- 确认丢弃则用
ALTER SUBSCRIPTION ... SKIP (lsn = '0/14C0378')跳过整个远端事务(finish LSN 取自日志),注意SKIP会丢掉事务内所有变更; - 也可用
pg_replication_origin_advance()推进原点(需先禁用订阅或已设disable_on_error),当前进度见pg_replication_origin_status; - 长期防呆:订阅端开启
track_commit_timestamp(冲突日志会带上来源与提交时间戳)、订阅设disable_on_error = true、监控pg_stat_subscription_stats的apply_error_count与confl_*计数器; - 另外两类非典型「冲突」:订阅端权限不足、目标表启用了对订阅 owner 生效的行级安全(RLS),同样会让应用失败——表结构与权限也要纳入同步清单。
WAL 滞留与磁盘风险
严格说这不是「复制能力」的限制,而是逻辑复制链路最著名的副作用:复制槽会阻止发布端回收 WAL。订阅离线、apply 停滞、发布端长事务未提交,都会让 pg_wal 持续增长,最终吃满磁盘——物理槽同样有此风险,但逻辑链路更容易「悄悄停住」:apply 冲突停摆时连接还在、进程还在,不像断线那样显眼。
观察与设防:
-- 滞留量 = 当前 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';# 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 协议版本协商机制决定了特性是「向下不兼容」的。
版本组合速查:
| 发布端 | 订阅端 | 是否支持 | 说明 |
|---|---|---|---|
| 16 | 18 | ✅ | 官方升级方向:近零停机大版本迁移的标准做法 |
| 17 | 17 | ✅ | 同版本常规链路 |
| 15 | 16 | ✅ | 订阅端略新,兼容 |
| 18 | 16 | ❌ | 不支持;旧订阅端无法理解新版协议能力 |
规避方案: 升级拓扑永远把新版本放在订阅端;发布端与订阅端的小版本都保持跟进最新补丁版本。升级前先在测试环境用真实版本组合演练一轮,streaming、two_phase 等能力按两端较低版本生效。
订阅是一库的契约
发布与订阅都是数据库级对象:发布定义在发布端的某一个库里,订阅的 CONNECTION 只指向一个数据库。这带来几条工程上的推论:
- 多数据库的复制需求 = 每个库一对「发布 + 订阅」,不存在「实例级发布」;
FOR ALL TABLES的「全部」也只是当前库的全部表;- 备份与迁移时,
pg_dump按库导出发布定义,要逐库处理; - 多库同步拓扑中,worker、逻辑槽、walsender 连接都按库数线性增长,容量预算要做乘法。
同一实例上两个库各自要被订阅时的资源放大(每库独立一份):
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+):
-- 节点 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 的两阶段事务无效。
-- 排查订阅端残留的两阶段事务(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 步骤,覆盖本页前六条限制的检测:
-- 体检一:高危表清单(发布中、无主键、复制标识为默认)
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'); pubname | schemaname | tablename | replica_identity
--------------+------------+-----------+-----------------------
pub_orders_dw| public | no_pk | DEFAULT(无主键=无标识)
(1 row)-- 体检二:大对象与 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');-- 体检三:订阅健康(错误计数非 0 即有问题)
SELECT subname, apply_error_count, sync_error_count
FROM pg_stat_subscription_stats
WHERE apply_error_count > 0 OR sync_error_count > 0;示例二:切换前的序列同步
-- 发布端(停写后)执行,生成全部序列的 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; sync_stmt
---------------------------------------------
SELECT setval(public.orders_order_id_seq, 10086);
SELECT setval(public.items_item_id_seq, 50233);
(2 rows)# 把输出灌到订阅端执行
psql -h sub-host -p 5433 -U postgres -d app -f sequence_sync.sqlsetval 之后再加安全余量
为防在生成与执行之间有漏网写入,可在 setval 的值上再加大一个余量(例如 +1000),或在切换前再次校验两端 max(id)。
示例三:结构漂移的定位与修复
订阅端日志持续报错:
ERROR: replication target table "public.orders" is missing replicated column: "discount_code"处理流程:
# 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)-- 2. 订阅端补齐加法式变更
ALTER TABLE public.orders ADD COLUMN discount_code text;-- 3. 观察 apply 恢复推进(滞后 LSN 开始缩小即为恢复)
SELECT subname, received_lsn, latest_end_lsn FROM pg_stat_subscription
WHERE worker_type = 'apply';示例四:加列变更的三段式演练
需求:orders 表新增 discount_code text 列。按「订阅端加法 → 发布端变更 → diff 收尾」执行:
-- 第一段:订阅端先加列(加法式变更,现有数据流不受影响)
ALTER TABLE public.orders ADD COLUMN discount_code text;-- 第二段:发布端变更(此后到达订阅端的行已携带新列)
ALTER TABLE public.orders ADD COLUMN discount_code text NOT NULL DEFAULT '';-- 第三段:验证 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),初始同步按表逐一校验——「连接成功了但每张表同步都报权限错」的案例,九成是漏了这一层授权。
最佳实践
- 把本页总览表变成团队的上线检查单:每一次新建订阅,逐行确认「这条限制对我的链路是否成立、规避方案是否落地」。
- 无主键表治理常态化:体检 SQL(无主键 +
relreplident = 'd'/'n')放进 CI,新增一张高危表就挡一次发布。 - DDL 走统一变更平台:约定「订阅端先做加法、发布端随后变更、窗口后 diff 验证」的三段式顺序,禁止绕过平台手工改结构。
- 序列同步脚本进切换手册:与「停写、追平、校验」同属切换序列,执行后抽查两三个核心序列的
last_value。 - 存量库先做大对象体检再上逻辑复制:
pg_largeobject_metadata非零或存在lo/oid列的库,先改造再复制。 - 冲突预案演练化:在测试环境制造一次
insert_exists,完整走一遍「看日志 → 修数重放 → 必要时 SKIP → 验证恢复」,把 finish LSN、原点名、命令写成 runbook。 - 订阅端开启
track_commit_timestamp:冲突日志会带上来源与提交时间戳,仲裁「保本地还是保远端」时这就是证据。 - 订阅一律
disable_on_error = true:熔断 + 告警比无限重试可控;恢复前先看pg_stat_subscription_stats的计数变化。 - 监控把
confl_*与apply_error_count当核心指标:它们从 0 变非 0,意味着两端数据已经开始分叉,要按数据事故处理。 - 双向/多主需求先做架构否决:能用单写 + 分发的就不要上双写;确需双写,接受「仲裁逻辑自研」的成本并预留一致对账工具。
- 升级链路固定「旧发布 → 新订阅」:跨版本迁移、大版本升级统一用逻辑复制,且把订阅端版本管理进升级计划。
- 保持
run_as_owner = false默认值:订阅 owner 的SET ROLE链路用角色层级表达,不要为了省一步授权打开安全缺口。
下一节
继续学习:逻辑复制与流复制对比
参考链接
- PostgreSQL 官方文档:Restrictions
- PostgreSQL 官方文档:Conflicts
- PostgreSQL 官方文档:Logical Replication Subscription
- PostgreSQL 官方文档:Logical Replication Security
- PostgreSQL 官方文档:Logical Replication Upgrade
- PostgreSQL 官方文档:Large Objects
- PostgreSQL 官方文档:ALTER TABLE(REPLICA IDENTITY)
- PostgreSQL 官方文档:pg_dump
- PostgreSQL 官方文档:pg_dumpall
- PostgreSQL 官方文档:pg_stat_subscription_stats