目前来看,社区内部比较认可的解决方案是构建一种新的存储格式,例如zheap。在EnterpriseDB 公司(后文简称为EDB)的对外分享中,也解释了为什么需要一种新的存储格式:

  • 所有数据库的MVCC 机制都需要保存旧版本的数据,但是具体的方法不同:
    • PostgreSQL 和Firebird 把新旧数据都直接存储在表中,通过一些辅助的字段来确定元组的对外可见性。
    • Oracle 和 MySQL 将旧数据保存在undo log 中。
    • SQL Server 将旧数据保存在tempdb 中。
  • 在表中存储旧的数据会使得清理变得困难,很多时候需要CLUSTER or VACUUM FULL。
  • 之前对VACUUM 的优化可以改善数据膨胀,但是不能完全避免它。

而zheap 这种新的存储格式致力于:

  • 更好控制数据膨胀:
    • 在原来的位置上直接更新数据避免数据膨胀。
    • 当commit 或者abort 之后重用空间,更少或者直接不需要引入VACUUM。
  • 更少的数据写:
    • hint-bits, freezing 等其他辅助信息的改变不会引发数据脏页,只有数据内容改变才会引发脏页写。
    • 当index 对应列更新,对应的index 标记为deleted 来支持原位置上更新数据。如果index 列没有更新,则不需要index 的操作。这也有利于控制数据膨胀。
  • 更小的数据大小:
    • 大多数的事务信息不再需要,大大缩小了元组的头部信息。

为了最终将zheap 这个新存储格式对应代码合入到社区主干,当前EDB 计划分几步走:

  • 提供可插拔的存储接口。PostgreSQL 12 已支持。
  • 提供undo log 相关机制和接口。
  • 提供zheap 存储格式。

其中的undo log 就是我们本文讨论的重点,目前该特性已经在社区的邮件列表中(邮件列表详见)提交了POC,同时引发了社区广泛的讨论。

但是为什么需要undo log 呢?目前来看,有以下几个可见的收益:

  • 当事务回滚时,undo log 可以快速回滚数据,相比之前的实现(标记该事务为没有提交,对应数据变成对外不可见的垃圾数据)大大减少数据膨胀。
  • 旧版本的数据存储在undo log 中,其清理或者重用,几乎对正常的数据访问无影响。
  • undo log 单独存储,可以设计高效的清理机制,避免数据膨胀。
  • undo log 解决了创建表的事务中崩溃后的孤儿文件残留的问题。

而从邮件列表中,我们可以看出社区对undo log 相关的讨论可以分成三个patch:

  • Undo log manager
  • Undo worker and transaction rollback
  • Cleaning up orphaned files using undo logs

本文主要讨论第一个patch 的逻辑和一些具体实现。在该patch 的简介中,介绍该patch 实现了一个undo log 的管理子系统,解决了以下几个问题:

  • 多个并发后台进程下和logs 一样高效地undo data 空间申请,。
  • 对无效的undo data 和queue 一样高效地清理。
  • 对undo data 和relation 一样高效地随机读访问。

值得注意的是,在这几个patch 的具体实现中,只涉及到了undo log 的管理控制机制和文件读写接口,但是并没有涉及到undo log 的具体内容和具体访问方法,这部分的内容由其上层的client code 来具体设计,例如zheap。

我们接下来主要分析上文说到的该patch 解决的三个问题。

物理存储结构

在该patch 中,undo log 被存储在$PGDATA/base/undo 下。另外用户可以设的GUC 参数undo_tablespaces 将对应undo log 保存在其他的tablespace 路径/undo 下。

undo log 文件名按照xxxxxx.xxxxxxxxxx 格式命名,其中每一位都是16进制数字。和redo log 相同,undo log 每个记录是由64 位的数字来表示其位点,对应去掉“.”的文件名。其中前24 位表示undo log 虚拟文件号,对应文件名的前6个数字。多个undo log 虚拟文件号可以允许undo log 的空间并行分配,减少锁的冲突。而后面的40位表示undo log 的文件内偏移,所以每个undo log 文件最大为1TB。

每个undo log 虚拟文件是由多个segment 组成,每个segment 目前是由128 个undo log 数据页面组成,默认大小为128 * 8kB,即1MB。特别注意:在文件系统中,每个segment 是一个实际的文件,而undo log 虚拟文件是一个虚拟的文件概念。

因为每个连接都可能写入undo log,为了对undo log 进行管理,在共享内存中分配了一块共享内存结构来存储undo log 的管理信息。这块共享内存分为2部分:

  • UndoLogSharedData ,结构如下:
  • UndoLogNumSlots 个 UndoLogControl ,结构如下:

