数据库内核:PostgreSQL 存储

存储管理

数据库管理系统的存储管理分级

存储管理分级

在数据库管理系统中存储管理的目的是:

  • 提供页或者元组集合的数据视图

  • 将数据库对象(例如表)映射到磁盘文件上

  • 管理数据与磁盘存储之间的传输

  • 使用缓冲区来减少磁盘/内存之间传输次数

  • 将加载的数据还原成为元组

  • 是使用访问方法来访问文件结构的基础

数据库管理系统中存储管理分为如下几个级别:

  • 磁盘或文件(Disks and Files):磁盘文件的性能问题和组织
  • 缓存区管理(Buffer Management):使用缓存来提高数据库管理系统的吞吐量
  • 元组或页管理(Tuple/Page Management):元组是如何在磁盘中存储和管理的
  • 数据库对象管理(Catalog):表、视图、函数、类型等对象是如何表示的

以查询数据的视角来看存储结构

DBMS存储管理(查询视角)

在查询过程中,表示数据库对象的有:

  • DB:处理一个授权或开放的数据库
  • Rel:处理一个开放的关系
  • Page:存储磁盘块内容的内存缓冲区
  • Tuple:保存来自一个元组的数据值的内存

在数据库管理系统中的寻址方式:

  • \(PageID=FileID+Offset\):表示一个数据块,\(Offset\) 表示这个数据块在文件中的位置
  • \(TupleID=PageID+Index\):表示一个元组,\(Index\) 表示这个元组在数据块(页)的位置

存储技术

存储介质类别

  • 持久存储:存储空间大、价格低、获取数据速度慢、以块为单位访问,但是可以存储很长的时间
  • 计算存储:存储空间小、价格高、获取数据速度快、以字节或者字为单位访问,一般用于分析数据
  • HDD 与 RAM 访问数据速度之比约是 100000:1,也就是说
    • 10 毫秒可以读取包含两个元组的块
    • 1 微妙可以对两个元组进行比较

硬盘是成熟的、廉价的、大容量的一种存储介质。

另外还有一种批量存储的介质:固态硬盘(SSD):

  • 比 HDD 更快,没有延迟
  • 可以读取单个项目
  • 更新需要块擦除然后写入
  • 随着时间的推移,需要重写“磨损”块
  • 需要分散写入负载的控制器
HDD SDD
Cost/byte 约 4c/GB 约 13c/GB
读取延迟 约 10ms 约 50μs
写入延迟 约 10ms 约 900μs
读取单元 块(例如 1KB) 字节
写的方式 写入一块数据块 写入一块空的数据块

成本模型

数据库操作的成本主要的方面:

  • 数据总是作为整个块(页面)传输到磁盘或者从磁盘中传出
  • 在内存中操作元组的成本可以忽略不计
  • 总成本主要由数据块的读/写次数决定

但是,并非所有页面访问都需要进行磁盘的访问(当数据在缓冲池中时),而且元组通常是可变大小的。

文件管理

文件管理子系统的目的:

  • 组织文件系统内的数据布局
  • 处理从数据库 ID 到文件地址的映射
  • 在缓冲池和文件系统之间传输数据块
  • 尝试处理文件访问错误问题(重试)
  • 在操作系统文件操作之上构建更高级别的操作

操作系统提供的一般文件操作:

fd = open(fileName,mode)	// 打开fileName文件用于读、写
close(fd)	// 关闭打开的文件
nread = read(fd, buf, nbytes)	// 尝试从文件中读取数据到缓冲区中
nwritten = write(fd, buf, nbytes)	// 尝试将缓冲区的数据写入到文件中
lseek(fd, offset, seek_type)	// 将文件指针移动到相对/绝对文件偏移量
fsync(fd)	// 将文件缓冲区的内容刷新到磁盘

数据库管理系统的文件组织

不同的数据库管理系统会有不同的选择:

  • 绕过文件系统并使用原始磁盘分区
  • 一个包含所有数据库数据的非常大的文件
  • 有几个大文件,表分布在它们之间
  • 拥有多个数据文件,每个表一个
  • 每个表都有多个文件
  • 等等

单文件的存储管理器

整个数据库只有一个文件(例如 SQLite),对象都被分配到文件划分出来的区域(段)中。

