理解数据库(一):数据组织方式与索引

2,512 阅读22分钟

我们常说的“数据库”,比如“MySQL”、“Oracle”等,其实严格来说是DBMS(Database Management System),数据库只是一个存储数据着数据的仓库,而DBMS做的事是让我们能够操作数据库,比如解析SQL、DML等,都是DBMS在支持着。 在DBMS之下,又有着存储引擎,为DBMS提供数据增删改查的支持,不同的存储引擎提供不同的特性,负责组织数据的就是存储引擎。

一、数据的组织方式

1. 单条记录的结构

每一个字段都需要定义一个数据类型(DataType),数据类型在数据组织时的意义是确定数据长度,存储介质将会为其分配合适长度的空间。 每个字段被按照顺序组织起来,并且在开头存储着这一行的某些头信息(MetaData),例如记录总长度、时间戳等,这就组成了一条在硬盘上被存储的完整记录:

定长记录行的表示
如果是变长记录,比如某一个字段是varchar(256),则会在头信息后紧接着存储这一列的指针,指向这个字段的值存储地址(一般是在记录的末尾):
变长记录行的表示
由于每次从磁盘中读取数据的单位是页(Page),为了保证读取速度,一般数据库都会要求一条记录的长度不超过一页(页的长度不固定,可以被设置,一般是4kb),保证一次读取就能读到一条完整记录,而不需要跨页读取,因此对于某些存储着大数据的字段,比如图片、视频,他们往往被独立存储到其他文件,而不与记录中的其他小字段存储在一起。

2. 多条记录如何被组织

行是一条具有完整意义的记录,被按照一定的规则,依次存储在文件中。 记录在文件中有以下几种主要的组织方法:

1. 堆文件

记录与记录之间没有顺序关系,每条记录可以存放在文件中的任何地方,只要想被存储的地址有足够空间。

2. 顺序文件

也就是遵循某个搜索码(Search key)的顺序,依次存储每一条记录。搜索码是一系列搜索条件的组合,可以是一个键,也可以是多个键组合。如下图,按照第二列,也就是姓名的顺序去决定记录的存储顺序:

顺序文件
这种方式非常利于顺序读取,因为连续的记录都保存在连续的页中,可一次性读出多个页获得批量的记录,而无需多次寻址多次读取。 但是这种方式不利于记录的删除和插入,因为删除记录时需要将后面的记录依次前移,插入记录时需要将后面的记录依次后移,如果受影响的记录多,效率将会很低。 为了解决每次插入删除数据都需要移动后面的记录的问题,现实中可能采取了指针,也就是每一条记录中保存着指向下一条记录的指针,当有记录被删除时,仅仅修改指针,避免记录的移动;当有记录插入时,先检查在合适的位置是否有空闲空间,如果没有,采用开辟溢出块的方式,不直接插入至合适位置,而是在其他空间存储这条记录,然后修改上一条记录的指针指向这条新插入的记录,去避免大量记录的移动:
开辟溢出块
但是如果大量的记录都存储在溢出块中,顺序文件本身所带来的好处就大打折扣,因为很多记录已经不再在物理上按顺序存储,那么顺序获取记录时,就可能需要多次寻址多次读取,而不能通过一次性读取连续的页来获取连续的记录。此时,文件就需要被重组,会将所有记录重新组织成完全物理邻接的文件。

3. 聚簇文件

前面提到的顺序文件是不同的表存储在不同的文件中,但是某些具体应用场景下,可能常常涉及多表查询,比如有一个名为Singers的表保存着歌手信息,又有一个名为Albums的表保存着每个歌手发布的专辑信息,如果你正在开发一个音乐播放器,那么涉及的场景一般都是需要找出某个歌手发布的所有专辑展示给客户,如果不同的表保存在不同的文件中,那么需要进行连接(Join) ,复杂度比较高,但是如果将每个歌手的专辑信息都在物理上存储在歌手信息之后,也就是两张表混合存放在同一个文件中:

聚簇文件
采用聚簇文件则不需要进行Join操作,找到Singer后,直接顺序读取后面的页,就能拿到指定歌手的所有专辑,提高了查询效率。

4. 散列文件

散列文件完全没有顺序,每条记录应该存放的位置,是根据搜索码的Hash值决定的,因此插入删除都不涉及记录移动,且由于搜索码的Hash值直接决定了存储位置,所以查找符合特定搜索码的记录非常快,但是不支持范围查找与顺序读取。

3. 记录的读取

DBMS维护着自己的缓存空间,使用一些缓存置换算法尽量确保那些经常被使用的数据在缓存中,以避免磁盘的读取。与DBMS一样,磁盘一般也有着自己的缓冲区以保存经常被读取的数据,减少响应时间。因此,如果要读取一条记录,根据优先顺序,路径为DBMS缓存区 => 磁盘缓存区 => 磁盘。

1. 从DBMS缓存区读取

这是成本最低的方式,因为DBMS缓存区就在内存,可以直接被CPU使用,不涉及磁盘IO,可以考虑IO时间为0。

2. 从磁盘缓存区读取

如果磁盘缓存区有需要的记录,则只需要直接读出,传输时间考虑为1ms。

3. 从磁盘读取

由于SSD比较贵,常用的还是机械硬盘,对于机械硬盘,要读取指定地址的数据,是需要经过寻道的,机械臂需要先移动到指定位置,因此无论读取多少数据,准备工作都会耗费一段时间。 整个IO流程包括:排队等待 => 寻道 => 半圈旋转 => 传输

随机读取
一次随机读取中,有90%的时间都花费在排队和准备工作,真正的传输时间只有1ms,随机读取10页,就需要10*10=100ms,但如果是顺序读,对于传输速度为40MB/s的硬盘,读取一个4kb的页仅需要0.1ms,即使顺序读取100页,也只需要1页随机读99页顺序读,也就是10ms+9.9ms=19.9ms,速度差距几十倍,这也是为何我们想要尽量保证需要读取的数据都在物理上排列在一起,因为这样就可以顺序读取多个页,而不需要进行多次随机读取。

磁盘及CPU时间的基础假设
因此对于数据读取速度的优化,主要就是需要降低IO时间,而降低IO时间的关键,就在于减少随机读次数以及读取更少的数据。 合适的索引将会很大程度上地帮助我们实现这个目标。

二、索引

考虑一种情况:我们有一张存储着100万个注册用户的Users表,我们要搜索用户名为AfterShip的用户,如果这张表是使用顺序文件存储,并且存储顺序是根据account_id列,而不是根据username列,在没有索引时,查找的方式应该是从第一条记录起依次读入记录,并对比每一条记录的username是否为AfterShip,直到找到为止。最好的情况是第一条记录即符合要求,最坏的情况是最后一条记录才符合要求,在最坏的情况下,需要读取100万条记录,假设每条记录1kb,需要读取976MB的数据!即使以200MB/s的传输速度,仅仅是IO时间就需要5s读取记录,并且还需要大量的时间给CPU处理100万条的记录。 如果是以account_id作为搜索条件,最快的方式是从文件的最中间位置读出最中间记录,对比account_id的大小,再判断往前还是往后读,也就是使用2分搜索,最坏的情况下需要进行logN次,也就是20次左右的随机读,耗时200ms。 因此,当我们的搜索码被顺序地组织起来,我们就能更少地读取数据,以更快的方式查询到符合要求的记录,但是,文件只能以一种搜索码组织起来,不能既以account_id为顺序,又以username为顺序,因此,我们需要一种冗余的数据——索引,来以我们想要的顺序组织某个搜索码,加速我们的查询。

1. 什么是索引

索引是一种被以合适的数据结构组织起来方便搜索的冗余数据,也存储在文件中。 比如对于Users表,我们为username建立索引,那么DBMS会将username的值复制一份,并排序,保存在一个文件中:

