我们都知道,数据库使用索引,会提升效率。那数据库索引是如何实现的呢?底层使用的是什么数据结构和算法?
从执行效率方面,我们希望通过索引,查询效率尽可能的高;在存储空间方面,我们希望索引不要消耗太多的空间。
假设我们有这两个基本的需求:
对于常用的数据结构,能否解决上面的两个需求呢?
先看散列表,它的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按区间快速查找数据。所以,散列表不能满足我们的需求。
再看平衡二叉查找树。尽管平衡二叉查找树查询性能也很高,时间复杂度是O(logn)。而且,对树进行中序遍历,我们还可能得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
我们再看跳表。跳表是在链表之上加上多层索引结构。它支持快速插入、查找、删除数据,对应的时间复杂度是O(logn)。并且,跳表也支持按区间快速地查找数。我们只需要定位到区间起点值对应在链表的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据。
这样看来,跳表是可能解决这个问题。实际上,数据库索引所用的数据结构跟跳表非常相似,叫作B+树。不过,这是通过二叉查找树演化过来的,而不是跳表。我们从二叉查找树讲起,看它如何一步一步被改造成 B+树的。
为了让二叉查找树支持按照区间来查找数据,我们对其进行改造:树中的节点并不存储数据本身,而只是作为索引。除此之外,我们把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的。经过改造之后的二叉树,就像图上这样,看越来是不是很像跳表呢?
改造之后,如果我们要求区间的数据。我们只需要拿区间的起始值,在树中查找,当查找到某个叶子节点之后,我们再顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止。所有遍历到的数据,就是符合区间值的所有数据。
但是,要对上千万、上亿的数据构建索引,如果将索引存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是,占用的内存会非常多。
比如,给一亿个数据构建二叉树查找树索引,那索引中会包含大约1亿个节点,每个节点假设占用16个字节,那需要大约1GB的内存。给一张表建立索引,我们需要1GB的内存空间。如果我们要给10张表建立索引,那对内存的需要是无法满足的。如何解决这个索引占用太多内存的问题呢?
借助时间换空间的思路,把索引存储在硬盘中,而非内存中。我们都知道,硬盘是一个非常慢的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍。
这种方案,需要读取磁盘中的索引,因此数据查询效率就相应降低很多。
二叉查找树,经过改造之后,支持区间查找的功能。可为了节省内存,把树存储在硬盘中,每个节点的读取(或访问),都对应一次磁盘IO操作。树的高度就等于每次查询数据时磁盘IO操作的次数。
如果我们把索引构建成 m 叉树,树的高度就会降低。如果对16个数据构建五叉树索引,那高度只有2,查找一个数据,对应只需要2次磁盘操作。如果 m=1000,那对一亿个数据构建索引,树的高度也只是3,最多只要3次磁盘IO就能获取到数据。磁盘IO变少了,查找数据的效率也就提高了。
如果我们将 m 叉树实现 B+ 树索引,用代码实现出来,就是下面这个样子(假设我们给 int 类型的数据库字段添加索引,所以代码中的 keywords 是 int 类型的):
/**
* 这是 B+ 树非叶子节点的定义。
*
* 假设 keywords=[3, 5, 8, 10]
* 4 个键值将数据分为 5 个区间:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
* 5 个区间分别对应:children[0]...children[4]
*
* m 值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
* PAGE_SIZE = (m-1)*4[keywordss 大小]+m*8[children 大小]
*/
class BPlusTreeNode {
public:
static int m; // 5 叉树
vector<int> keywords;// 键值,用来划分数据区间
vector<BPlusTreeNode *> children;// 保存子节点指针
};
int BPlusTreeNode::m = 5;
/**
* 这是 B+ 树中叶子节点的定义。
*
* B+ 树中的叶子节点跟内部结点是不一样的,
* 叶子节点存储的是值,而非区间。
* 这个定义里,每个叶子节点存储 3 个数据行的键值及地址信息。
*
* k 值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
* PAGE_SIZE = k*4[keyw.. 大小]+k*8[dataAd.. 大小]+8[prev 大小]+8[next 大小]
*/
class BPlusTreeLeafNode {
public:
static int k;
vector<int> keywords; // 数据的键值
vector<long> dataAddress; // 数据地址
BPlusTreeLeafNode *prev; // 这个结点在链表中的前驱结点
BPlusTreeLeafNode *next; // 这个结点在链表中的后继结点
};
int BPlusTreeLeafNode::k = 3;
对于相同个数的数据构建 m 叉树索引,m 越大,树的高度越小,那 m 多大才最合适呢?
不管内存中的数据,还是磁盘中的数据,操作系统都是按页(一页大小通常是4KB,这个值可以通过 getconfig PAGE_SIZE 命令查看)来读取的,一次会读一页的数据。如果需要读取的数据量超过一页的大小,就会触发多次IO操作。所以,在选择m大小时,尽量让每个节点的大小等于一页的大小,读取一个节点,只需要一次磁盘IO操作。
尽管索引可以提高数据查询的效率,但是索引会让写入数据的效率下降。因为在数据写入过程,会涉及索引的更新,这是索引导致写入变慢的主要原因。
对于一个B+树来说,m 值是根据页面的大小事先计算好的,也就是说,每个节点最多只能有 m 个子节点。在数据写入的过程中,这样就可能使索引中某些节点的子节点个数超过 m,这个节点的大小超过了一个页的大小,读取一个节点,就会导致多次磁盘IO操作。
实际上,解决这个问题并不复杂。只需要将这个节点分裂成两个节点。但是节点分裂之后,其上层父节点的子节点就可能超过 m 个。不过也没关系,可以用同样的方法,将这个父节点也分裂成两个节点。这种级联反应会从下往上,一直影响到根节点。这个分裂过程,结合图来看,就容易理解些(图中的 B+ 树是一个三叉树。我们限定叶子节点中,数据的个数超过2个就分裂节点;非叶子节点中,子节点的个数超过3个就分裂节点)。
正是因为要时刻保证 B+ 树索引是一个 m 叉树,所有,索引的存在会导致数据库写入的速度降低。实际上,删除数据也会变慢。
因为在删除某个数据的时候,也要对应更新索引节点。频繁的数据删除,就会导致某些节点中,子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。
我们可以设置一个阈值。在 B+ 树中,这个阈值等于 m/2。如果某个节点的子节点个数小于 m/2 ,我们就将它跟相邻的兄弟节点合并。不过,合并之后结点个数有可能超过m。针对这种情况,我们要以借助插入数据时的处理方法,再分裂节点。
文字描述不是很直观,我举了一个删除操作的例子,你可以对比着看下(图中的 B+ 树是一个五叉树。我们限定叶子节点中,数据的个数少于 2 个就合并节点;非叶子节点中,子节点的个数少于 3 个就合并节点)。
数据库索引以及B+树的由来,就讲完了。有没有发现,B+ 树的结构和操作,跟跳表非常类似。理论上讲,对跳表稍加改造,也可以替代 B+ 树,作为数据库的索引实现。
B+树发明于1972年,跳表发明于1989年。
数据库索引依赖的底层数据结构,B+树。它通过存储在磁盘的多叉树结构,做到了时间、空间的平衡,既保证了执行效率,又节省了内存。
B+ 数的特点大概有以个几个
除了 B+ 树,你可能还听过 B树、B-树。实际上B-树就是B树,其英文翻译都是 B-Tree,这里的 “-”只是一个连接符。
而B树实际上是低版本的B+树,或者说B+树是B树的改进版。B树跟B+不同点主要集中在这几个地方:
也就是说,B树只是一个每个节点的子节点个数不小于m/2的m叉树。
1. B+树中,将叶子节点串起来的链表,是单链表还是双向链表?为什么?
链表是双向链表,用以支持前后遍历
对于B+tree叶子节点,是用双向链表还是用单链表,得从具体的场景思考。我想,大部分同学在开发中遇到的数据库查询,都遇到过升序或降序问题,即类似这样的sql: select name,age, ... from where uid > startValue and uid < endValue order by uid asc(或者desc),此时,数据底层实现有两种做法:
1)保证查出来的数据就是用户想要的顺序
2)不保证查出来的数据的有序性,查出来之后再排序
以上两种方案,不加思考,肯定选第一种,因为第二种做法浪费了时间(如果选用内存排序,还是考虑数据的量级)。那如何能保证查询出来的数据就是有序的呢?单链表肯定做不到,只能从头往后遍历,再想想,只能选择双向链表了。此时,可能有的同学又问了:双向链表,多出来了一倍的指针,不是会多占用空间嘛? 答案是肯定的。可是,我们再细想下,数据库索引本身都已经在磁盘中了,对于磁盘来说,这点空间已经微不足道了,用这点空间换来时间肯定划算呀。顺便提一下:在实际工程应用中,双向链表应用的场景非常广泛,毕竟能大量减少链表的遍历时间
2. 我们对平衡二叉查找树进行改造,将叶子结点串在链表中,就支持了按照区间来查找数据。我们在散列表(下)讲到,散列表也经常跟链表一块使用,如果我们把散列表中的结点,也用链表串起来,能否支持按照区间查找数据呢?
可以支持区间查询。java中linkedHashMap就是链表链表+HashMap的组合,用于实现缓存的lru算法比较方便,不过要支持区间查询需要在插入时维持链表的有序性,复杂度O(n).效率比跳表和b+tree差。
JDK中的LinkedHashMap为了能做到保持节点的顺序(插入顺序或者访问顺序),就是用双向链表将节点串起来的。