数据在写入 LevelDB 时,并不是立即写入磁盘,而是会先写入内存中,当内存中的数据达到一定数量时,才会写入磁盘。这些内存中的数据统一由 MemDB 来维护,包括数据写入与查询。本文将以 GoLevelDB 源码为例,介绍 MemDB 组件。
整个LevelDB会在内存中维护三种数据,包括Batch
、MemDB
、FrozenMemDB
:
- Batch 包含一条或多条用户待写入的数据
- MemDB 在内存中临时存储写入的数据
- FrozenMemDB 一种只读的MemDB,当 MemDB 数据量达到上限时,切换为 FrozenMemDB
下面将分别介绍 Batch
和MemDB
。
Batch
有两种方式可以往LevelDB中写入数据,一种是单次写入一条(Put
)或者单次写入多条(Write
),不论是一条还是多条,在内部都是以Batch
的形式组织。另外,并发地多次写入也可以被合并成一个Batch
。
func (db *DB) Put(key, value []byte, wo *opt.WriteOptions) error
func (db *DB) Write(batch *Batch, wo *opt.WriteOptions) error
Batch 主要存储了2个数据项,分别是data
、index
、internalLen
:
- data 存储
key
value
数据 - index 存储索引,即记录每个
key
、value
在data
中的位置。len(index)
等于key的数量
Batch Record
有没有发现多条记录都一起存储在data
这个一维数组中,那么怎么判断一条记录的起止区间呢?可以看看上图的Batch Record
结构,每条记录中都存储了key_length
和value_length
。
- type 用1个字节区分
Val
和Del
,也就是写入和删除 - key_length 用
binary.MaxVarintLen32
(5个字节)存储key
的长度(最大232字节) - value_length用
binary.MaxVarintLen32
(5个字节)存储value
的长度(最大232字节);如果type
是删除的话,那么就没有value_length
和value
。
Index
每往Batch
中写一条记录时,就会往index
这个数组中写入一条索引信息,包括每条记录在data
中的位置以及key
、value
的长度。那么当我们想知道Batch
中有多少条数据时,就可以很方便地直接返回len(index)
。此外,在遍历Batch
时,也可以直接遍历index
,通过其记录的keyPos
、valuePos
来读取数据。
Internal Key
数据从Batch
进入到MemDB
后,其key
就会被转换成internal key
,其结构如下图。其实就是在原有的key
后面加了8个字节的额外信息,包括7个字节的sequence number
和1个字节的type
(添加、删除)。sequence number
是一个自增的ID,代表了数据的版本号,在本文中暂时用不到。
MemDB
Batch
中的数据会写入到MemDB
中,MemDB
的默认最大容量是 4MB,当容量达到4MB
时,会将其修改为只读状态,记为FrozenMemDB
,并重新初始化一个新的MemDB
。新的数据会写到新的MemDB
中,而FrozenMemDB
中的数据会在后台任务中被持久化到文件系统中。如果在FrozenMemDB
完成持久化前,新的MemDB
也达到了容量上限,那么新的写入请求会被阻塞住,直到FrozenMemDB
完成持久化。也就是说,同一时刻,最多只可能有1个MemDB
和1个FrozenMemDB
存在。
SkipList
MemDB
的结构其实是一个 SkipList,这是为了方便查询内存中的数据。数据从Batch
中写入MemDB
的过程也就是构建SkipList
的过程。跳表长这样:
其实就是有很多个节点,每个节点高度不同,每个节点都记录了它在各个高度的下一个节点的指针,每一层都是一个有序链表。越高层的数据量越少,越底层的数据量越大,最底层的链表包含了所有数据。检索的时候从高层往低层移动,相当于二分查找。
该怎么表示 SkipList
呢?最简单的方式是抽象出一个Node
的struct
,其中存放相关指针和key
value
数据。但是 GoLevelDB 并没有这么实现,而是把数据和节点分开,并且巧妙地把所有数据/节点都分别存储在了一个一维数组中。
Node < 0 means nill
是错误的,应该是=0
。也就是
nodeData[node + nNext + level] = 0
时,表示指向nil
kvData
很简单粗暴地按序存储了key (internal key)
、value
的数据,没有维护任何额外信息,比如长度,MemDB
的最大容量也就是kvData
的最大容量。nodeData
是kvData
中数据对应的SkipList
节点,记node
为每个节点在nodeData
中的偏移量,那么每个节点的结构为:
nodeData[node + nKV]
: kvOffset其数据在kvData
的位置nodeData[node + nKey]
: Key LengthnodeData[node + nVal]
: Value LengthnodeData[node + nHeight]
: Height 高度,从 1 开始计数nodeData[node + nNext + level]
: 各层next
指针,其值是下一个节点在nodeData
中的offset,level
范围是[0, Height - 1]
初始化
初始化MemDB
时,会创建一个dummy node
,作为最开始的那个节点。其高度为tMaxHeight = 12
,各层的next
指针都是0,即指向它自己。
Put
首先通过findGE
,从dummy node
开始,由maxHeight
往下找Greate Or Equal
待写入的value
的节点。不论是否在level0
找到,都要移动到level0
,并把在每一层中最后一个访问的节点写入prevNode
数组中。
为什么要走到level0
呢?因为插入数据时,需要重建每一层的链表。我们根据prevNode
存储的每一层的节点,即可以完成每一层的链表插入操作。
插入数据时,会随机一个height
,范围为[1, tMaxHeight]
,只会修改[1, height]
内的链表。key
value
数据会append
到kvData
末尾。
// Put sets the value for the given key. It overwrites any previous value
// for that key; a DB is not a multi-map.
//
// It is safe to modify the contents of the arguments after Put returns.
func (p *DB) Put(key []byte, value []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
if node, exact := p.findGE(key, true); exact {
kvOffset := len(p.kvData)
p.kvData = append(p.kvData, key...)
p.kvData = append(p.kvData, value...)
p.nodeData[node] = kvOffset
m := p.nodeData[node+nVal]
p.nodeData[node+nVal] = len(value)
p.kvSize += len(value) - m
return nil
}
h := p.randHeight()
if h > p.maxHeight {
for i := p.maxHeight; i < h; i++ {
p.prevNode[i] = 0
}
p.maxHeight = h
}
kvOffset := len(p.kvData)
p.kvData = append(p.kvData, key...)
p.kvData = append(p.kvData, value...)
// Node
node := len(p.nodeData)
p.nodeData = append(p.nodeData, kvOffset, len(key), len(value), h)
for i, n := range p.prevNode[:h] {
m := n + nNext + i
p.nodeData = append(p.nodeData, p.nodeData[m])
p.nodeData[m] = node
}
p.kvSize += len(key) + len(value)
p.n++
return nil
}
Update
更新操作和 Put
操作流程一样,只是在 findGE
时,能找到equal
的节点,进而直接更新节点的kvOffset
Key Length
Value Length
。
不过需要注意的是,并不会更新或者删除kvData
中的旧数据,而是仍然在kvData
添加新数据。之前的旧值就放在了kvData
中保持不动,作为脏数据,没有任何节点指向它。
Delete
删除操作也是先findGE
,然后把各层的链表都更新一下,同样地也不会删除kvData
中的数据。
Get
Get 操作也是调用 findGE
,找到后使用kvOffset
Key Length
Value Length
把数据从kvData
取出来即可。
findGE
下面是 findGE
源码,需要修改链表时,prev
才为true
,只是Get
数据时,不需要走到level0
,因此prev = false
// Must hold RW-lock if prev == true, as it use shared prevNode slice.
// returns if equal
func (p *DB) findGE(key []byte, prev bool) (int, bool) {
node := 0
level := p.maxHeight - 1
for {
next := p.nodeData[node+nNext+level]
cmp := 1
if next != 0 {
cmp = p.cmp.Compare(p.keyAt(next), key)
}
if cmp < 0 {
// Keep searching in this list
node = next
} else {
if prev {
p.prevNode[level] = node
} else if cmp == 0 {
return next, true
}
if level == 0 {
return next, cmp == 0
}
level--
}
}
}
Log
(本节内容来自于 https://leveldb-handbook.readthedocs.io/zh/latest/journal.html )
放在内存中的数据并不可靠,当进程崩溃时可能会导致数据丢失。因此再写入内存前会先把数据写到WAL中,当 MemDB 切换到 Frozen MemDB 时,也同时会生成一个 Fronzen Log。当 Frozen MemDB 持久化到文件系统中时,就会把 Fronzen Log 删掉。
为了增加读取效率,日志文件中按照block进行划分,每个block的大小为32KiB。每个block中包含了若干个完整的chunk。一条日志记录包含一个或多个chunk。每个chunk包含了一个7字节大小的header,前4字节是该chunk的校验码,紧接的2字节是该chunk数据的长度,以及最后一个字节是该chunk的类型。其中checksum校验的范围包括chunk的类型以及随后的data数据。
chunk共有四种类型:full,first,middle,last。一条日志记录若只包含一个chunk,则该chunk的类型为full。若一条日志记录包含多个chunk,则这些chunk的第一个类型为first, 最后一个类型为last,中间包含大于等于0个middle类型的chunk。
由于一个block的大小为32KiB,因此当一条日志文件过大时,会将第一部分数据写在第一个block中,且类型为first,若剩余的数据仍然超过一个block的大小,则第二部分数据写在第二个block中,类型为middle,最后剩余的数据写在最后一个block中,类型为last。
(完)
References: