2
头图

这里以 LevelDB(go版本)的源码简单探究一下LevelDB的架构实现,欢迎在评论区留言交流

整体架构

image.png

log

leveldb 的写操作并不是直接写入磁盘的,而是首先写入到内存。假设写入到内存的数据还未来得及持久化,leveldb 进程发生了异常,或者是宿主机此时发生了宕机,会造成用户写入数据发生丢失。因此 leveldb 在写内存之前会首先将所有的写操作写到日志文件中,也就是 log 文件。当进程出现异常的时候,可以通过log来进行恢复,一个log对应一个memdbfrozen memdb的数据,当frozen memdb持久化为sstable之后,这个log就会被删掉

version(版本)

这个概念在上图中没有展示,但是是一个比较重要的概念

首先看下version在代码里面的结构体

type version struct {
    id int64 // unique monotonous increasing version id
    s  *session

    levels []tFiles

    // Level that should be compacted next and its compaction score.
    // Score < 1 means compaction is not strictly needed. These fields
    // are initialized by computeCompaction()
    cLevel int
    cScore float64

    cSeek unsafe.Pointer

    closing  bool
    ref      int
    released bool
}

// tFiles hold multiple tFile.
type tFiles []*tFile

// tFile holds basic information about a table.
type tFile struct {
    fd         storage.FileDesc
    seekLeft   int32
    size       int64
    imin, imax internalKey
}

version 结构体里面是个二维数组,二维数组的key表示level的层级,而value则表示当前level下面所有的文件

这也是LevelDB的名字的由来

manifest

由上面version的定义,我们可以看到,每个sst文件(LevelDB的存储文件),在内存里面是编码和记录好的,属于哪个level;但是version只是内存里面的结构,如果程序重启了,那么version的信息也就不存在了,所以manifest的主要做作用就是记录version信息的;记录方式一般有两种:增量记录和全量记录,manifest就选用了增量记录和全量记录混合的方式

在继续介绍manifest的存储内容之前,先介绍一种数据结构

type sessionRecord struct {
    hasRec         int
    comparer       string
    // 日志文件的序号
    journalNum     int64
    // 已废弃
    prevJournalNum int64
    // 下一个sst文件的序号
    nextFileNum    int64
    // 日志的序号,每增加一条日志,序号+1
    seqNum         uint64
    compPtrs       []cpRecord
    // 当前这个compaction中,增加的表信息
    addedTables    []atRecord
    // 当前这个compaction中,增加的表信息
    deletedTables  []dtRecord

    scratch [binary.MaxVarintLen64]byte
    err     error
}

type atRecord struct {
    level int
    num   int64
    size  int64
    imin  internalKey
    imax  internalKey
}

type dtRecord struct {
    level int
    num   int64
}

sessionRecord 记录了一次compaction中增加和删除的表,以及这个增加的表所在的level等信息,而version也可以映射成为一个sessionRecord,当每次程序重新启动的时候,程序就会读取老的manifest文件,产生version,并将这个version映射成为一条sessionRecord记录到manifest文件里面,后续compaction的时候,再去追加sessionRecord(也就是sessionRecord)

current

指向当前应该使用的manifast文件

memdb

内存表,用户增删数据时,并不是直接操作文件,而是写入log,同时将数据写入内存表,当内存表达到一定的阈值时(用户可设置),这个内存表不允许再写入,转换成 frozen memdb 然后进行compaction;memdb底层使用了跳跃表的数据结构

frozen memdb

memdb到达设定的阈值的时候,就会转换成 frozen memdb这只是一个理论概念,它的结构类型跟memdb是一样的,只是不写可而已,当memdb转换成 frozen memdb 会触发后台compaction,转换成文件存储

sstable

LevelDB的最终的落地的文件,这些文件会被定期的进行整合,即compaction,compaction之后,文件在逻辑上会分为若干层,frozen memdb 直接dump出来的文件落在0层,0层文件compaction之后会落在1层,依次类推;同时,需要留意的是,0层文件之间的key是可以重叠的,而0层以上的各层文件之间的key是不会重叠的

读写操作

写入

image.png

