背景 在如今的互聯網環境下,海量數據已隨處可見並且還在不斷增長,對於如何存儲處理海量數據,比較常見的方法有兩種: 垂直擴展:通過增加單台伺服器的配置,例如使用更強悍的 CPU、更大的記憶體、更大容量的磁碟,此種方法雖然成本很高,但是實現比較簡單,維護起來也比較方便。 水平擴展:通過使用更多配置一般的服 ...
背景
在如今的互聯網環境下,海量數據已隨處可見並且還在不斷增長,對於如何存儲處理海量數據,比較常見的方法有兩種:
- 垂直擴展:通過增加單台伺服器的配置,例如使用更強悍的 CPU、更大的記憶體、更大容量的磁碟,此種方法雖然成本很高,但是實現比較簡單,維護起來也比較方便。
- 水平擴展:通過使用更多配置一般的伺服器來共同承擔工作負載,此種方法很靈活,可以根據工作負載的大小動態增減伺服器的數量,但是實現比較複雜,得有專門的人員來運維。
試用 IBM Cloud 上提供的 MongoDB 資料庫服務。
Hyper Protect DBaaS for MongoDB
試用 IBM Cloud 上提供的更加安全的 MongoDB 企業服務,您可以通過標準化的界面管理 MongoDB。
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 的分片集群。
搭建環境
搭建環境如下:
- OS: CentOS 7 64 位。
- Software: 採用的官方 64 位 MongoDB 4.0.12 二進位包。
- 準備三台虛擬機,按如下表格規劃每台虛擬機 MongoDB 實例的角色以及副本集名稱。
虛擬機一
角色 | IP | 埠 | 副本集名稱 |
---|---|---|---|
mongos | 10.0.4.6 | 27017 | |
shard1 | 10.0.4.6 | 27018 | rep_shard1 |
shard2 | 10.0.4.6 | 27019 | rep_shard2 |
confserver | 10.0.4.6 | 20000 | rep_confsvr |
虛擬機二
角色 | IP | 埠 | 副本集名稱 |
---|---|---|---|
mongos | 10.0.4.7 | 27017 | |
shard1 | 10.0.4.7 | 27018 | rep_shard1 |
shard2 | 10.0.4.7 | 27019 | rep_shard2 |
confserver | 10.0.4.7 | 20000 | rep_confsvr |
虛擬機三
角色 | IP | 埠 | 副本集名稱 |
---|---|---|---|
shard1 | 10.0.4.8 | 27018 | rep_shard1 |
shard2 | 10.0.4.8 | 27019 | rep_shard2 |
confserver | 10.0.4.8 | 20000 | rep_confsvr |
搭建步驟
MongoDB 分片集群搭建並不複雜,以下只描述關鍵步驟。
步驟 1. 配置文件
下載官方 MongoDB 4.0.12 版本的二進位包,按照如下步驟修改配置文件。
- 每台虛擬機的 27018 實例即分片 1 的配置文件需要配置以下關鍵參數:
1 2 replSet = rep_shard1 # 副本集名稱
shardsvr = true # 3.4 版本之後必須明確指定該參數,3.4 版本之前該參數其實無實用性
- 每台虛擬機的 27019 實例即分片 2 的配置文件需要配置以下關鍵參數:
1 2 replSet = rep_shard2
shardsvr = true
- 每台虛擬機的 20000 實例即配置伺服器的配置文件需要配置以下關鍵參數:
1 2 replSet = rep_confsvr
configsvr = true
- 虛擬機一和虛擬機二的 27017 實例即 mongos 路由的配置文件需要配置以下關鍵參數:
1 configdb = rep_confsvr/10.0.4.6:20000,10.0.4.7:20000,10.0.4.8:20000 # 指定配置伺服器
步驟 2. 建立相關目錄並啟動副本集實例
關於如何配置副本集可以參考 MongoDB 副本集實戰,然後按照下列順序執行。
- 啟動每台虛擬機的 27018 實例即分片 1 並配置副本集。
- 啟動每台虛擬機的 27019 實例即分片 2 並配置副本集。
- 啟動每台虛擬機的 20000 實例即配置伺服器並配置副本集。
- 啟動虛擬機一和虛擬機二的 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 的拆分過程如下:
- mongos 接收到客戶端發起的寫請求後會檢查當前塊的拆分閾值點。
- 如果需要拆分,mongos 則會像分片伺服器發起一個拆分請求。
- 分片伺服器會做拆分工作,然後將信息返回 mongos。
註意,相同的片鍵只能保存在相同的塊中,如果一個相同的片鍵過多,則會導致一個塊過大,成為 jumbo chunk,所以具有不同值的片鍵很重要。
Chunk 的遷移
chunk 在以下情況會發生遷移:
- chunk 數位於 [1,20),閾值為 2。
- chunk 數位於 [20,80),閾值為 4。
- chunk 數位於 [80,max),閾值為 8。
chunk 的遷移過程如下,可以參考官方文檔。
- 均衡器進程發送
moveChunk
命令到源分片。 - 源分片使用內部
moveChunk
命令,在遷移過程,對該塊的操作還是會路由到源分片。 - 目標分片構建索引。
- 目標分片開始進行數據複製。
- 複製完成後會同步在遷移過程中該塊的更改。
- 同步完成後源分片會連接到配置伺服器,使用塊的新位置更新集群元數據。
- 源分片完成元數據更新後,一旦塊上沒有打開的游標,源分片將刪除其文檔副本。
遷移過程可確保一致性,併在平衡期間最大化塊的可用性。
修改 chunk 大小的註意事項
修改 chunk 大小需要註意以下幾點:
- chunk 的自動拆分操作僅發生在插入或更新的時候。
- 如果減少 chunk size,將會耗費一些時間將原有的 chunk 拆分到新 chunk,並且此操作不可逆。
- 如果新增 chunk size,已存在的 chunk 只會等到新的插入或更新操作將其擴充至新的大小。
- 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 的分片操作
基於範圍分片特別適合範圍查找,因為可以直接定位到分片,所以效率很高。
以下為具體操作步驟:
- 開啟 test 庫的分片功能。
1 sh.enableSharding("test")
- 選擇集合的分片鍵,此時 MongoDB 會自動為 age 欄位創建索引。
1 sh.shardCollection("test.test_shard",{"age": 1})
- 批量造測試數據。
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()})
- 觀察分片效果。以下為命令和部分輸出示例:
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 的分片操作
為了和基於範圍分片形成對比,這一步操作使用相同的測試數據。操作步驟如下所示。
- 開啟 test 庫的分片功能。
1 sh.enableSharding("test")
- 選擇集合的分片鍵,註意這裡創建的是 hash 索引。
1 sh.shardCollection("test.test_shard",{"age": "hashed"})
- 批量造測試數據。
1 2 use test
for (i = 1; i <= 20000; i++) db.test_shard.insert({age:(i%100), name:"user"+i, create_at:new Date()})
- 觀察分片效果。以下為命令和部分輸出示例:
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 歲的文檔數據。