MongoDB 是一個強大的分布式存盤引擎,天然支持高可用、分布式和靈活設計,MongoDB 的一個很重要的設計理念是:服務端只關注底層核心能力的輸出,至于怎么用,就盡可能的將作業交個客戶端去決策,這也就是 MongoDB 靈活性的保證,但是靈活性帶來的代價就是使用成本的提升,與 MySql 相比,想要用好 MongoDB,減少在專案中出問題,用戶需要掌握的東西更多,本文致力于全方位的介紹 MongoDB 的理論和應用知識,目標是讓大家可以通過閱讀這篇文章之后能夠掌握 MongoDB 的常用知識,具備在實際專案中高效應用 MongoDB 的能力,
本文既有 MongoDB 基礎知識也有相對深入的進階知識,同時適用于對 MonogDB 感興趣的初學者或者希望對 MongoDB 有更深入了解的業務開發者,
前言
以下是筆者在學習和使用 MongoDB 程序中總結的 MongoDB 知識圖譜,本文將按照一下圖譜中依次介紹 MongoDB 的一些核心內容,由于能力和篇幅有限,本文并不會對圖譜中全部內容都做深入分析,后續將會針對特定條目做專門的分析,同時,如果圖譜和內容中有錯誤或疏漏的地方,也請大家隨意指正,筆者這邊會積極修正和完善,
本文按照圖譜從以下 3 個方面來介紹 MongoDB 相關知識:
- 基礎知識:主要介紹 MongoDB 的重要特性,No Schema、高可用、分布式擴展等特性,以及支撐這些特性的相關設計
- 應用接入:主要介紹 MongoDB 的一些測驗資料、接入方式、spring-data-mongo 應用以及使用 Mongo 的一些注意事項,
- 進階知識:主要介紹 MongoDB 的一些核心功能的設計實作,包括 WiredTiger 存盤引擎介紹、Page/Chunk 等資料結構、一致性/高可用保證、索引等相關知識,

