第三章:存储与检索
当你把数据交给数据库时,它应当把数据存储起来;而后当你向数据库要数据时,它应当把数据返回给你。
驱动数据库的数据结构
我们可以简单的使用键值实现存储功能,然后保存在文本中。
底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对,文件尾部追加写入将有很好的性能。
许多数据库在内部使用了日志(log),也就是一个 仅追加(append-only) 的数据文件。
但是这样,如果数据库中有大量数据的时候,查找一个键性能会非常糟糕,必须从头到尾扫描整个数据库文件来查找键的出现。
为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。索引背后的大致思想是,保存一些额外的元数据作为路标,帮助你找到想要的数据。
索引是从主数据衍生的附加(additional)结构。维护额外的结构会产生开销,特别是在写入时。写入性能很难超过简单地追加写入文件,因为追加写入是最简单的写入操作。任何类型的索引通常都会减慢写入速度,因为每次写入数据时都需要更新索引。
精心选择的索引加快了读查询的速度,但是每个索引都会拖慢写入速度。
哈希索引
键值存储与在大多数编程语言中可以找到的字典(dictionary)类型非常相似,通常字典都是用散列映射(hash map)(或哈希表(hash table))实现的。
最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置。
图 - 以类CSV格式存储键值对的日志,并使用内存哈希映射进行索引
追加写入一个文件 —— 所以如何避免最终用完磁盘空间?一种好的解决方案是,将日志分为特定大小的段,当日志增长到特定尺寸时关闭当前段文件,并开始写入一个新的段文件。然后,我们就可以对这些段进行压缩(compaction)。压缩意味着在日志中丢弃重复的键,只保留每个键的最近更新。
图 - 压缩键值更新日志(统计猫视频的播放次数),只保留每个键的最近值
可以将压缩段再次合并成一个新的文件,段被写入后,永远不会被修改。合并过程完成后,我们将读取请求转换为使用新的合并段而不是旧段 —— 然后可以简单地删除旧的段文件。
图 - 同时执行压缩和分段合并
每个段现在都有自己的内存散列表,将键映射到文件偏移量。为了找到一个键的值,我们首先检查最近段的哈希映射;如果键不存在,我们检查第二个最近的段,依此类推。
文件格式
CSV不是日志的最佳格式。使用二进制格式更快。
删除记录
如果要删除一个键及其关联的值,则必须在数据文件(有时称为逻辑删除)中附加一个特殊的删除记录。当日志段被合并时,逻辑删除告诉合并过程放弃删除键的任何以前的值。
崩溃恢复
如果数据库重新启动,则内存散列映射将丢失。原则上,您可以通过从头到尾读取整个段文件并在每次按键时注意每个键的最近值的偏移量来恢复每个段的哈希映射。
部分写入记录
数据库可能随时崩溃,包括将记录附加到日志中途。
并发控制
由于写操作是以严格顺序的顺序附加到日志中的,所以常见的实现选择是只有一个写入器线程,但是可以被多个线程同时读取。
记录日志只能追加设计原因:
- 追加和分段合并是顺序写入操作,通常比随机写入快得多,尤其是在磁盘旋转硬盘上。在某种程度上,顺序写入在基于闪存的 固态硬盘(SSD) 上也是优选的。
- 如果段文件是附加的或不可变的,并发和崩溃恢复就简单多了。不必担心在覆盖值时发生崩溃的情况,而将包含旧值和新值的一部分的文件保留在一起。
- 合并旧段可以避免数据文件随着时间的推移而分散的问题。
哈希表索引局限性:
- 散列表必须能放进内存
- 范围查询效率不高
SSTables和LSM树
前面键值对按照写入顺序出现,现在我们按照键值对的键排序。
我们把这个格式称为排序字符串表(Sorted String Table),简称SSTable。还要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。
SSTable有几个很大的优势:
- 合并段是简单而高效的,即使文件大于可用内存。开始并排读取输入文件,查看每个文件中的第一个键,复制最低键(根据排序顺序)到输出文件,并重复。这产生一个新的合并段文件,也按键排序。
图 - 合并几个SSTable段,只保留每个键的最新值
- 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。假设你正在内存中寻找键 handiwork,但是你不知道段文件中该关键字的确切偏移量。然而,你知道 handbag 和 handsome 的偏移,而且由于排序特性,你知道 handiwork 必须出现在这两者之间。这意味着您可以跳到 handbag 的偏移位置并从那里扫描,直到您找到 handiwork。
图 - 具有内存索引的SSTable
- 由于读取请求无论如何都需要扫描所请求范围内的多个键值对,因此可以将这些记录分组到块中,并在将其写入磁盘之前对其进行压缩(图中的阴影区域所示) 。稀疏内存中索引的每个条目都指向压缩块的开始处。除了节省磁盘空间之外,压缩还可以减少IO带宽的使用。
构建和维护SSTables
我们可以使我们的存储引擎工作如下:
- 写入时,将其添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树有时被称为内存表(memtable)。
- 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。
- 为了提供读取请求,首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在下一个较旧的段中找到该关键字。
- 有时会在后台运行合并和压缩过程以组合段文件并丢弃覆盖或删除的值。
一个问题:如果数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失。为了避免这个问题,我们可以在磁盘上保存一个单独的日志,每个写入都会立即被附加到磁盘上。
用SSTables制作LSM树
在日志结构合并树(或LSM树)的基础上,建立在以前的工作上日志结构的文件系统。基于这种合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎。
Lucene是Elasticsearch和Solr使用的一种全文搜索的索引引擎,它使用类似的方法来存储它的词典。全文索引比键值索引复杂得多,但是基于类似的想法:在搜索查询中给出一个单词,找到提及单词的所有文档(网页,产品描述等)。这是通过键值结构实现的,其中键是单词(关键词(term)),值是包含单词(文章列表)的所有文档的ID的列表。在Lucene中,从术语到发布列表的这种映射保存在SSTable类的有序文件中,根据需要在后台合并。
性能优化
当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。为了优化这种访问,存储引擎通常使用额外的Bloom过滤器。 (布隆过滤器是用于近似集合内容的内存高效数据结构,它可以告诉您数据库中是否出现键,从而为不存在的键节省许多不必要的磁盘读取操作。
不同的策略来确定SSTables如何被压缩和合并的顺序和时间:
- 大小分层压实:在规模级别的调整中,更新和更小的SSTables先后被合并到更老的和更大的SSTable中。
- 水平压实:关键范围被拆分成更小的SSTables,而较旧的数据被移动到单独的“水平”,这使得压缩能够更加递增地进行,并且使用更少的磁盘空间。
LSM树的基本思想 —— 保存一系列在后台合并的SSTables —— 简单而有效。
即使数据集比可用内存大得多,它仍能继续正常工作。由于数据按排序顺序存储,因此可以高效地执行范围查询(扫描所有高于某些最小值和最高值的所有键),并且因为磁盘写入是连续的,所以LSM树可以支持非常高的写入吞吐量。
B树
像SSTables一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。但是B树有着非常不同的设计理念。
B树将数据库分解成固定大小的块或页面,传统上大小为4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为磁盘也被安排在固定大小的块中。
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树。
图 - 使用B树索引查找一个键
一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。该页面包含几个键和对子页面的引用。每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。
在B树的一个页面中对子页面的引用的数量称为分支因子。分支因子取决于存储页面参考和范围边界所需的空间量,但通常是几百个。
如果要更新B树中现有键的值,则搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘(对该页的任何引用保持有效) 。
如果你想添加一个新的键,你需要找到其范围包含新键的页面,并将其添加到该页面。如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区。
图 - 通过分割页面来生长B树
该算法确保树保持平衡:具有 n 个键的B树总是具有 $O(log n)$ 的深度。大多数数据库可以放入一个三到四层的B树,所以你不需要遵追踪多页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)
让B树更可靠
B树的基本底层写操作是用新数据覆盖磁盘上的页面。
B树实现通常会带有一个额外的磁盘数据结构:预写式日志(WAL, write-ahead-log)(也称为重做日志(redo log))。
这是一个仅追加的文件,每个B树修改都可以应用到树本身的页面上。当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态。
如果多个线程要同时访问B树,则需要仔细的并发控制 —— 否则线程可能会看到树处于不一致的状态。这通常通过使用锁存器(latches)(轻量级锁)保护树的数据结构来完成。
B树优化
- 一些数据库(如LMDB)使用写时复制方案,而不是覆盖页面并维护WAL进行崩溃恢复。修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新的位置。
- 我们可以通过不存储整个键来节省页面空间,但可以缩小它的大小。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此更少的层次
- 通常,页面可以放置在磁盘上的任何位置;没有什么要求附近的键范围页面附近的磁盘上。如果查询需要按照排序顺序扫描大部分关键字范围,那么每个页面的布局可能会非常不方便,因为每个读取的页面都可能需要磁盘查找。因此,许多B树实现尝试布局树,使得叶子页面按顺序出现在磁盘上。但是,随着树的增长,维持这个顺序是很困难的。相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在磁盘上彼此靠近。
- 额外的指针已添加到树中。例如,每个叶子页面可以在左边和右边具有对其兄弟页面的引用,这允许不跳回父页面就能顺序扫描。
- B树的变体如分形树借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)。
比较B树和LSM树
B树实现通常比LSM树实现更成熟。
通常LSM树的写入速度更快,而B树的读取速度更快。
LSM树上的读取通常比较慢,因为它们必须在压缩的不同阶段检查几个不同的数据结构和SSTables。
LSM树的优点
B树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面本身(也许再次分页)。
由于反复压缩和合并SSTables,日志结构索引也会重写数据。在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为写放大(write amplification)。
在写入繁重的应用程序中,性能瓶颈可能是数据库可以写入磁盘的速度。在这种情况下,写放大会导致直接的性能代价:存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。
LSM树通常能够比B树支持更高的写入吞吐量,部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎配置和工作负载),部分是因为它们顺序地写入紧凑的SSTable文件而不是必须覆盖树中的几个页面。这种差异在磁性硬盘驱动器上尤其重要,顺序写入比随机写入快得多。
LSM树可以被压缩得更好,因此经常比B树在磁盘上产生更小的文件。
B树存储引擎会由于分割而留下一些未使用的磁盘空间:当页面被拆分或某行不能放入现有页面时,页面中的某些空间仍未被使用。由于LSM树不是面向页面的,并且定期重写SSTables以去除碎片,所以它们具有较低的存储开销,特别是当使用平坦压缩时。
低的写入放大率和减少的碎片对SSD仍然有利:更紧凑地表示数据可在可用的I/O带宽内提供更多的读取和写入请求。
LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。尽管存储引擎尝试逐步执行压缩而不影响并发访问,但是磁盘资源有限,所以很容易发生请求需要等待而磁盘完成昂贵的压缩操作。
压缩的另一个问题出现在高写入吞吐量:磁盘的有限写入带宽需要在初始写入(记录和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。
B树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。
在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在B树索引中,这些锁可以直接连接到树。
其他索引结构
主键(primary key) 索引:主键唯一标识关系表中的一行,或文档数据库中的一个文档或图形数据库中的一个顶点。数据库中的其他记录可以通过其主键(或ID)引用该行/文档/顶点,并且索引用于解析这样的引用。
二级索引可以很容易地从一个键值索引构建。主要的不同是键不是唯一的。
将值存储在索引中
索引中的关键字是查询搜索的内容,但是该值可以是以下两种情况之一:
- 所讨论的实际行(文档,顶点)
- 对存储在别处的行的引用:行被存储的地方被称为堆文件(heap file),并且存储的数据没有特定的顺序(它可以是仅附加的,或者可以跟踪被删除的行以便用新数据覆盖它们)。
堆文件方法很常见,因为它避免了在存在多个二级索引时复制数据:每个索引只引用堆文件中的一个位置,实际的数据保存在一个地方。
在不更改键的情况下更新值时,堆文件方法可以非常高效:只要新值不大于旧值,就可以覆盖该记录。如果新值更大,可能需要移到堆中有足够空间的新位置。在这种情况下,要么所有的索引都需要更新,以指向记录的新堆位置,或者在旧堆位置留下一个转发指针。
聚集索引:从索引到堆文件的额外跳跃对读取来说性能损失太大,因此可能希望将索引行直接存储在索引中。
在 聚集索引(clustered index) (在索引中存储所有行数据)和 非聚集索引(nonclustered index) (仅在索引中存储对数据的引用)之间的折衷被称为 包含列的索引(index with included columns)或覆盖索引(covering index),其存储表的一部分在索引内。这允许通过单独使用索引来回答一些查询(这种情况叫做:索引 覆盖(cover) 了查询)。
聚簇和覆盖索引可以加快读取速度,但是它们需要额外的存储空间,并且会增加写入开销。
多列索引
连接索引(concatenated index):通过将一列的值追加到另一列后面,简单地将多个字段组合成一个键(索引定义中指定了字段的连接顺序)。
多维索引(multi-dimensional index) :一种查询多个列的更一般的方法,这对于地理空间数据尤为重要。
全文搜索和模糊索引
全文搜索引擎通常允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法变体,并且搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他功能取决于文本的语言分析。为了处理文档或查询中的拼写错误,Lucene能够在一定的编辑距离内搜索文本(编辑距离1意味着添加,删除或替换了一个字母)。
Lucene为其词典使用了一个类似于SSTable的结构。这个结构需要一个小的内存索引,告诉查询在排序文件中哪个偏移量需要查找关键字。在Lucene中,内存中的索引是键中字符的有限状态自动机,类似于trie。
在内存中存储一切
磁盘有两个显著的优点:它们是耐用的(它们的内容在电源关闭时不会丢失),并且每GB的成本比RAM低。
内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作耐久性附加日志,读取完全由内存提供。写入磁盘也具有操作优势:磁盘上的文件可以很容易地由外部实用程序进行备份,检查和分析。
内存数据库的性能优势并不是因为它们不需要从磁盘读取,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。
所谓的 反缓存(anti-caching) 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到磁盘,并在将来再次访问时将其重新加载到内存中。
事务处理还是分析?
事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。事务处理只是意味着允许客户端进行低延迟读取和写入 —— 而不是批量处理作业,而这些作业只能定期运行(例如每天一次)。
据用户的输入插入或更新记录。由于这些应用程序是交互式的,因此访问模式被称为 在线事务处理(OLTP, OnLine Transaction Processing) 。
数据库也开始越来越多地用于数据分析,这些数据分析具有非常不同的访问模式。这些查询通常由业务分析师编写,并提供给帮助公司管理层做出更好决策(商业智能)的报告。为了区分这种使用数据库的事务处理模式,它被称为在线分析处理(OLAP, OnLine Analytice Processing)。
属性 |
事务处理 OLTP |
分析系统 OLAP |
主要读取模式 |
查询少量记录,按键读取 |
在大批量记录上聚合 |
主要写入模式 |
随机访问,写入要求低延时 |
批量导入(ETL),事件流 |
主要用户 |
终端用户,通过Web应用 |
内部数据分析师,决策支持 |
处理的数据 |
数据的最新状态(当前时间点) |
随时间推移的历史事件 |
数据集尺寸 |
GB ~ TB |
TB ~ PB |
表 - 比较交易处理和分析系统的特点
SQL在这方面证明是非常灵活的:对于OLTP类型的查询以及OLAP类型的查询来说效果很好。
在二十世纪八十年代末和九十年代初期,公司有停止使用OLTP系统进行分析的趋势,而是在单独的数据库上运行分析。这个单独的数据库被称为数据仓库(data warehouse)。
数据仓库
OLTP系统往往对业务运作至关重要,因而通常会要求 高可用 与 低延迟。通常不会在OLTP数据库上运行临时分析查询,因为这些查询通常开销巨大,会扫描大部分数据集,这会损害同时执行的事务的性能。
数据仓库是一个独立的数据库,查询分析想要的内容而不影响OLTP操作。数据仓库包含公司各种OLTP系统中所有的只读数据副本。从OLTP数据库中提取数据(使用定期的数据转储或连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。将数据存入仓库的过程称为“抽取-转换-加载(ETL)”。
图 - ETL至数据仓库的简化提纲
使用单独的数据仓库,而不是直接查询OLTP系统进行分析的一大优势是数据仓库可针对分析访问模式进行优化。
OLTP数据库和数据仓库之间的分歧
数据仓库的数据模型通常是关系型的,因为SQL通常很适合分析查询。
表面上,一个数据仓库和一个关系OLTP数据库看起来很相似,因为它们都有一个SQL查询接口。然而,系统的内部看起来可能完全不同,因为它们针对非常不同的查询模式进行了优化。
Teradata,Vertica,SAP HANA和ParAccel等数据仓库供应商通常使用昂贵的商业许可证销售他们的系统。
星型和雪花型:分析的模式
在事务处理领域中使用了大量不同的数据模型。
在分析中,数据模型的多样性则少得多。许多数据仓库都以相当公式化的方式使用,被称为星型模式(也称为维度建模)
图 - 用于数据仓库的星型模式的示例
在模式的中心是一个所谓的事实表(在这个例子中,它被称为 fact_sales)。事实表的每一行代表在特定时间发生的事件(这里,每一行代表客户购买的产品)。如果我们分析的是网站流量而不是零售量,则每行可能代表一个用户的页面浏览量或点击量。
事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。
事实表中的一些列是属性,其他列是对其他表(称为维表)的外键引用。
“星型模式”这个名字来源于这样一个事实,即当表关系可视化时,事实表在中间,由维表包围;与这些表的连接就像星星的光芒。
这个模板的变体被称为雪花模式,其中尺寸被进一步分解为子尺寸。雪花模式比星形模式更规范化,但是星形模式通常是首选,因为分析师使用它更简单。
表格通常非常宽泛:事实表格通常有100列以上,有时甚至有数百列。维度表也可以是非常宽的,因为它们包括可能与分析相关的所有元数据。
列存储
在大多数OLTP数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库是相似的:整个文档通常存储为一个连续的字节序列。
当查询时,可能会有部分列上具有索引,它们告诉存储引擎在哪里查找需要的内容。但是,面向行的存储引擎仍然需要将所有这些行(每个包含超过100个属性)从磁盘加载到内存中,解析它们,并过滤掉那些不符合要求的条件。
面向列的存储:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。
图 - 使用列存储关系型数据,而不是行
面向列的存储布局依赖于包含相同顺序行的每个列文件。 因此,如果您需要重新组装整行,您可以从每个单独的列文件中获取第23项,并将它们放在一起形成表的第23行。
列压缩
面向列的存储通常很适合压缩。
图 - 压缩位图索引存储布局
通常情况下,一列中不同值的数量与行数相比较小(例如,零售商可能有数十亿的销售交易,但只有100,000个不同的产品)。现在我们可以得到一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值的一个位图,每行一位。如果该行具有该值,则该位为 1 ,否则为 0 。
如果 n 非常小(例如,国家/地区列可能有大约200个不同的值),则这些位图可以每行存储一位。但是,如果n更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外进行游程编码,如图底部所示。这可以使列的编码非常紧凑。
这些位图索引非常适合数据仓库中常见的各种查询。例如:
WHERE product_sk IN(30,68,69)
加载 product_sk = 30 , product_sk = 68 , product_sk = 69 的三个位图,并计算三个位图的按位或,这可以非常有效地完成。
WHERE product_sk = 31 AND store_sk = 3
加载 product_sk = 31 和 store_sk = 3 的位图,并逐位计算AND。 这是因为列按照相同的顺序包含行,因此一列的位图中的第 k 位对应于与另一列的位图中的第 k 位相同的行。
面向列的存储和列族
Cassandra和HBase有一个列族的概念,他们从Bigtable继承。然而,把它们称为面向列是非常具有误导性的:在每个列族中,它们将一行中的所有列与行键一起存储,并且不使用列压缩。因此,Bigtable模型仍然主要是面向行的。
内存带宽和向量处理
对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从磁盘获取数据到内存的带宽。但是,这不是唯一的瓶颈。分析数据库的开发人员也担心有效利用主存储器带宽到CPU缓存中的带宽,避免CPU指令处理流水线中的分支错误预测和泡沫,以及在现代中使用单指令多数据(SIMD)指令CPU 。
除了减少需要从磁盘加载的数据量以外,面向列的存储布局也可以有效利用CPU周期。例如,查询引擎可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即没有函数调用)。一个CPU可以执行这样一个循环比代码要快得多,这个代码需要处理每个记录的大量函数调用和条件。列压缩允许列中的更多行适合相同数量的L1缓存。前面描述的按位“与”和“或”运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为矢量化处理。
列存储中的排序顺序
每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行。我们只能重建一行,因为我们知道一列中的第k项与另一列中的第k项属于同一行。
按列存储数据,也需要一次对整行进行排序。可以使用常见查询的字段来选择表格应该被排序的列。
第二列可以确定第一列中具有相同值的任何行的排序顺序。
排序顺序的另一个好处是它可以帮助压缩列。如果主要排序列没有多个不同的值,那么在排序之后,它将具有很长的序列,其中相同的值连续重复多次。
第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长时间的重复值。排序优先级下面的列以基本上随机的顺序出现,所以它们可能不会被压缩。但前几列排序仍然是一个整体。
几个不同的排序顺序
在一个面向列的存储中有多个排序顺序有点类似于在一个面向行的存储中有多个二级索引。但最大的区别在于面向行的存储将每一行保存在一个地方(在堆文件或聚簇索引中),二级索引只包含指向匹配行的指针。在列存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。
写入列存储
使用B树的更新对于压缩的列是不可能的。由于行由列中的位置标识,因此插入必须始终更新所有列。
一个很好的解决方案:LSM树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入磁盘。内存中的存储是面向行还是列的,这并不重要。当已经积累了足够的写入数据时,它们将与磁盘上的列文件合并,并批量写入新文件。这基本上是Vertica所做的。
聚合:数据立方体和物化视图
物化视图:在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,写入磁盘,而虚拟视图只是写入查询的捷径。从虚拟视图读取时,SQL引擎会将其展开到视图的底层查询中,然后处理展开的查询。
当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。这将增加写入成本,这就是在OLTP数据库中不经常使用物化视图的原因。但是在读取频繁的数据仓库中,这是有意义的。
物化视图的常见特例称为数据立方体或OLAP立方。它是按不同维度分组的聚合网格。
图 - 数据立方的两个维度,通过求和聚合
绘制一个二维表格,一个轴线上是日期和另一个轴上是产品。每个单元包含具有该日期 - 产品组合的所有属性(例如,net_price)的聚集(例如,SUM)。然后,可以沿着每行或每列应用相同的汇总,并获得一个维度减少的汇总。
物化数据立方体还可以是多维度的。
物化数据立方体的优点是某些查询变得非常快,因为它们已经被有效地预先计算了。
缺点是数据立方体不具有查询原始数据的灵活性。大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升。