PostgreSQL的逻辑解码(Logical Decoding)是构建数据同步、缓存刷新、审计系统的核心能力。不过很多使用者在深入使用之后都会遇到一个共同的问题:逻辑解码默认只支持DML,遇到DDL语句时行为往往不可控,轻则产生解析告警,重则中断复制链路。理解DDL在WAL中的记录方式,并学会在解码层面对其进行识别和过滤,是保证同步链路稳定运行的关键一环。

DDL在WAL中是如何被记录的
要过滤DDL,首先要知道它长什么样。PostgreSQL对DML(INSERT、UPDATE、DELETE)会在WAL中记录关系级的信息,逻辑解码模块通过ReorderBuffer把这些变更重组为事务,再交给输出插件处理。而DDL语句在WAL中的记录方式完全不同:以CREATE TABLE为例,它并不是一条独立的"DDL记录",而是一组对系统 catalog 表(如pg_class、pg_attribute、pg_type)的普通DML写入。
这就是问题的关键所在。当你执行一条ALTER TABLE时,PostgreSQL内部实际做的是对系统表插入、更新、删除若干行。这些变更同样会进入WAL,逻辑解码理论上能"看到"它们。默认配置下,输出插件通过filter_by_origin和关系过滤机制跳过了对系统表(pg_catalog schema下的表)的解码,所以你不会在pgoutput的输出流里看到DDL事件。
但问题在于,DDL改变了表结构之后,历史WAL中的记录格式可能与当前表结构不匹配。比如一条UPDATE的WAL记录是在旧表结构下生成的,如果表后来加了列,解码器按新结构去解析旧元组就会出错。这正是逻辑复制订阅端执行DDL后容易出现"replica identity"或"tuple format"错误的根源。因此在实际方案中,处理DDL通常分两条路线:要么在解码端识别并放行特定的DDL事件,要么在快照点之后保证表结构不再变化,从源头规避。
使用test_decoding观察DDL相关的WAL行为
在动手写插件之前,建议先用官方自带的test_decoding插件实际观察一下。创建一个测试用的复制槽:
-- 创建逻辑复制槽,使用 test_decoding 插件
SELECT * FROM pg_create_logical_replication_slot('test_slot', 'test_decoding');
-- 执行一条DDL
CREATE TABLE demo_t (id int primary key, name text);
-- 再执行一条DML
INSERT INTO demo_t VALUES (1, 'hello');
接着消费这个槽的数据:
SELECT * FROM pg_logical_slot_get_changes('test_slot', NULL, NULL);
你会看到输出流里只有INSERT相关的记录,CREATE TABLE产生的 catalog 变更被插件内部过滤掉了。这是因为在输出插件的回调中,对系统表的关系过滤逻辑直接返回了跳过标记。理解了这一点,你就明白所谓的"DDL过滤",本质上是在解码层面对 catalog 表变更做选择性处理:完全跳过(默认行为)、转换为自定义事件输出、或者结合外部元数据做更精细的控制。
编写自定义输出插件实现DDL识别与过滤
如果你需要在下游感知到DDL事件(比如同步系统需要在收到DDL后刷新表结构缓存),就需要写一个自定义的输出插件。核心思路是:在change_cb回调中,检查变更所属的关系是否落在系统 catalog 中,再根据 schema 和表名决定是跳过还是转换输出。
#include "postgres.h"
#include "replication/output_plugin.h"
#include "catalog/pg_class.h"
#include "utils/rel.h"
/* 关系过滤回调之前,每条变更都会进入这里 */
static void
my_change_cb(LogicalDecodingContext *ctx, ReorderBufferTXN *txn,
Relation relation, ReorderBufferChange *change)
{
if (relation->rd_rel->relnamespace == PG_CATALOG_NAMESPACE ||
relation->rd_rel->relnamespace == PG_TOAST_NAMESPACE)
{
/* 系统表或TOAST表的变更,直接跳过,不进入输出流 */
return;
}
/* 非系统表按普通DML处理 */
OutputPluginPrepareWrite(ctx, true);
switch (change->action)
{
case REORDER_BUFFER_CHANGE_INSERT:
appendStringInfo(ctx->out, "INSERT into %s",
RelationGetRelationName(relation));
break;
case REORDER_BUFFER_CHANGE_UPDATE:
appendStringInfo(ctx->out, "UPDATE on %s",
RelationGetRelationName(relation));
break;
case REORDER_BUFFER_CHANGE_DELETE:
appendStringInfo(ctx->out, "DELETE from %s",
RelationGetRelationName(relation));
break;
default:
/* 其他类型变更同样忽略,起到兜底过滤作用 */
return;
}
OutputPluginWrite(ctx, true);
}
void
_PG_output_plugin_init(OutputPluginCallbacks *cb)
{
cb->change_cb = my_change_cb;
}
上面这段代码演示了最基础的过滤逻辑:所有落在pg_catalog和pg_toast命名空间下的变更一律跳过,这就等价于过滤掉了DDL产生的 catalog 变更。如果你不希望完全丢弃,而是想把DDL事件转发给下游,可以在跳过之前先解析变更内容。例如识别到对pg_class的INSERT,可以提取出新表名,输出一条自定义的DDL_NOTICE消息。不过要注意,从 catalog 变更反推出完整的DDL语句是很困难的,ALTER TABLE可能对应多张系统表的多次修改,通常只能做到事件级别的提示,无法还原原始SQL文本。
还有一种更轻量的替代方案:不解析 catalog 变更,而是借助事件触发器(Event Trigger)配合一个桥接表。在主库上创建ddl_command_end事件触发器,把DDL语句写入一张普通的业务表,这样DDL本身就变成了一条可以被正常解码的DML。下游消费到这张桥接表的数据时,再执行对应的DDL处理。这种方案实现简单、能拿到完整的DDL文本,代价是引入了一张辅助表,并且需要保证触发器的可靠性。
-- 桥接表:DDL语句以普通DML形式记录,天然可被逻辑解码
CREATE TABLE ddl_events (
id bigserial primary key,
ddl_text text,
happened_at timestamptz default now()
);
CREATE OR REPLACE FUNCTION capture_ddl() RETURNS event_trigger AS $$
BEGIN
INSERT INTO ddl_events (ddl_text)
VALUES (current_query());
END;
$$ LANGUAGE plpgsql;
CREATE EVENT TRIGGER on_ddl_end
ON ddl_command_end
EXECUTE FUNCTION capture_ddl();
方案对比与生产实践建议
三种方案各有适用场景。直接在输出插件中过滤 catalog 变更,适合只做DML同步、明确不需要DDL的场景,实现简单且与官方行为一致,pgoutput插件本身就是这么做的。自定义解析 catalog 变更并输出事件提示,适合需要在下游触发元数据刷新的场景,但开发成本高,且要跟随PostgreSQL大版本升级维护catalog结构映射,长期维护负担不小。事件触发器桥接方案拿到的是完整DDL文本,实现最快,适合自建同步系统。
生产环境还有几个容易踩的坑需要提醒。第一,复制槽没有消费时WAL会持续堆积,做DDL过滤测试时记得用pg_drop_replication_slot及时清理,否则磁盘可能被撑爆。第二,如果同步链路中确实要执行DDL,务必保证订阅端先执行、发布端后执行,或者干脆在维护窗口暂停解码,避免新旧表结构不一致导致的解析错误。第三,wal_level必须设置为logical,同时max_replication_slots要预留足够的槽位,这些参数修改后需要重启实例才生效。
总结一下,PostgreSQL逻辑解码层面过滤DDL的本质是对系统 catalog 变更的选择性处理。绝大多数场景下沿用默认的过滤行为即可;确有DDL感知需求时,优先考虑事件触发器桥接方案,只有在深度定制的情况下才建议直接解析 catalog 变更。根据链路的复杂度和团队的维护能力选择合适的方案,才能让数据同步系统长期稳定地跑下去。
PostgreSQL逻辑解码DDL过滤修改时间:2026-09-07 03:58:35