数据库系统笔记 | 索引

声明:本文配图和样本数据均来自《数据库系统概念》官网,再次对原作者表示感谢。

概述

数据库的记录按照一定的组织顺序保存在文件中,插入、删除和更新操作都一般都需要先做查询,在文件中找到"合适的位置",再做插入、删除和更新。因此,对数据库文件最频繁的操作便是查询,当数据库的记录很多的时候,从文件中查询想要的记录便会很慢,瓶颈就在于磁盘I/O的速度太慢。

磁盘中的文件是由大量的块组成的,文件系统的I/O以块为单位,相比主存的运算速度,对磁盘上块的访问是非常耗时的(读一个块大约需要8-20ms),如果数据量很大,每次查询需要访问大量的磁盘块,由于主存容量的限制,不可能一次性将所有的块全部读到主存中,所以在查询过程中如何减少读块的次数便是数据库系统设计的关键之一。

图书馆系统的索引帮助管理员快速找到图书的具体位置,数据库索引的作用也是一样:最小化从磁盘读块的次数,快速找到查询记录所在的磁盘块,提高查询速度。

索引类型的基本分类(其中B+树索引和B树索引实际上也属于顺序索引):

  • 顺序索引--按索引值的顺序排序
  • B+树索引
  • B树索引
  • 散列索引--将索引值均匀分布到多个散列通里
    • 静态散列索引
    • 动态散列索引

搜索码:在文件中查找记录的属性或者属性集。

顺序索引

顺序索引实际上包含两层意思:

  • 索引有序:将搜索码的值按顺序存储,并将搜索码值与包含该搜索码值的记录关联起来。
  • 记录有序:数据库文件中的记录也按照搜索码的顺序存储。

聚集索引(主索引):一个数据库文件可以有多个索引,分别基于不同的搜索码。但是文件中的记录只能按照其中一个搜索码的顺序来排序,而这个搜索码对应的索引就是聚集索引。聚集索引的搜索码一般是主码。

非聚集索引(辅助索引):很显然,搜索码值顺序不代表文件记录的索引就是非聚集索引。

索引项(索引记录):就是索引结构里的一条记录,一般包含了搜索码的值和指向具有该搜索码值的一条或多条记录的指针(一个块一般存储多条记录,所以指针包含了存有记录的磁盘块的标识和块内偏移量)。

顺序索引分类

聚集索引和非聚集索引描述的是索引项的顺序和文件记录顺序的关系,而稠密索引和稀疏索引索引则是顺序索引的不同实现:

稠密索引

在稠密索引中,文件中的每个搜索码值都有一条索引项,搜索码值在文件中可能有重复。对于稠密聚集索引,索引项包含搜索码值以及指向具有该搜索码值的第一条数据记录的指针,具有相同搜索码值的其他记录顺序的存储在第一条记录后面。对于稠密非聚集索引,索引项必须存储指向所有具有相同搜索码值的记录的指针列表,非聚集索引只能使用稠密索引。

下图描述了教师关系上的稠密索引,索引对应的搜索码是教师ID,每条记录的尾部保存了指向下一条记录的指针。初始记录写入到文件中时,逻辑上相邻记录分配的是连续的块,但是经过大量删除插入操作后,逻辑上相邻的记录很难再保证物理位置也是连续的,所以通过尾部的指针来保证记录的顺序。

稀疏索引

在稀疏索引中,只为搜索码的某些值建立索引项。只有当关系按照搜索码值的顺序存储时才能使用稀疏索引,或者说只有索引是聚集索引时才能使用稀疏索引。查询时,先根据搜索码值找到最近的文件记录,再沿着文件中的指针查找,直到找到所需记录。

多级索引

假设对有1 0000 0000条记录的关系建立了稠密索引,块的大小为4KB,一个块可以存储100个索引项,总共需要100 0000个块,也就是4GB的空间,系统分给数据库索引的主存可能只有几十M而已,这么大索引结构也不可能全部留在主存中,所以对搜索码值进行索引时,仍然需要大量的块I/O(即使读到主存中的块也会被换出)。即使采用二分法搜索,占用10000个块的索引最坏情况下需要14次读块操作,总共耗时大约112ms-280ms,如果考虑文件系统的预读技术,需要的时间会少很多,但是考虑有100 0000个块呢,这种预读技术的作用就不大了。

