深入理解DB2索引(Index)

索引(Index)是数据库管理系统中一个非常重要的数据结构,索引的合理使用能够极大提高数据库系统的性能。那么,什么是索引?索引有时如何提高数据库系统性能的呢?

阅读本文时建议参考:深入理解数据库磁盘存储(Disk Storage)

索引概念

以一本书为例,通常一本书开头会有目录,而后才是正文,通过目录中每行左侧的标题和右侧的页码,我们可以快速定位到需要阅读的页面,而无需一页一页翻阅到该页面。数据库中的索引就像目录,它能帮助数据库管理系统快速定位到表中符合查询条件的数据行。索引实际上也是数据表的组成部分之一(数据表的存储包括数据页面+索引页面)

定义:数据库索引实际上是一个单独的、物理的数据库结构,它是某个表中一列或若干列值的集合和相应的指向表中物理标识这些值的数据页的逻辑指针的清单(序列)

从索引的定义中可以得到:

1.索引是一个物理数据结构,也就是说,它是需要保持到物理磁盘上的,和普通的表数据一样要占用磁盘空间,也是存储在数据页上的。

2.索引是一个清单(或者说序列),由两部分组成:数据库表中的一列或多利的列值的集合、指向这些列值的数据页的逻辑指针。

可以把索引看作是一张只有两列的表,一列是普通数据表的列值(key-value),另一列是该列值的行对应的数据页的逻辑指针(Row-Pointer),这个逻辑指针可以理解为就是RID。需要说明的是,根据数据库产品和索引类型的不同,逻辑指针的结构也各不相同。如下图是索引的一个概览性描述,右侧是数据表页,左侧是对应的索引页。还有一点,从图上可以看到,表中的行之间是有指针相连的,即数据页中的每个记录除了存放数据,还会包含一个指针,指向其下一行记录。各记录形成一种链表结构。当然,这种结构并不是所有数据库系统都会采用的。


索引怎样提高性能

考虑这样一种情况:如上图,银行数据库的account表有编号(ID),城市(City)和余额(Balance)三列。表中一共有10万行数据。现在要列出属于Mianus这个城市的所有账号,查询语句为:

Select * From account Where city=‘Mianus’

为了找出满足条件的查询,数据库管理器必须扫描account表中的所有行,逐行匹配,即表扫描。这会向缓冲池调入大量的数据页,执行大量I/O操作无疑是非常影响性能的。

如果在city列建立索引,则DB2查询优化器会在索引中扫描匹配的行,然后根据该行的逻辑指针找到那些满足条件的表数据行并将对应的数据页调入缓冲池,从而大大减少I/O操作。【一般而言,索引是可以常驻缓冲池的,所以对索引的扫描可以无需进行耗时的I/O操作】

当然上面的索引工作机制的描述是直观性的,真正的索引工作机制下文再讨论。

索引的分类–按结构分

索引按照结构可以分为有序索引(ordered index)和散列索引(hash index)两种基本类型。其中有序索引是基于值的顺序排序,根据值的排序进行索引值的查找。而散列索引则是基于将值平均分布到若干散列桶(hash bucket)。根据散列函数确定索引值所在的散列桶。

有序索引

顺序文件索引

有序索引的一种最简单实现形式是顺序文件索引。顺序文件索引结构更加类似一张两列表,所有的索引值顺序排列,进行查询的时候逐行扫描索引中的各行记录,找到匹配项后就能根据逻辑指针找到表数据行的数据页。

那么,是不是建立索引的列的所有列值都必须成为索引中的索引值呢?不是。

稠密索引(dense index)和稀疏索引(sparse index)

我们将表中所有列值建立一个索引记录的索引称为稠密索引(dense index),将只为某些列值建立索引记录的索引称为稀疏索引(sparse index),如图:


                                                稠密索引                                                                                                         稀疏索引

左侧的稠密索引很好理解,右侧的稀疏索引是怎样工作的呢?该索引只为Brighton、Mianus,和Redwood建立的索引记录,当需要查找Downtown时,由于顺序上Downtown在Brighton和Mianus之间,所以就根据Brighton这条记录的指针找到Brighton的数据页,从Brighton这一行记录开始根据记录的指针逐个查找,直到找到全部Downtown的行记录为止。

