【DDIA笔记】第三章、数据存储与检索

373 阅读12分钟

数据库的核心:数据结构

日志结构的存储引擎

日志结构的特点:

  • 仅追加

索引:基于原始数据派生而来的额外的数据结构

  • 优点

    • 提高查询的效率
  • 缺点

    • 占用额外的空间
    • 降低写入速度

哈希索引

假设数据存储全部采用追加文件组成,难么最简单的所以策略就是:保存内存中的hash map,每个键意义映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。

问题:

  • 如果只追加到一个文件,那么如何避免最终用尽磁盘空间?

    • 分段+压缩+合并。将日志文件分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中。然后可以在这些段上执行压缩。压缩就是丢弃日志中的重复键,并且只保留每个键最近的更新。压缩后的段往往很小,可以将压缩后的段合并在一起。由于段在写入后不会再修改,所以合并的段会被写入到另一个新的文件。
  • 文件格式

    • CSV不是日志的最佳格式。更快更简单的方法是使用二进制格式,<字符出的长度> + <原始字符串>
  • 删除记录

    • 在数据文件中追加一个特殊的删除标记(有时候称为墓碑)。当合并段时,一旦发现墓碑标记,则会丢弃这个已删除键的所有值。
  • 崩溃恢复:如何恢复内存中的hash map?

    • 从头到尾读取整个段文件,然后记录每个键的最新的偏移量,来恢复每个段的hash map。

      • 扫描时间长,使重启变慢
    • 将每个段的hash map快照存储在磁盘上,可以更快地加载到内存中,以此加快恢复速度

  • 部分写入的记录

    • 文件校验
  • 并发控制

    • 为了保证写入以严格的先后顺序追加到日志中,通常的实现选择是只有一个写线程。数据文件是追加的,并且是不可变的,所有它们通常可以被多个线程同时读取。

Q: 为什么要涉及为追加式的,而不是原地修改,用新值覆盖旧值?

  • 追加和分段合并是顺序写,通常比随机写快得多。
  • 如果是追加或不可变的,则并发和崩溃恢复要简单的多。如不必担心重写值时发生崩溃。
  • 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题

哈希索引的局限性:

  • 哈希表必须全部放入内存(原则上可以放到磁盘,但这样需要对磁盘进行随机I/O访问)
  • 区间查询效率不高

SSTables(排序字符串表) 和 LSM-Tree

SSTables: 每个日志结构的存储段都是一组key-value对的序列。这些key-value按照它们的写入顺序排列,并且对于出现在日志中的同一个键,后出现的值优于之前的值。除此之外,文件中key-value对的顺序并不重要。

SSTables 要求每个键在每个合并的段文件中只能出现一次(压缩过程已经确保)。

SSTables相比于哈希表的优点:

  1. 合并段更加高效。方法类似于归并排序算法中使用的方法。
  2. 在文件中查找特定的键时,不再需要在内存中保存所有键的索引。
  3. 将多个记录保存到一个块中并在写之前将其压缩。然后内存中的索引的每个条目指向压缩块的开头。除了节省了磁盘空间,还减少了I/O带宽的占用

如何让数据按键排序?

使用内存中的排序数据结构(如红黑树或AVL树)。

  • 当写入时,将其添加到内存中的平衡数据结构中。这个内存中的树优势被称为内存表
  • 当内存表大于某个阈值时,将其作为SSTables文件写入磁盘。
  • 处理读取请求时,首先尝试在内存表中查找键,然后时最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)。
  • 后台进行周期性地执行合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值

上述方案的问题:如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。

解决方法:在磁盘上保留单独的日志,每个写入都会立即追加到该日志。该日志文件不需要按键排序,它唯一的目的是在崩溃后恢复内存表。每当将内存表写入SSTable时,相应的日志可以被丢弃。

最初这个索引结构以日志结构的合并树(Log-Structed Merge-Tree,或LSM-Tree)命名,它建立在更早期的日志结构文件系统之上。因此,基于合并和压缩排序文件原理的存储引擎通常都被称为LSM存储引擎。

基于页的存储引擎

B-Trees

日志结构索引将数据库分解为可便大小的段,通常大小为几兆字节或更大,并且始终按顺序写入段。而B-tree将数据库分解为固定大小的块或页,传统上大小为4KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。

每个页都可以使用地址或位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过是指向磁盘地址,而不是内存。可以使用这些页面应用来构造一个树状页面。

  • 分支因子:B-tree中一个页包含的子页引用数量。分支因子取决于存储页面引用和范围边界所需的空间总量,通常为几百个。
  • 页分裂:如果页中没有足够的空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围

B-tree可靠性

B-tree底层的基本写操是使用新数据覆盖磁盘上的旧页。它假设覆盖不会改变页的磁盘存储位置,也就是说,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引形成鲜明对比,LSM-tree仅追加更新文件(并最终删除过时的文件),但不会修改文件。

  • 崩溃恢复:常见B-tree需要预写日志(WAL,也称为重做日志)的支持。

    • 预写日志:一个仅追加的文件,每个B-tree的修改必须先更新WAL然后再修改树本身的页。当数据在崩溃后需要恢复时,该日志用于将B-tree恢复到最近一致的状态。
  • 并发控制:原地更新页,需要注意并发控制,否则线程可能看到树处于不一致的状态。通常使用锁存器(Latch,轻量级的锁)保护树的数据结构完整。

优化B-tree

  • 不使用覆盖页和维护WAL来进行崩溃恢复,而是使用写时复制方案。修改的页被写入不同的位置,树种页的新版本被创建,并指向新的位置。这种方法对于并发控制很有帮助。

  • 保存键的缩略信息,而不是完整的键,这样可以节省页空间。特别是在树中间的页中,只需要提供足够的信息来描述键的起止范围。这样可以将更多的键压入到页中,让树具有更高的分支因子,从而减少层数。

    • B+树

其他索引结构

  • 二级索引

    • B-tree和日志结构索引都可以用作二级索引

索引中的键是查询搜索的对象,而值则可以是以下两类之一:

  • 记录行
  • 对其他地方存储的行的引用。存储行的具体位置被称为堆文件,并且它不以特定的顺序存储数据。堆文件方法比较常见,这样当存在多个二级索引时,它可以避免复制数据,即每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。
  • 聚集索引:索引行直接存储在索引中。避免了从索引到堆文件的额外跳转。
  • 非聚集索引:引用逐渐(而不是堆文件)。
  • 覆盖索引:聚集索引和非聚集索引之间的一个折中设计,它在索引中保存一些表的列值,它可以支持只通过索引即可回答某些简单的查询。
  • 多列索引(级联索引):通过将一列追加到另一列,将几个字段组合成一个键(索引的定义指定字段连接的顺序)。
  • 全文搜索和模

在内存中保存所有数据

  • 持有化:通过特殊的硬件,或通过将更改记录写入到磁盘,或者将定期快照写入磁盘,以及复制内存中的状态到其他机器等方式来实现
  • 内存数据库的性能优势并不是因为它们并不需要从磁盘读取。如果有足够的内存,即使是基于磁盘的存储引擎,也可能永远不需要从磁盘读取,因为操作系统将最近使用的磁盘块缓存在内存中。相反,内存数据库更快,是因为它们避免使用写磁盘的格式对内存数据结构编码的开销。
  • 提供了基于磁盘索引难以实现的某些数据模型。

事物处理与分析处理

在线事物处理(online transaction processing, OLTP)

交互式的,根据用户的输入插入或更新记录。

image.png

数据仓库

OLTP对于业务的运行至关重要,所以期望它们高度可用,处理事物延迟足够低。因此放弃使用OLTP系统用于分析目的,而是在单独的数据库上运行分析。这个单独的数据库称为数据仓库。

数据仓库包含公司所有各种OLTP系统的只读副本。将OLTP中的数据导入数据仓库的过程称为提取-转换-加载(Extract-Transform-Load,ELT)。

星型与雪花型分析模式

  • 星型哦是(也称为维度建模):模式的中心是一个事实表。事实表的每一行表示在特定时间发生的事件。通常,事实被捕获为单独的事件,这样之后的分析具有最大的灵活性。不过,这也意味着事实表可能会变得已非常庞大。事实表中的列是属性。其他列可能会引用其他表的外键,称为维度表。维度通常代表事件对象(who)、什么(what)、地点(where)、时间(when)、方法(how)以及原因(why)。
  • 雪花模式(星型模式的变体):维度进一步分为子空间。不同子空间可能会分表存储。

在线分析处理(online analytic processing, OLAP)

列式存储

虽然事实表通常超过100列,但典型的数据仓库查询往往一次只访问其中4或5各。如何高效地执行这个查询?

在OLTP数据库中,存储以面向行的方式布局:来自表的一行的所有值彼此相邻存储。文档数据库也是类似,整个文档通常被存储为一个连续的字节序列。

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

面向列的存储布局依赖一组列文件,每个文件以相同顺序保存数据行。

列压缩

除了仅从磁盘加载查询所需的列之外,还可以通过压缩数据来进一步降低对磁盘吞吐量的要求。不同的列数据模式,可以采用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码。

内存带宽和矢量化处理

面向列的存储布局可以:

  • 减少需要从磁盘加载的数据量
  • 有利于高效利用CPU周期

排序

排序的好处:

  • 有利于范围查询、过滤查询
  • 有助于进一步压缩列

写操作

所有的写入首先进入内存存储区,将其添加到已排序的结构中,接着再准备写入磁盘。内存中的存储是面向行还是面向列无关紧要。当累积了足够多的写入时,它们将于磁盘上的列文件合并,并批量写入新文件。

执行查询时,需要检查磁盘上的列数据和内存中最近的写入,并结合这两者。

聚合

数据仓库查询通常涉及聚合函数。如果许多不同查询使用相同的聚合,每次都处理原始数据将非常浪费。为什么不缓存查询最常使用的一些计数或总和呢?

创建这种缓存的一种方式是物化视图。在关系数据模型中叫做标准(虚拟)视图。

物化视图于虚拟视图的区别:

  • 物化视图是查询结果的实际副本,并被写到磁盘,而虚拟视图只是用于编写查询的快捷方式。

当底层数据发生变化时,物化视图也需要随之更新。这种更新方式会影响数据库的写入性能,这就是为什么OLTP数据库中不经常使用物化视图的原因。而对于大量读密集型的数据仓库,物化视图则更有意义。