操作系统中一个进程能打开的文件数是受限制的,进程能获得的文件描述符是有限的,对于数据库进程来说,其经常会打开许多文件,很可能会超出操作系统的限制(一般为1024,可以修改)。

postgres@slpc:~$ ulimit -n
1024

为使数据库满足操作系统对进程的文件描述符数限制。可通过配置GUC参数max_files_per_process设置数据库进程(例如后端进程)可同时打开的最大文件数。

-- max_files_per_process默认值是1000
postgres=# show max_files_per_process ;
 max_files_per_process 
-----------------------
 1000
(1 row)

为了解决这个问题,引入了虚拟文件描述符(VFD)机制。VDF采用LRU(最近最少使用)池管理策略,根据需求动态开关实际操作系统文件描述符。在PostgreSQL中,进程打开的VDF都存储在VfdCache数组中,该数组的每一个元素都表示该进程拥有一个虚拟文件描述符。

/*
 * VFD数组根据需要长度会动态扩容
 * VfdCache[0]是不可用的VFD, 仅表示链表头部.
 */
static Vfd *VfdCache;      // 虚拟文件描述符数组
static Size SizeVfdCache = 0;  // VfdCache数组大小
static int	nfile = 0;  // 已打开的真实的物理文件描述符数量

nfile记录了VFD数组中已打开的物理文件描述符数量,当这个数量超过指定的限制时会通过释放掉最近最少使用的VFD(关闭该VFD对应的物理文件进行释放),这样才能打开新的物理文件描述符。

虚拟文件描述符定义:

typedef struct vfd
{
	int			fd;				/* 真实的物理文件描述符, 如果没有打开文件描述符则为 VFD_CLOSED */
	unsigned short fdstate;		/* 虚拟文件描述符的标记位 */
	ResourceOwner resowner;		/* owner, for automatic cleanup */
	File		nextFree;		/* 指向下一个空闲的VFD,其数据类型File是一个整数,表示在VfdCache数组中的索引下标 */
	File		lruMoreRecently;	/* 指向比该VFD更常用的VFD */
	File		lruLessRecently;   /* 指向比该VFD更不常用的VFD */
	off_t		fileSize;		/* 文件大小(0 if not temporary) */
	char	   *fileName;		/* 文件名 or NULL for unused VFD */
	int			fileFlags;		/* 文件打开时的标志,例如只读,只写等 */
	mode_t		fileMode;		/* 文件创建时所指定的模式 */
} Vfd;

每一个进程都拥有其私有的LRU池和一系列的VFD,进程需要打开文件时都需要从LRU池中申请VFD。当LRU池未满时,即进程打开的文件个数未超过系统限制时,进程可以正常申请一个VFD用来打开一个物理文件,而当LRU池已满时,进程需要首先关闭一个VFD,这样打开新的文件时就不会因为超出操作系统限制而造成不可预料的错误。在LRU池中,使用替换最长时间未使用的VFD淘汰策略。

int BasicOpenFilePerm(const char *fileName, int fileFlags, mode_t fileMode)
{
	int			fd;

tryAgain:
	fd = open(fileName, fileFlags, fileMode);  // 系统调用

	if (fd >= 0)
		return fd;				/* success! */

	// EMFILE:Too many open files 打开的文件数超过限制
	// FNFILE: 全局文件表已耗尽,无法再为任何进程分配新的文件描述符
	if (errno == EMFILE || errno == ENFILE)
	{
		int			save_errno = errno;

		ereport(LOG,
				(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
				 errmsg("out of file descriptors: %m; release and retry")));
		errno = 0;
		// 释放LRU池中最近最少使用的VFD
		if (ReleaseLruFile())
			goto tryAgain;
		errno = save_errno;
	}

	return -1;					/* failure */
}

打开文件调用栈:

libc.so.6!__libc_open64(const char * file, int oflag) (open64.c:30)
BasicOpenFilePerm(const char * fileName, int fileFlags, mode_t fileMode) (fd.c:1068)
PathNameOpenFilePerm(const char * fileName, int fileFlags, mode_t fileMode) (fd.c:1529)
PathNameOpenFile(const char * fileName, int fileFlags) (fd.c:1494)
mdopenfork(SMgrRelation reln, ForkNumber forknum, int behavior) (md.c:494)
mdnblocks(SMgrRelation reln, ForkNumber forknum) (md.c:772)
smgrnblocks(SMgrRelation reln, ForkNumber forknum) (smgr.c:557)
table_block_relation_size(Relation rel, ForkNumber forkNumber) (tableam.c:639)
table_relation_size(Relation rel, ForkNumber forkNumber) (tableam.h:1840)
RelationGetNumberOfBlocksInFork(Relation relation, ForkNumber forkNum) (bufmgr.c:2979)

