高性能nosql ledisdb设计与实现(1)

ledisdb是一个用go实现的基于leveldb的高性能nosql数据库,它提供多种数据结构的支持,网络交互协议参考redis,你能够很方便的将其做为redis的替代品,用来存储大于内存容量的数据(固然你的硬盘得足够大!)。git

同时ledisdb也提供了丰富的api,你能够在你的go项目中方便嵌入,做为你app的主要数据存储方案。github

与redis的区别

ledisdb提供了相似redis的几种数据结构,包括kv,hash,list以及zset,(set由于咱们用的太少如今不予支持,后续能够考虑加入),可是由于其基于leveldb,考虑到操做硬盘的时间消耗铁定大于内存,因此在一些接口上面会跟redis不一样。redis

最大的不一样在于ledisdb对于在redis里面能够操做不一样数据类型的命令,譬如(del,expire),是只支持kv操做的。也就是说,对于del命令,ledisdb只支持删除kv,若是你须要删除一个hash,你得使用ledisdb额外提供的hclear命令。sql

为何要这么设计,主要是性能考量。leveldb是一个高效的kv数据库,只支持kv操做,因此为了模拟redis中高级的数据结构,咱们须要在存储kv数据的时候在key前面加入相关数据结构flag。数据库

譬如对于kv结构的key来讲,咱们按照以下方式生成leveldb的key:api

func (db *DB) encodeKVKey(key []byte) []byte {
    ek := make([]byte, len(key)+2)
    ek[0] = db.index
    ek[1] = kvType
    copy(ek[2:], key)
    return ek
}

kvType就是kv的flag,至于第一个字节的index,后面咱们在讨论。性能优化

若是咱们须要支持del删除任意类型,可能的一个作法就是在另外一个地方存储该key对应的实际类型,而后del的时候根据查出来的类型再去作相应处理。这不光损失了效率,也提升了复杂度。网络

另外,在使用ledisdb的时候还须要明确知道,它只是提供了一些相似redis接口,并非redis,若是想用redis的所有功能,这个就有点无能为力了。数据结构

db select

redis支持select的操做,你能够根据你的业务选择不一样的db进行数据的存放。原本ledisdb只打算支持一个db,可是通过再三考虑,咱们决定也实现select的功能。app

由于在实际场景中,咱们不可能使用太多的db,因此select db的index默认范围就是[0-15],也就是咱们最多只支持16个db。redis默认也是16个,可是你能够配置更多。不过咱们以为16个彻底够用了,到如今为止,咱们的业务也仅仅使用了3个db。

要实现多个db,咱们开始定了两种方案:

  • 一个db使用一个leveldb,也就是最多ledisdb将打开16个leveldb实例。

  • 只使用一个leveldb,每一个key的第一个字节用来标示该db的索引。

这两种方案咱们也不知道如何取舍,最后决定采用使用同一个leveldb的方式。可能咱们以为一个leveldb能够更好的进行优化处理吧。

因此咱们任何leveldb key的生成第一个字节都是存放的该db的index信息。

KV

kv是最经常使用的数据结构,由于leveldb原本就是一个kv数据库,因此对于kv类型咱们能够很简单的处理。额外的工做就是生成leveldb对应的key,也就是前面提到的encodeKVKey的实现。

Hash

hash能够算是一种两级kv,首先经过key找到一个hash对象,而后再经过field找到或者设置相应的值。

在ledisdb里面,咱们须要将key跟field关联成一个key,用来存放或者获取对应的值,也就是key:field这种格式。

这样咱们就将两级的kv获取转换成了一次kv操做。

另外,对于hash来讲,(后面的list以及zset也同样),咱们须要快速的知道它的size,因此咱们须要在leveldb里面用另外一个key来实时的记录该hash的size。

hash还必须提供keys,values等遍历操做,由于leveldb里面的key默认是按照内存字节升序进行排列的,因此咱们只须要找到该hash在leveldb里面的最小key以及最大key,就能够轻松的遍历出来。

在前面咱们看到,咱们采用的是key:field的方式来存入leveldb的,那么对于该hash来讲,它的最小key就是"key:",而最大key则是"key;",因此该hash的field必定在"(key:, key;)"这个区间范围。至于为何是“;”,由于它比":"大1。因此"key:field"必定小于"key;"。后续zset的遍历也采用的是该种方式,就不在说明了。

List

list只支持从两端push,pop数据,而不支持中间的insert,这样主要是为了简单。咱们使用key:sequence的方式来存放list实际的值。

sequence是一个int整形,相关常量定义以下:

listMinSeq     int32 = 1000
listMaxSeq     int32 = 1<<31 - 1000
listInitialSeq int32 = listMinSeq + (listMaxSeq-listMinSeq)/2

也就是说,一个list最多存放1<<31 - 2000条数据,至于为啥是1000,我说随便定得你信不?

对于一个list来讲,咱们会记录head seq以及tail seq,用来获取当前list开头和结尾的数据。

当第一次push一个list的时候,咱们将head seq以及tail seq都设置为listInitialSeq。

当lpush一个value的时候,咱们会获取当前的head seq,而后将其减1,新获得的head seq存放对应的value。而对于rpush,则是tail seq + 1。

当lpop的时候,咱们会获取当前的head seq,而后将其加1,同时删除之前head seq对应的值。而对于rpop,则是tail seq - 1。

咱们在list里面一个meta key来存放该list对应的head seq,tail seq以及size信息。

ZSet

zset能够算是最为复杂的,咱们须要使用三套key来实现。

  • 须要用一个key来存储zset的size

  • 须要用一个key:member来存储对应的score

  • 须要用一个key:score:member来实现按照score的排序

这里重点说一下score,在redis里面,score是一个double类型的,可是咱们决定在ledisdb里面只使用int64类型,缘由一是double仍是有浮点精度问题,在不一样机器上面可能会有偏差(没准是我想多了),另外一个则是我不肯定double的8字节memcmp是否是也跟实际比较结果同样(没准也是我想多了),其实更可能的缘由在于咱们以为int64就够用了,实际上咱们项目也只使用了int的score。

由于score是int64的,咱们须要将其转成大端序存储(好吧,我假设你们都是小端序的机器),这样经过memcmp比较才会有正确的结果。同时int64有正负的区别,负数最高位为1,因此若是只是单纯的进行binary比较,那么负数必定比正数大,这个咱们经过在构建key的时候负数前面加"<",而正数(包括0)加"="来解决。因此咱们score这套key的格式就是这样:

key<score:member //<0
key=score:member //>=0

对于zset的range处理,其实就是肯定某一个区间以后经过leveldb iterator进行遍历获取,这里咱们须要明确知道的事情是leveldb的iterator正向遍历的速度和逆向遍历的速度彻底不在一个数量级上面,正向遍历快太多了,因此最好别去使用zset里面带有rev前缀的函数。

总结

总的来讲,用leveldb来实现redis那些高级的数据结构还算是比较简单的,同时根据咱们的压力测试,发现性能还能接受,除了zset的rev相关函数,其他的都可以跟redis保持在同一个数量级上面,具体能够参考ledisdb里面的性能测试报告以及运行ledis-benchmark本身测试。

后续ledisdb还会持续进行性能优化,同时提供expire以及replication功能的支持,预计6月份咱们就会实现。

ledisdb的代码在这里https://github.com/siddontang/ledisdb,但愿感兴趣的童鞋共同参与。

相关文章
相关标签/搜索