其中UndoLogNumSlots 目前取值为MaxBackends * 4,决定了当前多少个undo log 可以同时活跃状态,这也直接限制了最大(造成数据变化)的事务数。

每个undo log 虚拟文件的元信息如果有变化会在checkpoint 时刷到磁盘上保证其持久化,这样在数据库崩溃恢复的时候能够提供足够的信息。

每个backend 启动时都会生成自己的MyUndoLogState 结构,保存了上述共享内存UndoLogSharedData 的指针,同时都会维护一个hash 表,可以映射undo log 虚拟文件号和对应的UndoLogControl,而且还保存了该undo log 虚拟文件的tablespace 和status。

undo log 空间申请

这部分的逻辑在UndoRecPtr UndoLogAllocate(size_t size, UndoPersistence persistence) 函数中,其参数size 为申请的空间大小,persistence 为申请的具体持久化级别(包含UNDO_PERMANENT,UNDO_UNLOGGED,UNDO_TEMP与relation data 的持久化级别对应),返回一个undo log 的可插入位点,其具体的流程可以概括为:

  1. 从MyUndoLogState 中获取当前该持久化级别的undo log 虚拟文件的控制信息UndoLogControl,如果为空或者tablespace 变化了,则需要去申请新的undo log 虚拟文件并填充它的控制信息:

    a. 确认tablespace,persistence 等信息,从共享内存的UndoLogSharedData中找到对应的空闲undo log 虚拟文件号。 b. 如果当前有空闲的undo log 虚拟文件号,则会通过上文的hash 表获取对应的UndoLogControl 控制信息。如果hash 表中未找到,则通过遍历UndoLogSharedData 中的logs 来获取对应的UndoLogControl 控制信息,并且将undo log 虚拟文件号和UndoLogControl 的映射关系存储在上文的hash 表中,便于下次检索。这步会去比对tablespace,persistence 等信息,很可能虽然有空闲的undo log 文件,但其tablespace,persistence属性不对。 c. 如果未找到期望的undo log 虚拟文件,则需要分配最近最新的undo log 虚拟文件,同时更新其元信息。插入一条xl_undolog_create 类型的WAL 日志记录。下期月报会介绍当前支持undo log 的几种WAL 日志记录类型。 d. 更新获取的undo log 虚拟文件的UndoLogControl 信息,并更新MyUndoLogState。

  2. 如果获取的undo log 的xid 为空,则为undo log 的第一个record,更新其控制信息。当持久化级别为UNDO_PERMANENT时,则插入一条xl_undolog_attach 类型的WAL 日志记录。

  3. 如果当前的segment 不够存储size 大小的记录时:

    a. 如果当前的undo log 虚拟文件(1TB)不足存储size 大小的记录,则将当时的undo log 虚拟文件释放,将undo log 虚拟文件置为空,并重新执行步骤1。 b. 如果当前的undo log 虚拟文件足够存储size 大小的记录,则需要申请一个新的segment。即在文件系统中申请一个数据填充为0 、大小为UndoLogSegmentSize 的新文件。同时插入一条xl_undolog_extend 类型的WAL 日志记录,更新undo log 虚拟文件元信息中的end 位点。

  4. 将得到的undo log 虚拟文件号和insert 位点拼接,返回64位插入位点。

这样,我们就得到了可分配size 空间的undo log 插入位点。从上文可知,通过这个位点我们就可以进行对应的undo log 写操作。不过这里只提供了一个接口,具体实现需要对应的client code 来实现。

可以看出,为了加快undo log 申请空间的速度,这里做了一些设计:

  • 每个后台进程保存undo log 虚拟文件号到 UndoLogControl 的hash 映射。因为在整个空间申请的过程中,需要高频地访问和更新UndoLogControl,增加hash 表的设计将这个过程复杂度变低,更加高效。
  • 锁比较细粒度。很多地方的设计将锁更加细粒度,例如因为WAL 日志中恢复行为的不一致,将undo log 和relation data 一样也分为了不同的持久化级别,并分开进行存储其空闲队列和本地的活跃队列。
  • 可多进程并行批量分配物理空间。根据当前的实现,每个backend 每次可扩容(真正的文件操作)一个segment 大小的空间,即上文的1MB,而且多个backend 可以并行分配。

不过因为该patch 还在讨论中,所以在代码实现中有一些TODO 和有疑问的地方,例如:

  • MaxBackends * 4 个UndoLogControl 控制信息是否足够?
  • 申请新的undo log 虚拟文件时是否需要加锁?

