前两篇都是在介绍一些数据密集型应用的名词,包括可靠性,可扩展性,可维护性,数据模型等,这一篇我们来从数据存储的角度看看,不同的数据模型,怎样存储和检索数据.这里开始是比较硬核的内容了,前面的感觉书里面写的也比较简单.
首先来看看两个存储引擎家族:日志结构的存储引擎和面向页的存储引擎.
面向页的存储引擎,比如B-Tree一般用于传统的关系型数据库.
日志结构的存储引擎,比如LSM-Tree则用于大部分NoSQL的数据库.
数据库的核心:数据结构
#!/bin/bash
function db_set() {
echo "$1.$2" >> database
}
function db_get() {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
上面是一个最简单的K-V数据库.基于文本文件,每行代表一条记录.
db_set函数运行的非常快,因为采用追加的方式,对应于磁盘的顺序写操作.基于日志结构的数据库系统一般也采用这种方式,即日志文件只能追加,不能修改.
但是如果database文件保存了太多的数据,那么db_get函数的运行将不尽如人意.因为我们是扫描整个文件的.
为了提高查询的效率我们需要引入索引这个概念.索引就是通过记录一些额外的元数据,然后使用这些元数据作为路标,帮助定位需要查询的数据.
这里有一个基本原理:索引只会提升查询效率,但是一定会降低写入效率.因为每次写入都伴随了处理索引这个数据结构.
哈希索引
我们先从K-V索引开始.为了查询的更快我们可以想到编程语言中的hash map(或者hash table)将我们的查询效率从O(n)降低到O(1).
一种最简单的想法是,我们在内存中维护一张hash map,其中key就是数据库中的键,value则是值相对于文件开始处的偏移量.这样在查找的时候,就可以直接通过这张hash map找到对应的偏移量,然后直接读取value.
使用这种哈希索引的方式有以下缺陷:
- 必须能在内存中保存所有的key.
- 针对区间查询不友好.
但是使用这种方式存储数据的时候,还需要一种方式来避免磁盘被用尽.我们可以将database文件拆分为多个固定大小的段,当当前文件达到这个限制时就关闭,然后新的写入请求写入新的文件中,然后就可以在这些段上进行压缩操作.
压缩意味着去掉重复的键,对每个键只保留最新的value.同时在压缩过程中也可以通过归并的方式合并多个段,从而减少压缩段的数量.
我们可以对每一个段维护一个对应的hash map索引,这样在查找时则先查找最新段的hash map,如果不存在在查找次新段的hash map,以此类推.由于合并的存在我们不会维护太多的段,所以这个迭代查询是可以接受的.
这个想法还有一些需要解决的问题:
- database的文件格式.使用纯文本保存数据并不是最佳实践.我们可以使用二进制格式来替代.
- 目前不支持删除记录.删除记录的时候我们的database文件需要记录一个特殊的标记,同时在hash map中删除掉这个key.在合并段的时候如果发现这个标记则丢弃这个key所有记录.
- 崩溃恢复.如果系统崩溃,我们将丢失整个索引信息.虽然可以通过扫描所有的database文件进行重建,但是这样耗时很长.Bitcask通过将每个段的hash map的快照保存到磁盘上,可以更快的进行恢复.
- 部分写入的记录.记录写入database文件时也可能发生崩溃,导致记录没有写完整.Bitcask通过增加校验值的方式进行检查,发现以后直接丢弃该记录.
- 并发控制.写操作是顺序追加的,所以应该只有一个线程执行写动作.但是可以并发的读操作(读写锁).
使用追加的方式存储数据的优势:
- 追加和分段合并都是顺序写的动作.顺序写磁盘性能要比随机写好很多.
- 追加操作说明文件之前的记录是不可变的,这样进行并发控制和崩溃恢复比较简单.不会出现旧值和新值各一部分的情况.
- 合并操作可以避免随着时间的推移出现数据文件碎片化的问题.
SSTables和LSM-Tree
目前针对database中保存的内容是完全按照写入的顺序追加进来的.现在我们添加一条规则:要求对写入的K-V对根据K进行排序.
这似乎打破了顺序写入的规则,不过先让我们来看看这样做带来的好处吧.
- 合并段更加高效了.思想类似于合并多个有序链表的方式.
- 由于数据排列有序,现在不需要在内存中保存整个Key的hash map了.可以采用稀疏的hash map索引.
- 可以将两个索引之间的数据保存在一个块中并在写入磁盘之前进行压缩.然后稀疏内存索引的每个条目指向压缩快的开头.这样在范围扫描的时候可以减少磁盘的I/O.
构建和维护SSTable
在磁盘上维护排序结构是可行的(B-Tree),但是放在内存中更好操作.我们可以使用一些排序树结构来保证在写入database文件段时可以按照排序的方式顺序写入文件.比如:红黑树,AVL等.
这个存储引擎的工作流程如下:
- 写入新数据优先添加到内存中的红黑树结构中.这个结构也叫做内存表
- 当内存表到达一定规模的时候将其作为SSTable文件写入磁盘持久化.可以按照中序遍历的方式遍历内存表,达到顺序写入磁盘的目的.
- 处理读请求的时候,优先查看内存表,然后在查看最新的磁盘段文件,以此类推直到找到结果或者找不到结果.
- 后台进程周期性的执行合并和压缩过程.合并多个SSTable文件时丢弃那些被删除或者覆盖的值.
但是这个存储还没有处理崩溃的问题.这里我们采用在磁盘中单独保留操作日志的方式.该日志用来恢复内存表,同时当内存表保存为SStable文件的时候,可以丢弃相应的日志文件.
这种索引结构被称为:以日志结构的合并树命名,即LSM-Tree.
性能优化
如果查找不存在的Key时,LSM-Tree的查找效率很低,因为需要不断回溯保存的段文件.一般采用增加一个布隆过滤器来判断不存在的键.
段文件的压缩和合并时机存在两种策略:分层压缩,大小分级.
大小分级是将最新的和较小的SStable文件连续合并到较旧的和较大的SSTable文件中.
分层压缩则是键的范围分裂成多个更小的SSTable文件,旧数据被移动到单独的层级.
LSM-Tree的优势:
因为写入磁盘是顺序的,所以使用这种索引结构的数据库支持非常高的写入吞吐量.
B-Tree
B-Tree几乎是所有关系型数据库中的标准索引实现.
B-Tree和SSTable一样都是保留了按键排序的K-V对,只是B-Tree在设计理念上跟SSTable完全不一样.
SSTable将文件分为不同的段,但是B-Tree将数据库分解为固定大小的页,传统上大小为4kB,页是读写的最小单元.
每个页面都可以使用地址或者位置进行标识,这样可以让一个页面引用另一个页面,从而可以通过这些页面间的引用构建一个树状结构.
注意:B-Tree不是一个放在内存中的数据结构,是一个维护在磁盘上的数据结构.
在B-Tree中查找时总是从根结点出发,然后通过比较查找key和根结点中的key确定下一个要去读取的页,然后继续比较,直到到达叶子节点.该叶子结点中的要么保存着key的值,要么保存者指向key值的页的引用.
更新和写入B-Tree时,如果是更新,那么先找到该key对应的叶子页,然后修改该页的值,并写回磁盘.
写入时,需要找到其范围包含新key的页,并将该键写入.如果这个页中没有足够的空间容纳这个新key,那么这个页将分裂为两个半满的页,将新key写入分裂以后的页,其原来的父节点需要更新,保存新分裂出来的页.
这个写入算法确保了B-Tree的平衡.降低了树的高度,进而减小读取磁盘的次数.
使B-Tree可靠
为了能支持崩溃后恢复,B-Tree还需要引入额外的数据结构:预写日志(WAL).也叫做重做日志.这是一个仅支持追加的文件,每个B-Tree都必须先更新WAL再修改树本身.
还需要对并发进行控制.通常需要使用锁来保护书的数据结构.
优化B-Tree
- 不使用覆盖页和WAL进行崩溃恢复.采用写时复制的方案.
- 保存键的缩略信息,而不是完整信息,节省页空间,支持更多的键,降低树高度(B+Tree).
- 添加额外的指针到树中.保存向左或者向右的同级指针,方便顺序扫描.
- B-Tree的变体:分形树.
B-Tree VS. LSM-Tree
LSM-Tree优势:
- LSM-Tree支持比B-Tree更高的写入吞吐量.因为LSM-Tree具有较低的写放大,同时是顺序写入磁盘.而B-Tree最少需要写入两次(写WAL, 写树中的页),同时对于页的修改,即使仅修改少量的字节,也必须承担整个页的开销.同时B-Tree的页不一定是顺序写入的,新增的key可能同时要更新很多个页.
- LSM-Tree支持压缩,并且通常比B-Tree的文件小.B-Tree由于存在分裂页的情况,页中某些空间无法使用,会产生碎片.但是LSM-Tree可以通过合并的方式消除碎片.
LSM-Tree缺点:
- 压缩过程中会影响读写操作.但是B-Tree的延迟基本上跟有确定性.
- 由于功能和压缩合并在同时运行,高吞吐量的时候会导致磁盘的带宽被占满,并且有可能不满足需求.
- 因为B-Tree每个键在索引中都有一个确定的位置,然而LSM-Tree则存在多个副本,如果希望提供事务语义的场景,B-Tree是更好的方案.在许多关系型数据库中,事务隔离是通过键范围伤的锁来实现的,并且在B-Tree的索引中,这些所可以直接定义到树中.
其他索引结构
无论是LSM_Tree索引还是B-Tree索引都是KV索引,只表示了一个key到一条记录的方式.
二级索引也是很常见的,在关系型数据库中可以通过CREATE INDEX
命令创建二级索引.但是二级索引中一个Key可能对应多条记录.一般有两种解决方案: 1. 在索引中针对key保存一个链表,每个链表对应一条记录的位置. 2. 通过追加一些标识使每个键变得唯一.无论采用哪种方式,LSM-Tree和B-Tree都是可用的数据结构.
在有些场景中,通过索引找到对应记录的保存位置,然后在读取这个文件对应的位置获取记录增加了耗时,对于某些读取场景不可接受,那么我们可以考虑在索引中直接保存对应的值,这样做的索引叫做聚集索引.MySQL中InnoDB存储引擎中,主键的索引就是聚集索引.
这里有一点需要注意:增加索引仅能提升读取效率,对应的会增加写操作的负担.
多列索引
在查询多列数据的时候,我们需要多列索引.例如需要同时查询表的多个列.
一种最常见的做法是级联索引.他通过将一列追加到另外一列,将几个字段组合成一个键.由于存在级联的问题,所以按照非索引级联的顺序查询会遇到问题.
另外一种做法是采用多维索引.多维索引可以使用R树
来实现.
模糊索引
无论是单列索引还是多列索引都不能支持模糊查询的操作.在Lucene中采用了一种索引结构用来支持模糊查询.这种索引是键中字符序列的有限状态自动机,类似于字典树.
内存中保存所有内容
目前还有一种完全基于内存的数据库.这种数据库一般用于缓存的处理.针对缓存的使用场景是不需要考虑崩溃恢复的.应用代码会帮助进行缓存重建.
但是有一些内存数据库也是支持持久化的,一般采用如下方案:
- 使用特殊硬件结构供电.
- 将更改记录写入磁盘
- 定期保存快照到磁盘
- 复制内存中的状态到其他机器等.
尽管有些内存数据库会写入磁盘,但是磁盘仅记录了追加日志用于崩溃后恢复.
Redis和Couchbase通过一部写入磁盘提供较弱的持久化.
内存数据库一般比基于磁盘的数据库要更快,而且支持更多的数据结构类型.
但是内存数据库快的原因并不在于不需要读取磁盘,而是他们避免了使用写磁盘的格式对内存数据结构编码的开销. 例如:使用SSTable的数据库,需要在内存中维护一颗平衡树以便在写磁盘的时候可以按照SSTable的要求写入磁盘.
考虑操作系统的内存管理方式,基于内存的数据库也可以使用类似的方式,通过将最近最少使用的记录写入磁盘,在读取时重新从磁盘读入内存中,来突破内存容量的限制.但是这里有一个前提:索引必须完整的保存在内存中.
事务处理与分析处理
事务,主要指组成一个逻辑单元的一组读写操作.
分为两类:
- 根据索引中的键查询少量的数据,然后根据用户的输入插入或者更新这些数据-在线事务处理(OLTP)
- 查询大量的数据,每个记录只读取少量的列,并计算汇总统计信息(如,计数,求和,平均值等)-在线分析处理(OLAP)
我们来对比下这两种事务的特点:
属性 | OLTP | OLAP |
---|---|---|
主要读特征 | 基于键,每次查询返回少量记录 | 对大量记录进行汇总 |
主要写特征 | 随机访问,低延迟写入用户输入 | 批量导入(ETL)或事件流 |
典型使用场景 | 终端用户,通过网络应用程序 | 内部分析师,为决策提供支持 |
数据表征 | 最新的数据状态 | 随时间变化的所有事件历史 |
数据规模 | GB到TB | TB到PB |
为了针对OLAP进行优化和支持,现在一般是通过数据仓库
进行存储和查询数据.
数据仓库
传统的数据也是支持进行OLAP的,但是因为OLAP需要进行ETL(提取-转换-导入)操作,很有可能影响线上应用服务的OLTP服务,所以我们使用一个称为数据仓库的数据库为OLAP提供支持.
分离OLTP和OLAP事务使用的数据库一个很大的优势就是我们可以根据OLAP的特点对其进行优化了.
数据仓库数据组织模式
- 星型与雪花型分析模式
星型模式是数据仓库最基本的数据管理方式,也叫做维度建模.这种模式的中心是一张事实表,事实表的每一行表示在特定时间发生的事件.事实表有一些列会使用其他表的外键,这些列被叫做维度,那些被引用的表叫做维度表.
星型模式来源与这个建模方式,当数据可视化的时候,事实表被放在中心的位置,维度表分散在事实表周围构成星型.
该模式的一个变体叫做雪花模式,雪花模式就是在星型模式的基础上,将维度表进一步细分子空间.
一般事实表是一张比较宽的表,一般超过100列.维度表也可能会非常宽.但是在进行数据分析的查询时,我们可能只需要查询几列,但是星型模式下我们需要访问大量的行(包含所有列),然后仅输出指定的列.这样就引出了下面的存储方式. - 列式存储
列式存储的思路是:不要将一行中的所有值存储在一起,而是将每列中的所有值存储在一起.如果每一列的数据都单独保存为文件,那么查询的时候仅需要扫描对应列的文件即可.
列式存储有一个隐形的约定:每个列式存储的文件中都以相同的顺序保存着数据行.这样我们才可以从列存储中重建每一行的数据.
列压缩
采用列式存储以后,我们可以通过位图编码的方式对保存的数据进行压缩.
例如:
fact_sales表
date_key | product_sk | store_sk | promotion_sk | customer_sk | quantity | net_price | discount_price |
---|---|---|---|---|---|---|---|
140102 | 69 | 4 | NULL | NULL | 1 | 13.99 | 13.99 |
140102 | 69 | 5 | 19 | NULL | 3 | 14.99 | 9.99 |
140102 | 69 | 5 | NULL | 191 | 1 | 14.99 | 14.99 |
140102 | 74 | 3 | 23 | 202 | 5 | 0.99 | 0.89 |
140103 | 31 | 2 | NULL | NULL | 1 | 2.49 | 2.49 |
140103 | 31 | 3 | NULL | NULL | 3 | 14.99 | 14.99 |
140103 | 31 | 3 | 21 | 123 | 1 | 49.99 | 39.99 |
140103 | 31 | 8 | NULL | 233 | 1 | 0.99 | 0.99 |
列式存储磁盘布局:
date_key文件内容: 140102,140102,140102,140102,140103,140103,140103,140103
product_sk文件内容: 69,69,69,74,31,31,31,31
store_sk文件内容:4,5,5,3,2,3,3,8
使用位图编码压缩以后:
product_sk列值: 69,69,69,74,31,31,31,31
product_sk=69: 11100000
product_sk=74: 00010000
product_sk=31: 00001111
使用更为紧密的压缩:游程编码 (8位的情况)
product_sk=31: 4,4 (4个0, 4个1)
product_sk=69: 0,3 (0个0, 3个1,剩下为0)
product_sk=74: 3,1 (3个0, 1个1, 剩下为0)
在使用位图编码以后,加入要查询:
WHERE product_sk in (31, 69);
则只需要product_sk=31
和product_sk=69
两个向量执行按位或操作.
这样的位图编码压缩可以用在索引当中,同时加快读取的速度.
将这样的索引放在内存中,我们可以把压缩以后的列数据块放在cpu的缓存当中,同时使用迭代访问,加速数据的读取.
列存储中的排序
首先有一个前提:单独对列存储的某一列进行排序是没有意义的.这会打破列存储的约定.
但是排序可以带来更好的读取速度.如果数据是有序的,那么在查询的时候可能就不需要扫描所有的元数据了.
但是这给写入带来了困难.
考虑前面提到的LSM-Tree,我们可以在内存中保存一个有序的内存表,然后在达到阈值的时候写入列存储磁盘.内存中的这个有序结构是面向行的还是面向列的是无所谓的事情.然后我们可以在后台执行列文件的合并,并批量写入新的文件.
在查询的时候需要查看磁盘上的数据和内存中最近写入的数据,并且合并处理.
聚合:数据立方体和物化视图
在进行数据分析的时候经常会使用一些聚合函数,例如SQL提供的count
,sum
,avg
等.如果每次查询时都需要通过原始数据才能得到,会带来不必要的时间损耗.我们可以先缓存这些常用的聚合数据,这种缓存的方式被叫做物化视图.物化视图在关系型数据库中被定义为标准视图:一个类似表的对象,其内容为一些查询结果.
因为物化视图依赖原始数据而得到结果,所以当原始数据发生变化的时候物化视图也需要变.
物化视图一个常见的特例是数据立方体:它是由不同维度分组的聚合网格.
例如:
32 | 33 | 34 | 35 | ... | total | ||
---|---|---|---|---|---|---|---|
140101 | 149.60 | 31.01 | 84.58 | 28.18 | .... | 40710.53 | |
140102 | 2321 | 43 | 443 | 556 | 2234 | ... | 444444 |
140103 | 3433 | 22 | 34 | 445 | 342 | ... | 33333 |
... | ... | ... | ... | ... | ... | ... | ... |
total | 213212 | 344522 | 54542.2 | 33421 | 42345 | 45454 | 2421234 |
上面就是一个数据立方体的例子,可以按照这张表的行或者列得到去掉一个维度的总和.其中每一个单元格都是data-product组合的所有事实属性的聚合(这里是sum)
不只是对于两个维度,针对任意维度我们都可以创建这样一份数据立方体,只是我们不是很容易理解.
数据立方体的优势:某些查询会非常快,相当于使用了缓存.
缺点也很明显,数据立方体没有原始数据带来的灵活性.