逻辑复制(Logical Replication)是PostgreSQL提供的高可用与数据同步方案。相比物理复制,逻辑复制能够按表或按数据库独立传输变更,但其订阅端在应用事务时,如果数据与已有数据产生冲突,复制进程会立即停止并抛出错误,等待人工处理。这种冲突如果不及时解决,会导致复制延迟持续增长,甚至造成主备数据失配。针对这一痛点,我们可以通过自定义逻辑来干预apply过程,将冲突行为转化为可预期的业务操作。

冲突的主要类型与产生原因
逻辑复制冲突几乎都发生在订阅端执行DML语句的过程中。最常见的冲突是主键重复。例如发布端表user_info中新插入一条id=1001的记录,而订阅端表user_info中已经存在同id的记录,这可能是由于应用直接在订阅端写入了相同主键的数据,也可能是因为双向同步场景中两端都对同一记录做了修改。当apply worker尝试在订阅端执行INSERT时,就触发唯一约束错误。类似地,UPDATE和DELETE操作也可能遇到目标行不存在的情况,比如发布端刚删掉一行,订阅端却因为先前跳过事务或手工清理已丢失该行。
除了主键和行存在性冲突,唯一约束、外键约束、CHECK约束同样会造成复制中断。在逻辑复制设计中,这些冲突尤其容易出现在订阅端存在额外数据修改的混合架构中。默认情况下,apply worker使用数据库的replica角色执行SQL,触发器和规则都不会自动触发,而且遇到错误后会直接停止整个订阅,后续所有变更积压在发布端的WAL中。因此,必须设计一套自定义处理机制来主动规避或修复这些冲突。
使用订阅端触发器实现自定义冲突解决
一种直接有效的自定义处理方式是在订阅端表上创建触发器,并在触发器中编写业务规则来化解冲突。触发器默认在replica角色下不会执行,但可以通过ALTER TABLE ... ENABLE REPLICA TRIGGER显式启用。这样,apply worker执行INSERT时,BEFORE INSERT触发器会先行介入,我们便可以在其中检测是否存在相同主键,如果存在就把INSERT转换为UPDATE,然后返回NULL让原插入被忽略。
下面的示例演示了如何处理插入冲突。假设订阅端表结构为user_info(id INTEGER PRIMARY KEY, name TEXT, email TEXT, updated_at TIMESTAMP),我们创建一个处理函数和触发器。
-- 创建处理插入冲突的函数
CREATE OR REPLACE FUNCTION handle_user_info_insert_conflict()
RETURNS TRIGGER AS $$
BEGIN
-- 如果目标表中已存在相同id,则执行更新并跳过本次插入
IF EXISTS (SELECT 1 FROM user_info WHERE id = NEW.id) THEN
UPDATE user_info
SET name = NEW.name,
email = NEW.email,
updated_at = NOW()
WHERE id = NEW.id;
RETURN NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 创建BEFORE INSERT触发器
CREATE TRIGGER trg_user_info_insert_conflict
BEFORE INSERT ON user_info
FOR EACH ROW
EXECUTE FUNCTION handle_user_info_insert_conflict();
-- 启用为REPLICA触发器,使apply worker能触发它
ALTER TABLE user_info ENABLE REPLICA TRIGGER trg_user_info_insert_conflict;
启用后,当复制流中携带id=1001的INSERT到达订阅端时,触发器先检查到已有id=1001的记录,于是执行UPDATE,让这条记录的name和email更新为发布端的最新值,最后返回NULL跳过插入,复制进程不受影响。同理,你也可以为UPDATE冲突编写处理程序,比如在目标行不存在时转为INSERT,或者为DELETE冲突编写忽略或记录日志的触发器。
需要注意的是,触发器方案只能解决同一条记录的确定性冲突,而且要求你清楚表上的所有业务规则。如果发布端删除了一条记录,而订阅端刚刚更新了它,此时UPDATE触发器可能将删除操作改成将行更新为删除标记,这需要结合业务场景仔细设计。此外,触发器的执行会影响apply worker的性能,当复制流量很大时,应当对触发器函数进行优化,避免在函数中执行过于复杂的SQL或调用外部服务。
手动跳过与数据修复的应急策略
当冲突已经发生,且没有合适的触发器兜底时,复制进程会进入停止状态。此时可以通过查看订阅端日志,找到冲突事务的LSN或事务ID,然后使用ALTER SUBSCRIPTION ... SKIP跳过该事务,让复制继续处理后续变更。这个命令的好处是能迅速恢复复制通道,但代价是会丢失该冲突事务中涉及的所有变更,可能导致两端数据不一致。
-- 查看订阅状态 SELECT subname, subenabled FROM pg_subscription; -- 查看冲突事务(伪代码,实际需要从日志中获取事务ID) -- ALTER SUBSCRIPTION my_sub SKIP (txid = 12345); -- 跳过冲突事务后,重新启用订阅 ALTER SUBSCRIPTION my_sub ENABLE;
在跳过事务之前,需要评估该事务的影响范围。如果只是单条记录更新,可以用SQL在订阅端手工补做;如果涉及大批量数据,建议先对订阅端做一致性校验,再决定是否需要重新初始化订阅。另一种做法是先修复订阅端冲突数据,再强制恢复。例如对于主键冲突,可以在订阅端删除或修改那条“多余”的记录,然后执行ALTER SUBSCRIPTION my_sub ENABLE,apply worker会重新尝试应用事务。
对于长期运行的复制系统,仅靠人工处理显然不可持续。你可以在应用层面建立一个冲突检测任务,周期性查询订阅端的pg_stat_subscription视图,当apply_error字段非空时,自动提取冲突信息并执行相应的修复SQL。这个任务可以用cron调度,也可以集成到运维平台的告警系统中,形成一个半自动化的冲突处理闭环。
将冲突处理融入应用设计
最完善的冲突处理不是等冲突发生后再去补救,而是在应用设计阶段就考虑如何避免冲突。一个常用的思路是采用单向同步与幂等写入模式。在订阅端,所有写入必须通过专用的应用层入口,避免出现与复制流并发操作同一主键的情况。如果你的业务确实需要多端写入,那么可以为每端分配一个唯一的主键前缀或ID区间,从源头消除主键重复的可能。
对于不可消除的业务冲突,比如不同节点同时更新同一字段,可以在发布端使用字段级版本号或更新时间戳,合并逻辑放到订阅端触发器中进行。例如在触发器函数中,利用OLD.updated_at和NEW.updated_at的对比,决定是否采用最新值更新。这种基于业务规则的“最后一次写入获胜”策略,可以很好地替代简单的主键覆盖。
另一个值得尝试的方向是使用支持冲突解决机制的逻辑复制扩展,例如pglogical。它提供了conflict_resolution参数,可以指定当主键冲突时是保留本地记录还是覆盖为远程记录,甚至可以在冲突发生时调用自定义函数。相比自建触发器,这些扩展往往经过大量生产环境验证,提供了更全面的冲突处理能力。不过,引入扩展会增加部署复杂度,在决定使用前需要充分测试其与现有版本的兼容性和性能影响。
无论采用哪种方式,冲突处理都应当纳入整体的复制监控体系。通过定期检查复制延迟和错误日志,结合自动修复脚本,你可以建立一套“预防-检测-修复”的闭环机制,让PostgreSQL逻辑复制真正成为可靠的数据同步基础设施。
PostgreSQL逻辑复制冲突处理修改时间:2026-08-19 12:46:04