想像一下,你開了一間很受歡迎的早餐店。一開始,只有一個廚師、一個收銀機、一本手寫的訂單記錄本,生意好好的,什麼都很順。隨著口碑越來越好,客人越來越多,有一天你發現那本訂單記錄本已經快寫滿了,翻找某一天的訂單也越來越費時,光是整理那本子就開始讓廚師頭痛。這個時候,「找一本更大的本子」也許是個辦法,但如果生意繼續成長,遲早還是會面臨一樣的問題。更根本的解法,是把訂單記錄分散到多本本子上管理。
這個比喻,幾乎完美地描述了大型系統在面對「資料量暴增」時所遭遇的難題。
在資料庫的世界裡,當一個資料庫表格的資料量成長到幾千萬甚至幾億筆,光是一台資料庫伺服器可能就撐不住了。磁碟空間不夠、記憶體放不下索引、查詢速度越來越慢,這些問題都會接踵而來。通常工程師會先嘗試「垂直擴展(vertical scaling)」,也就是換一台更強的機器,加更多的 RAM、更快的 SSD、更多的 CPU。這個方法簡單直接,而且通常有效。但硬體的規格是有上限的,而且價格往往不是線性成長,越頂規的機器,通常貴得不成比例。
當垂直擴展已經到了極限,或者成本實在太高的時候,工程師就必須考慮「水平擴展(horizontal scaling)」,也就是把資料分散到多台機器上儲存和處理。這種技術,就叫做資料分片(Sharding)(我不確定這翻譯是否適當,所以列出英文)。
Sharding 這個字的字面意思是「碎片」,在資料庫的脈絡下,我們把一張大表格切成多個小塊,每一塊就是一個 shard,每個 shard 存放在不同的機器上。概念聽起來很美好,但實作起來,「要怎麼切」這個問題,卻大有學問。
最直覺的方式:Range Sharding(範圍分片)
Range Sharding,顧名思義,就是依照某個欄位(通常是主鍵或日期)的「範圍」來切割資料。舉個例子,假設你有一個電商平台,裡面有一張「訂單」表格,主鍵是訂單編號。你可以這樣切:
- 節點 A:存放訂單編號 1 到 1,000,000
- 節點 B:存放訂單編號 1,000,001 到 2,000,000
- 節點 C:存放訂單編號 2,000,001 到 3,000,000
- 以此類推……
這種方式最大的優點是直覺易懂,而且對於「範圍查詢(range query)」非常友善。比如說你想查「2024 年 1 月份的所有訂單」,如果是用日期當分片鍵,系統馬上就知道要去哪個節點找,不需要把所有節點都掃一遍。這在資料分析、報表系統裡非常實用。
但 Range Sharding 有一個非常致命的缺點:熱點問題(hot spot)。
以電商訂單為例,剛剛建立的新訂單,編號一定是最大的,所以永遠都被分配到最後一個節點。這台機器要承受所有的新寫入流量,其他機器反而閒閒沒事。這種情況下,分片非但沒有解決問題,反而製造了一個「超載的節點」,讓整個系統的瓶頸更加明顯。就像你開了四間倉庫,結果所有貨都只堆到第四間,前三間都空著,這做法只分散了資料,但沒有分散工作負擔,這並不是真正意義上的分散。
如果用時間當分片鍵,這個現象更嚴重。因為永遠是「最近的時間分片」在承擔所有的讀寫流量,舊的分片反而幾乎沒有人去動。
所以 Range Sharding 適合什麼場合呢?適合那些讀取模式是以範圍掃描為主、並且寫入流量相對均勻分佈的情境。例如地理位置(依照郵遞區號分片)、或者確定每段範圍的資料量大致平均的場合。如果你的情境不符合這些條件,就要考慮下一種方法了。
用雜湊打散資料:雜湊分片(Hash Sharding)
Hash Sharding 的出發點,就是要解決 Range Sharding 的熱點問題。它的做法很簡單:對分片鍵做一個雜湊運算(hash function),然後用結果除以節點數量取餘數,來決定這筆資料要放到哪個節點。
公式大概是這樣:
node = hash(key) mod N
其中 N 是節點的總數量。舉個例子,假設有 4 個節點(編號 0 到 3),現在要決定訂單 ID 為 12345 的資料要放哪裡:
hash(12345) = 987654321 (某個雜湊值) 987654321 mod 4 = 1 → 放到節點 1
雜湊函數的特性是「輸入稍微不同,輸出就會差很多」,所以就算訂單編號是連續的,經過雜湊之後會散落到各個節點,不會集中在同一台機器上。這樣就有效地解決了 Range Sharding 的熱點問題。
Hash Sharding 的優點是資料分佈均勻,對於以主鍵查詢(point query)為主的情境非常有效。你知道一個使用者 ID 或一個訂單 ID,馬上就能算出它在哪台機器,查詢速度很快。
不過,天下沒有白吃的午餐。Hash Sharding 的缺點有兩個:
第一,範圍查詢效率極差。因為資料被打散了,如果你想查「所有 2024 年 1 月的訂單」,系統沒有辦法只去一台機器找,必須把所有節點都查一遍,再把結果合併起來。這種操作叫做 scatter-gather,在節點數量多的時候,效能開銷非常大。
第二,也是更棘手的問題:節點增減的時候,資料幾乎全部要搬移。這就是本文標題所說的「搬家難題」。
假設原本有 4 台節點,某天流量大增,你決定加一台,變成 5 台。那麼公式就從 mod 4 變成了 mod 5。原本 hash(key) mod 4 = 1 的資料,現在 hash(key) mod 5 可能是 2 或 3。幾乎所有資料的「預期位置」都改變了,這意味著你要把幾乎全部的資料都從原來的節點搬到新的節點。對於一個存有幾十 TB 資料的系統來說,這是一個非常痛苦、耗時,又充滿風險的過程。
更糟的是,資料搬移本身會消耗大量的網路頻寬和磁碟 I/O。在「搬家」進行的這段時間裡,這些資源都在忙著搬資料,真正來自使用者的查詢請求反而要排隊等待,造成服務延遲明顯上升。如果搬移速度太慢、持續太久,整個叢集甚至可能觸發連鎖反應,讓系統陷入更大的麻煩。這種在擴充期間特別容易發生的故障連鎖,正是分散式系統工程師最害怕遇到的「雪崩效應(cascading failure)」。
那有沒有一種方法,能夠兼顧「資料分佈均勻」又能讓「節點增減時只需要搬移少量資料」呢?
一致性雜湊:讓搬家的痛苦降到最低
一致性雜湊(Consistent Hashing) 就是為了解決這個問題而生的。這個方法的設計非常巧妙,但只要跟著例子一步一步走,你會發現它其實並不難懂。
從一個生活比喻開始:接力賽的交接區
在正式介紹技術之前,先想像一個情境。
你的班級要舉辦一個「管理公告欄」的輪值任務。班上目前有三個同學負責:小明、小華、小美,他們三個人圍成一個圓圈站好(想像成一個時鐘的圓盤)。現在老師手上有一疊佈告,每張佈告上面有一個號碼(例如 42 號、17 號、88 號……),規則是:號碼落在哪個區間,就交給站在那個區間終點的同學管理。
一開始,三個人把圓圈分成三段:
0 ~ 33 號 → 小明 34 ~ 66 號 → 小華 67 ~ 99 號 → 小美
某天,班上轉來一個新同學小強,老師安排他在小華和小美之間。現在圓圈變成四段,小強負責原本屬於小美的前半段(67 ~ 82 號)。所以只有那些 67 ~ 82 號的佈告,需要從小美那裡轉交給小強。小明和小華管的佈告完全不動,小美也只需要把一部分交出去,整個「搬家」的動作非常小。
這個圓圈輪值的概念,就是一致性雜湊的核心精神。接下來我們用真正的技術語言來描述它。
把節點和資料都放到同一個「環」上
一致性雜湊的核心工具叫做「雜湊環(hash ring)」。
想像一個時鐘的圓形錶盤,但刻度不是 1 到 12,而是從 0 到 2^32 - 1(也就是 4,294,967,295)。然後把這個數字的頭尾相接,形成一個完整的環,這就是一致性雜湊的雜湊空間 [0, 2^32 - 1]。
第一步:把「節點」放上環。
對每一台伺服器節點的名稱做雜湊運算,得到一個數字,然後把這個節點「釘」在環上對應的位置。假設我們有三個節點 A、B、C,雜湊之後分別落在環上 12 點、4 點、8 點的位置(這只是示意,實際上是 0 到 2^32 之間的某個數字)。
A(12點)
/ \
C(8點) B(4點)
第二步:把「資料」放上環,並決定它的歸屬。
當一筆資料要決定要存到哪台伺服器時,對它的分片鍵做雜湊,得到一個數字,也標在環上。
接著,要決定這筆資料歸哪個節點管,規則是這樣的:
從資料的位置出發,沿著數字增大的方向前進,第一個碰到的節點,就是這筆資料的負責人。如果走到環的末端都沒碰到節點,就繞回環的起點繼續找。
換個角度來理解,其實每個節點負責的是環上的一段弧,從「上一個節點的位置(不包含)」到「自己的位置(包含)」這一段。所有落在這段弧裡的資料,都由這個節點負責。這樣想,是不是更清楚?
讓我們直接用數字來驗證。假設環的刻度是 0 到 99,節點和資料的位置如下:
節點 A 的雜湊值 = 10 節點 B 的雜湊值 = 40 節點 C 的雜湊值 = 75 資料 D1 的雜湊值 = 5 資料 D2 的雜湊值 = 15 資料 D3 的雜湊值 = 50 資料 D4 的雜湊值 = 60 資料 D5 的雜湊值 = 80
套用「每個節點負責前一個節點到自己這一段弧」的規則,先算出各節點的負責範圍:
節點 A(位置 10)→ 負責 76 ~ 10 (從 C 之後繞回來,包含 76、77、...、99、0、1、...、10) 節點 B(位置 40)→ 負責 11 ~ 40 節點 C(位置 75)→ 負責 41 ~ 75
再來看每筆資料落在哪個範圍:
- D1(位置 5)→ 落在 76~10 這段弧 → 歸 A 管
- D2(位置 15)→ 落在 11~40 這段弧 → 歸 B 管
- D3(位置 50)→ 落在 41~75 這段弧 → 歸 C 管
- D4(位置 60)→ 落在 41~75 這段弧 → 歸 C 管
- D5(位置 80)→ 落在 76~10 這段弧(繞回起點那段)→ 歸 A 管
新增節點的時候,只需要搬一小部分
現在重點來了。假設系統需要擴充,我們加入一台新節點 X,它的雜湊值是 55,落在 B(40)和 C(75)之間。
(加入前)環上順序:... A(10) ... B(40) ... C(75) ... (加入後)環上順序:... A(10) ... B(40) ... X(55) ... C(75) ...
現在再重新套用「從資料位置出發,找到的第一個節點」的規則,看看哪些資料的歸屬改變了:
- D1(位置 5)→ 遇到 A(10)→ 歸 A 管。不變。
- D2(位置 15)→ 遇到 B(40)→ 歸 B 管。不變。
- D3(位置 50)→ 原本會遇到 C(75),但現在 X(55)插進來了,先遇到 X → 改歸 X 管!
- D4(位置 60)→ 原本會遇到 C(75),但現在先遇到 X(55)→ 改歸 X 管!
- D5(位置 80)→ 繞回去,遇到 A(10)→ 歸 A 管。不變。
結果只有 D3 和 D4 需要從 C 搬到 X,其他三筆資料完全待在原地不動!
相比之下,如果我們用普通的 Hash Sharding,節點從 3 台變成 4 台,幾乎每一筆資料的 hash(key) mod 3 和 hash(key) mod 4 的結果都不同,造成大量資料需要搬家。而 Consistent Hashing 的情況是:加一台新節點,只有新節點「身後那一段弧」的資料需要搬過來,大約是全部資料的 1/N(N 是節點總數)。節點越多,每次擴充需要搬的比例就越小。這就是它名字裡「一致性」的由來——節點增減時,大多數資料的歸屬「保持一致,不受影響」。
移除節點的邏輯完全對稱:如果 X 要下線,只有它負責的 D3、D4 需要轉移給 C,其他節點的資料完全不動。
虛擬節點:解決分佈不均的問題
Consistent Hashing 聽到這裡很美好,但有一個實務上很現實的問題:節點在環上分佈不均勻。
假設一個特別情境,A、B、C 三個節點雜湊之後剛好都擠在環的同一側,例如全部落在 0 到 30 之間,那麼 30 到 99 這一大段弧幾乎都是 A(因為從 30 之後順時針繞一圈才會回到 A)。結果 A 要負責幾乎全部的資料,B 和 C 反而很閒。這樣分了等於沒分,甚至更糟。
解決方法是引入「虛擬節點(virtual node)」。
做法是這樣的:每一台實體伺服器,不是只在環上放一個點,而是放很多個點。例如,每台機器放 150 個虛擬節點,做法是對「節點名稱 + 編號」做雜湊,產生 150 個不同的位置:
節點 A 的虛擬節點:hash("A-1") = 5, hash("A-2") = 38, hash("A-3") = 71, ...(共 150 個)
節點 B 的虛擬節點:hash("B-1") = 12, hash("B-2") = 49, hash("B-3") = 83, ...(共 150 個)
節點 C 的虛擬節點:hash("C-1") = 21, hash("C-2") = 55, hash("C-3") = 90, ...(共 150 個)
這 450 個虛擬節點散落在環上,由於數量夠多,統計上就會趨近於均勻分佈。而每個虛擬節點負責的實際資料,最終還是由它背後的那台真實機器來儲存。
虛擬節點還帶來另外兩個在實務上非常重要的好處。
第一個是應對異質性(heterogeneity)。現實中的機器叢集往往不是整齊劃一的:有些是舊機器(CPU 慢、記憶體小),有些是新機器(效能強)。如果強迫每台機器負責相同份量的資料,舊機器可能早就喘不過氣,新機器卻還很悠閒。有了虛擬節點,就可以彈性調整:效能強的機器配 200 個虛擬節點,效能弱的只配 50 個,讓每台機器承擔與自己能力相稱的負載。這種彈性,在需要混用新舊機器的維運環境中,是非常實用的特性。Amazon Dynamo 的原始論文中就明確指出,虛擬節點的設計正是為了處理這種「節點效能不一致」的問題。
第二個是故障時的負載分散。當一台機器突然故障下線時,它的多個虛擬節點會分別把資料轉移給環上各自的下一個節點,等於把負擔分散給了多台機器,而不是全部壓給同一台。如果沒有虛擬節點,一台機器掛掉,它的所有資料就全部湧向單一的下一台,那台機器的負載瞬間暴增,說不定也跟著掛掉,接著又觸發下一台……這正是前面提到的雪崩效應。
一致性雜湊搭配虛擬節點的組合,最早在 2007 年被 Amazon 發表的一篇名為「Dynamo: Amazon's Highly Available Key-value Store」的論文中系統性地提出,並完整描述了它在大規模生產系統中的應用。這篇論文後來被公認為分散式資料庫領域最具影響力的論文之一,直接啟發了 Apache Cassandra(Facebook 開發)等一代 NoSQL 資料庫的設計。現今 AWS 雲端服務上的 DynamoDB,雖然底層實作已經遠比當年複雜,但其核心概念——透過 Partition Key 進行雜湊分片、並在後台自動處理資料搬遷——仍然延續自 Dynamo 的設計哲學。
三種策略的比較
講了這麼多,讓我們來整理一下這三種方法的優缺點,方便日後查閱。
Range Sharding 的優點是範圍查詢效率高,資料的分佈邏輯直覺,容易調試和維運。缺點是容易產生熱點,資料分佈可能不均,增減節點時部分資料需要搬遷。適用情境是讀取以範圍掃描為主、寫入流量較均勻的場合,例如時間序列資料(log、監控指標)的歸檔系統。
Hash Sharding 的優點是資料分佈均勻,單鍵查詢速度快,有效避免熱點。缺點是範圍查詢需要掃全部節點,以及增減節點時幾乎所有資料都要搬移,代價極高。適用情境是查詢模式以 point query 為主、資料量穩定不常增刪節點的系統。
Consistent Hashing 的優點是增減節點時只搬少量資料,搭配虛擬節點後資料分佈均勻,非常適合動態擴縮容的分散式環境。缺點是範圍查詢同樣不友善,實作相對複雜,調試成本也比前兩者高一些。適用情境是需要頻繁動態擴縮的大型分散式系統,例如 Apache Cassandra 這類分散式 NoSQL 資料庫。
事實上,很多成熟的系統會混合使用這些策略,或是在這些策略上發展出變形。例如 MongoDB 同時支援 Range Sharding 和 Hash Sharding,工程師在建立 Sharded Cluster 時必須決定「分片鍵(Shard Key)」,一旦選定,分片策略就固定下來,無法任意更改,所以選對分片鍵是 MongoDB 維運中非常關鍵的決策。
Redis Cluster 則是一個有趣的案例。它官方文件明確寫道「Redis Cluster does not use consistent hashing」,而是採用了一套叫做雜湊槽(Hash Slots)的機制:將整個 key 空間切成固定的 16,384 個槽位,每個節點負責其中一部分槽位。擴充節點時,是把槽位從舊節點搬到新節點,而不是重新計算整個雜湊環。這個設計在邏輯上與 Consistent Hashing 非常相似,但因為槽位數量固定,對資料分佈的控制更精確,實作和維運工具也更易於管理。
Sharding 只是開始
Sharding 解決了「資料量太大放不下」的問題,但它同時也帶來了一系列新的複雜度。例如,跨 shard 的 JOIN 查詢怎麼做?跨 shard 的交易(transaction)如何保持一致性?如果一筆資料需要根據不同的查詢維度存放在不同的 shard(例如訂單既要依使用者 ID 查,又要依商品 ID 查),怎麼辦?
這些問題,每一個都是分散式系統裡的大哉問,每一個都有一整套的理論和工程實踐來對應。Sharding 是分散式資料儲存的入門門票,但要真正把一個大型分散式系統做好,還有非常漫長的路要走。
但至少現在,當你下次在面試被問到「系統資料量太大怎麼辦」的時候,你已經知道不只是「加一台更大的機器」這個答案了。你會知道怎麼「搬家」,也會知道怎麼讓搬家的過程痛苦少一點。