暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

PolarDB PostgreSQL 版虚拟文件描述符

原创 PolarDB-PG 2024-09-23
344

背景

PostgreSQL 后端进程经常需要打开大量的文件,包括表文件、索引文件、临时文件(用于排序或构造 hash 表)等。由于操作系统允许一个进程能够打开的文件数量是有上限的,为了防止后端进程打开文件时因超出 OS 的限制而失败,PostgreSQL 提供了 虚拟文件描述符 (VFD) 机制。VFD 抽象层能够对使用 VFD API 的代码屏蔽对 OS 文件描述符的管理细节,使更高层的代码能够打开比 OS 限制的数量更多的文件。

设计

VFD 机制最核心的设计,就是使用一个 LRU (Least-Recently-Used) 缓存池来管理当前进程所有的 操作系统文件描述符,同时对更上层的代码暴露出 虚拟文件描述符 (Virtual File Descriptor) 供使用。理想情况下,除了 VFD 的内部实现,其它部分内核代码不应该直接调用 C 库函数去操作文件。当 PostgreSQL 后端进程需要打开一个文件,且此时进程已经打开的文件数量将要超过 OS 允许的最大数量时,VFD 从其管理的 OS 文件描述符中选出最近最久未被使用的文件描述符并关闭它,此时打开这个文件就不会被 OS 拒绝。这个过程对使用 VFD API 的更高层代码来说是无感知的,仿佛可以不受数量限制地打开文件一样。

虚拟文件描述符

PostgreSQL 中,所有的 VFD 在物理上被组织成一个数组。VfdCache 是指向这个数组起始位置的指针,SizeVfdCache 保存这个数组的长度。这个数组的长度会随着对 VFD 需求量的增加而动态扩容。

nfile 变量记录了 VFD 数组中到底管理了多少个操作系统的文件描述符,这样 VFD 机制才能在打开的文件数量即将超出 OS 限制时,关闭最近最久未被使用的文件描述符。

/*
 * Virtual File Descriptor array pointer and size.  This grows as
 * needed.  'File' values are indexes into this array.
 * Note that VfdCache[0] is not a usable VFD, just a list header.
 */
static Vfd *VfdCache;
static Size SizeVfdCache = 0;

/*
 * Number of file descriptors known to be in use by VFD entries.
 */
static int  nfile = 0;

虚拟文件描述符的结构定义如下:

typedef struct vfd
{
    int         fd;             /* current FD, or VFD_CLOSED if none */
    unsigned short fdstate;     /* bitflags for VFD's state */
    ResourceOwner resowner;     /* owner, for automatic cleanup */
    File        nextFree;       /* link to next free VFD, if in freelist */
    File        lruMoreRecently;    /* doubly linked recency-of-use list */
    File        lruLessRecently;
    off_t       fileSize;       /* current size of file (0 if not temporary) */
    char       *fileName;       /* name of file, or NULL for unused VFD */
    /* NB: fileName is malloc'd, and must be free'd when closing the VFD */
    int         fileFlags;      /* open(2) flags for (re)opening the file */
    mode_t      fileMode;       /* mode to pass to open(2) */
} Vfd;

其中包括了 VFD 的状态信息:

  • fd 用于保存真正的 OS 文件描述符,如果未使用,那么将会被设置为 VFD_CLOSED

  • fdstate 保存了 VFD 的状态标志位

  • resowner 表示这个 VFD 的持有者,方便后续的自动清理

此外,还包括 VFD 数组的管理信息。整个 VFD 数组被组织为两部分:LRU 池和空闲 VFD 列表。

LRU 池在逻辑上是一个双向链表,管理了所有正在持有 OS 文件描述符的 VFD。当一个 VFD 被使用后,它就会从 LRU 链表中移动到队头;此时 LRU 双向链表的尾部就是最近最久未被使用过的那个 VFD。

空闲 VFD 列表在逻辑上是一个单向链表。所有未被使用的 VFD 都会被串联在这个单链表中。被使用完毕释放的 VFD 也会被串回这个链表中。

上述两个部分虽然在逻辑上是双向或单向链表,但在形态上还是存放在 VFD 数组中,通过保存以下三个数组下标来代替链表本应该拥有的指针:

  • nextFree 是下一个空闲 VFD 在数组中的下标,用于串联空闲 VFD 列表

  • lruMoreRecently 是后一个被使用过的 VFD 在数组中的下标

  • lruLessRecently 是前一个被使用过的 VFD 在数组中的下标

后两者相当于双向链表中的 prevnext 指针,用于串联 LRU 池。VFD 数组中的第一个元素 VfdCache[0] 永远不使用,该元素中的空间将会被分别作为两个链表的头指针。

最后剩下的几个信息,是真正向操作系统打开文件时传入的参数:

  • fileSize 表示文件大小

  • fileName 表示文件名

  • fileFlags 表示打开文件时的标志位

  • fileMode 表示打开文件时的模式(权限)

低层函数

LruInsert / Insert

LruInsert() 函数会向 OS 申请真正打开 VFD 所对应的 OS 文件描述符,然后把这个 VFD 添加到 LRU 池的队头。LRU 池中的 VFD 是真正持有 OS 文件描述符的 VFD。把 VFD 移动到 LRU 池的队头是通过 Insert() 函数实现的,它会修改 VFD 的 LRU 双向链表指针。

LruDelete / Delete

LruDelete() 函数会真正释放 VFD 所持有的 OS 文件描述符,然后将 VFD 从 LRU 池中移除,因为这个 VFD 已经不再持有 OS 文件描述符了。移除动作是由 Delete() 函数实现的,它负责修改 VFD 的 LRU 指针,重新串联这个 VFD 之前和之后的 VFD。

