前面的文章我们已经讨论了B树的基本结构和操作,现在我们来看看数据库系统中B树是怎样被存储到磁盘文件的。磁盘的访问和内存的访问方式是完全不同的,操作系统的虚拟内存机制使得应用程序可以透明的访问内存;而访问磁盘时需要借助系统调用并指定访问文件的位置,然后还要将文件的内容读取解析到内存中。
当在设计高效的磁盘数据结构时必须考虑到以上这一区别。下面我们将讨论如何设计易于创建、修改和解析的文件格式,讨论设计高效磁盘数据结构的通用原则和实践,这些原则不仅仅只适用于B树。
事实上有很多可行的方式可以实现B树的磁盘存储,这里我们讨论几个常用的实现方式。每个具体实现的细节可能略有不同,但一般原则是相同的。在真正实现B树磁盘存储时,仅仅理解B树的基本机制(比如节点拆分和合并)是不够的,许多其它因素也必须一并考虑才能达到最终目的。
在磁盘数据结构中,指针的含义和内存中的含义有很大的不同。事实上可以把磁盘B树看成是一种文件页面管理机制:它组织文件页面并能快速的定位页面,因此文件的页面和指针都必须正确的设置和保存到适当的位置。
页面结构
数据库系统在数据文件和索引文件中存储数据记录,这些文件被划分为大小固定的称为页的单元。页的大小是文件系统块的大小的倍数,通常在4到16Kb之间。
B树中节点分为两类,包含键和数据记录对的叶子节点,以及包含键和指向其它节点指针的非叶子节点。每一个节点由一个或多个页构成,所以B树中节点和页可以认为是等同的。
在最早关于B树的论文中描述了一种存储固定大小数据记录的简单页数据组织方式。如图3-4, 每一页包含多个由三个成员组成的结构:k存储键,v存储键关联的值,p指向子页面。
![ce4f7fa53ccc15321666e8709cafc9ab.png](https://i-blog.csdnimg.cn/blog_migrate/712364c712fb765644eca496a69da829.jpeg)
图3-4
这种方法很容易实现,但是有一些显著的缺陷:
- 添加键值时为了保持有序需要移动其右边的元素;
- 仅仅适用于固定大小的数据记录,无法保存可变大小的数据记录;
分槽页结构(Slotted Pages)
当存储可变大小记录时我们面临的主要问题是空闲空间的管理:即删除数据记录后释放空间的回收管理。假如一条待插入数据记录的大小为N,而此前删除数据记录的大小为M,除非恰好M等于N,否则就会有M-N的空间不能被利用;同样的,如果此前删除数据记录空间的大小为K,K小于待插入记录的大小N,那么删除释放的空间也无法直接利用,必须为待插入记录分配新的空间。
因此,我们需要一种结构,它能够:
- 能存储可变大小的记录,同时额外存储开销最小;
- 能回收被删除记录占用的空间;
- 引用记录时不需要考虑记录在页中的具体位置;
分槽页结构恰巧具有上面这些些特征,这种页数据组织方式在许多数据库中都被使用了,例如PostgreSQL。每个页面分割为许多槽(Slot或Cell),同时页面中指针指向这个槽。 指针和槽分布在页面的两个不同的区域中。我们只需要调整指针位置来保证槽中的数据有序即可,删除记录时我们也只需要清空或移除指向槽的指针即可。
分槽页中还包含一个固定大小的头,一些关于页和槽的信息被保存在头中。图3-5是一个分槽页的结构示意图,每一个页面维护了一个页头区域,槽和指向槽的指针。
![f518c0d6bbbaaa852e448582f9d936b4.png](https://i-blog.csdnimg.cn/blog_migrate/af15984b246ccef57be28dc67047e718.jpeg)
图3-5
现在让我们看看这个是怎么解决我们的问题:
- 最小的开销: 分槽页中的惟一额外开销是一个指针数组,它保存了记录在页面中偏移的准确位置;
- 空间回收: 通过页面碎片整理并重写页面就可以回收空间;
- 动态布局: 在页面外部就需要引用槽ID,不需要设计页面内的具体位置;
槽的结构
这里我区分键槽和键值对槽。键槽保存一个分割键和一个指向子节点页面的指针;键值对槽保存键和与之关联的数据记录。
我们假设在同一页上所有的槽的类型是相同的,同一页面要么是存储固定类型的数据记录要么就是存储可变类型的数据记录。这样我每一页上只需要存储一份描述也信息的元数据,不需要在每一个槽内都维护一份。
对于键槽,我们需要保持如下这些信息:
- 槽类型
- 键的大小
- 该槽指向的子页面ID
- 键
![5b152cea885da7a16077738373513658.png](https://i-blog.csdnimg.cn/blog_migrate/ae4ac1229859f25dc57236c3a5b88176.jpeg)
我们把固定大小的字段放在一起,然后紧跟着的是key_size个比特的键。不是要求必须这样的,但是可以简化偏移量的计算,因为所有固定大小的字段都可以通过使用静态的、预先计算好的偏移量来访问。我们只需要为可变大小的数据计算偏移量。
键值对槽(key-value cells)保存数据记录,具有如下字段:
- 槽类型
- 键的大小
- 数据记录的大小
- 键
- 数据记录
![c43c707c7f2b9cd7f013b695c8acd39c.png](https://i-blog.csdnimg.cn/blog_migrate/8e195fac75610e525f09e2bdde1f3f5d.jpeg)
因为页是固定大小,所以我们只需要保存页面ID就可以了。在需要时可使直接计算出在文件中的偏移。槽的偏移是相对于页的偏移的。通过这种方式,我们可以用一个更小的整数就可以保存偏移。
使用槽构成分槽页
槽(Cell)被保存到页的右边,由右边往左边扩展;槽的偏移(指针)保存在页的左边的偏移数组中,由左往右扩展。如图3-6。
![5dca91b059ff7aa1c4827b23f7b1e8e3.png](https://i-blog.csdnimg.cn/blog_migrate/892ed56a6977bb9251449ab90afc5bec.jpeg)
图3-6
右边槽中的键不需要按照顺序保存,只要左边槽的偏移指针是按键有序保存的即可。这样插入槽到页面时开销最小,因为在插入,更新或删除时槽都不需要移动。
以一个保存名字的页面为例:插入两个名字(先插入Tom,然后插入Leslie)。 Tom所在的槽首先被添加到最右边,指向该槽的偏移被保存到偏移数组的第一个元素;接下来插入Lesliebe,按字母序Lesliebe在Tom之前,指向Tom的偏移从偏移数组的第一个元素移动到第二个元素,Lesliebe的偏移保存到第一个元素。这样从偏移数组低位置到高位置依次访问,名字依然是有序的,可以允许二分查找。
![a5395b06ffd0e5b1401136540ab46c43.png](https://i-blog.csdnimg.cn/blog_migrate/772203dc09423f169fb8c90f08caeed3.jpeg)
图3-7
现在再添加一个名子Ron,Ron所在槽被添加到最右边空闲的位置。但是偏移数组需要保证三个名字是按知名有序的:Leslie, Ron, Tom。为了,我们需要调整偏移数组中各个槽偏移的保存位置,即插入点后面的偏移往右边移动,空出一个新的位置保存Ron槽的偏移。最终结果如图3-8。
![71871ebe00ee8158b0c38882b05ed0ac.png](https://i-blog.csdnimg.cn/blog_migrate/ad31709717cb62bd9e92f2eade85351d.jpeg)
图3-8
管理可变大小数据
从页面(节点)中删除条目时并不需要删除实际的条目并移动其它条目到释放的空间。可以仅仅是将条目标记为已删除,并报该条目释放的空间数量和空间位置添加到可用空间列表中。可用空间列表中存储未被使用存储空间的偏移量及其大小。在插入新条目时,检查可用空间列表中是否有适合它的段。如图3-9中,空闲空间列表保存了所有可用空间的偏移。
![2c530588ced17b42f6160cb1c56692d8.png](https://i-blog.csdnimg.cn/blog_migrate/595473582740d51d3c9f5c400e4891e7.jpeg)
图3-9
在SQLite中,未被使用的空间被称为空闲块(free blocks),页头中保存了第一个空闲块的偏移(指针)。另外,页头中也保存了页面所有空闲块的大小之和,在插入一个新元素可以空闲空间的大小快速的判断是否可以通过碎片整理获得足够的存储空间。
在为数据选择空闲块时,可以根据以下策略进行:
- 首次满足(Fist Fit): 第一个大小可以存储槽的的空闲块。这种策略可能导致剩下的空间过小,无法存储任何其它槽而导致空间被浪费;
- 最适合(Best Fit): 遍历所以的空闲块,找到一个大小最匹配的块,即插入后剩余空间最小的空闲块。
如果找不到足够的连续字节来容纳单元,但是有足够的碎片字节可用,那可以对页面进行碎片整理,生成足够大小连续的空间来容纳新单元。如果即使在碎片整理之后也没有足够的空闲空间,那必须要创建一个溢出页面(我们后面会继续讨论)。
深入理解数据库系统(储存引擎概述1)
深入理解数据库系统之存储存引擎2(数据和索引)
深入理解数据库系统之存储存引擎(二叉搜索树)
深入理解数据库系统之存储存引擎(B树)