概要
数据库核心:数据结构
最简单的数据库
bash
#!/bin/bash
db_set () {
echo "$1,$2" >> database
}
db_get () {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
文本文件存储,每行是k-v,写入时追加写,像日志一样,日志机制非常重要。
大数据量查找,效率慢,需要更高效的数据结构:索引。
存储系统重要的权衡设计:
- 适当的索引可加快查询,但多出来的索引会影响写入性能
需要手动选择索引,保证加速查询,又不带来过多的开销。
hash索引
所有数据顺序追加到磁盘,为了加快查询,构建在内存中的hashmap
- k-key
- v-文件中特定的字节偏移量
写入时,追加数据到文件,并更新hashmap中数据的偏移量。
查找时,使用hashmap找到对应key的偏移量(存储位置),然后读取内容
这就是Bitcask的核心做法。
适用场景:
key能全量放在内存中,并且单个key更新频繁。书中举例,key为视频url,value为播放量。
问题:
单个文件越写越大,耗尽磁盘空间怎么办
应对思路:
单文件到一个大小后,新建文件写入(分段),原文件变为只读,并且原文件重写丢弃重复更新的key,只保存最新的(就像redis的aof重写)。并且压缩的段更小,可以在压缩时合并多个段。
看似可行,但是达到工业可用,仍需要解决的问题
- 日志格式,csv格式并不紧凑,可以用二进制,如len+record
- 删除记录,需要支持delete,但是日志文件不支持删除,可以标记为已删除(墓碑),后续压缩时,发现墓碑标记,就丢弃
- 崩溃恢复,宕机重启后,内存中的hashmap会丢失。可以全量扫描重新创建,但是可行的优化是,持久化每个段的hashmap快照,重启时,可更快加载。
- 记录写坏,校验值,发现损坏部分并丢弃
- 并发控制,只有一个进行追加写,写入并发度只有1,但是已写文件是只读的,所以可以并发读取和压缩
乍一看,基于日志的存储结构存在折不少浪费:需要以追加进行更新和删除。但日志结构有几个原地更新结构无法做的优点:
- 以顺序写代替随机写。对于磁盘和 SSD,顺序写都要比随机写快几个数量级。
- 简易的并发控制 。由于大部分的文件都是不可变 的,因此更容易做并发读取和紧缩。也不用担心原地更新会造成新老数据交替。
- 更少的内部碎片。每次紧缩会将垃圾完全挤出。但是原地更新就会在 page 中留下一些不可用空间。
当然,基于内存的哈希索引也有其局限:
- 所有 Key 必须放内存。一旦 Key 的数据量超过内存大小,这种方案便不再 work。当然你可以设计基于磁盘的哈希表,但那又会带来大量的随机写。
- 不支持范围查询。由于 key 是无序的,要进行范围查询必须全表扫描。
后面讲的 LSM-Tree 和 B+ 树,都能部分规避上述问题。
SSTables和LSM-Tree
对于前面的kv数据,整体是
- 磁盘,日志分段
- 内存,hashmap
磁盘上的是追加写入,key是无序的。而**SSTable(Sorted String Table)**就是按key进行排序。
由于段文件合并,所以每个合并的段文件中,单个key只会出现一次。
对比hash索引的优点
- 高效文件合并。有序文件归并外排,顺序读写,对于不同文件的相同key,保留最新段,丢弃旧的段
- 无需内存中保存全量key的索引,即变为了稀疏索引。根据每个文件的记录界限,根据key的有序性,在对应的段内,进行二分查找即可
- 分块压缩,节省空间,减少IO。具有相同前缀key的放到一块,称为block,内存中只记录block的索引。
构建和维护SSTable
对于实际数据,key是乱序写进来的,如何保证key的有序呢?
问题拆解:
- 如何构建
- 如何维护
构建SSTable:磁盘整理数据较难,内存整理是方便的。
- 内存中维护有序结构,如红黑树,avl,跳表等等
- 达到某一大小,持久化磁盘上
维护SSTable:为什么需要维护呢?首先要问,对于上述结构,我们怎么进行查询:
- 先去 内存的有序结构 中查找,如果命中则返回。
- 再去 磁盘上的SSTable 按时间顺序由新到旧逐一查找。
如果 SSTable 文件越来越多,则查找代价会越来越大。因此需要将多个 SSTable 文件合并 ,以减少文件数量,同时进行 紧缩( Compaction)。
缺陷:
崩溃时,内存中数据会丢失。解决方案,每个写入追加到单独的日志文件。这不就是wal?有意思
从SSTable到LSM-Tree
前面的内容结合起来,便是LSM-Tree****( Log-Structured MergeTree****),日志结构的合并树,因此,基于合并和压缩排序文件原理的存储引擎通常被称为LSM存储引擎
Elasticsearch 和 Solr 的索引引擎 Lucene,也使用类似 LSM-Tree 存储结构。但其数据模型不是 KV,但类似:word → document list。
性能优化
实际工程仍需对LSM-Tree做的性能优化
- 优化查找 。 对于不存在的键,需要先找内存表,然后从磁盘可能是很多个段按时间读取,最后读完发现不存在。可以用布隆过滤器
- 层级组织SSTable。不同的策略会影响SSTable压缩和合并的具体顺序和时机。 常见的思路有:大小分级和分层压缩。
核心思想:保存合理组织的,后台合并的SSTables,范围查询好,顺序写入
B-Trees
B 树于 1970 年被 R. Bayer and E. McCreight 提出后,便迅速流行了起来。现在几乎所有的关系型数据中,它都是数据索引标准一般的实现。
与 LSM-Tree 一样,它也支持高效的点查 和范围查。但却使用了完全不同的组织方式。
其特点有:
- 以页(在磁盘上叫 page,在内存中叫 block,通常为 4k)为单位进行组织。
- 页之间以页 ID 来进行逻辑引用,从而组织成一颗磁盘上的树。
查找。从根节点出发,进行二分查找,然后加载新的页到内存中,继续二分,直到命中或者到叶子节点。查找复杂度,树的高度------ O(lgn),影响树高度的因素:分支因子(分叉数,通常是几百个)。
插入 or 更新。和查找过程一样,定位到原 Key 所在页,插入或者更新后,将页完整写回。如果页剩余空间不够,则分裂后写入。
分裂 or 合并。级联分裂和合并。
- 一个记录大于一个 page 怎么办? 树的节点是逻辑概念,page or block 是物理概念。一个逻辑节点可以对应多个物理 page。
让B-Tree更可靠
b树会原地修改数据文件,当出现分裂时,要修改两个叶子节点,并更新父节点的叶子指针,这可能发生在崩溃时,导致恢复后出现错误。
解决:同样是WAL,mysql中就是redo咯
多线程访问
加锁,LSM-Tree在后台执行合并,并且旧段可以被并发读取
优化B-Tree
其他的优化
- 一些数据库用写时复制,而不是WAL来进行崩溃恢复。同时方便了并发控制
- 保留单个页中键的缩略信息,而不是完整信息,节省空间,可让一个页分更多的岔,有更少的层数。比如树中间页,可以只记录key的起始范围,可以将更多的键压入页中
- 为了优化范围扫描,有些采用写入时保证叶子节点的顺序保存在磁盘,但是这个成本很高。需要时刻保证有序性
- 为叶子节点增加兄弟指针,以避免顺序遍历时的回溯。即 B+ 树的做法
- B 树的变种,分形树,从 LSM-tree 借鉴了一些思想以优化 磁盘寻址
B-Tree对比LSM-Tree
存储引擎
B-Tree
LSM-Tree
备注
优势
读取更快
写入更快
写放大
-
数据和 WAL
-
更改数据时多次覆盖整个 Page
-
数据和 WAL
-
紧缩
SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写。
写吞吐
相对较低:
- 大量随机写。
相对较高:
-
较低的写放大(取决于数据和配置)
-
顺序写入。
-
更为紧凑。
压缩率
-
存在较多内部碎片。
-
更加紧凑,没有内部碎片。
-
压缩潜力更大(共享前缀)。
但紧缩不及时会造成 LSM-Tree 存在很多垃圾
后台流量
-
更稳定可预测,不会受后台 compaction 突发流量影响。
-
写吞吐过高,compaction 跟不上,会进一步加重读放大。
-
由于外存总带宽有限,compaction 会影响读写吞吐。
-
随着数据越来越多,compaction 对正常写影响越来越大。
RocksDB 写入太过快会引起 write stall,即限制写入,以期尽快 compaction 将数据下沉。
存储放大
-
有些 Page 没有用满
-
同一个 Key 存多遍
并发控制
-
同一个 Key 只存在一个地方
-
树结构容易加范围锁。
同一个 Key 会存多遍,一般使用 MVCC 进行控制。
其他索引结构
二级索引
非主键的其他属性到该元素(SQL 中的行,MongoDB 中的文档和图数据库中的点和边)的映射
聚集索引和非聚集索引
- 数据本身无序 的存在文件中,称为 堆文件(heap file),索引的值指向对应数据在 heap file 中的位置。这样可以避免多个索引时的数据拷贝。
- 数据本身按某个字段有序存储,该字段通常是主键。则称基于此字段的索引为聚集索引 (clustered index),从另外一个角度理解,即将索引和数据存在一块。则基于其他字段的索引为非聚集索引,在索引中仅存数据的引用。
- 一部分列内嵌到索引中存储,一部分列数据额外存储。称为覆盖索引(covering index) 或 包含列的索引(index with included columns)。
索引可以加快查询速度,但需要占用额外空间,并且牺牲了部分写入开销,且需要维持某种一致性
多列索引
现实场景中,多个字段联合查询更为常见
sql
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
AND longitude > -0.1162 AND longitude < -0.1004;
有两种处理方式
- 将多维转一维,然后使用常规的B-Tree索引
- 专门的空间索引,如R树
全文索引和模糊索引
前述索引只提供全字段的精确匹配,而不提供类似搜索引擎的功能。比如,按字符串中包含的单词查询,针对笔误的单词查询。
在工程中常用Lucene 包装出来的服务:Elasticsearch。他也使用类似 LSM-tree 的日志存储结构,但其索引是一个有限状态自动机,在行为上类似 Trie 树。(实际上是fst)。
内存中保存所有内容
随着内存成本的下降,且数据集并不特别大,内存数据库也得到很好发展。
根据是否需要持久化,内存数据大概可以分为两类:
- 不需要持久化。如只用于缓存的 Memcached。
- 需要持久化。通过 WAL、定期 snapshot、远程备份等等来对数据进行持久化。但使用内存处理全部读写,因此仍是内存数据库
优势不仅在于不需要读取磁盘,而在更于不需要对数据结构进行序列化、编码 后以适应磁盘所带来的额外开销。
当然,内存数据库还有以下优点:
- 提供更丰富的数据抽象。如 集合 和 优先级队列 这种只存在于内存中的数据抽象。
- 实现相对简单。因为所有数据都在内存中。
此外,内存数据库还可以通过类似操作系统 swap 的方式,提供比物理机内存更大的存储空间,但由于其有更多数据库相关信息,可以将换入换出的粒度做的更细、性能做的更好。
基于非易失性存储器(non-volatile memory,NVM)的存储引擎也是这些年研究的一个热点
事务型和分析型
事务不一定具有 ACID 特性,事务型处理多是随机的以较低的延迟进行读写,与之相反,分析型处理多为定期的批处理,延迟较高。
OLTP(online transaction processing):在线事务处理
OLAP(online analytic processing):在线分析处理
对比如下表
属性
OLTP
OLAP
主要读取模式
小数据量的随机读,通过 key 查询
大数据量的聚合(max,min,sum, avg)查询
主要写入模式
随机访问,低延迟写入
批量导入(ETL)或者流式写入
主要应用场景
通过 web 方式使用的最终用户
互联网分析,为了辅助决策
如何看待数据
当前时间点的最新状态
随着时间推移的
数据尺寸
通常 GB 到 TB
通常 TB 到 PB
最初sql非常灵活,可同时胜任这两种,但是后续大数据量场景下,sql表现不尽如人意,人们转而使用专门的数据库进行分析,称为数据仓库
数据仓库
一个企业,可能会有很多偏交易型的系统,如用户网站、收银系统、仓库管理、供应链管理、员工管理等等。这些系统对业务运行至关重要,通常要求高可用 与低延迟 ,因此直接在原库进行业务分析,会极大影响正常负载。因此需要一种手段将数据从原库导入到专门的数仓。
数据导入数据仓库的过程称为ETL:extract-transform-load。(提取-转换-加载)
TP 和 AP 都可以使用 SQL 模型进行查询分析。但是由于其负载类型完全不同,在查询引擎实现和存储格式优化时,做出的设计决策也就大相径庭。因此,在同一套 SQL 接口的表面下,两者对应的数据库实现结构差别很大。
虽然有的数据库系统号称两者都支持,比如之前的 Microsoft SQL Server 和 SAP HANA,但是也正日益发展成两种独立的查询引擎。近年来提的较多的 HTAP 系统也是类似,其为了服务不同类型负载底层其实有两套不同的存储,只不过系统内部会自动的做数据的冗余和重新组织,对用户透明。
星型和雪花型分析模式
星型模型 ,也称为维度模型
通常包含一张事实表(
fact table
) 和多张维度表(
dimension tables
)。事实表以事件流的方式将数据组织起来,然后通过外键指向不同的维度。
名称"星型模式"来源于当表关系可视化时 ,事实表位于中间,被一系列维度表包围;这些表的连接就像星星的光芒。
雪花型 是星型的一个变体,类比雪花(❄️)图案,其特点是在维度表中 会进一步进行二次细分 ,将一个维度分解为几个子维度。比如品牌和产品类别可能有单独的表格。星状模型简单,雪花模型精细,具体应用中会做不同取舍
典型的数据仓库中,事实表和维度通常都会很宽,达到几百列
列式存储
事实表通常万亿行,PB级别的数据大小,高效存储和查询有难度。
虽然表很宽,有几种列,但是一次查询往往只关注几个列(维度)。传统关系型数据库是按行存储,但是这种分析的场景下,虽然只用到一个属性,也必须从磁盘上取出很多属性,成本太高,一个想法是按列进行存储,将每列中的所有值放在一起存储。
不同列之间同一个行的字段可以通过下标来对应。当然也可以内嵌主键来对应,但那样存储成本就太高了。
列压缩
可以通过压缩来进一步降低磁盘IO,同一列的数据相似度高,更容易压缩。
通常,列中的不同值的数量小于行数(一个例如,零售商可能拥有数十亿个销售交易,但只有 100000 不同的产品)。现在可以使用 n 个不同值的列,并将其转换为n个单独的位图,每个位图对应每个不同的值,一个位对应一行。 如果行具有该值,该位为1,否则为0
如果n非常小(例如,表示国家的列可能具有大约200个不同的值),那么这些位图由每行一位存储。但是,如果n越大,在大多数位图中将会有很多零(它们很稀疏)。
此时,位图也 以进行游程编码:
- 将连续的 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)。
ini
WHERE product_sk = 31 AND store_sk = 3
可以转换为 product_sk = 31和 store_sk = 3的 bit array 的按位与,就可以得到所有需要的位置。
列族
列族(column families) 。它是 Cassandra 和 HBase 中的的概念,他们都起源于自谷歌的 BigTable 。注意到他们和列式(column-oriented)存储有相似之处,但绝不相同:
- 同一个列族中多个列是一块存储的,并且内嵌行键(row key)。
- 并且列不压缩
因此 BigTable模型主要还是面向行的
内存带宽和矢量化处理
数仓的超大规模数据量带来了以下瓶颈:
- 内存处理带宽
- CPU 分支预测错误和流水线停顿
关于内存的瓶颈可已通过前述的数据压缩来缓解。对于 CPU 的瓶颈可以使用:
- 列式存储和压缩可以让数据尽可能多地缓存在 L1 中,结合位图存储进行快速处理。
- 使用 SIMD(单指令多数据) 用更少的时钟周期处理更多的数据。
列存储的排序
由于数仓查询多集中于聚合算子(比如 sum,avg,min,max),列式存储中的存储顺序相对不重要。但也免不了需要对某些列利用条件进行筛选,为此我们可以如 LSM-Tree 一样,对所有行按某一列进行排序后存储。
注意,不可能同时对多列进行排序。因为我们需要维护多列间的下标间的对应关系,才可能按行取数据。
同时,排序后的那一列,压缩效果会更好。
在分布式数据库(数仓这么大,通常是分布式的)中,同一份数据我们会存储多份。对于每一份数据,我们可以按不同列有序存储。这样,针对不同的查询需求,便可以路由到不同的副本上做处理。当然,这样也最多只能建立副本数(通常是 3 个左右)列索引。
这一想法由 C-Store 引入,并且为商业数据仓库 Vertica 采用。
列式存储的写入
上述针对数仓的优化(列式存储、列压缩和按列排序)都是为了解决数仓中常见的读写负载,读多写少,且读取都是超大规模的数据。
我们针对读做了优化,就让写入变得相对困难。
比如 B 树的原地更新流 是不太行的。举个例子,要在中间某行插入一个数据,纵向 来说,会影响所有的列文件(如果不做 segment 的话);为了保证多列间按下标对应,横向来说,又得更新该行不同列的所有列文件。
所幸我们有 LSM-Tree 的追加流。
- 将新写入的数据在内存中 Batch 好,按行按列,选什么数据结构可以看需求。
- 然后达到一定阈值后,批量刷到外存,并与老数据合并。
数仓 Vertica 就是这么做的。
聚合-数据立方体和物化视图
不一定所有的数仓都是列式存储,但列式存储的种种好处让其变得流行了起来。
其中一个值得一提的是物化聚合(materialized aggregates,或者物化汇总)。
物化,可以简单理解为持久化。本质上是一种空间换时间的 tradeoff。
数据仓库查询通常涉及聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果这些函数被多次用到,每次都即时计算显然存在巨大浪费。因此一个想法就是,能不能将其缓存起来。
其与关系数据库中的视图 (View)区别在于,视图是虚拟的、逻辑存在的,只是对用户提供的一种抽象,是一个查询的中间结果,并没有进行持久化(有没有缓存就不知道了)。
物化视图本质上是对数据的一个摘要存储,如果原数据发生了变动,该视图要被重新生成。因此,如果写多读少,则维持物化视图的代价很大。但在数仓中往往反过来,因此物化视图才能较好的起作用。
物化视图一个特化的例子,是数据立方(data cube,或者 OLAP cube):按不同维度对量化数据进行聚合。
上图是一个按日期和产品分类两个维度进行加和的数据立方,当针对日期和产品进行汇总查询时,由于该表的存在,就会变得非常快。
当然,现实中,一个表中常常有多个维度,比如 3-9 中有日期、产品、商店、促销和客户五个维度。但构建数据立方的意义和方法都是相似的。
但这种构建出来的视图只能针对固定的查询进行优化,如果有的查询不在此列,则这些优化就不再起作用。
在实际中,需要针对性的识别(或者预估)每个场景查询分布,针对性的构建物化视图