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 分析一下自己的語句是否合理、索引是否合理,避免在項目上線之後出現問題。
文章來源:https://mp.weixin.qq.com/s?src=11×tamp=1684982643&ver=4549&signature=4mX-RNOcUNKCZS16AYEQrTZb-zxKQUL5El1GswB3FpRD44TQuwgX9oGUcsKZNtlk*OglzuQte5fbV3nkw5But-AqFrqAxVZX9eMisq*h4mU4IOxF9Q-RiUw7dPx53SfK&new=1