MongoDB 是一個強大的分散式存儲引擎,天然支持高可用、分散式和靈活設計。MongoDB 的一個很重要的設計理念是:服務端只關註底層核心能力的輸出,至於怎麼用,就儘可能的將工作交個客戶端去決策。這也就是 MongoDB 靈活性的保證,但是靈活性帶來的代價就是使用成本的提升。與 MySql 相比,... ...
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 會為數組的每個值創建索引。就