很显然,对于稀疏索引,数据行在数据页中必须按照索引记录的顺序而顺序排列才可能有效,否则有的列值就可能找不到或者找不全。但是和稠密索引相比,稀疏索引有着节省存储空间的优势。

多级索引

有时候,即便使用稀疏索引,由于数据量大且索引列上的列值多,索引本身也会变得非常大而难于有效处理(索引过大就难以保证常驻内存,进行磁盘读取的话,索引越大,需要的I/O越多),为了处理这种问题,我们使用了多级索引技术,其思想就是对索引建立索引。以一个二级索引为例,先根据外层索引定位到内层索引相应位置,在根据内层索引定位到表数据的数据页。这样的多级索引结构肯定会使得外层索引中的索引记录会少很多,如何便可以只将外层索引常驻内存从而达到减少I/O的目的了。

当然,无论是哪一级索引,都既可以是稠密索引也可以是稀疏索引(最内层可能只能是稠密索引)。实际上,索引记录本身就是顺序存储的,所以建立在索引上的索引通常是稀疏索引,这样才能更好的发挥多级索引的作用。


B+树索引

顺序文件索引的最大缺点在于,随着表数据行的增大和索引的增大,索引查找性能(多级索引的复杂度,索引扫描量)和数据顺序扫描性能(数据在增删改等操作中会变得非常混乱,难以维护)都会下降。虽然这种性能下降可以通过表重组和索引重组解决,但是频繁的重组同样是我们不愿意看到的。

因此,一种新的索引结构被广泛接受,这就是B+树索引。B+树是B树(平衡查找树,一种多路查找树)的一个重要变种(参考:B 树、B- 树、B+ 树和B* 树),B+树索引结构是使用最广泛,在数据插入和删除的情况下仍能保持其执行效率的几种索引结构之一。目前DB2的索引结构就是B+树索引,Oracle的普通索引结构是B+树索引的变种B*树索引。

B+树索引采用平衡树(balanced tree)结构,其中根结点到每个叶结点的路径长度都是相同的(所有叶结点都在同一层),树中除根结点外,每个非叶结点有[n/2] (这里的[]符号表示取天棚,如[3/2]=2。天棚符号显示不出。下同)到n个子结点,每个页结点有[(n-1)/2]到n-1个索引值(key-value,我们称为搜索码:search key)。至于n是什么,后面会解释。一个B+树的例子:其中n=4,索引值(搜索码)是2,3,5,7,11,13,17,19,23,29,31,37,41,43,47.



B+树索引结构

既然DB2的索引是B+树索引,那么B+树索引在磁盘上到底是怎样存储的呢?

前面介绍过,索引和表数据行一样,存储在数据页中。事实上,一个索引数据页就是索引B+树的一个结点!

每个结点在数据页中的存储方式是这样的:


其中P1,P2,…….,Pn是n个指针;K1,K2,……,Kn-1是n-1个搜索码(索引值),这n-1个搜索码是按照从小到大的顺序排列的。

对于非叶结点:

P1指向另一个结点数据页,这个结点数据页中的所有搜索码值都小于K1(按某种排序规则规定的大于小于关系,比如Brighton<Downtown);

P2指向另一个结点数据页,这个结点数据页中的所有搜索码值都大于或等于K1且都小于K2;

依此类推,Pn指向的结点数据页中所有搜索码值都大于或等于Kn-1。

而对于叶结点:

P1就是K1搜索码所对应的表数据行的逻辑指针,直接指向数据行的地址(页号+槽号);

同理,P2指向K2搜索码数对应的表数据行的逻辑指针,……,Pn-1指向Kn-1搜索码数对应的表数据行的逻辑指针;

最后的Pn指针则指向该B+树中的下一个页结点。


B+树索引分析

