本文源自閱讀了 MongoDB 于 VLDB 19 上發表的 Tunable Consistency in MongoDB 論文之後,在内部所做的分享(分享 PPT 見文末)。現在把分享的内容整理成此文,并且補充了部分在之前的分享中略過的細節,以及在分享中沒有提及的 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 年,在一篇
中被正式提出,其核心觀點是,根據 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 上
的 2.3 節。
Causal Consistency 在 MongoDB 中是相對比較獨立一塊實作,隻有當用戶端讀寫的時候開啟 Causal Consistency Session 才提供相應承諾。
沒有開啟 Causal Consistency Session 時,MongoDB 通過
writeConcern readConcern接口提供了可調一緻性,具體來說,包括線性一緻性和最終一緻性。最終一緻性在标準中的定義是非常寬松的,是最弱的一緻性模型,但是在這個一緻性級别下 MongoDB 也通過 writeConcern 和 readConcern 接口的配合使用,提供了豐富的對性能和正确性的選擇,進而貼近真實的業務場景。
MongoDB 可調一緻性模型功能接口 —— writeConcern 和 readConcern
在 MongoDB 中,
是針對寫操作的配置,
是針對讀操作的配置,而且都支援在單操作粒度(Operation Level) 上調整這些配置,使用起來非常的靈活。writeConcern 和 readConcern 互相配合,共同構成了 MongoDB 可調一緻性模型的對外功能接口。
writeConcern —— 唯一關心的就是寫入資料的持久性(Durability)
我們首先來看針對寫操作的 writeConcern,寫操作改變了資料庫的狀态,才有了讀操作的一緻性問題。同時,我們在後面章節也會看到,MongoDB 一些 readConcern 級别的實作也強依賴 writeConcern 的實作。
MongoDB writeConcern 包含如下選項,
{ w: <value>, j: <boolean>, wtimeout: <number> }
-
,指定了這次的寫操作需要複制并應用到多少個副本內建員才能傳回成功,可以為數字或 “majority”(為了避免引入過多的複雜性,這裡忽略 基于 tag 的自定義 writeConcern )。w
時比較特殊,即用戶端不需要收到任何有關寫操作是否執行成功的确認,具有最高性能。w:0
需要收到多數派節點(含 Primary)關于操作執行成功的确認,具體個數由 MongoDB 根據副本集配置自動得出。w: majority
-
,額外要求節點回複确認時,寫操作對應的修改已經被持久化到存儲引擎日志中。j
-
,Primary 節點在等待足夠數量的确認時的逾時時間,逾時傳回錯誤,但并不代表寫操作已經執行失敗。wtimeout
從上面的定義我們可以看出,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>
,表示了節點的複制進度。
- local/available:local 和 available 的語義基本一緻,都是讀操作直接讀取本地最新的資料。但是,available 使用在 MongoDB 分片叢集場景下,含 特殊語義 (為了保證性能,可以傳回孤兒文檔),這個特殊語義和本文的主題關聯不大,是以後面我們隻讨論 local readConcern。在這個級别下,發生重新選主時,已經讀到的資料可能會被復原掉。
- majority:讀取「majority committed」的資料,可以保證讀取的資料不會被復原,但是并不能保證讀到本地最新的資料。比如,對于上圖中的 Primary 節點讀,雖然
已經是最新的已送出值,但是由于不是「majority committed」,是以當讀操作使用 majority readConcern 時,隻傳回x=5
。x=4
- linearizable:承諾線性一緻性,即,既保證能讀取到最新的資料(Recency Guarantee),也保證讀到資料不會被復原(Durability Guarantee)。前面我們說了,線性一緻性在真實系統中很難實作,MongoDB 在這裡采用了一個相當簡化的設計,當讀操作指定 linearizable readConcern level 時,讀操作隻能讀取 Primary 節點,而考慮到寫操作也隻能發生在 Primary,相當于 MongoDB 的線性一緻性承諾被限定在了單機環境下,而非分布式環境,實作上自然就簡單很多。考慮到會有重新選主的情況,MongoDB 在這個 readConcern level 下唯一需要解決的問題就是,確定每次讀發生在真正的 Primary 節點上。後面分析具體實作我們可以看到,解決這個問題是以增加讀延遲為代價的。
以上各 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 産生影響,
- 「linearizable readConcern」讀取的資料需要是以「majority writeConcern」寫入且持久化到日志中,才能提供 真正的 「線性一緻性」語義。考慮如下情況,資料寫入到 majority 節點後,沒有在日志中持久化,當 majority 節點發生重新開機恢複,那麼之前使用 「linearizable readConcern」讀取到的資料就可能丢失,顯然和「線性一緻性」的語義不相符。在 MongoDB 中, writeConcernMajorityJournalDefault 參數控制了,當寫操作指定 「majority writeConcern」的時候,是否保證寫操作在日志中持久化,該參數預設為 true。另外一種情況是,寫操作持久化到了日志中,但是沒有複制到 majority 節點,在重新選主後,同樣可能會發生資料丢失,違背一緻性承諾。
- 「majority readConcern」要求讀取 majority committed 的資料,是以受限于不同節點的複制進度,可能會讀取到更舊的值。但是如果資料是以更高的 writeConcern
值寫入的,即寫操作在擴散到更多的副本集節點上之後才傳回寫成功,顯然之後再去讀取,「majority readConcern」能有更大的機率讀到最新寫入的值(More Recency Guarantee)。w
是以,writeConcern 雖然隻關注了寫入資料的持久化程度,但是作為讀操作的資料來源,也間接的也影響了 MongoDB 對讀操作的一緻性承諾。
writeConcern 和 readConcern 在實際業務中的應用
前面是對 writeConcern 和 readConcern 在功能定義上的介紹,可以看到,讀寫采用不同的配置,每個配置下面又包含不同的級别,這個接口設計對于使用者來說還是稍顯複雜的(社群中也有不少類似的回報),下面我們就來了解一下 writeConcern 和 readConcern 在真實業務中的統計資料以及幾個典型應用場景,以加深對它們的了解。
上面的統計資料來自于 MongoDB 自己的 Atlas 雲服務中使用者 Driver 上報的資料,統計樣本在百億量級,是以準确性是可以保證的,從資料中我們可以分析出如下結論,
- 大部分的使用者實際上隻是單純的使用預設值
- 在讀取資料時,99% 以上的使用者都隻關心能否盡可能快的讀取資料,即使用 local readConcern
- 在寫入資料時,雖然大部分使用者也隻要求寫操作在本地寫成功即可,但仍然有不小的比例使用了 majority writeConcern(16%,遠高于使用 majority readConcern 的比例),因為寫操作被復原對使用者來說通常都是更影響體驗的。
此外,MongoDB 的預設配置({w:1} writeConcern, local readConcern)都是更傾向于保護 Latency 的,主要是基于這樣的一個事實:主備切換事件發生的機率比較低,即使發生了丢資料的機率也不大。
統計資料給了我們一個 MongoDB readConcern/writeConcern 在真實業務場景下使用情況的直覺認識,即,大部分使用者更關注 Latency,而不是 Consistency。但是,統計資料同時也說明 readConcern/writeConcern 的使用組合是非常豐富的,使用者通過使用不同的配置值來滿足需求各異的業務場景對一緻性和性能的要求,比如如下幾個實際業務場景中的應用案例(均來自于 Atlas 雲服務中的使用者使用場景),
- Majority reads and writes:在這個組合下,意味着對資料安全性的關注是第一優先級的。考慮一個助學貸款的網站,網站的通路流量并不高,大約每分鐘兩次寫入(送出申請),對于一個申請貸款的學生來說,顯然不能接受自己成功送出的申請在背景 MongoDB 資料庫發生重新選主時資料“丢失”,同樣也不能接受擷取到申請通過結果的情況下,再次查詢,可能因為讀取的資料被復原,結果發生變化的情形,是以業務選擇使用 majority readConcern & writeConcern 的組合,通過犧牲讀寫延遲來換取資料的安全性。
- Local reads and Majority writes:考慮一個餐飲評價的 App,比如大衆點評,使用者可能要花很大的精力來編輯一條精彩的評價,如果因為後端 MongoDB 執行個體發生主備切換導緻評論丢失,對使用者來說顯然是不可接受的,是以使用者評價的送出(寫)需要使用 majority writeConcern,但是讀到一條可能後續會因為復原而“消失”的評價,對使用者來說往往是可以接受,考慮到要兼顧性能,使用 local readConcern 顯然是一個更優的選擇。
- Multiple Write Concern Values:在同一個業務場景中,也不用隻局限于一種 writeConcern/readConcern value,可以在不同的條件下使用不同的值來兼顧性能和一緻性。比如,考慮一個文檔系統,通常這樣的系統在使用者編輯文檔時,會提供自動儲存功能,對于非使用者主動觸發的釋出或儲存,自動儲存的結果如果産生丢失,使用者往往是感覺不到的,而自動儲存功能相對又是會比較頻繁的觸發(寫壓力更大),是以這種寫動作使用 local writeConcern 顯然更合理,寫延遲更低,而低頻的主動儲存或釋出,應該使用 majority writeConcern,因為這種情況使用者對要儲存的資料有明确的感覺,很難接受資料的丢失。
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 的一緻性模型的具體實作機制以及在其中做了哪些優化。
在 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
的含義和差別如下,
-
:Secondary 上 Apply 完一批 Oplog 後,最新的 Oplog Entry 的時間戳。appliedOpTime
-
:Secondary 上 Apply 完成并在 Disk 上持久化的 Oplog Entry 最新的時間戳, Oplog 也是作為 WiredTiger 引擎的一個 Table 來實作的,但 WT 引擎的 WAL sync 政策預設是 100ms 一次,是以這個時間戳通常滞後于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 的實作相比于 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 有效性保證的機制之後,我們還需要回答下面兩個關鍵問題,整個細節才算完整。
- Secondary 的複制進度,以及進一步由複制進度計算出的 mcp 是由 oplog 中的 ts 字段來辨別的,而資料的版本号是由 commit_ts 來辨別的,他們之間有什麼關系,為什麼是可比的?
- 前面提到了引擎的 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 是可以不斷推進的:
-
基于副本集高可用的心跳機制:
i. 預設情況下,每個副本集節點都會每 2 秒向其他成員發送心跳(
replSetHeartBeat
指令)
ii. 其他成員傳回的資訊中會包含
元資訊,Secondary 節點會根據其中的$replData
直接推進自己的 mcplastOpCommitted
$replData: { term: 147, lastOpCommitted: { ts: Timestamp(1598455722, 1), t: 147 } ...
-
基于副本集的增量同步機制:
i. 基于心跳機制的 mcp 推進方式,顯然實時性是不夠的,Primary 計算出新的 mcp 後,最多要等 2 秒,下遊才能更新自己的 mcp
ii. 是以,MongoDB 在 oplog 增量同步的過程中,upstream 同樣會在向 downstream 傳回的 oplog batch 中夾帶
元資訊,下遊節點收到這個資訊後同樣會根據其中的$replData
iii. 由于 Secondary 節點的 oplog fetcher 線程是持續不斷的從上遊拉取 oplog,隻要有新的寫入,導緻 Primary mcp 推進,那麼下遊就會立刻拉取新的 oplog,可以保證在 ms 級别同步推進自己的 mcplastOpCommitted
另外一點需要說明的是,心跳回複中實際上也包含了目标節點的
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:
-
:internalQueryExecYieldIterations
調用累積次數超過該配置值會主動 yield,預設為 128,本質上反映的是從索引或者表上擷取了多少條資料後主動 yield。yield 之後該累積次數清零。shouldYieldOrInterrupt()
-
:從上次 yield 到現在的時間間隔超過該配置值,主動 yield,預設為 10ms,本質上反映的是目前線程擷取資料的行為持續了多久需要 yield。internalQueryExecYieldPeriodMS
最後,除了根據上述配置主動的 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 下的讀寫
規則- 對于讀:
- 對任意事務 $T_i$ ,如果它讀到了資料 $X$ 的版本 $X_j$,而 $X_j$ 是由事務 $T_j$ 修改産生,則 $T_j$ 一定已經送出,且 $T_j$ 的送出時間戳一定小于事務 $T_i$ 的快照讀時間戳,即隻有這樣, $T_j$ 的修改對 $T_i$ 才是可見的。這個規則保證了事務隻能讀取到自己可見範圍内的資料。
- 另外,對任意事務 $T_k$,如果它修改了 $X$ 并且産生了新的版本 $X_k$,且 $T_k$ 已送出,那麼 $T_k$ 要麼在事務 $T_j$ 之前送出($commit(T_k) < commit(T_j)$),要麼在事務 $T_i$ 的快照讀時間戳之後送出。這個規則保證了事務在可見範圍内讀取最新的資料。
- 對于寫:
- 對于任意事務 $T_i$ 和 $T_j$,他們都成功送出的前提是沒有産生沖突。
- 沖突的定義:如果 $T_j$ 的送出時間戳在事務 $T_i$ 的觀測時間段([$snapshot(T_i)$, $commit(T_i)$])内,且二者的修改資料集存在交集,則二者存在沖突。這種情況下 $T_i$ 需要 abort。
- 對這個規則可以有一個通俗的了解,即事務的并發控制存在一個基本原則:「過去不能修改将來」,$snapshot(T_i) < commit(T_j)$ 表明 $T_i$ 相對于 $T_j$ 發生在過去(此時 $T_i$ 看不到 $T_j$ 産生的修改), $T_i$ 如果正常送出,因為 $commit(T_i) > commit(T_j)$,也就意味着發生在過去的 $T_i$ 的寫會覆寫将來的 $T_j$。
然後再回到前面的問題:為什麼在 "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。
關于 MongoDB 基于 Raft 協定修改的延伸閱讀: 4 modifications for Raft consensus
重選舉時的 Catchup Phase
MongoDB 從 3.4 版本開始實作了上述機制(
catchup phase
),流程如下,
- 候選節點在成功收到多數派節點的投票後,會通過心跳(
指令)向其他節點廣播自己當選的消息;replSetHeartBeat
- 其他節點的的 heartbeat response 中會包含自己最新的 applied opTime,當選節點會把其中最大的 opTIme 作為自己 catchup 的
;targetOpTime
- 從 applied opTime 最大的節點或其下遊節點同步資料,這個過程和正常的基于 oplog 的增量複制沒有太大差別;
- 如果在逾時時間(由
決定,3.4 預設 60 秒)内追上了settings.catchUpTimeoutMillis
,catchup 完成;targetOpTime
- 如果逾時,當選節點并不會 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」仍然複雜且代價高:
- 「refetch」階段需要和同步源通信,并進行資料拉取,如果復原的是删表操作,代價很大
- 「reapply」階段也需要和同步源通信,如果「refetch」階段比較慢,需要拉取和重新應用的 oplog 也比較多
- 實作上複雜,每種可能出現在 oplog 中的操作都需要有對應的復原邏輯,新增類型時同樣需要考慮,代碼維護代價高
是以在 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” 讀。說是“幾乎”,因為這個政策仍然需要解決如下兩個在副本集這個分布式環境下存在的問題,
- Primary 角色可能會發生變化,“linearizable” readConcern 需要保證每次讀取總是能夠從目前的 Primary 讀取,而不是被取代的舊主。
- 需要保證讀取到讀操作開始前最新的寫,而且讀到的結果不會在重新選主後發生復原。
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 中幾個基本的概念,
-
:節點本地的實體時鐘,通常是基于 ntp 進行時鐘同步,HLC 隻會讀取該值,不會對該值做修改。pt
-
:HLC 的高位部分,是 HLC 的實體部分,和l
關聯,HLC 保證pt
l
間的內插補點不會無限增長(bounded)。pt
-
:HLC 的低位部分,是 HLC 的邏輯部分。c
從上面的 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 的一緻性模型設計。
參考文檔
- Tunable Consistency In MongoDB: http://www.vldb.org/pvldb/vol12/p2071-schultz.pdf
- Implementation of Cluster-wide Logical Clock and Causal Consistency in MongoDB: https://dl.acm.org/doi/pdf/10.1145/3299869.3314049
- Logical Physical Clocks: https://cse.buffalo.edu/~demirbas/publications/hlc.pdf
- PACELC: http://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdf
- Consistency and Replication 1: https://web2.qatar.cmu.edu/~msakr/15440-f11/lectures/Lecture11_15440_VKO_10Oct_2011.pptx
- Consistency and Replication 2:
- MongoDB writeConcern: https://docs.mongodb.com/manual/reference/write-concern/
- MongoDB readConcern: https://docs.mongodb.com/manual/reference/read-concern/
- WiredTiger Application-specified Transaction Timestamps: https://source.wiredtiger.com/develop/transactions.html#transaction_timestamps
- Database Replication Using Generalized Snapshot Isolation: https://infoscience.epfl.ch/record/53561/files/srds2005-gsi.pdf
- MongoDB Logical Session: https://www.mongodb.com/blog/post/transactions-background-part-2-logical-sessions-in-mongodb
- 4 modifications for Raft consensus: https://www.openlife.cc/blogs/2015/september/4-modifications-raft-consensus
- Time, Clocks, and the Ordering of Events in a Distributed System: https://lamport.azurewebsites.net/pubs/time-clocks.pdf
- MongoDB Sharding Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/s/README.md
- MongoDB Replication Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/repl/README.md