多级索引就是在原始的内层索引上加上一层外层稀疏索引,因为内层索引项一定是有序的,所以外层索引可以使用稀疏索引,索引的过程和稀疏索引类似,只是外层索引的结果不是文件记录的指针,而是内层索引的最近位置(一般取小于等于搜索码值的位置)。占用10000个块的内层索引需要外层索引有10000个索引项,也就是100个块不到1M,所以这个外层索引可以完全驻留在主存中,查询时只需要读一个内层索引块,而不是14个,查询效率可以显著提升。下面这张图描述了二级索引的结构:

随着内层索引项的增大,外层索引占用的块也可能多到主存放不下,这时可以再构造一层外层索引,每构造一层外层索引,支持的记录数都是指数级增长。

多级索引的意义就在于通过对记录的索引项构造索引,减少了索引项本身的读块次数。

B+树索引

索引顺序文件组织最大的缺陷在于:随着文件的增大,索引查找性能和数据顺序扫描性能都会下降,虽然可以通过对文件进行重新组织来弥补,但一般不希望频繁地对文件进行重组。

B+树索引结构在数据插入和删除的情况下仍能保持执行效率,它也是一种多级索引结构

B+树的结构

典型的B+树结点结构如下,它最多包含n-1个搜索码值(\small Ki^{_{}}),以及n个指针(\small Pi_{}),每个结点中的搜索码值排序存放。

叶结点

叶结点的指针\small Pi_{}指向具有搜索码值\small Ki^{_{}}的一条文件记录。叶结点的一些特点:

  • 每个叶结点最多可有n-1个值。尾部的指针\small Pn并不指向一条记录,而是指向下一个叶结点,因为叶结点之间按照所含的搜索码大小有一个线性的顺序,所以用\small Pn来将所有的叶结点串在一起。
  • 每个叶结点最少有[n-1/2]个值。比如n=4,每个结点最多3个值,最少2个值。
  • 各叶结点中搜索码值的范围互不重合。除非有重复的搜索码值,在这种情况下,一个值可能出现在多个叶结点中。

内部结点

B+树的非叶结点(也称内部结点)形成叶结点上的多级稀疏索引。内部结点的一些特点:

  • 非叶结点的结构和叶结点结构相同,只是其中的指针都是指向树中的结点。
  • 一个非叶结点容纳最多n个指针,最少[n/2]个指针,结点的指针数成为该结点的扇出
  • 根结点包含的指针数可以小于[n/2]。除非整颗树只有1个结点,否则根结点必须至少包含两个指针。

B+树也是平衡树,并且到任意叶结点的路径长度都是相同的。

下图是一个B+树索引结构的例子:根结点中的值EI Said左边的指针指向所有值都小于EI Said(或者说按升序在EI Said前面)的第一个叶结点,根结点EI Said右边的指针指向所有值都比EI Said大的第二个叶结点。第一个叶结点的指针P1会指向文件中搜索码值为Brandt的记录。

B+树的查询

如果索引中所有的搜索码值都是唯一的,那么B+树的查询直接从根结点开始向下游历,每到一个结点就用查询值V和结点中的值K按顺序进行比较:

  • 如果V>K,就移动指针继续和结点中下一个值比较,如果最后一个值仍然小于V,就游历到该结点尾部指针\small Pn指向的下一层结点。
  • 如果V<=K,就游历到K左边的指针指向的下一层的结点。
  • 直到游历到合适的叶子结点,然后按顺序遍历叶子结点中的值,直到找到查询值V或者查询失败。

如果索引中搜索码值是有重复的,查询时仍然按照上面的方法找到第一次出现查询值V的叶结点,只是这时并不代表查询结束,而是继续按顺序遍历其它的值,甚至需要通过尾部指针再次游历到下一个叶结点,直到碰到搜索码值大于V的位置,查找结束。

在一次查询过程中,需要遍历树中从根结点到某个叶结点的一条路径。如果文件中有N个搜索码值,那么B+树的高度不超过\small [log_{}[_{}n_{}/_{}2_{}](N)],所以这条路径的长度不超过\small [log_{}[_{}n_{}/_{}2_{}](N)]

一个结点一般占用一个块,4KB,如果搜索码值和指针占用20个字节,则n大约为200。假设文件中有100 0000条记录,一次查找也只需要访问3个结点(100^3>=100 0000)。通常根结点常驻在主存中,所以只需要读取最多2个磁盘块。相比前面的顺序文件索引,B+树的查询效率确实高了很多,而且这种性能足够稳定,以前面的假设考虑,只有记录的数量扩大100倍后磁盘块访问数才会增加一次。

范围查询

B+树执行范围查询应该很容易的,比如查找搜索码值50-70这个范围的记录。查找过程一般是先从根结点游历整颗B+树,找到搜索码值大于或者等于50的最小值,也就是实际记录中的范围下边界(50可能不存在)。然后顺序访问结点中的其他搜索码值,读取记录,直到碰到大于或者等于100的最小值,也就是范围的上边界(100可能不存在)时停止,当然如果实际上边界大于查询范围上限,则不读取边界值指向的记录。

