數據結構 - hashtable

@本文首發於 https://yeqown.github.iohtml

背景

最近一直在看《redis設計與實現》,其中講了redis中使用到的數據結構如:sds, ziplist, skiplist, hashtable, intset, linkedlist等。讀完第一部分以後,再結合github上的源碼redis,本着好記性不如爛筆頭的理念,便準備動手擼一遍。git

redis中hashtable的特色

  1. 鏈地址法解決hash衝突(除此之外,常見的衝突解決辦法還有:再散列法/再哈希法/創建公共溢出區)
  2. 使用了murmur2哈希函數。
  3. 漸進式rehashrehash過程並非一步到位,而是在get/set/del 等操做中,穿插着完成。
  4. 自動擴容和自動收縮,經過閥值來控制擴容和收縮。
  5. 有2個bucket,其中0號bucket是最經常使用的,而1號只會在rehash過程當中使用,一旦rehash完成,便再也不使用。

解析和實現

hashtable:是根據Key直接訪問在內存存儲位置的數據結構。如何根據key獲得內存中的位置,就須要使用hash函數來從旁協助了。

hash函數:是一種從任何一種數據中建立小的數字「指紋」的方法。簡單的說:hash(input) = 1122334455。github

這裏選擇了golang來實現;murmur3 hash算法;golang

數據結構

一圖以蔽之:redis

image.png

// 對外暴露的hashtable
type LinkedDict struct {
    // 2個存儲桶,0號正常使用,1號在rehash過程當中使用;rehash完成以後,1號賦值給0號而後重置1號。
    ht [2]*hashtable
    // 初始值 -1,表示沒有在rehash
    rehashIdx int
}

// 存儲桶
type hashtable struct {
    // 底層「數組」
    table []*dictEntry
    size int
    sizemask int
    used int
}

// hashtable 元素定義
type dictEntry struct {
    key string
    value interface{}
    next *dictEntry
}

方法集

hashtable經常使用的方法有 GET/SET/DELETE/ITER,接下來會在SET和DEL中介紹rehash的詳細過程。算法

SET
func (d *LinkedDict) Set(key string, value interface{}) {
    if d.ht[0].table == nil {
        d.ht[0].init(InitTableSize)
    }

    // isRehashing 斷定:`rehashIdx != -1`
    // needRehash 斷定: 裝載因子 used / size > 1.0 時觸發擴容rehash
    if !d.isRehashing() && d.needRehash() {
        // rehashGrowup 表示本次rehash是須要擴容,在配置ht[1].table時會擴展爲當前的2倍
        // 反之則會縮減內存空間
        // startrehash 會設置 rehashIdx = 0, 標誌rehash的進度
        d.startrehash(rehashGrowup) 
    }

    if d.isRehashing() {
        // 若是在rehash過程當中,則完成一部分任務: 
        // 根據rehash的進度rehashIdx,選擇搬移 d.ht[0].table[rehashIdx]部分的數據,添加到d.ht[1]中
        d.steprehash()
    }

    // 上述工做完成以後,就能夠考慮新增數據了
    hashkey := d.hashkey(key)
    if d.isRehashing() {
        // 若是在rehash過程當中,毋庸考慮,直接新增到d.ht[1]中
        d.ht[1].insert(hashkey, newDictEntry(key, value, nil))
        return
    }

    // 反之,不在rehash過程當中,則直接新增到d.ht[0]中
    d.ht[0].insert(hashkey, newDictEntry(key, value, nil))
    return
}

// 漸進式rehash,根據rehashIdx肯定,要搬移那一部分的數據。
func (d *LinkedDict) steprehash() {
    entry := d.ht[0].table[d.rehashIdx]
    // 若是rehashIdx指向的側鏈爲空,則rehashIdx自增,直到找到有數據的側鏈或者數據均搬移完成
    for entry == nil {
        d.rehashIdx++
        if d.rehashIdx > d.ht[0].sizemask {
            d.finishrehash()
            return
        }
        entry = d.ht[0].table[d.rehashIdx]
    }

    // 開始搬移動做
    // 遍歷鏈表,將全部數據,新增到d.ht[1]中
    next := entry.next
    for entry != nil {
        entry.next = nil
        d.ht[1].insert(d.hashkey(entry.key), entry)
        if next == nil {
            break
        }
        entry = next
        next = next.next
    }

    // 釋放d.ht[0].table[rehashIdx]鏈表:避免干擾查詢;釋放內存
    d.ht[0].table[d.rehashIdx] = nil
    d.rehashIdx++

    if d.rehashIdx > d.ht[0].sizemask {
        // 是否已經結束,若是結束則:
        // d.ht[0] = d.ht[1]
        // d.ht[1] = newHashTable()
        // rehashIdx = -1
        d.finishrehash()
    }
}

// 新增一個元素到到存儲桶:
// 根據hash函數的結果(hashkey)對存儲桶大小(size)取模獲得結果(pos);ht.table[pos]完成對鏈表的新增工做。
func (ht *hashtable) insert(hashkey uint64, item *dictEntry) {
    pos := hashkey % uint64(ht.size)
    entry := ht.table[pos]
    last := entry
    if entry == nil {
        ht.used++
        ht.table[pos] = item
        return
    }

    for entry != nil {
        if ht.keyCompare(entry.key, item.key) {
            // 若是key已經存在則覆蓋舊值
            entry.value = item.value
            return
        }
        last = entry
        entry = entry.next
    }
    ht.used++
    last.next = item
}

總結:數組

  1. rehashIdx 不只用於標示hashtable是否在rehash過程當中,也標示了rehash的進度;
  2. rehash過程當中,新增元素直接新增到1號bucket中;
  3. 非rehash狀態,則新增到0號bucket中;
  4. 側鏈新增元素過程,需比較key值是否存在,若是存在則更新並返回;
  5. rehash過程當中,rehashIdx不是隻會增長1單位,而是根據側鏈狀況來更新;
GET
func (d *LinkedDict) Get(key string) (v interface{}, ok bool) {
    // 同上SET,不過多贅述
    if d.isRehashing() {
        d.steprehash()
    }

    hashkey := d.hashkey(key)
    v, ok = d.ht[0].lookup(hashkey, key)
    if !d.isRehashing() {
        // 若是不在rehash過程當中 d.ht[0]中檢索的結果即是最終結果
        return
    } else if ok {
        // 若是在rehash過程當中且命中,也返回結果
        return v, ok
    }

    // 反之 rehash過程當中,但在d.ht[0]中沒找到,卻不表明該key真的不存在, 還須要在d.ht[1]中肯定
    v, ok = d.ht[1].lookup(hashkey, key)
    return
}

總結:安全

  1. 漸進rehash過程與SET中一致
  2. 查詢動做也須要根據rehash狀而定:在rehash中則須要檢查ht[0]和ht[1];反之只須要檢查rehash[0]便可;
  3. 這裏省略了lookup部分的代碼,是由於查詢和新增在原理上是一致的:定位 -> 遍歷檢查 -> 比較key -> 動做
DEL
// Del to delete an item in hashtable.
func (d *LinkedDict) Del(key string) {
    if d.ht[0].used == 0 && d.ht[1].used == 0 {
        return
    }

    // 這裏相比Set,區別在於:斷定的內容不是是否須要擴容而是縮容
    // 縮容斷定:d.ht[0]的內存空間大於初始值4且「填充率」少於 10%
    // d.ht[0].size > 4 && (d.ht[0].used*100/d.ht[0].size) < 10
    if !d.isRehashing() && d.needShrink() {
        d.startrehash(rehashShrink)
    }

    if d.isRehashing() {
        d.steprehash()
    }

    hashkey := d.hashkey(key)
    d.ht[0].del(hashkey, key)

    if d.isRehashing() {
        d.ht[1].del(hashkey, key)
    }
}

總結:數據結構

  1. 萬變不離其宗,不論是SET,GET,DEL 都是先定位,再肯定元素位置,再執行相應的動做;
  2. 縮容斷定中,填充率也等價於裝載因子;
  3. 代碼中有個取巧:當使用率爲0時則直接返回,避免了後續調用~
ITER
  1. 此部分代碼略去;
  2. 遍歷操做也須要視rehash狀況而定;

測試

這裏我使用了golang內置的Map作了對比測試,結果以下:併發

builtinMap_1000                  cost: 0ms
builtinLinkedDict_1000           cost: 0ms
getMap_1000                      cost: 0ms
getLinkedDict_1000               cost: 0ms

builtinMap_10000                 cost: 4ms
builtinLinkedDict_10000          cost: 6ms
getMap_10000                     cost: 2ms
getLinkedDict_10000              cost: 5ms

builtinMap_100000                cost: 76ms
builtinLinkedDict_100000         cost: 108ms
getMap_100000                    cost: 56ms
getLinkedDict_100000             cost: 131ms

builtinMap_1000000               cost: 1053ms
builtinLinkedDict_1000000        cost: 1230ms
getMap_1000000                   cost: 581ms
getLinkedDict_1000000            cost: 915ms

builtinMap_10000000              cost: 13520ms
builtinLinkedDict_10000000       cost: 17137ms
getMap_10000000                  cost: 8663ms
getLinkedDict_10000000           cost: 14271ms

可見差距仍是很是大的,這裏大膽分析下致使這些差距的緣由:

  1. Key類型,經過pprof分析,在hashtable.keyCompare上花費了較多時間,雖然已經經過strings.Compare來加速orz;相比下golang內置的Map使用了unsafe.Pointerpointer to unsafe.ArbitraryType (int)做爲key,並針對不一樣的key類型來設計哈希算法。
  2. bucket(數據結構)的使用:在個人實現中只使用了2個bucket,經常使用的只有1個bucket,在定位上:hash後的結果使用取模的方法定位;相比之下,map採用了多個bucket,每一個bucket只存放8個元素,在定位上:hash後用low bits & bucketMask定位buckets和high 8bits找到對應的位置,效率更高;

總結

  • 實現一個hashtable並不難,難點在於:hash算法的選用(均勻分佈);如何下降hash衝突(rehash時機);
  • 當完成上述工做的時候,我再去閱讀go內置的map的實現會容易不少orz,僅類似部分;map比上述hashtable的實現要複雜得多;
  • 文中全部代碼均在 https://github.com/yeqown/has...
  • 若是隻是想要了解原理,參考資料中的推薦文檔足以;
  • 已經實現的版本還能夠繼續優化,並考慮併發安全問題~

參考資料

相關文章
相關標籤/搜索