4. 页布局(Page Layout)
每个页都有页头(Header),在 Header 中一般包含:
- 页大小
- 校验和(CheckSum):这个可能会用来检查是否有未完成的写入(例如写一半就宕机了)
- DBMS 版本:创建这个页的数据库管理系统的版本,这个一般用于向前兼容使用,比如在某个版本后页布局发生了变化,我们可以通过这个 DBMS 版本让这个页的解析走不同的分支。
- 压缩相关信息(Compression Information):如果对页面做了压缩,需要标注一些信息,例如使用的算法,是 lz4 还是 gzip 等等。
- 事务可见性相关信息(Transaction Visibility):对于实现事务可见性需要的一些信息,例如是哪个事务修改了这个页的内容,以及修改后的内容在当前时间点对于谁可见等等。
页需要是自包含的,即关于如何解释和理解页内容,所需要的所有信息都必须存储在页本身中。这样,即使丢失任何一页,也不会影响其他任何一页的解析和使用。如果你把元数据存储与元组数据分开存储在不同的页,如果元数据页丢失或者损坏了,那么元组数据页也就无法解析了。这种自包含的设计,对于容灾更好。
在页内部,我们有两种方式存储元组信息:
- 面向元组存储(Tuple-oriented):即页中存储元组的数据
- 面向日志存储(Log-oriented):即存储修改元组的日志而不是元组数据本身
首先我们来看看面向元组的存储方式设计,首先看一个很糟糕的设计:
假设所有元组的字节长度都是一样的,那么实现方式比较简单,基本就是在头维护一个元组数量(这样我们就能直接跳转到要插入下一个元组的文件位置偏移,如果元组大小不一样,那么这里维护的就是要插入的文件位置偏移),如果有新的元组插入,则根据元组数量计算出要插入的位置插入然后更新头部计数。当需要删除一个元组的时候,假设删除的是 Tuple 2:
我们可以把 Tuple 2 本来占用的空间标记为未使用,而不是将 Tuple 2 之后的所有元组数据都向前移动。但是这带来了其他的问题,即存储碎片:
- 我想再插入新的数据的时候,假设所有元组的字节长度都是一样的,我可以插入到原来 Tuple 2 的位置,但是我怎么知道这个位置有空闲的空间呢?这样就需要引入额外的记录
- 如果元组的字节长度不一样呢?就更麻烦了
所以这种设计很不好,没人会这么做。一般采用槽页(Slotted Pages)这种设计:
这种设计被大部分数据库所采用,虽然在细节上有些不同,但是大致是这么个结构。在开头还是前面提到的页头,之后跟着两种存储结构
- 槽数组(slot array):从前向后写,在这个数组中的元素记录所有元组在文件中的起始位置偏移。槽数组可以解耦元组位置与外部访问,相当于前面提到的间接层。我们可能在页内部移动元组(比如更新元组导致元组长度改变会标记删除原始元组在最后追加新的元组数据),通过这个槽数组外部就可以不关心这个位置变化了。
- 实际元组数据:从后向前写
思考下:我们是否可以在这一页中存储来自于不同表的数据呢?实际上没有人这么做,首先是需要额外的元数据记录每个元组所属的表,然后是表数据访问一般具有局部性,即我们访问表的某一条数据,那么之后这个表这个记录他周围的数据也可能稍后会访问到,把他们放在同一页里面一起读取效率更高,如果在一页中混合了不同表的数据就丧失了这个局部性。
还有就是如果一个元祖或者一个元祖的某个属性数据大小超过一页的大小,那我们应该怎么做?这个下节课会讲到。这节课还是假设某个元组的数据只会存在于单页上。
5. 元组结构(Tuple Layout)
元组其实就是一个字节序列,DBMS 负责解析这个字节序列。
每个元组都有一个头,包含元组的元数据,例如:
- 元组的长度
- 可见性信息(Visibility info):例如是哪个事务那个查询最后修改的这个元组,其他事务或者查询根据这个信息以及事务隔离级别设置来决定是否能看到这个元组。
- NULL 位图(NULL BitMap):通过位图标记哪些列是 NULL 值
我们一般不将模式信息(例如有哪些属性列,列是什么格式的数据,是否可以为 NULL 等等)存储在每个元组的头部,这样会冗余太多数据造成存储浪费并且一页内包含更少的数据导致更新与读取效率下降。
然后是元组数据,我们通常按照创建表语句中的属性顺序去存储元组数据,大部分系统是这么做的。有的系统会对于属性进行重排序,让它能更适应内存对齐(例如 8 字节对齐)增加访问效率(内存与磁盘存储访问一般都是内存对齐的访问)。
DBMS 的其它层,如何在这种存储结构下定位一个元组的数据呢?
一般所有元组都会被分配一个唯一 ID,这个 ID 中会直接或者间接包页 ID 信息和槽(或者偏移量)信息,直接包含即从 ID 中直接就可以看出页 ID 以及槽信息,间接包含则是需要解码或者查询另一个元数据表来解析出页 ID 以及槽信息。可能还会包含文件位置信息用于定位去哪个目录或者文件位置去寻找页目录定位页 ID 对应的文件位置偏移量等等。这样,我们可以通过页 ID 查询页目录找出页对应的文件以及偏移量,根据槽信息读取页中的槽数组找到元组的位置进行读取。