leveldb深入浅出

leveldb

简介

LevelDB 是一个快速的提供持久化存储的 key/value(byte array) 存储数据库,可以把它理解为一个存储在本地磁盘的超大 map<string, string>。

其局限性有:

  1. 不是一个关系型数据库,不支持 sql 查询,不支持索引
  2. 不支持进程级别的并发
  3. 不支持类似 memcached、redis 的 c/s 架构

leveldb 的整体架构如下图:
/Users/nealzhu/Library/Application Support/typora-user-images/image-20190909140502849.png

log

log 文件内顺序保存着最新的一些列更新操作。在功能上 log 文件主要是将将还未存储到数据文件的更新操作(write buffer)进行一个持久化存储。

下面是 log 文件内部的逻辑组织形式:
在这里插入图片描述
每个 block 的大小是固定的(32KB)。block 内部可能会划分为若干个 chunk,共有 4 种类型的 chunk(对应上图第三列):

  1. full chunk 记录是一条完整逻辑记录
  2. First、[middle]、last 共同构成一条逻辑记录

在更新数据库的时候,第一步就是更新操作写入到 log 文件中。但是 log 的读取往往只发生在 recover 数据库阶段。

我们调用数据库的查询接口的时候,并不会查询 log 文件,相反,我们查询的是 log 在内存的拷贝 memtable(memmory table)。

memtable

memtable 是当前 log 在内存的拷贝。主要作用是将 log 内的数据进行结构化存储,以便高效查询 log 内数据。

skiplist

skiplist 利用概率均衡的技术,通过在 list 节点内存储一些冗余信息,实现了复杂度为 O(lgn) 的插入、查询操作。因为其实现简单,很多时候可以作为替代平衡数的选择。skiplist 的一个节点的定义如下:

struct Node {
  /// ... val etc
	Node *next[1]; // next 数组,可能存储最多 12 层的后驱节点
};

跳表的内存布局如下:

[外链图片转存失败(img-FJMlkwa2-1568519085469)(/Users/nealzhu/Library/Application Support/typora-user-images/image-20190911103040176.png)]

memtable 底层是一个一写多读安全的 lock free skiplist ,其每个节点存储的是 InternalKey+value 的序列化后的字符串。

InternalKey

虽然 leveld 提供 Get(key), Put(key, value),Delete(key) 接口都是接收一个简单的字符串 key (后面称为 UserKey),但是数据库内部存储的是 InternalKey。

sequence number

leveldb 中每个写操作都唯一对应一个 seq#(uint64_t),这个 seq# 是按照写入操作顺序严格递增的。因为 seq# 的每次改变都会导致数据库数据发生变化,所以这个 seq# 也被用来实现快照,用一个 seq# 表示数据库某一时刻的快照。

type

写操作有两种类型:

  1. 删除(kTypeDeletion)
  2. 写值(kTypeValue)

leveldb 中将 UserKey与 seq# 、 type 拼接在一起,就构成了 InternaKey,内部数据都存储的是 InternalKey

               | 7 bytes  |  1 byte  |
|---UserKey----|---seq#---|---type---|