单文件数据库管理系统

如果一个对象对分配的段来说太大,就需要分配一个扩展的段。

在 Unix 文件中分配空间是很容易的,只需寻找到你想要的地方并写下数据,如果那里没有数据,数据就会附加到文件中,如果那里已经有数据了,它就会被覆盖。如果查找远远超出了文件的末尾,Unix 就不会为“漏洞”分配磁盘空间,仅当数据写入那里时,才会分配磁盘存储。

考虑以下简单的单文件的数据库管理系统的布局

单文件数据库管理系统例子

每个文件段由一个数字固定大小的块组成,以下是一些重要的数据/常量的定义:

#define PAGESIZE 2048 // 每页的字节数,一般设置为1024, 2048, 4096, 8192
typedef long PageId;	// PageId是块的索引,pageOffset=PageId*PAGESIZE
typedef char *Page;	// 指向页或块的缓冲区的指针

开放的数据库和表的存储管理器的数据结构

typedef struct DBrec {	// 数据库的存储管理器的数据结构
char *dbname;	// 数据库的名称副本
int fd;	// 数据库文件的地址
SpaceMap map;	// 空闲和已使用区域的映射
NameTable names; // 将名称映射到 areas + sizes 处
} *DB;
typedef struct Relrec {	// 表的存储管理器的数据结构
   char *relname;	// 表的名称副本
   int   start;	// 表的开始数据所在的页的索引
   int   npages;	// 表数据所占页的数量
   ...
} *Rel;

例子:查看关系 select name from Employee

实现的“伪”代码:

DB db = openDatabase(”myDB”); // 获取名为myDB数据库对象
Rel r = openRelation(db,”Employee”);	// 打开数据库中Employee这张关系表,得到表对象
Page buffer = malloc(PAGESIZE*sizeof(char));	// 获取PAGESIZE*sizeof(char)大小的缓冲区
for (int i = 0; i < r->npages; i++) {	// 遍历Employee这张关系表所占的所有页面
		PageId pid = r->start+i; 	// 当前页面的索引 
  	get_page(db, pid, buffer); 	// 将当前页面中的数据加载到缓冲区中
  	for each tuple in buffer {	// 对缓冲区中的元组进行遍历
       get tuple data and extract name
       add (name) to result tuples
   	}
}

对数据库对象的初始化和释放操作

DB openDatabase(char *name) {	// 初始化
	DB db = new(struct DBrec);	// 创建一个数据库对象
  db->dbname = strdup(name);	// 数据库的名字
	db->fd = open(name,O_RDWR); 	// 数据库文件的地址
  db->map = readSpaceTable(db->fd);	// 数据库中表的映射信息
  db->names = readNameTable(db->fd); 	// 数据库中表的名字
  return db;
}

void closeDatabase(DB db) {	// 释放资源以及一些同步数据操作
  writeSpaceTable(db->fd,db->map);
  writeNameTable(db->fd,db->map); 
  fsync(db->fd);
	close(db->fd);
	free(db->dbname);
	free(db);
}

对关系表对象的初始化和释放操作

Rel openRelation(DB db, char *rname) { // 初始化
  Rel r = new(struct Relrec);	// 创建一个表对象
  r->relname = strdup(rname);	// 表名称
  r->start = ...;	// 表的开始页面
	r->npages = ...;	// 表的页面总数
	return r; 
}

void closeRelation(Rel r) { // 释放资源
  free(r->relname); 
  free(r);
}

对页面的操作

// 假设 Page = byte[PageSize],PageId = block number in file
// 从文件系统中读取页面到内存的缓冲区中
void get_page(DB db, PageId p, Page buf) { 
  lseek(db->fd, p*PAGESIZE, SEEK_SET);	// 将文件指针移动到指定页面的位置
  read(db->fd, buf, PAGESIZE);	// 从文件中读取页面的内容,并将其存储在缓冲区中
}
// 将内存缓冲区的页写入到文件系统中
void put_page(Db db, PageId p, Page buf) { 
  lseek(db->fd, p*PAGESIZE, SEEK_SET); 
  write(db->fd, buf, PAGESIZE);
}

管理空间映射表的内容

// 假设存在一个由(偏移量、长度、状态)记录组成的数组,分配n个新页面
PageId allocate_pages(int n) {
   if (no existing free chunks are large enough) {	// 没有足够大的现有空闲块
			int endfile = lseek(db->fd, 0, SEEK_END);	// 将文件指针移动到末尾
			addNewEntry(db->map, endfile, n); // 在文件的末尾添加一个新条目以分配所需的页面
   } else {
      grab ”worst fit” chunk	// 选择“最差匹配”块
      split off unused section as new chunk	// 将未使用的部分拆分为新的块
   }
}

// 从页面 p 开始释放 n 个页面
void deallocate_pages(PageId p, int n) {
   if (no adjacent free chunks) {	// 没有相邻的空闲块
			markUnused(db->map, p, n);	// 将标记这些页面为未使用(markUnused)
   } else {
      merge adjacent free chunks	// 合并相邻的空闲块
      compress mapping table	// 压缩映射表
   }
}

例题:查看关系表的成本

一张模式为 \(R(x,y,z)\) 的表有 \(10^5\) 个元组,有以下假设:

  • 元组的数量 \(r=10000\)
  • 元组的平均大小是 \(R=200\) 字节
  • 页面的大小是 \(B=4096\) 字节
  • 读取一个页面的时间为 \(T_r=10\) 毫秒
  • 检查一个元组的时间为 \(T_c=1\) 微秒
  • 形成一个结果元组的时间 \(T_c=1\) 微秒
  • 写一个结果页需要 \(T_w=10\) 毫秒

计算回答以下查询的总时间成本:

insert into S select * from R where x > 10;

假设有一半的元组满足过滤条件。

解:

  1. 读取输入表 R 的总页面数: \(P_R = \lceil\frac{r\cdot R}{B}\rceil=489\)
  2. 读取页面并对所有元组进行过滤的时间成本: \(T_f=P_R\cdot T_r+r\cdot T_c=4890000+10000=4900000\mu s\)
  3. 插入结果表 S 的总页面数: \(P_S = \lceil \frac{r}{2} \cdot \frac{R}{B} \rceil=245\)
  4. 形成结果元组和写入结果表的时间成本: \(T_w = \frac{r}{2}\cdot T_c + P_S \cdot T_w=5000+2450000=2455000\mu s\)
  5. 因此总时间为 \(7355000\mu s\)

数据库管理系统参数

  • 一个关系有 \(r\) 个元组,元组平均大小为 \(R\) 字节
  • 这些元祖存储在磁盘上占 \(b\) 个页面
  • 每个页面的大小是 \(B\) 字节,包含 \(c\) 个元组
  • 数据在磁盘和内存之间转换是以页为单位的
  • \(T_r\) 表示磁盘和内存之间转换的成本,\(T_w\) 表示其他成本

DBMS参数

多文件的磁盘管理器

大多数数据库管理系统不会对所有数据都使用单个大文件。一般会提供:

  • 在物理上或逻辑上分区为多个文件
  • 将数据库级的对象映射到文件(例如通过元数据)

使用多个文件(每个关系一个文件),这会更容易。但是具体的文件结构还是依赖于数据库管理系统。

单文件与多文件比较

单文件与多文件比较

如果系统每个表都使用一个文件,PageId 包含:

  • 关系标识符(用于映射到对应的文件名,以确定所属的表)
  • 页码(用于标识在该文件中的具体页面)

如果系统每个表使用多个文件,PageId 包含:

  • 关系标识符
  • 文件标识符(与关系标识符结合,给出了文件名,用于确定所在的具体文件)
  • 页码

PostgreSQL 的文件管理

PostgreSQL的文件管理

存储子系统的组件:

  • 从关系到文件的映射(RelFileNode):包含关系的标识符,可以映射到对应的文件名或文件描述符。
  • 开放的关系池的抽象(storage/smgr):用于管理和操作关系文件,包括创建、打开、关闭、读取、写入等操作。Smgr 专为许多存储设备设计,仅提供磁盘处理程序。
  • 管理文件的函数(storage/smgr/md.c):实现了管理文件的具体函数和逻辑,提供了对文件的创建、删除、重命名、扩展、截断等操作的支持。
  • 文件描述符池(storage/file):用于管理文件描述符的分配和释放,负责维护可用的文件描述符池,用于打开和操作文件,避免频繁地打开和关闭文件,提高文件操作的效率。

