本文根据CMU15-445课程内容编写,因为数据库术语较多,为避免翻译问题带来的理解偏差,部分术语使用英语表达。
1. 存储
之前我们讨论了从一个较高的维度来看数据库的运行是怎么样的,我们利用数据库存储数据,并且在数据库上运行SQL语句。现在我们研究如何构建一个数据库软件。
首先明确一点:我们假设数据库的主要存储位置是在非易失性的磁盘上。
也就是说每当我们需要数据库中的数据时,我们都需要将其从磁盘取到内存当中。然而,在存储层次中,内存离CPU较近,存取速度较快,而磁盘离CPU较远,磁盘的存储速度较慢。因此,我们在设计数据库时,出于运行速度的考虑,应当尽可能减少磁盘IO。同时,一般来说,磁盘的顺序读取速度大于其随机读取的速度,因此,我们更希望存取磁盘上连续的数据。
一句题外话:近年来一种非易失性内存很火,这是一种理想的存储设备,因为其兼顾了内存和磁盘的优点,所以基于非易失性内存的数据库设计也是未来的一个重要研究方向。
同时注意一点,在本门课中,我们更多地关注如何减少磁盘的延迟,也就是磁盘->内存的过程,而不是如果在寄存器和缓存中做优化(当然这个也很重要,会在15-721中涉及到)。
2. 基于磁盘的数据库管理系统概览
数据库文件在磁盘上以页面的形式管理。一个文件有多个页面,其中,第一个页面是目录页,用来存放一些元数据,剩下的页面是数据页,存放元组数据。当我们需要数据库中的页面时,DBMS需要将页面从磁盘取到内存。
DBMS在内存中维护一个缓冲池来管理数据在内存和磁盘之间的移动。同时,DBMS有一个查询引擎来执行一些查询。这个查询引擎在执行过程中向缓冲池申请对应页面的数据,然后由缓冲池负责将对应页面从磁盘复制到内存中,并返回给查询引擎该页面在内存中的指针,并且缓冲池保证在查询引擎执行的过程中,该页面一直在缓冲池中留存。
Q1: 为什么不用操作系统来管理内存,而需要数据库自己设计缓冲池来管理内存?
A1: 让操作系统来管理内存是一个很糟糕的想法,因为操作系统对数据库中的数据一无所知,它不知道什么时候可以将数据写出到磁盘,也不知道数据库中的数据代表的含义,这会让数据库设计变得复杂。而如果让数据库自己设计缓冲池模块,就可以轻松实现,针对数据特点的预取,用正确的顺序将数据写回到磁盘等等功能。
3. 文件存储
DBMS将数据库以文件的形式存储,一个数据库就是一个或者多个文件(大多数DBMS会保存为多个文件,因为单个文件发生故障的话很难恢复)。并且通常DBMS会设计自己的专门的文件格式,也就是说我们没办法将MySQL
没办法直接读取Sqlserver
的数据库文件。对于操作系统来说,操作系统并不了解数据库文件的内容,在操作系统看来,数据库文件只是一串2进制数据,但是我们一般会基于操作系统提供的文件系统API来读取和写入文件。
每一个文件有一系列的页面组成。页面是一个固定大小的数据块,可以存储任何数据,比如,元数据,元组,索引,日志等等。不过大多数系统不会混合存储不同类型的数据,例如,一个页面不会同时存储元组和索引。
一些系统要求页面是self-containted
的,意思是解释页面所需的信息都是包含在该页面中的,例如当我们存储数据库表时,一般的做法是将该表的元数据放到一个单独的页面,将其余的元组信息放到其他的页面。但是如果页面是self-contained
的,那么每一个存储元组信息的页面都必须包含这个表的元数据,这样做的目的是,当我们发生磁盘故障时,我们打开任何一个页面,都可以解释这个页面存储的信息内容。
每个页面都有一个特殊的标识符,一般叫做page_id
,数据库用一个映射层将page_id
映射到物理位置,映射层的目的是方便文件在磁盘中的移动,当文件位置发生变化时,我们只需要修改这个映射的位置即可。这种做法在之后会经常见到,因为其优点很明显,我们修改底层文件的位置时,只需要修改这个映射层,修改信息不会传导到上层。
在DBMS中,有许多“页面”的概念,现在我们需要对这些概念做一些区分:
- 硬件页面(一般是4KB)
- 操作系统页面(一般也是4KB)
- 数据库页面(512Byte~16KB)
我们讨论的一般是数据库页面,数据库页面的大小不一致,不同的系统会设置不同的页面大小,一些高级的数据库允许用户自定义数据库页面的大小。
这里有一个潜在的问题:不管我们数据库页面设置的大小是多大,硬件页面的大小是不变的,也就是说硬件一次写入或者读出的页面大小就是固定的4KB。所以如果我们将页面设置地比硬件页面更大,比如8KB,当我们写入页面时,就需要执行两次硬件写入,这是一个非原子性的操作,数据库有可能在两次写入之间发生故障,从而导致数据库数据出错。
4. 页面存储架构
文件是有页面组成的,那么多个页面存储的结构是怎么样的呢?一般而言,有下面几种方式:
- 堆文件结构
- 顺序/排序文件结构
- 哈希文件结构
一般而言,我们采用的是第一种方式。
一个堆文件是一个无序的页面集合,其中元组是乱序存储在页面中的。同时,一个堆文件需要一些元数据来记录文件中有哪些页面(方便查找)和哪些页面是空闲的(方便插入)。有两种方式实现这个:
- 链表:维护一个头页面,在头页面中存储两个指针:空闲页面头指针,数据页面头指针,分别指向其他的数据页面。这个方法很糟糕,因为我们每次都要插入和查找元组都要遍历所有的页面。几乎没有人会使用这个方法。
- 目录页:维护一个或几个特殊的页面专门用来记录文件中其他数据页的位置,同时记录这些数据页还有多少空闲空间,当然,DBMS必须保证这个目录页的信息和文件中的数据页是同步的。
Q2: 为什么有些数据库要用更大的页面(超过4KB),即使这样会造成数据写入的非原子性?
A2: 这其中有一些权衡,类似于操作系统中的页表缓冲(TLB)。如果我们的页面过小,那么需要的页面数量就会更大,这样页表就会更大。这样每张页表的命中率可能就会降低,(我们将页表放入内存,根据页表来查找其他数据页)。当然,如果采用更大的页表,我们就需要添加一些额外的逻辑来保证写入页面的时候不出现错误。
5. 页面内部布局
页面内部分为两部分,第一部分是页面头部,第二部分是页面数据。每个页面都会在页面头部保存该页面的一些元数据,例如,页面大小,校验和,DBMS版本等等。也包括一些其他元数据,比如,我们之前提到的,部分系统要求页面是self-contained
。
在页面内部,有两种组织数据的方式,基于元组的和基于日志结构的。两种方式的不同之处在于,前者保存数据库表的一个一个元组,而后者仅仅保存一些日志,也就是说,每当我们要取数据时,都要运行这些日志来得到数据。一般我们讨论基于元组的数据组织方式。
最常见的页面内部布局方式是slotted pages
。这种方式是基于元组的,它将页面分为了三部分,第一部分是头部,保存一些元数据,第二部分是slot array
,第三部分是数据部分。slot array
从头部开始,向后增长,每个slot
保存一个指针,指向对应元组在数据部分的开始位置。数据部分从页面尾部向前增长。头部记录哪些slots
已经被使用,以及最后一个slot的开始位置。
采用slotted pages
可以通过page_id + slot
唯一标识一个元组。
对于日志结构的存储方式,DBMS不直接存储元组,而仅仅存储日志记录。也就是说数据库只存储数据是如何被修改的(插入,修改,删除等语句)。每当要读取数据时,DBMS都会从日志的某一个点开始扫描日志然后计算得到这些元组并返回。可想而知,这种方法写入的效率很高,但是对于读取的效率很低。因此十分适合append-only
型的场景。
6. 元组内部布局
刚才我们将了页面内部的布局,现在我们讲解元组内部的布局。一个元组其实就是一系列的字节,至于如何解释这些字节,则和我们数据库的模式相关。DBMS根据我们表的定义来将这些字节翻译为对应的类型的值。
元组内部分为两部分,第一部分是元组头部,第二部分是属性数据:
元组头部存放一些元数据,例如可视性信息,比特图等。元组数据部分则一般按照表定义时的顺序存放字节数据:
同时,需要注意的是,大部分DBMS不允许一个元组跨页面存储。也就是说不能出现一个元组的前一部分在一个页面中,后一部分在另一个页面中。并且同一个页面一般只能存储一张表的元组,不能将多个表的数据都存放在一张表上。