leveldb源码阅读分析笔记

这是本人在阅读leveldb源代码的基础上写的读书笔记,现贡献出来供大家交流之用。

内容主要来源于leveldb官网的文档和阅读leveldb的源码。

转载请注明出处,谢谢


Leveldb的实现


1.   调研目的

互联网业务中大量的数据都是简单地key-value类型,查询时不需要复杂的关系数据块支持。在大量涌现出来的NoSql开源产品中,Leveldb是一个轻量级的,快速的以存储为目的的key-value存储引擎。

调研leveldb中各种存储机制的实现,调研leveldb的适用场景,为后续key-value存储系统开发提供存储引擎方面的参考。

2.   调研内容

2.1       leveldb和LSM

Leveldb是Google开源的一个快速,轻量级的key-value存储引擎,以库的形式提供,没有提供上层c/s架构和网络通信的功能。

Leveldb存储引擎的功能如下:

ü  提供key-value存储,key和value是任意的二进制数据。

ü  数据时按照key有序存储的,可以修改排序规则。

ü  只提供了Put/Get/Delete等基本操作接口,支持批量原子操作。

ü  支持快照功能。

ü  支持数据的正向和反向遍历访问。

ü  支持数据压缩功能(Snappy压缩)。

ü  支持多线程同步,但不支持多进程同时访问。

Leveldb和传统的存储引擎,比如Innodb,BerkeleyDb最大的区别是leveldb的数据存储方式采用的是LSM(log-structured-merge)的实现方法,传统的存储引擎大多采用的是B+树系列的方法。

磁盘的性能主要受限于磁盘的寻道时间,优化磁盘数据访问的方法是尽量减少磁盘的IO次数。磁盘数据访问效率取决于磁盘IO次数,而磁盘IO次数又取决于数据在磁盘上的组织方式。

磁盘数据存储大多采用B+树类型数据结构,这种数据结构针对磁盘数据的存储和访问进行了优化,减少访问数据时磁盘IO次数。

B+树是一种专门针对磁盘存储而优化的N叉排序树,以树节点为单位存储在磁盘中,从根开始查找所需数据所在的节点编号和磁盘位置,将其加载到内存中然后继续查找,直到找到所需的数据。

目前数据库多采用两级索引的B+树,树的层次最多三层。因此可能需要5次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行ID,然后再进行一次数据文件读操作及一次数据文件写操作)。

但是由于每次磁盘访问都是随机的,而传统机械硬盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。

LSM树可以看作是一个N阶合并树。数据写操作(包括插入、修改、删除)都在内存中进行,并且都会创建一个新记录(修改会记录新的数据值,而删除会记录一个删除标志),这些数据在内存中仍然还是一棵排序树,当数据量超过设定的内存阈值后,会将这棵排序树和磁盘上最新的排序树合并。当这棵排序树的数据量也超过设定阈值后,和磁盘上下一级的排序树合并。合并过程中,会用最新更新的数据覆盖旧的数据(或者记录为不同版本)。

在需要进行读操作时,总是从内存中的排序树开始搜索,如果没有找到,就从磁盘上的排序树顺序查找。

在LSM树上进行一次数据更新不需要磁盘访问,在内存即可完成,速度远快于B+树。当数据访问以写操作为主,而读操作则集中在最近写入的数据上时,使用LSM树可以极大程度地减少磁盘的访问次数,加快访问速度。   

2.2       Memtable及SkipList(跳表)

Leveldb采用LSM思想设计存储结构。各个存储文件也是分层的,新插入的值放在内存表中,称为memtable,该表写满时变为immutable table,并建立新的memtable接收写操作,而immutable table是不可变更的,会通过Compaction过程写入level0,数据被组织成sst的数据文件。level0的文件会通过后台的Compaction过程写入level1,level1的文件又会写入level2,依次类推。

sst(sortedtable)文件格式见后续的leveldb文件格式章节。

内存中的memtable的实现是通过SkipList(跳表)的数据结构实现的。

跳表是平衡树的一种替代的数据结构,但是和红黑树不相同的是,跳表对于树的平衡的实现是基于一种随机化的算法的,这样也就是说跳表的插入和删除的工作是比较简单的。

简单说一下跳表的核心思想:


如果是说链表是排序的,并且节点中还存储了指向前面第二个节点的指针的话,那么在查找一个节点时,仅仅需要遍历N/2个节点即可。

这基本上就是跳表的核心思想,其实也是一种通过“空间来换取时间”的一个算法,通过在每个节点中增加了向前的指针,从而提升查找的效率。

       在跳表中,每个节点除了存储指向直接后继的节点之外,还随机的指向后代节点。如果一个节点存在k个向前的指针的话,那么陈该节点是k层的节点。一个跳表的层MaxLevel义为跳表中所有节点中最大的层数。而每个节点的层数是在插入该节点时随机生成的,范围在[0,MaxLevel]。

下面给出一个完整的跳表的图示:

       在leveldb中,跳表的实现代码在/db/skiplist.h中,而在该结构的实现中,内存管理使用了arena内存管理,是leveldb实现的一个简易内存池。在memtable操作中分配的内存会在memtable被删除时统一释放,leveldb没有提供删除单个skiplist节点的接口。

2.3       leveldb的文件及文件结构

leveldb中有这几类文件,

ü  数据文件以.sst结尾,用来存储数据和索引。

ü  日志文件以.log结尾,用来存储最近的数据更新操作。采用追加写的形式,每次更新操作都被追加到log文件的末尾,每个log文件对应当前的memtable,更新操作先写入log文件,然后更新memtable。当memtable被写入sst数据文件后,相应的log文件会被删除。

ü  Manifest文件,Manifest文件列出了每个层(level)的数据文件构成,每个文件的key的范围以及其他的元信息。Manifest文件格式同log文件,对leveldb的数据文件级别更改(数据文件的增加和删除等)操作会追加到Manifest文件末尾。每次打开leveldb会对Manifest文件进行重构,重新生成一份精简的Manifest文件。

ü  Current文件是一个简单的文本文件,其内容是当前使用的Manifest文件名。

ü  信息日至文件,存放leveldb的操作日志。

ü  其他文件,比如LOCK文件等,用来保证同一时刻只有一个进程打开该数据库。

2.3.1   leveldb日志文件结构

leveldb的日志文件包括写数据的日志文件和Manifest文件,这些文件均采用追加写的方式增加记录。

其中写操作日志文件保存leveldb的写操作日志,用于在leveldb启动时进行内存数据(memtable)恢复,写日志文件是和memtable一一对应的,当memtable被Compact成一个表数据文件之后,对应的写操作日志文件会被删除。

Manifest文件也是一个日志文件,保存对leveldb对数据文件(sst)在文件级别所做的更改,比如增加了数据文件,删除了数据文件等。用于在leveldb启动时恢复当前的数据文件元信息。

日志文件被划分成32K大小的block,改大小可调。每个block中是一系列的记录。每条记录的格式是

record:=

        checksum:uint32 //存放该记录datacrc32校验值

        length:uint16 //data长度

        type:uint8 //FULL,FIRST,MIDDLE,LAST,记录类型

        data:uint8[length] //记录内容

写操作日志和Manifest文件写入的内容格式是LogRecord和VersionEdit格式化后形成的字符串,存放在每条记录的data字段中。

一条日志记录的头部信息占用7个字节,如果一个Block的剩余空间不足7个字节时,该Block尾部被填补为0,新的记录从下一个Block开始;如果一个Block尾部恰好只剩下7个字节,则填出一个长度为0的起始Record在该Block。

2.3.2   sst数据文件格式

leveldb的数据存放在数据表中,对应于磁盘上的数据文件,以.sst结尾。数据文件一旦写入完成,其内容不可更改,当sst的数据被合并到新的sst文件之后,无用的sst文件会被删除。

sst文件的文件格式如下:

<beginning_of_file>

  [data block 1]

  [data block 2]

  ...

  [data block N]

  [meta block 1]

  ...

  [meta block K]

  [metaindex block]

  [index block]

  [Footer]       (fixed size; starts at file_size - sizeof(Footer))

  <end_of_file>

一个sst数据文件包含多个data block,多个种类的metablock,一个metaindex block和一个index block,文件的末尾存放的是一个固定长度的Footer类型。

其中,data block存放的是用户存储的key-value数据,data block是leveldb磁盘IO读取数据的基本单位,data block的默认大小为4kB,可以通过参数调整,此处的4kB大小指的是Block中未压缩的数据大小,数据存入sst之前默认会进行snappy压缩,压缩后的数据会小于4KB。各个Block数据在sst文件中是有序存放的。

Meta block存放另外的元信息数据,当前的leveldb(1.12.0版本)使用该block存放filterpolicy产生的filter数据,在查找数据时利用bloomfilter算法可以显著减少不存在的key导致的磁盘IO。

各个metablock会存放index到metaindexblock中。

每个block会存放一个index到index Block中,相当于该block的一个索引,存放的也是key/value结果,其中key是大于该block最大key的最短前缀,而value则是该Block的一个位置索引,结构为struct{offset,size};

Footer是一个固定的结构,存放了indexBlockHander+metaBlockHandler和一个8字节的magicNumber。

 dataBlock中存储的每个key/value对儿也有自身的结构。每个key/value对儿称为一个record,需要注意的是,其中存储的key不同于用户提供的key,内部存储的key是将用户提供的key,操作的sequencenum和存储的值类型(普通value还是删除)进行编码之后形成的internal key。

Leveldb中的记录是key有序存储的,所以相邻的key有很大概率共享前缀。为了减少存储空间,leveldb的key采用前缀压缩存储,同时为了加快检索速度和避免丢失大量数据,每隔一定数量的record就会存储一个完整的key字符串,这种完整的record地址被称为Restart Point默认是每16个记录存储一个完整的key。

每个record的结构如下:

 

record:

     shared_bytes: varint32 //key共享前缀长度,完整的key0

     unshared_bytes: varint32 //key私有数据长度

     value_length: varint32 //value数据长度

     key_delta: char[unshared_bytes] //key私有数据

     value: char[value_length] //value

每个未压缩block结尾的结构如下:

blocktailer:

    restarts: uint32[num_restarts] //restart pointer数组

    num_restarts: uint32 //restart point数组长度

在存入sst数据文件之前,还要对以上结果进行snappy压缩,压缩后的数据作为content,再加上type和crc32作为block的完整数据存入sst文件,其中type标记该block是否进行了压缩。

2.3.3   Current文件结构

Current文件时一个文本文件,文件内容是当前使用的Manifest文件的文件名,leveldb初始化时通过Current文件找到上次关闭时使用的manifest文件,然后进行数据初始化和恢复。Leveldb初始化完成时会将当前快照写入新的manifest文件并更新Current文件内容。

2.4       leveldb版本控制

leveldb可以保留数据的对个版本,每次查询操作默认总是在最新版本上进行,而所有的写操作都是在最新版本进行。Leveldb的多版本控制通过Version,VersionSet和VersionEdit结构来实现。每个Version结构代表了leveldb所存储数据的一个视图,或者说一个版本。从本质上来看,Version是由磁盘文件构成的,注意Version不包含内存中的memtable和immutable table。

因为leveldb的数据文件一旦写入完成,其内容不会发生更改,每个Compaction过程会将现有的sst文件merge生成新的sst文件,或者将内存中的immutable table生成sst文件,每个Compaction过程会产生新的数据文件,旧的数据文件不再需要。这种文件级别的更改会形成一个VersionEdit结构写入manifest文件,供leveldb下次启动时回放;同时会将生成的VersionEdit结构和当前的Version进行合并操作形成新的Version来反映leveldb最新的数据文件信息,可以看出VersionEdit包含的是相邻版本之间的变化信息。

Leveldb可以保存多个版本,当形成新的Version时,旧的Version有可能正在被检索,所以无法必须保留。Leveldb采用VersionSet结构来管理多个Version结构,VersionSet本质上就是一个Version结构的双向链表。

为了管理Version和sst文件的生命周期,leveldb广泛采用了引用计数的方法。每生成一个新的Version,Version的引用计数被标记为1,当检索该Version时,该Version的引用计数会增加1;当产生了新的Version后,当前Version的引用计数会被减1,Version引用计数为0时会被删除,并且将其管理的数据文件的引用计数减少1,当数据文件的引用计数变为0时,数据文件会被删除。

这几个数据结构的关系简单描述如下:

  VersionSet=Version1+Version2+Version3+...+currentVersion

  Vn=Vn-1+VersionEdit

