返回介绍

驱动数据库的数据结构

发布于 2024-08-24 16:53:18 字数 19951 浏览 0 评论 0 收藏 0

世界上最简单的数据库可以用两个 Bash 函数实现:

#!/bin/bash
db_set () {
    echo "$1,$2" >> database
}

db_get () {
    grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

这两个函数实现了键值存储的功能。执行 db_set key value ,会将 键(key)值(value) 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON 文档。然后调用 db_get key ,查找与该键关联的最新值并将其返回。

麻雀虽小,五脏俱全:

$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}' $ 

$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'

$ db_get 42
{"name":"San Francisco","attractions":["Golden Gate Bridge"]}

底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与 CSV 文件类似)。每次对 db_set 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 —— 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 db_get 中使用了 tail -n 1 。)

$ db_set 42 '{"name":"San Francisco","attractions":["Exploratorium"]}' 

$ db_get 42
{"name":"San Francisco","attractions":["Exploratorium"]}

$ cat database
123456,{"name":"London","attractions":["Big Ben","London Eye"]}
42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
42,{"name":"San Francisco","attractions":["Exploratorium"]}

db_set 函数对于极其简单的场景其实有非常好的性能,因为在文件尾部追加写入通常是非常高效的。与 db_set 做的事情类似,许多数据库在内部使用了 日志(log) ,也就是一个 仅追加(append-only) 的数据文件。真正的数据库有更多的问题需要处理(如并发控制,回收磁盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。日志极其有用,我们还将在本书的其它部分重复见到它好几次。

日志(log) 这个词通常指应用日志:即应用程序输出的描述发生事情的文本。本书在更普遍的意义下使用 日志 这一词:一个仅追加的记录序列。它可能压根就不是给人类看的,使用二进制格式,并仅能由其他程序读取。

另一方面,如果这个数据库中有着大量记录,则这个 db_get 函数的性能会非常糟糕。每次你想查找一个键时, db_get 必须从头到尾扫描整个数据库文件来查找键的出现。用算法的语言来说,查找的开销是 O(n) :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。这就不好了。

为了高效查找数据库中特定键的值,我们需要一个数据结构: 索引(index) 。本章将介绍一系列的索引结构,并它们进行对比。索引背后的大致思想是,保存一些额外的元数据作为路标,帮助你找到想要的数据。如果您想在同一份数据中以几种不同的方式进行搜索,那么你也许需要不同的索引,建在数据的不同部分上。

索引是从主数据衍生的 附加(additional) 结构。许多数据库允许添加与删除索引,这不会影响数据的内容,它只影响查询的性能。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。

这是存储系统中一个重要的权衡:精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。因为这个原因,数据库默认并不会索引所有的内容,而需要你(程序员或 DBA)通过对应用查询模式的了解来手动选择索引。你可以选择能为应用带来最大收益,同时又不会引入超出必要开销的索引。

哈希索引

让我们从 键值数据(key-value Data) 的索引开始。这不是您可以索引的唯一数据类型,但键值数据是很常见的。对于更复杂的索引来说,这是一个有用的构建模块。

键值存储与在大多数编程语言中可以找到的 字典(dictionary) 类型非常相似,通常字典都是用 散列映射(hash map) (或 哈希表(hash table) )实现的。哈希映射在许多算法教科书中都有描述【1,2】,所以这里我们不会讨论它的工作细节。既然我们已经有 内存中 数据结构 —— 哈希映射,为什么不使用它来索引在 磁盘上 的数据呢?

假设我们的数据存储只是一个追加写入的文件,就像前面的例子一样。那么最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置,如 图 3-1 所示。当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)。当你想查找一个值时,使用哈希映射来查找数据文件中的偏移量, 寻找(seek) 该位置并读取该值。

图 3-1 以类 CSV 格式存储键值对的日志,并使用内存哈希映射进行索引。

