MongoDB 分片集群實戰


背景

在如今的互聯網環境下,海量數據已隨處可見並且還在不斷增長,對於如何存儲處理海量數據,比較常見的方法有兩種:

  • 垂直擴展:通過增加單台服務器的配置,例如使用更強悍的 CPU、更大的內存、更大容量的磁盤,此種方法雖然成本很高,但是實現比較簡單,維護起來也比較方便。
  • 水平擴展:通過使用更多配置一般的服務器來共同承擔工作負載,此種方法很靈活,可以根據工作負載的大小動態增減服務器的數量,但是實現比較復雜,得有專門的人員來運維。

 

MongoDB 支持通過分片技術從而進行水平擴展,用以支撐海量數據集和高吞吐量的操作。如果數據集不夠大,還是建議您使用 MongoDB 副本集,因為分片需要處理更多的技術細節,所以在分片環境下其性能可能始終沒有副本集性能強。本文通過介紹如何搭建 MongoDB 分片集群以及及一些相關核心概念,可以幫您快速理解 MongoDB 是如何通過分片技術來處理海量數據的。

MongoDB 分片集群組件

在搭建 MongoDB 分片集群環境之前,我們先了解一下分片集群包含哪些組件。一個 MongoDB 分片集群(參考官方文檔 Sharded Cluster)由以下三個組件構成,缺一不可:

  • shard:每個分片是整體數據的一部分子集。每個分片都可以部署為副本集。強烈建議在生產環境下將分片部署為副本集且最少部署 2 個分片。
  • mongos:充當查詢路由器,提供客戶端應用程序和分片集群之間的接口。應用程序直接連接 mongos 即可,可以部署一個或多個。
  • config servers:配置服務器存儲集群的元數據和配置(包括權限認證相關)。從 MongoDB 3.4 開始,必須將配置服務器部署為副本集(CSRS,全稱是 Config Servers Replica Set)。

MongoDB 分片集群的架構圖如下所示,該圖片引用自 MongoDB 官網文檔

分片集群環境搭建

接下來我們開始准備部署一個具有 2 個 shard(3 節點副本集)加 1 個 config server(3 節點副本集)加 2 個 mongos 的分片集群。

搭建環境

搭建環境如下:

虛擬機一

虛擬機二

虛擬機三

搭建步驟

MongoDB 分片集群搭建並不復雜,以下只描述關鍵步驟。

步驟 1. 配置文件

下載官方 MongoDB 4.0.12 版本的二進制包,按照如下步驟修改配置文件。

  1. 每台虛擬機的 27018 實例即分片 1 的配置文件需要配置以下關鍵參數:
    1
    2
    replSet = rep_shard1 # 副本集名稱
    shardsvr = true # 3.4 版本之后必須明確指定該參數,3.4 版本之前該參數其實無實用性
  2. 每台虛擬機的 27019 實例即分片 2 的配置文件需要配置以下關鍵參數:
    1
    2
    replSet = rep_shard2
    shardsvr = true
  3. 每台虛擬機的 20000 實例即配置服務器的配置文件需要配置以下關鍵參數:
    1
    2
    replSet = rep_confsvr
    configsvr = true
  4. 虛擬機一和虛擬機二的 27017 實例即 mongos 路由的配置文件需要配置以下關鍵參數:
    1
    configdb = rep_confsvr/10.0.4.6:20000,10.0.4.7:20000,10.0.4.8:20000 # 指定配置服務器

步驟 2. 建立相關目錄並啟動副本集實例

關於如何配置副本集可以參考 MongoDB 副本集實戰,然后按照下列順序執行。

  1. 啟動每台虛擬機的 27018 實例即分片 1 並配置副本集。
  2. 啟動每台虛擬機的 27019 實例即分片 2 並配置副本集。
  3. 啟動每台虛擬機的 20000 實例即配置服務器並配置副本集。
  4. 啟動虛擬機一和虛擬機二的 27017 實例即 mongos 路由,注意這里是通過 mongos 啟動非 mongod。

步驟 3. 配置分片集群

登錄虛擬機一的 mongos 終端,和普通登錄 MongoDB 一樣,只需將端口改成 mongos 的端口 27017 即可,運行以下命令將 rep_shard1 和 rep_shard2 分片加入集群,到此一個分片環境已經搭建完成。

