DDIA读书笔记-第三章

概要

数据库核心:数据结构

最简单的数据库

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,但是已写文件是只读的,所以可以并发读取和压缩

乍一看,基于日志的存储结构存在折不少浪费:需要以追加进行更新和删除。但日志结构有几个原地更新结构无法做的优点:

  1. 以顺序写代替随机写。对于磁盘和 SSD,顺序写都要比随机写快几个数量级。
  2. 简易的并发控制 。由于大部分的文件都是不可变 的,因此更容易做并发读取和紧缩。也不用担心原地更新会造成新老数据交替。
  3. 更少的内部碎片。每次紧缩会将垃圾完全挤出。但是原地更新就会在 page 中留下一些不可用空间。

当然,基于内存的哈希索引也有其局限:

  1. 所有 Key 必须放内存。一旦 Key 的数据量超过内存大小,这种方案便不再 work。当然你可以设计基于磁盘的哈希表,但那又会带来大量的随机写。
  2. 不支持范围查询。由于 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:磁盘整理数据较难,内存整理是方便的。

  1. 内存中维护有序结构,如红黑树,avl,跳表等等
  2. 达到某一大小,持久化磁盘上

维护SSTable:为什么需要维护呢?首先要问,对于上述结构,我们怎么进行查询:

  1. 先去 内存的有序结构 中查找,如果命中则返回。
  2. 再去 磁盘上的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 一样,它也支持高效的点查范围查。但却使用了完全不同的组织方式。

其特点有:

  1. 以页(在磁盘上叫 page,在内存中叫 block,通常为 4k)为单位进行组织。
  2. 页之间以页 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

备注

优势

读取更快

写入更快

写放大

  1. 数据和 WAL

  2. 更改数据时多次覆盖整个 Page

  3. 数据和 WAL

  4. 紧缩

SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写。

写吞吐

相对较低:

  1. 大量随机写。

相对较高:

  1. 较低的写放大(取决于数据和配置)

  2. 顺序写入。

  3. 更为紧凑。

压缩率

  1. 存在较多内部碎片。

  2. 更加紧凑,没有内部碎片。

  3. 压缩潜力更大(共享前缀)。

但紧缩不及时会造成 LSM-Tree 存在很多垃圾

后台流量

  1. 更稳定可预测,不会受后台 compaction 突发流量影响。

  2. 写吞吐过高,compaction 跟不上,会进一步加重读放大。

  3. 由于外存总带宽有限,compaction 会影响读写吞吐。

  4. 随着数据越来越多,compaction 对正常写影响越来越大。

RocksDB 写入太过快会引起 write stall,即限制写入,以期尽快 compaction 将数据下沉。

存储放大

  1. 有些 Page 没有用满

  2. 同一个 Key 存多遍

并发控制

  1. 同一个 Key 只存在一个地方

  2. 树结构容易加范围锁。

同一个 Key 会存多遍,一般使用 MVCC 进行控制。

其他索引结构

二级索引

非主键的其他属性到该元素(SQL 中的行,MongoDB 中的文档和图数据库中的点和边)的映射

