本章目标: 针对存储引擎,即数据库是如何安排磁盘结构从而提高检索效率。
从最基本的层面看,数据库只需做两件事情:向它插入数据时,它就保存数据;之后查询时,它应该返回哪些数据。特别地,针对事务型工作负载和针对分析型负载的存储引擎优化存在很大的差异。接下来我们讨论传统的关系数据库和大多数所谓的NoSQL数据库。我们研究两个存储引擎家族,即日志结构的存储引擎与面向页的存储引擎,比如B-tree。
日志:一个仅支持追加的数据文件,表示一个仅能追加的记录序列集合(可能是人类不可读的,可能是二进制格式而只能被其他程序来读取)
索引:为了更加高效地查找,是基于原始数据派生而来的额外数据结构,适当的索引可以加速读取查询,但每个索引都会减慢写速度。
举一个以键-值数据的索引为例,Key-value类型,数据存储全部采用追加式文件组成。最简单的索引策略: 保存内存中的hash map,把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值得位置。很多细节问题如下:
采用追加式设计的原因:
上一节中每个日志文件结构的存储段都是一组key-value对的序列。这些key-value对按照它们的写入顺序排列,并且对于出现在日志中的同一个键,后出现的值优于之前的值。除此之外,文件中key-value对的顺序并不重要。
排序字符串表(SSTable):简单改变段文件的格式,要求key-value对的顺序按键排列。要求每个键在每个合并的段文件中只能出现一次(压缩过程已经确保了)。SSTable相比哈希索引的日志段,具有以下优点:
如何让数据按键排序呢? 在磁盘上维护排序结构是可行的,不过将其保存在内存中更容易。(红黑树、AVL树)
存储引擎的基本工作流程:
LSM存储引擎: 基于合并和压缩排序文件原理的存储引擎
最初这个索引结构由Patrick O’Neil等人以日志结构的合并树(Log-Structured Merge-Tree,或LSM-Tree)命名,它建立在更早期的日志系统文件系统之上。Lucene是Elasticsearch和Solr等全文搜索系统所使用的索引引擎,它采用了类似的方法来保存其字典。
对当查询数据库不存在的数据时进行优化,存储引擎通常使用额外的布隆过滤器(布隆过滤器是内存高效的数据结构,用于近似计算集合的内容。如果数据库中不存在某个键,它能够很快告诉你结果,从而节省了很多对于不存在的键的不必要的磁盘读取)
不同的策略会影响甚至决定SSTables压缩和合并时的具体顺序和时机。最常见的方式是大小分级和分层压缩。
最广泛使用的索引结构:B-tree。像SSTable一样,B-tree保留按键排序的key-value对,这样可以实现高效的key-value查找和区间查询。但相似仅此而已:B-tree本质上具有非常不同的设计理念。
之前看到的日志结构索引将数据库分解为可变大小的段,通常大小为及兆字节或更大,并且始终按顺序写入段。相比之下,B-tree将数据库分解成固定大小的块或页,传统上大小为4KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。
B-tree中一个页所包含的子叶引用数量成为分支因子。在实际中,分支因素取决于存储页面引用和范围边界所需的空间总量,通常为几百个。
该算法确保树保持平衡:具有n个键的B-tree总是具有O(logN)的深度。大多数数据库可以适应3~4层的B-tree,因此不需要遍历非常深的页面层次即可找到所需的页(分支因子为500的4KB页的四级树可以存储高达256TB)
B-tree底层的基本写操作是使用新数据覆盖磁盘上的旧页。当某些操作需要覆盖多个不同的页时,例如插入导致的页溢出,这时数据库崩溃会导致索引破坏。对策是需要支持磁盘上的额外的数据结构:预写日志(write-ahead log,WAL),也叫重做日志。一个仅支持追加修改的文件,每个B-tree的修改必须先更新WAL然后再修改树本身的页。
并发控制相对于日志结构化的方法更加困难。
优化措施:
根据经验,LSM-tree通常对于写入更快,而B-tree被认为对于读取更快
B-tree索引必须至少写两次数据:一次写入预写日志,一次写入树的页本身(还可能发生页分裂)
由于反复压缩和SSTable的合并,日志结构索引也会重写数据多次。这种影响(在数据库内,由于一次数据库写入请求导致的多次磁盘写)称为写放大。对于SSD,由于只能承受有限次地擦除覆盖,因此尤为关注写放大指标。
此外,LSM-tree通常能够承受比B-tree更高的写入吞吐量,部分是因为它们有时具有较低的写放大(尽管这通常取决于存储引擎的配置和工作负载),部分原因是它们以顺序方式写入紧凑的SSTable文件,而不必重写树中的多个页。这种差异对于磁盘驱动器尤为重要,原因是磁盘的顺序写比随机写要快得多。
LSM-tree可以支持更好地压缩,因此通常磁盘上的文件比B-tree小很多
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。
高写入吞吐量时,压缩的另一个问题就会冒出来:磁盘的有限写入带宽需要在初始写入(记录并刷新内存表到磁盘)和后台运行的压缩线程之间所共享。
如果写入吞吐量很高并且压缩没有仔细配置,那么就会发生压缩无法匹配新数据写入速率的情况。
B-tree的优点则是每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储可能在不同的段中具有相同键的多个副本。如果数据库希望提供强大的事物语义,这方面B-tree显得更具有新引力:在许多关系型数据库中,事务隔离是通过键范围上的锁来实现的,并且在B-tree索引中,这些锁可以直接定义到树中。
数据库中的其他记录可以通过其主键(ID)来引用该行/文档/顶点,该索引用于解析此类引用。
二级索引可以容易地基于key-value索引来构建。主要区别在于它的键不是唯一的,极可能有许多行(文档,顶点)具有相同键。这可以通过两种方式解决:使索引中的每个值成为匹配行标识符的列表(像全文检索中的posting list),或者追加一些行标识符来使每个键变得唯一。无论哪种方式,B-tree和日志结构索引都可以用作二级索引。
索引中的键是查询搜索的对象,而值则可以是以下两类之一:它可能是上述的实际行(文档,顶点),也可以是对其他地方存储的行的引用。
在某些情况下,从索引到堆文件的额外跳转对于读取来说意味着太多的性能损失,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。
聚集索引(在索引中直接保存行数据)和非聚集索引(仅存储索引中的数据的引用)之间有一种折中设计称为覆盖索引或包含列的索引,它在索引中保存一些表的列值。它可以支持只用过所以即可回答某些简单查询(在这种情况下,称索引覆盖了查询)。
迄今为止讨论的索引只将一个键映射到一个值。如果需要同时查询表的多个列(或文档中的多个字段),那么这是不够的。
最常见的多列索引类型称为级联索引,它通过将一列追加到另一列,将几个字段简单地组合成一个键(索引的定义指定字段链接的顺序)。
多维索引是更普遍的一次查询多列的方法。
到目前为止讨论的所有索引都假定具有确切的数据,并允许查询键的确切值或排序的键的取值范围。它们不支持搜索类似的键,如拼写错误的单词。这种模糊查询需要不同的技术。
例如,全文检索引擎通常支持对一个单词的所有同义词进行查询,并忽略单词语法上的变体,在同一文档中搜索彼此接近的单词的出现,并且支持多种依赖语言分析的其他高级功能。为了处理文档或查询中的拼写错误,Lucene能够在某个编辑距离内搜索文本(编辑距离为1表示已经添加、删除或替换了一个字母)。
其他模糊搜索技术则沿着文档分类和机器学习的方向发展。
本章迄今为止讨论的数据结构都是为了适应磁盘限制。内存价格的下降推动了内存数据库的发展。
一些内存中的key-value存储(如Memcached),主要用于缓存,如果机器重启造成的数据丢失是可以接收的。但是其他内存数据库旨在实现持久性,例如可以通过用特殊硬件(如电池供电的内存),或者通过将更改记录写入磁盘,或者将定期快照写入磁盘,以及复制内存中的状态到其他机器等方式来实现。
内存数据库的性能优势不是因为它们不需要磁盘读取而是它们避免使用写磁盘的格式对内存数据结构编码的开销。
除了性能外,内存数据库的另一个有意思的地方是,它提供了基于磁盘索引难以实现的某些数据模型。例如,Redis为各种数据结构(如优先级队列和集合)都提供了类似数数据库的访问接口。由于所有数据都保存在内存中,所以实现可以比较简单。
最近的研究表明,内存数据库架构可以扩展到支持远大于可用内存的数据集,而不会导致以磁盘为中心架构的开销。所谓的反缓存方法,当没有足够的内存时,通过将最少使用的数据从内存写到磁盘,并且将来再次被访问时将其加载到内存。这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以在记录级别而不是整个内存页的粒度工作,而因比操作系统更有效地管理内存。不过,这种方法仍然需要索引完全放入内存。
事务主要指组成一个逻辑单元的一组读取操作。数据库主要分为OLTP和OLAP两种模式,他们之间的区别有时并不是那么明确。
最初,相同的数据库可以同时用于事务处理和分析查询。后来,公司放弃使用OLTP系统用于分析目的,而是在单独的数据库上运行分析。这个单独的数据库被称为数据仓库。
从OLTP数据库(使用周期性数据转储或连续更新流)中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为提取0转换-加载(Extract-Transform-Load,ETL)
使用单独的数据仓库而不是直接查询OLTP系统进行分析,很大的有时在于数据仓库可以针对分析访问模式进行优化。事实证明,本章前半部分讨论的索引算法适合OLTP,但不擅长应对分析查询。
数据仓库的数据模型最常见的是关系型,因为SQL通常适合分析查询。有许多图形化数据分析工具,它们可以生成SQL查询、可视化结果并支持分析师探索数据,例如通过诸如向下钻取、切片和切丁等操作。
表面上,数据仓库和关系型OLTP数据库看起来相似,因为它们都具有SQL查询接口。然而,系统内部实则差异很大,它们针对迥然不同的查询模式进行了各自优化。许多数据库供应商现在专注事务处理或分析工作负载,但不能同时支持两者。
分析型业务的数据模型相对于事务型的数据模型要少很多,许多数据仓库都相当公式化的使用了星型模式,也称为维度建模。
星型模型中包括事实表和维度表,名称“星型模式“来源于当表关系可视化时,事实表位于中间,被一系列维度表包围;这些表的连接就像星星的光芒。雪花模式则是将维度进一步细分为子空间,更规范但更麻烦。
在典型的数据仓库中,表通常非常宽:事实表通常超过100例,有时候又几百列。维度表也可能非常宽,可能包括与分析相关的所有元数据。
如果事实表中有数以万亿行、PB大小的数据,则高效地存储和查询这些数据将成为一个具有挑战性的问题。维度表通常小得多(数百万行)
在大多数OLTP数据库中,存储以面向行的方式布局:来自表的一行的所有值彼此相邻存储。文档数据库也是类似的,整个文档通常被存储为一个连续的字节序列。虽然可以对需要使用的列加索引,但是这仍然需要将所有行(每个由超过100个属性组成)从磁盘加载到内存中、解析它们,并过滤出不符合所需条件的行。这可能需要很长时间。
虽然事实表通常超过100列,但典型的数据仓库查询往往一次只访问其中的4或5个。面向列存储的想法很简单:不要将一行中的所有值存储在一起,而是将每列中的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列,这可以节省大量的工作。
列存储在关系数据模型中最容易理解,但它同样适用于非关系数据。面向列的存储布局依赖一组列文件,每个文件以相同顺序保存着数据行。
除了仅从磁盘中加载查询所需的列之外,还可以通过压缩数据来进一步降低对磁盘吞吐量的要求。幸运的是,面向列的存储恰好非常适合压缩。
列存储的值序列看起来由很多重复,这是压缩的好兆头。取决于列中具体数据模式,可以采用不同的数据压缩技术。在数据仓库中特别有效的一种技术是位图编码。
当位图很稀疏的时候,可以进行游程编码,使得编码更加紧凑。
对于不同类型的数据,还有各种其他压缩方法。
分析数据库的开发人员需要关心如何高效地将内存的带宽用于CPU缓存,避免分支错误预测和CPU指令流水线中的气泡,并利用现代CPU中的单指令多数据(SIMD)指令。
除了减少需要从磁盘加载的数据量之外,面向列的存储布局也有利于高效利用CPU周期。列压缩使得列中更多的行可以加载到L1缓存。诸如先前描述的按位AND和OR的运算符,可被设计成直接对这样的列压缩数据块进行操作。这种技术被称为矢量化处理。
排序主要两个好处。第一点就是方便了查询,加快特定需求的查询速度。另一点就是帮助进一步压缩列。
基于第一个排序键的压缩效果通常最好。第二个和第三个排序键会使情况更加复杂,也通常不会有太多相邻的重复值。排序优先级进一步下降的列基本上会呈现接近随机的顺序,因此通常无法压缩。但总体来讲,对前几列排序仍然可以获得不错的收益。
不妨存储不同方式排序的冗余数据,以便在处理查询时,可以选择最适合特定查询模式的排序版本。
面向列的存储具有多个排序顺序,这有些类似在面向行的存储中具有多个二级索引。但最大的区别是,面向行的存储将每一行都保存在一个位置(在堆文件或聚簇索引中),而二级索引只包含指向匹配行的指针。而对于列存储,通常没有任何指向别处数据的指针,只有包含列的值。
上面的各种优化非常有助于加速读取查询,但是缺点是让写入更加困难。
对于压缩的列来说,原地更新是不可能的,在排序表中间插入一条数据的代价是很高的。解决办法是类似LSM-tree,先存在内存存储区,累积够了再写入磁盘。
执行查询时,需要检查磁盘上的列数据和内存中最近的写入,并结合这两者。而查询优化器可以对用户隐藏这些内部细节。从数据分析师的角度来看,插入、更新或删除数据可以立即反映在随后的查询中。
**数据仓库的另一个值得一提的是物化聚合。**数据仓库查询通常涉及聚合函数,如果许多不同查询使用相同的聚合,每次都处理原始数据将非常浪费。解决办法是为此创建缓存,物化视图。
物化视图常见的一种特殊情况被称为数据立方体或OLAP立方体。它是由不同维度分组的聚合网格。可以在聚合表格上进行聚合操作来减少维度,得到想要的结果。
物化数据立方体的优点是某些查询会非常快,主要是它们已被预先计算出来。缺点则是,数据立方体缺乏像查询原始数据那样的灵活性。
概括来讲,存储引擎分为两大类:针对事务处理(OLTP)优化的架构,以及针对分析型(OLAP)的优化架构。它们典型的访问模式存在很大差异:
日志结构的存储引擎是一个相对较新的方案。其关键思想是系统地将磁盘上随机访问写入转为顺序写入,由于硬盘驱动器和SSD的性能特性,可以实现更高的写入吞吐量。