背景
在操作系统中,当一个进程创建或是打开一个文件时,操作系统会为该文件分配一个唯一文件描述符(或者叫文件句柄),并通过该文件描述符来唯一标识和操作该文件。由于每个操作系统都对一个进程能打开的文件数加以限制,因此进程能获得的文件描述符是有限的。对于经常需要打开许多文件的数据库进程来说,很容易就会超过操作系统对于文件描述符数量的限制。为了解决这个问题,在PostgreSQL中使用了虚拟文件描述符(VFD)机制,并实现了相应的管理机制。
一、Vfd数据结构
在PostgreSQL中,一个进程打开的VFD都是存储在VfdCache数组当中,该数组的每一个元素都表示该进程拥有的一个虚拟文件描述符,其数据类型为Vfd。
typedef struct vfd
{
int fd;
unsigned short fdstate;
SubTransactionID create_subid;
File nextFree;
File lruMoreRecently;
File lruMoreRecently;
off_t seekPos;
char filieName;
int fileFlags;
int fileMode;
}vfd;
Vfd的每个字段意义如下:
fd记录该VFD所对应的真实文件描述符。如果当前VFD没有打开文件描述符,则其值为VFD_CLOSED(VFD_CLOSED=-1)。
fdstate表示该虚拟文件描述符的标记位:
①如果它的第0位置1,即为 FD_DIRTY,表明该文件的内容已被修改过,但还没有被写回到磁盘,则在关闭此文件时要将该文件同步到磁盘里;
②如果它的第1位置1,即为FD_TEMPORARY,表明该文件在关闭时要被删除。
nextFree指向下一个空闲的VFD,其数据类型File就是一个整数,表示 VFD在VFD数组中的下标。
lruMoreRecently指向比该VFD最近更常用的虚拟文件描述符。
lruLessRecently指向比该VFD最近更不常用的虚拟文件描述符。
seekPos记录该VFD的当前读写指针的位置。
fileName表示该VFD对应文件的文件名,如果是空闲的VFD,则fileName为空值。
fileFlags表示该文件打开时标志,包括只读、只写、读写等。
fileMode表示文件创建时所指定模式。
二、LRU缓存池
在每一个 PostgreSQL后台进程中都使用一个LRU(Last Recently Used,最近最少使用)池来管理所有已经打开的VFD,池中每个VFD都对应一个物理上已经打开的文件。每一个进程都拥有其私有的 LRU 池和一系列的VFD,进程需要打开文件时都是从自己私有的LRU池中申请VFD。
当LRU 池未满时,即进程打开的文件个数未超过系统限制时,进程可以照常申请一个VFD用来打开一个物理文件;而当LRU池已满的时候,进程需要首先关闭一个VFD,这样打开新的文件时就不会因为超出操作系统限制而造成不可预料的错误。在LRU池中,使用替换最长时间未使用VFD 的策略。
事实上,进程在VfdCache上保持了两个链表,一个是LRU池(双向链表),另一个是FreeList(空闲链表,记录了所有可被分配的VFD)。前者通过Vfd数据结构的lruMoreRecently属性和lruLessRecently属性来链接,后者则通过Vfd数据结构的nextFree属性来链接。而VfdCache[0]不参与VFD分配,它仅用来标识FreeList和LRU池的链表头部。
当进程需要打开一个文件时,将会为其分配一个VFD;而关闭文件时则会回收VFD。VFD的分配和回收流程如下:
-
进程在打开第一个文件时,将初始化VfdCache数组,置其大小为32,为其中每一个Vfd结构分配内存空间,将Vfd结构中的fd字段置为VFD_CLOSED,并将所有的数组元素放在 FreeList上。
-
分配一个VFD,即从FreeList头取一个VFD,并打开该文件,将该文件的相关信息(包括真实文件描述符、文件名、各种标志等)记录在分配的VFD中。
-
若FreeList上没有空闲VFD,则将VfdCache数组扩大一倍,新增加的VFD放入FreeList链表中。
-
关闭文件时,将该文件所对应的VFD插入到FreeList的头部。
进程获得VFD之后,需要检査LRU池是否已满,也就是检査当前进程所打开的物理文件个数是否已经达到了操作系统的限制。如果没有超过限制,进程可以使用该VFD打开物理文件并将其插入到LRU池中;否则需要用到接下来要介绍的LRU池替换算法,先关闭一个VFD及其对应的物理文件,然后再使用获得的VFD来打开物理文件。在介绍LRU池替换算法之前先介绍一下LRU池的组织方式。
PostgreSQL将一个进程当前正打开(所谓当前正打开是指操作系统当前确实为该文件分配了真实文件描述符)的所有文件的 VFD都链成一个环,即LRU池,如图所示。
图中的每个方框代表一个VFD,即一个VfdCache数组元素,方框里的符号表示该VFD在数组中的下标。在LRU池中,每一个VFD都通过指针链接着两个VFD,通过指针lruMoreRecently链接的是最近更常用的VFD,通过指针lruLessRecently链接的是最近更不常用的VFD。例如中 a 2 a_2 a2链接着 a 1 a_1 a1和 a 3 a_3 a3。LRU池中有一个特殊的VFD和两个特殊的链接,VfdCachec充当了LRU链头部的作用,它永远不会被实际分配给任何文件。一个特殊链接是LRU链末尾的VfdCache [ a n ] [a_n] [an]通过lruLessRecently链接到VfdCache[0],另一个特殊链接是VfdCache[0]通过lruMoreRecently链接到VfdCache [ a n ] [a_n] [an],这样系统可以通过VfdCache[0]的lruLessRecently值找到最近最少使用的文件的 VFD。显然,LRU池的大小与操作系统对于进程打开文件数的限制是一致的,在PostgreSQL的实现中用全局变量max_safe_fds来记录该限制数,在Postmaster进程的启动过程中将会调用set_max_safe_fds函数来检测操作系统限制,并设置max_safe_fds的值。
三、LRU池中Vfd的操作
对LRU池里的VFD的主要操作包括以下三种:
1、从 LRU 池删除 VFD
static void Delete(File file)
{
Vfd vfdP;
Assert(file != 0);
DO_DB(elog(LOG, Delete %d (%s), file, VfdCache[file].fileName));
DO_DB(_dump_lru());
vfdP = &VfdCache[file];
VfdCache[vfdP-lruLessRecently].lruMoreRecently = vfdP-lruMoreRecently; 将前一个节点的 lruMoreRecently 字段设置为当前节点的 lruMoreRecently
VfdCache[vfdP-lruMoreRecently].lruLessRecently = vfdP-lruLessRecently; 将后一个节点的 lruLessRecently 字段设置为当前节点的 lruLessRecently
DO_DB(_dump_lru());
}
static void LruDelete(File file)
{
Vfd vfdP;
Assert(file != 0);
DO_DB(elog(LOG, LruDelete %d (%s), file, VfdCache[file].fileName));
vfdP = &VfdCache[file];
Close the file. We aren't expecting this to fail; if it does, better
to leak the FD than to mess up our internal state.
if (close(vfdP-fd) != 0)
elog(vfdP-fdstate & FD_TEMP_FILE_LIMIT LOG data_sync_elevel(LOG),
could not close file %s %m, vfdP-fileName);
vfdP-fd = VFD_CLOSED;
--nfile;
delete the vfd record from the LRU ring
Delete(file);
}
该操作发生在进程使用完一个文件并关闭它时,通过LruDelete函数实现。该操作将指定的VFD从LRU池中删除,并将该VFD对应的文件关闭掉。具体来说,首先根据file从VfdCache数组中找到其对应的VFD结构体vfdp,接着调用系统函数关闭物理文件,将vfcp-fd设置为VFD_CLOSED,递减进程私有的全局VFD计数器。最后调用Delete函数进行真正的删除操作。
2、将VFD插人 LRU 池
static void Insert(File file)
{
Vfd vfdP;
Assert(file != 0);
DO_DB(elog(LOG, Insert %d (%s), file, VfdCache[file].fileName));
DO_DB(_dump_lru());
vfdP = &VfdCache[file];
vfdP-lruMoreRecently = 0; 假设链表头部没有更近的节点
vfdP-lruLessRecently = VfdCache[0].lruLessRecently; 将vfdP的lruLessRecently指向当前链表头部的lruLessRecently
VfdCache[0].lruLessRecently = file; 更新当前链表头部的lruLessRecently,使其指向vfdP
VfdCache[vfdP-lruLessRecently].lruMoreRecently = file; 更新vfdP的lruLessRecently指向的元素的lruMoreRecently,使其指向vfdP
DO_DB(_dump_lru());
}
static int LruInsert(File file)
{
Vfd vfdP;
Assert(file != 0);
DO_DB(elog(LOG, LruInsert %d (%s), file, VfdCache[file].fileName));
vfdP = &VfdCache[file];
if (FileIsNotOpen(file))
{
Close excess kernel FDs.
ReleaseLruFiles();
The open could still fail for lack of file descriptors, eg due to
overall system file table being full. So, be prepared to release
another FD if necessary...
vfdP-fd = BasicOpenFilePerm(vfdP-fileName, vfdP-fileFlags, vfdP-fileMode);
if (vfdP-fd 0)
{
DO_DB(elog(LOG, re-open failed %m));
return -1;
}
else
{
++nfile;
}
}
put it at the head of the Lru ring
Insert(file);
return 0;
}
该操作发生在打开一个新的VFD时,通过LruInsert函数实现。首先找到file对应的VFD在VfdCache中的位置,若文件尚未打开,先进行安全性检查,尝试关闭一些文件确保此时的文件描述符个数不得超过系统所支持的最大安全数目。接着调用BasicOpenFilePerm打开文件,递增进程私有的全局VFD计数器。最后调用Insert函数进行真正的插入操作。
3、删除LRU池尾的VFD
static bool
ReleaseLruFile(void)
{
DO_DB(elog(LOG, ReleaseLruFile. Opened %d, nfile));
if (nfile 0)
{
There are opened files and so there should be at least one used vfd
in the ring.
Assert(VfdCache[0].lruMoreRecently != 0); 头部节点的lruMoreRecently不为0
LruDelete(VfdCache[0].lruMoreRecently); 删除关闭最近最少使用的Vfd
return true; freed a file
}
return false; no files available to free
}
当LRU 池已满而此时又要打开新的文件时,就需要执行ReleaseLruFile 操作,将池中末尾的VFD(最少使用的VFD)删掉,这样新打开的VFD就可以插人到LRU 中。注意,这里被删除的VFD仅仅只是从 LRU 池中脱链并关闭其对应的物理文件,VFD结构本身并不做其他修改和删除。因为进程后面的操作还可能会用到该VFD所对应的物理文件。当再次需要访问一个LRU池之外的VFD时,需要先根据VFD中记录的文件打开标志打开其对应的物理文件,然后根据VFD中记录的读写指针位置将物理文件描述符的读写指针移动到正确的位置,最后还要把该VFD重新插人到LRU池中。
作者介绍
殷子婷 移动云数据库工程师,负责云原生数据库He3DB的研发