简介
如下图所示,索引是这样一种数据结构,它以一个或多个字段的值为输入并能“快速地“找出具有该值的记录。具体来说,索引使我们只需查找所有可能记录中的一小部分就能找到所需记录。建立索引的字段(组合)成为查找键。
本节我们将介绍数据库系统中最常用的索引结构:B-树。同时我们还会讨论另一种重要的索引结构,即在辅助存储器上的散列表索引。最后,我们考虑适用于多媒体数据的索引。这些索引结构都支持在一个或多个属上的值查询和范围查询。
索引结构基础
一个数据文件可以用来存储一个关系。一个数据文件可能拥有一个或多个索引文件,每个索引文件建立查找键和数据记录之间的关联,查找键的指针指向与查找键具有相同属性值的记录。
索引可以是稠密的,即数据文件中的每个记录在索引文件中都设有一个索引项;索引也可以是稀疏的,即数据文件中只有一些记录在索引文件中表示出来,通常为每个数据库在索引文件中设立一个索引项。索引还可以是主索引或辅助索引,主索引能确定记录在数据文件中的位置,而辅助索引不能。通常我们会在关系的主键上建立主索引,而在其他键上建辅助索引。
顺序文件是对关系中的元组按主键进行排序而生产的文件。关系中的元组按照这个次序分布在多个数据块之中。
如果记录是排好序的,我们就可以在记录上建立稠密索引,如下图所示,它是这样一系列的存储块:块中只存放记录的键以及指向记录本身的指针。一般来说,查找键和指针所占存储空间远远小于记录本身。基于索引的查找之所以效率很高是因为:
1 索引库数量通常比数据块数量少很多
2 由于键被排序,我们可以用二分查找法来查找特定的键K。若有n个索引块,只需查找个块
3 索引文件可能足够小,以至可以永久存放在主存缓冲区中。这样的话,查找键K时就只涉及贮存访问而不需要执行I/O操作。
稀疏索引只为数据文件的每个存储块设一个键-值对,键值是每个数据库中第一个记录对应的值。它比稠密索引节省了更多存储空间,如下图所示,在已有稀疏索引的情况下,要查找键值为K的记录,我们得在索引文件中查找键值小于或等于K的最大键值。
索引文件可能占据多个存储块,这时查找索引也需要很多次I/O操作,此时我们可以建立多级索引,如下图所示,如果二级索引不够,还可以再建立三级索引,但这很少使用,因为用B-树能更好的解决这个问题。
辅助索引总是稠密索引,谈论一个稀疏的辅助索引是毫无意义的,因为辅助索引不影响记录的存储位置,我们也不能根据它来预测键值所在的位置,一个典型的辅助索引如下图所示。
上面索引中相同记录较多,存在空间浪费,一个简单的优化方法是使用一个称为桶的间接层,它介于辅助索引文件和数据文件之间,如下图所示,每个查找键K有一个键-值指针对,指针指向一个桶文件,该文件中存放K的桶。只要查找键值的存储空间比指针大并且每个键平均出现至少两次就可以节省空间,即使空间节省不大,在辅助索引上使用间接层也有一个重要好处:我们通常可以在不访问数据记录的前提下利用桶的指针来帮助回答一些查询。特别是,当查询有多个条件,而每个条件都有一个辅助索引时,我们可以通过在主村中将指针集合求交集来找到满足所有条件的指针,然后只查询交集中指针指向的记录,减小I/O开销。
B-树
虽然一级或二级索引通常有助于加快查询,但商用数据库系统一般使用一种更通用的结构,这一数据结构家族成为B-树,而最常使用的是其成为B+树的变体。B-树的优点在于:
1 B-树能自动的保持与数据大小相适应的索引曾测
2 对所使用的存储块空间进行管理,是每个块的充满程度在半满和全满之间
B-树把它的存储结构组织成一棵树,这棵树是平衡的,即从树根到树叶的所有路径都一样长,如下图所示。通常B-数有三层,根,中间层和树叶,但也可以是任意多层。每一个B-数都有一个参数n,每个存储块存放n个查找键值和n+1个指针。在存储块能容纳n个键和n+1个指针的前提下,我们把n取得尽可能大。假如存储块大小为4096个字节,且整形键值占4个字节,指针占用8个字节,那我们希望找到满足4n+8(n+1)<=4096的最大值n,这个值是340。假如我们取一个中间值255,即每个节点有255个指针,则三层的B-树有255*255*255=1660万条记录,对大部分场景来说足够了。
B-树需要满足的限制规则:
1 叶节点的键是数据文件中键的拷贝,这些键以排好序的形式,从左到右分布在叶节点中
2 根节点至少有连个指针被使用,所有指针指向B-树下一层的存储块。但如果只有一条记录,这个条件可以不满足,此时,树根节点同时也是叶节点
3 叶节点中,最后一个指针指向它右边的下一个叶节点存储块。在叶节点的其他n个指针中,至少个指针指向数据记录,如下图所示
4 在内层节点中,所有n+1个指针都可以用来指向B-数中下一层的块。它们中至少个指针真正使用。如果j个指针被使用,那么块中将有j-1个键,设为,第一个指针指向B-树的一部分,一些键值小于的记录可以在这一部分找到,第二个指针指向B-树的另一部分,所有的键值大于等于且小于的记录在这一部分找到,以此类推。
5 所有使用的键和指针通常都存放在数据块的开头位置,叶节点第n+1个指针是个例外,它用来指向下一个叶节点。
一些常用的B-树构造场景如下:
1 B-树的查找键是数据文件的主键,且索引是稠密的。该数据文件可以按主键排序,也可以不按主键排序
2 数据文件按主键排序,B+树索引是稀疏索引,在叶节点中为数据文件的每个块设置一键-指针对
3 数据文件按非主键属性排序,该属性是B+树的查找键,叶节点中为数据文件里出现的每一个属性值K设有一个键-指针对,指针指向排序键值为K的记录中的第一个。
B-树的查找
基础:若我们处于叶节点上,我们就在其键值中查重,若第i个键是K,则第i个指针可让我们找到所需记录
归纳:若我们处于内部节点,其它的键为。如果,则为第一个子节点;若果,则为第二个子节点... ...依此类推。
范围查询和单个键值查询类似,因为最终的叶子节点是一个有序的单链表,只需找到起始节点,依次往后找即可。
B-树的插入
1 我们设法在适当的叶节点为新键找到空闲空间,如果有的话,我们就把键放在那里
2 如果适当的叶节点中没有空间,我们就把该叶节点分成两个,使每个新节点都刚好有一半或超过一半的键。假定N是一个容量为n个键的叶节点,我们正试图给他插入第n+1个键和它对应的指针。我们创建一个新的叶节点M,该结点成为N的兄弟,紧挨在N的右边。按键排序的前个键-指针对保留在N中,剩下的键-指针对移到M中。
3 某一层的节点分裂在其上一层看来,相当于在这一较高层次插入一个新的键-值对,我们递归的应用这个策略。假如N是一个容量为n个键和n+1个指针的内部节点,并且由于内部分裂正好又被分配第n+2个指针,首先创建一个新节点M,它将是N的兄弟节点且紧挨在N的右边。按顺序将前个指针保留在N,剩下的个指针移到M。前个键保留在结点N中,后个键盘移到M,中间的键值K会被保留,既不在M也不在N,而是被节点N和M的父节点用来划分这两个节点之间的查找。
4 例外情况是,当我们试图插入键到根节点并且根节点没有空间,那么我们把根节点分裂并形成一个新的根节点,因为根节点要去只需两个指针即满足条件
B-树的删除
1 如果发生删除的B-树节点在删除后还有最小数目的键和指针,就不需要再做什么
2 如果删除后,节点数刚好小于要求的最小数目,则需做如下两个操作之一:
2.1 如果与N节点相邻的兄弟中有一个的键和指针超过最小数目,那它的一个键-指针对可以移到节点N中并保持键的顺序。节点N的父节点的键可能需要调整以反映这个新的情况。
2.2 困难的是相邻兄弟中没有一个能提供键-值给节点N时,此时,我们有节点N和它的一个兄弟节点M,后者的键数刚好是最小数,前者的键数小于最小数。因此,它们合在一起也没超过单个节点所允许的键和指针数,因此,我们把这两个节点合并,并删除父节点的一个键和指针。如果父节点足够满,那我们就完成了删除操作,否则,我们需要在父节点上递归地运用这个删除算法
B-树的效率
由于B-树一般三层就可以,只需3次I/O操作就可完成查询。B-树的根节点永久地缓存在内存中是绝佳选择,这样只需两次I/O操作即可查找3层B-树。在某些情况下,把B-树的第二层节点保存在缓冲区中也是合理的。这样,B-树的查找就减少到一次磁盘I/O再加上处理数据文件本身所需磁盘I/O。
散列表
内存散列表
散列表是一种常见的数据结构,在这种结构中有一个散列函数h,它以查找键为参数并计算出一个介于0和B-1之间的整数,其中B是桶的数目。桶数组,即一个序号0到B-1的数组,其中包含B个链表的头,每一个对应于数组中的一个桶。如果记录的查找键为K,那么我们通过将该记录链接到桶号为h(K)的桶表中来存储它
辅存散列表
辅存上的散列表和主存中的散列表存在一些区别,如下图所示。首先,桶数组有存储块组成而不是由指向链表头的指针组成。通过散列函数h散列到某个桶中的记录被放到该桶的存储块中。如果桶中有太多的记录,可以给该桶增加溢出块的链以存放更多记录。我们将假定,只要给一个i,桶i的第一个存储块的位置就可以被找到,比如,主存中有一个指向存储块的指针数组,数组项以桶号为序号。另一种可能是每个桶的第一个存储块存放咋磁盘上某固定的连续的位置,这样我们就可以根据i计算出桶i的位置。
当一个查找键为K的新纪录需要被插入时,我们计算h(K)。如果桶号为h(K)的桶还有空间,我们就将该记录存放到此桶的存储块或已存在的溢出块的存储空间。否则,增加一个新的新出块到该桶的链上,并将新记录存入该块。删除查找键值为K的记录与插入操作相同,删除后,我们可选择在存储块中移动记录,也可以选择合并同一链上的存储块。
散列表索引效率
理想情况是有足够的桶,绝大多数桶只由单个块组成,如果这样,那么一般查询只需一次I/O,且文件的插入和删除只需两次I/O。需要注意的是,索引查询不能支持范围查询,并且随着文件不断增长,最终会出现一般的桶链表中有许多块的情况,每个块至少需要一次I/O。因此,我们必须设法减少每个桶的块数。
到目前位为止,我们学过的散列表都被称为静态散列表,因为桶的数目B从不改变,下面我们介绍两种动态散列表,它们允许B改变。
可扩展散列表
1 为桶引入一个间接层,即用一个指向块的指针数组来表示桶,而不是用数据块本身组成的数组来表示桶,如下图所示,每个存储块的小方块中的数字1,表明由散列函数中的多少位确定记录在该块中的成员资格。
2 指针数组能增长,它的长度总是2的幂,因而数组每增长一次,桶的数目就翻倍
3 不是每个桶都有一个数据块,如果某些桶中的记录可以放在一个块中,那么,这些桶可以共享一个块
4 散列函数h为每个键计算出一个K为二进制序列,该K足够大,比如32。但是,桶的数目总是使用从序列第一位或最后一位算起的若干位,此位数小于K,比如说是i位,此时桶数组有个项
可扩展散列的插入类似静态散列函数的插入。为插入键值为K的记录,我们先计算h(K),取出这一二进制序列的前i位,并找到桶数组中序号为i位二进制对应的项。我们根据桶数组中该项的指针找到某个存储块B,如果B中还有存储空间,直接插入新纪录即可。如果B中没有空间,那么根据存储块右上角的j值采取不同措施:
1 若如果j
a)将块B分裂成两个存储块
b)根据记录散列值的j+1位,将B中的记录分配到这两个存储块中,该位为0的记录保留在B中,该位为1的记录存储到新块
c)把j+1存入两个存储块的小方块中,以表明用于确定成员资格的二进制位数
d)调整桶数组中的指针,是原来指向块B的项指向块B或新块,这由项的第j+1为决定
2 如果j=i,那么我们先将i加1,是桶数组长度翻倍。在新桶数组中,w0和w1的项都指向原w指向的块,由于现在i>j, 按照步骤1进行即可。
线性散列表
可扩展散列表存在一些缺点:
1 桶数组翻倍时,需做大量工作
2 翻倍后,内存中可能就装不下来,需要涉及磁盘I/O操作
3如果每块记录很少,那么很有可能某一块的分裂比在逻辑上需要分裂的时间提前许多。比如,块中记录很少,但某几个记录的前20位二进制已有,这样,我们不得步用i=20和100万个桶数组项,尽管记录的块数远小于100万。
另一种称为线性散列的策略,桶的增长较缓慢,如下图所示,其特点是
1 桶数n的选择总是使存储块的平均记录数保持与存储块所能容纳的记录总数成一个固定比例,比如80%
2 由于存储块并不总是可以分裂,所以允许有一出块,尽管每个桶的平均溢出块数远小于1
3 用来做桶数组序号的二进制位是,其中n是当前的桶数。这些位总是从散列函数得到的位序列的右端(低位)开始
4 假定散列函数值的i位正在用来给桶数组项编号,且有一个键值为K的记录想要插入到编号为有的桶中;那么,把当作二进制数,设它为m。如果m 5 每次插入,我们都用当前记录总数/n的值跟阈值r/n相比,若当前比例太大,我们就增加一个桶到线性散列表中。注意,新增加的桶和发生桶之间没有任何连续。我们新加入的桶号的二进制表示为,那么我们就分裂桶中的记录,根据记录的后i位值分别存入这两个桶。最后一个细节是当n超过时,i的值加1。从技术上来讲,所有的桶号前面都需要加一个0,但由于这些序列位被解释成整数,因此不需要做任何物理上的变化。