2. 逻辑解码原理深入

逻辑复制之所以能够把 PostgreSQL 的行级变更“翻译”成下游可消费的变更流,根本原因在于它并不直接读取表,而是读取 WAL(Write-Ahead Log)。WAL 是物理层面的重做日志,记录的是“页面上某个位置被改成了什么字节”,而逻辑解码要做的是把这种物理记录还原为“哪张表的哪一行发生了 INSERT/UPDATE/DELETE”。理解这条还原链路,是掌握逻辑复制可靠性的关键。

WAL 的逻辑结构:从段文件到记录

WAL 在磁盘上按 16MB 的段文件(segment)组织,文件名如 000000010000000000000001,由时间线、逻辑日志号和段号组成。每个段内部由一条条 WAL 记录(record)串联,每条记录带有资源管理器标识(rmgr)、信息位、长度以及可选的块引用(block reference)。资源管理器决定了这条记录由谁解析,例如 heap 负责堆表元组变更,btree 负责索引页变更,transaction 负责事务提交与回滚。

逻辑解码真正关心的是 heap 资源管理器下的记录,尤其是 XLOG_HEAP_INSERT、XLOG_HEAP_UPDATE、XLOG_HEAP_DELETE 以及 HOT 更新。这些记录里保存了新元组(new tuple)和旧元组(old tuple)的字段值,但字段值是否完整、是否包含旧值,取决于表是否设置了 REPLICA IDENTITY。默认情况下,UPDATE/DELETE 只记录主键列作为标识,因此下游要正确应用变更,必须保证主键或唯一键存在且被记录。

逻辑解码插件机制与输出插件接口

逻辑解码本身不是单一函数,而是一套“内核提供 WAL 解析能力 + 插件决定输出格式”的机制。内核负责推进复制槽(replication slot)、维护事务状态、调用回调;输出插件则实现一组固定接口,把内核解析出的变更转换成用户可读的格式。核心接口包括:

  • _PG_output_plugin_init:插件入口,注册各回调函数。
  • startup:解码启动时调用,用于初始化插件上下文。
  • begin / commit / abort:事务边界回调,标记一个事务的开始、提交或回滚。
  • change:每一条行级变更触发,携带 relation 信息、变更类型和元组数据。
  • truncate:表被 TRUNCATE 时触发。
  • shutdown:解码结束时清理资源。

PostgreSQL 内置了 test_decoding 插件,输出人类可读的文本,适合调试;生产环境常用 pgoutput,它是逻辑复制协议的原生输出插件,输出二进制格式,由订阅端解析。理解这些回调的调用顺序,就能理解“事务边界”是如何被下游感知的。

逻辑解码:从 WAL 物理记录到行级变更流 WAL 段文件 000000010000000000000001 16MB / 段 rmgr: heap / btree / xact WAL 记录 XLOG_HEAP_INSERT XLOG_HEAP_UPDATE XLOG_HEAP_DELETE new tuple / old tuple 内核逻辑解码器 推进复制槽 LSN 维护事务状态 调用插件回调 ReorderBuffer 输出插件 pgoutput test_decoding wal2json 自定义格式 输出插件回调时序(以一次事务为例) startup begin change × N commit abort shutdown begin 与 commit 之间可能包含多条 change;abort 表示事务回滚,下游应丢弃该事务已收到的变更 事务边界处理要点 变更先缓存,commit 后才对外可见 避免下游看到未提交数据 大事务受 logical_decoding_work_mem 限制 超限时溢出到磁盘,防止内存膨胀 子事务与两阶段提交需特殊处理 保证边界与主事务一致

解码过程:ReorderBuffer 与事务边界

内核解码器并不会按 WAL 写入顺序直接输出变更,因为一个事务的变更可能分散在多个 WAL 记录中,且并发事务交错写入。为此,PostgreSQL 使用 ReorderBuffer 按事务 ID 重新组织变更:每条变更先挂到对应事务的缓冲区,只有当事务提交(commit)时,才按顺序把该事务的所有变更交给输出插件;如果事务回滚(abort),则直接丢弃。这就是“事务边界处理”的核心。

ReorderBuffer 还承担内存控制职责。参数 logical_decoding_work_mem 默认 64MB,当某个事务的变更缓存超过该值时,内核会把已解码的变更溢出(spill)到磁盘,避免单个大事务耗尽内存。溢出后,事务提交时仍需按原顺序重组输出,因此下游看到的变更顺序与事务内执行顺序一致。

另一个关键点是复制槽(replication slot)。逻辑复制槽会记录 confirmed_flush_lsn,表示下游已确认消费到的位置。只有当下游确认后,内核才允许回收该 LSN 之前的 WAL。如果下游长时间不确认,WAL 会持续堆积,甚至撑满磁盘。因此监控 pg_replication_slots 中的 restart_lsn 与 confirmed_flush_lsn 差值,是运维逻辑复制的必备动作。

一次完整解码的观察示例

下面用 test_decoding 插件观察一次事务的解码输出。先创建逻辑复制槽:

SELECT * FROM pg_create_logical_replication_slot('demo_slot', 'test_decoding');

CREATE TABLE orders (
  id      int PRIMARY KEY,
  amount  numeric(10,2),
  status  text
);
ALTER TABLE orders REPLICA IDENTITY FULL;

BEGIN;
INSERT INTO orders VALUES (1, 99.50, 'new');
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;

然后读取该槽的解码结果:

SELECT data FROM pg_logical_slot_get_changes(
  'demo_slot', NULL, NULL
);

典型输出如下(示例结果,实际 LSN 与顺序以环境为准):

BEGIN 735
table public.orders: INSERT: id[integer]:1 amount[numeric]:99.50 status[text]:'new'
table public.orders: UPDATE: id[integer]:1 amount[numeric]:99.50 status[text]:'paid'
COMMIT 735

可以看到,BEGIN 与 COMMIT 成对出现,中间是该事务内的两条 change。若把 COMMIT 改为 ROLLBACK,则只会看到 BEGIN 和 ABORT,中间的 change 不会输出。这正是事务边界处理在输出层的直接体现。若表未设置 REPLICA IDENTITY FULL,UPDATE 的旧值可能只包含主键,下游若需要完整旧值就会缺失字段,因此生产环境应根据下游需求显式设置 REPLICA IDENTITY。

逻辑解码的输出顺序由事务提交顺序决定,而不是 WAL 写入顺序。下游应用变更时,应按收到的事务边界整体提交,避免把同一事务的变更拆散执行。
逻辑复制槽会阻止 WAL 回收。若订阅端长时间离线,restart_lsn 会停滞,WAL 目录持续增长。应设置监控告警,并在确认不再需要时及时删除无用槽。

把以上环节串起来:WAL 段中的 heap 记录提供原始行级数据,内核解码器借助 ReorderBuffer 按事务重组,输出插件通过 begin/change/commit 回调把变更格式化为下游可消费的流,复制槽则保证消费位置可追踪、WAL 可安全回收。理解这条链路,才能在逻辑复制出现延迟、数据不一致或 WAL 堆积时,快速定位是解码、插件、事务边界还是槽位确认环节出了问题。