因为 skiplist 内部是有序的,所以我们有必要看下 InternaKey 的比较逻辑:

  1. 按照 UserKey 升序
  2. (seq# + type) 降序(其实发挥作用的只是 seq#)

追加实现 overwrite

在 memtable 中,对同一个 UserKey 的 overwrite 是通过添加一个新节点、而非修改原有节点值实现的。假设有如下代码:

// open a db
db->Put(WriteOptions(), "foo", "first key"); // seq# is 0
// after some ops
db->Put(WriteOptions(), "foo", "second key"); // say seq# is 4
// after some ops
db->Delete(WriteOptions(), "foo"); // say seq# is 10

则这时,memtable 中节点为布局为:

. -> [foo-10-0] -> [foo-4-1-second key] -> [foo-0-1-first key] -> .

构造出合适的 InternalKey 之后,我们就可以查询到 key 对应的快照版本中的值了

memtable 是我们读取操作时候优先查询的地方,如果内存中没有相关数据,则需要在 sstable 中进一步查找了。

sstable

sstable (sorted table) 文件存储着一系列按照 key 排序的 k/v 对数据。sstable 文件是 compaction 的产物。数据库内的所有 sstable 文件按照层级的方式组织起来。

先来看一下单个 sstable 文件的格式组织方式。

文件格式

<beginning_of_file>
[data block 0]
[data block 1]
...
[data block N]
[meta block 0]
...
[meta block M]
[metaindex block]
[index block]
[Footer]        (fixed size; starts at file_size - sizeof(Footer))
<end_of_file>

sstable 文件内部分为多个大小固定的 block(4KB),一个 block 的构成如下:

|--- Data ---|--- Compression Type ---|--- CRC ---|

block 本身内容可以压缩以后存储(由 Compression Type 标示,目前仅支持 Snappy)。一个 block 会对应一个描述它的 BlockHandle:

struct BlockHandle {
	uint64_t offset;
	uint64_t size;
};

按照职责不同,这些 block 在逻辑上可以分为:

data block

文件存储的一些列 k/v 对排序后分布在不同彼此相邻的 data block 中。data block 内部会对 k/v 存储做一些优化(shared key 机制),以及存储一些额外的信息(restart points 数组)来优化对 block 的查询。

其结构如下:

<beginning_of_data_block_data>
[entry1] // serialized k/v pair
...
[entryN]
[restart point1] // offset of restart point1
...
[restart pointN]
[restart point num]
<end_of_data_block_data>
Meta block

紧随在最后一个 data block 后面的是 meta block。meta block 故名思义存储的 block 的元数据。但是目前,只实现了 filter block 这一种 meta block。

filter block 存储的本 sstable 文件所有 filter 信息。通过存储 filter 信息,我们可以在查询不存在与本文件的 key 的时候,避免查询 data block 的数据。

filter block 结构如下:

[filer 0]
...
[filter N]
[offset of filter 0] // uint32   <----|
...                                   |
[offset of filter N]                  |
[offset of beginning of offset array]-|
[lg(base)] // currently 11
metaindex block

metaindex block 中的每条记录都记录的一个 meta block 名字(比如 filter block 是 filter.)和其对应 BlockHandle

index block

index block 中每条记录如下:

|--- MaxKeyN ---|--- BlockHandle of DataBlockN ---|

其中 key 满足:MaxKeyN >= LastKey of DataBlockN && MakKeyN < FirstKey of DataBlockN+1

value 则是 data block 对应的 BlockHandle

index block 作为查询 sstable 的第一级索引,可以帮助我们快速定位到给定 Key 所在的 data block。

footer

在文件最末尾处,是 sstable 的 footer,footer 是一个定长的结构

  metaindex_handle: char[p];     // Block handle for metaindex
  index_handle:     char[q];     // Block handle for index
  padding:          char[40-p-q];// zeroed bytes to make fixed length
                                 // (40==2*BlockHandle::kMaxEncodedLength)
  magic:            fixed64;     // == 0xdb4775248b80fb57 (little-endian)

查询一个 key

Open

要将一个 sstable 文件从硬盘加载到内存中,需要:

  1. 读取 sstable 文件末尾的 footer(长度固定),解析获取到 metaindex block、index block 对应的BlockHandle
  2. 按照 metaindex block 快内的记录,读取所有的 meta block,并加载数据到内存,并且还原各个 meta block 数据(目前只有 filter)
  3. 加载 index block 到内存
lookup

要使用一个已经打开的 sstable 来查询给定的 key,我们需要

  1. 在 index block 中查询到 key 对应的 data block 的 BlockHandle bh。(因为 index block 存的 MaxKey >= 对应 data block 的最大 key,所以我们只需要找第一个 >= key 的 MaxKey 即可)
  2. 根据 bh 的 offset 信息查询 filter block,如果 filter 信息表示这个 key 不存在,那么结束查询,返回不存在
  3. 在 bh 对应的 data block 中不存在,结束查询,返回不存在
  4. 如果查询到对应的记录,但是 type 是 kTypeDeletion,结束查询,返回不存在
  5. 值存在,结束查询,返回对应的记录中的 value

可以看到,查询一个 sstable 文件的逻辑还是比较复杂的。leveldb 中通过 iterator 模式(TwoLevelIterator,非常优雅),将二级索引这个逻辑巧妙的封装了起来。

compaction

compaction 分为两种:

  1. minor compaction
  2. major compaction

compaction 的作用主要是整理数据,优化存储,其发生在一个后台线程中。

minor compaction

当 memtable 的大小超过阈值(4MB),就会触发 minor compaction:

  1. 创建一个新的 memtable,当前的 memtable 不再进行写入操作,转为 imm(immutable memtable),后续的写入数据都将写入新 memtable
  2. 打开一个新 log 文件,当前 log 也不再进行写入,后续的写入都将写入到新 log 文件
  3. 将 imm 数据 dump 为一个 sstable 文件,版本变化中记录 level-0 层新增对应的文件(实际实现中,可能会推到最高 level2 层)
  4. 删除掉无用的 log 文件和 memtable (都使用引用计数,并不是直接删除,会在引用降为 0 后删除对应资源)

[外链图片转存失败(img-6jCrYqyh-1568519085470)(/Users/nealzhu/Library/Application Support/typora-user-images/image-20190906155355972.png)]

minor compaction 的过程中没有整理 memtable 中的数据,起到的作用也主要是控制内存占用(不能让 memtable 无限膨胀)。

major compaction

major compaction 的触发条件有三种:

  1. level-0 文件数目超过 4 个
  2. Level-L (L >=1) 文件内存占用磁盘 超过 10^L MB
  3. 某文件进行的无效 seek 次数超过 min(file_size/16kb, 100) 次

major compaction 过程如下:

  1. 确定 compaction 发生的 level L,以及初始输入文件 f1
  2. 如果 L == 0,需要找到所有 key range 与 f1 重叠的文件,加入到输入文件中
  3. 把 L 层的添加 BoundaryInputs(假设现有所有输入文件集合的最大 InternalKey 为 u1,如果 L 层存在不在输入集合的文件 f2,其最小 InternalKey 为 l2,使得 user_key(u1) == user_key(l2),那么就要把 f2 页添加到 L 层的输入文件中,这个过程要一直重复,知道不存在 f2 这样的 BoudaryFile)
  4. 得到 L 层所有输入文件的 key range(l1, u1)后,查找所有 L+1 层 key range 与(l1,u1) 重叠的文件集合,作为 L+1 层的输入
  5. 对所有输入文件进行多路归并合并,生成一系列的 sstable 文件(合并过程要做数据整理,删除过期无效数据)
  6. 版本变化中,将所有输入文件删除,在 L+1 层新增所有新生成的文件。
  7. 更新数据库版本信息

通过 compaction 机制,我们的数据是会不断下沉到最高层的。

Recover

MANIFEST

MANIFEST 文件是以 log 文件的格式组织的,其内是对数据库非常重要的 metadata

<beginning_of_MANIFEST_file>
[VersionEdit 0] # base version
...
[VersionEdit N]
<end_of_MANIFEST_file>

MANIFEST 以增量记录的方式,记录了所有的版本变更的信息,包括:

  1. 删除那些 sstable 文件
  2. 新增那些 sstable 文件
  3. log#
  4. seq#
  5. compaction 信息(之前 compaction 对应的最大 InternalKey)

每次打开数据库,都会生成一个新的 MANIFEST 文件(将之前的 MANIFEST 数据进行合并以后重新存储)。

CURRENT 文件

CURRENT 文件非常简单,里面只有一行信息,存储的是最新的 MANIFEST 文件名。

recover 流程

数据库 recover 的过程如下:

  1. 读取 CURRENT 文件,获取到数据库最近的 MANIFEST 文件
  2. 读取 MANIFEST 文件,将所有的 VersionEdit replay,获取到最新的版本状态
  3. 检查文件夹下面的所有文件,检查是否有文件缺失,缺失的话代表数据库已经破坏
  4. 检查是否存在大于 MANIFEST 最新版本的中 log# 的 log 文件,如果有的话,需要 recover log files(log files 也是已经写入的数据,是不可丢弃的),并且为其生成 sstable
  5. 构造新的 MANIFEST 文件,修改 CURRENT 文件指向新文件

每次打开数据库,基本都会创建新的 MANIFEST 文件,这么做主要是为了将之前的增量记录合并,减少后续的 recover 时间。但是 leveldb 并不会在数据库打开以后整理 MANIFEST 文件。

读写数据库

最后再来看一下,当我们调用数据库的读写接口的时候,整个代码流程是怎么样的

写入

  1. 写入当前 log 文件
  2. 检查 memtable 大小,确保有足够的空间写入(可能需要进行 minor compaction、构造新的 memtable,将当前的 memtable 转为 immutable memtable 等逻辑)
  3. 进行 batch 写入(这部分逻辑很有意思,写入线程同时充当 producer 和 consumer),将后面等待的写入线程的数据合并为一个 batch 写入到数据库
  4. 如果替其他线程写入了数据,唤醒其他线程

不过要注意,数据仅仅写入 log 其实并不是百分百安全(数据可能还在缓存内,未进行 sync),如果要保证数据不丢失,可以使用同步写(默认都是异步写)。

读取

  1. 查询 memtable,存在则返回
  2. 如果存在 imutable memtable(因为触发了 minor compaction),查询 immutable memtable,存在则返回
  3. 查询 sstable 集,按照从底层到高层的顺序,查询所有 key range 覆盖查询 key 的文件(level-0 文件可能需要查询多个,但是其他 level 最多查询一个),这是一个短路操作,只要在一个文件中查询到,就可以不继续查询了。

结合读取的逻辑,我们也不难理解为什么 major compaction 的触发条件中,level-0 层的逻辑不同与其他 level 了。

收获

  1. iterator
  2. builder
  3. binary search
  4. 控制临界区
  5. 不变量对并发更加友好,尽量使用不变量
  6. 使用追加写实现 overwrite

写在最后

本文档的插图来源于一篇对 goleveldb 的源码解析文档 ,在学习 leveldb 源码的过程中这个文档给予我了很多帮助,推荐大家如果感兴趣的话也都可以看看。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值