基於hashicorp/raft的分散式一致性實戰教學
本文由雲+社群發表
作者:Super
導語:hashicorp/raft是raft演算法的一種比較流行的golang實現,基於它能夠比較方便的構建具有強一致性的分散式系統。本文通過實現一個簡單的分散式快取系統來介紹使用hashicorp/raft來構建分散式應用程式的方法。
1. 背景
對於後臺開發來說,隨著業務的發展,由於訪問量增大的壓力和資料容災的需要,一定會需要使用分散式的系統,而分散式勢必會引入一致性的問題。
一般把一致性分為三種型別:弱一致性、最終一致性、強一致性。這三種模型的一致性強度逐漸遞增,實現代價也越來越大。通常弱一致性和最終一致性可以非同步冗餘,強一致性則是同步冗餘,而同步也就意味著影響效能。
對常見的網際網路業務來說,使用弱一致性或者最終一致性即可。而使用強一致性一方面會影響系統的效能,另一方面實現也比較困難。常見的一致性協議如zab、raft、paxos,如果由業務純自己來實現的話代價較大,而且很可能會因為考慮不周而引入其他問題。
對於一些需要強一致性,而又希望花費較小代價的業務來說,使用開源的一致性協議實現元件會是個不錯的選擇。hashicorp/raft是raft協議的一種golang實現,由hashicorp公司實現並開源,已經在consul等軟體中使用。它封裝了raft協議的leader選舉、log同步等底層實現,基於它能夠相對比較容易的構建強一致性的分散式系統,下面以實現一個簡單的分散式快取服務(取名叫stcache)來演示hashicorp/raft的具體使用,完整程式碼可以在github上下載。
2. raft簡介
首先還是簡單介紹下raft協議。這裡不詳細介紹raft協議,只是為了方便理解後面的hashicorp/raft的使用步驟而簡單列舉出raft的一點原理。具體的raft協議可以參考raft的官網,如果已經瞭解raft協議可以直接跳過這一節。
raft是一種相對易於理解的一致性的協議。它屬於leader-follower型的協議,有且只有一個leader,所有的事務請求都由leader處理,leader徵求follower的意見,在叢集內部達成一致,決定是否執行事務。當leader出現故障,叢集中的follower會通過投票的方式選出一個新的leader,維持叢集執行。
raft的理論基礎是Replicated State Machine,Replicated State Machine需要滿足如下的條件:一個server可以有多個state,多個server從同一個start狀態出發,都執行相同的command序列,最終到達的stare是一樣的。如上圖,一般使用replicated log來記錄command序列,client的請求被leader轉化成log entry,然後通過一致性模組把log同步到各個server,讓各個server的log一致。每個server都有state Machine,從start出發,執行完這些log中的command後,server處於相同的state。所以raft協議的關鍵就是保證各個server的log一致,然後每個server通過執行相同的log來達到一致的狀態,理解這點有助於掌握後面對hashicorp/raft的具體使用。
3. hashicorp/raft使用
3.1 單機版
首先我們建立一個單機版本的stcache,它是一個簡單的快取伺服器,在服務內部用一個map來儲存資料,只提供簡單的get和set操作。
type cacheManager struct {
data map[string]string
sync.RWMutex
}
然後stcache開啟一個http服務,提供兩個api,第一個是set介面,用於設定資料到快取,成功時返回ok,失敗返回錯誤資訊:
第二個是get介面,根據key查詢具體的value:
下面我們在單機版stcache的基礎上逐步擴充,讓它成為一個具有強一致性的分散式系統。
3.2 建立節點
// NewRaft is used to construct a new Raft node. It takes a configuration, as well
// as implementations of various interfaces that are required. If we have any
// old state, such as snapshots, logs, peers, etc, all those will be restored
// when creating the Raft node.
func NewRaft(conf *Config,
fsm FSM,
logs LogStore,
stable StableStore,
snaps SnapshotStore,
trans Transport) (*Raft, error) {
hashicorp/raft庫提供NewRaft方法來建立一個raft節點,這也是使用這個庫的最重要的一個api。NewRaft需要呼叫層提供6個引數,分別是:
- Config: 節點配置
- FSM: finite state machine,有限狀態機
- LogStore: 用來儲存raft的日誌
- StableStore: 穩定儲存,用來儲存raft叢集的節點資訊等
- SnapshotStore: 快照儲存,用來儲存節點的快照資訊
- Transport: raft節點內部的通訊通道
下面從這些引數入手看應用程式需要做哪些工作。
3.3 Config
config是節點的配置資訊,我們直接使用raft預設的配置,然後用監聽的地址來作為節點的id。config裡面還有一些可配置的項,後面我們用到的時候再說。
raftConfig := raft.DefaultConfig()
raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)
3.4 LogStore 和 StableStore
LogStore、StableStore分別用來儲存raft log、節點狀態資訊,hashicorp提供了一個raft-boltdb來實現底層儲存,它是一個嵌入式的資料庫,能夠持久化儲存資料,我們直接用它來實現LogStore和StableStore.
logStore, err := raftboltdb.NewBoltStore(filepath.Join(opts.dataDir,
"raft-log.bolt"))
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(opts.dataDir,
"raft-stable.bolt"))
3.5 SnapshotStore
SnapshotStore用來儲存快照資訊,對於stcache來說,就是儲存當前的所有的kv資料,hashicorp內部提供3中快照儲存方式,分別是:
- DiscardSnapshotStore: 不儲存,忽略快照,相當於/dev/null,一般用於測試
- FileSnapshotStore: 檔案持久化儲存
- InmemSnapshotStore: 記憶體儲存,不持久化,重啟程式會丟失
這裡我們使用檔案持久化儲存。snapshotStore只是提供了一個快照儲存的介質,還需要應用程式提供快照生成的方式,後面我們再具體說。
snapshotStore, err := raft.NewFileSnapshotStore(opts.dataDir, 1, os.Stderr)
3.6 Transport
Transport是raft叢集內部節點之間的通訊渠道,節點之間需要通過這個通道來進行日誌同步、leader選舉等。hashicorp/raft內部提供了兩種方式來實現,一種是通過TCPTransport,基於tcp,可以跨機器跨網路通訊;另一種是InmemTransport,不走網路,在記憶體裡面通過channel來通訊。顯然一般情況下都使用TCPTransport即可,在stcache裡也採用tcp的方式。
func newRaftTransport(opts *options) (*raft.NetworkTransport, error) {
address, err := net.ResolveTCPAddr("tcp", opts.raftTCPAddress)
if err != nil {
return nil, err
}
transport, err := raft.NewTCPTransport(address.String(), address, 3, 10*time.Second, os.Stderr)
if err != nil {
return nil, err
}
return transport, nil
}
3.7 FSM
最後再看FSM,它是一個interface,需要應用程式來實現3個funcition。
/*FSM provides an interface that can be implemented by
clients to make use of the replicated log.*/
type FSM interface {
/* Apply log is invoked once a log entry is committed.
It returns a value which will be made available in the
ApplyFuture returned by Raft.Apply method if that
method was called on the same Raft node as the FSM.*/
Apply(*Log) interface{}
// Snapshot is used to support log compaction. This call should
// return an FSMSnapshot which can be used to save a point-in-time
// snapshot of the FSM. Apply and Snapshot are not called in multiple
// threads, but Apply will be called concurrently with Persist. This means
// the FSM should be implemented in a fashion that allows for concurrent
// updates while a snapshot is happening.
Snapshot() (FSMSnapshot, error)
// Restore is used to restore an FSM from a snapshot. It is not called
// concurrently with any other command. The FSM must discard all previous
// state.
Restore(io.ReadCloser) error
}
第一個是Apply,當raft內部commit了一個log entry後,會記錄在上面說過的logStore裡面,被commit的log entry需要被執行,就stcache來說,執行log entry就是把資料寫入快取,即執行set操作。我們改造doSet方法, 這裡不再直接寫快取,而是呼叫raft的Apply方式,為這次set操作生成一個log entry,這裡面會根據raft的內部協議,在各個節點之間進行通訊協作,確保最後這條log 會在整個叢集的節點裡面提交或者失敗。
// doSet saves data to cache, only raft master node provides this api
func (h *httpServer) doSet(w http.ResponseWriter, r *http.Request) {
// ... get params from request url
event := logEntryData{Key: key, Value: value}
eventBytes, err := json.Marshal(event)
if err != nil {
h.log.Printf("json.Marshal failed, err:%v", err)
fmt.Fprint(w, "internal error\n")
return
}
applyFuture := h.ctx.st.raft.raft.Apply(eventBytes, 5*time.Second)
if err := applyFuture.Error(); err != nil {
h.log.Printf("raft.Apply failed:%v", err)
fmt.Fprint(w, "internal error\n")
return
}
fmt.Fprintf(w, "ok\n")
}
對follower節點來說,leader會通知它來commit log entry,被commit的log entry需要呼叫應用層提供的Apply方法來執行日誌,這裡就是從logEntry拿到具體的資料,然後寫入快取裡面即可。
// Apply applies a Raft log entry to the key-value store.
func (f *FSM) Apply(logEntry *raft.Log) interface{} {
e := logEntryData{}
if err := json.Unmarshal(logEntry.Data, &e); err != nil {
panic("Failed unmarshaling Raft log entry.")
}
ret := f.ctx.st.cm.Set(e.Key, e.Value)
return ret
}
3.7.1 snapshot
FSM需要提供的另外兩個方法是Snapshot()和Restore(),分別用於生成一個快照結構和根據快照恢復資料。首先我們需要定義快照,hashicorp/raft內部定義了快照的interface,需要實現兩個func,Persist用來生成快照資料,一般只需要實現它即可;Release則是快照處理完成後的回撥,不需要的話可以實現為空函式。
// FSMSnapshot is returned by an FSM in response to a Snapshot
// It must be safe to invoke FSMSnapshot methods with concurrent
// calls to Apply.
type FSMSnapshot interface {
// Persist should dump all necessary state to the WriteCloser 'sink',
// and call sink.Close() when finished or call sink.Cancel() on error.
Persist(sink SnapshotSink) error
// Release is invoked when we are finished with the snapshot.
Release()
}
我們定義一個簡單的snapshot結構,在Persist裡面,自己把快取裡面的資料用json格式化的方式來生成快照,sink.Write就是把快照寫入snapStore,我們剛才定義的是FileSnapshotStore,所以會把資料寫入檔案。
type snapshot struct {
cm *cacheManager
}
// Persist saves the FSM snapshot out to the given sink.
func (s *snapshot) Persist(sink raft.SnapshotSink) error {
snapshotBytes, err := s.cm.Marshal()
if err != nil {
sink.Cancel()
return err
}
if _, err := sink.Write(snapshotBytes); err != nil {
sink.Cancel()
return err
}
if err := sink.Close(); err != nil {
sink.Cancel()
return err
}
return nil
}
func (f *snapshot) Release() {}
3.7.2 snapshot儲存與恢復
而快照生成和儲存的觸發條件除了應用程式主動觸發外,還可以在Config裡面設定SnapshotInterval和SnapshotThreshold,前者指每間隔多久生成一次快照,後者指每commit多少log entry後生成一次快照。需要兩個條件同時滿足才會生成和儲存一次快照,預設config裡面配置的條件比較高,我們可以自己修改配置,比如在stcache裡面配置SnapshotInterval為20s,SnapshotThreshold為2,表示當滿足距離上次快照儲存超過20s,且log增加2條的時候,儲存一個新的快照。
raftConfig := raft.DefaultConfig()
raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)
raftConfig.SnapshotInterval = 20 * time.Second
raftConfig.SnapshotThreshold = 2
服務重啟的時候,會先讀取本地的快照來恢復資料,在FSM裡面定義的Restore函式會被呼叫,這裡我們就簡單的對資料解析json反序列化然後寫入記憶體即可。至此,我們已經能夠正常的儲存快照,也能在重啟的時候從檔案恢復快照資料。
// Restore stores the key-value store to a previous state.
func (f *FSM) Restore(serialized io.ReadCloser) error {
return f.ctx.st.cm.UnMarshal(serialized)
}
// UnMarshal deserializes cache data
func (c *cacheManager) UnMarshal(serialized io.ReadCloser) error {
var newData map[string]string
if err := json.NewDecoder(serialized).Decode(&newData); err != nil {
return err
}
c.Lock()
defer c.Unlock()
c.data = newData
return nil
}
3.8 叢集建立
叢集最開始的時候只有一個節點,我們讓第一個節點通過bootstrap的方式啟動,它啟動後成為leader。
if opts.bootstrap {
configuration := raft.Configuration{
Servers: []raft.Server{
{
ID: raftConfig.LocalID,
Address: transport.LocalAddr(),
},
},
}
raftNode.BootstrapCluster(configuration)
}
後續的節點啟動的時候需要加入叢集,啟動的時候指定第一個節點的地址,併傳送請求加入叢集,這裡我們定義成直接通過http請求。
// joinRaftCluster joins a node to raft cluster
func joinRaftCluster(opts *options) error {
url := fmt.Sprintf("http://%s/join?peerAddress=%s",
opts.joinAddress,
opts.raftTCPAddress)
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if string(body) != "ok" {
return errors.New(fmt.Sprintf("Error joining cluster: %s", body))
}
return nil
}
先啟動的節點收到請求後,獲取對方的地址(指raft叢集內部通訊的tcp地址),然後呼叫AddVoter把這個節點加入到叢集即可。申請加入的節點會進入follower狀態,這以後叢集節點之間就可以正常通訊,leader也會把資料同步給follower。
// doJoin handles joining cluster request
func (h *httpServer) doJoin(w http.ResponseWriter, r *http.Request) {
vars := r.URL.Query()
peerAddress := vars.Get("peerAddress")
if peerAddress == "" {
h.log.Println("invalid PeerAddress")
fmt.Fprint(w, "invalid peerAddress\n")
return
}
addPeerFuture := h.ctx.st.raft.raft.AddVoter(raft.ServerID(peerAddress),
raft.ServerAddress(peerAddress),
0, 0)
if err := addPeerFuture.Error(); err != nil {
h.log.Printf("Error joining peer to raft, peeraddress:%s, err:%v, code:%d", peerAddress, err, http.StatusInternalServerError)
fmt.Fprint(w, "internal error\n")
return
}
fmt.Fprint(w, "ok")
}
3.9 故障切換
當叢集的leader故障後,叢集的其他節點能夠感知到,並申請成為leader,在各個follower中進行投票,最後選取出一個新的leader。leader選舉是屬於raft協議的內容,不需要應用程式操心,但是對有些場景而言,應用程式需要感知leader狀態,比如對stcache而言,理論上只有leader才能處理set請求來寫資料,follower應該只能處理get請求查詢資料。為了模擬說明這個情況,我們在stcache裡面我們設定一個寫標誌位,當本節點是leader的時候標識位置true,可以處理set請求,否則標識位為false,不能處理set請求。
// doSet saves data to cache, only raft master node provides this api
func (h *httpServer) doSet(w http.ResponseWriter, r *http.Request) {
if !h.checkWritePermission() {
fmt.Fprint(w, "write method not allowed\n")
return
}
// ... set data
}
當故障切換的時候,follower變成了leader,應用程式如何感知到呢? 在raft結構裡面提供有一個eaderCh,它是bool型別的channel,不帶快取,當本節點的leader狀態有變化的時候,會往這個channel裡面寫資料,但是由於不帶緩衝且寫資料的協程不會阻塞在這裡,有可能會寫入失敗,沒有及時變更狀態,所以使用leaderCh的可靠性不能保證。好在raft Config裡面提供了另一個channel NotifyCh,它是帶快取的,當leader狀態變化時會往這個chan寫資料,寫入的變更訊息能夠快取在channel裡面,應用程式能夠通過它獲取到最新的狀態變化。
我們首先在初始化config時候建立一個帶快取的chan,把它賦值給config裡面的NotifyCh,然後在節點啟動後監聽這個chan,當本節點的leader狀態變化時(變成leader或者從leader變成follower),就能夠從這個chan裡面讀取到bool值,並調整我們先前設定的寫標誌位,控制是否能否處理set操作。
func newRaftNode(opts *options, ctx *stCachedContext) (*raftNodeInfo, error) {
raftConfig := raft.DefaultConfig()
raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)
raftConfig.SnapshotInterval = 20 * time.Second
raftConfig.SnapshotThreshold = 2
leaderNotifyCh := make(chan bool, 1)
raftConfig.NotifyCh = leaderNotifyCh
// ...
}
4. 成果演示
做完上面的工作後,我們來測試下效果,我們同一臺機器上啟動3個節點來構成一個叢集,第一個節點用bootstrapt的方式啟動,成為leader
第二個節點和第三個節點啟動時指定加入叢集,成為follower
現在叢集中有3個節點,leader監聽127.0.01:6000對外提供set和get介面,兩個follower分別監聽127.0.0.1:6001和127.0.0.1:6002,對外提供get介面。
4.1 叢集資料同步
通過呼叫leader的set介面寫入一個資料,key是ping,value是pong
這時候能在兩個follower上看見apply的日誌,follower節點寫入了log,並收到leader的通知提交資料。
通過查詢介面,也能從follower裡面查詢到剛才寫入的資料,證明資料同步沒有問題。
有一點需要說明的事,我們這裡從follower是可能讀不到最新資料的。由於leader對set操作返回的時候,follower可能還沒有apply資料,所以從follower的get查詢可能返回舊資料或者空資料。如果要保證能從follower查詢到的一定是最新的資料還需要很多額外的工作,即做到linearizable read,有興趣可以看這篇測試文章,這裡不再展開。
4.2 快照儲存與恢復
我們再通過set介面寫入兩個資料,能看見節點開始儲存快照
在指定的目錄下面,能看見快照的具體資訊,有兩個檔案,meta.json儲存了版本號、log序號、叢集節點地址等叢集資訊;state.bin裡面是快照資料,這裡就是我們剛剛寫入的資料被json序列化後的字串。
現在把節點都停止,然後重新啟動leader,記憶體的資料都丟失,它會從儲存的快照檔案裡面恢復資料。重啟follower也一樣會從自己儲存的快照裡面載入資料。
4.3 leader切換
把leader和follower都重啟恢復,現在leader監聽127.0.01:6000,只有它能執行set操作,follower只能執行get操作
我們停掉leader節點,兩個follower會開始選舉,這裡node2贏得了選舉,進入leader狀態,並且它開始開啟set操作
我們再請求node2監聽的127.0.0.1:6001,發現已經可以正常寫入資料了,leader切換順利完成。
我們再重啟原來的leader節點,它會變成follower,並從新的leader(也就是node2)這裡同步它所缺失的資料。
5. 總結
上面所建立的stcache只是一個簡單的示例程式,真正要做到線上上使用還有很多問題需要考慮,目前基於hashicorp/raft比較成熟的開源軟體有consul,如果有興趣可以通過它做進一步研究。
總的來說,hashicorp/raft封裝了raft的內部協議,提供簡潔明瞭的使用方法,基於它能夠很快速地構建出具有強一致性的應用程式。
此文已由騰訊雲+社群在各渠道釋出
獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號
相關文章
- 基於Raft的分散式MySQL Binlog儲存系統開源Raft分散式MySql
- 理解分散式一致性與Raft演算法分散式Raft演算法
- 淺談分散式一致性演算法raft分散式演算法Raft
- 分散式理論(六) - 一致性協議Raft分散式協議Raft
- 搞懂分散式技術2:分散式一致性協議與Paxos,Raft演算法分散式協議Raft演算法
- 實戰與原理:如何基於RocketMQ實現分散式事務?MQ分散式
- 基於 Zookeeper 的分散式鎖實現分散式
- 分散式一致性協議Raft全面詳解(建議收藏)分散式協議Raft
- 分散式強一致性資料庫的靈魂 – Raft 演算法分散式資料庫Raft演算法
- 分散式強一致性資料庫的靈魂 - Raft 演算法分散式資料庫Raft演算法
- 基於合作教學的幾種教學方法
- 分散式系統理論基礎6:Raft、Zab分散式Raft
- 基於redis實現分散式鎖Redis分散式
- 基於ZK實現分散式鎖分散式
- 基於 Jepsen 來發現幾個 Raft 實現中的一致性問題(2)Raft
- kafka實戰教學Kafka
- 從分散式一致性到共識機制(二)Raft演算法分散式Raft演算法
- 分散式系統的Raft演算法分散式Raft演算法
- 基於RocketMQ實現分散式事務MQ分散式
- 基於kubernetes的分散式限流分散式
- 基於redis的分散式鎖Redis分散式
- 基於 Redis 的分散式鎖Redis分散式
- tikv/raft-rs:在 Rust 中實現的 Raft 分散式共識演算法原始碼RaftRust分散式演算法原始碼
- jmeter分散式實戰JMeter分散式
- 基於pytorch的深度學習實戰PyTorch深度學習
- 基於TensorFlow的深度學習實戰深度學習
- 【分散式架構】(10)---基於Redis元件的特性,實現一個分散式限流分散式架構Redis元件
- MySQL 中基於 XA 實現的分散式事務MySql分散式
- 基於快取或zookeeper的分散式鎖實現快取分散式
- 基於redis和zookeeper的分散式鎖實現方式Redis分散式
- 基於Redis實現一個分散式鎖Redis分散式
- 基於zookeeper實現分散式配置中心(二)分散式
- 基於redis分散式鎖實現“秒殺”Redis分散式
- 區塊鏈共識演算法(1)分散式一致性演算法Raft區塊鏈演算法分散式Raft
- 基於java的分散式爬蟲Java分散式爬蟲
- 基於 dubbo 的分散式架構分散式架構
- 基於 Redis 分散式鎖Redis分散式
- 分散式鎖與實現(一)基於Redis實現!分散式Redis