这部分的逻辑是在UndoLogDiscard(UndoRecPtr discard_point, TransactionId xid) 函数中实现,其中discard_point 是要清理的undo log 位点,xid是该undo log 位点对应的xid,具体流程如下:

  1. 获取undo log 虚拟文件对应的UndoLogControl。
  2. 如果undo log 虚拟文件已经写满,并且清理的位点等于当前undo log 虚拟文件的insert 位点,则标记为该undo log 虚拟文件需要全部清理。
  3. 清理buffer pool 中对应的buffers。其中buffers 是从旧的清理位点到现在请求的清理位点之间所有undo log 数据页的buffers。这里需要注意的是其中不包含新清理位点所在的undo log 数据页(可能仍然包含数据),但是如果该undo log 虚拟文件满了,则包含新位点所在的undo log 数据页,即该undo log 虚拟文件最后一个segment 的最后一个数据页。其中这个过程需要对每个undo log 数据页执行以下的步骤:

  4. 如果是旧清理位点和新清理位点跨越了segment,需要涉及到文件的操作,则需要对新旧清理位点之间的所有的segments 文件进行如下操作:

    a. 告知undofile_sync 该文件可能会丢失,同时将该文件的fsync 请求发送给checkpoint。 b. 如果该segment 可以循环(当前undo log 虚拟文件没有空闲的segment 并且该undo log 虚拟文件是活跃状态),则将该segment 文件重命名,同时更新undo log 虚拟文件的end 位点;否则对该segment 文件执行unlink 操作。

  5. 插入一条xl_undolog_discard 类型的WAL 日志记录。如果是步骤4中跨越了segment 的情况,则必须要将WAL 日志flush 到磁盘,保证WAL 日志在数据落盘之前落盘。

  6. 当需要对undo log 全部清理的时候,需要将UndoLogSharedData 中对应的UndoLogControl slot 释放。

从这部分的实现上,我们可以看出:

  • undo log 可以批量进行清理。
  • undo log segment 文件可以复用。
  • 无需保证对文件的修改立刻生效(fsync),只需要记录到WAL 日志中即可。

这无疑会使得清理比较高效,但是当前的实现也存在一些问题:

  • 必须保证新的清理位点之前的undo log 数据没有任何的进程将会访问。
  • 当前每次回收只能回收一个segment。

undo log 的读写

其实目前并没有实现undo log 文件基于buffer pool 的读写,根据作者的说明,这部分需要client code 自己去实现。但是该patch 实现了SMGR 兼容的读写访问接口,这就使得使用buffer pool 访问undo log 成为可能。从undo log 的清理中也可以看出,清理的时候会去清理buffer pool 中的undo log 数据页。

而为了实现SMGR 兼容的读写访问接口:

  • 在smgr 中增加undo log 的存储接口函数定义和具体实现。
  • 将一个undo log 数据页转换为和relation data 数据页相同的唯一访问标示。

我们知道relation data 文件可以由RelFileNode 结构唯一标示其物理位置,即RelFileNode 保存着访问具体文件的物理位置的所有信息,其结构如下:

而根据上文的定义,undo log 也是具有tablespace 属性的,所以可以对应RelFileNode 结构中的spcNode,而undo log 虚拟文件号可以对应relNode。其实根据这两个就可以唯一标示其文件的物理位置,但是为了区分relation data 和 undo log,把undo log 的dbNode 属性设为固定值9。

这样,undo log 虚拟文件可以使用和relation data 文件相同的结构RelFileNode 来唯一标示其物理位置。而每个relation data 数据页可以通过BufferTag 结构来唯一标示其物理位置,其具体结构如下:

很明显,undo log 数据页也是有blockNum 属性的,而undo log 数据页没有各种fork 的概念,这里把其forkNum 设置为默认值MAIN_FORKNUM。

这样我们就可以使用相同的结构BufferTag 来唯一标示undo log 数据页和relation data 数据页的物理机位置,同时都可以通过该结构来访问共享内存中的buffer pool。

不过值得一提的是,因为undo log segment 文件的fd 的数量可控,所以并没有使用vfd 的技术。

可以看出,因为undo log 中本质上存储的是旧的数据,所以在实现上也是尽量把undo log 作为一种数据来管理,包括:

  • 可以使用buffer pool。
  • 每次数据变更(这里主要指文件相关的操作)前都需要先写WAL 日志。
  • checkpoint 会定期fsync undo log 文件的变更。

至此,我们基本摸清了undo log 的存储管理相关的实现,但是未涉及与checkpoint 的联动、对应WAL 日志的内容以及对应的redo 操作(即如何崩溃恢复)。由于篇幅限制,这些内容将在下期月报进行分享。