本节目录
以下内容根据课件自行翻译总结,如有谬误,烦请指教~
介绍
DBMS的存储管理目标
- 提供数据的集合的view
- 把数据库对象映射到磁盘文件
- 管理数据从/到磁盘的传输
- 使用buffers减少磁盘/内存传输
- 将加载的数据解释为元组/记录
- basis for file structures used by access methods
数据在查询中的过程
-
Query Evaluation 要扫描一个表,调用关系代数层扫描表的元组
-
关系代数层:扫描表的元组,调用访问方法层扫描表的页
-
访问方法层:扫描表的页,请求缓存管理层把数据块读出来
-
缓存管理层:需要文件管理层读出数据块
-
文件管理层:根据表和文件的映射关系,读取出数据块给到缓存管理层
-
缓存管理层:把数据块放入缓存中,让访问方法层可以扫描块中的表
-
访问方法层:对页逐行扫描,把结果给到关系代数层
-
关系代数层:返回结果给Query Evaluation
文件管理的主要问题
- 磁盘和文件管理:性能问题和磁盘文件的组织问题
- 缓存管理:使用缓存来提高DBMS系统的吞吐量
- 元组和页的管理:如何在磁盘页中表示元组
- 数据库对象的管理: tables/views/functions/types这些怎么表示 —— 使用Catalog
文件管理
文件管理的目标
- 组织数据在文件系统中的布局
- 处理从数据库ID到文件地址的映射
- 在缓冲区池和文件系统之间传输数据块
- 还将尝试处理文件访问错误问题
在OS的文件操作上再进行一层封装
操作系统提供的文件操作:
fd = open(fileName,mode)
打开文件
close(fd)
关闭文件
nread = read(fd, buf, nbytes)
读文件数据到缓存
nwriten = write(fd, buf, nbytes)
写缓存数据到文件中
lseek(fd, offset, seek_type)
移动文件指针
fsync(fd)
刷盘
不同的DBMS有不同的文件管理方式选择(按照文件粒度)
- 绕过文件系统,直接使用原始的磁盘分区
- 一个大文件包含所有数据库的数据
- 使用几个大文件
- 每个表一个文件
- 每个表多个文件
单文件 DBMS
介绍
- 一个文件包含整个数据库,每个对象被分配单独的区域(段)
- 例如:
SQLite
如果一个对象太长了,溢出了当初分配的空间怎么办?对象被删除了会发生什么?
结构案例
- 以下图的一个简易单文件DBMS布局为例(注意以页/块为基本单位)
- SpaceMap 作为第一段,划分了每一段的开始和长度,以及是否被使用
例:SpaceMap = [ (0,10,U), (10,10,U), (20,600,U), (620,100,U), (720,20,F) ] - TableMap 作为第二段,记录了所有表所处页的开始位置和长度
例:TableMap = [ (”employee”,20,500), (”project”,620,40) ]
每个分段都是由固定大小的块组成的
#define PAGESIZE 2048 // 每页字节大小,一般是1024/2048/4096/8192
typedef long PageId; // PageId(long类型)是块索引,pageOffset=PageId*PAGESIZE
typedef char *Page; // 指向页或者缓存块的指针
打开的数据库和表的数据结构
// 数据库
typedef struct DBrec {
char *dbname; // 复制的数据库名
int fd; // 数据库的文件描述符
SpaceMap map; // 记录 空F/已使用U的区域
NameTable names; // 从表名name到(offset,sizes)的映射(参考上方的布局图)
} *DB;
// 表
typedef struct Relrec {
char *relname; // 复制的表名 copy of table name
int start; // 表数据的起始页码 page index of start of table data
int npages; // 表数据的页数 number of pages of table data
...
} *Rel;
实例:扫描表
对于 SQL查询 select name from Employee
的实现
// 打开指定数据库和表,得到表在数据库文件中的起始位置和大小
DB db = openDatabase(”myDB”);
Rel r = openRelation(db,”Employee”);
// 分配一页缓存
Page buffer = malloc(PAGESIZE*sizeof(char));
// 遍历表包含的所有页
for (int i = 0; i < r->npages; i++) {
PageId pid = r->start+i;
get_page(db, pid, buffer); // 把pid页读到缓存中
// 遍历该页中的所有元组
for each tuple in buffer {
get tuple data and extract name
add (name) to result tuples
}
}
打开和关闭数据库
// start using DB, buffer meta-data
// 创建新的DBrec结构体,主要打开数据库文件和把基本的map和names读出来
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;
}
// stop using DB and update all meta-data
// 主要把在内存中修改了的map和names重新写回磁盘,最后释放空间
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);
}
打开和关闭表
// set up struct describing relation
// 创建新的Rel结构体,主要从数据库读出该表的起始位置和页大小
Rel openRelation(DB db, char *rname) {
Rel r = new(struct Relrec);
r->relname = strdup(rname);
// get relation data from map tables
r->start = ...;
r->npages = ...;
return r;
}
// stop using a relation
// 关闭时释放空间
void closeRelation(Rel r) {
free(r->relname);
free(r);
}
页的读写(到这有了要读或写的页号,就可以直接调用OS的接口了)
// assume that Page = byte[PageSize]
// assume that PageId = block number in file
// read page from file into memory buffer
void get_page(DB db, PageId p, Page buf) {
lseek(db->fd, p*PAGESIZE, SEEK_SET);
read(db->fd, buf, PAGESIZE);
}
// write page from memory buffer to file
void put_page(Db db, PageId p, Page buf) {
lseek(db->fd, p*PAGESIZE, SEEK_SET);
write(db->fd, buf, PAGESIZE);
}
管理空间映射表可能有点复杂:创建n页和删除p开始的n页:
// assume an array of (offset,length,status) records
// 分配n页
PageId allocate_pages(int n) {
// 如果在map中已经没有空闲且足够大的段了,则在文件末尾增加n块;否则选择”worst fit”的块,分配出去后剩下的作为新的空闲段
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
}
// note that file itself is not changed
}
// 删除从p开始的n页
void deallocate_pages(PageId p, int n) {
// 如果没有相邻的空闲段,则直接在map中将该段标记为空闲;如果有则合并空闲段,并压缩map
if (no adjacent free chunks) {
markUnused(db->map, p, n);
} else {
merge adjacent free chunks
compress mapping table
}
// note that file itself is not changed
}
// 更改在closeDatabase()的时候生效(要写回map)
多文件磁盘管理器
介绍
大多数DBMS不是用一个单独的大文件存储所有数据的。一般把DB级别的对象映射到物理分区或逻辑分区的多个文件。具体的文件结构决定于DBMS。
使用一表一文件的多文件管理方式对于某些操作会更加简单,比如:创建一个表,扩展一张表的大小,计算在一个表里的页偏移量
结构案例
比较单文件和多文件的数据库,单文件数据库无法直接计算得到某个表的某一页的偏移量(必须先通过map获得该表的起始页号),而多文件数据库可以直接计算得到。
PageId
的结构在不同的多文件系统里也不相同
- 如果是一表一文件,则PageId包含 表标识符+页号(哪张表的第几页)
- 如果是一表多文件(如PostgreSQL),则PageId包含 表标识符+文件标识符+页号(哪张表的第几个文件的第几页)
PostgreSQL的存储管理
PostgreSQL存储管理的几个子系统:
-
把表映射到文件 mapping from relations to files ( RelFileNode )
-
打开表池的抽象 abstraction for open relation pool ( storage/smgr )
Note: smgr designed for many storage devices; only disk handler provided
smgr是为很多存储设备设计的,但只提供了对disk的设计(应该是这么理解)
-
文件管理方法 functions for managing files ( storage/smgr/md.c )
-
文件描述符池 file-descriptor pool ( storage/file )
两种基本文件类型:
- 堆文件:包含数据(元组)
- 索引文件:包含索引条目
把表当做多个文件 Relations as Files
PostgreSQL通过OID来区分表的文件,其中的核心结构是 RelFileNode (表文件节点)
typedef struct RelFileNode {
Oid spcNode; // tablespace 所属空间
Oid dbNode; // database 所属的数据库
Oid relNode; // relation 所属的表
} RelFileNode;
// 如果是Global (shared)的表(如pg_database),则spcNode == GLOBALTABLESPACE_OID,dbNode == 0
relpath
函数把 RelFileNode 映射到 文件
// 获取RelFileNode的真实路径
char *relpath(RelFileNode r) // 简化版本
{
char *path = malloc(ENOUGH_SPACE);
if (r.spcNode == GLOBALTABLESPACE_OID) {
/* Shared system relations live in PGDATA/global */
Assert(r.dbNode == 0);
// path=PGDATA/global/r.relNode
sprintf(path, ”%s/global/%u”, DataDir, r.relNode);
}
else if (r.spcNode == DEFAULTTABLESPACE_OID) {
/* The default tablespace is PGDATA/base */
// path=PGDATA/base/r.dbNode/r.relNode
sprintf(path, ”%s/base/%u/%u”,DataDir, r.dbNode, r.relNode);
}
else {
/* All other tablespaces accessed via symlinks */
// path=PGDATA/pg_tblspc/r.spcNode/r.dbNode/r.relNode
sprintf(path, ”%s/pg_tblspc/%u/%u/%u”, DataDir,r.spcNode, r.dbNode, r.relNode);
}
return path;
}
结合结构图理解更佳
文件描述符池 File Descriptor Pool
简介
Unix限制了同时打开文件的最大数量,因此PostgreSQL维护了一个文件描述符池去向上一层隐藏了这个限制,并且减少了文件的**open()**操作
文件名就是个字符串:typedef char *FileName
打开的文件时通过File(int类型)来引用的:typedef int File
,但其实这个File只是虚拟文件描述符表的一个索引(应该是理解为虚拟fd到操作系统fd的映射)
文件描述符池提供的接口
// open a file in the database directory ($PGDATA/base/...) 打开base下的文件的
File FileNameOpenFile(FileName fileName, int fileFlags, int fileMode);
// 打开临时文件; interXact: 是否在事务结束后关闭
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);
// 以上都可以类比unix的 open() , close(int fd) , read() , write() , lseek()
虚拟文件描述符
虚拟文件描述符(Vfd)在物理上存储在一个动态扩展的数组里面,里面包含了实际的文件描述符fd和文件路径pos
另外,虚拟文件描述符也用链表连接起来组成 recency-of-use(近期使用过的Vfd)
所以在VfdCache的数据结构里面还包含了头尾指针
以下是简化的Vfd的结构
typedef struct vfd
{
s_short fd; // current FD, or VFD_CLOSED if none 当前真实的fd,或者没有标记为VFD_CLOSED标记
u_short fdstate; // bitflags for VFD’s state 标记是否空闲
File nextFree; // link to next free VFD, if in freelist 下个空闲vfd的index
File lruMoreRecently; // doubly linked recency-of-use list lru的双指针
File lruLessRecently;
long seekPos; // current logical file position 当前文件的seekpos
char *fileName; // name of file, or NULL for unused VFD 文件名,空闲为NULL
// 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;
文件管理器 File Manager
简介
在PostgreSQL里保存了每张表的信息
-
可以在PGDATA/base/里看到有许多文件夹,每个目录是一个数据库,目录名就是数据库的Oid
lzh@iZwz98qdx9tvkkfsuhp66eZ:~/postgresql/pgdata/base$ ls 1 12367 12368 16384
-
进入其中一个目录中,可以看到有很多文件。
-
其中每个表由多个文件组成,被称为
forks
(分支,应该是指多个数据文件),同一张表的文件以该表的Oid为前缀Oid
存储表的数据 table data pagesOid.1
Oid.2
也是存储表的数据,前面的就会创建这些文件继续装Oid_fsm
存储 free space map 空闲空间映射Oid_vm
存储 visibility map
数据文件 (Oid
, Oid.1
, Oid.2
…):
- 由一系列固定大小的块/页组成(典型值为8KB)
- 每个页面都包含 tuple data 元组数据和 admin data 管理数据
- 数据文件的最大大小为1GB(由Unix系统限制)
Free space map ( Oid _fsm
):
-
指示数据页中空闲空间的位置
-
每当PostgreSQL数据库中的表中的行被更新或删除时,死亡行会被遗留下来。VACUUM则会把它们除去来使空间能被重新利用。VACUUM后才会记为free
VACUUM命令只可以移除这些不再被需要的行版本(也被称为元组)。如果被删除事务的事务ID(存储在xmax系统列中)比仍然活跃在PostgreSQL数据库(或者共享表的整个集群)中最老的事务(xmin界限)更老,那么这个元组将不再被需要(不会再被访问)。
(像MySQL中的DB_TRX_ID < m_up_limit_id的意思)
Visibility map ( Oid _vm
):
- 指示其中哪些页面的所有元组都是 “可见的” (所有当前活跃的事务都可以访问)
- 这些页面可以被VACUUM忽略(不可以移除删除的行版本,当前事务可能还会用到)
磁盘文件管理器 ( storage/smgr/md.c )
- 管理它自己的打开的文件描述符池 (vfd’s)
- 可能使用多个vfd去访问数据,如果由多文件组成的话(forks)
- 管理 PageId 到 文件+偏移 的映射
PostgreSQL中的 PageID
的数据结构
typedef struct
{
RelFileNode rnode; // 哪个文件(.../Oid)
ForkNumber forkNum; // 哪个 fork (.../Oid.n)
BlockNumber blockNum; // 哪一页/块 (.../Oid.n + offset)
} BufferTag;
访问(磁盘中的)一块/页数据的大致过程:
getBlock(BufferTag pageID, Buffer buf)
{
Vfd vf; off_t offset;
(vf, offset) = findBlock(pageID) // 找到pageID所处的文件vfd和offset
lseek(vf.fd, offset, SEEK_SET) // 猜测和FileSeek差不多,从vfd拿出真正fd调用lseek
vf.seekPos = offset;
nread = read(vf.fd, buf, BLOCKSIZE)
if (nread < BLOCKSIZE) ... we have a problem
}
findBlock(BufferTag pageID) returns (Vfd, off_t)
{
offset = pageID.blockNum * BLOCKSIZE // 偏移 = 块号 * 块大小
// RelFileNode得到.../Oid,再和ForkNumber拼接得到pageID所处的文件
fileName = relpath(pageID.rnode)
if (pageID.forkNum > 0)
fileName = fileName+”.”+pageID.forkNum
// 用文件名获得文件的vfd
if (fileName is not in Vfd pool)
fd = allocate new Vfd for fileName
else
fd = use Vfd from pool // File FileNameOpenFile(..)
// offset已经比文件大了,则分配下一个fork并重新计算块在新文件内的偏移
if (offset > fd.fileSize) {
fd = allocate new Vfd for next fork
offset = offset - fd.fileSize
}
return (fd, offset)
}
缓存池 Buffer Pool
简介
缓存池保存了读出来的数据库文件页,因为它们可能再次被用到。被读写数据块的access methods 用到(如顺序扫描、索引检索、哈希)。
磁盘里叫页Page,内存中叫块Block,知道差不多是同一个东西就行
在上一层请求页时会先在缓存池中找有没有缓存到这个页,如果有就不用去disk找了
缓存池操作都是使用PageID
作为唯一参数:如request_page(pid)
, release_page(pid)
, …
在某种程度上,request_page(pid)
代替了getBlock()
, release_page(pid)
代替了putBlock()
这一部分在学过操作系统的页面缓存机制后会更好理解
数据结构
-
Page
frames
[NBUFS] :frames线性表保存的是缓存页的数据- Page类型是 字节数组 byte[BUFSIZE]
frame也就相当于一个页或块了
-
FrameData
directory
[NBUFS] : directory线性表保存的是各个frames的信息
FrameData保存了哪些信息?
- PageID :保存的是哪一页,或者是空的
- dirty bit :是否修改过
- pin count : 当前有多少事务在使用这个页
- 最近使用时间:LRU替换用
有无缓存的区别
如果没有缓存池,我们每次扫描同样的N个页都要从磁盘读N个页(ps:读磁盘时操作系统也会读一大块到操作系统的缓存中,两个是不一样的)
Buffer buf;
int N = numberOfBlocks(Rel);
for (i = 0; i < N; i++) {
pageID = makePageID(db,Rel,i);
getBlock(pageID, buf);
for (j = 0; j < nTuples(buf); j++)
process(buf, j)
}
有了缓存池之后,使用request_page
,只有第一次扫描时需要从磁盘读N个页,后续先在缓存中找,读的页数<=N(可能被替换出去0~N块)
Buffer buf;
int N = numberOfBlocks(Rel);
for (i = 0; i < N; i++) {
pageID = makePageID(db,Rel,i);
bufID = request_page(pageID); // 代替getBlock,先找缓存
buf = frames[bufID]
for (j = 0; j < nTuples(buf); j++)
process(buf, j)
release_page(pageID); // 释放(我用完了这个page)
}
来看一下request_page(pageID)
是怎么实现的
int request_page(PageID pid)
{
// 现在缓存池里找,找不到再读磁盘
if (pid in Pool)
bufID = index for pid in Pool
else {
// 如果缓存池没有空位了就要把一个frame“驱赶”出去
if (no free frames in Pool)
evict a page (free a frame)
bufID = allocate free frame
// 感觉这里少了个getBlock
directory[bufID].page = pid
directory[bufID].pin_count = 0
directory[bufID].dirty_bit = 0
}
directory[bufID].pin_count++ // 使用的事务数++
return bufID
}
其他的操作:
release_page(pid)
:当前事务用完了,使用事务数–mark_page(pid)
:设置脏位,说明这个页被改过了还没写回磁盘flush_page(pid)
:把特定页写回磁盘(使用 write_page)
替换规则
什么页可以替换:
- 使用最安全的
- pin count = 0 :现在没有事务在用着它
- dirty bit = 0 :没被修改过,也就不用写回
- 如果选择的frame修改过,就要 flush_page 写回去,然后标记为空
那么怎么选择要换出的frame呢,常用的有以下几种:
- Least Recently Used (LRU) 选最久未用的
- Most Recently Used (MRU) 选最近被使用的(与LRU相反)
- First in First Out (FIFO) 选择最早进来的
- Random 随机
LRU 和 MRU 都要记录最近使用的时间,或使用计数器,详细可百度
如何挑选最好的替换策略?
- 因素一:可用的frames数
- 因素二:看页面的访问模式
假设有n个frames,表包含b个pages,参考下面几种情况:
- 情况一:顺序扫描,
n >= b
,采用LRU
或MRU
:第一次读b页,且都能缓存到,后续访问不用读 - 情况二:顺序扫描,
n < b
, 采用MRU
:第一次读b页,只能缓存n个页,后续访问都需要读b-n次 - 情况三:顺序扫描,
n < b
,采用LRU
:每次读都需要读b次,因为每次顺序读读到后面时前面的都被替换出去了,命中率为0。被称作 sequential flooding
(MRU其实我自己理解有点模糊,可以自行了解。总之不用情况用不同替换策略效果可能差别很大)
来考虑实际情况:SELECT c.name FROM Customer c, Employee e WHERE c.ssn = e.ssn;
,采用嵌套循环的方法,在不同层面到看的情况如下:
// table-level
for each tuple t1 in Customer {
for each tuple t2 in Employee {
if (t1.ssn == t2.ssn)
append (t1.name) to result set
}
}
// page-level
// 先打开两个表
Rel rC = openRelation(”Customer”);
Rel rE = openRelation(”Employee”);
for (int i = 0; i < nPages(rC); i++) {
// 对表C的每一页,遍历表E的所有页
PageID pid1 = makePageID(db,rC,i);
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 的缓存管理器
postgresql提供了一个所有backends共享的内存缓存池,所有从磁盘获取数据的访问方法都需要经过缓存管理器。缓冲区位于共享内存的一个大区域中。
定义:src/include/storage/buf*.h
函数:src/backend/storage/buffer/*.c
Buffer代码同时也被需要私有缓存池的backend使用
缓存池的组成:
- BufferDescriptors:共享的fix的/padding的BufferDesc数组(大小为NBuffers) // 信息
- BufferBlocks:共享的fix的8KB frames数组(大小为NBuffers) // 数据
- Buffer:索引值(int),全局buffer是1~NBuffers,本地buffer是负数
缓存池的大小可以在postgresql.conf
文件中设置,如shared_buffers = 16MB
源码导航:
include/storage/buf.h
定义buffer manager的基本数据类型 (e.g. Buffer )include/storage/bufmgr.h
定义buffer manager的函数接口
(i.e. functions that other parts of the system call to use buffer manager)include/storage/buf_internals.h
定义buffer manager的内部构件 (e.g. BufferDesc )- Code:
backend/storage/buffer/*.c
简单总结
DBMS的存储管理系统,文中主要举例介绍了单文件DBMS(如SQLite)和单表多文件的DBMS(如PostgreSQL),以及PostgreSQL的Storage Management。这个Storage Management主要做了以下的事:
- Relations as Files:把表分成多个文件,通过 RelFileNode 和 relpath 方法可以得到文件所在的路径
- File Descriptor Pool:用了一个文件描述符池来解决Unix不能同时打开过多文件的问题,它可以自动管理和分配 vfd ,封装起来,提供了一系列通过vfd访问文件的接口
- File Manager:一个表由多个文件组成(forks),每个文件包含多个页,元组数据就包含在这些页中。要标志一个页就要用到PageId,而这个PageId不是简单的id,而是 BufferTag 数据结构,包含了这个页属于哪个表的哪个fork文件里的哪一页的信息(FileName + offset)。要取出某个页时就需要magnetic disk storage manager 去用PageId获得 FileName和offset,使用FileName去 File Descriptor Pool 去得文件的 vfd,有了vfd 和 offset 就可以调用 File Descriptor Pool 提供的函数接口去访问对应的页
- Buffer Pool:它对访问磁盘的过程进行了优化,提供接口给Access Method用。有了缓存可以减少访问disk的次数。在不使用buffer pool前每次都要找 File Manager 去取一个块出来,使用buffer pool后,可以现去buffer pool找,找不到再去问 File Manager 要。
整个查询流程:在 Relational Operators 层要扫描一个表的 tuples
,就要 Access Methods 把这个表的 pages
取出来,它先用 PageId/BufferTag
去 Buffer Pool 找,如果找不到再去管 File Manager 要。
本章结束啦~