听上去简单,但这是一个可行的方法。现实中,Bitcask 实际上就是这么做的(Riak 中默认的存储引擎)【3】。 Bitcask 提供高性能的读取和写入操作,但所有键必须能放入可用内存中,因为哈希映射完全保留在内存中。这些值可以使用比可用内存更多的空间,因为可以从磁盘上通过一次 seek 加载所需部分,如果数据文件的那部分已经在文件系统缓存中,则读取根本不需要任何磁盘 I/O。

像 Bitcask 这样的存储引擎非常适合每个键的值经常更新的情况。例如,键可能是视频的 URL,值可能是它播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键——每个键有很多的写操作,但是将所有键保存在内存中是可行的。

直到现在,我们只是追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行 压缩(compaction) ,如 图 3-2 所示。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。

图 3-2 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值

而且,由于压缩经常会使得段变得很小(假设在一个段内键被平均重写了好几次),我们也可以在执行压缩的同时将多个段合并在一起,如 图 3-3 所示。段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段的合并和压缩可以在后台线程中完成,在进行时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。

图 3-3 同时执行压缩和分段合并

每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近段的哈希映射;如果键不存在,我们检查第二个最近的段,依此类推。合并过程保持细分的数量,所以查找不需要检查许多哈希映射。 大量的细节进入实践这个简单的想法工作。简而言之,一些真正实施中重要的问题是:

文件格式

​ CSV 不是日志的最佳格式。使用二进制格式更快,更简单,首先以字节为单位对字符串的长度进行编码,然后使用原始字符串(不需要转义)。

删除记录

如果要删除一个键及其关联的值,则必须在数据文件(有时称为逻辑删除)中附加一个特殊的删除记录。当日志段被合并时,逻辑删除告诉合并过程放弃删除键的任何以前的值。

崩溃恢复

如果数据库重新启动,则内存散列映射将丢失。原则上,您可以通过从头到尾读取整个段文件并在每次按键时注意每个键的最近值的偏移量来恢复每个段的哈希映射。但是,如果段文件很大,这可能需要很长时间,这将使服务器重新启动痛苦。 Bitcask 通过存储加速恢复磁盘上每个段的哈希映射的快照,可以更快地加载到内存中。

部分写入记录

数据库可能随时崩溃,包括将记录附加到日志中途。 Bitcask 文件包含校验和,允许检测和忽略日志的这些损坏部分。

并发控制

由于写操作是以严格顺序的顺序附加到日志中的,所以常见的实现选择是只有一个写入器线程。数据文件段是附加的,否则是不可变的,所以它们可以被多个线程同时读取。

乍一看,只有追加日志看起来很浪费:为什么不更新文件,用新值覆盖旧值?但是只能追加设计的原因有几个:

  • 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的 固态硬盘(SSD) 上也是优选的【4】。我们将在第 83 页的 比较 B-树和 LSM-树 中进一步讨论这个问题。
  • 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。例如,您不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。
  • 合并旧段可以避免数据文件随着时间的推移而分散的问题。

但是,哈希表索引也有局限性:

  • 散列表必须能放进内存

    如果你有非常多的键,那真是倒霉。原则上可以在磁盘上保留一个哈希映射,不幸的是磁盘哈希映射很难表现优秀。它需要大量的随机访问 I/O,当它变满时增长是很昂贵的,并且散列冲突需要很多的逻辑【5】。

  • 范围查询效率不高。例如,您无法轻松扫描 kitty00000 和 kitty99999 之间的所有键——您必须在散列映射中单独查找每个键。

在下一节中,我们将看看一个没有这些限制的索引结构。

SSTables 和 LSM 树

图 3-3 中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,日志中稍后的值优先于日志中较早的相同键的值。除此之外,文件中键值对的顺序并不重要。

现在我们可以对段文件的格式做一个简单的改变:我们要求键值对的序列按键排序。乍一看,这个要求似乎打破了我们使用顺序写入的能力,但是我们马上就会明白这一点。

我们把这个格式称为 排序字符串表(Sorted String Table) ,简称 SSTable。我们还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。与使用散列索引的日志段相比,SSTable 有几个很大的优势:

  1. 合并段是简单而高效的,即使文件大于可用内存。这种方法就像归并排序算法中使用的方法一样,如 图 3-4 所示:您开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。

    图 3-4 合并几个 SSTable 段,只保留每个键的最新值

    如果在几个输入段中出现相同的键,该怎么办?请记住,每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值。

  2. 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。以 图 3-5 为例:假设你正在内存中寻找键 handiwork ,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 handbaghandsome 的偏移,而且由于排序特性,你知道 handiwork 必须出现在这两者之间。这意味着您可以跳到 handbag 的偏移位置并从那里扫描,直到您找到 handiwork (或没找到,如果该文件中没有该键)。

    图 3-5 具有内存索引的 SSTable

    您仍然需要一个内存中索引来告诉您一些键的偏移量,但它可能很稀疏:每几千字节的段文件就有一个键就足够了,因为几千字节可以很快被扫描i

  1. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(如 图 3-5 中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少 IO 带宽的使用。
i. 如果所有的键与值都是定长的,你可以使用段文件上的二分查找并完全避免使用内存索引。然而实践中键值通常都是变长的,因此如果没有索引,就很难知道记录的分界点(前一条记录结束,后一条记录开始的地方) ↩

构建和维护 SSTables

到目前为止,但是如何让你的数据首先被按键排序呢?我们的传入写入可以以任何顺序发生。

在磁盘上维护有序结构是可能的(参阅 B 树 ),但在内存保存则要容易得多。有许多可以使用的众所周知的树形数据结构,例如红黑树或 AVL 树【2】。使用这些数据结构,您可以按任何顺序插入键,并按排序顺序读取它们。

现在我们可以使我们的存储引擎工作如下:

  • 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为 内存表(memtable)
  • 内存表 大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的 SSTable 文件成为数据库的最新部分。当 SSTable 被写入磁盘时,写入可以继续到一个新的内存表实例。
  • 为了提供读取请求,首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。
  • 有时会在后台运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。

这个方案效果很好。它只会遇到一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被附加到磁盘上,就像在前一节中一样。该日志不是按排序顺序,但这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当内存表写出到 SSTable 时,相应的日志都可以被丢弃。

用 SSTables 制作 LSM 树

这里描述的算法本质上是 LevelDB 【6】和 RocksDB 【7】中使用的关键值存储引擎库,被设计嵌入到其他应用程序中。除此之外,LevelDB 可以在 Riak 中用作 Bitcask 的替代品。在 Cassandra 和 HBase 中使用了类似的存储引擎【8】,这两种引擎都受到了 Google 的 Bigtable 文档【9】(引入了 SSTable 和 memtable)的启发。

最初这种索引结构是由 Patrick O'Neil 等人描述的。在日志结构合并树(或 LSM 树)【10】的基础上,建立在以前的工作上日志结构的文件系统【11】。基于这种合并和压缩排序文件原理的存储引擎通常被称为 LSM 存储引擎。

Lucene 是 Elasticsearch 和 Solr 使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典【12,13】。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词( 关键词(term) ),值是包含单词(文章列表)的所有文档的 ID 的列表。在 Lucene 中,从术语到发布列表的这种映射保存在 SSTable 类的有序文件中,根据需要在后台合并【14】。

性能优化

与往常一样,大量的细节使得存储引擎在实践中表现良好。例如,当查找数据库中不存在的键时,LSM 树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的 Bloom 过滤器【15】。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。

还有不同的策略来确定 SSTables 如何被压缩和合并的顺序和时间。最常见的选择是大小分层压实。 LevelDB 和 RocksDB 使用平坦压缩(LevelDB 因此得名),HBase 使用大小分层,Cassandra 同时支持【16】。在规模级别的调整中,更新和更小的 SSTables 先后被合并到更老的和更大的 SSTable 中。在水平压实中,关键范围被拆分成更小的 SSTables,而较旧的数据被移动到单独的 水平 ,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。

即使有许多微妙的东西,LSM 树的基本思想 —— 保存一系列在后台合并的 SSTables —— 简单而有效。即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键),并且因为磁盘写入是连续的,所以 LSM 树可以支持非常高的写入吞吐量。

B 树

刚才讨论的日志结构索引正处在逐渐被接受的阶段,但它们并不是最常见的索引类型。使用最广泛的索引结构在 1970 年被引入【17】,不到 10 年后变得 无处不在 【18】,B 树经受了时间的考验。在几乎所有的关系数据库中,它们仍然是标准的索引实现,许多非关系数据库也使用它们。

像 SSTables 一样,B 树保持按键排序的键值对,这允许高效的键值查找和范围查询。但这就是相似之处的结尾:B 树有着非常不同的设计理念。

我们前面看到的日志结构索引将数据库分解为可变大小的段,通常是几兆字节或更大的大小,并且总是按顺序编写段。相比之下,B 树将数据库分解成固定大小的块或页面,传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。

每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如 图 3-6 所示。

图 3-6 使用 B 树索引查找一个键

一个页面会被指定为 B 树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。

图 3-6 的例子中,我们正在寻找关键字 251 ,所以我们知道我们需要遵循边界 200 和 300 之间的页面引用。这将我们带到一个类似的页面,进一步打破了 200 - 300 到子范围。

最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。

在 B 树的一个页面中对子页面的引用的数量称为分支因子。例如,在 图 3-6 中,分支因子是 6 。在实践中,分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。

如果要更新 B 树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区,如 图 3-7 所示ii

ii. 向 B 树中插入一个新的键是相当符合直觉的,但删除一个键(同时保持树平衡)就会牵扯很多其他东西了。 ↩

图 3-7 通过分割页面来生长 B 树

该算法确保树保持平衡:具有 n 个键的 B 树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的 B 树,所以你不需要遵追踪多页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)

让 B 树更可靠

B 树的基本底层写操作是用新数据覆盖磁盘上的页面。假定覆盖不改变页面的位置;即,当页面被覆盖时,对该页面的所有引用保持完整。这与日志结构索引(如 LSM 树)形成鲜明对比,后者只附加到文件(并最终删除过时的文件),但从不修改文件。

您可以考虑将硬盘上的页面覆盖为实际的硬件操作。在磁性硬盘驱动器上,这意味着将磁头移动到正确的位置,等待旋转盘上的正确位置出现,然后用新的数据覆盖适当的扇区。在固态硬盘上,由于 SSD 必须一次擦除和重写相当大的存储芯片块,所以会发生更复杂的事情【19】。

而且,一些操作需要覆盖几个不同的页面。例如,如果因为插入导致页面过度而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。

为了使数据库对崩溃具有韧性,B 树实现通常会带有一个额外的磁盘数据结构: 预写式日志(WAL, write-ahead-log) (也称为 重做日志(redo log) )。这是一个仅追加的文件,每个 B 树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使 B 树恢复到一致的状态【5,20】。

更新页面的一个额外的复杂情况是,如果多个线程要同时访问 B 树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常通过使用 锁存器(latches) (轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰传入的查询,并且不时地将旧的分段原子交换为新的分段。

B 树优化

由于 B 树已经存在了这么久,许多优化已经发展了多年,这并不奇怪。仅举几例:

  • 一些数据库(如 LMDB)使用写时复制方案【21】,而不是覆盖页面并维护 WAL 进行崩溃恢复。修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新的位置。这种方法对于并发控制也很有用,我们将在 快照隔离和可重复读 中看到。
  • 我们可以通过不存储整个键来节省页面空间,但可以缩小它的大小。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此更少的层次
  • 通常,页面可以放置在磁盘上的任何位置;没有什么要求附近的键范围页面附近的磁盘上。如果查询需要按照排序顺序扫描大部分关键字范围,那么每个页面的布局可能会非常不方便,因为每个读取的页面都可能需要磁盘查找。因此,许多 B 树实现尝试布局树,使得叶子页面按顺序出现在磁盘上。但是,随着树的增长,维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在磁盘上彼此靠近。
  • 额外的指针已添加到树中。例如,每个叶子页面可以在左边和右边具有对其兄弟页面的引用,这允许不跳回父页面就能顺序扫描。
  • B 树的变体如分形树【22】借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)。

比较 B 树和 LSM 树

尽管 B 树实现通常比 LSM 树实现更成熟,但 LSM 树由于其性能特点也非常有趣。根据经验,通常 LSM 树的写入速度更快,而 B 树的读取速度更快【23】。 LSM 树上的读取通常比较慢,因为它们必须在压缩的不同阶段检查几个不同的数据结构和 SSTables。

然而,基准通常对工作量的细节不确定和敏感。 您需要测试具有特定工作负载的系统,以便进行有效的比较。 在本节中,我们将简要讨论一些在衡量存储引擎性能时值得考虑的事情。

LSM 树的优点

B 树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面本身(也许再次分页)。即使在该页面中只有几个字节发生了变化,也需要一次编写整个页面的开销。有些存储引擎甚至会覆盖同一个页面两次,以免在电源故障的情况下导致页面部分更新【24,25】。

由于反复压缩和合并 SSTables,日志结构索引也会重写数据。这种影响 —— 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为 写放大(write amplification) 。需要特别关注的是固态硬盘,固态硬盘在磨损之前只能覆写一段时间。

在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。

而且,LSM 树通常能够比 B 树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎配置和工作负载),部分是因为它们顺序地写入紧凑的 SSTable 文件而不是必须覆盖树中的几个页面【26】。这种差异在磁性硬盘驱动器上尤其重要,顺序写入比随机写入快得多。

LSM 树可以被压缩得更好,因此经常比 B 树在磁盘上产生更小的文件。 B 树存储引擎会由于分割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于 LSM 树不是面向页面的,并且定期重写 SSTables 以去除碎片,所以它们具有较低的存储开销,特别是当使用平坦压缩时【27】。

在许多固态硬盘上,固件内部使用日志结构化算法,将随机写入转变为顺序写入底层存储芯片,因此存储引擎写入模式的影响不太明显【19】。但是,较低的写入放大率和减少的碎片对 SSD 仍然有利:更紧凑地表示数据可在可用的 I/O 带宽内提供更多的读取和写入请求。

LSM 树的缺点

日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试逐步执行压缩而不影响并发访问,但是磁盘资源有限,所以很容易发生请求需要等待而磁盘完成昂贵的压缩操作。对吞吐量和平均响应时间的影响通常很小,但是在更高百分比的情况下(参阅 描述性能 ),对日志结构化存储引擎的查询响应时间有时会相当长,而 B 树的行为则相对更具可预测性【28】。

压缩的另一个问题出现在高写入吞吐量:磁盘的有限写入带宽需要在初始写入(记录和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全磁盘带宽进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。

如果写入吞吐量很高,并且压缩没有仔细配置,压缩跟不上写入速率。在这种情况下,磁盘上未合并段的数量不断增加,直到磁盘空间用完,读取速度也会减慢,因为它们需要检查更多段文件。通常情况下,即使压缩无法跟上,基于 SSTable 的存储引擎也不会限制传入写入的速率,所以您需要进行明确的监控来检测这种情况【29,30】。

B 树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。这个方面使得 B 树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B 树索引中,这些锁可以直接连接到树【5】。在 第 7 章 中,我们将更详细地讨论这一点。

B 树在数据库体系结构中是非常根深蒂固的,为许多工作负载提供始终如一的良好性能,所以它们不可能很快就会消失。在新的数据存储中,日志结构化索引变得越来越流行。没有快速和容易的规则来确定哪种类型的存储引擎对你的场景更好,所以值得进行一些经验上的测试

其他索引结构

到目前为止,我们只讨论了关键值索引,它们就像关系模型中的 主键(primary key) 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或 ID)引用该行/文档/顶点,并且索引用于解析这样的引用。

有二级索引也很常见。在关系数据库中,您可以使用 CREATE INDEX 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。例如,在 第 2 章 中的 图 2-1 中,很可能在 user_id 列上有一个二级索引,以便您可以在每个表中找到属于同一用户的所有行。

一个二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。即可能有许多行(文档,顶点)具有相同的键。这可以通过两种方式来解决:或者通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表),或者通过向每个索引添加行标识符来使每个关键字唯一。无论哪种方式,B 树和日志结构索引都可以用作辅助索引。

将值存储在索引中

索引中的关键字是查询搜索的内容,但是该值可以是以下两种情况之一:它可以是所讨论的实际行(文档,顶点),也可以是对存储在别处的行的引用。在后一种情况下,行被存储的地方被称为 堆文件(heap file) ,并且存储的数据没有特定的顺序(它可以是仅附加的,或者可以跟踪被删除的行以便用新数据覆盖它们后来)。堆文件方法很常见,因为它避免了在存在多个二级索引时复制数据:每个索引只引用堆文件中的一个位置,实际的数据保存在一个地方。 在不更改键的情况下更新值时,堆文件方法可以非常高效:只要新值不大于旧值,就可以覆盖该记录。如果新值更大,情况会更复杂,因为它可能需要移到堆中有足够空间的新位置。在这种情况下,要么所有的索引都需要更新,以指向记录的新堆位置,或者在旧堆位置留下一个转发指针【5】。

在某些情况下,从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键总是一个聚簇索引,二级索引用主键(而不是堆文件中的位置)【31】。在 SQL Server 中,可以为每个表指定一个聚簇索引【32】。

聚集索引(clustered index) (在索引中存储所有行数据)和 非聚集索引(nonclustered index) (仅在索引中存储对数据的引用)之间的折衷被称为 包含列的索引(index with included columns)覆盖索引(covering index) ,其存储表的一部分在索引内【33】。这允许通过单独使用索引来回答一些查询(这种情况叫做:索引 覆盖(cover) 了查询)【32】。

与任何类型的数据重复一样,聚簇和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。数据库还需要额外的努力来执行事务保证,因为应用程序不应该因为重复而导致不一致。

多列索引

至今讨论的索引只是将一个键映射到一个值。如果我们需要同时查询一个表中的多个列(或文档中的多个字段),这显然是不够的。

最常见的多列索引被称为 连接索引(concatenated index) ,它通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。这就像一个老式的纸质电话簿,它提供了一个从(姓,名)到电话号码的索引。由于排序顺序,索引可以用来查找所有具有特定姓氏的人,或所有具有特定姓-名组合的人。 然而,如果你想找到所有具有特定名字的人,这个索引是没有用的

多维索引(multi-dimensional index) 是一种查询多个列的更一般的方法,这对于地理空间数据尤为重要。例如,餐厅搜索网站可能有一个数据库,其中包含每个餐厅的经度和纬度。当用户在地图上查看餐馆时,网站需要搜索用户正在查看的矩形地图区域内的所有餐馆。这需要一个二维范围查询,如下所示:

SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079 
                           AND longitude > -0.1162 AND longitude < -0.1004;

一个标准的 B 树或者 LSM 树索引不能够高效地响应这种查询:它可以返回一个纬度范围内的所有餐馆(但经度可能是任意值),或者返回在同一个经度范围内的所有餐馆(但纬度可能是北极和南极之间的任意地方),但不能同时满足。

一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引【34】。更普遍的是,使用特殊化的空间索引,例如 R 树。例如,PostGIS 使用 PostgreSQL 的通用 Gist 工具【35】将地理空间索引实现为 R 树。这里我们没有足够的地方来描述 R 树,但是有大量的文献可供参考。

