elasticsearch入門系列">elasticsearch入門系列
975
2025-04-09
概論
分布式共識算法(consensus algorithm)通常的做法就是在多個節點上復制狀態機。分布在不同服務器上的狀態機執行著相同的狀態變化,即使其中幾臺機器掛掉,整個集群還能繼續運作。
復制狀態機正確運行的核心的同步日志,日志是保證各節點狀態同步的關鍵,日志中保存了一系列狀態機命令,共識算法的核心是保證這些不同節點上的日志以相同的順序保存相同的命令,由于狀態機是確定的,所以相同的命令以相同的順序執行,會得到相同的結果。
raft協議保證系統在任何時刻都保持一下特性:
1. 選舉安全:每次給定的Term,整個集群只能選上一個leader。
2. leader只追加日志: leader永遠不會改寫或者刪除日志中的條目,它只會追加日志。
3. 日志匹配: 如果兩個節點的日志包含了相同的index和term的條目,則這兩個節點的日志中,該條目及以后的條目都一樣。
4. leader日志完整性: 在一個term中如果1條日志已經commit,那么后續的term中選舉出來的leader一定存有這條日志。
5. 狀態機安全性:如果一個server已經apply了一條日志條目到狀態機中,則其他的server不會apply一調相同index但是不同的日志。
其中1、4、5中我們在心跳和選舉一章已經有所闡述,我們將在這一章中詳細闡述ETCD是如何保證所有這5條特性成立的。
日志的基本形式和存儲方式
日志的是以條目(Entry)的方式順序組織在一起的,日志中包含index、term、type和data等字段。index隨日志條目的遞增而遞增,term是生成該條目的leader當時處于的term。type是ETCD定義的字段,目前有兩個類型,一個是EntryNormal正常的日志,EntryConfChange是etcd本身配置變化的日志。data是日志的內容。
內存中的日志操作,主要是由一個raftLog類型的對象完成的,以下是raftLog的源碼。可以看到,里面有兩個存儲位置,一個是storage是保存已經持久化過的日志條目。unstable是保存的尚未持久化的日志條目。
持久化日志: WAL和snapshot。下圖顯示持久化的Storage接口定義和storage結構中字段的定義。它實際上就是包含一個WAL來保存日志條目,一個Snapshotter負責保存日志快照的。
WAL是一種追加的方式將日志條目一條一條順序存放在文件中。存放在WAL的記錄都是walpb.Record形式的結構。Type代表數據的類型,Crc是生成的Crc校驗字段。Data是真正的數據。v3版本中,有下圖顯示的幾種Type:
- metadataType:元數據類型,元數據會保存當前的node id和cluster id。
- entryType:日志條目
- stateType:存放的是集群當前的狀態HardState,如果集群的狀態有變化,就會在WAL中存放一個新集群狀態數據。里面包括當前Term,當前競選者、當前已經commit的日志。
- crcType:存放crc校驗字段。讀取數據是,會根據這個記錄里的crc字段對前面已經讀出來的數據進行校驗。
- snapshotType:存放snapshot的日志點。包括日志的Index和Term。
WAL有read模式和write模式,區別是write模式會使用文件鎖開啟獨占文件模式。read模式不會獨占文件。
Snapshotter 提供保存快照的SaveSnap方法。在v2中,快照實際就是storage中存的那個node組成的樹結構。它是將整個樹給序列化成了json。在v3中,快照是boltdb數據庫的數據文件,通常就是一個叫db的文件。v3的處理實際代碼比較混亂,并沒有真正走snapshotter。
etcd日志的保存總體流程如下:
1. 集群某個節點收到client的put請求要求修改數據。節點會生成一個Type為MsgProp的Message,發送給leader。
2. leader收到Message以后,會處理Message中的日志條目,將其append到raftLog的unstable的日志中,并且調用bcastAppend()廣播append日志的消息。
3. leader中有協程處理unstable日志和剛剛準備發送的消息,newReady方法會把這些都封裝到Ready結構中。
4. leader的另一個協程處理這個Ready,先發送消息,然后調用WAL將日志持久化到本地磁盤。
5. follower收到append日志的消息,會調用它自己的raftLog,將消息中的日志append到本地緩存中。隨后follower也像leader一樣,有協程將緩存中的日志條目持久化到磁盤中并將當前已經持久化的最新日志index返回給leader。
6. 所有節點,包括follower 和leader都會將已經認定為commit的日志apply到kv存儲中。對于v2就是更新store中的樹節點。對于v3就是調用boltdb的接口更新數據。
7. 日志條目到一定數目以后,會觸發snapshot,leader會持久化保存第6步所說的kv存儲的數據。然后刪除內存中過期的日志條目。
8. WAL中保存的持久化的日志條目會有一個定時任務定時刪除。
以下將以v3代碼為例,詳細分析以上過程
日志的生成
v3操作etcd一般是直接使用etcd提供的client庫,因為v3的client和server也采用grpc通信,直接用httpclient會非常復雜。Client結構中包含了一個叫KV的接口,里面定義了Put、Get、Delete等方法。Put方法的實現實際就是向其中一個server發送一條grpc請求,請求體正是PutRequest結構的對象。
服務端收到gprc請求以后,會調用EtcdServer的Put()、Range()、DeleteRange()、Txn()等方法,這些方法最終都會調用到processInternalRaftRequestOnce(),這個方法的處理是先用request的id注冊一個channel,調用raftNode的Propose()方法,將request對象序列化成byte數組,作為參數傳入Propose()方法,最后等待剛剛注冊的channel上的數據,node會在請求已經apply到狀態機以后,也就是請求處理結束以后,往這個channel推送一個ApplyResult對象,觸發等待它的請求處理協程繼續往下走,返回請求結果。
3、raftNode的Propose方法實現在node結構上。它會生成一條MsgProp消息,消息的Data字段是已經序列化的request。也就是說v3中,日志條目的內容就是request。最后調用step()方法,是把消息推到propc channel中。
4、propc channel由node啟動時運行的一個協程處理,調用raft的Step()方法,如果當前節點是follower,實際就是調用stepFollower()。而stepFollower對MsgProp消息的處理就是:直接轉發給leader。
5、實例之間消息發送的過程在本系列文章的第二篇《心跳與選舉》中已經介紹,不在贅述。消息到leader以后。下圖是leader的處理,leader在接收到MsgProp消息以后,會調用appendEntries()將日志append到raftLog中。這時候日志已經保存到了leader的緩存中。
leader同步日志
從上圖可以看到,leader在append日志以后會調用bcastAppend()廣播日志給所有其他節點。raft結構中有一個Progress數組,這個數組是leader用來保存各個follower當前的同步狀態的,由于不同實例運行的硬件環境、網絡等條件不同,各follower同步日志的快慢不一樣,因此leader會在本地記錄每個follower當前同步到哪了,才能在每次同步日志的時候知道需要發送那些日志過去。Progress中有一個Match字段,代表其中一個follower當前已經同步過的最新的index。而Next字段是需要leader發送給它的下一條日志的index。
sendAppend先根據Progress中的Next字段獲取前一條日志的term,這個是為了給follower校驗用的,待會我們會講到。然后獲取本地的日志條目到ents。獲取的時候是從Next字段開始往后取,直到達到單條消息承載的最大日志條數(如果沒有達到最大日志條數,就取到最新的日志結束,細節可以看raftLog的entries方法)。
2如果獲取日志有問題,說明Next字段標示的日志可能已經過期,需要同步snapshot,這個就是上圖的if語句里面的內容。這部分我們等snapshot的時候再細講。
3正常獲取到日志以后,就把日志塞到Message的Entries字段中,Message的Type為MsgApp,表示這是一條同步日志的消息。Index設置為Next-1,和LogTerm一樣,都是為了給follower校驗用的,下面會詳細講述。設置commit為raftLog的commited字段,這個是給follower設置它的本地緩存里面的commited用的。最后的那個”switch pr.State”是一個優化措施,它在send之前就將pr的Next值設置為準備發送的日志的最大index+1。意思是我還沒有發出去,就認為它發完了,后面比如leader接收到heartbeat response以后也可以直接發送entries。
4、follower接收到MsgApp以后會調用handleAppendEntries()方法處理。處理邏輯是:如果index小于已經確認為commited的index,說明這些日志已經過期了,則直接回復commited的index。否則,調用maybeAppend()把日志append到raftLog里面。maybeAppend的處理比較重要。首先它通過判斷消息中的Index和LogTerm來判斷發來的這批日志的前一條日志和本地存的是不是一樣,如果不一樣,說明leader和follower的日志在Index這個地方就沒有對上號了,直接返回不能append。如果是一樣的,再進去判斷發來的日志里面有沒有和本地有沖突(有可能有些日志前面已經發過來同步過,所以會出現leader發來的日志已經在follower這里存了)。如果有沖突,就從第一個沖突的地方開始覆蓋本地的日志。
5、follower調用完maybeAppend以后會調用send發送MsgAppResp,把當前已經append的日志最新index告訴給leader。如果是maybeAppend返回了false說明不能append,會回復Reject消息給leader。消息和日志最后都是在raftNode.start()啟動的協程里面處理的。它會先持久化日志,然后發送消息。
6、leader收到follower回復的MsgAppResp以后,首先判斷如果follower reject了日志,就把Progress的Next減回到Match+1,從已經確定同步的日志開始從新發送日志。如果沒有reject日志,就用剛剛已經發送的日志index更新Progess的Match和Next,下一次發送日志就可以從新的Next開始了。然后調用maybeCommit把多數節點同步的日志設置為commited。
7、commited會隨著MsgHeartbeat或者MsgApp同步給follower。隨后leader和follower都會將commited的日志apply到狀態機中,也就是會更新kv存儲。
持久化
日志的持久化是調用WAL的Save完成的,同時如果有raft狀態變更也會寫到WAL中(作為stateType)。日志會順序地寫入文件。同時使用MustSync判斷是不是要調用操作系統的系統調用fsync,fsync是一次真正的io調用。從MustSync函數可以看到,只要有log條目,或者raft狀態有變更,都會調用fsync持久化。最后我們看到如果寫得太多超過了一個段大小的話(一個段是64MB,就是wal一個文件的大小)。會調用cut()拆分文件。
應用平臺ROMA PaaS
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。