一致性Hash的原理與實現

Philosophy發表於2022-04-10

應用場景

在瞭解一致性Hash之前,我們先了解一下一致性Hash適用於什麼場景,能解決什麼問題?這裡先放一下我自己認為適用的場景。一致性Hash適用於伺服器動態擴充套件且需要負載均衡的場景

試想以下場景,某一天,公司的業務不斷髮展壯大,現有的資料庫伺服器無法支撐那麼大的資料量,我們該怎麼辦呢?我們會想到把使用者請求分散,對資料庫伺服器做叢集,把使用者請求分別路由到不同的資料庫伺服器上, 但是這樣會帶來一個問題,我們如何精確的知道某一個使用者請求的資料在哪一臺具體伺服器上呢?

解決方案

方案一

A同學講,使用者發來一個請求,對所有的資料庫伺服器做查詢,如果能查詢到結果,就返回,否則就是沒有。這樣是可行,但是這樣做的話,一個請求同樣會請求N臺資料庫伺服器,我們做資料庫叢集也就沒有意義了,所以是不可取的。

方案二

B同學講,我們可以根據請求的資料引數做Hash運算,然後利用Hash值對資料庫伺服器的個數求餘,這樣就能知道使用者的資料在哪一臺伺服器上了,然後在做具體的操作就可以了。

顯然,B同學的思路很正確,但是我們還要考慮一些其他的問題(動態擴充套件性),假如某一時刻使用者請求量非常大,導致其中的某一臺資料庫伺服器當機了,這樣Hash值對資料庫伺服器的數量求餘結果明顯就是不正確的了。或者某一天,我們需要對增加該叢集伺服器的數量,結果也是不正確的,就需要對所有的資料做遷移。

方案三

C同學則是在B同學的基礎上,考慮到我們是不是可能把所有的伺服器節點放到一個足夠大的環上,如圖一,當請求到達之後,我們僅僅找到該節點之後的一個節點。req1會到Node1上,req2會到Node2上,req3會到Node3上。假如Node3下線了,那麼所有路由到Node3的請求req3都會到Node1上,這樣只會有原來請求Node3請求會受到影響,而其他的節點並不會因此受到影響。

 

 

圖一

 

那麼假設我們要增加一個節點呢?我們希望的情況肯定是下面這樣,如圖二,但是事實往往不是這樣,事實往往是如圖三這樣的,這就是Hash環的偏斜問題。這樣就會導致的Node3的請求量要等比其他三臺伺服器請求量之和還要大,嚴重的負載不均衡,那麼我們該如何解決這樣問題呢?我們可以利用虛擬節點的概念,也就是每一個節點在這一個環上可以表現為多個節點,但是真正處理請求的還是這一個節點。比如圖四那樣,但是這樣並不能做到真正意義上的完全平衡,只能說盡量平均分不到不同的節點上,而虛擬節點的數量越多,平衡的概率就越大。

 

 

 

圖二

 

 

 

圖三

 

 

 

圖四

 

性質

 

上面我們已經說了一致性Hash的應用場景以及它可以解決什麼問題,下面我們就來總結一下一致性Hash性質,原理。

  分散式系統每個節點都有可能失效,並且很可能會有新的節點增加進來,那麼如何保證當系統的節點數目發生變化時仍然能夠對外提供良好的服務,這是分散式系統需要考慮的一個重點問題。在分散式系統的設計時,我們就必須要考慮動態性擴充套件的問題,這樣才不至於某一臺服務當機會影響整個系統。如果不採用合適的演算法來保證一致性,那麼系統中的所有資料都可能會失效,因此一致性hash演算法就顯得尤為重要。一般情況下一致性Hash要滿足以下幾個性質。這裡可以參考https://brpc.apache.org/docs/rpc-in-depth/consistent-hashing/

  • 平衡性 (Balance) : 每個節點被選到的概率是O(1/n)。

  • 單調性 (Monotonicity) : 當新節點加入時, 不會有請求在老節點間移動, 只會從老節點移動到新節點。當有節點被刪除時,也不會影響落在別的節點上的請求。

  • 分散性 (Spread) : 當上遊的機器看到不同的下游列表時(在上線時及不穩定的網路中比較常見), 同一個請求儘量對映到少量的節點中。

  • 負載 (Load) : 當上遊的機器看到不同的下游列表的時候, 保證每臺下遊分到的請求數量儘量一致。

實現(golang)

 

  1. 資料準備工作,定義資料結構,PhysicalNode包括IP,埠和節點名稱

type UInt32Slice []uint32

func (s UInt32Slice) Len() int {
  return len(s)
}

func (s UInt32Slice) Less(i, j int) bool {
  return s[i] < s[j]
}

func (s UInt32Slice) Swap(i, j int) {
  s[i], s[j] = s[j], s[i]
}

type PhysicalNode struct {
  ip   string
  port int
  name string
}
type PhysicalNodes struct {
  nodeMap     map[uint32]PhysicalNode //key 節點的Hash值 value節點的資訊
  nodeList    UInt32Slice             //排序之後的節點的Hash值資料,方便用於二分搜尋
  virtualNums int                     //虛擬節點數目
  lock        sync.Mutex              //刪除節點時需要用的鎖
}
  1. 工具類:雜湊值的計算,這裡採用網上用的較多的Hash演算法,具體可百度

package utils

const p uint32 = 16777619

func Hash(key string) uint32 {
  var hash uint32 = 2166136261
  num := len(key)
  for idx := 0; idx < num; idx++ {
     hash = (hash ^ uint32(key[idx])) * p
  }
  hash += hash << 13
  hash ^= hash >> 7
  hash += hash << 3
  hash ^= hash >> 17
  hash += hash << 5

  if hash < 0 {
     return -hash
  }
  return hash
}
  1. 獲取節點的Hash值

func getNodeHash(node PhysicalNode, i int) uint32 {
  hash := utils.Hash(node.ip + strconv.Itoa(node.port) + node.name + "@@" + strconv.Itoa(i))
  return hash
}
  1. 新增節點

func (receiver *PhysicalNodes) AddPhysicalNodes(nodes ...PhysicalNode) {
  for _, node := range nodes {
     for i := 0; i < receiver.virtualNums; i++ {
        hash := getNodeHash(node, i)
        receiver.nodeList = append(receiver.nodeList, hash)
        receiver.nodeMap[hash] = node
    }
  }
  sort.Sort(receiver.nodeList)
}
  1. 刪除節點

func (receiver *PhysicalNodes) DeletePhysicalNodes(nodes ...PhysicalNode) {
  willDelNodeSet := make(map[uint32]bool)
  for _, node := range nodes {
    for i := 0; i < receiver.virtualNums; i++ {
        hash := getNodeHash(node, i)
        willDelNodeSet[hash] = true
        delete(receiver.nodeMap, hash)
    }
  }

  //copyOnWrite
  receiver.lock.Lock()
  newNodeList := make([]uint32, 0)
  for _, v := range receiver.nodeList {
    if !willDelNodeSet[v] {
        newNodeList = append(newNodeList, v)
    }
  }
  receiver.nodeList = newNodeList
  receiver.lock.Unlock()
  sort.Sort(receiver.nodeList)
}
  1. 獲取節點(採用二分查詢)

func (receiver *PhysicalNodes) GetPhysicalNode(key string) PhysicalNode {
  hash := utils.Hash(key)
  idx := sort.Search(len(receiver.nodeList), func(i int) bool {
     return receiver.nodeList[i] >= hash
  })
  if idx == len(receiver.nodeList) {
     idx = 0
  }
  return receiver.nodeMap[receiver.nodeList[idx]]
}
  1. 測試(這裡僅僅給出單執行緒測試版)

const total = 100000000

func TestConsistentHashSingleRoutineOne(t *testing.T) {
  physicalNodes := NewPhysicalNodes(100)
  for i := 0; i < 5; i++ {
     node := PhysicalNode{"10.22.35.6" + strconv.Itoa(i), 8080 + i, "node" + strconv.Itoa(i)}
     physicalNodes.AddPhysicalNodes(node)
  }

  str := "abcfkdhgbofdsphgoqrenvdfajboiergfrjm8ioydctrbcc5fi0-2u24ich20945ch209hc2cj-24rjc2-j4-5j224ic29h4fsg6yfdhgfk"
  n := len(str)

  statistics := make(map[string]int)

  for i := 0; i < total; i++ {
     end := 0
     for end == 0 {
        end = rand.Intn(n)
    }
     start := rand.Intn(end)
     //start := 0
     //end := i % n
     node := physicalNodes.GetPhysicalNode(str[start:end])
     statistics[node.name]++
  }

  for s, i := range statistics {
     fmt.Printf("節點%s出現了%d次,rate:%.2f%%\n", s, i, float32(i)/float32(total)*100)
  }
}

 

  1. 測試結果(上面的測試程式碼寫的不是特別的嚴謹,僅僅是做一下大概的請求分佈測試)

節點node3出現了17760919次,rate:17.76%
節點node4出現了20222723次,rate:20.22%
節點node0出現了22199979次,rate:22.20%
節點node1出現了19649432次,rate:19.65%
節點node2出現了20166947次,rate:20.17%

總結

雖說一致性Hash的原理和演算法有很多框架都有自己的實現,但是那終究不是我們的,只有我們自己掌握了他們的原理並加以練習,成為我們自己的一部分,這樣才能融入我們自己的知識體系中。

 

參考引用:https://brpc.apache.org/docs/rpc-in-depth/consistent-hashing/

完整程式碼地址:https://gitee.com/philosoxhy/leetcode/tree/master/code/hash

相關文章