数据存储与检索

如果你把东西整理得井井有条,下次就不用再找了。

        ——德国谚语

从最基本的层面看,数据库只需要做两件事情:向它插入数据时,它就保存数据;之后查询时,它应该返回那些数据

数据库核心:数据结构

许多数据库都使用日志(log),日志是一个仅支持追加式更新的数据文件;实现原理大致为:每行包含一个key-value对,用逗号分割,每次调用插入时,追加新内容到文件末尾,因此多次更新某个键时,旧版本的值不会被覆盖,而是需要查看文件中最后一次出新的键来查找最新的值

索引是基于原始数据派生而来的额外数据结构。很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外结构势必会引入开销,特别是在心数据写入时,。对于写入,它很难超过简单地最佳文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据,需要更新索引,因此任何类型的索引通常都会降低写的速度

哈希索引

key-value类型并不是唯一可以索引的数据,但随处可见,而且是其他更复杂索引的基础构造模块

那么最简单的索引策略是:保存内存中的hash map,将每个键一一映射到数据文件中的特定字节偏移量,这样就可以找到每个值的位置;每当在文件中追加新的数据时,还要更新hash map来反应刚刚写入数据的偏移量(包括插入和更新)。当查找某个值时,使用hash map来找到文件中的偏移量,即存储位置,然后读取

把数据追加到文件中,怎么避免用尽磁盘空间?可以将日志分解为一定大小的段,当文件达到一定大小时就关闭它,并将后续写入新的段文件总,然后在这些段中执行压缩。压缩意味着在日志总丢弃重复的键,只保留每个键最近的更新;此外压缩往往使得段更小,也可以在压缩的同时将多个段合并在一起

实现过程中需要考虑的问题

  • 文件格式:CSV不是日志的最佳格式,更快更简单的方法是使用二进制格式,首先以字节单位来记录字符串长度,之后跟上原始字符串(不需要转义)
  • 删除记录:如果需要删除键和它关联的值,则必须在数据文件中追加一个特殊的删除记录(有时候称为墓碑),当合并日志段时,一旦发小墓碑标记,则会丢弃这个已删除键的所有值
  • 崩溃恢复:如果数据库重新启动,则会丢失内存中的hash map。原则上,可以通过从头读取整个段文件,然后记录每个键的最新值得偏移量;但是如果段文件很大,这将使服务器重启变慢。Bitcask通过将每个段的hash map的快照存储在磁盘上,可以更快地加载到内存中
  • 部分写入记录:数据库随时可能崩溃,包括将记录追加到日志的过程中。Bitcask文件包括校验值,这样可以发现损坏的部分,并丢失
  • 并发控制:只有一个写线程,允许多个读线程

为什么不原地更新文件:

  • 追加和分段合并主要是顺序写,它通常比随机写入快得多,特别是在旋转式磁盘中。在某种程度上顺序写入在基于内存的固态硬盘中也是适合的
  • 如果段文件是追加或者不可变得,则并发和崩溃恢复要简单的多
  • 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题

哈希表索引的局限性:

  • 哈希表必须全部放入内存,原则上,可以在磁盘上维护hash map,但很难使磁盘上的hash map表现良好,它需要大量的磁盘I/O,当哈希变满时,继续增长代价昂贵,并且哈希冲突时需要复杂的处理逻辑
  • 区间查询效率不高。例如:不能简单的支持扫描kitty00000和kitty99999区间内的所有键,只能采用逐个查找的方式,查询每一个键

SSTables和LSM-Tree

SSTable:与哈希索引相比,要求key-value对的顺序按键排序,要求每个键在每个合并的段文件中只能出现一次(在压缩的过程中已经确保了)

SSTable相较于哈希索引的优点

  1. 合并段更加简单高效,即使文件大于内存。方法类似于合并排序算法中使用的方法:比较每个文件的第一个键,把最小的键(按照排序顺序)拷贝到输出文件,并重复这个过程,这会产生一个新的按键排序的合并段文件。如果相同的键出现在多个输入段怎么办?记住!每个段包含在某段时间内写入数据库的所有值。这意味着一个段文件中的所有值肯定比其他段中的所有值更新。当多个段包含相同的键时,可以保留最新段的值,并丢弃旧段的值
  2. 在文件中查找特定的键时,不需要再内存中保存所有键的索引;因为键是有序的,可以根据排序规则确定键的大致位置,然后扫描查询;所以仍然需要一个内存索引来记录某些键的偏移,但它可以是稀疏的,由于可以很快的扫描几千字节,对于段文件中的几千字节,只需要一个键就足够了
  3. 由于读请求往往需要扫描请求范围内多个key-value对,可以考录将这些记录保存到一个块中,并在写磁盘之前将其压缩,然后稀疏内存索引的每个条目指向压缩块的开头。除了节省磁盘空间,压缩还减少了I/O带宽的占用