索引
每条索引保存着指向原始记录的指针,同时保存着这条索引字段的值。 如果索引也是一个顺序文件,那么我们根据上面的例子,要查找usernameAfterShip的记录,就可以使用二分搜索,或者哪怕是顺序扫描整个索引也比之前进行全表扫描快得多,因为一个username的长度如果是50bytes,那么扫描整个索引也只需要读取不到50MB的索引文件,体积只是全表扫描的二十分之一。 由此可见,索引可以有效地加快查询速度。刚刚讲到的是顺序索引,在索引的具体实现中还有多种更复杂的数据结构和算法,索引有多种实现方式,每种实现方式都各有优缺点,适应不同的应用环境。

2. 索引的分类

索引可以从多个维度分类,每个维度的分类互不冲突。

  • 聚簇索引(Clustered Index) 与 非聚簇索引(Nonclustered Index / Secondary Index) 前面讲到顺序文件是将记录根据某个搜索码的值排列的,我们在这个搜索码上建立的索引就是聚簇索引,聚簇索引代表着记录的物理存储顺序是被这个索引的排列顺序决定的。在MySQL中,主键(Primary Key)就是聚簇索引,因为每条记录的物理顺序是与主键顺序相同的。聚簇索引不一定是主键,可以是任何搜索码,但一般的DBMS都将主键作为聚簇索引。 不以索引顺序组织表文件顺序的索引,就是非聚簇索引,也称为辅助索引(Secondary Index),比如上面讲到的以username建立的索引。
  • 稠密索引 与 稀疏索引 根据是否是为每个搜索码都建立对应的索引,分为稠密索引与稀疏索引。 稠密索引为每一个搜索码都建立一条索引记录,如果此稠密索引是聚簇索引,那么只需要保存符合此搜索码的第一条记录的指针即可,因为所有符合此搜索码的记录一定都在物理顺序上紧随其后,如果此稠密索引是非聚簇索引,那么每个索引项中都必须保存着符合此搜索码的所有记录的指针,在下图中,我们为第二列,也就是地名,建立稠密索引:
    稠密索引为每一个搜索码都建立一条索引记录
    如果我们将记录分为多个组,仅为每个组的第一条记录建立索引,也就是索引到组而不是索引到记录,那么就称为稀疏索引。如何将记录分组?其中一种方式是以页为单位,为每一页的记录建立一条索引:
    稀疏索引仅为每组的第一条记录建立索引

查找速度: 对于稠密索引,由于为每一个搜索码都建立的相应的索引项,因此空间占用比较大,但是查找速度较快,因为可以从索引文件中直接找到对应记录的位置,而使用稀疏索引需要先找到记录所在的页,再读出整个页,从页中找到具体的记录。 维护成本: 稠密索引为每个搜索码都建立对应的索引项,且索引项中还保存着符合此搜索码的所有记录的指针,也就是关联到了表中的每一条记录,因此当任何一条记录被删除、插入,都需要修改甚至移动、重组索引文件,维护成本较高。 而稀疏索引仅仅为分组建立索引项,当组中有记录删除时不一定会马上修改索引,有记录插入到现有的组时,只要不占用新的页或者影响到组的第一条记录,那么也不会建立新的索引,索引更新相对不那么频繁,维护成本较小。

  • 有序索引 与 散列索引 前面讲到的索引都是根据特定搜索码排序的,都叫做有序索引,索引项的位置是根据其搜索码在整个搜索码集合中的相对位置决定的,要查找某条记录,通过对比搜索码大小的方式找到相应索引项,然后通过指针从表中读取记录。 而散列索引使用特定散列算法算出搜索码的Hash值,根据Hash值直接确定这条搜索码的索引项的地址,而不是通过比较搜索码大小的方式,对于指定搜索码,查找速度非常快,但不能像有序索引一样支持范围查找。在增删记录时,也不需要造成索引的移动、重组,因此维护成本比有序索引更低。

3. 多级索引

继续考虑那张存有100万条用户数据的Users表,我们为username建立了有序稠密索引,并且我们假设username是具备唯一性的,也就是对于100万个用户,就有100万个不同的username,稠密索引将会有100万条索引项,如果一个4kb的页能保存100条索引项,那么就需要1万页来保存整个索引文件。如果我们要查询usernameAfterShip的用户,使用二分法就需要进行logN次的查询,也就是14次随机读找到其索引项,再通过一次随机读读出记录,一共150ms,一秒内只能进行6次查询。如果我们能减少其随机读次数,那么每少一次随机读,就会少10ms的耗时,减少随机读有以下两个思路:

  1. 将索引缓存在内存中,避免磁盘读取
  2. 优化路径,以更少的随机读查找到对应索引项

如果我们基于100万条稠密索引再去建立稀疏索引,也就是对1万个页建立索引,那么对于一页能保存100条索引项的情况下,我们将会有更上一级的,仅占用100页的稀疏索引,整个索引文件为400kb,足够小到能够放入内存,因此可以保存在DBMS缓存区,先通过稀疏索引找到稠密索引所在的页地址,再进行一次随机读,读出整个页,找到搜索码对应的具体索引项,然后再进行第二次随机读,读出表中记录,一共只有1次内存读+2次随机读,20ms。仅仅多建立一层稀疏索引,也即是使用二级索引结构,就有7倍的效率提升。在现实场景中,往往会多次进行这种索引结构的建立,也就是多级索引结构。

二级索引结构

4. 代表性索引结构

(1)B+树索引

B+树是一种多级索引的实现,采用平衡树结构,有非页节点叶节点两种节点组成,每种节点存放的数据有细微差别:

  • 非叶节点(根节点也是非叶节点): 节点最多包含着n-1个搜索码值K1…Kn-1,并包含着n个指针P1…Pn,也就是两边是指针,中间是搜索码值,Pn指针指向小于其Kn搜索码的下一级索引节点,Pn+1指向大于等于Kn搜索码的下一级索引节点。

    根节点
    举个栗子:
    非叶节点

  • 叶节点 叶节点的P1…Pn-1的指针都指向记录地址(如果是稠密索引)或者页地址(如果是稀疏索引),叶节点的最后一个指针Pn与非叶节点不同,它指向的是下一个同级叶节点,构成横向有序的索引结构。

    叶节点

一个完整的三级B+树如下所示:

三级B+树
B+树的叶节点中的搜索码值是可以重复的,当这个B+树索引是非聚簇稠密索引且搜索码对应的记录不唯一时,就需要将一个搜索码重复放置在叶节点中,指向不同的记录:
搜索码重复

  • B+树维护成本 考虑一个稠密B+树索引,在删除记录时,由于B+树要求每个叶节点都必须处于半满状态,当被删除索引项所处的节点不满足半满时,需要向兄弟节点借搜索码值,并且在需要时调整父节点,是一个局部重组B+树的过程。 在插入记录时,可能出现某个索引节点已经没有多余空间存储,此时则需要分裂叶节点,并且上层非叶节点也可能需要分裂,依次往上递归,也是一次重组的过程。

  • B+树的优点 从上面能够看出,在某些情况下,删除和插入记录时,B+树的维护成本比较高,但是为何依旧是最常用的索引结构之一呢,因为我们往往会把每个节点的空间设置得足够大,一般是一整页,如果一个索引项占用100bytes,则对于4kb的页能够存储40个索引项,即使是100万条记录的表,B+树也只需要log(40)1000000=3层,查询路径非常短,因此B+树实际上是一种效率非常高的索引结构。

####(2)B树索引 这是一种与B+树类似的平衡树索引,区别在于,B+树只有叶节点保存着指向记录的指针,非叶节点仅仅是索引着索引的索引,而B树整棵树的所有节点都保存着指向其对应记录的指针,整棵树才是一个完整的索引:

B树索引
B树不常被应用,因为在B树种范围查询的效率非常低,B+树中所有叶节点被链接起来成为有序链表,可以方便地遍历所需范围的数据,而B树则需要更加复杂的算法去遍历多个层次的节点才能获取到一定范围内的数据。

(3)散列索引

前面讲到的索引结构都需要通过对比搜索码的大小去查找索引项的位置,复杂度是对数级别的,而散列索引将存储空间分为多个组,称为桶(Bucket),直接通过散列函数计算搜索码的Hash值,通过Hash值确定此搜索码的索引项在哪个桶中,读取桶中的索引项,就可以找到对应索引项,复杂度为O(1),因此散列索引对于查询指定搜索码的效率非常高。 根据桶的数量是否固定,散列索引分为静态散列动态散列两种:

散列索引分类

  • 静态散列 静态散列非常简单,桶的个数早已确定,比如对于Users表,已确定共有100个桶,那么对于每条记录应该放置在哪个桶,即计算Hash(搜索码) mod 100,就能确定应该放置到哪个桶。 我们并不知道每张表最终会有多少记录,因此预先分配的桶的容量可能随着记录的增加而不够用,比如预先分配的桶容量可能是一页4kb,每个索引项100bytes只能存储40个索引项,当有第41条索引项插入时,就需要开辟溢出桶
    溢出桶
    溢出桶是使用链表实现的,主桶保存着下一个溢出桶的指针,每个溢出桶依次链接。 于是,静态散列有一个非常明显的缺陷:当数据量变得很大时,可能会大量开辟溢出桶,造成每次查找索引项,可能要进行多次随机读,链表越长,随机读次数越多,效率下降。
  • 动态散列 动态散列可以使得桶的个数随着记录的增加而动态增加,这里介绍比较基础的可扩展散列(Extendable Hashing): 首先,我们选择一个具有均匀和随机特性的散列函数H,此散列函数的结果是N位二进制数,比如N=32,则每个Hash值为32位的二进制数。 此记录的索引项应该存储在哪个桶中,取二进制数的前 i 位,i 的起始值为1,我们会保存着这个 i 值,我们来看一条记录是如何放入桶中:
    1. 使用散列函数H计算搜索码X的Hash值,假设H(X) = 0001…(省略后面的28位,省略号表示在我们的讨论中不重要,下同)
    2. 查看 i 值,此时 i = 1,则表示此记录的索引项应该存在 0001…的第1位,也就是0号桶中
    3. 我们维护着一个桶地址列表,保存着每个号码的桶的指针,在列表中找到0号桶的指针,访问0号桶,将其放进去。下图中,我们为每个桶中保存着一个值K,表示这个桶是以前K位作为标识的。
      桶地址列表与桶
  • 桶分裂 在上图中,1号桶已经满了,如果新增一条Hash值为1010…的记录,根据i的值,我们需要将它放进1号桶,但是检查发现1号桶已经满了,于是需要进行桶的分裂,先更新桶地址列表,使得i值增加1,使用前2位作为桶号,列表中变成00、01、10、11四个桶,将之前已经满了的1号桶,其中10开头的记录放入10号桶,11开头的记录放入11号桶,且这两个新桶的K值设置为2,之前的0号桶不动,并且00和01都同时指向0号旧桶,不改变:
    仅分裂已经满了的桶
    因此可以发现,我们仅仅分裂已经满了的桶,其他桶不会动,并且不同的桶号,可能指向的是同一个地址,暂时共用一个未满的桶。 在记录被删除时,如果桶已经空了,则会合并桶。 这就是神奇的动态散列算法。

5. 多码索引

目前为止我们讨论的都是搜索码为一个字段的情况,其实搜索码可以是多个字段的组合,比如index(username,age,city),索引项中按照索引定义次序依次存储着三个字段的值,比如(AfterShip,25,ShenZhen),索引项之间的排序先根据第一列索引排序,第一列相同的情况下再根据第二列排序,以此类推。

6. 覆盖索引(Covering Index)

覆盖索引不是一种索引分类,而是一种对索引的使用方式。 继续考虑上面那张保存着100万用户的Users表,我们要查找usernameAfterShip的用户的email,如果我们仅仅为username建立索引,那么我们需要先通过索引查找到usernameAfterShip的账号的记录指针,再回表读取此记录email列的值。但如果我们的索引是为(username,email)建立的复合索引,那么我们在索引项中就能直接获取到email值,而不需要回表读取,减少一次随机IO操作。 因此,适当地利用覆盖索引,可以减少IO,加快查询。


参考资料