我们认为一个(指针,搜索码)对,即一个(P,K)对是一个索引项(index entry,一个索引项就是索引数据页中的一条记录)。假设一个B+树索引的一个索引项大小为40B,一个数据页中可以存储的索引项数大致是4KB/40B=100个。那么,n=100。这就是B+树索引中的n的来历。它完全是由索引数据页大小和索引项大小决定的。(当然,这个例子中按计算值来说,n应该等于101,这样才是100个搜索码,101个指针,才有100个索引项。但是由于页面空间利用和方便计算的原因,直接取个大概值100)

按照树结构的层数公式:层数= (K表示总共的搜索码数量,m表示每个结点的子结点数)。由于非叶结点的子结点数为[n/2]到n-1,那么如果所有非叶结点的子结点都是[n/2]个,B+树索引的层数为;如果所有非叶结点的子结点都是n-1个,索引B+树的层数则为。也就是说,B+树索引的层数最多为层。由于B+树根结点到所有叶结点的路径都一样长,而且所有搜索码都在叶结点上,所以,查找任意一个搜索码所需要的路径长度都是一样的。

继续假设一个表中建立了索引的列上有100万个不同的列值,即有100万个搜索码(索引值),则B+树索引的层数最高为层。可见,通常DB2B+树索引的层数不会超过3层。(100万个不同的搜索码才4层,很少需要在这样的列上建立索引。除非是主键。主键默认是有唯一性索引的)层数越低表示查找到所需的搜索码越快,定位相应的数据行的物理位置越快。

下面以n=3为例看看上面account表的city列的索引的结构是怎样的:


B+树索引生成

下面的图详细演示了一棵n=5的B+树索引的生成过程(叶结点之间的指针未标出):


关于B+树的生成规则以及结点中搜索码的添加,删除和结点的合并分裂规则这里不作介绍,有空再写。

从B+树的结构和相关规则可以看出,B+树在插入和删除操作方面会相对复杂,不能会带来性能开销,还会增加空间开销。但是由于数据库中数据的查询操作通常是远多于删除和插入操作的,也就是说,B+树索引带来的稳定高性能查询优势是其他索引结构无法比拟的。插入和删除带来的开销实际上也是可以接受的,因为它减小了表重组和索引重组的代价。此外,由于结点有可能是半空的,这会造成空间的浪费。但是这种浪费依然是可接受的。

多值指针

还有一个问题在之前的讨论中一直被回避着,那就是,如果一个索引值(搜索码)对应着表中多个数据行(这种情况是极为普遍的,比如上面account表中的Perryridge),那么,该叶结点的指针该指向谁呢?

如果是顺序文件索引,可以通过指向第一个满足条件的行,再根据各行的next指针来遍历所有满足条件的行(这一点上文有解释)。可是B+树索引并没有这样的行间next指针结构。

包括Oracle数据库在内的大多数数据库产品采用的策略是使用散列桶(hash bucket)。即对于一个搜索码对应多个数据行的情况,其叶结点指针指向一个散列桶,再通过遍历散列桶来获取所有满足条件的行地址。(这个策略类似B树索引结构)

而DB2则是利用其优秀的索引压缩技术而采用了一种新的解决方案,DB2使用一种逻辑块结构来解决搜索码值重复的问题,如图:


一个逻辑块包含一个前缀(Prefix,大小为2B),一个搜索码(Keyval)和一个与该搜索码对应的RID(大小为4B)列表。规定一个逻辑块中的搜索码后跟的RID的个数最多为255个。假设搜索码A有N个不同的数据行与之对应,则意味着一个搜索码对应N个RID。如果N<=255,则一个逻辑块中能够包含该搜索码以及其全部RID。如果N>255,则需要另外生成一个逻辑块,该逻辑块中的搜索码仍然是之前的搜索码A,RID列表中为剩下的RID。如果仍然无法容纳剩余的全部RID,则继续生成该搜索码的逻辑块,直到能够全部容纳为止。多个逻辑块则以前缀Prx来进行区分(前缀不仅能区分同一搜索码的不同逻辑块,还能区分不同搜索码的逻辑块)。如此,一个逻辑块类似于一个索引项。但是这里的索引项跟上面无重复搜索码的索引项是有着本质的区别的。

