Leveldb二三事

摘要

阅读这篇文章,但愿你首先已经对Leveldb有了必定的了解,并预先知晓下列概念:html

  • LSM技术
  • 跳表
  • WAL技术
  • Log Compaction

本文不是一篇专一于源代码解析的文章,也不是一篇Leveldb的介绍文。咱们更但愿探讨的是对于通常的单机数据存储引擎存在哪些问题,Leveldb做为一个经典实现,是采用什么策略并如何解决这些问题的。
Leveldb的解决方案是出于什么考虑,如何高效实现的,作出了哪些权衡以及如何组织代码和工程。你能够先从如下几篇文章对Leveldb有一个基本了解。git

Leveldb的实现原理github

LevelDB之LSM-Tree数据库

LevelDB设计与实现编程

Leveldb的基本架构

数据模型和需求

首先提出几个问题:数组

  • Leveldb在用户视图中的基本单元是什么?
  • Leveldb一条记录在内存中的形式是什么,记录以怎样的方式被组织?
  • Leveldb的记录在文件中的存储格式是什么,多条记录在单文件中是如何被管理的,多文件又是如何被管理的?
  • Leveldb向用户作出了怎样的保证,在什么样的场景下提供了优化?

首先,Leveldb所处理的每条记录都是一条键值对,因为它基于sequence number提供快照读,准确来讲应该是键,序列号,值三元组,因为用户通常关心最新的数据,能够简化为键值对。缓存

Leveldb对持久化的保证是基于操做日志的,一条写操做只有落盘到操做日志中以后(暂时先这么理解,实际上这里有所出入,后面在优化部分会讲到)才会在内存中生效,才能被读取到。这就保证了对于已经能见到的操做,一定能够从操做日志中恢复。
它对一致性的保障能够认为是顺序一致性(这里的一致性不是数据库理论的一致性,不强调从安全状态到另外一个安全状态,而是指从各个视图看事件发生的顺序是一致的,因为使用了write batch 竞争锁,实际上写入是串行化的,但同时并发的写操做的顺序取决于线程抢占锁的顺序)。
在这里咱们能够稍微脱离leveldb的实现讨论一下一致性,能不能实现线性一致性呢?若是咱们不支持追加操做的情形下,写是幂等的,若是确保版本号是按照操做开始时间严格递增分配的,即便并发读写也是能够的,这样作还有一个问题,就是如何支持快照读,那就必须保留每个写记录,但它们是乱序的,进行查找将是困难的,咱们能够经过设置同步点,两个同步点之间的是写缓冲,快照读只有在写缓冲中须要遍历查找,在写缓冲被刷入以前重排序记录,刷入的时机是任意小于当前同步点版本号的写操做执行完毕。上述所描述的只可能适合于对热点key的大量并发写。上面所讨论的接近编程语言的内存模型,能够参考JMM内存模型或者C++内存模型。安全

Leveldb对写操做的要求是持久化到操做日志中,其所应对的数据量也超出了内存范围,或者说其存储内容的存储主体仍是在磁盘上,只不过基于最近写的数据每每会被大量访问的假设在内存中存储了较新的数据。leveldb的核心作法就是保存了多个版本的数据以让写入操做不须要在磁盘中查找键的位置,将随机写改成顺序写,将这一部分代价某种程度上转嫁给读时在0层SSTable上的查找。那么它的读性能受到影响了吗?我的认为它的读性能稍显不足主要是受制于LSM的检索方式而非因为多版本共存的问题,固然写的便利也是基于这样的组织方式。数据结构

上面这几段主要是我的的一些想法,可能有些混乱,剩余的几个问题将在下面的部分再详细解答。架构

工程上的层次结构

leveldb的实现大体上能够分红如下几层结构:

  • 向用户提供的DB类接口及其实现,主要是DB、DbImpl、iter等
  • 中间概念层的memtable、table、version以及其辅助类,好比对应的iter、builder、VersionEdit等
  • 更底层的偏向实际的读写辅助类,好比block、BlockBuilder、WritableFile及其实现等
  • 最后是它定义的一些辅助类和实现的数据结构好比它用来表示数据的最小单元Slice、操做状态类Status、memtable中用到的SkipList等

可能的性能瓶颈

首先让咱们考虑设计一款相似于Leveldb的存储产品,那么面临的主要问题主要是如下几项:

  • 写入磁盘时的延迟
  • 并发写加锁形成的竞争
  • 读操做时如何经过索引下降查找延迟
  • 如何更好地利用cache优化查询效率,增长命中
  • 快速地从快照或者日志中恢复
  • 后台工做如何保持服务可用

Leveldb的内存管理

什么应该在内存中

在内存中存放的数据主要包含当前数据库的元信息、memtable、ImmutableMemtable,前者显然是必要的,后二者存放的都是最新更新的数据。那么为何须要有ImmutableMemtable呢。这是为了在持久化到磁盘上的同时保持对外服务可用,若是没有这样一个机制,那么咱们要么须要持久化两次,并在第一次持久化的中途记录增量日志,第二次应用上去,这是CMS垃圾回收器的作法,可是显然十分复杂;还有一种选择是咱们预留必定的空间,直接将要持久化的memtable拷贝一份,这样作显然会浪费大量可用内存,对于一个数据库来讲,这是灾难性的。

那么元信息具体应该包含哪些信息呢?

  • 当前的操做日志句柄
  • 版本管理器、当前的版本信息(对应compaction)和对应的持久化文件标示
  • 当前的所有db配置信息好比comparator及其对应的memtable指针
  • 当前的状态信息以决定是否须要持久化memtable和合并sstable
  • sstable文件集合的信息

上面列出了一些比较重要的元信息,可能还有遗漏

memtable详解

memtable的结构

memtable的键包含三个部分:

  • Slice user ley
  • sequence number
  • value type

键的比较器首先按照递增顺序比较user key,而后安装递减顺序比较sequence number,这两个足以惟一肯定一条记录了。把user key放到前面的缘由是,这样对同一个user key的操做就能够按照sequence number顺序连续存放了,不一样的user key是互不相干的,所以把它们的操做放在一块儿也没有什么意义。用户所传入的是LookupKey,它也是由User Key和Sequence Number组合而成的,其格式为:

| Size (int32变长)| User key (string) | sequence number (7 bytes) | value type (1 byte) |

这里的Size是user key长度+8,也就是整个字符串长度了。value type是kValueTypeForSeek,它等于kTypeValue。因为LookupKey的size是变长存储的,所以它使用kstart_记录了user key string的起始地址,不然将不能正确的获取size和user key。

memtable自己存储同一键的多个版本的数据,这一点从刚刚指出的键的格式也能够看出。这里为何不直接在写的时候直接将原有值替换并使用用户键做为查找键呢?毕竟在memtable中add和update都须要先进行查找。我的认为除了须要支持快照读也没有别的解释了,虽然这样作会使得较老的记录没有被compact而较新的记录已经compact了的奇怪现象发生,但并不影响数据库的读写,在性能上也没有损害。那么快照读为什么是必要的呢?这个问题我目前也没有很好的回答,读者能够自行思考。

memtable的追加

memtable的追加操做主要是将键值对进行编码操做并最后委托给跳表处理,代码很简单,就放上来吧。

// KV entry字符串有下面4部分链接而成  
   //  key_size     : varint32 of internal_key.size()  
//  key bytes    : char[internal_key.size()]  
//  value_size   : varint32 of value.size()  
//  value bytes  : char[value.size()]  
size_t key_size = key.size();  
size_t val_size = value.size();  
size_t internal_key_size = key_size + 8;  
const size_t encoded_len =  
    VarintLength(internal_key_size) + internal_key_size +  
    VarintLength(val_size) + val_size;  
char* buf = arena_.Allocate(encoded_len);  
char* p = EncodeVarint32(buf, internal_key_size);  
memcpy(p, key.data(), key_size);  
p += key_size;  
EncodeFixed64(p, (s << 8) | type);  
p += 8;  
p = EncodeVarint32(p, val_size);  
memcpy(p, value.data(), val_size);  
assert((p + val_size) - buf == encoded_len);  
table_.Insert(buf);

有关跳表能够参考下列文章:

Skip List(跳跃表)原理详解与实现

memtable的查找

根据传入的LookupKey获得在memtable中存储的key,而后调用Skip list::Iterator的Seek函数查找。Seek直接调用Skip list的FindGreaterOrEqual(key)接口,返回大于等于key的Iterator。而后取出user key判断时候和传入的user key相同,若是相同则取出value,若是记录的Value Type为kTypeDeletion,返回Status::NotFound(Slice())。本质上依然委托跳表处理。

内存分配和释放

Leveldb本身实现了基于引用计数的垃圾回收和一个简单的内存池Arena,其实现预先分配大内存块,划分为不一样对齐的内存空间,其机制乏善可陈,在这里就很少言,放张图吧。

Arena示意图

Arena主要提供了两个申请函数:其中一个直接分配内存,另外一个能够申请对齐的内存空间。Arena没有直接调用delete/free函数,而是由Arena的析构函数统一释放全部的内存。应该说这是和leveldb特定的应用场景相关的,好比一个memtable使用一个Arena,当memtable被释放时,由Arena统一释放其内存。

另外就是对于许多类好比memtable、table、cahe等leveldb都加上了引用计数,其实现也很是简单,就是在对象中加入数据域refs,这也很是好理解。好比在迭代的过程当中,已经进入下一个block中了,上一个block理应能够释放了,但它有可能被传递出去提供某些查询服务使用,在其计数不为0时不容许释放,同理对于immutable_memtable,当它持久化完毕时,若是还在为用户提供读服务,也不能释放。不得不说Leveldb的工程层次很清楚,几乎没有循环引用的问题。

