# 第六章:分割槽 ![](../img/ch6.png) > 我們必須跳出電腦指令序列的窠臼。 敘述定義、描述元資料、梳理關係,而不是編寫過程。 > > —— Grace Murray Hopper,未來的計算機及其管理(1962) > ------------- [TOC] ​ 在[第5章](ch5.md)中,我們討論了複製——即資料在不同節點上的副本,對於非常大的資料集,或非常高的吞吐量,僅僅進行復制是不夠的:我們需要將資料進行**分割槽(partitions)**,也稱為**分片(sharding)**[^i]。 [^i]: 正如本章所討論的,分割槽是一種有意將大型資料庫分解成小型資料庫的方式。它與 **網路分割槽(network partitions, netsplits)** 無關,這是節點之間網路故障的一種。我們將在[第8章](ch8.md)討論這些錯誤。 > #### 術語澄清 > > ​ 上文中的**分割槽(partition)**,在MongoDB,Elasticsearch和Solr Cloud中被稱為**分片(shard)**,在HBase中稱之為**區域(Region)**,Bigtable中則是 **表塊(tablet)**,Cassandra和Riak中是**虛節點(vnode)**,Couchbase中叫做**虛桶(vBucket)**。但是**分割槽(partitioning)** 是最約定俗成的叫法。 > ​ 通常情況下,每條資料(每條記錄,每行或每個文件)屬於且僅屬於一個分割槽。有很多方法可以實現這一點,本章將進行深入討論。實際上,每個分割槽都是自己的小型資料庫,儘管資料庫可能支援同時進行多個分割槽的操作。 ​ 分割槽主要是為了**可伸縮性**。不同的分割槽可以放在不共享叢集中的不同節點上(參閱[第二部分](part-ii.md)關於[無共享架構](part-ii.md#無共享架構)的定義)。因此,大資料集可以分佈在多個磁碟上,並且查詢負載可以分佈在多個處理器上。 ​ 對於在單個分割槽上執行的查詢,每個節點可以獨立執行對自己的查詢,因此可以透過新增更多的節點來擴大查詢吞吐量。大型,複雜的查詢可能會跨越多個節點並行處理,儘管這也帶來了新的困難。 ​ 分割槽資料庫在20世紀80年代由Teradata和NonStop SQL【1】等產品率先推出,最近因為NoSQL資料庫和基於Hadoop的資料倉庫重新被關注。有些系統是為事務性工作設計的,有些系統則用於分析(參閱“[事務處理還是分析](ch3.md#事務處理還是分析)”):這種差異會影響系統的運作方式,但是分割槽的基本原理均適用於這兩種工作方式。 ​ 在本章中,我們將首先介紹分割大型資料集的不同方法,並觀察索引如何與分割槽配合。然後我們將討論[分割槽再平衡(rebalancing)](#分割槽再平衡),如果想要新增或刪除叢集中的節點,則必須進行再平衡。最後,我們將概述資料庫如何將請求路由到正確的分割槽並執行查詢。 ## 分割槽與複製 ​ 分割槽通常與複製結合使用,使得每個分割槽的副本儲存在多個節點上。 這意味著,即使每條記錄屬於一個分割槽,它仍然可以儲存在多個不同的節點上以獲得容錯能力。 ​ 一個節點可能儲存多個分割槽。 如果使用主從複製模型,則分割槽和複製的組合如[圖6-1](../img/fig6-1.png)所示。 每個分割槽領導者(主)被分配給一個節點,追隨者(從)被分配給其他節點。 每個節點可能是某些分割槽的領導者,同時是其他分割槽的追隨者。 我們在[第5章](ch5.md)討論的關於資料庫複製的所有內容同樣適用於分割槽的複製。 大多數情況下,分割槽方案的選擇與複製方案的選擇是獨立的,為簡單起見,本章中將忽略複製。 ![](../img/fig6-1.png) **圖6-1 組合使用複製和分割槽:每個節點充當某些分割槽的領導者,其他分割槽充當追隨者。** ## 鍵值資料的分割槽 ​ 假設你有大量資料並且想要分割槽,如何決定在哪些節點上儲存哪些記錄呢? ​ 分割槽目標是將資料和查詢負載均勻分佈在各個節點上。如果每個節點公平分享資料和負載,那麼理論上10個節點應該能夠處理10倍的資料量和10倍的單個節點的讀寫吞吐量(暫時忽略複製)。 ​ 如果分割槽是不公平的,一些分割槽比其他分割槽有更多的資料或查詢,我們稱之為**偏斜(skew)**。資料偏斜的存在使分割槽效率下降很多。在極端的情況下,所有的負載可能壓在一個分割槽上,其餘9個節點空閒的,瓶頸落在這一個繁忙的節點上。不均衡導致的高負載的分割槽被稱為**熱點(hot spot)**。 ​ 避免熱點最簡單的方法是將記錄隨機分配給節點。這將在所有節點上平均分配資料,但是它有一個很大的缺點:當你試圖讀取一個特定的值時,你無法知道它在哪個節點上,所以你必須並行地查詢所有的節點。 ​ 我們可以做得更好。現在假設您有一個簡單的鍵值資料模型,其中您總是透過其主鍵訪問記錄。例如,在一本老式的紙質百科全書中,你可以透過標題來查詢一個條目;由於所有條目按字母順序排序,因此您可以快速找到您要查詢的條目。 ### 根據鍵的範圍分割槽 ​ 一種分割槽的方法是為每個分割槽指定一塊連續的鍵範圍(從最小值到最大值),如紙質百科全書的卷([圖6-2](../img/fig6-2.png))。如果知道範圍之間的邊界,則可以輕鬆確定哪個分割槽包含某個值。如果您還知道分割槽所在的節點,那麼可以直接向相應的節點發出請求(對於百科全書而言,就像從書架上選取正確的書籍)。 ![](../img/fig6-2.png) **圖6-2 印刷版百科全書按照關鍵字範圍進行分割槽** ​ 鍵的範圍不一定均勻分佈,因為資料也很可能不均勻分佈。例如在[圖6-2](../img/fig6-2.png)中,第1捲包含以A和B開頭的單詞,但第12卷則包含以T,U,V,X,Y和Z開頭的單詞。只是簡單的規定每個捲包含兩個字母會導致一些卷比其他卷大。為了均勻分配資料,分割槽邊界需要依據資料調整。 ​ 分割槽邊界可以由管理員手動選擇,也可以由資料庫自動選擇(我們會在“[分割槽再平衡](#分割槽再平衡)”中更詳細地討論分割槽邊界的選擇)。 Bigtable使用了這種分割槽策略,以及其開源等價物HBase 【2, 3】,RethinkDB和2.4版本之前的MongoDB 【4】。 ​ 在每個分割槽中,我們可以按照一定的順序儲存鍵(參見“[SSTables和LSM樹](ch3.md#SSTables和LSM樹)”)。好處是進行範圍掃描非常簡單,您可以將鍵作為聯合索引來處理,以便在一次查詢中獲取多個相關記錄(參閱“[多列索引](ch3.md#多列索引)”)。例如,假設我們有一個程式來儲存感測器網路的資料,其中主鍵是測量的時間戳(年月日時分秒)。範圍掃描在這種情況下非常有用,因為我們可以輕鬆獲取某個月份的所有資料。 ​ 然而,Key Range分割槽的缺點是某些特定的訪問模式會導致熱點。 如果主鍵是時間戳,則分割槽對應於時間範圍,例如,給每天分配一個分割槽。 不幸的是,由於我們在測量發生時將資料從感測器寫入資料庫,因此所有寫入操作都會轉到同一個分割槽(即今天的分割槽),這樣分割槽可能會因寫入而過載,而其他分割槽則處於空閒狀態【5】。 ​ 為了避免感測器資料庫中的這個問題,需要使用除了時間戳以外的其他東西作為主鍵的第一個部分。 例如,可以在每個時間戳前新增感測器名稱,這樣會首先按感測器名稱,然後按時間進行分割槽。 假設有多個感測器同時執行,寫入負載將最終均勻分佈在不同分割槽上。 現在,當想要在一個時間範圍內獲取多個感測器的值時,您需要為每個感測器名稱執行一個單獨的範圍查詢。 ### 根據鍵的雜湊分割槽 ​ 由於偏斜和熱點的風險,許多分散式資料儲存使用雜湊函式來確定給定鍵的分割槽。 ​ 一個好的雜湊函式可以將偏斜的資料均勻分佈。假設你有一個32位雜湊函式,無論何時給定一個新的字串輸入,它將返回一個0到$2^{32}$ -1之間的“隨機”數。即使輸入的字串非常相似,它們的雜湊也會均勻分佈在這個數字範圍內。 ​ 出於分割槽的目的,雜湊函式不需要多麼強壯的加密演算法:例如,Cassandra和MongoDB使用MD5,Voldemort使用Fowler-Noll-Vo函式。許多程式語言都有內建的簡單雜湊函式(它們用於雜湊表),但是它們可能不適合分割槽:例如,在Java的`Object.hashCode()`和Ruby的`Object#hash`,同一個鍵可能在不同的程序中有不同的雜湊值【6】。 ​ 一旦你有一個合適的鍵雜湊函式,你可以為每個分割槽分配一個雜湊範圍(而不是鍵的範圍),每個透過雜湊雜湊落在分割槽範圍內的鍵將被儲存在該分割槽中。如[圖6-3](../img/fig6-3.png)所示。 ![](../img/fig6-3.png) **圖6-3 按雜湊鍵分割槽** ​ 這種技術擅長在分割槽之間公平地分配鍵。分割槽邊界可以是均勻間隔的,也可以是偽隨機選擇的(在這種情況下,該技術有時也被稱為**一致性雜湊(consistent hashing)**)。 > #### 一致性雜湊 > > ​ 一致性雜湊由Karger等人定義。【7】 用於跨網際網路級別的快取系統,例如CDN中,是一種能均勻分配負載的方法。它使用隨機選擇的 **分割槽邊界(partition boundaries)** 來避免中央控制或分散式共識的需要。 請注意,這裡的一致性與複製一致性(請參閱[第5章](ch5.md))或ACID一致性(參閱[第7章](ch7.md))無關,而只是描述了一種重新平衡(reblancing)的特定方法。 > > ​ 正如我們將在“[分割槽再平衡](#分割槽再平衡)”中所看到的,這種特殊的方法對於資料庫實際上並不是很好,所以在實際中很少使用(某些資料庫的文件仍然會使用一致性雜湊的說法,但是它往往是不準確的)。 因為有可能產生混淆,所以最好避免使用一致性雜湊這個術語,而只是把它稱為**雜湊分割槽(hash partitioning)**。 ​ 不幸的是,透過使用鍵雜湊進行分割槽,我們失去了鍵範圍分割槽的一個很好的屬性:高效執行範圍查詢的能力。曾經相鄰的鍵現在分散在所有分割槽中,所以它們之間的順序就丟失了。在MongoDB中,如果您使用了基於雜湊的分割槽模式,則任何範圍查詢都必須傳送到所有分割槽【4】。Riak 【9】,Couchbase 【10】或Voldemort不支援主鍵上的範圍查詢。 ​ Cassandra採取了折衷的策略【11, 12, 13】。 Cassandra中的表可以使用由多個列組成的複合主鍵來宣告。鍵中只有第一列會作為雜湊的依據,而其他列則被用作Casssandra的SSTables中排序資料的連線索引。儘管查詢無法在複合主鍵的第一列中按範圍掃表,但如果第一列已經指定了固定值,則可以對該鍵的其他列執行有效的範圍掃描。 ​ 組合索引方法為一對多關係提供了一個優雅的資料模型。例如,在社交媒體網站上,一個使用者可能會發布很多更新。如果更新的主鍵被選擇為`(user_id, update_timestamp)`,那麼您可以有效地檢索特定使用者在某個時間間隔內按時間戳排序的所有更新。不同的使用者可以儲存在不同的分割槽上,對於每個使用者,更新按時間戳順序儲存在單個分割槽上。 ### 負載偏斜與熱點消除 ​ 如前所述,雜湊分割槽可以幫助減少熱點。但是,它不能完全避免它們:在極端情況下,所有的讀寫操作都是針對同一個鍵的,所有的請求都會被路由到同一個分割槽。 ​ 這種場景也許並不常見,但並非聞所未聞:例如,在社交媒體網站上,一個擁有數百萬追隨者的名人使用者在做某事時可能會引發一場風暴【14】。這個事件可能導致同一個鍵的大量寫入(鍵可能是名人的使用者ID,或者人們正在評論的動作的ID)。雜湊策略不起作用,因為兩個相同ID的雜湊值仍然是相同的。 ​ 如今,大多數資料系統無法自動補償這種高度偏斜的負載,因此應用程式有責任減少偏斜。例如,如果一個主鍵被認為是非常火爆的,一個簡單的方法是在主鍵的開始或結尾新增一個隨機數。只要一個兩位數的十進位制隨機數就可以將主鍵分散為100種不同的主鍵,從而儲存在不同的分割槽中。 ​ 然而,將主鍵進行分割之後,任何讀取都必須要做額外的工作,因為他們必須從所有100個主鍵分佈中讀取資料並將其合併。此技術還需要額外的記錄:只需要對少量熱點附加隨機數;對於寫入吞吐量低的絕大多數主鍵來說是不必要的開銷。因此,您還需要一些方法來跟蹤哪些鍵需要被分割。 ​ 也許在將來,資料系統將能夠自動檢測和補償偏斜的工作負載;但現在,您需要自己來權衡。 ## 分割槽與次級索引 ​ 到目前為止,我們討論的分割槽方案依賴於鍵值資料模型。如果只通過主鍵訪問記錄,我們可以從該鍵確定分割槽,並使用它來將讀寫請求路由到負責該鍵的分割槽。 ​ 如果涉及次級索引,情況會變得更加複雜(參考“[其他索引結構](ch3.md#其他索引結構)”)。輔助索引通常並不能唯一地標識記錄,而是一種搜尋記錄中出現特定值的方式:查詢使用者123的所有操作,查詢包含詞語`hogwash`的所有文章,查詢所有顏色為紅色的車輛等等。 ​ 次級索引是關係型資料庫的基礎,並且在文件資料庫中也很普遍。許多鍵值儲存(如HBase和Volde-mort)為了減少實現的複雜度而放棄了次級索引,但是一些(如Riak)已經開始新增它們,因為它們對於資料模型實在是太有用了。並且次級索引也是Solr和Elasticsearch等搜尋伺服器的基石。 ​ 次級索引的問題是它們不能整齊地對映到分割槽。有兩種用二級索引對資料庫進行分割槽的方法:**基於文件的分割槽(document-based)**和**基於關鍵詞(term-based)的分割槽**。 ### 基於文件的二級索引進行分割槽 ​ 假設你正在經營一個銷售二手車的網站(如[圖6-4](../img/fig6-4.png)所示)。 每個列表都有一個唯一的ID——稱之為文件ID——並且用文件ID對資料庫進行分割槽(例如,分割槽0中的ID 0到499,分割槽1中的ID 500到999等)。 ​ 你想讓使用者搜尋汽車,允許他們透過顏色和廠商過濾,所以需要一個在顏色和廠商上的次級索引(文件資料庫中這些是**欄位(field)**,關係資料庫中這些是**列(column)** )。 如果您聲明瞭索引,則資料庫可以自動執行索引[^ii]。例如,無論何時將紅色汽車新增到資料庫,資料庫分割槽都會自動將其新增到索引條目`color:red`的文件ID列表中。 [^ii]: 如果資料庫僅支援鍵值模型,則你可能會嘗試在應用程式程式碼中建立從值到文件ID的對映來實現輔助索引。 如果沿著這條路線走下去,請萬分小心,確保您的索引與底層資料保持一致。 競爭條件和間歇性寫入失敗(其中一些更改已儲存,但其他更改未儲存)很容易導致資料不同步 - 參見“[多物件事務的需求](ch7.md#多物件事務的需求)”。 ![](../img/fig6-4.png) **圖6-4 基於文件的二級索引進行分割槽** ​ 在這種索引方法中,每個分割槽是完全獨立的:每個分割槽維護自己的二級索引,僅覆蓋該分割槽中的文件。它不關心儲存在其他分割槽的資料。無論何時您需要寫入資料庫(新增,刪除或更新文件),只需處理包含您正在編寫的文件ID的分割槽即可。出於這個原因,**文件分割槽索引**也被稱為**本地索引(local index)**(而不是將在下一節中描述的**全域性索引(global index)**)。 ​ 但是,從文件分割槽索引中讀取需要注意:除非您對文件ID做了特別的處理,否則沒有理由將所有具有特定顏色或特定品牌的汽車放在同一個分割槽中。在[圖6-4](../img/fig6-4.png)中,紅色汽車出現在分割槽0和分割槽1中。因此,如果要搜尋紅色汽車,則需要將查詢傳送到所有分割槽,併合並所有返回的結果。 ​ 這種查詢分割槽資料庫的方法有時被稱為**分散/聚集(scatter/gather)**,並且可能會使二級索引上的讀取查詢相當昂貴。即使並行查詢分割槽,分散/聚集也容易導致尾部延遲放大(參閱“[實踐中的百分位點](ch1.md#實踐中的百分位點)”)。然而,它被廣泛使用:MongoDB,Riak 【15】,Cassandra 【16】,Elasticsearch 【17】,SolrCloud 【18】和VoltDB 【19】都使用文件分割槽二級索引。大多數資料庫供應商建議您構建一個能從單個分割槽提供二級索引查詢的分割槽方案,但這並不總是可行,尤其是當在單個查詢中使用多個二級索引時(例如同時需要按顏色和製造商查詢)。 ### 基於關鍵詞(Term)的二級索引進行分割槽 ​ 我們可以構建一個覆蓋所有分割槽資料的**全域性索引**,而不是給每個分割槽建立自己的次級索引(本地索引)。但是,我們不能只把這個索引儲存在一個節點上,因為它可能會成為瓶頸,違背了分割槽的目的。全域性索引也必須進行分割槽,但可以採用與主鍵不同的分割槽方式。 ​ [圖6-5](../img/fig6-5.png)描述了這可能是什麼樣子:來自所有分割槽的紅色汽車在紅色索引中,並且索引是分割槽的,首字母從`a`到`r`的顏色在分割槽0中,`s`到`z`的在分割槽1。汽車製造商的索引也與之類似(分割槽邊界在`f`和`h`之間)。 ![](../img/fig6-5.png) **圖6-5 基於關鍵詞對二級索引進行分割槽** ​ 我們將這種索引稱為**關鍵詞分割槽(term-partitioned)**,因為我們尋找的關鍵詞決定了索引的分割槽方式。例如,一個關鍵詞可能是:`color:red`。**關鍵詞(Term)** 這個名稱來源於全文搜尋索引(一種特殊的次級索引),指文件中出現的所有單詞。 ​ 和之前一樣,我們可以透過**關鍵詞**本身或者它的雜湊進行索引分割槽。根據關鍵詞本身來分割槽對於範圍掃描非常有用(例如對於數值類的屬性,像汽車的報價),而對關鍵詞的雜湊分割槽提供了負載均衡的能力。 ​ 關鍵詞分割槽的全域性索引優於文件分割槽索引的地方點是它可以使讀取更有效率:不需要**分散/收集**所有分割槽,客戶端只需要向包含關鍵詞的分割槽發出請求。全域性索引的缺點在於寫入速度較慢且較為複雜,因為寫入單個文件現在可能會影響索引的多個分割槽(文件中的每個關鍵詞可能位於不同的分割槽或者不同的節點上) 。 ​ 理想情況下,索引總是最新的,寫入資料庫的每個文件都會立即反映在索引中。但是,在關鍵詞分割槽索引中,這需要跨分割槽的分散式事務,並不是所有資料庫都支援(請參閱[第7章](ch7.md)和[第9章](ch9.md))。 ​ 在實踐中,對全域性二級索引的更新通常是**非同步**的(也就是說,如果在寫入之後不久讀取索引,剛才所做的更改可能尚未反映在索引中)。例如,Amazon DynamoDB聲稱在正常情況下,其全域性次級索引會在不到一秒的時間內更新,但在基礎架構出現故障的情況下可能會有延遲【20】。 ​ 全域性關鍵詞分割槽索引的其他用途包括Riak的搜尋功能【21】和Oracle資料倉庫,它允許您在本地和全域性索引之間進行選擇【22】。我們將在[第12章](ch12.md)中繼續關鍵詞分割槽二級索引實現的話題。 ## 分割槽再平衡 隨著時間的推移,資料庫會有各種變化: * 查詢吞吐量增加,所以您想要新增更多的CPU來處理負載。 * 資料集大小增加,所以您想新增更多的磁碟和RAM來儲存它。 * 機器出現故障,其他機器需要接管故障機器的責任。 所有這些更改都需要資料和請求從一個節點移動到另一個節點。 將負載從叢集中的一個節點向另一個節點移動的過程稱為**再平衡(rebalancing)**。 無論使用哪種分割槽方案,再平衡通常都要滿足一些最低要求: * 再平衡之後,負載(資料儲存,讀取和寫入請求)應該在叢集中的節點之間公平地共享。 * 再平衡發生時,資料庫應該繼續接受讀取和寫入。 * 節點之間只移動必須的資料,以便快速再平衡,並減少網路和磁碟I/O負載。 ### 再平衡策略 有幾種不同的分割槽分配方法【23】,讓我們依次簡要討論一下。 #### 反面教材:hash mod N ​ 我們在前面說過([圖6-3](../img/fig6-3.png)),最好將可能的雜湊分成不同的範圍,並將每個範圍分配給一個分割槽(例如,如果$0≤hash(key)