13

摘要

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

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

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

Leveldb的实现原理

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消耗,所以大家也知道解析过滤器部分应该用在哪儿了吧。

参考文章


suemi
1.5k 声望102 粉丝

程序猿一只,研究僧一枚


引用和评论

0 条评论