Leveldb的磁盘存储

须要存储的内容

对于一个db,大体须要存储下列文件

  • db的操做日志
  • 存储实际数据的SSTable文件
  • DB的元信息Manifest文件
  • 记录当前正在使用的Manifest文件,它的内容就是当前的manifest文件名
  • 系统的运行日志,记录系统的运行信息或者错误日志。
  • 临时数据库文件,repair时临时生成的。

SSTable详解

SSTable文件组织

单个SSTable文件的组织以下图所示:

SSTable文件结构图

大体分为几个部分:

  • 数据块 Data Block,直接存储有序键值对
  • Meta Block,存储Filter相关信息
  • Meta Index Block,对Meta Block的索引,它只有一条记录,key是meta index的名字(也就是Filter的名字),value为指向meta index的位置。
  • Index Block,是对Data Block的索引,对于其中的每一个记录,其key >=Data Block最后一条记录的key,同时<其后Data Block的第一条记录的key;value是指向data index的位置信息
  • Footer,指向各个分区的位置和大小,示意图以下:

Footer结构示意图

全部类型的block格式是一致的,主要包含下面几部分:

Block结构示意图

其中type指的是采用哪一种压缩方式,当前主要是snappy压缩,接下来主要讲讲block data部分的组织:

snappy是前缀压缩的,为了兼顾查找效率,在构建Block时,每隔几个key就直接存储一个重启点key。Block在结尾记录全部重启点的偏移,能够二分查找指定的key。Value直接存储在key的后面,无压缩。

普通的kv对存储结构以下:

  • 共享前缀长度
  • 非共享键部分的长度
  • 前缀以后的字符串

整体的Block Data以下:

Block内部示意图

整体来看Block可分为k/v存储区和后面的重启点存储区两部分,后面主要是重启点的位置和个数。Block的大小是根据参数固定的,当不能存放下一条记录时多余的空间将会闲置。

SSTable逻辑表达

SSTable在代码上主要有负责读相关的Table、Block和对应的Iterator实现;在写上主要是BlockBuilder和TableBuilder。能够看出来这也是个典型的二层委托结构了,上面的层次将操做委托给下面层次的类执行,本身管控住progress的信息,控制当前的下层实体。这里咱们主要关心Table和Block中应该存放哪些信息以支持它们的操做。

先讲讲简单的Block,毫无疑问除了数据(char*+size)自己之外就是重启点了,重启点但是查询的利器啊,直接的思路是解析重启点部分红一个vector等,实际上Leveldb不是这样作的,只是保留了一个指向重启点部分的指针,至于为何咱们在查询一节里再详谈。

再说说Table,

SSTable的写入

首先,咱们考虑在内存中构建一个连续的内存区域表明一个block的内容,它又能够分为两部分:1. 数据的写入 2. 数据写入完毕后附加信息的添加。 先考虑追加一条记录,咱们须要知道哪些东西?

  • 当前block提供给数据的剩余空间以肯定是否须要换block
  • 当前的重启点以肯定共享前缀
  • 当前重启点已有的key数量以肯定是否将本次写入做为新的重启点
  • 确保key的有序性,因此必须知道上次添加的key

在肯定这些须要的信息后,追加的过程就是查找和维护这些信息以及单纯的memcpy了。

第二步,让咱们考虑在数据写入完毕以后须要为block添加其余信息的过程:

  • 咱们须要记录全部的重启点和重启点位置,咱们不得不在追加的时候来维护它们,看来得回去改上面的代码了
  • 咱们得从配置元数据中获得压缩类型
  • 最后咱们得记录CRC

如今,咱们能够把这么一段char[]的数据转换成Slice表达的block了。接下来,让咱们考虑如何批量的把数据写入单个SSTable文件中。这一样分为三个步骤:1. 追加数据 2. 附加信息 3. Flush到文件。 咱们依次考虑。

追加数据须要作哪些:

  • 知道当前block及当前block可否再添加一条数据
  • 维护有序性,须要上一次的key和新加key比较
  • 若是生成新的block,为了维护索引,须要为将被替换的block生成索引记录,因此必须维护一个index Block
  • 维护过滤器信息(这一部分将在布隆过滤再详细解释,能够暂时忽略)
  • 为了决定是否须要刷到文件中去,须要知道已写的block数

实际上向文件写入是以Block为单位的,当咱们完成一个Block时,在将它写入文件时须要作什么呢?

  • 检查工做,肯定block确实须要写入
  • 压缩工做
  • 通知工做,告知index Block和Filter Block的维护方
  • 重置工做,将当前block重置,准备下一次追加

最后,当数据所有添加完毕,该SSTable文件今后将不可变动,这一步须要执行的是:

  • 写入最后一块data block
  • 写入Meta Block
  • 根据上文写入时留存的位置信息构建Meta Index Block
  • 写入Meta Index Block
  • 将最后的data block位置信息写入Index Block中,并将Index Block写入文件
  • 写入Footer信息

SSTable的遍历

SSTable的遍历主要委托给一个two level iterator处理,咱们只须要弄清楚它的Next操做就能明白其工做原理。所谓的two level,指的是索引一层,数据一层。在拿到一个SSTable文件的时候,咱们先解析它的Index block部分,而后根据当前的index初始化data block层的iterator。接下来咱们主要关注Next的过程。

分为两种情形:

  1. 当前记录不是当前Data Block的最后一条,只须要data iter向前进一步便可
  2. 当前记录是最后一条,这时就要先前进一步index iter,获得data block的位置信息
  3. 读取data block,此处先暂时省略table cache的优化,简单起见都是从文件中读
  4. 建立新的data iter

固然,二级迭代器还作了许多的其余工做,好比容许你传入block function,但这和咱们讨论的主线无关,这里就不过多陈述了。

SSTable的查询

SSTable的查询也委托给iter处理,其主要过程就是对key的定位,也是主要分为三部分:

  • 定位到哪一个block
  • 迁移到该block上
  • 定位到block中的哪一条

不管是index block仍是data block,它们的iter实现是一致的,其查找都遵循如下过程:

  • 经过重启点进行二分查找
  • 跳到最大的不比目标大的重启点,遍历查找,一直到一个不比目标小的key出现

这里最绝妙的是两点

  • index block的设计和二级迭代器,一方面经过这种手段进行快速定位,另外一方面将遍历和查找统一到一个框架下,不可谓不妙
  • 重启点的设计,避免解析数据内容快速使用二分查找定位key的大体区域

咱们都知道磁盘的读写是十分耗时的,索引的手段大量减小了磁盘读的必要。固然,还有许多加速的手段好比过滤器和缓存,咱们将在最后一节详细解释。

元信息存储与管理

这里咱们主要关注db的元信息,也即Manifest文件。

元信息文件的格式

首先,Manifest中应该包含哪些信息呢?

首先是使用的coparator名、log编号、前一个log编号、下一个文件编号、上一个序列号。这些都是日志、sstable文件使用到的重要信息,这些字段不必定必然存在。其次是compact点,可能有多个,写入格式为{kCompactPointer, level, internal key}。其后是删除文件,可能有多个,格式为{kDeletedFile, level, file number}。最后是新文件,可能有多个,格式为{kNewFile, level, file number, file size, min key, max key}。对于版本间变更它是新加的文件集合,对于MANIFEST快照是该版本包含的全部sstable文件集合。下面给出一张Manifest示意结构图。

Manifest文件结构图

Leveldb在写入每一个字段以前,都会先写入一个varint型数字来标记后面的字段类型。在读取时,先读取此字段,根据类型解析后面的信息。

元信息的逻辑表达

在代码中元信息这一部分主要是Version类和VersionSet类。LeveDB用 Version 表示一个版本的元信息,Version中主要包括一个FileMetaData指针的二维数组,分层记录了全部的SST文件信息。 FileMetaData 数据结构用来维护一个文件的元信息,包括文件大小,文件编号,最大最小值,引用计数等,其中引用计数记录了被不一样的Version引用的个数,保证被引用中的文件不会被删除。除此以外,Version中还记录了触发Compaction相关的状态信息,这些信息会在读写请求或Compaction过程当中被更新。在CompactMemTable和BackgroundCompaction过程当中会致使新文件的产生和旧文件的删除。每当这个时候都会有一个新的对应的Version生成,并插入VersionSet链表头部。

VersionSet是一个Version构成的双向链表,这些Version按时间顺序前后产生,记录了当时的元信息,链表头指向当前最新的Version,同时维护了每一个Version的引用计数,被引用中的Version不会被删除,其对应的SST文件也所以得以保留,经过这种方式,使得LevelDB能够在一个稳定的快照视图上访问文件。VersionSet中除了Version的双向链表外还会记录一些如LogNumber,Sequence,下一个SST文件编号的状态信息。

VersionSet示意图

元信息的修改

这里咱们主要探讨二个问题:

  • 如何描述一次修改,或者说一次修改应该包括什么,怎样才算是一次合法的修改?
  • 如何应用一次修改,使得系统切换到新的配置上

描述一次变动的是VersionEdit类,而最为直接的持久化和apply它的办法就是

  1. 构造VersionEdit并写入Manifest文件
  2. 合并当前Version和versionEdit获得新version加入versionSet
  3. 将当前version指向新生成的version

