DDIA 第一部分-4. 存储与检索

0 阅读21分钟

OLTP 系统的存储与索引

许多数据库内部使用 日志,这是一个仅追加的数据文件。真正的数据库有更多问题要处理(如处理并发写入、回收磁盘空间以防日志无限增长,以及从崩溃中恢复时处理部分写入的记录),但基本原理是相同的。

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

这是存储系统中的一个重要权衡:精心选择的索引加快了读查询速度,但每个索引都会消耗额外的磁盘空间并减慢写入速度,有时会大幅减慢 。因此,数据库通常不会默认为所有内容建立索引,而是要求你 —— 编写应用程序或管理数据库的人 —— 使用你对应用程序典型查询模式的了解来手动选择索引。然后你可以选择为你的应用程序带来最大收益的索引,而不会引入超过必要的写入开销。

日志结构存储

每当你向文件追加新的键值对时,你也会更新哈希映射以反映刚刚写入数据的偏移量。当你想查找一个值时,你使用哈希映射找到日志文件中的偏移量,寻找到该位置,然后读取值。如果数据文件的那部分已经在文件系统缓存中,读取根本不需要任何磁盘 I/O。

SSTable 文件格式

实际上,哈希表很少用于数据库索引,相反,保持数据 按键排序 的结构更为常见。这种结构的一个例子是 排序字符串表(Sorted String Table),简称 SSTable,如所示。这种文件格式也存储键值对,但它确保它们按键排序,每个键在文件中只出现一次。

hammock.png

构建和合并 SSTable

SSTable 文件格式在读取方面比仅追加日志更好,但它使写入更加困难。我们不能简单地追加到末尾,因为那样文件就不再有序了(除非键恰好按升序写入)。如果我们每次在中间某处插入键时都必须重写整个 SSTable,写入将变得太昂贵。

我们可以用 日志结构 方法解决这个问题,这是仅追加日志和排序文件之间的混合:

  1. 当写入操作到来时,将其添加到内存中的有序映射数据结构中,例如红黑树、跳表或字典树。使用这些数据结构,你可以按任意顺序插入键,高效地查找它们,并按排序顺序读回它们。这个内存数据结构称为 内存表(memtable)。
  2. 当内存表变得大于某个阈值(通常是几兆字节)时,将其按排序顺序作为 SSTable 文件写入磁盘。我们将这个新的 SSTable 文件称为数据库的最新 段,它与旧段一起作为单独的文件存储。每个段都有自己内容的单独索引。当新段被写入磁盘时,数据库可以继续写入新的内存表实例,当 SSTable 写入完成时,旧内存表的内存被释放。
  3. 为了读取某个键的值,首先尝试在内存表和最新的磁盘段中找到该键。如果没有找到,就在下一个较旧的段中查找,依此类推,直到找到键或到达最旧的段。如果键没有出现在任何段中,则它不存在于数据库中。
  4. 不时地在后台运行合并和压实过程,以合并段文件并丢弃被覆盖或删除的值。

ddia_0403.png

这里描述的算法本质上就是 RocksDB、Cassandra、Scylla 和 HBase中使用的算法,它们都受到 Google 的 Bigtable 论文的启发(该论文引入了 SSTable 和 memtable 这两个术语)。

该算法最初于 1996 年以 日志结构合并树(Log-Structured Merge-Tree)或 LSM 树(LSM-Tree)的名称发布,建立在早期日志结构文件系统工作的基础上 。因此,基于合并和压实排序文件原理的存储引擎通常被称为 LSM 存储引擎。

布隆过滤器

使用 LSM 存储,读取很久以前更新的键或不存在的键可能会很慢,因为存储引擎需要检查多个段文件。为了加快此类读取,LSM 存储引擎通常在每个段中包含一个 布隆过滤器(Bloom filter),它提供了一种快速但近似的方法来检查特定键是否出现在特定 SSTable 中。

在 LSM 存储引擎的上下文中,假阳性没有问题:

  • 如果布隆过滤器说键 不 存在,我们可以安全地跳过该 SSTable,因为我们可以确定它不包含该键。
  • 如果布隆过滤器说键 存在,我们必须查询稀疏索引并解码键值对块以检查键是否真的在那里。如果是假阳性,我们做了一些不必要的工作,但除此之外没有害处 —— 我们只是继续使用下一个最旧的段进行搜索。

压实策略

分层压实(Size-tiered compaction)

较新和较小的 SSTable 依次合并到较旧和较大的 SSTable 中。包含较旧数据的 SSTable 可能变得非常大,合并它们需要大量的临时磁盘空间。这种策略的优点是它可以处理非常高的写入吞吐量。

分级压实(Leveled compaction)

