MongoDB復制集原理


版權聲明:本文由孔德雨原創文章,轉載請注明出處: 
文章原文鏈接:https://www.qcloud.com/community/article/136

來源:騰雲閣 https://www.qcloud.com/community

 

MongoDB的單實例模式下,一個mongod進程為一個實例,一個實例中包含若干db,每個db包含若干張表。
MongoDB通過一張特殊的表local.oplog.rs存儲oplog,該表的特點是:固定大小,滿了會刪除最舊記錄插入新記錄,而且只支持append操作,因此可以理解為一個持久化的ring-buffer。oplog是MongoDB復制集的核心功能點。
MongoDB復制集是指MongoDB實例通過復制並應用其他實例的oplog達到數據冗余的技術。

常用的復制集構成一般有下圖兩種方式 (注意,可以使用mongoshell 手工指定復制源,但mongdb不保證這個指定是持久的,下文會講到在某些情況下,MongoDB會自動進行復制源切換)。

MongoDB的復制集技術並不少見,很類似mysql的異步復制模式,這種模式主要有幾個技術點:

  1. 新節點加入,正常同步前的初始化

  2. Primary節點掛掉后,剩余的Secondary節點如何提供服務

  3. 如何保證主節點掛掉后數據不丟失/主節點掛掉后丟失數據的處理

MongoDB作為一個成熟的數據庫產品,較好的解決了上述問題,一個完整的復制集包含如下幾點功能:

  1. 數據同步
    1.1 initial-sync
    1.2steady-sync
    1.3異常數據回滾

  2. MongoDB集群心跳與選舉

一.數據同步

initial_sync

當一個節點剛加入集群時,它需要初始化數據使得 自身與集群中其它節點的數據量差距盡量少,這個過程稱為initial-sync。
一個initial-sync 包括六步(閱讀rs_initialSync.cpp:_initialSync函數的邏輯)

  1. 刪除本地除local庫以外的所有db
  2. 選取一個源節點,將源節點中的所有db導入到本地(注意,此處只導入數據,不導入索引)
  3. 將2)開始執行到執行結束中源產生的oplog 應用到本地
  4. 將3)開始執行到執行結束中源產生的oplog 應用到本地
  5. 從源將所有table的索引在本地重建(導入索引)
  6. 將5)開始執行到執行結束中源產生的oplog 應用到本地
    當第6)步結束后,源和本地的差距足夠小,MongoDB進入Secondary(從節點)狀態。

