一文講透一致性雜湊的原理和實現

kevwan 發表於 2021-11-30

為什麼需要一致性雜湊

首先介紹一下什麼是雜湊

Hash,一般翻譯做雜湊,或音譯為雜湊,是把任意長度的輸入(又叫做預對映pre-image)通過雜湊演算法變換成固定長度的輸出,該輸出就是雜湊值。這種轉換是一種壓縮對映,也就是,雜湊值的空間通常遠小於輸入的空間,不同的輸入可能會雜湊成相同的輸出,所以不可能從雜湊值來確定唯一的輸入值。簡單的說就是一種將任意長度的訊息壓縮到某一固定長度的訊息摘要的函式。

在分散式快取服務中,經常需要對服務進行節點新增和刪除操作,我們希望的是節點新增和刪除操作儘量減少資料-節點之間的對映關係更新。

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

一文講透一致性雜湊的原理和實現

雜湊取模的缺點在於如果有節點的刪除和新增操作,對 hash(key)%nodes 結果影響範圍太大了,造成大量的請求無法命中從而導致快取資料被重新載入。

基於上面的缺點提出了一種新的演算法:一致性雜湊。一致性雜湊可以實現節點刪除和新增只會影響一小部分資料的對映關係,由於這個特性雜湊演算法也常常用於各種均衡器中實現系統流量的平滑遷移。

一致性雜湊工作原理

一文講透一致性雜湊的原理和實現

首先對節點進行雜湊計算,雜湊值通常在 2^32-1 範圍內。然後將 2^32-1 這個區間首尾連線抽象成一個環並將節點的雜湊值對映到環上,當我們要查詢 key 的目標節點時,同樣的我們對 key 進行雜湊計算,然後順時針查詢到的第一個節點就是目標節點。

根據原理我們分析一下節點新增和刪除對資料範圍的影響。

  1. 節點新增

    一文講透一致性雜湊的原理和實現

    只會影響新增節點與前一個節點(新增節點逆時針查詢的第一個節點)之間的資料。

  2. 節點刪除

    一文講透一致性雜湊的原理和實現

    只會影響刪除節點與前一個節點(刪除節點逆時針查詢的第一個節點)之間的資料。

這樣就完了嗎?還沒有,試想一下假如環上的節點數量非常少,那麼非常有可能造成資料分佈不平衡,本質上是環上的區間分佈粒度太粗。

怎麼解決呢?不是粒度太粗嗎?那就加入更多的節點,這就引出了一致性雜湊的虛擬節點概念,虛擬節點的作用在於讓環上的節點區間分佈粒度變細。

一個真實節點對應多個虛擬節點,將虛擬節點的雜湊值對映到環上,查詢 key 的目標節點我們先查詢虛擬節點再找到真實節點即可。

一文講透一致性雜湊的原理和實現

程式碼實現

基於上面的一致性雜湊原理,我們可以提煉出一致性雜湊的核心功能:

  1. 新增節點
  2. 刪除節點
  3. 查詢節點

我們來定義一下介面:

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

現實中不同的節點服務能力因硬體差異可能各不相同,於是我們希望在新增節點時可以指定權重。反應到一致性雜湊當中所謂的權重意思就是我們希望 key 的目標節點命中概率比例,一個真實節點的虛擬節點數量多則意味著被命中概率高。

在介面定義中我們可以增加兩個方法:支援指定虛擬節點數量新增節點,支援按權重新增。本質上最終都會反應到虛擬節點的數量不同導致概率分佈差異。

指定權重時:實際虛擬節點數量 = 配置的虛擬節點 * weight/100

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

接下來考慮幾個工程實現的問題:

  1. 虛擬節點如何儲存?

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

  2. 虛擬節點 - 真實節點關係儲存

    map 即可。

  3. 順時針查詢第一個虛擬節點如何實現

    讓虛擬節點列表保持有序,二分查詢第一個比 hash(key) 的 index,list[index] 即可。

  4. 虛擬節點雜湊時會有很小的概率出現衝突,如何處理呢?

    衝突時意味著這一個虛擬節點會對應多個真實節點,map 中 value 儲存真實節點陣列,查詢 key 的目標節點時對 nodes 取模。

  5. 如何生成虛擬節點

    基於虛擬節點數量配置 replicas,迴圈 replicas 次依次追加 i 位元組 進行雜湊計算。

go-zero 原始碼解析

core/hash/consistenthash.go

詳細註釋可檢視:github.com/Ouyangan/go-zero-annota...

花了一天時間把 go-zero 原始碼一致性雜湊原始碼看完,寫的真好啊,各種細節都考慮到了。

go-zero 使用的雜湊函式是 MurmurHash3,GitHub:github.com/spaolacci/murmur3

go-zero 並沒有進行介面定義,沒啥關係,直接看結構體 ConsistentHash

// Func defines the hash method.
// 雜湊函式
Func func(data []byte) uint64

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

key 和虛擬節點的雜湊計算

在進行雜湊前主要進行先進行序列化

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

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

如果可以定義一個序列化介面,讓所有的 key 物件都實現方法會不會更清晰一點。

Node interface {
    Key() string
}

新增節點

最終呼叫的是 指定虛擬節點新增節點方法

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

新增節點 - 指定權重

最終呼叫的同樣是 指定虛擬節點新增節點方法

// 按權重新增節點
// 通過權重來計算方法因子,最終控制虛擬節點的數量
// 權重越高,虛擬節點數量越多
func (h *ConsistentHash) AddWithWeight(node interface{}, weight int) {
    replicas := h.replicas * weight / TopWeight
    h.AddWithReplicas(node, replicas)
}

新增節點 - 指定虛擬節點數量

// 擴容操作,增加物理節點
func (h *ConsistentHash) AddWithReplicas(node interface{}, replicas int) {
    // 支援可重複新增
    // 先執行刪除操作
    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++ {
        // 建立虛擬節點
        hash := h.hashFunc([]byte(nodeRepr + strconv.Itoa(i)))
        // 新增虛擬節點
        h.keys = append(h.keys, hash)
        // 對映虛擬節點-真實節點
        // 注意hashFunc可能會出現雜湊衝突,所以採用的是追加操作
        // 虛擬節點-真實節點的對映對應的其實是個陣列
        // 一個虛擬節點可能對應多個真實節點,當然概率非常小
        h.ring[hash] = append(h.ring[hash], node)
    }
    // 排序
    // 後面會使用二分查詢虛擬節點
    sort.Slice(h.keys, func(i, j int) bool {
        return h.keys[i] < h.keys[j]
    })
}

刪除節點

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

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

查詢節點

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

專案地址

github.com/zeromicro/go-zero

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

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。

本作品採用《CC 協議》,轉載必須註明作者和本文連結