键范围被分成较小的 SSTable,较旧的数据被移动到单独的"级别"中,这允许压实更增量地进行,并且比分层策略使用更少的磁盘空间。这种策略对于读取比分层压实更有效,因为存储引擎需要读取更少的 SSTable 来检查它们是否包含该键。

嵌入式数据库在移动应用中非常常用,用于存储本地用户的数据。在后端,如果数据足够小以适合单台机器,并且没有太多并发事务,它们可能是一个合适的选择。例如,在多租户系统中,如果每个租户足够小且完全与其他租户分离(即,你不需要运行合并多个租户数据的查询),你可能可以为每个租户使用单独的嵌入式数据库实例。

B 树

B 树的一个页中对子页的引用数称为 分支因子

这个算法确保树保持 平衡:具有 n 个键的 B 树始终具有 O(log n) 的深度。大多数数据库可以适合三或四层深的 B 树,所以你不需要跟随许多页引用来找到你要查找的页。(具有 500 分支因子的 4 KiB 页的四层树可以存储多达 250 TB。)

使 B 树可靠

为了使数据库对崩溃具有弹性,B 树实现通常包括磁盘上的额外数据结构:预写日志(write-ahead log,WAL)。这是一个仅追加文件,每个 B 树修改必须在应用于树本身的页之前写入其中。当数据库在崩溃后恢复时,此日志用于将 B 树恢复到一致状态。在文件系统中,等效机制称为 日志记录(journaling)。

比较 B 树与 LSM 树

读取性能

如果内存表填满,高写入吞吐量可能会导致日志结构存储引擎中的延迟峰值。如果数据无法足够快地写入磁盘,可能是因为压实过程无法跟上传入的写入,就会发生这种情况。许多存储引擎,包括 RocksDB,在这种情况下执行 背压:它们暂停所有读取和写入,直到内存表被写入磁盘

顺序与随机写入

许多小的、分散的写入模式(如 B 树中的)称为 随机写入,而较少的大写入模式(如 LSM 树中的)称为 顺序写入。磁盘通常具有比随机写入更高的顺序写入吞吐量,这意味着日志结构存储引擎通常可以在相同硬件上处理比 B 树更高的写入吞吐量。这种差异在旋转磁盘硬盘(HDD)上特别大;在今天大多数数据库使用的固态硬盘(SSD)上,差异较小,但仍然明显

SSD 对顺序写入的吞吐量也高于随机写入。原因是闪存可以一次读取或写入一页(通常为 4 KiB),但只能一次擦除一个块(通常为 512 KiB)。块中的某些页可能包含有效数据,而其他页可能包含不再需要的数据。在擦除块之前,控制器必须首先将包含有效数据的页移动到其他块中;这个过程称为 垃圾回收(GC)

写放大

如果你获取在某个工作负载中写入磁盘的总字节数,然后除以如果你只是写入没有索引的仅追加日志需要写入的字节数,你就得到了 写放大。(有时写放大是根据 I/O 操作而不是字节来定义的。)在写入密集型应用程序中,瓶颈可能是数据库可以写入磁盘的速率。在这种情况下,写放大越高,它在可用磁盘带宽内可以处理的每秒写入次数就越少。

磁盘空间使用

B 树可能会随着时间的推移变得 碎片化:例如,如果删除了大量键,数据库文件可能包含许多 B 树不再使用的页。对 B 树的后续添加可以使用这些空闲页,但它们不能轻易地返回给操作系统,因为它们在文件的中间,所以它们仍然占用文件系统上的空间。因此,数据库需要一个后台过程来移动页以更好地放置它们,例如 PostgreSQL 中的真空过程

多列索引与二级索引

二级索引可以很容易地从键值索引构建。主要区别在于,在二级索引中,索引值不一定是唯一的;也就是说,同一索引条目下可能有许多行(文档、顶点)。这可以通过两种方式解决:要么使索引中的每个值成为匹配行标识符的列表(如全文索引中的倒排列表),要么通过向其追加行标识符使每个条目唯一。具有就地更新的存储引擎(如 B 树)和日志结构存储都可用于实现索引。

在索引中存储值

索引中的键是查询搜索的内容,但值可以是几种东西之一:

  • 如果实际数据(行、文档、顶点)直接存储在索引结构中,则称为 聚簇索引。例如,在 MySQL 的 InnoDB 存储引擎中,表的主键始终是聚簇索引,在 SQL Server 中,你可以为每个表指定一个聚簇索引。
  • 或者,值可以是对实际数据的引用:要么是相关行的主键(InnoDB 对二级索引这样做),要么是对磁盘上位置的直接引用。在后一种情况下,存储行的地方称为 堆文件,它以无特定顺序存储数据(它可能是仅追加的,或者它可能跟踪已删除的行以便稍后用新数据覆盖它们)。例如,Postgres 使用堆文件方法。
  • 两者之间的折中是 覆盖索引 或 包含列的索引,它在索引中存储表的 某些 列,除了在堆上或主键聚簇索引中存储完整行。这允许仅使用索引来回答某些查询,而无需解析主键或查看堆文件(在这种情况下,索引被称为 覆盖 查询)。这可以使某些查询更快,但数据的重复意味着索引使用更多的磁盘空间并减慢写入速度。

全内存存储

内存数据库的性能优势不是因为它们不需要从磁盘读取。即使是基于磁盘的存储引擎,如果你有足够的内存,也可能永远不需要从磁盘读取,因为操作系统无论如何都会在内存中缓存最近使用的磁盘块。相反,它们可以更快,因为它们可以避免将内存数据结构编码为可以写入磁盘的形式的开销。

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

分析型数据存储

云数据仓库

与传统数据仓库不同,云数据仓库利用可扩展的云基础设施,如对象存储和无服务器计算平台。这些仓库也更具弹性,因为它们将查询计算与存储层解耦。数据持久存储在对象存储而不是本地磁盘上,这使得可以独立调整存储容量和查询的计算资源

随着分析数据存储转移到对象存储上的数据湖,开源仓库也开始解耦拆分。以下组件以前集成在单个系统(如 Apache Hive)中,现在通常作为单独的组件实现:

查询引擎

Trino、Apache DataFusion 和 Presto 等查询引擎解析 SQL 查询,将其优化为执行计划,并在数据上执行这些计划。执行通常需要并行、分布式的数据处理任务。一些查询引擎提供内置任务执行,而有些则选择使用第三方执行框架,如 Apache Spark 或 Apache Flink。

存储格式

存储格式确定表的行如何编码为文件中的字节,然后通常存储在对象存储或分布式文件系统中。然后查询引擎可以访问这些数据,但使用数据湖的其他应用程序也可以访问。此类存储格式的示例包括 Parquet、ORC、Lance 或 Nimble,我们将在下一节中看到更多关于它们的内容。

表格式

以 Apache Parquet 和类似存储格式编写的文件一旦写入通常就是不可变的。为了支持行插入和删除,通常会使用 Apache Iceberg 或 Databricks Delta 等表格式。表格式规定了哪些文件构成一张表,以及表模式的定义格式。此类格式还提供高级功能,例如时间旅行(查询表在过去某个时间点状态的能力)、垃圾回收,甚至事务。

数据目录

就像表格式定义哪些文件构成表一样,数据目录定义哪些表组成数据库。目录用于创建、重命名和删除表。与存储和表格式不同,Snowflake 的 Polaris 和 Databricks 的 Unity Catalog 等数据目录通常作为可以使用 REST 接口查询的独立服务运行。Apache Iceberg 也提供目录,可以在客户端内运行或作为单独的进程运行。查询引擎在读取和写入表时使用目录信息。传统上,目录和查询引擎已经集成,但将它们解耦使数据发现和数据治理系统也能够访问目录的元数据。

列式存储

面向列(或 列式)存储背后的想法很简单:不要将一行中的所有值存储在一起,而是将每 列 中的所有值存储在一起。如果每列单独存储,查询只需要读取和解析该查询中使用的那些列,这可以节省大量工作。

列压缩

除了只从磁盘加载查询所需的那些列之外,我们还可以通过压缩数据进一步减少对磁盘吞吐量和网络带宽的需求。幸运的是,面向列的存储通常非常适合压缩。

它们看起来经常重复,这是压缩的良好迹象。根据列中的数据,可以使用不同的压缩技术。在数据仓库中特别有效的一种技术是 位图编码

hammock.png

一种选择是使用每行一位来存储这些位图。然而,这些位图通常包含大量零(我们说它们是 稀疏 的)。在这种情况下,位图可以另外进行游程编码:计算连续零或一的数量并存储该数字,如底部所示。诸如 咆哮位图(roaring bitmaps)之类的技术在两种位图表示之间切换,使用最紧凑的表示。这可以使列的编码非常高效。

列存储中的排序顺序

数据需要一次排序整行,即使它是按列存储的。数据库管理员可以使用他们对常见查询的了解来选择表应按哪些列排序。例如,如果查询经常针对日期范围(例如上个月),则将 date_key 作为第一个排序键可能是有意义的。然后查询可以只扫描上个月的行,这将比扫描所有行快得多。

排序顺序的另一个优点是它可以帮助压缩列。如果主排序列没有许多不同的值,那么排序后,它将有很长的序列,其中相同的值在一行中重复多次。简单的游程编码,就像我们在中用于位图的那样,可以将该列压缩到几千字节 —— 即使表有数十亿行。

写入列式存储

通常使用日志结构方法以批次执行写入。所有写入首先进入面向行的、排序的内存存储。当积累了足够的写入时,它们将与磁盘上的列编码文件合并,并批量写入新文件。由于旧文件保持不可变,新文件一次写入,对象存储非常适合存储这些文件。

查询执行:编译与向量化

查询编译

查询引擎获取 SQL 查询并生成用于执行它的代码。代码逐行迭代,查看感兴趣列中的值,执行所需的任何比较或计算,如果满足所需条件,则将必要的值复制到输出缓冲区。查询引擎将生成的代码编译为机器代码(通常使用现有编译器,如 LLVM),然后在已加载到内存中的列编码数据上运行它。这种代码生成方法类似于 Java 虚拟机(JVM)和类似运行时中使用的即时(JIT)编译方法。

向量化处理

查询被解释,而不是编译,但通过批量处理列中的许多值而不是逐行迭代来提高速度。一组固定的预定义算子内置在数据库中;我们可以向它们传递参数并获得一批结果

物化视图与数据立方体

区别在于物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是编写查询的快捷方式。当你从虚拟视图读取时,SQL 引擎会即时将其扩展为视图的基础查询,然后处理扩展的查询。

物化聚合 是一种可以在数据仓库中有用的物化视图类型。如前所述,数据仓库查询通常涉及聚合函数,例如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果许多不同的查询使用相同的聚合,每次都处理原始数据可能会很浪费。为什么不缓存查询最常使用的一些计数或总和?数据立方体(OLAP 立方体)通过创建按不同维度分组的聚合网格来做到这一点

物化数据立方体的优点是某些查询会变得非常快,因为结果已经被预先计算好了。例如,如果你想知道昨天每个商店的总销售额,你只需要查看相应维度上的汇总值 —— 不需要扫描数百万行。

缺点是数据立方体不像直接查询原始数据那样灵活。例如,没有办法计算售价超过 100 美元的商品销售占比,因为价格并不是其中一个维度。因此,大多数数据仓库都会尽可能保留原始数据,只把这类聚合(如数据立方体)当作特定查询的性能加速手段

140102.png

多维索引与全文索引

多维索引 允许你一次查询多个列。在地理空间数据中这尤其重要。一种选择是使用空间填充曲线将二维位置转换为单个数字,然后使用常规 B 树索引。更常见的是,使用专门的空间索引,如 R 树或 Bkd 树;它们划分空间,使附近的数据点倾向于分组在同一子树中。

全文检索

你可以将全文检索视为另一种多维查询:在这种情况下,可能出现在文本中的每个单词(词项)是一个维度。包含词项 x 的文档在维度 x 中的值为 1,不包含 x 的文档的值为 0。搜索提到“红苹果”的文档意味着查询在 红 维度中查找 1,同时在 苹果 维度中查找 1。维度数量可能因此非常大。

许多搜索引擎用来回答此类查询的数据结构称为 倒排索引。这是一个键值结构,其中键是词项,值是包含该词项的所有文档的 ID 列表(倒排列表)。如果文档 ID 是顺序数字,倒排列表也可以表示为稀疏位图

向量嵌入

为了理解文档的语义 —— 它的含义 —— 语义搜索索引使用嵌入模型将文档转换为浮点值向量,称为 向量嵌入。向量表示多维空间中的一个点,每个浮点值表示文档沿着一个维度轴的位置。嵌入模型生成的向量嵌入在(这个多维空间中)彼此接近,当嵌入的输入文档在语义上相似时。

使用专门的向量索引,例如:

平面索引(Flat indexes)

向量按原样存储在索引中。查询必须读取每个向量并测量其与查询向量的距离。平面索引是准确的,但测量查询与每个向量之间的距离很慢。

倒排文件(IVF)索引

向量空间被聚类为向量的分区(称为 质心),以减少必须比较的向量数量。IVF 索引比平面索引更快,但只能给出近似结果:即使查询和文档彼此接近,它们也可能落入不同的分区。对 IVF 索引的查询首先定义 探针,这只是要检查的分区数。使用更多探针的查询将更准确,但会更慢,因为必须比较更多向量。

分层可导航小世界(HNSW)

HNSW 索引维护向量空间的多个层,如所示。每一层都表示为一个图,其中节点表示向量,边表示与附近向量的接近度。查询首先在最顶层定位最近的向量,该层具有少量节点。然后查询移动到下面一层的同一节点,并跟随该层中的边,该层连接更密集,寻找更接近查询向量的向量。该过程继续直到到达最后一层。与 IVF 索引一样,HNSW 索引是近似的。

start traversal.png

ddia_0408.png