ReleaseLruFile / ReleaseLruFiles

这两个函数负责不断关闭 LRU 池中最近最久未被使用的 VFD 所持有的 OS 文件描述符,直到将 LRU 池中的 VFD 数量控制到 OS 允许的安全范围以下。具体的实现方式就是调用上面的 LruDelete() 来关闭 OS 文件描述符并从 LRU 池中移除。

AllocateVfd / FreeVfd

这两个函数负责在 VFD 数组中占用一个空闲的 VFD(但并不打开底层的 OS 文件),以及归还 VFD。

在分配 VFD 时,如果 VFD 数组的空闲链表已经为空,那么就需要使用 realloc() 重新分配一个更大的 VFD 数组(通常是原 VFD 数组长度的两倍),并把新分配数组的后一半 VFD 初始化到空闲链表中以备未来使用。然后从空闲链表中摘下一个 VFD 并返回其下标。

归还 VFD 的过程很直接:将 VFD 恢复为初始状态,然后将其重新放回空闲链表中。

BasicOpenFile:Open 系统调用的替代者

这个函数封装了传统的 open() 系统调用。理论上,PostgreSQL 内核的其它部分不应再直接使用 open() 系统调用。这个函数将会返回一个裸的 OS 文件描述符,而不是 VFD——所以调用这个函数的代码需要保证这个文件描述符不会被泄露。

该函数内部真正调用了 open() 来获取一个 OS 文件描述符。如果失败,那么将会试图从 LRU 池中删除一个已有的文件描述符,然后再度重试,直到成功为止。

FileAccess:访问文件

每个高层文件访问接口都会调用 FileAccess()。调用这个函数意味着 VFD 持有的 OS 文件描述符将要被使用。那么:

  1. 如果这个文件在 OS 层面还没有被打开,那么调用 LruInsert() 打开文件并将 VFD 插入 LRU 池

  2. 如果这个文件已被打开,那么先将 VFD 从 LRU 池中移除,然后将 VFD 插入到 LRU 池的队头,表示这个 VFD 最近刚刚被访问

高层文件操作接口

打开文件

PathNameOpenFilePerm() 将会根据参数打开文件,并返回一个 VFD。经历了以下步骤:

  1. 调用 AllocateVfd() 从 VFD 数组中拿到一个空闲 VFD 并初始化

  2. 调用 ReleaseLruFiles() 把 LRU 池中的 VFD 减少到操作系统允许的水平

  3. 调用 BasicOpenFilePerm() 打开一个 OS 文件描述符,并关联到新分配的 VFD 上

  4. 调用 Insert() 把新分配的 VFD 添加到 LRU 池中

  5. 返回新分配的 VFD

关闭文件

FileClose() 将会关闭一个 VFD 所对应的一切:

  1. 如果 VFD 对应的 OS 文件描述符已被打开,那么调用 close() 关掉它,然后调用 Delete() 从 LRU 池里移除这个 VFD

  2. 如果 VFD 对应的文件被设置了 关闭时删除 的标志(临时文件),那么调用 unlink() 删掉它!

  3. 调用 FreeVfd() 清空这个 VFD 并重新归还到 VFD 数组的空闲链表中

其它文件操作

除去文件的打开与关闭以外,其它文件操作需要基于 文件已被打开 的假设进行。这些文件操作都被 PostgreSQL 内核做了一层封装。在进行真正的文件操作之前,需要先使用打开文件后持有的 VFD 调用一次 FileAccess() 函数。这个函数能够保证:

  1. VFD 内部持有的 OS 文件描述符已经打开(如果没打开,那就立刻打开,或许会导致其它 OS 文件描述符被关闭)

  2. VFD 在 LRU 池中的位置移动到队头,因为这个 VFD 对应的 OS 文件描述符最近被使用了

以文件库函数 read() 的包装 FileRead() 为例:

int
FileRead(File file, char *buffer, int amount, off_t offset,
         uint32 wait_event_info)
{
    int         returnCode;
    Vfd        *vfdP;

    Assert(FileIsValid(file));

    DO_DB(elog(LOG, "FileRead: %d (%s) " INT64_FORMAT " %d %p",
               file, VfdCache[file].fileName,
               (int64) offset,
               amount, buffer));

    returnCode = FileAccess(file);
    if (returnCode < 0)
        return returnCode;

    vfdP = &VfdCache[file];

retry:
    pgstat_report_wait_start(wait_event_info);
    returnCode = pread(vfdP->fd, buffer, amount, offset);
    pgstat_report_wait_end();

    if (returnCode < 0)
    {
        /* OK to retry if interrupted */
        if (errno == EINTR)
            goto retry;
    }

    return returnCode;
}

总结

PostgreSQL 内核中的 VFD 机制用于防止 PostgreSQL 后端进程受 OS 对进程打开文件数量的限制。VFD 内部维护了一个 LRU 池来管理所有被打开的 OS 文件描述符。使用 VFD 的高层接口来操作文件,就可以享受到 VFD 为我们屏蔽掉的文件描述符管理所带来的的便利。

VFD 的实现思想与操作系统的进程调度有些类似。OS 上的进程有成百上千个,而 CPU 只有一个(或几个)。从使用者的角度看,这些进程似乎都在同时执行,只有 OS 知道每一个时刻只有一个进程在一个 CPU 核心上运行;在 PostgreSQL 中,类似地,从 VFD 使用者的角度看,似乎能够同时持有远超操作系统数量限制的文件描述符,但只有 VFD 知道,每一个时刻打开的 OS 文件描述符数量必定小于操作系统对进程打开文件数量的限制。

一招瞒天过海。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论