(跳躍)一致性哈希及其在Greenplum中的應用

前言

一致性哈希(consistent hashing)是分佈式系統中非常重要的算法,在平滑擴縮容、動態負載均衡等方向有大量應用。相對於傳統的線性(取模)哈希算法,一致性哈希可以保證在分佈式哈希表中的桶數量發生變化時,受到影響需要重新映射的key儘量少。本文先簡要複習下經典的割環一致性哈希方案,然後介紹它的變種——跳躍一致性哈希(jump consistent hash)。

割環一致性哈希

一致性哈希的概念最初在1997年由David Karger等大佬提出,原始論文見<<Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web>>,起初是爲了解決網絡中的熱點問題,後來發展成分佈式系統中通用的算法。爲了與此後出現的其他一致性哈希算法相區別,一般將這個經典方法稱爲“割環法”。該算法能夠滿足論文中提出的兩大目標,即平衡性(balance)和單調性(monotonicity)。

顧名思義,割環法將整個哈希空間組織成一個首尾相接的圓環,一般設爲[0, 232 - 1]。以分佈式K-V存儲爲例,哈希桶即爲存儲節點。將節點N的編號或IP等按哈希函數hash(N)映射在環上,再將數據的key按同樣的哈希函數hash(k)映射在環上,數據就會存儲在環上以順時針方向遍歷找到的第一個節點。當節點擴容或縮容時,仍然按照順時針原則,將受到影響的區間內的數據重新分佈到相鄰的節點上去,達到增量更新的目的,即滿足單調性。以下3張圖能夠簡單地說明。

雖然哈希函數的結果是均勻的,但節點映射在環上可能不均勻,節點數越少,數據傾斜的可能性就越大。解決此問題的方法是將物理節點虛擬成多個影子節點,數據經過哈希後按順時針原則落到影子節點指向的物理節點上。如果我們想要人爲干預各節點上數據量的權重,還可以指定不同的影子節點數量。如下圖所示,影子節點數量爲3:2:2:1。

虛擬節點擴縮容時的數據遷移方法與僅採用物理節點相同,因此調整權重值也會觸發數據遷移。

對於有N個桶和K個鍵的一致性哈希方案,其時間複雜度是:

  • 添加、刪除節點——O(K / N + logN);
  • 添加、刪除key——O(logN)。

其中,O(K / N)是數據重分佈操作的平均代價,O(log N)則是在環上進行二分查找定位哈希桶的代價。

最後有一個小問題:節點擴縮容以及節點宕機時如何保證系統仍然可用呢?有兩種直接的思路:

  • 中繼——如果在某個節點上查不到所需的數據,就把請求轉發給該節點的順時針方向下一個節點進行處理。
  • 雙寫——每次寫入數據時,都另外寫一份到目標節點的順時針方向下一個節點。

割環法已經能夠滿足一般分佈式系統中的多數需求,Cassandra、Memcached等著名的存儲系統都用到了它(注意Redis Cluster並沒有)。下面介紹思想更加精妙,效率也更高的跳躍一致性哈希(jump consistent hash)方法。

跳躍一致性哈希

這個算法比較年輕,在2014年由Google的大佬John Lamping和Eric Veach提出,原始論文見<<A Fast, Minimal Memory, Consistent Hash Algorithm>>。它的實現非常簡潔,僅有5行代碼,如下。

int32_t JumpConsistentHash(uint64_t key, int32_t num_buckets) {
  int64_t b = ­1, j = 0;
  while (j < num_buckets) {
    b = j;
    key = key * 2862933555777941757ULL + 1;
    j = (b + 1) * (double(1LL << 31) / double((key >> 33) + 1));
  }
  return b;
}

看官可能還無法理解爲什麼能這樣實現,接下來重走一遍論文的推導思路。

假設最終要求的滿足平衡性和單調性的哈希函數是ch(k, n)(k爲數據的鍵,n爲哈希桶的數量),有如下簡單的遞推關係:

  • 當n = 1時,所有key都要映射到同一個桶中,即ch(k, 1) = 0;
  • 當n = 2時,爲保證均勻性,需有K / 2個key分別映射到兩個桶中(K是key的總數量),故K / 2個key需要重新映射;
  • ......
  • 當桶數量由n變爲n + 1時,有K / (n + 1)個key需要重新映射。

那麼該如何決定哪些key被重新映射到新的桶中呢?答案是採用線性同餘法(LCG)生成的僞隨機數決定。關於線性同餘法,可參見筆者之前寫過的這篇文章,上文中的magic number 2862933555777941757就是線性同餘法的乘數a。

以k作爲種子生成一個僞隨機數序列,可以保證對於確定的k,ch(k, n)的結果也是確定的,進而使用條件rand < 1 / (j + 1)即可保證哈希桶由j個變爲j + 1個時,有1 / (j + 1)比例的數據會重新映射。

此時ch()函數的邏輯如下,時間複雜度顯然爲O(n)。

int ch(int key, int num_buckets) {
  random.seed(key);
  int b = 0; // This will track ch(key, j+1).
  for (int j = 1; j < num_buckets; j++) {
    if (random.next() < 1.0 / (j + 1)) b = j;
  }
  return b;
}

這個複雜度比割環法還要高,如何優化?容易想到,rand < 1 / (j + 1)的概率肯定是相對小的,也就是說隨着j的增大,發生重分佈的key的比例越來越小,j可以不必逐次自增,而是跳躍前進,這也就是算法名稱中"jump"一詞的由來。

觀察上面的代碼,b表示k最後一跳的目的哈希桶的編號,即滿足條件:

ch(k, b + 1) ≠ ch(k, b) && ch(k, b + 1) = b

假設k連續不跳變,直到增加到j + 1個桶才發生跳變,可知此概率爲:

[(b + 1)/(b + 2)] * [(b + 2)/(b + 3)] * ... * [(j - 1)/j] = (b + 1) / j

或者表示爲:

P[j ≥ i] = P[ch(k, i) = ch(k, b + 1)] = (b + 1) / i

圖示如下。

那麼,j最多可以直接跳到哪裏纔不至於漏掉原有的循環過程呢?容易得知,要滿足rand < (b + 1) / j,需要j < (b + 1) / rand,將其向下取整即可。改進後的ch()函數如下。

int ch(int k, int n) {
  random.seed(k);
  int b = -1, j = 0;
  while (j < n) {
    b = j;
    r = random.next();
    j = floor((b+1) / r);
  }
  return b;
}

將random替換爲具體的LCG,就是本節開頭的算法了。

分析時間複雜度:對於任意一個k,在哈希桶數從1增加到n的過程中,發生跳躍的期望次數是1 / 2 + ... + 1 / i + ... + 1 / n。根據歐拉常數的定義,調和級數與自然對數的差值的極限會收斂到一個小數,因此跳躍一致性哈希算法的複雜度是O(ln n),比割環法更優。

根據論文給出的實驗數據,跳躍一致性哈希產生的分佈的標準差遠遠比割環法小,也就是非常均勻。

隨着桶數量的增加,跳躍一致性哈希算法的執行時間增長也不明顯。

另外,它不需要額外的數據結構,內存佔用極小(即論文標題中所說的minimal memory)。

但是,它相對於割環法而言有個非常大的缺點,即只能在哈希桶序列的尾部添加和刪除桶,而不能在中間增刪。顯而易見,如果在中間增刪桶,由於桶的標號是按自然順序來的,因此會導致後方所有桶的標號發生變化,不再滿足一致性哈希的基本性質。

仍然考慮節點擴縮容以及節點宕機時如何保證系統仍然可用的問題。

  • 中繼——如果在尾部的哈希桶j + 1中查不到所需的數據,就把請求轉發給ch(k, j)桶,即它的上一跳節點。
  • 雙寫——每次寫入數據時,如果寫入的是尾部的哈希桶j + 1,就另外寫一份到ch(k, j);如果寫入的是非尾部的哈希桶i,就另外寫一份到i + 1。這樣,不管是哪個節點失敗,數據都不會丟失。

Greenplum中的應用

Greenplum提供了一個爲集羣擴容的工具gpexpand。在GP v5中,執行gpexpand時需要將所有哈希分佈改爲隨機分佈,按照新的集羣規模重新根據hash key計算哈希值,再將數據重新均衡到各個segment節點上,相當於進行了一次完全的shuffle,如下圖所示。

這種方式的缺點顯而易見:集羣在擴容期間處於不可用狀態,數據交換量巨大。並且在數據由隨機分佈轉爲新的哈希分佈之前,無法利用數據的本地性信息做查詢優化,拖累性能。

在GP v6中,通過將跳躍一致性哈希引入gpexpand,實現了完全在線、高性能的集羣擴容方式。如下圖所示,將集羣由3節點擴容到4節點,只有1/4的數據需要重分佈。

GP v6的跳躍一致性哈希實現與Google原版完全相同,可參見這裏

另外,如何保證那些沒有重分佈完畢的表被正確地查詢呢?GP v6在catalog表gp_distribution_policy里加入了一個新的字段numsegments,表示一張表的數據分佈在前numsegments個節點上。因此,就算擴容的過程中有事務正在運行,只要numsegments沒有改變,就仍然只在原有節點上執行查詢。

最後,可以通過全局配置gp_use_legacy_hashops設定是否改回舊版的取模哈希方式,默認當然爲false

The End

最近天氣有點冷,民那注意保暖。

晚安晚安。

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