一个有趣的主意是,多维索引不仅可以用于地理位置。例如,在电子商务网站上可以使用维度(红色,绿色,蓝色)上的三维索引来搜索特定颜色范围内的产品,也可以在天气观测数据库中搜索二维(日期,温度)的指数,以便有效地搜索 2013 年的温度在 25 至 30°C 之间的所有观测资料。使用一维索引,你将不得不扫描 2013 年的所有记录(不管温度如何),然后通过温度进行过滤,反之亦然。 二维索引可以同时通过时间戳和温度来收窄数据集。这个技术被 HyperDex 使用【36】。

全文搜索和模糊索引

到目前为止所讨论的所有索引都假定您有确切的数据,并允许您查询键的确切值或具有排序顺序的键的值范围。他们不允许你做的是搜索类似的键,如拼写错误的单词。这种模糊的查询需要不同的技术。

例如,全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法变体,并且搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他功能取决于文本的语言分析。为了处理文档或查询中的拼写错误,Lucene 能够在一定的编辑距离内搜索文本(编辑距离 1 意味着添加,删除或替换了一个字母)【37】。

正如 在 SSTables 中创建 LSM 树 中所提到的,Lucene 为其词典使用了一个类似于 SSTable 的结构。这个结构需要一个小的内存索引,告诉查询在排序文件中哪个偏移量需要查找关键字。在 LevelDB 中,这个内存中的索引是一些键的稀疏集合,但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于 trie 【38】。这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词【39】。

其他的模糊搜索技术正朝着文档分类和机器学习的方向发展。有关更多详细信息,请参阅信息检索教科书,例如【40】。

在内存中存储一切

本章到目前为止讨论的数据结构都是对磁盘限制的回答。与主内存相比,磁盘处理起来很尴尬。对于磁盘和 SSD,如果要在读取和写入时获得良好性能,则需要仔细地布置磁盘上的数据。但是,我们容忍这种尴尬,因为磁盘有两个显着的优点:它们是耐用的(它们的内容在电源关闭时不会丢失),并且每 GB 的成本比 RAM 低。

随着 RAM 变得更便宜,每 GB 的成本价格被侵蚀了。许多数据集不是那么大,所以将它们全部保存在内存中是非常可行的,可能分布在多个机器上。这导致了内存数据库的发展。

某些内存中的键值存储(如 Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的 RAM),将更改日志写入磁盘,将定时快照写入磁盘或通过复制内存来实现,记忆状态到其他机器。

内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作耐久性附加日志,读取完全由内存提供。写入磁盘也具有操作优势:磁盘上的文件可以很容易地由外部实用程序进行备份,检查和分析。

诸如 VoltDB,MemSQL 和 Oracle TimesTen 等产品是具有关系模型的内存数据库,供应商声称,通过消除与管理磁盘上的数据结构相关的所有开销,他们可以提供巨大的性能改进【41,42】。 RAM Cloud 是一个开源的内存键值存储器,具有持久性(对存储器中的数据以及磁盘上的数据使用日志结构化方法)【43】。 Redis 和 Couchbase 通过异步写入磁盘提供了较弱的持久性。

反直觉的是,内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统缓存最近在内存中使用了磁盘块。相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。【44】。

除了性能,内存数据库的另一个有趣的领域是提供难以用基于磁盘的索引实现的数据模型。例如,Redis 为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。

最近的研究表明,内存数据库体系结构可以扩展到支持比可用内存更大的数据集,而不必重新采用以磁盘为中心的体系结构【45】。所谓的 反缓存(anti-caching) 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到磁盘,并在将来再次访问时将其重新加载到内存中。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以比操作系统更有效地管理内存,因为它可以按单个记录的粒度工作,而不是整个内存页面。尽管如此,这种方法仍然需要索引能完全放入内存中(就像本章开头的 Bitcask 例子)。

如果 非易失性存储器(NVM) 技术得到更广泛的应用,可能还需要进一步改变存储引擎设计【46】。目前这是一个新的研究领域,值得关注。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文