PostgreSQL逻辑复制的原理和实践

PostgreSQL复制槽的基本含义

PostgreSQL的复制槽(Replication Slot)是一种关键的数据复制管理机制,主要用于确保数据在复制过程中的完整性和一致性。本文所说的复制槽都是逻辑复制槽(Logical Replication Slot),以下简称为复制槽。

  • 逻辑复制槽允许按表或行级别筛选数据变更,适用于异构数据库同步或跨版本升级。它通过输出插件(如pgoutput、wal2json)将WAL解码为逻辑操作(insert/update)。
  • 复制槽会跟踪订阅端的复制进度,确保主库不会清理订阅端尚未接收或应用的WAL日志文件。这解决了传统复制中因备库延迟或断开连接导致的数据丢失问题。

从这一点来讲,MySQL的binlog的日志管理机制就无法跟踪订阅端的复制进度。MySQL的binlog需要配置expire_logs_days,binlog日志会自动清理;PostgreSQL需要订阅端告知主库消费到哪里了,哪里可以清理。

然而,PostgreSQL的日志保留机制容易因WAL日志堆积导致磁盘打满,以阿里云的RDS PostgreSQL为例,磁盘打满后实例就会停止,影响业务。

在PostgreSQL上操作复制槽

创建复制槽

假设需要创建一个名为my_test的复制槽,并使用pgoutput作为输出插件。

  • 在创建复制槽之前,需要保证PostgreSQL的参数wal_level置为logical。wal_level具有3个级别:minimal、replica、logical,决定了写入WAL日志的信息量。

在生产中,设置为logical时需要考虑WAL日志可能会使得PostgreSQL的磁盘负载变高,增加主库CPU负载。该餐宿修改后,需要重启PostgreSQL服务器才能生效。

SQL 复制代码
SELECT * FROM pg_create_logical_replication_slot('my_test','pgoutput');

查看复制槽的状态

在一个PostgreSQL实例中,不允许创建同名复制槽(哪怕是在同一个PostgreSQL实例下的不同数据库中,也不允许同名)。因此可以根据复制槽名称(slot_name字段)唯一标识一个复制槽。

SQL 复制代码
select * from pg_catalog.pg_replication_slots where slot_name ='my_test';

在查看复制槽时,我主要关注如下几个指标。这些参数的具体含义可在官网pg_replication_slots一节中查看。

  1. active:表示该复制槽是否活跃。若不活跃(值为f),说明此时没有进程连接到该复制槽。若是活跃状态(值为t),则存在一个进程(也只能有一个)连接到该复制槽。

在生产中需要留意当active=f时带来的风险,此时无进程消费主库的WAL日志(例如备库处于离线状态,或使用的ETL工具已经停止),则有可能导致WAL日志堆积,进而耗尽磁盘空间。若已确定没有进程需要再次连接该复制槽去同步数据,我建议尽早删除该复制槽。

  • 如下图所示,如果PostgreSQL实例经常有写入操作,则很快会导致WAL堆积。
  1. restart_lsn:这是一个LSN类型的数据,表达的是在该LSN之前,WAL日志已经被清理。即在该LSN之前的WAL日志保证不存在。

  2. confirmed_flush_lsn:这是一个LSN类型的数据。表达的是在该LSN之前,WAL日志可能 会被清理,但是具体什么时候清理,由PostgreSQL内核说了算。即在该LSN之前的WAL日志不保证存在。

    • 在ETL工具中,通常是订阅端进程汇报confirmed_flush_lsn给主库以告知复制进度,从而让主库清理WAL日志。

删除复制槽

SQL 复制代码
select * from pg_drop_replication_slot('my_test');

复制槽不存在,则上述SQL报错。为了鲁棒性,增加一个判断条件:

SQL 复制代码
select * from pg_drop_replication_slot('my_test')
	where exists (select * from pg_replication_slots where slot_name='di_slot_2661');

查看复制槽的大小

如果是自建PostgreSQL,没有云产品的管控页面,可通过如下SQL查看复制槽的大小。

SQL 复制代码
SELECT slot_name, 
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_insert_lsn(),restart_lsn)) 
       AS wal_delay 
FROM pg_replication_slots;

其中select pg_current_wal_insert_lsn()可用于查看当前WAL日志最新的LSN。

Java程序同步PostgreSQL

创建Publication

PostgreSQL复制槽解决的是"复制到哪里"和"如何不丢失数据",那"复制什么"就必须通过Publication来解决了。

创建一个发布(Publication),并且关联到特定的表。假设该发布的名称为my_pub,需要同步2张表:public.table1、public.table2。

SQL 复制代码
create publication my_pub for table public.table1, public.table2;

-- 加表
alter publication my_pub add table public.table3;

-- 减表
alter publication my_pub drop table public.table3;

删除发布:

SQL 复制代码
drop publication if exists my_pub;

Publication定义主库上哪些表需要被复制,本质是一个数据变更的"发布集合"。Slot记录订阅者的复制进度(LSN位置),确保主库不会删除订阅者未接收的WAL日志。

应当先创建谁?通常是先创建Publication,后创建Slot,即先定义需要复制什么,后管理进度。但实际上,若是这样的流程:1)创建Publication,2)第一次变更表,3)创建slot,4)第二次变更表;此时订阅端收到的数据,并不会包含第一次变更,只会包含第二次变更。即第一次变更作为SNAPSHOT,第二次变更才作为增量。

通过Debezium消费WAL日志

下面以Debezium PostgreSQL Connector为例,读取PostgreSQL实例中的增量数据。

如下是Maven构建项目时的pom.xml文件中的主要信息,需要添加debezium相关依赖。我以Debezium 1.9版本为例(官网最新版本是3.1),官方文档

xml 复制代码
<dependencies>
    <dependency>
        <groupId>io.debezium</groupId>
        <artifactId>debezium-core</artifactId>
        <version>${version.debezium}</version>
    </dependency>
    <dependency>
        <groupId>io.debezium</groupId>
        <artifactId>debezium-embedded</artifactId>
        <version>${version.debezium}</version>
    </dependency>
    <dependency>
        <groupId>io.debezium</groupId>
        <artifactId>debezium-api</artifactId>
        <version>${version.debezium}</version>
    </dependency>
    <dependency>
        <groupId>io.debezium</groupId>
        <artifactId>debezium-connector-postgres</artifactId>
        <version>${version.debezium}</version>
    </dependency>
</dependencies>
<properties>
    <version.debezium>1.9.7.Final</version.debezium>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
</properties>

启动类(一个朴素的示例程序),主要功能如下:

  1. 设置Debezium的参数,包括数据库的连接信息、需要同步哪些表、是否需要做SNAPSHOT、是否要监听truncate事件等。
  2. 创建Debezium引擎。通过using()方法设置参数,通过notifying()关联到消费者,通过build()方法构建引擎。
  3. 通过线程池执行Debezium引擎。注意,只能单线程。
java 复制代码
public class Main {

    public static void main(String[] args) throws IOException {
        Properties props = new Properties();
        // 设置用户名、密码等连接信息(敏感信息,因此隐藏)
        props.setProperty("database.hostname", "******");
        props.setProperty("database.dbname", "******");
        props.setProperty("database.user", "******");
        props.setProperty("database.password", "******");
        props.setProperty("database.port", "5432");
        
        // 以下参数可以控制Debezium的行为
        props.setProperty("table.include.list", "public.table1,public.table2");
        props.setProperty("slot.name", "my_test");
        props.setProperty("publication.name", "my_pub");
        props.setProperty("plugin.name", "pgoutput");
        props.setProperty("tombstones.on.delete", "false");
        props.setProperty("snapshot.mode", "never");
        props.setProperty("database.server.name", "******");
        props.setProperty("publication.autocreate.mode", "filtered");
        props.setProperty("connector.class", "io.debezium.connector.postgresql.PostgresConnector");
        props.setProperty("truncate.handling.mode", "include");
        
        // 创建Debezium引擎,并执行同步
        DebeziumEngine<ChangeEvent<String, String>> engine = DebeziumEngine.create(Json.class)
                .using(props)
                .notifying(new MyConsumer())
                .build();
        ExecutorService debeziumExecutor = new ThreadPoolExecutor(
                /*corePoolSize*/1,
                /*maximumPoolSize*/1,
                /*keepAliveTime*/0L,
                TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>());
        // 启动一个进程执行复制
        debeziumExecutor.execute(engine);
    }

还需要定义一个消费者

java 复制代码
public class MyConsumer implements Consumer<ChangeEvent<String, String>> {
	/**
	 *
	 * @param stringStringChangeEvent the input argument
	 */
	@Override
	public void accept(ChangeEvent<String, String> stringStringChangeEvent) {
	    logger.info("读取到变更: {}", stringStringChangeEvent.value());
	}
}

完成Slot的创建和Publication的创建后,运行上述代码,能够从PostgreSQL实例同步变更。

控制LSN的汇报频率

然而,上述代码没有控制LSN的汇报。查看复制槽的状态,可以发现一旦有对表的变更,confirm_flush_lsn立即会往前推进。即WAL日志一刻钟都不会多保留。 用下图来说明一下上述代码存在的问题:

sequenceDiagram PostgreSQL实例->>我写的Java程序: event: insert into values (1,'hello') at LSN1 我写的Java程序-->>PostgreSQL实例: comfirm LSN1 PostgreSQL实例-->>PostgreSQL实例: 清理LSN1之前的WAL日志 PostgreSQL实例-)我写的Java程序: event: insert into values (2,'hello') at LSN2 我写的Java程序-->>PostgreSQL实例: comfirm LSN2 PostgreSQL实例-->>PostgreSQL实例: 清理LSN2之前的WAL日志

上述代码的好处显而易见:不会给PostgreSQL造成WAL堆积,因此不用担心耗尽磁盘空间的风险。但是如果"我写的Java程序"需要将数据同步到下游,在comfirm LAN1后还没来得及写入到下游,程序就异常终止,则下一次开始从该Slot同步,则无法读取到LSN1上的event。

为了控制LSN能够按照预期的方式进行汇报,可以自定义LSN的汇报策略。

  1. 重写performCommit()方法。每当Debezium接收到一个变更,则会触发调用performCommit()方法,若该方法返回true,则上报LSN。
  2. 下面的程序定义了在间隔多长时间后,触发上报当前变更的LSN(注意,这也存在危险,即最新的变更并没有在Slot中做保留)。
java 复制代码
public class MyOffsetCommitPolicy implements OffsetCommitPolicy {
    // 超过多长时间,则上报 LSN
    public static int confirm_interval_second = 5 * 60;

    @Override
    public boolean performCommit(long numberOfMessagesSinceLastCommit, Duration timeSinceLastCommit) {
        if (timeSinceLastCommit.getSeconds() > confirm_interval_second) {
            logger.info("Do commit LSN");
            return true;
        } else {
            logger.info("Do not commit LSN");
            return false;
        }
    }
}

上述代码虽然给上报LSN留了一些时间间隔,但是依然存在某些变更在Slot中一刻也不保留的情况。最好能够精准控制每一个LSN在何时上报。

用下图来说明一下上述代码存在的问题:

sequenceDiagram PostgreSQL实例->>我写的Java程序: event: insert into values (1,'hello') at LSN1 我写的Java程序-->>我写的Java程序: 时间间隔到了吗?还没 PostgreSQL实例-)我写的Java程序: event: insert into values (2,'hello') at LSN2 我写的Java程序-->>我写的Java程序: 时间间隔到了吗?还没 PostgreSQL实例-)我写的Java程序: event: insert into values (3,'hello') at LSN3 我写的Java程序-->>PostgreSQL实例: comfirm LSN3 PostgreSQL实例-->>PostgreSQL实例: 清理LSN3之前的WAL日志

如果"我写的Java程序"在comfirm LAN3后还没来得及写入到下游,程序就异常终止,则下一次开始从该Slot同步,则无法读取到LSN3上的event。

控制哪一个Record的LSN会被上报

我认为比较实用的是实现DebeziumEngine.ChangeConsumer<R>接口。上述代码中的消费者实现的是Consumer<T>,只有一个accept()方法。DebeziumEngine.ChangeConsumer<R>接口提供了handleBatch()方法,参数RecordCommitter<R>可以精准控制哪一个LSN被汇报给主库。

将上述程序的引擎创建步骤修改为如下代码:

java 复制代码
DebeziumEngine<ChangeEvent<SourceRecord, SourceRecord>> engine = DebeziumEngine.create(Connect.class)
	.using(props)
	.using(new MyOffsetCommitPolicy())
	.notifying(new RecordConsumer())
	.build();

同时定义消费者如下

java 复制代码
public class RecordConsumer implements DebeziumEngine.ChangeConsumer<ChangeEvent<SourceRecord, SourceRecord>> {

	@Override
	ChangeEvent<SourceRecord, SourceRecord>> committer) throws InterruptedException {
	    for (ChangeEvent<SourceRecord, SourceRecord> record : records) {

	        Envelope.Operation op = null;
	        if (rawRecord.value() != null) {
	            op = Envelope.operationFor(rawRecord);
	        }
	        if (op != null) {
	            Struct value = (Struct) rawRecord.value();
	value.getStruct(FieldName.SOURCE).getString("table"));
	        }
           
           // 将当前record标记为已完成,则confirm LSN时汇报的是该record的LSN。
	        committer.markProcessed(record);
	    }
	    committer.markBatchFinished();
	}
}

一种常见的做法是维持一个队列,保存接收到的所有SourceRecord,定期将队首元素取出并做committer.markProcessed(record)操作,此时就会立即将队首元素的LSN上报给主库,使得confirm_flush_lsn更新为该元素的LSN。

心跳机制的引入

考虑一个场景,如果在PostgreSQL实例中存在2张表:A表和B表。我要订阅的是A表,但是A表的变更很少,假设仅有每周一条数据;我并没有订阅B表,但是B表的变更量却很大,达到每天1TB。此时会出现什么情况?

  • 由于实例中的所有变更都被记录在WAL日志,所以当Slot长时间没有收到订阅端发来的confirm LSN消息时,就不会清理WAL日志。即使按照最开始的程序,每一次收到event就confirm LSN,Slot也要保存1周。直观影响是PostgreSQL实例的磁盘负载升高。

Slot没有清理导致confirm_flush_lsn停留在1周之前,本质上是因为订阅端没有收到Record就不会调用OffsetCommitPolicy接口的performCommit()方法。

Debezium提供的心跳机制可以解决这个问题,官网链接。需要说明的是,这个心跳并不是PostgreSQL服务端发来的心跳,而是Debezium作为客户端发送的心跳。模拟的是一个虚拟的event,在代码中,也相当于一个Record。

添加如下参数,即可在每秒发送一个心跳。Debezium收到心跳event就会调用OffsetCommitPolicy接口的performCommit()方法。

java 复制代码
props.setProperty("heartbeat.interval.ms", "1000");

欢迎批评指正。

相关推荐
Nicander6 分钟前
Spring Boot 全局异常处理:原理与实践
spring boot·后端
若阳安好23 分钟前
【备忘录】正则表达式
后端·正则表达式·restful
Cosolar1 小时前
AI Agent 的记忆战争:OpenClaw vs Hermes vs QwenPaw vs HiClaw,谁真正"记得住"?
人工智能·后端·面试
M ? A1 小时前
VuReact:Vue转React的增量编译利器
前端·vue.js·后端·react.js·面试·开源·vureact
aircrushin1 小时前
给宝宝办了个宴,朋友用trae做的工具帮了大忙
前端·后端
码上小翔哥1 小时前
Jackson 配置深度解析
java·后端
程序员Sunday1 小时前
爆肝万字!这应该是全网最全的 Codex 实战教程了
前端·后端·ai编程
aircrushin1 小时前
朋友用trae搭建的工具,解决了旅行拍照共享的大事儿
前端·后端
星栈1 小时前
把业务逻辑写成纯函数之后,我再也不想写 Service 层了
后端·开源
未秃头的程序猿1 小时前
如何用 AI 写出符合规范的 Java 代码?我总结了 7 条有效建议
java·后端·ai编程