第2)步要拷貝所有數據,因此一般第2)步消耗時間最長,第3)與第4)步是一個連續逼近的過程,MongoDB這里做了兩次
是因為第2)步一般耗時太長,導致第3)步數據量變多,間接受到影響。然而這么做並不是必須的,rs_initialSync.cpp:384 開始的TODO建議使用SyncTail的方式將數據一次性讀回來(SyncTail以及TailableCursor的行為與原理如果不熟悉請看官方文檔

steady-sync

當節點初始化完成后,會進入steady-sync狀態,顧名思義,正常情況下,這是一個穩定靜默運行於后台的,從復制源不斷同步新oplog的過程。該過程一般會出現這兩種問題:

  1. 復制源寫入過快(或者相對的,本地寫入速度過慢),復制源的oplog覆蓋了 本地用於同步源oplog而維持在源的游標。
  2. 本節點在宕機之前是Primary,在重啟后本地oplog有和當前Primary不一致的Oplog。
    這兩種情況分別如下圖所示:

這兩種情況在bgsync.cpp:_produce函數中,雖然這兩種情況很不一樣,但是最終都會進入 bgsync.cpp:_rollback函數處理,
對於第二種情況,處理過程在rs_rollback.cpp中,具體步驟為:

  1. 維持本地與遠程的兩個反向游標,以線性的時間復雜度找到LCA(最近公共祖先,上conflict.png中為Record4)
    該過程與經典的兩個有序鏈表找公共節點的過程類似,具體實現在roll_back_local_operations.cpp:syncRollBackLocalOperations中,讀者可以自行思考這一過程如何以線性時間復雜度實現。

  2. 針對本地每個沖突的oplog,枚舉該oplog的類型,推斷出回滾該oplog需要的逆操作並記錄,如下:
    2.1: create_table -> drop_table
    2.2: drop_table -> 重新同步該表
    2.3: drop_index -> 重新同步並構建索引
    2.4: drop_db -> 放棄rollback,改由用戶手工init_resync
    2.5: apply_ops -> 針對apply_ops 中的每一條子oplog,遞歸執行 2)這一過程
    2.6: create_index -> drop_index
    2.7: 普通文檔的CUD操作 -> 從Primary重新讀取真實值並替換。相關函數為:rs_rollback.cpp:refetch

  3. 針對2)中分析出的每條oplog的處理方式,執行處理,相關函數為 rs_rollback.cpp:syncFixUp,此處操作主要是對步驟2)的實踐,實際處理過程相當繁瑣。

  4. truncate掉本地沖突的oplog。
    上面我們說到,對於本地失速(stale)的情況,也是走_rollback 流程統一處理的,對於失速,走_rollback時會在找LCA這步失敗,之后會嘗試更換復制源,方法為:從當前存活的所有secondary和primary節點中找一個使自己“不處於失速”的節點。
    這里有必要解釋一下,oplog是一個有限大小的ring-buffer, 失速的唯一判斷條件為:本地維護在復制源的游標被復制源的寫覆蓋(想象一下你和同學同時開始繞着操場跑步,當你被同學超過一圈時,你和同學相遇了)。因此如果某些節點的oplog設置的比較大,繞完一圈的時間就更長,利用這樣的節點作為復制源,失速的可能性會更小。
    對MongoDB的集群數據同步的描述暫告段落。我們利用一張流程圖來做總結:

steady-sync的線程模型與Oplog指令亂序加速

