Design Data-Intensive Applications 读书笔记五 索引结构:哈希表和LSM

本文介绍了数据库存储和检索的基本原理,重点探讨了索引结构,包括哈希索引和LSM-Trees。哈希索引通过内存中的哈希表加速查找,适用于键值对频繁更新的场景。而LSM-Trees通过SSTables和内存中的memtable实现高效读写,支持数据排序和范围查询,适用于大数据量存储。LSM-Trees在写入性能和空间效率上有所牺牲,但能提供更好的查询性能。
摘要由CSDN通过智能技术生成

第三章、存储和检索

数据库最基础的两个功能:存储和返回数据。

第二章我们讨论了数据模型和查询语句,即你存进数据库的数据格式以及你后来取出数据的原理。这章我们从相同的视角讨论数据库:我们如何存储数据,以及如何找到查询结果。

我们开发应用时,不需要关系数据库如何存储和检索数据,但是你需要为应用选择合适的存储引擎,你需要理解引擎背后的工作原理。并且,事务优化的引擎和检索优化的引擎有很大不同,我们后续会讨论它们的不同以及一系列检索优化的存储引擎。这章我们开始会讨论我们熟悉的数据库类型:传统的关系型数据库,以及NoSQL数据库。我们会审视两类存储引擎:log-structured存储引擎以及page-oriented存储引擎。

增强数据库的数据结构

考虑下世界上最简单的数据库,只有两个Bash函数:

 

这两个函数实现了一个键值存储,可以称呼为db_set 键值,就是在数据库中存储键值对。键和值都可以为任意数据,比如JSON文档。你可以调用db_get key 这会查找出与之关联的值并返回。

这背后的存储格式十分简单:就是一个文本文件,每一行存储一对键值,用逗号分隔。每次调用db_set 都会往文件尾添加记录,如果你多次更新键值,老版本的值不会重写,你需要找到最新的值。

类似db_set,很多系统使用log,就是一个只追加写入的数据文件。真实的数据库有很多问题要处理(例如并发控制,回收硬盘空间让日志文件不会永远增长,以及处理错误和部分已经写入的记录),但是基本的准则是相同的。

另一方面,我们的db_get函数在数据库中有大量数据时,性能很糟糕。每次你想查找一个键,db_get不得不从头到尾扫描整个数据库来搜索键的位置,查找的消耗是 O(n) 。

为了快速找到特定的值,我们需要一个不同的数据结构:索引。这章我们会浏览一系列索引结构,然后比较,它们背后的共同原理就是额外添置一些元数据,用于帮助定位我们想要的数据。如果你通过不同的方法来检索相同的数据,你可能需要数据不同部分所建立的索引。

索引是原始数据衍生出来的附加的数据结构。很多数据库允许添加和删除索引,并且不影响数据库的内容,这只会影响查询性能。维护附加的数据结构会产生额外的开支,特别是写入的时候。写入时,性能很难超过直接写入文件的性能,因为这是最简单的写入操作。任何索引都会降低写入性能,因为每次写入时都会更新索引。

存储系统需要做一个重要的权衡:索引能加速查询,但是每次建索引会降低写入速度。因此,数据库默认不会给所有东西都建立索引,而是需要开发者或者数据库管理者认为选择建立索引。你可以选择那些给你的应用最大收益的索引而不引入过多的开支。

 

哈希索引

我们开始介绍键值数据的索引。这不仅是因为这是你唯一能检索的数据,而且它是复杂索引的基础。键值存储非常类似编程语言中的字典结构,通常被实现成哈希表。

现在设想我们的数据存储仅仅是往文件中追加数据。最简单的索引策略数:维护一个内存中的哈希表,表中键对应数据文件中的数据的字节偏移量,也就是值在文件中的位置。当追加写入键值对时,你同样更新哈希表来反映刚才写入数据的偏移量。当你查找值时,使用哈希表找到偏移量,定位然后读取值。

这听起来很简单,但是是可行的方案。实际上Bitcask存储引擎就是这么做的。Bitcask提供高性能的读写,归功于它将所有的键都维持在RAM中。因为哈希表在内存中,值能使用比可用内存更多的空间,因为可以在一次磁盘读取中加载值。如果部分数据文件已经在文件系统的缓存中,读取就不需要任何I/O操作。

类似Bitcask的存储引擎适用于每个键对应的值频繁更新的场景。比如,键是一个视屏的URL地址,值是它已经播放的次数(每次点击播放按钮都会增加)。这种场景下,有大量的写操作,但是将所有的键都存在内存中是可行的。

目前为止,我们只是向一个文件中追加数据,我们如何避免最终用完磁盘空间?一个好的解决方法就是将日志分块,在文件达到特定大小的时候关闭它,然后将后续的写入新建的块文件。我们可以将这些块文件压缩,丢弃掉重复的键,只保留每个键最新更新的值。更进一步,因为压缩后每个块文件会缩小,我们可以同时合并多个块文件,进行压缩操作,操作中块文件被冻结。块文件在被写入之后不会被修改,所以合并的块会被写入新文件。合并和压缩操作可以在后台进行,这期间我们可以使用旧的块文件。当合并操作完成了,我们转而使用新的合并后的文件,旧的块文件删除就好。

每个块文件有自己的内存中的哈希表,记录着键和文件偏移量。为了找到键的值,我们首先检查最近的块文件,如果没找到会检查第二新的块文件,依次类推,所以查找操作不需要检查很多哈希表。

实际工作中有很多,实现这个简单的想法有很多细节,下面是些重要的细节:

文件格式:CSV不是日志最好的格式。使用二进制文件在原生的字符串后面编码字符串的长度更加容易(不需要转义)。

删除记录:如果你想删除一个键和对应的值,你需要往数据文件中追加一个特别的删除记录(有时称之为墓碑)。当日志块文件合并后,墓碑会告诉合并进程丢弃掉所删除键对应的之前的任何值。

故障恢复:如果数据库重启了,内存中的哈希表就丢失了。原则上,你可以通过从头读取整个块文件来恢复每个块文件的哈希表,然后标注出每个键对应的最新值。但是,如果块文件很大,这会需要非常长的时间,导致服务重启困难。Bitcask在磁盘上存储了每个块的哈希表的快照,来加快恢复速度,快照能更快的读进内存。

不完全写入的记录:数据库会在任何时间发生故障,包括往日志中追加记录的过程中。Bitcask文件包含校验码,允许删除和忽略损坏的部分。

并发控制:如果是遵循连续的严格的顺序写入日志,一个普遍的选择是只有一个写入器。数据分块文件都是只增的或者不可更改的,所以可以被多个线程读取。

一个只增的日志看起来很浪费:为什么不更新文件,重写旧值?但是只增的设计后来证明是很有效的:

1、追加操作和分块合并都是连续写入操作,这些都比随机写入快,特别是在磁盘上。在SSD上某些程度上连续写入更适合。

2、在块文件都是只增或者不变的情况下,并发和故障恢复更加简单。比如,你不用担心当一个值正在重写时故障发生了,结果你的文件部分是旧值部分是新值。

3、合并旧的块文件能避免你的数据文件碎片化。

但是,哈希表索引有如下缺点:

1、哈希表必须存在内存中,如果你的键的数量非常大,那么真的很难办。理论上,你可以在磁盘上维护一个哈希表,但是在磁盘上操作哈希表很困难,它需要大量的随机读写,当哈希表写满时,再增长的代价很高,而且解决哈希冲突需要繁杂的逻辑。

2、范围查询很低效。比如比很难扫描得到在kitty00000和kitty99999之间的值,因为你不得不对每个键单独在哈希表中搜索。

下一章,我们会看看没有这些缺点的索引框架

 

SSTables 和 LSM-Trees

每个存储分块文件都是一系列的键值对。这些键值是按照写入顺序排列,后来的值优先于先前的值。除自之外,文件中键值对的顺序不重要。

现在我们对块文件的格式做些改变:我们要求键值对按照键来排序。乍看去,这个要求破坏了连续写入,我们稍后讨论。

我们称这种格式为有序字段表(Sorted String Table),简称SSTale。我们同时要求每个键在每个合并后的块文件中只出现一个(压缩程序可以保证)。SSTable对比日志分块有诸多优势。

1、合并块是简单而且有效率的,即便是文件大于可用内存。这个方法类似于图3-4所示的合并排序算法:按顺序读入文件,找到每个文件中的第一个键,复制最小的键(按照排序规则)到输出文件,如此重复。这会产生一个新的按照键排序的合并块文件。

图3-4

如果多个输入文件有相同的键呢?记住每个块文件包含的是数据库在一个时间段内写入的所有数据。这意味着一个块文件的包含所有值一定比其他块文件的值更新(假设我们一直合并临近的块)。当多个块包含相同的键,我们可以只保留最新的块文件中的值,其余丢弃。

2、为了找到文件中的特定键,你不在需要在内存中维护一个包含所有值得索引。图3-5所示:你再寻找键 handiwork ,但是你不知道键在块文件中的准确偏移。但是你知道键 handbag和handsome 的偏移,并且因为排序,你知道handiwork一定出现在这两者之中。这意味着你能跳至handbag的偏移然后从那儿开始搜索直到找到(或者那个文件中找不到)。

图3-5

你依然需要在内存中维护一个索引来记录一些键的偏移,但是它稀疏得多:一个键对应几kb的块文件是足够的,因为几kb的文件能够很快的扫描。

3、由于读请求需要扫描在请求范围中的键值对,所以可以将这些记录组合进块中然后再写入磁盘时压缩。每个内存中叙述索引的条目都指向压缩快开始的点。这不仅节省磁盘空间,而且压缩能够减少I/O带宽使用。

 

构筑和维护SSH Table

那么如何在一开始对键排序?我们的写入时乱序的。

在磁盘上维护一个有序架构是可能的,在内存中维护有序架构更加容易。我们可以使用多个已知树结构,比如红黑树或者AVL树,使用这些结构,我们可以以任意顺序插入数据,然后有序读取。

现在我们可以让我们的存储引擎如下工作:

1、写入时,将其添加都内存中的平衡树结构,内存中的树有时候被称为memtable。

2、当memtable大小超过一定阈值的时候,会将其写入磁盘生成一个SSTable文件。这可以做得很高效,因为树结构已经是排好序的键值对了。新的SSTable文件就是最新的数据库分块文件。当SSTable文件写入到磁盘上时,后续写入的数据会生成新的memtable实例。

3、读取时,会在内存中memtable中查找,如果找不到会找磁盘上最新的SSTable文件,然后是次新的,依次寻找。

4、合并和压缩程序在后台会 不时运行,会整合块文件,丢弃那些被覆写或者删除的值。

 

这个模式工作得很好。但是它面临一个问题:如果数据库故障,那么最新写入的记录(写入至memtable中,还未写出到磁盘上的记录)会丢失。为了避免这个问题,我们在磁盘单独维护一个日志文件,每次写入都会立即追加到文件里,就像上节所做的。日志中记录是乱序的,它的唯一目的就是在数据库故障后重建memtable,每次memtable写入磁盘,相应的日志会被丢弃。

 

从SSTales生成LSM-Tree

描述的算法使用在了LevelDB和RocksDB,这些可用于其他应用的存储引擎。类似的存储引擎应用在了Cassandra和HBase,它们都是Google的BigTable论文催生出来的。这种索引结构最初被Patrick描述为“日志架构合并树”(Log-Structured Merge-Tree,LSM-Tree)。基于合并和压缩有序文件的规则做出的存储引擎被称为LSM存储引擎。

Lucene,一个被Elasticsearch和Solr使用的全文搜索索引引擎,用了类似的方法来存储它的项目字典(item dictionary)。全文索引比键值索引更加复杂,但是思想类似:在查询中给定一个单词,然后找到所有提到单词的文档。这使用键值结构实现,键是一个单词(item),值是一系列包含单词的文档的id(检索条数,the postings list)。在Lucene中,从单词到检索条数的映射存储在类似SSTable的有序文件中,后台在需要时会将其合并。

 

性能优化

在实际工作中,需要做很多工作来让保证存储引擎的性能。比如,LSM-tree算法在查找在数据库中不存在的键时会变得很慢,你不得不检查memtable,然后是从最新到最旧的块文件来确认数据库中不存在键。为了优化这种过程,存储引擎经常使用额外的Bloom filter(它是一个高效的内存结构,用于估算集合的内容)。它能告诉你数据库中是否出现键,因此在查找不存在的键时节省了很多不必要的磁盘读取。

有很多策略决定SSTable排序和压缩工作的排序和定时。最常见的选择是尺寸分层(size-tiered)压缩和水平压缩。在尺寸压缩中,更新的和更小的SSTable被相继合并到旧的和更大的SSTable。在水平压缩中,键的值域被分为小的SSTable,就得数据移动至其他“层级”,这允许压缩工作循序渐进并且使用更少的磁盘空间。

尽管要注意很多地方,LSM-tree,维护一系列在后台进行合并的SSTable,是简单高效的。即便数据库增长超过可用内存,它也能继续工作。因为数据有序存储,你可以很有效的进行范围查询(扫描介于最大值和最小值之间的所有键),而且因为磁盘是连续写入,LSM-tree支持高写入量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值