首先,咱们看看VersionEdit包含哪些内容:

std::string comparator_;
  uint64_t log_number_;
  uint64_t prev_log_number_;
  uint64_t next_file_number_;
  SequenceNumber last_sequence_;
  bool has_comparator_;
  bool has_log_number_;
  bool has_prev_log_number_;
  bool has_next_file_number_;
  bool has_last_sequence_;

  std::vector< std::pair<int, InternalKey> > compact_pointers_;
  DeletedFileSet deleted_files_;
  std::vector< std::pair<int, FileMetaData> > new_files_;

对比上文Manifest的结构,咱们不难发现:Manifest文件记录的是一组VersionEdit值,在Manifest中的一次增量内容称做一个Block。

Manifest Block := N * VersionEdit

能够看出恢复元信息的过程也变成了依次应用VersionEdit的过程,这个过程当中有大量的中间Version产生,但这些并非咱们所须要的。LevelDB引入VersionSet::Builder来避免这种中间变量,方法是先将全部的VersoinEdit内容整理到VersionBuilder中,而后一次应用产生最终的Version,这种实现上的优化以下图所示:

构造version的过程

元信息的持久化

Compaction过程会形成文件的增长和删除,这就须要生成新的Version,上面提到的Compaction对象包含本次Compaction所对应的VersionEdit,Compaction结束后这个VersionEdit会被用来构造新的VersionSet中的Version。同时为了数据安全,这个VersionEdit会被Append写入到Manifest中。在库重启时,会首先尝试从Manifest中恢复出当前的元信息状态,过程以下:

  • 依次读取Manifest文件中的每个Block, 将从文件中读出的Record反序列化为VersionEdit;
  • 将每个的VersionEdit Apply到VersionSet::Builder中,以后从VersionSet::Builder的信息中生成Version;
  • 计算compaction_level_、compaction_score_;
  • 将新生成的Version挂到VersionSet中,并初始化VersionSet的manifest_file_number_, next_file_number_,last_sequence_,log_number_,prev_log_number_ 信息;

操做日志存储与管理

数据写入Memtable以前,会首先顺序写入Log文件,以免数据丢失。LevelDB实例启动时会从Log文件中恢复Memtable内容。因此咱们对Log的需求是:

  • 磁盘存储
  • 大量的Append操做
  • 没有删除单条数据的操做
  • 遍历的读操做

LevelDB首先将每条写入数据序列化为一个Record,单个Log文件中包含多个Record。同时,Log文件又划分为固定大小的Block单位。对于一个log文件,LevelDB会把它切割成以32K为单位的物理Block(能够作Block Cache),并保证Block的开始位置必定是一个新的Record。这种安排使得发生数据错误时,最多只需丢弃一个Block大小的内容。显而易见地,不一样的Record可能共存于一个Block,同时,一个Record也可能横跨几个Block。

block组织示意

Block := Record * N
Record := Header + Content
Header := Checksum + Length + Type
Type := Full or First or Midder or Last

操做日志文件结构

Log文件划分为固定长度的Block,每一个Block中包含多个Record;Record的前56个字节为Record头,包括32位checksum用作校验,16位存储Record实际内容数据的长度,8位的Type能够是Full、First、Middle或Last中的一种,表示该Record是否完整的在当前的Block中,若是不是则经过Type指明其先后的Block中是否有当前Record的前驱后继。

Leveldb的交互流程

recovery过程

Db恢复的步骤:

  1. 首先从CURRENT读取最后提交的MANIFEST
  2. 读取MANIFEST内容
  3. 清除过时文件
  4. 这里能够打开全部的sstable文件,可是更好的方案是lazy open
  5. 把log转换为新的level 0sstable
  6. 将新写操做导向到新的log文件,从恢复的序号开始

读过程

读的过程能够分为两步:查找对应key+读取对应值,主要问题在第一步。前面咱们在SSTable章节中已经详细解释了对于单个SSTable文件如何快速定位key,在MemTable章节解释了如何在内存中快速定位key;咱们先大体列出查找的流程:

  1. 在MemTable中查找,没法命中转到2
  2. 在immutable_memtable中查找,查找不中转到3
  3. 在第0层SSTable中查找,没法命中转到4
  4. 在剩余SSTable中查找

那么咱们接下来的问题是对于第0层以及接下来若干层,如何快速定位key到某个SSTable文件?

对于Level > 1的层级,因为每一个SSTable没有交叠,在version中又包含了每一个SSTable的key range,你可使用二分查找快速找到你处于哪两个点之间,再判断这两个点是否属于同一个SSTable,就能够快速知道是否在这一层存在以及存在于哪一个SSTable。

对于0层的,看来只能遍历了,因此咱们须要控制0层文件的数目。

写过程

完成插入操做包含两个具体步骤:

  1. KV记录以顺序的方式追加到log文件末尾,并调用Sync将数据真正写入磁盘。尽管这涉及到一次磁盘IO,可是文件的顺序追加写入效率是很高的,因此并不会致使写入速度的下降;
  2. 若是写入log文件成功,那么将这条KV记录插入内存中的Memtable中。前面介绍过,Memtable只是一层封装,其内部实际上是一个Key有序的SkipList列表,插入一条新记录的过程也很简单,即先查找合适的插入位置,而后修改相应的连接指针将新记录插入便可。

log文件内是key无序的,而Memtable中是key有序的。对于删除操做,基本方式与插入操做相同的,区别是,插入操做插入的是Key:Value 值,而删除操做插入的是“Key:删除标记”,由后台Compaction程序执行真正的垃圾回收操做。

其中的具体步骤能够参阅操做日志管理和memtable详解这两部分。

Leveldb的Log Compaction

Log Compaction的经典问题

在解释Leveldb的log compaction过程以前咱们先回顾几个关于如何作compaction的重要问题:

  • 为何须要compaction?
  • 什么时候须要作compaction
  • 具体怎么作compaction
  • 如何在compaction的同时保证服务可用
  • compaction对性能的影响
  • 如何在服务的延迟和单次compaction的收益作trade off

先回答第一个问题:,LevelDB之因此须要Compaction是有如下几方面缘由:

  • 数据文件中的被删除的KV记录占用的存储空间须要被回收;
  • 将key存在重合的不一样Level的SSTable进行Compaction,能够减小磁盘上的文件数量,提升读取效率

咱们接下来将主要围绕这些问题给出Leveldb的答案。

compaction的时机

  • 按期后台触发compaction任务
  • 正常的读写流程中断定系统达到了一个临界状态,此时必需要进行Compaction

这里咱们主要谈谈二,何时判断,如何判断到达了这个临界状态?

首先了解Leveldb的两种Compaction:

  • minor compaction:将内存immune memtable的数据dump至磁盘上的sstable文件。
  • major compaction:多个level众多SSTable之间的合并。

什么时候判断是否须要compaction

  • 启动时,Db_impl.cc::Open()在完成全部的启动准备工做之后,会发起一次Compaction任务。这时是因为尚未开始提供服务,不会形成任何影响,还可以提供以后全部的读效率,一本万利。
  • 数据写入过程当中,使用函数MakeRoomForWrite确认memtable有足够空间写入数据
  • get 操做时,若是有超过一个 sstable 文件进行了 IO,会检查作 IO 的最后一个文件是否达到了 compact 的条件( allowed_seeks 用光),达到条件,则主动触发 compact。

在MakeRoomForWrite函数中:

  1. 先判断是否有后台合并错误,若是有,则啥都不作;若是没有,则执行2;
  2. 若是后台没错误,则判断mem_的大小是是否小于事先定义阈值:若是是,则啥都不作返回,继续插入数据;若是大于事先定义的阈值,则须要进行一次minor compaction;
  3. 若是imm_不为空,表明后台有线程在执行合并,在此等待;
  4. 若是0层文件个数太多,则也须要等待;
  5. 若是都不是以上状况,表示此时memtable空间不足且immu memtable不为空,须要将immune memtable的数据dump至磁盘sstable文件中。 这就是Minor Compaction了,调用MaybeScheduleCompaction()函数执行此事。

说明下为何会有第4点:由于每进行一次minor compaction,level 0层文件个数可能超过事先定义的值,因此会又进行一次major compcation。而此次major compaction,imm_是空的,因此才会有第4条判断。

如何判断是否须要compaction

上文的MakeRoomForWrite主要针对Minor compaction,能够看出其判断的依据主要就是有没有足够的空间执行下一次写入操做;这里咱们将主要关注major compaction,也就是文件的合并,其执行主要是在后台的清理线程。

major compaction的触发方式主要有三种:

  • 某一level的文件数太多
  • 某一文件的查找次数超过容许值
  • 手动触发

既然要判断这几个条件,就要维护相关信息,咱们看看Leveldb为它们维护了哪些信息。

首先,介绍下列事实

不一样level之间,可能存在Key值相同的记录,可是记录的Seq不一样。
最新的数据存放在较低的level中,其对应的seq也必定比level+1中的记录的seq要大。
所以当出现相同Key值的记录时,只须要记录第一条记录,后面的均可以丢弃。

level 0中也可能存在Key值相同的数据,但其Seq也不一样。数据越新,其对应的Seq越大。
且level 0中的记录是按照user_key递增,seq递减的方式存储的,相同user_key对应的记录被汇集在一块儿按照Seq递减的方式存放的。
在更高层的Compaction时,只须要处理第一条出现的user_key相同的记录便可,后面的相同user_key的记录均可以丢弃。

删除记录的操做也会在此时完成,删除数据的记录会被直接丢弃,而不会被写入到更高level的文件。

接下来,咱们分别对几种触发方式详细介绍其机制:

  • 容量触发Compaction:每一个Version在其生成的时候会初始化两个值compaction_level_、compaction_score_,记录了当前Version最须要进行Compaction的Level,以及其须要进行Compaction的紧迫程度,score大于1被认为是须要立刻执行的。咱们知道每次文件信息的改变都会生成新的Version,因此每一个Version对应的这两个值初始化后不会再改变。level0层compaction_score_与文件数相关,其余level的则与当前层的文件总大小相关。这种区分的必要性也是显而易见的:每次Get操做都须要从level0层的每一个文件中尝试查找,所以控制level0的文件数是颇有必要的。同时Version中会记录每层上次Compaction结束后的最大Key值compact_pointer_,下一次触发自动Compaction会从这个Key开始。容量触发的优先级高于下面将要提到的Seek触发。
  • Seek触发Compaction:Version中会记录file_to_compact_和file_to_compact_level_,这两个值会在Get操做每次尝试从文件中查找时更新。LevelDB认为每次查找一样会消耗IO,这个消耗在达到必定数量能够抵消一次Compaction操做消耗的IO,因此对Seek较多的文件应该主动触发一次Compaction。但在引入布隆过滤器后,这种查找消耗的IO就会变得微不足道了,所以由Seek触发的Compaction其实也就变得没有必要了。
  • 手动Compaction:LevelDB提供了外部接口CompactRange,用户能够指定触发某个Key Range的Compaction,LevelDB默认手动Compaction的优先级高于两种自动触发。

这几个触发条件并不是无的放矢,单个文件过大的容量会吸引大量的查询而且这些查询的速度因为其容量均会减慢,考虑极端状况,只有一个SSTable,那么查询最快也得经历其全部重启点的二分查找。容量越大,可以装入内存的table就更少,须要发生文件读的可能性就越大。对每一层次来讲,上面的理由依然成立,一层的容量过大,要么是文件数不少,要么是单个文件的容量过大,后者已经分析过了,前者会致使二分变慢,并且新数据和老数据没有区分度,不能对于这一假设(新的数据每每被更频繁地访问)作优化,并且对于同一key,其记录数变多,重启点能覆盖的key变少,即便单个文件内的查找也变得低效。

某个文件频繁地被查找,可能出于几种情形:1. 它包含了太多的热点key最新的记录,也就是说它的查找大部分命中了。2. 它的key range 和一些长期木有更新而又被常常访问的key重合了,这种就是出现大量未命中的查找。我的认为compaction主要改善的是后者,这也是为何布隆过滤器使得seek compaction无足轻重,由于判断一个SSTable是否含有对应key所须要的IO资源变少了,但若是你命中了,该读的仍是得读,布隆并不能改善啥,因此我的认为主要为了改善第二点。

上面两段就是Leveldb对于compaction的IO消耗与单次comapct收益权衡以后给出的答案。

compaction的过程

minor compaction

首先,咱们来说讲minor compaction,它的目的是把immutable_memtable写入0层的SSTable文件中。咱们已经只读如何遍历一个memtable了,也知道如何经过逐条添加构建一个SSTable了,更清楚了SSTable如何持久化到文件中。对上述步骤不明白的,请参阅上文memtable和sstable章节,因此minor compaction的过程不是理所固然的吗?

这里,主要仍是强调两点:

  • 写入过程发生在immutable_memtable上,因此丝绝不影响写服务,memtable依然可用
  • 写入文件过程完毕后,在交换memtable和immutable_memtabled以后,immutable_memtable正在服务的读操做不会受到影响,这是得益于引用计数,直到服务完毕才会删除原来的immutable_memtable

接下来,咱们主要解析major compaction。

选取参与compaction的SSTable

除level0外,每一个level内的SSTable之间不会有key的重叠:也就是说,某一个key只会出如今该level(level > 0)内的某个SSTable中。可是某个key可能出如今多个不一样level的SSTable中。所以,大部分情形下,Compaction应该是发生在不一样的level之间的SSTable之间。

对level K的某个SSTable S1,Level K+1中可以与它进行Compaction的SSTable必须知足条件:与S1存在key范围的重合

SSTable选择示意图

如上图所示,对于SSTable X,其key范围为hello ~ world,在level K+1中,SSTable M的key范围为 mine ~ yours,与SSTable X存在key范围的重合,同时SSTable N也是这样。所以,对于 SSTable X,其Compaction的对象是Level K+1的SSTable M和SSTable N。

最后,考虑特殊情形——level0 的情况。Level 0的SSTable之间也会存在key范围的重合,所以进行Compaction的时候,不只须要在level 1寻找可Compaction的SSTable,同时也要在level 0寻找,以下图示:

Level0情形示意图

major compaction的过程

