DDIA | 数据存储与检索

240 阅读10分钟

引言

对《数据密集型应用系统设计》第三章数据存储与检索的摘要和补充。

从最基本的层面看,数据库只需要完成保存数据和返回数据两个功能即可。作为应用系统开发人员,不可能从头开发自己的存储引擎,如何从众多现有的存储引擎中选择一个适合自己应用的存储引擎并为了特定的工作负载而对数据库调优时,需要了解存储引擎的底层机制。

数据库核心:数据结构

追加日志和哈希索引

在一个文本文件中每行包含一个键值对,中间用逗号分隔,每次写入时追加新内容到文件末尾,就完成了一个最简单的数据库。

这种文件在无并发和数据量小的情况下表现还可以,但是当文件中保存大量数据后,每次想查找一个键,必须全表扫描来确定键出现的位置,时间复杂度为O(n)。

为了高效地查找数据库中特定键的值,需要新的数据结构:索引。索引是基于原始数据派生而来的额外数据结构,可以单独添加和删除索引而不影响数据库的内容。但维护额外的结构势必会引入额外开销,在每次写入时都需要更新索引,因此任何索引都会降低写的速度。

针对追加日志式文件构成的数据库,最简单的索引策略就是使用保存在内存中的哈希表,将每个键一一映射到数据文件中特定的字节偏移量。在文件中追加新的键值对时,还要更新哈希表来反映刚刚写入数据的偏移量。这种简单的做法是Bitcask所采用的核心做法。只要所有的键都可以放入内存,Bitcask就可以提供高性能的读和写。而值可以超过内存大小,只需要一次磁盘寻址就可以将值从磁盘加载到内存。

在实际中使这个简单的想法行之有效还需要考虑更多细节:

  • 文件格式:使用二进制是更快更简单的方法,首先以字节为单位记录长度,之后跟上原始字符串(不会对\进行转义)
  • 删除记录:如果要删除键和它关联的值,需要在数据文件中追加一个特殊的删除记录。合并日志段时发现标记则会丢弃这个值
  • 崩溃恢复:如果重新启动会丢失在内存中的哈希索引,可以将每个段的哈希表快照存储在磁盘,加快恢复速度
  • 部分写入的记录:在将记录追加到日志过程中有可能崩溃,需要包括校验值发现损坏部分并丢弃
  • 并发控制:由于写入需要以严格的先后顺序追加到日志,通常选择只有一个写线程而有多个读线程

哈希索引也有其局限性:

  • 哈希表必须全部加载进内存,当哈希变满时持续增长代价昂贵且哈希冲突需要复杂的处理逻辑
  • 区间查询效率不高,只能逐一查找每一个键

SSTables和LSM-Tree

SSTable(Sorted Strings Table)是一种存储结构,可以用于存储持久化的键值对数据。

新数据被写入系统时会被存储在内存中的数据结构(如MemTable)中。当这个内存表达到一定的阈值时,它会被冻结并转换为一个不可变的SSTable文件,这个文件被称为“输入段文件”。多个旧的输入段文件可以通过合并排序的方式整合成一个更大的SSTable文件,这个新文件就是输出段文件。这种文件通常是持久化存储的,用于长期保留数据。

可以使用很多有序的树状数据结构(如红黑树、AVL树)在内存中排序,可以按任意顺序插入键并以排序后的顺序读取。存储引擎的基本工作流程如下:

  1. 在写入时将其添加到内存的平衡树数据结构中,这个内存中的树也被称为内存表
  2. 当这个内存表达到一定的阈值时,将其作为SSTable文件写入磁盘,这个文件被称为输入段文件
  3. 处理读请求时,首先在内存表中查找,然后是最新的磁盘段文件,依次类推
  4. 后台进程周期性地执行段合并和压缩过程,整合后的文件被称为输出段文件

image.png

这种格式相比哈希索引的日志段,具有以下优点:

  • 合并高效
    在合并多个SSTable时,由于它们都是排序的,可以并发读取多个输入段文件,把最小的键拷贝到输出文件。
  • 索引稀疏
    由于数据是排序的,可以使用二分查找快速定位键。不需要在内存中保存所有键的索引,只需要一个稀疏的内存索引记录某些键的偏移。
  • 节省磁盘空间和IO带宽
    由于读请求往往需要扫描请求范围内的多个键值对,可以将多条记录保存在一个块中并在写磁盘前压缩。然后稀疏内存索引的每个条目指向压缩块的开头。

最初这个索引结构以Log-Structured Merge-Tree(LSM-Tree)命名,因此基于合并和压缩排序文件原理的存储引擎都被称为LSM存储引擎。

