聊一聊存储(三)-存储与检索

在上节中,介绍了常用的数据模型。即如何将现实中的场景抽象到具体的数据模型,并将其存储到数据库。在本章将从数据库的视角来讨论一下数据库是如何将数据存储到磁盘中,以及如何检索出对应的数据。

最简单的数据库

数据库的本质就是提供数据的读写能力,可以使用两个 Bash 函数实现最简单的数据库。当然,在具体实现的时候会有许多细节「事务、分布式、并发、读写性能等」,这里就不做讨论。

bash 复制代码
#!/bin/bash
db_set () { // 写数据,输入是 key value
  echo "$1,$2" >> database
}

db_get () { // 检索 key, 并返回最新的值
  grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

底层的存储格式非常简单:一个文本文件,每行包含一条逗号分隔的键值对(忽略转义问题的话,大致与 CSV 文件类似)。每次对 db_set 的调用都会向文件末尾追加记录,所以更新键的时候旧版本的值不会被覆盖 ------ 因而查找最新值的时候,需要找到文件中键最后一次出现的位置(因此 db_get 中使用了 tail -n 1 )。

数据的写入和读取操作:

json 复制代码
db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'
db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'
db_get 42 {"name":"San Francisco","attractions":["Golden Gate Bridge"]}

索引

在上面的例子中,使用一个简单的 Bash 函数实现了一个 KV 模型的数据库。当数据量变大时,查询性能会变得非常糟糕。每次查询时的开销都是 O(n)。为了提高数据库的查询效率,需要引入一个新的概念索引。

索引是从主数据衍生的额外数据结构,通过存储一些额外的元数据作为路标来快速查找数据,降低查询的开销。索引的引入会拖慢数据写入速度「数据写入时需要同步更新对应的索引」,但是提升了查询的速度。因此,在具体设计的时候,需要根据具体的业务场景来设计索引。

散列索引

在编程中,一说起查询速度最快的数据结构就会想到哈希表。因此,可以考虑在内存中维护一个哈希表,K 为需要存储到数据库的 key,V 为需要存储到数据库中的 value 对应磁盘文件的偏移量。

如下图所示,写入数据时直接追加文件,然后更新内存中维护的哈希表;查询数据时,从哈希表中获取对应的偏移量,读取该位置的值。

目前来看,上面的 KV 数据库似乎可以正常运行。但是由于文件的写入方式是一直追加文件,从而导致相同 K 的数据会存储不同的版本,但实际用到的只是最新的数据,造成存储空间的浪费。

可以考虑将文件分为大小相同的段,每个段维护一个哈希表。每次写入数据时先判断当前段是否写满,如果写满则写入新的段。然后启动后台进程,将之前的段进行压缩,即丢弃旧 Key,合并成新的段,生成新的哈希表。数据读取时仍然读取旧的哈希表。当段合并结束,则使用原子操作替换哈希表,同时删除旧段,释放空间。

在生产环境中,仍然需要考虑一些其他的技术细节:

  • 文件格式:CSV 不是日志的最佳格式。使用二进制格式更快,更简单:首先以字节为单位对字符串的长度进行编码,然后是原始的字符串(不需要转义)。
  • 删除操作:由于文件时一直追加,在数据写入的时候增加一个特殊的标记位,在进行压缩操作时就是丢弃 key。
  • 崩溃回复:如果数据库重新启动,存储在内存中的哈希表就会丢失。如果从数据段中重新加载则需要花费大量的时间,可以考虑将哈希表的快照存储到硬盘上,加快恢复时间。
  • 部分写入:数据库会随时崩溃。当 value 写入到一半时发生崩溃,则会导致业务异常。可以使用文件校验和检测和忽略文件中不完整的部分。
  • 并发操作:由于数据是直接追加到文件中,可以只由一个写线程完成。数据只追加,并不涉及更改,可以保证多个线程并发的读。

使用文件追加而不是文件修改的方式更新数据,优势在于对文件的写入都是顺序写入,加快了写入性能;不用处理并发问题;对旧段的数据归并可以避免数据碎片的问题。

使用哈希索引可以快速查询到指定 key 的数据,但是在范围查询时则需要进行全盘扫描。此外,内存空间是有限的,哈希表的空间也是有限的。

LSM 树

为了可以实现范围查询,可以对上述的日志分段文件中存储的数据按照 key 排序,且每个分段文件中的 key 只有一个。我们将这种数据结构称为 SSTable(Sorted String Table)。整个算法我们叫日志结构合并树 LSM(log struct merge-tree) 。

整体流程

在 LSM 中,分段文件中存储的是 SSTable。为了实现分段文件的数据时按照 key 排序的,数据写入磁盘前先在内存中进行排序和去重,统一写入分段文件。其流程如下:

  1. 有新的记录写入时,将其加入到内存中的平衡树里(如:红黑树)。通常将内存中的树结构称为内存表(memtable)。
  2. 当内存表到达一定的阈值时,将其作为 SSTable 文件写入磁盘。由于内存表中已经对写入的记录按照 key 进行去重和排序,在数据写入的时候可以顺序的写入文件。有新的记录写入时,则创建一个新的内存表重复次逻辑。
  3. 收到读请求时,先从内存表中读取 key,一次向后续的 SSTable 查找 key。
  4. 后台启动线程对 SSTable 进行合并与压缩,合并 SSTable 文件并将覆盖和删除 key 移除。由于每个分段文件均已经按照 key 排序,因此可以按照归并顺序将其归并了一个文件中。如果遇到相同的 key,则以最新文件的值为准。

这个方案最大的问题在于数据库崩溃之后内存表会丢失,从而导致数据丢失。可以考虑数据写入时仍然追加日志表,这个文件并不需要按 key 排序。当数据库崩溃重启后,从追加的日志中构建内存表。当内存表写入分段文件后,日志表就可以被丢弃。

性能优化

当数据不存在时,需要扫描所有的内存表,从而造成性能开销。在 LSM 树中,可以加入一个布隆过滤器,避免无效的查询,提升查询性能。

可以根据业务场景采取对 SSTable 压缩和合并采用不同的策略:

  • Size-Tiered Compaction策略 (STC):这种策略是将大小相近的几个SSTable合并成一个新的SSTable。这种策略的优点是写放大系数较小,即每个键值对被重写的次数相对较少,从而减少了写入的I/O开销。但是这种策略的缺点是读放大系数较大,即在查询時可能需要查找多个SSTable,从而增加了查询的I/O开销。另外,这种策略可能会占用更多的磁盘空间。
  • Leveled Compaction策略 (LC):这种策略是将SSTable分为多个层级,同一层级的SSTable大小相同,但是不同层级的SSTable大小不同。当一个层级的SSTable数量达到一定数目时,会将这个层级的SSTable和下一个层级的部分SSTable合并。这种策略的优点是读放大系数小,即在查询时只需要查找少量的SSTable,从而减少了查询的I/O开销。另外,这种策略占用的磁盘空间相对较小。但是这种策略的缺点是写放大系数较大,即每个键值对可能被重写多次,从而增加了写入的I/O开销。

LSM 树的基本思想是将数据存储到分段的 SSTable 中,后台对 SSTable 进行压缩与合并。使用 SSTable 可以直接按照 key 进行范围查询,同时写入是按顺序写入磁盘,保证了写入的速度。

B 树

B 树是目前使用最广泛的索引结构。和 LSM 树一样,B 树支持精确的 key 查询和范围查询。只是底层的存储结构不同,LSM 树是将数据分段,顺序写入 SSTable。B 树将数据库分解成固定大小的块(block)或分页(page),传统上大小为 4KB(有时会更大),并且一次只能读取或写入一个页面。这种设计更接近于底层硬件,因为硬盘空间也是按固定大小的块来组织的。

每个页面都可以使用地址或位置来标识,这允许一个页面引用另一个页面 ------ 类似于指针,但在硬盘而不是在内存中。我们可以使用这些页面引用来构建一个页面树,如图所示。

整体流程

以查询 user_id = 251 为例子,先从根节点加载页面数据;寻找到[200,300)的节点地址;加载数据寻找[250,270)的数据;加载叶子结点;返回 user_id=251 的数据。

与 LSM 树不同的是,B 树中每个 key 的数据只存储一份。数据的修改、删除均是操作这份数据。下图是添加一个 key 为 334 的数据。当叶子结点已经写满时,则需要将节点拆分为两个新的半新页面,并将数据索引更新到付节点。数据删除时则需要考虑是否需要调整节点保证树的平衡。

这个算法可以确保树保持平衡:具有 n 个键的 B 树总是具有 O(logn) 的深度。大多数数据库可以放入一个三到四层的 B 树,所以你不需要追踪多个页面引用来找到你正在查找的页面(分支因子为 500 的 4KB 页面的四层树可以存储多达 256TB 的数据)。

在数据变更时,操作的是同一个节点,且涉及节点的调整。为了避免因为数据库崩溃而导致索引没有完全更新,通常会增加一个额外的数据结构:预写式日志(WAL,即 write-ahead log,也称为 重做日志,即 redo log)。这是一个仅追加的文件,每个 B 树的修改在其能被应用到树本身的页面之前都必须先写入到该文件。当数据库在崩溃后恢复时,这个日志将被用来使 B 树恢复到一致的状态。

多线程对数据进行读取时,为了保证数据的一致性,需要使用锁机制控制读逻辑。在 LSM 中,通过日志文件 append 和异步合并解决了这个问题。分段文件合并之后会使用原子操作替换哈希索引。

性能优化

  • 不同于覆写页面并维护 WAL 以支持崩溃恢复,一些数据库(如 LMDB)使用写时复制方案。经过修改的页面被写入到不同的位置,并且还在树中创建了父页面的新版本,以指向新的位置。
  • 我们可以通过不存储整个键,而是缩短其大小,来节省页面空间。特别是在树内部的页面上,键只需要提供足够的信息来充当键范围之间的边界。在页面中包含更多的键允许树具有更高的分支因子,因此也就允许更少的层级 。
  • 通常,页面可以放置在硬盘上的任何位置;没有什么要求相邻键范围的页面也放在硬盘上相邻的区域。如果某个查询需要按照排序顺序扫描大部分的键范围,那么这种按页面存储的布局可能会效率低下,因为每个页面的读取都需要执行一次硬盘查找。因此,许多 B 树的实现在布局树时会尽量使叶子页面按顺序出现在硬盘上。但是,随着树的增长,要维持这个顺序是很困难的。相比之下,由于 LSM 树在合并过程中一次性重写一大段存储,所以它们更容易使顺序键在硬盘上连续存储。
  • 额外的指针被添加到树中。例如,每个叶子页面可以引用其左边和右边的兄弟页面,使得不用跳回父页面就能按顺序对键进行扫描。
  • B 树的变体如分形树(fractal trees)借用了一些日志结构的思想来减少硬盘查找(而且它们与分形无关)。

LSM 树和 B 树对比

LSM 树和 B 树是存储数据的两种不同方式。在 LSM 树中,将数据分段存入到 SSTable 中,启动异步任务对分段数据进行合并,来释放空间。在 B 树中,将主键存储到一颗 B 树中,叶子结点存储元数据,数据的修改是操作同一个节点。

从写操作来看,LSM 树由于使用顺序写机制,可以重新发挥磁盘的写性能。从读操作来看,B 树的性能较高,它不会像 LSM 树一样需要不断向后查找不同的内存表。从磁盘空间的使用上来看,LSM 树顺序写磁盘,不会产生空间碎片,如果合并不及时会造成冗余存储;B 树中每个 key 只存储在一个地方,不会造成冗余存储,但是如果数据频繁变更可能会导致空间碎片,同时需要进行并发控制。

其他索引结构

目前介绍了散列索引、LSM 树、B 树都是数据中的一个 Key 「数据行的唯一标识」搭建的索引,当对该 Key 进行精确的查询时可以避免全表扫描,对于 LSM 和 B 树还支持范围查询。如果想对数据中的其他字段进行精确查询时,则仍然需要全表扫描。这时候可以创建一个次级索引,次级索引的值往往会对应多条记录。通常在叶子结点中存储对应的主键集合,然后通过主键查询具体的数据。

索引中存储数据

索引是用来精确查找具体值的数据,在叶子结点中存储整行的数据。我们将数据存储到索引中的索引称为聚集索引。聚集索引决定了数据在磁盘中的物理顺序。而在次级索引中,为了避免数据存储多份。通常在叶子结点中存储聚集索引的值。我们将这种索引中不存在行数据的索引叫做非聚集索引。

如下图所示,左边是一个聚集索引「id」,叶子结点中存储了每一行的数据。右边是一个非聚集索引「name」,叶子结点中存储了「id」。当查询指定 name 的数据时,需要先查询到对应的 id,然后再去聚集索引进行查询。通常将这一过程叫做回表。

为了避免回表,又提出一种新的概念叫做覆盖索引。相当于聚集索引和非聚集索引的一个中间态。将部分字段放到索引的叶子结点。例如,业务上最常使用到的数据时 name 和 sex,查询条件时 name。则可以创建 name, sex 作为覆盖索引,从而避免回表。

在上面的例子均是使用行中的一个属性用来构建索引。如果想查询数据表中的多列,可以使用多列索引来加快索引速度。

全文搜索

上面介绍的索引查询中,都是通过具体的属性来搭建索引。那有没有什么方法不指定属性,直接从全部的数据中查询包含特定词的记录呢?将这种查询叫做全文搜索。最常见的方式就是倒排索引,一种将词汇和出现该词汇的文档列表映射起来的索引方法,可以用来实现全文搜索。

内存数据库

上面介绍的数据存储方案中,均是将最终的数据存储到磁盘中。使用磁盘的优势在于成本低,最核心的是程序崩溃后数据不会丢失。然而使用磁盘的缺点就是读取速度慢,通常会将索引加载到内存中提升读取速度。数据仍然是存储在磁盘中防止数据丢失。

当然,现在也有一些数据库直接在内存中存储。它不用考虑磁盘的结构而实际对应的存储方式。可以支持复杂的数据结构。例如 redis 中的 string、set、hash、zset 等。为了避免数据库崩溃而导致的数据丢失,可以想 LSM 树和 B 树一样,先讲数据直接写入文件,再写入内存。或者定期将快照存储到文件中。此外,为了解决内存空间有限的问题。可以像操作系统一样,采取虚拟内存的形式。或者使用分布式的形式。

OLTP 和 OLAP

在应用程序中,可以使用索引来实现少量数据的查询,快速的完成用户的交互。对于这种查询数据量少,请求量大,对时延要求高的场景,称为在线事务处理「OLTP, Online Transaction Processing」。除了这种场景外,还有一种场景叫做在线事务分析处理「OLAP,Online Analytice Processing」。它通常需要扫描大量的数据用于数据分析,将最终的统计结果返回给用户,并非原始的数据。例如,根据用户交易表,计算本月的收入,家电销售额等。

下面是 OLTP 和 OLAP 的一些区别:

在互联网发展早期,数据量并不大。可以使用一个书库直接实现 OLTP 和 OLAP。随着互联网规模的不断变大,数据量开始发生指数级别的增长。使用同一个数据库已经不能同时支持两种场景。针对 OLAP 这一场景,开始转向单独的数据库上进行运行,这种数据库叫做数据仓库「Data Warehouse」。

数据仓库

如下图所示,是一个大型互联网公司的数据系统架构。整体分成了电商系统、库存系统、路线规划系统,这样拆分的好处是各个系统可以独立演进,避免耦合。对于在线业务,业务相对独立,拆成子系统可以降低数据规模,同时提升可维护性。

与在线系统不同的是,数据分析师往往需要聚合大量的数据进行统计,从而制定不同的策略。为了避免数据分析对 OLTP 系统的影响,往往对其数据库进行提取「往往通过数据流的形式,例如 binlog」,转换成合适的分析模式,清理并加载到数据仓库中。

使用数据仓库可以将 OLTP 和 OLAP 模式进行分离,避免相互影响。上面介绍的数据模式「关系、文档、图、KV」,索引「散列索引、LSM 树、B 树」往往更适用于 OLTP 的数据库。针对 OLAP 场景,则需要单独的数据模型和存储结构,优化读取和写入的性能。

分析模式

在第二篇文章中,介绍不同的数据模式。用于对现实问题进行抽象,方便应用程序进行读写操作。在 OLAP 中,数据模式则相对比较统一,使用星型模式来建模。

下图是一个使用星型模式构建的数据仓库。之所以被叫做星型模式,是因为整个关系图是由一个事实表作为中心,周边为维度表。事实表「fact_sales」:每一行表示一个事件,即用户购买的商品,来自那个仓库,对应的活动,购买数量和价格。事实表相当于一个完整的事件,它描述了事件发生的时间、地点、内容等属性。维度表「product、store、date、customer、promotion」: 它表示的是事件发生过程中的描述信息,为事实表中的事件提供上下文。

列式存储

OLAP 的特点在于读取数据量大,且需要进行聚合运算。但是在分析的时候往往只需要聚合部分列。例如:分析人们是否倾向于在一周里的某一天购买新鲜水果或糖果。在这个例子中,会扫描全年的数据,但是查询的列只有三行。

vbnet 复制代码
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;

这个时候使用索引是并不能解决我们的问题。索引的特点是可以快速定位少量的行,从而减少磁盘读取。为了优化 OLAP 数据库中的查询。通常使用列式存储的方案来解决。

如下图所示,在具体存储的时候,每个列对应一个列文件。这意味着如果要读取一个完整的行就需要读取每一个列文件进行聚合。

列压缩

为了减少对磁盘的吞吐,通常数据进行压缩。使用列存储之后,由于列的数据格式是固定的,且存在大量的重复数据,更容易进行压缩处理。下面展示的是使用位图编码处理后的数据,由于位图编码后有大量的 0, 即数据是稀疏的。此时,可以在使用游程编码进行二次编码,使列的编码非常紧凑。

列排序

和 LSM 树一样,可以选择按照列进行排序,从而方便进行范围扫描。例如,我们需要聚合分析全年的数据,则可以考虑使用按照 date 列进行排序。为了保证行数据的正常读取,当一个列的顺序确定之后,其他列的顺序也就确定了。对于列中相同的数据,可以选择第二列进行排序。

列排序保证了此列的数据按照顺序存储。由于数据量巨大,必然会有大量的相同数据存储在一起。这时可以使用游程编码对该列数据进行压缩,从而降低磁盘吞吐。

列写入

使用列存储,并在列上进行排序和压缩,极大的节省了磁盘的吞吐。然而,却增加了数据写入的难度。对于使用列排序方式的写入,使用 B 树是不太现实的。当插入一行数据时,可能会导致所有的数据读需要发生重写。可以使用 LSM 树的方式,在内存表中维护列的顺序,只是在数据写入的时候按照列的方式写入。后台启动线程对列文件进行压缩与合并。数据查询时将内存表的数据与写入的列文件进行合并即可。

总结

针对业务场景的不同,将数据库类型分为两类:OLTP 和 OLTP。OLTP:主要特点是查询的数据量少,对写入和查询的性能要求高。OLTP: 主要特点是需要读取大量的列才能计算最终的结果,对实时性要求不是很高。

针对 OLTP 场景,通常使用的哈希索引、LSM 树、B 树的形式存储数据。LSM 树是将数据分为一个个数据段,每个数据段里存储排序过的数据,通过后台程序对数据段进行归并,释放空间。B 树是将数据放到一个个数据块中存储,数据的更新和删除都是操作同一块地址。LSM 树的优点是写入速度快,查询速度慢,如果数据不及时合并,也会造成空间浪费。B 树的优点是查询速度快,不会对一个数据进行多次存储,但是数据变更需要复杂处理,且数据读写需要进行加锁控制。

针对 OLTP 场景,使用 LSM 树和 B 树并不能优化查询速度。由于 OLTP 需要读取大量数据,重点考虑如何优化磁盘吞吐。使用列存储可以很好的解决这个场景中的问题。由于列中的数据格式相同,且存在大量重复的数据。可以很方便的进行压缩处理。

数据库的本质是对数据读写的抽象,方便应用程序对数据库进行读写操作。本文只是泛泛的介绍针对不同场景,数据库如何使用相关的数据结构存储数据。但其中仍然涉及大量的细节和优化,这里就不做深入讨论。

相关推荐
何中应10 分钟前
Spring Boot中选择性加载Bean的几种方式
java·spring boot·后端
web2u1 小时前
MySQL 中如何进行 SQL 调优?
java·数据库·后端·sql·mysql·缓存
michael.csdn1 小时前
Spring Boot & MyBatis Plus 版本兼容问题(记录)
spring boot·后端·mybatis plus
Ciderw2 小时前
Golang并发机制及CSP并发模型
开发语言·c++·后端·面试·golang·并发·共享内存
Мартин.2 小时前
[Meachines] [Easy] Help HelpDeskZ-SQLI+NODE.JS-GraphQL未授权访问+Kernel<4.4.0权限提升
后端·node.js·graphql
程序员牛肉2 小时前
不是哥们?你也没说使用intern方法把字符串对象添加到字符串常量池中还有这么大的坑啊
后端
烛阴2 小时前
Go 语言进阶必学:&^ 操作符,高效清零的秘密武器!
后端·go
网络风云2 小时前
golang中的包管理-下--详解
开发语言·后端·golang
京东零售技术3 小时前
一次线上生产库的全流程切换完整方案
后端
我们的五年3 小时前
【C语言学习】:C语言补充:转义字符,<<,>>操作符,IDE
c语言·开发语言·后端·学习