构建和维护SStables

如何让数据按键排序?

在磁盘上维护排序结构式可行的(B-trees),不过将其保存在内存中更容易。内存排序有很多广为认知的树状数据结构,例如红黑树和AVL树。使用这些数据结构,可以按人一顺序插入键并以排序后的顺序读取

存储引擎的基本工作流程如下:

  • 当写入时,将其添加到内存中的平衡树数据结构中(如红黑树)。这个内存中的树有时称为内存表
  • 当内存表大于某个阀值(几M)时,将其作为SSTable文件写入磁盘。由于树已经维护了按键排序的key-value对,写磁盘可以比较高效。新的SSTable文件称为数据库的最新部分。当SSTable写磁盘的同时,写入可以继续添加到另一个内存表实例
  • 为了处理读请求,首先尝试在内存表总查找键,然后死最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或者空)
  • 后台进程周期性执行合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或者删除的值

当数据库崩溃时,最近写入(在内存表中,未写入磁盘)将会丢失。为避免该问题,可以在磁盘中保留单独的日志,每个写入都会立即追加到该日志,该日志文件不需要按键排序,每当内存表写入SSTable时,相应的日志可以被丢弃

从SSTables到LSM-Tree

以上算法本质上正是LevelDB和RocksDB所使用还被用于,类似的存储引擎Cassandra和HBase

这种索引结构也并命名为Log-Structured Merge-Tree(LSM-Tree)是基于合并压缩排序文件原理的

Lucene是Elasticsearch和Solr等全文搜索系统所使用的索引引擎,它采用类似的方法来保存其词典:给定搜索查询中的某个单词,找到提及该单词的所有文档(网页、产品描述等)。它主要采用key-value结构实现,其中键是单词(词条),值是所有包含该单词的文档ID的列表(倒排表)。在Lucene中,从词条到Posting list的映射关系保存在类SSTable的排序文件总,这些文件可以根据需要在后台合并

性能优化

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

不同的策略会影响甚至觉得SSTable压缩和合并时的具体顺序和时机,最常见的时大小分级和分层压缩。LevelDB和RocksDB使用分层压缩,HBase使用大小分级。Cassandra则同时支持者两种

  • 大小分级:较新和较小的SSTables被连续合并到较旧和较大的SSTables中
  • 分层压缩:键的范围分裂成多个更小的SSTables。旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省空间

即使有许多细微差异,但LSM-Tree的基本思想却足够简单有效,即使数据集远远大于可用内存,它仍然能够正常工作,由于数据按排序存储,因此 可以有效地执行区间查询(从最小值到最大值扫描所有键),并且由于磁盘是顺序写入的,所以LSM-tree可以支持非常高的写入吞吐量

B-Trees

B-Tree将数据库分解成固定大小的块或者页,传统上为4kb。页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列的

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

某一页被指定为B-tree的根时,每当查找索引中的一个键时,总会从这里开始,向下寻找,直到找到值的页的引用。页面包含若干个键和对子页面的引用,每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界

B-tree中一个页包含的子页引用数量称为分支因子,分支因素取决于存储页面引用和范围边界所需的空间总量,通常为几百个

如果要更新B-tree中现有键的值,首先要搜索包含该键的叶子页,更改该页的值,并将页写回到磁盘中。如果要添加新键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则会将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围

该算法确保树保持平衡:具有n个键的B-tree总是具有O(log n)的深度

使B-tree可靠

B-tree的基本写操作是原地更新页,这和LSM-Tree不同。原地更新页在磁性硬盘驱动器上意味着磁头首先移动到正确的位置,然后旋转盘面,最后用新的数据覆盖响应的扇区。对于SSD,由于SSD必须一次擦除并重写非常大的存储芯片块,情况更负责。另外,某些操作需要覆盖多个不同的页,比如,插入导致页溢出,需要分类也,那么需要写两个分裂的页,并覆盖其父页对两个子页的引用。这是个比较危险的操作,因为如果数据库在完成部分页写入后发生崩溃,最终会导致索引破坏(可能有一个孤儿页,没有被任何其他页所指向)

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