注意,结点尾部只有指向下一个结点的指针,所以这种范围查询是单向的,只能从下边界开始读取记录,反过来则不行。

为什么是B+树?

到这里,我们可以思考一下,为什么选择了B+树来做文件记录的索引结构?而不是AVL树和红黑树这些平衡二叉树?如果说B+树的特点是"矮胖型",平衡二叉树每个结点最多存放两个数据,所以必定是"瘦高型"。回顾文章开头,索引的意义在于减少磁盘访问数,而决定这个最大的因素就是树的高度,B+树从根结点游历时每下降一层就要读一个索引块,虽然平衡二叉树结点占用空间很小,由于是按需动态分配,也很难保证能把所有的结点分配到尽可能少的磁盘块中,所以读块次数会非常多。当然,如果所有数据都在主存中,B+树的"矮胖"特点也就没这种优势了,所以说B+树结构就几乎是为数据库海量记录的索引"量身设计"的。

B+树的插入

执行插入操作时,也必然先执行前面的查询操作,找到合适的插入位置。插入过程大致如下:

  • 如果待插入的叶结点仍有剩余的空间(指针数小于n),直接插入即可,必要时需要移动其他指针。
  • 如果待插入节点已满(指针数等于n),则需要对该叶结点进行分裂:前[n/2]个放在原来的结点中,剩下的放到一个新的叶结点中,这样来保证前面描述的结点特性不变。因为分裂出一个新的结点,原结点的父结点需要插入一个新的项(搜索码值等于新结点最小的值,指针指向新的叶结点),如果父结点有剩余空间直接插入,如果父结点也满了,则继续分裂父结点。最坏的情况下,结点插入新项时都需要分裂,最终树的高度增加。

需要注意的是,非叶结点分裂时,孩子指针的分裂介于原始结点和新创建的结点之间。

下面是一个教师关系索引插入搜索码值时分裂的例子:

在上图中插入名字为Adams的记录索引:

在上图中插入名字为Lamport的记录索引,第四个叶结点已经没有合适的插入位置,所以分裂出新的叶结点,并向父结点插入新的项(Kim,n1),n1指向新的叶结点。但是父结点也满了,所以第二层的父结点继续分裂,分裂后新结点的第一个指针指向Gold所在叶结点,还需要向根结点插入新的项,而Gold位于原结点和新结点之间,也不再需要指向叶结点,所以(Gold,n2)被插入根结点,n2指向新分裂的内部结点。

 

B+树的删除

和插入相反,删除会使叶结点变得太空,为了保持结点性质,所以需要进行合并操作。

在上图中删除名字为Srinivasan的记录索引后,最后一个叶结点只剩一个搜索码值Wu,而前面的叶结点也只有2个搜索码值,所以和前面一个叶结点合并成一个叶结点。

在上图中删除名字为Singh和Wu的记录索引后,最后一个叶结点只剩一个搜索码值Mozart,但是前面的叶结点是满的,这种情况就没法直接合并了,所以将两个结点的搜索码值和指针重新分配。保证每个叶结点指向记录的指针数至少有[n/2]个。

在上图中删除名字为Gold的记录索引后,倒数第二个叶结点太空,不过这次合并后使树的高度减少一层。

B+树的扩展

B+树文件组织

B+树结构不仅可以作为索引结构,也可以作为文件记录的组织结构。B+树文件组织中,树的叶结点存储的是记录而不是指向记录的指针。

目前结点的空间利用率保证至少50%,还可以怎样提高结点的利用率?

前面在插入操作时,如果待插入结点已满则直接分裂,另一种方法是从相邻结点"借一点空间",也就是把已满结点的部分项重新分布到相邻结点。如果相邻结点也满了则无法重新分配,这个时候再分裂出新结点,所以是3个结点分配至少2n+1个搜索码值,因此每个结点大约是2/3满的,相比1/2满利用率提高了不少。删除时,如果结点的项数少于2n/3,则从相邻结点借入一项,如果两个相邻结点都有2n/3个项,则考虑合并三个结点至两个结点。

一般地,如果重新分布涉及m个结点(m-1个兄弟结点),每个结点能保证至少包含[(m-1)n/m]个索引项,结点的利用率也就是(m-1)/m,m越大,空间利用率越高,同时每次操作需要的记录移动和块I/O次数也越多了(不仅是读块次数增多,写块次数也增多,相比读块写块操作耗时更多),所以代价也更高。