1
2
sh.addShard("rep_shard1/10.0.4.6:27018,10.0.4.7:27018,10.0.4.8:27018")
sh.addShard("rep_shard2/10.0.4.6:27019,10.0.4.7:27019,10.0.4.8:27019")

步驟 4. 驗證分片集群是否搭建成功

通過運行 sh.status() 命令可以查看分片相關信息,如有以下輸出,說明分片集群搭建成功。

1
2
3
shards:
{  "_id" : "rep_shard1",  "host" : "rep_shard1/10.0.4.6:27018,10.0.4.7:27018,10.0.4.8:27018",  "state" : 1 }
{  "_id" : "rep_shard2",  "host" : "rep_shard2/10.0.4.6:27019,10.0.4.7:27019,10.0.4.8:27019",  "state" : 1 }

整個分片集群搭建成功的關鍵點為各個角色的配置文件需要配置正確,副本集的配置不能有誤,如果說需要配置權限認證相關,最好在開始規划集群的時候就定下來。在生產環境下,可以一台服務器掛載多個硬盤,每個硬盤對應一個分片實例,這樣可以將資源最大化利用,此種搭建方法需要注意 MongoDB 實例內存的限制。

分片集群操作的相關概念

為了更好地理解 MongoDB 分片集群的運行原理,需要對以下核心概念有所了解。

Shard Key(分片鍵)

MongoDB 通過定義 shared key(分片鍵)從而對整個集合進行分片,分片鍵的好壞直接影響到整個集群的性能。另外需要注意的是,一個集合只有且只能有一個分片鍵,一旦分片鍵確定好之后就不能更改。分片鍵分為以下兩種類型:

  • 基於 Hashed 的分片:MongoDB 會計算分片鍵字段值的哈希值,用以確定該文檔存於哪個 chunk(參見下文 “Chunk(塊)“的介紹),從而達到將集合分攤到不同的 chunk。此種類型能夠使得數據整體分布比較均勻,對於等值查詢效率很高,但是對於范圍查詢效率就比較低,因為可能要掃描所有的分片才能獲取到數據。
  • 基於 Ranged 的分片:MongoDB 會將相似的值放到一個 chunk 中,所以說如果在查詢的時候帶上分片鍵的范圍條件,查詢效率會非常高,因為不需要掃描所有的分片就可以定位到數據。注意,如果片鍵的值為單調遞增或單調遞減,那么
  • 適合采用該分片策略,因為數據總會寫到一個分片,從而沒有很好地分散 IO。

分片鍵的類型需要根據實際的業務場景決定,例如有張非常大的用戶表,用戶表里有用戶 ID 字段,每次查詢的時候都會帶上用戶 ID,如果想對該用戶表進行分片,可以選擇將用戶 ID 字段作為 shard key,並且分片鍵類型可以使用基於 Hashed 的分片。

Chunk(塊)

chunk(塊)是均衡器遷移數據的最小單元,默認大小為 64MB,取值范圍為 1-1024MB。一個塊只存在於一個分片,每個塊由片鍵特定范圍內的文檔組成,塊的范圍為左閉又開即 [start,end)。一個文檔屬於且只屬於一個塊,當一個塊增加到特定大小的時候,會通過拆分點(split point)被拆分成 2 個較小的塊。在有些情況下,chunk 會持續增長,超過 ChunkSize,官方稱為 jumbo chunk,該塊無法被 MongoDB 拆分,也不能被均衡器(參見下文 “blancer(均衡器)” 的介紹)遷移,故久而久之會導致 chunk 在分片服務器上分布不均勻,從而成為性能瓶頸,表現之一為 insert 數據變慢。

Chunk 的拆分

mongos 會記錄每個塊中有多少數據,一旦達到了閾值就會檢查是否需要對其進行拆分,如果確實需要拆分則可以在配置服務器上更新這個塊的相關元信息。

chunk 的拆分過程如下:

  1. mongos 接收到客戶端發起的寫請求后會檢查當前塊的拆分閾值點。
  2. 如果需要拆分,mongos 則會像分片服務器發起一個拆分請求。
  3. 分片服務器會做拆分工作,然后將信息返回 mongos。

