【DDIA】存储和查询

Capture III -- 存储和查询

聊完了数据模型和查询语言,这一章我们聊一聊数据存储和查询优化。数据库分为两类:OLTP 和 OLAP。

引擎类型 请求数量 数据量 瓶颈 存储格式 用户 场景举例 产品举例
OLTP 相对频繁,侧重在线交易 总体和单次查询都相对较小 Disk Seek 多用行存 比较普遍,一般应用用的比较多 银行交易 MySQL
OLAP 相对较少,侧重离线分析 总体和单次查询都相对巨大 Disk Bandwidth 列存逐渐流行 多为商业用户 商业分析 ClickHouse

其中,OLTP 侧,常用的存储引擎又有两种流派:

流派 主要特点 基本思想 代表
log-structured 流 只允许追加,所有修改都表现为文件的追加和文件整体增删 变随机写为顺序写 Bitcask、LevelDB、RocksDB、Cassandra、Lucene
update-in-place 流 以页(page)为粒度对磁盘数据进行修改 面向页、查找树 B 族树,所有主流关系型数据库和一些非关系型数据库

下面,我们先聊聊 OLTP 数据库的存储和查询方式。

OLTP型

Bitcask

我们首先介绍一种很特殊但实现很简单的数据库 --- Bitcask。之所以特殊,是因为他是一个全内存数据库,之所以简单,是因为他的核心 get/set 指令如下

bash 复制代码
#!/bin/bash
db_set () {
    echo "$1,$2" >> database
}

db_get () {
    grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

从 shell 脚本可以看出,其存取的基本原理为:

  • set:在文件末尾追加一个 KV 对。
  • get:匹配所有 Key,返回最后(也即最新)一条 KV 对中的 Value。

可以看出这是一种典型的以读换写:写很快,但是读需要全文逐行扫描,会慢很多。为了加快读,Bitcask 在内存中构建了一个哈希索引。索引定义如下:

  • Key 是查询 Key
  • Value 是 KV 条目的起始位置和长度。

看来很简单,但这正是 Bitcask 的基本设计,但关键是,他 Work(在小数据量时,即所有 key 都能存到内存中时)能提供很高的读写性能:

  • 写:文件追加写。
  • 读:一次内存查询,一次磁盘 seek;如果数据已经被缓存,则 seek 也可以省掉。

如果你的 key 集合很小(意味着能全放内存),但是每个 key 更新很频繁,那么 Bitcask 便是你的菜。

你可能有很多疑惑?

  1. 由于频繁写入,单个文件越来越大,磁盘空间不够怎么办?
  2. Bitcask 如何应对数据的更新?

实际上,Bitcask 具有如下三种机制:

  1. 分割文件:化整为零
    Bitcask 不会让单个数据文件无限增长。它将写入操作局限在一个活动文件中,采用仅追加的方式顺序写入,保证了极高的写入吞吐量。当这个活动文件的大小达到用户设定的阈值(例如 4MB 或 1GB)后,它会被立即关闭,变为一个只读的旧数据文件。之后系统会创建一个全新的活动文件来继续承接写入请求。通过这种方式,庞大的数据被分解为一个个大小适中、不可变的旧文件和一个接受写入的活动文件,为后续的清理奠定了基础。
  2. 标记过期:写入即生效
    Bitcask 的处理方式很巧妙,它把"修改"和"删除"也当作一次新的写入。当更新一个已有 Key 时,Bitcask 并不会去找到并覆盖旧的数据。它只是在当前活动文件的末尾追加写入一条新的 Key-Value 数据,然后更新内存中的索引(KeyDir),让索引指向新数据的位置。执行删除操作时,Bitcask 同样是追加写入一条特殊的记录,称为墓碑值(Tombstone),并更新内存索引,标记该 Key 已被删除。
    这样一来,旧的数据文件里就积压了大量"无效"的旧版本数据和被标记删除的数据,它们白白占着磁盘空间,等待被清理。
  3. 合并清理:后台回收,一劳永逸
    这是解决磁盘空间问题的核心步骤。Bitcask 会在后台定期或手动执行一个名为 合并(Merge) 的操作。
    这个过程就像一个高效的档案整理员:合并进程会遍历所有只读的旧数据文件。他会根据内存中的最新索引,找出每个 Key 在旧文件中的最新、有效的那条数据。对于被标记了"墓碑值"的 Key,则直接忽略,表示这些数据可以被物理删除了。
    将这些筛选出的有效数据,重新写入到一组新的数据文件中。这些新文件只包含每个 Key 的最新值,因此体积会小很多。合并完成后,系统会用这些新生成的、更紧凑的文件,去替换掉那些包含大量垃圾数据的旧文件。被替换的旧文件就可以被安全地删除,从而真正释放磁盘空间。
    为了加速系统重启后内存索引的重建,Bitcask 在合并时还会为每个新数据文件生成一个对应的 提示文件(Hint File)。它相当于一份精简的索引,只记录 Key 的位置信息,不包含 Value,加载速度极快。

当然,Bitcask 还有很多其他的 feature,在这里我就不一一介绍了,感兴趣的友友们可以去 Bitcask 自行了解。

SSTable

对于 KV 数据,BitCask 的存储结构是:

  • 外存上日志片段
  • 内存中的哈希表

其中外存上的数据是简单追加写而形成的,并没有按照某个字段有序。假设加一个限制,让这些文件按 Key 有序。我们称这种格式为:SSTable(Sorted String Table)。这种文件格式的优点是:

  • 高效的数据文件合并。即有序文件的归并外排,顺序读,顺序写。

  • 不需要在内存中保存所有数据的索引。仅需要记录下每个文件界限(以区间表示:[startKey, endKey],当然实际会记录的更细)即可。查找某个 Key 时,去所有可能包含该 Key 的 block 对应的文件二分查找即可。

SSTables 格式听起来很美好,但须知数据是乱序的来的,我们如何得到有序的数据文件呢?

  • 构建 SSTable 文件。将乱序数据在外存(磁盘 or SSD)中上整理为有序文件,是比较难的。但是在内存就方便的多。于是一个大胆的想法就形成了:

    • 在内存中维护一个有序结构(称为 MemTable)。红黑树、AVL 树、跳表。
    • 到达一定阈值之后全量 dump 到外存。
  • 维护 SSTable 文件。对于上述复合结构,我们怎么进行查询:

    • 先去 MemTable 中查找,如果命中则返回。
    • 再去 SSTable 按时间顺序由新到旧逐一查找。

同样,如果 SSTable 文件越来越多,则查找代价会越来越大。因此需要将多个 SSTable 文件合并,以减少文件数量,同时进行 GC,我们称之为紧缩( Compaction),这一流程我们也很熟悉了。如果出现宕机,内存中的数据结构将会消失。解决方法也很经典:WAL(预写日志,Write-Ahead Logging)。

LSM-Tree

将前面两节的一些碎片有机的组织起来,便是时下流行的存储引擎 LevelDB 和 RocksDB 后面的存储结构:LSM-Tree。

这种数据结构是 Patrick O'Neil 等人,在 1996 年提出的:The Log-Structured Merge-Tree。Elasticsearch 和 Solr 的索引引擎 Lucene,也使用类似 LSM-Tree 存储结构。但其数据模型不是 KV,而是类似:word → document list。

LSM-Tree 在 SSTable 基础上进行了一些优化,例如:

  • 优化 SSTable 的查找。常用 Bloom Filter。该数据结构可以使用较少的内存为每个 SSTable 做一些指纹,起到一些初筛的作用。
  • 层级化组织 SSTable。用以控制 Compaction 的顺序和时间。常见的有 size-tiered 和 leveled compaction。LevelDB 便是支持后者而得名。前者比较简单粗暴,后者性能更好,也因此更为常见。

但无论有多少变种和优化,LSM-Tree 的核心思想都是保存一组合理组织、后台合并的 SSTables。可以方便的进行范围遍历,可以变大量随机为少量顺序。

B族树

B 树于 1970 年被 R. Bayer and E. McCreight 提出后,便迅速流行了起来。现在几乎所有的关系型数据中,它都是数据索引标准一般的实现。与 LSM-Tree 一样,它也支持高效的点查和范围查。但却使用了完全不同的组织方式。

其特点有:

  • 以页(在磁盘上叫 page,在内存中叫 block,通常为 4k)为单位进行组织。
  • 页之间以页 ID 来进行逻辑引用,从而组织成一颗磁盘上的树。
    查找。从根节点出发,进行二分查找,然后加载新的页到内存中,继续二分,直到命中或者到叶子节点。查找复杂度为 O(logn),其中 n 为树的节点总数,影响树高度的因素:分叉数(某节点具有的子节点数,通常是几百个)。

插入 or 更新 。和查找过程一样,定位到原 Key 所在页,插入或者更新后,将页完整写回。如果页剩余空间不够,则分裂后写入。
分裂 or 合并 。级联分裂和合并。

某条记录大于一个 page 怎么办? 树的节点是逻辑概念,page or block 是物理概念。一个逻辑节点可以对应多个物理 page。

B 树不像 LSM-Tree,会在原地修改数据文件。在树结构调整时,可能会级联修改很多 Page。比如叶子节点分裂后,就需要写入两个新的叶子节点,和一个父节点(更新叶子指针)。他的解决方案是:

  • 增加预写日志(WAL),将所有修改操作记录下来,预防宕机时中断树结构调整而产生的混乱现场。
  • 使用锁机制对树结构进行并发控制。

B 树出来了这么久,有了很多优化:

  1. 不使用 WAL,而在写入时利用 Copy On Write 技术。同时,也方便了并发控制。如 LMDB、BoltDB。
  2. 对中间节点的 Key 做压缩,保留足够的路由信息即可。以此,可以节省空间,增大分支因子。
  3. 为了优化范围查询,有的 B 族树将叶子节点存储时物理连续。但当数据不断插入时,维护此有序性的代价非常大。
  4. 为叶子节点增加兄弟指针,以避免顺序遍历时的回溯。即 B+ 树的做法,但远不局限于 B+ 树。
  5. B 树的变种,分形树,从 LSM-tree 借鉴了一些思想以优化 seek。
B-Tree LSM-Tree 备注
优势 读取更快 写入更快
写放大 1. 数据和 WAL 2. 更改数据时多次覆盖整个 Page 1. 数据和 WAL 2. Compaction SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写
写吞吐 相对较低:1. 大量随机写 相对较高:1. 较低的写放大(取决于数据和配置)2. 顺序写入 3. 更为紧凑
压缩率 存在较多内部碎片 1. 更加紧凑,没有内部碎片 2. 压缩潜力更大(共享前缀) 但紧缩不及时会造成 LSM-Tree 存在很多垃圾
后台流量 更稳定可预测,不会受后台 compaction 突发流量影响 1. 写吞吐过高,compaction 跟不上,会进一步加重读放大 2. 由于外存总带宽有限,compaction 会影响读写吞吐 3. 随着数据越来越多,compaction 对正常写影响越来越大 RocksDB 写入太过快会引起 write stall,即限制写入,以期尽快 compaction 将数据下沉
存储放大 有些 Page 没有用满,存在碎片 同一个 Key 存多遍
并发控制 1. 同一个 Key 只存在一个地方 2. 树结构容易加范围锁 同一个 Key 会存多遍,一般使用 MVCC 进行控制

索引

在介绍 Bitcask、SSTable和LSM-Tree 时,我们提到了哈希索引。这一节,我们再详细地介绍下索引。

  • 数据库索引
    • 维度1:存储结构(物理存储)
      • 聚集索引。数据本身按某个字段有序存储,该字段通常是主键,则称基于此字段的索引为聚集索引,他会将索引和整行数据存在一块。
      • 非聚集索引。基于其他字段的索引为非聚集索引,在索引中存储的是索引列的值 + 指向数据行的指针。
    • 维度2:数据结构(实现机制)
      • B+Tree 索引(含多列索引)
      • 全文索引(倒排索引)
      • 模糊索引(GIN/GiST/Trigram)
      • 哈希索引
      • 空间索引(R-Tree)
    • 维度3:使用特性(逻辑属性)
      • 覆盖索引
      • 唯一索引
      • 前缀索引
      • 函数索引

OLAP型

聊完了 OLTP,很多友友好奇 OLAP 是什么呢?其实,一开始对于 AP 场景,仍然使用的传统的 TP 数据库。在模型层面来说,SQL 足够灵活,能够基本满足 AP 查询需求。但在实现层面,传统数据库在 AP 负载中的表现(大数据量吞吐较低)不尽如人意,因此大家开始转向在专门设计的数据库中进行 AP 查询,我们称之为数据仓库(Data Warehouse)。

AP 建模:星状型和雪花型

AP 中的处理模型相对较少,比较常用的有星状模型,也称为维度模型。

如上图所示,星状模型通常包含一张事件表(fact table) 和多张维度表(dimension tables)。事件表以事件流的方式将数据组织起来,然后通过外键指向不同的维度。

星状模型的一个变种是雪花模型,可以类比雪花(❄️)图案,其特点是在维度表中会进一步进行二次细分,讲一个维度分解为几个子维度。比如品牌和产品类别可能有单独的表格。星状模型更简单,雪花模型更精细,具体应用中会做不同取舍。

在典型的数仓中,事件表可能会非常宽,即有很多的列:一百到数百列。

列存

前一小节我们提到了维度表和事实表,对于后者来说,有可能达到数十亿行和数 PB 大。虽然事实表可能通常有几十上百列,但是单次查询通常只关注其中几个维度(列)。

由于传统数据库通常是按行存储的,这意味着对于属性(列)很多的表,哪怕只查询一个属性,也必须从磁盘上取出很多属性,无疑浪费了 IO 带宽,增大了读放大。

于是一个很自然的想法呼之欲出:每一个列分开存储好不好?

可以看出,不同列之间同一个行的字段可以通过下标来对应。

列压缩

将所有数据分列存储在一块,带来了一个意外的好处,由于同一属性的数据相似度高,因此更易压缩。

如果每一列中值阈相比行数要小的多,可以用位图编码(bitmap encoding)。举个例子,零售商可能有数十亿次的销售交易,但只有 100,000 个不同的产品。

上图中,是一个列分片中的数据,可以看出只有 {29, 30, 31, 68, 69, 74} 六个离散值。针对每个值出现的位置,我们使用一个 bit array 来表示:

  • bit map 下标对应列的下标
  • 值为 0 则表示该下标没有出现该值
  • 值为 1 则表示该下标出现了该值

如果 bit array 是稀疏的,即大量的都是 0,只要少量的 1。其实还可以使用游程编码(RLE,Run-length encoding) 进一步压缩:

  • 将连续的 0 和 1,改写成 数量+值,比如 product_sk = 29 是 9 个 0,1 个 1,8 个 0。
  • 使用一个小技巧,将信息进一步压缩。比如将同值项合并后,肯定是 0 1 交错出现,固定第一个值为 0,则交错出现的 0 和 1 的值也不用写了。则 product_sk = 29 编码变成 9,1,8
  • 由于我们知道 bit array 长度,则最后一个数字也可以省掉,因为它可以通过 array len - sum(other lens) 得到,则 product_sk = 29 的编码最后变成:9,1

位图索引很适合应对查询中的逻辑运算条件,比如:

SQL 复制代码
WHERE product_sk IN(30,68,69)

可以转换为 product_sk = 30、product_sk = 68和 product_sk = 69这三个 bit array 按位或(OR),最终变为

SQL 复制代码
WHERE product_sk = 31

列族

书中特别提到列族。它是 Cassandra 和 HBase 中的的概念,他们都起源于自谷歌的 BigTable 。注意到他们和列式存储有相似之处,但绝不完全相同:

  • 同一个列族中多个列是一块存储的,并且内嵌行键。
  • 并且列通常不压缩。

因此 BigTable 在用的时候主要还是面向行的,可以理解为每一个列族都是一个子表。

内存处理带宽

数仓的超大规模数据量带来了以下瓶颈:

  • 内存处理带宽。数据从内存到 CPU 的通路是有限的。如果处理每行数据都要加载大量无关数据,或者数据格式不紧凑,那么内存带宽会成为瓶颈------CPU 在等数据,而不是在算数据。
  • CPU 分支预测错误和流水线停顿。

什么是 CPU 分支预测错误呢?

  • CPU 有个硬件单元叫分支预测器(Branch Predictor),它的作用是在分支指令的结果确定之前,根据历史执行模式猜测分支的走向,并让 CPU 推测执行猜中的分支指令。这样可以在等待分支结果的同时继续工作,避免流水线空闲。如果猜对了,效率提升;如果猜错了,需要清空推测执行的指令并重新开始。
    举个🌰,

    理想状态下,每个时钟周期都能完成一条指令(吞吐量为 1)。假设存在如下的分支指令:
shell 复制代码
cmp eax, 30      ; 比较 eax 是否等于 30
je label_true    ; 如果相等,跳转到 label_true
mov ebx, 1       ; 否则执行这里(常见路径)
jmp next
label_true:
mov ebx, 2       ; 跳转到这里(罕见路径)
next:

当 CPU 遇到 je label_true(条件跳转指令)时,不知道下一指令是 mov ebx, 1 还是 mov ebx, 2,因为要等 cmp 的结果出来才知道,但这个结果要几个时钟周期后才能得到。

此时,CPU 有个硬件单元叫分支预测器(Branch Predictor),它会记录历史:之前 100 次这个跳转,95 次没跳,5 次跳了,预测:这次大概率也不跳,于是提前执行 mov ebx, 1 后面的指令。如果发现预测错误,则清空流水线。
为什么数仓会出现 CPU 分支预测错误和流水线停顿 瓶颈呢?

  • 数据分布不均匀性加剧
SQL 复制代码
-- 不同时间段的查询
SELECT * FROM orders WHERE amount > 1000;
-- 平时:只有 5% 的订单金额 > 1000(容易预测"不满足")
-- 双十一:80% 的订单金额 > 1000(预测器频繁出错)
-- 双十一后:又回到 5%(再次频繁出错)
  • 数据局部性丧失
    小数据量(全内存):
    ┌─────────────────────────┐
    │ [满足][不满足][满足]... │ ← 全部在内存,可以学习模式
    └─────────────────────────┘
    大数据量(磁盘分批加载):
    ┌──────────┐ 第一批:全是北京用户(全部满足 city='北京')
    ├──────────┤ 第二批:全是上海用户(全不满足)
    ├──────────┤ 第三批:混和数据(部分满足)
    └──────────┘
    每次换批次,分支模式剧变,预测器无所适从
  • 缓存失效加剧
shell 复制代码
; 处理第1行
cmp age, 30      ; age 在缓存中
je check_city    ; 预测器学习:这次跳了
; 处理第2行(可能缓存未命中,等待加载)
; CPU 空闲,预测器无法学习新模式
; 处理第3行(数据终于来了)
cmp age, 30      ; 模式已经变了
; 预测器还在用第1行的模式,大概率猜错

关于内存的瓶颈可以通过前述的数据压缩来缓解。对于 CPU 的瓶颈可以使用:

  • 列式存储和压缩可以让数据尽可能多地缓存在 L1 中,结合位图存储进行快速处理。
  • 使用 SIMD 用更少的时钟周期处理更多的数据。

列式存储的排序

  • 由于数仓查询多集中于聚合算子(比如 sum,avg,min,max),列式存储中的存储顺序相对不重要。但也免不了需要对某些列利用条件进行筛选,为此我们可以如 LSM-Tree 一样,对所有行按某一列进行排序后存储。

    注意,不可能同时对多列进行排序。因为我们需要维护多列间的下标间的对应关系,才可能按行取数据。同时,排序后的那一列,压缩效果会更好。

  • 此外,在分布式数据库中,同一份数据我们会存储多份。对于每一份数据,我们可以按不同列有序存储。这样,针对不同的查询需求,便可以路由到不同的副本上做处理。当然,这样也最多只能建立副本数(通常是 3 个左右)列索引。

这一想法由 C-Store 引入,并且为商业数据仓库 Vertica 采用。

列式存储的写入

上述针对数仓的优化(列式存储、数据压缩和按列排序)都是为了解决数仓中常见的读写负载,读多写少,且读取都是超大规模的数据。一个很自然的现象是,我们针对读做了优化,就让写入变得相对困难。

像 B 树那样的原地更新是不太行的。举个🌰,要在中间某行插入一个数据,纵向来说,会影响所有的列文件(如果不做 segment 的话);为了保证多列间按下标对应,横向来说,又得更新该行不同列的所有列文件。

所幸我们有 LSM-Tree 的追加流。将新写入的数据在内存中 Batch 好,按行按列,选什么数据结构可以看需求。然后达到一定阈值后,批量刷到外存,并与老数据合并。

数仓 Vertica 就是这么做的。

聚合:数据立方和物化视图

物化,可以简单理解为持久化。本质上是一种空间换时间的 tradeoff。

数据仓库查询通常涉及聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果这些函数被多次用到,每次都即时计算显然存在巨大浪费。因此一个想法就是,能不能将其缓存起来。

其与关系数据库中的视图(View)区别在于,视图是虚拟的、逻辑存在的,只是对用户提供的一种抽象,是一个查询的中间结果,并没有进行持久化。

物化视图本质上是对数据的一个摘要存储,如果原数据发生了变动,该视图要被重新生成。因此,如果写多读少,则维持物化视图的代价很大。但在数仓中往往反过来,因此物化视图才能较好的起作用。

物化视图一个特化的例子,是数据立方(data cube,或者 OLAP cube):按不同维度对量化数据进行聚合。

上图是一个按日期和产品分类两个维度进行加和的数据立方,当针对日期和产品进行汇总查询时,由于该表的存在,就会变得非常快。

在实际中,需要针对性的识别(或者预估)每个场景查询分布,针对性的构建物化视图。

相关推荐
what丶k1 小时前
【微服务】Spring AI 使用详解:让微服务无缝集成 AI 能力
java·后端·ai编程
iqiu1 小时前
一键启动支持postgresql的nacos
后端·docker
蒸蒸yyyyzwd1 小时前
redis实战学习笔记p1-12
数据库·笔记
前端拿破轮1 小时前
利用Github Page + Hexo 搭建专属的个人网站(二)
前端·后端·ai编程
菜鸟小九1 小时前
redis原理篇(五种数据结构)
数据结构·数据库·redis
Fox爱分享1 小时前
拼多多面试: 设计“砍一刀”算法,怎么防止被刷破产?90% 的人死在了“最后 0.01 元”
后端·算法·面试
Henry Zhu1231 小时前
数据库:事务管理详解
数据库
初次攀爬者2 小时前
Redis分布式锁实现的三种方式-基于setnx,lua脚本和Redisson
redis·分布式·后端
kangzerun2 小时前
SQLiteManager:一个优雅的Qt SQLite数据库操作类
数据库·qt·sqlite