MongoDB 一致性模型設計與實現

本文源自閱讀了 MongoDB 於 VLDB 19 上發表的 Tunable Consistency in MongoDB 論文之後,在內部所做的分享。現在把分享的內容整理成此文,並且補充了部分在之前的分享中略過的細節,以及在分享中沒有提及的 MongoDB Causal Consistency(也出現在另外一篇 SIGMOD'19 Paper),希望能夠幫助大家對 MongoDB 的一致性模型設計有一個清晰的認識。

需要額外說明的是,文章後續牽扯到具體實現的分析,都是基於 MongoDB 4.2 (WiredTiger 引擎),但是大部分關於原理的描述也仍然適用 4.2 之前的版本。

**/****/ **MongoDB 可調一致性(Tunable Consistency)概念及理論支撐

我們都知道,早期的數據庫系統往往是部署在單機上的,隨着業務的發展,對可用性和性能的要求也越來越高,數據庫系統也進而演進爲一種分佈式的架構。這種架構通常表現爲由多個單機數據庫節點通過某種複製協議組成一個整體,稱之爲「Shared-nothing」,典型的如 MySQL,PG,MongoDB

另外一種值得一提是,伴隨着「雲」的普及,爲了發揮雲環境下資源池化的優勢而出現的「雲原生」的架構,典型的如 Aurora,PolarDB,因這種架構通常採用存儲計算分離和存儲資源共享,所以稱之爲「Shared-storage」。

不管是哪種架構,在分佈式環境下,根據大家耳熟能詳的 CAP 理論,都要解決所謂的一致性(Consistency)問題,即在讀寫發生在不同節點的情況下,怎麼保證每次讀取都能獲取到最新寫入的數據。這個一致性即是我們今天要討論的 MongoDB 可調一致性模型中的一致性,區別於單機數據庫系統中經常提到的 ACID 理論中的一致性。

CAP 理論中的一致性直觀來看是強調讀取數據的新近度(Recency),但個人認爲也隱含了對**持久性(Durability)**的要求,即,當前如果已經讀取了最新的數據,不能因爲節點故障或網絡分區,導致已經讀到的更新丟失。關於這一點,我們後面討論具體設計的時候也能看到 MongoDB 的一致性模型對持久性的關注

既然標題提到了是可調(Tunable)一致性,那這個可調性具體又指的是什麼呢?

這裏就不得不提分佈式系統中的另外一個理論,PACELC。PACELC 在 CAP 提出 10 年之後,即 2012 年,在一篇 Paper 中被正式提出,其核心觀點是,根據 CAP,在一個存在網絡分區(**P**)的分佈式系統中,我們面臨在可用性(**A**)和一致性(**C**)之間的選擇,但除此之外(**E**),即使暫時沒有網絡分區的存在,在實際系統中,我們也要面臨在訪問延遲(**L**)和一致性(**C**)之間的抉擇。所以,PACELC 理論是結合現實情況,對 CAP 理論的一種擴展。

而我們今天要討論的 MongoDB 一致性模型的可調之處,指的就是調節 MongoDB 讀寫操作對 L 和 C 的選擇,或者更具體的來說,是調節對性能(Performance——Latency、Throughput)和正確性(Correctness——Recency、Durability)的選擇(Tradeoff)

**/****/ **MongoDB 一致性模型設計

在討論具體的實現之前,我們先來嘗試從功能設計的角度,理解 MongoDB 的可調一致性模型,這樣的好處是可以對其有一個比較全局的認知,後續也可以幫助我們更好的理解它的實現機制。

在學術中,對一致性模型有一些標準的劃分和定義,比如我們聽到過的線性一致性(Linearizable Consistency),因果一致性(Causal Consistency)等都在這個標準當中,MongoDB 的一致性模型設計自然也不能脫離這個標準。

但是,和很多其他的數據庫系統一樣,設計上需要綜合考慮和其他子系統的關聯,比如複製、存儲引擎,具體的實現往往和標準又不是完全一致的。下面的第一個小節,我們就詳細探討標準的一致性模型和 MongoDB 一致性模型的關係,以對其有一個基本的認識。

在這個基礎上,我們再來看在具體的功能設計上,MongoDB 的一致性模型是怎麼做的,以及在實際的業務場景中是如何被使用的。

標準一致性模型和 MongoDB 一致性模型的關係

以複製爲基礎構建的分佈式系統中,一致性模型通常可按照**「以數據爲中心(Data-centric)」**和**「以客戶端爲中心(Client-centric)」**來劃分,下圖中的「Linearizable」,「Sequential」,「Causal」,「Eventual」即屬於 Data-centric 的範疇,對一致性的保證也是由強到弱。

Data-centric 的一致性模型要求我們站在整個系統的角度看,所有訪問進程(客戶端)的讀寫順序滿足同一個特定的約束,比如,對於線性一致性(Linearizable)來說,它要求這個讀寫順序和操作真實發生的時間(Real Time)完全一致,是最強的一致性模型,實際系統中很難做到,而對於因果一致性來說,只約束了存在因果關係的操作之間的順序。

Data-centric 一致性模型雖然對訪問進程提供了全局一致的視圖,但是在真實的系統中,不同的讀寫進程(客戶端)訪問的往往是不同的數據,維護這樣的全局視圖會產生不必要的代價。舉個例子,在因果一致性模型下,P1 執行了 **Write1(X=1)**,P2 執行了 **Read1(X=1),Write2(X=3)**,那麼 P1 和 P2 之間就產生了因果關係,進而導致**P1:Write1(X=1)** 和 **P2:Write2(X=3)** 的可見順序存在一個約束,即,需要其他訪問進程看到的這兩個寫操作順序是一樣的,且 Write1 在前,但如果其他進程讀的不是 X,顯然再提供這種全局一致視圖就沒有必要了。

由此,爲了簡化這種全局的一致性約束,就有了 Client-centric 一致性模型,相比於 Data-centric 一致性模型,它只要求提供單客戶端維度的一致性視圖,對單客戶端的讀寫操作提供這幾個一致性承諾:「RYW(Read Your Write)」,「MR(Monotonic Read)」,「MW(Monotonic Write)」,「WFR(Write Follow Read)」。關於這些一致性模型的概念和劃分,本文不做太詳細介紹,感興趣的可以看 CMU 的這兩篇 Lecture(Lec1,Lec2),講的很清晰。

MongoDB 的 Causal Consistency Session 即提供了上述幾個承諾:RYW,MR,MW,WFR。但是,這裏是 MongoDB 和標準不太一樣的地方,MongoDB 的因果一致性提供的是 Client-centric 一致性模型下的承諾,而非 Data-centric。這麼做主要還是從系統開銷角度考慮,實現 Data-centric 下的因果一致性所需要的全局一致性視圖代價過高,在真實的場景中,Client-centric 一致性模型往往足夠了,關於這一點的詳細論述可參考 MongoDB 官方在 SIGMOD'19 上 Paper 的 2.3 節。

Causal Consistency 在 MongoDB 中是相對比較獨立一塊實現,只有當客戶端讀寫的時候開啓 Causal Consistency Session 才提供相應承諾

沒有開啓 Causal Consistency Session 時,MongoDB 通過 writeConcern 和 readConcern 接口提供了可調一致性,具體來說,包括線性一致性和最終一致性。最終一致性在標準中的定義是非常寬鬆的,是最弱的一致性模型,但是在這個一致性級別下 MongoDB 也通過 writeConcern 和 readConcern 接口的配合使用,提供了豐富的對性能和正確性的選擇,從而貼近真實的業務場景。

MongoDB 可調一致性模型功能接口 —— writeConcern 和 readConcern

在 MongoDB 中,writeConcern 是針對寫操作的配置,readConcern 是針對讀操作的配置,而且都支持在**單操作粒度(Operation Level)**上調整這些配置,使用起來非常的靈活。writeConcern 和 readConcern 互相配合,共同構成了 MongoDB 可調一致性模型的對外功能接口。

writeConcern —— 唯一關心的就是寫入數據的持久性(Durability)

我們首先來看針對寫操作的 writeConcern,寫操作改變了數據庫的狀態,纔有了讀操作的一致性問題。同時,我們在後面章節也會看到,MongoDB 一些 readConcern 級別的實現也強依賴 writeConcern 的實現。

MongoDB writeConcern 包含如下選項,

{ w: <value>, j: <boolean>, wtimeout: <number> }

從上面的定義我們可以看出,writeConcern 唯一關心的就是寫操作的持久性**,這個持久性不僅僅包含由**** **j** 決定、傳統的單機數據庫層面的持久性,更重要的是包含了由 **w** ****決定、整個副本集(Cluster)層面的持久性**。**w** 決定了當副本集發生重新選主時,已經返回寫成功的修改是否會 “丟失”,在 MongoDB 中,我們稱之爲**被回滾**。**w** 值越大,對客戶端來說,數據的持久性保證越強,寫操作的延遲越大。

這裏還要提及兩個概念,「local committed」「majority committed」,對應到 writeConcern 分別爲 **w:1** 和 **w: majority**,它們在後續實現分析中會多次涉及。每個 MongoDB 的寫操作會開啓底層 WiredTiger 引擎上的一個事務,如下圖,**w:1** 要求事務只要在本地成功提交(local committed)即可,而 **w: majority** 要求事務在副本集的多數派節點提交成功(majority committed)。

readConcern —— 關心讀取數據的新近度(Recency)和持久性(Durability)

在 MongoDB 4.2 中包含 5 種 readConcern 級別,我們先來看前 4 種:「local」, 「available」, 「majority」, 「linearizable」,它們對一致性的承諾依次由弱到強。其中,「linearizable」即對應我們前面提到的標準一致性模型中的線性一致性,另外 3 種 readConcern 級別代表了 MongoDB 在最終一致性模型下,對 Latency 和 Consistency(Recency & Durability) 的取捨。

下面我們結合一個三節點副本集複製架構圖,來簡要說明這幾個 readConcern 級別的含義。在這個圖中,oplog 代表了 MongoDB 的複製日誌,類似於 MySQL 中的 binlog,複製日誌上最新的**x=<value>**,表示了節點的複製進度。

以上各 readConcern level 在 Latency、Durability、Recency 上的 Tradeoff 如下,

我們還有最後一種 readConcern level 沒有提及,即「snapshot readConcern」,放在這裏單獨討論的原因是,「snapshot readConcern」是伴隨着 4.0 中新出現的多文檔事務( multi-document transaction,其他系統也常稱之爲多行事務)而設計的,只能用在顯式開啓的多文檔事務中。而在 4.0 之前的版本中,對於一條讀寫操作,MongoDB 默認只支持單文檔上的事務性語義(單行事務),前面提到的 4 種 readConcern level 正是爲這些普通的讀寫操作(未顯式開啓多文檔事務)而設計的。

「snapshot readConcern」從定義上來看,和 majority readConcern 比較相似,即,讀取「majority committed」的數據,也可能讀不到最新的已提交數據,但是其特殊性在於,當用在多文檔事務中時,它承諾真正的一致性快照語義,而其他的 readConcern level 並不提供,關於這一點,我們在後面的實現部分再詳細探討。

writeConcern 和 readConcern 的關係

在分佈式系統中,當我們討論一致性的時候,通常指的是讀操作對數據的關注,即「what read concerns」,那爲什麼在 MongoDB 中我們還要單獨討論 writeConcern 呢?從一致性承諾的角度來看,writeConcern 從如下兩方面會對 readConcern 產生影響,

所以,writeConcern 雖然只關注了寫入數據的持久化程度,但是作爲讀操作的數據來源,也間接的也影響了 MongoDB 對讀操作的一致性承諾。

writeConcern 和 readConcern 在實際業務中的應用

前面是對 writeConcern 和 readConcern 在功能定義上的介紹,可以看到,讀寫採用不同的配置,每個配置下面又包含不同的級別,這個接口設計對於使用者來說還是稍顯複雜的(社區中也有不少類似的反饋),下面我們就來了解一下 writeConcern 和 readConcern 在真實業務中的統計數據以及幾個典型應用場景,以加深對它們的理解。

上面的統計數據來自於 MongoDB 自己的 Atlas 雲服務中用戶 Driver 上報的數據,統計樣本在百億量級,所以準確性是可以保證的,從數據中我們可以分析出如下結論,

此外,MongoDB 的默認配置({w:1} writeConcern, local readConcern)都是更傾向於保護 Latency 的,主要是基於這樣的一個事實:主備切換事件發生的概率比較低,即使發生了丟數據的概率也不大。

統計數據給了我們一個 MongoDB readConcern/writeConcern 在真實業務場景下使用情況的直觀認識,即,大部分用戶更關注 Latency,而不是 Consistency。但是,統計數據同時也說明 readConcern/writeConcern 的使用組合是非常豐富的,用戶通過使用不同的配置值來滿足需求各異的業務場景對一致性和性能的要求,比如如下幾個實際業務場景中的應用案例(均來自於 Atlas 雲服務中的用戶使用場景),

MongoDB 因果一致性模型功能接口 —— Causal Consistency Session

前面已經提及了,相比於 writeConcern/readConcern 構建的可調一致性模型,MongoDB 的因果一致性模型是另外一塊相對比較獨立的實現,有自己專門的功能接口。MongoDB 的因果一致性是藉助於客戶端的 causally consistent session 來實現的,causally consistent session 可以理解爲,維護一系列存在因果關係的讀寫操作間的因果一致性的執行載體

causally consistent session 通過維護 Server 端返回的一些操作執行的元信息(主要是關於操作定序的信息),再結合 Server 端的實現來提供 MongoDB Causal Consistency 所定義的一致性承諾(RYW,MR,MW,WFR),具體原理我們在後面的實現部分再詳述。

針對 causally consistent session,我們可以看一個簡單的例子,比如現在有一個訂單集合 orders,用於存儲用戶的訂單信息,爲了擴展讀流量,客戶端採用主庫寫入從庫讀取的方式,用戶希望自己在提交訂單之後總是能夠讀取到最新的訂單信息(Read Your Write),爲了滿足這個條件,客戶端就可以通過 causally consistent session 來實現這個目的,

""" new order """
with client.start_session(causal_consistency=True) as s1:
    orders = client.get_database(
        'test', read_concern=ReadConcern('majority'),
        write_concern=WriteConcern('majority', wtimeout=1000)).orders
    orders.insert_one(
        {'order_id': "123", 'user': "tony", 'order_info': {}}, session=s1)
""" another session get user orders """
with client.start_session(causal_consistency=True) as s2:
    s2.advance_cluster_time(s1.cluster_time) # hybird logical clock
    s2.advance_operation_time(s1.operation_time)
    orders = client.get_database(
        'test', read_preference=ReadPreference.SECONDARY,
        read_concern=ReadConcern('majority'),
        write_concern=WriteConcern('majority', wtimeout=1000)).orders
    for order in orders.find({'user': "tony"}, session=s2):
        print(order)

從上面的例子我們可以看到,使用 causally consistent session,仍然需要指定合適的 readConcern/writeConcern value,原因是,只有指定 majority writeConcern & readConcern,MongoDB 才能提供完整的 Causal Consistency 語義,即同時滿足前面定義的 4 個承諾(RYW,MR,MW,WFR)。

簡單起見,我們只舉例其中的一種情況:爲什麼在 {w: 1} writeConcern 和 majority readConcern 下,不能滿足 RYW(Read Your Write)?

上圖是一個 5 節點的副本集,當發生網絡分區時(P~old~, S~1~ 和 P~new~, S~2~, S~3~ 分區), 在 P~old~ 上發生的 W~1~ 寫入因爲使用了 {w:1} writeConcern ,會向客戶端返回成功,但是因爲沒有複製到多數派節點,最終會在網絡恢復後被回滾掉,R~1~ 雖然發生在 W~1~ 之後,但是從 S~2~ 並不能讀取到 W~1~ 的結果,不符合 RYW 語義。其他情況下爲什麼不能滿足 Causal Consistency 語義,可以參考官方文檔,有非常詳細的說明。

**/****/ **MongoDB 一致性模型實現機制及優化

前面對 MongoDB 的可調一致性和因果一致性模型,在理論以及具體的功能設計層面做了一個總體的闡述,下面我們就深入到內核層面,來看下 MongoDB 的一致性模型的具體實現機制以及在其中做了哪些優化。

writeConcern

在 MongoDB 中,writeConcern 的實現相對比較簡單,因爲不同的 writeConcern value 實際上只是決定了寫操作返回的快慢**w <= 1** 時,寫操作的執行及返回的流程只發生在本地,並不會涉及等待副本集其他成員確認的情況,比較簡單,所以我們只探討 **w > 1** 時 writeConcern 的實現。

w>1 時 writeConcern 的實現

每一個用戶的寫操作會開啓 WiredTiger 引擎層的一個事務,這個事務在提交時會順便記錄本次寫操作對應的 Oplog Entry 的時間戳(Oplog 可理解爲 MongoDB 的複製日誌,這裏不做詳細介紹,可參考文檔),這個時間戳在代碼裏面稱之爲**lastOpTime**

// mongo::RecoveryUnit::OnCommitChange::commit -> mongo::repl::ReplClientInfo::setLastOp
void ReplClientInfo::setLastOp(OperationContext* opCtx, const OpTime& ot) {
    invariant(ot >= _lastOp);
    _lastOp = ot;
    lastOpInfo(opCtx).lastOpSetExplicitly = true;
}

引擎層事務提交後,相當於本地已經完成了本次寫操作,對於 **w:1** 的 writeConcern,已經可以直接向客戶端返回成功,但是當 **w > 1** 時就需要等待足夠多的 Secondary 節點也確認寫操作執行成功,這個時候 MongoDB 會通過執行 **ReplicationCoordinatorImpl::_awaitReplication_inlock** 阻塞在一個條件變量上,等待被喚醒,被阻塞的用戶線程會被加入到 **_replicationWaiterList** 中。

Secondary 在拉取到 Primary 上的這個寫操作對應的 Oplog 並且 Apply 完成後,會更新自身的位點信息,並通知另外一個後臺線程彙報自己的 **appliedOpTime** 和 **durableOpTime** 等信息給 upstream(主要的方式,還有其他一些特殊的彙報時機)。

void ReplicationCoordinatorImpl::setMyLastAppliedOpTimeAndWallTimeForward(
    ...
    if (opTime > myLastAppliedOpTime) {
        _setMyLastAppliedOpTimeAndWallTime(lock, opTimeAndWallTime, false, consistency);
        _reportUpstream_inlock(std::move(lock)); // 這裏是向 sync source 彙報自己的 oplog apply 進度信息
    }
    ...
}

**appliedOpTime** 和 **durableOpTime** 的含義和區別如下,

上述信息的彙報是通過給 upstream 發送 **replSetUpdatePosition** 命令來完成的,upstream 在收到該命令後,通過比較如果發現某個副本集成員彙報過來的時間戳信息比上次新,就會觸發,喚醒等待 writeConcern 的用戶線程的邏輯。

喚醒邏輯會去比較用戶線程等待的 **lastOptime** 是否小於等於 Secondary 彙報過來的時間戳 TS,如果是,表示有一個 Secondary 節點滿足了本次 writeConcern 的要求。那麼,TS 要使用 Secondary 彙報過來的那個時間戳呢?如果 writeConcern 中 **j** 參數指定的是 false,意味着本次寫操作並不關注是否在 Disk 上持久化,那麼 TS 使用 **appliedOpTime**, 否則使用 **durableOpTime** 。當有指定的 **w** 個節點(含 Primary 自身)彙報的 TS 大於等於 **lastOptime**,用戶線程即可被喚醒,向客戶端返回成功。

// TopologyCoordinator::haveNumNodesReachedOpTime
    for (auto&& memberData : _memberData) {
        const OpTime& memberOpTime =
            durablyWritten ? memberData.getLastDurableOpTime() : memberData.getLastAppliedOpTime();
        if (memberOpTime >= targetOpTime) {
            --numNodes;
        }
        if (numNodes <= 0) {
            return true;
        }
    }

到這裏,用戶線程因 writeConcern 被阻塞到喚醒的基本流程就完成了,但是我們還需要思考一個問題,MongoDB 是支持鏈式複製的,即,P->S1->S2 這種複製拓撲,如果在 P 上執行了寫操作,且使用了 writeConcern w:3,即,要求得到三個節點的確認,而 S2 並不直接向 P 彙報自己的 Oplog Apply 信息,那這種場景下 writeConcern 要如何滿足?

MongoDB 採用了信息轉發的方式來解決這個問題,當 S1 收到 S2 彙報過來的 **replSetUpdatePosition** 命令,進行處理時(**processReplSetUpdatePosition()**),如果發現自己不是 Primary 角色,會立刻觸發一個 **forwardSlaveProgress** 任務,即,把自己的 Oplog Apply 信息,連同自己的 Secondary 彙報過來的,構造一個 **replSetUpdatePosition** 命令,發往上游,從而保證,當任一個 Secondary 節點的 Oplog Apply 進度推進,Primary 都能夠及時的收到消息,儘可能降低 w>1 時,因 writeConcern 而帶來的寫操作延遲。

readConcern

readConcern 的實現相比於 writeConcern,要複雜很多,因爲它和存儲引擎的關聯要更爲緊密,在某些情況下,還要依賴於 writeConcern 的實現,同時部分 readConcern level 的實現還要依賴 MongoDB 的複製機制和存儲引擎共同提供支持。

另外,MongoDB 爲了在滿足指定 readConcern level 要求的前提下,儘量降低讀操作的延遲和事務執行效率,也做了一些優化。下面我們就結合不同的 readConcern level 來分別描述它們的實現原理和優化手段。

“majority” readConcern

“majority” readConcern 的語義前面的章節已經介紹,這裏不再贅述。爲了保證客戶端讀到 majority committed 數據,根據存儲引擎能力的不同,MongoDB 分別實現了兩種機制用於提供該承諾。

依賴 WiredTiger 存儲引擎快照的實現方式

WiredTiger 爲了保證併發事務在執行時,不同事務的讀寫不會互相 block,提升事務執行性能,也採用了 MVCC 的併發控制策略,即不同的寫事務在提交時,會生成多個版本的數據,每個版本的數據由一個時間戳(commit_ts)來標識。所謂的存儲引擎快照(Snapshot),實際上就是在某個時間點看到的,由歷史版本數據所組成的一致性數據視圖。所以,在引擎內部,快照也是由一個時間戳來標識的

前面我們已經提到,由於 MongoDB 採用異步複製的機制,不同節點的複製進度會有差異。如果我們在某個副本集節點直接讀取最新的已提交數據,如果它還沒有複製到大多數節點,顯然就不滿足 “majority” readConcern 語義。

這個時候可以採取一個辦法,就是仍然讀取最新的數據,但是在返回 Client 前等待其他節點確認本次讀取的數據已經 apply 完成了,但是這樣顯然會大幅的增加讀操作的延遲(雖然這種情況下,一致性體驗反而更好了,因爲能讀到更新的數據,但是前面我們已經分析了,絕大部分用戶在讀取時,希望更快的返回的數據,而不是追求一致性)。

所以,MongoDB 採用的做法是在存儲引擎層面維護一個 majority committed 數據視圖(快照),這個快照對應的時間戳在 MongoDB 裏面稱之爲 majority committed point(後面簡稱 mcp)。當 Client 指定 majority 讀時,通過直接讀取這個快照,來快速的返回數據,無需等待。需要注意的一點是,由於複製進度的差異,mcp 並不能反映當前最新的已提交數據,即,這個方法是通過犧牲 Recency 來換取更低的 Latency。

// 以 getMore 命令舉例
void applyCursorReadConcern(OperationContext* opCtx, repl::ReadConcernArgs rcArgs) {
        ... 
        switch (rcArgs.getMajorityReadMechanism()) {
            case repl::ReadConcernArgs::MajorityReadMechanism::kMajoritySnapshot: {
                // Make sure we read from the majority snapshot.
                opCtx->recoveryUnit()->setTimestampReadSource(
                    RecoveryUnit::ReadSource::kMajorityCommitted);
                // 獲取 majority committed snapshot
                uassertStatusOK(opCtx->recoveryUnit()->obtainMajorityCommittedSnapshot());
                break;
        ...
}

但基於 mcp 快照的實現方式需要解決一個問題,即,如何保證這個快照的有效性? 進一步來說, 如何保證 mcp 視圖所依賴的歷史版本數據不會被 WiredTiger 引擎清理掉?

正常情況下,WiredTiger 會根據事務的提交情況自動的去清理多版本的數據,只要當前的活躍事務對某個歷史版本的數據沒有依賴,即可以從內存中的 MVCC List 裏面刪掉(不考慮 LAS 機制,WT 的多版本數據設計上只存放在內存中)。但是,所謂的 majority committed point,實際上是 Server 層的概念,引擎層並不感知,如果只根據事務的依賴來清理歷史版本數據,mcp 依賴的歷史版本版本數據可能就會被提前清理掉。

舉個例子,在下圖的三節點副本集中,如果 Client 從 Primary 節點讀取並且指定了 majority readConcern,由於 **mcp = 4**,那麼 MongoDB 只能向 Client 返回 **commit_ts = 4** 的歷史值。但是,對於 WiredTiger 引擎來說,當前活躍的事務列表中只有 T1,commit_ts = 4 的歷史版本是可以被清理的,但清理掉該版本,mcp 所依賴的 snapshot 顯然就無法保證。所以,需要 WiredTiger 引擎層提供一個新機制,根據 Server 層告知的複製進度,即, mcp 位點,來清理歷史版本數據

在 WiredTiger 3.0 版本中,開始提供「Application-specified Transaction Timestamps」功能,來解決 Server 層對事務提交順序(基於 Application Timestamp)的需求和 WiredTiger 引擎層內部的事務提交順序(基於 Internal Transaction ID)不一致的問題(根源來自於基於 Oplog 的複製機制,這裏不作展開)。進一步,在這個功能的基礎上,WT 也提供了所謂的「read "as of" a timestamp」功能(也有文章稱之爲 「Time Travel Query」),即支持從某個指定的 Timestamp 進行快照讀,而這個特性正是前面提到的基於 mcp 位點實現 "majority" readConcern 的功能基礎。

WiredTiger 對外提供了 set_timestamp() 的 API,用於 Server 層來更新相關的 Application Timestamp。WT 目前包含如下語義的 Application Timestamp,

要回答前面提到的關於 mcp snapshot 有效性保證的問題,我們需要重點關注紅框中的幾個 Timestamp。

首先,**stable** timestamp 在 MongoDB 中含義是,在這個時間戳之前提交的寫,不會被回滾,所以它和 majority commit point(mcp) 的語義是一致的**stable** timestamp 對應的快照被存儲引擎持久化後,稱之爲「stable checkpoint」,這個 checkpoint 在 MongoDB 中也有重要的意義,在後面的「"local" readConcern」章節我們再詳述。

MongoDB 在 Crash Recovery 時,總是從 stable checkpoint 初始化,然後重新應用增量的 Oplog 來完成一次恢復。所以爲了提升 Crash Recovery 效率及回收日誌空間,引擎層需要定期的產生新的 stable checkpoint,也就意味着**stable** timestamp 也需要不斷的被 Server 層推進(更新)。而 MongoDB 在更新 **stable** timestamp 的同時,也會順便去基於該時間戳去更新 **oldest** timestamp,所以,在基於快照的實現機制下,oldest timestamp 和 stable timestamp 的語義也是一致的

...
->ReplicationCoordinatorImpl::_updateLastCommittedOpTimeAndWallTime()
->ReplicationCoordinatorImpl::_setStableTimestampForStorage()
->WiredTigerKVEngine::setStableTimestamp()
->WiredTigerKVEngine::setOldestTimestampFromStable()
->WiredTigerKVEngine::setOldestTimestamp()

當前 WiredTiger 收到新的 **oldest** timestamp 時,會結合當前的活躍事務(**oldest_reader**)和 **oldest** timestamp 來計算新的全局 **pinned** timestamp,當進行歷史版本數據的清理時,pinned timestamp 之後的版本不會被清理,從而保證了 mcp snapshot 的有效性。

// 計算新的全局 pinned timestamp
__conn_set_timestamp->__wt_txn_global_set_timestamp->__wt_txn_update_pinned_timestamp->
__wt_txn_get_pinned_timestamp {
...
    tmp_ts = include_oldest ? txn_global->oldest_timestamp : 0;
...
    if (!include_oldest && tmp_ts == 0)
        return (WT_NOTFOUND);
    *tsp = tmp_ts;
...
}
// 判斷歷史版本是否可清理
static inline bool
__wt_txn_visible_all(WT_SESSION_IMPL *session, uint64_t id, wt_timestamp_t timestamp)
{
...
    __wt_txn_pinned_timestamp(session, &pinned_ts);
    return (timestamp <= pinned_ts);
}

在分析了 mcp snapshot 有效性保證的機制之後,我們還需要回答下面兩個關鍵問題,整個細節纔算完整。

  1. Secondary 的複製進度,以及進一步由複製進度計算出的 mcp 是由 oplog 中的 ts 字段來標識的,而數據的版本號是由 commit_ts 來標識的,他們之間有什麼關係,爲什麼是可比的?

  2. 前面提到了引擎的 Crash Recovery 需要 stable timestamp(mcp)不斷的推進來產生新的 stable checkpoint,那 mcp 具體是如何推進的?

要回答第一個問題,我們需要先看下,對於一條 insert 操作,它所對應的 oplog entry 的 ts 字段值是怎麼來的,以及這條 oplog 和 insert 操作的關係。

首先,當 Server 層收到一條 insert 操作後,會提前調用 **LocalOplogInfo::getNextOpTimes()** 來給其即將要寫的 oplog entry 生成 ts 值,獲取這個 ts 是需要加鎖的,避免併發的寫操作產生同樣的 ts。然後, Server 層會調用 **WiredTigerRecoveryUnit::setTimestamp** 開啓 WiredTiger 引擎層的事務,並且把這個事務中後續寫操作的** **commit_ts** ****都設置爲 oplog entry 的 ts**,insert 操作在引擎層執行完成後,會把其對應的 oplog entry 也通過同一事務寫到 WiredTiger Table 中,之後事務才提交。

也就是說 MongoDB 是通過把寫 oplog 和寫操作放到同一個事務中,來保證複製日誌和實際數據之間的一致性,同時也確保了,oplog entry ts 和寫操作本身所產生修改的版本號是一致的

對於第二個問題,mcp 如何推進,在前面的 writeConcern 實現章節我們提到了,downstream 在 apply 完一批 oplog 之後會向 upstream 彙報自己的 apply 進度信息,upstream 同時也會向自己的 upstream 轉發這個信息,基於這個機制,對 Primary 來說,顯然最終它能不斷的獲取到整個副本集所有成員的 oplog apply 進度信息,進而推進自己的 majority commit point(計算的方式比較簡單,具體見**TopologyCoordinator::updateLastCommittedOpTimeAndWallTime**)。

但是,上述是一個單向傳播的機制,而副本集的 Secondary 節點也是能夠提供讀的,同樣需要獲取其他節點的 oplog apply 信息來更新 mcp 視圖,所以 MongoDB 也提供瞭如下兩種機制來保證 Secondary 節點的 mcp 是可以不斷推進的:

1. 基於副本集高可用的心跳機制:

i. 默認情況下,每個副本集節點都會每 2 秒向其他成員發送心跳(**replSetHeartBeat** 命令)
ii. 其他成員返回的信息中會包含 **$replData** 元信息,Secondary 節點會根據其中的 **lastOpCommitted** 直接推進自己的 mcp

$replData: { term: 147, lastOpCommitted: { ts: Timestamp(1598455722, 1), t: 147 } ...

2. 基於副本集的增量同步機制:

i. 基於心跳機制的 mcp 推進方式,顯然實時性是不夠的,Primary 計算出新的 mcp 後,最多要等 2 秒,下游才能更新自己的 mcp
ii. 所以,MongoDB 在 oplog 增量同步的過程中,upstream 同樣會在向 downstream 返回的 oplog batch 中夾帶 **$replData** 元信息,下游節點收到這個信息後同樣會根據其中的 **lastOpCommitted** 直接推進自己的 mcp
iii. 由於 Secondary 節點的 oplog fetcher 線程是持續不斷的從上游拉取 oplog,只要有新的寫入,導致 Primary mcp 推進,那麼下游就會立刻拉取新的 oplog,可以保證在 ms 級別同步推進自己的 mcp

另外一點需要說明的是,心跳回復中實際上也包含了目標節點的 **lastAppliedOpTime** 和 **lastDurableOpTime** 信息,但是 Secondary 節點並不會根據這些信息自行計算新的 mcp,而是總是等待 Primary 把 **lastOpCommittedOpTime** 傳播過來,直接 set 自己的 mcp。

Speculative Read —— 不依賴快照的實現方式

類似於 MySQL,MongoDB 也是支持插件式的存儲引擎體系的,但是並非每個支持的存儲引擎都實現了 MVCC,即具備快照能力,比如在 MongoDb 3.2 之前默認的 MMAPv1 引擎就不具備。

此外,即使對於具備 MVCC 的 WiredTiger 引擎,維護 majority commit point 對應的 snapshot 是會帶來存儲引擎 cache 壓力上漲的,所以 MongoDB 提供了 **replication.enableMajorityReadConcern** 參數用於關閉這個機制。

所以,結合以上兩方面的原因,MongoDB 需要提供一種不依賴快照的機制來實現 majority readConcern,MongoDB 把這個機制稱之爲 Speculative Read ,中文上我覺得可以稱爲 “未決讀”。

Speculative Read 的實現方式非常簡單,上一小節實際上也基本描述了,就是直接讀當前最新的數據,但是在實際返回 Client 前,會等待讀到的數據在多數節點 apply 完成,故可以滿足 majority readConcern 語義。本質上,這是一種後驗的機制,在其他的數據庫系統中,比如 Hekaton,VoltDB ,事務的併發控制中也有類似的做法。

在具體的實現上,首先在命令實際執行前會通過 **WiredTigerRecoveryUnit::setTimestampReadSource()** 設置自己的讀時間戳,即 readTs,讀事務在執行的過程中只會讀到 readTs 或之前的版本。

在命令執行完成後,會調用 **waitForSpeculativeMajorityReadConcern()** 確保 readTs 對應的時間點及之前的 oplog 在 majority 節點應用完成。這裏實際上最終也是通過調用 **ReplicationCoordinatorImpl::_awaitReplication_inlock** 阻塞在一個條件變量上,等待足夠多的 Secondary 節點彙報自己的複製進度信息後才被喚醒,完全複用了 majority writeConcern 的實現。所以,writeConcern,readConcern 除了在功能設計上有強關聯,在內部實現上也有互相依賴。

需要注意的是,**Speculative Read** 機制 MongoDB 並不打算提供給普通用戶使用,如果把 **replication.enableMajorityReadConcern** 設置爲 false 之後,繼續使用 majority readConcern,MongoDB 會返回 **ReadConcernMajorityNotEnabled** 錯誤。目前在一些內部命令的場景下才會使用該機制,測試目的的話,可以在 **find** 命令中加一個特殊參數: **allowSpeculativeMajorityRead: true**,強制開啓 **Speculative Read** 的支持。

針對 readConcern 的優化 —— Query Yielding

考慮到後文邏輯上的依賴,在分析其他 readConcern level 之前,需要先看一個 MongoDB 針對 readConcern 的優化措施。

**默認情況下,MongoDB Server 層面所有的讀操作在 WiredTiger 上都會開啓一個事務,並且採用 snapshot 隔離級別。**在 snapshot isolation 下,事務需要讀到一個一致性的快照,且讀取的數據是事務開始時最新提交的數據。而 WiredTiger 目前的多版本數據只能存放在內存中,所以在這個規則下,執行時間太久的事務會導致 WiredTiger 的內存壓力升高,進一步會影響事務的執行性能。

比如,在上圖中,事務 T1 開始後,根據 majority commit point 讀取自己可見的版本,x=1,其他的事務繼續對 x 產生修改並且提交,會產生的新的版本 x=2,x=3……,T1 只要不提交,那麼 x=2 及之後的版本都不能從內存中清理,否則就會違反 snapshot isolation 的語義。

面對上述情況,MongoDB 採用了一種稱之爲「Query Yielding」的手段來 “優化” 這個問題。

「Query Yielding」的思路其實非常簡單,就是在事務執行的過程中,定期的進行 **yield**,即釋放鎖,abort 當前的 WiredTiger 事務,釋放 hold 的 snapshot,然後重新打開事務,獲取新的 snapshot。顯然,通過這種方式,對於一個執行時間很長的 MongoDB 讀操作,它在引擎層事務的 read_ts 是不斷推進的,進而保證 read_ts 之後的版本能夠被及時從內存中清理。

之所以在優化前面加一個引號的原因是,這種方式雖然解決了長事務場景下,WT 內存壓力上漲的問題,但是是以犧牲快照隔離級別的語義爲代價的(降級爲 read committed 隔離級別),又是一個典型的犧牲一致性來換取更好的訪問性能的應用案例。

"local" 和 "majority" readConcern 都應用了「Query Yielding」機制,他們的主要區別是,"majority" readConcern 在 reopen 事務時採用新推進的 mcp 對應的 snapshot,而 "local" readConcern 採用最新的時間點對應的 snapshot。

Server 層在一個 Query 正常執行的過程中(**getNext()**),會不斷的調用 **_yieldPolicy->shouldYieldOrInterrupt()** 來判定是否需要 yield,目前主要由如下兩個因素共同決定是否 yield:

最後,除了根據上述配置主動的 yield 行爲,存儲引擎層面也會因爲一些原因,比如需要從 disk load page,事務衝突等,告知計劃執行器(PlanExecutor)需要 yield。MongoDB 的慢查詢日誌中會輸出一些有關執行計劃的信息,其中一項就是 Query 執行期間 yield 的次數,如果數據集不變的情況下,執行時長差別比較大,那麼就可能和要訪問的 page 在 WiredTiger Cache 中的命中率相關,可以通過 yield 次數來進行一定的判斷。

“snapshot” readConcern

前面我們已經提到了 "snapshot" readConcern 是專門用於 MongoDB 的多文檔事務的,MongoDB 多文檔事務提供類似於傳統關係型數據庫的事務模型(Conversational Transaction),即通過 **begin transaction** 語句顯示開啓事務, 根據業務邏輯執行不同的操作序列,然後通過 **commit transaction** 語句提交事務。"snapshot" readConcern 除了包含 "majority" readConcern 提供的語義,同時它還提供真正的一致性快照語義,因爲多文檔事務中的多個操作只會對應到一個 WiredTiger 引擎事務,並不會應用「Query Yielding」

這裏這麼設計的主要考慮是,和默認情況下爲了保證性能而採用單文檔事務不同,當應用顯示啓用多文檔事務時,往往意味着它希望 MongoDB 提供類似關係型數據庫的,更強的一致性保證,「Query Yielding」導致的 snapshot “漂移” 顯然是無法接受的。而且在目前的實現中,如果應用使用了多文檔事務,即使指定 "majority" 或 "local" readConcern,也會被強制提升爲 "snapshot" readConcern。

// If "startTransaction" is present, it must be true due to the parsing above.
const bool upconvertToSnapshot(sessionOptions.getStartTransaction());
auto newReadConcernArgs = uassertStatusOK(
  _extractReadConcern(invocation.get(), request.body, upconvertToSnapshot)); // 這裏強制提升爲 "snapshot" readConcern

不採用 「Query Yielding」也就意味着存在上節所說的 “WiredTiger Cache 壓力過大” 的問題,在 “snapshot” readConcern 下,當前版本沒有太好的解法(在 4.4 中會通過 durable history,即支持把多版本數據寫到磁盤,而不是隻保存在內存中來解決這個問題)。MongoDB 目前採用了另外一個比較簡單粗暴的方式來緩解這個問題,即限制事務執行的時長,**transactionLifetimeLimitSeconds** 配置的值決定了多文檔事務的最大執行時長,默認爲 60 秒。

超出最大執行時長的事務由後臺線程負責清理,默認每 30 秒進行一次清理動作。每個多文檔事務都會和一個 Logical Session 關聯,清理線程會遍歷內存中的 **SessionCatalog** 緩存找到所有過期事務,清理和事務關聯的 Session,然後 **abortTransaction**(具體可參考**killAllExpiredTransactions()**)。

"snapshot" readConcern 爲了同時維持分佈式環境下的 "majority" read 語義和事務本地執行的一致性快照語義,還會帶來另外一個問題:事務因爲寫衝突而 abort 的概率提升

在單機環境下,事務的寫衝突往往是因爲併發事務的執行修改了同一份數據,進而導致後提交的事務需要 abort(first-writer-win)。但是通過後面的解釋我們會看到,"snapshot" readConcern 爲了同時維持兩種語義,即使在單機環境下看起來是非併發的事務,也會因爲寫衝突而 abort。

要說明這個問題,先來簡單看下事務在 snapshot isolation 下的讀寫規則。

然後再回到前面的問題:爲什麼在 "snapshot" readConcern 下事務衝突 abort 的概率會提升?這裏我們結合一個例子來進行說明,

上圖中,C1 發起的事務 T1 在主節點(P)上提交後,需要複製到一個從節點(S) 並且 apply 完成纔算是 majority committed。在事務從 local committed 變爲 majority committed 這個延遲內(上圖中的紅圈),如果 C2 也發起了一個事務 T2,雖然 T2 是在 T1 提交之後纔開始的,但根據 "majority" read 語義的要求,T2 不能夠讀取 T1 剛提交的修改,而是基於 mcp 讀取 T1 修改前的版本,這個是符合前面的 snapshot read rule 的( D1 規則)。

但是,如果 T2 讀取了這個更早的版本並且做了修改,因爲 T2 的 **commit_ts**(有遞增要求) 大於 T1 的,根據前面的 snapshot commit rule(D2 規則),T2 需要 abort。

需要說明的是,應用對數據的訪問在時間和空間上往往呈現一定的局部性,所以上述這種 back-to-back transaction workload(T1 本地修改完成後,T2 接着修改同一份數據)在實際場景中是比較常見的,所以很有必要對這個問題作出優化。

MongoDB 對這個問題的優化也比較簡單,採用了和 "majority" readConcern 一樣的實現思路,即「speculative read」。MongoDB 把這種基於「speculative read」機制實現的 snapshot isolation 稱之爲「speculative snapshot isolation」。

仍然使用上面的例子,在「speculative snapshot isolation」機制下,事務 T2 在開始時不再基於 mcp 讀取 T1 提交前的版本,而是直接讀取最新的已提交值(T1 提交),這樣 $snapshot(T_2) >= commit(T_1)$ ,即使 T2 修改了同一條數據,也不會違反 D2 規則。

但是此時 T1 還沒有被複制到 majority 節點,T2 如果直接返回客戶端成功,顯然違反了 "majority" read 的語義。MongoDB 的做法是,在事務 T2 提交時,如果要維持 "majority" read 的語義,其必須也以 "majority" writeConcern 提交。這樣,如果 T2 產生了修改,在其等待自身的修改成爲 majority committed 時,發生它之前的事務 T1 的修改顯然也已經是 majority committed(這個是由 MongoDB 複製協議的順序性和 batch 併發 apply 的原子性保證的),所以自然可保證 T2 讀取到的最新值滿足 "majority" 語義。

這個方式本質上是一種犧牲 Latency 換取 Consistency 的做法,和基於 snapshot 的 "majority" readConcern 做法正好相反。這裏這麼設計的原因,並不是有目的的去提供更好的一致性,主要還是爲了降低事務衝突 abort 的概率,這個對 MongoDB 自身性能和業務的影響非常大,在這個基礎上,也可以說,保證業務讀取到最新的數據總是更有用的。

關於犧牲 Latency,實際上上述實現機制,對於寫事務來說並沒有導致額外的延遲,因爲事務自身以 "majority" writeConcern 提交進行等待以滿足自身寫的 majority committed 要求時,也順便滿足了 「speculative read」對等待的需求,缺點就是事務的提交必須要和 "majority" readConcern 強綁定,但是從多文檔事務隱含了對一致性有更高的要求來看,這種綁定也是合理的,避免了已提交事務的修改在重新選主後被回滾。

真正產生額外延遲的是隻讀事務,因爲事務本身沒有做任何修改,仍然需要等待。實際上這個延遲也可以被優化掉,因爲事務如果只是只讀,不管讀取了哪個時間點的快照,都不會和其他寫事務形成衝突,但是 MongoDB 目前並沒有提供標記多文檔事務爲只讀事務的接口,期待後續的優化。

“local” readConcern

"local" readConcern 在 MongoDB 裏面的語義最爲簡單,即直接讀取本地最新的已提交數據,但是它在 MongoDB 裏面的實現卻相對複雜。

首先我們需要了解的是 MongoDB 的複製協議是一種類似於 Raft 的複製狀態機(Replicated State Machine)協議,但它和 Raft 最大區別是,Raft 先把日誌複製到多數派節點,然後再 Apply RSM,而 MongoDB 是先 Apply RSM,然後再異步的把日誌複製到 Follower(Secondary) 去 Apply。

這種實現方式除了可以降低寫操作(在 default writeConcern 下)的延遲,也爲實現 "local" readConcern 提供了機會,而 Recency,前面的統計數據已經分析了,正是大部分的業務所更加關注的。

MongoDB 的這種設計雖然更貼近於用戶需求,但也爲它的 RSM 協議引入了額外的複雜性,這點主要體現在重新選舉時。

重新選主時可能會發生,已經在之前的 Primary 上追加的部分 log entry 沒有來及複製到新的 Primary 節點,那麼在前任 Primary 重新加入集羣時,需要把這部分多餘的 log entry 回滾掉(**注:**這種情況,除了舊主可能發生,其他節點也可能發生)。對於 Raft 來說這個回滾動作特別簡單,只需對 replicated log 執行 truncate,移除尾部多餘的 log entry,然後重新從現任 Primary 追日誌即可。

但是,對於 MongoDB 來說,由於在追加日誌前就已經對狀態機進行了 apply,所以除了 Log Truncation,還需要一個狀態機回滾(Data Rollback)流程。Data Rollback 是一個代價比較大的過程,而 MongoDB 本身的日誌複製是通常是很快的,真正在發生重新選舉時,未及時同步到新主的 log entry 是比較少的,所以如果能夠讓新主在接受寫操作之前,把舊主上 “多餘” 的日誌重新拉取過來並應用,顯然可以避免舊主的 Data Rollback。

重選舉時的 Catchup Phase

MongoDB 從 3.4 版本開始實現了上述機制(**catchup phase**),流程如下,

  1. 候選節點在成功收到多數派節點的投票後,會通過心跳(**replSetHeartBeat** 命令)向其他節點廣播自己當選的消息;

  2. 其他節點的的 heartbeat response 中會包含自己最新的 applied opTime,當選節點會把其中最大的 opTIme 作爲自己 catchup 的 **targetOpTime**

  3. 從 applied opTime 最大的節點或其下游節點同步數據,這個過程和正常的基於 oplog 的增量複製沒有太大區別;

  4. 如果在超時時間(由 **settings.catchUpTimeoutMillis** 決定,3.4 默認 60 秒)內追上了 **targetOpTime**,catchup 完成;

  5. 如果超時,當選節點並不會 stepDown,而是繼續作爲新的 Primary 節點。

void ReplicationCoordinatorImpl::CatchupState::signalHeartbeatUpdate_inlock() {
    auto targetOpTime = _repl->_topCoord->latestKnownOpTimeSinceHeartbeatRestart();
    ...
    ReplicationMetrics::get(getGlobalServiceContext()).setTargetCatchupOpTime(targetOpTime.get());
    log() << "Heartbeats updated catchup target optime to " << *targetOpTime;
    ...
}

上述第 5 步意味着,catchup 過程中如果有超時發生,其他節點仍然需要回滾,所以在 3.6 版本中,MongoDB 對這個機制進行了強化。3.6 把 **settings.catchUpTimeoutMillis** 的默認值調整爲 -1,即不超時。但爲了避免 **catchup phase** 無限進行,影響可用性(集羣不可寫),增加了 **catchup takeover** 機制,即集羣當前正在被當選節點作爲同步源 catchup 的節點,在等待一定的時間後,會主動發起選舉投票,來使 “不合格” 的當選節點下臺,從而減少 Data Rollback 的幾率和保證集羣儘快可用。

這個等待時間由副本集的 **settings.catchUpTakeoverDelayMillis** 配置決定,默認爲 30 秒。

stdx::unique_lock<stdx::mutex> ReplicationCoordinatorImpl::_handleHeartbeatResponseAction_inlock(
    ...
        case HeartbeatResponseAction::CatchupTakeover: {
            // Don't schedule a catchup takeover if any takeover is already scheduled.
            if (!_catchupTakeoverCbh.isValid() && !_priorityTakeoverCbh.isValid()) {
                Milliseconds catchupTakeoverDelay = _rsConfig.getCatchUpTakeoverDelay();
                _catchupTakeoverWhen = _replExecutor->now() + catchupTakeoverDelay;
                LOG_FOR_ELECTION(0) << "Scheduling catchup takeover at " << _catchupTakeoverWhen;
                _catchupTakeoverCbh = _scheduleWorkAt(
                    _catchupTakeoverWhen, [=](const mongo::executor::TaskExecutor::CallbackArgs&) {
                        _startElectSelfIfEligibleV1(StartElectionReasonEnum::kCatchupTakeover); // 主動發起選舉
                    });
            }
    ...

Data Rollback 是無法徹底避免的,因爲 **catchup phase** 也只能發生在擁有最新 log entry 的節點在線的情況下,即能夠向當選節點恢復心跳包,如果在選舉完成後,節點才重新加入集羣,仍然需要回滾。

MongoDB 目前存在兩種 Data Rollback 機制:「Refeched Based Rollback」 和 「Recover To Timestamp Rollback」,其中後一種是在 4.0 及之後的版本,伴隨着 WiredTiger 存儲引擎能力的提升而演進出來的,下面就簡要描述一下它們的實現方式及關聯。

Refeched Based Rollback

「Refeched Based Rollback」 可以稱之爲邏輯回滾,下面這個圖是邏輯回滾的流程圖,

首先待回滾的舊主,需要確認重新選主後,自己的 oplog 歷史和新主的 oplog 歷史發生 “分叉” 的時間點,在這個時間點之前,新主和舊主的 oplog 是一致的,所以這個點也被稱之爲「common point」。舊主上從「common point」開始到自己最新的時間點之間的 oplog 就是未來及複製到新主的 “多餘” 部分,需要回滾掉。

common point 的查找邏輯在 **syncRollBackLocalOperations()** 中實現,大致流程爲,由新到老(反向)從同步源節點獲取每條 oplog,然後和自己本地的 oplog 進行比對。本地 oplog 的掃描同樣爲反向,由於 oplog 的時間戳可以保證遞增,掃描時可以通過保存中間位點的方式來減少重複掃描。如果最終在本地找到一條 oplog 的時間戳和 **term** 和同步源的完全一樣,那麼這條 oplog 即爲 common point。由於在分佈式環境下,不同節點的時鐘不能做到完全實時同步,而 term 可以唯一標識一個主節點在任期間的修改(oplog)歷史,所以需要把 oplog ts 和 term 結合起來進行 common point 的查找。

在找到 common point 之後,待回滾節點需要把當前最新的時間戳到 common point 之間的 oplog 都回滾掉,由於回滾採用邏輯的方式,整個流程還是比較複雜的。

首先,MongoDB 的 oplog 本質上是一種 redo log,可以通過重新 apply 來進行數據恢復,而且 oplog 記錄時對部分操作進行了重寫,比如 **{$inc : {quantity : 1}}** 重寫爲 **{$set : {quantity : val}}** 等,來保證 oplog 的冪等性,按序重複應用 oplog,並不會導致數據不一致。但是 oplog 並不包含 undo 信息,所以對於部分操作來說,無法實現基於本地信息直接回滾,比如對於 delete,dropCollection 等操作,刪除掉的文檔在 oplog 並無記錄,顯然無法直接回滾。

對於上述情況,MongoDB 採用了所謂「refetch」的方式進行回滾,即重新從同步源獲取無法在本地直接回滾的文檔,但是這個方式的問題在於 oplog 回滾到 tcommon 時,節點可能處於一個不一致的狀態。舉個例子,在 tcommon 時舊主上存在兩條文檔 **{x : 10}** 和 **{y : 20}**,在重新選主之後,舊主上對 **x** 的 delete 操作並未同步到新主,在新主新的歷史中,客戶端先後對 x 和 y 做了更新:**{$set : {y : 200}} ; {$set : {x : 100}}**。在舊主通過「refetch」的方式完成回滾後,它在 tcommon 的狀態爲: **{x : 100}** 和 **{y : 20}**,顯然這個狀態對於客戶端來說是不一致的。

這個問題的根本原因在於,**「refetch」時只能獲取到被刪除文檔當前最新的狀態,而不是被刪除前的狀態,這個方式破壞了在客戶端看來可能存在因果關係的不同文檔間的一致性狀態。**我們具體上面的例子來說,回滾節點在「refetch」時相當於直接獲取了 **{$set : {x : 100}}** 的狀態變更操作,而跳過了 **{$set : {y : 200}}**,如果要達到一致性狀態,看起來只要重新應用 **{$set : {y : 200}}** 即可。但是回滾節點基於現有信息是無法分析出來跳過了哪些狀態的,對於這個問題,直接但是有效的做法是,把同步源從 tcommon 之後的 oplog 都重新拉取並「reapply」一遍,顯然可以把跳過的狀態補齊。而這中間也可能存在對部分狀態變更操作的重複應用,比如 **{$set : {x : 100}}**,這個時候 oplog 的冪等性就發揮作用了,可以保證數據在最終「reapply」完後的一致性不受影響。

剩下的問題就是,拉取到同步源 oplog 的什麼位置爲止?對於回滾節點來說,導致狀態被跳過的原因是進行了「refetch」,所以只需要記錄每次「refetch」時同步源最新的 oplog 時間戳,「reapply」時拉取到最後一次「refetch」對應的這個同步源時間戳就可以保證狀態的正確補齊,MongoDB 在實現中把這個時間戳稱之爲 **minValid**

MongoDB 在邏輯回滾的過程中也進行了一些優化,比如在「refetch」之前,會掃描一遍需要回滾的操作(這個不需要專門來做,在查找 common point 的過程即可實現),對於一些存在 “互斥” 關係的操作,比如 **{insert : {_id:1}** 和 **{delete : {_id:1}}**,就沒必要先 refetch 再 delete 了,直接忽略回滾處理即可。但是從上面整體流程看,「Refeched Based Rollback」仍然複雜且代價高:

所以在 4.0 版本中,隨着 WiredTiger 引擎提供了回滾到指定的 Timestamp 的功能後,MongoDB 也用物理回滾的機制取代了上述邏輯回滾的機制,但在某些特殊情況下,邏輯回滾仍然有用武之地,下面就對這些做簡要分析。

Recover To Timestamp Rollback

「Recover To Timestamp Rollback」是藉助於存儲引擎把物理數據直接回滾到某個指定的時間點,所以這裏把它稱之爲物理回滾,下面是 MongoDB 物理回滾的一個簡化的流程圖,

前面已經提到了 stable timestamp 的語義,這裏不再贅述,MongoDB 有一個後臺線程(**WTCheckpointThread**)會定期(默認情況下每 60 秒,由 **storage.syncPeriodSecs** 配置決定)根據 stable timestamp 觸發新的 checkpoint 創建,這個 checkpoint 在實現中被稱爲 「stable checkpoint」。

class WiredTigerKVEngine::WiredTigerCheckpointThread : public BackgroundJob {
public:
...
    virtual void run() {
            ...
            {
                stdx::unique_lock<stdx::mutex> lock(_mutex);
                MONGO_IDLE_THREAD_BLOCK;
                _condvar.wait_for(lock,
                                  stdx::chrono::seconds(static_cast<std::int64_t>(
                                      wiredTigerGlobalOptions.checkpointDelaySecs)));
            }
            ...    
                    UniqueWiredTigerSession session = _sessionCache->getSession();
                    WT_SESSION* s = session->getSession();
                    invariantWTOK(s->checkpoint(s, "use_timestamp=true"));
            ...
    }
...    
}

stable checkpoint 本質上是一個持久化的歷史快照,它所包含的數據修改已經複製到多數派節點,所以不會發生重新選主後修改被回滾。其實 WiredTiger 本身也可以配置根據生成的 WAL 大小或時間來自動觸發創建新的 checkpoint,但是 Server 層並沒有使用,原因就在於 MongoDB 需要保證在回滾到上一個 checkpoint 時,狀態機肯定是 “stable” 的,不需要回滾。

WiredTiger 在創建 stable checkpoint 時也是開啓一個帶時間戳的事務來保證 checkpoint 的一致性,checkpoint 線程會把事務可見範圍內的髒頁刷盤,最後對應到磁盤上就是一個由多個變長數據塊(WT 中稱之爲**extent**)構成的 BTree。

回滾時,同樣要先確定 common point,這個流程和邏輯回滾沒有區別,之後, Server 層會首先 abort 掉所有活躍事務,接着調用 WT 提供的 **rollback_to_stable()** 接口把數據庫回滾到 stable checkpoint 對應的狀態,這個動作主要是重新打開 checkpoint 對應的 BTree,並重新初始化 catalog 信息,**rollback_to_stable()** 執行完後會向 Server 層返回對應的 stable timestamp。

考慮到 stable checkpoint 觸發的間隔較大,通常 common point 總是大於 stable checkpoint 對應的時間戳,所以 Server 層在拿到引擎返回的時間戳之後會還需要從其開始重新 apply 本地的 oplog 到 common point 爲止,然後把 common point 之後的 oplog truncate 掉,從而達到和新的同步源一致的狀態。這個流程主要在 **RollbackImpl::_runRollbackCriticalSection()** 中實現,

Status RollbackImpl::_runRollbackCriticalSection(
    OperationContext* opCtx,
    RollBackLocalOperations::RollbackCommonPoint commonPoint) noexcept try {
    ...
    killSessionsAbortAllPreparedTransactions(opCtx); // abort 活躍事務
    ...
    auto stableTimestampSW = _recoverToStableTimestamp(opCtx); // 引擎層回滾
    ...
    Timestamp truncatePoint = _findTruncateTimestamp(opCtx, commonPoint); // 查找並設置 truncate 位點
    _replicationProcess->getConsistencyMarkers()->setOplogTruncateAfterPoint(opCtx, truncatePoint);
    ...
    // Run the recovery process. // 這裏會進行 reapply oplog 和 truncate oplog
    _replicationProcess->getReplicationRecovery()->recoverFromOplog(opCtx,
                                                                    stableTimestampSW.getValue());
    ...                                                                    
}

此外,爲了確保回滾可以正常進行,Server 層在 oplog 的自動回收時還需要考慮 stable checkpoint 對部分 oplog 的依賴。通常來說,stable timestamp 之前的 oplog 可以安全的回收,但是在 4.2 中 MongoDB 增加了對大事務(對應的 oplog 大小超過 16MB)和分佈式事務的支持,在 stable timestamp 之前的 oplog 在回滾 reapply oplog 的過程中也可能是需要的,所以在 4.2 中 oplog 的回收需要綜合考慮當前最老的活躍事務和 stable timestamp。

StatusWith<Timestamp> WiredTigerKVEngine::getOplogNeededForRollback() const {
    ...
    if (oldestActiveTransactionTimestamp) {
        return std::min(oldestActiveTransactionTimestamp.value(), Timestamp(stableTimestamp));
    } else {
        return Timestamp(stableTimestamp);
    }
}

整體上來說,基於引擎 stable checkpoint 的物理回滾方式在回滾效率和回滾邏輯複雜性上都要優於邏輯回滾。但是 stable checkpoint 的推進要依賴 Server 層 majority commit point 的推進,而 majority commit point 的推進受限於各個節點的複製進度,所以複製慢時可能會導致 Primary 節點 cache 壓力過大,所以 MongoDB 提供了 **replication.enableMajorityReadConcern** 參數用於控制是否維護 mcp,關閉後存儲引擎也不再維護 stable checkpoint,此時回滾就仍然需要進行邏輯回滾,這也是在 4.2 中仍然保留「Refeched Based Rollback」的原因。

“linearizable” readConcern

在一個分佈式系統中,如果總是把可用性擺在第一位,那麼因果一致性是其能夠實現的最高一致性級別。前面我們也通過統計數據分析了在大部分情況下用戶總是更關注延遲(可用性)而不是一致性,而 MongoDB 副本集,正是從用戶需求角度出發,被設計成了一個在默認情況下總是優先保證可用性的分佈式系統,下圖是一個簡單的例證。

既然如此,那 MongoDB 是如何實現 “linearizable” readConcern,即更高級別的線性一致性呢?MongoDB 的策略很簡單,就是把它退化到幾乎是單機環境下的問題,即只允許客戶端在 Primary 節點上進行 “linearizable” 讀。說是 “幾乎”,因爲這個策略仍然需要解決如下兩個在副本集這個分佈式環境下存在的問題,

  1. Primary 角色可能會發生變化,“linearizable” readConcern 需要保證每次讀取總是能夠從當前的 Primary 讀取,而不是被取代的舊主。

  2. 需要保證讀取到讀操作開始前最新的寫,而且讀到的結果不會在重新選主後發生回滾。

MongoDB 採用同一個手段解決了上述兩個問題,當客戶端採用 “linearizable” readConcern 時,在讀取完 Primary 上最新的數據後,在返回前會向 Oplog 中顯示的寫一條** **noop** ****的操作,然後等待這條操作在多數派節點複製成功**。顯然,如果當前讀取的節點並不是真正的主,那麼這條 **noop** 操作就不可能在 majority 節點複製成功,同時,如果 **noop** 操作在 majority 節點複製成功,也就意味着之前讀取的在 **noop** 之前寫入的數據也已經複製到多數派節點,確保了讀到的數據不會被回滾。

// src/mongo/db/read_concern_mongod.cpp:waitForLinearizableReadConcern()
...
        writeConflictRetry(
            opCtx,
            "waitForLinearizableReadConcern",
            NamespaceString::kRsOplogNamespace.ns(),
            [&opCtx] {
                WriteUnitOfWork uow(opCtx);
                opCtx->getClient()->getServiceContext()->getOpObserver()->onOpMessage(
                    opCtx,
                    BSON("msg"
                         << "linearizable read")); // 寫 noop 操作
                uow.commit();
            });
...
    auto awaitReplResult = replCoord->awaitReplication(opCtx, lastOpApplied, wc); // 等待 noop 操作 majority committed

這個方案的缺點比較明顯,單純的讀操作既產生了額外的寫開銷,也增加了延遲,但是這個是選擇最高的一致性級別所需要付出的代價。

Causal Consistency

前面幾個章節描述的由 writeConcern 和 readConcern 所構成的 MongoDB 可調一致性模型,仍然是屬於最終一致性的範疇(特殊實現的 “linearizable” readConcern 除外)。雖然最終一致性對於大部分業務場景來說已經足夠了,但是在某些情況下仍然需要更高的一致性級別,比如在下圖這個經典的銀行存款業務中,如果只有最終一致性,那麼就可能導致客戶看到的賬戶餘額異常。

這個問題雖然可以在業務端通過記錄一些額外的狀態和重試來解決,但是顯然會導致業務邏輯過於複雜,所以 MongoDB 實現了「Causal Consistency Session」功能來幫助降低業務複雜度。

Causal Consistency 定義了分佈式系統上的讀寫操作需要滿足一個偏序(Partial Order)關係,即只部分操作發生的先後順序可比。這個部分操作,進一步來說,指的是存在因果關係的操作,在 MongoDB 的「Causal Consistency Session」實現中,什麼樣的操作間算是存在因果關係,可參考前文提到的 Client-centric Consistency Model 下的 4 個一致性承諾分別對應的讀寫場景,此處不再贅述。

所以,要實現因果一致性,MongoDB 首要解決的問題是如何給分佈式環境下存在因果關係的操作定序,這裏 MongoDB 借鑑了 Hybrid Logical Clock 的設計,實現了自己的 ClusterTime 機制,下面就對其實現進行分析。

分佈式系統中存在因果關係的操作定序

關於分佈式系統中的事件如何定序的論述,最有影響力的當屬 Leslie Lamport 的這篇 《Time, Clocks, and the Ordering of Events in a Distributed System》,其中提到了一種 Logical Clock 用來確定不同事件的全序,後人也把它稱爲 Lamport Clock。

Lamport Clock 只用一個單純的變量(scalar value)來表示,所以它的缺點之一是無法識別存在併發的事件****(independent event),而這個會在實際的系統帶來一些問題,比如在支持多點寫入的系統中,無法基於 Lamport Clock 對存在寫衝突的事件進行識別和處理。所以,後面又衍生出了 vector clock 來解決這一問題,但 vector clock 會存儲數據的多個版本,數據量和系統中的節點數成正比,所以實際使用會帶來一些擴展性的問題。

Lamport Clock 存在的另外一個缺點是,它完全是一個邏輯意義上的值,和具體的物理時鐘沒有關聯,而在現實的應用場景中,存在一些需要基於真實的物理時間進行訪問的場景,比如數據的備份和恢復。Google 在其 Spanner 分佈式數據庫中提到了一種稱之爲 TrueTime 的分佈式時鐘設計,爲事務執行提供時間戳。TrueTime 和真實物理時鐘關聯,但是需要特殊的硬件(原子鐘 / GPS)支持,MongoDB 作爲一款開源軟件,需要做到通用的部署,顯然無法採用該方案。

考慮到 MongoDB 本身不支持 「Multi-Master」 架構,而上述的分佈式時鐘方案均存在一些 MongoDB 在設計上需要規避的問題,所以 MongoDB 採用了一種所謂的混合邏輯時鐘(Hybrid Logical Clock)的方案。HLC 設計上基於 Lamport Clock,只使用單個時鐘變量,在具備給因果操作定序的能力同時,也能夠(儘可能)接近真實的物理時鐘。

Hybrid Logical Clock 基本原理

先來了解一下 HLC 中幾個基本的概念,

從上面的 HLC 時鐘推進圖中,可以看到,如果不考慮 **l** 部分(假設 **l** 總是不變),則 **c** 等同於 Lamport Clock,如果考慮 **l** 的變化,因爲 **l** 是高位部分,只需要保證 **if e hb f, l.e <= l.f**,仍然可以確定存在因果關係的事件的先後順序,具體的更新規則可以參考上面的算法。

但是 **l** 的更新機制也決定了其他節點的時鐘出現跳變或不同步,會導致 HLC 被推進,進而導致和 **pt** 產生誤差,但 HLC 的機制決定了這個誤差是有限的。上面的圖就是一個很好的案例,假設當前的真實物理時鐘是 0,而 0 號節點的時鐘出現了跳變,變爲 10,則在後續的時鐘推進中,**l** 部分不會再增長,只會增加 **c** 部分,直到真實的物理時鐘推進到 10,**l** 纔會關聯新的 **pt** 。

MongoDB 在實現 Causal Consistency 之前就已經在副本集同步的 oplog 時間戳中使用了類似的設計,選擇 HLC,也是爲了方便和現有設計集成。Causal Consistency 不僅是在單一的副本集層面使用,在基於副本集構建的分片集羣中同樣有需求,所以這個新的分佈式時鐘,在 v3.6 中被稱爲 ClusterTime

MongoDB ClusterTime 實現

MongoDB ClusterTime 基本上是嚴格按照 HLC 的思路來實現的,但它和 HLC 最大的一點不同是,在 HLC 或 Lamport Clock 中,消息的發送和接受都被認爲是一個事件,會導致時鐘值增加,但在 MongoDB ClusterTime 實現中,只有會改變數據庫狀態的操作發生纔會導致 ClusterTime 增加,比如通常的寫操作,這麼做的目的還是爲了和現有的 oplog 中的混合時間戳機制集成,避免更大的重構開銷和由此帶來的兼容性問題,同時這麼做也並不會影響 ClusterTime 在邏輯上的正確性。

因爲有了上述區別,ClusterTime 的實現就可以被分爲兩部分,一個是 ClusterTime 的增加 (Tick),一個是 ClusterTime 的推進 (Advance)。

ClusterTime 的 Tick 發生在 MongoDB 接收到寫操作時,ClusterTime 由 **<Time><Counter>** 來表示,是一個 64bit 的整數,其中高 32 位對應到 HLC 中的物理部分,低 32 位對應到 HLC 中的邏輯部分。而每一個寫操作在執行前都會爲即將要寫的 oplog 提前申請對應的 OpTime(調用 **getNextOpTimes()** 來完成),OpTime 由 **<Time><Counter><ElectionTerm>** 來表示,**ElectionTerm** 和 MongoDB 的複製協議相關,是一個本地的狀態值,不需要被包含到 ClusterTime 中,所以原有的 OpTime 在新版本中實際上是可以由 ClusterTime 直接轉化得來,而 ClusterTime 也會隨着 Oplog 寫到磁盤而被持久化。

std::vector<OplogSlot> LocalOplogInfo::getNextOpTimes(OperationContext* opCtx, std::size_t count) {
...
        // 申請 OpTime 時會 Tick ClusterTime 並獲取 Tick 後的值
        ts = LogicalClock::get(opCtx)->reserveTicks(count).asTimestamp();
        const bool orderedCommit = false;
...
    std::vector<OplogSlot> oplogSlots(count);
    for (std::size_t i = 0; i < count; i++) {
        oplogSlots[i] = {Timestamp(ts.asULL() + i), term}; // 把 ClusterTime 轉化爲 OpTime
...
    return oplogSlots;
}
// src/mongo/db/logical_clock.cpp:LogicalClock::reserveTicks() 包含了 Tick 的邏輯,和 HLC paper 一致,主要邏輯如下
{
    newCounter = 0;
    wallClockSecs = now();
    // _clusterTime is a current local value of node’s ClusterTime
    currentSecs = _clusterTime.getSecs();
    if (currentSecs > wallClockSecs) {
        newSecs = currentSecs;
        newCounter = _clusterTime.getCounter() + 1;
    } else {
        newSecs = wallClockSecs;
    }
    _clusterTime = ClusterTime(newSecs, newCounter);
    return _clusterTime;
}

ClusterTime 的 Advance 邏輯比較簡單,MongoDB 會在每個請求的回覆中帶上當前節點最新的 ClusterTime,如下,

"$clusterTime" : {
    "clusterTime" : Timestamp(1495470881, 5),
    "signature" : {
        "hash" : BinData(0, "7olYjQCLtnfORsI9IAhdsftESR4="),
        "keyId" : "6422998367101517844"
    }
}

接收到該 ClusterTime 的角色(mongos,client)如果發現更新的 ClusterTime,就會更新本地的值,同時在和別的節點通信的時候,帶上這個新 ClusterTime,從而推進其他節點上的 ClusterTime,這個流程實際上是一種類似於 Gossip 的消息傳播機制

因爲 Client 會參與到 ClusterTime 的推進(Advance),如果有惡意的 Client 篡改了自己收到的 ClusterTime,比如把高位和低位部分都改成了 UINT32_MAX,則收到該 ClusterTime 的節點後續就無法再進行 Tick,這個會導致整個服務不可用,所以 MongoDB 的 ClusterTime 實現增加了簽名機制(這個安全方面的增強 HLC 沒有提及),上面的**signature** 字段即對應該功能,mongos 或 mongod 在收到 Client 發送過來的 **$ClusterTime** 時,會根據 config server 上存儲的 key 來進行簽名校驗,如果 ClusterTime 被篡改,則簽名不匹配,就不會推進本地時鐘。

除了惡意的 Client,操作失誤也可能導致 mongod 節點的 wall clock 被更新爲一個極大的值,同樣會導致 ClusterTime 不能 Tick,針對這個問題,MongoDB 做了一個限制,新的 ClusterTime 和當前 ClusterTime 的差值如果超出 **maxAcceptableLogicalClockDriftSecs**,默認爲 1 年,則當前的 ClusterTime 不會被推進。

MongoDB Causal Consistency 實現

在 ClusterTime 機制的基礎上,我們就可以給不同的讀寫操作定序,但是操作對應的 ClusterTime 是在其被髮送到數據節點(mongod)上之後才被賦予的,如果要實現 Causal Consistency 的承諾,比如前面提到的「Read Your Own Write」,顯然我們需要 Client 也知道寫操作在主節點執行完後對應的 ClusterTime。

        ...
        "operationTime" : Timestamp(1612418230, 1), # Stable ClusterTime
        "ok" : 1,
        "$clusterTime" : { ... }

所以 MongoDB 在請求的回覆中除了帶上 **$clusterTIme** 用於幫助推進混合邏輯時鐘,還會帶上另外一個字段 **operationTime** 用來表明這個請求包含的操作對應的 ClusterTime,**operationTime** 在 MongoDB 中也被稱之爲 「Stable ClusterTime」,它的準確含義是操作執行完成時,當前最新的 Oplog 時間戳(OpTime)。所以對於寫操作來說,**operationTime** 就是這個寫操作本身對應的 Oplog 的 OpTime,而對於讀操作,取決於併發的寫操作的執行情況。

Client 在收到這個 **operationTime** 後,如果要實現因果一致,就會在發送給其他節點的請求的 **afterClusterTime** 字段中帶上這個 **operationTime**,其他節點在處理這個請求時,只會讀取 **afterClusterTime** 之後的數據狀態,這個過程是通過顯式的等待同步位點推進來實現的,等待的邏輯和前面提到的 speculative “majority” readConcern 實現類似。上圖是 MongoDB 副本集實現「Read Your Own Write」的基本流程。

如果是在分片集羣形態下,由於混合邏輯時鐘的推進依賴於各個參與方(client/mongos/mongd)的交互,所以會暫時出現不同分片間的邏輯時鐘不一致的情況,所以在這個架構下,我們**需要解決某個分片的邏輯時鐘滯後於**** **afterClusterTime** **而且一直沒有新的寫入,導致請求持續被阻塞的問題,MongoDB 的做法是,在這種情況下顯式的寫一條 **noop** 操作到 oplog 中,相當於強制把這個分片的數據狀態推進到 **afterClusterTime** 之後,從而確保操作能夠儘快返回,同時也符合因果一致性的要求。

**/****/ **總結

本文對 MongoDB 一致性模型在設計上的一些考慮和主要的實現機制進行了分析,這其中包括由 writeConcern 和 readConcern 機制構建的可調一致性模型,對應到標準模型中就是最終一致性和線性一致性,但是 MongoDB 藉助 read/write concern 這兩者的配合,爲用戶提供更豐富的一致性和性能間的選擇。此外,我們也分析了 MongoDB 如何基於 ClusterTime 混合邏輯時鐘機制來給分佈式環境下的讀寫操作定序,進而實現因果一致性。

從功能和設計思路來看,MongoDB 無疑是豐富和先進的,但是在接口層面,讀寫採用不同的配置和級別,事務和非事務的概念區分,Causal Consistency Session 對 read/writeConcern 的依賴等,都爲用戶的實際使用增加了門檻,當然這些也是 MongoDB 在易用性、功能性和性能多方取捨的結果,相信 MongoDB 後續會持續的做出改進。

最後,伴隨着 NewSQL 概念的興起,「分佈式 + 橫向擴展 + 事務能力」逐漸成爲新數據庫系統的標配,MongoDB 也不例外。當我們在傳統單機數據庫環境下談論一致性,更多指的是事務間的隔離性(Isolation),如果把隔離性這個概念映射到分佈式架構下,可以容易看出,MongoDB 的 "local" readConcern 即對應 read uncommitted,"majority" readConcern 即對應 read committed,而 "snapshot" readConcern 對應的就是分佈式的全局快照隔離,即這些新的概念部分也是來自於經典的 ACID 理論在分佈式環境下的延伸,帶上這樣的視角可以讓我們更容易理解 MongoDB 的一致性模型設計。

**/****/ **參考文檔

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/JS_wDs4Ynu-TdQFZhuzdDw