索引与散列
顺序索引
索引结构与一个特定的搜索码相关联,用于快速随机访问表中的一个字段。搜索码可以是表中的任何属性。
顺序索引按顺序存储搜索码的值,并将搜索码与每个包含搜索码的记录关联起来。
如果被索引的文件自也按照一定顺序排序存储的话,那么可以将索引类型细分:
- 聚集索引(clustering index):被索引的文件中的记录按照某个搜索码的顺序存储,该搜索码对应的索引称为聚集索引,或主索引(primary index)
- 非聚集索引(nonclustering index):被索引文件中的记录顺序不按照搜索码指定顺序存储,这种索引称作非聚集索引,或辅助索引(secondary index)
主索引不一定非要建立在主码上,但一般是这样的。
稠密索引和稀疏索引
索引项(index entry)或者说索引记录(index record)由一个搜索码值和一个指向具有该搜索码值的一条或多条记录的指针构成,指针中包括磁盘块标识和记录在块中的偏移量。
- 稠密索引(dense index):文件中的每个搜索码都有一个索引项与之对应,在
instructor
的例子中,假如ID是搜索码,那么稠密索引中包含所有存在的ID - 稀疏索引(sparse index):文件中只有部分搜索码才有索引项与之对应,在
instructor
的例子中,假如ID是搜索码,那么稀疏索引中包含一部分教师的ID
对于稠密索引,如果索引是聚集的,则索引项中可以只包括搜索码值和具有该搜索码值的第一条记录的指针,因为相同搜索码值的记录就排在这条记录后面。而如果索引不是聚集的,那么索引项中需要存储搜索码以及具有该搜索码值的所有记录的指针列表。
稀疏索引一样,索引项中包含搜索码值和具有该搜索码值的第一条记录的指针。稀疏索引必须是聚集的,如果你想通过稀疏索引定位一条记录时,你只能从索引中找到离这个搜索码最近的那个索引项(所有比它小的索引项中的最大的那个),然后按顺序往下查找,直到找到具有相同搜索码的记录。
如下两个图演示了稠密索引和稀疏索引
在这个例子中,如果你想定位搜索码为22222
这个人,在稠密索引中你可以直接通过索引找到它,而稀疏索引,你只能找到10101
,然后顺序向下查找3个人。
稠密索引具有更快的查找速度,但占用更大的空间。在数据库系统中,一个影响性能的主要开销就是把块读到内存中,在已经读取到内存中的块上操作的开销可以忽略不计,如果为每个块建立一个索引项,所有块的索引项组合成一个稀疏索引,那么我们可以很快的定位待查找的搜索码在哪个块中,然后只加载对应的块。
多级索引
索引比记录来说,通常小得多,但是当一个表中的记录非常多的话,索引占用的规模也是可观的。所以索引文件一般都顺序存储在磁盘上。
为了系统的效率,存在于磁盘上的东西我们就要尽量少的访问次数,由于索引文件是顺序存储的,所以我们可以使用二分法来查找定位索引文件当中的索引项,如果索引文件占据了b个磁盘块,那么二分算法需要\(\left \lceil \log_2(b) \right \rceil\)次磁盘块读取。
对数级别在算法中已经算效率不错了,但是我们的操作目标是磁盘,以2为底的对数级别还是有些慢。如果读一个块平均需要10ms,索引文件占用10000个块,二分搜索每次需要读取14个块,那么每次查找就耗费了140ms。你的数据库肯定不可能每秒钟只能读取7次。
如果出现了溢出块,无法使用二分查找,那么算法效率会退化到线性级别,需要b次读取块。
多级索引的原理是给索引创建索引,比如占用10000个块的索引项,我们给每个块构造一个稀疏索引(因为内层索引总是有序的),然后当想要定位一个搜索码时,先从外层的稀疏索引中使用二分查找找到保存内层的实际索引的块,然后扫描这一块即可。
如果二级索引的大小还是太大,导致搜索效率变低,那么可以再给二级索引之上建立一个三级稀疏索引。
所以二级索引的时间复杂度也是对数级别,只不过底数大了?貌似很多有关IO读取操作的算法都是这样优化啊,稍微在内存中处理多些来换取更少次的IO。类似B+树和二叉树的那种感觉。
索引的更新
当文件中有插入或删除时,索引文件也要相应更新,如果发生了更新,如果搜索码受影响,那么包含该搜索码的索引也要更新。我们可以不关心表更新而带来的影响,因为更新可以看作删除原索引项插入新索引项。
插入
-
稠密索引:
- 如果插入的记录的搜索码不在该索引中,在适当的位置插入具有该搜索码的索引项,完成
- 如果索引项保存的是搜索码相同的第一条记录的指针时,把待插入记录放到所有具有相同搜索码值的其他记录之后,完成
- 如果索引项保存的是搜索码相同的所有记录指针的列表时,待插入记录随便插入到一个位置,并更新索引项中的指针列表
-
稀疏索引:假设为每个块保存一个索引项
- 如果创建了新的块,将块中的第一个搜索码值保存成一个索引项
- 如果插入的记录含有块中的最小搜索码值,更新该块的索引项
删除
-
稠密索引:
- 被删除的记录是具有该搜索码的唯一记录,删除
- 如果索引项保存的是搜索码相同的第一条记录的指针时,如果被删除的记录是第一条记录,修改索引项,指向下一条记录
- 如果索引项保存的是搜索码相同的全部记录的指针列表时,删除索引项指针列表中的对应记录指针
-
稀疏索引:
- 如果索引项中不包含被删除记录的搜索码,什么都不做
- 如果包含,且该记录是具有这个搜索码值的唯一记录,那么将索引项中的搜索码值更新成下一个搜索码值,如果下一个搜索码值已经有一个索引项,直接删除这个索引项
- 否则,就是索引项中包含被删除记录的搜索码并且还有具有该搜索码值的记录,那么就直接将索引项中指向的记录改成具有该搜索码值的下一条记录即可。
辅助索引
辅助索引就是非聚集索引。
因为辅助索引文件中搜索码的顺序和对应表文件中的顺序不一致,所以辅助索引不能是稀疏索引,一定是稠密索引。
除此之外,辅助索引还有一些限制,我们没法在索引项中只存储表文件中具有相同搜索码的第一条记录的指针,因为表文件和索引文件的顺序不一致。(除非搜索码是一个候选码,这样一个搜索码在表中只会有一条数据与之对应)
可以让辅助索引指向一个桶,这个桶中包含表文件中所有具有相同搜索码的记录的指针。
辅助索引让查询效率提升,但是也不能一股脑的创建一大堆索引,索引会降低更新效率,需要仔细做权衡。
多码上的索引
索引的搜索码可以是由多个码组成的。
B+树索引文件
B+树能够在插入和删除的情况下仍然保持执行效率。虽然也会在插入删除时产生性能开销,但相比之前的顺序索引文件,这种开销可以被接受。
结构
B+树有一个参数n,决定了一个节点能包含多少数据,一个节点能包含\(K_1,K_2,...,K_{n-1}共(n-1)\)个搜索码和\(P_1,P_2,...,P_n共n\)条指针。
搜索码在一个节点中按顺序排放,也就是说如果在一个节点中\(i<j\),那么\(K_i<K_j\)。(假设没有重复搜索码)
B+树是平衡的,所有叶子节点的深度相同。
叶节点
叶节点\(K_1...K_{n-1}\)存储搜索码,\(P_1...P_{n-1}\)存储\(K_i\)所对应的记录在数据表文件中的指针。
左侧的节点都比右侧的节点小,假如\(L_i,L_j\)是两个叶子节点并且\(i<j\),那么\(L_i\)中的所有搜索码小于\(L_j\)中的任意一个搜索码。
\(P_n\)指向右侧相邻叶子节点的指针,因为左侧的叶子节点中的搜索码都比右侧的小,并且节点中的搜索码也是有序的,所以从左到右所有叶子节点中的搜索码都是有序的,所以用一个指针将它们连接起来可以组成一个有序的链表。
非叶节点
非叶节点不存储实际指向数据表文件中记录的指针,而是指向B+树的中间节点。
\(K_1...K_{n-1}\)包含\(n-1\)个搜索码
\(P_i(1\leq i \leq n)\)指向一颗子树,这颗子树包含的搜索码值大于等于\({K_{i-1}}\)小于\({K_i}\)
从图中可以看出叶子节点构成了完整的顺序索引,非叶节点构成了这个顺序索引的多级索引,多级索引的层数就是树的高度。
当n=6时树更矮了,在一个节点中比较所花费的时间更长,但是读取块次数更少。
其实说白了就是为了减少磁盘IO次数而将二叉树弄成了N叉树,这样树矮了,log的底数变大了,需要的读取次数就少了。
B+树的叶子节点最少包含\(\left \lceil (n-1)/2 \right \rceil\)个值,非叶节点必须至少容纳\(\left \lceil n/2 \right \rceil\)个指针(除了根节点)。这个限制是为了保证树的平衡。
B+树查询
在B+树种查询是一个从根节点到叶子节点的递归过程。
在一个非叶子节点,假设\(V\)是搜索码,那么需要从一个节点保存的第一个搜索码\(K_1\)开始查找,直到\(K_{n-1}\),如果找到一个\(K_i \geq V\),停止查找,这时如果\(K_i>V\),则叶子节点中包含搜索码V的节点(如果它存在的话)在\(P_i\)所指向的子树中,如果\(K_i=V\)则包含搜索码V的节点在\(P_{i+1}\)所指向的子树中,如果查询到了\(K_{n-1}\)还没有找到大于等于\(V\)的搜索码,那么\(V\)肯定在\(P_{n}\)中。重复上面的过程直到找到叶子节点。
在一个叶子节点,如果\(K_i=V\),那么与这个搜索码匹配的记录被\(P_i\)所指,如果在叶子节点中没法找到一个\(K_i=V\),那么表中不存在这条记录,查找失败。
下面是在B+树中查找的伪代码。
下面考虑存在重复搜索码的情况。
这里我们之前的假设,在一个节点中,如果\(i<j\)那么\(K_i<K_j\)不再成立,而是\(K_i\leq K_j\)。
这样的话,之前的断言——“\(P_i\)所指向的子树中包含的搜索码一定小于\(K_i\)”也不成立了,因为\(K_{i-1}\)可能等于\(K_i\),所以\(P_i\)中有可能包含等于\(K_i\)的搜索码。
这样我们必须修改上面的伪代码,下面这一段,无论用户搜索的\(V\)等于或小于\(K_i\),都置\(C=C.P_i\)
随之而来的,之前的断言——“如果遍历到叶子节点之后这个节点中不包含用户搜索的搜索码V,那么表中就没有一个与这个搜索码对应的记录”也不成立了,它的右侧可能有包含这个搜索码V的节点。
这时候之前保存的指向右兄弟的\(P_n\)就有用了,当我们在叶子节点中找到对应搜索码,就代表命中,如果没找到,向右兄弟查找,也就是C=C的右兄弟
,直到V在树中第一次出现。
除了查找一条数据,B+树的顺序结构以及叶节点互相连通的性质可以让它很轻松的进行范围查询。
一般情况下,B+树的n的大小由块大小决定,4KB的块大小大约n=200,这让B+树通常很矮,一般也就四层,也就是四次块访问,如果常用块被放置到缓存中,那么访问次数将更少。
B+树的更新
同样只考虑插入和删除,插入和删除的情况有点复杂,和其他平衡树一样,因为要保持平衡所以可能要进行递归分裂和合并。
插入
假如要插入一个Adams,他应该落在最左边的叶子节点上,但是这个叶子节点满了,就得把它分裂成两个节点
表示连接到它们的父节点也得更新,在这里就只需要简单的将分裂出的节点中最左侧的最小搜索码Califieri
插入到父节点的最左侧即可,父节点并没有过满,所以父节点不用分裂。最后就是这样的
一个插入必定要最终落到叶结点上,所以分裂时肯定从叶节点开始,如果上面的例子中,分裂后向父节点添加分裂出的最小搜索码时父节点也过满了,那么父节点也需要分裂。
删除
看不懂不想看略
多码索引
可以以多个搜索码建立一个索引
比如(dept_name, salary)
,然后可以这样进行查询
select * from instructory
where dept_name = "Comp. Sci." and salary = 70000;
也可以高效的应用范围查询
select * from instructory
where dept_name = "Comp. Sci." and salary > 70000;
首先过滤dept_name=Comp. Sci.
的,然后只需按顺序读取这些人的salary
是否大于70000。
但这样的效率不高
select * from instructory
where dept_name > "Comp. Sci." and salary > 70000;
考虑这种,首先过滤出所有按照字典序dept_name
>Comp. Sci.那些人,然后对于每一个人,继续过滤出工资大于70000的,它们可能在不同的磁盘块中,所以需要大量IO操作。
所以只推荐在多码索引中最后面的搜索码应用范围搜索。
散列索引
散列函数
散列函数将搜索码映射成一个散列值,每一个散列值有一个桶,搜索码存储在它对应的散列值的桶中。
一般一个桶就是一个磁盘块。
散列函数要和外部数据的结构尽量无关,才能保证搜索码被均匀的分布到桶中。比如以name
作为搜索码,散列函数h(name)=name.first_character
,也就是名字的首字母,使用Q和X开头的人显然比B和R的少得多,所以这个散列函数会造成某些桶存了很多搜索码,而某些桶只存了很少。
桶溢出
桶没有足够的空间容纳新的数据,那么就会溢出。
造成桶溢出的原因可能是我们分配的桶不足和桶中数据分布不均匀。
溢出后可以通过在溢出的桶后面添加溢出桶组成一个桶链。
散列索引
通过散列函数,我们能很快确定搜索码在哪个桶中,然后遍历这个桶,我们可以立马找到对应的记录,这让磁盘操作降低到了1次。
桶5是包含一个溢出桶的桶。
习题
当数据规模大的时候,索引文件会占用更多的空间,并且当主文件更新,所有的索引文件也要更新,太多的索引会将更新速度拖慢。所以要在频繁使用的码上建立索引,同时要在索引带来的加速查询的好处和拖慢更新的坏处之间做权衡
通常来说不可能,因为一个文件不可能按照两个搜索码的顺序来排序,它只能按照一个搜索码的顺序来排序。
a
bc 略