PostgreSQL的逻辑复制(Logical Replication)基于发布订阅模型,只复制DML操作(INSERT、UPDATE、DELETE和TRUNCATE),并不复制DDL。这意味着当发布端执行ALTER TABLE这类表结构变更时,订阅端不会被自动更新,两边结构不一致后,轻则数据复制报错,重则复制槽堆积WAL导致发布端磁盘被撑爆。本文围绕表结构变更如何同步这一问题,详细分析原理、给出可落地的方案和注意事项。

为什么DDL会导致逻辑复制出问题
逻辑复制的底层依赖是逻辑解码(Logical Decoding),发布端把WAL日志解析成逻辑变更记录,再通过walsender推送给订阅端的apply worker。这个解析过程依赖表的Relation信息,也就是复制协议中的relation message。当发布端执行了ALTER TABLE之后,发布端写入的新数据可能包含新字段,而订阅端的表结构还是旧的,apply worker在应用变更时就会因为字段数量或类型不匹配而报错。
一个典型的错误日志类似这样:
ERROR: logical replication target relation
"public.orders" is missing replicated column: discount_rate
CONTEXT: processing remote data for table "public.orders" during
"INSERT" for replication target relation "public.orders"这个错误出现后,该表的复制 worker 会一直重试失败,对应的复制槽会持续保留WAL。如果长时间不处理,发布端的pg_wal目录会不断膨胀,最终耗尽磁盘空间,影响整个实例。所以DDL变更绝不是发布端一个人说了算的事,必须把订阅端纳入整个变更流程统一考虑。
方案一:手工同步加事件触发器自动化
最直接的做法是手工在订阅端先执行DDL,再在发布端执行。原则是:加列时先在订阅端加,删列时先在发布端删(或者两边同时处理),保证apply worker应用数据时目标表结构始终兼容。对于加列这种操作,订阅端多一列是没问题的,逻辑复制按列名匹配而不是按位置匹配,多出来的列保持默认值即可。
手工方式在表少的时候可行,但表一多就容易漏。更可靠的方式是使用事件触发器(Event Trigger)自动捕获DDL并记录下来。PostgreSQL从10版本开始提供ddl_command_end事件,可以在DDL执行后自动记录变更语句。下面是一个把DDL写入记录表的例子:
-- 发布端:创建DDL记录表
CREATE TABLE public.ddl_log (
id bigserial PRIMARY KEY,
event_time timestamp DEFAULT now(),
tag text,
command text
);
-- 创建事件触发器,捕获表结构变更
CREATE OR REPLACE FUNCTION public.log_ddl()
RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE
v_record record;
BEGIN
FOR v_record IN
SELECT command_tag, pg_catalog.current_query()
LOOP
IF v_record.command_tag IN ('ALTER TABLE','CREATE TABLE') THEN
INSERT INTO public.ddl_log(tag, command)
VALUES (v_record.command_tag, pg_catalog.current_query());
END IF;
END LOOP;
END;
$$;
CREATE EVENT TRIGGER trg_log_ddl
ON ddl_command_end
EXECUTE FUNCTION public.log_ddl();记录下来的DDL语句可以通过普通表同步机制(甚至单独一个发布订阅)送到订阅端执行,也可以由定时任务轮询后在订阅端重放。需要注意的是,事件触发器只是记录,并不能保证两边执行的原子性,像ADD COLUMN ... NOT NULL DEFAULT这种在PG 11之前的版本会重写整表的语句,还是要评估执行时间差带来的影响。
此外社区有现成的扩展如pg_ddl_replicate思路的方案,本质也是借助事件触发器加消息队列,把DDL在订阅端排队重放,适合不想自己维护脚本的团队。
方案二:触发器双向确认与大表在线变更
对于加列、加默认值这类向后兼容的变更,标准流程是:先在订阅端执行,确认无误后在发布端执行,中间不需要停复制。但修改字段类型这种破坏性变更就麻烦了,例如把integer改成bigint,订阅端如果先改,旧数据还能兼容;如果发布端先改,新写入的值订阅端可能装不下,apply就会报错。
对于修改类型的大变更,推荐使用新建表加数据迁移加切换的方式,具体步骤如下:
-- 1. 两端都创建新表(结构一致) CREATE TABLE orders_new (LIKE orders INCLUDING ALL); ALTER TABLE orders_new ALTER COLUMN amount TYPE bigint; -- 2. 发布端把新表加入发布 ALTER PUBLICATION pub_orders ADD TABLE orders_new; -- 3. 停止业务写入,同步存量数据到新表 INSERT INTO orders_new SELECT * FROM orders; -- 4. 事务内完成切换 BEGIN; ALTER TABLE orders RENAME TO orders_old; ALTER TABLE orders_new RENAME TO orders; COMMIT; -- 5. 订阅端执行同样的重命名切换 -- 6. 发布端从发布中移除旧表,确认稳定后清理 ALTER PUBLICATION pub_orders DROP TABLE orders_old; DROP TABLE orders_old;
这个方案的好处是切换动作是纯元数据操作,瞬间完成,而且新旧表结构在变更期间始终一致,apply worker不会遇到字段不匹配的问题。缺点是整个过程需要短暂的写入暂停,且订阅端的重命名必须赶在发布端新数据写入新表之前完成,否则旧表名上的订阅关系会对不上。为了保险,切换前可以临时执行ALTER SUBSCRIPTION ... DISABLE,切换完成后重新刷新表列表再启用。
大表加索引也属于结构变更,但索引不影响复制的数据匹配。建议在订阅端先建索引再在发布端建,发布端建索引时使用CREATE INDEX CONCURRENTLY避免锁表,订阅端因为apply是单线程的,索引建得慢会拖慢数据应用速度,最好安排在低峰期。
变更前后的检查与验证
任何DDL操作前,都应该先核对两端的表结构是否一致。可以用下面的查询比对列信息:
SELECT table_name, column_name, data_type,
character_maximum_length, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'orders'
ORDER BY ordinal_position;在发布端和订阅端分别执行,比对输出结果。更自动化的方式是把两边的查询结果各自落表,再通过dblink或postgres_fdw做差集比对,纳入日常巡检。
变更执行后,重点观察三个地方:一是订阅端的状态,执行SELECT * FROM pg_stat_subscription;查看worker是否正常,latest_end_time是否持续更新;二是发布端的复制槽,查pg_replication_slots中的restart_lsn是否推进,confirmed_flush_lsn与当前LSN的差距是否在缩小;三是业务侧写入一条测试数据,确认订阅端能及时收到。一旦发现apply报错,第一时间修复订阅端结构,然后执行ALTER SUBSCRIPTION sub_name REFRESH PUBLICATION;让worker重新加载表信息,必要时还可参考错误日志中提示的字段差异做针对性处理。
总结来说,PostgreSQL逻辑复制下的表结构变更核心思路就八个字:先订阅端、后发布端,破坏性变更走新表切换,再配合事件触发器或巡检脚本兜底,就能把DDL带来的复制中断风险降到最低。
PostgreSQL逻辑复制DDL同步表结构变更修改时间:2026-09-10 00:48:43