注意,相同的片鍵只能保存在相同的塊中,如果一個相同的片鍵過多,則會導致一個塊過大,成為 jumbo chunk,所以具有不同值的片鍵很重要。

Chunk 的遷移

chunk 在以下情況會發生遷移:

  • chunk 數位於 [1,20),閾值為 2。
  • chunk 數位於 [20,80),閾值為 4。
  • chunk 數位於 [80,max),閾值為 8。

chunk 的遷移過程如下,可以參考官方文檔

  1. 均衡器進程發送 moveChunk 命令到源分片。
  2. 源分片使用內部 moveChunk 命令,在遷移過程,對該塊的操作還是會路由到源分片。
  3. 目標分片構建索引。
  4. 目標分片開始進行數據復制。
  5. 復制完成后會同步在遷移過程中該塊的更改。
  6. 同步完成后源分片會連接到配置服務器,使用塊的新位置更新集群元數據。
  7. 源分片完成元數據更新后,一旦塊上沒有打開的游標,源分片將刪除其文檔副本。

遷移過程可確保一致性,並在平衡期間最大化塊的可用性。

修改 chunk 大小的注意事項

修改 chunk 大小需要注意以下幾點:

  1. chunk 的自動拆分操作僅發生在插入或更新的時候。
  2. 如果減少 chunk size,將會耗費一些時間將原有的 chunk 拆分到新 chunk,並且此操作不可逆。
  3. 如果新增 chunk size,已存在的 chunk 只會等到新的插入或更新操作將其擴充至新的大小。
  4. chunk size 的可調整范圍為 1-1024MB。

Balancer(均衡器)

MongoDB 的 balancer(均衡器)是監視每個分片的 chunk 數的一個后台進程。當分片上的 chunk 數達到特定遷移閾值時,均衡器會嘗試在分片之間自動遷移塊,使得每個分片的塊的數量達到平衡。分片群集的平衡過程對用戶和應用程序層完全透明,但在執行過程時可能會對性能產生一些影響。

從 MongoDB 3.4 開始,balancer 在配置服務器副本集(CSRS)的主服務器上運行,

在 3.4 版本中,當平衡器進程處於活動狀態時,主配置服務器的的 locks 集合通過修改 _id: "balancer" 文檔會獲取一個 balancer lock,該 balancer lock 不會被釋放,是為了保證只有一個 mongos 實例能夠在分片集群中執行管理任務。從 3.6 版本開始,均衡器不再需要 balancer lock。

均衡器可以動態的開啟和關閉,也可以針對指定的集合開啟和關閉,還可以手動控制均衡器遷移 chunk 的時間,避免在業務高峰期的時候遷移 chunk 從而影響集群性能。以下命令將均衡器的遷移 chunk 時間控制在凌晨 02 點至凌晨 06 點:

1
2
3
4
5
6
use config
db.settings.update(
    { _id: "balancer" },
    { $set: { activeWindow : { start : "02:00", stop : "06:00" } } },
    { upsert: true }
)

分片集群操作實戰

了解了一些基本概念后,我們就可以來做一些實戰操作,假設在 test 庫下有個空集合為 test_shard,注意這里是個空集合,該集合有年齡字段,我們將選擇年齡字段作為分片鍵分別進行范圍分片和哈希分片。

為了觀察效果,提前將 chunk 的大小調整為 1MB,並且所有的操作都在 mongos 節點執行,隨便哪個 mongos 都可以執行。以下為命令和輸出示例:

1
2
3
4
5
6
7
8
9
10
11
use config
db.settings.save({_id:"chunksize",value:1})
db.serverStatus().sharding
{
     "configsvrConnectionString" : "confsvr/10.0.4.6:20000,10.0.4.7:20000,10.0.4.8:20000",
     "lastSeenConfigServerOpTime" : {
         "ts" : Timestamp(1566895485, 2),
         "t" : NumberLong(16)
     },
     "maxChunkSizeInBytes" : NumberLong(1048576)
}

基於 Ranged 的分片操作

基於范圍分片特別適合范圍查找,因為可以直接定位到分片,所以效率很高。

以下為具體操作步驟:

  1. 開啟 test 庫的分片功能。
    1
    sh.enableSharding("test")
  2. 選擇集合的分片鍵,此時 MongoDB 會自動為 age 字段創建索引。
    1
    sh.shardCollection("test.test_shard",{"age": 1})
  3. 批量造測試數據。
    1
    2
    3
    use test
    for (i = 1; i < = 20000; i++) db.test_shard.insert({age:(i%100), name:"user"+i,
    create_at:new Date()})
  4. 觀察分片效果。以下為命令和部分輸出示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    sh.status()
    test.test_shard
    shard key: { "age" : 1 }
    unique: false
    balancing: true
    chunks:
             rep_shard1  2
             rep_shard2  3
    { "age" : { "$minKey" : 1 } } --<< { "age" : 0 } on : rep_shard1 Timestamp(2, 0)
    { "age" : 0 } --<< { "age" : 36 } on : rep_shard1 Timestamp(3, 0)
    { "age" : 36 } --<< { "age" : 73 } on : rep_shard2 Timestamp(2, 3)
    { "age" : 73 } --<< { "age" : 92 } on : rep_shard2 Timestamp(2, 4)
    { "age" : 92 } --<< { "age" : { "$maxKey" : 1 } } on : rep_shard2 Timestamp(3, 1)

從輸出結果可以看到 test.test_shard 集合總共有 2 個分片,分片 rep_shard1 上有 2 個 chunk,分片 rep_shard2 上有 3 個 chunk,年齡大於或等於 0 並且小於 36 的文檔數據放到了第一個分片 rep_shard1,年齡大於或等於 36 並且小於 73 的文檔數據放到了第二個分片 rep_shard2,此時已經達到了分片的效果。我們可以使用 find 命令來確認是否對應的數據存在相應的分片,以下為命令和部分輸出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
db.test_shard.find({ age: { $gte : 36 ,$lt : 73 } }).explain()
{
     "queryPlanner" : {
         "winningPlan" : {
             "stage" : "SINGLE_SHARD",
             "shards" : [
                 {
                     "shardName" : "rep_shard2",
                     "connectionString" : "rep_shard2/10.0.4.6:27019,10.0.4.7:27019,10.0.4.8:27019",
                     "namespace" : "test.test_shard",
                     "winningPlan" : {
                         "stage" : "FETCH",
                         "inputStage" : {
                             "stage" : "SHARDING_FILTER",
                             "inputStage" : {
                                 "stage" : "IXSCAN",
                                 "keyPattern" : {
                                     "age" : 1
                                 },
                                 "indexName" : "age_1",
                                 "direction" : "forward",
                                 "indexBounds" : {
                                     "age" : [
                                         "[36.0, 73.0)"
                                     ]
                                 }
                             }
                         }
                     },
                 }
             ]
         }
     }
}

從以上輸出結果可以看到,當查找年齡范圍為大於等於 36 並且小於 73 的文檔數據,MongoDB 會直接定位到分片 rep_shard2,從而避免全分片掃描以提高查找效率。如果將 $gte : 36 改為 $gte : 35,結果會是怎么樣的呢?答案是 MongoDB 會掃描全部分片,執行計划的結果將由 SINGLE_SHARD 變為 SHARD_MERGE,如果感興趣,您可以自行驗證。

基於 Hashed 的分片操作

為了和基於范圍分片形成對比,這一步操作使用相同的測試數據。操作步驟如下所示。

  1. 開啟 test 庫的分片功能。
    1
    sh.enableSharding("test")
  2. 選擇集合的分片鍵,注意這里創建的是 hash 索引。
    1
    sh.shardCollection("test.test_shard",{"age": "hashed"})
  3. 批量造測試數據。
    1
    2
    use test
    for (i = 1; i <= 20000; i++) db.test_shard.insert({age:(i%100), name:"user"+i, create_at:new Date()})
  4. 觀察分片效果。以下為命令和部分輸出示例:
    1
    2
    3
    4
    5
    6
    7
    8
    sh.status()
    chunks:
             rep_shard1  2
             rep_shard2  2
    { "age" : { "$minKey" : 1 } } --<< { "age" : NumberLong("-4611686018427387902") } on : rep_shard1 Timestamp(1, 0)
    { "age" : NumberLong("-4611686018427387902") } --<< { "age" : NumberLong(0) } on : rep_shard1 Timestamp(1, 1)
    { "age" : NumberLong(0) } --<< { "age" : NumberLong("4611686018427387902") } on : rep_shard2 Timestamp(1, 2)
    { "age" : NumberLong("4611686018427387902") } --<< { "age" : { "$maxKey" : 1 } } on : rep_shard2 Timestamp(1, 3)