PostgreSQL 中有两类基础类型的文件:

  • 包含数据的堆文件
  • 包含索引条目的索引文件

在 PostgreSQL 中都是通过其 OID 来识别关系文件的。其核心数据结构是 RelFileNode

typedef struct RelFileNode {
    Oid  spcNode;  // 表空间是数据库中逻辑存储空间的组织单位,用于存储表和索引等数据库对象的物理文件。
    Oid  dbNode;   // 数据库
    Oid  relNode;  // 关系
} RelFileNode;

在全局共享表(pg_database)中的参数:spcNode == GLOBALTABLESPACE_OIDdbNode == 0

relpath 函数将 RelFileNode 映射到文件

// 根据 RelFileNode 中的 spcNode 和 relNode 字段的值,确定了不同情况下的文件路径
char *relpath(RelFileNode r) {
   char *path = malloc(ENOUGH_SPACE);
   if (r.spcNode == GLOBALTABLESPACE_OID) {	// 关系属于共享系统关系,存储在 PGDATA/global 目录中
      Assert(r.dbNode == 0);
      sprintf(path, ”%s/global/%u”,
              DataDir, r.relNode);	// 将路径拼接为 DataDir/global/关系标识符 的形式
   }
   else if (r.spcNode == DEFAULTTABLESPACE_OID) { // 关系属于默认表空间,存储在 PGDATA/base 目录中
      sprintf(path, ”%s/base/%u/%u”,
              DataDir, r.dbNode, r.relNode);	// 将路径拼接为 DataDir/base/数据库标识符/关系标识符 的形式
	 }
   else {	// 其他的表空间
      sprintf(path, ”%s/pg_tblspc/%u/%u/%u”, DataDir
              r.spcNode, r.dbNode, r.relNode);	// 将路径拼接为 DataDir/pg_tblspc/表空间标识符/数据库标识符/关系标识符 的形式
	 }
   return path;
}

文件描述符池

Unix 对并发打开的文件数量有限制,PostgreSQL 维护了一个开放文件描述符池:

  • 从更高级别的功能中隐藏这一限制
  • 尽量减少昂贵的 open() 操作

文件名只是字符串 typedef char *FileName,打开的文件通过索引引用 typedef int File,一个文件就是对“虚拟文件描述符”表的索引。文件描述符的接口(池):

File FileNameOpenFile(FileName fileName, int fileFlags, int fileMode);	// 打开一个位于数据库目录($PGDATA/base/)下的文件
File OpenTemporaryFile(bool interXact);	// 打开一个临时文件
void FileClose(File file);	// 关闭一个打开的文件
void FileUnlink(File file);	// 删除一个文件
int  FileRead(File file, char *buffer, int amount);	// 从文件中读取数据
int  FileWrite(File file, char *buffer, int amount);	// 向文件中写入数据
int  FileSync(File file);	// 将文件内容刷新到磁盘,确保数据持久化
long FileSeek(File file, long offset, int whence);	// 在文件中移动文件指针的位置
int  FileTruncate(File file, long offset);	// 截断文件,将文件大小调整为指定的大小

虚拟文件描述符(Vfd)

物理存储在动态分配的数组中:

Vfd物理存储

还可以按使用的新旧程度排列成列表:

Vfd列表

typedef struct vfd
{
    s_short  fd;	// 当前的文件描述符,如果是空,则为 VFD_CLOSED
    u_short  fdstate;	// 当前的文件描述符的状态
    File     nextFree;	// 链接到下一个空的VFD
    File     lruMoreRecently; // 指向链表前一个VFD
    File     lruLessRecently;
    long     seekPos;	// 当前文件的逻辑地址
    char     *fileName;	// 文件的名称,如果没有使用VF则为NULL。通过动态分配内存(malloc)而来的,在关闭文件时需要释放内存
    int      fileFlags;	// 用于(重新)打开文件的open(2)标志
    int      fileMode;	// 传递给open(2)的模式
} Vfd;

文件管理

PostgreSQL 将每一张表都存储在 PGDATA/pg_database.oid 中。