第一部分:基礎知識
MongoDB 是基于檔案的 NoSql 存盤引擎,MongoDB 的資料庫管理由資料庫、Collection(集合,類似 MySql 的表)、Document(檔案,類似 MySQL 的行)組成,每個 Document 都是一個類 JSON 結構 BSON 結構資料,
MongoDB 的核心特性是:No Schema、高可用、分布式(可平行擴展),另外 MongoDB 自帶資料壓縮功能,使得同樣的資料存盤所需的資源更少,本節將會依次介紹這些特性的基本知識,以及 MongoDB 是如何實作這些能力的,
1.1 No Schema
MongoDB 是檔案型資料庫,其檔案組織結構是 BSON(Binary Serialized Document Format) 是類 JSON 的二進制存盤格式,資料組織和訪問方式完全和 JSON 一樣,支持動態的添加欄位、支持內嵌物件和陣列物件,同時它也對 JSON 做了一些擴充,如支持 Date 和 BinData 資料型別,正是 BSON 這種欄位靈活管理能力賦予了 Mongo 的 No Schema 或者 Schema Free 的特性,
No Schema 特性帶來的好處包括:
-
強大的表現能力:物件嵌套和陣列結構可以讓資料庫中的物件具備更高的表現能力,能夠用更少的資料物件表現復雜的領域模型物件,
-
便于開發和快速迭代:靈活的欄位管理,使得專案迭代新增欄位非常容易
-
降低運維成本:資料物件結構變更不需要執行 DDL 陳述句,降低 Online 環境的資料庫操作風險,特別是在海量資料分庫分表場景,
MongoDB 在提供 No Schema 特性基礎上,提供了部分可選的 Schema 特性:Validation,其主要功能有包括:
-
規定某個 Document 物件必須包含某些欄位
-
規定 Document 某個欄位的資料型別 $(中 $開頭的都是關鍵字)
-
規定 Document 某個欄位的取值范圍:可以是列舉 $,或者正則$$regex
上面的欄位包含內嵌檔案的,也就是說,你可以指定 Document 內任意一層 JSON 檔案的欄位屬性,validator 的值有兩種,一種是簡單的 JSON Object,另一種是通過關鍵字 $jsonSchema 指定,以下是簡單示例,想了解更多請參考官方檔案:MongoDB JSON Schema 詳解,
方式一:
db.createCollection("saky_test_validation",{validator:
{
$and:[
{name:{$type: "string"}},
{status:{$in:["INIT","DEL"]}}]
}
})
方式二:
db.createCollection("saky_test_validation", {
validator: {
$jsonSchema: {
bsonType: "object",
required: [ "name", "status", ],
properties: {
name: {
bsonType: "string",
description: "must be a string and is required"
},
status: {
enum: [ "INIT", "DEL"],
description: "can only be one of the enum values and is required"
}
} }})
1.2 MongoDB 的高可用
高可用是 MongoDB 最核心的功能之一,相信很多同學也是因為這一特性才想深入了解它的,那么本節就來說下 MongoDB 通過哪些方式來實作它的高可用,然后給予這些特性我們可以實作什么程度的高可用,
相信一旦提到高可用,浮現在大家腦海里會有如下幾個問題:
-
是什么:MongoDB 高可用包括些什么功能?它能保證多大程度的高可用?
-
為什么:MongoDB 是怎樣做到這些高可用的?
-
怎么用:我們需要做些怎樣的配置或者使用才能享受到 MongoDB 的高可用特性?
那么,帶著這些問題,我們繼續看下去,看完大家應該會對這些問題有所了解了,
1.2.1 MongDB 復制集群
MongoDB 高可用的基礎是復制集群,復制集群本質來說就是一份資料存多份,保證一臺機器掛掉了資料不會丟失,一個副本集至少有 3 個節點組成:
-
至少一個主節點(Primary):負責整個集群的寫操作入口,主節點掛掉之后會自動選出新的主節點,
-
一個或多個從節點(Secondary):一般是 2 個或以上,從主節點同步資料,在主節點掛掉之后選舉新節點,
-
零個或 1 個仲裁節點(Arbiter):這個是為了節約資源或者多機房容災用,只負責主節點選舉時投票不存資料,保證能有節點獲得多數贊成票,
從上面的節點型別可以看出,一個三節點的復制集群可能是 PSS 或者 PSA 結構,PSA 結構優點是節約成本,但是缺點是 Primary 掛掉之后,一些依賴 majority(多數)特性的寫功能出問題,因此一般不建議使用,
復制集群確保資料一致性的核心設計是:
-
Journal:Journal日志是 MongoDB 的預寫日志 WAL,類似 MySQL 的 redo log,然后100ms一次將Journal 日子刷盤,
-
Oplog:Oplog 是用來做主從復制的,類似 MySql 里的 binlog,MongoDB 的寫操作都由 Primary 節點負責,Primary 節點會在寫資料時會將操作記錄在 Oplog 中,Secondary 節點通過拉取 oplog 資訊,回放操作實作資料同步的,
-
Checkpoint:上面提到了 MongoDB 的寫只寫了記憶體和 Journal 日志 ,并沒有做資料持久化,Checkpoint 就是將記憶體變更重繪到磁盤持久化的程序,MongoDB 會每60s一次將記憶體中的變更刷盤,并記錄當前持久化點(checkpoint),以便資料庫在重啟后能快速恢復資料,
-
節點選舉:MongoDB 的節點選舉規則能夠保證在Primary掛掉之后選取的新節點一定是集群中資料最全的一個,在3.3.1節點選舉有說明具體實作,
從上面 4 點我們可以得出 MongoDB 高可用的如下結論:
- MongoDB 宕機重啟之后可以通過 checkpoint 快速恢復上一個 60s 之前的資料,
- MongoDB 最后一個 checkpoint 到宕機期間的資料可以通過 Journal日志回放恢復,
- Journal日志因為是 100ms 刷盤一次,因此至多會丟失 100ms 的資料(這個可以通過 WriteConcern 的引數控制不丟失,只是性能會受影響,適合可靠性要求非常嚴格的場景)
- 如果在寫資料開啟了多數寫,那么就算 Primary 宕機了也是至多丟失 100ms 資料(可避免,同上)
1.2.2 讀寫策略
從上一小節發現,MongoDB 的高可用機制在不同的場景表現是不一樣的,實際上,MongoDB 提供了一整套的機制讓用戶根據自己業務場景選擇不同的策略,這里要說的就是 MongoDB 的讀寫策略,根據用戶選取不同的讀寫策略,你會得到不同程度的資料可靠性和一致性保障,這些對業務開放者非常重要,因為你只有徹底掌握了這些知識,才能根據自己的業務場景選取合適的策略,同時兼顧讀寫性能和可靠性,
Write Concern —— 寫策略
控制服務端一次寫操作在什么情況下才回傳客戶端成功,由兩個引數控制:
- w 引數:控制資料同步到多少個節點才算成功,取值范圍0~節點個數/majority,0 表示服務端收到請求就回傳成功,major表示同步到大多數(大于等于 N/2)節點才回傳成功,其它值表示具體的同步節點個數,默認為 1,表示 Primary 寫成功就回傳成功,
- j 引數:控制單個節點是否完成 oplog 持久化到磁盤才回傳成功,取值范圍 true/false,默認 false,因此可能最多丟 100ms 資料,
Read Concern —— 讀策略
控制客戶端從什么節點讀取資料,默認為 primary,具體引數及含義:
- primary:讀主節點
- primaryPreferred:優先讀主節點,不存在時讀從節點
- secondary:讀從節點
- secondaryPreferred:優先讀從節點,不存在時讀主節點
- nearest:就近讀,不區分主節點還是從節點,只考慮節點延時,
更多資訊可參考MongoDB 官方檔案
Read Concern Level —— 讀級別
這是一個非常有意思的引數,也是最不容易理解的例外引數,它主要控制的是讀到的資料是不是最新的、是不是持久的,最新的和持久的是一對矛盾,最新的資料可能會被回滾,持久的資料可能不是最新的,這需要業務根據自己場景的容忍度做決策,前提是你的先知道有哪些,他們代表什么意義:
-
local:直接從查詢節點回傳,不關心這些資料被同步到了多少個節點,存在被回滾的風險,
-
available:適用于分片集群,和 local 差不多,也存在被回滾的風險,
-
majority:回傳被大多數節點確認過的資料,不會被回滾,前提是 WriteConcern=majority
-
linearizable:適用于事務,讀操作會等待在它開始前已經在執行的事務提交了才回傳
-
snapshot:適用于事務,快照隔離,直接從快照去,
為了便于理解 local 和 majority,這里參考一下 MongoDB 官網上的一張 WriteConcern=majority 時寫操作的程序圖:

通過這張圖可以看出,不同節點在不同階段看待同一條資料滿足的 level 是不同的:
1.3 MongoDB 的可擴展性 —— 分片集群
水平擴展是 MongoDB 的另一個核心特性,它是 MongoDB 支持海量資料存盤的基礎,MongoDB 天然的分布式特性使得它幾乎可無限的橫向擴展,你再也不用為 MySQL 分庫分表的各種繁瑣問題操碎心了,當然,我們這里不討論 MongoDB 和其它存盤引擎的對比,這個以后專門寫下,這里只關注分片集群相關資訊,
1.3.1 分片集群架構
MongoDB 的分片集群由如下三個部分組成:

- Config:配置,本質上是一個 MongoDB 的副本集,負責存盤集群的各種元資料和配置,如分片地址、chunks 等
- Mongos:路由服務,不存具體資料,從 Config 獲取集群配置講請求轉發到特定的分片,并且整合分片結果回傳給客戶端,
- Mongod:一般將具體的單個分片叫 mongod,實質上每個分片都是一個單獨的復制集群,具備負責集群的高可用特性,
其實分片集群的架構看起來和很多支持海量存盤的設計很像,本質上都是將存盤分片,然后在前面掛一個 proxy 做請求路由,但是,MongoDB 的分片集群有個非常重要的特性是其它資料庫沒有的,這個特性就是資料均衡,資料分片一個繞不開的話題就是資料分布不均勻導致不同分片負載差異巨大,不能最大化利用集群資源,
MongoDB 的資料均衡的實作方式是:
- 分片集群上資料管理單元叫 chunk,一個 chunk 默認 64M,可選范圍 1 ~ 1024M,
- 集群有多少個 chunk,每個 chunk 的范圍,每個 chunk 是存在哪個分片上的,這些資料都是存盤在 Config 的,
- chunk 會在其內部包含的資料超過閾值時分裂成兩個,
- MongoDB 在運行時會自定檢測不同分片上的 chunk 數,當發現最多和最少的差異超過閾值就會啟動 chunk 遷移,使得每個分片上的 chunk 數差不多,
- chunk 遷移程序叫 rebalance,會比較耗資源,因此一般要把它的執行時間設定到業務低峰期,
關于 chunk 更加深入的知識會在后面進階知識里面講解,這里就不展開了,
1.3.2 分片演算法
MongoDB 支持兩種分片演算法來滿足不同的查詢需求:
- 區間分片:可以按 shardkey 做區間查詢的分片演算法,直接按照 shardkey 的值來分片,
- hash分片:用的最多的分片演算法,按 shardkey 的 hash 值來分片,hash 分片可以看作一種特殊的區間分片,
區間分片示例:

hash 分片示例:

從上面兩張圖可以看出:
- 分片的本質是將 shardkey 按一定的函式變換 f(x) 之后的空間劃分為一個個連續的段,每一段就是一個 chunk,
- 區間分片 f(x) = x;hash 分片 f(x) = hash(x)
- 每個 chunk 在空間中起始值是存在 Config 里面的,
- 當請求到 Mongos 的時候,根據 shardkey 的值算出 f(x) 的具體值為 f(shardkey),找到包含該值的 chunk,然后就能定位到資料的實際位置了,
1.4 資料壓縮
MongoDB 的另外一個比較重要的特性是資料壓縮,MongoDB 會自動把客戶資料壓縮之后再落盤,這樣就可以節省存盤空間,MongoDB 的資料壓縮演算法有多種:
- Snappy:默認的壓縮演算法,壓縮比 3 ~ 5 倍
- Zlib:高度壓縮演算法,壓縮比 5 ~ 7 倍
- 前綴壓縮:索參考的壓縮演算法,簡單理解就是丟掉重復的前綴
- zstd:MongoDB 4.2 之后新增的壓縮演算法,擁有更好的壓縮率
現在推薦的 MongoDB 版本是 4.0,在這個版本下推薦使用 snappy 演算法,雖然 zlib 有更高的壓縮比,但是讀寫會有一定的性能波動,不適合核心業務,但是比較適合流水、日志等場景,
第二部分:應用接入
在掌握第一部分的基礎上,基本上對 MongoDB 有一個比較直觀的認識了,知道它是什么,有什么優勢,適合什么場景,在此基礎上,我們基本上已經可以判定 MongoDB 是否適合自己的業務了,如果適合,那么接下來就需要考慮怎么將其應用到業務中,在此之前,我們還得先對 MonoDB 的性能有個大致的了解,這樣才能根據業務情況選取合適的配置,
2.1 基本性能測驗
在使用 MongoDB 之前,需要對其功能和性能有一定的了解,才能判定是否符合自己的業務場景,以及需要注意些什么才能更好的使用,筆者這邊對其做了一些測驗,本測驗是基于自己業務的一些資料特性,而且這邊使用的是分片集群,因此有些測驗項不同資料會有差異,如壓縮比、讀寫性能具體值等,但是也有一些是共性的結論,如寫性能隨資料量遞減并最終區域平穩,
壓縮比
對比了同樣資料在 Mongo 和 MySQL 下壓縮比對比,可以看出 snapy 演算法大概是 MySQL 的 3 倍,zlib 大概是 6 倍,

寫性能
分片集群寫性能在測驗之后得到如下結論,這里分片是 4 核 8G 的配置:
- 寫性能的瓶頸在單個分片上
- 當資料量小時是存記憶體讀寫,寫性能很好,之后隨著數量增加急劇下降,并最終趨于平穩,在 3000QPS,
- 少量簡單的索引對寫性能影響不大
- 分片集群批量寫和逐條寫性能無差異,而如果是復制集群批量寫性能是逐條寫性能的數倍,這點有點違背常識,具體原因這邊還未找到,

讀性能
分片集群的讀分為三年種情況:按 shardkey 查詢、按索引查詢、其他查詢,下面這些測驗資料都是在單分片 2 億以上的資料,這個時候 cache 已經不能完全換成業務資料了,如果資料量很小,資料全在 cache 這個性能應該會很好,
-
按 shardkey 查下,在 Mongos 處能算出具體的分片和 chunk,所以查詢速度非常穩定,不會隨著資料量變化,平均耗時 2ms 以內,4 核 8G 單分片 3 萬 QPS,這種查詢方式的瓶頸一般在 分片 Mongod 上,但也要注意 Mongos 配置不能太低,
-
按索引查詢的時候,由于 Mongos 需要將資料全部轉發到所有的分片,然后聚合全部結果回傳客戶端,因此性能瓶頸在 Mongos 上,測驗 Mongos 8 核 16G + 10 分片情況下,單個 Mongos 的性能在 1400QPS,平均時延 10ms,業務場景索引是唯一的,因此如果索引資料不唯一,后端分片數更多,這個性能還會更低,
-
如果不按 shardkey 和索引查詢因為涉及全表掃描,因此在資料量上千萬之后基本不可用
Mongos 有點特殊情況要注意的,就是客戶端請求會到哪個 Mongos 是通過客戶端 ip 的 hash 值決定的,因此同一個客戶端所有請求一定會到同一個 Mongos,如果客戶端過少的時候還會出現 Mongos 負載不均問題,
2.2 分片選擇
在了解了 MongoDB 的基本性能資料之后,就可以根據自己的業務需求選取合適的配置了,如果是分片集群,其中最重要的就是分片選取,包括:
-
需要多少個 Mongos
-
需要分為多少個分片
-
分片鍵和分片演算法用什么
關于前面兩點,其實在知道各種性能引數之后就很簡單了,前人已經總結出了相關的公式,我這里就簡單把圖再貼一下,
2.3 spring-data-mongo
MonogDB 官方提供了各種語言的 Client,這些 Client 是對 mongo 原始命令的封裝,筆者這邊是使用的 java,因此并未直接使用 MongoDB 官方的客戶端,而是經過二次封裝之后的 spring-data-mongo,好處是可以不用他關心底層的設計如連接管理、POJO 轉換等,
2.3.1 接入步驟
spring-data-mongo 的使用方式非常簡單,
第一步:引入 jar 包
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency>
第二步:ymal 配置
spring:
data:
mongodb:
host: {{.MONGO_HOST}}
port: {{.MONGO_PORT}}
database: {{.MONGO_DB}}
username: {{.MONGO_USER}}
password: {{.MONGO_PASS}}
這里有個兩個要注意:
- 權限,MongoDB 的權限是到資料級別的,所有配置的 username 必須有 database 那個庫的權限,要不然會連不上,
- 這種方式配置沒有指定讀寫 concern,如果需要在連接上指定的話,需要用 uri 的方式來配置,兩種配置方式是不兼容的,或者自己初始化 MongoTemplate,
關于配置,跟多的可以在 IDEA 里面搜索 MongoAutoConfiguration 查看原始碼,具體就是這個類:org.springframework.boot.autoconfigure.mongo.MongoProperties
關于自己初始化 MongoTemplate 的方式是:
@Configuration public class MyMongoConfig { @Primary @Bean public MongoTemplate mongoTemplate(MongoDbFactory mongoDbFactory, MongoConverter mongoConverter){ MongoTemplate mongoTemplate = new MongoTemplate(mongoDbFactory,mongoConverter); mongoTemplate.setWriteConcern(WriteConcern.MAJORITY); return mongoTemplate; } }
第三步:使用 MongoTemplate
在完成上面這些之后,就可以在代碼里面注入 MongoTemplate,然后使用各種增刪改查介面了,
2.3.2 批量操作注意事項
MongoDB Client 的批量操作有兩種方式:
-
一條命令操作批量資料:insertAll,updateMany 等
-
批量提交一批命令:bulkOps,這種方式節省的就是客戶端與服務端的互動次數
bulkOps 的方式會比另外一種方式在性能上低一些,
這兩種方式到引擎層面具體執行時都是一條條陳述句單獨執行,它們有一個很重要的引數:ordered,這個引數的作用是控制批量操作在引擎內最終執行時是并行的還是穿行的,其默認值是 true,
-
true:批量命令竄行執行,遇到某個命令錯誤時就退出并報錯,這個和事物不一樣,它不會回滾已經執行成功的命令,如批量插入如果某條資料主鍵沖突了,那么它前面的資料都會插入成功,后面的會不執行,
-
false:批量命令并行執行,單個命令錯誤不影響其它,在執行結構里會回傳錯誤的部分,還是以批量插入為例,這種模式下只會是主鍵沖突那條插入失敗,其他都會成功,
顯然,false 模式下插入耗時會低一些,但是 MongoTemplate 的 insertAll 函式是在內部寫死的 true,因此,如果想用 false 模式,需要自己繼承 MongoTemplate 然后重寫里面的 insertDocumentList 方法,
public class MyMongoTemplate extends MongoTemplate { @Override protected List<Object> insertDocumentList(String collectionName, List<Document> documents) { ......... InsertManyOptions options = new InsertManyOptions(); options = options.ordered(false); // 要自己初始化一個這物件,然后設定為false long begin = System.currentTimeMillis(); if (writeConcernToUse == null) { collection.insertMany(documents, options); // options這里默認是null } else { collection.withWriteConcern(writeConcernToUse).insertMany(documents,options); } return null; }); return MappedDocument.toIds(documents); }
2.3.3 一些常見的坑
因為 MongoDB 真的將太多自主性交給的客戶端來決策,因此如果對其了解不夠,真的會很容易踩坑,這里例舉一些常見的坑,避免大家遇到,
預分片
這個問題的常見表現就是:為啥我的資料分布很隨機了,但是分片集群的 MongoDB 插入性能還是這么低?
首先我們說下預分片是什么,預分片就是提前把 shard key 的空間劃分成若干段,然后把這些段對應的 chunk 創建出來,那么,這個和插入性能的關系是什么呢?
我們回顧下前面說到的 chunk 知識,其中有兩點需要注意:
- 當 chunk 內的資料超過閾值就會將 chunk 拆分成兩個,
- 當各個分片上 chunk 數差異過大時就會啟動 rebalance,遷移 chunk,
那么,很明顯,問題就是出在這了,chunk 分裂和 chunk 遷移都是比較耗資源的,必然就會影響插入性能,
因此,如果提前將個分片上的 chunk 創建好,就能避免頻繁的分裂和遷移 chunk,進而提升插入性能,預分片的設定方式為:
sh.shardCollection("saky_db.saky_table", {"_id": "hashed"}, false,{numInitialChunks:8192*分片數})
numInitialChunks 的最大值為 8192 * 分片數
記憶體排序
這個是一個不容易被注意到的問題,但是使用 MongoDB 時一定要注意的就是避免任何查詢的記憶體操作,因為用 MongoDB 的很多場景都是海量資料,這個情況下任何記憶體操作的成本都可能是非常高昂甚至會搞垮資料庫的,當然 MongoDB 為了避免記憶體操作搞垮它,是有個閾值,如果需要記憶體處理的資料超過閾值它就不會處理并報錯,
繼續說記憶體排序問題,它的本質是索引問題,MongoDB 的索引都是有序的,正序或者逆序,如果我們有一個 Collection 里面記錄了學生資訊,包括年齡和性別兩個欄位,然后我們創建了這樣一個復合索引:
{gender: 1, age: 1} // 這個索引先按性別升序排序,相同的再按年齡升序排序
當這個時候,如果你排序順序是下面這樣的話,就會導致記憶體排序,如果資料兩小到沒事,如果非常大的話就會影響性能,避免記憶體排序就是要查詢的排序方式要和索引的相同,
{gender: 1, age: -1} // 這個索引先按性別升序排序,相同的再按年齡降序排序
鏈式復制
鏈式復制是指副本集的各個副本在復制資料時,并不是都是從 Primary 節點拉 oplog,而是各個節點排成一條鏈,依次復制過去,
優點:避免大量 Secondary 從 Primary 拉 oplog ,影響 Primary 的性能,
缺點:如果 WriteConcern=majority,那么鏈式復制會導致寫操作耗時更長,
因此,是否開啟鏈式復制就是一個成本與性能的平衡,默認是開啟鏈式復制的:
-
是關閉鏈式復制,用更好的機器配置來支持所有節點從 Primary 拉 oplog,
-
還是開啟鏈式復制,用更長的寫耗時來降低對節點配置的需求,
鏈式復制關閉時,節點資料復制對 Primary 節點性能影響程度目前沒有專業測驗過,因此不能評判到底開啟還是關閉好,這邊資料庫同學從他們的經驗來建議是關閉,因此我這邊是關閉的,如果有用到 MongoDB 的可以考慮關掉,
第三部分:進階知識
接下來終于到了最重要的部分了,這部分將講解一些 MongoDB 的一些高級功能和底層設計,雖然不了解這些也能使用,但是如果想用好 MongoDB,這部分知識是必須掌握的,
3.1 存盤引擎 Wired Tiger
說到 MongoDB 最重要的知識,其存盤引擎 Wired Tiger 肯定是要第一個說的,因為 MongoDB 的所有功能都是依賴底層存盤引擎實作的,掌握了存盤引擎的核心知識,有利于我們理解 MongoDB 的各種功能,存盤引擎的核心作業是管理資料如何在磁盤和記憶體上讀寫,從 MongoDB 3.2 開始支持多種存盤引擎:Wired Tiger,MMAPv1 和 In-Memory,其中默認為 Wired Tiger,
3.1.1 重要資料結構和 Page
B+ Tree
存盤引擎最核心的功能就是完成資料在客戶端 - 記憶體 - 磁盤之間的互動,客戶端是不可控的,因此如何設計一個高效的資料結構和演算法,實作資料快速在記憶體和磁盤間互動就是存盤引擎需要考慮的核心問題,目前大多少流行的存盤引擎都是基于 B/B+ Tree 和 LSM(Log Structured Merge) Tree 來實作,至于他們的優勢和劣勢,以及各種適用的場景,暫時超出了筆者的能力,后面到是有興趣去研究一下,
Oracle、SQL Server、DB2、MySQL (InnoDB) 這些傳統的關系資料庫依賴的底層存盤引擎是基于 B+ Tree 開發的;而像 Cassandra、Elasticsearch (Lucene)、Google Bigtable、Apache HBase、LevelDB 和 RocksDB 這些當前比較流行的 NoSQL 資料庫存盤引擎是基于 LSM 開發的,MongoDB 雖然是 NoSQL 的,但是其存盤引擎 Wired Tiger 卻是用的 B+ Tree,因此有種說法是 MongoDB 是最接近 SQL 的 NoSQL 存盤引擎,好了,我們這里知道 Wired Tiger 的存盤結構是 B+ Tree 就行了,至于什么是 B+ Tree,它有些啥優勢網都有很多文章,這里就不在贅述了,
Page
Wired Tiger 在記憶體和磁盤上的資料結構都 B+ Tree,B+ 的特點是中間節點只有索引,資料都是存在葉節點,Wired Tiger 管理資料結構的基本單元 Page,

上圖是 Page 在記憶體中的資料結構,是一個典型的 B+ Tree,Page 上有 3 個重要的 list WT_ROW、WT_UPDATE、WT_INSERT,這個 Page 的組織結構和 Page 的 3 個 list 對后面理解 cache、checkpoint 等操作很重要:
- 記憶體中的 Page 樹是一個 checkpoint
- 葉節點 Page 的 WT_ROW:是從磁盤加載進來的資料陣列
- 葉節點 Page 的 WT_UPDATE:是記錄資料加載之后到下個 checkpoint 之間被修改的資料
- 葉節點 Page 的 WT_INSERT:是記錄資料加載之后到下個 checkpoint 之間新增的資料
上面說了 Page 的基本結構,接下來再看下 Page 的生命周期和狀態扭轉,這個生命周期和 Wired Tiger 的快取息息相關,

Page 在磁盤和記憶體中的整個生命周期狀態機如上圖:
- DIST:Page 在磁盤中
- DELETE:Page 已經在磁盤中從樹中洗掉
- READING:Page 正在被從磁盤加載到記憶體中
- MEM:Page 在記憶體中,且能正常讀寫,
- LOCKED:記憶體淘汰程序(evict)正在鎖住 Page
- LOOKASIDE:在執行 reconcile 的時候,如果 page 正在被其他執行緒讀取被修改的部分,這個時候會把資料存盤在 lookasidetable 里面,當頁面再次被讀時可以通過 lookasidetable 重構出記憶體 Page,
- LIMBO:在執行完 reconcile 之后,Page 會被刷到磁盤,這個時候如果 page 有 lookasidetable 資料,并且還沒合并過來之前就又被加載到記憶體了,就會是這個狀態,需要先從 lookasidetable 重構記憶體 Page 才能正常訪問,
其中兩個比較重要的程序是 reconcile 和 evict,
其中 reconcile 發生在 checkpoint 的時候,將記憶體中 Page 的修改轉換成磁盤需要的 B+ Tree 結構,前面說了 Page 的 WT_UPDATE 和 WT_UPDATE 串列存盤了資料被加載到記憶體之后的修改,類似一個記憶體級的 oplog,而資料在磁盤中時顯然不可能是這樣的結構,因此 reconcile 會新建一個 Page 來將修改了的資料做整合,然后原 Page 就會被 discarded,新 page 會被重繪到磁盤,同時加入 LRU 佇列,
evict 是記憶體不夠用了或者臟資料過多的時候觸發的,根據 LRU 規則淘汰記憶體 Page 到磁盤,
3.1.2 cache
MongoDB 不是記憶體資料庫,但是為了提供高效的讀寫操作存盤引擎會最大化的利用記憶體快取,MongoDB 的讀寫性能都會隨著資料量增加到了某個點出現近乎斷崖式跌落最終趨于穩定,這其中的根本原因就是記憶體是否能 cover 住全部的資料,資料量小的時候是純記憶體讀寫,性能肯定非常好,當資料量過大時就會觸發記憶體和磁盤間資料的來回交換,導致性能降低,所以,如果在使用 MongoDB 時,如果發現自己某些操作明顯高于常規,那么很大可能是它觸發了磁盤操作,
接下來說下 MongoDB 的存盤引擎 Wired Tiger 是怎樣利用記憶體 cache 的,首先,Wired Tiger 會將整個記憶體劃分為 3 塊:
- 存盤引擎內部 cache:快取前面提到的記憶體資料,默認大小 Max((RAM - 1G)/2,256M ),服務器 16G 的話,就是(16-1)/2 = 7.5G ,這個記憶體配置一定要注意,因為 Wired Tiger 如果記憶體不夠可能會導致資料庫宕掉的,
- 索引 cache:換成索引資訊,默認 500M
- 檔案系統 cache:這個實際上不是存盤引擎管理,是利用的作業系統的檔案系統快取,目的是減少記憶體和磁盤互動,剩下的記憶體都會用來做這個,
記憶體分配大小一般是不建議改的,除非你確實想把自己全部資料放到記憶體,并且主夠的引擎知識,
引擎 cache 和檔案系統 cache 在資料結構上是不一樣的,檔案系統 cache 是直接加載的記憶體檔案,是經過壓縮的資料,可以占用更少的記憶體空間,相對的就是資料不能直接用,需要解壓;而引擎中的資料就是前面提到的 B+ Tree,是解壓后的,可以直接使用的資料,占有的記憶體會大一些,
Evict
就算記憶體再大它與磁盤間的差距也是資料量級的差異,隨著資料增長也會出現記憶體不夠用的時候,因此記憶體管理一個很重要的操作就是記憶體淘汰 evict,記憶體淘汰時機由 eviction_target(記憶體使用量)和 eviction_dirty_target(記憶體臟資料量)來控制,而記憶體淘汰默認是有后臺的 evict 執行緒控制的,但是如果超過一定閾值就會把用戶執行緒也用來淘汰,會嚴重影響性能,應該避免這種情況,用戶執行緒參與 evict 的原因,一般是大量的寫入導致磁盤 IO 抗不住了,需要控制寫入或者更換磁盤,
3.1.3 checkpoint
前面說過,MongoDB 的讀寫都是操作的記憶體,因此必須要有一定的機制將記憶體資料持久化到磁盤,這個功能就是 Wired Tiger 的 checkpoint 來實作的,checkpoint 實作將記憶體中修改的資料持久化到磁盤,保證系統在因意外重啟之后能快速恢復資料,checkpoint 本身資料也是會在每次 checkpoint 執行時落盤持久化的,

一個 checkpoint 就是一個記憶體 B+ Tree,其結構就是前面提到的 Page 組成的樹,它有幾個重要的欄位:
-
root page:就是指向 B+ Tree 的根節點
-
allocated list pages:上個 checkpoint 結束之后到本 checkpoint 結束前新分配的 page 串列
-
available list pages:Wired Tiger 分配了但是沒有使用的 page,新建 page 時直接從這里取,
-
discarded list pages:上個 checkpoint 結束之后到本 checkpoint 結束前被刪掉的 page 串列

checkpoint 的大致流程入上圖所述:
- 在系統啟動或者集合檔案打開時,從磁盤加載最新的 checkpoint,
- 根據 checkpoint 的 file size truncate 檔案,因為只有 checkpoint 確認的資料才是真正持久化的資料,它后面的資料可能是最新 checkpoint 之后到宕機之間的資料,不能直接用,需要通過 Journal 日志來回放,
- 根據 checkpoint 構建記憶體的 B+ Tree,
- 資料庫 run 起來之后,各種修改操作都是操作 checkpoint 的 B+ Tree,并且會 checkpoint 會有專門的 list 來記錄這些修改和新增的 page
- 在 60s 一次的 checkpoint 執行時,會創建新的 checkpoint,并且將舊的 checkpoint 資料合并過來,然后執行 reconcile 將修改的資料重繪到磁盤,并洗掉舊的 checkpoint,這時候會清空 allocated,discarded 里面的 page,并且將空閑的 page 加到 available 里面,
3.2 Chunk
Chunk 為啥要單獨出來說一下呢,因為它是 MongoDB 分片集群的一個核心概念,是使用和理解分片集群讀寫實作的最基礎的概念,
3.2.1 基本資訊
首先,說下 chunk 是什么,chunk 本質上就是由一組 Document 組成的邏輯資料單元,它是分片集群用來管理資料存盤和路由的基本單元,具體來說就是,分片集群不會記錄每條資料在哪個分片上,這不現實,它只會記錄哪一批(一個 chunk)資料存盤在哪個分片上,以及這個 chunk 包含哪些范圍的資料,而資料與 chunk 之間的關聯是有資料的 shard key 的分片演算法 f(x) 的值是否在 chunk 的起始范圍來確定的,
前面說過,分片集群的 chunk 資訊是存在 Config 里面的,而 Config 本質上是一個復制集群,如果你創建一個分片集群,那么你默認會得到兩個庫,admin 和 config,其中 config 庫對應的就是分片集群架構里面的 Config,其中的包含一個 Collection chunks 里面記錄的就是分片集群的全部 chunk 資訊,具體結構如下圖:

chunk 的幾個關鍵屬性:
- _id:chunk 的唯一標識
- ns:命名空間,就是 DB.COLLECTION 的結構
- min:chunk 包含資料的 shard key 的 f(x) 最小值
- max:chunk 包含資料的 shard key 的 f(x) 最大值
- shard:chunk 當前所在分片 ID
- history:記錄 chunk 的遷移歷史
3.2.2 chunk 分裂
chunk 是分片集群管理資料的基本單元,本身有一個大小,那么隨著 chunk 內的資料不斷新增,最終大小會超過限制,這個時候就需要把 chunk 拆分成 2 個,這個就 chunk 的分裂,
chunk 的大小不能太大也不能太小,太大了會導致遷移成本高,太小了有會觸發頻繁分裂,因此它需要一個合理的范圍,默認大小是 64M,可配置的取值范圍是 1M ~ 1024M,這個大小一般來說是不用專門配置的,但是也有特例:
-
如果你的單條資料太小了,25W 條也遠小于 64M,那么可以適當調小,但也不是必要的,
-
如果你的資料單條過大,大于了 64M,那么就必須得調大 chunk 了,否則會產生 jumbo chunk,導致 chunk 不能遷移,
導致 chunk 分裂有兩個條件,達到任何一個都會觸發:
-
容量達到閾值:就是 chunk 中的資料大小加起來超過閾值,默認是上面說的 64M
-
資料量到達閾值:前面提到了,如果單條資料太小,不加限制的話,一個 chunk 內資料量可能幾十上百萬條,這也會影響讀寫性能,因此 MongoDB 內置了一個閾值,chunk 內資料量超過 25W 條也會分裂,
3.2.3 rebalance
MongoDB 一個區別于其他分布式資料庫的特性就是自動資料均衡,
chunk 分裂是 MongoDB 保證資料均衡的基礎:資料的不斷增加,chunk 不斷分裂,如果資料不均勻就會導致不同分片上的 chunk 數目出現差異,這就解決了分片集群的資料不均勻問題發現,然后就可以通過將 chunk 從資料多的分片遷移到資料少的分片來實作資料均衡,這個程序就是 rebalance,
如下圖所示,隨著資料插入,導致 chunk 分裂,讓 AB 兩個分片有 3 個 chunk,C 分片只有一個,這個時候就會把 B 分配的遷移一個到 C 分分片實作集群資料均衡,

執行 rebalance 是有幾個前置條件的:
- 資料庫和集合開啟了 rebalance 開關,默認是開啟的,
- 當前時間在設定的 rebalance 時間窗,默認沒有配置,就是只要檢測到了就會執行 rebalance,
- 集群中分片 chunk 數最大和最小之差超過閾值,這個閾值和 chunk 總數有關,具體如下:

rebalance 為了盡快完成資料遷移,其設計是盡最大努力遷移,因此是非常消耗系統資源的,在系統配置不高的時候會影響系統正常業務,因此,為了減少其影響需要:
- 預分片:減少大量資料插入時頻繁的分裂和遷移 chunk
- 設定 rebalance 時間窗
- 對于可能會影響業務的大規模資料遷移,如擴容分片,可以采取手段遷移的方式來控制遷移速度,
3.3 一致性/高可用
分布式系統必須要面對的一個問題就是資料的一致性和高可用,針對這個問題有一個非常著名的理論就是 CAP 理論,CAP 理論的核心結論是:一個分布式系統最多只能同時滿足一致性(Consistency)、可用性(Availability)和磁區容錯性(Partition tolerance)這三項中的兩項,關于 CAP 理論在網上有非常多的論述,這里也不贅述,
CAP 理論提出了分布式系統必須面臨的問題,但是我們也不可能因為這個問題就不用分布式系統,因此,BASE(Basically Available 基本可用、Soft state 軟狀態、Eventually consistent 最終一致性)理論被提出來了,BASE 理論是在一致性和可用性上的平衡,現在大部分分布式系統都是基于 BASE 理論設計的,當然 MongoDB 也是遵循此理論的,
3.3.1 選舉和 Raft 協議
MongoDB 為了保證可用性和磁區容錯性,采用的是副本集的方式,這種模式就必須要解決的一個問題就是怎樣快速在系統啟動和 Primary 發生例外時選取一個合適的主節點,這里潛在著多個問題:
- 系統怎樣發現 Primary 例外?
- 哪些 Secondary 節點有資格參加 Primary 選舉?
- 發現 Primary 例外之后用什么樣的演算法選出新的 Primary 節點?
- 怎么樣確保選出的 Primary 是最合適的?
Raft 協議
MongoDB 的選舉演算法是基于 Raft 協議的改進,Raft 協議將分布式集群里面的節點有 3 種狀態:
- leader:就是 Primary 節點,負責整個集群的寫操作,
- candidate:候選者,在 Primary 節點掛掉之后,參與競選的節點,只有選舉期間才會存在,是個臨時狀態,
- flower:就是 Secondary 節點,被動的從 Primary 節點拉取更新資料,
節點的狀態變化是:正常情況下只有一個 leader 和多個 flower,當 leader 掛掉了,那么 flower 里面就會有部分節點成為 candidate 參與競選,當某個 candidate 競選成功之后就成為新的 leader,而其他 candidate 回到 flower 狀態,具體狀態機如下:

Raft 協議中有兩個核心 RPC 協議分別應用在選舉階段和正常階段:
- 請求投票:選舉階段,candidate 向其他節點發起請求,請求對方給自己投票,
- 追加條目:正常階段,leader 節點向 flower 節點發起請求,告訴對方有資料更新,同時作為心跳機制來向所有 flower 宣示自己的地位,如果 flower 在一定時間內沒有收到該請求就會啟動新一輪的選舉投票,
投票規則
Raft 協議規定了在選舉階段的投票規則:
- 一個節點,在一個選舉周期(Term)內只能給一個 candidate 節點投贊成票,且先到先得
- 只有在 candidate 節點的 oplog 領先或和自己相同時才投贊成票
選舉程序
一輪完整的選舉程序包含如下內容:
- 某個/多個 flower 節點超時未收到 leader 的心跳,將自己改變成 candidate 狀態,增加選舉周期(Term),然后先給自己投一票,并向其他節點發起投票請求,
- 等待其它節點的投票回傳,在此期間如果收到其它 candidate 發來的請求,根據投票規則給其它節點投票,
- 如果某個 candidate 在收到過半的贊成票之后,就把自己轉換成 leader 狀態,并向其它節點發送心跳宣誓即位,
- 如果節點在沒有收到過半贊成票之前,收到了來自 leader 的心跳,就將自己退回到 flower 狀態,
- 只要本輪有選出 leader 就完成了選舉,否則超時啟動新一輪選舉,
catchup(追趕)
以上就是目前掌握的 MongoDB 的選舉機制,其中有個問題暫時還未得到解答,就是最后一個,怎樣確保選出的 Primary 是最合適的那一個,因為,從前面的協議來看,存在一個邏輯 bug:由于 flower 轉換成 candidate 是隨機并行的,再加上先到先得的投票機制會導致選出一個次優的節點成為 Primary,但是這一點應該是筆者自己掌握知識不夠,應該是有相關機制保證的,懷疑是通過節點優先級實作的,這點也和相關同學確認過,因此這里暫定此問題不存在,等深入學習這里的細節之后補充其設計和實作,
針對 Raft 協議的這個問題,下來查詢了一些資料,結論是:
- Raft 協議確實不保證選舉出來的 Primary 節點是最優的
- MongoDB 通過在選舉成功,到新 Primary 即位之前,新增了一個 catchup(追趕)操作來解決,即在節點獲取投票勝利之后,會先檢查其它節點是否有比自己更新的 oplog,如果沒有就直接即位,如果有就先把資料同步過來再即位,
3.3.2 主從同步
MongoDB 的主從同步機制是確保資料一致性和可靠性的重要機制,其同步的基礎是 oplog,類似 MySQL 的 binlog,但是也有一些差異,oplog 雖然叫 log 但并不是一個檔案,而是一個集合(Collection),同時由于 oplog 的并行寫入,存在尾部亂序和空洞現象,具體來說就是 oplog 里面的資料順序可能是和實際資料順序不一致,并且存在時間的不連續問題,為了解決這個問題,MongoDB 采用的是混合邏輯時鐘(HLC)來解決的,HLC 不止解決亂序和空洞問題,同時也是用來解決分布式系統上事務一致性的方案,
主從同步的本質實際上就是,Primary 節點接收客戶端請求,將更新操作寫到 oplog,然后 Secondary 從同步源拉取 oplog 并本地回放,實作資料的同步,
同步源選取
同步源是指節點拉取 oplog 的源節點,這個節點不一定是 Primary ,鏈式復制模式下就可能是任何節點,節點的同步源選取是一個非常復雜的程序,大致上來說是:
- 節點維護整個集群的全部節點資訊,并每 2s 發送一次心跳檢測,存活的節點都是同步源備選節點,
- 落后自己的節點不能做同步源:就是源節點最新的 opTime 不能小于自己最新的 opTime
- 落后 Primary 30s 以上的不能作為同步源
- 太超前的節點不能作為同步源:就是源節點最老的 opTime 不能大于自己最新的 opTime,否則有 oplog 空洞,
在同步源選取時有些特殊情況:
- 用戶可以為節點指定同步源
- 如果關閉鏈式復制,所有 Secondary 節點的同步源都是 Primary 節點
- 如果從同步源拉取出錯了,會被短期加入黑名單
oplog拉取和回放
整個拉取和回放的邏輯非常復雜,這里根據自己的理解簡化說明,如果想了解更多知識可以參考《MongoDB 復制技術內幕》
節點有一個專門拉取 oplog 的執行緒,通過 Exhausted cursor 從同步源拉取 oplog,拉取下來之后,并不會執行回放執行,而是會將其丟到一個本地的阻塞佇列中,
然后有多個具體的執行執行緒,從阻塞佇列中取出 oplog 并執行,在取出程序中,同一個 Collection 的 oplog 一定會被同一個執行緒取出執行,執行緒會盡可能的合并連續的插入命令,
整個回放的執行程序,大致為先加鎖,然后寫本店 oplog,然后將 oplog 刷盤(WAL 機制),最后更新自己的最新 opTime,
3.4 索引
索引對任何資料庫而言都是非常重要的一個功能,資料庫支持的索引型別,決定的資料庫的查詢方式和應用場景,而正確的使用索引能夠讓我們最大化的利用資料庫性能,同時避免不合理的操作導致的資料庫問題,最常見的問題就是 CPU 或記憶體耗盡,
3.4.1 基本概念
MongoDB 的索引和 MySql 的索引有點不一樣,它的索引在創建時必須指定順序(1:升序,-1:降序),同時所有的集合都有一個默認索引 _id,這是一個唯一索引,類似 MySql 的主鍵,
MongoDB 支持的索引型別有:
- 單欄位索引:建立在單個欄位上的索引,索引創建的排序順序無所謂,MongoDB 可以頭/尾開始遍歷,
- 復合索引:建立在多個欄位上的索引,
- 多 key 索引:我們知道 MongoDB 的一個欄位可能是陣列,在對這種欄位創建索引時,就是多 key 索引,MongoDB 會為陣列的每個值創建索引,就是說你可以按照陣列里面的值做條件來查詢,這個時候依然會走索引,
- Hash 索引:按資料的哈希值索引,用在 hash 分片集群上,
- 地理位置索引:基于經緯度的索引,適合 2D 和 3D 的位置查詢,
- 文本索引:MongoDB 雖然支持全文索引,但是性能低下,暫時不建議使用,
3.4.2 注意事項
索引功能強大,但是也有很多限制,使用索引時一定要注意一些問題,
復合索引
復合索引有幾個問題需要注意:
- 復合索引遵循前綴匹配原則:{userid:1,score:-1} 的索引隱含了 {userid:1} 的索引
- 避免記憶體排序:復合索引除第一個欄位之外,其他欄位的查詢排序方式,必須和索引排序方式一致,否則會導致記憶體排序,如前面的索引,可以支持 {userid:-1,score:-1} 的查詢,同時也能支持 {userid:1,score:1} 的查詢,只是后一種需要記憶體排序 score 欄位,
- 索引交集:索引交集時查詢優化器的優化方案,很少用到,盡量不要依賴這個功能,索引交集本質上就有創建兩個獨立的單欄位索引,在查詢保護兩個欄位時,優化器自動做索引交集,如 {user:1} + {score:-1} 兩個索引的交集可以支持前面的 {userid:1,score:1} 的查詢
后臺創建索引
在對一個已經擁有較大資料集的 Collection 創建索引時,建議通過創建命令引數指定后臺創建,不會阻塞命令和意外中斷,但是,在后臺創建多個索引時,不能命令執行完就接著下一個,因為是后臺創建,命令列雖然推出了,但是索引還沒創建完,這個時候如果同事輸入多個創建索引命令,會因為大量的寫操作和資料復制導致系統 cpu 耗盡,這個時候需要觀察系統監控,確定第一個索引創建完了再執行下一個,
3.4.3 explain
explain 是 MongoDB 的查詢計劃工具,和 MySql 的 explain 功能相同,都是用來分析一條陳述句的索引使用情況、影響行數、執行時間等,
explain 有三種引數分別對應結果輸出的三部分資料:
- queryPlanner:MongoDB 運行查詢優化器對當前的查詢進行評估并選擇一個最佳的查詢計劃,
- exectionStats:mongoDB 運行查詢優化器對當前的查詢進行評估并選擇一個最佳的查詢計劃進行執行,在執行完畢后回傳這個最佳執行計劃執行完成時的相關統計資訊,
- allPlansExecution:即按照最佳的執行計劃執行以及列出統計資訊,如果有多個查詢計劃,還會列出這些非最佳執行計劃部分的統計資訊,
explain 是一個非常有用的工具,建議在一個資料量較大的資料庫上開發新功能時,一定要用 explain 分析一下自己的陳述句是否合理、索引是否合理,避免在專案上線之后出現問題,
作者:sakychen
本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/MongoDB-omnidirectional-knowledge-graph.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/515124.html
標籤:NoSQL