找到一篇详细介绍DB2索引压缩技术的论文,有时间再翻一翻:

http://www2.hawaii.edu/~lipyeow/pub/vldb09-indexcompression.pdf (很值得研究的一篇论文)


散列索引

有序索引的一个缺点是必须访问索引结构来定位数据,或者必须使用多路查找,这些都导致相对过多的I/O操作。而基于散列(hashing)技术的索引则能够避免访问索引结构。介绍散列索引前先理解一下散列的相关概念。

静态散列

散列技术中有一个重要的概念,那就是桶(bucket)。桶表示能够存储一条或多条记录的存储单位。通常一个桶就是一个磁盘块,但也可能大小不定。我们用K表示所有搜索码的集合,用B表示所有桶的集合。散列技术的思想就是:将所有的搜索码K及其相关信息按照某种规则(称这种规则为散列函数h)分散到各个散列桶中(就是一种映射关系)。当查找某个搜索码时,就可以根据规则计算出该搜索码所在的桶,然后在桶中找到搜索码及其信息。这种方法可以在一开始就定位到小范围内进行查找工作,效率可想而知是很高的。

散列函数

散列技术所要解决的首要问题就是散列函数的确定了。不好的散列函数可能导致所有搜索码只被分配到单独几个散列桶中,这既导致散列桶空间的浪费,也使得散列查找的优势丧失(不得不在一个桶中查找大量记录)。良好的散列函数至少需要满足两大特性:分布是均匀的;分布是随机的。散列函数的确定是非常灵活的,可以非常简单(比如简单求余,根据余数的不同将搜索码分配到不同桶中),也可以异常复杂。不作过多讨论。

桶溢出

当插入一条记录,而根据散列函数映射到的散列桶已经没有存储空间存放该记录的搜索码,就会发生桶溢出(bucket overflow)。发生桶溢出的可能原因有:

1.桶不足(Insufficient bucket)。即桶的总数不够,当然这种问题一定程度上是由于散列函数的不合理造成的,它导致大量的搜索码通过散列函数只能映射到少数几个桶中。当然这也是无法完全避免的,任何散列函数都只能将搜索码映射到有限的桶中,任何桶的存储空间也是有限的。但是搜索码的数量可以是无限的。

2.偏斜(Skew)。即大量的搜索码分布到少数几个桶中,其他的桶中的记录很少或者没有。这种情况一方面也是由于散列函数选取不当,另一方面也可能是特定的搜索码集合本身具有一定的耦合性(比如多个记录具有相同的搜索码)。

解决这一问题的策略就是使用溢出桶(overflow bucket)。即如果一个搜索码映射到的桶已经满了,则为这个桶增加一个溢出桶,将这个搜索码存储在溢出桶中。如果溢出桶也满了,就再增加一个溢出桶,如此反复。散列桶和它的溢出桶通过链表连接起来,称为溢出链(overflow chaining)。


很显然,一个散列桶上挂的溢出桶越多,散列桶所拥有的优势也就丧失越多,因为不得不进行更多的查找操作。

以上的散列技术应用与索引就产生了散列索引了。

散列索引将搜索码及其相应的逻辑指针封装成一个散列结构,将散列函数作用于搜索码,将这些散列结构分配到不同的散列桶中。当进行查找时,根据搜索码找到装有含该搜索码的散列结构的散列桶,然后在散列桶中更加搜索码找到对应的散列结构,得到散列结构中的逻辑指针就能找到对应的表数据的数据页及槽地址了。

以一个桶能够存储2个封装了搜索码和逻辑指针的散列结构为例(以ID主码为搜索码):


以上就是使用静态散列技术构造散列索引的方法了。

动态散列

静态散列索引的一个主要的缺陷是,随着散列函数的确定,桶地址集合B也就确定了。(比如将散列函数定义为对10求余,则散列桶就只有10个:0-9)。随着数据库的不断增大,就需要使用溢出桶处理溢出问题了,但过多的溢出桶会导致效率的明显下降。

