高性能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,但願感興趣的童鞋共同參與。

相關文章
相關標籤/搜索