先从触发点开始考虑,咱们就先从简单的状况——也就是compact单个文件开始讲起。先假设咱们须要compact Level K层的某个文件,首先咱们要作的就是首先找到参与compaction的全部文件,而后遍历这些文件中的全部记录,选取里面有效且最新的记录写入到新的SSTable文件。最后用新生成的SSTable文件替换掉原来的Level K + 1层的文件。

这样咱们就面临一个生死攸关的问题了:当处理一条记录的时候,如何判断要不要将它写入新文件中呢?答案是当有比它更新的同一key的记录就抛弃它,那么如何找到这个更新的记录呢?

最简单的作法:因为Level k 比Level k+1新,Level k+1又不会出现key 重合,咱们很天然地能够获得一个重新到旧的遍历顺序,只要去新写入的SSTable中查询便可。但这样每次写入都须要一次查询,依然太慢了。咱们能不能先按key序遍历,在同一key内部再按seq递减序遍历,这样只要保留每一个key区间的第一个。Leveldb就是这么作的,可是如何实现呢?

Leveldb使用了一个merging iterator,它统筹控制每一个SSTable的iterator,并在它们中选取一个前进,而后跳过全部同一key的记录。这样处理一条记录所需的查找代价从查询新SSTable文件的全部内容变成了询问几个SSTable对应iter的当前游标,不可谓不妙啊,使人惊叹的作法!下图是一个简单的流程示意:

compaction流程示意图

关于iterator的详细参考能够阅读下列文章:

庖丁解LevelDB之Iterator

下一步,咱们把它扩展到一层文件的compaction:对于大多数层,因为文件之间的key range没有交叠,因此你彻底能够迭代进行上面的操做,分别对每个文件合并。实际上major compaction是按key range来的,它每次会compact一个level中的一个范围内的SSTable,而后将这个key范围更新,下次就compact下一范围,控制每一层参与一次compact的SSTable数量。

接下来,咱们考虑Level 0 的情形,因为咱们必须保证0层的总比1层新,假设0层原本有两个同一key的记录,较新的那个被合并到1层以后,查询时在0层能查到较老的那个,bug出现了!因此咱们不得不找出本层全部和当前所要合并的文件有重叠的文件加入合并集合来解决。

而后,咱们来说讲删除的情形。Leveldb中的删除是一个特殊记录,它不会致使数据当即被删除,而是查询到删除记录后将会忽略更老的记录。真正的删除过程是发生在Compaction中的,这里咱们又得问一个问题了:那么删除记录须要写入到上一层吗?须要的,不然在上上层的记录就有可能被查到,只有最上层的删除记录会真正被删除,因此删除是逐步逐层地进行的,一层一层删去过去的记录。

咱们考虑major compaction对服务可用性和性能的影响:在生成新SSTable期间,旧的SSTable依然可用。因为SSTable本就是不可写的,因此对写服务不会形成任何不可用,对于读服务,依然能够在老的SSTable上进行。新的SSTable写到的是一个临时文件,当写入完毕后会进行重命名操做,可是注意对于旧文件,必须查询它在内存中有没有对应的table以及该table的引用计数。只有当没有读服务在该文件上,才能删除该文件。因此,综上compaction对服务可用性没有什么影响。

最后,咱们还须要生成一次compact点,进行一次version edit并写入Manifest文件,最终使当前version更新到新版本。这个过程在元信息管理中已经讲述过了,就再也不赘述了。

Leveldb的工程优化

write batch

Leveldb采用write batch来优化并发写,对每个写操做,先经过传入的键值对构造一个WriteBatch对象,这玩意里面其实就是一个字符串,多个并发写的write batch最后会被合并成一个。这一段的代码确实精妙,请参阅下列文章。

leveldb - 并发写入处理

table cache

对于Leveldb这种主要基于磁盘存储的引擎,cache优化是很是天然的想法。levelDb中引入了两个不一样的Cache:Table Cache和Block Cache。其中Block Cache是配置可选的。cache主要仍是做用在读过程当中,详细状况你们请参阅下列文章:

LevelDB教程9:levelDB中的Cache

源代码实现解析:

leveldb源码分析之Cache

如何做用在读操做流程中的:

Leveldb源码分析--11

布隆过滤器

先了解布隆过滤器的原理和概念:

Bloom Filter概念和原理

对实现感兴趣的盆友,能够继续看这篇文章

leveldb源码学习--BloomFilter布隆过滤器

增长过滤器就须要在写入SSTable的时候向过滤器添加本身写入的键,这一点能够回头看SSTable写入过程。过滤器的做用在Compaction一章中也说了,主要为了改善当发现目标key在某个SSTable的key range内,但事实上未命中时,减小IO消耗,因此你们也知道解析过滤器部分应该用在哪儿了吧。

参考文章

相关文章
相关标签/搜索