怎么打开一个表文件的不同segment文件?通过_mdfd_openseg函数打开。会返回一个MdfdVec结构体,表示这个段文件的段号和虚拟文件描述符编号(VfdCache数组下标)。

typedef struct _MdfdVec
{
	File		mdfd_vfd;		/* 虚拟文件描述符编号 */
	BlockNumber mdfd_segno;		/* 段文件编号 */
} MdfdVec;

_mdfd_openseg函数实现如下:

static MdfdVec *_mdfd_openseg(SMgrRelation reln, ForkNumber forknum, BlockNumber segno, int oflags)
{
	MdfdVec    *v;
	// 获取段文件的全路径 psprintf("%s.%u", relpath, segno)
	char *fullpath = _mdfd_segpath(reln, forknum, segno);

	/* open the file */
	File fd = PathNameOpenFile(fullpath, O_RDWR | PG_BINARY | oflags);

	pfree(fullpath);

	if (fd < 0)
		return NULL;
	// 调整SMgrRelationData->md_seg_fds数组长度
	// 更新md_num_open_segs值
	_fdvec_resize(reln, forknum, segno + 1); 

	/* fill the entry */
	v = &reln->md_seg_fds[forknum][segno]; 
	v->mdfd_vfd = fd;        // 虚拟文件描述符编号
	v->mdfd_segno = segno;   // 段号

	/* all done */
	return v;
}

怎么遍历表的所有段文件?例如在获取表的页数时,需要知道表有多少个段文件。数据库并没有一个关于段文件数量的元数据,需要根据已打开的segment文件数量,获取最后一个已打开的segment号segno,判断是否为最后一个segment(如果segment的大小小于1G则为最后一个segment),如过不是,则有可能不是最后一个segment,需要继续判断,将segno++,获取下一个segment的大小,直到segment的大小小于1G,则说明是最后一个segment。此时的segno即为段文件数量

VFD机制核心原理:根据需求动态开关实际操作系统文件描述符,实际上一个进程能打开的最大数量能为操作系统的限定值,但是在VFD机制下,当进程申请打开一个文件时,如果LRU已满(达到了进程受限的最大文件描述符数量),则VFD机制会根据LRU策略选择最近最少使用的VFD,关闭该VFD对应的物理文件,释放资源后,再尝试打开新的文件。

比如下面的从文件读操作,在读的时候,会首先执行FileAccess函数重新打开物理文件描述符,因为该物理文件可能因LRU策略而被关闭。在执行FileAccess函数时,可能会因为超出进程最大文件描述符数量而触发LRU淘汰策略,选择一个最近最少使用的VFD文件关闭。

这个关闭不会引起异常,因为PostgreSQL是进程模型,在当前进程下关闭某个物理文件描述符的同时,不会同时发生对该文件的读写等操作。单个postgres进程的某个时刻只能对一个文件新读写,但是这个时刻可以同时存在多个打开的物理文件描述符。

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

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

	returnCode = FileAccess(file);  // 重新获取文件描述符,如果文件描述符因LRU被关闭了
	if (returnCode < 0)
		return returnCode;

	vfdP = &VfdCache[file];

retry:
	pgstat_report_wait_start(wait_event_info);
	returnCode = pg_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;
}

/* returns 0 on success, -1 on re-open failure (with errno set) */
static int FileAccess(File file)
{
	int			returnValue;

	DO_DB(elog(LOG, "FileAccess %d (%s)",
			   file, VfdCache[file].fileName));

	/*
	 * Is the file open?  If not, open it and put it at the head of the LRU
	 * ring (possibly closing the least recently used file to get an FD).
	 */

	if (FileIsNotOpen(file)) // 如果物理文件没有打开
	{
		returnValue = LruInsert(file);  // 打开文件,放人LRU中
		if (returnValue != 0)
			return returnValue;
	}
	else if (VfdCache[0].lruLessRecently != file)
	{ 
		/*
		 * We now know that the file is open and that it is not the last one
		 * accessed, so we need to move it to the head of the Lru ring.
		 */
		// 因最近访问了,更新在LRU中的位置
		Delete(file);   
		Insert(file);
	}

	return 0;
}

参考文档:【PostgreSQL 内核】虚拟文件描述符 VFD