辅助索引和记录重定位

文件组织可能会改变记录的位置,即使记录没有更新。比如B+树文件组织中叶结点分裂(注意:不是B+树索引的结点分裂),部分记录会被移动到新的结点,在这种情况下,所有存储了哪些指向重定位过的记录的指针的辅助索引都需要更新,这个更新代价是很高的。

解决这个问题的方法:在辅助索引中,不存储指向被索引的记录的指针,而是存储主索引搜索码的值。执行辅助索引时,先查找到主索引搜索码的值,然后用主索引找到对应的记录。

B树结构

B树结构与B+树结构最大的不同是B树只允许每个搜索码值出现一次(如果它们是唯一的),在内部结点上出现过的搜索码值不会再在叶结点中存储。因此,内部结点里的搜索码值还需要一个指针直接指向文件中的记录。

B树叶结点和内部结点的结构如下,叶结点结构和B+树相同,内部结点多了\small Bi,用以指向文件记录。

B树中进行一次查找所访问的结点数取决于搜索码的位置,最快根节点内完成查找。B树存储在叶结点的码大约n倍于存储在内部结点的码,而n一般比较大,所以很快找到特定值的好处不大。

相比B+树,B树的内部结点多了一个指针域,能够存储的项也更少,对于相同数目的记录,B树的高度明显高于B+树,而且这一点也几乎抵消了搜索码值唯一性带来的空间利用率的提升。所以,对于部分值的查找B树快于B+树,部分查找慢于B+树。总的来说,B树和B+树的平均查找时间与搜索码数目取对数成正比。

另外,B树的删除操作相比B+树也更复杂。

MySQL InnoDB存储引擎选择了B+树来实现索引,而MongoDB的存储引擎选择了B树,具体原因参考下面这篇文章:

为什么MongoDB索引用B树,而Mysql用B+树?

散列索引

学过数据结构的都知道散列桶(或者叫哈希桶),数值或者字串等通过散列函数运算后得到有限的散列值,每个散列值对应一个散列桶,散列值相同的项存储在同一个桶内,桶内的项一般以链表方式存储。对于理想的散列函数,得到的散列值是均匀分布的,所有项均匀分布在各个桶内,可以得到最佳的平均查找时间。这种技术同样可以被用来设计数据库文件的索引和文件组织。

静态散列

下面是散列文件组织的例子,桶内直接存放的是记录:

静态散列就是散列桶的数目是固定的,当某个桶的空间用完后,就会发生桶溢出。桶溢出的原因一般有两个:

  • 记录太多导致桶不足。
  • 桶倾斜--某些桶分到的项过多,可能是多条记录具有相同的搜索码值,也可能是散列函数不够好,导致计算的散列值分布不均匀。

对于桶溢出的一般处理是在桶后面链接溢出桶,这也是增加存储空间的方法。

散列索引的例子,桶内存放的是指向文件中记录的指针:

一般地,散列索引只是作为辅助索引结构,因为如果文件本身就是按照散列组织的,就不用再为其建立一个独立的聚集索引结构。

动态散列

前面介绍的静态散列的最大问题就是随着文件记录的增大,现有散列桶很可能满足不了使用需求。一些解决办法:

  • 提前选择值更多的散列函数,这样得到大量的预留桶空间,但是很容易造成空间浪费。
  • 随着文件的增大,周期性的对散列结构进行重组,代价很高。
  • 动态散列技术--允许散列函数和桶空间动态改变,以适应数据库记录增多或减少的需求。

这里介绍一种动态散列技术--可扩充散列技术:

可扩充散列技术的思想:使用一个具有均匀性和随机性特性的散列函数h,这个散列函数产生的值范围很大,是b位二进制整数,典型的b值是32位。开始并不为每个散列值都创建对应的桶(那大概是40多亿个吧),而是在记录插入时按需建桶。

开始时桶地址表不使用散列值的所有位来对桶进行索引,而是其中的i位(0<=i<=b),桶地址表每次扩充时都是倍数扩充(增加1个位,地址空间扩大一倍),桶地址表内新的空间的内容从原来的复制得到,所以会有连续的表项指向同一个桶,所有这些表项有一个共同的散列前缀(可能小于i)。因此给每个桶都附加一个整数值(后面用j表示)来表示共同的散列前缀长度。

下面用例图来演示可扩充散列的在插入过程的动态增长过程以及一些特殊的处理方法(每个桶只能容纳两个项):

这里的散列函数h对教师记录中的系名称进行运算,得到32位的散列值,各个系的二进制散列值如下:

注:后面用记录中的教师名代表一条记录。

1.最开始左边的桶地址表只用0位,i=0,只有1个表项,指向右边的桶0。

2.插入记录Srinivasan,其散列值的第一位是1,桶地址表没有1这个项,所以桶地址表扩展到1位(i=1),然后创建新桶1,这条记录被插入到了新建的桶1中,并且桶的j=1(原本i=j=0,桶地址表扩展后,新桶的j值等于桶地址表的i值)。接着插入记录Wu,它可以直接插入到桶2中。最后插入记录Mozart,其散列值前缀为0,直接插入桶0。

3.插入记录Einstein,其散列值为1,可是桶1已经满了,这时涉及到桶的分裂,因为i=j=1,桶地址表只有一个项(1)指向桶1,所以需要扩展桶地址表来存放分裂出的桶指针。这是一个典型的分裂插入场景(i=j),需要扩充桶地址表(典型情况1),详细步骤如下:

  • 对桶地址表进行扩充,置i=2。    --倍数扩充桶地址表
  • 扩充后两个表项(10,11)指向桶1,系统分配一个新桶2,并让第二个表项(11)指向桶2,桶1和桶2的j=i=2。 --建新桶
  • 对桶1里的记录重新散列,因此记录Srinivasan(11)移动到桶2。 --重新散列旧桶记录
  • 最后将记录Einstein到桶1中。 --插入

4.插入记录EI Said,散列值为11,直接插入桶2。接着插入记录Gold,散列值为10,应该插入桶1,因为桶1满了并且i=j=2,继续上面相同分裂步骤,结果如下:

5.插入记录Katz,散列值为11,应该插入上图中最后一个桶(叫做桶j吧),可是这个桶满了,并且j=2 < i=3,这是另一个典型的分裂插入场景,桶地址表有多个表项指向同一个桶j,所以不需要扩充桶地址表(典型情况2),详细步骤如下:

  • 系统分配一个新桶,并将桶j和新桶(叫桶z)的j值都置为j+1=3。
  • 此时,由于j值的变化,桶地址表项中指向桶j的表项的前j位未必仍和桶j每条记录的前j位一致。比如桶j记录Srinivasan的散列值前3位是111,记录EI Said的散列值前3位是110。而桶地址表110,111两个表项都指向了桶j。这种情况下,让桶地址表项的前一半(110)指向桶j,后一半(111)指向新桶z。
  • 对桶j内的记录重新散列,记录Srinivasan被移动到新桶z。
  • 插入记录Katz到新桶z。

6.插入记录Califieri、Singh和Crick,没有特殊处理。继续插入记录Brandt,其散列值为111,这个值对应的桶满了,而且这几条记录的散列值完全相同,都是计算机系的,这也是一种典型的插入溢出情况,但是这种情况不能通过桶分裂来处理,因为分裂再多的桶他们都会最终"走到一起"。这种情况需要用前面静态散列提到的溢出桶来处理,在桶j后面添加溢出桶存放新的记录(典型情况3)。

总结一下可扩充散列的插入过程:通过散列函数找到对应的桶,有空间直接插入。如果桶满了,有三种场景:

  • 桶地址表前缀i等于桶前缀j:扩充桶地址表后建新桶,并重新散列旧桶中的记录,最后插入。
  • 桶地址表前缀i大于桶前缀j:直接创建新桶,分配表项指向新桶,重新散列旧桶中的记录,最后插入。
  • 桶中所有记录的散列值完全相同:用增加溢出桶来处理。

散列结构的问题

由于所有搜索码值的散列值都均匀分布到了不同的桶里,这样的分布对于范围查询是非常不利的,在一定范围内的值很可能散步在很多甚至全部的桶中,因而为了找到所需记录,不得不读取所有的桶,这对查询性能的影响是致命的。

通常情况下,数据库会采用顺序索引,除非预先知道将来不会有频繁的范围查询。

位图索引

位图就是位的简单数组。位图索引结构就是在记录属性的不同值上建立位图数组,每个位代表一条记录。对于单个属性,需要建立多少个位图数组,取决于该属性的值域。下面的例子非常清晰:

位图索引主要是用于多个码上的选择操作,往往对几个位图进行交运算就可以得到想要的记录。

 

至此,常见的顺序索引结构、B+树索引结构、散列结构和位图索引结构就基本学习完了,还有一些重要的细节问题需要的时候再补充吧,比如位图索引如何解决插入和删除操作破坏记录顺序的问题。

References

http://db-book.com/

https://blog.csdn.net/ifollowrivers/article/details/73614549

 

 

 

 

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值