逻辑复制的原理是把主库的WAL解码成逻辑变更,通过发布端和订阅端之间的复制槽推送到目标库。订阅端apply worker会按照事务顺序重放INSERT、UPDATE、DELETE。如果目标表上已经存在一个主键相同的行,INSERT就会撞上唯一约束;如果UPDATE要修改的行已经被订阅端本地删除,apply worker会因为找不到行而报错;DELETE同理。这三类冲突本质上都是主库与订阅端的数据视图出现了分叉。

最容易出现冲突的场景是业务做了双活写入,或者灾备切换后原主库还残留应用连接,又或者订阅端承担了一部分本地写入任务。此时如果不加干预,apply worker通常会按默认行为反复重试,复制槽会越积越多,延迟逐渐拉大。需要注意的是,PostgreSQL标准逻辑复制没有内置订阅端优先或发布端优先的开关,必须借助触发器、规则和复制标识来组合实现。
一、冲突从哪里来
要理解优先订阅端方案,先得明确逻辑复制的冲突类型。第一种是INSERT主键冲突:主库插入了一行,订阅端已经存在同主键的行,apply worker在订阅端执行INSERT时会被主键或唯一索引拦下。第二种是UPDATE行缺失:主库更新了一行,但订阅端这行已经被本地删除,apply worker找不到目标行,导致更新失败。第三种是DELETE行缺失:主库删除了一行,但订阅端这行同样已经不存在,删除操作无事可做但可能触发错误。
这三种冲突的共同点是,主库认为应该发生的数据变更,在订阅端当前状态下无法正确应用。如果订阅端的数据是可信的、需要被保留的,那么优先订阅端的策略就是让这些冲突操作被跳过,而不是用主库数据强制覆盖订阅端。这个策略可以在订阅端通过行级触发器来实现,并且只针对逻辑复制会话生效。
二、优先订阅端的基本思路
要实现订阅端优先,核心原则是:当复制重放的操作与订阅端本地数据发生冲突时,选择跳过主库传来的这条变更,而不是用主库数据覆盖订阅端。对于INSERT主键冲突,跳过插入可以保留订阅端已有的行;对于UPDATE行缺失或已有行,选择不用主库版本覆盖;对于DELETE行缺失或已有行,选择保留订阅端行。
判断一个SQL操作是否来自逻辑复制,可以通过会话变量session_replication_role。逻辑复制apply进程连接订阅端时,该变量会被设置为replica,而普通业务连接默认是origin或local。因此我们可以创建BEFORE触发器,仅在session_replication_role = 'replica'时执行优先订阅端逻辑,本地业务写入不受影响。这样既能保护订阅端本地数据,又不会干扰正常的业务SQL。
三、触发器实现与代码示例
下面给出一个针对订单表orders的触发器函数。这个函数会拦截逻辑复制的INSERT、UPDATE和DELETE操作:如果复制INSERT发现主键已经存在,直接返回NULL跳过;如果复制UPDATE或DELETE到达,也直接返回NULL,表示订阅端保留当前数据。为了不误伤本地业务操作,函数先检查session_replication_role,只有复制会话才进入跳过逻辑。
CREATE OR REPLACE FUNCTION prefer_subscriber_data()
RETURNS trigger AS $$
BEGIN
IF session_replication_role = 'replica' THEN
IF TG_OP = 'INSERT' THEN
IF EXISTS (SELECT 1 FROM orders WHERE id = NEW.id) THEN
RETURN NULL;
END IF;
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
RETURN NULL;
ELSIF TG_OP = 'DELETE' THEN
RETURN NULL;
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
接着在orders表上创建BEFORE触发器,事件范围覆盖INSERT、UPDATE和DELETE。BEFORE触发器的好处是它在约束检查之前执行,因此对于即将违反主键或唯一约束的INSERT,只要触发器返回NULL,就不会继续执行后续约束检查,冲突自然消失。这比让apply worker报错后人工处理要平滑得多。
CREATE TRIGGER trg_prefer_subscriber BEFORE INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION prefer_subscriber_data();
这种方案有一个明显的优点:它不改变发布端,也不调整复制槽,只在订阅端建立一套针对复制会话的过滤规则。但代价也很直接:主库的更新和删除会被静默丢弃,订阅端与主库的数据会长期不一致。如果后续需要重新对齐,必须通过全量重做或手工比对来修复。
四、订阅参数与复制标识的配合
只靠触发器还不够,还要保证逻辑复制能把足够的旧行信息从主库传过来。对于UPDATE和DELETE,如果发布端表没有设置合适的REPLICA IDENTITY,订阅端可能拿不到旧主键值,导致定位目标行失败。建议在发布端对参与冲突解决的表执行:
ALTER TABLE orders REPLICA IDENTITY FULL;
REPLICA IDENTITY FULL会让UPDATE和DELETE的WAL中携带整行旧值,订阅端应用时更容易判断行是否存在。它的代价是发布端WAL量会增加,尤其是宽表或大字段较多的表。如果表上有主键或唯一索引,也可以使用REPLICA IDENTITY USING INDEX index_name,在保证可定位行的同时减少日志量。
创建订阅时,建议显式设置disable_on_error = false,这样在触发器未能拦截的意外冲突出现时,apply worker会停止并留下现场,而不是禁用订阅。配合监控pg_stat_subscription_stats视图的apply_error_count字段,可以第一时间发现复制链路问题。如果已经发生冲突导致复制卡住,需要先查看pg_replication_origin_status找到对应复制槽,然后根据事务号跳过或手动修复。
五、验证与风险控制
搭建测试环境后,可以先在订阅端本地插入一条主键为1的订单,再在主库同样插入主键为1的订单。如果没有触发器,apply worker会报主键冲突并停止;加上trg_prefer_subscriber后,可以看到主库这条INSERT被跳过,订阅端仍保留本地数据。再测试主库更新订阅端已有的行,会发现订阅端行内容没有变化;主库删除订阅端行,订阅端行仍然存在。
这种优先订阅端策略适合短期的双活冲突窗口、迁移回退期间或订阅端有本地修正数据的场景。但长期运行会让主库和订阅端出现无法自愈的数据分叉,所以不建议作为永久架构。更稳妥的做法是配合业务层生成全局唯一主键、使用分区表隔离写入来源,或者在冲突率达到阈值后停止订阅端本地写入。对于需要自动合并冲突的系统,可以考虑使用支持冲突解决器的第三方逻辑复制工具,例如pglogical,它提供了更细粒度的冲突策略。
最后还要关注长事务和大事务的影响。触发器在每个行事件上执行,如果复制高峰期每秒数万行,EXISTS子查询会带来额外开销。可以通过在触发器中使用IF found THEN或调整索引来降低代价,也可以缩小触发器只作用于冲突风险高的表。总之,优先订阅端是一个工程上可落地的折中方案,理解它的跳过语义和一致性代价,比直接套用触发器更重要。
PostgreSQL逻辑复制冲突解决订阅端优先修改时间:2026-09-24 09:40:21