数据库核心:数据结构
假设有一个世界上最简单的数据库,底层的存储格式非常简单:一个纯文本文件。其中每行包含一个键值对,用逗号分隔。每次存储数据即追加新内容到文件末尾,因此,如果多次更新某个键,旧版本的值不会被覆盖,查找的时候则需要扫描文件,定位到键最后一次出现的位置来找到最新的值。
与该结构类似,许多数据库都在内部使用日志,日志是一个仅支持追加式更新的数据文件。虽然真正的数据库有很多更为复杂问题需要考虑,但是基本的原理是相同的。
为了高效地查找数据库中特定键的值,需要新的数据结构:索引。它们背后的基本想法都是保留一些额外的元数据,这些元数据作为路标,帮助定位想要的数据。很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的结构势必会引入开销,因此任何类型的索引通常都会降低写的速度。
哈希索引
首先我们以键-值数据的索引开始。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 文件写入磁盘,新的 SSTable 文件成为数据库的最新部分。当 SSTable 写磁盘的同时,写入可以继续添加到一个新的内存表实例。
- 为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)。
- 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值。
上述方案可以很好地工作。但它还存在一个问题:如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。为了避免该问题,可以在磁盘上保留单独的日志,每个写入都会立即追加到该日志。日志文件不需要按键排序,这并不重要,因为它的唯一目的是在崩溃后恢复内存表。每当将内存表写入 SSTable 时,相应的日志可以被丢弃。
从 SSTables 到 LSM-Tree
最初这个索引结构以日志结构的合并树(Log-Structured Merge-Tree,或 LSM-Tree)命名,它建立在更早期的日志结构文件系统之上。因此,基于合并和压缩排序文件原理的存储引擎通常都被称为 LSM 存储引擎。
Lucene 是 Elasticsearch 和 Solr 等全文搜索系统所使用的索引引擎,它采用了类似的方法来保存其词典。全文索引比 key-value 索引复杂得多,但它基于类似的想法:给定搜索查询中的某个单词,找到提及该单词的所有文档。它主要采用 key-value 结构实现,其中键是单词(词条),值是所有包含该单词的文档 ID 的列表(倒排表)。在 Lucene 中,从词条到倒排表的映射关系保存在类 SSTable 的排序文件中,这些文件可以根据需要在后台合并。
性能优化
总是有很多细节值得深入优化,这样才能使存储引擎在实际中表现得更好。例如,当查找数据库中某个不存在的键时,LSM-Tree 算法可能很慢:在确定键不存在之前,必须先检查内存表,然后将段一直回溯访问到最旧的段文件(可能必须从磁盘多次读取)。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(布隆过滤器是内存高效的数据结构,用于近似计算集合的内容。如果数据库中不存在某个键,它能够很快告诉你结果,从而节省了很多对于不存在的键的不必要的磁盘读取)。
还有不同的策略会影响甚至决定 SSTables 压缩和合并时的具体顺序和时机。最常见的方式是大小分级和分层压缩。LevelDB 和 RocksDB 使用分层压缩,HBase 使用大小分级,Cassandra 则同时支持这两种压缩。在大小分级的压缩中,较新的和较小的 SSTables 被连续合并到较旧和较大的 SSTables。在分层压缩中,键的范围分裂成多个更小的 SSTables,旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘空间。
即使有许多细微的差异,但 LSM-Tree 的基本思想(保存在后台合并的一系列 SSTable)却足够简单有效。即使数据集远远大于可用内存,它仍然能够正常工作。由于数据按排序存储,因此可以有效地执行区间查询,并且由于磁盘是顺序写入的,所以 LSM-Tree 可以支持非常高的写入吞吐量。
B-tree
上面讨论的日志结构索引正在逐渐受到更多的认可,但目前最广泛使用的索引结构还是 B-tree,它几乎是所有关系型数据库的标准索引实现,很多非关系型数据库也经常使用。
之前看到的日志结构索引将数据库分解为可变大小的段,通常大小为几兆字节或更大,并且始终按顺序写入段。相比之下,B-tree 将数据库分解成固定大小的块或页,传统上大小为 4 KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。
每个页面都可以使用地址或位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过是指向磁盘地址,而不是内存。可以使用这些页面引用来构造一个树状页面,如图所示。
某一页被指定为 B-tree 的根;每当查找索引中的一个键时,总是从这里开始。该页面包含若干个键和对子页的引用。每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。
B-tree 中一个页所包含的子页引用数量称为分支因子。在实际中,分支因子取决于存储页面引用和范围边界所需的空间总量,通常为几百个。如果要更新 B-tree 中现有键的值,首先搜索包含该键的叶子页,更改该页的值,并将页写回到磁盘(对该页的任何引用仍然有效)。如果要添加新键,则需要找到其范围包含新键的页,并将其添加到该页。如果页中没有足够的可用空间来容纳新键,则将其分裂为两个半满的页,并且父页也需要更新以包含分裂之后的新的键范围。
使 B-tree 可靠
为了使数据库能从崩溃中恢复,常见 B-tree 的实现需要支持额外的数据结构:预写日志,也称为重做日志。这是一个仅支持追加修改的文件,每个 B-tree 的修改必须先更新重做日志然后再修改树本身的页。当数据库在崩溃后需要恢复时,该日志用于将 B-tree 恢复到最近一致的状态。
另一个复杂因素是,如果多个线程要同时访问 B-tree,则需要注意并发控制,否则线程可能会看到树处于不一致的状态。通常使用锁存器(轻量级的锁)保护树的数据结构来完成。在这方面,日志结构化的方法显得更简单,因为它们在后台执行所有合并,而不会干扰前端的查询,并且会不时地用新段原子地替换旧段。
优化 B-tree
B-tree 已经存在了很长时间,多年来开发了许多优化措施。这里列举一些:
- 一些数据库不使用覆盖页和维护重做日志来进行崩溃恢复,而是使用写时复制方案。修改的页被写入不同的位置,树中父页的新版本被创建,并指向新的位置。
- 保存键的缩略信息,而不是完整的键,这样可以节省页空间。
- 一般来说,页可以放在磁盘上的任何位置;没有要求相邻的页需要放在磁盘的相邻位置。如果查询需要按照顺序扫描大段的键范围,考虑到每个读取的页都可能需要磁盘 I/O,所以逐页的布局可能是低效的。因此,许多 B-tree 的实现尝试对树进行布局,以便相邻叶子页可以按顺序保存在磁盘上。然而,随着树的增长,维持这个顺序会变得越来越困难。
- 添加额外的指针到树中。例如,每个叶子页面可能会向左和向右引用其同级的兄弟页,这样可以顺序扫描键。而不用跳回到父页。
- B-tree 的变体如分形树,借鉴了一些日志结构的想法来减少磁盘寻道。
对比 B-tree 和 LSM-tree
根据经验,LSM-tree 通常对于写入更快,而 B-tree 被认为对于读取更快。读取通常在 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,如果要获得良好的读写性能,需要精心地安排磁盘上的数据布局。随着内存变得更便宜,而许多数据集不是那么大,可以将它们完全保留在内存中,或者分布在多台机器上。
一些内存中的 key-value 存储,主要用于缓存。但是其他内存数据库旨在实现持久性,例如可以通过用特殊硬件或者通过将更改记录写入磁盘,或者将定期快照写入磁盘,以及复制内存中的状态到其他机器等方式来实现。
当内存数据库重启时,它需要重新载入其状态,无论是从磁盘还是通过网络副本。尽管写入磁盘,但磁盘仅仅用作为了持久性目的的追加日志,读取完全靠内存服务。此外,写入磁盘还具有一些运维方面优势:磁盘上的文件可以容易地通过外部工具来执行备份、检查和分析。
与直觉相反,内存数据库的性能优势并不是因为它们不需要从磁盘读取。如果有足够的内存,即使是基于磁盘的存储引擎,也可能永远不需要从磁盘读取,因为操作系统将最近使用的磁盘块缓存在内存中。相反,内存数据库可以更快,是因为它们避免使用写磁盘的格式对内存数据结构编码的开销。
除了性能外,内存数据库的另一个有意思的地方是,它提供了基于磁盘索引难以实现的某些数据模型。例如,Redis 为各种数据结构都提供了类似数据库的访问接口。
事务处理与分析处理
尽管数据库开始被用于许多不同种类的数据,例如博客的评论、游戏中的动作、通讯录中的联系人等,然而其基本访问模式仍然与处理业务交易类似。应用程序通常使用索引中的某些键查找少量记录,根据用户的输入插入或更新记录。因为这些应用程序是交互式的,所以访问模式被称为在线事务处理(online transaction processing,OLTP)。
然而,数据库也开始越来越多地用于数据分析,数据分析具有非常不同的访问模式。通常,分析查询需要扫描大量记录,每个记录只读取少数几列,并计算汇总统计信息,而不是返回原始数据给用户。这些查询通常由业务分析师编写,以形成有助于公司管理层更好决策的报告。为了区分使用数据库与事务处理的模式,称之为在线分析处理(online analyticprocessing,OLAP)。
数据仓库
由于企业主要的业务系统通常是 OLTP 系统,因此数据库管理员通常不愿意让业务分析人员在数据库上直接运行临时分析查询,这些查询通常代价很高,要扫描大量数据集,这可能会损害并发执行事务的性能。
相比之下,数据仓库则是单独的数据库,分析人员可以在不影响 OLTP 操作的情况下尽情地使用。数据仓库包含公司所有各种 OLTP 系统的只读副本。从 OLTP 数据库(使用周期性数据转储或连续更新流)中提取数据,转换为分析友好的模式,执行必要的清理,然后加载到数据仓库中。将数据导入数据仓库的过程称为提取-转换-加载(Extract-Transform-Load,ETL),如图所示:
使用单独的数据仓库而不是直接查询 OLTP 系统进行分析,很大的优势在于数据仓库可以针对分析访问模式进行优化。事实证明,前半部分讨论的索引算法适合 OLTP,但不擅长应对分析查询。
列式存储
如果 OLAP 系统表中有数以万亿行、PB 大小的数据,如何高效存储和查询将成为具有挑战性的问题。虽然这些表通常超过 100 列,但典型的 OLAP 查询往往一次只访问其中的几行。看如下的示例,如何高效地执行这个查询?
SELECT
dim_date.weekday,dim_product.category,
SUM(fact_sales.quantity) AS quantity_sold
FROM fact_sales
JOIN dim_date ON fact_sales.date_key = dim_date.date_key
JOIN dim_product ON fact_sales.product_sk = dim_product.product_sk
WHERE
dim_date.year = 2013 AND
dim_product.category IN ('Fresh fruit', 'Candy')
GROUP BY
dim_date.weekday,dim_product.category;
为了处理像这样的查询,可以在 fact_sales.date_key 和 fact_salesproduct_sk 列上使用索引,告诉存储引擎在哪里查找特定日期和特定产品的所有记录。但是,面向行的存储引擎仍然需要将所有行从磁盘加载到内存中,解析它们并过滤出不符合所需条件的行。
此时列式存储就派上用场了,它的想法很简单:将每列中的所有值存储在一起。如果每个列存储在一个单独的文件中,查询只需要读取和解析该查询中使用的那些列,这可以节省大量的工作。原理如下所示:
面向列的存储布局依赖一组列文件,每个文件以相同顺序保存着数据行。如果需要重新组装整行,可以从每个单独的列文件中获取第 23 个条目,并将它们放在一起构成表的第 23 行。
列压缩
除了仅从磁盘中加载查询所需的列之外,还可以通过压缩数据来进一步降低对磁盘吞吐量的要求。列式存储非常适合压缩,因为每列的值通常会有很多重复。根据列中的具体数据模式,可以采用不同的压缩技术。其中特别有效的一种是位图编码,如下图所示:
通常,列中的不同值的数量小于行数。现在可以使用 n 个不同值的列,并将其转换为 n 个单独的位图:一个位图对应每个不同的值,一个位对应一行。如果行具有该值,该位为 1,否则为 0。如果 n 非常小,那么这些位图由每行一位存储。如果 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 的位图,并按位与计算。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。