MongoDB 全方位知識圖譜
作者:sakychen,騰訊 CSIG 後台開發工程師
MongoDB 是一個強大的分布式存儲引擎,天然支持高可用、分布式和靈活設計。MongoDB 的一個很重要的設計理念是:服務端隻關注底層核心能力的輸齣,至於怎麼用,就盡可能的將工作交個客戶端去決策。這也就是 MongoDB 靈活性的保證,但是靈活性帶來的代價就是使用成本的提升。與 MySql 相比,想要用好 MongoDB,減少在項目中齣問題,用戶需要掌握的東西更多。本文緻力於全方位的介紹 MongoDB 的理論和應用知識,目標是讓大傢可以通過閱讀這篇文章之後能夠掌握 MongoDB 的常用知識,具備在實際項目中高效應用 MongoDB 的能力。
本文既有 MongoDB 基礎知識也有相對深入的進階知識,同時適用於對 MonogDB 感興趣的初學者或者希望對 MongoDB 有更深入瞭解的業務開發者。
前言
以下是筆者在學習和使用 MongoDB 過程中總結的 MongoDB 知識圖譜。本文將按照一下圖譜中依次介紹 MongoDB 的一些核心內容。由於能力和篇幅有限,本文並不會對圖譜中全部內容都做深入分析,後續將會針對特定條目做專門的分析。同時,如果圖譜和內容中有錯誤或疏漏的地方,也請大傢隨意指正,筆者這邊會積極修正和完善。
本文按照圖譜從以下 3 個方麵來介紹 MongoDB 相關知識:
第一部分:基礎知識
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", deion: "must be a string and is required" }, status: { enum: [ "INIT", "DEL"], deion: "can only be one of the enum values and is required" }} }}) 1.2 MongoDB 的高可用
高可用是 MongoDB 最核心的功能之一,相信很多同學也是因為這一特性纔想深入瞭解它的。那麼本節就來說下 MongoDB 通過哪些方式來實現它的高可用,然後給予這些特性我們可以實現什麼程度的高可用。
相信一旦提到高可用,浮現在大傢腦海裏會有如下幾個問題:
- 是什麼:MongoDB 高可用包括些什麼功能?它能保證多大程度的高可用?
- 為什麼:MongoDB 是怎樣做到這些高可用的?
- 怎麼用:我們需要做些怎樣的配置或者使用纔能享受到 MongoDB 的高可用特性?那麼,帶著這些問題,我們繼續看下去,看完大傢應該會對這些問題有所瞭解瞭。
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 的高可用機製在不同的場景錶現是不一樣的。實際上,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 更加深入的知識會在後麵進階知識裏麵講解,這裏就不展開瞭。
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,然後就能定位到數據的實際位置瞭。
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 負載不均問題。
在瞭解瞭 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 裏麵搜索 Mongo AutoConfiguration 查看源碼,具體就是這個類:org.springframework.boot.autoconfigure.mongo.MongoProperties
關於自己初始化 MongoTemplate 的方式是:
@Configurationpublic 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 方法。
因為 MongoDB 真的將太多自主性交給的客戶端來決策,因此如果對其瞭解不夠,真的會很容易踩坑。這裏例舉一些常見的坑,避免大傢遇到。
預分片
這個問題的常見錶現就是:為啥我的數據分布很隨機瞭,但是分片集群的 MongoDB 插入性能還是這麼低?
首先我們說下預分片是什麼,預分片就是提前把 shard key 的空間劃分成若乾段,然後把這些段對應的 chunk 創建齣來。那麼,這個和插入性能的關係是什麼呢?
我們迴顧下前麵說到的 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 的大緻流程入上圖所述:
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 的遷移曆史
chunk 是分片集群管理數據的基本單元,本身有一個大小,那麼隨著 chunk 內的數據不斷新增,最終大小會超過限製,這個時候就需要把 chunk 拆分成 2 個,這個就 chunk 的分裂。
chunk 的大小不能太大也不能太小。太大瞭會導緻遷移成本高,太小瞭有會觸發頻繁分裂。因此它需要一個閤理的範圍,默認大小是 64M,可配置的取值範圍是 1M ~ 1024M。這個大小一般來說是不用專門配置的,但是也有特例:
- 如果你的單條數據太小瞭,25W 條也遠小於 64M,那麼可以適當調小,但也不是必要的。
- 如果你的數據單條過大,大於瞭 64M,那麼就必須得調大 chunk 瞭,否則會産生 jumbo chunk,導緻 chunk 不能遷移。導緻 chunk 分裂有兩個條件,達到任何一個都會觸發:
- 容量達到閾值: 就是 chunk 中的數據大小加起來超過閾值,默認是上麵說的 64M
- 數據量到達閾值: 前麵提到瞭,如果單條數據太小,不加限製的話,一個 chunk 內數據量可能幾十上百萬條,這也會影響讀寫性能,因此 MongoDB 內置瞭一個閾值,chunk 內數據量超過 25W 條也會分裂。
MongoDB 一個區彆於其他分布式數據庫的特性就是自動數據均衡。
chunk 分裂是 MongoDB 保證數據均衡的基礎 :數據的不斷增加,chunk 不斷分裂,如果數據不均勻就會導緻不同分片上的 chunk 數目齣現差異,這就解決瞭分片集群的 數據不均勻問題發現 。然後就可以通過將 chunk 從數據多的分片遷移到數據少的分片來實現數據均衡,這個過程就是 rebalance。
如下圖所示,隨著數據插入,導緻 chunk 分裂,讓 AB 兩個分片有 3 個 chunk,C 分片隻有一個,這個時候就會把 B 分配的遷移一個到 C 分分片實現集群數據均衡。
執行 rebalance 是有幾個前置條件的:
- 數據庫和集閤開啓瞭 rebalance 開關,默認是開啓的。
- 當前時間在設置的 rebalance 時間窗,默認沒有配置,就是隻要檢測到瞭就會執行 rebalance。
- 集群中分片 chunk 數最大和最小之差超過閾值,這個閾值和 chunk 總數有關,具體如下:
rebalance 為瞭盡快完成數據遷移,其設計是盡最大努力遷移,因此是非常消耗係統資源的,在係統配置不高的時候會影響係統正常業務。因此,為瞭減少其影響需要:
- 預分片:減少大量數據插入時頻繁的分裂和遷移 chunk
- 設置 rebalance 時間窗
- 對於可能會影響業務的大規模數據遷移,如擴容分片,可以采取手段遷移的方式來控製遷移速度。
分布式係統必須要麵對的一個問題就是數據的一緻性和高可用,針對這個問題有一個非常著名的理論就是 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 領先或和自己相同時纔投贊成票
選舉過程
一輪完整的選舉過程包含如下內容:
catchup (追趕)
以上就是目前掌握的 MongoDB 的選舉機製,其中有個問題暫時還未得到解答,就是最後一個,怎樣確保選齣的 Primary 是最閤適的那一個。因為,從前麵的協議來看,存在一個邏輯 bug: 由於 flower 轉換成 candidate 是隨機並行的,再加上先到先得的投票機製會導緻選齣一個次優的節點成為 Primary 。但是這一點應該是筆者自己掌握知識不夠,應該是有相關機製保證的,懷疑是通過節點優先級實現的。這點也和相關同學確認過,因此這裏暫定此問題不存在,等深入學習這裏的細節之後補充其設計和實現。
針對 Raft 協議的這個問題,下來查詢瞭一些資料,結論是:
- Raft 協議確實不保證選舉齣來的 Primary 節點是最優的
- MongoDB 通過在選舉成功,到新 Primary 即位之前,新增瞭一個 catchup(追趕)操作來解決。即在節點獲取投票勝利之後,會先檢查其它節點是否有比自己更新的 oplog,如果沒有就直接即位,如果有就先把數據同步過來再即位。
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 雖然支持全文索引,但是性能低下,暫時不建議使用。
索引功能強大,但是也有很多限製,使用索引時一定要注意一些問題。
復閤索引
復閤索引有幾個問題需要注意:
- 復閤索引遵循前綴匹配原則:{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 分析一下自己的語句是否閤理、索引是否閤理,避免在項目上綫之後齣現問題。
責任編輯: