高性能nosql ledisdb設計與實現(1)

ledisdb是一個用go實現的基於leveldb的高性能nosql數據庫,它提供多種數據結構的支持,網絡交互協議參考redis,你可以很方便的將其作爲redis的替代品,用來存儲大於內存容量的數據(當然你的硬盤得足夠大!)。

同時ledisdb也提供了豐富的api,你可以在你的go項目中方便嵌入,作爲你app的主要數據存儲方案。

與redis的區別

ledisdb提供了類似redis的幾種數據結構,包括kv,hash,list以及zset,(set因爲我們用的太少現在不予支持,後續可以考慮加入),但是因爲其基於leveldb,考慮到操作硬盤的時間消耗鐵定大於內存,所以在一些接口上面會跟redis不同。

最大的不同在於ledisdb對於在redis裏面可以操作不同數據類型的命令,譬如(del,expire),是隻支持kv操作的。也就是說,對於del命令,ledisdb只支持刪除kv,如果你需要刪除一個hash,你得使用ledisdb額外提供的hclear命令。

爲什麼要這麼設計,主要是性能考量。leveldb是一個高效的kv數據庫,只支持kv操作,所以爲了模擬redis中高級的數據結構,我們需要在存儲kv數據的時候在key前面加入相關數據結構flag。

譬如對於kv結構的key來說,我們按照如下方式生成leveldb的key:

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的功能。

因爲在實際場景中,我們不可能使用太多的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,希望感興趣的童鞋共同參與。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章