提到PostgreSQL流复制和逻辑复制这两种复制方式,不得不想到的一个东西,就是复制槽(Replication Slot)。
复制槽在PostgreSQL 9.4版本中被引入,引入之初是为了防止备库需要的WAL日志在主库被删除,确保主库在所有的备库收到wal之前不会移除它们,主库会会根据备库返回的信息确认哪些WAL已不再需要,才能进行清理。Replication Slot能够确保在主备断连后主库的WAL仍不被清理,因为Replication Slot的状态信息是持久化保存的,即便从库断掉或主库重启,这些信息仍然不会丢掉或失效。
复制槽分为物理复制槽(Physical Replication Slot)和逻辑复制槽(Logic Replication Slot)。物理复制槽一般结合流复制一起使用,能够很好的保证备库需要的日志不会在主库删除。
而逻辑复制槽是PostgreSQL自身提供的WAL(WAL解析)功能,将数据的数据操作按照事务,依次放到逻辑复制槽中,(复制槽中可以用一些解析插件WAL解析为各种形式)然后能过walsender发出。他最主要的作用是来记录和做事务切换,这样才能保证不会丢事务或者重发事务。
复制槽实质上是内存中的一些数据结构,加上持久化保存到pg_replslot/目录中的二进制状态文件。在PostgreSQL启动的时候,预先在共享内存中分配好这些数据结构所用内存(即一个大小为max_replication_slots的数组)。需要注意的一点是,我们在进行basebackup的时候是会把pg_replslot这个目录排除掉的,所以就算主库上存在逻辑复制槽,在用basebackup搭建流复制的时候,也是不会把原本的主库上的逻辑复制槽拷贝到备节点。如下为PostgreSQL数据库源码里的一部分,位置在src/backend/replication/basebackup.c,这里面包含了basebackup命令执行过程会忽略的目录,
static const char *const excludeDirContents[] =
{
PG_STAT_TMP_DIR,
"pg_replslot",
PG_DYNSHMEM_DIR,
"pg_notify",
"pg_serial",
"pg_snapshots",
"pg_subtrans",
NULL
};
这些目录的内容在服务器启动时被删除或重新创建,因此它们不包括在备份中。 这些目录本身被保留并作为空目录包括在内,以保持访问权限。这一点需要我们尤为注意,不要错误的认为pg_basebackup会备份数据目录下的所有东西。
如果真的想要在其他节点去转移复制槽的话,其实可以选择手动拷贝这个pg_replslot目录,并且拷贝完之后,需要数据库重启才能在数据库里查看到这个复制槽的信息,因为复制槽是在数据库的启动阶段,把pg_replslot目录下的复制槽信息加载到数据库里的。如果在高可用环境,主库发生了宕机,那么此时主库作为发布端的逻辑复制就可能存在问题,而现阶段的高可用方案,大多都做不到复制槽的自动故障转移,其中Patroni是个个例,它支持逻辑复制槽的故障转移。
编辑Patroni的配置文件,在“slots:”部分可以定义永久复制槽。复制槽将在切换/故障转移期间保留。应用更改后,将在主节点上创建逻辑复制槽,同样的复制槽也将在备用数据库上创建。Patroni在内部将复制槽信息从主节点复制到所有符合条件的备用节点。 Patroni 集群的所有备用节点中的 Replication Slot 信息也随着逻辑复制从主端进行而提前,当 LSN 编号在主节点上的相应插槽上增加时,自动增加备用节点插槽上的 LSN 号。这样备库的LSN推进也不会因为LSN的原因造成日志的累积。在切换或故障转移的情况下,不会丢失任何插槽信息,因为它们已经在备用节点上维护。因此在严格意义上讲,并不能算复制槽的故障转移,而是在所有的节点上都维护一个相同的复制槽。
Patroni的这个方案需要PostgreSQL 11 以上版本,因为它使用从 PostgreSQL 11 开始可用的pg_replication_slot_advance()函数来推进插槽。Patroni使用pg_read_binary_file() 函数来读取槽的二进制信息,永久插槽信息将被添加到 DCS中,并由Patroni 的主实例持续维护。此外必须在需要维护逻辑复制槽的所有备用节点上启用hot_standby_feedback ,必须启用Patroni参数postgresql.use_slots以确保每个备用节点都使用主节点上的插槽。
之前提到了,复制槽可以保证备库需要的日志不会在主库删除。数据库为复制槽所保留的最早的的LSN就是逻辑复制槽记录的restart_lsn。在PostgreSQL源码的src/backend/replication/slot.c里,通过ReplicationSlotsComputeRequiredLSN()函数我们能清晰得看到,它计算了所有槽位的最老的restart_lsn,并通知xlog模块,把最老的restart_lsn的值赋给了min_required变量,也就是把这个LSN作为数据库保留的最小得LSN,即作为数据库还需要的最小的LSN。
void
ReplicationSlotsComputeRequiredLSN(void)
{
... ...
if (restart_lsn != InvalidXLogRecPtr &&
(min_required == InvalidXLogRecPtr ||
restart_lsn < min_required))
min_required = restart_lsn;
}
LWLockRelease(ReplicationSlotControlLock);
XLogSetReplicationSlotMinimumLSN(min_required);
}
我们在上边搞清楚了数据库会把所有复制槽里restart_lsn最小的作为最老的LSN,这个LSN往后的所有较新的日志都会保留下来。但是同时也面临了新的问题:如果复制槽失效了怎么办?如果复制槽上的restart_lsn不推进了怎么办?没错,如果数据库是一直正常运行着的,且有一定的业务,那么,这两个现象都可能会引起WAL日志累计的问题,复制槽失效,那他的restart_lsn必然不会正常推进。从这个最小的restart_lsn往后的日志都会保留下来,如果一段时间内业务量很大,而这个复制槽没有有效处理的话,WAL数量可能会急剧增长,超过受参数限制的WAL的常规数量,毕竟wal_keep_segments、max_wal_size是软限制。
因此我们应及时关注失效和不使用的逻辑复制槽。好在PostgreSQL13提供了max_slot_wal_keep_size,控制最大为复制槽保留多少WAL日志。此外,在PostgreSQL13、14、15每个版本都针对这个失效复制槽的问题,进行了一些调整。
在PostgreSQL数据库里,每次发生检查点的时候,都会触发旧日志(WAL)的清理动作。
我们去查看源码的src/backend/access/transam/xlog.c文件下,CreateCheckPoint()这个函数的定义,找到这一清理日志的代码部分,其中,PostgreSQL12版本的如下:
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
_logSegNo--;
RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr);
这个功能调用的函数主要是这四行,第一个函数用来做segment段号的计算,第二个函数计算要保留的日志段号,第三部分是一个简单的日志段号递减1的过程,第四行是做老的日志移除的函数。
这里我们不去详细看它的实现原理,仅仅看各个版本的这部分函数调用,就可以大致了解在最近几个版本,PostgreSQL数据库对这个问题做了什么优化。
比如,PostgreSQL13版本的对应部分如下:
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
InvalidateObsoleteReplicationSlots(_logSegNo);
_logSegNo--;
RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr);
PostgreSQL13版本在这一部分比PostgreSQL12版本多了一行函数调用,从字面意思也可以猜出个所以然来,这个函数的作用是,将任何指向比给定区段更早的LSN的槽标记为无效;这些无效的槽所需要的WAL日志将被移除。看来,PostgreSQL的开发者们已经意识到这种失效的复制槽带来的负面影响了,因此,在PostgreSQL13版本起,就引入了失效的复制槽的处理机制(注意13.4版本的处理机制已经和13前几个版本不一样了,与PG14版本相同)。
我们再来看PostgreSQL14版本的相关部分,PostgreSQL14版本在13版本的基础上,又加了一个判断,如果有失效的复制槽,则会重新计算要保留的最老的LSN。如果没有失效的复制槽,则省去重复的处理部分。
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
if (InvalidateObsoleteReplicationSlots(_logSegNo))
{
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
}
_logSegNo--;
RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr);
再看一看最新的PostgreSQL15版本,PostgreSQL15版本的函数调用其实和14版本没有什么不同,只不过对于移除旧的WAL日志的函数RemoveOldXlogFiles(),在原本的基础上,传入变量多了checkPoint.ThisTimeLineID,这个值是XLOG插入的当前时间轴。任何回收的部分应该在这个时间线重复使用。ThisTimeLineID其实并不重要,在比较中会忽略它。在决定是否仍然需要一个段时,忽略XLOG段标识符的时间轴部分。这确保了我们不会过早地从父时间轴中删除一个段。因为可能会更主动地删除非父时间轴的片段,那样会更加棘手。
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
if (InvalidateObsoleteReplicationSlots(_logSegNo))
{
XLByteToSeg(RedoRecPtr, _logSegNo, wal_segment_size);
KeepLogSeg(recptr, &_logSegNo);
}
_logSegNo--;
RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr,
checkPoint.ThisTimeLineID);
从上边的分析我们可以看到,失效的复制槽有WAL日志堆积的风险,针对该问题,PostgreSQL的开发者们在PostgreSQL13、14、15版本逐步做了一些优化。此外,逻辑复制的性能和功能也在这几个版本做了一定程度的提升,如果您对PostgreSQL数据库的使用中,涉及到了复制槽或者逻辑复制的场景,我建议使用PostgreSQL 13+的版本,这样可能会大大减少您在使用过程中的相关问题。除此之外,相关监控也是必不可少的。




