Author: 卓刀
PostgreSQL 数据库通过采用保存旧的元组来实现MVCC 机制,这就容易造成数据的膨胀。数据的膨胀不仅增加了存储的空间,而且可能降低查询的性能。为了解决这个问题,PostgreSQL 开源社区进行了一系列的讨论。
目前来看,社区内部比较认可的解决方案是构建一种新的存储格式,例如zheap。在EnterpriseDB 公司(后文简称为EDB)的对外分享中,也解释了为什么需要一种新的存储格式:
而zheap 这种新的存储格式致力于:
为了最终将zheap 这个新存储格式对应代码合入到社区主干,当前EDB 计划分几步走:
其中的undo log 就是我们本文讨论的重点,目前该特性已经在社区的邮件列表中(邮件列表详见链接)提交了POC,同时引发了社区广泛的讨论。
但是为什么需要undo log 呢?目前来看,有以下几个可见的收益:
而从邮件列表中,我们可以看出社区对undo log 相关的讨论可以分成三个patch:
本文主要讨论第一个patch 的逻辑和一些具体实现。在该patch 的简介中,介绍该patch 实现了一个undo log 的管理子系统,解决了以下几个问题:
值得注意的是,在这几个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部分:
typedef struct UndoLogSharedData
{
UndoLogNumber free_lists[UndoPersistenceLevels];
UndoLogNumber low_logno; /* the lowest logno */
UndoLogNumber next_logno; /* one past the highest logno */
UndoLogNumber array_size; /* how many UndoLogControl objects do we have? */
UndoLogControl logs[FLEXIBLE_ARRAY_MEMBER];
} UndoLogSharedData;
typedef struct UndoLogControl
{
/*
* Protected by UndoLogLock and 'mutex'. Both must be held to steal this
* slot for another undolog. Either may be held to prevent that from
* happening.
*/
UndoLogNumber logno; /* InvalidUndoLogNumber for unused slots */
/* Protected by UndoLogLock. */
UndoLogNumber next_free; /* link for active unattached undo logs */
/* Protected by 'mutex'. */
LWLock mutex;
UndoLogMetaData meta; /* current meta-data */
XLogRecPtr lsn;
bool need_attach_wal_record; /* need_attach_wal_record */
pid_t pid; /* InvalidPid for unattached */
TransactionId xid;
/* Protected by 'discard_lock'. State used by undo workers. */
LWLock discard_lock; /* prevents discarding while reading */
TransactionId oldest_xid; /* cache of oldest transaction's xid */
uint32 oldest_xidepoch;
UndoRecPtr oldest_data;
} UndoLogControl;
其中UndoLogNumSlots 目前取值为MaxBackends * 4,决定了当前多少个undo log 可以同时活跃状态,这也直接限制了最大(造成数据变化)的事务数。
UndoLogControl 结构中meta 是存储的对应undo log 虚拟文件的元信息,结构如下:
typedef struct UndoLogMetaData
{
UndoLogNumber logno;
UndoLogStatus status;
Oid tablespace;
UndoPersistence persistence; /* permanent, unlogged, temp? */
UndoLogOffset insert; /* next insertion point (head) */
UndoLogOffset end; /* one past end of highest segment */
UndoLogOffset discard; /* oldest data needed (tail) */
UndoLogOffset last_xact_start; /* last transactions start undo offset */
/*
* If the same transaction is split over two undo logs then it stored the
* previous log number, see file header comments of undorecord.c for its
* usage.
*
* Fixme: See if we can find other way to handle it instead of keeping
* previous log number.
*/
UndoLogNumber prevlogno; /* Previous undo log number */
bool is_first_rec;
/*
* last undo record's length. We need to save this in undo meta and WAL
* log so that the value can be preserved across restart so that the first
* undo record after the restart can get this value properly. This will be
* used going to the previous record of the transaction during rollback.
* In case the transaction have done some operation before checkpoint and
* remaining after checkpoint in such case if we can't get the previous
* record prevlen which which before checkpoint we can not properly
* rollback. And, undo worker is also fetch this value when rolling back
* the last transaction in the undo log for locating the last undo record
* of the transaction.
*/
uint16 prevlen;
} UndoLogMetaData;
每个undo log 虚拟文件的元信息如果有变化会在checkpoint 时刷到磁盘上保证其持久化,这样在数据库崩溃恢复的时候能够提供足够的信息。
每个backend 启动时都会生成自己的MyUndoLogState 结构,保存了上述共享内存UndoLogSharedData 的指针,同时都会维护一个hash 表,可以映射undo log 虚拟文件号和对应的UndoLogControl,而且还保存了该undo log 虚拟文件的tablespace 和status。
这部分的逻辑在UndoRecPtr UndoLogAllocate(size_t size, UndoPersistence persistence) 函数中,其参数size 为申请的空间大小,persistence 为申请的具体持久化级别(包含UNDO_PERMANENT,UNDO_UNLOGGED,UNDO_TEMP与relation data 的持久化级别对应),返回一个undo log 的可插入位点,其具体的流程可以概括为:
从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。
如果当前的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 位点。
这样,我们就得到了可分配size 空间的undo log 插入位点。从上文可知,通过这个位点我们就可以进行对应的undo log 写操作。不过这里只提供了一个接口,具体实现需要对应的client code 来实现。
可以看出,为了加快undo log 申请空间的速度,这里做了一些设计:
不过因为该patch 还在讨论中,所以在代码实现中有一些TODO 和有疑问的地方,例如:
这部分的逻辑是在UndoLogDiscard(UndoRecPtr discard_point, TransactionId xid) 函数中实现,其中discard_point 是要清理的undo log 位点,xid是该undo log 位点对应的xid,具体流程如下:
清理buffer pool 中对应的buffers。其中buffers 是从旧的清理位点到现在请求的清理位点之间所有undo log 数据页的buffers。这里需要注意的是其中不包含新清理位点所在的undo log 数据页(可能仍然包含数据),但是如果该undo log 虚拟文件满了,则包含新位点所在的undo log 数据页,即该undo log 虚拟文件最后一个segment 的最后一个数据页。其中这个过程需要对每个undo log 数据页执行以下的步骤:
a. 根据提供的信息构造buffer tag(undo log 的读写小节中会具体介绍)。 b. 根据buffer tag 在缓存hash 表中搜索该undo log 数据页,如果搜索到,则释放对应的缓存描述符,并将对应buffer 置为free,否则直接返回。
如果是旧清理位点和新清理位点跨越了segment,需要涉及到文件的操作,则需要对新旧清理位点之间的所有的segments 文件进行如下操作:
a. 告知undofile_sync 该文件可能会丢失,同时将该文件的fsync 请求发送给checkpoint。 b. 如果该segment 可以循环(当前undo log 虚拟文件没有空闲的segment 并且该undo log 虚拟文件是活跃状态),则将该segment 文件重命名,同时更新undo log 虚拟文件的end 位点;否则对该segment 文件执行unlink 操作。
从这部分的实现上,我们可以看出:
这无疑会使得清理比较高效,但是当前的实现也存在一些问题:
其实目前并没有实现undo log 文件基于buffer pool 的读写,根据作者的说明,这部分需要client code 自己去实现。但是该patch 实现了SMGR 兼容的读写访问接口,这就使得使用buffer pool 访问undo log 成为可能。从undo log 的清理中也可以看出,清理的时候会去清理buffer pool 中的undo log 数据页。
而为了实现SMGR 兼容的读写访问接口:
我们知道relation data 文件可以由RelFileNode 结构唯一标示其物理位置,即RelFileNode 保存着访问具体文件的物理位置的所有信息,其结构如下:
typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} 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 结构来唯一标示其物理位置,其具体结构如下:
typedef struct buftag
{
RelFileNode rnode; /* physical relation identifier */
ForkNumber forkNum;
BlockNumber blockNum; /* blknum relative to begin of reln */
} 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 作为一种数据来管理,包括:
这样能尽量复用原来的一些实现,而且相对来说也比较符合undo log 的定义。
至此,我们基本摸清了undo log 的存储管理相关的实现,但是未涉及与checkpoint 的联动、对应WAL 日志的内容以及对应的redo 操作(即如何崩溃恢复)。由于篇幅限制,这些内容将在下期月报进行分享。