【读书笔记】《数据密集型应用系统设计》第3章 数据存储与检索
引言
如果你把东西整理得井井有条,下次就不用查找了。
——德国谚语
本章我们主要从数据库的角度来探讨,如何存储输入的数据,并在收到查询请求时,怎样重新找到数据。
特别地,针对事务型工作负载和针对分析型负载的存储引擎优化存在很大的差异。本章“事务处理与分析处理”和“面向列的存储”部分,将讨论一系列针对分析型进行优化的存储引擎。
我们首先讨论存储引擎,这些存储引擎用于大家比较熟悉的两种数据库,即传统的关系数据库和大多数所谓的NoSQL数据库。我们将研究两个存储引擎家族 ,即日志结构的存储引擎和面向页的存储引擎,比如B-tree 。
数据库核心:数据结构
我们来看一个世界上最简单的数据库,它由两个Bash函数实现:
这两个函数实现了一个key-value存储 。当调用db_set key value,它将在数据库中保存你所输入的key和value。它底层的存储格式其实非常简单 :一个纯文本文件。每次调用 db_set 即追加新内容到文件末尾,因此,如果多次更新某个键,旧版本的值不会被覆盖,而是需要查看文中最后一次出现的键来找到最新的值。
对于简单的情况,追加到文件尾部方式通常足够高效,因而 db_set 函数性能很好。与 db_set 相似,许多数据库内部都使用日志(log),日志是一个仅支持追加式更新的数据文件。另一方面,如果日志文件保存了大量的记录,那么 db_get 函数的性能会非常差。在算法术语中,查找的开销是 O(n),即如果数据库的记录条数加倍,则查找需要两倍的时间。
为了高效地查找数据库中特定键的值,需要新的数据结构:索引。
索引背后的基本想法都是保留一些额外的元数据,这些元数据作为路标,帮助定位想要的数据。如果希望用几种不同的方式搜索相同的数据,在数据的不同部分,我们可能定义多种不同的索引。
索引是基于原始数据报生而来的额外数据结构。很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的结构势必会引入开销,特别是在新数据写入时。对于写人,它很难超过简单地追加文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据时,需要更新索引,因此任何类型的索引通常都会降低写的速度。
这里涉及存储系统中重要的权衡设计 : 适当的索引可以加速读取查询,但每个索引都会减慢写速度。
哈希索引
首先我们以键-值数据的索引开始 。 key-value类型并不是唯一可以索引的数据,但它随处可见,而且是其他更复杂索引的基础构造模块。
假设数据存储全部采用追加式文件组成,那么最简单的索引策略就是 : 保存内存中的hashmap,把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置,如图 3-1 所示。
这听起来可能过于简单,但它的确是一个可行的方法。事实上,这就是Bitcask (Riak中的默认存储引擎)所采用的核心做法。Bitcask可以提供高性能的读和写,只要所有的key可以放入内存(因为hashmap需要保存在内存中)。而va lue数据量则可以超过内存大小,只需一次磁盘寻址, 就可以将value从磁盘加载到内存。 如果那部分数据文件已经在文件系统的缓存中,则读取根本不需要任何的磁盘I/O。
像Bitcask这样的存储引擎非常适合每个键的值频繁更新的场景。
如上所述,只追加到一个文件,那么如何避免最终用尽磁盘空间? 一个好的解决方案是将日志分解成一定大小的段, 当文件达到一定大小时就关闭它, 井将后续写入到新的段文件中。 然后可以在这些段上执行压缩,如图3-2所示。 压缩意味着在日志中丢弃重复的键, 并且只保留每个键最近的更新。
此外,由于压缩往往使得段更小(假设键在段内被覆盖多次), 也可以在执行压缩的同时将多个段合并在一起,如图3-3所示。
由于段在写入后不会再进行修改,所以合并的段会被写入另一个新的文件。对于这些冻结段的合井和压缩过程可以在后台线程中完成,而且运行时,仍然可以用旧的段文件继续正常读取和写请求。当合并过程完成后,将读取请求切换到新的合并段上,而旧的段文件可以安全删除。
每个段现在都有自己的内存哈希表,将键映射到文件的偏移量。 为了找到键的值,首先检查最新的段的hashmap;如果键不存在,检查第二最新的段,以此类推。由于合并过程可以维持较少的段数量 ,因此查找通常不需要检查很多 hashmap。
一个追加的日志乍看起来似乎很浪费空间:为什么不原地更新文件,用新值覆盖旧值?但是,结果证明追加式的设计非常不错,主要原因有以下几个:
-
追加和分段合并主要是顺序写,它通常比随机写入快得多,特别是在旋转式磁性硬盘上。在某种程度上,顺序写入在基于闪存的固态硬盘(solid state drives, SSD)上也是适合的。
-
如果段文件是追加的或不可变的,则并发和崩溃恢复要简单得多。例如,不必担心在重写值时发生崩溃的情况,留下一个包含部分旧值和部分新值混杂在一起的文件。
-
合并旧段可以避免随着时间的推移数据文件出现碎片化的问题。
但是,哈希表索引也有其局限性:
-
哈希表必须全部放入内存,所以如果有大量的键,就没那么幸运了。在磁盘上维护hashmap需要大量的随机访问I/O ,当哈希变满时,继续增长代价昂贵,井且哈希冲突时需要复杂的处理逻辑。
-
区间查询效率不高。
SSTables和LSM-tree
现在简单地改变段文件的格式:要求key-value对的顺序按键排序。乍一看,这个要求似乎打破了顺序写规则。
这种格式称为排序字符串表,或简称为SSTable 。它要求每个键在每个合并的段文件中只能出现一次(压缩过程已经确保了)。SSTable相比哈希索引的日志段,具有以下优点:
-
合并段更加简单高效,即使文件大于可用内存。方法类似于合并排序算法中使用的方法,如图3-4所示 。并发读取多个输入段文件,比较每个文件的第一个键, 把最小的键(根据排序顺序)拷贝到输出文件,并重复这个过程。这会产生一个新的按键排序的合并段文件。
如果相同的键出现在多个输入段怎么办?当多个段包含相同的键时,可以保留最新段的值,丢并弃旧段中的值。 -
在文件中查找特定的键时,不再需要在内存中保存所有键的索引。如果知道前后相似键的偏移量,并根据键排序,则可根据范围跳转到目标键。所以,仍然需要一个内存索引来记录某些键的偏移,但它可以是稀疏的,对于段文件中每几千字节,只需要一个键就足够了。
-
由于读请求往往需要扫描请求范围内的多个key value对,可以考虑将这些记录保存到一个块中并在写磁盘之前将其压缩(如图 3-5 中阴影区域所示)。然后稀疏内存索引的每个条目指向压缩块的开头。除了节省磁盘空间,压缩还减少了 IO 带宽的占用。
构建和维护SSTables
考虑到写入可能以任意顺序出现,首先该如何让数据按键排序呢?
在磁盘上维护排序结构是可行的(参阅本章后面的“ B-trees”),不过,将其保存在内存中更容易。内存排序有很多广为人知的树状数据结构,例如红黑树或AVL树。使用这些数据结构,可以按任意顺序插入键并以排序后的顺序,读取它们。
存储引擎的基本工作流程如下 :
-
当写入时,将其添加到内存中的平衡树数据结构中(例如红黑树)。这个内存中的树有时被称为内存表。
-
当内存表大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入磁盘。由于树已经维护了按键排序的 key - value对, 写磁盘可以比较高效。新的 SSTable 文件成为数据库的最新部分。当 SSTable 写磁盘的同时,写入可以继续添加到一个新的内存表实例 。
-
为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)。
-
后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值。
上述方案可以很好地工作。但它还存在一个问题 : 如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。为了避免该问题,可以在磁盘上保留单独的日志,每个写入都会立即追加到该日志,就像上一节所述。那个日志文件不需要按键排序,这并不重要,因为它的唯一目的是在崩愤后恢复内存表。每当将内存表写入 SSTable 时,相应的日志可以被丢弃。
从SSTables到LSM-tree
最初这个索引结构 由 Patrick O’Neil 等人以日志结构的合井树( Log-Structured Merge-Tree ,或LSM-Tree) 命名,它建立在更早期的日志结构文件系统之上。因此,基于合井和压缩排序文件原理的存储引擎通常都被称为LSM存储引擎。
性能优化
当查找数据库中某个不存在的键时,LSM-Tree算法可能很慢:在确定键不存在之前, 必须先检查内存表,然后将段一直回溯访问到最旧的段文件(可能必须从磁盘多次读取)。为了优化这种访问,存储引擎通常使用额外的布隆过滤器。
还有不同的策略会影响甚至决定 SSTables 压缩和合并时的具体顺序和时机 。最常见的方式是大小分级和分层压缩。在大小分级的压缩中,较新的和较小的 SSTables 被连续合并到较旧和较大的 SSTables 。在分层压缩中,键的范围分裂成多个更小的 SSTables ,旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘空间。
B-trees
像SSTable一样,B-tree保留按键排序的key value对,这样可以实现高效的key-value查找和区间查询。但相似仅此而已: B-tree本质上具有非常不同的设计理念。
B-tree将数据库分解成固定大小的块或页,传统上大小为4KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。
每个页面都可以使用地址或位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过是指向磁盘地址,而不是内存。可以使用这些页面引用来构造一个树状页面,如图 3-6 所示。
某一页被指定为B-tree的根:每当查找索引中的一个键时,总是从这里开始。该页面包含若干个键和对子页的引用。每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。
B-tree 中一个页所包含的子页引用数量称为分支因子。例如,在图 3-6 中,分支因子为6。在实际中,分支因子取决于存储页面引用和范围边界所需的空间总量, 通常为几百个。
如果要更新 B-tree 中现有键的值,首先搜索包含该键的叶子页,更改该页的值, 并将页写回到磁盘(对该页的任何 引用仍然有效)。如果要添加新键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围, 如图3-7所示。
该算法确保树保持平衡:具有n个键的B-tree总是具有O (logn)的深度。 大多数数据库可以适合3~4层的B-tree,因此不需要遍历非常深的页面层次即可找到所需的页(分支因子为500的4KB页的四级树可以存储高达256TB)。
使B-tree可靠
B-tree底层的基本写操作是使用新数据覆盖磁盘上的旧页。它假设覆盖不会改变页的磁盘存储位置,也就是说,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引(如LSM-tree )形成鲜明对比 LSM-tree仅追加更新文件(并最终删除过时的文件),但不会修改文件。
此外,某些操作需要覆盖多个不同的页。例如,如果插入导致页溢出,因而需分裂页,那么需要写两个分裂的页,并且覆盖其父页以更新对两个子页的引用。这是个比较危险的操作,因为如果数据库在完成部分页写入之后发生崩溃,最终会导致索引破坏(例如,可能有一个孤儿页,没有被任何其他页所指向)。
为了使数据库能从崩溃中恢复,常见B-tree的实现需要支持磁盘上的额外的数据结构: 预写日志( write-ahead log, WAL ),也称为重做日志。这是一个仅支持追加修改的文件,每个B-tree的修改必须先更新WAL然后再修改树本身的页。当数据库在崩溃后需要恢复时 ,该日志用于将B-tree恢复到最近一致的状态。
原地更新页的另一个复杂因素是,如果多个线程要同时访问B-tree , 则需要注意并发控制 ,否则线程可能会看到树处于不一致的状态。通常使用锁存器(轻量级的锁)保护树的数据结构来完成。在这方面,日志结构化的方法显得更简单,因为它们在后台执行所有合井,而不会干扰前端的查询,并且会不时地用新段原子地替换旧段。
优化B-tree
由于B-tree 已经存在了很长时间 ,自然多年来开发了许多优化措施 。这里只列举一些:
-
一些数据库( 如LMDB )不使用覆盖页和维护WAL来进行崩愤恢复,而是使用写时复制方案。修改的页被写人不同的位置,树中父页的新版本被创建,并指向新的位置。
-
保存键的缩略信息,而不是完整的键,这样可以节省页空间。特别是在树中间的页中,只需要提供足够的信息来描述键的起止范围。这样可以将更多的键压入到页中,让树具有更高的分支因子,从而减少层数。
-
许多B-tree的实现尝试对树进行布局,以便相邻叶子页可以按顺序保存在磁盘上。 然而,随着树的增长,维持这个顺序会变得越来越困难。
-
添加额外的指针到树中。 例如,每个叶子页面可能会向左和向右引用其同级的兄弟页,这样可以顺序扫描键,而不用跳回到父页。(B+树?)
对比B-tree和LSM-tree
根据经验, LSM-tree通常对于写入更快,而B-tree被认为对于读取更快。 读取通常在LSM-tree上较慢, 因为它们必须在不同的压缩阶段检查多个不同的数据结构和SSTable。
LSM-tree的优点
B-tree索引必须至少写两次数据:一次写入预写日志,一次写入树的页本身(还可能发生页分裂)。即使该页中只有几个字节更改,也必须承受写整个页的开销。
由于反复压缩和SSTable的合井,日志结构索引也会重写数据多次。 这种影响( 在数据库内, 由于一次数据库写入请求导致的多次磁盘写)称为写放大。 对于SSD, 只能承受有限次地擦除覆盖,因此尤为关注写放大指标。
对于大量写密集的应用程序, 性能瓶颈很可能在于数据库写入磁盘的速率。 在这种情况下,写放大具有直接的性能成本:存储引擎写入磁盘的次数越多,可用磁盘带宽中每秒可以处理的写入越少。
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索引中,这些锁可以直接定义到树中。
其他索引结构
到目前为止,只讨论了 key-value 索引,它们像关系模型中的主键(primary key)索引。
二级索引也很常见 。在关系数据库中,可以使用 CREATE INDEX 命令在同一个表上创建多个二级索引,井且它们通常对于高效地执行联结操作至关重要。二级索引可以容易地基于 key-value 索引来构建 。主要区别在于它的键不是唯一的,即可能有许多行(文档,顶点)具有相同键 。这可以通过两种方式解决:使索引中的每个值成为匹配行标识符的列表(像全文索引中的posting list ),或者追加一些行标识符来使每个键变得唯一 。无论那种方式, B-tree和日志结构索引都可以用作二级索引。
在索引中存储值
索引中的键是查询搜索的对象,而值则可以是以下两类之一 :它可能是上述的实际行(文档,顶点),也可以是对其他地方存储的行的引用。在后一种情况下,存储行的具体位置被称为堆文件,井且它不以特定的顺序存储数据(它可以是追加的,或者记录删掉的行以便用新数据在之后覆盖它们) 。堆文件方法比较常见, 这样当存在多个二级索引时,它可以避免复制数据,即每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。
当更新值而不更改键时,堆文件方法会非常高效:只要新值的字节数不大于旧值,记录就可以直接覆盖。如果新值较大,则情况会更复杂,它可能需要移动数据以得到一个足够大空间的新位置。在这种情况下,所有索引都需要更新以指向记录的新的堆位置 , 或者在旧堆位置保留一个间接指针。
在某些情况下,从索引到堆文件的额外跳转对于读取来说意味着太多的性能损失,因此可能希望将索引行直接存储在索引中。这被称为聚集索引。例如,在MySQL InnoDB 存储引擎中,表的主键始终是聚集索引, 二级索引引用主键。
聚集索引(在索引中直接保存行数据 )和非聚集索引(仅存储索引中的数据的引用) 之间有一种折中设计称为覆盖索引,它在索引中保存一些表的列值。它可以支持只通过索引即可回答某些简单查询(在这种情况下,称索引覆盖了查询)。
与任何类型的数据冗余一样,聚集和覆盖索引可以加快读取速度,但是它们需要额外的存储,并且会增加写入的开销 。此外,数据库还需要更多的工作来保证事务性,这样应用程序不会因为数据冗余而得到不一致的结果。
多列索引
如果需要同时查询表的多个列(或文档中的多个字段),那么索引只将一个键映射到一个值是不够的。
最常见的多列索引类型称为级联索引,它通过将一列追加到另一列,将几个字段简单地组合成一个键(索引的定义指定字段连接的顺序)。类似电话簿先查姓再查同姓中的名。
多维索引是更普遍的一次查询多列的方法,这对地理空间数据尤为重要(经纬度-->多列范围查询)。标准的B-tree或LSM-tree只能提供一个维度范围内或者一个精度范围内,无法同时经纬度范围内。更常见的是使用专门的空间索引,如R树。还有很多例子如电商网站同时搜索红、蓝、绿三个维度的颜色范围内的产品等。
全文索引和模糊索引
到目前为止讨论的所有索引都假定具有确切的数据,并允许查询键的确切值或排序的键的取值范围。它们不支持搜索类似的键,如拼写错误的单词。这种模糊查询需要不同的技术。
如上节“从SSTables到LSM-tree”所述,Lucene对其词典使用类似SSTable的结构。此结构需要一个小的内存索引来告诉查询,为了找到一个键,需要排序文件中的哪个偏移量。在LevelDB中,这个内存中的索引是一些键的稀疏集合,但是在Lucene中,内存中的索引是键中的字符序列的有限状态自动机 ,类似字典树。这个自动机可以转换成Levenshtein自动机,它支持在给定编辑距离内高效地搜索单词。
在内存中保存所有内容
本章迄今为止讨论的数据结构都是为了适应磁盘限制。与内存相比,磁盘更难以处理。
一些内存中的key-value存储(如Memcached ), 主要用于缓存,如果机器重启造成的数据丢失是可以接受的。但是其他内存数据库旨在实现持久性,例如可以通过用特殊硬件(如电池供电的内存),或者通过将更改记录写入磁盘,或者将定期快照写入磁盘,以及复制内存中的状态到其他机器等方式来实现。
当内存数据库重启时,它需要重新载入其状态,无论是从磁盘还是通过网络从副本 (除非使用特殊硬件)。尽管写入磁盘,但磁盘仅仅用作为了持久性目的的追加日志,读取完全靠内存服务。此外,写入磁盘还具有-些运维方面优势:磁盘上的文件可以容易地通过外部工具来执行备份、检查和分析。
与直觉相反,内存数据库的性能优势并不是因为它们不需要从磁盘读取。如果有足够的内存,即使是基于磁盘的存储引擎 ,也可能永远不需要从磁盘读取,因为操作系统将最近使用的磁盘块缓存在内存中。相反,内存数据库可以更快,是因为它们避免使用写磁盘的格式对内存数据结构编码的开销。
除了性能外,内存数据库的另 一个有意思的地方是,它提供了基于磁盘索引难以实现的某些数据模型。例如, Redis为各种数据结构(如优先级队列和集合 )都提供了类似数据库的访问接口。由于所有的数据都保存在内存中,所以实现可以比较简单。
事务处理与分析处理
在商业数据处理的早期阶段,写人数据库通常对应于商业交易场景,例如销售、订单、支付员工工资等。尽管后来数据库扩展到了不涉及金钱交易的领域,事务一词仍然存在,主要指组成一个逻辑单元的一组读写操作。(事务不一定具有ACID)
根据用户的输入插入或更新记录。因为这些应用程序是交互式的, 所以访问模式被称为在线事务处理( onlin巳 transacti on processing, OLTP )。
一些查询通常由业务分析师编写(当月销量、促销期间销量、购买组合倾向等), 以形成有助于公司管理层更好决策(商业智能)的报告。为了区分使用数据库与事务处理的模式,称之为在线分析处理( online analyticprocessing , OLAP)
SQL被证明是非 常灵活的,可以同时胜任OLTP类型和OLAP类型查询。然而,在20世纪80年代后期和90年代初期的一种趋势是,公司放弃使用OLTP系统用于分析目的,而是在单独的数据库上运行分析。这个单独的数据库被称为数据仓库。
数据仓库
由于这些OLTP系统对于业务的运行至关重要,所以往往期望它们高度可用,数据库管理员通常不愿意让业务分析人员在OLTP数据库上直接运行临时分析查询,这些查询通常代价很高,要扫描大量数据集,这可能会损害并发执行事务的性能。
相比之下,数据仓库则是单独的数据库,分析人员可以在不影响OLTP操作的情况下尽情地使用。数据仓库包含公司所有各种OLTP系统的只读副本。从OLTP数据库(使用周期性数据转储或连续更新流) 中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为提取-转换-加载 (Extract-Transform-Load, ETL ),如图3-8所示。
OLTP数据库和数据仓库之间的差异
数据仓库的数据模型最常见的是关系型,因为SQL通常适合分析查询。有许多图形化数据分析工具,它们可以生成SQL查询、可视化结果并支持分析师探索数据,例如通过诸如向下钻取、切片和切丁等操作 。
表面上 ,数据仓库和关系型OLTP数据库看起来相似,因为它们都具有SQL查询接口。然而,系统内部实则差异很大,它们针对迥然不同的查询模式进行了各自优化。 许多数据库供应商现在专注于支持事务处理或分析工作负载,但不能同时支持两者。
星型与雪花型分析模式
根据不同的应用需求, 事务处理领域广泛使用了多种不同数据模型。 而另一方面,分析型业务的数据模型则要少得多。许多数据仓库都相当公式化的使用了星型模式,也称为维度建模。
图 3-9所示的模式可用于零售数据仓库。模式的中心是一个所谓的事实表(在这个例子中,它被称为fact sales )。事实表的每一行表示在特定时间发生的事件(这里, 每一行代表客户购买的一个产品 )。通常,事实被捕获为单独的事件,这样之后的分析具有最大的灵活性。不过 ,这意味着事实表可能会变得非常庞大。像苹果、沃尔码或者eB ay这样的大企业,其数据仓库可能有数十PB的交易历史,其中大部分都保存在事实表中。
事实表中的列是属性,其他列可能会引用其他表的外键,称为维度表。由于事实表中的每一行都代表一个事件,维度通常代表事件的对象( who )、什么( what )、地点( where )、时间 (when )、方告( how )以及原因( why )。
日期和时间通常使用维度表来表示,这样可以对日期(如公共假期)的相关信息进行编码,从而查询可以对比假期和非假日之间的销售情况。
名称“星型模式”来源于当表关系可视化时,事实表位于中间,被一系列维度表包围;这些表的连接就像星星的光芒。
该模板的一个变体称为雪花模式,其中维度进一步细分为子空间。例如,品牌和产品类别可能有单独的表格。雪花模式比星型模式更规范化,但是星型模式通常是首边,主要是因为对于分析人员,星型模式使用起来更简单。
在典型的数据仓库中,表通常非常宽:事实表通常超过100列 ,有时候有几百列。维度表也可能非常宽,可能包括与分析相关的所有元数据。
列式存储
虽然事实表通常超过100列,但典型的数据仓库查询往往一次只访问其中的4或5个。
在大多数OLTP数据库中,存储以面向行的方式布局:来自表的一行的所有值彼此相邻存储。文档数据库也是类似,整个文档通常被存储为一个连续的字节序列。
为了处理像示例3-1这样的查询,可以在fact_ sales.date_key和/或fact_sales.product_sk上使用索引,告诉存储引擎在哪里查找特定日期或特定产品的所有销售。 但是,面向行的存储引擎仍然需要将所有行(每个由超过100个属性组成)从磁盘加载到内存中、解析它们, 井过滤出不符合所需条件的行。这可能需要很长时间。
面向列存储的想法很简单:不要将一行中的所有值存储在一起,而是将每列中的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列,这可以节省大量的工作。该原理如图 3-10所示。
看看图 3-10 中每列的值序列:它们看起来有很多重复,这是压缩的好兆头。取决于列中具体数据模式,可以采用不同的压缩技术。在数据仓库中特别有效的一种技术是位图编码,如图 3-1 所示。
通常,列中的不同值的数量小于行数(一个例如,零售商可能拥有数十亿个销售交易,但只有100000个不同的产品)。现在可以使用n个不同值的列,并将其转换为n个单独的位图:一个位图对应每个不同的值,一个位对应一行。 如果行具有该值,该位为1 ,否则为0。 如果n非常小(例如,表示国家的列可能具有大约200个不同的值),那么这些位图由每行一位存储。但是,如果n越大,在大多数位图中将会有很多零(它们很稀疏)。 此时,位图也可以进行游程编码,如图3-11底部所示。这样列的编码非常紧凑。
这些位图索引非常适合在数据仓库中常见的查询。例如,
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 的位图,并按位与计算。 这样做也是可行的,这是因为这些列包含相同顺序的行, 因此一列的位图中的第k位对应于另 一 列的位图中与第k位相同的行。
内存带宽和矢量化处理
对于需要扫描数百万行的数据仓库查询,将数据从磁盘加载到内存的带宽是一大瓶颈。然而,这还不是唯一的瓶颈 。分析数据库的开发人员还要关心如何高效地将内存的带宽用于CPU缓存,避免分支错误预测和CPU指令处理流水线中的气泡,并利用现代CPU 中的单指令多数据( SIMD )指令。
除了减少需要从磁盘加载的数据量之外,面向列的存储布局也有利于高效利用 CPU周期。例如,查询引擎可以将一大块压缩列数据放入CPU的L1缓存中,并以紧凑循环(即没有函数调用)进行迭代。对于每个被处理的记录,CPU能够比基于很多函数调用和条件判断的代码更快地执行这种循环。列压缩使得列中更多的行可以加载到L1缓存。诸如先前描述的按位AND和OR的运算符,可被设计成直接对这样的列压缩数据块进行操作。这种技术被称为矢量化处理。
列存储中的排序
单独排序每列是没有意义的,如果这样的话就无法知道列中的某一项属于哪一行。因为知道某列中的第k项和另一列的第k项一定属于同一行,基于这种约定我们可以重建一行。
相反,即使数据是按列存储的,它也需要一次排序整行。数据库管理员可以基于常见查询的知识来选择要排序表的列。当第一列排序出现相同值时,可以指定第二列继续进行排序。
排序的另一个优点是它可以帮助进一步压缩列。如果主排序列上没有很多不同的值, 那么在排序之后,它将出现一个非常长的序列, 其中相同的值在一行中重复多次。一个简单的游程编码,如图 3-11 中的位图那样,即使该表可能拥有数十亿行,也可以将其压缩到几千字节。
基于第一个排序键的压缩效果通常最好。第二个和第三个排序键会使情况更加复杂, 也通常不会有太多相邻的重复值。排序优先级进一步下降的列基本上会呈现接近随机的顺序,因此通常无法压缩。
几种不同的排序
考虑到不同的查询会从不同的排序中获益,那么 为什么不以多种不同的方式存储相同的数据呢?无论如何,数据需要复制到多台机器,这样在一台机器发生故障肘,不会丢失数据。不妨存储不同方式排序的冗余数据,以便在处理查询时,可以选择最适合特定查询模式的排序版本。
面向列的存储具有多个排序顺序,这有些类似在面向行的存储中具有多个二级索引。 但最大的区别是,面向行的存储将每一行都保存在一个位置(在堆文件或聚集索引中) , 而二级索引只包含指向匹配行的指针。而对于列存储,通常没有任何指向别处数据的指针,只有包含值的列。
列存储的写操作
面向列的存储、 压缩和排序都非常有助于加速读取查询 。但是 ,它们的缺点是让写入更加困难。
像B-tree使用的原地更新方式,对于压缩的列是不可能的。如果在排序表的中间插入一行 , 那么很可能不得不重写所有的列文件。 因为各行是由它们在列中的位置标识的,所以插入操作必须一致地更新所有列。
幸运的是,在本章前面已经看到了一个很好的解决方案LSM-tree。 所有的写入首先进入内存存储区,将其添加到已排序的结构中,接着再准备写入磁盘。内存中的存储是面向行还是面向列无关紧要。当累积了足够的写入时,它们将与磁盘上的列文件合井,井批量写入新文件。
聚合:数据立方体与物化视图
数据仓库的另一个值得一提的是物化聚合。如前所述,数据仓库查询通常涉及聚合函数,例如SQL中的COUNT、SUM、AVG、MIN或MAX 。如果许多不同查询使用相同的聚合,每次都处理原始数据将非常浪费。为什么不缓存查询最常使用的一些计数或总和呢?
创建这种缓存的一种方式是物化视图。在关系数据模型中,它通常被定义为标准(虚拟)视图: 一个类似表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,并被写到磁盘 ,而虚拟视图只是用于编写查询的快捷方式 。 从虚拟视图中读取时, SQL引擎将其动态地扩展到视图的底层查询,然后处理扩展查询。
当底层数据发生变化时 ,物化视图也需要随之更新,因为它是数据的非规范化副本。 数据库可以自动执行,但这种更新方式会影响数据写入性能,这就是为什么在OLTP 数据库中不经常使用物化视图的原因。而对于大量读密集的数据仓库,物化视图则更有意义(它们是否能够真正地提高读性能还要取决于具体情况)。
物化视图常见的一种特殊情况称为数据立方体或OLAP立方体。它是由不同维度分组的聚合网格,如图 3-12所示的例子。
一般来说,事实表的维度不止两个,例如在图 3-9 中有五个维度 : 日期、产品、商店、促销和客户。想象五维超立方体是什么样子有些困难,但是原理是类似的:每个单元格包含特定日期-产品-商店-促销-客户组合的销售值。然后可以沿着每个维度汇总这些值。
物化数据立方体的优点是某些查询会非常快,主要是它们已被预先计算出来。例如, 如果想知道昨天每个商店的总销售量 ,只需要直接查看对应维度的总和,而不需要扫描数百万行。
缺点则是,数据立方体缺乏像查询原始数据那样的灵活性。例如,因为价格不是其中的一个维度,所以没有办法直接计算成本超过100美元的物品所占销售的比重。因此,大多数数据仓库都保留尽可能多的原始数据,仅当数据立方体可以对特定查询显著提升性能时,才会采用多维数据聚合。
浙公网安备 33010602011771号