文件管理

  • 数据文件(Oid、Oid.1):固定大小的块/页序列(通常为8KB),每个页面都包含元组数据和管理数据,数据文件的最大大小为 1 GB(Unix 的限制)。
  • 自由空间映射(Oid_fsm):表示数据页中的可用空间在哪里,“免费”空间只有在真空后才是免费的,而 DELETE 只是将元组标记为不再使用,这种就不是“免费”空间。
  • 可见映射(Oid_vm):表示所有元组都是“可见”的页面,可见就是所有当前活跃的事务都可以访问这些页面。

磁盘存储管理器 storage/smgr/md.c

  • 管理自己的开放文件描述符池(Vfd)
  • 如果多个分支,可以使用多个 Vfd 来访问数据
  • 管理从 PageID 到 file+offset 的映射

在 PostgreSQL 中 PageID 的结构如下:

typedef struct
{
    RelFileNode rnode;    // 表示与页面相关的关系(relation)和文件(file)
    ForkNumber  forkNum;  //  表示与页面相关的文件的分支(fork),在 PostgreSQL 中,一个表可以有多个分支,每个分支用于不同的目的,如主数据分支(main fork)、TOAST 分支、索引分支等。ForkNumber 是一个枚举类型,用于表示不同的分支。
    BlockNumber blockNum; //  表示页面(block)在文件中的块号
} BufferTag;

访问数据块的方法(大致)如下:

// BufferTag结构体用于标识一个页面,Buffer则是用于存储页面数据的缓冲区
getBlock(BufferTag pageID, Buffer buf)
{
  Vfd vf; off_t offset;
  (vf, offset) = findBlock(pageID) // 根据给定的pageID找到对应的磁盘文件和偏移量
  lseek(vf.fd, offset, SEEK_SET)	// 将文件指针定位到offset所指定的位置
  vf.seekPos = offset;
  nread = read(vf.fd, buf, BLOCKSIZE)	// 从文件中读取数据,并将数据存储到buf缓冲区中,BLOCKSIZE是全局常量(默认:8192)
  if (nread < BLOCKSIZE) ... we have a problem
}

findBlock(BufferTag pageID) returns (Vfd, off_t)
{
	offset = pageID.blockNum * BLOCKSIZE // 根据blockNum和页面大小BLOCKSIZE计算偏移量,确定页面在文件中的位置
  fileName = relpath(pageID.rnode)	// 生成文件名
	if (pageID.forkNum > 0)	// 表示页面属于某个特定的fork
      fileName = fileName+”.”+pageID.forkNum	// 在文件名中添加fork信息,以区分不同的fork
  if (fileName is not in Vfd pool)	// 检查文件名是否存在于Vfd池中,即是否已经打开过该文件
      fd = allocate new Vfd for fileName	// 如果文件名不在池中,说明是首次访问该文件,需要为其分配一个新的Vfd
  else
			fd = use Vfd from pool // 如果文件名已存在于池中,说明该文件已经打开,可以直接使用池中的Vfd
  if (offset > fd.fileSize) {	// 检查偏移量是否超出文件大小,如果是,则说明需要访问下一个fork
      fd = allocate new Vfd for next fork
      offset = offset - fd.fileSize	// 偏移量需要相应地减去当前文件的大小,以确定在下一个文件中的偏移位置
   }
   return (fd, offset)
}

缓冲池

缓冲池的目的就是保留从数据库文件中读取的页面,以便可能重复使用,提高效率。

缓冲池

缓冲池的操作(两者都采用单个 PageID 参数):

  • request_page(pid):用于替代 getBlock()
  • release_page(pid):用于替代 putBlock()

缓冲池数据结构:

  • Page frames [NBUFS]
  • FrameData directory [NBUFS]
  • 这里的 Pagebyte[BUFSIZE]

缓冲池的数据结构

对于每个 frameFrameData)都需要知道:

  • 哪些页是有数据的,哪些页是空的
  • 自加载以来是否已修改(脏位)
  • 目前有多少笔交易正在使用它(别针数)
  • 最近访问的时间戳(用于页面替换时使用)

页面由 PageID 引用,PageID = BufferTag = (rnode, forkNum, blockNum)

在没有缓冲池的情况下进行扫描

Buffer buf;
int N = numberOfBlocks(Rel); // 获取关系Rel中的块数目
for (i = 0; i < N; i++) {	// 遍历关系中的每个块
	pageID = makePageID(db,Rel,i);	// 创建一个BufferTag结构体,用于标识要读取的块
  getBlock(pageID, buf);	// 根据给定的pageID读取块的内容,并将内容存储在缓冲区buf中
	for (j = 0; j < nTuples(buf); j++)	// 遍历缓冲区中的每个元组
    process(buf, j)
}
  • 需要读 N 个页面
  • 如果需要再读一次,则又要读 N 个页面

在有缓冲池的情况下进行扫描

Buffer buf;
int N = numberOfBlocks(Rel); 
for (i = 0; i < N; i++) {
	pageID = makePageID(db,Rel,i); 
  bufID = request_page(pageID);	// 从缓冲池中获取指定页的缓冲区ID
	buf = frames[bufID];	// // 获取缓冲区中的数据
	for (j = 0; j < nTuples(buf); j++)
      process(buf, j)
  release_page(pageID);	// // 释放使用的页,将缓冲区标记为可用状态
}
  • 第一次需要读 N 个页面
  • 之后如果需要读,读的页面将少于 N 个页面

request_page()

int request_page(PageID pid)
{
   if (pid in Pool)	// 页面已存在于缓冲池中
      bufID = index for pid in Pool	// 直接获取该页面对应的缓冲区ID
   else {
      if (no free frames in Pool)	 // 缓冲池中没有可用的空闲帧
         evict a page (free a frame)	// 需要选择一个页面进行替换(驱逐)
      bufID = allocate free frame	 // 分配一个空闲帧给新页面
      // 在目录中记录分配的帧的信息
      directory[bufID].page = pid
      directory[bufID].pin_count = 0
      directory[bufID].dirty_bit = 0
   }
   directory[bufID].pin_count++	// 增加页面的引用计数
   return bufID
}

页面替换

  • 找到适合替换的帧,这个帧需要满足:
    • pin count = 0
    • dirty bit = 0
  • 如果选定的帧被修改,则将帧刷新到磁盘
  • 将目录条目标记为“空帧”
  • 如果满足条件的帧有多个,就需要使用页面替换策略(Least Recently Used (LRU)、Most Recently Used (MRU)、First in First Out (FIFO)、Random)

缓冲管理的效率

考虑一个查询,以找到同时也是员工的客户:

select c.name
from Customer c, Employee e
where c.ssn = e.ssn;

这可以通过嵌套循环在数据库管理系统中实现:

for each tuple t1 in Customer {
    for each tuple t2 in Employee {
      	if (t1.ssn == t2.ssn)
          	append (t1.name) to result set
		} 
}

在页面级操作来看,该算法如下:

Rel rC = openRelation(”Customer”);
Rel rE = openRelation(”Employee”);
for (int i = 0; i < nPages(rC); i++) {
		PageID pid1 = makePageID(db,rC,i); // 获取关系Customer的第i个页面的PageID
  	Page p1 = request_page(pid1);	// 获取具体的页面内容
		for (int j = 0; j < nPages(rE); j++) {
        PageID pid2 = makePageID(db,rE,j);
        Page p2 = request_page(pid2);
        // compare all pairs of tuples from p1,p2
        // construct solution set from matching pairs
        release_page(pid2);
		}
    release_page(pid1);
}

PostgreSQL 缓冲区管理器为所有后端提供共享的内存缓冲区池,所有访问方法都通过缓冲区管理器从磁盘获取数据。缓冲区位于一个大的共享内存区域。

定义:src/include/storage/buf*.h

  • include/storage/buf.h:基本缓冲管理器数据类型(例如 Buffer)
  • include/storage/bufmgr.h:缓冲区管理器函数接口的定义
  • include/storage/buf_internals.h:缓冲区管理器内部的定义(例如 BufferDesc)

函数:src/backend/storage/buffer/*.c

缓冲池的构成:

  • BufferDescriptorsBufferDesc 的共享固定数组(大小为 NBuffers
  • BufferBlocks:帧(8KB 大小)的共享固定数组(大小为 NBuffers
  • Buffer:上述数组中的索引值

缓冲池的大小在 postgresql.conf 中设置:

shared_buffers = 16MB   # min 128KB, 16*8KB buffers

缓冲池图示

posted @ 2023-06-24 16:50  FireOnFire  阅读(59)  评论(0编辑  收藏  举报