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

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

1、数据的组织方式

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时间的关键,就在于减小随机读次数以及读取更少的数据。 合适的索引将会很大程度上地帮助咱们实现这个目标。

2、索引

考虑一种状况:咱们有一张存储着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、0一、十、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,加快查询。


参考资料

相关文章
相关标签/搜索