2.5       leveldb快照实现

Leveldb支持快照功能。可以通过db->GetSnapshot()来建立新的当前快照。在检索时通过option->snapshot字段来指明检索的快照,默认是在当前数据上进行检索。

Levendb的快照实现并不是新建Version或者增加当前的Version的引用计数来实现的。因为Version只是反映了磁盘文件信息,并没有反映内存中的memtable和immutable table信息。

Leveldb的快照功能是通过写入序列号来实现的,leveldb将所有的写入操作(包括删除操作)都进行了序列号,每个写入操作都会有一个序列号,一个uint64类型的整数,高56位存储的是写入操作的序列号,低8位存储的是操作类型(Put/Delete)。Leveldb会将用户提供的key和序列号格式化后作为internalkey保存,我们记写入操作的序列号为seqw,用户查询操作会带上一个当前序列号或者快照的序列号进行查询,记查询时带的序列号为seqr,查询过程中只会查找序列号小于seqr的最大序列号的记录,如果某个记录的seqw>seqr,则在查询时该记录会被忽略。

这种机制是的leveldb可以保留多个版本的数据,保留的数据版本数理论上不受限制,另外,这种快照机制实现简单。

使用序列号来实现快照的方法对leveldb数据Compact过程也会造成影响,见Compaction相关部分。

 

 

2.6       leveldb的Compaction

leveldb的数据存储采用LSM的思想,LSM思想和log&dump思想很相近,大体来讲都是变随机写入为顺序写入,记录写入操作日志,一旦日志被以追加写的形式写入硬盘,就返回写入成功,由后台线程将写入日志作用于原有的磁盘文件生成新的磁盘数据。

Leveldb在内存中维护一个数据结构memtable,采用skiplist来实现,保存当前写入的数据,当数据达到一定规模后变为不可写的内存表immutable table。新的写入操作会写入新的memtable,而immutable table会被后台线程写入到数据文件。

Leveldb的数据文件时按层存放的,level0,level1,…,level7。默认配置的最高层级是7,内存中的immutable总是写入level0,除level0之外的各个层leveli的所有数据文件的key范围都是互相不相交的。

当满足一定条件时,leveli的数据文件会和leveli+1的数据文件进行merge,产生新的leveli+1层级的文件,这个磁盘文件的merge过程和immutable的dump过程叫做Compaction,在leveldb中是由一个单独的后台线程来完成的。

进行Compaction操作的条件如下:

1>   产生了新的immutable table需要写入数据文件;

2>   某个level的数据规模过大;

3>   某个文件被无效查询的次数过多;

a)       在文件i中查询key,没有找到key,这次查询称为文件i的无效查询。

4>   手动compaction;

满足以上条件是会启动Compaction过程,详细的Compaction过程见代码分析章节。

2.7       Leveldb的缓存机制

为了提高leveldb的数据读写效率,leveldb内部实现了cache功能模块。

Leveldb的Cache分为两个,block cache和table cahce。Block cache存放的是sst文件中的一个data block,这个cache的作用是缓存sst文件中的数据,减少磁盘IO。按照LRU(least-recently-used)的策略进行淘汰,这个cache由用户指定大小,要配置该Cache,则需要用户在初始化用特定Cache结构初始化options.block_cache字段,否则leveldb会自动建立一个8M的Blockcache使用。

Leveldb实现的另一个cache是tablecache,该cache是leveldb内部管理的,没有对外提供配置结构,每个table(sst文件)在打开时需要读indexblock,metaindexblock,建立相应的内存结构,leveldb会将这些结构进行缓存,以备将来使用,也是采用LRU的删除策略。

2.8       Leveldb核心框架代码分析

由于leveldb的代码比较多,有17000行左右,并且leveldb是一个存储引擎库,代码组织比较松散,本节先分析leveldb的代码结构及典型的访问流程,然后以流程为切入点,重点分析leveldb的初始化过程,读写流程及Compact过程,为了使得流程更加清晰,分析过程中使用的代码在原有的leveldb源代码基础上删掉了很多与理解流程关系不大的代码。

2.8.1   Leveldb的源代码结构

Leveldb的源代码组织结构如下:

核心目录包括

db目录,存放leveldb的实现代码。