写入操作仅仅操作了journal log 和memdb

  1. 开始写入的时候,首先判断是否开启了写合并,写合并就是将多个goroutine的请求,归并到一个goroutine来操作,提高写的效率
  2. 如果开启了写合并,并且有其他goroutine拿到了写入锁,则把自己的数据交给另一个goroutine,等待他的返回写入结果即可
  3. 如果开启了写合并,并且拿到了写入锁,那就同理查一下,有没有其他goroutine需要搭个车一起写入的
  4. 将数据放到batch结构体里面,这是个批操作时申请的一个结构(所以,无论写入单个数据还是批量写入数据,最后都是以批量写入的形式来操作的),然后判断一下,memdb剩余的空间,够不够写入当前数据了,不够的话,就将当前memdb frozen,然后重新申请一个memdb,frozen memdb进行异步compaction
  5. 然后开始写入日志
  6. 写入日志后,开始写入memdb
  7. 如果开启了合并写,通知其他合并写入的goroutine 写入结果

读取

先放下memdb的结构体

type DB struct {
    cmp comparer.BasicComparer
    rnd *rand.Rand

    mu     sync.RWMutex
    // 数据存储的地方
    kvData []byte
    // Node data:
    // [0]         : KV offset
    // [1]         : Key length
    // [2]         : Value length
    // [3]         : Height
    // [3..height] : Next nodes
    // 跳跃表
    nodeData  []int
    prevNode  [tMaxHeight]int
    maxHeight int
    n         int
    kvSize    int
}

读取的逻辑是比较简单的,先从memdb和frozen memdb查找,然后再去文件里面查找,但是有些细节处理还是比较复杂的,我们这里主要看下ikey是个什么东西

上图就是kv存储的样例,所以如果内存中存储了 k1=v1, k2=v2 两个kv,那在memdb中就是一个byte数据,大致如下

那么snapshot大致结构也就如下了

说完kv的存储结构之后,可能会感觉有点挂怪的,那就是kv都摞在一起了,我怎么区分哪些是key,哪些是value呢,db数据结构里面还有一个字段nodeData,这个即充当了跳跃表,也用于记录kv的长度

这样如何在memdb中根据nodedData和kvData,使用skiplist的形式查找数据,也就比较清晰了

除了在memdb中查找,还会在sstable中查找,这个到后面再详说了

日志读写

日志结构

下图表示为一个block的数据存储示意

(图:日志读写-chunk结构)

而一个日志结构是由多个block组成的

(图:日志读写-日志结构)

其中上图中的data就是对应(图:日志读写-block结构)中的batch1...N

checksum: 用于校验数据准确性的

chunk type: 有first/middle/last/full 三种类型,用于表示一个chunk的完整性, full表示是一个完整的chunk

​ 如果一条数据可以在一个chunk里面完全写入,则这个chunk的chunk type则为full

​ 如果一条数据比较大,需要3个以上chunk的空间,则第一个chunk为first,最后一个为last,其余均为middle

length: batch的数量

日志写入

日志的写入比较简单,在程序内部,封装了一个singleWrite,这个singleWrite负责一次写入,同时对于大数据进行切块写入

日志写入时,会首先判断,这个block剩余的空间是否足够写一个header,不够的话,就把这个block的余下空间补0,够的话就写入,而chunk type就是判断当前block是否足够把数据写完,能够写完就是Full,不能够,就先写个first,然后middle/last

日志读取

日志的读取,按着写入反着来即可,唯一需要注意就是,读取一个block时,要校验一下checksum,并判断一个chunk type,如果chunk type不是Full/Last,说明不是最后一个block,数据不完整,继续读取,直到读取完整

sstable读取

sstable结构

当frozen memdb中的数据持久化到文件中时,按照一定的结构进行组织,而leveldb的这种数据存储的结构就称为sstable

一个sstable的数据结构如下所示

data block: 用来存储kv键值对

filter block: 开启过滤器后,存储由key创建的过滤器,levelDB中仅提供了布隆过滤器,且默认是不开启的;不开启则这里不存储

meta index block: 用来存储filter block的索引信息

index block: 用来存储每个data block的索引信息

footer: 用来存储index block的索引信息和meta index block的索引信息,同时还会存储一个特殊字符-"\x57\xfb\x80\x8b\x24\x75\x47\xdb"

注:每个block都会存储压缩类型和CRC校验信息,filter block 不进行压缩,也不存储CRC校验信息

footer

footer是的长度是48个字段,且恒定不变,分别存储meta index block和 index block在整个文件中的offset 及meta index的长度,最后8个字节存储一个magic字符

meta index block

meta index block 就记录了 filter block 的offset 和length

index block

index block相对与meta index block,每条记录多存储了一个 max key,记录每个data block的最大key,用于快速定位到目标data block

