4.3 雜湊索引的實務議題#
概念說明#
理論上的雜湊索引「一次 I/O 查到」,實務上有許多折扣因素,設計時必須逐項檢視:
- 範圍查詢無效:雜湊打散了鍵的順序,
WHERE age BETWEEN 20 AND 30必須逐桶探測或全表掃描。這是 B+tree 在資料庫中勝出的主因——雜湊索引只適合等值查詢(=、IN)與唯一鍵查找(如主鍵、雜湊 join 的建表階段)。 - 雜湊函數的品質:磁碟雜湊常用乘法雜湊、MurmurHash、或資料庫內建的 64-bit 雜湊。若鍵有結構性偏斜(如遞增序號、日期),
mod型雜湊會產生熱點,必須用「攪拌(avalanche)」良好的函數。 - 溢位鏈管理:靜態雜湊依賴溢位頁,鏈過長時查詢退化。實務上要監控平均鏈長,超過門檻就重建(rehash to new table),重建成本 O(N) 次 I/O,通常在離峰時段執行。
- 桶大小與分頁對齊:一桶一頁最簡單,但桶利用率常僅 60~70%;可讓一桶占多頁或一頁多桶,在空間與 I/O 之間權衡。
- 與緩衝池互動:目錄與熱門桶若能常駐緩衝池,等效 I/O 可降為 1 甚至 0;冷熱差異大的工作負載應把熱桶集中配置。
- 並發控制:分裂時的鎖順序(先桶後目錄)、latch crabbing 等議題與 B-tree 類似,讀多寫少時可用讀寫鎖。
Python 範例:監控與重建#
import random
class StaticHash:
def __init__(self, m):
self.m = m
self.buckets = [[] for _ in range(m)]
self.io = 0
def insert(self, key):
self.io += 1
self.buckets[key % self.m].append(key)
def stats(self):
lens = [len(b) for b in self.buckets]
avg = sum(lens) / len(lens)
var = sum((l - avg) ** 2 for l in lens) / len(lens)
return avg, var ** 0.5, max(lens) # 平均、標準差、最長鏈
def rehash(self, new_m):
self.io += sum(1 for b in self.buckets if b) # 讀每個非空桶
self.m = new_m
flat = [k for b in self.buckets for k in b]
self.io += len(flat) # 寫回
self.buckets = [[] for _ in range(new_m)]
for k in flat:
self.buckets[k % new_m].append(k)
random.seed(3)
h = StaticHash(m=32)
for _ in range(500):
h.insert(random.randrange(10**6))
print("M=32 統計(平均, 標準差, 最長):", h.stats())
h.rehash(128)
print("M=128 統計:", h.stats())
# 展示偏斜鍵的問題:遞增鍵 mod M 完全均勻,但前綴鍵會集中
h2 = StaticHash(m=32)
for i in range(500):
h2.insert(i * 32) # 全部落在桶 0
print("偏斜鍵最長鏈:", h2.stats()[2], "→ 必須換雜湊函數")
複雜度與 I/O 成本分析#
- 等值查詢:理想 1 次 I/O;含溢位鏈 L 頁時為 L+1 次。
- 重建:O(N) 次 I/O,但把之後的每次查詢降回 1 次 I/O,屬於典型的攤 sweet spot 決策。
- 經驗法則:負載因子(記錄數 / 桶容量總和)超過 0.75~0.85 就該規劃重建或改用可成長結構。
練習#
- 用範例統計 M=64、記錄 1000 筆時的桶長分布,估計查詢的期望 I/O 次數。
- 實作「平均鏈長 > 2 自動 rehash」的策略,比較重建前後的累計 I/O。
- 說明什麼樣的工作負載該選雜湊索引、什麼情況必須退讓給 B+tree,各舉一例。