1、什么是文件描述符
在操作系统中,为了高效地管理文件,当进程打开或创建一个文件时,操作系统会为该文件分配一个文件描述符(文件句柄),通过该文件描述符来唯一标识该文件,其本质上就是用来管理文件的索引。
理论上来说,我们的操作系统内存有多少就可以打开多少的文件描述符,但实际上一般最大打开文件数会是系统内存的10%(以KB来计算),我们可以通过sysctl -a | grep fs.file-max命令查看。
同时为了防止单个进程过度使用文件描述符,也会对单个进程进行限制,可以通过ulimit -n查看,一般默认值是1024。
2、VFD概述
如上所述,操作系统中默认单个文件使用的文件描述符为1024,即单个进程最多创建或打开1024个文件。那么问题来了,我们都知道pg的表是通过一个个文件构成,默认单个文件大小是1G,那如果我的一个客户端进行执行了一条SQL,涉及到的表加在一起大小超过1024 * 1G呢?那么我们不就没法执行了吗。
为了解决这个问题,pg通过VFD(Virtual File Description)虚拟文件描述符机制来处理。本质上就是对与底层文件操作的一层封装,通过VFD来避免直接使用文件描述符,从而避免操作系统层面的限制。
VFD有点类似连接池,而VFD是通过一个LRU(Last Recently Used)的缓存池来实现的,通过在LRU池中存放一个个VFD。
其原理大致为:当LRU池未满时,进程可以直接申请VFD来打开一个新的文件;而当LRU池已经满了,会先通过LRU算法将最近最少使用的文件的VFD删除,然后给新的文件使用。
3、PostgreSQL启动与文件描述符
既然pg通过VFD机制可以避免操作系统的ulimit限制,那么是不是我们该参数设置多小都没关系呢?当然不是这样,在我们的数据库启动时,会需要去检查操作系统的资源限制,通过getrlimit()函数去获取相关的参数,而如果我们单个进程可打开的最多进程数过小,会受到类似下面的报错:
2021-06-30 14:37:06.645 CST [22060] FATAL: 53000: insufficient file descriptors available to start server process
2021-06-30 14:37:06.645 CST [22060] DETAIL: System allows 27, we need at least 58.
2021-06-30 14:37:06.645 CST [22060] LOCATION: set_max_safe_fds, fd.c:974
2021-06-30 14:37:06.651 CST [22060] LOG: 00000: database system is shut down
2021-06-30 14:37:06.651 CST [22060] LOCATION: UnlinkLockFiles, miscinit.c:928
这是因为如果我们的postgres进程可使用的文件描述符数量少于10,则会启动失败:
/*
* If we have fewer than this many usable FDs after allowing for the reserved
* ones, choke.
*/
#define FD_MINFREE 10
/*
* Make sure we still have enough to get by.
*/
if (max_safe_fds < FD_MINFREE)
ereport(FATAL,
(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
errmsg("insufficient file descriptors available to start server process"),
errdetail("System allows %d, we need at least %d.",
max_safe_fds + NUM_RESERVED_FDS,
FD_MINFREE + NUM_RESERVED_FDS)));
那么这个max_safe_fds是怎么计算的呢?其计算方法如下:
max_safe_fds = Min(usable_fds, max_files_per_process - already_open)
usable_fds即可使用的fd的数量。其计算流程大致为:首先执行getrlimit()函数,若成功,那么一直调用dup(0)系统函数,直到循环次数大于等于max_files_per_process(默认是1000),或者是dup(0)返回的最小可用文件描述符大于等于资源软限制(默认是1024)时,则结束循环。
除此之外,还需要为system()留下10个fd,即:
#define NUM_RESERVED_FDS 10
max_safe_fds -= NUM_RESERVED_FDS;
最终计算得出的max_safe_fds如果小于10,则启动报错,反之则启动成功,启动成功后则会初始化全局变量 max_safe_fds。
4、VFD机制详解
4.1、VFD结构体
pg中通过vfd结构体来构造链表,进而实现LRU缓存池(其实就是双向链表)。
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 seekPos; /* current logical file position, or -1 */
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 */
int fileMode; /* mode to pass to open(2) */
} Vfd;
- fd:VFD对应的实际的文件系统描述符,如果为VFD_CLOSED即-1,表示没有打开文件
- fdstate:是VFD的标志位:①如果它的第0位置1,即为FD_DIRTY,表明该文件的内容已被修改过,但还没有写回磁盘,在关闭此文件是要将该文件同步到磁盘里。②如果它的第1位置1,即为FD_TEMPORARY,表明该文件是临时文件,需要在关闭时删除
- nextfree:指向下一个空闲的VFD,其数据类型File其实是一个整数(不是<stdio.h>里面的FILE),用来表示VFD在VFD数组中的下标
- lruMoreRecently:指向比该VFD最近更不常用的VFD,这里的More做打开时长更长(即更旧)来理解
- lruLessRecently:指向比该VFD最近更常用的VFD,这里的Less做打开时长更短(即更新)来理解
- fileSize:表示文件大小
- fileName:表示该VFD对应文件的文件名,如果是空闲的VFD,则fileName位空值
- fileFlags:表示该文件打开时的标志,包括只读、只写、读写等
- fileMode:表示文件创建时所指定的模式
4.2、LRU池
我们前面说过,pg通过LRU池来管理VDF,每个VDF都对应一个打开的文件,而每个进程都有自己的LRU池,当该进程需要打开文件时,便从自己的LRU池中申请VDF即可。
对于单个进程来说,它打开的一个个进程都是存储在VfdCache数组中,而每个进程在VfdCache上维护了两个链表:一个就是LRU池(双向链表),另一个是FreeList链表,用来记录可以被使用的VFD。
如上图所示,LRU池是一个双向链表,开始和结束于元素VfdCache[0], 元素0是特殊节点,它不代表一个文件,其中fd字段总是等于VFD_CLOSED。元素0是一个头节点,它标明了LRU池的开始/结束。
VFD的分配和回收大致过程如下:
- 当后台进程启动时(一个客户端对应一起后台进程),会调用InitFileAccess函数创建VfdCache头,即VfdCache[0]
- 进程打开第一个文件时(调用AllocateVfd函数),将初始化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来打开物理文件。
参考链接:
<<PostgreSQL数据库内核分析>>