聚集索引和非聚集索引

  1. 数据本身无序 的存在文件中,称为 堆文件(heap file),索引的值指向对应数据在 heap file 中的位置。这样可以避免多个索引时的数据拷贝。
  2. 数据本身按某个字段有序存储,该字段通常是主键。则称基于此字段的索引为聚集索引 (clustered index),从另外一个角度理解,即将索引和数据存在一块。则基于其他字段的索引为非聚集索引,在索引中仅存数据的引用。
  3. 一部分列内嵌到索引中存储,一部分列数据额外存储。称为覆盖索引(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;

有两种处理方式

  1. 将多维转一维,然后使用常规的B-Tree索引
  2. 专门的空间索引,如R树

全文索引和模糊索引

前述索引只提供全字段的精确匹配,而不提供类似搜索引擎的功能。比如,按字符串中包含的单词查询,针对笔误的单词查询。

在工程中常用Lucene 包装出来的服务:Elasticsearch。他也使用类似 LSM-tree 的日志存储结构,但其索引是一个有限状态自动机,在行为上类似 Trie 树。(实际上是fst)。

内存中保存所有内容

随着内存成本的下降,且数据集并不特别大,内存数据库也得到很好发展。

根据是否需要持久化,内存数据大概可以分为两类:

  1. 不需要持久化。如只用于缓存的 Memcached。
  2. 需要持久化。通过 WAL、定期 snapshot、远程备份等等来对数据进行持久化。但使用内存处理全部读写,因此仍是内存数据库

优势不仅在于不需要读取磁盘,而在更于不需要对数据结构进行序列化、编码 后以适应磁盘所带来的额外开销

当然,内存数据库还有以下优点:

  1. 提供更丰富的数据抽象。如 集合 和 优先级队列 这种只存在于内存中的数据抽象。
  2. 实现相对简单。因为所有数据都在内存中。

此外,内存数据库还可以通过类似操作系统 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越大,在大多数位图中将会有很多零(它们很稀疏)。

此时,位图也 以进行游程编码:

  1. 将连续的 0 和 1,改写成 数量+值,比如 product_sk = 29 是 9 个 0,1 个 1,8 个 0。
  2. 使用一个小技巧,将信息进一步压缩。比如将同值项合并后,肯定是 0 1 交错出现,固定第一个值为 0,则交错出现的 0 和 1 的值也不用写了。则 product_sk = 29 编码变成 9,1,8
  3. 由于我们知道 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)存储有相似之处,但绝不相同:

  1. 同一个列族中多个列是一块存储的,并且内嵌行键(row key)。
  2. 并且列不压缩

因此 BigTable模型主要还是面向行的

内存带宽和矢量化处理

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

  1. 内存处理带宽
  2. CPU 分支预测错误和流水线停顿

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

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

列存储的排序

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

注意,不可能同时对多列进行排序。因为我们需要维护多列间的下标间的对应关系,才可能按行取数据。

同时,排序后的那一列,压缩效果会更好。

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

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

列式存储的写入

上述针对数仓的优化(列式存储、列压缩和按列排序)都是为了解决数仓中常见的读写负载,读多写少,且读取都是超大规模的数据。

我们针对读做了优化,就让写入变得相对困难。

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

所幸我们有 LSM-Tree 的追加流。

  1. 将新写入的数据在内存中 Batch 好,按行按列,选什么数据结构可以看需求。
  2. 然后达到一定阈值后,批量刷到外存,并与老数据合并。

数仓 Vertica 就是这么做的。

聚合-数据立方体和物化视图

不一定所有的数仓都是列式存储,但列式存储的种种好处让其变得流行了起来。

其中一个值得一提的是物化聚合(materialized aggregates,或者物化汇总)

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

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

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

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

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

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

当然,现实中,一个表中常常有多个维度,比如 3-9 中有日期、产品、商店、促销和客户五个维度。但构建数据立方的意义和方法都是相似的。

但这种构建出来的视图只能针对固定的查询进行优化,如果有的查询不在此列,则这些优化就不再起作用。

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

相关推荐
2401_857610035 分钟前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
杨哥带你写代码2 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_2 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
背水2 小时前
初识Spring
java·后端·spring
晴天飛 雪3 小时前
Spring Boot MySQL 分库分表
spring boot·后端·mysql
weixin_537590453 小时前
《Spring boot从入门到实战》第七章习题答案
数据库·spring boot·后端
AskHarries3 小时前
Spring Cloud Gateway快速入门Demo
java·后端·spring cloud
Qi妙代码3 小时前
MyBatisPlus(Spring Boot版)的基本使用
java·spring boot·后端
宇宙超级勇猛无敌暴龙战神4 小时前
Springboot整合xxl-job
java·spring boot·后端·xxl-job·定时任务