作者: 凹凸曼-軍軍
前言:mongodb 因為高性能、高可用性、支持分片等特性,作為非關系型數據庫被大家廣泛使用。其高可用性主要是體現在 mongodb 的副本集上面(可以簡單理解為一主多從的集群),本篇文章主要從副本集介紹、本地搭建副本集、副本集讀寫數據這三個方面來帶大家認識下 mongodb 副本集。
一、 mongodb 副本集介紹
mongodb 副本集(Replica Set)包括主節點(primary)跟副本節點(Secondaries)。
主節點只能有一個,所有的寫操作請求都在主節點上面處理。副本節點可以有多個,通過同步主節點的操作日志(oplog)來備份主節點數據。
在主節點掛掉后,有選舉權限的副本節點會自動發起選舉,並從中選舉出新的主節點。
副本節點可以通過配置指定其具體的屬性,比如選舉、隱藏、延遲同步等,最多可以有50個副本節點,但只能有7個副本節點能參與選舉。雖然副本節點不能處理寫操作,但可以處理讀請求,這個下文會專門講到。
搭建一個副本集集群最少需要三個節點:一個主節點,兩個備份節點,如果三個節點分布合理,基本可以保證線上數據99.9%安全。三個節點的架構如下圖所示:
如果只有一個主節點,一個副本節點,且沒有資源拿來當第二個副本節點,那就可以起一個仲裁者節點(arbiter),不存數據,只用來選舉用,如下圖所示:
當主節點掛掉后,那么兩個副本節點會進行選舉,從中選舉出一個新的主節點,流程如下:
對於副本集成員屬性,特別需要說明下這幾個:priority、hidden、slaveDelay、tags、votes。
- priority
對於副本節點,可以通過該屬性來增大或者減小該節點被選舉成為主節點的可能性,取值范圍為0-1000(如果是arbiters,則取值只有0或者1),數據越大,成為主節點的可能性越大,如果被配置為0,那么他就不能被選舉成為主節點,而且也不能主動發起選舉。
這種特性一般會被用在有多個數據中心的情況下,比如一個主數據中心,一個備份數據中心,主數據中心速度會更快,如果主節點掛掉,我們肯定希望新主節點也在主數據中心產生,那么我們就可以設置在備份數據中心的副本節點優先級為0,如下圖所示:
-
hidden
隱藏節點會從主節點同步數據,但對客戶端不可見,在mongo shell 執行 db.isMaster() 方法也不會展示該節點,隱藏節點必須Priority為0,即不可以被選舉成為主節點。但是如果有配置選舉權限的話,可以參與選舉。
因為隱藏節點對客戶端不可見,所以跟客戶端不會互相影響,可以用來備份數據或者跑一些后端定時任務之類的操作,具體如下圖,4個備份節點都從主節點同步數據,其中1個為隱藏節點:
-
slaveDelay
延遲同步即延遲從主節點同步數據,比如延遲時間配置的1小時,現在時間是 09:52,那么延遲節點中只同步到主節點 08:52 之前的數據。另外需要注意延遲節點必須是隱藏節點,且Priority為0。
那這個延遲節點有什么用呢?有過數據庫誤操作慘痛經歷的開發者肯定知道答案,那就是為了防止數據庫誤操作,比如更新服務前,一般會先執行數據庫更新腳本,如果腳本有問題,且操作前未做備份,那數據可能就找不回了。但如果說配置了延遲節點,那誤操作完,還有該節點可以兜底,只能說該功能真是貼心。具體延遲節點如下圖所展示:
-
tags
支持對副本集成員打標簽,在查詢數據時會用到,比如找到對應標簽的副本節點,然后從該節點讀取數據,這點也非常有用,可以根據標簽對節點分類,查詢數據時不同服務的客戶端指定其對應的標簽的節點,對某個標簽的節點數量進行增加或減少,也不怕會影響到使用其他標簽的服務。Tags 的具體使用,文章下面章節也會講到。
-
votes
表示節點是否有權限參與選舉,最大可以配置7個副本節點參與選舉。
二、副本集的搭建以及測試
安裝mongodb 教程:https://docs.mongodb.com/manual/installation/
我們來搭建一套 P-S-S 結構的副本集(1個 Primary 節點,2個 Secondary 節點),大致過程為:先啟動三個不同端口的 mongod 進程,然后在 mongo shell 中執行命令初始化副本集。
啟動單個mongod 實例的命令為:
mongod --replSet rs0 --port 27017 --bind_ip localhost,<hostname(s)|ip address(es)> --dbpath /data/mongodb/rs0-0 --oplogSize 128
參數說明:
參數 | 說明 | 示例 |
---|---|---|
replSet | 副本集名稱 | rs0 |
port | mongod 實例端口 | 27017 |
bind_ip | 訪問該實例的地址列表,只是本機訪問可以設置為localhost 或者 127.0.0.1,生產環境建議使用內部域名 | Localhost |
dbpath | 數據存放位置 | /data/mongodb/rs0-0 |
oplogSize | 操作日志大小 | 128 |
搭建步驟如下:
-
先創建三個目錄來分別存放這三個節點的數據
mkdir -p /data/mongodb/rs0-0 /data/mongodb/rs0-1 /data/mongodb/rs0-2
-
分別啟動三個mongod 進程,端口分別為:27018,27019,27020
第一個:
mongod --replSet rs0 --port 27018 --bind_ip localhost --dbpath /data/mongodb/rs0-0 --oplogSize 128
第二個:
mongod --replSet rs0 --port 27019 --bind_ip localhost --dbpath /data/mongodb/rs0-1 --oplogSize 128
第三個:
mongod --replSet rs0 --port 27020 --bind_ip localhost --dbpath /data/mongodb/rs0-2 --oplogSize 128
- 使用 mongo 進入第一個 mongod 示例,使用 rs.initiate() 進行初始化
登錄到27018: mongo localhost:27018
執行:
rsconf = {
_id: "rs0",
members: [
{
_id: 0,
host: "localhost:27018"
},
{
_id: 1,
host: "localhost:27019"
},
{
_id: 2,
host: "localhost:27020"
}
]
}
rs.initiate( rsconf )
以上就已經完成了一個副本集的搭建,在 mongo shell 中執行 rs.conf() 可以看到每個節點中 host、arbiterOnly、hidden、priority、 votes、slaveDelay等屬性,是不是超級簡單。。
執行 rs.conf() ,結果展示如下:
rs.conf()
{
"_id" : "rs0",
"version" : 1,
"protocolVersion" : NumberLong(1),
"writeConcernMajorityJournalDefault" : true,
"members" : [
{
"_id" : 0,
"host" : "localhost:27018",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
},
{
"_id" : 1,
"host" : "localhost:27019",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
},
{
"_id" : 2,
"host" : "localhost:27020",
"arbiterOnly" : false,
"buildIndexes" : true,
"hidden" : false,
"priority" : 1,
"tags" : {
},
"slaveDelay" : NumberLong(0),
"votes" : 1
}
],
"settings" : {
"chainingAllowed" : true,
"heartbeatIntervalMillis" : 2000,
"heartbeatTimeoutSecs" : 10,
"electionTimeoutMillis" : 10000,
"catchUpTimeoutMillis" : -1,
"catchUpTakeoverDelayMillis" : 30000,
"getLastErrorModes" : {
},
"getLastErrorDefaults" : {
"w" : 1,
"wtimeout" : 0
},
"replicaSetId" : ObjectId("5f957f12974186fc616688fb")
}
}
特別注意下:在 mongo shell 中,有 rs 跟 db。
- rs 是指副本集,有rs.initiate(),rs.conf(), rs.reconfig(), rs.add() 等操作副本集的方法
- db 是指數據庫,其下是對數據庫的一些操作,比如下面會用到 db.isMaster(), db.collection.find(), db.collection.insert() 等。
我們再來測試下 Automatic Failover
- 可以直接停掉主節點localhost:27018 來測試下主節點掛掉后,副本節點重新選舉出新的主節點,即自動故障轉移(Automatic Failover)
殺掉主節點 27018后,可以看到 27019 的輸出日志里面選舉部分,27019 發起選舉,並成功參選成為主節點:
2020-10-26T21:43:58.156+0800 I REPL [replexec-304] Scheduling remote command request for vote request: RemoteCommand 100694 -- target:localhost:27018 db:admin cmd:{ replSetRequestVotes: 1, setName: "rs0", dryRun: false, term: 17, candidateIndex: 1, configVersion: 1, lastCommittedOp: { ts: Timestamp(1603719830, 1), t: 16 } }
2020-10-26T21:43:58.156+0800 I REPL [replexec-304] Scheduling remote command request for vote request: RemoteCommand 100695 -- target:localhost:27020 db:admin cmd:{ replSetRequestVotes: 1, setName: "rs0", dryRun: false, term: 17, candidateIndex: 1, configVersion: 1, lastCommittedOp: { ts: Timestamp(1603719830, 1), t: 16 } }
2020-10-26T21:43:58.159+0800 I ELECTION [replexec-301] VoteRequester(term 17) received an invalid response from localhost:27018: ShutdownInProgress: In the process of shutting down; response message: { operationTime: Timestamp(1603719830, 1), ok: 0.0, errmsg: "In the process of shutting down", code: 91, codeName: "ShutdownInProgress", $clusterTime: { clusterTime: Timestamp(1603719830, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } } }
2020-10-26T21:43:58.164+0800 I ELECTION [replexec-305] VoteRequester(term 17) received a yes vote from localhost:27020; response message: { term: 17, voteGranted: true, reason: "", ok: 1.0, $clusterTime: { clusterTime: Timestamp(1603719830, 1), signature: { hash: BinData(0, 0000000000000000000000000000000000000000), keyId: 0 } }, operationTime: Timestamp(1603719830, 1) }
2020-10-26T21:43:58.164+0800 I ELECTION [replexec-304] election succeeded, assuming primary role in term 17
- 然后執行 rs.status() 查看當前副本集情況,可以看到27019變為主節點,27018 顯示已掛掉 health = 0
rs.status()
{
"set" : "rs0",
"date" : ISODate("2020-10-26T13:44:22.071Z"),
"myState" : 1,
"heartbeatIntervalMillis" : NumberLong(2000),
"majorityVoteCount" : 2,
"writeMajorityCount" : 2,
"members" : [
{
"_id" : 0,
"name" : "localhost:27018",
"ip" : "127.0.0.1",
"health" : 0,
"state" : 8,
"stateStr" : "(not reachable/healthy)",
"uptime" : 0,
"optime" : {
"ts" : Timestamp(0, 0),
"t" : NumberLong(-1)
},
"optimeDurable" : {
"ts" : Timestamp(0, 0),
"t" : NumberLong(-1)
},
"optimeDate" : ISODate("1970-01-01T00:00:00Z"),
"optimeDurableDate" : ISODate("1970-01-01T00:00:00Z"),
"lastHeartbeat" : ISODate("2020-10-26T13:44:20.202Z"),
"lastHeartbeatRecv" : ISODate("2020-10-26T13:43:57.861Z"),
"pingMs" : NumberLong(0),
"lastHeartbeatMessage" : "Error connecting to localhost:27018 (127.0.0.1:27018) :: caused by :: Connection refused",
"syncingTo" : "",
"syncSourceHost" : "",
"syncSourceId" : -1,
"infoMessage" : "",
"configVersion" : -1
},
{
"_id" : 1,
"name" : "localhost:27019",
"ip" : "127.0.0.1",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 85318,
"optime" : {
"ts" : Timestamp(1603719858, 1),
"t" : NumberLong(17)
},
"optimeDate" : ISODate("2020-10-26T13:44:18Z"),
"syncingTo" : "",
"syncSourceHost" : "",
"syncSourceId" : -1,
"infoMessage" : "",
"electionTime" : Timestamp(1603719838, 1),
"electionDate" : ISODate("2020-10-26T13:43:58Z"),
"configVersion" : 1,
"self" : true,
"lastHeartbeatMessage" : ""
},
{
"_id" : 2,
"name" : "localhost:27020",
"ip" : "127.0.0.1",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 52468,
"optime" : {
"ts" : Timestamp(1603719858, 1),
"t" : NumberLong(17)
},
"optimeDurable" : {
"ts" : Timestamp(1603719858, 1),
"t" : NumberLong(17)
},
"optimeDate" : ISODate("2020-10-26T13:44:18Z"),
"optimeDurableDate" : ISODate("2020-10-26T13:44:18Z"),
"lastHeartbeat" : ISODate("2020-10-26T13:44:20.200Z"),
"lastHeartbeatRecv" : ISODate("2020-10-26T13:44:21.517Z"),
"pingMs" : NumberLong(0),
"lastHeartbeatMessage" : "",
"syncingTo" : "localhost:27019",
"syncSourceHost" : "localhost:27019",
"syncSourceId" : 1,
"infoMessage" : "",
"configVersion" : 1
}
]
}
- 再次啟動27018:
mongod --replSet rs0 --port 27018 --bind_ip localhost --dbpath /data/mongodb/rs0-0 --oplogSize 128
可以在節點 27019 日志中看到已檢測到 27018,並且已變為副本節點,通過rs.status 查看結果也是如此。
2020-10-26T21:52:06.871+0800 I REPL [replexec-305] Member localhost:27018 is now in state SECONDARY
三、副本集寫跟讀的一些特性
寫關注(Write concern)
副本集寫關注是指寫入一條數據,主節點處理完成后,需要其他承載數據的副本節點也確認寫成功后,才能給客戶端返回寫入數據成功。
這個功能主要是解決主節點掛掉后,數據還未來得及同步到副本節點,而導致數據丟失的問題。
可以配置節點個數,默認配置 {“w”:1},這樣表示主節點寫入數據成功即可給客戶端返回成功,“w” 配置為2,則表示除了主節點,還需要收到其中一個副本節點返回寫入成功,“w” 還可以配置為 "majority",表示需要集群中大多數承載數據且有選舉權限的節點返回寫入成功。
如下圖所示,P-S-S 結構(一個 primary 節點,兩個 secondary 節點),寫請求里面帶了w : “majority" ,那么主節點寫入完成后,數據同步到第一個副本節點,且第一個副本節點回復數據寫入成功后,才給客戶端返回成功。
關於寫關注在實際中如何操作,有下面兩種方法:
- 在寫請求中指定 writeConcern 相關參數,如下:
db.products.insert(
{ item: "envelopes", qty : 100, type: "Clasp" },
{ writeConcern: { w: "majority" , wtimeout: 5000 } }
)
- 修改副本集 getLastErrorDefaults 配置,如下:
cfg = rs.conf()
cfg.settings.getLastErrorDefaults = { w: "majority", wtimeout: 5000 }
rs.reconfig(cfg)
讀偏好 (Read preference)
讀跟寫不一樣,為了保持一致性,寫只能通過主節點,但讀可以選擇主節點,也可以選擇副本節點,區別是主節點數據最新,副本節點因為同步問題可能會有延遲,但從副本節點讀取數據可以分散對主節點的壓力。
因為承載數據的節點會有多個,那客戶端如何選擇從那個節點讀呢?主要有3個條件(Tag Sets、 maxStalenessSeconds、Hedged Read),5種模式(primary、primaryPreferred、secondary、secondaryPreferred、nearest)
首先說一下 5種模式,其特點如下表所示:
模式 | 特點 |
---|---|
primary | 所有讀請求都從主節點讀取 |
primaryPreferred | 主節點正常,則所有讀請求都從主節點讀取,如果主節點掛掉,則從符合條件的副本節點讀取 |
secondary | 所有讀請求都從副本節點讀取 |
secondaryPreferred | 所有讀請求都從副本節點讀取,但如果副本節點都掛掉了,那就從主節點讀取 |
nearest | 主要看網絡延遲,選取延遲最小的節點,主節點跟副本節點均可 |
再說下3個條件,條件是在符合模式的基礎上,再根據條件刪選具體的節點
- Tag Sets(標簽)
顧名思義,這個可以給節點加上標簽,然后查找數據時,可以根據標簽選擇對應的節點,然后在該節點查找數據。可以通過mongo shell 使用 rs.conf() 查看當前每個節點下面的 tags, 修改或者添加tags 過程同上面修改 getLastErrorDefaults 配置 ,如: cfg.members[n].tags = { "region": "South", "datacenter": "A" }
- maxStalenessSeconds (可容忍的最大同步延遲)
顧名思義+1,這個值是指副本節點同步主節點寫入的時間 跟 主節點實際最近寫入時間的對比值,如果主節點掛掉了,那就跟副本集中最新寫入的時間做對比。
這個值建議設置,避免因為部分副本節點網絡原因導致比較長時間未同步主節點數據,然后讀到比較老的數據。特別注意的是該值需要設置 90s 以上,因為客戶端是定時去校驗副本節點的同步延遲時間,數據不會特別准確,設置比 90s 小,會拋出異常。
- Hedged Read (對沖讀取)
該選項是在分片集群 MongoDB 4.4 版本后才支持,指 mongos 實例路由讀取請求時會同時發給兩個符合條件的副本集節點,然后那個先返回結果就返回這個結果給客戶端。
那問題來了,如此好用的模式以及條件在查詢請求中如何使用呢?
- 在代碼中連接數據庫,使用 connection string uri 時,可以加上下面的這三個參數
參數 | 說明 |
---|---|
readPreference | 模式,枚舉值有:primary(默認值)、 primaryPreferred、secondary、secondaryPreferred、nearest |
maxStalenessSeconds | 最大同步延時秒數,取值0 - 90 會報錯, -1 表示沒有最大值 |
readPreferenceTags | 標簽,如果標簽是 { "dc": "ny", "rack": "r1" }, 則在uri 為 readPreferenceTags=dc:ny,rack:r1 |
例如下面:
mongodb://db0.example.com,db1.example.com,db2.example.com/?replicaSet=myRepl&readPreference=secondary&maxStalenessSeconds=120&readPreferenceTags=dc:ny,rack:r1
- 在mogo shell 中,可以使用 cursor.readPref() 或者 Mongo.setReadPref()
cursor.readPref() 參數分別為: mode、tag set、hedge options, 具體請求例如下面這樣
db.collection.find({ }).readPref(
"secondary", // mode
[ { "datacenter": "B" }, { } ], // tag set
{ enabled: true } // hedge options
)
Mongo.setReadPref() 類似,只是預先設置請求條件,這樣就不用每個請求后面帶上 readPref 條件。
可以在搭建好的集群中簡單測試下該功能
-
登錄主節點:
mongo localhost:27018
-
插入一條數據:
db.nums.insert({name: “num0”})
在當前節點查詢:
db.nums.find()
可以看到本條數據:
{ "_id" : ObjectId("5f958687233b11771912ced5"), "name" : "num0" }
-
登錄副本節點:
mongo localhost:27019
查詢:db.nums.find()
因為查詢模式默認為 primary,所以在副本節點查詢會報錯,如下:
Error: error: {
"operationTime" : Timestamp(1603788383, 1),
"ok" : 0,
"errmsg" : "not master and slaveOk=false",
"code" : 13435,
"codeName" : "NotMasterNoSlaveOk",
"$clusterTime" : {
"clusterTime" : Timestamp(1603788383, 1),
"signature" : {
"hash" : BinData(0,"AAAAAAAAAAAAAAAAAAAAAAAAAAA="),
"keyId" : NumberLong(0)
}
}
}
查詢時指定模式為 “secondary”: db.nums.find().readPref(“secondary")
就可以查詢到插入的數據: { "_id" : ObjectId("5f958687233b11771912ced5"), "name" : "num0" }
結語
以上內容都是閱讀 MongoDB 官方文檔后,然后挑簡單且重要的一些點做的總結,如果大家對 MongoDB 感興趣,建議直接啃一啃官方文檔。
歡迎關注凹凸實驗室博客:aotu.io
或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章: