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");

欢迎批评指正。

相关推荐
小马爱打代码28 分钟前
Spring Boot:将应用部署到Kubernetes的完整指南
spring boot·后端·kubernetes
卜锦元42 分钟前
Go中使用wire进行统一依赖注入管理
开发语言·后端·golang
SoniaChen332 小时前
Rust基础-part3-函数
开发语言·后端·rust
全干engineer2 小时前
Flask 入门教程:用 Python 快速搭建你的第一个 Web 应用
后端·python·flask·web
William一直在路上3 小时前
SpringBoot 拦截器和过滤器的区别
hive·spring boot·后端
小马爱打代码3 小时前
Spring Boot 3.4 :@Fallback 注解 - 让微服务容错更简单
spring boot·后端·微服务
豌豆花下猫4 小时前
Python 潮流周刊#110:JIT 编译器两年回顾,AI 智能体工具大爆发(摘要)
后端·python·ai
轻语呢喃4 小时前
JavaScript :事件循环机制的深度解析
javascript·后端
ezl1fe4 小时前
RAG 每日一技(四):让AI读懂你的话,初探RAG的“灵魂”——Embedding
后端