第三章:存储与检索

本章介绍了传统关系型数据库与“NoSQL”数据库的存储引擎。 我们会研究两大类存储引擎:日志结构(log-structured) 的存储引擎,以及面向页面(page-oriented) 的存储引擎(例如B树)。
驱动数据库的数据结构
世界上最简单的数据库可以用两个Bash函数实现:
|
|
这两个函数实现了键值存储的功能。
麻雀虽小,五脏俱全:
|
|
huhu 底层的存储格式非常简单:
|
|
- 与db_set做的事情类似,许多数据库在内部使用了日志(log),也就是一个 仅追加(append-only) 的数据文件。
- 每次你想查找一个键时,db_get 必须从头到尾扫描整个数据库文件来查找键的出现。
- 用算法的语言来说,查找的开销是 O(n) :如果数据库记录数量 n 翻了一倍,查找时间也要翻一倍。
索引
为了高效查找数据库中特定键的值,我们需要一个数据结构:索引(index)。
哈希索引
假设我们的数据存储只是一个追加写入的文件,最简单的索引策略就是:保留一个内存中的哈希映射,其中每个键都映射到一个数据文件中的字节偏移量,指明了可以找到对应值的位置

也可以把多个段的内容压缩合并到一起。

重要的问题:
- 文件格式
- a. csv 不好,使用二进制格式更快。
- a. 删除一个键,则必须在数据文件(有时称为逻辑删除)中附加一个特殊的删除记录。
- a. 如果数据库重新启动,则内存散列映射将丢失。
- b. 可以根据日志文件重新恢复每个段的哈希映射,但段很大的时候,很费时间。
- c. Bitcask通过存储加速恢复磁盘上每个段的哈希映射的快照,可以更快地加载到内存中。
- a. 数据库可能随时崩溃,包括将记录附加到日志中途。
- b. Bitcask文件包含校验和,允许检测和忽略日志的这些损坏部分。
- a. 写操作是以严格顺序的顺序附加到日志中的,所以常见的方法是只有一个写线程。
- b. 读操作可以有多个线程同时读取。
为什么采用追加模式,而不是不更新文件,用新值覆盖旧值? 原因有几个:
哈希表索引的局限性:
SSTables和LSM树
在之前的存储中,每个日志结构存储段都是一系列键值对。这些对按照它们写入的顺序出现,而不是键值对的顺序。 我们做一个简单的改变:我们要求键值对的序列按键排序。把这个格式称为排序字符串表(Sorted String Table),简称 SSTable。同时,要求每个键只在每个合并的段文件中出现一次(压缩过程已经保证)。
SSTable 与散列索引日志段相比,有几个很大的优势:
- 合并段是简单而高效的,即使文件大于可用内存。方法类似于归并排序。

a. 如果在几个输入段中出现相同的键,该怎么办? ⅰ. 答:保留最近段的值,并丢弃旧段中的值。
- 为了在文件中找到一个特定的键,你不再需要保存内存中所有键的索引。因为是有序的,可以先查找出键所处的范围,然后就找到这个键所在的偏移量的区间。比如可以从 handbag 和 handsome 的偏移量找到 handiwork 的区间。

构建和维护SSTables
如何让数据首先被按键排序呢?
存储工作的操作步骤:
如何解决数据库崩溃,则最近的写入(在内存表中,但尚未写入磁盘)将丢失的问题?
用SSTables制作LSM树
LSM 树:在 LevelDB 和 RocksDB 使用。
性能优化
- 当查找数据库中不存在的键时,LSM树算法可能会很慢:您必须检查内存表,然后将这些段一直回到最老的(可能必须从磁盘读取每一个),然后才能确定键不存在。
B树
像SSTables一样,B树保持按键排序的键值对,这允许高效的键值查找和范围查询。 不同:
每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 —— 类似于指针,但在磁盘而不是在内存中。

查找过程: ● 一个页面会被指定为B树的根;在索引中查找一个键时,就从这里开始。 ● 该页面包含几个键和对子页面的引用。 ● 每个子页面负责一段连续范围的键,引用之间的键,指明了引用子页面的键范围。 ● 最后,我们可以看到包含单个键(叶页)的页面,该页面包含每个键的内联值,或者包含对可以找到值的页面的引用。
更新过程: ● 搜索包含该键的叶页,更改该页中的值,并将该页写回到磁盘原来的位置(对该页的任何引用保持有效)
插入过程:
● 找到其范围包含新键的页面,并将其添加到该页面。
● 如果页面中没有足够的可用空间容纳新键,则将其分成两个半满页面,并更新父页面以解释键范围的新分区。
删除过程: ● 删除一个键(同时保持树平衡)就牵扯很多其他东西了。
深度: ● 该算法确保树保持平衡:具有 n 个键的B树总是具有 $O(log n)$ 的深度。 ● 大多数数据库可以放入一个三到四层的 B 树,所以你不需要遵追踪多页面引用来找到你正在查找的页面。 (分支因子为 500 的 4KB 页面的四级树可以存储多达 256TB 。)
让B树更可靠
● B 树的基本底层写操作是用新数据覆盖磁盘上的页面。 ● 假定覆盖不改变页面的位置; ● 而日志结构索引(如LSM树)只附加到文件(并最终删除过时的文件),但从不修改文件。
危险操作: ● 插入导致页面过度而拆分页面,则需要编写已拆分的两个页面,并覆盖其父页面以更新对两个子页面的引用。 ● 这是一个危险的操作,因为如果数据库在仅有一些页面被写入后崩溃,那么最终将导致一个损坏的索引(例如,可能有一个孤儿页面不是任何父项的子项) 。
预写式日志(WAL) ● 为了使数据库对崩溃具有韧性,B树实现通常会带有一个额外的磁盘数据结构:预写式日志(WAL, write-ahead-log)(也称为重做日志(redo log))。 ● 这是一个仅追加的文件,每个B树修改都可以应用到树本身的页面上。 ● 当数据库在崩溃后恢复时,这个日志被用来使B树恢复到一致的状态.
并发访问: ● 更新页面时,如果多个线程要同时访问B树,则需要仔细的并发控制,否则可能会看到树处于不一致的状态。 ● 通过使用锁存器(latches)(轻量级锁)保护树的数据结构来完成 ● 而 LSM 比较简单:在后台完成所有的合并,不干扰查询;通过「原子交换」把旧的分段变为新的分段。
B树优化
● LMDB 数据库中使用「写时复制方案 」,而不是不是覆盖页面并维护 WAL 进行崩溃恢复。 ○ 修改的页面被写入到不同的位置,并且树中的父页面的新版本被创建,指向新的位置。 ○ 这种方法对于并发控制也很有用。 ● 保存键的缩略信息,而不是完整的键。 ○ 键只用保存一个区间,而不是具体的数值(类似于 B+树)。 ○ 可以包含更多的键,减少树的层次。 ● 不方便扫描大部分关键词的范围查找。 ○ 许多B树实现尝试布局树,使得叶子页面按顺序出现在磁盘上。 ○ 但是,随着树的增长,维持这个顺序是很困难的。 ○ 相比之下,由于LSM树在合并过程中一次又一次地重写存储的大部分,所以它们更容易使顺序键在磁盘上彼此靠近。 ● 额外的指针已添加到树中。 ○ 例如,每个叶子页面可以在左边和右边具有对其兄弟页面的引用,这允许不跳回父页面就能顺序扫描。 ● B树的变体如分形树借用一些日志结构的思想来减少磁盘寻道(而且它们与分形无关)。
比较B树和LSM树
通常LSM树的写入速度更快,而B树的读取速度更快。 LSM树上的读取通常比较慢,因为它们必须在压缩的不同阶段检查几个不同的数据结构和SSTables。
LSM树的优点
● B树索引必须至少两次写入每一段数据:一次写入预先写入日志,一次写入树页面本身(也许再次分页) ● 即使在该页面中只有几个字节发生了变化,也需要一次编写整个页面的开销。 ● 有些存储引擎甚至会覆盖同一个页面两次,以免在电源故障的情况下导致页面部分更新
写放大 ● 反复压缩和合并SSTables,日志结构索引也会重写数据。 ● 在数据库的生命周期中写入数据库导致对磁盘的多次写入 —— 被称为写放大(write amplification)。 ● 存储引擎写入磁盘的次数越多,可用磁盘带宽内的每秒写入次数越少。
LSM树通常能够比B树支持更高的写入吞吐量: ● 部分原因是它们有时具有较低的写放大(尽管这取决于存储引擎配置和工作负载) ● 部分是因为它们顺序地写入紧凑的SSTable文件而不是必须覆盖树中的几个页面 ● 这种差异在磁性硬盘驱动器上尤其重要,顺序写入比随机写入快得多。
文件碎片: ● LSM树可以被压缩得更好,因此经常比B树在磁盘上产生更小的文件。 ● B树存储引擎会由于分割而留下一些未使用的磁盘空间 ● 由于LSM树不是面向页面的,并且定期重写SSTables以去除碎片,所以它们具有较低的存储开销,特别是当使用平坦压缩时
LSM树的缺点
日志结构存储的缺点是压缩过程有时会干扰正在进行的读写操作。 B树的行为则相对更具可预测性。
高写入吞吐量: ● 磁盘的有限写入带宽需要在初始写入(记录和刷新内存表到磁盘)和在后台运行的压缩线程之间共享。 ● 写入空数据库时,可以使用全磁盘带宽进行初始写入,但数据库越大,压缩所需的磁盘带宽就越多。
压缩和读取的速度: ● 如果写入吞吐量很高,并且压缩没有仔细配置,压缩跟不上写入速率。 ● 在这种情况下,磁盘上未合并段的数量不断增加,直到磁盘空间用完,读取速度也会减慢,因为它们需要检查更多段文件。
键出现的个数: ● B树的一个优点是每个键只存在于索引中的一个位置,而日志结构化的存储引擎可能在不同的段中有相同键的多个副本。 ● 有利于事务语义。 ● 在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在B树索引中,这些锁可以直接连接到树
其他索引结构
前面讨论的是关键值索引,它们就像关系模型中的主键(primary key) 索引。主键唯一标识关系表中的一行、一个文档、一个顶点。
二级索引 ● 在关系数据库中,您可以使用 CREATE INDEX 命令在同一个表上创建多个二级索引,而且这些索引通常对于有效地执行联接而言至关重要。 ● 一个二级索引可以很容易地从一个键值索引构建,区别是键不是唯一的。实现方式: ○ 通过使索引中的每个值,成为匹配行标识符的列表(如全文索引中的发布列表) ○ 通过向每个索引添加行标识符来使每个关键字唯一 ○ 无论哪种方式,B树和日志结构索引都可以用作辅助索引。
将值存储在索引中
索引中的关键字是查询搜索的内容,它属于两种之一: ● 实际行(文档,顶点) ● 对存储在别处的行的引用。此时,行被存储的地方被称为堆文件(heap file),并且存储的数据没有特定的顺序 ○ 堆文件很常见。 ○ 它避免了在存在多个二级索引时复制数据 ○ 新值字节数不大于旧值,原地覆盖; ○ 新值更大,需要移到堆中有足够空间的新位置; ■ 此时,要么所有索引都更新指向新堆位置; ■ 要么在旧堆位置留一个转发指针。
聚集索引 ● 从索引到堆文件的额外跳转性能损失太大,希望可以把索引行直接进存储到索引中。被叫做聚集索引。 ● 在 MySQL 的 InnoDB 存储引擎中,表的主键总是一个聚簇索引,二级索引用主键(而不是堆文件中的位置) ● 在SQL Server中,可以为每个表指定一个聚簇索引。
包含列的索引或覆盖索引 ● 在 聚集索引(clustered index) (在索引中存储所有行数据)和 非聚集索引(nonclustered index) (仅在索引中存储对数据的引用)之间的折衷被称为 包含列的索引(index with included columns)或覆盖索引(covering index),其存储表的一部分在索引内。 ● 允许通过单独使用索引来回答一些查询。 ● 加快了读取速度,但是增加了额外的存储空间,增加了写入开销,还要事务保证。
多列索引
上面讨论的都是一个 key 对应一个 value,如果需要同时根据一个表中的多个列(或文档中的多个字段)进行查询,则不行。 ● 连接索引(concatenated index) ○ 将一列的值追加到另一列后面,简单地将多个字段组合成一个键。 ○ 但是这不能做复杂查询。 ● 多维索引(multi-dimensional index) ○ 比如需要根据经纬度做二维范围查询,则 B 树和 LSM 树不能高效; ○ 普遍做法是用特殊化的空间索引,比如 R 树(TODO)。 ○ 多维索引除了地理位置,还可以用于其他很多地方:电子网站、天气数据。
全文搜索和模糊索引
上文讨论的索引都是查询确切的值或者确定范围的值,如果搜索类似的键,需要用模糊查询。
全文搜索引擎 ● 允许搜索一个单词以扩展为包括该单词的同义词,忽略单词的语法变体,并且搜索在相同文档中彼此靠近的单词的出现,并且支持各种其他功能取决于文本的语言分析。 ● 为了处理文档或查询中的拼写错误,Lucene能够在一定的编辑距离内搜索文本(编辑距离1意味着添加,删除或替换了一个字母)
Lucene ● 为其词典使用了一个类似于SSTable的结构。 ● 这个结构需要一个小的内存索引,告诉查询在排序文件中哪个偏移量需要查找关键字。 ● 在 LevelDB 中,这个内存中的索引是一些键的稀疏集合。 ● 但在 Lucene 中,内存中的索引是键中字符的有限状态自动机,类似于trie。 ● 这个自动机可以转换成 Levenshtein 自动机,它支持在给定的编辑距离内有效地搜索单词。
在内存中存储一切
对于磁盘和SSD,如果要在读取和写入时获得良好性能,则需要仔细地布置磁盘上的数据。 磁盘优点:耐用,成本低。 内存变得便宜,促进了内存数据库发展。
内存数据库 ● 某些内存中的键值存储(如Memcached)仅用于缓存,在重新启动计算机时丢失的数据是可以接受的。 ● 但其他内存数据库的目标是持久性,可以通过特殊的硬件(例如电池供电的RAM),将更改日志写入磁盘,将定时快照写入磁盘或通过复制内存来实现,记忆状态到其他机器。
实现 ● 内存数据库重新启动时,需要从磁盘或通过网络从副本重新加载其状态(除非使用特殊的硬件)。 ● 尽管写入磁盘,它仍然是一个内存数据库,因为磁盘仅用作耐久性附加日志,读取完全由内存提供。 ● 写入磁盘也具有操作优势:磁盘上的文件可以很容易地由外部实用程序进行备份,检查和分析。
常见产品 ● VoltDB,MemSQL和Oracle TimesTen等产品是具有关系模型的内存数据库 ● RAM Cloud是一个开源的内存键值存储器,具有持久性(对存储器中的数据以及磁盘上的数据使用日志结构化方法) ● Redis和Couchbase通过异步写入磁盘提供了较弱的持久性。
内存数据库性能优势到底在哪? ● 内存数据库的性能优势并不是因为它们不需要从磁盘读取的事实。 ● 即使是基于磁盘的存储引擎也可能永远不需要从磁盘读取,因为操作系统缓存最近在内存中使用了磁盘块。 ● 相反,它们更快的原因在于省去了将内存数据结构编码为磁盘数据结构的开销。
除了性能还有什么优势? ● 提供了难以用基于磁盘的索引实现的数据模型。 ● 例如,Redis为各种数据结构(如优先级队列和集合)提供了类似数据库的接口。因为它将所有数据保存在内存中,所以它的实现相对简单。
内存不够用怎么办? ● 反缓存(anti-caching) 方法通过在内存不足的情况下将最近最少使用的数据从内存转移到磁盘,并在将来再次访问时将其重新加载到内存中。 ● 这与操作系统对虚拟内存和交换文件的操作类似,但数据库可以比操作系统更有效地管理内存,因为它可以按单个记录的粒度工作,而不是整个内存页面。 ● 尽管如此,这种方法仍然需要索引能完全放入内存中。
事务处理还是分析?
事务处理 ● 早起的业务处理,典型的数据库写入与一笔商业交易(transaction)相对应,以后交易/事务(transaction) 仍留了下来。 ● 事务不一定具有ACID(原子性,一致性,隔离性和持久性)属性。 ● 事务处理只是意味着允许客户端进行低延迟读取和写入 —— 而不是只能定期运行(例如每天一次)的批量处理作业。 ● 数据库仍然常被用作在线事务处理(OLTP, OnLine Transaction Processing) 。
数据分析 ● 数据库也开始越来越多地用于数据分析,需要扫描大量记录; ● 为了将这种使用数据库的模式和事务处理区分开,它被称为在线分析处理(OLAP, OnLine Analytice Processing)
属性 | 事务处理 OLTP | 分析系统 OLAP |
主要读取模式 | 查询少量记录,按键读取 | 在大批量记录上聚合 |
主要写入模式 | 随机访问,写入要求低延时 | 批量导入(ETL),事件流 |
主要用户 | 终端用户,通过Web应用 | 内部数据分析师,决策支持 |
处理的数据 | 数据的最新状态(当前时间点) | 随时间推移的历史事件 |
数据集尺寸 | GB ~ TB | TB ~ PB/td> |
数据仓库

星型和雪花型:分析的模式

列存储
面向列的存储

列压缩
● 除了仅从磁盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对磁盘吞吐量的需求。 ● 面向列的存储通常很适合压缩。
位图编码
内存带宽和向量处理
● 需要扫描数百万行的数据仓库查询来说,瓶颈: ○ 是从磁盘获取数据到内存的带宽。 ○ 主存储器带宽到CPU缓存中的带宽,避免CPU指令处理流水线中的分支错误预测和泡沫,以及在现代中使用单指令多数据(SIMD)指令CPU ● 面向列的存储布局: ○ 可以将大量压缩的列数据放在CPU的L1缓存中,然后在紧密的循环中循环(即没有函数调用) ○ 更有利于 CPU 的计算。
列存储中的排序顺序
● 在列存储中,存储行的顺序并不一定很重要。 ● 按插入顺序存储它们是最简单的,因为插入一个新行只需要追加到每个列文件。 ● 每列独自排序是没有意义的,因为那样我们就不会知道列中的哪些项属于同一行 ● 即使按列存储数据,也需要一次对整行进行排序。
好处 ● 速度快:这样数据库的管理员可以根据他们对常用查询的了解来选择表格应该被排序的列。 ● 压缩列:第一个排序键的压缩效果最强。
几个不同的排序顺序
● 不同的查询受益于不同的排序顺序,而无论如何,数据需要复制到多台机器, ● 在一个面向列的存储中有多个排序顺序有点类似于在一个面向行的存储中有多个二级索引。
写入列存储
列存储的优点:大部分只用查询;压缩和排序都有助于更快地读取这些查询。 缺点:写入困难。插入必须始终更新所有列。
解决方案:LSM树。 ● 现在内存中存储,添加到一个已排序的结构中,然后准备写入磁盘。
聚合:数据立方体和物化视图
并不是每个数据仓库都必定是一个列存储。列存储在迅速普及。
物化汇总 ● 数据仓库查询通常涉及一个聚合函数,如SQL中的COUNT,SUM,AVG,MIN或MAX。 ● 创建这种缓存的一种方式是物化视图(Materialized View)。

物化数据立方体的优点是某些查询变得非常快,因为它们已经被有效地预先计算了。
本章小结
优化 事务处理(OLTP) 或 在线分析(OLAP) 。这些用例的访问模式之间有很大的区别: ● OLTP系统通常面向用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序通常只访问每个查询中的少部分记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。磁盘寻道时间往往是这里的瓶颈。 ● 数据仓库和类似的分析系统会低调一些,因为它们主要由业务分析人员使用,而不是由最终用户使用。它们的查询量要比OLTP系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。磁盘带宽(而不是查找时间)往往是瓶颈,列式存储是这种工作负载越来越流行的解决方案。
在OLTP方面,我们能看到两派主流的存储引擎:
日志结构学派
只允许附加到文件和删除过时的文件,但不会更新已经写入的文件。 Bitcask,SSTables,LSM树,LevelDB,Cassandra,HBase,Lucene等都属于这个类别。
就地更新学派
将磁盘视为一组可以覆写的固定大小的页面。 B树是这种哲学的典范,用在所有主要的关系数据库中和许多非关系型数据库。
日志结构的存储引擎是相对较新的发展。他们的主要想法是,他们系统地将随机访问写入顺序写入磁盘,由于硬盘驱动器和固态硬盘的性能特点,可以实现更高的写入吞吐量。在完成OLTP方面,我们通过一些更复杂的索引结构和为保留所有数据而优化的数据库做了一个简短的介绍。