include目录,存放leveldb的对位接口头文件。

port目录,存放leveldb的平台相关代码,锁和条件变量,原子操作的平台实现。

util目录,存放工具类代码,内存池,bloomfilter,cache,整形变长编码,crc32,hash,随机数,以及env的平台相关代码。

table目录,存放table相关的读写代码。

总的源代码行数在17000行左右。

2.8.2   典型的leveldb访问流程

对leveldb的典型访问是先打开db,进行读写操作,然后关闭db,代码示例如下:

 

#include <assert>

#include"leveldb/db.h"

 

leveldb::DB* db;

 

leveldb::Options options;

options.create_if_missing = true;

//打开db

leveldb::Status status = leveldb::DB::Open(options, "/tmp/testdb", &db);

assert(status.ok());

std::string value;

//读写db

leveldb::Status s = db->Get(leveldb::ReadOptions(), key1, &value);

if (s.ok()) s = db->Put(leveldb::WriteOptions(), key2, value);

if (s.ok()) s = db->Delete(leveldb::WriteOptions(), key1);

//关闭db

delete db;

 

       DB::Open函数进行数据块初始化,打开或新建数据库,并且进行数据库恢复操作。

2.8.3   Leveldb的初始化流程

leveldb是C++编写的,db.h中定义了leveldb对外接口,定义了class DB,这个类只是一个接口类,具体的实现是在DBImpl类中实现的。DB::Open函数创建的是一个DBImpl类,具体的操作由DBImpl类来处理。

       //open

  Status DB::Open(const Options& options, const std::string& dbname,DB** dbptr) {

    DBImpl* impl = new DBImpl(options, dbname);

//调用DBImpl的恢复数据接口,读取元数据,恢复日志数据

Status s = impl->Recover(&edit);

    uint64_t new_log_number = impl->versions_->NewFileNumber();

    //创建新的写操作日志文件

    options.env->NewWritableFile(LogFileName(dbname, new_log_number),&lfile);

    //添加VersionEdit,初始化时会将现在的VersionSet的状态写入新的manifest文件,并更新Current文件

    impl->versions_->LogAndApply(&edit, &impl->mutex_);

    //删除无用文件

    impl->DeleteObsoleteFiles

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
阅读Leveldb源码,你可以按照以下步骤进行: 1. 确保你对C++语言有基本的了解。Leveldb是用C++编写的,因此你需要熟悉C++的语法和面向对象编程的概念。 2. 阅读Leveldb的README文件。这个文件提供了关于Leveldb的基本信息,如其用途、功能和性能特征。同时,它还列出了Leveldb的依赖关系,这对于理解源码以及构建和运行Leveldb非常重要。 3. 了解Leveldb的核心概念和数据结构。Leveldb是一个高效的键值存储库,它使用了一些关键的数据结构,如有序字符串表(Skip List)和持久化存储。 4. 查看Leveldb的目录结构。Leveldb源码包含了一些核心文件和目录,如“db”目录下的文件是Leveldb的核心实现。理解源码的组织结构可以帮助你快速找到感兴趣的部分。 5. 阅读核心文件的源码。从“db/db_impl.cc”文件开始,这个文件是Leveldb的主要实现。阅读这个文件可以帮助你了解Leveldb如何管理内存、实施并发控制和实现持久化存储。 6. 跟踪函数调用和数据流。了解Leveldb的主要功能是如何通过函数调用进行实现的很重要。你可以使用调试器或添加日志输出来跟踪函数调用和数据流,这有助于你了解代码的执行流程和逻辑。 7. 阅读Leveldb的测试用例。Leveldb源码中包含了大量的测试用例,这些用例对于理解Leveldb的不同功能和特性非常有帮助。通过阅读和运行这些测试用例,你可以对Leveldb的行为有更深入的了解。 8. 参考文档和论文。如果你想更深入地了解Leveldb的实现原理和技术细节,可以查阅Leveldb的官方文档或相关的论文。这些文档可以为你提供更详细的信息和背景知识。 最后,要理解Leveldb源码并不是一件简单的任务,需要投入大量的时间和精力。所以,建议你在阅读源码之前,对C++和数据库原理有一定的了解和经验,同时也要具备耐心和持续学习的精神。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值