LevelDB源码解析3. 基本思路

LevelDB源码解析3. 基本思路

第一义

在阅读源码之前,应该回答这个问题:

源码的大概框架结构是什么样的?

这个问题非常重要。如果有了大概的框架结构,那么后面在看代码的时候就心中有数。相当于房子框架已经搭建好了。后面需要看工程师如何堆砖块与实施工程技术手段。

这是看源码重要的方法论。

LevelDB能做什么?

class DB {
 public:
  virtual Status Put(
      const WriteOptions& options,
      const Slice& key,
      const Slice& value) = 0;

  virtual Status Delete(
      const WriteOptions& options,
      const Slice& key) = 0;

  virtual Status Write(
      const WriteOptions& options,
      WriteBatch* updates) = 0;

  virtual Status Get(
      const ReadOptions& options,
      const Slice& key,
      std::string* value) = 0;
}

简单来说,也就是四个接口。

  1. Put
  2. Delete
  3. Write
  4. Get

Write与Put的区别在于,Write是批处理写。比如有两个{key1: val1, key2: val2}一定要同时写入,要么同时写成功,要么同时写失败。而Put只是支持单项的写入。(从底层实现上来说,最后都是调用Write接口。因为写单项的时候,只是相当于批处理里面只有一个Job,从而实现了统一)。

LevelDB解决的问题

如果只是从简单的代码的角度上来讲。实现这四个接口的代码并不难。那么为什么LevelDB面临的问题又是什么?要解决的问题又是什么?

假设想完成一个单机版的KV存储系统。

  • 不能只用内存,一个是数据量很大。内存存不下。不能只用内存的另外一个原因是:内存掉电后,内容会丢失,用户存储的数据是一定要持久化的。
  • 数据持久化也就意味着需要写到磁盘上。存储资源空间可以很大,但是速度则会很慢。
  • 磁盘的特点是:顺序写与顺序读速度比较快。随机写与随机读则需要磁盘寻道。因此单位时间内的读写(IOPS)比顺序的情况要差。一般要下降3倍左右。

这个时候就清楚LevelDB需要解决的问题是什么样的了。

  • 在以上条件的制约下,如何设计一个可靠的高性能的KV存储系统。

版本1

在直接去看LevelDB的设计的时候,想大致地整理一下思路。进而可以更好地知道为什么LevelDB要这么设计。

知道Why有时候比知道What, How更加重要。

想像一下非常极端的例子。如果极度地追求写入速度。能多快就多快的情况下。假设磁盘足够用。那么每次写入的数据是以下情况就可以了。

WAL的工作原理

也就是直接采用WAL的工作方式。每次都是只是把要写的操作扔到磁盘上。但是这样只是满足了两个要求:

  1. 数据写入速度最快
  2. 数据持久化

也就是:

class DB {
 public:
  virtual Status Put(
      const WriteOptions& options,
      const Slice& key,
      const Slice& value) = 0;

  virtual Status Delete(
      const WriteOptions& options,
      const Slice& key) = 0;

  virtual Status Write(
      const WriteOptions& options,
      WriteBatch* updates) = 0;
}

但是这种处理方式是不能服务读求请的。万一来了读请求,立马就歇菜。通过版本1已经可以支持其中的3个接口了。现在只需要重点考虑如何支持Get请求。

  virtual Status Get(
      const ReadOptions& options,
      const Slice& key,
      std::string* value) = 0;

由于写入的时候是顺序的。能够获得性能最大的提升。那么肯定也想读最好也是可以达到性能最大化。也就是读的时候,也是顺序读为主。如何实现呢?


版本2

现在知道版本1是肯定不能服务读请求的。那么接下来如何支持读请求呢?

当数据量小的时候,比如客户的数据只有1MB。也就不用那么麻烦了。 在版本1的基础上加上一个内存表。

  • 每次WAL写入到磁盘之后,立马更新到内存里面。
  • 当发现有读请求过来的时候,立马到内存中查找相应的表。

但是,这种方案只能是支持数据量比较小的情况。如果数据量一大。那么内存肯定是存不下那么多客户的Key/Val的。剩下的情况还是需要到磁盘中来进行查找。那么这个时候,情况就分成了两种:

  1. 内存中可以找到,直接返回。
  2. 内存大小总是有限的,数据量大的时候,肯定有一部分是在磁盘上。那么必须要到磁盘上寻找。

前面的思路实际上是把内存当成了一个状态机。然后所有状态的更新操作都是在内存上完成。(更改记录则是通过WAL LOG持久在磁盘上。如果机器掉电,那么只需要新的把WAL LOG在内存里面回放则可)。

现在只需要处理第2种情况就可以了。由于WAL的方式,会导致的情况是,磁盘数据布局如下:

磁盘上的数据布局

实际上,针对于读而言。历史数据作用不是特别大。重要的是当前数据值。

这个时候,除了把内存当成一个状态机,还可以把内存+磁盘一起组成一个巨大的状态机。

从状态机的角度来考虑就是,WAL记录了修改操作的每一个历史记录。而余下的内存和磁盘则用来组成一个KV系统的状态机。这个状态机的记录了当前的KV值。并且可以满足如下目标。

  1. 维持数据的有序性:有序性主要是为了查找方便。比如能够快速地找到key对应的val是多少。
  2. 能够快速地遍历区间。比如位于aa到cc中间的key/val有哪些。都取出来吧。
有序状态机模型

其中WAL LOG与内存数据是等价的。如果掉电,WAL与disk block都是存在的。数据的持久性仍然可以得到保证。State Machine由于是有序的。读取的速度也可以保证。

接下来的问题就是:如何保证有序性?考虑这样一种算法:

  1. 先写WAL LOG
  2. 然后把WAL LOG里面的操作更新到内存里面。
  3. 当内存容量不足的时候,把内存里面的内容与磁盘上的block合并。

这样的操作如下图:

但是如果这样操作,很容易出现合并的时候,合并的写入就变成了随机写。那么有什么改进的方法呢?

版本3

考虑到内存里面的数据都是有序的。那么在处理的时候可以这样。

  1. 写WAL LOG
  2. 更新内存,即是下图中的MemTable
  3. 当内存size达到一定程度的时候。把Memtable变成不可变的内存块。
  4. 把内存块与磁盘上的Block。这里叫做.sst文件进行合并。到这里内存的合并是顺序写的。
  5. 磁盘上的block根据新旧先后分层。总是上面一层的与下面一层的合并。
  6. 读的时候先查内存,内存中没有的时候,再顺次从Level0~LevelN里面的磁盘块内容。直到找到Key即返回相应的val。找不到说明不存在,返回NULL。
LevelDB的基本思路
编辑于 2018-03-18

文章被以下专栏收录