從輸出結果可以看到總共有 4 個 chunk,分片 rep_shard1 有 2 個 chunk,分片 rep_shard2 有 2 個 chunk,分片后按照分片值 hash 后,存放到對應不同的分片。現在我們來將使用同一查詢,看看在基於 Hashed 分片和基於 Ranged 分片的效果,以下為命令和部分輸出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
db.test_shard.find({ age: { $gte : 36 ,$lt : 73 } }).explain()
{
     "queryPlanner" : {
         "winningPlan" : {
             "stage" : "SHARD_MERGE",
             "shards" : [
                 {
                     "shardName" : "rep_shard1",
                     "connectionString" : "rep_shard1/10.0.4.6:27018,10.0.4.7:27018,10.0.4.8:27018",
                     "winningPlan" : {
                         "stage" : "SHARDING_FILTER",
                         "inputStage" : {
                             "stage" : "COLLSCAN",                       }
                     }
                 {
                     "shardName" : "rep_shard2",
                     "connectionString" : "rep_shard2/10.0.4.6:27019,10.0.4.7:27019,10.0.4.8:27019",
                     "winningPlan" : {
                         "stage" : "SHARDING_FILTER",
                         "inputStage" : {
                             "stage" : "COLLSCAN",
                         }
                     }
         }
     }

從以上結果可以看到,對於范圍查找,基於 Hashed 的分片很可能需要全部分片都掃描一遍才能找到對應的數據,效率比較低下,如果等值查找,效率會高些,接下來我們來驗證。

同樣還是數據不變,我們將查詢改為只查找年齡為 36 歲的文檔數據。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
     db.test_shard.find({ age: 36 }).explain()
     {
     "queryPlanner" : {
         "winningPlan" : {
             "stage" : "SINGLE_SHARD",
             "shards" : [
                 {
                     "shardName" : "rep_shard2",
                     "connectionString" : "rep_shard2/10.0.4.6:27019,10.0.4.7:27019,10.0.4.8:27019",
                     "namespace" : "test.test_shard",
                     "parsedQuery" : {
                         "age" : {
                             "$eq" : 36
                         }
                     },
                     "winningPlan" : {
                         "stage" : "SHARDING_FILTER",
                         "inputStage" : {
                             "stage" : "FETCH",
                             "inputStage" : {
                                 "stage" : "IXSCAN",
                                 "keyPattern" : {
                                     "age" : "hashed"
                                 },
                                 "indexName" : "age_hashed",
                                 "indexBounds" : {
                                     "age" : [
                                         "[7618808261848727468, 7618808261848727468]"
                                     ]
                                 }
                             }
                         }
                     }
                 }
             ]
         }
     }
}

通過以上輸出可以看到,對於等值查找,基於 Hashed 分片查找效率很高,直接定位到一個分片就可以返回滿足條件的數據,無需進行全部分片的查找。

小結

通上述兩個實驗演示了兩種分片方法的優缺點,在真實環境中,需要根據實際業務場景去選擇分片方法,這樣才能構建一個高性能的集群環境。以上的操作步驟都是對一個空的集合進行分片,如果對於一個已經存在的集合分片,操作步驟會有所不同,需要提前手動對集合的片鍵建立相關類型的索引,如果是基於 Hashed 分片,需要建立 hash 索引。

結束語

通過閱讀本文,可以讓您對 MongoDB 的分片集群以及集群中的一些組件有個基本認識,同時通過進行一個簡單的實驗闡述了兩種分片方法的優缺點以及適合場景,相信您在面對 MongoDB 里的海量數據,已不會感到恐懼。

參考資源


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM