一文講透一致性哈希的原理和實現(xiàn)

為什么需要一致性哈希

首先介紹一下什么是哈希

Hash塌忽,一般翻譯做散列,或音譯為哈希,是把任意長度的輸入(又叫做預(yù)映射pre-image)通過散列算法變換成固定長度的輸出架曹,該輸出就是散列值取逾。這種轉(zhuǎn)換是一種壓縮映射说莫,也就是得运,散列值的空間通常遠小于輸入的空間姑蓝,不同的輸入可能會散列成相同的輸出鹅心,所以不可能從散列值來確定唯一的輸入值。簡單的說就是一種將任意長度的消息壓縮到某一固定長度的消息摘要的函數(shù)纺荧。

在分布式緩存服務(wù)中旭愧,經(jīng)常需要對服務(wù)進行節(jié)點添加和刪除操作,我們希望的是節(jié)點添加和刪除操作盡量減少數(shù)據(jù)-節(jié)點之間的映射關(guān)系更新宙暇。

假如我們使用的是哈希取模( hash(key)%nodes ) 算法作為路由策略:

image

哈希取模的缺點在于如果有節(jié)點的刪除和添加操作榕茧,對 hash(key)%nodes 結(jié)果影響范圍太大了,造成大量的請求無法命中從而導(dǎo)致緩存數(shù)據(jù)被重新加載客给。

基于上面的缺點提出了一種新的算法:一致性哈希用押。一致性哈希可以實現(xiàn)節(jié)點刪除和添加只會影響一小部分數(shù)據(jù)的映射關(guān)系靶剑,由于這個特性哈希算法也常常用于各種均衡器中實現(xiàn)系統(tǒng)流量的平滑遷移蜻拨。

一致性哈希工作原理

image

首先對節(jié)點進行哈希計算,哈希值通常在 2^32-1 范圍內(nèi)桩引。然后將 2^32-1 這個區(qū)間首尾連接抽象成一個環(huán)并將節(jié)點的哈希值映射到環(huán)上缎讼,當(dāng)我們要查詢 key 的目標(biāo)節(jié)點時,同樣的我們對 key 進行哈希計算坑匠,然后順時針查找到的第一個節(jié)點就是目標(biāo)節(jié)點血崭。

根據(jù)原理我們分析一下節(jié)點添加和刪除對數(shù)據(jù)范圍的影響。

  1. 節(jié)點添加

    image

    只會影響新增節(jié)點與前一個節(jié)點(新增節(jié)點逆時針查找的第一個節(jié)點)之間的數(shù)據(jù)。

  2. 節(jié)點刪除

    image

    只會影響刪除節(jié)點與前一個節(jié)點(刪除節(jié)點逆時針查找的第一個節(jié)點)之間的數(shù)據(jù)夹纫。

這樣就完了嗎咽瓷?還沒有,試想一下假如環(huán)上的節(jié)點數(shù)量非常少舰讹,那么非常有可能造成數(shù)據(jù)分布不平衡茅姜,本質(zhì)上是環(huán)上的區(qū)間分布粒度太粗。

怎么解決呢月匣?不是粒度太粗嗎钻洒?那就加入更多的節(jié)點,這就引出了一致性哈希的虛擬節(jié)點概念锄开,虛擬節(jié)點的作用在于讓環(huán)上的節(jié)點區(qū)間分布粒度變細素标。

一個真實節(jié)點對應(yīng)多個虛擬節(jié)點,將虛擬節(jié)點的哈希值映射到環(huán)上萍悴,查詢 key 的目標(biāo)節(jié)點我們先查詢虛擬節(jié)點再找到真實節(jié)點即可糯钙。

image

代碼實現(xiàn)

基于上面的一致性哈希原理,我們可以提煉出一致性哈希的核心功能:

  1. 添加節(jié)點
  2. 刪除節(jié)點
  3. 查詢節(jié)點

我們來定義一下接口:

ConsistentHash interface {
    Add(node Node)
    Get(key Node) Node
    Remove(node Node)
}

現(xiàn)實中不同的節(jié)點服務(wù)能力因硬件差異可能各不相同退腥,于是我們希望在添加節(jié)點時可以指定權(quán)重。反應(yīng)到一致性哈希當(dāng)中所謂的權(quán)重意思就是我們希望 key 的目標(biāo)節(jié)點命中概率比例再榄,一個真實節(jié)點的虛擬節(jié)點數(shù)量多則意味著被命中概率高狡刘。

在接口定義中我們可以增加兩個方法:支持指定虛擬節(jié)點數(shù)量添加節(jié)點,支持按權(quán)重添加困鸥。本質(zhì)上最終都會反應(yīng)到虛擬節(jié)點的數(shù)量不同導(dǎo)致概率分布差異嗅蔬。

指定權(quán)重時:實際虛擬節(jié)點數(shù)量 = 配置的虛擬節(jié)點 * weight/100

ConsistentHash interface {
    Add(node Node)
    AddWithReplicas(node Node, replicas int)
    AddWithWeight(node Node, weight int)
    Get(key Node) Node
    Remove(node Node)
}

接下來考慮幾個工程實現(xiàn)的問題:

  1. 虛擬節(jié)點如何存儲?

    很簡單疾就,用列表(切片)存儲即可澜术。

  2. 虛擬節(jié)點 - 真實節(jié)點關(guān)系存儲

    map 即可。

  3. 順時針查詢第一個虛擬節(jié)點如何實現(xiàn)

    讓虛擬節(jié)點列表保持有序猬腰,二分查找第一個比 hash(key) 大的 index鸟废,list[index] 即可。

  4. 虛擬節(jié)點哈希時會有很小的概率出現(xiàn)沖突姑荷,如何處理呢盒延?

    沖突時意味著這一個虛擬節(jié)點會對應(yīng)多個真實節(jié)點,map 中 value 存儲真實節(jié)點數(shù)組鼠冕,查詢 key 的目標(biāo)節(jié)點時對 nodes 取模添寺。

  5. 如何生成虛擬節(jié)點

    基于虛擬節(jié)點數(shù)量配置 replicas,循環(huán) replicas 次依次追加 i 字節(jié) 進行哈希計算懈费。

go-zero 源碼解析

core/hash/consistenthash.go

詳細注釋可查看:https://github.com/Ouyangan/go-zero-annotation/blob/84ae351e4ebce558e082d54f4605acf750f5d285/core/hash/consistenthash.go

花了一天時間把 go-zero 源碼一致性哈希源碼看完计露,寫的真好啊,各種細節(jié)都考慮到了。

go-zero 使用的哈希函數(shù)是 MurmurHash3票罐,GitHub:https://github.com/spaolacci/murmur3

go-zero 并沒有進行接口定義叉趣,沒啥關(guān)系,直接看結(jié)構(gòu)體 ConsistentHash

// Func defines the hash method.
// 哈希函數(shù)
Func func(data []byte) uint64

// A ConsistentHash is a ring hash implementation.
// 一致性哈希
ConsistentHash struct {
    // 哈希函數(shù)
    hashFunc Func
    // 確定node的虛擬節(jié)點數(shù)量
    replicas int
    // 虛擬節(jié)點列表
    keys []uint64
    // 虛擬節(jié)點到物理節(jié)點的映射
    ring map[uint64][]interface{}
    // 物理節(jié)點映射胶坠,快速判斷是否存在node
    nodes map[string]lang.PlaceholderType
    // 讀寫鎖
    lock sync.RWMutex
}

key 和虛擬節(jié)點的哈希計算

在進行哈希前要先將 key 轉(zhuǎn)換成 string

// 可以理解為確定node字符串值的序列化方法
// 在遇到哈希沖突時需要重新對key進行哈希計算
// 為了減少沖突的概率前面追加了一個質(zhì)數(shù)prime來減小沖突的概率
func innerRepr(v interface{}) string {
   return fmt.Sprintf("%d:%v", prime, v)
}

// 可以理解為確定node字符串值的序列化方法
// 如果讓node強制實現(xiàn)String()會不會更好一些君账?
func repr(node interface{}) string {
   return mapping.Repr(node)
}

這里 mapping.Repr 里會判斷 fmt.Stringer 接口,如果符合沈善,就會調(diào)用其 String 方法乡数。go-zero 代碼如下:

// Repr returns the string representation of v.
func Repr(v interface{}) string {
    if v == nil {
        return ""
    }

    // if func (v *Type) String() string, we can't use Elem()
    switch vt := v.(type) {
    case fmt.Stringer:
        return vt.String()
    }

    val := reflect.ValueOf(v)
    if val.Kind() == reflect.Ptr && !val.IsNil() {
        val = val.Elem()
    }

    return reprOfValue(val)
}

添加節(jié)點

最終調(diào)用的是 指定虛擬節(jié)點添加節(jié)點方法

// 擴容操作,增加物理節(jié)點
func (h *ConsistentHash) Add(node interface{}) {
    h.AddWithReplicas(node, h.replicas)
}

添加節(jié)點 - 指定權(quán)重

最終調(diào)用的同樣是 指定虛擬節(jié)點添加節(jié)點方法

// 按權(quán)重添加節(jié)點
// 通過權(quán)重來計算方法因子闻牡,最終控制虛擬節(jié)點的數(shù)量
// 權(quán)重越高净赴,虛擬節(jié)點數(shù)量越多
func (h *ConsistentHash) AddWithWeight(node interface{}, weight int) {
    replicas := h.replicas * weight / TopWeight
    h.AddWithReplicas(node, replicas)
}

添加節(jié)點 - 指定虛擬節(jié)點數(shù)量

// 擴容操作,增加物理節(jié)點
func (h *ConsistentHash) AddWithReplicas(node interface{}, replicas int) {
    // 支持可重復(fù)添加
    // 先執(zhí)行刪除操作
    h.Remove(node)
    // 不能超過放大因子上限
    if replicas > h.replicas {
        replicas = h.replicas
    }
    // node key
    nodeRepr := repr(node)
    h.lock.Lock()
    defer h.lock.Unlock()
    // 添加node map映射
    h.addNode(nodeRepr)
    for i := 0; i < replicas; i++ {
        // 創(chuàng)建虛擬節(jié)點
        hash := h.hashFunc([]byte(nodeRepr + strconv.Itoa(i)))
        // 添加虛擬節(jié)點
        h.keys = append(h.keys, hash)
        // 映射虛擬節(jié)點-真實節(jié)點
        // 注意hashFunc可能會出現(xiàn)哈希沖突罩润,所以采用的是追加操作
        // 虛擬節(jié)點-真實節(jié)點的映射對應(yīng)的其實是個數(shù)組
        // 一個虛擬節(jié)點可能對應(yīng)多個真實節(jié)點玖翅,當(dāng)然概率非常小
        h.ring[hash] = append(h.ring[hash], node)
    }
    // 排序
    // 后面會使用二分查找虛擬節(jié)點
    sort.Slice(h.keys, func(i, j int) bool {
        return h.keys[i] < h.keys[j]
    })
}

刪除節(jié)點

// 刪除物理節(jié)點
func (h *ConsistentHash) Remove(node interface{}) {
    // 節(jié)點的string
    nodeRepr := repr(node)
    // 并發(fā)安全
    h.lock.Lock()
    defer h.lock.Unlock()
    // 節(jié)點不存在
    if !h.containsNode(nodeRepr) {
        return
    }
    // 移除虛擬節(jié)點映射
    for i := 0; i < h.replicas; i++ {
        // 計算哈希值
        hash := h.hashFunc([]byte(nodeRepr + strconv.Itoa(i)))
        // 二分查找到第一個虛擬節(jié)點
        index := sort.Search(len(h.keys), func(i int) bool {
            return h.keys[i] >= hash
        })
        // 切片刪除對應(yīng)的元素
        if index < len(h.keys) && h.keys[index] == hash {
            // 定位到切片index之前的元素
            // 將index之后的元素(index+1)前移覆蓋index
            h.keys = append(h.keys[:index], h.keys[index+1:]...)
        }
        // 虛擬節(jié)點刪除映射
        h.removeRingNode(hash, nodeRepr)
    }
    // 刪除真實節(jié)點
    h.removeNode(nodeRepr)
}

// 刪除虛擬-真實節(jié)點映射關(guān)系
// hash - 虛擬節(jié)點
// nodeRepr - 真實節(jié)點
func (h *ConsistentHash) removeRingNode(hash uint64, nodeRepr string) {
    // map使用時應(yīng)該校驗一下
    if nodes, ok := h.ring[hash]; ok {
        // 新建一個空的切片,容量與nodes保持一致
        newNodes := nodes[:0]
        // 遍歷nodes
        for _, x := range nodes {
            // 如果序列化值不相同,x是其他節(jié)點
            // 不能刪除
            if repr(x) != nodeRepr {
                newNodes = append(newNodes, x)
            }
        }
        // 剩余節(jié)點不為空則重新綁定映射關(guān)系
        if len(newNodes) > 0 {
            h.ring[hash] = newNodes
        } else {
            // 否則刪除即可
            delete(h.ring, hash)
        }
    }
}

查詢節(jié)點

// 根據(jù)v順時針找到最近的虛擬節(jié)點
// 再通過虛擬節(jié)點映射找到真實節(jié)點
func (h *ConsistentHash) Get(v interface{}) (interface{}, bool) {
    h.lock.RLock()
    defer h.lock.RUnlock()
    // 當(dāng)前沒有物理節(jié)點
    if len(h.ring) == 0 {
        return nil, false
    }
    // 計算哈希值
    hash := h.hashFunc([]byte(repr(v)))
    // 二分查找
    // 因為每次添加節(jié)點后虛擬節(jié)點都會重新排序
    // 所以查詢到的第一個節(jié)點就是我們的目標(biāo)節(jié)點
    // 取余則可以實現(xiàn)環(huán)形列表效果割以,順時針查找節(jié)點
    index := sort.Search(len(h.keys), func(i int) bool {
        return h.keys[i] >= hash
    }) % len(h.keys)
    // 虛擬節(jié)點->物理節(jié)點映射
    nodes := h.ring[h.keys[index]]
    switch len(nodes) {
    // 不存在真實節(jié)點
    case 0:
        return nil, false
    // 只有一個真實節(jié)點金度,直接返回
    case 1:
        return nodes[0], true
    // 存在多個真實節(jié)點意味這出現(xiàn)哈希沖突
    default:
        // 此時我們對v重新進行哈希計算
        // 對nodes長度取余得到一個新的index
        innerIndex := h.hashFunc([]byte(innerRepr(v)))
        pos := int(innerIndex % uint64(len(nodes)))
        return nodes[pos], true
    }
}

項目地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支持我們!

微信交流群

關(guān)注『微服務(wù)實踐』公眾號并點擊 交流群 獲取社區(qū)群二維碼严沥。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末猜极,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子消玄,更是在濱河造成了極大的恐慌跟伏,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,539評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件翩瓜,死亡現(xiàn)場離奇詭異受扳,居然都是意外死亡,警方通過查閱死者的電腦和手機兔跌,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評論 3 396
  • 文/潘曉璐 我一進店門勘高,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人坟桅,你說我怎么就攤上這事相满。” “怎么了桦卒?”我有些...
    開封第一講書人閱讀 165,871評論 0 356
  • 文/不壞的土叔 我叫張陵立美,是天一觀的道長。 經(jīng)常有香客問我方灾,道長建蹄,這世上最難降的妖魔是什么碌更? 我笑而不...
    開封第一講書人閱讀 58,963評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮洞慎,結(jié)果婚禮上痛单,老公的妹妹穿的比我還像新娘。我一直安慰自己劲腿,他們只是感情好旭绒,可當(dāng)我...
    茶點故事閱讀 67,984評論 6 393
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著焦人,像睡著了一般挥吵。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上花椭,一...
    開封第一講書人閱讀 51,763評論 1 307
  • 那天忽匈,我揣著相機與錄音,去河邊找鬼矿辽。 笑死丹允,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的袋倔。 我是一名探鬼主播雕蔽,決...
    沈念sama閱讀 40,468評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼宾娜!你這毒婦竟也來了批狐?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤碳默,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后缘眶,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體嘱根,經(jīng)...
    沈念sama閱讀 45,850評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,002評論 3 338
  • 正文 我和宋清朗相戀三年巷懈,在試婚紗的時候發(fā)現(xiàn)自己被綠了该抒。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,144評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡顶燕,死狀恐怖凑保,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情涌攻,我是刑警寧澤欧引,帶...
    沈念sama閱讀 35,823評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站恳谎,受9級特大地震影響芝此,放射性物質(zhì)發(fā)生泄漏憋肖。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,483評論 3 331
  • 文/蒙蒙 一婚苹、第九天 我趴在偏房一處隱蔽的房頂上張望岸更。 院中可真熱鬧,春花似錦膊升、人聲如沸怎炊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽评肆。三九已至,卻和暖如春责循,著一層夾襖步出監(jiān)牢的瞬間糟港,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評論 1 272
  • 我被黑心中介騙來泰國打工院仿, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留秸抚,地道東北人。 一個月前我還...
    沈念sama閱讀 48,415評論 3 373
  • 正文 我出身青樓歹垫,卻偏偏與公主長得像剥汤,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子排惨,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,092評論 2 355

推薦閱讀更多精彩內(nèi)容