还有一些系统调用可以让我们来告诉操作系统如何操作 mmap 内存:
- madvise:告诉操作系统你会怎么读取,是随机读取还是顺序读取
- mlock:防止操作系统将某些页与物理内存解绑
- msync:告诉操作系统将某些页刷入磁盘
主流数据库(比如 MySQL,Postgres,Oracle,sqlserver 等等)都没有使用 mmap,那些在使用的大部分也正在考虑替换掉 mmap。
所以,我们总结下:
如果你将内存管理全权交给操作系统,操作系统可能会做出一些对于你的性能有损的决策,DBMS 通常都想自己控制内存管理策略,这样就可以带来:
- 根据当前数据库存储格式以及用户请求,决策脏页刷入磁盘的顺序,提高效率
- 根据存储结构以及用户请求,缓存预取提高查询速度
- 根据存储结构优化缓冲过期策略
- 多线程管理
3. 数据库文件存储结构-存储管理(Storage Manager)
数据库其实就是磁盘上的一个或者多个文件,例如 sqlite 整个数据库就在一个文件中,大多数其他的数据库例如 MySQL 则是分了不同文件存储。sqlite 本身就是轻量级的系统,MySQL 这种一般考虑存储性能以及限制等会划分成不同的文件,在现在的文件系统中,文件大小限制已经不太是一个考虑因素了,你甚至可以有超 PB 的文件,但是像是那种老的文件系统例如 fat32 那种,最大只支持 2GB 大小。我们这里提到了文件系统(File System),文件系统其实就是操作系统给我们提供的操作文件的 api,我们通过这些 api 操作文件(文件其实就是一堆存储上面的块)。大部分数据库是依赖操作系统提供的文件系统的,曾经有一些厂商提供了自己原生的文件系统专门用于自己的数据库产品使用,但是这样就丧失了迁移以及部署在云上的灵活性。
我们这里要实现的就是DBMS的存储管理器(Storage Manager)
存储管理器负责管理上面提到的数据库文件,我们在这里会向操作系统发送读和写请求,操作系统会调度这些读写。一些比较高端的系统,还会优化这些系统调度,即在这文件系统上抽象出来一个中介层,在这个中阶层管理本由操作系统的管理的调度,比如这个中阶层知道会有多线程发多请求写互相临近的块,这个中阶层会组合在一起形成一个写请求。这里我们就不做这个了,太复杂了。
在这些文件里面,是由页(page)组织起来的,由一组页组成。我们会以页的维度记录这些页的读写(例如哪些页还有空间可以存储新数据,哪些页里面有脏数据还没刷入磁盘,哪些页已经完全满了)
页其实就是固定大小的数据块,页中存储数据库中所有相关的数据,例如元组数据、元数据、索引还有日志记录等等。但是我们总是尽量将内容存储在单个页中,并且页需要是自包含的,即关于如何解释和理解页内容,所需要的所有信息都必须存储在页本身中。这样,即使丢失任何一页,也不会影响其他任何一页的解析和使用。如果你把元数据存储与元组数据分开存储在不同的页,如果元数据页丢失或者损坏了,那么元组数据页也就无法解析了。这种自包含的设计,对于容灾更好。
虽然不同种类的数据(例如元组数据、元数据、索引还有日志记录)都是存储在页中,但是在同一页内一般不会存储不同类型的数据。有一些研究型的数据库可能会在同一页中存储不同类型的数据,但是大部分系统都没有这么做。
每个页都有唯一的标识符即页 ID(Page ID)。我们使用这个唯一标识符形成一个中间层,在这个中间层维护 Page ID 与实际存储的文件的位置的映射,通过 Page ID 就能定位到对应的文件位置并读取这个页。同时如果我们移动了页(例如我们想扩展存储,压缩存储使用等等),Page ID 不会变,只是修改了文件位置,这比直接使用文件位置存储要方便很多。这样 DBMS 的其它层就不用关心这个,只记录 Page ID 就可以,我们在修改页位置的时候不用告知其他层同时修改。
我们有很多不同的层面有页这个概念:
- 硬件存储页(Hardware Page):通常是 4KB。也就是如果你写的数据在 4KB 以内,那么就能保证原子性,不会发生写了一部分掉电导致只写入了一部分成功的情况,只会要么全成功或者全写入失败
- 操作系统页(OS Page):通常也是 4KB
- 数据库页(Database Page):通常是 512~16KB,例如 MySQL InnoDB 页大小默认 16KB,目前 8.0 可以配置成 4KB 8KB 16KB 32KB 64KB。在一些比较高端的系统,你甚至可以对于不同类型的页设置不同的页大小,例如元组数据页大小比较小,索引页大小调整的比较大。
将数据库页调大,那么单页就可以保存更多的数据,那么后面会提到的页目录大小就会降低,页目录是用来找所有页的位置的存储,一般会一直存在于内存中,页目录越小,那么 CPU 缓存命中率越高。并且更多的数据在同一页上面,读取数据也是一页一页读取,这样对于缓存预取也有好处。但是相应的,写入也是一页一页刷入,带来的写入消耗也更大。这就是为啥那些商用的数据库允许你调整页大小的原因。
那么如何设计文件页结构呢,一般有三种:
- 堆文件组织(Heap File Organization):这是最常用的,我们也会主要研究这种。每种关系存储到一个单独的文件,文件中的记录是无顺序的。
- 顺序文件组织(Sequential File Organization):所有文件里面的记录内容,都按照记录的某个属性值的一定顺序排序好。
- 哈希散列文件组织(Hashing File Organization):使用记录的某些属性计算哈希值,决定存储在文件的哪个位置。
数据库堆文件是一个无序的页面集合,其中元组数据可以随机顺序存储。其实关系模型中,也没有对元组定义任何顺序。我们所要实现的就是针对页操作的增删改查 API,以及遍历所有页的 API。
同时,我们需要一些元数据追踪哪些页还有剩余空间,这样在我们需要插入新数据的时候就能快速定位到在哪里插入。
一般我们通过页目录(Page)这种方式设计堆文件中的结构,但是首先我们先来通过链表(Linked List)设计下文件结构来看下为何这种方式是愚蠢的。
如果使用链表设计的话,一般我们会维护一个 Header 页,在这个页 里面我们维护两个指针,一个指针是指向所有还有剩余空间的页链表的指针,另一个指针指向的是已经被填充满的页链表指针。这两个链表都是双向链表,我们可能顺序遍历也可能逆序遍历:顺序遍历一般用于找合适的位置比如找还有足够容纳我们要插入数据空间的页,逆序遍历一般用于移动。这并不是一个比较好的设计方式,主要是插入与更新的效率比较低
这是使用更多的设计方式,即页目录。我们维护一个专门存储目录的页,在这个目录中维护页 ID 到具体文件位置的偏移量的映射,可以简单把它理解成一个 key 为页 ID value 为文件位置偏移的哈希表。并且在这里还会维护页的空闲空间信息,这样在插入的时候,我们可以直接通过页目录直接定位到要插入的页。但是这样也带来了原子更新的问题,即页的空闲空间信息与插入数据是否在一个原子操作内。但是由于这个两个页的操作,硬件层面上我是很难保证两页更新是原子性的,所以我们需要额外的机制在数据库重启的时候检查是否有这些未完成的写入,这在后面讲恢复与日志的章节的时候会说到。