與steady-sync相關的代碼有 bgsync.cpp, sync_tail.cpp。上面我們介紹過,steady-sync過程從復制源讀取新產生的oplog,並應用到本地,這樣的過程脫不離是一個producer-consumer模型。由於oplog需要保證順序性,producer只能單線程實現。
對於consumer端,是否有並發提速機制呢?

  1. 首先,不相干的文檔之間無需保證oplog apply的順序,因此可以對oplog 按照objid 哈希分組。每一組內必須保證嚴格的寫入順序性。

    572 void fillWriterVectors(OperationContext* txn, 573 MultiApplier::Operations* ops, 574 std::vector<MultiApplier::OperationPtrs>* writerVectors) { 581 for (auto&& op : *ops) { 582 StringMapTraits::HashedKey hashedNs(op.ns); 583 uint32_t hash = hashedNs.hash(); 584 585 // For doc locking engines, include the _id of the document in the hash so we get 586 // parallelism even if all writes are to a single collection. We can't do this for capped 587 // collections because the order of inserts is a guaranteed property, unlike for normal 588 // collections. 589 if (supportsDocLocking && op.isCrudOpType() && !isCapped(txn, hashedNs)) { 590 BSONElement id = op.getIdElement(); 591 const size_t idHash = BSONElement::Hasher()(id); 592 MurmurHash3_x86_32(&idHash, sizeof(idHash), hash, &hash); 593 } 601 auto& writer = (*writerVectors)[hash % numWriters]; 602 if (writer.empty()) 603 writer.reserve(8); // skip a few growth rounds. 604 writer.push_back(&op); 605 } 606 } 
  2. 其次對於command命令,會對表或者庫有全局性的影響,因此command命令必須在當前的consumer完成工作之后單獨處理,而且在處理command oplog時,不能有其他命令同時執行。這里可以類比SMP體系結構下的
    cpu-memory-barrior

    899 // Check for ops that must be processed one at a time. 900 if (entry.raw.isEmpty() || // sentinel that network queue is drained. 901 (entry.opType[0] == 'c') || // commands. 902 // Index builds are achieved through the use of an insert op, not a command op. 903 // The following line is the same as what the insert code uses to detect an index build. 904 (!entry.ns.empty() && nsToCollectionSubstring(entry.ns) == "system.indexes")) { 905 if (ops->getCount() == 1) { 906 // apply commands one-at-a-time 907 _networkQueue->consume(txn); 908 } else { 909 // This op must be processed alone, but we already had ops in the queue so we can't 910 // include it in this batch. Since we didn't call consume(), we'll see this again next 911 // time and process it alone. 912 ops->pop_back(); 913 } 
  3. 從庫和主庫的oplog 順序必須完全一致,因此不管1、2步寫入用戶數據的順序如何,oplog的必須保證順序性。對於mmap引擎的capped-collection,只能以順序插入來保證,因此對oplog的插入是單線程進行的。對於wiredtiger引擎的capped-collection,可以在ts(時間戳字段)上加上索引,從而保證讀取的順序與插入的順序無關。

    517 // Only doc-locking engines support parallel writes to the oplog because they are required to 518 // ensure that oplog entries are ordered correctly, even if inserted out-of-order. Additionally, 519 // there would be no way to take advantage of multiple threads if a storage engine doesn't 520 // support document locking. 521 if (!enoughToMultiThread || 522 !txn->getServiceContext()->getGlobalStorageEngine()->supportsDocLocking()) { 523 524 threadPool->schedule(makeOplogWriterForRange(0, ops.size())); 525 return false; 526 } 

steady-sync 的類依賴與線程模型總結如下圖:

二.MongoDB心跳與選舉機制

MongoDB的主節點選舉由心跳觸發。一個復制集N個節點中的任意兩個節點維持心跳,每個節點維護其他N-1個節點的狀態(該狀態僅是該節點的POV,比如因為網絡分區,在同一時刻A觀察C處於down狀態,B觀察C處於seconary狀態)

以任意一個節點的POV,在每一次心跳后會企圖將主節點降級(step down primary)(topology_coordinator_impl.cpp:_updatePrimaryFromHBData),主節點降級的理由如下:

  1. 心跳檢測到有其他primary節點的優先級高於當前主節點,則嘗試將主節點降級(stepDown) 為
    Secondary, primary值的動態變更提供給了運維一個可以熱變更主節點的方式
  2. 本節點若是主節點,但是無法ping通集群中超過半數的節點(majority原則),則將自身降級為Secondary

選舉主節點

Secondary節點檢測到當前集群沒有存活的主節點,則嘗試將自身選舉為Primary。主節點選舉是一個二階段過程+多數派協議。

第一階段

以自身POV,檢測自身是否有被選舉的資格:

  1. 能ping通集群的過半數節點
  2. priority必須大於0
  3. 不能是arbitor節點
    如果檢測通過,向集群中所有存活節點發送FreshnessCheck(詢問其他節點關於“我”是否有被選舉的資格)

同僚仲裁

選舉第一階段中,某節點收到其他節點的選舉請求后,會執行更嚴格的同僚仲裁

  1. 集群中有其他節點的primary比發起者高
  2. 不能是arbitor節點
  3. primary必須大於0
  4. 以沖裁者的POV,發起者的oplog 必須是集群存活節點中oplog最新的(可以有相等的情況,大家都是最新的)

第二階段

發起者向集群中存活節點發送Elect請求,仲裁者收到請求的節點會執行一系列合法性檢查,如果檢查通過,則仲裁者給發起者投一票,並獲得30秒鍾“選舉鎖”,選舉鎖的作用是:在持有鎖的時間內不得給其他發起者投票。
發起者如果或者超過半數的投票,則選舉通過,自身成為Primary節點。獲得低於半數選票的原因,除了常見的網絡問題外,相同優先級的節點同時通過第一階段的同僚仲裁並進入第二階段也是一個原因。因此,當選票不足時,會sleep[0,1]秒內的隨機時間,之后再次嘗試選舉。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM