|
| 1 | +# 数据结构在数据库中的应用 |
| 2 | + |
| 3 | +> 关键词:链表、数组、散列表、红黑树、B+ 树、LSM 树、跳表 |
| 4 | +
|
| 5 | +## 引言 |
| 6 | + |
| 7 | +从本质来看,数据库只负责两件事:读数据、写数据。 |
| 8 | + |
| 9 | +数据结构的核心就是合理组织数据,尽可能提升读、写数据的效率。 |
| 10 | + |
| 11 | +所以,数据结构是实现数据库的基石。 |
| 12 | + |
| 13 | +## 索引 |
| 14 | + |
| 15 | +索引是基于原始数据衍生的扩展数据结构。它的主要作用是缩小检索的数据范围,提升查询性能。 |
| 16 | + |
| 17 | +很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的结构势必会引入开销,特别是在新数据写入时。对于写人,它很难超过简单地追加文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据时,需要更新索引,因此任何类型的索引通常都会降低写的速度。 |
| 18 | + |
| 19 | +### 数组和链表 |
| 20 | + |
| 21 | +数组和链表分别代表了连续空间和不连续空间的存储方式,它们是线性表(Linear List)的典型代表。其他所有的数据结构,比如栈、队列、二叉树、B+ 树等,实际上都是这两者的结合和变化。 |
| 22 | + |
| 23 | +数组**支持随机访问**。根据下标随机访问的时间复杂度为 `O(1)`。但这并不代表数组的查找时间复杂度也是 `O(1)`。 |
| 24 | + |
| 25 | +- 对于无序数组,只能顺序查找,其时间复杂度为 `O(n)`。 |
| 26 | +- 对于有序数组,可以应用二分查找法,其时间复杂度为 `O(log n)`。 |
| 27 | + |
| 28 | +在有序数组上应用二分查找法如此高效,为什么几乎没有数据库直接使用数组作为索引?这是因为它的限制条件:数据有序。为了保证数据有序,每次添加、删除数组数据时,都必须要进行数据调整,来保证其有序。此外,由于数组空间大小固定,每次扩容只能采用复制数组的方式。数组的这些特性,决定了它不适合用于数据频繁变化的应用场景。 |
| 29 | + |
| 30 | +### 散列表 |
| 31 | + |
| 32 | +散列表的思路是:使用 Hash 函数将 Key 转换为数组下标。 |
| 33 | + |
| 34 | +哈希表的本质是一个数组,它通过 Hash 函数将查询的 Key 转为数组下标,利用数组的随机访问特性,使得我们能在 `O(1)` 的时间代价内完成检索。 |
| 35 | + |
| 36 | +#### 位图和布隆过滤器 |
| 37 | + |
| 38 | +在海量数据中,快速判断一个对象是否存在。相比于有序数组、二叉检索树和哈希表这三种方案,位图和布隆过滤器其实更适合解决这类状态检索的问题。这是因为,在不要求 100% 判断正确的情况下,使用位图和布隆过滤器可以达到 `O(1)` 时间代价的检索效率,同时空间使用率也非常高效。 |
| 39 | + |
| 40 | +为了判断一个很大的数据范围中,某数值是否存在,可以将这个范围的数据存为数组,其数组值为布尔型(true 或 false)。由于很多语言中,布尔类型需要 1 个字节,而二进制位(bit)的值 0 或 1 也可以表示 true 或 false,并且占用空间更小,所以更加合适。而这种基于位运算的哈希结构,即为位图。 |
| 41 | + |
| 42 | +布隆过滤器最大的特点,就是对一个对象使用多个哈希函数。如果我们使用了 k 个哈希函数,就会得到 k 个哈希值,也就是 k 个下标,我们会把数组中对应下标位置的值都置为 1。布隆过滤器和位图最大的区别就在于,我们不再使用一位来表示一个对象,而是使用 k 位来表示一个对象。这样两个对象的 k 位都相同的概率就会大大降低,从而能够解决哈希冲突的问题了。 |
| 43 | + |
| 44 | +布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。不过,只要我们调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。 |
| 45 | + |
| 46 | +布隆过滤器过滤器适用于对误判有一定容忍度的场景。 |
| 47 | + |
| 48 | +### B+ 树 |
| 49 | + |
| 50 | +内存是半导体元件。对于内存而言,只要给出了内存地址,我们就可以直接访问该地址取出数据。这个过程具有高效的随机访问特性,因此内存也叫随机访问存储器(Random Access Memory,即 RAM)。内存的访问速度很快,但是价格相对较昂贵,因此一般的计算机内存空间都相对较小。 |
| 51 | + |
| 52 | +而磁盘是机械器件。磁盘访问数据时,需要等磁盘盘片旋转到磁头下,才能读取相应的数据。尽管磁盘的旋转速度很快,但是和内存的随机访问相比,性能差距非常大。一般来说,如果是随机读写,会有 10 万到 100 万倍左右的差距。但如果是顺序访问大批量数据的话,磁盘的性能和内存就是一个数量级的。 |
| 53 | + |
| 54 | +磁盘的最小读写单位是扇区,较早期的磁盘一个扇区是 **`512`** 字节。随着磁盘技术的发展,目前常见的磁盘扇区是 **`4K`** 个字节。操作系统一次会读写多个扇区,所以操作系统的最小读写单位是块(Block),也叫作簇(Cluster)。当我们要从磁盘中读取一个数据时,操作系统会一次性将整个块都读出来。因此,对于大批量的顺序读写来说,磁盘的效率会比随机读写高许多。 |
| 55 | + |
| 56 | +假设有一个有序数组存储在硬盘中,如果它足够大,那么它会存储在多个块中。当我们要对这个数组使用二分查找时,需要先找到中间元素所在的块,将这个块从磁盘中读到内存里,然后在内存中进行二分查找。如果下一步要读的元素在其他块中,则需要再将相应块从磁盘中读入内存。直到查询结束,这个过程可能会多次访问磁盘。我们可以看到,这样的检索性能非常低。 |
| 57 | + |
| 58 | +由于磁盘相对于内存而言访问速度实在太慢,因此,对于磁盘上数据的高效检索,我们有一个极其重要的原则:对磁盘的访问次数要尽可能的少! |
| 59 | + |
| 60 | +将索引和数据分离就是一种常见的设计思路。在数据频繁变化的场景中,有序数组并不是一个最好的选择,二叉检索树或者哈希表往往更有普适性。但是,哈希表由于缺乏范围检索的能力,在一些场合也不适用。因此,二叉检索树这种树形结构是许多常见检索系统的实施方案。 |
| 61 | + |
| 62 | +随着索引数据越来越大,直到无法完全加载到内存中,这是需要将索引数据也存入磁盘中。B+ 树给出了将树形索引的所有节点都存在磁盘上的高效检索方案。操作系统对磁盘数据的访问是以块为单位的。因此,如果我们想将树型索引的一个节点从磁盘中读出,即使该节点的数据量很小(比如说只有几个字节),但磁盘依然会将整个块的数据全部读出来,而不是只读这一小部分数据,这会让有效读取效率很低。B+ 树的一个关键设计,就是让一个节点的大小等于一个块的大小。节点内存储的数据,不是一个元素,而是一个可以装 m 个元素的有序数组。这样一来,我们就可以将磁盘一次读取的数据全部利用起来,使得读取效率最大化。 |
| 63 | + |
| 64 | +B+ 树还有另一个设计,就是将所有的节点分为内部节点和叶子节点。内部节点仅存储 key 和维持树形结构的指针,并不存储 key 对应的数据(无论是具体数据还是文件位置信息)。这样内部节点就能存储更多的索引数据,我们也就可以使用最少的内部节点,将所有数据组织起来了。而叶子节点仅存储 key 和对应数据,不存储维持树形结构的指针。通过这样的设计,B+ 树就能做到节点的空间利用率最大化。此外,B+ 树还将同一层的所有节点串成了有序的双向链表,这样一来,B+ 树就同时具备了良好的范围查询能力和灵活调整的能力了。 |
| 65 | + |
| 66 | +因此,B+ 树是一棵完全平衡的 m 阶多叉树。所谓的 m 阶,指的是每个节点最多有 m 个子节点,并且每个节点里都存了一个紧凑的可包含 m 个元素的数组。 |
| 67 | + |
| 68 | +即使是复杂的 B+ 树,我们将它拆解开来,其实也是由简单的数组、链表和树组成的,而且 B+ 树的检索过程其实也是二分查找。因此,如果 B+ 树完全加载在内存中的话,它的检索效率其实并不会比有序数组或者二叉检索树更 |
| 69 | +高,也还是二分查找的 log(n) 的效率。并且,它还比数组和二叉检索树更加复杂,还会带来额外的开销。 |
| 70 | + |
| 71 | +另外,这一节还有一个很重要的设计思想需要你掌握,那就是将索引和数据分离。通过这样的方式,我们能将索引的数组大小保持在一个较小的范围内,让它能加载在内存中。在许多大规模系统中,都是使用这个设计思想来精简索引的。而且,B+ 树的内部节点和叶子节点的区分,其实也是索引和数据分离的一次实践。 |
| 72 | + |
| 73 | +MySQL 中的 B+ 树实现其实有两种,一种是 MyISAM 引擎,另一种是 InnoDB 引擎。它们的核心区别就在于,数据和索引是否是分离的。 |
| 74 | + |
| 75 | +在 MyISAM 引擎中,B+ 树的叶子节点仅存储了数据的位置指针,这是一种索引和数据分离的设计方案,叫作非聚集索引。如果要保证 MyISAM 的数据一致性,那我们需要在表级别上进行加锁处理。 |
| 76 | + |
| 77 | +在 InnoDB 中,B+ 树的叶子节点直接存储了具体数据,这是一种索引和数据一体的方案。叫作聚集索引。由于数据直接就存在索引的叶子节点中,因此 InnoDB 不需要给全表加锁来保证一致性,它只需要支持行级的锁就可以了。 |
| 78 | + |
| 79 | +### LSM 树 |
| 80 | + |
| 81 | +B+ 树的数据都存储在叶子节点中,而叶子节点一般都存储在磁盘中。因此,每次插入的新数据都需要随机写入磁盘,而随机写入的性能非常慢。如果是一个日志系统,每秒钟要写入上千条甚至上万条数据,这样的磁盘操作代价会使得系统性能急剧下降,甚至无法使用。 |
| 82 | + |
| 83 | +操作系统对磁盘的读写是以块为单位的,我们能否以块为单位写入,而不是每次插入一个数据都要随机写入磁盘呢?这样是不是就可以大幅度减少写入操作了呢?解决方案就是:**LSM 树**(Log Structured Merge Trees)。 |
| 84 | + |
| 85 | +LSM 树就是根据这个思路设计了这样一个机制:当数据写入时,延迟写磁盘,将数据先存放在内存中的树里,进行常规的存储和查询。当内存中的树持续变大达到阈值时,再批量地以块为单位写入磁盘的树中。因此,LSM 树至少需要由两棵树组成,一棵是存储在内存中较小的 C0 树,另一棵是存储在磁盘中较大的 C1 树。 |
| 86 | + |
| 87 | +LSM 树具有以下 3 个特点: |
| 88 | + |
| 89 | +1. 将索引分为内存和磁盘两部分,并在内存达到阈值时启动树合并(Merge Trees); |
| 90 | +2. 用批量写入代替随机写入,并且用预写日志 WAL 技术(Write AheadLog,预写日志技术)保证内存数据,在系统崩溃后可以被恢复; |
| 91 | +3. 数据采取类似日志追加写的方式写入(Log Structured)磁盘,以顺序写的方式提高写 |
| 92 | + 入效率。 |
| 93 | + |
| 94 | +LSM 树的这些特点,使得它相对于 B+ 树,在写入性能上有大幅提升。所以,许多 NoSQL 系统都使用 LSM 树作为检索引擎,而且还对 LSM 树进行了优化以提升检索性能。 |
| 95 | + |
| 96 | +### 倒排索引 |
| 97 | + |
| 98 | +倒排索引的核心其实并不复杂,它的具体实现其实是哈希表,只是它不是将文档 ID 或者题目作为 key,而是反过来,通过将内容或者属性作为 key 来存储对应的文档列表,使得我们能在 O(1) 的时间代价内完成查询。 |
| 99 | + |
| 100 | +尽管原理并不复杂,但是倒排索引是许多检索引擎的核心。比如说,数据库的全文索引功能、搜索引擎的索引、广告引擎和推荐引擎,都使用了倒排索引技术来实现检索功能。 |
| 101 | + |
| 102 | +### 索引的维护 |
| 103 | + |
| 104 | +#### 创建索引 |
| 105 | + |
| 106 | +- **数据压缩**:一个是尽可能地将数据加载到内存中,因为内存的检索效率大大高于磁盘。那为了将数据更多地加载到内存中,索引压缩是一个重要的研究方向。 |
| 107 | +- **分支处理**:另一个是将大数据集合拆成多个小数据集合来处理。这其实就是分布式系统的核心思想。 |
| 108 | + |
| 109 | +#### 更新索引 |
| 110 | + |
| 111 | +(1)Double Buffer(双缓冲)机制 |
| 112 | + |
| 113 | +就是在内存中同时保存两份一样的索引,一个是索引 A,一个是索引 B。两个索引保持一个读、一个写,并且来回切换,最终完成高性能的索引更新。 |
| 114 | + |
| 115 | +优点:简单高效 |
| 116 | + |
| 117 | +缺点:达到一定数据量级后,会带来翻倍的内存开销,甚至有些索引存储在磁盘上的情况下,更是无法使用此机制。 |
| 118 | + |
| 119 | +(2)全量索引和增量索引 |
| 120 | + |
| 121 | +将新接收到的数据单独建立一个可以存在内存中的倒排索引,也就是增量索引。当查询发生的时候,我们会同时查询全量索引和增量索引,将合并的结果作为总的结果输出。 |
| 122 | + |
| 123 | +因为增量索引相对全量索引而言会小很多,内存资源消耗在可承受范围,所以我们可以使用 Double Buffer 机制 |
| 124 | +对增量索引进行索引更新。这样一来,增量索引就可以做到无锁访问。而全量索引本身就是只读的,也不需要加锁。因此,整个检索过程都可以做到无锁访问,也就提高了系统的检索效率。 |
| 125 | + |
| 126 | +## 参考资料 |
| 127 | + |
| 128 | +- **书籍** |
| 129 | + - [《数据密集型应用系统设计》](https://book.douban.com/subject/30329536/) |
| 130 | +- **教程** |
| 131 | + - [数据结构与算法之美](https://time.geekbang.org/column/intro/100017301) |
| 132 | + - [检索技术核心 20 讲](https://time.geekbang.org/column/intro/100048401) |
| 133 | +- **论文** |
| 134 | + - [Data Structures for Databases](https://www.cise.ufl.edu/~mschneid/Research/papers/HS05BoCh.pdf) |
| 135 | +- **文章** |
| 136 | + - [Data Structures and Algorithms for Big Databases](https://people.csail.mit.edu/bradley/BenderKuszmaul-tutorial-xldb12.pdf) |
0 commit comments