有很多现代数据库系统采用了基于 LSM-Tree 的引擎以提高性能和可扩展性:

  1. Apache Cassandra
    这是一个高度可扩展的分布式数据库,支持高性能、高可用性和容错能力。Cassandra 使用自己的 LSM-Tree 实现来存储数据,适合于需要处理大量数据的分布式环境。
  2. RocksDB
    虽然 RocksDB 本身是一个嵌入式键值存储库,但它广泛用作其他系统的底层存储引擎。RocksDB 是 Facebook 基于 Google 的 LevelDB 开发的,优化了性能和资源使用。
  3. HBase
    Apache HBase 是一个开源的非关系型分布式数据库(NoSQL),它是 Google Bigtable 的开源实现。HBase 使用 HFiles(存储在 HDFS 上的文件),这些文件的写入和存储管理是基于 LSM-Tree 设计的。
  4. LevelDB
    Google 开发的一个轻量级、单机版的键值存储库,使用 LSM-Tree 作为其数据存储机制。它提供了快速的写入性能,适用于各种类型的应用程序。

Elasticsearch和Solr等全文搜索系统使用了名为Lucene的引擎,它采用了类似的方法保存字典。全文索引比哈希索引复杂得多,但基于类似的想法:给定搜索查询中的某个单词,找到提及该单词的所有文档。

为了使存储引擎在实际中表现得更好,例如在查找数据库中某个不存在的键时,可以使用额外的布隆过滤器,近似计算集合的内容,节省对于不存在键的磁盘读取。

B-trees

B-tree索引是关系型数据库中的标准索引实现,是最广泛使用的索引实现。

B-tree将数据库分解成固定大小的块或页,传统上大小为4KB,页是读写的最小单元。B-tree的节点通常被设计成与系统的磁盘块(页)大小相匹配,每次磁盘I/O操作能够加载完整的一个节点,从而减少了磁盘IO的次数。

每个页面都可以使用地址作为标识,让一个页面引用另一个页面,类似指针但指向的不是内存而是磁盘地址。可以使用这些页面引用构造一个树状页面:某一页被指定为B树的根,每个子节点都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。

image.png

如果要更新B-tree的中现有键的值,首先搜索包含该键的叶子页,更改该页的值并将页写回到磁盘。如果要添加新键,需要找到范围包含新键的页,并将其添加到该页。如果页中没有足够的空间则会分裂成两个半满的页,同时更新父页的键的范围。

image.png

该算法可以保证树的平衡,具有n个键的B树总是具有O(logn)的深度,分支因子为500的4KB页的四级树可以存储256TB。

B-tree底层的基本写操作使用新数据覆盖磁盘上的旧页,覆盖不会改变磁盘存储位置,对该页所有的引用不变。为了能使数据库从崩溃中恢复,常见B树的实现需要支持预写日志(write-ahead log,WAL),每个对B-tree的修改必须先更新WAL然后再修改树本身的页。

PostgreSQL即使用WAL记录所有更改数据库状态的操作,MySQL的InnoDB引擎中,页使用了名为Redo Log的日志实现同样的功能。

B-tree索引必须写两次数据:一次写入预写日志,一次写入页本身。即使页中只有几个字节更改,也必须承受写整个页的开销。在数据库中一次数据库写入请求导致多次磁盘写被称为写放大。与LSM-Tree相比,B-tree拥有更高的写放大。因为LSM-Tree可以以顺序写的方式写入,所以LSM-Tree可以承受比B-tree更高的写入吞吐量。

B-tree的每个键都恰好对应于索引中的某个位置,可以通过键范围上的锁实现事务隔离。

MySQL中,使用行锁实现读已提交,使用Next-Key锁实现可重复读。

其他索引结构

在索引中存储值

索引中的键是查询搜索的对象,而值可以是实际的行数据,也可以是对其他地方存储的行的引用。存储具体行数据的文件被称为堆文件。

从索引到堆文件的额外跳转被称作回表,如果希望减少这种性能损失,可以将行数据直接存储在索引中,这被称作聚簇索引。在MySQL的InnoDB存储引擎中,表的主键始终是聚簇索引,二级索引引用主键。同时也可以在索引中包含一些表的列值,避免回表而直接查询,这被称作覆盖索引。

与任何类型的数据冗余一样,聚簇索引和覆盖索引可以加快读取速度,但是需要额外的存储和写入开销,数据库还需要更多的工作保证事务性。

多列索引

之前的索引只将一个键映射到一个值,需要查询表的多个列时可以使用多列索引。

最常见的多列索引被称作级联索引,通过将一列追加到另一列,将几个字段组合成一个键

全文搜索和模糊索引

全文搜索引擎支持对一个单词的所有同义词进行查询并忽略单词语法的变体和语法错误(如在某个编辑距离内)

在内存中保存所有内容

随着内存的成本降低,可以将数据集全部保存在内存中。