做逻辑复制的DBA几乎都被同一个问题折磨过:上游一条批量UPDATE语句改了几百万行数据,逻辑复制的下游延迟直接飙到小时级,WAL堆积把磁盘也快撑爆了。问题的根源在于,PostgreSQL的逻辑解码默认以事务为单位输出变更,大事务在提交之前,任何一条变更都不会发送给下游。要解决它,就得想办法把大事务拆开、边解码边发送,这就是所谓的大事务拆分。本文从逻辑解码的内部机制讲起,再给出具体的配置方案和下游适配思路。

为什么大事务会让逻辑复制卡死
先理解逻辑解码的工作方式。PostgreSQL在逻辑复制中并不是直接解析WAL字节流发给下游,而是由walsender进程读取WAL,将其重组为逻辑变更。重组的核心数据结构叫ReorderBuffer(重排序缓冲区),它会按事务ID缓存该事务产生的所有变更,包括行级的前镜像和后镜像,直到收到提交记录(commit record)才把整个事务的变更一次性交给输出插件(output plugin)去编码发送。
这个设计保证了事务原子性——下游要么收到完整事务,要么一条都收不到。但在大事务场景下代价非常高:假设一个事务更新了500万行,ReorderBuffer要在本地缓存这500万行变更的快照数据,内存压力大时会溢写到磁盘(reorderbuffer目录下的临时文件);下游在提交前收不到任何数据,apply延迟从事务开始执行那一刻就在累积。
更麻烦的是溢写磁盘。逻辑解码内存上限由logical_decoding_work_mem(PG 13之前是max_changes_in_memory和max_tuple_in_memory)控制,默认64MB,超限后变更被刷到磁盘临时文件,解码速度骤降,进一步放大延迟。所以大事务拆分本质上要解决两个问题:一是不要等提交才发送,二是不要把变更全部堆在内存或磁盘里。
方案一:开启流式发送(streaming large transactions)
PostgreSQL 14正式引入了流式解码支持,这是官方给出的半自动拆分方案。它的思路是:大事务不必等提交,在事务执行过程中就把已经产生的变更分批发送给下游,只是用额外的stream start、stream stop、stream abort、stream commit四类消息标记流的边界。这样ReorderBuffer可以在发送后立即释放对应缓存,内存占用可控,下游也能边收边apply。
启用流式发送需要两边的配置。发布端在创建订阅或逻辑槽时指定并行与流式参数,10以上版本使用物理复制槽的话没有这个能力,必须是逻辑槽:
-- 下游创建订阅时开启流式(PG 14+) CREATE SUBSCRIPTION my_sub CONNECTION 'host=192.168.0.1 dbname=app user=repl' PUBLICATION my_pub WITH (copy_data = true, streaming = on); -- PG 16 起还可以配合并行apply ALTER SUBSCRIPTION my_sub SET (streaming = parallel);
如果用的是自定义输出插件而不是内置的pgoutput,需要插件本身支持流式回调,即实现begin_prepare、stream_start等回调函数,并在创建槽时指定两阶段或流式协议版本。test_decoding插件从PG 14起也支持了流式输出,可以用它先做行为验证:
# 用test_decoding验证流式解码 pg_recvlogical -d mydb --slot=test_slot --create-slot -P test_decoding pg_recvlogical -d mydb --slot=test_slot --start -f - \ --option streaming=on --plugin=test_decoding
需要注意的是,流式发送有一个前提:下游apply进度落后太多时(feedback未及时推进),发送端仍会等待。另外流式拆分破坏了单条消息内的事务原子性,如果事务最后回滚了,上游会补发stream abort,下游必须回滚该流内已apply的变更。内置的逻辑订阅(logical replication subscriber)已经处理好了这些细节,自研消费端就需要自己实现。
方案二:应用层手动拆分大事务
如果数据库版本低于14,或者下游消费链路无法改造支持流式协议,就得在应用层把大事务拆小。思路很直接:把一次性的批量操作改成多批次提交。比如一个要更新500万行的任务,按主键范围或按LIMIT分页,每批1万行一个事务:
-- 拆分前的写法:一个巨型事务 UPDATE orders SET status = 'archived' WHERE status = 'done'; -- 拆分后的写法:循环小事务 WITH batch AS ( SELECT id FROM orders WHERE status = 'done' ORDER BY id LIMIT 10000 FOR UPDATE SKIP LOCKED ) UPDATE orders o SET status = 'archived' FROM batch WHERE o.id = batch.id; -- 应用层循环执行,直到影响行数为0
手动拆分的好处是完全不依赖数据库版本,行为可控、可观测,每批提交后复制延迟立即下降。代价是牺牲了原子性:中途失败时一部分数据已更新、一部分还是旧状态,业务上必须容忍中间状态,或者引入批次表记录断点位置,支持任务重跑。同时要小心拆分后锁竞争和索引热点,按主键顺序分批通常是最平稳的方式。
还有一种折中做法是拆为多个事务但在下游做幂等合并,比如每批数据带上相同的batch_id,下游apply时以batch_id为粒度做去重和最终一致性校验。这种方式在数据仓库同步场景里很常见,因为数仓端本来就不强依赖单事务原子性。
拆分之后:顺序性、一致性与监控
拆分不是把数据切开就完事,还要考虑顺序问题。逻辑解码保证同一个事务内的变更按WAL顺序输出,流式模式下也是如此,但不同批次之间可能交错。下游apply如果有外键依赖(比如先插子表再插父表的批次顺序),必须保证按发送顺序串行apply,或者在消费端做拓扑排序。并行apply(streaming = parallel)只会对无冲突的流并行执行,有依赖的变更仍会串行,这一点由订阅端的apply worker自动判断。
监控方面重点盯三个指标:复制槽的confirmed_flush_lsn与当前WAL LSN的差值(即延迟字节数,可用pg_stat_replication或pg_replication_slots视图查看)、订阅端的pg_stat_subscription中latest_end_time,以及磁盘上pg_replslot目录的大小。槽位持续膨胀说明下游消费不动,这是最危险的信号,极端情况下WAL堆积会把主库磁盘写满。
最后给一个实践建议:先把logical_decoding_work_mem调大到256MB或512MB减少溢写,再开启streaming,最后才是改造应用层拆分。绝大多数场景下前两步就能把延迟从小时级压到分钟级,配合合理的批次拆分可以进一步稳定在秒级。如果大事务来自不可控的第三方工具(比如某些ETL),优先在WAL产生侧做拦截和规范化,比在复制链路上补救要省力得多。
PostgreSQL逻辑解码大事务拆分修改时间:2026-09-14 07:26:39