DDIA 读书分享会,会逐章进行分享,结合我在工业界分布式存储和数据库的一些经验,补充一些细节。每两周左右分享一次。
第二章讲了上层抽象:数据模型和查询语言。本章下沉一些,聚焦数据库底层如何处理查询和存储。这其中,有个逻辑链条:
使用场景→ 查询类型 → 存储格式。
查询类型主要分为两大类:
引擎类型 | 请求数量 | 数据量 | 瓶颈 | 存储格式 | 用户 | 场景举例 | 产品举例 |
---|---|---|---|---|---|---|---|
OLTP | 相对频繁,侧重在线交易 | 总体和单次查询都相对较小 | Disk Seek | 多用行存 | 比较普遍,一般应用用的比较多 | 银行交易 | MySQL |
OLAP | 相对较少,侧重离线分析 | 总体和单次查询都相对巨大 | Disk Bandwidth | 列存逐渐流行 | 多为商业用户 | 商业分析 | ClickHouse |
其中,OLTP 侧,常用的存储引擎又有两种流派:
流派 | 主要特点 | 基本思想 | 代表 |
---|---|---|---|
log-structured 流 | 只允许追加,所有修改都表现为文件的追加和文件整体增删 | 变随机写为顺序写 | Bitcask、LevelDB、RocksDB、Cassandra、Lucene |
update-in-place 流 | 以页(page)为粒度对磁盘数据进行修改 | 面向页、查找树 | B族树,所有主流关系型数据库和一些非关系型数据库 |
此外,针对 OLTP, 还探索了常见的建索引的方法,以及一种特殊的数据库——全内存数据库。
对于数据仓库,本章分析了它与 OLTP 的主要不同之处。数据仓库主要侧重于聚合查询,需要扫描很大量的数据,此时,索引就相对不太有用。需要考虑的是存储成本、带宽优化等,由此引出列式存储。
本节由一个 shell 脚本出发,到一个相当简单但可用的存储引擎 Bitcask,然后引出 LSM-tree,他们都属于日志流范畴。之后转向存储引擎另一流派——B 族树,之后对其做了简单对比。最后探讨了存储中离不开的结构——索引。
首先来看,世界上“最简单”的数据库,由两个 Bash 函数构成:
#!/bin/bash
db_set () {
echo "$1,$2" >> database
}
db_get () {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
这两个函数实现了一个基于字符串的 KV 存储(只支持 get/set,不支持 delete):
$ 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"]}
来分析下它为什么 work,也反映了日志结构存储的最基本原理:
可以看出:写很快,但是读需要全文逐行扫描,会慢很多。典型的以读换写。为了加快读,我们需要构建索引:一种允许基于某些字段查找的额外数据结构。
索引从原数据中构建,只为加快查找。因此索引会耗费一定额外空间,和插入时间(每次插入要更新索引),即,重新以空间和写入换读取。
这便是数据库存储引擎设计和选择时最常见的权衡(trade off):
存储格式一般不好动,但是索引构建与否,一般交予用户选择。
本节主要基于最基础的 KV 索引。
依上小节的例子,所有数据顺序追加到磁盘上。为了加快查询,我们在内存中构建一个哈希索引:
看来很简单,但这正是 Bitcask[1] 的基本设计,但关键是,他 Work(在小数据量时,即所有 key 都能存到内存中时):能提供很高的读写性能:
如果你的 key 集合很小(意味着能全放内存),但是每个 key 更新很频繁,那么 Bitcask 便是你的菜。举个栗子:频繁更新的视频播放量,key 是视频 url,value 是视频播放量。
但有个很重要问题,单个文件越来越大,磁盘空间不够怎么办? 在文件到达一定尺寸后,就新建一个文件,将原文件变为只读。同时为了回收多个 key 多次写入的造成的空间浪费,可以将只读文件进行紧缩( compact ),将旧文件进行重写,挤出“水分”(被覆写的数据)以进行垃圾回收。
当然,如果我们想让其工业可用,还有很多问题需要解决:
乍一看,基于日志的存储结构存在着不少浪费:需要以追加进行更新和删除。但日志结构有几个原地更新结构无法做的优点:
当然,基于内存的哈希索引也有其局限:
后面讲的 LSM-Tree 和 B+ 树,都能部分规避上述问题。
这一节层层递进,步步做引,从 SSTables 格式出发,牵出 LSM-Trees 全貌。
对于 KV 数据,前面的 BitCask 存储结构是:
其中外存上的数据是简单追加写而形成的,并没有按照某个字段有序。
假设加一个限制,让这些文件按 key 有序。我们称这种格式为:SSTable(Sorted String Table)。
这种文件格式有什么优点呢?
高效的数据文件合并。即有序文件的归并外排,顺序读,顺序写。不同文件出现相同 Key 怎么办?
不需要在内存中保存所有数据的索引。仅需要记录下每个文件界限(以区间表示:[startKey, endKey],当然实际会记录的更细)即可。查找某个 Key 时,去所有包含该 Key 的区间对应的文件二分查找即可。
分块压缩,节省空间,减少 IO。相邻 Key 共享前缀,既然每次都要批量取,那正好一组 key batch 到一块,称为 block,且只记录 block 的索引。
SSTables 格式听起来很美好,但须知数据是乱序来的,我们如何得到有序的数据文件呢?
这可以拆解为两个小问题:
构建 SSTable 文件。将乱序数据在外存(磁盘 or SSD)中上整理为有序文件,是比较难的。但是在内存就方便的多。于是一个大胆的想法就形成了:
维护 SSTable 文件。为什么需要维护呢?首先要问,对于上述复合结构,我们怎么进行查询:
如果 SSTable 文件越来越多,则查找代价会越来越大。因此需要将多个 SSTable 文件合并,以减少文件数量,同时进行 GC,我们称之为紧缩( Compaction)。
该方案的问题:如果出现宕机,内存中的数据结构将会消失。解决方法也很经典:WAL。
将前面几节的一些碎片有机的组织起来,便是时下流行的存储引擎 LevelDB 和 RocksDB 后面的存储结构:LSM-Tree:
这种数据结构是 Patrick O’Neil 等人,在 1996 年提出的:The Log-Structured Merge-Tree[2]。
Elasticsearch 和 Solr 的引擎 Lucene,也使用类似 LSM-Tree 存储结构。但其数据模型不是 KV,但类似:word → document list。
如果想让一个引擎工程上可用,还会做大量的性能优化。对于 LSM-Tree 来说,包括:
优化 SSTable 的查找。常用 Bloom Filter。该数据结构可以使用较少的内存为每个 SSTable 做一些指纹,起到一些初筛的作用。
层级化组织 SSTable。以控制 Compaction 的顺序和时间。常见的有 size-tiered 和 leveled compaction。LevelDB 便是支持后者而得名。前者比较简单粗暴,后者性能更好,也因此更为常见。
对于 RocksDB 来说,工程上的优化和使用上的优化就更多了。在其 Wiki[3] 上随便摘录几点:
但无论有多少变种和优化,LSM-Tree 的核心思想——保存一组合理组织、后台合并的 SSTables ——简约而强大。可以方便的进行范围遍历,可以变大量随机为少量顺序。
虽然先讲的 LSM-Tree,但是它要比 B+ 树新的多。
B 树于 1970 年被 R. Bayer and E. McCreight 提出[4]后,便迅速流行了起来。现在几乎所有的关系型数据中,它都是数据索引标准一般的实现。
与 LSM-Tree 一样,它也支持高效的点查和范围查。但却使用了完全不同的组织方式。
其特点有:
查找。从根节点出发,进行二分查找,然后加载新的页到内存中,继续二分,直到命中或者到叶子节点。查找复杂度,树的高度—— O(lgn),影响树高度的因素:分支因子(分叉数,通常是几百个)。
插入 or 更新。和查找过程一样,定位到原 Key 所在页,插入或者更新后,将页完整写回。如果页剩余空间不够,则分裂后写入。
分裂 or 合并。级联分裂和合并。
B 树不像 LSM-Tree ,会在原地修改数据文件。
在树结构调整时,可能会级联修改很多 Page。比如叶子节点分裂后,就需要写入两个新的叶子节点,和一个父节点(更新叶子指针)。
B 树出来了这么久,因此有很多优化:
存储引擎 | B-Tree | LSM-Tree | 备注 |
---|---|---|---|
优势 | 读取更快 | 写入更快 | |
写放大 | 1. 数据和 WAL2. 更改数据时多次覆盖整个 Page | 1. 数据和 WAL2. Compaction | SSD 不能过多擦除。因此 SSD 内部的固件中也多用日志结构来减少随机小写。 |
写吞吐 | 相对较低:大量随机写。 | 相对较高: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 进行控制。 |
次级索引(secondary indexes)。即,非主键的其他属性到该元素(SQL 中的行,MongoDB 中的文档和图数据库中的点和边)的映射。
对于存储数据和组织索引,我们可以有多种选择:
索引可以加快查询速度,但需要占用额外空间,并且牺牲了部分更新开销,且需要维持某种一致性。
现实生活中,多个字段联合查询更为常见。比如查询某个用户周边一定范围内的商户,需要经度和纬度二维查询。
SELECT * FROM restaurants WHERE latitude > 51.4946 AND latitude < 51.5079
AND longitude > -0.1162 AND longitude < -0.1004;
可以:
前述索引只提供全字段的精确匹配,而不提供类似搜索引擎的功能。比如,按字符串中包含的单词查询,针对笔误的单词查询。
在工程中常用 Apace Lucene[5] 库,和其包装出来的服务:Elasticsearch[6]。他也使用类似 LSM-tree 的日志存储结构,但其索引是一个有限状态自动机,在行为上类似 Trie 树。
随着单位内存成本下降,甚至支持持久化(non-volatile memory,NVM,如 Intel 的 傲腾[7]),全内存数据库也逐渐开始流行。
根据是否需要持久化,内存数据大概可以分为两类:
VoltDB, MemSQL, and Oracle TimesTen 是提供关系模型的内存数据库。RAMCloud 是提供持久化保证的 KV 数据库。Redis and Couchbase 仅提供弱持久化保证。
内存数据库存在优势的原因不仅在于不需要读取磁盘,而更在于不需要对数据结构进行序列化、编码后以适应磁盘所带来的额外开销。
当然,内存数据库还有以下优点:
此外,内存数据库还可以通过类似操作系统 swap 的方式,提供比物理机内存更大的存储空间,但由于其有更多数据库相关信息,可以将换入换出的粒度做的更细、性能做的更好。
基于非易失性存储器(non-volatile memory,NVM) 的存储引擎也是这些年研究的一个热点。
[1]Bitcask: https://docs.riak.com/riak/kv/2.2.3/setup/planning/backend/bitcask/index.html
[2]The Log-Structured Merge-Tree: https://www.cs.umb.edu/~poneil/lsmtree.pdf
[3]rocksdb wiki: https://github.com/facebook/rocksdb/wiki
[4]b tree paper: https://dl.acm.org/doi/10.1145/1734663.1734671
[5]Apace Lucene: https://lucene.apache.org/
[6]Elasticsearch: https://www.elastic.co/cn/
[7]傲腾: https://www.intel.cn/content/www/cn/zh/products/details/memory-storage/optane-dc-persistent-memory.html