(一)密集型系统设计之数据系统基础

223 阅读21分钟

#数据库 #消息队列 #分布式

本文为数据密集型应用系统设计的读后笔记。相信你近年来一定被层出不穷的商业名词所包围:NoSQL、Big Data、Web-scale、Sharding、Eventual consistency、ACID、CAP理论、云服务、MapReduce和Real-time等一系列理论包围,所有这些其实都围绕着如何构建高效存储与数据处理这一核心主题。

一、数据系统基础

数据密集性系统遵守若干基本原则,其中以下三个原则先得尤为重要:

  • 可靠性 容忍硬件,软件或者认为的错误。

  • 可扩展性 评测负载与性能,延迟百分位数,吞吐量等。随着规模的增长,例如数据量、流量或复杂性,系统应以合理的方式来匹配这种增长。

  • 可维护性 可运维,简单与可演化。

这些原则是为了应对数据量,数据的复杂度以及数据的快速多边性而做出的努力。

设计数据系统或数据服务时,一定会碰到很多棘手的问题。例如,当系统内出现了局部失效时,如何确保数据的正确性与完整性? 当发生系统降级 (degrade) 时,该如何为客户提供一致的良好表现? 负载增加时,系统如何扩展? 友好的服务 API 该如何设计?

(一)、数据模型

1. 数据模型分类

  • 关系数据模型

  • 文档数据模型

  • 图数据模型

    在属性图模型中,每个顶点包括:

    • 唯一的标识符。
    • 出边的集合。
    • 人边的集合。
    • 属性的集合(键-值对)

    每个边包括:

    • 唯一的标识符。
    • 边结束的顶点
    • 头部顶点
    • 描述两个顶点间关系类型的标签。
    • 属性的集合(键-值对)
    • 边开始的顶点(尾部顶点

    图查询语言:

    • Cypher 查询语言 Cypher 是一种用于属性图的声明式查询语言,最早为 Neo4j 图形数据库而创建。

    • 利用 SQL 建立关系表查询

    • 三元存储与 SPARQL 在三元存储中,所有信息都以非常简单的三部分形式存储 (主体,谓语,客体)。例如,在三元组 (吉姆,喜欢,香蕉) 中,吉姆是主体,喜欢是谓语 (动词),香蕉是客体。

    • Datalog Datalog 的数据模型类似于三元存储模式,但更为通用一些。它采用 “ 谓语 (主体,客体)” 的表达方式而不是三元组 (主体,谓语,客体)。


文档数据库和图数据库有一个共同点,那就是它们通常不会对存储的数据强加某个模式,这可以使应用程序更容易适应不断变化的需求。但是,应用程序很可能仍然假定数据具有一定的结构,只不过是模式是显式 (写时强制) 还是隐式 (读时处理) 的问题。

2. 数据查询语言

  • 命令行语言 命令行语言类似于程序代码的方式,通过命令行查询数据。

    function getSharks() { var sharks = [];
    for (var i = o; i < animals.length; i++) { 
    	if (animals[i].family === "Sharks") {
    		sharks.push(animals[i]);
    	}}
    	return sharks;
    }
    
  • 声明式语言 声明式语言类似于 SQL。

(二)、数据存储与检索

1. 哈希表索引

假设数据存储全部采用追加式文件组成,那么最简单的索引策略就是: 保存内存中的 hash map, 把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。这就是 Bitcask (Riak 中的默认存储引擎) 所采用的核心做法。 Bitcask 可以提供高性能的读和写,只要所有的 key 可以放入内存 (因为 hash map 需要保存在内存中)。而 value 数据量则可以超过内存大小,只需一次磁盘寻址,就可以将 value 从磁盘加载到内存。如果那部分数据文件已经在文件系统的缓存中,则读取根本不需要任何的磁盘 I/0。

Bitcask 这样的存储引擎非常适合每个键的值频繁更新的场景。

1.1 追加式的设计优点:

  • 追加和分段合并主要是顺序写,它通常比随机写入快得多,特别是在旋转式磁性硬盘上。在 SSD 上也是适合的。

  • 段文件是追加的或不可变的,则并发和崩溃恢复要简单得多。

  • 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题。

1.2 哈希表索引局限性:

  • 哈希表必须存放在内存中,如果存放在磁盘中,需要大量的随机 IO 访问。

  • 区间查询效率不高,例如,不能简单地支持扫描 kittyooooo 和 kitty99999 区间内的所有键,只能采用逐个查找的方式查询每一个键。

2. SSTables (SortStringTables)

2.1 SSTable 优势

SSTables 要求 key-value 对的顺序按键排序,这样相比哈希索引表有以下优点:

  1. 合并段更加高效,可以采取类似归并排序的算法进行段合并。

  2. 查找特定 key 时,无需在内存中保存所有键的索引。 假设正在查找键 handiwork, 且不知道该键在段文件中的确切偏移。但是,如果知道键 handbag 和键 handsome 的偏移显,考虑到根据键排序,则键 handiwork 一定位于它们两者之间。 这样代表着 SSTables 索引可以是稀疏的

  3. 由于读请求往往需要扫描请求范围内的多个 key-value 对,可以考虑将这些记录存到一个块中并在存磁盘前压缩它们。稀疏内存索引每个条目指向每个压缩块的开头。这样除了节省磁盘空间,还减少了 IO 带宽。

2.2 SSTables 写入与维护

将稀疏索引表保存在内存中(当然,磁盘中亦可,如 B-Tree),可以使用红黑树或 AVL 树。使用这些结构可以按任意顺序插入然后读取它们。

  1. 写入时,将其写入到内存的平衡树结构中,这个结构有时称为内存表。

  2. 内存表大于某个阈值(通常几兆字节),将其写入磁盘。写入的数据称为数据库最新的部分,当 SSTbale 写入磁盘时,新的索引写入可以添加到一个新的内存表实例。

  3. 为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标。

  4. 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值。

上述方案可以很好地工作。但它还存在一个问题: 如果数据库崩溃,最近的写入 (在内存表中但尚未写入磁盘) 将会丢失。 为了避免该问题,可以在 磁盘上保留单独的日志,每个写入都立即追加到该日志,就像上一节哈希表索引存储的那样。这个日志文件不需要按键排序,这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当将内存表写入 SSTable 时,相应的日志可以被丢弃。

以上描述的算法本质上正是 LevelDB 和 RocksDB 所使用的,主要用于嵌入到其他应用程序的 key-value 存储引擎库。此外,在 Riak 中 LevelDB 可以用作 Bitcask 的替代品。类似的存储引擎还被用于 Cassandra 和 HBase, 这两个引擎都受到 Google 的 Bigtable 论文 的启发 (它引入了 SSTable 和内存表这两个术语)。

Lucene 是 Elasticsearch 和 Solr 等全文搜索系统所使用的索引引擎,它采用了类似的方法来保存其词典。

因为基于日志合并,所以这个被简称为 Log-Structured Merge-Tree, 或 LSM-Tree

2.3 SSTables 的优化

  1. 优化不存在的 key 查询 查找数据库中某个不存在的键时, LSM-Tree 算法可能很慢: 在确定键不存在之前,必须先检查内存表,然后将段一直回溯访问到最旧的段文件 (可能必须从磁盘多次读取)。为了优化这种访问,存储引擎通常使用额外的布隆过滤器。

  2. 优化内存表的压缩与合并

    1. 大小分级压缩 在大小分级的压缩中,较新的和较小的 SSTables 被连续合并到较旧和较大的 SSTables。

    2. 分层压缩 在分层压缩中,键的范围分裂成多个更小的 SSTables, 旧数据被移动到单独的 "层级”。

3. B-Trees

3.1 B-Tree 概念

B-Tree 像 SSTable 一样, B-Tree 保留按键排序的 key-value 对,这样可以实现高效的 key-value 查找和区间查询。 上面提到的 SSTables 日志结构索引将数据库分解为可变大小的段,通常大小为几兆字节或更大,并且始终按顺序写入段。相比之下, B-Tree 将数据库分解成固定大小的块或页,传统上大小为 4 KB (有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。 每个页面都可以使用地址或位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过是指向磁盘地址,而不是内存。可以使用这些页面引用来构造一个树状页面,如图 3-6 所示。 某一页被指定为 B-Tree 的根; 每当查找索引中的一个键时,总是从这里开始。该页面包含若干个键和对子页的引用。每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。

3.2 B-Tree 的查询

在图 3-6 的例子中,假定正在查找键 251, 因此需要沿着 200300 间的页引用,到达类似的页,它进一步将 200300 范围分解成子范围。最终,我们到达一个包含单个键的页 (叶子页),该页包含每个内联键的值或包含可以找到值的页的引用。

B-Tree 中一个页所包含的子页引用数觉称为分支因子。

3.3 B-Tree 的写入

如果要更新 B-Tree 中现有键的值,首先搜索包含该键的叶子页,更改该页的值,并将页写回到磁盘 (对该页的任何引用仍然有效) 。如果要添加新键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围,如图 3-7 所示。

该算法确保树保持平衡: 具有 n 个键的 B-Tree 总是具有 0 (log n) 的深度。大多数数据库可以适合 3~4 层的 B-Tree, 因此不需要遍历非常深的页面层次即可找到所需的页 (分支因子为 500 的 4KB 页的四级树可以存储高达 256 TB)。

3.4 B-Tree 的优化

3.4.1 使 B-Tree 保持可靠

B-Tree 的实现是采用新数据覆盖磁盘上的旧页,它假设不会改变页的存储位置,当页被覆盖时,对该页的引用保持不变。这与 [[数据密集型系统设计引入#2 SSTables SortStringTables|LSM-Tree]] 形成鲜明对比,LSM-Tree 仅追加与合并日志而不会改写日志。这意味着 B-Tree 需要实现更多的功能以保障数据的准确性:

  1. 物理磁盘数据变更 磁头需要先移动到正确位置,然后旋转盘面,最后用新的数据覆盖相应的扇区。对于 SSD, 由于 SSD 必须一次擦除并重写非常大的存储芯片块,情况会更为复杂。

  2. 部分操作可能涉及多个页 某些操作需要覆盖多个不同的页。例如,如果插入导致页溢出,因而需分裂页,那么需要写两个分裂的页,并且覆盖其父页以更新对两个子页的引用。这是个比较危险的操作,因为如果数据库在完成部分页写入之后发生崩溃,最终会导致索引破坏 (例如,可能有一个孤儿页,没有被任何其他页所指向)。

    为了使数据库能从崩溃中恢复,常见 B-Tree 的实现需要支持磁盘上的额外的数据结构: 预写日志 (write-ahead log, WAL), 也称为重做日志。这是一个仅支持追加修改的文件,每个 B-Tree 的修改必须先更新 WAL 然后再修改树本身的页。当数据库在崩溃后需要恢复时,该日志用于将 B-Tree 恢复到最近一致的状态。

  3. 多个线程写页时,需要进行控制 如果多个线程要同时访问 B-Tree, 则需要注意并发控制,否则线程可能会看到树处于不一致的状态。通常使用锁存器 (轻晕级的锁) 保护树的数据结构来完成。

3.4.2 B-Tree 的优化
  1. 不使用覆盖页和维护 WAL 来进行崩溃恢复 一些数据库 (如 LMDB) 不使用覆盖页和维护 WAL 来进行崩溃恢复,而是使用[[附件资料#^86cdca|写时复制方案]]。修改的页被写入不同的位置,树中父页的新版本被创建,并指向新的位置。这种方法对于并发控制也很有帮助。

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

  3. 使逻辑相邻的页物理磁盘相邻 如果查询需要按照顺序扫描大段的键范围,考虑到每个读取的页都可能需要磁盘 I/0, 所以逐页的布局可能是低效的。许多 B-Tree 的实现都尝试对树进行布局,以便相邻叶子页可以按顺序保存在磁盘上。然而,随着树的增长,维持这个顺序会变得越来越困难。

  4. 添加额外指针 添加额外的指针到树中,如每个叶子页面都可能向左或者向右扫描同级兄弟页,这样就可以顺序扫描而无需回跳到父页。

  5. 减少磁盘寻道 借鉴一些其他日志结构设计如[[附件资料#^9df9ab|分形树]]来减少磁盘寻道。

4. LSM-Tree 与 B-Tree 对比

尽管 B-Tree 的实现比 LSM-Tree 的实现更为成熟,然而由于 LSM-Tree 的性能特点,LSM-Tree 目前很有吸引力。根据经验,LSM-Tree 通常对于写入更快,而 B-Tree 被认为对于读取更快。读取通常在 LSM-Tree 上较慢,因为它们必须在不同的压缩阶段检查多个不同的数据结构和 SSTable。

然而,基准测试通常并不太确定,而且取决于很多工作负载的具体细节。最好测试 特定工作负载,这样方便进行更有效的比较。

4.1 LSM-Tree 的优点

由于 LSM-Tree 基于追加文件的方式进行数据写入,所以相比 B-Tree 有以下优点:

  1. 数据重写次数少 B-Tree 索引必须至少写两次数据: 一次写入预写日志,一次写入树的页本身 (还可能发生页分裂)。即使该页中只有几个字节更改,也必须承受写整个页的开销。

    LSM-Tree 在压缩与合并时会重写数据多次,这种影响 (在数据库内,由于一次数据库写入请求导致的多次磁盘写称为 写放大)。对于 SSD, 由于只能承受[[附件资料#^e79710|有限次地擦除覆盖]],因此尤为关注写放大指标。

    对于大量写密集的应用程序,性能瓶颈很可能在于数据库写入磁盘的速率。在这种情况下,写放大具有直接的性能成本: 存储引擎写入磁盘的次数越多,可用磁盘带宽中每秒可以处理的写入越少。

  2. LSM-Tree 可以承受更大的写入吞吐量 LSM-Tree 通常能够承受比 B-Tree 更高的写入吞吐最,部分是因为它们有时具有较低的写放大 (尽管这取决于存储引擎的配置和工作负载),部分原因是它们以顺序方式写入紧凑的 SSTable 文件,而不必重写树中的多个页。这种差异对于磁盘驱动器尤为重要,原因是磁盘的顺序写比随机写要快得多。

  3. LSM-Tree 可以承受更大的写入吞吐量 LSM-Tree 可以支持更好地压缩,因此通常磁盘上的文件比 B-Tree 小很多。

    由于碎片, B-Tree 存储引擎使某些磁盘空间无法使用: 当页被分裂或当一行的内容不能适合现有页时,页中的某些空间无法使用。由于 LSM-Tree 不是面向页的,并且定期重写 SSTables 以消除碎片化,所以它们具有较低的存储开销,特别是在使用分层压缩时。

在许多 SSD 上,固件内部使用日志结构化算法将随机写入转换为底层存储芯片上的顺序写入,所以存储引擎写入模式的影响不那么明显。然而,更低的写放大和碎片减少对于 SSD 上仍然有益,以更紧凑的方式表示数据,从而在可用的 I/0 带宽中支持更多的读写请求。

4.2 LSM-Tree 的缺点

  1. LSM-Tree 在压缩过程中会干扰正在进行的读写操作 日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。即使存储引擎尝试增量地执行压缩,并且不影响并发访问,但由于磁盘的并发资源有限,所以当磁盘执行昂贵的压缩操作时,很容易发生读写请求等待的情况。这对吞吐量和平均响应时间的影响通常很小,但是如果观察较高的百分位数日志结构化存储引擎的查询响应时间有时会相当高,而B-Tree 的响应延迟则更具确定性

  2. 高写入吞吐量时,磁盘的有限写入带宽需要在初始写入 (记录并刷新内存表到磁盘) 和后台运行的压缩线程之间所共享。

  3. 事务处理相比 B-Tree 更复杂 B-Tree 的优点则是每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本。如果数据库希望提供强大的事务语义,这方面 B-Tree 显得更具有吸引力。

B-Tree 在数据库架构中已经根探蒂固,为许多工作负载提供了一贯良好的性能,所以不太可能在短期内会消失。对于新的数据存储,日志结构索引则越来越受欢迎。不存在快速和简单的规则来确定哪种存储引擎更适合你的用例,因此,实地的测试总是需要的。

5. 其它索引类型

5.1 二级索引

上面讨论了 key-value 索引,它们像关系模型中的主键 (primary key) 索引。主键唯一标识关系表中的一行,或文档数据库中的一个文档,或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键 (或 ID) 来引用该行 I 文档/顶点,该索引用于解析此类引用。

当 key 不唯一( 即可能有许多行,文档,顶点具有相同键)时同样可以用于索引,这个时候索引被称为二级索引。这种情况下若要基于 key-value 索引构建,则可以采取以下两种方式:

  1. 使索引中当每个值成为匹配行标志符的列表,类似于全文索引的 posting list。
  2. 追加一些行标志符来使每个键变得唯一。

无论哪种方式,B-tree 和日志结构索引都可以用作二级索引。

5.2 多列索引

迄今为止讨论的索引只将一个键映射到一个值。如果需要同时查询表的多个列或文档中的多个字段,那么这是不够的。

最常见的多列索引类型称为级联索引,它通过将一列追加到另一列,将几个字段简单地组合成一个键 (索引的定义指定字段连接的顺序)。

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

标准 B-tree 或 LSM-tree 索引无法高效地应对这种查询,它只能提供一个纬度范围内 (但在任何经度) 的所有餐馆,或者所有经度范围内的餐厅 (在北极和南极之间的任何地方),但不能同时满足。

一种选择是使用空格填充曲线将二维位置转换为单个数字,然后使用常规的 B-tree 索引。更常见的是使用专门的空间索引,如 [[附件资料#^372f2c|R树]]。例如, PostGIS 使用 PostgreSQL 的广义搜索树索引实现了地理空间索引作为 R 树。

6. 索引数据的存储方式

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

  • 索引值存储实际的实际行,文档或顶点。

  • 索引值存储对其他地方存储的行的引用。 存储行的具体位置被称为 堆文件,并且它不以特定的顺序存储数据 (它可以是追加的,或者记录删掉的行以便用新数据在之后覆盖它们)。堆文件方法比较常见,这样当存在多个二级索引时,它可以避免复制数据,即每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。

    当更新值而不更改键时,堆文件方法会非常高效: 只要新值的字节数不大于旧值,记录就可以直接覆盖。如果新值较大,则情况会更复杂,它可能需要移动数据以得到一个足够大空间的新位置。在这种情况下,所有索引都需要更新以指向记录的新的堆位四,或者在旧堆位置保留一个间接指针。

某些情况下,从索引到堆文件的额外跳转对于读取来说意味着太多的性能损失,因此可能希望将索引行直接存储在索引中。这被称为聚簇索引。例如,在 MySQL InnoDB 存储引擎中,表的主键始终是聚集索引,二级索引引用主键 (而不是堆文件位置)。

聚集索引 (在索引中直接保存行数据) 和非聚集索引 (仅存储索引中的数据的引用) 之间有一种折中设计称为 覆盖索引 或包含列的索引,它在索引中保存一些表的列值。它可以支持只通过索引即可回答某些简单查询 (在这种情况下,称索引覆盖了查询)。

与任何类型的数据冗余一样,聚集和覆盖索引可以加快读取速度,但是它们需要额外的存储,并且会增加写入的开销。此外,数据库还需要更多的工作来保证事务性,这样应用程序不会因为数据冗余而得到不一致的结果。