9.2 分散式雜湊與一致性雜湊#
概念說明#
把 N 筆鍵值資料分散到 S 台伺服器,最直覺的做法是 hash(key) mod S。問題在於節點增減:S 從 10 變 11 時,幾乎所有鍵的落點都改變,需要搬移 ~N 筆資料——叢集伸縮瞬間變成全量重組。
一致性雜湊(consistent hashing,1997)把鍵與節點都映射到同一個環狀空間(如 0 ~ 2^32):
- 每個節點依其位址(或多個虛擬節點)雜湊到環上。
- 鍵的擁有者 = 環上順時針第一個遇到的節點。
- 新增節點時,只有「落在它與前一節點之間」的鍵需要搬移——平均只動 N/S 筆(1/S 的資料)。
純一致性雜湊的缺點是負載不均:節點少時,各段弧長差異可能達數倍。解法是虛擬節點(vnode):每台實體機器掛 100~1000 個環上點,負載標準差降到個位數百分比。
有界載入(bounded loads)是另一個加強:給定環分配後,若某節點負載超過平均 ×(1+ε),就把超出部分順時針讓給下一節點,保證負載偏差有上界。
演算法步驟#
ring = sorted(各虛擬節點的雜湊值),附節點標籤。- 查詢鍵 k:
h = hash(k),在 ring 中二分搜尋第一個 ≥ h 的點;環尾回繞到開頭。 - 新增節點:計算其 vnode 雜湊值插入 ring;搬移範圍 = 新點到「其順時針前一點」之間的鍵。
- 移除節點:從 ring 刪除其 vnode;其鍵由順時針下一節點接管。
Python 範例#
import bisect, hashlib
def h(s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
class ConsistentHashRing:
def __init__(self, vnodes=100):
self.ring, self.nodes, self.vnodes = [], {}, vnodes
def add_node(self, name):
for i in range(self.vnodes):
key = h(f"{name}#v{i}")
bisect.insort(self.ring, key)
self.nodes[key] = name
def remove_node(self, name):
dead = [k for k, v in self.nodes.items() if v == name]
for k in dead:
self.nodes.pop(k)
self.ring.remove(k)
def get_node(self, key):
if not self.ring: return None
pos = bisect.bisect_left(self.ring, h(key)) % len(self.ring)
return self.nodes[self.ring[pos]]
ring = ConsistentHashRing(vnodes=50)
for n in ["A", "B", "C"]:
ring.add_node(n)
keys = [f"key{i}" for i in range(3000)]
before = [ring.get_node(k) for k in keys]
from collections import Counter
print("3 節點負載:", dict(Counter(before)))
ring.add_node("D") # 擴容
after = [ring.get_node(k) for k in keys]
moved = sum(b != a for b, a in zip(before, after))
print("加入節點 D 後搬移比例:", f"{moved/len(keys):.1%}",
"(naive mod 將搬移 ~75%)")
print("4 節點負載:", dict(Counter(after)))
複雜度與 I/O 成本分析#
- 查詢定位:O(log V)(V 為環上虛擬節點總數,記憶體二分搜尋,0 次磁碟 I/O)。
- 擴縮容搬移:N/S 筆(理論),虛擬節點使其更平滑;對比 naive mod 的 ~N 筆。
- 副本放置:沿環順時針再取 2~3 個「不同機房」的節點,同時滿足負載與容錯。
- 與 9.3 節 erasure coding 搭配:環決定「誰負責」,編碼決定「存什麼」。
練習#
- 把 vnodes 從 50 降到 5,觀察負載標準差與搬移比例的變化。
- 證明或說明:加入一個節點時,需搬移的鍵期望數為 N/(S+1)。
- 實作 bounded-loads:負載超過平均 1.25 倍時順時針讓渡,重測負載分布。