原地更新页的另一个复杂因素时,如果多个线程同时访问B-tree,则需要注意并发控制,否则线程可能会看到树处于不一致状态。通常使用锁存器保护树的数据结构来完成

优化B-tree

  • 一些数据库(LMDB)不是用覆盖页和维护WAL来进行崩溃恢复,而是使用写时复制方案。修改的页被写入不同的位置,树中的父页的新版本被创建,并指向新的位置。这种方法对并发控制也很有帮助(父页对子页的引用也要更新,等于父页也被修改了,父页是不是也要被写入不同的位置,那就太复杂了)
  • 保存键的缩略信息,而不是完整的键,这样可以节省页空间。特别是在树中间的页中,只需要提供足够的信息来描述键的起止范围。这样可以将更多的键压入到页中,让树具有更高的分支因子,从而减少层数
  • 一般来说,页可以放在磁盘上的任何位置;没有要求相邻的页需要放在磁盘相邻位置。如果查询需要按照顺序扫描大段的键范围,考虑到每个读取的页都可能需要磁盘I/O,所以逐页的布局可能是低效的。因此,许多B-tree的实现尝试对树进行布局,一遍相邻叶子页可以按顺序保存在磁盘上,然而,随着树的增长,维持这个顺序会变得越来越困难。相比之下LSM-tree在合并过程中一次重写大量存储段,因此他们更容易让连续的键在磁盘上相互靠近
  • 添加额外的指针到树中,例如每个叶子页可能会向左和向右引用其同级的兄弟页,这样可以顺序扫描键而不用条回到父页
  • B-tree的变体如分形树,借鉴了一些日志结构的想法来减少磁盘寻道

对比B-tree和LSM-Tree

LSM-tree的优点

  1. B-tree索引必须至少写两次数据:一次写入预写日志,一次写入树的页本身(可能有页分裂),即使该页中只有几个字节的更改也需要承受整个页的开销。LSM-Tree只需要一次
  2. 在数据库中,由于一次数据库写入请求导致多次磁盘写的情况被称为写放大,相较于B-tree的整个页的写入,LSM-Tree反复压缩和SSTables的合并,写放大的情况较低。对于有大量写密集的应用程序,性能瓶颈很可能在于数据库写入磁盘的速率。这种情况下,写放大具有直接的性能:存储引擎写入磁盘的次数越多,可用磁盘带宽中的每秒可处理的写入越少
  3. LSM-tree以顺序写方式写入紧凑的SSTable文件,而不必重写树中的多个页。这种区别对磁盘驱动尤为重要,因为磁盘顺序写比随机写要快很多、
  4. LSM-Tree可以更好的压缩,因此磁盘上的文件比B-tree小很多。由于碎片B-tree存储引擎使某些磁盘空间无法被使用:当页被分裂或者当一行内人不能适合现有页时,页中的某些空间无法使用。由于LSM-tree不是面向页的,并且定期重写SSTable以消除碎片化,所以它们具有较低的存储开销

LSM-tree的缺点

  1. 日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。即使存储引擎尝试增量的执行压缩,并不影响并发访问,但由于磁盘并发资源有限,所以但磁盘执行昂贵的压缩操作时,很容易发生读写请求等待的情况。
  2. 高写入吞吐量时,磁盘有限的写入带宽在需要初始写入时(记录并刷新内存表到磁盘)与后台运行的压缩线程之间所共享。写入空数据库时,所有磁盘带宽可用于初始写入,当数据库数据量越大,压缩所需的磁盘带宽就越多
  3. 如果写入吞吐量很高,并且压缩没有仔细配置,那么就会发生压缩无法匹配新数据写入速率的情况。这种情况磁盘上未合并段的数量不断增加直到磁盘空间不足
  4. B-tree能更好的支持事务隔离,B-tree的每个键都切好唯一对应索引中的某个位置,而日志结构的存储引擎可能在不同的段有相同键的多个副本,所以B-tree可以通过键范围上的所来实现事务

其他索引结构

二级索引:在关系数据库中,可以使用CREATE INDEX命令在同一个表上创建多个耳机索引,并且他们通过对于高效的执行联结操作只管重要

二级索引可以根据key-value索引来构建;可以使用索引中的每个值作为匹配行标识的列表,或者追加一些行标识来是每个键唯一

在索引中存储值

索引中的键是查询搜索的对象,而值可以是实际行,也可以是对其他地方存储行的引用。后一种情况下,存储行的具体位置被称为堆文件

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

在某些情况下,从 索引到堆文件的额外跳转对于读取来说意味着太多性能损失,因此可能希望将索引行直接存储在索引中。这类被称为聚集索引

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

多列索引

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

多维索引是更普遍的一次查询多列的方法,这对地理空间数据尤为重要;PostGIS使用PostgreSQL的广义搜索树索引实现了地理空间索引作为R树

全文搜索和模糊索引

全文搜索引擎通常支持对一个单词的所有同义词进行查询,并忽略单词语法上的变体,在同一文档中搜索彼此接近的单词的出现,并支持多种依赖语言分析的其他高级功能。为了处理文档或查询中的拼写错误,Lucene能够在某个编辑距离内搜索文本

在内存中保存所有内容

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

内存数据库另一个有意思的地方是,它提供了基于磁盘索引难以实现的某些数据模型,例如redis为各种数据结构(优先队列和集合)都提供了类似数据库的访问接口

最近研究表明,内存数据库架构可以扩展到支持远大于可用内存的数据集,而不会导致以磁盘为中心架构的开销。所谓反缓存方法,当没有足够的内存时,通过将最近最少使用的数据从内存写到磁盘,并在将来再次访问时将其加载到内存。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以在记录级别而不是整个内存页的粒度工作,因而比操作系统更有效的管理内存,不过这种方法仍然需要索引完全放入内存

事务处理与分析处理

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

OLTP(online transaction processing,OLTP):在线事务处理,主要应用于系统基本的,日常的事务处理(银行交易等)

OLAP(online analtic processing,OLAP):在线分析处理,支持复杂的分析操作,侧重决策支持,并且提供直观易懂的查询结果

对比事务处理与分析系统的主要特性
属性 事务处理系统(OLTP) 分析系统(OLAP)
主要读特征 基于键,每次查询返回少量的记录 对大量记录进行汇总
主要写特征 随机访问,低延迟写入用户的输入 批量导入(ETL)或事件流
典型使用场景 终端用户,通过网络应用程序 内部分析师,为决策提供支持
数据表征 最新的数据状态(当前时间点) 随着时间而变化的所有事件历史
数据规模 GB到TB  TB到PB

数据仓库

在实际应用中,OLTP系统对于业务运行只管重要,所以往往期望它们高度可用,处理事务时延迟足够低,并且数据库管理员要密切关注OLTP数据库运行状态。数据库管理员通常不愿意让业务分析人员在OLTP数据库上直接运行临时分析查询,这些查询通常代价很高,需要扫描大量数据姐,这可能会所害并发执行事务的性能

数据仓库是单独的数据库,分析人员可以在不影响OLTP操作的情况下进行地使用。数据仓库包含公司所有各种OLTP系统的只读副本。从OLTP(使用周期性数据转储或连续更新流)中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为提取-转换-加载(Extract-transform-Load,ETL)

使用单独的数据仓库而不是直接查询OLTP系统进行分析,很大的优势在于数据仓库可以针对分析访问模式进行优化。而前面提到的索引算法适合OLTP,但不适合分析查询

OLTP数据库和数据仓库之间的差异

数据仓库和关系型OLTP数据库看起来相似,因为它们都具有SQL查询接口。然而,系统内部实则差异很大,它们针对迥然不同的查询模式进行了各自优化。许多数据库供应商现在专注于支持事务处理或分析工作负载,但不能同时支持两者

星型与雪花型分析模式

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

模式的中心一个所谓的事实表,事实表的每一行表示在特定时间发生的事件,事实表的列表示属性。其他列可能会引用其他表的外键,称为维度表。由于事实表中的每一行都代表一个事件,维度通常代表时间的who,what,where,when,how,why

名称"星型模式"来源于当表关系可视化时,事实表位于中间,被一系列维度表包围。这些表的连接就像星星的光芒

该模版的一个变体称为雪花模式,其中维度进一步细分为子空间

在典型的数据仓库中,表通常非常宽:事实表通常超过100列,有时候几百列。维度表可能也非常宽,可能包括与分析相关的所有元数据

列式存储

面向列存储的想法很简单:不要将一行中的所有值存储在一起,而是将没咧中的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列,面向列的存储布局依赖一组列文件,每个文件以相同顺序保存着顺序行。因此,如果需要重新组装整行,可以从每个单独列文件中获取第23个条目,名将它们放在一起构成表的第23行

列存储在关系数据模型中最容易理解,但它同样适用于非关系数据,例如Parquet是基于Google的Dremel的一种支持文档数据模型的列存储格式

列压缩

取决于列中具体数据模式,可以采用不同的压缩技术。在数据仓库中特别有效的是一种技术是位图编码

通常,列中的不同值得数量小于行数。现在可以使用n个不同的值得列,并将其转换为n个单独的位图:一个位图对应每个不同的值,一个位图对应不同的值,一个位对应一个行,如果行具有该值,该位为1,否则为0;

Cassandra和HBase有一个列族的概念,它们继承自Google Bigtable。但是,将它们称为面向列则非常令人误解:在每个列族中,它们将一行中的所有列与行主键一起保存,并且不是用列压缩。因此,Bigtable模型仍然主要是面向行

内存带宽和矢量化处理

除了减少需要从磁盘加载的数据量之外,面向列的存储布局也有利于高效利用CPU周期。例如,查询引擎可以将一大块压缩列数据放入CPU的L1缓存中,并以紧凑循环(没有函数调用)进行迭代。对于每个被处理的记录,CPU能够比基于很多函数调用和用条件判断的代码更快地执行这种循环。列压缩使得列中更多的行可以加载到L1缓存

列存储中的排序

即使数据是按列存储的,它也需要一次排序整行。数据库管理员可以基于常见的查询的知识来选择要排序表的列。这样查询优化器可以减少扫描的范围

另一个,排序可以帮助进一步压缩列。如果主排序列上没有很多不同的值,那么在排序之后,它将出现一个非常长的序列,其中相同的值在一行中重复多次。一个简单的游程编码,即使该表可能拥有数十亿行,也可以将其压缩到几千字节

列存储的写操作

面向列的存储、压缩和排序都非常有助于加速读取查询,但写入更困难;类似于B-tree那样原地更新的方式并不适合。合适的方式是使用LSM-tree。所有的写入首先进入内存存储区,将其添加到已排序的结构中,接着再准备写入磁盘

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

为避免每次聚合查询时都需要处理原数据,可以选择将最常使用的一些计数或总和缓存起来

创建这种缓存的一种方式是物化视图:物化视图是查询结果的实际副本,并被写入到磁盘中

当底层数据发生变化时,物化视图需要随之更新,因为它是数据的非规范化副本。数据库可以自动执行,但这种更新方式会影响数据写入性能,这就是为什么在OLTP数据库中不经常使用物化视图。面对大量读密集的数据仓库,物化视图则更有意义

物化视图常见的一种特殊情况被称为数据立方体或OLAP立方体。它是哟不同维度分组的聚合网格

物化数据立方体的优点是某些查询会非常快,主要是它们已被预先计算出来;缺点是,数据立方体缺乏想查询原始数据那样的灵活性

小结

存储类型分为两大类:针对事务处理(OLAP)优化的架构,以及针对分析型(OLAP)的优化架构。它们典型的访问模式存在很大的差异:

  • OLTP系统通常面向用户,这意味着它们可能收到大量的请求。为了处理负载,应用程序通常在每个查询中只涉及少量的机率。应用程序基于某种键来请求记录,而存储引擎使用索引来查找所请求键的数据。磁盘寻道时间往往是瓶颈
  • 由于不是直接面对最终用户,数据仓库和类似的分析性系统相对并不太广为认知,它们主要有业务分析师使用。处理的查询请求数目远低于OLTP系统,但每个查询通常要求非常苛刻,需要在短时间内扫描数百万记录。磁盘带宽(不是寻道时间)通常是瓶颈,而面向列的存储对这种工作负载称为日益流行的解决方案

在OLTP方面,主要由两个流派的存储引擎

  • 日志结构流派,它只允许追加式更新文件和删除过时文件,但不会修改已写入的文件。BitCask,SSTables,LSM-tree,LevelDB,Cassandra,HBase,Lucene等属于此类
  • 原地更新流派,将磁盘是为可以覆盖的一组固定大小的页。B-tree是这一哲学的最典型代表,它已应用于所有主要的关系数据库,以及大量的非关系数据库

日志结构的存储引擎是一个相对较新的方案,其关键思想是系统地将磁盘上随机访问写入转为顺序写入,由于磁盘驱动器和SSD的性能特性,可以实现更高的写入吞吐量

 

你可能感兴趣的:(学习笔记,数据密集型应用系统设计,数据存储与检索)