一种处理方法是一开始就确定一个能使用足够多散列桶的散列函数,但这必然造成前期存储空间的巨大浪费。另一种方法是周期性的对散列结构进行重组。重新选择散列函数,重新分配桶,但这无疑是一个复杂而耗时的工作。

为了克服静态散列的缺陷,一些动态散列(dynamic hashing)技术被提出并应用与散列索引,以保证数据库的增大不会给性能带来大的影响。一种比较好的动态散列技术是可扩充散列(extendable hashing),当数据库增大或缩小时,通过桶的分裂或合并来适应数据库大小的变化。具体实现就不解释了。

索引的分类–按功能分

除了按照结构将索引分为有序索引和散列索引外,还可以按照索引功能将其分为唯一索引,非唯一索引,集群索引,非集群索引和MDC块索引这样的5种类型。

唯一索引(unique index)和非唯一索引(nonunique index)

实际上,索引的优势主要体现在两方面:提高查询效率和保证数据唯一性。其中保证数据唯一性的优势就是依靠唯一索引提供的。

唯一索引是指对某一列创建的索引必须保证每一个key-value(索引值,搜索码)只能对应一个Row-pointer。比如最开始的示意图中,一个Downtown的key-value就对应了两个表数据行的指针;一个Perryridge的key-value对应了3个表数据行的指针。这样的索引就是非唯一索引。

那么要保证索引是唯一索引,显然就必须保证对应表中的建立了索引的列上没有两行的列值是相同的。也就是说,唯一索引效果上等同于非唯一索引+索引列唯一性约束。

只有在表中某列上的所有列值都不相同是才能在该列上成功建立唯一索引。一旦在表中某列上建立了唯一索引,那么向表中插入的任何数据行都不允许在该索引列上出现重复值,否则插入失败。需要注意的是:创建了唯一索引的列上的数据允许为空,但是一旦有数据,就必须是唯一的。

对于任何一张表,一旦指定了主键,那么数据库会默认在主键上创建一个唯一索引,有时称主键上的唯一索引为主键索引,主键索引其实就是在唯一索引和非空约束的组合实现(主键是不允许为空的)。另外,一旦为某个列建立了唯一性约束,数据库同样会默认在该列上创建一个唯一索引。

集群索引(clustered index)和非集群索引(nonclustered index)

概念

集群索引又称聚集索引,聚簇索引,其中聚簇索引是各数据库通用的常用的叫法,集群索引是DB2官方叫法。同时聚簇索引又称主索引(primary index)【注意:主索引并不表示该索引是建立在主键上的,事实上,主索引通常建立在非主键上】。聚簇索引的概念对于有序索引和散列索引都是有效的,但是对于有序索引才有意义。通常散列索引都是非聚簇索引。(当然散列聚簇索引也是存在的)

我们知道,无论是顺序文件索引还是B树、B+树、B*树索引,这些有序索引中索引值(搜索码)都是有一定的排列顺序的。如果这些索引值(搜索码)对应的数据行在数据页存储空间中跟索引值的排列顺序是一致的,那么这样的索引就是聚簇索引。反之就是非聚簇索引(nonclustered index,非聚簇索引又称辅助索引:secondary index)。注意:这里只要求排列的顺序一致,并不要求索引值在索引数据页上是连续的,也不要求表数据行在常规数据页上是连续的。如图是DB2聚簇索引和非聚簇索引的示例:


很显然,由于聚簇索引要求数据页中的数据行顺序上与索引值的顺序保持一致,而数据行的排列顺序是一种物理上的顺序,不可能要求数据在磁盘上同时满足多种物理排序(就像不可能要求一群人人既按身高排队的同时又按照年龄排队一样),所以,一个表上只允许有一个聚簇索引。对于非聚簇索引则没有限制。

联系前面提到的稀疏索引和稠密索引的含义,可以知道,非聚簇索引必须是稠密索引,因为如果是稀疏索引,那么由于索引值与数据行排序的不一致,无法定位没有出现在索引值中的搜索码。

聚簇索引的优势

那么,既然聚簇索引有这么大的限制,聚簇索引存在的必要性在哪呢?

聚簇索引相较非聚簇索引唯一的优势是拥有更高的查询性能。参照上图,试想account表上的这样一条查询语句:

select * from account where balance > 500

如果在balance列上建立了非聚簇索引,那么,balance > 500的行可能分布在各个数据页上,那么要查询到满足条件的所有行,就必须将大量的数据页调入缓冲池中(每一页上只有少数几行是满足要求的),而且,数据库的预取机制的效率就显得不怎么高。不仅如此,由于包含满足条件的行的数据页分散分布,数据在磁盘上很可能也分散到距离间隔比较大的扇区上。因此,这样的查询不仅I/O操作多而且I/O也更费时。

如果在balance列上建立的是聚簇索引,那么balance>500的行很可能就分布在一个数据页内或者一个数据页中有大量满足条件的数据行,需要调入缓冲池的数据页就会少很多,预取机制也能较好的发挥作用。同时,数据在磁盘上也会分布在邻近的扇区。因此,这样的查询不仅I/O操作少 而且I/O相对省时。

聚簇率(clustering ratio)

使用聚簇索引固然有查询方面的优势,但是在数据插入方面就产生一个问题:新插入的数据应该放在哪个数据页的什么位置?由于聚簇索引要求数据行与索引值顺序保持一致,那新插入的数据行以及其索引值是否必须寻找相应位置执行插入操作呢?如果数据页中相应位置没有足够的空间插入该怎么办呢?可以想象,如果要保证顺序的严格一致,必然会导致大量的数据迁移,这样的花销是不可接受的。所以,真正的处理办法是允许有一定的顺序不一致出现,即便有些数据是无序的,仍然认为该索引是聚簇索引。

我们把索引中满足聚簇顺序条件的(索引值,数据行)对占该索引上所有(索引值,数据行)对的比例称为聚簇率,聚簇索引中85%以上的聚簇率是可接受的。否则就需要进行重组。

顺便提一个小技巧:

假设已经建好一张表,准备向表中导入大量数据,且要在某列上建立索引,那么:

如果是建立非聚簇索引,最好是先导入数据,然后建立索引。因为这样保证B+树索引建立时已经存在大量搜索码,无需在B+树生成后进行频繁的插入,合并,分裂操作。

如果是建立聚簇索引,最好先建立索引,然后导入数据。因为如果先导入了数据,再建索引,就必须对已经存在的数据进行耗时的重新排序。

聚簇索引的实现

还有一点需要说明,那就是,虽然各种数据库产品都有聚簇索引的概念,但具体的实现方式和索引结构也是有区别的。DB2的聚簇索引结构如上图所示,聚簇索引的叶结点和非聚簇索引一样,都是指向相应数据页的逻辑指针。即索引数据页和常规数据页是严格分开的。但是包括Oracle,SQL Server在内的大部分数据库的聚簇索引的页结点不是指向数据页的指针,而是页结点本身就是数据页。也就是说,索引数据页和常规数据页发生了融合,二者已经没有了严格的界限。如图(这是SQL Server数据库的聚簇索引和非聚簇索引的对比图,SQL Server数据库的索引是二叉树结构):


MDC块索引(MDC block index)

MDC块索引与多维集群表相关,还没有研究过。

标准表的表、索引和数据页的关系

标准表是相对与多维集群表而言的,常规表就是标准表。其实标准表,索引和数据页的关系在这篇文章和《数据库学习笔记—-磁盘存储内部结构》这两篇文章里已经解释得很清楚了,这里放上一张全景的关系结构图:


DB2索引优化

索引虽然能够大大提升查询效率,但是并不是对所有查询都适用的。比如对于“Select * From account where balance != 500”这样的语句,balance列上的索引基本上是无效的。(这是很好理解的,“不等于”在B+树索引中怎么能查找呢?)

我们把查询语句中Where后面的表达式称为谓词。DB2中谓词能否使用索引的情况列表如下: