数据库核心:数据结构
假设有一个世界上最简单的数据库,底层的存储格式非常简单:一个纯文本文件。其中每行包含一个键值对,用逗号分隔。每次存储数据即追加新内容到文件末尾,读取的时候需要扫描整个文本来查找键的出现位置。
与该结构类似,许多数据库都在内部使用日志(log),日志是一个仅支持追加式更新的数据文件。虽然真正的数据库有很多更为复杂问题需要考虑(例如并发控制、回收磁盘空间以控制日志文件大小、处理错误和部分完成写记录等),但是基本的原理是相同的。
为了高效地查找数据库中特定键的值,需要新的数据结构:索引。它们背后的基本想法都是保留一些额外的元数据,这些元数据作为路标,帮助定位想要的数据。如果希望用几种不同的方式搜索相同的数据,在数据的不同部分,我们可能定义多种不同的索引。
索引是基于原始数据派生而来的额外数据结构。很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的结构势必会引入开销,特别是在新数据写入时。对于写入,它很难超过简单地追加文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据时,需要更新索引,因此任何类型的索引通常都会降低写的速度。
哈希索引
首先我们以键-值数据的索引开始。key-value类型并不是唯一可以索引的数据,但它随处可见,而且是其他更复杂索引的基础构造模块。
key-value存储与大多数编程语言所内置的字典结构非常相似,通常采用hash map(或者hash table,哈希表)来实现。假设数据存储全部采用追加式文件组成,如之前的例子所示。那么最简单的索引策略就是:保存内存中的hash map,把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。每当在文件中追加新的key-value对时,还要更新hash map来反映刚刚写入数据的偏移量(包括插入新的键和更新已有的键)。当查找某个值时,使用hash map来找到文件中的偏移量,即存储位置,然后读取其内容。
所有的key都需要放入内存,而value数据量则可以超过内存大小,只需一次磁盘寻址,就可以将value从磁盘加载到内存。如果那部分数据文件已经在文件系统的缓存中,则读取根本不需要任何的磁盘I/O。这样的存储引擎非常适合每个键的值频繁更新的场景。
如上所述,只追加到一个文件,那么如何避免最终用尽磁盘空间?一个好的解决方案是将日志分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中,然后可以在这些段上执行压缩。压缩意味着在日志中丢弃重复的键,并且只保留每个键最近的更新。
此外,由于压缩往往使得段更小(假设键在段内被覆盖多次),也可以在执行压缩的同时将多个段合并在一起。由于段在写入后不会再进行修改,所以合并的段会被写入另一个新的文件。对于这些冻结段的合并和压缩过程可以在后台线程中完成,而且运行时,仍然可以用旧的段文件继续正常读取和写请求。当合并过程完成后,将读取请求切换到新的合并段上,而旧的段文件可以安全删除。
每个段现在都有自己的内存哈希表,将键映射到文件的偏移量。为了找到键的值,首先检查最新的段的hash map;如果键不存在,检查第二最新的段,以此类推。由于合并过程可以维持较少的段数量,因此查找通常不需要检查很多hash map。
但是,哈希表索引有其局限性:
- 哈希表必须全部放入内存,所以如果有大量的键,就没那么幸运了。
- 区间查询效率不高。
SSTables和LSM-Tree
现在简单地改变段文件的格式:要求键值对按键排序。这种格式称为排序字符串表,或简称为SSTable。它要求每个键在每个合并的段文件中只能出现一次(压缩过程已经确保了)。SSTable相比哈希索引的日志段,具有以下优点:
- 合并段更加简单高效,即使文件大于可用内存。方法类似于合并排序算法中使用的方法。并发读取多个输入段文件,比较每个文件的第一个键,把最小的键(根据排序顺序)拷贝到输出文件,并重复这个过程。这会产生一个新的按键排序的合并段文件。
- 在文件中查找特定的键时,不再需要在内存中保存所有键的索引。以下图为例,假设正在查找键handiwork,且不知道该键在段文件中的确切偏移。但是,如果知道键handbag和键handsome的偏移量,考虑到根据键排序,则键handiwork一定位于它们两者之间。这意味着可以跳到handbag的偏移,从那里开始扫描,直到找到handiwork。所以,仍然需要一个内存索引来记录某些键的偏移,但它可以是稀疏的,由于可以很快扫描几千字节,对于段文件中每几千字节,只需要一个键就足够了。
- 由于读请求往往需要扫描请求范围内的多个key-value对,可以考虑将这些记录保存到一个块中并在写磁盘之前将其压缩(如图中阴影区域所示)。然后稀疏内存索引的每个条目指向压缩块的开头。除了节省磁盘空间,压缩还减少了I/O带宽的占用。
构建和维护SSTables
考虑到写入可能以任意顺序出现,首先该如何让数据按键排序呢?在磁盘上维护排序结构是可行的,不过,将其保存在内存中更容易。内存排序有很多广为人知的树状数据结构,例如红黑树或AVL树。使用这些数据结构,可以按任意顺序插入键并以排序后的顺序读取它们。
存储引擎的基本工作流程如下:
- 当写入时,将其添加到内存中的平衡树数据结构中(例如红黑树)。这个内存中的树有时被称为内存表。
- 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。由于树已经维护了按键排序的key-value对,写磁盘可以比较高效。新的SSTable文件成为数据库的最新部分。当SSTable写磁盘的同时,写入可以继续添加到一个新的内存表实例。
- 为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)。
- 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值。
上述方案可以很好地工作。但它还存在一个问题:如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。为了避免该问题,可以在磁盘上保留单独的日志,每个写入都会立即追加到该日志。日志文件不需要按键排序,这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当将内存表写入SSTable时,相应的日志可以被丢弃。
从SSTables到LSM-Tree
最初这个索引结构由Patrick O'Neil等人以日志结构的合并树(Log-Structured Merge-Tree,或LSM-Tree)命名,它建立在更早期的日志结构文件系统之上。因此,基于合并和压缩排序文件原理的存储引擎通常都被称为LSM存储引擎。
Lucene是Elasticsearch和Solr等全文搜索系统所使用的索引引擎,它采用了类似的方法来保存其词典。全文索引比key-value索引复杂得多,但它基于类似的想法:给定搜索查询中的某个单词,找到提及该单词的所有文档(网页、产品描述等)。它主要采用key-value结构实现,其中键是单词(词条),值是所有包含该单词的文档ID的列表(倒排表)。在Lucene中,从词条到posting list的映射关系保存在类SSTable的排序文件中,这些文件可以根据需要在后台合并。
性能优化
总是有很多细节值得深入优化,这样才能使存储引擎在实际中表现得更好。例如,当查找数据库中某个不存在的键时,LSM-Tree算法可能很慢:在确定键不存在之前,必须先检查内存表,然后将段一直回溯访问到最旧的段文件(可能必须从磁盘多次读取)。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(布隆过滤器是内存高效的数据结构,用于近似计算集合的内容。如果数据库中不存在某个键,它能够很快告诉你结果,从而节省了很多对于不存在的键的不必要的磁盘读取)。
还有不同的策略会影响甚至决定SSTables压缩和合并时的具体顺序和时机。最常见的方式是大小分级和分层压缩。LevelDB和RocksDB使用分层压缩,HBase使用大小分级,Cassandra则同时支持这两种压缩。在大小分级的压缩中,较新的和较小的SSTables被连续合并到较旧和较大的SSTables。在分层压缩中,键的范围分裂成多个更小的SSTables,旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘空间。
即使有许多细微的差异,但LSM-Tree的基本思想(保存在后台合并的一系列SSTable)却足够简单有效。即使数据集远远大于可用内存,它仍然能够正常工作。由于数据按排序存储,因此可以有效地执行区间查询(从最小值到最大值扫描所有的键),并且由于磁盘是顺序写入的,所以LSM-Tree可以支持非常高的写入吞吐量。
B-tree
之前看到的日志结构索引将数据库分解为可变大小的段,通常大小为几兆字节或更大,并且始终按顺序写入段。相比之下,B-tree将数据库分解成固定大小的块或页,传统上大小为4 KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。
每个页面都可以使用地址或位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过是指向磁盘地址,而不是内存。可以使用这些页面引用来构造一个树状页面,如图所示。
某一页被指定为B-tree的根;每当查找索引中的一个键时,总是从这里开始。该页面包含若干个键和对子页的引用。每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。
B-tree中一个页所包含的子页引用数量称为分支因子。在实际中,分支因素取决于存储页面引用和范围边界所需的空间总量,通常为几百个。如果要更新B-tree中现有键的值,首先搜索包含该键的叶子页,更改该页的值,并将页写回到磁盘(对该页的任何引用仍然有效)。如果要添加新键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围。
使B-tree可靠
为了使数据库能从崩溃中恢复,常见B-tree的实现需要支持磁盘上的额外的数据结构:预写日志(write-ahead log,WAL),也称为重做日志。这是一个仅支持追加修改的文件,每个B-tree的修改必须先更新WAL然后再修改树本身的页。当数据库在崩溃后需要恢复时,该日志用于将B-tree恢复到最近一致的状态。
原地更新页的另一个复杂因素是,如果多个线程要同时访问B-tree,则需要注意并发控制,否则线程可能会看到树处于不一致的状态。通常使用锁存器(轻量级的锁)保护树的数据结构来完成。在这方面,日志结构化的方法显得更简单,因为它们在后台执行所有合并,而不会干扰前端的查询,并且会不时地用新段原子地替换旧段。
优化B-tree
由于B-tree已经存在了很长时间,自然多年来开发了许多优化措施。这里只列举一些:
- 一些数据库(如LMDB)不使用覆盖页和维护WAL来进行崩溃恢复,而是使用写时复制方案。修改的页被写入不同的位置,树中父页的新版本被创建,并指向新的位置。
- 保存键的缩略信息,而不是完整的键,这样可以节省页空间。
- 一般来说,页可以放在磁盘上的任何位置;没有要求相邻的页需要放在磁盘的相邻位置。如果查询需要按照顺序扫描大段的键范围,考虑到每个读取的页都可能需要磁盘I/O,所以逐页的布局可能是低效的。因此,许多B-tree的实现尝试对树进行布局,以便相邻叶子页可以按顺序保存在磁盘上。然而,随着树的增长,维持这个顺序会变得越来越困难。
- 添加额外的指针到树中。例如,每个叶子页面可能会向左和向右引用其同级的兄弟页,这样可以顺序扫描键。而不用跳回到父页。
- B-tree的变体如分形树,借鉴了一些日志结构的想法来减少磁盘寻道。
对比B-tree和LSM-tree
根据经验,LSM-tree通常对于写入更快,而B-tree被认为对于读取更快123。读取通常在LSM-tree上较慢,因为它们必须在不同的压缩阶段检查多个不同的数据结构和SSTable。
LSM-tree的优点
LSM-tree通常能够承受比B-tree更高的写入吞吐量,部分是因为它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分原因是它们以顺序方式写入紧凑的SSTable文件,而不必重写树中的多个页。这种差异对于磁盘驱动器尤为重要,原因是磁盘的顺序写比随机写要快得多。
LSM-tree可以支持更好地压缩,因此通常磁盘上的文件比B-tree小很多。由于碎片,B-tree存储引擎使某些磁盘空间无法使用:当页被分裂或当一行的内容不能适合现有页时,页中的某些空间无法使用。由于LSM-tree不是面向页的,并且定期重写SSTables以消除碎片化,所以它们具有较低的存储开销,特别是在使用分层压缩时。
LSM-Tree的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。即使存储引擎尝试增量地执行压缩,并且不影响并发访问,但由于磁盘的并发资源有限,所以当磁盘执行昂贵的压缩操作时,很容易发生读写请求等待的情况。这对吞吐量和平均响应时间的影响通常很小,但是如果观察较高的百分位数,日志结构化存储引擎的查询响应时间有时会相当高,而B-tree的响应延迟则更具确定性。
高写入吞吐量时,压缩的另一个问题就会冒出来:磁盘的有限写入带宽需要在初始写入(记录并刷新内存表到磁盘)和后台运行的压缩线程之间所共享。写入空数据库时,全部的磁盘带宽可用于初始写入,但数据库的数据量越大,压缩所需的磁盘带宽就越多。
如果写入吞吐量很高并且压缩没有仔细配置,那么就会发生压缩无法匹配新数据写入速率的情况。在这种情况下,磁盘上未合并段的数量不断增加,直到磁盘空间不足,由于它们需要检查更多的段文件,因此读取速度也会降低。通常,即使压缩不能跟上,基于SSTable的存储引擎也不会限制到来的写入速率,因此需要额外的监控措施来及时发现这种情况。
B-tree的优点则是每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本。如果数据库希望提供强大的事务语义,这方面B-tree显得更具有吸引力:在许多关系数据库中,事务隔离是通过键范围上的锁来实现的,并且在B-tree索引中,这些锁可以直接定义到树中。
其他索引结构
在索引中存储值
索引中的键是查询搜索的对象,而值则可以是以下两类之一:它可能是上述的实际行,也可以是对其他地方存储的行的引用。在后一种情况下,存储行的具体位置被称为堆文件,并且它不以特定的顺序存储数据。堆文件方法比较常见,这样当存在多个二级索引时,它可以避免复制数据,即每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。
在某些情况下,从索引到堆文件的额外跳转对于读取来说意味着太多的性能损失,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在MySQL InnoDB存储引擎中,表的主键始终是聚集索引,二级索引引用主键。
多列索引
最常见的多列索引类型称为级联索引,它通过将一列追加到另一列,将几个字段简单地组合成一个键(索引的定义指定字段连接的顺序)。
多维索引是更普遍的一次查询多列的方法,这对地理空间数据尤为重要。例如,餐馆搜索网站可能有一个包含每个餐厅的纬度和经度的数据库。当用户在地图上查看餐馆时,网站需要搜索用户正在查看的矩形地图区域内的所有餐馆。这要求一个二维的范围查询,如下所示:
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
AND longitude > -0.1162 AND longitude < -0.1004;
标准B-tree或LSM-tree索引无法高效地应对这种查询,它只能提供一个纬度范围内的所有餐馆,或者所有经度范围内的餐厅,但不能同时满足。
一种选择是使用空格填充曲线将二维位置转换为单个数字,然后使用常规的B-tree索引。更常见的是使用专门的空间索引,如R树。例如,PostGIS使用PostgreSQL的广义搜索树索引实现了地理空间索引作为R树。
全文搜索和模糊索引
全文搜索引擎通常支持对一个单词的所有同义词进行查询,并忽略单词语法上的变体,在同一文档中搜索彼此接近的单词的出现,并且支持多种依赖语言分析的其他高级功能。
Lucene对其词典使用类似SSTable的结构。此结构需要一个小的内存索引来告诉查询,为了找到一个键,需要排序文件中的哪个偏移量。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但是在Lucene中,内存中的索引是键中的字符序列的有限状态自动机,类似字典树。这个自动机可以转换成Levenshtein自动机,它支持在给定编辑距离内高效地搜索单词。
在内存中保存所有数据
与内存相比,磁盘更难以处理。使用磁盘和SSD,如果要获得良好的读写性能,需要精心地安排磁盘上的数据布局。随着内存变得更便宜,每GB成本被摊薄。而许多数据集不是那么大,可以将它们完全保留在内存中,或者分布在多台机器上。
一些内存中的key-value存储(如Memcached),主要用于缓存。但是其他内存数据库旨在实现持久性,例如可以通过用特殊硬件或者通过将更改记录写入磁盘,或者将定期快照写入磁盘,以及复制内存中的状态到其他机器等方式来实现。
当内存数据库重启时,它需要重新载入其状态,无论是从磁盘还是通过网络从副本(除非使用特殊硬件)。尽管写入磁盘,但磁盘仅仅用作为了持久性目的的追加日志,读取完全靠内存服务。此外,写入磁盘还具有一些运维方面优势:磁盘上的文件可以容易地通过外部工具来执行备份、检查和分析。
与直觉相反,内存数据库的性能优势并不是因为它们不需要从磁盘读取。如果有足够的内存,即使是基于磁盘的存储引擎,也可能永远不需要从磁盘读取,因为操作系统将最近使用的磁盘块缓存在内存中。相反,内存数据库可以更快,是因为它们避免使用写磁盘的格式对内存数据结构编码的开销。
除了性能外,内存数据库的另一个有意思的地方是,它提供了基于磁盘索引难以实现的某些数据模型。例如,Redis为各种数据结构(如优先级队列和集合)都提供了类似数据库的访问接口。
事务处理与分析处理
尽管数据库开始被用于许多不同种类的数据,例如博客的评论、游戏中的动作、通讯录中的联系人等,然而其基本访问模式仍然与处理业务交易类似。应用程序通常使用索引中的某些键查找少量记录,根据用户的输入插入或更新记录。因为这些应用程序是交互式的,所以访问模式被称为在线事务处理(online transaction processing,OLTP)。
然而,数据库也开始越来越多地用于数据分析,数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取少数几列,并计算汇总统计信息,而不是返回原始数据给用户。这些查询通常由业务分析师编写,以形成有助于公司管理层更好决策(商业智能)的报告。为了区分使用数据库与事务处理的模式,称之为在线分析处理(online analyticprocessing,OLAP)。
数据仓库
数据库管理员通常不愿意让业务分析人员在OLTP数据库上直接运行临时分析查询,这些查询通常代价很高,要扫描大量数据集,这可能会损害并发执行事务的性能。
相比之下,数据仓库则是单独的数据库,分析人员可以在不影响OLTP操作的情况下尽情地使用。数据仓库包含公司所有各种OLTP系统的只读副本。从OLTP数据库(使用周期性数据转储或连续更新流)中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为提取-转换-加载(Extract-Transform-Load,ETL),如图所示。