DDIA读书笔记 | 第三章:数据存储与检索

文章目录

  • 前言
  • 一、驱动数据库的数据结构
      • 1.0 日志索引结构
          • 索引:
      • 1.1 散列索引
      • 散列表的局限性
      • 1.2 SSTable和LSM树
        • 1.2.1 排序字符串表(Sorted String Table)
        • 与使用散列索引的日志段相比,SSTable优势:
        • 1.2.2 构建和维护SSTable
        • 1.2.3 用SSTable制作LSM树
      • 1.3 B树
      • 1.4 B树与LSM树
        • 1.4.1 LSM树的优点
          • 1.4.1 LSM树的缺点
        • 1.4.2 B树的优点
      • 1.5 其他索引结构
        • 1.5.1 将值存储在索引中
        • 1.5.2 多列索引
        • 1.5.3 全文搜索和模糊索引
        • 1.5.4 内存数据库
  • 二、事务处理还是分析
      • 2.0 主要讨论:
      • 2.1 简介
      • 2.2 星型和雪花型:分析的模式
  • 三、列式存储
      • 3.0 简介:
      • 3.1 列压缩:
        • 3.1.1 内存带宽和矢量化处理:
      • 3.2 列式存储中的排序顺序:
      • 3.3 写入列式存储:
      • 3.4 聚合:数据立方体和物化视图:
  • 总结


前言

本章主要内容:
数据库如何存储我们提供的数据,以及如何在我们需要时重新找到数据。


一、驱动数据库的数据结构

我们从两大类数据库(传统的关系型数据库和很多所谓的“NoSQL”数据库)中使用的存储引擎来开始本章的内容。

两大类存储引擎:

  • 日志结构(log-structured) 的存储引擎
  • 面向页面(page-oriented) 的存储引擎(例如B树)

1.0 日志索引结构

许多数据库在内部使用了日志(log),也就是一个 仅追加(append-only) 的数据文件。
真正的数据库有更多的问题需要处理(如并发控制,回收硬盘空间以避免日志无限增长,处理错误与部分写入的记录),但基本原理是一样的。

索引:

是从主数据衍生的额外的(additional) 结构。索引加快了读查询的速度,但是每个索引都会拖慢写入速度

1.1 散列索引

通常字典都是用散列映射(hash map)(或散列表(hash table))实现的。

  • 当你将新的键值对追加写入文件中时,还要更新散列映射,以反映刚刚写入的数据的偏移量(这同时适用于插入新键与更新现有键)
  • 当你想查找一个值时,使用散列映射来查找数据文件中的**偏移量**,**寻找**(seek) 该位置并读取该值即可
  • 如何避免最终用完硬盘空间?

    1. 分段:将日志分为特定大小的段(segment),当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。
    2. 压缩(compaction),在日志中丢弃重复的键,只保留每个键的最近更新。

    需求内容:

    1. 段被写入后永远不会被修改,所以合并的段被写入一个新的文件。冻结段合并和压缩可以在后台线程中完成,这个过程进行的同时,我们仍然可以继续使用旧的段文件来正常提供读写请求。合并过程完成后,我们将读取请求转换为使用新合并的段而不是旧的段 —— 然后旧的段文件就可以简单地删除掉了。
    2. 每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近的段的散列映射;如果键不存在,我们就检查第二个最近的段,依此类推。合并过程将保持段的数量足够小,所以查找过程不需要检查太多的散列映射。

    实现细节
    1. 文件格式:CSV不是日志的最佳格式。使用二进制格式更快,更简单:首先以字节为单位对字符串的长度进行编码,然后是原始的字符串(不需要转义)。
    2. 删除记录:如果要删除一个键及其关联的值,则必须在数据文件中追加一个特殊的删除记录(逻辑删除)。当日志段被合并时,逻辑删除告诉合并过程丢弃被删除键的任何以前的值。
    3. 崩溃恢复:如果数据库重新启动,则内存散列映射将丢失。你可以通过从头到尾读取整个段文件并记录下来每个键的最近值来恢复每个段的散列映射。段文件很大时比较费时。Bitcask 通过将每个段的散列映射的快照存储在硬盘上(速度较快)
    4. 部分写入记录:数据库随时可能崩溃,包括在将记录追加到日志的过程中。 Bitcask文件包含校验和,允许检测和忽略日志中的这些损坏部分。
    5. 并发控制:由于写操作是以严格的顺序追加到日志中的,所以常见的实现是只有一个写入线程。也因为数据文件段是仅追加的或者说是不可变的,所以它们可以被多个线程同时读取。
    仅追加日志设计的优点

  • 追加和分段合并都是顺序写入,通常比随机写入快得多
  • 如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了
  • 合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题
  • 散列表的局限性

    1. 散列表必须能放进内存(键不能太多)。原则上可以在硬盘上维护一个散列映射,但它需要大量的随机访问I/O,当它用满时想要再增长是很昂贵的,并且散列冲突的处理也需要很烦琐的逻辑。
    2. 范围查询的效率不高,必须在散列映射中单独查找每个键,范围查找较麻烦。

    1.2 SSTable和LSM树

    1.2.1 排序字符串表(Sorted String Table)

    要求:

    1. 键值对的序列按键排序
    2. 要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)

    与使用散列索引的日志段相比,SSTable优势:

    1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。(类似于归并排序)
      首先开始并排读取多个输入文件,查看每个文件中的第一个键,复制最低的键(根据排序顺序)到输出文件,不断重复此步骤,将产生一个新的合并段文件,而且它也是也按键排序的。
      注意:一次这样的操作,肯定会有一个段的KV都是早于本次操作的其他段,可以直接将这个段中的KV保存,然后删除其他段中与此段中相同的KV。

    2. 为了在文件中找到一个特定的键,你不再需要在内存中保存所有键的索引。(依据SSTable有序的特性,你只有知道特定键的大致位置即可,然后再那个区间内查找目标键值)
      但仍然需要一个内存中的索引来告诉你一些键的偏移量,但它可以是稀疏的:每几千字节的段文件有一个键就足够了,因为几千字节可以很快地被扫描完。

    3. 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组为块(block),并在将其写入硬盘之前对其进行压缩 。稀疏内存索引中的每个条目都指向压缩块的开始处。除了节省硬盘空间之外,压缩还可以减少对I/O带宽的使用。(此处有疑问

    1.2.2 构建和维护SSTable

    有序结构的维护:

  • 硬盘上(较复杂)——>B树
  • 内存上(较容易)——>红黑树 / AVL树
  • 存储引擎工作方式:

    1. 将新写入数据 添加到 内存 中的平衡树(例如红黑树)。这个内存树有时被称为内存表(memtable)

    2. 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入硬盘。新的SSTable文件将成为数据库中最新的段。

    3. 收到读取请求时,首先尝试在内存表中找到对应的键,如果没有就在最近的硬盘段中寻找,如果还没有就在下一个较旧的段中继续寻找,以此类推。

    4. 时不时地,在后台运行一个合并和压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉。

    该方式遇到的问题:
    如果数据库崩溃,则最近的写入(在内存表中,但尚未写入硬盘)将丢失。

    解决方案
    可以在硬盘上保存一个单独的日志,每个写入都会立即被追加到这个日志上。它唯一目的是在崩溃后恢复内存表。每当内存表写出到SSTable时,相应的日志即可丢弃。

    1.2.3 用SSTable制作LSM树

    LSM(日志结构合并树),基于合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。

    性能优化

    1. 当查找数据库中不存在的键时,LSM树算法可能会很慢:
      操作者必须先检查内存表,然后查看从最近的到最旧的所有的段(可能还必须从硬盘读取每一个段文件),然后才能确定这个键不存在。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(Bloom filters)

    2. 确定SSTables被压缩和合并的顺序和时间:

    **size-tiered:**较新和较小的SSTables相继被合并到较旧的和较大的SSTable中

    leveled compaction:key范围被拆分到较小的SSTables,而较旧的数据被移动到单独的层级(level),这使得压缩(compaction)能够更加增量地进行,并且使用较少的硬盘空间。
    LSM树的基本思想:保存一系列在后台合并的SSTables

    1.3 B树

    B树保持按键排序的键值对,这允许高效的键值查找和范围查询。

    B树将数据库分解成固定大小的块(block)或页面(page),传统上大小为4KB,并且一次只能读取或写入一个页面。这种设计更接近于底层硬件。

    查找:
    B树从根页面往下逐层查找,最后到达某个包含 单个键 的页面(叶子页面),该页面或者直接包含每个键的值,或者包含了该键对应值的页面的引用。

    分支因子:
    一个页面中对子页面的引用的数量。实践中,分支因子取决于存储页面引用和范围边界所需的空间量,通常是几百个

    更新与插入:

  • 更新:搜索包含该键的叶子页面,更改该页面中的值,并将该页面写回到硬盘(对该页面的任何引用都将保持有效)。
  • 插入:找到其范围能包含新键的页面,并将其添加到该页面。将已经满的页面分成两个半满页面,并更新父页面
  • 删除
    删除的时候会涉及到页面的合并。

    B树的平衡性
    B树的合并与分裂是为了平衡,树的深度是O(log n)。大多数数据库可以放入一个三到四层的B树 [ 分支因子为500的4KB页面的四层树可以存储多达256TB的数据 ]

    B树的可靠性

  • 底层写操作:
  • 用新数据**覆写硬盘上的页面**,并假定覆写不改变页面的位置:即,当页面被覆写时,对该页面的所有引用保持完整。但日志结构索引(如LSM树)**只追加**到文件(并最终删除过时的文件),但从不修改文件中已有的内容。
  • 处理异常崩溃:
  • B树实现通常会带有一个额外的硬盘数据结构:预写式日志(WAL, write-ahead log)(也称为重做日志(redo log))。这是一个仅追加的文件,每个B树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使B树恢复到一致的状态。
  • 多线程同时访问B树:
  • 并发控制保持一致性,通常使用锁存器(latches)(轻量级锁)保护树的数据结构来完成。日志结构化的方法在这方面更简单,因为它们在后台进行所有的合并,而不会干扰新接收到的查询,并且能够时不时地将旧的段原子交换为新的段。

    B树优化

  • 写时复制
  • 有的数据库使用写时复制,而不是覆盖页面并维护WAL来支持 崩溃恢复。这种方法将修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。有利于并发控制!
  • 不存储全部的键
  • 特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级
  • 页面可以放置在硬盘上的任何位置
  • 没有什么要求相邻键范围的页面也放在硬盘上相邻的区域,许多B树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上,但随着树的增长,要维持这个顺序是很困难。但是LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在硬盘上彼此靠近。
  • 额外的指针已被添加到树中
  • 例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。()
  • B树的变体
  • 如分形树(fractal tree)借用一些日志结构的思想来减少硬盘查找(而且它们与分形无关)

    1.4 B树与LSM树

    LSM树写较快,B树读较快

    1.4.1 LSM树的优点

  • LSM树写入速度更快, 读取较慢。
  • 因为它必须检查不同的数据结构和不同压缩层级的SSTables。

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

    由于反复压缩和合并SSTables,日志结构索引也会多次重写数据{ 写放大——在数据库的生命周期中每次写入数据库导致对硬盘的多次写入 }(固态硬盘的闪存寿命在覆写有限次数后就会耗尽)
    但是LSM树通常能够比B树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大。(顺序地写入紧凑的SSTable文件)

  • LSM树压缩的更好
  • B树存储引擎会出现碎片化问题:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。
    但LSM树不是面向页面的,并且会通过定期重写SSTables去除碎片,存储开销较低,特别是当使用分层压缩时。

  • 较低的写入放大率和减少的碎片对固态硬盘更有利:
  • 更紧凑地表示数据允许在可用的I/O带宽内处理更多的读取和写入请求
    1.4.1 LSM树的缺点

    日志结构存储进行压缩引发的缺点:

    ①压缩过程有时会干扰正在进行的读写操作:

    尽管存储引擎尝试增量地执行压缩以尽量不影响并发访问,但是硬盘资源有限,很容易发生某个请求需要等待硬盘先完成昂贵的压缩操作。

    结果就是:
    对吞吐量和平均响应时间的影响通常很小,但是日志结构化存储引擎的响应时间在更高百分位。而B树的行为则相对更具可预测性。

    ② 压缩速率跟不上高写入吞吐量时:

    硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。

    写入吞吐量很高,压缩较慢的情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。
    结果就是:
    通常情况下,即使压缩无法跟上,基于SSTable的存储引擎也不会限制传入写入的速率,所以你需要进行明确的监控来检测这种情况。

    1.4.2 B树的优点

  • 原地更新流派
  • 每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。 这个方面使得B树在想要提供强大的事务语义的数据库中很有吸引力:在许多关系数据库中,**事务隔离**是通过在键范围上使用锁来实现的,在B树索引中,这些**锁可以直接附加到树**上。
  • 1.5 其他索引结构

    键值索引:
    上文讨论的都是键值索引,它们就像关系模型中的主键索引。

    次级索引(secondary indexes):
    特点是键不是唯一的,B树和日志结构索引都可用作次级索引。
    在关系数据库中,你可以使用 CREATE INDEX 命令在同一个表上创建多个次级索引,而且这些索引通常对于有效地执行**联接(join)**而言至关重要。

    1.5.1 将值存储在索引中

    索引中的键是查询要搜索的内容,其值可以是两种情况:1. 实际的行(文档,顶点)2. 行的引用

    行的引用: 称为堆文件(heap file)。并且存储的数据没有特定的顺序(它可以是仅追加的,或者它可以跟踪被删除的行以便后续可以用新的数据进行覆盖)

    行的引用 (堆文件) 优点:
    查找: 避免了在存在多个次级索引时对数据的复制,实际的数据都保存在一个地方。
    更新: 新值的字节数不大于旧值,就可以覆盖该记录,如果新值字节数更大,情况会复杂,因为它需要移到堆中有足够空间的新位置。在这种情况下,要么1. 所有索引都更新,以指向记录的新堆位置,或者在旧堆位置留下一个2. 转发指针

    聚集索引(clustered index)
    从索引到堆文件的额外跳跃对读取来说性能损失太大,所以将被索引的行直接存储在索引中。

    非聚集索引
    仅在索引中存储对数据的引用

    覆盖索引(covering index) / 包含列的索引(index with included columns)
    其在索引内存储表的一部分列。这允许通过单独使用索引来处理一些查询(即:索引 覆盖(cover)了查询)

    结果:
    与任何类型的数据重复一样,聚集索引覆盖索引可以加快读取速度,但是它们需要额外存储空间,并且会增加写入开销

    1.5.2 多列索引

    作用: 同时查询一个表中的多个列(或文档中的多个字段)

  • 连接索引
  • 将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)
  • 多维索引
  • 对于地理位置数据的查询尤其重要。(例如某个地点的经纬度)

    但一个标准的B树或者LSM树索引:只能返回指定经度或者纬度范围内的地址,不能同时满足

    解决方案:

  • 使用空间填充曲线将二维位置转换为单个数字,然后使用常规B树索引
  • 特殊化的空间索引,例如R树。
  • 1.5.3 全文搜索和模糊索引

    模糊的查询需要不同的技术。

    1.5.4 内存数据库

    有些内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的RAM)来实现,也可以将更改日志写入硬盘,还可以将定时快照写入硬盘或者将内存中的状态复制到其他机器上。

    优势:

  • 内存数据库的性能优势并不是因为它们不需要从硬盘读取的事实,它们更快的原因在于省去了将内存数据结构编码为硬盘数据结构的开销。
  • 内存数据库提供了难以用基于硬盘的索引实现的数据模型。
  • 二、事务处理还是分析

    2.0 主要讨论:

    针对事务性负载优化的和针对分析性负载优化存储引擎之间的差异。

    2.1 简介

    事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟的读取和写入 —— 而不是只能定期运行(例如每天一次)的批处理作业。

    1. 在线事务处理(OLTP):
    根据用户的输入插入或更新少量记录,查询结果通常要返回给用户,这些应用程序是交互式的。

    2. 在线分析处理(OLAP, OnLine Analytice Processing)
    由数据分析师对数据库扫描大量记录 的 某几个列进行数据分析,原始数据不返回给用户

    目前情况(解耦,以达到高可用与低延迟):
    基本停止使用OLTP系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为数据仓库(data warehouse)。

    数据仓库:
    将数据批量导入仓库的过程称为“抽取-转换-加载(ETL)

    2.2 星型和雪花型:分析的模式

    许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模)

    雪花模式:其中星型模型的维度被进一步分解为子维度。


    三、列式存储

    讨论一系列针对分析性负载而优化的存储引擎。
    (行多列相对少,但是我们需要操作的列更加的少,但是按照行存储的话我们需要加载指定行的所有列,真正使用的是少数列,会造成冗余,引入列存储)

    3.0 简介:

    在大多数OLTP数据库中,存储都是以面向行的方式进行布局的。面向行的存储引擎需要将所有这些行(每个包含超过100个属性)从硬盘加载到内存中,解析,并过滤掉不符合要求的属性,比较费时。

    注意:
    列式存储布局依赖于每个列文件包含相同顺序的行

    3.1 列压缩:

    数据仓库中常用位图编码实现列压缩。

    现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为1,否则为0。

    3.1.1 内存带宽和矢量化处理:

    扫描大量行的时候,从硬盘获取数据到内存的带宽是一个瓶颈。

    列存储优点:

  • 1. 减少从硬盘加载的数据量
  • 2. 有效利用CPU周期
  • 例如:查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环(即没有函数调用)中遍历。

    按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理

    3.2 列式存储中的排序顺序:

    多级排序,如果第一个排序键相同就按照第二个键排序逐次往后。**第一个排序键的压缩效果最强,**一般来说越往后效果越差(具有相同值的行数越少)

    3.3 写入列式存储:

    列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。
    使用B树的就地更新方法对于压缩的列是不可能的

    这里可以使用LSM树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是Vertica所做的

    3.4 聚合:数据立方体和物化视图:

    列式存储可以显著加快专门的分析查询

    数据仓库查询通常涉及聚合函数,如SQL中的COUNT、SUM、AVG、MIN或MAX。如果相同的聚合被许多不同的查询使用,那么每次都通过原始数据来处理可能太浪费了。
    我们可以创建这种缓存的一种方式是物化视图
    在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。
    不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。

    当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在OLTP数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义。

    数据立方体的缺点是不具有查询原始数据的灵活性。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升手段。

    总结

    1. 存储引擎分为两大类:

  • 针对事务处理(OLTP) 优化的存储引擎
  • OLTP系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈

  • 针对在线分析(OLAP) 优化的存储引擎
  • 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比OLTP系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈列式存储是针对这种工作负载的日益流行的解决方案。

    2. 对于OLTP,有两派主流的存储引擎:

  • 日志结构学派:
  • 只允许追加到文件和删除过时的文件,但不会更新已经写入的文件。Bitcask、SSTables、**LSM树**、LevelDB、Cassandra、HBase、Lucene等都属于这个类别。
  • 就地更新学派:
  • 将硬盘视为一组可以覆写的固定大小的页面。 **B树**是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。

    3.关于OLTP
    还介绍了一些更复杂的索引结构,以及针对所有数据都放在内存里而优化的数据库。

    4. 然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构
    并说明了为什么分析工作负载与OLTP差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。

你可能感兴趣的:(DDIA读书总结,数据库,nosql,java)