filter block

filter block 是一个过滤器,在levelDB里面默认是关闭的,关闭的时候,这个filter block也就没有数据,开启的时候可以选择过滤器,levelDB里面仅实现了布隆过滤器供使用

filter block的结构如上

base lg: 一个布隆过滤器的大小,默认值是11,对应布隆过滤器的大小就是 2^11 = 2K

filter offset’s offset:第一个filter offset的位置,用于分割data和 filter

filter n offset:虽然filter n offset指向filter data n,但是真实去查找 filter data的时候,并不会先去找filter offset,然后再偏移过去;实际操作是,baselg记录了每个filter data的大小,直接位移操作即可

data block

我们首先看下 data block的结构

在这个结构里面,有几个生面孔--entry 、 restart pointer

这里就需要首先了解下,kv在data block 里面的存储结构了

entry就是kv在data block里面的存储结构,可以表示为

shared key 长度: 指与前面一个key,前缀相同的长度,类似于前一个key 为 test1,当前存储的key 为 test2, 则shared key = test,unshared key = 4

可以看出,levelDB里面为了节省存储空间,并不是如同memdb中kv结构一般存储,而是将key进行压缩,所以也就造成了restart pointer的出现

restart pointer: 每个完整的key在 entry 中存储的位置

restart pointer 的存在是为了快速查找key的,当我们查找一个key是否存在的时候,我们首先根据restart pointer 找到完整的key,并对比,确定是否在两个restart pointer指向的key之间,如果在,则遍历这两个key之间的数据;如果没有restart pointer的存在,就需要一个个遍历entry来查找key了,从效率方面提升了很多

举个🌰:

// 每间隔2个,设置一个restart pointer
restart_interval=2 
entry1: key=deck,value=v1
entry2: key=dock,value=v2
entry3: key=duck,value=v3

则存储的数据结构为

读取操作

了解了上面的数据结构后,大致可以梳理出来sstable的读取流程了

在sstable文件查找key的时候,0层文件和非0层文件查找的时候有点区别

0层文件是允许重叠的,而非0层文件是不允许重叠的

0层文件1-N文件,并不保证是顺序排列的,非0层文件1-N文件的顺序排列的,也就是文件1的key是0-20,文件2的key是20-40这样,而0层文件的文件1key是0-20,文件2的key可以是10-30

上面两个区别点,导致了0层文件和非0层文件的查找的区别

compaction

compaction分为两种类型

  1. memory compaction:将frozen memdb中的数据持久化到L0层文件
  2. table compaction:将N层文件与N+1层中的文件进行合并的过程

memory compaction

memory compaction是比较简单的,遍历读取 memdb的数据,并顺序写入sstable中,添加上辅助数据即可,其本质就是内存数据的持久化;每次 memory compaction之后,都会新增一个0层文件;

memory compaction是一个时效性要求比较高的过程,要求在尽量短的时间内完成,所以其优先级要比table compaction要高,table compaction需要让步于memory compaction

table compaction

table compaction相对memory compaction要复杂一点,其原因在于

  1. 怎么确认要compaction 哪层的文件
  2. 怎么确认需要compaction的那个level, compaction 哪些文件

所以针对上面的需求,compaction的时候就设计了一些条件,当满足条件的时候开始compaction

  • 当 0 层文件数量超过预定的上限(默认为 4 个)
  • 当 level i 层文件的总大小超过 (10 ^ i)MB
  • 当某个文件无效读取的次数过多

确定了compaction的level,下一步就是确认compaction level的sstable了,主要逻辑如下

  • 如果是level 0 comapction,选择最悠久的sstable,即tFiles[0]
  • 如果不是level 0 compaction,如果是 因为无效读取次数过多,则 选择这个sstable
  • 如果既不是 level 0 compaction,也不是 无效读取次数过多导致,则选取上次大于 comapction sstable 的maxKey的 sstable

compaction流程

Level 0 compaction

因为 level 0 允许 key的重叠,所以 level 0 的compaction上有一点不同:当确定一个sstable之后,会首先 遍历当前level下所有的sstable,然后找到所有有重叠key的sstable,组成一个新的 [minKey,maxKey] 范围,然后再去level 1寻找

compaction-0

Level N compaction

level N (N > 0) 不会存在key的重叠,所以 level N不需要遍历当前level下的所有的sstable,来扩展需要查找的key的范围

compaction-1


tyloafer
814 声望228 粉丝